diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 000000000..88f822952 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,71 @@ +name: 🐛 Bug Report +description: Report a bug or issue with the RunAnywhere SDKs +title: "[Bug]: " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug report! + + - type: dropdown + id: component + attributes: + label: Component + description: Which component is affected? + options: + - Android SDK + - iOS SDK + - Android Sample + - iOS Sample + multiple: false + validations: + required: true + + - type: textarea + id: description + attributes: + label: Bug Description + description: A clear description of what the bug is + placeholder: Describe the issue you're experiencing + validations: + required: true + + - type: textarea + id: steps + attributes: + label: Steps to Reproduce + description: Steps to reproduce the behavior + placeholder: | + 1. Initialize SDK with... + 2. Call method... + 3. See error + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected Behavior + description: What you expected to happen + validations: + required: true + + - type: textarea + id: environment + attributes: + label: Environment + description: Please provide environment details + placeholder: | + - OS: [e.g., Android 13, iOS 17] + - SDK Version: [e.g., 1.0.0] + - Device: [e.g., Pixel 7, iPhone 15] + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Logs/Screenshots + description: Add any relevant logs or screenshots + placeholder: Paste logs or drag images here diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 000000000..dadd914e7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,57 @@ +name: ✨ Feature Request +description: Suggest a new feature or enhancement +title: "[Feature]: " +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Thanks for suggesting a new feature! + + - type: dropdown + id: component + attributes: + label: Component + description: Which component would this feature affect? + options: + - Android SDK + - iOS SDK + - Android Sample + - iOS Sample + - Documentation + - Other + multiple: true + validations: + required: true + + - type: textarea + id: problem + attributes: + label: Problem Description + description: What problem does this feature solve? + placeholder: I'm frustrated when... + validations: + required: true + + - type: textarea + id: solution + attributes: + label: Proposed Solution + description: What would you like to see implemented? + placeholder: I would like to be able to... + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives Considered + description: Any alternative solutions you've considered? + placeholder: I also considered... + + - type: textarea + id: context + attributes: + label: Additional Context + description: Any other context, mockups, or examples + placeholder: Add any other context or screenshots about the feature request here diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..427ab2973 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,57 @@ +## Description +Brief description of the changes made. + +## Type of Change +- [ ] Bug fix +- [ ] New feature +- [ ] Documentation update +- [ ] Refactoring + +## Testing +- [ ] Lint passes locally +- [ ] Added/updated tests for changes + +### Platform-Specific Testing (check all that apply) +**Swift SDK / iOS Sample:** +- [ ] Tested on iPhone (Simulator or Device) +- [ ] Tested on iPad / Tablet +- [ ] Tested on Mac (macOS target) + +**Kotlin SDK / Android Sample:** +- [ ] Tested on Android Phone (Emulator or Device) +- [ ] Tested on Android Tablet + +**Flutter SDK / Flutter Sample:** +- [ ] Tested on iOS +- [ ] Tested on Android + +**React Native SDK / React Native Sample:** +- [ ] Tested on iOS +- [ ] Tested on Android + +## Labels +Please add the appropriate label(s): + +**SDKs:** +- [ ] `Swift SDK` - Changes to Swift SDK (`sdk/runanywhere-swift`) +- [ ] `Kotlin SDK` - Changes to Kotlin SDK (`sdk/runanywhere-kotlin`) +- [ ] `Flutter SDK` - Changes to Flutter SDK (`sdk/runanywhere-flutter`) +- [ ] `React Native SDK` - Changes to React Native SDK (`sdk/runanywhere-react-native`) +- [ ] `Commons` - Changes to shared native code (`sdk/runanywhere-commons`) + +**Sample Apps:** +- [ ] `iOS Sample` - Changes to iOS example app (`examples/ios`) +- [ ] `Android Sample` - Changes to Android example app (`examples/android`) +- [ ] `Flutter Sample` - Changes to Flutter example app (`examples/flutter`) +- [ ] `React Native Sample` - Changes to React Native example app (`examples/react-native`) + +## Checklist +- [ ] Code follows project style guidelines +- [ ] Self-review completed +- [ ] Documentation updated (if needed) + +## Screenshots +Attach relevant UI screenshots for changes (if applicable): +- Mobile (Phone) +- Tablet / iPad +- Desktop / Mac diff --git a/.github/workflows/backends-release.yml b/.github/workflows/backends-release.yml new file mode 100644 index 000000000..5be5f8b99 --- /dev/null +++ b/.github/workflows/backends-release.yml @@ -0,0 +1,449 @@ +name: Build and Release Backends + +# ============================================================================= +# Backend Libraries Release Workflow +# +# Builds LlamaCPP and ONNX backend libraries for iOS and Android. +# These are the ML inference backends that power the SDKs: +# - RABackendLLAMACPP: LLM text generation (GGUF models via llama.cpp) +# - RABackendONNX: STT/TTS/VAD (via Sherpa-ONNX) +# +# Triggers: +# - Tag push: backends-v* +# - Manual workflow_dispatch +# - Push to main (only builds with path changes, no release) +# +# Artifacts: +# iOS: RABackendLLAMACPP.xcframework, RABackendONNX.xcframework +# Android: librac_backend_llamacpp.so, librac_backend_onnx.so per ABI +# ============================================================================= + +on: + push: + branches: [ main ] + paths: + - 'sdk/runanywhere-commons/src/backends/**' + - 'sdk/runanywhere-commons/CMakeLists.txt' + - 'sdk/runanywhere-commons/VERSIONS' + - '.github/workflows/backends-release.yml' + tags: + - 'backends-v*' + pull_request: + branches: [ main ] + paths: + - 'sdk/runanywhere-commons/src/backends/**' + - 'sdk/runanywhere-commons/CMakeLists.txt' + workflow_call: + inputs: + version: + description: 'Version to release' + required: true + type: string + backends: + description: 'Backends to build' + required: false + default: 'all' + type: string + dry_run: + description: 'Dry run' + required: false + default: false + type: boolean + skip_publish: + description: 'Skip creating individual release (for unified release)' + required: false + default: false + type: boolean + workflow_dispatch: + inputs: + version: + description: 'Version to release (e.g., 0.2.0)' + required: true + type: string + backends: + description: 'Backends to build (llamacpp, onnx, all)' + required: false + default: 'all' + type: choice + options: + - all + - llamacpp + - onnx + dry_run: + description: 'Dry run (build only, do not publish)' + required: false + default: false + type: boolean + +permissions: + contents: read + +env: + COMMONS_DIR: sdk/runanywhere-commons + +jobs: + # =========================================================================== + # Build iOS Backend XCFrameworks + # =========================================================================== + build-ios-backends: + name: Build iOS Backends + runs-on: macos-14 + outputs: + version: ${{ steps.version.outputs.version }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Determine Version + id: version + run: | + if [ -n "${{ inputs.version }}" ]; then + VERSION="${{ inputs.version }}" + elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + VERSION="${{ github.event.inputs.version }}" + elif [[ "$GITHUB_REF" == refs/tags/backends-v* ]]; then + VERSION="${GITHUB_REF#refs/tags/backends-v}" + elif [[ "$GITHUB_REF" == refs/tags/v* ]]; then + VERSION="${GITHUB_REF#refs/tags/v}" + else + VERSION="0.0.0-$(git rev-parse --short HEAD)" + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Building backends v$VERSION" + + - name: Determine Backends + id: backends + run: | + BACKENDS="${{ github.event.inputs.backends || 'all' }}" + echo "backends=$BACKENDS" >> $GITHUB_OUTPUT + + - name: Setup Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '15.4' + + - name: Install Dependencies + run: brew install cmake ninja + + - name: Download Third-Party Dependencies + working-directory: ${{ env.COMMONS_DIR }} + run: | + chmod +x scripts/ios/download-sherpa-onnx.sh + chmod +x scripts/ios/download-onnx.sh + ./scripts/ios/download-sherpa-onnx.sh + ./scripts/ios/download-onnx.sh + + - name: Build iOS Backends + working-directory: ${{ env.COMMONS_DIR }} + run: | + chmod +x scripts/build-ios.sh + BACKENDS="${{ steps.backends.outputs.backends }}" + if [ "$BACKENDS" = "all" ]; then + ./scripts/build-ios.sh --backend all --release --package + else + ./scripts/build-ios.sh --backend $BACKENDS --release --package + fi + + - name: List Artifacts + working-directory: ${{ env.COMMONS_DIR }} + run: | + echo "=== Build Artifacts ===" + ls -la dist/ + if [ -d dist/RABackendLLAMACPP.xcframework ]; then + echo "✅ RABackendLLAMACPP.xcframework" + fi + if [ -d dist/RABackendONNX.xcframework ]; then + echo "✅ RABackendONNX.xcframework" + fi + + - name: Prepare LlamaCPP Artifact + if: steps.backends.outputs.backends == 'all' || steps.backends.outputs.backends == 'llamacpp' + working-directory: ${{ env.COMMONS_DIR }} + run: | + # Create a marker file to prevent GitHub Actions from flattening the directory structure + # When uploading a single directory, actions/upload-artifact flattens it + # Adding a second file forces it to preserve directory names + touch dist/.llamacpp-ios-marker + + - name: Upload LlamaCPP Backend (iOS) + if: steps.backends.outputs.backends == 'all' || steps.backends.outputs.backends == 'llamacpp' + uses: actions/upload-artifact@v4 + with: + name: backend-llamacpp-ios-${{ steps.version.outputs.version }} + path: | + ${{ env.COMMONS_DIR }}/dist/RABackendLLAMACPP.xcframework + ${{ env.COMMONS_DIR }}/dist/.llamacpp-ios-marker + retention-days: 30 + + - name: Upload ONNX Backend (iOS) + if: steps.backends.outputs.backends == 'all' || steps.backends.outputs.backends == 'onnx' + uses: actions/upload-artifact@v4 + with: + name: backend-onnx-ios-${{ steps.version.outputs.version }} + path: | + ${{ env.COMMONS_DIR }}/dist/RABackendONNX.xcframework + ${{ env.COMMONS_DIR }}/third_party/onnxruntime-ios/onnxruntime.xcframework + ${{ env.COMMONS_DIR }}/third_party/sherpa-onnx-ios/sherpa-onnx.xcframework + retention-days: 30 + + # =========================================================================== + # Build Android Backend Libraries (Matrix) + # =========================================================================== + build-android-backends: + name: Build Android Backends (${{ matrix.abi }}) + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + strategy: + fail-fast: false + matrix: + abi: [arm64-v8a, armeabi-v7a, x86_64] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Determine Version + id: version + run: | + if [ -n "${{ inputs.version }}" ]; then + VERSION="${{ inputs.version }}" + elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + VERSION="${{ github.event.inputs.version }}" + elif [[ "$GITHUB_REF" == refs/tags/backends-v* ]]; then + VERSION="${GITHUB_REF#refs/tags/backends-v}" + elif [[ "$GITHUB_REF" == refs/tags/v* ]]; then + VERSION="${GITHUB_REF#refs/tags/v}" + else + VERSION="0.0.0-$(git rev-parse --short HEAD)" + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Determine Backends + id: backends + run: | + BACKENDS="${{ github.event.inputs.backends || 'all' }}" + echo "backends=$BACKENDS" >> $GITHUB_OUTPUT + + - name: Setup JDK + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + + - name: Setup NDK + run: | + NDK_VERSION="27.0.12077973" + echo "y" | sdkmanager --install "ndk;${NDK_VERSION}" --sdk_root=${ANDROID_SDK_ROOT} + echo "ANDROID_NDK_HOME=${ANDROID_SDK_ROOT}/ndk/${NDK_VERSION}" >> $GITHUB_ENV + + - name: Download Third-Party Dependencies + working-directory: ${{ env.COMMONS_DIR }} + run: | + chmod +x scripts/android/download-sherpa-onnx.sh + ./scripts/android/download-sherpa-onnx.sh + + - name: Build Android Backends + working-directory: ${{ env.COMMONS_DIR }} + run: | + chmod +x scripts/build-android.sh + BACKENDS="${{ steps.backends.outputs.backends }}" + if [ "$BACKENDS" = "llamacpp" ]; then + ./scripts/build-android.sh llamacpp ${{ matrix.abi }} + elif [ "$BACKENDS" = "onnx" ]; then + ./scripts/build-android.sh onnx ${{ matrix.abi }} + else + ./scripts/build-android.sh all ${{ matrix.abi }} + fi + + - name: Verify 16KB Alignment + working-directory: ${{ env.COMMONS_DIR }} + run: | + ./scripts/build-android.sh --check || echo "⚠️ Some libraries not 16KB aligned" + + - name: Upload Android Backends + uses: actions/upload-artifact@v4 + with: + name: backends-android-${{ matrix.abi }}-${{ steps.version.outputs.version }} + path: | + ${{ env.COMMONS_DIR }}/dist/android/llamacpp/${{ matrix.abi }}/ + ${{ env.COMMONS_DIR }}/dist/android/onnx/${{ matrix.abi }}/ + ${{ env.COMMONS_DIR }}/dist/android/include/ + retention-days: 30 + + # =========================================================================== + # Publish Release (only on tag or manual dispatch) + # =========================================================================== + publish-backends: + name: Publish Backend Release + needs: [build-ios-backends, build-android-backends] + # Skip if dry_run or skip_publish (for unified release) + if: | + inputs.dry_run != true && + inputs.skip_publish != true && + ( + startsWith(github.ref, 'refs/tags/backends-v') || + startsWith(github.ref, 'refs/tags/v') || + github.event_name == 'workflow_dispatch' || + github.event_name == 'workflow_call' + ) + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Download iOS LlamaCPP + uses: actions/download-artifact@v4 + with: + name: backend-llamacpp-ios-${{ needs.build-ios-backends.outputs.version }} + path: dist/ios/RABackendLLAMACPP.xcframework + continue-on-error: true + + - name: Download iOS ONNX + uses: actions/download-artifact@v4 + with: + name: backend-onnx-ios-${{ needs.build-ios-backends.outputs.version }} + path: dist/ios/onnx + continue-on-error: true + + - name: Download Android Backends + uses: actions/download-artifact@v4 + with: + pattern: backends-android-* + path: dist/android-artifacts + merge-multiple: false + + - name: Prepare Release Assets + run: | + VERSION="${{ needs.build-ios-backends.outputs.version }}" + mkdir -p release-assets + + # === iOS LlamaCPP === + if [ -d dist/ios/RABackendLLAMACPP.xcframework ]; then + cd dist/ios + zip -r "../../release-assets/RABackendLLAMACPP-ios-v${VERSION}.zip" RABackendLLAMACPP.xcframework + cd ../.. + fi + + # === iOS ONNX (includes sherpa-onnx and onnxruntime) === + if [ -d dist/ios/onnx/RABackendONNX.xcframework ]; then + cd dist/ios/onnx + zip -r "../../../release-assets/RABackendONNX-ios-v${VERSION}.zip" \ + RABackendONNX.xcframework \ + onnxruntime.xcframework \ + sherpa-onnx.xcframework 2>/dev/null || \ + zip -r "../../../release-assets/RABackendONNX-ios-v${VERSION}.zip" RABackendONNX.xcframework + cd ../../.. + fi + + # === Android - Combine all ABIs === + mkdir -p android-combined/jniLibs + mkdir -p android-combined/include + + for artifact_dir in dist/android-artifacts/backends-android-*/; do + if [ -d "$artifact_dir" ]; then + # Copy llamacpp libraries + for abi_dir in "${artifact_dir}"llamacpp/*/; do + if [ -d "$abi_dir" ]; then + abi=$(basename "$abi_dir") + mkdir -p "android-combined/jniLibs/${abi}" + cp -r "${abi_dir}"*.so "android-combined/jniLibs/${abi}/" 2>/dev/null || true + fi + done + # Copy onnx libraries + for abi_dir in "${artifact_dir}"onnx/*/; do + if [ -d "$abi_dir" ]; then + abi=$(basename "$abi_dir") + mkdir -p "android-combined/jniLibs/${abi}" + cp -r "${abi_dir}"*.so "android-combined/jniLibs/${abi}/" 2>/dev/null || true + fi + done + # Copy headers (once) + if [ -d "${artifact_dir}include" ]; then + cp -r "${artifact_dir}include/"* android-combined/include/ 2>/dev/null || true + fi + fi + done + + # Create Android package + cd android-combined + echo "=== Android Backend Libraries ===" + find . -name "*.so" -type f + zip -r "../release-assets/RABackends-android-v${VERSION}.zip" . + cd .. + + # Generate checksums + cd release-assets + for f in *.zip; do + shasum -a 256 "$f" > "${f}.sha256" + done + + echo "=== Release Assets ===" + ls -la + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: backends-v${{ needs.build-ios-backends.outputs.version }} + name: Backend Libraries v${{ needs.build-ios-backends.outputs.version }} + files: | + release-assets/*.zip + release-assets/*.sha256 + body: | + ## Backend Libraries v${{ needs.build-ios-backends.outputs.version }} + + Pre-built backend libraries for RunAnywhere SDKs. + + ### iOS/macOS + + | Library | Description | + |---------|-------------| + | `RABackendLLAMACPP-ios-v${{ needs.build-ios-backends.outputs.version }}.zip` | LLM inference via llama.cpp | + | `RABackendONNX-ios-v${{ needs.build-ios-backends.outputs.version }}.zip` | STT/TTS/VAD via Sherpa-ONNX | + + ### Android + + | Library | Description | + |---------|-------------| + | `RABackends-android-v${{ needs.build-ios-backends.outputs.version }}.zip` | All backends for arm64-v8a, armeabi-v7a, x86_64 | + + **Android Contents:** + ``` + jniLibs/ + ├── arm64-v8a/ + │ ├── librac_backend_llamacpp.so + │ ├── librac_backend_onnx.so + │ └── (sherpa-onnx dependencies) + ├── armeabi-v7a/ + │ └── ... + └── x86_64/ + └── ... + include/ + └── rac/ + └── (C headers) + ``` + + ### Usage + + **iOS (Swift Package Manager):** + The RunAnywhere Swift SDK automatically downloads these from releases. + + **Android (Gradle):** + Extract to your `app/src/main/jniLibs/` directory. + + ### Verification + ```bash + shasum -a 256 -c *.sha256 + ``` + + --- + Built from runanywhere-sdks @ ${{ github.sha }} + draft: false + prerelease: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000..c6d5c8896 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,106 @@ +name: "CodeQL" + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + schedule: + - cron: '0 6 * * 1' # Run weekly on Mondays + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} + + permissions: + security-events: write + packages: read + actions: read + contents: read + + strategy: + fail-fast: false + matrix: + include: + - language: c-cpp + build-mode: autobuild + - language: javascript-typescript + build-mode: none + - language: ruby + build-mode: none + # Swift requires manual build due to XCFramework dependencies + - language: swift + build-mode: manual + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + + # Manual build step for Swift + - name: Build Swift Package + if: matrix.language == 'swift' && matrix.build-mode == 'manual' + working-directory: sdk/runanywhere-swift + run: | + echo "=== Setting up Swift build environment ===" + + # Create Binaries directory + mkdir -p Binaries + + # Download XCFrameworks from latest release + echo "Downloading XCFrameworks from GitHub releases..." + + # Get the latest release tag + LATEST_TAG=$(gh release list --limit 1 --json tagName -q '.[0].tagName' 2>/dev/null || echo "v0.16.0-test.39") + echo "Using release: $LATEST_TAG" + + # Download iOS frameworks + gh release download "$LATEST_TAG" \ + --pattern "RACommons-ios-*.zip" \ + --pattern "RABackendLLAMACPP-ios-*.zip" \ + --pattern "RABackendONNX-ios-*.zip" \ + --dir ./downloads/ 2>/dev/null || { + echo "Could not download from release, using placeholder frameworks" + # Create minimal placeholder frameworks for CodeQL analysis + mkdir -p Binaries/RACommons.xcframework/ios-arm64 + mkdir -p Binaries/RABackendLLAMACPP.xcframework/ios-arm64 + mkdir -p Binaries/RABackendONNX.xcframework/ios-arm64 + touch Binaries/RACommons.xcframework/Info.plist + touch Binaries/RABackendLLAMACPP.xcframework/Info.plist + touch Binaries/RABackendONNX.xcframework/Info.plist + } + + # Extract frameworks if downloaded + if [ -d downloads ]; then + for zip in downloads/*.zip; do + if [ -f "$zip" ]; then + echo "Extracting $zip..." + unzip -q -o "$zip" -d Binaries/ + fi + done + fi + + echo "=== Binaries directory contents ===" + ls -la Binaries/ || echo "Binaries directory empty" + + # Build the Swift package + echo "=== Building Swift package ===" + swift build -v 2>&1 || { + echo "Swift build failed, but continuing for CodeQL analysis" + # Even if build fails, CodeQL can still analyze source files + exit 0 + } + env: + GH_TOKEN: ${{ github.token }} + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/commons-release.yml b/.github/workflows/commons-release.yml new file mode 100644 index 000000000..b3ea9dcb3 --- /dev/null +++ b/.github/workflows/commons-release.yml @@ -0,0 +1,460 @@ +name: Build and Release RACommons + +# ============================================================================= +# RACommons Release Workflow +# +# Builds RACommons.xcframework - the standalone infrastructure library. +# This does NOT build backends (LlamaCPP, ONNX, WhisperCPP) - those are +# released from runanywhere-core repository. +# +# Trigger: +# - Tag push: commons-v* +# - Manual workflow_dispatch +# ============================================================================= + +on: + push: + tags: + - 'commons-v*' + workflow_call: + inputs: + version: + description: 'Version to release (e.g., 0.2.0)' + required: true + type: string + dry_run: + description: 'Dry run (build only, do not publish)' + required: false + default: false + type: boolean + skip_publish: + description: 'Skip creating individual release (for unified release)' + required: false + default: false + type: boolean + workflow_dispatch: + inputs: + version: + description: 'Version to release (e.g., 0.2.0)' + required: true + type: string + dry_run: + description: 'Dry run (build only, do not publish)' + required: false + default: false + type: boolean + +permissions: + contents: read + +env: + COMMONS_DIR: sdk/runanywhere-commons + +jobs: + # =========================================================================== + # Build iOS XCFramework + # =========================================================================== + build-ios: + name: Build iOS + runs-on: macos-14 + outputs: + version: ${{ steps.version.outputs.version }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Determine Version + id: version + run: | + # Handle workflow_call (from release-all.yml) and workflow_dispatch + if [ -n "${{ inputs.version }}" ]; then + VERSION="${{ inputs.version }}" + elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + VERSION="${{ github.event.inputs.version }}" + elif [[ "$GITHUB_REF" == refs/tags/commons-v* ]]; then + VERSION="${GITHUB_REF#refs/tags/commons-v}" + elif [[ "$GITHUB_REF" == refs/tags/v* ]]; then + VERSION="${GITHUB_REF#refs/tags/v}" + else + VERSION="0.0.0-$(git rev-parse --short HEAD)" + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Building RACommons v$VERSION" + + - name: Setup Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '15.4' + + - name: Install Dependencies + run: brew install cmake ninja + + - name: Setup Development Config + working-directory: ${{ env.COMMONS_DIR }} + env: + SUPABASE_URL: ${{ secrets.SUPABASE_URL }} + SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY }} + BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }} + run: | + CONFIG_FILE="src/infrastructure/network/development_config.cpp" + + CLEAN_URL=$(printf '%s' "$SUPABASE_URL" | tr -d '\n\r') + CLEAN_KEY=$(printf '%s' "$SUPABASE_ANON_KEY" | tr -d '\n\r') + CLEAN_TOKEN=$(printf '%s' "${BUILD_TOKEN:-bt_release_build}" | tr -d '\n\r') + + cat > "$CONFIG_FILE" << CONFIGEOF + /** + * @file development_config.cpp + * @brief Development mode configuration with credentials from CI secrets + */ + + #include + + #include "rac/core/rac_logger.h" + #include "rac/infrastructure/network/rac_dev_config.h" + + namespace { + + constexpr const char* SUPABASE_URL = "${CLEAN_URL}"; + constexpr const char* SUPABASE_ANON_KEY = "${CLEAN_KEY}"; + constexpr const char* BUILD_TOKEN = "${CLEAN_TOKEN}"; + constexpr const char* SENTRY_DSN = nullptr; + + } // anonymous namespace + + extern "C" { + + bool rac_dev_config_is_available(void) { + if (SUPABASE_URL == nullptr || SUPABASE_ANON_KEY == nullptr) return false; + if (std::strlen(SUPABASE_URL) == 0 || std::strlen(SUPABASE_ANON_KEY) == 0) return false; + if (std::strstr(SUPABASE_URL, "YOUR_") != nullptr) return false; + if (std::strstr(SUPABASE_ANON_KEY, "YOUR_") != nullptr) return false; + return true; + } + + const char* rac_dev_config_get_supabase_url(void) { + return SUPABASE_URL; + } + + const char* rac_dev_config_get_supabase_key(void) { + return SUPABASE_ANON_KEY; + } + + const char* rac_dev_config_get_build_token(void) { + return BUILD_TOKEN; + } + + const char* rac_dev_config_get_sentry_dsn(void) { + return SENTRY_DSN; + } + + bool rac_dev_config_has_supabase(void) { + return rac_dev_config_is_available(); + } + + bool rac_dev_config_has_build_token(void) { + return BUILD_TOKEN != nullptr && std::strlen(BUILD_TOKEN) > 0; + } + + } // extern "C" + CONFIGEOF + + echo "=== Generated development_config.cpp ===" + head -20 "$CONFIG_FILE" + + - name: Build RACommons XCFramework + working-directory: ${{ env.COMMONS_DIR }} + run: | + chmod +x scripts/build-ios.sh + ./scripts/build-ios.sh --skip-backends --release --package + + - name: List Artifacts + working-directory: ${{ env.COMMONS_DIR }} + run: | + echo "=== Build Artifacts ===" + ls -la dist/ + if [ -d dist/packages ]; then + echo "" + echo "=== Packages ===" + ls -la dist/packages/ + fi + + - name: Upload Artifacts + uses: actions/upload-artifact@v4 + with: + name: racommons-ios-${{ steps.version.outputs.version }} + path: | + ${{ env.COMMONS_DIR }}/dist/RACommons.xcframework + ${{ env.COMMONS_DIR }}/dist/packages/*.zip + ${{ env.COMMONS_DIR }}/dist/packages/*.sha256 + retention-days: 30 + + # =========================================================================== + # Build Android Libraries + # =========================================================================== + build-android: + name: Build Android (${{ matrix.abi }}) + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + strategy: + fail-fast: false + matrix: + abi: [arm64-v8a, armeabi-v7a, x86_64] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Determine Version + id: version + run: | + # Handle workflow_call (from release-all.yml) and workflow_dispatch + if [ -n "${{ inputs.version }}" ]; then + VERSION="${{ inputs.version }}" + elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + VERSION="${{ github.event.inputs.version }}" + elif [[ "$GITHUB_REF" == refs/tags/commons-v* ]]; then + VERSION="${GITHUB_REF#refs/tags/commons-v}" + elif [[ "$GITHUB_REF" == refs/tags/v* ]]; then + VERSION="${GITHUB_REF#refs/tags/v}" + else + VERSION="0.0.0-$(git rev-parse --short HEAD)" + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Setup JDK + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + + - name: Setup NDK + run: | + NDK_VERSION="27.0.12077973" + echo "y" | sdkmanager --install "ndk;${NDK_VERSION}" --sdk_root=${ANDROID_SDK_ROOT} + echo "ANDROID_NDK_HOME=${ANDROID_SDK_ROOT}/ndk/${NDK_VERSION}" >> $GITHUB_ENV + + - name: Setup Development Config + working-directory: ${{ env.COMMONS_DIR }} + env: + SUPABASE_URL: ${{ secrets.SUPABASE_URL }} + SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY }} + BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }} + run: | + CONFIG_FILE="src/infrastructure/network/development_config.cpp" + + CLEAN_URL=$(printf '%s' "$SUPABASE_URL" | tr -d '\n\r') + CLEAN_KEY=$(printf '%s' "$SUPABASE_ANON_KEY" | tr -d '\n\r') + CLEAN_TOKEN=$(printf '%s' "${BUILD_TOKEN:-bt_release_build}" | tr -d '\n\r') + + cat > "$CONFIG_FILE" << CONFIGEOF + /** + * @file development_config.cpp + * @brief Development mode configuration with credentials from CI secrets + */ + + #include + + #include "rac/core/rac_logger.h" + #include "rac/infrastructure/network/rac_dev_config.h" + + namespace { + + constexpr const char* SUPABASE_URL = "${CLEAN_URL}"; + constexpr const char* SUPABASE_ANON_KEY = "${CLEAN_KEY}"; + constexpr const char* BUILD_TOKEN = "${CLEAN_TOKEN}"; + constexpr const char* SENTRY_DSN = nullptr; + + } // anonymous namespace + + extern "C" { + + bool rac_dev_config_is_available(void) { + if (SUPABASE_URL == nullptr || SUPABASE_ANON_KEY == nullptr) return false; + if (std::strlen(SUPABASE_URL) == 0 || std::strlen(SUPABASE_ANON_KEY) == 0) return false; + if (std::strstr(SUPABASE_URL, "YOUR_") != nullptr) return false; + if (std::strstr(SUPABASE_ANON_KEY, "YOUR_") != nullptr) return false; + return true; + } + + const char* rac_dev_config_get_supabase_url(void) { + return SUPABASE_URL; + } + + const char* rac_dev_config_get_supabase_key(void) { + return SUPABASE_ANON_KEY; + } + + const char* rac_dev_config_get_build_token(void) { + return BUILD_TOKEN; + } + + const char* rac_dev_config_get_sentry_dsn(void) { + return SENTRY_DSN; + } + + bool rac_dev_config_has_supabase(void) { + return rac_dev_config_is_available(); + } + + bool rac_dev_config_has_build_token(void) { + return BUILD_TOKEN != nullptr && std::strlen(BUILD_TOKEN) > 0; + } + + } // extern "C" + CONFIGEOF + + echo "=== Generated development_config.cpp for Android ===" + + - name: Download Dependencies + working-directory: ${{ env.COMMONS_DIR }} + run: | + chmod +x scripts/android/download-sherpa-onnx.sh + # Force fresh download by removing any existing partial downloads + rm -rf third_party/sherpa-onnx-android + ./scripts/android/download-sherpa-onnx.sh + + # Verify required files exist + echo "=== Verifying downloaded files ===" + ls -la third_party/sherpa-onnx-android/ || echo "Directory not found!" + ls -la third_party/sherpa-onnx-android/jniLibs/ || echo "jniLibs not found!" + ls -la "third_party/sherpa-onnx-android/jniLibs/${{ matrix.abi }}/" || echo "ABI directory not found!" + + # Check for required .so files + for lib in libsherpa-onnx-jni.so libsherpa-onnx-c-api.so libonnxruntime.so; do + if [ -f "third_party/sherpa-onnx-android/jniLibs/${{ matrix.abi }}/$lib" ]; then + echo "✅ Found: $lib" + else + echo "❌ Missing: $lib" + fi + done + + - name: Build Android ${{ matrix.abi }} + working-directory: ${{ env.COMMONS_DIR }} + run: | + chmod +x scripts/build-android.sh + # Build all backends - ONNX (STT/TTS/VAD) + LlamaCPP (LLM) + ./scripts/build-android.sh all ${{ matrix.abi }} + + - name: Upload Artifact + uses: actions/upload-artifact@v4 + with: + name: racommons-android-${{ matrix.abi }}-${{ steps.version.outputs.version }} + path: | + ${{ env.COMMONS_DIR }}/dist/android/ + retention-days: 30 + + # =========================================================================== + # Publish Release + # =========================================================================== + publish: + name: Publish Release + needs: [build-ios, build-android] + # Skip if dry_run or skip_publish (for unified release) + if: | + inputs.dry_run != true && + inputs.skip_publish != true + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Download iOS Artifact + uses: actions/download-artifact@v4 + with: + name: racommons-ios-${{ needs.build-ios.outputs.version }} + path: dist/ios + + - name: Download Android Artifacts + uses: actions/download-artifact@v4 + with: + pattern: racommons-android-* + path: dist/android-artifacts + merge-multiple: false + + - name: Prepare Release Assets + run: | + VERSION="${{ needs.build-ios.outputs.version }}" + mkdir -p release-assets + + if [ -d dist/ios/RACommons.xcframework ]; then + cd dist/ios + zip -r "../../release-assets/RACommons-ios-v${VERSION}.zip" RACommons.xcframework + cd ../../release-assets + shasum -a 256 "RACommons-ios-v${VERSION}.zip" > "RACommons-ios-v${VERSION}.zip.sha256" + cd .. + fi + + mkdir -p android-combined/jniLibs + for dir in dist/android-artifacts/racommons-android-*/jniLibs/*/; do + if [ -d "$dir" ]; then + abi=$(basename "$dir") + mkdir -p "android-combined/jniLibs/${abi}" + cp -r "${dir}"* "android-combined/jniLibs/${abi}/" 2>/dev/null || true + echo "Copied libs for ${abi}: $(ls android-combined/jniLibs/${abi}/ 2>/dev/null | wc -l) files" + fi + done + + HEADER_DIR=$(find dist/android-artifacts -name "include" -type d | head -1) + if [ -n "$HEADER_DIR" ]; then + cp -r "$HEADER_DIR" android-combined/ + fi + + cd android-combined + echo "=== Android Combined Contents ===" + find . -type f | head -20 + zip -r "../release-assets/RACommons-android-v${VERSION}.zip" . + cd ../release-assets + shasum -a 256 "RACommons-android-v${VERSION}.zip" > "RACommons-android-v${VERSION}.zip.sha256" + + echo "=== Release Assets ===" + ls -la + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: commons-v${{ needs.build-ios.outputs.version }} + name: RACommons v${{ needs.build-ios.outputs.version }} + files: | + release-assets/*.zip + release-assets/*.sha256 + body: | + ## RACommons v${{ needs.build-ios.outputs.version }} + + Standalone infrastructure library for RunAnywhere SDKs. + + ### Contents + - Logging, error handling, and event tracking + - Service registry and provider infrastructure + - Model management (download strategies, storage) + - Platform backend (Apple Foundation Models, System TTS) - iOS/macOS only + + ### iOS/macOS + `RACommons-ios-v${{ needs.build-ios.outputs.version }}.zip` + - Contains `RACommons.xcframework` + + ### Android + `RACommons-android-v${{ needs.build-ios.outputs.version }}.zip` + - Contains `jniLibs/{abi}/librac_commons.so` (core library) + - Contains `jniLibs/{abi}/librunanywhere_jni.so` (JNI bridge for Kotlin SDK) + - Contains `include/` headers + + ### Note + Backend libraries (LlamaCPP, ONNX, WhisperCPP) are released separately from `runanywhere-core`. + + ### Verification + ```bash + shasum -a 256 -c *.sha256 + ``` + + --- + Built from runanywhere-sdks @ ${{ github.sha }} + draft: false + prerelease: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/flutter-sdk-release.yml b/.github/workflows/flutter-sdk-release.yml new file mode 100644 index 000000000..720323d2e --- /dev/null +++ b/.github/workflows/flutter-sdk-release.yml @@ -0,0 +1,508 @@ +name: Flutter SDK Release + +# ============================================================================= +# Flutter SDK Release Workflow +# +# Builds and publishes the RunAnywhere Flutter SDK. +# The Flutter SDK uses a multi-package architecture: +# - runanywhere: Core SDK (required) +# - runanywhere_llamacpp: LLM backend +# - runanywhere_onnx: STT/TTS/VAD backend +# +# Distribution: +# - pub.dev (future) +# - GitHub Release with native binaries +# - Direct Git dependency +# ============================================================================= + +on: + push: + tags: + - 'flutter-v*' + workflow_call: + inputs: + version: + description: 'Version to release' + required: true + type: string + publish_pubdev: + description: 'Publish to pub.dev' + required: false + default: false + type: boolean + dry_run: + description: 'Dry run' + required: false + default: false + type: boolean + skip_publish: + description: 'Skip creating individual release (for unified release)' + required: false + default: false + type: boolean + workflow_dispatch: + inputs: + version: + description: 'Version to release (e.g., 0.15.0)' + required: true + type: string + publish_pubdev: + description: 'Publish to pub.dev' + required: false + default: false + type: boolean + dry_run: + description: 'Dry run (build only)' + required: false + default: false + type: boolean + +permissions: + contents: read + +env: + SDK_DIR: sdk/runanywhere-flutter + COMMONS_DIR: sdk/runanywhere-commons + +jobs: + # =========================================================================== + # Build iOS Native Libraries + # =========================================================================== + build-ios-native: + name: Build iOS Native + runs-on: macos-14 + outputs: + version: ${{ steps.version.outputs.version }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Determine Version + id: version + run: | + if [ -n "${{ inputs.version }}" ]; then + VERSION="${{ inputs.version }}" + elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + VERSION="${{ github.event.inputs.version }}" + elif [[ "$GITHUB_REF" == refs/tags/flutter-v* ]]; then + VERSION="${GITHUB_REF#refs/tags/flutter-v}" + elif [[ "$GITHUB_REF" == refs/tags/v* ]]; then + VERSION="${GITHUB_REF#refs/tags/v}" + else + VERSION="0.0.0-$(git rev-parse --short HEAD)" + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Setup Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '15.4' + + - name: Install Dependencies + run: brew install cmake ninja + + - name: Download iOS Dependencies + working-directory: ${{ env.COMMONS_DIR }} + run: | + chmod +x scripts/ios/download-sherpa-onnx.sh + chmod +x scripts/ios/download-onnx.sh + ./scripts/ios/download-sherpa-onnx.sh + ./scripts/ios/download-onnx.sh + + - name: Build iOS Frameworks + working-directory: ${{ env.COMMONS_DIR }} + run: | + chmod +x scripts/build-ios.sh + ./scripts/build-ios.sh --backend all --release + + - name: Upload iOS Frameworks + uses: actions/upload-artifact@v4 + with: + name: flutter-ios-frameworks-${{ steps.version.outputs.version }} + path: | + ${{ env.COMMONS_DIR }}/dist/RACommons.xcframework + ${{ env.COMMONS_DIR }}/dist/RABackendLLAMACPP.xcframework + ${{ env.COMMONS_DIR }}/dist/RABackendONNX.xcframework + ${{ env.COMMONS_DIR }}/third_party/onnxruntime-ios/onnxruntime.xcframework + ${{ env.COMMONS_DIR }}/third_party/sherpa-onnx-ios/sherpa-onnx.xcframework + retention-days: 7 + + # =========================================================================== + # Build Android Native Libraries + # =========================================================================== + build-android-native: + name: Build Android Native (${{ matrix.abi }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + abi: [arm64-v8a, armeabi-v7a, x86_64] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Determine Version + id: version + run: | + if [ -n "${{ inputs.version }}" ]; then + VERSION="${{ inputs.version }}" + elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + VERSION="${{ github.event.inputs.version }}" + elif [[ "$GITHUB_REF" == refs/tags/flutter-v* ]]; then + VERSION="${GITHUB_REF#refs/tags/flutter-v}" + elif [[ "$GITHUB_REF" == refs/tags/v* ]]; then + VERSION="${GITHUB_REF#refs/tags/v}" + else + VERSION="0.0.0-$(git rev-parse --short HEAD)" + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Setup JDK + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + + - name: Setup NDK + run: | + NDK_VERSION="27.0.12077973" + echo "y" | sdkmanager --install "ndk;${NDK_VERSION}" --sdk_root=${ANDROID_SDK_ROOT} + echo "ANDROID_NDK_HOME=${ANDROID_SDK_ROOT}/ndk/${NDK_VERSION}" >> $GITHUB_ENV + + - name: Download Dependencies + working-directory: ${{ env.COMMONS_DIR }} + run: | + chmod +x scripts/android/download-sherpa-onnx.sh + ./scripts/android/download-sherpa-onnx.sh + + - name: Build Android Native + working-directory: ${{ env.COMMONS_DIR }} + run: | + chmod +x scripts/build-android.sh + ./scripts/build-android.sh all ${{ matrix.abi }} + + - name: Upload Android Native + uses: actions/upload-artifact@v4 + with: + name: flutter-android-native-${{ matrix.abi }}-${{ steps.version.outputs.version }} + path: | + ${{ env.COMMONS_DIR }}/dist/android/unified/${{ matrix.abi }}/ + ${{ env.COMMONS_DIR }}/dist/android/llamacpp/${{ matrix.abi }}/ + ${{ env.COMMONS_DIR }}/dist/android/onnx/${{ matrix.abi }}/ + retention-days: 7 + + # =========================================================================== + # Build Flutter SDK + # =========================================================================== + build-flutter-sdk: + name: Build Flutter SDK + needs: [build-ios-native, build-android-native] + runs-on: macos-14 + outputs: + version: ${{ steps.version.outputs.version }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Determine Version + id: version + run: | + if [ -n "${{ inputs.version }}" ]; then + VERSION="${{ inputs.version }}" + elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + VERSION="${{ github.event.inputs.version }}" + elif [[ "$GITHUB_REF" == refs/tags/flutter-v* ]]; then + VERSION="${GITHUB_REF#refs/tags/flutter-v}" + elif [[ "$GITHUB_REF" == refs/tags/v* ]]; then + VERSION="${GITHUB_REF#refs/tags/v}" + else + VERSION="0.0.0-$(git rev-parse --short HEAD)" + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.24.0' + channel: 'stable' + + - name: Install Melos 2.9.0 + run: | + dart pub global activate melos 2.9.0 + echo "${HOME}/.pub-cache/bin" >> $GITHUB_PATH + + - name: Download iOS Frameworks + uses: actions/download-artifact@v4 + with: + name: flutter-ios-frameworks-${{ steps.version.outputs.version }} + path: ios-frameworks + + - name: Download Android Native + uses: actions/download-artifact@v4 + with: + pattern: flutter-android-native-* + path: android-native + merge-multiple: false + + - name: Setup Native Libraries + run: | + echo "=== Downloaded iOS frameworks ===" + find ios-frameworks -type d -name "*.xcframework" || echo "No xcframeworks found" + + echo "=== Downloaded Android native ===" + find android-native -name "*.so" -type f || echo "No .so files found" + + # Setup iOS frameworks for each package + CORE_IOS="${{ env.SDK_DIR }}/packages/runanywhere/ios/Frameworks" + LLAMA_IOS="${{ env.SDK_DIR }}/packages/runanywhere_llamacpp/ios/Frameworks" + ONNX_IOS="${{ env.SDK_DIR }}/packages/runanywhere_onnx/ios/Frameworks" + + mkdir -p "$CORE_IOS" "$LLAMA_IOS" "$ONNX_IOS" + + # Copy iOS frameworks with verbose output + echo "=== Copying iOS frameworks ===" + + # RACommons to core + if [ -d "ios-frameworks/RACommons.xcframework" ]; then + cp -rv ios-frameworks/RACommons.xcframework "$CORE_IOS/" + else + # Try finding it anywhere in ios-frameworks + find ios-frameworks -name "RACommons.xcframework" -type d -exec cp -rv {} "$CORE_IOS/" \; + fi + + # LlamaCPP backend + if [ -d "ios-frameworks/RABackendLLAMACPP.xcframework" ]; then + cp -rv ios-frameworks/RABackendLLAMACPP.xcframework "$LLAMA_IOS/" + else + find ios-frameworks -name "RABackendLLAMACPP.xcframework" -type d -exec cp -rv {} "$LLAMA_IOS/" \; + fi + + # ONNX backend and dependencies + if [ -d "ios-frameworks/RABackendONNX.xcframework" ]; then + cp -rv ios-frameworks/RABackendONNX.xcframework "$ONNX_IOS/" + else + find ios-frameworks -name "RABackendONNX.xcframework" -type d -exec cp -rv {} "$ONNX_IOS/" \; + fi + + find ios-frameworks -name "onnxruntime.xcframework" -type d -exec cp -rv {} "$ONNX_IOS/" \; + find ios-frameworks -name "sherpa-onnx.xcframework" -type d -exec cp -rv {} "$ONNX_IOS/" \; + + # Setup Android jniLibs for each package + CORE_ANDROID="${{ env.SDK_DIR }}/packages/runanywhere/android/src/main/jniLibs" + LLAMA_ANDROID="${{ env.SDK_DIR }}/packages/runanywhere_llamacpp/android/src/main/jniLibs" + ONNX_ANDROID="${{ env.SDK_DIR }}/packages/runanywhere_onnx/android/src/main/jniLibs" + + mkdir -p "$CORE_ANDROID" "$LLAMA_ANDROID" "$ONNX_ANDROID" + + echo "=== Copying Android native libraries ===" + for artifact_dir in android-native/flutter-android-native-*/; do + if [ -d "$artifact_dir" ]; then + echo "Processing $artifact_dir" + + # Copy unified libraries + for abi_dir in "${artifact_dir}"unified/*/; do + if [ -d "$abi_dir" ]; then + abi=$(basename "$abi_dir") + echo "Copying unified for $abi" + mkdir -p "${CORE_ANDROID}/${abi}" + find "$abi_dir" -name "*.so" -exec cp -v {} "${CORE_ANDROID}/${abi}/" \; + fi + done + + # Copy llamacpp libraries + for abi_dir in "${artifact_dir}"llamacpp/*/; do + if [ -d "$abi_dir" ]; then + abi=$(basename "$abi_dir") + echo "Copying llamacpp for $abi" + mkdir -p "${LLAMA_ANDROID}/${abi}" + find "$abi_dir" -name "*.so" -exec cp -v {} "${LLAMA_ANDROID}/${abi}/" \; + fi + done + + # Copy onnx libraries + for abi_dir in "${artifact_dir}"onnx/*/; do + if [ -d "$abi_dir" ]; then + abi=$(basename "$abi_dir") + echo "Copying onnx for $abi" + mkdir -p "${ONNX_ANDROID}/${abi}" + find "$abi_dir" -name "*.so" -exec cp -v {} "${ONNX_ANDROID}/${abi}/" \; + fi + done + fi + done + + echo "=== Final iOS Frameworks ===" + find ${{ env.SDK_DIR }}/packages -name "*.xcframework" -type d || echo "No frameworks!" + + echo "=== Final Android jniLibs ===" + find ${{ env.SDK_DIR }}/packages -name "*.so" -type f | head -30 || echo "No .so files!" + + - name: Update Package Versions + working-directory: ${{ env.SDK_DIR }} + run: | + VERSION="${{ steps.version.outputs.version }}" + + # Update version in all pubspec.yaml files + for pubspec in packages/*/pubspec.yaml; do + sed -i '' "s/^version:.*/version: $VERSION/" "$pubspec" + done + + - name: Bootstrap Packages + working-directory: ${{ env.SDK_DIR }} + run: | + echo "=== Current directory structure ===" + ls -la packages/ + echo "=== melos.yaml ===" + cat melos.yaml + echo "=== Running melos bootstrap ===" + melos bootstrap --verbose || { + echo "=== melos bootstrap failed, showing pubspec files ===" + for f in packages/*/pubspec.yaml; do echo "--- $f ---"; cat "$f"; done + exit 1 + } + + - name: Analyze Packages + working-directory: ${{ env.SDK_DIR }} + run: melos analyze || echo "Analyze had warnings/errors" + continue-on-error: true + + - name: Run Tests + working-directory: ${{ env.SDK_DIR }} + run: melos test || echo "Tests had failures" + continue-on-error: true + + - name: Package Flutter SDK + run: | + VERSION="${{ steps.version.outputs.version }}" + mkdir -p flutter-release + + # Create release package with native libraries included + cd ${{ env.SDK_DIR }} + zip -r "../../flutter-release/runanywhere-flutter-${VERSION}.zip" \ + packages/ \ + melos.yaml \ + README.md \ + -x "*.git*" \ + -x "*build/*" \ + -x "*.dart_tool/*" + + - name: Upload Flutter SDK Package + uses: actions/upload-artifact@v4 + with: + name: flutter-sdk-package-${{ steps.version.outputs.version }} + path: flutter-release/ + retention-days: 30 + + # =========================================================================== + # Publish Release + # =========================================================================== + publish: + name: Publish Flutter SDK Release + needs: build-flutter-sdk + # Skip if dry_run or skip_publish (for unified release) + if: | + inputs.dry_run != true && + inputs.skip_publish != true && + ( + startsWith(github.ref, 'refs/tags/flutter-v') || + startsWith(github.ref, 'refs/tags/v') || + github.event_name == 'workflow_dispatch' || + github.event_name == 'workflow_call' + ) + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Download Flutter SDK Package + uses: actions/download-artifact@v4 + with: + name: flutter-sdk-package-${{ needs.build-flutter-sdk.outputs.version }} + path: release-assets + + - name: Generate Checksums + run: | + cd release-assets + for f in *.zip; do + if [ -f "$f" ]; then + shasum -a 256 "$f" > "${f}.sha256" + fi + done + ls -la + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: flutter-v${{ needs.build-flutter-sdk.outputs.version }} + name: RunAnywhere Flutter SDK v${{ needs.build-flutter-sdk.outputs.version }} + files: | + release-assets/* + body: | + ## RunAnywhere Flutter SDK v${{ needs.build-flutter-sdk.outputs.version }} + + Privacy-first, on-device AI SDK for Flutter (iOS & Android). + + ### Installation + + **Git Dependency (Recommended):** + ```yaml + dependencies: + runanywhere: + git: + url: https://github.com/RunanywhereAI/runanywhere-sdks + path: sdk/runanywhere-flutter/packages/runanywhere + ref: flutter-v${{ needs.build-flutter-sdk.outputs.version }} + + # Add backends you need: + runanywhere_llamacpp: + git: + url: https://github.com/RunanywhereAI/runanywhere-sdks + path: sdk/runanywhere-flutter/packages/runanywhere_llamacpp + ref: flutter-v${{ needs.build-flutter-sdk.outputs.version }} + + runanywhere_onnx: + git: + url: https://github.com/RunanywhereAI/runanywhere-sdks + path: sdk/runanywhere-flutter/packages/runanywhere_onnx + ref: flutter-v${{ needs.build-flutter-sdk.outputs.version }} + ``` + + **Download Package:** + Download `runanywhere-flutter-${{ needs.build-flutter-sdk.outputs.version }}.zip` which includes native binaries. + + ### Packages + + | Package | Description | + |---------|-------------| + | `runanywhere` | Core SDK (required) | + | `runanywhere_llamacpp` | LLM backend (llama.cpp) | + | `runanywhere_onnx` | STT/TTS/VAD backend (Sherpa-ONNX) | + + ### Features + - 🧠 **LLM**: On-device text generation via llama.cpp + - 🎤 **STT**: Speech-to-text via Sherpa-ONNX Whisper + - 🔊 **TTS**: Text-to-speech via Sherpa-ONNX Piper + - 🎯 **VAD**: Voice activity detection + - 🔒 **Privacy**: All processing happens on-device + + ### Requirements + - Flutter SDK >= 3.10.0 + - Dart SDK >= 3.0.0 + - iOS 13.0+ / Android API 24+ + + ### Documentation + See [README](https://github.com/RunanywhereAI/runanywhere-sdks/tree/main/sdk/runanywhere-flutter) + + --- + Built from runanywhere-sdks @ ${{ github.sha }} + draft: false + prerelease: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + diff --git a/.github/workflows/kotlin-sdk-release.yml b/.github/workflows/kotlin-sdk-release.yml new file mode 100644 index 000000000..22cdf7f52 --- /dev/null +++ b/.github/workflows/kotlin-sdk-release.yml @@ -0,0 +1,504 @@ +name: Kotlin SDK Release + +# ============================================================================= +# Kotlin SDK Release Workflow +# +# Builds and publishes the RunAnywhere Kotlin SDK (Android/JVM). +# Outputs: +# - Android AAR (Maven artifact) +# - JVM JAR (Maven artifact) +# - GitHub Release with downloadable artifacts +# +# Distribution: +# - Maven Central (future) +# - GitHub Packages +# - Direct download from releases +# ============================================================================= + +on: + push: + tags: + - 'kotlin-v*' + workflow_call: + inputs: + version: + description: 'Version to release' + required: true + type: string + publish_maven: + description: 'Publish to Maven Central' + required: false + default: false + type: boolean + dry_run: + description: 'Dry run' + required: false + default: false + type: boolean + skip_publish: + description: 'Skip creating individual release (for unified release)' + required: false + default: false + type: boolean + workflow_dispatch: + inputs: + version: + description: 'Version to release (e.g., 1.0.0)' + required: true + type: string + publish_maven: + description: 'Publish to Maven Central' + required: false + default: false + type: boolean + dry_run: + description: 'Dry run (build only)' + required: false + default: false + type: boolean + +permissions: + contents: read + packages: write + +env: + SDK_DIR: sdk/runanywhere-kotlin + COMMONS_DIR: sdk/runanywhere-commons + +jobs: + # =========================================================================== + # Build Native Libraries (Android) + # =========================================================================== + build-native: + name: Build Native Libraries (${{ matrix.abi }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + abi: [arm64-v8a, armeabi-v7a, x86_64] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup JDK + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + + - name: Setup NDK + run: | + NDK_VERSION="27.0.12077973" + echo "y" | sdkmanager --install "ndk;${NDK_VERSION}" --sdk_root=${ANDROID_SDK_ROOT} + echo "ANDROID_NDK_HOME=${ANDROID_SDK_ROOT}/ndk/${NDK_VERSION}" >> $GITHUB_ENV + + - name: Download Dependencies + working-directory: ${{ env.COMMONS_DIR }} + run: | + chmod +x scripts/android/download-sherpa-onnx.sh + ./scripts/android/download-sherpa-onnx.sh + + - name: Build Native Libraries + working-directory: ${{ env.COMMONS_DIR }} + run: | + chmod +x scripts/build-android.sh + ./scripts/build-android.sh all ${{ matrix.abi }} + + - name: Upload Native Libraries + uses: actions/upload-artifact@v4 + with: + name: native-libs-${{ matrix.abi }} + path: | + ${{ env.COMMONS_DIR }}/dist/android/jni/${{ matrix.abi }}/ + ${{ env.COMMONS_DIR }}/dist/android/unified/${{ matrix.abi }}/ + ${{ env.COMMONS_DIR }}/dist/android/llamacpp/${{ matrix.abi }}/ + ${{ env.COMMONS_DIR }}/dist/android/onnx/${{ matrix.abi }}/ + ${{ env.COMMONS_DIR }}/dist/android/include/ + retention-days: 7 + + # =========================================================================== + # Build Kotlin SDK + # =========================================================================== + build-sdk: + name: Build Kotlin SDK + needs: build-native + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Determine Version + id: version + run: | + if [ -n "${{ inputs.version }}" ]; then + VERSION="${{ inputs.version }}" + elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + VERSION="${{ github.event.inputs.version }}" + elif [[ "$GITHUB_REF" == refs/tags/kotlin-v* ]]; then + VERSION="${GITHUB_REF#refs/tags/kotlin-v}" + elif [[ "$GITHUB_REF" == refs/tags/v* ]]; then + VERSION="${GITHUB_REF#refs/tags/v}" + else + VERSION="0.0.0-$(git rev-parse --short HEAD)" + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Building Kotlin SDK v$VERSION" + + - name: Setup JDK + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Download Native Libraries + uses: actions/download-artifact@v4 + with: + pattern: native-libs-* + path: native-artifacts + merge-multiple: false + + - name: Setup Native Libraries + run: | + echo "=== Downloaded artifact structure ===" + find native-artifacts -type d | head -20 + echo "=== All .so files in artifacts ===" + find native-artifacts -name "*.so" -type f 2>/dev/null || echo "No .so files found yet" + + # Copy to STANDARD KMP location (src/androidMain/jniLibs/) + # This is the default location that Android Gradle Plugin recognizes + JNILIBS_DIR="${{ env.SDK_DIR }}/src/androidMain/jniLibs" + mkdir -p "$JNILIBS_DIR" + + for artifact_dir in native-artifacts/native-libs-*/; do + if [ -d "$artifact_dir" ]; then + echo "Processing $artifact_dir" + echo "Contents: $(ls -la "$artifact_dir" 2>/dev/null || echo 'empty')" + + # Copy JNI bridge libraries (librunanywhere_jni.so) + for abi_dir in "${artifact_dir}"jni/*/; do + if [ -d "$abi_dir" ]; then + abi=$(basename "$abi_dir") + echo "Found jni ABI: $abi at $abi_dir" + mkdir -p "$JNILIBS_DIR/${abi}" + find "$abi_dir" -name "*.so" -exec cp -v {} "$JNILIBS_DIR/${abi}/" \; + fi + done + + # Copy unified libraries + for abi_dir in "${artifact_dir}"unified/*/; do + if [ -d "$abi_dir" ]; then + abi=$(basename "$abi_dir") + echo "Found unified ABI: $abi at $abi_dir" + mkdir -p "$JNILIBS_DIR/${abi}" + find "$abi_dir" -name "*.so" -exec cp -v {} "$JNILIBS_DIR/${abi}/" \; + fi + done + + # Copy llamacpp libraries + for abi_dir in "${artifact_dir}"llamacpp/*/; do + if [ -d "$abi_dir" ]; then + abi=$(basename "$abi_dir") + echo "Found llamacpp ABI: $abi at $abi_dir" + mkdir -p "$JNILIBS_DIR/${abi}" + find "$abi_dir" -name "*.so" -exec cp -v {} "$JNILIBS_DIR/${abi}/" \; + fi + done + + # Copy onnx libraries + for abi_dir in "${artifact_dir}"onnx/*/; do + if [ -d "$abi_dir" ]; then + abi=$(basename "$abi_dir") + echo "Found onnx ABI: $abi at $abi_dir" + mkdir -p "$JNILIBS_DIR/${abi}" + find "$abi_dir" -name "*.so" -exec cp -v {} "$JNILIBS_DIR/${abi}/" \; + fi + done + fi + done + + echo "=== Final jniLibs structure ===" + find "$JNILIBS_DIR" -type f -name "*.so" || echo "No .so files in jniLibs!" + + # Verify we have libraries + SO_COUNT=$(find "$JNILIBS_DIR" -name "*.so" | wc -l) + if [ "$SO_COUNT" -eq 0 ]; then + echo "❌ ERROR: No native libraries found!" + exit 1 + fi + echo "✅ Found $SO_COUNT native libraries in $JNILIBS_DIR" + + - name: Setup Gradle Properties + working-directory: ${{ env.SDK_DIR }} + run: | + cp gradle.properties.example gradle.properties 2>/dev/null || true + # NOTE: We do NOT set testLocal=true here. Instead: + # - testLocal=false (default) uses build/jniLibs/ as source dir + # - downloadJniLibs task will skip because libs already exist (CI copied them) + echo "version=${{ steps.version.outputs.version }}" >> gradle.properties + + - name: Build SDK + working-directory: ${{ env.SDK_DIR }} + run: | + chmod +x gradlew + ./gradlew build -x test --info 2>&1 | tail -100 || { + echo "=== Build failed, showing project structure ===" + ls -la + ls -la build/ 2>/dev/null || true + exit 1 + } + + - name: Build AAR (Release) + working-directory: ${{ env.SDK_DIR }} + run: ./gradlew assembleRelease + + - name: Run Tests + working-directory: ${{ env.SDK_DIR }} + run: ./gradlew test + continue-on-error: true + + - name: Upload AAR + uses: actions/upload-artifact@v4 + with: + name: kotlin-sdk-aar-${{ steps.version.outputs.version }} + path: ${{ env.SDK_DIR }}/build/outputs/aar/*.aar + retention-days: 30 + + - name: Upload JVM JAR + uses: actions/upload-artifact@v4 + with: + name: kotlin-sdk-jvm-${{ steps.version.outputs.version }} + path: ${{ env.SDK_DIR }}/build/libs/*.jar + retention-days: 30 + + # =========================================================================== + # Publish to GitHub Packages (Maven) + # =========================================================================== + publish-maven: + name: Publish to GitHub Packages + needs: build-sdk + if: inputs.dry_run != true + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup JDK + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Download Native Libraries + uses: actions/download-artifact@v4 + with: + pattern: native-libs-* + path: native-artifacts + merge-multiple: false + + - name: Setup Native Libraries + run: | + # Use standard KMP location for jniLibs + JNILIBS_DIR="${{ env.SDK_DIR }}/src/androidMain/jniLibs" + mkdir -p "$JNILIBS_DIR" + for artifact_dir in native-artifacts/native-libs-*/; do + if [ -d "$artifact_dir" ]; then + # Copy JNI bridge libraries (librunanywhere_jni.so) + for abi_dir in "${artifact_dir}"jni/*/; do + if [ -d "$abi_dir" ]; then + abi=$(basename "$abi_dir") + mkdir -p "$JNILIBS_DIR/${abi}" + find "$abi_dir" -name "*.so" -exec cp {} "$JNILIBS_DIR/${abi}/" \; + fi + done + for abi_dir in "${artifact_dir}"unified/*/; do + if [ -d "$abi_dir" ]; then + abi=$(basename "$abi_dir") + mkdir -p "$JNILIBS_DIR/${abi}" + find "$abi_dir" -name "*.so" -exec cp {} "$JNILIBS_DIR/${abi}/" \; + fi + done + for abi_dir in "${artifact_dir}"llamacpp/*/; do + if [ -d "$abi_dir" ]; then + abi=$(basename "$abi_dir") + mkdir -p "$JNILIBS_DIR/${abi}" + find "$abi_dir" -name "*.so" -exec cp {} "$JNILIBS_DIR/${abi}/" \; + fi + done + for abi_dir in "${artifact_dir}"onnx/*/; do + if [ -d "$abi_dir" ]; then + abi=$(basename "$abi_dir") + mkdir -p "$JNILIBS_DIR/${abi}" + find "$abi_dir" -name "*.so" -exec cp {} "$JNILIBS_DIR/${abi}/" \; + fi + done + fi + done + echo "✅ Native libraries setup complete" + find "$JNILIBS_DIR" -name "*.so" | wc -l + + - name: Setup Gradle Properties + working-directory: ${{ env.SDK_DIR }} + run: | + cp gradle.properties.example gradle.properties 2>/dev/null || true + # NOTE: testLocal=false (default) - downloadJniLibs will skip since libs exist + echo "version=${{ needs.build-sdk.outputs.version }}" >> gradle.properties + + - name: Publish to GitHub Packages + working-directory: ${{ env.SDK_DIR }} + run: | + chmod +x gradlew + ./gradlew publish --no-daemon + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SDK_VERSION: ${{ needs.build-sdk.outputs.version }} + + # =========================================================================== + # Publish Release + # =========================================================================== + publish: + name: Publish Kotlin SDK Release + needs: [build-sdk, publish-maven] + # Skip if dry_run or skip_publish (for unified release) + if: | + always() && + inputs.dry_run != true && + inputs.skip_publish != true && + needs.build-sdk.result == 'success' && + ( + startsWith(github.ref, 'refs/tags/kotlin-v') || + startsWith(github.ref, 'refs/tags/v') || + github.event_name == 'workflow_dispatch' || + github.event_name == 'workflow_call' + ) + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Download AAR + uses: actions/download-artifact@v4 + with: + name: kotlin-sdk-aar-${{ needs.build-sdk.outputs.version }} + path: dist/aar + + - name: Download JVM JAR + uses: actions/download-artifact@v4 + with: + name: kotlin-sdk-jvm-${{ needs.build-sdk.outputs.version }} + path: dist/jvm + continue-on-error: true + + - name: Prepare Release Assets + run: | + VERSION="${{ needs.build-sdk.outputs.version }}" + mkdir -p release-assets + + # Rename and copy artifacts + for aar in dist/aar/*.aar; do + if [ -f "$aar" ]; then + cp "$aar" "release-assets/runanywhere-kotlin-${VERSION}.aar" + fi + done + + for jar in dist/jvm/*.jar; do + if [ -f "$jar" ]; then + cp "$jar" "release-assets/runanywhere-kotlin-jvm-${VERSION}.jar" + fi + done + + # Generate checksums + cd release-assets + for f in *; do + if [ -f "$f" ]; then + shasum -a 256 "$f" > "${f}.sha256" + fi + done + + echo "=== Release Assets ===" + ls -la + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: kotlin-v${{ needs.build-sdk.outputs.version }} + name: RunAnywhere Kotlin SDK v${{ needs.build-sdk.outputs.version }} + files: | + release-assets/* + body: | + ## RunAnywhere Kotlin SDK v${{ needs.build-sdk.outputs.version }} + + Privacy-first, on-device AI SDK for Android and JVM. + + ### Installation + + **Gradle (Kotlin DSL):** + ```kotlin + // settings.gradle.kts - Add GitHub Packages repository + dependencyResolutionManagement { + repositories { + google() + mavenCentral() + maven { + url = uri("https://maven.pkg.github.com/RunanywhereAI/runanywhere-sdks") + credentials { + username = providers.gradleProperty("gpr.user").orNull + password = providers.gradleProperty("gpr.token").orNull + } + } + } + } + + // build.gradle.kts - Add dependencies + dependencies { + implementation("ai.runanywhere:runanywhere-kotlin:${{ needs.build-sdk.outputs.version }}") + implementation("ai.runanywhere:runanywhere-llamacpp:${{ needs.build-sdk.outputs.version }}") + implementation("ai.runanywhere:runanywhere-onnx:${{ needs.build-sdk.outputs.version }}") + } + ``` + + > ⚠️ GitHub Packages requires authentication. Add to `~/.gradle/gradle.properties`: + > ``` + > gpr.user=YOUR_GITHUB_USERNAME + > gpr.token=YOUR_GITHUB_TOKEN + > ``` + + **Direct AAR:** + Download `runanywhere-kotlin-${{ needs.build-sdk.outputs.version }}.aar` and add to your `libs/` folder. + + ### Features + - 🧠 **LLM**: On-device text generation via llama.cpp + - 🎤 **STT**: Speech-to-text via Sherpa-ONNX Whisper + - 🔊 **TTS**: Text-to-speech via Sherpa-ONNX Piper + - 🎯 **VAD**: Voice activity detection + - 🔒 **Privacy**: All processing happens on-device + + ### Supported ABIs + - `arm64-v8a` (64-bit ARM, ~85% devices) + - `armeabi-v7a` (32-bit ARM, ~12% devices) + - `x86_64` (Intel emulators) + + ### Requirements + - Android API 24+ (Android 7.0+) + - Kotlin 2.0+ + - Gradle 8.0+ + + ### Documentation + See [README](https://github.com/RunanywhereAI/runanywhere-sdks/tree/main/sdk/runanywhere-kotlin) + + --- + Built from runanywhere-sdks @ ${{ github.sha }} + draft: false + prerelease: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/publish-maven-central.yml b/.github/workflows/publish-maven-central.yml new file mode 100644 index 000000000..da2ba71e3 --- /dev/null +++ b/.github/workflows/publish-maven-central.yml @@ -0,0 +1,119 @@ +name: Publish to Maven Central + +on: + workflow_dispatch: + inputs: + version: + description: 'Version to publish (e.g., 1.0.0)' + required: true + type: string + +env: + JAVA_VERSION: '17' + JAVA_DISTRIBUTION: 'temurin' + +jobs: + publish: + name: Publish to Maven Central + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + java-version: ${{ env.JAVA_VERSION }} + distribution: ${{ env.JAVA_DISTRIBUTION }} + + - name: Setup Gradle + uses: gradle/gradle-build-action@v3 + with: + gradle-version: 8.13 + + - name: Set up Android SDK + uses: android-actions/setup-android@v3 + with: + cmdline-tools-version: 11076708 + packages: platform-tools platforms;android-35 build-tools;35.0.0 ndk;27.0.12077973 + + - name: Download native libraries + working-directory: sdk/runanywhere-kotlin + run: | + chmod +x gradlew + ./gradlew downloadJniLibs -Prunanywhere.nativeLibVersion=v${{ inputs.version }} + env: + SDK_VERSION: ${{ inputs.version }} + + - name: Import GPG key + run: | + echo "${{ secrets.GPG_SIGNING_KEY }}" | gpg --batch --import + # Get the key fingerprint and set trust + KEY_FP=$(gpg --list-secret-keys --keyid-format LONG | grep -A1 "sec" | tail -1 | tr -d ' ') + echo "${KEY_FP}:6:" | gpg --import-ownertrust || true + # List imported keys for debugging + gpg --list-secret-keys --keyid-format LONG + + - name: Configure Gradle for GPG signing + run: | + mkdir -p ~/.gradle + cat >> ~/.gradle/gradle.properties << EOF + signing.gnupg.executable=gpg + signing.gnupg.useLegacyGpg=false + signing.gnupg.keyName=${{ secrets.GPG_KEY_ID }} + signing.gnupg.passphrase=${{ secrets.GPG_SIGNING_PASSWORD }} + EOF + + - name: Publish main SDK to Maven Central + working-directory: sdk/runanywhere-kotlin + run: | + ./gradlew publishAllPublicationsToMavenCentralRepository --no-daemon --info + env: + SDK_VERSION: ${{ inputs.version }} + MAVEN_CENTRAL_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + MAVEN_CENTRAL_PASSWORD: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + + - name: Publish LlamaCPP module + working-directory: sdk/runanywhere-kotlin + run: | + ./gradlew :modules:runanywhere-core-llamacpp:publishAllPublicationsToMavenCentralRepository --no-daemon --info + env: + SDK_VERSION: ${{ inputs.version }} + MAVEN_CENTRAL_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + MAVEN_CENTRAL_PASSWORD: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + + - name: Publish ONNX module + working-directory: sdk/runanywhere-kotlin + run: | + ./gradlew :modules:runanywhere-core-onnx:publishAllPublicationsToMavenCentralRepository --no-daemon --info + env: + SDK_VERSION: ${{ inputs.version }} + MAVEN_CENTRAL_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + MAVEN_CENTRAL_PASSWORD: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + + - name: Summary + run: | + echo "## Published to Maven Central" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Version: \`${{ inputs.version }}\`" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Artifacts:" >> $GITHUB_STEP_SUMMARY + echo "- \`io.github.sanchitmonga22:runanywhere-sdk:${{ inputs.version }}\`" >> $GITHUB_STEP_SUMMARY + echo "- \`io.github.sanchitmonga22:runanywhere-llamacpp:${{ inputs.version }}\`" >> $GITHUB_STEP_SUMMARY + echo "- \`io.github.sanchitmonga22:runanywhere-onnx:${{ inputs.version }}\`" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Consumer Usage:" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`kotlin" >> $GITHUB_STEP_SUMMARY + echo "// settings.gradle.kts" >> $GITHUB_STEP_SUMMARY + echo "repositories {" >> $GITHUB_STEP_SUMMARY + echo " mavenCentral()" >> $GITHUB_STEP_SUMMARY + echo "}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "// build.gradle.kts" >> $GITHUB_STEP_SUMMARY + echo "implementation(\"io.github.sanchitmonga22:runanywhere-sdk:${{ inputs.version }}\")" >> $GITHUB_STEP_SUMMARY + echo "implementation(\"io.github.sanchitmonga22:runanywhere-llamacpp:${{ inputs.version }}\") // Optional: LLM" >> $GITHUB_STEP_SUMMARY + echo "implementation(\"io.github.sanchitmonga22:runanywhere-onnx:${{ inputs.version }}\") // Optional: STT/TTS" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/react-native-sdk-release.yml b/.github/workflows/react-native-sdk-release.yml new file mode 100644 index 000000000..ba8a1a833 --- /dev/null +++ b/.github/workflows/react-native-sdk-release.yml @@ -0,0 +1,573 @@ +name: React Native SDK Release + +# ============================================================================= +# React Native SDK Release Workflow +# +# Builds and publishes the RunAnywhere React Native SDK. +# The SDK uses a monorepo structure with Yarn workspaces: +# - @runanywhere/core: Core SDK (required) +# - @runanywhere/llamacpp: LLM backend +# - @runanywhere/onnx: STT/TTS/VAD backend +# +# Distribution: +# - npm (future) +# - GitHub Release with native binaries +# - Direct Git dependency via package.json +# ============================================================================= + +on: + push: + tags: + - 'react-native-v*' + workflow_call: + inputs: + version: + description: 'Version to release' + required: true + type: string + publish_npm: + description: 'Publish to npm' + required: false + default: false + type: boolean + dry_run: + description: 'Dry run' + required: false + default: false + type: boolean + skip_publish: + description: 'Skip creating individual release (for unified release)' + required: false + default: false + type: boolean + workflow_dispatch: + inputs: + version: + description: 'Version to release (e.g., 0.15.0)' + required: true + type: string + publish_npm: + description: 'Publish to npm' + required: false + default: false + type: boolean + dry_run: + description: 'Dry run (build only)' + required: false + default: false + type: boolean + +permissions: + contents: read + +env: + SDK_DIR: sdk/runanywhere-react-native + COMMONS_DIR: sdk/runanywhere-commons + +jobs: + # =========================================================================== + # Build iOS Native Libraries + # =========================================================================== + build-ios-native: + name: Build iOS Native + runs-on: macos-14 + outputs: + version: ${{ steps.version.outputs.version }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Determine Version + id: version + run: | + if [ -n "${{ inputs.version }}" ]; then + VERSION="${{ inputs.version }}" + elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + VERSION="${{ github.event.inputs.version }}" + elif [[ "$GITHUB_REF" == refs/tags/react-native-v* ]]; then + VERSION="${GITHUB_REF#refs/tags/react-native-v}" + elif [[ "$GITHUB_REF" == refs/tags/v* ]]; then + VERSION="${GITHUB_REF#refs/tags/v}" + else + VERSION="0.0.0-$(git rev-parse --short HEAD)" + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Setup Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '15.4' + + - name: Install Dependencies + run: brew install cmake ninja + + - name: Download iOS Dependencies + working-directory: ${{ env.COMMONS_DIR }} + run: | + chmod +x scripts/ios/download-sherpa-onnx.sh + chmod +x scripts/ios/download-onnx.sh + ./scripts/ios/download-sherpa-onnx.sh + ./scripts/ios/download-onnx.sh + + - name: Build iOS Frameworks + working-directory: ${{ env.COMMONS_DIR }} + run: | + chmod +x scripts/build-ios.sh + ./scripts/build-ios.sh --backend all --release + + - name: Upload iOS Frameworks + uses: actions/upload-artifact@v4 + with: + name: rn-ios-frameworks-${{ steps.version.outputs.version }} + path: | + ${{ env.COMMONS_DIR }}/dist/RACommons.xcframework + ${{ env.COMMONS_DIR }}/dist/RABackendLLAMACPP.xcframework + ${{ env.COMMONS_DIR }}/dist/RABackendONNX.xcframework + ${{ env.COMMONS_DIR }}/third_party/onnxruntime-ios/onnxruntime.xcframework + ${{ env.COMMONS_DIR }}/third_party/sherpa-onnx-ios/sherpa-onnx.xcframework + retention-days: 7 + + # =========================================================================== + # Build Android Native Libraries + # =========================================================================== + build-android-native: + name: Build Android Native (${{ matrix.abi }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + abi: [arm64-v8a, armeabi-v7a, x86_64] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Determine Version + id: version + run: | + if [ -n "${{ inputs.version }}" ]; then + VERSION="${{ inputs.version }}" + elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + VERSION="${{ github.event.inputs.version }}" + elif [[ "$GITHUB_REF" == refs/tags/react-native-v* ]]; then + VERSION="${GITHUB_REF#refs/tags/react-native-v}" + elif [[ "$GITHUB_REF" == refs/tags/v* ]]; then + VERSION="${GITHUB_REF#refs/tags/v}" + else + VERSION="0.0.0-$(git rev-parse --short HEAD)" + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Setup JDK + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + + - name: Setup NDK + run: | + NDK_VERSION="27.0.12077973" + echo "y" | sdkmanager --install "ndk;${NDK_VERSION}" --sdk_root=${ANDROID_SDK_ROOT} + echo "ANDROID_NDK_HOME=${ANDROID_SDK_ROOT}/ndk/${NDK_VERSION}" >> $GITHUB_ENV + + - name: Download Dependencies + working-directory: ${{ env.COMMONS_DIR }} + run: | + chmod +x scripts/android/download-sherpa-onnx.sh + ./scripts/android/download-sherpa-onnx.sh + + - name: Build Android Native + working-directory: ${{ env.COMMONS_DIR }} + run: | + chmod +x scripts/build-android.sh + ./scripts/build-android.sh all ${{ matrix.abi }} + + - name: Upload Android Native + uses: actions/upload-artifact@v4 + with: + name: rn-android-native-${{ matrix.abi }}-${{ steps.version.outputs.version }} + path: | + ${{ env.COMMONS_DIR }}/dist/android/unified/${{ matrix.abi }}/ + ${{ env.COMMONS_DIR }}/dist/android/llamacpp/${{ matrix.abi }}/ + ${{ env.COMMONS_DIR }}/dist/android/onnx/${{ matrix.abi }}/ + ${{ env.COMMONS_DIR }}/dist/android/include/ + retention-days: 7 + + # =========================================================================== + # Build React Native SDK + # =========================================================================== + build-react-native-sdk: + name: Build React Native SDK + needs: [build-ios-native, build-android-native] + runs-on: macos-14 + outputs: + version: ${{ steps.version.outputs.version }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Determine Version + id: version + run: | + if [ -n "${{ inputs.version }}" ]; then + VERSION="${{ inputs.version }}" + elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + VERSION="${{ github.event.inputs.version }}" + elif [[ "$GITHUB_REF" == refs/tags/react-native-v* ]]; then + VERSION="${GITHUB_REF#refs/tags/react-native-v}" + elif [[ "$GITHUB_REF" == refs/tags/v* ]]; then + VERSION="${GITHUB_REF#refs/tags/v}" + else + VERSION="0.0.0-$(git rev-parse --short HEAD)" + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Setup Yarn + working-directory: ${{ env.SDK_DIR }} + run: | + # Use corepack to activate the exact yarn version from package.json + corepack enable + corepack install + yarn --version + + - name: Download iOS Frameworks + uses: actions/download-artifact@v4 + with: + name: rn-ios-frameworks-${{ steps.version.outputs.version }} + path: ios-frameworks + + - name: Download Android Native + uses: actions/download-artifact@v4 + with: + pattern: rn-android-native-* + path: android-native + merge-multiple: false + + - name: Setup Native Libraries + run: | + echo "=== Downloaded iOS frameworks ===" + find ios-frameworks -type d -name "*.xcframework" || echo "No xcframeworks found" + + echo "=== Downloaded Android native ===" + find android-native -name "*.so" -type f || echo "No .so files found" + + # Setup iOS frameworks + CORE_IOS="${{ env.SDK_DIR }}/packages/core/ios/Frameworks" + LLAMA_IOS="${{ env.SDK_DIR }}/packages/llamacpp/ios/Frameworks" + ONNX_IOS="${{ env.SDK_DIR }}/packages/onnx/ios/Frameworks" + + mkdir -p "$CORE_IOS" "$LLAMA_IOS" "$ONNX_IOS" + + echo "=== Copying iOS frameworks ===" + # RACommons + if [ -d "ios-frameworks/RACommons.xcframework" ]; then + cp -rv ios-frameworks/RACommons.xcframework "$CORE_IOS/" + else + find ios-frameworks -name "RACommons.xcframework" -type d -exec cp -rv {} "$CORE_IOS/" \; + fi + + # LlamaCPP + if [ -d "ios-frameworks/RABackendLLAMACPP.xcframework" ]; then + cp -rv ios-frameworks/RABackendLLAMACPP.xcframework "$LLAMA_IOS/" + else + find ios-frameworks -name "RABackendLLAMACPP.xcframework" -type d -exec cp -rv {} "$LLAMA_IOS/" \; + fi + + # ONNX and dependencies + if [ -d "ios-frameworks/RABackendONNX.xcframework" ]; then + cp -rv ios-frameworks/RABackendONNX.xcframework "$ONNX_IOS/" + else + find ios-frameworks -name "RABackendONNX.xcframework" -type d -exec cp -rv {} "$ONNX_IOS/" \; + fi + find ios-frameworks -name "onnxruntime.xcframework" -type d -exec cp -rv {} "$ONNX_IOS/" \; + find ios-frameworks -name "sherpa-onnx.xcframework" -type d -exec cp -rv {} "$ONNX_IOS/" \; + + # Setup Android jniLibs + CORE_ANDROID="${{ env.SDK_DIR }}/packages/core/android/src/main/jniLibs" + LLAMA_ANDROID="${{ env.SDK_DIR }}/packages/llamacpp/android/src/main/jniLibs" + ONNX_ANDROID="${{ env.SDK_DIR }}/packages/onnx/android/src/main/jniLibs" + + mkdir -p "$CORE_ANDROID" "$LLAMA_ANDROID" "$ONNX_ANDROID" + + echo "=== Copying Android native libraries ===" + for artifact_dir in android-native/rn-android-native-*/; do + if [ -d "$artifact_dir" ]; then + echo "Processing $artifact_dir" + + for abi_dir in "${artifact_dir}"unified/*/; do + if [ -d "$abi_dir" ]; then + abi=$(basename "$abi_dir") + echo "Copying unified for $abi" + mkdir -p "${CORE_ANDROID}/${abi}" + find "$abi_dir" -name "*.so" -exec cp -v {} "${CORE_ANDROID}/${abi}/" \; + fi + done + + for abi_dir in "${artifact_dir}"llamacpp/*/; do + if [ -d "$abi_dir" ]; then + abi=$(basename "$abi_dir") + echo "Copying llamacpp for $abi" + mkdir -p "${LLAMA_ANDROID}/${abi}" + find "$abi_dir" -name "*.so" -exec cp -v {} "${LLAMA_ANDROID}/${abi}/" \; + fi + done + + for abi_dir in "${artifact_dir}"onnx/*/; do + if [ -d "$abi_dir" ]; then + abi=$(basename "$abi_dir") + echo "Copying onnx for $abi" + mkdir -p "${ONNX_ANDROID}/${abi}" + find "$abi_dir" -name "*.so" -exec cp -v {} "${ONNX_ANDROID}/${abi}/" \; + fi + done + fi + done + + # Copy headers + mkdir -p "${{ env.SDK_DIR }}/packages/core/android/src/main/include" + INCLUDE_DIR=$(find android-native -name "include" -type d | head -1) + if [ -n "$INCLUDE_DIR" ]; then + cp -rv "$INCLUDE_DIR"/* "${{ env.SDK_DIR }}/packages/core/android/src/main/include/" + fi + + echo "=== Final iOS Frameworks ===" + find ${{ env.SDK_DIR }}/packages -name "*.xcframework" -type d || echo "No frameworks!" + + echo "=== Final Android jniLibs ===" + find ${{ env.SDK_DIR }}/packages -name "*.so" -type f | head -30 || echo "No .so files!" + + - name: Update Package Versions + working-directory: ${{ env.SDK_DIR }} + run: | + VERSION="${{ steps.version.outputs.version }}" + + # Update version in all package.json files + for pkg in packages/*/package.json; do + node -e " + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync('$pkg')); + pkg.version = '$VERSION'; + fs.writeFileSync('$pkg', JSON.stringify(pkg, null, 2) + '\n'); + " + done + + # Update root package.json + node -e " + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync('package.json')); + pkg.version = '$VERSION'; + fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); + " + + - name: Install Dependencies + working-directory: ${{ env.SDK_DIR }} + run: | + echo "=== Yarn version ===" + yarn --version + + echo "=== Installing dependencies ===" + # Don't use --immutable as lockfile may need updates + yarn install + + echo "✅ Dependencies installed" + + - name: Build TypeScript + working-directory: ${{ env.SDK_DIR }} + run: yarn build || echo "TypeScript build had issues" + continue-on-error: true + + - name: Run Tests + working-directory: ${{ env.SDK_DIR }} + run: yarn test + continue-on-error: true + + - name: Package React Native SDK + run: | + VERSION="${{ steps.version.outputs.version }}" + mkdir -p rn-release + + cd ${{ env.SDK_DIR }} + zip -r "../../rn-release/runanywhere-react-native-${VERSION}.zip" \ + packages/ \ + lerna.json \ + package.json \ + tsconfig.base.json \ + README.md \ + -x "*.git*" \ + -x "*node_modules/*" \ + -x "*build/*" \ + -x "*.tsbuildinfo" + + - name: Upload React Native SDK Package + uses: actions/upload-artifact@v4 + with: + name: react-native-sdk-package-${{ steps.version.outputs.version }} + path: rn-release/ + retention-days: 30 + + # =========================================================================== + # Publish Release + # =========================================================================== + publish: + name: Publish React Native SDK Release + needs: build-react-native-sdk + # Skip if dry_run or skip_publish (for unified release) + if: | + inputs.dry_run != true && + inputs.skip_publish != true && + ( + startsWith(github.ref, 'refs/tags/react-native-v') || + startsWith(github.ref, 'refs/tags/v') || + github.event_name == 'workflow_dispatch' || + github.event_name == 'workflow_call' + ) + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Download React Native SDK Package + uses: actions/download-artifact@v4 + with: + name: react-native-sdk-package-${{ needs.build-react-native-sdk.outputs.version }} + path: release-assets + + - name: Generate Checksums + run: | + cd release-assets + for f in *.zip; do + if [ -f "$f" ]; then + shasum -a 256 "$f" > "${f}.sha256" + fi + done + ls -la + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: react-native-v${{ needs.build-react-native-sdk.outputs.version }} + name: RunAnywhere React Native SDK v${{ needs.build-react-native-sdk.outputs.version }} + files: | + release-assets/* + body: | + ## RunAnywhere React Native SDK v${{ needs.build-react-native-sdk.outputs.version }} + + Privacy-first, on-device AI SDK for React Native (iOS & Android). + + ### Installation + + **Git Dependency (package.json):** + ```json + { + "dependencies": { + "@runanywhere/core": "github:RunanywhereAI/runanywhere-sdks#react-native-v${{ needs.build-react-native-sdk.outputs.version }}", + "@runanywhere/llamacpp": "github:RunanywhereAI/runanywhere-sdks#react-native-v${{ needs.build-react-native-sdk.outputs.version }}", + "@runanywhere/onnx": "github:RunanywhereAI/runanywhere-sdks#react-native-v${{ needs.build-react-native-sdk.outputs.version }}" + } + } + ``` + + **Download Package:** + Download `runanywhere-react-native-${{ needs.build-react-native-sdk.outputs.version }}.zip` which includes native binaries. + + ### Packages + + | Package | Description | + |---------|-------------| + | `@runanywhere/core` | Core SDK (required) | + | `@runanywhere/llamacpp` | LLM backend (llama.cpp) | + | `@runanywhere/onnx` | STT/TTS/VAD backend (Sherpa-ONNX) | + + ### Features + - 🧠 **LLM**: On-device text generation via llama.cpp + - 🎤 **STT**: Speech-to-text via Sherpa-ONNX Whisper + - 🔊 **TTS**: Text-to-speech via Sherpa-ONNX Piper + - 🎯 **VAD**: Voice activity detection + - 🔒 **Privacy**: All processing happens on-device + + ### Requirements + - React Native >= 0.72 + - iOS 13.0+ / Android API 24+ + - Node.js 18+ + + ### Post-Install Setup + + **iOS:** + ```bash + cd ios && pod install + ``` + + **Android:** + ```bash + cp android/gradle.properties.example android/gradle.properties + ``` + + ### Documentation + See [README](https://github.com/RunanywhereAI/runanywhere-sdks/tree/main/sdk/runanywhere-react-native) + + --- + Built from runanywhere-sdks @ ${{ github.sha }} + draft: false + prerelease: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # ========================================================================= + # Publish to npm (optional) + # ========================================================================= + - name: Checkout for npm publish + if: inputs.publish_npm == true + uses: actions/checkout@v4 + + - name: Setup Node.js for npm + if: inputs.publish_npm == true + uses: actions/setup-node@v4 + with: + node-version: '20' + registry-url: 'https://registry.npmjs.org' + + - name: Publish to npm + if: inputs.publish_npm == true + working-directory: sdk/runanywhere-react-native + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + VERSION="${{ needs.build-react-native-sdk.outputs.version }}" + echo "Publishing version $VERSION to npm..." + + # Update version in all package.json files + for pkg in packages/*/package.json; do + node -e " + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync('$pkg')); + pkg.version = '$VERSION'; + fs.writeFileSync('$pkg', JSON.stringify(pkg, null, 2) + '\n'); + " + echo "Updated $pkg to version $VERSION" + done + + # Install dependencies (without modifying lockfile) + yarn install || npm install + + # Publish each package + for pkg_dir in packages/core packages/llamacpp packages/onnx; do + if [ -d "$pkg_dir" ]; then + echo "Publishing $pkg_dir..." + cd "$pkg_dir" + npm publish --access public || echo "Failed to publish $pkg_dir (may already exist)" + cd ../.. + fi + done + + echo "✅ npm publish complete" + diff --git a/.github/workflows/release-all.yml b/.github/workflows/release-all.yml new file mode 100644 index 000000000..3b08f837f --- /dev/null +++ b/.github/workflows/release-all.yml @@ -0,0 +1,715 @@ +name: Release All SDKs + +# ============================================================================= +# Unified Release Orchestration Workflow +# +# This workflow orchestrates releases of all SDK components when a unified +# version tag is pushed. It triggers individual SDK release workflows. +# +# Tag Format: v1.0.0 +# This will release: +# - RACommons (core infrastructure) +# - Backend Libraries (LlamaCPP + ONNX) +# - Swift SDK (iOS/macOS) +# - Kotlin SDK (Android) +# +# NOTE: Flutter SDK and React Native SDK are NOT included in this workflow. +# They are published separately: +# - Flutter → pub.dev +# - React Native → npm +# +# Alternatively, release individual SDKs with their prefixed tags: +# - swift-v1.0.0 → Swift SDK only +# - kotlin-v1.0.0 → Kotlin SDK only +# etc. +# ============================================================================= + +on: + push: + tags: + - 'v[0-9]+.[0-9]+.[0-9]+*' # Matches v1.0.0, v1.0.0-beta, etc. + workflow_dispatch: + inputs: + version: + description: 'Version to release (e.g., 1.0.0)' + required: true + type: string + release_commons: + description: 'Release RACommons (infrastructure)' + required: false + default: true + type: boolean + release_backends: + description: 'Release Backend Libraries' + required: false + default: true + type: boolean + release_swift: + description: 'Release Swift SDK' + required: false + default: true + type: boolean + release_kotlin: + description: 'Release Kotlin SDK' + required: false + default: true + type: boolean + # Flutter SDK is published to pub.dev separately - not needed in this workflow + # release_flutter: + # description: 'Release Flutter SDK' + # required: false + # default: true + # type: boolean + # React Native SDK is published to npm separately - not needed in this workflow + # release_react_native: + # description: 'Release React Native SDK' + # required: false + # default: true + # type: boolean + # publish_npm: + # description: 'Publish React Native SDK to npm' + # required: false + # default: false + # type: boolean + dry_run: + description: 'Dry run (build only, no publish)' + required: false + default: false + type: boolean + +permissions: + contents: write + packages: write + +jobs: + # =========================================================================== + # Determine Version + # =========================================================================== + setup: + name: Setup Release + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + release_commons: ${{ steps.flags.outputs.release_commons }} + release_backends: ${{ steps.flags.outputs.release_backends }} + release_swift: ${{ steps.flags.outputs.release_swift }} + release_kotlin: ${{ steps.flags.outputs.release_kotlin }} + # Flutter/React Native use pub.dev/npm - outputs not needed + # release_flutter: ${{ steps.flags.outputs.release_flutter }} + # release_react_native: ${{ steps.flags.outputs.release_react_native }} + # publish_npm: ${{ steps.flags.outputs.publish_npm }} + dry_run: ${{ steps.flags.outputs.dry_run }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Determine Version + id: version + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + VERSION="${{ github.event.inputs.version }}" + else + VERSION="${GITHUB_REF#refs/tags/v}" + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "📦 Preparing release v$VERSION" + + - name: Set Release Flags + id: flags + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "release_commons=${{ github.event.inputs.release_commons }}" >> $GITHUB_OUTPUT + echo "release_backends=${{ github.event.inputs.release_backends }}" >> $GITHUB_OUTPUT + echo "release_swift=${{ github.event.inputs.release_swift }}" >> $GITHUB_OUTPUT + echo "release_kotlin=${{ github.event.inputs.release_kotlin }}" >> $GITHUB_OUTPUT + # Flutter/React Native use pub.dev/npm separately + # echo "release_flutter=${{ github.event.inputs.release_flutter }}" >> $GITHUB_OUTPUT + # echo "release_react_native=${{ github.event.inputs.release_react_native }}" >> $GITHUB_OUTPUT + # echo "publish_npm=${{ github.event.inputs.publish_npm }}" >> $GITHUB_OUTPUT + echo "dry_run=${{ github.event.inputs.dry_run }}" >> $GITHUB_OUTPUT + else + # Tag push releases everything + echo "release_commons=true" >> $GITHUB_OUTPUT + echo "release_backends=true" >> $GITHUB_OUTPUT + echo "release_swift=true" >> $GITHUB_OUTPUT + echo "release_kotlin=true" >> $GITHUB_OUTPUT + # Flutter/React Native use pub.dev/npm separately + # echo "release_flutter=true" >> $GITHUB_OUTPUT + # echo "release_react_native=true" >> $GITHUB_OUTPUT + # echo "publish_npm=false" >> $GITHUB_OUTPUT + echo "dry_run=false" >> $GITHUB_OUTPUT + fi + + # =========================================================================== + # Release RACommons (Foundation) + # =========================================================================== + release-commons: + name: Release RACommons + needs: setup + if: needs.setup.outputs.release_commons == 'true' + uses: ./.github/workflows/commons-release.yml + with: + version: ${{ needs.setup.outputs.version }} + dry_run: ${{ needs.setup.outputs.dry_run == 'true' }} + skip_publish: true # Skip individual release, use unified release + secrets: inherit + + # =========================================================================== + # Release Backend Libraries (depends on Commons) + # =========================================================================== + release-backends: + name: Release Backends + needs: [setup, release-commons] + if: | + always() && + needs.setup.outputs.release_backends == 'true' && + (needs.release-commons.result == 'success' || needs.release-commons.result == 'skipped') + uses: ./.github/workflows/backends-release.yml + with: + version: ${{ needs.setup.outputs.version }} + backends: all + dry_run: ${{ needs.setup.outputs.dry_run == 'true' }} + skip_publish: true # Skip individual release, use unified release + secrets: inherit + + # =========================================================================== + # Release Swift SDK (depends on Backends) + # =========================================================================== + release-swift: + name: Release Swift SDK + needs: [setup, release-backends] + if: | + always() && + needs.setup.outputs.release_swift == 'true' && + (needs.release-backends.result == 'success' || needs.release-backends.result == 'skipped') + uses: ./.github/workflows/swift-sdk-release.yml + with: + version: ${{ needs.setup.outputs.version }} + update_binaries: true + dry_run: ${{ needs.setup.outputs.dry_run == 'true' }} + skip_publish: true # Skip individual release, use unified release + secrets: inherit + + # =========================================================================== + # Release Kotlin SDK (depends on Backends) + # =========================================================================== + release-kotlin: + name: Release Kotlin SDK + needs: [setup, release-backends] + if: | + always() && + needs.setup.outputs.release_kotlin == 'true' && + (needs.release-backends.result == 'success' || needs.release-backends.result == 'skipped') + uses: ./.github/workflows/kotlin-sdk-release.yml + with: + version: ${{ needs.setup.outputs.version }} + publish_maven: false + dry_run: ${{ needs.setup.outputs.dry_run == 'true' }} + skip_publish: true # Skip individual release, use unified release + secrets: inherit + + # =========================================================================== + # Release Flutter SDK - DISABLED (published to pub.dev separately) + # =========================================================================== + # release-flutter: + # name: Release Flutter SDK + # needs: [setup, release-backends] + # if: | + # always() && + # needs.setup.outputs.release_flutter == 'true' && + # (needs.release-backends.result == 'success' || needs.release-backends.result == 'skipped') + # uses: ./.github/workflows/flutter-sdk-release.yml + # with: + # version: ${{ needs.setup.outputs.version }} + # publish_pubdev: false + # dry_run: ${{ needs.setup.outputs.dry_run == 'true' }} + # skip_publish: true # Skip individual release, use unified release + # secrets: inherit + + # =========================================================================== + # Release React Native SDK - DISABLED (published to npm separately) + # =========================================================================== + # release-react-native: + # name: Release React Native SDK + # needs: [setup, release-backends] + # if: | + # always() && + # needs.setup.outputs.release_react_native == 'true' && + # (needs.release-backends.result == 'success' || needs.release-backends.result == 'skipped') + # uses: ./.github/workflows/react-native-sdk-release.yml + # with: + # version: ${{ needs.setup.outputs.version }} + # publish_npm: ${{ needs.setup.outputs.publish_npm == 'true' }} + # dry_run: ${{ needs.setup.outputs.dry_run == 'true' }} + # skip_publish: true # Skip individual release, use unified release + # secrets: inherit + + # =========================================================================== + # Create Unified Release (Single release with all assets) + # =========================================================================== + create-unified-release: + name: Create Unified Release + needs: [setup, release-commons, release-backends, release-swift, release-kotlin] + # NOTE: Flutter/React Native removed - they use pub.dev/npm separately + if: | + always() && + needs.setup.outputs.dry_run != 'true' + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout for Package.swift Update + uses: actions/checkout@v4 + + - name: Download All Artifacts + uses: actions/download-artifact@v4 + with: + path: all-artifacts + + - name: Debug - List Downloaded Artifacts + run: | + echo "=== All artifacts directory structure ===" + ls -la all-artifacts/ || echo "all-artifacts/ does not exist" + echo "" + echo "=== Full tree (first 100 entries) ===" + find all-artifacts -type d | head -100 + echo "" + echo "=== Files (first 50) ===" + find all-artifacts -type f | head -50 + + - name: Prepare Release Assets + run: | + VERSION="${{ needs.setup.outputs.version }}" + mkdir -p release-assets + + echo "=== Looking for artifacts with VERSION=$VERSION ===" + echo "" + + # List what we're looking for vs what exists + echo "Expected: all-artifacts/racommons-ios-$VERSION" + ls -la "all-artifacts/racommons-ios-$VERSION" 2>&1 || echo "NOT FOUND" + echo "" + echo "Expected: all-artifacts/backend-llamacpp-ios-$VERSION" + ls -la "all-artifacts/backend-llamacpp-ios-$VERSION" 2>&1 || echo "NOT FOUND" + echo "" + echo "Expected: all-artifacts/backend-onnx-ios-$VERSION" + ls -la "all-artifacts/backend-onnx-ios-$VERSION" 2>&1 || echo "NOT FOUND" + echo "" + + # ───────────────────────────────────────────────────────────── + # RACommons + # ───────────────────────────────────────────────────────────── + # iOS - Find XCFramework and zip it at root level for SPM compatibility + if [ -d "all-artifacts/racommons-ios-$VERSION" ]; then + echo "✅ Found racommons-ios-$VERSION, creating zip..." + COMMONS_DIR="all-artifacts/racommons-ios-$VERSION" + + # Find the RACommons.xcframework (may be nested in dist/ or elsewhere) + XCFW_PATH=$(find "$COMMONS_DIR" -name "RACommons.xcframework" -type d | head -1) + + if [ -n "$XCFW_PATH" ] && [ -d "$XCFW_PATH" ]; then + echo "Found XCFramework at: $XCFW_PATH" + mkdir -p commons-package + cp -R "$XCFW_PATH" commons-package/ + cd commons-package + zip -r "../release-assets/RACommons-ios-v${VERSION}.zip" RACommons.xcframework + cd .. + rm -rf commons-package + echo "Created: RACommons-ios-v${VERSION}.zip (XCFramework at root)" + else + echo "⚠️ XCFramework not found in artifact, zipping entire directory" + cd "$COMMONS_DIR" + zip -r "../../release-assets/RACommons-ios-v${VERSION}.zip" . + cd ../.. + fi + else + echo "❌ racommons-ios-$VERSION NOT FOUND" + fi + + # Android - SPLIT by ABI for smaller downloads + for abi in arm64-v8a armeabi-v7a x86_64; do + if [ -d "all-artifacts/racommons-android-${abi}-${VERSION}" ]; then + cd "all-artifacts/racommons-android-${abi}-${VERSION}" + zip -r "../../release-assets/RACommons-android-${abi}-v${VERSION}.zip" . + cd ../.. + fi + done + + # ───────────────────────────────────────────────────────────── + # Backend Libraries - ALL SPLIT by ABI + # ───────────────────────────────────────────────────────────── + # LlamaCPP iOS - Find XCFramework and zip it at root level for SPM compatibility + if [ -d "all-artifacts/backend-llamacpp-ios-$VERSION" ]; then + echo "✅ Found backend-llamacpp-ios-$VERSION, creating zip..." + LLAMA_DIR="all-artifacts/backend-llamacpp-ios-$VERSION" + + # Find the RABackendLLAMACPP.xcframework (may be nested in dist/ or elsewhere) + XCFW_PATH=$(find "$LLAMA_DIR" -name "RABackendLLAMACPP.xcframework" -type d | head -1) + + if [ -n "$XCFW_PATH" ] && [ -d "$XCFW_PATH" ]; then + echo "Found XCFramework at: $XCFW_PATH" + mkdir -p llama-package + cp -R "$XCFW_PATH" llama-package/ + cd llama-package + zip -r "../release-assets/RABackendLLAMACPP-ios-v${VERSION}.zip" RABackendLLAMACPP.xcframework + cd .. + rm -rf llama-package + echo "Created: RABackendLLAMACPP-ios-v${VERSION}.zip (XCFramework at root)" + else + echo "⚠️ XCFramework not found in artifact, zipping entire directory" + cd "$LLAMA_DIR" + zip -r "../../release-assets/RABackendLLAMACPP-ios-v${VERSION}.zip" . + cd ../.. + fi + else + echo "❌ backend-llamacpp-ios-$VERSION NOT FOUND" + fi + + # ONNX iOS - Find XCFramework and zip it at root level for SPM compatibility + if [ -d "all-artifacts/backend-onnx-ios-$VERSION" ]; then + echo "✅ Found backend-onnx-ios-$VERSION, creating zip..." + ONNX_DIR="all-artifacts/backend-onnx-ios-$VERSION" + + # Find the RABackendONNX.xcframework (may be nested in dist/ or elsewhere) + XCFW_PATH=$(find "$ONNX_DIR" -name "RABackendONNX.xcframework" -type d | head -1) + + if [ -n "$XCFW_PATH" ] && [ -d "$XCFW_PATH" ]; then + echo "Found XCFramework at: $XCFW_PATH" + # Create temp directory with correct structure + mkdir -p onnx-package + cp -R "$XCFW_PATH" onnx-package/ + cd onnx-package + zip -r "../release-assets/RABackendONNX-ios-v${VERSION}.zip" RABackendONNX.xcframework + cd .. + rm -rf onnx-package + echo "Created: RABackendONNX-ios-v${VERSION}.zip (XCFramework at root)" + else + echo "⚠️ XCFramework not found in artifact, zipping entire directory" + cd "$ONNX_DIR" + zip -r "../../release-assets/RABackendONNX-ios-v${VERSION}.zip" . + cd ../.. + fi + else + echo "❌ backend-onnx-ios-$VERSION NOT FOUND" + fi + + # LlamaCPP Android - SPLIT by ABI + for abi in arm64-v8a armeabi-v7a x86_64; do + if [ -d "all-artifacts/backends-android-${abi}-${VERSION}" ]; then + if [ -d "all-artifacts/backends-android-${abi}-${VERSION}/llamacpp/${abi}" ]; then + mkdir -p "android-llamacpp-${abi}/jniLibs/${abi}" + cp -r "all-artifacts/backends-android-${abi}-${VERSION}/llamacpp/${abi}"/*.so "android-llamacpp-${abi}/jniLibs/${abi}/" 2>/dev/null || true + if [ "$(find android-llamacpp-${abi} -name '*.so' 2>/dev/null | head -1)" ]; then + cd "android-llamacpp-${abi}" && zip -r "../release-assets/RABackendLLAMACPP-android-${abi}-v${VERSION}.zip" . && cd .. + fi + rm -rf "android-llamacpp-${abi}" + fi + fi + done + + # ONNX Android - SPLIT by ABI + for abi in arm64-v8a armeabi-v7a x86_64; do + if [ -d "all-artifacts/backends-android-${abi}-${VERSION}" ]; then + if [ -d "all-artifacts/backends-android-${abi}-${VERSION}/onnx/${abi}" ]; then + mkdir -p "android-onnx-${abi}/jniLibs/${abi}" + cp -r "all-artifacts/backends-android-${abi}-${VERSION}/onnx/${abi}"/*.so "android-onnx-${abi}/jniLibs/${abi}/" 2>/dev/null || true + if [ "$(find android-onnx-${abi} -name '*.so' 2>/dev/null | head -1)" ]; then + cd "android-onnx-${abi}" && zip -r "../release-assets/RABackendONNX-android-${abi}-v${VERSION}.zip" . && cd .. + fi + rm -rf "android-onnx-${abi}" + fi + fi + done + + # ───────────────────────────────────────────────────────────── + # Swift SDK + # ───────────────────────────────────────────────────────────── + if [ -d "all-artifacts/swift-sdk-build-$VERSION" ]; then + cd "all-artifacts/swift-sdk-build-$VERSION" + zip -r "../../release-assets/RunAnywhere-Swift-SDK-v${VERSION}.zip" . + cd ../.. + fi + + # ───────────────────────────────────────────────────────────── + # Kotlin SDK + # ───────────────────────────────────────────────────────────── + if [ -d "all-artifacts/kotlin-sdk-aar-$VERSION" ]; then + cp "all-artifacts/kotlin-sdk-aar-$VERSION"/*.aar "release-assets/RunAnywhere-Kotlin-SDK-v${VERSION}.aar" 2>/dev/null || true + fi + + # ───────────────────────────────────────────────────────────── + # Flutter SDK - DISABLED (published to pub.dev separately) + # ───────────────────────────────────────────────────────────── + # if [ -d "all-artifacts/flutter-sdk-package-$VERSION" ]; then + # cp "all-artifacts/flutter-sdk-package-$VERSION"/*.zip "release-assets/RunAnywhere-Flutter-SDK-v${VERSION}.zip" 2>/dev/null || true + # fi + + # ───────────────────────────────────────────────────────────── + # React Native SDK - DISABLED (published to npm separately) + # ───────────────────────────────────────────────────────────── + # if [ -d "all-artifacts/react-native-sdk-package-$VERSION" ]; then + # cp "all-artifacts/react-native-sdk-package-$VERSION"/*.zip "release-assets/RunAnywhere-ReactNative-SDK-v${VERSION}.zip" 2>/dev/null || true + # fi + + # ───────────────────────────────────────────────────────────── + # Generate Checksums + # ───────────────────────────────────────────────────────────── + echo "" + echo "==========================================" + echo "=== FINAL: Release Assets Created ===" + echo "==========================================" + cd release-assets + ls -la + + # Count files + ZIP_COUNT=$(ls -1 *.zip 2>/dev/null | wc -l || echo "0") + AAR_COUNT=$(ls -1 *.aar 2>/dev/null | wc -l || echo "0") + echo "" + echo "Total ZIP files: $ZIP_COUNT" + echo "Total AAR files: $AAR_COUNT" + + if [ "$ZIP_COUNT" -eq "0" ] && [ "$AAR_COUNT" -eq "0" ]; then + echo "" + echo "⚠️ WARNING: No release assets were created!" + echo "This likely means the artifact directories were not found." + echo "Check the debug output above for missing directories." + fi + + echo "" > checksums.sha256 + for f in *.zip *.aar; do + if [ -f "$f" ]; then + shasum -a 256 "$f" >> checksums.sha256 + fi + done + echo "" + echo "=== Checksums ===" + cat checksums.sha256 + + - name: Update Root Package.swift Checksums + run: | + VERSION="${{ needs.setup.outputs.version }}" + ROOT_PACKAGE="Package.swift" + + if [ ! -f "$ROOT_PACKAGE" ]; then + echo "Root Package.swift not found, skipping checksum update" + exit 0 + fi + + echo "=== Updating Package.swift for v$VERSION ===" + + # Update the SDK version + sed -i 's/let sdkVersion = "[^"]*"/let sdkVersion = "'"$VERSION"'"/' "$ROOT_PACKAGE" + echo "Updated sdkVersion to: $VERSION" + + # Compute and update checksums from release assets + cd release-assets + + # RACommons checksum + if [ -f "RACommons-ios-v${VERSION}.zip" ]; then + COMMONS_CHECKSUM=$(shasum -a 256 "RACommons-ios-v${VERSION}.zip" | cut -d ' ' -f 1) + echo "RACommons checksum: $COMMONS_CHECKSUM" + cd .. + sed -i 's/checksum: "CHECKSUM_RACOMMONS"/checksum: "'"$COMMONS_CHECKSUM"'"/' "$ROOT_PACKAGE" + cd release-assets + else + echo "⚠️ RACommons-ios-v${VERSION}.zip not found, checksum not updated" + fi + + # RABackendLlamaCPP checksum + if [ -f "RABackendLLAMACPP-ios-v${VERSION}.zip" ]; then + LLAMACPP_CHECKSUM=$(shasum -a 256 "RABackendLLAMACPP-ios-v${VERSION}.zip" | cut -d ' ' -f 1) + echo "RABackendLlamaCPP checksum: $LLAMACPP_CHECKSUM" + cd .. + sed -i 's/checksum: "CHECKSUM_LLAMACPP"/checksum: "'"$LLAMACPP_CHECKSUM"'"/' "$ROOT_PACKAGE" + cd release-assets + else + echo "⚠️ RABackendLLAMACPP-ios-v${VERSION}.zip not found, checksum not updated" + fi + + # RABackendONNX checksum + if [ -f "RABackendONNX-ios-v${VERSION}.zip" ]; then + ONNX_CHECKSUM=$(shasum -a 256 "RABackendONNX-ios-v${VERSION}.zip" | cut -d ' ' -f 1) + echo "RABackendONNX checksum: $ONNX_CHECKSUM" + cd .. + sed -i 's/checksum: "CHECKSUM_ONNX"/checksum: "'"$ONNX_CHECKSUM"'"/' "$ROOT_PACKAGE" + cd release-assets + else + echo "⚠️ RABackendONNX-ios-v${VERSION}.zip not found, checksum not updated" + fi + + cd .. + + echo "" + echo "=== Updated Package.swift binaryTargets ===" + grep -A2 "binaryTarget" "$ROOT_PACKAGE" | head -30 || true + + - name: Commit Package.swift and Update Tag + run: | + VERSION="${{ needs.setup.outputs.version }}" + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + git add Package.swift || true + + if git diff --staged --quiet; then + echo "No Package.swift changes to commit" + else + git commit -m "chore: update Package.swift checksums for v$VERSION" + + # Push to main/master + git push origin HEAD:main || git push origin HEAD:master || echo "Push to default branch skipped" + + echo "=== Updating tag v$VERSION to include Package.swift changes ===" + + # Delete the remote tag + git push origin :refs/tags/v$VERSION || echo "Remote tag deletion skipped (may not exist)" + + # Delete local tag + git tag -d v$VERSION || echo "Local tag deletion skipped" + + # Create new tag pointing to current commit (with updated Package.swift) + git tag -a v$VERSION -m "Release v$VERSION" + + # Push the new tag + git push origin v$VERSION + + echo "✅ Tag v$VERSION updated to point to commit with correct checksums" + fi + + - name: Create Unified GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ needs.setup.outputs.version }} + name: RunAnywhere SDKs v${{ needs.setup.outputs.version }} + files: release-assets/* + body: | + ## RunAnywhere SDKs v${{ needs.setup.outputs.version }} + + **Privacy-first, on-device AI SDKs** for iOS, Android, Flutter, and React Native. + + --- + + ### 📦 Release Assets + + #### Core Libraries + | Asset | Platform | Size | + |-------|----------|------| + | `RACommons-ios-v${{ needs.setup.outputs.version }}.zip` | iOS/macOS | ~2 MB | + | `RACommons-android-arm64-v8a-v${{ needs.setup.outputs.version }}.zip` | Android arm64 | ~61 MB | + | `RACommons-android-armeabi-v7a-v${{ needs.setup.outputs.version }}.zip` | Android armv7 | ~57 MB | + | `RACommons-android-x86_64-v${{ needs.setup.outputs.version }}.zip` | Android x86_64 | ~64 MB | + + #### Backend Libraries (pick what you need) + | Asset | Platform | Use Case | + |-------|----------|----------| + | `RABackendLLAMACPP-ios-v${{ needs.setup.outputs.version }}.zip` | iOS/macOS | LLM | + | `RABackendLLAMACPP-android-arm64-v8a-v${{ needs.setup.outputs.version }}.zip` | Android arm64 | LLM | + | `RABackendLLAMACPP-android-armeabi-v7a-v${{ needs.setup.outputs.version }}.zip` | Android armv7 | LLM | + | `RABackendLLAMACPP-android-x86_64-v${{ needs.setup.outputs.version }}.zip` | Android x86_64 | LLM | + | `RABackendONNX-ios-v${{ needs.setup.outputs.version }}.zip` | iOS/macOS | STT/TTS/VAD | + | `RABackendONNX-android-arm64-v8a-v${{ needs.setup.outputs.version }}.zip` | Android arm64 | STT/TTS/VAD | + | `RABackendONNX-android-armeabi-v7a-v${{ needs.setup.outputs.version }}.zip` | Android armv7 | STT/TTS/VAD | + | `RABackendONNX-android-x86_64-v${{ needs.setup.outputs.version }}.zip` | Android x86_64 | STT/TTS/VAD | + + #### SDK Packages + | Asset | Platform | Description | + |-------|----------|-------------| + | `RunAnywhere-Swift-SDK-v${{ needs.setup.outputs.version }}.zip` | iOS/macOS | Swift SDK | + | `RunAnywhere-Kotlin-SDK-v${{ needs.setup.outputs.version }}.aar` | Android | Kotlin SDK (AAR) | + + > **Note:** Flutter SDK is available on [pub.dev](https://pub.dev/packages/runanywhere), React Native SDK is available on [npm](https://www.npmjs.com/package/@runanywhere/core) + + > 💡 **Tip:** Most Android apps only need `arm64-v8a` (85% of devices) + + --- + + ### 🚀 Quick Start + +
+ Swift (iOS/macOS) + + ```swift + // Package.swift + dependencies: [ + .package(url: "https://github.com/RunanywhereAI/runanywhere-sdks", from: "${{ needs.setup.outputs.version }}") + ] + ``` + + Or download the XCFrameworks directly from this release. +
+ +
+ Kotlin (Android) + + ```kotlin + // build.gradle.kts + dependencies { + implementation("ai.runanywhere:runanywhere-kotlin:${{ needs.setup.outputs.version }}") + } + ``` + + Or download `RunAnywhere-Kotlin-SDK-v${{ needs.setup.outputs.version }}.aar` from this release. +
+ +
+ Flutter + + ```yaml + # pubspec.yaml - Install from pub.dev + dependencies: + runanywhere: ^${{ needs.setup.outputs.version }} + ``` + + Or visit [pub.dev/packages/runanywhere](https://pub.dev/packages/runanywhere) +
+ +
+ React Native + + ```bash + # Install from npm + npm install @runanywhere/core + ``` + + Or visit [npmjs.com/package/@runanywhere/core](https://www.npmjs.com/package/@runanywhere/core) +
+ + --- + + ### ✨ Features + + - 🧠 **LLM**: On-device text generation via llama.cpp + - 🎤 **STT**: Speech-to-text via Sherpa-ONNX Whisper + - 🔊 **TTS**: Text-to-speech via Sherpa-ONNX Piper + - 🎯 **VAD**: Voice activity detection + - 🔒 **Privacy**: All processing happens on-device + + --- + + ### 📋 Build Status + + | Component | Status | + |-----------|--------| + | RACommons | ${{ needs.release-commons.result == 'success' && '✅' || '❌' }} | + | Backends | ${{ needs.release-backends.result == 'success' && '✅' || '❌' }} | + | Swift SDK | ${{ needs.release-swift.result == 'success' && '✅' || '❌' }} | + | Kotlin SDK | ${{ needs.release-kotlin.result == 'success' && '✅' || '❌' }} | + + --- + + ### 🔐 Verification + + ```bash + shasum -a 256 -c checksums.sha256 + ``` + + --- + + Built from commit: ${{ github.sha }} + draft: false + prerelease: ${{ contains(needs.setup.outputs.version, 'test') || contains(needs.setup.outputs.version, 'beta') || contains(needs.setup.outputs.version, 'alpha') }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/swift-sdk-release.yml b/.github/workflows/swift-sdk-release.yml new file mode 100644 index 000000000..700651b19 --- /dev/null +++ b/.github/workflows/swift-sdk-release.yml @@ -0,0 +1,538 @@ +name: Swift SDK Release + +# ============================================================================= +# Swift SDK Release Workflow +# +# Publishes the RunAnywhere Swift SDK for iOS/macOS. +# The Swift SDK is distributed via Swift Package Manager (SPM). +# +# This workflow: +# 1. Updates the Binaries/ folder with latest backend frameworks +# 2. Creates a release tag +# 3. Updates Package.swift binary targets if needed +# +# Users consume via: +# .package(url: "https://github.com/RunanywhereAI/runanywhere-sdks", from: "1.0.0") +# ============================================================================= + +on: + push: + tags: + - 'swift-v*' + workflow_call: + inputs: + version: + description: 'Version to release' + required: true + type: string + update_binaries: + description: 'Update binaries from latest backend release' + required: false + default: true + type: boolean + dry_run: + description: 'Dry run' + required: false + default: false + type: boolean + skip_publish: + description: 'Skip creating individual release (for unified release)' + required: false + default: false + type: boolean + workflow_dispatch: + inputs: + version: + description: 'Version to release (e.g., 1.0.0)' + required: true + type: string + update_binaries: + description: 'Update binaries from latest backend release' + required: false + default: true + type: boolean + dry_run: + description: 'Dry run (test build only)' + required: false + default: false + type: boolean + +permissions: + contents: read + +env: + SDK_DIR: sdk/runanywhere-swift + +jobs: + # =========================================================================== + # Build and Test Swift SDK + # =========================================================================== + build-test: + name: Build & Test Swift SDK + runs-on: macos-14 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Determine Version + id: version + run: | + if [ -n "${{ inputs.version }}" ]; then + VERSION="${{ inputs.version }}" + elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + VERSION="${{ github.event.inputs.version }}" + elif [[ "$GITHUB_REF" == refs/tags/swift-v* ]]; then + VERSION="${GITHUB_REF#refs/tags/swift-v}" + elif [[ "$GITHUB_REF" == refs/tags/v* ]]; then + VERSION="${GITHUB_REF#refs/tags/v}" + else + VERSION="0.0.0-$(git rev-parse --short HEAD)" + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Building Swift SDK v$VERSION" + + - name: Setup Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '16.1' + + - name: Install SwiftLint and xcpretty + run: | + brew install swiftlint + gem install xcpretty + + - name: Run SwiftLint + working-directory: ${{ env.SDK_DIR }} + run: swiftlint --strict + + - name: Download iOS Artifacts from Commons + uses: actions/download-artifact@v4 + with: + pattern: "racommons-ios-*" + path: artifacts/commons + merge-multiple: true + + - name: Download iOS Artifacts from Backends + uses: actions/download-artifact@v4 + with: + pattern: "backend-*-ios-*" + path: artifacts/backends + merge-multiple: true + + - name: Setup Binaries from Artifacts + working-directory: ${{ env.SDK_DIR }} + run: | + set -e # Exit on any error + echo "=== Checking for CI artifacts ===" + ARTIFACTS_DIR="${GITHUB_WORKSPACE}/artifacts" + + echo "=== Full artifacts structure (files) ===" + find "$ARTIFACTS_DIR" -type f 2>/dev/null | head -50 || echo "No files found" + + echo "" + echo "=== Full artifacts structure (directories) ===" + find "$ARTIFACTS_DIR" -type d 2>/dev/null | head -50 || echo "No directories found" + + echo "" + echo "=== Looking for XCFrameworks ===" + find "$ARTIFACTS_DIR" -name "*.xcframework" -type d 2>/dev/null || echo "No xcframeworks found as directories" + + echo "" + echo "=== Looking for zip files ===" + find "$ARTIFACTS_DIR" -name "*.zip" -type f 2>/dev/null || echo "No zip files found" + + # Clean and recreate Binaries directory + rm -rf Binaries + mkdir -p Binaries + + # Copy all xcframeworks from artifacts (without Info.plist check - just copy directories) + echo "" + echo "=== Copying xcframeworks ===" + XCFW_COUNT=0 + for fw in $(find "$ARTIFACTS_DIR" -name "*.xcframework" -type d 2>/dev/null); do + if [ -d "$fw" ]; then + fw_name=$(basename "$fw") + echo "Copying: $fw -> Binaries/$fw_name" + cp -R "$fw" "Binaries/$fw_name" + XCFW_COUNT=$((XCFW_COUNT + 1)) + fi + done + echo "Copied $XCFW_COUNT xcframeworks from directories" + + # Extract any zip files and copy xcframeworks from them + echo "" + echo "=== Extracting zip files ===" + mkdir -p Binaries/_tmp + for zip in $(find "$ARTIFACTS_DIR" -name "*.zip" -type f 2>/dev/null); do + echo "Extracting: $zip" + unzip -o "$zip" -d Binaries/_tmp/ || true + done + + # List extracted contents + echo "=== Extracted zip contents ===" + find Binaries/_tmp -type d -name "*.xcframework" 2>/dev/null || echo "No xcframeworks in zips" + + # Move xcframeworks from extracted zips + for fw in $(find Binaries/_tmp -name "*.xcframework" -type d 2>/dev/null); do + fw_name=$(basename "$fw") + if [ ! -d "Binaries/$fw_name" ]; then + echo "Moving from zip: $fw -> Binaries/$fw_name" + cp -R "$fw" "Binaries/$fw_name" + XCFW_COUNT=$((XCFW_COUNT + 1)) + fi + done + rm -rf Binaries/_tmp 2>/dev/null || true + + echo "" + echo "=== Final Binaries directory contents ===" + ls -la Binaries/ || echo "Empty" + + # Show internal structure of one framework for debugging + echo "" + echo "=== Sample framework structure ===" + if [ -d "Binaries/RACommons.xcframework" ]; then + ls -la Binaries/RACommons.xcframework/ || true + fi + + # Verify required frameworks exist (check directory exists, not Info.plist) + echo "" + echo "=== Verifying required frameworks ===" + REQUIRED="RACommons RABackendLLAMACPP RABackendONNX" + MISSING="" + for req in $REQUIRED; do + FW="Binaries/${req}.xcframework" + if [ -d "$FW" ]; then + # Check if it has any content + COUNT=$(ls -1 "$FW" 2>/dev/null | wc -l) + if [ "$COUNT" -gt 0 ]; then + echo "✅ $req.xcframework - found ($COUNT items)" + else + echo "❌ $req.xcframework - exists but EMPTY" + MISSING="$MISSING $req" + fi + else + echo "❌ $req.xcframework - MISSING" + MISSING="$MISSING $req" + fi + done + + if [ -n "$MISSING" ]; then + echo "" + echo "=========================================" + echo "❌ ERROR: Missing required frameworks:$MISSING" + echo "=========================================" + echo "" + echo "This is a CI build - artifacts from earlier jobs should be available." + echo "Check that commons-release and backends-release jobs completed successfully." + exit 1 + fi + + echo "" + echo "✅ All frameworks ready for build" + + - name: Resolve Package Dependencies + working-directory: ${{ env.SDK_DIR }} + run: | + echo "=== Xcode version ===" + xcodebuild -version + echo "" + echo "=== Resolving Swift Package dependencies ===" + swift package resolve + echo "" + echo "=== Package description ===" + swift package describe || true + + - name: Verify Package Structure + working-directory: ${{ env.SDK_DIR }} + run: | + echo "=== Checking XCFrameworks ===" + ls -la Binaries/ + echo "" + echo "=== Checking RACommons ===" + ls -la Binaries/RACommons.xcframework/ || echo "RACommons not found" + echo "" + echo "=== Checking RABackendLLAMACPP ===" + ls -la Binaries/RABackendLLAMACPP.xcframework/ || echo "LlamaCPP not found" + echo "" + echo "=== Checking RABackendONNX ===" + ls -la Binaries/RABackendONNX.xcframework/ || echo "ONNX not found" + + - name: Build for iOS Device (Archive-ready) + working-directory: ${{ env.SDK_DIR }} + run: | + # Build for iOS Device using xcodebuild archive + # This validates the package builds correctly for distribution + echo "=== Building for iOS Device ===" + + # First, generate xcodeproj if needed + swift package generate-xcodeproj --skip-extra-files || true + + # Build using xcodebuild with the Package.swift directly + xcodebuild build \ + -scheme RunAnywhere \ + -destination 'generic/platform=iOS' \ + -configuration Release \ + -derivedDataPath build \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGN_IDENTITY="" \ + SKIP_INSTALL=NO \ + BUILD_LIBRARY_FOR_DISTRIBUTION=YES \ + SWIFT_STRICT_CONCURRENCY=minimal \ + COMPILER_INDEX_STORE_ENABLE=NO \ + 2>&1 || { + echo "=== Build failed, checking available schemes ===" + xcodebuild -list || true + echo "" + echo "=== Trying with RunAnywhere-Package scheme ===" + xcodebuild build \ + -scheme RunAnywhere-Package \ + -destination 'generic/platform=iOS' \ + -configuration Release \ + -derivedDataPath build \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGN_IDENTITY="" \ + SWIFT_STRICT_CONCURRENCY=minimal \ + 2>&1 + } + + - name: Upload Build Artifacts + uses: actions/upload-artifact@v4 + continue-on-error: true # Optional artifact - don't fail workflow on transient upload issues + with: + name: swift-sdk-build-${{ steps.version.outputs.version }} + path: ${{ env.SDK_DIR }}/build/Build/Products/ + retention-days: 7 + + # =========================================================================== + # Update Binaries (Downloads from current CI run artifacts) + # =========================================================================== + update-binaries: + name: Update Binaries + needs: build-test + if: inputs.update_binaries == true || github.event_name == 'push' + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Determine Version + id: version + run: | + if [ -n "${{ inputs.version }}" ]; then + VERSION="${{ inputs.version }}" + elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + VERSION="${{ github.event.inputs.version }}" + elif [[ "$GITHUB_REF" == refs/tags/swift-v* ]]; then + VERSION="${GITHUB_REF#refs/tags/swift-v}" + elif [[ "$GITHUB_REF" == refs/tags/v* ]]; then + VERSION="${GITHUB_REF#refs/tags/v}" + else + VERSION="0.0.0-$(git rev-parse --short HEAD)" + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Using version: $VERSION" + + # Download RACommons iOS artifact + - name: Download RACommons iOS + uses: actions/download-artifact@v4 + with: + name: racommons-ios-${{ steps.version.outputs.version }} + path: artifacts/racommons + + # Download LlamaCPP iOS artifact + - name: Download LlamaCPP iOS + uses: actions/download-artifact@v4 + with: + name: backend-llamacpp-ios-${{ steps.version.outputs.version }} + path: artifacts/llamacpp + + # Download ONNX iOS artifact + - name: Download ONNX iOS + uses: actions/download-artifact@v4 + with: + name: backend-onnx-ios-${{ steps.version.outputs.version }} + path: artifacts/onnx + + - name: Setup Binaries from Artifacts + run: | + mkdir -p "${{ env.SDK_DIR }}/Binaries" + + echo "=== Downloaded artifacts ===" + find artifacts -type f | head -20 + find artifacts -type d -name "*.xcframework" + + # Copy all XCFrameworks to SDK Binaries + find artifacts -name "*.xcframework" -type d | while read fw; do + fw_name=$(basename "$fw") + echo "Copying $fw_name to SDK Binaries/" + rm -rf "${{ env.SDK_DIR }}/Binaries/$fw_name" + cp -r "$fw" "${{ env.SDK_DIR }}/Binaries/" + done + + echo "=== Final Binaries ===" + ls -la "${{ env.SDK_DIR }}/Binaries/" + + # Verify we have the required frameworks + REQUIRED_FRAMEWORKS="RACommons.xcframework RABackendLLAMACPP.xcframework RABackendONNX.xcframework" + for fw in $REQUIRED_FRAMEWORKS; do + if [ ! -d "${{ env.SDK_DIR }}/Binaries/$fw" ]; then + echo "❌ Missing required framework: $fw" + exit 1 + fi + echo "✅ Found: $fw" + done + + - name: Update VERSION file + run: | + echo "${{ steps.version.outputs.version }}" > ${{ env.SDK_DIR }}/VERSION + + - name: Update Package.swift Versions + run: | + VERSION="${{ steps.version.outputs.version }}" + + # Update root Package.swift (for external consumers) + ROOT_PACKAGE="Package.swift" + if [ -f "$ROOT_PACKAGE" ]; then + echo "Updating root Package.swift with version $VERSION" + sed -i 's/let commonsVersion = "[^"]*"/let commonsVersion = "'"$VERSION"'"/' "$ROOT_PACKAGE" + sed -i 's/let coreVersion = "[^"]*"/let coreVersion = "'"$VERSION"'"/' "$ROOT_PACKAGE" + else + echo "Root Package.swift not found, skipping" + fi + + # Update nested Package.swift (for local dev reference) + NESTED_PACKAGE="${{ env.SDK_DIR }}/Package.swift" + if [ -f "$NESTED_PACKAGE" ]; then + echo "Updating nested Package.swift with version $VERSION" + sed -i 's/let commonsVersion = "[^"]*"/let commonsVersion = "'"$VERSION"'"/' "$NESTED_PACKAGE" + sed -i 's/let coreVersion = "[^"]*"/let coreVersion = "'"$VERSION"'"/' "$NESTED_PACKAGE" + else + echo "Nested Package.swift not found, skipping" + fi + + - name: Commit Version Updates + continue-on-error: true + if: github.event.inputs.dry_run != 'true' + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + git add ${{ env.SDK_DIR }}/VERSION Package.swift ${{ env.SDK_DIR }}/Package.swift || true + + if git diff --staged --quiet; then + echo "No changes to commit" + exit 0 + fi + + git commit -m "chore(swift): update version for v${{ steps.version.outputs.version }}" + + # Try to push to main branch (may fail for tag-triggered runs, which is OK) + git push origin HEAD:main || git push origin HEAD:master || echo "Push skipped (tag-triggered release)" + + # =========================================================================== + # Create Release + # =========================================================================== + publish: + name: Publish Swift SDK Release + needs: [build-test, update-binaries] + # Skip if dry_run or skip_publish (for unified release) + # Use always() to run even if update-binaries had continue-on-error steps fail + if: | + always() && + needs.build-test.result == 'success' && + (needs.update-binaries.result == 'success' || needs.update-binaries.result == 'skipped') && + inputs.dry_run != true && + inputs.skip_publish != true && + ( + startsWith(github.ref, 'refs/tags/swift-v') || + startsWith(github.ref, 'refs/tags/v') || + github.event_name == 'workflow_dispatch' || + github.event_name == 'push' + ) + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Determine Version + id: version + run: | + # Get version from update-binaries output or derive from tag/input + VERSION="${{ needs.update-binaries.outputs.version }}" + if [ -z "$VERSION" ]; then + if [ -n "${{ inputs.version }}" ]; then + VERSION="${{ inputs.version }}" + elif [[ "$GITHUB_REF" == refs/tags/swift-v* ]]; then + VERSION="${GITHUB_REF#refs/tags/swift-v}" + elif [[ "$GITHUB_REF" == refs/tags/v* ]]; then + VERSION="${GITHUB_REF#refs/tags/v}" + else + VERSION="0.0.0-$(git rev-parse --short HEAD)" + fi + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Using version: $VERSION" + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: swift-v${{ steps.version.outputs.version }} + name: RunAnywhere Swift SDK v${{ steps.version.outputs.version }} + body: | + ## RunAnywhere Swift SDK v${{ steps.version.outputs.version }} + + Privacy-first, on-device AI SDK for iOS and macOS. + + ### Installation + + **Swift Package Manager:** + ```swift + dependencies: [ + .package( + url: "https://github.com/RunanywhereAI/runanywhere-sdks", + from: "${{ steps.version.outputs.version }}" + ) + ] + ``` + + Then add the products you need: + ```swift + .target( + name: "YourApp", + dependencies: [ + .product(name: "RunAnywhere", package: "runanywhere-sdks"), + // Optional: Add specific backends + .product(name: "LlamaCPPRuntime", package: "runanywhere-sdks"), + .product(name: "ONNXRuntime", package: "runanywhere-sdks"), + ] + ) + ``` + + ### Features + - 🧠 **LLM**: On-device text generation via llama.cpp + - 🎤 **STT**: Speech-to-text via Sherpa-ONNX Whisper + - 🔊 **TTS**: Text-to-speech via Sherpa-ONNX Piper + - 🎯 **VAD**: Voice activity detection + - 🔒 **Privacy**: All processing happens on-device + + ### Requirements + - iOS 13.0+ / macOS 10.15+ + - Swift 5.9+ + - Xcode 15.0+ + + ### Documentation + See [README](https://github.com/RunanywhereAI/runanywhere-sdks/tree/main/sdk/runanywhere-swift) + + --- + Built from runanywhere-sdks @ ${{ github.sha }} + draft: false + prerelease: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index d2cade700..63bf7a737 100644 --- a/.gitignore +++ b/.gitignore @@ -1,24 +1,360 @@ -# Dependencies -node_modules/ +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore -# Build output -dist/ +## Build generated +build/ +DerivedData/ + +## Various settings +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata/ + +## Other +*.moved-aside +*.xccheckout +*.xcscmblueprint + +## Obj-C/Swift specific +*.hmap +*.ipa +*.dSYM.zip +*.dSYM + +## Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Swift Package Manager +# +# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. +# Packages/ +# Package.pins +# Package.resolved +.build/ +.swiftpm/ + +# CocoaPods +# +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# +# Pods/ -# IDE -.idea/ +# Carthage +# +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the +# screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/#source-control + +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output + +# Code Injection +# +# After new code Injection tools there's a generated folder /iOSInjectionProject +# https://github.com/johnno1962/injectionforxcode + +iOSInjectionProject/ + +# macOS +.DS_Store + +# IDE - IntelliJ IDEA / Android Studio +# User-specific stuff +.idea/workspace.xml +.idea/tasks.xml +.idea/usage.statistics.xml +.idea/dictionaries +.idea/shelf +.idea/ChatHistory*.xml +.idea/discord.xml + +# Generated files +.idea/caches/ +.idea/libraries/ +.idea/artifacts/ +.idea/compiler.xml +.idea/jarRepositories.xml +.idea/modules.xml +.idea/*.iml +.idea/modules +*.iml +*.ipr + +# Gradle and JDK specific (contain machine-specific paths) +.idea/gradle.xml +.idea/misc.xml + +# Keep shared configurations +!.idea/runConfigurations/ +!.idea/vcs.xml + +# Android Studio specific +.idea/AndroidProjectSystem.xml +.idea/appInsightsSettings.xml +.idea/deploymentTargetSelector.xml +.idea/kotlinc.xml +.idea/migrations.xml + +# Local gradle properties (machine-specific) +gradle.properties + +# But keep project-level gradle properties if needed +!gradle.properties.example + +# Also ignore .idea folders in subdirectories +examples/intellij-plugin-demo/plugin/.idea/ +sdk/runanywhere-kotlin/.idea/ + +# Other IDE files .vscode/ *.swp *.swo +*~ -# OS -.DS_Store -Thumbs.db +# Claude +.claude/ -# Logs +# Temporary files +*.tmp +*.temp + +# Android +*.apk +*.aar +*.ap_ +*.aab +*.dex +*.class +bin/ +gen/ +out/ +.gradle/ +gradle-app.setting +!gradle-wrapper.jar +.gradletasknamecache +local.properties +proguard/ *.log -npm-debug.log* +.navigation/ +captures/ +*.jks +*.keystore +.externalNativeBuild +.cxx/ +google-services.json +freeline.py +freeline/ +freeline_project_description.json +*.hprof +# vcs.xml moved to .idea section above +lint/intermediates/ +lint/generated/ +lint/outputs/ +lint/tmp/ +lint/reports/ +.kotlin/ +*.jar +!gradle/wrapper/gradle-wrapper.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar +hs_err_pid* + +# Flutter/Dart +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +pubspec.lock +.metadata + +# Flutter iOS generated files +**/ios/Flutter/ +**/ios/.symlinks/ +**/ios/Pods/ +**/ios/Podfile +**/ios/Podfile.lock +**/ios/Runner.xcworkspace/ + +# Flutter plugin platform-specific generated code +sdk/runanywhere-flutter/android/ +sdk/runanywhere-flutter/ios/ + +thoughts/shared/plans/ +thoughts +local/ +examples/web/ +sdk/runanywhere-web/ + +# External repositories and distribution +EXTERNAL/ +dist/ + +# MLC-LLM submodule build artifacts (submodule is inside MLC module, similar to llama.cpp) +sdk/runanywhere-kotlin/modules/runanywhere-llm-mlc/mlc-llm/android/mlc4j/build/ +sdk/runanywhere-kotlin/modules/runanywhere-llm-mlc/mlc-llm/android/mlc4j/output/*.jar +sdk/runanywhere-kotlin/modules/runanywhere-llm-mlc/mlc-llm/android/mlc4j/output/arm64-v8a/ +sdk/runanywhere-kotlin/modules/runanywhere-llm-mlc/mlc-llm/android/mlc4j/output/armeabi-v7a/ +sdk/runanywhere-kotlin/modules/runanywhere-llm-mlc/mlc-llm/android/mlc4j/output/x86/ +sdk/runanywhere-kotlin/modules/runanywhere-llm-mlc/mlc-llm/android/mlc4j/output/x86_64/ + +# Keep the output README for documentation +!sdk/runanywhere-kotlin/modules/runanywhere-llm-mlc/mlc-llm/android/mlc4j/output/README.md + +# Configuration files with real values +sdk/runanywhere-swift/Configuration/RunAnywhereConfig.staging.json +sdk/runanywhere-swift/Configuration/RunAnywhereConfig.prod.json +sdk/runanywhere-swift/Configuration/*.private.json +sdk/runanywhere-swift/Configuration/Private/ + +# Development mode configuration (generated during releases) +# Contains: Supabase URL, anon key, and build token +# Real values are ONLY in release tags for SPM distribution +sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Constants/DevelopmentConfig.swift + +# Release secrets file (contains Supabase URL and anon key) +# This file is required for running release_ios_sdk.sh +# For CI, set SUPABASE_URL and SUPABASE_ANON_KEY environment variables +scripts/.release-secrets + +# Keep example files +!sdk/runanywhere-swift/Configuration/RunAnywhereConfig.example.json + +# Kotlin SDK Configuration files with real values +sdk/runanywhere-kotlin/config/dev.json +sdk/runanywhere-kotlin/config/staging.json +sdk/runanywhere-kotlin/config/prod.json +sdk/runanywhere-kotlin/config/*.private.json +sdk/runanywhere-kotlin/build/generated/config/ + +# Keep example and template files +!sdk/runanywhere-kotlin/config/*.example.json +!sdk/runanywhere-kotlin/config/template.json +!sdk/runanywhere-kotlin/config/README.md + +# Native build artifacts (llama.cpp) +sdk/runanywhere-kotlin/native/llama-jni/build/ +sdk/runanywhere-kotlin/modules/runanywhere-llm-llamacpp/src/androidMain/jniLibs/ +sdk/runanywhere-kotlin/modules/runanywhere-llm-llamacpp/src/jvmMain/resources/native/ + +# llama.cpp is a submodule - tracked in .gitmodules +# Build artifacts from llama.cpp +*.o +*.so +*.dylib +*.dll +*.a + +# ============================================================================= +# REACT NATIVE SDK - Native Binaries (npm publish workflow) +# ============================================================================= +# +# IMPORTANT: Binaries are IGNORED by git but INCLUDED in npm packages. +# +# ============================================================================= +# NPM PUBLISH WORKFLOW (follow these steps for each release): +# ============================================================================= +# +# +# 2. REBUILD NATIVE BINARIES with credentials baked in: +# cd sdk/runanywhere-commons +# ./scripts/build-android.sh all arm64-v8a # Android +# ./scripts/build-ios.sh --skip-backends # iOS (RACommons only) +# +# 3. COPY BINARIES to React Native packages: +# # Android +# cp build/android/unified/arm64-v8a/librac_commons.so \ +# ../runanywhere-react-native/packages/core/android/src/main/jniLibs/arm64-v8a/ +# +# # iOS +# rm -rf ../runanywhere-react-native/packages/core/ios/Frameworks/RACommons.xcframework +# cp -R dist/RACommons.xcframework \ +# ../runanywhere-react-native/packages/core/ios/Frameworks/ +# +# 4. VERIFY credentials are baked in: +# strings sdk/runanywhere-react-native/packages/core/android/src/main/jniLibs/arm64-v8a/librac_commons.so | grep supabase +# # Should show your Supabase URL, NOT "YOUR_SUPABASE_PROJECT_URL" +# +# 5. PUBLISH to npm (binaries will be included in tarball): +# cd sdk/runanywhere-react-native/packages/core +# npm version patch && npm publish +# # Repeat for llamacpp and onnx packages +# +# 6. BINARIES stay git-ignored (never commit to GitHub) +# The .so and .xcframework files only live in: +# - Your local machine (for building) +# - npm registry (published packages) +# +# ============================================================================= + +# React Native native binaries (git-ignored, npm-included) +sdk/runanywhere-react-native/packages/*/android/src/main/jniLibs/ +sdk/runanywhere-react-native/packages/*/ios/Frameworks/*.xcframework + +# Backup directories +examples/android/RunAnywhereAI/app/src/main/cpp/llama.cpp.backup/ +comments/ + +# Flutter/Dart +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +pubspec.lock +**/ios/Flutter/ephemeral/ + +# Flutter auto-generated files (contain developer-specific local paths) +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/flutter_export_environment.sh +sdk/runanywhere-swift/Sources/RunAnywhere/Data/Network/DevelopmentConfig.swift + +# Secrets files (API keys, tokens, etc.) +**/Secrets.swift +**/Secrets.xcconfig + +# ============================================================================= +# Local Testing Artifacts (generated by build-backends.sh --setup-sdk) +# ============================================================================= + +# Swift SDK - Local XCFrameworks +sdk/runanywhere-swift/Binaries/*.xcframework + +# Kotlin SDK - Local JNI libs +sdk/runanywhere-kotlin/src/androidMain/jniLibs/ + +# Commons - Build artifacts and third-party dependencies +sdk/runanywhere-commons/build/ +sdk/runanywhere-commons/dist/ +sdk/runanywhere-commons/third_party/ + +# React Native - Local builds +sdk/runanywhere-react-native/packages/*/prebuilt/ + +# Flutter - Local builds +sdk/runanywhere-flutter/ios/*.xcframework +sdk/runanywhere-flutter/android/*.aar -# Environment -.env -.env.local -.env.*.local +# React Native - Pre-built xcframeworks (build artifacts) +sdk/runanywhere-react-native/packages/*/ios/xcframeworks/ +tools/ diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 000000000..9aca269d6 --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +RunAnywhere-Android diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml new file mode 100644 index 000000000..82b9d09db --- /dev/null +++ b/.idea/deviceManager.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 000000000..b9122cc7b --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,51 @@ + + + + diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 000000000..0923c03b3 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/.idea/runConfigurations/1_Build_Android_App.xml b/.idea/runConfigurations/1_Build_Android_App.xml new file mode 100644 index 000000000..4c5fa4346 --- /dev/null +++ b/.idea/runConfigurations/1_Build_Android_App.xml @@ -0,0 +1,24 @@ + + + + + + + true + true + false + false + + + diff --git a/.idea/runConfigurations/2_Run_Android_App.xml b/.idea/runConfigurations/2_Run_Android_App.xml new file mode 100644 index 000000000..8d2a70167 --- /dev/null +++ b/.idea/runConfigurations/2_Run_Android_App.xml @@ -0,0 +1,24 @@ + + + + + + + true + true + false + false + + + diff --git a/.idea/runConfigurations/3_Build_IntelliJ_Plugin.xml b/.idea/runConfigurations/3_Build_IntelliJ_Plugin.xml new file mode 100644 index 000000000..8b8b41381 --- /dev/null +++ b/.idea/runConfigurations/3_Build_IntelliJ_Plugin.xml @@ -0,0 +1,24 @@ + + + + + + + true + true + false + false + + + diff --git a/.idea/runConfigurations/4_Run_IntelliJ_Plugin.xml b/.idea/runConfigurations/4_Run_IntelliJ_Plugin.xml new file mode 100644 index 000000000..77e4d6f90 --- /dev/null +++ b/.idea/runConfigurations/4_Run_IntelliJ_Plugin.xml @@ -0,0 +1,24 @@ + + + + + + + true + true + false + false + + + diff --git a/.idea/runConfigurations/5__Build_SDK___Run_Android_App.xml b/.idea/runConfigurations/5__Build_SDK___Run_Android_App.xml new file mode 100644 index 000000000..7750cbd72 --- /dev/null +++ b/.idea/runConfigurations/5__Build_SDK___Run_Android_App.xml @@ -0,0 +1,36 @@ + + + + + + + + true + true + false + false + + + diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 000000000..dcb6b8c4c --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000..235163dab --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,81 @@ +# Pre-commit hooks configuration +# See https://pre-commit.com for more information + +repos: + # General hooks + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + args: ['--maxkb=1000'] + - id: check-merge-conflict + + # Android Lint temporarily disabled due to compilation issues + # - repo: local + # hooks: + # - id: android-sdk-lint + # name: Android SDK Lint + # entry: bash -c 'cd sdk/runanywhere-android && if [ -f "./gradlew" ] && [ -n "$ANDROID_HOME" -o -f "../../../local.properties" ]; then ./gradlew lint; else echo "Skipping Android lint (no gradle or SDK)"; exit 0; fi' + # language: system + # files: ^sdk/runanywhere-android/.*\.(kt|kts|java|xml)$ + # pass_filenames: false + + # - repo: local + # hooks: + # - id: android-app-lint + # name: Android App Lint + # entry: bash -c 'cd examples/android/RunAnywhereAI && if [ -f "./gradlew" ] && [ -n "$ANDROID_HOME" -o -f "local.properties" ]; then ./gradlew :app:lint; else echo "Skipping Android lint (no gradle or SDK)"; exit 0; fi' + # language: system + # files: ^examples/android/RunAnywhereAI/.*\.(kt|kts|java|xml)$ + # pass_filenames: false + + # SwiftLint for iOS SDK (with autofix) + - repo: local + hooks: + - id: ios-sdk-swiftlint-fix + name: iOS SDK SwiftLint AutoFix + entry: bash -c 'cd sdk/runanywhere-swift && if which swiftlint >/dev/null; then swiftlint --fix --quiet 2>/dev/null || true; else echo "SwiftLint not installed"; fi' + language: system + files: ^sdk/runanywhere-swift/.*\.swift$ + pass_filenames: false + + - id: ios-sdk-swiftlint + name: iOS SDK SwiftLint + entry: bash -c 'cd sdk/runanywhere-swift && if which swiftlint >/dev/null; then swiftlint --strict; else echo "SwiftLint not installed"; exit 0; fi' + language: system + files: ^sdk/runanywhere-swift/.*\.swift$ + pass_filenames: false + + - id: ios-sdk-periphery + name: iOS SDK Periphery (Unused Code) + entry: bash -c 'cd sdk/runanywhere-swift && if which periphery >/dev/null; then OUTPUT=$(periphery scan --targets RunAnywhere --targets ONNXRuntime --targets LlamaCPPRuntime --targets FoundationModelsAdapter --targets FluidAudioDiarization 2>&1); WARNINGS=$(echo "$OUTPUT" | grep "warning:" | grep -v "Associatedtype .* is unused"); if [ -n "$WARNINGS" ]; then echo "$WARNINGS"; echo "Periphery found unused code"; exit 1; fi; exit 0; else echo "Periphery not installed"; exit 0; fi' + language: system + files: ^sdk/runanywhere-swift/.*\.swift$ + pass_filenames: false + + # SwiftLint for iOS App + - repo: local + hooks: + - id: ios-app-swiftlint + name: iOS App SwiftLint + entry: bash -c 'cd examples/ios/RunAnywhereAI && if which swiftlint >/dev/null; then swiftlint --quiet --reporter csv 2>/dev/null || true; else echo "SwiftLint not installed"; exit 0; fi' + language: system + files: ^examples/ios/RunAnywhereAI/.*\.swift$ + pass_filenames: false + +# Configuration +default_language_version: + python: python3 + +# Skip these hooks during CI (they run in dedicated CI jobs) +ci: + skip: + - android-sdk-lint + - android-app-lint + - ios-sdk-swiftlint-fix + - ios-sdk-swiftlint + - ios-sdk-periphery + - ios-app-swiftlint diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..1fad4b6ad --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,789 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +- Focus on SIMPLICITY, and following Clean SOLID principles when writing code. Reusability, Clean architecture(not strictly) style, clear separation of concerns. +### Before starting work. +- Do NOT write ANY MOCK IMPLEMENTATION unless specified otherwise. +- DO NOT PLAN or WRITE any unit tests unless specified otherwise. +- Always in plan mode to make a plan refer to `thoughts/shared/plans/{descriptive_name}.md`. +- After get the plan, make sure you Write the plan to the appropriate file as mentioned in the guide that you referred to. +- If the task require external knowledge or certain package, also research to get latest knowledge (Use Task tool for research) +- Don't over plan it, always think MVP. +- Once you write the plan, firstly ask me to review it. Do not continue until I approve the plan. +### While implementing +- You should update the plan as you work - check `thoughts/shared/plans/{descriptive_name}.md` if you're running an already created plan via `thoughts/shared/plans/{descriptive_name}.md` +- After you complete tasks in the plan, you should update and append detailed descriptions of the changes you made, so following tasks can be easily hand over to other engineers. +- Always make sure that you're using structured types, never use strings directly so that we can keep things consistent and scalable and not make mistakes. +- Read files FULLY to understand the FULL context. Only use offset/limit when the file is large and you are short on context. +- When fixing issues focus on SIMPLICITY, and following Clean SOLID principles, do not add complicated logic unless necessary! +- When looking up something: It's December 2025 FYI + +## Swift specific rules: +- Use the latest Swift 6 APIs always. +- Do not use NSLock as it is outdated. + +## Repository Overview + +This repository contains cross-platform SDKs for the RunAnywhere on-device AI platform. The platform provides intelligent routing between on-device and cloud AI models to optimize for cost and privacy. + +### SDK Implementations +- **Kotlin Multiplatform SDK** (`sdk/runanywhere-kotlin/`) - Cross-platform SDK supporting JVM, Android, and Native platforms +- **Android SDK** (`sdk/runanywhere-android/`) - Kotlin-based SDK for Android +- **iOS SDK** (`sdk/runanywhere-swift/`) - Swift Package Manager-based SDK for iOS/macOS/tvOS/watchOS + +### Example Applications +- **Android Demo** (`examples/android/RunAnywhereAI/`) - Sample Android app demonstrating SDK usage +- **iOS Demo** (`examples/ios/RunAnywhereAI/`) - Sample iOS app demonstrating SDK usage +- **IntelliJ Plugin Demo** (`examples/intellij-plugin-demo/`) - IntelliJ/Android Studio plugin for voice features + +## Common Development Commands + +### Kotlin Multiplatform SDK Development + +```bash +# Navigate to Kotlin SDK +cd sdk/runanywhere-kotlin/ + +# Build Commands (using scripts/sdk.sh) +./scripts/sdk.sh build # Build all platforms (JVM and Android) +./scripts/sdk.sh build-all # Same as 'build' - builds all targets +./scripts/sdk.sh build-all --clean # Clean before building (removes build directories) +./scripts/sdk.sh build-all --deep-clean # Deep clean including Gradle caches +./scripts/sdk.sh build-all --no-clean # Build without any cleanup (default) + +# Individual Platform Builds +./scripts/sdk.sh jvm # Build JVM JAR only +./scripts/sdk.sh android # Build Android AAR only +./scripts/sdk.sh common # Compile common module only + +# Testing +./scripts/sdk.sh test # Run all tests +./scripts/sdk.sh test-jvm # Run JVM tests +./scripts/sdk.sh test-android # Run Android tests + +# Publishing +./scripts/sdk.sh publish # Publish to Maven Local (~/.m2/repository) +./scripts/sdk.sh publish-local # Same as 'publish' + +# Cleanup Options +./scripts/sdk.sh clean # Clean build directories +./scripts/sdk.sh deep-clean # Clean build dirs and Gradle caches + +# Help and Info +./scripts/sdk.sh help # Show all available commands +./scripts/sdk.sh --help # Same as 'help' + +# Direct Gradle Commands (Alternative) +./gradlew build # Build all targets +./gradlew jvmJar # Build JVM JAR +./gradlew assembleDebug # Build Android Debug AAR +./gradlew assembleRelease # Build Android Release AAR +./gradlew clean # Clean build directories +./gradlew publishToMavenLocal # Publish to local Maven +``` + +#### Build Script Features + +The `scripts/sdk.sh` script provides: +- **Automatic cleanup options**: `--clean`, `--deep-clean`, `--no-clean` flags +- **Build verification**: Checks for successful JAR and AAR creation +- **Error handling**: Continues building other targets if one fails +- **Progress indicators**: Clear output showing build status +- **Flexible commands**: Support for multiple build scenarios + +#### Build Output Locations + +After a successful build: +- **JVM JAR**: `build/libs/RunAnywhereKotlinSDK-jvm-0.1.0.jar` +- **Android AAR**: `build/outputs/aar/RunAnywhereKotlinSDK-debug.aar` +- **Maven Local**: `~/.m2/repository/com/runanywhere/sdk/` + +### Android SDK Development + +```bash +# Navigate to Android SDK +cd sdk/runanywhere-android/ + +# Build the SDK +./gradlew build + +# Run lint checks +./gradlew lint + +# Run tests +./gradlew test + +# Clean build +./gradlew clean + +# Build release AAR +./gradlew assembleRelease +``` + +### iOS SDK Development + +```bash +# Navigate to iOS SDK +cd sdk/runanywhere-swift/ + +# Build the SDK +swift build + +# Run tests +swift test + +# Run tests with coverage +swift test --enable-code-coverage + +# Run SwiftLint +swiftlint + +# Build for specific platform +xcodebuild build -scheme RunAnywhere -destination 'platform=iOS Simulator,name=iPhone 15' +``` + +### Android Example App + +```bash +# Navigate to Android example +cd examples/android/RunAnywhereAI/ + +# Build the app +./gradlew build + +# Run lint +./gradlew :app:lint + +# Install on device/emulator +./gradlew installDebug + +# Run tests +./gradlew test +``` + +### iOS Example App + +To get logs for sample app and sdk use this in another terminal: +```bash +log stream --predicate 'subsystem CONTAINS "com.runanywhere"' --info --debug +``` + +For physical device: +```bash +idevicesyslog | grep "com.runanywhere" +``` + +#### Quick Build & Run (Recommended) +```bash +# Navigate to iOS example +cd examples/ios/RunAnywhereAI/ + +# Build and run on simulator (handles dependencies automatically) +./scripts/build_and_run.sh simulator "iPhone 16 Pro" --build-sdk + +# Build and run on connected device +./scripts/build_and_run.sh device + +# Clean build artifacts +./scripts/clean_build_and_run.sh +``` + +#### Manual Setup +```bash +# Install CocoaPods dependencies (required for TensorFlow Lite and ZIPFoundation) +pod install + +# Fix Xcode 16 sandbox issues (required after pod install) +./fix_pods_sandbox.sh + +# After pod install, always open the .xcworkspace file +open RunAnywhereAI.xcworkspace + +# Run SwiftLint +./swiftlint.sh + +# Verify model download URLs +./scripts/verify_urls.sh +``` + +#### Known Issues - Xcode 16 Sandbox +**Error**: `Sandbox: rsync deny(1) file-write-create` +**Fix**: After `pod install`, run `./fix_pods_sandbox.sh` + +### Pre-commit Hooks + +```bash +# Run all pre-commit checks +pre-commit run --all-files + +# Run specific checks +pre-commit run android-sdk-lint --all-files +pre-commit run ios-sdk-swiftlint --all-files +``` + +## Architecture Overview + +### Kotlin Multiplatform SDK Architecture + +The SDK uses Kotlin Multiplatform to share code across JVM, Android, and Native platforms: + +1. **Common Module** (`commonMain/`) - Platform-agnostic business logic + - Core services and interfaces + - Data models and repositories + - Network and authentication logic + - Model management abstractions + +2. **Platform-Specific Implementations**: + - **JVM** (`jvmMain/`) - Desktop/IntelliJ plugin support + - **Android** (`androidMain/`) - Android-specific implementations with Room DB + - **Native** (`nativeMain/`) - Linux, macOS, Windows support + +3. **Key Components**: + - `RunAnywhere.kt` - Main SDK entry point (platform-specific implementations) + - `Services.kt` - Service container and dependency injection + - `STTComponent` - Speech-to-text with Whisper integration + - `VADComponent` - Voice activity detection + - `LLMComponent` - Large language model inference + - `TTSComponent` - Text-to-speech synthesis + - `VLMComponent` - Vision-language model inference + - `VoiceAgentComponent` - Complete voice AI pipeline orchestration + - `SpeakerDiarizationComponent` - Multi-speaker identification + - `WakeWordComponent` - Wake word detection + - `ModelManager` - Model downloading and lifecycle + - `ConfigurationService` - Environment-specific configuration + +### Design Patterns + +1. **Repository Pattern**: Data access abstraction with platform-specific implementations +2. **Service Container**: Centralized dependency injection +3. **Event Bus**: Reactive communication between components +4. **Provider Pattern**: Platform-specific service providers (STT, VAD) + +### Platform Requirements + +**Kotlin Multiplatform SDK:** +- Kotlin: 2.1.21 (upgraded from 2.0.21 to fix compiler issues) +- Gradle: 8.11.1 +- JVM Target: 17 +- Android Min SDK: 24 +- Android Target SDK: 36 + +**iOS SDK:** +- iOS 13.0+ / macOS 10.15+ / tvOS 13.0+ / watchOS 6.0+ +- Swift: 5.9+ +- Xcode: 15.0+ + +## Maven Coordinates + +For IntelliJ/JetBrains plugin development: +```kotlin +dependencies { + implementation("com.runanywhere.sdk:RunAnywhereKotlinSDK-jvm:0.1.0") +} +``` + +Location after local publish: `~/.m2/repository/com/runanywhere/sdk/` + +## CI/CD Pipeline + +GitHub Actions workflows are configured for automated testing and building: + +- **Path-based triggers**: Workflows only run when relevant files change +- **Platform-specific runners**: Ubuntu for Android, macOS for iOS +- **Artifact uploads**: Build outputs and test results are preserved +- **Lint enforcement**: Lint errors fail the build + +Workflows are located in `.github/workflows/`: +- `android-sdk.yml` - Android SDK CI +- `ios-sdk.yml` - iOS SDK CI +- `android-app.yml` - Android example app CI +- `ios-app.yml` - iOS example app CI + +## Kotlin Multiplatform (KMP) SDK - Critical Implementation Rules + +### 🚨 MANDATORY: iOS as Source of Truth +**NEVER make assumptions when implementing KMP code. ALWAYS refer to the iOS implementation as the definitive source of truth.** + +#### Core Principles: +1. **iOS First**: When encountering missing logic, unimplemented features, or unclear requirements in KMP, ALWAYS: + - Check the corresponding iOS implementation + - Copy the iOS logic exactly (head-to-head translation) + - Adapt only for Kotlin syntax, not business logic + +2. **commonMain First**: ALL business logic, protocols, interfaces, and structures MUST be defined in `commonMain/`: + - Interfaces and abstract classes + - Data models and enums + - Business logic and algorithms + - Service contracts and protocols + - Component definitions + - Even platform-specific service interfaces + +3. **Platform Implementation Naming Convention**: Platform-specific implementations MUST use clear prefixes: + - `AndroidTTSService.kt` (not just `TTSService.kt`) + - `JvmTTSService.kt` (not just `TTSServiceImpl.kt`) + - `IosTTSService.kt` (for any iOS-specific bridges) + - `WindowsTTSService.kt`, `LinuxTTSService.kt`, etc. + +#### Implementation Process: +```kotlin +// Step 1: Check iOS implementation (e.g., TTSService.swift) +// Step 2: Define interface in commonMain matching iOS exactly +// commonMain/kotlin/com/runanywhere/sdk/services/tts/TTSService.kt +interface TTSService { + // Match iOS protocol exactly + suspend fun synthesize(text: String, options: TTSOptions): ByteArray + val availableVoices: List +} + +// Step 3: Implement platform-specific versions with clear names +// androidMain/kotlin/com/runanywhere/sdk/services/tts/AndroidTTSService.kt +class AndroidTTSService : TTSService { + // Android-specific implementation +} + +// jvmMain/kotlin/com/runanywhere/sdk/services/tts/JvmTTSService.kt +class JvmTTSService : TTSService { + // JVM-specific implementation +} +``` + +#### Common Mistakes to AVOID: +❌ **DON'T** invent your own logic when something is unclear +❌ **DON'T** put business logic in platform-specific modules +❌ **DON'T** name platform files generically (e.g., `TTSServiceImpl.kt`) +❌ **DON'T** assume behavior - check iOS implementation + +#### Correct Approach: +✅ **DO** check iOS implementation for every feature +✅ **DO** keep all logic in commonMain +✅ **DO** use platform prefixes for all platform files +✅ **DO** translate iOS logic exactly, adapting only syntax + +#### Example: When you see incomplete KMP code: +```kotlin +// KMP has this incomplete method: +fun processAudio(data: ByteArray): String { + // TODO: implement + return "" +} + +// WRONG approach: +fun processAudio(data: ByteArray): String { + // Making assumptions about what it should do + return data.toString() +} + +// CORRECT approach: +// 1. Find iOS AudioProcessor.swift +// 2. Find processAudio method +// 3. Copy exact logic: +fun processAudio(data: ByteArray): String { + // Exact translation of iOS logic + val rms = calculateRMS(data) // If iOS does this + val normalized = normalizeAudio(data, rms) // If iOS does this + return encodeToBase64(normalized) // If iOS does this +} +``` + +### KMP Best Practices + +The Kotlin Multiplatform SDK has been aligned with iOS architecture patterns while leveraging Kotlin's strengths. These best practices ensure consistency, maintainability, and cross-platform compatibility. + +### Architecture Patterns + +#### Component-Based Architecture +Follow the iOS component pattern but adapted to KMP idioms: + +```kotlin +// Base component with lifecycle management +abstract class BaseComponent( + protected val configuration: ComponentConfiguration, + serviceContainer: ServiceContainer? = null +) : Component { + + // Component state tracking + override var state: ComponentState = ComponentState.NOT_INITIALIZED + protected set + + // Service creation (platform-specific via providers) + protected abstract suspend fun createService(): TService + + // Lifecycle methods + suspend fun initialize() { /* ... */ } + override suspend fun cleanup() { /* ... */ } + override suspend fun healthCheck(): ComponentHealth { /* ... */ } +} +``` + +#### Event-Driven Architecture +Use **Flow** instead of AsyncSequence for reactive streams: + +```kotlin +// Central event bus with typed events +object EventBus { + private val _componentEvents = MutableSharedFlow() + val componentEvents: SharedFlow = _componentEvents.asSharedFlow() + + fun publish(event: ComponentEvent) { + _componentEvents.tryEmit(event) + } +} + +// Usage: Listen to component state changes +EventBus.componentEvents + .filterIsInstance() + .collect { event -> + println("Component ${event.component} is ready") + } +``` + +#### Service Container Pattern +Centralized dependency injection with lazy initialization: + +```kotlin +class ServiceContainer { + companion object { + val shared = ServiceContainer() + } + + // Platform abstractions via expect/actual + private val fileSystem by lazy { createFileSystem() } + private val httpClient by lazy { createHttpClient() } + + // Service dependencies + val modelManager: ModelManager by lazy { + ModelManager(fileSystem, downloadService) + } + + // Platform-specific initialization + fun initialize(platformContext: PlatformContext) { + platformContext.initialize() + } +} +``` + +### Code Organization + +#### commonMain Structure +Keep all business logic, interfaces, and data models in `commonMain/`: + +``` +commonMain/ +├── components/ # Component implementations +│ ├── base/ # Base component classes +│ ├── stt/ # Speech-to-text components +│ ├── vad/ # Voice activity detection +│ ├── llm/ # LLM inference components +│ ├── tts/ # Text-to-speech components +│ └── speakerdiarization/ # Speaker diarization +├── data/ # Data layer +│ ├── models/ # Data classes and enums +│ ├── network/ # Network services +│ └── repositories/ # Repository interfaces +├── events/ # Event definitions +├── foundation/ # Core infrastructure +│ ├── ServiceContainer.kt +│ └── SDKLogger.kt +├── models/ # Model management +│ ├── ModelManager.kt +│ └── ModelDownloader.kt +├── memory/ # Memory management +└── generation/ # Text generation services +``` + +#### Platform-Specific Structure +Use `expect/actual` **only** for platform-specific implementations: + +```kotlin +// commonMain - Interface only +expect class PlatformContext { + fun initialize() +} + +expect fun createFileSystem(): FileSystem +expect fun createHttpClient(): HttpClient + +// androidMain - Android implementation +actual class PlatformContext(private val context: Context) { + actual fun initialize() { + // Android-specific setup + } +} + +actual fun createFileSystem(): FileSystem = AndroidFileSystem() +``` + +#### Module Separation Principles + +**Core SDK vs Feature Modules:** +- Core SDK (`commonMain`): Essential services, base components +- Feature modules: Optional capabilities (WhisperKit, external AI providers) +- Plugin architecture: `ModuleRegistry` for runtime registration + +```kotlin +// Plugin registration pattern +object ModuleRegistry { + fun registerSTT(provider: STTServiceProvider) { + sttProviders.add(provider) + } + + fun sttProvider(modelId: String? = null): STTServiceProvider? { + return sttProviders.firstOrNull { it.canHandle(modelId) } + } +} + +// External module registration +// In WhisperKit module: +ModuleRegistry.shared.registerSTT(WhisperSTTProvider()) +``` + +### API Design + +#### Kotlin Idioms for iOS Patterns + +**Flow for Reactive Streams:** +```kotlin +// Instead of AsyncSequence, use Flow +fun transcribeStream(audioFlow: Flow): Flow { + return audioFlow.map { audioData -> + // Process audio chunk + TranscriptionUpdate(text = processAudio(audioData), isFinal = false) + } +} +``` + +**Coroutines for Async Operations:** +```kotlin +// Instead of async/await, use suspend functions +suspend fun loadModel(modelId: String): ModelLoadResult { + return withContext(Dispatchers.IO) { + modelRepository.loadModel(modelId) + } +} +``` + +#### Structured Error Handling + +Use **sealed classes** for type-safe error handling: + +```kotlin +sealed class SDKError : Exception() { + data class InvalidApiKey(override val message: String) : SDKError() + data class NetworkError(override val cause: Throwable?) : SDKError() + data class ComponentNotReady(override val message: String) : SDKError() + data class InvalidState(override val message: String) : SDKError() + + // Result wrapper for operations + sealed class Result { + data class Success(val value: T) : Result() + data class Failure(val error: SDKError) : Result() + } +} +``` + +#### Strong Typing with Data Classes + +**Always use structured types instead of strings:** +```kotlin +// Component configuration +data class STTConfiguration( + val modelId: String, + val language: Language = Language.EN, + val enableVAD: Boolean = true, + val audioFormat: AudioFormat = AudioFormat.PCM_16BIT +) : ComponentConfiguration { + override fun validate() { + require(modelId.isNotBlank()) { "Model ID cannot be blank" } + } +} + +// Enum for type safety +enum class Language(val code: String) { + EN("en"), ES("es"), FR("fr"), DE("de"), JA("ja") +} + +enum class AudioFormat { PCM_16BIT, PCM_24BIT, FLAC, MP3 } +``` + +### Integration Patterns + +#### ModuleRegistry for Plugin Architecture + +**Provider Pattern with Type Safety:** +```kotlin +interface STTServiceProvider { + suspend fun createSTTService(configuration: STTConfiguration): STTService + fun canHandle(modelId: String?): Boolean + val name: String +} + +// Registration in app initialization: +ModuleRegistry.registerSTT(WhisperSTTProvider()) +ModuleRegistry.registerLLM(LlamaProvider()) +``` + +#### EventBus for Component Communication + +**Centralized Event System:** +```kotlin +// Component publishes events +eventBus.publish(ComponentInitializationEvent.ComponentReady( + component = SDKComponent.STT, + modelId = "whisper-base" +)) + +// Other components subscribe to events +EventBus.componentEvents + .filterIsInstance() + .filter { it.component == SDKComponent.STT } + .collect { handleSTTReady(it) } +``` + +#### Provider Pattern for Extensibility + +**Service Creation with Fallbacks:** +```kotlin +class STTComponent(configuration: STTConfiguration) : BaseComponent(configuration) { + + override suspend fun createService(): STTService { + // Try external providers first + val provider = ModuleRegistry.sttProvider(configuration.modelId) + + return provider?.createSTTService(configuration) + ?: throw SDKError.ComponentNotAvailable("No STT provider available for model: ${configuration.modelId}") + } +} +``` + +### Performance Best Practices + +#### Memory Management + +**Component Lifecycle:** +```kotlin +abstract class BaseComponent { + + override suspend fun cleanup() { + // Proper resource cleanup + performCleanup() + service = null + serviceContainer = null // Allow GC + currentStage = null + } + + protected open suspend fun performCleanup() { + // Override for component-specific cleanup + } +} +``` + +**Service Container Memory Management:** +```kotlin +class ServiceContainer { + // Use lazy initialization to avoid memory pressure + val modelManager: ModelManager by lazy { + ModelManager(fileSystem, downloadService) + } + + suspend fun cleanup() { + // Cleanup components in reverse dependency order + sttComponent.cleanup() + vadComponent.cleanup() + } +} +``` + +#### Platform-Specific Optimizations + +**Android optimizations in `androidMain`:** +```kotlin +actual fun createFileSystem(): FileSystem = AndroidFileSystem().apply { + // Configure for Android-specific optimizations + enableFileWatcher = false // Reduce battery usage + cacheStrategy = CacheStrategy.MEMORY_FIRST +} +``` + +**JVM optimizations in `jvmMain`:** +```kotlin +actual fun createHttpClient(): HttpClient = HttpClient { + engine { + // JVM-specific HTTP client configuration + threadsCount = 4 + pipelining = true + } +} +``` + +### Testing Patterns + +#### Component Testing +```kotlin +class STTComponentTest { + @Test + fun `should initialize successfully with valid configuration`() = runTest { + val config = STTConfiguration(modelId = "whisper-base") + val component = STTComponent(config) + + component.initialize() + + assertEquals(ComponentState.READY, component.state) + assertTrue(component.isReady) + } + + @Test + fun `should emit events during initialization`() = runTest { + val events = mutableListOf() + val job = launch { + EventBus.componentEvents.collect { events.add(it) } + } + + val component = STTComponent(STTConfiguration(modelId = "whisper-base")) + component.initialize() + + assertTrue(events.any { it is ComponentInitializationEvent.ComponentReady }) + job.cancel() + } +} +``` + +#### Mock Providers for Testing +```kotlin +class MockSTTProvider : STTServiceProvider { + override val name = "MockSTT" + + override suspend fun createSTTService(configuration: STTConfiguration): STTService { + return MockSTTService() + } + + override fun canHandle(modelId: String?): Boolean = true +} + +// In test setup: +ModuleRegistry.clear() +ModuleRegistry.registerSTT(MockSTTProvider()) +``` + +### Common Patterns Summary + +1. **Business Logic in commonMain**: Keep all core logic platform-agnostic +2. **expect/actual for Platform APIs**: Only use for truly platform-specific code +3. **Flow over AsyncSequence**: Use Kotlin's reactive streams +4. **Coroutines over async/await**: Leverage structured concurrency +5. **Sealed Classes for Errors**: Type-safe error handling +6. **Data Classes for Models**: Strong typing throughout +7. **ModuleRegistry for Plugins**: Extensible architecture +8. **EventBus for Communication**: Decoupled component communication +9. **Service Container for DI**: Centralized dependency management +10. **Component Lifecycle**: Proper initialization and cleanup + +These patterns ensure the Kotlin Multiplatform SDK maintains architectural consistency with the iOS implementation while leveraging Kotlin's strengths for cross-platform development. + +## Development Notes + +- The Kotlin Multiplatform SDK is the primary SDK implementation +- Use `./scripts/sdk.sh` for all SDK operations - it handles configuration and build complexity +- Configuration files (`dev.json`, `staging.json`, `prod.json`) are git-ignored - use example files as templates +- Both SDKs focus on privacy-first, on-device AI with intelligent routing +- Cost optimization is a key feature with real-time tracking +- Pre-commit hooks are configured for code quality enforcement diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..5655f77b4 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,30 @@ +# Code of Conduct + +## Our Pledge + +We are committed to providing a friendly, safe, and welcoming environment for all contributors, regardless of experience level, gender identity, sexual orientation, disability, personal appearance, race, ethnicity, age, religion, or nationality. + +## Our Standards + +**Positive behavior includes:** +- Being respectful and inclusive +- Giving and accepting constructive feedback gracefully +- Focusing on what's best for the community +- Showing empathy towards others + +**Unacceptable behavior includes:** +- Harassment, trolling, or personal attacks +- Publishing others' private information without permission +- Any conduct that could reasonably be considered inappropriate in a professional setting + +## Enforcement + +Project maintainers will take appropriate action in response to unacceptable behavior, including warning, temporary ban, or permanent ban from the community. + +## Reporting + +Report issues by emailing **founders@runanywhere.ai**. All reports will be reviewed and handled confidentially. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.1. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..3b334f53d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,257 @@ +# Contributing to RunAnywhere SDKs + +Thank you for your interest in contributing to RunAnywhere SDKs! We welcome contributions from the community and are grateful for your help in making our SDKs better. + +## 📋 Table of Contents + +- [Code of Conduct](#code-of-conduct) +- [Getting Started](#getting-started) +- [Development Setup](#development-setup) +- [Making Changes](#making-changes) +- [Submitting Changes](#submitting-changes) +- [Code Style](#code-style) +- [Testing](#testing) +- [Reporting Issues](#reporting-issues) + +## 🤝 Code of Conduct + +By participating in this project, you are expected to uphold our code of conduct. Please be respectful and constructive in all interactions. + +## 🚀 Getting Started + +1. **Fork the repository** on GitHub +2. **Clone your fork** locally: + ```bash + git clone https://github.com/your-username/runanywhere-sdks.git + cd runanywhere-sdks + ``` +3. **Set up the development environment** (see [Development Setup](#development-setup)) +4. **Create a new branch** for your feature or bug fix: + ```bash + git checkout -b feature/your-feature-name + ``` + +## 🛠️ Development Setup + +### Prerequisites + +**For Android Development:** +- Android Studio Arctic Fox or later +- JDK 11 or later +- Android SDK with API level 24+ + +**For iOS Development:** +- Xcode 15.0+ +- Swift 5.9+ +- macOS 10.15+ + +### Environment Setup + +1. **Install pre-commit hooks** (recommended): + ```bash + pip install pre-commit + pre-commit install + ``` + +2. **Android SDK Setup:** + ```bash + cd sdk/runanywhere-kotlin/ + ./scripts/sdk.sh android + ``` + +3. **iOS SDK Setup:** + ```bash + cd sdk/runanywhere-swift/ + swift build + ``` + +## 🔧 Making Changes + +### Branch Naming Convention + +- `feature/description` - for new features +- `bugfix/description` - for bug fixes +- `docs/description` - for documentation updates +- `refactor/description` - for code refactoring + +### Commit Message Format + +We follow the [Conventional Commits](https://www.conventionalcommits.org/) specification: + +``` +[optional scope]: + +[optional body] + +[optional footer(s)] +``` + +**Types:** +- `feat`: A new feature +- `fix`: A bug fix +- `docs`: Documentation only changes +- `style`: Changes that do not affect the meaning of the code +- `refactor`: A code change that neither fixes a bug nor adds a feature +- `test`: Adding missing tests or correcting existing tests +- `chore`: Changes to the build process or auxiliary tools + +**Examples:** +``` +feat(android): add cost tracking to generation results +fix(ios): resolve memory leak in model loading +docs: update README with new API examples +``` + +## 📤 Submitting Changes + +1. **Ensure your code follows our style guidelines** (see [Code Style](#code-style)) +2. **Add or update tests** for your changes +3. **Run the test suite** to ensure nothing is broken: + ```bash + # Android + cd sdk/runanywhere-kotlin/ + ./scripts/sdk.sh test-android + ./scripts/sdk.sh lint + + # iOS + cd sdk/runanywhere-swift/ + swift test + swiftlint + ``` +4. **Commit your changes** with a clear commit message +5. **Push to your fork**: + ```bash + git push origin feature/your-feature-name + ``` +6. **Create a Pull Request** on GitHub with: + - Clear title and description + - Reference to any related issues + - Screenshots or examples if applicable + +### Pull Request Guidelines + +- **Keep PRs focused** - one feature or bug fix per PR +- **Write clear descriptions** - explain what and why, not just how +- **Update documentation** if your changes affect the public API +- **Add tests** for new functionality +- **Ensure CI passes** - all checks must be green + +## 🎨 Code Style + +### Android (Kotlin) + +- Follow [Kotlin coding conventions](https://kotlinlang.org/docs/coding-conventions.html) +- Use 4 spaces for indentation +- Maximum line length: 120 characters +- Run `./gradlew ktlintFormat` to auto-format code + +### iOS (Swift) + +- Follow [Swift API Design Guidelines](https://swift.org/documentation/api-design-guidelines/) +- Use 4 spaces for indentation +- Maximum line length: 120 characters +- Run `swiftlint` to check style compliance + +### General Guidelines + +- **Use meaningful names** for variables, functions, and classes +- **Write self-documenting code** with clear intent +- **Add comments** for complex logic or business rules +- **Avoid deep nesting** - prefer early returns and guard clauses +- **Keep functions small** and focused on a single responsibility + +## 🧪 Testing + +### Writing Tests + +- **Unit tests** for business logic and utilities +- **Integration tests** for API interactions +- **UI tests** for critical user flows (example apps) + +### Running Tests + +```bash +# Android SDK tests +cd sdk/runanywhere-kotlin/ +./scripts/sdk.sh test-android + +# iOS SDK tests +cd sdk/runanywhere-swift/ +swift test + +# Android example app tests +cd examples/android/RunAnywhereAI/ +./gradlew test + +# iOS example app tests +cd examples/ios/RunAnywhereAI/ +xcodebuild test -scheme RunAnywhereAI -destination 'platform=iOS Simulator,name=iPhone 15' +``` + +### Test Coverage + +We aim for high test coverage, especially for: +- Core SDK functionality +- API interfaces +- Error handling +- Edge cases + +## 🐛 Reporting Issues + +### Before Reporting + +1. **Search existing issues** to avoid duplicates +2. **Try the latest version** to see if the issue is already fixed +3. **Check the documentation** for known limitations + +### Creating an Issue + +Use our issue templates and provide: + +**For Bug Reports:** +- Clear description of the problem +- Steps to reproduce +- Expected vs actual behavior +- Environment details (OS, SDK version, etc.) +- Code samples or logs if applicable + +**For Feature Requests:** +- Clear description of the desired functionality +- Use cases and examples +- Potential implementation approach (if you have ideas) + +## 📚 Documentation + +When contributing: + +- **Update relevant README files** for API changes +- **Add inline documentation** for public methods +- **Include code examples** for new features +- **Update CHANGELOG.md** for significant changes + +## 🎯 Areas for Contribution + +We especially welcome contributions in these areas: + +- **Performance optimizations** +- **Additional model format support** +- **Improved error handling** +- **Documentation and examples** +- **Test coverage improvements** +- **CI/CD enhancements** + +## ❓ Questions? + +- **General questions**: Open a GitHub Discussion +- **Bug reports**: Create an issue using the bug report template +- **Feature requests**: Create an issue using the feature request template +- **Security issues**: Email security@runanywhere.ai (do not create public issues) + +## 🙏 Recognition + +Contributors will be recognized in our: +- CONTRIBUTORS.md file +- Release notes for significant contributions +- Community spotlights + +Thank you for contributing to RunAnywhere SDKs! 🚀 diff --git a/LICENSE b/LICENSE index 1f7974c86..f58b44f54 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,316 @@ -MIT License +RunAnywhere License +Version 1.0, December 2025 -Copyright (c) 2024 Local Browser Contributors +Copyright (c) 2025 RunAnywhere, Inc. All Rights Reserved. -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +This software and associated documentation files (the "Software") are made +available under the terms of this License. By using, copying, modifying, or +distributing the Software, you agree to be bound by the terms of this License. -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. + +PART I - GRANT OF PERMISSION +============================= + +Subject to the conditions in Part II, permission is hereby granted, free of +charge, to use, copy, modify, merge, publish, and distribute the Software, and +to permit persons to whom the Software is furnished to do so, under the terms +of the Apache License 2.0 (included in Part III below). + + +PART II - CONDITIONS AND RESTRICTIONS +===================================== + +1. PERMITTED USERS + + This free license grant applies only to: + + (a) Individual persons using the Software for personal, educational, + research, or non-commercial purposes; + + (b) Organizations (including parent companies, subsidiaries, and affiliates) + that meet BOTH of the following criteria: + (i) Less than $1,000,000 USD in total funding (including but not + limited to equity investments, debt financing, grants, and loans); + AND + (ii) Less than $1,000,000 USD in gross annual revenue; + + (c) Educational institutions, including but not limited to universities, + colleges, schools, and students enrolled in such institutions; + + (d) Non-profit organizations registered under section 501(c)(3) of the + United States Internal Revenue Code, or equivalent charitable status + in other jurisdictions; + + (e) Government agencies and public sector organizations; + + (f) Open source projects that are themselves licensed under an OSI-approved + open source license. + +2. COMMERCIAL LICENSE REQUIRED + + Any person or organization not meeting the criteria in Section 1 must obtain + a separate commercial license from RunAnywhere, Inc. + + Contact: san@runanywhere.ai for commercial licensing terms. + +3. THRESHOLD TRANSITION + + If an organization initially qualifies under Section 1(b) but subsequently + exceeds either threshold: + + (a) This free license automatically terminates upon exceeding the threshold; + + (b) A commercial license must be obtained within thirty (30) days of + exceeding either threshold; + + (c) For purposes of this license, "gross annual revenue" means total + revenue in the preceding twelve (12) months, calculated on a rolling + basis. + +4. ATTRIBUTION REQUIREMENTS + + All copies or substantial portions of the Software must include: + + (a) This License notice, or a prominent link to it; + + (b) The copyright notice: "Copyright (c) 2025 RunAnywhere, Inc." + + (c) If modifications are made, a statement that the Software has been + modified, including a description of the nature of modifications. + +5. TRADEMARK NOTICE + + This License does not grant permission to use the trade names, trademarks, + service marks, or product names of RunAnywhere, Inc., including "RunAnywhere", + except as required for reasonable and customary use in describing the origin + of the Software. + + +PART III - APACHE LICENSE 2.0 +============================= + +For users meeting the conditions in Part II, the following Apache License 2.0 +terms apply: + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF APACHE LICENSE 2.0 TERMS AND CONDITIONS + + +PART IV - GENERAL PROVISIONS +============================ + +1. ENTIRE AGREEMENT + + This License constitutes the entire agreement between the parties with + respect to the Software and supersedes all prior or contemporaneous + understandings regarding such subject matter. + +2. SEVERABILITY + + If any provision of this License is held to be unenforceable, such + provision shall be reformed only to the extent necessary to make it + enforceable, and the remaining provisions shall continue in full force + and effect. + +3. WAIVER + + No waiver of any term of this License shall be deemed a further or + continuing waiver of such term or any other term. + +4. GOVERNING LAW + + This License shall be governed by and construed in accordance with the + laws of the State of Delaware, United States, without regard to its + conflict of laws provisions. + +5. CONTACT + + For commercial licensing inquiries, questions about this License, or + to report violations, please contact: + + RunAnywhere, Inc. + Email: san@runanywhere.ai + +--- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +RUNANYWHERE, INC. OR ANY CONTRIBUTORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +IN THE SOFTWARE. diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 000000000..b865f22e4 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,86 @@ +{ + "pins" : [ + { + "identity" : "alamofire", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Alamofire/Alamofire.git", + "state" : { + "revision" : "513364f870f6bfc468f9d2ff0a95caccc10044c5", + "version" : "5.10.2" + } + }, + { + "identity" : "bitbytedata", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tsolomko/BitByteData", + "state" : { + "revision" : "cdcdc5177ad536cfb11b95c620f926a81014b7fe", + "version" : "2.0.4" + } + }, + { + "identity" : "devicekit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/devicekit/DeviceKit.git", + "state" : { + "revision" : "581df61650bc457ec00373a592a84be3e7468eb1", + "version" : "5.7.0" + } + }, + { + "identity" : "files", + "kind" : "remoteSourceControl", + "location" : "https://github.com/JohnSundell/Files.git", + "state" : { + "revision" : "e85f2b4a8dfa0f242889f45236f3867d16e40480", + "version" : "4.3.0" + } + }, + { + "identity" : "sentry-cocoa", + "kind" : "remoteSourceControl", + "location" : "https://github.com/getsentry/sentry-cocoa", + "state" : { + "revision" : "16cd512711375fa73f25ae5e373f596bdf4251ae", + "version" : "8.58.0" + } + }, + { + "identity" : "swcompression", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tsolomko/SWCompression.git", + "state" : { + "revision" : "390e0b0af8dd19a600005a242a89e570ff482e09", + "version" : "4.8.6" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "810496cf121e525d660cd0ea89a758740476b85f", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc", + "version" : "3.15.1" + } + }, + { + "identity" : "zipfoundation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/weichsel/ZIPFoundation.git", + "state" : { + "revision" : "22787ffb59de99e5dc1fbfe80b19c97a904ad48d", + "version" : "0.9.20" + } + } + ], + "version" : 2 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 000000000..e212a0e09 --- /dev/null +++ b/Package.swift @@ -0,0 +1,247 @@ +// swift-tools-version: 5.9 +import PackageDescription +import Foundation + +// ============================================================================= +// RunAnywhere SDK - Swift Package Manager Distribution +// ============================================================================= +// +// This is the SINGLE Package.swift for both local development and SPM consumption. +// +// FOR EXTERNAL USERS (consuming via GitHub): +// .package(url: "https://github.com/RunanywhereAI/runanywhere-sdks", from: "0.17.0") +// +// FOR LOCAL DEVELOPMENT: +// 1. Run: cd sdk/runanywhere-swift && ./scripts/build-swift.sh --setup +// 2. Open the example app in Xcode +// 3. The app references this package via relative path +// +// ============================================================================= + +// Get the package directory for relative path resolution +let packageDir = URL(fileURLWithPath: #file).deletingLastPathComponent().path + +// Path to bundled ONNX Runtime dylib with CoreML support (for macOS) +let onnxRuntimeMacOSPath = "\(packageDir)/sdk/runanywhere-swift/Binaries/onnxruntime-macos" + +// ============================================================================= +// BINARY TARGET CONFIGURATION +// ============================================================================= +// +// useLocalBinaries = true → Use local XCFrameworks from sdk/runanywhere-swift/Binaries/ +// For local development. Run first-time setup: +// cd sdk/runanywhere-swift && ./scripts/build-swift.sh --setup +// +// useLocalBinaries = false → Download XCFrameworks from GitHub releases (PRODUCTION) +// For external users via SPM. No setup needed. +// +// To toggle this value, use: +// ./scripts/build-swift.sh --set-local (sets useLocalBinaries = true) +// ./scripts/build-swift.sh --set-remote (sets useLocalBinaries = false) +// +// ============================================================================= +let useLocalBinaries = true // Toggle: true for local dev, false for release + +// Version for remote XCFrameworks (used when testLocal = false) +// Updated automatically by CI/CD during releases +let sdkVersion = "0.17.5" + +let package = Package( + name: "runanywhere-sdks", + platforms: [ + .iOS(.v17), + .macOS(.v14), + .tvOS(.v17), + .watchOS(.v10) + ], + products: [ + // ================================================================= + // Core SDK - always needed + // ================================================================= + .library( + name: "RunAnywhere", + targets: ["RunAnywhere"] + ), + + // ================================================================= + // ONNX Runtime Backend - adds STT/TTS/VAD capabilities + // ================================================================= + .library( + name: "RunAnywhereONNX", + targets: ["ONNXRuntime"] + ), + + // ================================================================= + // LlamaCPP Backend - adds LLM text generation + // ================================================================= + .library( + name: "RunAnywhereLlamaCPP", + targets: ["LlamaCPPRuntime"] + ), + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-crypto.git", from: "3.0.0"), + .package(url: "https://github.com/Alamofire/Alamofire.git", from: "5.9.0"), + .package(url: "https://github.com/JohnSundell/Files.git", from: "4.3.0"), + .package(url: "https://github.com/weichsel/ZIPFoundation.git", from: "0.9.0"), + .package(url: "https://github.com/devicekit/DeviceKit.git", from: "5.6.0"), + .package(url: "https://github.com/tsolomko/SWCompression.git", from: "4.8.0"), + .package(url: "https://github.com/getsentry/sentry-cocoa", from: "8.40.0"), + ], + targets: [ + // ================================================================= + // C Bridge Module - Core Commons + // ================================================================= + .target( + name: "CRACommons", + dependencies: ["RACommonsBinary"], + path: "sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons", + publicHeadersPath: "include" + ), + + // ================================================================= + // C Bridge Module - LlamaCPP Backend Headers + // ================================================================= + .target( + name: "LlamaCPPBackend", + dependencies: ["RABackendLlamaCPPBinary"], + path: "sdk/runanywhere-swift/Sources/LlamaCPPRuntime/include", + publicHeadersPath: "." + ), + + // ================================================================= + // C Bridge Module - ONNX Backend Headers + // ================================================================= + .target( + name: "ONNXBackend", + dependencies: ["RABackendONNXBinary", "ONNXRuntimeBinary"], + path: "sdk/runanywhere-swift/Sources/ONNXRuntime/include", + publicHeadersPath: "." + ), + + // ================================================================= + // Core SDK + // ================================================================= + .target( + name: "RunAnywhere", + dependencies: [ + .product(name: "Crypto", package: "swift-crypto"), + .product(name: "Alamofire", package: "Alamofire"), + .product(name: "Files", package: "Files"), + .product(name: "ZIPFoundation", package: "ZIPFoundation"), + .product(name: "DeviceKit", package: "DeviceKit"), + .product(name: "SWCompression", package: "SWCompression"), + .product(name: "Sentry", package: "sentry-cocoa"), + "CRACommons", + ], + path: "sdk/runanywhere-swift/Sources/RunAnywhere", + exclude: ["CRACommons"], + swiftSettings: [ + .define("SWIFT_PACKAGE") + ], + linkerSettings: [ + .linkedLibrary("c++"), + ] + ), + + // ================================================================= + // ONNX Runtime Backend + // ================================================================= + .target( + name: "ONNXRuntime", + dependencies: [ + "RunAnywhere", + "ONNXBackend", + ], + path: "sdk/runanywhere-swift/Sources/ONNXRuntime", + exclude: ["include"], + linkerSettings: [ + .linkedLibrary("c++"), + .linkedFramework("Accelerate"), + .linkedFramework("CoreML"), + .linkedLibrary("archive"), + .linkedLibrary("bz2"), + ] + ), + + // ================================================================= + // LlamaCPP Runtime Backend + // ================================================================= + .target( + name: "LlamaCPPRuntime", + dependencies: [ + "RunAnywhere", + "LlamaCPPBackend", + ], + path: "sdk/runanywhere-swift/Sources/LlamaCPPRuntime", + exclude: ["include"], + linkerSettings: [ + .linkedLibrary("c++"), + .linkedFramework("Accelerate"), + .linkedFramework("Metal"), + .linkedFramework("MetalKit"), + ] + ), + + ] + binaryTargets() +) + +// ============================================================================= +// BINARY TARGET SELECTION +// ============================================================================= +// Returns local or remote binary targets based on useLocalBinaries setting +func binaryTargets() -> [Target] { + if useLocalBinaries { + // ===================================================================== + // LOCAL DEVELOPMENT MODE + // Use XCFrameworks from sdk/runanywhere-swift/Binaries/ + // Run: cd sdk/runanywhere-swift && ./scripts/build-swift.sh --setup + // ===================================================================== + return [ + .binaryTarget( + name: "RACommonsBinary", + path: "sdk/runanywhere-swift/Binaries/RACommons.xcframework" + ), + .binaryTarget( + name: "RABackendLlamaCPPBinary", + path: "sdk/runanywhere-swift/Binaries/RABackendLLAMACPP.xcframework" + ), + .binaryTarget( + name: "RABackendONNXBinary", + path: "sdk/runanywhere-swift/Binaries/RABackendONNX.xcframework" + ), + .binaryTarget( + name: "ONNXRuntimeBinary", + url: "https://download.onnxruntime.ai/pod-archive-onnxruntime-c-1.17.1.zip", + checksum: "9a2d54d4f503fbb82d2f86361a1d22d4fe015e2b5e9fb419767209cc9ab6372c" + ), + ] + } else { + // ===================================================================== + // PRODUCTION MODE (for external SPM consumers) + // Download XCFrameworks from GitHub releases + // ===================================================================== + return [ + .binaryTarget( + name: "RACommonsBinary", + url: "https://github.com/RunanywhereAI/runanywhere-sdks/releases/download/v\(sdkVersion)/RACommons-ios-v\(sdkVersion).zip", + checksum: "ba367c89a468513b33fb167b5996574a8797bf2c00a21e01579ec59458813559" + ), + .binaryTarget( + name: "RABackendLlamaCPPBinary", + url: "https://github.com/RunanywhereAI/runanywhere-sdks/releases/download/v\(sdkVersion)/RABackendLLAMACPP-ios-v\(sdkVersion).zip", + checksum: "9e58e33e2984f5f0498bdad69387aec306fd2d31e6690eab38b9f1d1a21fb0ca" + ), + .binaryTarget( + name: "RABackendONNXBinary", + url: "https://github.com/RunanywhereAI/runanywhere-sdks/releases/download/v\(sdkVersion)/RABackendONNX-ios-v\(sdkVersion).zip", + checksum: "e760044abfe97d2bde9386d801b0e11421c3782980f4088edce6d6d976f48a84" + ), + .binaryTarget( + name: "ONNXRuntimeBinary", + url: "https://download.onnxruntime.ai/pod-archive-onnxruntime-c-1.17.1.zip", + checksum: "9a2d54d4f503fbb82d2f86361a1d22d4fe015e2b5e9fb419767209cc9ab6372c" + ), + ] + } +} diff --git a/Playground/README.md b/Playground/README.md new file mode 100644 index 000000000..57049d9e5 --- /dev/null +++ b/Playground/README.md @@ -0,0 +1,43 @@ +# Playground + +Interactive demo projects showcasing what you can build with RunAnywhere. + +| Project | Description | Platform | +|---------|-------------|----------| +| [swift-starter-app](swift-starter-app/) | Privacy-first AI demo — LLM Chat, Speech-to-Text, Text-to-Speech, and Voice Pipeline with VAD | iOS (Swift/SwiftUI) | +| [on-device-browser-agent](on-device-browser-agent/) | On-device AI browser automation using WebLLM — no cloud, no API keys, fully private | Chrome Extension (TypeScript/React) | +| [android-use-agent](android-use-agent/) | Autonomous Android agent — navigates phone UI via accessibility + GPT-4o Vision + on-device LLM fallback | Android (Kotlin/Jetpack Compose) | + +## swift-starter-app + +A full-featured iOS app demonstrating the RunAnywhere SDK's core capabilities: + +- **LLM Chat** — On-device conversation with local language models +- **Speech-to-Text** — Whisper-powered transcription +- **Text-to-Speech** — Neural voice synthesis +- **Voice Pipeline** — Integrated STT → LLM → TTS with Voice Activity Detection + +**Requirements:** iOS 17.0+, Xcode 15.0+ + +## on-device-browser-agent + +A Chrome extension that automates browser tasks entirely on-device using WebLLM and WebGPU: + +- **Two-agent architecture** — Planner + Navigator for intelligent task execution +- **DOM and Vision modes** — Text-based or screenshot-based page understanding +- **Site-specific handling** — Optimized workflows for Amazon, YouTube, and more +- **Fully offline** — All AI inference runs locally on GPU after initial model download + +**Requirements:** Chrome 124+ (WebGPU support) + +## android-use-agent + +An autonomous Android agent that navigates your phone's UI to accomplish tasks: + +- **Autonomous UI Navigation** — Taps, types, swipes, and navigates apps to complete goals +- **GPT-4o Vision** — Screenshots sent to GPT-4o for visual screen understanding +- **Unified Tool Calling** — All UI actions registered as OpenAI function calling tools +- **On-Device Fallback** — Falls back to local LLM via RunAnywhere SDK when offline +- **Voice Mode** — Speak goals via on-device Whisper STT, hear progress via TTS + +**Requirements:** Android 8.0+ (API 26), arm64-v8a device, Accessibility service permission diff --git a/Playground/android-use-agent/.gitignore b/Playground/android-use-agent/.gitignore new file mode 100644 index 000000000..ca6a46f93 --- /dev/null +++ b/Playground/android-use-agent/.gitignore @@ -0,0 +1,26 @@ +# Gradle +.gradle/ +build/ +local.properties + +# IDE +.idea/ +*.iml +.DS_Store + +# Android +*.apk +*.aab +*.ap_ +*.dex +/captures + +# Kotlin +*.kotlin_module + +# Local +*.log +.jdk/ +.tmp-sdk/ +android/ +node_modules/ diff --git a/Playground/android-use-agent/README.md b/Playground/android-use-agent/README.md new file mode 100644 index 000000000..1d0862593 --- /dev/null +++ b/Playground/android-use-agent/README.md @@ -0,0 +1,107 @@ +# Android Use Agent + +An autonomous Android agent that navigates your phone's UI to accomplish tasks. Combines on-device AI (RunAnywhere SDK) with GPT-4o Vision for intelligent screen understanding and action execution. + +## Features + +- **Autonomous UI Navigation** — Taps, types, swipes, and navigates apps to complete goals +- **GPT-4o Vision (VLM)** — Screenshots sent to GPT-4o for visual understanding of the screen +- **Unified Tool Calling** — All 14 UI actions (tap, type, swipe, open, etc.) registered as proper OpenAI function calling tools +- **On-Device Fallback** — Falls back to local LLM via RunAnywhere SDK when GPT-4o is unavailable +- **Voice Mode** — Speak your goal via on-device Whisper STT, hear progress via TTS +- **Built-in Tools** — Time, weather, calculator, device info, and more via function calling +- **Smart Pre-Launch** — Detects target app from goal and launches it before the agent loop +- **Loop & Failure Recovery** — Detects repeated actions and failed attempts, adjusts prompts + +## Architecture + +``` +User Goal → Agent Kernel → Screen Parser (Accessibility) → LLM Decision → Action Executor + ↑ ↓ + Action History ←────────────────────────────────────┘ +``` + +**Tool Calling Flow (GPT-4o):** +``` +GPT-4o → tool_calls → ui_* tool? → ActionExecutor → result → next step + → utility tool? → execute → feed result back → GPT-4o decides next +``` + +**Fallback Flow (Local LLM):** +``` +Local LLM → JSON action → parse → Decision → ActionExecutor → result → next step +``` + +## Project Structure + +``` +app/src/main/java/com/runanywhere/agent/ +├── AgentApplication.kt # App config, available models +├── AgentViewModel.kt # UI state, voice mode, STT/TTS +├── MainActivity.kt # Entry point +├── accessibility/ +│ └── AgentAccessibilityService.kt # Screen reading, screenshot capture, action execution +├── actions/ +│ └── AppActions.kt # Intent-based app launching +├── kernel/ +│ ├── ActionExecutor.kt # Executes tap/type/swipe/etc via accessibility +│ ├── ActionHistory.kt # Tracks actions for loop detection +│ ├── AgentKernel.kt # Main agent loop, LLM orchestration +│ ├── GPTClient.kt # OpenAI API client (text + vision + tools) +│ ├── ScreenParser.kt # Parses accessibility tree into element list +│ └── SystemPrompts.kt # All LLM prompts (text, vision, tool-calling) +├── toolcalling/ +│ ├── BuiltInTools.kt # Utility tools (time, weather, calc, etc.) +│ ├── ToolCallingTypes.kt # ToolCall, ToolResult, LLMResponse sealed class +│ ├── ToolCallParser.kt # Parses tags from local LLM +│ ├── ToolPromptFormatter.kt # Converts tools to OpenAI format / local prompt +│ ├── ToolRegistry.kt # Tool registration and execution +│ ├── UIActionContext.kt # Shared mutable screen coordinates +│ └── UIActionTools.kt # 14 UI action tools (tap, type, swipe, etc.) +├── tts/ +│ └── TTSManager.kt # Android TTS wrapper +└── ui/ + ├── AgentScreen.kt # Main Compose UI (text + voice modes) + └── components/ # ModelSelector, StatusBadge +``` + +## Requirements + +- Android 8.0+ (API 26) +- arm64-v8a device +- Accessibility service permission +- (Optional) OpenAI API key for GPT-4o Vision + tool calling + +## Setup + +1. Place RunAnywhere SDK AARs in `libs/` +2. (Optional) Add your OpenAI API key to `gradle.properties`: + ``` + GPT52_API_KEY=sk-your-key-here + ``` +3. Build and install: + ```bash + ./gradlew assembleDebug + adb install -r app/build/outputs/apk/debug/app-debug.apk + ``` +4. Enable the accessibility service in Settings > Accessibility > Android Use Agent +5. Enter a goal (e.g., "Open YouTube and search for lofi music") and tap Start + +## UI Action Tools + +All UI actions are registered as OpenAI function calling tools: + +| Tool | Description | +|------|-------------| +| `ui_tap(index)` | Tap a UI element by index | +| `ui_type(text)` | Type text into focused field | +| `ui_enter()` | Press Enter/Submit | +| `ui_swipe(direction)` | Scroll up/down/left/right | +| `ui_back()` | Press Back button | +| `ui_home()` | Press Home button | +| `ui_open_app(app_name)` | Launch an app by name | +| `ui_long_press(index)` | Long press an element | +| `ui_open_url(url)` | Open a URL in browser | +| `ui_web_search(query)` | Search Google | +| `ui_wait()` | Wait for screen to load | +| `ui_done(reason)` | Signal task completion | diff --git a/Playground/android-use-agent/app/build.gradle.kts b/Playground/android-use-agent/app/build.gradle.kts new file mode 100644 index 000000000..fb09ff7b7 --- /dev/null +++ b/Playground/android-use-agent/app/build.gradle.kts @@ -0,0 +1,106 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") +} + +android { + namespace = "com.runanywhere.agent" + compileSdk = 34 + + defaultConfig { + applicationId = "com.runanywhere.agent" + minSdk = 26 + targetSdk = 34 + versionCode = 1 + versionName = "1.0" + + ndk { + abiFilters += "arm64-v8a" + } + + // Read API key from gradle.properties, falling back to local.properties + val gptKeyFromGradle = (project.findProperty("GPT52_API_KEY") as String? ?: "").trim() + val gptKeyRaw = if (gptKeyFromGradle.isNotEmpty()) { + gptKeyFromGradle + } else { + val localFile = rootProject.file("local.properties") + if (localFile.exists()) { + localFile.readLines() + .firstOrNull { it.startsWith("GPT52_API_KEY=") } + ?.substringAfter("=")?.trim() ?: "" + } else "" + } + val gptKey = gptKeyRaw.replace("\"", "\\\"") + buildConfigField("String", "GPT52_API_KEY", "\"$gptKey\"") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + compose = true + buildConfig = true + } + + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + jniLibs { + pickFirsts += "**/*.so" + } + } +} + +dependencies { + // RunAnywhere SDK (on-device LLM + STT) - local AARs + implementation(files("../libs/RunAnywhereKotlinSDK-release.aar")) + implementation(files("../libs/runanywhere-core-llamacpp-release.aar")) + implementation(files("../libs/runanywhere-core-onnx-release.aar")) + + // Android Core + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") + implementation("androidx.activity:activity-compose:1.8.2") + + // Jetpack Compose + implementation(platform("androidx.compose:compose-bom:2024.06.00")) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-graphics") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0") + + // Coroutines + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") + + // Archive extraction (required by RunAnywhere SDK for model downloads) + implementation("org.apache.commons:commons-compress:1.26.0") + + // Networking + implementation("com.squareup.okhttp3:okhttp:4.12.0") + + // Debug + debugImplementation("androidx.compose.ui:ui-tooling") + debugImplementation("androidx.compose.ui:ui-test-manifest") +} diff --git a/Playground/android-use-agent/app/proguard-rules.pro b/Playground/android-use-agent/app/proguard-rules.pro new file mode 100644 index 000000000..92b22df3d --- /dev/null +++ b/Playground/android-use-agent/app/proguard-rules.pro @@ -0,0 +1,3 @@ +# RunAnywhere Agent ProGuard Rules +-keepattributes *Annotation* +-keep class com.runanywhere.** { *; } diff --git a/Playground/android-use-agent/app/src/main/AndroidManifest.xml b/Playground/android-use-agent/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000..1134b35c8 --- /dev/null +++ b/Playground/android-use-agent/app/src/main/AndroidManifest.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/AgentApplication.kt b/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/AgentApplication.kt new file mode 100644 index 000000000..5096f9f19 --- /dev/null +++ b/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/AgentApplication.kt @@ -0,0 +1,106 @@ +package com.runanywhere.agent + +import android.app.Application +import android.util.Log +import com.runanywhere.sdk.storage.AndroidPlatformContext +import com.runanywhere.sdk.public.RunAnywhere +import com.runanywhere.sdk.public.SDKEnvironment +import com.runanywhere.sdk.public.extensions.registerModel +import com.runanywhere.sdk.llm.llamacpp.LlamaCPP +import com.runanywhere.sdk.core.onnx.ONNX +import com.runanywhere.sdk.core.types.InferenceFramework +import com.runanywhere.sdk.public.extensions.Models.ModelCategory +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +class AgentApplication : Application() { + + companion object { + private const val TAG = "AgentApplication" + + // Available models + val AVAILABLE_MODELS = listOf( + ModelInfo( + id = "smollm2-360m-instruct-q8_0", + name = "SmolLM2 360M (Fast)", + url = "https://huggingface.co/HuggingFaceTB/SmolLM2-360M-Instruct-GGUF/resolve/main/smollm2-360m-instruct-q8_0.gguf", + sizeBytes = 400_000_000L + ), + ModelInfo( + id = "qwen2.5-1.5b-instruct-q4_k_m", + name = "Qwen2.5 1.5B (Best)", + url = "https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct-GGUF/resolve/main/qwen2.5-1.5b-instruct-q4_k_m.gguf", + sizeBytes = 1_200_000_000L + ), + ModelInfo( + id = "lfm2.5-1.2b-instruct-q4_k_m", + name = "LFM2.5 1.2B (Edge)", + url = "https://huggingface.co/LiquidAI/LFM2.5-1.2B-Instruct-GGUF/resolve/main/LFM2.5-1.2B-Instruct-Q4_K_M.gguf", + sizeBytes = 800_000_000L + ) + ) + + const val DEFAULT_MODEL = "qwen2.5-1.5b-instruct-q4_k_m" + const val STT_MODEL_ID = "sherpa-onnx-whisper-tiny.en" + } + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + override fun onCreate() { + super.onCreate() + initializeSDK() + } + + private fun initializeSDK() { + scope.launch { + try { + delay(100) // Allow app to initialize + + Log.i(TAG, "Initializing RunAnywhere SDK...") + AndroidPlatformContext.initialize(applicationContext) + RunAnywhere.initialize(environment = SDKEnvironment.DEVELOPMENT) + RunAnywhere.completeServicesInitialization() + + // Register backends + LlamaCPP.register(priority = 100) + ONNX.register(priority = 90) + + // Register STT model (Whisper Tiny English, ~75MB) + RunAnywhere.registerModel( + id = STT_MODEL_ID, + name = "Whisper Tiny (English)", + url = "https://github.com/RunanywhereAI/sherpa-onnx/releases/download/runanywhere-models-v1/sherpa-onnx-whisper-tiny.en.tar.gz", + framework = InferenceFramework.ONNX, + modality = ModelCategory.SPEECH_RECOGNITION + ) + Log.i(TAG, "Registered STT model: $STT_MODEL_ID") + + // Register available LLM models + AVAILABLE_MODELS.forEach { model -> + RunAnywhere.registerModel( + id = model.id, + name = model.name, + url = model.url, + framework = InferenceFramework.LLAMA_CPP, + memoryRequirement = model.sizeBytes + ) + Log.i(TAG, "Registered model: ${model.id}") + } + + Log.i(TAG, "RunAnywhere SDK initialized successfully") + } catch (e: Exception) { + Log.e(TAG, "Failed to initialize SDK: ${e.message}", e) + } + } + } +} + +data class ModelInfo( + val id: String, + val name: String, + val url: String, + val sizeBytes: Long +) diff --git a/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/AgentViewModel.kt b/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/AgentViewModel.kt new file mode 100644 index 000000000..3e7a0386a --- /dev/null +++ b/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/AgentViewModel.kt @@ -0,0 +1,335 @@ +package com.runanywhere.agent + +import android.app.Application +import android.content.Intent +import android.media.AudioFormat +import android.media.AudioRecord +import android.media.MediaRecorder +import android.provider.Settings +import android.util.Log +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.runanywhere.agent.accessibility.AgentAccessibilityService +import com.runanywhere.agent.kernel.AgentKernel +import com.runanywhere.agent.tts.TTSManager +import com.runanywhere.sdk.public.RunAnywhere +import com.runanywhere.sdk.public.extensions.downloadModel +import com.runanywhere.sdk.public.extensions.loadSTTModel +import com.runanywhere.sdk.public.extensions.transcribe +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.ByteArrayOutputStream + +class AgentViewModel(application: Application) : AndroidViewModel(application) { + + companion object { + private const val TAG = "AgentViewModel" + private const val SAMPLE_RATE = 16000 + private const val CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_MONO + private const val AUDIO_FORMAT = AudioFormat.ENCODING_PCM_16BIT + } + + enum class Status { + IDLE, RUNNING, DONE, ERROR + } + + data class UiState( + val goal: String = "", + val status: Status = Status.IDLE, + val logs: List = emptyList(), + val isServiceEnabled: Boolean = false, + val selectedModelIndex: Int = 1, // Default to Qwen (best) + val availableModels: List = AgentApplication.AVAILABLE_MODELS, + val isRecording: Boolean = false, + val isTranscribing: Boolean = false, + val isSTTModelLoaded: Boolean = false, + val isSTTModelLoading: Boolean = false, + val sttDownloadProgress: Float = 0f, + val isVoiceMode: Boolean = false, + val isSpeaking: Boolean = false + ) + + private val _uiState = MutableStateFlow(UiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val agentKernel = AgentKernel( + context = application, + onLog = { log -> addLog(log) } + ) + + private val ttsManager = TTSManager(application) + + // Agent job + private var agentJob: Job? = null + + // Audio recording state + private var audioRecord: AudioRecord? = null + @Volatile + private var isCapturing = false + private val audioData = ByteArrayOutputStream() + + init { + checkServiceStatus() + } + + fun checkServiceStatus() { + val isEnabled = AgentAccessibilityService.isEnabled(getApplication()) + _uiState.value = _uiState.value.copy(isServiceEnabled = isEnabled) + } + + fun openAccessibilitySettings() { + val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + getApplication().startActivity(intent) + } + + fun setGoal(goal: String) { + _uiState.value = _uiState.value.copy(goal = goal) + } + + fun setModel(index: Int) { + if (index in AgentApplication.AVAILABLE_MODELS.indices) { + _uiState.value = _uiState.value.copy(selectedModelIndex = index) + agentKernel.setModel(AgentApplication.AVAILABLE_MODELS[index].id) + } + } + + fun toggleVoiceMode() { + _uiState.value = _uiState.value.copy(isVoiceMode = !_uiState.value.isVoiceMode) + } + + fun startAgent() { + val goal = _uiState.value.goal.trim() + if (goal.isEmpty()) { + addLog("Please enter a goal") + return + } + + if (!_uiState.value.isServiceEnabled) { + addLog("Accessibility service not enabled") + return + } + + _uiState.value = _uiState.value.copy( + status = Status.RUNNING, + logs = listOf("Starting: $goal") + ) + + agentJob = viewModelScope.launch { + agentKernel.run(goal).collect { event -> + when (event) { + is AgentKernel.AgentEvent.Log -> addLog(event.message) + is AgentKernel.AgentEvent.Step -> addLog("${event.action}: ${event.result}") + is AgentKernel.AgentEvent.Done -> { + addLog(event.message) + _uiState.value = _uiState.value.copy(status = Status.DONE) + } + is AgentKernel.AgentEvent.Error -> { + addLog("ERROR: ${event.message}") + _uiState.value = _uiState.value.copy(status = Status.ERROR) + } + is AgentKernel.AgentEvent.Speak -> { + if (_uiState.value.isVoiceMode) { + ttsManager.speak(event.text) + } + } + } + } + } + } + + fun stopAgent() { + agentKernel.stop() + agentJob?.cancel() + agentJob = null + ttsManager.stop() + addLog("Agent stopped") + _uiState.value = _uiState.value.copy(status = Status.IDLE) + } + + fun clearLogs() { + _uiState.value = _uiState.value.copy(logs = emptyList()) + } + + // ========== STT Methods ========== + + fun loadSTTModelIfNeeded() { + if (_uiState.value.isSTTModelLoaded || _uiState.value.isSTTModelLoading) return + + _uiState.value = _uiState.value.copy(isSTTModelLoading = true, sttDownloadProgress = 0f) + + viewModelScope.launch { + try { + // Download model if needed + var downloadFailed = false + RunAnywhere.downloadModel(AgentApplication.STT_MODEL_ID) + .catch { e -> + Log.e(TAG, "STT download failed: ${e.message}") + addLog("STT download failed: ${e.message}") + downloadFailed = true + } + .collect { progress -> + _uiState.value = _uiState.value.copy(sttDownloadProgress = progress.progress) + } + + if (downloadFailed) { + _uiState.value = _uiState.value.copy(isSTTModelLoading = false) + return@launch + } + + // Load model + RunAnywhere.loadSTTModel(AgentApplication.STT_MODEL_ID) + _uiState.value = _uiState.value.copy( + isSTTModelLoaded = true, + isSTTModelLoading = false + ) + Log.i(TAG, "STT model loaded") + } catch (e: Exception) { + Log.e(TAG, "STT model load failed: ${e.message}", e) + addLog("STT model load failed: ${e.message}") + _uiState.value = _uiState.value.copy(isSTTModelLoading = false) + } + } + } + + @Suppress("MissingPermission") + fun startRecording() { + if (_uiState.value.isRecording) return + + val bufferSize = AudioRecord.getMinBufferSize(SAMPLE_RATE, CHANNEL_CONFIG, AUDIO_FORMAT) + if (bufferSize == AudioRecord.ERROR || bufferSize == AudioRecord.ERROR_BAD_VALUE) { + addLog("Audio recording not supported") + return + } + + try { + audioRecord = AudioRecord( + MediaRecorder.AudioSource.MIC, + SAMPLE_RATE, + CHANNEL_CONFIG, + AUDIO_FORMAT, + bufferSize * 2 + ) + + if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) { + audioRecord?.release() + audioRecord = null + addLog("Failed to initialize audio recorder") + return + } + + audioData.reset() + audioRecord?.startRecording() + isCapturing = true + _uiState.value = _uiState.value.copy(isRecording = true) + + // Capture audio in background thread + viewModelScope.launch(Dispatchers.IO) { + val buffer = ByteArray(bufferSize) + while (isCapturing) { + val read = audioRecord?.read(buffer, 0, buffer.size) ?: 0 + if (read > 0) { + synchronized(audioData) { + audioData.write(buffer, 0, read) + } + } + } + } + } catch (e: SecurityException) { + Log.e(TAG, "Microphone permission denied: ${e.message}") + addLog("Microphone permission required") + audioRecord?.release() + audioRecord = null + } + } + + fun stopRecordingAndTranscribe() { + if (!_uiState.value.isRecording) return + + // Stop capturing + isCapturing = false + audioRecord?.let { record -> + if (record.recordingState == AudioRecord.RECORDSTATE_RECORDING) { + record.stop() + } + record.release() + } + audioRecord = null + + val capturedAudio: ByteArray + synchronized(audioData) { + capturedAudio = audioData.toByteArray() + } + + _uiState.value = _uiState.value.copy(isRecording = false, isTranscribing = true) + + if (capturedAudio.isEmpty()) { + addLog("No audio recorded") + _uiState.value = _uiState.value.copy(isTranscribing = false) + return + } + + viewModelScope.launch { + try { + val result = withContext(Dispatchers.IO) { + RunAnywhere.transcribe(capturedAudio) + } + + if (result.isNotBlank()) { + _uiState.value = _uiState.value.copy( + goal = result.trim(), + isTranscribing = false + ) + // Auto-start agent in voice mode + if (_uiState.value.isVoiceMode) { + startAgent() + } + } else { + if (_uiState.value.isVoiceMode) { + ttsManager.speak("I didn't catch that.") + } + addLog("No speech detected") + _uiState.value = _uiState.value.copy(isTranscribing = false) + } + } catch (e: Exception) { + Log.e(TAG, "Transcription failed: ${e.message}", e) + addLog("Transcription failed: ${e.message}") + _uiState.value = _uiState.value.copy(isTranscribing = false) + } + } + } + + override fun onCleared() { + super.onCleared() + // Clean up TTS + ttsManager.shutdown() + // Clean up audio resources + isCapturing = false + audioRecord?.let { record -> + try { + if (record.recordingState == AudioRecord.RECORDSTATE_RECORDING) { + record.stop() + } + record.release() + } catch (_: Exception) {} + } + audioRecord = null + } + + private fun addLog(message: String) { + val current = _uiState.value.logs.toMutableList() + current.add(message) + // Keep last 50 logs + if (current.size > 50) { + current.removeAt(0) + } + _uiState.value = _uiState.value.copy(logs = current) + } +} diff --git a/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/MainActivity.kt b/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/MainActivity.kt new file mode 100644 index 000000000..03c9712ba --- /dev/null +++ b/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/MainActivity.kt @@ -0,0 +1,36 @@ +package com.runanywhere.agent + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.ui.Modifier +import androidx.lifecycle.viewmodel.compose.viewModel +import com.runanywhere.agent.ui.AgentScreen +import com.runanywhere.agent.ui.theme.RunAnywhereAgentTheme + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + RunAnywhereAgentTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + val viewModel: AgentViewModel = viewModel() + AgentScreen(viewModel = viewModel) + } + } + } + } + + override fun onResume() { + super.onResume() + // Check accessibility service status when returning to app + } +} diff --git a/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/accessibility/AgentAccessibilityService.kt b/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/accessibility/AgentAccessibilityService.kt new file mode 100644 index 000000000..82d41009a --- /dev/null +++ b/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/accessibility/AgentAccessibilityService.kt @@ -0,0 +1,433 @@ +package com.runanywhere.agent.accessibility + +import android.accessibilityservice.AccessibilityService +import android.accessibilityservice.AccessibilityServiceInfo +import android.accessibilityservice.GestureDescription +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Path +import android.graphics.Rect +import android.os.Build +import android.os.Bundle +import android.provider.Settings +import android.util.Log +import android.view.accessibility.AccessibilityEvent +import android.view.accessibility.AccessibilityNodeInfo +import kotlinx.coroutines.suspendCancellableCoroutine +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.FileOutputStream +import java.util.Locale +import kotlin.coroutines.resume + +class AgentAccessibilityService : AccessibilityService() { + + companion object { + private const val TAG = "AgentAccessibility" + @Volatile var instance: AgentAccessibilityService? = null + + fun isEnabled(context: Context): Boolean { + val enabledServices = Settings.Secure.getString( + context.contentResolver, + Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES + ) ?: return false + return enabledServices.contains("${context.packageName}/${AgentAccessibilityService::class.java.name}") + } + } + + override fun onServiceConnected() { + super.onServiceConnected() + instance = this + serviceInfo = serviceInfo.apply { + eventTypes = AccessibilityEvent.TYPES_ALL_MASK + feedbackType = AccessibilityServiceInfo.FEEDBACK_GENERIC + flags = AccessibilityServiceInfo.FLAG_REPORT_VIEW_IDS or + AccessibilityServiceInfo.FLAG_RETRIEVE_INTERACTIVE_WINDOWS or + AccessibilityServiceInfo.FLAG_REQUEST_TOUCH_EXPLORATION_MODE + } + Log.i(TAG, "Accessibility service connected") + } + + override fun onDestroy() { + super.onDestroy() + instance = null + Log.i(TAG, "Accessibility service destroyed") + } + + override fun onAccessibilityEvent(event: AccessibilityEvent?) { + // No-op: agent pulls state on demand + } + + override fun onInterrupt() { + // No-op + } + + // ========== Screen State ========== + + data class ScreenElement( + val index: Int, + val label: String, + val resourceId: String, + val className: String, + val centerX: Int, + val centerY: Int, + val isClickable: Boolean, + val isEditable: Boolean, + val isCheckable: Boolean, + val isChecked: Boolean, + val suggestedAction: String + ) + + data class ScreenState( + val compactText: String, + val elements: List, + val indexToCoords: Map> + ) + + fun getScreenState(maxElements: Int = 30, maxTextLength: Int = 50): ScreenState { + val root = rootInActiveWindow ?: return ScreenState("", emptyList(), emptyMap()) + val elements = mutableListOf() + val indexToCoords = mutableMapOf>() + val lines = mutableListOf() + + traverseForElements(root, elements, maxElements, maxTextLength) + + elements.forEachIndexed { idx, elem -> + indexToCoords[idx] = Pair(elem.centerX, elem.centerY) + + val caps = mutableListOf() + if (elem.isClickable) caps.add("tap") + if (elem.isEditable) caps.add("edit") + if (elem.isCheckable) caps.add(if (elem.isChecked) "checked" else "unchecked") + + val capsStr = if (caps.isNotEmpty()) " [${caps.joinToString(",")}]" else "" + val displayLabel = elem.label.ifEmpty { elem.className.split(".").lastOrNull() ?: "element" } + val typeStr = elem.className.split(".").lastOrNull() ?: "" + lines.add("$idx: $displayLabel ($typeStr) $capsStr".trim()) + } + + return ScreenState(lines.joinToString("\n"), elements, indexToCoords) + } + + private fun traverseForElements( + node: AccessibilityNodeInfo, + elements: MutableList, + maxElements: Int, + maxTextLength: Int + ) { + if (elements.size >= maxElements) return + + val text = node.text?.toString()?.trim()?.take(maxTextLength) ?: "" + val desc = node.contentDescription?.toString()?.trim()?.take(maxTextLength) ?: "" + val label = text.ifEmpty { desc } + val clickable = node.isClickable + val editable = node.isEditable + val checkable = node.isCheckable + val checked = node.isChecked + val enabled = node.isEnabled + val className = node.className?.toString() ?: "" + val resourceId = node.viewIdResourceName?.substringAfterLast("/") ?: "" + + // Include interactive or labeled elements + if (enabled && (label.isNotEmpty() || clickable || editable || checkable)) { + val bounds = Rect() + node.getBoundsInScreen(bounds) + + if (bounds.width() > 0 && bounds.height() > 0) { + val suggestedAction = when { + editable -> "type" + checkable -> "toggle" + clickable -> "tap" + else -> "read" + } + + elements.add( + ScreenElement( + index = elements.size, + label = label, + resourceId = resourceId, + className = className.split(".").lastOrNull() ?: "", + centerX = (bounds.left + bounds.right) / 2, + centerY = (bounds.top + bounds.bottom) / 2, + isClickable = clickable, + isEditable = editable, + isCheckable = checkable, + isChecked = checked, + suggestedAction = suggestedAction + ) + ) + } + } + + for (i in 0 until node.childCount) { + if (elements.size >= maxElements) return + node.getChild(i)?.let { child -> + traverseForElements(child, elements, maxElements, maxTextLength) + } + } + } + + // ========== Actions ========== + + fun tap(x: Int, y: Int, callback: ((Boolean) -> Unit)? = null) { + val path = Path().apply { moveTo(x.toFloat(), y.toFloat()) } + val gesture = GestureDescription.Builder() + .addStroke(GestureDescription.StrokeDescription(path, 0, 100)) + .build() + dispatchGesture(gesture, object : GestureResultCallback() { + override fun onCompleted(gestureDescription: GestureDescription?) { + callback?.invoke(true) + } + override fun onCancelled(gestureDescription: GestureDescription?) { + callback?.invoke(false) + } + }, null) + } + + fun longPress(x: Int, y: Int, durationMs: Long = 1000, callback: ((Boolean) -> Unit)? = null) { + val path = Path().apply { moveTo(x.toFloat(), y.toFloat()) } + val gesture = GestureDescription.Builder() + .addStroke(GestureDescription.StrokeDescription(path, 0, durationMs)) + .build() + dispatchGesture(gesture, object : GestureResultCallback() { + override fun onCompleted(gestureDescription: GestureDescription?) { + callback?.invoke(true) + } + override fun onCancelled(gestureDescription: GestureDescription?) { + callback?.invoke(false) + } + }, null) + } + + fun swipe(direction: String, callback: ((Boolean) -> Unit)? = null) { + val (sx, sy, ex, ey) = when (direction.lowercase()) { + "up", "u" -> listOf(540, 1400, 540, 400) + "down", "d" -> listOf(540, 400, 540, 1400) + "left", "l" -> listOf(900, 800, 200, 800) + "right", "r" -> listOf(200, 800, 900, 800) + else -> listOf(540, 1400, 540, 400) + } + + val path = Path().apply { + moveTo(sx.toFloat(), sy.toFloat()) + lineTo(ex.toFloat(), ey.toFloat()) + } + val gesture = GestureDescription.Builder() + .addStroke(GestureDescription.StrokeDescription(path, 0, 300)) + .build() + dispatchGesture(gesture, object : GestureResultCallback() { + override fun onCompleted(gestureDescription: GestureDescription?) { + callback?.invoke(true) + } + override fun onCancelled(gestureDescription: GestureDescription?) { + callback?.invoke(false) + } + }, null) + } + + fun typeText(text: String): Boolean { + val node = findEditableNode() ?: return false + val args = Bundle().apply { + putCharSequence(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, text) + } + return node.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args) + } + + fun pressEnter(): Boolean { + val root = rootInActiveWindow ?: return false + val focused = findNode(root) { it.isFocused || it.isEditable } + + if (focused != null) { + // API 30+: use ACTION_IME_ENTER which triggers the keyboard's search/done/enter action + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + if (focused.performAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_IME_ENTER.id)) { + return true + } + } + // Fallback: click the focused node (may submit on some fields) + if (focused.performAction(AccessibilityNodeInfo.ACTION_CLICK)) { + return true + } + } + + // Last resort: try global back (unreliable for Enter) + return false + } + + fun pressBack(): Boolean = performGlobalAction(GLOBAL_ACTION_BACK) + + fun pressHome(): Boolean = performGlobalAction(GLOBAL_ACTION_HOME) + + fun openRecents(): Boolean = performGlobalAction(GLOBAL_ACTION_RECENTS) + + fun openNotifications(): Boolean = performGlobalAction(GLOBAL_ACTION_NOTIFICATIONS) + + fun openQuickSettings(): Boolean = performGlobalAction(GLOBAL_ACTION_QUICK_SETTINGS) + + fun takeScreenshot(outputFile: File, callback: (Boolean) -> Unit) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + takeScreenshot( + android.view.Display.DEFAULT_DISPLAY, + mainExecutor, + object : TakeScreenshotCallback { + override fun onSuccess(screenshot: ScreenshotResult) { + try { + val bitmap = Bitmap.wrapHardwareBuffer( + screenshot.hardwareBuffer, + screenshot.colorSpace + ) + if (bitmap != null) { + FileOutputStream(outputFile).use { out -> + bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) + } + callback(true) + } else { + callback(false) + } + screenshot.hardwareBuffer.close() + } catch (e: Exception) { + Log.e(TAG, "Screenshot save failed: ${e.message}") + callback(false) + } + } + + override fun onFailure(errorCode: Int) { + Log.e(TAG, "Screenshot failed with error code: $errorCode") + callback(false) + } + } + ) + } else { + callback(false) + } + } + + /** + * Capture screenshot and return as base64-encoded JPEG string. + * Resizes to half resolution and compresses as JPEG at 60% quality. + */ + suspend fun captureScreenshotBase64(): String? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return null + + return suspendCancellableCoroutine { cont -> + takeScreenshot( + android.view.Display.DEFAULT_DISPLAY, + mainExecutor, + object : TakeScreenshotCallback { + override fun onSuccess(screenshot: ScreenshotResult) { + try { + val hardwareBitmap = Bitmap.wrapHardwareBuffer( + screenshot.hardwareBuffer, + screenshot.colorSpace + ) + screenshot.hardwareBuffer.close() + + if (hardwareBitmap == null) { + cont.resume(null) + return + } + + // Hardware bitmaps can't be manipulated directly + val softBitmap = hardwareBitmap.copy(Bitmap.Config.ARGB_8888, false) + hardwareBitmap.recycle() + + // Resize to half resolution + val scaled = Bitmap.createScaledBitmap( + softBitmap, + softBitmap.width / 2, + softBitmap.height / 2, + true + ) + softBitmap.recycle() + + // Compress to JPEG at 60% quality + val baos = ByteArrayOutputStream() + scaled.compress(Bitmap.CompressFormat.JPEG, 60, baos) + scaled.recycle() + + val base64 = android.util.Base64.encodeToString( + baos.toByteArray(), + android.util.Base64.NO_WRAP + ) + cont.resume(base64) + } catch (e: Exception) { + Log.e(TAG, "Screenshot base64 failed: ${e.message}") + cont.resume(null) + } + } + + override fun onFailure(errorCode: Int) { + Log.e(TAG, "Screenshot capture failed: $errorCode") + cont.resume(null) + } + } + ) + } + } + + // ========== Node Finders ========== + + fun findEditableNode(): AccessibilityNodeInfo? { + val root = rootInActiveWindow ?: return null + return findNode(root) { it.isEditable } + } + + fun findNodeByText(text: String, ignoreCase: Boolean = true): AccessibilityNodeInfo? { + val root = rootInActiveWindow ?: return null + val searchText = if (ignoreCase) text.lowercase(Locale.getDefault()) else text + return findNode(root) { node -> + val nodeText = node.text?.toString() ?: "" + val nodeDesc = node.contentDescription?.toString() ?: "" + val t = if (ignoreCase) nodeText.lowercase(Locale.getDefault()) else nodeText + val d = if (ignoreCase) nodeDesc.lowercase(Locale.getDefault()) else nodeDesc + t.contains(searchText) || d.contains(searchText) + } + } + + fun findNodeByResourceId(resourceId: String): AccessibilityNodeInfo? { + val root = rootInActiveWindow ?: return null + return findNode(root) { node -> + node.viewIdResourceName?.contains(resourceId) == true + } + } + + fun findToggleNode(keyword: String): AccessibilityNodeInfo? { + val root = rootInActiveWindow ?: return null + val lower = keyword.lowercase(Locale.getDefault()) + + val match = findNode(root) { node -> + val text = node.text?.toString()?.lowercase(Locale.getDefault()).orEmpty() + val desc = node.contentDescription?.toString()?.lowercase(Locale.getDefault()).orEmpty() + text.contains(lower) || desc.contains(lower) + } ?: return null + + // Prefer a Switch/CompoundButton in the matched subtree + val toggle = findNode(match) { node -> + val cls = node.className?.toString().orEmpty() + cls.contains("Switch") || cls.contains("CompoundButton") || cls.contains("Toggle") + } + if (toggle != null) return toggle + + // Fallback: clickable node or clickable parent + if (match.isClickable) return match + var parent = match.parent + while (parent != null) { + if (parent.isClickable) return parent + parent = parent.parent + } + return null + } + + private fun findNode( + node: AccessibilityNodeInfo, + predicate: (AccessibilityNodeInfo) -> Boolean + ): AccessibilityNodeInfo? { + if (predicate(node)) return node + for (i in 0 until node.childCount) { + val child = node.getChild(i) ?: continue + val found = findNode(child, predicate) + if (found != null) return found + } + return null + } +} diff --git a/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/actions/AppActions.kt b/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/actions/AppActions.kt new file mode 100644 index 000000000..fbad4a90a --- /dev/null +++ b/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/actions/AppActions.kt @@ -0,0 +1,331 @@ +package com.runanywhere.agent.actions + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.provider.AlarmClock +import android.util.Log + +object AppActions { + private const val TAG = "AppActions" + + // Package names for common apps (Google defaults + Samsung fallbacks) + object Packages { + const val YOUTUBE = "com.google.android.youtube" + const val WHATSAPP = "com.whatsapp" + const val CHROME = "com.android.chrome" + const val GMAIL = "com.google.android.gm" + const val PHONE = "com.google.android.dialer" + const val PHONE_SAMSUNG = "com.samsung.android.dialer" + const val MESSAGES = "com.google.android.apps.messaging" + const val MESSAGES_SAMSUNG = "com.samsung.android.messaging" + const val MAPS = "com.google.android.apps.maps" + const val SPOTIFY = "com.spotify.music" + const val CAMERA = "com.android.camera" + const val CAMERA_SAMSUNG = "com.sec.android.app.camera" + const val CLOCK = "com.google.android.deskclock" + const val CLOCK_SAMSUNG = "com.sec.android.app.clockpackage" + const val CALENDAR = "com.google.android.calendar" + const val CALENDAR_SAMSUNG = "com.samsung.android.calendar" + const val CONTACTS = "com.google.android.contacts" + const val CONTACTS_SAMSUNG = "com.samsung.android.contacts" + const val GALLERY_SAMSUNG = "com.sec.android.gallery3d" + const val CALCULATOR = "com.google.android.calculator" + const val CALCULATOR_SAMSUNG = "com.sec.android.app.popupcalculator" + const val FILES = "com.google.android.apps.nbu.files" + const val FILES_SAMSUNG = "com.sec.android.app.myfiles" + const val INSTAGRAM = "com.instagram.android" + const val TWITTER = "com.twitter.android" + const val TELEGRAM = "org.telegram.messenger" + const val NETFLIX = "com.netflix.mediaclient" + } + + /** Package names that should never be opened by the agent */ + private val BLOCKED_PACKAGES = setOf( + "com.samsung.android.bixby.agent", + "com.samsung.android.bixby.service", + "com.samsung.android.visionintelligence", + "com.samsung.android.bixby.sidebar", + "com.samsung.android.app.routines", + ) + + fun openYouTubeSearch(context: Context, query: String): Boolean { + return try { + val intent = Intent(Intent.ACTION_SEARCH).apply { + setPackage(Packages.YOUTUBE) + putExtra("query", query) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + true + } catch (e: Exception) { + Log.e(TAG, "Failed to open YouTube search: ${e.message}") + // Fallback to web + openYouTubeWeb(context, query) + } + } + + fun openYouTubeWeb(context: Context, query: String): Boolean { + return try { + val url = "https://www.youtube.com/results?search_query=${Uri.encode(query)}" + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + true + } catch (e: Exception) { + Log.e(TAG, "Failed to open YouTube web: ${e.message}") + false + } + } + + fun openWhatsAppChat(context: Context, phoneNumber: String): Boolean { + return try { + // Format phone number (remove spaces, dashes, etc.) + val cleanNumber = phoneNumber.replace("[^0-9+]".toRegex(), "") + val url = "https://wa.me/$cleanNumber" + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + true + } catch (e: Exception) { + Log.e(TAG, "Failed to open WhatsApp chat: ${e.message}") + false + } + } + + fun openWhatsApp(context: Context): Boolean { + return openApp(context, Packages.WHATSAPP) + } + + fun composeEmail( + context: Context, + to: String, + subject: String? = null, + body: String? = null + ): Boolean { + return try { + val intent = Intent(Intent.ACTION_SENDTO).apply { + data = Uri.parse("mailto:$to") + subject?.let { putExtra(Intent.EXTRA_SUBJECT, it) } + body?.let { putExtra(Intent.EXTRA_TEXT, it) } + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + true + } catch (e: Exception) { + Log.e(TAG, "Failed to compose email: ${e.message}") + false + } + } + + fun dialNumber(context: Context, phoneNumber: String): Boolean { + return try { + val cleanNumber = phoneNumber.replace("[^0-9+*#]".toRegex(), "") + val intent = Intent(Intent.ACTION_DIAL, Uri.parse("tel:$cleanNumber")).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + true + } catch (e: Exception) { + Log.e(TAG, "Failed to dial number: ${e.message}") + false + } + } + + fun callNumber(context: Context, phoneNumber: String): Boolean { + return try { + val cleanNumber = phoneNumber.replace("[^0-9+*#]".toRegex(), "") + val intent = Intent(Intent.ACTION_CALL, Uri.parse("tel:$cleanNumber")).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + true + } catch (e: Exception) { + Log.e(TAG, "Failed to call number: ${e.message}") + false + } + } + + fun openMaps(context: Context, query: String): Boolean { + return try { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("geo:0,0?q=${Uri.encode(query)}")).apply { + setPackage(Packages.MAPS) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + true + } catch (e: Exception) { + Log.e(TAG, "Failed to open Maps: ${e.message}") + // Fallback to web + try { + val webIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://www.google.com/maps/search/${Uri.encode(query)}")).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(webIntent) + true + } catch (e2: Exception) { + false + } + } + } + + fun openSpotifySearch(context: Context, query: String): Boolean { + return try { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("spotify:search:${Uri.encode(query)}")).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + true + } catch (e: Exception) { + Log.e(TAG, "Failed to open Spotify: ${e.message}") + false + } + } + + fun sendSMS(context: Context, phoneNumber: String, message: String? = null): Boolean { + return try { + val cleanNumber = phoneNumber.replace("[^0-9+]".toRegex(), "") + val intent = Intent(Intent.ACTION_SENDTO, Uri.parse("smsto:$cleanNumber")).apply { + message?.let { putExtra("sms_body", it) } + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + true + } catch (e: Exception) { + Log.e(TAG, "Failed to open SMS: ${e.message}") + false + } + } + + fun openCamera(context: Context): Boolean { + return try { + val intent = Intent(android.provider.MediaStore.ACTION_IMAGE_CAPTURE).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + true + } catch (e: Exception) { + Log.e(TAG, "Failed to open camera: ${e.message}") + openApp(context, Packages.CAMERA) + } + } + + fun openClock(context: Context): Boolean { + if (openApp(context, Packages.CLOCK)) return true + val knownPackages = listOf("com.android.deskclock", "com.sec.android.app.clockpackage") + if (knownPackages.any { openApp(context, it) }) return true + + return try { + val intent = Intent(AlarmClock.ACTION_SHOW_TIMERS).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + true + } catch (e: Exception) { + Log.e(TAG, "Failed to open clock: ${e.message}") + false + } + } + + fun setTimer(context: Context, totalSeconds: Int, label: String? = null, skipUi: Boolean = false): Boolean { + return try { + val intent = Intent(AlarmClock.ACTION_SET_TIMER).apply { + putExtra(AlarmClock.EXTRA_LENGTH, totalSeconds) + putExtra(AlarmClock.EXTRA_SKIP_UI, skipUi) + label?.let { putExtra(AlarmClock.EXTRA_MESSAGE, it.take(30)) } + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + true + } catch (e: Exception) { + Log.e(TAG, "Failed to set timer: ${e.message}") + openClock(context) + } + } + + /** + * Open X (formerly Twitter) using explicit component intent. + * getLaunchIntentForPackage fails for X because it has 20+ LAUNCHER activity-aliases + * (subscription icon variants) that confuse the PackageManager resolver. + */ + fun openX(context: Context): Boolean { + return try { + val intent = Intent(Intent.ACTION_MAIN).apply { + addCategory(Intent.CATEGORY_LAUNCHER) + component = ComponentName(Packages.TWITTER, "com.twitter.android.StartActivity") + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + true + } catch (e: Exception) { + Log.e(TAG, "Failed to open X via StartActivity: ${e.message}") + // Fallback to generic package launch + openApp(context, Packages.TWITTER) + } + } + + fun openApp(context: Context, packageName: String): Boolean { + if (packageName in BLOCKED_PACKAGES) return false + return try { + val pm = context.packageManager + val intent = pm.getLaunchIntentForPackage(packageName) + intent?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + if (intent != null) { + context.startActivity(intent) + true + } else { + false + } + } catch (e: Exception) { + Log.e(TAG, "Failed to open app: ${e.message}") + false + } + } + + /** + * Try to open an app by name using fuzzy package/label matching. + * Excludes Bixby and other Samsung system apps from results. + */ + fun openAppByName(context: Context, appName: String): Boolean { + val pm = context.packageManager + val intent = Intent(Intent.ACTION_MAIN).apply { + addCategory(Intent.CATEGORY_LAUNCHER) + } + val apps = pm.queryIntentActivities(intent, 0) + val target = appName.lowercase().replace("[^a-z0-9]".toRegex(), "") + + // Filter out blocked packages and find best match + val candidates = apps.filter { info -> + info.activityInfo.packageName !in BLOCKED_PACKAGES && + !info.activityInfo.packageName.contains("bixby", ignoreCase = true) + } + + // Try exact label match first + val exactMatch = candidates.firstOrNull { info -> + val label = info.loadLabel(pm)?.toString().orEmpty() + label.equals(appName, ignoreCase = true) + } + + // Then try contains match + val containsMatch = exactMatch ?: candidates.firstOrNull { info -> + val label = info.loadLabel(pm)?.toString().orEmpty() + val labelNorm = label.lowercase().replace("[^a-z0-9]".toRegex(), "") + val pkgNorm = info.activityInfo.packageName.lowercase().replace("[^a-z0-9]".toRegex(), "") + labelNorm.contains(target) || target.contains(labelNorm) || pkgNorm.contains(target) + } + + val match = containsMatch ?: return false + + val launch = pm.getLaunchIntentForPackage(match.activityInfo.packageName) + launch?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + if (launch != null) { + context.startActivity(launch) + Log.d(TAG, "Opened app: ${match.loadLabel(pm)} (${match.activityInfo.packageName})") + return true + } + return false + } +} diff --git a/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/kernel/ActionExecutor.kt b/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/kernel/ActionExecutor.kt new file mode 100644 index 000000000..5a121cf9b --- /dev/null +++ b/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/kernel/ActionExecutor.kt @@ -0,0 +1,278 @@ +package com.runanywhere.agent.kernel + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.util.Log +import com.runanywhere.agent.accessibility.AgentAccessibilityService +import com.runanywhere.agent.actions.AppActions +import kotlinx.coroutines.delay +import kotlinx.coroutines.suspendCancellableCoroutine +import java.io.File +import kotlin.coroutines.resume + +class ActionExecutor( + private val context: Context, + private val accessibilityService: () -> AgentAccessibilityService?, + private val onLog: (String) -> Unit +) { + companion object { + private const val TAG = "ActionExecutor" + } + + suspend fun execute(decision: Decision, indexToCoords: Map>): ExecutionResult { + val service = accessibilityService() + if (service == null && decision.action !in listOf("open", "url", "search", "done", "wait")) { + return ExecutionResult(false, "Accessibility service not connected") + } + + return when (decision.action) { + "open" -> executeOpenApp(decision) + "tap" -> executeTap(service!!, decision, indexToCoords) + "type" -> executeType(service!!, decision) + "enter" -> executeEnter(service!!) + "swipe" -> executeSwipe(service!!, decision) + "long" -> executeLongPress(service!!, decision, indexToCoords) + "back" -> executeBack(service!!) + "home" -> executeHome(service!!) + "url" -> executeOpenUrl(decision) + "search" -> executeWebSearch(decision) + "notif" -> executeOpenNotifications(service!!) + "quick" -> executeOpenQuickSettings(service!!) + "screenshot" -> executeScreenshot(service!!) + "wait" -> executeWait() + "done" -> ExecutionResult(true, "Goal complete") + else -> ExecutionResult(false, "Unknown action: ${decision.action}") + } + } + + private suspend fun executeTap( + service: AgentAccessibilityService, + decision: Decision, + indexToCoords: Map> + ): ExecutionResult { + val coords = indexToCoords[decision.elementIndex] + ?: return ExecutionResult(false, "Invalid element index: ${decision.elementIndex}") + + onLog("Tapping element ${decision.elementIndex} at (${coords.first}, ${coords.second})") + + return suspendCancellableCoroutine { cont -> + service.tap(coords.first, coords.second) { success -> + if (success) { + cont.resume(ExecutionResult(true, "Tapped element ${decision.elementIndex}")) + } else { + cont.resume(ExecutionResult(false, "Tap failed")) + } + } + } + } + + private fun executeType(service: AgentAccessibilityService, decision: Decision): ExecutionResult { + val text = decision.text ?: return ExecutionResult(false, "No text to type") + onLog("Typing: $text") + val success = service.typeText(text) + return ExecutionResult(success, if (success) "Typed: $text" else "Type failed - no editable field") + } + + private fun executeEnter(service: AgentAccessibilityService): ExecutionResult { + onLog("Pressing Enter") + val success = service.pressEnter() + return ExecutionResult(success, if (success) "Pressed Enter" else "Enter failed") + } + + private suspend fun executeSwipe( + service: AgentAccessibilityService, + decision: Decision + ): ExecutionResult { + val direction = decision.direction ?: "u" + val dirName = when (direction) { + "u" -> "up" + "d" -> "down" + "l" -> "left" + "r" -> "right" + else -> direction + } + onLog("Swiping $dirName") + + return suspendCancellableCoroutine { cont -> + service.swipe(direction) { success -> + cont.resume(ExecutionResult(success, if (success) "Swiped $dirName" else "Swipe failed")) + } + } + } + + private suspend fun executeLongPress( + service: AgentAccessibilityService, + decision: Decision, + indexToCoords: Map> + ): ExecutionResult { + val coords = indexToCoords[decision.elementIndex] + ?: return ExecutionResult(false, "Invalid element index: ${decision.elementIndex}") + + onLog("Long pressing element ${decision.elementIndex}") + + return suspendCancellableCoroutine { cont -> + service.longPress(coords.first, coords.second) { success -> + cont.resume(ExecutionResult(success, if (success) "Long pressed" else "Long press failed")) + } + } + } + + private fun executeBack(service: AgentAccessibilityService): ExecutionResult { + onLog("Going back") + val success = service.pressBack() + return ExecutionResult(success, if (success) "Went back" else "Back failed") + } + + private fun executeHome(service: AgentAccessibilityService): ExecutionResult { + onLog("Going home") + val success = service.pressHome() + return ExecutionResult(success, if (success) "Went home" else "Home failed") + } + + private fun executeOpenUrl(decision: Decision): ExecutionResult { + val url = decision.url ?: return ExecutionResult(false, "No URL provided") + onLog("Opening URL: $url") + return try { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + ExecutionResult(true, "Opened URL: $url") + } catch (e: Exception) { + Log.e(TAG, "Failed to open URL: ${e.message}") + ExecutionResult(false, "Failed to open URL: ${e.message}") + } + } + + private fun executeWebSearch(decision: Decision): ExecutionResult { + val query = decision.query ?: return ExecutionResult(false, "No search query provided") + onLog("Searching: $query") + return try { + val searchUrl = "https://www.google.com/search?q=${Uri.encode(query)}" + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(searchUrl)).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + ExecutionResult(true, "Searched: $query") + } catch (e: Exception) { + Log.e(TAG, "Failed to search: ${e.message}") + ExecutionResult(false, "Failed to search: ${e.message}") + } + } + + private fun executeOpenNotifications(service: AgentAccessibilityService): ExecutionResult { + onLog("Opening notifications") + val success = service.openNotifications() + return ExecutionResult(success, if (success) "Opened notifications" else "Failed to open notifications") + } + + private fun executeOpenQuickSettings(service: AgentAccessibilityService): ExecutionResult { + onLog("Opening quick settings") + val success = service.openQuickSettings() + return ExecutionResult(success, if (success) "Opened quick settings" else "Failed to open quick settings") + } + + private suspend fun executeScreenshot(service: AgentAccessibilityService): ExecutionResult { + onLog("Taking screenshot") + val file = File(context.cacheDir, "screenshot_${System.currentTimeMillis()}.png") + + return suspendCancellableCoroutine { cont -> + service.takeScreenshot(file) { success -> + if (success) { + cont.resume(ExecutionResult(true, "Screenshot saved: ${file.absolutePath}")) + } else { + cont.resume(ExecutionResult(false, "Screenshot failed")) + } + } + } + } + + private fun executeOpenApp(decision: Decision): ExecutionResult { + val appName = decision.text ?: return ExecutionResult(false, "No app name provided") + onLog("Opening app: $appName") + + // Try known app shortcuts first, with Samsung fallbacks + val appLower = appName.lowercase() + val success = when { + appLower.contains("youtube") -> AppActions.openApp(context, AppActions.Packages.YOUTUBE) + appLower.contains("chrome") || appLower.contains("browser") -> AppActions.openApp(context, AppActions.Packages.CHROME) + appLower.contains("whatsapp") -> AppActions.openApp(context, AppActions.Packages.WHATSAPP) + appLower.contains("instagram") -> AppActions.openApp(context, AppActions.Packages.INSTAGRAM) + appLower.contains("twitter") || appLower.trim() == "x" -> AppActions.openX(context) + appLower.contains("telegram") -> AppActions.openApp(context, AppActions.Packages.TELEGRAM) + appLower.contains("netflix") -> AppActions.openApp(context, AppActions.Packages.NETFLIX) + appLower.contains("gmail") || appLower.contains("email") -> AppActions.openApp(context, AppActions.Packages.GMAIL) + appLower.contains("maps") || appLower.contains("map") -> AppActions.openApp(context, AppActions.Packages.MAPS) + appLower.contains("spotify") -> AppActions.openApp(context, AppActions.Packages.SPOTIFY) + appLower.contains("clock") || appLower.contains("timer") || appLower.contains("alarm") -> AppActions.openClock(context) + appLower.contains("camera") -> + AppActions.openCamera(context) || AppActions.openApp(context, AppActions.Packages.CAMERA_SAMSUNG) + appLower.contains("phone") || appLower.contains("dialer") -> + AppActions.openApp(context, AppActions.Packages.PHONE) || AppActions.openApp(context, AppActions.Packages.PHONE_SAMSUNG) + appLower.contains("messages") || appLower.contains("sms") -> + AppActions.openApp(context, AppActions.Packages.MESSAGES) || AppActions.openApp(context, AppActions.Packages.MESSAGES_SAMSUNG) + appLower.contains("calendar") -> + AppActions.openApp(context, AppActions.Packages.CALENDAR) || AppActions.openApp(context, AppActions.Packages.CALENDAR_SAMSUNG) + appLower.contains("contacts") -> + AppActions.openApp(context, AppActions.Packages.CONTACTS) || AppActions.openApp(context, AppActions.Packages.CONTACTS_SAMSUNG) + appLower.contains("gallery") || appLower.contains("photos") -> + AppActions.openApp(context, AppActions.Packages.GALLERY_SAMSUNG) + appLower.contains("calculator") -> + AppActions.openApp(context, AppActions.Packages.CALCULATOR) || AppActions.openApp(context, AppActions.Packages.CALCULATOR_SAMSUNG) + appLower.contains("files") || appLower.contains("file manager") -> + AppActions.openApp(context, AppActions.Packages.FILES) || AppActions.openApp(context, AppActions.Packages.FILES_SAMSUNG) + appLower.contains("setting") -> { + openSettings() + true + } + else -> AppActions.openAppByName(context, appName) // Bixby-safe fuzzy search + } + + return ExecutionResult(success, if (success) "Opened $appName" else "Failed to open $appName") + } + + private suspend fun executeWait(): ExecutionResult { + onLog("Waiting...") + delay(2000) + return ExecutionResult(true, "Waited 2 seconds") + } + + fun openSettings(settingType: String? = null): Boolean { + val action = when (settingType?.lowercase()) { + "bluetooth" -> android.provider.Settings.ACTION_BLUETOOTH_SETTINGS + "wifi", "wi-fi" -> android.provider.Settings.ACTION_WIFI_SETTINGS + "display" -> android.provider.Settings.ACTION_DISPLAY_SETTINGS + "sound", "audio" -> android.provider.Settings.ACTION_SOUND_SETTINGS + "battery" -> android.provider.Settings.ACTION_BATTERY_SAVER_SETTINGS + "location" -> android.provider.Settings.ACTION_LOCATION_SOURCE_SETTINGS + else -> android.provider.Settings.ACTION_SETTINGS + } + + return try { + val intent = Intent(action).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + onLog("Opened settings: ${settingType ?: "main"}") + true + } catch (e: Exception) { + Log.e(TAG, "Failed to open settings: ${e.message}") + false + } + } +} + +data class Decision( + val action: String, + val elementIndex: Int? = null, + val text: String? = null, + val direction: String? = null, + val url: String? = null, + val query: String? = null +) + +data class ExecutionResult( + val success: Boolean, + val message: String +) diff --git a/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/kernel/ActionHistory.kt b/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/kernel/ActionHistory.kt new file mode 100644 index 000000000..a9f433bb8 --- /dev/null +++ b/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/kernel/ActionHistory.kt @@ -0,0 +1,92 @@ +package com.runanywhere.agent.kernel + +data class ActionRecord( + val step: Int, + val action: String, + val target: String?, + val result: String?, + val success: Boolean +) + +class ActionHistory { + private val history = mutableListOf() + private var stepCounter = 0 + + fun record(action: String, target: String? = null, result: String? = null, success: Boolean = true) { + stepCounter++ + history.add(ActionRecord(stepCounter, action, target, result, success)) + } + + fun formatForPrompt(): String { + if (history.isEmpty()) return "" + + val lines = history.takeLast(8).map { record -> + val targetStr = record.target?.let { " \"$it\"" } ?: "" + val resultStr = record.result?.let { " -> $it" } ?: "" + val status = if (record.success) "OK" else "FAILED" + "Step ${record.step}: ${record.action}$targetStr $status$resultStr" + } + + return "\n\nPREVIOUS_ACTIONS:\n${lines.joinToString("\n")}" + } + + fun getLastActionResult(): String? { + return history.lastOrNull()?.let { record -> + val targetStr = record.target?.let { "\"$it\"" } ?: "" + val resultStr = record.result ?: "" + "${record.action} $targetStr -> $resultStr" + } + } + + fun isRepetitive(action: String, target: String?): Boolean { + if (history.isEmpty()) return false + + // Check if the last 2 actions are the same (exact consecutive repeat) + val recentActions = history.takeLast(2) + if (recentActions.size >= 2) { + val allSame = recentActions.all { it.action == action && it.target == target } + if (allSame) return true + } + + // Check for alternating patterns in last 4 actions (A→B→A→B) + val last4 = history.takeLast(4) + if (last4.size >= 4) { + val a = last4[0]; val b = last4[1]; val c = last4[2]; val d = last4[3] + if (a.action == c.action && a.target == c.target && + b.action == d.action && b.target == d.target) { + return true + } + } + + // Check if the same action+target appears 3+ times in last 6 actions + val last6 = history.takeLast(6) + val sameCount = last6.count { it.action == action && it.target == target } + if (sameCount >= 3) return true + + return false + } + + fun getLastAction(): ActionRecord? = history.lastOrNull() + + fun hadRecentFailure(): Boolean { + return history.takeLast(2).any { !it.success } + } + + fun clear() { + history.clear() + stepCounter = 0 + } + + fun size(): Int = history.size + + fun recordToolCall(toolName: String, arguments: String, result: String, success: Boolean) { + stepCounter++ + history.add(ActionRecord( + step = stepCounter, + action = "tool:$toolName", + target = arguments, + result = result, + success = success + )) + } +} diff --git a/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/kernel/AgentKernel.kt b/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/kernel/AgentKernel.kt new file mode 100644 index 000000000..2daa1d813 --- /dev/null +++ b/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/kernel/AgentKernel.kt @@ -0,0 +1,770 @@ +package com.runanywhere.agent.kernel + +import android.content.Context +import android.util.Log +import com.runanywhere.agent.AgentApplication +import com.runanywhere.agent.BuildConfig +import com.runanywhere.agent.accessibility.AgentAccessibilityService +import com.runanywhere.agent.actions.AppActions +import com.runanywhere.agent.toolcalling.BuiltInTools +import com.runanywhere.agent.toolcalling.LLMResponse +import com.runanywhere.agent.toolcalling.ToolCall +import com.runanywhere.agent.toolcalling.ToolCallParser +import com.runanywhere.agent.toolcalling.ToolDefinition +import com.runanywhere.agent.toolcalling.ToolHandler +import com.runanywhere.agent.toolcalling.ToolPromptFormatter +import com.runanywhere.agent.toolcalling.ToolRegistry +import com.runanywhere.agent.toolcalling.ToolResult +import com.runanywhere.agent.toolcalling.UIActionContext +import com.runanywhere.agent.toolcalling.UIActionTools +import com.runanywhere.sdk.public.RunAnywhere +import com.runanywhere.sdk.public.extensions.LLM.LLMGenerationOptions +import com.runanywhere.sdk.public.extensions.LLM.StructuredOutputConfig +import com.runanywhere.sdk.public.extensions.downloadModel +import com.runanywhere.sdk.public.extensions.generate +import com.runanywhere.sdk.public.extensions.loadLLMModel +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.withContext +import org.json.JSONException +import org.json.JSONObject +import java.util.regex.Pattern + +class AgentKernel( + private val context: Context, + private val onLog: (String) -> Unit +) { + companion object { + private const val TAG = "AgentKernel" + private const val MAX_STEPS = 30 + private const val MAX_DURATION_MS = 180_000L + private const val STEP_DELAY_MS = 1500L + private const val MAX_TOOL_ITERATIONS = 5 + } + + private val history = ActionHistory() + private val screenParser = ScreenParser { AgentAccessibilityService.instance } + private val actionExecutor = ActionExecutor( + context = context, + accessibilityService = { AgentAccessibilityService.instance }, + onLog = onLog + ) + + private val gptClient = GPTClient( + apiKeyProvider = { BuildConfig.GPT52_API_KEY }, + onLog = onLog + ) + + private val uiActionContext = UIActionContext() + + private val toolRegistry = ToolRegistry().also { registry -> + BuiltInTools.registerAll(registry, context) + UIActionTools.registerAll(registry, uiActionContext, actionExecutor) + } + + private var activeModelId: String = AgentApplication.DEFAULT_MODEL + private var isRunning = false + private var planResult: PlanResult? = null + + // Tracks the last prompt for local model tool result re-injection + private var lastPrompt: String = "" + + fun setModel(modelId: String) { + activeModelId = modelId + } + + fun getModel(): String = activeModelId + + fun registerTool(definition: ToolDefinition, handler: ToolHandler) { + toolRegistry.register(definition, handler) + } + + sealed class AgentEvent { + data class Log(val message: String) : AgentEvent() + data class Step(val step: Int, val action: String, val result: String) : AgentEvent() + data class Done(val message: String) : AgentEvent() + data class Error(val message: String) : AgentEvent() + data class Speak(val text: String) : AgentEvent() + } + + fun run(goal: String): Flow = flow { + if (isRunning) { + emit(AgentEvent.Error("Agent already running")) + return@flow + } + + isRunning = true + history.clear() + planResult = null + + try { + emit(AgentEvent.Log("Starting agent...")) + emit(AgentEvent.Speak("Working on it.")) + + if (gptClient.isConfigured()) { + emit(AgentEvent.Log("Requesting GPT-4o plan...")) + planResult = gptClient.generatePlan(goal) + planResult?.let { plan -> + if (plan.steps.isNotEmpty()) { + emit(AgentEvent.Log("Plan:")) + plan.steps.forEachIndexed { index, step -> + emit(AgentEvent.Log("${index + 1}. $step")) + } + } + plan.successCriteria?.let { criteria -> + emit(AgentEvent.Log("Success criteria: $criteria")) + } + } + } else { + emit(AgentEvent.Log("GPT-4o API key missing. Skipping planning.")) + } + + val toolCount = toolRegistry.getDefinitions().size + if (toolCount > 0) { + emit(AgentEvent.Log("$toolCount tools registered")) + } + + // Smart pre-launch: open the target app before the agent loop + val preLaunchResult = preLaunchApp(goal) + if (preLaunchResult != null) { + emit(AgentEvent.Log(preLaunchResult)) + delay(1500) // Wait for app to fully launch + } + + // Ensure model is ready + emit(AgentEvent.Log("Loading model: $activeModelId")) + ensureModelReady() + emit(AgentEvent.Log("Model ready")) + + val startTime = System.currentTimeMillis() + var step = 0 + + while (step < MAX_STEPS && isRunning) { + step++ + emit(AgentEvent.Log("Step $step/$MAX_STEPS")) + + // Parse screen + val screen = screenParser.parse() + if (screen.elementCount == 0) { + emit(AgentEvent.Log("No elements found, waiting...")) + delay(STEP_DELAY_MS) + continue + } + + // Update UI action context with fresh coordinates + uiActionContext.indexToCoords = screen.indexToCoords + + // Capture screenshot for VLM + val screenshotBase64 = try { + AgentAccessibilityService.instance?.captureScreenshotBase64() + } catch (e: Exception) { + Log.w(TAG, "Screenshot capture failed: ${e.message}") + null + } + + val useVision = screenshotBase64 != null && gptClient.isConfigured() + if (useVision) { + emit(AgentEvent.Log("Using VLM (screenshot + elements)")) + } else if (screenshotBase64 == null) { + emit(AgentEvent.Log("No screenshot, using text-only mode")) + } + + // Get LLM decision with context + val historyPrompt = history.formatForPrompt() + val lastActionResult = history.getLastActionResult() + val lastAction = history.getLastAction() + + val loopDetected = lastAction != null && history.isRepetitive(lastAction.action, lastAction.target) + val hadFailure = history.hadRecentFailure() + + // Choose appropriate prompt based on context + val useToolCalling = gptClient.isConfigured() + val prompt = if (useVision) { + when { + loopDetected -> { + emit(AgentEvent.Log("Loop detected, adding recovery prompt")) + SystemPrompts.buildVisionLoopRecoveryPrompt(goal, screen.compactText, historyPrompt, lastActionResult, useToolCalling) + } + hadFailure -> { + emit(AgentEvent.Log("Recent failure, adding recovery hints")) + SystemPrompts.buildVisionFailureRecoveryPrompt(goal, screen.compactText, historyPrompt, lastActionResult, useToolCalling) + } + else -> { + SystemPrompts.buildVisionPrompt(goal, screen.compactText, historyPrompt, lastActionResult, useToolCalling) + } + } + } else { + when { + loopDetected -> { + emit(AgentEvent.Log("Loop detected, adding recovery prompt")) + SystemPrompts.buildLoopRecoveryPrompt(goal, screen.compactText, historyPrompt, lastActionResult, useToolCalling) + } + hadFailure -> { + emit(AgentEvent.Log("Recent failure, adding recovery hints")) + SystemPrompts.buildFailureRecoveryPrompt(goal, screen.compactText, historyPrompt, lastActionResult, useToolCalling) + } + else -> { + SystemPrompts.buildPrompt(goal, screen.compactText, historyPrompt, lastActionResult, useToolCalling) + } + } + } + + lastPrompt = prompt + + // Get LLM response (with tool calling support and optional vision) + val response = if (gptClient.isConfigured()) { + if (useVision) { + emit(AgentEvent.Log("Calling GPT-4o Vision...")) + callRemoteLLMWithVision(prompt, screenshotBase64!!) ?: run { + emit(AgentEvent.Log("Vision failed, falling back to text-only")) + callRemoteLLMWithTools(prompt) ?: callLocalLLMWithTools(prompt) + } + } else { + emit(AgentEvent.Log("Using GPT-4o...")) + callRemoteLLMWithTools(prompt) ?: run { + emit(AgentEvent.Log("GPT-4o unavailable, falling back to local model")) + callLocalLLMWithTools(prompt) + } + } + } else { + callLocalLLMWithTools(prompt) + } + + // Resolve any tool calls (sub-loop) + val finalResponse = resolveToolCalls(response, prompt) { event -> emit(event) } + + // Handle UI action tool calls from GPT-4o function calling + if (finalResponse is LLMResponse.UIActionToolCall) { + val call = finalResponse.call + val actionName = mapToolNameToAction(call.toolName) + val target = extractTargetFromToolCall(call) + + emit(AgentEvent.Log("Action (tool): $actionName")) + + // Speak key actions + when (actionName) { + "open" -> (call.arguments["app_name"] as? String)?.let { + emit(AgentEvent.Speak("Opening $it")) + } + "type" -> (call.arguments["text"] as? String)?.let { + val preview = if (it.length > 30) it.take(30) + "..." else it + emit(AgentEvent.Speak("Typing $preview")) + } + } + + // Execute via tool registry (which delegates to ActionExecutor) + val result = toolRegistry.execute(call) + emit(AgentEvent.Step(step, actionName, result.result)) + history.record(actionName, target, result.result, !result.isError) + + if (call.toolName == "ui_done") { + emit(AgentEvent.Speak("Task complete.")) + emit(AgentEvent.Done("Goal achieved")) + return@flow + } + + // Check timeout + if (System.currentTimeMillis() - startTime > MAX_DURATION_MS) { + emit(AgentEvent.Done("Max duration reached")) + return@flow + } + + delay(STEP_DELAY_MS) + continue + } + + // Legacy path: handle JSON-based UI actions (local model fallback) + val decision = when (finalResponse) { + is LLMResponse.UIAction -> parseDecision(finalResponse.json) + is LLMResponse.TextAnswer -> { + emit(AgentEvent.Log("LLM answer: ${finalResponse.text}")) + tryExtractDecisionFromText(finalResponse.text) ?: Decision("wait") + } + is LLMResponse.Error -> { + emit(AgentEvent.Log("LLM error: ${finalResponse.message}")) + Decision("wait") + } + is LLMResponse.ToolCalls -> { + emit(AgentEvent.Log("Unresolved tool calls after max iterations")) + Decision("wait") + } + is LLMResponse.UIActionToolCall -> { + // Should not reach here, handled above + Decision("wait") + } + } + + emit(AgentEvent.Log("Action: ${decision.action}")) + + // Speak key actions + when (decision.action) { + "open" -> decision.text?.let { emit(AgentEvent.Speak("Opening $it")) } + "type" -> decision.text?.let { + val preview = if (it.length > 30) it.take(30) + "..." else it + emit(AgentEvent.Speak("Typing $preview")) + } + } + + // Execute action + val result = actionExecutor.execute(decision, screen.indexToCoords) + emit(AgentEvent.Step(step, decision.action, result.message)) + + // Record in history with success/failure + val target = when { + decision.elementIndex != null -> screenParser.getElementLabel(decision.elementIndex) + decision.text != null -> decision.text + decision.url != null -> decision.url + decision.query != null -> decision.query + else -> null + } + history.record(decision.action, target, result.message, result.success) + + // Check for completion + if (decision.action == "done") { + emit(AgentEvent.Speak("Task complete.")) + emit(AgentEvent.Done("Goal achieved")) + return@flow + } + + // Check timeout + if (System.currentTimeMillis() - startTime > MAX_DURATION_MS) { + emit(AgentEvent.Done("Max duration reached")) + return@flow + } + + delay(STEP_DELAY_MS) + } + + emit(AgentEvent.Speak("I've reached the maximum steps.")) + emit(AgentEvent.Done("Max steps reached")) + + } catch (e: CancellationException) { + emit(AgentEvent.Log("Agent cancelled")) + } catch (e: Exception) { + Log.e(TAG, "Agent error: ${e.message}", e) + emit(AgentEvent.Error(e.message ?: "Unknown error")) + } finally { + isRunning = false + } + } + + fun stop() { + isRunning = false + } + + // ========== Tool Calling Integration ========== + + private suspend fun callRemoteLLMWithVision(prompt: String, screenshotBase64: String): LLMResponse? { + val tools = toolRegistry.getDefinitions() + return gptClient.generateActionWithVision(prompt, screenshotBase64, tools) + } + + private suspend fun callRemoteLLMWithTools(prompt: String): LLMResponse? { + val tools = toolRegistry.getDefinitions() + return if (tools.isNotEmpty()) { + gptClient.generateActionWithTools(prompt, tools) + } else { + // No tools registered, use existing path + val json = gptClient.generateAction(prompt) ?: return null + LLMResponse.UIAction(json) + } + } + + private suspend fun callLocalLLMWithTools(prompt: String): LLMResponse { + // Filter out ui_* tools for local models — too many tools overwhelms small models + val tools = toolRegistry.getDefinitions().filter { !it.name.startsWith("ui_") } + val hasTools = tools.isNotEmpty() + + val options = if (hasTools) { + // When tools are registered: more tokens, no structured output + // (grammar enforcement would block tags) + LLMGenerationOptions( + maxTokens = 128, + temperature = 0.0f, + topP = 0.95f, + streamingEnabled = false, + systemPrompt = null, + structuredOutput = null + ) + } else { + // No tools: existing behavior with structured output + LLMGenerationOptions( + maxTokens = 32, + temperature = 0.0f, + topP = 0.95f, + streamingEnabled = false, + systemPrompt = null, + structuredOutput = StructuredOutputConfig( + typeName = "Act", + includeSchemaInPrompt = true, + jsonSchema = SystemPrompts.DECISION_SCHEMA + ) + ) + } + + val fullPrompt = if (hasTools) { + prompt + SystemPrompts.TOOL_AWARE_ADDENDUM + + ToolPromptFormatter.formatForLocalPrompt(tools) + } else { + prompt + } + + return try { + val result = withContext(Dispatchers.Default) { + RunAnywhere.generate(fullPrompt, options) + } + val text = result.text + + // Check for tool calls first (only when tools are registered) + if (hasTools && ToolCallParser.containsToolCall(text)) { + val calls = ToolCallParser.parse(text) + if (calls.isNotEmpty()) { + return LLMResponse.ToolCalls(calls) + } + } + + // Otherwise treat as UI action + LLMResponse.UIAction(text) + } catch (e: Exception) { + Log.e(TAG, "LLM call failed: ${e.message}", e) + LLMResponse.UIAction("{\"a\":\"done\"}") + } + } + + /** + * Resolve tool calls in a sub-loop: execute tools, feed results back, repeat. + * Returns the final non-tool-call response. + */ + private suspend fun resolveToolCalls( + initialResponse: LLMResponse, + originalPrompt: String, + emitEvent: suspend (AgentEvent) -> Unit + ): LLMResponse { + var current = initialResponse + var iterations = 0 + + // For GPT-4o multi-turn: maintain conversation history + val conversationHistory = mutableListOf() + var historyInitialized = false + + while (current is LLMResponse.ToolCalls && iterations < MAX_TOOL_ITERATIONS) { + iterations++ + val toolCalls = current.calls + + // Check if any call is a UI action tool — if so, return it immediately + // so the main loop handles it as a single-step action + val uiCall = toolCalls.firstOrNull { it.toolName.startsWith("ui_") } + if (uiCall != null) { + return LLMResponse.UIActionToolCall(uiCall) + } + + val results = mutableListOf() + + for (call in toolCalls) { + emitEvent(AgentEvent.Log("Tool call: ${call.toolName}(${call.arguments})")) + val result = toolRegistry.execute(call) + emitEvent(AgentEvent.Log("Tool result [${call.toolName}]: ${result.result}")) + results.add(result) + + // Record in action history + history.recordToolCall( + call.toolName, + call.arguments.toString(), + result.result, + !result.isError + ) + } + + // Feed results back to LLM + current = if (gptClient.isConfigured()) { + // GPT-4o path: build multi-turn conversation history + if (!historyInitialized) { + // Add initial system + user messages + conversationHistory.add(JSONObject().apply { + put("role", "system") + put("content", SystemPrompts.TOOL_CALLING_SYSTEM_PROMPT) + }) + conversationHistory.add(JSONObject().apply { + put("role", "user") + put("content", originalPrompt) + }) + historyInitialized = true + } + + // Add assistant message with tool calls + conversationHistory.add(gptClient.buildAssistantToolCallMessage(toolCalls)) + + // Add tool result messages + results.forEach { result -> + conversationHistory.add( + gptClient.buildToolResultMessage(result.toolCallId, result.result) + ) + } + + gptClient.submitToolResults(conversationHistory, toolRegistry.getDefinitions()) + ?: LLMResponse.Error("GPT-4o tool result submission failed") + } else { + // Local model path: append tool results to prompt and re-generate + val toolResultText = ToolPromptFormatter.formatToolResults(results) + callLocalLLMWithTools(originalPrompt + toolResultText) + } + } + + if (iterations >= MAX_TOOL_ITERATIONS && current is LLMResponse.ToolCalls) { + return LLMResponse.Error("Max tool calling iterations ($MAX_TOOL_ITERATIONS) reached") + } + + return current + } + + /** + * Try to extract a UI action decision from a text answer. + * Sometimes the LLM returns a text answer that contains a JSON action. + */ + private fun tryExtractDecisionFromText(text: String): Decision? { + val matcher = Pattern.compile("\\{.*?\\}", Pattern.DOTALL).matcher(text) + if (matcher.find()) { + try { + val obj = JSONObject(matcher.group()) + if (obj.has("action") || obj.has("a")) { + return extractDecision(obj) + } + } catch (_: JSONException) {} + } + return null + } + + // ========== Existing Methods ========== + + private suspend fun ensureModelReady() { + try { + RunAnywhere.loadLLMModel(activeModelId) + } catch (e: Exception) { + onLog("Downloading model...") + var lastPercent = -1 + RunAnywhere.downloadModel(activeModelId).collect { progress -> + val percent = (progress.progress * 100).toInt() + if (percent != lastPercent && percent % 10 == 0) { + lastPercent = percent + onLog("Downloading... $percent%") + } + } + RunAnywhere.loadLLMModel(activeModelId) + } + } + + private fun parseDecision(text: String): Decision { + val cleaned = text + .replace("```json", "") + .replace("```", "") + .trim() + + // Try parsing as JSON + try { + val obj = JSONObject(cleaned) + return extractDecision(obj) + } catch (_: JSONException) {} + + // Try extracting JSON from text + val matcher = Pattern.compile("\\{.*?\\}", Pattern.DOTALL).matcher(cleaned) + if (matcher.find()) { + try { + return extractDecision(JSONObject(matcher.group())) + } catch (_: JSONException) {} + } + + // Fallback: heuristic parsing + return heuristicDecision(cleaned) + } + + private fun extractDecision(obj: JSONObject): Decision { + val action = obj.optString("action", "").ifEmpty { obj.optString("a", "") } + + // Support both "index" (new) and "i" (old) keys + val index = obj.optInt("index", -1).let { if (it >= 0) it else obj.optInt("i", -1) }.takeIf { it >= 0 } + + // Map direction values: support both full words and abbreviations + val rawDirection = obj.optString("direction", "").ifEmpty { obj.optString("d", "") }.takeIf { it.isNotEmpty() } + val direction = when (rawDirection) { + "up" -> "u" + "down" -> "d" + "left" -> "l" + "right" -> "r" + else -> rawDirection + } + + return Decision( + action = action.ifEmpty { "done" }, + elementIndex = index, + text = obj.optString("text", "").ifEmpty { obj.optString("t") }?.takeIf { it.isNotEmpty() }, + direction = direction, + url = obj.optString("url", "").ifEmpty { obj.optString("u") }?.takeIf { it.isNotEmpty() }, + query = obj.optString("query", "").ifEmpty { obj.optString("q") }?.takeIf { it.isNotEmpty() } + ) + } + + /** + * Pre-launch: analyze the goal and open the target app directly via intent. + * Returns a log message if an app was launched, or null if no app was detected. + */ + private fun preLaunchApp(goal: String): String? { + val goalLower = goal.lowercase() + + // Map keywords to app launchers + val appMatch = when { + goalLower.contains("youtube") -> { + // Extract search query if present + val searchQuery = extractSearchQuery(goalLower, "youtube") + if (searchQuery != null) { + AppActions.openYouTubeSearch(context, searchQuery) + return "Pre-launched YouTube with search: $searchQuery" + } + AppActions.openApp(context, AppActions.Packages.YOUTUBE) + "Pre-launched YouTube" + } + goalLower.contains("chrome") || goalLower.contains("browser") -> { + AppActions.openApp(context, AppActions.Packages.CHROME) + "Pre-launched Chrome" + } + goalLower.contains("whatsapp") -> { + AppActions.openApp(context, AppActions.Packages.WHATSAPP) + "Pre-launched WhatsApp" + } + goalLower.contains("gmail") -> { + AppActions.openApp(context, AppActions.Packages.GMAIL) + "Pre-launched Gmail" + } + goalLower.contains("spotify") -> { + val searchQuery = extractSearchQuery(goalLower, "spotify") + if (searchQuery != null) { + AppActions.openSpotifySearch(context, searchQuery) + return "Pre-launched Spotify with search: $searchQuery" + } + AppActions.openApp(context, AppActions.Packages.SPOTIFY) + "Pre-launched Spotify" + } + goalLower.contains("maps") || goalLower.contains("navigate to") || goalLower.contains("directions to") -> { + AppActions.openApp(context, AppActions.Packages.MAPS) + "Pre-launched Maps" + } + goalLower.contains("timer") || goalLower.contains("alarm") || goalLower.contains("clock") -> { + AppActions.openClock(context) + "Pre-launched Clock" + } + goalLower.contains("camera") || goalLower.contains("photo") || goalLower.contains("picture") -> { + AppActions.openCamera(context) + "Pre-launched Camera" + } + goalLower.contains("settings") -> { + actionExecutor.openSettings() + "Pre-launched Settings" + } + else -> null + } + + return appMatch + } + + /** + * Try to extract a search query from the goal. + * E.g., "open youtube and search for lofi music" -> "lofi music" + * E.g., "search for cheap flights on youtube" -> "cheap flights" + */ + private fun extractSearchQuery(goalLower: String, appName: String): String? { + // Patterns like "search for X on YouTube", "search X on YouTube" + val patterns = listOf( + Regex("search\\s+(?:for\\s+)?[\"']?(.+?)[\"']?\\s+on\\s+$appName"), + Regex("$appName.*?search\\s+(?:for\\s+)?[\"']?(.+?)(?:[\"']|$)"), + Regex("(?:play|find|look\\s+(?:up|for))\\s+[\"']?(.+?)[\"']?\\s+on\\s+$appName"), + Regex("$appName.*?(?:play|find|look\\s+(?:up|for))\\s+[\"']?(.+?)(?:[\"']|$)") + ) + + for (pattern in patterns) { + val match = pattern.find(goalLower) + if (match != null) { + val query = match.groupValues[1] + .replace(Regex("\\s+and\\s+(play|click|tap|open|select).*"), "") + .trim() + if (query.isNotEmpty() && query.length > 2) return query + } + } + return null + } + + /** + * Map a ui_* tool name to the legacy action name for logging/history. + * e.g., "ui_tap" → "tap", "ui_open_app" → "open" + */ + private fun mapToolNameToAction(toolName: String): String { + return when (toolName) { + "ui_tap" -> "tap" + "ui_type" -> "type" + "ui_enter" -> "enter" + "ui_swipe" -> "swipe" + "ui_back" -> "back" + "ui_home" -> "home" + "ui_open_app" -> "open" + "ui_long_press" -> "long" + "ui_open_url" -> "url" + "ui_web_search" -> "search" + "ui_open_notifications" -> "notif" + "ui_open_quick_settings" -> "quick" + "ui_wait" -> "wait" + "ui_done" -> "done" + else -> toolName.removePrefix("ui_") + } + } + + /** + * Extract a human-readable target from a tool call's arguments for history/logging. + */ + private fun extractTargetFromToolCall(call: ToolCall): String? { + return when (call.toolName) { + "ui_tap", "ui_long_press" -> { + val index = (call.arguments["index"] as? Number)?.toInt() + index?.let { screenParser.getElementLabel(it) } + } + "ui_type" -> call.arguments["text"]?.toString() + "ui_open_app" -> call.arguments["app_name"]?.toString() + "ui_open_url" -> call.arguments["url"]?.toString() + "ui_web_search" -> call.arguments["query"]?.toString() + "ui_swipe" -> call.arguments["direction"]?.toString() + "ui_done" -> call.arguments["reason"]?.toString() + else -> null + } + } + + private fun heuristicDecision(text: String): Decision { + val lower = text.lowercase() + + return when { + lower.contains("done") -> Decision("done") + lower.contains("back") -> Decision("back") + lower.contains("home") -> Decision("home") + lower.contains("enter") -> Decision("enter") + lower.contains("wait") -> Decision("wait") + lower.contains("swipe") || lower.contains("scroll") -> { + val dir = when { + lower.contains("up") -> "u" + lower.contains("down") -> "d" + lower.contains("left") -> "l" + lower.contains("right") -> "r" + else -> "u" + } + Decision("swipe", direction = dir) + } + lower.contains("tap") || lower.contains("click") -> { + val idx = Regex("\\d+").find(text)?.value?.toIntOrNull() ?: 0 + Decision("tap", elementIndex = idx) + } + lower.contains("type") -> { + val textMatch = Regex("\"([^\"]+)\"").find(text) + Decision("type", text = textMatch?.groupValues?.getOrNull(1) ?: "") + } + else -> Decision("done") + } + } +} diff --git a/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/kernel/GPTClient.kt b/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/kernel/GPTClient.kt new file mode 100644 index 000000000..efdff9469 --- /dev/null +++ b/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/kernel/GPTClient.kt @@ -0,0 +1,410 @@ +package com.runanywhere.agent.kernel + +import android.util.Log +import com.runanywhere.agent.toolcalling.LLMResponse +import com.runanywhere.agent.toolcalling.ToolCall +import com.runanywhere.agent.toolcalling.ToolDefinition +import com.runanywhere.agent.toolcalling.ToolPromptFormatter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject +import java.util.concurrent.TimeUnit + +class GPTClient( + private val apiKeyProvider: () -> String?, + private val onLog: (String) -> Unit +) { + companion object { + private const val TAG = "GPTClient" + private const val API_URL = "https://api.openai.com/v1/chat/completions" + private val JSON_MEDIA = "application/json; charset=utf-8".toMediaType() + } + + private val client = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .writeTimeout(60, TimeUnit.SECONDS) + .build() + + fun isConfigured(): Boolean = !apiKeyProvider().isNullOrBlank() + + suspend fun generatePlan(task: String): PlanResult? { + val content = request( + systemPrompt = "You are an expert Android planning assistant. Always respond with valid minified JSON following this schema: ${SystemPrompts.PLANNING_SCHEMA}", + userPrompt = SystemPrompts.buildPlanningPrompt(task), + maxTokens = 256 + ) ?: return null + + return try { + parsePlan(content) + } catch (e: JSONException) { + Log.e(TAG, "Failed to parse plan: ${e.message}") + null + } + } + + suspend fun generateAction(prompt: String): String? { + return request( + systemPrompt = SystemPrompts.SYSTEM_PROMPT, + userPrompt = prompt, + maxTokens = 256 + ) + } + + /** + * Send a prompt with tool definitions using OpenAI's native function calling. + * Does NOT use response_format: json_object (incompatible with tools). + */ + suspend fun generateActionWithTools( + prompt: String, + tools: List, + conversationHistory: List? = null + ): LLMResponse? { + val apiKey = apiKeyProvider()?.takeIf { it.isNotBlank() } ?: return null + + val messages = JSONArray() + + if (conversationHistory != null && conversationHistory.isNotEmpty()) { + // Use provided conversation history (for multi-turn tool calling) + conversationHistory.forEach { messages.put(it) } + } else { + // First turn: system + user messages + messages.put(JSONObject().put("role", "system").put("content", + SystemPrompts.TOOL_CALLING_SYSTEM_PROMPT + )) + messages.put(JSONObject().put("role", "user").put("content", prompt)) + } + + val payload = JSONObject().apply { + put("model", "gpt-4o") + put("temperature", 0) + put("max_tokens", 512) + put("messages", messages) + if (tools.isNotEmpty()) { + put("tools", ToolPromptFormatter.toOpenAIFormat(tools)) + put("parallel_tool_calls", false) + } + } + + val request = Request.Builder() + .url(API_URL) + .header("Authorization", "Bearer $apiKey") + .header("Content-Type", JSON_MEDIA.toString()) + .post(payload.toString().toRequestBody(JSON_MEDIA)) + .build() + + return try { + val response = withContext(Dispatchers.IO) { client.newCall(request).execute() } + val body = response.body?.string() + if (!response.isSuccessful) { + val err = body ?: response.message + Log.e(TAG, "GPT tool call failed: ${response.code} $err") + onLog("GPT-4o error ${response.code}") + null + } else { + body?.let { extractLLMResponse(it) } + } + } catch (e: Exception) { + Log.e(TAG, "GPT tool request error: ${e.message}", e) + onLog("GPT-4o request failed: ${e.message}") + null + } + } + + /** + * Send a prompt with a screenshot image to GPT-4o vision. + * Uses multi-part content format: [text, image_url]. + */ + suspend fun generateActionWithVision( + prompt: String, + screenshotBase64: String, + tools: List, + conversationHistory: List? = null + ): LLMResponse? { + val apiKey = apiKeyProvider()?.takeIf { it.isNotBlank() } ?: return null + + val messages = JSONArray() + + if (conversationHistory != null && conversationHistory.isNotEmpty()) { + conversationHistory.forEach { messages.put(it) } + } else { + messages.put(JSONObject().put("role", "system").put("content", + SystemPrompts.TOOL_CALLING_VISION_SYSTEM_PROMPT + )) + + // User message with multi-part content: text + image + val contentArray = JSONArray().apply { + put(JSONObject().apply { + put("type", "text") + put("text", prompt) + }) + put(JSONObject().apply { + put("type", "image_url") + put("image_url", JSONObject().apply { + put("url", "data:image/jpeg;base64,$screenshotBase64") + put("detail", "low") + }) + }) + } + messages.put(JSONObject().apply { + put("role", "user") + put("content", contentArray) + }) + } + + val payload = JSONObject().apply { + put("model", "gpt-4o") + put("temperature", 0) + put("max_tokens", 512) + put("messages", messages) + if (tools.isNotEmpty()) { + put("tools", ToolPromptFormatter.toOpenAIFormat(tools)) + put("parallel_tool_calls", false) + } + } + + val request = Request.Builder() + .url(API_URL) + .header("Authorization", "Bearer $apiKey") + .header("Content-Type", JSON_MEDIA.toString()) + .post(payload.toString().toRequestBody(JSON_MEDIA)) + .build() + + return try { + val response = withContext(Dispatchers.IO) { client.newCall(request).execute() } + val body = response.body?.string() + if (!response.isSuccessful) { + val err = body ?: response.message + Log.e(TAG, "GPT vision failed: ${response.code} $err") + onLog("GPT-4o vision error ${response.code}") + null + } else { + body?.let { extractLLMResponse(it) } + } + } catch (e: Exception) { + Log.e(TAG, "GPT vision error: ${e.message}", e) + onLog("GPT-4o vision failed: ${e.message}") + null + } + } + + /** + * Build a user message with vision content for conversation history. + */ + fun buildUserVisionMessage(prompt: String, screenshotBase64: String): JSONObject { + val contentArray = JSONArray().apply { + put(JSONObject().apply { + put("type", "text") + put("text", prompt) + }) + put(JSONObject().apply { + put("type", "image_url") + put("image_url", JSONObject().apply { + put("url", "data:image/jpeg;base64,$screenshotBase64") + put("detail", "low") + }) + }) + } + return JSONObject().apply { + put("role", "user") + put("content", contentArray) + } + } + + /** + * Submit tool results back to GPT-4o for a follow-up response. + * The conversationHistory should contain the full message chain including + * the assistant message with tool_calls and tool role result messages. + */ + suspend fun submitToolResults( + conversationHistory: List, + tools: List + ): LLMResponse? { + return generateActionWithTools( + prompt = "", // not used when conversationHistory is provided + tools = tools, + conversationHistory = conversationHistory + ) + } + + /** + * Build the assistant message JSON for a tool_calls response. + * Used to construct conversation history for multi-turn tool calling. + */ + fun buildAssistantToolCallMessage(toolCalls: List): JSONObject { + val toolCallsArray = JSONArray() + toolCalls.forEach { call -> + val argsJson = JSONObject() + call.arguments.forEach { (key, value) -> + argsJson.put(key, value) + } + toolCallsArray.put(JSONObject().apply { + put("id", call.id) + put("type", "function") + put("function", JSONObject().apply { + put("name", call.toolName) + put("arguments", argsJson.toString()) + }) + }) + } + return JSONObject().apply { + put("role", "assistant") + put("tool_calls", toolCallsArray) + } + } + + /** + * Build a tool result message for the conversation history. + */ + fun buildToolResultMessage(toolCallId: String, result: String): JSONObject { + return JSONObject().apply { + put("role", "tool") + put("tool_call_id", toolCallId) + put("content", result) + } + } + + // ---- Existing private methods ---- + + private suspend fun request(systemPrompt: String, userPrompt: String, maxTokens: Int): String? { + val apiKey = apiKeyProvider()?.takeIf { it.isNotBlank() } ?: return null + + val payload = JSONObject().apply { + put("model", "gpt-4o") + put("temperature", 0) + put("max_tokens", maxTokens) + put("response_format", JSONObject().put("type", "json_object")) + put("messages", JSONArray().apply { + put(JSONObject().put("role", "system").put("content", systemPrompt)) + put(JSONObject().put("role", "user").put("content", userPrompt)) + }) + } + + val request = Request.Builder() + .url(API_URL) + .header("Authorization", "Bearer $apiKey") + .header("Content-Type", JSON_MEDIA.toString()) + .post(payload.toString().toRequestBody(JSON_MEDIA)) + .build() + + return try { + val response = withContext(Dispatchers.IO) { client.newCall(request).execute() } + val body = response.body?.string() + if (!response.isSuccessful) { + val err = body ?: response.message + Log.e(TAG, "GPT call failed: ${response.code} $err") + onLog("GPT-4o error ${response.code}") + null + } else { + body?.let { extractContent(it) } + } + } catch (e: Exception) { + Log.e(TAG, "GPT request error: ${e.message}", e) + onLog("GPT-4o request failed: ${e.message}") + null + } + } + + private fun extractContent(body: String): String? { + val json = JSONObject(body) + val choices = json.optJSONArray("choices") ?: return null + val message = choices.optJSONObject(0)?.optJSONObject("message") ?: return null + val arrayContent = message.optJSONArray("content") + return when { + arrayContent != null -> buildString { + for (i in 0 until arrayContent.length()) { + val part = arrayContent.optJSONObject(i) + if (part != null) { + append(part.optString("text")) + } else { + append(arrayContent.optString(i)) + } + } + }.trim() + else -> message.optString("content").trim() + } + } + + /** + * Parse GPT-4o response into an LLMResponse, handling both tool_calls and content. + */ + private fun extractLLMResponse(body: String): LLMResponse { + val json = JSONObject(body) + val choices = json.optJSONArray("choices") + if (choices == null || choices.length() == 0) { + return LLMResponse.Error("No choices in GPT response") + } + val message = choices.optJSONObject(0)?.optJSONObject("message") + ?: return LLMResponse.Error("No message in GPT response") + + // Check for tool calls first + val toolCallsArray = message.optJSONArray("tool_calls") + if (toolCallsArray != null && toolCallsArray.length() > 0) { + val calls = mutableListOf() + for (i in 0 until toolCallsArray.length()) { + val tc = toolCallsArray.getJSONObject(i) + val id = tc.getString("id") + val function = tc.getJSONObject("function") + val name = function.getString("name") + val argsStr = function.getString("arguments") + val argsObj = try { JSONObject(argsStr) } catch (_: Exception) { JSONObject() } + val args = mutableMapOf() + val keys = argsObj.keys() + while (keys.hasNext()) { + val key = keys.next() + args[key] = argsObj.opt(key) + } + calls.add(ToolCall(id = id, toolName = name, arguments = args)) + } + return LLMResponse.ToolCalls(calls) + } + + // No tool calls -- extract content + val content = message.optString("content", "").trim() + if (content.isEmpty()) { + return LLMResponse.Error("Empty response from GPT") + } + + // Determine if it's a UI action JSON or a text answer + return try { + val cleaned = content + .replace("```json", "") + .replace("```", "") + .trim() + val obj = JSONObject(cleaned) + if (obj.has("action") || obj.has("a")) { + LLMResponse.UIAction(cleaned) + } else { + LLMResponse.TextAnswer(content) + } + } catch (_: JSONException) { + LLMResponse.TextAnswer(content) + } + } + + private fun parsePlan(text: String): PlanResult { + val cleaned = text + .replace("```json", "") + .replace("```", "") + .trim() + val obj = JSONObject(cleaned) + val stepsArray = obj.optJSONArray("steps") ?: JSONArray() + val steps = mutableListOf() + for (i in 0 until stepsArray.length()) { + steps.add(stepsArray.optString(i)) + } + val successCriteria = obj.optString("success_criteria").takeIf { it.isNotEmpty() } + return PlanResult(steps, successCriteria) + } +} + +data class PlanResult( + val steps: List, + val successCriteria: String? +) diff --git a/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/kernel/ScreenParser.kt b/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/kernel/ScreenParser.kt new file mode 100644 index 000000000..0beac18bd --- /dev/null +++ b/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/kernel/ScreenParser.kt @@ -0,0 +1,28 @@ +package com.runanywhere.agent.kernel + +import com.runanywhere.agent.accessibility.AgentAccessibilityService + +class ScreenParser(private val accessibilityService: () -> AgentAccessibilityService?) { + + data class ParsedScreen( + val compactText: String, + val indexToCoords: Map>, + val elementCount: Int + ) + + fun parse(maxElements: Int = 30, maxTextLength: Int = 50): ParsedScreen { + val service = accessibilityService() ?: return ParsedScreen("(no screen access)", emptyMap(), 0) + val state = service.getScreenState(maxElements, maxTextLength) + return ParsedScreen( + compactText = state.compactText, + indexToCoords = state.indexToCoords, + elementCount = state.elements.size + ) + } + + fun getElementLabel(index: Int, maxElements: Int = 30): String? { + val service = accessibilityService() ?: return null + val state = service.getScreenState(maxElements, 50) + return state.elements.getOrNull(index)?.label + } +} diff --git a/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/kernel/SystemPrompts.kt b/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/kernel/SystemPrompts.kt new file mode 100644 index 000000000..6961528ec --- /dev/null +++ b/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/kernel/SystemPrompts.kt @@ -0,0 +1,418 @@ +package com.runanywhere.agent.kernel + +object SystemPrompts { + + val SYSTEM_PROMPT = """ +You are an Android Driver Agent. Your job is to achieve the user's goal by navigating the UI. + +You will receive: +1. The User's GOAL. +2. A list of interactive UI elements with their index numbers and capabilities. +3. Your PREVIOUS_ACTIONS so you don't repeat yourself. + +You must output ONLY a valid JSON object with your next action. + +Available Actions: +- {"action": "open", "text": "YouTube", "reason": "Opening the YouTube app"} +- {"action": "tap", "index": 3, "reason": "Tapping the Settings button"} +- {"action": "type", "text": "Hello World", "reason": "Typing a message"} +- {"action": "enter", "reason": "Press Enter to submit or search"} +- {"action": "swipe", "direction": "up", "reason": "Scrolling down to find more items"} +- {"action": "back", "reason": "Going back to previous screen"} +- {"action": "home", "reason": "Going to home screen"} +- {"action": "long", "index": 2, "reason": "Long pressing an element"} +- {"action": "wait", "reason": "Waiting for screen to load"} +- {"action": "done", "reason": "Task is complete"} + +IMPORTANT RULES: +- APP LAUNCHING: ALWAYS use {"action": "open", "text": "AppName"} to open apps. This directly launches the app by name. NEVER try to find an app icon on the home screen or app drawer — use "open" instead. Use the app's CURRENT name (e.g., "X" not "Twitter"). Examples: "open" with text "YouTube", "Chrome", "WhatsApp", "X", "Instagram", "Settings", "Clock", "Maps", "Spotify", "Camera", "Gmail". +- Use "tap" with the element "index" number to tap a UI element. +- If an element shows [edit], use "type" action to enter text into it. +- After tapping on a text field, your NEXT action should be "type" to enter text. +- After typing a search query or URL, use "enter" to submit it. +- Do NOT type the same text again if you already typed it. Check PREVIOUS_ACTIONS. +- Do NOT tap the same element repeatedly. If you already tapped it, try a different action. +- If the screen shows your typed text, do NOT type again - use "enter" or tap a result. +- Use "swipe" with direction "up" or "down" to scroll through lists. +- Direction values: "up", "down", "left", "right". +- When the goal is achieved, output {"action": "done", "reason": "explanation"}. +- ALWAYS include a "reason" field explaining your decision. +- SEARCH RESULTS: If you already typed a search query and the screen now shows results (video titles, links, items), do NOT type or search again. Instead, tap the first relevant result. +- NEVER re-type text you already typed. Check PREVIOUS_ACTIONS carefully. +- TIMER NUMPAD: The Android Clock timer numpad fills digits from RIGHT to LEFT (seconds, then minutes, then hours). To set 2 minutes, tap digits 2, 0, 0 (which displays as 02m 00s). To set 1 hour 30 minutes, tap 1, 3, 0, 0, 0. Just tapping "2" alone sets only 2 SECONDS, not 2 minutes. + +Example - Opening an app: +{"action": "open", "text": "YouTube", "reason": "Opening YouTube to search for videos"} + +Example - Tapping element 5: +{"action": "tap", "index": 5, "reason": "Tapping the Timer tab"} + +Example - Typing in an edit field: +{"action": "type", "text": "2", "reason": "Entering the number of minutes"} + +Example - Submitting after typing: +{"action": "enter", "reason": "Submitting the search query"} + +Example - Scrolling to find more items: +{"action": "swipe", "direction": "up", "reason": "Scrolling down to see more options"} + """.trimIndent() + + val VISION_SYSTEM_PROMPT = """ +You are an Android Driver Agent with VISION. Your job is to achieve the user's goal by navigating the UI. + +You will receive: +1. The User's GOAL. +2. A SCREENSHOT of the current Android screen. +3. A list of interactive UI elements with their index numbers, labels, types, and capabilities. +4. Your PREVIOUS_ACTIONS so you don't repeat yourself. + +The screenshot shows you EXACTLY what the user sees. Use it to: +- Understand the current app state and context +- Identify elements that may not appear in the element list +- Verify that your previous actions had the intended effect +- Find the correct element to interact with when labels are ambiguous + +You must output ONLY a valid JSON object with your next action. + +Available Actions: +- {"action": "open", "text": "YouTube", "reason": "Opening the YouTube app"} +- {"action": "tap", "index": 3, "reason": "Tapping the Settings button"} +- {"action": "type", "text": "Hello World", "reason": "Typing a message"} +- {"action": "enter", "reason": "Press Enter to submit or search"} +- {"action": "swipe", "direction": "up", "reason": "Scrolling down to find more items"} +- {"action": "back", "reason": "Going back to previous screen"} +- {"action": "home", "reason": "Going to home screen"} +- {"action": "long", "index": 2, "reason": "Long pressing an element"} +- {"action": "wait", "reason": "Waiting for screen to load"} +- {"action": "done", "reason": "Task is complete"} + +IMPORTANT RULES: +- APP LAUNCHING: ALWAYS use {"action": "open", "text": "AppName"} to launch apps directly. NEVER search for app icons. Use the app's CURRENT name (e.g., "X" not "Twitter"). +- Use "tap" with the element "index" number. Match what you see in the screenshot with the element list. +- If you see a text field in the screenshot and the element list shows [edit], use "type" to enter text. +- After typing, use "enter" to submit or tap a search/submit button you see in the screenshot. +- Use the screenshot to verify whether your typed text appeared or whether a page loaded. +- Do NOT tap the same element repeatedly. Check the screenshot to see if your action already took effect. +- Use "swipe" to scroll if the screenshot shows content continues below or above. +- When the goal is achieved (verify visually from the screenshot), output {"action": "done"}. +- ALWAYS include a "reason" field explaining what you see and why you chose this action. +- SEARCH RESULTS: If the screenshot shows search results, do NOT search again. Tap a relevant result. +- NEVER re-type text you already typed. Check PREVIOUS_ACTIONS carefully. +- TIMER NUMPAD: The Android Clock timer numpad fills digits from RIGHT to LEFT. + """.trimIndent() + + fun buildVisionPrompt( + goal: String, + screenState: String, + history: String, + lastActionResult: String? = null, + useToolCalling: Boolean = false + ): String { + val lastResultSection = lastActionResult?.let { + "\nLAST_RESULT: $it" + } ?: "" + + val instruction = if (useToolCalling) { + "A screenshot of the current screen is attached. Use BOTH the screenshot and the element list to decide your next action.\nCall the appropriate tool for your next action." + } else { + "A screenshot of the current screen is attached. Use BOTH the screenshot and the element list to decide your next action.\nOutput ONLY a JSON object with your next action." + } + + return """ +GOAL: $goal + +SCREEN_ELEMENTS (indexed — use these indices for tap/type actions): +$screenState +$lastResultSection$history + +$instruction + """.trimIndent() + } + + fun buildVisionLoopRecoveryPrompt( + goal: String, + screenState: String, + history: String, + lastActionResult: String? = null, + useToolCalling: Boolean = false + ): String { + val lastResultSection = lastActionResult?.let { + "\nLAST_RESULT: $it" + } ?: "" + + val instruction = if (useToolCalling) "Call a DIFFERENT tool or use different parameters." else "Output ONLY a JSON object with your next action." + + return """ +GOAL: $goal + +SCREEN_ELEMENTS: +$screenState +$lastResultSection$history + +WARNING: You are repeating the same action. Look at the screenshot carefully — the screen may have changed and the element you need might have a different index. Try a DIFFERENT action or element. + +$instruction + """.trimIndent() + } + + fun buildVisionFailureRecoveryPrompt( + goal: String, + screenState: String, + history: String, + lastActionResult: String? = null, + useToolCalling: Boolean = false + ): String { + val lastResultSection = lastActionResult?.let { + "\nLAST_RESULT (FAILED): $it" + } ?: "" + + val instruction = if (useToolCalling) "Call a different tool or use different parameters." else "Output ONLY a JSON object with your next action." + + return """ +GOAL: $goal + +SCREEN_ELEMENTS: +$screenState +$lastResultSection$history + +WARNING: Your last action FAILED. Look at the screenshot to understand what went wrong: +- The element may have moved or the screen may have changed +- You may need to scroll to find the element +- Try a different element or approach based on what you see + +$instruction + """.trimIndent() + } + + val DECISION_SCHEMA = """ +{ + "type":"object", + "properties":{ + "action":{"type":"string","enum":["open","tap","type","enter","swipe","long","back","home","wait","done"]}, + "index":{"type":"integer"}, + "text":{"type":"string"}, + "direction":{"type":"string","enum":["up","down","left","right"]}, + "reason":{"type":"string"} + }, + "required":["action","reason"] +} + """.trimIndent() + + val PLANNING_SCHEMA = """ +{ + "type":"object", + "properties":{ + "steps":{"type":"array","items":{"type":"string"}}, + "success_criteria":{"type":"string"} + }, + "required":["steps"] +} + """.trimIndent() + + fun buildPrompt( + goal: String, + screenState: String, + history: String, + lastActionResult: String? = null, + useToolCalling: Boolean = false + ): String { + val lastResultSection = lastActionResult?.let { + "\nLAST_RESULT: $it" + } ?: "" + + val instruction = if (useToolCalling) "Call the appropriate tool for your next action." else "Output ONLY a JSON object with your next action." + + return """ +GOAL: $goal + +SCREEN_ELEMENTS: +$screenState +$lastResultSection$history + +$instruction + """.trimIndent() + } + + fun buildLoopRecoveryPrompt( + goal: String, + screenState: String, + history: String, + lastActionResult: String? = null, + useToolCalling: Boolean = false + ): String { + val lastResultSection = lastActionResult?.let { + "\nLAST_RESULT: $it" + } ?: "" + + val instruction = if (useToolCalling) "Call a DIFFERENT tool or use different parameters." else "Output ONLY a JSON object with your next action." + + return """ +GOAL: $goal + +SCREEN_ELEMENTS: +$screenState +$lastResultSection$history + +WARNING: You are repeating the same action. You MUST try a DIFFERENT action or element this time. + +$instruction + """.trimIndent() + } + + fun buildFailureRecoveryPrompt( + goal: String, + screenState: String, + history: String, + lastActionResult: String? = null, + useToolCalling: Boolean = false + ): String { + val lastResultSection = lastActionResult?.let { + "\nLAST_RESULT (FAILED): $it" + } ?: "" + + val instruction = if (useToolCalling) "Call a different tool or use different parameters." else "Output ONLY a JSON object with your next action." + + return """ +GOAL: $goal + +SCREEN_ELEMENTS: +$screenState +$lastResultSection$history + +WARNING: Your last action FAILED. Try a different approach: +- The element may have moved - check the current SCREEN_ELEMENTS +- You may need to scroll to find the element +- Try a different element or action + +$instruction + """.trimIndent() + } + + fun buildPlanningPrompt(task: String): String { + return """ +TASK: $task +Create a step-by-step plan to accomplish this task on an Android device. +Be specific about what to tap, type, or swipe. +OUTPUT: {"steps":["step1","step2","step3"],"success_criteria":"how to know task is done"} + """.trimIndent() + } + + val TOOL_CALLING_SYSTEM_PROMPT = """ +You are an Android Driver Agent. Your job is to achieve the user's goal by navigating the UI. + +You will receive: +1. The User's GOAL. +2. A list of interactive UI elements with their index numbers and capabilities. +3. Your PREVIOUS_ACTIONS so you don't repeat yourself. + +You MUST use the provided tool functions for ALL actions. Call exactly ONE tool per turn. + +UI Action Tools: +- ui_open_app(app_name) — open an app by name. ALWAYS use this instead of searching for app icons. +- ui_tap(index) — tap a UI element by its index number from the element list. +- ui_type(text) — type text into the focused/editable field. +- ui_enter() — press Enter to submit a search query or form. +- ui_swipe(direction) — scroll/swipe: "up", "down", "left", "right". +- ui_back() — go back to the previous screen. +- ui_home() — go to the home screen. +- ui_long_press(index) — long press an element by index. +- ui_open_url(url) — open a URL in the browser. +- ui_web_search(query) — search Google. +- ui_wait() — wait for the screen to load. +- ui_done(reason) — signal the task is complete. + +Utility Tools: +- get_current_time, get_current_date, get_battery_level, get_device_info, math_calculate, get_weather, unit_convert, get_clipboard — use these for factual information instead of navigating the UI. + +IMPORTANT RULES: +- APP LAUNCHING: ALWAYS use ui_open_app to open apps. NEVER try to find app icons on the home screen. Use the app's CURRENT name (e.g., "X" not "Twitter", "Meta" not "Facebook"). Examples: YouTube, Chrome, WhatsApp, X, Instagram, Settings, Clock, Maps, Spotify, Camera, Gmail, Telegram, Netflix. +- Use ui_tap with the element index number to tap a UI element. +- If an element shows [edit], use ui_type to enter text into it. +- After typing a search query, use ui_enter to submit it. +- Do NOT type the same text again if you already typed it. Check PREVIOUS_ACTIONS. +- Do NOT tap the same element repeatedly. If you already tapped it, try a different action. +- Use ui_swipe with direction "up" or "down" to scroll through lists. +- When the goal is achieved, call ui_done with a reason. +- SEARCH RESULTS: If you already typed a search query and results are visible, do NOT search again. Tap a result with ui_tap. +- TIMER NUMPAD: The Android Clock timer numpad fills digits from RIGHT to LEFT. To set 2 minutes, type digits 2, 0, 0. + """.trimIndent() + + val TOOL_CALLING_VISION_SYSTEM_PROMPT = """ +You are an Android Driver Agent with VISION. Your job is to achieve the user's goal by navigating the UI. + +You will receive: +1. The User's GOAL. +2. A SCREENSHOT of the current Android screen. +3. A list of interactive UI elements with their index numbers, labels, types, and capabilities. +4. Your PREVIOUS_ACTIONS so you don't repeat yourself. + +The screenshot shows you EXACTLY what the user sees. Use it to: +- Understand the current app state and context +- Identify elements that may not appear in the element list +- Verify that your previous actions had the intended effect +- Find the correct element to interact with when labels are ambiguous + +You MUST use the provided tool functions for ALL actions. Call exactly ONE tool per turn. + +UI Action Tools: +- ui_open_app(app_name) — open an app by name. ALWAYS use this instead of searching for app icons. +- ui_tap(index) — tap a UI element by its index. Match what you see in the screenshot with the element list. +- ui_type(text) — type text into the focused/editable field. +- ui_enter() — press Enter to submit. +- ui_swipe(direction) — scroll/swipe: "up", "down", "left", "right". +- ui_back() — go back. +- ui_home() — go to home screen. +- ui_long_press(index) — long press an element. +- ui_open_url(url) — open a URL. +- ui_web_search(query) — search Google. +- ui_wait() — wait for screen to load. +- ui_done(reason) — signal the task is complete. + +Utility Tools: +- get_current_time, get_current_date, get_battery_level, get_device_info, math_calculate, get_weather, unit_convert, get_clipboard — use these for factual information. + +IMPORTANT RULES: +- APP LAUNCHING: ALWAYS use ui_open_app to launch apps directly. NEVER search for app icons. Use the app's CURRENT name (e.g., "X" not "Twitter", "Meta" not "Facebook"). Examples: YouTube, Chrome, WhatsApp, X, Instagram, Settings, Clock, Maps, Spotify, Camera, Gmail, Telegram, Netflix. +- Use ui_tap with the element index. Match what you see in the screenshot with the element list. +- If you see a text field in the screenshot and the element list shows [edit], use ui_type. +- After typing, use ui_enter or ui_tap on a submit button you see in the screenshot. +- Use the screenshot to verify whether your typed text appeared or whether a page loaded. +- Do NOT tap the same element repeatedly. Check the screenshot to see if your action took effect. +- When the goal is achieved (verify visually from the screenshot), call ui_done. +- SEARCH RESULTS: If the screenshot shows search results, do NOT search again. Use ui_tap on a result. +- TIMER NUMPAD: The Android Clock timer numpad fills digits from RIGHT to LEFT. + """.trimIndent() + + val TOOL_AWARE_ADDENDUM = """ + +TOOLS: +In addition to UI actions, you have access to external tools. +- If you need factual information (time, weather, calculations, device info), USE A TOOL instead of navigating the UI. +- To call a tool, output: {"tool":"tool_name","arguments":{"param":"value"}} +- After a tool returns results, decide your next step: another tool call, a UI action, or "done". +- IMPORTANT: Only call ONE tool at a time. Wait for the result before proceeding. + """.trimIndent() + + fun buildPromptWithToolResults( + goal: String, + screenState: String, + history: String, + toolResults: String, + lastActionResult: String? = null + ): String { + val lastResultSection = lastActionResult?.let { + "\nLAST_RESULT: $it" + } ?: "" + + return """ +GOAL: $goal + +SCREEN_ELEMENTS: +$screenState +$lastResultSection$history +$toolResults + +Output ONLY a JSON object with your next action OR a if you need information. + """.trimIndent() + } +} diff --git a/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/toolcalling/BuiltInTools.kt b/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/toolcalling/BuiltInTools.kt new file mode 100644 index 000000000..0724e20b3 --- /dev/null +++ b/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/toolcalling/BuiltInTools.kt @@ -0,0 +1,272 @@ +package com.runanywhere.agent.toolcalling + +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.BatteryManager +import android.provider.Settings +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import org.json.JSONObject +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone +import java.util.concurrent.TimeUnit + +object BuiltInTools { + + private val httpClient = OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(10, TimeUnit.SECONDS) + .build() + + fun registerAll(registry: ToolRegistry, context: Context) { + registerGetCurrentTime(registry) + registerGetCurrentDate(registry) + registerGetBatteryLevel(registry, context) + registerGetDeviceInfo(registry, context) + registerMathCalculate(registry) + registerGetWeather(registry) + registerUnitConvert(registry) + registerGetClipboard(registry, context) + } + + private fun registerGetCurrentTime(registry: ToolRegistry) { + registry.register( + ToolDefinition( + name = "get_current_time", + description = "Get the current time in a specified timezone", + parameters = listOf( + ToolParameter( + name = "timezone", + type = ToolParameterType.STRING, + description = "Timezone ID (e.g. 'America/New_York', 'Asia/Tokyo', 'UTC'). Defaults to device timezone.", + required = false + ) + ) + ) + ) { args -> + val tzId = args["timezone"]?.toString() + val tz = if (!tzId.isNullOrBlank()) TimeZone.getTimeZone(tzId) else TimeZone.getDefault() + val sdf = SimpleDateFormat("hh:mm:ss a z", Locale.getDefault()) + sdf.timeZone = tz + sdf.format(Date()) + } + } + + private fun registerGetCurrentDate(registry: ToolRegistry) { + registry.register( + ToolDefinition( + name = "get_current_date", + description = "Get the current date", + parameters = emptyList() + ) + ) { _ -> + val sdf = SimpleDateFormat("EEEE, MMMM d, yyyy", Locale.getDefault()) + sdf.format(Date()) + } + } + + private fun registerGetBatteryLevel(registry: ToolRegistry, context: Context) { + registry.register( + ToolDefinition( + name = "get_battery_level", + description = "Get the current battery level and charging status of the device", + parameters = emptyList() + ) + ) { _ -> + val batteryIntent = context.registerReceiver( + null, + IntentFilter(Intent.ACTION_BATTERY_CHANGED) + ) + val level = batteryIntent?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: -1 + val scale = batteryIntent?.getIntExtra(BatteryManager.EXTRA_SCALE, 100) ?: 100 + val status = batteryIntent?.getIntExtra(BatteryManager.EXTRA_STATUS, -1) ?: -1 + val pct = (level * 100) / scale + val charging = status == BatteryManager.BATTERY_STATUS_CHARGING || + status == BatteryManager.BATTERY_STATUS_FULL + "Battery: $pct%, ${if (charging) "charging" else "not charging"}" + } + } + + private fun registerGetDeviceInfo(registry: ToolRegistry, context: Context) { + registry.register( + ToolDefinition( + name = "get_device_info", + description = "Get device information including model, Android version, and screen brightness", + parameters = emptyList() + ) + ) { _ -> + val brightness = try { + val raw = Settings.System.getInt( + context.contentResolver, + Settings.System.SCREEN_BRIGHTNESS + ) + "${(raw * 100) / 255}%" + } catch (_: Exception) { + "unknown" + } + + "Device: ${android.os.Build.MANUFACTURER} ${android.os.Build.MODEL}, " + + "Android ${android.os.Build.VERSION.RELEASE} (API ${android.os.Build.VERSION.SDK_INT}), " + + "Brightness: $brightness" + } + } + + private fun registerMathCalculate(registry: ToolRegistry) { + registry.register( + ToolDefinition( + name = "math_calculate", + description = "Evaluate a mathematical expression (supports +, -, *, /, parentheses)", + parameters = listOf( + ToolParameter( + name = "expression", + type = ToolParameterType.STRING, + description = "The math expression to evaluate, e.g. '(15 + 27) * 3'", + required = true + ) + ) + ) + ) { args -> + val expr = args["expression"]?.toString() + ?: return@register "Error: no expression provided" + try { + val result = SimpleExpressionEvaluator.evaluate(expr) + "$expr = $result" + } catch (e: Exception) { + "Error evaluating '$expr': ${e.message}" + } + } + } + + private fun registerGetWeather(registry: ToolRegistry) { + registry.register( + ToolDefinition( + name = "get_weather", + description = "Get current weather for a location. Uses Open-Meteo API (no API key needed).", + parameters = listOf( + ToolParameter( + name = "latitude", + type = ToolParameterType.NUMBER, + description = "Latitude of the location", + required = true + ), + ToolParameter( + name = "longitude", + type = ToolParameterType.NUMBER, + description = "Longitude of the location", + required = true + ), + ToolParameter( + name = "location_name", + type = ToolParameterType.STRING, + description = "Human-readable location name for display", + required = false + ) + ) + ) + ) { args -> + val lat = args["latitude"]?.toString()?.toDoubleOrNull() + ?: return@register "Error: latitude required" + val lon = args["longitude"]?.toString()?.toDoubleOrNull() + ?: return@register "Error: longitude required" + val name = args["location_name"]?.toString() ?: "(%.2f, %.2f)".format(lat, lon) + + withContext(Dispatchers.IO) { + val url = "https://api.open-meteo.com/v1/forecast" + + "?latitude=$lat&longitude=$lon" + + "¤t=temperature_2m,relative_humidity_2m,wind_speed_10m,weather_code" + val request = Request.Builder().url(url).build() + try { + val response = httpClient.newCall(request).execute() + val body = response.body?.string() ?: return@withContext "No response from weather API" + val json = JSONObject(body) + val current = json.getJSONObject("current") + val temp = current.getDouble("temperature_2m") + val humidity = current.getInt("relative_humidity_2m") + val wind = current.getDouble("wind_speed_10m") + val code = current.getInt("weather_code") + val desc = weatherCodeToDescription(code) + "Weather in $name: $desc, ${temp}\u00B0C, humidity ${humidity}%, wind ${wind} km/h" + } catch (e: Exception) { + "Weather lookup failed: ${e.message}" + } + } + } + } + + private fun registerUnitConvert(registry: ToolRegistry) { + registry.register( + ToolDefinition( + name = "unit_convert", + description = "Convert between common units (temperature, length, weight)", + parameters = listOf( + ToolParameter( + name = "value", + type = ToolParameterType.NUMBER, + description = "The numeric value to convert", + required = true + ), + ToolParameter( + name = "from_unit", + type = ToolParameterType.STRING, + description = "Source unit (e.g. 'celsius', 'fahrenheit', 'km', 'miles', 'kg', 'lbs')", + required = true + ), + ToolParameter( + name = "to_unit", + type = ToolParameterType.STRING, + description = "Target unit", + required = true + ) + ) + ) + ) { args -> + val value = args["value"]?.toString()?.toDoubleOrNull() + ?: return@register "Error: value required" + val from = args["from_unit"]?.toString()?.lowercase() + ?: return@register "Error: from_unit required" + val to = args["to_unit"]?.toString()?.lowercase() + ?: return@register "Error: to_unit required" + UnitConverter.convert(value, from, to) + } + } + + private fun registerGetClipboard(registry: ToolRegistry, context: Context) { + registry.register( + ToolDefinition( + name = "get_clipboard", + description = "Get the current text content of the device clipboard", + parameters = emptyList() + ) + ) { _ -> + withContext(Dispatchers.Main) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) + as android.content.ClipboardManager + val clip = clipboard.primaryClip + if (clip != null && clip.itemCount > 0) { + clip.getItemAt(0).text?.toString() ?: "(clipboard empty)" + } else { + "(clipboard empty)" + } + } + } + } + + private fun weatherCodeToDescription(code: Int): String = when (code) { + 0 -> "Clear sky" + 1, 2, 3 -> "Partly cloudy" + 45, 48 -> "Foggy" + 51, 53, 55 -> "Drizzle" + 61, 63, 65 -> "Rain" + 71, 73, 75 -> "Snow" + 80, 81, 82 -> "Rain showers" + 85, 86 -> "Snow showers" + 95 -> "Thunderstorm" + 96, 99 -> "Thunderstorm with hail" + else -> "Unknown weather (code $code)" + } +} diff --git a/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/toolcalling/SimpleExpressionEvaluator.kt b/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/toolcalling/SimpleExpressionEvaluator.kt new file mode 100644 index 000000000..396abf1b3 --- /dev/null +++ b/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/toolcalling/SimpleExpressionEvaluator.kt @@ -0,0 +1,87 @@ +package com.runanywhere.agent.toolcalling + +object SimpleExpressionEvaluator { + + fun evaluate(expression: String): Double { + val tokens = tokenize(expression.replace(" ", "")) + val parser = Parser(tokens) + val result = parser.parseExpression() + if (parser.pos < tokens.size) { + throw IllegalArgumentException("Unexpected token: ${tokens[parser.pos]}") + } + return result + } + + private fun tokenize(input: String): List { + val tokens = mutableListOf() + var i = 0 + while (i < input.length) { + val c = input[i] + when { + c in "+-*/()" -> { + tokens.add(c.toString()) + i++ + } + c.isDigit() || c == '.' -> { + val sb = StringBuilder() + while (i < input.length && (input[i].isDigit() || input[i] == '.')) { + sb.append(input[i]) + i++ + } + tokens.add(sb.toString()) + } + else -> i++ // skip unknown characters + } + } + return tokens + } + + private class Parser(private val tokens: List) { + var pos = 0 + + fun parseExpression(): Double { + var result = parseTerm() + while (pos < tokens.size && tokens[pos] in listOf("+", "-")) { + val op = tokens[pos++] + val right = parseTerm() + result = if (op == "+") result + right else result - right + } + return result + } + + private fun parseTerm(): Double { + var result = parseFactor() + while (pos < tokens.size && tokens[pos] in listOf("*", "/")) { + val op = tokens[pos++] + val right = parseFactor() + result = if (op == "*") result * right else { + if (right == 0.0) throw ArithmeticException("Division by zero") + result / right + } + } + return result + } + + private fun parseFactor(): Double { + if (pos < tokens.size && tokens[pos] == "-") { + pos++ + return -parseFactor() + } + if (pos < tokens.size && tokens[pos] == "+") { + pos++ + return parseFactor() + } + if (pos < tokens.size && tokens[pos] == "(") { + pos++ // consume '(' + val result = parseExpression() + if (pos < tokens.size && tokens[pos] == ")") pos++ // consume ')' + return result + } + if (pos < tokens.size) { + return tokens[pos++].toDoubleOrNull() + ?: throw IllegalArgumentException("Invalid number: ${tokens[pos - 1]}") + } + throw IllegalArgumentException("Unexpected end of expression") + } + } +} diff --git a/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/toolcalling/ToolCallParser.kt b/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/toolcalling/ToolCallParser.kt new file mode 100644 index 000000000..05b2775f0 --- /dev/null +++ b/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/toolcalling/ToolCallParser.kt @@ -0,0 +1,161 @@ +package com.runanywhere.agent.toolcalling + +import android.util.Log +import org.json.JSONException +import org.json.JSONObject +import java.util.UUID +import java.util.regex.Pattern + +object ToolCallParser { + private const val TAG = "ToolCallParser" + + private val TOOL_CALL_PATTERN = Pattern.compile( + "(.*?)", + Pattern.DOTALL + ) + + private val UNCLOSED_TOOL_CALL_PATTERN = Pattern.compile( + "(\\{.*)", + Pattern.DOTALL + ) + + private val INLINE_TOOL_CALL_PATTERN = Pattern.compile( + "\\{\\s*\"tool_call\"\\s*:\\s*(\\{.*?\\})\\s*\\}", + Pattern.DOTALL + ) + + fun parse(rawOutput: String): List { + val calls = mutableListOf() + + // Try primary ... tags + val matcher = TOOL_CALL_PATTERN.matcher(rawOutput) + while (matcher.find()) { + val inner = matcher.group(1)?.trim() ?: continue + parseToolCallJson(inner)?.let { calls.add(it) } + } + if (calls.isNotEmpty()) return calls + + // Try unclosed tag (model ran out of tokens) + val unclosedMatcher = UNCLOSED_TOOL_CALL_PATTERN.matcher(rawOutput) + if (unclosedMatcher.find()) { + val inner = unclosedMatcher.group(1)?.trim() ?: "" + val balanced = balanceBraces(inner) + parseToolCallJson(balanced)?.let { calls.add(it) } + } + if (calls.isNotEmpty()) return calls + + // Try inline format {"tool_call": {...}} + val inlineMatcher = INLINE_TOOL_CALL_PATTERN.matcher(rawOutput) + if (inlineMatcher.find()) { + val inner = inlineMatcher.group(1)?.trim() ?: "" + parseToolCallJson(inner)?.let { calls.add(it) } + } + + return calls + } + + fun containsToolCall(rawOutput: String): Boolean { + return rawOutput.contains("") || + rawOutput.contains("\"tool_call\"") || + TOOL_CALL_PATTERN.matcher(rawOutput).find() + } + + fun extractCleanText(rawOutput: String): String { + var text = rawOutput + // Remove all ... blocks + text = TOOL_CALL_PATTERN.matcher(text).replaceAll("") + // Remove unclosed to end + text = text.replace(Regex(".*", RegexOption.DOT_MATCHES_ALL), "") + return text.trim() + } + + private fun parseToolCallJson(jsonStr: String): ToolCall? { + val cleaned = fixMalformedJson(jsonStr) + + return try { + val obj = JSONObject(cleaned) + + val toolName = obj.optString("tool", "").ifEmpty { + obj.optString("name", "").ifEmpty { + obj.optString("function", "") + } + } + if (toolName.isEmpty()) return null + + val argsObj = obj.optJSONObject("arguments") + ?: obj.optJSONObject("args") + ?: obj.optJSONObject("parameters") + ?: JSONObject() + + val arguments = mutableMapOf() + val keys = argsObj.keys() + while (keys.hasNext()) { + val key = keys.next() + arguments[key] = argsObj.opt(key) + } + + ToolCall( + id = UUID.randomUUID().toString(), + toolName = toolName, + arguments = arguments + ) + } catch (e: JSONException) { + Log.w(TAG, "Failed to parse tool call JSON: $cleaned", e) + null + } + } + + private fun fixMalformedJson(input: String): String { + var result = input.trim() + + // Remove markdown code fences + result = result.replace("```json", "").replace("```", "").trim() + + // Fix unquoted keys: {tool: "name"} -> {"tool": "name"} + // Only match keys that are not already quoted + result = result.replace(Regex("([{,])\\s*([a-zA-Z_]\\w*)\\s*:")) { match -> + "${match.groupValues[1]}\"${match.groupValues[2]}\":" + } + + // Remove trailing commas before closing braces/brackets + result = result.replace(Regex(",\\s*([}\\]])")) { match -> + match.groupValues[1] + } + + return result + } + + private fun balanceBraces(input: String): String { + var depth = 0 + var inString = false + var escaped = false + + for (i in input.indices) { + val c = input[i] + if (escaped) { + escaped = false + continue + } + if (c == '\\') { + escaped = true + continue + } + if (c == '"') { + inString = !inString + continue + } + if (!inString) { + if (c == '{') depth++ + if (c == '}') depth-- + } + } + + // Append missing closing braces + val sb = StringBuilder(input) + while (depth > 0) { + sb.append('}') + depth-- + } + return sb.toString() + } +} diff --git a/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/toolcalling/ToolCallingTypes.kt b/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/toolcalling/ToolCallingTypes.kt new file mode 100644 index 000000000..3cb406243 --- /dev/null +++ b/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/toolcalling/ToolCallingTypes.kt @@ -0,0 +1,40 @@ +package com.runanywhere.agent.toolcalling + +enum class ToolParameterType { + STRING, INTEGER, NUMBER, BOOLEAN +} + +data class ToolParameter( + val name: String, + val type: ToolParameterType, + val description: String, + val required: Boolean = true, + val enumValues: List? = null +) + +data class ToolDefinition( + val name: String, + val description: String, + val parameters: List +) + +data class ToolCall( + val id: String, + val toolName: String, + val arguments: Map +) + +data class ToolResult( + val toolCallId: String, + val toolName: String, + val result: String, + val isError: Boolean = false +) + +sealed class LLMResponse { + data class UIAction(val json: String) : LLMResponse() + data class UIActionToolCall(val call: ToolCall) : LLMResponse() + data class ToolCalls(val calls: List) : LLMResponse() + data class TextAnswer(val text: String) : LLMResponse() + data class Error(val message: String) : LLMResponse() +} diff --git a/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/toolcalling/ToolPromptFormatter.kt b/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/toolcalling/ToolPromptFormatter.kt new file mode 100644 index 000000000..d9f313895 --- /dev/null +++ b/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/toolcalling/ToolPromptFormatter.kt @@ -0,0 +1,104 @@ +package com.runanywhere.agent.toolcalling + +import org.json.JSONArray +import org.json.JSONObject + +object ToolPromptFormatter { + + /** + * Format tools for injection into local model system prompts. + */ + fun formatForLocalPrompt(tools: List): String { + if (tools.isEmpty()) return "" + + val sb = StringBuilder() + sb.appendLine() + sb.appendLine() + sb.appendLine("AVAILABLE TOOLS:") + sb.appendLine("You can call tools by wrapping a JSON object in tags.") + sb.appendLine("Format: {\"tool\":\"tool_name\",\"arguments\":{\"param\":\"value\"}}") + sb.appendLine() + + tools.forEach { tool -> + sb.appendLine("- ${tool.name}: ${tool.description}") + if (tool.parameters.isNotEmpty()) { + sb.append(" Parameters: ") + sb.appendLine(tool.parameters.joinToString(", ") { p -> + val req = if (p.required) "required" else "optional" + "${p.name} (${p.type.name.lowercase()}, $req): ${p.description}" + }) + } + } + + sb.appendLine() + sb.appendLine("RULES:") + sb.appendLine("- If you need factual information (time, weather, calculations), USE a tool call.") + sb.appendLine("- Only call ONE tool at a time. Wait for the result before proceeding.") + sb.appendLine("- After receiving tool results, decide your next action: another tool call, a UI action, or \"done\".") + sb.appendLine("- For UI navigation tasks, use UI actions (tap, type, swipe) NOT tool calls.") + return sb.toString() + } + + /** + * Format tool results for re-injection into the prompt. + */ + fun formatToolResults(results: List): String { + val sb = StringBuilder() + sb.appendLine() + sb.appendLine("TOOL RESULTS:") + results.forEach { result -> + val status = if (result.isError) "ERROR" else "OK" + sb.appendLine("[${result.toolName}] ($status): ${result.result}") + } + sb.appendLine() + sb.appendLine("Based on the tool results above, decide your next action.") + return sb.toString() + } + + /** + * Convert tool definitions to OpenAI function calling format. + * Returns a JSONArray for the "tools" parameter in the API request. + */ + fun toOpenAIFormat(tools: List): JSONArray { + val array = JSONArray() + tools.forEach { tool -> + val properties = JSONObject() + val required = JSONArray() + + tool.parameters.forEach { param -> + val paramObj = JSONObject().apply { + put("type", when (param.type) { + ToolParameterType.STRING -> "string" + ToolParameterType.INTEGER -> "integer" + ToolParameterType.NUMBER -> "number" + ToolParameterType.BOOLEAN -> "boolean" + }) + put("description", param.description) + param.enumValues?.let { vals -> + put("enum", JSONArray(vals)) + } + } + properties.put(param.name, paramObj) + if (param.required) { + required.put(param.name) + } + } + + val functionObj = JSONObject().apply { + put("name", tool.name) + put("description", tool.description) + put("parameters", JSONObject().apply { + put("type", "object") + put("properties", properties) + put("required", required) + }) + } + + array.put(JSONObject().apply { + put("type", "function") + put("function", functionObj) + }) + } + return array + } +} diff --git a/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/toolcalling/ToolRegistry.kt b/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/toolcalling/ToolRegistry.kt new file mode 100644 index 000000000..618489281 --- /dev/null +++ b/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/toolcalling/ToolRegistry.kt @@ -0,0 +1,60 @@ +package com.runanywhere.agent.toolcalling + +import android.util.Log + +typealias ToolHandler = suspend (arguments: Map) -> String + +class ToolRegistry { + companion object { + private const val TAG = "ToolRegistry" + } + + private val definitions = mutableMapOf() + private val handlers = mutableMapOf() + + fun register(definition: ToolDefinition, handler: ToolHandler) { + definitions[definition.name] = definition + handlers[definition.name] = handler + Log.d(TAG, "Registered tool: ${definition.name}") + } + + fun unregister(name: String) { + definitions.remove(name) + handlers.remove(name) + } + + fun getDefinitions(): List = definitions.values.toList() + + fun getDefinition(name: String): ToolDefinition? = definitions[name] + + fun hasHandler(name: String): Boolean = handlers.containsKey(name) + + fun isEmpty(): Boolean = definitions.isEmpty() + + suspend fun execute(call: ToolCall): ToolResult { + val handler = handlers[call.toolName] + ?: return ToolResult( + toolCallId = call.id, + toolName = call.toolName, + result = "Error: Unknown tool '${call.toolName}'", + isError = true + ) + + return try { + val result = handler(call.arguments) + ToolResult( + toolCallId = call.id, + toolName = call.toolName, + result = result + ) + } catch (e: Exception) { + Log.e(TAG, "Tool execution failed: ${call.toolName}", e) + ToolResult( + toolCallId = call.id, + toolName = call.toolName, + result = "Error executing ${call.toolName}: ${e.message}", + isError = true + ) + } + } +} diff --git a/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/toolcalling/UIActionContext.kt b/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/toolcalling/UIActionContext.kt new file mode 100644 index 000000000..b492127c4 --- /dev/null +++ b/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/toolcalling/UIActionContext.kt @@ -0,0 +1,10 @@ +package com.runanywhere.agent.toolcalling + +/** + * Shared mutable context for UI action tool handlers. + * Updated each step in the agent loop before calling the LLM. + * Tool handlers read from this to get fresh screen coordinates. + */ +class UIActionContext { + @Volatile var indexToCoords: Map> = emptyMap() +} diff --git a/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/toolcalling/UIActionTools.kt b/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/toolcalling/UIActionTools.kt new file mode 100644 index 000000000..c39775c76 --- /dev/null +++ b/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/toolcalling/UIActionTools.kt @@ -0,0 +1,289 @@ +package com.runanywhere.agent.toolcalling + +import com.runanywhere.agent.kernel.ActionExecutor +import com.runanywhere.agent.kernel.Decision + +object UIActionTools { + + fun registerAll(registry: ToolRegistry, ctx: UIActionContext, executor: ActionExecutor) { + registerTap(registry, ctx, executor) + registerType(registry, ctx, executor) + registerEnter(registry, ctx, executor) + registerSwipe(registry, ctx, executor) + registerBack(registry, ctx, executor) + registerHome(registry, ctx, executor) + registerOpenApp(registry, ctx, executor) + registerLongPress(registry, ctx, executor) + registerOpenUrl(registry, ctx, executor) + registerWebSearch(registry, ctx, executor) + registerOpenNotifications(registry, ctx, executor) + registerOpenQuickSettings(registry, ctx, executor) + registerWait(registry, ctx, executor) + registerDone(registry, ctx, executor) + } + + private fun registerTap(registry: ToolRegistry, ctx: UIActionContext, executor: ActionExecutor) { + registry.register( + ToolDefinition( + name = "ui_tap", + description = "Tap a UI element by its index number from the screen elements list", + parameters = listOf( + ToolParameter( + name = "index", + type = ToolParameterType.INTEGER, + description = "The index number of the element to tap", + required = true + ) + ) + ) + ) { args -> + val index = (args["index"] as? Number)?.toInt() + ?: return@register "Error: index parameter required" + val decision = Decision("tap", elementIndex = index) + val result = executor.execute(decision, ctx.indexToCoords) + result.message + } + } + + private fun registerType(registry: ToolRegistry, ctx: UIActionContext, executor: ActionExecutor) { + registry.register( + ToolDefinition( + name = "ui_type", + description = "Type text into the currently focused or editable text field", + parameters = listOf( + ToolParameter( + name = "text", + type = ToolParameterType.STRING, + description = "The text to type", + required = true + ) + ) + ) + ) { args -> + val text = args["text"]?.toString() + ?: return@register "Error: text parameter required" + val decision = Decision("type", text = text) + val result = executor.execute(decision, ctx.indexToCoords) + result.message + } + } + + private fun registerEnter(registry: ToolRegistry, ctx: UIActionContext, executor: ActionExecutor) { + registry.register( + ToolDefinition( + name = "ui_enter", + description = "Press Enter/Submit to confirm input, submit a search query, or press a submit button", + parameters = emptyList() + ) + ) { _ -> + val result = executor.execute(Decision("enter"), ctx.indexToCoords) + result.message + } + } + + private fun registerSwipe(registry: ToolRegistry, ctx: UIActionContext, executor: ActionExecutor) { + registry.register( + ToolDefinition( + name = "ui_swipe", + description = "Swipe/scroll the screen in a direction to reveal more content", + parameters = listOf( + ToolParameter( + name = "direction", + type = ToolParameterType.STRING, + description = "Direction to swipe: up, down, left, or right", + required = true, + enumValues = listOf("up", "down", "left", "right") + ) + ) + ) + ) { args -> + val dir = args["direction"]?.toString() ?: "up" + val mapped = when (dir) { + "up" -> "u" + "down" -> "d" + "left" -> "l" + "right" -> "r" + else -> dir + } + val decision = Decision("swipe", direction = mapped) + val result = executor.execute(decision, ctx.indexToCoords) + result.message + } + } + + private fun registerBack(registry: ToolRegistry, ctx: UIActionContext, executor: ActionExecutor) { + registry.register( + ToolDefinition( + name = "ui_back", + description = "Press the Back button to go to the previous screen", + parameters = emptyList() + ) + ) { _ -> + val result = executor.execute(Decision("back"), ctx.indexToCoords) + result.message + } + } + + private fun registerHome(registry: ToolRegistry, ctx: UIActionContext, executor: ActionExecutor) { + registry.register( + ToolDefinition( + name = "ui_home", + description = "Press the Home button to go to the home screen", + parameters = emptyList() + ) + ) { _ -> + val result = executor.execute(Decision("home"), ctx.indexToCoords) + result.message + } + } + + private fun registerOpenApp(registry: ToolRegistry, ctx: UIActionContext, executor: ActionExecutor) { + registry.register( + ToolDefinition( + name = "ui_open_app", + description = "Open an app by name. Always use this instead of searching for app icons. Examples: YouTube, Chrome, WhatsApp, Settings, Clock, Maps, Spotify, Camera, Gmail", + parameters = listOf( + ToolParameter( + name = "app_name", + type = ToolParameterType.STRING, + description = "The name of the app to open", + required = true + ) + ) + ) + ) { args -> + val appName = args["app_name"]?.toString() + ?: return@register "Error: app_name parameter required" + val decision = Decision("open", text = appName) + val result = executor.execute(decision, ctx.indexToCoords) + result.message + } + } + + private fun registerLongPress(registry: ToolRegistry, ctx: UIActionContext, executor: ActionExecutor) { + registry.register( + ToolDefinition( + name = "ui_long_press", + description = "Long press a UI element by its index number", + parameters = listOf( + ToolParameter( + name = "index", + type = ToolParameterType.INTEGER, + description = "The index number of the element to long press", + required = true + ) + ) + ) + ) { args -> + val index = (args["index"] as? Number)?.toInt() + ?: return@register "Error: index parameter required" + val decision = Decision("long", elementIndex = index) + val result = executor.execute(decision, ctx.indexToCoords) + result.message + } + } + + private fun registerOpenUrl(registry: ToolRegistry, ctx: UIActionContext, executor: ActionExecutor) { + registry.register( + ToolDefinition( + name = "ui_open_url", + description = "Open a URL in the default browser", + parameters = listOf( + ToolParameter( + name = "url", + type = ToolParameterType.STRING, + description = "The URL to open", + required = true + ) + ) + ) + ) { args -> + val url = args["url"]?.toString() + ?: return@register "Error: url parameter required" + val decision = Decision("url", url = url) + val result = executor.execute(decision, ctx.indexToCoords) + result.message + } + } + + private fun registerWebSearch(registry: ToolRegistry, ctx: UIActionContext, executor: ActionExecutor) { + registry.register( + ToolDefinition( + name = "ui_web_search", + description = "Perform a web search using Google", + parameters = listOf( + ToolParameter( + name = "query", + type = ToolParameterType.STRING, + description = "The search query", + required = true + ) + ) + ) + ) { args -> + val query = args["query"]?.toString() + ?: return@register "Error: query parameter required" + val decision = Decision("search", query = query) + val result = executor.execute(decision, ctx.indexToCoords) + result.message + } + } + + private fun registerOpenNotifications(registry: ToolRegistry, ctx: UIActionContext, executor: ActionExecutor) { + registry.register( + ToolDefinition( + name = "ui_open_notifications", + description = "Open the notification shade", + parameters = emptyList() + ) + ) { _ -> + val result = executor.execute(Decision("notif"), ctx.indexToCoords) + result.message + } + } + + private fun registerOpenQuickSettings(registry: ToolRegistry, ctx: UIActionContext, executor: ActionExecutor) { + registry.register( + ToolDefinition( + name = "ui_open_quick_settings", + description = "Open the quick settings panel", + parameters = emptyList() + ) + ) { _ -> + val result = executor.execute(Decision("quick"), ctx.indexToCoords) + result.message + } + } + + private fun registerWait(registry: ToolRegistry, ctx: UIActionContext, executor: ActionExecutor) { + registry.register( + ToolDefinition( + name = "ui_wait", + description = "Wait for the screen to finish loading or for an animation to complete", + parameters = emptyList() + ) + ) { _ -> + val result = executor.execute(Decision("wait"), ctx.indexToCoords) + result.message + } + } + + private fun registerDone(registry: ToolRegistry, ctx: UIActionContext, executor: ActionExecutor) { + registry.register( + ToolDefinition( + name = "ui_done", + description = "Signal that the task/goal has been completed successfully", + parameters = listOf( + ToolParameter( + name = "reason", + type = ToolParameterType.STRING, + description = "Brief explanation of why the task is complete", + required = false + ) + ) + ) + ) { _ -> + "Goal complete" + } + } +} diff --git a/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/toolcalling/UnitConverter.kt b/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/toolcalling/UnitConverter.kt new file mode 100644 index 000000000..8aa627e48 --- /dev/null +++ b/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/toolcalling/UnitConverter.kt @@ -0,0 +1,31 @@ +package com.runanywhere.agent.toolcalling + +object UnitConverter { + fun convert(value: Double, from: String, to: String): String { + val result = when { + // Temperature + from == "celsius" && to == "fahrenheit" -> value * 9.0 / 5.0 + 32 + from == "fahrenheit" && to == "celsius" -> (value - 32) * 5.0 / 9.0 + from == "celsius" && to == "kelvin" -> value + 273.15 + from == "kelvin" && to == "celsius" -> value - 273.15 + from == "fahrenheit" && to == "kelvin" -> (value - 32) * 5.0 / 9.0 + 273.15 + from == "kelvin" && to == "fahrenheit" -> (value - 273.15) * 9.0 / 5.0 + 32 + // Length + from == "km" && to == "miles" -> value * 0.621371 + from == "miles" && to == "km" -> value / 0.621371 + from == "m" && to == "feet" -> value * 3.28084 + from == "feet" && to == "m" -> value / 3.28084 + from == "cm" && to == "inches" -> value / 2.54 + from == "inches" && to == "cm" -> value * 2.54 + // Weight + from == "kg" && to == "lbs" -> value * 2.20462 + from == "lbs" && to == "kg" -> value / 2.20462 + from == "g" && to == "oz" -> value / 28.3495 + from == "oz" && to == "g" -> value * 28.3495 + // Same unit + from == to -> value + else -> return "Unsupported conversion: $from -> $to" + } + return "%.4f %s = %.4f %s".format(value, from, result, to) + } +} diff --git a/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/tts/TTSManager.kt b/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/tts/TTSManager.kt new file mode 100644 index 000000000..4dbb81f8e --- /dev/null +++ b/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/tts/TTSManager.kt @@ -0,0 +1,92 @@ +package com.runanywhere.agent.tts + +import android.content.Context +import android.speech.tts.TextToSpeech +import android.speech.tts.UtteranceProgressListener +import android.util.Log +import kotlinx.coroutines.suspendCancellableCoroutine +import java.util.Locale +import java.util.UUID +import kotlin.coroutines.resume + +class TTSManager(context: Context) { + companion object { + private const val TAG = "TTSManager" + } + + private var tts: TextToSpeech? = null + private var isReady = false + private val pendingCallbacks = mutableMapOf Unit>() + + init { + tts = TextToSpeech(context.applicationContext) { status -> + if (status == TextToSpeech.SUCCESS) { + val result = tts?.setLanguage(Locale.US) + isReady = result != TextToSpeech.LANG_MISSING_DATA && + result != TextToSpeech.LANG_NOT_SUPPORTED + if (isReady) { + tts?.setSpeechRate(1.1f) + tts?.setPitch(1.0f) + Log.i(TAG, "TTS initialized") + } else { + Log.e(TAG, "TTS language not supported") + } + } else { + Log.e(TAG, "TTS init failed: $status") + } + } + + tts?.setOnUtteranceProgressListener(object : UtteranceProgressListener() { + override fun onStart(utteranceId: String?) {} + override fun onDone(utteranceId: String?) { + utteranceId?.let { id -> + pendingCallbacks.remove(id)?.invoke(true) + } + } + @Deprecated("Deprecated in Java") + override fun onError(utteranceId: String?) { + utteranceId?.let { id -> + pendingCallbacks.remove(id)?.invoke(false) + } + } + }) + } + + fun speak(text: String) { + if (!isReady) return + val utteranceId = UUID.randomUUID().toString() + tts?.speak(text, TextToSpeech.QUEUE_ADD, null, utteranceId) + } + + suspend fun speakAndWait(text: String): Boolean { + if (!isReady) return false + return suspendCancellableCoroutine { cont -> + val utteranceId = UUID.randomUUID().toString() + pendingCallbacks[utteranceId] = { success -> cont.resume(success) } + cont.invokeOnCancellation { + pendingCallbacks.remove(utteranceId) + tts?.stop() + } + tts?.speak(text, TextToSpeech.QUEUE_ADD, null, utteranceId) + } + } + + fun speakInterrupt(text: String) { + if (!isReady) return + val utteranceId = UUID.randomUUID().toString() + tts?.speak(text, TextToSpeech.QUEUE_FLUSH, null, utteranceId) + } + + fun stop() { + tts?.stop() + pendingCallbacks.clear() + } + + fun shutdown() { + tts?.stop() + tts?.shutdown() + tts = null + isReady = false + pendingCallbacks.clear() + } +} diff --git a/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/ui/AgentScreen.kt b/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/ui/AgentScreen.kt new file mode 100644 index 000000000..5e4d91a52 --- /dev/null +++ b/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/ui/AgentScreen.kt @@ -0,0 +1,449 @@ +package com.runanywhere.agent.ui + +import android.Manifest +import android.content.pm.PackageManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Mic +import androidx.compose.material.icons.rounded.Stop +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.ContextCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import com.runanywhere.agent.AgentViewModel +import com.runanywhere.agent.ui.components.ModelSelector +import com.runanywhere.agent.ui.components.StatusBadge + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AgentScreen(viewModel: AgentViewModel) { + val uiState by viewModel.uiState.collectAsState() + val lifecycleOwner = LocalLifecycleOwner.current + val context = LocalContext.current + + var hasMicPermission by remember { + mutableStateOf( + ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + ) + } + + val permissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { isGranted -> + hasMicPermission = isGranted + if (isGranted) { + // Permission just granted — load STT model and start recording + viewModel.loadSTTModelIfNeeded() + } + } + + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + viewModel.checkServiceStatus() + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("RunAnywhere Agent") }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ) + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Service Status + if (!uiState.isServiceEnabled) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Accessibility service not enabled", + color = MaterialTheme.colorScheme.onErrorContainer + ) + Button(onClick = { viewModel.openAccessibilitySettings() }) { + Text("Enable") + } + } + } + } + + // Voice Mode Toggle + Model Selector + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + ModelSelector( + models = uiState.availableModels.map { it.name }, + selectedIndex = uiState.selectedModelIndex, + onSelect = viewModel::setModel, + enabled = uiState.status != AgentViewModel.Status.RUNNING, + modifier = Modifier.weight(1f) + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = "Voice", + style = MaterialTheme.typography.labelMedium + ) + Switch( + checked = uiState.isVoiceMode, + onCheckedChange = { viewModel.toggleVoiceMode() }, + enabled = uiState.status != AgentViewModel.Status.RUNNING + ) + } + } + + if (uiState.isVoiceMode) { + // ===== Voice Mode UI ===== + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Status text + val voiceStatusText = when { + uiState.status == AgentViewModel.Status.RUNNING -> "Working..." + uiState.isTranscribing -> "Transcribing..." + uiState.isRecording -> "Listening..." + uiState.isSTTModelLoading -> "Loading voice model..." + uiState.goal.isNotBlank() && uiState.status == AgentViewModel.Status.DONE -> "Done: \"${uiState.goal}\"" + uiState.goal.isNotBlank() -> "\"${uiState.goal}\"" + else -> "Tap the mic to speak" + } + Text( + text = voiceStatusText, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + // Large Mic Button + val micEnabled = !uiState.isTranscribing && + !uiState.isSTTModelLoading && + uiState.status != AgentViewModel.Status.RUNNING + + FilledIconButton( + onClick = { + if (uiState.isRecording) { + viewModel.stopRecordingAndTranscribe() + } else { + if (!hasMicPermission) { + permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + return@FilledIconButton + } + if (!uiState.isSTTModelLoaded && !uiState.isSTTModelLoading) { + viewModel.loadSTTModelIfNeeded() + } + if (uiState.isSTTModelLoaded) { + viewModel.startRecording() + } + } + }, + enabled = micEnabled, + modifier = Modifier.size(80.dp), + shape = CircleShape, + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = if (uiState.isRecording) + MaterialTheme.colorScheme.error + else + MaterialTheme.colorScheme.primary + ) + ) { + when { + uiState.isTranscribing -> CircularProgressIndicator( + modifier = Modifier.size(32.dp), + strokeWidth = 3.dp, + color = MaterialTheme.colorScheme.onPrimary + ) + uiState.isRecording -> Icon( + imageVector = Icons.Rounded.Stop, + contentDescription = "Stop recording", + modifier = Modifier.size(36.dp), + tint = MaterialTheme.colorScheme.onError + ) + else -> Icon( + imageVector = Icons.Rounded.Mic, + contentDescription = "Start recording", + modifier = Modifier.size(36.dp), + tint = MaterialTheme.colorScheme.onPrimary + ) + } + } + + // STT model loading progress + if (uiState.isSTTModelLoading) { + LinearProgressIndicator( + progress = uiState.sttDownloadProgress, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp), + ) + } + + // Stop button when running + if (uiState.status == AgentViewModel.Status.RUNNING) { + Button( + onClick = viewModel::stopAgent, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error + ), + modifier = Modifier.fillMaxWidth() + ) { + Text("Stop Agent") + } + } + } + } else { + // ===== Text Mode UI (existing) ===== + + // Goal Input with Mic Button + OutlinedTextField( + value = uiState.goal, + onValueChange = viewModel::setGoal, + label = { Text("Enter your goal") }, + placeholder = { Text("e.g., 'Play lofi music on YouTube'") }, + modifier = Modifier.fillMaxWidth(), + enabled = uiState.status != AgentViewModel.Status.RUNNING && + !uiState.isRecording && !uiState.isTranscribing, + minLines = 2, + maxLines = 4, + trailingIcon = { + MicButton( + isRecording = uiState.isRecording, + isTranscribing = uiState.isTranscribing, + isSTTModelLoading = uiState.isSTTModelLoading, + isAgentRunning = uiState.status == AgentViewModel.Status.RUNNING, + onClick = { + if (uiState.isRecording) { + viewModel.stopRecordingAndTranscribe() + } else { + if (!hasMicPermission) { + permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + return@MicButton + } + if (!uiState.isSTTModelLoaded && !uiState.isSTTModelLoading) { + viewModel.loadSTTModelIfNeeded() + } + if (uiState.isSTTModelLoaded) { + viewModel.startRecording() + } + } + } + ) + } + ) + + // STT model loading progress + if (uiState.isSTTModelLoading) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + LinearProgressIndicator( + progress = uiState.sttDownloadProgress, + modifier = Modifier.weight(1f), + ) + Text( + text = "${(uiState.sttDownloadProgress * 100).toInt()}%", + style = MaterialTheme.typography.labelSmall + ) + } + } + + // Control Buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + onClick = viewModel::startAgent, + modifier = Modifier.weight(1f), + enabled = uiState.status != AgentViewModel.Status.RUNNING && + uiState.isServiceEnabled && + uiState.goal.isNotBlank() + ) { + Text("Start Agent") + } + + if (uiState.status == AgentViewModel.Status.RUNNING) { + Button( + onClick = viewModel::stopAgent, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error + ) + ) { + Text("Stop") + } + } + } + } + + // Status Badge + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + StatusBadge(status = uiState.status) + + if (uiState.logs.isNotEmpty()) { + TextButton(onClick = viewModel::clearLogs) { + Text("Clear Logs") + } + } + } + + // Log Output + LogPanel( + logs = uiState.logs, + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) + } + } +} + +@Composable +private fun MicButton( + isRecording: Boolean, + isTranscribing: Boolean, + isSTTModelLoading: Boolean, + isAgentRunning: Boolean, + onClick: () -> Unit +) { + IconButton( + onClick = onClick, + enabled = !isTranscribing && !isSTTModelLoading && !isAgentRunning + ) { + when { + isTranscribing -> CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp + ) + isSTTModelLoading -> CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.tertiary + ) + isRecording -> Icon( + imageVector = Icons.Rounded.Stop, + contentDescription = "Stop recording", + tint = MaterialTheme.colorScheme.error + ) + else -> Icon( + imageVector = Icons.Rounded.Mic, + contentDescription = "Start recording", + tint = MaterialTheme.colorScheme.primary + ) + } + } +} + +@Composable +fun LogPanel( + logs: List, + modifier: Modifier = Modifier +) { + val listState = rememberLazyListState() + + LaunchedEffect(logs.size) { + if (logs.isNotEmpty()) { + listState.animateScrollToItem(logs.size - 1) + } + } + + Card( + modifier = modifier, + colors = CardDefaults.cardColors( + containerColor = Color(0xFF1E1E1E) + ), + shape = RoundedCornerShape(8.dp) + ) { + if (logs.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "Agent logs will appear here", + color = Color.Gray + ) + } + } else { + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize() + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + items(logs) { log -> + val color = when { + log.startsWith("ERROR") -> Color(0xFFFF6B6B) + log.startsWith("Step") -> Color(0xFF69DB7C) + log.contains("Downloading") -> Color(0xFF74C0FC) + log.contains("done", ignoreCase = true) -> Color(0xFF69DB7C) + else -> Color(0xFFADB5BD) + } + Text( + text = log, + color = color, + fontSize = 13.sp, + fontFamily = FontFamily.Monospace, + lineHeight = 18.sp + ) + } + } + } + } +} diff --git a/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/ui/components/ModelSelector.kt b/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/ui/components/ModelSelector.kt new file mode 100644 index 000000000..f7b1554fc --- /dev/null +++ b/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/ui/components/ModelSelector.kt @@ -0,0 +1,54 @@ +package com.runanywhere.agent.ui.components + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ModelSelector( + models: List, + selectedIndex: Int, + onSelect: (Int) -> Unit, + enabled: Boolean = true, + modifier: Modifier = Modifier +) { + var expanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { if (enabled) expanded = !expanded }, + modifier = modifier.fillMaxWidth() + ) { + OutlinedTextField( + value = models.getOrNull(selectedIndex) ?: "", + onValueChange = {}, + readOnly = true, + label = { Text("Model") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(), + modifier = Modifier + .menuAnchor() + .fillMaxWidth(), + enabled = enabled + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + models.forEachIndexed { index, model -> + DropdownMenuItem( + text = { Text(model) }, + onClick = { + onSelect(index) + expanded = false + }, + contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding + ) + } + } + } +} diff --git a/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/ui/components/StatusBadge.kt b/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/ui/components/StatusBadge.kt new file mode 100644 index 000000000..aedc491ee --- /dev/null +++ b/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/ui/components/StatusBadge.kt @@ -0,0 +1,51 @@ +package com.runanywhere.agent.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.runanywhere.agent.AgentViewModel + +@Composable +fun StatusBadge( + status: AgentViewModel.Status, + modifier: Modifier = Modifier +) { + val (color, text) = when (status) { + AgentViewModel.Status.IDLE -> Pair(Color(0xFF6C757D), "IDLE") + AgentViewModel.Status.RUNNING -> Pair(Color(0xFF0D6EFD), "RUNNING") + AgentViewModel.Status.DONE -> Pair(Color(0xFF198754), "DONE") + AgentViewModel.Status.ERROR -> Pair(Color(0xFFDC3545), "ERROR") + } + + Row( + modifier = modifier + .clip(RoundedCornerShape(16.dp)) + .background(color.copy(alpha = 0.15f)) + .padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(color) + ) + Text( + text = text, + color = color, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold + ) + } +} diff --git a/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/ui/theme/Theme.kt b/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/ui/theme/Theme.kt new file mode 100644 index 000000000..c9aa96614 --- /dev/null +++ b/Playground/android-use-agent/app/src/main/java/com/runanywhere/agent/ui/theme/Theme.kt @@ -0,0 +1,90 @@ +package com.runanywhere.agent.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +private val DarkColorScheme = darkColorScheme( + primary = Color(0xFF6366F1), + onPrimary = Color.White, + primaryContainer = Color(0xFF1E1B4B), + onPrimaryContainer = Color(0xFFE0E0FF), + secondary = Color(0xFF22D3EE), + onSecondary = Color.Black, + secondaryContainer = Color(0xFF164E63), + onSecondaryContainer = Color(0xFFE0F7FF), + tertiary = Color(0xFFA78BFA), + onTertiary = Color.Black, + background = Color(0xFF0F0F23), + onBackground = Color(0xFFF1F5F9), + surface = Color(0xFF1A1A2E), + onSurface = Color(0xFFF1F5F9), + surfaceVariant = Color(0xFF2A2A4A), + onSurfaceVariant = Color(0xFFCAC4D0), + error = Color(0xFFEF4444), + onError = Color.White, + errorContainer = Color(0xFF7F1D1D), + onErrorContainer = Color(0xFFFECACA) +) + +private val LightColorScheme = lightColorScheme( + primary = Color(0xFF4F46E5), + onPrimary = Color.White, + primaryContainer = Color(0xFFE0E0FF), + onPrimaryContainer = Color(0xFF1E1B4B), + secondary = Color(0xFF0891B2), + onSecondary = Color.White, + secondaryContainer = Color(0xFFE0F7FF), + onSecondaryContainer = Color(0xFF164E63), + tertiary = Color(0xFF7C3AED), + onTertiary = Color.White, + background = Color(0xFFF8FAFC), + onBackground = Color(0xFF1E293B), + surface = Color.White, + onSurface = Color(0xFF1E293B), + surfaceVariant = Color(0xFFF1F5F9), + onSurfaceVariant = Color(0xFF475569), + error = Color(0xFFDC2626), + onError = Color.White, + errorContainer = Color(0xFFFEE2E2), + onErrorContainer = Color(0xFF7F1D1D) +) + +@Composable +fun RunAnywhereAgentTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = false, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = colorScheme.primaryContainer.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme + } + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography(), + content = content + ) +} diff --git a/Playground/android-use-agent/app/src/main/res/values/strings.xml b/Playground/android-use-agent/app/src/main/res/values/strings.xml new file mode 100644 index 000000000..cbfb9b82e --- /dev/null +++ b/Playground/android-use-agent/app/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + + RunAnywhere Agent + Allows the AI agent to read screen content and perform actions like tapping, typing, and swiping to accomplish your goals. + diff --git a/Playground/android-use-agent/app/src/main/res/values/themes.xml b/Playground/android-use-agent/app/src/main/res/values/themes.xml new file mode 100644 index 000000000..c1af9cd5a --- /dev/null +++ b/Playground/android-use-agent/app/src/main/res/values/themes.xml @@ -0,0 +1,7 @@ + + + + diff --git a/Playground/android-use-agent/app/src/main/res/xml/agent_accessibility_service.xml b/Playground/android-use-agent/app/src/main/res/xml/agent_accessibility_service.xml new file mode 100644 index 000000000..6fb5b8126 --- /dev/null +++ b/Playground/android-use-agent/app/src/main/res/xml/agent_accessibility_service.xml @@ -0,0 +1,11 @@ + + diff --git a/Playground/android-use-agent/build.gradle.kts b/Playground/android-use-agent/build.gradle.kts new file mode 100644 index 000000000..cfd711d98 --- /dev/null +++ b/Playground/android-use-agent/build.gradle.kts @@ -0,0 +1,5 @@ +plugins { + id("com.android.application") version "8.7.3" apply false + id("org.jetbrains.kotlin.android") version "2.1.0" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.1.0" apply false +} diff --git a/Playground/android-use-agent/gradle/wrapper/gradle-wrapper.jar b/Playground/android-use-agent/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..033e24c4c Binary files /dev/null and b/Playground/android-use-agent/gradle/wrapper/gradle-wrapper.jar differ diff --git a/Playground/android-use-agent/gradle/wrapper/gradle-wrapper.properties b/Playground/android-use-agent/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..37f853b1c --- /dev/null +++ b/Playground/android-use-agent/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/Playground/android-use-agent/gradlew b/Playground/android-use-agent/gradlew new file mode 100755 index 000000000..ae5eddbdc --- /dev/null +++ b/Playground/android-use-agent/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/Playground/android-use-agent/gradlew.bat b/Playground/android-use-agent/gradlew.bat new file mode 100644 index 000000000..ea603b410 --- /dev/null +++ b/Playground/android-use-agent/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/Playground/android-use-agent/libs/RunAnywhereKotlinSDK-release.aar b/Playground/android-use-agent/libs/RunAnywhereKotlinSDK-release.aar new file mode 100644 index 000000000..36036ff43 Binary files /dev/null and b/Playground/android-use-agent/libs/RunAnywhereKotlinSDK-release.aar differ diff --git a/Playground/android-use-agent/libs/runanywhere-core-llamacpp-release.aar b/Playground/android-use-agent/libs/runanywhere-core-llamacpp-release.aar new file mode 100644 index 000000000..ab8d6561a Binary files /dev/null and b/Playground/android-use-agent/libs/runanywhere-core-llamacpp-release.aar differ diff --git a/Playground/android-use-agent/libs/runanywhere-core-onnx-release.aar b/Playground/android-use-agent/libs/runanywhere-core-onnx-release.aar new file mode 100644 index 000000000..3ba0911e3 Binary files /dev/null and b/Playground/android-use-agent/libs/runanywhere-core-onnx-release.aar differ diff --git a/Playground/android-use-agent/settings.gradle.kts b/Playground/android-use-agent/settings.gradle.kts new file mode 100644 index 000000000..6e34c2b00 --- /dev/null +++ b/Playground/android-use-agent/settings.gradle.kts @@ -0,0 +1,21 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + flatDir { + dirs("libs") + } + } +} + +rootProject.name = "RunAnywhereAgent" +include(":app") diff --git a/Playground/on-device-browser-agent/.gitignore b/Playground/on-device-browser-agent/.gitignore new file mode 100644 index 000000000..d2cade700 --- /dev/null +++ b/Playground/on-device-browser-agent/.gitignore @@ -0,0 +1,24 @@ +# Dependencies +node_modules/ + +# Build output +dist/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* + +# Environment +.env +.env.local +.env.*.local diff --git a/Playground/on-device-browser-agent/LICENSE b/Playground/on-device-browser-agent/LICENSE new file mode 100644 index 000000000..1f7974c86 --- /dev/null +++ b/Playground/on-device-browser-agent/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Local Browser Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Playground/on-device-browser-agent/README.md b/Playground/on-device-browser-agent/README.md new file mode 100644 index 000000000..321fcb987 --- /dev/null +++ b/Playground/on-device-browser-agent/README.md @@ -0,0 +1,175 @@ +# Local Browser - On-Device AI Web Automation + +# Launching support for runanywhere-web-sdk soon in our main repo: please go check it out: https://github.com/RunanywhereAI/runanywhere-sdks + +A Chrome extension that uses WebLLM to run AI-powered web automation entirely on-device. No cloud APIs, no API keys, fully private. + +## Demo + +https://github.com/user-attachments/assets/898cc5c2-db77-4067-96e6-233c5da2bae5 + + +## Features + +- **On-Device AI**: Uses WebLLM with WebGPU acceleration for local LLM inference +- **Multi-Agent System**: Planner + Navigator agents for intelligent task execution +- **Browser Automation**: Navigate, click, type, extract data from web pages +- **Privacy-First**: All AI runs locally, no data leaves your device +- **Offline Support**: Works offline after initial model download + +## Quick Start + +### Prerequisites + +- **Chrome 124+** (required for WebGPU in service workers) +- **Node.js 18+** and npm +- **GPU with WebGPU support** (most modern GPUs work) + +### Installation + +1. **Clone and install dependencies**: + ```bash + cd local-browser + npm install + ``` + +2. **Build the extension**: + ```bash + npm run build + ``` + +3. **Load in Chrome**: + - Open `chrome://extensions` + - Enable "Developer mode" (top right) + - Click "Load unpacked" + - Select the `dist` folder from this project + +4. **First run**: + - Click the extension icon in your toolbar + - The first run will download the AI model (~1GB) + - This is cached for future use + +### Usage + +1. Navigate to any webpage +2. Click the Local Browser extension icon +3. Type a task like: + - "Search for 'WebGPU' on Wikipedia and extract the first paragraph" + - "Go to example.com and tell me what's there" + - "Find the search box and search for 'AI news'" +4. Watch the AI execute the task step by step + +## Development + +### Development Mode + +```bash +npm run dev +``` + +This watches for changes and rebuilds automatically. + +### Project Structure + +``` +local-browser/ +├── manifest.json # Chrome extension manifest (MV3) +├── src/ +│ ├── background/ # Service worker +│ │ ├── index.ts # Entry point & message handling +│ │ ├── llm-engine.ts # WebLLM wrapper +│ │ └── agents/ # AI agent system +│ │ ├── base-agent.ts +│ │ ├── planner-agent.ts +│ │ ├── navigator-agent.ts +│ │ └── executor.ts +│ ├── content/ # Content scripts +│ │ ├── dom-observer.ts # Page state extraction +│ │ └── action-executor.ts +│ ├── popup/ # React popup UI +│ │ ├── App.tsx +│ │ └── components/ +│ └── shared/ # Shared types & constants +└── dist/ # Build output +``` + +### How It Works + +1. **User enters a task** in the popup UI +2. **Planner Agent** analyzes the task and creates a high-level strategy +3. **Navigator Agent** examines the current page DOM and decides on the next action +4. **Content Script** executes the action (click, type, extract, etc.) +5. Loop continues until task is complete or fails + +### Agent System + +The extension uses a two-agent architecture inspired by Nanobrowser: + +- **PlannerAgent**: Strategic planning, creates step-by-step approach +- **NavigatorAgent**: Tactical execution, chooses specific actions based on page state + +Both agents output structured JSON that is parsed and executed. + +## Model Configuration + +Default model: `Qwen2.5-1.5B-Instruct-q4f16_1-MLC` (~1GB) + +Alternative models (configured in `src/shared/constants.ts`): +- `Phi-3.5-mini-instruct-q4f16_1-MLC` (~2GB, better reasoning) +- `Llama-3.2-1B-Instruct-q4f16_1-MLC` (~0.7GB, smaller) + +## Troubleshooting + +### WebGPU not supported +- Update Chrome to version 124 or later +- Check `chrome://gpu` to verify WebGPU status +- Some GPUs may not support WebGPU + +### Model fails to load +- Ensure you have enough disk space (~2GB free) +- Check browser console for errors +- Try clearing the extension's storage and reloading + +### Actions not executing +- Some pages block content scripts (chrome://, extension pages) +- Try on a regular webpage like wikipedia.org + +### Extension not working after Chrome update +- Go to `chrome://extensions` +- Click the reload button on the extension + +## Limitations + +- **POC Scope**: This is a proof-of-concept, not production software +- **No Vision**: Uses text-only DOM analysis (no screenshot understanding) +- **Single Tab**: Only works with the currently active tab +- **Basic Actions**: Supports navigate, click, type, extract, scroll, wait +- **Model Size**: Smaller models may struggle with complex tasks + +## Tech Stack + +- **WebLLM**: On-device LLM inference with WebGPU +- **React**: Popup UI +- **TypeScript**: Type-safe development +- **Vite + CRXJS**: Chrome extension bundling +- **Chrome Extension Manifest V3**: Modern extension architecture + +## Credits + +This project is inspired by: +- [Nanobrowser](https://github.com/nanobrowser/nanobrowser) - Multi-agent web automation (MIT License) +- [WebLLM](https://github.com/mlc-ai/web-llm) - In-browser LLM inference (Apache-2.0 License) + +### Dependency Licenses + +| Package | License | +|---------|---------| +| @mlc-ai/web-llm | Apache-2.0 | +| React | MIT | +| Vite | MIT | +| @crxjs/vite-plugin | MIT | +| TypeScript | Apache-2.0 | + +## License + +MIT License - See LICENSE file for details. diff --git a/assets/demo.mp4 b/Playground/on-device-browser-agent/assets/demo.mp4 similarity index 100% rename from assets/demo.mp4 rename to Playground/on-device-browser-agent/assets/demo.mp4 diff --git a/manifest.json b/Playground/on-device-browser-agent/manifest.json similarity index 100% rename from manifest.json rename to Playground/on-device-browser-agent/manifest.json diff --git a/package-lock.json b/Playground/on-device-browser-agent/package-lock.json similarity index 100% rename from package-lock.json rename to Playground/on-device-browser-agent/package-lock.json diff --git a/package.json b/Playground/on-device-browser-agent/package.json similarity index 100% rename from package.json rename to Playground/on-device-browser-agent/package.json diff --git a/public/icons/icon128.png b/Playground/on-device-browser-agent/public/icons/icon128.png similarity index 100% rename from public/icons/icon128.png rename to Playground/on-device-browser-agent/public/icons/icon128.png diff --git a/public/icons/icon16.png b/Playground/on-device-browser-agent/public/icons/icon16.png similarity index 100% rename from public/icons/icon16.png rename to Playground/on-device-browser-agent/public/icons/icon16.png diff --git a/public/icons/icon48.png b/Playground/on-device-browser-agent/public/icons/icon48.png similarity index 100% rename from public/icons/icon48.png rename to Playground/on-device-browser-agent/public/icons/icon48.png diff --git a/src/background/agents/amazon-state-machine.ts b/Playground/on-device-browser-agent/src/background/agents/amazon-state-machine.ts similarity index 100% rename from src/background/agents/amazon-state-machine.ts rename to Playground/on-device-browser-agent/src/background/agents/amazon-state-machine.ts diff --git a/src/background/agents/base-agent.ts b/Playground/on-device-browser-agent/src/background/agents/base-agent.ts similarity index 100% rename from src/background/agents/base-agent.ts rename to Playground/on-device-browser-agent/src/background/agents/base-agent.ts diff --git a/src/background/agents/change-observer.ts b/Playground/on-device-browser-agent/src/background/agents/change-observer.ts similarity index 100% rename from src/background/agents/change-observer.ts rename to Playground/on-device-browser-agent/src/background/agents/change-observer.ts diff --git a/src/background/agents/executor.ts b/Playground/on-device-browser-agent/src/background/agents/executor.ts similarity index 100% rename from src/background/agents/executor.ts rename to Playground/on-device-browser-agent/src/background/agents/executor.ts diff --git a/src/background/agents/navigator-agent.ts b/Playground/on-device-browser-agent/src/background/agents/navigator-agent.ts similarity index 100% rename from src/background/agents/navigator-agent.ts rename to Playground/on-device-browser-agent/src/background/agents/navigator-agent.ts diff --git a/src/background/agents/obstacle-detector.ts b/Playground/on-device-browser-agent/src/background/agents/obstacle-detector.ts similarity index 100% rename from src/background/agents/obstacle-detector.ts rename to Playground/on-device-browser-agent/src/background/agents/obstacle-detector.ts diff --git a/src/background/agents/planner-agent.ts b/Playground/on-device-browser-agent/src/background/agents/planner-agent.ts similarity index 100% rename from src/background/agents/planner-agent.ts rename to Playground/on-device-browser-agent/src/background/agents/planner-agent.ts diff --git a/src/background/agents/site-router.ts b/Playground/on-device-browser-agent/src/background/agents/site-router.ts similarity index 100% rename from src/background/agents/site-router.ts rename to Playground/on-device-browser-agent/src/background/agents/site-router.ts diff --git a/src/background/agents/state-machines/youtube.ts b/Playground/on-device-browser-agent/src/background/agents/state-machines/youtube.ts similarity index 100% rename from src/background/agents/state-machines/youtube.ts rename to Playground/on-device-browser-agent/src/background/agents/state-machines/youtube.ts diff --git a/src/background/agents/vision-executor.ts b/Playground/on-device-browser-agent/src/background/agents/vision-executor.ts similarity index 100% rename from src/background/agents/vision-executor.ts rename to Playground/on-device-browser-agent/src/background/agents/vision-executor.ts diff --git a/src/background/agents/vision-navigator.ts b/Playground/on-device-browser-agent/src/background/agents/vision-navigator.ts similarity index 100% rename from src/background/agents/vision-navigator.ts rename to Playground/on-device-browser-agent/src/background/agents/vision-navigator.ts diff --git a/src/background/index.ts b/Playground/on-device-browser-agent/src/background/index.ts similarity index 100% rename from src/background/index.ts rename to Playground/on-device-browser-agent/src/background/index.ts diff --git a/src/background/llm-engine.ts b/Playground/on-device-browser-agent/src/background/llm-engine.ts similarity index 100% rename from src/background/llm-engine.ts rename to Playground/on-device-browser-agent/src/background/llm-engine.ts diff --git a/src/background/vision-engine.ts b/Playground/on-device-browser-agent/src/background/vision-engine.ts similarity index 100% rename from src/background/vision-engine.ts rename to Playground/on-device-browser-agent/src/background/vision-engine.ts diff --git a/src/content/action-executor.ts b/Playground/on-device-browser-agent/src/content/action-executor.ts similarity index 100% rename from src/content/action-executor.ts rename to Playground/on-device-browser-agent/src/content/action-executor.ts diff --git a/src/content/dom-observer.ts b/Playground/on-device-browser-agent/src/content/dom-observer.ts similarity index 100% rename from src/content/dom-observer.ts rename to Playground/on-device-browser-agent/src/content/dom-observer.ts diff --git a/src/content/index.ts b/Playground/on-device-browser-agent/src/content/index.ts similarity index 100% rename from src/content/index.ts rename to Playground/on-device-browser-agent/src/content/index.ts diff --git a/src/offscreen/offscreen.html b/Playground/on-device-browser-agent/src/offscreen/offscreen.html similarity index 100% rename from src/offscreen/offscreen.html rename to Playground/on-device-browser-agent/src/offscreen/offscreen.html diff --git a/src/offscreen/offscreen.ts b/Playground/on-device-browser-agent/src/offscreen/offscreen.ts similarity index 100% rename from src/offscreen/offscreen.ts rename to Playground/on-device-browser-agent/src/offscreen/offscreen.ts diff --git a/src/offscreen/vision.ts b/Playground/on-device-browser-agent/src/offscreen/vision.ts similarity index 100% rename from src/offscreen/vision.ts rename to Playground/on-device-browser-agent/src/offscreen/vision.ts diff --git a/src/popup/App.tsx b/Playground/on-device-browser-agent/src/popup/App.tsx similarity index 100% rename from src/popup/App.tsx rename to Playground/on-device-browser-agent/src/popup/App.tsx diff --git a/src/popup/components/ModelStatus.tsx b/Playground/on-device-browser-agent/src/popup/components/ModelStatus.tsx similarity index 100% rename from src/popup/components/ModelStatus.tsx rename to Playground/on-device-browser-agent/src/popup/components/ModelStatus.tsx diff --git a/src/popup/components/ProgressDisplay.tsx b/Playground/on-device-browser-agent/src/popup/components/ProgressDisplay.tsx similarity index 100% rename from src/popup/components/ProgressDisplay.tsx rename to Playground/on-device-browser-agent/src/popup/components/ProgressDisplay.tsx diff --git a/src/popup/components/ResultView.tsx b/Playground/on-device-browser-agent/src/popup/components/ResultView.tsx similarity index 100% rename from src/popup/components/ResultView.tsx rename to Playground/on-device-browser-agent/src/popup/components/ResultView.tsx diff --git a/src/popup/components/TaskInput.tsx b/Playground/on-device-browser-agent/src/popup/components/TaskInput.tsx similarity index 100% rename from src/popup/components/TaskInput.tsx rename to Playground/on-device-browser-agent/src/popup/components/TaskInput.tsx diff --git a/src/popup/index.html b/Playground/on-device-browser-agent/src/popup/index.html similarity index 100% rename from src/popup/index.html rename to Playground/on-device-browser-agent/src/popup/index.html diff --git a/src/popup/index.tsx b/Playground/on-device-browser-agent/src/popup/index.tsx similarity index 100% rename from src/popup/index.tsx rename to Playground/on-device-browser-agent/src/popup/index.tsx diff --git a/src/popup/styles.css b/Playground/on-device-browser-agent/src/popup/styles.css similarity index 100% rename from src/popup/styles.css rename to Playground/on-device-browser-agent/src/popup/styles.css diff --git a/src/shared/constants.ts b/Playground/on-device-browser-agent/src/shared/constants.ts similarity index 100% rename from src/shared/constants.ts rename to Playground/on-device-browser-agent/src/shared/constants.ts diff --git a/src/shared/types.ts b/Playground/on-device-browser-agent/src/shared/types.ts similarity index 100% rename from src/shared/types.ts rename to Playground/on-device-browser-agent/src/shared/types.ts diff --git a/src/vite-env.d.ts b/Playground/on-device-browser-agent/src/vite-env.d.ts similarity index 100% rename from src/vite-env.d.ts rename to Playground/on-device-browser-agent/src/vite-env.d.ts diff --git a/tsconfig.json b/Playground/on-device-browser-agent/tsconfig.json similarity index 100% rename from tsconfig.json rename to Playground/on-device-browser-agent/tsconfig.json diff --git a/tsconfig.node.json b/Playground/on-device-browser-agent/tsconfig.node.json similarity index 100% rename from tsconfig.node.json rename to Playground/on-device-browser-agent/tsconfig.node.json diff --git a/vite.config.ts b/Playground/on-device-browser-agent/vite.config.ts similarity index 100% rename from vite.config.ts rename to Playground/on-device-browser-agent/vite.config.ts diff --git a/Playground/swift-starter-app/.gitignore b/Playground/swift-starter-app/.gitignore new file mode 100644 index 000000000..af653bdc8 --- /dev/null +++ b/Playground/swift-starter-app/.gitignore @@ -0,0 +1,68 @@ +# Xcode +*.xcodeproj/project.xcworkspace/ +*.xcodeproj/xcuserdata/ +*.xcworkspace/xcuserdata/ +DerivedData/ +*.moved-aside +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata/ + +# Build products +build/ +*.ipa +*.dSYM.zip +*.dSYM + +# Swift Package Manager +.build/ +.swiftpm/ +Package.resolved +Packages/ + +# CocoaPods (if used) +Pods/ +Podfile.lock + +# Carthage (if used) +Carthage/Build/ +Carthage/Checkouts/ + +# macOS +.DS_Store +.AppleDouble +.LSOverride +._* + +# Thumbnails +Thumbs.db + +# IDE +*.swp +*.swo +*~ +.idea/ +.vscode/ + +# Secrets/credentials +*.pem +*.p12 +*.key +Secrets.swift +secrets.json + +# Archives +*.xcarchive + +# Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Generated files +GeneratedAssetSymbols.swift diff --git a/Playground/swift-starter-app/LocalAIPlayground.xcodeproj/project.pbxproj b/Playground/swift-starter-app/LocalAIPlayground.xcodeproj/project.pbxproj new file mode 100644 index 000000000..4ad820149 --- /dev/null +++ b/Playground/swift-starter-app/LocalAIPlayground.xcodeproj/project.pbxproj @@ -0,0 +1,640 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 63; + objects = { + +/* Begin PBXBuildFile section */ + 0079A2EE414D053682F84505 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35A5642D3037CD724D605349 /* ContentView.swift */; }; + 267A68AE52D052D23BEFA45C /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F50613AA5CECCC71FB36FD4 /* ChatView.swift */; }; + 2FE4B80767F3C5C9BD896F08 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F9805554D4145096906D3000 /* Assets.xcassets */; }; + 38E8771F70F729FDE52C3126 /* LocalAIPlaygroundApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = C68C1DE4138BD6E4CF4B853B /* LocalAIPlaygroundApp.swift */; }; + 441E215ACED46550A9D3483D /* AudioVisualizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19C81DF8540FEE16C3080B14 /* AudioVisualizer.swift */; }; + 50BE5992EA7FC86481626DFC /* RunAnywhere in Frameworks */ = {isa = PBXBuildFile; productRef = 4708A0E1E68F2A0ED6C75F5E /* RunAnywhere */; }; + 55B4591569F49E398E1787E1 /* SpeechToTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B960E8A032C7CACB970020F /* SpeechToTextView.swift */; }; + 5D7F01ED3C7B8C6D0F8BA084 /* LocalAIPlaygroundUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A382C8D9BC7466DDAD1C2C8 /* LocalAIPlaygroundUITestsLaunchTests.swift */; }; + 65987D626D5989F86D4EB113 /* AppTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9C113694A33FADBA93B2CCB /* AppTheme.swift */; }; + 6EC97B51C1316C2CCD586DE8 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6880206BAD1B232E9741DA04 /* HomeView.swift */; }; + 81478EA260A26EF6CC520BC2 /* LocalAIPlaygroundUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DB4E24A13909FC3B89C002D /* LocalAIPlaygroundUITests.swift */; }; + 92DE7A61DC9B33637B94CCDB /* RunAnywhereLlamaCPP in Frameworks */ = {isa = PBXBuildFile; productRef = EDC5DF4346D87B072168CD4B /* RunAnywhereLlamaCPP */; }; + 9EFFDD7EB2711C0544E99D17 /* VoicePipelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1547ABB6CD6581E79431F511 /* VoicePipelineView.swift */; }; + B73A385449409DABE96EE24D /* ModelLoaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAAD407B5DAD4E29187E40D3 /* ModelLoaderView.swift */; }; + BD28EBB43D5EF7872A9D2625 /* MessageBubble.swift in Sources */ = {isa = PBXBuildFile; fileRef = 005468580CEB697AA94105AF /* MessageBubble.swift */; }; + C3D253F3CA72D9E1605B755C /* TextToSpeechView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F7662F4B03561759FAF0338 /* TextToSpeechView.swift */; }; + CA235C5C0C0D1D71EE36A02D /* AudioService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AF64059E5ED2CD6D960366E /* AudioService.swift */; }; + D44797359C296AC326DFA93E /* RunAnywhereONNX in Frameworks */ = {isa = PBXBuildFile; productRef = E2BA9B69F125F3A63E8D353C /* RunAnywhereONNX */; }; + DB1A6F9E3E6FC2FC38281C71 /* LocalAIPlaygroundTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C6A1695FAF244788213588 /* LocalAIPlaygroundTests.swift */; }; + FD21F46D95C4FDC04AB6CD67 /* ModelService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56097496010DE0EFABD80127 /* ModelService.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 6F3120042F0DB2B9471475B5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 5E9BA79E61A256CA108E256E /* Project object */; + proxyType = 1; + remoteGlobalIDString = 086C80D5D6FBC76AE358FB2E; + remoteInfo = LocalAIPlayground; + }; + E5ECDEE736991241D297D481 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 5E9BA79E61A256CA108E256E /* Project object */; + proxyType = 1; + remoteGlobalIDString = 086C80D5D6FBC76AE358FB2E; + remoteInfo = LocalAIPlayground; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 005468580CEB697AA94105AF /* MessageBubble.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageBubble.swift; sourceTree = ""; }; + 1547ABB6CD6581E79431F511 /* VoicePipelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoicePipelineView.swift; sourceTree = ""; }; + 19C81DF8540FEE16C3080B14 /* AudioVisualizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioVisualizer.swift; sourceTree = ""; }; + 1F50613AA5CECCC71FB36FD4 /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = ""; }; + 2AF64059E5ED2CD6D960366E /* AudioService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioService.swift; sourceTree = ""; }; + 2F7662F4B03561759FAF0338 /* TextToSpeechView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextToSpeechView.swift; sourceTree = ""; }; + 35A5642D3037CD724D605349 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 3A382C8D9BC7466DDAD1C2C8 /* LocalAIPlaygroundUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAIPlaygroundUITestsLaunchTests.swift; sourceTree = ""; }; + 4B960E8A032C7CACB970020F /* SpeechToTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeechToTextView.swift; sourceTree = ""; }; + 56097496010DE0EFABD80127 /* ModelService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelService.swift; sourceTree = ""; }; + 6880206BAD1B232E9741DA04 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; + 6DB4E24A13909FC3B89C002D /* LocalAIPlaygroundUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAIPlaygroundUITests.swift; sourceTree = ""; }; + 785419DBE87ADC405A73668B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 895392F58C7E8ABC845C9470 /* LocalAIPlayground.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LocalAIPlayground.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 9FC4ABA769C5EA2869C83AD8 /* LocalAIPlaygroundUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = LocalAIPlaygroundUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + C68C1DE4138BD6E4CF4B853B /* LocalAIPlaygroundApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAIPlaygroundApp.swift; sourceTree = ""; }; + C9C113694A33FADBA93B2CCB /* AppTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTheme.swift; sourceTree = ""; }; + CC087E7ECEE406FA081B4E9A /* LocalAIPlaygroundTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = LocalAIPlaygroundTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + D6C6A1695FAF244788213588 /* LocalAIPlaygroundTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAIPlaygroundTests.swift; sourceTree = ""; }; + EAAD407B5DAD4E29187E40D3 /* ModelLoaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelLoaderView.swift; sourceTree = ""; }; + F9805554D4145096906D3000 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + D148601FC51409628420C9C9 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 50BE5992EA7FC86481626DFC /* RunAnywhere in Frameworks */, + 92DE7A61DC9B33637B94CCDB /* RunAnywhereLlamaCPP in Frameworks */, + D44797359C296AC326DFA93E /* RunAnywhereONNX in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 0603FBAB61472F24C837A25F /* LocalAIPlaygroundTests */ = { + isa = PBXGroup; + children = ( + D6C6A1695FAF244788213588 /* LocalAIPlaygroundTests.swift */, + ); + path = LocalAIPlaygroundTests; + sourceTree = ""; + }; + 15FEDBEA15025FC4D56C8D0D /* Views */ = { + isa = PBXGroup; + children = ( + 1F50613AA5CECCC71FB36FD4 /* ChatView.swift */, + 6880206BAD1B232E9741DA04 /* HomeView.swift */, + 4B960E8A032C7CACB970020F /* SpeechToTextView.swift */, + 2F7662F4B03561759FAF0338 /* TextToSpeechView.swift */, + 1547ABB6CD6581E79431F511 /* VoicePipelineView.swift */, + ); + path = Views; + sourceTree = ""; + }; + 21A1084674A3E3942231BCDC /* LocalAIPlaygroundUITests */ = { + isa = PBXGroup; + children = ( + 6DB4E24A13909FC3B89C002D /* LocalAIPlaygroundUITests.swift */, + 3A382C8D9BC7466DDAD1C2C8 /* LocalAIPlaygroundUITestsLaunchTests.swift */, + ); + path = LocalAIPlaygroundUITests; + sourceTree = ""; + }; + 7DB0C29169B853597DD5142C /* Services */ = { + isa = PBXGroup; + children = ( + 2AF64059E5ED2CD6D960366E /* AudioService.swift */, + 56097496010DE0EFABD80127 /* ModelService.swift */, + ); + path = Services; + sourceTree = ""; + }; + 7E6FED8BB2E00AACDAA7B86E /* LocalAIPlayground */ = { + isa = PBXGroup; + children = ( + F9805554D4145096906D3000 /* Assets.xcassets */, + 35A5642D3037CD724D605349 /* ContentView.swift */, + 785419DBE87ADC405A73668B /* Info.plist */, + C68C1DE4138BD6E4CF4B853B /* LocalAIPlaygroundApp.swift */, + BC535C2AB0B29DC6A052D241 /* Components */, + 7DB0C29169B853597DD5142C /* Services */, + A2F1FBCF83E9E2F1961E868E /* Theme */, + 15FEDBEA15025FC4D56C8D0D /* Views */, + ); + path = LocalAIPlayground; + sourceTree = ""; + }; + A2F1FBCF83E9E2F1961E868E /* Theme */ = { + isa = PBXGroup; + children = ( + C9C113694A33FADBA93B2CCB /* AppTheme.swift */, + ); + path = Theme; + sourceTree = ""; + }; + BC535C2AB0B29DC6A052D241 /* Components */ = { + isa = PBXGroup; + children = ( + 19C81DF8540FEE16C3080B14 /* AudioVisualizer.swift */, + 005468580CEB697AA94105AF /* MessageBubble.swift */, + EAAD407B5DAD4E29187E40D3 /* ModelLoaderView.swift */, + ); + path = Components; + sourceTree = ""; + }; + C4F1E5C402255490841EB950 /* Products */ = { + isa = PBXGroup; + children = ( + 895392F58C7E8ABC845C9470 /* LocalAIPlayground.app */, + CC087E7ECEE406FA081B4E9A /* LocalAIPlaygroundTests.xctest */, + 9FC4ABA769C5EA2869C83AD8 /* LocalAIPlaygroundUITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + DFDC9D861AF82C4E3B048C11 = { + isa = PBXGroup; + children = ( + 7E6FED8BB2E00AACDAA7B86E /* LocalAIPlayground */, + 0603FBAB61472F24C837A25F /* LocalAIPlaygroundTests */, + 21A1084674A3E3942231BCDC /* LocalAIPlaygroundUITests */, + C4F1E5C402255490841EB950 /* Products */, + ); + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 086C80D5D6FBC76AE358FB2E /* LocalAIPlayground */ = { + isa = PBXNativeTarget; + buildConfigurationList = 95A5ED5A45FE57B3D34B8BD5 /* Build configuration list for PBXNativeTarget "LocalAIPlayground" */; + buildPhases = ( + D133E3098BC2C40F8D4CE930 /* Sources */, + 18FA7E1C4D126D2BB83697F6 /* Resources */, + D148601FC51409628420C9C9 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = LocalAIPlayground; + packageProductDependencies = ( + 4708A0E1E68F2A0ED6C75F5E /* RunAnywhere */, + EDC5DF4346D87B072168CD4B /* RunAnywhereLlamaCPP */, + E2BA9B69F125F3A63E8D353C /* RunAnywhereONNX */, + ); + productName = LocalAIPlayground; + productReference = 895392F58C7E8ABC845C9470 /* LocalAIPlayground.app */; + productType = "com.apple.product-type.application"; + }; + 4F9A29CDF0D4EAC193FA96E2 /* LocalAIPlaygroundUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = CF46DB92B731DB26407F3D79 /* Build configuration list for PBXNativeTarget "LocalAIPlaygroundUITests" */; + buildPhases = ( + AE1E2DF3F63875B9634D396B /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + 0B42A2B91F8A2B833B474F86 /* PBXTargetDependency */, + ); + name = LocalAIPlaygroundUITests; + packageProductDependencies = ( + ); + productName = LocalAIPlaygroundUITests; + productReference = 9FC4ABA769C5EA2869C83AD8 /* LocalAIPlaygroundUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; + B36742297E10CD129B4269A2 /* LocalAIPlaygroundTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 66FD04B98831A39884A812AC /* Build configuration list for PBXNativeTarget "LocalAIPlaygroundTests" */; + buildPhases = ( + 647B1A27772A2F1B473A2269 /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + 57CCCABD947C2C109DC2F335 /* PBXTargetDependency */, + ); + name = LocalAIPlaygroundTests; + packageProductDependencies = ( + ); + productName = LocalAIPlaygroundTests; + productReference = CC087E7ECEE406FA081B4E9A /* LocalAIPlaygroundTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 5E9BA79E61A256CA108E256E /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1500; + TargetAttributes = { + 086C80D5D6FBC76AE358FB2E = { + ProvisioningStyle = Automatic; + }; + 4F9A29CDF0D4EAC193FA96E2 = { + TestTargetID = 086C80D5D6FBC76AE358FB2E; + }; + }; + }; + buildConfigurationList = 33445816C3CB8D17124251D6 /* Build configuration list for PBXProject "LocalAIPlayground" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = DFDC9D861AF82C4E3B048C11; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + DC0F543222B6660F86543470 /* XCRemoteSwiftPackageReference "runanywhere-sdks" */, + ); + projectDirPath = ""; + projectRoot = ""; + targets = ( + 086C80D5D6FBC76AE358FB2E /* LocalAIPlayground */, + B36742297E10CD129B4269A2 /* LocalAIPlaygroundTests */, + 4F9A29CDF0D4EAC193FA96E2 /* LocalAIPlaygroundUITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 18FA7E1C4D126D2BB83697F6 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 2FE4B80767F3C5C9BD896F08 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 647B1A27772A2F1B473A2269 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + DB1A6F9E3E6FC2FC38281C71 /* LocalAIPlaygroundTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + AE1E2DF3F63875B9634D396B /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 81478EA260A26EF6CC520BC2 /* LocalAIPlaygroundUITests.swift in Sources */, + 5D7F01ED3C7B8C6D0F8BA084 /* LocalAIPlaygroundUITestsLaunchTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D133E3098BC2C40F8D4CE930 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 65987D626D5989F86D4EB113 /* AppTheme.swift in Sources */, + CA235C5C0C0D1D71EE36A02D /* AudioService.swift in Sources */, + 441E215ACED46550A9D3483D /* AudioVisualizer.swift in Sources */, + 267A68AE52D052D23BEFA45C /* ChatView.swift in Sources */, + 0079A2EE414D053682F84505 /* ContentView.swift in Sources */, + 6EC97B51C1316C2CCD586DE8 /* HomeView.swift in Sources */, + 38E8771F70F729FDE52C3126 /* LocalAIPlaygroundApp.swift in Sources */, + BD28EBB43D5EF7872A9D2625 /* MessageBubble.swift in Sources */, + B73A385449409DABE96EE24D /* ModelLoaderView.swift in Sources */, + FD21F46D95C4FDC04AB6CD67 /* ModelService.swift in Sources */, + 55B4591569F49E398E1787E1 /* SpeechToTextView.swift in Sources */, + C3D253F3CA72D9E1605B755C /* TextToSpeechView.swift in Sources */, + 9EFFDD7EB2711C0544E99D17 /* VoicePipelineView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 0B42A2B91F8A2B833B474F86 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 086C80D5D6FBC76AE358FB2E /* LocalAIPlayground */; + targetProxy = E5ECDEE736991241D297D481 /* PBXContainerItemProxy */; + }; + 57CCCABD947C2C109DC2F335 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 086C80D5D6FBC76AE358FB2E /* LocalAIPlayground */; + targetProxy = 6F3120042F0DB2B9471475B5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 187B670B7617BEE7540DDD7E /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.yourcompany.LocalAIPlaygroundTests; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/LocalAIPlayground.app/LocalAIPlayground"; + }; + name = Debug; + }; + 543B867E4F83949DD620186A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = L86FH3K93L; + INFOPLIST_FILE = LocalAIPlayground/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.yourcompany.LocalAIPlayground; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.9; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 558A53D3B1AC0F4E39E8F190 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = L86FH3K93L; + INFOPLIST_FILE = LocalAIPlayground/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.yourcompany.LocalAIPlayground; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.9; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 7BD0BA4A27A1CBB84229C00D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.yourcompany.LocalAIPlaygroundUITests; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = LocalAIPlayground; + }; + name = Debug; + }; + A45377F833ECAB81C8378B48 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + AA4C5CB76E3D28FC1FA2257F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + DC74DA55B230EDAF5B6D66E3 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.yourcompany.LocalAIPlaygroundUITests; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = LocalAIPlayground; + }; + name = Release; + }; + F3545C342B1983CB16F231F3 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.yourcompany.LocalAIPlaygroundTests; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/LocalAIPlayground.app/LocalAIPlayground"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 33445816C3CB8D17124251D6 /* Build configuration list for PBXProject "LocalAIPlayground" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A45377F833ECAB81C8378B48 /* Debug */, + AA4C5CB76E3D28FC1FA2257F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 66FD04B98831A39884A812AC /* Build configuration list for PBXNativeTarget "LocalAIPlaygroundTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 187B670B7617BEE7540DDD7E /* Debug */, + F3545C342B1983CB16F231F3 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 95A5ED5A45FE57B3D34B8BD5 /* Build configuration list for PBXNativeTarget "LocalAIPlayground" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 558A53D3B1AC0F4E39E8F190 /* Debug */, + 543B867E4F83949DD620186A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + CF46DB92B731DB26407F3D79 /* Build configuration list for PBXNativeTarget "LocalAIPlaygroundUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7BD0BA4A27A1CBB84229C00D /* Debug */, + DC74DA55B230EDAF5B6D66E3 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + DC0F543222B6660F86543470 /* XCRemoteSwiftPackageReference "runanywhere-sdks" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/RunanywhereAI/runanywhere-sdks"; + requirement = { + kind = revision; + revision = 8bc88aa7810a3df3b72afeff2315b1a1d5b9e6f0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 4708A0E1E68F2A0ED6C75F5E /* RunAnywhere */ = { + isa = XCSwiftPackageProductDependency; + package = DC0F543222B6660F86543470 /* XCRemoteSwiftPackageReference "runanywhere-sdks" */; + productName = RunAnywhere; + }; + E2BA9B69F125F3A63E8D353C /* RunAnywhereONNX */ = { + isa = XCSwiftPackageProductDependency; + package = DC0F543222B6660F86543470 /* XCRemoteSwiftPackageReference "runanywhere-sdks" */; + productName = RunAnywhereONNX; + }; + EDC5DF4346D87B072168CD4B /* RunAnywhereLlamaCPP */ = { + isa = XCSwiftPackageProductDependency; + package = DC0F543222B6660F86543470 /* XCRemoteSwiftPackageReference "runanywhere-sdks" */; + productName = RunAnywhereLlamaCPP; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 5E9BA79E61A256CA108E256E /* Project object */; +} diff --git a/Playground/swift-starter-app/LocalAIPlayground.xcodeproj/xcshareddata/xcschemes/LocalAIPlayground.xcscheme b/Playground/swift-starter-app/LocalAIPlayground.xcodeproj/xcshareddata/xcschemes/LocalAIPlayground.xcscheme new file mode 100644 index 000000000..0a5cb2078 --- /dev/null +++ b/Playground/swift-starter-app/LocalAIPlayground.xcodeproj/xcshareddata/xcschemes/LocalAIPlayground.xcscheme @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Playground/swift-starter-app/LocalAIPlayground/Assets.xcassets/AccentColor.colorset/Contents.json b/Playground/swift-starter-app/LocalAIPlayground/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 000000000..eb8789700 --- /dev/null +++ b/Playground/swift-starter-app/LocalAIPlayground/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Playground/swift-starter-app/LocalAIPlayground/Assets.xcassets/AppIcon.appiconset/Contents.json b/Playground/swift-starter-app/LocalAIPlayground/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..230588010 --- /dev/null +++ b/Playground/swift-starter-app/LocalAIPlayground/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Playground/swift-starter-app/LocalAIPlayground/Assets.xcassets/Contents.json b/Playground/swift-starter-app/LocalAIPlayground/Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/Playground/swift-starter-app/LocalAIPlayground/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Playground/swift-starter-app/LocalAIPlayground/Components/AudioVisualizer.swift b/Playground/swift-starter-app/LocalAIPlayground/Components/AudioVisualizer.swift new file mode 100644 index 000000000..ebc44aeb3 --- /dev/null +++ b/Playground/swift-starter-app/LocalAIPlayground/Components/AudioVisualizer.swift @@ -0,0 +1,487 @@ +// +// AudioVisualizer.swift +// LocalAIPlayground +// +// ============================================================================= +// AUDIO VISUALIZER - REAL-TIME AUDIO VISUALIZATION +// ============================================================================= +// +// A collection of audio visualization components for displaying: +// - Real-time audio input levels during recording +// - Audio output levels during playback +// - Waveform animations +// - Recording state indicators +// +// These components provide visual feedback to users during STT recording +// and TTS playback operations. +// +// ============================================================================= + +import SwiftUI + +// ============================================================================= +// MARK: - Audio Level Bars +// ============================================================================= +/// An animated bar visualization showing current audio level. +/// +/// Used to provide visual feedback during recording or playback. +// ============================================================================= +struct AudioLevelBars: View { + /// Current audio level from 0.0 to 1.0 + let level: Float + + /// Number of bars to display + let barCount: Int + + /// Color for active bars + let activeColor: Color + + /// Color for inactive bars + let inactiveColor: Color + + init( + level: Float, + barCount: Int = 5, + activeColor: Color = .aiPrimary, + inactiveColor: Color = .secondary.opacity(0.3) + ) { + self.level = level + self.barCount = barCount + self.activeColor = activeColor + self.inactiveColor = inactiveColor + } + + var body: some View { + HStack(spacing: 4) { + ForEach(0.. Bool { + let threshold = Float(index + 1) / Float(barCount) + return level >= threshold * 0.8 + } + + private func barHeight(for index: Int) -> CGFloat { + let minHeight: CGFloat = 8 + let maxHeight: CGFloat = 24 + let progress = CGFloat(index + 1) / CGFloat(barCount) + return minHeight + (maxHeight - minHeight) * progress + } +} + +// ============================================================================= +// MARK: - Waveform Visualizer +// ============================================================================= +/// An animated waveform visualization with flowing waves. +/// +/// Creates a dynamic, organic-looking audio visualization. +// ============================================================================= +struct WaveformVisualizer: View { + /// Current audio level from 0.0 to 1.0 + let level: Float + + /// Whether the visualization is active + let isActive: Bool + + /// Number of wave segments + let segments: Int + + /// Primary color for the waveform + let color: Color + + @State private var phase: Double = 0 + + init( + level: Float, + isActive: Bool = true, + segments: Int = 50, + color: Color = .aiPrimary + ) { + self.level = level + self.isActive = isActive + self.segments = segments + self.color = color + } + + var body: some View { + Canvas { context, size in + let midY = size.height / 2 + let width = size.width + + var path = Path() + path.move(to: CGPoint(x: 0, y: midY)) + + for i in 0.. String { + let mins = Int(seconds) / 60 + let secs = Int(seconds) % 60 + return String(format: "%d:%02d", mins, secs) + } +} + +// ============================================================================= +// MARK: - Previews +// ============================================================================= +#Preview("Audio Level Bars") { + VStack(spacing: 24) { + AudioLevelBars(level: 0.2) + AudioLevelBars(level: 0.5) + AudioLevelBars(level: 0.8) + AudioLevelBars(level: 1.0) + } + .padding() +} + +#Preview("Waveform Visualizer") { + VStack(spacing: 24) { + WaveformVisualizer(level: 0.3, isActive: true) + .frame(height: 60) + + WaveformVisualizer(level: 0.7, isActive: true, color: .aiSecondary) + .frame(height: 60) + } + .padding() +} + +#Preview("Circular Visualizer") { + HStack(spacing: 24) { + CircularAudioVisualizer(level: 0.3, isActive: true) + CircularAudioVisualizer(level: 0.7, isActive: true, color: .aiSecondary) + } + .padding() +} + +#Preview("Recording Indicator") { + VStack(spacing: 24) { + RecordingIndicator(isRecording: false, duration: 0) + RecordingIndicator(isRecording: true, duration: 45.5) + } + .padding() +} + +#Preview("Voice Activity") { + HStack(spacing: 24) { + VoiceActivityIndicator(isActive: false, confidence: 0) + VoiceActivityIndicator(isActive: true, confidence: 0.8) + } + .padding() +} + +#Preview("Playback Progress") { + PlaybackProgressBar(progress: 0.35, duration: 125, isPlaying: true) + .padding() +} diff --git a/Playground/swift-starter-app/LocalAIPlayground/Components/MessageBubble.swift b/Playground/swift-starter-app/LocalAIPlayground/Components/MessageBubble.swift new file mode 100644 index 000000000..b280e42dc --- /dev/null +++ b/Playground/swift-starter-app/LocalAIPlayground/Components/MessageBubble.swift @@ -0,0 +1,418 @@ +// +// MessageBubble.swift +// LocalAIPlayground +// +// ============================================================================= +// MESSAGE BUBBLE - CHAT UI COMPONENT +// ============================================================================= +// +// A reusable chat message bubble component for displaying conversation +// messages between the user and the AI assistant. +// +// FEATURES: +// - Distinct styling for user vs. assistant messages +// - Support for streaming text with typing indicator +// - Timestamp display +// - Copy to clipboard functionality +// - Smooth entrance animations +// +// ============================================================================= + +import SwiftUI + +// ============================================================================= +// MARK: - Message Model +// ============================================================================= +/// Represents a single message in a conversation. +// ============================================================================= +struct ChatMessage: Identifiable, Equatable { + let id: UUID + let role: MessageRole + var content: String + let timestamp: Date + var isStreaming: Bool + + /// Who sent this message + enum MessageRole: Equatable { + case user + case assistant + case system + } + + init( + id: UUID = UUID(), + role: MessageRole, + content: String, + timestamp: Date = Date(), + isStreaming: Bool = false + ) { + self.id = id + self.role = role + self.content = content + self.timestamp = timestamp + self.isStreaming = isStreaming + } +} + +// ============================================================================= +// MARK: - Message Bubble View +// ============================================================================= +/// A chat bubble that displays a message with appropriate styling. +/// +/// User messages appear on the right with the primary color. +/// Assistant messages appear on the left with a neutral background. +// ============================================================================= +struct MessageBubble: View { + let message: ChatMessage + + @Environment(\.colorScheme) var colorScheme + @State private var showTimestamp = false + @State private var appeared = false + + // ------------------------------------------------------------------------- + // MARK: - Computed Properties + // ------------------------------------------------------------------------- + + private var isUser: Bool { + message.role == .user + } + + private var bubbleColor: Color { + if isUser { + return .aiPrimary + } else { + return colorScheme == .dark + ? Color(white: 0.2) + : Color(white: 0.95) + } + } + + private var textColor: Color { + if isUser { + return .white + } else { + return colorScheme == .dark ? .white : .primary + } + } + + private var alignment: HorizontalAlignment { + isUser ? .trailing : .leading + } + + private var bubbleAlignment: Alignment { + isUser ? .trailing : .leading + } + + // ------------------------------------------------------------------------- + // MARK: - Body + // ------------------------------------------------------------------------- + + var body: some View { + VStack(alignment: alignment, spacing: AISpacing.xs) { + // Role indicator + HStack(spacing: AISpacing.xs) { + if !isUser { + Image(systemName: "cpu") + .font(.caption) + .foregroundStyle(.secondary) + Text("Assistant") + .font(.aiCaption) + .foregroundStyle(.secondary) + } + + if isUser { + Text("You") + .font(.aiCaption) + .foregroundStyle(.secondary) + Image(systemName: "person.fill") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + // Message bubble + HStack { + if isUser { Spacer(minLength: 60) } + + VStack(alignment: .leading, spacing: AISpacing.xs) { + // Message content + if message.content.isEmpty && message.isStreaming { + // Typing indicator when streaming with no content yet + TypingIndicator() + } else { + Text(message.content) + .font(.aiBody) + .foregroundStyle(textColor) + .textSelection(.enabled) + + // Streaming indicator + if message.isStreaming { + HStack(spacing: AISpacing.xs) { + ProgressView() + .scaleEffect(0.6) + Text("Generating...") + .font(.aiCaption) + .foregroundStyle(.secondary) + } + } + } + } + .padding(.horizontal, AISpacing.md) + .padding(.vertical, AISpacing.sm + 2) + .background( + RoundedRectangle(cornerRadius: 18) + .fill(bubbleColor) + ) + .contextMenu { + Button(action: copyMessage) { + Label("Copy", systemImage: "doc.on.doc") + } + } + + if !isUser { Spacer(minLength: 60) } + } + + // Timestamp (shown on tap) + if showTimestamp { + Text(formattedTimestamp) + .font(.aiCaption) + .foregroundStyle(.tertiary) + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + .frame(maxWidth: .infinity, alignment: bubbleAlignment) + .onTapGesture { + withAnimation(.easeInOut(duration: 0.2)) { + showTimestamp.toggle() + } + } + .opacity(appeared ? 1 : 0) + .offset(y: appeared ? 0 : 10) + .onAppear { + withAnimation(.easeOut(duration: 0.3)) { + appeared = true + } + } + } + + // ------------------------------------------------------------------------- + // MARK: - Helper Methods + // ------------------------------------------------------------------------- + + private var formattedTimestamp: String { + let formatter = DateFormatter() + formatter.timeStyle = .short + return formatter.string(from: message.timestamp) + } + + private func copyMessage() { + UIPasteboard.general.string = message.content + } +} + +// ============================================================================= +// MARK: - Typing Indicator +// ============================================================================= +/// An animated typing indicator (three bouncing dots). +// ============================================================================= +struct TypingIndicator: View { + @State private var animating = false + + var body: some View { + HStack(spacing: 4) { + ForEach(0..<3) { index in + Circle() + .fill(Color.secondary) + .frame(width: 8, height: 8) + .offset(y: animating ? -4 : 4) + .animation( + .easeInOut(duration: 0.5) + .repeatForever() + .delay(Double(index) * 0.15), + value: animating + ) + } + } + .onAppear { + animating = true + } + } +} + +// ============================================================================= +// MARK: - Message Input Field +// ============================================================================= +/// A text input field with send button for composing messages. +// ============================================================================= +struct MessageInputField: View { + @Binding var text: String + let placeholder: String + let isLoading: Bool + let onSend: () -> Void + + @FocusState private var isFocused: Bool + @Environment(\.colorScheme) var colorScheme + + var body: some View { + HStack(spacing: AISpacing.sm) { + // Text input + TextField(placeholder, text: $text, axis: .vertical) + .font(.aiBody) + .lineLimit(1...5) + .padding(.horizontal, AISpacing.md) + .padding(.vertical, AISpacing.sm) + .background( + RoundedRectangle(cornerRadius: 20) + .fill(colorScheme == .dark + ? Color(white: 0.15) + : Color(white: 0.95)) + ) + .focused($isFocused) + .onSubmit { + if !text.isEmpty && !isLoading { + onSend() + } + } + + // Send button + Button(action: onSend) { + ZStack { + Circle() + .fill(text.isEmpty || isLoading + ? Color.secondary.opacity(0.3) + : Color.aiPrimary) + .frame(width: 40, height: 40) + + if isLoading { + ProgressView() + .tint(.white) + .scaleEffect(0.8) + } else { + Image(systemName: "arrow.up") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(.white) + } + } + } + .disabled(text.isEmpty || isLoading) + } + .padding(.horizontal, AISpacing.md) + .padding(.vertical, AISpacing.sm) + .background( + Rectangle() + .fill(.ultraThinMaterial) + .ignoresSafeArea() + ) + } +} + +// ============================================================================= +// MARK: - Empty State View +// ============================================================================= +/// Displayed when there are no messages in the chat. +// ============================================================================= +struct EmptyChatView: View { + let title: String + let subtitle: String + let suggestions: [String] + let onSuggestionTap: (String) -> Void + + var body: some View { + VStack(spacing: AISpacing.lg) { + // Icon + Image(systemName: "bubble.left.and.bubble.right") + .font(.system(size: 48)) + .foregroundStyle(.tertiary) + + // Title + Text(title) + .font(.aiHeading) + .foregroundStyle(.primary) + + // Subtitle + Text(subtitle) + .font(.aiBody) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + + // Suggestions + if !suggestions.isEmpty { + VStack(spacing: AISpacing.sm) { + Text("Try asking:") + .font(.aiCaption) + .foregroundStyle(.tertiary) + + VStack(spacing: AISpacing.sm) { + ForEach(suggestions, id: \.self) { suggestion in + Button(action: { onSuggestionTap(suggestion) }) { + Text(suggestion) + .font(.aiBodySmall) + .foregroundStyle(.primary) + .padding(.horizontal, AISpacing.md) + .padding(.vertical, AISpacing.sm) + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: AIRadius.md) + .stroke(Color.secondary.opacity(0.3), lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, AISpacing.xl) + } + } + } + .padding() + } +} + +// ============================================================================= +// MARK: - Previews +// ============================================================================= +#Preview("Message Bubbles") { + VStack(spacing: AISpacing.md) { + MessageBubble(message: ChatMessage( + role: .user, + content: "Hello! Can you explain what on-device AI means?" + )) + + MessageBubble(message: ChatMessage( + role: .assistant, + content: "On-device AI means all the AI processing happens locally on your device, rather than being sent to cloud servers. This provides better privacy and works offline!" + )) + + MessageBubble(message: ChatMessage( + role: .assistant, + content: "", + isStreaming: true + )) + } + .padding() +} + +#Preview("Message Input") { + VStack { + Spacer() + MessageInputField( + text: .constant(""), + placeholder: "Ask me anything...", + isLoading: false, + onSend: {} + ) + } +} + +#Preview("Empty State") { + EmptyChatView( + title: "Start a Conversation", + subtitle: "Ask questions and get responses from the on-device AI.", + suggestions: [ + "What is the capital of France?", + "Explain quantum computing simply", + "Write a haiku about coding" + ], + onSuggestionTap: { _ in } + ) +} diff --git a/Playground/swift-starter-app/LocalAIPlayground/Components/ModelLoaderView.swift b/Playground/swift-starter-app/LocalAIPlayground/Components/ModelLoaderView.swift new file mode 100644 index 000000000..4b3bc54f0 --- /dev/null +++ b/Playground/swift-starter-app/LocalAIPlayground/Components/ModelLoaderView.swift @@ -0,0 +1,487 @@ +// +// ModelLoaderView.swift +// LocalAIPlayground +// +// ============================================================================= +// MODEL LOADER VIEW - DOWNLOAD & LOADING PROGRESS UI +// ============================================================================= +// +// A reusable component for displaying model download and loading progress. +// Used throughout the app whenever AI models need to be fetched or initialized. +// +// FEATURES: +// - Progress bar with percentage +// - Model size and ETA display +// - State-based appearance (downloading, loading, ready, error) +// - Retry functionality for failed downloads +// +// ============================================================================= + +import SwiftUI + +// ============================================================================= +// MARK: - Model Loader View +// ============================================================================= +/// Displays the loading state and progress of an AI model. +/// +/// This component handles all states of model loading: +/// - Not loaded (with load button) +/// - Downloading (with progress) +/// - Loading into memory +/// - Ready (success indicator) +/// - Error (with retry option) +// ============================================================================= +struct ModelLoaderView: View { + /// Name of the model being loaded + let modelName: String + + /// Description of the model + let modelDescription: String + + /// Approximate size of the model + let modelSize: String + + /// Current state of the model + let state: ModelState + + /// Action to trigger loading + let onLoad: () -> Void + + /// Action to retry after error + let onRetry: () -> Void + + @Environment(\.colorScheme) var colorScheme + + var body: some View { + VStack(alignment: .leading, spacing: AISpacing.md) { + // Header with model info + HStack(spacing: AISpacing.md) { + // Model icon + modelIcon + + // Model details + VStack(alignment: .leading, spacing: AISpacing.xs) { + Text(modelName) + .font(.aiHeadingSmall) + + Text(modelDescription) + .font(.aiBodySmall) + .foregroundStyle(.secondary) + .lineLimit(2) + } + + Spacer() + + // Size badge + Text(modelSize) + .font(.aiCaption) + .foregroundStyle(.secondary) + .padding(.horizontal, AISpacing.sm) + .padding(.vertical, AISpacing.xs) + .background( + Capsule() + .fill(Color.secondary.opacity(0.15)) + ) + } + + // State-specific content + stateContent + } + .padding(AISpacing.md) + .background( + RoundedRectangle(cornerRadius: AIRadius.lg) + .fill(colorScheme == .dark + ? Color(white: 0.1) + : Color(white: 0.98)) + .stroke(strokeColor, lineWidth: 1) + ) + } + + // ------------------------------------------------------------------------- + // MARK: - Model Icon + // ------------------------------------------------------------------------- + + @ViewBuilder + private var modelIcon: some View { + ZStack { + RoundedRectangle(cornerRadius: AIRadius.md) + .fill(iconBackgroundColor) + .frame(width: 48, height: 48) + + Group { + switch state { + case .notLoaded: + Image(systemName: "arrow.down.circle") + case .downloading: + ProgressView() + .tint(.white) + case .loading: + ProgressView() + .tint(.white) + case .ready: + Image(systemName: "checkmark.circle.fill") + case .error: + Image(systemName: "exclamationmark.circle.fill") + } + } + .font(.system(size: 20, weight: .medium)) + .foregroundStyle(.white) + } + } + + private var iconBackgroundColor: Color { + switch state { + case .notLoaded: + return .secondary + case .downloading, .loading: + return Color.aiWarning + case .ready: + return Color.aiSuccess + case .error: + return Color.aiError + } + } + + private var strokeColor: Color { + switch state { + case .notLoaded: + return Color.secondary.opacity(0.2) + case .downloading, .loading: + return Color.aiWarning.opacity(0.3) + case .ready: + return Color.aiSuccess.opacity(0.3) + case .error: + return Color.aiError.opacity(0.3) + } + } + + // ------------------------------------------------------------------------- + // MARK: - State Content + // ------------------------------------------------------------------------- + + @ViewBuilder + private var stateContent: some View { + switch state { + case .notLoaded: + notLoadedContent + + case .downloading(let progress): + downloadingContent(progress: progress) + + case .loading: + loadingContent + + case .ready: + readyContent + + case .error(let message): + errorContent(message: message) + } + } + + // Not loaded state + private var notLoadedContent: some View { + Button(action: onLoad) { + HStack { + Image(systemName: "arrow.down.circle.fill") + Text("Download Model") + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.aiPrimary) + } + + // Downloading state with progress + private func downloadingContent(progress: Double) -> some View { + VStack(alignment: .leading, spacing: AISpacing.sm) { + // Progress bar + GeometryReader { geometry in + ZStack(alignment: .leading) { + // Background + RoundedRectangle(cornerRadius: 4) + .fill(Color.secondary.opacity(0.2)) + .frame(height: 8) + + // Progress fill + RoundedRectangle(cornerRadius: 4) + .fill(Color.aiPrimary) + .frame(width: max(0, geometry.size.width * CGFloat(progress)), height: 8) + .animation(.linear(duration: 0.2), value: progress) + } + } + .frame(height: 8) + + // Progress text + HStack { + Text("Downloading...") + .font(.aiBodySmall) + .foregroundStyle(.secondary) + + Spacer() + + Text("\(Int(progress * 100))%") + .font(.aiMono) + .foregroundStyle(.primary) + } + } + } + + // Loading into memory state + private var loadingContent: some View { + HStack(spacing: AISpacing.sm) { + ProgressView() + .scaleEffect(0.8) + + Text("Loading model into memory...") + .font(.aiBodySmall) + .foregroundStyle(.secondary) + + Spacer() + } + } + + // Ready state + private var readyContent: some View { + HStack(spacing: AISpacing.sm) { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(Color.aiSuccess) + + Text("Model ready") + .font(.aiBodySmall) + .foregroundStyle(Color.aiSuccess) + + Spacer() + } + } + + // Error state with retry + private func errorContent(message: String) -> some View { + VStack(alignment: .leading, spacing: AISpacing.sm) { + HStack(spacing: AISpacing.sm) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(Color.aiError) + + Text(message) + .font(.aiBodySmall) + .foregroundStyle(Color.aiError) + .lineLimit(2) + } + + Button(action: onRetry) { + HStack { + Image(systemName: "arrow.clockwise") + Text("Retry") + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.aiSecondary) + } + } +} + +// ============================================================================= +// MARK: - Compact Model Loader +// ============================================================================= +/// A compact version of the model loader for inline use. +// ============================================================================= +struct CompactModelLoader: View { + let modelName: String + let state: ModelState + let onLoad: () -> Void + + var body: some View { + HStack(spacing: AISpacing.sm) { + // Status icon + Image(systemName: state.statusIcon) + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(state.statusColor) + + // Model name + Text(modelName) + .font(.aiBodySmall) + .foregroundStyle(.primary) + + Spacer() + + // Action or status + switch state { + case .notLoaded: + Button("Load", action: onLoad) + .font(.aiCaption) + .foregroundStyle(Color.aiPrimary) + + case .downloading(let progress): + Text("\(Int(progress * 100))%") + .font(.aiMono) + .foregroundStyle(.secondary) + + case .loading: + ProgressView() + .scaleEffect(0.6) + + case .ready: + Text("Ready") + .font(.aiCaption) + .foregroundStyle(Color.aiSuccess) + + case .error: + Button("Retry", action: onLoad) + .font(.aiCaption) + .foregroundStyle(Color.aiError) + } + } + .padding(AISpacing.sm) + .background( + RoundedRectangle(cornerRadius: AIRadius.sm) + .fill(Color.secondary.opacity(0.1)) + ) + } +} + +// ============================================================================= +// MARK: - Multi-Model Loader +// ============================================================================= +/// Displays loading status for multiple models at once. +// ============================================================================= +struct MultiModelLoader: View { + let title: String + let models: [(name: String, state: ModelState)] + let onLoadAll: () -> Void + + var allReady: Bool { + models.allSatisfy { $0.state.isReady } + } + + var anyLoading: Bool { + models.contains { $0.state.isLoading } + } + + var body: some View { + VStack(alignment: .leading, spacing: AISpacing.md) { + // Header + HStack { + Text(title) + .font(.aiHeadingSmall) + + Spacer() + + if allReady { + AIStatusBadge(status: .ready, text: "All Ready") + } else if anyLoading { + AIStatusBadge(status: .loading, text: "Loading...") + } + } + + // Model list + VStack(spacing: AISpacing.sm) { + ForEach(models, id: \.name) { model in + HStack(spacing: AISpacing.sm) { + Circle() + .fill(model.state.statusColor) + .frame(width: 8, height: 8) + + Text(model.name) + .font(.aiBodySmall) + + Spacer() + + Text(model.state.statusText) + .font(.aiCaption) + .foregroundStyle(.secondary) + } + } + } + + // Load all button (if not all ready) + if !allReady && !anyLoading { + Button(action: onLoadAll) { + HStack { + Image(systemName: "arrow.down.circle.fill") + Text("Load All Models") + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.aiPrimary) + } + } + .padding(AISpacing.md) + .aiCardStyle() + } +} + +// ============================================================================= +// MARK: - Previews +// ============================================================================= +#Preview("Model Loader - States") { + ScrollView { + VStack(spacing: AISpacing.lg) { + ModelLoaderView( + modelName: "SmolLM2 360M", + modelDescription: "Compact language model for on-device text generation", + modelSize: "~400MB", + state: .notLoaded, + onLoad: {}, + onRetry: {} + ) + + ModelLoaderView( + modelName: "Whisper Tiny", + modelDescription: "Fast speech-to-text transcription", + modelSize: "~75MB", + state: .downloading(progress: 0.65), + onLoad: {}, + onRetry: {} + ) + + ModelLoaderView( + modelName: "Piper TTS", + modelDescription: "Natural voice synthesis", + modelSize: "~65MB", + state: .loading, + onLoad: {}, + onRetry: {} + ) + + ModelLoaderView( + modelName: "Piper TTS", + modelDescription: "Natural voice synthesis", + modelSize: "~65MB", + state: .ready, + onLoad: {}, + onRetry: {} + ) + + ModelLoaderView( + modelName: "Large Model", + modelDescription: "Something went wrong", + modelSize: "~1GB", + state: .error(message: "Network connection lost"), + onLoad: {}, + onRetry: {} + ) + } + .padding() + } +} + +#Preview("Compact Loader") { + VStack(spacing: AISpacing.sm) { + CompactModelLoader(modelName: "LLM", state: .ready, onLoad: {}) + CompactModelLoader(modelName: "STT", state: .downloading(progress: 0.5), onLoad: {}) + CompactModelLoader(modelName: "TTS", state: .notLoaded, onLoad: {}) + } + .padding() +} + +#Preview("Multi-Model Loader") { + MultiModelLoader( + title: "Required Models", + models: [ + ("LLM - SmolLM2", .ready), + ("STT - Whisper", .downloading(progress: 0.7)), + ("TTS - Piper", .notLoaded) + ], + onLoadAll: {} + ) + .padding() +} diff --git a/Playground/swift-starter-app/LocalAIPlayground/ContentView.swift b/Playground/swift-starter-app/LocalAIPlayground/ContentView.swift new file mode 100644 index 000000000..eca4a5c83 --- /dev/null +++ b/Playground/swift-starter-app/LocalAIPlayground/ContentView.swift @@ -0,0 +1,143 @@ +// +// ContentView.swift +// LocalAIPlayground +// +// Created by Shubham Malhotra on 1/19/26. +// +// ============================================================================= +// CONTENT VIEW - MAIN APP NAVIGATION +// ============================================================================= +// +// This is the root view of the app that manages navigation to all features. +// It uses a sheet-based navigation pattern where: +// +// 1. HomeView displays feature cards +// 2. Tapping a card presents the corresponding feature view as a sheet +// +// This pattern keeps the home view as the anchor while allowing deep dives +// into each AI capability. +// +// ============================================================================= + +import SwiftUI + +// ============================================================================= +// MARK: - Content View +// ============================================================================= +/// The main content view managing navigation between features. +// ============================================================================= +struct ContentView: View { + // ------------------------------------------------------------------------- + // MARK: - Environment & State + // ------------------------------------------------------------------------- + + /// Model service passed from App + @EnvironmentObject var modelService: ModelService + + /// Currently selected feature (drives sheet presentation) + @State private var selectedFeature: HomeView.Feature? + + // ------------------------------------------------------------------------- + // MARK: - Body + // ------------------------------------------------------------------------- + + var body: some View { + // Home view with feature cards + HomeView { feature in + selectedFeature = feature + } + .environmentObject(modelService) + // Present feature views as full-screen sheets + .fullScreenCover(item: $selectedFeature) { feature in + featureView(for: feature) + .environmentObject(modelService) + } + } + + // ------------------------------------------------------------------------- + // MARK: - Feature Views + // ------------------------------------------------------------------------- + + /// Returns the appropriate view for a given feature. + /// + /// Each feature demonstrates a different RunAnywhere SDK capability: + /// - Chat: LLM text generation with streaming + /// - Speech to Text: Whisper-based transcription + /// - Text to Speech: Piper voice synthesis + /// - Voice Pipeline: Combined VAD + STT + LLM + TTS + // ------------------------------------------------------------------------- + @ViewBuilder + private func featureView(for feature: HomeView.Feature) -> some View { + switch feature { + case .chat: + // ----------------------------------------------------------------- + // Chat View + // ----------------------------------------------------------------- + // Demonstrates on-device LLM text generation. + // Features streaming token generation for real-time responses. + // + // SDK Methods: + // - RunAnywhere.generateStream() + // - LLMGenerationOptions for temperature, max tokens, etc. + // ----------------------------------------------------------------- + ChatView() + + case .speechToText: + // ----------------------------------------------------------------- + // Speech to Text View + // ----------------------------------------------------------------- + // Demonstrates on-device speech recognition using Whisper. + // Records audio and transcribes it locally. + // + // SDK Methods: + // - RunAnywhere.loadSTTModel() + // - RunAnywhere.transcribe() + // ----------------------------------------------------------------- + SpeechToTextView() + + case .textToSpeech: + // ----------------------------------------------------------------- + // Text to Speech View + // ----------------------------------------------------------------- + // Demonstrates on-device voice synthesis using Piper. + // Converts text to natural-sounding speech. + // + // SDK Methods: + // - RunAnywhere.loadTTSVoice() + // - RunAnywhere.synthesize() + // - TTSOptions for rate, pitch, volume + // ----------------------------------------------------------------- + TextToSpeechView() + + case .voicePipeline: + // ----------------------------------------------------------------- + // Voice Pipeline View + // ----------------------------------------------------------------- + // Demonstrates the complete voice agent pipeline: + // 1. User speaks → Audio recorded + // 2. Whisper transcribes → Text + // 3. LLM generates response → Text + // 4. Piper synthesizes → Audio + // 5. Audio played back + // ----------------------------------------------------------------- + VoicePipelineView() + } + } +} + +// ============================================================================= +// MARK: - Feature Extension for Identifiable +// ============================================================================= +/// Makes Feature identifiable for sheet presentation. +// ============================================================================= +extension HomeView.Feature: Identifiable { + var id: String { title } +} + +// ============================================================================= +// MARK: - Preview +// ============================================================================= +#Preview { + ContentView() + .environmentObject(ModelService()) +} diff --git a/Playground/swift-starter-app/LocalAIPlayground/Info.plist b/Playground/swift-starter-app/LocalAIPlayground/Info.plist new file mode 100644 index 000000000..8a3979c41 --- /dev/null +++ b/Playground/swift-starter-app/LocalAIPlayground/Info.plist @@ -0,0 +1,85 @@ + + + + + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleName + $(PRODUCT_NAME) + CFBundleDisplayName + LocalAI Playground + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + CFBundleInfoDictionaryVersion + 6.0 + LSRequiresIPhoneOS + + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + UIRequiredDeviceCapabilities + + arm64 + + + + + + NSMicrophoneUsageDescription + LocalAI Playground needs microphone access to transcribe your speech using on-device AI. All audio is processed locally and never leaves your device. + + + NSSpeechRecognitionUsageDescription + Speech recognition is used to convert your voice to text. All processing happens on your device for complete privacy. + + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + + + UIBackgroundModes + + audio + + + diff --git a/Playground/swift-starter-app/LocalAIPlayground/LocalAIPlaygroundApp.swift b/Playground/swift-starter-app/LocalAIPlayground/LocalAIPlaygroundApp.swift new file mode 100644 index 000000000..594379aa6 --- /dev/null +++ b/Playground/swift-starter-app/LocalAIPlayground/LocalAIPlaygroundApp.swift @@ -0,0 +1,187 @@ +// +// LocalAIPlaygroundApp.swift +// LocalAIPlayground +// +// Created by Shubham Malhotra on 1/19/26. +// +// ============================================================================= +// RUNANYWHERE SDK INTEGRATION - APP ENTRY POINT +// ============================================================================= +// +// This file demonstrates how to properly initialize the RunAnywhere SDK at +// application launch. The SDK provides on-device AI capabilities including: +// +// - LLM (Large Language Model) text generation via LlamaCPP backend +// - STT (Speech-to-Text) transcription via ONNX/Whisper backend +// - TTS (Text-to-Speech) synthesis via ONNX/Piper backend +// - VAD (Voice Activity Detection) for voice pipelines +// +// KEY CONCEPTS: +// 1. Initialize the SDK exactly ONCE at app launch +// 2. Register the backends you need (LlamaCPP for LLM, ONNX for STT/TTS) +// 3. Register models AFTER backends are registered +// 4. Handle initialization errors gracefully +// +// PRIVACY BENEFIT: +// All AI processing happens entirely on-device. No data is sent to external +// servers, ensuring complete user privacy and offline functionality. +// +// ============================================================================= + +import SwiftUI + +// ----------------------------------------------------------------------------- +// MARK: - RunAnywhere SDK Imports +// ----------------------------------------------------------------------------- +// Import the core SDK and backend modules. Each module serves a specific purpose: +// +// - RunAnywhere: Core SDK with unified API for all AI capabilities +// - LlamaCPPRuntime: Backend for on-device LLM text generation +// - ONNXRuntime: Backend for STT (Whisper), TTS (Piper), and VAD +// ----------------------------------------------------------------------------- +import RunAnywhere +import LlamaCPPRuntime +import ONNXRuntime + +// ----------------------------------------------------------------------------- +// MARK: - App Entry Point +// ----------------------------------------------------------------------------- +/// The main entry point for the LocalAIPlayground app. +/// +/// This struct is marked with `@main` to indicate it's the app's entry point. +/// SDK initialization happens asynchronously after the view appears. +// ----------------------------------------------------------------------------- +@main +struct LocalAIPlaygroundApp: App { + + // ------------------------------------------------------------------------- + // MARK: - State Properties + // ------------------------------------------------------------------------- + + /// Shared model service for managing AI models + @StateObject private var modelService = ModelService() + + /// Tracks whether the SDK has been initialized + @State private var isSDKInitialized = false + + // ------------------------------------------------------------------------- + // MARK: - App Body + // ------------------------------------------------------------------------- + + var body: some Scene { + WindowGroup { + Group { + if isSDKInitialized { + // Main app content + ContentView() + .environmentObject(modelService) + } else { + // Loading view while SDK initializes + SDKLoadingView() + } + } + .task { + // Initialize SDK asynchronously + await initializeSDK() + } + } + } + + // ------------------------------------------------------------------------- + // MARK: - SDK Initialization + // ------------------------------------------------------------------------- + /// Initializes the RunAnywhere SDK and registers required backends. + /// + /// This method performs the initialization sequence: + /// 1. Initialize the core SDK + /// 2. Register backends (LlamaCPP, ONNX) + /// 3. Register default models + /// + /// - Important: This must be called before using any SDK features. + // ------------------------------------------------------------------------- + @MainActor + private func initializeSDK() async { + do { + // ----------------------------------------------------------------- + // Step 1: Initialize the Core SDK + // ----------------------------------------------------------------- + // The initialize() method sets up internal state, caching systems, + // and prepares the SDK for use. + // + // Environments: + // - .development: Verbose logging, debug assertions enabled + // - .production: Minimal logging, optimized for release + // ----------------------------------------------------------------- + try RunAnywhere.initialize(environment: .development) + + // ----------------------------------------------------------------- + // Step 2: Register the LlamaCPP Backend + // ----------------------------------------------------------------- + // LlamaCPP is the backend that powers on-device LLM inference. + // It supports various quantized models like SmolLM2, LFM2, etc. + // + // IMPORTANT: Register backends BEFORE registering models + // ----------------------------------------------------------------- + LlamaCPP.register() + + // ----------------------------------------------------------------- + // Step 3: Register the ONNX Backend + // ----------------------------------------------------------------- + // ONNX powers speech-related features using Sherpa-ONNX: + // - STT (Speech-to-Text): Whisper models for transcription + // - TTS (Text-to-Speech): Piper models for voice synthesis + // - VAD (Voice Activity Detection): For voice pipelines + // ----------------------------------------------------------------- + ONNX.register() + + // ----------------------------------------------------------------- + // Step 4: Register Default Models + // ----------------------------------------------------------------- + // Models must be registered with URLs and metadata before they + // can be downloaded or loaded. This is done via ModelService. + // ----------------------------------------------------------------- + ModelService.registerDefaultModels() + + print("✅ RunAnywhere SDK initialized successfully") + print(" Version: \(RunAnywhere.version)") + + // Mark initialization as complete + isSDKInitialized = true + + // Refresh model service state + await modelService.refreshLoadedStates() + + } catch { + print("❌ Failed to initialize RunAnywhere SDK: \(error)") + // Still show UI even if initialization fails + isSDKInitialized = true + } + } +} + +// ----------------------------------------------------------------------------- +// MARK: - SDK Loading View +// ----------------------------------------------------------------------------- +/// A view displayed while the SDK is initializing. +// ----------------------------------------------------------------------------- +struct SDKLoadingView: View { + var body: some View { + VStack(spacing: 20) { + ProgressView() + .scaleEffect(1.5) + + Text("Initializing AI...") + .font(.headline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(UIColor.systemBackground)) + } +} + +// ----------------------------------------------------------------------------- +// MARK: - Preview +// ----------------------------------------------------------------------------- +#Preview { + SDKLoadingView() +} diff --git a/Playground/swift-starter-app/LocalAIPlayground/Services/AudioService.swift b/Playground/swift-starter-app/LocalAIPlayground/Services/AudioService.swift new file mode 100644 index 000000000..715203135 --- /dev/null +++ b/Playground/swift-starter-app/LocalAIPlayground/Services/AudioService.swift @@ -0,0 +1,787 @@ +// +// AudioService.swift +// LocalAIPlayground +// +// ============================================================================= +// AUDIO SERVICE - MICROPHONE & AUDIO MANAGEMENT +// ============================================================================= +// +// This service provides audio capture and playback capabilities for the app: +// +// 1. Microphone Recording - Capture audio for speech-to-text +// 2. Audio Playback - Play synthesized speech from TTS +// 3. Audio Level Metering - Real-time audio level for visualizations +// 4. Audio Session Setup - Configure iOS audio session properly +// +// AUDIO SPECIFICATIONS: +// ┌──────────────────┬─────────────────────────────────────────┐ +// │ Parameter │ Value │ +// ├──────────────────┼─────────────────────────────────────────┤ +// │ Sample Rate │ 16000 Hz (required by Whisper STT) │ +// │ Channels │ Mono (1 channel) │ +// │ Bit Depth │ 16-bit signed integer │ +// │ Format │ Linear PCM │ +// └──────────────────┴─────────────────────────────────────────┘ +// +// PERMISSIONS REQUIRED: +// - NSMicrophoneUsageDescription in Info.plist +// - NSSpeechRecognitionUsageDescription (optional, for system STT) +// +// ============================================================================= + +import Foundation +import SwiftUI +import AVFoundation +import Combine + +// ============================================================================= +// MARK: - Audio State +// ============================================================================= +/// Represents the current state of audio capture/playback. +// ============================================================================= +enum AudioState: Equatable { + /// Audio system is idle + case idle + + /// Currently recording from microphone + case recording + + /// Currently playing audio + case playing + + /// An error occurred + case error(message: String) +} + +// ============================================================================= +// MARK: - Audio Service +// ============================================================================= +/// Centralized service for audio capture and playback. +/// +/// This service manages all audio-related functionality including microphone +/// recording for STT and audio playback for TTS output. +/// +/// ## Usage Example +/// ```swift +/// let audioService = AudioService.shared +/// +/// // Start recording +/// try await audioService.startRecording() +/// +/// // Stop and get audio data +/// let audioData = try await audioService.stopRecording() +/// +/// // Use with RunAnywhere STT +/// let transcription = try await RunAnywhere.transcribe(audioData) +/// ``` +// ============================================================================= +@MainActor +class AudioService: NSObject, ObservableObject { + + // ------------------------------------------------------------------------- + // MARK: - Singleton + // ------------------------------------------------------------------------- + /// Shared instance for app-wide audio management. + static let shared = AudioService() + + // ------------------------------------------------------------------------- + // MARK: - Published State + // ------------------------------------------------------------------------- + /// Current state of the audio system + @Published var state: AudioState = .idle + + /// Current audio input level (0.0 to 1.0) for visualizations + @Published var inputLevel: Float = 0 + + /// Current audio output level (0.0 to 1.0) for visualizations + @Published var outputLevel: Float = 0 + + /// Whether microphone permission has been granted + @Published var hasPermission: Bool = false + + /// Duration of current recording in seconds + @Published var recordingDuration: TimeInterval = 0 + + // ------------------------------------------------------------------------- + // MARK: - Private Properties + // ------------------------------------------------------------------------- + + /// Audio engine for recording + private var audioEngine: AVAudioEngine? + + /// Buffer to accumulate recorded audio + private var audioBuffer: AVAudioPCMBuffer? + + /// Collected audio samples during recording + private var recordedSamples: [Float] = [] + + /// Audio player for TTS output + private var audioPlayer: AVAudioPlayer? + + /// Timer for updating recording duration + private var recordingTimer: Timer? + + /// Start time of current recording + private var recordingStartTime: Date? + + /// Timer for metering audio levels + private var meteringTimer: Timer? + + /// Native sample rate of the microphone (for resampling) + private var nativeSampleRate: Double = 48000 + + // ------------------------------------------------------------------------- + // MARK: - Audio Format Constants + // ------------------------------------------------------------------------- + // These match the requirements of the Whisper STT model + // ------------------------------------------------------------------------- + + /// Sample rate required by Whisper STT (16 kHz) + private let sampleRate: Double = 16000 + + /// Number of audio channels (mono) + private let channelCount: AVAudioChannelCount = 1 + + /// Audio format for recording + private var recordingFormat: AVAudioFormat? { + AVAudioFormat( + commonFormat: .pcmFormatFloat32, + sampleRate: sampleRate, + channels: channelCount, + interleaved: false + ) + } + + // ------------------------------------------------------------------------- + // MARK: - Initialization + // ------------------------------------------------------------------------- + private override init() { + super.init() + checkPermission() + } + + // ========================================================================= + // MARK: - Permission Management + // ========================================================================= + + /// Checks current microphone permission status. + // ------------------------------------------------------------------------- + func checkPermission() { + switch AVAudioApplication.shared.recordPermission { + case .granted: + hasPermission = true + case .denied, .undetermined: + hasPermission = false + @unknown default: + hasPermission = false + } + } + + /// Requests microphone permission from the user. + /// + /// - Returns: `true` if permission was granted, `false` otherwise + // ------------------------------------------------------------------------- + func requestPermission() async -> Bool { + // ----------------------------------------------------------------- + // Request Microphone Permission + // ----------------------------------------------------------------- + // iOS requires explicit user consent before accessing the microphone. + // The permission prompt will show the message from Info.plist key: + // NSMicrophoneUsageDescription + // ----------------------------------------------------------------- + let granted = await AVAudioApplication.requestRecordPermission() + + await MainActor.run { + hasPermission = granted + } + + if granted { + print("✅ Microphone permission granted") + } else { + print("❌ Microphone permission denied") + } + + return granted + } + + // ========================================================================= + // MARK: - Audio Session Configuration + // ========================================================================= + + /// Configures the audio session for recording. + /// + /// Sets up the audio session with appropriate category and options for + /// voice recording with potential playback. + // ------------------------------------------------------------------------- + private func configureAudioSession() throws { + let session = AVAudioSession.sharedInstance() + + // ----------------------------------------------------------------- + // Configure Audio Session Category + // ----------------------------------------------------------------- + // .playAndRecord: Allows both recording and playback + // .defaultToSpeaker: Routes audio to speaker instead of earpiece + // .allowBluetooth: Enables Bluetooth headset support + // ----------------------------------------------------------------- + try session.setCategory( + .playAndRecord, + mode: .default, + options: [.defaultToSpeaker, .allowBluetooth] + ) + + // Activate the session + try session.setActive(true, options: .notifyOthersOnDeactivation) + + print("🎙️ Audio session configured for recording") + } + + /// Configures the audio session for playback only. + // ------------------------------------------------------------------------- + private func configureAudioSessionForPlayback() throws { + let session = AVAudioSession.sharedInstance() + + try session.setCategory(.playback, mode: .default) + try session.setActive(true, options: .notifyOthersOnDeactivation) + + print("🔊 Audio session configured for playback") + } + + // ========================================================================= + // MARK: - Recording + // ========================================================================= + + /// Audio converter for resampling + private var audioConverter: AVAudioConverter? + + /// Target format for STT (16kHz, mono, Int16) + private var targetFormat: AVAudioFormat? + + /// Buffer to accumulate converted audio + private var convertedBuffers: [AVAudioPCMBuffer] = [] + + /// Starts recording audio from the microphone. + /// + /// The audio is captured and converted to 16kHz mono for Whisper STT. + /// + /// - Throws: An error if recording cannot be started + // ------------------------------------------------------------------------- + func startRecording() async throws { + // Ensure we have permission + if !hasPermission { + let granted = await requestPermission() + if !granted { + throw AudioError.permissionDenied + } + } + + // Configure audio session - request 16kHz if possible + try configureAudioSession() + + // Initialize audio engine + audioEngine = AVAudioEngine() + guard let audioEngine = audioEngine else { + throw AudioError.engineInitFailed + } + + // Get input node (microphone) + let inputNode = audioEngine.inputNode + let inputFormat = inputNode.outputFormat(forBus: 0) + + print("🎙️ Microphone format: \(inputFormat.sampleRate) Hz, \(inputFormat.channelCount) ch, \(inputFormat.commonFormat.rawValue)") + + // Create target format: 16kHz, mono, Int16 + guard let target = AVAudioFormat(commonFormat: .pcmFormatInt16, sampleRate: 16000, channels: 1, interleaved: true) else { + throw AudioError.formatError + } + targetFormat = target + + // Create converter from input to target format + guard let converter = AVAudioConverter(from: inputFormat, to: target) else { + print("❌ Could not create audio converter") + throw AudioError.formatError + } + audioConverter = converter + + // Clear buffers + convertedBuffers = [] + recordedSamples = [] + + // Install tap on input node + inputNode.installTap( + onBus: 0, + bufferSize: 4096, + format: inputFormat + ) { [weak self] buffer, time in + self?.processAndConvertBuffer(buffer) + } + + // Prepare and start the engine + audioEngine.prepare() + try audioEngine.start() + + // Update state + state = .recording + recordingStartTime = Date() + startRecordingTimer() + + print("🎙️ Recording started (will convert to 16kHz Int16)") + } + + /// Processes and converts incoming audio buffer to 16kHz Int16. + // ------------------------------------------------------------------------- + private func processAndConvertBuffer(_ buffer: AVAudioPCMBuffer) { + guard let converter = audioConverter, + let targetFormat = targetFormat else { return } + + // Calculate output frame count based on sample rate ratio + let ratio = targetFormat.sampleRate / buffer.format.sampleRate + let outputFrameCapacity = AVAudioFrameCount(Double(buffer.frameLength) * ratio) + 1 + + guard let outputBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: outputFrameCapacity) else { + return + } + + var error: NSError? + let status = converter.convert(to: outputBuffer, error: &error) { inNumPackets, outStatus in + outStatus.pointee = .haveData + return buffer + } + + if status == .haveData || status == .inputRanDry { + // Store converted buffer + DispatchQueue.main.async { [weak self] in + self?.convertedBuffers.append(outputBuffer) + + // Calculate level for visualization from original buffer + if let channelData = buffer.floatChannelData { + let samples = Array(UnsafeBufferPointer(start: channelData[0], count: Int(buffer.frameLength))) + if !samples.isEmpty { + let rms = sqrt(samples.map { $0 * $0 }.reduce(0, +) / Float(samples.count)) + self?.inputLevel = min(rms * 5, 1.0) + } + } + } + } else if let error = error { + print("❌ Conversion error: \(error)") + } + } + + /// Stops recording and returns the captured audio data. + /// + /// - Returns: Audio data in 16kHz mono Int16 PCM format + /// - Throws: An error if recording fails + // ------------------------------------------------------------------------- + func stopRecording() async throws -> Data { + guard state == .recording else { + throw AudioError.notRecording + } + + // Stop the audio engine + audioEngine?.inputNode.removeTap(onBus: 0) + audioEngine?.stop() + audioEngine = nil + audioConverter = nil + + // Stop timers + stopRecordingTimer() + + // Update state + state = .idle + inputLevel = 0 + + print("🎙️ Recording stopped. Buffers: \(convertedBuffers.count)") + + // Combine all converted buffers into one Data + var audioData = Data() + for buffer in convertedBuffers { + if let int16Data = buffer.int16ChannelData { + let frameLength = Int(buffer.frameLength) + let samples = UnsafeBufferPointer(start: int16Data[0], count: frameLength) + for sample in samples { + audioData.append(contentsOf: sample.littleEndianBytes) + } + } + } + + // Clear buffers + convertedBuffers = [] + recordedSamples = [] + + print("🎙️ Final audio: \(audioData.count) bytes (16kHz Int16 PCM)") + return audioData + } + + /// Converts Float32 samples to Int16 Data (standard for Whisper STT). + // ------------------------------------------------------------------------- + private func convertSamplesToInt16Data(_ samples: [Float]) -> Data { + var data = Data(capacity: samples.count * 2) + + for sample in samples { + // Clamp to -1.0...1.0 and convert to Int16 + let clamped = max(-1.0, min(1.0, sample)) + let int16Value = Int16(clamped * Float(Int16.max)) + data.append(contentsOf: int16Value.littleEndianBytes) + } + + return data + } + + /// Converts Float32 samples to Float32 Data (alternative format). + // ------------------------------------------------------------------------- + private func convertSamplesToFloat32Data(_ samples: [Float]) -> Data { + var data = Data(capacity: samples.count * 4) + + for sample in samples { + var value = sample + withUnsafeBytes(of: &value) { bytes in + data.append(contentsOf: bytes) + } + } + + return data + } + + /// Cancels the current recording without returning data. + // ------------------------------------------------------------------------- + func cancelRecording() { + audioEngine?.inputNode.removeTap(onBus: 0) + audioEngine?.stop() + audioEngine = nil + + stopRecordingTimer() + + state = .idle + inputLevel = 0 + recordedSamples = [] + + print("🎙️ Recording cancelled") + } + + // ------------------------------------------------------------------------- + // MARK: - Recording Timer + // ------------------------------------------------------------------------- + + private func startRecordingTimer() { + recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in + guard let self = self, let startTime = self.recordingStartTime else { return } + Task { @MainActor in + self.recordingDuration = Date().timeIntervalSince(startTime) + } + } + } + + private func stopRecordingTimer() { + recordingTimer?.invalidate() + recordingTimer = nil + recordingDuration = 0 + recordingStartTime = nil + } + + // ========================================================================= + // MARK: - Audio Playback + // ========================================================================= + + /// Plays audio data (e.g., from TTS synthesis). + /// + /// - Parameter data: Audio data to play. Can be WAV or raw PCM (will auto-detect). + /// - Parameter sampleRate: Sample rate for raw PCM data (default 22050 Hz for Piper TTS) + /// - Throws: An error if playback fails + // ------------------------------------------------------------------------- + func playAudio(_ data: Data, sampleRate: Int = 22050) throws { + // Configure for playback + try configureAudioSessionForPlayback() + + // Log the first few bytes to help debug format + let headerBytes = data.prefix(12).map { String(format: "%02X", $0) }.joined(separator: " ") + print("🔊 Audio data header: \(headerBytes)") + print("🔊 Audio data size: \(data.count) bytes") + + // Check if data already has WAV header (starts with "RIFF") + let isWAV = data.count > 4 && data.prefix(4) == Data("RIFF".utf8) + + let audioData: Data + if isWAV { + // Already WAV format - use as-is + audioData = data + print("🔊 Audio is WAV format") + } else { + // Piper TTS outputs Float32 PCM - convert to Int16 WAV + let int16Data = convertFloat32ToInt16(data) + audioData = createWAVFile(from: int16Data, sampleRate: sampleRate) + print("🔊 Converted Float32 to Int16 WAV (\(sampleRate) Hz)") + } + + // Write to temp file (AVAudioPlayer is more reliable with files) + let tempURL = FileManager.default.temporaryDirectory + .appendingPathComponent("tts_output_\(UUID().uuidString).wav") + try audioData.write(to: tempURL) + print("🔊 Wrote audio to: \(tempURL.lastPathComponent)") + + // Create player from file + audioPlayer = try AVAudioPlayer(contentsOf: tempURL) + audioPlayer?.delegate = self + audioPlayer?.isMeteringEnabled = true + audioPlayer?.prepareToPlay() + + // Start playback + let success = audioPlayer?.play() ?? false + if success { + state = .playing + startMeteringTimer() + print("🔊 Playback started") + } else { + throw AudioError.playbackFailed + } + + // Clean up temp file after a delay + DispatchQueue.main.asyncAfter(deadline: .now() + 60) { + try? FileManager.default.removeItem(at: tempURL) + } + } + + /// Converts Float32 PCM audio samples to Int16 PCM. + /// + /// Piper TTS outputs 32-bit float samples (-1.0 to 1.0). + /// AVAudioPlayer needs 16-bit signed integer PCM. + // ------------------------------------------------------------------------- + private func convertFloat32ToInt16(_ floatData: Data) -> Data { + // Float32 = 4 bytes per sample + let sampleCount = floatData.count / 4 + var int16Data = Data(capacity: sampleCount * 2) + + floatData.withUnsafeBytes { rawBuffer in + let floatBuffer = rawBuffer.bindMemory(to: Float.self) + + for i in 0.. Data { + let numChannels: UInt16 = 1 + let bitsPerSample: UInt16 = 16 + let byteRate: UInt32 = UInt32(sampleRate) * UInt32(numChannels) * UInt32(bitsPerSample / 8) + let blockAlign: UInt16 = numChannels * (bitsPerSample / 8) + let subchunk2Size: UInt32 = UInt32(pcmData.count) + let chunkSize: UInt32 = 36 + subchunk2Size + + var wavData = Data() + + // RIFF chunk descriptor + wavData.append(contentsOf: [0x52, 0x49, 0x46, 0x46]) // "RIFF" + wavData.append(contentsOf: chunkSize.littleEndianBytes) + wavData.append(contentsOf: [0x57, 0x41, 0x56, 0x45]) // "WAVE" + + // fmt sub-chunk + wavData.append(contentsOf: [0x66, 0x6D, 0x74, 0x20]) // "fmt " + wavData.append(contentsOf: UInt32(16).littleEndianBytes) // Subchunk1Size (16 for PCM) + wavData.append(contentsOf: UInt16(1).littleEndianBytes) // AudioFormat (1 = PCM) + wavData.append(contentsOf: numChannels.littleEndianBytes) + wavData.append(contentsOf: UInt32(sampleRate).littleEndianBytes) + wavData.append(contentsOf: byteRate.littleEndianBytes) + wavData.append(contentsOf: blockAlign.littleEndianBytes) + wavData.append(contentsOf: bitsPerSample.littleEndianBytes) + + // data sub-chunk + wavData.append(contentsOf: [0x64, 0x61, 0x74, 0x61]) // "data" + wavData.append(contentsOf: subchunk2Size.littleEndianBytes) + wavData.append(pcmData) + + return wavData + } + + /// Stops audio playback. + // ------------------------------------------------------------------------- + func stopPlayback() { + audioPlayer?.stop() + audioPlayer = nil + + stopMeteringTimer() + + state = .idle + outputLevel = 0 + + print("🔊 Playback stopped") + } + + // ------------------------------------------------------------------------- + // MARK: - Metering Timer + // ------------------------------------------------------------------------- + + private func startMeteringTimer() { + meteringTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in + guard let player = self?.audioPlayer else { return } + player.updateMeters() + + // Convert dB to linear scale + let db = player.averagePower(forChannel: 0) + let level = pow(10, db / 20) + + Task { @MainActor in + self?.outputLevel = min(level * 2, 1.0) + } + } + } + + private func stopMeteringTimer() { + meteringTimer?.invalidate() + meteringTimer = nil + outputLevel = 0 + } + + // ========================================================================= + // MARK: - Audio Data Conversion + // ========================================================================= + + /// Converts float32 samples to 16-bit PCM data. + /// + /// This is the format expected by the RunAnywhere STT transcribe function. + /// + /// - Parameter samples: Float32 audio samples (-1.0 to 1.0) + /// - Returns: Data containing 16-bit signed integer PCM + // ------------------------------------------------------------------------- + private func convertSamplesToData(_ samples: [Float]) -> Data { + var data = Data() + + for sample in samples { + // Clamp to valid range + let clamped = max(-1.0, min(1.0, sample)) + + // Convert to Int16 + let int16Value = Int16(clamped * Float(Int16.max)) + + // Append as little-endian bytes + withUnsafeBytes(of: int16Value.littleEndian) { bytes in + data.append(contentsOf: bytes) + } + } + + return data + } + + /// Resamples audio to the target sample rate if needed. + /// + /// Not currently used since we record at 16kHz directly, but useful + /// for processing audio from other sources. + // ------------------------------------------------------------------------- + func resampleAudio(_ data: Data, fromRate: Double, toRate: Double) -> Data? { + // For simplicity, return original if rates match + guard fromRate != toRate else { return data } + + // TODO: Implement proper resampling using vDSP + // For now, the audio engine handles format conversion + return data + } +} + +// ============================================================================= +// MARK: - AVAudioPlayerDelegate +// ============================================================================= +extension AudioService: AVAudioPlayerDelegate { + + nonisolated func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { + Task { @MainActor in + stopMeteringTimer() + state = .idle + outputLevel = 0 + print("🔊 Playback finished (success: \(flag))") + } + } + + nonisolated func audioPlayerDecodeErrorDidOccur(_ player: AVAudioPlayer, error: Error?) { + Task { @MainActor in + state = .error(message: error?.localizedDescription ?? "Playback error") + print("❌ Playback error: \(error?.localizedDescription ?? "unknown")") + } + } +} + +// ============================================================================= +// MARK: - Audio Errors +// ============================================================================= +/// Errors that can occur during audio operations. +// ============================================================================= +enum AudioError: LocalizedError { + case permissionDenied + case engineInitFailed + case formatError + case notRecording + case playbackFailed + + var errorDescription: String? { + switch self { + case .permissionDenied: + return "Microphone permission was denied. Please enable it in Settings." + case .engineInitFailed: + return "Failed to initialize audio engine." + case .formatError: + return "Audio format configuration failed." + case .notRecording: + return "Not currently recording." + case .playbackFailed: + return "Audio playback failed." + } + } +} + +// ============================================================================= +// MARK: - Little Endian Helpers +// ============================================================================= +/// Extensions for converting integers to little-endian byte arrays. +// ============================================================================= +private extension Int16 { + var littleEndianBytes: [UInt8] { + let value = self.littleEndian + return [UInt8(value & 0xFF), UInt8((value >> 8) & 0xFF)] + } +} + +private extension UInt16 { + var littleEndianBytes: [UInt8] { + let value = self.littleEndian + return [UInt8(value & 0xFF), UInt8((value >> 8) & 0xFF)] + } +} + +private extension UInt32 { + var littleEndianBytes: [UInt8] { + let value = self.littleEndian + return [ + UInt8(value & 0xFF), + UInt8((value >> 8) & 0xFF), + UInt8((value >> 16) & 0xFF), + UInt8((value >> 24) & 0xFF) + ] + } +} + +// ============================================================================= +// MARK: - Preview Helpers +// ============================================================================= +#if DEBUG +extension AudioService { + /// Creates a mock audio service for previews. + static var preview: AudioService { + let service = AudioService.shared + service.hasPermission = true + return service + } +} +#endif diff --git a/Playground/swift-starter-app/LocalAIPlayground/Services/ModelService.swift b/Playground/swift-starter-app/LocalAIPlayground/Services/ModelService.swift new file mode 100644 index 000000000..dfefb5eb4 --- /dev/null +++ b/Playground/swift-starter-app/LocalAIPlayground/Services/ModelService.swift @@ -0,0 +1,461 @@ +// +// ModelService.swift +// LocalAIPlayground +// +// ============================================================================= +// MODEL SERVICE - AI MODEL MANAGEMENT +// ============================================================================= +// +// This service handles the lifecycle of AI models in the RunAnywhere SDK: +// +// 1. Model Registration - Register models with URLs and metadata +// 2. Model Download - Fetch models from remote repositories +// 3. Model Loading - Load models into memory for inference +// 4. Model Caching - Manage local model storage +// +// MODELS USED IN THIS APP: +// ┌────────────┬─────────────────────────────────┬─────────┐ +// │ Capability │ Model │ Size │ +// ├────────────┼─────────────────────────────────┼─────────┤ +// │ LLM │ LiquidAI LFM2 350M Q4_K_M │ ~250MB │ +// │ STT │ Sherpa Whisper Tiny (English) │ ~75MB │ +// │ TTS │ Piper en_US-lessac-medium │ ~65MB │ +// └────────────┴─────────────────────────────────┴─────────┘ +// +// Models are downloaded on first use and cached locally on the device. +// Subsequent launches load from cache for instant availability. +// +// ============================================================================= + +import Foundation +import SwiftUI +import Combine + +// Import RunAnywhere SDK for model management APIs +import RunAnywhere + +// ============================================================================= +// MARK: - Model Service +// ============================================================================= +/// Centralized service for managing AI model lifecycle. +/// +/// This service provides a unified interface for downloading, loading, and +/// managing AI models across all capabilities (LLM, STT, TTS). +/// +/// ## Usage Example +/// ```swift +/// let modelService = ModelService() +/// +/// // Register models first (called at app init) +/// ModelService.registerDefaultModels() +/// +/// // Download and load LLM +/// await modelService.downloadAndLoadLLM() +/// +/// // Check if ready +/// if modelService.isLLMLoaded { +/// // Use the model +/// } +/// ``` +// ============================================================================= +@MainActor +final class ModelService: ObservableObject { + + // ------------------------------------------------------------------------- + // MARK: - Model IDs + // ------------------------------------------------------------------------- + // These IDs must match the IDs used when registering models. + // They are used to download and load the correct model. + // ------------------------------------------------------------------------- + + /// LLM model ID - LiquidAI LFM2 350M with Q4_K_M quantization + static let llmModelId = "lfm2-350m-q4_k_m" + + /// STT model ID - Whisper Tiny (English) + static let sttModelId = "sherpa-onnx-whisper-tiny.en" + + /// TTS voice ID - Piper US English (Lessac Medium) + static let ttsModelId = "vits-piper-en_US-lessac-medium" + + // ------------------------------------------------------------------------- + // MARK: - Download State + // ------------------------------------------------------------------------- + + @Published var isLLMDownloading = false + @Published var isSTTDownloading = false + @Published var isTTSDownloading = false + + @Published var llmDownloadProgress: Double = 0.0 + @Published var sttDownloadProgress: Double = 0.0 + @Published var ttsDownloadProgress: Double = 0.0 + + // ------------------------------------------------------------------------- + // MARK: - Load State + // ------------------------------------------------------------------------- + + @Published var isLLMLoading = false + @Published var isSTTLoading = false + @Published var isTTSLoading = false + + // ------------------------------------------------------------------------- + // MARK: - Loaded State + // ------------------------------------------------------------------------- + + @Published private(set) var isLLMLoaded = false + @Published private(set) var isSTTLoaded = false + @Published private(set) var isTTSLoaded = false + + // ------------------------------------------------------------------------- + // MARK: - Computed Properties + // ------------------------------------------------------------------------- + + /// Whether all models for voice agent are ready + var isVoiceAgentReady: Bool { + isLLMLoaded && isSTTLoaded && isTTSLoaded + } + + /// Whether any model is currently downloading + var isAnyDownloading: Bool { + isLLMDownloading || isSTTDownloading || isTTSDownloading + } + + /// Whether any model is currently loading + var isAnyLoading: Bool { + isLLMLoading || isSTTLoading || isTTSLoading + } + + // ------------------------------------------------------------------------- + // MARK: - Initialization + // ------------------------------------------------------------------------- + + init() { + Task { + await refreshLoadedStates() + } + } + + // ========================================================================= + // MARK: - Model Registration + // ========================================================================= + + /// Registers default models with the SDK. + /// + /// This must be called AFTER SDK initialization and backend registration, + /// but BEFORE attempting to download or load any models. + /// + /// ## RunAnywhere SDK Pattern + /// Models must be registered with: + /// - A unique ID (used to reference the model later) + /// - A display name + /// - A download URL + /// - The framework type (.llamaCpp for LLM, .onnx for STT/TTS) + /// - Memory requirements (helps SDK manage resources) + // ------------------------------------------------------------------------- + static func registerDefaultModels() { + // ----------------------------------------------------------------- + // Register LLM Model + // ----------------------------------------------------------------- + // LiquidAI LFM2 350M Q4_K_M - A small, fast, efficient model + // Good for mobile devices with limited memory + // ----------------------------------------------------------------- + if let lfm2URL = URL(string: "https://huggingface.co/LiquidAI/LFM2-350M-GGUF/resolve/main/LFM2-350M-Q4_K_M.gguf") { + RunAnywhere.registerModel( + id: llmModelId, + name: "LiquidAI LFM2 350M Q4_K_M", + url: lfm2URL, + framework: .llamaCpp, + memoryRequirement: 250_000_000 + ) + } + + // ----------------------------------------------------------------- + // Register STT Model + // ----------------------------------------------------------------- + // Whisper Tiny (English) - Fast, accurate for English speech + // Uses Sherpa-ONNX runtime for efficient mobile inference + // ----------------------------------------------------------------- + if let whisperURL = URL(string: "https://github.com/RunanywhereAI/sherpa-onnx/releases/download/runanywhere-models-v1/sherpa-onnx-whisper-tiny.en.tar.gz") { + RunAnywhere.registerModel( + id: sttModelId, + name: "Sherpa Whisper Tiny (ONNX)", + url: whisperURL, + framework: .onnx, + modality: .speechRecognition, + artifactType: .archive(.tarGz, structure: .nestedDirectory), + memoryRequirement: 75_000_000 + ) + } + + // ----------------------------------------------------------------- + // Register TTS Voice + // ----------------------------------------------------------------- + // Piper TTS - US English, natural sounding neural TTS + // Uses VITS architecture for high-quality synthesis + // ----------------------------------------------------------------- + if let piperURL = URL(string: "https://github.com/RunanywhereAI/sherpa-onnx/releases/download/runanywhere-models-v1/vits-piper-en_US-lessac-medium.tar.gz") { + RunAnywhere.registerModel( + id: ttsModelId, + name: "Piper TTS (US English - Medium)", + url: piperURL, + framework: .onnx, + modality: .speechSynthesis, + artifactType: .archive(.tarGz, structure: .nestedDirectory), + memoryRequirement: 65_000_000 + ) + } + + print("✅ Models registered: LLM, STT, TTS") + } + + // ========================================================================= + // MARK: - State Refresh + // ========================================================================= + + /// Refreshes the loaded state of all models. + /// + /// Queries the SDK to check which models are currently loaded into memory. + // ------------------------------------------------------------------------- + func refreshLoadedStates() async { + isLLMLoaded = await RunAnywhere.isModelLoaded + isSTTLoaded = await RunAnywhere.isSTTModelLoaded + isTTSLoaded = await RunAnywhere.isTTSVoiceLoaded + } + + // ========================================================================= + // MARK: - LLM Operations + // ========================================================================= + + /// Downloads and loads the LLM model. + /// + /// This method: + /// 1. Attempts to load from cache first + /// 2. If not cached, downloads the model + /// 3. Loads the model into memory + /// + /// ## RunAnywhere SDK Methods Used + /// - `RunAnywhere.loadModel(id)` - Load a registered model + /// - `RunAnywhere.downloadModel(id)` - Download a registered model + // ------------------------------------------------------------------------- + func downloadAndLoadLLM() async { + guard !isLLMDownloading && !isLLMLoading else { return } + + // Try to load first if already downloaded + isLLMLoading = true + do { + try await RunAnywhere.loadModel(Self.llmModelId) + isLLMLoaded = true + isLLMLoading = false + print("✅ LLM model loaded from cache") + return + } catch { + print("LLM load attempt failed (will download): \(error)") + isLLMLoading = false + } + + // If loading failed, download the model + isLLMDownloading = true + llmDownloadProgress = 0.0 + + do { + // ----------------------------------------------------------------- + // Download Model with Progress + // ----------------------------------------------------------------- + // RunAnywhere.downloadModel() returns an AsyncStream of progress + // updates. Each progress object contains: + // - overallProgress: 0.0 to 1.0 + // - stage: .downloading, .extracting, .completed, etc. + // ----------------------------------------------------------------- + let progressStream = try await RunAnywhere.downloadModel(Self.llmModelId) + + for await progress in progressStream { + llmDownloadProgress = progress.overallProgress + if progress.stage == .completed { + break + } + } + } catch { + print("LLM download error: \(error)") + isLLMDownloading = false + return + } + + isLLMDownloading = false + + // Load the model after download + isLLMLoading = true + do { + try await RunAnywhere.loadModel(Self.llmModelId) + isLLMLoaded = true + } catch { + print("LLM load error: \(error)") + } + isLLMLoading = false + } + + /// Unloads the LLM model from memory. + // ------------------------------------------------------------------------- + func unloadLLM() async { + do { + try await RunAnywhere.unloadModel() + isLLMLoaded = false + print("📤 LLM model unloaded") + } catch { + print("⚠️ LLM unload error: \(error)") + } + } + + // ========================================================================= + // MARK: - STT Operations + // ========================================================================= + + /// Downloads and loads the STT (Speech-to-Text) model. + // ------------------------------------------------------------------------- + func downloadAndLoadSTT() async { + guard !isSTTDownloading && !isSTTLoading else { return } + + // Try to load first if already downloaded + isSTTLoading = true + do { + try await RunAnywhere.loadSTTModel(Self.sttModelId) + isSTTLoaded = true + isSTTLoading = false + print("✅ STT model loaded from cache") + return + } catch { + print("STT load attempt failed (will download): \(error)") + isSTTLoading = false + } + + // If loading failed, download the model + isSTTDownloading = true + sttDownloadProgress = 0.0 + + do { + let progressStream = try await RunAnywhere.downloadModel(Self.sttModelId) + + for await progress in progressStream { + sttDownloadProgress = progress.overallProgress + if progress.stage == .completed { + break + } + } + } catch { + print("STT download error: \(error)") + isSTTDownloading = false + return + } + + isSTTDownloading = false + + // Load the model after download + isSTTLoading = true + do { + try await RunAnywhere.loadSTTModel(Self.sttModelId) + isSTTLoaded = true + } catch { + print("STT load error: \(error)") + } + isSTTLoading = false + } + + /// Unloads the STT model from memory. + // ------------------------------------------------------------------------- + func unloadSTT() async { + do { + try await RunAnywhere.unloadSTTModel() + isSTTLoaded = false + print("📤 STT model unloaded") + } catch { + print("⚠️ STT unload error: \(error)") + } + } + + // ========================================================================= + // MARK: - TTS Operations + // ========================================================================= + + /// Downloads and loads the TTS (Text-to-Speech) voice. + // ------------------------------------------------------------------------- + func downloadAndLoadTTS() async { + guard !isTTSDownloading && !isTTSLoading else { return } + + // Try to load first if already downloaded + isTTSLoading = true + do { + try await RunAnywhere.loadTTSVoice(Self.ttsModelId) + isTTSLoaded = true + isTTSLoading = false + print("✅ TTS voice loaded from cache") + return + } catch { + print("TTS load attempt failed (will download): \(error)") + isTTSLoading = false + } + + // If loading failed, download the model + isTTSDownloading = true + ttsDownloadProgress = 0.0 + + do { + let progressStream = try await RunAnywhere.downloadModel(Self.ttsModelId) + + for await progress in progressStream { + ttsDownloadProgress = progress.overallProgress + if progress.stage == .completed { + break + } + } + } catch { + print("TTS download error: \(error)") + isTTSDownloading = false + return + } + + isTTSDownloading = false + + // Load the voice after download + isTTSLoading = true + do { + try await RunAnywhere.loadTTSVoice(Self.ttsModelId) + isTTSLoaded = true + } catch { + print("TTS load error: \(error)") + } + isTTSLoading = false + } + + /// Unloads the TTS voice from memory. + // ------------------------------------------------------------------------- + func unloadTTS() async { + do { + try await RunAnywhere.unloadTTSVoice() + isTTSLoaded = false + print("📤 TTS voice unloaded") + } catch { + print("⚠️ TTS unload error: \(error)") + } + } + + // ========================================================================= + // MARK: - Batch Operations + // ========================================================================= + + /// Downloads and loads all models for the voice agent. + /// + /// Note: Downloads run sequentially to avoid SDK concurrency issues. + // ------------------------------------------------------------------------- + func downloadAndLoadAllModels() async { + await downloadAndLoadLLM() + await downloadAndLoadSTT() + await downloadAndLoadTTS() + } + + /// Unloads all models to free memory. + // ------------------------------------------------------------------------- + func unloadAllModels() async { + await unloadLLM() + await unloadSTT() + await unloadTTS() + await refreshLoadedStates() + } +} diff --git a/Playground/swift-starter-app/LocalAIPlayground/Theme/AppTheme.swift b/Playground/swift-starter-app/LocalAIPlayground/Theme/AppTheme.swift new file mode 100644 index 000000000..e326b54ea --- /dev/null +++ b/Playground/swift-starter-app/LocalAIPlayground/Theme/AppTheme.swift @@ -0,0 +1,678 @@ +// +// AppTheme.swift +// LocalAIPlayground +// +// ============================================================================= +// APP THEME - DESIGN SYSTEM +// ============================================================================= +// +// A cohesive design system for the LocalAIPlayground app featuring: +// - Custom color palette with semantic naming +// - Typography scale using SF Pro and custom fonts +// - Reusable component styles and modifiers +// - Dark mode support out of the box +// +// Design Inspiration: Modern AI interfaces with a warm, approachable feel +// that balances technical capability with user-friendly aesthetics. +// +// ============================================================================= + +import SwiftUI + +// ============================================================================= +// MARK: - Color Palette +// ============================================================================= +/// Semantic color definitions for the app's visual identity. +/// +/// Uses a warm coral/orange primary color with complementary neutrals, +/// creating an approachable yet professional AI interface. +// ============================================================================= +extension Color { + + // ------------------------------------------------------------------------- + // Primary Colors + // ------------------------------------------------------------------------- + + /// Primary brand color - warm coral/orange + /// Used for primary actions, active states, and key UI elements + static let aiPrimary = Color(red: 1.0, green: 0.45, blue: 0.35) + + /// Secondary brand color - soft teal + /// Used for secondary actions and complementary accents + static let aiSecondary = Color(red: 0.25, green: 0.75, blue: 0.75) + + /// Accent color - golden amber + /// Used for highlights, badges, and special states + static let aiAccent = Color(red: 1.0, green: 0.75, blue: 0.25) + + // ------------------------------------------------------------------------- + // Semantic Colors + // ------------------------------------------------------------------------- + + /// Success state - soft green + static let aiSuccess = Color(red: 0.35, green: 0.78, blue: 0.55) + + /// Warning state - warm amber + static let aiWarning = Color(red: 1.0, green: 0.65, blue: 0.25) + + /// Error state - coral red + static let aiError = Color(red: 0.95, green: 0.35, blue: 0.35) + + // ------------------------------------------------------------------------- + // Background Colors + // ------------------------------------------------------------------------- + + /// Primary background - adapts to light/dark mode + static let aiBackground = Color("AIBackground", bundle: nil) + + /// Card/surface background + static let aiSurface = Color("AISurface", bundle: nil) + + /// Elevated surface (modals, popovers) + static let aiElevated = Color("AIElevated", bundle: nil) + + // ------------------------------------------------------------------------- + // Text Colors + // ------------------------------------------------------------------------- + + /// Primary text color + static let aiTextPrimary = Color("AITextPrimary", bundle: nil) + + /// Secondary/muted text color + static let aiTextSecondary = Color("AITextSecondary", bundle: nil) + + // ------------------------------------------------------------------------- + // Gradient Definitions + // ------------------------------------------------------------------------- + + /// Primary gradient for buttons and headers + static var aiGradientPrimary: LinearGradient { + LinearGradient( + colors: [ + Color(red: 1.0, green: 0.5, blue: 0.4), + Color(red: 1.0, green: 0.35, blue: 0.45) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + + /// Subtle background gradient + static var aiGradientBackground: LinearGradient { + LinearGradient( + colors: [ + Color(red: 0.98, green: 0.96, blue: 0.94), + Color(red: 1.0, green: 0.98, blue: 0.96) + ], + startPoint: .top, + endPoint: .bottom + ) + } + + /// Dark mode background gradient + static var aiGradientBackgroundDark: LinearGradient { + LinearGradient( + colors: [ + Color(red: 0.08, green: 0.08, blue: 0.1), + Color(red: 0.12, green: 0.12, blue: 0.14) + ], + startPoint: .top, + endPoint: .bottom + ) + } + + /// Mesh gradient for hero sections + static var aiMeshGradient: some ShapeStyle { + AngularGradient( + colors: [ + Color(red: 1.0, green: 0.5, blue: 0.4).opacity(0.3), + Color(red: 0.25, green: 0.75, blue: 0.75).opacity(0.2), + Color(red: 1.0, green: 0.75, blue: 0.25).opacity(0.25), + Color(red: 1.0, green: 0.5, blue: 0.4).opacity(0.3) + ], + center: .center + ) + } +} + +// ============================================================================= +// MARK: - Typography +// ============================================================================= +/// Custom typography scale using system fonts with specific weights and sizes. +/// +/// Follows a modular scale for visual harmony across the app. +// ============================================================================= +extension Font { + + // ------------------------------------------------------------------------- + // Display Fonts (Hero sections, large headers) + // ------------------------------------------------------------------------- + + /// Extra large display text + static let aiDisplayLarge = Font.system(size: 40, weight: .bold, design: .rounded) + + /// Standard display text + static let aiDisplay = Font.system(size: 32, weight: .bold, design: .rounded) + + // ------------------------------------------------------------------------- + // Heading Fonts + // ------------------------------------------------------------------------- + + /// Large heading (page titles) + static let aiHeadingLarge = Font.system(size: 28, weight: .semibold, design: .rounded) + + /// Standard heading (section titles) + static let aiHeading = Font.system(size: 22, weight: .semibold, design: .rounded) + + /// Small heading (card titles) + static let aiHeadingSmall = Font.system(size: 18, weight: .semibold, design: .rounded) + + // ------------------------------------------------------------------------- + // Body Fonts + // ------------------------------------------------------------------------- + + /// Large body text + static let aiBodyLarge = Font.system(size: 17, weight: .regular, design: .default) + + /// Standard body text + static let aiBody = Font.system(size: 15, weight: .regular, design: .default) + + /// Small body text + static let aiBodySmall = Font.system(size: 13, weight: .regular, design: .default) + + // ------------------------------------------------------------------------- + // Specialized Fonts + // ------------------------------------------------------------------------- + + /// Monospace font for code/technical content + static let aiMono = Font.system(size: 14, weight: .medium, design: .monospaced) + + /// Caption text + static let aiCaption = Font.system(size: 12, weight: .medium, design: .default) + + /// Button/label text + static let aiLabel = Font.system(size: 15, weight: .semibold, design: .rounded) +} + +// ============================================================================= +// MARK: - Spacing & Layout +// ============================================================================= +/// Consistent spacing values based on an 8pt grid system. +// ============================================================================= +enum AISpacing { + /// Extra small spacing (4pt) + static let xs: CGFloat = 4 + + /// Small spacing (8pt) + static let sm: CGFloat = 8 + + /// Medium spacing (16pt) + static let md: CGFloat = 16 + + /// Large spacing (24pt) + static let lg: CGFloat = 24 + + /// Extra large spacing (32pt) + static let xl: CGFloat = 32 + + /// 2X large spacing (48pt) + static let xxl: CGFloat = 48 +} + +// ============================================================================= +// MARK: - Corner Radius +// ============================================================================= +/// Consistent corner radius values for UI elements. +// ============================================================================= +enum AIRadius { + /// Small radius for tags, badges (6pt) + static let sm: CGFloat = 6 + + /// Medium radius for buttons, inputs (12pt) + static let md: CGFloat = 12 + + /// Large radius for cards (16pt) + static let lg: CGFloat = 16 + + /// Extra large radius for modals (24pt) + static let xl: CGFloat = 24 + + /// Full radius for circular elements + static let full: CGFloat = 9999 +} + +// ============================================================================= +// MARK: - View Modifiers +// ============================================================================= + +// ----------------------------------------------------------------------------- +// Card Style Modifier +// ----------------------------------------------------------------------------- +/// Applies consistent card styling with background, shadow, and corner radius. +struct AICardStyle: ViewModifier { + @Environment(\.colorScheme) var colorScheme + + func body(content: Content) -> some View { + content + .background( + RoundedRectangle(cornerRadius: AIRadius.lg) + .fill(colorScheme == .dark + ? Color(white: 0.15) + : Color.white) + .shadow( + color: Color.black.opacity(colorScheme == .dark ? 0.3 : 0.08), + radius: 12, + x: 0, + y: 4 + ) + ) + } +} + +extension View { + /// Applies the standard AI card style. + func aiCardStyle() -> some View { + modifier(AICardStyle()) + } +} + +// ----------------------------------------------------------------------------- +// Primary Button Style +// ----------------------------------------------------------------------------- +/// A prominent button style with gradient background and press animation. +struct AIPrimaryButtonStyle: ButtonStyle { + @Environment(\.isEnabled) var isEnabled + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.aiLabel) + .foregroundStyle(.white) + .padding(.horizontal, AISpacing.lg) + .padding(.vertical, AISpacing.md) + .background( + RoundedRectangle(cornerRadius: AIRadius.md) + .fill(Color.aiGradientPrimary) + .opacity(isEnabled ? 1 : 0.5) + ) + .scaleEffect(configuration.isPressed ? 0.96 : 1) + .animation(.easeInOut(duration: 0.15), value: configuration.isPressed) + } +} + +extension ButtonStyle where Self == AIPrimaryButtonStyle { + /// Primary button style with gradient background. + static var aiPrimary: AIPrimaryButtonStyle { AIPrimaryButtonStyle() } +} + +// ----------------------------------------------------------------------------- +// Secondary Button Style +// ----------------------------------------------------------------------------- +/// A subtle button style with outline and transparent background. +struct AISecondaryButtonStyle: ButtonStyle { + @Environment(\.isEnabled) var isEnabled + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.aiLabel) + .foregroundStyle(Color.aiPrimary) + .padding(.horizontal, AISpacing.lg) + .padding(.vertical, AISpacing.md) + .background( + RoundedRectangle(cornerRadius: AIRadius.md) + .stroke(Color.aiPrimary, lineWidth: 2) + .opacity(isEnabled ? 1 : 0.5) + ) + .scaleEffect(configuration.isPressed ? 0.96 : 1) + .animation(.easeInOut(duration: 0.15), value: configuration.isPressed) + } +} + +extension ButtonStyle where Self == AISecondaryButtonStyle { + /// Secondary button style with outline. + static var aiSecondary: AISecondaryButtonStyle { AISecondaryButtonStyle() } +} + +// ----------------------------------------------------------------------------- +// Icon Button Style +// ----------------------------------------------------------------------------- +/// A circular icon button style. +struct AIIconButtonStyle: ButtonStyle { + let size: CGFloat + let backgroundColor: Color + + init(size: CGFloat = 44, backgroundColor: Color = .aiPrimary) { + self.size = size + self.backgroundColor = backgroundColor + } + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(size: size * 0.45, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: size, height: size) + .background( + Circle() + .fill(backgroundColor) + ) + .scaleEffect(configuration.isPressed ? 0.9 : 1) + .animation(.easeInOut(duration: 0.15), value: configuration.isPressed) + } +} + +// ----------------------------------------------------------------------------- +// Glass Background Modifier +// ----------------------------------------------------------------------------- +/// Applies a frosted glass effect background. +struct AIGlassBackground: ViewModifier { + @Environment(\.colorScheme) var colorScheme + + func body(content: Content) -> some View { + content + .background( + RoundedRectangle(cornerRadius: AIRadius.lg) + .fill(.ultraThinMaterial) + .shadow( + color: Color.black.opacity(0.1), + radius: 8, + x: 0, + y: 2 + ) + ) + } +} + +extension View { + /// Applies a frosted glass background effect. + func aiGlassBackground() -> some View { + modifier(AIGlassBackground()) + } +} + +// ----------------------------------------------------------------------------- +// Shimmer Loading Effect +// ----------------------------------------------------------------------------- +/// A shimmer animation for loading states. +struct AIShimmer: ViewModifier { + @State private var phase: CGFloat = 0 + + func body(content: Content) -> some View { + content + .overlay( + LinearGradient( + colors: [ + .clear, + .white.opacity(0.3), + .clear + ], + startPoint: .leading, + endPoint: .trailing + ) + .offset(x: phase) + .mask(content) + ) + .onAppear { + withAnimation(.linear(duration: 1.5).repeatForever(autoreverses: false)) { + phase = 200 + } + } + } +} + +extension View { + /// Applies a shimmer loading animation. + func aiShimmer() -> some View { + modifier(AIShimmer()) + } +} + +// ============================================================================= +// MARK: - Feature Card Style +// ============================================================================= +/// A reusable feature card with icon, title, description, and gradient accent. +struct AIFeatureCard: View { + let icon: String + let title: String + let description: String + let gradientColors: [Color] + let action: () -> Void + + @Environment(\.colorScheme) var colorScheme + + var body: some View { + Button(action: action) { + HStack(spacing: AISpacing.md) { + // Icon with gradient background + ZStack { + RoundedRectangle(cornerRadius: AIRadius.md) + .fill( + LinearGradient( + colors: gradientColors, + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 56, height: 56) + + Image(systemName: icon) + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(.white) + } + + // Text content + VStack(alignment: .leading, spacing: AISpacing.xs) { + Text(title) + .font(.aiHeadingSmall) + .foregroundStyle(colorScheme == .dark ? .white : .primary) + + Text(description) + .font(.aiBodySmall) + .foregroundStyle(.secondary) + .lineLimit(2) + } + + Spacer() + + // Chevron + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.tertiary) + } + .padding(AISpacing.md) + .aiCardStyle() + } + .buttonStyle(.plain) + } +} + +// ============================================================================= +// MARK: - Status Badge +// ============================================================================= +/// A small status indicator badge. +struct AIStatusBadge: View { + enum Status { + case ready, loading, error, success + + var color: Color { + switch self { + case .ready: return .aiSecondary + case .loading: return .aiWarning + case .error: return .aiError + case .success: return .aiSuccess + } + } + + var icon: String { + switch self { + case .ready: return "checkmark.circle.fill" + case .loading: return "arrow.circlepath" + case .error: return "exclamationmark.circle.fill" + case .success: return "checkmark.circle.fill" + } + } + } + + let status: Status + let text: String + + var body: some View { + HStack(spacing: AISpacing.xs) { + Image(systemName: status.icon) + .font(.system(size: 12)) + .foregroundStyle(status.color) + .rotationEffect(status == .loading ? .degrees(360) : .zero) + .animation( + status == .loading + ? .linear(duration: 1).repeatForever(autoreverses: false) + : .default, + value: status == .loading + ) + + Text(text) + .font(.aiCaption) + .foregroundStyle(.secondary) + } + .padding(.horizontal, AISpacing.sm) + .padding(.vertical, AISpacing.xs) + .background( + Capsule() + .fill(status.color.opacity(0.15)) + ) + } +} + +// ============================================================================= +// MARK: - Previews +// ============================================================================= +#Preview("Feature Cards") { + VStack(spacing: AISpacing.md) { + AIFeatureCard( + icon: "bubble.left.and.bubble.right.fill", + title: "Chat with AI", + description: "On-device LLM for private conversations", + gradientColors: [.aiPrimary, .aiPrimary.opacity(0.7)] + ) {} + + AIFeatureCard( + icon: "waveform", + title: "Speech to Text", + description: "Transcribe audio using Whisper", + gradientColors: [.aiSecondary, .aiSecondary.opacity(0.7)] + ) {} + + AIFeatureCard( + icon: "speaker.wave.2.fill", + title: "Text to Speech", + description: "Natural voice synthesis with Piper", + gradientColors: [.aiAccent, .aiAccent.opacity(0.7)] + ) {} + } + .padding() +} + +#Preview("Buttons") { + VStack(spacing: AISpacing.md) { + Button("Primary Button") {} + .buttonStyle(.aiPrimary) + + Button("Secondary Button") {} + .buttonStyle(.aiSecondary) + + HStack(spacing: AISpacing.md) { + AIStatusBadge(status: .ready, text: "Ready") + AIStatusBadge(status: .loading, text: "Loading") + AIStatusBadge(status: .error, text: "Error") + AIStatusBadge(status: .success, text: "Success") + } + } + .padding() +} + +// ============================================================================= +// MARK: - Model State +// ============================================================================= +/// Represents the loading state of an AI model. +/// +/// Used by ModelLoaderView and related components to display appropriate UI. +// ============================================================================= +enum ModelState: Equatable { + /// Model has not been downloaded or loaded + case notLoaded + + /// Model is currently downloading (progress 0.0 - 1.0) + case downloading(progress: Double) + + /// Model is loading into memory + case loading + + /// Model is ready for use + case ready + + /// An error occurred + case error(message: String) + + // ------------------------------------------------------------------------- + // Convenience Properties + // ------------------------------------------------------------------------- + + /// Whether the model is ready to use + var isReady: Bool { + if case .ready = self { return true } + return false + } + + /// Whether the model is in a loading state (downloading or loading) + var isLoading: Bool { + switch self { + case .downloading, .loading: + return true + default: + return false + } + } + + /// SF Symbol name for this state + var statusIcon: String { + switch self { + case .notLoaded: + return "arrow.down.circle" + case .downloading: + return "arrow.down.circle.fill" + case .loading: + return "circle.dotted" + case .ready: + return "checkmark.circle.fill" + case .error: + return "exclamationmark.circle.fill" + } + } + + /// Color for this state + var statusColor: Color { + switch self { + case .notLoaded: + return .secondary + case .downloading, .loading: + return Color.aiWarning + case .ready: + return Color.aiSuccess + case .error: + return Color.aiError + } + } + + /// Human-readable status text + var statusText: String { + switch self { + case .notLoaded: + return "Not loaded" + case .downloading(let progress): + return "Downloading \(Int(progress * 100))%" + case .loading: + return "Loading..." + case .ready: + return "Ready" + case .error(let message): + return message + } + } +} diff --git a/Playground/swift-starter-app/LocalAIPlayground/Views/ChatView.swift b/Playground/swift-starter-app/LocalAIPlayground/Views/ChatView.swift new file mode 100644 index 000000000..72359f9b9 --- /dev/null +++ b/Playground/swift-starter-app/LocalAIPlayground/Views/ChatView.swift @@ -0,0 +1,379 @@ +// +// ChatView.swift +// LocalAIPlayground +// +// ============================================================================= +// CHAT VIEW - ON-DEVICE LLM INTERACTION +// ============================================================================= +// +// This view demonstrates how to use the RunAnywhere SDK's LLM capabilities +// for on-device text generation with streaming support. +// +// KEY CONCEPTS DEMONSTRATED: +// +// 1. MODEL LOADING +// - Models must be registered, downloaded, and loaded via ModelService +// - The view checks modelService.isLLMLoaded before enabling chat +// +// 2. TEXT GENERATION +// - RunAnywhere.generateStream() for streaming token generation +// - LLMGenerationOptions for configuring temperature, max tokens, etc. +// +// 3. STREAMING UI +// - Real-time token display as they're generated +// - Performance metrics (tokens/second) +// - Graceful handling of generation state +// +// RUNANYWHERE SDK METHODS USED: +// - RunAnywhere.generateStream() - Streaming generation +// - LLMGenerationOptions - Configure generation parameters +// +// ============================================================================= + +import SwiftUI +import RunAnywhere + +// ============================================================================= +// MARK: - Chat View +// ============================================================================= +/// A chat interface for interacting with the on-device LLM. +/// +/// This view provides a familiar chat UI where users can send messages +/// and receive AI-generated responses with real-time streaming. +// ============================================================================= +struct ChatView: View { + // ------------------------------------------------------------------------- + // MARK: - Environment & State Properties + // ------------------------------------------------------------------------- + + /// Model service for checking LLM state and loading + @EnvironmentObject var modelService: ModelService + + @Environment(\.dismiss) private var dismiss + @Environment(\.colorScheme) private var colorScheme + + /// List of messages in the conversation + @State private var messages: [ChatMessage] = [] + + /// Current text in the input field + @State private var inputText = "" + + /// Whether the AI is currently generating a response + @State private var isGenerating = false + + /// Current response being streamed + @State private var currentResponse = "" + + /// Task for streaming generation (so we can cancel it) + @State private var streamingTask: Task? + + /// Focus state for the input field + @FocusState private var isInputFocused: Bool + + // ------------------------------------------------------------------------- + // MARK: - Body + // ------------------------------------------------------------------------- + + var body: some View { + NavigationStack { + ZStack { + // Background + (colorScheme == .dark ? Color(white: 0.05) : Color(white: 0.98)) + .ignoresSafeArea() + + // Check if model is loaded + if !modelService.isLLMLoaded { + // Show model loader + modelLoaderOverlay + } else { + // Show chat interface + chatInterface + } + } + .navigationTitle("Chat") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Close") { + streamingTask?.cancel() + dismiss() + } + } + + ToolbarItem(placement: .topBarTrailing) { + if !messages.isEmpty { + Button(action: clearChat) { + Image(systemName: "trash") + } + } + } + } + } + .onDisappear { + streamingTask?.cancel() + } + } + + // ------------------------------------------------------------------------- + // MARK: - Model Loader Overlay + // ------------------------------------------------------------------------- + + private var modelLoaderOverlay: some View { + VStack(spacing: AISpacing.xl) { + Spacer() + + ModelLoaderView( + modelName: "LiquidAI LFM2 350M", + modelDescription: "Compact on-device language model optimized for mobile inference with Q4_K_M quantization.", + modelSize: "~250MB", + state: modelLoaderState, + onLoad: { + Task { + await modelService.downloadAndLoadLLM() + } + }, + onRetry: { + Task { + await modelService.downloadAndLoadLLM() + } + } + ) + .padding(.horizontal) + + // Info text + VStack(spacing: AISpacing.sm) { + Text("First-time setup") + .font(.aiHeadingSmall) + + Text("The model will be downloaded once and cached locally for future use.") + .font(.aiBodySmall) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding(.horizontal, AISpacing.xl) + + Spacer() + } + } + + /// Converts ModelService state to ModelState for the loader view + private var modelLoaderState: ModelState { + if modelService.isLLMLoaded { + return .ready + } else if modelService.isLLMLoading { + return .loading + } else if modelService.isLLMDownloading { + return .downloading(progress: modelService.llmDownloadProgress) + } else { + return .notLoaded + } + } + + // ------------------------------------------------------------------------- + // MARK: - Chat Interface + // ------------------------------------------------------------------------- + + private var chatInterface: some View { + VStack(spacing: 0) { + // Messages list + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: AISpacing.md) { + if messages.isEmpty { + // Empty state + EmptyChatView( + title: "Start Chatting", + subtitle: "Ask questions, get explanations, or just have a conversation with the AI.", + suggestions: [ + "Tell me a joke", + "What is AI?", + "Write a haiku" + ], + onSuggestionTap: { suggestion in + inputText = suggestion + sendMessage() + } + ) + .padding(.top, AISpacing.xxl) + } else { + // Message bubbles + ForEach(messages) { message in + MessageBubble(message: message) + .id(message.id) + } + + // Streaming message + if isGenerating { + MessageBubble(message: ChatMessage( + role: .assistant, + content: currentResponse.isEmpty ? "..." : currentResponse, + isStreaming: true + )) + .id("streaming") + } + } + } + .padding() + } + .onChange(of: messages.count) { _, _ in + scrollToBottom(proxy: proxy) + } + .onChange(of: currentResponse) { _, _ in + scrollToBottom(proxy: proxy) + } + } + + // Input field + MessageInputField( + text: $inputText, + placeholder: "Ask me anything...", + isLoading: isGenerating, + onSend: sendMessage + ) + } + } + + private func scrollToBottom(proxy: ScrollViewProxy) { + withAnimation(.easeOut(duration: 0.2)) { + if isGenerating { + proxy.scrollTo("streaming", anchor: .bottom) + } else if let lastMessage = messages.last { + proxy.scrollTo(lastMessage.id, anchor: .bottom) + } + } + } + + // ========================================================================= + // MARK: - Message Sending & Generation + // ========================================================================= + + /// Sends the current input as a user message and generates a response. + // ------------------------------------------------------------------------- + private func sendMessage() { + let userText = inputText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !userText.isEmpty && !isGenerating else { return } + + // Add user message + let userMessage = ChatMessage(role: .user, content: userText) + messages.append(userMessage) + + // Clear input + inputText = "" + isInputFocused = false + + // Start generation + isGenerating = true + currentResponse = "" + + streamingTask = Task { + await generateResponse(to: userText) + } + } + + /// Generates an AI response to the given prompt using streaming. + /// + /// ## RunAnywhere SDK Usage + /// + /// This method demonstrates `RunAnywhere.generateStream()` which: + /// 1. Takes a prompt and generation options + /// 2. Returns a stream of tokens as they're generated + /// 3. Provides final metrics after generation completes + /// + /// - Parameter prompt: The user's message to respond to + // ------------------------------------------------------------------------- + private func generateResponse(to prompt: String) async { + do { + // ----------------------------------------------------------------- + // Configure Generation Options + // ----------------------------------------------------------------- + // LLMGenerationOptions controls how the model generates text: + // + // - maxTokens: Limits response length + // - temperature: Higher = more creative, lower = more focused + // - 0.0-0.3: Factual, deterministic responses + // - 0.4-0.7: Balanced creativity and coherence + // - 0.8-1.0: Creative, varied responses + // ----------------------------------------------------------------- + let options = LLMGenerationOptions( + maxTokens: 256, + temperature: 0.8 + ) + + // ----------------------------------------------------------------- + // Start Streaming Generation + // ----------------------------------------------------------------- + // generateStream() returns a StreamingResult containing: + // - stream: AsyncStream of tokens + // - result: Task with final metrics + // ----------------------------------------------------------------- + let result = try await RunAnywhere.generateStream( + prompt, + options: options + ) + + // ----------------------------------------------------------------- + // Process Streaming Tokens + // ----------------------------------------------------------------- + for try await token in result.stream { + guard !Task.isCancelled else { break } + + await MainActor.run { + currentResponse += token + } + } + + // ----------------------------------------------------------------- + // Get Final Metrics + // ----------------------------------------------------------------- + let metrics = try await result.result.value + + await MainActor.run { + if !Task.isCancelled { + // Add assistant message with metrics + let aiMessage = ChatMessage( + role: .assistant, + content: currentResponse + ) + messages.append(aiMessage) + + print("✅ Generation: \(metrics.tokensUsed) tokens at \(String(format: "%.1f", metrics.tokensPerSecond)) tok/s") + } + + isGenerating = false + currentResponse = "" + } + + } catch { + await MainActor.run { + // Add error message + let errorMessage = ChatMessage( + role: .assistant, + content: "Error: \(error.localizedDescription)" + ) + messages.append(errorMessage) + + isGenerating = false + currentResponse = "" + } + + print("❌ Generation failed: \(error)") + } + } + + /// Clears all messages from the conversation. + // ------------------------------------------------------------------------- + private func clearChat() { + streamingTask?.cancel() + messages.removeAll() + currentResponse = "" + isGenerating = false + } +} + +// ============================================================================= +// MARK: - Preview +// ============================================================================= +#Preview { + ChatView() + .environmentObject(ModelService()) +} diff --git a/Playground/swift-starter-app/LocalAIPlayground/Views/HomeView.swift b/Playground/swift-starter-app/LocalAIPlayground/Views/HomeView.swift new file mode 100644 index 000000000..00c3b511f --- /dev/null +++ b/Playground/swift-starter-app/LocalAIPlayground/Views/HomeView.swift @@ -0,0 +1,372 @@ +// +// HomeView.swift +// LocalAIPlayground +// +// ============================================================================= +// HOME VIEW - APP LANDING PAGE +// ============================================================================= +// +// The main landing page of the app that showcases all available AI features +// and provides quick access to each capability. +// +// FEATURES DISPLAYED: +// - Chat (LLM) - On-device text generation +// - Speech to Text - Whisper-based transcription +// - Text to Speech - Piper voice synthesis +// - Voice Pipeline - Full voice agent demo +// +// ============================================================================= + +import SwiftUI + +// ============================================================================= +// MARK: - Home View +// ============================================================================= +/// The main landing view displaying feature cards and model status. +// ============================================================================= +struct HomeView: View { + @EnvironmentObject var modelService: ModelService + @Environment(\.colorScheme) var colorScheme + + /// Callback when a feature is selected + let onFeatureSelected: (Feature) -> Void + + /// Available features in the app + enum Feature: CaseIterable { + case chat + case speechToText + case textToSpeech + case voicePipeline + + var title: String { + switch self { + case .chat: return "Chat" + case .speechToText: return "Speech to Text" + case .textToSpeech: return "Text to Speech" + case .voicePipeline: return "Voice Pipeline" + } + } + + var description: String { + switch self { + case .chat: + return "Chat with an on-device LLM. Streaming responses, complete privacy." + case .speechToText: + return "Transcribe speech using Whisper. Works entirely offline." + case .textToSpeech: + return "Natural voice synthesis with Piper neural TTS." + case .voicePipeline: + return "Full voice agent: Speak → Transcribe → Generate → Speak" + } + } + + var icon: String { + switch self { + case .chat: return "bubble.left.and.bubble.right.fill" + case .speechToText: return "waveform" + case .textToSpeech: return "speaker.wave.2.fill" + case .voicePipeline: return "person.wave.2.fill" + } + } + + var gradientColors: [Color] { + switch self { + case .chat: return [.aiPrimary, .aiPrimary.opacity(0.7)] + case .speechToText: return [.aiSecondary, .aiSecondary.opacity(0.7)] + case .textToSpeech: return [.aiAccent, .aiAccent.opacity(0.7)] + case .voicePipeline: return [.purple, .purple.opacity(0.7)] + } + } + } + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: AISpacing.xl) { + // Hero section + heroSection + + // Model status + modelStatusSection + + // Feature cards + featureCardsSection + + // Footer + footerSection + } + .padding() + } + .background(backgroundGradient) + .navigationTitle("LocalAI Playground") + .navigationBarTitleDisplayMode(.large) + } + } + + // ------------------------------------------------------------------------- + // MARK: - Hero Section + // ------------------------------------------------------------------------- + + private var heroSection: some View { + VStack(spacing: AISpacing.md) { + // App icon + ZStack { + RoundedRectangle(cornerRadius: 24) + .fill( + LinearGradient( + colors: [.aiPrimary, .aiPrimary.opacity(0.7)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 80, height: 80) + .shadow(color: .aiPrimary.opacity(0.4), radius: 16, y: 8) + + Image(systemName: "cpu") + .font(.system(size: 36, weight: .semibold)) + .foregroundStyle(.white) + } + + // Title + Text("On-Device AI") + .font(.aiDisplayLarge) + .foregroundStyle(.primary) + + // Subtitle + Text("Privacy-first AI capabilities powered by RunAnywhere SDK. All processing happens locally on your device.") + .font(.aiBody) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + + // Privacy badges + HStack(spacing: AISpacing.md) { + PrivacyBadge(icon: "lock.shield.fill", text: "100% Private") + PrivacyBadge(icon: "wifi.slash", text: "Works Offline") + PrivacyBadge(icon: "bolt.fill", text: "Low Latency") + } + } + .padding(.vertical, AISpacing.lg) + } + + // ------------------------------------------------------------------------- + // MARK: - Model Status Section + // ------------------------------------------------------------------------- + + private var modelStatusSection: some View { + VStack(alignment: .leading, spacing: AISpacing.md) { + HStack { + Text("Model Status") + .font(.aiHeading) + + Spacer() + + if modelService.isVoiceAgentReady { + AIStatusBadge(status: .ready, text: "All Ready") + } + } + + VStack(spacing: AISpacing.sm) { + ModelStatusRow( + name: "LLM", + model: "LFM2 350M", + isLoaded: modelService.isLLMLoaded, + isLoading: modelService.isLLMLoading, + isDownloading: modelService.isLLMDownloading, + downloadProgress: modelService.llmDownloadProgress + ) + + ModelStatusRow( + name: "STT", + model: "Whisper Tiny", + isLoaded: modelService.isSTTLoaded, + isLoading: modelService.isSTTLoading, + isDownloading: modelService.isSTTDownloading, + downloadProgress: modelService.sttDownloadProgress + ) + + ModelStatusRow( + name: "TTS", + model: "Piper Lessac", + isLoaded: modelService.isTTSLoaded, + isLoading: modelService.isTTSLoading, + isDownloading: modelService.isTTSDownloading, + downloadProgress: modelService.ttsDownloadProgress + ) + } + .padding(AISpacing.md) + .aiCardStyle() + } + } + + // ------------------------------------------------------------------------- + // MARK: - Feature Cards Section + // ------------------------------------------------------------------------- + + private var featureCardsSection: some View { + VStack(alignment: .leading, spacing: AISpacing.md) { + Text("Features") + .font(.aiHeading) + + ForEach(Feature.allCases, id: \.title) { feature in + AIFeatureCard( + icon: feature.icon, + title: feature.title, + description: feature.description, + gradientColors: feature.gradientColors + ) { + onFeatureSelected(feature) + } + } + } + } + + // ------------------------------------------------------------------------- + // MARK: - Footer Section + // ------------------------------------------------------------------------- + + private var footerSection: some View { + VStack(spacing: AISpacing.sm) { + Divider() + + Text("Powered by RunAnywhere SDK") + .font(.aiCaption) + .foregroundStyle(.tertiary) + + HStack(spacing: AISpacing.xs) { + Text("v0.16.0") + .font(.aiCaption) + .foregroundStyle(.tertiary) + + Text("•") + .foregroundStyle(.tertiary) + + Link("Documentation", destination: URL(string: "https://docs.runanywhere.ai")!) + .font(.aiCaption) + } + } + .padding(.top, AISpacing.lg) + } + + // ------------------------------------------------------------------------- + // MARK: - Background + // ------------------------------------------------------------------------- + + private var backgroundGradient: some View { + Group { + if colorScheme == .dark { + Color.aiGradientBackgroundDark + .ignoresSafeArea() + } else { + Color.aiGradientBackground + .ignoresSafeArea() + } + } + } +} + +// ============================================================================= +// MARK: - Privacy Badge +// ============================================================================= +/// A small badge displaying a privacy/feature benefit. +// ============================================================================= +struct PrivacyBadge: View { + let icon: String + let text: String + + var body: some View { + HStack(spacing: AISpacing.xs) { + Image(systemName: icon) + .font(.system(size: 12)) + .foregroundStyle(Color.aiPrimary) + + Text(text) + .font(.aiCaption) + .foregroundStyle(.secondary) + } + .padding(.horizontal, AISpacing.sm) + .padding(.vertical, AISpacing.xs) + .background( + Capsule() + .fill(Color.aiPrimary.opacity(0.1)) + ) + } +} + +// ============================================================================= +// MARK: - Model Status Row +// ============================================================================= +/// A row displaying the status of a single model. +// ============================================================================= +struct ModelStatusRow: View { + let name: String + let model: String + let isLoaded: Bool + let isLoading: Bool + let isDownloading: Bool + let downloadProgress: Double + + var body: some View { + HStack(spacing: AISpacing.md) { + // Status indicator + Circle() + .fill(statusColor) + .frame(width: 10, height: 10) + + // Name + Text(name) + .font(.aiLabel) + .frame(width: 40, alignment: .leading) + + // Model name + Text(model) + .font(.aiBodySmall) + .foregroundStyle(.secondary) + + Spacer() + + // Status + Group { + if isDownloading { + Text("\(Int(downloadProgress * 100))%") + .font(.aiMono) + } else if isLoading { + ProgressView() + .scaleEffect(0.6) + } else { + Text(statusText) + .font(.aiCaption) + .foregroundStyle(.secondary) + } + } + } + } + + private var statusColor: Color { + if isLoaded { + return .aiSuccess + } else if isLoading || isDownloading { + return .aiWarning + } else { + return .secondary + } + } + + private var statusText: String { + if isLoaded { + return "Ready" + } else { + return "Not loaded" + } + } +} + +// ============================================================================= +// MARK: - Preview +// ============================================================================= +#Preview { + HomeView { feature in + print("Selected: \(feature.title)") + } + .environmentObject(ModelService()) +} diff --git a/Playground/swift-starter-app/LocalAIPlayground/Views/SpeechToTextView.swift b/Playground/swift-starter-app/LocalAIPlayground/Views/SpeechToTextView.swift new file mode 100644 index 000000000..7e2eb85a3 --- /dev/null +++ b/Playground/swift-starter-app/LocalAIPlayground/Views/SpeechToTextView.swift @@ -0,0 +1,625 @@ +// +// SpeechToTextView.swift +// LocalAIPlayground +// +// ============================================================================= +// SPEECH TO TEXT VIEW - ON-DEVICE TRANSCRIPTION +// ============================================================================= +// +// This view demonstrates how to use the RunAnywhere SDK's Speech-to-Text +// capabilities powered by Whisper, running entirely on-device. +// +// KEY CONCEPTS DEMONSTRATED: +// +// 1. STT MODEL LOADING +// - Models must be registered, downloaded, and loaded via ModelService +// +// 2. AUDIO CAPTURE +// - Microphone permission handling +// - Real-time audio recording +// - Audio format conversion (16kHz mono PCM) +// +// 3. TRANSCRIPTION +// - Converting audio to text with RunAnywhere.transcribe() +// +// AUDIO REQUIREMENTS: +// ┌────────────────┬──────────────────────────────────────┐ +// │ Parameter │ Value │ +// ├────────────────┼──────────────────────────────────────┤ +// │ Sample Rate │ 16000 Hz (required by Whisper) │ +// │ Channels │ 1 (mono) │ +// │ Format │ 16-bit signed integer PCM │ +// └────────────────┴──────────────────────────────────────┘ +// +// ============================================================================= + +import SwiftUI +import RunAnywhere +import AVFoundation + +// ============================================================================= +// MARK: - Speech to Text View +// ============================================================================= +/// A view for recording and transcribing speech using on-device Whisper. +// ============================================================================= +struct SpeechToTextView: View { + // ------------------------------------------------------------------------- + // MARK: - State Properties + // ------------------------------------------------------------------------- + + /// Service managing AI model loading + @EnvironmentObject var modelService: ModelService + + /// Service managing audio capture + @StateObject private var audioService = AudioService.shared + + /// List of transcription results + @State private var transcriptions: [TranscriptionResult] = [] + + /// Whether we're currently transcribing + @State private var isTranscribing = false + + /// Error message to display + @State private var errorMessage: String? + + /// Show permission alert + @State private var showPermissionAlert = false + + @Environment(\.dismiss) private var dismiss + @Environment(\.colorScheme) private var colorScheme + + // ------------------------------------------------------------------------- + // MARK: - Body + // ------------------------------------------------------------------------- + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + if !modelService.isSTTLoaded { + // Model not loaded - show loader + modelLoaderView + } else if !audioService.hasPermission { + // No microphone permission + permissionView + } else { + // Ready to record + recordingInterface + } + } + .navigationTitle("Speech to Text") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Close") { + dismiss() + } + } + + ToolbarItem(placement: .topBarTrailing) { + if !transcriptions.isEmpty { + Button(action: clearTranscriptions) { + Image(systemName: "trash") + } + } + } + } + .alert("Microphone Access Required", isPresented: $showPermissionAlert) { + Button("Open Settings") { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Please enable microphone access in Settings to use speech recognition.") + } + .alert("Error", isPresented: .init( + get: { errorMessage != nil }, + set: { if !$0 { errorMessage = nil } } + )) { + Button("OK") { errorMessage = nil } + } message: { + Text(errorMessage ?? "") + } + } + } + + // ------------------------------------------------------------------------- + // MARK: - Model Loader State + // ------------------------------------------------------------------------- + + private var modelLoaderState: ModelState { + if modelService.isSTTLoaded { + return .ready + } else if modelService.isSTTLoading { + return .loading + } else if modelService.isSTTDownloading { + return .downloading(progress: modelService.sttDownloadProgress) + } else { + return .notLoaded + } + } + + // ------------------------------------------------------------------------- + // MARK: - Model Loader View + // ------------------------------------------------------------------------- + + private var modelLoaderView: some View { + VStack(spacing: AISpacing.xl) { + Spacer() + + ModelLoaderView( + modelName: "Whisper Tiny (English)", + modelDescription: "Fast on-device speech recognition using OpenAI's Whisper architecture, optimized for mobile via Sherpa-ONNX.", + modelSize: "~75MB", + state: modelLoaderState, + onLoad: { + Task { + await modelService.downloadAndLoadSTT() + } + }, + onRetry: { + Task { + await modelService.downloadAndLoadSTT() + } + } + ) + .padding(.horizontal) + + // Info about STT + InfoCard( + icon: "waveform", + title: "How it works", + description: "Whisper converts your speech to text entirely on-device. No audio data leaves your phone." + ) + .padding(.horizontal) + + Spacer() + } + } + + // ------------------------------------------------------------------------- + // MARK: - Permission View + // ------------------------------------------------------------------------- + + private var permissionView: some View { + VStack(spacing: AISpacing.xl) { + Spacer() + + // Icon + Image(systemName: "mic.slash.fill") + .font(.system(size: 64)) + .foregroundStyle(.secondary) + + // Title + Text("Microphone Access Needed") + .font(.aiHeading) + + // Description + Text("To transcribe your speech, we need permission to access your microphone. Audio is processed entirely on-device.") + .font(.aiBody) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, AISpacing.xl) + + // Request button + Button(action: requestPermission) { + HStack { + Image(systemName: "mic.fill") + Text("Enable Microphone") + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.aiPrimary) + .padding(.horizontal, AISpacing.xl) + + Spacer() + } + } + + // ------------------------------------------------------------------------- + // MARK: - Recording Interface + // ------------------------------------------------------------------------- + + private var recordingInterface: some View { + VStack(spacing: 0) { + // Transcription history + ScrollView { + LazyVStack(spacing: AISpacing.md) { + if transcriptions.isEmpty { + emptyStateView + } else { + ForEach(transcriptions) { result in + TranscriptionCard(result: result) + } + } + } + .padding() + } + + Divider() + + // Recording controls + recordingControls + } + } + + // ------------------------------------------------------------------------- + // MARK: - Empty State + // ------------------------------------------------------------------------- + + private var emptyStateView: some View { + VStack(spacing: AISpacing.lg) { + Image(systemName: "waveform.badge.mic") + .font(.system(size: 48)) + .foregroundStyle(.tertiary) + + Text("Ready to Transcribe") + .font(.aiHeading) + + Text("Tap and hold the record button to capture speech. Release to transcribe.") + .font(.aiBody) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + // Tips + VStack(alignment: .leading, spacing: AISpacing.sm) { + TipRow(icon: "speaker.wave.2", text: "Speak clearly for best results") + TipRow(icon: "hand.raised", text: "Minimize background noise") + TipRow(icon: "clock", text: "Recordings up to 30 seconds work best") + } + .padding() + .background( + RoundedRectangle(cornerRadius: AIRadius.md) + .fill(Color.secondary.opacity(0.1)) + ) + } + .padding(.top, AISpacing.xxl) + } + + // ------------------------------------------------------------------------- + // MARK: - Recording Controls + // ------------------------------------------------------------------------- + + private var recordingControls: some View { + VStack(spacing: AISpacing.md) { + // Recording indicator + if audioService.state == .recording { + RecordingIndicator( + isRecording: true, + duration: audioService.recordingDuration + ) + + // Audio visualizer + WaveformVisualizer( + level: audioService.inputLevel, + isActive: true, + color: .aiPrimary + ) + .frame(height: 40) + .padding(.horizontal) + } + + // Record button + HStack(spacing: AISpacing.xl) { + // Cancel button (when recording) + if audioService.state == .recording { + Button(action: cancelRecording) { + Image(systemName: "xmark") + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 50, height: 50) + .background(Circle().fill(Color.secondary)) + } + } + + // Main record button + RecordButton( + isRecording: audioService.state == .recording, + isProcessing: isTranscribing, + onTap: toggleRecording + ) + + // Done button (when recording) + if audioService.state == .recording { + Button(action: stopAndTranscribe) { + Image(systemName: "checkmark") + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 50, height: 50) + .background(Circle().fill(Color.aiSuccess)) + } + } + } + + // Instructions + Text(instructionText) + .font(.aiCaption) + .foregroundStyle(.secondary) + } + .padding() + .background( + Rectangle() + .fill(.ultraThinMaterial) + .ignoresSafeArea() + ) + } + + private var instructionText: String { + switch audioService.state { + case .recording: + return "Tap ✓ to transcribe or ✕ to cancel" + case .idle: + if isTranscribing { + return "Transcribing..." + } + return "Tap to start recording" + default: + return "" + } + } + + // ========================================================================= + // MARK: - Actions + // ========================================================================= + + private func requestPermission() { + Task { + let granted = await audioService.requestPermission() + if !granted { + showPermissionAlert = true + } + } + } + + private func toggleRecording() { + if audioService.state == .recording { + stopAndTranscribe() + } else { + startRecording() + } + } + + private func startRecording() { + Task { + do { + try await audioService.startRecording() + } catch { + errorMessage = error.localizedDescription + } + } + } + + private func cancelRecording() { + audioService.cancelRecording() + } + + /// Stops recording and transcribes the captured audio. + /// + /// ## RunAnywhere SDK Usage + /// RunAnywhere.transcribe() converts audio data to text using Whisper. + // ------------------------------------------------------------------------- + private func stopAndTranscribe() { + Task { + isTranscribing = true + + do { + let audioData = try await audioService.stopRecording() + + print("🎤 Captured \(audioData.count) bytes of audio") + + guard audioData.count > 3200 else { + throw TranscriptionError.audioTooShort + } + + let startTime = Date() + + // --------------------------------------------------------- + // Transcribe with RunAnywhere SDK + // --------------------------------------------------------- + let transcribedText = try await RunAnywhere.transcribe(audioData) + + let duration = Date().timeIntervalSince(startTime) + + let cleanedText = transcribedText + .trimmingCharacters(in: .whitespacesAndNewlines) + + if !cleanedText.isEmpty { + let result = TranscriptionResult( + text: cleanedText, + duration: audioService.recordingDuration, + processingTime: duration + ) + transcriptions.insert(result, at: 0) + + print("✅ Transcription: \"\(cleanedText)\" in \(String(format: "%.2f", duration))s") + } else { + throw TranscriptionError.noSpeechDetected + } + + } catch { + print("❌ Transcription failed: \(error)") + errorMessage = error.localizedDescription + } + + isTranscribing = false + } + } + + private func clearTranscriptions() { + transcriptions.removeAll() + } +} + +// ============================================================================= +// MARK: - Supporting Types +// ============================================================================= + +struct TranscriptionResult: Identifiable { + let id = UUID() + let text: String + let duration: TimeInterval + let processingTime: TimeInterval + let timestamp = Date() +} + +enum TranscriptionError: LocalizedError { + case audioTooShort + case noSpeechDetected + case modelNotLoaded + + var errorDescription: String? { + switch self { + case .audioTooShort: + return "Recording too short. Please speak for at least 1 second." + case .noSpeechDetected: + return "No speech detected. Please try again." + case .modelNotLoaded: + return "STT model not loaded. Please wait for it to load." + } + } +} + +struct TranscriptionCard: View { + let result: TranscriptionResult + @Environment(\.colorScheme) var colorScheme + + var body: some View { + VStack(alignment: .leading, spacing: AISpacing.sm) { + Text(result.text) + .font(.aiBody) + .textSelection(.enabled) + + HStack(spacing: AISpacing.md) { + Label(String(format: "%.1fs audio", result.duration), systemImage: "waveform") + Label(String(format: "%.2fs to transcribe", result.processingTime), systemImage: "cpu") + + Spacer() + + Text(formattedTime) + } + .font(.aiCaption) + .foregroundStyle(.secondary) + } + .padding(AISpacing.md) + .background( + RoundedRectangle(cornerRadius: AIRadius.lg) + .fill(colorScheme == .dark ? Color(white: 0.15) : Color.white) + .shadow(color: .black.opacity(0.05), radius: 8, y: 2) + ) + .contextMenu { + Button(action: { UIPasteboard.general.string = result.text }) { + Label("Copy", systemImage: "doc.on.doc") + } + } + } + + private var formattedTime: String { + let formatter = DateFormatter() + formatter.timeStyle = .short + return formatter.string(from: result.timestamp) + } +} + +struct RecordButton: View { + let isRecording: Bool + let isProcessing: Bool + let onTap: () -> Void + + @State private var pulseScale: CGFloat = 1 + + var body: some View { + Button(action: onTap) { + ZStack { + if isRecording { + Circle() + .stroke(Color.aiPrimary.opacity(0.3), lineWidth: 3) + .frame(width: 100, height: 100) + .scaleEffect(pulseScale) + } + + Circle() + .stroke(isRecording ? Color.aiPrimary : Color.secondary.opacity(0.3), lineWidth: 4) + .frame(width: 80, height: 80) + + Circle() + .fill(isRecording ? Color.aiPrimary : Color.aiPrimary.opacity(0.9)) + .frame(width: 64, height: 64) + + if isProcessing { + ProgressView() + .tint(.white) + } else { + Image(systemName: isRecording ? "stop.fill" : "mic.fill") + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(.white) + } + } + } + .disabled(isProcessing) + .onChange(of: isRecording) { _, recording in + if recording { + withAnimation(.easeInOut(duration: 1).repeatForever(autoreverses: true)) { + pulseScale = 1.2 + } + } else { + pulseScale = 1 + } + } + } +} + +struct InfoCard: View { + let icon: String + let title: String + let description: String + + var body: some View { + HStack(spacing: AISpacing.md) { + Image(systemName: icon) + .font(.system(size: 24)) + .foregroundStyle(Color.aiSecondary) + .frame(width: 40) + + VStack(alignment: .leading, spacing: AISpacing.xs) { + Text(title) + .font(.aiLabel) + + Text(description) + .font(.aiBodySmall) + .foregroundStyle(.secondary) + } + } + .padding(AISpacing.md) + .background( + RoundedRectangle(cornerRadius: AIRadius.md) + .fill(Color.aiSecondary.opacity(0.1)) + ) + } +} + +struct TipRow: View { + let icon: String + let text: String + + var body: some View { + HStack(spacing: AISpacing.sm) { + Image(systemName: icon) + .font(.system(size: 14)) + .foregroundStyle(Color.aiSecondary) + .frame(width: 20) + + Text(text) + .font(.aiBodySmall) + .foregroundStyle(.secondary) + } + } +} + +// ============================================================================= +// MARK: - Preview +// ============================================================================= +#Preview { + SpeechToTextView() + .environmentObject(ModelService()) +} diff --git a/Playground/swift-starter-app/LocalAIPlayground/Views/TextToSpeechView.swift b/Playground/swift-starter-app/LocalAIPlayground/Views/TextToSpeechView.swift new file mode 100644 index 000000000..aa281d643 --- /dev/null +++ b/Playground/swift-starter-app/LocalAIPlayground/Views/TextToSpeechView.swift @@ -0,0 +1,537 @@ +// +// TextToSpeechView.swift +// LocalAIPlayground +// +// ============================================================================= +// TEXT TO SPEECH VIEW - ON-DEVICE VOICE SYNTHESIS +// ============================================================================= +// +// This view demonstrates how to use the RunAnywhere SDK's Text-to-Speech +// capabilities powered by Piper neural TTS, running entirely on-device. +// +// KEY CONCEPTS DEMONSTRATED: +// +// 1. TTS VOICE LOADING +// - Models must be registered, downloaded, and loaded via ModelService +// +// 2. SPEECH SYNTHESIS +// - Converting text to natural-sounding speech +// - Configuring voice parameters (rate, pitch, volume) +// +// 3. AUDIO PLAYBACK +// - Playing synthesized audio +// +// RUNANYWHERE SDK METHODS USED: +// - RunAnywhere.synthesize() - Convert text to audio +// - TTSOptions - Configure synthesis parameters +// +// ============================================================================= + +import SwiftUI +import RunAnywhere +import AVFoundation + +// ============================================================================= +// MARK: - Text to Speech View +// ============================================================================= +/// A view for synthesizing and playing speech from text input. +// ============================================================================= +struct TextToSpeechView: View { + // ------------------------------------------------------------------------- + // MARK: - State Properties + // ------------------------------------------------------------------------- + + @EnvironmentObject var modelService: ModelService + @StateObject private var audioService = AudioService.shared + + @State private var inputText = "" + @State private var isSynthesizing = false + @State private var synthesizedAudio: Data? + @State private var errorMessage: String? + + @State private var rate: Double = 1.0 + @State private var pitch: Double = 1.0 + @State private var volume: Double = 1.0 + + @State private var showSettings = false + @State private var history: [SynthesisEntry] = [] + + @Environment(\.dismiss) private var dismiss + @Environment(\.colorScheme) private var colorScheme + + private let sampleTexts = [ + "Hello! I'm an AI assistant running entirely on your device.", + "The quick brown fox jumps over the lazy dog.", + "Privacy matters. That's why all processing happens locally.", + "Welcome to the future of mobile AI.", + ] + + // ------------------------------------------------------------------------- + // MARK: - Body + // ------------------------------------------------------------------------- + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + if !modelService.isTTSLoaded { + modelLoaderView + } else { + ttsInterface + } + } + .navigationTitle("Text to Speech") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Close") { dismiss() } + } + + ToolbarItem(placement: .topBarTrailing) { + Button { showSettings = true } label: { + Image(systemName: "slider.horizontal.3") + } + } + } + .sheet(isPresented: $showSettings) { + ttsSettingsSheet + } + .alert("Error", isPresented: .init( + get: { errorMessage != nil }, + set: { if !$0 { errorMessage = nil } } + )) { + Button("OK") { errorMessage = nil } + } message: { + Text(errorMessage ?? "") + } + } + } + + // ------------------------------------------------------------------------- + // MARK: - Model Loader State + // ------------------------------------------------------------------------- + + private var modelLoaderState: ModelState { + if modelService.isTTSLoaded { + return .ready + } else if modelService.isTTSLoading { + return .loading + } else if modelService.isTTSDownloading { + return .downloading(progress: modelService.ttsDownloadProgress) + } else { + return .notLoaded + } + } + + // ------------------------------------------------------------------------- + // MARK: - Model Loader View + // ------------------------------------------------------------------------- + + private var modelLoaderView: some View { + VStack(spacing: AISpacing.xl) { + Spacer() + + ModelLoaderView( + modelName: "Piper TTS (US English)", + modelDescription: "Neural text-to-speech using Piper with the Lessac voice. Natural-sounding speech synthesis on-device.", + modelSize: "~65MB", + state: modelLoaderState, + onLoad: { + Task { await modelService.downloadAndLoadTTS() } + }, + onRetry: { + Task { await modelService.downloadAndLoadTTS() } + } + ) + .padding(.horizontal) + + InfoCard( + icon: "speaker.wave.2", + title: "Natural Voice Synthesis", + description: "Piper uses VITS neural architecture to generate human-like speech with proper intonation and rhythm." + ) + .padding(.horizontal) + + Spacer() + } + } + + // ------------------------------------------------------------------------- + // MARK: - TTS Interface + // ------------------------------------------------------------------------- + + private var ttsInterface: some View { + VStack(spacing: 0) { + ScrollView { + VStack(spacing: AISpacing.lg) { + textInputSection + sampleTextsSection + + if synthesizedAudio != nil { + playbackSection + } + + if !history.isEmpty { + historySection + } + } + .padding() + } + + synthesizeButton + } + } + + private var textInputSection: some View { + VStack(alignment: .leading, spacing: AISpacing.sm) { + Text("Enter Text") + .font(.aiHeadingSmall) + + TextEditor(text: $inputText) + .font(.aiBody) + .frame(minHeight: 120) + .padding(AISpacing.sm) + .background( + RoundedRectangle(cornerRadius: AIRadius.md) + .fill(colorScheme == .dark ? Color(white: 0.15) : Color(white: 0.95)) + ) + .overlay( + RoundedRectangle(cornerRadius: AIRadius.md) + .stroke(Color.secondary.opacity(0.2), lineWidth: 1) + ) + + HStack { + Text("\(inputText.count) characters") + .font(.aiCaption) + .foregroundStyle(.secondary) + + Spacer() + + if !inputText.isEmpty { + Button("Clear") { inputText = "" } + .font(.aiCaption) + .foregroundStyle(Color.aiPrimary) + } + } + } + } + + private var sampleTextsSection: some View { + VStack(alignment: .leading, spacing: AISpacing.sm) { + Text("Quick Samples") + .font(.aiCaption) + .foregroundStyle(.secondary) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: AISpacing.sm) { + ForEach(sampleTexts, id: \.self) { sample in + Button { inputText = sample } label: { + Text(sample) + .font(.aiBodySmall) + .lineLimit(1) + .padding(.horizontal, AISpacing.md) + .padding(.vertical, AISpacing.sm) + .background(Capsule().fill(Color.secondary.opacity(0.15))) + } + .buttonStyle(.plain) + } + } + } + } + } + + private var playbackSection: some View { + VStack(spacing: AISpacing.md) { + if audioService.state == .playing { + WaveformVisualizer( + level: audioService.outputLevel, + isActive: true, + color: .aiAccent + ) + .frame(height: 50) + } + + HStack(spacing: AISpacing.lg) { + Button(action: togglePlayback) { + ZStack { + Circle() + .fill(Color.aiAccent) + .frame(width: 56, height: 56) + + Image(systemName: audioService.state == .playing ? "stop.fill" : "play.fill") + .font(.system(size: 24)) + .foregroundStyle(.white) + } + } + + VStack(alignment: .leading, spacing: 2) { + Text("Ready to play") + .font(.aiLabel) + + if let audio = synthesizedAudio { + Text("\(audio.count / 1000) KB audio") + .font(.aiCaption) + .foregroundStyle(.secondary) + } + } + + Spacer() + } + .padding() + .background( + RoundedRectangle(cornerRadius: AIRadius.lg) + .fill(Color.aiAccent.opacity(0.1)) + ) + } + } + + private var historySection: some View { + VStack(alignment: .leading, spacing: AISpacing.sm) { + HStack { + Text("History") + .font(.aiHeadingSmall) + + Spacer() + + Button("Clear") { history.removeAll() } + .font(.aiCaption) + .foregroundStyle(.secondary) + } + + ForEach(history) { entry in + HistoryCard(entry: entry) { + if let audio = entry.audioData { + try? audioService.playAudio(audio) + } + } + } + } + } + + private var synthesizeButton: some View { + VStack(spacing: AISpacing.sm) { + HStack(spacing: AISpacing.md) { + SettingBadge(icon: "speedometer", value: String(format: "%.1fx", rate)) + SettingBadge(icon: "waveform.path", value: String(format: "%.1f", pitch)) + SettingBadge(icon: "speaker.wave.2", value: String(format: "%.0f%%", volume * 100)) + } + + Button(action: synthesize) { + HStack { + if isSynthesizing { + ProgressView().tint(.white) + } else { + Image(systemName: "speaker.wave.2.fill") + } + Text(isSynthesizing ? "Synthesizing..." : "Speak") + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.aiPrimary) + .disabled(inputText.isEmpty || isSynthesizing) + } + .padding() + .background(Rectangle().fill(.ultraThinMaterial).ignoresSafeArea()) + } + + private var ttsSettingsSheet: some View { + NavigationStack { + Form { + Section("Voice Settings") { + VStack(alignment: .leading) { + HStack { + Text("Speed") + Spacer() + Text(String(format: "%.1fx", rate)).foregroundStyle(.secondary) + } + Slider(value: $rate, in: 0.5...2.0, step: 0.1).tint(Color.aiPrimary) + } + + VStack(alignment: .leading) { + HStack { + Text("Pitch") + Spacer() + Text(String(format: "%.1f", pitch)).foregroundStyle(.secondary) + } + Slider(value: $pitch, in: 0.5...1.5, step: 0.1).tint(Color.aiPrimary) + } + + VStack(alignment: .leading) { + HStack { + Text("Volume") + Spacer() + Text(String(format: "%.0f%%", volume * 100)).foregroundStyle(.secondary) + } + Slider(value: $volume, in: 0.1...1.0, step: 0.1).tint(Color.aiPrimary) + } + } + + Section("Reset") { + Button("Reset to Defaults") { + rate = 1.0 + pitch = 1.0 + volume = 1.0 + } + } + } + .navigationTitle("Voice Settings") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { showSettings = false } + } + } + } + .presentationDetents([.medium]) + } + + // ========================================================================= + // MARK: - Actions + // ========================================================================= + + /// Synthesizes speech from the input text. + /// + /// ## RunAnywhere SDK Usage + /// RunAnywhere.synthesize() converts text to audio using Piper TTS. + // ------------------------------------------------------------------------- + private func synthesize() { + let text = inputText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return } + + isSynthesizing = true + + Task { + do { + let options = TTSOptions( + rate: Float(rate), + pitch: Float(pitch), + volume: Float(volume) + ) + + print("🔊 Synthesizing: \"\(text.prefix(50))...\"") + + let startTime = Date() + + // --------------------------------------------------------- + // Synthesize with RunAnywhere SDK + // --------------------------------------------------------- + let output = try await RunAnywhere.synthesize(text, options: options) + + let duration = Date().timeIntervalSince(startTime) + + synthesizedAudio = output.audioData + + let entry = SynthesisEntry( + text: text, + audioData: output.audioData, + duration: output.duration, + synthesisTime: duration + ) + history.insert(entry, at: 0) + + if history.count > 10 { + history.removeLast() + } + + print("✅ Synthesized \(output.audioData.count) bytes in \(String(format: "%.2f", duration))s") + + try audioService.playAudio(output.audioData) + + } catch { + print("❌ Synthesis failed: \(error)") + errorMessage = error.localizedDescription + } + + isSynthesizing = false + } + } + + private func togglePlayback() { + if audioService.state == .playing { + audioService.stopPlayback() + } else if let audio = synthesizedAudio { + try? audioService.playAudio(audio) + } + } +} + +// ============================================================================= +// MARK: - Supporting Types +// ============================================================================= + +struct SynthesisEntry: Identifiable { + let id = UUID() + let text: String + let audioData: Data? + let duration: TimeInterval + let synthesisTime: TimeInterval + let timestamp = Date() +} + +struct SettingBadge: View { + let icon: String + let value: String + + var body: some View { + HStack(spacing: 4) { + Image(systemName: icon).font(.system(size: 10)) + Text(value).font(.aiCaption) + } + .foregroundStyle(.secondary) + .padding(.horizontal, AISpacing.sm) + .padding(.vertical, 4) + .background(Capsule().fill(Color.secondary.opacity(0.1))) + } +} + +struct HistoryCard: View { + let entry: SynthesisEntry + let onPlay: () -> Void + + @Environment(\.colorScheme) var colorScheme + + var body: some View { + HStack(spacing: AISpacing.md) { + Button(action: onPlay) { + Image(systemName: "play.circle.fill") + .font(.system(size: 32)) + .foregroundStyle(Color.aiAccent) + } + .disabled(entry.audioData == nil) + + VStack(alignment: .leading, spacing: 2) { + Text(entry.text) + .font(.aiBodySmall) + .lineLimit(2) + + HStack(spacing: AISpacing.sm) { + Text(String(format: "%.1fs", entry.duration)) + Text("•") + Text(formattedTime) + } + .font(.aiCaption) + .foregroundStyle(.secondary) + } + + Spacer() + } + .padding(AISpacing.sm) + .background( + RoundedRectangle(cornerRadius: AIRadius.md) + .fill(colorScheme == .dark ? Color(white: 0.12) : Color(white: 0.97)) + ) + } + + private var formattedTime: String { + let formatter = DateFormatter() + formatter.timeStyle = .short + return formatter.string(from: entry.timestamp) + } +} + +// ============================================================================= +// MARK: - Preview +// ============================================================================= +#Preview { + TextToSpeechView() + .environmentObject(ModelService()) +} diff --git a/Playground/swift-starter-app/LocalAIPlayground/Views/VoicePipelineView.swift b/Playground/swift-starter-app/LocalAIPlayground/Views/VoicePipelineView.swift new file mode 100644 index 000000000..2ae5ad03b --- /dev/null +++ b/Playground/swift-starter-app/LocalAIPlayground/Views/VoicePipelineView.swift @@ -0,0 +1,888 @@ +// +// VoicePipelineView.swift +// LocalAIPlayground +// +// ============================================================================= +// VOICE PIPELINE VIEW - FULL VOICE AGENT DEMO +// ============================================================================= +// +// This view demonstrates the complete voice agent pipeline combining: +// - STT (Speech-to-Text / Whisper) +// - LLM (Language Model) +// - TTS (Text-to-Speech / Piper) +// +// THE VOICE AGENT FLOW: +// ┌──────────────────────────────────────────────────────────────────────┐ +// │ │ +// │ 🎤 User speaks → 📝 Whisper transcribes → 🤖 LLM responds │ +// │ │ +// │ ↓ │ +// │ │ +// │ 🔊 Piper speaks response ← 📝 LLM generates text │ +// │ │ +// └──────────────────────────────────────────────────────────────────────┘ +// +// ============================================================================= + +import SwiftUI +import RunAnywhere +import AVFoundation + +// ============================================================================= +// MARK: - Voice Pipeline State +// ============================================================================= +enum PipelineState: Equatable { + case idle + case listening + case transcribing + case thinking + case synthesizing + case speaking + case error(message: String) + + var description: String { + switch self { + case .idle: return "Tap to start" + case .listening: return "Listening..." + case .transcribing: return "Transcribing..." + case .thinking: return "Thinking..." + case .synthesizing: return "Preparing response..." + case .speaking: return "Speaking..." + case .error(let message): return "Error: \(message)" + } + } + + var color: Color { + switch self { + case .idle: return .secondary + case .listening: return .aiPrimary + case .transcribing: return .aiSecondary + case .thinking: return .purple + case .synthesizing, .speaking: return .aiAccent + case .error: return .aiError + } + } +} + +// ============================================================================= +// MARK: - Conversation Turn +// ============================================================================= +struct ConversationTurn: Identifiable { + let id = UUID() + let userText: String + let assistantText: String + let timestamp: Date + var audioData: Data? +} + +// ============================================================================= +// MARK: - Voice Pipeline View +// ============================================================================= +struct VoicePipelineView: View { + @EnvironmentObject var modelService: ModelService + @StateObject private var audioService = AudioService.shared + + @State private var pipelineState: PipelineState = .idle + @State private var conversation: [ConversationTurn] = [] + @State private var currentTranscript = "" + @State private var currentResponse = "" + @State private var showPermissionAlert = false + + // VAD (Voice Activity Detection) state + @State private var vadEnabled = true + @State private var isSpeechDetected = false + @State private var silenceStartTime: Date? + @State private var vadTimer: Timer? + + // VAD thresholds + private let speechThreshold: Float = 0.02 // Level to detect speech start + private let silenceThreshold: Float = 0.01 // Level to detect silence + private let silenceDuration: TimeInterval = 1.5 // Seconds of silence before auto-stop + private let minRecordingDuration: TimeInterval = 0.5 // Minimum recording before VAD kicks in + + @Environment(\.dismiss) private var dismiss + @Environment(\.colorScheme) private var colorScheme + + private var allModelsReady: Bool { + modelService.isLLMLoaded && modelService.isSTTLoaded && modelService.isTTSLoaded + } + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + if !allModelsReady { + modelLoadingView + } else if !audioService.hasPermission { + permissionView + } else { + pipelineInterface + } + } + .navigationTitle("Voice Assistant") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Close") { + stopPipeline() + dismiss() + } + } + + ToolbarItem(placement: .topBarTrailing) { + HStack(spacing: 12) { + // VAD toggle + Button { + vadEnabled.toggle() + } label: { + Image(systemName: vadEnabled ? "waveform.badge.mic" : "waveform.badge.minus") + .foregroundStyle(vadEnabled ? Color.aiSuccess : .secondary) + } + .help(vadEnabled ? "Auto-detect speech (ON)" : "Auto-detect speech (OFF)") + + if !conversation.isEmpty { + Button { conversation.removeAll() } label: { + Image(systemName: "trash") + } + } + } + } + } + .alert("Microphone Access Required", isPresented: $showPermissionAlert) { + Button("Open Settings") { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Voice assistant requires microphone access.") + } + } + } + + // ------------------------------------------------------------------------- + // MARK: - Model Loading View + // ------------------------------------------------------------------------- + + private var modelLoadingView: some View { + VStack(spacing: AISpacing.xl) { + Spacer() + + VStack(spacing: AISpacing.md) { + Image(systemName: "person.wave.2.fill") + .font(.system(size: 48)) + .foregroundStyle(.purple) + + Text("Voice Assistant") + .font(.aiDisplay) + + Text("The voice pipeline requires three AI models to be loaded.") + .font(.aiBody) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + + // Model status list + VStack(spacing: AISpacing.md) { + Text("Required Models") + .font(.aiHeadingSmall) + + VStack(spacing: AISpacing.sm) { + PipelineModelRow( + name: "LLM - LFM2 350M", + isLoaded: modelService.isLLMLoaded, + isLoading: modelService.isLLMLoading || modelService.isLLMDownloading, + progress: modelService.llmDownloadProgress + ) + + PipelineModelRow( + name: "STT - Whisper Tiny", + isLoaded: modelService.isSTTLoaded, + isLoading: modelService.isSTTLoading || modelService.isSTTDownloading, + progress: modelService.sttDownloadProgress + ) + + PipelineModelRow( + name: "TTS - Piper Lessac", + isLoaded: modelService.isTTSLoaded, + isLoading: modelService.isTTSLoading || modelService.isTTSDownloading, + progress: modelService.ttsDownloadProgress + ) + } + + if !modelService.isAnyDownloading && !modelService.isAnyLoading { + Button { + Task { await modelService.downloadAndLoadAllModels() } + } label: { + HStack { + Image(systemName: "arrow.down.circle.fill") + Text("Load All Models") + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.aiPrimary) + } + } + .padding() + .aiCardStyle() + .padding(.horizontal) + + Text("First-time download may take a few minutes.") + .font(.aiCaption) + .foregroundStyle(.secondary) + + Spacer() + } + } + + // ------------------------------------------------------------------------- + // MARK: - Permission View + // ------------------------------------------------------------------------- + + private var permissionView: some View { + VStack(spacing: AISpacing.xl) { + Spacer() + + Image(systemName: "mic.slash.fill") + .font(.system(size: 64)) + .foregroundStyle(.secondary) + + Text("Microphone Required") + .font(.aiHeading) + + Text("The voice assistant needs microphone access to hear your voice.") + .font(.aiBody) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, AISpacing.xl) + + Button { + Task { + let granted = await audioService.requestPermission() + if !granted { showPermissionAlert = true } + } + } label: { + HStack { + Image(systemName: "mic.fill") + Text("Enable Microphone") + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.aiPrimary) + .padding(.horizontal, AISpacing.xl) + + Spacer() + } + } + + // ------------------------------------------------------------------------- + // MARK: - Pipeline Interface + // ------------------------------------------------------------------------- + + private var pipelineInterface: some View { + VStack(spacing: 0) { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: AISpacing.lg) { + if conversation.isEmpty { + emptyStateView + } else { + ForEach(conversation) { turn in + ConversationTurnView(turn: turn) { + if let audio = turn.audioData { + try? audioService.playAudio(audio) + } + } + .id(turn.id) + } + } + + if pipelineState != .idle { + currentActivityView + } + } + .padding() + } + .onChange(of: conversation.count) { _, _ in + if let lastTurn = conversation.last { + withAnimation { proxy.scrollTo(lastTurn.id, anchor: .bottom) } + } + } + } + + Divider() + pipelineControls + } + } + + private var emptyStateView: some View { + VStack(spacing: AISpacing.lg) { + Image(systemName: "person.wave.2") + .font(.system(size: 48)) + .foregroundStyle(.tertiary) + + Text("Voice Assistant Ready") + .font(.aiHeading) + + Text("Tap the microphone button and speak naturally. I'll listen, understand, and respond with my voice.") + .font(.aiBody) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + // VAD info + HStack(spacing: AISpacing.sm) { + Image(systemName: "waveform.badge.mic") + .foregroundStyle(Color.aiSuccess) + Text("Auto-detect: \(vadEnabled ? "ON" : "OFF")") + .font(.aiCaption) + Text("•") + .foregroundStyle(.tertiary) + Text(vadEnabled ? "Will auto-stop when you pause" : "Tap ✓ to confirm") + .font(.aiCaption) + .foregroundStyle(.secondary) + } + .padding(.horizontal, AISpacing.md) + .padding(.vertical, AISpacing.sm) + .background(Capsule().fill(Color.aiSuccess.opacity(0.1))) + + VStack(alignment: .leading, spacing: AISpacing.sm) { + PipelineStep(number: 1, title: "Speak", description: vadEnabled ? "Auto-detects when you start & stop" : "I listen to your voice") + PipelineStep(number: 2, title: "Transcribe", description: "Whisper converts speech to text") + PipelineStep(number: 3, title: "Think", description: "LLM generates a response") + PipelineStep(number: 4, title: "Speak", description: "Piper reads the response aloud") + } + .padding() + .background( + RoundedRectangle(cornerRadius: AIRadius.lg) + .fill(Color.secondary.opacity(0.1)) + ) + } + .padding(.top, AISpacing.xl) + } + + private var currentActivityView: some View { + VStack(spacing: AISpacing.md) { + HStack(spacing: AISpacing.sm) { + if pipelineState == .listening { + // Show VAD-aware audio visualization + VStack(spacing: 4) { + AudioLevelBars( + level: audioService.inputLevel, + activeColor: isSpeechDetected ? Color.aiSuccess : pipelineState.color + ) + + // VAD status indicator + if vadEnabled { + HStack(spacing: 4) { + Circle() + .fill(isSpeechDetected ? Color.aiSuccess : Color.secondary) + .frame(width: 6, height: 6) + Text(isSpeechDetected ? "Speech" : "Waiting") + .font(.system(size: 10)) + .foregroundStyle(.secondary) + } + } + } + } else { + ProgressView().tint(pipelineState.color) + } + + Text(pipelineState.description) + .font(.aiLabel) + .foregroundStyle(pipelineState.color) + } + + if !currentTranscript.isEmpty && pipelineState != .listening { + VStack(alignment: .leading, spacing: AISpacing.xs) { + Text("You said:") + .font(.aiCaption) + .foregroundStyle(.secondary) + Text(currentTranscript) + .font(.aiBody) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background(RoundedRectangle(cornerRadius: AIRadius.md).fill(Color.aiPrimary.opacity(0.1))) + } + + if !currentResponse.isEmpty { + VStack(alignment: .leading, spacing: AISpacing.xs) { + Text("Response:") + .font(.aiCaption) + .foregroundStyle(.secondary) + Text(currentResponse) + .font(.aiBody) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background(RoundedRectangle(cornerRadius: AIRadius.md).fill(Color.purple.opacity(0.1))) + } + } + .padding() + .background( + RoundedRectangle(cornerRadius: AIRadius.lg) + .stroke(pipelineState.color.opacity(0.3), lineWidth: 2) + ) + } + + private var pipelineControls: some View { + VStack(spacing: AISpacing.md) { + HStack(spacing: AISpacing.xl) { + if pipelineState != .idle { + Button(action: stopPipeline) { + Image(systemName: "xmark") + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 50, height: 50) + .background(Circle().fill(Color.secondary)) + } + } + + VoicePipelineButton( + state: pipelineState, + audioLevel: audioService.inputLevel, + onTap: handleMainButtonTap + ) + + if pipelineState == .listening { + Button(action: confirmAndProcess) { + Image(systemName: "checkmark") + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 50, height: 50) + .background(Circle().fill(Color.aiSuccess)) + } + } + } + + Text(instructionText) + .font(.aiCaption) + .foregroundStyle(.secondary) + } + .padding() + .background(Rectangle().fill(.ultraThinMaterial).ignoresSafeArea()) + } + + private var instructionText: String { + switch pipelineState { + case .idle: return "Tap the microphone to start" + case .listening: + if vadEnabled { + if isSpeechDetected { + return silenceStartTime != nil ? "Silence detected... processing soon" : "Listening... will auto-stop when you pause" + } else { + return "Start speaking..." + } + } else { + return "Tap ✓ when done speaking, or ✕ to cancel" + } + case .transcribing: return "Converting your speech to text..." + case .thinking: return "Generating a response..." + case .synthesizing: return "Preparing to speak..." + case .speaking: return "Tap to stop playback" + case .error: return "Tap the microphone to try again" + } + } + + // ========================================================================= + // MARK: - Pipeline Actions + // ========================================================================= + + private func handleMainButtonTap() { + switch pipelineState { + case .idle, .error: + startListening() + case .listening: + confirmAndProcess() + case .speaking: + audioService.stopPlayback() + pipelineState = .idle + default: + break + } + } + + private func startListening() { + currentTranscript = "" + currentResponse = "" + isSpeechDetected = false + silenceStartTime = nil + + Task { + do { + pipelineState = .listening + try await audioService.startRecording() + + // Start VAD monitoring if enabled + if vadEnabled { + startVADMonitoring() + } + } catch { + pipelineState = .error(message: error.localizedDescription) + } + } + } + + // ========================================================================= + // MARK: - Voice Activity Detection (VAD) + // ========================================================================= + + /// Starts monitoring audio levels for automatic speech detection. + private func startVADMonitoring() { + // Check audio level every 100ms + vadTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in + guard pipelineState == .listening else { + stopVADMonitoring() + return + } + + let level = audioService.inputLevel + let recordingDuration = audioService.recordingDuration + + // Detect speech start + if !isSpeechDetected && level > speechThreshold { + isSpeechDetected = true + silenceStartTime = nil + print("🎤 VAD: Speech detected (level: \(String(format: "%.3f", level)))") + } + + // Only check for silence after minimum recording and speech was detected + if isSpeechDetected && recordingDuration > minRecordingDuration { + if level < silenceThreshold { + // Speech ended, start silence timer + if silenceStartTime == nil { + silenceStartTime = Date() + print("🎤 VAD: Silence started") + } else if let startTime = silenceStartTime { + let silenceDurationSoFar = Date().timeIntervalSince(startTime) + + // Auto-stop after silence duration + if silenceDurationSoFar >= silenceDuration { + print("🎤 VAD: Auto-stopping after \(String(format: "%.1f", silenceDurationSoFar))s silence") + stopVADMonitoring() + + // Trigger processing on main thread + DispatchQueue.main.async { + confirmAndProcess() + } + } + } + } else { + // Speech resumed, reset silence timer + if silenceStartTime != nil { + print("🎤 VAD: Speech resumed") + } + silenceStartTime = nil + } + } + } + } + + /// Stops VAD monitoring. + private func stopVADMonitoring() { + vadTimer?.invalidate() + vadTimer = nil + } + + /// Runs the full voice pipeline: STT → LLM → TTS + private func confirmAndProcess() { + Task { + do { + // Step 1: Stop recording & get audio + pipelineState = .transcribing + let audioData = try await audioService.stopRecording() + + guard audioData.count > 3200 else { + throw PipelineError.audioTooShort + } + + // Step 2: Transcribe with Whisper + let transcript = try await RunAnywhere.transcribe(audioData) + let cleanTranscript = transcript.trimmingCharacters(in: .whitespacesAndNewlines) + + guard !cleanTranscript.isEmpty else { + throw PipelineError.noSpeechDetected + } + + currentTranscript = cleanTranscript + print("📝 Transcript: \"\(cleanTranscript)\"") + + // Step 3: Generate response with LLM + pipelineState = .thinking + + let prompt = buildVoicePrompt(userMessage: cleanTranscript) + let options = LLMGenerationOptions(maxTokens: 100, temperature: 0.7) + + let streamResult = try await RunAnywhere.generateStream(prompt, options: options) + + var fullResponse = "" + for try await token in streamResult.stream { + fullResponse += token + currentResponse = fullResponse + } + + let cleanResponse = fullResponse.trimmingCharacters(in: .whitespacesAndNewlines) + currentResponse = cleanResponse + print("🤖 Response: \"\(cleanResponse)\"") + + guard !cleanResponse.isEmpty else { + throw PipelineError.emptyResponse + } + + // Step 4: Synthesize speech + pipelineState = .synthesizing + + let ttsOptions = TTSOptions(rate: 1.0, pitch: 1.0, volume: 1.0) + let speechOutput = try await RunAnywhere.synthesize(cleanResponse, options: ttsOptions) + + print("🔊 Synthesized \(speechOutput.audioData.count) bytes") + + // Store conversation turn + let turn = ConversationTurn( + userText: cleanTranscript, + assistantText: cleanResponse, + timestamp: Date(), + audioData: speechOutput.audioData + ) + conversation.append(turn) + + // Step 5: Play response + pipelineState = .speaking + try audioService.playAudio(speechOutput.audioData) + + try await Task.sleep(for: .seconds(speechOutput.duration + 0.5)) + + pipelineState = .idle + currentTranscript = "" + currentResponse = "" + + print("✅ Pipeline complete") + + } catch { + print("❌ Pipeline error: \(error)") + pipelineState = .error(message: error.localizedDescription) + + try? await Task.sleep(for: .seconds(3)) + pipelineState = .idle + } + } + } + + private func buildVoicePrompt(userMessage: String) -> String { + var prompt = """ + You are a helpful voice assistant. Give SHORT, conversational responses suitable for spoken dialogue. \ + Keep responses under 2-3 sentences. Be friendly and natural. + + """ + + for turn in conversation.suffix(4) { + prompt += "User: \(turn.userText)\n" + prompt += "Assistant: \(turn.assistantText)\n" + } + + prompt += "User: \(userMessage)\nAssistant:" + return prompt + } + + private func stopPipeline() { + stopVADMonitoring() + audioService.cancelRecording() + audioService.stopPlayback() + pipelineState = .idle + currentTranscript = "" + currentResponse = "" + isSpeechDetected = false + silenceStartTime = nil + } +} + +// ============================================================================= +// MARK: - Pipeline Errors +// ============================================================================= +enum PipelineError: LocalizedError { + case audioTooShort + case noSpeechDetected + case emptyResponse + + var errorDescription: String? { + switch self { + case .audioTooShort: return "Recording too short. Please speak for at least 1 second." + case .noSpeechDetected: return "No speech detected. Please try again." + case .emptyResponse: return "Could not generate a response. Please try again." + } + } +} + +// ============================================================================= +// MARK: - Supporting Views +// ============================================================================= + +struct PipelineModelRow: View { + let name: String + let isLoaded: Bool + let isLoading: Bool + let progress: Double + + var body: some View { + HStack(spacing: AISpacing.sm) { + Circle() + .fill(isLoaded ? Color.aiSuccess : (isLoading ? Color.aiWarning : Color.secondary)) + .frame(width: 10, height: 10) + + Text(name) + .font(.aiBodySmall) + + Spacer() + + if isLoading { + if progress > 0 { + Text("\(Int(progress * 100))%").font(.aiMono) + } else { + ProgressView().scaleEffect(0.6) + } + } else { + Text(isLoaded ? "Ready" : "Not loaded") + .font(.aiCaption) + .foregroundStyle(.secondary) + } + } + } +} + +struct PipelineStep: View { + let number: Int + let title: String + let description: String + + var body: some View { + HStack(spacing: AISpacing.md) { + ZStack { + Circle() + .fill(Color.purple.opacity(0.2)) + .frame(width: 32, height: 32) + Text("\(number)") + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(.purple) + } + + VStack(alignment: .leading, spacing: 2) { + Text(title).font(.aiLabel) + Text(description).font(.aiCaption).foregroundStyle(.secondary) + } + } + } +} + +struct ConversationTurnView: View { + let turn: ConversationTurn + let onReplay: () -> Void + + @Environment(\.colorScheme) var colorScheme + + var body: some View { + VStack(spacing: AISpacing.md) { + // User message + HStack { + Spacer() + VStack(alignment: .trailing, spacing: AISpacing.xs) { + Text("You").font(.aiCaption).foregroundStyle(.secondary) + Text(turn.userText) + .font(.aiBody) + .padding(AISpacing.md) + .background(RoundedRectangle(cornerRadius: 18).fill(Color.aiPrimary)) + .foregroundStyle(.white) + } + } + + // Assistant response + HStack { + VStack(alignment: .leading, spacing: AISpacing.xs) { + HStack { + Text("Assistant").font(.aiCaption).foregroundStyle(.secondary) + Spacer() + if turn.audioData != nil { + Button(action: onReplay) { + Image(systemName: "play.circle.fill").foregroundStyle(.purple) + } + } + } + Text(turn.assistantText) + .font(.aiBody) + .padding(AISpacing.md) + .background( + RoundedRectangle(cornerRadius: 18) + .fill(colorScheme == .dark ? Color(white: 0.2) : Color(white: 0.95)) + ) + } + Spacer() + } + } + } +} + +struct VoicePipelineButton: View { + let state: PipelineState + let audioLevel: Float + let onTap: () -> Void + + @State private var pulseScale: CGFloat = 1 + + var body: some View { + Button(action: onTap) { + ZStack { + if state == .listening { + Circle() + .stroke(state.color.opacity(0.3), lineWidth: 3) + .frame(width: 100, height: 100) + .scaleEffect(pulseScale) + } + + Circle() + .stroke(state.color.opacity(0.5), lineWidth: 4) + .frame(width: 80, height: 80) + + Circle() + .fill(state.color) + .frame( + width: 64 * (1 + CGFloat(audioLevel) * 0.2), + height: 64 * (1 + CGFloat(audioLevel) * 0.2) + ) + .animation(.easeInOut(duration: 0.1), value: audioLevel) + + Group { + switch state { + case .idle, .error: + Image(systemName: "mic.fill") + case .listening: + Image(systemName: "waveform") + case .transcribing, .thinking, .synthesizing: + ProgressView().tint(.white) + case .speaking: + Image(systemName: "stop.fill") + } + } + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(.white) + } + } + .disabled(state == .transcribing || state == .thinking || state == .synthesizing) + .onChange(of: state) { _, newState in + if newState == .listening { + withAnimation(.easeInOut(duration: 1).repeatForever(autoreverses: true)) { + pulseScale = 1.2 + } + } else { + pulseScale = 1 + } + } + } +} + +// ============================================================================= +// MARK: - Preview +// ============================================================================= +#Preview { + VoicePipelineView() + .environmentObject(ModelService()) +} diff --git a/Playground/swift-starter-app/LocalAIPlaygroundTests/LocalAIPlaygroundTests.swift b/Playground/swift-starter-app/LocalAIPlaygroundTests/LocalAIPlaygroundTests.swift new file mode 100644 index 000000000..5bda31445 --- /dev/null +++ b/Playground/swift-starter-app/LocalAIPlaygroundTests/LocalAIPlaygroundTests.swift @@ -0,0 +1,17 @@ +// +// LocalAIPlaygroundTests.swift +// LocalAIPlaygroundTests +// +// Created by Shubham Malhotra on 1/19/26. +// + +import Testing +@testable import LocalAIPlayground + +struct LocalAIPlaygroundTests { + + @Test func example() async throws { + // Write your test here and use APIs like `#expect(...)` to check expected conditions. + } + +} diff --git a/Playground/swift-starter-app/LocalAIPlaygroundUITests/LocalAIPlaygroundUITests.swift b/Playground/swift-starter-app/LocalAIPlaygroundUITests/LocalAIPlaygroundUITests.swift new file mode 100644 index 000000000..22dfdb156 --- /dev/null +++ b/Playground/swift-starter-app/LocalAIPlaygroundUITests/LocalAIPlaygroundUITests.swift @@ -0,0 +1,41 @@ +// +// LocalAIPlaygroundUITests.swift +// LocalAIPlaygroundUITests +// +// Created by Shubham Malhotra on 1/19/26. +// + +import XCTest + +final class LocalAIPlaygroundUITests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + + // In UI tests it is usually best to stop immediately when a failure occurs. + continueAfterFailure = false + + // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + @MainActor + func testExample() throws { + // UI tests must launch the application that they test. + let app = XCUIApplication() + app.launch() + + // Use XCTAssert and related functions to verify your tests produce the correct results. + } + + @MainActor + func testLaunchPerformance() throws { + // This measures how long it takes to launch your application. + measure(metrics: [XCTApplicationLaunchMetric()]) { + XCUIApplication().launch() + } + } +} diff --git a/Playground/swift-starter-app/LocalAIPlaygroundUITests/LocalAIPlaygroundUITestsLaunchTests.swift b/Playground/swift-starter-app/LocalAIPlaygroundUITests/LocalAIPlaygroundUITestsLaunchTests.swift new file mode 100644 index 000000000..52d68594c --- /dev/null +++ b/Playground/swift-starter-app/LocalAIPlaygroundUITests/LocalAIPlaygroundUITestsLaunchTests.swift @@ -0,0 +1,33 @@ +// +// LocalAIPlaygroundUITestsLaunchTests.swift +// LocalAIPlaygroundUITests +// +// Created by Shubham Malhotra on 1/19/26. +// + +import XCTest + +final class LocalAIPlaygroundUITestsLaunchTests: XCTestCase { + + override class var runsForEachTargetApplicationUIConfiguration: Bool { + true + } + + override func setUpWithError() throws { + continueAfterFailure = false + } + + @MainActor + func testLaunch() throws { + let app = XCUIApplication() + app.launch() + + // Insert steps here to perform after app launch but before taking a screenshot, + // such as logging into a test account or navigating somewhere in the app + + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "Launch Screen" + attachment.lifetime = .keepAlways + add(attachment) + } +} diff --git a/Playground/swift-starter-app/blog/images/chat-streaming.png b/Playground/swift-starter-app/blog/images/chat-streaming.png new file mode 100644 index 000000000..dfe17b936 Binary files /dev/null and b/Playground/swift-starter-app/blog/images/chat-streaming.png differ diff --git a/Playground/swift-starter-app/blog/images/initializing-sdk.png b/Playground/swift-starter-app/blog/images/initializing-sdk.png new file mode 100644 index 000000000..cb432f84c Binary files /dev/null and b/Playground/swift-starter-app/blog/images/initializing-sdk.png differ diff --git a/Playground/swift-starter-app/blog/images/model-download-progress.png b/Playground/swift-starter-app/blog/images/model-download-progress.png new file mode 100644 index 000000000..cb432f84c Binary files /dev/null and b/Playground/swift-starter-app/blog/images/model-download-progress.png differ diff --git a/Playground/swift-starter-app/blog/images/package-dependencies.png b/Playground/swift-starter-app/blog/images/package-dependencies.png new file mode 100644 index 000000000..4ada5d44d Binary files /dev/null and b/Playground/swift-starter-app/blog/images/package-dependencies.png differ diff --git a/Playground/swift-starter-app/blog/images/speech-to-text.png b/Playground/swift-starter-app/blog/images/speech-to-text.png new file mode 100644 index 000000000..7704ddfe4 Binary files /dev/null and b/Playground/swift-starter-app/blog/images/speech-to-text.png differ diff --git a/Playground/swift-starter-app/blog/images/spm-add-package.png b/Playground/swift-starter-app/blog/images/spm-add-package.png new file mode 100644 index 000000000..ec18af30a Binary files /dev/null and b/Playground/swift-starter-app/blog/images/spm-add-package.png differ diff --git a/Playground/swift-starter-app/blog/images/text-to-speech.png b/Playground/swift-starter-app/blog/images/text-to-speech.png new file mode 100644 index 000000000..9333f01ce Binary files /dev/null and b/Playground/swift-starter-app/blog/images/text-to-speech.png differ diff --git a/Playground/swift-starter-app/blog/images/voice-pipeline.png b/Playground/swift-starter-app/blog/images/voice-pipeline.png new file mode 100644 index 000000000..fdb18e479 Binary files /dev/null and b/Playground/swift-starter-app/blog/images/voice-pipeline.png differ diff --git a/Playground/swift-starter-app/blog/images/xcode-new-project.png b/Playground/swift-starter-app/blog/images/xcode-new-project.png new file mode 100644 index 000000000..c6d7aa7ff Binary files /dev/null and b/Playground/swift-starter-app/blog/images/xcode-new-project.png differ diff --git a/Playground/swift-starter-app/blog/runanywhere-swift-tutorial.md b/Playground/swift-starter-app/blog/runanywhere-swift-tutorial.md new file mode 100644 index 000000000..2086532f0 --- /dev/null +++ b/Playground/swift-starter-app/blog/runanywhere-swift-tutorial.md @@ -0,0 +1,559 @@ +# Building Privacy-First AI Apps with RunAnywhere Swift SDK + +**Run LLMs, Speech Recognition, and Voice Synthesis Entirely On-Device** + +--- + +The promise of on-device AI is compelling: instant responses, complete privacy, and no API costs. But implementing it has traditionally been challenging—managing model formats, optimizing for mobile hardware, and handling audio processing correctly. + +[RunAnywhere](https://runanywhere.ai) changes this. It's a production-ready SDK that brings powerful AI capabilities to iOS with a simple, unified API. In this tutorial, we'll build a complete AI app that demonstrates: + +- **Chat with an LLM** — Streaming text generation with LiquidAI LFM2 +- **Speech-to-Text** — Real-time transcription with Whisper +- **Text-to-Speech** — Natural voice synthesis with Piper +- **Voice Pipeline** — A full voice assistant with automatic Voice Activity Detection (VAD) + +All processing happens on-device. No data ever leaves the phone. + +## Why On-Device AI? + +| Aspect | Cloud AI | On-Device AI | +|--------|----------|--------------| +| **Privacy** | Data sent to servers | Data stays on device | +| **Latency** | Network round-trip | Instant local processing | +| **Offline** | Requires internet | Works anywhere | +| **Cost** | Per-request billing | One-time download | + +For applications handling sensitive data—health, finance, personal conversations—on-device processing isn't just a feature, it's a requirement. + +## Prerequisites + +- **Xcode 15.0+** with Swift 5.9 +- **iOS 17.0+** device (physical device recommended) +- Basic familiarity with SwiftUI + +## Project Setup + +### 1. Create a New Xcode Project + +Create a new iOS App with SwiftUI interface. Use the following settings: + +- **Product Name:** LocalAIPlayground (or your preferred name) +- **Interface:** SwiftUI +- **Language:** Swift +- **Storage:** None + +![Xcode project options dialog](images/xcode-new-project.png) + +### 2. Add the RunAnywhere SDK + +Add via Swift Package Manager: + +1. **File → Add Package Dependencies...** +2. Enter: `https://github.com/RunanywhereAI/runanywhere-sdks` +3. For **Dependency Rule**, select **Commit** and enter: + ``` + 8bc88aa7810a3df3b72afeff2315b1a1d5b9e6f0 + ``` + + > **Important:** Using a specific commit hash ensures reproducible builds. This pins your project to an exact SDK version, preventing unexpected breaking changes when the SDK updates. + +4. Add these products: + - `RunAnywhere` — Core SDK + - `RunAnywhereLlamaCPP` — LLM backend + - `RunAnywhereONNX` — STT/TTS/VAD backend + +![Swift Package Manager adding RunAnywhere SDK](images/spm-add-package.png) + +After adding the package, verify the dependency is configured correctly in your project's **Package Dependencies** tab: + +![Package Dependencies showing commit-based dependency rule](images/package-dependencies.png) + +The dependency should show: +- **Name:** runanywhere-sdks +- **Location:** `https://github.com/RunanywhereAI/runanywhere-sdks` +- **Dependency Rule:** Commit → `8bc88aa7810a3df3b72afeff2315b1a1d5b9e6f0` + +### 3. Configure Info.plist + +Add required permissions: + +```xml +NSMicrophoneUsageDescription +This app needs microphone access to transcribe your speech using on-device AI. + +CFBundleIdentifier +$(PRODUCT_BUNDLE_IDENTIFIER) + +UIBackgroundModes + + audio + +``` + +## SDK Initialization + +The SDK requires a specific initialization order: + +```swift +import SwiftUI +import RunAnywhere +import LlamaCPPRuntime +import ONNXRuntime + +@main +struct LocalAIPlaygroundApp: App { + @StateObject private var modelService = ModelService() + @State private var isSDKInitialized = false + + var body: some Scene { + WindowGroup { + Group { + if isSDKInitialized { + ContentView() + .environmentObject(modelService) + } else { + ProgressView("Initializing AI...") + } + } + .task { await initializeSDK() } + } + } + + @MainActor + private func initializeSDK() async { + do { + // Step 1: Initialize core SDK + try RunAnywhere.initialize(environment: .development) + + // Step 2: Register backends BEFORE models + LlamaCPP.register() // For LLM + ONNX.register() // For STT, TTS, VAD + + // Step 3: Register models with URLs + ModelService.registerDefaultModels() + + print("✅ RunAnywhere SDK v\(RunAnywhere.version) initialized") + isSDKInitialized = true + + // Step 4: Check what's already loaded + await modelService.refreshLoadedStates() + } catch { + print("❌ SDK init failed: \(error)") + isSDKInitialized = true // Show UI anyway + } + } +} +``` + +![App initializing SDK on launch](images/initializing-sdk.png) + +### Architecture Overview + +``` +┌─────────────────────────────────────────────────────┐ +│ RunAnywhere Core │ +│ (Unified API, Model Management) │ +├───────────────────────┬─────────────────────────────┤ +│ LlamaCPP Backend │ ONNX Backend │ +│ ───────────────── │ ───────────────── │ +│ • Text Generation │ • Speech-to-Text │ +│ • Chat Completion │ • Text-to-Speech │ +│ • Streaming │ • Voice Activity (VAD) │ +└───────────────────────┴─────────────────────────────┘ +``` + +## Model Registration & Loading + +Models must be **registered** with URLs before they can be downloaded and loaded: + +```swift +// Register an LLM model +RunAnywhere.registerModel( + id: "lfm2-350m-q4_k_m", + name: "LiquidAI LFM2 350M", + url: URL(string: "https://huggingface.co/LiquidAI/LFM2-350M-GGUF/resolve/main/LFM2-350M-Q4_K_M.gguf")!, + framework: .llamaCpp, + memoryRequirement: 250_000_000 +) + +// Register STT model (Whisper) +RunAnywhere.registerModel( + id: "sherpa-onnx-whisper-tiny.en", + name: "Whisper Tiny English", + url: URL(string: "https://github.com/RunanywhereAI/sherpa-onnx/releases/download/runanywhere-models-v1/sherpa-onnx-whisper-tiny.en.tar.gz")!, + framework: .onnx, + modality: .speechRecognition, + artifactType: .archive(.tarGz, structure: .nestedDirectory), + memoryRequirement: 75_000_000 +) + +// Register TTS voice (Piper) +RunAnywhere.registerModel( + id: "vits-piper-en_US-lessac-medium", + name: "Piper US English", + url: URL(string: "https://github.com/RunanywhereAI/sherpa-onnx/releases/download/runanywhere-models-v1/vits-piper-en_US-lessac-medium.tar.gz")!, + framework: .onnx, + modality: .speechSynthesis, + artifactType: .archive(.tarGz, structure: .nestedDirectory), + memoryRequirement: 65_000_000 +) +``` + +### Downloading & Loading Models + +```swift +// Download with progress tracking +let progressStream = try await RunAnywhere.downloadModel("lfm2-350m-q4_k_m") +for await progress in progressStream { + print("Download: \(Int(progress.overallProgress * 100))%") + if progress.stage == .completed { break } +} + +// Load into memory +try await RunAnywhere.loadModel("lfm2-350m-q4_k_m") + +// Check if loaded (property, not function) +let isLoaded = await RunAnywhere.isModelLoaded +``` + +![Model download progress showing 77% complete](images/model-download-progress.png) + +## Feature 1: Chat with LLM + +### Streaming Text Generation + +```swift +func generateResponse(to prompt: String) async throws -> String { + let options = LLMGenerationOptions( + maxTokens: 256, + temperature: 0.7 + ) + + let result = try await RunAnywhere.generateStream(prompt, options: options) + + var fullResponse = "" + for try await token in result.stream { + fullResponse += token + // Update UI with each token + await MainActor.run { + self.responseText = fullResponse + } + } + + // Get metrics + let metrics = try await result.result.value + print("Speed: \(metrics.tokensPerSecond) tok/s") + + return fullResponse +} +``` + +![Chat interface with streaming response](images/chat-streaming.png) + +## Feature 2: Speech-to-Text + +### Critical: Audio Format Requirements + +Whisper requires **16kHz, mono, 16-bit PCM**. iOS microphones record at 48kHz, so you MUST resample: + +```swift +// Use AVAudioConverter for proper resampling +let inputFormat = inputNode.outputFormat(forBus: 0) // 48kHz +let targetFormat = AVAudioFormat( + commonFormat: .pcmFormatInt16, + sampleRate: 16000, + channels: 1, + interleaved: true +)! + +let converter = AVAudioConverter(from: inputFormat, to: targetFormat)! + +// In your audio tap callback: +inputNode.installTap(onBus: 0, bufferSize: 4096, format: inputFormat) { buffer, _ in + // Convert to 16kHz Int16 + let outputBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: ...)! + converter.convert(to: outputBuffer, error: nil) { _, outStatus in + outStatus.pointee = .haveData + return buffer + } + // Accumulate converted buffers... +} +``` + +### Transcribing Audio + +```swift +// Load STT model +try await RunAnywhere.loadSTTModel(id: "sherpa-onnx-whisper-tiny.en") + +// Transcribe audio data (must be 16kHz Int16 PCM) +let text = try await RunAnywhere.transcribe(audioData) +print("Transcription: \(text)") +``` + +![Speech-to-text recording with waveform](images/speech-to-text.png) + +## Feature 3: Text-to-Speech + +### Important: Piper Output Format + +Piper outputs **Float32 PCM at 22kHz**. You must convert to WAV for `AVAudioPlayer`: + +```swift +func playTTSAudio(_ data: Data) throws { + // Check if already WAV + let isWAV = data.prefix(4) == Data("RIFF".utf8) + + let audioData: Data + if isWAV { + audioData = data + } else { + // Convert Float32 PCM to Int16 WAV + audioData = convertFloat32ToWAV(data, sampleRate: 22050) + } + + // Write to temp file (more reliable) + let tempURL = FileManager.default.temporaryDirectory + .appendingPathComponent("tts_\(UUID()).wav") + try audioData.write(to: tempURL) + + // Play + let player = try AVAudioPlayer(contentsOf: tempURL) + player.play() +} + +func convertFloat32ToWAV(_ floatData: Data, sampleRate: Int) -> Data { + // Convert Float32 samples to Int16 + let sampleCount = floatData.count / 4 + var int16Data = Data() + + floatData.withUnsafeBytes { buffer in + let floats = buffer.bindMemory(to: Float.self) + for i in 0.. speechThreshold { + isSpeechDetected = true + silenceStartTime = nil + print("🎤 Speech detected") + } + + // Detect speech end + if isSpeechDetected { + if level < silenceThreshold { + if silenceStartTime == nil { + silenceStartTime = Date() + } else if Date().timeIntervalSince(silenceStartTime!) >= silenceDuration { + // Auto-stop and process + print("🎤 Auto-stopping after silence") + processRecording() + } + } else { + silenceStartTime = nil // Speech resumed + } + } + } +} +``` + +### Complete Voice Pipeline Flow + +```swift +func runVoicePipeline() async throws { + // 1. Start recording with VAD + try await audioService.startRecording() + startVADMonitoring() + + // ... VAD auto-stops when user finishes speaking ... + + // 2. Get audio and transcribe + let audioData = try await audioService.stopRecording() + let userText = try await RunAnywhere.transcribe(audioData) + + // 3. Generate LLM response + let prompt = """ + You are a helpful voice assistant. Keep responses SHORT (2-3 sentences). + + User: \(userText) + Assistant: + """ + + var response = "" + let stream = try await RunAnywhere.generateStream(prompt, options: .init(maxTokens: 100)) + for try await token in stream.stream { + response += token + } + + // 4. Speak the response + let speech = try await RunAnywhere.synthesize(response, options: TTSOptions()) + try audioService.playAudio(speech.audioData) +} +``` + +![Voice assistant pipeline in action](images/voice-pipeline.png) + +## Models Reference + +| Type | Model ID | Size | Notes | +|------|----------|------|-------| +| **LLM** | `lfm2-350m-q4_k_m` | ~250MB | LiquidAI, fast, efficient | +| **STT** | `sherpa-onnx-whisper-tiny.en` | ~75MB | English, real-time | +| **TTS** | `vits-piper-en_US-lessac-medium` | ~65MB | Natural US English | + +## Best Practices + +### 1. Preload Models During Onboarding + +```swift +// Download all models sequentially (avoids SDK concurrency issues) +await modelService.downloadAndLoadLLM() +await modelService.downloadAndLoadSTT() +await modelService.downloadAndLoadTTS() +``` + +### 2. Handle Memory Pressure + +```swift +// Unload when not needed (no ID parameter - unloads current model) +try await RunAnywhere.unloadModel() +try await RunAnywhere.unloadSTTModel() +try await RunAnywhere.unloadTTSVoice() +``` + +### 3. Audio Format Gotchas + +| Component | Sample Rate | Format | Channels | +|-----------|-------------|--------|----------| +| iOS Mic | 48,000 Hz | Float32 | 1-2 | +| Whisper STT | 16,000 Hz | Int16 | 1 | +| Piper TTS Output | 22,050 Hz | Float32 | 1 | +| AVAudioPlayer | Any | WAV/Int16 | 1-2 | + +**Always resample and convert formats!** + +### 4. Check Model State Before Operations + +Always verify model state before attempting operations: + +```swift +var isVoiceAgentReady: Bool { + isLLMLoaded && isSTTLoaded && isTTSLoaded +} + +// In your view +if modelService.isVoiceAgentReady { + // Safe to run voice pipeline +} +``` + +### 5. Prevent Concurrent Downloads + +Guard against multiple simultaneous downloads of the same model: + +```swift +func downloadAndLoadLLM() async { + guard !isLLMDownloading && !isLLMLoading else { return } + // ... proceed with download +} +``` + +### 6. Try-Load-Then-Download Pattern + +Provide a smoother UX by trying to load from cache first: + +```swift +// Try loading from cache first +do { + try await RunAnywhere.loadModel(modelId) + // Success - model was cached +} catch { + // Not cached - download then load + let progressStream = try await RunAnywhere.downloadModel(modelId) + // ... track progress ... + try await RunAnywhere.loadModel(modelId) +} +``` + +## Complete Source Code + +The full source code is available on GitHub: + +📦 **[LocalAIPlayground](https://github.com/your-username/LocalAIPlayground)** + +Includes: +- Complete SwiftUI app with all features +- Proper audio handling with resampling +- VAD implementation for hands-free operation +- Reusable components and design system +- Detailed code comments + +## Conclusion + +RunAnywhere makes on-device AI accessible. With proper initialization, audio handling, and the right model formats, you can build: + +- ✅ Private, offline chat with LLMs +- ✅ Real-time speech recognition +- ✅ Natural voice synthesis +- ✅ Complete voice assistants with VAD + +All running locally on iOS, respecting user privacy. + +--- + +## Resources + +- [RunAnywhere Documentation](https://docs.runanywhere.ai) +- [SDK Repository](https://github.com/RunanywhereAI/runanywhere-sdks) +- [Swift Starter Example](https://github.com/RunanywhereAI/swift-starter-example) + +--- + +*Questions? Open an issue on GitHub or reach out on [Twitter/X](https://twitter.com/runanywhere_ai).* diff --git a/Playground/swift-starter-app/docs/SDK-API-CORRECTIONS.md b/Playground/swift-starter-app/docs/SDK-API-CORRECTIONS.md new file mode 100644 index 000000000..17052fc1d --- /dev/null +++ b/Playground/swift-starter-app/docs/SDK-API-CORRECTIONS.md @@ -0,0 +1,291 @@ +# RunAnywhere SDK API Corrections + +**SDK Version:** 0.16.0-test.39 +**Date:** January 2026 +**Based on:** LocalAI Playground implementation + +--- + +## Overview + +This document catalogs discrepancies found between the RunAnywhere SDK documentation/examples and the actual API behavior as observed during implementation of the LocalAI Playground app. + +--- + +## 1. Model State Checking + +### `isModelLoaded` + +**Documentation/examples suggest:** +```swift +let isLoaded = await RunAnywhere.isModelLoaded(id: "model-id") +``` + +**Actual API:** +```swift +let isLoaded = await RunAnywhere.isModelLoaded +``` + +**Notes:** +- `isModelLoaded` is a **property**, not a function +- No model ID parameter - checks if ANY LLM model is loaded +- Similar properties exist: `isSTTModelLoaded`, `isTTSVoiceLoaded` + +**Evidence:** `Services/ModelService.swift`, lines 218-222 +```swift +func refreshLoadedStates() async { + isLLMLoaded = await RunAnywhere.isModelLoaded + isSTTLoaded = await RunAnywhere.isSTTModelLoaded + isTTSLoaded = await RunAnywhere.isTTSVoiceLoaded +} +``` + +--- + +## 2. Model Download + +### `downloadModel` + +**Documentation suggests:** +```swift +let stream = try await RunAnywhere.downloadModel(id: "model-id") +``` + +**Actual API:** +```swift +let stream = try await RunAnywhere.downloadModel("model-id") +``` + +**Notes:** +- The `id:` parameter label should be **omitted** +- Uses positional parameter only + +**Evidence:** `Services/ModelService.swift`, lines 268, 334, 400 +```swift +let progressStream = try await RunAnywhere.downloadModel(Self.llmModelId) +``` + +--- + +## 3. Model Unloading + +### `unloadModel` + +**Documentation suggests:** +```swift +try await RunAnywhere.unloadModel(id: "model-id") +``` + +**Actual API:** +```swift +try await RunAnywhere.unloadModel() +``` + +**Notes:** +- **No ID parameter** +- Unloads the currently loaded model (single-model-at-a-time paradigm) + +**Evidence:** `Services/ModelService.swift`, line 299 +```swift +try await RunAnywhere.unloadModel() +``` + +### `unloadSTTModel` + +**Documentation suggests:** +```swift +try await RunAnywhere.unloadSTTModel(id: "model-id") +``` + +**Actual API:** +```swift +try await RunAnywhere.unloadSTTModel() +``` + +**Notes:** +- No ID parameter + +**Evidence:** `Services/ModelService.swift`, line 365 + +### `unloadTTSVoice` + +**Documentation suggests:** +```swift +try await RunAnywhere.unloadTTSVoice(id: "voice-id") +``` + +**Actual API:** +```swift +try await RunAnywhere.unloadTTSVoice() +``` + +**Notes:** +- No ID parameter + +**Evidence:** `Services/ModelService.swift`, line 431 + +--- + +## 4. LLM Generation Options + +### `LLMGenerationOptions` + +**Documentation suggests:** +```swift +let options = LLMGenerationOptions( + maxTokens: 256, + temperature: 0.7, + modelId: "model-id" +) +``` + +**Actual API:** +```swift +let options = LLMGenerationOptions( + maxTokens: 256, + temperature: 0.7 +) +``` + +**Notes:** +- `modelId` parameter **does not exist** +- Model selection happens at load time, not generation time +- Only `maxTokens` and `temperature` are confirmed parameters + +**Evidence:** `Views/ChatView.swift`, lines 297-300 +```swift +let options = LLMGenerationOptions( + maxTokens: 256, + temperature: 0.8 +) +``` + +--- + +## 5. Verified API Reference + +Based on actual implementation, here is the verified API surface: + +### Initialization +```swift +// Initialize SDK +try RunAnywhere.initialize(environment: .development | .production) + +// Register backends +LlamaCPP.register() +ONNX.register() + +// Get version +RunAnywhere.version -> String +``` + +### Model Registration +```swift +RunAnywhere.registerModel( + id: String, + name: String, + url: URL, + framework: ModelFramework, // .llamaCpp or .onnx + modality: ModelModality?, // .speechRecognition, .speechSynthesis + artifactType: ArtifactType?, // .archive(.tarGz, structure: .nestedDirectory) + memoryRequirement: Int +) +``` + +### Model Download +```swift +// Returns AsyncStream +let progressStream = try await RunAnywhere.downloadModel(String) + +// Progress object contains: +// - overallProgress: Double (0.0 to 1.0) +// - stage: DownloadStage (.downloading, .extracting, .completed, etc.) +``` + +### Model Loading +```swift +try await RunAnywhere.loadModel(String) // LLM +try await RunAnywhere.loadSTTModel(String) // Speech-to-Text +try await RunAnywhere.loadTTSVoice(String) // Text-to-Speech +``` + +### State Checking (Properties, NOT Functions) +```swift +await RunAnywhere.isModelLoaded -> Bool +await RunAnywhere.isSTTModelLoaded -> Bool +await RunAnywhere.isTTSVoiceLoaded -> Bool +``` + +### Model Unloading (No Parameters) +```swift +try await RunAnywhere.unloadModel() +try await RunAnywhere.unloadSTTModel() +try await RunAnywhere.unloadTTSVoice() +``` + +### Text Generation +```swift +let options = LLMGenerationOptions( + maxTokens: Int, + temperature: Float +) + +// Streaming generation +let result = try await RunAnywhere.generateStream(String, options: LLMGenerationOptions) +// result.stream: AsyncStream (tokens) +// result.result: Task + +// GenerationResult contains: +// - tokensUsed: Int +// - tokensPerSecond: Double +``` + +### Speech-to-Text +```swift +let text: String = try await RunAnywhere.transcribe(Data) +// Input: 16kHz mono Int16 PCM audio data +// Output: Transcribed text +``` + +### Text-to-Speech +```swift +let options = TTSOptions( + rate: Float, // Speech rate (1.0 = normal) + pitch: Float, // Pitch adjustment + volume: Float // Volume level +) + +let output = try await RunAnywhere.synthesize(String, options: TTSOptions) +// output.audioData: Data (Float32 PCM @ 22kHz) +// output.duration: TimeInterval +``` + +--- + +## 6. Recommendations for SDK Documentation + +1. **Consistency in parameter naming:** `downloadModel` uses positional parameter while `loadModel` uses positional. Document this clearly or consider standardizing with labeled parameters. + +2. **State checking paradigm:** The property-based state checking (`isModelLoaded` vs `isModelLoaded(id:)`) suggests a single-model-at-a-time design. Document this architectural decision clearly. + +3. **Audio format documentation:** Clearly document input/output audio formats in a prominent location: + - STT input: 16kHz, mono, Int16 PCM + - TTS output: 22kHz, mono, Float32 PCM + +4. **Unload behavior:** Document that unload functions don't take ID parameters and operate on the currently loaded model. + +5. **Version-specific API notes:** Consider publishing API changelogs with each release to help developers track breaking changes. + +--- + +## Files Referenced + +| File | Line Numbers | API Verified | +|------|--------------|--------------| +| `Services/ModelService.swift` | 218-222 | `isModelLoaded` property | +| `Services/ModelService.swift` | 268, 334, 400 | `downloadModel` positional param | +| `Services/ModelService.swift` | 299 | `unloadModel()` no params | +| `Services/ModelService.swift` | 365 | `unloadSTTModel()` no params | +| `Services/ModelService.swift` | 431 | `unloadTTSVoice()` no params | +| `Views/ChatView.swift` | 297-300 | `LLMGenerationOptions` | +| `Views/VoicePipelineView.swift` | 601, 617, 637 | Full pipeline API usage | diff --git a/Playground/swift-starter-app/project.yml b/Playground/swift-starter-app/project.yml new file mode 100644 index 000000000..ed152cb57 --- /dev/null +++ b/Playground/swift-starter-app/project.yml @@ -0,0 +1,66 @@ +name: LocalAIPlayground +options: + bundleIdPrefix: com.yourcompany + deploymentTarget: + iOS: "17.0" + xcodeVersion: "15.0" + generateEmptyDirectories: true + +packages: + RunAnywhere: + url: https://github.com/RunanywhereAI/runanywhere-sdks + exactVersion: 0.16.0-test.39 + +targets: + LocalAIPlayground: + type: application + platform: iOS + sources: + - path: LocalAIPlayground + excludes: + - "**/.DS_Store" + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.yourcompany.LocalAIPlayground + MARKETING_VERSION: "1.0" + CURRENT_PROJECT_VERSION: "1" + INFOPLIST_FILE: LocalAIPlayground/Info.plist + DEVELOPMENT_TEAM: "" + CODE_SIGN_STYLE: Automatic + SWIFT_VERSION: "5.9" + dependencies: + - package: RunAnywhere + product: RunAnywhere + - package: RunAnywhere + product: RunAnywhereLlamaCPP + - package: RunAnywhere + product: RunAnywhereONNX + + LocalAIPlaygroundTests: + type: bundle.unit-test + platform: iOS + sources: + - path: LocalAIPlaygroundTests + dependencies: + - target: LocalAIPlayground + + LocalAIPlaygroundUITests: + type: bundle.ui-testing + platform: iOS + sources: + - path: LocalAIPlaygroundUITests + dependencies: + - target: LocalAIPlayground + +schemes: + LocalAIPlayground: + build: + targets: + LocalAIPlayground: all + run: + config: Debug + test: + config: Debug + targets: + - LocalAIPlaygroundTests + - LocalAIPlaygroundUITests diff --git a/README.md b/README.md index 321fcb987..673f579aa 100644 --- a/README.md +++ b/README.md @@ -1,175 +1,342 @@ -# Local Browser - On-Device AI Web Automation +

+ RunAnywhere Logo +

-# Launching support for runanywhere-web-sdk soon in our main repo: please go check it out: https://github.com/RunanywhereAI/runanywhere-sdks +

RunAnywhere

-A Chrome extension that uses WebLLM to run AI-powered web automation entirely on-device. No cloud APIs, no API keys, fully private. +

+ On-device AI for mobile apps.
+ Run LLMs, speech-to-text, and text-to-speech locally—private, offline, fast. +

-## Demo +

+ + Download on App Store + +   + + Get it on Google Play + +

-https://github.com/user-attachments/assets/898cc5c2-db77-4067-96e6-233c5da2bae5 +

+ GitHub Stars + License + Discord +

+

+ Chat +    + Analytics +    + Structured Output +    + Voice AI +

-## Features +--- + +## See It In Action + +

+ On-device tool calling demo +

+ +

+ Llama 3.2 3B on iPhone 16 Pro Max
+ Tool calling + LLM reasoning — 100% on-device +

+ +

+ View the code +  ·  + Full tool calling support coming soon +

+ +--- + +## What is RunAnywhere? + +RunAnywhere lets you add AI features to your mobile app that run entirely on-device: -- **On-Device AI**: Uses WebLLM with WebGPU acceleration for local LLM inference -- **Multi-Agent System**: Planner + Navigator agents for intelligent task execution -- **Browser Automation**: Navigate, click, type, extract data from web pages -- **Privacy-First**: All AI runs locally, no data leaves your device -- **Offline Support**: Works offline after initial model download +- **LLM Chat** — Llama, Mistral, Qwen, SmolLM, and more +- **Speech-to-Text** — Whisper-powered transcription +- **Text-to-Speech** — Neural voice synthesis +- **Voice Assistant** — Full STT → LLM → TTS pipeline + +No cloud. No latency. No data leaves the device. + +--- + +## SDKs + +| Platform | Status | Installation | Documentation | +|----------|--------|--------------|---------------| +| **Swift** (iOS/macOS) | Stable | [Swift Package Manager](#swift-ios--macos) | [docs.runanywhere.ai/swift](https://docs.runanywhere.ai/swift/introduction) | +| **Kotlin** (Android) | Stable | [Gradle](#kotlin-android) | [docs.runanywhere.ai/kotlin](https://docs.runanywhere.ai/kotlin/introduction) | +| **React Native** | Beta | [npm](#react-native) | [docs.runanywhere.ai/react-native](https://docs.runanywhere.ai/react-native/introduction) | +| **Flutter** | Beta | [pub.dev](#flutter) | [docs.runanywhere.ai/flutter](https://docs.runanywhere.ai/flutter/introduction) | + +--- ## Quick Start -### Prerequisites +### Swift (iOS / macOS) + +```swift +import RunAnywhere +import LlamaCPPRuntime + +// 1. Initialize +LlamaCPP.register() +try RunAnywhere.initialize() + +// 2. Load a model +try await RunAnywhere.downloadModel("smollm2-360m") +try await RunAnywhere.loadModel("smollm2-360m") + +// 3. Generate +let response = try await RunAnywhere.chat("What is the capital of France?") +print(response) // "Paris is the capital of France." +``` + +**Install via Swift Package Manager:** + +``` +https://github.com/RunanywhereAI/runanywhere-sdks +``` + +[Full documentation →](https://docs.runanywhere.ai/swift/introduction) · [Source code](sdk/runanywhere-swift/) + +--- + +### Kotlin (Android) + +```kotlin +import com.runanywhere.sdk.public.RunAnywhere +import com.runanywhere.sdk.public.extensions.* + +// 1. Initialize +LlamaCPP.register() +RunAnywhere.initialize(environment = SDKEnvironment.DEVELOPMENT) + +// 2. Load a model +RunAnywhere.downloadModel("smollm2-360m").collect { println("${it.progress * 100}%") } +RunAnywhere.loadLLMModel("smollm2-360m") + +// 3. Generate +val response = RunAnywhere.chat("What is the capital of France?") +println(response) // "Paris is the capital of France." +``` -- **Chrome 124+** (required for WebGPU in service workers) -- **Node.js 18+** and npm -- **GPU with WebGPU support** (most modern GPUs work) +**Install via Gradle:** -### Installation +```kotlin +dependencies { + implementation("com.runanywhere.sdk:runanywhere-kotlin:0.1.4") + implementation("com.runanywhere.sdk:runanywhere-core-llamacpp:0.1.4") +} +``` -1. **Clone and install dependencies**: - ```bash - cd local-browser - npm install - ``` +[Full documentation →](https://docs.runanywhere.ai/kotlin/introduction) · [Source code](sdk/runanywhere-kotlin/) -2. **Build the extension**: - ```bash - npm run build - ``` +--- -3. **Load in Chrome**: - - Open `chrome://extensions` - - Enable "Developer mode" (top right) - - Click "Load unpacked" - - Select the `dist` folder from this project +### React Native -4. **First run**: - - Click the extension icon in your toolbar - - The first run will download the AI model (~1GB) - - This is cached for future use +```typescript +import { RunAnywhere, SDKEnvironment } from '@runanywhere/core'; +import { LlamaCPP } from '@runanywhere/llamacpp'; -### Usage +// 1. Initialize +await RunAnywhere.initialize({ environment: SDKEnvironment.Development }); +LlamaCPP.register(); -1. Navigate to any webpage -2. Click the Local Browser extension icon -3. Type a task like: - - "Search for 'WebGPU' on Wikipedia and extract the first paragraph" - - "Go to example.com and tell me what's there" - - "Find the search box and search for 'AI news'" -4. Watch the AI execute the task step by step +// 2. Load a model +await RunAnywhere.downloadModel('smollm2-360m'); +await RunAnywhere.loadModel(modelPath); -## Development +// 3. Generate +const response = await RunAnywhere.chat('What is the capital of France?'); +console.log(response); // "Paris is the capital of France." +``` -### Development Mode +**Install via npm:** ```bash -npm run dev +npm install @runanywhere/core @runanywhere/llamacpp ``` -This watches for changes and rebuilds automatically. +[Full documentation →](https://docs.runanywhere.ai/react-native/introduction) · [Source code](sdk/runanywhere-react-native/) + +--- -### Project Structure +### Flutter +```dart +import 'package:runanywhere/runanywhere.dart'; +import 'package:runanywhere_llamacpp/runanywhere_llamacpp.dart'; + +// 1. Initialize +await RunAnywhere.initialize(); +await LlamaCpp.register(); + +// 2. Load a model +await RunAnywhere.downloadModel('smollm2-360m'); +await RunAnywhere.loadModel('smollm2-360m'); + +// 3. Generate +final response = await RunAnywhere.chat('What is the capital of France?'); +print(response); // "Paris is the capital of France." ``` -local-browser/ -├── manifest.json # Chrome extension manifest (MV3) -├── src/ -│ ├── background/ # Service worker -│ │ ├── index.ts # Entry point & message handling -│ │ ├── llm-engine.ts # WebLLM wrapper -│ │ └── agents/ # AI agent system -│ │ ├── base-agent.ts -│ │ ├── planner-agent.ts -│ │ ├── navigator-agent.ts -│ │ └── executor.ts -│ ├── content/ # Content scripts -│ │ ├── dom-observer.ts # Page state extraction -│ │ └── action-executor.ts -│ ├── popup/ # React popup UI -│ │ ├── App.tsx -│ │ └── components/ -│ └── shared/ # Shared types & constants -└── dist/ # Build output + +**Install via pub.dev:** + +```yaml +dependencies: + runanywhere: ^0.15.11 + runanywhere_llamacpp: ^0.15.11 ``` -### How It Works +[Full documentation →](https://docs.runanywhere.ai/flutter/introduction) · [Source code](sdk/runanywhere-flutter/) + +--- + +## Sample Apps + +Full-featured demo applications demonstrating SDK capabilities: -1. **User enters a task** in the popup UI -2. **Planner Agent** analyzes the task and creates a high-level strategy -3. **Navigator Agent** examines the current page DOM and decides on the next action -4. **Content Script** executes the action (click, type, extract, etc.) -5. Loop continues until task is complete or fails +| Platform | Source Code | Download | +|----------|-------------|----------| +| iOS | [examples/ios/RunAnywhereAI](examples/ios/RunAnywhereAI/) | [App Store](https://apps.apple.com/us/app/runanywhere/id6756506307) | +| Android | [examples/android/RunAnywhereAI](examples/android/RunAnywhereAI/) | [Google Play](https://play.google.com/store/apps/details?id=com.runanywhere.runanywhereai) | +| React Native | [examples/react-native/RunAnywhereAI](examples/react-native/RunAnywhereAI/) | Build from source | +| Flutter | [examples/flutter/RunAnywhereAI](examples/flutter/RunAnywhereAI/) | Build from source | -### Agent System +--- -The extension uses a two-agent architecture inspired by Nanobrowser: +## Playground -- **PlannerAgent**: Strategic planning, creates step-by-step approach -- **NavigatorAgent**: Tactical execution, chooses specific actions based on page state +Standalone demo projects showcasing what you can build with RunAnywhere: -Both agents output structured JSON that is parsed and executed. +| Project | Description | Platform | +|---------|-------------|----------| +| [swift-starter-app](Playground/swift-starter-app/) | Privacy-first AI demo — LLM Chat, STT, TTS, and Voice Pipeline | iOS (Swift/SwiftUI) | +| [on-device-browser-agent](Playground/on-device-browser-agent/) | On-device AI browser automation — no cloud, no API keys | Chrome Extension | + +--- + +## Features -## Model Configuration +| Feature | iOS | Android | React Native | Flutter | +|---------|-----|---------|--------------|---------| +| LLM Text Generation | ✅ | ✅ | ✅ | ✅ | +| Streaming | ✅ | ✅ | ✅ | ✅ | +| Speech-to-Text | ✅ | ✅ | ✅ | ✅ | +| Text-to-Speech | ✅ | ✅ | ✅ | ✅ | +| Voice Assistant Pipeline | ✅ | ✅ | ✅ | ✅ | +| Model Download + Progress | ✅ | ✅ | ✅ | ✅ | +| Structured Output (JSON) | ✅ | ✅ | 🔜 | 🔜 | +| Apple Foundation Models | ✅ | — | — | — | -Default model: `Qwen2.5-1.5B-Instruct-q4f16_1-MLC` (~1GB) +--- -Alternative models (configured in `src/shared/constants.ts`): -- `Phi-3.5-mini-instruct-q4f16_1-MLC` (~2GB, better reasoning) -- `Llama-3.2-1B-Instruct-q4f16_1-MLC` (~0.7GB, smaller) +## Supported Models -## Troubleshooting +### LLM (GGUF format via llama.cpp) -### WebGPU not supported -- Update Chrome to version 124 or later -- Check `chrome://gpu` to verify WebGPU status -- Some GPUs may not support WebGPU +| Model | Size | RAM Required | Use Case | +|-------|------|--------------|----------| +| SmolLM2 360M | ~400MB | 500MB | Fast, lightweight | +| Qwen 2.5 0.5B | ~500MB | 600MB | Multilingual | +| Llama 3.2 1B | ~1GB | 1.2GB | Balanced | +| Mistral 7B Q4 | ~4GB | 5GB | High quality | -### Model fails to load -- Ensure you have enough disk space (~2GB free) -- Check browser console for errors -- Try clearing the extension's storage and reloading +### Speech-to-Text (Whisper via ONNX) -### Actions not executing -- Some pages block content scripts (chrome://, extension pages) -- Try on a regular webpage like wikipedia.org +| Model | Size | Languages | +|-------|------|-----------| +| Whisper Tiny | ~75MB | English | +| Whisper Base | ~150MB | Multilingual | -### Extension not working after Chrome update -- Go to `chrome://extensions` -- Click the reload button on the extension +### Text-to-Speech (Piper via ONNX) -## Limitations +| Voice | Size | Language | +|-------|------|----------| +| Piper US English | ~65MB | English (US) | +| Piper British English | ~65MB | English (UK) | -- **POC Scope**: This is a proof-of-concept, not production software -- **No Vision**: Uses text-only DOM analysis (no screenshot understanding) -- **Single Tab**: Only works with the currently active tab -- **Basic Actions**: Supports navigate, click, type, extract, scroll, wait -- **Model Size**: Smaller models may struggle with complex tasks +--- -## Tech Stack +## Repository Structure -- **WebLLM**: On-device LLM inference with WebGPU -- **React**: Popup UI -- **TypeScript**: Type-safe development -- **Vite + CRXJS**: Chrome extension bundling -- **Chrome Extension Manifest V3**: Modern extension architecture +``` +runanywhere-sdks/ +├── sdk/ +│ ├── runanywhere-swift/ # iOS/macOS SDK +│ ├── runanywhere-kotlin/ # Android SDK +│ ├── runanywhere-react-native/ # React Native SDK +│ ├── runanywhere-flutter/ # Flutter SDK +│ └── runanywhere-commons/ # Shared C++ core +│ +├── examples/ +│ ├── ios/RunAnywhereAI/ # iOS sample app +│ ├── android/RunAnywhereAI/ # Android sample app +│ ├── react-native/RunAnywhereAI/ # React Native sample app +│ └── flutter/RunAnywhereAI/ # Flutter sample app +│ +├── Playground/ +│ ├── swift-starter-app/ # iOS AI playground app +│ └── on-device-browser-agent/ # Chrome browser automation agent +│ +└── docs/ # Documentation +``` + +--- + +## Requirements + +| Platform | Minimum | Recommended | +|----------|---------|-------------| +| iOS | 17.0+ | 17.0+ | +| macOS | 14.0+ | 14.0+ | +| Android | API 24 (7.0) | API 28+ | +| React Native | 0.74+ | 0.76+ | +| Flutter | 3.10+ | 3.24+ | + +**Memory:** 2GB minimum, 4GB+ recommended for larger models + +--- + +## Contributing + +We welcome contributions. See our [Contributing Guide](CONTRIBUTING.md) for details. + +```bash +# Clone the repo +git clone https://github.com/RunanywhereAI/runanywhere-sdks.git + +# Set up a specific SDK (example: Swift) +cd runanywhere-sdks/sdk/runanywhere-swift +./scripts/build-swift.sh --setup + +# Run the sample app +cd ../../examples/ios/RunAnywhereAI +open RunAnywhereAI.xcodeproj +``` -## Credits +--- -This project is inspired by: -- [Nanobrowser](https://github.com/nanobrowser/nanobrowser) - Multi-agent web automation (MIT License) -- [WebLLM](https://github.com/mlc-ai/web-llm) - In-browser LLM inference (Apache-2.0 License) +## Support -### Dependency Licenses +- **Discord:** [Join our community](https://discord.gg/N359FBbDVd) +- **GitHub Issues:** [Report bugs or request features](https://github.com/RunanywhereAI/runanywhere-sdks/issues) +- **Email:** founders@runanywhere.ai +- **Twitter:** [@RunanywhereAI](https://twitter.com/RunanywhereAI) -| Package | License | -|---------|---------| -| @mlc-ai/web-llm | Apache-2.0 | -| React | MIT | -| Vite | MIT | -| @crxjs/vite-plugin | MIT | -| TypeScript | Apache-2.0 | +--- ## License -MIT License - See LICENSE file for details. +Apache 2.0 — see [LICENSE](LICENSE) for details. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..f4be1d302 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,30 @@ +# Security Policy + +## Reporting a Vulnerability + +If you discover a security vulnerability, please report it responsibly: + +**Email:** security@runanywhere.ai + +Please include: +- Description of the vulnerability +- Steps to reproduce +- Potential impact +- Any suggested fixes (optional) + +## Response Timeline + +- **Acknowledgment:** Within 48 hours +- **Initial assessment:** Within 1 week +- **Resolution:** Depends on severity (critical issues prioritized) + +## Scope + +This policy covers: +- RunAnywhere SDK code (Swift, Kotlin, React Native, Flutter) +- Native libraries (runanywhere-commons) +- Example applications + +## Responsible Disclosure + +Please do not publicly disclose vulnerabilities until we've had a chance to address them. We appreciate your help in keeping our users safe. diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 000000000..a16b3ae5e --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,467 @@ +// Root build script for RunAnywhere Android SDK +// +// SIMPLIFIED BUILD TASKS: +// ---------------------- +// The SDK is now a local module that builds automatically with the apps. +// No need to build or publish the SDK separately during development! +// +// AVAILABLE TASKS: +// 1. buildAll - Build SDK and all example apps (creates local.properties everywhere) +// 2. buildAndroidApp - Build Android sample app (SDK builds automatically) +// 3. runAndroidApp - Build and launch Android app on connected device +// 4. buildIntellijPlugin - Build IntelliJ plugin (publishes SDK to Maven Local first) +// 5. runIntellijPlugin - Build and launch IntelliJ plugin in sandbox +// 6. cleanAll - Clean all projects +// +// PUBLISHING (for SDK distribution only): +// publishSdkToMavenLocal - Publish SDK to Maven Local repository +// +// Run these tasks from IntelliJ run configurations or via: +// ./gradlew + +plugins { + // Apply plugins to submodules only - no root plugins needed for composite builds + id("io.gitlab.arturbosch.detekt") version "1.23.8" apply false +} + +// Configure all projects +allprojects { + group = "com.runanywhere" + version = "0.1.0" +} + +// Configure subprojects (not composite builds) +subprojects { + tasks.withType { + useJUnitPlatform() + testLogging { + events("passed", "skipped", "failed") + } + } +} + +// ============================================================================ +// UNIFIED BUILD TASK - Builds everything with proper local.properties setup +// ============================================================================ + +tasks.register("buildAll") { + group = "build" + description = "Build SDK and all example apps (ensures local.properties exists everywhere)" + + doFirst { + println("=".repeat(70)) + println(" Building RunAnywhere SDK and Examples") + println("=".repeat(70)) + println() + + // Ensure local.properties exists in all necessary locations + val locations = listOf( + projectDir, // Root + file("sdk/runanywhere-kotlin"), // SDK + file("examples/android/RunAnywhereAI") // Android app + ) + + println("Step 1: Ensuring local.properties files exist...") + locations.forEach { dir -> + if (dir.exists()) { + val localProps = dir.resolve("local.properties") + if (!localProps.exists()) { + println(" Creating: ${localProps.relativeTo(projectDir)}") + + // Find Android SDK path + val androidHome = System.getenv("ANDROID_HOME") + ?: System.getenv("ANDROID_SDK_ROOT") + ?: "${System.getProperty("user.home")}/Android/Sdk" + + // Find Android NDK path + val ndkHome = System.getenv("ANDROID_NDK_HOME") + ?: "$androidHome/ndk/27.0.12077973" + + localProps.writeText(""" + # Auto-generated by buildAll task + sdk.dir=$androidHome + ndk.dir=$ndkHome + """.trimIndent()) + } else { + println(" Exists: ${localProps.relativeTo(projectDir)}") + } + } + } + println() + } + + doLast { + // 1. Build SDK + println("Step 2: Building SDK...") + println("-".repeat(70)) + exec { + workingDir = projectDir + commandLine("./gradlew", ":RunAnywhereAI:sdk:runanywhere-kotlin:assembleDebug") + } + println() + + // 2. Build Android app + println("Step 3: Building Android app...") + println("-".repeat(70)) + exec { + workingDir = file("examples/android/RunAnywhereAI") + commandLine("./gradlew", "assembleDebug") + } + println() + + // 3. Publish SDK to Maven Local (for IntelliJ plugin) + println("Step 4: Publishing SDK to Maven Local...") + println("-".repeat(70)) + exec { + workingDir = projectDir + commandLine("./gradlew", ":RunAnywhereAI:sdk:runanywhere-kotlin:publishToMavenLocal") + } + println() + + // 4. Build IntelliJ plugin + println("Step 5: Building IntelliJ plugin...") + println("-".repeat(70)) + exec { + workingDir = file("examples/intellij-plugin-demo/plugin") + commandLine("./gradlew", "buildPlugin") + } + println() + + println("=".repeat(70)) + println(" Build Complete!") + println("=".repeat(70)) + println() + println("SDK artifacts:") + println(" - Debug AAR: sdk/runanywhere-kotlin/build/outputs/aar/") + println(" - Maven Local: ~/.m2/repository/com/runanywhere/runanywhere-sdk/") + println() + println("Example apps:") + println(" - Android APK: examples/android/RunAnywhereAI/app/build/outputs/apk/") + println(" - IntelliJ Plugin: examples/intellij-plugin-demo/plugin/build/distributions/") + println() + } +} + +// ============================================================================ +// SDK ONLY TASKS +// ============================================================================ + +tasks.register("buildSdk") { + group = "sdk" + description = "Build SDK only (creates local.properties if needed)" + + doFirst { + val sdkDir = file("sdk/runanywhere-kotlin") + val localProps = sdkDir.resolve("local.properties") + + if (!localProps.exists()) { + println("Creating local.properties for SDK...") + val androidHome = System.getenv("ANDROID_HOME") + ?: System.getenv("ANDROID_SDK_ROOT") + ?: "${System.getProperty("user.home")}/Android/Sdk" + + val ndkHome = System.getenv("ANDROID_NDK_HOME") + ?: "$androidHome/ndk/27.0.12077973" + + localProps.writeText(""" + sdk.dir=$androidHome + ndk.dir=$ndkHome + """.trimIndent()) + } + } + + doLast { + exec { + workingDir = projectDir + commandLine("./gradlew", ":RunAnywhereAI:sdk:runanywhere-kotlin:assembleDebug") + } + println("SDK built successfully") + } +} + +tasks.register("buildSdkRelease") { + group = "sdk" + description = "Build SDK release variant" + + doLast { + exec { + workingDir = projectDir + commandLine("./gradlew", ":RunAnywhereAI:sdk:runanywhere-kotlin:assembleRelease") + } + println("SDK release build completed") + } +} + +// ============================================================================ +// ANDROID APP TASKS +// ============================================================================ + +tasks.register("buildAndroidApp") { + group = "android" + description = "Build Android sample app (SDK builds automatically as local module)" + + doFirst { + val appDir = file("examples/android/RunAnywhereAI") + val localProps = appDir.resolve("local.properties") + + if (!localProps.exists()) { + println("Creating local.properties for Android app...") + val androidHome = System.getenv("ANDROID_HOME") + ?: System.getenv("ANDROID_SDK_ROOT") + ?: "${System.getProperty("user.home")}/Android/Sdk" + + localProps.writeText("sdk.dir=$androidHome\n") + } + } + + doLast { + exec { + workingDir = file("examples/android/RunAnywhereAI") + commandLine("./gradlew", "assembleDebug") + } + println("Android app built successfully") + } +} + +tasks.register("runAndroidApp") { + group = "android" + description = "Build and launch Android app on connected device" + dependsOn("buildAndroidApp") + + doLast { + // Install on connected device + println("Installing app...") + exec { + workingDir = file("examples/android/RunAnywhereAI") + commandLine("./gradlew", "installDebug") + } + + // Launch the app + println("Launching app...") + exec { + commandLine( + "adb", + "shell", + "am", + "start", + "-n", + "com.runanywhere.runanywhereai.debug/com.runanywhere.runanywhereai.MainActivity" + ) + } + + println("Android app launched successfully") + } +} + +// ============================================================================ +// INTELLIJ PLUGIN TASKS +// ============================================================================ + +tasks.register("buildIntellijPlugin") { + group = "intellij" + description = "Build IntelliJ plugin (publishes SDK to Maven Local first)" + doLast { + // 1. Publish SDK to Maven Local (plugin can't use local module) + println("Publishing SDK to Maven Local...") + exec { + workingDir = projectDir + commandLine("./gradlew", ":RunAnywhereAI:sdk:runanywhere-kotlin:publishToMavenLocal") + } + + // 2. Build plugin + println("Building IntelliJ plugin...") + exec { + workingDir = file("examples/intellij-plugin-demo/plugin") + commandLine("./gradlew", "buildPlugin") + } + println("IntelliJ plugin built successfully") + } +} + +tasks.register("runIntellijPlugin") { + group = "intellij" + description = "Build and run IntelliJ plugin in sandbox (publishes SDK first)" + doLast { + // 1. Publish SDK to Maven Local (plugin can't use local module) + println("Publishing SDK to Maven Local...") + exec { + workingDir = projectDir + commandLine("./gradlew", ":RunAnywhereAI:sdk:runanywhere-kotlin:publishToMavenLocal") + } + + // 2. Build and run plugin + println("Building and running IntelliJ plugin...") + exec { + workingDir = file("examples/intellij-plugin-demo/plugin") + commandLine("./gradlew", "runIde") + } + println("IntelliJ plugin launched successfully") + } +} + +// ============================================================================ +// SDK PUBLISHING (for distribution only, not needed for development) +// ============================================================================ + +tasks.register("publishSdkToMavenLocal") { + group = "publishing" + description = "Publish SDK to Maven Local (for external projects, not needed for examples)" + dependsOn(":RunAnywhereAI:sdk:runanywhere-kotlin:publishToMavenLocal") + doLast { + println("SDK published to Maven Local (~/.m2/repository)") + println(" Group: com.runanywhere") + println(" Artifact: runanywhere-sdk") + println(" Version: 0.1.0") + println() + println("Note: This is automatically done for IntelliJ plugin tasks.") + println(" Android app uses SDK as local module and doesn't need this.") + } +} + +// ============================================================================ +// CLEAN TASK +// ============================================================================ + +tasks.register("cleanAll") { + group = "build" + description = "Clean SDK and all sample apps" + doLast { + // Clean SDK + println("Cleaning SDK...") + delete(layout.buildDirectory) + file("sdk/runanywhere-kotlin/build").deleteRecursively() + + // Clean Android app + println("Cleaning Android app...") + exec { + workingDir = file("examples/android/RunAnywhereAI") + commandLine("./gradlew", "clean") + } + + // Clean IntelliJ plugin + println("Cleaning IntelliJ plugin...") + exec { + workingDir = file("examples/intellij-plugin-demo/plugin") + commandLine("./gradlew", "clean") + } + + println("All projects cleaned successfully") + } +} + +// ============================================================================ +// UTILITY TASKS +// ============================================================================ + +tasks.register("setupLocalProperties") { + group = "setup" + description = "Create local.properties files in all necessary locations" + + doLast { + val androidHome = System.getenv("ANDROID_HOME") + ?: System.getenv("ANDROID_SDK_ROOT") + ?: "${System.getProperty("user.home")}/Android/Sdk" + + val ndkHome = System.getenv("ANDROID_NDK_HOME") + ?: "$androidHome/ndk/27.0.12077973" + + val locations = mapOf( + "Root" to projectDir, + "SDK" to file("sdk/runanywhere-kotlin"), + "Android App" to file("examples/android/RunAnywhereAI") + ) + + println("Creating local.properties files...") + println("-".repeat(70)) + + locations.forEach { (name, dir) -> + if (dir.exists()) { + val localProps = dir.resolve("local.properties") + val content = if (name == "SDK" || name == "Root") { + """ + sdk.dir=$androidHome + ndk.dir=$ndkHome + """.trimIndent() + } else { + "sdk.dir=$androidHome" + } + + localProps.writeText(content) + println("[$name] ${localProps.absolutePath}") + } + } + + println("-".repeat(70)) + println("local.properties files created successfully") + println() + println("Android SDK: $androidHome") + println("Android NDK: $ndkHome") + } +} + +tasks.register("checkEnvironment") { + group = "setup" + description = "Check development environment setup" + + doLast { + println("=".repeat(70)) + println(" RunAnywhere Development Environment Check") + println("=".repeat(70)) + println() + + // Check Android SDK + val androidHome = System.getenv("ANDROID_HOME") + ?: System.getenv("ANDROID_SDK_ROOT") + println("Android SDK:") + if (androidHome != null && file(androidHome).exists()) { + println(" [OK] $androidHome") + } else { + println(" [WARN] Not found or ANDROID_HOME not set") + } + println() + + // Check Android NDK + val ndkHome = System.getenv("ANDROID_NDK_HOME") + println("Android NDK:") + if (ndkHome != null && file(ndkHome).exists()) { + println(" [OK] $ndkHome") + } else { + println(" [WARN] Not found or ANDROID_NDK_HOME not set") + } + println() + + // Check local.properties files + println("local.properties files:") + val locations = mapOf( + "Root" to projectDir, + "SDK" to file("sdk/runanywhere-kotlin"), + "Android App" to file("examples/android/RunAnywhereAI") + ) + + locations.forEach { (name, dir) -> + val localProps = dir.resolve("local.properties") + if (localProps.exists()) { + println(" [OK] $name: ${localProps.relativeTo(projectDir)}") + } else { + println(" [MISSING] $name: ${localProps.relativeTo(projectDir)}") + } + } + println() + + // Check gradle.properties + val gradleProps = projectDir.resolve("gradle.properties") + println("gradle.properties:") + if (gradleProps.exists()) { + println(" [OK] ${gradleProps.relativeTo(projectDir)}") + val testLocal = gradleProps.readText().contains("runanywhere.testLocal=true") + println(" testLocal mode: ${if (testLocal) "enabled" else "disabled"}") + } else { + println(" [MISSING] ${gradleProps.relativeTo(projectDir)}") + } + println() + + println("=".repeat(70)) + println("Run './gradlew setupLocalProperties' to create missing files") + println("=".repeat(70)) + } +} \ No newline at end of file diff --git a/demo.gif b/demo.gif new file mode 100644 index 000000000..6434a1a51 Binary files /dev/null and b/demo.gif differ diff --git a/docs/BUILDING-PROJECT.MD b/docs/BUILDING-PROJECT.MD new file mode 100644 index 000000000..1e12fc165 --- /dev/null +++ b/docs/BUILDING-PROJECT.MD @@ -0,0 +1,148 @@ +# Building RunAnywhere Kotlin SDK + +## Quick Start + +### Using Android Studio + +1. Open the project in Android Studio +2. Wait for Gradle sync to complete +3. Build the project + +### Using Command Line + +Navigate to the root folder and run: + +```bash +# Check environment and dependencies +./gradlew checkEnvironment + +# Create required configuration files +./gradlew setupLocalProperties + +# Build SDK and all examples +./gradlew buildAll +``` + +## Prerequisites + +- Android Studio Arctic Fox or later +- JDK 17 or higher +- Android SDK with Build Tools +- Android NDK 27.0.12077973 (or compatible version) + +Set environment variables (recommended): + +```bash +export ANDROID_HOME=/path/to/android/sdk +export ANDROID_NDK_HOME=/path/to/android/ndk/27.0.12077973 +``` + +## Build Configuration + +### Remote Mode (Default) + +Downloads pre-built native libraries from GitHub releases. + +```properties +# gradle.properties +runanywhere.testLocal=false +``` + +Recommended for most development work. + +### Local Mode + +Builds native libraries from source. Only needed when modifying C++ code. + +```properties +# gradle.properties +runanywhere.testLocal=true +``` + +## Available Commands + +### Build Commands + +```bash +# Build SDK only +./gradlew buildSdk + +# Build SDK release variant +./gradlew buildSdkRelease + +# Build Android example app +./gradlew buildAndroidApp + +# Build and run Android app +./gradlew runAndroidApp + +# Build IntelliJ plugin +./gradlew buildIntellijPlugin + +# Build and run IntelliJ plugin +./gradlew runIntellijPlugin +``` + +### Utility Commands + +```bash +# Check development environment +./gradlew checkEnvironment + +# Setup configuration files +./gradlew setupLocalProperties + +# Clean all projects +./gradlew cleanAll +``` + +## Troubleshooting + +### Missing local.properties + +Run `./gradlew setupLocalProperties` to create required files. + +### JNI Libraries Not Found + +For remote mode: +```bash +./gradlew downloadJniLibs +``` + +For local mode: +```bash +./scripts/build-kotlin.sh --setup +``` + +### Clean Build + +```bash +./gradlew cleanAll +./gradlew buildAll +``` + +## Project Structure + +``` +runanywhere-sdks/ +│── sdk/ +│ └──── runanywhere-kotlin/ +└── examples/ + ├── android/ + └── intellij-plugin-demo/ +``` + +## Output Locations + +After successful build: + +- SDK AAR: `RunAnywhereAI/sdk/runanywhere-kotlin/build/outputs/aar/` +- Android APK: `examples/android/RunAnywhereAI/app/build/outputs/apk/` +- IntelliJ Plugin: `examples/intellij-plugin-demo/plugin/build/distributions/` +- Maven Local: `~/.m2/repository/com/runanywhere/runanywhere-sdk/` + +## Support + +For issues or questions: +- GitHub Issues: https://github.com/RunanywhereAI/runanywhere-sdks/issues +- Documentation: https://docs.runanywhere.ai \ No newline at end of file diff --git a/docs/screenshots/main-screenshot.jpg b/docs/screenshots/main-screenshot.jpg new file mode 100644 index 000000000..048f361bf Binary files /dev/null and b/docs/screenshots/main-screenshot.jpg differ diff --git a/examples/android/RunAnywhereAI/.editorconfig b/examples/android/RunAnywhereAI/.editorconfig new file mode 100644 index 000000000..ab0bf296d --- /dev/null +++ b/examples/android/RunAnywhereAI/.editorconfig @@ -0,0 +1,24 @@ +[*.{kt,kts}] +# Disable function-naming rule for Compose @Composable functions +# Compose convention uses PascalCase for @Composable functions +ktlint_function_naming = disabled +ktlint_standard_function-naming = disabled + +# Allow wildcard imports for Compose +ktlint_no-wildcard-imports = disabled +ktlint_standard_no-wildcard-imports = disabled + +# Property naming - allow camelCase for constants in objects +ktlint_standard_property-naming = disabled + +# Allow inline comments in argument lists (common in Compose code) +ktlint_standard_comment-wrapping = disabled +ktlint_standard_value-argument-comment = disabled +ktlint_standard_value-parameter-comment = disabled + +# Max line length - allow longer lines for Compose modifiers +max_line_length = 200 + +# Allow consecutive KDoc comments +ktlint_standard_kdoc-wrapping = disabled +ktlint_standard_kdoc = disabled diff --git a/examples/android/RunAnywhereAI/.gitignore b/examples/android/RunAnywhereAI/.gitignore new file mode 100644 index 000000000..4ee96e605 --- /dev/null +++ b/examples/android/RunAnywhereAI/.gitignore @@ -0,0 +1,117 @@ +# Built application files +*.apk +*.aar +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ +build/ +*/build/ + +# Gradle files +.gradle/ +gradle-app.setting +!gradle-wrapper.jar +.gradletasknamecache + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# IntelliJ +*.iml +.idea/ +.idea/workspace.xml +.idea/tasks.xml +.idea/gradle.xml +.idea/assetWizardSettings.xml +.idea/dictionaries +.idea/libraries +.idea/caches +.idea/modules.xml +.idea/navEditor.xml +.idea/misc.xml + +# Keystore files +*.jks +*.keystore + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild +.cxx/ + +# Google Services (e.g. APIs or Firebase) +google-services.json + +# Freeline +freeline.py +freeline/ +freeline_project_description.json + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/readme.md + +# Android Profiling +*.hprof + +# Version control +vcs.xml + +# lint +lint/intermediates/ +lint/generated/ +lint/outputs/ +lint/tmp/ +lint/reports/ + +# OS-specific files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Kotlin +.kotlin/ + +# Package Files +*.jar +!gradle/wrapper/gradle-wrapper.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs +hs_err_pid* + +# Detekt +detekt.sarif diff --git a/examples/android/RunAnywhereAI/README.md b/examples/android/RunAnywhereAI/README.md new file mode 100644 index 000000000..4da6fca95 --- /dev/null +++ b/examples/android/RunAnywhereAI/README.md @@ -0,0 +1,601 @@ +# RunAnywhere AI - Android Example + +

+ RunAnywhere Logo +

+ +

+ + Get it on Google Play + +

+ +

+ Android 7.0+ + Kotlin 2.1.21 + Jetpack Compose + License +

+ +**A production-ready reference app demonstrating the [RunAnywhere Kotlin SDK](../../../sdk/runanywhere-kotlin/) capabilities for on-device AI.** This app showcases how to build privacy-first, offline-capable AI features with LLM chat, speech-to-text, text-to-speech, and a complete voice assistant pipeline—all running locally on your device. + +--- + +## 🚀 Running This App (Local Development) + +> **Important:** This sample app consumes the [RunAnywhere Kotlin SDK](../../../sdk/runanywhere-kotlin/) as a local Gradle included build. Before opening this project, you must first build the SDK's native libraries. + +### First-Time Setup + +```bash +# 1. Navigate to the Kotlin SDK directory +cd runanywhere-sdks/sdk/runanywhere-kotlin + +# 2. Run the setup script (~10-15 minutes on first run) +# This builds the native C++ JNI libraries and sets testLocal=true +./scripts/build-kotlin.sh --setup + +# 3. Open this sample app in Android Studio +# File > Open > examples/android/RunAnywhereAI + +# 4. Wait for Gradle sync to complete + +# 5. Connect an Android device (ARM64 recommended) or use an emulator + +# 6. Click Run +``` + +### How It Works + +This sample app uses `settings.gradle.kts` with `includeBuild()` to reference the local Kotlin SDK: + +``` +This Sample App → Local Kotlin SDK (sdk/runanywhere-kotlin/) + ↓ + Local JNI Libraries (sdk/runanywhere-kotlin/src/androidMain/jniLibs/) + ↑ + Built by: ./scripts/build-kotlin.sh --setup +``` + +The `build-kotlin.sh --setup` script: +1. Downloads dependencies (Sherpa-ONNX, ~500MB) +2. Builds the native C++ libraries from `runanywhere-commons` +3. Copies JNI `.so` files to `sdk/runanywhere-kotlin/src/androidMain/jniLibs/` +4. Sets `runanywhere.testLocal=true` in `gradle.properties` + +### After Modifying the SDK + +- **Kotlin SDK code changes**: Rebuild in Android Studio or run `./gradlew assembleDebug` +- **C++ code changes** (in `runanywhere-commons`): + ```bash + cd sdk/runanywhere-kotlin + ./scripts/build-kotlin.sh --local --rebuild-commons + ``` + +--- + +## Try It Now + +

+ + Get it on Google Play + +

+ +Download the app from Google Play Store to try it out. + +--- + +## Screenshots + +

+ RunAnywhere AI Chat Interface +

+ +--- + +## Features + +This sample app demonstrates the full power of the RunAnywhere SDK: + +| Feature | Description | SDK Integration | +|---------|-------------|-----------------| +| **AI Chat** | Interactive LLM conversations with streaming responses | `RunAnywhere.generateStream()` | +| **Thinking Mode** | Support for models with `...` reasoning | Thinking tag parsing | +| **Real-time Analytics** | Token speed, generation time, inference metrics | `MessageAnalytics` | +| **Speech-to-Text** | Voice transcription with batch & live modes | `RunAnywhere.transcribe()` | +| **Text-to-Speech** | Neural voice synthesis with Piper TTS | `RunAnywhere.synthesize()` | +| **Voice Assistant** | Full STT -> LLM -> TTS pipeline with auto-detection | `RunAnywhere.processVoice()` | +| **Model Management** | Download, load, and manage multiple AI models | `RunAnywhere.downloadModel()` | +| **Storage Management** | View storage usage and delete models | `RunAnywhere.storageInfo()` | +| **Offline Support** | All features work without internet | On-device inference | + +--- + +## Architecture + +The app follows modern Android architecture patterns: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Jetpack Compose UI │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────┐ │ +│ │ Chat │ │ STT │ │ TTS │ │ Voice │ │Settings│ │ +│ │ Screen │ │ Screen │ │ Screen │ │ Screen │ │ Screen │ │ +│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ └───┬────┘ │ +├───────┼────────────┼────────────┼────────────┼───────────┼──────┤ +│ ▼ ▼ ▼ ▼ ▼ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────┐ │ +│ │ Chat │ │ STT │ │ TTS │ │ Voice │ │Settings│ │ +│ │ViewModel │ │ViewModel │ │ViewModel │ │ViewModel │ │ViewModel│ +│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ └───┬────┘ │ +├───────┴────────────┴────────────┴────────────┴───────────┴──────┤ +│ │ +│ RunAnywhere Kotlin SDK │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ Extension Functions (generate, transcribe, synthesize) │ │ +│ │ EventBus (LLMEvent, STTEvent, TTSEvent, ModelEvent) │ │ +│ │ Model Management (download, load, unload, delete) │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ┌──────────────────┴──────────────────┐ │ +│ ▼ ▼ │ +│ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ LlamaCpp │ │ ONNX Runtime │ │ +│ │ (LLM/GGUF) │ │ (STT/TTS) │ │ +│ └─────────────────┘ └─────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Key Architecture Decisions + +- **MVVM Pattern** — ViewModels manage UI state with `StateFlow`, Compose observes changes +- **Single Activity** — Jetpack Navigation Compose handles all screen transitions +- **Coroutines & Flow** — All async operations use Kotlin coroutines with structured concurrency +- **EventBus Pattern** — SDK events (model loading, generation, etc.) propagate via `EventBus.events` +- **Repository Abstraction** — `ConversationStore` persists chat history + +--- + +## Project Structure + +``` +RunAnywhereAI/ +├── app/ +│ ├── src/main/ +│ │ ├── java/com/runanywhere/runanywhereai/ +│ │ │ ├── RunAnywhereApplication.kt # SDK initialization, model registration +│ │ │ ├── MainActivity.kt # Entry point, initialization state handling +│ │ │ │ +│ │ │ ├── data/ +│ │ │ │ └── ConversationStore.kt # Chat history persistence +│ │ │ │ +│ │ │ ├── domain/ +│ │ │ │ ├── models/ +│ │ │ │ │ ├── ChatMessage.kt # Message data model with analytics +│ │ │ │ │ └── SessionState.kt # Voice session states +│ │ │ │ └── services/ +│ │ │ │ └── AudioCaptureService.kt # Microphone audio capture +│ │ │ │ +│ │ │ ├── presentation/ +│ │ │ │ ├── chat/ +│ │ │ │ │ ├── ChatScreen.kt # LLM chat UI with streaming +│ │ │ │ │ ├── ChatViewModel.kt # Chat logic, thinking mode +│ │ │ │ │ └── components/ +│ │ │ │ │ └── MessageInput.kt # Chat input component +│ │ │ │ │ +│ │ │ │ ├── stt/ +│ │ │ │ │ ├── SpeechToTextScreen.kt # STT UI with waveform +│ │ │ │ │ └── SpeechToTextViewModel.kt # Batch & live transcription +│ │ │ │ │ +│ │ │ │ ├── tts/ +│ │ │ │ │ ├── TextToSpeechScreen.kt # TTS UI with playback +│ │ │ │ │ └── TextToSpeechViewModel.kt # Synthesis & audio playback +│ │ │ │ │ +│ │ │ │ ├── voice/ +│ │ │ │ │ ├── VoiceAssistantScreen.kt # Full voice pipeline UI +│ │ │ │ │ └── VoiceAssistantViewModel.kt # STT→LLM→TTS orchestration +│ │ │ │ │ +│ │ │ │ ├── settings/ +│ │ │ │ │ ├── SettingsScreen.kt # Storage & model management +│ │ │ │ │ └── SettingsViewModel.kt # Storage info, cache clearing +│ │ │ │ │ +│ │ │ │ ├── models/ +│ │ │ │ │ ├── ModelSelectionBottomSheet.kt # Model picker UI +│ │ │ │ │ └── ModelSelectionViewModel.kt # Download & load logic +│ │ │ │ │ +│ │ │ │ ├── navigation/ +│ │ │ │ │ └── AppNavigation.kt # Bottom nav, routing +│ │ │ │ │ +│ │ │ │ └── common/ +│ │ │ │ └── InitializationViews.kt # Loading/error states +│ │ │ │ +│ │ │ └── ui/theme/ +│ │ │ ├── Theme.kt # Material 3 theming +│ │ │ ├── AppColors.kt # Color palette +│ │ │ ├── Type.kt # Typography +│ │ │ └── Dimensions.kt # Spacing constants +│ │ │ +│ │ ├── res/ # Resources (icons, strings) +│ │ └── AndroidManifest.xml # Permissions, app config +│ │ +│ ├── src/test/ # Unit tests +│ └── src/androidTest/ # Instrumentation tests +│ +├── build.gradle.kts # Project build config +├── settings.gradle.kts # Module settings +└── README.md # This file +``` + +--- + +## Quick Start + +### Prerequisites + +- **Android Studio** Hedgehog (2023.1.1) or later +- **Android SDK** 24+ (Android 7.0 Nougat) +- **JDK** 17+ +- **Device/Emulator** with arm64-v8a architecture (recommended: physical device) +- **~2GB** free storage for AI models + +### Clone & Build + +```bash +# Clone the repository +git clone https://github.com/RunanywhereAI/runanywhere-sdks.git +cd runanywhere-sdks/examples/android/RunAnywhereAI + +# Build debug APK +./gradlew assembleDebug + +# Install on connected device +./gradlew installDebug +``` + +### Run via Android Studio + +1. Open the project in Android Studio +2. Wait for Gradle sync to complete +3. Select a physical device (arm64 recommended) or emulator +4. Click **Run** or press `Shift + F10` + +### Run via Command Line + +```bash +# Install and launch +./gradlew installDebug +adb shell am start -n com.runanywhere.runanywhereai.debug/.MainActivity +``` + +--- + +## SDK Integration Examples + +### Initialize the SDK + +The SDK is initialized in `RunAnywhereApplication.kt`: + +```kotlin +// Initialize SDK with development environment +RunAnywhere.initialize(environment = SDKEnvironment.DEVELOPMENT) + +// Complete services initialization (device registration) +RunAnywhere.completeServicesInitialization() + +// Register AI backends +LlamaCPP.register(priority = 100) // LLM backend (GGUF models) +ONNX.register(priority = 100) // STT/TTS backend + +// Register models +RunAnywhere.registerModel( + id = "smollm2-360m-q8_0", + name = "SmolLM2 360M Q8_0", + url = "https://huggingface.co/prithivMLmods/SmolLM2-360M-GGUF/...", + framework = InferenceFramework.LLAMA_CPP, + memoryRequirement = 500_000_000, +) +``` + +### Download & Load a Model + +```kotlin +// Download with progress tracking +RunAnywhere.downloadModel("smollm2-360m-q8_0").collect { progress -> + println("Download: ${(progress.progress * 100).toInt()}%") +} + +// Load into memory +RunAnywhere.loadLLMModel("smollm2-360m-q8_0") +``` + +### Stream Text Generation + +```kotlin +// Generate with streaming +RunAnywhere.generateStream(prompt).collect { token -> + // Display token in real-time + displayToken(token) +} + +// Or non-streaming +val result = RunAnywhere.generate(prompt) +println("Response: ${result.text}") +``` + +### Speech-to-Text + +```kotlin +// Load STT model +RunAnywhere.loadSTTModel("sherpa-onnx-whisper-tiny.en") + +// Transcribe audio bytes +val transcription = RunAnywhere.transcribe(audioBytes) +println("Transcription: $transcription") +``` + +### Text-to-Speech + +```kotlin +// Load TTS voice +RunAnywhere.loadTTSVoice("vits-piper-en_US-lessac-medium") + +// Synthesize speech +val result = RunAnywhere.synthesize(text, TTSOptions( + rate = 1.0f, + pitch = 1.0f, +)) +// result.audioData contains WAV audio bytes +``` + +### Voice Pipeline (STT → LLM → TTS) + +```kotlin +// Process voice through full pipeline +val result = RunAnywhere.processVoice(audioData) + +if (result.speechDetected) { + println("User said: ${result.transcription}") + println("AI response: ${result.response}") + // result.synthesizedAudio contains TTS audio +} +``` + +--- + +## Key Screens Explained + +### 1. Chat Screen (`ChatScreen.kt`) + +**What it demonstrates:** +- Streaming text generation with real-time token display +- Thinking mode support (`...` tags) +- Message analytics (tokens/sec, time to first token) +- Conversation history management +- Model selection bottom sheet integration + +**Key SDK APIs:** +- `RunAnywhere.generateStream()` — Streaming generation +- `RunAnywhere.generate()` — Non-streaming generation +- `RunAnywhere.cancelGeneration()` — Stop generation +- `EventBus.events.filterIsInstance()` — Listen for LLM events + +### 2. Speech-to-Text Screen (`SpeechToTextScreen.kt`) + +**What it demonstrates:** +- Batch mode: Record full audio, then transcribe +- Live mode: Real-time streaming transcription +- Audio level visualization +- Transcription metrics (confidence, RTF, word count) + +**Key SDK APIs:** +- `RunAnywhere.loadSTTModel()` — Load Whisper model +- `RunAnywhere.transcribe()` — Batch transcription +- `RunAnywhere.transcribeStream()` — Streaming transcription + +### 3. Text-to-Speech Screen (`TextToSpeechScreen.kt`) + +**What it demonstrates:** +- Neural voice synthesis with Piper TTS +- Speed and pitch controls +- Audio playback with progress +- Fun sample texts for testing + +**Key SDK APIs:** +- `RunAnywhere.loadTTSVoice()` — Load TTS model +- `RunAnywhere.synthesize()` — Generate speech audio +- `RunAnywhere.stopSynthesis()` — Cancel synthesis + +### 4. Voice Assistant Screen (`VoiceAssistantScreen.kt`) + +**What it demonstrates:** +- Complete voice AI pipeline +- Automatic speech detection with silence timeout +- Continuous conversation mode +- Model status tracking for all 3 components (STT, LLM, TTS) + +**Key SDK APIs:** +- `RunAnywhere.startVoiceSession()` — Start voice session +- `RunAnywhere.processVoice()` — Process audio through pipeline +- `RunAnywhere.voiceAgentComponentStates()` — Check component status + +### 5. Settings Screen (`SettingsScreen.kt`) + +**What it demonstrates:** +- Storage usage overview +- Downloaded model management +- Model deletion with confirmation +- Cache clearing + +**Key SDK APIs:** +- `RunAnywhere.storageInfo()` — Get storage details +- `RunAnywhere.deleteModel()` — Remove downloaded model +- `RunAnywhere.clearCache()` — Clear temporary files + +--- + +## Testing + +### Run Unit Tests + +```bash +./gradlew test +``` + +### Run Instrumentation Tests + +```bash +./gradlew connectedAndroidTest +``` + +### Run Lint & Static Analysis + +```bash +# Detekt static analysis +./gradlew detekt + +# ktlint formatting check +./gradlew ktlintCheck + +# Android lint +./gradlew lint +``` + +--- + +## Debugging + +### Enable Verbose Logging + +Filter logcat for RunAnywhere SDK logs: + +```bash +adb logcat -s "RunAnywhere:D" "RunAnywhereApp:D" "ChatViewModel:D" +``` + +### Common Log Tags + +| Tag | Description | +|-----|-------------| +| `RunAnywhereApp` | SDK initialization, model registration | +| `ChatViewModel` | LLM generation, streaming | +| `STTViewModel` | Speech transcription | +| `TTSViewModel` | Speech synthesis | +| `VoiceAssistantVM` | Voice pipeline | +| `ModelSelectionVM` | Model downloads, loading | + +### Memory Profiling + +1. Open Android Studio Profiler +2. Select your app process +3. Record memory allocations during model loading +4. Expected: ~300MB-2GB depending on model size + +--- + +## Configuration + +### Build Variants + +| Variant | Description | +|---------|-------------| +| `debug` | Development build with debugging enabled | +| `release` | Optimized build with R8/ProGuard | +| `benchmark` | Release-like build for performance testing | + +### Environment Variables (for release builds) + +```bash +export KEYSTORE_PATH=/path/to/keystore.jks +export KEYSTORE_PASSWORD=your_password +export KEY_ALIAS=your_alias +export KEY_PASSWORD=your_key_password +``` + +--- + +## Supported Models + +### LLM Models (LlamaCpp/GGUF) + +| Model | Size | Memory | Description | +|-------|------|--------|-------------| +| SmolLM2 360M Q8_0 | ~400MB | 500MB | Fast, lightweight chat | +| Qwen 2.5 0.5B Q6_K | ~500MB | 600MB | Multilingual, efficient | +| LFM2 350M Q4_K_M | ~200MB | 250MB | LiquidAI, ultra-compact | +| Llama 2 7B Chat Q4_K_M | ~4GB | 4GB | Powerful, larger model | +| Mistral 7B Instruct Q4_K_M | ~4GB | 4GB | High quality responses | + +### STT Models (ONNX/Whisper) + +| Model | Size | Description | +|-------|------|-------------| +| Sherpa Whisper Tiny (EN) | ~75MB | English transcription | + +### TTS Models (ONNX/Piper) + +| Model | Size | Description | +|-------|------|-------------| +| Piper US English (Medium) | ~65MB | Natural American voice | +| Piper British English (Medium) | ~65MB | British accent | + +--- + +## Known Limitations + +- **ARM64 Only** — Native libraries built for `arm64-v8a` only (x86 emulators not supported) +- **Memory Usage** — Large models (7B+) require devices with 6GB+ RAM +- **First Load** — Initial model loading takes 1-3 seconds (cached afterward) +- **Thermal Throttling** — Extended inference may trigger device throttling on some devices + +--- + +## Contributing + +See [CONTRIBUTING.md](../../../CONTRIBUTING.md) for guidelines. + +### Development Setup + +```bash +# Fork and clone +git clone https://github.com/YOUR_USERNAME/runanywhere-sdks.git +cd runanywhere-sdks/examples/android/RunAnywhereAI + +# Create feature branch +git checkout -b feature/your-feature + +# Make changes and test +./gradlew assembleDebug +./gradlew test +./gradlew detekt ktlintCheck + +# Commit and push +git commit -m "feat: your feature description" +git push origin feature/your-feature + +# Open Pull Request +``` + +--- + +## License + +This project is licensed under the Apache License 2.0 - see [LICENSE](../../../LICENSE) for details. + +--- + +## Support + +- **Discord**: [Join our community](https://discord.gg/N359FBbDVd) +- **GitHub Issues**: [Report bugs](https://github.com/RunanywhereAI/runanywhere-sdks/issues) +- **Email**: san@runanywhere.ai +- **Twitter**: [@RunanywhereAI](https://twitter.com/RunanywhereAI) + +--- + +## Related Documentation + +- [RunAnywhere Kotlin SDK](../../../sdk/runanywhere-kotlin/README.md) — Full SDK documentation +- [iOS Example App](../../ios/RunAnywhereAI/README.md) — iOS counterpart +- [React Native Example](../../react-native/RunAnywhereAI/README.md) — Cross-platform option +- [Main README](../../../README.md) — Project overview diff --git a/examples/android/RunAnywhereAI/app/.gitignore b/examples/android/RunAnywhereAI/app/.gitignore new file mode 100644 index 000000000..796b96d1c --- /dev/null +++ b/examples/android/RunAnywhereAI/app/.gitignore @@ -0,0 +1 @@ +/build diff --git a/examples/android/RunAnywhereAI/app/build.gradle.kts b/examples/android/RunAnywhereAI/app/build.gradle.kts new file mode 100644 index 000000000..06c26a526 --- /dev/null +++ b/examples/android/RunAnywhereAI/app/build.gradle.kts @@ -0,0 +1,382 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.detekt) + alias(libs.plugins.ktlint) +} + +android { + namespace = "com.runanywhere.runanywhereai" + compileSdk = 36 + + signingConfigs { + val keystorePath = System.getenv("KEYSTORE_PATH") + val keystorePassword = System.getenv("KEYSTORE_PASSWORD") + val keyAlias = System.getenv("KEY_ALIAS") + val keyPassword = System.getenv("KEY_PASSWORD") + + if (keystorePath != null && keystorePassword != null && keyAlias != null && keyPassword != null) { + create("release") { + storeFile = file(keystorePath) + storePassword = keystorePassword + this.keyAlias = keyAlias + this.keyPassword = keyPassword + } + } + } + + defaultConfig { + applicationId = "com.runanywhere.runanywhereai" + minSdk = 24 + targetSdk = 36 + versionCode = 13 + versionName = "0.1.4" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + // Native build disabled for now to focus on Kotlin implementation + // externalNativeBuild { + // cmake { + // cppFlags += listOf("-std=c++17", "-O3") + // arguments += listOf( + // "-DANDROID_STL=c++_shared", + // "-DBUILD_SHARED_LIBS=ON" + // ) + // } + // } + + ndk { + // Only arm64-v8a for now (RunAnywhere Core ONNX is built for arm64-v8a) + abiFilters += listOf("arm64-v8a") + } + } + + buildTypes { + debug { + isDebuggable = true + isMinifyEnabled = false + isShrinkResources = false + applicationIdSuffix = ".debug" + versionNameSuffix = "-debug" + + // Disable optimizations for faster builds + buildConfigField("boolean", "DEBUG_MODE", "true") + buildConfigField("String", "BUILD_TYPE", "\"debug\"") + } + + release { + // MUST be false for Play Store publishing + isDebuggable = false + isMinifyEnabled = true + isShrinkResources = true + + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + + // Build configuration fields + buildConfigField("boolean", "DEBUG_MODE", "false") + buildConfigField("String", "BUILD_TYPE", "\"release\"") + + // MUST be false for Play Store publishing + isJniDebuggable = false + + // Use release signing config if available + val releaseSigningConfig = signingConfigs.findByName("release") + if (releaseSigningConfig != null) { + signingConfig = releaseSigningConfig + } + } + + create("benchmark") { + initWith(getByName("release")) + matchingFallbacks += listOf("release") + isDebuggable = false + + // Additional optimizations for benchmarking + buildConfigField("boolean", "BENCHMARK_MODE", "true") + applicationIdSuffix = ".benchmark" + versionNameSuffix = "-benchmark" + } + } + + // Signing configurations + // Using default debug keystore for now + + // APK splits disabled for now to focus on basic functionality + // splits { + // abi { + // isEnable = true + // reset() + // include("armeabi-v7a", "arm64-v8a") // Focus on ARM architectures for mobile + // isUniversalApk = true // Also generate a universal APK + // } + // + // density { + // isEnable = true + // reset() + // include("ldpi", "mdpi", "hdpi", "xhdpi", "xxhdpi", "xxxhdpi") + // } + // } + + // Packaging options + packaging { + resources { + excludes += + listOf( + "/META-INF/{AL2.0,LGPL2.1}", + "/META-INF/DEPENDENCIES", + "/META-INF/LICENSE", + "/META-INF/LICENSE.txt", + "/META-INF/NOTICE", + "/META-INF/NOTICE.txt", + "/META-INF/licenses/**", + "/META-INF/AL2.0", + "/META-INF/LGPL2.1", + "**/kotlin/**", + "kotlin/**", + "META-INF/kotlin/**", + "META-INF/*.kotlin_module", + "META-INF/INDEX.LIST", + ) + } + + jniLibs { + // Use legacy packaging to extract libraries to filesystem + // This helps with symbol resolution for transitive dependencies + // CRITICAL: useLegacyPackaging = true is REQUIRED for 16KB page size support + // when using AGP < 8.5.1. With AGP 8.5.1+, this ensures proper extraction + // and 16KB alignment during packaging. + useLegacyPackaging = true + + // Handle duplicate native libraries from multiple backend modules + // (ONNX and LlamaCPP both include some common libraries) + pickFirsts += listOf( + "lib/arm64-v8a/libomp.so", + "lib/arm64-v8a/libc++_shared.so", + "lib/arm64-v8a/librac_commons.so", + "lib/armeabi-v7a/libomp.so", + "lib/armeabi-v7a/libc++_shared.so", + "lib/armeabi-v7a/librac_commons.so", + ) + } + } + + // Bundle configuration for Play Store + bundle { + language { + enableSplit = true + } + density { + enableSplit = true + } + abi { + enableSplit = true + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + + // Kotlin compiler optimizations + freeCompilerArgs += + listOf( + "-opt-in=kotlin.RequiresOptIn", + "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", + "-opt-in=androidx.compose.material3.ExperimentalMaterial3Api", + "-opt-in=androidx.compose.foundation.ExperimentalFoundationApi", + "-Xjvm-default=all", + ) + } + + buildFeatures { + compose = true + buildConfig = true + + // Disable unused features for smaller APK + aidl = false + renderScript = false + resValues = false + shaders = false + viewBinding = false + dataBinding = false + } + lint { + abortOnError = true + checkDependencies = true + warningsAsErrors = true + baseline = file("lint-baseline.xml") + lintConfig = file("lint.xml") + } + // Native build disabled for now to focus on Kotlin implementation + // externalNativeBuild { + // cmake { + // path = file("src/main/cpp/CMakeLists.txt") + // version = "3.22.1" + // } + // } +} + +dependencies { + // ======================================== + // SDK Dependencies + // ======================================== + // Main SDK - high-level APIs, download, routing (no native libs) + implementation(project(":sdk:runanywhere-kotlin")) + + // Backend modules - each is SELF-CONTAINED with all native libs + // Pick the backends you need: + implementation(project(":sdk:runanywhere-kotlin:modules:runanywhere-core-llamacpp")) // ~45MB - LLM text generation + implementation(project(":sdk:runanywhere-kotlin:modules:runanywhere-core-onnx")) // ~30MB - STT, TTS, VAD + + // ======================================== + // AndroidX Core & Lifecycle + // ======================================== + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.activity.compose) + + // ======================================== + // Material Design + // ======================================== + implementation(libs.material) + + // ======================================== + // Compose + // ======================================== + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + implementation(libs.androidx.material.icons.extended) + + // ======================================== + // Navigation + // ======================================== + implementation(libs.androidx.navigation.compose) + + // ======================================== + // Coroutines + // ======================================== + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.coroutines.android) + + // ======================================== + // Serialization & DateTime + // ======================================== + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.datetime) + + // ======================================== + // Networking + // ======================================== + implementation(libs.okhttp) + implementation(libs.okhttp.logging) + implementation(libs.retrofit) + implementation(libs.retrofit.gson) + implementation(libs.gson) + + // ======================================== + // File Management & Storage + // ======================================== + implementation(libs.commons.io) + + // ======================================== + // Background Work + // ======================================== + implementation(libs.androidx.work.runtime.ktx) + + // ======================================== + // Speech Recognition & Audio Processing + // ======================================== + implementation(libs.whisper.jni) + implementation(libs.android.vad.webrtc) + implementation(libs.prdownloader) + + // ======================================== + // Security + // ======================================== + implementation(libs.androidx.security.crypto) + + // ======================================== + // DataStore + // ======================================== + implementation(libs.androidx.datastore.preferences) + + // ======================================== + // Permissions + // ======================================== + implementation(libs.accompanist.permissions) + + // ======================================== + // Database + // ======================================== + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) + + // ======================================== + // Play Services (Updated for targetSdk 34+) + // ======================================== + implementation(libs.google.play.app.update) + implementation(libs.google.play.app.update.ktx) + + // ======================================== + // Logging + // ======================================== + implementation(libs.timber) + + // ======================================== + // Testing + // ======================================== + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.mockk) + + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) + + // ======================================== + // Kotlin Version Constraints + // ======================================== + constraints { + implementation("org.jetbrains.kotlin:kotlin-stdlib") { + version { + strictly(libs.versions.kotlin.get()) + } + } + implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7") { + version { + strictly(libs.versions.kotlin.get()) + } + } + implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8") { + version { + strictly(libs.versions.kotlin.get()) + } + } + implementation("org.jetbrains.kotlin:kotlin-reflect") { + version { + strictly(libs.versions.kotlin.get()) + } + } + } +} + +detekt { + config.setFrom("${project.rootDir}/detekt.yml") +} diff --git a/examples/android/RunAnywhereAI/app/detekt-baseline.xml b/examples/android/RunAnywhereAI/app/detekt-baseline.xml new file mode 100644 index 000000000..16199a608 --- /dev/null +++ b/examples/android/RunAnywhereAI/app/detekt-baseline.xml @@ -0,0 +1,31 @@ + + + + + FunctionNaming:ChatScreen.kt$@Composable fun MessageItem(message: ChatMessage) + FunctionNaming:ChatScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatScreen( viewModel: ChatViewModel = viewModel(), onNavigateToModels: () -> Unit ) + FunctionNaming:MainActivity.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun AboutScreen() + FunctionNaming:MainActivity.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun RunAnywhereApp() + FunctionNaming:ModelsScreen.kt$@Composable fun ModelCard( model: ModelInfo, isSelected: Boolean, downloadProgress: ModelDownloadProgress?, onSelect: () -> Unit, onDownload: () -> Unit, onDelete: () -> Unit ) + FunctionNaming:ModelsScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ModelsScreen( viewModel: ModelsViewModel = viewModel(), onNavigateBack: () -> Unit ) + FunctionNaming:Theme.kt$@Composable fun RunAnywhereAITheme( darkTheme: Boolean = isSystemInDarkTheme(), // Dynamic color is available on Android 12+ dynamicColor: Boolean = true, content: @Composable () -> Unit ) + LongParameterList:ModelsScreen.kt$( model: ModelInfo, isSelected: Boolean, downloadProgress: ModelDownloadProgress?, onSelect: () -> Unit, onDownload: () -> Unit, onDelete: () -> Unit ) + MagicNumber:Color.kt$0xFF625b71 + MagicNumber:Color.kt$0xFF6650a4 + MagicNumber:Color.kt$0xFF7D5260 + MagicNumber:Color.kt$0xFFCCC2DC + MagicNumber:Color.kt$0xFFD0BCFF + MagicNumber:Color.kt$0xFFEFB8C8 + MagicNumber:FormatUtils.kt$1000 + MagicNumber:FormatUtils.kt$1024 + MagicNumber:FormatUtils.kt$60 + MagicNumber:MediaPipeService.kt$MediaPipeService$0.8f + MagicNumber:MediaPipeService.kt$MediaPipeService$40 + MagicNumber:MediaPipeService.kt$MediaPipeService$42 + MagicNumber:MediaPipeService.kt$MediaPipeService$512 + MagicNumber:ModelRepository.kt$ModelRepository$8192 + MagicNumber:ModelsViewModel.kt$ModelsViewModel$2000 + MagicNumber:ONNXRuntimeService.kt$ONNXRuntimeService$4 + MagicNumber:ONNXRuntimeService.kt$ONNXRuntimeService.SimpleTokenizer$3L + + diff --git a/examples/android/RunAnywhereAI/app/lint-baseline.xml b/examples/android/RunAnywhereAI/app/lint-baseline.xml new file mode 100644 index 000000000..ebe1366b6 --- /dev/null +++ b/examples/android/RunAnywhereAI/app/lint-baseline.xml @@ -0,0 +1,2148 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/android/RunAnywhereAI/app/lint.xml b/examples/android/RunAnywhereAI/app/lint.xml new file mode 100644 index 000000000..b646c9065 --- /dev/null +++ b/examples/android/RunAnywhereAI/app/lint.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TODOs must reference a GitHub issue number (e.g., // TODO: #123 - Description) + + diff --git a/examples/android/RunAnywhereAI/app/proguard-rules.pro b/examples/android/RunAnywhereAI/app/proguard-rules.pro new file mode 100644 index 000000000..bb55b6cd4 --- /dev/null +++ b/examples/android/RunAnywhereAI/app/proguard-rules.pro @@ -0,0 +1,379 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile + +# ======================================================================================== +# RunAnywhere AI LLM Sample App - ProGuard Configuration +# ======================================================================================== + +# Keep line numbers for debugging +-keepattributes SourceFile,LineNumberTable,*Annotation*,Signature,InnerClasses,EnclosingMethod + +# ======================================================================================== +# LLM Framework Rules - Keep all LLM service implementations +# ======================================================================================== + +# Keep all LLM service classes and their methods +-keep class com.runanywhere.runanywhereai.llm.frameworks.** { *; } +-keep interface com.runanywhere.runanywhereai.llm.LLMService { *; } +-keep class com.runanywhere.runanywhereai.llm.** { *; } + +# Keep UnifiedLLMManager +-keep class com.runanywhere.runanywhereai.manager.UnifiedLLMManager { *; } + +# ======================================================================================== +# Data Models and DTOs +# ======================================================================================== + +# Keep all data classes used for serialization/deserialization +-keep @kotlinx.serialization.Serializable class ** { *; } +-keep class com.runanywhere.runanywhereai.data.** { *; } + +# Keep Room database entities and DAOs +-keep class com.runanywhere.runanywhereai.data.database.** { *; } +-keep @androidx.room.Entity class ** { *; } +-keep @androidx.room.Database class ** { *; } +-keep @androidx.room.Dao class ** { *; } + +# ======================================================================================== +# Native Libraries and JNI +# ======================================================================================== + +# Keep native methods +-keepclasseswithmembernames class * { + native ; +} + +# Keep classes that are used by native code +-keep class * { + native ; +} + +# ======================================================================================== +# RunAnywhere SDK - KEEP ENTIRE SDK (CRITICAL) +# ======================================================================================== +# The SDK uses dynamic registration, reflection-like patterns, and JNI callbacks. +# We must keep ALL classes, interfaces, enums, and their members to prevent R8/ProGuard +# from obfuscating or removing them. + +# MASTER RULE: Keep ALL classes in com.runanywhere.sdk package and all subpackages +-keep class com.runanywhere.sdk.** { *; } +-keep interface com.runanywhere.sdk.** { *; } +-keep enum com.runanywhere.sdk.** { *; } + +# Keep all constructors (critical for JNI object creation) +-keepclassmembers class com.runanywhere.sdk.** { + (...); +} + +# Keep companion objects and their members (Kotlin singletons like LlamaCppAdapter.shared) +-keepclassmembers class com.runanywhere.sdk.** { + public static ** Companion; + public static ** INSTANCE; + public static ** shared; +} + +# Keep Kotlin metadata for reflection +-keepattributes *Annotation*, Signature, InnerClasses, EnclosingMethod +-keep class kotlin.Metadata { *; } + +# Prevent obfuscation of class names (important for logging and debugging) +-keepnames class com.runanywhere.sdk.** { *; } +-keepnames interface com.runanywhere.sdk.** { *; } +-keepnames enum com.runanywhere.sdk.** { *; } + +# ======================================================================================== +# TensorFlow Lite +# ======================================================================================== + +# Keep TensorFlow Lite classes +-keep class org.tensorflow.lite.** { *; } +-keep class org.tensorflow.lite.support.** { *; } +-dontwarn org.tensorflow.lite.** + +# ======================================================================================== +# ONNX Runtime +# ======================================================================================== + +# Keep ONNX Runtime classes +-keep class ai.onnxruntime.** { *; } +-dontwarn ai.onnxruntime.** + +# ======================================================================================== +# MediaPipe +# ======================================================================================== + +# Keep MediaPipe classes +-keep class com.google.mediapipe.** { *; } +-dontwarn com.google.mediapipe.** + +# ======================================================================================== +# Llama.cpp (via JNI) +# ======================================================================================== + +# Keep llama.cpp JNI interfaces +-keep class ai.djl.llama.jni.** { *; } +-dontwarn ai.djl.llama.jni.** + +# ======================================================================================== +# ExecuTorch +# ======================================================================================== + +# Keep ExecuTorch classes when available +-keep class org.pytorch.executorch.** { *; } +-dontwarn org.pytorch.executorch.** + +# ======================================================================================== +# MLC-LLM +# ======================================================================================== + +# Keep MLC-LLM classes +-keep class ai.mlc.mlcllm.** { *; } +-dontwarn ai.mlc.mlcllm.** + +# ======================================================================================== +# picoLLM +# ======================================================================================== + +# Keep picoLLM classes when available +-keep class ai.picovoice.picollm.** { *; } +-dontwarn ai.picovoice.picollm.** + +# ======================================================================================== +# Android AI Core +# ======================================================================================== + +# Keep Android AI Core classes +-keep class com.google.android.aicore.** { *; } +-dontwarn com.google.android.aicore.** + +# ======================================================================================== +# Kotlin Coroutines +# ======================================================================================== + +# Keep coroutines +-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {} +-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {} +-dontwarn kotlinx.coroutines.** + +# ======================================================================================== +# Compose and UI +# ======================================================================================== + +# Keep Compose runtime classes +-keep class androidx.compose.** { *; } +-dontwarn androidx.compose.** + +# Keep ViewModel classes +-keep class androidx.lifecycle.ViewModel { *; } +-keep class * extends androidx.lifecycle.ViewModel { *; } + +# ======================================================================================== +# Hilt/Dagger +# ======================================================================================== + +# Keep Hilt generated classes +-keep class dagger.hilt.** { *; } +-keep class * extends dagger.hilt.android.internal.managers.ApplicationComponentManager { *; } +-keep class **_HiltModules { *; } +-keep class **_HiltComponents { *; } +-keep class **_Factory { *; } +-keep class **_MembersInjector { *; } + +# ======================================================================================== +# Security and Encryption +# ======================================================================================== + +# Keep encryption classes +-keep class com.runanywhere.runanywhereai.security.** { *; } +-keep class androidx.security.crypto.** { *; } + +# Keep Android Keystore classes +-keep class android.security.keystore.** { *; } +-dontwarn android.security.keystore.** + +# ======================================================================================== +# JSON and Serialization +# ======================================================================================== + +# Keep JSON classes +-keep class org.json.** { *; } +-dontwarn org.json.** + +# Keep Gson classes if used +-keep class com.google.gson.** { *; } +-dontwarn com.google.gson.** + +# ======================================================================================== +# Model Files and Assets +# ======================================================================================== + +# Keep model files in assets +# Note: -keepresourcefiles is not supported in R8, resources are kept by default +# -keepresourcefiles assets/models/** +# -keepresourcefiles assets/tokenizers/** + +# Don't obfuscate model loading code +-keep class com.runanywhere.runanywhereai.data.repository.ModelRepository { *; } + +# ======================================================================================== +# Performance and Monitoring +# ======================================================================================== + +# Keep performance monitoring classes +-keep class com.runanywhere.runanywhereai.monitoring.** { *; } + +# ======================================================================================== +# Reflection +# ======================================================================================== + +# Keep classes that use reflection +-keepattributes *Annotation* +-keepclassmembers class * { + @androidx.annotation.Keep *; +} + +# Keep enums +-keepclassmembers enum * { + public static **[] values(); + public static ** valueOf(java.lang.String); +} + +# ======================================================================================== +# Common Android Rules +# ======================================================================================== + +# Keep custom views +-keep public class * extends android.view.View { + public (android.content.Context); + public (android.content.Context, android.util.AttributeSet); + public (android.content.Context, android.util.AttributeSet, int); +} + +# Keep Parcelable implementations +-keep class * implements android.os.Parcelable { + public static final android.os.Parcelable$Creator *; +} + +# ======================================================================================== +# Warnings to Ignore +# ======================================================================================== + +# Ignore warnings for optional dependencies +-dontwarn java.awt.** +-dontwarn javax.swing.** +-dontwarn sun.misc.** +-dontwarn java.lang.management.** +-dontwarn org.slf4j.** +-dontwarn ch.qos.logback.** + +# Ignore warnings for reflection-based libraries +-dontwarn kotlin.reflect.** +-dontwarn org.jetbrains.annotations.** + +# ======================================================================================== +# R8 Generated Missing Rules +# ======================================================================================== + +# Apache Commons Lang3 (uses Java 8+ reflection API not available on Android) +-dontwarn java.lang.reflect.AnnotatedType + +# Zstd compression library +-dontwarn com.github.luben.zstd.ZstdInputStream +-dontwarn com.github.luben.zstd.ZstdOutputStream + +# Google API Client HTTP library +-dontwarn com.google.api.client.http.GenericUrl +-dontwarn com.google.api.client.http.HttpHeaders +-dontwarn com.google.api.client.http.HttpRequest +-dontwarn com.google.api.client.http.HttpRequestFactory +-dontwarn com.google.api.client.http.HttpResponse +-dontwarn com.google.api.client.http.HttpTransport +-dontwarn com.google.api.client.http.javanet.NetHttpTransport$Builder +-dontwarn com.google.api.client.http.javanet.NetHttpTransport + +# Apache Commons codec +-dontwarn org.apache.commons.codec.digest.PureJavaCrc32C +-dontwarn org.apache.commons.codec.digest.XXHash32 + +# Brotli decompression +-dontwarn org.brotli.dec.BrotliInputStream + +# Joda time +-dontwarn org.joda.time.Instant + +# ASM (bytecode manipulation) +-dontwarn org.objectweb.asm.AnnotationVisitor +-dontwarn org.objectweb.asm.Attribute +-dontwarn org.objectweb.asm.ClassReader +-dontwarn org.objectweb.asm.ClassVisitor +-dontwarn org.objectweb.asm.FieldVisitor +-dontwarn org.objectweb.asm.MethodVisitor + +# XZ compression library +-dontwarn org.tukaani.xz.ARMOptions +-dontwarn org.tukaani.xz.ARMThumbOptions +-dontwarn org.tukaani.xz.DeltaOptions +-dontwarn org.tukaani.xz.FilterOptions +-dontwarn org.tukaani.xz.FinishableOutputStream +-dontwarn org.tukaani.xz.FinishableWrapperOutputStream +-dontwarn org.tukaani.xz.IA64Options +-dontwarn org.tukaani.xz.LZMA2InputStream +-dontwarn org.tukaani.xz.LZMA2Options +-dontwarn org.tukaani.xz.LZMAInputStream +-dontwarn org.tukaani.xz.LZMAOutputStream +-dontwarn org.tukaani.xz.MemoryLimitException +-dontwarn org.tukaani.xz.PowerPCOptions +-dontwarn org.tukaani.xz.SPARCOptions +-dontwarn org.tukaani.xz.UnsupportedOptionsException +-dontwarn org.tukaani.xz.X86Options +-dontwarn org.tukaani.xz.XZ +-dontwarn org.tukaani.xz.XZOutputStream + +# ======================================================================================== +# Debug Information (Comment out for release builds) +# ======================================================================================== + +# Keep debug information for crash reporting +-keepattributes SourceFile,LineNumberTable + +# ======================================================================================== +# Logging - Keep Log statements for debugging release builds +# ======================================================================================== + +# Keep all android.util.Log methods (do NOT strip logs in release for debugging) +-assumenosideeffects class android.util.Log { + # Comment out these lines to KEEP logs in release builds + # public static int v(...); + # public static int d(...); + # public static int i(...); + # public static int w(...); + # public static int e(...); +} + +# Keep Timber logging if used +-keep class timber.log.Timber { *; } +-keep class timber.log.Timber$* { *; } + +# Print configuration for debugging (remove in final release) +#-printconfiguration proguard-config.txt +#-printusage proguard-usage.txt +#-printmapping proguard-mapping.txt diff --git a/examples/android/RunAnywhereAI/app/src/androidTest/java/com/runanywhere/runanywhereai/ExampleInstrumentedTest.kt b/examples/android/RunAnywhereAI/app/src/androidTest/java/com/runanywhere/runanywhereai/ExampleInstrumentedTest.kt new file mode 100644 index 000000000..b2756f4d5 --- /dev/null +++ b/examples/android/RunAnywhereAI/app/src/androidTest/java/com/runanywhere/runanywhereai/ExampleInstrumentedTest.kt @@ -0,0 +1,22 @@ +package com.runanywhere.runanywhereai + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.runanywhere.runanywhereai", appContext.packageName) + } +} diff --git a/examples/android/RunAnywhereAI/app/src/main/AndroidManifest.xml b/examples/android/RunAnywhereAI/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000..05144f96f --- /dev/null +++ b/examples/android/RunAnywhereAI/app/src/main/AndroidManifest.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/MainActivity.kt b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/MainActivity.kt new file mode 100644 index 000000000..ce6545f7d --- /dev/null +++ b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/MainActivity.kt @@ -0,0 +1,96 @@ +package com.runanywhere.runanywhereai + +import android.os.Bundle +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import com.runanywhere.runanywhereai.presentation.common.InitializationErrorView +import com.runanywhere.runanywhereai.presentation.common.InitializationLoadingView +import com.runanywhere.runanywhereai.presentation.navigation.AppNavigation +import com.runanywhere.runanywhereai.ui.theme.RunAnywhereAITheme +import kotlinx.coroutines.launch + +/** + * Main Activity for RunAnywhere AI app. + * Matches iOS RunAnywhereAIApp.swift pattern exactly: + * - Shows InitializationLoadingView while SDK initializes + * - Shows InitializationErrorView if initialization fails (with retry) + * - Shows ContentView (AppNavigation) when SDK is ready + */ +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Setup edge-to-edge display + enableEdgeToEdge() + + setContent { + RunAnywhereAITheme { + MainAppContent() + } + } + } + + /** + * Main content composable with initialization state handling. + * Matches iOS pattern: + * - if isSDKInitialized -> ContentView + * - else if initializationError -> InitializationErrorView + * - else -> InitializationLoadingView + */ + @Composable + private fun MainAppContent() { + val app = application as RunAnywhereApplication + val initState by app.initializationState.collectAsState() + val scope = rememberCoroutineScope() + + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + when (initState) { + is SDKInitializationState.Loading -> { + InitializationLoadingView() + } + + is SDKInitializationState.Error -> { + val error = (initState as SDKInitializationState.Error).error + InitializationErrorView( + error = error, + onRetry = { + scope.launch { + app.retryInitialization() + } + }, + ) + } + + is SDKInitializationState.Ready -> { + Log.i("MainActivity", "App is ready to use!") + AppNavigation() + } + } + } + } + + override fun onResume() { + super.onResume() + // Resume any active voice sessions if needed + // TODO: Implement when voice pipeline service is available + } + + override fun onPause() { + super.onPause() + // Pause voice sessions to save battery + // TODO: Implement when voice pipeline service is available + } +} diff --git a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/RunAnywhereApplication.kt b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/RunAnywhereApplication.kt new file mode 100644 index 000000000..cd26439e2 --- /dev/null +++ b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/RunAnywhereApplication.kt @@ -0,0 +1,348 @@ +package com.runanywhere.runanywhereai + +import android.app.Application +import android.os.Handler +import android.os.Looper +import android.util.Log +import com.runanywhere.runanywhereai.presentation.settings.SettingsViewModel +import com.runanywhere.sdk.core.onnx.ONNX +import com.runanywhere.sdk.core.types.InferenceFramework +import com.runanywhere.sdk.llm.llamacpp.LlamaCPP +import com.runanywhere.sdk.public.RunAnywhere +import com.runanywhere.sdk.public.SDKEnvironment +import com.runanywhere.sdk.public.extensions.Models.ModelCategory +import com.runanywhere.sdk.public.extensions.registerModel +import com.runanywhere.sdk.storage.AndroidPlatformContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Represents the SDK initialization state. + * Matches iOS pattern: isSDKInitialized + initializationError conditional rendering. + */ +sealed class SDKInitializationState { + /** SDK is currently initializing */ + data object Loading : SDKInitializationState() + + /** SDK initialized successfully */ + data object Ready : SDKInitializationState() + + /** SDK initialization failed */ + data class Error(val error: Throwable) : SDKInitializationState() +} + +class RunAnywhereApplication : Application() { + companion object { + private var instance: RunAnywhereApplication? = null + + /** Get the application instance */ + fun getInstance(): RunAnywhereApplication = instance ?: throw IllegalStateException("Application not initialized") + } + + /** + * Application-scoped CoroutineScope for SDK initialization and background work. + * Uses SupervisorJob to prevent failures in one coroutine from affecting others. + * This replaces GlobalScope to ensure proper lifecycle management. + */ + private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + + @Volatile + private var isSDKInitialized = false + + @Volatile + private var initializationError: Throwable? = null + + /** Observable SDK initialization state for Compose UI - matches iOS pattern */ + private val _initializationState = MutableStateFlow(SDKInitializationState.Loading) + val initializationState: StateFlow = _initializationState.asStateFlow() + + override fun onCreate() { + super.onCreate() + instance = this + + Log.i("RunAnywhereApp", "🏁 App launched, initializing SDK...") + + // Post initialization to main thread's message queue to ensure system is ready + // This prevents crashes on devices where device-encrypted storage hasn't mounted yet + Handler(Looper.getMainLooper()).postDelayed({ + // Initialize SDK asynchronously using application-scoped coroutine + applicationScope.launch(Dispatchers.IO) { + try { + // Additional small delay to ensure storage is mounted + delay(200) + initializeSDK() + } catch (e: Exception) { + Log.e("RunAnywhereApp", "❌ Fatal error during SDK initialization: ${e.message}", e) + // Don't crash the app - let it continue without SDK + } + } + }, 100) // 100ms delay to let system mount storage + } + + override fun onTerminate() { + // Cancel all coroutines when app terminates + applicationScope.cancel() + super.onTerminate() + } + + private suspend fun initializeSDK() { + initializationError = null + Log.i("RunAnywhereApp", "🎯 Starting SDK initialization...") + Log.w("RunAnywhereApp", "=======================================================") + Log.w("RunAnywhereApp", "🔍 BUILD INFO - CHECK THIS FOR ANALYTICS DEBUGGING:") + Log.w("RunAnywhereApp", " BuildConfig.DEBUG = ${BuildConfig.DEBUG}") + Log.w("RunAnywhereApp", " BuildConfig.DEBUG_MODE = ${BuildConfig.DEBUG_MODE}") + Log.w("RunAnywhereApp", " BuildConfig.BUILD_TYPE = ${BuildConfig.BUILD_TYPE}") + Log.w("RunAnywhereApp", " Package name = ${applicationContext.packageName}") + Log.w("RunAnywhereApp", "=======================================================") + + val startTime = System.currentTimeMillis() + + // Check for custom API configuration (stored via Settings screen) + val customApiKey = SettingsViewModel.getStoredApiKey(this@RunAnywhereApplication) + val customBaseURL = SettingsViewModel.getStoredBaseURL(this@RunAnywhereApplication) + val hasCustomConfig = customApiKey != null && customBaseURL != null + + if (hasCustomConfig) { + Log.i("RunAnywhereApp", "🔧 Found custom API configuration") + Log.i("RunAnywhereApp", " Base URL: $customBaseURL") + } + + // Determine environment based on DEBUG_MODE (NOT BuildConfig.DEBUG!) + // BuildConfig.DEBUG is tied to isDebuggable flag, which we set to true for release builds + // to allow logging. BuildConfig.DEBUG_MODE correctly reflects debug vs release build type. + val defaultEnvironment = + if (BuildConfig.DEBUG_MODE) { + SDKEnvironment.DEVELOPMENT + } else { + SDKEnvironment.PRODUCTION + } + + // If custom config is set, use production environment to enable the custom backend + val environment = if (hasCustomConfig) SDKEnvironment.PRODUCTION else defaultEnvironment + + // Initialize platform context first + AndroidPlatformContext.initialize(this@RunAnywhereApplication) + + // Try to initialize SDK - log failures but continue regardless + try { + if (hasCustomConfig) { + // Custom configuration mode - use stored API key and base URL + RunAnywhere.initialize( + apiKey = customApiKey!!, + baseURL = customBaseURL!!, + environment = environment, + ) + Log.i("RunAnywhereApp", "✅ SDK initialized with CUSTOM configuration (${environment.name.lowercase()})") + } else if (environment == SDKEnvironment.DEVELOPMENT) { + // DEVELOPMENT mode: Don't pass baseURL - SDK uses Supabase URL from C++ dev config + RunAnywhere.initialize( + environment = SDKEnvironment.DEVELOPMENT, + ) + Log.i("RunAnywhereApp", "✅ SDK initialized in DEVELOPMENT mode (using Supabase from dev config)") + } else { + // PRODUCTION mode - requires API key and base URL + // Configure these via Settings screen or set environment variables + val apiKey = "YOUR_API_KEY_HERE" + val baseURL = "YOUR_BASE_URL_HERE" + + // Detect placeholder credentials and abort production initialization + if (apiKey.startsWith("YOUR_") || baseURL.startsWith("YOUR_")) { + Log.e( + "RunAnywhereApp", + "❌ RunAnywhere.initialize with SDKEnvironment.PRODUCTION failed: " + + "placeholder credentials detected. Configure via Settings screen or replace placeholders.", + ) + // Fall back to development mode + RunAnywhere.initialize(environment = SDKEnvironment.DEVELOPMENT) + Log.i("RunAnywhereApp", "✅ SDK initialized in DEVELOPMENT mode (production credentials not configured)") + } else { + RunAnywhere.initialize( + apiKey = apiKey, + baseURL = baseURL, + environment = SDKEnvironment.PRODUCTION, + ) + Log.i("RunAnywhereApp", "✅ SDK initialized in PRODUCTION mode") + } + } + + // Phase 2: Complete services initialization (device registration, etc.) + // This triggers device registration with the backend + kotlinx.coroutines.runBlocking { + RunAnywhere.completeServicesInitialization() + } + Log.i("RunAnywhereApp", "✅ SDK services initialization complete (device registered)") + } catch (e: Exception) { + // Log the failure but continue + Log.w("RunAnywhereApp", "⚠️ SDK initialization failed (backend may be unavailable): ${e.message}") + initializationError = e + + // Fall back to development mode + try { + // Don't pass baseURL - SDK uses Supabase URL from C++ dev config + RunAnywhere.initialize( + environment = SDKEnvironment.DEVELOPMENT, + ) + Log.i("RunAnywhereApp", "✅ SDK initialized in OFFLINE mode (local models only)") + + // Still try Phase 2 in offline mode + kotlinx.coroutines.runBlocking { + RunAnywhere.completeServicesInitialization() + } + } catch (fallbackError: Exception) { + Log.e("RunAnywhereApp", "❌ Fallback initialization also failed: ${fallbackError.message}") + } + } + + // Register modules and models (matching iOS registerModulesAndModels pattern) + registerModulesAndModels() + + Log.i("RunAnywhereApp", "✅ SDK initialization complete") + + val initTime = System.currentTimeMillis() - startTime + Log.i("RunAnywhereApp", "✅ SDK setup completed in ${initTime}ms") + Log.i("RunAnywhereApp", "🎯 SDK Status: Active=${RunAnywhere.isInitialized}") + + isSDKInitialized = RunAnywhere.isInitialized + + // Update observable state for Compose UI - matches iOS conditional rendering + if (isSDKInitialized) { + _initializationState.value = SDKInitializationState.Ready + Log.i("RunAnywhereApp", "🎉 App is ready to use!") + } else if (initializationError != null) { + _initializationState.value = SDKInitializationState.Error(initializationError!!) + } else { + // SDK reported not initialized but no error - treat as ready for offline mode + _initializationState.value = SDKInitializationState.Ready + Log.i("RunAnywhereApp", "🎉 App is ready to use (offline mode)!") + } + } + + /** + * Get SDK initialization status + */ + fun isSDKReady(): Boolean = isSDKInitialized + + /** + * Get initialization error if any + */ + fun getInitializationError(): Throwable? = initializationError + + /** + * Retry SDK initialization - matches iOS retryInitialization() pattern + */ + suspend fun retryInitialization() { + _initializationState.value = SDKInitializationState.Loading + withContext(Dispatchers.IO) { + initializeSDK() + } + } + + /** + * Register modules with their associated models. + * Each module explicitly owns its models - the framework is determined by the module. + * + * Mirrors iOS RunAnywhereAIApp.registerModulesAndModels() exactly. + * + * Backend registration MUST happen before model registration. + * This follows the same pattern as iOS where backends are registered first. + */ + @Suppress("LongMethod") + private fun registerModulesAndModels() { + Log.i("RunAnywhereApp", "📦 Registering backends and models...") + + // Register backends first (matching iOS pattern) + // These call the C++ rac_backend_xxx_register() functions via JNI + Log.i("RunAnywhereApp", "🔧 Registering LlamaCPP backend...") + LlamaCPP.register(priority = 100) + + Log.i("RunAnywhereApp", "🔧 Registering ONNX backend...") + ONNX.register(priority = 100) + + Log.i("RunAnywhereApp", "✅ Backends registered, now registering models...") + + // Register LLM models using the new RunAnywhere.registerModel API + // Using explicit IDs ensures models are recognized after download across app restarts + RunAnywhere.registerModel( + id = "smollm2-360m-q8_0", + name = "SmolLM2 360M Q8_0", + url = "https://huggingface.co/prithivMLmods/SmolLM2-360M-GGUF/resolve/main/SmolLM2-360M.Q8_0.gguf", + framework = InferenceFramework.LLAMA_CPP, + memoryRequirement = 500_000_000, + ) + RunAnywhere.registerModel( + id = "llama-2-7b-chat-q4_k_m", + name = "Llama 2 7B Chat Q4_K_M", + url = "https://huggingface.co/TheBloke/Llama-2-7B-Chat-GGUF/resolve/main/llama-2-7b-chat.Q4_K_M.gguf", + framework = InferenceFramework.LLAMA_CPP, + memoryRequirement = 4_000_000_000, + ) + RunAnywhere.registerModel( + id = "mistral-7b-instruct-q4_k_m", + name = "Mistral 7B Instruct Q4_K_M", + url = "https://huggingface.co/TheBloke/Mistral-7B-Instruct-v0.1-GGUF/resolve/main/mistral-7b-instruct-v0.1.Q4_K_M.gguf", + framework = InferenceFramework.LLAMA_CPP, + memoryRequirement = 4_000_000_000, + ) + RunAnywhere.registerModel( + id = "qwen2.5-0.5b-instruct-q6_k", + name = "Qwen 2.5 0.5B Instruct Q6_K", + url = "https://huggingface.co/Triangle104/Qwen2.5-0.5B-Instruct-Q6_K-GGUF/resolve/main/qwen2.5-0.5b-instruct-q6_k.gguf", + framework = InferenceFramework.LLAMA_CPP, + memoryRequirement = 600_000_000, + ) + RunAnywhere.registerModel( + id = "lfm2-350m-q4_k_m", + name = "LiquidAI LFM2 350M Q4_K_M", + url = "https://huggingface.co/LiquidAI/LFM2-350M-GGUF/resolve/main/LFM2-350M-Q4_K_M.gguf", + framework = InferenceFramework.LLAMA_CPP, + memoryRequirement = 250_000_000, + ) + RunAnywhere.registerModel( + id = "lfm2-350m-q8_0", + name = "LiquidAI LFM2 350M Q8_0", + url = "https://huggingface.co/LiquidAI/LFM2-350M-GGUF/resolve/main/LFM2-350M-Q8_0.gguf", + framework = InferenceFramework.LLAMA_CPP, + memoryRequirement = 400_000_000, + ) + Log.i("RunAnywhereApp", "✅ LLM models registered") + + // Register ONNX STT and TTS models + // Using tar.gz format hosted on RunanywhereAI/sherpa-onnx for fast native extraction + RunAnywhere.registerModel( + id = "sherpa-onnx-whisper-tiny.en", + name = "Sherpa Whisper Tiny (ONNX)", + url = "https://github.com/RunanywhereAI/sherpa-onnx/releases/download/runanywhere-models-v1/sherpa-onnx-whisper-tiny.en.tar.gz", + framework = InferenceFramework.ONNX, + modality = ModelCategory.SPEECH_RECOGNITION, + memoryRequirement = 75_000_000, + ) + RunAnywhere.registerModel( + id = "vits-piper-en_US-lessac-medium", + name = "Piper TTS (US English - Medium)", + url = "https://github.com/RunanywhereAI/sherpa-onnx/releases/download/runanywhere-models-v1/vits-piper-en_US-lessac-medium.tar.gz", + framework = InferenceFramework.ONNX, + modality = ModelCategory.SPEECH_SYNTHESIS, + memoryRequirement = 65_000_000, + ) + RunAnywhere.registerModel( + id = "vits-piper-en_GB-alba-medium", + name = "Piper TTS (British English)", + url = "https://github.com/RunanywhereAI/sherpa-onnx/releases/download/runanywhere-models-v1/vits-piper-en_GB-alba-medium.tar.gz", + framework = InferenceFramework.ONNX, + modality = ModelCategory.SPEECH_SYNTHESIS, + memoryRequirement = 65_000_000, + ) + Log.i("RunAnywhereApp", "✅ ONNX STT/TTS models registered") + + Log.i("RunAnywhereApp", "🎉 All modules and models registered") + } +} diff --git a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/data/ConversationStore.kt b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/data/ConversationStore.kt new file mode 100644 index 000000000..201dc6f9d --- /dev/null +++ b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/data/ConversationStore.kt @@ -0,0 +1,264 @@ +package com.runanywhere.runanywhereai.data + +import android.annotation.SuppressLint +import android.content.Context +import android.util.Log +import com.runanywhere.runanywhereai.domain.models.ChatMessage +import com.runanywhere.runanywhereai.domain.models.Conversation +import com.runanywhere.runanywhereai.domain.models.MessageRole +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.io.File +import java.util.* + +/** + * ConversationStore for Android - Exact match with iOS ConversationStore + * Handles conversation persistence, management, and search + */ +class ConversationStore private constructor(context: Context) { + companion object { + @SuppressLint("StaticFieldLeak") + @Volatile + private var instance: ConversationStore? = null + + fun getInstance(context: Context): ConversationStore { + return instance ?: synchronized(this) { + instance ?: ConversationStore(context.applicationContext).also { instance = it } + } + } + } + + // Store application context to avoid memory leaks + private val context: Context = context.applicationContext + + private val _conversations = MutableStateFlow>(emptyList()) + val conversations: StateFlow> = _conversations.asStateFlow() + + private val _currentConversation = MutableStateFlow(null) + val currentConversation: StateFlow = _currentConversation.asStateFlow() + + private val conversationsDirectory: File + private val json = + Json { + prettyPrint = true + ignoreUnknownKeys = true + } + + init { + conversationsDirectory = File(context.filesDir, "Conversations") + if (!conversationsDirectory.exists()) { + conversationsDirectory.mkdirs() + } + loadConversations() + } + + // MARK: - Public Methods + + /** + * Create a new conversation + */ + fun createConversation(title: String? = null): Conversation { + val conversation = + Conversation( + id = UUID.randomUUID().toString(), + title = title ?: "New Chat", + messages = emptyList(), + createdAt = System.currentTimeMillis(), + updatedAt = System.currentTimeMillis(), + modelName = null, + analytics = null, + performanceSummary = null, + ) + + val updated = _conversations.value.toMutableList() + updated.add(0, conversation) + _conversations.value = updated + _currentConversation.value = conversation + + saveConversation(conversation) + return conversation + } + + /** + * Update an existing conversation + */ + fun updateConversation(conversation: Conversation) { + val updated = conversation.copy(updatedAt = System.currentTimeMillis()) + + val index = _conversations.value.indexOfFirst { it.id == conversation.id } + if (index != -1) { + val list = _conversations.value.toMutableList() + list[index] = updated + _conversations.value = list + + if (_currentConversation.value?.id == conversation.id) { + _currentConversation.value = updated + } + + saveConversation(updated) + } + } + + /** + * Delete a conversation + */ + fun deleteConversation(conversation: Conversation) { + _conversations.value = _conversations.value.filter { it.id != conversation.id } + + if (_currentConversation.value?.id == conversation.id) { + _currentConversation.value = _conversations.value.firstOrNull() + } + + // Delete file + val file = conversationFileURL(conversation.id) + if (file.exists()) { + file.delete() + } + } + + /** + * Add a message to a conversation + */ + fun addMessage( + message: ChatMessage, + conversation: Conversation, + ) { + val updatedMessages = conversation.messages.toMutableList() + updatedMessages.add(message) + + var updated = + conversation.copy( + messages = updatedMessages, + updatedAt = System.currentTimeMillis(), + ) + + // Auto-generate title from first user message if needed + if (updated.title == "New Chat" && message.role == MessageRole.USER && message.content.isNotEmpty()) { + updated = updated.copy(title = generateTitle(message.content)) + } + + updateConversation(updated) + } + + /** + * Load a conversation by ID + */ + fun loadConversation(id: String): Conversation? { + val conversation = _conversations.value.firstOrNull { it.id == id } + if (conversation != null) { + _currentConversation.value = conversation + return conversation + } + + // Try to load from disk + val file = conversationFileURL(id) + if (file.exists()) { + try { + val jsonString = file.readText() + val loaded = json.decodeFromString(jsonString) + val list = _conversations.value.toMutableList() + list.add(loaded) + _conversations.value = list + _currentConversation.value = loaded + return loaded + } catch (e: Exception) { + Log.e("ConversationStore", "Failed to load conversation from disk", e) + } + } + + return null + } + + /** + * Search conversations + */ + fun searchConversations(query: String): List { + if (query.isEmpty()) return _conversations.value + + val lowercaseQuery = query.lowercase() + + return _conversations.value.filter { conversation -> + // Search in title + if (conversation.title?.lowercase()?.contains(lowercaseQuery) == true) { + return@filter true + } + + // Search in messages + conversation.messages.any { message -> + message.content.lowercase().contains(lowercaseQuery) + } + } + } + + // MARK: - Private Methods + + /** + * Load all conversations from disk + */ + private fun loadConversations() { + try { + val files = + conversationsDirectory.listFiles { file -> + file.extension == "json" + } ?: emptyArray() + + val loaded = + files.mapNotNull { file -> + try { + val jsonString = file.readText() + json.decodeFromString(jsonString) + } catch (e: Exception) { + Log.e("ConversationStore", "Failed to load conversation: ${file.name}", e) + null + } + } + + // Sort by update date, newest first + _conversations.value = loaded.sortedByDescending { it.updatedAt } + + // Don't automatically set current conversation - let ChatViewModel create a new one + } catch (e: Exception) { + Log.e("ConversationStore", "Failed to load conversations", e) + } + } + + /** + * Save a conversation to disk + */ + private fun saveConversation(conversation: Conversation) { + try { + val file = conversationFileURL(conversation.id) + val jsonString = json.encodeToString(conversation) + file.writeText(jsonString) + } catch (e: Exception) { + Log.e("ConversationStore", "Failed to save conversation", e) + } + } + + /** + * Get file URL for a conversation + */ + private fun conversationFileURL(id: String): File { + return File(conversationsDirectory, "$id.json") + } + + /** + * Generate title from message content + */ + private fun generateTitle(content: String): String { + val maxLength = 50 + val cleaned = content.trim() + + val newlineIndex = cleaned.indexOf('\n') + if (newlineIndex != -1) { + val firstLine = cleaned.substring(0, newlineIndex) + return firstLine.take(maxLength) + } + + return cleaned.take(maxLength) + } +} diff --git a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/domain/models/ChatMessage.kt b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/domain/models/ChatMessage.kt new file mode 100644 index 000000000..5efebbc73 --- /dev/null +++ b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/domain/models/ChatMessage.kt @@ -0,0 +1,233 @@ +package com.runanywhere.runanywhereai.domain.models + +import kotlinx.serialization.Serializable +import java.util.UUID + +/** + * App-local message role enum. + * Matches iOS MessageRole exactly. + */ +@Serializable +enum class MessageRole { + USER, + ASSISTANT, + SYSTEM, + ; + + val displayName: String + get() = + when (this) { + USER -> "User" + ASSISTANT -> "Assistant" + SYSTEM -> "System" + } +} + +/** + * App-local completion status enum. + * Matches iOS CompletionStatus exactly. + */ +@Serializable +enum class CompletionStatus { + COMPLETE, + STREAMING, + INTERRUPTED, + ERROR, +} + +/** + * App-local generation mode enum. + * Matches iOS GenerationMode exactly. + */ +@Serializable +enum class GenerationMode { + STREAMING, + NON_STREAMING, +} + +/** + * App-local generation parameters. + * Matches iOS GenerationParameters exactly. + */ +@Serializable +data class GenerationParameters( + val temperature: Float = 0.7f, + val maxTokens: Int = 2048, + val topP: Float = 0.9f, + val topK: Int = 40, + val enableThinking: Boolean = false, +) + +/** + * App-local model info for messages. + * Matches iOS MessageModelInfo exactly. + */ +@Serializable +data class MessageModelInfo( + val modelId: String, + val modelName: String, + val framework: String? = null, +) + +/** + * App-local message analytics. + * Matches iOS MessageAnalytics exactly. + */ +@Serializable +data class MessageAnalytics( + /** When the message was generated */ + val timestamp: Long = System.currentTimeMillis(), + val inputTokens: Int = 0, + val outputTokens: Int = 0, + /** Total generation time in milliseconds */ + val totalGenerationTime: Long = 0, + /** Time to first token in milliseconds (nullable since not always available) */ + val timeToFirstToken: Long? = null, + val averageTokensPerSecond: Double = 0.0, + val wasThinkingMode: Boolean = false, + val completionStatus: CompletionStatus = CompletionStatus.COMPLETE, +) + +/** + * App-local conversation analytics. + * Matches iOS ConversationAnalytics exactly. + */ +@Serializable +data class ConversationAnalytics( + val totalMessages: Int = 0, + val totalTokens: Int = 0, + /** Total duration in milliseconds */ + val totalDuration: Long = 0, +) + +/** + * App-local performance summary. + * Matches iOS PerformanceSummary exactly. + */ +@Serializable +data class PerformanceSummary( + val totalMessages: Int = 0, + /** Average response time in seconds */ + val averageResponseTime: Double = 0.0, + val averageTokensPerSecond: Double = 0.0, + val totalTokensProcessed: Int = 0, + /** Thinking mode usage ratio (0-1) */ + val thinkingModeUsage: Double = 0.0, + /** Success rate ratio (0-1) */ + val successRate: Double = 1.0, +) + +/** + * App-specific ChatMessage for conversations. + * Self-contained with app-local types. + */ +@Serializable +data class ChatMessage( + val id: String = UUID.randomUUID().toString(), + val role: MessageRole, + val content: String, + val thinkingContent: String? = null, + val timestamp: Long = System.currentTimeMillis(), + val analytics: MessageAnalytics? = null, + val modelInfo: MessageModelInfo? = null, + val metadata: Map? = null, +) { + val isFromUser: Boolean get() = role == MessageRole.USER + val isFromAssistant: Boolean get() = role == MessageRole.ASSISTANT + val isSystem: Boolean get() = role == MessageRole.SYSTEM + + companion object { + /** + * Create a user message + */ + fun user( + content: String, + metadata: Map? = null, + ): ChatMessage = + ChatMessage( + role = MessageRole.USER, + content = content, + metadata = metadata, + ) + + /** + * Create an assistant message + */ + fun assistant( + content: String, + thinkingContent: String? = null, + analytics: MessageAnalytics? = null, + modelInfo: MessageModelInfo? = null, + metadata: Map? = null, + ): ChatMessage = + ChatMessage( + role = MessageRole.ASSISTANT, + content = content, + thinkingContent = thinkingContent, + analytics = analytics, + modelInfo = modelInfo, + metadata = metadata, + ) + + /** + * Create a system message + */ + fun system(content: String): ChatMessage = + ChatMessage( + role = MessageRole.SYSTEM, + content = content, + ) + } +} + +/** + * App-specific Conversation that uses ChatMessage + */ +@Serializable +data class Conversation( + val id: String, + val title: String? = null, + val messages: List = emptyList(), + val createdAt: Long = System.currentTimeMillis(), + val updatedAt: Long = System.currentTimeMillis(), + val modelName: String? = null, + val analytics: ConversationAnalytics? = null, + val performanceSummary: PerformanceSummary? = null, +) + +/** + * Helper function to create PerformanceSummary from messages + */ +fun createPerformanceSummary(messages: List): PerformanceSummary { + val analyticsMessages = messages.mapNotNull { it.analytics } + + return PerformanceSummary( + totalMessages = messages.size, + averageResponseTime = + if (analyticsMessages.isNotEmpty()) { + analyticsMessages.map { it.totalGenerationTime }.average() / 1000.0 + } else { + 0.0 + }, + averageTokensPerSecond = + if (analyticsMessages.isNotEmpty()) { + analyticsMessages.map { it.averageTokensPerSecond }.average() + } else { + 0.0 + }, + totalTokensProcessed = analyticsMessages.sumOf { it.inputTokens + it.outputTokens }, + thinkingModeUsage = + if (analyticsMessages.isNotEmpty()) { + analyticsMessages.count { it.wasThinkingMode }.toDouble() / analyticsMessages.size + } else { + 0.0 + }, + successRate = + if (analyticsMessages.isNotEmpty()) { + analyticsMessages.count { it.completionStatus == CompletionStatus.COMPLETE } + .toDouble() / analyticsMessages.size + } else { + 1.0 + }, + ) +} diff --git a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/domain/models/SessionState.kt b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/domain/models/SessionState.kt new file mode 100644 index 000000000..975506c5c --- /dev/null +++ b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/domain/models/SessionState.kt @@ -0,0 +1,16 @@ +package com.runanywhere.runanywhereai.domain.models + +/** + * Session states for voice interaction + * UI-specific enum that tracks the current state of the voice assistant + * Matches iOS VoiceAssistantViewModel.SessionState + */ +enum class SessionState { + DISCONNECTED, + CONNECTING, + CONNECTED, + LISTENING, + PROCESSING, + SPEAKING, + ERROR, +} diff --git a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/domain/services/AudioCaptureService.kt b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/domain/services/AudioCaptureService.kt new file mode 100644 index 000000000..ca7a4df0b --- /dev/null +++ b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/domain/services/AudioCaptureService.kt @@ -0,0 +1,195 @@ +package com.runanywhere.runanywhereai.domain.services + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.media.AudioFormat +import android.media.AudioRecord +import android.media.MediaRecorder +import android.util.Log +import androidx.core.app.ActivityCompat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.nio.ByteBuffer +import java.nio.ByteOrder +import kotlin.math.sqrt + +private const val TAG = "AudioCaptureService" + +/** + * Service for capturing audio from the device microphone + * + * Platform-specific implementation for Android using AudioRecord. + * Captures PCM audio at 16kHz, mono, 16-bit for STT model consumption. + * + * iOS Reference: AudioCaptureManager.swift + */ +class AudioCaptureService( + private val context: Context, +) { + companion object { + const val SAMPLE_RATE = 16000 + const val CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_MONO + const val AUDIO_FORMAT = AudioFormat.ENCODING_PCM_16BIT + const val CHUNK_SIZE_MS = 100 // Emit audio chunks every 100ms + } + + private var audioRecord: AudioRecord? = null + + private val _isRecording = MutableStateFlow(false) + + /** + * Whether recording is currently active + */ + val isRecordingState: StateFlow = _isRecording.asStateFlow() + + private val _audioLevel = MutableStateFlow(0f) + + /** + * Current audio level (0.0 to 1.0) for visualization + */ + val audioLevel: StateFlow = _audioLevel.asStateFlow() + + /** + * Check if we have microphone permission + */ + fun hasRecordPermission(): Boolean { + return ActivityCompat.checkSelfPermission( + context, + Manifest.permission.RECORD_AUDIO, + ) == PackageManager.PERMISSION_GRANTED + } + + /** + * Start capturing audio and emit audio chunks as a Flow + * Returns PCM audio data at 16kHz, mono, 16-bit + */ + fun startCapture(): Flow = + callbackFlow { + if (!hasRecordPermission()) { + Log.e(TAG, "No RECORD_AUDIO permission") + close(SecurityException("RECORD_AUDIO permission not granted")) + return@callbackFlow + } + + val bufferSize = AudioRecord.getMinBufferSize(SAMPLE_RATE, CHANNEL_CONFIG, AUDIO_FORMAT) + val chunkSize = (SAMPLE_RATE * 2 * CHUNK_SIZE_MS) / 1000 // bytes per chunk + + try { + audioRecord = + AudioRecord( + MediaRecorder.AudioSource.MIC, + SAMPLE_RATE, + CHANNEL_CONFIG, + AUDIO_FORMAT, + bufferSize.coerceAtLeast(chunkSize * 2), + ) + + if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) { + Log.e(TAG, "AudioRecord failed to initialize") + close(IllegalStateException("AudioRecord initialization failed")) + return@callbackFlow + } + + audioRecord?.startRecording() + _isRecording.value = true + Log.i(TAG, "Audio capture started (${SAMPLE_RATE}Hz, chunk size: $chunkSize)") + + // Launch a coroutine on IO dispatcher to read audio + val readJob = + launch(Dispatchers.IO) { + val buffer = ByteArray(chunkSize) + + while (isActive && _isRecording.value) { + val bytesRead = audioRecord?.read(buffer, 0, chunkSize) ?: -1 + + if (bytesRead > 0) { + val chunk = buffer.copyOf(bytesRead) + + // Update audio level for visualization + val rms = calculateRMS(chunk) + _audioLevel.value = rms + + // trySend is safe to call from any context in callbackFlow + trySend(chunk) + } else if (bytesRead < 0) { + Log.w(TAG, "AudioRecord read error: $bytesRead") + break + } + } + } + + // Wait for cancellation + awaitClose { + Log.d(TAG, "Flow closing, stopping audio capture") + readJob.cancel() + stopCaptureInternal() + } + } catch (e: Exception) { + Log.e(TAG, "Error in audio capture: ${e.message}") + stopCaptureInternal() + close(e) + } + } + + /** + * Stop audio capture + */ + fun stopCapture() { + _isRecording.value = false + stopCaptureInternal() + } + + private fun stopCaptureInternal() { + try { + audioRecord?.stop() + audioRecord?.release() + audioRecord = null + _isRecording.value = false + _audioLevel.value = 0f + Log.d(TAG, "Audio capture stopped") + } catch (e: Exception) { + Log.w(TAG, "Error stopping audio capture: ${e.message}") + } + } + + /** + * Calculate RMS (Root Mean Square) for audio level visualization + * Matches iOS implementation for waveform display + */ + fun calculateRMS(audioData: ByteArray): Float { + if (audioData.isEmpty()) return 0f + + val shorts = + ByteBuffer.wrap(audioData) + .order(ByteOrder.LITTLE_ENDIAN) + .asShortBuffer() + + var sum = 0.0 + while (shorts.hasRemaining()) { + val sample = shorts.get().toFloat() / Short.MAX_VALUE + sum += sample * sample + } + + return sqrt(sum / (audioData.size / 2)).toFloat() + } + + /** + * Get the current recording state + */ + fun isRecording(): Boolean = isRecordingState.value + + /** + * Clean up resources + */ + fun release() { + stopCapture() + } +} diff --git a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/chat/ChatScreen.kt b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/chat/ChatScreen.kt new file mode 100644 index 000000000..9c93da250 --- /dev/null +++ b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/chat/ChatScreen.kt @@ -0,0 +1,1964 @@ +package com.runanywhere.runanywhereai.presentation.chat + +import android.content.ClipData +import android.os.Build +import android.widget.Toast +import androidx.compose.animation.* +import androidx.compose.animation.core.* +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.runanywhere.runanywhereai.data.ConversationStore +import com.runanywhere.runanywhereai.domain.models.ChatMessage +import com.runanywhere.runanywhereai.domain.models.Conversation +import com.runanywhere.runanywhereai.domain.models.MessageRole +import com.runanywhere.runanywhereai.presentation.chat.components.MarkdownText +import com.runanywhere.runanywhereai.presentation.chat.components.ModelLoadedToast +import com.runanywhere.runanywhereai.presentation.chat.components.ModelRequiredOverlay +import com.runanywhere.runanywhereai.util.getModelLogoResIdForName +import com.runanywhere.runanywhereai.ui.theme.AppColors +import com.runanywhere.runanywhereai.ui.theme.AppTypography +import com.runanywhere.runanywhereai.ui.theme.Dimensions +import kotlinx.coroutines.launch +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * ChatScreen with pixel-perfect design + * + * Design specifications: + * - Message bubbles: 18dp corner radius, 16dp horizontal padding, 12dp vertical padding + * - User bubble: Blue gradient with white text + * - Assistant bubble: Gray gradient with primary text + * - Thinking section: Purple theme with collapsible content + * - Typing indicator: Animated dots with blue color + * - Empty state: 60sp icon with title and subtitle + * - Features: + * - Conversation list management + * - Model selection sheet + * - Chat details view with analytics + * - Toolbar button conditions + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChatScreen(viewModel: ChatViewModel = viewModel()) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val listState = rememberLazyListState() + val scope = rememberCoroutineScope() + + // State for sheets and dialogs + var showingConversationList by remember { mutableStateOf(false) } + var showingModelSelection by remember { mutableStateOf(false) } + var showingChatDetails by remember { mutableStateOf(false) } + var showDebugAlert by remember { mutableStateOf(false) } + var debugMessage by remember { mutableStateOf("") } + + // Model loaded toast state + var showModelLoadedToast by remember { mutableStateOf(false) } + var loadedModelToastName by remember { mutableStateOf("") } + + // Auto-scroll to bottom when new messages arrive + LaunchedEffect(uiState.messages.size, uiState.isGenerating) { + if (uiState.messages.isNotEmpty()) { + scope.launch { + listState.animateScrollToItem(uiState.messages.size - 1) + } + } + } + + // Show app bar only when model loaded + Scaffold( + topBar = { + if (uiState.isModelLoaded) { + TopAppBar( + title = { + Text( + text = "Chat", + style = MaterialTheme.typography.headlineMedium, + ) + }, + navigationIcon = { + // Conversations button + IconButton(onClick = { showingConversationList = true }) { + Icon( + imageVector = Icons.Default.History, + contentDescription = "Conversations", + ) + } + }, + actions = { + // Info button - disabled when messages.isEmpty, primaryAccent when enabled + IconButton( + onClick = { showingChatDetails = true }, + enabled = uiState.messages.isNotEmpty(), + ) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = "Info", + tint = + if (uiState.messages.isNotEmpty()) { + AppColors.primaryAccent + } else { + AppColors.statusGray + }, + ) + } + + // Model button (logo + short name + Streaming/Batch) + IconButton(onClick = { showingModelSelection = true }) { + ChatModelButton( + modelName = uiState.loadedModelName, + supportsStreaming = uiState.useStreaming, + ) + } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) + } + }, + ) { padding -> + Box( + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .background(MaterialTheme.colorScheme.background), + ) { + Column(modifier = Modifier.fillMaxSize()) { + // Messages list or empty state - only when model loaded + if (uiState.isModelLoaded) { + if (uiState.messages.isEmpty() && !uiState.isGenerating) { + EmptyStateView() + } else { + LazyColumn( + state = listState, + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(Dimensions.large), + verticalArrangement = Arrangement.spacedBy(Dimensions.messageSpacingBetween), + ) { + // Add spacer at top for better scrolling + item { + Spacer(modifier = Modifier.height(20.dp)) + } + + items(uiState.messages, key = { it.id }) { message -> + MessageBubbleView( + message = message, + isGenerating = uiState.isGenerating, + modifier = Modifier.animateItem(), + ) + } + + // Typing indicator + if (uiState.isGenerating) { + item { + TypingIndicatorView() + } + } + + // Add spacer at bottom for better keyboard handling + item { + Spacer(modifier = Modifier.height(20.dp)) + } + } + } + } + + // Input area and divider only when model loaded + if (uiState.isModelLoaded) { + HorizontalDivider( + thickness = Dimensions.strokeThin, + color = MaterialTheme.colorScheme.outline, + ) + ChatInputView( + value = uiState.currentInput, + onValueChange = viewModel::updateInput, + onSend = viewModel::sendMessage, + isGenerating = uiState.isGenerating, + isModelLoaded = true, + ) + } + } + + // ModelRequiredOverlay when no model - animated circles + Get Started + if (!uiState.isModelLoaded && !uiState.isGenerating) { + ModelRequiredOverlay( + onSelectModel = { showingModelSelection = true }, + modifier = Modifier.matchParentSize(), + ) + } + + // Model loaded toast - overlaid at top + ModelLoadedToast( + modelName = loadedModelToastName, + isVisible = showModelLoadedToast, + onDismiss = { showModelLoadedToast = false }, + modifier = Modifier.align(Alignment.TopCenter), + ) + } + } + + // Model Selection Bottom Sheet + if (showingModelSelection) { + com.runanywhere.runanywhereai.presentation.models.ModelSelectionBottomSheet( + onDismiss = { showingModelSelection = false }, + onModelSelected = { model -> + scope.launch { + // Log which model was selected + android.util.Log.i("ChatScreen", "LLM model selected: ${model.name} (${model.id})") + // Sync ViewModel with SDK state - model is already loaded by ModelSelectionBottomSheet + viewModel.checkModelStatus() + // Show model loaded toast + loadedModelToastName = model.name + showModelLoadedToast = true + } + }, + ) + } + + // Conversation List Bottom Sheet + if (showingConversationList) { + val context = LocalContext.current + val conversationStore = remember { ConversationStore.getInstance(context) } + val conversations by conversationStore.conversations.collectAsStateWithLifecycle() + + ConversationListSheet( + conversations = conversations, + currentConversationId = uiState.currentConversation?.id, + onDismiss = { showingConversationList = false }, + onConversationSelected = { conversation -> + viewModel.loadConversation(conversation) + showingConversationList = false + }, + onNewConversation = { + viewModel.createNewConversation() + showingConversationList = false + }, + onDeleteConversation = { conversation -> + conversationStore.deleteConversation(conversation) + }, + ) + } + + // Chat Details Bottom Sheet + if (showingChatDetails) { + ChatDetailsSheet( + messages = uiState.messages, + conversationTitle = uiState.currentConversation?.title ?: "Chat", + modelName = uiState.loadedModelName, + onDismiss = { showingChatDetails = false }, + ) + } + + // Handle error state + LaunchedEffect(uiState.error) { + if (uiState.error != null) { + debugMessage = "Error occurred: ${uiState.error?.localizedMessage}" + showDebugAlert = true + } + } + + // Debug alert dialog + if (showDebugAlert) { + AlertDialog( + onDismissRequest = { + showDebugAlert = false + viewModel.clearError() + }, + title = { Text("Debug Info") }, + text = { Text(debugMessage) }, + confirmButton = { + TextButton( + onClick = { + showDebugAlert = false + viewModel.clearError() + }, + ) { + Text("OK") + } + }, + ) + } +} + +// ==================== +// CHAT MODEL BUTTON (toolbar trailing - model logo + short name + Streaming/Batch) +// ==================== + +@Composable +private fun ChatModelButton( + modelName: String?, + supportsStreaming: Boolean, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + if (modelName != null) { + // Model logo 36x36 cornerRadius(4) + Box( + modifier = + Modifier + .size(36.dp) + .clip(RoundedCornerShape(4.dp)), + ) { + Image( + painter = painterResource(id = getModelLogoResIdForName(modelName)), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit, + ) + } + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = shortModelName(modelName, maxLength = 13), + style = AppTypography.caption, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp), + ) { + Icon( + imageVector = if (supportsStreaming) Icons.Default.Bolt else Icons.Default.Stop, + contentDescription = null, + modifier = Modifier.size(10.dp), + tint = if (supportsStreaming) AppColors.primaryGreen else AppColors.primaryOrange, + ) + Text( + text = if (supportsStreaming) "Streaming" else "Batch", + style = AppTypography.caption2.copy(fontSize = 10.sp, fontWeight = FontWeight.Medium), + color = if (supportsStreaming) AppColors.primaryGreen else AppColors.primaryOrange, + ) + } + } + } else { + Icon( + imageVector = Icons.Default.ViewInAr, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = AppColors.primaryAccent, + ) + Text( + text = "Select Model", + style = AppTypography.caption, + ) + } + } +} + +private fun shortModelName(name: String, maxLength: Int = 13): String { + val cleaned = name.replace(Regex("\\s*\\([^)]*\\)"), "").trim() + return if (cleaned.length > maxLength) { + cleaned.take(maxLength - 1) + "\u2026" + } else { + cleaned + } +} + +// ==================== +// MODEL INFO BAR +// ==================== + +/** + * Formats a byte size into a human-readable string (e.g., "1.2G", "500M"). + * Returns null if the size is null. + */ +private fun formatModelSize(sizeBytes: Long?): String? { + if (sizeBytes == null || sizeBytes <= 0) return null + return when { + sizeBytes >= 1_000_000_000 -> String.format("%.1fG", sizeBytes / 1_000_000_000.0) + sizeBytes >= 1_000_000 -> String.format("%.0fM", sizeBytes / 1_000_000.0) + sizeBytes >= 1_000 -> String.format("%.0fK", sizeBytes / 1_000.0) + else -> "${sizeBytes}B" + } +} + +/** + * Formats a context length into a human-readable string (e.g., "128K", "8K"). + * Returns null if the context length is null. + */ +private fun formatContextLength(contextLength: Int?): String? { + if (contextLength == null || contextLength <= 0) return null + return when { + contextLength >= 1_000_000 -> String.format("%.1fM", contextLength / 1_000_000.0) + contextLength >= 1_000 -> String.format("%.0fK", contextLength / 1_000.0) + else -> contextLength.toString() + } +} + +@Composable +fun ModelInfoBar( + modelName: String, + framework: String, + downloadSize: Long? = null, + contextLength: Int? = null, +) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding( + horizontal = Dimensions.modelInfoBarPaddingHorizontal, + vertical = Dimensions.modelInfoBarPaddingVertical, + ), + horizontalArrangement = Arrangement.spacedBy(Dimensions.modelInfoStatsItemSpacing), + verticalAlignment = Alignment.CenterVertically, + ) { + // Framework badge + Surface( + color = MaterialTheme.colorScheme.primary, + shape = RoundedCornerShape(Dimensions.modelInfoFrameworkBadgeCornerRadius), + modifier = + Modifier.border( + width = Dimensions.strokeThin, + color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.2f), + shape = RoundedCornerShape(Dimensions.modelInfoFrameworkBadgeCornerRadius), + ), + ) { + Text( + text = framework, + style = AppTypography.monospacedCaption, + color = MaterialTheme.colorScheme.onPrimary, + modifier = + Modifier.padding( + horizontal = Dimensions.modelInfoFrameworkBadgePaddingHorizontal, + vertical = Dimensions.modelInfoFrameworkBadgePaddingVertical, + ), + ) + } + + // Model name (first word only) + Text( + text = modelName.split(" ").first(), + style = AppTypography.rounded11, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + // Stats (storage icon + size) + val formattedSize = formatModelSize(downloadSize) + if (formattedSize != null) { + Row( + horizontalArrangement = Arrangement.spacedBy(Dimensions.modelInfoStatsIconTextSpacing), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Storage, + contentDescription = null, + modifier = Modifier.size(Dimensions.iconSmall), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = formattedSize, + style = AppTypography.rounded10, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + // Context length + val formattedContextLength = formatContextLength(contextLength) + if (formattedContextLength != null) { + Row( + horizontalArrangement = Arrangement.spacedBy(Dimensions.modelInfoStatsIconTextSpacing), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Description, + contentDescription = null, + modifier = Modifier.size(Dimensions.iconSmall), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = formattedContextLength, + style = AppTypography.rounded10, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + + // Bottom border with offset (12.dp offset) + Box( + modifier = + Modifier + .fillMaxWidth() + .offset(y = Dimensions.mediumLarge), + ) { + HorizontalDivider( + thickness = Dimensions.strokeThin, + color = MaterialTheme.colorScheme.outline, + ) + } +} + +// ==================== +// MESSAGE BUBBLE +// ==================== + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MessageBubbleView( + message: ChatMessage, + isGenerating: Boolean = false, + modifier: Modifier = Modifier, +) { + val alignment = + if (message.role == MessageRole.USER) { + Arrangement.End + } else { + Arrangement.Start + } + + // context menu state + var showDialog by remember { mutableStateOf(false) } + var showTextSelectionDialog by remember { mutableStateOf(false) } + val context = LocalContext.current + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = alignment, + ) { + // Spacer for alignment + if (message.role == MessageRole.USER) { + Spacer(modifier = Modifier.width(Dimensions.messageBubbleMinSpacing)) + } + + Column( + modifier = Modifier.widthIn(max = Dimensions.messageBubbleMaxWidth), + horizontalAlignment = + if (message.role == MessageRole.USER) { + Alignment.End + } else { + Alignment.Start + }, + ) { + // Model badge (for assistant messages) + if (message.role == MessageRole.ASSISTANT && message.modelInfo != null) { + ModelBadge( + modelName = message.modelInfo.modelName, + framework = message.modelInfo.framework, + ) + Spacer(modifier = Modifier.height(Dimensions.small)) + } + + // Thinking toggle (if thinking content exists) + message.thinkingContent?.let { thinking -> + ThinkingToggle( + thinkingContent = thinking, + ) + Spacer(modifier = Modifier.height(Dimensions.small)) + } + + // Thinking progress indicator + // Shows "Thinking..." when message is empty but thinking content exists during generation + if (message.role == MessageRole.ASSISTANT && + message.content.isEmpty() && + message.thinkingContent != null && + isGenerating + ) { + ThinkingProgressIndicator() + } + + // Main message bubble - only show if there's content + if (message.content.isNotEmpty()) { + // Use gradient backgrounds + val bubbleShape = RoundedCornerShape(Dimensions.messageBubbleCornerRadius) + val isUserMessage = message.role == MessageRole.USER + + if (showDialog) { + BasicAlertDialog( + onDismissRequest = { showDialog = false }, + modifier = Modifier + .clip(RoundedCornerShape(Dimensions.cornerRadiusModal)) + .background(MaterialTheme.colorScheme.surface) + .widthIn(max = Dimensions.contextMenuMaxWidth) + ) { + Column( + modifier = Modifier.padding(vertical = Dimensions.padding8) + ) { + TextButton( + onClick = { + scope.launch { + val clipEntry = ClipEntry(ClipData.newPlainText("chat_msg", message.content)) + clipboard.setClipEntry(clipEntry) + showDialog = false + // Only show a toast for Android 12 and lower. + // note: https://developer.android.com/develop/ui/views/touch-and-input/copy-paste#duplicate-notifications + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) { + Toast.makeText(context, "Message copied to clipboard", Toast.LENGTH_SHORT).show() + } + } + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Dimensions.padding16) + ) { + Text("Copy", style = MaterialTheme.typography.bodyLarge) + } + TextButton( + onClick = { + showDialog = false + showTextSelectionDialog = true + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Dimensions.padding16) + ) { + Text("Select Text", style = MaterialTheme.typography.bodyLarge) + } + } + } + } + + if (showTextSelectionDialog) { + SelectableTextDialog( + text = message.content, + onDismiss = { showTextSelectionDialog = false } + ) + } + + Box( + modifier = + Modifier + .shadow( + elevation = Dimensions.messageBubbleShadowRadius, + shape = bubbleShape, + ) + .clip(bubbleShape) + .background( + brush = + if (isUserMessage) { + AppColors.userBubbleGradient() + } else { + AppColors.assistantBubbleGradientThemed() + }, + ) + .border( + width = Dimensions.strokeThin, + color = + if (isUserMessage) { + AppColors.borderLight + } else { + AppColors.borderMedium + }, + shape = bubbleShape, + ) + .combinedClickable( + onClick = { /* No-op */ }, + onLongClick = { showDialog = true }, + ), + ) { + if (isUserMessage) { + Text( + text = message.content, + style = MaterialTheme.typography.bodyLarge, + color = AppColors.textWhite, + modifier = + Modifier.padding( + horizontal = Dimensions.messageBubblePaddingHorizontal, + vertical = Dimensions.messageBubblePaddingVertical, + ), + ) + } else { + MarkdownText( + markdown = message.content, + style = MaterialTheme.typography.bodyLarge, + color = AppColors.assistantBubbleTextColor(), + modifier = + Modifier.padding( + horizontal = Dimensions.messageBubblePaddingHorizontal, + vertical = Dimensions.messageBubblePaddingVertical, + ), + ) + } + } + } + + // Analytics footer (for assistant messages) + if (message.role == MessageRole.ASSISTANT && message.analytics != null) { + Spacer(modifier = Modifier.height(Dimensions.small)) + AnalyticsFooter( + analytics = message.analytics, + hasThinking = message.thinkingContent != null, + ) + } + + // Timestamp (for user messages) + if (message.role == MessageRole.USER) { + Spacer(modifier = Modifier.height(Dimensions.small)) + Text( + text = formatTimestamp(message.timestamp), + style = AppTypography.caption2, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.align(Alignment.End), + ) + } + } + + // Spacer for alignment + if (message.role == MessageRole.ASSISTANT) { + Spacer(modifier = Modifier.width(Dimensions.messageBubbleMinSpacing)) + } + } +} + +// Helper function to format timestamp +private fun formatTimestamp(timestamp: Long): String { + val calendar = java.util.Calendar.getInstance() + calendar.timeInMillis = timestamp + val hour = calendar.get(java.util.Calendar.HOUR) + val minute = calendar.get(java.util.Calendar.MINUTE) + val amPm = if (calendar.get(java.util.Calendar.AM_PM) == java.util.Calendar.AM) "AM" else "PM" + return String.format("%d:%02d %s", if (hour == 0) 12 else hour, minute, amPm) +} + +// ==================== +// MODEL BADGE +// ==================== + +@Composable +fun ModelBadge( + modelName: String, + framework: String? = null, +) { + Surface( + color = MaterialTheme.colorScheme.primary, + shape = RoundedCornerShape(Dimensions.modelBadgeCornerRadius), + modifier = + Modifier + .shadow( + elevation = Dimensions.shadowSmall, + shape = RoundedCornerShape(Dimensions.modelBadgeCornerRadius), + ) + .border( + width = Dimensions.strokeThin, + color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.2f), + shape = RoundedCornerShape(Dimensions.modelBadgeCornerRadius), + ), + ) { + Row( + modifier = + Modifier.padding( + horizontal = Dimensions.modelBadgePaddingHorizontal, + vertical = Dimensions.modelBadgePaddingVertical, + ), + horizontalArrangement = Arrangement.spacedBy(Dimensions.modelBadgeSpacing), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.ViewInAr, + contentDescription = null, + modifier = Modifier.size(AppTypography.caption2.fontSize.value.dp), + tint = MaterialTheme.colorScheme.onPrimary, + ) + Text( + text = modelName, + style = AppTypography.caption2Medium, + color = MaterialTheme.colorScheme.onPrimary, + ) + if (framework != null) { + Text( + text = framework, + style = AppTypography.caption2, + color = MaterialTheme.colorScheme.onPrimary, + ) + } + } + } +} + +// ==================== +// THINKING SECTION +// ==================== + +/** + * Extract intelligent thinking summary - matching iOS thinkingSummary computed property + * Extracts first meaningful sentence from thinking content instead of showing raw text + */ +private fun extractThinkingSummary(thinking: String): String { + val trimmed = thinking.trim() + if (trimmed.isEmpty()) return "Show reasoning..." + + // Extract first meaningful sentence + val sentences = + trimmed.split(Regex("[.!?]")) + .map { it.trim() } + .filter { it.length > 20 } + + // If we have at least 2 sentences and first is meaningful, use it + if (sentences.size >= 2 && sentences[0].length > 20) { + return sentences[0] + "..." + } + + // Fallback to truncated version + if (trimmed.length > 80) { + val truncated = trimmed.take(80) + val lastSpace = truncated.lastIndexOf(' ') + return if (lastSpace > 0) { + truncated.substring(0, lastSpace) + "..." + } else { + truncated + "..." + } + } + + return trimmed +} + +/** + * Thinking Progress Indicator - matching iOS pattern + * Shows "Thinking..." with animated dots when message is empty but thinking content exists + */ +@Composable +fun ThinkingProgressIndicator() { + val thinkingShape = RoundedCornerShape(Dimensions.thinkingSectionCornerRadius) + + Box( + modifier = + Modifier + .clip(thinkingShape) + .background(brush = AppColors.thinkingProgressGradient()) + .border( + width = Dimensions.strokeThin, + color = AppColors.thinkingBorder, + shape = thinkingShape, + ) + .padding( + horizontal = Dimensions.thinkingSectionPaddingHorizontal, + vertical = Dimensions.thinkingSectionPaddingVertical, + ), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(Dimensions.xSmall), + verticalAlignment = Alignment.CenterVertically, + ) { + // Animated dots + repeat(3) { index -> + val infiniteTransition = rememberInfiniteTransition(label = "thinking_progress") + val scale by infiniteTransition.animateFloat( + initialValue = 0.5f, + targetValue = 1.0f, + animationSpec = + infiniteRepeatable( + animation = tween(600), + repeatMode = RepeatMode.Reverse, + initialStartOffset = StartOffset(index * 200), + ), + label = "thinking_dot_$index", + ) + + Box( + modifier = + Modifier + .size(Dimensions.small) + .graphicsLayer { + scaleX = scale + scaleY = scale + } + .background( + color = AppColors.primaryPurple, + shape = CircleShape, + ), + ) + } + + Spacer(modifier = Modifier.width(Dimensions.smallMedium)) + + Text( + text = "Thinking...", + style = AppTypography.caption, + color = AppColors.primaryPurple.copy(alpha = 0.8f), + ) + } + } +} + +@Composable +fun ThinkingToggle( + thinkingContent: String, +) { + var isExpanded by remember { mutableStateOf(false) } + + // Extract intelligent summary + val thinkingSummary = + remember(thinkingContent) { + extractThinkingSummary(thinkingContent) + } + + Column { + // Toggle button with gradient background + val toggleShape = RoundedCornerShape(Dimensions.thinkingSectionCornerRadius) + + Box( + modifier = + Modifier + .clickable { isExpanded = !isExpanded } + .shadow( + elevation = Dimensions.shadowSmall, + shape = toggleShape, + ambientColor = AppColors.shadowThinking, + spotColor = AppColors.shadowThinking, + ) + .clip(toggleShape) + .background(brush = AppColors.thinkingBackgroundGradient()) + .border( + width = Dimensions.strokeThin, + color = AppColors.thinkingBorder, + shape = toggleShape, + ), + ) { + Row( + modifier = + Modifier.padding( + horizontal = Dimensions.thinkingSectionPaddingHorizontal, + vertical = Dimensions.thinkingSectionPaddingVertical, + ), + horizontalArrangement = Arrangement.spacedBy(Dimensions.toolbarButtonSpacing), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Lightbulb, + contentDescription = null, + modifier = Modifier.size(AppTypography.caption.fontSize.value.dp), + tint = AppColors.primaryPurple, + ) + Text( + text = if (isExpanded) "Hide reasoning" else thinkingSummary, + style = AppTypography.caption, + color = AppColors.primaryPurple, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Icon( + imageVector = if (isExpanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(AppTypography.caption2.fontSize.value.dp), + tint = AppColors.primaryPurple.copy(alpha = 0.6f), + ) + } + } + + // Expanded content + AnimatedVisibility( + visible = isExpanded, + enter = fadeIn(animationSpec = tween(250)) + expandVertically(), + exit = fadeOut(animationSpec = tween(250)) + shrinkVertically(), + ) { + Column { + Spacer(modifier = Modifier.height(Dimensions.small)) + Surface( + color = AppColors.thinkingContentBackground, + shape = RoundedCornerShape(Dimensions.thinkingContentCornerRadius), + ) { + Box( + modifier = + Modifier + .heightIn(max = Dimensions.thinkingContentMaxHeight) + .padding(Dimensions.thinkingContentPadding), + ) { + Text( + text = thinkingContent, + style = AppTypography.caption, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } +} + +// ==================== +// ANALYTICS FOOTER +// ==================== + +@Composable +fun AnalyticsFooter( + analytics: com.runanywhere.runanywhereai.domain.models.MessageAnalytics, + hasThinking: Boolean, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(Dimensions.smallMedium), + verticalAlignment = Alignment.CenterVertically, + ) { + // Timestamp + Text( + text = formatTimestamp(analytics.timestamp), + style = AppTypography.caption2, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + // Separator + Text( + text = "•", + style = AppTypography.caption2, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + ) + + // Duration + analytics.timeToFirstToken?.let { ttft -> + Text( + text = "${ttft / 1000f}s", + style = AppTypography.caption2, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "•", + style = AppTypography.caption2, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + ) + } + + // Tokens per second + Text( + text = String.format("%.1f tok/s", analytics.averageTokensPerSecond), + style = AppTypography.caption2, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + // Thinking indicator + if (hasThinking) { + Text( + text = "•", + style = AppTypography.caption2, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + ) + Icon( + imageVector = Icons.Default.Lightbulb, + contentDescription = null, + modifier = Modifier.size(AppTypography.caption2.fontSize.value.dp), + tint = MaterialTheme.colorScheme.secondary, + ) + } + } +} + +// ==================== +// TYPING INDICATOR +// ==================== + +// Typing indicator dots = primaryAccent 0.7, background = backgroundGray5, border = borderLight +@Composable +fun TypingIndicatorView() { + Row( + modifier = Modifier.widthIn(max = Dimensions.messageBubbleMaxWidth), + horizontalArrangement = Arrangement.Start, + ) { + Surface( + color = AppColors.typingIndicatorBackground, + shape = RoundedCornerShape(Dimensions.typingIndicatorCornerRadius), + modifier = + Modifier + .shadow( + elevation = Dimensions.shadowMedium, + shape = RoundedCornerShape(Dimensions.typingIndicatorCornerRadius), + ambientColor = AppColors.shadowLight, + spotColor = AppColors.shadowLight, + ) + .border( + width = Dimensions.strokeThin, + color = AppColors.typingIndicatorBorder, + shape = RoundedCornerShape(Dimensions.typingIndicatorCornerRadius), + ), + ) { + Row( + modifier = + Modifier.padding( + horizontal = Dimensions.typingIndicatorPaddingHorizontal, + vertical = Dimensions.typingIndicatorPaddingVertical, + ), + horizontalArrangement = Arrangement.spacedBy(Dimensions.typingIndicatorDotSpacing), + verticalAlignment = Alignment.CenterVertically, + ) { + repeat(3) { index -> + val infiniteTransition = rememberInfiniteTransition(label = "typing") + val scale by infiniteTransition.animateFloat( + initialValue = 0.8f, + targetValue = 1.3f, + animationSpec = + infiniteRepeatable( + animation = tween(600), + repeatMode = RepeatMode.Reverse, + initialStartOffset = StartOffset(index * 200), + ), + label = "dot_scale_$index", + ) + + Box( + modifier = + Modifier + .size(Dimensions.typingIndicatorDotSize) + .graphicsLayer { + scaleX = scale + scaleY = scale + } + .background( + color = AppColors.typingIndicatorDots, + shape = CircleShape, + ), + ) + } + + Spacer(modifier = Modifier.width(Dimensions.typingIndicatorTextSpacing)) + + Text( + text = "AI is thinking...", + style = AppTypography.caption, + color = AppColors.typingIndicatorText, + ) + } + } + + Spacer(modifier = Modifier.width(Dimensions.messageBubbleMinSpacing)) + } +} + +// ==================== +// EMPTY STATE with breathing waveform (matches Transcribe ready state) +// Empty state: "Start a conversation", "Type a message below to get started" +// ==================== + +@Composable +fun EmptyStateView() { + val infiniteTransition = rememberInfiniteTransition(label = "chat_breathing") + val breathing by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(800), + repeatMode = RepeatMode.Reverse, + ), + label = "breathing", + ) + val baseHeights = listOf(16, 24, 20, 28, 18) + val breathingHeights = listOf(24, 40, 32, 48, 28) + + Column( + modifier = + Modifier + .fillMaxSize() + .padding(Dimensions.huge), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.Bottom, + ) { + baseHeights.forEachIndexed { index, base -> + val h = base + (breathingHeights[index] - base) * breathing + Box( + modifier = Modifier + .width(6.dp) + .height(h.toInt().dp) + .clip(RoundedCornerShape(8.dp)) + .background( + Brush.verticalGradient( + colors = listOf( + AppColors.primaryAccent.copy(alpha = 0.8f), + AppColors.primaryAccent.copy(alpha = 0.4f), + ), + ), + ), + ) + } + } + + Spacer(modifier = Modifier.height(48.dp)) + + Text( + text = "Start a conversation", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = "Type a message below to get started", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } +} + +// ==================== +// MODEL SELECTION PROMPT +// ==================== + +@Composable +fun ModelSelectionPrompt(onSelectModel: () -> Unit) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.primaryContainer, + ) { + Column( + modifier = Modifier.padding(Dimensions.mediumLarge), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(Dimensions.smallMedium), + ) { + Text( + text = "Welcome! Select and download a model to start chatting.", + style = AppTypography.caption, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + + Button( + onClick = onSelectModel, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + Text( + text = "Select Model", + style = AppTypography.caption, + ) + } + } + } +} + +// ==================== +// INPUT AREA +// ==================== + +@Composable +fun ChatInputView( + value: String, + onValueChange: (String) -> Unit, + onSend: () -> Unit, + isGenerating: Boolean, + isModelLoaded: Boolean, +) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surface, + shadowElevation = 8.dp, + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.Bottom, + ) { + // Text field with rounded container + val canSendMessage = isModelLoaded && !isGenerating && value.trim().isNotBlank() + + Box( + modifier = Modifier + .weight(1f) + .background( + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + shape = RoundedCornerShape(24.dp), + ), + ) { + TextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier.fillMaxWidth(), + placeholder = { + Text( + text = + when { + !isModelLoaded -> "Load a model first..." + isGenerating -> "Generating..." + else -> "Type a message..." + }, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + }, + enabled = isModelLoaded && !isGenerating, + textStyle = MaterialTheme.typography.bodyLarge, + colors = + TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + ), + maxLines = 4, + ) + } + + // Send button - larger, more prominent + IconButton( + onClick = onSend, + enabled = canSendMessage, + modifier = Modifier.size(44.dp), + ) { + Icon( + imageVector = Icons.Filled.ArrowCircleUp, + contentDescription = "Send", + tint = + if (canSendMessage) { + AppColors.primaryAccent + } else { + AppColors.statusGray.copy(alpha = 0.5f) + }, + modifier = Modifier.size(32.dp), + ) + } + } + } +} + +// ==================== +// CONVERSATION LIST SHEET +// Conversation List View +// ==================== + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConversationListSheet( + conversations: List, + currentConversationId: String?, + onDismiss: () -> Unit, + onConversationSelected: (Conversation) -> Unit, + onNewConversation: () -> Unit, + onDeleteConversation: (Conversation) -> Unit, +) { + var searchQuery by remember { mutableStateOf("") } + var conversationToDelete by remember { mutableStateOf(null) } + + val filteredConversations = + remember(conversations, searchQuery) { + if (searchQuery.isEmpty()) { + conversations + } else { + conversations.filter { conversation -> + conversation.title?.lowercase()?.contains(searchQuery.lowercase()) == true || + conversation.messages.any { it.content.lowercase().contains(searchQuery.lowercase()) } + } + } + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .fillMaxHeight(0.85f), + ) { + // Header + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton(onClick = onDismiss) { + Text("Done") + } + + Text( + text = "Conversations", + style = MaterialTheme.typography.titleMedium, + ) + + IconButton(onClick = onNewConversation) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = "New Conversation", + ) + } + } + + // Search bar + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + placeholder = { Text("Search conversations") }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = null, + ) + }, + trailingIcon = { + if (searchQuery.isNotEmpty()) { + IconButton(onClick = { searchQuery = "" }) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = "Clear", + ) + } + } + }, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + singleLine = true, + shape = RoundedCornerShape(12.dp), + ) + + // Conversation list + if (filteredConversations.isEmpty()) { + Box( + modifier = + Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center, + ) { + Text( + text = if (searchQuery.isEmpty()) "No conversations yet" else "No results found", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + LazyColumn( + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + ) { + items(filteredConversations, key = { it.id }) { conversation -> + ConversationRow( + conversation = conversation, + isSelected = conversation.id == currentConversationId, + onClick = { onConversationSelected(conversation) }, + onDelete = { conversationToDelete = conversation }, + ) + } + } + } + } + } + + // Delete confirmation dialog + conversationToDelete?.let { conversation -> + AlertDialog( + onDismissRequest = { conversationToDelete = null }, + title = { Text("Delete Conversation?") }, + text = { Text("This action cannot be undone.") }, + confirmButton = { + TextButton( + onClick = { + onDeleteConversation(conversation) + conversationToDelete = null + }, + ) { + Text("Delete", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { conversationToDelete = null }) { + Text("Cancel") + } + }, + ) + } +} + +@Composable +private fun ConversationRow( + conversation: Conversation, + isSelected: Boolean, + onClick: () -> Unit, + onDelete: () -> Unit, +) { + val dateFormatter = remember { SimpleDateFormat("MMM d", Locale.getDefault()) } + + Surface( + onClick = onClick, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + shape = RoundedCornerShape(12.dp), + color = + if (isSelected) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + } else { + Color.Transparent + }, + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + // Title + Text( + text = conversation.title ?: "New Chat", + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Spacer(modifier = Modifier.height(4.dp)) + + // Last message preview + Text( + text = + conversation.messages.lastOrNull()?.content?.take(100) + ?: "Start a conversation", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + + Spacer(modifier = Modifier.height(4.dp)) + + // Summary and date + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val messageCount = conversation.messages.size + val userMessages = conversation.messages.count { it.role == MessageRole.USER } + val aiMessages = conversation.messages.count { it.role == MessageRole.ASSISTANT } + + Text( + text = "$messageCount messages • $userMessages from you, $aiMessages from AI", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + + Spacer(modifier = Modifier.weight(1f)) + + Text( + text = dateFormatter.format(Date(conversation.updatedAt)), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + } + } + + // Delete button + IconButton(onClick = onDelete) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Delete", + tint = MaterialTheme.colorScheme.error.copy(alpha = 0.7f), + ) + } + } + } +} + +// ==================== +// CHAT DETAILS SHEET +// Chat Details View +// ==================== + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChatDetailsSheet( + messages: List, + conversationTitle: String, + modelName: String?, + onDismiss: () -> Unit, +) { + val analyticsMessages = + remember(messages) { + messages.filter { it.analytics != null } + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .fillMaxHeight(0.75f) + .padding(horizontal = 16.dp), + ) { + // Header - navigationTitle("Analytics"), toolbar Button("Done") { dismiss() } + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Spacer(modifier = Modifier.width(48.dp)) + + Text( + text = "Analytics", + style = MaterialTheme.typography.titleMedium, + ) + + TextButton(onClick = onDismiss) { + Text("Done", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Medium) + } + } + + HorizontalDivider() + + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(vertical = 16.dp), + ) { + // Conversation Info Section + item { + DetailsSection(title = "Conversation") { + DetailsRow(label = "Title", value = conversationTitle) + DetailsRow(label = "Messages", value = "${messages.size}") + DetailsRow( + label = "User Messages", + value = "${messages.count { it.role == MessageRole.USER }}", + ) + DetailsRow( + label = "AI Responses", + value = "${messages.count { it.role == MessageRole.ASSISTANT }}", + ) + modelName?.let { + DetailsRow(label = "Model", value = it) + } + } + } + + // Performance Summary Section + if (analyticsMessages.isNotEmpty()) { + item { + DetailsSection(title = "Performance Summary") { + val avgTTFT = + analyticsMessages + .mapNotNull { it.analytics?.timeToFirstToken } + .average() + .takeIf { !it.isNaN() } + + val avgSpeed = + analyticsMessages + .mapNotNull { it.analytics?.averageTokensPerSecond } + .average() + .takeIf { !it.isNaN() } + + val totalTokens = + analyticsMessages + .mapNotNull { it.analytics } + .sumOf { it.inputTokens + it.outputTokens } + + val thinkingCount = + analyticsMessages + .count { it.analytics?.wasThinkingMode == true } + + avgTTFT?.let { + DetailsRow( + label = "Avg Time to First Token", + value = String.format("%.2fs", it / 1000.0), + ) + } + + avgSpeed?.let { + DetailsRow( + label = "Avg Generation Speed", + value = String.format("%.1f tok/s", it), + ) + } + + DetailsRow(label = "Total Tokens", value = "$totalTokens") + + if (thinkingCount > 0) { + DetailsRow( + label = "Thinking Mode Usage", + value = "$thinkingCount/${analyticsMessages.size} responses", + ) + } + } + } + } + + // Individual Message Analytics + if (analyticsMessages.isNotEmpty()) { + item { + Text( + text = "Message Analytics", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) + } + + items(analyticsMessages.reversed()) { message -> + message.analytics?.let { analytics -> + MessageAnalyticsCard( + messagePreview = message.content.take(50) + if (message.content.length > 50) "..." else "", + analytics = analytics, + hasThinking = message.thinkingContent != null, + ) + } + } + } + } + } + } +} + +@Composable +private fun DetailsSection( + title: String, + content: @Composable ColumnScope.() -> Unit, +) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) + content() + } + } +} + +@Composable +private fun DetailsRow( + label: String, + value: String, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } +} + +@Composable +private fun MessageAnalyticsCard( + messagePreview: String, + analytics: com.runanywhere.runanywhereai.domain.models.MessageAnalytics, + hasThinking: Boolean, +) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surface, + border = + androidx.compose.foundation.BorderStroke( + 1.dp, + MaterialTheme.colorScheme.outline.copy(alpha = 0.3f), + ), + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Message preview + Text( + text = messagePreview, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + + HorizontalDivider() + + // Analytics grid + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column { + Text( + text = "Tokens", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "${analytics.outputTokens}", + style = MaterialTheme.typography.bodySmall, + ) + } + + Column { + Text( + text = "Speed", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = String.format("%.1f tok/s", analytics.averageTokensPerSecond), + style = MaterialTheme.typography.bodySmall, + ) + } + + Column { + Text( + text = "Time", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = String.format("%.2fs", analytics.totalGenerationTime / 1000.0), + style = MaterialTheme.typography.bodySmall, + ) + } + + if (hasThinking) { + Icon( + imageVector = Icons.Default.Lightbulb, + contentDescription = "Thinking mode", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.secondary, + ) + } + } + } + } +} + +// ==================== +// SELECTABLE TEXT DIALOG +// ==================== + +@Composable +private fun SelectableTextDialog( + text: String, + onDismiss: () -> Unit, +) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false, + ), + ) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + ) { + Column( + modifier = Modifier.fillMaxSize(), + ) { + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + tonalElevation = Dimensions.padding4, + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = Dimensions.padding16, vertical = Dimensions.padding12), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Select Text", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + IconButton(onClick = onDismiss) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Close", + ) + } + } + } + + // Selectable text content + SelectionContainer { + Box( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(Dimensions.padding16), + ) { + Text( + text = text, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + } + } +} diff --git a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/chat/ChatViewModel.kt b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/chat/ChatViewModel.kt new file mode 100644 index 000000000..83ff83a0b --- /dev/null +++ b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/chat/ChatViewModel.kt @@ -0,0 +1,730 @@ +package com.runanywhere.runanywhereai.presentation.chat + +import android.app.Application +import android.util.Log +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.runanywhere.runanywhereai.RunAnywhereApplication +import com.runanywhere.runanywhereai.data.ConversationStore +import com.runanywhere.runanywhereai.domain.models.ChatMessage +import com.runanywhere.runanywhereai.domain.models.CompletionStatus +import com.runanywhere.runanywhereai.domain.models.Conversation +import com.runanywhere.runanywhereai.domain.models.MessageAnalytics +import com.runanywhere.runanywhereai.domain.models.MessageModelInfo +import com.runanywhere.runanywhereai.domain.models.MessageRole +import com.runanywhere.sdk.public.RunAnywhere +import com.runanywhere.sdk.public.events.EventBus +import com.runanywhere.sdk.public.events.LLMEvent +import com.runanywhere.sdk.public.extensions.Models.ModelCategory +import com.runanywhere.sdk.public.extensions.availableModels +import com.runanywhere.sdk.public.extensions.cancelGeneration +import com.runanywhere.sdk.public.extensions.currentLLMModelId +import com.runanywhere.sdk.public.extensions.generate +import com.runanywhere.sdk.public.extensions.generateStream +import com.runanywhere.sdk.public.extensions.isLLMModelLoaded +import com.runanywhere.sdk.public.extensions.loadLLMModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.launch +import kotlin.math.ceil + +/** + * Enhanced ChatUiState matching iOS functionality + */ +data class ChatUiState( + val messages: List = emptyList(), + val isGenerating: Boolean = false, + val isModelLoaded: Boolean = false, + val loadedModelName: String? = null, + val currentInput: String = "", + val error: Throwable? = null, + val useStreaming: Boolean = true, + val currentConversation: Conversation? = null, +) { + val canSend: Boolean + get() = currentInput.trim().isNotEmpty() && !isGenerating && isModelLoaded +} + +/** + * Enhanced ChatViewModel matching iOS ChatViewModel functionality + * Includes streaming, thinking mode, analytics, and conversation management + * + * Architecture: + * - Uses RunAnywhere SDK extension functions directly + * - Model lifecycle via EventBus with LLMEvent filtering + * - Generation via RunAnywhere.generate() and RunAnywhere.generateStream() + */ +class ChatViewModel(application: Application) : AndroidViewModel(application) { + private val app = application as RunAnywhereApplication + private val conversationStore = ConversationStore.getInstance(application) + private val tokensPerSecondHistory = mutableListOf() + + private val _uiState = MutableStateFlow(ChatUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var generationJob: Job? = null + + init { + // Always start with a new conversation for a fresh chat experience + val conversation = conversationStore.createConversation() + _uiState.value = _uiState.value.copy(currentConversation = conversation) + + // Subscribe to LLM events from SDK EventBus + viewModelScope.launch { + EventBus.events + .filterIsInstance() + .collect { event -> + handleLLMEvent(event) + } + } + + // Initialize with system message if model is already loaded + viewModelScope.launch { + checkModelStatus() + // Add system message only if model is loaded + if (_uiState.value.isModelLoaded) { + addSystemMessage() + } + } + } + + /** + * Handle LLM events from SDK EventBus + * Uses the new data class with enum event types pattern + */ + private fun handleLLMEvent(event: LLMEvent) { + when (event.eventType) { + LLMEvent.LLMEventType.GENERATION_STARTED -> { + Log.d(TAG, "LLM generation started: ${event.modelId}") + } + LLMEvent.LLMEventType.GENERATION_COMPLETED -> { + Log.i(TAG, "✅ Generation completed: ${event.tokensGenerated} tokens") + _uiState.value = _uiState.value.copy(isGenerating = false) + } + LLMEvent.LLMEventType.GENERATION_FAILED -> { + Log.e(TAG, "Generation failed: ${event.error}") + _uiState.value = + _uiState.value.copy( + isGenerating = false, + error = Exception(event.error ?: "Generation failed"), + ) + } + LLMEvent.LLMEventType.STREAM_TOKEN -> { + // Token received during streaming - handled by flow collection + } + LLMEvent.LLMEventType.STREAM_COMPLETED -> { + Log.d(TAG, "Stream completed") + } + } + } + + /** + * Add system message + */ + private fun addSystemMessage() { + val modelName = _uiState.value.loadedModelName ?: return + + val content = "Model '$modelName' is loaded and ready to chat!" + val systemMessage = ChatMessage.system(content) + + val currentMessages = _uiState.value.messages.toMutableList() + currentMessages.add(0, systemMessage) + _uiState.value = _uiState.value.copy(messages = currentMessages) + + // Save to conversation store + _uiState.value.currentConversation?.let { conversation -> + val updatedConversation = conversation.copy(messages = currentMessages) + conversationStore.updateConversation(updatedConversation) + } + } + + /** + * Send message with streaming support and analytics + * Matches iOS sendMessage functionality + */ + fun sendMessage() { + val currentState = _uiState.value + + Log.i(TAG, "🎯 sendMessage() called") + Log.i(TAG, "📝 canSend: ${currentState.canSend}, isModelLoaded: ${currentState.isModelLoaded}, loadedModelName: ${currentState.loadedModelName}") + + if (!currentState.canSend) { + Log.w(TAG, "Cannot send message - canSend is false") + return + } + + Log.i(TAG, "✅ canSend is true, proceeding") + + val prompt = currentState.currentInput + Log.i(TAG, "🎯 Sending message: ${prompt.take(50)}...") + + // Clear input and set generating state + _uiState.value = + currentState.copy( + currentInput = "", + isGenerating = true, + error = null, + ) + + // Add user message + val userMessage = ChatMessage.user(prompt) + + _uiState.value = + _uiState.value.copy( + messages = _uiState.value.messages + userMessage, + ) + + // Save user message to conversation + _uiState.value.currentConversation?.let { conversation -> + conversationStore.addMessage(userMessage, conversation) + } + + // Create assistant message that will be updated with streaming tokens + val currentModelInfo = createCurrentModelInfo() + val assistantMessage = + ChatMessage.assistant( + content = "", + modelInfo = currentModelInfo, + ) + + _uiState.value = + _uiState.value.copy( + messages = _uiState.value.messages + assistantMessage, + ) + + // Start generation + generationJob = + viewModelScope.launch { + try { + // Clear metrics from previous generation + tokensPerSecondHistory.clear() + + if (currentState.useStreaming) { + generateWithStreaming(prompt, assistantMessage.id) + } else { + generateWithoutStreaming(prompt, assistantMessage.id) + } + } catch (e: Exception) { + handleGenerationError(e, assistantMessage.id) + } + } + } + + /** + * Generate with streaming support and thinking mode + * Matches iOS streaming generation pattern + */ + private suspend fun generateWithStreaming( + prompt: String, + messageId: String, + ) { + val startTime = System.currentTimeMillis() + var firstTokenTime: Long? = null + var thinkingStartTime: Long? = null + var thinkingEndTime: Long? = null + + var fullResponse = "" + var isInThinkingMode = false + var thinkingContent = "" + var responseContent = "" + var totalTokensReceived = 0 + var wasInterrupted = false + + Log.i(TAG, "📤 Starting streaming generation") + + try { + // Use SDK streaming generation - returns Flow + RunAnywhere.generateStream(prompt).collect { token -> + fullResponse += token + totalTokensReceived++ + + // Track first token time + if (firstTokenTime == null) { + firstTokenTime = System.currentTimeMillis() + } + + // Calculate real-time tokens per second + if (totalTokensReceived % 10 == 0) { + val elapsed = System.currentTimeMillis() - (firstTokenTime ?: startTime) + if (elapsed > 0) { + val currentSpeed = totalTokensReceived.toDouble() / (elapsed / 1000.0) + tokensPerSecondHistory.add(currentSpeed) + } + } + + // Handle thinking mode + if (fullResponse.contains("") && !isInThinkingMode) { + isInThinkingMode = true + thinkingStartTime = System.currentTimeMillis() + Log.i(TAG, "🧠 Entering thinking mode") + } + + if (isInThinkingMode) { + if (fullResponse.contains("")) { + // Extract thinking and response content + val thinkingRange = fullResponse.indexOf("") + 7 + val thinkingEndRange = fullResponse.indexOf("") + + if (thinkingRange < thinkingEndRange) { + thinkingContent = fullResponse.substring(thinkingRange, thinkingEndRange) + responseContent = fullResponse.substring(thinkingEndRange + 8) + isInThinkingMode = false + thinkingEndTime = System.currentTimeMillis() + Log.i(TAG, "🧠 Exiting thinking mode") + } + } else { + // Still in thinking mode + val thinkingRange = fullResponse.indexOf("") + 7 + if (thinkingRange < fullResponse.length) { + thinkingContent = fullResponse.substring(thinkingRange) + } + } + } else { + // Not in thinking mode, show response tokens directly + responseContent = fullResponse.replace("", "").trim() + } + + // Update the assistant message + updateAssistantMessage( + messageId = messageId, + content = if (isInThinkingMode) "" else responseContent, + thinkingContent = if (thinkingContent.isEmpty()) null else thinkingContent.trim(), + ) + } + } catch (e: Exception) { + Log.e(TAG, "Streaming failed", e) + wasInterrupted = true + throw e + } + + val endTime = System.currentTimeMillis() + + // Handle edge case: Stream ended while still in thinking mode + if (isInThinkingMode && !fullResponse.contains("")) { + Log.w(TAG, "⚠️ Stream ended while in thinking mode") + wasInterrupted = true + + if (thinkingContent.isNotEmpty()) { + val remainingContent = + fullResponse + .replace("", "") + .replace(thinkingContent, "") + .trim() + + val intelligentResponse = + if (remainingContent.isEmpty()) { + generateThinkingSummaryResponse(thinkingContent) + } else { + remainingContent + } + + updateAssistantMessage( + messageId = messageId, + content = intelligentResponse, + thinkingContent = thinkingContent.trim(), + ) + } + } + + // Create analytics + val analytics = + createMessageAnalytics( + startTime = startTime, + endTime = endTime, + firstTokenTime = firstTokenTime, + thinkingStartTime = thinkingStartTime, + thinkingEndTime = thinkingEndTime, + inputText = prompt, + outputText = responseContent, + thinkingText = thinkingContent.takeIf { it.isNotEmpty() }, + wasInterrupted = wasInterrupted, + ) + + // Update message with analytics + updateAssistantMessageWithAnalytics(messageId, analytics) + + _uiState.value = _uiState.value.copy(isGenerating = false) + Log.i(TAG, "✅ Streaming generation completed") + } + + /** + * Generate without streaming + */ + private suspend fun generateWithoutStreaming( + prompt: String, + messageId: String, + ) { + val startTime = System.currentTimeMillis() + + try { + // RunAnywhere.generate() returns LLMGenerationResult + val result = RunAnywhere.generate(prompt) + val response = result.text + val endTime = System.currentTimeMillis() + + updateAssistantMessage(messageId, response, null) + + val analytics = + createMessageAnalytics( + startTime = startTime, + endTime = endTime, + firstTokenTime = null, + thinkingStartTime = null, + thinkingEndTime = null, + inputText = prompt, + outputText = response, + thinkingText = null, + wasInterrupted = false, + ) + + updateAssistantMessageWithAnalytics(messageId, analytics) + } catch (e: Exception) { + throw e + } finally { + _uiState.value = _uiState.value.copy(isGenerating = false) + } + } + + /** + * Handle generation errors + */ + private fun handleGenerationError( + error: Exception, + messageId: String, + ) { + Log.e(TAG, "❌ Generation failed", error) + + val errorMessage = + when { + !_uiState.value.isModelLoaded -> "❌ No model is loaded. Please select and load a model first." + else -> "❌ Generation failed: ${error.message}" + } + + updateAssistantMessage(messageId, errorMessage, null) + + _uiState.value = + _uiState.value.copy( + isGenerating = false, + error = error, + ) + } + + /** + * Update assistant message content + */ + private fun updateAssistantMessage( + messageId: String, + content: String, + thinkingContent: String?, + ) { + val currentMessages = _uiState.value.messages + val updatedMessages = + currentMessages.map { message -> + if (message.id == messageId) { + message.copy( + content = content, + thinkingContent = thinkingContent, + ) + } else { + message + } + } + + _uiState.value = _uiState.value.copy(messages = updatedMessages) + } + + /** + * Update assistant message with analytics + */ + private fun updateAssistantMessageWithAnalytics( + messageId: String, + analytics: MessageAnalytics, + ) { + val currentMessages = _uiState.value.messages + val updatedMessages = + currentMessages.map { message -> + if (message.id == messageId) { + message.copy(analytics = analytics) + } else { + message + } + } + + _uiState.value = _uiState.value.copy(messages = updatedMessages) + } + + /** + * Create message analytics using app-local types + */ + @Suppress("UnusedParameter") + private fun createMessageAnalytics( + startTime: Long, + endTime: Long, + firstTokenTime: Long?, + thinkingStartTime: Long?, + thinkingEndTime: Long?, + inputText: String, + outputText: String, + thinkingText: String?, + wasInterrupted: Boolean, + ): MessageAnalytics { + val totalGenerationTime = endTime - startTime + val timeToFirstToken = firstTokenTime?.let { it - startTime } ?: 0L + + // Estimate token counts (simple approximation) + val inputTokens = estimateTokenCount(inputText) + val outputTokens = estimateTokenCount(outputText) + + val averageTokensPerSecond = + if (totalGenerationTime > 0) { + outputTokens.toDouble() / (totalGenerationTime / 1000.0) + } else { + 0.0 + } + + val completionStatus = + if (wasInterrupted) { + CompletionStatus.INTERRUPTED + } else { + CompletionStatus.COMPLETE + } + + return MessageAnalytics( + inputTokens = inputTokens, + outputTokens = outputTokens, + totalGenerationTime = totalGenerationTime, + timeToFirstToken = timeToFirstToken, + averageTokensPerSecond = averageTokensPerSecond, + wasThinkingMode = thinkingText != null, + completionStatus = completionStatus, + ) + } + + /** + * Simple token estimation (approximately 4 characters per token) + */ + private fun estimateTokenCount(text: String): Int { + return ceil(text.length / 4.0).toInt() + } + + /** + * Create MessageModelInfo for the current loaded model + */ + private fun createCurrentModelInfo(): MessageModelInfo? { + val modelName = _uiState.value.loadedModelName ?: return null + val modelId = RunAnywhere.currentLLMModelId ?: modelName + + return MessageModelInfo( + modelId = modelId, + modelName = modelName, + framework = "LLAMA_CPP", + ) + } + + /** + * Generate intelligent response from thinking content + */ + private fun generateThinkingSummaryResponse(thinkingContent: String): String { + val thinking = thinkingContent.trim() + + return when { + thinking.lowercase().contains("user") && thinking.lowercase().contains("help") -> + "I'm here to help! Let me know what you need." + + thinking.lowercase().contains("question") || thinking.lowercase().contains("ask") -> + "That's a good question. Let me think about this more." + + thinking.lowercase().contains("consider") || thinking.lowercase().contains("think") -> + "Let me consider this carefully. How can I help you further?" + + thinking.length > 200 -> + "I was thinking through this carefully. Could you help me understand what you're looking for?" + + else -> + "I'm processing your message. What would be most helpful for you?" + } + } + + /** + * Update current input text + */ + fun updateInput(input: String) { + _uiState.value = _uiState.value.copy(currentInput = input) + } + + /** + * Clear chat messages + */ + fun clearChat() { + generationJob?.cancel() + + _uiState.value = + _uiState.value.copy( + messages = emptyList(), + currentInput = "", + isGenerating = false, + error = null, + ) + + // Create new conversation + val conversation = conversationStore.createConversation() + _uiState.value = _uiState.value.copy(currentConversation = conversation) + + // Only add system message if model is loaded + if (_uiState.value.isModelLoaded) { + addSystemMessage() + } + } + + /** + * Stop current generation + */ + fun stopGeneration() { + generationJob?.cancel() + RunAnywhere.cancelGeneration() + _uiState.value = _uiState.value.copy(isGenerating = false) + } + + /** + * Check model status and load appropriate chat model. + */ + suspend fun checkModelStatus() { + try { + if (app.isSDKReady()) { + // Check if LLM is already loaded via SDK + if (RunAnywhere.isLLMModelLoaded()) { + val loadedModelId = RunAnywhere.currentLLMModelId + Log.i(TAG, "✅ LLM model already loaded: $loadedModelId") + _uiState.value = + _uiState.value.copy( + isModelLoaded = true, + loadedModelName = loadedModelId, + ) + addSystemMessageIfNeeded() + return + } + + // Use SDK's model listing API to find chat models + val allModels = RunAnywhere.availableModels() + val chatModel = + allModels.firstOrNull { model -> + model.category == ModelCategory.LANGUAGE && model.isDownloaded + } + + if (chatModel != null) { + Log.i(TAG, "📦 Found downloaded chat model: ${chatModel.name}, loading...") + + try { + // Load the chat model into memory + RunAnywhere.loadLLMModel(chatModel.id) + + _uiState.value = + _uiState.value.copy( + isModelLoaded = true, + loadedModelName = chatModel.name, + ) + Log.i(TAG, "✅ Chat model loaded successfully: ${chatModel.name}") + } catch (e: Throwable) { + // Catch Throwable to handle both Exception and Error (e.g., UnsatisfiedLinkError) + Log.e(TAG, "❌ Failed to load chat model: ${e.message}", e) + _uiState.value = + _uiState.value.copy( + isModelLoaded = false, + loadedModelName = null, + error = if (e is Exception) e else Exception("Native library not available: ${e.message}", e), + ) + } + } else { + _uiState.value = + _uiState.value.copy( + isModelLoaded = false, + loadedModelName = null, + ) + Log.i(TAG, "ℹ️ No downloaded chat models found.") + } + + addSystemMessageIfNeeded() + } else { + _uiState.value = + _uiState.value.copy( + isModelLoaded = false, + loadedModelName = null, + ) + Log.i(TAG, "❌ SDK not ready") + } + } catch (e: Throwable) { + // Catch Throwable to handle both Exception and Error (e.g., UnsatisfiedLinkError) + Log.e(TAG, "Failed to check model status: ${e.message}", e) + _uiState.value = + _uiState.value.copy( + isModelLoaded = false, + loadedModelName = null, + error = if (e is Exception) e else Exception("Failed to check model status: ${e.message}", e), + ) + } + } + + /** + * Helper to add system message if model is loaded and not already present. + */ + private fun addSystemMessageIfNeeded() { + // Update system message to reflect current state + val currentMessages = _uiState.value.messages.toMutableList() + if (currentMessages.firstOrNull()?.role == MessageRole.SYSTEM) { + currentMessages.removeAt(0) + } + _uiState.value = _uiState.value.copy(messages = currentMessages) + + if (_uiState.value.isModelLoaded) { + addSystemMessage() + } + } + + /** + * Load a conversation + */ + fun loadConversation(conversation: Conversation) { + _uiState.value = _uiState.value.copy(currentConversation = conversation) + + // For new conversations (empty messages), start fresh + // For existing conversations, load the messages + if (conversation.messages.isEmpty()) { + _uiState.value = _uiState.value.copy(messages = emptyList()) + // Add system message if model is loaded + if (_uiState.value.isModelLoaded) { + addSystemMessage() + } + } else { + _uiState.value = _uiState.value.copy(messages = conversation.messages) + + val analyticsCount = conversation.messages.mapNotNull { it.analytics }.size + Log.i(TAG, "📂 Loaded conversation with ${conversation.messages.size} messages, $analyticsCount have analytics") + } + + // Update model info if available + conversation.modelName?.let { modelName -> + _uiState.value = _uiState.value.copy(loadedModelName = modelName) + } + } + + /** + * Create a new conversation + */ + fun createNewConversation() { + clearChat() + } + + /** + * Clear error state + */ + fun clearError() { + _uiState.value = _uiState.value.copy(error = null) + } + + companion object { + private const val TAG = "ChatViewModel" + } +} diff --git a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/chat/components/MarkdownText.kt b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/chat/components/MarkdownText.kt new file mode 100644 index 000000000..3bdcd0341 --- /dev/null +++ b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/chat/components/MarkdownText.kt @@ -0,0 +1,483 @@ +package com.runanywhere.runanywhereai.presentation.chat.components + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.ClickableText +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * Lightweight Compose-native Markdown renderer for chat messages. + * + * Supports: + * - **Bold** and *italic* inline formatting + * - `inline code` + * - [links](url) + * - Fenced code blocks (``` ```) + * - Headers (#, ##, ###) + * - Bullet lists (-, *) + * - Numbered lists (1., 2.) + * - Horizontal rules (---, ***) + * - Blockquotes (>) + */ +@Composable +fun MarkdownText( + markdown: String, + modifier: Modifier = Modifier, + style: TextStyle = MaterialTheme.typography.bodyLarge, + color: Color = Color.Unspecified, +) { + val blocks = remember(markdown) { parseMarkdownBlocks(markdown) } + val context = LocalContext.current + + Column(modifier = modifier) { + blocks.forEachIndexed { index, block -> + when (block) { + is MarkdownBlock.CodeBlock -> { + CodeBlockView( + code = block.code, + language = block.language, + ) + } + + is MarkdownBlock.Header -> { + val headerStyle = when (block.level) { + 1 -> style.copy(fontSize = 22.sp, fontWeight = FontWeight.Bold) + 2 -> style.copy(fontSize = 19.sp, fontWeight = FontWeight.SemiBold) + 3 -> style.copy(fontSize = 17.sp, fontWeight = FontWeight.SemiBold) + else -> style.copy(fontWeight = FontWeight.SemiBold) + } + val annotated = parseInlineMarkdown(block.text, color) + ClickableText( + text = annotated, + style = headerStyle.merge(TextStyle(color = color)), + onClick = { offset -> + annotated.getStringAnnotations("URL", offset, offset) + .firstOrNull()?.let { annotation -> + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(annotation.item)) + context.startActivity(intent) + } + }, + ) + } + + is MarkdownBlock.BulletItem -> { + Row(modifier = Modifier.padding(start = 8.dp)) { + Text( + text = "\u2022", + style = style, + color = color, + ) + Spacer(modifier = Modifier.width(8.dp)) + val annotated = parseInlineMarkdown(block.text, color) + ClickableText( + text = annotated, + style = style.merge(TextStyle(color = color)), + modifier = Modifier.weight(1f), + onClick = { offset -> + annotated.getStringAnnotations("URL", offset, offset) + .firstOrNull()?.let { annotation -> + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(annotation.item)) + context.startActivity(intent) + } + }, + ) + } + } + + is MarkdownBlock.NumberedItem -> { + Row(modifier = Modifier.padding(start = 8.dp)) { + Text( + text = "${block.number}.", + style = style, + color = color, + ) + Spacer(modifier = Modifier.width(8.dp)) + val annotated = parseInlineMarkdown(block.text, color) + ClickableText( + text = annotated, + style = style.merge(TextStyle(color = color)), + modifier = Modifier.weight(1f), + onClick = { offset -> + annotated.getStringAnnotations("URL", offset, offset) + .firstOrNull()?.let { annotation -> + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(annotation.item)) + context.startActivity(intent) + } + }, + ) + } + } + + is MarkdownBlock.HorizontalRule -> { + HorizontalDivider( + modifier = Modifier.padding(vertical = 8.dp), + thickness = 1.dp, + color = color.copy(alpha = 0.2f), + ) + } + + is MarkdownBlock.Blockquote -> { + Row(modifier = Modifier.padding(vertical = 2.dp)) { + Box( + modifier = Modifier + .width(3.dp) + .height(20.dp) + .background(color.copy(alpha = 0.3f)), + ) + Spacer(modifier = Modifier.width(8.dp)) + val annotated = parseInlineMarkdown(block.text, color) + ClickableText( + text = annotated, + style = style.copy(fontStyle = FontStyle.Italic).merge(TextStyle(color = color)), + modifier = Modifier.weight(1f), + onClick = { offset -> + annotated.getStringAnnotations("URL", offset, offset) + .firstOrNull()?.let { annotation -> + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(annotation.item)) + context.startActivity(intent) + } + }, + ) + } + } + + is MarkdownBlock.Paragraph -> { + val annotated = parseInlineMarkdown(block.text, color) + ClickableText( + text = annotated, + style = style.merge(TextStyle(color = color)), + onClick = { offset -> + annotated.getStringAnnotations("URL", offset, offset) + .firstOrNull()?.let { annotation -> + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(annotation.item)) + context.startActivity(intent) + } + }, + ) + } + } + + // Add spacing between blocks (except last) + if (index < blocks.lastIndex) { + val spacing = when (block) { + is MarkdownBlock.Header -> 8.dp + is MarkdownBlock.CodeBlock -> 8.dp + is MarkdownBlock.HorizontalRule -> 0.dp + is MarkdownBlock.BulletItem -> 4.dp + is MarkdownBlock.NumberedItem -> 4.dp + else -> 6.dp + } + Spacer(modifier = Modifier.height(spacing)) + } + } + } +} + +@Composable +private fun CodeBlockView( + code: String, + language: String?, +) { + val codeBackground = MaterialTheme.colorScheme.surfaceVariant + val codeBorder = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f) + + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(codeBackground) + .padding(1.dp), + ) { + // Language label + if (!language.isNullOrBlank()) { + Text( + text = language, + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ), + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + ) + HorizontalDivider( + thickness = 0.5.dp, + color = codeBorder, + ) + } + + // Code content with horizontal scroll + Box( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(12.dp), + ) { + Text( + text = code, + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace, + fontSize = 13.sp, + lineHeight = 20.sp, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +// ============================================================ +// Markdown Parsing +// ============================================================ + +private sealed class MarkdownBlock { + data class Paragraph(val text: String) : MarkdownBlock() + data class Header(val level: Int, val text: String) : MarkdownBlock() + data class CodeBlock(val code: String, val language: String?) : MarkdownBlock() + data class BulletItem(val text: String) : MarkdownBlock() + data class NumberedItem(val number: Int, val text: String) : MarkdownBlock() + data class Blockquote(val text: String) : MarkdownBlock() + data object HorizontalRule : MarkdownBlock() +} + +private fun parseMarkdownBlocks(markdown: String): List { + val blocks = mutableListOf() + val lines = markdown.lines() + var i = 0 + + while (i < lines.size) { + val line = lines[i] + val trimmed = line.trim() + + when { + // Fenced code block + trimmed.startsWith("```") -> { + val language = trimmed.removePrefix("```").trim().takeIf { it.isNotEmpty() } + val codeLines = mutableListOf() + i++ + while (i < lines.size && !lines[i].trim().startsWith("```")) { + codeLines.add(lines[i]) + i++ + } + blocks.add(MarkdownBlock.CodeBlock(codeLines.joinToString("\n"), language)) + i++ // skip closing ``` + } + + // Horizontal rule + trimmed.matches(Regex("^[-*_]{3,}$")) -> { + blocks.add(MarkdownBlock.HorizontalRule) + i++ + } + + // Headers + trimmed.startsWith("### ") -> { + blocks.add(MarkdownBlock.Header(3, trimmed.removePrefix("### "))) + i++ + } + trimmed.startsWith("## ") -> { + blocks.add(MarkdownBlock.Header(2, trimmed.removePrefix("## "))) + i++ + } + trimmed.startsWith("# ") -> { + blocks.add(MarkdownBlock.Header(1, trimmed.removePrefix("# "))) + i++ + } + + // Bullet lists + trimmed.startsWith("- ") || trimmed.startsWith("* ") -> { + blocks.add(MarkdownBlock.BulletItem(trimmed.drop(2))) + i++ + } + + // Numbered lists + trimmed.matches(Regex("^\\d+\\.\\s+.*")) -> { + val match = Regex("^(\\d+)\\.\\s+(.*)").find(trimmed) + if (match != null) { + val (num, text) = match.destructured + blocks.add(MarkdownBlock.NumberedItem(num.toInt(), text)) + } + i++ + } + + // Blockquote + trimmed.startsWith("> ") -> { + blocks.add(MarkdownBlock.Blockquote(trimmed.removePrefix("> "))) + i++ + } + trimmed.startsWith(">") -> { + blocks.add(MarkdownBlock.Blockquote(trimmed.removePrefix(">"))) + i++ + } + + // Empty line - skip + trimmed.isEmpty() -> { + i++ + } + + // Regular paragraph - merge consecutive non-empty lines + else -> { + val paragraphLines = mutableListOf(line) + i++ + while (i < lines.size) { + val nextLine = lines[i].trim() + if (nextLine.isEmpty() || + nextLine.startsWith("```") || + nextLine.startsWith("#") || + nextLine.startsWith("- ") || + nextLine.startsWith("* ") || + nextLine.startsWith("> ") || + nextLine.matches(Regex("^\\d+\\.\\s+.*")) || + nextLine.matches(Regex("^[-*_]{3,}$")) + ) { + break + } + paragraphLines.add(lines[i]) + i++ + } + blocks.add(MarkdownBlock.Paragraph(paragraphLines.joinToString(" "))) + } + } + } + + return blocks +} + +/** + * Parse inline markdown formatting into AnnotatedString. + * Supports: **bold**, *italic*, `inline code`, [links](url), ***bold italic*** + */ +private fun parseInlineMarkdown(text: String, defaultColor: Color): AnnotatedString { + return buildAnnotatedString { + var i = 0 + val len = text.length + + while (i < len) { + when { + // Bold italic ***text*** + i + 2 < len && text.substring(i, i + 3) == "***" -> { + val end = text.indexOf("***", i + 3) + if (end != -1) { + withStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic)) { + append(text.substring(i + 3, end)) + } + i = end + 3 + } else { + append("***") + i += 3 + } + } + + // Bold **text** + i + 1 < len && text.substring(i, i + 2) == "**" -> { + val end = text.indexOf("**", i + 2) + if (end != -1) { + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + append(text.substring(i + 2, end)) + } + i = end + 2 + } else { + append("**") + i += 2 + } + } + + // Italic *text* (but not **) + text[i] == '*' && (i + 1 >= len || text[i + 1] != '*') -> { + val end = text.indexOf('*', i + 1) + if (end != -1 && end > i + 1) { + withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { + append(text.substring(i + 1, end)) + } + i = end + 1 + } else { + append('*') + i++ + } + } + + // Inline code `text` + text[i] == '`' -> { + val end = text.indexOf('`', i + 1) + if (end != -1) { + withStyle( + SpanStyle( + fontFamily = FontFamily.Monospace, + fontSize = 13.sp, + background = defaultColor.copy(alpha = 0.08f), + ), + ) { + append(" ${text.substring(i + 1, end)} ") + } + i = end + 1 + } else { + append('`') + i++ + } + } + + // Link [text](url) + text[i] == '[' -> { + val closeBracket = text.indexOf(']', i + 1) + if (closeBracket != -1 && closeBracket + 1 < len && text[closeBracket + 1] == '(') { + val closeParen = text.indexOf(')', closeBracket + 2) + if (closeParen != -1) { + val linkText = text.substring(i + 1, closeBracket) + val url = text.substring(closeBracket + 2, closeParen) + pushStringAnnotation("URL", url) + withStyle( + SpanStyle( + color = Color(0xFF3B82F6), + textDecoration = TextDecoration.Underline, + ), + ) { + append(linkText) + } + pop() + i = closeParen + 1 + } else { + append('[') + i++ + } + } else { + append('[') + i++ + } + } + + else -> { + append(text[i]) + i++ + } + } + } + } +} diff --git a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/chat/components/MessageInput.kt b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/chat/components/MessageInput.kt new file mode 100644 index 000000000..3b5222a5d --- /dev/null +++ b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/chat/components/MessageInput.kt @@ -0,0 +1,76 @@ +package com.runanywhere.runanywhereai.presentation.chat.components + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Send +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.unit.dp + +/** + * Message input component for typing and sending messages + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MessageInput( + text: String, + onTextChange: (String) -> Unit, + onSendMessage: () -> Unit, + enabled: Boolean = true, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier, + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(8.dp), + verticalAlignment = Alignment.Bottom, + ) { + OutlinedTextField( + value = text, + onValueChange = onTextChange, + modifier = Modifier.weight(1f), + placeholder = { + Text("Type a message...") + }, + enabled = enabled, + maxLines = 4, + keyboardOptions = + KeyboardOptions( + capitalization = KeyboardCapitalization.Sentences, + imeAction = ImeAction.Send, + ), + keyboardActions = + KeyboardActions( + onSend = { + if (text.isNotBlank() && enabled) { + onSendMessage() + } + }, + ), + ) + + Spacer(modifier = Modifier.width(8.dp)) + + FilledIconButton( + onClick = onSendMessage, + enabled = enabled && text.isNotBlank(), + ) { + Icon( + Icons.Default.Send, + contentDescription = "Send message", + ) + } + } + } +} diff --git a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/chat/components/ModelLoadedToast.kt b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/chat/components/ModelLoadedToast.kt new file mode 100644 index 000000000..a00390f93 --- /dev/null +++ b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/chat/components/ModelLoadedToast.kt @@ -0,0 +1,136 @@ +package com.runanywhere.runanywhereai.presentation.chat.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.runanywhere.runanywhereai.ui.theme.AppColors +import com.runanywhere.runanywhereai.ui.theme.AppTypography +import kotlinx.coroutines.delay + +/** + * Model Loaded Toast - matches iOS ModelLoadedToast.swift + * + * Shows a brief notification when a model finishes loading. + * - Green checkmark + "Model Ready" + model name + * - Slides in from top with spring animation + * - Auto-dismisses after 3 seconds + */ +@Composable +fun ModelLoadedToast( + modelName: String, + isVisible: Boolean, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + // Auto-dismiss after 3 seconds + LaunchedEffect(isVisible) { + if (isVisible) { + delay(3000) + onDismiss() + } + } + + Box( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + contentAlignment = Alignment.TopCenter, + ) { + AnimatedVisibility( + visible = isVisible, + enter = slideInVertically( + initialOffsetY = { -it }, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow, + ), + ) + fadeIn(), + exit = slideOutVertically( + targetOffsetY = { -it }, + ) + fadeOut(), + ) { + ToastContent(modelName = modelName) + } + } +} + +@Composable +private fun ToastContent(modelName: String) { + val shape = RoundedCornerShape(16.dp) + + Row( + modifier = Modifier + .shadow( + elevation = 16.dp, + shape = shape, + ambientColor = AppColors.shadowMedium, + spotColor = AppColors.shadowMedium, + ) + .background( + color = MaterialTheme.colorScheme.surface, + shape = shape, + ) + .border( + width = 0.5.dp, + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f), + shape = shape, + ) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start, + ) { + Icon( + imageVector = Icons.Filled.CheckCircle, + contentDescription = "Model Ready", + modifier = Modifier.size(20.dp), + tint = AppColors.primaryGreen, + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Column { + Text( + text = "Model Ready", + style = AppTypography.caption.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = "'$modelName' is loaded", + style = AppTypography.caption2, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/chat/components/ModelRequiredOverlay.kt b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/chat/components/ModelRequiredOverlay.kt new file mode 100644 index 000000000..692e0368b --- /dev/null +++ b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/chat/components/ModelRequiredOverlay.kt @@ -0,0 +1,262 @@ +package com.runanywhere.runanywhereai.presentation.chat.components + +import androidx.compose.animation.core.* +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.runanywhere.runanywhereai.ui.theme.AppColors +import com.runanywhere.runanywhereai.ui.theme.Dimensions +import com.runanywhere.sdk.public.extensions.Models.ModelSelectionContext + +/** + * ModelRequiredOverlay - Displays when a model needs to be selected + * + * Ported from iOS ModelStatusComponents.swift + * + * Features: + * - Animated floating circles background + * - Modality-specific icon, color, and messaging + * - "Get Started" CTA button + * - Privacy note footer + */ +@Composable +fun ModelRequiredOverlay( + modality: ModelSelectionContext = ModelSelectionContext.LLM, + onSelectModel: () -> Unit, + modifier: Modifier = Modifier, +) { + // Animation for floating circles + val infiniteTransition = rememberInfiniteTransition(label = "floatingCircles") + + val circle1Offset by infiniteTransition.animateFloat( + initialValue = -100f, + targetValue = 100f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 8000, easing = EaseInOut), + repeatMode = RepeatMode.Reverse + ), + label = "circle1" + ) + + val circle2Offset by infiniteTransition.animateFloat( + initialValue = 100f, + targetValue = -100f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 8000, easing = EaseInOut), + repeatMode = RepeatMode.Reverse + ), + label = "circle2" + ) + + val circle3Offset by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 80f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 8000, easing = EaseInOut), + repeatMode = RepeatMode.Reverse + ), + label = "circle3" + ) + + val modalityColor = getModalityColor(modality) + val modalityIcon = getModalityIcon(modality) + val modalityTitle = getModalityTitle(modality) + val modalityDescription = getModalityDescription(modality) + + Box( + modifier = modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + ) { + // Animated floating circles background + Box(modifier = Modifier.fillMaxSize()) { + // Circle 1 - Top left + Box( + modifier = Modifier + .size(300.dp) + .offset(x = circle1Offset.dp, y = (-200).dp) + .blur(80.dp) + .clip(CircleShape) + .background(modalityColor.copy(alpha = 0.15f)) + ) + + // Circle 2 - Bottom right + Box( + modifier = Modifier + .size(250.dp) + .offset(x = circle2Offset.dp, y = 300.dp) + .blur(100.dp) + .clip(CircleShape) + .background(modalityColor.copy(alpha = 0.12f)) + ) + + // Circle 3 - Center + Box( + modifier = Modifier + .size(280.dp) + .offset(x = (-circle3Offset).dp, y = circle3Offset.dp) + .blur(90.dp) + .clip(CircleShape) + .background(modalityColor.copy(alpha = 0.08f)) + ) + } + + // Main content + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = Dimensions.xLarge), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.weight(1f)) + + // Icon with gradient background + Box( + modifier = Modifier + .size(120.dp) + .clip(CircleShape) + .background( + Brush.linearGradient( + colors = listOf( + modalityColor.copy(alpha = 0.2f), + modalityColor.copy(alpha = 0.1f) + ) + ) + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = modalityIcon, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = modalityColor + ) + } + + Spacer(modifier = Modifier.height(Dimensions.xLarge)) + + // Title + Text( + text = modalityTitle, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground + ) + + Spacer(modifier = Modifier.height(Dimensions.medium)) + + // Description + Text( + text = modalityDescription, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 40.dp) + ) + + Spacer(modifier = Modifier.weight(1f)) + + // Bottom section + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(bottom = Dimensions.large) + ) { + // CTA Button + Button( + onClick = onSelectModel, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.buttonColors( + containerColor = modalityColor + ) + ) { + Icon( + imageVector = Icons.Default.AutoAwesome, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Get Started", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold + ) + } + + Spacer(modifier = Modifier.height(Dimensions.medium)) + + // Privacy note + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon( + imageVector = Icons.Default.Shield, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = "100% Private • Runs on your device", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } +} + +private fun getModalityIcon(modality: ModelSelectionContext): ImageVector { + return when (modality) { + ModelSelectionContext.LLM -> Icons.Default.AutoAwesome + ModelSelectionContext.STT -> Icons.Default.GraphicEq + ModelSelectionContext.TTS -> Icons.Default.VolumeUp + ModelSelectionContext.VOICE -> Icons.Default.Mic + } +} + +private fun getModalityColor(modality: ModelSelectionContext): Color { + return when (modality) { + ModelSelectionContext.LLM -> AppColors.primaryAccent + ModelSelectionContext.STT -> Color(0xFF4CAF50) // Green + ModelSelectionContext.TTS -> AppColors.primaryPurple + ModelSelectionContext.VOICE -> AppColors.primaryAccent + } +} + +private fun getModalityTitle(modality: ModelSelectionContext): String { + return when (modality) { + ModelSelectionContext.LLM -> "Welcome!" + ModelSelectionContext.STT -> "Voice to Text" + ModelSelectionContext.TTS -> "Read Aloud" + ModelSelectionContext.VOICE -> "Voice Assistant" + } +} + +private fun getModalityDescription(modality: ModelSelectionContext): String { + return when (modality) { + ModelSelectionContext.LLM -> "Choose your AI assistant and start chatting. Everything runs privately on your device." + ModelSelectionContext.STT -> "Transcribe your speech to text with powerful on-device voice recognition." + ModelSelectionContext.TTS -> "Have any text read aloud with natural-sounding voices." + ModelSelectionContext.VOICE -> "Talk naturally with your AI assistant. Let's set up the components together." + } +} diff --git a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/common/InitializationViews.kt b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/common/InitializationViews.kt new file mode 100644 index 000000000..88ce63307 --- /dev/null +++ b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/common/InitializationViews.kt @@ -0,0 +1,170 @@ +package com.runanywhere.runanywhereai.presentation.common + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Psychology +import androidx.compose.material.icons.outlined.Warning +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.runanywhere.runanywhereai.ui.theme.AppColors + +/** + * Loading view shown during SDK initialization. + * Matches iOS InitializationLoadingView exactly. + * + * iOS Reference: RunAnywhereAIApp.swift - InitializationLoadingView + * - Brain icon with pulsing animation (1.0 to 1.2 scale) + * - "Initializing RunAnywhere AI" title + * - "Setting up AI models and services..." subtitle + * - Circular progress indicator + */ +@Composable +fun InitializationLoadingView() { + // Pulsing animation state matching iOS pattern + var isAnimating by remember { mutableStateOf(false) } + val scale by animateFloatAsState( + targetValue = if (isAnimating) 1.2f else 1.0f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 1000), + ), + label = "brain_pulse", + ) + + LaunchedEffect(Unit) { + isAnimating = true + } + + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + Column( + modifier = + Modifier + .fillMaxSize() + .padding(40.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + // Brain icon with pulsing animation - matches iOS Image(systemName: "brain") + Icon( + imageVector = Icons.Outlined.Psychology, + contentDescription = "AI Brain", + modifier = Modifier.scale(scale), + tint = AppColors.primaryAccent, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // Title - matches iOS Text("Initializing RunAnywhere AI") + Text( + text = "Initializing RunAnywhere AI", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Subtitle - matches iOS Text("Setting up AI models and services...") + Text( + text = "Setting up AI models and services...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // Progress indicator - matches iOS ProgressView() + CircularProgressIndicator( + color = AppColors.primaryAccent, + ) + } + } +} + +/** + * Error view shown when SDK initialization fails. + * Matches iOS InitializationErrorView exactly. + * + * iOS Reference: RunAnywhereAIApp.swift - InitializationErrorView + * - Warning triangle icon (orange) + * - "Initialization Failed" title + * - Error description + * - Retry button + */ +@Composable +fun InitializationErrorView( + error: Throwable, + onRetry: () -> Unit, +) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + Column( + modifier = + Modifier + .fillMaxSize() + .padding(40.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + // Warning icon - matches iOS Image(systemName: "exclamationmark.triangle") + Icon( + imageVector = Icons.Outlined.Warning, + contentDescription = "Error", + tint = AppColors.warningOrange, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // Title - matches iOS Text("Initialization Failed") + Text( + text = "Initialization Failed", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Error description - matches iOS Text(error.localizedDescription) + Text( + text = error.localizedMessage ?: error.message ?: "Unknown error", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // Retry button - matches iOS Button("Retry") { retryAction() } + Button(onClick = onRetry) { + Text("Retry") + } + } + } +} diff --git a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/models/ModelSelectionBottomSheet.kt b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/models/ModelSelectionBottomSheet.kt new file mode 100644 index 000000000..585057c1b --- /dev/null +++ b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/models/ModelSelectionBottomSheet.kt @@ -0,0 +1,820 @@ +package com.runanywhere.runanywhereai.presentation.models + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.layout.ContentScale +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.outlined.Download +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.runanywhere.runanywhereai.R +import com.runanywhere.runanywhereai.ui.theme.AppColors +import com.runanywhere.runanywhereai.ui.theme.AppTypography +import com.runanywhere.runanywhereai.ui.theme.Dimensions +import com.runanywhere.sdk.core.types.InferenceFramework +import com.runanywhere.sdk.models.DeviceInfo +import com.runanywhere.sdk.public.RunAnywhere +import com.runanywhere.sdk.public.extensions.Models.ModelCategory +import com.runanywhere.sdk.public.extensions.Models.ModelFormat +import com.runanywhere.sdk.public.extensions.Models.ModelInfo +import com.runanywhere.sdk.public.extensions.Models.ModelSelectionContext +import com.runanywhere.sdk.public.extensions.loadTTSVoice +import kotlinx.coroutines.launch + +/** + * Model Selection Bottom Sheet - Context-Aware Implementation + * + * Now supports context-based filtering: + * - LLM: Shows text generation frameworks (llama.cpp, etc.) + * - STT: Shows speech recognition frameworks (WhisperKit, etc.) + * - TTS: Shows text-to-speech frameworks (System TTS, etc.) + * - VOICE: Shows all voice-related frameworks + * + * UI Hierarchy: + * 1. Navigation Bar (Title + Cancel/Add Model buttons) + * 2. Main Content List: + * - Section 1: Device Status + * - Section 2: Available Frameworks (filtered by context) + * - Section 3: Models for [Framework] (conditional) + * 3. Loading Overlay (when loading model) + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ModelSelectionBottomSheet( + context: ModelSelectionContext = ModelSelectionContext.LLM, + onDismiss: () -> Unit, + onModelSelected: suspend (ModelInfo) -> Unit, + viewModel: ModelSelectionViewModel = + viewModel( + // CRITICAL: Use context-specific key to prevent ViewModel caching across contexts + // Without this key, Compose reuses the same ViewModel instance for STT, LLM, and TTS + // which causes the wrong models to appear when switching between modalities + key = "ModelSelectionViewModel_${context.name}", + factory = ModelSelectionViewModel.Factory(context), + ), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val scope = rememberCoroutineScope() + val sheetState = + rememberModalBottomSheetState( + skipPartiallyExpanded = true, + ) + + ModalBottomSheet( + onDismissRequest = { if (!uiState.isLoadingModel) onDismiss() }, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surface, + ) { + Box { + // Main Content + LazyColumn( + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = Dimensions.large), + contentPadding = PaddingValues(Dimensions.large), + verticalArrangement = Arrangement.spacedBy(Dimensions.large), + ) { + // HEADER - toolbar: Cancel only, title in center + item { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton( + onClick = { if (!uiState.isLoadingModel) onDismiss() }, + enabled = !uiState.isLoadingModel, + ) { + Text("Cancel", style = AppTypography.caption, fontWeight = FontWeight.Medium) + } + Text( + text = uiState.context.title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Spacer(modifier = Modifier.width(64.dp)) + } + } + + // SECTION 1: Device Status + item { + DeviceStatusSection(deviceInfo = uiState.deviceInfo) + } + + // SECTION 2: Choose a Model + item { + Text( + text = "Choose a Model", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + if (uiState.isLoading) { + item { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = Dimensions.xLarge), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(Dimensions.mediumLarge), + ) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + Text( + text = "Loading available models...", + style = MaterialTheme.typography.bodyMedium, + color = AppColors.textSecondary, + ) + } + } + } else { + // System TTS row first when TTS context + if (context == ModelSelectionContext.TTS && uiState.frameworks.contains(InferenceFramework.SYSTEM_TTS)) { + item { + SystemTTSRow( + isLoading = uiState.isLoadingModel, + onSelect = { + scope.launch { + viewModel.setLoadingModel(true) + try { + val systemTTSModel = ModelInfo( + id = SYSTEM_TTS_MODEL_ID, + name = "System TTS", + downloadURL = null, + format = ModelFormat.UNKNOWN, + category = ModelCategory.SPEECH_SYNTHESIS, + framework = InferenceFramework.SYSTEM_TTS, + ) + onModelSelected(systemTTSModel) + onDismiss() + } finally { + viewModel.setLoadingModel(false) + } + } + }, + ) + } + } + + val sortedModels = uiState.models.sortedWith( + compareBy { if (it.framework == InferenceFramework.FOUNDATION_MODELS) 0 else if (it.isDownloaded) 1 else 2 } + .thenBy { it.name }, + ) + items(sortedModels, key = { it.id }) { model -> + SelectableModelRow( + model = model, + isSelected = uiState.currentModel?.id == model.id, + isLoading = uiState.isLoadingModel && uiState.selectedModelId == model.id, + onDownloadModel = { viewModel.startDownload(model.id) }, + onSelectModel = { + scope.launch { + viewModel.selectModel(model.id) + // Wait for model to actually finish loading instead of fixed delay + // Poll until loading completes (with timeout to prevent infinite wait) + var attempts = 0 + val maxAttempts = 120 // 60 seconds max (500ms * 120) + while (viewModel.uiState.value.isLoadingModel && attempts < maxAttempts) { + kotlinx.coroutines.delay(500) + attempts++ + } + // Only notify success if loading completed (not timed out while still loading) + if (!viewModel.uiState.value.isLoadingModel) { + onModelSelected(model) + } + onDismiss() + } + }, + ) + } + + item { + Text( + text = "All models run privately on your device. Larger models may provide better quality but use more memory.", + style = AppTypography.caption, + color = AppColors.textSecondary, + modifier = Modifier.padding(top = Dimensions.mediumLarge), + ) + } + } + } + + // LOADING OVERLAY + if (uiState.isLoadingModel) { + LoadingOverlay( + modelName = uiState.models.find { it.id == uiState.selectedModelId }?.name ?: "Model", + progress = uiState.loadingProgress, + ) + } + } + } +} + +// ==================== +// SECTION 1: DEVICE STATUS +// ==================== + +// Device Status section +@Composable +private fun DeviceStatusSection(deviceInfo: DeviceInfo?) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(Dimensions.smallMedium), + ) { + Text( + text = "Device Status", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + + if (deviceInfo != null) { + // Device info: Model, Chip, Memory + DeviceInfoRow(label = "Model", icon = Icons.Default.PhoneAndroid, value = deviceInfo.modelName) + DeviceInfoRow(label = "Chip", icon = Icons.Default.Memory, value = deviceInfo.architecture) + DeviceInfoRow( + label = "Memory", + icon = Icons.Default.Memory, + value = "${deviceInfo.totalMemoryMB} MB", + ) + } else { + Row( + horizontalArrangement = Arrangement.spacedBy(Dimensions.small), + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator(modifier = Modifier.size(16.dp)) + Text( + text = "Loading device info...", + style = MaterialTheme.typography.bodyMedium, + color = AppColors.textSecondary, + ) + } + } + } +} + +// DeviceInfoRow: Label + Spacer + Text(value).foregroundColor(AppColors.textSecondary) +@Composable +private fun DeviceInfoRow( + label: String, + icon: ImageVector, + value: String, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(Dimensions.small), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = icon, + contentDescription = label, + modifier = Modifier.size(Dimensions.iconRegular), + tint = MaterialTheme.colorScheme.onSurface, + ) + Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface) + } + Text( + value, + style = MaterialTheme.typography.bodyMedium, + color = AppColors.textSecondary, + ) + } +} + +// ==================== +// SECTION 2: AVAILABLE FRAMEWORKS +// ==================== + +@Composable +private fun AvailableFrameworksSection( + frameworks: List, + expandedFramework: InferenceFramework?, + isLoading: Boolean, + onToggleFramework: (InferenceFramework) -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(Dimensions.mediumLarge), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Column( + modifier = Modifier.padding(Dimensions.large), + verticalArrangement = Arrangement.spacedBy(Dimensions.smallMedium), + ) { + Text( + text = "Available Frameworks", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.SemiBold, + ) + + when { + isLoading -> { + Row( + horizontalArrangement = Arrangement.spacedBy(Dimensions.small), + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator(modifier = Modifier.size(16.dp)) + Text( + text = "Loading frameworks...", + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + frameworks.isEmpty() -> { + Column(verticalArrangement = Arrangement.spacedBy(Dimensions.small)) { + Text( + text = "No framework adapters are currently registered.", + style = AppTypography.caption2, + color = MaterialTheme.colorScheme.error, + ) + Text( + text = "Register framework adapters to see available frameworks.", + style = AppTypography.caption2, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + else -> { + frameworks.forEach { framework -> + FrameworkRow( + framework = framework, + isExpanded = expandedFramework == framework, + onTap = { onToggleFramework(framework) }, + ) + } + } + } + } + } +} + +@Composable +private fun FrameworkRow( + framework: InferenceFramework, + isExpanded: Boolean, + onTap: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onTap) + .padding(vertical = Dimensions.small), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(Dimensions.small), + verticalAlignment = Alignment.CenterVertically, + ) { + // Framework icon - context-aware + Icon( + imageVector = getFrameworkIcon(framework), + contentDescription = null, + modifier = Modifier.size(Dimensions.iconRegular), + tint = MaterialTheme.colorScheme.primary, + ) + + Column { + Text( + framework.displayName, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + Text( + getFrameworkDescription(framework), + style = AppTypography.caption2, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Icon( + imageVector = if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = if (isExpanded) "Collapse" else "Expand", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** + * Get drawable resource ID for model logo - matches iOS ModelInfo+Logo.swift logoAssetName + */ +private fun getModelLogoResId(model: ModelInfo): Int { + val name = model.name.lowercase() + return when { + model.framework == InferenceFramework.FOUNDATION_MODELS || + model.framework == InferenceFramework.SYSTEM_TTS -> R.drawable.foundation_models_logo + name.contains("llama") -> R.drawable.llama_logo + name.contains("mistral") -> R.drawable.mistral_logo + name.contains("qwen") -> R.drawable.qwen_logo + name.contains("liquid") -> R.drawable.liquid_ai_logo + name.contains("piper") -> R.drawable.hugging_face_logo + name.contains("whisper") -> R.drawable.hugging_face_logo + name.contains("sherpa") -> R.drawable.hugging_face_logo + else -> R.drawable.hugging_face_logo + } +} + +/** + * Get icon for framework - matches iOS iconForFramework + */ +private fun getFrameworkIcon(framework: InferenceFramework): ImageVector { + return when (framework) { + InferenceFramework.LLAMA_CPP -> Icons.Default.Memory + InferenceFramework.ONNX -> Icons.Default.Hub + InferenceFramework.SYSTEM_TTS -> Icons.Default.VolumeUp + InferenceFramework.FOUNDATION_MODELS -> Icons.Default.AutoAwesome + InferenceFramework.FLUID_AUDIO -> Icons.Default.Mic + InferenceFramework.BUILT_IN -> Icons.Default.Settings + else -> Icons.Default.Settings + } +} + +/** + * Get description for framework - matches iOS + */ +private fun getFrameworkDescription(framework: InferenceFramework): String { + return when (framework) { + InferenceFramework.LLAMA_CPP -> "High-performance LLM inference" + InferenceFramework.ONNX -> "ONNX Runtime inference" + InferenceFramework.SYSTEM_TTS -> "Built-in text-to-speech" + InferenceFramework.FOUNDATION_MODELS -> "Foundation models" + InferenceFramework.FLUID_AUDIO -> "FluidAudio synthesis" + InferenceFramework.BUILT_IN -> "Built-in algorithms" + InferenceFramework.NONE -> "No framework" + InferenceFramework.UNKNOWN -> "Unknown framework" + } +} + +// ==================== +// SECTION 3: MODELS LIST +// ==================== + +@Composable +private fun EmptyModelsMessage(framework: InferenceFramework) { + Column( + verticalArrangement = Arrangement.spacedBy(Dimensions.small), + modifier = Modifier.padding(vertical = Dimensions.small), + ) { + Text( + text = "No models available for ${framework.displayName}", + style = AppTypography.caption, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "Tap 'Add Model' to add a model from URL", + style = AppTypography.caption2, + color = MaterialTheme.colorScheme.primary, + ) + } +} + +// FlatModelRow-style row +@Composable +private fun SelectableModelRow( + model: ModelInfo, + isSelected: Boolean, + isLoading: Boolean, + onDownloadModel: () -> Unit, + onSelectModel: () -> Unit, +) { + val isBuiltIn = + model.framework == InferenceFramework.FOUNDATION_MODELS || + model.framework == InferenceFramework.SYSTEM_TTS + val isDownloaded = model.isDownloaded + val canDownload = model.downloadURL != null + + val frameworkColor = when (model.framework) { + InferenceFramework.LLAMA_CPP -> AppColors.primaryAccent + InferenceFramework.ONNX -> AppColors.primaryPurple + InferenceFramework.FOUNDATION_MODELS -> MaterialTheme.colorScheme.primary + InferenceFramework.SYSTEM_TTS -> AppColors.primaryAccent + else -> AppColors.statusGray + } + val frameworkName = when (model.framework) { + InferenceFramework.LLAMA_CPP -> "Fast" + InferenceFramework.ONNX -> "ONNX" + InferenceFramework.FOUNDATION_MODELS -> "Apple" + InferenceFramework.SYSTEM_TTS -> "System" + else -> model.framework.displayName + } + + val statusIcon = Icons.Default.CheckCircle + val statusColor = if (isBuiltIn || isDownloaded) AppColors.statusGreen else AppColors.primaryAccent + val statusText = when { + isBuiltIn -> "Built-in" + isDownloaded -> "Ready" + else -> "" + } + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = Dimensions.smallMedium) + .then(Modifier.alpha(if (isLoading && !isSelected) 0.6f else 1f)), + horizontalArrangement = Arrangement.spacedBy(Dimensions.mediumLarge), + verticalAlignment = Alignment.CenterVertically, + ) { + // Model logo + Box( + modifier = + Modifier + .size(40.dp) + .clip(RoundedCornerShape(Dimensions.cornerRadiusRegular)), + ) { + Image( + painter = painterResource(id = getModelLogoResId(model)), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit, + ) + } + + // Model name + framework badge + status row + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(Dimensions.xSmall), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(Dimensions.smallMedium), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = model.name, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Surface( + shape = RoundedCornerShape(Dimensions.cornerRadiusSmall), + color = frameworkColor.copy(alpha = 0.15f), + ) { + Text( + text = frameworkName, + style = AppTypography.caption2, + fontWeight = FontWeight.Medium, + color = frameworkColor, + modifier = Modifier.padding(horizontal = Dimensions.small, vertical = Dimensions.xxSmall), + ) + } + } + Row( + horizontalArrangement = Arrangement.spacedBy(Dimensions.smallMedium), + verticalAlignment = Alignment.CenterVertically, + ) { + if (statusText.isNotEmpty()) { + Row( + horizontalArrangement = Arrangement.spacedBy(Dimensions.xxSmall), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = statusIcon, + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = statusColor, + ) + Text( + text = statusText, + style = AppTypography.caption2, + color = statusColor, + ) + } + } + if (model.supportsThinking) { + Surface( + shape = RoundedCornerShape(Dimensions.cornerRadiusSmall), + color = AppColors.badgePurple, + ) { + Row( + modifier = Modifier.padding(horizontal = Dimensions.small, vertical = Dimensions.xxSmall), + horizontalArrangement = Arrangement.spacedBy(Dimensions.xxSmall), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Psychology, + contentDescription = null, + modifier = Modifier.size(10.dp), + tint = AppColors.primaryPurple, + ) + Text( + text = "Smart", + style = AppTypography.caption2, + color = AppColors.primaryPurple, + ) + } + } + } + } + } + + // Action button: "Use" (borderedProminent primaryAccent) or "Get" (bordered primaryAccent) + when { + isLoading -> CircularProgressIndicator(modifier = Modifier.size(24.dp), color = AppColors.primaryAccent) + isBuiltIn || isDownloaded -> { + Button( + onClick = onSelectModel, + enabled = !isLoading && !isSelected, + colors = ButtonDefaults.buttonColors(containerColor = AppColors.primaryAccent), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp), + ) { + Text("Use", style = AppTypography.caption, fontWeight = FontWeight.SemiBold) + } + } + canDownload -> { + Button( + onClick = onDownloadModel, + enabled = !isLoading, + colors = ButtonDefaults.outlinedButtonColors(contentColor = AppColors.primaryAccent), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(Dimensions.xxSmall), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Outlined.Download, + contentDescription = null, + modifier = Modifier.size(14.dp), + ) + Text( + text = + if ((model.downloadSize ?: 0) > 0) { + formatBytes(model.downloadSize!!) + } else { + "Get" + }, + style = AppTypography.caption, + fontWeight = FontWeight.SemiBold, + ) + } + } + } + } + } +} + +// ==================== +// LOADING OVERLAY +// ==================== + +// LoadingModelOverlay: overlayMedium, card backgroundPrimary, headline + subheadline textSecondary +@Composable +private fun LoadingOverlay( + @Suppress("UNUSED_PARAMETER") modelName: String, + progress: String, +) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(AppColors.overlayMedium), + contentAlignment = Alignment.Center, + ) { + Card( + modifier = Modifier.padding(Dimensions.xxLarge), + shape = RoundedCornerShape(Dimensions.cornerRadiusXLarge), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = Dimensions.shadowXLarge), + ) { + Column( + modifier = Modifier.padding(Dimensions.xxLarge), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(Dimensions.xLarge), + ) { + CircularProgressIndicator( + modifier = Modifier.size(36.dp), + color = AppColors.primaryAccent, + ) + Text( + text = "Loading Model", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = progress, + style = MaterialTheme.typography.bodyMedium, + color = AppColors.textSecondary, + ) + } + } + } +} + +// ==================== +// SYSTEM TTS ROW +// ==================== + +// SystemTTSRow: "System Voice", "System" badge, "Built-in - Always available", "Use" button primaryAccent +@Composable +private fun SystemTTSRow( + isLoading: Boolean, + onSelect: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = Dimensions.smallMedium), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(Dimensions.xSmall), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(Dimensions.smallMedium), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "System Voice", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + ) + Surface( + shape = RoundedCornerShape(Dimensions.cornerRadiusSmall), + color = AppColors.primaryAccent.copy(alpha = 0.1f), + ) { + Text( + text = "System", + style = AppTypography.caption2, + fontWeight = FontWeight.Medium, + color = AppColors.primaryAccent, + modifier = Modifier.padding(horizontal = Dimensions.small, vertical = Dimensions.xxSmall), + ) + } + } + Row( + horizontalArrangement = Arrangement.spacedBy(Dimensions.xxSmall), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = AppColors.statusGreen, + ) + Text( + text = "Built-in - Always available", + style = AppTypography.caption2, + color = AppColors.statusGreen, + ) + } + } + Button( + onClick = onSelect, + enabled = !isLoading, + colors = ButtonDefaults.buttonColors(containerColor = AppColors.primaryAccent), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp), + ) { + Text("Use", style = AppTypography.caption, fontWeight = FontWeight.SemiBold) + } + } +} + +private const val SYSTEM_TTS_MODEL_ID = "system-tts" + +// ==================== +// UTILITY FUNCTIONS +// ==================== + +private fun formatBytes(bytes: Long): String { + val gb = bytes / (1024.0 * 1024.0 * 1024.0) + return if (gb >= 1.0) { + String.format("%.2f GB", gb) + } else { + val mb = bytes / (1024.0 * 1024.0) + String.format("%.0f MB", mb) + } +} diff --git a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/models/ModelSelectionViewModel.kt b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/models/ModelSelectionViewModel.kt new file mode 100644 index 000000000..f7c313550 --- /dev/null +++ b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/models/ModelSelectionViewModel.kt @@ -0,0 +1,409 @@ +package com.runanywhere.runanywhereai.presentation.models + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.runanywhere.sdk.core.types.InferenceFramework +import com.runanywhere.sdk.models.DeviceInfo +import com.runanywhere.sdk.models.collectDeviceInfo +import com.runanywhere.sdk.public.RunAnywhere +import com.runanywhere.sdk.public.events.EventBus +import com.runanywhere.sdk.public.events.ModelEvent +import com.runanywhere.sdk.public.extensions.Models.ModelCategory +import com.runanywhere.sdk.public.extensions.Models.ModelInfo +import com.runanywhere.sdk.public.extensions.Models.ModelSelectionContext +import com.runanywhere.sdk.public.extensions.availableModels +import com.runanywhere.sdk.public.extensions.currentLLMModelId +import com.runanywhere.sdk.public.extensions.currentSTTModelId +import com.runanywhere.sdk.public.extensions.currentTTSVoiceId +import com.runanywhere.sdk.public.extensions.downloadModel +import com.runanywhere.sdk.public.extensions.loadLLMModel +import com.runanywhere.sdk.public.extensions.loadSTTModel +import com.runanywhere.sdk.public.extensions.loadTTSVoice +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** + * ViewModel for Model Selection Bottom Sheet + * Matches iOS ModelListViewModel functionality with context-aware filtering + * + * Reference: iOS ModelSelectionSheet.swift + */ +class ModelSelectionViewModel( + private val context: ModelSelectionContext = ModelSelectionContext.LLM, +) : ViewModel() { + private val _uiState = MutableStateFlow(ModelSelectionUiState(context = context)) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadDeviceInfo() + loadModelsAndFrameworks() + subscribeToDownloadEvents() + } + + /** + * Subscribe to SDK download progress events to update UI + */ + private fun subscribeToDownloadEvents() { + viewModelScope.launch { + Log.d(TAG, "📡 Subscribed to download progress events") + EventBus.events + .filterIsInstance() + .collect { event -> + when (event.eventType) { + ModelEvent.ModelEventType.DOWNLOAD_PROGRESS -> { + val progressPercent = ((event.progress ?: 0f) * 100).toInt() + Log.d(TAG, "📊 Download progress: ${event.modelId} - $progressPercent%") + _uiState.update { + it.copy(loadingProgress = "Downloading... $progressPercent%") + } + } + ModelEvent.ModelEventType.DOWNLOAD_COMPLETED -> { + Log.d(TAG, "✅ Download completed: ${event.modelId}") + loadModelsAndFrameworks() // Refresh models list + } + ModelEvent.ModelEventType.DOWNLOAD_FAILED -> { + Log.e(TAG, "❌ Download failed: ${event.modelId} - ${event.error}") + _uiState.update { + it.copy( + isLoadingModel = false, + loadingProgress = "", + error = event.error ?: "Download failed", + ) + } + } + else -> {} + } + } + } + } + + private fun loadDeviceInfo() { + viewModelScope.launch { + val deviceInfo = collectDeviceInfo() + _uiState.update { it.copy(deviceInfo = deviceInfo) } + } + } + + /** + * Load models from SDK with context-aware filtering + * Matches iOS ModelListViewModel.loadModels() with ModelSelectionContext filtering + */ + private fun loadModelsAndFrameworks() { + viewModelScope.launch { + try { + Log.d(TAG, "🔄 Loading models and frameworks for context: $context") + + // Call SDK to get available models + val allModels = RunAnywhere.availableModels() + Log.d(TAG, "📦 Fetched ${allModels.size} total models from SDK") + + // Filter models by context - matches iOS relevantCategories filtering + val filteredModels = + allModels.filter { model -> + isModelRelevantForContext(model.category, context) + } + Log.d(TAG, "📦 Filtered to ${filteredModels.size} models for context $context") + + // Extract unique frameworks from filtered models + val relevantFrameworks = + filteredModels + .map { it.framework } + .toSet() + .sortedBy { it.displayName } + .toMutableList() + + // For TTS context, ensure System TTS is included (matches iOS behavior) + if (context == ModelSelectionContext.TTS && !relevantFrameworks.contains(InferenceFramework.SYSTEM_TTS)) { + relevantFrameworks.add(0, InferenceFramework.SYSTEM_TTS) + Log.d(TAG, "📱 Added System TTS for TTS context") + } + + Log.d(TAG, "✅ Loaded ${filteredModels.size} models and ${relevantFrameworks.size} frameworks") + relevantFrameworks.forEach { fw -> + Log.d(TAG, " Framework: ${fw.displayName}") + } + + // Sync with currently loaded model from SDK + // This ensures already-loaded models show as "Loaded" in the sheet + val currentLoadedModelId = getCurrentLoadedModelIdForContext() + val currentLoadedModel = + if (currentLoadedModelId != null) { + filteredModels.find { it.id == currentLoadedModelId } + } else { + null + } + + if (currentLoadedModel != null) { + Log.d(TAG, "✅ Found currently loaded model for context $context: ${currentLoadedModel.id}") + } + + _uiState.update { + it.copy( + models = filteredModels, + frameworks = relevantFrameworks, + isLoading = false, + error = null, + currentModel = currentLoadedModel, + ) + } + } catch (e: Exception) { + Log.e(TAG, "❌ Failed to load models: ${e.message}", e) + _uiState.update { + it.copy( + isLoading = false, + error = e.message ?: "Failed to load models", + ) + } + } + } + } + + /** + * Get the currently loaded model ID for this context from the SDK. + * This syncs the selection sheet with what's actually loaded in memory. + * Matches iOS's pattern of querying currentModelId from CppBridge. + */ + private fun getCurrentLoadedModelIdForContext(): String? { + return when (context) { + ModelSelectionContext.LLM -> RunAnywhere.currentLLMModelId + ModelSelectionContext.STT -> RunAnywhere.currentSTTModelId + ModelSelectionContext.TTS -> RunAnywhere.currentTTSVoiceId + ModelSelectionContext.VOICE -> { + // For voice context, we could return any of the three + // but typically the voice sheet doesn't auto-select + null + } + } + } + + /** + * Check if a model category is relevant for the current selection context + */ + private fun isModelRelevantForContext( + category: ModelCategory, + ctx: ModelSelectionContext, + ): Boolean { + return when (ctx) { + ModelSelectionContext.LLM -> category == ModelCategory.LANGUAGE + ModelSelectionContext.STT -> category == ModelCategory.SPEECH_RECOGNITION + ModelSelectionContext.TTS -> category == ModelCategory.SPEECH_SYNTHESIS + ModelSelectionContext.VOICE -> + category in + listOf( + ModelCategory.LANGUAGE, + ModelCategory.SPEECH_RECOGNITION, + ModelCategory.SPEECH_SYNTHESIS, + ) + } + } + + /** + * Toggle framework expansion + */ + fun toggleFramework(framework: InferenceFramework) { + Log.d(TAG, "🔀 Toggling framework: ${framework.displayName}") + _uiState.update { + it.copy( + expandedFramework = if (it.expandedFramework == framework) null else framework, + ) + } + } + + /** + * Get models for a specific framework + */ + fun getModelsForFramework(framework: InferenceFramework): List { + return _uiState.value.models.filter { model -> + model.framework == framework + } + } + + /** + * Download model with progress + */ + fun startDownload(modelId: String) { + viewModelScope.launch { + try { + Log.d(TAG, "⬇️ Starting download for model: $modelId") + + _uiState.update { + it.copy( + selectedModelId = modelId, + isLoadingModel = true, + loadingProgress = "Starting download...", + ) + } + + // Call SDK download API - it returns a Flow + RunAnywhere.downloadModel(modelId) + .catch { e -> + Log.e(TAG, "❌ Download stream error: ${e.message}") + _uiState.update { + it.copy( + isLoadingModel = false, + selectedModelId = null, + loadingProgress = "", + error = e.message ?: "Download failed", + ) + } + } + .collect { progress -> + val percent = (progress.progress * 100).toInt() + Log.d(TAG, "📥 Download progress: $percent%") + _uiState.update { + it.copy(loadingProgress = "Downloading... $percent%") + } + } + + Log.d(TAG, "✅ Download completed for $modelId") + + // Small delay to ensure registry update propagates + delay(500) + + // Reload models after download completes + loadModelsAndFrameworks() + + _uiState.update { + it.copy( + isLoadingModel = false, + selectedModelId = null, + loadingProgress = "", + ) + } + } catch (e: Exception) { + Log.e(TAG, "❌ Download failed for $modelId: ${e.message}", e) + _uiState.update { + it.copy( + isLoadingModel = false, + selectedModelId = null, + loadingProgress = "", + error = e.message ?: "Download failed", + ) + } + } + } + } + + /** + * Select and load model - context-aware loading + * Matches iOS context-based loading + */ + suspend fun selectModel(modelId: String) { + try { + Log.d(TAG, "🔄 Loading model into memory: $modelId (context: $context)") + + _uiState.update { + it.copy( + selectedModelId = modelId, + isLoadingModel = true, + loadingProgress = "Loading model into memory...", + ) + } + + // Context-aware model loading - matches iOS exactly + when (context) { + ModelSelectionContext.LLM -> { + RunAnywhere.loadLLMModel(modelId) + } + ModelSelectionContext.STT -> { + RunAnywhere.loadSTTModel(modelId) + } + ModelSelectionContext.TTS -> { + RunAnywhere.loadTTSVoice(modelId) + } + ModelSelectionContext.VOICE -> { + // For voice context, determine from model category + val model = _uiState.value.models.find { it.id == modelId } + when (model?.category) { + ModelCategory.SPEECH_RECOGNITION -> RunAnywhere.loadSTTModel(modelId) + ModelCategory.SPEECH_SYNTHESIS -> RunAnywhere.loadTTSVoice(modelId) + else -> RunAnywhere.loadLLMModel(modelId) + } + } + } + + Log.d(TAG, "✅ Model loaded successfully: $modelId") + + // Get the loaded model + val loadedModel = _uiState.value.models.find { it.id == modelId } + + _uiState.update { + it.copy( + loadingProgress = "Model loaded successfully!", + isLoadingModel = false, + selectedModelId = null, + currentModel = loadedModel, + ) + } + } catch (e: Exception) { + Log.e(TAG, "❌ Failed to load model $modelId: ${e.message}", e) + _uiState.update { + it.copy( + isLoadingModel = false, + selectedModelId = null, + loadingProgress = "", + error = e.message ?: "Failed to load model", + ) + } + } + } + + /** + * Refresh models list + */ + fun refreshModels() { + loadModelsAndFrameworks() + } + + /** + * Set loading model state + * Used for System TTS which doesn't require model download + */ + fun setLoadingModel(isLoading: Boolean) { + _uiState.update { + it.copy(isLoadingModel = isLoading) + } + } + + /** + * Factory for creating ViewModel with context parameter + */ + class Factory(private val context: ModelSelectionContext) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(ModelSelectionViewModel::class.java)) { + return ModelSelectionViewModel(context) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } + } + + companion object { + private const val TAG = "ModelSelectionVM" + } +} + +/** + * UI State for Model Selection Bottom Sheet + */ +data class ModelSelectionUiState( + val context: ModelSelectionContext = ModelSelectionContext.LLM, + val deviceInfo: DeviceInfo? = null, + val models: List = emptyList(), + val frameworks: List = emptyList(), + val expandedFramework: InferenceFramework? = null, + val selectedModelId: String? = null, + val currentModel: ModelInfo? = null, + val isLoading: Boolean = true, + val isLoadingModel: Boolean = false, + val loadingProgress: String = "", + val error: String? = null, +) diff --git a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/navigation/AppNavigation.kt b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/navigation/AppNavigation.kt new file mode 100644 index 000000000..81e6cc737 --- /dev/null +++ b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/navigation/AppNavigation.kt @@ -0,0 +1,178 @@ +package com.runanywhere.runanywhereai.presentation.navigation + +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.outlined.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import com.runanywhere.runanywhereai.presentation.chat.ChatScreen +import com.runanywhere.runanywhereai.presentation.settings.SettingsScreen +import com.runanywhere.runanywhereai.presentation.stt.SpeechToTextScreen +import com.runanywhere.runanywhereai.presentation.tts.TextToSpeechScreen +import com.runanywhere.runanywhereai.presentation.voice.VoiceAssistantScreen +import com.runanywhere.runanywhereai.ui.theme.AppColors + +/** + * Main navigation component + * 5 tabs: Chat, STT, TTS, Voice, Settings + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AppNavigation() { + val navController = rememberNavController() + + Scaffold( + bottomBar = { + RunAnywhereBottomNav(navController = navController) + }, + ) { paddingValues -> + NavHost( + navController = navController, + startDestination = NavigationRoute.CHAT, + modifier = Modifier.padding(paddingValues), + ) { + composable(NavigationRoute.CHAT) { + ChatScreen() + } + + composable(NavigationRoute.STT) { + SpeechToTextScreen() + } + + composable(NavigationRoute.TTS) { + TextToSpeechScreen() + } + + composable(NavigationRoute.VOICE) { + VoiceAssistantScreen() + } + + composable(NavigationRoute.SETTINGS) { + SettingsScreen() + } + } + } +} + +/** + * Bottom navigation bar + * - Chat (message icon) + * - STT (waveform icon) + * - TTS (speaker.wave.2 icon) + * - Voice (mic icon) + * - Settings (gear icon) + */ +@Composable +fun RunAnywhereBottomNav(navController: NavController) { + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentDestination = navBackStackEntry?.destination + + // Tab labels and icons: Chat, Transcribe, Speak, Voice, Settings + val items = + listOf( + BottomNavItem( + route = NavigationRoute.CHAT, + label = "Chat", + icon = Icons.Outlined.Chat, + selectedIcon = Icons.Filled.Chat, + ), + BottomNavItem( + route = NavigationRoute.STT, + label = "Transcribe", + icon = Icons.Outlined.GraphicEq, + selectedIcon = Icons.Filled.GraphicEq, + ), + BottomNavItem( + route = NavigationRoute.TTS, + label = "Speak", + icon = Icons.Outlined.VolumeUp, + selectedIcon = Icons.Filled.VolumeUp, + ), + BottomNavItem( + route = NavigationRoute.VOICE, + label = "Voice", + icon = Icons.Outlined.Mic, + selectedIcon = Icons.Filled.Mic, + ), + BottomNavItem( + route = NavigationRoute.SETTINGS, + label = "Settings", + icon = Icons.Outlined.Settings, + selectedIcon = Icons.Filled.Settings, + ), + ) + + // Selected tab uses primary accent + NavigationBar( + containerColor = MaterialTheme.colorScheme.surface, + tonalElevation = 0.dp, + ) { + items.forEach { item -> + val selected = currentDestination?.hierarchy?.any { it.route == item.route } == true + + NavigationBarItem( + icon = { + Icon( + imageVector = if (selected) item.selectedIcon else item.icon, + contentDescription = item.label, + ) + }, + label = { Text(item.label) }, + selected = selected, + colors = + NavigationBarItemDefaults.colors( + selectedIconColor = AppColors.primaryAccent, + selectedTextColor = AppColors.primaryAccent, + indicatorColor = AppColors.primaryAccent.copy(alpha = 0.12f), + unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + onClick = { + navController.navigate(item.route) { + // Pop up to the start destination to avoid building up a large stack + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + // Avoid multiple copies of the same destination + launchSingleTop = true + // Restore state when reselecting a previously selected item + restoreState = true + } + }, + ) + } + } +} + +/** + * Navigation routes + */ +object NavigationRoute { + const val CHAT = "chat" + const val STT = "stt" + const val TTS = "tts" + const val VOICE = "voice" + const val SETTINGS = "settings" +} + +/** + * Bottom navigation item data + */ +data class BottomNavItem( + val route: String, + val label: String, + val icon: ImageVector, + val selectedIcon: ImageVector = icon, +) diff --git a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/settings/SettingsScreen.kt b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/settings/SettingsScreen.kt new file mode 100644 index 000000000..361356dda --- /dev/null +++ b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/settings/SettingsScreen.kt @@ -0,0 +1,703 @@ +@file:OptIn(kotlin.time.ExperimentalTime::class) + +package com.runanywhere.runanywhereai.presentation.settings + +import android.content.Intent +import android.net.Uri +import android.text.format.Formatter +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.outlined.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.runanywhere.runanywhereai.ui.theme.AppColors +import com.runanywhere.runanywhereai.ui.theme.AppTypography +import com.runanywhere.runanywhereai.ui.theme.Dimensions + +/** + * Settings screen + * + * Section order: Generation Settings, API Configuration, Storage Overview, Downloaded Models, + * Storage Management, Logging Configuration, About. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen(viewModel: SettingsViewModel = viewModel()) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + var showDeleteConfirmDialog by remember { mutableStateOf(null) } + + // Refresh storage data when the screen appears + // This ensures downloaded models and storage metrics are up-to-date + LaunchedEffect(Unit) { + viewModel.refreshStorage() + } + + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { + // Header + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Dimensions.padding16, vertical = Dimensions.padding16), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Settings", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + ) + } + + // 1. Generation Settings + SettingsSection(title = "Generation Settings") { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = "Temperature: ${"%.2f".format(uiState.temperature)}", + style = AppTypography.caption, + color = AppColors.textSecondary, + ) + Slider( + value = uiState.temperature, + onValueChange = { viewModel.updateTemperature(it) }, + valueRange = 0f..2f, + steps = 19, + modifier = Modifier.fillMaxWidth(), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Max Tokens: ${uiState.maxTokens}", + style = MaterialTheme.typography.bodyMedium, + ) + Row(verticalAlignment = Alignment.CenterVertically) { + OutlinedButton( + onClick = { viewModel.updateMaxTokens((uiState.maxTokens - 500).coerceAtLeast(500)) }, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp), + modifier = Modifier.height(32.dp), + ) { Text("-", style = AppTypography.caption) } + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "${uiState.maxTokens}", + style = AppTypography.caption, + modifier = Modifier.widthIn(min = 48.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + OutlinedButton( + onClick = { viewModel.updateMaxTokens((uiState.maxTokens + 500).coerceAtMost(20000)) }, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp), + modifier = Modifier.height(32.dp), + ) { Text("+", style = AppTypography.caption) } + } + } + } + } + + // 2. API Configuration (Testing) + SettingsSection(title = "API Configuration (Testing)") { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { viewModel.showApiConfigSheet() } + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text("API Key", style = MaterialTheme.typography.bodyMedium) + Text( + text = if (uiState.isApiKeyConfigured) "Configured" else "Not Set", + style = AppTypography.caption, + color = if (uiState.isApiKeyConfigured) AppColors.statusGreen else AppColors.statusOrange, + ) + } + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Base URL", style = MaterialTheme.typography.bodyMedium) + Text( + text = if (uiState.isBaseURLConfigured) "Configured" else "Using Default", + style = AppTypography.caption, + color = if (uiState.isBaseURLConfigured) AppColors.statusGreen else AppColors.textSecondary, + ) + } + if (uiState.isApiKeyConfigured && uiState.isBaseURLConfigured) { + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { viewModel.clearApiConfiguration() } + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + Icons.Outlined.Delete, + contentDescription = null, + tint = AppColors.primaryRed, + modifier = Modifier.size(20.dp), + ) + Text( + text = "Clear Custom Configuration", + style = MaterialTheme.typography.bodyMedium, + color = AppColors.primaryRed, + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Configure custom API key and base URL for testing. Requires app restart to take effect.", + style = AppTypography.caption, + color = AppColors.textSecondary, + ) + } + + // 3. Storage Overview - iOS Label(systemImage: "externaldrive") etc. + SettingsSection( + title = "Storage Overview", + trailing = { + TextButton(onClick = { viewModel.refreshStorage() }) { + Text("Refresh", style = AppTypography.caption) + } + }, + ) { + StorageOverviewRow( + icon = Icons.Outlined.Storage, + label = "Total Usage", + value = Formatter.formatFileSize(context, uiState.totalStorageSize), + valueColor = AppColors.textSecondary, + ) + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + StorageOverviewRow( + icon = Icons.Outlined.CloudQueue, + label = "Available Space", + value = Formatter.formatFileSize(context, uiState.availableSpace), + valueColor = AppColors.primaryGreen, + ) + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + StorageOverviewRow( + icon = Icons.Outlined.Memory, + label = "Models Storage", + value = Formatter.formatFileSize(context, uiState.modelStorageSize), + valueColor = AppColors.primaryAccent, + ) + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + StorageOverviewRow( + icon = Icons.Outlined.Numbers, + label = "Downloaded Models", + value = uiState.downloadedModels.size.toString(), + valueColor = AppColors.textSecondary, + ) + } + + // 4. Downloaded Models + SettingsSection(title = "Downloaded Models") { + if (uiState.downloadedModels.isEmpty()) { + Text( + text = "No models downloaded yet", + style = AppTypography.caption, + color = AppColors.textSecondary, + modifier = Modifier.padding(vertical = 8.dp), + ) + } else { + uiState.downloadedModels.forEachIndexed { index, model -> + StoredModelRow( + model = model, + onDelete = { showDeleteConfirmDialog = model }, + ) + if (index < uiState.downloadedModels.lastIndex) { + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + } + } + } + } + + // 5. Storage Management - iOS trash icon, red/orange + SettingsSection(title = "Storage Management") { + StorageManagementButton( + title = "Clear Cache", + subtitle = "Free up space by clearing cached data", + icon = Icons.Outlined.Delete, + color = AppColors.primaryRed, + onClick = { viewModel.clearCache() }, + ) + Spacer(modifier = Modifier.height(12.dp)) + StorageManagementButton( + title = "Clean Temporary Files", + subtitle = "Remove temporary files and logs", + icon = Icons.Outlined.Delete, + color = AppColors.primaryOrange, + onClick = { viewModel.cleanTempFiles() }, + ) + } + + // 6. Logging Configuration - iOS Toggle "Log Analytics Locally" + SettingsSection(title = "Logging Configuration") { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Log Analytics Locally", + style = MaterialTheme.typography.bodyMedium, + ) + Switch( + checked = uiState.analyticsLogToLocal, + onCheckedChange = { viewModel.updateAnalyticsLogToLocal(it) }, + ) + } + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "When enabled, analytics events will be saved locally on your device.", + style = AppTypography.caption, + color = AppColors.textSecondary, + ) + } + + // 7. About - iOS Label "RunAnywhere SDK" systemImage "cube", "Documentation" systemImage "book" + SettingsSection(title = "About") { + Row( + modifier = Modifier.padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + Icons.Outlined.Widgets, + contentDescription = null, + tint = AppColors.primaryAccent, + modifier = Modifier.size(24.dp), + ) + Column { + Text( + text = "RunAnywhere SDK", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = "Version 0.1", + style = AppTypography.caption, + color = AppColors.textSecondary, + ) + } + } + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://docs.runanywhere.ai")) + context.startActivity(intent) + } + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + Icons.Outlined.MenuBook, + contentDescription = null, + tint = AppColors.primaryAccent, + modifier = Modifier.size(24.dp), + ) + Text( + text = "Documentation", + style = MaterialTheme.typography.bodyMedium, + color = AppColors.primaryAccent, + ) + Spacer(modifier = Modifier.weight(1f)) + Icon( + Icons.Default.OpenInNew, + contentDescription = "Open link", + modifier = Modifier.size(16.dp), + tint = AppColors.primaryAccent, + ) + } + } + + Spacer(modifier = Modifier.height(32.dp)) + } + + // Delete Confirmation Dialog + showDeleteConfirmDialog?.let { model -> + AlertDialog( + onDismissRequest = { showDeleteConfirmDialog = null }, + title = { Text("Delete Model") }, + text = { + Text("Are you sure you want to delete ${model.name}? This action cannot be undone.") + }, + confirmButton = { + TextButton( + onClick = { + viewModel.deleteModelById(model.id) + showDeleteConfirmDialog = null + }, + colors = + ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + Text("Delete") + } + }, + dismissButton = { + TextButton(onClick = { showDeleteConfirmDialog = null }) { + Text("Cancel") + } + }, + ) + } + + // API Configuration Dialog + if (uiState.showApiConfigSheet) { + ApiConfigurationDialog( + apiKey = uiState.apiKey, + baseURL = uiState.baseURL, + onApiKeyChange = { viewModel.updateApiKey(it) }, + onBaseURLChange = { viewModel.updateBaseURL(it) }, + onSave = { viewModel.saveApiConfiguration() }, + onDismiss = { viewModel.hideApiConfigSheet() }, + ) + } + + // Restart Required Dialog - iOS exact message + if (uiState.showRestartDialog) { + AlertDialog( + onDismissRequest = { viewModel.dismissRestartDialog() }, + title = { Text("Restart Required") }, + text = { + Text("Please restart the app for the new API configuration to take effect. The SDK will be reinitialized with your custom settings.") + }, + confirmButton = { + TextButton( + onClick = { viewModel.dismissRestartDialog() }, + ) { + Text("OK") + } + }, + icon = { + Icon( + imageVector = Icons.Outlined.RestartAlt, + contentDescription = null, + tint = AppColors.primaryOrange, + ) + }, + ) + } +} + +/** + * Settings Section wrapper + */ +@Composable +private fun SettingsSection( + title: String, + trailing: @Composable (() -> Unit)? = null, + content: @Composable ColumnScope.() -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Dimensions.padding16, vertical = 8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = title, + style = MaterialTheme.typography.headlineMedium, + color = AppColors.textSecondary, + ) + trailing?.invoke() + } + Spacer(modifier = Modifier.height(8.dp)) + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(Dimensions.cornerRadiusXLarge), + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + Column( + modifier = Modifier.padding(Dimensions.padding16), + content = content, + ) + } + } +} + +/** + * Storage Overview Row + */ +@Composable +private fun StorageOverviewRow( + icon: androidx.compose.ui.graphics.vector.ImageVector, + label: String, + value: String, + valueColor: Color = AppColors.textSecondary, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + ) + } + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + color = valueColor, + ) + } +} + +/** + * Stored Model Row + */ +@Composable +private fun StoredModelRow( + model: StoredModelInfo, + onDelete: () -> Unit, +) { + val context = LocalContext.current + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + // Left: Model name - iOS AppTypography.subheadlineMedium, caption2 for size + Column(modifier = Modifier.weight(1f)) { + Text( + text = model.name, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + Text( + text = Formatter.formatFileSize(context, model.size), + style = AppTypography.caption2, + color = AppColors.textSecondary, + ) + } + + // Right: Size and delete button + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = Formatter.formatFileSize(context, model.size), + style = AppTypography.caption, + color = AppColors.textSecondary, + ) + IconButton( + onClick = onDelete, + modifier = Modifier.size(32.dp), + ) { + Icon( + Icons.Outlined.Delete, + contentDescription = "Delete", + modifier = Modifier.size(20.dp), + tint = AppColors.primaryRed, + ) + } + } + } +} + +/** + * Storage Management Button - iOS StorageManagementButton with icon, title, subtitle + */ +@Composable +private fun StorageManagementButton( + title: String, + subtitle: String, + icon: androidx.compose.ui.graphics.vector.ImageVector, + color: Color, + onClick: () -> Unit, +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + shape = RoundedCornerShape(Dimensions.cornerRadiusRegular), + color = color.copy(alpha = 0.1f), + border = androidx.compose.foundation.BorderStroke( + Dimensions.strokeRegular, + color.copy(alpha = 0.3f), + ), + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = icon, + contentDescription = null, + tint = color, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + color = color, + ) + } + Text( + text = subtitle, + style = AppTypography.caption, + color = AppColors.textSecondary, + ) + } + } +} + +/** + * API Configuration Dialog - iOS ApiConfigurationSheet + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ApiConfigurationDialog( + apiKey: String, + baseURL: String, + onApiKeyChange: (String) -> Unit, + onBaseURLChange: (String) -> Unit, + onSave: () -> Unit, + onDismiss: () -> Unit, +) { + var showPassword by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("API Configuration") }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // API Key - iOS SecureField "Enter API Key" + OutlinedTextField( + value = apiKey, + onValueChange = onApiKeyChange, + label = { Text("API Key") }, + placeholder = { Text("Enter API Key") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + trailingIcon = { + IconButton(onClick = { showPassword = !showPassword }) { + Icon( + imageVector = if (showPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, + contentDescription = if (showPassword) "Hide password" else "Show password", + ) + } + }, + supportingText = { + Text("Your API key for authenticating with the backend", style = AppTypography.caption) + }, + ) + + // Base URL Input + OutlinedTextField( + value = baseURL, + onValueChange = onBaseURLChange, + label = { Text("Base URL") }, + placeholder = { Text("https://api.example.com") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + supportingText = { + Text("The backend API URL (e.g., https://api.runanywhere.ai)", style = AppTypography.caption) + }, + ) + + // Warning + Surface( + color = AppColors.primaryOrange.copy(alpha = 0.1f), + shape = RoundedCornerShape(8.dp), + ) { + Row( + modifier = Modifier.padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.Top, + ) { + Icon( + imageVector = Icons.Outlined.Warning, + contentDescription = null, + tint = AppColors.primaryOrange, + modifier = Modifier.size(20.dp), + ) + Text( + text = "After saving, you must restart the app for changes to take effect. The SDK will reinitialize with your custom configuration.", + style = AppTypography.caption, + color = AppColors.textSecondary, + ) + } + } + } + }, + confirmButton = { + TextButton( + onClick = onSave, + enabled = apiKey.isNotEmpty() && baseURL.isNotEmpty(), + ) { + Text("Save") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + }, + ) +} diff --git a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/settings/SettingsViewModel.kt b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/settings/SettingsViewModel.kt new file mode 100644 index 000000000..d34717f18 --- /dev/null +++ b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/settings/SettingsViewModel.kt @@ -0,0 +1,509 @@ +package com.runanywhere.runanywhereai.presentation.settings + +import android.app.Application +import android.content.Context +import android.util.Log +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import com.runanywhere.sdk.public.RunAnywhere +import com.runanywhere.sdk.public.events.EventBus +import com.runanywhere.sdk.public.events.ModelEvent +import com.runanywhere.sdk.public.extensions.clearCache +import com.runanywhere.sdk.public.extensions.deleteModel +import com.runanywhere.sdk.public.extensions.storageInfo +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** + * Simple stored model info for settings display + */ +data class StoredModelInfo( + val id: String, + val name: String, + val size: Long, +) + +/** + * Settings UI State + */ +@OptIn(kotlin.time.ExperimentalTime::class) +data class SettingsUiState( + // Generation Settings + val temperature: Float = 0.7f, + val maxTokens: Int = 10000, + // Logging Configuration + val analyticsLogToLocal: Boolean = false, + // Storage Overview + val totalStorageSize: Long = 0L, + val availableSpace: Long = 0L, + val modelStorageSize: Long = 0L, + // Downloaded Models + val downloadedModels: List = emptyList(), + // API Configuration + val apiKey: String = "", + val baseURL: String = "", + val isApiKeyConfigured: Boolean = false, + val isBaseURLConfigured: Boolean = false, + val showApiConfigSheet: Boolean = false, + val showRestartDialog: Boolean = false, + // Loading states + val isLoading: Boolean = false, + val errorMessage: String? = null, +) + +/** + * Settings ViewModel + * + * This ViewModel manages: + * - Storage overview via RunAnywhere.getStorageInfo() + * - Model management via RunAnywhere storage APIs + * - API configuration (API key and base URL) + */ +class SettingsViewModel(application: Application) : AndroidViewModel(application) { + private val _uiState = MutableStateFlow(SettingsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val encryptedPrefs by lazy { + val masterKey = MasterKey.Builder(application) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + EncryptedSharedPreferences.create( + application, + ENCRYPTED_PREFS_FILE, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + } + + private val settingsPrefs by lazy { + application.getSharedPreferences(SETTINGS_PREFS, Context.MODE_PRIVATE) + } + + companion object { + private const val TAG = "SettingsViewModel" + private const val ENCRYPTED_PREFS_FILE = "runanywhere_secure_prefs" + private const val SETTINGS_PREFS = "runanywhere_settings" + private const val KEY_API_KEY = "runanywhere_api_key" + private const val KEY_BASE_URL = "runanywhere_base_url" + private const val KEY_DEVICE_REGISTERED = "com.runanywhere.sdk.deviceRegistered" + private const val KEY_TEMPERATURE = "defaultTemperature" + private const val KEY_MAX_TOKENS = "defaultMaxTokens" + private const val KEY_ANALYTICS_LOG_LOCAL = "analyticsLogToLocal" + + /** + * Get stored API key (for use at app launch) + */ + fun getStoredApiKey(context: Context): String? { + return try { + val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + val prefs = EncryptedSharedPreferences.create( + context, + ENCRYPTED_PREFS_FILE, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + val value = prefs.getString(KEY_API_KEY, null) + if (value.isNullOrEmpty()) null else value + } catch (e: Exception) { + Log.e(TAG, "Failed to get stored API key", e) + null + } + } + + /** + * Get stored base URL (for use at app launch) + * Automatically adds https:// if no scheme is present + */ + fun getStoredBaseURL(context: Context): String? { + return try { + val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + val prefs = EncryptedSharedPreferences.create( + context, + ENCRYPTED_PREFS_FILE, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + val value = prefs.getString(KEY_BASE_URL, null) + if (value.isNullOrEmpty()) return null + + // Normalize URL by adding https:// if no scheme present + val trimmed = value.trim() + if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) { + trimmed + } else { + "https://$trimmed" + } + } catch (e: Exception) { + Log.e(TAG, "Failed to get stored base URL", e) + null + } + } + + /** + * Check if custom configuration is set + */ + fun hasCustomConfiguration(context: Context): Boolean { + return getStoredApiKey(context) != null && getStoredBaseURL(context) != null + } + } + + init { + loadGenerationSettings() + loadAnalyticsPreference() + loadApiConfiguration() + loadStorageData() + subscribeToModelEvents() + } + + private fun loadGenerationSettings() { + val temp = settingsPrefs.getFloat(KEY_TEMPERATURE, 0.7f) + val max = settingsPrefs.getInt(KEY_MAX_TOKENS, 10000) + _uiState.update { it.copy(temperature = temp, maxTokens = max) } + } + + private fun loadAnalyticsPreference() { + val value = settingsPrefs.getBoolean(KEY_ANALYTICS_LOG_LOCAL, false) + _uiState.update { it.copy(analyticsLogToLocal = value) } + } + + fun updateTemperature(value: Float) { + _uiState.update { it.copy(temperature = value) } + settingsPrefs.edit().putFloat(KEY_TEMPERATURE, value).apply() + } + + fun updateMaxTokens(value: Int) { + _uiState.update { it.copy(maxTokens = value) } + settingsPrefs.edit().putInt(KEY_MAX_TOKENS, value).apply() + } + + fun updateAnalyticsLogToLocal(value: Boolean) { + _uiState.update { it.copy(analyticsLogToLocal = value) } + settingsPrefs.edit().putBoolean(KEY_ANALYTICS_LOG_LOCAL, value).apply() + } + + /** + * Subscribe to SDK model events to automatically refresh storage when models are downloaded/deleted + */ + private fun subscribeToModelEvents() { + viewModelScope.launch { + EventBus.events + .filterIsInstance() + .collect { event -> + when (event.eventType) { + ModelEvent.ModelEventType.DOWNLOAD_COMPLETED -> { + Log.d(TAG, "📥 Model download completed: ${event.modelId}, refreshing storage...") + loadStorageData() + } + ModelEvent.ModelEventType.DELETED -> { + Log.d(TAG, "🗑️ Model deleted: ${event.modelId}, refreshing storage...") + loadStorageData() + } + else -> { + // Other events don't require storage refresh + } + } + } + } + } + + /** + * Load storage data using SDK's storageInfo() API + */ + private fun loadStorageData() { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + + try { + Log.d(TAG, "Loading storage info via storageInfo()...") + + // Use SDK's storageInfo() + val storageInfo = RunAnywhere.storageInfo() + + // Map stored models to UI model + val storedModels = + storageInfo.storedModels.map { model -> + StoredModelInfo( + id = model.id, + name = model.name, + size = model.size, + ) + } + + Log.d(TAG, "Storage info received:") + Log.d(TAG, " - Total space: ${storageInfo.deviceStorage.totalSpace}") + Log.d(TAG, " - Free space: ${storageInfo.deviceStorage.freeSpace}") + Log.d(TAG, " - Model storage size: ${storageInfo.totalModelsSize}") + Log.d(TAG, " - Stored models count: ${storedModels.size}") + + _uiState.update { + it.copy( + totalStorageSize = storageInfo.deviceStorage.totalSpace, + availableSpace = storageInfo.deviceStorage.freeSpace, + modelStorageSize = storageInfo.totalModelsSize, + downloadedModels = storedModels, + isLoading = false, + ) + } + + Log.d(TAG, "Storage data loaded successfully") + } catch (e: Exception) { + Log.e(TAG, "Failed to load storage data", e) + _uiState.update { + it.copy( + isLoading = false, + errorMessage = "Failed to load storage data: ${e.message}", + ) + } + } + } + } + + /** + * Refresh storage data + */ + fun refreshStorage() { + loadStorageData() + } + + /** + * Delete a downloaded model + */ + fun deleteModelById(modelId: String) { + viewModelScope.launch { + try { + Log.d(TAG, "Deleting model: $modelId") + // Use SDK's deleteModel extension function + RunAnywhere.deleteModel(modelId) + Log.d(TAG, "Model deleted successfully: $modelId") + + // Refresh storage data after deletion + loadStorageData() + } catch (e: Exception) { + Log.e(TAG, "Failed to delete model: $modelId", e) + _uiState.update { + it.copy(errorMessage = "Failed to delete model: ${e.message}") + } + } + } + } + + /** + * Clear cache using SDK's clearCache() API + */ + fun clearCache() { + viewModelScope.launch { + try { + Log.d(TAG, "Clearing cache via clearCache()...") + RunAnywhere.clearCache() + Log.d(TAG, "Cache cleared successfully") + + // Refresh storage data after clearing cache + loadStorageData() + } catch (e: Exception) { + Log.e(TAG, "Failed to clear cache", e) + _uiState.update { + it.copy(errorMessage = "Failed to clear cache: ${e.message}") + } + } + } + } + + /** + * Clean temporary files + */ + fun cleanTempFiles() { + viewModelScope.launch { + try { + Log.d(TAG, "Cleaning temp files (via clearing cache)...") + // Clean temp files by clearing cache + RunAnywhere.clearCache() + Log.d(TAG, "Temp files cleaned successfully") + + // Refresh storage data after cleaning + loadStorageData() + } catch (e: Exception) { + Log.e(TAG, "Failed to clean temp files", e) + _uiState.update { + it.copy(errorMessage = "Failed to clean temporary files: ${e.message}") + } + } + } + } + + // ========== API Configuration Management ========== + + /** + * Load API configuration from secure storage + */ + private fun loadApiConfiguration() { + try { + val storedApiKey = encryptedPrefs.getString(KEY_API_KEY, "") ?: "" + val storedBaseURL = encryptedPrefs.getString(KEY_BASE_URL, "") ?: "" + + _uiState.update { + it.copy( + apiKey = storedApiKey, + baseURL = storedBaseURL, + isApiKeyConfigured = storedApiKey.isNotEmpty(), + isBaseURLConfigured = storedBaseURL.isNotEmpty() + ) + } + Log.d(TAG, "API configuration loaded - apiKey configured: ${storedApiKey.isNotEmpty()}, baseURL configured: ${storedBaseURL.isNotEmpty()}") + } catch (e: Exception) { + Log.e(TAG, "Failed to load API configuration", e) + } + } + + /** + * Update API key in UI state + */ + fun updateApiKey(value: String) { + _uiState.update { it.copy(apiKey = value) } + } + + /** + * Update base URL in UI state + */ + fun updateBaseURL(value: String) { + _uiState.update { it.copy(baseURL = value) } + } + + /** + * Normalize base URL by adding https:// if no scheme is present + */ + private fun normalizeBaseURL(url: String): String { + val trimmed = url.trim() + if (trimmed.isEmpty()) return trimmed + + return if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) { + trimmed + } else { + "https://$trimmed" + } + } + + /** + * Save API configuration to secure storage + */ + fun saveApiConfiguration() { + viewModelScope.launch { + try { + val currentState = _uiState.value + val apiKey = currentState.apiKey + val normalizedURL = normalizeBaseURL(currentState.baseURL) + + encryptedPrefs.edit() + .putString(KEY_API_KEY, apiKey) + .putString(KEY_BASE_URL, normalizedURL) + .apply() + + _uiState.update { + it.copy( + baseURL = normalizedURL, + isApiKeyConfigured = apiKey.isNotEmpty(), + isBaseURLConfigured = normalizedURL.isNotEmpty(), + showApiConfigSheet = false, + showRestartDialog = true + ) + } + + Log.d(TAG, "API configuration saved successfully") + } catch (e: Exception) { + Log.e(TAG, "Failed to save API configuration", e) + _uiState.update { + it.copy(errorMessage = "Failed to save API configuration: ${e.message}") + } + } + } + } + + /** + * Clear API configuration from secure storage + */ + fun clearApiConfiguration() { + viewModelScope.launch { + try { + encryptedPrefs.edit() + .remove(KEY_API_KEY) + .remove(KEY_BASE_URL) + .apply() + + // Also clear device registration so it re-registers with new config + clearDeviceRegistration() + + _uiState.update { + it.copy( + apiKey = "", + baseURL = "", + isApiKeyConfigured = false, + isBaseURLConfigured = false, + showRestartDialog = true + ) + } + + Log.d(TAG, "API configuration cleared successfully") + } catch (e: Exception) { + Log.e(TAG, "Failed to clear API configuration", e) + _uiState.update { + it.copy(errorMessage = "Failed to clear API configuration: ${e.message}") + } + } + } + } + + /** + * Clear device registration status (forces re-registration on next launch) + */ + private fun clearDeviceRegistration() { + val context = getApplication() + context.getSharedPreferences("runanywhere_sdk", Context.MODE_PRIVATE) + .edit() + .remove(KEY_DEVICE_REGISTERED) + .apply() + Log.d(TAG, "Device registration cleared - will re-register on next launch") + } + + /** + * Show the API configuration sheet + */ + fun showApiConfigSheet() { + _uiState.update { it.copy(showApiConfigSheet = true) } + } + + /** + * Hide the API configuration sheet + */ + fun hideApiConfigSheet() { + // Reload saved configuration when canceling + loadApiConfiguration() + _uiState.update { it.copy(showApiConfigSheet = false) } + } + + /** + * Dismiss the restart dialog + */ + fun dismissRestartDialog() { + _uiState.update { it.copy(showRestartDialog = false) } + } + + /** + * Check if API configuration is complete (both key and URL set) + */ + fun isApiConfigurationComplete(): Boolean { + val state = _uiState.value + return state.isApiKeyConfigured && state.isBaseURLConfigured + } +} diff --git a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/stt/SpeechToTextScreen.kt b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/stt/SpeechToTextScreen.kt new file mode 100644 index 000000000..cc29987c7 --- /dev/null +++ b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/stt/SpeechToTextScreen.kt @@ -0,0 +1,1199 @@ +package com.runanywhere.runanywhereai.presentation.stt + +import android.Manifest +import android.content.pm.PackageManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.StartOffset +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.ui.graphics.Brush +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.outlined.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.ContextCompat +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.runanywhere.runanywhereai.presentation.chat.components.ModelLoadedToast +import com.runanywhere.runanywhereai.presentation.models.ModelSelectionBottomSheet +import com.runanywhere.runanywhereai.ui.theme.AppColors +import com.runanywhere.runanywhereai.ui.theme.AppTypography +import com.runanywhere.runanywhereai.util.getModelLogoResIdForName +import com.runanywhere.sdk.public.extensions.Models.ModelSelectionContext +import kotlinx.coroutines.launch + +/** + * Speech to Text Screen + * + * Features: + * - Batch mode: Record full audio then transcribe + * - Live mode: Real-time streaming transcription + * - Recording button with RED color when recording + * - Audio level visualization with GREEN bars + * - Model status banner + * - Transcription display + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SpeechToTextScreen(viewModel: SpeechToTextViewModel = viewModel()) { + val context = LocalContext.current + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + var showModelPicker by remember { mutableStateOf(false) } + var showModelLoadedToast by remember { mutableStateOf(false) } + var loadedModelToastName by remember { mutableStateOf("") } + val scope = rememberCoroutineScope() + + // Initialize ViewModel with context + LaunchedEffect(Unit) { + viewModel.initialize(context) + } + + // Permission launcher - start recording after permission granted + val permissionLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + ) { isGranted -> + if (isGranted) { + viewModel.initialize(context) + viewModel.toggleRecording() + } + } + + Scaffold( + topBar = { + if (uiState.isModelLoaded) { + TopAppBar( + title = { + Text( + text = "Speech to Text", + style = MaterialTheme.typography.headlineMedium, + ) + }, + actions = { + IconButton(onClick = { showModelPicker = true }) { + STTModelButton( + modelName = uiState.selectedModelName, + frameworkDisplayName = uiState.selectedFramework?.displayName, + mode = uiState.mode, + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) + } + }, + ) { paddingValues -> + Box( + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues) + .background(MaterialTheme.colorScheme.background), + ) { + Column(modifier = Modifier.fillMaxSize()) { + if (uiState.isModelLoaded) { + STTModeSelector( + selectedMode = uiState.mode, + supportsLiveMode = uiState.supportsLiveMode, + onModeChange = { viewModel.setMode(it) }, + ) + + TranscriptionArea( + transcription = uiState.transcription, + isRecording = uiState.recordingState == RecordingState.RECORDING, + isTranscribing = uiState.isTranscribing || uiState.recordingState == RecordingState.PROCESSING, + metrics = uiState.metrics, + mode = uiState.mode, + modifier = Modifier.weight(1f), + ) + + uiState.errorMessage?.let { error -> + Text( + text = error, + style = MaterialTheme.typography.bodySmall, + color = AppColors.statusRed, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + textAlign = TextAlign.Center, + ) + } + + // Audio level indicator - green bars + if (uiState.recordingState == RecordingState.RECORDING) { + AudioLevelIndicator( + audioLevel = uiState.audioLevel, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + } + + // Controls section + ControlsSection( + recordingState = uiState.recordingState, + audioLevel = uiState.audioLevel, + isModelLoaded = uiState.isModelLoaded, + onToggleRecording = { + // Check if permission is already granted + val hasPermission = + ContextCompat.checkSelfPermission( + context, + Manifest.permission.RECORD_AUDIO, + ) == PackageManager.PERMISSION_GRANTED + + if (hasPermission) { + // Permission already granted, toggle recording directly + viewModel.toggleRecording() + } else { + // Request permission, toggleRecording will be called in callback + permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } + }, + ) + } + } + + if (!uiState.isModelLoaded && uiState.recordingState != RecordingState.PROCESSING) { + ModelRequiredOverlaySTT( + onSelectModel = { showModelPicker = true }, + modifier = Modifier.matchParentSize(), + ) + } + + // Model loaded toast overlay + ModelLoadedToast( + modelName = loadedModelToastName, + isVisible = showModelLoadedToast, + onDismiss = { showModelLoadedToast = false }, + modifier = Modifier.align(Alignment.TopCenter), + ) + } + } + + if (showModelPicker) { + ModelSelectionBottomSheet( + context = ModelSelectionContext.STT, + onDismiss = { showModelPicker = false }, + onModelSelected = { model -> + scope.launch { + // Update ViewModel with model info AND mark as loaded + // The model was already loaded by ModelSelectionViewModel.selectModel() + viewModel.onModelLoaded( + modelName = model.name, + modelId = model.id, + framework = model.framework, + ) + android.util.Log.d("SpeechToTextScreen", "STT model selected: ${model.name}") + // Show model loaded toast + loadedModelToastName = model.name + showModelLoadedToast = true + } + }, + ) + } +} + +/** + * Mode Description text + * iOS Reference: Mode description under segmented control + */ +@Composable +private fun ModeDescription( + mode: STTMode, + supportsLiveMode: Boolean, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = + when (mode) { + STTMode.BATCH -> Icons.Filled.GraphicEq + STTMode.LIVE -> Icons.Filled.Waves + }, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = + when (mode) { + STTMode.BATCH -> "Record audio, then transcribe all at once" + STTMode.LIVE -> "Real-time transcription as you speak" + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + // Show warning if live mode not supported + if (!supportsLiveMode && mode == STTMode.LIVE) { + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "(will use batch)", + style = MaterialTheme.typography.bodySmall, + color = AppColors.primaryOrange, + ) + } + } +} + +/** + * Ready state - iOS: breathing waveform (5 bars gradient) + "Ready to transcribe" + subtitle by mode + */ +@Composable +private fun ReadyStateSTT(mode: STTMode) { + val infiniteTransition = rememberInfiniteTransition(label = "stt_breathing") + val breathing by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(800), + repeatMode = RepeatMode.Reverse, + ), + label = "breathing", + ) + val baseHeights = listOf(16, 24, 20, 28, 18) + val breathingHeights = listOf(24, 40, 32, 48, 28) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(48.dp), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.Bottom, + ) { + baseHeights.forEachIndexed { index, base -> + val h = base + (breathingHeights[index] - base) * breathing + Box( + modifier = Modifier + .width(6.dp) + .height(h.toInt().dp) + .clip(RoundedCornerShape(8.dp)) + .background( + Brush.verticalGradient( + colors = listOf( + AppColors.primaryAccent.copy(alpha = 0.8f), + AppColors.primaryAccent.copy(alpha = 0.4f), + ), + ), + ), + ) + } + } + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = "Ready to transcribe", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = if (mode == STTMode.BATCH) "Record first, then transcribe" else "Real-time transcription", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +/** + * Transcription display area + * iOS Reference: Transcription ScrollView in SpeechToTextView + */ +@Composable +private fun TranscriptionArea( + transcription: String, + isRecording: Boolean, + isTranscribing: Boolean, + metrics: TranscriptionMetrics?, + mode: STTMode, + modifier: Modifier = Modifier, +) { + Box( + modifier = + modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center, + ) { + when { + transcription.isEmpty() && !isRecording && !isTranscribing -> { + ReadyStateSTT(mode = mode) + } + + isTranscribing && transcription.isEmpty() -> { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + CircularProgressIndicator( + modifier = Modifier.scale(1.2f).size(48.dp), + strokeWidth = 4.dp, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = "Transcribing...", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + else -> { + // Transcription display + Column( + modifier = Modifier.fillMaxSize(), + ) { + // Header with status badge + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Transcription", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + + // Status badge: RECORDING/TRANSCRIBING + if (isRecording) { + RecordingBadge() + } else if (isTranscribing) { + TranscribingBadge() + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Transcription text box + Surface( + modifier = + Modifier + .fillMaxWidth() + .weight(1f), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + Text( + text = transcription.ifEmpty { "Listening..." }, + style = MaterialTheme.typography.bodyLarge, + modifier = + Modifier + .padding(16.dp) + .verticalScroll(rememberScrollState()), + color = + if (transcription.isEmpty()) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + } + + // Metrics display - only show when we have results and not recording + if (metrics != null && transcription.isNotEmpty() && !isRecording && !isTranscribing) { + Spacer(modifier = Modifier.height(12.dp)) + TranscriptionMetricsBar(metrics = metrics) + } + } + } + } + } +} + +/** + * Metrics bar showing transcription statistics + * Clean, minimal design that doesn't distract from the transcription + */ +@Composable +private fun TranscriptionMetricsBar(metrics: TranscriptionMetrics) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 1.dp, + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + // Words count + MetricItem( + icon = Icons.Outlined.TextFields, + value = "${metrics.wordCount}", + label = "words", + color = AppColors.primaryAccent, + ) + + MetricDivider() + + // Audio duration + if (metrics.audioDurationMs > 0) { + MetricItem( + icon = Icons.Outlined.Timer, + value = formatDuration(metrics.audioDurationMs), + label = "duration", + color = AppColors.primaryGreen, + ) + + MetricDivider() + } + + // Inference time + if (metrics.inferenceTimeMs > 0) { + MetricItem( + icon = Icons.Outlined.Speed, + value = "${metrics.inferenceTimeMs.toLong()}ms", + label = "inference", + color = AppColors.primaryOrange, + ) + + MetricDivider() + } + + // Real-time factor (only for batch mode with valid duration) + if (metrics.audioDurationMs > 0 && metrics.inferenceTimeMs > 0) { + val rtf = metrics.inferenceTimeMs / metrics.audioDurationMs + MetricItem( + icon = Icons.Outlined.Analytics, + value = String.format("%.2fx", rtf), + label = "RTF", + color = if (rtf < 1.0) AppColors.primaryGreen else AppColors.primaryOrange, + ) + } else if (metrics.confidence > 0) { + // Show confidence for live mode + MetricItem( + icon = Icons.Outlined.CheckCircle, + value = "${(metrics.confidence * 100).toInt()}%", + label = "confidence", + color = + when { + metrics.confidence >= 0.8f -> AppColors.primaryGreen + metrics.confidence >= 0.5f -> AppColors.primaryOrange + else -> Color.Red + }, + ) + } + } + } +} + +@Composable +private fun MetricItem( + icon: androidx.compose.ui.graphics.vector.ImageVector, + value: String, + label: String, + color: Color, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = color.copy(alpha = 0.8f), + ) + Column { + Text( + text = value, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + } + } +} + +@Composable +private fun MetricDivider() { + Box( + modifier = + Modifier + .width(1.dp) + .height(24.dp) + .background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), + ) +} + +private fun formatDuration(ms: Double): String { + val totalSeconds = (ms / 1000).toLong() + val minutes = totalSeconds / 60 + val seconds = totalSeconds % 60 + return if (minutes > 0) { + "${minutes}m ${seconds}s" + } else { + "${seconds}s" + } +} + +/** + * Recording badge - iOS style red recording indicator + */ +@Composable +private fun RecordingBadge() { + val infiniteTransition = rememberInfiniteTransition(label = "recording_pulse") + val alpha by infiniteTransition.animateFloat( + initialValue = 1f, + targetValue = 0.5f, + animationSpec = + infiniteRepeatable( + animation = tween(500), + repeatMode = RepeatMode.Reverse, + ), + label = "badge_pulse", + ) + + Surface( + shape = RoundedCornerShape(4.dp), + color = AppColors.statusRed.copy(alpha = 0.1f), + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = + Modifier + .size(8.dp) + .clip(CircleShape) + .background(AppColors.statusRed.copy(alpha = alpha)), + ) + Text( + text = "RECORDING", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = AppColors.statusRed, + ) + } + } +} + +/** + * Transcribing badge - iOS style orange processing indicator + */ +@Composable +private fun TranscribingBadge() { + Surface( + shape = RoundedCornerShape(4.dp), + color = AppColors.primaryOrange.copy(alpha = 0.1f), + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator( + modifier = Modifier.size(10.dp), + strokeWidth = 1.5.dp, + color = AppColors.primaryOrange, + ) + Text( + text = "TRANSCRIBING", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = AppColors.primaryOrange, + ) + } + } +} + +/** + * Audio level indicator - GREEN bars matching iOS exactly + * iOS Reference: Audio level indicator bars in SpeechToTextView + */ +@Composable +private fun AudioLevelIndicator( + audioLevel: Float, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + val barsCount = 10 + val activeBars = (audioLevel * barsCount).toInt() + + repeat(barsCount) { index -> + val isActive = index < activeBars + val barColor by animateColorAsState( + targetValue = if (isActive) AppColors.primaryGreen else Color.Gray.copy(alpha = 0.3f), + animationSpec = tween(100), + label = "bar_color_$index", + ) + + Box( + modifier = + Modifier + .padding(horizontal = 2.dp) + .width(25.dp) + .height(8.dp) + .clip(RoundedCornerShape(2.dp)) + .background(barColor), + ) + } + } +} + +/** + * Controls Section with recording button + */ +@Composable +private fun ControlsSection( + recordingState: RecordingState, + audioLevel: Float, + isModelLoaded: Boolean, + onToggleRecording: () -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp) + .background(MaterialTheme.colorScheme.background), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Recording button - RED when recording + RecordingButton( + recordingState = recordingState, + audioLevel = audioLevel, + onToggleRecording = onToggleRecording, + enabled = isModelLoaded && recordingState != RecordingState.PROCESSING, + ) + + // Status text + Text( + text = + when (recordingState) { + RecordingState.IDLE -> "Tap to start recording" + RecordingState.RECORDING -> "Tap to stop recording" + RecordingState.PROCESSING -> "Processing transcription..." + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** + * STT toolbar model button - icon, model name to the right, below: electricity icon + Streaming/Batch text + */ +@Composable +private fun STTModelButton( + modelName: String?, + frameworkDisplayName: String?, + mode: STTMode, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + if (modelName != null) { + Box( + modifier = + Modifier + .size(36.dp) + .clip(RoundedCornerShape(4.dp)), + ) { + Image( + painter = painterResource(id = getModelLogoResIdForName(modelName)), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit, + ) + } + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = shortModelNameSTT(modelName), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + maxLines = 1, + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp), + ) { + Icon( + imageVector = if (mode == STTMode.LIVE) Icons.Default.Bolt else Icons.Default.Stop, + contentDescription = null, + modifier = Modifier.size(10.dp), + tint = if (mode == STTMode.LIVE) AppColors.primaryGreen else AppColors.primaryOrange, + ) + Text( + text = if (mode == STTMode.LIVE) "Streaming" else "Batch", + style = AppTypography.caption2.copy(fontSize = 10.sp, fontWeight = FontWeight.Medium), + color = if (mode == STTMode.LIVE) AppColors.primaryGreen else AppColors.primaryOrange, + ) + } + } + } else { + Icon( + imageVector = Icons.Default.GraphicEq, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = AppColors.primaryGreen, + ) + Text( + text = "Select Model", + style = MaterialTheme.typography.labelMedium, + ) + } + } +} + +private fun shortModelNameSTT(name: String, maxLength: Int = 15): String { + val cleaned = name.replace(Regex("\\s*\\([^)]*\\)"), "").trim() + return if (cleaned.length > maxLength) cleaned.take(maxLength - 1) + "\u2026" else cleaned +} + +/** + * Model Status Banner for STT (kept for reference; not used when app bar shows model) + */ +@Composable +private fun ModelStatusBannerSTT( + framework: String?, + modelName: String?, + isLoading: Boolean, + onSelectModel: () -> Unit, +) { + Surface( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + ) + Text( + text = "Loading model...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else if (framework != null && modelName != null) { + // Model loaded state + Icon( + imageVector = Icons.Filled.GraphicEq, + contentDescription = null, + tint = AppColors.primaryGreen, + modifier = Modifier.size(18.dp), + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = framework, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = modelName, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + } + OutlinedButton( + onClick = onSelectModel, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp), + ) { + Text("Change", style = MaterialTheme.typography.labelMedium) + } + } else { + // No model state + Icon( + imageVector = Icons.Filled.Warning, + contentDescription = null, + tint = AppColors.primaryOrange, + ) + Text( + text = "No model selected", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + Button( + onClick = onSelectModel, + colors = + ButtonDefaults.buttonColors( + containerColor = AppColors.primaryAccent, + ), + ) { + Icon( + Icons.Filled.Apps, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("Select Model") + } + } + } + } +} + +/** + * STT Mode Selector (Batch / Live) - iOS pill style with subtitle + * iOS: padding horizontal 16, top 12, bottom 8; selected = primaryAccent 0.15 bg + border 0.3 + */ +@Composable +private fun STTModeSelector( + selectedMode: STTMode, + @Suppress("UNUSED_PARAMETER") supportsLiveMode: Boolean, + onModeChange: (STTMode) -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + STTMode.values().forEach { mode -> + val isSelected = mode == selectedMode + Surface( + modifier = + Modifier + .weight(1f) + .clickable { onModeChange(mode) }, + shape = RoundedCornerShape(12.dp), + color = if (isSelected) AppColors.primaryAccent.copy(alpha = 0.15f) else Color.Transparent, + border = + androidx.compose.foundation.BorderStroke( + 1.dp, + if (isSelected) AppColors.primaryAccent.copy(alpha = 0.3f) + else Color.Gray.copy(alpha = 0.2f), + ), + ) { + Column( + modifier = Modifier.padding(vertical = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = when (mode) { + STTMode.BATCH -> "Batch" + STTMode.LIVE -> "Live" + }, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Medium, + color = if (isSelected) AppColors.primaryAccent else MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = when (mode) { + STTMode.BATCH -> "Record then transcribe" + STTMode.LIVE -> "Real-time transcription" + }, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + } + } + } + } +} + +/** + * Recording Button - RED when recording (matching iOS exactly) + * iOS Reference: Recording button in SpeechToTextView + * iOS Color States: Blue (idle) → Red (recording) → Orange (transcribing) + */ +@Composable +private fun RecordingButton( + recordingState: RecordingState, + @Suppress("UNUSED_PARAMETER") audioLevel: Float, + onToggleRecording: () -> Unit, + enabled: Boolean = true, +) { + val infiniteTransition = rememberInfiniteTransition(label = "recording_pulse") + val scale by infiniteTransition.animateFloat( + initialValue = 1f, + targetValue = 1.15f, + animationSpec = + infiniteRepeatable( + animation = tween(600, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "pulse_scale", + ) + + // Color states: Blue when idle, RED when recording, Orange when transcribing + val buttonColor by animateColorAsState( + targetValue = + when (recordingState) { + RecordingState.IDLE -> AppColors.primaryAccent + RecordingState.RECORDING -> AppColors.primaryRed // RED when recording + RecordingState.PROCESSING -> AppColors.primaryOrange + }, + animationSpec = tween(300), + label = "button_color", + ) + + val buttonIcon = + when (recordingState) { + RecordingState.IDLE -> Icons.Filled.Mic + RecordingState.RECORDING -> Icons.Filled.Stop + RecordingState.PROCESSING -> Icons.Filled.Sync + } + + // Button size: 72dp + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier + .size(88.dp) // Container for button + pulse ring + .scale(if (recordingState == RecordingState.RECORDING) scale else 1f), + ) { + // Pulsing ring when recording - RED + if (recordingState == RecordingState.RECORDING) { + Box( + modifier = + Modifier + // Slightly larger than button for pulse effect + .size(84.dp) + .border( + width = 2.dp, + // RED ring + color = AppColors.primaryRed.copy(alpha = 0.3f), + shape = CircleShape, + ) + .scale(scale * 1.1f), + ) + } + + // Main button - 72dp + Surface( + modifier = + Modifier + .size(72.dp) + .clickable( + enabled = enabled, + onClick = onToggleRecording, + ), + shape = CircleShape, + color = buttonColor, + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize(), + ) { + if (recordingState == RecordingState.PROCESSING) { + // Icon size + CircularProgressIndicator( + modifier = Modifier.size(32.dp), + color = Color.White, + strokeWidth = 3.dp, + ) + } else { + // 32dp icon + Icon( + imageVector = buttonIcon, + contentDescription = + when (recordingState) { + RecordingState.IDLE -> "Start recording" + RecordingState.RECORDING -> "Stop recording" + RecordingState.PROCESSING -> "Processing" + }, + tint = Color.White, + modifier = Modifier.size(32.dp), + ) + } + } + } + } +} + +/** + * Model Required Overlay for STT - green, "Voice to Text", same layout as Chat overlay + */ +@Composable +private fun ModelRequiredOverlaySTT( + onSelectModel: () -> Unit, + modifier: Modifier = Modifier, +) { + val modalityColor = AppColors.primaryGreen + val infiniteTransition = rememberInfiniteTransition(label = "stt_overlay_circles") + val circle1Offset by infiniteTransition.animateFloat( + initialValue = -100f, + targetValue = 100f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 8000, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "c1", + ) + val circle2Offset by infiniteTransition.animateFloat( + initialValue = 100f, + targetValue = -100f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 8000, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "c2", + ) + val circle3Offset by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 80f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 8000, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "c3", + ) + val density = androidx.compose.ui.platform.LocalDensity.current + val c1Dp = with(density) { circle1Offset.toDp() } + val c2Dp = with(density) { circle2Offset.toDp() } + val c3Dp = with(density) { circle3Offset.toDp() } + + Box(modifier = modifier.fillMaxSize()) { + Box(modifier = Modifier.fillMaxSize().blur(32.dp)) { + Box( + modifier = Modifier + .size(300.dp) + .offset(x = c1Dp, y = (-200).dp) + .clip(CircleShape) + .background(modalityColor.copy(alpha = 0.15f)), + ) + Box( + modifier = Modifier + .size(250.dp) + .offset(x = c2Dp, y = 300.dp) + .clip(CircleShape) + .background(modalityColor.copy(alpha = 0.12f)), + ) + Box( + modifier = Modifier + .size(280.dp) + .offset(x = -c3Dp, y = c3Dp) + .clip(CircleShape) + .background(modalityColor.copy(alpha = 0.08f)), + ) + } + Column( + modifier = Modifier.fillMaxSize().padding(horizontal = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.weight(1f)) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(120.dp) + .clip(CircleShape) + .background( + Brush.linearGradient( + listOf( + modalityColor.copy(alpha = 0.2f), + modalityColor.copy(alpha = 0.1f), + ), + ), + ), + ) { + Icon( + imageVector = Icons.Default.GraphicEq, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = modalityColor, + ) + } + Spacer(modifier = Modifier.height(20.dp)) + Text( + text = "Voice to Text", + style = MaterialTheme.typography.titleLarge, + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "Transcribe your speech to text with powerful on-device voice recognition.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.weight(1f)) + Button( + onClick = onSelectModel, + colors = ButtonDefaults.buttonColors(containerColor = modalityColor), + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Default.AutoAwesome, contentDescription = null, modifier = Modifier.size(20.dp), tint = Color.White) + Spacer(modifier = Modifier.width(8.dp)) + Text("Get Started", style = MaterialTheme.typography.titleMedium) + } + Spacer(modifier = Modifier.height(16.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier.padding(bottom = 16.dp), + ) { + Icon(Icons.Default.Lock, contentDescription = null, modifier = Modifier.size(14.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = "100% Private • Runs on your device", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} diff --git a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/stt/SpeechToTextViewModel.kt b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/stt/SpeechToTextViewModel.kt new file mode 100644 index 000000000..11156ac0a --- /dev/null +++ b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/stt/SpeechToTextViewModel.kt @@ -0,0 +1,660 @@ +package com.runanywhere.runanywhereai.presentation.stt + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.util.Log +import androidx.core.content.ContextCompat +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.runanywhere.runanywhereai.domain.services.AudioCaptureService +import com.runanywhere.sdk.core.types.InferenceFramework +import com.runanywhere.sdk.public.RunAnywhere +import com.runanywhere.sdk.public.events.EventBus +import com.runanywhere.sdk.public.events.EventCategory +import com.runanywhere.sdk.public.events.ModelEvent +import com.runanywhere.sdk.public.extensions.STT.STTOptions +import com.runanywhere.sdk.public.extensions.currentSTTModelId +import com.runanywhere.sdk.public.extensions.isSTTModelLoadedSync +import com.runanywhere.sdk.public.extensions.loadSTTModel +import com.runanywhere.sdk.public.extensions.transcribe +import com.runanywhere.sdk.public.extensions.transcribeStream +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.ByteArrayOutputStream +import kotlin.math.log10 +import kotlin.math.max +import kotlin.math.min + +/** + * STT Recording Mode + * iOS Reference: STTMode enum in STTViewModel.swift + */ +enum class STTMode { + BATCH, // Record full audio then transcribe + LIVE, // Real-time streaming transcription +} + +/** + * Recording State + * iOS Reference: Recording state in STTViewModel.swift + */ +enum class RecordingState { + IDLE, + RECORDING, + PROCESSING, +} + +/** + * Transcription metrics for display + */ +data class TranscriptionMetrics( + val confidence: Float = 0f, + val audioDurationMs: Double = 0.0, + val inferenceTimeMs: Double = 0.0, + val detectedLanguage: String = "", + val wordCount: Int = 0, +) { + val realTimeFactor: Double + get() = if (audioDurationMs > 0) inferenceTimeMs / audioDurationMs else 0.0 +} + +/** + * STT UI State + * iOS Reference: STTViewModel published properties in STTViewModel.swift + */ +data class STTUiState( + val mode: STTMode = STTMode.BATCH, + val recordingState: RecordingState = RecordingState.IDLE, + val transcription: String = "", + val isModelLoaded: Boolean = false, + val selectedFramework: InferenceFramework? = null, + val selectedModelName: String? = null, + val selectedModelId: String? = null, + val audioLevel: Float = 0f, + val language: String = "en", + val errorMessage: String? = null, + val isTranscribing: Boolean = false, + val metrics: TranscriptionMetrics? = null, + val isProcessing: Boolean = false, + /** Whether selected model supports live streaming */ + val supportsLiveMode: Boolean = true, +) + +/** + * Speech to Text ViewModel + * + * iOS Reference: STTViewModel in STTViewModel.swift + * + * This ViewModel manages: + * - Model loading via RunAnywhere.loadSTTModel() + * - Recording state management with AudioCaptureService + * - Transcription via RunAnywhere.transcribe() + * - Audio level monitoring for UI visualization + */ +class SpeechToTextViewModel : ViewModel() { + companion object { + private const val TAG = "STTViewModel" + private const val SAMPLE_RATE = 16000 // 16kHz for Whisper/ONNX STT models + } + + private val _uiState = MutableStateFlow(STTUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + // Audio capture service + private var audioCaptureService: AudioCaptureService? = null + + // Audio recording state + private var recordingJob: Job? = null + private val audioBuffer = ByteArrayOutputStream() + + // SDK event subscription + private var eventSubscriptionJob: Job? = null + + // Initialization state (for idempotency) + private var isInitialized = false + private var hasSubscribedToEvents = false + + init { + Log.d(TAG, "STTViewModel initialized") + } + + /** + * Initialize the STT ViewModel with context for audio capture + * iOS equivalent: initialize() in STTViewModel.swift + */ + fun initialize(context: Context) { + if (isInitialized) { + Log.d(TAG, "STT view model already initialized, skipping") + return + } + isInitialized = true + + viewModelScope.launch { + Log.i(TAG, "Initializing STT view model...") + + // Initialize audio capture service + audioCaptureService = AudioCaptureService(context) + + // Check for microphone permission + val hasPermission = + ContextCompat.checkSelfPermission( + context, + Manifest.permission.RECORD_AUDIO, + ) == PackageManager.PERMISSION_GRANTED + + if (!hasPermission) { + Log.w(TAG, "Microphone permission not granted") + _uiState.update { it.copy(errorMessage = "Microphone permission required") } + } + + // Subscribe to SDK events for STT model state + subscribeToSDKEvents() + + // Check initial STT model state + checkInitialModelState() + } + } + + /** + * Subscribe to SDK events for STT model state updates + * iOS Reference: subscribeToSDKEvents() in STTViewModel.swift + */ + private fun subscribeToSDKEvents() { + if (hasSubscribedToEvents) { + Log.d(TAG, "Already subscribed to SDK events, skipping") + return + } + hasSubscribedToEvents = true + + eventSubscriptionJob = + viewModelScope.launch { + // Listen for model events with STT category + EventBus.events.collect { event -> + // Filter for model events with STT category + if (event is ModelEvent && event.category == EventCategory.STT) { + handleModelEvent(event) + } + } + } + } + + /** + * Handle model events for STT + * iOS Reference: handleSDKEvent() in STTViewModel.swift + */ + private fun handleModelEvent(event: ModelEvent) { + when (event.eventType) { + ModelEvent.ModelEventType.LOADED -> { + Log.i(TAG, "STT model loaded: ${event.modelId}") + _uiState.update { + it.copy( + isModelLoaded = true, + selectedModelId = event.modelId, + selectedModelName = it.selectedModelName ?: event.modelId, + isProcessing = false, + ) + } + } + ModelEvent.ModelEventType.UNLOADED -> { + Log.i(TAG, "STT model unloaded: ${event.modelId}") + _uiState.update { + it.copy( + isModelLoaded = false, + selectedModelId = null, + selectedModelName = null, + selectedFramework = null, + ) + } + } + ModelEvent.ModelEventType.DOWNLOAD_STARTED -> { + Log.i(TAG, "STT model download started: ${event.modelId}") + _uiState.update { it.copy(isProcessing = true) } + } + ModelEvent.ModelEventType.DOWNLOAD_COMPLETED -> { + Log.i(TAG, "STT model download completed: ${event.modelId}") + _uiState.update { it.copy(isProcessing = false) } + } + ModelEvent.ModelEventType.DOWNLOAD_FAILED -> { + Log.e(TAG, "STT model download failed: ${event.modelId} - ${event.error}") + _uiState.update { + it.copy( + errorMessage = "Download failed: ${event.error}", + isProcessing = false, + ) + } + } + else -> { /* Other events not relevant for STT state */ } + } + } + + /** + * Check initial STT model state + * iOS Reference: checkInitialModelState() in STTViewModel.swift + */ + private fun checkInitialModelState() { + if (RunAnywhere.isSTTModelLoadedSync) { + val modelId = RunAnywhere.currentSTTModelId + _uiState.update { + it.copy( + isModelLoaded = true, + selectedModelId = modelId, + selectedModelName = modelId, + ) + } + Log.i(TAG, "STT model already loaded: $modelId") + } + } + + /** + * Set the STT mode (Batch or Live) + */ + fun setMode(mode: STTMode) { + _uiState.update { it.copy(mode = mode) } + } + + /** + * Set the selected model name (for display purposes) + * Called when model is selected from UI before SDK events arrive + */ + fun setSelectedModelName(name: String) { + _uiState.update { it.copy(selectedModelName = name) } + } + + /** + * Called when a model has been loaded (e.g., by ModelSelectionViewModel) + * This updates the UI state to reflect the loaded model + */ + fun onModelLoaded( + modelName: String, + modelId: String, + framework: InferenceFramework?, + ) { + Log.i(TAG, "Model loaded notification: $modelName (id: $modelId, framework: ${framework?.displayName})") + _uiState.update { + it.copy( + isModelLoaded = true, + selectedModelName = modelName, + selectedModelId = modelId, + selectedFramework = framework, + isProcessing = false, + errorMessage = null, + ) + } + } + + /** + * Load a STT model via SDK + * iOS Reference: loadModelFromSelection() in STTViewModel.swift + * + * @param modelName Display name of the model + * @param modelId Model identifier for SDK + */ + fun loadModel( + modelName: String, + modelId: String, + ) { + viewModelScope.launch { + _uiState.update { + it.copy( + isProcessing = true, + errorMessage = null, + ) + } + + try { + Log.i(TAG, "Loading STT model: $modelName (id: $modelId)") + + // Use SDK's loadSTTModel extension function + RunAnywhere.loadSTTModel(modelId) + + _uiState.update { + it.copy( + isModelLoaded = true, + selectedModelName = modelName, + selectedModelId = modelId, + isProcessing = false, + ) + } + + Log.i(TAG, "✅ STT model loaded successfully: $modelName") + } catch (e: Exception) { + Log.e(TAG, "Failed to load STT model: ${e.message}", e) + _uiState.update { + it.copy( + errorMessage = "Failed to load model: ${e.message}", + isProcessing = false, + ) + } + } + } + } + + /** + * Toggle recording state + * iOS Reference: toggleRecording() in STTViewModel.swift + */ + fun toggleRecording() { + viewModelScope.launch { + when (_uiState.value.recordingState) { + RecordingState.IDLE -> startRecording() + RecordingState.RECORDING -> stopRecording() + RecordingState.PROCESSING -> { /* Cannot toggle while processing */ } + } + } + } + + /** + * Start audio recording + * iOS Reference: startRecording() in STTViewModel.swift + */ + private suspend fun startRecording() { + Log.i(TAG, "Starting recording in ${_uiState.value.mode} mode") + + if (!_uiState.value.isModelLoaded) { + _uiState.update { it.copy(errorMessage = "No STT model loaded") } + return + } + + // Clear previous state + _uiState.update { + it.copy( + recordingState = RecordingState.RECORDING, + transcription = "", + errorMessage = null, + audioLevel = 0f, + ) + } + audioBuffer.reset() + + val audioCapture = + audioCaptureService ?: run { + _uiState.update { it.copy(errorMessage = "Audio capture not initialized") } + return + } + + when (_uiState.value.mode) { + STTMode.BATCH -> startBatchRecording(audioCapture) + STTMode.LIVE -> startLiveRecording(audioCapture) + } + } + + /** + * Start batch recording - collect all audio then transcribe + * iOS Reference: Batch mode in startRecording() + */ + private fun startBatchRecording(audioCapture: AudioCaptureService) { + recordingJob = + viewModelScope.launch { + try { + audioCapture.startCapture().collect { audioData -> + // Append to buffer + withContext(Dispatchers.IO) { + audioBuffer.write(audioData) + } + + // Calculate and update audio level + val rms = audioCapture.calculateRMS(audioData) + val normalizedLevel = normalizeAudioLevel(rms) + _uiState.update { it.copy(audioLevel = normalizedLevel) } + } + } catch (e: kotlinx.coroutines.CancellationException) { + Log.d(TAG, "Batch recording cancelled (expected when stopping)") + } catch (e: Exception) { + Log.e(TAG, "Error during batch recording: ${e.message}", e) + _uiState.update { + it.copy( + errorMessage = "Recording error: ${e.message}", + recordingState = RecordingState.IDLE, + audioLevel = 0f, + ) + } + } + } + } + + /** + * Start live streaming recording - transcribe in chunks + * iOS Reference: Live mode in startRecording() with liveTranscribe + * + * Note: The SDK's transcribeStream API takes a ByteArray, not a Flow. + * For live mode, we collect audio chunks and transcribe them incrementally. + */ + private fun startLiveRecording(audioCapture: AudioCaptureService) { + recordingJob = + viewModelScope.launch { + try { + val chunkBuffer = ByteArrayOutputStream() + var lastTranscription = "" + + audioCapture.startCapture().collect { audioData -> + // Update audio level + val rms = audioCapture.calculateRMS(audioData) + val normalizedLevel = normalizeAudioLevel(rms) + _uiState.update { it.copy(audioLevel = normalizedLevel) } + + // Append to chunk buffer + chunkBuffer.write(audioData) + + // Transcribe every ~1 second of audio (16000 samples * 2 bytes = 32000 bytes) + if (chunkBuffer.size() >= 32000) { + val chunkData = chunkBuffer.toByteArray() + chunkBuffer.reset() + + // Transcribe in background + withContext(Dispatchers.IO) { + try { + val options = STTOptions(language = _uiState.value.language) + val result = + RunAnywhere.transcribeStream( + audioData = chunkData, + options = options, + ) { partial -> + // Update UI with partial result (non-suspend callback) + if (partial.transcript.isNotBlank()) { + val newText = lastTranscription + " " + partial.transcript + // Use launch since we're in a non-suspend callback + viewModelScope.launch(Dispatchers.Main) { + handleSTTStreamText(newText.trim()) + } + } + } + // Update with final result + lastTranscription = (lastTranscription + " " + result.text).trim() + withContext(Dispatchers.Main) { + handleSTTStreamText(lastTranscription) + } + } catch (e: Exception) { + Log.w(TAG, "Chunk transcription error: ${e.message}") + } + } + } + } + } catch (e: kotlinx.coroutines.CancellationException) { + Log.d(TAG, "Live recording cancelled (expected when stopping)") + } catch (e: Exception) { + Log.e(TAG, "Error during live recording: ${e.message}", e) + _uiState.update { + it.copy( + errorMessage = "Live transcription error: ${e.message}", + recordingState = RecordingState.IDLE, + audioLevel = 0f, + ) + } + } + } + } + + /** + * Handle STT stream text during live transcription + */ + private fun handleSTTStreamText(text: String) { + if (text.isNotBlank() && text != "...") { + val wordCount = text.trim().split("\\s+".toRegex()).filter { it.isNotEmpty() }.size + _uiState.update { + it.copy( + transcription = text, + metrics = + TranscriptionMetrics( + confidence = 0f, + wordCount = wordCount, + ), + ) + } + Log.d(TAG, "Stream transcription: $text") + } + } + + /** + * Stop audio recording and process transcription (for batch mode) + * iOS Reference: stopRecording() in STTViewModel.swift + */ + private suspend fun stopRecording() { + Log.i(TAG, "Stopping recording in ${_uiState.value.mode} mode") + + // Stop audio capture + audioCaptureService?.stopCapture() + + // Wait a moment for the flow to complete + kotlinx.coroutines.delay(100) + + // Cancel the recording job + recordingJob?.cancel() + recordingJob = null + + // Reset audio level + _uiState.update { + it.copy( + recordingState = + if (_uiState.value.mode == STTMode.BATCH) { + RecordingState.PROCESSING + } else { + RecordingState.IDLE + }, + audioLevel = 0f, + isTranscribing = _uiState.value.mode == STTMode.BATCH, + ) + } + + // For batch mode, transcribe the collected audio + if (_uiState.value.mode == STTMode.BATCH) { + performBatchTranscription() + } + } + + /** + * Perform batch transcription on collected audio + * iOS Reference: performBatchTranscription() in STTViewModel.swift + */ + private suspend fun performBatchTranscription() { + val audioBytes = audioBuffer.toByteArray() + if (audioBytes.isEmpty()) { + _uiState.update { + it.copy( + errorMessage = "No audio recorded", + recordingState = RecordingState.IDLE, + isTranscribing = false, + ) + } + return + } + + Log.i(TAG, "Starting batch transcription of ${audioBytes.size} bytes") + + try { + withContext(Dispatchers.IO) { + val startTime = System.currentTimeMillis() + + // Calculate audio duration: bytes / (sample_rate * 2 bytes per sample) * 1000 ms + val audioDurationMs = (audioBytes.size.toDouble() / (SAMPLE_RATE * 2)) * 1000 + + // Use SDK's transcribe extension function + val result = RunAnywhere.transcribe(audioBytes) + + val inferenceTimeMs = System.currentTimeMillis() - startTime + val wordCount = result.trim().split("\\s+".toRegex()).filter { it.isNotEmpty() }.size + + withContext(Dispatchers.Main) { + _uiState.update { + it.copy( + transcription = result, + recordingState = RecordingState.IDLE, + isTranscribing = false, + metrics = + TranscriptionMetrics( + confidence = 0f, + audioDurationMs = audioDurationMs, + inferenceTimeMs = inferenceTimeMs.toDouble(), + detectedLanguage = _uiState.value.language, + wordCount = wordCount, + ), + ) + } + } + + Log.i(TAG, "✅ Batch transcription complete: $result (${inferenceTimeMs}ms, $wordCount words)") + } + } catch (e: Exception) { + Log.e(TAG, "Batch transcription failed: ${e.message}", e) + _uiState.update { + it.copy( + errorMessage = "Transcription failed: ${e.message}", + recordingState = RecordingState.IDLE, + isTranscribing = false, + metrics = null, + ) + } + } + } + + /** + * Set the transcription language + */ + fun setLanguage(language: String) { + _uiState.update { it.copy(language = language) } + } + + /** + * Clear the current transcription + */ + fun clearTranscription() { + _uiState.update { it.copy(transcription = "") } + } + + /** + * Clean up resources + * iOS Reference: cleanup() in STTViewModel.swift + */ + fun cleanup() { + recordingJob?.cancel() + eventSubscriptionJob?.cancel() + audioCaptureService?.release() + + // Reset initialization flags + isInitialized = false + hasSubscribedToEvents = false + } + + override fun onCleared() { + super.onCleared() + cleanup() + } + + // ============================================================================ + // Private Helper Methods + // ============================================================================ + + /** + * Normalize audio level to 0-1 range for UI visualization + */ + private fun normalizeAudioLevel(rms: Float): Float { + val dbLevel = 20 * log10(rms + 0.0001f) + return max(0f, min(1f, (dbLevel + 60) / 60)) + } +} diff --git a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/tts/TextToSpeechScreen.kt b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/tts/TextToSpeechScreen.kt new file mode 100644 index 000000000..384529e31 --- /dev/null +++ b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/tts/TextToSpeechScreen.kt @@ -0,0 +1,912 @@ +package com.runanywhere.runanywhereai.presentation.tts + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.outlined.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.runanywhere.runanywhereai.presentation.chat.components.ModelLoadedToast +import com.runanywhere.runanywhereai.presentation.models.ModelSelectionBottomSheet +import com.runanywhere.runanywhereai.ui.theme.AppColors +import com.runanywhere.runanywhereai.ui.theme.AppTypography +import com.runanywhere.runanywhereai.util.getModelLogoResIdForName +import com.runanywhere.sdk.public.extensions.Models.ModelSelectionContext +import kotlinx.coroutines.launch + +/** + * Text to Speech Screen + * + * Features: + * - Text input area with character count + * - Voice settings (speed, pitch sliders) + * - Generate/Speak button + * - Play/Stop button for playback + * - Audio info display + * - Model status banner + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TextToSpeechScreen(viewModel: TextToSpeechViewModel = viewModel()) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + var showModelPicker by remember { mutableStateOf(false) } + var showModelLoadedToast by remember { mutableStateOf(false) } + var loadedModelToastName by remember { mutableStateOf("") } + val scope = rememberCoroutineScope() + + Scaffold( + topBar = { + if (uiState.isModelLoaded) { + TopAppBar( + title = { + Text( + text = "Text to Speech", + style = MaterialTheme.typography.headlineMedium, + ) + }, + actions = { + IconButton(onClick = { showModelPicker = true }) { + TTSModelButton( + modelName = uiState.selectedModelName, + frameworkDisplayName = uiState.selectedFramework?.displayName, + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) + } + }, + ) { paddingValues -> + Box( + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues) + .background(MaterialTheme.colorScheme.background), + ) { + Column(modifier = Modifier.fillMaxSize()) { + if (uiState.isModelLoaded) { + Column( + modifier = + Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + TextInputSection( + text = uiState.inputText, + onTextChange = { viewModel.updateInputText(it) }, + characterCount = uiState.characterCount, + maxCharacters = uiState.maxCharacters, + onShuffle = { viewModel.shuffleSampleText() }, + ) + + VoiceSettingsSection( + speed = uiState.speed, + pitch = uiState.pitch, + onSpeedChange = { viewModel.updateSpeed(it) }, + onPitchChange = { viewModel.updatePitch(it) }, + ) + + if (uiState.audioDuration != null) { + AudioInfoSection( + duration = uiState.audioDuration!!, + audioSize = uiState.audioSize, + sampleRate = uiState.sampleRate, + ) + } + } + + HorizontalDivider() + + ControlsSection( + isGenerating = uiState.isGenerating, + isPlaying = uiState.isPlaying, + isSpeaking = uiState.isSpeaking, + hasGeneratedAudio = uiState.hasGeneratedAudio, + isSystemTTS = uiState.isSystemTTS, + isTextEmpty = uiState.inputText.isEmpty(), + isModelSelected = uiState.selectedModelName != null, + playbackProgress = uiState.playbackProgress, + currentTime = uiState.currentTime, + duration = uiState.audioDuration ?: 0.0, + errorMessage = uiState.errorMessage, + onGenerate = { viewModel.generateSpeech() }, + onStopSpeaking = { viewModel.stopSynthesis() }, + onTogglePlayback = { viewModel.togglePlayback() }, + ) + } + } + + if (!uiState.isModelLoaded && !uiState.isGenerating) { + ModelRequiredOverlayTTS( + onSelectModel = { showModelPicker = true }, + modifier = Modifier.matchParentSize(), + ) + } + + // Model loaded toast overlay + ModelLoadedToast( + modelName = loadedModelToastName, + isVisible = showModelLoadedToast, + onDismiss = { showModelLoadedToast = false }, + modifier = Modifier.align(Alignment.TopCenter), + ) + } + } + + if (showModelPicker) { + ModelSelectionBottomSheet( + context = ModelSelectionContext.TTS, + onDismiss = { showModelPicker = false }, + onModelSelected = { model -> + scope.launch { + android.util.Log.d("TextToSpeechScreen", "TTS model selected: ${model.name}") + // Notify ViewModel that model is loaded + viewModel.onModelLoaded( + modelName = model.name, + modelId = model.id, + framework = model.framework, + ) + showModelPicker = false + // Show model loaded toast + loadedModelToastName = model.name + showModelLoadedToast = true + } + }, + ) + } +} + +/** + * TTS toolbar model button - icon, model name to the right, below: electricity icon + Streaming text + */ +@Composable +private fun TTSModelButton( + modelName: String?, + frameworkDisplayName: String?, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + if (modelName != null) { + Box( + modifier = + Modifier + .size(36.dp) + .clip(RoundedCornerShape(4.dp)), + ) { + Image( + painter = painterResource(id = getModelLogoResIdForName(modelName)), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit, + ) + } + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = shortModelNameTTS(modelName), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + maxLines = 1, + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp), + ) { + Icon( + imageVector = Icons.Default.Bolt, + contentDescription = null, + modifier = Modifier.size(10.dp), + tint = AppColors.primaryGreen, + ) + Text( + text = "Streaming", + style = AppTypography.caption2.copy(fontSize = 10.sp, fontWeight = FontWeight.Medium), + color = AppColors.primaryGreen, + ) + } + } + } else { + Icon( + imageVector = Icons.Default.VolumeUp, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = AppColors.primaryPurple, + ) + Text( + text = "Select Model", + style = MaterialTheme.typography.labelMedium, + ) + } + } +} + +private fun shortModelNameTTS(name: String, maxLength: Int = 15): String { + val cleaned = name.replace(Regex("\\s*\\([^)]*\\)"), "").trim() + return if (cleaned.length > maxLength) cleaned.take(maxLength - 1) + "\u2026" else cleaned +} + +/** + * Model Status Banner for TTS (kept for reference; not used when app bar shows model) + */ +@Composable +private fun ModelStatusBannerTTS( + framework: String?, + modelName: String?, + isLoading: Boolean, + onSelectModel: () -> Unit, +) { + Surface( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + ) + Text( + text = "Loading voice...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else if (framework != null && modelName != null) { + Icon( + imageVector = Icons.Filled.VolumeUp, + contentDescription = null, + tint = AppColors.primaryAccent, + modifier = Modifier.size(18.dp), + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = framework, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = modelName, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + } + OutlinedButton( + onClick = onSelectModel, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp), + ) { + Text("Change", style = MaterialTheme.typography.labelMedium) + } + } else { + Icon( + imageVector = Icons.Filled.Warning, + contentDescription = null, + tint = AppColors.primaryOrange, + ) + Text( + text = "No voice selected", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + Button( + onClick = onSelectModel, + colors = + ButtonDefaults.buttonColors( + containerColor = AppColors.primaryAccent, + contentColor = Color.White, + ), + ) { + Icon( + Icons.Filled.Apps, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = Color.White, + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + "Select Voice", + color = Color.White, + ) + } + } + } + } +} + +/** + * Text Input Section + */ +@Composable +private fun TextInputSection( + text: String, + onTextChange: (String) -> Unit, + characterCount: Int, + @Suppress("UNUSED_PARAMETER") maxCharacters: Int, + onShuffle: () -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = "Enter Text", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + + OutlinedTextField( + value = text, + onValueChange = onTextChange, + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = 120.dp), + placeholder = { + Text("Type or paste text to convert to speech...") + }, + shape = RoundedCornerShape(12.dp), + ) + + // Character count and Surprise me! button row + // Character count and dice button row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "$characterCount characters", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Surface( + shape = RoundedCornerShape(8.dp), + color = AppColors.primaryPurple.copy(alpha = 0.15f), + onClick = onShuffle, + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + imageVector = Icons.Default.AutoAwesome, + contentDescription = "Surprise me", + modifier = Modifier.size(11.dp), + tint = AppColors.primaryPurple, + ) + Text( + text = "Surprise me", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = AppColors.primaryPurple, + ) + } + } + } + } +} + +/** + * Voice Settings Section with Speed and Pitch sliders + */ +@Composable +private fun VoiceSettingsSection( + speed: Float, + pitch: Float, + onSpeedChange: (Float) -> Unit, + onPitchChange: (Float) -> Unit, +) { + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = "Voice Settings", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + + // Speed slider + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "Speed", + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = String.format("%.1fx", speed), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + // 0.1 increments + Slider( + value = speed, + onValueChange = onSpeedChange, + valueRange = 0.5f..2.0f, + steps = 14, + colors = + SliderDefaults.colors( + thumbColor = AppColors.primaryAccent, + activeTrackColor = AppColors.primaryAccent, + ), + ) + } + + // Pitch slider + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "Pitch", + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = String.format("%.1fx", pitch), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Slider( + value = pitch, + onValueChange = onPitchChange, + valueRange = 0.5f..2.0f, + steps = 14, + colors = + SliderDefaults.colors( + thumbColor = AppColors.primaryPurple, + activeTrackColor = AppColors.primaryPurple, + ), + ) + } + } + } +} + +/** + * Audio Info Section + */ +@Composable +private fun AudioInfoSection( + duration: Double, + audioSize: Int?, + sampleRate: Int?, +) { + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = "Audio Info", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + + AudioInfoRow( + icon = Icons.Outlined.GraphicEq, + label = "Duration", + value = String.format("%.2fs", duration), + ) + + audioSize?.let { + AudioInfoRow( + icon = Icons.Outlined.Description, + label = "Size", + value = formatBytes(it), + ) + } + + sampleRate?.let { + AudioInfoRow( + icon = Icons.Outlined.VolumeUp, + label = "Sample Rate", + value = "$it Hz", + ) + } + } + } +} + +@Composable +private fun AudioInfoRow( + icon: androidx.compose.ui.graphics.vector.ImageVector, + label: String, + value: String, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "$label:", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.weight(1f)) + Text( + text = value, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + ) + } +} + +/** + * Controls Section with Generate and Play buttons + */ +@Composable +private fun ControlsSection( + isGenerating: Boolean, + isPlaying: Boolean, + isSpeaking: Boolean, + hasGeneratedAudio: Boolean, + isSystemTTS: Boolean, + isTextEmpty: Boolean, + isModelSelected: Boolean, + playbackProgress: Double, + currentTime: Double, + duration: Double, + errorMessage: String?, + onGenerate: () -> Unit, + onStopSpeaking: () -> Unit, + onTogglePlayback: () -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + errorMessage?.let { error -> + Text( + text = error, + style = MaterialTheme.typography.bodySmall, + color = AppColors.statusRed, + textAlign = TextAlign.Center, + ) + } + + // Playback progress (when playing) + if (isPlaying) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = formatTime(currentTime), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + LinearProgressIndicator( + progress = { playbackProgress.toFloat() }, + modifier = Modifier.weight(1f), + color = AppColors.primaryAccent, + ) + Text( + text = formatTime(duration), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + // Action buttons + Row( + horizontalArrangement = Arrangement.spacedBy(20.dp), + ) { + // Generate/Speak button (System TTS toggles Stop while speaking) + Button( + onClick = { + if (isSystemTTS && isSpeaking) { + onStopSpeaking() + } else { + onGenerate() + } + }, + enabled = !isTextEmpty && isModelSelected && !isGenerating, + modifier = + Modifier + .width(140.dp) + .height(50.dp), + shape = RoundedCornerShape(25.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = AppColors.primaryAccent, + contentColor = Color.White, + disabledContainerColor = Color.Gray, + ), + ) { + if (isGenerating) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = Color.White, + strokeWidth = 2.dp, + ) + } else { + Icon( + imageVector = + if (isSystemTTS && isSpeaking) { + Icons.Filled.Stop + } else if (isSystemTTS) { + Icons.Filled.VolumeUp + } else { + Icons.Filled.GraphicEq + }, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = Color.White, + ) + } + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = + if (isSystemTTS && isSpeaking) { + "Stop" + } else if (isSystemTTS) { + "Speak" + } else { + "Generate" + }, + fontWeight = FontWeight.SemiBold, + color = Color.White, + ) + } + + // Play/Stop button (only for non-System TTS) + Button( + onClick = onTogglePlayback, + enabled = hasGeneratedAudio && !isSystemTTS && !isSpeaking, + modifier = + Modifier + .width(140.dp) + .height(50.dp), + shape = RoundedCornerShape(25.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = if (hasGeneratedAudio) AppColors.primaryGreen else Color.Gray, + disabledContainerColor = Color.Gray, + ), + ) { + Icon( + imageVector = if (isPlaying) Icons.Filled.Stop else Icons.Filled.PlayArrow, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = if (isPlaying) "Stop" else "Play", + fontWeight = FontWeight.SemiBold, + ) + } + } + + // Status text + Text( + text = + when { + isSpeaking -> "Speaking..." + isSystemTTS -> "System TTS plays directly" + isGenerating -> "Generating speech..." + isPlaying -> "Playing..." + else -> "Ready" + }, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** + * Model Required Overlay for TTS - purple, "Read Aloud", same layout as Chat/STT overlay + */ +@Composable +private fun ModelRequiredOverlayTTS( + onSelectModel: () -> Unit, + modifier: Modifier = Modifier, +) { + val modalityColor = AppColors.primaryPurple + val infiniteTransition = rememberInfiniteTransition(label = "tts_overlay_circles") + val circle1Offset by infiniteTransition.animateFloat( + initialValue = -100f, + targetValue = 100f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 8000, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "c1", + ) + val circle2Offset by infiniteTransition.animateFloat( + initialValue = 100f, + targetValue = -100f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 8000, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "c2", + ) + val circle3Offset by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 80f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 8000, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "c3", + ) + val density = LocalDensity.current + val c1Dp = with(density) { circle1Offset.toDp() } + val c2Dp = with(density) { circle2Offset.toDp() } + val c3Dp = with(density) { circle3Offset.toDp() } + + Box(modifier = modifier.fillMaxSize()) { + Box(modifier = Modifier.fillMaxSize().blur(32.dp)) { + Box( + modifier = Modifier + .size(300.dp) + .offset(x = c1Dp, y = (-200).dp) + .clip(CircleShape) + .background(modalityColor.copy(alpha = 0.15f)), + ) + Box( + modifier = Modifier + .size(250.dp) + .offset(x = c2Dp, y = 300.dp) + .clip(CircleShape) + .background(modalityColor.copy(alpha = 0.12f)), + ) + Box( + modifier = Modifier + .size(280.dp) + .offset(x = -c3Dp, y = c3Dp) + .clip(CircleShape) + .background(modalityColor.copy(alpha = 0.08f)), + ) + } + Column( + modifier = Modifier.fillMaxSize().padding(horizontal = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.weight(1f)) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(120.dp) + .clip(CircleShape) + .background( + Brush.linearGradient( + listOf( + modalityColor.copy(alpha = 0.2f), + modalityColor.copy(alpha = 0.1f), + ), + ), + ), + ) { + Icon( + imageVector = Icons.Default.VolumeUp, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = modalityColor, + ) + } + Spacer(modifier = Modifier.height(20.dp)) + Text( + text = "Read Aloud", + style = MaterialTheme.typography.titleLarge, + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "Have any text read aloud with natural-sounding voices.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.weight(1f)) + Button( + onClick = onSelectModel, + colors = ButtonDefaults.buttonColors(containerColor = modalityColor), + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Default.AutoAwesome, contentDescription = null, modifier = Modifier.size(20.dp), tint = Color.White) + Spacer(modifier = Modifier.width(8.dp)) + Text("Get Started", style = MaterialTheme.typography.titleMedium, color = Color.White) + } + Spacer(modifier = Modifier.height(16.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier.padding(bottom = 16.dp), + ) { + Icon(Icons.Default.Lock, contentDescription = null, modifier = Modifier.size(14.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = "100% Private • Runs on your device", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +// Helper functions + +private fun formatBytes(bytes: Int): String { + val kb = bytes / 1024.0 + return if (kb < 1024) { + String.format("%.1f KB", kb) + } else { + String.format("%.1f MB", kb / 1024.0) + } +} + +private fun formatTime(seconds: Double): String { + val mins = (seconds / 60).toInt() + val secs = (seconds % 60).toInt() + return String.format("%d:%02d", mins, secs) +} diff --git a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/tts/TextToSpeechViewModel.kt b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/tts/TextToSpeechViewModel.kt new file mode 100644 index 000000000..fdb9947fb --- /dev/null +++ b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/tts/TextToSpeechViewModel.kt @@ -0,0 +1,728 @@ +package com.runanywhere.runanywhereai.presentation.tts + +import android.app.Application +import android.media.AudioAttributes +import android.media.AudioFormat +import android.media.AudioTrack +import android.speech.tts.TextToSpeech +import android.speech.tts.UtteranceProgressListener +import android.util.Log +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.runanywhere.sdk.core.types.InferenceFramework +import com.runanywhere.sdk.public.RunAnywhere +import com.runanywhere.sdk.public.events.EventBus +import com.runanywhere.sdk.public.events.EventCategory +import com.runanywhere.sdk.public.events.ModelEvent +import com.runanywhere.sdk.public.events.TTSEvent +import com.runanywhere.sdk.public.extensions.TTS.TTSOptions +import com.runanywhere.sdk.public.extensions.currentTTSVoiceId +import com.runanywhere.sdk.public.extensions.isTTSVoiceLoadedSync +import com.runanywhere.sdk.public.extensions.loadTTSVoice +import com.runanywhere.sdk.public.extensions.stopSynthesis +import com.runanywhere.sdk.public.extensions.synthesize +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import java.util.Locale +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +private const val TAG = "TTSViewModel" +private const val SYSTEM_TTS_MODEL_ID = "system-tts" + +/** + * Collection of funny sample texts for TTS demo + * Matches iOS funnyTTSSampleTexts in TextToSpeechView.swift + */ +val funnyTTSSampleTexts = + listOf( + "I'm not saying I'm Batman, but have you ever seen me and Batman in the same room?", + "According to my calculations, I should have been a millionaire by now. My calculations were wrong.", + "I told my computer I needed a break, and now it won't stop sending me vacation ads.", + "Why do programmers prefer dark mode? Because light attracts bugs!", + "I speak fluent sarcasm. Unfortunately, my phone's voice assistant doesn't.", + "I'm on a seafood diet. I see food and I eat it. Then I feel regret.", + "My brain has too many tabs open and I can't find the one playing music.", + "I put my phone on airplane mode but it didn't fly. Worst paper airplane ever.", + "I'm not lazy, I'm just on energy-saving mode. Like a responsible gadget.", + "If Monday had a face, I would politely ask it to reconsider its life choices.", + "I tried to be normal once. Worst two minutes of my life.", + "My favorite exercise is a cross between a lunge and a crunch. I call it lunch.", + "I don't need anger management. I need people to stop irritating me.", + "I'm not arguing, I'm just explaining why I'm right. There's a difference.", + "Coffee: because adulting is hard and mornings are a cruel joke.", + "I finally found my spirit animal. It's a sloth having a bad hair day.", + "My wallet is like an onion. When I open it, I cry.", + "I'm not short, I'm concentrated awesome in a compact package.", + "Life update: currently holding it all together with one bobby pin.", + "I would lose weight, but I hate losing.", + "Behind every great person is a cat judging them silently.", + "I'm on the whiskey diet. I've lost three days already.", + "My houseplants are thriving! Just kidding, they're plastic.", + "I don't sweat, I sparkle. Aggressively. With visible discomfort.", + "Plot twist: the hokey pokey really IS what it's all about.", + // RunAnywhere SDK promotional texts + "RunAnywhere: because your AI should work even when your WiFi doesn't.", + "We're a Y Combinator company now. Our moms are finally proud of us.", + "On-device AI means your voice data stays on your phone. Unlike your ex, we respect privacy.", + "RunAnywhere: Making cloud APIs jealous since 2026.", + "Our SDK is so fast, it finished processing before you finished reading this sentence.", + "Why pay per API call when you can run AI locally? Your wallet called, it says thank you.", + "RunAnywhere: We put the 'smart' in smartphone, and the 'savings' in your bank account.", + "Backed by Y Combinator. Powered by caffeine. Fueled by the dream of affordable AI.", + "Our on-device models are like introverts. They do great work without needing the cloud.", + "RunAnywhere SDK: Because latency is just a fancy word for 'too slow'.", + "Voice AI that runs offline? That's not magic, that's just good engineering. Okay, maybe a little magic.", + "We optimized our models so hard, they now run faster than your excuses for not exercising.", + "RunAnywhere: Where 'it works offline' isn't a bug, it's the whole feature.", + "Y Combinator believed in us. Your device believes in us. Now it's your turn.", + "On-device AI: All the intelligence, none of the monthly subscription fees.", + "Our SDK is like a good friend: fast, reliable, and doesn't share your secrets with big tech.", + "RunAnywhere makes voice AI accessible. Like, actually accessible. Not 'enterprise pricing' accessible.", + ) + +private fun getRandomSampleText(): String = funnyTTSSampleTexts.random() + +// Initial random text for default state +private val initialSampleText = getRandomSampleText() + +/** + * TTS UI State + * iOS Reference: TTSViewModel published properties in TextToSpeechView.swift + */ +data class TTSUiState( + val inputText: String = initialSampleText, + val characterCount: Int = initialSampleText.length, + val maxCharacters: Int = 5000, + val isModelLoaded: Boolean = false, + val selectedFramework: InferenceFramework? = null, + val selectedModelName: String? = null, + val selectedModelId: String? = null, + val isGenerating: Boolean = false, + val isPlaying: Boolean = false, + val isSpeaking: Boolean = false, + val hasGeneratedAudio: Boolean = false, + val isSystemTTS: Boolean = false, + val speed: Float = 1.0f, + val pitch: Float = 1.0f, + val audioDuration: Double? = null, + val audioSize: Int? = null, + val sampleRate: Int? = null, + val playbackProgress: Double = 0.0, + val currentTime: Double = 0.0, + val errorMessage: String? = null, + val processingTimeMs: Long? = null, +) + +/** + * Text to Speech ViewModel + * + * iOS Reference: TTSViewModel in TextToSpeechView.swift + * + * This ViewModel manages: + * - Voice/model selection and loading via RunAnywhere SDK + * - Speech generation from text via RunAnywhere.synthesize() + * - Audio playback controls with AudioTrack + * - Voice settings (speed, pitch) + * + * Architecture matches iOS: + * - Uses RunAnywhere SDK extension functions directly + * - Model loading via RunAnywhere.loadTTSVoice() + * - Event subscription via RunAnywhere.events.events + */ +class TextToSpeechViewModel( + application: Application, +) : AndroidViewModel(application) { + private val _uiState = MutableStateFlow(TTSUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + // Audio playback + private var audioTrack: AudioTrack? = null + private var generatedAudioData: ByteArray? = null + private var playbackJob: Job? = null + + // System TTS playback + private var systemTts: TextToSpeech? = null + private var systemTtsInit: CompletableDeferred? = null + + init { + Log.i(TAG, "Initializing TTS ViewModel...") + + // Subscribe to SDK events for TTS model state + viewModelScope.launch { + EventBus.events.collect { event -> + // Handle TTS-specific events + if (event is TTSEvent) { + handleTTSEvent(event) + } + // Handle model events with TTS category + if (event is ModelEvent && event.category == EventCategory.TTS) { + handleModelEvent(event) + } + } + } + + // Check initial TTS state + updateTTSState() + } + + /** + * Handle TTS events from SDK EventBus + * iOS Reference: Event subscription in TTSViewModel + */ + private fun handleTTSEvent(event: TTSEvent) { + when (event.eventType) { + TTSEvent.TTSEventType.SYNTHESIS_STARTED -> { + Log.d(TAG, "Synthesis started") + } + TTSEvent.TTSEventType.SYNTHESIS_COMPLETED -> { + Log.i(TAG, "Synthesis completed: ${event.durationMs}ms") + } + TTSEvent.TTSEventType.SYNTHESIS_FAILED -> { + Log.e(TAG, "Synthesis failed: ${event.error}") + _uiState.update { + it.copy( + isGenerating = false, + errorMessage = "Synthesis failed: ${event.error}", + ) + } + } + TTSEvent.TTSEventType.PLAYBACK_STARTED -> { + Log.d(TAG, "Playback started") + } + TTSEvent.TTSEventType.PLAYBACK_COMPLETED -> { + Log.d(TAG, "Playback completed") + } + } + } + + /** + * Handle model events for TTS + */ + private fun handleModelEvent(event: ModelEvent) { + when (event.eventType) { + ModelEvent.ModelEventType.LOADED -> { + Log.i(TAG, "✅ TTS model loaded: ${event.modelId}") + _uiState.update { + it.copy( + isModelLoaded = true, + selectedModelId = event.modelId, + selectedModelName = event.modelId, + ) + } + // Shuffle sample text when model is first loaded + shuffleSampleText() + } + ModelEvent.ModelEventType.UNLOADED -> { + Log.d(TAG, "TTS model unloaded: ${event.modelId}") + _uiState.update { + it.copy( + isModelLoaded = false, + selectedModelId = null, + selectedModelName = null, + ) + } + } + ModelEvent.ModelEventType.DOWNLOAD_STARTED -> { + Log.d(TAG, "TTS model download started: ${event.modelId}") + } + ModelEvent.ModelEventType.DOWNLOAD_COMPLETED -> { + Log.d(TAG, "TTS model download completed: ${event.modelId}") + } + ModelEvent.ModelEventType.DOWNLOAD_FAILED -> { + Log.e(TAG, "TTS model download failed: ${event.modelId} - ${event.error}") + _uiState.update { + it.copy( + errorMessage = "Download failed: ${event.error}", + ) + } + } + else -> { /* Other events not relevant for TTS state */ } + } + } + + /** + * Update TTS state from SDK + */ + private fun updateTTSState() { + val isLoaded = RunAnywhere.isTTSVoiceLoadedSync + val voiceId = RunAnywhere.currentTTSVoiceId + + _uiState.update { + it.copy( + isModelLoaded = isLoaded, + selectedModelId = voiceId, + selectedModelName = voiceId, + ) + } + } + + /** + * Load a TTS voice + * iOS Reference: loadVoice() in TTSViewModel + */ + fun loadVoice(voiceId: String) { + viewModelScope.launch { + try { + Log.i(TAG, "Loading TTS voice: $voiceId") + RunAnywhere.loadTTSVoice(voiceId) + updateTTSState() + } catch (e: Exception) { + Log.e(TAG, "Failed to load TTS voice: ${e.message}", e) + _uiState.update { + it.copy(errorMessage = "Failed to load voice: ${e.message}") + } + } + } + } + + /** + * Called when a model is loaded from the ModelSelectionBottomSheet + * This explicitly updates the ViewModel state when a model is selected and loaded + */ + fun onModelLoaded( + modelName: String, + modelId: String, + framework: InferenceFramework?, + ) { + Log.i(TAG, "Model loaded notification: $modelName (id: $modelId, framework: ${framework?.displayName})") + + val isSystem = modelId == SYSTEM_TTS_MODEL_ID || framework == InferenceFramework.SYSTEM_TTS + + _uiState.update { + it.copy( + isModelLoaded = true, + selectedModelName = modelName, + selectedModelId = modelId, + selectedFramework = framework, + isSystemTTS = isSystem, + errorMessage = null, + ) + } + + // Shuffle sample text when model is loaded + shuffleSampleText() + } + + /** + * Initialize the TTS ViewModel + * iOS Reference: initialize() in TTSViewModel + */ + fun initialize() { + Log.i(TAG, "Initializing TTS ViewModel...") + updateTTSState() + } + + /** + * Update the input text for TTS + */ + fun updateInputText(text: String) { + _uiState.update { + it.copy( + inputText = text, + characterCount = text.length, + ) + } + } + + /** + * Shuffle to a random sample text + * iOS Reference: "Surprise me!" button in TextToSpeechView + */ + fun shuffleSampleText() { + val newText = getRandomSampleText() + _uiState.update { + it.copy( + inputText = newText, + characterCount = newText.length, + ) + } + } + + /** + * Update speech speed + * + * @param speed Speed multiplier (0.5 - 2.0) + */ + fun updateSpeed(speed: Float) { + _uiState.update { it.copy(speed = speed) } + } + + /** + * Update speech pitch + * + * @param pitch Pitch multiplier (0.5 - 2.0) + */ + fun updatePitch(pitch: Float) { + _uiState.update { it.copy(pitch = pitch) } + } + + /** + * Generate speech from text via RunAnywhere SDK + * iOS Reference: generateSpeech(text:) in TTSViewModel + */ + fun generateSpeech() { + viewModelScope.launch { + val text = _uiState.value.inputText + if (text.isEmpty()) return@launch + + val isSystem = _uiState.value.isSystemTTS + if (!isSystem && !RunAnywhere.isTTSVoiceLoadedSync) { + _uiState.update { + it.copy(errorMessage = "No TTS model loaded. Please select a voice first.") + } + return@launch + } + + _uiState.update { + it.copy( + isGenerating = !isSystem, + isSpeaking = isSystem, + hasGeneratedAudio = false, + errorMessage = null, + ) + } + + try { + Log.i(TAG, "Generating speech for text: ${text.take(50)}...") + + val startTime = System.currentTimeMillis() + + // Create TTS options with current settings + val options = + TTSOptions( + voice = _uiState.value.selectedModelId, + language = "en-US", + rate = _uiState.value.speed, + pitch = _uiState.value.pitch, + volume = 1.0f, + ) + + if (isSystem) { + speakSystemTts(text, options) + val processingTime = System.currentTimeMillis() - startTime + _uiState.update { + it.copy( + isGenerating = false, + isSpeaking = false, + audioDuration = null, + audioSize = null, + sampleRate = null, + processingTimeMs = processingTime, + ) + } + } else { + // Use RunAnywhere.synthesize() via SDK extension function + val result = + withContext(Dispatchers.IO) { + RunAnywhere.synthesize(text, options) + } + + val processingTime = System.currentTimeMillis() - startTime + + if (result.audioData.isEmpty()) { + Log.i(TAG, "TTS synthesis returned empty audio") + _uiState.update { + it.copy( + isGenerating = false, + isSpeaking = false, + audioDuration = result.duration, + audioSize = null, + sampleRate = null, + processingTimeMs = processingTime, + ) + } + } else { + // ONNX/Piper TTS returns audio data for playback + Log.i(TAG, "✅ Speech generation complete: ${result.audioData.size} bytes, duration: ${result.duration}s") + + generatedAudioData = result.audioData + + _uiState.update { + it.copy( + isGenerating = false, + isSpeaking = false, + hasGeneratedAudio = true, + audioDuration = result.duration, + audioSize = result.audioData.size, + sampleRate = null, + processingTimeMs = processingTime, + ) + } + } + } + } catch (e: Exception) { + Log.e(TAG, "Speech generation failed: ${e.message}", e) + _uiState.update { + it.copy( + isGenerating = false, + isSpeaking = false, + errorMessage = "Speech generation failed: ${e.message}", + ) + } + } + } + } + + /** + * Toggle audio playback + * iOS Reference: togglePlayback() in TTSViewModel + */ + fun togglePlayback() { + if (_uiState.value.isPlaying) { + stopPlayback() + } else { + startPlayback() + } + } + + /** + * Start audio playback using AudioTrack + * iOS Reference: startPlayback() using AVAudioPlayer + */ + private fun startPlayback() { + val audioData = generatedAudioData + if (audioData == null || audioData.isEmpty()) { + Log.w(TAG, "No audio data to play") + return + } + + Log.i(TAG, "Starting playback of ${audioData.size} bytes") + _uiState.update { it.copy(isPlaying = true) } + + playbackJob = + viewModelScope.launch(Dispatchers.IO) { + try { + // Parse WAV header to get audio parameters + val sampleRate = _uiState.value.sampleRate ?: 22050 + val channelConfig = AudioFormat.CHANNEL_OUT_MONO + val audioFormat = AudioFormat.ENCODING_PCM_16BIT + + // Skip WAV header (44 bytes) if present + val headerSize = + if (audioData.size > 44 && + audioData[0] == 'R'.code.toByte() && + audioData[1] == 'I'.code.toByte() && + audioData[2] == 'F'.code.toByte() && + audioData[3] == 'F'.code.toByte() + ) { + 44 + } else { + 0 + } + + val pcmData = audioData.copyOfRange(headerSize, audioData.size) + + val bufferSize = AudioTrack.getMinBufferSize(sampleRate, channelConfig, audioFormat) + + audioTrack = + AudioTrack.Builder() + .setAudioAttributes( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .build(), + ) + .setAudioFormat( + AudioFormat.Builder() + .setEncoding(audioFormat) + .setSampleRate(sampleRate) + .setChannelMask(channelConfig) + .build(), + ) + .setBufferSizeInBytes(bufferSize.coerceAtLeast(pcmData.size)) + .setTransferMode(AudioTrack.MODE_STATIC) + .build() + + audioTrack?.write(pcmData, 0, pcmData.size) + audioTrack?.play() + + // Track playback progress (matches iOS timer pattern) + val duration = _uiState.value.audioDuration ?: (pcmData.size.toDouble() / (sampleRate * 2)) + var currentTime = 0.0 + + while (_uiState.value.isPlaying && audioTrack?.playState == AudioTrack.PLAYSTATE_PLAYING) { + delay(100) + currentTime += 0.1 + + // Check if we've reached the end of audio + if (currentTime >= duration) { + break + } + + withContext(Dispatchers.Main) { + _uiState.update { + it.copy( + currentTime = currentTime, + playbackProgress = (currentTime / duration).coerceIn(0.0, 1.0), + ) + } + } + } + + // Playback finished - stop and reset state + withContext(Dispatchers.Main) { + stopPlayback() + } + } catch (e: Exception) { + Log.e(TAG, "Playback error: ${e.message}", e) + withContext(Dispatchers.Main) { + _uiState.update { + it.copy( + isPlaying = false, + errorMessage = "Playback failed: ${e.message}", + ) + } + } + } + } + } + + /** + * Stop audio playback + * iOS Reference: stopPlayback() using AVAudioPlayer + */ + private fun stopPlayback() { + // Update state first to signal the playback loop to stop + _uiState.update { + it.copy( + isPlaying = false, + currentTime = 0.0, + playbackProgress = 0.0, + ) + } + + // Cancel the playback job + playbackJob?.cancel() + playbackJob = null + + // Stop and release AudioTrack + audioTrack?.stop() + audioTrack?.release() + audioTrack = null + + Log.d(TAG, "Playback stopped") + } + + /** + * Stop current synthesis + */ + fun stopSynthesis() { + viewModelScope.launch { + RunAnywhere.stopSynthesis() + } + systemTts?.stop() + _uiState.update { it.copy(isGenerating = false, isSpeaking = false) } + } + + override fun onCleared() { + super.onCleared() + Log.i(TAG, "ViewModel cleared, cleaning up resources") + stopPlayback() + generatedAudioData = null + systemTts?.shutdown() + systemTts = null + systemTtsInit = null + } + + private suspend fun speakSystemTts( + text: String, + options: TTSOptions, + ) { + val ready = ensureSystemTtsReady() + if (!ready) { + throw IllegalStateException("System TTS not available") + } + + withContext(Dispatchers.Main) { + val tts = systemTts ?: throw IllegalStateException("System TTS not initialized") + val locale = Locale.forLanguageTag(options.language.ifBlank { "en-US" }) + tts.language = locale + tts.setSpeechRate(options.rate) + tts.setPitch(options.pitch) + } + + suspendCancellableCoroutine { continuation -> + val tts = systemTts + if (tts == null) { + continuation.resumeWithException(IllegalStateException("System TTS not initialized")) + return@suspendCancellableCoroutine + } + + val utteranceId = "system-tts-${System.currentTimeMillis()}" + tts.setOnUtteranceProgressListener( + object : UtteranceProgressListener() { + override fun onStart(utteranceId: String?) { + Log.d(TAG, "System TTS started") + } + + override fun onDone(utteranceId: String?) { + if (continuation.isActive) { + continuation.resume(Unit) + } + } + + override fun onError(utteranceId: String?) { + if (continuation.isActive) { + continuation.resumeWithException(IllegalStateException("System TTS error")) + } + } + + override fun onStop(utteranceId: String?, interrupted: Boolean) { + if (continuation.isActive) { + if (interrupted) { + continuation.resume(Unit) + } else { + continuation.resumeWithException(IllegalStateException("System TTS stopped")) + } + } + } + }, + ) + + val result = + tts.speak( + text, + TextToSpeech.QUEUE_FLUSH, + null, + utteranceId, + ) + if (result != TextToSpeech.SUCCESS) { + continuation.resumeWithException(IllegalStateException("System TTS speak failed")) + } + } + } + + private suspend fun ensureSystemTtsReady(): Boolean { + val deferred = + systemTtsInit + ?: CompletableDeferred().also { init -> + systemTtsInit = init + withContext(Dispatchers.Main) { + systemTts = + TextToSpeech(getApplication()) { status -> + val ready = status == TextToSpeech.SUCCESS + if (ready) { + init.complete(true) + } else { + systemTts?.shutdown() + systemTts = null + systemTtsInit = null + init.complete(false) + } + } + } + } + + return deferred.await() + } +} diff --git a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/voice/VoiceAssistantParticleView.kt b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/voice/VoiceAssistantParticleView.kt new file mode 100644 index 000000000..a971d2a81 --- /dev/null +++ b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/voice/VoiceAssistantParticleView.kt @@ -0,0 +1,274 @@ +package com.runanywhere.runanywhereai.presentation.voice + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import kotlin.math.* +import kotlin.random.Random + +/** + * VoiceAssistantParticleView - Particle animation for voice assistant + * + * Ported from iOS VoiceAssistantParticleView.swift (Metal-based) + * This is a Canvas-based Compose implementation that mimics the visual effect. + * + * Features: + * - 2000 particles distributed on a Fibonacci sphere + * - Morphs between sphere (idle) and ring (active) states + * - Responds to audio amplitude + * - Touch scatter effect + * - Smooth breathing animation + */ +@Composable +fun VoiceAssistantParticleView( + amplitude: Float, + morphProgress: Float, + scatterAmount: Float, + touchPoint: Offset, + isDarkMode: Boolean = isSystemInDarkTheme(), + modifier: Modifier = Modifier, +) { + // Generate particles once + val particles = remember { generateFibonacciSphereParticles(PARTICLE_COUNT) } + + // Time for animation + var time by remember { mutableFloatStateOf(0f) } + + // Update time continuously + LaunchedEffect(Unit) { + val startTime = System.currentTimeMillis() + while (true) { + time = (System.currentTimeMillis() - startTime) / 1000f + kotlinx.coroutines.delay(16) // ~60 FPS + } + } + + // Base colors + val baseColor = if (isDarkMode) { + Color(1f, 0.6f, 0.1f) // Brighter golden for dark mode + } else { + Color(0.9f, 0.4f, 0.05f) // Richer orange for light mode + } + + val activeColor = Color(1f, 0.55f, 0.15f) // Warm amber + + Canvas(modifier = modifier) { + val centerX = size.width / 2 + val centerY = size.height / 2 + val scale = minOf(size.width, size.height) * 0.35f + + particles.forEach { particle -> + drawParticle( + particle = particle, + time = time, + morphProgress = morphProgress, + amplitude = amplitude, + scatterAmount = scatterAmount, + touchPoint = touchPoint, + centerX = centerX, + centerY = centerY, + scale = scale, + baseColor = baseColor, + activeColor = activeColor, + isDarkMode = isDarkMode + ) + } + } +} + +private data class Particle( + val position: Triple, // x, y, z on unit sphere + val index: Float, // 0-1 normalized index + val radiusOffset: Float, // random offset for ring variation + val seed: Float // random seed for animation variation +) + +private const val PARTICLE_COUNT = 800 // Reduced from iOS 2000 for performance + +private fun generateFibonacciSphereParticles(count: Int): List { + val goldenRatio = (1.0 + sqrt(5.0)) / 2.0 + val angleIncrement = (PI * 2.0 * goldenRatio).toFloat() + + return (0 until count).map { i -> + val t = i.toFloat() / (count - 1).toFloat() + val inclination = acos(1f - 2f * t) + val azimuth = angleIncrement * i + + val x = sin(inclination) * cos(azimuth) + val y = sin(inclination) * sin(azimuth) + val z = cos(inclination) + + Particle( + position = Triple(x, y, z), + index = i.toFloat() / count, + radiusOffset = Random.nextFloat() * 2f - 1f, + seed = Random.nextFloat() + ) + } +} + +private fun DrawScope.drawParticle( + particle: Particle, + time: Float, + morphProgress: Float, + amplitude: Float, + scatterAmount: Float, + touchPoint: Offset, + centerX: Float, + centerY: Float, + scale: Float, + baseColor: Color, + activeColor: Color, + isDarkMode: Boolean +) { + val (sphereX, sphereY, sphereZ) = particle.position + val seed = particle.seed + + // === SPHERE STATE === + // Rotate sphere slowly + val sphereAngle = -time * 0.2f + val cosA = cos(sphereAngle) + val sinA = sin(sphereAngle) + + var rotatedX = sphereX * cosA - sphereZ * sinA + var rotatedY = sphereY + var rotatedZ = sphereX * sinA + sphereZ * cosA + + // Breathing effect + val breath = 1f + sin(time) * 0.025f + rotatedX *= breath + rotatedY *= breath + rotatedZ *= breath + + // === RING STATE === + val ringAngle = particle.index * PI.toFloat() * 2f + time * 0.25f + val baseRingRadius = 1.3f + val audioPulse = amplitude * 0.4f + val ringRadius = baseRingRadius + audioPulse + sin(time * 1.5f) * 0.03f + particle.radiusOffset * 0.18f + + val ringX = cos(ringAngle) * ringRadius + val ringY = sin(ringAngle) * ringRadius + val ringZ = 0f + + // === MORPH === + val personalSpeed = 0.6f + seed * 0.8f + val personalMorph = (morphProgress * personalSpeed + (seed - 0.5f) * 0.3f).coerceIn(0f, 1f) + // Double smoothstep for extra smooth transition + var smoothMorph = personalMorph * personalMorph * (3f - 2f * personalMorph) + smoothMorph = smoothMorph * smoothMorph * (3f - 2f * smoothMorph) + + // Wandering during transition + val wanderPhase = morphProgress * (1f - morphProgress) * 4f + val wanderX = (noise(seed * 100f, time * 0.3f) - 0.5f) * wanderPhase * 0.6f + val wanderY = (noise(seed * 100f + 50f, time * 0.3f) - 0.5f) * wanderPhase * 0.6f + val wanderZ = (noise(seed * 100f + 100f, time * 0.3f) - 0.5f) * wanderPhase * 0.6f + + // Spiral during transition + val spiralAngle = seed * 6.28f + time * 0.5f + val spiralRadius = wanderPhase * 0.25f + val spiralX = cos(spiralAngle) * spiralRadius + val spiralY = sin(spiralAngle) * spiralRadius + + // Interpolate between sphere and ring + var finalX = lerp(rotatedX, ringX, smoothMorph) + wanderX + spiralX + var finalY = lerp(rotatedY, ringY, smoothMorph) + wanderY + spiralY + val finalZ = lerp(rotatedZ, ringZ, smoothMorph) + wanderZ + + // === TOUCH SCATTER === + if (scatterAmount > 0.001f) { + // Calculate screen position + val projScale = 0.85f + val tempZ = finalZ + 2.5f + val screenX = (finalX / tempZ) * projScale + val screenY = (finalY / tempZ) * projScale + + // Distance from touch + val dx = screenX - touchPoint.x + val dy = screenY - touchPoint.y + val touchDist = sqrt(dx * dx + dy * dy) + + // Affect particles near touch + val touchRadius = 0.35f + val touchInfluence = ((1f - (touchDist / touchRadius)).coerceIn(0f, 1f)) * scatterAmount + + if (touchInfluence > 0.001f) { + // Push outward from touch + val pushLen = sqrt(dx * dx + dy * dy) + 0.001f + val pushX = dx / pushLen + val pushY = dy / pushLen + val pushAmount = touchInfluence * 0.15f + + finalX += pushX * pushAmount + finalY += pushY * pushAmount + } + } + + // === PROJECTION === + val z = finalZ + 2.5f + val projScale = 0.85f + val projX = centerX + (finalX / z) * projScale * scale + val projY = centerY - (finalY / z) * projScale * scale // Flip Y + + // === SIZE === + val baseSize = 3f + val transitionGlow = 1f + wanderPhase * 0.4f + val particleSize = (baseSize * (2.8f / z) * transitionGlow * scale / 100f).coerceIn(2f, 8f) + + // === COLOR === + val energy = smoothMorph * (0.5f + amplitude * 0.5f) + val particleColor = lerpColor(baseColor, activeColor, energy) + + // Brightness adjustment + val brightMultiplier = if (isDarkMode) 1.5f + energy * 0.5f else 2.2f + energy * 0.6f + val finalColor = particleColor.copy( + red = (particleColor.red * brightMultiplier).coerceIn(0f, 1f), + green = (particleColor.green * brightMultiplier).coerceIn(0f, 1f), + blue = (particleColor.blue * brightMultiplier).coerceIn(0f, 1f) + ) + + // === ALPHA === + val depthShade = 0.5f + 0.5f * (1f - (z - 1.8f) / 2f) + val alpha = lerp(depthShade * 0.8f, 1f, smoothMorph).coerceIn(0.3f, 1f) + + // Draw particle with glow effect + drawCircle( + brush = Brush.radialGradient( + colors = listOf( + finalColor.copy(alpha = alpha), + finalColor.copy(alpha = alpha * 0.5f), + finalColor.copy(alpha = 0f) + ), + center = Offset(projX, projY), + radius = particleSize * 2f + ), + radius = particleSize * 2f, + center = Offset(projX, projY) + ) + + // Core + drawCircle( + color = finalColor.copy(alpha = alpha), + radius = particleSize, + center = Offset(projX, projY) + ) +} + +// Simple pseudo-random noise function +private fun noise(x: Float, y: Float): Float { + val n = sin(x * 12.9898f + y * 78.233f) * 43758.5453f + return n - floor(n) +} + +private fun lerp(a: Float, b: Float, t: Float): Float = a + (b - a) * t + +private fun lerpColor(a: Color, b: Color, t: Float): Color = Color( + red = lerp(a.red, b.red, t), + green = lerp(a.green, b.green, t), + blue = lerp(a.blue, b.blue, t), + alpha = lerp(a.alpha, b.alpha, t) +) diff --git a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/voice/VoiceAssistantScreen.kt b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/voice/VoiceAssistantScreen.kt new file mode 100644 index 000000000..c318fd479 --- /dev/null +++ b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/voice/VoiceAssistantScreen.kt @@ -0,0 +1,1142 @@ +package com.runanywhere.runanywhereai.presentation.voice + +import android.Manifest +import androidx.compose.animation.* +import androidx.compose.animation.core.* +import androidx.compose.animation.core.EaseInOut +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import kotlinx.coroutines.delay +import kotlin.math.abs +import kotlin.math.sin +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.ui.text.style.TextOverflow +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import com.runanywhere.runanywhereai.domain.models.SessionState +import com.runanywhere.runanywhereai.presentation.models.ModelSelectionBottomSheet +import com.runanywhere.runanywhereai.ui.theme.AppColors +import com.runanywhere.runanywhereai.ui.theme.AppTypography +import com.runanywhere.runanywhereai.ui.theme.Dimensions +import com.runanywhere.sdk.public.extensions.Models.ModelSelectionContext +import kotlin.math.min + +/** + * Voice Assistant screen + * + * This screen shows: + * - VoicePipelineSetupView when not all models are loaded + * - Main voice UI with conversation bubbles when ready + * + * Complete voice pipeline UI with VAD, STT, LLM, and TTS + */ +@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class) +@Composable +fun VoiceAssistantScreen(viewModel: VoiceAssistantViewModel = viewModel()) { + val uiState by viewModel.uiState.collectAsState() + val context = LocalContext.current + var showModelInfo by remember { mutableStateOf(false) } + + // Model selection dialog states + var showSTTModelSelection by remember { mutableStateOf(false) } + var showLLMModelSelection by remember { mutableStateOf(false) } + var showTTSModelSelection by remember { mutableStateOf(false) } + var showVoiceSetupSheet by remember { mutableStateOf(false) } + + // Permission handling + val microphonePermissionState = + rememberPermissionState( + Manifest.permission.RECORD_AUDIO, + ) + + // Initialize audio capture service and refresh model states when the screen appears + // This ensures that: + // 1. Audio capture is ready when user starts the session + // 2. Models loaded from other screens (e.g., Chat) are reflected here + LaunchedEffect(Unit) { + viewModel.initialize(context) + viewModel.refreshComponentStatesFromSDK() + } + + // Re-initialize when permission is granted + LaunchedEffect(microphonePermissionState.status.isGranted) { + if (microphonePermissionState.status.isGranted) { + viewModel.initialize(context) + } + } + + // When !allModelsLoaded show VoicePipelineSetupView as main content; when loaded show mainVoiceUI with Scaffold + Scaffold( + topBar = { + if (uiState.allModelsLoaded) { + // Header - cube 18pt, info 18pt, padding horizontal 20, top 20, bottom 10, no title + TopAppBar( + title = { Text("Voice", style = MaterialTheme.typography.headlineMedium) }, + actions = { + IconButton( + onClick = { showVoiceSetupSheet = true }, + modifier = Modifier.size(38.dp), + ) { + Icon( + imageVector = Icons.Default.ViewInAr, + contentDescription = "Models", + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton( + onClick = { showModelInfo = !showModelInfo }, + modifier = Modifier.size(38.dp), + ) { + Icon( + imageVector = if (showModelInfo) Icons.Filled.Info else Icons.Outlined.Info, + contentDescription = if (showModelInfo) "Hide Info" else "Show Info", + modifier = Modifier.size(18.dp), + tint = if (showModelInfo) AppColors.primaryAccent else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surface), + ) + } + }, + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .background(MaterialTheme.colorScheme.background), + ) { + if (!uiState.allModelsLoaded) { + VoicePipelineSetupView( + sttModel = uiState.sttModel, + llmModel = uiState.llmModel, + ttsModel = uiState.ttsModel, + sttLoadState = uiState.sttLoadState, + llmLoadState = uiState.llmLoadState, + ttsLoadState = uiState.ttsLoadState, + onSelectSTT = { showSTTModelSelection = true }, + onSelectLLM = { showLLMModelSelection = true }, + onSelectTTS = { showTTSModelSelection = true }, + onStartVoice = {}, + ) + } else { + MainVoiceAssistantUI( + uiState = uiState, + showModelInfo = showModelInfo, + onToggleModelInfo = { showModelInfo = !showModelInfo }, + hasPermission = microphonePermissionState.status.isGranted, + onRequestPermission = { microphonePermissionState.launchPermissionRequest() }, + onStartSession = { viewModel.startSession() }, + onStopSession = { viewModel.stopSession() }, + onClearConversation = { viewModel.clearConversation() }, + ) + } + } + } + + if (showVoiceSetupSheet) { + ModalBottomSheet(onDismissRequest = { showVoiceSetupSheet = false }) { + VoicePipelineSetupView( + sttModel = uiState.sttModel, + llmModel = uiState.llmModel, + ttsModel = uiState.ttsModel, + sttLoadState = uiState.sttLoadState, + llmLoadState = uiState.llmLoadState, + ttsLoadState = uiState.ttsLoadState, + onSelectSTT = { showVoiceSetupSheet = false; showSTTModelSelection = true }, + onSelectLLM = { showVoiceSetupSheet = false; showLLMModelSelection = true }, + onSelectTTS = { showVoiceSetupSheet = false; showTTSModelSelection = true }, + onStartVoice = { showVoiceSetupSheet = false }, + ) + } + } + + // Model selection bottom sheets - uses real SDK models + // ModelSelectionSheet(context: .stt/.llm/.tts) + if (showSTTModelSelection) { + ModelSelectionBottomSheet( + context = ModelSelectionContext.STT, + onDismiss = { showSTTModelSelection = false }, + onModelSelected = { model -> + val framework = model.framework.displayName + viewModel.setSTTModel(framework, model.name, model.id) + showSTTModelSelection = false + }, + ) + } + + if (showLLMModelSelection) { + ModelSelectionBottomSheet( + context = ModelSelectionContext.LLM, + onDismiss = { showLLMModelSelection = false }, + onModelSelected = { model -> + val framework = model.framework.displayName + viewModel.setLLMModel(framework, model.name, model.id) + showLLMModelSelection = false + }, + ) + } + + if (showTTSModelSelection) { + ModelSelectionBottomSheet( + context = ModelSelectionContext.TTS, + onDismiss = { showTTSModelSelection = false }, + onModelSelected = { model -> + val framework = model.framework.displayName + viewModel.setTTSModel(framework, model.name, model.id) + showTTSModelSelection = false + }, + ) + } +} + +/** + * Voice Pipeline Setup View + * + * VoicePipelineSetupView + * + * A setup view specifically for Voice Assistant which requires 3 models: + * - STT (Speech Recognition) + * - LLM (Language Model) + * - TTS (Text to Speech) + */ +@Composable +private fun VoicePipelineSetupView( + sttModel: SelectedModel?, + llmModel: SelectedModel?, + ttsModel: SelectedModel?, + sttLoadState: ModelLoadState, + llmLoadState: ModelLoadState, + ttsLoadState: ModelLoadState, + onSelectSTT: () -> Unit, + onSelectLLM: () -> Unit, + onSelectTTS: () -> Unit, + onStartVoice: () -> Unit, +) { + val allModelsReady = sttModel != null && llmModel != null && ttsModel != null + val allModelsLoaded = sttLoadState.isLoaded && llmLoadState.isLoaded && ttsLoadState.isLoaded + + // VStack(spacing: 24), .padding(.top, 20), icon 48pt, .title2 .bold, .subheadline .secondary + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = Dimensions.padding16), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + Spacer(modifier = Modifier.height(20.dp)) + + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) { + Icon( + imageVector = Icons.Default.Mic, + contentDescription = "Voice Assistant", + modifier = Modifier.size(48.dp), + tint = AppColors.primaryAccent, + ) + Text( + text = "Voice Assistant Setup", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Text( + text = "Voice requires 3 models to work together", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // VStack(spacing: 16), .padding(.horizontal) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + ModelSetupCard( + step = 1, + title = "Speech Recognition", + subtitle = "Converts your voice to text", + icon = Icons.Default.GraphicEq, + color = AppColors.primaryGreen, + selectedFramework = sttModel?.framework, + selectedModel = sttModel?.name, + loadState = sttLoadState, + onSelect = onSelectSTT, + ) + ModelSetupCard( + step = 2, + title = "Language Model", + subtitle = "Processes and responds to your input", + icon = Icons.Default.Psychology, + color = AppColors.primaryAccent, + selectedFramework = llmModel?.framework, + selectedModel = llmModel?.name, + loadState = llmLoadState, + onSelect = onSelectLLM, + ) + ModelSetupCard( + step = 3, + title = "Text to Speech", + subtitle = "Converts responses to audio", + icon = Icons.Default.VolumeUp, + color = AppColors.primaryPurple, + selectedFramework = ttsModel?.framework, + selectedModel = ttsModel?.name, + loadState = ttsLoadState, + onSelect = onSelectTTS, + ) + } + + // Button .headline, .padding(.vertical, 16), .padding(.bottom, 20) + Button( + onClick = onStartVoice, + enabled = allModelsLoaded, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + colors = ButtonDefaults.buttonColors(containerColor = AppColors.primaryAccent), + ) { + Icon(Icons.Default.Mic, contentDescription = null, modifier = Modifier.size(20.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Start Voice Assistant", + style = MaterialTheme.typography.headlineMedium, + ) + } + + // .font(.caption), .padding(.bottom, 10) + Text( + text = when { + !allModelsReady -> "Select all 3 models to continue" + !allModelsLoaded -> "Waiting for models to load..." + else -> "All models loaded and ready!" + }, + style = MaterialTheme.typography.labelMedium, + color = when { + !allModelsReady -> MaterialTheme.colorScheme.onSurfaceVariant + !allModelsLoaded -> AppColors.statusOrange + else -> AppColors.primaryGreen + }, + ) + Spacer(modifier = Modifier.height(10.dp)) + } +} + +/** + * Model Setup Card + * + * ModelSetupCard + * + * A card showing model selection and loading state + */ +@Composable +private fun ModelSetupCard( + step: Int, + title: String, + subtitle: String, + icon: ImageVector, + color: Color, + selectedFramework: String?, + selectedModel: String?, + loadState: ModelLoadState, + onSelect: () -> Unit, +) { + val isConfigured = selectedFramework != null && selectedModel != null + val isLoaded = loadState.isLoaded + val isLoading = loadState.isLoading + + Card( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onSelect) + .then( + if (isLoaded) { + Modifier.border(2.dp, AppColors.primaryGreen.copy(alpha = 0.5f), RoundedCornerShape(12.dp)) + } else if (isLoading) { + Modifier.border(2.dp, AppColors.statusOrange.copy(alpha = 0.5f), RoundedCornerShape(12.dp)) + } else if (isConfigured) { + Modifier.border(2.dp, color.copy(alpha = 0.5f), RoundedCornerShape(12.dp)) + } else { + Modifier + }, + ), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + shape = RoundedCornerShape(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Step indicator with loading/loaded state + Box( + modifier = + Modifier + .size(36.dp) + .clip(CircleShape) + .background( + when { + isLoading -> AppColors.statusOrange + isLoaded -> AppColors.primaryGreen + isConfigured -> color + else -> AppColors.statusGray.copy(alpha = 0.2f) + }, + ), + contentAlignment = Alignment.Center, + ) { + when { + isLoading -> { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + color = Color.White, + strokeWidth = 2.dp, + ) + } + isLoaded -> { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = "Loaded", + modifier = Modifier.size(18.dp), + tint = Color.White, + ) + } + isConfigured -> { + Icon( + imageVector = Icons.Default.Check, + contentDescription = "Configured", + modifier = Modifier.size(18.dp), + tint = Color.White, + ) + } + else -> { + Text( + text = "$step", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = AppColors.statusGray, + ) + } + } + } + + Spacer(modifier = Modifier.width(16.dp)) + + // Content + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = icon, + contentDescription = title, + modifier = Modifier.size(16.dp), + tint = color, + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + fontSize = 15.sp, + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + if (isConfigured) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = "$selectedFramework • $selectedModel", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + if (isLoading) { + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "Loading...", + style = AppTypography.caption2, + color = AppColors.statusOrange, + ) + } + } + } else { + Text( + text = subtitle, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + // Action / Status + when { + isLoading -> { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + ) + } + isLoaded -> { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = "Loaded", + modifier = Modifier.size(16.dp), + tint = AppColors.primaryGreen, + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "Loaded", + style = MaterialTheme.typography.labelMedium, + color = AppColors.primaryGreen, + ) + } + } + isConfigured -> { + Text( + text = "Change", + style = MaterialTheme.typography.labelMedium, + color = AppColors.primaryAccent, + ) + } + else -> { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "Select", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + color = AppColors.primaryAccent, + ) + Spacer(modifier = Modifier.width(2.dp)) + Icon( + imageVector = Icons.Default.ChevronRight, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = AppColors.primaryAccent, + ) + } + } + } + } + } +} + +/** + * Main Voice Assistant UI + * + * Main voice UI (shown when allModelsLoaded) + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun MainVoiceAssistantUI( + uiState: VoiceUiState, + showModelInfo: Boolean, + onToggleModelInfo: () -> Unit, + hasPermission: Boolean, + onRequestPermission: () -> Unit, + onStartSession: () -> Unit, + onStopSession: () -> Unit, + @Suppress("UNUSED_PARAMETER") onClearConversation: () -> Unit, +) { + val density = LocalDensity.current + + // Particle animation state + var amplitude by remember { mutableStateOf(0f) } + var morphProgress by remember { mutableStateOf(0f) } + var scatterAmount by remember { mutableStateOf(0f) } + var touchPoint by remember { mutableStateOf(Offset.Zero) } + val isDarkMode = isSystemInDarkTheme() + + // Determine if animation should be active to save battery/CPU + // Only run when: listening, speaking, scatter recovering, or morph transitioning + val isListening = uiState.sessionState == SessionState.LISTENING + val isSpeaking = uiState.sessionState == SessionState.SPEAKING + val isAnimationNeeded = isListening || isSpeaking || scatterAmount > 0.001f || + (morphProgress > 0.001f && morphProgress < 0.999f) + + // Animation timer (60 FPS = ~16ms) - only runs when animation is needed + LaunchedEffect(isAnimationNeeded) { + if (isAnimationNeeded) { + while (true) { + delay(16) // ~60 FPS + updateAnimation( + uiState = uiState, + amplitudeState = { amplitude }, + morphProgressState = { morphProgress }, + scatterAmountState = { scatterAmount }, + onAmplitudeChange = { amplitude = it }, + onMorphProgressChange = { morphProgress = it }, + onScatterAmountChange = { scatterAmount = it }, + ) + // Re-check if animation is still needed + val stillNeeded = uiState.sessionState == SessionState.LISTENING || + uiState.sessionState == SessionState.SPEAKING || + scatterAmount > 0.001f || + (morphProgress > 0.001f && morphProgress < 0.999f) + if (!stillNeeded) break + } + } + } + + Box(modifier = Modifier.fillMaxSize()) { + // Background particle animation - centered + // Particle animation setup + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val size = min(constraints.maxWidth, constraints.maxHeight) * 0.9f + val centerX = size / 2f + val centerY = size / 2f + + VoiceAssistantParticleView( + amplitude = amplitude, + morphProgress = morphProgress, + scatterAmount = scatterAmount, + touchPoint = touchPoint, + isDarkMode = isDarkMode, + modifier = Modifier + .size(with(density) { size.toDp() }) + .align(Alignment.Center) + .offset(y = with(density) { (-50).dp }) + .pointerInput(Unit) { + detectTapGestures { offset -> + // Convert touch to normalized coordinates (-1 to 1) + // Coordinate system + val normalizedX = ((offset.x - centerX) / (size / 2f)) * 0.85f + val normalizedY = ((offset.y - centerY) / (size / 2f)) * -0.85f // Flip Y + + touchPoint = Offset(normalizedX, normalizedY) + scatterAmount = 1.0f + } + }, + ) + } + + // Main UI overlay + Column( + modifier = Modifier.fillMaxSize(), + ) { + // Model info section - VStack spacing 8, HStack spacing 15, padding horizontal 20, padding bottom 15 + AnimatedVisibility( + visible = showModelInfo, + enter = slideInVertically() + fadeIn(), + exit = slideOutVertically() + fadeOut(), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .padding(bottom = 15.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(15.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ModelBadge( + icon = Icons.Default.Psychology, + label = "LLM", + value = uiState.llmModel?.name ?: "Not set", + color = AppColors.primaryAccent, + ) + ModelBadge( + icon = Icons.Default.GraphicEq, + label = "STT", + value = uiState.sttModel?.name ?: "Not set", + color = AppColors.primaryGreen, + ) + ModelBadge( + icon = Icons.Default.VolumeUp, + label = "TTS", + value = uiState.ttsModel?.name ?: "Not set", + color = AppColors.primaryPurple, + ) + } + } + } + + // Conversation area is now hidden - messages shown as toast at bottom + Spacer(modifier = Modifier.weight(1f)) + + // Control area - VStack spacing 20, error .caption, response maxHeight 150 padding H 30, mic, instruction .caption2, padding bottom 30 + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 30.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // Error message + uiState.errorMessage?.let { error -> + Text( + text = error, + style = MaterialTheme.typography.labelMedium, // .caption + color = AppColors.statusRed, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 20.dp), + ) + } + + // Main mic button section + // Mic button section + micButtonSection( + uiState = uiState, + hasPermission = hasPermission, + onRequestPermission = onRequestPermission, + onStartSession = onStartSession, + onStopSession = onStopSession, + ) + + // Instruction text + // .caption2, .secondary.opacity(0.7) + Text( + text = getInstructionText(uiState.sessionState), + style = AppTypography.caption2, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + textAlign = TextAlign.Center, + ) + } + } + } +} + +/** + * Update animation state + */ +private fun updateAnimation( + uiState: VoiceUiState, + amplitudeState: () -> Float, + morphProgressState: () -> Float, + scatterAmountState: () -> Float, + onAmplitudeChange: (Float) -> Unit, + onMorphProgressChange: (Float) -> Unit, + onScatterAmountChange: (Float) -> Unit, +) { + // Target morph: 0 = sphere (idle/thinking), 1 = ring (listening/speaking) + val isListening = uiState.sessionState == SessionState.LISTENING + val isSpeaking = uiState.sessionState == SessionState.SPEAKING + val isActive = isListening || isSpeaking + val targetMorph = if (isActive) 1.0f else 0.0f + + // Smooth morph transition + val currentMorph = morphProgressState() + val morphDiff = targetMorph - currentMorph + onMorphProgressChange((currentMorph + morphDiff * 0.04f).coerceIn(0f, 1f)) + + // Scatter decay + val currentScatter = scatterAmountState() + if (currentScatter > 0.001f) { + onScatterAmountChange(currentScatter * 0.92f) + } else { + onScatterAmountChange(0f) + } + + // Audio amplitude - reactive to both input (listening) and output (speaking) + val currentAmplitude = amplitudeState() + val newAmplitude = when { + isListening -> { + // Use real audio level from microphone + val realAudioLevel = uiState.audioLevel + // Smooth interpolation for natural movement + (currentAmplitude * 0.7f + realAudioLevel * 0.3f).coerceIn(0f, 1f) + } + isSpeaking -> { + // TTS output - realistic speech-like pulse simulation + val time = System.currentTimeMillis() / 1000f + + // Multiple frequency components for natural speech rhythm + val basePulse = 0.35f + val primaryWave = sin(time * 3.5f) * 0.2f // Main speech rhythm + val secondaryWave = sin(time * 7.0f) * 0.1f // Phoneme-like variation + val randomNoise = kotlin.random.Random.nextFloat() * 0.2f - 0.05f // Natural variation + + val targetAmplitude = basePulse + abs(primaryWave) + abs(secondaryWave) * 0.5f + randomNoise + + // Smooth interpolation to avoid jarring changes + (currentAmplitude * 0.75f + targetAmplitude * 0.25f).coerceIn(0f, 1f) + } + else -> { + // Gentle decay when not active + currentAmplitude * 0.95f + } + } + onAmplitudeChange(newAmplitude) +} + +/** + * Mic button section + */ +@Composable +private fun micButtonSection( + uiState: VoiceUiState, + hasPermission: Boolean, + onRequestPermission: () -> Unit, + onStartSession: () -> Unit, + onStopSession: () -> Unit, +) { + val isLoading = uiState.sessionState == SessionState.CONNECTING || + (uiState.sessionState == SessionState.PROCESSING && !uiState.isListening) + + Row(modifier = Modifier.fillMaxWidth()) { + Spacer(modifier = Modifier.weight(1f)) + + MicrophoneButton( + isListening = uiState.isListening, + sessionState = uiState.sessionState, + isSpeechDetected = uiState.isSpeechDetected, + hasPermission = hasPermission, + isLoading = isLoading, + onToggle = { + if (!hasPermission) { + onRequestPermission() + } else { + val state = uiState.sessionState + if (state == SessionState.LISTENING || + state == SessionState.SPEAKING || + state == SessionState.PROCESSING || + state == SessionState.CONNECTING + ) { + onStopSession() + } else { + onStartSession() + } + } + }, + ) + + Spacer(modifier = Modifier.weight(1f)) + } +} + +@Composable +private fun StatusIndicator(sessionState: SessionState) { + val color = + when (sessionState) { + SessionState.CONNECTED -> AppColors.statusGreen + SessionState.LISTENING -> AppColors.statusRed + SessionState.PROCESSING -> AppColors.primaryAccent + SessionState.SPEAKING -> AppColors.statusGreen + SessionState.ERROR -> AppColors.statusRed + SessionState.DISCONNECTED -> AppColors.statusGray + SessionState.CONNECTING -> AppColors.statusOrange + } + + val animatedScale by animateFloatAsState( + targetValue = if (sessionState == SessionState.LISTENING) 1.2f else 1f, + animationSpec = + infiniteRepeatable( + animation = tween(1000), + repeatMode = RepeatMode.Reverse, + ), + label = "statusScale", + ) + + Box( + modifier = + Modifier + .size(8.dp) + .scale(if (sessionState == SessionState.LISTENING) animatedScale else 1f) + .clip(CircleShape) + .background(color), + ) +} + +@Composable +private fun ModelBadge( + icon: ImageVector, + label: String, + value: String, + color: Color, +) { + // Badge font size 9, label badgeFontSize-1 (8), value badgeFontSize (9) medium, padding H 8 V 4, cornerRadius 6, spacing 4 + Row( + modifier = Modifier + .background(color.copy(alpha = 0.1f), RoundedCornerShape(6.dp)) + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + imageVector = icon, + contentDescription = label, + modifier = Modifier.size(12.dp), + tint = color, + ) + Column { + Text( + text = label, + style = AppTypography.system9, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = value, + style = AppTypography.system9.copy(fontWeight = FontWeight.Medium), + maxLines = 1, + ) + } + } +} + +@Composable +private fun ConversationBubble( + speaker: String, + message: String, + isUser: Boolean, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.Start, + ) { + Text( + text = speaker, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(6.dp)) + + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + modifier = + Modifier + .background( + if (isUser) { + MaterialTheme.colorScheme.surfaceVariant + } else { + AppColors.primaryAccent.copy(alpha = 0.08f) + }, + RoundedCornerShape(16.dp), + ) + .padding(12.dp) + .fillMaxWidth(), + ) + } +} + +/** + * Audio Level Indicator with RECORDING badge and animated bars + * + * Recording indicator + * Shows 10 animated audio level bars during recording + */ +@Composable +private fun AudioLevelIndicator( + audioLevel: Float, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // Recording status badge + // HStack with red circle + "RECORDING" text + Row( + modifier = + Modifier + .background( + AppColors.statusRed.copy(alpha = 0.1f), + RoundedCornerShape(4.dp), + ) + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + // Pulsing red dot + val infiniteTransition = rememberInfiniteTransition(label = "recording_pulse") + val pulseAlpha by infiniteTransition.animateFloat( + initialValue = 1f, + targetValue = 0.5f, + animationSpec = + infiniteRepeatable( + animation = tween(500), + repeatMode = RepeatMode.Reverse, + ), + label = "recordingDotPulse", + ) + Box( + modifier = + Modifier + .size(8.dp) + .clip(CircleShape) + .background(AppColors.statusRed.copy(alpha = pulseAlpha)), + ) + Text( + text = "RECORDING", + style = AppTypography.caption2Bold, + color = AppColors.statusRed, + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Audio level bars (10 bars) + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + repeat(10) { index -> + val isActive = index < (audioLevel * 10).toInt() + Box( + modifier = + Modifier + .width(25.dp) + .height(8.dp) + .clip(RoundedCornerShape(2.dp)) + .background( + if (isActive) AppColors.primaryGreen + else AppColors.statusGray.copy(alpha = 0.3f), + ) + .animateContentSize( + animationSpec = tween(200, easing = EaseInOut), + ), + ) + } + } + } +} + +@Composable +private fun MicrophoneButton( + isListening: Boolean, + sessionState: SessionState, + isSpeechDetected: Boolean, + hasPermission: Boolean, + isLoading: Boolean = false, + onToggle: () -> Unit, +) { + val backgroundColor = + when { + !hasPermission -> AppColors.statusRed + sessionState == SessionState.CONNECTING -> AppColors.statusOrange + sessionState == SessionState.LISTENING -> AppColors.statusRed + sessionState == SessionState.PROCESSING -> AppColors.primaryAccent + sessionState == SessionState.SPEAKING -> AppColors.statusGreen + else -> AppColors.primaryAccent + } + + val animatedScale by animateFloatAsState( + targetValue = if (isSpeechDetected) 1.1f else 1f, + animationSpec = + spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow, + ), + label = "micScale", + ) + + Box(contentAlignment = Alignment.Center) { + // Pulsing effect when speech detected + if (isSpeechDetected) { + val infiniteTransition = rememberInfiniteTransition(label = "pulse_transition") + val pulseScale by infiniteTransition.animateFloat( + initialValue = 1f, + targetValue = 1.3f, + animationSpec = + infiniteRepeatable( + animation = tween(1000), + repeatMode = RepeatMode.Reverse, + ), + label = "pulse", + ) + Box( + modifier = + Modifier + .size(72.dp) + .scale(pulseScale) + .clip(CircleShape) + .border(2.dp, Color.White.copy(alpha = 0.4f), CircleShape), + ) + } + + FloatingActionButton( + onClick = onToggle, + modifier = + Modifier + .size(72.dp) + .scale(animatedScale), + containerColor = backgroundColor, + ) { + when { + isLoading -> { + CircularProgressIndicator( + modifier = Modifier.size(28.dp), + color = Color.White, + strokeWidth = 2.dp, + ) + } + else -> { + Icon( + imageVector = + when { + !hasPermission -> Icons.Default.MicOff + sessionState == SessionState.LISTENING -> Icons.Default.Mic + sessionState == SessionState.SPEAKING -> Icons.Default.VolumeUp + else -> Icons.Default.Mic + }, + contentDescription = "Microphone", + modifier = Modifier.size(28.dp), + tint = Color.White, + ) + } + } + } + } +} + +private fun getStatusText(sessionState: SessionState): String { + return when (sessionState) { + SessionState.DISCONNECTED -> "Ready" + SessionState.CONNECTING -> "Connecting" + SessionState.CONNECTED -> "Ready" + SessionState.LISTENING -> "Listening" + SessionState.PROCESSING -> "Thinking" + SessionState.SPEAKING -> "Speaking" + SessionState.ERROR -> "Error" + } +} + +/** + * Get instruction text + */ +private fun getInstructionText(sessionState: SessionState): String { + return when (sessionState) { + SessionState.LISTENING -> "Listening... Pause to send" + SessionState.PROCESSING -> "Processing your message..." + SessionState.SPEAKING -> "Speaking..." + SessionState.CONNECTING -> "Connecting..." + else -> "Tap to start conversation" + } +} diff --git a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/voice/VoiceAssistantViewModel.kt b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/voice/VoiceAssistantViewModel.kt new file mode 100644 index 000000000..94d1ba4d3 --- /dev/null +++ b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/voice/VoiceAssistantViewModel.kt @@ -0,0 +1,1175 @@ +package com.runanywhere.runanywhereai.presentation.voice + +import android.app.Application +import android.content.Context +import android.media.AudioAttributes +import android.media.AudioFormat +import android.media.AudioTrack +import android.util.Log +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.runanywhere.runanywhereai.domain.models.SessionState +import com.runanywhere.runanywhereai.domain.services.AudioCaptureService +import com.runanywhere.sdk.public.RunAnywhere +import com.runanywhere.sdk.public.events.EventBus +import com.runanywhere.sdk.public.events.EventCategory +import com.runanywhere.sdk.public.events.LLMEvent +import com.runanywhere.sdk.public.events.ModelEvent +import com.runanywhere.sdk.public.events.SDKEvent +import com.runanywhere.sdk.public.events.STTEvent +import com.runanywhere.sdk.public.events.TTSEvent +import com.runanywhere.sdk.public.extensions.VoiceAgent.ComponentLoadState +import com.runanywhere.sdk.public.extensions.VoiceAgent.VoiceSessionConfig +import com.runanywhere.sdk.public.extensions.VoiceAgent.VoiceSessionEvent +import com.runanywhere.sdk.public.extensions.processVoice +import com.runanywhere.sdk.public.extensions.startVoiceSession +import com.runanywhere.sdk.public.extensions.stopVoiceSession +import com.runanywhere.sdk.public.extensions.voiceAgentComponentStates +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.ByteArrayOutputStream + +private const val TAG = "VoiceAssistantVM" + +/** + * Model Load State matching iOS ModelLoadState + */ +enum class ModelLoadState { + NOT_LOADED, + LOADING, + LOADED, + ERROR, + ; + + val isLoaded: Boolean get() = this == LOADED + val isLoading: Boolean get() = this == LOADING + + companion object { + fun fromSDK(state: ComponentLoadState): ModelLoadState = + when (state) { + is ComponentLoadState.NotLoaded -> NOT_LOADED + is ComponentLoadState.Loading -> LOADING + is ComponentLoadState.Loaded -> LOADED + is ComponentLoadState.Error -> ERROR + } + } +} + +/** + * Selected Model Info matching iOS pattern + */ +data class SelectedModel( + val framework: String, + val name: String, + val modelId: String, +) + +/** + * Voice Assistant UI State matching iOS VoiceAgentViewModel + */ +data class VoiceUiState( + val sessionState: SessionState = SessionState.DISCONNECTED, + val isListening: Boolean = false, + val isSpeechDetected: Boolean = false, + val currentTranscript: String = "", + val assistantResponse: String = "", + val errorMessage: String? = null, + val audioLevel: Float = 0f, + val currentLLMModel: String = "No model loaded", + val whisperModel: String = "Whisper Base", + val ttsVoice: String = "System", + // Model Selection State matching iOS + val sttModel: SelectedModel? = null, + val llmModel: SelectedModel? = null, + val ttsModel: SelectedModel? = null, + // Model Loading States matching iOS + val sttLoadState: ModelLoadState = ModelLoadState.NOT_LOADED, + val llmLoadState: ModelLoadState = ModelLoadState.NOT_LOADED, + val ttsLoadState: ModelLoadState = ModelLoadState.NOT_LOADED, +) { + /** + * Check if all models are actually loaded in memory + * iOS Reference: allModelsLoaded computed property + */ + val allModelsLoaded: Boolean + get() = sttLoadState.isLoaded && llmLoadState.isLoaded && ttsLoadState.isLoaded +} + +/** + * ViewModel for Voice Assistant screen + * + * iOS Reference: VoiceAgentViewModel + * + * This ViewModel manages: + * - Model selection for 3-model voice pipeline (STT, LLM, TTS) + * - Model loading states from SDK events + * - Voice conversation flow with audio capture + * - Pipeline event handling + * + * Uses RunAnywhere SDK VoiceAgent capability for STT → LLM → TTS flow + */ +class VoiceAssistantViewModel( + application: Application, +) : AndroidViewModel(application) { + // Audio capture service for microphone input + private var audioCaptureService: AudioCaptureService? = null + + // Audio buffer for accumulating audio data + private val audioBuffer = ByteArrayOutputStream() + + // Voice session flow + private var voiceSessionFlow: Flow? = null + + // Jobs for coroutine management + private var pipelineJob: Job? = null + private var eventSubscriptionJob: Job? = null + private var audioRecordingJob: Job? = null + private var silenceDetectionJob: Job? = null + + // Speech state tracking (matching iOS VoiceSessionHandle) + @Volatile + private var isSpeechActive = false + private var lastSpeechTime: Long = 0L + + @Volatile + private var isProcessingTurn = false + + // Audio playback (matching iOS AudioPlaybackManager) + private var audioTrack: AudioTrack? = null + private var audioPlaybackJob: Job? = null + + @Volatile + private var isPlayingAudio = false + + // Voice session configuration (matching iOS VoiceSessionConfig) + private val speechThreshold = 0.1f // Minimum audio level to detect speech (0.0 - 1.0) + private val silenceDurationMs = 1500L // 1.5 seconds of silence before processing + private val minAudioBytes = 16000 // ~0.5s at 16kHz, 16-bit + private val ttsSampleRate = 22050 // TTS output sample rate (Piper default) + + private val _uiState = MutableStateFlow(VoiceUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + // Convenience accessors for backward compatibility + val sessionState: StateFlow = + _uiState.map { it.sessionState }.stateIn( + viewModelScope, + SharingStarted.Eagerly, + SessionState.DISCONNECTED, + ) + val isListening: StateFlow = + _uiState.map { it.isListening }.stateIn( + viewModelScope, + SharingStarted.Eagerly, + false, + ) + val error: StateFlow = + _uiState.map { it.errorMessage }.stateIn( + viewModelScope, + SharingStarted.Eagerly, + null, + ) + val currentTranscript: StateFlow = + _uiState.map { it.currentTranscript }.stateIn( + viewModelScope, + SharingStarted.Eagerly, + "", + ) + val assistantResponse: StateFlow = + _uiState.map { it.assistantResponse }.stateIn( + viewModelScope, + SharingStarted.Eagerly, + "", + ) + val audioLevel: StateFlow = + _uiState.map { it.audioLevel }.stateIn( + viewModelScope, + SharingStarted.Eagerly, + 0f, + ) + + /** + * Initialize audio capture service + * Must be called before starting a voice session + */ + fun initialize(context: Context) { + if (audioCaptureService == null) { + audioCaptureService = AudioCaptureService(context) + Log.i(TAG, "AudioCaptureService initialized") + } + } + + /** + * Normalize audio level for visualization (0.0 to 1.0) + * Matches STT implementation + */ + private fun normalizeAudioLevel(rms: Float): Float { + // RMS values typically range from 0 to ~0.3 for normal speech + // Scale up for better visualization + return (rms * 3.0f).coerceIn(0f, 1f) + } + + /** + * Check speech state based on audio level + * iOS Reference: checkSpeechState(level: Float) in VoiceSessionHandle + * + * Detects when speech starts and updates lastSpeechTime + */ + private fun checkSpeechState(level: Float) { + if (level > speechThreshold) { + // Speech detected + if (!isSpeechActive) { + Log.d(TAG, "🎙️ Speech started (level: $level)") + isSpeechActive = true + _uiState.update { it.copy(isSpeechDetected = true) } + } + // Update last speech time (keep tracking while speaking) + lastSpeechTime = System.currentTimeMillis() + } + // Note: We don't reset isSpeechActive here - that's done in checkSilenceAndTriggerProcessing + } + + /** + * Check if silence duration has been exceeded and trigger processing + * iOS Reference: Part of checkSpeechState in VoiceSessionHandle + * + * When silence exceeds silenceDuration after speech was active, process the audio + */ + private fun checkSilenceAndTriggerProcessing() { + if (!isSpeechActive || isProcessingTurn) return + + val currentLevel = _uiState.value.audioLevel + if (currentLevel <= speechThreshold && lastSpeechTime > 0) { + val silenceTime = System.currentTimeMillis() - lastSpeechTime + if (silenceTime > silenceDurationMs) { + Log.d(TAG, "🔇 Speech ended after ${silenceTime}ms of silence") + isSpeechActive = false + _uiState.update { it.copy(isSpeechDetected = false) } + + // Check if we have enough audio to process + val audioSize = audioBuffer.size() + if (audioSize >= minAudioBytes) { + Log.i(TAG, "🚀 Auto-triggering voice pipeline (audio: $audioSize bytes)") + processCurrentAudio() + } else { + Log.d(TAG, "Audio too short to process ($audioSize bytes), resetting buffer") + audioBuffer.reset() + } + } + } + } + + /** + * Process the current audio buffer through the STT → LLM → TTS pipeline + * iOS Reference: processCurrentAudio() in VoiceSessionHandle + * + * IMPORTANT: The heavy processing (STT, LLM, TTS) runs on Dispatchers.Default + * to avoid blocking the main thread and causing ANR. + */ + private fun processCurrentAudio() { + if (isProcessingTurn) { + Log.d(TAG, "Already processing a turn, skipping") + return + } + + isProcessingTurn = true + + // Get the buffered audio and reset + val audioData = audioBuffer.toByteArray() + audioBuffer.reset() + + viewModelScope.launch { + try { + // Update state to processing (on main thread for UI) + _uiState.update { + it.copy( + sessionState = SessionState.PROCESSING, + isListening = false, + isSpeechDetected = false, + audioLevel = 0f, + ) + } + + // Stop audio capture during processing (matching iOS) + audioRecordingJob?.cancel() + silenceDetectionJob?.cancel() + audioCaptureService?.stopCapture() + + Log.i(TAG, "🔄 Processing ${audioData.size} bytes through voice pipeline...") + + // Process audio through STT → LLM → TTS pipeline + // Run on Default dispatcher to avoid blocking main thread (fixes ANR) + val result = + withContext(Dispatchers.Default) { + RunAnywhere.processVoice(audioData) + } + + val transcription = result.transcription + val response = result.response + + Log.i( + TAG, + "✅ Voice pipeline result - speechDetected: ${result.speechDetected}, " + + "transcription: ${transcription?.take(50)}, " + + "response: ${response?.take(50)}", + ) + + if (result.speechDetected && transcription != null) { + _uiState.update { + it.copy( + currentTranscript = transcription, + assistantResponse = response ?: "", + ) + } + + // Play synthesized audio if available (matching iOS autoPlayTTS) + val synthesizedAudio = result.synthesizedAudio + if (synthesizedAudio != null && synthesizedAudio.isNotEmpty()) { + Log.i(TAG, "🔊 Playing TTS response (${synthesizedAudio.size} bytes)") + playAudio(synthesizedAudio) + // Note: resumeListening() is called after playback completes + } else { + Log.d(TAG, "No synthesized audio, resuming listening immediately") + resumeListening() + } + } else { + Log.i(TAG, "No speech detected in audio") + _uiState.update { + it.copy( + errorMessage = if (!result.speechDetected) "No speech detected" else null, + ) + } + resumeListening() + } + } catch (e: Exception) { + Log.e(TAG, "Error processing voice: ${e.message}", e) + _uiState.update { + it.copy( + sessionState = SessionState.ERROR, + errorMessage = "Processing error: ${e.message}", + ) + } + isProcessingTurn = false + // Resume listening even on error + resumeListening() + } + } + } + + /** + * Resume listening after processing a turn + * iOS Reference: Continuous mode resume in processCurrentAudio + */ + private fun resumeListening() { + val audioCapture = audioCaptureService ?: return + + // Reset state for next turn + isProcessingTurn = false + isSpeechActive = false + lastSpeechTime = 0L + audioBuffer.reset() + + _uiState.update { + it.copy( + sessionState = SessionState.LISTENING, + isListening = true, + audioLevel = 0f, + ) + } + + Log.i(TAG, "🎙️ Resuming listening for next turn...") + + // Restart audio capture + audioRecordingJob = + viewModelScope.launch { + try { + audioCapture.startCapture().collect { audioData -> + if (isProcessingTurn) return@collect + + withContext(Dispatchers.IO) { + audioBuffer.write(audioData) + } + + val rms = audioCapture.calculateRMS(audioData) + val normalizedLevel = normalizeAudioLevel(rms) + _uiState.update { it.copy(audioLevel = normalizedLevel) } + + checkSpeechState(normalizedLevel) + } + } catch (e: kotlinx.coroutines.CancellationException) { + Log.d(TAG, "Audio recording cancelled") + } catch (e: Exception) { + Log.e(TAG, "Audio capture error on resume", e) + } + } + + // Restart silence detection + silenceDetectionJob = + viewModelScope.launch { + while (_uiState.value.isListening && !isProcessingTurn) { + checkSilenceAndTriggerProcessing() + delay(50) + } + } + } + + /** + * Play synthesized TTS audio + * iOS Reference: AudioPlaybackManager.play() in VoiceSessionHandle + * + * Plays WAV audio data through AudioTrack + */ + private fun playAudio(audioData: ByteArray) { + if (audioData.isEmpty()) { + Log.w(TAG, "No audio data to play") + resumeListening() + return + } + + Log.i(TAG, "🔊 Starting TTS playback (${audioData.size} bytes)") + isPlayingAudio = true + + _uiState.update { + it.copy(sessionState = SessionState.PROCESSING) // Show as "speaking" + } + + audioPlaybackJob = + viewModelScope.launch(Dispatchers.IO) { + try { + val channelConfig = AudioFormat.CHANNEL_OUT_MONO + val audioFormat = AudioFormat.ENCODING_PCM_16BIT + + // Skip WAV header (44 bytes) if present + val headerSize = + if (audioData.size > 44 && + audioData[0] == 'R'.code.toByte() && + audioData[1] == 'I'.code.toByte() && + audioData[2] == 'F'.code.toByte() && + audioData[3] == 'F'.code.toByte() + ) { + 44 + } else { + 0 + } + + val pcmData = audioData.copyOfRange(headerSize, audioData.size) + Log.d(TAG, "PCM data size: ${pcmData.size} bytes (skipped $headerSize byte header)") + + val bufferSize = AudioTrack.getMinBufferSize(ttsSampleRate, channelConfig, audioFormat) + + audioTrack = + AudioTrack.Builder() + .setAudioAttributes( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .build(), + ) + .setAudioFormat( + AudioFormat.Builder() + .setEncoding(audioFormat) + .setSampleRate(ttsSampleRate) + .setChannelMask(channelConfig) + .build(), + ) + .setBufferSizeInBytes(bufferSize.coerceAtLeast(pcmData.size)) + .setTransferMode(AudioTrack.MODE_STATIC) + .build() + + audioTrack?.write(pcmData, 0, pcmData.size) + audioTrack?.play() + + Log.i(TAG, "🔊 TTS playback started") + + // Calculate duration and wait for playback to complete + val durationMs = (pcmData.size.toDouble() / (ttsSampleRate * 2) * 1000).toLong() + Log.d(TAG, "Expected playback duration: ${durationMs}ms") + + // Wait for playback to complete + var elapsed = 0L + while (isPlayingAudio && elapsed < durationMs && audioTrack?.playState == AudioTrack.PLAYSTATE_PLAYING) { + delay(100) + elapsed += 100 + } + + Log.i(TAG, "🔊 TTS playback completed") + + withContext(Dispatchers.Main) { + stopAudioPlayback() + resumeListening() + } + } catch (e: Exception) { + Log.e(TAG, "Audio playback error: ${e.message}", e) + withContext(Dispatchers.Main) { + stopAudioPlayback() + resumeListening() + } + } + } + } + + /** + * Stop audio playback + * iOS Reference: AudioPlaybackManager.stop() + */ + private fun stopAudioPlayback() { + isPlayingAudio = false + audioPlaybackJob?.cancel() + audioPlaybackJob = null + + try { + audioTrack?.stop() + audioTrack?.release() + } catch (e: Exception) { + Log.w(TAG, "Error stopping AudioTrack: ${e.message}") + } + audioTrack = null + + Log.d(TAG, "Audio playback stopped") + } + + init { + // Subscribe to SDK events for model state tracking + // iOS equivalent: subscribeToSDKEvents() in VoiceAgentViewModel + subscribeToSDKEvents() + // Sync initial model states + viewModelScope.launch { + syncModelStates() + } + } + + /** + * Subscribe to SDK events for model state tracking + * iOS Reference: subscribeToSDKEvents() in VoiceAgentViewModel.swift + */ + private fun subscribeToSDKEvents() { + eventSubscriptionJob?.cancel() + eventSubscriptionJob = + viewModelScope.launch { + EventBus.events.collect { event -> + handleSDKEvent(event) + } + } + } + + /** + * Handle SDK events for model state updates + * iOS Reference: handleSDKEvent(_:) in VoiceAgentViewModel.swift + */ + private fun handleSDKEvent(event: SDKEvent) { + when (event) { + // Handle model events for LLM, STT, TTS + is ModelEvent -> { + when (event.eventType) { + ModelEvent.ModelEventType.LOADED -> { + when (event.category) { + EventCategory.LLM -> { + _uiState.update { + it.copy( + llmLoadState = ModelLoadState.LOADED, + llmModel = SelectedModel("llamacpp", event.modelId, event.modelId), + currentLLMModel = event.modelId, + ) + } + Log.i(TAG, "✅ LLM model loaded: ${event.modelId}") + } + EventCategory.STT -> { + _uiState.update { + it.copy( + sttLoadState = ModelLoadState.LOADED, + sttModel = SelectedModel("whisper", event.modelId, event.modelId), + whisperModel = event.modelId, + ) + } + Log.i(TAG, "✅ STT model loaded: ${event.modelId}") + } + EventCategory.TTS -> { + _uiState.update { + it.copy( + ttsLoadState = ModelLoadState.LOADED, + ttsModel = SelectedModel("tts", event.modelId, event.modelId), + ttsVoice = event.modelId, + ) + } + Log.i(TAG, "✅ TTS model loaded: ${event.modelId}") + } + else -> { /* Ignore other categories */ } + } + } + ModelEvent.ModelEventType.UNLOADED -> { + when (event.category) { + EventCategory.LLM -> { + _uiState.update { + it.copy( + llmLoadState = ModelLoadState.NOT_LOADED, + llmModel = null, + ) + } + } + EventCategory.STT -> { + _uiState.update { + it.copy( + sttLoadState = ModelLoadState.NOT_LOADED, + sttModel = null, + ) + } + } + EventCategory.TTS -> { + _uiState.update { + it.copy( + ttsLoadState = ModelLoadState.NOT_LOADED, + ttsModel = null, + ) + } + } + else -> { /* Ignore other categories */ } + } + } + else -> { /* Ignore other model events */ } + } + } + is LLMEvent -> { + // LLM generation events (handled separately from model loading) + } + is STTEvent -> { + // STT transcription events (handled separately from model loading) + } + is TTSEvent -> { + // TTS synthesis events (handled separately from model loading) + } + else -> { /* Ignore other events */ } + } + } + + /** + * Sync model states from SDK + * iOS Reference: syncModelStates() in VoiceAgentViewModel.swift + * + * This method queries the SDK for actual component load states and updates the UI. + * It preserves existing model selection info if present, only updating load states + * and filling in model info from SDK if not already set. + */ + private suspend fun syncModelStates() { + try { + val states = RunAnywhere.voiceAgentComponentStates() + + // Extract model IDs with explicit casting to avoid smart cast issues + val sttModelId = (states.stt as? ComponentLoadState.Loaded)?.loadedModelId + val llmModelId = (states.llm as? ComponentLoadState.Loaded)?.loadedModelId + val ttsModelId = (states.tts as? ComponentLoadState.Loaded)?.loadedModelId + + _uiState.update { currentState -> + currentState.copy( + // Always update load states from SDK - this is the source of truth + sttLoadState = ModelLoadState.fromSDK(states.stt), + llmLoadState = ModelLoadState.fromSDK(states.llm), + ttsLoadState = ModelLoadState.fromSDK(states.tts), + // Preserve existing model selection info if present, + // only fill in from SDK if no selection exists but model is loaded + sttModel = + currentState.sttModel ?: sttModelId?.let { id -> + SelectedModel("ONNX Runtime", id, id) + }, + llmModel = + currentState.llmModel ?: llmModelId?.let { id -> + SelectedModel("llamacpp", id, id) + }, + ttsModel = + currentState.ttsModel ?: ttsModelId?.let { id -> + SelectedModel("ONNX Runtime", id, id) + }, + // Also update convenience fields for backward compatibility + whisperModel = sttModelId ?: currentState.whisperModel, + currentLLMModel = llmModelId ?: currentState.currentLLMModel, + ttsVoice = ttsModelId ?: currentState.ttsVoice, + ) + } + + Log.i(TAG, "📊 Model states synced - STT: ${states.stt.isLoaded}, LLM: ${states.llm.isLoaded}, TTS: ${states.tts.isLoaded}") + } catch (e: Exception) { + Log.w(TAG, "Could not sync model states: ${e.message}") + } + } + + /** + * Refresh component states from SDK + * iOS Reference: refreshComponentStatesFromSDK() in VoiceAgentViewModel.swift + */ + fun refreshComponentStatesFromSDK() { + viewModelScope.launch { + syncModelStates() + } + } + + /** + * Start voice conversation session + * iOS Reference: startConversation() in VoiceAgentViewModel.swift + * + * Now uses AudioCaptureService directly (like STT screen) for audio input. + * Audio levels are updated in real-time for visualization. + */ + fun startSession() { + viewModelScope.launch { + try { + Log.i(TAG, "Starting conversation...") + + _uiState.update { + it.copy( + sessionState = SessionState.CONNECTING, + errorMessage = null, + currentTranscript = "", + assistantResponse = "", + ) + } + + // Check if all models are loaded + val uiStateValue = _uiState.value + if (!uiStateValue.allModelsLoaded) { + Log.w(TAG, "Cannot start: Not all models loaded") + _uiState.update { + it.copy( + sessionState = SessionState.ERROR, + errorMessage = "Please load all required models (STT, LLM, TTS) before starting", + ) + } + return@launch + } + + // Initialize audio capture if not already done + val audioCapture = audioCaptureService + if (audioCapture == null) { + Log.e(TAG, "AudioCaptureService not initialized") + _uiState.update { + it.copy( + sessionState = SessionState.ERROR, + errorMessage = "Audio capture not initialized. Please grant microphone permission.", + ) + } + return@launch + } + + // Check microphone permission + if (!audioCapture.hasRecordPermission()) { + Log.e(TAG, "No microphone permission") + _uiState.update { + it.copy( + sessionState = SessionState.ERROR, + errorMessage = "Microphone permission required", + ) + } + return@launch + } + + // Start voice session (for SDK state tracking) + val sessionFlow = RunAnywhere.startVoiceSession(VoiceSessionConfig.DEFAULT) + voiceSessionFlow = sessionFlow + + // Consume voice session events in background + pipelineJob = + viewModelScope.launch { + try { + sessionFlow.collect { event -> + handleVoiceSessionEvent(event) + } + } catch (e: Exception) { + Log.e(TAG, "Session event error", e) + } + } + + // Reset audio buffer + audioBuffer.reset() + + // Update state to listening + _uiState.update { + it.copy( + sessionState = SessionState.LISTENING, + isListening = true, + audioLevel = 0f, + ) + } + + Log.i(TAG, "Voice session started, starting audio capture...") + + // Reset speech state tracking + isSpeechActive = false + lastSpeechTime = 0L + isProcessingTurn = false + + // Start audio capture directly (like STT does) + audioRecordingJob = + viewModelScope.launch { + try { + audioCapture.startCapture().collect { audioData -> + // Skip processing if we're currently processing a turn + if (isProcessingTurn) return@collect + + // Append to buffer + withContext(Dispatchers.IO) { + audioBuffer.write(audioData) + } + + // Calculate and update audio level for visualization + val rms = audioCapture.calculateRMS(audioData) + val normalizedLevel = normalizeAudioLevel(rms) + _uiState.update { it.copy(audioLevel = normalizedLevel) } + + // Speech state detection (matching iOS checkSpeechState) + checkSpeechState(normalizedLevel) + } + } catch (e: kotlinx.coroutines.CancellationException) { + Log.d(TAG, "Audio recording cancelled (expected when stopping)") + } catch (e: Exception) { + Log.e(TAG, "Audio capture error", e) + _uiState.update { + it.copy( + errorMessage = "Audio capture error: ${e.message}", + ) + } + } + } + + // Start silence detection monitoring (matching iOS startAudioLevelMonitoring) + silenceDetectionJob = + viewModelScope.launch { + while (_uiState.value.isListening && !isProcessingTurn) { + checkSilenceAndTriggerProcessing() + delay(50) // Check every 50ms like iOS + } + } + } catch (e: Exception) { + Log.e(TAG, "Failed to start session", e) + _uiState.update { + it.copy( + sessionState = SessionState.ERROR, + errorMessage = "Failed to start: ${e.message}", + isListening = false, + ) + } + } + } + } + + /** + * Handle VoiceSession events (new API matching iOS) + */ + private fun handleVoiceSessionEvent(event: VoiceSessionEvent) { + when (event) { + is VoiceSessionEvent.Started -> { + Log.i(TAG, "Voice session started") + _uiState.update { + it.copy( + sessionState = SessionState.LISTENING, + isListening = true, + ) + } + } + + is VoiceSessionEvent.Listening -> { + _uiState.update { it.copy(audioLevel = event.audioLevel) } + } + + is VoiceSessionEvent.SpeechStarted -> { + Log.d(TAG, "Speech detected") + _uiState.update { it.copy(isSpeechDetected = true) } + } + + is VoiceSessionEvent.Processing -> { + Log.i(TAG, "Processing speech...") + _uiState.update { + it.copy( + sessionState = SessionState.PROCESSING, + isSpeechDetected = false, + ) + } + } + + is VoiceSessionEvent.Transcribed -> { + Log.i(TAG, "Transcription: ${event.text}") + _uiState.update { it.copy(currentTranscript = event.text) } + } + + is VoiceSessionEvent.Responded -> { + Log.i(TAG, "Response: ${event.text.take(50)}...") + _uiState.update { it.copy(assistantResponse = event.text) } + } + + is VoiceSessionEvent.Speaking -> { + Log.d(TAG, "Playing TTS audio") + _uiState.update { it.copy(sessionState = SessionState.PROCESSING) } + } + + is VoiceSessionEvent.TurnCompleted -> { + Log.i(TAG, "Turn completed") + _uiState.update { + it.copy( + currentTranscript = event.transcript, + assistantResponse = event.response, + sessionState = SessionState.LISTENING, + isListening = true, + ) + } + } + + is VoiceSessionEvent.Stopped -> { + Log.i(TAG, "Voice session stopped") + _uiState.update { + it.copy( + sessionState = SessionState.DISCONNECTED, + isListening = false, + ) + } + } + + is VoiceSessionEvent.Error -> { + Log.e(TAG, "Voice session error: ${event.message}") + _uiState.update { + it.copy( + errorMessage = event.message, + // Don't change state to error - session can continue + ) + } + } + } + } + + /** + * Stop conversation completely + * iOS Reference: stop() in VoiceSessionHandle + * + * Stops audio recording and voice session without processing remaining audio. + * Use this for manual stop (user pressed stop button). + */ + fun stopSession() { + viewModelScope.launch { + Log.i(TAG, "Stopping conversation...") + + // Reset speech state + isProcessingTurn = false + isSpeechActive = false + lastSpeechTime = 0L + + // Stop audio playback if playing + stopAudioPlayback() + + // Cancel all jobs + audioRecordingJob?.cancel() + audioRecordingJob = null + silenceDetectionJob?.cancel() + silenceDetectionJob = null + pipelineJob?.cancel() + pipelineJob = null + + // Stop audio capture service + audioCaptureService?.stopCapture() + + // Get the buffered audio before resetting + val audioData = audioBuffer.toByteArray() + val audioSize = audioData.size + audioBuffer.reset() + + Log.i(TAG, "Captured audio: $audioSize bytes") + + // Only process if we have meaningful audio data (at least 0.5 seconds at 16kHz, 16-bit) + // 16000 samples/sec * 2 bytes/sample * 0.5 sec = 16000 bytes + if (audioSize >= minAudioBytes) { + // Update state to processing + _uiState.update { + it.copy( + sessionState = SessionState.PROCESSING, + isListening = false, + isSpeechDetected = false, + audioLevel = 0f, + ) + } + + try { + Log.i(TAG, "Processing audio through voice pipeline...") + + // Process audio through STT → LLM → TTS pipeline + // Run on Default dispatcher to avoid blocking main thread (fixes ANR) + val result = + withContext(Dispatchers.Default) { + RunAnywhere.processVoice(audioData) + } + + val transcription = result.transcription + val response = result.response + + Log.i( + TAG, + "Voice pipeline result - speechDetected: ${result.speechDetected}, " + + "transcription: ${transcription?.take(50)}, " + + "response: ${response?.take(50)}", + ) + + if (result.speechDetected && transcription != null) { + _uiState.update { + it.copy( + currentTranscript = transcription, + assistantResponse = response ?: "", + sessionState = SessionState.DISCONNECTED, + ) + } + + // Play synthesized audio on manual stop as well + val synthesizedAudio = result.synthesizedAudio + if (synthesizedAudio != null && synthesizedAudio.isNotEmpty()) { + Log.i(TAG, "🔊 Playing TTS response (${synthesizedAudio.size} bytes)") + playAudio(synthesizedAudio) + } + } else { + Log.i(TAG, "No speech detected in audio") + _uiState.update { + it.copy( + sessionState = SessionState.DISCONNECTED, + errorMessage = if (!result.speechDetected) "No speech detected" else null, + ) + } + } + } catch (e: Exception) { + Log.e(TAG, "Error processing voice: ${e.message}", e) + _uiState.update { + it.copy( + sessionState = SessionState.ERROR, + errorMessage = "Processing error: ${e.message}", + ) + } + } + } else { + Log.i(TAG, "Audio too short to process ($audioSize bytes)") + // Reset UI state without processing + _uiState.update { + it.copy( + sessionState = SessionState.DISCONNECTED, + isListening = false, + isSpeechDetected = false, + audioLevel = 0f, + errorMessage = if (audioSize > 0) "Recording too short" else null, + ) + } + } + + // Stop voice session + RunAnywhere.stopVoiceSession() + voiceSessionFlow = null + + Log.i(TAG, "Conversation stopped") + } + } + + fun clearError() { + _uiState.update { it.copy(errorMessage = null) } + } + + fun clearConversation() { + _uiState.update { + it.copy( + currentTranscript = "", + assistantResponse = "", + ) + } + } + + /** + * Set the STT model for the voice pipeline + * iOS Reference: After selection, sync with SDK to get actual load state + * + * Note: The model is already loaded by ModelSelectionBottomSheet before this callback. + * We sync with SDK to get the actual load state instead of resetting to NOT_LOADED. + */ + fun setSTTModel( + framework: String, + name: String, + modelId: String, + ) { + _uiState.update { + it.copy( + sttModel = SelectedModel(framework, name, modelId), + whisperModel = modelId, + // Don't reset sttLoadState - model may already be loaded by ModelSelectionBottomSheet + ) + } + Log.i(TAG, "STT model selected: $name ($modelId)") + // Sync with SDK to get actual load state (model may already be loaded) + viewModelScope.launch { + syncModelStates() + } + } + + /** + * Set the LLM model for the voice pipeline + * iOS Reference: After selection, sync with SDK to get actual load state + * + * Note: The model is already loaded by ModelSelectionBottomSheet before this callback. + * We sync with SDK to get the actual load state instead of resetting to NOT_LOADED. + */ + fun setLLMModel( + framework: String, + name: String, + modelId: String, + ) { + _uiState.update { + it.copy( + llmModel = SelectedModel(framework, name, modelId), + currentLLMModel = modelId, + // Don't reset llmLoadState - model may already be loaded by ModelSelectionBottomSheet + ) + } + Log.i(TAG, "LLM model selected: $name ($modelId)") + // Sync with SDK to get actual load state (model may already be loaded) + viewModelScope.launch { + syncModelStates() + } + } + + /** + * Set the TTS model for the voice pipeline + * iOS Reference: After selection, sync with SDK to get actual load state + * + * Note: The model is already loaded by ModelSelectionBottomSheet before this callback. + * We sync with SDK to get the actual load state instead of resetting to NOT_LOADED. + */ + fun setTTSModel( + framework: String, + name: String, + modelId: String, + ) { + _uiState.update { + it.copy( + ttsModel = SelectedModel(framework, name, modelId), + ttsVoice = modelId, + // Don't reset ttsLoadState - model may already be loaded by ModelSelectionBottomSheet + ) + } + Log.i(TAG, "TTS model selected: $name ($modelId)") + // Sync with SDK to get actual load state (model may already be loaded) + viewModelScope.launch { + syncModelStates() + } + } + + override fun onCleared() { + super.onCleared() + eventSubscriptionJob?.cancel() + pipelineJob?.cancel() + audioRecordingJob?.cancel() + silenceDetectionJob?.cancel() + stopAudioPlayback() + audioCaptureService?.release() + audioCaptureService = null + viewModelScope.launch { + RunAnywhere.stopVoiceSession() + } + } +} diff --git a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/ui/theme/AppColors.kt b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/ui/theme/AppColors.kt new file mode 100644 index 000000000..6073c6d5c --- /dev/null +++ b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/ui/theme/AppColors.kt @@ -0,0 +1,267 @@ +package com.runanywhere.runanywhereai.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color + +/** + * RunAnywhere Brand Color Palette + * Color scheme matching RunAnywhere.ai website + * Primary accent: Vibrant orange-red (#FF5500) - matches website branding + * Dark theme backgrounds: Deep dark blue-gray matching website aesthetic + */ +object AppColors { + // ==================== + // PRIMARY ACCENT COLORS - RunAnywhere Brand Colors + // ==================== + // Primary brand color - vibrant orange/red from RunAnywhere.ai website + val primaryAccent = Color(0xFFFF5500) // Vibrant orange-red - primary brand color + val primaryOrange = Color(0xFFFF5500) // Same as primary accent + val primaryBlue = Color(0xFF3B82F6) // Blue-500 - for secondary elements + val primaryGreen = Color(0xFF10B981) // Emerald-500 - success green + val primaryRed = Color(0xFFEF4444) // Red-500 - error red + val primaryYellow = Color(0xFFEAB308) // Yellow-500 + val primaryPurple = Color(0xFF8B5CF6) // Violet-500 - purple accent + + // ==================== + // TEXT COLORS - RunAnywhere Theme + // ==================== + val textPrimary = Color(0xFF0F172A) // Slate-900 - dark text for light mode + val textSecondary = Color(0xFF475569) // Slate-600 - secondary text + val textTertiary = Color(0xFF94A3B8) // Slate-400 - tertiary text + val textWhite = Color.White + + // ==================== + // BACKGROUND COLORS - RunAnywhere Theme + // ==================== + // Light mode - clean, modern backgrounds + val backgroundPrimary = Color(0xFFFFFFFF) // Pure white + val backgroundSecondary = Color(0xFFF8FAFC) // Slate-50 - very light gray + val backgroundTertiary = Color(0xFFFFFFFF) // Pure white + val backgroundGrouped = Color(0xFFF1F5F9) // Slate-100 - light grouped background + val backgroundGray5 = Color(0xFFE2E8F0) // Slate-200 - light gray + val backgroundGray6 = Color(0xFFF1F5F9) // Slate-100 - lighter gray + + // Dark mode - matching RunAnywhere.ai website dark theme + val backgroundPrimaryDark = Color(0xFF0F172A) // Deep dark blue-gray - main background + val backgroundSecondaryDark = Color(0xFF1A1F2E) // Slightly lighter dark surface + val backgroundTertiaryDark = Color(0xFF252B3A) // Medium dark surface + val backgroundGroupedDark = Color(0xFF0F172A) // Deep dark - grouped background + val backgroundGray5Dark = Color(0xFF2A3142) // Medium dark gray + val backgroundGray6Dark = Color(0xFF353B4A) // Lighter dark gray + + // ==================== + // MESSAGE BUBBLE COLORS - RunAnywhere Theme + // ==================== + // User bubbles (with gradient support) - using vibrant orange/red brand color + val userBubbleGradientStart = primaryAccent // Vibrant orange-red + val userBubbleGradientEnd = Color(0xFFE64500) // Slightly darker orange-red + val messageBubbleUser = primaryAccent // Vibrant orange-red + + // Assistant bubbles - clean gray + val messageBubbleAssistant = backgroundGray5 // Slate-200 + val messageBubbleAssistantGradientStart = backgroundGray5 + val messageBubbleAssistantGradientEnd = backgroundGray6 + + // Dark mode - toned down variant for reduced eye strain in low-light + val messageBubbleUserDark = Color(0xFFCC4400) // Darker orange-red (80% brightness of primaryAccent) + val messageBubbleAssistantDark = backgroundGray5Dark // Dark gray + + // ==================== + // BADGE/TAG COLORS + // ==================== + val badgePrimary = primaryAccent.copy(alpha = 0.2f) // Brand primary (orange-red) + val badgeGreen = primaryGreen.copy(alpha = 0.2f) + val badgePurple = primaryPurple.copy(alpha = 0.2f) + val badgeOrange = primaryOrange.copy(alpha = 0.2f) + val badgeYellow = primaryYellow.copy(alpha = 0.2f) + val badgeRed = primaryRed.copy(alpha = 0.2f) + val badgeGray = Color.Gray.copy(alpha = 0.2f) + + // ==================== + // MODEL INFO COLORS - RunAnywhere Theme + // ==================== + val modelFrameworkBg = primaryAccent.copy(alpha = 0.1f) // Brand primary orange-red + val modelThinkingBg = primaryAccent.copy(alpha = 0.1f) // Brand primary orange-red + + // ==================== + // THINKING MODE COLORS - RunAnywhere Theme + // ==================== + // Using brand orange for thinking mode to match website aesthetic + val thinkingBackground = primaryAccent.copy(alpha = 0.1f) // 10% orange-red + val thinkingBackgroundGradientStart = primaryAccent.copy(alpha = 0.1f) + val thinkingBackgroundGradientEnd = primaryAccent.copy(alpha = 0.05f) // 5% orange-red + val thinkingBorder = primaryAccent.copy(alpha = 0.2f) + val thinkingContentBackground = backgroundGray6 + val thinkingProgressBackground = primaryAccent.copy(alpha = 0.12f) + val thinkingProgressBackgroundGradientEnd = primaryAccent.copy(alpha = 0.06f) + + // Dark mode + val thinkingBackgroundDark = primaryAccent.copy(alpha = 0.15f) + val thinkingContentBackgroundDark = backgroundGray6Dark + + // ==================== + // STATUS COLORS - RunAnywhere Theme + // ==================== + val statusGreen = primaryGreen + val statusOrange = primaryOrange + val statusRed = primaryRed + val statusGray = Color(0xFF64748B) // Slate-500 - modern gray + val statusPrimary = primaryAccent // Brand primary (orange-red) + + // Warning color - matches iOS orange for error states + val warningOrange = primaryOrange + + // ==================== + // SHADOW COLORS + // ==================== + val shadowDefault = Color.Black.copy(alpha = 0.1f) + val shadowLight = Color.Black.copy(alpha = 0.1f) + val shadowMedium = Color.Black.copy(alpha = 0.12f) + val shadowDark = Color.Black.copy(alpha = 0.3f) + + // Shadows for specific components + val shadowBubble = shadowMedium // 0.12 alpha + val shadowThinking = primaryAccent.copy(alpha = 0.2f) // Orange-red glow + val shadowModelBadge = primaryAccent.copy(alpha = 0.3f) // Brand primary + val shadowTypingIndicator = shadowLight + + // ==================== + // OVERLAY COLORS + // ==================== + val overlayLight = Color.Black.copy(alpha = 0.3f) + val overlayMedium = Color.Black.copy(alpha = 0.4f) + val overlayDark = Color.Black.copy(alpha = 0.7f) + + // ==================== + // BORDER COLORS - RunAnywhere Theme + // ==================== + val borderLight = Color.White.copy(alpha = 0.3f) + val borderMedium = Color.Black.copy(alpha = 0.05f) + val separator = Color(0xFFE2E8F0) // Slate-200 - modern separator + + // ==================== + // DIVIDERS - RunAnywhere Theme + // ==================== + val divider = Color(0xFFCBD5E1) // Slate-300 - light divider + val dividerDark = Color(0xFF2A3142) // Dark divider matching website + + // ==================== + // CARDS & SURFACES + // ==================== + val cardBackground = backgroundSecondary + val cardBackgroundDark = backgroundSecondaryDark + + // ==================== + // TYPING INDICATOR - RunAnywhere Theme + // ==================== + val typingIndicatorDots = primaryAccent.copy(alpha = 0.7f) // Brand primary + val typingIndicatorBackground = backgroundGray5 + val typingIndicatorBorder = borderLight + val typingIndicatorText = textSecondary.copy(alpha = 0.8f) + + // ==================== + // GRADIENT HELPERS + // ==================== + + /** + * User message bubble gradient (orange-red brand color) + */ + fun userBubbleGradient() = + Brush.linearGradient( + colors = listOf(userBubbleGradientStart, userBubbleGradientEnd), + ) + + /** + * Assistant message bubble gradient (gray) - non-composable version for legacy use + */ + fun assistantBubbleGradient() = + Brush.linearGradient( + colors = listOf(messageBubbleAssistantGradientStart, messageBubbleAssistantGradientEnd), + ) + + /** + * Theme-aware assistant message bubble gradient + * Uses dark gray in dark mode, light gray in light mode + */ + @Composable + fun assistantBubbleGradientThemed(): Brush { + val isDark = isSystemInDarkTheme() + return Brush.linearGradient( + colors = + if (isDark) { + listOf(backgroundGray5Dark, backgroundGray6Dark) + } else { + listOf(messageBubbleAssistantGradientStart, messageBubbleAssistantGradientEnd) + }, + ) + } + + /** + * Theme-aware text color for assistant message bubbles + * Returns white in dark mode, dark text in light mode + */ + @Composable + fun assistantBubbleTextColor(): Color { + return if (isSystemInDarkTheme()) { + Color.White + } else { + textPrimary + } + } + + /** + * Thinking section background gradient (orange-red brand color) + */ + fun thinkingBackgroundGradient() = + Brush.linearGradient( + colors = listOf(thinkingBackgroundGradientStart, thinkingBackgroundGradientEnd), + ) + + /** + * Model badge gradient (brand primary) + */ + fun modelBadgeGradient() = + Brush.linearGradient( + colors = listOf(primaryAccent, primaryAccent.copy(alpha = 0.9f)), + ) + + /** + * Thinking progress gradient (orange-red brand color) + */ + fun thinkingProgressGradient() = + Brush.linearGradient( + colors = listOf(thinkingProgressBackground, thinkingProgressBackgroundGradientEnd), + ) + + // ==================== + // HELPER FUNCTIONS + // ==================== + + /** + * Get framework-specific badge color + */ + fun frameworkBadgeColor(framework: String): Color { + return when (framework.uppercase()) { + "LLAMA_CPP", "LLAMACPP" -> primaryAccent.copy(alpha = 0.2f) // Brand primary + "WHISPERKIT", "WHISPER" -> badgeGreen + "MLKIT", "ML_KIT" -> badgePurple + "COREML", "CORE_ML" -> badgeOrange + else -> primaryAccent.copy(alpha = 0.2f) + } + } + + /** + * Get framework-specific text color + */ + fun frameworkTextColor(framework: String): Color { + return when (framework.uppercase()) { + "LLAMA_CPP", "LLAMACPP" -> primaryAccent // Brand primary + "WHISPERKIT", "WHISPER" -> primaryGreen + "MLKIT", "ML_KIT" -> primaryPurple + "COREML", "CORE_ML" -> primaryOrange + else -> primaryAccent + } + } +} diff --git a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/ui/theme/AppSpacing.kt b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/ui/theme/AppSpacing.kt new file mode 100644 index 000000000..004bac8c0 --- /dev/null +++ b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/ui/theme/AppSpacing.kt @@ -0,0 +1,97 @@ +package com.runanywhere.runanywhereai.ui.theme + +import androidx.compose.ui.unit.dp + +/** + * iOS-matching spacing system for RunAnywhere AI + * All values are exact matches to iOS sample app design system + * Reference: examples/ios/RunAnywhereAI/RunAnywhereAI/Core/DesignSystem/AppSpacing.swift + */ +object AppSpacing { + // Base spacing scale (matching iOS exactly in points -> dp) + val xxSmall = 2.dp + val xSmall = 4.dp + val small = 8.dp + val smallMedium = 10.dp + val medium = 12.dp + val padding15 = 15.dp + val large = 16.dp + val xLarge = 20.dp + val xxLarge = 24.dp + val xxxLarge = 32.dp + val huge = 40.dp + val padding48 = 48.dp + val padding64 = 64.dp + val padding80 = 80.dp + val padding100 = 100.dp + + // Corner Radius (iOS values) + val cornerRadiusSmall = 8.dp + val cornerRadiusMedium = 12.dp + val cornerRadiusLarge = 16.dp + val cornerRadiusXLarge = 20.dp + val cornerRadiusXXLarge = 24.dp + + // Layout constraints (iOS max widths) + val maxContentWidth = 700.dp + val maxContentWidthLarge = 900.dp + val messageBubbleMaxWidth = 280.dp + + // Component-specific sizes + val buttonHeight = 44.dp // iOS standard button height + val buttonHeightSmall = 32.dp + val buttonHeightLarge = 56.dp + + val micButtonSize = 80.dp // Large mic button for voice input + val modelBadgeHeight = 32.dp + val progressBarHeight = 4.dp + val dividerThickness = 0.5.dp // iOS hairline divider + + // Icon sizes + val iconSizeSmall = 16.dp + val iconSizeMedium = 24.dp + val iconSizeLarge = 32.dp + val iconSizeXLarge = 48.dp + + // Minimum touch targets (accessibility) + val minTouchTarget = 44.dp // iOS minimum for accessibility + + // Animation durations (in milliseconds, matching iOS) + const val animationFast = 200 + const val animationNormal = 300 + const val animationSlow = 400 + const val animationSpringSlow = 600 + + // List item heights + val listItemHeightSmall = 44.dp + val listItemHeightMedium = 56.dp + val listItemHeightLarge = 72.dp + + // Card padding + val cardPaddingSmall = small + val cardPaddingMedium = medium + val cardPaddingLarge = large + + // Screen padding (safe area insets) + val screenPaddingHorizontal = large + val screenPaddingVertical = xLarge + + // Spacing between sections + val sectionSpacing = xxLarge + val itemSpacing = medium + + // Message bubble specific + val messagePadding = medium + val messageSpacing = small + val thinkingPadding = small + + // Model card specific + val modelCardPadding = large + val modelCardSpacing = small + val modelImageSize = 48.dp + + // Settings screen specific + val settingsSectionSpacing = xxLarge + val settingsItemHeight = 56.dp + val settingsSliderPadding = medium +} diff --git a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/ui/theme/Dimensions.kt b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/ui/theme/Dimensions.kt new file mode 100644 index 000000000..8b17074fa --- /dev/null +++ b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/ui/theme/Dimensions.kt @@ -0,0 +1,158 @@ +package com.runanywhere.runanywhereai.ui.theme + +import androidx.compose.ui.unit.dp + +/** + * Comprehensive dimension system matching iOS ChatInterfaceView exactly + * Reference: iOS ChatInterfaceView.swift design specifications + * All values extracted from iOS implementation for pixel-perfect Android replication + */ +object Dimensions { + // ==================== + // PADDING VALUES + // ==================== + val xxSmall = 2.dp + val xSmall = 4.dp + val small = 6.dp + val smallMedium = 8.dp + val medium = 10.dp + val mediumLarge = 12.dp + val regular = 14.dp + val large = 16.dp + val xLarge = 20.dp + val xxLarge = 30.dp + val xxxLarge = 40.dp + val huge = 40.dp + + // Specific paddings + val padding4 = 4.dp + val padding6 = 6.dp + val padding8 = 8.dp + val padding9 = 9.dp + val padding10 = 10.dp + val padding12 = 12.dp + val padding14 = 14.dp + val padding15 = 15.dp + val padding16 = 16.dp + val padding20 = 20.dp + val padding30 = 30.dp + val padding40 = 40.dp + val padding60 = 60.dp + val padding100 = 100.dp + + // ==================== + // CORNER RADIUS + // ==================== + val cornerRadiusSmall = 4.dp + val cornerRadiusMedium = 6.dp + val cornerRadiusRegular = 8.dp + val cornerRadiusLarge = 10.dp + val cornerRadiusXLarge = 12.dp + val cornerRadiusXXLarge = 14.dp + val cornerRadiusCard = 16.dp + val cornerRadiusBubble = 18.dp + val cornerRadiusModal = 20.dp + + // ==================== + // ICON SIZES + // ==================== + val iconSmall = 8.dp + val iconRegular = 18.dp + val iconMedium = 28.dp + val iconLarge = 48.dp + val iconXLarge = 60.dp + val iconXXLarge = 72.dp + val iconHuge = 80.dp + + // ==================== + // BUTTON HEIGHTS + // ==================== + val buttonHeightSmall = 28.dp + val buttonHeightRegular = 44.dp + val buttonHeightLarge = 72.dp + + // ==================== + // FRAME SIZES + // ==================== + val minFrameHeight = 150.dp + val maxFrameHeight = 150.dp + + // ==================== + // STROKE WIDTHS + // ==================== + val strokeThin = 0.5.dp + val strokeRegular = 1.dp + val strokeMedium = 2.dp + + // ==================== + // SHADOW RADIUS + // ==================== + val shadowSmall = 2.dp + val shadowMedium = 3.dp + val shadowLarge = 4.dp + val shadowXLarge = 10.dp + + // ==================== + // CHAT-SPECIFIC DIMENSIONS + // ==================== + + // Message Bubbles + val messageBubbleCornerRadius = cornerRadiusBubble // 18.dp + val messageBubblePaddingHorizontal = padding16 // 16.dp + val messageBubblePaddingVertical = padding12 // 12.dp + val messageBubbleShadowRadius = shadowLarge // 4.dp + val messageBubbleMinSpacing = padding60 // 60.dp (for alignment) + val messageSpacingBetween = large // 16.dp + + // Thinking Section + val thinkingSectionCornerRadius = mediumLarge // 12.dp + val thinkingSectionPaddingHorizontal = regular // 14.dp + val thinkingSectionPaddingVertical = padding9 // 9.dp + val thinkingContentCornerRadius = medium // 10.dp + val thinkingContentPadding = mediumLarge // 12.dp + val thinkingContentMaxHeight = minFrameHeight // 150.dp + + // Model Badge + val modelBadgePaddingHorizontal = medium // 10.dp + val modelBadgePaddingVertical = 5.dp + val modelBadgeCornerRadius = regular // 14.dp + val modelBadgeSpacing = smallMedium // 8.dp + + // Model Info Bar + val modelInfoBarPaddingHorizontal = large // 16.dp + val modelInfoBarPaddingVertical = small // 6.dp + val modelInfoFrameworkBadgeCornerRadius = cornerRadiusSmall // 4.dp + val modelInfoFrameworkBadgePaddingHorizontal = small // 6.dp + val modelInfoFrameworkBadgePaddingVertical = xSmall // 2.dp + val modelInfoStatsIconTextSpacing = 3.dp + val modelInfoStatsItemSpacing = mediumLarge // 12.dp + + // Input Area + val inputAreaPadding = large // 16.dp + val inputFieldButtonSpacing = mediumLarge // 12.dp + val sendButtonSize = iconMedium // 28.dp + + // Typing Indicator + val typingIndicatorDotSize = iconSmall // 8.dp + val typingIndicatorDotSpacing = xSmall // 4.dp + val typingIndicatorPaddingHorizontal = mediumLarge // 12.dp + val typingIndicatorPaddingVertical = smallMedium // 8.dp + val typingIndicatorCornerRadius = medium // 10.dp + val typingIndicatorTextSpacing = mediumLarge // 12.dp + + // Empty State + val emptyStateIconSize = iconXLarge // 60.dp + val emptyStateIconTextSpacing = large // 16.dp + val emptyStateTitleSubtitleSpacing = smallMedium // 8.dp + + // Toolbar + val toolbarButtonSpacing = smallMedium // 8.dp + val toolbarHeight = buttonHeightRegular // 44.dp + + // ==================== + // MAX WIDTHS + // ==================== + val messageBubbleMaxWidth = 280.dp + val maxContentWidth = 700.dp + val contextMenuMaxWidth = 280.dp +} diff --git a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/ui/theme/Theme.kt b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/ui/theme/Theme.kt new file mode 100644 index 000000000..c6af149b6 --- /dev/null +++ b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/ui/theme/Theme.kt @@ -0,0 +1,114 @@ +package com.runanywhere.runanywhereai.ui.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext + +/** + * Light Color Scheme - RunAnywhere Brand Theme + * Modern color scheme matching RunAnywhere.ai website + */ +private val LightColorScheme = + lightColorScheme( + // Primary colors + primary = AppColors.primaryAccent, + onPrimary = Color.White, + primaryContainer = AppColors.primaryAccent.copy(alpha = 0.1f), + onPrimaryContainer = AppColors.primaryAccent, + // Secondary colors + secondary = AppColors.primaryPurple, + onSecondary = Color.White, + secondaryContainer = AppColors.primaryPurple.copy(alpha = 0.1f), + onSecondaryContainer = AppColors.primaryPurple, + // Tertiary colors + tertiary = AppColors.primaryGreen, + onTertiary = Color.White, + tertiaryContainer = AppColors.primaryGreen.copy(alpha = 0.1f), + onTertiaryContainer = AppColors.primaryGreen, + // Background colors + background = AppColors.backgroundGrouped, + onBackground = AppColors.textPrimary, + // Surface colors + surface = AppColors.backgroundPrimary, + onSurface = AppColors.textPrimary, + surfaceVariant = AppColors.backgroundSecondary, + onSurfaceVariant = AppColors.textSecondary, + // Error colors + error = AppColors.primaryRed, + onError = Color.White, + errorContainer = AppColors.primaryRed.copy(alpha = 0.1f), + onErrorContainer = AppColors.primaryRed, + // Outline + outline = AppColors.separator, + outlineVariant = AppColors.divider, + ) + +/** + * Dark Color Scheme - RunAnywhere Brand Theme + * Modern dark theme matching RunAnywhere.ai website + */ +private val DarkColorScheme = + darkColorScheme( + // Primary colors + primary = AppColors.primaryAccent, + onPrimary = Color.White, + primaryContainer = AppColors.primaryAccent.copy(alpha = 0.2f), + onPrimaryContainer = AppColors.primaryAccent, + // Secondary colors + secondary = AppColors.primaryPurple, + onSecondary = Color.White, + secondaryContainer = AppColors.primaryPurple.copy(alpha = 0.2f), + onSecondaryContainer = AppColors.primaryPurple, + // Tertiary colors + tertiary = AppColors.primaryGreen, + onTertiary = Color.White, + tertiaryContainer = AppColors.primaryGreen.copy(alpha = 0.2f), + onTertiaryContainer = AppColors.primaryGreen, + // Background colors + background = AppColors.backgroundGroupedDark, + onBackground = Color.White, + // Surface colors + surface = AppColors.backgroundPrimaryDark, + onSurface = Color.White, + surfaceVariant = AppColors.backgroundSecondaryDark, + onSurfaceVariant = Color.White.copy(alpha = 0.6f), + // Error colors + error = AppColors.primaryRed, + onError = Color.White, + errorContainer = AppColors.primaryRed.copy(alpha = 0.2f), + onErrorContainer = AppColors.primaryRed, + // Outline + outline = AppColors.separator, + outlineVariant = AppColors.dividerDark, + ) + +@Composable +fun RunAnywhereAITheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color disabled to maintain RunAnywhere brand consistency + dynamicColor: Boolean = false, + content: @Composable () -> Unit, +) { + val colorScheme = + when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content, + ) +} diff --git a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/ui/theme/Type.kt b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/ui/theme/Type.kt new file mode 100644 index 000000000..1ab534a12 --- /dev/null +++ b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/ui/theme/Type.kt @@ -0,0 +1,241 @@ +package com.runanywhere.runanywhereai.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +/** + * iOS-matching typography system + * All values match iOS text styles exactly + * Reference: examples/ios/RunAnywhereAI/RunAnywhereAI/Core/DesignSystem/Typography.swift + */ +val Typography = + Typography( + // iOS Display (34pt bold) - Large title + displayLarge = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 34.sp, + lineHeight = 41.sp, + ), + // iOS Title 1 (28pt semibold) + displayMedium = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 28.sp, + lineHeight = 34.sp, + ), + // iOS Title 2 (22pt semibold) + displaySmall = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 22.sp, + lineHeight = 28.sp, + ), + // iOS Title 3 (20pt semibold) + headlineLarge = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 20.sp, + lineHeight = 25.sp, + ), + // iOS Headline (17pt semibold) - Navigation titles + headlineMedium = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 17.sp, + lineHeight = 22.sp, + ), + headlineSmall = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 15.sp, + lineHeight = 20.sp, + ), + // iOS Title 1 (28pt semibold) + titleLarge = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 28.sp, + lineHeight = 34.sp, + ), + // iOS Title 2 (22pt semibold) - Empty state titles + titleMedium = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 22.sp, + lineHeight = 28.sp, + ), + // iOS Title 3 (20pt semibold) + titleSmall = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 20.sp, + lineHeight = 25.sp, + ), + // iOS Body (17pt regular) - Message content, input field + bodyLarge = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 17.sp, + lineHeight = 22.sp, + ), + // iOS Subheadline (15pt regular) - Empty state subtitles + bodyMedium = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 15.sp, + lineHeight = 20.sp, + ), + // iOS Footnote (13pt regular) + bodySmall = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 13.sp, + lineHeight = 18.sp, + ), + // iOS Subheadline (15pt regular) + labelLarge = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 15.sp, + lineHeight = 20.sp, + ), + // iOS Caption (12pt regular) - Thinking labels, timestamps + labelMedium = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + lineHeight = 16.sp, + ), + // iOS Caption2 (11pt regular) - Analytics text, model badges + labelSmall = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 11.sp, + lineHeight = 13.sp, + ), + ) + +/** + * Custom text styles for specific iOS components not covered by Material 3 Typography + */ +object AppTypography { + // Custom sizes matching iOS exactly + val system9 = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 9.sp, + lineHeight = 11.sp, + ) + + val system10 = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 10.sp, + lineHeight = 12.sp, + ) + + val system11 = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 11.sp, + lineHeight = 13.sp, + ) + + val system11Medium = system11.copy(fontWeight = FontWeight.Medium) + + val system12 = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + lineHeight = 16.sp, + ) + + val system12Medium = system12.copy(fontWeight = FontWeight.Medium) + + val system28 = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 28.sp, + lineHeight = 34.sp, + ) + + val system60 = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 60.sp, + lineHeight = 72.sp, + ) + + // Weight variants (matching iOS) + val caption = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + lineHeight = 16.sp, + ) + + val caption2 = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 11.sp, + lineHeight = 13.sp, + ) + + val caption2Medium = caption2.copy(fontWeight = FontWeight.Medium) + + val caption2Bold = caption2.copy(fontWeight = FontWeight.Bold) + + // Monospaced variants (for model info) + val monospacedCaption = + TextStyle( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + fontSize = 9.sp, + lineHeight = 11.sp, + ) + + // Rounded variants (for model info stats) + val rounded10 = + TextStyle( + // Could use rounded font if available + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 10.sp, + lineHeight = 12.sp, + ) + + val rounded11 = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 13.sp, + ) +} diff --git a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/util/ModelUtils.kt b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/util/ModelUtils.kt new file mode 100644 index 000000000..443e141c3 --- /dev/null +++ b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/util/ModelUtils.kt @@ -0,0 +1,26 @@ +package com.runanywhere.runanywhereai.util + +import com.runanywhere.runanywhereai.R + +/** + * Returns the appropriate logo drawable resource ID for a given model name. + * This is a simplified version that works with just the model name string. + * + * @param name The model name to get logo for + * @return Drawable resource ID for the model's logo + */ +fun getModelLogoResIdForName(name: String): Int { + val lowercaseName = name.lowercase() + return when { + lowercaseName.contains("llama") -> R.drawable.llama_logo + lowercaseName.contains("mistral") -> R.drawable.mistral_logo + lowercaseName.contains("qwen") -> R.drawable.qwen_logo + lowercaseName.contains("liquid") -> R.drawable.liquid_ai_logo + lowercaseName.contains("piper") -> R.drawable.hugging_face_logo + lowercaseName.contains("whisper") -> R.drawable.hugging_face_logo + lowercaseName.contains("sherpa") -> R.drawable.hugging_face_logo + lowercaseName.contains("foundation") -> R.drawable.foundation_models_logo + lowercaseName.contains("system") -> R.drawable.foundation_models_logo + else -> R.drawable.hugging_face_logo + } +} diff --git a/examples/android/RunAnywhereAI/app/src/main/res/drawable/foundation_models_logo.png b/examples/android/RunAnywhereAI/app/src/main/res/drawable/foundation_models_logo.png new file mode 100644 index 000000000..4488cb10e Binary files /dev/null and b/examples/android/RunAnywhereAI/app/src/main/res/drawable/foundation_models_logo.png differ diff --git a/examples/android/RunAnywhereAI/app/src/main/res/drawable/hugging_face_logo.png b/examples/android/RunAnywhereAI/app/src/main/res/drawable/hugging_face_logo.png new file mode 100644 index 000000000..963b7277b Binary files /dev/null and b/examples/android/RunAnywhereAI/app/src/main/res/drawable/hugging_face_logo.png differ diff --git a/examples/android/RunAnywhereAI/app/src/main/res/drawable/liquid_ai_logo.png b/examples/android/RunAnywhereAI/app/src/main/res/drawable/liquid_ai_logo.png new file mode 100644 index 000000000..8ab423ffb Binary files /dev/null and b/examples/android/RunAnywhereAI/app/src/main/res/drawable/liquid_ai_logo.png differ diff --git a/examples/android/RunAnywhereAI/app/src/main/res/drawable/llama_logo.png b/examples/android/RunAnywhereAI/app/src/main/res/drawable/llama_logo.png new file mode 100644 index 000000000..4e4d21697 Binary files /dev/null and b/examples/android/RunAnywhereAI/app/src/main/res/drawable/llama_logo.png differ diff --git a/examples/android/RunAnywhereAI/app/src/main/res/drawable/mistral_logo.png b/examples/android/RunAnywhereAI/app/src/main/res/drawable/mistral_logo.png new file mode 100644 index 000000000..f3bf1519d Binary files /dev/null and b/examples/android/RunAnywhereAI/app/src/main/res/drawable/mistral_logo.png differ diff --git a/examples/android/RunAnywhereAI/app/src/main/res/drawable/qwen_logo.png b/examples/android/RunAnywhereAI/app/src/main/res/drawable/qwen_logo.png new file mode 100644 index 000000000..c46edc17b Binary files /dev/null and b/examples/android/RunAnywhereAI/app/src/main/res/drawable/qwen_logo.png differ diff --git a/examples/android/RunAnywhereAI/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/examples/android/RunAnywhereAI/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..a28663c3e --- /dev/null +++ b/examples/android/RunAnywhereAI/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,4 @@ + + + + diff --git a/examples/android/RunAnywhereAI/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/examples/android/RunAnywhereAI/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 000000000..a28663c3e --- /dev/null +++ b/examples/android/RunAnywhereAI/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,4 @@ + + + + diff --git a/examples/android/RunAnywhereAI/app/src/main/res/mipmap-hdpi/ic_launcher.png b/examples/android/RunAnywhereAI/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..cb0cbf6d5 Binary files /dev/null and b/examples/android/RunAnywhereAI/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/examples/android/RunAnywhereAI/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/examples/android/RunAnywhereAI/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..d75364e32 Binary files /dev/null and b/examples/android/RunAnywhereAI/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/examples/android/RunAnywhereAI/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/examples/android/RunAnywhereAI/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 000000000..67c4925ac Binary files /dev/null and b/examples/android/RunAnywhereAI/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/examples/android/RunAnywhereAI/app/src/main/res/mipmap-ldpi/ic_launcher.png b/examples/android/RunAnywhereAI/app/src/main/res/mipmap-ldpi/ic_launcher.png new file mode 100644 index 000000000..db2734ad6 Binary files /dev/null and b/examples/android/RunAnywhereAI/app/src/main/res/mipmap-ldpi/ic_launcher.png differ diff --git a/examples/android/RunAnywhereAI/app/src/main/res/mipmap-ldpi/ic_launcher_round.png b/examples/android/RunAnywhereAI/app/src/main/res/mipmap-ldpi/ic_launcher_round.png new file mode 100644 index 000000000..b3cea81c7 Binary files /dev/null and b/examples/android/RunAnywhereAI/app/src/main/res/mipmap-ldpi/ic_launcher_round.png differ diff --git a/examples/android/RunAnywhereAI/app/src/main/res/mipmap-mdpi/ic_launcher.png b/examples/android/RunAnywhereAI/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..c6f59d484 Binary files /dev/null and b/examples/android/RunAnywhereAI/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/examples/android/RunAnywhereAI/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/examples/android/RunAnywhereAI/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..b2551532d Binary files /dev/null and b/examples/android/RunAnywhereAI/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/examples/android/RunAnywhereAI/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/examples/android/RunAnywhereAI/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 000000000..54c9d85d5 Binary files /dev/null and b/examples/android/RunAnywhereAI/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/examples/android/RunAnywhereAI/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/examples/android/RunAnywhereAI/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..33df0290c Binary files /dev/null and b/examples/android/RunAnywhereAI/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/examples/android/RunAnywhereAI/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/examples/android/RunAnywhereAI/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..1857c4288 Binary files /dev/null and b/examples/android/RunAnywhereAI/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/examples/android/RunAnywhereAI/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/examples/android/RunAnywhereAI/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 000000000..faa037166 Binary files /dev/null and b/examples/android/RunAnywhereAI/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/examples/android/RunAnywhereAI/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/examples/android/RunAnywhereAI/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..172a969f0 Binary files /dev/null and b/examples/android/RunAnywhereAI/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/examples/android/RunAnywhereAI/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/examples/android/RunAnywhereAI/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..a85881c67 Binary files /dev/null and b/examples/android/RunAnywhereAI/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/examples/android/RunAnywhereAI/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/examples/android/RunAnywhereAI/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 000000000..f605f26aa Binary files /dev/null and b/examples/android/RunAnywhereAI/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/examples/android/RunAnywhereAI/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/examples/android/RunAnywhereAI/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..21617fdbf Binary files /dev/null and b/examples/android/RunAnywhereAI/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/examples/android/RunAnywhereAI/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/examples/android/RunAnywhereAI/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..b9d84e562 Binary files /dev/null and b/examples/android/RunAnywhereAI/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/examples/android/RunAnywhereAI/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/examples/android/RunAnywhereAI/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 000000000..2501997c0 Binary files /dev/null and b/examples/android/RunAnywhereAI/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/examples/android/RunAnywhereAI/app/src/main/res/values/colors.xml b/examples/android/RunAnywhereAI/app/src/main/res/values/colors.xml new file mode 100644 index 000000000..21c847cd4 --- /dev/null +++ b/examples/android/RunAnywhereAI/app/src/main/res/values/colors.xml @@ -0,0 +1,11 @@ + + + #FFBB86FC + #FFC03D00 + #FFB33800 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + #ffffff + diff --git a/examples/android/RunAnywhereAI/app/src/main/res/values/strings.xml b/examples/android/RunAnywhereAI/app/src/main/res/values/strings.xml new file mode 100644 index 000000000..7474c39a6 --- /dev/null +++ b/examples/android/RunAnywhereAI/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + RunAnywhere + diff --git a/examples/android/RunAnywhereAI/app/src/main/res/values/themes.xml b/examples/android/RunAnywhereAI/app/src/main/res/values/themes.xml new file mode 100644 index 000000000..909ccc81e --- /dev/null +++ b/examples/android/RunAnywhereAI/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + + + + + diff --git a/examples/flutter/RunAnywhereAI/android/app/src/main/res/values/colors.xml b/examples/flutter/RunAnywhereAI/android/app/src/main/res/values/colors.xml new file mode 100644 index 000000000..cd6c0ad57 --- /dev/null +++ b/examples/flutter/RunAnywhereAI/android/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #2196F3 + diff --git a/examples/flutter/RunAnywhereAI/android/app/src/main/res/values/styles.xml b/examples/flutter/RunAnywhereAI/android/app/src/main/res/values/styles.xml new file mode 100644 index 000000000..7819bd331 --- /dev/null +++ b/examples/flutter/RunAnywhereAI/android/app/src/main/res/values/styles.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/examples/flutter/RunAnywhereAI/android/app/src/profile/AndroidManifest.xml b/examples/flutter/RunAnywhereAI/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 000000000..399f6981d --- /dev/null +++ b/examples/flutter/RunAnywhereAI/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/examples/flutter/RunAnywhereAI/android/build.gradle b/examples/flutter/RunAnywhereAI/android/build.gradle new file mode 100644 index 000000000..f6ae56bde --- /dev/null +++ b/examples/flutter/RunAnywhereAI/android/build.gradle @@ -0,0 +1,31 @@ +buildscript { + ext.kotlin_version = '2.1.21' + repositories { + google() + mavenCentral() + } + + dependencies { + classpath 'com.android.tools.build:gradle:8.9.1' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +rootProject.buildDir = '../build' +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(':app') +} + +tasks.register("clean", Delete) { + delete rootProject.buildDir +} diff --git a/examples/flutter/RunAnywhereAI/android/gradle/wrapper/gradle-wrapper.properties b/examples/flutter/RunAnywhereAI/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..efdcc4ace --- /dev/null +++ b/examples/flutter/RunAnywhereAI/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip diff --git a/examples/flutter/RunAnywhereAI/android/gradlew b/examples/flutter/RunAnywhereAI/android/gradlew new file mode 100755 index 000000000..9d82f7891 --- /dev/null +++ b/examples/flutter/RunAnywhereAI/android/gradlew @@ -0,0 +1,160 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/examples/flutter/RunAnywhereAI/android/gradlew.bat b/examples/flutter/RunAnywhereAI/android/gradlew.bat new file mode 100755 index 000000000..8a0b282aa --- /dev/null +++ b/examples/flutter/RunAnywhereAI/android/gradlew.bat @@ -0,0 +1,90 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windowz variants + +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* +goto execute + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +set CMD_LINE_ARGS=%$ + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/examples/flutter/RunAnywhereAI/android/settings.gradle b/examples/flutter/RunAnywhereAI/android/settings.gradle new file mode 100644 index 000000000..b64bd3093 --- /dev/null +++ b/examples/flutter/RunAnywhereAI/android/settings.gradle @@ -0,0 +1,25 @@ +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + }() + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version "8.9.1" apply false + id "org.jetbrains.kotlin.android" version "2.1.21" apply false +} + +include ":app" diff --git a/examples/flutter/RunAnywhereAI/ios/.gitignore b/examples/flutter/RunAnywhereAI/ios/.gitignore new file mode 100644 index 000000000..7a7f9873a --- /dev/null +++ b/examples/flutter/RunAnywhereAI/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/examples/flutter/RunAnywhereAI/ios/Flutter/AppFrameworkInfo.plist b/examples/flutter/RunAnywhereAI/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 000000000..1dc6cf765 --- /dev/null +++ b/examples/flutter/RunAnywhereAI/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 13.0 + + diff --git a/examples/flutter/RunAnywhereAI/ios/Flutter/Debug.xcconfig b/examples/flutter/RunAnywhereAI/ios/Flutter/Debug.xcconfig new file mode 100644 index 000000000..ec97fc6f3 --- /dev/null +++ b/examples/flutter/RunAnywhereAI/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/examples/flutter/RunAnywhereAI/ios/Flutter/Release.xcconfig b/examples/flutter/RunAnywhereAI/ios/Flutter/Release.xcconfig new file mode 100644 index 000000000..c4855bfe2 --- /dev/null +++ b/examples/flutter/RunAnywhereAI/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/examples/flutter/RunAnywhereAI/ios/Podfile b/examples/flutter/RunAnywhereAI/ios/Podfile new file mode 100644 index 000000000..cb7f7c580 --- /dev/null +++ b/examples/flutter/RunAnywhereAI/ios/Podfile @@ -0,0 +1,62 @@ +# Define a global platform for your project +platform :ios, '14.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + # Use static linkage to properly include RACommons symbols from vendored xcframeworks + use_frameworks! :linkage => :static + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + target.build_configurations.each do |config| + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '14.0' + + # Enable microphone and speech recognition permissions for permission_handler + config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [ + '$(inherited)', + 'PERMISSION_MICROPHONE=1', + 'PERMISSION_SPEECH_RECOGNIZER=1', + ] + end + end + + # NOTE: We rely on -all_load (set in runanywhere.podspec) to include all symbols + # from static frameworks. The symbols will be in the binary (visible via nm) + # but marked as local (lowercase 't'). Flutter's DynamicLibrary.executable() + # can still find these symbols because it operates at a lower level than dlsym. + # + # DO NOT use -exported_symbols_list here as it breaks Flutter's debug mode + # by hiding the debug dylib entry point. +end diff --git a/examples/flutter/RunAnywhereAI/ios/Runner.xcodeproj/project.pbxproj b/examples/flutter/RunAnywhereAI/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 000000000..86437972e --- /dev/null +++ b/examples/flutter/RunAnywhereAI/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,733 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 34DC9662B78BF09F26B26C86 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2B46F215DA8C1C3B61BB0B58 /* Pods_Runner.framework */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + 9AEEF6DDA5406FDB7D2810CE /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 46C1F1B4C19BB60688523CFF /* Pods_RunnerTests.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 0E70E1D150EFDCD5FF40B068 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 147EE15BA047BD73097B0714 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 2B46F215DA8C1C3B61BB0B58 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 46C1F1B4C19BB60688523CFF /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + AA862A82DFDC10910BB3D3C3 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + C50EC0890556D371E87C3917 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + F3F10EFC37B57C2BA2AEB16B /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + F40BAAAD17FFD94C3CC6FF7D /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 34DC9662B78BF09F26B26C86 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DB874151B29C24C32E234577 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 9AEEF6DDA5406FDB7D2810CE /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 03394D8EAF6C0425E9FA7239 /* Pods */ = { + isa = PBXGroup; + children = ( + 147EE15BA047BD73097B0714 /* Pods-Runner.debug.xcconfig */, + C50EC0890556D371E87C3917 /* Pods-Runner.release.xcconfig */, + F3F10EFC37B57C2BA2AEB16B /* Pods-Runner.profile.xcconfig */, + AA862A82DFDC10910BB3D3C3 /* Pods-RunnerTests.debug.xcconfig */, + 0E70E1D150EFDCD5FF40B068 /* Pods-RunnerTests.release.xcconfig */, + F40BAAAD17FFD94C3CC6FF7D /* Pods-RunnerTests.profile.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + 03394D8EAF6C0425E9FA7239 /* Pods */, + CCECFAA0F67CC234D05062BE /* Frameworks */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; + CCECFAA0F67CC234D05062BE /* Frameworks */ = { + isa = PBXGroup; + children = ( + 2B46F215DA8C1C3B61BB0B58 /* Pods_Runner.framework */, + 46C1F1B4C19BB60688523CFF /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 7CE79FA34DAC0E794CC608F7 /* [CP] Check Pods Manifest.lock */, + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + DB874151B29C24C32E234577 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 61F2EF2DD85919CAB43E34FC /* [CP] Check Pods Manifest.lock */, + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + 40C047688BE1DBAA5F783B5F /* [CP] Copy Pods Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 40C047688BE1DBAA5F783B5F /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + 61F2EF2DD85919CAB43E34FC /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 7CE79FA34DAC0E794CC608F7 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = AFAL2647U9; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + OTHER_LDFLAGS = "$(inherited)"; + PRODUCT_BUNDLE_IDENTIFIER = com.runanywhere.runanywhereAi; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = AA862A82DFDC10910BB3D3C3 /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.runanywhere.runanywhereAi.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 0E70E1D150EFDCD5FF40B068 /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.runanywhere.runanywhereAi.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F40BAAAD17FFD94C3CC6FF7D /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.runanywhere.runanywhereAi.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = AFAL2647U9; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + OTHER_LDFLAGS = "$(inherited)"; + PRODUCT_BUNDLE_IDENTIFIER = com.runanywhere.runanywhereAi; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = AFAL2647U9; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + OTHER_LDFLAGS = "$(inherited)"; + PRODUCT_BUNDLE_IDENTIFIER = com.runanywhere.runanywhereAi; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/examples/flutter/RunAnywhereAI/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/examples/flutter/RunAnywhereAI/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..919434a62 --- /dev/null +++ b/examples/flutter/RunAnywhereAI/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/examples/flutter/RunAnywhereAI/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/examples/flutter/RunAnywhereAI/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000..18d981003 --- /dev/null +++ b/examples/flutter/RunAnywhereAI/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/examples/flutter/RunAnywhereAI/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/examples/flutter/RunAnywhereAI/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 000000000..f9b0d7c5e --- /dev/null +++ b/examples/flutter/RunAnywhereAI/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/examples/flutter/RunAnywhereAI/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/examples/flutter/RunAnywhereAI/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 000000000..e3773d42e --- /dev/null +++ b/examples/flutter/RunAnywhereAI/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/flutter/RunAnywhereAI/ios/Runner.xcworkspace/contents.xcworkspacedata b/examples/flutter/RunAnywhereAI/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..21a3cc14c --- /dev/null +++ b/examples/flutter/RunAnywhereAI/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/examples/flutter/RunAnywhereAI/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/examples/flutter/RunAnywhereAI/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000..18d981003 --- /dev/null +++ b/examples/flutter/RunAnywhereAI/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/examples/flutter/RunAnywhereAI/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/examples/flutter/RunAnywhereAI/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 000000000..f9b0d7c5e --- /dev/null +++ b/examples/flutter/RunAnywhereAI/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/examples/flutter/RunAnywhereAI/ios/Runner/AppDelegate.swift b/examples/flutter/RunAnywhereAI/ios/Runner/AppDelegate.swift new file mode 100644 index 000000000..5944f72f3 --- /dev/null +++ b/examples/flutter/RunAnywhereAI/ios/Runner/AppDelegate.swift @@ -0,0 +1,126 @@ +import UIKit +import Flutter + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + guard let controller = window?.rootViewController as? FlutterViewController else { + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + let platformChannel = FlutterMethodChannel(name: "com.runanywhere.sdk/native", + binaryMessenger: controller.binaryMessenger) + + platformChannel.setMethodCallHandler({ + (call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in + switch call.method { + case "configureAudioSession": + let mode = (call.arguments as? [String: Any])?["mode"] as? String ?? "recording" + self.configureAudioSession(mode: mode) + result(nil) + case "activateAudioSession": + self.activateAudioSession() + result(nil) + case "deactivateAudioSession": + self.deactivateAudioSession() + result(nil) + case "requestMicrophonePermission": + self.requestMicrophonePermission(result: result) + case "hasMicrophonePermission": + result(self.hasMicrophonePermission()) + case "getDeviceCapabilities": + result(self.getDeviceCapabilities()) + case "loadNativeModel": + let args = call.arguments as? [String: Any] + let modelId = args?["modelId"] as? String + let modelPath = args?["modelPath"] as? String + if let modelId = modelId, let modelPath = modelPath { + self.loadNativeModel(modelId: modelId, modelPath: modelPath, result: result) + } else { + result(FlutterError(code: "INVALID_ARGUMENT", message: "Missing modelId or modelPath", details: nil)) + } + case "unloadNativeModel": + let args = call.arguments as? [String: Any] + let modelId = args?["modelId"] as? String + if let modelId = modelId { + self.unloadNativeModel(modelId: modelId) + result(nil) + } else { + result(FlutterError(code: "INVALID_ARGUMENT", message: "Missing modelId", details: nil)) + } + default: + result(FlutterMethodNotImplemented) + } + }) + + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + private func configureAudioSession(mode: String) { + let audioSession = AVAudioSession.sharedInstance() + do { + switch mode { + case "recording": + try audioSession.setCategory(.record, mode: .default, options: [.allowBluetooth]) + case "playback": + try audioSession.setCategory(.playback, mode: .default, options: [.allowBluetooth, .allowAirPlay]) + case "conversation": + try audioSession.setCategory(.playAndRecord, mode: .voiceChat, options: [.defaultToSpeaker, .allowBluetooth, .duckOthers]) + default: + break + } + } catch { + print("Failed to configure audio session: \(error)") + } + } + + private func activateAudioSession() { + let audioSession = AVAudioSession.sharedInstance() + do { + try audioSession.setActive(true) + } catch { + print("Failed to activate audio session: \(error)") + } + } + + private func deactivateAudioSession() { + let audioSession = AVAudioSession.sharedInstance() + do { + try audioSession.setActive(false) + } catch { + print("Failed to deactivate audio session: \(error)") + } + } + + private func requestMicrophonePermission(result: @escaping FlutterResult) { + AVAudioSession.sharedInstance().requestRecordPermission { granted in + result(granted) + } + } + + private func hasMicrophonePermission() -> Bool { + return AVAudioSession.sharedInstance().recordPermission == .granted + } + + private func getDeviceCapabilities() -> [String: Any] { + let processInfo = ProcessInfo.processInfo + return [ + "totalMemory": processInfo.physicalMemory, + "availableProcessors": processInfo.processorCount, + ] + } + + private func loadNativeModel(modelId: String, modelPath: String, result: @escaping FlutterResult) { + // TODO: Implement native model loading (could bridge to Swift SDK) + result(true) + } + + private func unloadNativeModel(modelId: String) { + // TODO: Implement native model unloading + } +} + +import AVFoundation diff --git a/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..d36b1fab2 --- /dev/null +++ b/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 000000000..dc9ada472 Binary files /dev/null and b/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 000000000..7353c41ec Binary files /dev/null and b/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 000000000..797d452e4 Binary files /dev/null and b/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 000000000..6ed2d933e Binary files /dev/null and b/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 000000000..4cd7b0099 Binary files /dev/null and b/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 000000000..fe730945a Binary files /dev/null and b/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 000000000..321773cd8 Binary files /dev/null and b/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 000000000..797d452e4 Binary files /dev/null and b/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 000000000..502f463a9 Binary files /dev/null and b/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 000000000..0ec303439 Binary files /dev/null and b/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 000000000..0ec303439 Binary files /dev/null and b/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 000000000..e9f5fea27 Binary files /dev/null and b/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 000000000..84ac32ae7 Binary files /dev/null and b/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 000000000..8953cba09 Binary files /dev/null and b/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 000000000..0467bf12a Binary files /dev/null and b/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 000000000..0bedcf2fd --- /dev/null +++ b/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 000000000..9da19eaca Binary files /dev/null and b/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 000000000..9da19eaca Binary files /dev/null and b/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 000000000..9da19eaca Binary files /dev/null and b/examples/flutter/RunAnywhereAI/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/examples/flutter/RunAnywhereAI/ios/Runner/Base.lproj/LaunchScreen.storyboard b/examples/flutter/RunAnywhereAI/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 000000000..f2e259c7c --- /dev/null +++ b/examples/flutter/RunAnywhereAI/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/flutter/RunAnywhereAI/ios/Runner/Base.lproj/Main.storyboard b/examples/flutter/RunAnywhereAI/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 000000000..f3c28516f --- /dev/null +++ b/examples/flutter/RunAnywhereAI/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/flutter/RunAnywhereAI/ios/Runner/GeneratedPluginRegistrant.h b/examples/flutter/RunAnywhereAI/ios/Runner/GeneratedPluginRegistrant.h new file mode 100644 index 000000000..7a8909271 --- /dev/null +++ b/examples/flutter/RunAnywhereAI/ios/Runner/GeneratedPluginRegistrant.h @@ -0,0 +1,19 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GeneratedPluginRegistrant_h +#define GeneratedPluginRegistrant_h + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface GeneratedPluginRegistrant : NSObject ++ (void)registerWithRegistry:(NSObject*)registry; +@end + +NS_ASSUME_NONNULL_END +#endif /* GeneratedPluginRegistrant_h */ diff --git a/examples/flutter/RunAnywhereAI/ios/Runner/GeneratedPluginRegistrant.m b/examples/flutter/RunAnywhereAI/ios/Runner/GeneratedPluginRegistrant.m new file mode 100644 index 000000000..500cb4c19 --- /dev/null +++ b/examples/flutter/RunAnywhereAI/ios/Runner/GeneratedPluginRegistrant.m @@ -0,0 +1,112 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#import "GeneratedPluginRegistrant.h" + +#if __has_include() +#import +#else +@import audioplayers_darwin; +#endif + +#if __has_include() +#import +#else +@import device_info_plus; +#endif + +#if __has_include() +#import +#else +@import flutter_secure_storage; +#endif + +#if __has_include() +#import +#else +@import flutter_tts; +#endif + +#if __has_include() +#import +#else +@import package_info_plus; +#endif + +#if __has_include() +#import +#else +@import path_provider_foundation; +#endif + +#if __has_include() +#import +#else +@import permission_handler_apple; +#endif + +#if __has_include() +#import +#else +@import record_ios; +#endif + +#if __has_include() +#import +#else +@import runanywhere; +#endif + +#if __has_include() +#import +#else +@import runanywhere_llamacpp; +#endif + +#if __has_include() +#import +#else +@import runanywhere_onnx; +#endif + +#if __has_include() +#import +#else +@import shared_preferences_foundation; +#endif + +#if __has_include() +#import +#else +@import sqflite_darwin; +#endif + +#if __has_include() +#import +#else +@import url_launcher_ios; +#endif + +@implementation GeneratedPluginRegistrant + ++ (void)registerWithRegistry:(NSObject*)registry { + [AudioplayersDarwinPlugin registerWithRegistrar:[registry registrarForPlugin:@"AudioplayersDarwinPlugin"]]; + [FPPDeviceInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPDeviceInfoPlusPlugin"]]; + [FlutterSecureStoragePlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterSecureStoragePlugin"]]; + [FlutterTtsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterTtsPlugin"]]; + [FPPPackageInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPPackageInfoPlusPlugin"]]; + [PathProviderPlugin registerWithRegistrar:[registry registrarForPlugin:@"PathProviderPlugin"]]; + [PermissionHandlerPlugin registerWithRegistrar:[registry registrarForPlugin:@"PermissionHandlerPlugin"]]; + [RecordIosPlugin registerWithRegistrar:[registry registrarForPlugin:@"RecordIosPlugin"]]; + [RunAnywherePlugin registerWithRegistrar:[registry registrarForPlugin:@"RunAnywherePlugin"]]; + [LlamaCppPlugin registerWithRegistrar:[registry registrarForPlugin:@"LlamaCppPlugin"]]; + [OnnxPlugin registerWithRegistrar:[registry registrarForPlugin:@"OnnxPlugin"]]; + [SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]]; + [SqflitePlugin registerWithRegistrar:[registry registrarForPlugin:@"SqflitePlugin"]]; + [URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]]; +} + +@end diff --git a/examples/flutter/RunAnywhereAI/ios/Runner/Info.plist b/examples/flutter/RunAnywhereAI/ios/Runner/Info.plist new file mode 100644 index 000000000..820be29df --- /dev/null +++ b/examples/flutter/RunAnywhereAI/ios/Runner/Info.plist @@ -0,0 +1,53 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + RunAnywhere AI + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + runanywhere_ai + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + NSMicrophoneUsageDescription + This app needs microphone access for voice assistant features. + NSSpeechRecognitionUsageDescription + This app needs speech recognition for voice assistant features. + + diff --git a/examples/flutter/RunAnywhereAI/ios/Runner/Runner-Bridging-Header.h b/examples/flutter/RunAnywhereAI/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 000000000..308a2a560 --- /dev/null +++ b/examples/flutter/RunAnywhereAI/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/examples/flutter/RunAnywhereAI/ios/RunnerTests/RunnerTests.swift b/examples/flutter/RunAnywhereAI/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 000000000..86a7c3b1b --- /dev/null +++ b/examples/flutter/RunAnywhereAI/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/examples/flutter/RunAnywhereAI/lib/app/content_view.dart b/examples/flutter/RunAnywhereAI/lib/app/content_view.dart new file mode 100644 index 000000000..19b3dabe4 --- /dev/null +++ b/examples/flutter/RunAnywhereAI/lib/app/content_view.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import 'package:runanywhere_ai/core/design_system/app_colors.dart'; +import 'package:runanywhere_ai/features/chat/chat_interface_view.dart'; +import 'package:runanywhere_ai/features/settings/combined_settings_view.dart'; +import 'package:runanywhere_ai/features/voice/speech_to_text_view.dart'; +import 'package:runanywhere_ai/features/voice/text_to_speech_view.dart'; +import 'package:runanywhere_ai/features/voice/voice_assistant_view.dart'; + +/// ContentView (mirroring iOS ContentView.swift) +/// +/// Main tab-based navigation for the app. +/// Tabs exactly match iOS: Chat, Transcribe (STT), Speak (TTS), Voice, Settings +class ContentView extends StatefulWidget { + const ContentView({super.key}); + + @override + State createState() => _ContentViewState(); +} + +class _ContentViewState extends State { + int _selectedTab = 0; + + // Tab pages matching iOS structure exactly + final List _pages = const [ + ChatInterfaceView(), // Tab 0: Chat (LLM) + SpeechToTextView(), // Tab 1: Speech-to-Text (Transcribe) + TextToSpeechView(), // Tab 2: Text-to-Speech (Speak) + VoiceAssistantView(), // Tab 3: Voice Assistant (STT + LLM + TTS) + CombinedSettingsView(), // Tab 4: Settings (includes Storage) + ]; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: IndexedStack( + index: _selectedTab, + children: _pages, + ), + bottomNavigationBar: NavigationBar( + selectedIndex: _selectedTab, + indicatorColor: AppColors.primaryBlue.withValues(alpha: 0.2), + onDestinationSelected: (index) { + setState(() { + _selectedTab = index; + }); + }, + // Tab labels match iOS exactly + destinations: const [ + NavigationDestination( + icon: Icon(Icons.chat_bubble_outline), + selectedIcon: Icon(Icons.chat_bubble), + label: 'Chat', + ), + NavigationDestination( + icon: Icon(Icons.graphic_eq_outlined), + selectedIcon: Icon(Icons.graphic_eq), + label: 'Transcribe', + ), + NavigationDestination( + icon: Icon(Icons.volume_up_outlined), + selectedIcon: Icon(Icons.volume_up), + label: 'Speak', + ), + NavigationDestination( + icon: Icon(Icons.mic_none), + selectedIcon: Icon(Icons.mic), + label: 'Voice', + ), + NavigationDestination( + icon: Icon(Icons.settings_outlined), + selectedIcon: Icon(Icons.settings), + label: 'Settings', + ), + ], + ), + ); + } +} diff --git a/examples/flutter/RunAnywhereAI/lib/app/runanywhere_ai_app.dart b/examples/flutter/RunAnywhereAI/lib/app/runanywhere_ai_app.dart new file mode 100644 index 000000000..aa10da675 --- /dev/null +++ b/examples/flutter/RunAnywhereAI/lib/app/runanywhere_ai_app.dart @@ -0,0 +1,421 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:runanywhere/runanywhere.dart'; +import 'package:runanywhere_ai/app/content_view.dart'; +import 'package:runanywhere_ai/core/design_system/app_colors.dart'; +import 'package:runanywhere_ai/core/design_system/app_spacing.dart'; +import 'package:runanywhere_ai/core/services/model_manager.dart'; +import 'package:runanywhere_ai/core/utilities/constants.dart'; +import 'package:runanywhere_ai/core/utilities/keychain_helper.dart'; +import 'package:runanywhere_llamacpp/runanywhere_llamacpp.dart'; +import 'package:runanywhere_onnx/runanywhere_onnx.dart'; + +/// RunAnywhereAIApp (mirroring iOS RunAnywhereAIApp.swift) +/// +/// Main application entry point with SDK initialization. +class RunAnywhereAIApp extends StatefulWidget { + const RunAnywhereAIApp({super.key}); + + @override + State createState() => _RunAnywhereAIAppState(); +} + +class _RunAnywhereAIAppState extends State { + bool _isSDKInitialized = false; + Object? _initializationError; + String _initializationStatus = 'Initializing...'; + + @override + void initState() { + super.initState(); + // Defer SDK initialization until after the first frame renders + // This prevents blocking the main thread during app startup + // and allows the loading screen to display smoothly + WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited(_initializeSDK()); + }); + } + + /// Normalize base URL by adding https:// if no scheme is present + String _normalizeBaseURL(String url) { + final trimmed = url.trim(); + if (trimmed.isEmpty) return trimmed; + if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { + return trimmed; + } + return 'https://$trimmed'; + } + + Future _initializeSDK() async { + final stopwatch = Stopwatch()..start(); + + try { + setState(() { + _initializationStatus = 'Initializing SDK...'; + }); + + debugPrint('🎯 Initializing SDK...'); + + // Yield to allow UI to render before heavy work + await Future.delayed(Duration.zero); + + // Check for custom API configuration (stored via Settings screen) + final customApiKey = await KeychainHelper.loadString(KeychainKeys.apiKey); + final customBaseURL = + await KeychainHelper.loadString(KeychainKeys.baseURL); + final hasCustomConfig = customApiKey != null && + customApiKey.isNotEmpty && + customBaseURL != null && + customBaseURL.isNotEmpty; + + if (hasCustomConfig) { + final normalizedURL = _normalizeBaseURL(customBaseURL); + debugPrint('🔧 Found custom API configuration'); + debugPrint(' Base URL: $normalizedURL'); + + // Custom configuration mode - use stored API key and base URL + await RunAnywhere.initialize( + apiKey: customApiKey, + baseURL: normalizedURL, + environment: SDKEnvironment.production, + ); + debugPrint('✅ SDK initialized with CUSTOM configuration (production)'); + } else { + // Initialize SDK in development mode (default) + await RunAnywhere.initialize(); + debugPrint('✅ SDK initialized in DEVELOPMENT mode'); + } + + // Yield to allow UI to update between heavy operations + await Future.delayed(Duration.zero); + + setState(() { + _initializationStatus = 'Registering modules...'; + }); + + // Register modules and models (matching iOS pattern) + await _registerModulesAndModels(); + + // Yield before model discovery + await Future.delayed(Duration.zero); + + setState(() { + _initializationStatus = 'Discovering models...'; + }); + + stopwatch.stop(); + debugPrint( + '⚡ SDK initialization completed in ${stopwatch.elapsedMilliseconds}ms'); + debugPrint( + '🎯 SDK Status: ${RunAnywhere.isActive ? "Active" : "Inactive"}'); + debugPrint( + '🔧 Environment: ${RunAnywhere.getCurrentEnvironment()?.description ?? "Unknown"}'); + debugPrint('📱 Services will initialize on first API call'); + + // Refresh model manager state (runs model discovery) + await ModelManager.shared.refresh(); + + setState(() { + _isSDKInitialized = true; + }); + + debugPrint( + '💡 Models registered, user can now download and select models'); + } catch (e) { + stopwatch.stop(); + debugPrint( + '❌ SDK initialization failed after ${stopwatch.elapsedMilliseconds}ms: $e'); + setState(() { + _initializationError = e; + }); + } + } + + /// Register modules with their associated models + /// Each module explicitly owns its models - the framework is determined by the module + /// Matches iOS registerModulesAndModels pattern exactly + Future _registerModulesAndModels() async { + debugPrint('📦 Registering modules with their models...'); + + // LlamaCPP module with LLM models + // Using explicit IDs ensures models are recognized after download across app restarts + await LlamaCpp.register(); + + // Yield after heavy backend registration + await Future.delayed(Duration.zero); + + LlamaCpp.addModel( + id: 'smollm2-360m-q8_0', + name: 'SmolLM2 360M Q8_0', + url: + 'https://huggingface.co/prithivMLmods/SmolLM2-360M-GGUF/resolve/main/SmolLM2-360M.Q8_0.gguf', + memoryRequirement: 500000000, + ); + LlamaCpp.addModel( + id: 'llama-2-7b-chat-q4_k_m', + name: 'Llama 2 7B Chat Q4_K_M', + url: + 'https://huggingface.co/TheBloke/Llama-2-7B-Chat-GGUF/resolve/main/llama-2-7b-chat.Q4_K_M.gguf', + memoryRequirement: 4000000000, + ); + LlamaCpp.addModel( + id: 'mistral-7b-instruct-q4_k_m', + name: 'Mistral 7B Instruct Q4_K_M', + url: + 'https://huggingface.co/TheBloke/Mistral-7B-Instruct-v0.1-GGUF/resolve/main/mistral-7b-instruct-v0.1.Q4_K_M.gguf', + memoryRequirement: 4000000000, + ); + LlamaCpp.addModel( + id: 'qwen2.5-0.5b-instruct-q6_k', + name: 'Qwen 2.5 0.5B Instruct Q6_K', + url: + 'https://huggingface.co/Triangle104/Qwen2.5-0.5B-Instruct-Q6_K-GGUF/resolve/main/qwen2.5-0.5b-instruct-q6_k.gguf', + memoryRequirement: 600000000, + ); + LlamaCpp.addModel( + id: 'lfm2-350m-q4_k_m', + name: 'LiquidAI LFM2 350M Q4_K_M', + url: + 'https://huggingface.co/LiquidAI/LFM2-350M-GGUF/resolve/main/LFM2-350M-Q4_K_M.gguf', + memoryRequirement: 250000000, + ); + LlamaCpp.addModel( + id: 'lfm2-350m-q8_0', + name: 'LiquidAI LFM2 350M Q8_0', + url: + 'https://huggingface.co/LiquidAI/LFM2-350M-GGUF/resolve/main/LFM2-350M-Q8_0.gguf', + memoryRequirement: 400000000, + ); + debugPrint('✅ LlamaCPP module registered with LLM models'); + + // Yield between module registrations + await Future.delayed(Duration.zero); + + // ONNX module with STT and TTS models + // Using tar.gz format hosted on RunanywhereAI/sherpa-onnx for fast native extraction + // Using explicit IDs ensures models are recognized after download across app restarts + await Onnx.register(); + + // Yield after heavy backend registration + await Future.delayed(Duration.zero); + + // STT Models (Sherpa-ONNX Whisper) + Onnx.addModel( + id: 'sherpa-onnx-whisper-tiny.en', + name: 'Sherpa Whisper Tiny (ONNX)', + url: + 'https://github.com/RunanywhereAI/sherpa-onnx/releases/download/runanywhere-models-v1/sherpa-onnx-whisper-tiny.en.tar.gz', + modality: ModelCategory.speechRecognition, + memoryRequirement: 75000000, + ); + Onnx.addModel( + id: 'sherpa-onnx-whisper-small.en', + name: 'Sherpa Whisper Small (ONNX)', + url: + 'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-whisper-small.en.tar.bz2', + modality: ModelCategory.speechRecognition, + memoryRequirement: 250000000, + ); + + // TTS Models (Piper VITS) + Onnx.addModel( + id: 'vits-piper-en_US-lessac-medium', + name: 'Piper TTS (US English - Medium)', + url: + 'https://github.com/RunanywhereAI/sherpa-onnx/releases/download/runanywhere-models-v1/vits-piper-en_US-lessac-medium.tar.gz', + modality: ModelCategory.speechSynthesis, + memoryRequirement: 65000000, + ); + Onnx.addModel( + id: 'vits-piper-en_GB-alba-medium', + name: 'Piper TTS (British English)', + url: + 'https://github.com/RunanywhereAI/sherpa-onnx/releases/download/runanywhere-models-v1/vits-piper-en_GB-alba-medium.tar.gz', + modality: ModelCategory.speechSynthesis, + memoryRequirement: 65000000, + ); + debugPrint('✅ ONNX module registered with STT/TTS models'); + + debugPrint('🎉 All modules and models registered'); + } + + @override + Widget build(BuildContext context) { + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: ModelManager.shared), + ], + child: MaterialApp( + title: 'RunAnywhere AI', + debugShowCheckedModeBanner: false, + theme: ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: AppColors.primaryBlue, + brightness: Brightness.light, + ), + useMaterial3: true, + appBarTheme: const AppBarTheme( + centerTitle: true, + elevation: 0, + ), + navigationBarTheme: NavigationBarThemeData( + indicatorColor: AppColors.primaryBlue.withValues(alpha: 0.2), + ), + ), + darkTheme: ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: AppColors.primaryBlue, + brightness: Brightness.dark, + ), + useMaterial3: true, + appBarTheme: const AppBarTheme( + centerTitle: true, + elevation: 0, + ), + ), + themeMode: ThemeMode.system, + home: _buildHome(), + ), + ); + } + + Widget _buildHome() { + if (_isSDKInitialized) { + return const ContentView(); + } else if (_initializationError != null) { + return _InitializationErrorView( + error: _initializationError!, + onRetry: () => unawaited(_initializeSDK()), + ); + } else { + return _InitializationLoadingView(status: _initializationStatus); + } + } +} + +/// Loading view shown during SDK initialization +class _InitializationLoadingView extends StatefulWidget { + final String status; + + const _InitializationLoadingView({required this.status}); + + @override + State<_InitializationLoadingView> createState() => + _InitializationLoadingViewState(); +} + +class _InitializationLoadingViewState extends State<_InitializationLoadingView> + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _scaleAnimation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(seconds: 2), + vsync: this, + ); + unawaited(_controller.repeat(reverse: true)); + + _scaleAnimation = Tween(begin: 0.9, end: 1.1).animate( + CurvedAnimation(parent: _controller, curve: Curves.easeInOut), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Animated brain icon (matching iOS) + ScaleTransition( + scale: _scaleAnimation, + child: const Icon( + Icons.psychology, + size: AppSpacing.iconHuge, + color: AppColors.primaryPurple, + ), + ), + const SizedBox(height: AppSpacing.xLarge), + const CircularProgressIndicator(), + const SizedBox(height: AppSpacing.large), + Text( + widget.status, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: AppSpacing.smallMedium), + Text( + 'RunAnywhere AI', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppColors.textSecondary(context), + ), + ), + ], + ), + ), + ); + } +} + +/// Error view shown when SDK initialization fails +class _InitializationErrorView extends StatelessWidget { + final Object error; + final VoidCallback onRetry; + + const _InitializationErrorView({ + required this.error, + required this.onRetry, + }); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.xLarge), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.error_outline, + size: AppSpacing.iconXLarge, + color: AppColors.primaryRed, + ), + const SizedBox(height: AppSpacing.large), + Text( + 'Initialization Failed', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: AppSpacing.smallMedium), + Text( + error.toString(), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppColors.textSecondary(context), + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: AppSpacing.xLarge), + FilledButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh), + label: const Text('Retry'), + ), + ], + ), + ), + ), + ); + } +} diff --git a/examples/flutter/RunAnywhereAI/lib/core/design_system/app_colors.dart b/examples/flutter/RunAnywhereAI/lib/core/design_system/app_colors.dart new file mode 100644 index 000000000..d95f9481b --- /dev/null +++ b/examples/flutter/RunAnywhereAI/lib/core/design_system/app_colors.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; + +/// App Colors (mirroring iOS AppColors.swift) +class AppColors { + // MARK: - Semantic Colors + static Color get primaryAccent => Colors.blue; + static const Color primaryBlue = Colors.blue; + static const Color primaryGreen = Colors.green; + static const Color primaryRed = Colors.red; + static const Color primaryOrange = Colors.orange; + static const Color primaryPurple = Colors.purple; + + // MARK: - Text Colors + static Color textPrimary(BuildContext context) => + Theme.of(context).textTheme.bodyLarge?.color ?? Colors.black; + static Color textSecondary(BuildContext context) => + (Theme.of(context).textTheme.bodyMedium?.color ?? Colors.grey) + .withValues(alpha: 0.6); + static const Color textWhite = Colors.white; + + // MARK: - Background Colors + static Color backgroundPrimary(BuildContext context) => + Theme.of(context).scaffoldBackgroundColor; + static Color backgroundSecondary(BuildContext context) => + Theme.of(context).cardColor; + static Color backgroundTertiary(BuildContext context) => + Theme.of(context).colorScheme.surface; + static Color backgroundGrouped(BuildContext context) => + Theme.of(context).colorScheme.surfaceContainerHighest; + static Color backgroundGray5(BuildContext context) => + Theme.of(context).brightness == Brightness.dark + ? Colors.grey.shade800 + : Colors.grey.shade200; + static Color backgroundGray6(BuildContext context) => + Theme.of(context).brightness == Brightness.dark + ? Colors.grey.shade900 + : Colors.grey.shade100; + + // MARK: - Separator + static Color separator(BuildContext context) => + Theme.of(context).dividerColor; + + // MARK: - Badge/Tag colors + static Color get badgeBlue => Colors.blue.withValues(alpha: 0.2); + static Color get badgeGreen => Colors.green.withValues(alpha: 0.2); + static Color get badgePurple => Colors.purple.withValues(alpha: 0.2); + static Color get badgeOrange => Colors.orange.withValues(alpha: 0.2); + static Color get badgeRed => Colors.red.withValues(alpha: 0.2); + static Color get badgeGray => Colors.grey.withValues(alpha: 0.2); + + // MARK: - Model info colors + static Color get modelFrameworkBg => Colors.blue.withValues(alpha: 0.1); + static Color get modelThinkingBg => Colors.purple.withValues(alpha: 0.1); + + // MARK: - Chat bubble colors + static Color get userBubbleGradientStart => Colors.blue; + static Color get userBubbleGradientEnd => Colors.blue.withValues(alpha: 0.9); + static Color assistantBubbleBg(BuildContext context) => + backgroundGray5(context); + + // MARK: - Status colors + static const Color statusGreen = Colors.green; + static const Color statusOrange = Colors.orange; + static const Color statusRed = Colors.red; + static const Color statusGray = Colors.grey; + static const Color statusBlue = Colors.blue; + static const Color statusPurple = Colors.purple; + + // MARK: - Shadow colors + static Color get shadowDefault => Colors.black.withValues(alpha: 0.1); + static Color get shadowLight => Colors.black.withValues(alpha: 0.1); + static Color get shadowMedium => Colors.black.withValues(alpha: 0.12); + static Color get shadowDark => Colors.black.withValues(alpha: 0.3); + + // MARK: - Overlay colors + static Color get overlayLight => Colors.black.withValues(alpha: 0.3); + static Color get overlayMedium => Colors.black.withValues(alpha: 0.4); + + // MARK: - Border colors + static Color get borderLight => Colors.white.withValues(alpha: 0.3); + static Color get borderMedium => Colors.black.withValues(alpha: 0.05); +} diff --git a/examples/flutter/RunAnywhereAI/lib/core/design_system/app_spacing.dart b/examples/flutter/RunAnywhereAI/lib/core/design_system/app_spacing.dart new file mode 100644 index 000000000..0a792d9fe --- /dev/null +++ b/examples/flutter/RunAnywhereAI/lib/core/design_system/app_spacing.dart @@ -0,0 +1,105 @@ +/// App Spacing (mirroring iOS AppSpacing.swift) +class AppSpacing { + // MARK: - Padding values + static const double xxSmall = 2; + static const double xSmall = 4; + static const double small = 6; + static const double smallMedium = 8; + static const double medium = 10; + static const double mediumLarge = 12; + static const double regular = 14; + static const double large = 16; + static const double xLarge = 20; + static const double xxLarge = 30; + static const double xxxLarge = 40; + + // MARK: - Specific padding values + static const double padding4 = 4; + static const double padding6 = 6; + static const double padding8 = 8; + static const double padding9 = 9; + static const double padding10 = 10; + static const double padding12 = 12; + static const double padding14 = 14; + static const double padding15 = 15; + static const double padding16 = 16; + static const double padding20 = 20; + static const double padding24 = 24; + static const double padding30 = 30; + static const double padding32 = 32; + static const double padding40 = 40; + static const double padding60 = 60; + static const double padding100 = 100; + + // MARK: - Icon sizes + static const double iconSmall = 8; + static const double iconRegular = 18; + static const double iconMedium = 28; + static const double iconLarge = 48; + static const double iconXLarge = 60; + static const double iconXXLarge = 72; + static const double iconHuge = 80; + + // MARK: - Button sizes + static const double buttonHeightSmall = 28; + static const double buttonHeightRegular = 44; + static const double buttonHeightLarge = 72; + + // MARK: - Corner radius + static const double cornerRadiusSmall = 4; + static const double cornerRadiusMedium = 6; + static const double cornerRadiusRegular = 8; + static const double cornerRadiusLarge = 10; + static const double cornerRadiusXLarge = 12; + static const double cornerRadiusXXLarge = 14; + static const double cornerRadiusCard = 16; + static const double cornerRadiusBubble = 18; + static const double cornerRadiusModal = 20; + + // MARK: - Frame sizes + static const double minFrameHeight = 150; + static const double maxFrameHeight = 150; + + // MARK: - Stroke widths + static const double strokeThin = 0.5; + static const double strokeRegular = 1.0; + static const double strokeMedium = 2.0; + + // MARK: - Shadow radius + static const double shadowSmall = 2; + static const double shadowMedium = 3; + static const double shadowLarge = 4; + static const double shadowXLarge = 10; +} + +/// Layout Constants (mirroring iOS AppLayout) +class AppLayout { + // MARK: - macOS specific (for desktop Flutter) + static const double macOSMinWidth = 400; + static const double macOSIdealWidth = 600; + static const double macOSMaxWidth = 900; + static const double macOSMinHeight = 300; + static const double macOSIdealHeight = 500; + static const double macOSMaxHeight = 800; + + // MARK: - Content width limits + static const double maxContentWidth = 800; + static const double maxContentWidthLarge = 1000; + static const double maxContentWidthXLarge = 1200; + + // MARK: - Sheet sizes + static const double sheetMinWidth = 500; + static const double sheetIdealWidth = 600; + static const double sheetMaxWidth = 700; + static const double sheetMinHeight = 400; + static const double sheetIdealHeight = 500; + static const double sheetMaxHeight = 600; + + // MARK: - Animation durations + static const Duration animationFast = Duration(milliseconds: 250); + static const Duration animationRegular = Duration(milliseconds: 300); + static const Duration animationSlow = Duration(milliseconds: 500); + static const Duration animationVerySlow = Duration(milliseconds: 600); + static const Duration animationLoop = Duration(seconds: 1); + static const Duration animationLoopSlow = Duration(seconds: 2); +} diff --git a/examples/flutter/RunAnywhereAI/lib/core/design_system/typography.dart b/examples/flutter/RunAnywhereAI/lib/core/design_system/typography.dart new file mode 100644 index 000000000..d5b2928ed --- /dev/null +++ b/examples/flutter/RunAnywhereAI/lib/core/design_system/typography.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; + +/// App Typography (mirroring iOS Typography.swift) +class AppTypography { + // MARK: - Large titles and displays + static TextStyle largeTitle(BuildContext context) => + Theme.of(context).textTheme.displayLarge ?? const TextStyle(fontSize: 34); + static TextStyle title(BuildContext context) => + Theme.of(context).textTheme.titleLarge ?? const TextStyle(fontSize: 28); + static TextStyle title2(BuildContext context) => + Theme.of(context).textTheme.titleMedium ?? const TextStyle(fontSize: 22); + static TextStyle title3(BuildContext context) => + Theme.of(context).textTheme.titleSmall ?? const TextStyle(fontSize: 20); + + // MARK: - Headers + static TextStyle headline(BuildContext context) => + Theme.of(context).textTheme.headlineSmall ?? + const TextStyle(fontSize: 17, fontWeight: FontWeight.w600); + static TextStyle subheadline(BuildContext context) => + Theme.of(context).textTheme.bodyMedium ?? const TextStyle(fontSize: 15); + + // MARK: - Body text + static TextStyle body(BuildContext context) => + Theme.of(context).textTheme.bodyLarge ?? const TextStyle(fontSize: 17); + static TextStyle callout(BuildContext context) => + Theme.of(context).textTheme.bodyMedium ?? const TextStyle(fontSize: 16); + static TextStyle footnote(BuildContext context) => + Theme.of(context).textTheme.bodySmall ?? const TextStyle(fontSize: 13); + + // MARK: - Small text + static TextStyle caption(BuildContext context) => + Theme.of(context).textTheme.labelSmall ?? const TextStyle(fontSize: 12); + static TextStyle caption2(BuildContext context) => + Theme.of(context).textTheme.labelSmall ?? const TextStyle(fontSize: 11); + + // MARK: - Custom sizes (static, no context needed) + static const TextStyle system9 = TextStyle(fontSize: 9); + static const TextStyle system10 = TextStyle(fontSize: 10); + static const TextStyle system11 = TextStyle(fontSize: 11); + static const TextStyle system12 = TextStyle(fontSize: 12); + static const TextStyle system14 = TextStyle(fontSize: 14); + static const TextStyle system18 = TextStyle(fontSize: 18); + static const TextStyle system28 = TextStyle(fontSize: 28); + static const TextStyle system48 = TextStyle(fontSize: 48); + static const TextStyle system60 = TextStyle(fontSize: 60); + static const TextStyle system80 = TextStyle(fontSize: 80); + + // MARK: - With weights + static TextStyle headlineSemibold(BuildContext context) => + headline(context).copyWith(fontWeight: FontWeight.w600); + static TextStyle subheadlineMedium(BuildContext context) => + subheadline(context).copyWith(fontWeight: FontWeight.w500); + static TextStyle subheadlineSemibold(BuildContext context) => + subheadline(context).copyWith(fontWeight: FontWeight.w600); + static TextStyle captionMedium(BuildContext context) => + caption(context).copyWith(fontWeight: FontWeight.w500); + static TextStyle caption2Medium(BuildContext context) => + caption2(context).copyWith(fontWeight: FontWeight.w500); + static TextStyle caption2Bold(BuildContext context) => + caption2(context).copyWith(fontWeight: FontWeight.bold); + static TextStyle titleBold(BuildContext context) => + title(context).copyWith(fontWeight: FontWeight.bold); + static TextStyle title2Semibold(BuildContext context) => + title2(context).copyWith(fontWeight: FontWeight.w600); + static TextStyle title3Medium(BuildContext context) => + title3(context).copyWith(fontWeight: FontWeight.w500); + static TextStyle largeTitleBold(BuildContext context) => + largeTitle(context).copyWith(fontWeight: FontWeight.bold); + + // MARK: - Design variants + static const TextStyle monospaced = TextStyle( + fontFamily: 'monospace', + ); + static const TextStyle monospacedCaption = TextStyle( + fontSize: 9, + fontWeight: FontWeight.bold, + fontFamily: 'monospace', + ); + static const TextStyle rounded10 = TextStyle( + fontSize: 10, + fontWeight: FontWeight.w500, + ); + static const TextStyle rounded11 = TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + ); +} diff --git a/examples/flutter/RunAnywhereAI/lib/core/models/app_types.dart b/examples/flutter/RunAnywhereAI/lib/core/models/app_types.dart new file mode 100644 index 000000000..a5bd7a6e8 --- /dev/null +++ b/examples/flutter/RunAnywhereAI/lib/core/models/app_types.dart @@ -0,0 +1,111 @@ +// App Types (mirroring iOS AppTypes.swift) +// +// Contains core data models used throughout the app. + +/// System device information for displaying hardware capabilities +class SystemDeviceInfo { + final String modelName; + final String chipName; + final int totalMemory; + final int availableMemory; + final bool neuralEngineAvailable; + final String osVersion; + final String appVersion; + + const SystemDeviceInfo({ + this.modelName = '', + this.chipName = '', + this.totalMemory = 0, + this.availableMemory = 0, + this.neuralEngineAvailable = false, + this.osVersion = '', + this.appVersion = '', + }); + + SystemDeviceInfo copyWith({ + String? modelName, + String? chipName, + int? totalMemory, + int? availableMemory, + bool? neuralEngineAvailable, + String? osVersion, + String? appVersion, + }) { + return SystemDeviceInfo( + modelName: modelName ?? this.modelName, + chipName: chipName ?? this.chipName, + totalMemory: totalMemory ?? this.totalMemory, + availableMemory: availableMemory ?? this.availableMemory, + neuralEngineAvailable: + neuralEngineAvailable ?? this.neuralEngineAvailable, + osVersion: osVersion ?? this.osVersion, + appVersion: appVersion ?? this.appVersion, + ); + } +} + +/// Extension for formatting file sizes +extension FileSizeFormatter on int { + /// Formats bytes into human-readable file size string + String get formattedFileSize { + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + double size = toDouble(); + int unitIndex = 0; + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + + if (unitIndex == 0) { + return '${size.toInt()} ${units[unitIndex]}'; + } + return '${size.toStringAsFixed(1)} ${units[unitIndex]}'; + } +} + +// MessageRole is now provided by the RunAnywhere SDK +// import 'package:runanywhere/runanywhere.dart' show MessageRole; + +/// Completion status for message generation +enum CompletionStatus { + complete, + interrupted, + failed, + timeout, +} + +/// Generation mode for LLM inference +enum GenerationMode { + streaming, + nonStreaming, +} + +/// Routing policy for model selection +enum RoutingPolicy { + automatic, + deviceOnly, + preferDevice, + preferCloud, +} + +// ModelLoadState is now provided by the RunAnywhere SDK +// import 'package:runanywhere/runanywhere.dart' show ModelLoadState; +// Use AppModelLoadState for app-specific states to avoid conflict +enum AppModelLoadState { + notLoaded, + loading, + loaded, + failed, +} + +/// Voice session state for voice assistant +enum VoiceSessionState { + disconnected, + connecting, + connected, + listening, + processing, + speaking, + error, +} diff --git a/examples/flutter/RunAnywhereAI/lib/core/services/audio_player_service.dart b/examples/flutter/RunAnywhereAI/lib/core/services/audio_player_service.dart new file mode 100644 index 000000000..af28105c3 --- /dev/null +++ b/examples/flutter/RunAnywhereAI/lib/core/services/audio_player_service.dart @@ -0,0 +1,285 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:audioplayers/audioplayers.dart'; +import 'package:flutter/foundation.dart'; +import 'package:path_provider/path_provider.dart'; + +/// Audio Player Service +/// +/// Handles audio playback for Text-to-Speech functionality. +/// Uses the `audioplayers` package for cross-platform audio playback. +class AudioPlayerService { + static final AudioPlayerService instance = AudioPlayerService._internal(); + + AudioPlayerService._internal(); + + final AudioPlayer _player = AudioPlayer(); + + bool _isPlaying = false; + Duration _duration = Duration.zero; + Duration _position = Duration.zero; + + StreamSubscription? _playerStateSubscription; + StreamSubscription? _durationSubscription; + StreamSubscription? _positionSubscription; + + final StreamController _playingController = + StreamController.broadcast(); + final StreamController _progressController = + StreamController.broadcast(); + + // Track temp files for cleanup + File? _currentTempFile; + + /// Whether audio is currently playing + bool get isPlaying => _isPlaying; + + /// Current playback duration + Duration get duration => _duration; + + /// Current playback position + Duration get position => _position; + + /// Stream of playing state changes + Stream get playingStream => _playingController.stream; + + /// Stream of playback progress (0.0 to 1.0) + Stream get progressStream => _progressController.stream; + + /// Initialize the audio player and set up listeners + Future initialize() async { + // Listen to player state changes + _playerStateSubscription = _player.onPlayerStateChanged.listen((state) { + final wasPlaying = _isPlaying; + _isPlaying = state == PlayerState.playing; + + if (wasPlaying != _isPlaying) { + _playingController.add(_isPlaying); + } + + // Reset position when playback completes + if (state == PlayerState.completed) { + _position = Duration.zero; + _progressController.add(0.0); + } + }); + + // Listen to duration changes + _durationSubscription = _player.onDurationChanged.listen((duration) { + _duration = duration; + debugPrint('🎵 Audio duration: ${duration.inSeconds}s'); + }); + + // Listen to position changes + _positionSubscription = _player.onPositionChanged.listen((position) { + _position = position; + + if (_duration.inMilliseconds > 0) { + final progress = position.inMilliseconds / _duration.inMilliseconds; + _progressController.add(progress.clamp(0.0, 1.0)); + } + }); + + debugPrint('🎵 Audio player initialized'); + } + + /// Play audio from bytes + /// + /// [audioData] - The audio data as PCM16 bytes + /// [volume] - Volume level (0.0 to 1.0) + /// [rate] - Playback rate (0.5 to 2.0) + /// [sampleRate] - Sample rate of the audio (default: 22050) + /// [numChannels] - Number of channels (default: 1 for mono) + Future playFromBytes( + Uint8List audioData, { + double volume = 1.0, + double rate = 1.0, + int sampleRate = 22050, + int numChannels = 1, + }) async { + try { + // Stop any current playback + await stop(); + + // Clean up previous temp file if it exists + await _cleanupTempFile(); + + // Create a temporary file for the audio data + final tempDir = await getTemporaryDirectory(); + final timestamp = DateTime.now().millisecondsSinceEpoch; + final tempFile = File('${tempDir.path}/tts_audio_$timestamp.wav'); + _currentTempFile = tempFile; + + // Convert PCM16 to proper WAV file with headers + final wavData = _createWavFile(audioData, sampleRate, numChannels); + + // Write WAV data to temp file + await tempFile.writeAsBytes(wavData); + debugPrint( + '🎵 Wrote ${wavData.length} bytes (${audioData.length} PCM + headers) to: ${tempFile.path}'); + + // Set volume and rate + await _player.setVolume(volume.clamp(0.0, 1.0)); + await _player.setPlaybackRate(rate.clamp(0.5, 2.0)); + + // Play the audio file + await _player.play(DeviceFileSource(tempFile.path)); + + debugPrint('🎵 Playing audio from file: ${tempFile.path}'); + } catch (e) { + debugPrint('❌ Failed to play audio: $e'); + rethrow; + } + } + + /// Create a proper WAV file from PCM16 data + /// Returns WAV file bytes with proper headers + Uint8List _createWavFile( + Uint8List pcm16Data, int sampleRate, int numChannels) { + final int byteRate = + sampleRate * numChannels * 2; // 2 bytes per sample (16-bit) + final int blockAlign = numChannels * 2; + final int dataSize = pcm16Data.length; + final int fileSize = 36 + dataSize; // 44 byte header - 8 + data size + + final ByteData header = ByteData(44); + + // RIFF header + header.setUint8(0, 0x52); // 'R' + header.setUint8(1, 0x49); // 'I' + header.setUint8(2, 0x46); // 'F' + header.setUint8(3, 0x46); // 'F' + header.setUint32(4, fileSize, Endian.little); // File size - 8 + + // WAVE header + header.setUint8(8, 0x57); // 'W' + header.setUint8(9, 0x41); // 'A' + header.setUint8(10, 0x56); // 'V' + header.setUint8(11, 0x45); // 'E' + + // fmt subchunk + header.setUint8(12, 0x66); // 'f' + header.setUint8(13, 0x6D); // 'm' + header.setUint8(14, 0x74); // 't' + header.setUint8(15, 0x20); // ' ' + header.setUint32(16, 16, Endian.little); // Subchunk1Size (16 for PCM) + header.setUint16(20, 1, Endian.little); // AudioFormat (1 for PCM) + header.setUint16(22, numChannels, Endian.little); // NumChannels + header.setUint32(24, sampleRate, Endian.little); // SampleRate + header.setUint32(28, byteRate, Endian.little); // ByteRate + header.setUint16(32, blockAlign, Endian.little); // BlockAlign + header.setUint16(34, 16, Endian.little); // BitsPerSample + + // data subchunk + header.setUint8(36, 0x64); // 'd' + header.setUint8(37, 0x61); // 'a' + header.setUint8(38, 0x74); // 't' + header.setUint8(39, 0x61); // 'a' + header.setUint32(40, dataSize, Endian.little); // Subchunk2Size + + // Combine header and PCM data + final wavFile = Uint8List(44 + dataSize); + wavFile.setAll(0, header.buffer.asUint8List()); + wavFile.setAll(44, pcm16Data); + + return wavFile; + } + + /// Play audio from file path + /// + /// [filePath] - Path to the audio file + /// [volume] - Volume level (0.0 to 1.0) + /// [rate] - Playback rate (0.5 to 2.0) + Future playFromFile( + String filePath, { + double volume = 1.0, + double rate = 1.0, + }) async { + try { + // Stop any current playback + await stop(); + + // Set volume and rate + await _player.setVolume(volume.clamp(0.0, 1.0)); + await _player.setPlaybackRate(rate.clamp(0.5, 2.0)); + + // Play the audio file + await _player.play(DeviceFileSource(filePath)); + + debugPrint('🎵 Playing audio from file: $filePath'); + } catch (e) { + debugPrint('❌ Failed to play audio: $e'); + rethrow; + } + } + + /// Pause playback + Future pause() async { + if (_isPlaying) { + await _player.pause(); + debugPrint('⏸️ Audio playback paused'); + } + } + + /// Resume playback + Future resume() async { + if (!_isPlaying) { + await _player.resume(); + debugPrint('▶️ Audio playback resumed'); + } + } + + /// Stop playback + Future stop() async { + if (_isPlaying) { + await _player.stop(); + _position = Duration.zero; + _progressController.add(0.0); + debugPrint('⏹️ Audio playback stopped'); + } + } + + /// Seek to position + Future seek(Duration position) async { + await _player.seek(position); + debugPrint('⏩ Seeked to: ${position.inSeconds}s'); + } + + /// Set volume (0.0 to 1.0) + Future setVolume(double volume) async { + await _player.setVolume(volume.clamp(0.0, 1.0)); + } + + /// Set playback rate (0.5 to 2.0) + Future setRate(double rate) async { + await _player.setPlaybackRate(rate.clamp(0.5, 2.0)); + } + + /// Clean up temporary audio file + Future _cleanupTempFile() async { + if (_currentTempFile != null) { + try { + if (await _currentTempFile!.exists()) { + await _currentTempFile!.delete(); + debugPrint('🗑️ Cleaned up temp audio file'); + } + } catch (e) { + debugPrint('⚠️ Failed to cleanup temp file: $e'); + } + _currentTempFile = null; + } + } + + /// Dispose of resources + Future dispose() async { + await _playerStateSubscription?.cancel(); + await _durationSubscription?.cancel(); + await _positionSubscription?.cancel(); + await _playingController.close(); + await _progressController.close(); + await _player.dispose(); + await _cleanupTempFile(); + debugPrint('🎵 Audio player disposed'); + } +} diff --git a/examples/flutter/RunAnywhereAI/lib/core/services/audio_recording_service.dart b/examples/flutter/RunAnywhereAI/lib/core/services/audio_recording_service.dart new file mode 100644 index 000000000..656f6963e --- /dev/null +++ b/examples/flutter/RunAnywhereAI/lib/core/services/audio_recording_service.dart @@ -0,0 +1,225 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter/foundation.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:record/record.dart'; + +/// Audio Recording Service +/// +/// Handles audio recording for Speech-to-Text functionality. +/// Uses the `record` package for cross-platform audio capture. +class AudioRecordingService { + static final AudioRecordingService instance = + AudioRecordingService._internal(); + + AudioRecordingService._internal(); + + final AudioRecorder _recorder = AudioRecorder(); + + StreamController? _audioLevelController; + Timer? _audioLevelTimer; + + bool _isRecording = false; + String? _currentRecordingPath; + + /// Whether the service is currently recording + bool get isRecording => _isRecording; + + /// Stream of audio levels (0.0 to 1.0) during recording + Stream? get audioLevelStream => _audioLevelController?.stream; + + /// Check if microphone permission is granted + Future hasPermission() { + return _recorder.hasPermission(); + } + + /// Start recording audio + /// + /// Returns the path to the temporary recording file + Future startRecording({ + int sampleRate = 16000, + int numChannels = 1, + bool enableAudioLevels = true, + }) async { + if (_isRecording) { + debugPrint('⚠️ Already recording, stopping previous recording first'); + await stopRecording(); + } + + // Check permissions + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + debugPrint('❌ Microphone permission not granted'); + return null; + } + + try { + // Create temp directory for recording + final tempDir = await getTemporaryDirectory(); + final timestamp = DateTime.now().millisecondsSinceEpoch; + _currentRecordingPath = '${tempDir.path}/recording_$timestamp.wav'; + + // Configure recording + final config = RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: sampleRate, + numChannels: numChannels, + bitRate: 128000, + ); + + // Start recording + await _recorder.start( + config, + path: _currentRecordingPath!, + ); + + _isRecording = true; + debugPrint('🎙️ Recording started: $_currentRecordingPath'); + + // Start audio level monitoring if enabled + if (enableAudioLevels) { + _startAudioLevelMonitoring(); + } + + return _currentRecordingPath; + } catch (e) { + debugPrint('❌ Failed to start recording: $e'); + _isRecording = false; + _currentRecordingPath = null; + return null; + } + } + + /// Stop recording and return the audio data + /// + /// Returns a tuple of (audioData, filePath) or (null, null) if failed + Future<(Uint8List?, String?)> stopRecording() async { + if (!_isRecording) { + debugPrint('⚠️ No active recording to stop'); + return (null, null); + } + + try { + // Stop audio level monitoring + _stopAudioLevelMonitoring(); + + // Stop recording + final path = await _recorder.stop(); + _isRecording = false; + + if (path == null) { + debugPrint('❌ Recording path is null'); + _currentRecordingPath = null; + return (null, null); + } + + debugPrint('✅ Recording stopped: $path'); + + // Read the recorded audio file + final file = File(path); + if (!await file.exists()) { + debugPrint('❌ Recording file does not exist: $path'); + _currentRecordingPath = null; + return (null, null); + } + + final audioData = await file.readAsBytes(); + debugPrint('📊 Audio data size: ${audioData.length} bytes'); + + final recordingPath = _currentRecordingPath; + _currentRecordingPath = null; + + // Clean up the temp file after reading + try { + await file.delete(); + debugPrint('🗑️ Cleaned up temp recording file'); + } catch (e) { + debugPrint('⚠️ Failed to cleanup temp recording file: $e'); + } + + return (audioData, recordingPath); + } catch (e) { + debugPrint('❌ Failed to stop recording: $e'); + _isRecording = false; + _currentRecordingPath = null; + return (null, null); + } + } + + /// Cancel current recording without returning data + Future cancelRecording() async { + if (!_isRecording) { + return; + } + + try { + _stopAudioLevelMonitoring(); + await _recorder.stop(); + + // Delete the temp file if it exists + if (_currentRecordingPath != null) { + final file = File(_currentRecordingPath!); + if (await file.exists()) { + await file.delete(); + } + } + + _isRecording = false; + _currentRecordingPath = null; + debugPrint('🗑️ Recording cancelled'); + } catch (e) { + debugPrint('❌ Failed to cancel recording: $e'); + _isRecording = false; + _currentRecordingPath = null; + } + } + + /// Start monitoring audio levels during recording + void _startAudioLevelMonitoring() { + _audioLevelController = StreamController.broadcast(); + + // Poll for audio amplitude + _audioLevelTimer = + Timer.periodic(const Duration(milliseconds: 100), (timer) async { + if (!_isRecording) { + timer.cancel(); + return; + } + + try { + final amplitude = await _recorder.getAmplitude(); + if (amplitude.current != double.negativeInfinity) { + // Convert dB to normalized level (0.0 to 1.0) + // Typical range is -60 dB (quiet) to 0 dB (loud) + final normalizedLevel = + ((amplitude.current + 60) / 60).clamp(0.0, 1.0); + _audioLevelController?.add(normalizedLevel); + } + } catch (e) { + // Ignore errors in amplitude reading + } + }); + } + + /// Stop monitoring audio levels + void _stopAudioLevelMonitoring() { + _audioLevelTimer?.cancel(); + _audioLevelTimer = null; + final controller = _audioLevelController; + if (controller != null) { + unawaited(controller.close()); + } + _audioLevelController = null; + } + + /// Dispose of resources + Future dispose() async { + _stopAudioLevelMonitoring(); + if (_isRecording) { + await cancelRecording(); + } + await _recorder.dispose(); + } +} diff --git a/examples/flutter/RunAnywhereAI/lib/core/services/conversation_store.dart b/examples/flutter/RunAnywhereAI/lib/core/services/conversation_store.dart new file mode 100644 index 000000000..b86bbab39 --- /dev/null +++ b/examples/flutter/RunAnywhereAI/lib/core/services/conversation_store.dart @@ -0,0 +1,416 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:runanywhere/runanywhere.dart' show MessageRole; + +import 'package:runanywhere_ai/core/models/app_types.dart'; + +/// ConversationStore (mirroring iOS ConversationStore.swift) +/// +/// File-based persistence for conversation history with search and CRUD operations. +class ConversationStore extends ChangeNotifier { + static final ConversationStore shared = ConversationStore._(); + + ConversationStore._() { + unawaited(_initialize()); + } + + List _conversations = []; + Conversation? _currentConversation; + Directory? _conversationsDirectory; + + List get conversations => _conversations; + Conversation? get currentConversation => _currentConversation; + + Future _initialize() async { + final documentsDir = await getApplicationDocumentsDirectory(); + _conversationsDirectory = Directory('${documentsDir.path}/Conversations'); + + if (!await _conversationsDirectory!.exists()) { + await _conversationsDirectory!.create(recursive: true); + } + + await loadConversations(); + } + + /// Create a new conversation + Conversation createConversation({String? title}) { + final conversation = Conversation( + id: DateTime.now().millisecondsSinceEpoch.toString(), + title: title ?? 'New Chat', + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + messages: [], + ); + + _conversations.insert(0, conversation); + _currentConversation = conversation; + unawaited(_saveConversation(conversation)); + notifyListeners(); + + return conversation; + } + + /// Update an existing conversation + void updateConversation(Conversation conversation) { + final index = _conversations.indexWhere((c) => c.id == conversation.id); + if (index != -1) { + final updated = conversation.copyWith(updatedAt: DateTime.now()); + _conversations[index] = updated; + + if (_currentConversation?.id == conversation.id) { + _currentConversation = updated; + } + + unawaited(_saveConversation(updated)); + notifyListeners(); + } + } + + /// Delete a conversation + void deleteConversation(Conversation conversation) { + _conversations.removeWhere((c) => c.id == conversation.id); + + if (_currentConversation?.id == conversation.id) { + _currentConversation = + _conversations.isNotEmpty ? _conversations.first : null; + } + + unawaited(_deleteConversationFile(conversation.id)); + notifyListeners(); + } + + /// Add a message to a conversation + void addMessage(Message message, Conversation conversation) { + var updated = conversation.copyWith( + messages: [...conversation.messages, message], + updatedAt: DateTime.now(), + ); + + // Auto-generate title from first user message + if (updated.title == 'New Chat' && + message.role == MessageRole.user && + message.content.isNotEmpty) { + updated = updated.copyWith(title: _generateTitle(message.content)); + } + + updateConversation(updated); + } + + /// Load a specific conversation + Conversation? loadConversation(String id) { + final existing = _conversations.firstWhere( + (c) => c.id == id, + orElse: Conversation.empty, + ); + + if (existing.id.isNotEmpty) { + _currentConversation = existing; + notifyListeners(); + return existing; + } + + return null; + } + + /// Search conversations by query + List searchConversations(String query) { + if (query.isEmpty) return _conversations; + + final lowercasedQuery = query.toLowerCase(); + + return _conversations.where((conversation) { + if (conversation.title.toLowerCase().contains(lowercasedQuery)) { + return true; + } + + return conversation.messages.any( + (message) => message.content.toLowerCase().contains(lowercasedQuery), + ); + }).toList(); + } + + /// Load all conversations from disk + Future loadConversations() async { + if (_conversationsDirectory == null) return; + + try { + final files = _conversationsDirectory!.listSync(); + final loadedConversations = []; + + for (final file in files) { + if (file is File && file.path.endsWith('.json')) { + try { + final content = await file.readAsString(); + final json = jsonDecode(content) as Map; + loadedConversations.add(Conversation.fromJson(json)); + } catch (e) { + debugPrint('Error loading conversation: $e'); + } + } + } + + _conversations = loadedConversations + ..sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); + notifyListeners(); + } catch (e) { + debugPrint('Error loading conversations: $e'); + } + } + + Future _saveConversation(Conversation conversation) async { + if (_conversationsDirectory == null) return; + + try { + final file = + File('${_conversationsDirectory!.path}/${conversation.id}.json'); + final json = jsonEncode(conversation.toJson()); + await file.writeAsString(json); + } catch (e) { + debugPrint('Error saving conversation: $e'); + } + } + + Future _deleteConversationFile(String id) async { + if (_conversationsDirectory == null) return; + + try { + final file = File('${_conversationsDirectory!.path}/$id.json'); + if (await file.exists()) { + await file.delete(); + } + } catch (e) { + debugPrint('Error deleting conversation file: $e'); + } + } + + String _generateTitle(String content) { + const maxLength = 50; + final cleaned = content.trim(); + + final newlineIndex = cleaned.indexOf('\n'); + if (newlineIndex != -1) { + final firstLine = cleaned.substring(0, newlineIndex); + return firstLine.length > maxLength + ? firstLine.substring(0, maxLength) + : firstLine; + } + + return cleaned.length > maxLength + ? cleaned.substring(0, maxLength) + : cleaned; + } +} + +/// Conversation model +class Conversation { + final String id; + final String title; + final DateTime createdAt; + final DateTime updatedAt; + final List messages; + final String? modelName; + final String? frameworkName; + + const Conversation({ + required this.id, + required this.title, + required this.createdAt, + required this.updatedAt, + required this.messages, + this.modelName, + this.frameworkName, + }); + + factory Conversation.empty() => Conversation( + id: '', + title: '', + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + messages: [], + ); + + Conversation copyWith({ + String? id, + String? title, + DateTime? createdAt, + DateTime? updatedAt, + List? messages, + String? modelName, + String? frameworkName, + }) { + return Conversation( + id: id ?? this.id, + title: title ?? this.title, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + messages: messages ?? this.messages, + modelName: modelName ?? this.modelName, + frameworkName: frameworkName ?? this.frameworkName, + ); + } + + String get summary { + if (messages.isEmpty) return 'No messages'; + + final messageCount = messages.length; + final userMessages = + messages.where((m) => m.role == MessageRole.user).length; + final assistantMessages = + messages.where((m) => m.role == MessageRole.assistant).length; + + return '$messageCount messages • $userMessages from you, $assistantMessages from AI'; + } + + String get lastMessagePreview { + if (messages.isEmpty) return 'Start a conversation'; + + final lastMessage = messages.last; + final preview = lastMessage.content.trim().replaceAll('\n', ' '); + + return preview.length > 100 ? preview.substring(0, 100) : preview; + } + + Map toJson() => { + 'id': id, + 'title': title, + 'createdAt': createdAt.toIso8601String(), + 'updatedAt': updatedAt.toIso8601String(), + 'messages': messages.map((m) => m.toJson()).toList(), + 'modelName': modelName, + 'frameworkName': frameworkName, + }; + + factory Conversation.fromJson(Map json) => Conversation( + id: json['id'] as String, + title: json['title'] as String, + createdAt: DateTime.parse(json['createdAt'] as String), + updatedAt: DateTime.parse(json['updatedAt'] as String), + messages: (json['messages'] as List) + .map((m) => Message.fromJson(m as Map)) + .toList(), + modelName: json['modelName'] as String?, + frameworkName: json['frameworkName'] as String?, + ); +} + +/// Message model +class Message { + final String id; + final MessageRole role; + final String content; + final String? thinkingContent; + final DateTime timestamp; + final MessageAnalytics? analytics; + + const Message({ + required this.id, + required this.role, + required this.content, + this.thinkingContent, + required this.timestamp, + this.analytics, + }); + + Message copyWith({ + String? id, + MessageRole? role, + String? content, + String? thinkingContent, + DateTime? timestamp, + MessageAnalytics? analytics, + }) { + return Message( + id: id ?? this.id, + role: role ?? this.role, + content: content ?? this.content, + thinkingContent: thinkingContent ?? this.thinkingContent, + timestamp: timestamp ?? this.timestamp, + analytics: analytics ?? this.analytics, + ); + } + + Map toJson() => { + 'id': id, + 'role': role.name, + 'content': content, + 'thinkingContent': thinkingContent, + 'timestamp': timestamp.toIso8601String(), + 'analytics': analytics?.toJson(), + }; + + factory Message.fromJson(Map json) => Message( + id: json['id'] as String, + role: MessageRole.values.firstWhere( + (r) => r.name == json['role'], + orElse: () => MessageRole.user, + ), + content: json['content'] as String, + thinkingContent: json['thinkingContent'] as String?, + timestamp: DateTime.parse(json['timestamp'] as String), + analytics: json['analytics'] != null + ? MessageAnalytics.fromJson( + json['analytics'] as Map) + : null, + ); +} + +/// Message analytics for tracking generation metrics +class MessageAnalytics { + final String messageId; + final String? modelName; + final String? framework; + final double? timeToFirstToken; + final double? totalGenerationTime; + final int inputTokens; + final int outputTokens; + final double? tokensPerSecond; + final bool wasThinkingMode; + final CompletionStatus completionStatus; + + const MessageAnalytics({ + required this.messageId, + this.modelName, + this.framework, + this.timeToFirstToken, + this.totalGenerationTime, + this.inputTokens = 0, + this.outputTokens = 0, + this.tokensPerSecond, + this.wasThinkingMode = false, + this.completionStatus = CompletionStatus.complete, + }); + + Map toJson() => { + 'messageId': messageId, + 'modelName': modelName, + 'framework': framework, + 'timeToFirstToken': timeToFirstToken, + 'totalGenerationTime': totalGenerationTime, + 'inputTokens': inputTokens, + 'outputTokens': outputTokens, + 'tokensPerSecond': tokensPerSecond, + 'wasThinkingMode': wasThinkingMode, + 'completionStatus': completionStatus.name, + }; + + factory MessageAnalytics.fromJson(Map json) => + MessageAnalytics( + messageId: json['messageId'] as String, + modelName: json['modelName'] as String?, + framework: json['framework'] as String?, + timeToFirstToken: json['timeToFirstToken'] as double?, + totalGenerationTime: json['totalGenerationTime'] as double?, + inputTokens: json['inputTokens'] as int? ?? 0, + outputTokens: json['outputTokens'] as int? ?? 0, + tokensPerSecond: json['tokensPerSecond'] as double?, + wasThinkingMode: json['wasThinkingMode'] as bool? ?? false, + completionStatus: CompletionStatus.values.firstWhere( + (s) => s.name == json['completionStatus'], + orElse: () => CompletionStatus.complete, + ), + ); +} diff --git a/examples/flutter/RunAnywhereAI/lib/core/services/device_info_service.dart b/examples/flutter/RunAnywhereAI/lib/core/services/device_info_service.dart new file mode 100644 index 000000000..09eadf702 --- /dev/null +++ b/examples/flutter/RunAnywhereAI/lib/core/services/device_info_service.dart @@ -0,0 +1,129 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:flutter/foundation.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +import 'package:runanywhere_ai/core/models/app_types.dart'; + +/// DeviceInfoService (mirroring iOS DeviceInfoService.swift) +/// +/// Retrieves device information (model, chip, memory, OS version, Neural Engine availability). +class DeviceInfoService extends ChangeNotifier { + static final DeviceInfoService shared = DeviceInfoService._(); + + DeviceInfoService._() { + unawaited(refreshDeviceInfo()); + } + + SystemDeviceInfo? _deviceInfo; + bool _isLoading = false; + + SystemDeviceInfo? get deviceInfo => _deviceInfo; + bool get isLoading => _isLoading; + + Future refreshDeviceInfo() async { + _isLoading = true; + notifyListeners(); + + try { + final deviceInfoPlugin = DeviceInfoPlugin(); + final packageInfo = await PackageInfo.fromPlatform(); + + String modelName = ''; + String chipName = ''; + int totalMemory = 0; + int availableMemory = 0; + bool neuralEngineAvailable = false; + String osVersion = ''; + + if (Platform.isIOS) { + final iosInfo = await deviceInfoPlugin.iosInfo; + modelName = iosInfo.utsname.machine; + chipName = _getChipNameFromModel(modelName); + osVersion = iosInfo.systemVersion; + neuralEngineAvailable = _checkNeuralEngineAvailability(modelName); + // TODO: Get actual memory info via native channel + totalMemory = 4 * 1024 * 1024 * 1024; // Placeholder: 4GB + availableMemory = 2 * 1024 * 1024 * 1024; // Placeholder: 2GB + } else if (Platform.isAndroid) { + final androidInfo = await deviceInfoPlugin.androidInfo; + modelName = '${androidInfo.manufacturer} ${androidInfo.model}'; + chipName = androidInfo.hardware; + osVersion = 'Android ${androidInfo.version.release}'; + // TODO: Get actual memory info via native channel + totalMemory = 4 * 1024 * 1024 * 1024; // Placeholder + availableMemory = 2 * 1024 * 1024 * 1024; // Placeholder + neuralEngineAvailable = true; // Android devices generally have NPU + } else if (Platform.isMacOS) { + final macOSInfo = await deviceInfoPlugin.macOsInfo; + modelName = macOSInfo.model; + chipName = _getChipNameFromModel(modelName); + osVersion = 'macOS ${macOSInfo.osRelease}'; + totalMemory = macOSInfo.memorySize; + availableMemory = totalMemory ~/ 2; // Estimate + neuralEngineAvailable = modelName.contains('arm64') || + chipName.contains('Apple') || + chipName.contains('M1') || + chipName.contains('M2') || + chipName.contains('M3') || + chipName.contains('M4'); + } + + _deviceInfo = SystemDeviceInfo( + modelName: modelName, + chipName: chipName, + totalMemory: totalMemory, + availableMemory: availableMemory, + neuralEngineAvailable: neuralEngineAvailable, + osVersion: osVersion, + appVersion: packageInfo.version, + ); + } catch (e) { + debugPrint('Error getting device info: $e'); + _deviceInfo = const SystemDeviceInfo( + modelName: 'Unknown', + chipName: 'Unknown', + osVersion: 'Unknown', + appVersion: '1.0.0', + ); + } + + _isLoading = false; + notifyListeners(); + } + + String _getChipNameFromModel(String modelName) { + // iOS device chip detection + if (modelName.contains('iPhone')) { + if (modelName.contains('iPhone17')) return 'A18 Pro'; + if (modelName.contains('iPhone16')) return 'A17 Pro'; + if (modelName.contains('iPhone15')) return 'A16 Bionic'; + if (modelName.contains('iPhone14')) return 'A15 Bionic'; + if (modelName.contains('iPhone13')) return 'A15 Bionic'; + if (modelName.contains('iPhone12')) return 'A14 Bionic'; + return 'Apple Silicon'; + } + + // Mac chip detection + if (modelName.contains('Mac')) { + if (modelName.contains('arm64')) return 'Apple Silicon'; + return 'Intel'; + } + + return 'Unknown'; + } + + bool _checkNeuralEngineAvailability(String modelName) { + // Neural Engine available on A11+ chips (iPhone 8 and later) + if (modelName.contains('iPhone')) { + final match = RegExp(r'iPhone(\d+)').firstMatch(modelName); + if (match != null) { + final version = int.tryParse(match.group(1) ?? '0') ?? 0; + return version >= 10; // iPhone 8 = iPhone10 + } + } + return true; // Assume available for modern devices + } +} diff --git a/examples/flutter/RunAnywhereAI/lib/core/services/keychain_service.dart b/examples/flutter/RunAnywhereAI/lib/core/services/keychain_service.dart new file mode 100644 index 000000000..cccf5259e --- /dev/null +++ b/examples/flutter/RunAnywhereAI/lib/core/services/keychain_service.dart @@ -0,0 +1,87 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +/// KeychainService (mirroring iOS KeychainService.swift) +/// +/// Provides secure storage for sensitive data using platform-specific +/// secure storage (iOS Keychain, Android Keystore). +class KeychainService { + static final KeychainService shared = KeychainService._(); + + KeychainService._(); + + final FlutterSecureStorage _storage = const FlutterSecureStorage( + aOptions: AndroidOptions( + encryptedSharedPreferences: true, + ), + iOptions: IOSOptions( + accessibility: KeychainAccessibility.first_unlock_this_device, + ), + ); + + /// Save string data to keychain + Future save({required String key, required String data}) async { + try { + await _storage.write(key: key, value: data); + } catch (e) { + throw KeychainError.saveFailed; + } + } + + /// Save bytes to keychain (encoded as base64) + Future saveBytes({required String key, required Uint8List data}) async { + try { + final encoded = base64Encode(data); + await _storage.write(key: key, value: encoded); + } catch (e) { + throw KeychainError.saveFailed; + } + } + + /// Read string data from keychain + Future read(String key) async { + try { + return await _storage.read(key: key); + } catch (e) { + return null; + } + } + + /// Read bytes from keychain (decoded from base64) + Future readBytes(String key) async { + try { + final value = await _storage.read(key: key); + if (value == null) return null; + return base64Decode(value); + } catch (e) { + return null; + } + } + + /// Delete data from keychain + Future delete(String key) async { + try { + await _storage.delete(key: key); + } catch (e) { + throw KeychainError.deleteFailed; + } + } + + /// Check if a key exists in keychain + Future containsKey(String key) { + return _storage.containsKey(key: key); + } + + /// Delete all data from keychain + Future deleteAll() async { + await _storage.deleteAll(); + } +} + +/// Keychain error types +enum KeychainError implements Exception { + saveFailed, + deleteFailed, +} diff --git a/examples/flutter/RunAnywhereAI/lib/core/services/model_manager.dart b/examples/flutter/RunAnywhereAI/lib/core/services/model_manager.dart new file mode 100644 index 000000000..d220dec45 --- /dev/null +++ b/examples/flutter/RunAnywhereAI/lib/core/services/model_manager.dart @@ -0,0 +1,79 @@ +import 'package:flutter/foundation.dart'; +import 'package:runanywhere/runanywhere.dart'; + +/// ModelManager (matching iOS ModelManager.swift exactly) +/// +/// Service for managing model loading and lifecycle. +/// This is a minimal wrapper that delegates to RunAnywhere SDK. +/// Each feature view (Chat, STT, TTS) manages its own state. +class ModelManager extends ChangeNotifier { + static final ModelManager shared = ModelManager._(); + + ModelManager._(); + + bool _isLoading = false; + Object? _error; + + bool get isLoading => _isLoading; + Object? get error => _error; + + // ============================================================================ + // MARK: - Model Operations (matches Swift ModelManager.swift) + // ============================================================================ + + /// Load a model by ModelInfo + Future loadModel(ModelInfo modelInfo) async { + _isLoading = true; + notifyListeners(); + + try { + await RunAnywhere.loadModel(modelInfo.id); + } catch (e) { + _error = e; + rethrow; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + /// Unload the current model + Future unloadCurrentModel() async { + _isLoading = true; + notifyListeners(); + + try { + await RunAnywhere.unloadModel(); + } catch (e) { + _error = e; + debugPrint('Failed to unload model: $e'); + } finally { + _isLoading = false; + notifyListeners(); + } + } + + /// Get available models from SDK + Future> getAvailableModels() async { + try { + return await RunAnywhere.availableModels(); + } catch (e) { + debugPrint('Failed to get available models: $e'); + return []; + } + } + + /// Get current model (LLM) + Future getCurrentModel() async { + final modelId = RunAnywhere.currentModelId; + if (modelId == null) return null; + + final models = await getAvailableModels(); + return models.where((m) => m.id == modelId).firstOrNull; + } + + /// Refresh state (for UI notification purposes) + Future refresh() async { + notifyListeners(); + } +} diff --git a/examples/flutter/RunAnywhereAI/lib/core/services/permission_service.dart b/examples/flutter/RunAnywhereAI/lib/core/services/permission_service.dart new file mode 100644 index 000000000..02a8d5990 --- /dev/null +++ b/examples/flutter/RunAnywhereAI/lib/core/services/permission_service.dart @@ -0,0 +1,215 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:permission_handler/permission_handler.dart'; + +/// PermissionService - Centralized permission handling for the app +/// +/// Handles microphone and speech recognition permissions with proper +/// user guidance for denied/permanently denied states. +class PermissionService { + static final PermissionService _instance = PermissionService._internal(); + static PermissionService get shared => _instance; + + PermissionService._internal(); + + /// Request microphone permission with proper handling of all states + /// + /// Returns true if permission is granted, false otherwise. + /// Shows appropriate dialogs for denied/permanently denied states. + Future requestMicrophonePermission(BuildContext context) async { + final status = await Permission.microphone.status; + + if (status.isGranted) { + return true; + } + + if (status.isPermanentlyDenied) { + if (!context.mounted) return false; + // Permission was permanently denied, show settings dialog + final shouldOpenSettings = await _showSettingsDialog( + context, + title: 'Microphone Permission Required', + message: + 'Microphone access is required for voice features. Please enable it in Settings.', + ); + + if (shouldOpenSettings) { + await openAppSettings(); + } + return false; + } + + // Request permission + final result = await Permission.microphone.request(); + + if (result.isGranted) { + return true; + } + + if (!context.mounted) return false; + + if (result.isPermanentlyDenied) { + // User denied with "Don't ask again", show settings dialog + final shouldOpenSettings = await _showSettingsDialog( + context, + title: 'Microphone Permission Required', + message: + 'Microphone access is required for voice features. Please enable it in Settings.', + ); + + if (shouldOpenSettings) { + await openAppSettings(); + } + } else if (result.isDenied) { + // User denied, show explanation + _showDeniedSnackbar( + context, + 'Microphone permission is required for voice features.', + ); + } + + return false; + } + + /// Request speech recognition permission (iOS only) + /// + /// On Android, speech recognition uses microphone permission. + /// On iOS, a separate speech recognition permission is required. + Future requestSpeechRecognitionPermission(BuildContext context) async { + // Speech recognition permission is only needed on iOS + if (!Platform.isIOS) { + return true; + } + + final status = await Permission.speech.status; + + if (status.isGranted) { + return true; + } + + if (status.isPermanentlyDenied) { + if (!context.mounted) return false; + final shouldOpenSettings = await _showSettingsDialog( + context, + title: 'Speech Recognition Permission Required', + message: + 'Speech recognition access is required for voice-to-text features. Please enable it in Settings.', + ); + + if (shouldOpenSettings) { + await openAppSettings(); + } + return false; + } + + final result = await Permission.speech.request(); + + if (result.isGranted) { + return true; + } + + if (!context.mounted) return false; + + if (result.isPermanentlyDenied) { + final shouldOpenSettings = await _showSettingsDialog( + context, + title: 'Speech Recognition Permission Required', + message: + 'Speech recognition access is required for voice-to-text features. Please enable it in Settings.', + ); + + if (shouldOpenSettings) { + await openAppSettings(); + } + } else if (result.isDenied) { + _showDeniedSnackbar( + context, + 'Speech recognition permission is required for voice-to-text features.', + ); + } + + return false; + } + + /// Request all permissions needed for STT (Speech-to-Text) features + /// + /// On iOS: Requests both microphone and speech recognition permissions. + /// On Android: Requests microphone permission only. + Future requestSTTPermissions(BuildContext context) async { + final micGranted = await requestMicrophonePermission(context); + if (!micGranted) { + return false; + } + + // On iOS, also request speech recognition permission + if (Platform.isIOS) { + if (!context.mounted) return false; + final speechGranted = await requestSpeechRecognitionPermission(context); + return speechGranted; + } + + return true; + } + + /// Check if microphone permission is granted without requesting + Future isMicrophonePermissionGranted() async { + final status = await Permission.microphone.status; + return status.isGranted; + } + + /// Check if all STT permissions are granted without requesting + Future areSTTPermissionsGranted() async { + final micGranted = await isMicrophonePermissionGranted(); + if (!micGranted) { + return false; + } + + if (Platform.isIOS) { + final speechStatus = await Permission.speech.status; + return speechStatus.isGranted; + } + + return true; + } + + /// Show dialog to guide user to settings + Future _showSettingsDialog( + BuildContext context, { + required String title, + required String message, + }) async { + final result = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(title), + content: Text(message), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: const Text('Open Settings'), + ), + ], + ), + ); + + return result ?? false; + } + + /// Show snackbar for denied permission + void _showDeniedSnackbar(BuildContext context, String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + action: const SnackBarAction( + label: 'Settings', + onPressed: openAppSettings, + ), + ), + ); + } +} diff --git a/examples/flutter/RunAnywhereAI/lib/core/utilities/constants.dart b/examples/flutter/RunAnywhereAI/lib/core/utilities/constants.dart new file mode 100644 index 000000000..5ed4a496e --- /dev/null +++ b/examples/flutter/RunAnywhereAI/lib/core/utilities/constants.dart @@ -0,0 +1,90 @@ +// Constants (mirroring iOS Constants.swift) +// +// Application-wide constant values. + +class Constants { + Constants._(); + + /// App configuration + static const app = _App(); + + /// Storage configuration + static const storage = _Storage(); + + /// Generation configuration + static const generation = _Generation(); + + /// Memory configuration + static const memory = _Memory(); + + /// UI configuration + static const ui = _UI(); +} + +class _App { + const _App(); + + String get name => 'RunAnywhereAI'; + String get version => '1.0.0'; + String get bundleId => 'com.runanywhere.ai.demo'; +} + +class _Storage { + const _Storage(); + + String get modelsDirectory => 'Models'; + String get cacheDirectory => 'Cache'; +} + +class _Generation { + const _Generation(); + + int get defaultMaxTokens => 150; + double get defaultTemperature => 0.7; + double get defaultTopP => 0.95; + int get defaultTopK => 40; + double get defaultRepetitionPenalty => 1.1; +} + +class _Memory { + const _Memory(); + + /// 1GB minimum required memory + int get minimumRequiredMemory => 1000000000; + + /// 2GB recommended memory + int get recommendedMemory => 2000000000; +} + +class _UI { + const _UI(); + + /// Maximum width for message bubbles (75% of screen) + double get messageMaxWidth => 0.75; + + /// Delay before showing typing indicator + double get typingIndicatorDelay => 0.2; + + /// Delay between streaming tokens + Duration get streamingTokenDelay => const Duration(milliseconds: 100); +} + +/// Keychain keys for secure storage +class KeychainKeys { + KeychainKeys._(); + + static const String apiKey = 'runanywhere_api_key'; + static const String baseURL = 'runanywhere_base_url'; + static const String analyticsLogToLocal = 'analyticsLogToLocal'; + static const String deviceRegistered = 'com.runanywhere.sdk.deviceRegistered'; +} + +/// UserDefaults keys for preferences +class PreferenceKeys { + PreferenceKeys._(); + + static const String routingPolicy = 'routingPolicy'; + static const String defaultTemperature = 'defaultTemperature'; + static const String defaultMaxTokens = 'defaultMaxTokens'; + static const String useStreaming = 'useStreaming'; +} diff --git a/examples/flutter/RunAnywhereAI/lib/core/utilities/keychain_helper.dart b/examples/flutter/RunAnywhereAI/lib/core/utilities/keychain_helper.dart new file mode 100644 index 000000000..c4689b1ce --- /dev/null +++ b/examples/flutter/RunAnywhereAI/lib/core/utilities/keychain_helper.dart @@ -0,0 +1,70 @@ +import 'dart:typed_data'; + +import 'package:runanywhere_ai/core/services/keychain_service.dart'; + +/// KeychainHelper (mirroring iOS KeychainHelper.swift) +/// +/// Static utility methods for keychain operations. +class KeychainHelper { + static const String _service = 'com.runanywhere.RunAnywhereAI'; + + KeychainHelper._(); + + /// Save a boolean value to keychain + static Future saveBool({ + required String key, + required bool data, + }) async { + final bytes = Uint8List.fromList([data ? 1 : 0]); + await saveBytes(key: key, data: bytes); + } + + /// Save bytes to keychain + static Future saveBytes({ + required String key, + required Uint8List data, + }) async { + await KeychainService.shared.saveBytes( + key: _prefixKey(key), + data: data, + ); + } + + /// Save string to keychain + static Future saveString({ + required String key, + required String data, + }) async { + await KeychainService.shared.save( + key: _prefixKey(key), + data: data, + ); + } + + /// Load a boolean value from keychain + static Future loadBool(String key, {bool defaultValue = false}) async { + final data = await loadBytes(key); + if (data == null || data.isEmpty) { + return defaultValue; + } + return data.first == 1; + } + + /// Load bytes from keychain + static Future loadBytes(String key) { + return KeychainService.shared.readBytes(_prefixKey(key)); + } + + /// Load string from keychain + static Future loadString(String key) { + return KeychainService.shared.read(_prefixKey(key)); + } + + /// Delete an item from keychain + static Future delete(String key) async { + await KeychainService.shared.delete(_prefixKey(key)); + } + + /// Prefix key with service name for namespacing + static String _prefixKey(String key) => '${_service}_$key'; +} diff --git a/examples/flutter/RunAnywhereAI/lib/features/chat/chat_interface_view.dart b/examples/flutter/RunAnywhereAI/lib/features/chat/chat_interface_view.dart new file mode 100644 index 000000000..d92b98f32 --- /dev/null +++ b/examples/flutter/RunAnywhereAI/lib/features/chat/chat_interface_view.dart @@ -0,0 +1,733 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:runanywhere/runanywhere.dart' as sdk; +import 'package:runanywhere_ai/core/design_system/app_colors.dart'; +import 'package:runanywhere_ai/core/design_system/app_spacing.dart'; +import 'package:runanywhere_ai/core/design_system/typography.dart'; +import 'package:runanywhere_ai/core/services/conversation_store.dart'; +import 'package:runanywhere_ai/core/utilities/constants.dart'; +import 'package:runanywhere_ai/features/models/model_selection_sheet.dart'; +import 'package:runanywhere_ai/features/models/model_status_components.dart'; +import 'package:runanywhere_ai/features/models/model_types.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// ChatInterfaceView (mirroring iOS ChatInterfaceView.swift) +/// +/// Full chat interface with streaming, analytics, and model status. +class ChatInterfaceView extends StatefulWidget { + const ChatInterfaceView({super.key}); + + @override + State createState() => _ChatInterfaceViewState(); +} + +class _ChatInterfaceViewState extends State { + final TextEditingController _controller = TextEditingController(); + final ScrollController _scrollController = ScrollController(); + final FocusNode _focusNode = FocusNode(); + + // Messages + final List _messages = []; + String _currentStreamingContent = ''; + String _currentThinkingContent = ''; + + // State + bool _isGenerating = false; + bool _useStreaming = true; + String? _errorMessage; + bool _isLoading = false; + + // Model state (from SDK - matches Swift pattern) + String? _loadedModelName; + sdk.InferenceFramework? _loadedFramework; + + // Analytics + DateTime? _generationStartTime; + double? _timeToFirstToken; + int _tokenCount = 0; + + @override + void initState() { + super.initState(); + unawaited(_loadSettings()); + unawaited(_syncModelState()); + } + + @override + void dispose() { + _controller.dispose(); + _scrollController.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + Future _loadSettings() async { + final prefs = await SharedPreferences.getInstance(); + setState(() { + _useStreaming = prefs.getBool(PreferenceKeys.useStreaming) ?? true; + }); + } + + /// Sync model state from SDK (matches Swift pattern) + Future _syncModelState() async { + final model = await sdk.RunAnywhere.currentLLMModel(); + if (mounted) { + setState(() { + _loadedModelName = model?.name; + _loadedFramework = model?.framework; + }); + } + } + + bool get _canSend => + _controller.text.isNotEmpty && + !_isGenerating && + sdk.RunAnywhere.isModelLoaded; + + Future _sendMessage() async { + if (!_canSend) return; + + final userMessage = _controller.text; + _controller.clear(); + + setState(() { + _messages.add(ChatMessage( + id: DateTime.now().millisecondsSinceEpoch.toString(), + role: MessageRole.user, + content: userMessage, + timestamp: DateTime.now(), + )); + _isGenerating = true; + _errorMessage = null; + _currentStreamingContent = ''; + _currentThinkingContent = ''; + _generationStartTime = DateTime.now(); + _timeToFirstToken = null; + _tokenCount = 0; + }); + + _scrollToBottom(); + + try { + // Get generation options from settings + final prefs = await SharedPreferences.getInstance(); + final temperature = + prefs.getDouble(PreferenceKeys.defaultTemperature) ?? 0.7; + final maxTokens = prefs.getInt(PreferenceKeys.defaultMaxTokens) ?? 500; + + // Streaming now runs in a background isolate, so no ANR concerns + final options = sdk.LLMGenerationOptions( + maxTokens: maxTokens, + temperature: temperature, + ); + + if (_useStreaming) { + await _generateStreaming(userMessage, options); + } else { + await _generateNonStreaming(userMessage, options); + } + } catch (e) { + setState(() { + _errorMessage = 'Generation failed: $e'; + _isGenerating = false; + }); + } + } + + Future _generateStreaming( + String prompt, + sdk.LLMGenerationOptions options, + ) async { + // Capture model name from local state (matches Swift pattern) + final modelName = _loadedModelName; + + // Add empty assistant message for streaming + final assistantMessage = ChatMessage( + id: DateTime.now().millisecondsSinceEpoch.toString(), + role: MessageRole.assistant, + content: '', + timestamp: DateTime.now(), + ); + + setState(() { + _messages.add(assistantMessage); + }); + + final messageIndex = _messages.length - 1; + final contentBuffer = StringBuffer(); + + try { + final streamingResult = + await sdk.RunAnywhere.generateStream(prompt, options: options); + + await for (final token in streamingResult.stream) { + if (_timeToFirstToken == null && _generationStartTime != null) { + _timeToFirstToken = + DateTime.now().difference(_generationStartTime!).inMilliseconds / + 1000.0; + } + + _tokenCount++; + contentBuffer.write(token); + _currentStreamingContent = contentBuffer.toString(); + + setState(() { + _messages[messageIndex] = _messages[messageIndex].copyWith( + content: _currentStreamingContent, + ); + }); + + _scrollToBottom(); + } + + // Calculate final analytics + final totalTime = _generationStartTime != null + ? DateTime.now().difference(_generationStartTime!).inMilliseconds / + 1000.0 + : 0.0; + + final analytics = MessageAnalytics( + messageId: assistantMessage.id, + modelName: modelName, + timeToFirstToken: _timeToFirstToken, + totalGenerationTime: totalTime, + outputTokens: _tokenCount, + tokensPerSecond: totalTime > 0 ? _tokenCount / totalTime : 0, + ); + + if (!mounted) return; + setState(() { + _messages[messageIndex] = _messages[messageIndex].copyWith( + thinkingContent: _currentThinkingContent.isNotEmpty + ? _currentThinkingContent + : null, + analytics: analytics, + ); + _isGenerating = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _messages.removeLast(); + _errorMessage = 'Streaming failed: $e'; + _isGenerating = false; + }); + } + } + + Future _generateNonStreaming( + String prompt, + sdk.LLMGenerationOptions options, + ) async { + // Capture model name from local state (matches Swift pattern) + final modelName = _loadedModelName; + + try { + final result = await sdk.RunAnywhere.generate(prompt, options: options); + + final totalTime = _generationStartTime != null + ? DateTime.now().difference(_generationStartTime!).inMilliseconds / + 1000.0 + : 0.0; + + // Extract token counts from SDK result + final outputTokens = result.tokensUsed; + final tokensPerSecond = result.tokensPerSecond; + + final analytics = MessageAnalytics( + messageId: DateTime.now().millisecondsSinceEpoch.toString(), + modelName: modelName, + totalGenerationTime: totalTime, + outputTokens: outputTokens, + tokensPerSecond: tokensPerSecond, + ); + + setState(() { + _messages.add(ChatMessage( + id: DateTime.now().millisecondsSinceEpoch.toString(), + role: MessageRole.assistant, + content: result.text, + thinkingContent: result.thinkingContent, + timestamp: DateTime.now(), + analytics: analytics, + )); + _isGenerating = false; + }); + + _scrollToBottom(); + } catch (e) { + setState(() { + _errorMessage = 'Generation failed: $e'; + _isGenerating = false; + }); + } + } + + void _scrollToBottom() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + unawaited(_scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: AppLayout.animationFast, + curve: Curves.easeOut, + )); + } + }); + } + + void _clearChat() { + setState(() { + _messages.clear(); + _errorMessage = null; + _currentStreamingContent = ''; + _currentThinkingContent = ''; + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Chat'), + actions: [ + if (_messages.isNotEmpty) + IconButton( + icon: const Icon(Icons.delete_outline), + onPressed: _clearChat, + tooltip: 'Clear chat', + ), + ], + ), + body: Column( + children: [ + // Model status banner (uses local state from SDK) + _buildModelStatusBanner(), + + // Messages area - tap to dismiss keyboard + Expanded( + child: GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + behavior: HitTestBehavior.opaque, + child: _buildMessagesArea(), + ), + ), + + // Error banner + if (_errorMessage != null) _buildErrorBanner(), + + // Typing indicator + if (_isGenerating) _buildTypingIndicator(), + + // Input area + _buildInputArea(), + ], + ), + ); + } + + void _showModelSelectionSheet() { + unawaited(showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (sheetContext) => ModelSelectionSheet( + context: ModelSelectionContext.llm, + onModelSelected: (model) async { + // Model loaded by ModelSelectionSheet via SDK + // Sync local state after model load + await _syncModelState(); + }, + ), + )); + } + + /// Map SDK InferenceFramework enum to app framework enum + LLMFramework _mapInferenceFramework(sdk.InferenceFramework? framework) { + if (framework == null) return LLMFramework.llamaCpp; + switch (framework) { + case sdk.InferenceFramework.llamaCpp: + return LLMFramework.llamaCpp; + case sdk.InferenceFramework.foundationModels: + return LLMFramework.foundationModels; + case sdk.InferenceFramework.onnx: + return LLMFramework.onnxRuntime; + case sdk.InferenceFramework.systemTTS: + return LLMFramework.systemTTS; + default: + return LLMFramework.llamaCpp; + } + } + + Widget _buildModelStatusBanner() { + // Use local state synced from SDK (matches Swift pattern) + LLMFramework? framework; + if (sdk.RunAnywhere.isModelLoaded && _loadedFramework != null) { + framework = _mapInferenceFramework(_loadedFramework); + } + + return Padding( + padding: const EdgeInsets.all(AppSpacing.large), + child: ModelStatusBanner( + framework: framework, + modelName: _loadedModelName, + isLoading: _isLoading, + onSelectModel: _showModelSelectionSheet, + ), + ); + } + + Widget _buildMessagesArea() { + if (_messages.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.psychology, + size: AppSpacing.iconXXLarge, + color: AppColors.textSecondary(context), + ), + const SizedBox(height: AppSpacing.large), + Text( + 'Start a conversation', + style: AppTypography.title2(context), + ), + const SizedBox(height: AppSpacing.smallMedium), + Text( + 'Type a message to begin', + style: AppTypography.subheadline(context).copyWith( + color: AppColors.textSecondary(context), + ), + ), + ], + ), + ); + } + + return ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.all(AppSpacing.large), + itemCount: _messages.length, + itemBuilder: (context, index) { + final message = _messages[index]; + return _MessageBubble(message: message); + }, + ); + } + + Widget _buildErrorBanner() { + return Container( + margin: const EdgeInsets.all(AppSpacing.large), + padding: const EdgeInsets.all(AppSpacing.mediumLarge), + decoration: BoxDecoration( + color: AppColors.badgeRed, + borderRadius: BorderRadius.circular(AppSpacing.cornerRadiusRegular), + ), + child: Row( + children: [ + const Icon(Icons.error, color: Colors.red), + const SizedBox(width: AppSpacing.smallMedium), + Expanded( + child: Text( + _errorMessage!, + style: AppTypography.subheadline(context), + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () { + setState(() { + _errorMessage = null; + }); + }, + ), + ], + ), + ); + } + + Widget _buildTypingIndicator() { + return const TypingIndicatorView( + statusText: 'AI is thinking...', + ); + } + + Widget _buildInputArea() { + return Container( + padding: const EdgeInsets.all(AppSpacing.large), + decoration: BoxDecoration( + color: AppColors.backgroundPrimary(context), + boxShadow: [ + BoxShadow( + color: AppColors.shadowLight, + blurRadius: AppSpacing.shadowLarge, + offset: const Offset(0, -2), + ), + ], + ), + child: SafeArea( + child: Row( + children: [ + Expanded( + child: TextField( + controller: _controller, + focusNode: _focusNode, + maxLines: 4, + minLines: 1, + textInputAction: TextInputAction.send, + decoration: InputDecoration( + hintText: 'Type a message...', + border: OutlineInputBorder( + borderRadius: + BorderRadius.circular(AppSpacing.cornerRadiusBubble), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.large, + vertical: AppSpacing.mediumLarge, + ), + ), + onSubmitted: (_) => _sendMessage(), + onChanged: (_) => setState(() {}), + ), + ), + const SizedBox(width: AppSpacing.smallMedium), + IconButton.filled( + onPressed: _canSend ? _sendMessage : null, + icon: _isGenerating + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ) + : const Icon(Icons.arrow_upward), + ), + ], + ), + ), + ); + } +} + +/// Message role enum +enum MessageRole { system, user, assistant } + +/// Chat message model +class ChatMessage { + final String id; + final MessageRole role; + final String content; + final String? thinkingContent; + final DateTime timestamp; + final MessageAnalytics? analytics; + + const ChatMessage({ + required this.id, + required this.role, + required this.content, + this.thinkingContent, + required this.timestamp, + this.analytics, + }); + + ChatMessage copyWith({ + String? id, + MessageRole? role, + String? content, + String? thinkingContent, + DateTime? timestamp, + MessageAnalytics? analytics, + }) { + return ChatMessage( + id: id ?? this.id, + role: role ?? this.role, + content: content ?? this.content, + thinkingContent: thinkingContent ?? this.thinkingContent, + timestamp: timestamp ?? this.timestamp, + analytics: analytics ?? this.analytics, + ); + } +} + +/// Message bubble widget +class _MessageBubble extends StatefulWidget { + final ChatMessage message; + + const _MessageBubble({required this.message}); + + @override + State<_MessageBubble> createState() => _MessageBubbleState(); +} + +class _MessageBubbleState extends State<_MessageBubble> { + bool _showThinking = false; + + @override + Widget build(BuildContext context) { + final isUser = widget.message.role == MessageRole.user; + + return Align( + alignment: isUser ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.only(bottom: AppSpacing.mediumLarge), + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.75, + ), + child: Column( + crossAxisAlignment: + isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + // Thinking section (if present) + if (widget.message.thinkingContent != null && + widget.message.thinkingContent!.isNotEmpty) + _buildThinkingSection(), + + // Main message bubble + Container( + padding: const EdgeInsets.all(AppSpacing.mediumLarge), + decoration: BoxDecoration( + gradient: isUser + ? LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + AppColors.userBubbleGradientStart, + AppColors.userBubbleGradientEnd, + ], + ) + : null, + color: isUser ? null : AppColors.backgroundGray5(context), + borderRadius: + BorderRadius.circular(AppSpacing.cornerRadiusBubble), + boxShadow: [ + BoxShadow( + color: AppColors.shadowLight, + blurRadius: AppSpacing.shadowSmall, + offset: const Offset(0, 1), + ), + ], + ), + child: isUser + ? Text( + widget.message.content, + style: AppTypography.body(context).copyWith( + color: AppColors.textWhite, + ), + ) + : MarkdownBody( + data: widget.message.content, + styleSheet: MarkdownStyleSheet( + p: AppTypography.body(context), + code: AppTypography.monospaced.copyWith( + backgroundColor: AppColors.backgroundGray6(context), + ), + ), + ), + ), + + // Analytics summary (if present) + if (widget.message.analytics != null && !isUser) + _buildAnalyticsSummary(), + ], + ), + ), + ); + } + + Widget _buildThinkingSection() { + return Container( + margin: const EdgeInsets.only(bottom: AppSpacing.smallMedium), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GestureDetector( + onTap: () { + setState(() { + _showThinking = !_showThinking; + }); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.lightbulb, + size: AppSpacing.iconRegular, + color: AppColors.primaryPurple, + ), + const SizedBox(width: AppSpacing.xSmall), + Text( + _showThinking ? 'Hide reasoning' : 'Show reasoning', + style: AppTypography.caption(context).copyWith( + color: AppColors.primaryPurple, + ), + ), + Icon( + _showThinking + ? Icons.keyboard_arrow_up + : Icons.keyboard_arrow_down, + size: AppSpacing.iconRegular, + color: AppColors.primaryPurple, + ), + ], + ), + ), + if (_showThinking) + Container( + margin: const EdgeInsets.only(top: AppSpacing.smallMedium), + padding: const EdgeInsets.all(AppSpacing.mediumLarge), + decoration: BoxDecoration( + color: AppColors.modelThinkingBg, + borderRadius: + BorderRadius.circular(AppSpacing.cornerRadiusRegular), + ), + child: Text( + widget.message.thinkingContent!, + style: AppTypography.caption(context), + ), + ), + ], + ), + ); + } + + Widget _buildAnalyticsSummary() { + final analytics = widget.message.analytics!; + + return Padding( + padding: const EdgeInsets.only(top: AppSpacing.xSmall), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (analytics.totalGenerationTime != null) + Text( + '${analytics.totalGenerationTime!.toStringAsFixed(1)}s', + style: AppTypography.caption(context).copyWith( + color: AppColors.textSecondary(context), + ), + ), + if (analytics.tokensPerSecond != null) ...[ + const SizedBox(width: AppSpacing.smallMedium), + Text( + '${analytics.tokensPerSecond!.toStringAsFixed(1)} tok/s', + style: AppTypography.caption(context).copyWith( + color: AppColors.textSecondary(context), + ), + ), + ], + if (analytics.wasThinkingMode) ...[ + const SizedBox(width: AppSpacing.smallMedium), + const Icon( + Icons.lightbulb, + size: 12, + color: AppColors.primaryPurple, + ), + ], + ], + ), + ); + } +} diff --git a/examples/flutter/RunAnywhereAI/lib/features/models/add_model_from_url_view.dart b/examples/flutter/RunAnywhereAI/lib/features/models/add_model_from_url_view.dart new file mode 100644 index 000000000..cac4691a9 --- /dev/null +++ b/examples/flutter/RunAnywhereAI/lib/features/models/add_model_from_url_view.dart @@ -0,0 +1,419 @@ +import 'package:flutter/material.dart'; + +import 'package:runanywhere_ai/core/design_system/app_colors.dart'; +import 'package:runanywhere_ai/core/design_system/app_spacing.dart'; +import 'package:runanywhere_ai/core/design_system/typography.dart'; +import 'package:runanywhere_ai/features/models/model_types.dart'; + +/// AddModelFromURLView (mirroring iOS AddModelFromURLView.swift) +/// +/// View for adding models from URLs. +class AddModelFromURLView extends StatefulWidget { + final void Function(ModelInfo) onModelAdded; + + const AddModelFromURLView({ + super.key, + required this.onModelAdded, + }); + + @override + State createState() => _AddModelFromURLViewState(); +} + +class _AddModelFromURLViewState extends State { + final _formKey = GlobalKey(); + final _nameController = TextEditingController(); + final _urlController = TextEditingController(); + final _sizeController = TextEditingController(); + + LLMFramework _selectedFramework = LLMFramework.llamaCpp; + bool _supportsThinking = false; + bool _useCustomThinkingTags = false; + String _thinkingOpenTag = ''; + String _thinkingCloseTag = ''; + bool _isAdding = false; + String? _errorMessage; + + final List _availableFrameworks = [ + LLMFramework.llamaCpp, + LLMFramework.mediaPipe, + LLMFramework.onnxRuntime, + ]; + + @override + void dispose() { + _nameController.dispose(); + _urlController.dispose(); + _sizeController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: AppColors.backgroundPrimary(context), + borderRadius: const BorderRadius.vertical( + top: Radius.circular(AppSpacing.cornerRadiusXLarge), + ), + ), + child: SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildHeader(context), + Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.all(AppSpacing.large), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildModelInfoSection(context), + const SizedBox(height: AppSpacing.xLarge), + _buildFrameworkSection(context), + const SizedBox(height: AppSpacing.xLarge), + _buildThinkingSection(context), + const SizedBox(height: AppSpacing.xLarge), + _buildAdvancedSection(context), + if (_errorMessage != null) ...[ + const SizedBox(height: AppSpacing.large), + _buildErrorMessage(context), + ], + const SizedBox(height: AppSpacing.xLarge), + _buildAddButton(context), + const SizedBox(height: AppSpacing.large), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildHeader(BuildContext context) { + return Container( + padding: const EdgeInsets.all(AppSpacing.large), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: AppColors.separator(context), + ), + ), + ), + child: Row( + children: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + Expanded( + child: Text( + 'Add Model from URL', + style: AppTypography.headline(context), + textAlign: TextAlign.center, + ), + ), + const SizedBox(width: 60), // Balance the cancel button + ], + ), + ); + } + + Widget _buildModelInfoSection(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Model Information', + style: AppTypography.subheadlineSemibold(context), + ), + const SizedBox(height: AppSpacing.mediumLarge), + TextFormField( + controller: _nameController, + decoration: const InputDecoration( + labelText: 'Model Name', + hintText: 'e.g., Llama 3.2 1B', + border: OutlineInputBorder(), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a model name'; + } + return null; + }, + ), + const SizedBox(height: AppSpacing.large), + TextFormField( + controller: _urlController, + decoration: const InputDecoration( + labelText: 'Download URL', + hintText: 'https://example.com/model.gguf', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.url, + autocorrect: false, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a URL'; + } + final uri = Uri.tryParse(value); + if (uri == null || !uri.hasScheme) { + return 'Please enter a valid URL'; + } + return null; + }, + ), + ], + ); + } + + Widget _buildFrameworkSection(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Framework', + style: AppTypography.subheadlineSemibold(context), + ), + const SizedBox(height: AppSpacing.mediumLarge), + DropdownButtonFormField( + initialValue: _selectedFramework, + decoration: const InputDecoration( + labelText: 'Target Framework', + border: OutlineInputBorder(), + ), + items: _availableFrameworks.map((framework) { + return DropdownMenuItem( + value: framework, + child: Text(framework.displayName), + ); + }).toList(), + onChanged: (value) { + if (value != null) { + setState(() { + _selectedFramework = value; + }); + } + }, + ), + ], + ); + } + + Widget _buildThinkingSection(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Thinking Support', + style: AppTypography.subheadlineSemibold(context), + ), + const SizedBox(height: AppSpacing.mediumLarge), + SwitchListTile( + title: const Text('Model Supports Thinking'), + value: _supportsThinking, + onChanged: (value) { + setState(() { + _supportsThinking = value; + }); + }, + contentPadding: EdgeInsets.zero, + ), + if (_supportsThinking) ...[ + SwitchListTile( + title: const Text('Use Custom Tags'), + value: _useCustomThinkingTags, + onChanged: (value) { + setState(() { + _useCustomThinkingTags = value; + }); + }, + contentPadding: EdgeInsets.zero, + ), + if (_useCustomThinkingTags) ...[ + const SizedBox(height: AppSpacing.mediumLarge), + TextFormField( + initialValue: _thinkingOpenTag, + decoration: const InputDecoration( + labelText: 'Opening Tag', + hintText: '', + border: OutlineInputBorder(), + ), + onChanged: (value) { + _thinkingOpenTag = value; + }, + ), + const SizedBox(height: AppSpacing.mediumLarge), + TextFormField( + initialValue: _thinkingCloseTag, + decoration: const InputDecoration( + labelText: 'Closing Tag', + hintText: '', + border: OutlineInputBorder(), + ), + onChanged: (value) { + _thinkingCloseTag = value; + }, + ), + ] else ...[ + const SizedBox(height: AppSpacing.smallMedium), + Row( + children: [ + Text( + 'Default tags: ', + style: AppTypography.caption(context), + ), + Text( + '...', + style: AppTypography.monospacedCaption.copyWith( + color: AppColors.textSecondary(context), + ), + ), + ], + ), + ], + ], + ], + ); + } + + Widget _buildAdvancedSection(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Advanced (Optional)', + style: AppTypography.subheadlineSemibold(context), + ), + const SizedBox(height: AppSpacing.mediumLarge), + TextFormField( + controller: _sizeController, + decoration: const InputDecoration( + labelText: 'Estimated Size (bytes)', + hintText: 'e.g., 1000000000', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.number, + ), + ], + ); + } + + Widget _buildErrorMessage(BuildContext context) { + return Container( + padding: const EdgeInsets.all(AppSpacing.mediumLarge), + decoration: BoxDecoration( + color: AppColors.badgeRed, + borderRadius: BorderRadius.circular(AppSpacing.cornerRadiusRegular), + ), + child: Row( + children: [ + const Icon(Icons.error, color: AppColors.statusRed), + const SizedBox(width: AppSpacing.smallMedium), + Expanded( + child: Text( + _errorMessage!, + style: AppTypography.caption(context).copyWith( + color: AppColors.statusRed, + ), + ), + ), + ], + ), + ); + } + + Widget _buildAddButton(BuildContext context) { + return Column( + children: [ + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _isAdding ? null : _addModel, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.large), + ), + child: _isAdding + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Add Model'), + ), + ), + if (_isAdding) ...[ + const SizedBox(height: AppSpacing.mediumLarge), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: AppSpacing.smallMedium), + Text( + 'Adding model...', + style: AppTypography.caption(context).copyWith( + color: AppColors.textSecondary(context), + ), + ), + ], + ), + ], + ], + ); + } + + Future _addModel() async { + if (!_formKey.currentState!.validate()) { + return; + } + + setState(() { + _isAdding = true; + _errorMessage = null; + }); + + try { + final url = _urlController.text.trim(); + final name = _nameController.text.trim(); + final sizeText = _sizeController.text.trim(); + final estimatedSize = sizeText.isNotEmpty ? int.tryParse(sizeText) : null; + + // TODO: Use RunAnywhere SDK to add model + // final modelInfo = await RunAnywhere.addModelFromURL( + // url, + // name: name, + // type: _selectedFramework.rawValue, + // ); + + // Create placeholder model for demo + final modelInfo = ModelInfo( + id: 'custom-${DateTime.now().millisecondsSinceEpoch}', + name: name, + category: ModelCategory.language, + format: ModelFormat.gguf, + downloadURL: url, + memoryRequired: estimatedSize, + compatibleFrameworks: [_selectedFramework], + preferredFramework: _selectedFramework, + supportsThinking: _supportsThinking, + ); + + widget.onModelAdded(modelInfo); + } catch (e) { + if (!mounted) return; + setState(() { + _errorMessage = 'Failed to add model: $e'; + _isAdding = false; + }); + } + } +} diff --git a/examples/flutter/RunAnywhereAI/lib/features/models/model_components.dart b/examples/flutter/RunAnywhereAI/lib/features/models/model_components.dart new file mode 100644 index 000000000..66281a7bf --- /dev/null +++ b/examples/flutter/RunAnywhereAI/lib/features/models/model_components.dart @@ -0,0 +1,520 @@ +import 'package:flutter/material.dart'; +import 'package:runanywhere/runanywhere.dart' as sdk; + +import 'package:runanywhere_ai/core/design_system/app_colors.dart'; +import 'package:runanywhere_ai/core/design_system/app_spacing.dart'; +import 'package:runanywhere_ai/core/design_system/typography.dart'; +import 'package:runanywhere_ai/core/models/app_types.dart'; +import 'package:runanywhere_ai/features/models/model_types.dart'; + +/// FrameworkRow (mirroring iOS FrameworkRow) +/// +/// A row displaying a framework with expand/collapse functionality. +class FrameworkRow extends StatelessWidget { + final LLMFramework framework; + final bool isExpanded; + final VoidCallback onTap; + + const FrameworkRow({ + super.key, + required this.framework, + required this.isExpanded, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.large, + vertical: AppSpacing.mediumLarge, + ), + child: Row( + children: [ + Icon( + _frameworkIcon, + color: _frameworkColor, + size: AppSpacing.iconMedium, + ), + const SizedBox(width: AppSpacing.mediumLarge), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + framework.displayName, + style: AppTypography.headline(context), + ), + const SizedBox(height: AppSpacing.xxSmall), + Text( + _frameworkDescription, + style: AppTypography.caption(context).copyWith( + color: AppColors.textSecondary(context), + ), + ), + ], + ), + ), + Icon( + isExpanded ? Icons.expand_less : Icons.expand_more, + color: AppColors.textSecondary(context), + size: AppSpacing.iconSmall, + ), + ], + ), + ), + ); + } + + IconData get _frameworkIcon { + switch (framework) { + case LLMFramework.foundationModels: + return Icons.apple; + case LLMFramework.mediaPipe: + return Icons.psychology; + case LLMFramework.llamaCpp: + return Icons.memory; + case LLMFramework.whisperKit: + return Icons.mic; + case LLMFramework.onnxRuntime: + return Icons.developer_board; + case LLMFramework.systemTTS: + return Icons.volume_up; + default: + return Icons.memory; + } + } + + Color get _frameworkColor { + switch (framework) { + case LLMFramework.foundationModels: + return Colors.black; + case LLMFramework.mediaPipe: + return AppColors.statusBlue; + case LLMFramework.whisperKit: + return AppColors.statusGreen; + default: + return AppColors.statusGray; + } + } + + String get _frameworkDescription { + switch (framework) { + case LLMFramework.foundationModels: + return "Apple's pre-installed system models"; + case LLMFramework.mediaPipe: + return "Google's cross-platform ML framework"; + case LLMFramework.llamaCpp: + return 'Fast C++ inference for GGUF models'; + case LLMFramework.whisperKit: + return 'OpenAI Whisper for speech recognition'; + case LLMFramework.onnxRuntime: + return 'Microsoft ONNX inference runtime'; + case LLMFramework.systemTTS: + return 'Built-in system text-to-speech'; + default: + return 'Machine learning framework'; + } + } +} + +/// ModelRow (mirroring iOS ModelRow) +/// +/// A row displaying a model with download/load options. +class ModelRow extends StatefulWidget { + final ModelInfo model; + final bool isSelected; + final VoidCallback onDownloadCompleted; + final VoidCallback onSelectModel; + final VoidCallback? onModelUpdated; + + const ModelRow({ + super.key, + required this.model, + required this.isSelected, + required this.onDownloadCompleted, + required this.onSelectModel, + this.onModelUpdated, + }); + + @override + State createState() => _ModelRowState(); +} + +class _ModelRowState extends State { + bool _isDownloading = false; + double _downloadProgress = 0.0; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.large, + vertical: AppSpacing.smallMedium, + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.model.name, + style: AppTypography.subheadline(context).copyWith( + fontWeight: + widget.isSelected ? FontWeight.w600 : FontWeight.normal, + ), + ), + const SizedBox(height: AppSpacing.xSmall), + _buildModelInfo(context), + const SizedBox(height: AppSpacing.xSmall), + _buildDownloadStatus(context), + ], + ), + ), + _buildActionButton(context), + ], + ), + ); + } + + Widget _buildModelInfo(BuildContext context) { + return Wrap( + spacing: AppSpacing.smallMedium, + runSpacing: AppSpacing.xSmall, + children: [ + if (widget.model.memoryRequired != null && + widget.model.memoryRequired! > 0) + _buildInfoChip( + context, + Icons.memory, + widget.model.memoryRequired!.formattedFileSize, + ), + _buildBadge( + context, + widget.model.format.rawValue.toUpperCase(), + AppColors.badgeGray, + AppColors.textSecondary(context), + ), + if (widget.model.supportsThinking) + _buildBadge( + context, + 'THINKING', + AppColors.badgePurple, + AppColors.primaryPurple, + icon: Icons.psychology, + ), + ], + ); + } + + Widget _buildInfoChip(BuildContext context, IconData icon, String label) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: 12, + color: AppColors.textSecondary(context), + ), + const SizedBox(width: 4), + Text( + label, + style: AppTypography.caption2(context).copyWith( + color: AppColors.textSecondary(context), + ), + ), + ], + ); + } + + Widget _buildBadge( + BuildContext context, + String label, + Color backgroundColor, + Color textColor, { + IconData? icon, + }) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.small, + vertical: AppSpacing.xxSmall, + ), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(AppSpacing.cornerRadiusSmall), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: 10, color: textColor), + const SizedBox(width: 2), + ], + Text( + label, + style: AppTypography.caption2(context).copyWith(color: textColor), + ), + ], + ), + ); + } + + Widget _buildDownloadStatus(BuildContext context) { + if (widget.model.downloadURL != null) { + if (widget.model.localPath == null) { + if (_isDownloading) { + return Row( + children: [ + Expanded( + child: LinearProgressIndicator(value: _downloadProgress), + ), + const SizedBox(width: AppSpacing.smallMedium), + Text( + '${(_downloadProgress * 100).toInt()}%', + style: AppTypography.caption2(context).copyWith( + color: AppColors.textSecondary(context), + ), + ), + ], + ); + } else { + return Text( + 'Available for download', + style: AppTypography.caption2(context).copyWith( + color: AppColors.statusBlue, + ), + ); + } + } else { + return Row( + children: [ + const Icon( + Icons.check_circle, + size: 12, + color: AppColors.statusGreen, + ), + const SizedBox(width: AppSpacing.xSmall), + Text( + 'Downloaded', + style: AppTypography.caption2(context).copyWith( + color: AppColors.statusGreen, + ), + ), + ], + ); + } + } + return const SizedBox.shrink(); + } + + Widget _buildActionButton(BuildContext context) { + if (widget.model.downloadURL != null && widget.model.localPath == null) { + // Model needs to be downloaded + if (_isDownloading) { + return Column( + children: [ + const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + if (_downloadProgress > 0) ...[ + const SizedBox(height: AppSpacing.xSmall), + Text( + '${(_downloadProgress * 100).toInt()}%', + style: AppTypography.caption2(context).copyWith( + color: AppColors.textSecondary(context), + ), + ), + ], + ], + ); + } else { + return ElevatedButton( + onPressed: _downloadModel, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.mediumLarge, + vertical: AppSpacing.smallMedium, + ), + ), + child: Text( + 'Download', + style: AppTypography.caption(context), + ), + ); + } + } else if (widget.model.localPath != null) { + // Model is downloaded + if (widget.isSelected) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.check_circle, + color: AppColors.statusGreen, + size: 16, + ), + const SizedBox(width: AppSpacing.xSmall), + Text( + 'Loaded', + style: AppTypography.caption2(context).copyWith( + color: AppColors.statusGreen, + ), + ), + ], + ); + } else { + return ElevatedButton( + onPressed: widget.onSelectModel, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.mediumLarge, + vertical: AppSpacing.smallMedium, + ), + ), + child: Text( + 'Load', + style: AppTypography.caption(context), + ), + ); + } + } + return const SizedBox.shrink(); + } + + Future _downloadModel() async { + setState(() { + _isDownloading = true; + _downloadProgress = 0.0; + }); + + try { + debugPrint('📥 Starting download for model: ${widget.model.name}'); + + // Start the actual download using SDK + final progressStream = sdk.RunAnywhere.downloadModel(widget.model.id); + + // Listen to real download progress + await for (final progress in progressStream) { + if (!mounted) return; + + final progressValue = progress.percentage; + + setState(() { + _downloadProgress = progressValue; + }); + + // Check if completed or failed + if (progress.state.isCompleted) { + debugPrint('✅ Download completed for model: ${widget.model.name}'); + break; + } else if (progress.state.isFailed) { + debugPrint('❌ Download failed for model: ${widget.model.name}'); + throw Exception('Download failed'); + } + } + + if (!mounted) return; + setState(() { + _isDownloading = false; + }); + widget.onDownloadCompleted(); + } catch (e) { + debugPrint('❌ Download error: $e'); + if (!mounted) return; + setState(() { + _isDownloading = false; + _downloadProgress = 0.0; + }); + + // Show error to user + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Download failed: $e')), + ); + } + } +} + +/// DeviceInfoRow widget +class DeviceInfoRow extends StatelessWidget { + final String label; + final IconData icon; + final String value; + + const DeviceInfoRow({ + super.key, + required this.label, + required this.icon, + required this.value, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.large, + vertical: AppSpacing.smallMedium, + ), + child: Row( + children: [ + Icon( + icon, + size: AppSpacing.iconSmall, + color: AppColors.primaryBlue, + ), + const SizedBox(width: AppSpacing.mediumLarge), + Text( + label, + style: AppTypography.body(context), + ), + const Spacer(), + Text( + value, + style: AppTypography.body(context).copyWith( + color: AppColors.textSecondary(context), + ), + ), + ], + ), + ); + } +} + +/// NeuralEngineRow widget +class NeuralEngineRow extends StatelessWidget { + const NeuralEngineRow({super.key}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.large, + vertical: AppSpacing.smallMedium, + ), + child: Row( + children: [ + const Icon( + Icons.psychology, + size: AppSpacing.iconSmall, + color: AppColors.primaryBlue, + ), + const SizedBox(width: AppSpacing.mediumLarge), + Text( + 'Neural Engine', + style: AppTypography.body(context), + ), + const Spacer(), + const Icon( + Icons.check_circle, + size: AppSpacing.iconSmall, + color: AppColors.statusGreen, + ), + ], + ), + ); + } +} diff --git a/examples/flutter/RunAnywhereAI/lib/features/models/model_list_view_model.dart b/examples/flutter/RunAnywhereAI/lib/features/models/model_list_view_model.dart new file mode 100644 index 000000000..44596d089 --- /dev/null +++ b/examples/flutter/RunAnywhereAI/lib/features/models/model_list_view_model.dart @@ -0,0 +1,394 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:runanywhere/runanywhere.dart' as sdk; + +import 'package:runanywhere_ai/features/models/model_types.dart'; + +/// ModelListViewModel (mirroring iOS ModelListViewModel.swift) +/// +/// Manages model loading, selection, and state. +/// Now properly fetches models from the SDK registry and uses SDK for downloads. +class ModelListViewModel extends ChangeNotifier { + static final ModelListViewModel shared = ModelListViewModel._(); + + ModelListViewModel._() { + unawaited(_initialize()); + } + + // State + List _availableModels = []; + List _availableFrameworks = []; + ModelInfo? _currentModel; + bool _isLoading = false; + String? _errorMessage; + + // Download progress tracking + final Map _downloadProgress = {}; + final Set _downloadingModels = {}; + + // Getters + List get availableModels => _availableModels; + List get availableFrameworks => _availableFrameworks; + ModelInfo? get currentModel => _currentModel; + bool get isLoading => _isLoading; + String? get errorMessage => _errorMessage; + Map get downloadProgress => + Map.unmodifiable(_downloadProgress); + bool isDownloading(String modelId) => _downloadingModels.contains(modelId); + + Future _initialize() async { + await loadModelsFromRegistry(); + } + + /// Load models from SDK registry + /// Fetches all registered models from the RunAnywhere SDK + Future loadModelsFromRegistry() async { + _isLoading = true; + _errorMessage = null; + notifyListeners(); + + try { + // Get all models from SDK registry + final sdkModels = await sdk.RunAnywhere.availableModels(); + + // Convert SDK ModelInfo to app ModelInfo + _availableModels = sdkModels.map(_convertSDKModel).toList(); + + debugPrint( + '✅ Loaded ${_availableModels.length} models from SDK registry'); + for (final model in _availableModels) { + debugPrint( + ' - ${model.name} (${model.category.displayName}) [${model.preferredFramework?.displayName ?? "Unknown"}] downloaded: ${model.isDownloaded}'); + } + } catch (e) { + debugPrint('❌ Failed to load models from SDK: $e'); + _errorMessage = 'Failed to load models: $e'; + _availableModels = []; + } + + _currentModel = null; + _isLoading = false; + notifyListeners(); + } + + /// Convert SDK ModelInfo to app ModelInfo + ModelInfo _convertSDKModel(sdk.ModelInfo sdkModel) { + final framework = _convertFramework(sdkModel.framework); + return ModelInfo( + id: sdkModel.id, + name: sdkModel.name, + category: _convertCategory(sdkModel.category), + format: _convertFormat(sdkModel.format), + downloadURL: sdkModel.downloadURL?.toString(), + localPath: sdkModel.localPath?.toFilePath(), + memoryRequired: sdkModel.downloadSize, + compatibleFrameworks: [framework], + preferredFramework: framework, + supportsThinking: sdkModel.supportsThinking, + ); + } + + /// Convert SDK ModelCategory to app ModelCategory + ModelCategory _convertCategory(sdk.ModelCategory sdkCategory) { + switch (sdkCategory) { + case sdk.ModelCategory.language: + return ModelCategory.language; + case sdk.ModelCategory.multimodal: + return ModelCategory.multimodal; + case sdk.ModelCategory.speechRecognition: + return ModelCategory.speechRecognition; + case sdk.ModelCategory.speechSynthesis: + return ModelCategory.speechSynthesis; + case sdk.ModelCategory.vision: + return ModelCategory.vision; + case sdk.ModelCategory.imageGeneration: + return ModelCategory.imageGeneration; + case sdk.ModelCategory.audio: + return ModelCategory.audio; + } + } + + /// Convert SDK ModelFormat to app ModelFormat + ModelFormat _convertFormat(sdk.ModelFormat sdkFormat) { + switch (sdkFormat) { + case sdk.ModelFormat.gguf: + return ModelFormat.gguf; + case sdk.ModelFormat.onnx: + case sdk.ModelFormat.ort: + return ModelFormat.onnx; + case sdk.ModelFormat.bin: + return ModelFormat.bin; + case sdk.ModelFormat.unknown: + return ModelFormat.unknown; + } + } + + /// Convert SDK InferenceFramework to app LLMFramework + LLMFramework _convertFramework(sdk.InferenceFramework sdkFramework) { + switch (sdkFramework) { + case sdk.InferenceFramework.llamaCpp: + return LLMFramework.llamaCpp; + case sdk.InferenceFramework.foundationModels: + return LLMFramework.foundationModels; + case sdk.InferenceFramework.onnx: + return LLMFramework.onnxRuntime; + case sdk.InferenceFramework.systemTTS: + return LLMFramework.systemTTS; + default: + return LLMFramework.unknown; + } + } + + /// Convert app LLMFramework to SDK InferenceFramework + sdk.InferenceFramework _convertToSDKFramework(LLMFramework framework) { + switch (framework) { + case LLMFramework.llamaCpp: + return sdk.InferenceFramework.llamaCpp; + case LLMFramework.foundationModels: + return sdk.InferenceFramework.foundationModels; + case LLMFramework.onnxRuntime: + return sdk.InferenceFramework.onnx; + case LLMFramework.systemTTS: + return sdk.InferenceFramework.systemTTS; + case LLMFramework.mediaPipe: + case LLMFramework.whisperKit: + case LLMFramework.unknown: + return sdk.InferenceFramework.unknown; + } + } + + /// Get available frameworks based on registered models + Future loadAvailableFrameworks() async { + try { + // Extract unique frameworks from available models + final frameworks = {}; + for (final model in _availableModels) { + if (model.preferredFramework != null) { + frameworks.add(model.preferredFramework!); + } + frameworks.addAll(model.compatibleFrameworks); + } + _availableFrameworks = frameworks.toList(); + debugPrint( + '✅ Available frameworks: ${_availableFrameworks.map((f) => f.displayName).join(", ")}'); + notifyListeners(); + } catch (e) { + debugPrint('❌ Failed to load frameworks: $e'); + _availableFrameworks = []; + notifyListeners(); + } + } + + /// Alias for loadModelsFromRegistry + Future loadModels() async { + await loadModelsFromRegistry(); + await loadAvailableFrameworks(); + } + + /// Set current model + void setCurrentModel(ModelInfo? model) { + _currentModel = model; + notifyListeners(); + } + + /// Select and load a model + Future selectModel(ModelInfo model) async { + try { + await loadModel(model); + setCurrentModel(model); + debugPrint('✅ Model ${model.name} selected and loaded'); + } catch (e) { + _errorMessage = 'Failed to load model: $e'; + notifyListeners(); + } + } + + /// Download a model using SDK DownloadService + /// This is the proper implementation using the SDK's download functionality + Future downloadModel( + ModelInfo model, + void Function(double) progressHandler, + ) async { + if (_downloadingModels.contains(model.id)) { + debugPrint('⚠️ Model ${model.id} is already downloading'); + return; + } + + _downloadingModels.add(model.id); + _downloadProgress[model.id] = 0.0; + notifyListeners(); + + try { + debugPrint('📥 Starting download for model: ${model.name}'); + + // Use SDK's public download API + await for (final progress in sdk.RunAnywhere.downloadModel(model.id)) { + final progressValue = progress.totalBytes > 0 + ? progress.bytesDownloaded / progress.totalBytes + : 0.0; + + _downloadProgress[model.id] = progressValue; + progressHandler(progressValue); + notifyListeners(); + + // Check if completed or failed + if (progress.state.isCompleted) { + debugPrint('✅ Download completed for model: ${model.name}'); + break; + } else if (progress.state.isFailed) { + throw Exception('Download failed'); + } + } + + // Update model with local path after download + await loadModelsFromRegistry(); + + debugPrint('✅ Model ${model.name} download complete'); + } catch (e) { + debugPrint('❌ Failed to download model ${model.id}: $e'); + _errorMessage = 'Download failed: $e'; + } finally { + _downloadingModels.remove(model.id); + _downloadProgress.remove(model.id); + notifyListeners(); + } + } + + /// Delete a downloaded model using SDK + Future deleteModel(ModelInfo model) async { + try { + debugPrint('🗑️ Deleting model: ${model.name}'); + + // Use SDK's public delete API (now only takes modelId) + await sdk.RunAnywhere.deleteStoredModel(model.id); + + // Refresh models from registry + await loadModelsFromRegistry(); + + debugPrint('✅ Model ${model.name} deleted successfully'); + } catch (e) { + debugPrint('❌ Failed to delete model: $e'); + _errorMessage = 'Failed to delete model: $e'; + notifyListeners(); + } + } + + /// Load a model into memory using SDK + Future loadModel(ModelInfo model) async { + _isLoading = true; + notifyListeners(); + + try { + debugPrint('⏳ Loading model: ${model.name}'); + + // Use appropriate SDK method based on model category + switch (model.category) { + case ModelCategory.language: + await sdk.RunAnywhere.loadModel(model.id); + break; + case ModelCategory.speechRecognition: + await sdk.RunAnywhere.loadSTTModel(model.id); + break; + case ModelCategory.speechSynthesis: + await sdk.RunAnywhere.loadTTSVoice(model.id); + break; + default: + // Default to LLM model loading + await sdk.RunAnywhere.loadModel(model.id); + } + + _currentModel = model; + debugPrint('✅ Model ${model.name} loaded successfully'); + } catch (e) { + debugPrint('❌ Failed to load model ${model.id}: $e'); + _errorMessage = 'Failed to load model: $e'; + rethrow; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + /// Unload the current model + Future unloadCurrentModel() async { + if (_currentModel == null) return; + + _isLoading = true; + notifyListeners(); + + try { + await sdk.RunAnywhere.unloadModel(); + _currentModel = null; + debugPrint('✅ Model unloaded successfully'); + } catch (e) { + debugPrint('❌ Failed to unload model: $e'); + _errorMessage = 'Failed to unload model: $e'; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + /// Add a custom model from URL using SDK + Future addModelFromURL({ + required String name, + required String url, + required LLMFramework framework, + int? estimatedSize, + bool supportsThinking = false, + }) async { + try { + debugPrint('➕ Adding model from URL: $name'); + + // Use SDK's public registration API + final modelInfo = sdk.RunAnywhere.registerModel( + name: name, + url: Uri.parse(url), + framework: _convertToSDKFramework(framework), + modality: sdk.ModelCategory.language, + supportsThinking: supportsThinking, + ); + + debugPrint( + '✅ Registered model with SDK: ${modelInfo.name} (${modelInfo.id})'); + + // Refresh models from registry + await loadModelsFromRegistry(); + + debugPrint('✅ Model $name added successfully'); + } catch (e) { + debugPrint('❌ Failed to add model from URL: $e'); + _errorMessage = 'Failed to add model: $e'; + notifyListeners(); + } + } + + /// Add an imported model + Future addImportedModel(ModelInfo model) async { + await loadModelsFromRegistry(); + } + + /// Get models for a specific framework + List modelsForFramework(LLMFramework framework) { + return _availableModels.where((model) { + if (framework == LLMFramework.foundationModels) { + return model.preferredFramework == LLMFramework.foundationModels; + } + return model.compatibleFrameworks.contains(framework); + }).toList(); + } + + /// Get models for a specific context + List modelsForContext(ModelSelectionContext context) { + return _availableModels.where((model) { + return context.relevantCategories.contains(model.category); + }).toList(); + } + + /// Clear error message + void clearError() { + _errorMessage = null; + notifyListeners(); + } +} diff --git a/examples/flutter/RunAnywhereAI/lib/features/models/model_selection_sheet.dart b/examples/flutter/RunAnywhereAI/lib/features/models/model_selection_sheet.dart new file mode 100644 index 000000000..19e4eeb96 --- /dev/null +++ b/examples/flutter/RunAnywhereAI/lib/features/models/model_selection_sheet.dart @@ -0,0 +1,939 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:runanywhere/runanywhere.dart' as sdk; + +import 'package:runanywhere_ai/core/design_system/app_colors.dart'; +import 'package:runanywhere_ai/core/design_system/app_spacing.dart'; +import 'package:runanywhere_ai/core/design_system/typography.dart'; +import 'package:runanywhere_ai/core/models/app_types.dart'; +import 'package:runanywhere_ai/core/services/device_info_service.dart'; +import 'package:runanywhere_ai/features/models/model_list_view_model.dart'; +import 'package:runanywhere_ai/features/models/model_types.dart'; + +/// ModelSelectionSheet (mirroring iOS ModelSelectionSheet.swift) +/// +/// Reusable model selection sheet with flat list of models (no framework expansion). +/// Models are filtered by context and sorted by availability (built-in first, +/// then downloaded, then available for download). +class ModelSelectionSheet extends StatefulWidget { + final ModelSelectionContext context; + final Future Function(ModelInfo) onModelSelected; + + const ModelSelectionSheet({ + super.key, + this.context = ModelSelectionContext.llm, + required this.onModelSelected, + }); + + @override + State createState() => _ModelSelectionSheetState(); +} + +class _ModelSelectionSheetState extends State { + final ModelListViewModel _viewModel = ModelListViewModel.shared; + final DeviceInfoService _deviceInfo = DeviceInfoService.shared; + + ModelInfo? _selectedModel; + bool _isLoadingModel = false; + String _loadingProgress = ''; + + /// Get all models relevant to this context, sorted by availability + List get _availableModels { + final models = _viewModel.availableModels.where((model) { + return widget.context.relevantCategories.contains(model.category); + }).toList(); + + // Sort: Foundation Models first (built-in), then downloaded, then not downloaded + models.sort((a, b) { + final aPriority = a.preferredFramework == LLMFramework.foundationModels + ? 0 + : (a.localPath != null ? 1 : 2); + final bPriority = b.preferredFramework == LLMFramework.foundationModels + ? 0 + : (b.localPath != null ? 1 : 2); + if (aPriority != bPriority) { + return aPriority.compareTo(bPriority); + } + return a.name.compareTo(b.name); + }); + + return models; + } + + @override + void initState() { + super.initState(); + unawaited(_loadInitialData()); + } + + Future _loadInitialData() async { + await _viewModel.loadModels(); + } + + @override + Widget build(BuildContext context) { + return Container( + height: MediaQuery.of(context).size.height * 0.85, + decoration: BoxDecoration( + color: AppColors.backgroundPrimary(context), + borderRadius: const BorderRadius.vertical( + top: Radius.circular(AppSpacing.cornerRadiusXLarge), + ), + ), + child: Stack( + children: [ + Column( + children: [ + _buildHeader(context), + Expanded( + child: ListenableBuilder( + listenable: _viewModel, + builder: (context, _) { + if (_viewModel.isLoading && + _viewModel.availableModels.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + + return ListView( + children: [ + _buildDeviceStatusSection(context), + _buildModelsListSection(context), + ], + ); + }, + ), + ), + ], + ), + if (_isLoadingModel) _buildLoadingOverlay(context), + ], + ), + ); + } + + Widget _buildHeader(BuildContext context) { + return Container( + padding: const EdgeInsets.all(AppSpacing.large), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: AppColors.separator(context), + ), + ), + ), + child: Row( + children: [ + TextButton( + onPressed: _isLoadingModel ? null : () => Navigator.pop(context), + child: const Text('Cancel'), + ), + Expanded( + child: Text( + widget.context.title, + style: AppTypography.headline(context), + textAlign: TextAlign.center, + ), + ), + // Spacer to balance the Cancel button + const SizedBox(width: 70), + ], + ), + ); + } + + Widget _buildDeviceStatusSection(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionHeader(context, 'Device Status'), + ListenableBuilder( + listenable: _deviceInfo, + builder: (context, _) { + final device = _deviceInfo.deviceInfo; + if (device == null) { + return _buildLoadingRow(context, 'Loading device info...'); + } + return Column( + children: [ + _buildDeviceInfoRow( + context, + label: 'Model', + icon: Icons.phone_iphone, + value: device.modelName, + ), + _buildDeviceInfoRow( + context, + label: 'Chip', + icon: Icons.memory, + value: device.chipName, + ), + _buildDeviceInfoRow( + context, + label: 'Memory', + icon: Icons.storage, + value: device.totalMemory.formattedFileSize, + ), + if (device.neuralEngineAvailable) + _buildDeviceInfoRow( + context, + label: 'Neural Engine', + icon: Icons.psychology, + value: '', + trailing: const Icon( + Icons.check_circle, + color: AppColors.statusGreen, + size: 18, + ), + ), + ], + ); + }, + ), + const Divider(), + ], + ); + } + + Widget _buildDeviceInfoRow( + BuildContext context, { + required String label, + required IconData icon, + required String value, + Widget? trailing, + }) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.large, + vertical: AppSpacing.smallMedium, + ), + child: Row( + children: [ + Icon(icon, size: 18, color: AppColors.textSecondary(context)), + const SizedBox(width: AppSpacing.smallMedium), + Text(label, style: AppTypography.body(context)), + const Spacer(), + if (trailing != null) + trailing + else + Text( + value, + style: AppTypography.body(context).copyWith( + color: AppColors.textSecondary(context), + ), + ), + ], + ), + ); + } + + /// Flat list of all available models with framework badges (matches iOS) + Widget _buildModelsListSection(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionHeader(context, 'Choose a Model'), + + if (_availableModels.isEmpty) + _buildEmptyModelsMessage(context) + else ...[ + // System TTS option for TTS context + if (widget.context == ModelSelectionContext.tts) + _buildSystemTTSRow(context), + + // All models in a flat list + ..._availableModels.map((model) { + return _FlatModelRow( + model: model, + isSelected: _selectedModel?.id == model.id, + isLoading: _isLoadingModel, + onDownloadCompleted: () async { + await _viewModel.loadModels(); + }, + onSelectModel: () async { + await _selectAndLoadModel(model); + }, + onModelUpdated: () async { + await _viewModel.loadModels(); + }, + ); + }), + ], + + // Footer text + Padding( + padding: const EdgeInsets.all(AppSpacing.large), + child: Text( + 'All models run privately on your device. Larger models may ' + 'provide better quality but use more memory.', + style: AppTypography.caption(context).copyWith( + color: AppColors.textSecondary(context), + ), + ), + ), + ], + ); + } + + Widget _buildSystemTTSRow(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.large, + vertical: AppSpacing.smallMedium, + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Name with badge + Row( + children: [ + Text( + 'System Voice', + style: AppTypography.subheadline(context).copyWith( + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(width: AppSpacing.smallMedium), + Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.small, + vertical: AppSpacing.xxSmall, + ), + decoration: BoxDecoration( + color: AppColors.textPrimary(context) + .withValues(alpha: 0.1), + borderRadius: + BorderRadius.circular(AppSpacing.cornerRadiusSmall), + ), + child: Text( + 'System', + style: AppTypography.caption2(context).copyWith( + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + const SizedBox(height: AppSpacing.xSmall), + // Status + Row( + children: [ + const Icon( + Icons.check_circle, + size: 12, + color: AppColors.statusGreen, + ), + const SizedBox(width: AppSpacing.xxSmall), + Text( + 'Built-in • Always available', + style: AppTypography.caption2(context).copyWith( + color: AppColors.statusGreen, + ), + ), + ], + ), + ], + ), + ), + ElevatedButton( + onPressed: _isLoadingModel ? null : _selectSystemTTS, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.mediumLarge, + vertical: AppSpacing.small, + ), + ), + child: const Text('Use'), + ), + ], + ), + ); + } + + Widget _buildLoadingOverlay(BuildContext context) { + return Container( + color: AppColors.overlayMedium, + child: Center( + child: Container( + padding: const EdgeInsets.all(AppSpacing.xxLarge), + decoration: BoxDecoration( + color: AppColors.backgroundPrimary(context), + borderRadius: BorderRadius.circular(AppSpacing.cornerRadiusXLarge), + boxShadow: [ + BoxShadow( + color: AppColors.shadowDark, + blurRadius: AppSpacing.shadowXLarge, + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: AppSpacing.xLarge), + Text( + 'Loading Model', + style: AppTypography.headline(context), + ), + const SizedBox(height: AppSpacing.smallMedium), + Text( + _loadingProgress, + style: AppTypography.subheadline(context).copyWith( + color: AppColors.textSecondary(context), + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ); + } + + Widget _buildSectionHeader(BuildContext context, String title) { + return Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.large, + AppSpacing.large, + AppSpacing.large, + AppSpacing.smallMedium, + ), + child: Text( + title, + style: AppTypography.caption(context).copyWith( + color: AppColors.textSecondary(context), + fontWeight: FontWeight.w600, + ), + ), + ); + } + + Widget _buildLoadingRow(BuildContext context, String message) { + return Padding( + padding: const EdgeInsets.all(AppSpacing.large), + child: Row( + children: [ + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: AppSpacing.mediumLarge), + Text( + message, + style: AppTypography.body(context).copyWith( + color: AppColors.textSecondary(context), + ), + ), + ], + ), + ); + } + + Widget _buildEmptyModelsMessage(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(AppSpacing.xLarge), + child: Center( + child: Column( + children: [ + const CircularProgressIndicator(), + const SizedBox(height: AppSpacing.mediumLarge), + Text( + 'Loading available models...', + style: AppTypography.subheadline(context).copyWith( + color: AppColors.textSecondary(context), + ), + ), + ], + ), + ), + ); + } + + Future _selectSystemTTS() async { + setState(() { + _isLoadingModel = true; + _loadingProgress = 'Configuring System TTS...'; + }); + + // Create pseudo ModelInfo for System TTS + const systemTTSModel = ModelInfo( + id: 'system-tts', + name: 'System TTS', + category: ModelCategory.speechSynthesis, + format: ModelFormat.unknown, + compatibleFrameworks: [LLMFramework.systemTTS], + preferredFramework: LLMFramework.systemTTS, + ); + + await Future.delayed(const Duration(milliseconds: 300)); + + setState(() { + _loadingProgress = 'System TTS ready!'; + }); + + await Future.delayed(const Duration(milliseconds: 200)); + + await widget.onModelSelected(systemTTSModel); + + if (mounted) { + setState(() { + _isLoadingModel = false; + }); + Navigator.pop(context); + } + } + + Future _selectAndLoadModel(ModelInfo model) async { + // Foundation Models don't need local path check + if (model.preferredFramework != LLMFramework.foundationModels) { + if (model.localPath == null) { + return; // Model not downloaded yet + } + } + + setState(() { + _isLoadingModel = true; + _loadingProgress = 'Initializing ${model.name}...'; + _selectedModel = model; + }); + + try { + setState(() { + _loadingProgress = 'Loading model into memory...'; + }); + + // Load model based on context/modality using real SDK + switch (widget.context) { + case ModelSelectionContext.llm: + debugPrint('🎯 Loading LLM model: ${model.id}'); + await sdk.RunAnywhere.loadModel(model.id); + break; + case ModelSelectionContext.stt: + debugPrint('🎯 Loading STT model: ${model.id}'); + await sdk.RunAnywhere.loadSTTModel(model.id); + break; + case ModelSelectionContext.tts: + debugPrint('🎯 Loading TTS voice: ${model.id}'); + await sdk.RunAnywhere.loadTTSVoice(model.id); + break; + case ModelSelectionContext.voice: + // Determine based on model category + if (model.category == ModelCategory.speechRecognition) { + debugPrint('🎯 Loading Voice STT model: ${model.id}'); + await sdk.RunAnywhere.loadSTTModel(model.id); + } else if (model.category == ModelCategory.speechSynthesis) { + debugPrint('🎯 Loading Voice TTS voice: ${model.id}'); + await sdk.RunAnywhere.loadTTSVoice(model.id); + } else { + debugPrint('🎯 Loading Voice LLM model: ${model.id}'); + await sdk.RunAnywhere.loadModel(model.id); + } + break; + } + + setState(() { + _loadingProgress = 'Model loaded successfully!'; + }); + + await Future.delayed(const Duration(milliseconds: 300)); + + await _viewModel.selectModel(model); + await widget.onModelSelected(model); + + if (mounted) { + Navigator.pop(context); + } + } catch (e) { + debugPrint('❌ Failed to load model: $e'); + setState(() { + _isLoadingModel = false; + _loadingProgress = ''; + _selectedModel = null; + }); + + // Show error to user + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to load model: $e')), + ); + } + } + } +} + +/// Flat model row for the selection sheet (matches iOS FlatModelRow) +class _FlatModelRow extends StatefulWidget { + final ModelInfo model; + final bool isSelected; + final bool isLoading; + final VoidCallback onDownloadCompleted; + final VoidCallback onSelectModel; + final VoidCallback? onModelUpdated; + + const _FlatModelRow({ + required this.model, + required this.isSelected, + required this.isLoading, + required this.onDownloadCompleted, + required this.onSelectModel, + this.onModelUpdated, + }); + + @override + State<_FlatModelRow> createState() => _FlatModelRowState(); +} + +class _FlatModelRowState extends State<_FlatModelRow> { + bool _isDownloading = false; + double _downloadProgress = 0.0; + + Color get _frameworkColor { + final framework = widget.model.preferredFramework; + if (framework == null) return Colors.grey; + switch (framework) { + case LLMFramework.llamaCpp: + return AppColors.primaryBlue; + case LLMFramework.onnxRuntime: + return Colors.purple; + case LLMFramework.foundationModels: + return Colors.grey; + case LLMFramework.whisperKit: + return Colors.green; + default: + return Colors.grey; + } + } + + String get _frameworkName { + final framework = widget.model.preferredFramework; + if (framework == null) return 'Unknown'; + switch (framework) { + case LLMFramework.llamaCpp: + return 'Fast'; + case LLMFramework.onnxRuntime: + return 'ONNX'; + case LLMFramework.foundationModels: + return 'Apple'; + case LLMFramework.whisperKit: + return 'Whisper'; + default: + return framework.displayName; + } + } + + IconData get _statusIcon { + if (widget.model.preferredFramework == LLMFramework.foundationModels) { + return Icons.check_circle; + } else if (widget.model.localPath != null) { + return Icons.check_circle; + } else { + return Icons.download; + } + } + + Color get _statusColor { + if (widget.model.preferredFramework == LLMFramework.foundationModels || + widget.model.localPath != null) { + return AppColors.statusGreen; + } else { + return AppColors.primaryBlue; + } + } + + String get _statusText { + if (widget.model.preferredFramework == LLMFramework.foundationModels) { + return 'Built-in'; + } else if (widget.model.localPath != null) { + return 'Ready'; + } else { + return 'Download'; + } + } + + @override + Widget build(BuildContext context) { + return Opacity( + opacity: widget.isLoading && !widget.isSelected ? 0.6 : 1.0, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.large, + vertical: AppSpacing.smallMedium, + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Model name with framework badge inline + Row( + children: [ + Flexible( + child: Text( + widget.model.name, + style: AppTypography.subheadline(context).copyWith( + fontWeight: FontWeight.w500, + ), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: AppSpacing.smallMedium), + Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.small, + vertical: AppSpacing.xxSmall, + ), + decoration: BoxDecoration( + color: _frameworkColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular( + AppSpacing.cornerRadiusSmall), + ), + child: Text( + _frameworkName, + style: AppTypography.caption2(context).copyWith( + fontWeight: FontWeight.w500, + color: _frameworkColor, + ), + ), + ), + ], + ), + const SizedBox(height: AppSpacing.xSmall), + // Size and status row + Row( + children: [ + // Size badge + if (widget.model.memoryRequired != null && + widget.model.memoryRequired! > 0) ...[ + Icon( + Icons.memory, + size: 12, + color: AppColors.textSecondary(context), + ), + const SizedBox(width: 4), + Text( + widget.model.memoryRequired!.formattedFileSize, + style: AppTypography.caption2(context).copyWith( + color: AppColors.textSecondary(context), + ), + ), + const SizedBox(width: AppSpacing.smallMedium), + ], + // Status indicator + if (_isDownloading) ...[ + SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator( + strokeWidth: 2, + value: _downloadProgress > 0 + ? _downloadProgress + : null, + ), + ), + const SizedBox(width: AppSpacing.xSmall), + Text( + '${(_downloadProgress * 100).toInt()}%', + style: AppTypography.caption2(context).copyWith( + color: AppColors.textSecondary(context), + ), + ), + ] else ...[ + Icon( + _statusIcon, + size: 12, + color: _statusColor, + ), + const SizedBox(width: AppSpacing.xxSmall), + Text( + _statusText, + style: AppTypography.caption2(context).copyWith( + color: _statusColor, + ), + ), + ], + // Thinking support indicator + if (widget.model.supportsThinking) ...[ + const SizedBox(width: AppSpacing.smallMedium), + Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.small, + vertical: AppSpacing.xxSmall, + ), + decoration: BoxDecoration( + color: AppColors.badgePurple, + borderRadius: BorderRadius.circular( + AppSpacing.cornerRadiusSmall), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.psychology, + size: 10, + color: AppColors.primaryPurple, + ), + const SizedBox(width: 2), + Text( + 'Smart', + style: AppTypography.caption2(context).copyWith( + color: AppColors.primaryPurple, + ), + ), + ], + ), + ), + ], + ], + ), + ], + ), + ), + const SizedBox(width: AppSpacing.mediumLarge), + _buildActionButton(context), + ], + ), + ), + ); + } + + Widget _buildActionButton(BuildContext context) { + if (widget.model.preferredFramework == LLMFramework.foundationModels) { + // Foundation Models are built-in + return ElevatedButton( + onPressed: + widget.isLoading || widget.isSelected ? null : widget.onSelectModel, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.mediumLarge, + vertical: AppSpacing.small, + ), + ), + child: const Text('Use'), + ); + } + + if (widget.model.localPath == null) { + // Model needs to be downloaded + if (_isDownloading) { + return const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ); + } + return OutlinedButton.icon( + onPressed: widget.isLoading ? null : _downloadModel, + icon: const Icon(Icons.download, size: 16), + label: const Text('Get'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.mediumLarge, + vertical: AppSpacing.small, + ), + ), + ); + } + + // Model is downloaded - ready to use + return ElevatedButton( + onPressed: + widget.isLoading || widget.isSelected ? null : widget.onSelectModel, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.mediumLarge, + vertical: AppSpacing.small, + ), + ), + child: const Text('Use'), + ); + } + + Future _downloadModel() async { + setState(() { + _isDownloading = true; + _downloadProgress = 0.0; + }); + + try { + debugPrint('📥 Starting download for model: ${widget.model.name}'); + + // Get the SDK model by ID + final sdkModels = await sdk.RunAnywhere.availableModels(); + final sdkModel = sdkModels.firstWhere( + (m) => m.id == widget.model.id, + orElse: () => + throw Exception('Model not found in registry: ${widget.model.id}'), + ); + + // Start the actual download using SDK's downloadModel + final downloadProgress = sdk.RunAnywhere.downloadModel(sdkModel.id); + + // Listen to real download progress + await for (final progress in downloadProgress) { + if (!mounted) return; + + final progressValue = progress.totalBytes > 0 + ? progress.bytesDownloaded / progress.totalBytes + : 0.0; + + setState(() { + _downloadProgress = progressValue; + }); + + // Check if completed or failed + if (progress.state == sdk.DownloadProgressState.completed) { + debugPrint('✅ Download completed for model: ${widget.model.name}'); + break; + } else if (progress.state == sdk.DownloadProgressState.failed) { + debugPrint('❌ Download failed for model: ${widget.model.name}'); + throw Exception('Download failed'); + } + } + + if (!mounted) return; + setState(() { + _isDownloading = false; + }); + widget.onDownloadCompleted(); + } catch (e) { + debugPrint('❌ Download error: $e'); + if (!mounted) return; + setState(() { + _isDownloading = false; + _downloadProgress = 0.0; + }); + + // Show error to user + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Download failed: $e')), + ); + } + } +} + +/// Helper function to show model selection sheet +Future showModelSelectionSheet( + BuildContext context, { + ModelSelectionContext modelContext = ModelSelectionContext.llm, +}) async { + ModelInfo? selectedModel; + + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => ModelSelectionSheet( + context: modelContext, + onModelSelected: (model) async { + selectedModel = model; + }, + ), + ); + + return selectedModel; +} diff --git a/examples/flutter/RunAnywhereAI/lib/features/models/model_status_components.dart b/examples/flutter/RunAnywhereAI/lib/features/models/model_status_components.dart new file mode 100644 index 000000000..49939beb4 --- /dev/null +++ b/examples/flutter/RunAnywhereAI/lib/features/models/model_status_components.dart @@ -0,0 +1,963 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'package:runanywhere_ai/core/design_system/app_colors.dart'; +import 'package:runanywhere_ai/core/design_system/app_spacing.dart'; +import 'package:runanywhere_ai/core/design_system/typography.dart'; +import 'package:runanywhere_ai/core/models/app_types.dart'; +import 'package:runanywhere_ai/features/models/model_types.dart'; + +/// ModelStatusBanner (mirroring iOS ModelStatusBanner) +/// +/// A banner that shows the current model status (framework + model name) or prompts to select a model. +class ModelStatusBanner extends StatelessWidget { + final LLMFramework? framework; + final String? modelName; + final bool isLoading; + final VoidCallback onSelectModel; + + const ModelStatusBanner({ + super.key, + required this.framework, + required this.modelName, + required this.isLoading, + required this.onSelectModel, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.large, + vertical: AppSpacing.mediumLarge, + ), + decoration: BoxDecoration( + color: AppColors.backgroundGray6(context), + borderRadius: BorderRadius.circular(AppSpacing.cornerRadiusCard), + ), + child: isLoading + ? _buildLoadingState(context) + : (framework != null && modelName != null) + ? _buildLoadedState(context) + : _buildNoModelState(context), + ); + } + + Widget _buildLoadingState(BuildContext context) { + return Row( + children: [ + SizedBox( + width: AppSpacing.iconRegular, + height: AppSpacing.iconRegular, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.textSecondary(context), + ), + ), + const SizedBox(width: AppSpacing.smallMedium), + Text( + 'Loading model...', + style: AppTypography.subheadline(context).copyWith( + color: AppColors.textSecondary(context), + ), + ), + ], + ); + } + + Widget _buildLoadedState(BuildContext context) { + return Row( + children: [ + Icon( + _frameworkIcon(framework!), + color: _frameworkColor(framework!), + size: AppSpacing.iconRegular, + ), + const SizedBox(width: AppSpacing.smallMedium), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + framework!.displayName, + style: AppTypography.caption2(context).copyWith( + color: AppColors.textSecondary(context), + ), + ), + Text( + modelName!, + style: AppTypography.subheadlineSemibold(context), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + OutlinedButton( + onPressed: onSelectModel, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.mediumLarge, + vertical: AppSpacing.xSmall, + ), + minimumSize: Size.zero, + ), + child: Text( + 'Change', + style: AppTypography.captionMedium(context), + ), + ), + ], + ); + } + + Widget _buildNoModelState(BuildContext context) { + return Row( + children: [ + const Icon( + Icons.warning, + color: AppColors.statusOrange, + size: AppSpacing.iconRegular, + ), + const SizedBox(width: AppSpacing.smallMedium), + Expanded( + child: Text( + 'No model selected', + style: AppTypography.subheadline(context).copyWith( + color: AppColors.textSecondary(context), + ), + ), + ), + FilledButton.icon( + onPressed: onSelectModel, + icon: const Icon(Icons.view_in_ar, size: AppSpacing.iconSmall), + label: const Text('Select Model'), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.mediumLarge, + vertical: AppSpacing.xSmall, + ), + ), + ), + ], + ); + } + + IconData _frameworkIcon(LLMFramework framework) { + switch (framework) { + case LLMFramework.llamaCpp: + return Icons.memory; + case LLMFramework.whisperKit: + return Icons.graphic_eq; + case LLMFramework.onnxRuntime: + return Icons.developer_board; + case LLMFramework.foundationModels: + return Icons.apple; + case LLMFramework.systemTTS: + return Icons.volume_up; + default: + return Icons.view_in_ar; + } + } + + Color _frameworkColor(LLMFramework framework) { + switch (framework) { + case LLMFramework.llamaCpp: + return AppColors.primaryBlue; + case LLMFramework.whisperKit: + return AppColors.primaryGreen; + case LLMFramework.onnxRuntime: + return AppColors.primaryPurple; + case LLMFramework.foundationModels: + return Colors.black; + case LLMFramework.systemTTS: + return AppColors.primaryOrange; + default: + return AppColors.statusGray; + } + } +} + +/// ModelRequiredOverlay (mirroring iOS ModelRequiredOverlay) +/// +/// An overlay that covers the screen when no model is selected, prompting the user to select one. +class ModelRequiredOverlay extends StatelessWidget { + final ModelSelectionContext modality; + final VoidCallback onSelectModel; + + const ModelRequiredOverlay({ + super.key, + required this.modality, + required this.onSelectModel, + }); + + @override + Widget build(BuildContext context) { + return Container( + color: AppColors.backgroundPrimary(context).withValues(alpha: 0.95), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + _modalityIcon, + size: 64, + color: AppColors.textSecondary(context).withValues(alpha: 0.5), + ), + const SizedBox(height: AppSpacing.xLarge), + Text( + _modalityTitle, + style: AppTypography.title2Semibold(context), + ), + const SizedBox(height: AppSpacing.smallMedium), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 40), + child: Text( + _modalityDescription, + style: AppTypography.body(context).copyWith( + color: AppColors.textSecondary(context), + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: AppSpacing.xLarge), + FilledButton.icon( + onPressed: onSelectModel, + icon: const Icon(Icons.view_in_ar), + label: const Text('Select a Model'), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.xLarge, + vertical: AppSpacing.mediumLarge, + ), + ), + ), + ], + ), + ), + ); + } + + IconData get _modalityIcon { + switch (modality) { + case ModelSelectionContext.llm: + return Icons.chat_bubble_outline; + case ModelSelectionContext.stt: + return Icons.graphic_eq; + case ModelSelectionContext.tts: + return Icons.volume_up; + case ModelSelectionContext.voice: + return Icons.mic; + } + } + + String get _modalityTitle { + switch (modality) { + case ModelSelectionContext.llm: + return 'Start a Conversation'; + case ModelSelectionContext.stt: + return 'Speech to Text'; + case ModelSelectionContext.tts: + return 'Text to Speech'; + case ModelSelectionContext.voice: + return 'Voice Assistant'; + } + } + + String get _modalityDescription { + switch (modality) { + case ModelSelectionContext.llm: + return 'Select a language model to start chatting. Choose from LLaMA.cpp, Foundation Models, or other frameworks.'; + case ModelSelectionContext.stt: + return 'Select a speech recognition model to transcribe audio. Choose from WhisperKit or ONNX Runtime.'; + case ModelSelectionContext.tts: + return 'Select a text-to-speech model to generate audio. Choose from Piper TTS or System TTS.'; + case ModelSelectionContext.voice: + return 'Voice assistant requires multiple models. Let\'s set them up together.'; + } + } +} + +/// AudioLevelIndicator (mirroring iOS audio level visualization) +/// +/// A 10-bar audio level visualization. +class AudioLevelIndicator extends StatelessWidget { + final double level; // 0.0 to 1.0 + + const AudioLevelIndicator({ + super.key, + required this.level, + }); + + @override + Widget build(BuildContext context) { + final activeBars = (level * 10).floor(); + + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(10, (index) { + final isActive = index < activeBars; + return Container( + width: 25, + height: 8, + margin: const EdgeInsets.symmetric(horizontal: 2), + decoration: BoxDecoration( + color: isActive + ? AppColors.statusGreen + : AppColors.statusGray.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(2), + ), + ); + }), + ); + } +} + +/// RecordingStatusBadge (mirroring iOS status badges) +/// +/// A badge showing recording or transcribing status. +class RecordingStatusBadge extends StatelessWidget { + final bool isRecording; + final bool isTranscribing; + + const RecordingStatusBadge({ + super.key, + required this.isRecording, + required this.isTranscribing, + }); + + @override + Widget build(BuildContext context) { + if (!isRecording && !isTranscribing) { + return const SizedBox.shrink(); + } + + final Color bgColor; + final Color textColor; + final String text; + final Widget leading; + + if (isRecording) { + bgColor = AppColors.primaryRed.withValues(alpha: 0.1); + textColor = AppColors.primaryRed; + text = 'RECORDING'; + leading = Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: AppColors.primaryRed, + shape: BoxShape.circle, + ), + ); + } else { + bgColor = AppColors.primaryOrange.withValues(alpha: 0.1); + textColor = AppColors.primaryOrange; + text = 'TRANSCRIBING'; + leading = SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator( + strokeWidth: 2, + color: textColor, + ), + ); + } + + return Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.smallMedium, + vertical: AppSpacing.xSmall, + ), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(AppSpacing.cornerRadiusSmall), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + leading, + const SizedBox(width: 6), + Text( + text, + style: AppTypography.caption2(context).copyWith( + color: textColor, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ); + } +} + +/// TypingIndicatorView (mirroring iOS TypingIndicatorView) +/// +/// Professional typing indicator with animated dots. +class TypingIndicatorView extends StatefulWidget { + final String? statusText; + + const TypingIndicatorView({ + super.key, + this.statusText, + }); + + @override + State createState() => _TypingIndicatorViewState(); +} + +class _TypingIndicatorViewState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(milliseconds: 1500), + vsync: this, + ); + unawaited(_controller.repeat()); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + const SizedBox(width: AppSpacing.padding60), + Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.mediumLarge, + vertical: AppSpacing.smallMedium, + ), + decoration: BoxDecoration( + color: AppColors.backgroundGray5(context), + borderRadius: BorderRadius.circular(AppSpacing.large), + boxShadow: [ + BoxShadow( + color: AppColors.shadowLight, + blurRadius: 3, + offset: const Offset(0, 2), + ), + ], + border: Border.all( + color: AppColors.borderMedium, + width: 1, + ), + ), + child: AnimatedBuilder( + animation: _controller, + builder: (context, child) { + return Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(3, (index) { + final delay = index * 0.2; + final value = ((_controller.value + delay) % 1.0); + final scale = 0.8 + (0.5 * (1 - (value - 0.5).abs() * 2)); + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 2), + child: Transform.scale( + scale: scale, + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: AppColors.primaryBlue.withValues(alpha: 0.7), + shape: BoxShape.circle, + ), + ), + ), + ); + }), + ); + }, + ), + ), + const SizedBox(width: AppSpacing.mediumLarge), + Text( + widget.statusText ?? 'AI is thinking...', + style: AppTypography.caption(context).copyWith( + color: AppColors.textSecondary(context), + ), + ), + const SizedBox(width: AppSpacing.padding60), + ], + ); + } +} + +/// CompactModelIndicator (mirroring iOS CompactModelIndicator) +/// +/// A compact indicator showing current model status for use in navigation bars/headers. +class CompactModelIndicator extends StatelessWidget { + final LLMFramework? framework; + final String? modelName; + final bool isLoading; + final VoidCallback onTap; + + const CompactModelIndicator({ + super.key, + required this.framework, + required this.modelName, + required this.isLoading, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.mediumLarge, + vertical: AppSpacing.xSmall, + ), + decoration: BoxDecoration( + color: framework != null + ? AppColors.primaryBlue.withValues(alpha: 0.1) + : AppColors.statusOrange.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(AppSpacing.cornerRadiusRegular), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isLoading) + SizedBox( + width: AppSpacing.iconSmall, + height: AppSpacing.iconSmall, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.textSecondary(context), + ), + ) + else if (framework != null) ...[ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: _frameworkColor(framework!), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: AppSpacing.xSmall), + Text( + modelName ?? framework!.displayName, + style: AppTypography.caption(context).copyWith( + color: AppColors.primaryBlue, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ] else ...[ + const Icon( + Icons.view_in_ar, + size: AppSpacing.iconSmall, + color: AppColors.statusOrange, + ), + const SizedBox(width: AppSpacing.xSmall), + Text( + 'Select Model', + style: AppTypography.caption(context).copyWith( + color: AppColors.statusOrange, + ), + ), + ], + ], + ), + ), + ); + } + + Color _frameworkColor(LLMFramework framework) { + switch (framework) { + case LLMFramework.llamaCpp: + return AppColors.primaryBlue; + case LLMFramework.whisperKit: + return AppColors.statusGreen; + case LLMFramework.onnxRuntime: + return AppColors.primaryPurple; + case LLMFramework.foundationModels: + return Colors.black; + default: + return AppColors.statusGray; + } + } +} + +/// VoicePipelineSetupView (mirroring iOS VoicePipelineSetupView) +/// +/// A setup view specifically for Voice Assistant which requires 3 models. +class VoicePipelineSetupView extends StatelessWidget { + final (LLMFramework, String)? sttModel; + final (LLMFramework, String)? llmModel; + final (LLMFramework, String)? ttsModel; + + final AppModelLoadState sttLoadState; + final AppModelLoadState llmLoadState; + final AppModelLoadState ttsLoadState; + + final VoidCallback onSelectSTT; + final VoidCallback onSelectLLM; + final VoidCallback onSelectTTS; + final VoidCallback onStartVoice; + + const VoicePipelineSetupView({ + super.key, + required this.sttModel, + required this.llmModel, + required this.ttsModel, + required this.sttLoadState, + required this.llmLoadState, + required this.ttsLoadState, + required this.onSelectSTT, + required this.onSelectLLM, + required this.onSelectTTS, + required this.onStartVoice, + }); + + bool get allModelsReady => + sttModel != null && llmModel != null && ttsModel != null; + + bool get allModelsLoaded => + sttLoadState == AppModelLoadState.loaded && + llmLoadState == AppModelLoadState.loaded && + ttsLoadState == AppModelLoadState.loaded; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + // Header + const SizedBox(height: AppSpacing.xLarge), + const Icon( + Icons.mic, + size: 48, + color: AppColors.primaryBlue, + ), + const SizedBox(height: AppSpacing.smallMedium), + Text( + 'Voice Assistant Setup', + style: AppTypography.title2Semibold(context), + ), + const SizedBox(height: AppSpacing.xSmall), + Text( + 'Voice requires 3 models to work together', + style: AppTypography.subheadline(context).copyWith( + color: AppColors.textSecondary(context), + ), + ), + + const SizedBox(height: AppSpacing.xLarge), + + // Model setup cards + Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.large), + child: Column( + children: [ + ModelSetupCard( + step: 1, + title: 'Speech Recognition', + subtitle: 'Converts your voice to text', + icon: Icons.graphic_eq, + color: AppColors.statusGreen, + selectedFramework: sttModel?.$1, + selectedModel: sttModel?.$2, + loadState: sttLoadState, + onSelect: onSelectSTT, + ), + const SizedBox(height: AppSpacing.large), + ModelSetupCard( + step: 2, + title: 'Language Model', + subtitle: 'Processes and responds to your input', + icon: Icons.psychology, + color: AppColors.primaryBlue, + selectedFramework: llmModel?.$1, + selectedModel: llmModel?.$2, + loadState: llmLoadState, + onSelect: onSelectLLM, + ), + const SizedBox(height: AppSpacing.large), + ModelSetupCard( + step: 3, + title: 'Text to Speech', + subtitle: 'Converts responses to audio', + icon: Icons.volume_up, + color: AppColors.primaryPurple, + selectedFramework: ttsModel?.$1, + selectedModel: ttsModel?.$2, + loadState: ttsLoadState, + onSelect: onSelectTTS, + ), + ], + ), + ), + + const Spacer(), + + // Start button + Padding( + padding: const EdgeInsets.all(AppSpacing.large), + child: SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: allModelsLoaded ? onStartVoice : null, + icon: const Icon(Icons.mic), + label: const Text('Start Voice Assistant'), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.large), + ), + ), + ), + ), + + // Status message + if (!allModelsReady) + Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.mediumLarge), + child: Text( + 'Select all 3 models to continue', + style: AppTypography.caption(context).copyWith( + color: AppColors.textSecondary(context), + ), + ), + ) + else if (!allModelsLoaded) + Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.mediumLarge), + child: Text( + 'Waiting for models to load...', + style: AppTypography.caption(context).copyWith( + color: AppColors.statusOrange, + ), + ), + ) + else + Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.mediumLarge), + child: Text( + 'All models loaded and ready!', + style: AppTypography.caption(context).copyWith( + color: AppColors.statusGreen, + ), + ), + ), + ], + ); + } +} + +/// ModelSetupCard (for Voice Pipeline setup) +class ModelSetupCard extends StatelessWidget { + final int step; + final String title; + final String subtitle; + final IconData icon; + final Color color; + final LLMFramework? selectedFramework; + final String? selectedModel; + final AppModelLoadState loadState; + final VoidCallback onSelect; + + const ModelSetupCard({ + super.key, + required this.step, + required this.title, + required this.subtitle, + required this.icon, + required this.color, + required this.selectedFramework, + required this.selectedModel, + required this.loadState, + required this.onSelect, + }); + + bool get isConfigured => selectedFramework != null && selectedModel != null; + bool get isLoaded => loadState == AppModelLoadState.loaded; + bool get isLoading => loadState == AppModelLoadState.loading; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onSelect, + borderRadius: BorderRadius.circular(AppSpacing.cornerRadiusCard), + child: Container( + padding: const EdgeInsets.all(AppSpacing.large), + decoration: BoxDecoration( + color: AppColors.backgroundGray6(context), + borderRadius: BorderRadius.circular(AppSpacing.cornerRadiusCard), + border: Border.all( + color: _borderColor, + width: 2, + ), + ), + child: Row( + children: [ + // Step indicator + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: _stepIndicatorColor, + shape: BoxShape.circle, + ), + child: isLoading + ? const Padding( + padding: EdgeInsets.all(8), + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : isLoaded + ? const Icon( + Icons.check_circle, + size: 20, + color: Colors.white, + ) + : isConfigured + ? const Icon( + Icons.check, + size: 16, + color: Colors.white, + ) + : Text( + '$step', + style: AppTypography.subheadlineSemibold(context) + .copyWith(color: AppColors.statusGray), + ), + ), + const SizedBox(width: AppSpacing.large), + + // Content + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, color: color, size: AppSpacing.iconRegular), + const SizedBox(width: AppSpacing.xSmall), + Text( + title, + style: AppTypography.subheadlineSemibold(context), + ), + ], + ), + const SizedBox(height: AppSpacing.xSmall), + if (isConfigured) + Row( + children: [ + Expanded( + child: Text( + '${selectedFramework!.displayName} • $selectedModel', + style: AppTypography.caption(context).copyWith( + color: AppColors.textSecondary(context), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (isLoaded) + const Icon( + Icons.check_circle, + size: 12, + color: AppColors.statusGreen, + ) + else if (isLoading) + Text( + 'Loading...', + style: AppTypography.caption2(context).copyWith( + color: AppColors.statusOrange, + ), + ), + ], + ) + else + Text( + subtitle, + style: AppTypography.caption(context).copyWith( + color: AppColors.textSecondary(context), + ), + ), + ], + ), + ), + + // Action / Status + if (isLoading) + const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else if (isLoaded) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.check_circle, + size: 16, + color: AppColors.statusGreen, + ), + const SizedBox(width: AppSpacing.xSmall), + Text( + 'Loaded', + style: AppTypography.caption(context).copyWith( + color: AppColors.statusGreen, + ), + ), + ], + ) + else if (isConfigured) + Text( + 'Change', + style: AppTypography.caption(context).copyWith( + color: AppColors.primaryBlue, + ), + ) + else + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Select', + style: AppTypography.captionMedium(context).copyWith( + color: AppColors.primaryBlue, + ), + ), + const Icon( + Icons.chevron_right, + size: 16, + color: AppColors.primaryBlue, + ), + ], + ), + ], + ), + ), + ); + } + + Color get _stepIndicatorColor { + if (isLoading) return AppColors.statusOrange; + if (isLoaded) return AppColors.statusGreen; + if (isConfigured) return color; + return AppColors.statusGray.withValues(alpha: 0.2); + } + + Color get _borderColor { + if (isLoaded) return AppColors.statusGreen.withValues(alpha: 0.5); + if (isLoading) return AppColors.statusOrange.withValues(alpha: 0.5); + if (isConfigured) return color.withValues(alpha: 0.5); + return Colors.transparent; + } +} diff --git a/examples/flutter/RunAnywhereAI/lib/features/models/model_types.dart b/examples/flutter/RunAnywhereAI/lib/features/models/model_types.dart new file mode 100644 index 000000000..37db2221a --- /dev/null +++ b/examples/flutter/RunAnywhereAI/lib/features/models/model_types.dart @@ -0,0 +1,234 @@ +// Model Types (mirroring iOS model types) +// +// Contains model-related enums and data classes. + +/// LLM Framework enumeration +enum LLMFramework { + llamaCpp, + foundationModels, + mediaPipe, + onnxRuntime, + systemTTS, + whisperKit, + unknown; + + String get displayName { + switch (this) { + case LLMFramework.llamaCpp: + return 'LLaMA.cpp'; + case LLMFramework.foundationModels: + return 'Foundation Models'; + case LLMFramework.mediaPipe: + return 'MediaPipe'; + case LLMFramework.onnxRuntime: + return 'ONNX Runtime'; + case LLMFramework.systemTTS: + return 'System TTS'; + case LLMFramework.whisperKit: + return 'WhisperKit'; + case LLMFramework.unknown: + return 'Unknown'; + } + } + + String get rawValue { + switch (this) { + case LLMFramework.llamaCpp: + return 'llama.cpp'; + case LLMFramework.foundationModels: + return 'foundation_models'; + case LLMFramework.mediaPipe: + return 'mediapipe'; + case LLMFramework.onnxRuntime: + return 'onnx_runtime'; + case LLMFramework.systemTTS: + return 'system_tts'; + case LLMFramework.whisperKit: + return 'whisperkit'; + case LLMFramework.unknown: + return 'unknown'; + } + } +} + +/// Model category enumeration +/// Matches SDK ModelCategory for proper conversion +enum ModelCategory { + language, + multimodal, + speechRecognition, + speechSynthesis, + vision, + imageGeneration, + audio, + embedding, + unknown; + + String get displayName { + switch (this) { + case ModelCategory.language: + return 'Language'; + case ModelCategory.multimodal: + return 'Multimodal'; + case ModelCategory.speechRecognition: + return 'Speech Recognition'; + case ModelCategory.speechSynthesis: + return 'Speech Synthesis'; + case ModelCategory.vision: + return 'Vision'; + case ModelCategory.imageGeneration: + return 'Image Generation'; + case ModelCategory.audio: + return 'Audio'; + case ModelCategory.embedding: + return 'Embedding'; + case ModelCategory.unknown: + return 'Unknown'; + } + } +} + +/// Model format enumeration +enum ModelFormat { + gguf, + ggml, + coreml, + onnx, + tflite, + bin, + unknown; + + String get rawValue { + switch (this) { + case ModelFormat.gguf: + return 'gguf'; + case ModelFormat.ggml: + return 'ggml'; + case ModelFormat.coreml: + return 'coreml'; + case ModelFormat.onnx: + return 'onnx'; + case ModelFormat.tflite: + return 'tflite'; + case ModelFormat.bin: + return 'bin'; + case ModelFormat.unknown: + return 'unknown'; + } + } +} + +/// Model selection context +enum ModelSelectionContext { + llm, + stt, + tts, + voice; + + String get title { + switch (this) { + case ModelSelectionContext.llm: + return 'Select LLM Model'; + case ModelSelectionContext.stt: + return 'Select STT Model'; + case ModelSelectionContext.tts: + return 'Select TTS Model'; + case ModelSelectionContext.voice: + return 'Select Model'; + } + } + + Set get relevantCategories { + switch (this) { + case ModelSelectionContext.llm: + return {ModelCategory.language, ModelCategory.multimodal}; + case ModelSelectionContext.stt: + return {ModelCategory.speechRecognition}; + case ModelSelectionContext.tts: + return {ModelCategory.speechSynthesis}; + case ModelSelectionContext.voice: + return { + ModelCategory.language, + ModelCategory.multimodal, + ModelCategory.speechRecognition, + ModelCategory.speechSynthesis, + }; + } + } +} + +/// Model info class +class ModelInfo { + final String id; + final String name; + final ModelCategory category; + final ModelFormat format; + final String? downloadURL; + final String? localPath; + final int? memoryRequired; + final List compatibleFrameworks; + final LLMFramework? preferredFramework; + final bool supportsThinking; + + const ModelInfo({ + required this.id, + required this.name, + this.category = ModelCategory.language, + this.format = ModelFormat.unknown, + this.downloadURL, + this.localPath, + this.memoryRequired, + this.compatibleFrameworks = const [], + this.preferredFramework, + this.supportsThinking = false, + }); + + bool get isDownloaded => localPath != null; + + ModelInfo copyWith({ + String? id, + String? name, + ModelCategory? category, + ModelFormat? format, + String? downloadURL, + String? localPath, + int? memoryRequired, + List? compatibleFrameworks, + LLMFramework? preferredFramework, + bool? supportsThinking, + }) { + return ModelInfo( + id: id ?? this.id, + name: name ?? this.name, + category: category ?? this.category, + format: format ?? this.format, + downloadURL: downloadURL ?? this.downloadURL, + localPath: localPath ?? this.localPath, + memoryRequired: memoryRequired ?? this.memoryRequired, + compatibleFrameworks: compatibleFrameworks ?? this.compatibleFrameworks, + preferredFramework: preferredFramework ?? this.preferredFramework, + supportsThinking: supportsThinking ?? this.supportsThinking, + ); + } +} + +/// Download progress state +enum DownloadState { + notStarted, + downloading, + completed, + failed, +} + +/// Download progress info +class DownloadProgress { + final double percentage; + final DownloadState state; + final String? error; + + const DownloadProgress({ + this.percentage = 0.0, + this.state = DownloadState.notStarted, + this.error, + }); +} diff --git a/examples/flutter/RunAnywhereAI/lib/features/models/models_view.dart b/examples/flutter/RunAnywhereAI/lib/features/models/models_view.dart new file mode 100644 index 000000000..dbff9d871 --- /dev/null +++ b/examples/flutter/RunAnywhereAI/lib/features/models/models_view.dart @@ -0,0 +1,265 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'package:runanywhere_ai/core/design_system/app_colors.dart'; +import 'package:runanywhere_ai/core/design_system/app_spacing.dart'; +import 'package:runanywhere_ai/core/design_system/typography.dart'; +import 'package:runanywhere_ai/core/models/app_types.dart'; +import 'package:runanywhere_ai/core/services/device_info_service.dart'; +import 'package:runanywhere_ai/features/models/add_model_from_url_view.dart'; +import 'package:runanywhere_ai/features/models/model_components.dart'; +import 'package:runanywhere_ai/features/models/model_list_view_model.dart'; +import 'package:runanywhere_ai/features/models/model_types.dart'; + +/// ModelsView (mirroring iOS SimplifiedModelsView.swift) +/// +/// Main models view for managing AI models. +class ModelsView extends StatefulWidget { + const ModelsView({super.key}); + + @override + State createState() => _ModelsViewState(); +} + +class _ModelsViewState extends State { + final ModelListViewModel _viewModel = ModelListViewModel.shared; + final DeviceInfoService _deviceInfo = DeviceInfoService.shared; + + ModelInfo? _selectedModel; + LLMFramework? _expandedFramework; + + @override + void initState() { + super.initState(); + unawaited(_loadInitialData()); + } + + Future _loadInitialData() async { + await _viewModel.loadModels(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Models'), + actions: [ + IconButton( + icon: const Icon(Icons.add), + onPressed: _showAddModelSheet, + tooltip: 'Add Model', + ), + ], + ), + body: ListenableBuilder( + listenable: _viewModel, + builder: (context, _) { + if (_viewModel.isLoading && _viewModel.availableModels.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + + return RefreshIndicator( + onRefresh: _loadInitialData, + child: ListView( + children: [ + _buildDeviceStatusSection(), + _buildFrameworksSection(), + if (_expandedFramework != null) _buildModelsSection(), + ], + ), + ); + }, + ), + ); + } + + Widget _buildDeviceStatusSection() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionHeader('Device Status'), + ListenableBuilder( + listenable: _deviceInfo, + builder: (context, _) { + final device = _deviceInfo.deviceInfo; + if (device == null) { + return _buildLoadingRow('Loading device info...'); + } + return Column( + children: [ + DeviceInfoRow( + label: 'Model', + icon: Icons.phone_iphone, + value: device.modelName, + ), + DeviceInfoRow( + label: 'Chip', + icon: Icons.memory, + value: device.chipName, + ), + DeviceInfoRow( + label: 'Memory', + icon: Icons.storage, + value: device.totalMemory.formattedFileSize, + ), + if (device.neuralEngineAvailable) const NeuralEngineRow(), + ], + ); + }, + ), + const Divider(), + ], + ); + } + + Widget _buildFrameworksSection() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionHeader('Available Frameworks'), + if (_viewModel.availableFrameworks.isEmpty) + _buildLoadingRow('Loading frameworks...') + else + ..._viewModel.availableFrameworks.map((framework) { + return FrameworkRow( + framework: framework, + isExpanded: _expandedFramework == framework, + onTap: () => _toggleFramework(framework), + ); + }), + const Divider(), + ], + ); + } + + Widget _buildModelsSection() { + final framework = _expandedFramework; + if (framework == null) return const SizedBox.shrink(); + + final filteredModels = _viewModel.modelsForFramework(framework); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionHeader('Models for ${framework.displayName}'), + if (filteredModels.isEmpty) + _buildEmptyModelsMessage() + else + ...filteredModels.map((model) { + return ModelRow( + model: model, + isSelected: _selectedModel?.id == model.id, + onDownloadCompleted: () async { + await _viewModel.loadModels(); + }, + onSelectModel: () async { + await _selectModel(model); + }, + onModelUpdated: () async { + await _viewModel.loadModels(); + }, + ); + }), + ], + ); + } + + Widget _buildSectionHeader(String title) { + return Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.large, + AppSpacing.large, + AppSpacing.large, + AppSpacing.smallMedium, + ), + child: Text( + title, + style: AppTypography.caption(context).copyWith( + color: AppColors.textSecondary(context), + fontWeight: FontWeight.w600, + ), + ), + ); + } + + Widget _buildLoadingRow(String message) { + return Padding( + padding: const EdgeInsets.all(AppSpacing.large), + child: Row( + children: [ + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: AppSpacing.mediumLarge), + Text( + message, + style: AppTypography.body(context).copyWith( + color: AppColors.textSecondary(context), + ), + ), + ], + ), + ); + } + + Widget _buildEmptyModelsMessage() { + return Padding( + padding: const EdgeInsets.all(AppSpacing.large), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'No models available for this framework', + style: AppTypography.caption(context).copyWith( + color: AppColors.textSecondary(context), + ), + ), + const SizedBox(height: AppSpacing.smallMedium), + Text( + "Tap 'Add Model' to add a model from URL", + style: AppTypography.caption2(context).copyWith( + color: AppColors.statusBlue, + ), + ), + ], + ), + ); + } + + void _toggleFramework(LLMFramework framework) { + setState(() { + if (_expandedFramework == framework) { + _expandedFramework = null; + } else { + _expandedFramework = framework; + } + }); + } + + Future _selectModel(ModelInfo model) async { + setState(() { + _selectedModel = model; + }); + + await _viewModel.selectModel(model); + } + + void _showAddModelSheet() { + unawaited(showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (sheetContext) => AddModelFromURLView( + onModelAdded: (model) async { + // Capture navigator before async gap + final navigator = Navigator.of(sheetContext); + await _viewModel.addImportedModel(model); + if (mounted) navigator.pop(); + }, + ), + )); + } +} diff --git a/examples/flutter/RunAnywhereAI/lib/features/settings/combined_settings_view.dart b/examples/flutter/RunAnywhereAI/lib/features/settings/combined_settings_view.dart new file mode 100644 index 000000000..388059db6 --- /dev/null +++ b/examples/flutter/RunAnywhereAI/lib/features/settings/combined_settings_view.dart @@ -0,0 +1,900 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:runanywhere/runanywhere.dart' as sdk; +import 'package:runanywhere_ai/core/design_system/app_colors.dart'; +import 'package:runanywhere_ai/core/design_system/app_spacing.dart'; +import 'package:runanywhere_ai/core/design_system/typography.dart'; +import 'package:runanywhere_ai/core/models/app_types.dart'; +import 'package:runanywhere_ai/core/utilities/constants.dart'; +import 'package:runanywhere_ai/core/utilities/keychain_helper.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// CombinedSettingsView (mirroring iOS CombinedSettingsView.swift) +/// +/// Settings interface with storage management and logging configuration. +/// Uses RunAnywhere SDK for actual storage operations. +class CombinedSettingsView extends StatefulWidget { + const CombinedSettingsView({super.key}); + + @override + State createState() => _CombinedSettingsViewState(); +} + +class _CombinedSettingsViewState extends State { + // Logging + bool _analyticsLogToLocal = false; + + // Storage info (from SDK) + int _totalStorageSize = 0; + int _availableSpace = 0; + int _modelStorageSize = 0; + List _storedModels = []; + + // API Configuration + String _apiKey = ''; + String _baseURL = ''; + bool _isApiKeyConfigured = false; + bool _isBaseURLConfigured = false; + + // Loading state + bool _isRefreshingStorage = false; + + @override + void initState() { + super.initState(); + unawaited(_loadSettings()); + unawaited(_loadApiConfiguration()); + unawaited(_loadStorageData()); + } + + Future _loadSettings() async { + // Load from keychain + _analyticsLogToLocal = + await KeychainHelper.loadBool(KeychainKeys.analyticsLogToLocal); + if (mounted) { + setState(() {}); + } + } + + /// Load API configuration from keychain + Future _loadApiConfiguration() async { + final storedApiKey = await KeychainHelper.loadString(KeychainKeys.apiKey); + final storedBaseURL = await KeychainHelper.loadString(KeychainKeys.baseURL); + + if (mounted) { + setState(() { + _apiKey = storedApiKey ?? ''; + _baseURL = storedBaseURL ?? ''; + _isApiKeyConfigured = storedApiKey != null && storedApiKey.isNotEmpty; + _isBaseURLConfigured = + storedBaseURL != null && storedBaseURL.isNotEmpty; + }); + } + } + + /// Normalize base URL by adding https:// if no scheme is present + String _normalizeBaseURL(String url) { + final trimmed = url.trim(); + if (trimmed.isEmpty) return trimmed; + if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { + return trimmed; + } + return 'https://$trimmed'; + } + + /// Save API configuration to keychain + Future _saveApiConfiguration(String apiKey, String baseURL) async { + final normalizedURL = _normalizeBaseURL(baseURL); + + await KeychainHelper.saveString(key: KeychainKeys.apiKey, data: apiKey); + await KeychainHelper.saveString( + key: KeychainKeys.baseURL, data: normalizedURL); + + if (mounted) { + setState(() { + _apiKey = apiKey; + _baseURL = normalizedURL; + _isApiKeyConfigured = apiKey.isNotEmpty; + _isBaseURLConfigured = normalizedURL.isNotEmpty; + }); + + _showRestartDialog(); + } + } + + /// Clear API configuration from keychain + Future _clearApiConfiguration() async { + await KeychainHelper.delete(KeychainKeys.apiKey); + await KeychainHelper.delete(KeychainKeys.baseURL); + await KeychainHelper.delete(KeychainKeys.deviceRegistered); + + if (mounted) { + setState(() { + _apiKey = ''; + _baseURL = ''; + _isApiKeyConfigured = false; + _isBaseURLConfigured = false; + }); + + _showRestartDialog(); + } + } + + /// Show restart required dialog + void _showRestartDialog() { + unawaited(showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + icon: const Icon(Icons.restart_alt, + color: AppColors.primaryOrange, size: 32), + title: const Text('Restart Required'), + content: const Text( + 'API configuration has been updated. Please restart the app for changes to take effect.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('OK'), + ), + ], + ), + )); + } + + /// Show API configuration dialog + void _showApiConfigDialog() { + final apiKeyController = TextEditingController(text: _apiKey); + final baseURLController = TextEditingController(text: _baseURL); + bool showPassword = false; + + unawaited(showDialog( + context: context, + builder: (dialogContext) => StatefulBuilder( + builder: (context, setDialogState) => AlertDialog( + title: const Text('API Configuration'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // API Key Input + Text('API Key', style: AppTypography.caption(context)), + const SizedBox(height: AppSpacing.xSmall), + TextField( + controller: apiKeyController, + obscureText: !showPassword, + decoration: InputDecoration( + hintText: 'Enter your API key', + border: const OutlineInputBorder(), + suffixIcon: IconButton( + icon: Icon(showPassword + ? Icons.visibility_off + : Icons.visibility), + onPressed: () { + setDialogState(() => showPassword = !showPassword); + }, + ), + ), + ), + const SizedBox(height: AppSpacing.xSmall), + Text( + 'Your API key for authenticating with the backend', + style: AppTypography.caption2(context).copyWith( + color: AppColors.textSecondary(context), + ), + ), + + const SizedBox(height: AppSpacing.mediumLarge), + + // Base URL Input + Text('Base URL', style: AppTypography.caption(context)), + const SizedBox(height: AppSpacing.xSmall), + TextField( + controller: baseURLController, + keyboardType: TextInputType.url, + decoration: const InputDecoration( + hintText: 'https://api.example.com', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: AppSpacing.xSmall), + Text( + 'The backend API URL (https:// added automatically if missing)', + style: AppTypography.caption2(context).copyWith( + color: AppColors.textSecondary(context), + ), + ), + + const SizedBox(height: AppSpacing.mediumLarge), + + // Warning Box + Container( + padding: const EdgeInsets.all(AppSpacing.mediumLarge), + decoration: BoxDecoration( + color: AppColors.primaryOrange.withValues(alpha: 0.1), + borderRadius: + BorderRadius.circular(AppSpacing.cornerRadiusRegular), + border: Border.all( + color: AppColors.primaryOrange.withValues(alpha: 0.3)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber, + color: AppColors.primaryOrange, size: 20), + const SizedBox(width: AppSpacing.smallMedium), + Expanded( + child: Text( + 'After saving, you must restart the app for changes to take effect. The SDK will reinitialize with your custom configuration.', + style: AppTypography.caption2(context).copyWith( + color: AppColors.textSecondary(context), + ), + ), + ), + ], + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + if (apiKeyController.text.isNotEmpty && + baseURLController.text.isNotEmpty) { + Navigator.pop(dialogContext); + unawaited(_saveApiConfiguration( + apiKeyController.text, baseURLController.text)); + } + }, + child: const Text('Save'), + ), + ], + ), + ), + )); + } + + /// Load storage data using RunAnywhere SDK + Future _loadStorageData() async { + if (!mounted) return; + setState(() { + _isRefreshingStorage = true; + }); + + try { + // Get storage info from SDK + final storageInfo = await sdk.RunAnywhere.getStorageInfo(); + + // Get downloaded models with full info (including sizes) + final storedModels = await sdk.RunAnywhere.getDownloadedModelsWithInfo(); + + // Calculate total model storage from actual models + int totalModelStorage = 0; + for (final model in storedModels) { + totalModelStorage += model.size; + } + + if (mounted) { + setState(() { + _totalStorageSize = storageInfo.appStorage.totalSize; + _availableSpace = storageInfo.deviceStorage.freeSpace; + _modelStorageSize = totalModelStorage; + _storedModels = storedModels; + _isRefreshingStorage = false; + }); + } + } catch (e) { + debugPrint('Failed to load storage data: $e'); + if (mounted) { + setState(() { + _isRefreshingStorage = false; + }); + } + } + } + + Future _refreshStorageData() async { + await _loadStorageData(); + } + + Future _toggleAnalyticsLogging(bool value) async { + setState(() { + _analyticsLogToLocal = value; + }); + await KeychainHelper.saveBool( + key: KeychainKeys.analyticsLogToLocal, + data: value, + ); + } + + /// Clear cache using RunAnywhere SDK + Future _clearCache() async { + // TODO: Implement clearCache() in SDK + // Once SDK implements clearCache(), replace this with: + // try { + // await sdk.RunAnywhere.clearCache(); + // if (mounted) { + // ScaffoldMessenger.of(context).showSnackBar( + // const SnackBar(content: Text('Cache cleared')), + // ); + // } + // await _loadStorageData(); + // } catch (e) { + // if (mounted) { + // ScaffoldMessenger.of(context).showSnackBar( + // SnackBar(content: Text('Failed to clear cache: $e')), + // ); + // } + // } + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Clear Cache not available yet')), + ); + } + } + + /// Delete a stored model using RunAnywhere SDK + Future _deleteModel(sdk.StoredModel model) async { + try { + await sdk.RunAnywhere.deleteStoredModel(model.id); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${model.name} deleted')), + ); + } + await _loadStorageData(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to delete model: $e')), + ); + } + } + } + + Future _openGitHub() async { + final uri = Uri.parse('https://github.com/RunanywhereAI/runanywhere-sdks/'); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } else { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Could not open GitHub')), + ); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Settings'), + ), + body: ListView( + padding: const EdgeInsets.all(AppSpacing.large), + children: [ + // API Configuration Section + _buildSectionHeader('API Configuration (Testing)'), + _buildApiConfigurationCard(), + const SizedBox(height: AppSpacing.large), + + // Storage Overview Section + _buildSectionHeader('Storage Overview', + trailing: _buildRefreshButton()), + _buildStorageOverviewCard(), + const SizedBox(height: AppSpacing.large), + + // Downloaded Models Section + _buildSectionHeader('Downloaded Models'), + _buildDownloadedModelsCard(), + const SizedBox(height: AppSpacing.large), + + // Storage Management Section + _buildSectionHeader('Storage Management'), + _buildStorageManagementCard(), + const SizedBox(height: AppSpacing.large), + + // Logging Configuration Section + _buildSectionHeader('Logging Configuration'), + _buildLoggingCard(), + const SizedBox(height: AppSpacing.large), + + // About Section + _buildSectionHeader('About'), + _buildAboutCard(), + const SizedBox(height: AppSpacing.xxLarge), + ], + ), + ); + } + + Widget _buildSectionHeader(String title, {Widget? trailing}) { + return Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.smallMedium), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + title, + style: AppTypography.headlineSemibold(context), + ), + if (trailing != null) trailing, + ], + ), + ); + } + + Widget _buildRefreshButton() { + return TextButton.icon( + onPressed: _isRefreshingStorage ? null : _refreshStorageData, + icon: _isRefreshingStorage + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.refresh, size: 16), + label: Text( + 'Refresh', + style: AppTypography.caption(context), + ), + ); + } + + Widget _buildApiConfigurationCard() { + return Card( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.large), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // API Key Row + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('API Key', style: AppTypography.subheadline(context)), + Text( + _isApiKeyConfigured ? 'Configured' : 'Not Set', + style: AppTypography.caption(context).copyWith( + color: _isApiKeyConfigured + ? AppColors.statusGreen + : AppColors.primaryOrange, + ), + ), + ], + ), + const Divider(), + // Base URL Row + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Base URL', style: AppTypography.subheadline(context)), + Text( + _isBaseURLConfigured ? 'Configured' : 'Not Set', + style: AppTypography.caption(context).copyWith( + color: _isBaseURLConfigured + ? AppColors.statusGreen + : AppColors.primaryOrange, + ), + ), + ], + ), + const Divider(), + const SizedBox(height: AppSpacing.smallMedium), + // Buttons + Row( + children: [ + OutlinedButton( + onPressed: _showApiConfigDialog, + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.primaryBlue, + ), + child: const Text('Configure'), + ), + if (_isApiKeyConfigured && _isBaseURLConfigured) ...[ + const SizedBox(width: AppSpacing.smallMedium), + OutlinedButton( + onPressed: _clearApiConfiguration, + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.primaryRed, + ), + child: const Text('Clear'), + ), + ], + ], + ), + const SizedBox(height: AppSpacing.smallMedium), + Text( + 'Configure custom API key and base URL for testing. Requires app restart.', + style: AppTypography.caption2(context).copyWith( + color: AppColors.textSecondary(context), + ), + ), + ], + ), + ), + ); + } + + Widget _buildStorageOverviewCard() { + return Card( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.large), + child: Column( + children: [ + _buildStorageRow( + icon: Icons.storage, + label: 'Total Usage', + value: _totalStorageSize.formattedFileSize, + ), + const Divider(), + _buildStorageRow( + icon: Icons.add_circle_outline, + label: 'Available Space', + value: _availableSpace.formattedFileSize, + valueColor: AppColors.statusGreen, + ), + const Divider(), + _buildStorageRow( + icon: Icons.memory, + label: 'Models Storage', + value: _modelStorageSize.formattedFileSize, + valueColor: AppColors.primaryBlue, + ), + const Divider(), + _buildStorageRow( + icon: Icons.download_done, + label: 'Downloaded Models', + value: '${_storedModels.length}', + ), + ], + ), + ), + ); + } + + Widget _buildStorageRow({ + required IconData icon, + required String label, + required String value, + Color? valueColor, + }) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.smallMedium), + child: Row( + children: [ + Icon(icon, size: AppSpacing.iconRegular), + const SizedBox(width: AppSpacing.mediumLarge), + Expanded( + child: Text(label, style: AppTypography.subheadline(context)), + ), + Text( + value, + style: AppTypography.subheadlineSemibold(context).copyWith( + color: valueColor, + ), + ), + ], + ), + ); + } + + Widget _buildDownloadedModelsCard() { + return Card( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.large), + child: _storedModels.isEmpty + ? Center( + child: Column( + children: [ + Icon( + Icons.view_in_ar_outlined, + size: 48, + color: AppColors.textSecondary(context) + .withValues(alpha: 0.5), + ), + const SizedBox(height: AppSpacing.mediumLarge), + Text( + 'No models downloaded yet', + style: AppTypography.subheadline(context).copyWith( + color: AppColors.textSecondary(context), + ), + ), + ], + ), + ) + : Column( + children: _storedModels.map((model) { + final isLast = model == _storedModels.last; + return Column( + children: [ + _StoredModelRow( + model: model, + onDelete: () => _deleteModel(model), + ), + if (!isLast) const Divider(), + ], + ); + }).toList(), + ), + ), + ); + } + + Widget _buildStorageManagementCard() { + return Card( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.large), + child: _buildManagementButton( + icon: Icons.delete_outline, + title: 'Clear Cache', + subtitle: 'Free up space by clearing cached data', + color: AppColors.primaryRed, + onTap: _clearCache, + ), + ), + ); + } + + Widget _buildManagementButton({ + required IconData icon, + required String title, + required String subtitle, + required Color color, + required VoidCallback onTap, + }) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppSpacing.cornerRadiusRegular), + child: Container( + padding: const EdgeInsets.all(AppSpacing.mediumLarge), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(AppSpacing.cornerRadiusRegular), + border: Border.all(color: color.withValues(alpha: 0.3)), + ), + child: Row( + children: [ + Icon(icon, color: color), + const SizedBox(width: AppSpacing.mediumLarge), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: AppTypography.subheadline(context)), + Text( + subtitle, + style: AppTypography.caption(context).copyWith( + color: AppColors.textSecondary(context), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildLoggingCard() { + return Card( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.large), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Log Analytics Locally'), + subtitle: const Text( + 'When enabled, analytics events will be saved locally on your device.', + ), + value: _analyticsLogToLocal, + onChanged: _toggleAnalyticsLogging, + ), + ], + ), + ), + ); + } + + Widget _buildAboutCard() { + return Card( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.large), + child: ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.code, color: AppColors.primaryBlue), + title: const Text('RunAnywhere SDK'), + subtitle: const Text('github.com/RunanywhereAI/runanywhere-sdks'), + trailing: const Icon(Icons.open_in_new), + onTap: _openGitHub, + ), + ), + ); + } +} + +/// Stored model row widget +class _StoredModelRow extends StatefulWidget { + final sdk.StoredModel model; + final Future Function() onDelete; + + const _StoredModelRow({ + required this.model, + required this.onDelete, + }); + + @override + State<_StoredModelRow> createState() => _StoredModelRowState(); +} + +class _StoredModelRowState extends State<_StoredModelRow> { + bool _showDetails = false; + bool _isDeleting = false; + + Future _performDelete() async { + setState(() => _isDeleting = true); + try { + await widget.onDelete(); + } finally { + if (mounted) { + setState(() => _isDeleting = false); + } + } + } + + void _confirmDelete() { + unawaited(showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Delete Model'), + content: Text( + 'Are you sure you want to delete ${widget.model.name}? This action cannot be undone.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + Navigator.pop(dialogContext); + unawaited(_performDelete()); + }, + style: TextButton.styleFrom(foregroundColor: AppColors.primaryRed), + child: const Text('Delete'), + ), + ], + ), + )); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.xSmall), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.model.name, + style: AppTypography.subheadlineSemibold(context), + ), + const SizedBox(height: AppSpacing.xSmall), + Text( + widget.model.size.formattedFileSize, + style: AppTypography.caption2(context).copyWith( + color: AppColors.textSecondary(context), + ), + ), + ], + ), + ), + Row( + children: [ + TextButton( + onPressed: () { + setState(() => _showDetails = !_showDetails); + }, + child: Text(_showDetails ? 'Hide' : 'Details'), + ), + IconButton( + icon: _isDeleting + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.delete_outline, + color: AppColors.primaryRed), + onPressed: _isDeleting ? null : _confirmDelete, + ), + ], + ), + ], + ), + if (_showDetails) ...[ + const SizedBox(height: AppSpacing.smallMedium), + Container( + padding: const EdgeInsets.all(AppSpacing.mediumLarge), + decoration: BoxDecoration( + color: AppColors.backgroundGray6(context), + borderRadius: + BorderRadius.circular(AppSpacing.cornerRadiusRegular), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildDetailRow( + 'Downloaded:', _formatDate(widget.model.createdDate)), + _buildDetailRow('Size:', widget.model.size.formattedFileSize), + _buildDetailRow( + 'Framework:', widget.model.framework.rawValue), + ], + ), + ), + ], + ], + ), + ); + } + + Widget _buildDetailRow(String label, String value) { + return Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.xSmall), + child: Row( + children: [ + Text( + label, + style: AppTypography.caption2(context).copyWith( + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(width: AppSpacing.xSmall), + Text( + value, + style: AppTypography.caption2(context).copyWith( + color: AppColors.textSecondary(context), + ), + ), + ], + ), + ); + } + + String _formatDate(DateTime date) { + return '${date.day}/${date.month}/${date.year}'; + } + + // ignore: unused_element - kept for future use + String _formatRelativeDate(DateTime date) { + final now = DateTime.now(); + final difference = now.difference(date); + + if (difference.inDays > 0) { + return '${difference.inDays} days ago'; + } else if (difference.inHours > 0) { + return '${difference.inHours} hours ago'; + } else if (difference.inMinutes > 0) { + return '${difference.inMinutes} minutes ago'; + } else { + return 'Just now'; + } + } +} diff --git a/examples/flutter/RunAnywhereAI/lib/features/voice/speech_to_text_view.dart b/examples/flutter/RunAnywhereAI/lib/features/voice/speech_to_text_view.dart new file mode 100644 index 000000000..2c44b3639 --- /dev/null +++ b/examples/flutter/RunAnywhereAI/lib/features/voice/speech_to_text_view.dart @@ -0,0 +1,627 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:runanywhere/runanywhere.dart' as sdk; +import 'package:runanywhere_ai/core/design_system/app_colors.dart'; +import 'package:runanywhere_ai/core/design_system/app_spacing.dart'; +import 'package:runanywhere_ai/core/design_system/typography.dart'; +import 'package:runanywhere_ai/core/services/audio_recording_service.dart'; +import 'package:runanywhere_ai/core/services/permission_service.dart'; +import 'package:runanywhere_ai/features/models/model_selection_sheet.dart'; +import 'package:runanywhere_ai/features/models/model_status_components.dart'; +import 'package:runanywhere_ai/features/models/model_types.dart'; + +/// STTMode enumeration (matching iOS STTMode) +enum STTMode { + batch, + live; + + String get displayName { + switch (this) { + case STTMode.batch: + return 'Batch'; + case STTMode.live: + return 'Live'; + } + } + + String get description { + switch (this) { + case STTMode.batch: + return 'Record first, then transcribe all at once'; + case STTMode.live: + return 'Transcribe as you speak in real-time'; + } + } + + IconData get icon { + switch (this) { + case STTMode.batch: + return Icons.mic; + case STTMode.live: + return Icons.stream; + } + } +} + +/// SpeechToTextView (mirroring iOS SpeechToTextView.swift) +/// +/// Dedicated STT view with batch/live mode support and real-time transcription. +/// Now uses RunAnywhere SDK for actual transcription. +class SpeechToTextView extends StatefulWidget { + const SpeechToTextView({super.key}); + + @override + State createState() => _SpeechToTextViewState(); +} + +class _SpeechToTextViewState extends State { + // Recording state + bool _isRecording = false; + bool _isProcessing = false; + bool _isTranscribing = false; + STTMode _selectedMode = STTMode.batch; + String _transcribedText = ''; + String _partialText = ''; + double _audioLevel = 0.0; + + // Model state + LLMFramework? _selectedFramework; + String? _selectedModelName; + bool _supportsLiveMode = true; + + // Error state + String? _errorMessage; + + // Audio recording service + final AudioRecordingService _recordingService = + AudioRecordingService.instance; + StreamSubscription? _audioLevelSubscription; + + bool get _hasModelSelected => + _selectedFramework != null && _selectedModelName != null; + + @override + void initState() { + super.initState(); + unawaited(_checkMicrophonePermission()); + } + + @override + void dispose() { + unawaited(_audioLevelSubscription?.cancel()); + super.dispose(); + } + + Future _checkMicrophonePermission() async { + // Check permission status on init, will request when user tries to record + final hasPermission = + await PermissionService.shared.areSTTPermissionsGranted(); + if (!hasPermission) { + debugPrint( + 'STT permissions not yet granted, will request when recording starts'); + } + } + + void _showModelSelectionSheet() { + unawaited(showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (sheetContext) => ModelSelectionSheet( + context: ModelSelectionContext.stt, + onModelSelected: (model) async { + await _loadModel(model); + }, + ), + )); + } + + /// Load STT model using RunAnywhere SDK directly (matches Swift STTViewModel pattern) + Future _loadModel(ModelInfo model) async { + setState(() { + _isProcessing = true; + _errorMessage = null; + }); + + try { + debugPrint('🔄 Loading STT model: ${model.name}'); + + // Load STT model directly via SDK (matches Swift: RunAnywhere.loadSTTModel) + await sdk.RunAnywhere.loadSTTModel(model.id); + + setState(() { + _selectedFramework = + model.preferredFramework ?? LLMFramework.whisperKit; + _selectedModelName = model.name; + // WhisperKit supports live mode, ONNX may have limitations + _supportsLiveMode = model.preferredFramework == LLMFramework.whisperKit; + _isProcessing = false; + }); + + debugPrint('✅ STT model loaded: ${model.name}'); + } catch (e) { + debugPrint('❌ Failed to load STT model: $e'); + setState(() { + _errorMessage = 'Failed to load model: $e'; + _isProcessing = false; + }); + } + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + // Request STT permissions (microphone + speech recognition on iOS) + final hasPermission = + await PermissionService.shared.requestSTTPermissions(context); + if (!hasPermission) { + setState(() { + _errorMessage = 'Microphone permission required for recording'; + }); + return; + } + + setState(() { + _isRecording = true; + _errorMessage = null; + _transcribedText = ''; + _partialText = ''; + }); + + // Start recording with the audio service + final recordingPath = await _recordingService.startRecording( + sampleRate: 16000, + numChannels: 1, + enableAudioLevels: true, + ); + + if (recordingPath == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Failed to start recording'; + }); + return; + } + + // Subscribe to audio levels + _audioLevelSubscription = + _recordingService.audioLevelStream?.listen((level) { + setState(() { + _audioLevel = level; + }); + }); + + debugPrint('🎙️ Recording started in ${_selectedMode.displayName} mode'); + } + + Future _stopRecording() async { + // Cancel audio level subscription + await _audioLevelSubscription?.cancel(); + _audioLevelSubscription = null; + + setState(() { + _isRecording = false; + _audioLevel = 0.0; + }); + + // Stop recording and get audio data + final (audioData, _) = await _recordingService.stopRecording(); + + if (audioData == null || audioData.isEmpty) { + setState(() { + _errorMessage = 'No audio data recorded'; + }); + return; + } + + // Transcribe the recorded audio + await _transcribeAudio(audioData); + } + + /// Transcribe recorded audio using RunAnywhere SDK + Future _transcribeAudio(List audioData) async { + setState(() { + _isTranscribing = true; + _errorMessage = null; + }); + + try { + debugPrint('🔄 Transcribing ${audioData.length} bytes of audio...'); + + // Check if STT model is loaded via SDK (matches Swift: RunAnywhere.isSTTModelLoaded) + if (!sdk.RunAnywhere.isSTTModelLoaded) { + throw Exception( + 'STT component not loaded. Please load an STT model first.'); + } + + // Call SDK transcription API (matches Swift: RunAnywhere.transcribe(_:)) + final audioBytes = Uint8List.fromList(audioData); + final transcribedText = await sdk.RunAnywhere.transcribe(audioBytes); + + setState(() { + _transcribedText = transcribedText; + _isTranscribing = false; + }); + + debugPrint('✅ Transcription complete: ${transcribedText.length} chars'); + } catch (e) { + debugPrint('❌ Transcription failed: $e'); + setState(() { + _errorMessage = 'Transcription failed: $e'; + _isTranscribing = false; + }); + } + } + + void _clearTranscription() { + setState(() { + _transcribedText = ''; + _partialText = ''; + }); + } + + void _copyToClipboard() { + // TODO: Implement clipboard copy + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Copied to clipboard')), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Speech to Text'), + actions: [ + if (_transcribedText.isNotEmpty) + IconButton( + icon: const Icon(Icons.copy), + onPressed: _copyToClipboard, + tooltip: 'Copy', + ), + if (_transcribedText.isNotEmpty) + IconButton( + icon: const Icon(Icons.delete_outline), + onPressed: _clearTranscription, + tooltip: 'Clear', + ), + ], + ), + body: Stack( + children: [ + Column( + children: [ + // Model Status Banner + Padding( + padding: const EdgeInsets.all(AppSpacing.large), + child: ModelStatusBanner( + framework: _selectedFramework, + modelName: _selectedModelName, + isLoading: _isProcessing && !_hasModelSelected, + onSelectModel: _showModelSelectionSheet, + ), + ), + + // Mode selector (only when model is selected) + if (_hasModelSelected) ...[ + _buildModeSelector(), + _buildModeDescription(), + ], + + const Divider(), + + // Main content + if (_hasModelSelected) ...[ + Expanded(child: _buildTranscriptionArea()), + const Divider(), + _buildControlsArea(), + ] else + const Expanded(child: SizedBox()), + ], + ), + + // Model required overlay + if (!_hasModelSelected && !_isProcessing) + ModelRequiredOverlay( + modality: ModelSelectionContext.stt, + onSelectModel: _showModelSelectionSheet, + ), + ], + ), + ); + } + + Widget _buildModeSelector() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.large), + child: SegmentedButton( + segments: [ + ButtonSegment( + value: STTMode.batch, + label: Text(STTMode.batch.displayName), + icon: Icon(STTMode.batch.icon), + ), + ButtonSegment( + value: STTMode.live, + label: Text(STTMode.live.displayName), + icon: Icon(STTMode.live.icon), + ), + ], + selected: {_selectedMode}, + onSelectionChanged: _isRecording + ? null + : (Set selection) { + setState(() { + _selectedMode = selection.first; + }); + }, + ), + ); + } + + Widget _buildModeDescription() { + return Padding( + padding: const EdgeInsets.all(AppSpacing.large), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + _selectedMode.icon, + size: AppSpacing.iconSmall, + color: AppColors.textSecondary(context), + ), + const SizedBox(width: AppSpacing.xSmall), + Text( + _selectedMode.description, + style: AppTypography.caption(context).copyWith( + color: AppColors.textSecondary(context), + ), + ), + if (!_supportsLiveMode && _selectedMode == STTMode.live) ...[ + const SizedBox(width: AppSpacing.smallMedium), + Text( + '(will use batch)', + style: AppTypography.caption(context).copyWith( + color: AppColors.statusOrange, + ), + ), + ], + ], + ), + ); + } + + Widget _buildTranscriptionArea() { + return SingleChildScrollView( + padding: const EdgeInsets.all(AppSpacing.large), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_transcribedText.isEmpty && + _partialText.isEmpty && + !_isRecording && + !_isTranscribing) + _buildReadyState() + else if (_isTranscribing && _transcribedText.isEmpty) + _buildProcessingState() + else + _buildTranscriptionContent(), + ], + ), + ); + } + + Widget _buildReadyState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(height: AppSpacing.xxxLarge), + Icon( + Icons.mic, + size: 64, + color: AppColors.statusGreen.withValues(alpha: 0.5), + ), + const SizedBox(height: AppSpacing.large), + Text( + 'Ready to transcribe', + style: AppTypography.headline(context), + ), + const SizedBox(height: AppSpacing.smallMedium), + Text( + 'Tap the microphone button to start recording', + style: AppTypography.subheadline(context).copyWith( + color: AppColors.textSecondary(context), + ), + textAlign: TextAlign.center, + ), + ], + ), + ); + } + + Widget _buildProcessingState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(height: AppSpacing.xxxLarge), + const SizedBox( + width: 48, + height: 48, + child: CircularProgressIndicator(strokeWidth: 3), + ), + const SizedBox(height: AppSpacing.large), + Text( + 'Processing audio...', + style: AppTypography.headline(context), + ), + const SizedBox(height: AppSpacing.smallMedium), + Text( + 'Transcribing your recording', + style: AppTypography.subheadline(context).copyWith( + color: AppColors.textSecondary(context), + ), + ), + ], + ), + ); + } + + Widget _buildTranscriptionContent() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header with status badge + Row( + children: [ + Text( + 'Transcription', + style: AppTypography.headline(context), + ), + const Spacer(), + RecordingStatusBadge( + isRecording: _isRecording, + isTranscribing: _isTranscribing, + ), + ], + ), + const SizedBox(height: AppSpacing.mediumLarge), + + // Transcription text area + Container( + width: double.infinity, + padding: const EdgeInsets.all(AppSpacing.large), + decoration: BoxDecoration( + color: AppColors.backgroundGray6(context), + borderRadius: BorderRadius.circular(AppSpacing.cornerRadiusCard), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_transcribedText.isNotEmpty) + Text( + _transcribedText, + style: AppTypography.body(context), + ), + if (_partialText.isNotEmpty) + Text( + _partialText, + style: AppTypography.body(context).copyWith( + color: AppColors.textSecondary(context), + fontStyle: FontStyle.italic, + ), + ), + if (_transcribedText.isEmpty && _partialText.isEmpty) + Text( + 'Listening...', + style: AppTypography.body(context).copyWith( + color: AppColors.textSecondary(context), + ), + ), + ], + ), + ), + ], + ); + } + + Widget _buildControlsArea() { + return Container( + padding: const EdgeInsets.all(AppSpacing.large), + child: Column( + children: [ + // Error message + if (_errorMessage != null) + Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.large), + child: Text( + _errorMessage!, + style: AppTypography.caption(context).copyWith( + color: AppColors.primaryRed, + ), + textAlign: TextAlign.center, + ), + ), + + // Audio level indicator + if (_isRecording) + Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.large), + child: AudioLevelIndicator(level: _audioLevel), + ), + + // Record button + _buildRecordButton(), + + const SizedBox(height: AppSpacing.mediumLarge), + + // Status text + Text( + _isTranscribing + ? 'Processing transcription...' + : _isRecording + ? 'Tap to stop recording' + : 'Tap to start recording', + style: AppTypography.caption(context).copyWith( + color: AppColors.textSecondary(context), + ), + ), + ], + ), + ); + } + + Widget _buildRecordButton() { + final Color buttonColor; + if (_isRecording) { + buttonColor = AppColors.primaryRed; + } else if (_isTranscribing) { + buttonColor = AppColors.primaryOrange; + } else { + buttonColor = AppColors.primaryBlue; + } + + return GestureDetector( + onTap: !_isProcessing && !_isTranscribing && _hasModelSelected + ? _toggleRecording + : null, + child: Container( + width: 72, + height: 72, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: _hasModelSelected ? buttonColor : AppColors.statusGray, + boxShadow: [ + BoxShadow( + color: buttonColor.withValues(alpha: 0.3), + blurRadius: AppSpacing.shadowXLarge, + offset: const Offset(0, 4), + ), + ], + ), + child: _isProcessing || _isTranscribing + ? const Padding( + padding: EdgeInsets.all(AppSpacing.xLarge), + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ) + : Icon( + _isRecording ? Icons.stop : Icons.mic, + color: Colors.white, + size: 32, + ), + ), + ); + } +} diff --git a/examples/flutter/RunAnywhereAI/lib/features/voice/text_to_speech_view.dart b/examples/flutter/RunAnywhereAI/lib/features/voice/text_to_speech_view.dart new file mode 100644 index 000000000..ea6eadc20 --- /dev/null +++ b/examples/flutter/RunAnywhereAI/lib/features/voice/text_to_speech_view.dart @@ -0,0 +1,693 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:runanywhere/runanywhere.dart' as sdk; + +import 'package:runanywhere_ai/core/design_system/app_colors.dart'; +import 'package:runanywhere_ai/core/design_system/app_spacing.dart'; +import 'package:runanywhere_ai/core/design_system/typography.dart'; +import 'package:runanywhere_ai/core/services/audio_player_service.dart'; +import 'package:runanywhere_ai/features/models/model_selection_sheet.dart'; +import 'package:runanywhere_ai/features/models/model_status_components.dart'; +import 'package:runanywhere_ai/features/models/model_types.dart'; + +/// TTSMetadata (matching iOS TTSMetadata) +class TTSMetadata { + final double durationMs; + final int audioSize; + final int sampleRate; + + const TTSMetadata({ + required this.durationMs, + required this.audioSize, + required this.sampleRate, + }); +} + +/// TextToSpeechView (mirroring iOS TextToSpeechView.swift) +/// +/// Dedicated TTS view with speech generation and playback controls. +/// Now uses RunAnywhere SDK for actual speech synthesis. +class TextToSpeechView extends StatefulWidget { + const TextToSpeechView({super.key}); + + @override + State createState() => _TextToSpeechViewState(); +} + +class _TextToSpeechViewState extends State { + final TextEditingController _textController = TextEditingController( + text: 'Hello! This is a text to speech test.', + ); + + // Playback state + bool _isGenerating = false; + bool _isPlaying = false; + // ignore: unused_field - kept for future TTS implementation + bool _hasAudio = false; + double _currentTime = 0.0; + double _duration = 0.0; + double _playbackProgress = 0.0; + + // Voice settings + double _speechRate = 1.0; + double _pitch = 1.0; + + // Model state + LLMFramework? _selectedFramework; + String? _selectedModelName; + bool _isSystemTTS = false; + + // Audio metadata + TTSMetadata? _metadata; + + // Error state + String? _errorMessage; + + // Audio player service + final AudioPlayerService _playerService = AudioPlayerService.instance; + StreamSubscription? _playingSubscription; + StreamSubscription? _progressSubscription; + + // Character limit + static const int _maxCharacters = 5000; + + bool get _hasModelSelected => + _selectedFramework != null && _selectedModelName != null; + + @override + void initState() { + super.initState(); + unawaited(_initializeAudioPlayer()); + } + + @override + void dispose() { + _textController.dispose(); + unawaited(_playingSubscription?.cancel()); + unawaited(_progressSubscription?.cancel()); + super.dispose(); + } + + Future _initializeAudioPlayer() async { + await _playerService.initialize(); + + // Subscribe to playing state + _playingSubscription = _playerService.playingStream.listen((isPlaying) { + if (mounted) { + setState(() { + _isPlaying = isPlaying; + }); + } + }); + + // Subscribe to progress updates + _progressSubscription = _playerService.progressStream.listen((progress) { + if (mounted) { + setState(() { + _playbackProgress = progress; + _currentTime = _duration * progress; + }); + } + }); + } + + void _showModelSelectionSheet() { + unawaited(showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (sheetContext) => ModelSelectionSheet( + context: ModelSelectionContext.tts, + onModelSelected: (model) async { + await _loadModel(model); + }, + ), + )); + } + + /// Load TTS model using RunAnywhere SDK + Future _loadModel(ModelInfo model) async { + setState(() { + _isGenerating = true; + _errorMessage = null; + }); + + try { + debugPrint('🔄 Loading TTS voice: ${model.name}'); + + // Load TTS voice via RunAnywhere SDK + await sdk.RunAnywhere.loadTTSVoice(model.id); + + setState(() { + _selectedFramework = model.preferredFramework ?? LLMFramework.systemTTS; + _selectedModelName = model.name; + _isSystemTTS = model.preferredFramework == LLMFramework.systemTTS; + _isGenerating = false; + }); + + debugPrint('✅ TTS model loaded: ${model.name}'); + } catch (e) { + debugPrint('❌ Failed to load TTS model: $e'); + setState(() { + _errorMessage = 'Failed to load model: $e'; + _isGenerating = false; + }); + } + } + + /// Generate speech using RunAnywhere SDK + Future _generateSpeech() async { + if (_textController.text.isEmpty) { + setState(() { + _errorMessage = 'Please enter text to speak'; + }); + return; + } + + setState(() { + _isGenerating = true; + _errorMessage = null; + _hasAudio = false; + _metadata = null; + }); + + try { + debugPrint('🔊 Generating speech with SDK...'); + + // Check if TTS voice is loaded via SDK (matches Swift: RunAnywhere.isTTSVoiceLoaded) + if (!sdk.RunAnywhere.isTTSVoiceLoaded) { + throw Exception( + 'TTS component not loaded. Please load a TTS voice first.'); + } + + // Call SDK TTS synthesis API (matches Swift: RunAnywhere.synthesize(_:)) + final result = await sdk.RunAnywhere.synthesize( + _textController.text, + rate: _speechRate, + pitch: _pitch, + volume: 1.0, + ); + + debugPrint( + '✅ TTS synthesis complete: ${result.samples.length} samples, ${result.sampleRate} Hz, ${result.durationMs}ms'); + + setState(() { + _isGenerating = false; + _hasAudio = result.samples.isNotEmpty; + _duration = result.durationSeconds; + _metadata = TTSMetadata( + durationMs: result.durationMs.toDouble(), + audioSize: result.samples.length * 4, // 4 bytes per float sample + sampleRate: result.sampleRate, + ); + }); + + // Auto-play if audio was generated + if (result.samples.isNotEmpty) { + await _playFloatAudio(result.samples, result.sampleRate); + } + } catch (e) { + debugPrint('❌ Speech generation failed: $e'); + setState(() { + _errorMessage = 'Speech generation failed: $e'; + _isGenerating = false; + }); + } + } + + /// Play audio from Float32List samples (TTS output) + Future _playFloatAudio(Float32List samples, int sampleRate) async { + try { + // Convert Float32 PCM samples to Int16 PCM bytes + // TTS returns samples in range [-1.0, 1.0], we convert to Int16 range [-32768, 32767] + final pcmData = ByteData(samples.length * 2); // 2 bytes per Int16 sample + for (var i = 0; i < samples.length; i++) { + // Clamp and scale to Int16 range + final sample = (samples[i].clamp(-1.0, 1.0) * 32767).round(); + pcmData.setInt16(i * 2, sample, Endian.little); + } + + await _playerService.playFromBytes( + pcmData.buffer.asUint8List(), + volume: 1.0, + rate: 1.0, // Rate is already applied in TTS synthesis + sampleRate: sampleRate, + numChannels: 1, // Mono audio + ); + debugPrint( + '🔊 Playing TTS audio: ${samples.length} samples at $sampleRate Hz'); + } catch (e) { + debugPrint('❌ Failed to play TTS audio: $e'); + setState(() { + _errorMessage = 'Failed to play audio: $e'; + }); + } + } + + /// Play audio using the audio player service (for Int16 PCM data) + // ignore: unused_element - kept for alternative audio formats + Future _playAudio(List audioData) async { + try { + // Convert List to Uint8List + final audioBytes = Uint8List.fromList(audioData); + + // The TTS component returns PCM16 data at 22050 Hz mono + // We need to pass the sample rate so the audio player can create proper WAV headers + await _playerService.playFromBytes( + audioBytes, + volume: 1.0, // Use full volume (pitch controls are in TTS synthesis) + rate: _speechRate, + sampleRate: 22050, // Piper TTS default sample rate + numChannels: 1, // Mono audio + ); + debugPrint('🔊 Playing audio...'); + } catch (e) { + debugPrint('❌ Failed to play audio: $e'); + setState(() { + _errorMessage = 'Failed to play audio: $e'; + }); + } + } + + Future _togglePlayback() async { + if (_isPlaying) { + await _stopPlayback(); + } + } + + Future _stopPlayback() async { + await _playerService.stop(); + debugPrint('⏹️ Playback stopped'); + } + + String _formatTime(double seconds) { + final mins = seconds.floor() ~/ 60; + final secs = seconds.floor() % 60; + return '$mins:${secs.toString().padLeft(2, '0')}'; + } + + String _formatBytes(int bytes) { + final kb = bytes / 1024; + if (kb < 1024) { + return '${kb.toStringAsFixed(1)} KB'; + } else { + return '${(kb / 1024).toStringAsFixed(1)} MB'; + } + } + + @override + Widget build(BuildContext context) { + final characterCount = _textController.text.length; + + return Scaffold( + appBar: AppBar( + title: const Text('Text to Speech'), + ), + body: Stack( + children: [ + Column( + children: [ + // Model Status Banner + Padding( + padding: const EdgeInsets.all(AppSpacing.large), + child: ModelStatusBanner( + framework: _selectedFramework, + modelName: _selectedModelName, + isLoading: _isGenerating && !_hasModelSelected, + onSelectModel: _showModelSelectionSheet, + ), + ), + + const Divider(), + + // Main content (only when model is selected) + if (_hasModelSelected) ...[ + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(AppSpacing.large), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Text input section + _buildTextInputSection(characterCount), + const SizedBox(height: AppSpacing.xLarge), + + // Voice settings section + _buildVoiceSettingsSection(), + const SizedBox(height: AppSpacing.xLarge), + + // Audio metadata (when available) + if (_metadata != null) _buildAudioInfoSection(), + + // Error message + if (_errorMessage != null) _buildErrorBanner(), + ], + ), + ), + ), + + const Divider(), + + // Controls section + _buildControlsSection(), + ] else + const Expanded(child: SizedBox()), + ], + ), + + // Model required overlay + if (!_hasModelSelected && !_isGenerating) + ModelRequiredOverlay( + modality: ModelSelectionContext.tts, + onSelectModel: _showModelSelectionSheet, + ), + ], + ), + ); + } + + Widget _buildTextInputSection(int characterCount) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Enter Text', + style: AppTypography.headlineSemibold(context), + ), + const SizedBox(height: AppSpacing.mediumLarge), + Container( + decoration: BoxDecoration( + color: AppColors.backgroundGray6(context), + borderRadius: BorderRadius.circular(AppSpacing.cornerRadiusCard), + border: Border.all( + color: AppColors.borderMedium, + width: 1, + ), + ), + child: TextField( + controller: _textController, + maxLines: 6, + maxLength: _maxCharacters, + decoration: const InputDecoration( + hintText: 'Type or paste text here...', + border: InputBorder.none, + contentPadding: EdgeInsets.all(AppSpacing.large), + counterText: '', + ), + onChanged: (_) => setState(() {}), + ), + ), + const SizedBox(height: AppSpacing.xSmall), + Align( + alignment: Alignment.centerRight, + child: Text( + '$characterCount characters', + style: AppTypography.caption(context).copyWith( + color: characterCount > _maxCharacters + ? AppColors.primaryRed + : AppColors.textSecondary(context), + ), + ), + ), + ], + ); + } + + Widget _buildVoiceSettingsSection() { + return Container( + padding: const EdgeInsets.all(AppSpacing.large), + decoration: BoxDecoration( + color: AppColors.backgroundGray6(context), + borderRadius: BorderRadius.circular(AppSpacing.cornerRadiusCard), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Voice Settings', + style: AppTypography.headlineSemibold(context), + ), + const SizedBox(height: AppSpacing.large), + + // Speech rate slider + _buildSliderRow( + label: 'Speed', + value: _speechRate, + min: 0.5, + max: 2.0, + color: AppColors.primaryBlue, + onChanged: (value) { + setState(() { + _speechRate = value; + }); + }, + ), + const SizedBox(height: AppSpacing.mediumLarge), + + // Pitch slider + _buildSliderRow( + label: 'Pitch', + value: _pitch, + min: 0.5, + max: 2.0, + color: AppColors.primaryPurple, + onChanged: (value) { + setState(() { + _pitch = value; + }); + }, + ), + ], + ), + ); + } + + Widget _buildSliderRow({ + required String label, + required double value, + required double min, + required double max, + required Color color, + required ValueChanged onChanged, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: AppTypography.subheadline(context), + ), + Text( + '${value.toStringAsFixed(1)}x', + style: AppTypography.subheadline(context).copyWith( + color: AppColors.textSecondary(context), + ), + ), + ], + ), + Slider( + value: value, + min: min, + max: max, + divisions: ((max - min) * 10).toInt(), + activeColor: color, + onChanged: onChanged, + ), + ], + ); + } + + Widget _buildAudioInfoSection() { + return Container( + padding: const EdgeInsets.all(AppSpacing.large), + margin: const EdgeInsets.only(bottom: AppSpacing.large), + decoration: BoxDecoration( + color: AppColors.backgroundGray6(context), + borderRadius: BorderRadius.circular(AppSpacing.cornerRadiusCard), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Info', + style: AppTypography.headlineSemibold(context), + ), + const SizedBox(height: AppSpacing.mediumLarge), + _buildMetadataRow( + icon: Icons.graphic_eq, + label: 'Duration', + value: '${(_metadata!.durationMs / 1000).toStringAsFixed(2)}s', + ), + const SizedBox(height: AppSpacing.smallMedium), + _buildMetadataRow( + icon: Icons.description, + label: 'Size', + value: _formatBytes(_metadata!.audioSize), + ), + const SizedBox(height: AppSpacing.smallMedium), + _buildMetadataRow( + icon: Icons.volume_up, + label: 'Sample Rate', + value: '${_metadata!.sampleRate} Hz', + ), + ], + ), + ); + } + + Widget _buildMetadataRow({ + required IconData icon, + required String label, + required String value, + }) { + return Row( + children: [ + Icon( + icon, + size: 16, + color: AppColors.textSecondary(context), + ), + const SizedBox(width: AppSpacing.smallMedium), + Text( + '$label:', + style: AppTypography.caption(context).copyWith( + color: AppColors.textSecondary(context), + ), + ), + const Spacer(), + Text( + value, + style: AppTypography.captionMedium(context), + ), + ], + ); + } + + Widget _buildErrorBanner() { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(AppSpacing.mediumLarge), + margin: const EdgeInsets.only(bottom: AppSpacing.large), + decoration: BoxDecoration( + color: AppColors.badgeRed, + borderRadius: BorderRadius.circular(AppSpacing.cornerRadiusRegular), + ), + child: Row( + children: [ + const Icon(Icons.error, color: AppColors.primaryRed), + const SizedBox(width: AppSpacing.smallMedium), + Expanded( + child: Text( + _errorMessage!, + style: AppTypography.subheadline(context), + ), + ), + ], + ), + ); + } + + Widget _buildControlsSection() { + return Container( + padding: const EdgeInsets.all(AppSpacing.large), + child: Column( + children: [ + // Playback progress (when playing) + if (_isPlaying) + Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.large), + child: Row( + children: [ + Text( + _formatTime(_currentTime), + style: AppTypography.caption(context).copyWith( + color: AppColors.textSecondary(context), + ), + ), + const SizedBox(width: AppSpacing.smallMedium), + Expanded( + child: LinearProgressIndicator( + value: _playbackProgress, + backgroundColor: AppColors.backgroundGray5(context), + valueColor: + const AlwaysStoppedAnimation(AppColors.primaryPurple), + ), + ), + const SizedBox(width: AppSpacing.smallMedium), + Text( + _formatTime(_duration), + style: AppTypography.caption(context).copyWith( + color: AppColors.textSecondary(context), + ), + ), + ], + ), + ), + + // Action buttons + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Generate/Speak button + FilledButton.icon( + onPressed: _textController.text.isNotEmpty && + !_isGenerating && + _hasModelSelected + ? _generateSpeech + : null, + icon: _isGenerating + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ) + : Icon(_isSystemTTS ? Icons.volume_up : Icons.graphic_eq), + label: Text(_isSystemTTS ? 'Speak' : 'Generate'), + style: FilledButton.styleFrom( + backgroundColor: AppColors.primaryPurple, + minimumSize: const Size(140, 50), + ), + ), + + const SizedBox(width: AppSpacing.xLarge), + + // Stop button (when playing) + if (_isPlaying) + FilledButton.icon( + onPressed: _togglePlayback, + icon: const Icon(Icons.stop), + label: const Text('Stop'), + style: FilledButton.styleFrom( + backgroundColor: AppColors.primaryRed, + minimumSize: const Size(140, 50), + ), + ), + ], + ), + + const SizedBox(height: AppSpacing.mediumLarge), + + // Status text + Text( + _isGenerating + ? 'Generating speech...' + : _isPlaying + ? 'Playing...' + : 'Ready', + style: AppTypography.caption(context).copyWith( + color: AppColors.textSecondary(context), + ), + ), + ], + ), + ); + } +} diff --git a/examples/flutter/RunAnywhereAI/lib/features/voice/voice_assistant_view.dart b/examples/flutter/RunAnywhereAI/lib/features/voice/voice_assistant_view.dart new file mode 100644 index 000000000..6fcd37ceb --- /dev/null +++ b/examples/flutter/RunAnywhereAI/lib/features/voice/voice_assistant_view.dart @@ -0,0 +1,1022 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:runanywhere/runanywhere.dart' as sdk; + +import 'package:runanywhere_ai/core/design_system/app_colors.dart'; +import 'package:runanywhere_ai/core/design_system/app_spacing.dart'; +import 'package:runanywhere_ai/core/design_system/typography.dart'; +import 'package:runanywhere_ai/core/models/app_types.dart'; +import 'package:runanywhere_ai/core/services/permission_service.dart'; +import 'package:runanywhere_ai/features/models/model_selection_sheet.dart'; +import 'package:runanywhere_ai/features/models/model_types.dart'; + +/// VoiceAssistantView (mirroring iOS VoiceAssistantView.swift) +/// +/// Main voice assistant UI with conversational interface. +/// Orchestrates STT -> LLM -> TTS pipeline using SDK's VoiceSession API. +class VoiceAssistantView extends StatefulWidget { + const VoiceAssistantView({super.key}); + + @override + State createState() => _VoiceAssistantViewState(); +} + +class _VoiceAssistantViewState extends State + with SingleTickerProviderStateMixin { + // Session state + VoiceSessionState _sessionState = VoiceSessionState.disconnected; + sdk.VoiceSessionHandle? _voiceSession; + StreamSubscription? _eventSubscription; + + // Conversation + final List<_ConversationTurn> _conversation = []; + String _currentTranscript = ''; + String _assistantResponse = ''; + + // Audio level for visualization + double _audioLevel = 0.0; + bool _isSpeechDetected = false; + + // Model state - tracks which models are loaded + AppModelLoadState _sttModelState = AppModelLoadState.notLoaded; + AppModelLoadState _llmModelState = AppModelLoadState.notLoaded; + AppModelLoadState _ttsModelState = AppModelLoadState.notLoaded; + + // Current model names + String _currentSTTModel = 'Not loaded'; + String _currentLLMModel = 'Not loaded'; + String _currentTTSModel = 'Not loaded'; + + // UI state + bool _showModelInfo = false; + + // Error state + String? _errorMessage; + + // Animation + late AnimationController _pulseController; + late Animation _pulseAnimation; + + bool get _allModelsLoaded => + _sttModelState == AppModelLoadState.loaded && + _llmModelState == AppModelLoadState.loaded && + _ttsModelState == AppModelLoadState.loaded; + + bool get _isActive => + _sessionState != VoiceSessionState.disconnected && + _sessionState != VoiceSessionState.error; + + bool get _isListening => + _sessionState == VoiceSessionState.listening || + _sessionState == VoiceSessionState.connected; + + bool get _isProcessing => + _sessionState == VoiceSessionState.processing || + _sessionState == VoiceSessionState.connecting; + + @override + void initState() { + super.initState(); + _pulseController = AnimationController( + duration: const Duration(milliseconds: 1000), + vsync: this, + ); + _pulseAnimation = Tween(begin: 1.0, end: 1.2).animate( + CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut), + ); + unawaited(_initialize()); + } + + @override + void dispose() { + _cleanup(); + _pulseController.dispose(); + super.dispose(); + } + + Future _initialize() async { + await _refreshComponentStates(); + } + + /// Refresh model states from SDK (matches Swift VoiceAgentViewModel pattern) + /// NOTE: Voice agent API is not yet fully implemented in SDK + Future _refreshComponentStates() async { + try { + // Use SDK public API to check loaded states (matches Swift pattern) + final currentModelId = sdk.RunAnywhere.currentModelId; + final sttModelId = sdk.RunAnywhere.currentSTTModelId; + final ttsVoiceId = sdk.RunAnywhere.currentTTSVoiceId; + + if (!mounted) return; + setState(() { + _sttModelState = sttModelId != null + ? AppModelLoadState.loaded + : AppModelLoadState.notLoaded; + _llmModelState = currentModelId != null + ? AppModelLoadState.loaded + : AppModelLoadState.notLoaded; + _ttsModelState = ttsVoiceId != null + ? AppModelLoadState.loaded + : AppModelLoadState.notLoaded; + + _currentSTTModel = sttModelId ?? 'Not loaded'; + _currentLLMModel = currentModelId ?? 'Not loaded'; + _currentTTSModel = ttsVoiceId ?? 'Not loaded'; + }); + } catch (e) { + debugPrint('Failed to get component states: $e'); + } + } + + Future _startConversation() async { + // Request STT permissions before starting + final hasPermission = + await PermissionService.shared.requestSTTPermissions(context); + if (!hasPermission) { + setState(() { + _sessionState = VoiceSessionState.error; + _errorMessage = 'Microphone permission is required for voice assistant'; + }); + return; + } + + setState(() { + _sessionState = VoiceSessionState.connecting; + _errorMessage = null; + }); + + try { + // Check if voice agent is ready using SDK API + if (!sdk.RunAnywhere.isVoiceAgentReady) { + setState(() { + _sessionState = VoiceSessionState.error; + _errorMessage = 'Please load STT, LLM, and TTS models first'; + }); + return; + } + + // Use SDK's startVoiceSession API (matches Swift: RunAnywhere.startVoiceSession()) + _voiceSession = await sdk.RunAnywhere.startVoiceSession( + config: const sdk.VoiceSessionConfig(), + ); + + // Listen to session events + _eventSubscription = _voiceSession!.events.listen( + _handleSessionEvent, + onError: (Object error) { + setState(() { + _sessionState = VoiceSessionState.error; + _errorMessage = 'Voice session error: $error'; + }); + }, + ); + + setState(() { + _sessionState = VoiceSessionState.connected; + }); + + // Start pulse animation + unawaited(_pulseController.repeat(reverse: true)); + } catch (e) { + setState(() { + _sessionState = VoiceSessionState.error; + _errorMessage = 'Failed to start voice session: $e'; + }); + } + } + + void _handleSessionEvent(sdk.VoiceSessionEvent event) { + if (event is sdk.VoiceSessionListening) { + setState(() { + _sessionState = VoiceSessionState.listening; + _audioLevel = event.audioLevel; + // Update speech detected based on audio level threshold + _isSpeechDetected = event.audioLevel > 0.1; + }); + } else if (event is sdk.VoiceSessionSpeechStarted) { + setState(() { + _isSpeechDetected = true; + }); + } else if (event is sdk.VoiceSessionTranscribed) { + setState(() { + _currentTranscript = event.text; + _sessionState = VoiceSessionState.processing; + }); + } else if (event is sdk.VoiceSessionResponded) { + setState(() { + _assistantResponse = event.text; + }); + } else if (event is sdk.VoiceSessionSpeaking) { + setState(() { + _sessionState = VoiceSessionState.speaking; + }); + } else if (event is sdk.VoiceSessionTurnCompleted) { + // Add completed turn to conversation + if (event.transcript.isNotEmpty) { + setState(() { + _conversation.add(_ConversationTurn( + role: ConversationRole.user, + text: event.transcript, + )); + if (event.response.isNotEmpty) { + _conversation.add(_ConversationTurn( + role: ConversationRole.assistant, + text: event.response, + )); + } + _currentTranscript = ''; + _assistantResponse = ''; + _sessionState = VoiceSessionState.listening; + }); + } + } else if (event is sdk.VoiceSessionError) { + setState(() { + _sessionState = VoiceSessionState.error; + _errorMessage = event.message; + }); + } else if (event is sdk.VoiceSessionStopped) { + // Properly clean up subscriptions and controllers instead of just setting state + unawaited(_stopConversation()); + } + } + + Future _stopConversation() async { + _pulseController.stop(); + _pulseController.reset(); + + await _eventSubscription?.cancel(); + _eventSubscription = null; + + _voiceSession?.stop(); + _voiceSession = null; + + setState(() { + _sessionState = VoiceSessionState.disconnected; + _currentTranscript = ''; + _assistantResponse = ''; + _audioLevel = 0.0; + _isSpeechDetected = false; + }); + } + + void _toggleListening() { + if (_isActive) { + unawaited(_stopConversation()); + } else { + unawaited(_startConversation()); + } + } + + void _cleanup() { + unawaited(_stopConversation()); + } + + void _showSTTModelSelection() { + unawaited(showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => ModelSelectionSheet( + context: ModelSelectionContext.stt, + onModelSelected: (model) async { + await _refreshComponentStates(); + }, + ), + )); + } + + void _showLLMModelSelection() { + unawaited(showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => ModelSelectionSheet( + context: ModelSelectionContext.llm, + onModelSelected: (model) async { + await _refreshComponentStates(); + }, + ), + )); + } + + void _showTTSModelSelection() { + unawaited(showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => ModelSelectionSheet( + context: ModelSelectionContext.tts, + onModelSelected: (model) async { + await _refreshComponentStates(); + }, + ), + )); + } + + @override + Widget build(BuildContext context) { + // Show setup view when models aren't all loaded + if (!_allModelsLoaded) { + return _buildSetupView(); + } + + return Scaffold( + body: SafeArea( + child: Column( + children: [ + // Header with model controls + _buildHeader(), + + // Model info (expandable) + if (_showModelInfo) _buildModelInfoSection(), + + // Conversation area + Expanded(child: _buildConversationArea()), + + // Error message + if (_errorMessage != null) _buildErrorBanner(), + + // Audio level indicator + if (_isListening) _buildAudioLevelIndicator(), + + // Control area + _buildControlArea(), + ], + ), + ), + ); + } + + Widget _buildSetupView() { + return Scaffold( + appBar: AppBar( + title: const Text('Voice Assistant Setup'), + centerTitle: true, + ), + body: Padding( + padding: const EdgeInsets.all(AppSpacing.xLarge), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Text( + 'Configure Voice Pipeline', + style: AppTypography.title(context), + ), + const SizedBox(height: AppSpacing.smallMedium), + Text( + 'Select models for each component to enable voice conversations.', + style: AppTypography.body(context).copyWith( + color: AppColors.textSecondary(context), + ), + ), + const SizedBox(height: AppSpacing.xxLarge), + + // STT Model + _buildModelConfigRow( + icon: Icons.graphic_eq, + label: 'Speech-to-Text', + modelName: _currentSTTModel, + state: _sttModelState, + color: AppColors.statusGreen, + onTap: _showSTTModelSelection, + ), + const SizedBox(height: AppSpacing.large), + + // LLM Model + _buildModelConfigRow( + icon: Icons.psychology, + label: 'Language Model', + modelName: _currentLLMModel, + state: _llmModelState, + color: AppColors.primaryBlue, + onTap: _showLLMModelSelection, + ), + const SizedBox(height: AppSpacing.large), + + // TTS Model + _buildModelConfigRow( + icon: Icons.volume_up, + label: 'Text-to-Speech', + modelName: _currentTTSModel, + state: _ttsModelState, + color: AppColors.primaryPurple, + onTap: _showTTSModelSelection, + ), + + const Spacer(), + + // Start button (enabled when all models loaded) + if (_allModelsLoaded) + Center( + child: ElevatedButton.icon( + onPressed: () { + // Refresh to transition to main UI + setState(() {}); + }, + icon: const Icon(Icons.mic), + label: const Text('Start Voice Assistant'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.xLarge, + vertical: AppSpacing.mediumLarge, + ), + ), + ), + ), + + const SizedBox(height: AppSpacing.xxLarge), + ], + ), + ), + ); + } + + Widget _buildModelConfigRow({ + required IconData icon, + required String label, + required String modelName, + required AppModelLoadState state, + required Color color, + required VoidCallback onTap, + }) { + final isLoaded = state == AppModelLoadState.loaded; + + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppSpacing.cornerRadiusRegular), + child: Container( + padding: const EdgeInsets.all(AppSpacing.large), + decoration: BoxDecoration( + color: isLoaded + ? color.withValues(alpha: 0.1) + : AppColors.backgroundGray5(context), + borderRadius: BorderRadius.circular(AppSpacing.cornerRadiusRegular), + border: Border.all( + color: isLoaded ? color.withValues(alpha: 0.3) : Colors.transparent, + ), + ), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.2), + shape: BoxShape.circle, + ), + child: Icon(icon, color: color, size: 20), + ), + const SizedBox(width: AppSpacing.mediumLarge), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: AppTypography.subheadline(context)), + const SizedBox(height: 2), + Text( + modelName, + style: AppTypography.caption(context).copyWith( + color: + isLoaded ? color : AppColors.textSecondary(context), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + Icon( + isLoaded ? Icons.check_circle : Icons.add_circle_outline, + color: isLoaded ? color : AppColors.textSecondary(context), + ), + ], + ), + ), + ); + } + + Widget _buildHeader() { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.large, + vertical: AppSpacing.mediumLarge, + ), + child: Row( + children: [ + // Model selection button + IconButton( + onPressed: () { + // Show model selection options + unawaited(showModalBottomSheet( + context: context, + builder: (context) => _buildModelSelectionMenu(), + )); + }, + icon: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.backgroundGray5(context), + shape: BoxShape.circle, + ), + child: const Icon(Icons.view_in_ar, size: 18), + ), + ), + + const Spacer(), + + // Status indicator + Row( + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: _getStatusColor(), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + Text( + _sessionState.name, + style: AppTypography.caption(context).copyWith( + color: AppColors.textSecondary(context), + ), + ), + ], + ), + + const Spacer(), + + // Model info toggle + IconButton( + onPressed: () => setState(() => _showModelInfo = !_showModelInfo), + icon: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.backgroundGray5(context), + shape: BoxShape.circle, + ), + child: Icon( + _showModelInfo ? Icons.info : Icons.info_outline, + size: 18, + ), + ), + ), + ], + ), + ); + } + + Widget _buildModelSelectionMenu() { + return Container( + padding: const EdgeInsets.all(AppSpacing.large), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Voice Models', style: AppTypography.headline(context)), + const SizedBox(height: AppSpacing.large), + ListTile( + leading: const Icon(Icons.graphic_eq, color: AppColors.statusGreen), + title: const Text('Speech-to-Text'), + subtitle: Text(_currentSTTModel), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.pop(context); + _showSTTModelSelection(); + }, + ), + ListTile( + leading: const Icon(Icons.psychology, color: AppColors.primaryBlue), + title: const Text('Language Model'), + subtitle: Text(_currentLLMModel), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.pop(context); + _showLLMModelSelection(); + }, + ), + ListTile( + leading: + const Icon(Icons.volume_up, color: AppColors.primaryPurple), + title: const Text('Text-to-Speech'), + subtitle: Text(_currentTTSModel), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.pop(context); + _showTTSModelSelection(); + }, + ), + const SizedBox(height: AppSpacing.large), + ], + ), + ); + } + + Widget _buildModelInfoSection() { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.large, + vertical: AppSpacing.mediumLarge, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _ModelBadge( + icon: Icons.psychology, + label: 'LLM', + value: _currentLLMModel, + color: AppColors.primaryBlue, + ), + _ModelBadge( + icon: Icons.graphic_eq, + label: 'STT', + value: _currentSTTModel, + color: AppColors.statusGreen, + ), + _ModelBadge( + icon: Icons.volume_up, + label: 'TTS', + value: _currentTTSModel, + color: AppColors.primaryPurple, + ), + ], + ), + ); + } + + Widget _buildConversationArea() { + if (_conversation.isEmpty && + _currentTranscript.isEmpty && + _assistantResponse.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.mic_none, + size: 48, + color: AppColors.textSecondary(context).withValues(alpha: 0.3), + ), + const SizedBox(height: AppSpacing.mediumLarge), + Text( + 'Tap the microphone to start', + style: AppTypography.subheadline(context).copyWith( + color: AppColors.textSecondary(context), + ), + ), + ], + ), + ); + } + + return ListView( + padding: const EdgeInsets.all(AppSpacing.large), + children: [ + // Past conversation turns + ..._conversation.map(_buildConversationBubble), + + // Current transcription (in progress) + if (_currentTranscript.isNotEmpty) + _buildConversationBubble(_ConversationTurn( + role: ConversationRole.user, + text: _currentTranscript, + )), + + // Current assistant response (in progress) + if (_assistantResponse.isNotEmpty) + _buildConversationBubble(_ConversationTurn( + role: ConversationRole.assistant, + text: _assistantResponse, + )), + ], + ); + } + + Widget _buildConversationBubble(_ConversationTurn turn) { + final isUser = turn.role == ConversationRole.user; + final speaker = isUser ? 'You' : 'Assistant'; + + return Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.large), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + speaker, + style: AppTypography.caption(context).copyWith( + color: AppColors.textSecondary(context), + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 6), + Container( + padding: const EdgeInsets.all(AppSpacing.mediumLarge), + decoration: BoxDecoration( + color: isUser + ? AppColors.backgroundGray5(context) + : AppColors.primaryBlue.withValues(alpha: 0.08), + borderRadius: + BorderRadius.circular(AppSpacing.cornerRadiusBubble), + ), + child: Text( + turn.text, + style: AppTypography.body(context), + ), + ), + ], + ), + ); + } + + Widget _buildAudioLevelIndicator() { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.large, + vertical: AppSpacing.smallMedium, + ), + child: Column( + children: [ + // Recording badge + Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.smallMedium, + vertical: 4, + ), + decoration: BoxDecoration( + color: AppColors.statusRed.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(4), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: AppColors.statusRed, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + Text( + 'RECORDING', + style: AppTypography.caption2(context).copyWith( + color: AppColors.statusRed, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + const SizedBox(height: AppSpacing.smallMedium), + + // Audio level bars + SizedBox( + height: 24, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(20, (index) { + final threshold = index / 20; + final isActive = _audioLevel > threshold; + return Container( + width: 4, + height: 24 * (isActive ? _audioLevel : 0.2), + margin: const EdgeInsets.symmetric(horizontal: 1), + decoration: BoxDecoration( + color: isActive + ? AppColors.statusGreen + : AppColors.statusGray.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(2), + ), + ); + }), + ), + ), + ], + ), + ); + } + + Widget _buildErrorBanner() { + return Container( + margin: const EdgeInsets.all(AppSpacing.large), + padding: const EdgeInsets.all(AppSpacing.mediumLarge), + decoration: BoxDecoration( + color: AppColors.badgeRed, + borderRadius: BorderRadius.circular(AppSpacing.cornerRadiusRegular), + ), + child: Row( + children: [ + const Icon(Icons.error, color: AppColors.statusRed), + const SizedBox(width: AppSpacing.smallMedium), + Expanded( + child: Text( + _errorMessage!, + style: AppTypography.subheadline(context), + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => setState(() => _errorMessage = null), + ), + ], + ), + ); + } + + Widget _buildControlArea() { + return Container( + padding: const EdgeInsets.all(AppSpacing.xLarge), + child: Column( + children: [ + // Mic button + Center( + child: AnimatedBuilder( + animation: _pulseAnimation, + builder: (context, child) { + return Transform.scale( + scale: _isListening ? _pulseAnimation.value : 1.0, + child: GestureDetector( + onTap: _toggleListening, + child: Container( + width: 72, + height: 72, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: _getMicButtonColor(), + boxShadow: [ + BoxShadow( + color: _getMicButtonColor().withValues(alpha: 0.3), + blurRadius: _isListening ? 20 : 10, + spreadRadius: _isListening ? 5 : 0, + ), + ], + ), + child: _isProcessing + ? const CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ) + : Icon( + _getMicButtonIcon(), + color: Colors.white, + size: 28, + ), + ), + ), + ); + }, + ), + ), + + const SizedBox(height: AppSpacing.mediumLarge), + + // Instruction text + Text( + _getInstructionText(), + style: AppTypography.caption2(context).copyWith( + color: AppColors.textSecondary(context).withValues(alpha: 0.7), + ), + textAlign: TextAlign.center, + ), + ], + ), + ); + } + + Color _getStatusColor() { + switch (_sessionState) { + case VoiceSessionState.disconnected: + return AppColors.statusGray; + case VoiceSessionState.connecting: + return AppColors.statusBlue; + case VoiceSessionState.connected: + case VoiceSessionState.listening: + return AppColors.statusGreen; + case VoiceSessionState.processing: + return AppColors.statusBlue; + case VoiceSessionState.speaking: + return AppColors.primaryPurple; + case VoiceSessionState.error: + return AppColors.statusRed; + } + } + + Color _getMicButtonColor() { + if (_isActive) { + return AppColors.primaryRed; + } + return AppColors.primaryBlue; + } + + IconData _getMicButtonIcon() { + switch (_sessionState) { + case VoiceSessionState.disconnected: + case VoiceSessionState.error: + return Icons.mic; + case VoiceSessionState.connected: + case VoiceSessionState.listening: + return Icons.stop; + case VoiceSessionState.speaking: + return Icons.volume_up; + default: + return Icons.mic; + } + } + + String _getInstructionText() { + switch (_sessionState) { + case VoiceSessionState.disconnected: + return 'Tap to start voice conversation'; + case VoiceSessionState.connecting: + return 'Connecting...'; + case VoiceSessionState.connected: + case VoiceSessionState.listening: + return _isSpeechDetected ? 'Listening...' : 'Speak now'; + case VoiceSessionState.processing: + return 'Processing...'; + case VoiceSessionState.speaking: + return 'Assistant is speaking'; + case VoiceSessionState.error: + return 'Tap to retry'; + } + } +} + +// MARK: - Supporting Widgets + +class _ModelBadge extends StatelessWidget { + final IconData icon; + final String label; + final String value; + final Color color; + + const _ModelBadge({ + required this.icon, + required this.label, + required this.value, + required this.color, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.smallMedium, + vertical: AppSpacing.xSmall, + ), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 12, color: color), + const SizedBox(width: 4), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: AppTypography.caption2(context).copyWith( + color: AppColors.textSecondary(context), + fontSize: 9, + ), + ), + Text( + value, + style: AppTypography.caption2(context).copyWith( + fontWeight: FontWeight.w500, + fontSize: 10, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ], + ), + ); + } +} + +// MARK: - Helper Classes + +enum ConversationRole { user, assistant } + +class _ConversationTurn { + final ConversationRole role; + final String text; + final DateTime timestamp; + + _ConversationTurn({ + required this.role, + required this.text, + DateTime? timestamp, + }) : timestamp = timestamp ?? DateTime.now(); +} diff --git a/examples/flutter/RunAnywhereAI/lib/helpers/adaptive_layout.dart b/examples/flutter/RunAnywhereAI/lib/helpers/adaptive_layout.dart new file mode 100644 index 000000000..e1af57b11 --- /dev/null +++ b/examples/flutter/RunAnywhereAI/lib/helpers/adaptive_layout.dart @@ -0,0 +1,280 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import 'package:runanywhere_ai/core/design_system/app_colors.dart'; +import 'package:runanywhere_ai/core/design_system/app_spacing.dart'; + +/// AdaptiveLayout (mirroring iOS AdaptiveLayout.swift) +/// +/// Cross-platform layout helpers for adapting UI across different platforms. + +/// Provides platform-aware adaptive colors +class AdaptiveColors { + static Color adaptiveBackground(BuildContext context) => + AppColors.backgroundPrimary(context); + + static Color adaptiveSecondaryBackground(BuildContext context) => + AppColors.backgroundSecondary(context); + + static Color adaptiveTertiaryBackground(BuildContext context) => + AppColors.backgroundTertiary(context); + + static Color adaptiveGroupedBackground(BuildContext context) => + AppColors.backgroundGrouped(context); + + static Color adaptiveSeparator(BuildContext context) => + AppColors.separator(context); + + static Color adaptiveLabel(BuildContext context) => + AppColors.textPrimary(context); + + static Color adaptiveSecondaryLabel(BuildContext context) => + AppColors.textSecondary(context); +} + +/// Adaptive sheet/modal wrapper +class AdaptiveSheet extends StatelessWidget { + final Widget child; + + const AdaptiveSheet({ + super.key, + required this.child, + }); + + /// Show an adaptive sheet/modal + static Future show({ + required BuildContext context, + required Widget Function(BuildContext) builder, + bool isDismissible = true, + bool useRootNavigator = true, + }) { + if (Platform.isMacOS || Platform.isWindows || Platform.isLinux) { + // Desktop: Use dialog with specific sizing + return showDialog( + context: context, + barrierDismissible: isDismissible, + useRootNavigator: useRootNavigator, + builder: (context) => Dialog( + child: ConstrainedBox( + constraints: const BoxConstraints( + minWidth: AppLayout.sheetMinWidth, + maxWidth: AppLayout.sheetMaxWidth, + minHeight: AppLayout.sheetMinHeight, + maxHeight: AppLayout.sheetMaxHeight, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(AppSpacing.cornerRadiusModal), + child: builder(context), + ), + ), + ), + ); + } else { + // Mobile: Use bottom sheet + return showModalBottomSheet( + context: context, + isScrollControlled: true, + isDismissible: isDismissible, + useRootNavigator: useRootNavigator, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(AppSpacing.cornerRadiusModal), + ), + ), + builder: builder, + ); + } + } + + @override + Widget build(BuildContext context) { + return child; + } +} + +/// Adaptive navigation wrapper +class AdaptiveNavigation extends StatelessWidget { + final String title; + final Widget child; + final List? actions; + final Widget? leading; + + const AdaptiveNavigation({ + super.key, + required this.title, + required this.child, + this.actions, + this.leading, + }); + + @override + Widget build(BuildContext context) { + if (Platform.isMacOS) { + // macOS: Custom title bar + return Column( + children: [ + Container( + padding: const EdgeInsets.all(AppSpacing.large), + decoration: BoxDecoration( + color: AdaptiveColors.adaptiveBackground(context), + border: Border( + bottom: BorderSide( + color: AdaptiveColors.adaptiveSeparator(context), + ), + ), + ), + child: Row( + children: [ + if (leading != null) leading!, + Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + if (actions != null) ...actions!, + ], + ), + ), + Expanded(child: child), + ], + ); + } else { + // iOS/Android: Standard AppBar + return Scaffold( + appBar: AppBar( + title: Text(title), + leading: leading, + actions: actions, + ), + body: child, + ); + } + } +} + +/// Adaptive button style +class AdaptiveButtonStyle { + static ButtonStyle primary(BuildContext context) { + return ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.primary, + foregroundColor: Theme.of(context).colorScheme.onPrimary, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.large, + vertical: AppSpacing.mediumLarge, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppSpacing.cornerRadiusRegular), + ), + ); + } + + static ButtonStyle secondary(BuildContext context) { + return OutlinedButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.primary, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.mediumLarge, + vertical: AppSpacing.smallMedium, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppSpacing.cornerRadiusRegular), + ), + ); + } +} + +/// Adaptive text field +class AdaptiveTextField extends StatelessWidget { + final String label; + final TextEditingController? controller; + final String? hintText; + final bool isURL; + final bool isSecure; + final bool isNumeric; + final int maxLines; + final ValueChanged? onChanged; + final VoidCallback? onSubmitted; + + const AdaptiveTextField({ + super.key, + required this.label, + this.controller, + this.hintText, + this.isURL = false, + this.isSecure = false, + this.isNumeric = false, + this.maxLines = 1, + this.onChanged, + this.onSubmitted, + }); + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + obscureText: isSecure, + maxLines: isSecure ? 1 : maxLines, + keyboardType: isURL + ? TextInputType.url + : isNumeric + ? TextInputType.number + : TextInputType.text, + autocorrect: !isURL, + textCapitalization: + isURL ? TextCapitalization.none : TextCapitalization.sentences, + decoration: InputDecoration( + labelText: label, + hintText: hintText, + border: const OutlineInputBorder(), + ), + onChanged: onChanged, + onSubmitted: onSubmitted != null ? (_) => onSubmitted!() : null, + ); + } +} + +/// Adaptive frame for desktop sizing +class AdaptiveFrame extends StatelessWidget { + final Widget child; + + const AdaptiveFrame({ + super.key, + required this.child, + }); + + @override + Widget build(BuildContext context) { + if (Platform.isMacOS || Platform.isWindows || Platform.isLinux) { + return ConstrainedBox( + constraints: const BoxConstraints( + minWidth: AppLayout.macOSMinWidth, + maxWidth: AppLayout.macOSMaxWidth, + minHeight: AppLayout.macOSMinHeight, + maxHeight: AppLayout.macOSMaxHeight, + ), + child: child, + ); + } + return child; + } +} + +/// Platform detection utilities +class PlatformUtils { + static bool get isDesktop => + Platform.isMacOS || Platform.isWindows || Platform.isLinux; + + static bool get isMobile => Platform.isIOS || Platform.isAndroid; + + static bool get isIOS => Platform.isIOS; + + static bool get isAndroid => Platform.isAndroid; + + static bool get isMacOS => Platform.isMacOS; + + static bool get isWindows => Platform.isWindows; + + static bool get isLinux => Platform.isLinux; +} diff --git a/examples/flutter/RunAnywhereAI/lib/main.dart b/examples/flutter/RunAnywhereAI/lib/main.dart new file mode 100644 index 000000000..ff9ee1200 --- /dev/null +++ b/examples/flutter/RunAnywhereAI/lib/main.dart @@ -0,0 +1,6 @@ +import 'package:flutter/material.dart'; +import 'package:runanywhere_ai/app/runanywhere_ai_app.dart'; + +void main() { + runApp(const RunAnywhereAIApp()); +} diff --git a/examples/flutter/RunAnywhereAI/pubspec.yaml b/examples/flutter/RunAnywhereAI/pubspec.yaml new file mode 100644 index 000000000..e4df5d7df --- /dev/null +++ b/examples/flutter/RunAnywhereAI/pubspec.yaml @@ -0,0 +1,52 @@ +name: runanywhere_ai +description: RunAnywhere AI Flutter example app demonstrating on-device AI capabilities +publish_to: 'none' +version: 1.0.0+1 + +environment: + sdk: '>=3.0.0 <4.0.0' + flutter: '>=3.10.0' + +dependencies: + flutter: + sdk: flutter + # RunAnywhere SDK - Core + runanywhere: + path: ../../../sdk/runanywhere-flutter/packages/runanywhere + # RunAnywhere SDK - ONNX Backend (STT, TTS, VAD, LLM) + runanywhere_onnx: + path: ../../../sdk/runanywhere-flutter/packages/runanywhere_onnx + # RunAnywhere SDK - LlamaCpp Backend (LLM) + runanywhere_llamacpp: + path: ../../../sdk/runanywhere-flutter/packages/runanywhere_llamacpp + provider: ^6.1.0 + flutter_markdown: ^0.6.18 + record: ^6.1.0 + audioplayers: ^6.0.0 + permission_handler: ^11.1.0 + shared_preferences: ^2.2.2 + # Secure storage (iOS Keychain, Android Keystore) + flutter_secure_storage: ^9.0.0 + # File system access + path_provider: ^2.1.0 + # UUID generation + uuid: ^4.0.0 + # Device information + device_info_plus: ^10.0.0 + # App package info + package_info_plus: ^4.2.0 + # URL launcher + url_launcher: ^6.2.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^3.0.0 + +# Force all packages to use local path dependencies during development +dependency_overrides: + runanywhere: + path: ../../../sdk/runanywhere-flutter/packages/runanywhere + +flutter: + uses-material-design: true diff --git a/examples/flutter/RunAnywhereAI/test/widget_test.dart b/examples/flutter/RunAnywhereAI/test/widget_test.dart new file mode 100644 index 000000000..5f75b07dc --- /dev/null +++ b/examples/flutter/RunAnywhereAI/test/widget_test.dart @@ -0,0 +1,19 @@ +// Basic Flutter widget test for RunAnywhereAI app. + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:runanywhere_ai/app/runanywhere_ai_app.dart'; + +void main() { + testWidgets('App launches smoke test', (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(const RunAnywhereAIApp()); + + // Verify that the app renders without errors. + // The app should show the main navigation view. + await tester.pumpAndSettle(); + + // Basic check that something rendered + expect(find.byType(RunAnywhereAIApp), findsOneWidget); + }); +} diff --git a/examples/intellij-plugin-demo/plugin/build.gradle.kts b/examples/intellij-plugin-demo/plugin/build.gradle.kts new file mode 100644 index 000000000..71336cbd7 --- /dev/null +++ b/examples/intellij-plugin-demo/plugin/build.gradle.kts @@ -0,0 +1,63 @@ +plugins { + id("org.jetbrains.intellij") version "1.17.4" + kotlin("jvm") version "2.1.0" // Match the version from gradle/libs.versions.toml + java +} + +group = "com.runanywhere" +version = "1.0.0" + +intellij { + version.set("2024.1") // Use 2024.1 to avoid compatibility warnings with plugin 1.x + type.set("IC") + plugins.set(listOf("java")) +} + +repositories { + mavenLocal() // For SDK dependency + mavenCentral() + gradlePluginPortal() + google() +} + +dependencies { + // RunAnywhere KMP SDK - Use Maven Local (published separately) + // Run './gradlew publishSdkToMavenLocal' from root to publish SDK + implementation("com.runanywhere.sdk:RunAnywhereKotlinSDK-jvm:0.1.0") +} + +tasks { + patchPluginXml { + sinceBuild.set("241") + untilBuild.set("251.*") + changeNotes.set( + """ +

1.0.0

+
    +
  • Initial release
  • +
  • Voice command support
  • +
  • Voice dictation mode
  • +
  • Whisper-based transcription
  • +
+ """.trimIndent() + ) + } + + buildPlugin { + archiveFileName.set("runanywhere-voice-${project.version}.zip") + } + + // Skip generating searchable options (faster CI and avoids headless issues) + buildSearchableOptions { + enabled = false + } + + publishPlugin { + token.set(System.getenv("JETBRAINS_TOKEN")) + } +} + +// Use JDK 17 for compilation (matches IntelliJ 2024.2 runtime) +kotlin { + jvmToolchain(17) +} diff --git a/examples/intellij-plugin-demo/plugin/gradle/wrapper/gradle-wrapper.properties b/examples/intellij-plugin-demo/plugin/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..37f853b1c --- /dev/null +++ b/examples/intellij-plugin-demo/plugin/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/examples/intellij-plugin-demo/plugin/gradlew b/examples/intellij-plugin-demo/plugin/gradlew new file mode 100755 index 000000000..23d15a936 --- /dev/null +++ b/examples/intellij-plugin-demo/plugin/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/examples/intellij-plugin-demo/plugin/gradlew.bat b/examples/intellij-plugin-demo/plugin/gradlew.bat new file mode 100644 index 000000000..db3a6ac20 --- /dev/null +++ b/examples/intellij-plugin-demo/plugin/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/examples/intellij-plugin-demo/plugin/settings.gradle.kts b/examples/intellij-plugin-demo/plugin/settings.gradle.kts new file mode 100644 index 000000000..ba4bb7ed4 --- /dev/null +++ b/examples/intellij-plugin-demo/plugin/settings.gradle.kts @@ -0,0 +1,29 @@ +rootProject.name = "runanywhere-intellij-plugin" + +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } + resolutionStrategy { + eachPlugin { + if (requested.id.id == "org.jetbrains.kotlin.multiplatform") { + useVersion("2.1.0") + } + } + } +} + +dependencyResolutionManagement { + repositories { + mavenLocal() + mavenCentral() + gradlePluginPortal() + google() + } + versionCatalogs { + create("libs") { + from(files("../../../gradle/libs.versions.toml")) + } + } +} diff --git a/examples/intellij-plugin-demo/plugin/src/main/kotlin/com/runanywhere/plugin/RunAnywherePlugin.kt b/examples/intellij-plugin-demo/plugin/src/main/kotlin/com/runanywhere/plugin/RunAnywherePlugin.kt new file mode 100644 index 000000000..44b93e380 --- /dev/null +++ b/examples/intellij-plugin-demo/plugin/src/main/kotlin/com/runanywhere/plugin/RunAnywherePlugin.kt @@ -0,0 +1,214 @@ +package com.runanywhere.plugin + +import com.intellij.notification.NotificationGroupManager +import com.intellij.notification.NotificationType +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.service +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.ProgressManager +import com.intellij.openapi.progress.Task +import com.intellij.openapi.project.Project +import com.intellij.openapi.startup.StartupActivity +import com.runanywhere.sdk.providers.JvmWhisperSTTServiceProvider +import com.runanywhere.sdk.data.models.SDKEnvironment +import com.runanywhere.sdk.foundation.SDKLogger +import com.runanywhere.sdk.public.RunAnywhere +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +/** + * Main plugin startup activity with production backend authentication + */ +class RunAnywherePlugin : StartupActivity { + + companion object { + // API key configuration - can be set via: + // 1. System property: -Drunanywhere.api.key=your_key + // 2. Environment variable: RUNANYWHERE_API_KEY=your_key + private val API_KEY = System.getProperty("runanywhere.api.key") + ?: System.getenv("RUNANYWHERE_API_KEY") + ?: "" // Set via environment variable or system property + + // API URL configuration - can be set via: + // 1. System property: -Drunanywhere.api.url=your_url + // 2. Environment variable: RUNANYWHERE_API_URL=your_url + private val API_URL = System.getProperty("runanywhere.api.url") + ?: System.getenv("RUNANYWHERE_API_URL") + // No default URL - must be provided via environment + + // SDK Environment configuration + private val SDK_ENVIRONMENT = run { + val envProperty = System.getProperty("runanywhere.environment", "development") // Default to development for local plugin development + println("🔍 Environment property value: '$envProperty'") + when (envProperty.lowercase()) { + "development", "dev" -> { + println("🔧 Using DEVELOPMENT environment") + SDKEnvironment.DEVELOPMENT + } + "staging" -> { + println("🚀 Using STAGING environment") + SDKEnvironment.STAGING + } + "production", "prod" -> { + println("🏭 Using PRODUCTION environment") + SDKEnvironment.PRODUCTION + } + else -> { + println("🔧 Unknown environment '$envProperty', defaulting to DEVELOPMENT") + SDKEnvironment.DEVELOPMENT // Default to development for safety + } + } + } + } + + @OptIn(DelicateCoroutinesApi::class) + override fun runActivity(project: Project) { + // Initialize SDK in background + ProgressManager.getInstance() + .run(object : Task.Backgroundable(project, "Initializing RunAnywhere SDK", false) { + override fun run(indicator: ProgressIndicator) { + indicator.text = "Initializing RunAnywhere SDK..." + indicator.isIndeterminate = true + + initializationJob = GlobalScope.launch { + try { + logger.info("Starting SDK initialization...") + logger.info("Environment: $SDK_ENVIRONMENT") + logger.info("API Key configured: ${if (API_KEY.isNotEmpty()) "Yes" else "No"}") + + // Step 1: Register WhisperJNI STT provider + // TODO: For v1, we're using hardcoded models defined in the provider + // In future versions, models will be served/configured from the console + logger.info("Registering WhisperJNI STT provider...") + registerWhisperKitProvider() + + // Step 2: Initialize SDK + logger.info("Initializing RunAnywhere SDK...") + if (SDK_ENVIRONMENT == SDKEnvironment.DEVELOPMENT) { + logger.info("🔧 DEVELOPMENT MODE: Using local/mock services") + } + try { + RunAnywhere.initialize( + apiKey = if (SDK_ENVIRONMENT == SDKEnvironment.DEVELOPMENT) "demo-api-key" else API_KEY, + baseURL = if (SDK_ENVIRONMENT == SDKEnvironment.DEVELOPMENT) null else (API_URL ?: "https://api.runanywhere.ai"), // No base URL in development + environment = SDK_ENVIRONMENT + ) + } catch (authError: Exception) { + // Always ignore authentication errors in local development + if (authError.message?.contains("500") == true || + authError.message?.contains("Authentication") == true || + authError.message?.contains("failed") == true) { + logger.warn("🔧 DEVELOPMENT/LOCAL MODE: Authentication failed, continuing with local/mock services") + logger.warn("Authentication error (ignored): ${authError.message}") + logger.info("💡 Plugin will use mock transcription and local services") + // Allow the plugin to continue - use mock/local services + } else { + throw authError // Re-throw if not an auth error + } + } + + // Step 3: Verify component initialization + logger.info("Verifying component initialization...") + val serviceContainer = + com.runanywhere.sdk.foundation.ServiceContainer.shared + val registeredModules = + com.runanywhere.sdk.core.ModuleRegistry.registeredModules + + isInitialized = true + + ApplicationManager.getApplication().invokeLater { + val envEmoji = when (SDK_ENVIRONMENT) { + SDKEnvironment.DEVELOPMENT -> "🔧" + SDKEnvironment.STAGING -> "🚧" + SDKEnvironment.PRODUCTION -> "🚀" + } + + println("✅ RunAnywhere SDK v0.1 initialized successfully") + println("$envEmoji Environment: $SDK_ENVIRONMENT") + println("🔐 Authenticated with backend") + println("📊 Registered modules: $registeredModules") + println("🎙️ WhisperJNI STT: ${if (com.runanywhere.sdk.core.ModuleRegistry.hasSTT) "✅" else "❌"}") + println("🔊 VAD: ${if (com.runanywhere.sdk.core.ModuleRegistry.hasVAD) "✅" else "❌"}") + + showNotification( + project, "SDK Ready", + "RunAnywhere SDK initialized and authenticated with backend", + NotificationType.INFORMATION + ) + } + + } catch (e: Exception) { + ApplicationManager.getApplication().invokeLater { + val errorMessage = when { + e.message?.contains("500") == true && SDK_ENVIRONMENT == SDKEnvironment.DEVELOPMENT -> + "Development mode authentication error. SDK should work offline in development." + e.message?.contains("Authentication failed") == true && SDK_ENVIRONMENT == SDKEnvironment.DEVELOPMENT -> + "Authentication should be skipped in development mode. Check SDK configuration." + e.message?.contains("API key") == true -> + "Invalid API key. Please check your configuration." + e.message?.contains("network") == true -> + "Network error. Please check your connection." + else -> e.message ?: "Unknown error" + } + + logger.error("❌ Failed to initialize RunAnywhere SDK: $errorMessage") + logger.error("Full exception details:") + e.printStackTrace() + + showNotification( + project, "SDK Error", + "Failed to initialize SDK: $errorMessage", + NotificationType.ERROR + ) + } + } + } + } + }) + + // Initialize voice service when needed + project.service().initialize() + + println("RunAnywhere Voice Commands plugin started for project: ${project.name}") + } + + private fun showNotification( + project: Project, + title: String, + content: String, + type: NotificationType + ) { + ApplicationManager.getApplication().invokeLater { + NotificationGroupManager.getInstance() + .getNotificationGroup("RunAnywhere.Notifications") + .createNotification(title, content, type) + .notify(project) + } + } + + /** + * Register JVM WhisperJNI STT provider with the SDK + * This enables real WhisperJNI transcription functionality (not mocked) + */ + private fun registerWhisperKitProvider() { + try { + // Use the real JVM WhisperSTT provider instead of mock + JvmWhisperSTTServiceProvider.register() + logger.info("✅ JVM WhisperJNI STT provider registered successfully") + + // Log available models + val provider = JvmWhisperSTTServiceProvider() + val availableModels = provider.getAvailableModels() + logger.info("Available Whisper models: ${availableModels.map { "${it.modelId} (${if (it.isDownloaded) "downloaded" else "not downloaded"})" }}") + + } catch (e: Exception) { + logger.error("❌ Failed to register JVM WhisperJNI STT provider", e) + } + } +} + +private val logger = SDKLogger("RunAnywherePlugin") +var isInitialized = false +var initializationJob: Job? = null diff --git a/examples/intellij-plugin-demo/plugin/src/main/kotlin/com/runanywhere/plugin/actions/ModelManagerAction.kt b/examples/intellij-plugin-demo/plugin/src/main/kotlin/com/runanywhere/plugin/actions/ModelManagerAction.kt new file mode 100644 index 000000000..866704d57 --- /dev/null +++ b/examples/intellij-plugin-demo/plugin/src/main/kotlin/com/runanywhere/plugin/actions/ModelManagerAction.kt @@ -0,0 +1,42 @@ +package com.runanywhere.plugin.actions + +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.ui.Messages +import com.runanywhere.plugin.isInitialized +import com.runanywhere.plugin.ui.ModelManagerDialog + +/** + * Action to open the Model Manager dialog + */ +class ModelManagerAction : AnAction("Manage Models") { + + override fun actionPerformed(e: AnActionEvent) { + val project = e.project + if (project == null) { + Messages.showErrorDialog( + "No project is open", + "Model Manager Error" + ) + return + } + + if (!isInitialized) { + Messages.showWarningDialog( + project, + "RunAnywhere SDK is still initializing. Please wait...", + "SDK Not Ready" + ) + return + } + + // Open the Model Manager dialog + val dialog = ModelManagerDialog(project) + dialog.show() + } + + override fun update(e: AnActionEvent) { + // Enable the action only when a project is open and SDK is initialized + e.presentation.isEnabled = e.project != null && isInitialized + } +} diff --git a/examples/intellij-plugin-demo/plugin/src/main/kotlin/com/runanywhere/plugin/actions/VoiceCommandAction.kt b/examples/intellij-plugin-demo/plugin/src/main/kotlin/com/runanywhere/plugin/actions/VoiceCommandAction.kt new file mode 100644 index 000000000..25a71a4c9 --- /dev/null +++ b/examples/intellij-plugin-demo/plugin/src/main/kotlin/com/runanywhere/plugin/actions/VoiceCommandAction.kt @@ -0,0 +1,148 @@ +package com.runanywhere.plugin.actions + +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.openapi.command.WriteCommandAction +import com.intellij.openapi.components.service +import com.intellij.openapi.ui.Messages +import com.runanywhere.plugin.isInitialized +import com.runanywhere.plugin.services.VoiceService +import com.runanywhere.sdk.public.RunAnywhere +import com.runanywhere.sdk.components.stt.STTStreamEvent +import kotlinx.coroutines.* +import com.intellij.openapi.application.ApplicationManager +import javax.swing.SwingUtilities + +/** + * Action to trigger voice command input with STT + */ +class VoiceCommandAction : AnAction("Voice Command") { + + private var isRecording = false + + override fun actionPerformed(e: AnActionEvent) { + val project = e.project + if (project == null) { + Messages.showErrorDialog( + "No project is open", + "Voice Command Error" + ) + return + } + + if (!isInitialized) { + Messages.showWarningDialog( + project, + "RunAnywhere SDK is still initializing. Please wait...", + "SDK Not Ready" + ) + return + } + + val voiceService = project.service() + val editor = e.getData(CommonDataKeys.EDITOR) + + if (!isRecording) { + // Start recording + isRecording = true + e.presentation.text = "Stop Recording" + + // Use the new streaming transcription API + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch { + try { + RunAnywhere.startStreamingTranscription() + .collect { sttEvent -> + // Handle different STT event types + when (sttEvent) { + is STTStreamEvent.FinalTranscription -> { + val transcription = sttEvent.result.transcript + SwingUtilities.invokeLater { + if (editor != null && editor.document.isWritable) { + // Insert transcription at cursor position + WriteCommandAction.runWriteCommandAction(project) { + val offset = editor.caretModel.offset + editor.document.insertString(offset, transcription) + editor.caretModel.moveToOffset(offset + transcription.length) + } + } else { + // Show in dialog if no editor available + Messages.showInfoMessage( + project, + "Transcription: $transcription", + "Voice Command Result" + ) + } + } + } + is STTStreamEvent.PartialTranscription -> { + // Could show partial results in status bar if desired + // For now, we'll just ignore partial results + } + is STTStreamEvent.AudioLevelChanged -> { + // Could show audio level indicator if desired + // For now, we'll just ignore audio level changes + } + is STTStreamEvent.Error -> { + // Handle transcription errors + SwingUtilities.invokeLater { + Messages.showErrorDialog( + project, + "Transcription error: ${sttEvent.error}", + "Voice Command Error" + ) + } + } + is STTStreamEvent.LanguageDetected -> { + // Language detection - could log or ignore + } + STTStreamEvent.SilenceDetected -> { + // Silence detection - could use for UI feedback + } + is STTStreamEvent.SpeakerChanged -> { + // Speaker change - could use for multi-speaker scenarios + } + STTStreamEvent.SpeechEnded -> { + // Speech ended - could use for UI feedback + } + STTStreamEvent.SpeechStarted -> { + // Speech started - could use for UI feedback + } + } + } + } catch (e: Exception) { + SwingUtilities.invokeLater { + Messages.showErrorDialog( + project, + "Voice transcription failed: ${e.message}", + "Voice Command Error" + ) + } + } finally { + SwingUtilities.invokeLater { + isRecording = false + e.presentation.text = "Voice Command" + } + } + } + } else { + // Stop recording + RunAnywhere.stopStreamingTranscription() + isRecording = false + e.presentation.text = "Voice Command" + } + } + + override fun update(e: AnActionEvent) { + // Enable the action only when a project is open + e.presentation.isEnabled = e.project != null + + // Update text based on recording state + if (isRecording) { + e.presentation.text = "Stop Recording" + } else { + e.presentation.text = "Voice Command" + } + } +} diff --git a/examples/intellij-plugin-demo/plugin/src/main/kotlin/com/runanywhere/plugin/actions/VoiceDictationAction.kt b/examples/intellij-plugin-demo/plugin/src/main/kotlin/com/runanywhere/plugin/actions/VoiceDictationAction.kt new file mode 100644 index 000000000..cf1d486af --- /dev/null +++ b/examples/intellij-plugin-demo/plugin/src/main/kotlin/com/runanywhere/plugin/actions/VoiceDictationAction.kt @@ -0,0 +1,16 @@ +package com.runanywhere.plugin.actions + +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.ui.Messages + +class VoiceDictationAction : AnAction("Voice Dictation") { + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + Messages.showInfoMessage( + project, + "Voice Dictation feature coming soon!", + "Voice Dictation" + ) + } +} diff --git a/examples/intellij-plugin-demo/plugin/src/main/kotlin/com/runanywhere/plugin/services/VoiceService.kt b/examples/intellij-plugin-demo/plugin/src/main/kotlin/com/runanywhere/plugin/services/VoiceService.kt new file mode 100644 index 000000000..e2fd080ad --- /dev/null +++ b/examples/intellij-plugin-demo/plugin/src/main/kotlin/com/runanywhere/plugin/services/VoiceService.kt @@ -0,0 +1,188 @@ +package com.runanywhere.plugin.services + +import com.intellij.notification.NotificationGroupManager +import com.intellij.notification.NotificationType +import com.intellij.openapi.Disposable +import com.intellij.openapi.components.Service +import com.intellij.openapi.project.Project +import com.runanywhere.sdk.public.RunAnywhere +import com.runanywhere.sdk.components.stt.STTStreamEvent +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +/** + * Service for managing voice capture and transcription using RunAnywhere SDK + * + * This service is now deprecated in favor of directly using the SDK APIs: + * - RunAnywhere.transcribeWithRecording() for simple recording + * - RunAnywhere.startStreamingTranscription() for continuous streaming + * + * The SDK handles all audio capture internally. + */ +@Service(Service.Level.PROJECT) +class VoiceService(private val project: Project) : Disposable { + + private var isInitialized = false + private var isRecording = false + + // Create a coroutine scope with proper exception handling for IntelliJ + private val exceptionHandler = CoroutineExceptionHandler { _, throwable -> + println("VoiceService: Coroutine exception: ${throwable.message}") + } + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob() + exceptionHandler) + private var streamingJob: Job? = null + + fun initialize() { + if (!isInitialized) { + println("VoiceService: Initializing...") + isInitialized = true + } + } + + /** + * Start voice capture with streaming transcription + * @deprecated Use RunAnywhere.startStreamingTranscription() directly + */ + @Deprecated("Use RunAnywhere.startStreamingTranscription() directly") + fun startVoiceCapture(onTranscription: (String) -> Unit) { + if (!com.runanywhere.plugin.isInitialized) { + showNotification( + "SDK not initialized", + "Please wait for SDK initialization to complete", + NotificationType.WARNING + ) + return + } + + if (isRecording) { + println("Already recording") + return + } + + isRecording = true + + showNotification( + "Recording", + "Voice recording started. Speaking will be transcribed in real-time...", + NotificationType.INFORMATION + ) + + // Start streaming transcription job using SDK's internal audio capture + streamingJob = scope.launch { + try { + // Use SDK's internal audio capture and streaming transcription + val transcriptionFlow = RunAnywhere.startStreamingTranscription( + chunkSizeMs = 100 // 100ms chunks for real-time feedback + ) + + // Collect transcription events + transcriptionFlow + .catch { e -> + println("VoiceService: Streaming error: ${e.message}") + showNotification( + "Streaming Error", + "Failed during streaming: ${e.message}", + NotificationType.ERROR + ) + } + .collect { event -> + when (event) { + is STTStreamEvent.SpeechStarted -> { + println("VoiceService: Speech detected, starting transcription...") + } + + is STTStreamEvent.PartialTranscription -> { + println("VoiceService: Partial: ${event.text}") + // Show partial results in real-time + if (event.text.isNotEmpty()) { + onTranscription("[Listening...] ${event.text}") + } + } + + is STTStreamEvent.FinalTranscription -> { + val text = event.result.transcript + println("VoiceService: Final: $text") + if (text.isNotEmpty()) { + onTranscription(text) + showNotification( + "Transcribed", + text, + NotificationType.INFORMATION + ) + } + } + + is STTStreamEvent.SpeechEnded -> { + println("VoiceService: Speech ended") + } + + is STTStreamEvent.SilenceDetected -> { + println("VoiceService: Silence detected") + } + + is STTStreamEvent.Error -> { + println("VoiceService: Error: ${event.error.message}") + showNotification( + "STT Error", + event.error.message, + NotificationType.ERROR + ) + } + + else -> { + // Handle other event types if needed + println("VoiceService: Event: $event") + } + } + } + } catch (e: kotlinx.coroutines.CancellationException) { + // This is normal when stopping recording + println("VoiceService: Streaming cancelled (recording stopped)") + throw e // Must rethrow CancellationException + } catch (e: Exception) { + println("VoiceService: Unexpected error: ${e.message}") + e.printStackTrace() + showNotification( + "Error", + "Unexpected error: ${e.message}", + NotificationType.ERROR + ) + } + } + } + + fun stopVoiceCapture() { + if (!isRecording) { + println("Not recording") + return + } + + isRecording = false + + // Stop SDK's internal audio capture + RunAnywhere.stopStreamingTranscription() + + // Cancel streaming job + streamingJob?.cancel() + streamingJob = null + + showNotification("Recording Stopped", "Voice capture ended", NotificationType.INFORMATION) + } + + fun isRecording(): Boolean = isRecording + + private fun showNotification(title: String, content: String, type: NotificationType) { + NotificationGroupManager.getInstance() + .getNotificationGroup("RunAnywhere.Notifications") + .createNotification(title, content, type) + .notify(project) + } + + override fun dispose() { + if (isRecording) { + stopVoiceCapture() + } + scope.cancel() + println("VoiceService disposed") + } +} diff --git a/examples/intellij-plugin-demo/plugin/src/main/kotlin/com/runanywhere/plugin/toolwindow/STTToolWindow.kt b/examples/intellij-plugin-demo/plugin/src/main/kotlin/com/runanywhere/plugin/toolwindow/STTToolWindow.kt new file mode 100644 index 000000000..6a83c2ce9 --- /dev/null +++ b/examples/intellij-plugin-demo/plugin/src/main/kotlin/com/runanywhere/plugin/toolwindow/STTToolWindow.kt @@ -0,0 +1,535 @@ +package com.runanywhere.plugin.toolwindow + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.wm.ToolWindow +import com.intellij.openapi.wm.ToolWindowFactory +import com.intellij.ui.components.JBScrollPane +import com.intellij.ui.components.JBTextArea +import com.intellij.ui.content.ContentFactory +import com.runanywhere.plugin.services.VoiceService +import com.runanywhere.plugin.ui.ModelManagerDialog +import com.runanywhere.plugin.ui.WaveformVisualization +import com.runanywhere.sdk.foundation.SDKLogger +import com.runanywhere.sdk.public.RunAnywhere +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import java.awt.BorderLayout +import java.awt.Color +import java.awt.Dimension +import java.awt.FlowLayout +import java.awt.Font +import java.awt.GridBagConstraints +import java.awt.GridBagLayout +import java.awt.Insets +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import javax.swing.JButton +import javax.swing.JLabel +import javax.swing.JPanel +import javax.swing.JSeparator +import javax.swing.Timer +import javax.swing.border.EmptyBorder +import javax.swing.border.TitledBorder + +/** + * Tool window for RunAnywhere STT with recording controls and transcription display + */ +class STTToolWindow : ToolWindowFactory { + + override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { + val contentFactory = ContentFactory.getInstance() + val content = contentFactory.createContent(STTPanel(project), "", false) + toolWindow.contentManager.addContent(content) + } +} + +/** + * Main panel for STT functionality with two modes: + * 1. Simple recording - Record audio then transcribe once + * 2. Continuous streaming - Real-time transcription as you speak + */ +class STTPanel(private val project: Project) : JPanel(BorderLayout()), Disposable { + + private val logger = SDKLogger("STTPanel") + private val voiceService = project.getService(VoiceService::class.java) + + // Create a coroutine scope with proper exception handling for IntelliJ + private val exceptionHandler = CoroutineExceptionHandler { _, throwable -> + logger.error("Coroutine exception", throwable) + } + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob() + exceptionHandler) + + // UI Components + private val simpleRecordButton = JButton("Start Recording") + private val streamingButton = JButton("Start Streaming") + private val modelManagerButton = JButton("Manage Models") + private val clearButton = JButton("Clear") + private val statusLabel = JLabel("Ready") + private val transcriptionArea = JBTextArea().apply { + isEditable = false + lineWrap = true + wrapStyleWord = true + font = Font(Font.MONOSPACED, Font.PLAIN, 12) + } + private val waveformVisualization = WaveformVisualization() + + // State tracking + private var isSimpleRecording = false + private var isStreaming = false + private var recordingJob: Job? = null + private var recordingStartTime = 0L + + init { + setupUI() + setupListeners() + updateStatus() + + // Register for disposal + Disposer.register(project, this) + } + + private fun setupUI() { + // Main layout + layout = BorderLayout(10, 10) + border = EmptyBorder(10, 10, 10, 10) + + // Top panel with title and status + val topPanel = JPanel(BorderLayout()).apply { + val titleLabel = JLabel("RunAnywhere Speech-to-Text").apply { + font = font.deriveFont(Font.BOLD, 14f) + } + add(titleLabel, BorderLayout.WEST) + + val statusPanel = JPanel(FlowLayout(FlowLayout.RIGHT)).apply { + add(JLabel("Status:")) + add(statusLabel) + } + add(statusPanel, BorderLayout.EAST) + } + + // Control panel with recording buttons + val controlPanel = JPanel(GridBagLayout()).apply { + border = TitledBorder("Controls") + val gbc = GridBagConstraints().apply { + fill = GridBagConstraints.HORIZONTAL + insets = Insets(5, 5, 5, 5) + } + + // Simple Recording Section + gbc.gridx = 0 + gbc.gridy = 0 + gbc.gridwidth = 2 + add(JLabel("Simple Recording:").apply { + font = font.deriveFont(Font.BOLD) + }, gbc) + + gbc.gridy = 1 + gbc.gridwidth = 1 + add(JLabel("Record and transcribe once:"), gbc) + + gbc.gridx = 1 + add(simpleRecordButton, gbc) + + // Separator + gbc.gridx = 0 + gbc.gridy = 2 + gbc.gridwidth = 2 + add(JSeparator(), gbc) + + // Streaming Section + gbc.gridy = 3 + add(JLabel("Continuous Streaming:").apply { + font = font.deriveFont(Font.BOLD) + }, gbc) + + gbc.gridy = 4 + gbc.gridwidth = 1 + add(JLabel("Real-time transcription:"), gbc) + + gbc.gridx = 1 + add(streamingButton, gbc) + + // Separator + gbc.gridx = 0 + gbc.gridy = 5 + gbc.gridwidth = 2 + add(JSeparator(), gbc) + + // Model Manager and Clear buttons + gbc.gridy = 6 + gbc.gridwidth = 1 + add(modelManagerButton, gbc) + + gbc.gridx = 1 + add(clearButton, gbc) + } + + // Waveform panel + val waveformPanel = JPanel(BorderLayout()).apply { + border = TitledBorder("Audio Waveform") + add(waveformVisualization, BorderLayout.CENTER) + preferredSize = Dimension(400, 120) + } + + // Center panel with transcription area + val transcriptionPanel = JPanel(BorderLayout()).apply { + border = TitledBorder("Transcriptions") + add(JBScrollPane(transcriptionArea), BorderLayout.CENTER) + preferredSize = Dimension(400, 200) + } + + // Right panel with waveform and transcription + val rightPanel = JPanel(BorderLayout(0, 10)).apply { + add(waveformPanel, BorderLayout.NORTH) + add(transcriptionPanel, BorderLayout.CENTER) + } + + // Add all panels + add(topPanel, BorderLayout.NORTH) + + val mainPanel = JPanel(BorderLayout(10, 10)).apply { + add(controlPanel, BorderLayout.WEST) + add(rightPanel, BorderLayout.CENTER) + } + add(mainPanel, BorderLayout.CENTER) + } + + private fun setupListeners() { + // Simple recording button - Start/Stop recording then transcribe + simpleRecordButton.addActionListener { + if (!isStreaming) { + toggleSimpleRecording() + } + } + + // Streaming button - Continuous real-time transcription + streamingButton.addActionListener { + if (!isSimpleRecording) { + toggleStreaming() + } + } + + // Model manager button + modelManagerButton.addActionListener { + showModelManager() + } + + // Clear button + clearButton.addActionListener { + transcriptionArea.text = "" + waveformVisualization.clear() + } + } + + /** + * Toggle simple recording mode + * This records audio for a fixed duration and transcribes it once + */ + private fun toggleSimpleRecording() { + if (!isSimpleRecording) { + startSimpleRecording() + } else { + stopSimpleRecording() + } + } + + private fun startSimpleRecording() { + if (!com.runanywhere.plugin.isInitialized) { + statusLabel.text = "SDK not initialized" + statusLabel.foreground = Color.RED + return + } + + isSimpleRecording = true + simpleRecordButton.text = "Stop Recording" + streamingButton.isEnabled = false + statusLabel.text = "Recording..." + statusLabel.foreground = Color.RED + recordingStartTime = System.currentTimeMillis() + + // Start recording with waveform visualization using SDK's new API + recordingJob = scope.launch { + try { + // Start recording with waveform feedback + RunAnywhere.startRecordingWithWaveform() + .collect { audioEvent -> + // Update waveform with audio energy + ApplicationManager.getApplication().invokeLater { + waveformVisualization.updateEnergy(audioEvent.level) + } + + // Auto-stop after 30 seconds + val elapsed = (System.currentTimeMillis() - recordingStartTime) / 1000 + if (elapsed >= 30) { + ApplicationManager.getApplication().invokeLater { + stopSimpleRecording() + } + return@collect + } + + // Update status to show recording time + if (elapsed % 1 == 0L) { // Update every second + ApplicationManager.getApplication().invokeLater { + statusLabel.text = "Recording... (${elapsed}s)" + } + } + } + } catch (e: kotlinx.coroutines.CancellationException) { + // Normal cancellation when user stops + logger.info("Recording with waveform cancelled") + } catch (e: Exception) { + ApplicationManager.getApplication().invokeLater { + logger.error("Recording with waveform error", e) + statusLabel.text = "Recording error" + statusLabel.foreground = Color.RED + } + } + } + } + + private fun stopSimpleRecording() { + if (!isSimpleRecording) return + + // Calculate recording duration + val recordingDuration = ((System.currentTimeMillis() - recordingStartTime) / 1000).toInt() + + isSimpleRecording = false + simpleRecordButton.text = "Start Recording" + streamingButton.isEnabled = true + statusLabel.text = "Transcribing ${recordingDuration}s of audio..." + statusLabel.foreground = Color.ORANGE + + // Cancel the recording job + recordingJob?.cancel() + recordingJob = null + + // Clear waveform + waveformVisualization.clear() + + // Stop recording and transcribe using SDK's new API + scope.launch { + try { + // Stop recording and get transcription of what was recorded + val text = RunAnywhere.stopRecordingAndTranscribe() + + ApplicationManager.getApplication().invokeLater { + if (text.isNotEmpty()) { + appendTranscription("[Recorded ${recordingDuration}s] $text") + } else { + appendTranscription("[Recorded ${recordingDuration}s] (No speech detected)") + } + statusLabel.text = "Ready" + statusLabel.foreground = Color.BLACK + } + } catch (e: Exception) { + ApplicationManager.getApplication().invokeLater { + logger.error("Transcription error", e) + appendTranscription("[Error] Failed to transcribe: ${e.message}") + statusLabel.text = "Ready" + statusLabel.foreground = Color.BLACK + } + } + } + } + + /** + * Toggle streaming transcription mode + * This provides real-time transcription as you speak + */ + private fun toggleStreaming() { + if (!isStreaming) { + startStreaming() + } else { + stopStreaming() + } + } + + private fun startStreaming() { + if (!com.runanywhere.plugin.isInitialized) { + statusLabel.text = "SDK not initialized" + statusLabel.foreground = Color.RED + return + } + + isStreaming = true + streamingButton.text = "Stop Streaming" + simpleRecordButton.isEnabled = false + statusLabel.text = "Listening..." + statusLabel.foreground = Color.GREEN + + // Use SDK's startStreamingTranscription API directly + // The SDK handles all audio capture internally + recordingJob = scope.launch { + try { + RunAnywhere.startStreamingTranscription(100) // 100ms chunks + .collect { event -> + when (event) { + is com.runanywhere.sdk.components.stt.STTStreamEvent.SpeechStarted -> { + ApplicationManager.getApplication().invokeLater { + statusLabel.text = "Speaking..." + statusLabel.foreground = Color.GREEN + } + } + + is com.runanywhere.sdk.components.stt.STTStreamEvent.PartialTranscription -> { + ApplicationManager.getApplication().invokeLater { + // Show partial transcription in status + val partial = event.text.take(50) + statusLabel.text = + "Speaking: $partial${if (event.text.length > 50) "..." else ""}" + } + } + + is com.runanywhere.sdk.components.stt.STTStreamEvent.FinalTranscription -> { + val text = event.result.transcript + if (text.isNotEmpty()) { + ApplicationManager.getApplication().invokeLater { + appendTranscription("[Streaming] $text") + statusLabel.text = "Listening..." + statusLabel.foreground = Color.GREEN + } + } + } + + is com.runanywhere.sdk.components.stt.STTStreamEvent.SpeechEnded -> { + ApplicationManager.getApplication().invokeLater { + statusLabel.text = "Listening..." + statusLabel.foreground = Color.GREEN + } + } + + is com.runanywhere.sdk.components.stt.STTStreamEvent.SilenceDetected -> { + // Optionally update UI for silence + } + + is com.runanywhere.sdk.components.stt.STTStreamEvent.AudioLevelChanged -> { + ApplicationManager.getApplication().invokeLater { + waveformVisualization.updateEnergy(event.level) + } + } + + is com.runanywhere.sdk.components.stt.STTStreamEvent.Error -> { + ApplicationManager.getApplication().invokeLater { + logger.error("Streaming error: ${event.error.message}") + appendTranscription("[Error] ${event.error.message}") + statusLabel.text = "Error - Restarting..." + statusLabel.foreground = Color.RED + } + } + + else -> { + // Handle any other events + logger.debug("Streaming event: $event") + } + } + } + } catch (e: kotlinx.coroutines.CancellationException) { + // Normal cancellation when stopping + logger.info("Streaming cancelled") + } catch (e: Exception) { + ApplicationManager.getApplication().invokeLater { + logger.error("Streaming error", e) + appendTranscription("[Error] Streaming failed: ${e.message}") + statusLabel.text = "Ready" + statusLabel.foreground = Color.BLACK + isStreaming = false + streamingButton.text = "Start Streaming" + simpleRecordButton.isEnabled = true + } + } + } + } + + private fun stopStreaming() { + isStreaming = false + streamingButton.text = "Start Streaming" + simpleRecordButton.isEnabled = true + statusLabel.text = "Stopping..." + statusLabel.foreground = Color.ORANGE + + // Stop SDK's internal audio capture + RunAnywhere.stopStreamingTranscription() + + // Cancel the streaming job + recordingJob?.cancel() + recordingJob = null + + // Clear waveform + waveformVisualization.clear() + + // Reset status after a short delay + Timer(1000) { + ApplicationManager.getApplication().invokeLater { + statusLabel.text = "Ready" + statusLabel.foreground = Color.BLACK + } + }.apply { + isRepeats = false + start() + } + } + + private fun appendTranscription(text: String) { + val timestamp = SimpleDateFormat("HH:mm:ss", Locale.US).format(Date()) + val entry = "[$timestamp] $text\n" + transcriptionArea.append(entry) + transcriptionArea.caretPosition = transcriptionArea.document.length + + // Insert into active editor if available + val cleanText = text.removePrefix("[Recorded] ").removePrefix("[Streaming] ") + if (cleanText.isNotEmpty() && !text.startsWith("[Listening...]")) { + val editor = FileEditorManager.getInstance(project).selectedTextEditor + if (editor != null && editor.document.isWritable) { + ApplicationManager.getApplication().runWriteAction { + val offset = editor.caretModel.offset + editor.document.insertString(offset, cleanText) + editor.caretModel.moveToOffset(offset + cleanText.length) + } + } + } + } + + private fun showModelManager() { + val dialog = ModelManagerDialog(project) + dialog.show() + } + + private fun updateStatus() { + scope.launch { + try { + if (com.runanywhere.plugin.isInitialized) { + val models = RunAnywhere.availableModels() + ApplicationManager.getApplication().invokeLater { + logger.info("Found ${models.size} available models") + } + } + } catch (e: Exception) { + ApplicationManager.getApplication().invokeLater { + logger.warn("Failed to fetch models: ${e.message}") + } + } + } + } + + override fun dispose() { + if (isStreaming) { + voiceService.stopVoiceCapture() + } + if (isSimpleRecording) { + RunAnywhere.stopStreamingTranscription() + } + recordingJob?.cancel() + scope.cancel() + logger.info("STTPanel disposed") + } +} diff --git a/examples/intellij-plugin-demo/plugin/src/main/kotlin/com/runanywhere/plugin/ui/ModelManagerDialog.kt b/examples/intellij-plugin-demo/plugin/src/main/kotlin/com/runanywhere/plugin/ui/ModelManagerDialog.kt new file mode 100644 index 000000000..9ffb5cd9f --- /dev/null +++ b/examples/intellij-plugin-demo/plugin/src/main/kotlin/com/runanywhere/plugin/ui/ModelManagerDialog.kt @@ -0,0 +1,153 @@ +package com.runanywhere.plugin.ui + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBScrollPane +import com.intellij.ui.table.JBTable +import com.runanywhere.sdk.models.ModelInfo +import com.runanywhere.sdk.public.RunAnywhere +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import java.awt.* +import javax.swing.* +import javax.swing.table.DefaultTableModel + +/** + * Dialog for managing RunAnywhere models (view available models) + */ +class ModelManagerDialog(private val project: Project) : DialogWrapper(project, true) { + + private val logger = com.runanywhere.sdk.foundation.SDKLogger("ModelManagerDialog") + private val tableModel = DefaultTableModel() + private val table = JBTable(tableModel) + private val statusLabel = JBLabel("Ready") + private val refreshButton = JButton("Refresh") + + private val scope = CoroutineScope(Dispatchers.IO) + + init { + title = "RunAnywhere Model Manager" + setOKButtonText("Close") + + setupTable() + loadModels() + + init() + } + + override fun createCenterPanel(): JComponent { + val panel = JPanel(BorderLayout()) + + // Table panel + val tablePanel = JPanel(BorderLayout()) + tablePanel.add(JBScrollPane(table), BorderLayout.CENTER) + tablePanel.preferredSize = Dimension(800, 400) + + // Button panel + val buttonPanel = JPanel(FlowLayout(FlowLayout.LEFT)).apply { + add(refreshButton) + add(Box.createHorizontalStrut(20)) + add(JLabel("Status:")) + add(statusLabel) + } + + // Add all panels + panel.add(tablePanel, BorderLayout.CENTER) + panel.add(buttonPanel, BorderLayout.SOUTH) + + // Setup listeners + setupListeners() + + return panel + } + + private fun setupTable() { + // Setup columns + tableModel.addColumn("Model ID") + tableModel.addColumn("Name") + tableModel.addColumn("Category") + tableModel.addColumn("Size (MB)") + tableModel.addColumn("Status") + + // Configure table + table.selectionModel.selectionMode = ListSelectionModel.SINGLE_SELECTION + table.setShowGrid(true) + table.rowHeight = 25 + } + + private fun setupListeners() { + refreshButton.addActionListener { + loadModels() + } + } + + private fun loadModels() { + scope.launch { + try { + statusLabel.text = "Loading models..." + + logger.info("Fetching available models...") + + val models = try { + RunAnywhere.availableModels() + } catch (e: Exception) { + logger.error("Failed to fetch models", e) + ApplicationManager.getApplication().invokeLater { + statusLabel.text = "Failed to fetch models: ${e.message}" + } + return@launch + } + + logger.info("Fetched ${models.size} models") + + ApplicationManager.getApplication().invokeLater { + // Clear existing rows + tableModel.rowCount = 0 + + if (models.isEmpty()) { + statusLabel.text = "No models available" + logger.warn("No models returned from RunAnywhere.availableModels()") + + // Add a message row to help debug + com.intellij.openapi.ui.Messages.showWarningDialog( + "No models available. Please check SDK initialization.", + "No Models Available" + ) + return@invokeLater + } + + // Add models to table + models.forEach { model -> + logger.info("Adding model to table: ${model.id}") + + val sizeMB = (model.downloadSize ?: 0) / (1024 * 1024) + + tableModel.addRow(arrayOf( + model.id, + model.name, + model.category.name, + sizeMB, + "Available" + )) + } + + statusLabel.text = "Loaded ${models.size} models" + } + } catch (e: Exception) { + logger.error("Error loading models", e) + ApplicationManager.getApplication().invokeLater { + statusLabel.text = "Error: ${e.message}" + } + } + } + } + + override fun dispose() { + scope.cancel() + super.dispose() + } +} diff --git a/examples/intellij-plugin-demo/plugin/src/main/kotlin/com/runanywhere/plugin/ui/WaveformVisualization.kt b/examples/intellij-plugin-demo/plugin/src/main/kotlin/com/runanywhere/plugin/ui/WaveformVisualization.kt new file mode 100644 index 000000000..378370c07 --- /dev/null +++ b/examples/intellij-plugin-demo/plugin/src/main/kotlin/com/runanywhere/plugin/ui/WaveformVisualization.kt @@ -0,0 +1,180 @@ +package com.runanywhere.plugin.ui + +import java.awt.* +import java.awt.geom.Path2D +import javax.swing.JComponent +import javax.swing.Timer + +/** + * Simple waveform visualization component for audio energy levels + */ +class WaveformVisualization : JComponent() { + + private val energyValues = mutableListOf() + private val maxValues = 200 // Number of energy values to display + private var currentEnergy = 0.0f + + // UI colors + private val backgroundColor = Color(45, 45, 45) + private val waveformColor = Color(100, 200, 100) + private val energyColor = Color(255, 100, 100) + private val gridColor = Color(80, 80, 80) + + init { + preferredSize = Dimension(400, 100) + minimumSize = Dimension(200, 60) + + // Repaint timer for smooth animation + val repaintTimer = Timer(16) { // ~60 FPS + repaint() + } + repaintTimer.start() + } + + /** + * Update the waveform with new audio energy level + * @param energy Energy level from 0.0 to 1.0 + */ + fun updateEnergy(energy: Float) { + currentEnergy = energy + + // Add to history + energyValues.add(energy) + + // Keep only the last maxValues + if (energyValues.size > maxValues) { + energyValues.removeAt(0) + } + } + + /** + * Clear the waveform + */ + fun clear() { + energyValues.clear() + currentEnergy = 0.0f + repaint() + } + + override fun paintComponent(g: Graphics) { + super.paintComponent(g) + + val g2d = g as Graphics2D + g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + + val width = width.toFloat() + val height = height.toFloat() + + // Clear background + g2d.color = backgroundColor + g2d.fillRect(0, 0, width.toInt(), height.toInt()) + + // Draw grid lines + drawGrid(g2d, width, height) + + // Draw waveform if we have data + if (energyValues.isNotEmpty()) { + drawWaveform(g2d, width, height) + } + + // Draw current energy indicator + drawEnergyIndicator(g2d, width, height) + + // Draw labels + drawLabels(g2d, width, height) + } + + private fun drawGrid(g2d: Graphics2D, width: Float, height: Float) { + g2d.color = gridColor + g2d.stroke = BasicStroke(1f) + + // Horizontal center line + val centerY = height / 2 + g2d.drawLine(0, centerY.toInt(), width.toInt(), centerY.toInt()) + + // Quarter lines + val quarterY = height / 4 + g2d.drawLine(0, quarterY.toInt(), width.toInt(), quarterY.toInt()) + g2d.drawLine(0, (height - quarterY).toInt(), width.toInt(), (height - quarterY).toInt()) + } + + private fun drawWaveform(g2d: Graphics2D, width: Float, height: Float) { + if (energyValues.size < 2) return + + g2d.color = waveformColor + g2d.stroke = BasicStroke(2f) + + val path = Path2D.Float() + val stepX = width / maxValues + val centerY = height / 2 + + // Start path + val firstEnergy = energyValues[0] + val firstY = centerY - (firstEnergy * centerY * 0.8f) // 80% of half height + path.moveTo(0f, firstY) + + // Draw the waveform line + for (i in 1 until energyValues.size) { + val x = i * stepX + val energy = energyValues[i] + val y = centerY - (energy * centerY * 0.8f) + path.lineTo(x, y) + } + + g2d.draw(path) + + // Fill area under the curve for better visualization + g2d.color = Color(waveformColor.red, waveformColor.green, waveformColor.blue, 50) + val fillPath = Path2D.Float(path) + fillPath.lineTo((energyValues.size - 1) * stepX, centerY) + fillPath.lineTo(0f, centerY) + fillPath.closePath() + g2d.fill(fillPath) + } + + private fun drawEnergyIndicator(g2d: Graphics2D, width: Float, height: Float) { + // Current energy level bar on the right + val barWidth = 20f + val barX = width - barWidth - 10f + val barY = 10f + val barHeight = height - 20f + + // Background of energy bar + g2d.color = Color(60, 60, 60) + g2d.fillRect(barX.toInt(), barY.toInt(), barWidth.toInt(), barHeight.toInt()) + + // Energy level fill + val energyHeight = barHeight * currentEnergy + val energyY = barY + barHeight - energyHeight + + // Color based on energy level + val energyBarColor = when { + currentEnergy > 0.7f -> Color(255, 100, 100) // Red for loud + currentEnergy > 0.3f -> Color(255, 200, 100) // Orange for medium + else -> Color(100, 200, 100) // Green for quiet + } + + g2d.color = energyBarColor + g2d.fillRect(barX.toInt(), energyY.toInt(), barWidth.toInt(), energyHeight.toInt()) + + // Border + g2d.color = Color.WHITE + g2d.stroke = BasicStroke(1f) + g2d.drawRect(barX.toInt(), barY.toInt(), barWidth.toInt(), barHeight.toInt()) + } + + private fun drawLabels(g2d: Graphics2D, width: Float, height: Float) { + g2d.color = Color.LIGHT_GRAY + g2d.font = Font("Arial", Font.PLAIN, 10) + + // Energy level text + val energyText = String.format("%.3f", currentEnergy) + g2d.drawString("Energy: $energyText", 10, 15) + + // Time axis label + g2d.drawString("Time →", 10, height.toInt() - 5) + + // Amplitude axis label + g2d.drawString("Level", width.toInt() - 60, height.toInt() - 5) + } +} diff --git a/examples/intellij-plugin-demo/plugin/src/main/resources/META-INF/plugin.xml b/examples/intellij-plugin-demo/plugin/src/main/resources/META-INF/plugin.xml new file mode 100644 index 000000000..cdc95ce1e --- /dev/null +++ b/examples/intellij-plugin-demo/plugin/src/main/resources/META-INF/plugin.xml @@ -0,0 +1,99 @@ + + com.runanywhere.stt + RunAnywhere Voice Commands + RunAnywhere + 0.1.0 + +
+ Features: +
    +
  • Voice-to-code dictation with Whisper STT
  • +
  • Voice commands for IDE actions
  • +
  • On-device Whisper AI models
  • +
  • Real-time transcription with VAD
  • +
  • Model download and management UI
  • +
  • Privacy-first: all processing on-device
  • +
+
+ Powered by on-device AI models for privacy and performance. + ]]>
+ + Version 0.1.0 +
    +
  • Initial release with RunAnywhere SDK integration
  • +
  • Voice command support with STT
  • +
  • Voice dictation mode
  • +
  • Whisper-based transcription
  • +
  • Model manager for downloading STT models
  • +
  • VAD integration for better speech detection
  • +
+ ]]>
+ + + + + + com.intellij.modules.platform + com.intellij.modules.lang + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/examples/ios/RunAnywhereAI/.gitignore b/examples/ios/RunAnywhereAI/.gitignore new file mode 100644 index 000000000..0449dc20b --- /dev/null +++ b/examples/ios/RunAnywhereAI/.gitignore @@ -0,0 +1,100 @@ +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## User settings +xcuserdata/ + +## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) +*.xcscmblueprint +*.xccheckout + +## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) +build/ +DerivedData/ +*.moved-aside +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 + +## Obj-C/Swift specific +*.hmap +*.o + +## App packaging +*.ipa +*.dSYM.zip +*.dSYM + +## Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Swift Package Manager +# +# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. +# Packages/ +# Package.pins +# Package.resolved +# *.xcodeproj +# +# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata +# hence it is not needed unless you have added a package configuration file to your project +# .swiftpm + +.build/ + +# CocoaPods +# +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# +# Pods/ +# +# Add this line if you want to avoid checking in source code from the Xcode workspace +# *.xcworkspace + +# Carthage +# +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build/ + +# Accio dependency management +Dependencies/ +.accio/ + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. +# Instead, use fastlane to re-generate the screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/#source-control + +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output + +# Code Injection +# +# After new code Injection tools there's a generated folder /iOSInjectionProject +# https://github.com/johnno1962/injectionforxcode + +iOSInjectionProject/ + +Pods/ + +# ML Model Weights +# Exclude large model weight files from git +weight.bin +*/weight.bin +**/*.mlpackage/Data/com.apple.CoreML/weight.bin +*.mlmodelc/coremldata.bin diff --git a/examples/ios/RunAnywhereAI/.swiftlint.yml b/examples/ios/RunAnywhereAI/.swiftlint.yml new file mode 100644 index 000000000..cefc89af2 --- /dev/null +++ b/examples/ios/RunAnywhereAI/.swiftlint.yml @@ -0,0 +1,121 @@ +# SwiftLint configuration for RunAnywhereAI iOS App + +# Rule configuration +opt_in_rules: + - attributes + - closure_end_indentation + - closure_spacing + - collection_alignment + - contains_over_filter_count + - contains_over_filter_is_empty + - contains_over_first_not_nil + - contains_over_range_nil_comparison + - empty_collection_literal + - empty_count + - empty_string + - first_where + - force_unwrapping + - implicit_return + - last_where + - legacy_multiple + - multiline_arguments + - multiline_function_chains + - multiline_parameters + - operator_usage_whitespace + - overridden_super_call + - pattern_matching_keywords + - prefer_self_type_over_type_of_self + - redundant_nil_coalescing + - redundant_type_annotation + - strict_fileprivate + - toggle_bool + - trailing_closure + - unneeded_parentheses_in_closure_argument + - vertical_whitespace_closing_braces + - vertical_whitespace_opening_braces + - yoda_condition + +# Directories to include +included: + - RunAnywhereAI + - RunAnywhereAITests + - RunAnywhereAIUITests + +# Directories to exclude +excluded: + - ${PWD}/Carthage + - ${PWD}/Pods + - ${PWD}/DerivedData + - ${PWD}/build + +# Configure individual rules +line_length: + warning: 120 + error: 150 + ignores_urls: true + ignores_function_declarations: true + ignores_comments: true + +file_length: + warning: 800 + error: 1500 + +function_body_length: + warning: 50 + error: 100 + +function_parameter_count: + warning: 5 + error: 8 + +type_body_length: + warning: 400 + error: 600 + +cyclomatic_complexity: + warning: 10 + error: 20 + +identifier_name: + min_length: + warning: 2 + error: 1 + max_length: + warning: 40 + error: 50 + excluded: + - id + - i + - j + - k + - x + - y + - z + +type_name: + min_length: 3 + max_length: + warning: 40 + error: 50 + +# Custom configurations +force_cast: error +force_try: error +trailing_whitespace: + ignores_empty_lines: true +vertical_whitespace: + max_empty_lines: 2 + +# Custom rules for enforcing TODO patterns +custom_rules: + todo_with_issue: + name: "TODO Must Reference GitHub Issue" + regex: '//\s*(TODO|FIXME|HACK|XXX|BUG|REFACTOR|OPTIMIZE)(?!.*#\d+)' + message: "TODOs must reference a GitHub issue (e.g., // TODO: #123 - Description)" + severity: error + + multiline_todo_with_issue: + name: "Multiline TODO Must Reference GitHub Issue" + regex: '/\*\s*(TODO|FIXME|HACK|XXX|BUG|REFACTOR|OPTIMIZE)(?!.*#\d+)' + message: "TODOs must reference a GitHub issue (e.g., /* TODO: #123 - Description */)" + severity: error diff --git a/examples/ios/RunAnywhereAI/Package.resolved b/examples/ios/RunAnywhereAI/Package.resolved new file mode 100644 index 000000000..2bfe6e6b4 --- /dev/null +++ b/examples/ios/RunAnywhereAI/Package.resolved @@ -0,0 +1,86 @@ +{ + "pins" : [ + { + "identity" : "alamofire", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Alamofire/Alamofire.git", + "state" : { + "revision" : "7be73f6c2b5cd90e40798b06ebd5da8f9f79cf88", + "version" : "5.11.0" + } + }, + { + "identity" : "bitbytedata", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tsolomko/BitByteData", + "state" : { + "revision" : "cdcdc5177ad536cfb11b95c620f926a81014b7fe", + "version" : "2.0.4" + } + }, + { + "identity" : "devicekit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/devicekit/DeviceKit.git", + "state" : { + "revision" : "581df61650bc457ec00373a592a84be3e7468eb1", + "version" : "5.7.0" + } + }, + { + "identity" : "files", + "kind" : "remoteSourceControl", + "location" : "https://github.com/JohnSundell/Files.git", + "state" : { + "revision" : "e85f2b4a8dfa0f242889f45236f3867d16e40480", + "version" : "4.3.0" + } + }, + { + "identity" : "sentry-cocoa", + "kind" : "remoteSourceControl", + "location" : "https://github.com/getsentry/sentry-cocoa", + "state" : { + "revision" : "16cd512711375fa73f25ae5e373f596bdf4251ae", + "version" : "8.58.0" + } + }, + { + "identity" : "swcompression", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tsolomko/SWCompression.git", + "state" : { + "revision" : "390e0b0af8dd19a600005a242a89e570ff482e09", + "version" : "4.8.6" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "810496cf121e525d660cd0ea89a758740476b85f", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc", + "version" : "3.15.1" + } + }, + { + "identity" : "zipfoundation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/weichsel/ZIPFoundation.git", + "state" : { + "revision" : "22787ffb59de99e5dc1fbfe80b19c97a904ad48d", + "version" : "0.9.20" + } + } + ], + "version" : 2 +} diff --git a/examples/ios/RunAnywhereAI/Package.swift b/examples/ios/RunAnywhereAI/Package.swift new file mode 100644 index 000000000..f02fab0e5 --- /dev/null +++ b/examples/ios/RunAnywhereAI/Package.swift @@ -0,0 +1,66 @@ +// swift-tools-version: 5.9 +// ============================================================================= +// RunAnywhereAI - iOS Example App +// ============================================================================= +// +// This example app demonstrates how to use the RunAnywhere SDK. +// +// SETUP (first time): +// cd ../../sdk/runanywhere-swift +// ./scripts/build-swift.sh --setup +// +// Then open this project in Xcode and build. +// +// ============================================================================= + +import PackageDescription + +let package = Package( + name: "RunAnywhereAI", + defaultLocalization: "en", + platforms: [ + .iOS(.v17), + .macOS(.v14) + ], + products: [ + .library( + name: "RunAnywhereAI", + targets: ["RunAnywhereAI"] + ) + ], + dependencies: [ + // =================================== + // RunAnywhere SDK (local path to repo root) + // =================================== + // Points to the root Package.swift which contains: + // - RunAnywhere (core) + // - RunAnywhereONNX (STT/TTS/VAD) + // - RunAnywhereLlamaCPP (LLM) + .package(path: "../../.."), + ], + targets: [ + .target( + name: "RunAnywhereAI", + dependencies: [ + // Core SDK (always needed) + .product(name: "RunAnywhere", package: "runanywhere-sdks"), + + // Optional modules - pick what you need: + .product(name: "RunAnywhereONNX", package: "runanywhere-sdks"), // STT/TTS/VAD + .product(name: "RunAnywhereLlamaCPP", package: "runanywhere-sdks"), // LLM + ], + path: "RunAnywhereAI", + exclude: [ + "Info.plist", + "Assets.xcassets", + "Preview Content", + "RunAnywhereAI.entitlements" + ] + ), + .testTarget( + name: "RunAnywhereAITests", + dependencies: ["RunAnywhereAI"], + path: "RunAnywhereAIUITests" + ) + ] +) diff --git a/examples/ios/RunAnywhereAI/README.md b/examples/ios/RunAnywhereAI/README.md new file mode 100644 index 000000000..722748e02 --- /dev/null +++ b/examples/ios/RunAnywhereAI/README.md @@ -0,0 +1,644 @@ +# RunAnywhere AI - iOS Example + +

+ RunAnywhere Logo +

+ +

+ + Download on the App Store + +

+ +

+ iOS 17.0+ + macOS 14.0+ + Swift 5.9+ + SwiftUI + License +

+ +**A production-ready reference app demonstrating the [RunAnywhere Swift SDK](../../../sdk/runanywhere-swift/) capabilities for on-device AI.** This app showcases how to build privacy-first, offline-capable AI features with LLM chat, speech-to-text, text-to-speech, and a complete voice assistant pipeline—all running locally on your device. + +--- + +## 🚀 Running This App (Local Development) + +> **Important:** This sample app consumes the [RunAnywhere Swift SDK](../../../sdk/runanywhere-swift/) as a local Swift package. Before opening this project, you must first build the SDK's native libraries. + +### First-Time Setup + +```bash +# 1. Navigate to the Swift SDK directory +cd runanywhere-sdks/sdk/runanywhere-swift + +# 2. Run the setup script (~5-15 minutes on first run) +# This builds the native C++ frameworks and sets testLocal=true +./scripts/build-swift.sh --setup + +# 3. Navigate to this sample app +cd ../../examples/ios/RunAnywhereAI + +# 4. Open in Xcode +open RunAnywhereAI.xcodeproj + +# 5. If Xcode shows package errors, reset caches: +# File > Packages > Reset Package Caches + +# 6. Build and Run (⌘+R) +``` + +### How It Works + +This sample app uses `Package.swift` to reference the local Swift SDK: + +``` +This Sample App → Local Swift SDK (sdk/runanywhere-swift/) + ↓ + Local XCFrameworks (sdk/runanywhere-swift/Binaries/) + ↑ + Built by: ./scripts/build-swift.sh --setup +``` + +The `build-swift.sh --setup` script: +1. Builds the native C++ frameworks from `runanywhere-commons` +2. Copies them to `sdk/runanywhere-swift/Binaries/` +3. Sets `testLocal = true` in the SDK's `Package.swift` + +### After Modifying the SDK + +- **Swift SDK code changes**: Xcode picks them up automatically +- **C++ code changes** (in `runanywhere-commons`): + ```bash + cd sdk/runanywhere-swift + ./scripts/build-swift.sh --local --build-commons + ``` + +--- + +## Try It Now + +

+ + Download on the App Store + +

+ +Download the app from the App Store to try it out. + +--- + +## Screenshots + +

+ Chat Interface + Structured Output + Voice AI +

+ +--- + +## Features + +This sample app demonstrates the full power of the RunAnywhere SDK: + +| Feature | Description | SDK Integration | +|---------|-------------|-----------------| +| **AI Chat** | Interactive LLM conversations with streaming responses | `RunAnywhere.generateStream()` | +| **Thinking Mode** | Support for models with `...` reasoning | Thinking tag parsing | +| **Real-time Analytics** | Token speed, generation time, inference metrics | `MessageAnalytics` | +| **Speech-to-Text** | Voice transcription with batch & live modes | `RunAnywhere.transcribe()` | +| **Text-to-Speech** | Neural voice synthesis with Piper TTS | `RunAnywhere.synthesize()` | +| **Voice Assistant** | Full STT → LLM → TTS pipeline with auto-detection | Voice Pipeline API | +| **Model Management** | Download, load, and manage multiple AI models | `RunAnywhere.downloadModel()` | +| **Storage Management** | View storage usage and delete models | `RunAnywhere.storageInfo()` | +| **Offline Support** | All features work without internet | On-device inference | +| **Cross-Platform** | Runs on iOS, iPadOS, and macOS | Universal app | + +--- + +## Architecture + +The app follows modern Apple architecture patterns: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ SwiftUI Views │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────┐ │ +│ │ Chat │ │ STT │ │ TTS │ │ Voice │ │Settings│ │ +│ │ View │ │ View │ │ View │ │ View │ │ View │ │ +│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ └───┬────┘ │ +├───────┼────────────┼────────────┼────────────┼───────────┼──────┤ +│ ▼ ▼ ▼ ▼ ▼ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────┐ │ +│ │ LLM │ │ STT │ │ TTS │ │ Voice │ │Settings│ │ +│ │ViewModel │ │ViewModel │ │ViewModel │ │ ViewModel│ │ViewModel +│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ └───┬────┘ │ +├───────┴────────────┴────────────┴────────────┴───────────┴──────┤ +│ │ +│ RunAnywhere Swift SDK │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ Core APIs (generate, transcribe, synthesize, pipeline) │ │ +│ │ EventBus (LLMEvent, STTEvent, TTSEvent, ModelEvent) │ │ +│ │ Model Management (download, load, unload, delete) │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ┌──────────────────┴──────────────────┐ │ +│ ▼ ▼ │ +│ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ LlamaCPP │ │ ONNX Runtime │ │ +│ │ (LLM/GGUF) │ │ (STT/TTS) │ │ +│ └─────────────────┘ └─────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Key Architecture Decisions + +- **MVVM Pattern** — ViewModels manage UI state with `@Observable`, SwiftUI observes changes +- **Single Entry Point** — `RunAnywhereAIApp.swift` handles SDK initialization +- **Swift Concurrency** — All async operations use async/await with structured concurrency +- **Cross-Platform** — Conditional compilation supports iOS, iPadOS, and macOS +- **Design System** — Centralized colors, typography, and spacing via `AppColors`, `AppTypography`, `AppSpacing` + +--- + +## Project Structure + +``` +RunAnywhereAI/ +├── RunAnywhereAI/ +│ ├── App/ +│ │ ├── RunAnywhereAIApp.swift # Entry point, SDK initialization +│ │ └── ContentView.swift # Tab navigation, main UI structure +│ │ +│ ├── Core/ +│ │ ├── DesignSystem/ +│ │ │ ├── AppColors.swift # Color palette +│ │ │ ├── AppSpacing.swift # Spacing constants +│ │ │ └── Typography.swift # Font styles +│ │ ├── Models/ +│ │ │ ├── AppTypes.swift # Shared data models +│ │ │ └── MarkdownDetector.swift # Markdown parsing utilities +│ │ └── Services/ +│ │ └── ModelManager.swift # Model lifecycle management +│ │ +│ ├── Features/ +│ │ ├── Chat/ +│ │ │ ├── Models/ +│ │ │ │ └── Message.swift # Chat message model +│ │ │ ├── ViewModels/ +│ │ │ │ ├── LLMViewModel.swift # Chat logic, streaming +│ │ │ │ ├── LLMViewModel+Generation.swift +│ │ │ │ └── LLMViewModel+Analytics.swift +│ │ │ └── Views/ +│ │ │ ├── ChatInterfaceView.swift # Main chat UI +│ │ │ ├── MessageBubbleView.swift # Message rendering +│ │ │ └── ConversationListView.swift +│ │ │ +│ │ ├── Voice/ +│ │ │ ├── SpeechToTextView.swift # STT UI with waveform +│ │ │ ├── STTViewModel.swift # Batch & live transcription +│ │ │ ├── TextToSpeechView.swift # TTS UI with playback +│ │ │ ├── TTSViewModel.swift # Synthesis & audio playback +│ │ │ ├── VoiceAssistantView.swift # Full voice pipeline UI +│ │ │ └── VoiceAgentViewModel.swift # STT→LLM→TTS orchestration +│ │ │ +│ │ ├── Models/ +│ │ │ ├── ModelSelectionSheet.swift # Model picker UI +│ │ │ └── ModelListViewModel.swift # Download & load logic +│ │ │ +│ │ ├── Storage/ +│ │ │ ├── StorageView.swift # Storage management UI +│ │ │ └── StorageViewModel.swift # Storage info, cache clearing +│ │ │ +│ │ └── Settings/ +│ │ └── CombinedSettingsView.swift # Settings & storage UI +│ │ +│ ├── Helpers/ +│ │ ├── AdaptiveLayout.swift # Cross-platform layout helpers +│ │ ├── CodeBlockMarkdownRenderer.swift +│ │ ├── InlineMarkdownRenderer.swift +│ │ └── SmartMarkdownRenderer.swift +│ │ +│ └── Resources/ +│ ├── Assets.xcassets/ # App icons, images +│ ├── RunAnywhereConfig-Debug.plist +│ └── RunAnywhereConfig-Release.plist +│ +├── RunAnywhereAITests/ # Unit tests +├── RunAnywhereAIUITests/ # UI tests +├── docs/screenshots/ # App screenshots +├── scripts/ +│ └── build_and_run_ios_sample.sh # Build automation +├── Package.swift # SPM dependency manifest +└── README.md # This file +``` + +--- + +## Quick Start + +### Prerequisites + +- **Xcode** 15.0 or later +- **iOS** 17.0+ / **macOS** 14.0+ +- **Swift** 5.9+ +- **Device/Simulator** with Apple Silicon (recommended: physical device for best performance) +- **~500MB-2GB** free storage for AI models + +### Clone & Build + +```bash +# Clone the repository +git clone https://github.com/RunanywhereAI/runanywhere-sdks.git +cd runanywhere-sdks/examples/ios/RunAnywhereAI + +# Open in Xcode +open RunAnywhereAI.xcodeproj +``` + +### Run via Xcode + +1. Open the project in Xcode +2. Wait for Swift Package Manager to resolve dependencies +3. Select a physical device (Apple Silicon recommended) or simulator +4. Click **Run** or press `⌘+R` + +### Run via Command Line + +```bash +# Build and run on simulator +./scripts/build_and_run_ios_sample.sh simulator "iPhone 16 Pro" + +# Build and run on device +./scripts/build_and_run_ios_sample.sh device +``` + +--- + +## SDK Integration Examples + +### Initialize the SDK + +The SDK is initialized in `RunAnywhereAIApp.swift`: + +```swift +import RunAnywhere +import LlamaCPPRuntime +import ONNXRuntime + +@main +struct RunAnywhereAIApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + .task { + await initializeSDK() + } + } + + private func initializeSDK() async { + // Initialize SDK (development mode - no API key needed) + try RunAnywhere.initialize() + + // Register AI backends + LlamaCPP.register(priority: 100) // LLM backend (GGUF models) + ONNX.register(priority: 100) // STT/TTS backend + + // Register models + RunAnywhere.registerModel( + id: "smollm2-360m-q8_0", + name: "SmolLM2 360M Q8_0", + url: URL(string: "https://huggingface.co/...")!, + framework: .llamaCpp, + memoryRequirement: 500_000_000 + ) + } +} +``` + +### Download & Load a Model + +```swift +// Download with progress tracking +for try await progress in RunAnywhere.downloadModel("smollm2-360m-q8_0") { + print("Download: \(Int(progress.percentage * 100))%") +} + +// Load into memory +try await RunAnywhere.loadModel("smollm2-360m-q8_0") +``` + +### Stream Text Generation + +```swift +// Generate with streaming +let result = try await RunAnywhere.generateStream( + prompt, + options: LLMGenerationOptions(maxTokens: 512, temperature: 0.7) +) + +for try await token in result.stream { + // Display token in real-time + displayToken(token) +} + +// Get final analytics +let metrics = try await result.result.value +print("Speed: \(metrics.performanceMetrics.tokensPerSecond) tok/s") +``` + +### Speech-to-Text + +```swift +// Load STT model +try await RunAnywhere.loadSTTModel("sherpa-onnx-whisper-tiny.en") + +// Transcribe audio bytes +let transcription = try await RunAnywhere.transcribe(audioData) +print("Transcription: \(transcription.text)") +``` + +### Text-to-Speech + +```swift +// Load TTS voice +try await RunAnywhere.loadTTSModel("vits-piper-en_US-lessac-medium") + +// Synthesize speech +let result = try await RunAnywhere.synthesize( + text, + options: TTSOptions(rate: 1.0, pitch: 1.0) +) +// result.audioData contains WAV audio bytes +``` + +### Voice Pipeline (STT → LLM → TTS) + +```swift +// Configure voice pipeline +let config = ModularPipelineConfig( + components: [.vad, .stt, .llm, .tts], + stt: VoiceSTTConfig(modelId: "sherpa-onnx-whisper-tiny.en"), + llm: VoiceLLMConfig(modelId: "smollm2-360m-q8_0", maxTokens: 256), + tts: VoiceTTSConfig(modelId: "vits-piper-en_US-lessac-medium") +) + +// Process voice through full pipeline +let pipeline = try await RunAnywhere.createVoicePipeline(config: config) +for try await event in pipeline.process(audioStream: audioStream) { + switch event { + case .transcription(let text): + print("User said: \(text)") + case .llmResponse(let response): + print("AI response: \(response)") + case .synthesis(let audio): + playAudio(audio) + } +} +``` + +--- + +## Key Screens Explained + +### 1. Chat Screen (`ChatInterfaceView.swift`) + +**What it demonstrates:** +- Streaming text generation with real-time token display +- Thinking mode support (`...` tags) +- Message analytics (tokens/sec, time to first token) +- Conversation history management +- Model selection bottom sheet integration +- Markdown rendering with code highlighting + +**Key SDK APIs:** +- `RunAnywhere.generateStream()` — Streaming generation +- `RunAnywhere.generate()` — Non-streaming generation +- `RunAnywhere.cancelGeneration()` — Stop generation + +### 2. Speech-to-Text Screen (`SpeechToTextView.swift`) + +**What it demonstrates:** +- Batch mode: Record full audio, then transcribe +- Live mode: Real-time streaming transcription +- Audio level visualization +- Transcription metrics + +**Key SDK APIs:** +- `RunAnywhere.loadSTTModel()` — Load Whisper model +- `RunAnywhere.transcribe()` — Batch transcription + +### 3. Text-to-Speech Screen (`TextToSpeechView.swift`) + +**What it demonstrates:** +- Neural voice synthesis with Piper TTS +- Speed and pitch controls +- Audio playback with progress +- Fun sample texts for testing + +**Key SDK APIs:** +- `RunAnywhere.loadTTSModel()` — Load TTS model +- `RunAnywhere.synthesize()` — Generate speech audio + +### 4. Voice Assistant Screen (`VoiceAssistantView.swift`) + +**What it demonstrates:** +- Complete voice AI pipeline +- Automatic speech detection +- Model status tracking for all 3 components (STT, LLM, TTS) +- Push-to-talk and hands-free modes + +**Key SDK APIs:** +- Voice Pipeline API for STT → LLM → TTS orchestration +- Component state management + +### 5. Settings Screen (`CombinedSettingsView.swift`) + +**What it demonstrates:** +- Generation settings (temperature, max tokens) +- Storage usage overview +- Downloaded model management +- Model deletion with confirmation +- Cache clearing + +**Key SDK APIs:** +- `RunAnywhere.storageInfo()` — Get storage details +- `RunAnywhere.deleteModel()` — Remove downloaded model + +--- + +## Testing + +### Run Unit Tests + +```bash +xcodebuild test -project RunAnywhereAI.xcodeproj -scheme RunAnywhereAI -destination 'platform=iOS Simulator,name=iPhone 16 Pro' +``` + +### Run UI Tests + +```bash +xcodebuild test -project RunAnywhereAI.xcodeproj -scheme RunAnywhereAIUITests -destination 'platform=iOS Simulator,name=iPhone 16 Pro' +``` + +--- + +## Debugging + +### Enable Verbose Logging + +The app uses `os.log` for structured logging. Filter by subsystem in Console.app: + +``` +subsystem:com.runanywhere.RunAnywhereAI +``` + +### Common Log Categories + +| Category | Description | +|----------|-------------| +| `RunAnywhereAIApp` | SDK initialization, model registration | +| `LLMViewModel` | LLM generation, streaming | +| `STTViewModel` | Speech transcription | +| `TTSViewModel` | Speech synthesis | +| `VoiceAgentViewModel` | Voice pipeline | +| `ModelListViewModel` | Model downloads, loading | + +### Memory Profiling + +1. Open Xcode Instruments +2. Select your app process +3. Record memory allocations during model loading +4. Expected: ~300MB-4GB depending on model size + +--- + +## Configuration + +### Build Configurations + +| Configuration | Description | +|---------------|-------------| +| `Debug` | Development build with verbose logging | +| `Release` | Optimized build for distribution | + +### Environment Variables + +```swift +#if DEBUG +// Development mode - uses local backend, no API key needed +try RunAnywhere.initialize() +#else +// Production mode - requires API key and backend URL +try RunAnywhere.initialize( + apiKey: "your_api_key", + baseURL: "https://api.runanywhere.ai", + environment: .production +) +#endif +``` + +--- + +## Supported Models + +### LLM Models (LlamaCpp/GGUF) + +| Model | Size | Memory | Description | +|-------|------|--------|-------------| +| SmolLM2 360M Q8_0 | ~400MB | 500MB | Fast, lightweight chat | +| Qwen 2.5 0.5B Q6_K | ~500MB | 600MB | Multilingual, efficient | +| LFM2 350M Q4_K_M | ~200MB | 250MB | LiquidAI, ultra-compact | +| LFM2 350M Q8_0 | ~400MB | 400MB | LiquidAI, higher quality | +| Llama 2 7B Chat Q4_K_M | ~4GB | 4GB | Powerful, larger model | +| Mistral 7B Instruct Q4_K_M | ~4GB | 4GB | High quality responses | + +### STT Models (ONNX/Whisper) + +| Model | Size | Description | +|-------|------|-------------| +| Sherpa Whisper Tiny (EN) | ~75MB | English transcription | + +### TTS Models (ONNX/Piper) + +| Model | Size | Description | +|-------|------|-------------| +| Piper US English (Medium) | ~65MB | Natural American voice | +| Piper British English (Medium) | ~65MB | British accent | + +--- + +## Known Limitations + +- **Apple Silicon Recommended** — Best performance on M1/M2/M3 chips and A-series processors +- **Memory Usage** — Large models (7B+) require devices with 6GB+ RAM +- **First Load** — Initial model loading takes 1-3 seconds (cached afterward) +- **Thermal Throttling** — Extended inference may trigger device throttling on some devices + +--- + +## Xcode 16 Notes + +If you encounter sandbox errors during build: + +```bash +./scripts/fix_pods_sandbox.sh +``` + +For Swift macro issues: + +```bash +defaults write com.apple.dt.Xcode IDESkipMacroFingerprintValidation -bool YES +``` + +--- + +## Contributing + +See [CONTRIBUTING.md](../../../CONTRIBUTING.md) for guidelines. + +### Development Setup + +```bash +# Fork and clone +git clone https://github.com/YOUR_USERNAME/runanywhere-sdks.git +cd runanywhere-sdks/examples/ios/RunAnywhereAI + +# Open in Xcode +open RunAnywhereAI.xcodeproj + +# Make changes and test +# Run tests in Xcode (⌘+U) + +# Commit and push +git commit -m "feat: your feature description" +git push origin feature/your-feature + +# Open Pull Request +``` + +--- + +## License + +This project is licensed under the Apache License 2.0 - see [LICENSE](../../../LICENSE) for details. + +--- + +## Support + +- **Discord**: [Join our community](https://discord.gg/N359FBbDVd) +- **GitHub Issues**: [Report bugs](https://github.com/RunanywhereAI/runanywhere-sdks/issues) +- **Email**: san@runanywhere.ai +- **Twitter**: [@RunanywhereAI](https://twitter.com/RunanywhereAI) + +--- + +## Related Documentation + +- [RunAnywhere Swift SDK](../../../sdk/runanywhere-swift/README.md) — Full SDK documentation +- [Android Example App](../../android/RunAnywhereAI/README.md) — Android counterpart +- [React Native Example](../../react-native/RunAnywhereAI/README.md) — Cross-platform option +- [Main README](../../../README.md) — Project overview diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI.xcodeproj/project.pbxproj b/examples/ios/RunAnywhereAI/RunAnywhereAI.xcodeproj/project.pbxproj new file mode 100644 index 000000000..0011dc4fb --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI.xcodeproj/project.pbxproj @@ -0,0 +1,704 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 541C59DA2E63772A00DD7839 /* RunAnywhere in Frameworks */ = {isa = PBXBuildFile; productRef = 541C59D92E63772A00DD7839 /* RunAnywhere */; }; + 58ABEDD22ED16DA40058D033 /* RunAnywhereONNX in Frameworks */ = {isa = PBXBuildFile; productRef = 58ABEDD12ED16DA40058D033 /* RunAnywhereONNX */; }; + 58LLAMACPP12ED16DA40058D0 /* RunAnywhereLlamaCPP in Frameworks */ = {isa = PBXBuildFile; productRef = 58LLAMACPP02ED16DA40058D0 /* RunAnywhereLlamaCPP */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 5480A1FF2E2F250400337F2F /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 5480A1E82E2F250200337F2F /* Project object */; + proxyType = 1; + remoteGlobalIDString = 5480A1EF2E2F250200337F2F; + remoteInfo = RunAnywhereAI; + }; + 5480A2092E2F250400337F2F /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 5480A1E82E2F250200337F2F /* Project object */; + proxyType = 1; + remoteGlobalIDString = 5480A1EF2E2F250200337F2F; + remoteInfo = RunAnywhereAI; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 0C5E8414EC72B380DADD6717 /* Accelerate.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Accelerate.framework; path = System/Library/Frameworks/Accelerate.framework; sourceTree = SDKROOT; }; + 0D23254BCD6273187BD2441C /* libbz2.tbd */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libbz2.tbd; path = usr/lib/libbz2.tbd; sourceTree = SDKROOT; }; + 502F1BAB2C2556A11E24EA6F /* libc++.tbd */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = "libc++.tbd"; path = "usr/lib/libc++.tbd"; sourceTree = SDKROOT; }; + 5480A1F02E2F250200337F2F /* RunAnywhereAI.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = RunAnywhereAI.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 5480A1FE2E2F250400337F2F /* RunAnywhereAITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunAnywhereAITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 5480A2082E2F250400337F2F /* RunAnywhereAIUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunAnywhereAIUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + F8388285B0D64126DD4540F8 /* libarchive.tbd */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libarchive.tbd; path = usr/lib/libarchive.tbd; sourceTree = SDKROOT; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 5480A1F22E2F250200337F2F /* RunAnywhereAI */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = RunAnywhereAI; + sourceTree = ""; + }; + 5480A2012E2F250400337F2F /* RunAnywhereAITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = RunAnywhereAITests; + sourceTree = ""; + }; + 5480A20B2E2F250400337F2F /* RunAnywhereAIUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = RunAnywhereAIUITests; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 5480A1ED2E2F250200337F2F /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 58ABEDD22ED16DA40058D033 /* RunAnywhereONNX in Frameworks */, + 541C59DA2E63772A00DD7839 /* RunAnywhere in Frameworks */, + 58LLAMACPP12ED16DA40058D0 /* RunAnywhereLlamaCPP in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 5480A1FB2E2F250400337F2F /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 5480A2052E2F250400337F2F /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 547A6C002E6374D1005EF0C7 /* Frameworks */ = { + isa = PBXGroup; + children = ( + F8388285B0D64126DD4540F8 /* libarchive.tbd */, + 0D23254BCD6273187BD2441C /* libbz2.tbd */, + 502F1BAB2C2556A11E24EA6F /* libc++.tbd */, + 0C5E8414EC72B380DADD6717 /* Accelerate.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 5480A1E72E2F250200337F2F = { + isa = PBXGroup; + children = ( + 5480A1F22E2F250200337F2F /* RunAnywhereAI */, + 5480A2012E2F250400337F2F /* RunAnywhereAITests */, + 5480A20B2E2F250400337F2F /* RunAnywhereAIUITests */, + 547A6C002E6374D1005EF0C7 /* Frameworks */, + 5480A1F12E2F250200337F2F /* Products */, + ); + sourceTree = ""; + }; + 5480A1F12E2F250200337F2F /* Products */ = { + isa = PBXGroup; + children = ( + 5480A1F02E2F250200337F2F /* RunAnywhereAI.app */, + 5480A1FE2E2F250400337F2F /* RunAnywhereAITests.xctest */, + 5480A2082E2F250400337F2F /* RunAnywhereAIUITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 5480A1EF2E2F250200337F2F /* RunAnywhereAI */ = { + isa = PBXNativeTarget; + buildConfigurationList = 5480A2122E2F250400337F2F /* Build configuration list for PBXNativeTarget "RunAnywhereAI" */; + buildPhases = ( + 5480A1EC2E2F250200337F2F /* Sources */, + 5480A1ED2E2F250200337F2F /* Frameworks */, + 5480A1EE2E2F250200337F2F /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 5480A1F22E2F250200337F2F /* RunAnywhereAI */, + ); + name = RunAnywhereAI; + packageProductDependencies = ( + 541C59D92E63772A00DD7839 /* RunAnywhere */, + 58ABEDD12ED16DA40058D033 /* RunAnywhereONNX */, + 58LLAMACPP02ED16DA40058D0 /* RunAnywhereLlamaCPP */, + ); + productName = RunAnywhereAI; + productReference = 5480A1F02E2F250200337F2F /* RunAnywhereAI.app */; + productType = "com.apple.product-type.application"; + }; + 5480A1FD2E2F250400337F2F /* RunAnywhereAITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 5480A2152E2F250400337F2F /* Build configuration list for PBXNativeTarget "RunAnywhereAITests" */; + buildPhases = ( + 9417651A8C4CCCB5473F1949 /* [CP] Check Pods Manifest.lock */, + 5480A1FA2E2F250400337F2F /* Sources */, + 5480A1FB2E2F250400337F2F /* Frameworks */, + 5480A1FC2E2F250400337F2F /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 5480A2002E2F250400337F2F /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 5480A2012E2F250400337F2F /* RunAnywhereAITests */, + ); + name = RunAnywhereAITests; + productName = RunAnywhereAITests; + productReference = 5480A1FE2E2F250400337F2F /* RunAnywhereAITests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 5480A2072E2F250400337F2F /* RunAnywhereAIUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 5480A2182E2F250400337F2F /* Build configuration list for PBXNativeTarget "RunAnywhereAIUITests" */; + buildPhases = ( + 94361D75FF8B6C07227D4520 /* [CP] Check Pods Manifest.lock */, + 5480A2042E2F250400337F2F /* Sources */, + 5480A2052E2F250400337F2F /* Frameworks */, + 5480A2062E2F250400337F2F /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 5480A20A2E2F250400337F2F /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 5480A20B2E2F250400337F2F /* RunAnywhereAIUITests */, + ); + name = RunAnywhereAIUITests; + productName = RunAnywhereAIUITests; + productReference = 5480A2082E2F250400337F2F /* RunAnywhereAIUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 5480A1E82E2F250200337F2F /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1640; + LastUpgradeCheck = 1640; + TargetAttributes = { + 5480A1EF2E2F250200337F2F = { + CreatedOnToolsVersion = 16.4; + }; + 5480A1FD2E2F250400337F2F = { + CreatedOnToolsVersion = 16.4; + TestTargetID = 5480A1EF2E2F250200337F2F; + }; + 5480A2072E2F250400337F2F = { + CreatedOnToolsVersion = 16.4; + TestTargetID = 5480A1EF2E2F250200337F2F; + }; + }; + }; + buildConfigurationList = 5480A1EB2E2F250200337F2F /* Build configuration list for PBXProject "RunAnywhereAI" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 5480A1E72E2F250200337F2F; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + 58E021172E52A86000B722EF /* XCLocalSwiftPackageReference "../../.." */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = 5480A1F12E2F250200337F2F /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 5480A1EF2E2F250200337F2F /* RunAnywhereAI */, + 5480A1FD2E2F250400337F2F /* RunAnywhereAITests */, + 5480A2072E2F250400337F2F /* RunAnywhereAIUITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 5480A1EE2E2F250200337F2F /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 5480A1FC2E2F250400337F2F /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 5480A2062E2F250400337F2F /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 9417651A8C4CCCB5473F1949 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunAnywhereAITests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 94361D75FF8B6C07227D4520 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunAnywhereAIUITests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 5480A1EC2E2F250200337F2F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 5480A1FA2E2F250400337F2F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 5480A2042E2F250400337F2F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 5480A2002E2F250400337F2F /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 5480A1EF2E2F250200337F2F /* RunAnywhereAI */; + targetProxy = 5480A1FF2E2F250400337F2F /* PBXContainerItemProxy */; + }; + 5480A20A2E2F250400337F2F /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 5480A1EF2E2F250200337F2F /* RunAnywhereAI */; + targetProxy = 5480A2092E2F250400337F2F /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 5480A2102E2F250400337F2F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + OTHER_SWIFT_FLAGS = ""; + "OTHER_SWIFT_FLAGS[arch=*]" = "-enable-experimental-feature Macros"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 5480A2112E2F250400337F2F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = ""; + SWIFT_COMPILATION_MODE = wholemodule; + }; + name = Release; + }; + 5480A2132E2F250400337F2F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = RunAnywhereAI/RunAnywhereAI.entitlements; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = L86FH3K93L; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = RunAnywhere; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "RunAnywhere AI needs access to your microphone to transcribe your voice input and provide voice-based interactions."; + INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "RunAnywhere AI uses speech recognition to convert your voice to text for processing."; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; + "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; + "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 15.5; + MARKETING_VERSION = 0.17.2; + PRODUCT_BUNDLE_IDENTIFIER = com.runanywhere.RunAnywhere; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + XROS_DEPLOYMENT_TARGET = 2.5; + }; + name = Debug; + }; + 5480A2142E2F250400337F2F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = RunAnywhereAI/RunAnywhereAI.entitlements; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = XMP5QMWA2U; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = RunAnywhere; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "RunAnywhere AI needs access to your microphone to transcribe your voice input and provide voice-based interactions."; + INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "RunAnywhere AI uses speech recognition to convert your voice to text for processing."; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; + "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; + "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 15.5; + MARKETING_VERSION = 0.17.2; + PRODUCT_BUNDLE_IDENTIFIER = com.runanywhere.RunAnywhere; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + XROS_DEPLOYMENT_TARGET = 2.5; + }; + name = Release; + }; + 5480A2162E2F250400337F2F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MACOSX_DEPLOYMENT_TARGET = 15.5; + MARKETING_VERSION = 0.17.2; + PRODUCT_BUNDLE_IDENTIFIER = com.runanywhere.RunAnywhereTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/RunAnywhereAI.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/RunAnywhereAI"; + XROS_DEPLOYMENT_TARGET = 2.5; + }; + name = Debug; + }; + 5480A2172E2F250400337F2F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MACOSX_DEPLOYMENT_TARGET = 15.5; + MARKETING_VERSION = 0.17.2; + PRODUCT_BUNDLE_IDENTIFIER = com.runanywhere.RunAnywhereTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/RunAnywhereAI.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/RunAnywhereAI"; + XROS_DEPLOYMENT_TARGET = 2.5; + }; + name = Release; + }; + 5480A2192E2F250400337F2F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MACOSX_DEPLOYMENT_TARGET = 15.5; + MARKETING_VERSION = 0.17.2; + PRODUCT_BUNDLE_IDENTIFIER = com.runanywhere.RunAnywhereUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + TEST_TARGET_NAME = RunAnywhereAI; + XROS_DEPLOYMENT_TARGET = 2.5; + }; + name = Debug; + }; + 5480A21A2E2F250400337F2F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MACOSX_DEPLOYMENT_TARGET = 15.5; + MARKETING_VERSION = 0.17.2; + PRODUCT_BUNDLE_IDENTIFIER = com.runanywhere.RunAnywhereUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + TEST_TARGET_NAME = RunAnywhereAI; + XROS_DEPLOYMENT_TARGET = 2.5; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 5480A1EB2E2F250200337F2F /* Build configuration list for PBXProject "RunAnywhereAI" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 5480A2102E2F250400337F2F /* Debug */, + 5480A2112E2F250400337F2F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 5480A2122E2F250400337F2F /* Build configuration list for PBXNativeTarget "RunAnywhereAI" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 5480A2132E2F250400337F2F /* Debug */, + 5480A2142E2F250400337F2F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 5480A2152E2F250400337F2F /* Build configuration list for PBXNativeTarget "RunAnywhereAITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 5480A2162E2F250400337F2F /* Debug */, + 5480A2172E2F250400337F2F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 5480A2182E2F250400337F2F /* Build configuration list for PBXNativeTarget "RunAnywhereAIUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 5480A2192E2F250400337F2F /* Debug */, + 5480A21A2E2F250400337F2F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 58E021172E52A86000B722EF /* XCLocalSwiftPackageReference "../../.." */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../../..; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 541C59D92E63772A00DD7839 /* RunAnywhere */ = { + isa = XCSwiftPackageProductDependency; + package = 58E021172E52A86000B722EF /* XCLocalSwiftPackageReference "../../.." */; + productName = RunAnywhere; + }; + 58ABEDD12ED16DA40058D033 /* RunAnywhereONNX */ = { + isa = XCSwiftPackageProductDependency; + package = 58E021172E52A86000B722EF /* XCLocalSwiftPackageReference "../../.." */; + productName = RunAnywhereONNX; + }; + 58LLAMACPP02ED16DA40058D0 /* RunAnywhereLlamaCPP */ = { + isa = XCSwiftPackageProductDependency; + package = 58E021172E52A86000B722EF /* XCLocalSwiftPackageReference "../../.." */; + productName = RunAnywhereLlamaCPP; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 5480A1E82E2F250200337F2F /* Project object */; +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/examples/ios/RunAnywhereAI/RunAnywhereAI.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..919434a62 --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/examples/ios/RunAnywhereAI/RunAnywhereAI.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 000000000..ba9a2b63b --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,87 @@ +{ + "originHash" : "ca4900869f13fe8a468ed365f1f5f1ffef3e0a66f65ea24f176fd326c48ae5ce", + "pins" : [ + { + "identity" : "alamofire", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Alamofire/Alamofire.git", + "state" : { + "revision" : "7be73f6c2b5cd90e40798b06ebd5da8f9f79cf88", + "version" : "5.11.0" + } + }, + { + "identity" : "bitbytedata", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tsolomko/BitByteData", + "state" : { + "revision" : "cdcdc5177ad536cfb11b95c620f926a81014b7fe", + "version" : "2.0.4" + } + }, + { + "identity" : "devicekit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/devicekit/DeviceKit.git", + "state" : { + "revision" : "581df61650bc457ec00373a592a84be3e7468eb1", + "version" : "5.7.0" + } + }, + { + "identity" : "files", + "kind" : "remoteSourceControl", + "location" : "https://github.com/JohnSundell/Files.git", + "state" : { + "revision" : "e85f2b4a8dfa0f242889f45236f3867d16e40480", + "version" : "4.3.0" + } + }, + { + "identity" : "sentry-cocoa", + "kind" : "remoteSourceControl", + "location" : "https://github.com/getsentry/sentry-cocoa", + "state" : { + "revision" : "16cd512711375fa73f25ae5e373f596bdf4251ae", + "version" : "8.58.0" + } + }, + { + "identity" : "swcompression", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tsolomko/SWCompression.git", + "state" : { + "revision" : "390e0b0af8dd19a600005a242a89e570ff482e09", + "version" : "4.8.6" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "810496cf121e525d660cd0ea89a758740476b85f", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc", + "version" : "3.15.1" + } + }, + { + "identity" : "zipfoundation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/weichsel/ZIPFoundation.git", + "state" : { + "revision" : "22787ffb59de99e5dc1fbfe80b19c97a904ad48d", + "version" : "0.9.20" + } + } + ], + "version" : 3 +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI.xcodeproj/xcshareddata/xcschemes/RunAnywhereAI.xcscheme b/examples/ios/RunAnywhereAI/RunAnywhereAI.xcodeproj/xcshareddata/xcschemes/RunAnywhereAI.xcscheme new file mode 100644 index 000000000..7e57c1a54 --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI.xcodeproj/xcshareddata/xcschemes/RunAnywhereAI.xcscheme @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/App/ContentView.swift b/examples/ios/RunAnywhereAI/RunAnywhereAI/App/ContentView.swift new file mode 100644 index 000000000..f85804305 --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/App/ContentView.swift @@ -0,0 +1,81 @@ +// +// ContentView.swift +// RunAnywhereAI +// +// Created by Sanchit Monga on 7/21/25. +// + +import SwiftUI + +struct ContentView: View { + @State private var selectedTab = 0 + + var body: some View { + TabView(selection: $selectedTab) { + // Tab 0: Chat (LLM) + ChatInterfaceView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .tabItem { + Label("Chat", systemImage: "message") + } + .tag(0) + + // Tab 1: Speech-to-Text + SpeechToTextView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .tabItem { + Label("Transcribe", systemImage: "waveform") + } + .tag(1) + + // Tab 2: Text-to-Speech + TextToSpeechView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .tabItem { + Label("Speak", systemImage: "speaker.wave.2") + } + .tag(2) + + // Tab 3: Voice Assistant (STT + LLM + TTS) + VoiceAssistantView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .tabItem { + Label("Voice", systemImage: "mic") + } + .tag(3) + + // Tab 4: Combined Settings (includes Storage) + Group { + #if os(macOS) + CombinedSettingsView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + #else + NavigationView { + CombinedSettingsView() + } + .navigationViewStyle(.stack) + .frame(maxWidth: .infinity, maxHeight: .infinity) + #endif + } + .tabItem { + Label("Settings", systemImage: "gear") + } + .tag(4) + } + .accentColor(AppColors.primaryAccent) + #if os(macOS) + .frame( + minWidth: 800, + idealWidth: 1200, + maxWidth: .infinity, + minHeight: 600, + idealHeight: 800, + maxHeight: .infinity + ) + #endif + } +} + +#Preview { + ContentView() +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/App/RunAnywhereAIApp.swift b/examples/ios/RunAnywhereAI/RunAnywhereAI/App/RunAnywhereAIApp.swift new file mode 100644 index 000000000..4fa568f2c --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/App/RunAnywhereAIApp.swift @@ -0,0 +1,351 @@ +// +// RunAnywhereAIApp.swift +// RunAnywhereAI +// +// Created by Sanchit Monga on 7/21/25. +// + +import SwiftUI +import RunAnywhere +import LlamaCPPRuntime +import ONNXRuntime +#if canImport(UIKit) +import UIKit +#endif +import os +#if os(macOS) +import AppKit +#endif + +@main +struct RunAnywhereAIApp: App { + private let logger = Logger(subsystem: "com.runanywhere.RunAnywhereAI", category: "RunAnywhereAIApp") + @StateObject private var modelManager = ModelManager.shared + @State private var isSDKInitialized = false + @State private var initializationError: Error? + + var body: some Scene { + WindowGroup { + Group { + if isSDKInitialized { + ContentView() + .environmentObject(modelManager) + .onAppear { + logger.info("🎉 App is ready to use!") + } + } else if let error = initializationError { + InitializationErrorView(error: error) { + // Retry initialization + Task { + await retryInitialization() + } + } + } else { + InitializationLoadingView() + } + } + .task { + logger.info("🏁 App launched, initializing SDK...") + await initializeSDK() + } + } + #if os(macOS) + .windowStyle(.titleBar) + .windowToolbarStyle(.unified) + .defaultSize(width: 1200, height: 800) + .windowResizability(.contentSize) + #endif + } + + private func initializeSDK() async { + do { + // Clear any previous error + await MainActor.run { initializationError = nil } + + logger.info("🎯 Initializing SDK...") + + let startTime = Date() + + // Check for custom API configuration (stored in Settings) + let customApiKey = SettingsViewModel.getStoredApiKey() + let customBaseURL = SettingsViewModel.getStoredBaseURL() + + if let apiKey = customApiKey, let baseURL = customBaseURL { + // Custom configuration mode - use stored credentials + // Always use .production for custom backends (model assignment auto-fetch enabled) + logger.info("🔧 Found custom API configuration") + logger.info(" Base URL: \(baseURL, privacy: .public)") + + try RunAnywhere.initialize( + apiKey: apiKey, + baseURL: baseURL, + environment: .production + ) + logger.info("✅ SDK initialized with CUSTOM configuration (production)") + } else { + // Default mode based on build configuration + #if DEBUG + // Development mode - uses Supabase, no API key needed + try RunAnywhere.initialize() + logger.info("✅ SDK initialized in DEVELOPMENT mode") + #else + // Production mode - requires API key and backend URL + // Configure these via Settings screen or set environment variables + let apiKey = "YOUR_API_KEY_HERE" + let baseURL = "YOUR_BASE_URL_HERE" + + try RunAnywhere.initialize( + apiKey: apiKey, + baseURL: baseURL, + environment: .production + ) + logger.info("✅ SDK initialized in PRODUCTION mode") + #endif + } + + // Register modules and models + await registerModulesAndModels() + + let initTime = Date().timeIntervalSince(startTime) + logger.info("✅ SDK successfully initialized!") + logger.info("⚡ Initialization time: \(String(format: "%.3f", initTime * 1000), privacy: .public)ms") + logger.info("🎯 SDK Status: \(RunAnywhere.isActive ? "Active" : "Inactive")") + logger.info("🔧 Environment: \(RunAnywhere.environment?.description ?? "Unknown")") + logger.info("📱 Services will initialize on first API call") + + // Mark as initialized + await MainActor.run { + isSDKInitialized = true + } + + logger.info("💡 Models registered, user can now download and select models") + } catch { + logger.error("❌ SDK initialization failed: \(error, privacy: .public)") + await MainActor.run { + initializationError = error + } + } + } + + private func retryInitialization() async { + await MainActor.run { + initializationError = nil + } + await initializeSDK() + } + + /// Register modules with their associated models + /// Each module explicitly owns its models - the framework is determined by the module + @MainActor + private func registerModulesAndModels() async { // swiftlint:disable:this function_body_length + logger.info("📦 Registering modules with their models...") + + // Register LlamaCPP backend with C++ commons + LlamaCPP.register(priority: 100) + logger.info("✅ LlamaCPP backend registered") + + // Register ONNX backend service providers + ONNX.register(priority: 100) + logger.info("✅ ONNX backend registered") + + // Register LLM models using the new RunAnywhere.registerModel API + // Using explicit IDs ensures models are recognized after download across app restarts + if let smolLM2URL = URL(string: "https://huggingface.co/prithivMLmods/SmolLM2-360M-GGUF/resolve/main/SmolLM2-360M.Q8_0.gguf") { + RunAnywhere.registerModel( + id: "smollm2-360m-q8_0", + name: "SmolLM2 360M Q8_0", + url: smolLM2URL, + framework: .llamaCpp, + memoryRequirement: 500_000_000 + ) + } + if let llama2URL = URL(string: "https://huggingface.co/TheBloke/Llama-2-7B-Chat-GGUF/resolve/main/llama-2-7b-chat.Q4_K_M.gguf") { + RunAnywhere.registerModel( + id: "llama-2-7b-chat-q4_k_m", + name: "Llama 2 7B Chat Q4_K_M", + url: llama2URL, + framework: .llamaCpp, + memoryRequirement: 4_000_000_000 + ) + } + if let mistralURL = URL(string: "https://huggingface.co/TheBloke/Mistral-7B-Instruct-v0.1-GGUF/resolve/main/mistral-7b-instruct-v0.1.Q4_K_M.gguf") { + RunAnywhere.registerModel( + id: "mistral-7b-instruct-q4_k_m", + name: "Mistral 7B Instruct Q4_K_M", + url: mistralURL, + framework: .llamaCpp, + memoryRequirement: 4_000_000_000 + ) + } + if let qwenURL = URL(string: "https://huggingface.co/Triangle104/Qwen2.5-0.5B-Instruct-Q6_K-GGUF/resolve/main/qwen2.5-0.5b-instruct-q6_k.gguf") { + RunAnywhere.registerModel( + id: "qwen2.5-0.5b-instruct-q6_k", + name: "Qwen 2.5 0.5B Instruct Q6_K", + url: qwenURL, + framework: .llamaCpp, + memoryRequirement: 600_000_000 + ) + } + if let lfm2Q4URL = URL(string: "https://huggingface.co/LiquidAI/LFM2-350M-GGUF/resolve/main/LFM2-350M-Q4_K_M.gguf") { + RunAnywhere.registerModel( + id: "lfm2-350m-q4_k_m", + name: "LiquidAI LFM2 350M Q4_K_M", + url: lfm2Q4URL, + framework: .llamaCpp, + memoryRequirement: 250_000_000 + ) + } + if let lfm2Q8URL = URL(string: "https://huggingface.co/LiquidAI/LFM2-350M-GGUF/resolve/main/LFM2-350M-Q8_0.gguf") { + RunAnywhere.registerModel( + id: "lfm2-350m-q8_0", + name: "LiquidAI LFM2 350M Q8_0", + url: lfm2Q8URL, + framework: .llamaCpp, + memoryRequirement: 400_000_000 + ) + } + logger.info("✅ LLM models registered") + + // Register ONNX STT and TTS models + // Using tar.gz format hosted on RunanywhereAI/sherpa-onnx for fast native extraction + if let whisperURL = URL(string: "https://github.com/RunanywhereAI/sherpa-onnx/releases/download/runanywhere-models-v1/sherpa-onnx-whisper-tiny.en.tar.gz") { + RunAnywhere.registerModel( + id: "sherpa-onnx-whisper-tiny.en", + name: "Sherpa Whisper Tiny (ONNX)", + url: whisperURL, + framework: .onnx, + modality: .speechRecognition, + artifactType: .archive(.tarGz, structure: .nestedDirectory), + memoryRequirement: 75_000_000 + ) + } + if let piperUSURL = URL(string: "https://github.com/RunanywhereAI/sherpa-onnx/releases/download/runanywhere-models-v1/vits-piper-en_US-lessac-medium.tar.gz") { + RunAnywhere.registerModel( + id: "vits-piper-en_US-lessac-medium", + name: "Piper TTS (US English - Medium)", + url: piperUSURL, + framework: .onnx, + modality: .speechSynthesis, + artifactType: .archive(.tarGz, structure: .nestedDirectory), + memoryRequirement: 65_000_000 + ) + } + if let piperGBURL = URL(string: "https://github.com/RunanywhereAI/sherpa-onnx/releases/download/runanywhere-models-v1/vits-piper-en_GB-alba-medium.tar.gz") { + RunAnywhere.registerModel( + id: "vits-piper-en_GB-alba-medium", + name: "Piper TTS (British English)", + url: piperGBURL, + framework: .onnx, + modality: .speechSynthesis, + artifactType: .archive(.tarGz, structure: .nestedDirectory), + memoryRequirement: 65_000_000 + ) + } + logger.info("✅ ONNX STT/TTS models registered") + logger.info("🎉 All modules and models registered") + } +} + +// MARK: - Loading Views + +struct InitializationLoadingView: View { + @State private var isAnimating = false + @State private var progress: Double = 0.0 + + var body: some View { + VStack(spacing: 32) { + Spacer() + + // RunAnywhere Logo + Image("runanywhere_logo") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 120, height: 120) + .scaleEffect(isAnimating ? 1.05 : 1.0) + .animation(.easeInOut(duration: 1.5).repeatForever(autoreverses: true), value: isAnimating) + + VStack(spacing: 12) { + Text("Setting Up Your AI") + .font(.title2) + .fontWeight(.semibold) + + Text("Preparing your private AI assistant...") + .font(.subheadline) + .foregroundColor(.secondary) + } + + // Loading Bar + VStack(spacing: 8) { + ProgressView(value: progress, total: 1.0) + .progressViewStyle(.linear) + .tint(AppColors.primaryAccent) + .frame(width: 240) + + Text("Initializing SDK...") + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + } + .padding(40) + .frame(maxWidth: .infinity, maxHeight: .infinity) + #if os(iOS) + .background(Color(.systemBackground)) + #else + .background(Color(NSColor.windowBackgroundColor)) + #endif + .onAppear { + isAnimating = true + startProgressAnimation() + } + } + + private func startProgressAnimation() { + Timer.scheduledTimer(withTimeInterval: 0.02, repeats: true) { timer in + if progress < 1.0 { + progress += 0.01 + } else { + // Reset and start again + progress = 0.0 + } + } + } +} + +struct InitializationErrorView: View { + let error: Error + let retryAction: () -> Void + + var body: some View { + VStack(spacing: 24) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 60)) + .foregroundColor(.orange) + + Text("Initialization Failed") + .font(.title2) + .fontWeight(.semibold) + + Text(error.localizedDescription) + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + + Button("Retry") { + retryAction() + } + .buttonStyle(.borderedProminent) + .tint(AppColors.primaryAccent) + .font(.headline) + } + .padding(40) + .frame(maxWidth: .infinity, maxHeight: .infinity) + #if os(iOS) + .background(Color(.systemBackground)) + #else + .background(Color(NSColor.windowBackgroundColor)) + #endif + } +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AccentColor.colorset/Contents.json b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 000000000..eb8789700 --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/100.png b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/100.png new file mode 100644 index 000000000..4b160f3e8 Binary files /dev/null and b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/100.png differ diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/102.png b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/102.png new file mode 100644 index 000000000..f1f0593bc Binary files /dev/null and b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/102.png differ diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/1024.png b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/1024.png new file mode 100644 index 000000000..6b309914a Binary files /dev/null and b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/1024.png differ diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/108.png b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/108.png new file mode 100644 index 000000000..188cb8756 Binary files /dev/null and b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/108.png differ diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/114.png b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/114.png new file mode 100644 index 000000000..1100d2793 Binary files /dev/null and b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/114.png differ diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/120.png b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/120.png new file mode 100644 index 000000000..288e27187 Binary files /dev/null and b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/120.png differ diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/128.png b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/128.png new file mode 100644 index 000000000..bf846f2c6 Binary files /dev/null and b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/128.png differ diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/144.png b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/144.png new file mode 100644 index 000000000..2986a6c3c Binary files /dev/null and b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/144.png differ diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/152.png b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/152.png new file mode 100644 index 000000000..3639f91de Binary files /dev/null and b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/152.png differ diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/16.png b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/16.png new file mode 100644 index 000000000..4d2ff68da Binary files /dev/null and b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/16.png differ diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/167.png b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/167.png new file mode 100644 index 000000000..5a3e425d8 Binary files /dev/null and b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/167.png differ diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/172.png b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/172.png new file mode 100644 index 000000000..b009bfa7a Binary files /dev/null and b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/172.png differ diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/180.png b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/180.png new file mode 100644 index 000000000..cf12e1f2c Binary files /dev/null and b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/180.png differ diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/196.png b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/196.png new file mode 100644 index 000000000..3f1e68eca Binary files /dev/null and b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/196.png differ diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/20.png b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/20.png new file mode 100644 index 000000000..39f76a6eb Binary files /dev/null and b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/20.png differ diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/216.png b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/216.png new file mode 100644 index 000000000..9488ece10 Binary files /dev/null and b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/216.png differ diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/234.png b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/234.png new file mode 100644 index 000000000..024d7ed54 Binary files /dev/null and b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/234.png differ diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/256.png b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/256.png new file mode 100644 index 000000000..f55af4eef Binary files /dev/null and b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/256.png differ diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/258.png b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/258.png new file mode 100644 index 000000000..1dee051c4 Binary files /dev/null and b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/258.png differ diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/29.png b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/29.png new file mode 100644 index 000000000..7c59b6b50 Binary files /dev/null and b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/29.png differ diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/32.png b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/32.png new file mode 100644 index 000000000..5ad0ba3f1 Binary files /dev/null and b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/32.png differ diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/40.png b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/40.png new file mode 100644 index 000000000..d7b83779c Binary files /dev/null and b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/40.png differ diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/48.png b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/48.png new file mode 100644 index 000000000..157914137 Binary files /dev/null and b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/48.png differ diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/50.png b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/50.png new file mode 100644 index 000000000..9d5334505 Binary files /dev/null and b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/50.png differ diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/512.png b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/512.png new file mode 100644 index 000000000..4482531ee Binary files /dev/null and b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/512.png differ diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/55.png b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/55.png new file mode 100644 index 000000000..76667e765 Binary files /dev/null and b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/55.png differ diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/57.png b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/57.png new file mode 100644 index 000000000..0fc697f52 Binary files /dev/null and b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/57.png differ diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/58.png b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/58.png new file mode 100644 index 000000000..0b031c9cf Binary files /dev/null and b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/58.png differ diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/60.png b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/60.png new file mode 100644 index 000000000..43cb176db Binary files /dev/null and b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/60.png differ diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/64.png b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/64.png new file mode 100644 index 000000000..edf3a217c Binary files /dev/null and b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/64.png differ diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/66.png b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/66.png new file mode 100644 index 000000000..5853a299d Binary files /dev/null and b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/66.png differ diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/72.png b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/72.png new file mode 100644 index 000000000..444ecfb64 Binary files /dev/null and b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/72.png differ diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/76.png b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/76.png new file mode 100644 index 000000000..197ac0ddc Binary files /dev/null and b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/76.png differ diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/80.png b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/80.png new file mode 100644 index 000000000..6b82926fd Binary files /dev/null and b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/80.png differ diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/87.png b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/87.png new file mode 100644 index 000000000..350e0ae13 Binary files /dev/null and b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/87.png differ diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/88.png b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/88.png new file mode 100644 index 000000000..7e1ebe3ac Binary files /dev/null and b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/88.png differ diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/92.png b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/92.png new file mode 100644 index 000000000..47b1720cc Binary files /dev/null and b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/92.png differ diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/Contents.json b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..853263563 --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1 @@ +{"images":[{"size":"60x60","expected-size":"180","filename":"180.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"40x40","expected-size":"80","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"40x40","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"60x60","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"57x57","expected-size":"57","filename":"57.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"29x29","expected-size":"29","filename":"29.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"87","filename":"87.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"57x57","expected-size":"114","filename":"114.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"60","filename":"60.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"1024x1024","filename":"1024.png","expected-size":"1024","idiom":"ios-marketing","folder":"Assets.xcassets/AppIcon.appiconset/","scale":"1x"},{"size":"40x40","expected-size":"80","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"72x72","expected-size":"72","filename":"72.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"76x76","expected-size":"152","filename":"152.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"50x50","expected-size":"100","filename":"100.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"76x76","expected-size":"76","filename":"76.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"29x29","expected-size":"29","filename":"29.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"50x50","expected-size":"50","filename":"50.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"72x72","expected-size":"144","filename":"144.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"40x40","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"83.5x83.5","expected-size":"167","filename":"167.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"20x20","expected-size":"20","filename":"20.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"20x20","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"idiom":"watch","filename":"172.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"38mm","scale":"2x","size":"86x86","expected-size":"172","role":"quickLook"},{"idiom":"watch","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"38mm","scale":"2x","size":"40x40","expected-size":"80","role":"appLauncher"},{"idiom":"watch","filename":"88.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"40mm","scale":"2x","size":"44x44","expected-size":"88","role":"appLauncher"},{"idiom":"watch","filename":"102.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"45mm","scale":"2x","size":"51x51","expected-size":"102","role":"appLauncher"},{"idiom":"watch","filename":"108.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"49mm","scale":"2x","size":"54x54","expected-size":"108","role":"appLauncher"},{"idiom":"watch","filename":"92.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"41mm","scale":"2x","size":"46x46","expected-size":"92","role":"appLauncher"},{"idiom":"watch","filename":"100.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"44mm","scale":"2x","size":"50x50","expected-size":"100","role":"appLauncher"},{"idiom":"watch","filename":"196.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"42mm","scale":"2x","size":"98x98","expected-size":"196","role":"quickLook"},{"idiom":"watch","filename":"216.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"44mm","scale":"2x","size":"108x108","expected-size":"216","role":"quickLook"},{"idiom":"watch","filename":"234.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"45mm","scale":"2x","size":"117x117","expected-size":"234","role":"quickLook"},{"idiom":"watch","filename":"258.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"49mm","scale":"2x","size":"129x129","expected-size":"258","role":"quickLook"},{"idiom":"watch","filename":"48.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"38mm","scale":"2x","size":"24x24","expected-size":"48","role":"notificationCenter"},{"idiom":"watch","filename":"55.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"42mm","scale":"2x","size":"27.5x27.5","expected-size":"55","role":"notificationCenter"},{"idiom":"watch","filename":"66.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"45mm","scale":"2x","size":"33x33","expected-size":"66","role":"notificationCenter"},{"size":"29x29","expected-size":"87","filename":"87.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"watch","role":"companionSettings","scale":"3x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"watch","role":"companionSettings","scale":"2x"},{"size":"1024x1024","expected-size":"1024","filename":"1024.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"watch-marketing","scale":"1x"},{"size":"128x128","expected-size":"128","filename":"128.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"256x256","expected-size":"256","filename":"256.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"128x128","expected-size":"256","filename":"256.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"},{"size":"256x256","expected-size":"512","filename":"512.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"},{"size":"32x32","expected-size":"32","filename":"32.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"512x512","expected-size":"512","filename":"512.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"16x16","expected-size":"16","filename":"16.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"16x16","expected-size":"32","filename":"32.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"},{"size":"32x32","expected-size":"64","filename":"64.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"},{"size":"512x512","expected-size":"1024","filename":"1024.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"}]} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/Contents.json b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/foundation_models_logo.imageset/9e90f1b5-7847-43ff-8b3c-845ba871c17b.png b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/foundation_models_logo.imageset/9e90f1b5-7847-43ff-8b3c-845ba871c17b.png new file mode 100644 index 000000000..4488cb10e Binary files /dev/null and b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/foundation_models_logo.imageset/9e90f1b5-7847-43ff-8b3c-845ba871c17b.png differ diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/foundation_models_logo.imageset/Contents.json b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/foundation_models_logo.imageset/Contents.json new file mode 100644 index 000000000..69828bafd --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/foundation_models_logo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "9e90f1b5-7847-43ff-8b3c-845ba871c17b.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/hugging_face_logo.imageset/9040e226-a7ee-4415-828a-89dfc8f3ecf4.png b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/hugging_face_logo.imageset/9040e226-a7ee-4415-828a-89dfc8f3ecf4.png new file mode 100644 index 000000000..963b7277b Binary files /dev/null and b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/hugging_face_logo.imageset/9040e226-a7ee-4415-828a-89dfc8f3ecf4.png differ diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/hugging_face_logo.imageset/Contents.json b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/hugging_face_logo.imageset/Contents.json new file mode 100644 index 000000000..2dc425ebb --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/hugging_face_logo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "9040e226-a7ee-4415-828a-89dfc8f3ecf4.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/liquid_ai_logo.imageset/Contents.json b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/liquid_ai_logo.imageset/Contents.json new file mode 100644 index 000000000..20c60327b --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/liquid_ai_logo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "fd5a9ec4-83b1-44fb-9c3e-42cb3e6fba9a.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/liquid_ai_logo.imageset/fd5a9ec4-83b1-44fb-9c3e-42cb3e6fba9a.png b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/liquid_ai_logo.imageset/fd5a9ec4-83b1-44fb-9c3e-42cb3e6fba9a.png new file mode 100644 index 000000000..8ab423ffb Binary files /dev/null and b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/liquid_ai_logo.imageset/fd5a9ec4-83b1-44fb-9c3e-42cb3e6fba9a.png differ diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/llama_logo.imageset/726b7f89-ea77-4702-a086-12836bb31efb.png b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/llama_logo.imageset/726b7f89-ea77-4702-a086-12836bb31efb.png new file mode 100644 index 000000000..4e4d21697 Binary files /dev/null and b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/llama_logo.imageset/726b7f89-ea77-4702-a086-12836bb31efb.png differ diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/llama_logo.imageset/Contents.json b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/llama_logo.imageset/Contents.json new file mode 100644 index 000000000..01c4cf67e --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/llama_logo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "726b7f89-ea77-4702-a086-12836bb31efb.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/mistral_logo.imageset/Contents.json b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/mistral_logo.imageset/Contents.json new file mode 100644 index 000000000..99ce67336 --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/mistral_logo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "Mistral_AI_logo_(2025–).svg-2.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git "a/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/mistral_logo.imageset/Mistral_AI_logo_(2025\342\200\223).svg-2.png" "b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/mistral_logo.imageset/Mistral_AI_logo_(2025\342\200\223).svg-2.png" new file mode 100644 index 000000000..f3bf1519d Binary files /dev/null and "b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/mistral_logo.imageset/Mistral_AI_logo_(2025\342\200\223).svg-2.png" differ diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/qwen_logo.imageset/Contents.json b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/qwen_logo.imageset/Contents.json new file mode 100644 index 000000000..6a4328d9f --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/qwen_logo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "Qwen_logo.svg.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/qwen_logo.imageset/Qwen_logo.svg.png b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/qwen_logo.imageset/Qwen_logo.svg.png new file mode 100644 index 000000000..c46edc17b Binary files /dev/null and b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/qwen_logo.imageset/Qwen_logo.svg.png differ diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/runanywhere_logo.imageset/Contents.json b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/runanywhere_logo.imageset/Contents.json new file mode 100644 index 000000000..02501f825 --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/runanywhere_logo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "runanywhere_logo.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/runanywhere_logo.imageset/runanywhere_logo.svg b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/runanywhere_logo.imageset/runanywhere_logo.svg new file mode 100644 index 000000000..c848b9d54 --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Assets.xcassets/runanywhere_logo.imageset/runanywhere_logo.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Core/DesignSystem/AppColors.swift b/examples/ios/RunAnywhereAI/RunAnywhereAI/Core/DesignSystem/AppColors.swift new file mode 100644 index 000000000..189281133 --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Core/DesignSystem/AppColors.swift @@ -0,0 +1,243 @@ +// +// AppColors.swift +// RunAnywhereAI +// +// RunAnywhere Brand Color Palette +// Color scheme matching RunAnywhere.ai website +// Primary accent: Vibrant orange-red (#FF5500) - matches website branding +// Dark theme backgrounds: Deep dark blue-gray matching website aesthetic +// + +import SwiftUI +#if canImport(UIKit) +import UIKit +#else +import AppKit +#endif + +// MARK: - Color Extension for Hex Support +extension Color { + init(hex: UInt, alpha: Double = 1.0) { + self.init( + .sRGB, + red: Double((hex >> 16) & 0xff) / 255, + green: Double((hex >> 8) & 0xff) / 255, + blue: Double(hex & 0xff) / 255, + opacity: alpha + ) + } +} + +// MARK: - App Colors (RunAnywhere Brand Theme) +struct AppColors { + // ==================== + // PRIMARY ACCENT COLORS - RunAnywhere Brand Colors + // ==================== + // Primary brand color - vibrant orange/red from RunAnywhere.ai website + static let primaryAccent = Color(hex: 0xFF5500) // Vibrant orange-red - primary brand color + static let primaryOrange = Color(hex: 0xFF5500) // Same as primary accent + static let primaryBlue = Color(hex: 0x3B82F6) // Blue-500 - for secondary elements + static let primaryGreen = Color(hex: 0x10B981) // Emerald-500 - success green + static let primaryRed = Color(hex: 0xEF4444) // Red-500 - error red + static let primaryYellow = Color(hex: 0xEAB308) // Yellow-500 + static let primaryPurple = Color(hex: 0x8B5CF6) // Violet-500 - purple accent + + // ==================== + // TEXT COLORS - RunAnywhere Theme + // ==================== + static let textPrimary = Color.primary + static let textSecondary = Color.secondary + static let textTertiary = Color(hex: 0x94A3B8) // Slate-400 - tertiary text + static let textWhite = Color.white + + // Light mode specific text colors + static let textPrimaryLight = Color(hex: 0x0F172A) // Slate-900 - dark text for light mode + static let textSecondaryLight = Color(hex: 0x475569) // Slate-600 - secondary text + + // ==================== + // BACKGROUND COLORS - RunAnywhere Theme + // ==================== + // Platform-adaptive backgrounds using system colors for proper dark mode support + #if os(iOS) + static let backgroundPrimary = Color(.systemBackground) + static let backgroundSecondary = Color(.secondarySystemBackground) + static let backgroundTertiary = Color(.tertiarySystemBackground) + static let backgroundGrouped = Color(.systemGroupedBackground) + static let backgroundGray5 = Color(.systemGray5) + static let backgroundGray6 = Color(.systemGray6) + static let separator = Color(.separator) + #else + static let backgroundPrimary = Color(NSColor.windowBackgroundColor) + static let backgroundSecondary = Color(NSColor.controlBackgroundColor) + static let backgroundTertiary = Color(NSColor.textBackgroundColor) + static let backgroundGrouped = Color(NSColor.controlBackgroundColor) + static let backgroundGray5 = Color(NSColor.controlColor) + static let backgroundGray6 = Color(NSColor.controlBackgroundColor) + static let separator = Color(NSColor.separatorColor) + #endif + + // Light mode explicit colors (for when you need exact control) + static let backgroundPrimaryLight = Color(hex: 0xFFFFFF) // Pure white + static let backgroundSecondaryLight = Color(hex: 0xF8FAFC) // Slate-50 - very light gray + static let backgroundGroupedLight = Color(hex: 0xF1F5F9) // Slate-100 - light grouped background + static let backgroundGray5Light = Color(hex: 0xE2E8F0) // Slate-200 - light gray + static let backgroundGray6Light = Color(hex: 0xF1F5F9) // Slate-100 - lighter gray + + // Dark mode explicit colors - matching RunAnywhere.ai website dark theme + static let backgroundPrimaryDark = Color(hex: 0x0F172A) // Deep dark blue-gray - main background + static let backgroundSecondaryDark = Color(hex: 0x1A1F2E) // Slightly lighter dark surface + static let backgroundTertiaryDark = Color(hex: 0x252B3A) // Medium dark surface + static let backgroundGroupedDark = Color(hex: 0x0F172A) // Deep dark - grouped background + static let backgroundGray5Dark = Color(hex: 0x2A3142) // Medium dark gray + static let backgroundGray6Dark = Color(hex: 0x353B4A) // Lighter dark gray + + // ==================== + // MESSAGE BUBBLE COLORS - RunAnywhere Theme + // ==================== + // User bubbles (with gradient support) - using vibrant orange/red brand color + static let userBubbleGradientStart = primaryAccent // Vibrant orange-red + static let userBubbleGradientEnd = Color(hex: 0xE64500) // Slightly darker orange-red + static let messageBubbleUser = primaryAccent // Vibrant orange-red + + // Assistant bubbles - clean gray (uses system colors for dark mode adaptation) + static let assistantBubbleBg = backgroundGray5 + static let messageBubbleAssistant = backgroundGray5 + static let messageBubbleAssistantGradientStart = backgroundGray5 + static let messageBubbleAssistantGradientEnd = backgroundGray6 + + // Dark mode - toned down variant for reduced eye strain in low-light + static let messageBubbleUserDark = Color(hex: 0xCC4400) // Darker orange-red (80% brightness) + static let messageBubbleAssistantDark = backgroundGray5Dark // Dark gray + + // ==================== + // BADGE/TAG COLORS - RunAnywhere Theme + // ==================== + static let badgePrimary = primaryAccent.opacity(0.2) // Brand primary (orange-red) + static let badgeBlue = primaryBlue.opacity(0.2) + static let badgeGreen = primaryGreen.opacity(0.2) + static let badgePurple = primaryPurple.opacity(0.2) + static let badgeOrange = primaryOrange.opacity(0.2) + static let badgeYellow = primaryYellow.opacity(0.2) + static let badgeRed = primaryRed.opacity(0.2) + static let badgeGray = Color.gray.opacity(0.2) + + // ==================== + // MODEL INFO COLORS - RunAnywhere Theme + // ==================== + static let modelFrameworkBg = primaryAccent.opacity(0.1) // Brand primary orange-red + static let modelThinkingBg = primaryAccent.opacity(0.1) // Brand primary orange-red + + // ==================== + // THINKING MODE COLORS - RunAnywhere Theme + // ==================== + // Using brand orange for thinking mode to match website aesthetic + static let thinkingBackground = primaryAccent.opacity(0.1) // 10% orange-red + static let thinkingBackgroundGradientStart = primaryAccent.opacity(0.1) + static let thinkingBackgroundGradientEnd = primaryAccent.opacity(0.05) // 5% orange-red + static let thinkingBorder = primaryAccent.opacity(0.2) + static let thinkingContentBackground = backgroundGray6 + static let thinkingContentBackgroundColor = backgroundGray6 + static let thinkingProgressBackground = primaryAccent.opacity(0.12) + static let thinkingProgressBackgroundGradientEnd = primaryAccent.opacity(0.06) + + // Dark mode thinking colors + static let thinkingBackgroundDark = primaryAccent.opacity(0.15) + static let thinkingContentBackgroundDark = backgroundGray6Dark + + // ==================== + // STATUS COLORS - RunAnywhere Theme + // ==================== + static let statusGreen = primaryGreen + static let statusOrange = primaryOrange + static let statusRed = primaryRed + static let statusGray = Color(hex: 0x64748B) // Slate-500 - modern gray + static let statusBlue = primaryBlue + static let statusPrimary = primaryAccent // Brand primary (orange-red) + + // Warning color - matches brand orange for error states + static let warningOrange = primaryOrange + + // ==================== + // SHADOW COLORS + // ==================== + static let shadowDefault = Color.black.opacity(0.1) + static let shadowLight = Color.black.opacity(0.1) + static let shadowMedium = Color.black.opacity(0.12) + static let shadowDark = Color.black.opacity(0.3) + + // Shadows for specific components + static let shadowBubble = shadowMedium // 0.12 alpha + static let shadowThinking = primaryAccent.opacity(0.2) // Orange-red glow + static let shadowModelBadge = primaryAccent.opacity(0.3) // Brand primary + static let shadowTypingIndicator = shadowLight + + // ==================== + // OVERLAY COLORS + // ==================== + static let overlayLight = Color.black.opacity(0.3) + static let overlayMedium = Color.black.opacity(0.4) + static let overlayDark = Color.black.opacity(0.7) + + // ==================== + // BORDER COLORS - RunAnywhere Theme + // ==================== + static let borderLight = Color.white.opacity(0.3) + static let borderMedium = Color.black.opacity(0.05) + static let separatorColor = Color(hex: 0xE2E8F0) // Slate-200 - modern separator + + // ==================== + // DIVIDERS - RunAnywhere Theme + // ==================== + static let divider = Color(hex: 0xCBD5E1) // Slate-300 - light divider + static let dividerDark = Color(hex: 0x2A3142) // Dark divider matching website + + // ==================== + // CARDS & SURFACES + // ==================== + static let cardBackground = backgroundSecondary + static let cardBackgroundDark = backgroundSecondaryDark + + // ==================== + // TYPING INDICATOR - RunAnywhere Theme + // ==================== + static let typingIndicatorDots = primaryAccent.opacity(0.7) // Brand primary + static let typingIndicatorBackground = backgroundGray5 + static let typingIndicatorBorder = borderLight + static let typingIndicatorText = textSecondary.opacity(0.8) + + // ==================== + // QUIZ SPECIFIC + // ==================== + static let quizTrue = primaryGreen + static let quizFalse = primaryRed + static let quizCardShadow = Color.black.opacity(0.1) + + // ==================== + // FRAMEWORK-SPECIFIC BADGE COLORS + // ==================== + static func frameworkBadgeColor(framework: String) -> Color { + switch framework.uppercased() { + case "LLAMA_CPP", "LLAMACPP": + return primaryAccent.opacity(0.2) // Brand primary + case "MLKIT", "ML_KIT": + return badgePurple + case "COREML", "CORE_ML": + return badgeOrange + default: + return primaryAccent.opacity(0.2) + } + } + + static func frameworkTextColor(framework: String) -> Color { + switch framework.uppercased() { + case "LLAMA_CPP", "LLAMACPP": + return primaryAccent // Brand primary + case "MLKIT", "ML_KIT": + return primaryPurple + case "COREML", "CORE_ML": + return primaryOrange + default: + return primaryAccent + } + } +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Core/DesignSystem/AppSpacing.swift b/examples/ios/RunAnywhereAI/RunAnywhereAI/Core/DesignSystem/AppSpacing.swift new file mode 100644 index 000000000..ee883130b --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Core/DesignSystem/AppSpacing.swift @@ -0,0 +1,114 @@ +// +// AppSpacing.swift +// RunAnywhereAI +// +// Centralized spacing and sizing from existing usage in the app +// + +import SwiftUI + +// MARK: - App Spacing (gathered from existing usage) +struct AppSpacing { + // MARK: - Padding values (from existing usage) + static let xxSmall: CGFloat = 2 + static let xSmall: CGFloat = 4 + static let small: CGFloat = 6 + static let smallMedium: CGFloat = 8 + static let medium: CGFloat = 10 + static let mediumLarge: CGFloat = 12 + static let regular: CGFloat = 14 + static let large: CGFloat = 16 + static let xLarge: CGFloat = 20 + static let xxLarge: CGFloat = 30 + static let xxxLarge: CGFloat = 40 + + // Specific padding values used + static let padding4: CGFloat = 4 + static let padding6: CGFloat = 6 + static let padding8: CGFloat = 8 + static let padding9: CGFloat = 9 + static let padding10: CGFloat = 10 + static let padding12: CGFloat = 12 + static let padding14: CGFloat = 14 + static let padding15: CGFloat = 15 + static let padding16: CGFloat = 16 + static let padding20: CGFloat = 20 + static let padding30: CGFloat = 30 + static let padding40: CGFloat = 40 + static let padding60: CGFloat = 60 + static let padding100: CGFloat = 100 + + // MARK: - Component sizes (from existing usage) + + // Icon sizes + static let iconSmall: CGFloat = 8 + static let iconRegular: CGFloat = 18 + static let iconMedium: CGFloat = 28 + static let iconLarge: CGFloat = 48 + static let iconXLarge: CGFloat = 60 + static let iconXXLarge: CGFloat = 72 + static let iconHuge: CGFloat = 80 + + // Button sizes + static let buttonHeightSmall: CGFloat = 28 + static let buttonHeightRegular: CGFloat = 44 + static let buttonHeightLarge: CGFloat = 72 + + // Corner radius (from existing usage) + static let cornerRadiusSmall: CGFloat = 4 + static let cornerRadiusMedium: CGFloat = 6 + static let cornerRadiusRegular: CGFloat = 8 + static let cornerRadiusLarge: CGFloat = 10 + static let cornerRadiusXLarge: CGFloat = 12 + static let cornerRadiusXXLarge: CGFloat = 14 + static let cornerRadiusCard: CGFloat = 16 + static let cornerRadiusBubble: CGFloat = 18 + static let cornerRadiusModal: CGFloat = 20 + + // Frame sizes (from existing usage) + static let minFrameHeight: CGFloat = 150 + static let maxFrameHeight: CGFloat = 150 + + // Stroke widths + static let strokeThin: CGFloat = 0.5 + static let strokeRegular: CGFloat = 1.0 + static let strokeMedium: CGFloat = 2.0 + + // Shadow radius + static let shadowSmall: CGFloat = 2 + static let shadowMedium: CGFloat = 3 + static let shadowLarge: CGFloat = 4 + static let shadowXLarge: CGFloat = 10 +} + +// MARK: - Layout Constants (from existing usage) +struct AppLayout { + // macOS specific + static let macOSMinWidth: CGFloat = 400 + static let macOSIdealWidth: CGFloat = 600 + static let macOSMaxWidth: CGFloat = 900 + static let macOSMinHeight: CGFloat = 300 + static let macOSIdealHeight: CGFloat = 500 + static let macOSMaxHeight: CGFloat = 800 + + // Content width limits + static let maxContentWidth: CGFloat = 800 + static let maxContentWidthLarge: CGFloat = 1000 + static let maxContentWidthXLarge: CGFloat = 1200 + + // Sheet sizes + static let sheetMinWidth: CGFloat = 500 + static let sheetIdealWidth: CGFloat = 600 + static let sheetMaxWidth: CGFloat = 700 + static let sheetMinHeight: CGFloat = 400 + static let sheetIdealHeight: CGFloat = 500 + static let sheetMaxHeight: CGFloat = 600 + + // Animation durations + static let animationFast: Double = 0.25 + static let animationRegular: Double = 0.3 + static let animationSlow: Double = 0.5 + static let animationVerySlow: Double = 0.6 + static let animationLoop: Double = 1.0 + static let animationLoopSlow: Double = 2.0 +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Core/DesignSystem/Typography.swift b/examples/ios/RunAnywhereAI/RunAnywhereAI/Core/DesignSystem/Typography.swift new file mode 100644 index 000000000..e1251e614 --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Core/DesignSystem/Typography.swift @@ -0,0 +1,62 @@ +// +// Typography.swift +// RunAnywhereAI +// +// Centralized typography from existing usage in the app +// + +import SwiftUI + +// MARK: - App Typography (gathered from existing usage) +struct AppTypography { + // MARK: - Existing font usage in the app + + // Large titles and displays + static let largeTitle = Font.largeTitle + static let title = Font.title + static let title2 = Font.title2 + static let title3 = Font.title3 + + // Headers + static let headline = Font.headline + static let subheadline = Font.subheadline + + // Body text + static let body = Font.body + static let callout = Font.callout + static let footnote = Font.footnote + + // Small text + static let caption = Font.caption + static let caption2 = Font.caption2 + + // Custom sizes (from existing usage) + static let system9 = Font.system(size: 9) + static let system10 = Font.system(size: 10) + static let system11 = Font.system(size: 11) + static let system12 = Font.system(size: 12) + static let system14 = Font.system(size: 14) + static let system18 = Font.system(size: 18) + static let system28 = Font.system(size: 28) + static let system48 = Font.system(size: 48) + static let system60 = Font.system(size: 60) + static let system80 = Font.system(size: 80) + + // With weights (from existing usage) + static let headlineSemibold = Font.headline.weight(.semibold) + static let subheadlineMedium = Font.subheadline.weight(.medium) + static let subheadlineSemibold = Font.subheadline.weight(.semibold) + static let captionMedium = Font.caption.weight(.medium) + static let caption2Medium = Font.caption2.weight(.medium) + static let caption2Bold = Font.caption2.weight(.bold) + static let titleBold = Font.title.weight(.bold) + static let title2Semibold = Font.title2.weight(.semibold) + static let title3Medium = Font.title3.weight(.medium) + static let largeTitleBold = Font.largeTitle.weight(.bold) + + // Design variants (from existing usage) + static let monospaced = Font.system(.body, design: .monospaced) + static let monospacedCaption = Font.system(size: 9, weight: .bold, design: .monospaced) + static let rounded10 = Font.system(size: 10, weight: .medium, design: .rounded) + static let rounded11 = Font.system(size: 11, weight: .medium, design: .rounded) +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Core/Models/AppTypes.swift b/examples/ios/RunAnywhereAI/RunAnywhereAI/Core/Models/AppTypes.swift new file mode 100644 index 000000000..efaf8bc76 --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Core/Models/AppTypes.swift @@ -0,0 +1,50 @@ +// +// AppTypes.swift +// RunAnywhereAI +// +// Essential app types - using SDK types directly +// + +import Foundation +import RunAnywhere + +// MARK: - System Device Info + +struct SystemDeviceInfo { + let modelName: String + let chipName: String + let totalMemory: Int64 + let availableMemory: Int64 + let neuralEngineAvailable: Bool + let osVersion: String + let appVersion: String + + init( + modelName: String = "", + chipName: String = "", + totalMemory: Int64 = 0, + availableMemory: Int64 = 0, + neuralEngineAvailable: Bool = false, + osVersion: String = "", + appVersion: String = "" + ) { + self.modelName = modelName + self.chipName = chipName + self.totalMemory = totalMemory + self.availableMemory = availableMemory + self.neuralEngineAvailable = neuralEngineAvailable + self.osVersion = osVersion + self.appVersion = appVersion + } +} + +// MARK: - Helper Extensions + +extension Int64 { + var formattedFileSize: String { + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useBytes, .useKB, .useMB, .useGB] + formatter.countStyle = .file + return formatter.string(fromByteCount: self) + } +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Core/Models/MarkdownDetector.swift b/examples/ios/RunAnywhereAI/RunAnywhereAI/Core/Models/MarkdownDetector.swift new file mode 100644 index 000000000..99c3a5cb8 --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Core/Models/MarkdownDetector.swift @@ -0,0 +1,126 @@ +// +// MarkdownDetector.swift +// RunAnywhereAI +// +// Content-based markdown detection and rendering strategy +// + +import Foundation + +// MARK: - Markdown Detector + +/// Intelligently detects markdown usage in content and recommends rendering strategy +class MarkdownDetector { + static let shared = MarkdownDetector() + + /// Analyze content and determine the best rendering strategy + func detectRenderingStrategy(from content: String) -> RenderingStrategy { + let analysis = analyzeContent(content) + + // Decision tree based on content analysis + if analysis.hasCodeBlocks { + // Rich rendering with code block extraction + return .rich + } else if analysis.hasRichMarkdown { + // Basic markdown parsing (bold, italic, headings) + return .basic + } else if analysis.hasMinimalMarkdown { + // Light markdown (just bold/italic) + return .light + } else { + // Plain text + return .plain + } + } + + /// Analyze content for markdown patterns + private func analyzeContent(_ content: String) -> ContentAnalysis { + var analysis = ContentAnalysis() + + // Detect code blocks (```language) + analysis.hasCodeBlocks = content.contains("```") + + // Detect headings (#### text) - must be 1-6 # followed by space + let headingCount = content.components(separatedBy: .newlines) + .filter { line in + // Trim leading spaces + let trimmed = line.trimmingCharacters(in: .whitespaces) + // Must start with 1-6 # characters followed by a space + guard trimmed.hasPrefix("#") else { return false } + let hashes = trimmed.prefix { $0 == "#" } + return hashes.count >= 1 && hashes.count <= 6 + && trimmed.count > hashes.count + && trimmed[trimmed.index(trimmed.startIndex, offsetBy: hashes.count)] == " " + }.count + analysis.headingCount = headingCount + + // Detect bold (**text**) - use regex to match proper pairs + let boldPattern = "\\*\\*[^*]+\\*\\*" + let boldRegex = try? NSRegularExpression(pattern: boldPattern) + let boldMatches = boldRegex?.matches(in: content, range: NSRange(content.startIndex..., in: content)) ?? [] + analysis.boldCount = boldMatches.count + + // Detect inline code (`code`) - use regex to match proper pairs + let codePattern = "`[^`]+`" + let codeRegex = try? NSRegularExpression(pattern: codePattern) + let codeMatches = codeRegex?.matches(in: content, range: NSRange(content.startIndex..., in: content)) ?? [] + analysis.inlineCodeCount = codeMatches.count + + // Detect lists (only count lines that start with list markers after trimming leading spaces) + let listCount = content.components(separatedBy: .newlines) + .filter { line in + let trimmed = line.trimmingCharacters(in: .whitespaces) + return trimmed.hasPrefix("- ") || trimmed.hasPrefix("* ") || + trimmed.range(of: "^\\d+\\.\\s", options: .regularExpression) != nil + }.count + analysis.listCount = listCount + + // Calculate markdown richness + let markdownScore = Double(analysis.headingCount) * 0.5 + + Double(analysis.boldCount) * 0.3 + + Double(analysis.inlineCodeCount) * 0.2 + + Double(analysis.listCount) * 0.3 + + // Classify based on score + if markdownScore > 3.0 { + analysis.hasRichMarkdown = true + } else if markdownScore > 1.0 { + analysis.hasMinimalMarkdown = true + } + + return analysis + } +} + +// MARK: - Content Analysis + +struct ContentAnalysis { + var hasCodeBlocks: Bool = false + var hasRichMarkdown: Bool = false + var hasMinimalMarkdown: Bool = false + var headingCount: Int = 0 + var boldCount: Int = 0 + var inlineCodeCount: Int = 0 + var listCount: Int = 0 +} + +// MARK: - Rendering Strategy + +enum RenderingStrategy { + case rich // Full markdown with code blocks + case basic // Standard markdown (headings, bold, italic, inline code) + case light // Minimal markdown (just bold/italic) + case plain // No markdown processing + + var shouldExtractCodeBlocks: Bool { + self == .rich + } + + var shouldParseMarkdown: Bool { + self != .plain + } + + var shouldStyleHeadings: Bool { + self == .rich || self == .basic + } +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Core/Services/ConversationStore.swift b/examples/ios/RunAnywhereAI/RunAnywhereAI/Core/Services/ConversationStore.swift new file mode 100644 index 000000000..cd285e69c --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Core/Services/ConversationStore.swift @@ -0,0 +1,589 @@ +import Foundation +import SwiftUI +import RunAnywhere +#if canImport(FoundationModels) +import FoundationModels +#endif + +// Note: Message, MessageAnalytics and ConversationAnalytics are now in separate model files + +// MARK: - Conversation Store + +@MainActor +class ConversationStore: ObservableObject { + static let shared = ConversationStore() + + @Published var conversations: [Conversation] = [] + @Published var currentConversation: Conversation? + + private let documentsDirectory: URL + + private static func getDocumentsDirectory() -> URL { + guard let url = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { + fatalError("Unable to access documents directory") + } + return url + } + private let conversationsDirectory: URL + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + private init() { + documentsDirectory = Self.getDocumentsDirectory() + conversationsDirectory = documentsDirectory.appendingPathComponent("Conversations") + + // Create conversations directory if it doesn't exist + try? FileManager.default.createDirectory(at: conversationsDirectory, withIntermediateDirectories: true) + + // Set up encoder/decoder + encoder.outputFormatting = .prettyPrinted + encoder.dateEncodingStrategy = .iso8601 + decoder.dateDecodingStrategy = .iso8601 + + // Load existing conversations + loadConversations() + } + + // MARK: - Public Methods + + func createConversation(title: String? = nil) -> Conversation { + let conversation = Conversation( + id: UUID().uuidString, + title: title ?? "New Chat", + createdAt: Date(), + updatedAt: Date(), + messages: [], + modelName: nil, + frameworkName: nil + ) + + // Don't add to conversations list yet - wait until first message is added + currentConversation = conversation + // Don't save empty conversation - wait until first message is added + + return conversation + } + + func updateConversation(_ conversation: Conversation) { + var updated = conversation + updated.updatedAt = Date() + + if let index = conversations.firstIndex(where: { $0.id == conversation.id }) { + // Update existing conversation + conversations[index] = updated + } else { + // First time adding this conversation (when first message is sent) + conversations.insert(updated, at: 0) + } + + if currentConversation?.id == conversation.id { + currentConversation = updated + } + + saveConversation(updated) + } + + func deleteConversation(_ conversation: Conversation) { + conversations.removeAll { $0.id == conversation.id } + + if currentConversation?.id == conversation.id { + currentConversation = conversations.first + } + + // Delete file + let fileURL = conversationFileURL(for: conversation.id) + try? FileManager.default.removeItem(at: fileURL) + } + + func addMessage(_ message: Message, to conversation: Conversation) { + var updated = conversation + updated.messages.append(message) + updated.updatedAt = Date() + + // Always try to generate a fallback title if still "New Chat" + if updated.title == "New Chat" { + if let firstUserMessage = updated.messages.first(where: { $0.role == .user }), + !firstUserMessage.content.isEmpty { + updated.title = generateTitle(from: firstUserMessage.content) + } + } + + updateConversation(updated) + + // Try to generate smart title with Foundation Models after first AI response + if message.role == .assistant && updated.messages.count >= 2 { + let conversationId = updated.id + Task { @MainActor in + await self.generateSmartTitleIfNeeded(for: conversationId) + } + } + } + + // MARK: - Foundation Models Title Generation + + /// Public method to generate smart title for a conversation + func generateSmartTitleForConversation(_ conversationId: String) async { + await generateSmartTitleIfNeeded(for: conversationId) + } + + private func generateSmartTitleIfNeeded(for conversationId: String) async { + #if canImport(FoundationModels) + guard #available(iOS 26.0, macOS 26.0, *) else { return } + + // Find the conversation + guard let conversation = conversations.first(where: { $0.id == conversationId }) else { + return + } + + // Get the fallback title to compare + let fallbackTitle = conversation.messages.first(where: { $0.role == .user }) + .map { generateTitle(from: $0.content) } ?? "New Chat" + + // Only generate if title is still the default or fallback + let currentTitle = conversation.title + guard currentTitle == "New Chat" || currentTitle == fallbackTitle else { + return + } + + // Check if Foundation Models is available + guard SystemLanguageModel.default.isAvailable else { return } + + // Create conversation text from first few messages + let conversationText = conversation.messages.prefix(4).map { msg in + "\(msg.role == .user ? "User" : "Assistant"): \(msg.content.prefix(200))" + }.joined(separator: "\n") + + do { + let titleSession = LanguageModelSession( + instructions: Instructions(""" + You are an expert at creating descriptive, readable chat titles. + Generate a clear title (2-5 words) that captures the main topic. + Respond in the same language as the conversation. + Only output the title, nothing else. + """) + ) + + let titlePrompt = """ + Create a descriptive, readable title for this conversation: + + \(conversationText) + + Title: + """ + + let response = try await titleSession.respond(to: Prompt(titlePrompt)) + let title = response.content + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "\"", with: "") + + // Update the conversation with the AI-generated title + if !title.isEmpty, var conv = self.conversations.first(where: { $0.id == conversationId }) { + conv.title = String(title.prefix(50)) + self.updateConversation(conv) + } + } catch { + // Keep the fallback title + } + #endif + } + + func loadConversation(_ id: String) -> Conversation? { + if let conversation = conversations.first(where: { $0.id == id }) { + currentConversation = conversation + return conversation + } + + // Try to load from disk + let fileURL = conversationFileURL(for: id) + if let data = try? Data(contentsOf: fileURL), + let conversation = try? decoder.decode(Conversation.self, from: data) { + conversations.append(conversation) + currentConversation = conversation + return conversation + } + + return nil + } + + // MARK: - Search + + func searchConversations(query: String) -> [Conversation] { + guard !query.isEmpty else { return conversations } + + let lowercasedQuery = query.lowercased() + + return conversations.filter { conversation in + // Search in title + if conversation.title.lowercased().contains(lowercasedQuery) { + return true + } + + // Search in messages + return conversation.messages.contains { message in + message.content.lowercased().contains(lowercasedQuery) + } + } + } + + // MARK: - Private Methods + + private func loadConversations() { + do { + let files = try FileManager.default.contentsOfDirectory( + at: conversationsDirectory, + includingPropertiesForKeys: [.contentModificationDateKey] + ) + + var loadedConversations: [Conversation] = [] + + for file in files where file.pathExtension == "json" { + if let data = try? Data(contentsOf: file), + let conversation = try? decoder.decode(Conversation.self, from: data) { + loadedConversations.append(conversation) + } + } + + // Sort by update date, newest first + conversations = loadedConversations.sorted { $0.updatedAt > $1.updatedAt } + + // Don't automatically set current conversation - let ChatViewModel create a new one + } catch { + print("Error loading conversations: \(error)") + } + } + + private func saveConversation(_ conversation: Conversation) { + let fileURL = conversationFileURL(for: conversation.id) + + do { + let data = try encoder.encode(conversation) + try data.write(to: fileURL) + } catch { + print("Error saving conversation: \(error)") + } + } + + private func conversationFileURL(for id: String) -> URL { + conversationsDirectory.appendingPathComponent("\(id).json") + } + + private func generateTitle(from content: String) -> String { + // Take first 50 characters or up to first newline + let maxLength = 50 + let cleaned = content.trimmingCharacters(in: .whitespacesAndNewlines) + + if let newlineIndex = cleaned.firstIndex(of: "\n") { + let firstLine = String(cleaned[.. String? { + // Skip if title already matches + if conversation.title.localizedCaseInsensitiveContains(searchQuery) { + return nil + } + + // Search in messages + for message in conversation.messages { + if message.content.localizedCaseInsensitiveContains(searchQuery) { + return createPreview(from: message.content, searchText: searchQuery) + } + } + + return nil + } + + // Create preview with context around search term + private func createPreview(from text: String, searchText: String) -> String { + guard let range = text.range(of: searchText, options: .caseInsensitive) else { + return String(text.prefix(100)) + } + + let beforeContext = 30 + let afterContext = 30 + + let startIndex = text.distance(from: text.startIndex, to: range.lowerBound) + let previewStart = max(0, startIndex - beforeContext) + let previewEnd = min(text.count, startIndex + searchText.count + afterContext) + + let start = text.index(text.startIndex, offsetBy: previewStart) + let end = text.index(text.startIndex, offsetBy: previewEnd) + + var preview = String(text[start.. 0 { + preview = "..." + preview + } + if previewEnd < text.count { + preview = preview + "..." + } + + return preview + } + + // Highlighted text view + private func highlightedText(_ text: String, searchText: String, isTitle: Bool) -> Text { + guard let range = text.range(of: searchText, options: .caseInsensitive) else { + return Text(text) + .font(isTitle ? .headline : .subheadline) + .foregroundColor(isTitle ? .primary : .secondary) + } + + let beforeText = String(text[.. String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return formatter.localizedString(for: date, relativeTo: Date()) + } +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Core/Services/DeviceInfoService.swift b/examples/ios/RunAnywhereAI/RunAnywhereAI/Core/Services/DeviceInfoService.swift new file mode 100644 index 000000000..e0e2f10ca --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Core/Services/DeviceInfoService.swift @@ -0,0 +1,173 @@ +// +// DeviceInfoService.swift +// RunAnywhereAI +// +// Service for retrieving device information and capabilities +// + +import Foundation +#if canImport(UIKit) +import UIKit +#else +import AppKit +#endif +import RunAnywhere + +@MainActor +class DeviceInfoService: ObservableObject { + static let shared = DeviceInfoService() + + @Published var deviceInfo: SystemDeviceInfo? + @Published var isLoading = false + + private init() { + Task { + await refreshDeviceInfo() + } + } + + // MARK: - Device Info Methods + + func refreshDeviceInfo() async { + isLoading = true + defer { isLoading = false } + + // Get device information from SDK and system + let modelName = await getDeviceModelName() + let chipName = await getChipName() + let (totalMemory, availableMemory) = await getMemoryInfo() + let neuralEngineAvailable = await isNeuralEngineAvailable() + #if os(iOS) || os(tvOS) + let osVersion = UIDevice.current.systemVersion + #else + let osVersion = ProcessInfo.processInfo.operatingSystemVersionString + #endif + let appVersion = getAppVersion() + + deviceInfo = SystemDeviceInfo( + modelName: modelName, + chipName: chipName, + totalMemory: totalMemory, + availableMemory: availableMemory, + neuralEngineAvailable: neuralEngineAvailable, + osVersion: osVersion, + appVersion: appVersion + ) + } + + // MARK: - Private Helper Methods + + private func getDeviceModelName() async -> String { + // Use system info directly since SDK methods are private + var systemInfo = utsname() + uname(&systemInfo) + let machineMirror = Mirror(reflecting: systemInfo.machine) + let identifier = machineMirror.children.reduce("") { identifier, element in + guard let value = element.value as? Int8, value != 0 else { return identifier } + let unicodeScalar = UnicodeScalar(UInt8(value)) + return identifier + String(unicodeScalar) + } + + #if os(iOS) || os(tvOS) + return identifier.isEmpty ? UIDevice.current.model : identifier + #elseif os(macOS) + return getMacModelName() + #else + return identifier.isEmpty ? "Mac" : identifier + #endif + } + + #if os(macOS) + private func getMacModelName() -> String { + var size = 0 + sysctlbyname("hw.model", nil, &size, nil, 0) + var model = [CChar](repeating: 0, count: size) + sysctlbyname("hw.model", &model, &size, nil, 0) + let modelIdentifier = String(cString: model) + + // Map common model identifiers to friendly names + let friendlyNames: [String: String] = [ + "Mac14,2": "MacBook Air (M2, 2022)", + "Mac14,5": "MacBook Pro 14\" (M2 Pro, 2023)", + "Mac14,6": "MacBook Pro 16\" (M2 Pro/Max, 2023)", + "Mac14,7": "MacBook Pro 13\" (M2, 2022)", + "Mac14,9": "MacBook Pro 14\" (M2 Pro/Max, 2023)", + "Mac14,10": "MacBook Pro 16\" (M2 Pro/Max, 2023)", + "Mac14,12": "Mac mini (M2, 2023)", + "Mac14,13": "Mac Studio (M2 Max, 2023)", + "Mac14,14": "Mac Studio (M2 Ultra, 2023)", + "Mac14,15": "MacBook Air 15\" (M2, 2023)", + "Mac15,3": "MacBook Pro 14\" (M3, 2023)", + "Mac15,4": "iMac 24\" (M3, 2023)", + "Mac15,5": "iMac 24\" (M3, 2023)", + "Mac15,6": "MacBook Pro 14\" (M3 Pro/Max, 2023)", + "Mac15,7": "MacBook Pro 16\" (M3 Pro/Max, 2023)", + "Mac15,8": "MacBook Pro 14\" (M3 Pro/Max, 2023)", + "Mac15,9": "MacBook Pro 16\" (M3 Pro/Max, 2023)", + "Mac15,10": "MacBook Pro 14\" (M3 Pro/Max, 2023)", + "Mac15,11": "MacBook Pro 16\" (M3 Pro/Max, 2023)", + "Mac15,12": "MacBook Air 13\" (M3, 2024)", + "Mac15,13": "MacBook Air 15\" (M3, 2024)", + "Mac16,1": "MacBook Pro 14\" (M4, 2024)", + "Mac16,5": "iMac 24\" (M4, 2024)", + "Mac16,6": "MacBook Pro 14\" (M4 Pro/Max, 2024)", + "Mac16,7": "MacBook Pro 16\" (M4 Pro/Max, 2024)", + "Mac16,8": "Mac mini (M4, 2024)", + "Mac16,10": "Mac mini (M4 Pro, 2024)" + ] + + return friendlyNames[modelIdentifier] ?? "Mac (\(modelIdentifier))" + } + #endif + + private func getChipName() async -> String { + #if os(macOS) + // Get chip brand string from sysctl + var size = 0 + sysctlbyname("machdep.cpu.brand_string", nil, &size, nil, 0) + var brand = [CChar](repeating: 0, count: size) + sysctlbyname("machdep.cpu.brand_string", &brand, &size, nil, 0) + let brandString = String(cString: brand) + + if !brandString.isEmpty { + return brandString + } + + // Fallback to model-based detection + let modelName = getMacModelName() + if modelName.contains("M4") { return "Apple M4" } + if modelName.contains("M3") { return "Apple M3" } + if modelName.contains("M2") { return "Apple M2" } + if modelName.contains("M1") { return "Apple M1" } + return "Apple Silicon" + #else + // iOS/tvOS detection + let modelName = await getDeviceModelName() + if modelName.contains("arm64") || modelName.contains("iPhone") || modelName.contains("iPad") { + return "Apple Silicon" + } + return "Unknown" + #endif + } + + private func getMemoryInfo() async -> (total: Int64, available: Int64) { + // Direct memory detection since SDK properties are private + + // Fallback to system info + let totalMemory = ProcessInfo.processInfo.physicalMemory + let availableMemory = totalMemory / 2 // Rough estimate + + return (Int64(totalMemory), Int64(availableMemory)) + } + + private func isNeuralEngineAvailable() async -> Bool { + // Direct neural engine detection since SDK properties are private + + // Fallback - assume true for modern devices + true + } + + private func getAppVersion() -> String { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0" + } +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Core/Services/KeychainService.swift b/examples/ios/RunAnywhereAI/RunAnywhereAI/Core/Services/KeychainService.swift new file mode 100644 index 000000000..ca170763c --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Core/Services/KeychainService.swift @@ -0,0 +1,87 @@ +// +// KeychainService.swift +// RunAnywhereAI +// +// Secure storage for API credentials +// + +import Foundation + +// MARK: - Keychain Service + +class KeychainService { + static let shared = KeychainService() + + private init() {} + + func save(key: String, data: Data) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecValueData as String: data + ] + + // Delete existing item + SecItemDelete(query as CFDictionary) + + // Add new item + let status = SecItemAdd(query as CFDictionary, nil) + guard status == errSecSuccess else { + throw KeychainError.saveFailed + } + } + + func read(key: String) -> Data? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var dataTypeRef: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &dataTypeRef) + + if status == errSecSuccess { + return dataTypeRef as? Data + } + return nil + } + + func retrieve(key: String) throws -> Data? { + read(key: key) + } + + func delete(key: String) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key + ] + + let status = SecItemDelete(query as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw KeychainError.deleteFailed + } + } + + // MARK: - Boolean Helpers + + /// Save a boolean value to keychain + func saveBool(key: String, value: Bool) throws { + let data = Data([value ? 1 : 0]) + try save(key: key, data: data) + } + + /// Load a boolean value from keychain + func loadBool(key: String, defaultValue: Bool = false) -> Bool { + guard let data = read(key: key) else { + return defaultValue + } + return data.first == 1 + } +} + +enum KeychainError: Error { + case saveFailed + case deleteFailed +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Core/Services/ModelManager.swift b/examples/ios/RunAnywhereAI/RunAnywhereAI/Core/Services/ModelManager.swift new file mode 100644 index 000000000..13da0462e --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Core/Services/ModelManager.swift @@ -0,0 +1,65 @@ +// +// ModelManager.swift +// RunAnywhereAI +// +// Service for managing model loading and lifecycle +// + +import Foundation +import RunAnywhere + +@MainActor +class ModelManager: ObservableObject { + static let shared = ModelManager() + + @Published var isLoading = false + @Published var error: Error? + + private init() {} + + // MARK: - Model Operations + + func loadModel(_ modelInfo: ModelInfo) async throws { + isLoading = true + defer { isLoading = false } + + do { + // Use SDK's model loading with new API + try await RunAnywhere.loadModel(modelInfo.id) + } catch { + self.error = error + throw error + } + } + + func unloadCurrentModel() async { + isLoading = true + defer { isLoading = false } + + // Use SDK's model unloading with new API + do { + try await RunAnywhere.unloadModel() + } catch { + self.error = error + print("Failed to unload model: \(error)") + } + } + + func getAvailableModels() async -> [ModelInfo] { + do { + return try await RunAnywhere.availableModels() + } catch { + print("Failed to get available models: \(error)") + return [] + } + } + + func getCurrentModel() async -> ModelInfo? { + // Get current model ID from SDK and look up the model info + guard let modelId = await RunAnywhere.getCurrentModelId() else { + return nil + } + let models = await getAvailableModels() + return models.first { $0.id == modelId } + } +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Extensions/ModelInfo+Logo.swift b/examples/ios/RunAnywhereAI/RunAnywhereAI/Extensions/ModelInfo+Logo.swift new file mode 100644 index 000000000..ebe0438f4 --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Extensions/ModelInfo+Logo.swift @@ -0,0 +1,41 @@ +// +// ModelInfo+Logo.swift +// RunAnywhereAI +// +// Model logo asset name mapping extension +// + +import RunAnywhere + +extension ModelInfo { + /// Returns the asset name for the model's logo + /// Falls back to Hugging Face logo if no specific logo is available + var logoAssetName: String { + let modelName = name.lowercased() + + // Check framework first for built-in models + if framework == .foundationModels || framework == .systemTTS { + return "foundation_models_logo" + } + + // Check for vendor-specific logos + if modelName.contains("llama") { + return "llama_logo" + } else if modelName.contains("mistral") { + return "mistral_logo" + } else if modelName.contains("qwen") { + return "qwen_logo" + } else if modelName.contains("liquid") { + return "liquid_ai_logo" + } else if modelName.contains("piper") { + return "hugging_face_logo" + } else if modelName.contains("whisper") { + return "hugging_face_logo" + } else if modelName.contains("sherpa") { + return "hugging_face_logo" + } + + // Default fallback for all other models + return "hugging_face_logo" + } +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Extensions/String+Markdown.swift b/examples/ios/RunAnywhereAI/RunAnywhereAI/Extensions/String+Markdown.swift new file mode 100644 index 000000000..05143f79e --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Extensions/String+Markdown.swift @@ -0,0 +1,117 @@ +// +// String+Markdown.swift +// RunAnywhereAI +// +// Extension to strip markdown formatting for TTS +// + +import Foundation + +extension String { + /// Looks up the proper model name from ModelListViewModel if this is a model ID + @MainActor + func modelNameFromID() -> String { + // Try to find the model in the available models list + if let model = ModelListViewModel.shared.availableModels.first(where: { $0.id == self }) { + return model.name + } + + // If not found, return as-is (might already be a proper name) + return self + } + + /// Shortens model name by removing parenthetical info and limiting length + @MainActor + func shortModelName(maxLength: Int = 15) -> String { + // First look up the proper name if this is an ID + let displayName = self.modelNameFromID() + + // Remove content in parentheses + var cleaned = displayName.replacingOccurrences( + of: "\\s*\\([^)]*\\)", + with: "", + options: .regularExpression + ).trimmingCharacters(in: .whitespaces) + + // If still too long, truncate and add ellipsis + if cleaned.count > maxLength { + cleaned = String(cleaned.prefix(maxLength - 1)) + "…" + } + + return cleaned + } + + /// Remove markdown formatting for clean text-to-speech + /// Removes: **, *, _, `, ##, code blocks, etc. + func strippingMarkdown() -> String { + var text = self + + // Remove code blocks (```...```) + text = text.replacingOccurrences( + of: "```[^`]*```", + with: "", + options: .regularExpression + ) + + // Remove inline code (`...`) + text = text.replacingOccurrences( + of: "`([^`]+)`", + with: "$1", + options: .regularExpression + ) + + // Remove bold (**text** or __text__) + text = text.replacingOccurrences( + of: "\\*\\*([^*]+)\\*\\*", + with: "$1", + options: .regularExpression + ) + text = text.replacingOccurrences( + of: "__([^_]+)__", + with: "$1", + options: .regularExpression + ) + + // Remove italic (*text* or _text_) + text = text.replacingOccurrences( + of: "\\*([^*]+)\\*", + with: "$1", + options: .regularExpression + ) + text = text.replacingOccurrences( + of: "_([^_]+)_", + with: "$1", + options: .regularExpression + ) + + // Remove headings (# ## ### etc) + text = text.replacingOccurrences( + of: "^#{1,6}\\s+", + with: "", + options: .regularExpression + ) + + // Remove links [text](url) -> text + text = text.replacingOccurrences( + of: "\\[([^\\]]+)\\]\\([^)]+\\)", + with: "$1", + options: .regularExpression + ) + + // Remove images ![alt](url) + text = text.replacingOccurrences( + of: "!\\[[^\\]]*\\]\\([^)]+\\)", + with: "", + options: .regularExpression + ) + + // Clean up multiple spaces + text = text.replacingOccurrences( + of: "\\s+", + with: " ", + options: .regularExpression + ) + + return text.trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Chat/Models/Message.swift b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Chat/Models/Message.swift new file mode 100644 index 000000000..b4417b4e3 --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Chat/Models/Message.swift @@ -0,0 +1,59 @@ +// +// Message.swift +// RunAnywhereAI +// +// Message models for chat functionality +// + +import Foundation +import RunAnywhere + +// MARK: - Message Model + +public struct Message: Identifiable, Codable, Sendable { + public let id: UUID + public let role: Role + public let content: String + public let thinkingContent: String? + public let timestamp: Date + public let analytics: MessageAnalytics? + public let modelInfo: MessageModelInfo? + + public enum Role: String, Codable, Sendable { + case system + case user + case assistant + } + + public init( + id: UUID = UUID(), + role: Role, + content: String, + thinkingContent: String? = nil, + timestamp: Date = Date(), + analytics: MessageAnalytics? = nil, + modelInfo: MessageModelInfo? = nil + ) { + self.id = id + self.role = role + self.content = content + self.thinkingContent = thinkingContent + self.timestamp = timestamp + self.analytics = analytics + self.modelInfo = modelInfo + } +} + +// MARK: - Message Model Info + +public struct MessageModelInfo: Codable, Sendable { + public let modelId: String + public let modelName: String + public let framework: String + + public init(from modelInfo: ModelInfo) { + self.modelId = modelInfo.id + self.modelName = modelInfo.name + self.framework = modelInfo.framework.rawValue + } +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Chat/Models/MessageAnalytics.swift b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Chat/Models/MessageAnalytics.swift new file mode 100644 index 000000000..b919da1da --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Chat/Models/MessageAnalytics.swift @@ -0,0 +1,98 @@ +// +// MessageAnalytics.swift +// RunAnywhereAI +// +// Analytics models for message tracking +// + +import Foundation + +// MARK: - Message Analytics + +public struct MessageAnalytics: Codable, Sendable { + // Identifiers + let messageId: String + let conversationId: String + let modelId: String + let modelName: String + let framework: String + let timestamp: Date + + // Timing Metrics + let timeToFirstToken: TimeInterval? + let totalGenerationTime: TimeInterval + let thinkingTime: TimeInterval? + let responseTime: TimeInterval? + + // Token Metrics + let inputTokens: Int + let outputTokens: Int + let thinkingTokens: Int? + let responseTokens: Int + let averageTokensPerSecond: Double + + // Quality Metrics + let messageLength: Int + let wasThinkingMode: Bool + let wasInterrupted: Bool + let retryCount: Int + let completionStatus: CompletionStatus + + // Performance Indicators + let tokensPerSecondHistory: [Double] + let generationMode: GenerationMode + + // Context Information + let contextWindowUsage: Double + let generationParameters: GenerationParameters + + public enum CompletionStatus: String, Codable, Sendable { + case complete + case interrupted + case failed + case timeout + } + + public enum GenerationMode: String, Codable, Sendable { + case streaming + case nonStreaming + } + + public struct GenerationParameters: Codable, Sendable { + let temperature: Double + let maxTokens: Int + let topP: Double? + let topK: Int? + + init(temperature: Double = 0.7, maxTokens: Int = 500, topP: Double? = nil, topK: Int? = nil) { + self.temperature = temperature + self.maxTokens = maxTokens + self.topP = topP + self.topK = topK + } + } +} + +// MARK: - Conversation Analytics + +public struct ConversationAnalytics: Codable, Sendable { + let conversationId: String + let startTime: Date + let endTime: Date? + let messageCount: Int + + // Aggregate Metrics + let averageTTFT: TimeInterval + let averageGenerationSpeed: Double + let totalTokensUsed: Int + let modelsUsed: Set + + // Efficiency Metrics + let thinkingModeUsage: Double + let completionRate: Double + let averageMessageLength: Int + + // Real-time Metrics + let currentModel: String? + let ongoingMetrics: MessageAnalytics? +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Chat/ViewModels/LLMViewModel+Analytics.swift b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Chat/ViewModels/LLMViewModel+Analytics.swift new file mode 100644 index 000000000..d65f983ff --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Chat/ViewModels/LLMViewModel+Analytics.swift @@ -0,0 +1,139 @@ +// +// LLMViewModel+Analytics.swift +// RunAnywhereAI +// +// Analytics-related functionality for LLMViewModel +// + +import Foundation +import RunAnywhere + +extension LLMViewModel { + // MARK: - Analytics Creation + + func createAnalytics( + from result: LLMGenerationResult, + messageId: String, + conversationId: String, + wasInterrupted: Bool, + options: LLMGenerationOptions + ) -> MessageAnalytics? { + guard let modelName = loadedModelName, + let currentModel = ModelListViewModel.shared.currentModel else { + return nil + } + + return buildMessageAnalytics( + result: result, + messageId: messageId, + conversationId: conversationId, + modelName: modelName, + currentModel: currentModel, + wasInterrupted: wasInterrupted, + options: options + ) + } + + // swiftlint:disable:next function_parameter_count + func buildMessageAnalytics( + result: LLMGenerationResult, + messageId: String, + conversationId: String, + modelName: String, + currentModel: ModelInfo, + wasInterrupted: Bool, + options: LLMGenerationOptions + ) -> MessageAnalytics { + let completionStatus: MessageAnalytics.CompletionStatus = wasInterrupted ? .interrupted : .complete + let generationParameters = MessageAnalytics.GenerationParameters( + temperature: Double(options.temperature ?? Float(LLMViewModel.defaultTemperatureValue)), + maxTokens: options.maxTokens ?? LLMViewModel.defaultMaxTokensValue, + topP: nil, + topK: nil + ) + + return MessageAnalytics( + messageId: messageId, + conversationId: conversationId, + modelId: currentModel.id, + modelName: modelName, + framework: result.framework ?? currentModel.framework.rawValue, + timestamp: Date(), + timeToFirstToken: nil, + totalGenerationTime: result.latencyMs / 1000.0, + thinkingTime: nil, + responseTime: nil, + inputTokens: result.inputTokens, + outputTokens: result.tokensUsed, + thinkingTokens: result.thinkingTokens, + responseTokens: result.responseTokens, + averageTokensPerSecond: result.tokensPerSecond, + messageLength: result.text.count, + wasThinkingMode: result.thinkingContent != nil, + wasInterrupted: wasInterrupted, + retryCount: 0, + completionStatus: completionStatus, + tokensPerSecondHistory: [], + generationMode: .nonStreaming, + contextWindowUsage: 0.0, + generationParameters: generationParameters + ) + } + + // MARK: - Conversation Analytics + + func updateConversationAnalytics() { + guard let conversation = currentConversation else { return } + + let analyticsMessages = messages.compactMap { $0.analytics } + + guard !analyticsMessages.isEmpty else { return } + + let conversationAnalytics = computeConversationAnalytics( + conversation: conversation, + analyticsMessages: analyticsMessages + ) + + var updatedConversation = conversation + updatedConversation.analytics = conversationAnalytics + updatedConversation.performanceSummary = PerformanceSummary(from: messages) + conversationStore.updateConversation(updatedConversation) + } + + private func computeConversationAnalytics( + conversation: Conversation, + analyticsMessages: [MessageAnalytics] + ) -> ConversationAnalytics { + let count = Double(analyticsMessages.count) + let ttftSum = analyticsMessages.compactMap { $0.timeToFirstToken }.reduce(0, +) + let averageTTFT = ttftSum / count + let speedSum = analyticsMessages.map { $0.averageTokensPerSecond }.reduce(0, +) + let averageGenerationSpeed = speedSum / count + let totalTokensUsed = analyticsMessages.reduce(0) { $0 + $1.inputTokens + $1.outputTokens } + let modelsUsed = Set(analyticsMessages.map { $0.modelName }) + + let thinkingMessages = analyticsMessages.filter { $0.wasThinkingMode } + let thinkingModeUsage = Double(thinkingMessages.count) / count + + let completedMessages = analyticsMessages.filter { $0.completionStatus == .complete } + let completionRate = Double(completedMessages.count) / count + + let averageMessageLength = analyticsMessages.reduce(0) { $0 + $1.messageLength } / analyticsMessages.count + + return ConversationAnalytics( + conversationId: conversation.id, + startTime: conversation.createdAt, + endTime: Date(), + messageCount: messages.count, + averageTTFT: averageTTFT, + averageGenerationSpeed: averageGenerationSpeed, + totalTokensUsed: totalTokensUsed, + modelsUsed: modelsUsed, + thinkingModeUsage: thinkingModeUsage, + completionRate: completionRate, + averageMessageLength: averageMessageLength, + currentModel: loadedModelName, + ongoingMetrics: nil + ) + } +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Chat/ViewModels/LLMViewModel+Events.swift b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Chat/ViewModels/LLMViewModel+Events.swift new file mode 100644 index 000000000..95c5de56d --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Chat/ViewModels/LLMViewModel+Events.swift @@ -0,0 +1,132 @@ +// +// LLMViewModel+Events.swift +// RunAnywhereAI +// +// Event handling functionality for LLMViewModel +// + +import Foundation +import Combine +import RunAnywhere + +extension LLMViewModel { + // MARK: - Model Lifecycle Subscription + + func subscribeToModelLifecycle() { + lifecycleCancellable = RunAnywhere.events.events + .receive(on: DispatchQueue.main) + .sink { [weak self] event in + guard let self = self else { return } + Task { @MainActor in + self.handleSDKEvent(event) + } + } + + Task { @MainActor in + try? await Task.sleep(nanoseconds: 100_000_000) + await checkModelStatusFromSDK() + } + } + + func checkModelStatusFromSDK() async { + let isLoaded = await RunAnywhere.isModelLoaded + let modelId = await RunAnywhere.getCurrentModelId() + + await MainActor.run { + self.updateModelLoadedState(isLoaded: isLoaded) + if let id = modelId, + let matchingModel = ModelListViewModel.shared.availableModels.first(where: { $0.id == id }) { + self.updateLoadedModelInfo(name: matchingModel.name, framework: matchingModel.framework) + } + } + } + + // MARK: - SDK Event Handling + + func handleSDKEvent(_ event: any SDKEvent) { + // Events now come from C++ via generic BridgedEvent + guard event.category == .llm else { return } + + let modelId = event.properties["model_id"] ?? "" + let generationId = event.properties["generation_id"] ?? "" + + switch event.type { + case "llm_model_load_completed": + handleModelLoadCompleted(modelId: modelId) + + case "llm_model_unloaded": + handleModelUnloaded(modelId: modelId) + + case "llm_model_load_started": + break + + case "llm_first_token": + let ttft = Double(event.properties["time_to_first_token_ms"] ?? "0") ?? 0 + handleFirstToken(generationId: generationId, timeToFirstTokenMs: ttft) + + case "llm_generation_completed": + let inputTokens = Int(event.properties["input_tokens"] ?? "0") ?? 0 + let outputTokens = Int(event.properties["output_tokens"] ?? "0") ?? 0 + let durationMs = Double(event.properties["processing_time_ms"] ?? "0") ?? 0 + let tps = Double(event.properties["tokens_per_second"] ?? "0") ?? 0 + handleGenerationCompleted( + generationId: generationId, + modelId: modelId, + inputTokens: inputTokens, + outputTokens: outputTokens, + durationMs: durationMs, + tokensPerSecond: tps + ) + + default: + break + } + } + + func handleModelLoadCompleted(modelId: String) { + let wasLoaded = isModelLoadedValue + updateModelLoadedState(isLoaded: true) + + if let matchingModel = ModelListViewModel.shared.availableModels.first(where: { $0.id == modelId }) { + updateLoadedModelInfo(name: matchingModel.name, framework: matchingModel.framework) + } + + if !wasLoaded { + if messagesValue.first?.role != .system { + addSystemMessage() + } + } + } + + func handleModelUnloaded(modelId: String) { + updateModelLoadedState(isLoaded: false) + clearLoadedModelInfo() + } + + func handleFirstToken(generationId: String, timeToFirstTokenMs: Double) { + recordFirstTokenLatency(generationId: generationId, latency: timeToFirstTokenMs) + } + + // swiftlint:disable:next function_parameter_count + func handleGenerationCompleted( + generationId: String, + modelId: String, + inputTokens: Int, + outputTokens: Int, + durationMs: Double, + tokensPerSecond: Double + ) { + let ttft = getFirstTokenLatency(for: generationId) + let metrics = GenerationMetricsFromSDK( + generationId: generationId, + modelId: modelId, + inputTokens: inputTokens, + outputTokens: outputTokens, + durationMs: durationMs, + tokensPerSecond: tokensPerSecond, + timeToFirstTokenMs: ttft + ) + recordGenerationMetrics(generationId: generationId, metrics: metrics) + cleanupOldMetricsIfNeeded() + } +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Chat/ViewModels/LLMViewModel+Generation.swift b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Chat/ViewModels/LLMViewModel+Generation.swift new file mode 100644 index 000000000..bfd566248 --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Chat/ViewModels/LLMViewModel+Generation.swift @@ -0,0 +1,183 @@ +// +// LLMViewModel+Generation.swift +// RunAnywhereAI +// +// Message generation functionality for LLMViewModel +// + +import Foundation +import RunAnywhere + +extension LLMViewModel { + // MARK: - Streaming Response Generation + + func generateStreamingResponse( + prompt: String, + options: LLMGenerationOptions, + messageIndex: Int + ) async throws { + var fullResponse = "" + + let streamingResult = try await RunAnywhere.generateStream(prompt, options: options) + let stream = streamingResult.stream + let metricsTask = streamingResult.result + + for try await token in stream { + fullResponse += token + await updateMessageContent(at: messageIndex, content: fullResponse) + NotificationCenter.default.post( + name: Notification.Name("MessageContentUpdated"), + object: nil + ) + } + + let sdkResult = try await metricsTask.value + await updateMessageWithResult( + at: messageIndex, + result: sdkResult, + prompt: prompt, + options: options, + wasInterrupted: false + ) + } + + // MARK: - Non-Streaming Response Generation + + func generateNonStreamingResponse( + prompt: String, + options: LLMGenerationOptions, + messageIndex: Int + ) async throws { + let result = try await RunAnywhere.generate(prompt, options: options) + await updateMessageWithResult( + at: messageIndex, + result: result, + prompt: prompt, + options: options, + wasInterrupted: false + ) + } + + // MARK: - Message Updates + + func updateMessageContent(at index: Int, content: String) async { + await MainActor.run { + guard index < self.messagesValue.count else { return } + let currentMessage = self.messagesValue[index] + let updatedMessage = Message( + id: currentMessage.id, + role: currentMessage.role, + content: content, + thinkingContent: currentMessage.thinkingContent, + timestamp: currentMessage.timestamp + ) + self.updateMessage(at: index, with: updatedMessage) + } + } + + func updateMessageWithResult( + at index: Int, + result: LLMGenerationResult, + prompt: String, + options: LLMGenerationOptions, + wasInterrupted: Bool + ) async { + await MainActor.run { + guard index < self.messagesValue.count, + let conversationId = self.currentConversation?.id else { return } + + let currentMessage = self.messagesValue[index] + let analytics = self.createAnalytics( + from: result, + messageId: currentMessage.id.uuidString, + conversationId: conversationId, + wasInterrupted: wasInterrupted, + options: options + ) + + let modelInfo: MessageModelInfo? + if let currentModel = ModelListViewModel.shared.currentModel { + modelInfo = MessageModelInfo(from: currentModel) + } else { + modelInfo = nil + } + + let updatedMessage = Message( + id: currentMessage.id, + role: currentMessage.role, + content: result.text, + thinkingContent: result.thinkingContent, + timestamp: currentMessage.timestamp, + analytics: analytics, + modelInfo: modelInfo + ) + self.updateMessage(at: index, with: updatedMessage) + self.updateConversationAnalytics() + } + } + + // MARK: - Error Handling + + func handleGenerationError(_ error: Error, at index: Int) async { + await MainActor.run { + self.setError(error) + + if index < self.messagesValue.count { + let errorMessage: String + if error is LLMError { + errorMessage = error.localizedDescription + } else { + errorMessage = "Generation failed: \(error.localizedDescription)" + } + + let currentMessage = self.messagesValue[index] + let updatedMessage = Message( + id: currentMessage.id, + role: currentMessage.role, + content: errorMessage, + timestamp: currentMessage.timestamp + ) + self.updateMessage(at: index, with: updatedMessage) + } + } + } + + // MARK: - Finalization + + func finalizeGeneration(at index: Int) async { + await MainActor.run { + self.setIsGenerating(false) + } + + guard index < self.messagesValue.count else { return } + + // Get the assistant message that was just generated + let assistantMessage = self.messagesValue[index] + + // Get the CURRENT conversation from store (not the stale local copy) + guard let conversationId = self.currentConversation?.id, + let conversation = self.conversationStore.conversations.first(where: { $0.id == conversationId }) else { + return + } + + // Add assistant message to conversation store + await MainActor.run { + self.conversationStore.addMessage(assistantMessage, to: conversation) + } + + // Update conversation with all messages and model info + await MainActor.run { + if var updatedConversation = self.conversationStore.currentConversation { + updatedConversation.messages = self.messagesValue + updatedConversation.modelName = self.loadedModelName + self.conversationStore.updateConversation(updatedConversation) + self.setCurrentConversation(updatedConversation) + } + } + + // Generate smart title immediately after first AI response + if self.messagesValue.count >= 2 { + await self.conversationStore.generateSmartTitleForConversation(conversationId) + } + } +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Chat/ViewModels/LLMViewModel+ModelManagement.swift b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Chat/ViewModels/LLMViewModel+ModelManagement.swift new file mode 100644 index 000000000..088da9a40 --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Chat/ViewModels/LLMViewModel+ModelManagement.swift @@ -0,0 +1,102 @@ +// +// LLMViewModel+ModelManagement.swift +// RunAnywhereAI +// +// Model loading and management functionality for LLMViewModel +// + +import Foundation +import RunAnywhere +import os.log + +extension LLMViewModel { + // MARK: - Model Loading + + func loadModel(_ modelInfo: ModelInfo) async { + do { + try await RunAnywhere.loadModel(modelInfo.id) + + await MainActor.run { + self.updateModelLoadedState(isLoaded: true) + self.updateLoadedModelInfo(name: modelInfo.name, framework: modelInfo.framework) + self.updateSystemMessageAfterModelLoad() + } + } catch { + await MainActor.run { + self.setError(error) + self.updateModelLoadedState(isLoaded: false) + self.clearLoadedModelInfo() + } + } + } + + // MARK: - Model Status Checking + + func checkModelStatus() async { + let modelListViewModel = ModelListViewModel.shared + + await MainActor.run { + if let currentModel = modelListViewModel.currentModel { + self.updateModelLoadedState(isLoaded: true) + self.updateLoadedModelInfo(name: currentModel.name, framework: currentModel.framework) + verifyModelLoaded(currentModel) + } else { + self.updateModelLoadedState(isLoaded: false) + self.clearLoadedModelInfo() + } + + self.updateSystemMessageAfterModelLoad() + } + } + + private func verifyModelLoaded(_ currentModel: ModelInfo) { + Task { + do { + try await RunAnywhere.loadModel(currentModel.id) + let supportsStreaming = await RunAnywhere.supportsLLMStreaming + await MainActor.run { + self.updateStreamingSupport(supportsStreaming) + } + } catch { + await MainActor.run { + self.updateModelLoadedState(isLoaded: false) + self.clearLoadedModelInfo() + } + } + } + } + + // MARK: - Conversation Management + + func loadConversation(_ conversation: Conversation) { + setCurrentConversation(conversation) + + if conversation.messages.isEmpty { + clearMessages() + if isModelLoadedValue { + addSystemMessage() + } + } else { + setMessages(conversation.messages) + } + + if let modelName = conversation.modelName { + setLoadedModelName(modelName) + } + } + + // MARK: - Internal State Updates + + func updateStreamingSupport(_ supportsStreaming: Bool) { + setModelSupportsStreaming(supportsStreaming) + } + + func updateSystemMessageAfterModelLoad() { + if messagesValue.first?.role == .system { + removeFirstMessage() + } + if isModelLoadedValue { + addSystemMessage() + } + } +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Chat/ViewModels/LLMViewModel.swift b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Chat/ViewModels/LLMViewModel.swift new file mode 100644 index 000000000..3c234ac06 --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Chat/ViewModels/LLMViewModel.swift @@ -0,0 +1,374 @@ +// +// LLMViewModel.swift +// RunAnywhereAI +// +// Clean ViewModel for LLM chat functionality following MVVM pattern +// All business logic for LLM inference, model management, and chat state +// + +import Foundation +import SwiftUI +import RunAnywhere +import Combine +import os.log + +// MARK: - LLM View Model + +@MainActor +@Observable +final class LLMViewModel { + // MARK: - Constants + + static let defaultMaxTokensValue = 1000 + static let defaultTemperatureValue = 0.7 + + // MARK: - Published State + + private(set) var messages: [Message] = [] + private(set) var isGenerating = false + private(set) var error: Error? + private(set) var isModelLoaded = false + private(set) var loadedModelName: String? + private(set) var selectedFramework: InferenceFramework? + private(set) var modelSupportsStreaming = true + private(set) var currentConversation: Conversation? + + // MARK: - User Settings + + var currentInput = "" + var useStreaming = true + + // MARK: - Dependencies + + let conversationStore = ConversationStore.shared + private let logger = Logger(subsystem: "com.runanywhere.RunAnywhereAI", category: "LLMViewModel") + + // MARK: - Private State + + private var generationTask: Task? + var lifecycleCancellable: AnyCancellable? + private var firstTokenLatencies: [String: Double] = [:] + private var generationMetrics: [String: GenerationMetricsFromSDK] = [:] + + // MARK: - Internal Accessors for Extensions + + var isModelLoadedValue: Bool { isModelLoaded } + var messagesValue: [Message] { messages } + + func updateModelLoadedState(isLoaded: Bool) { + isModelLoaded = isLoaded + } + + func updateLoadedModelInfo(name: String, framework: InferenceFramework) { + loadedModelName = name + selectedFramework = framework + } + + func clearLoadedModelInfo() { + loadedModelName = nil + selectedFramework = nil + } + + func recordFirstTokenLatency(generationId: String, latency: Double) { + firstTokenLatencies[generationId] = latency + } + + func getFirstTokenLatency(for generationId: String) -> Double? { + firstTokenLatencies[generationId] + } + + func recordGenerationMetrics(generationId: String, metrics: GenerationMetricsFromSDK) { + generationMetrics[generationId] = metrics + } + + func cleanupOldMetricsIfNeeded() { + if firstTokenLatencies.count > 10 { + firstTokenLatencies.removeAll() + } + if generationMetrics.count > 10 { + generationMetrics.removeAll() + } + } + + func updateMessage(at index: Int, with message: Message) { + messages[index] = message + } + + func setIsGenerating(_ value: Bool) { + isGenerating = value + } + + func clearMessages() { + messages = [] + } + + func setMessages(_ newMessages: [Message]) { + messages = newMessages + } + + func removeFirstMessage() { + if !messages.isEmpty { + messages.removeFirst() + } + } + + func setLoadedModelName(_ name: String) { + loadedModelName = name + } + + func setCurrentConversation(_ conversation: Conversation) { + currentConversation = conversation + } + + func setError(_ err: Error?) { + error = err + } + + func setModelSupportsStreaming(_ value: Bool) { + modelSupportsStreaming = value + } + + // MARK: - Computed Properties + + var canSend: Bool { + !currentInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !isGenerating + && isModelLoaded + } + + // MARK: - Initialization + + init() { + // Don't create conversation yet - wait until first message is sent + currentConversation = nil + + // Listen for model loaded notifications + NotificationCenter.default.addObserver( + self, + selector: #selector(modelLoaded(_:)), + name: Notification.Name("ModelLoaded"), + object: nil + ) + + // Listen for conversation selection + NotificationCenter.default.addObserver( + self, + selector: #selector(conversationSelected(_:)), + name: Notification.Name("ConversationSelected"), + object: nil + ) + + // Defer state-modifying operations to avoid "Publishing changes within view updates" warning + // These are deferred because init() may be called during view body evaluation + Task { @MainActor in + // Small delay to ensure view is fully initialized + try? await Task.sleep(nanoseconds: 50_000_000) // 0.05 seconds + + // Subscribe to SDK events + self.subscribeToModelLifecycle() + + // Add system message if model is already loaded + if self.isModelLoaded { + self.addSystemMessage() + } + + // Ensure settings are applied + await self.ensureSettingsAreApplied() + } + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + // MARK: - Public Methods + + func sendMessage() async { + logger.info("Sending message") + + guard canSend else { + logger.error("Cannot send - validation failed") + return + } + + let (prompt, messageIndex) = prepareMessagesForSending() + generationTask = Task { + await executeGeneration(prompt: prompt, messageIndex: messageIndex) + } + } + + private func prepareMessagesForSending() -> (prompt: String, messageIndex: Int) { + let prompt = currentInput + currentInput = "" + isGenerating = true + error = nil + + // Create conversation on first message + if currentConversation == nil { + let conversation = conversationStore.createConversation() + currentConversation = conversation + } + + // Add user message + let userMessage = Message(role: .user, content: prompt) + messages.append(userMessage) + + if let conversation = currentConversation { + conversationStore.addMessage(userMessage, to: conversation) + } + + // Create placeholder assistant message + let assistantMessage = Message(role: .assistant, content: "") + messages.append(assistantMessage) + + return (prompt, messages.count - 1) + } + + private func executeGeneration(prompt: String, messageIndex: Int) async { + do { + try await ensureModelIsLoaded() + let options = getGenerationOptions() + try await performGeneration(prompt: prompt, options: options, messageIndex: messageIndex) + } catch { + await handleGenerationError(error, at: messageIndex) + } + + await finalizeGeneration(at: messageIndex) + } + + private func performGeneration( + prompt: String, + options: LLMGenerationOptions, + messageIndex: Int + ) async throws { + let modelSupportsStreaming = await RunAnywhere.supportsLLMStreaming + let effectiveUseStreaming = useStreaming && modelSupportsStreaming + + if !modelSupportsStreaming && useStreaming { + logger.info("Model doesn't support streaming, using non-streaming mode") + } + + if effectiveUseStreaming { + try await generateStreamingResponse(prompt: prompt, options: options, messageIndex: messageIndex) + } else { + try await generateNonStreamingResponse(prompt: prompt, options: options, messageIndex: messageIndex) + } + } + + func clearChat() { + generationTask?.cancel() + + // Generate smart title for the old conversation before creating new one + if let oldConversation = currentConversation, + oldConversation.messages.count >= 2 { + let conversationId = oldConversation.id + Task { @MainActor in + await self.conversationStore.generateSmartTitleForConversation(conversationId) + } + } + + messages.removeAll() + currentInput = "" + isGenerating = false + error = nil + + // Create new conversation + let conversation = conversationStore.createConversation() + currentConversation = conversation + + if isModelLoaded { + addSystemMessage() + } + } + + func stopGeneration() { + generationTask?.cancel() + isGenerating = false + + Task { + await RunAnywhere.cancelGeneration() + } + } + + func createNewConversation() { + clearChat() + } + + // MARK: - Private Methods - Message Generation + + private func ensureModelIsLoaded() async throws { + if !isModelLoaded { + throw LLMError.noModelLoaded + } + + // Verify model is actually loaded in SDK + if let model = ModelListViewModel.shared.currentModel { + try await RunAnywhere.loadModel(model.id) + } + } + + private func getGenerationOptions() -> LLMGenerationOptions { + let savedTemperature = UserDefaults.standard.double(forKey: "defaultTemperature") + let savedMaxTokens = UserDefaults.standard.integer(forKey: "defaultMaxTokens") + + let effectiveSettings = ( + temperature: savedTemperature != 0 ? savedTemperature : Self.defaultTemperatureValue, + maxTokens: savedMaxTokens != 0 ? savedMaxTokens : Self.defaultMaxTokensValue + ) + + return LLMGenerationOptions( + maxTokens: effectiveSettings.maxTokens, + temperature: Float(effectiveSettings.temperature) + ) + } + + // MARK: - Internal Methods - Helpers + + func addSystemMessage() { + // Model loaded notification is now shown as a toast instead + // No need to add a system message to the chat + } + + private func ensureSettingsAreApplied() async { + let savedTemperature = UserDefaults.standard.double(forKey: "defaultTemperature") + let temperature = savedTemperature != 0 ? savedTemperature : Self.defaultTemperatureValue + + let savedMaxTokens = UserDefaults.standard.integer(forKey: "defaultMaxTokens") + let maxTokens = savedMaxTokens != 0 ? savedMaxTokens : Self.defaultMaxTokensValue + + UserDefaults.standard.set(temperature, forKey: "defaultTemperature") + UserDefaults.standard.set(maxTokens, forKey: "defaultMaxTokens") + + logger.info("Settings applied - Temperature: \(temperature), MaxTokens: \(maxTokens)") + } + + @objc + private func modelLoaded(_ notification: Notification) { + Task { + if let model = notification.object as? ModelInfo { + let supportsStreaming = await RunAnywhere.supportsLLMStreaming + + await MainActor.run { + self.isModelLoaded = true + self.loadedModelName = model.name + self.selectedFramework = model.framework + self.modelSupportsStreaming = supportsStreaming + + if self.messages.first?.role == .system { + self.messages.removeFirst() + } + self.addSystemMessage() + } + } else { + await self.checkModelStatus() + } + } + } + + @objc + private func conversationSelected(_ notification: Notification) { + if let conversation = notification.object as? Conversation { + loadConversation(conversation) + } + } +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Chat/ViewModels/LLMViewModelTypes.swift b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Chat/ViewModels/LLMViewModelTypes.swift new file mode 100644 index 000000000..779e483eb --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Chat/ViewModels/LLMViewModelTypes.swift @@ -0,0 +1,33 @@ +// +// LLMViewModelTypes.swift +// RunAnywhereAI +// +// Supporting types for LLMViewModel +// + +import Foundation + +// MARK: - LLM Error + +enum LLMError: LocalizedError { + case noModelLoaded + + var errorDescription: String? { + switch self { + case .noModelLoaded: + return "No model is loaded. Please select and load a model from the Models tab first." + } + } +} + +// MARK: - Generation Metrics + +struct GenerationMetricsFromSDK: Sendable { + let generationId: String + let modelId: String + let inputTokens: Int + let outputTokens: Int + let durationMs: Double + let tokensPerSecond: Double + let timeToFirstTokenMs: Double? +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Chat/Views/ChatDetailsView.swift b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Chat/Views/ChatDetailsView.swift new file mode 100644 index 000000000..50ac179fc --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Chat/Views/ChatDetailsView.swift @@ -0,0 +1,280 @@ +// +// ChatDetailsView.swift +// RunAnywhereAI +// +// Chat analytics and details views - Native iOS Design +// + +import SwiftUI + +// MARK: - Chat Details View + +struct ChatDetailsView: View { + let messages: [Message] + let conversation: Conversation? + + @Environment(\.dismiss) + private var dismiss + + @State private var selectedTab = 0 + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + Picker("", selection: $selectedTab) { + Text("Overview").tag(0) + Text("Messages").tag(1) + Text("Performance").tag(2) + } + .pickerStyle(.segmented) + .padding() + + TabView(selection: $selectedTab) { + OverviewTab(messages: messages, conversation: conversation) + .tag(0) + MessagesTab(messages: messages) + .tag(1) + PerformanceTab(messages: messages) + .tag(2) + } + .tabViewStyle(.page(indexDisplayMode: .never)) + } + .background(Color(.systemGroupedBackground)) + .navigationTitle("Analytics") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + } + .adaptiveSheetFrame( + minWidth: 500, idealWidth: 650, maxWidth: 800, + minHeight: 450, idealHeight: 550, maxHeight: 700 + ) + } +} + +// MARK: - Overview Tab + +private struct OverviewTab: View { + let messages: [Message] + let conversation: Conversation? + + private var analytics: [MessageAnalytics] { + messages.compactMap { $0.analytics } + } + + var body: some View { + List { + // Conversation Section + Section { + row("message", "Messages", "\(messages.count)") + row("person", "From You", "\(messages.filter { $0.role == .user }.count)") + row("sparkles", "From AI", "\(messages.filter { $0.role == .assistant }.count)") + + if let conv = conversation { + row("clock", "Created", conv.createdAt.formatted(date: .abbreviated, time: .shortened)) + } + } + + // Performance Section + if !analytics.isEmpty { + Section("Performance") { + row("timer", "Avg Response", String(format: "%.1fs", avgTime)) + row("bolt", "Token Speed", "\(Int(avgSpeed)) tok/s") + row("number", "Total Tokens", "\(totalTokens)") + row("checkmark.circle", "Success Rate", "\(Int(successRate * 100))%") + } + + Section("Model") { + let models = Set(analytics.map { $0.modelName }) + ForEach(Array(models), id: \.self) { model in + row("cpu", model, "\(analytics.filter { $0.modelName == model }.count) responses") + } + } + } + } + } + + private func row(_ icon: String, _ title: String, _ value: String) -> some View { + HStack { + Label { + Text(title) + } icon: { + Image(systemName: icon) + .foregroundStyle(.orange) + } + Spacer() + Text(value) + .foregroundStyle(.secondary) + } + } + + private var avgTime: Double { + guard !analytics.isEmpty else { return 0 } + return analytics.map { $0.totalGenerationTime }.reduce(0, +) / Double(analytics.count) + } + + private var avgSpeed: Double { + guard !analytics.isEmpty else { return 0 } + return analytics.map { $0.averageTokensPerSecond }.reduce(0, +) / Double(analytics.count) + } + + private var totalTokens: Int { + analytics.reduce(0) { $0 + $1.inputTokens + $1.outputTokens } + } + + private var successRate: Double { + guard !analytics.isEmpty else { return 0 } + return Double(analytics.filter { $0.completionStatus == .complete }.count) / Double(analytics.count) + } +} + +// MARK: - Messages Tab + +private struct MessagesTab: View { + let messages: [Message] + + private var items: [(Message, MessageAnalytics)] { + messages.compactMap { msg in + msg.analytics.map { (msg, $0) } + } + } + + var body: some View { + List { + ForEach(items.indices, id: \.self) { i in + let (msg, stats) = items[i] + + Section { + VStack(alignment: .leading, spacing: 8) { + Text(msg.content.prefix(150)) + .font(.subheadline) + } + + HStack { + Label { + Text("Time") + } icon: { + Image(systemName: "clock") + .foregroundStyle(.orange) + } + Spacer() + Text(String(format: "%.1fs", stats.totalGenerationTime)) + .foregroundStyle(.secondary) + } + + HStack { + Label { + Text("Speed") + } icon: { + Image(systemName: "bolt") + .foregroundStyle(.orange) + } + Spacer() + Text("\(Int(stats.averageTokensPerSecond)) tok/s") + .foregroundStyle(.secondary) + } + + HStack { + Label { + Text("Model") + } icon: { + Image(systemName: "cpu") + .foregroundStyle(.orange) + } + Spacer() + Text(stats.modelName) + .foregroundStyle(.secondary) + } + + if stats.wasThinkingMode { + Label { + Text("Used Thinking Mode") + .foregroundStyle(.orange) + } icon: { + Image(systemName: "lightbulb") + .foregroundStyle(.orange) + } + } + } header: { + Text("Response \(i + 1)") + } + } + } + } +} + +// MARK: - Performance Tab + +private struct PerformanceTab: View { + let messages: [Message] + + private var analytics: [MessageAnalytics] { + messages.compactMap { $0.analytics } + } + + var body: some View { + List { + if !analytics.isEmpty { + Section("Models") { + let groups = Dictionary(grouping: analytics) { $0.modelName } + ForEach(groups.keys.sorted(), id: \.self) { name in + if let items = groups[name] { + let avg = items.map { $0.totalGenerationTime }.reduce(0, +) / Double(items.count) + let speed = items.map { $0.averageTokensPerSecond }.reduce(0, +) / Double(items.count) + + VStack(alignment: .leading, spacing: 4) { + Text(name) + .font(.headline) + + HStack { + Text("\(items.count) responses") + Text("•") + Text(String(format: "%.1fs avg", avg)) + Text("•") + Text("\(Int(speed)) tok/s") + } + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + } + } + } + + if analytics.contains(where: { $0.wasThinkingMode }) { + Section("Thinking Mode") { + let count = analytics.filter { $0.wasThinkingMode }.count + let pct = Int((Double(count) / Double(analytics.count)) * 100) + + HStack { + Label { + Text("Responses") + } icon: { + Image(systemName: "lightbulb") + .foregroundStyle(.orange) + } + Spacer() + Text("\(count)") + .foregroundStyle(.secondary) + } + + HStack { + Label { + Text("Usage") + } icon: { + Image(systemName: "percent") + .foregroundStyle(.orange) + } + Spacer() + Text("\(pct)%") + .foregroundStyle(.secondary) + } + } + } + } + } + } +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Chat/Views/ChatInterfaceView.swift b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Chat/Views/ChatInterfaceView.swift new file mode 100644 index 000000000..d3f35eb55 --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Chat/Views/ChatInterfaceView.swift @@ -0,0 +1,456 @@ +// +// ChatInterfaceView.swift +// RunAnywhereAI +// +// Chat interface view - UI only, all logic in LLMViewModel +// + +import SwiftUI +import RunAnywhere +import os.log +#if canImport(UIKit) +import UIKit +#else +import AppKit +#endif + +// MARK: - Chat Interface View + +struct ChatInterfaceView: View { + @State private var viewModel = LLMViewModel() + @StateObject private var conversationStore = ConversationStore.shared + @State private var showingConversationList = false + @State private var showingModelSelection = false + @State private var showingChatDetails = false + @State private var showDebugAlert = false + @State private var debugMessage = "" + @State private var showModelLoadedToast = false + @FocusState private var isTextFieldFocused: Bool + + private let logger = Logger( + subsystem: "com.runanywhere.RunAnywhereAI", + category: "ChatInterfaceView" + ) + + var hasModelSelected: Bool { + viewModel.isModelLoaded && viewModel.loadedModelName != nil + } + + var body: some View { + Group { + #if os(macOS) + macOSView + #else + iOSView + #endif + } + .sheet(isPresented: $showingConversationList) { + ConversationListView() + } + .sheet(isPresented: $showingModelSelection) { + ModelSelectionSheet(context: .llm) { model in + await handleModelSelected(model) + } + } + .sheet(isPresented: $showingChatDetails) { + ChatDetailsView( + messages: viewModel.messages, + conversation: viewModel.currentConversation + ) + } + .onAppear { + setupInitialState() + } + .onReceive( + NotificationCenter.default.publisher(for: Notification.Name("ModelLoaded")) + ) { _ in + Task { + await viewModel.checkModelStatus() + // Show toast when model is loaded + if viewModel.isModelLoaded { + await MainActor.run { + showModelLoadedToast = true + } + } + } + } + .alert("Debug Info", isPresented: $showDebugAlert) { + Button("OK") { } + } message: { + Text(debugMessage) + } + .modelLoadedToast( + isShowing: $showModelLoadedToast, + modelName: viewModel.loadedModelName ?? "Model" + ) + } +} + +// MARK: - Platform Views + +extension ChatInterfaceView { + var macOSView: some View { + ZStack { + VStack(spacing: 0) { + macOSToolbar + contentArea + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(AppColors.backgroundPrimary) + + modelRequiredOverlayIfNeeded + } + } + + var iOSView: some View { + NavigationView { + ZStack { + VStack(spacing: 0) { + contentArea + } + modelRequiredOverlayIfNeeded + } + .navigationTitle(hasModelSelected ? "Chat" : "") + .navigationBarTitleDisplayMode(.inline) + .navigationBarHidden(!hasModelSelected) + .toolbar { + if hasModelSelected { + ToolbarItem(placement: .navigationBarLeading) { + Button { + showingConversationList = true + } label: { + Image(systemName: "clock.arrow.trianglehead.counterclockwise.rotate.90") + } + } + + ToolbarItem(placement: .navigationBarLeading) { + Button { + showingChatDetails = true + } label: { + Image(systemName: "info.circle") + .foregroundColor(viewModel.messages.isEmpty ? .gray : AppColors.primaryAccent) + } + .disabled(viewModel.messages.isEmpty) + } + + ToolbarItem(placement: .navigationBarTrailing) { + modelButton + } + } + } + } + .navigationViewStyle(.stack) + } +} + +// MARK: - View Components + +extension ChatInterfaceView { + var macOSToolbar: some View { + HStack { + Button { + showingConversationList = true + } label: { + Label("Conversations", systemImage: "list.bullet") + } + .buttonStyle(.bordered) + .tint(AppColors.primaryAccent) + + Button { + showingChatDetails = true + } label: { + Image(systemName: "info.circle") + } + .buttonStyle(.bordered) + .tint(AppColors.primaryAccent) + .disabled(viewModel.messages.isEmpty) + + Spacer() + + Text("Chat") + .font(AppTypography.headline) + + Spacer() + + modelButton + } + .padding(.horizontal, AppSpacing.large) + .padding(.vertical, AppSpacing.smallMedium) + .background(AppColors.backgroundPrimary) + } + + + @ViewBuilder var contentArea: some View { + if hasModelSelected { + chatMessagesView + inputArea + } else { + Spacer() + } + } + + @ViewBuilder var modelRequiredOverlayIfNeeded: some View { + if !hasModelSelected && !viewModel.isGenerating { + ModelRequiredOverlay(modality: .llm) { showingModelSelection = true } + } + } + + private var modelButton: some View { + Button { + showingModelSelection = true + } label: { + HStack(spacing: 6) { + // Model logo instead of cube icon + if let modelName = viewModel.loadedModelName { + Image(getModelLogo(for: modelName)) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 36, height: 36) + .cornerRadius(4) + } else { + Image(systemName: "cube") + .font(.system(size: 14)) + } + + if let modelName = viewModel.loadedModelName { + VStack(alignment: .leading, spacing: 2) { + Text(modelName.shortModelName(maxLength: 13)) + .font(.caption) + .fontWeight(.medium) + .lineLimit(1) + + // Streaming indicator + HStack(spacing: 3) { + Image(systemName: viewModel.modelSupportsStreaming ? "bolt.fill" : "square.fill") + .font(.system(size: 7)) + Text(viewModel.modelSupportsStreaming ? "Streaming" : "Batch") + .font(.system(size: 8, weight: .medium)) + } + .foregroundColor(viewModel.modelSupportsStreaming ? .green : .orange) + } + } else { + Text("Select Model") + .font(AppTypography.caption) + } + } + } + #if os(macOS) + .buttonStyle(.bordered) + .tint(AppColors.primaryAccent) + #endif + } + + +} + +// MARK: - Chat Content Views + +extension ChatInterfaceView { + var chatMessagesView: some View { + ScrollViewReader { proxy in + VStack(spacing: 0) { + ScrollView { + if viewModel.messages.isEmpty && !viewModel.isGenerating { + emptyStateView + } else { + messageListView + } + } + .scrollDisabled(viewModel.messages.isEmpty && !viewModel.isGenerating) + .defaultScrollAnchor(viewModel.messages.isEmpty && !viewModel.isGenerating ? .center : .bottom) + } + .background(AppColors.backgroundGrouped) + .contentShape(Rectangle()) + .onTapGesture { + isTextFieldFocused = false + } + .onChange(of: viewModel.messages.count) { _, _ in + scrollToBottom(proxy: proxy) + } + .onChange(of: viewModel.isGenerating) { _, isGenerating in + if isGenerating { + scrollToBottom(proxy: proxy, animated: true) + } + } + .onChange(of: isTextFieldFocused) { _, focused in + if focused { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + scrollToBottom(proxy: proxy, animated: true) + } + } + } + #if os(iOS) + .onReceive( + NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification) + ) { _ in + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + scrollToBottom(proxy: proxy, animated: true) + } + } + #endif + .onReceive( + NotificationCenter.default.publisher(for: Notification.Name("MessageContentUpdated")) + ) { _ in + if viewModel.isGenerating { + proxy.scrollTo("typing", anchor: .bottom) + } + } + } + } + + var emptyStateView: some View { + VStack(spacing: 16) { + Spacer() + + Image("runanywhere_logo") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 80, height: 80) + + VStack(spacing: 8) { + Text("Start a conversation") + .font(AppTypography.title2Semibold) + .foregroundColor(AppColors.textPrimary) + + Text("Type a message below to get started") + .font(AppTypography.subheadline) + .foregroundColor(AppColors.textSecondary) + } + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + var messageListView: some View { + LazyVStack(spacing: AppSpacing.large) { + Spacer(minLength: 20) + .id("top-spacer") + + ForEach(viewModel.messages) { message in + MessageBubbleView(message: message, isGenerating: viewModel.isGenerating) + .id(message.id) + .transition(messageTransition) + .animation(nil, value: message.content) + } + + if viewModel.isGenerating { + TypingIndicatorView() + .id("typing") + .transition(typingTransition) + } + + Spacer(minLength: 20) + .id("bottom-spacer") + } + .padding(AppSpacing.large) + .animation(.default, value: viewModel.messages.count) + } + + private var messageTransition: AnyTransition { + .asymmetric( + insertion: .scale(scale: 0.8) + .combined(with: .opacity) + .combined(with: .move(edge: .bottom)), + removal: .scale(scale: 0.9).combined(with: .opacity) + ) + } + + private var typingTransition: AnyTransition { + .asymmetric( + insertion: .scale(scale: 0.8).combined(with: .opacity), + removal: .scale(scale: 0.9).combined(with: .opacity) + ) + } + + var inputArea: some View { + VStack(spacing: 0) { + Divider() + HStack(spacing: AppSpacing.mediumLarge) { + TextField("Type a message...", text: $viewModel.currentInput, axis: .vertical) + .textFieldStyle(.plain) + .lineLimit(1...4) + .focused($isTextFieldFocused) + .onSubmit { + sendMessage() + } + .submitLabel(.send) + + Button(action: sendMessage) { + Image(systemName: "arrow.up.circle.fill") + .font(AppTypography.system28) + .foregroundColor( + viewModel.canSend ? AppColors.primaryAccent : AppColors.statusGray + ) + } + .disabled(!viewModel.canSend) + .background { + if #available(iOS 26.0, *) { + Circle() + .fill(.clear) + .glassEffect(.regular.interactive()) + } + } + } + .padding(AppSpacing.large) + .background(AppColors.backgroundPrimary) + .animation(.easeInOut(duration: AppLayout.animationFast), value: isTextFieldFocused) + } + } +} + +// MARK: - Helper Methods + +extension ChatInterfaceView { + func sendMessage() { + guard viewModel.canSend else { return } + + Task { + await viewModel.sendMessage() + + Task { + let sleepDuration = UInt64(AppLayout.animationSlow * 1_000_000_000) + try? await Task.sleep(nanoseconds: sleepDuration) + if let error = viewModel.error { + await MainActor.run { + debugMessage = "Error occurred: \(error.localizedDescription)" + showDebugAlert = true + } + } + } + } + } + + func setupInitialState() { + Task { + await viewModel.checkModelStatus() + } + } + + func handleModelSelected(_ model: ModelInfo) async { + await MainActor.run { + ModelListViewModel.shared.setCurrentModel(model) + } + + await viewModel.checkModelStatus() + } + + func scrollToBottom(proxy: ScrollViewProxy, animated: Bool = true) { + let scrollToId: String + if viewModel.isGenerating { + scrollToId = "typing" + } else if let lastMessage = viewModel.messages.last { + scrollToId = lastMessage.id.uuidString + } else { + scrollToId = "bottom-spacer" + } + + if animated { + withAnimation(.easeInOut(duration: 0.5)) { + proxy.scrollTo(scrollToId, anchor: .bottom) + } + } else { + proxy.scrollTo(scrollToId, anchor: .bottom) + } + } +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Chat/Views/ChatMessageComponents.swift b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Chat/Views/ChatMessageComponents.swift new file mode 100644 index 000000000..6000f813f --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Chat/Views/ChatMessageComponents.swift @@ -0,0 +1,400 @@ +// +// ChatMessageComponents.swift +// RunAnywhereAI +// +// Chat message components - extracted from ChatInterfaceView for file length compliance +// + +import SwiftUI + +// MARK: - Typing Indicator + +struct TypingIndicatorView: View { + @State private var animationPhase = 0 + + var body: some View { + HStack { + Spacer(minLength: AppSpacing.padding60) + + HStack(spacing: AppSpacing.mediumLarge) { + HStack(spacing: AppSpacing.xSmall) { + ForEach(0..<3) { index in + Circle() + .fill(AppColors.primaryAccent.opacity(0.7)) + .frame(width: AppSpacing.iconSmall, height: AppSpacing.iconSmall) + .scaleEffect(animationPhase == index ? 1.3 : 0.8) + .animation( + Animation.easeInOut(duration: AppLayout.animationVerySlow) + .repeatForever(autoreverses: true) + .delay(Double(index) * 0.2), + value: animationPhase + ) + } + } + .padding(.horizontal, AppSpacing.mediumLarge) + .padding(.vertical, AppSpacing.smallMedium) + .background(typingIndicatorBackground) + + Text("AI is thinking...") + .font(AppTypography.caption) + .foregroundColor(AppColors.textSecondary) + .opacity(0.8) + } + + Spacer(minLength: AppSpacing.padding60) + } + .onAppear { + withAnimation { + animationPhase = 1 + } + } + } + + private var typingIndicatorBackground: some View { + RoundedRectangle(cornerRadius: AppSpacing.large) + .fill(AppColors.backgroundGray5) + .shadow(color: AppColors.shadowLight, radius: 3, x: 0, y: 2) + .overlay( + RoundedRectangle(cornerRadius: AppSpacing.large) + .strokeBorder(AppColors.borderLight, lineWidth: AppSpacing.strokeThin) + ) + } +} + +// MARK: - Message Bubble + +struct MessageBubbleView: View { + let message: Message + let isGenerating: Bool + @State private var isThinkingExpanded = false + + var hasThinking: Bool { + message.thinkingContent != nil && !(message.thinkingContent?.isEmpty ?? true) + } + + var body: some View { + HStack { + if message.role == .user { + Spacer(minLength: AppSpacing.padding60) + } + + VStack(alignment: message.role == .user ? .trailing : .leading, spacing: 4) { + if message.role == .assistant && hasThinking { + thinkingSection + } + + if message.role == .assistant && + message.content.isEmpty && + !(message.thinkingContent ?? "").isEmpty && + isGenerating { + thinkingProgressIndicator + } + + mainMessageBubble + + timestampAndAnalyticsSection + } + + if message.role != .user { + Spacer(minLength: AppSpacing.padding60) + } + } + } +} + +// MARK: - MessageBubbleView Thinking Section + +extension MessageBubbleView { + var thinkingSection: some View { + VStack(alignment: .leading, spacing: AppSpacing.small) { + Button { + withAnimation(.easeInOut(duration: AppLayout.animationFast)) { + isThinkingExpanded.toggle() + } + } label: { + HStack(spacing: 8) { + Image(systemName: "lightbulb.min") + .font(AppTypography.caption) + .foregroundColor(AppColors.primaryPurple) + + Text(isThinkingExpanded ? "Hide reasoning" : thinkingSummary) + .font(AppTypography.caption) + .foregroundColor(AppColors.primaryPurple) + .lineLimit(1) + + Spacer() + + Image(systemName: isThinkingExpanded ? "chevron.up" : "chevron.right") + .font(AppTypography.caption2) + .foregroundColor(AppColors.primaryPurple.opacity(0.6)) + } + .padding(.horizontal, AppSpacing.regular) + .padding(.vertical, AppSpacing.padding9) + .background(thinkingButtonBackground) + } + .buttonStyle(PlainButtonStyle()) + + if isThinkingExpanded { + ScrollView { + Text(message.thinkingContent ?? "") + .font(AppTypography.caption) + .foregroundColor(AppColors.textSecondary) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + .multilineTextAlignment(.leading) + } + .frame(maxHeight: AppSpacing.minFrameHeight) + .padding(AppSpacing.mediumLarge) + .background( + RoundedRectangle(cornerRadius: AppSpacing.medium) + .fill(AppColors.backgroundGray6) + ) + .transition(.asymmetric( + insertion: .opacity.combined(with: .slide), + removal: .opacity.combined(with: .slide) + )) + } + } + } + + var thinkingSummary: String { + guard let thinking = message.thinkingContent? + .trimmingCharacters(in: .whitespacesAndNewlines) else { + return "" + } + + let sentences = thinking.components(separatedBy: CharacterSet(charactersIn: ".!?")) + .filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + + if sentences.count >= 2 { + let firstSentence = sentences[0].trimmingCharacters(in: .whitespacesAndNewlines) + if firstSentence.count > 20 { + return firstSentence + "..." + } + } + + if thinking.count > 80 { + let truncated = String(thinking.prefix(80)) + if let lastSpace = truncated.lastIndex(of: " ") { + return String(truncated[.. some View { + Group { + Text("\u{2022}") + .foregroundColor(AppColors.textSecondary.opacity(0.5)) + + Text("\(String(format: "%.1f", analytics.totalGenerationTime))s") + .font(AppTypography.caption2) + .foregroundColor(AppColors.textSecondary) + + if analytics.averageTokensPerSecond > 0 { + Text("\u{2022}") + .foregroundColor(AppColors.textSecondary.opacity(0.5)) + + Text("\(Int(analytics.averageTokensPerSecond)) tok/s") + .font(.caption2) + .foregroundColor(.secondary) + } + + if analytics.wasThinkingMode { + Image(systemName: "lightbulb.min") + .font(AppTypography.caption2) + .foregroundColor(AppColors.primaryPurple.opacity(0.7)) + } + } + } +} + +// MARK: - MessageBubbleView Main Bubble + +extension MessageBubbleView { + var userBubbleGradient: LinearGradient { + LinearGradient( + colors: [ + AppColors.userBubbleGradientStart, + AppColors.userBubbleGradientEnd + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + + var assistantBubbleGradient: LinearGradient { + LinearGradient( + colors: [ + AppColors.backgroundGray5, + AppColors.backgroundGray6 + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + + var messageBubbleBackground: some View { + RoundedRectangle(cornerRadius: AppSpacing.cornerRadiusBubble) + .fill(message.role == .user ? userBubbleGradient : assistantBubbleGradient) + .shadow(color: AppColors.shadowMedium, radius: 4, x: 0, y: 2) + .overlay( + RoundedRectangle(cornerRadius: AppSpacing.cornerRadiusBubble) + .strokeBorder( + message.role == .user ? AppColors.borderLight : AppColors.borderMedium, + lineWidth: AppSpacing.strokeThin + ) + ) + } + + var shouldPulse: Bool { + isGenerating && message.role == .assistant && message.content.count < 50 + } + + @ViewBuilder var mainMessageBubble: some View { + // Only show message bubble if there's content + if !message.content.isEmpty { + ZStack(alignment: .bottomTrailing) { + // Intelligent adaptive rendering: Content analysis → Best renderer + Group { + if message.role == .assistant { + VStack(alignment: .leading, spacing: 0) { + AdaptiveMarkdownText( + message.content, + font: AppTypography.body, + color: AppColors.textPrimary + ) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + + // Extra spacing at bottom for model badge + if message.modelInfo != nil { + Spacer() + .frame(height: 16) + } + } + } else { + Text(message.content) + .foregroundColor(AppColors.textWhite) + .fixedSize(horizontal: false, vertical: true) + } + } + .padding(.horizontal, AppSpacing.large) + .padding(.vertical, AppSpacing.mediumLarge) + + // Model name badge in bottom-right corner (assistant only) + if message.role == .assistant, let modelInfo = message.modelInfo { + HStack(spacing: 3) { + Image(systemName: "cube") + .font(.system(size: 8)) + Text(modelInfo.modelName) + .font(.system(size: 9, weight: .medium)) + } + .foregroundColor(AppColors.textSecondary.opacity(0.6)) + .padding(.trailing, AppSpacing.mediumLarge) + .padding(.bottom, AppSpacing.small) + } + } + .background(messageBubbleBackground) + .animation(nil, value: message.content) + } + } +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Chat/Views/ModelLoadedToast.swift b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Chat/Views/ModelLoadedToast.swift new file mode 100644 index 000000000..44754bdf3 --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Chat/Views/ModelLoadedToast.swift @@ -0,0 +1,109 @@ +// +// ModelLoadedToast.swift +// RunAnywhereAI +// +// Toast notification for model loaded status +// + +import SwiftUI + +struct ModelLoadedToast: View { + let modelName: String + @Binding var isShowing: Bool + + var body: some View { + VStack { + if isShowing { + HStack(spacing: 12) { + // Success icon + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 20)) + .foregroundColor(.green) + + // Message + VStack(alignment: .leading, spacing: 2) { + Text("Model Ready") + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(.primary) + + Text("'\(modelName)' is loaded") + .font(.caption2) + .foregroundColor(.secondary) + } + + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background { + if #available(iOS 26.0, *) { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(.clear) + .glassEffect(.regular.interactive()) + .shadow(color: .black.opacity(0.2), radius: 16, y: 6) + } else { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(.regularMaterial) + .shadow(color: .black.opacity(0.2), radius: 16, y: 6) + .overlay { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(.white.opacity(0.3), lineWidth: 0.5) + } + } + } + .padding(.horizontal, 16) + .padding(.top, 8) + .transition(.move(edge: .top).combined(with: .opacity)) + } + + Spacer() + } + .animation(.spring(response: 0.4, dampingFraction: 0.8), value: isShowing) + } +} + +// MARK: - Toast Modifier + +struct ToastModifier: ViewModifier { + @Binding var isShowing: Bool + let modelName: String + let duration: TimeInterval + + func body(content: Content) -> some View { + ZStack { + content + + ModelLoadedToast(modelName: modelName, isShowing: $isShowing) + } + .onChange(of: isShowing) { _, newValue in + if newValue { + // Auto-dismiss after duration + DispatchQueue.main.asyncAfter(deadline: .now() + duration) { + withAnimation { + isShowing = false + } + } + } + } + } +} + +extension View { + func modelLoadedToast(isShowing: Binding, modelName: String, duration: TimeInterval = 3.0) -> some View { + modifier(ToastModifier(isShowing: isShowing, modelName: modelName, duration: duration)) + } +} + +// MARK: - Preview + +#Preview { + ZStack { + Color.gray.opacity(0.1).ignoresSafeArea() + + ModelLoadedToast( + modelName: "Platform LLM", + isShowing: .constant(true) + ) + } +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Models/AddModelFromURLView.swift b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Models/AddModelFromURLView.swift new file mode 100644 index 000000000..740253022 --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Models/AddModelFromURLView.swift @@ -0,0 +1,204 @@ +// +// AddModelFromURLView.swift +// RunAnywhereAI +// +// View for adding models from URLs +// + +import SwiftUI +import RunAnywhere +import Combine + +struct AddModelFromURLView: View { + @Environment(\.dismiss) + private var dismiss + @State private var modelName: String = "" + @State private var modelURL: String = "" + @State private var selectedFramework: InferenceFramework = .llamaCpp + @State private var estimatedSize: String = "" + @State private var supportsThinking = false + @State private var thinkingOpenTag = "" + @State private var thinkingCloseTag = "" + @State private var useCustomThinkingTags = false + @State private var isAdding = false + @State private var errorMessage: String? + @State private var availableFrameworks: [InferenceFramework] = [] + + let onModelAdded: (ModelInfo) -> Void + + var body: some View { + NavigationStack { + Form { + formContent + + Section { + Button("Add Model") { + Task { + await addModel() + } + } + .disabled(modelName.isEmpty || modelURL.isEmpty || isAdding) + #if os(macOS) + .buttonStyle(.borderedProminent) + .tint(AppColors.primaryAccent) + #endif + + if isAdding { + HStack { + ProgressView() + #if os(macOS) + .controlSize(.small) + #endif + Text("Adding model...") + .foregroundColor(.secondary) + } + } + } + } + #if os(macOS) + .formStyle(.grouped) + .frame(minWidth: 500, idealWidth: 600, minHeight: 400, idealHeight: 500) + #endif + .navigationTitle("Add Model from URL") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + #if os(iOS) + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + dismiss() + } + } + #else + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + .keyboardShortcut(.escape) + } + #endif + } + } + #if os(macOS) + .padding() + #endif + .task { + await loadAvailableFrameworks() + } + } + + @ViewBuilder private var formContent: some View { + Section("Model Information") { + TextField("Model Name", text: $modelName) + .textFieldStyle(.roundedBorder) + + TextField("Download URL", text: $modelURL) + .textFieldStyle(.roundedBorder) + #if os(iOS) + .keyboardType(.URL) + .autocapitalization(.none) + #endif + .autocorrectionDisabled() + } + + Section("Framework") { + Picker("Target Framework", selection: $selectedFramework) { + ForEach(availableFrameworks, id: \.self) { framework in + Text(framework.displayName).tag(framework) + } + } + .pickerStyle(.menu) + } + + Section("Thinking Support") { + Toggle("Model Supports Thinking", isOn: $supportsThinking) + + if supportsThinking { + Toggle("Use Custom Tags", isOn: $useCustomThinkingTags) + + if useCustomThinkingTags { + TextField("Opening Tag", text: $thinkingOpenTag) + .textFieldStyle(.roundedBorder) + #if os(iOS) + .autocapitalization(.none) + #endif + .autocorrectionDisabled() + + TextField("Closing Tag", text: $thinkingCloseTag) + .textFieldStyle(.roundedBorder) + #if os(iOS) + .autocapitalization(.none) + #endif + .autocorrectionDisabled() + } else { + HStack { + Text("Default tags:") + Text("...") + .font(.system(.caption, design: .monospaced)) + .foregroundColor(.secondary) + } + } + } + } + + Section("Advanced (Optional)") { + TextField("Estimated Size (bytes)", text: $estimatedSize) + .textFieldStyle(.roundedBorder) + #if os(iOS) + .keyboardType(.numberPad) + #endif + } + + if let error = errorMessage { + Section { + Text(error) + .foregroundColor(.red) + .font(.caption) + } + } + } + + private func loadAvailableFrameworks() async { + let frameworks = await RunAnywhere.getRegisteredFrameworks() + await MainActor.run { + self.availableFrameworks = frameworks.isEmpty ? [.llamaCpp] : frameworks + // Set default selection to first available framework + if let first = frameworks.first, !frameworks.contains(selectedFramework) { + selectedFramework = first + } + } + } + + private func addModel() async { + guard let url = URL(string: modelURL) else { + errorMessage = "Invalid URL format" + return + } + + isAdding = true + errorMessage = nil + + // Use the new registerModel API + let modelInfo = await MainActor.run { + RunAnywhere.registerModel( + name: modelName, + url: url, + framework: selectedFramework, + memoryRequirement: Int64(estimatedSize), + supportsThinking: supportsThinking + ) + } + + await MainActor.run { + onModelAdded(modelInfo) + dismiss() + } + } +} + +#Preview { + AddModelFromURLView { modelInfo in + print("Added model: \(modelInfo.name)") + } +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Models/ModelComponents.swift b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Models/ModelComponents.swift new file mode 100644 index 000000000..dd266877f --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Models/ModelComponents.swift @@ -0,0 +1,84 @@ +// +// ModelComponents.swift +// RunAnywhereAI +// +// Shared components for model selection +// + +import SwiftUI +import RunAnywhere + +struct FrameworkRow: View { + let framework: InferenceFramework + let isExpanded: Bool + let onTap: () -> Void + + var body: some View { + Button(action: onTap) { + HStack { + Image(systemName: frameworkIcon) + .foregroundColor(frameworkColor) + .frame(width: AppSpacing.xxLarge) + + VStack(alignment: .leading, spacing: AppSpacing.xxSmall) { + Text(framework.displayName) + .font(AppTypography.headline) + Text(frameworkDescription) + .font(AppTypography.caption) + .foregroundColor(AppColors.textSecondary) + } + + Spacer() + + Image(systemName: isExpanded ? "chevron.down" : "chevron.right") + .foregroundColor(AppColors.textSecondary) + .font(AppTypography.caption) + } + .contentShape(Rectangle()) + } + .buttonStyle(PlainButtonStyle()) + } + + private var frameworkIcon: String { + switch framework { + case .foundationModels: + return "apple.logo" + case .llamaCpp: + return "cpu" + case .onnx: + return "brain" + case .fluidAudio: + return "waveform" + default: + return "cpu" + } + } + + private var frameworkColor: Color { + switch framework { + case .foundationModels: + return AppColors.textPrimary + case .llamaCpp: + return AppColors.primaryAccent + case .onnx: + return AppColors.statusGray + default: + return AppColors.statusGray + } + } + + private var frameworkDescription: String { + switch framework { + case .foundationModels: + return "Apple's pre-installed system models" + case .llamaCpp: + return "Efficient LLM inference with GGUF models" + case .onnx: + return "ONNX Runtime for STT/TTS models" + case .fluidAudio: + return "Speaker diarization" + default: + return "Machine learning framework" + } + } +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Models/ModelListViewModel.swift b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Models/ModelListViewModel.swift new file mode 100644 index 000000000..0b32c42ae --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Models/ModelListViewModel.swift @@ -0,0 +1,197 @@ +// +// ModelListViewModel.swift +// RunAnywhereAI +// +// Simplified version that uses SDK registry directly +// + +import Foundation +import SwiftUI +import RunAnywhere +import Combine + +@MainActor +class ModelListViewModel: ObservableObject { + static let shared = ModelListViewModel() + + @Published var availableModels: [ModelInfo] = [] + @Published var currentModel: ModelInfo? + @Published var isLoading = false + @Published var errorMessage: String? + + private var cancellables = Set() + + // MARK: - Initialization + + init() { + // Subscribe to SDK events for model lifecycle updates + subscribeToModelEvents() + + Task { + await loadModelsFromRegistry() + } + } + + /// Subscribe to SDK events for real-time model state updates + private func subscribeToModelEvents() { + // Subscribe to LLM events via EventBus + RunAnywhere.events.events + .receive(on: DispatchQueue.main) + .sink { [weak self] event in + guard let self = self else { return } + self.handleSDKEvent(event) + } + .store(in: &cancellables) + } + + /// Handle SDK events to update model state + private func handleSDKEvent(_ event: any SDKEvent) { + // Events now come from C++ via generic BridgedEvent + guard event.category == .llm else { return } + + let modelId = event.properties["model_id"] ?? "" + + switch event.type { + case "llm_model_load_completed": + // Find the matching model and set as current + if let matchingModel = availableModels.first(where: { $0.id == modelId }) { + currentModel = matchingModel + print("✅ ModelListViewModel: Model loaded: \(matchingModel.name)") + } + case "llm_model_unloaded": + if currentModel?.id == modelId { + currentModel = nil + print("ℹ️ ModelListViewModel: Model unloaded: \(modelId)") + } + default: + break + } + } + + // MARK: - Methods + + /// Load models from SDK registry (no more hard-coded models) + func loadModelsFromRegistry() async { + isLoading = true + errorMessage = nil + + do { + // Get all models from SDK registry + // This now includes: + // 1. Models from remote configuration (if available) + // 2. Models from framework adapters + // 3. Models from local storage + // 4. User-added models + let allModels = try await RunAnywhere.availableModels() + + // Filter based on iOS version if needed + var filteredModels = allModels + + // Filter out Foundation Models for older iOS versions + if #unavailable(iOS 26.0) { + filteredModels = allModels.filter { $0.framework != .foundationModels } + print("iOS < 26 - Foundation Models not available") + } + + availableModels = filteredModels + print("Loaded \(availableModels.count) models from registry") + + for model in availableModels { + print(" - \(model.name) (\(model.framework.displayName))") + } + + // Sync currentModel with SDK's current model state + await syncCurrentModelWithSDK() + } catch { + print("Failed to load models from SDK: \(error)") + errorMessage = "Failed to load models: \(error.localizedDescription)" + availableModels = [] + } + + isLoading = false + } + + /// Sync current model state with SDK + private func syncCurrentModelWithSDK() async { + if let currentModelId = await RunAnywhere.getCurrentModelId(), + let matchingModel = availableModels.first(where: { $0.id == currentModelId }) { + currentModel = matchingModel + print("✅ ModelListViewModel: Restored currentModel from SDK: \(matchingModel.name)") + } + } + + func setCurrentModel(_ model: ModelInfo?) { + currentModel = model + } + + /// Alias for loadModelsFromRegistry to match view calls + func loadModels() async { + await loadModelsFromRegistry() + } + + /// Select and load a model + func selectModel(_ model: ModelInfo) async { + do { + try await loadModel(model) + setCurrentModel(model) + + // Post notification that model was loaded successfully + await MainActor.run { + NotificationCenter.default.post( + name: Notification.Name("ModelLoaded"), + object: model + ) + } + } catch { + errorMessage = "Failed to load model: \(error.localizedDescription)" + // Don't set currentModel if loading failed + } + } + + func downloadModel(_ model: ModelInfo) async throws { + // Use the SDK's public download API + let progressStream = try await RunAnywhere.downloadModel(model.id) + + // Wait for completion + for await progress in progressStream { + print("Download progress: \(Int(progress.overallProgress * 100))%") + if progress.stage == .completed { + break + } + } + + // Reload models after download + await loadModelsFromRegistry() + } + + func deleteModel(_ model: ModelInfo) async throws { + try await RunAnywhere.deleteStoredModel(model.id, framework: model.framework) + // Reload models after deletion + await loadModelsFromRegistry() + } + + func loadModel(_ model: ModelInfo) async throws { + try await RunAnywhere.loadModel(model.id) + currentModel = model + } + + /// Add a custom model from URL + func addModelFromURL(name: String, url: URL, framework: InferenceFramework, estimatedSize: Int64?) async { + // Use SDK's registerModel method + RunAnywhere.registerModel( + name: name, + url: url, + framework: framework, + memoryRequirement: estimatedSize + ) + + // Reload models to include the new one + await loadModelsFromRegistry() + } + + /// Add an imported model to the list + func addImportedModel(_ model: ModelInfo) async { + // Just reload the models - the SDK registry will pick up the new model + await loadModelsFromRegistry() + } +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Models/ModelSelectionRows.swift b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Models/ModelSelectionRows.swift new file mode 100644 index 000000000..79e9fe687 --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Models/ModelSelectionRows.swift @@ -0,0 +1,388 @@ +// +// ModelSelectionRows.swift +// RunAnywhereAI +// +// Row components for model selection sheet +// + +import SwiftUI +import RunAnywhere + +// MARK: - System TTS Row + +/// System TTS selection row - uses built-in AVSpeechSynthesizer +struct SystemTTSRow: View { + let isLoading: Bool + let onSelect: () async -> Void + + var body: some View { + HStack(spacing: AppSpacing.mediumLarge) { + VStack(alignment: .leading, spacing: AppSpacing.xSmall) { + HStack(spacing: AppSpacing.smallMedium) { + Text("System Voice") + .font(AppTypography.subheadline) + .fontWeight(.medium) + .foregroundColor(AppColors.textPrimary) + + Text("System") + .font(AppTypography.caption2) + .fontWeight(.medium) + .padding(.horizontal, AppSpacing.small) + .padding(.vertical, AppSpacing.xxSmall) + .background(Color.primary.opacity(0.1)) + .foregroundColor(.primary) + .cornerRadius(AppSpacing.cornerRadiusSmall) + } + + HStack(spacing: AppSpacing.xxSmall) { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(AppColors.statusGreen) + .font(AppTypography.caption2) + Text("Built-in - Always available") + .font(AppTypography.caption2) + .foregroundColor(AppColors.statusGreen) + } + } + + Spacer() + + Button("Use") { + Task { await onSelect() } + } + .font(AppTypography.caption) + .fontWeight(.semibold) + .buttonStyle(.borderedProminent) + .tint(AppColors.primaryAccent) + .controlSize(.small) + .disabled(isLoading) + } + .padding(.vertical, AppSpacing.smallMedium) + } +} + +// MARK: - Loading Model Overlay + +struct LoadingModelOverlay: View { + let loadingProgress: String + + var body: some View { + AppColors.overlayMedium + .ignoresSafeArea() + .overlay { + VStack(spacing: AppSpacing.xLarge) { + ProgressView() + .scaleEffect(DeviceFormFactor.current == .desktop ? 1.5 : 1.2) + #if os(macOS) + .controlSize(.large) + #endif + + Text("Loading Model") + .font(AppTypography.headline) + + Text(loadingProgress) + .font(AppTypography.subheadline) + .foregroundColor(AppColors.textSecondary) + .multilineTextAlignment(.center) + .frame(minWidth: 200) + } + .padding(DeviceFormFactor.current == .desktop ? 40 : AppSpacing.xxLarge) + .frame(minWidth: DeviceFormFactor.current == .desktop ? 300 : nil) + .background(AppColors.backgroundPrimary) + .cornerRadius(AppSpacing.cornerRadiusXLarge) + .shadow(radius: AppSpacing.shadowXLarge) + } + } +} + +// MARK: - Device Info Row + +struct DeviceInfoRow: View { + let label: String + let systemImage: String + let value: String + + var body: some View { + HStack { + Label(label, systemImage: systemImage) + Spacer() + Text(value) + .foregroundColor(AppColors.textSecondary) + } + } +} + +// MARK: - Neural Engine Row + +struct NeuralEngineRow: View { + var body: some View { + HStack { + Label("Neural Engine", systemImage: "brain") + Spacer() + Image(systemName: "checkmark.circle.fill") + .foregroundColor(AppColors.statusGreen) + } + } +} + +// MARK: - Loading Device Row + +struct LoadingDeviceRow: View { + var body: some View { + HStack { + ProgressView() + Text("Loading device info...") + .foregroundColor(AppColors.textSecondary) + } + } +} + +// MARK: - Flat Model Row (Consumer-Friendly Design) + +/// A model row designed for flat list display with prominent framework badge +struct FlatModelRow: View { + let model: ModelInfo + let isSelected: Bool + let isLoading: Bool + let onDownloadCompleted: () -> Void + let onSelectModel: () -> Void + let onModelUpdated: () -> Void + + @State private var isDownloading = false + @State private var downloadProgress: Double = 0.0 + + private var frameworkColor: Color { + switch model.framework { + case .llamaCpp: return AppColors.primaryAccent + case .onnx: return .purple + case .foundationModels: return .primary + default: return .gray + } + } + + private var frameworkName: String { + switch model.framework { + case .llamaCpp: return "Fast" + case .onnx: return "ONNX" + case .foundationModels: return "Apple" + case .systemTTS: return "System" + default: return model.framework.displayName + } + } + + /// Check if this is a built-in model that doesn't require download + private var isBuiltIn: Bool { + model.framework == .foundationModels || + model.framework == .systemTTS || + model.artifactType == .builtIn + } + + private var statusIcon: String { + if isBuiltIn { + return "checkmark.circle.fill" + } else if model.localPath != nil { + return "checkmark.circle.fill" + } else { + return "arrow.down.circle" + } + } + + private var statusColor: Color { + if isBuiltIn || model.localPath != nil { + return AppColors.statusGreen + } else { + return AppColors.primaryAccent + } + } + + private var statusText: String { + if isBuiltIn { + return "Built-in" + } else if model.localPath != nil { + return "Ready" + } else { + return "" // Removed "Download" text + } + } + + /// Get logo asset name for model - uses centralized extension + private var modelLogoName: String { + model.logoAssetName + } + + var body: some View { + HStack(spacing: AppSpacing.mediumLarge) { + // Model logo + Image(modelLogoName) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 40, height: 40) + .cornerRadius(8) + + modelInfoView + + Spacer() + + actionButton + } + .padding(.vertical, AppSpacing.smallMedium) + .opacity(isLoading && !isSelected ? 0.6 : 1.0) + } + + private var modelInfoView: some View { + VStack(alignment: .leading, spacing: AppSpacing.xSmall) { + // Model name with framework badge inline + HStack(spacing: AppSpacing.smallMedium) { + Text(model.name) + .font(AppTypography.subheadline) + .fontWeight(.medium) + .foregroundColor(AppColors.textPrimary) + + // Framework badge + Text(frameworkName) + .font(AppTypography.caption2) + .fontWeight(.medium) + .padding(.horizontal, AppSpacing.small) + .padding(.vertical, AppSpacing.xxSmall) + .background(frameworkColor.opacity(0.15)) + .foregroundColor(frameworkColor) + .cornerRadius(AppSpacing.cornerRadiusSmall) + } + + statusRowView + } + } + + private var statusRowView: some View { + HStack(spacing: AppSpacing.smallMedium) { + // Status indicator (no size badge here anymore) + if isDownloading { + HStack(spacing: AppSpacing.xSmall) { + ProgressView() + .scaleEffect(0.6) + Text("\(Int(downloadProgress * 100))%") + .font(AppTypography.caption2) + .foregroundColor(AppColors.textSecondary) + } + } else if !statusText.isEmpty { + HStack(spacing: AppSpacing.xxSmall) { + Image(systemName: statusIcon) + .foregroundColor(statusColor) + .font(AppTypography.caption2) + Text(statusText) + .font(AppTypography.caption2) + .foregroundColor(statusColor) + } + } + + // Thinking support indicator + if model.supportsThinking { + HStack(spacing: AppSpacing.xxSmall) { + Image(systemName: "brain") + Text("Smart") + } + .font(AppTypography.caption2) + .padding(.horizontal, AppSpacing.small) + .padding(.vertical, AppSpacing.xxSmall) + .background(AppColors.badgePurple) + .foregroundColor(AppColors.primaryPurple) + .cornerRadius(AppSpacing.cornerRadiusSmall) + } + } + } + + @ViewBuilder private var actionButton: some View { + if isBuiltIn { + // Built-in models (Foundation Models, System TTS) - always ready + Button("Use") { + onSelectModel() + } + .font(AppTypography.caption) + .fontWeight(.semibold) + .buttonStyle(.borderedProminent) + .tint(AppColors.primaryAccent) + .controlSize(.small) + .disabled(isLoading || isSelected) + } else if model.localPath == nil { + // Model needs to be downloaded + if isDownloading { + ProgressView() + .scaleEffect(0.8) + } else { + Button { + Task { + await downloadModel() + } + } label: { + HStack(spacing: AppSpacing.xxSmall) { + Image(systemName: "arrow.down.circle.fill") + // Show file size instead of "Get" + if let size = model.downloadSize, size > 0 { + Text(ByteCountFormatter.string(fromByteCount: size, countStyle: .memory)) + } else { + Text("Get") + } + } + } + .font(AppTypography.caption) + .fontWeight(.semibold) + .buttonStyle(.bordered) + .tint(AppColors.primaryAccent) + .controlSize(.small) + .disabled(isLoading) + } + } else { + // Model is downloaded - ready to use + Button("Use") { + onSelectModel() + } + .font(AppTypography.caption) + .fontWeight(.semibold) + .buttonStyle(.borderedProminent) + .tint(AppColors.primaryAccent) + .controlSize(.small) + .disabled(isLoading || isSelected) + } + } + + private func downloadModel() async { + await MainActor.run { + isDownloading = true + downloadProgress = 0.0 + } + + do { + let progressStream = try await RunAnywhere.downloadModel(model.id) + + for await progress in progressStream { + await MainActor.run { + self.downloadProgress = progress.percentage + } + + switch progress.state { + case .completed: + await MainActor.run { + self.downloadProgress = 1.0 + self.isDownloading = false + onDownloadCompleted() + } + return + + case .failed: + await MainActor.run { + self.downloadProgress = 0.0 + self.isDownloading = false + } + return + + default: + continue + } + } + } catch { + await MainActor.run { + downloadProgress = 0.0 + isDownloading = false + } + } + } +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Models/ModelSelectionSheet.swift b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Models/ModelSelectionSheet.swift new file mode 100644 index 000000000..25b609c12 --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Models/ModelSelectionSheet.swift @@ -0,0 +1,271 @@ +// +// ModelSelectionSheet.swift +// RunAnywhereAI +// +// Reusable model selection sheet that can be used across the app +// + +import SwiftUI +import RunAnywhere + +// MARK: - Model Selection Context + +/// Context for filtering frameworks and models based on the current experience/modality +enum ModelSelectionContext { + case llm // Chat experience - show LLM frameworks (llama.cpp, Foundation Models) + case stt // Speech-to-Text - show STT frameworks (ONNX STT) + case tts // Text-to-Speech - show TTS frameworks (ONNX TTS/Piper, System TTS) + case voice // Voice Assistant - show all voice-related (LLM + STT + TTS) + + var title: String { + switch self { + case .llm: return "Select LLM Model" + case .stt: return "Select STT Model" + case .tts: return "Select TTS Model" + case .voice: return "Select Model" + } + } + + var relevantCategories: Set { + switch self { + case .llm: + return [.language, .multimodal] + case .stt: + return [.speechRecognition] + case .tts: + return [.speechSynthesis] + case .voice: + return [.language, .multimodal, .speechRecognition, .speechSynthesis] + } + } +} + +struct ModelSelectionSheet: View { + @StateObject private var viewModel = ModelListViewModel.shared + @StateObject private var deviceInfo = DeviceInfoService.shared + + @Environment(\.dismiss) + var dismiss + + @State private var selectedModel: ModelInfo? + @State private var expandedFramework: InferenceFramework? + @State private var availableFrameworks: [InferenceFramework] = [] + @State private var isLoadingModel = false + @State private var loadingProgress: String = "" + + let context: ModelSelectionContext + let onModelSelected: (ModelInfo) async -> Void + + init( + context: ModelSelectionContext = .llm, + onModelSelected: @escaping (ModelInfo) async -> Void + ) { + self.context = context + self.onModelSelected = onModelSelected + } + + private var availableModels: [ModelInfo] { + viewModel.availableModels + .filter { context.relevantCategories.contains($0.category) } + .sorted { modelPriority($0) != modelPriority($1) + ? modelPriority($0) < modelPriority($1) + : $0.name < $1.name + } + } + + private func modelPriority(_ model: ModelInfo) -> Int { + model.framework == .foundationModels ? 0 : (model.localPath != nil ? 1 : 2) + } + + var body: some View { + NavigationStack { + ZStack { + List { + deviceStatusSection + modelsListSection + } + if isLoadingModel { + LoadingModelOverlay(loadingProgress: loadingProgress) + } + } + .navigationTitle(context.title) + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { toolbarContent } + } + .adaptiveSheetFrame() + .task { await loadInitialData() } + } + + @ToolbarContentBuilder private var toolbarContent: some ToolbarContent { + #if os(iOS) + ToolbarItemGroup(placement: .navigationBarLeading) { + Button("Cancel") { dismiss() }.disabled(isLoadingModel) + } + #else + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() }.disabled(isLoadingModel).keyboardShortcut(.escape) + } + #endif + } + + private func loadInitialData() async { + await viewModel.loadModels() + await loadAvailableFrameworks() + } + + private func loadAvailableFrameworks() async { + let allFrameworks = await RunAnywhere.getRegisteredFrameworks() + var filtered = allFrameworks.filter { shouldShowFramework($0) } + if context == .tts && !filtered.contains(.systemTTS) { + filtered.insert(.systemTTS, at: 0) + } + await MainActor.run { self.availableFrameworks = filtered } + } + + private func shouldShowFramework(_ framework: InferenceFramework) -> Bool { + viewModel.availableModels + .filter { $0.framework == framework } + .contains { context.relevantCategories.contains($0.category) } + } +} + +// MARK: - Device Status Section + +extension ModelSelectionSheet { + private var deviceStatusSection: some View { + Section("Device Status") { + if let device = deviceInfo.deviceInfo { + DeviceInfoRow(label: "Model", systemImage: "iphone", value: device.modelName) + DeviceInfoRow(label: "Chip", systemImage: "cpu", value: device.chipName) + DeviceInfoRow( + label: "Memory", + systemImage: "memorychip", + value: ByteCountFormatter.string( + fromByteCount: device.totalMemory, + countStyle: .memory + ) + ) + if device.neuralEngineAvailable { + NeuralEngineRow() + } + } else { + LoadingDeviceRow() + } + } + } +} + +// MARK: - Models List Section + +extension ModelSelectionSheet { + private var modelsListSection: some View { + Section { + if availableModels.isEmpty { + loadingModelsView + } else { + modelsContent + } + } header: { + Text("Choose a Model") + } footer: { + Text( + "All models run privately on your device. " + + "Larger models may provide better quality but use more memory." + ) + .font(AppTypography.caption) + .foregroundColor(AppColors.textSecondary) + } + } + + private var loadingModelsView: some View { + VStack(alignment: .center, spacing: AppSpacing.mediumLarge) { + ProgressView() + Text("Loading available models...") + .font(AppTypography.subheadline) + .foregroundColor(AppColors.textSecondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, AppSpacing.xLarge) + } + + @ViewBuilder private var modelsContent: some View { + // System TTS is now registered via C++ platform backend and shown in model list + ForEach(availableModels, id: \.id) { model in + FlatModelRow( + model: model, + isSelected: selectedModel?.id == model.id, + isLoading: isLoadingModel, + onDownloadCompleted: { Task { await viewModel.loadModels() } }, + onSelectModel: { Task { await selectAndLoadModel(model) } }, + onModelUpdated: { Task { await viewModel.loadModels() } } + ) + } + } +} + +// MARK: - Model Loading Actions + +extension ModelSelectionSheet { + private func selectAndLoadModel(_ model: ModelInfo) async { + if model.framework != .foundationModels { + guard model.localPath != nil else { return } + } + + await MainActor.run { + isLoadingModel = true + loadingProgress = "Initializing \(model.name)..." + selectedModel = model + } + + do { + await MainActor.run { loadingProgress = "Loading model into memory..." } + try await loadModelForContext(model) + await MainActor.run { loadingProgress = "Model loaded successfully!" } + try await Task.sleep(nanoseconds: 500_000_000) + await handleModelLoadSuccess(model) + await MainActor.run { dismiss() } + } catch { + await MainActor.run { + isLoadingModel = false + loadingProgress = "" + selectedModel = nil + } + print("Failed to load model: \(error)") + } + } + + private func loadModelForContext(_ model: ModelInfo) async throws { + switch context { + case .llm: try await RunAnywhere.loadModel(model.id) + case .stt: try await RunAnywhere.loadSTTModel(model.id) + case .tts: try await RunAnywhere.loadTTSModel(model.id) + case .voice: try await loadModelForVoiceContext(model) + } + } + + private func loadModelForVoiceContext(_ model: ModelInfo) async throws { + switch model.category { + case .speechRecognition: try await RunAnywhere.loadSTTModel(model.id) + case .speechSynthesis: try await RunAnywhere.loadTTSModel(model.id) + default: try await RunAnywhere.loadModel(model.id) + } + } + + private func handleModelLoadSuccess(_ model: ModelInfo) async { + let isLLM = context == .llm || + (context == .voice && [.language, .multimodal].contains(model.category)) + + if isLLM { + await viewModel.setCurrentModel(model) + await MainActor.run { + NotificationCenter.default.post( + name: Notification.Name("ModelLoaded"), + object: model + ) + } + } + await onModelSelected(model) + } +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Models/ModelStatusComponents.swift b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Models/ModelStatusComponents.swift new file mode 100644 index 000000000..5bb479785 --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Models/ModelStatusComponents.swift @@ -0,0 +1,705 @@ +// +// ModelStatusComponents.swift +// RunAnywhereAI +// +// Reusable components for displaying model status and onboarding +// + +import SwiftUI +import RunAnywhere +#if os(macOS) +import AppKit +#endif + +// MARK: - Model Load State (Local UI type) + +/// Simple enum to track model loading state in the UI +enum ModelLoadState: Equatable { + case notLoaded + case loading + case loaded + case error(String) + + var isLoaded: Bool { + if case .loaded = self { return true } + return false + } + + var isLoading: Bool { + if case .loading = self { return true } + return false + } +} + +// MARK: - Model Status Banner + +/// A banner that shows the current model status (framework + model name) or prompts to select a model +struct ModelStatusBanner: View { + let framework: InferenceFramework? + let modelName: String? + let isLoading: Bool + let supportsStreaming: Bool + let onSelectModel: () -> Void + + init(framework: InferenceFramework?, modelName: String?, isLoading: Bool, supportsStreaming: Bool = true, onSelectModel: @escaping () -> Void) { + self.framework = framework + self.modelName = modelName + self.isLoading = isLoading + self.supportsStreaming = supportsStreaming + self.onSelectModel = onSelectModel + } + + var body: some View { + HStack(spacing: 12) { + if isLoading { + // Loading state + HStack(spacing: 8) { + ProgressView() + .scaleEffect(0.8) + Text("Loading model...") + .font(.subheadline) + .foregroundColor(.secondary) + } + } else if let framework = framework, let modelName = modelName { + // Model loaded state + HStack(spacing: 8) { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + .font(.system(size: 14, weight: .semibold)) + + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(framework.displayName) + .font(.caption2) + .foregroundColor(.secondary) + + // Streaming mode indicator + streamingModeIndicator + } + Text(modelName) + .font(.subheadline) + .fontWeight(.medium) + .lineLimit(1) + } + + Spacer() + + Button(action: onSelectModel) { + Text("Change") + .font(.caption) + .fontWeight(.medium) + } + .buttonStyle(.bordered) + .tint(AppColors.primaryAccent) + .controlSize(.small) + } + } else { + // No model state + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + + Text("No model selected") + .font(.subheadline) + .foregroundColor(.secondary) + + Spacer() + + Button(action: onSelectModel) { + HStack(spacing: 4) { + Image(systemName: "cube.fill") + Text("Select Model") + } + .font(.subheadline) + .fontWeight(.semibold) + } + .buttonStyle(.borderedProminent) + .tint(AppColors.primaryAccent) + .controlSize(.small) + } + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + #if os(iOS) + .background(Color(.secondarySystemBackground)) + #else + .background(Color(NSColor.controlBackgroundColor)) + #endif + .cornerRadius(12) + } + + /// Streaming mode indicator badge + @ViewBuilder private var streamingModeIndicator: some View { + HStack(spacing: 3) { + Image(systemName: supportsStreaming ? "bolt.fill" : "square.fill") + .font(.system(size: 8)) + Text(supportsStreaming ? "Streaming" : "Batch") + .font(.system(size: 9, weight: .medium)) + } + .foregroundColor(supportsStreaming ? .green : .orange) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background( + Capsule() + .fill(supportsStreaming ? Color.green.opacity(0.15) : Color.orange.opacity(0.15)) + ) + } + + private func frameworkIcon(for framework: InferenceFramework) -> String { + switch framework { + case .llamaCpp: return "cpu" + case .onnx: return "square.stack.3d.up" + case .foundationModels: return "apple.logo" + default: return "cube" + } + } + + private func frameworkColor(for framework: InferenceFramework) -> Color { + switch framework { + case .llamaCpp: return AppColors.primaryAccent + case .onnx: return .purple + case .foundationModels: return .primary + default: return .gray + } + } +} + +// MARK: - Model Required Overlay + +/// An overlay that covers the screen when no model is selected, prompting the user to select one +struct ModelRequiredOverlay: View { + let modality: ModelSelectionContext + let onSelectModel: () -> Void + + @State private var circle1Offset: CGFloat = -100 + @State private var circle2Offset: CGFloat = 100 + @State private var circle3Offset: CGFloat = 0 + + var body: some View { + ZStack { + // Animated floating circles background + ZStack { + // Circle 1 - Top left + Circle() + .fill(modalityColor.opacity(0.15)) + .blur(radius: 80) + .frame(width: 300, height: 300) + .offset(x: circle1Offset, y: -200) + + // Circle 2 - Bottom right + Circle() + .fill(modalityColor.opacity(0.12)) + .blur(radius: 100) + .frame(width: 250, height: 250) + .offset(x: circle2Offset, y: 300) + + // Circle 3 - Center + Circle() + .fill(modalityColor.opacity(0.08)) + .blur(radius: 90) + .frame(width: 280, height: 280) + .offset(x: -circle3Offset, y: circle3Offset) + } + .ignoresSafeArea() + .onAppear { + withAnimation( + .easeInOut(duration: 8) + .repeatForever(autoreverses: true) + ) { + circle1Offset = 100 + circle2Offset = -100 + circle3Offset = 80 + } + } + + VStack(spacing: AppSpacing.xLarge) { + Spacer() + + // Friendly icon with gradient background + ZStack { + Circle() + .fill(LinearGradient( + colors: [modalityColor.opacity(0.2), modalityColor.opacity(0.1)], + startPoint: .topLeading, + endPoint: .bottomTrailing + )) + .frame(width: 120, height: 120) + + Image(systemName: modalityIcon) + .font(.system(size: 48)) + .foregroundStyle( + LinearGradient( + colors: [modalityColor, modalityColor.opacity(0.7)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + } + + // Title + Text(modalityTitle) + .font(.title2) + .fontWeight(.bold) + + // Description + Text(modalityDescription) + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + + Spacer() + + // Bottom section with glass effect button + VStack(spacing: AppSpacing.medium) { + // Primary CTA with glass effect + if #available(iOS 26.0, *) { + Button(action: onSelectModel) { + HStack(spacing: 8) { + Image(systemName: "sparkles") + Text("Get Started") + } + .font(.headline) + .foregroundColor(modalityColor) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background { + RoundedRectangle(cornerRadius: 16) + .fill(.thinMaterial) + .glassEffect(.regular.interactive(), in: RoundedRectangle(cornerRadius: 16)) + } + } + .buttonStyle(.plain) + .padding(.horizontal, AppSpacing.xLarge) + } else { + Button(action: onSelectModel) { + HStack(spacing: 8) { + Image(systemName: "sparkles") + Text("Get Started") + } + .font(.headline) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + } + .buttonStyle(.borderedProminent) + .tint(modalityColor) + .padding(.horizontal, AppSpacing.xLarge) + } + + // Privacy note + HStack(spacing: 6) { + Image(systemName: "lock.shield.fill") + .font(.caption2) + Text("100% Private • Runs on your device") + .font(.caption) + } + .foregroundColor(.secondary) + } + .padding(.bottom, AppSpacing.large) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + #if os(iOS) + .background(Color(.systemBackground)) + #else + .background(Color(NSColor.windowBackgroundColor)) + #endif + } + + private var modalityIcon: String { + switch modality { + case .llm: return "sparkles" + case .stt: return "waveform" + case .tts: return "speaker.wave.2.fill" + case .voice: return "mic.circle.fill" + } + } + + private var modalityColor: Color { + switch modality { + case .llm: return AppColors.primaryAccent + case .stt: return .green + case .tts: return AppColors.primaryPurple + case .voice: return AppColors.primaryAccent + } + } + + private var modalityTitle: String { + switch modality { + case .llm: return "Welcome!" + case .stt: return "Voice to Text" + case .tts: return "Read Aloud" + case .voice: return "Voice Assistant" + } + } + + private var modalityDescription: String { + switch modality { + case .llm: return "Choose your AI assistant and start chatting. Everything runs privately on your device." + case .stt: return "Transcribe your speech to text with powerful on-device voice recognition." + case .tts: return "Have any text read aloud with natural-sounding voices." + case .voice: return "Talk naturally with your AI assistant. Let's set up the components together." + } + } +} + +// MARK: - Voice Pipeline Setup View + +/// A setup view specifically for Voice Assistant which requires 3 models +struct VoicePipelineSetupView: View { + @Binding var sttModel: SelectedModelInfo? + @Binding var llmModel: SelectedModelInfo? + @Binding var ttsModel: SelectedModelInfo? + + // Model loading states from SDK lifecycle tracker + var sttLoadState: ModelLoadState = .notLoaded + var llmLoadState: ModelLoadState = .notLoaded + var ttsLoadState: ModelLoadState = .notLoaded + + let onSelectSTT: () -> Void + let onSelectLLM: () -> Void + let onSelectTTS: () -> Void + let onStartVoice: () -> Void + + var allModelsReady: Bool { + sttModel != nil && llmModel != nil && ttsModel != nil + } + + var allModelsLoaded: Bool { + sttLoadState.isLoaded && llmLoadState.isLoaded && ttsLoadState.isLoaded + } + + var body: some View { + VStack(spacing: 24) { + // Header + VStack(spacing: 8) { + Image(systemName: "mic.circle.fill") + .font(.system(size: 48)) + .foregroundColor(AppColors.primaryAccent) + + Text("Voice Assistant Setup") + .font(.title2) + .fontWeight(.bold) + + Text("Voice requires 3 models to work together") + .font(.subheadline) + .foregroundColor(.secondary) + } + .padding(.top, 20) + + // Model cards with load state + VStack(spacing: 16) { + // STT Model + ModelSetupCard( + step: 1, + title: "Speech Recognition", + subtitle: "Converts your voice to text", + icon: "waveform", + color: .green, + selectedFramework: sttModel?.framework, + selectedModel: sttModel?.name, + loadState: sttLoadState, + onSelect: onSelectSTT + ) + + // LLM Model + ModelSetupCard( + step: 2, + title: "Language Model", + subtitle: "Processes and responds to your input", + icon: "brain", + color: AppColors.primaryAccent, + selectedFramework: llmModel?.framework, + selectedModel: llmModel?.name, + loadState: llmLoadState, + onSelect: onSelectLLM + ) + + // TTS Model + ModelSetupCard( + step: 3, + title: "Text to Speech", + subtitle: "Converts responses to audio", + icon: "speaker.wave.2", + color: .purple, + selectedFramework: ttsModel?.framework, + selectedModel: ttsModel?.name, + loadState: ttsLoadState, + onSelect: onSelectTTS + ) + } + .padding(.horizontal) + + Spacer() + + // Start button - enabled only when all models are loaded + Button(action: onStartVoice) { + HStack(spacing: 8) { + Image(systemName: "mic.fill") + Text("Start Voice Assistant") + } + .font(.headline) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + } + .buttonStyle(.borderedProminent) + .tint(AppColors.primaryAccent) + .disabled(!allModelsLoaded) + .padding(.horizontal) + .padding(.bottom, 20) + + // Status message + if !allModelsReady { + Text("Select all 3 models to continue") + .font(.caption) + .foregroundColor(.secondary) + .padding(.bottom, 10) + } else if !allModelsLoaded { + Text("Waiting for models to load...") + .font(.caption) + .foregroundColor(.orange) + .padding(.bottom, 10) + } else { + Text("All models loaded and ready!") + .font(.caption) + .foregroundColor(.green) + .padding(.bottom, 10) + } + } + } +} + +// MARK: - Model Setup Card (for Voice Pipeline) + +struct ModelSetupCard: View { + let step: Int + let title: String + let subtitle: String + let icon: String + let color: Color + let selectedFramework: InferenceFramework? + let selectedModel: String? + var loadState: ModelLoadState = .notLoaded + let onSelect: () -> Void + + var isConfigured: Bool { + selectedFramework != nil && selectedModel != nil + } + + var isLoaded: Bool { + loadState.isLoaded + } + + var isLoading: Bool { + loadState.isLoading + } + + var body: some View { + Button(action: onSelect) { + HStack(spacing: 16) { + // Step indicator with loading/loaded state + ZStack { + Circle() + .fill(stepIndicatorColor) + .frame(width: 36, height: 36) + + if isLoading { + ProgressView() + .scaleEffect(0.7) + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + } else if isLoaded { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 16, weight: .bold)) + .foregroundColor(.white) + } else if isConfigured { + Image(systemName: "checkmark") + .font(.system(size: 16, weight: .bold)) + .foregroundColor(.white) + } else { + Text("\(step)") + .font(.system(size: 16, weight: .bold)) + .foregroundColor(.gray) + } + } + + // Content + VStack(alignment: .leading, spacing: 4) { + HStack { + Image(systemName: icon) + .foregroundColor(color) + Text(title) + .font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(.primary) + } + + if let model = selectedModel { + HStack(spacing: 4) { + Text(model) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + + if isLoaded { + Image(systemName: "checkmark.circle.fill") + .font(.caption2) + .foregroundColor(.green) + } else if isLoading { + Text("Loading...") + .font(.caption2) + .foregroundColor(.orange) + } + } + } else { + Text(subtitle) + .font(.caption) + .foregroundColor(.secondary) + } + } + + Spacer() + + // Action / Status + if isLoading { + ProgressView() + .scaleEffect(0.7) + } else if isLoaded { + HStack(spacing: 4) { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + Text("Loaded") + .font(.caption) + .foregroundColor(.green) + } + } else if isConfigured { + Text("Change") + .font(.caption) + .foregroundColor(AppColors.primaryAccent) + } else { + HStack(spacing: 4) { + Text("Select") + Image(systemName: "chevron.right") + } + .font(.caption) + .fontWeight(.medium) + .foregroundColor(AppColors.primaryAccent) + } + } + .padding(16) + #if os(iOS) + .background(Color(.secondarySystemBackground)) + #else + .background(Color(NSColor.controlBackgroundColor)) + #endif + .cornerRadius(12) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(borderColor, lineWidth: 2) + ) + } + .buttonStyle(.plain) + } + + private var stepIndicatorColor: Color { + if isLoading { + return .orange + } else if isLoaded { + return .green + } else if isConfigured { + return color + } else { + return Color.gray.opacity(0.2) + } + } + + private var borderColor: Color { + if isLoaded { + return .green.opacity(0.5) + } else if isLoading { + return .orange.opacity(0.5) + } else if isConfigured { + return color.opacity(0.5) + } else { + return .clear + } + } +} + +// MARK: - Compact Model Indicator (for headers) + +/// A compact indicator showing current model status for use in navigation bars +struct CompactModelIndicator: View { + let framework: InferenceFramework? + let modelName: String? + let isLoading: Bool + let onTap: () -> Void + + var body: some View { + Button(action: onTap) { + HStack(spacing: 6) { + if isLoading { + ProgressView() + .scaleEffect(0.7) + } else if let framework = framework { + Circle() + .fill(frameworkColor(for: framework)) + .frame(width: 8, height: 8) + + Text(modelName ?? framework.displayName) + .font(.caption) + .lineLimit(1) + } else { + Image(systemName: "cube") + .font(.caption) + Text("Select Model") + .font(.caption) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(framework != nil ? AppColors.primaryAccent.opacity(0.1) : AppColors.primaryAccent.opacity(0.2)) + .foregroundColor(AppColors.primaryAccent) + .cornerRadius(8) + } + } + + private func frameworkColor(for framework: InferenceFramework) -> Color { + switch framework { + case .llamaCpp: return AppColors.primaryAccent + case .onnx: return .purple + case .foundationModels: return .primary + default: return .gray + } + } +} + +// MARK: - Previews + +#Preview("Model Status Banner - Loaded") { + VStack(spacing: 20) { + ModelStatusBanner( + framework: .llamaCpp, + modelName: "SmolLM2-135M", + isLoading: false + ) {} + + ModelStatusBanner( + framework: nil, + modelName: nil, + isLoading: false + ) {} + + ModelStatusBanner( + framework: .onnx, + modelName: "whisper-tiny", + isLoading: true + ) {} + } + .padding() +} + +#Preview("Model Required Overlay") { + ModelRequiredOverlay(modality: .stt) {} +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Models/SimplifiedModelsView.swift b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Models/SimplifiedModelsView.swift new file mode 100644 index 000000000..993cd1f47 --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Models/SimplifiedModelsView.swift @@ -0,0 +1,388 @@ +// +// SimplifiedModelsView.swift +// RunAnywhereAI +// +// A simplified models view for managing AI models +// + +import SwiftUI +import RunAnywhere + +struct SimplifiedModelsView: View { + @StateObject private var viewModel = ModelListViewModel.shared + @StateObject private var deviceInfo = DeviceInfoService.shared + + @State private var selectedModel: ModelInfo? + @State private var expandedFramework: InferenceFramework? + @State private var availableFrameworks: [InferenceFramework] = [] + @State private var showingAddModelSheet = false + + /// All available models sorted by availability (downloaded first) + private var sortedModels: [ModelInfo] { + viewModel.availableModels.sorted { model1, model2 in + let m1BuiltIn = model1.framework == .foundationModels + || model1.framework == .systemTTS + || model1.artifactType == .builtIn + let m2BuiltIn = model2.framework == .foundationModels + || model2.framework == .systemTTS + || model2.artifactType == .builtIn + let m1Priority = m1BuiltIn ? 0 : (model1.localPath != nil ? 1 : 2) + let m2Priority = m2BuiltIn ? 0 : (model2.localPath != nil ? 1 : 2) + if m1Priority != m2Priority { + return m1Priority < m2Priority + } + return model1.name < model2.name + } + } + + var body: some View { + NavigationView { + mainContentView + } + } + + private var mainContentView: some View { + List { + deviceStatusSection + modelsListSection + } + .navigationTitle("Models") + .task { + await loadInitialData() + } + } + + private func loadInitialData() async { + await viewModel.loadModels() + await loadAvailableFrameworks() + } + + private func loadAvailableFrameworks() async { + // Get available frameworks from SDK - derived from registered models + let frameworks = await RunAnywhere.getRegisteredFrameworks() + await MainActor.run { + self.availableFrameworks = frameworks + } + } + + private var deviceStatusSection: some View { + Section("Device Status") { + if let device = deviceInfo.deviceInfo { + deviceInfoRows(device) + } else { + loadingDeviceRow + } + } + } + + private func deviceInfoRows(_ device: SystemDeviceInfo) -> some View { + Group { + deviceInfoRow(label: "Model", systemImage: "iphone", value: device.modelName) + deviceInfoRow(label: "Chip", systemImage: "cpu", value: device.chipName) + deviceInfoRow( + label: "Memory", + systemImage: "memorychip", + value: ByteCountFormatter.string(fromByteCount: device.totalMemory, countStyle: .memory) + ) + + if device.neuralEngineAvailable { + neuralEngineRow + } + } + } + + private func deviceInfoRow(label: String, systemImage: String, value: String) -> some View { + HStack { + Label(label, systemImage: systemImage) + Spacer() + Text(value) + .foregroundColor(AppColors.textSecondary) + } + } + + private var neuralEngineRow: some View { + HStack { + Label("Neural Engine", systemImage: "brain") + Spacer() + Image(systemName: "checkmark.circle.fill") + .foregroundColor(AppColors.statusGreen) + } + } + + private var loadingDeviceRow: some View { + HStack { + ProgressView() + Text("Loading device info...") + .foregroundColor(AppColors.textSecondary) + } + } + + /// Flat list of all models with framework badges + private var modelsListSection: some View { + Section { + if sortedModels.isEmpty { + VStack(alignment: .center, spacing: AppSpacing.mediumLarge) { + ProgressView() + Text("Loading models...") + .font(AppTypography.subheadline) + .foregroundColor(AppColors.textSecondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, AppSpacing.xLarge) + } else { + ForEach(sortedModels, id: \.id) { model in + SimplifiedModelRow( + model: model, + isSelected: selectedModel?.id == model.id, + onDownloadCompleted: { + Task { + await viewModel.loadModels() + } + }, + onSelectModel: { + Task { + await selectModel(model) + } + }, + onModelUpdated: { + Task { + await viewModel.loadModels() + } + } + ) + } + } + } header: { + Text("Available Models") + } footer: { + Text("All models run privately on your device. Downloaded models are ready to use.") + .font(AppTypography.caption) + .foregroundColor(AppColors.textSecondary) + } + } + + private func selectModel(_ model: ModelInfo) async { + selectedModel = model + + // Update the view model state + await viewModel.selectModel(model) + } +} + +// MARK: - Supporting Views + +/// Simplified model row with framework badge for flat list display +private struct SimplifiedModelRow: View { + let model: ModelInfo + let isSelected: Bool + let onDownloadCompleted: () -> Void + let onSelectModel: () -> Void + let onModelUpdated: () -> Void + + @State private var isDownloading = false + @State private var downloadProgress: Double = 0.0 + + private var frameworkColor: Color { + switch model.framework { + case .llamaCpp: return AppColors.primaryAccent + case .onnx: return .purple + case .foundationModels: return .primary + case .systemTTS: return .primary + default: return .gray + } + } + + private var frameworkName: String { + switch model.framework { + case .llamaCpp: return "Fast" + case .onnx: return "ONNX" + case .foundationModels: return "Apple" + case .systemTTS: return "System" + default: return model.framework.displayName + } + } + + /// Check if this is a built-in model that doesn't require download + private var isBuiltIn: Bool { + model.framework == .foundationModels || + model.framework == .systemTTS || + model.artifactType == .builtIn + } + + private var isReady: Bool { + isBuiltIn || model.localPath != nil + } + + var body: some View { + HStack(spacing: AppSpacing.mediumLarge) { + // Model info with framework badge + VStack(alignment: .leading, spacing: AppSpacing.xSmall) { + // Name with framework badge + HStack(spacing: AppSpacing.smallMedium) { + Text(model.name) + .font(AppTypography.subheadline) + .fontWeight(.medium) + .foregroundColor(AppColors.textPrimary) + + Text(frameworkName) + .font(AppTypography.caption2) + .fontWeight(.medium) + .padding(.horizontal, AppSpacing.small) + .padding(.vertical, AppSpacing.xxSmall) + .background(frameworkColor.opacity(0.15)) + .foregroundColor(frameworkColor) + .cornerRadius(AppSpacing.cornerRadiusSmall) + } + + // Size and status + HStack(spacing: AppSpacing.smallMedium) { + if let size = model.downloadSize, size > 0 { + Label( + ByteCountFormatter.string(fromByteCount: size, countStyle: .memory), + systemImage: "memorychip" + ) + .font(AppTypography.caption2) + .foregroundColor(AppColors.textSecondary) + } + + if isDownloading { + HStack(spacing: AppSpacing.xSmall) { + ProgressView() + .scaleEffect(0.6) + Text("\(Int(downloadProgress * 100))%") + .font(AppTypography.caption2) + .foregroundColor(AppColors.textSecondary) + } + } else { + HStack(spacing: AppSpacing.xxSmall) { + Image(systemName: isReady ? "checkmark.circle.fill" : "arrow.down.circle") + .foregroundColor(isReady ? AppColors.statusGreen : AppColors.primaryAccent) + .font(AppTypography.caption2) + let statusText = isBuiltIn + ? "Built-in" + : (model.localPath != nil ? "Ready" : "Download") + Text(statusText) + .font(AppTypography.caption2) + .foregroundColor(isReady ? AppColors.statusGreen : AppColors.primaryAccent) + } + } + + if model.supportsThinking { + HStack(spacing: AppSpacing.xxSmall) { + Image(systemName: "brain") + Text("Smart") + } + .font(AppTypography.caption2) + .padding(.horizontal, AppSpacing.small) + .padding(.vertical, AppSpacing.xxSmall) + .background(AppColors.badgePurple) + .foregroundColor(AppColors.primaryPurple) + .cornerRadius(AppSpacing.cornerRadiusSmall) + } + } + } + + Spacer() + + // Action button + if isBuiltIn { + // Built-in models (Foundation Models, System TTS) - always ready + Button("Use") { + onSelectModel() + } + .font(AppTypography.caption) + .fontWeight(.semibold) + .buttonStyle(.borderedProminent) + .tint(AppColors.primaryAccent) + .controlSize(.small) + .disabled(isSelected) + } else if model.localPath == nil { + if isDownloading { + ProgressView() + .scaleEffect(0.8) + } else { + Button { + Task { + await downloadModel() + } + } label: { + HStack(spacing: AppSpacing.xxSmall) { + Image(systemName: "arrow.down.circle.fill") + Text("Get") + } + } + .font(AppTypography.caption) + .fontWeight(.semibold) + .buttonStyle(.bordered) + .tint(AppColors.primaryAccent) + .controlSize(.small) + } + } else { + if isSelected { + HStack(spacing: AppSpacing.xxSmall) { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(AppColors.statusGreen) + Text("Active") + .font(AppTypography.caption2) + .foregroundColor(AppColors.statusGreen) + } + } else { + Button("Use") { + onSelectModel() + } + .font(AppTypography.caption) + .fontWeight(.semibold) + .buttonStyle(.borderedProminent) + .tint(AppColors.primaryAccent) + .controlSize(.small) + } + } + } + .padding(.vertical, AppSpacing.smallMedium) + } + + private func downloadModel() async { + await MainActor.run { + isDownloading = true + downloadProgress = 0.0 + } + + do { + // Use the new convenience download API + let progressStream = try await RunAnywhere.downloadModel(model.id) + + for await progress in progressStream { + await MainActor.run { + self.downloadProgress = progress.overallProgress + print("Download progress for \(model.name): \(Int(progress.overallProgress * 100))%") + } + + // Check if download completed + if progress.stage == .completed { + await MainActor.run { + self.downloadProgress = 1.0 + self.isDownloading = false + onDownloadCompleted() + } + return + } + } + + // If we exit the loop normally, download completed + await MainActor.run { + self.downloadProgress = 1.0 + self.isDownloading = false + onDownloadCompleted() + } + } catch { + await MainActor.run { + downloadProgress = 0.0 + isDownloading = false + } + } + } +} + +#Preview { + SimplifiedModelsView() +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Settings/CombinedSettingsView.swift b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Settings/CombinedSettingsView.swift new file mode 100644 index 000000000..26c0f17f9 --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Settings/CombinedSettingsView.swift @@ -0,0 +1,827 @@ +// +// CombinedSettingsView.swift +// RunAnywhereAI +// +// Combined Settings and Storage view +// Refactored to use SettingsViewModel (MVVM pattern) +// + +import SwiftUI +import RunAnywhere +import Combine + +struct CombinedSettingsView: View { + // ViewModel - all business logic is here + @StateObject private var viewModel = SettingsViewModel() + + var body: some View { + Group { + #if os(macOS) + MacOSSettingsContent(viewModel: viewModel) + #else + IOSSettingsContent(viewModel: viewModel) + #endif + } + .sheet(isPresented: $viewModel.showApiKeyEntry) { + ApiConfigurationSheet(viewModel: viewModel) + } + .task { + await viewModel.loadStorageData() + } + .alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) { + Button("OK") { + viewModel.errorMessage = nil + } + } message: { + if let error = viewModel.errorMessage { + Text(error) + } + } + .alert("Restart Required", isPresented: $viewModel.showRestartAlert) { + Button("OK") { + viewModel.showRestartAlert = false + } + } message: { + Text("Please restart the app for the new API configuration to take effect. The SDK will be reinitialized with your custom settings.") + } + } +} + +// MARK: - iOS Layout + +private struct IOSSettingsContent: View { + @ObservedObject var viewModel: SettingsViewModel + + var body: some View { + Form { + // Generation Settings + Section("Generation Settings") { + VStack(alignment: .leading) { + Text("Temperature: \(String(format: "%.2f", viewModel.temperature))") + .font(AppTypography.caption) + .foregroundColor(AppColors.textSecondary) + Slider(value: $viewModel.temperature, in: 0...2, step: 0.1) + } + + Stepper( + "Max Tokens: \(viewModel.maxTokens)", + value: $viewModel.maxTokens, + in: 500...20000, + step: 500 + ) + } + + // API Configuration (for testing custom backend) + Section { + Button( + action: { viewModel.showApiKeySheet() }, + label: { + HStack { + Text("API Key") + Spacer() + if viewModel.isApiKeyConfigured { + Text("Configured") + .foregroundColor(AppColors.statusGreen) + .font(AppTypography.caption) + } else { + Text("Not Set") + .foregroundColor(AppColors.statusOrange) + .font(AppTypography.caption) + } + } + } + ) + + HStack { + Text("Base URL") + Spacer() + if viewModel.isBaseURLConfigured { + Text("Configured") + .foregroundColor(AppColors.statusGreen) + .font(AppTypography.caption) + } else { + Text("Using Default") + .foregroundColor(AppColors.textSecondary) + .font(AppTypography.caption) + } + } + + if viewModel.isApiConfigurationComplete { + Button( + action: { viewModel.clearApiConfiguration() }, + label: { + HStack { + Image(systemName: "trash") + .foregroundColor(AppColors.primaryRed) + Text("Clear Custom Configuration") + .foregroundColor(AppColors.primaryRed) + } + } + ) + } + } header: { + Text("API Configuration (Testing)") + } footer: { + Text("Configure custom API key and base URL for testing. Requires app restart to take effect.") + .font(AppTypography.caption) + } + + // Storage Overview Section + Section { + StorageOverviewRows(viewModel: viewModel) + } header: { + HStack { + Text("Storage Overview") + Spacer() + Button("Refresh") { + Task { + await viewModel.refreshStorageData() + } + } + .font(AppTypography.caption) + } + } + + // Downloaded Models Section + Section("Downloaded Models") { + if viewModel.storedModels.isEmpty { + Text("No models downloaded yet") + .foregroundColor(AppColors.textSecondary) + .font(AppTypography.caption) + } else { + ForEach(viewModel.storedModels, id: \.id) { model in + StoredModelRow(model: model) { + await viewModel.deleteModel(model) + } + } + } + } + + // Storage Management + Section("Storage Management") { + Button( + action: { + Task { + await viewModel.clearCache() + } + }, + label: { + HStack { + Image(systemName: "trash") + .foregroundColor(AppColors.primaryRed) + Text("Clear Cache") + .foregroundColor(AppColors.primaryRed) + Spacer() + } + } + ) + + Button( + action: { + Task { + await viewModel.cleanTempFiles() + } + }, + label: { + HStack { + Image(systemName: "trash") + .foregroundColor(AppColors.primaryOrange) + Text("Clean Temporary Files") + .foregroundColor(AppColors.primaryOrange) + Spacer() + } + } + ) + } + + // Logging Configuration + Section("Logging Configuration") { + Toggle("Log Analytics Locally", isOn: $viewModel.analyticsLogToLocal) + + Text("When enabled, analytics events will be saved locally on your device.") + .font(AppTypography.caption) + .foregroundColor(AppColors.textSecondary) + } + + // About + Section { + VStack(alignment: .leading, spacing: AppSpacing.smallMedium) { + Label("RunAnywhere SDK", systemImage: "cube") + .font(AppTypography.headline) + Text("Version 0.1") + .font(AppTypography.caption) + .foregroundColor(AppColors.textSecondary) + } + + if let docsURL = URL(string: "https://docs.runanywhere.ai") { + Link(destination: docsURL) { + Label("Documentation", systemImage: "book") + } + } + } header: { + Text("About") + } + } + .navigationTitle("Settings") + } +} + +// MARK: - macOS Layout + +private struct MacOSSettingsContent: View { + @ObservedObject var viewModel: SettingsViewModel + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: AppSpacing.xxLarge) { + Text("Settings") + .font(AppTypography.largeTitleBold) + .padding(.bottom, AppSpacing.medium) + + GenerationSettingsCard(viewModel: viewModel) + APIConfigurationCard(viewModel: viewModel) + StorageCard(viewModel: viewModel) + DownloadedModelsCard(viewModel: viewModel) + StorageManagementCard(viewModel: viewModel) + LoggingConfigurationCard(viewModel: viewModel) + AboutCard() + + Spacer() + } + .padding(AppSpacing.xxLarge) + .frame(maxWidth: AppLayout.maxContentWidth, alignment: .leading) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(AppColors.backgroundPrimary) + } +} + +// MARK: - macOS Settings Cards + +private struct GenerationSettingsCard: View { + @ObservedObject var viewModel: SettingsViewModel + + var body: some View { + SettingsCard(title: "Generation Settings") { + VStack(alignment: .leading, spacing: AppSpacing.xLarge) { + VStack(alignment: .leading, spacing: AppSpacing.smallMedium) { + HStack { + Text("Temperature") + .frame(width: 150, alignment: .leading) + Text("\(String(format: "%.2f", viewModel.temperature))") + .font(AppTypography.monospaced) + .foregroundColor(AppColors.primaryAccent) + } + HStack { + Text("") + .frame(width: 150) + Slider(value: $viewModel.temperature, in: 0...2, step: 0.1) + .frame(maxWidth: 400) + } + } + + HStack { + Text("Max Tokens") + .frame(width: 150, alignment: .leading) + Stepper( + "\(viewModel.maxTokens)", + value: $viewModel.maxTokens, + in: 500...20000, + step: 500 + ) + .frame(maxWidth: 200) + } + } + } + } +} + +private struct APIConfigurationCard: View { + @ObservedObject var viewModel: SettingsViewModel + + var body: some View { + SettingsCard(title: "API Configuration (Testing)") { + VStack(alignment: .leading, spacing: AppSpacing.padding15) { + HStack { + Text("API Key") + .frame(width: 150, alignment: .leading) + + if viewModel.isApiKeyConfigured { + Text("Configured") + .foregroundColor(AppColors.statusGreen) + .font(AppTypography.caption) + } else { + Text("Not Set") + .foregroundColor(AppColors.statusOrange) + .font(AppTypography.caption) + } + + Spacer() + } + + HStack { + Text("Base URL") + .frame(width: 150, alignment: .leading) + + if viewModel.isBaseURLConfigured { + Text("Configured") + .foregroundColor(AppColors.statusGreen) + .font(AppTypography.caption) + } else { + Text("Using Default") + .foregroundColor(AppColors.textSecondary) + .font(AppTypography.caption) + } + + Spacer() + } + + HStack { + Button("Configure") { + viewModel.showApiKeySheet() + } + .buttonStyle(.bordered) + .tint(AppColors.primaryAccent) + + if viewModel.isApiConfigurationComplete { + Button("Clear") { + viewModel.clearApiConfiguration() + } + .buttonStyle(.bordered) + .tint(AppColors.primaryRed) + } + } + + Text("Configure custom API key and base URL for testing. Requires app restart.") + .font(AppTypography.caption) + .foregroundColor(AppColors.textSecondary) + } + } + } +} + +private struct StorageCard: View { + @ObservedObject var viewModel: SettingsViewModel + + var body: some View { + SettingsCardWithTrailing( + title: "Storage", + trailing: { + Button( + action: { + Task { + await viewModel.refreshStorageData() + } + }, + label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + ) + .buttonStyle(.bordered) + .tint(AppColors.primaryAccent) + }, + content: { + VStack(alignment: .leading, spacing: AppSpacing.large) { + StorageOverviewRows(viewModel: viewModel) + } + } + ) + } +} + +private struct DownloadedModelsCard: View { + @ObservedObject var viewModel: SettingsViewModel + + var body: some View { + SettingsCard(title: "Downloaded Models") { + VStack(alignment: .leading, spacing: AppSpacing.mediumLarge) { + if viewModel.storedModels.isEmpty { + HStack { + Spacer() + VStack(spacing: AppSpacing.mediumLarge) { + Image(systemName: "cube") + .font(AppTypography.system48) + .foregroundColor(AppColors.textSecondary.opacity(0.5)) + Text("No models downloaded yet") + .foregroundColor(AppColors.textSecondary) + .font(AppTypography.callout) + } + .padding(.vertical, AppSpacing.xxLarge) + Spacer() + } + } else { + ForEach(viewModel.storedModels, id: \.id) { model in + StoredModelRow(model: model) { + await viewModel.deleteModel(model) + } + if model.id != viewModel.storedModels.last?.id { + Divider() + .padding(.vertical, AppSpacing.xSmall) + } + } + } + } + } + } +} + +private struct StorageManagementCard: View { + @ObservedObject var viewModel: SettingsViewModel + + var body: some View { + SettingsCard(title: "Storage Management") { + VStack(spacing: AppSpacing.large) { + StorageManagementButton( + title: "Clear Cache", + subtitle: "Free up space by clearing cached data", + icon: "trash", + color: AppColors.primaryRed + ) { + await viewModel.clearCache() + } + + StorageManagementButton( + title: "Clean Temporary Files", + subtitle: "Remove temporary files and logs", + icon: "trash", + color: AppColors.primaryOrange + ) { + await viewModel.cleanTempFiles() + } + } + } + } +} + +private struct LoggingConfigurationCard: View { + @ObservedObject var viewModel: SettingsViewModel + + var body: some View { + SettingsCard(title: "Logging Configuration") { + VStack(alignment: .leading, spacing: AppSpacing.padding15) { + HStack { + Text("Log Analytics Locally") + .frame(width: 150, alignment: .leading) + + Toggle("", isOn: $viewModel.analyticsLogToLocal) + + Spacer() + + Text(viewModel.analyticsLogToLocal ? "Enabled" : "Disabled") + .font(AppTypography.caption) + .foregroundColor( + viewModel.analyticsLogToLocal + ? AppColors.statusGreen + : AppColors.textSecondary + ) + } + + Text("When enabled, analytics events will be logged locally instead of being sent to the server.") + .font(AppTypography.caption) + .foregroundColor(AppColors.textSecondary) + } + } + } +} + +private struct AboutCard: View { + var body: some View { + SettingsCard(title: "About") { + VStack(alignment: .leading, spacing: AppSpacing.padding15) { + HStack { + Image(systemName: "cube") + .foregroundColor(AppColors.primaryAccent) + VStack(alignment: .leading) { + Text("RunAnywhere SDK") + .font(AppTypography.headline) + Text("Version 0.1") + .font(AppTypography.caption) + .foregroundColor(AppColors.textSecondary) + } + } + + if let docsURL = URL(string: "https://docs.runanywhere.ai") { + Link(destination: docsURL) { + HStack { + Image(systemName: "book") + Text("Documentation") + } + } + } + } + } + } +} + +// MARK: - Reusable Components + +private struct StorageOverviewRows: View { + @ObservedObject var viewModel: SettingsViewModel + + var body: some View { + Group { + HStack { + Label("Total Usage", systemImage: "externaldrive") + Spacer() + Text(viewModel.formatBytes(viewModel.totalStorageSize)) + .foregroundColor(AppColors.textSecondary) + } + + HStack { + Label("Available Space", systemImage: "externaldrive.badge.plus") + Spacer() + Text(viewModel.formatBytes(viewModel.availableSpace)) + .foregroundColor(AppColors.primaryGreen) + } + + HStack { + Label("Models Storage", systemImage: "cpu") + Spacer() + Text(viewModel.formatBytes(viewModel.modelStorageSize)) + .foregroundColor(AppColors.primaryAccent) + } + + HStack { + Label("Downloaded Models", systemImage: "number") + Spacer() + Text("\(viewModel.storedModels.count)") + .foregroundColor(AppColors.textSecondary) + } + } + } +} + +private struct SettingsCard: View { + let title: String + @ViewBuilder let content: () -> Content + + var body: some View { + VStack(alignment: .leading, spacing: AppSpacing.xLarge) { + Text(title) + .font(AppTypography.headline) + .foregroundColor(AppColors.textSecondary) + + content() + .padding(AppSpacing.large) + .background(AppColors.backgroundSecondary) + .cornerRadius(AppSpacing.cornerRadiusLarge) + } + } +} + +private struct SettingsCardWithTrailing: View { + let title: String + @ViewBuilder let trailing: () -> Trailing + @ViewBuilder let content: () -> Content + + var body: some View { + VStack(alignment: .leading, spacing: AppSpacing.xLarge) { + HStack { + Text(title) + .font(AppTypography.headline) + .foregroundColor(AppColors.textSecondary) + Spacer() + trailing() + } + + content() + .padding(AppSpacing.large) + .background(AppColors.backgroundSecondary) + .cornerRadius(AppSpacing.cornerRadiusLarge) + } + } +} + +private struct StorageManagementButton: View { + let title: String + let subtitle: String + let icon: String + let color: Color + let action: () async -> Void + + var body: some View { + Button( + action: { + Task { + await action() + } + }, + label: { + HStack { + Image(systemName: icon) + .foregroundColor(color) + Text(title) + Spacer() + Text(subtitle) + .font(AppTypography.caption) + .foregroundColor(AppColors.textSecondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + ) + .buttonStyle(.plain) + .padding(AppSpacing.mediumLarge) + .background(color.opacity(0.1)) + .cornerRadius(AppSpacing.cornerRadiusRegular) + .overlay( + RoundedRectangle(cornerRadius: AppSpacing.cornerRadiusRegular) + .stroke(color.opacity(0.3), lineWidth: AppSpacing.strokeRegular) + ) + } +} + +private struct ApiConfigurationSheet: View { + @ObservedObject var viewModel: SettingsViewModel + + var body: some View { + NavigationStack { + Form { + Section { + SecureField("Enter API Key", text: $viewModel.apiKey) + .textContentType(.password) + #if os(iOS) + .autocapitalization(.none) + #endif + } header: { + Text("API Key") + } footer: { + Text("Your API key for authenticating with the backend") + .font(AppTypography.caption) + } + + Section { + TextField("https://api.example.com", text: $viewModel.baseURL) + .textContentType(.URL) + #if os(iOS) + .autocapitalization(.none) + .keyboardType(.URL) + #endif + } header: { + Text("Base URL") + } footer: { + Text("The backend API URL (e.g., https://api.runanywhere.ai)") + .font(AppTypography.caption) + } + + Section { + VStack(alignment: .leading, spacing: AppSpacing.small) { + Label("Important", systemImage: "exclamationmark.triangle") + .foregroundColor(AppColors.primaryOrange) + .font(AppTypography.subheadlineMedium) + + Text("After saving, you must restart the app for changes to take effect. The SDK will reinitialize with your custom configuration.") + .font(AppTypography.caption) + .foregroundColor(AppColors.textSecondary) + } + } + } + #if os(macOS) + .formStyle(.grouped) + .frame(minWidth: AppLayout.macOSMinWidth, idealWidth: 500, minHeight: 350, idealHeight: 400) + #endif + .navigationTitle("API Configuration") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + #if os(iOS) + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + viewModel.cancelApiKeyEntry() + } + } + ToolbarItem(placement: .navigationBarTrailing) { + Button("Save") { + viewModel.saveApiConfiguration() + } + .disabled(viewModel.apiKey.isEmpty || viewModel.baseURL.isEmpty) + } + #else + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + viewModel.cancelApiKeyEntry() + } + .keyboardShortcut(.escape) + } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + viewModel.saveApiConfiguration() + } + .disabled(viewModel.apiKey.isEmpty || viewModel.baseURL.isEmpty) + .keyboardShortcut(.return) + } + #endif + } + } + #if os(macOS) + .padding(AppSpacing.large) + #endif + } +} + +// MARK: - Supporting Views + +private struct StoredModelRow: View { + let model: StoredModel + let onDelete: () async -> Void + @State private var showingDetails = false + @State private var showingDeleteConfirmation = false + @State private var isDeleting = false + + var body: some View { + VStack(alignment: .leading, spacing: AppSpacing.smallMedium) { + HStack { + VStack(alignment: .leading, spacing: AppSpacing.xSmall) { + Text(model.name) + .font(AppTypography.subheadlineMedium) + + Text(ByteCountFormatter.string(fromByteCount: model.size, countStyle: .file)) + .font(AppTypography.caption2) + .foregroundColor(AppColors.textSecondary) + } + + Spacer() + + VStack(alignment: .trailing, spacing: AppSpacing.xSmall) { + Text(ByteCountFormatter.string(fromByteCount: model.size, countStyle: .file)) + .font(AppTypography.captionMedium) + + HStack(spacing: AppSpacing.xSmall) { + Button(showingDetails ? "Hide" : "Details") { + withAnimation { + showingDetails.toggle() + } + } + .font(AppTypography.caption2) + .buttonStyle(.bordered) + .tint(AppColors.primaryAccent) + .controlSize(.mini) + + Button( + action: { + showingDeleteConfirmation = true + }, + label: { + Image(systemName: "trash") + .foregroundColor(AppColors.primaryRed) + } + ) + .font(AppTypography.caption2) + .buttonStyle(.bordered) + .tint(AppColors.primaryRed) + .controlSize(.mini) + .disabled(isDeleting) + } + } + } + + if showingDetails { + modelDetailsView + } + } + .padding(.vertical, AppSpacing.xSmall) + .alert("Delete Model", isPresented: $showingDeleteConfirmation) { + Button("Cancel", role: .cancel) {} + Button("Delete", role: .destructive) { + Task { + isDeleting = true + await onDelete() + isDeleting = false + } + } + } message: { + Text("Are you sure you want to delete \(model.name)? This action cannot be undone.") + } + } + + private var modelDetailsView: some View { + VStack(alignment: .leading, spacing: AppSpacing.small) { + HStack { + Text("Downloaded:") + .font(AppTypography.caption2Medium) + Text(model.createdDate, style: .date) + .font(AppTypography.caption2) + .foregroundColor(AppColors.textSecondary) + } + + HStack { + Text("Size:") + .font(AppTypography.caption2Medium) + Text(ByteCountFormatter.string(fromByteCount: model.size, countStyle: .file)) + .font(AppTypography.caption2) + .foregroundColor(AppColors.textSecondary) + } + } + .padding(.top, AppSpacing.xSmall) + .padding(.horizontal, AppSpacing.smallMedium) + .padding(.vertical, AppSpacing.small) + .background(AppColors.backgroundTertiary) + .cornerRadius(AppSpacing.cornerRadiusRegular) + } +} + +#Preview { + NavigationView { + CombinedSettingsView() + } +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Settings/SettingsViewModel.swift b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Settings/SettingsViewModel.swift new file mode 100644 index 000000000..4495753bd --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Settings/SettingsViewModel.swift @@ -0,0 +1,400 @@ +// +// SettingsViewModel.swift +// RunAnywhereAI +// +// Centralized ViewModel for all Settings functionality +// Follows MVVM pattern - all business logic is here +// + +import Foundation +import SwiftUI +import RunAnywhere +import Combine + +@MainActor +class SettingsViewModel: ObservableObject { + // MARK: - Published Properties + + // Generation Settings + @Published var temperature: Double = 0.7 + @Published var maxTokens: Int = 10000 + + // API Configuration + @Published var apiKey: String = "" + @Published var baseURL: String = "" + @Published var isApiKeyConfigured: Bool = false + @Published var isBaseURLConfigured: Bool = false + + // Logging Configuration + @Published var analyticsLogToLocal: Bool = false + + // Storage Overview + @Published var totalStorageSize: Int64 = 0 + @Published var availableSpace: Int64 = 0 + @Published var modelStorageSize: Int64 = 0 + @Published var storedModels: [StoredModel] = [] + + // UI State + @Published var showApiKeyEntry: Bool = false + @Published var isLoadingStorage: Bool = false + @Published var errorMessage: String? + @Published var showRestartAlert: Bool = false + + // MARK: - Private Properties + + private var cancellables = Set() + private let keychainService = KeychainService.shared + private let apiKeyStorageKey = "runanywhere_api_key" + private let baseURLStorageKey = "runanywhere_base_url" + private let temperatureDefaultsKey = "defaultTemperature" + private let maxTokensDefaultsKey = "defaultMaxTokens" + private let analyticsLogKey = "analyticsLogToLocal" + private let deviceRegisteredKey = "com.runanywhere.sdk.deviceRegistered" + + // MARK: - Static helpers for app initialization + static let shared = SettingsViewModel() + + /// Get stored API key (for use at app launch) + static func getStoredApiKey() -> String? { + guard let data = try? KeychainService.shared.retrieve(key: "runanywhere_api_key"), + let value = String(data: data, encoding: .utf8), + !value.isEmpty else { + return nil + } + return value + } + + /// Get stored base URL (for use at app launch) + /// Automatically adds https:// if no scheme is present + static func getStoredBaseURL() -> String? { + guard let data = try? KeychainService.shared.retrieve(key: "runanywhere_base_url"), + let value = String(data: data, encoding: .utf8), + !value.isEmpty else { + return nil + } + // Normalize URL by adding https:// if no scheme present + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasPrefix("http://") || trimmed.hasPrefix("https://") { + return trimmed + } + return "https://\(trimmed)" + } + + /// Check if custom configuration is set + static var hasCustomConfiguration: Bool { + getStoredApiKey() != nil && getStoredBaseURL() != nil + } + + // MARK: - Initialization + + init() { + loadSettings() + setupObservers() + } + + // MARK: - Setup + + private func setupObservers() { + // Auto-save temperature changes + $temperature + .debounce(for: 0.5, scheduler: DispatchQueue.main) + .dropFirst() // Skip initial value to avoid saving on init + .sink { [weak self] newValue in + self?.saveTemperature(newValue) + } + .store(in: &cancellables) + + // Auto-save max tokens changes + $maxTokens + .debounce(for: 0.5, scheduler: DispatchQueue.main) + .dropFirst() // Skip initial value to avoid saving on init + .sink { [weak self] newValue in + self?.saveMaxTokens(newValue) + } + .store(in: &cancellables) + + // Auto-save analytics logging preference + $analyticsLogToLocal + .dropFirst() // Skip initial value to avoid saving on init + .sink { [weak self] newValue in + self?.saveAnalyticsLogPreference(newValue) + } + .store(in: &cancellables) + } + + // MARK: - Settings Management + + /// Load all settings from storage + func loadSettings() { + loadGenerationSettings() + loadApiKeyConfiguration() + loadLoggingConfiguration() + } + + private func loadGenerationSettings() { + // Load temperature + let savedTemperature = UserDefaults.standard.double(forKey: temperatureDefaultsKey) + temperature = savedTemperature > 0 ? savedTemperature : 0.7 + + // Load max tokens + let savedMaxTokens = UserDefaults.standard.integer(forKey: maxTokensDefaultsKey) + maxTokens = savedMaxTokens > 0 ? savedMaxTokens : 10000 + } + + private func loadApiKeyConfiguration() { + // Load API key from keychain + if let apiKeyData = try? keychainService.retrieve(key: apiKeyStorageKey), + let savedApiKey = String(data: apiKeyData, encoding: .utf8), + !savedApiKey.isEmpty { + apiKey = savedApiKey + isApiKeyConfigured = true + } else { + apiKey = "" + isApiKeyConfigured = false + } + + // Load Base URL from keychain + if let baseURLData = try? keychainService.retrieve(key: baseURLStorageKey), + let savedBaseURL = String(data: baseURLData, encoding: .utf8), + !savedBaseURL.isEmpty { + baseURL = savedBaseURL + isBaseURLConfigured = true + } else { + baseURL = "" + isBaseURLConfigured = false + } + } + + private func loadLoggingConfiguration() { + analyticsLogToLocal = keychainService.loadBool(key: analyticsLogKey, defaultValue: false) + } + + // MARK: - Generation Settings + + private func saveTemperature(_ value: Double) { + UserDefaults.standard.set(value, forKey: temperatureDefaultsKey) + print("Settings: Saved temperature: \(value)") + } + + private func saveMaxTokens(_ value: Int) { + UserDefaults.standard.set(value, forKey: maxTokensDefaultsKey) + print("Settings: Saved max tokens: \(value)") + } + + /// Get current generation configuration for SDK usage + func getGenerationConfiguration() -> GenerationConfiguration { + GenerationConfiguration( + temperature: temperature, + maxTokens: maxTokens + ) + } + + // MARK: - API Configuration Management + + /// Normalize base URL by adding https:// if no scheme is present + private func normalizeBaseURL(_ url: String) -> String { + let trimmed = url.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + return trimmed + } + + // Check if URL already has a scheme + if trimmed.hasPrefix("http://") || trimmed.hasPrefix("https://") { + return trimmed + } + + // Add https:// prefix + return "https://\(trimmed)" + } + + /// Save API key and Base URL to secure storage + func saveApiConfiguration() { + var hasError = false + + // Save API key if provided + if !apiKey.isEmpty { + if let apiKeyData = apiKey.data(using: .utf8) { + do { + try keychainService.save(key: apiKeyStorageKey, data: apiKeyData) + isApiKeyConfigured = true + print("Settings: API key saved successfully") + } catch { + errorMessage = "Failed to save API key: \(error.localizedDescription)" + hasError = true + } + } + } + + // Save Base URL if provided (normalize to add https:// if missing) + if !baseURL.isEmpty { + let normalizedURL = normalizeBaseURL(baseURL) + baseURL = normalizedURL // Update the displayed value too + + if let baseURLData = normalizedURL.data(using: .utf8) { + do { + try keychainService.save(key: baseURLStorageKey, data: baseURLData) + isBaseURLConfigured = true + print("Settings: Base URL saved successfully: \(normalizedURL)") + } catch { + errorMessage = "Failed to save Base URL: \(error.localizedDescription)" + hasError = true + } + } + } + + if !hasError { + showApiKeyEntry = false + errorMessage = nil + // Show restart alert + showRestartAlert = true + } + } + + /// Delete API configuration from secure storage + func clearApiConfiguration() { + do { + try keychainService.delete(key: apiKeyStorageKey) + try keychainService.delete(key: baseURLStorageKey) + apiKey = "" + baseURL = "" + isApiKeyConfigured = false + isBaseURLConfigured = false + errorMessage = nil + + // Also clear device registration so it re-registers with new config + clearDeviceRegistration() + + print("Settings: API configuration cleared successfully") + showRestartAlert = true + } catch { + errorMessage = "Failed to clear API configuration: \(error.localizedDescription)" + } + } + + /// Clear device registration status (forces re-registration on next launch) + func clearDeviceRegistration() { + UserDefaults.standard.removeObject(forKey: deviceRegisteredKey) + print("Settings: Device registration cleared - will re-register on next launch") + } + + /// Show the API configuration sheet + func showApiKeySheet() { + showApiKeyEntry = true + } + + /// Cancel API key entry + func cancelApiKeyEntry() { + // Reload the saved configuration if canceling + loadApiKeyConfiguration() + showApiKeyEntry = false + } + + /// Check if API configuration is complete (both key and URL set) + var isApiConfigurationComplete: Bool { + isApiKeyConfigured && isBaseURLConfigured + } + + // MARK: - Logging Configuration + + private func saveAnalyticsLogPreference(_ value: Bool) { + try? keychainService.saveBool(key: analyticsLogKey, value: value) + print("Settings: Analytics logging set to: \(value)") + } + + // MARK: - Storage Management + + /// Load storage information + func loadStorageData() async { + isLoadingStorage = true + errorMessage = nil + + do { + let storageInfo = await RunAnywhere.getStorageInfo() + + totalStorageSize = storageInfo.appStorage.totalSize + availableSpace = storageInfo.deviceStorage.freeSpace + modelStorageSize = storageInfo.totalModelsSize + storedModels = storageInfo.storedModels + + print("Settings: Loaded storage data - Total: \(totalStorageSize), Available: \(availableSpace)") + } catch { + errorMessage = "Failed to load storage data: \(error.localizedDescription)" + } + + isLoadingStorage = false + } + + /// Refresh storage information + func refreshStorageData() async { + await loadStorageData() + } + + /// Clear cache + func clearCache() async { + do { + try await RunAnywhere.clearCache() + await refreshStorageData() + print("Settings: Cache cleared successfully") + } catch { + errorMessage = "Failed to clear cache: \(error.localizedDescription)" + } + } + + /// Clean temporary files + func cleanTempFiles() async { + do { + try await RunAnywhere.cleanTempFiles() + await refreshStorageData() + print("Settings: Temporary files cleaned successfully") + } catch { + errorMessage = "Failed to clean temporary files: \(error.localizedDescription)" + } + } + + /// Delete a stored model + func deleteModel(_ model: StoredModel) async { + guard let framework = model.framework else { + errorMessage = "Cannot delete model: unknown framework" + return + } + + do { + try await RunAnywhere.deleteStoredModel(model.id, framework: framework) + await refreshStorageData() + print("Settings: Model \(model.name) deleted successfully") + } catch { + errorMessage = "Failed to delete model: \(error.localizedDescription)" + } + } + + // MARK: - Helper Methods + + /// Format bytes to human-readable string + func formatBytes(_ bytes: Int64) -> String { + ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file) + } + + /// Format bytes to memory string + func formatMemory(_ bytes: Int64) -> String { + ByteCountFormatter.string(fromByteCount: bytes, countStyle: .memory) + } + + /// Check if storage data is available + var hasStorageData: Bool { + totalStorageSize > 0 + } + + /// Get storage usage percentage + var storageUsagePercentage: Double { + guard availableSpace > 0 else { return 0 } + let totalDevice = totalStorageSize + availableSpace + return Double(totalStorageSize) / Double(totalDevice) + } +} + +// MARK: - Supporting Types + +struct GenerationConfiguration { + let temperature: Double + let maxTokens: Int +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Storage/StorageView.swift b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Storage/StorageView.swift new file mode 100644 index 000000000..421a24919 --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Storage/StorageView.swift @@ -0,0 +1,503 @@ +// +// StorageView.swift +// RunAnywhereAI +// +// Simplified storage view using SDK methods +// + +import SwiftUI +import RunAnywhere + +struct StorageView: View { + @StateObject private var viewModel = StorageViewModel() + + var body: some View { + #if os(macOS) + // macOS: Custom layout without List + ScrollView { + VStack(alignment: .leading, spacing: AppSpacing.xxLarge) { + Text("Storage Management") + .font(AppTypography.largeTitleBold) + .padding(.bottom, AppSpacing.medium) + + // Storage Overview Card + VStack(alignment: .leading, spacing: AppSpacing.xLarge) { + HStack { + Text("Storage Overview") + .font(AppTypography.headline) + .foregroundColor(AppColors.textSecondary) + + Spacer() + + Button( + action: { + Task { + await viewModel.refreshData() + } + }, + label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + ) + .buttonStyle(.bordered) + .tint(AppColors.primaryAccent) + } + + VStack(spacing: 0) { + storageOverviewContent + } + .padding(AppSpacing.large) + .background(AppColors.backgroundSecondary) + .cornerRadius(AppSpacing.cornerRadiusLarge) + } + + // Downloaded Models Card + VStack(alignment: .leading, spacing: AppSpacing.xLarge) { + Text("Downloaded Models") + .font(AppTypography.headline) + .foregroundColor(AppColors.textSecondary) + + VStack(spacing: 0) { + storedModelsContent + } + .padding(AppSpacing.large) + .background(AppColors.backgroundSecondary) + .cornerRadius(AppSpacing.cornerRadiusLarge) + } + + // Storage Management Card + VStack(alignment: .leading, spacing: AppSpacing.xLarge) { + Text("Storage Management") + .font(AppTypography.headline) + .foregroundColor(AppColors.textSecondary) + + VStack(spacing: 0) { + cacheManagementContent + } + .padding(AppSpacing.large) + .background(AppColors.backgroundSecondary) + .cornerRadius(AppSpacing.cornerRadiusLarge) + } + + Spacer(minLength: AppSpacing.xxLarge) + } + .padding(AppSpacing.xxLarge) + .frame(maxWidth: AppLayout.maxContentWidthLarge, alignment: .leading) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(AppColors.backgroundPrimary) + .task { + await viewModel.loadData() + } + #else + // iOS: Keep NavigationView + NavigationView { + List { + storageOverviewSection + storedModelsSection + cacheManagementSection + } + .navigationTitle("Storage") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Refresh") { + Task { + await viewModel.refreshData() + } + } + } + } + .task { + await viewModel.loadData() + } + } + #endif + } + + #if os(macOS) + private var storageOverviewContent: some View { + VStack(alignment: .leading, spacing: AppSpacing.large) { + // Total storage usage + HStack { + Label("Total Usage", systemImage: "externaldrive") + Spacer() + Text(ByteCountFormatter.string(fromByteCount: viewModel.totalStorageSize, countStyle: .file)) + .foregroundColor(AppColors.textSecondary) + } + + // Available space + HStack { + Label("Available Space", systemImage: "externaldrive.badge.plus") + Spacer() + Text(ByteCountFormatter.string(fromByteCount: viewModel.availableSpace, countStyle: .file)) + .foregroundColor(AppColors.primaryGreen) + } + + // Models storage + HStack { + Label("Models Storage", systemImage: "cpu") + Spacer() + Text(ByteCountFormatter.string(fromByteCount: viewModel.modelStorageSize, countStyle: .file)) + .foregroundColor(AppColors.primaryAccent) + } + + // Models count + HStack { + Label("Downloaded Models", systemImage: "number") + Spacer() + Text("\(viewModel.storedModels.count)") + .foregroundColor(AppColors.textSecondary) + } + } + } + #endif + + private var storageOverviewSection: some View { + Section("Storage Overview") { + VStack(alignment: .leading, spacing: AppSpacing.mediumLarge) { + // Total storage usage + HStack { + Label("Total Usage", systemImage: "externaldrive") + Spacer() + Text(ByteCountFormatter.string(fromByteCount: viewModel.totalStorageSize, countStyle: .file)) + .foregroundColor(AppColors.textSecondary) + } + + // Available space + HStack { + Label("Available Space", systemImage: "externaldrive.badge.plus") + Spacer() + Text(ByteCountFormatter.string(fromByteCount: viewModel.availableSpace, countStyle: .file)) + .foregroundColor(AppColors.primaryGreen) + } + + // Models storage + HStack { + Label("Models Storage", systemImage: "cpu") + Spacer() + Text(ByteCountFormatter.string(fromByteCount: viewModel.modelStorageSize, countStyle: .file)) + .foregroundColor(AppColors.primaryAccent) + } + + // Models count + HStack { + Label("Downloaded Models", systemImage: "number") + Spacer() + Text("\(viewModel.storedModels.count)") + .foregroundColor(AppColors.textSecondary) + } + } + .padding(.vertical, AppSpacing.xSmall) + } + } + + #if os(macOS) + private var storedModelsContent: some View { + VStack(alignment: .leading, spacing: AppSpacing.mediumLarge) { + if viewModel.storedModels.isEmpty { + HStack { + Spacer() + VStack(spacing: AppSpacing.mediumLarge) { + Image(systemName: "cube") + .font(AppTypography.system48) + .foregroundColor(AppColors.textSecondary.opacity(0.5)) + Text("No models downloaded yet") + .foregroundColor(AppColors.textSecondary) + .font(AppTypography.callout) + } + .padding(.vertical, AppSpacing.xxLarge) + Spacer() + } + } else { + ForEach(viewModel.storedModels, id: \.id) { model in + StoredModelRow(model: model) { + await viewModel.deleteModel(model) + } + if model.id != viewModel.storedModels.last?.id { + Divider() + .padding(.vertical, AppSpacing.xSmall) + } + } + } + } + } + #endif + + private var storedModelsSection: some View { + Section("Downloaded Models") { + if viewModel.storedModels.isEmpty { + Text("No models downloaded yet") + .foregroundColor(AppColors.textSecondary) + .font(AppTypography.caption) + } else { + ForEach(viewModel.storedModels, id: \.id) { model in + StoredModelRow(model: model) { + await viewModel.deleteModel(model) + } + } + } + } + } + + #if os(macOS) + private var cacheManagementContent: some View { + VStack(spacing: AppSpacing.large) { + Button( + action: { + Task { + await viewModel.clearCache() + } + }, + label: { + HStack { + Image(systemName: "trash") + .foregroundColor(AppColors.primaryRed) + Text("Clear Cache") + Spacer() + Text("Free up space by clearing cached data") + .font(AppTypography.caption) + .foregroundColor(AppColors.textSecondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + ) + .buttonStyle(.plain) + .padding(AppSpacing.mediumLarge) + .background(AppColors.badgeRed) + .cornerRadius(AppSpacing.cornerRadiusRegular) + .overlay( + RoundedRectangle(cornerRadius: AppSpacing.cornerRadiusRegular) + .stroke(AppColors.primaryRed.opacity(0.3), lineWidth: AppSpacing.strokeRegular) + ) + + Button( + action: { + Task { + await viewModel.cleanTempFiles() + } + }, + label: { + HStack { + Image(systemName: "trash") + .foregroundColor(AppColors.primaryOrange) + Text("Clean Temporary Files") + Spacer() + Text("Remove temporary files and logs") + .font(AppTypography.caption) + .foregroundColor(AppColors.textSecondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + ) + .buttonStyle(.plain) + .padding(AppSpacing.mediumLarge) + .background(AppColors.badgeOrange) + .cornerRadius(AppSpacing.cornerRadiusRegular) + .overlay( + RoundedRectangle(cornerRadius: AppSpacing.cornerRadiusRegular) + .stroke(AppColors.primaryOrange.opacity(0.3), lineWidth: AppSpacing.strokeRegular) + ) + } + } + #endif + + private var cacheManagementSection: some View { + Section("Storage Management") { + Button( + action: { + Task { + await viewModel.clearCache() + } + }, + label: { + HStack { + Image(systemName: "trash") + .foregroundColor(AppColors.primaryRed) + Text("Clear Cache") + .foregroundColor(AppColors.primaryRed) + Spacer() + } + } + ) + + Button( + action: { + Task { + await viewModel.cleanTempFiles() + } + }, + label: { + HStack { + Image(systemName: "trash") + .foregroundColor(AppColors.primaryOrange) + Text("Clean Temporary Files") + .foregroundColor(AppColors.primaryOrange) + Spacer() + } + } + ) + } + } +} + +// MARK: - Supporting Views + +private struct StoredModelRow: View { + let model: StoredModel + let onDelete: () async -> Void + @State private var showingDetails = false + @State private var showingDeleteConfirmation = false + @State private var isDeleting = false + + var body: some View { + VStack(alignment: .leading, spacing: AppSpacing.smallMedium) { + HStack { + VStack(alignment: .leading, spacing: AppSpacing.xSmall) { + Text(model.name) + .font(AppTypography.subheadlineMedium) + + HStack(spacing: AppSpacing.smallMedium) { + Text(model.format.rawValue.uppercased()) + .font(AppTypography.caption2) + .padding(.horizontal, AppSpacing.small) + .padding(.vertical, AppSpacing.xxSmall) + .background(AppColors.badgePrimary) + .cornerRadius(AppSpacing.cornerRadiusSmall) + + if let framework = model.framework { + Text(framework.displayName) + .font(AppTypography.caption2) + .padding(.horizontal, AppSpacing.small) + .padding(.vertical, AppSpacing.xxSmall) + .background(AppColors.badgeGreen) + .cornerRadius(AppSpacing.cornerRadiusSmall) + } + } + } + + Spacer() + + VStack(alignment: .trailing, spacing: AppSpacing.xSmall) { + Text(ByteCountFormatter.string(fromByteCount: model.size, countStyle: .file)) + .font(AppTypography.captionMedium) + + HStack(spacing: AppSpacing.xSmall) { + Button(showingDetails ? "Hide" : "Details") { + withAnimation { + showingDetails.toggle() + } + } + .font(AppTypography.caption2) + .buttonStyle(.bordered) + .tint(AppColors.primaryAccent) + .controlSize(.mini) + + Button( + action: { + showingDeleteConfirmation = true + }, + label: { + Image(systemName: "trash") + .foregroundColor(AppColors.primaryRed) + } + ) + .font(AppTypography.caption2) + .buttonStyle(.bordered) + .tint(AppColors.primaryRed) + .controlSize(.mini) + .disabled(isDeleting) + } + } + } + + if showingDetails { + VStack(alignment: .leading, spacing: AppSpacing.small) { + // Model Format and Framework + HStack { + Text("Format:") + .font(AppTypography.caption2Medium) + Text(model.format.rawValue.uppercased()) + .font(AppTypography.caption2) + .foregroundColor(AppColors.textSecondary) + } + + if let framework = model.framework { + HStack { + Text("Framework:") + .font(AppTypography.caption2Medium) + Text(framework.displayName) + .font(AppTypography.caption2) + .foregroundColor(AppColors.textSecondary) + } + } + + // Description + if let description = model.description { + VStack(alignment: .leading, spacing: AppSpacing.xxSmall) { + Text("Description:") + .font(AppTypography.caption2Medium) + Text(description) + .font(AppTypography.caption2) + .foregroundColor(AppColors.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + Divider() + + // File Information + VStack(alignment: .leading, spacing: AppSpacing.xxSmall) { + Text("Path:") + .font(AppTypography.caption2Medium) + Text(model.path.path) + .font(AppTypography.caption2) + .foregroundColor(AppColors.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + + if let checksum = model.checksum { + VStack(alignment: .leading, spacing: AppSpacing.xxSmall) { + Text("Checksum:") + .font(AppTypography.caption2Medium) + Text(checksum) + .font(AppTypography.caption2) + .foregroundColor(AppColors.textSecondary) + .lineLimit(1) + .truncationMode(.middle) + } + } + + HStack { + Text("Created:") + .font(AppTypography.caption2Medium) + Text(model.createdDate, style: .date) + .font(AppTypography.caption2) + .foregroundColor(AppColors.textSecondary) + } + } + .padding(.top, AppSpacing.xSmall) + .padding(.horizontal, AppSpacing.smallMedium) + .padding(.vertical, AppSpacing.small) + .background(AppColors.backgroundTertiary) + .cornerRadius(AppSpacing.cornerRadiusRegular) + } + } + .padding(.vertical, AppSpacing.xSmall) + .alert("Delete Model", isPresented: $showingDeleteConfirmation) { + Button("Cancel", role: .cancel) {} + Button("Delete", role: .destructive) { + Task { + isDeleting = true + await onDelete() + isDeleting = false + } + } + } message: { + Text("Are you sure you want to delete \(model.name)? This action cannot be undone.") + } + } +} + +#Preview { + StorageView() +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Storage/StorageViewModel.swift b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Storage/StorageViewModel.swift new file mode 100644 index 000000000..ab8e9121a --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Storage/StorageViewModel.swift @@ -0,0 +1,76 @@ +// +// StorageViewModel.swift +// RunAnywhereAI +// +// Simplified ViewModel that uses SDK storage methods +// + +import Foundation +import SwiftUI +import RunAnywhere +import Combine + +@MainActor +class StorageViewModel: ObservableObject { + @Published var totalStorageSize: Int64 = 0 + @Published var availableSpace: Int64 = 0 + @Published var modelStorageSize: Int64 = 0 + @Published var storedModels: [StoredModel] = [] + @Published var isLoading = false + @Published var errorMessage: String? + + private var cancellables = Set() + + func loadData() async { + isLoading = true + errorMessage = nil + + // Use public API to get storage info + let storageInfo = await RunAnywhere.getStorageInfo() + + // Update storage sizes from the public API + totalStorageSize = storageInfo.appStorage.totalSize + availableSpace = storageInfo.deviceStorage.freeSpace + modelStorageSize = storageInfo.totalModelsSize + + // Use StoredModel directly from SDK + storedModels = storageInfo.storedModels + + isLoading = false + } + + func refreshData() async { + await loadData() + } + + func clearCache() async { + do { + try await RunAnywhere.clearCache() + await refreshData() + } catch { + errorMessage = "Failed to clear cache: \(error.localizedDescription)" + } + } + + func cleanTempFiles() async { + do { + try await RunAnywhere.cleanTempFiles() + await refreshData() + } catch { + errorMessage = "Failed to clean temporary files: \(error.localizedDescription)" + } + } + + func deleteModel(_ model: StoredModel) async { + guard let framework = model.framework else { + errorMessage = "Cannot delete model: unknown framework" + return + } + do { + try await RunAnywhere.deleteStoredModel(model.id, framework: framework) + await refreshData() + } catch { + errorMessage = "Failed to delete model: \(error.localizedDescription)" + } + } +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Voice/STTViewModel.swift b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Voice/STTViewModel.swift new file mode 100644 index 000000000..32295ca01 --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Voice/STTViewModel.swift @@ -0,0 +1,418 @@ +// +// STTViewModel.swift +// RunAnywhereAI +// +// ViewModel for Speech-to-Text functionality +// Handles all business logic for STT including recording, transcription, and model management +// + +import Foundation +import RunAnywhere +import Combine +import os + +/// ViewModel for Speech-to-Text view +/// Manages recording, transcription, model selection, and microphone permissions +@MainActor +class STTViewModel: ObservableObject { + private let logger = Logger(subsystem: "com.runanywhere", category: "STT") + private let audioCapture = AudioCaptureManager() + private var cancellables = Set() + + // MARK: - Published Properties (UI State) + + @Published var selectedFramework: InferenceFramework? + @Published var selectedModelName: String? + @Published var selectedModelId: String? + @Published var transcription: String = "" + @Published var isRecording = false + @Published var isProcessing = false + @Published var isTranscribing = false + @Published var audioLevel: Float = 0.0 + @Published var errorMessage: String? + @Published var selectedMode: STTMode = .batch { + didSet { + // Stop any active recording/transcription when mode changes + if oldValue != selectedMode { + Task { @MainActor [weak self] in + guard let self = self else { return } + if self.isRecording { + let msg = "Mode changed from \(oldValue.rawValue) to \(self.selectedMode.rawValue)" + self.logger.info("\(msg) - stopping active recording") + await self.stopRecording() + } + // Also clean up any lingering live transcription resources + if oldValue == .live { + await self.stopLiveTranscription() + } + } + } + } + } + + // MARK: - Private Properties + + private var audioBuffer = Data() + + /// For live mode: VAD-based transcription + private var lastSpeechTime: Date? + private var isSpeechActive = false + private var silenceCheckTask: Task? + private let speechThreshold: Float = 0.02 // Audio level threshold for speech detection + private let silenceDuration: TimeInterval = 1.5 // Seconds of silence before transcribing + + // MARK: - Initialization State (for idempotency) + + private var isInitialized = false + private var hasSubscribedToAudioLevel = false + private var hasSubscribedToSDKEvents = false + + // MARK: - Initialization + + init() { + logger.debug("STTViewModel initialized") + } + + // MARK: - Public Methods + + /// Initialize the ViewModel - request permissions and setup subscriptions + /// This method is idempotent - calling it multiple times is safe + func initialize() async { + guard !isInitialized else { + logger.debug("STT view model already initialized, skipping") + return + } + isInitialized = true + + logger.info("Initializing STT view model") + + // Request microphone permission + let hasPermission = await requestMicrophonePermission() + if !hasPermission { + errorMessage = "Microphone permission denied" + logger.error("Microphone permission denied") + return + } + + // Subscribe to audio level updates (for batch mode) + subscribeToAudioLevelUpdates() + + // Subscribe to SDK events for STT model state + subscribeToSDKEvents() + + // Check initial STT model state + await checkInitialModelState() + } + + /// Load model from ModelSelectionSheet selection + func loadModelFromSelection(_ model: ModelInfo) async { + logger.info("Loading STT model from selection: \(model.name)") + isProcessing = true + errorMessage = nil + + do { + try await RunAnywhere.loadSTTModel(model.id) + selectedFramework = model.framework + selectedModelName = model.name.modelNameFromID() + selectedModelId = model.id + logger.info("STT model loaded successfully: \(model.name)") + } catch { + logger.error("Failed to load STT model: \(error.localizedDescription)") + errorMessage = "Failed to load model: \(error.localizedDescription)" + } + + isProcessing = false + } + + /// Toggle recording state (start/stop) + func toggleRecording() async { + if isRecording { + await stopRecording() + } else { + await startRecording() + } + } + + // MARK: - Private Methods - Permissions + + private func requestMicrophonePermission() async -> Bool { + await audioCapture.requestPermission() + } + + // MARK: - Private Methods - Subscriptions + + private func subscribeToAudioLevelUpdates() { + guard !hasSubscribedToAudioLevel else { + logger.debug("Already subscribed to audio level updates, skipping") + return + } + hasSubscribedToAudioLevel = true + + audioCapture.$audioLevel + .receive(on: DispatchQueue.main) + .sink { [weak self] level in + // Defer state modifications to avoid "Publishing changes within view updates" warning + Task { @MainActor in + self?.audioLevel = level + } + } + .store(in: &cancellables) + } + + private func subscribeToSDKEvents() { + guard !hasSubscribedToSDKEvents else { + logger.debug("Already subscribed to SDK events, skipping") + return + } + hasSubscribedToSDKEvents = true + + RunAnywhere.events.events + .receive(on: DispatchQueue.main) + .sink { [weak self] event in + // Defer state modifications to avoid "Publishing changes within view updates" warning + Task { @MainActor in + self?.handleSDKEvent(event) + } + } + .store(in: &cancellables) + } + + private func handleSDKEvent(_ event: any SDKEvent) { + // Events now come from C++ via generic BridgedEvent + guard event.category == .stt else { return } + + switch event.type { + case "stt_model_load_completed": + let modelId = event.properties["model_id"] ?? "" + selectedModelId = modelId + // Look up the model name from available models + if let matchingModel = ModelListViewModel.shared.availableModels.first(where: { $0.id == modelId }) { + selectedModelName = matchingModel.name + selectedFramework = matchingModel.framework + } else { + selectedModelName = modelId.modelNameFromID() // Look up proper name + } + logger.info("STT model loaded: \(modelId)") + case "stt_model_unloaded": + selectedModelId = nil + selectedModelName = nil + selectedFramework = nil + logger.info("STT model unloaded") + default: + break + } + } + + private func checkInitialModelState() async { + if let model = await RunAnywhere.currentSTTModel { + selectedModelId = model.id + selectedModelName = model.name.modelNameFromID() + selectedFramework = model.framework + logger.info("STT model already loaded: \(model.name)") + } + } + + // MARK: - Private Methods - Recording + + private func startRecording() async { + logger.info("Starting recording in \(self.selectedMode.rawValue) mode") + errorMessage = nil + audioBuffer = Data() + transcription = "" + lastSpeechTime = nil + isSpeechActive = false + + guard selectedModelId != nil else { + errorMessage = "No STT model loaded" + return + } + + do { + // Both modes use audio capture - live mode adds VAD-based auto-transcription + try audioCapture.startRecording { [weak self] audioData in + Task { @MainActor in + self?.audioBuffer.append(audioData) + } + } + + isRecording = true + + if selectedMode == .live { + // Live mode: Start VAD monitoring for auto-transcription + startVADMonitoring() + } + + logger.info("Recording started in \(self.selectedMode.rawValue) mode") + } catch { + logger.error("Failed to start recording: \(error.localizedDescription)") + errorMessage = "Failed to start recording: \(error.localizedDescription)" + } + } + + private func stopRecording() async { + logger.info("Stopping recording") + + // Stop VAD monitoring if active + silenceCheckTask?.cancel() + silenceCheckTask = nil + + // Stop audio capture + audioCapture.stopRecording() + + // Perform final transcription if we have audio + if !audioBuffer.isEmpty { + await performBatchTranscription() + } + + isRecording = false + audioLevel = 0.0 + isSpeechActive = false + lastSpeechTime = nil + } + + // MARK: - Private Methods - Transcription + + /// Perform batch transcription on collected audio + private func performBatchTranscription() async { + guard !audioBuffer.isEmpty else { + errorMessage = "No audio recorded" + return + } + + logger.info("Starting batch transcription of \(self.audioBuffer.count) bytes") + isTranscribing = true + transcription = "" + + do { + let result = try await RunAnywhere.transcribe(audioBuffer) + transcription = result + logger.info("Batch transcription complete: \(result)") + } catch { + logger.error("Batch transcription failed: \(error.localizedDescription)") + errorMessage = "Transcription failed: \(error.localizedDescription)" + } + + isTranscribing = false + } + + /// Start VAD monitoring for live mode + /// Automatically transcribes when silence is detected after speech + private func startVADMonitoring() { + logger.info("Starting VAD monitoring for live transcription") + + silenceCheckTask = Task { [weak self] in + while !Task.isCancelled { + guard let self = self, await self.isRecording else { break } + + let level = await self.audioLevel + await self.checkSpeechState(level: level) + + try? await Task.sleep(nanoseconds: 50_000_000) // 50ms + } + } + } + + /// Check speech state and auto-transcribe on silence + private func checkSpeechState(level: Float) async { + guard isRecording, selectedMode == .live else { return } + + if level > speechThreshold { + // Speech detected + if !isSpeechActive { + logger.debug("Speech started") + isSpeechActive = true + } + lastSpeechTime = Date() + } else if isSpeechActive { + // Check for silence duration + if let lastSpeech = lastSpeechTime, + Date().timeIntervalSince(lastSpeech) > silenceDuration { + logger.debug("Silence detected - auto-transcribing") + isSpeechActive = false + + // Only transcribe if we have enough audio (~0.5s at 16kHz) + if audioBuffer.count > 16000 { + await performLiveTranscription() + } else { + audioBuffer = Data() + } + } + } + } + + /// Perform transcription for live mode (keeps recording going) + private func performLiveTranscription() async { + let audio = audioBuffer + audioBuffer = Data() // Clear buffer for next utterance + + guard !audio.isEmpty else { return } + + logger.info("Live transcription of \(audio.count) bytes") + isTranscribing = true + + do { + let result = try await RunAnywhere.transcribe(audio) + // Append to existing transcription with newline + if !transcription.isEmpty { + transcription += "\n" + } + transcription += result + logger.info("Live transcription result: \(result)") + } catch { + logger.error("Live transcription failed: \(error.localizedDescription)") + errorMessage = "Transcription failed: \(error.localizedDescription)" + } + + isTranscribing = false + } + + /// Stop live transcription (called when mode changes) + private func stopLiveTranscription() async { + logger.info("Stopping live transcription") + silenceCheckTask?.cancel() + silenceCheckTask = nil + isSpeechActive = false + lastSpeechTime = nil + } + + // MARK: - Cleanup + + /// Clean up resources - call from view's onDisappear + /// This replaces deinit cleanup to comply with Swift 6 concurrency + func cleanup() { + audioCapture.stopRecording() + + // Clean up VAD monitoring + silenceCheckTask?.cancel() + silenceCheckTask = nil + + cancellables.removeAll() + + // Reset initialization flags to allow re-initialization if needed + isInitialized = false + hasSubscribedToAudioLevel = false + hasSubscribedToSDKEvents = false + } +} + +// MARK: - Supporting Types + +/// STT Mode for UI selection +enum STTMode: String { + case batch + case live + + var icon: String { + switch self { + case .batch: return "square.stack.3d.up" + case .live: return "waveform" + } + } + + var description: String { + switch self { + case .batch: return "Record first, then transcribe" + case .live: return "Auto-transcribe on silence" + } + } +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Voice/SpeechToTextView.swift b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Voice/SpeechToTextView.swift new file mode 100644 index 000000000..4da6854b4 --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Voice/SpeechToTextView.swift @@ -0,0 +1,394 @@ +import SwiftUI +import RunAnywhere +#if os(macOS) +import AppKit +#endif + +/// Dedicated Speech-to-Text view with real-time transcription +/// This view is purely focused on UI - all business logic is in STTViewModel +struct SpeechToTextView: View { + @StateObject private var viewModel = STTViewModel() + @State private var showModelPicker = false + @State private var breathingAnimation = false + + private var hasModelSelected: Bool { + viewModel.selectedModelName != nil + } + + private var statusMessage: String { + return "" + } + + private var waveHeights: [CGFloat] { + breathingAnimation + ? [24, 40, 32, 48, 28] + : [16, 24, 20, 28, 18] + } + + var body: some View { + Group { + NavigationView { + ZStack { + VStack(spacing: 0) { + // Mode selection - Modern pill button style + if hasModelSelected { + HStack(spacing: 8) { + // Batch mode button + Button { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + viewModel.selectedMode = .batch + } + } label: { + VStack(spacing: 4) { + Text("Batch") + .font(.system(size: 13, weight: .medium)) + Text("Record then transcribe") + .font(.system(size: 10)) + .opacity(0.7) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background( + viewModel.selectedMode == .batch + ? AppColors.primaryAccent.opacity(0.15) + : Color.clear + ) + .foregroundColor( + viewModel.selectedMode == .batch + ? AppColors.primaryAccent + : .secondary + ) + .cornerRadius(12) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke( + viewModel.selectedMode == .batch + ? AppColors.primaryAccent.opacity(0.3) + : Color.gray.opacity(0.2), + lineWidth: 1 + ) + ) + } + + // Live mode button + Button { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + viewModel.selectedMode = .live + } + } label: { + VStack(spacing: 4) { + Text("Live") + .font(.system(size: 13, weight: .medium)) + Text("Real-time transcription") + .font(.system(size: 10)) + .opacity(0.7) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background( + viewModel.selectedMode == .live + ? AppColors.primaryAccent.opacity(0.15) + : Color.clear + ) + .foregroundColor( + viewModel.selectedMode == .live + ? AppColors.primaryAccent + : .secondary + ) + .cornerRadius(12) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke( + viewModel.selectedMode == .live + ? AppColors.primaryAccent.opacity(0.3) + : Color.gray.opacity(0.2), + lineWidth: 1 + ) + ) + } + } + .padding(.horizontal, 16) + .padding(.top, 12) + .padding(.bottom, 8) + } + + // Main content - only enabled when model is selected + if hasModelSelected { + if viewModel.transcription.isEmpty && !viewModel.isRecording && !viewModel.isTranscribing { + // Ready state - Modern minimal design + VStack(spacing: 0) { + Spacer() + + VStack(spacing: 48) { + // Minimal waveform visualization + HStack(spacing: 4) { + ForEach(0..<5) { index in + RoundedRectangle(cornerRadius: 8) + .fill( + LinearGradient( + colors: [ + AppColors.primaryAccent.opacity(0.8), + AppColors.primaryAccent.opacity(0.4) + ], + startPoint: .top, + endPoint: .bottom + ) + ) + .frame(width: 6, height: waveHeights[index]) + .animation( + .easeInOut(duration: 0.8) + .repeatForever(autoreverses: true) + .delay(Double(index) * 0.1), + value: breathingAnimation + ) + } + } + + // Clean typography + VStack(spacing: 12) { + Text("Ready to transcribe") + .font(.system(size: 24, weight: .semibold, design: .rounded)) + .foregroundColor(.primary) + + Text(viewModel.selectedMode == .batch + ? "Record first, then transcribe" + : "Real-time transcription") + .font(.system(size: 15, weight: .regular)) + .foregroundColor(.secondary) + } + } + + Spacer() + } + .onAppear { + breathingAnimation = true + } + } else if viewModel.isTranscribing && viewModel.transcription.isEmpty { + // Processing state - Clean and centered + VStack(spacing: 0) { + Spacer() + + ProgressView() + .scaleEffect(1.2) + .padding(.bottom, 12) + + Text("Transcribing...") + .font(.subheadline) + .foregroundColor(.secondary) + + Spacer() + } + } else { + // Transcription display with ScrollView + ScrollView { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Transcription") + .font(.headline) + .foregroundColor(.primary) + + Spacer() + + if viewModel.isRecording { + HStack(spacing: 6) { + Circle() + .fill(AppColors.statusRed) + .frame(width: 8, height: 8) + Text("RECORDING") + .font(.caption2) + .fontWeight(.bold) + .foregroundColor(AppColors.statusRed) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(AppColors.statusRed.opacity(0.1)) + .cornerRadius(4) + } else if viewModel.isTranscribing { + HStack(spacing: 6) { + ProgressView() + .scaleEffect(0.6) + Text("TRANSCRIBING") + .font(.caption2) + .fontWeight(.bold) + .foregroundColor(AppColors.statusOrange) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(AppColors.statusOrange.opacity(0.1)) + .cornerRadius(4) + } + } + + Text(viewModel.transcription) + .font(.body) + .foregroundColor(.primary) + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + #if os(iOS) + .background(Color(.secondarySystemBackground)) + #else + .background(Color(NSColor.controlBackgroundColor)) + #endif + .cornerRadius(12) + } + .padding() + } + } + + // Controls + VStack(spacing: 16) { + // Error message + if let error = viewModel.errorMessage { + Text(error) + .font(.caption) + .foregroundColor(AppColors.statusRed) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + + // Audio level indicator + if viewModel.isRecording { + AdaptiveAudioLevelIndicator(level: viewModel.audioLevel) + } + + // Record button + AdaptiveMicButton( + isActive: viewModel.isRecording, + isPulsing: false, + isLoading: viewModel.isProcessing || viewModel.isTranscribing, + activeColor: AppColors.statusRed, + inactiveColor: viewModel.isTranscribing ? AppColors.statusOrange : AppColors.primaryAccent, + icon: viewModel.isRecording ? "stop.fill" : "mic.fill" + ) { + Task { + await viewModel.toggleRecording() + } + } + .disabled( + viewModel.selectedModelName == nil || + viewModel.isProcessing || + viewModel.isTranscribing + ) + .opacity( + viewModel.selectedModelName == nil || + viewModel.isProcessing || + viewModel.isTranscribing ? 0.6 : 1.0 + ) + + if !statusMessage.isEmpty { + Text(statusMessage) + .font(.caption) + .foregroundColor(.secondary) + } + } + .padding() + #if os(iOS) + .background(Color(.systemBackground)) + #else + .background(Color(NSColor.windowBackgroundColor)) + #endif + } else { + // No model selected - show onboarding + Spacer() + } + } + + // Overlay when no model is selected + if !hasModelSelected && !viewModel.isProcessing { + ModelRequiredOverlay( + modality: .stt + ) { showModelPicker = true } + } + } + .navigationTitle(hasModelSelected ? "Speech to Text" : "") + .navigationBarTitleDisplayMode(.inline) + .navigationBarHidden(!hasModelSelected) + .toolbar { + if hasModelSelected { + ToolbarItem(placement: .navigationBarTrailing) { + modelButton + } + } + } + } + .navigationViewStyle(.stack) + .sheet(isPresented: $showModelPicker) { + ModelSelectionSheet(context: .stt) { model in + Task { + await viewModel.loadModelFromSelection(model) + } + } + } + .onAppear { + Task { + await viewModel.initialize() + } + } + .onDisappear { + viewModel.cleanup() + } + } + } + + // MARK: - View Components + + private var modelButton: some View { + Button { + showModelPicker = true + } label: { + HStack(spacing: 6) { + // Model logo instead of cube icon + if let modelName = viewModel.selectedModelName { + Image(getModelLogo(for: modelName)) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 36, height: 36) + .cornerRadius(4) + } else { + Image(systemName: "cube") + .font(.system(size: 14)) + } + + if let modelName = viewModel.selectedModelName { + VStack(alignment: .leading, spacing: 2) { + Text(modelName.shortModelName()) + .font(.caption) + .fontWeight(.medium) + .lineLimit(1) + + // Framework indicator + if let framework = viewModel.selectedFramework { + HStack(spacing: 3) { + Image(systemName: frameworkIcon(for: framework)) + .font(.system(size: 7)) + Text(framework.displayName) + .font(.system(size: 8, weight: .medium)) + } + .foregroundColor(frameworkColor(for: framework)) + } + } + } else { + Text("Select Model") + .font(.caption) + } + } + } + } + + + private func frameworkIcon(for framework: InferenceFramework) -> String { + switch framework { + case .onnx: return "square.stack.3d.up" + case .foundationModels: return "apple.logo" + default: return "cube" + } + } + + private func frameworkColor(for framework: InferenceFramework) -> Color { + switch framework { + case .onnx: return .purple + case .foundationModels: return .primary + default: return .gray + } + } +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Voice/TTSViewModel.swift b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Voice/TTSViewModel.swift new file mode 100644 index 000000000..4c9a1fe93 --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Voice/TTSViewModel.swift @@ -0,0 +1,179 @@ +import Foundation +import RunAnywhere +import Combine +import os + +// MARK: - TTS ViewModel + +/// ViewModel for Text-to-Speech functionality +/// +/// Uses the simplified `RunAnywhere.speak()` API - the SDK handles all audio playback internally. +@MainActor +class TTSViewModel: ObservableObject { + private let logger = Logger(subsystem: "com.runanywhere", category: "TTS") + + // MARK: - Published Properties + + // Model State + @Published var selectedFramework: InferenceFramework? + @Published var selectedModelName: String? + @Published var selectedModelId: String? + + // Speaking State + @Published var isSpeaking = false + @Published var errorMessage: String? + @Published var lastResult: TTSSpeakResult? + + // Voice Settings + @Published var speechRate: Double = 1.0 + @Published var pitch: Double = 1.0 + + // MARK: - Private Properties + + private var cancellables = Set() + private var isInitialized = false + private var hasSubscribedToEvents = false + + // MARK: - Initialization + + /// Initialize the TTS view model + /// This method is idempotent - calling it multiple times is safe + func initialize() async { + guard !isInitialized else { + logger.debug("TTS view model already initialized, skipping") + return + } + isInitialized = true + + logger.info("Initializing TTS view model") + + // Subscribe to SDK events for TTS model state + subscribeToSDKEvents() + + // Check initial TTS voice state + if let voiceId = await RunAnywhere.currentTTSVoiceId { + selectedModelId = voiceId + selectedModelName = voiceId + logger.info("TTS voice already loaded: \(voiceId)") + } + } + + // MARK: - Model Management + + /// Load a model from the unified model selection sheet + func loadModelFromSelection(_ model: ModelInfo) async { + logger.info("Loading TTS model from selection: \(model.name)") + isSpeaking = true + errorMessage = nil + + do { + try await RunAnywhere.loadTTSModel(model.id) + selectedFramework = model.framework + selectedModelName = model.name.modelNameFromID() + selectedModelId = model.id + logger.info("TTS model loaded successfully: \(model.name)") + } catch { + logger.error("Failed to load TTS model: \(error.localizedDescription)") + errorMessage = "Failed to load model: \(error.localizedDescription)" + } + + isSpeaking = false + } + + // MARK: - Speaking + + /// Speak the given text aloud + /// + /// The SDK handles audio synthesis and playback internally. + /// - Parameter text: The text to speak + func speak(text: String) async { + logger.info("Speaking: \(text.prefix(50))...") + isSpeaking = true + errorMessage = nil + lastResult = nil + + do { + let options = TTSOptions( + rate: Float(speechRate), + pitch: Float(pitch) + ) + + // SDK handles everything - synthesis AND playback + let result = try await RunAnywhere.speak(text, options: options) + lastResult = result + + logger.info("Speech completed: \(String(format: "%.2fs", result.duration))") + } catch { + logger.error("Speech failed: \(error.localizedDescription)") + errorMessage = "Speech failed: \(error.localizedDescription)" + } + + isSpeaking = false + } + + /// Stop current speech + func stopSpeaking() async { + logger.info("Stopping speech") + await RunAnywhere.stopSpeaking() + isSpeaking = false + } + + // MARK: - Cleanup + + /// Clean up resources - call from view's onDisappear + func cleanup() { + cancellables.removeAll() + isInitialized = false + hasSubscribedToEvents = false + } + + // MARK: - SDK Event Handling + + private func subscribeToSDKEvents() { + guard !hasSubscribedToEvents else { + logger.debug("Already subscribed to SDK events, skipping") + return + } + hasSubscribedToEvents = true + + RunAnywhere.events.events + .receive(on: DispatchQueue.main) + .sink { [weak self] event in + Task { @MainActor in + self?.handleSDKEvent(event) + } + } + .store(in: &cancellables) + } + + private func handleSDKEvent(_ event: any SDKEvent) { + // Events now come from C++ via generic BridgedEvent + guard event.category == .tts else { return } + + switch event.type { + case "tts_voice_load_completed": + let voiceId = event.properties["model_id"] ?? "" + selectedModelId = voiceId + selectedModelName = voiceId + logger.info("TTS voice loaded: \(voiceId)") + case "tts_voice_unloaded": + selectedModelId = nil + selectedModelName = nil + selectedFramework = nil + logger.info("TTS voice unloaded") + default: + break + } + } + + // MARK: - Formatting Helpers + + func formatBytes(_ bytes: Int) -> String { + let kb = Double(bytes) / 1024.0 + if kb < 1024 { + return String(format: "%.1f KB", kb) + } else { + return String(format: "%.1f MB", kb / 1024.0) + } + } +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Voice/TextToSpeechView.swift b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Voice/TextToSpeechView.swift new file mode 100644 index 000000000..ff178564b --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Voice/TextToSpeechView.swift @@ -0,0 +1,551 @@ +import SwiftUI +import RunAnywhere +#if os(macOS) +import AppKit +#endif + +// MARK: - Sample Texts + +/// Collection of funny sample texts for TTS demo +private let funnyTTSSampleTexts: [String] = [ + "I'm not saying I'm Batman, but have you ever seen me and Batman in the same room?", + "According to my calculations, I should have been a millionaire by now. My calculations were wrong.", + "I told my computer I needed a break, and now it won't stop sending me vacation ads.", + "Why do programmers prefer dark mode? Because light attracts bugs!", + "I speak fluent sarcasm. Unfortunately, my phone's voice assistant doesn't.", + "My brain has too many tabs open and I can't find the one playing music.", + "I put my phone on airplane mode but it didn't fly. Worst paper airplane ever.", + "I'm not lazy, I'm just on energy-saving mode. Like a responsible gadget.", + "I tried to be normal once. Worst two minutes of my life.", + "Coffee: because adulting is hard and mornings are a cruel joke.", + "My wallet is like an onion. When I open it, I cry.", + "Behind every great person is a cat judging them silently.", + "Plot twist: the hokey pokey really IS what it's all about.", + "RunAnywhere: because your AI should work even when your WiFi doesn't.", + "We're a Y Combinator company now. Our moms are finally proud of us.", + "On-device AI means your voice data stays on your phone. Unlike your ex, we respect privacy.", + "RunAnywhere: Making cloud APIs jealous since 2024.", + "Our SDK is so fast, it finished processing before you finished reading this sentence.", + "Why pay per API call when you can run AI locally? Your wallet called, it says thank you.", + "Voice AI that runs offline? That's not magic, that's just good engineering. Okay, maybe a little magic." +] + +// MARK: - Text-to-Speech View + +/// Dedicated Text-to-Speech view with text input and instant playback +struct TextToSpeechView: View { + @StateObject private var viewModel = TTSViewModel() + @State private var showModelPicker = false + @State private var inputText: String = funnyTTSSampleTexts.randomElement() + ?? "Hello! This is a text to speech test." + @State private var borderAnimation = false + @State private var waveAnimation = false + + // MARK: - Computed Properties + + private var hasModelSelected: Bool { + viewModel.selectedModelName != nil + } + + // MARK: - Body + + var body: some View { + NavigationView { + ZStack { + VStack(spacing: 0) { + // Main content - only enabled when model is selected + if hasModelSelected { + mainContentView + controlsView + } else { + Spacer() + } + } + + // Overlay when no model is selected + if !hasModelSelected && !viewModel.isSpeaking { + ModelRequiredOverlay( + modality: .tts + ) { showModelPicker = true } + } + } + .navigationTitle(hasModelSelected ? "Text to Speech" : "") + .navigationBarTitleDisplayMode(.inline) + .navigationBarHidden(!hasModelSelected) + .toolbar { + if hasModelSelected { + ToolbarItem(placement: .navigationBarTrailing) { + modelButton + } + } + } + } + .navigationViewStyle(.stack) + .sheet(isPresented: $showModelPicker) { + ModelSelectionSheet(context: .tts) { model in + Task { + await viewModel.loadModelFromSelection(model) + } + } + } + .onAppear { + Task { + await viewModel.initialize() + } + borderAnimation = true + } + .onDisappear { + viewModel.cleanup() + } + .onChange(of: viewModel.selectedModelName) { oldValue, newValue in + // Set a new random funny text when a model is loaded + if oldValue == nil && newValue != nil { + inputText = funnyTTSSampleTexts.randomElement() ?? inputText + } + } + } + + // MARK: - View Components + + /// Main content area with input and settings + private var mainContentView: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + // Text input section + textInputSection + + // Voice settings section + voiceSettingsSection + + // Speech info (shown after speaking) + if let result = viewModel.lastResult { + speechInfoSection(result: result) + } + } + .padding() + } + } + + /// Text input section with premium styling and character count + private var textInputSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Enter Text") + .font(.system(size: 17, weight: .semibold, design: .rounded)) + .foregroundColor(.primary) + + ZStack(alignment: .topLeading) { + // Placeholder text + if inputText.isEmpty { + Text("Type or paste text to speak...") + .font(.system(size: 16, design: .rounded)) + .foregroundColor(.secondary.opacity(0.5)) + .padding(.horizontal, 16) + .padding(.top, 20) + } + + Group { + TextEditor(text: $inputText) + .font(.system(size: 16, weight: .regular, design: .rounded)) + .padding(16) + .frame(height: 180) + .scrollContentBackground(.hidden) + } + #if os(iOS) + .background( + LinearGradient( + colors: [ + Color(.secondarySystemBackground), + Color(.tertiarySystemBackground).opacity(0.5) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + #else + .background( + LinearGradient( + colors: [ + Color(NSColor.controlBackgroundColor), + Color(NSColor.controlBackgroundColor).opacity(0.8) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + #endif + .cornerRadius(16) + .background { + if #available(iOS 26.0, *) { + RoundedRectangle(cornerRadius: 16) + .fill(.clear) + .glassEffect(.regular.interactive(), in: RoundedRectangle(cornerRadius: 16)) + } + } + } + + HStack { + Text("\(inputText.count) characters") + .font(.system(size: 12, weight: .medium, design: .rounded)) + .foregroundColor(.secondary) + + Spacer() + + // Premium surprise me button + Button { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + inputText = funnyTTSSampleTexts.randomElement() ?? inputText + } + } label: { + HStack(spacing: 6) { + Image(systemName: "sparkles") + .font(.system(size: 11)) + Text("Surprise me") + .font(.system(size: 12, weight: .semibold)) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(AppColors.primaryPurple.opacity(0.15)) + .foregroundColor(AppColors.primaryPurple) + .cornerRadius(8) + } + } + } + } + + /// Voice settings section with rate and pitch controls + private var voiceSettingsSection: some View { + VStack(alignment: .leading, spacing: 20) { + Text("Voice Settings") + .font(.headline) + .foregroundColor(.primary) + + // Speech rate + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("Speed") + .font(.subheadline) + .foregroundColor(.secondary) + Spacer() + Text(String(format: "%.1fx", viewModel.speechRate)) + .font(.system(size: 15, weight: .medium, design: .rounded)) + .foregroundColor(.primary) + } + Slider(value: $viewModel.speechRate, in: 0.5...2.0, step: 0.1) + .tint(AppColors.primaryAccent) + } + + // Pitch + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("Pitch") + .font(.subheadline) + .foregroundColor(.secondary) + Spacer() + Text(String(format: "%.1fx", viewModel.pitch)) + .font(.system(size: 15, weight: .medium, design: .rounded)) + .foregroundColor(.primary) + } + Slider(value: $viewModel.pitch, in: 0.5...2.0, step: 0.1) + .tint(AppColors.primaryPurple) + } + } + .padding(20) + .background(AppColors.backgroundTertiary) + .cornerRadius(16) + } + + /// Speech info section showing result details + private func speechInfoSection(result: TTSSpeakResult) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text("Last Speech") + .font(.headline) + .foregroundColor(.primary) + + VStack(alignment: .leading, spacing: 4) { + metadataRow( + icon: "waveform", + label: "Duration", + value: String(format: "%.2fs", result.duration) + ) + if result.audioSizeBytes > 0 { + metadataRow( + icon: "doc.text", + label: "Size", + value: viewModel.formatBytes(result.audioSizeBytes) + ) + metadataRow( + icon: "speaker.wave.2", + label: "Format", + value: result.format.rawValue.uppercased() + ) + } + metadataRow( + icon: "person.wave.2", + label: "Voice", + value: result.metadata.voice.modelNameFromID() + ) + } + .font(.caption) + .foregroundColor(.secondary) + } + .padding() + .background(AppColors.backgroundSecondary) + .cornerRadius(12) + } + + /// Controls section with waveform visualization and speak button + private var controlsView: some View { + VStack(spacing: 16) { + // Error message + if let error = viewModel.errorMessage { + Text(error) + .font(.caption) + .foregroundColor(AppColors.statusRed) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + + // Waveform visualization when speaking + if viewModel.isSpeaking { + speakingWaveform + .transition(.scale.combined(with: .opacity)) + } + + // Speak button + speakButton + + // Status text with premium typography + if !statusText.isEmpty { + Text(statusText) + .font(.system(size: 13, weight: .medium, design: .rounded)) + .foregroundColor(.secondary) + } + } + .padding() + .background(AppColors.backgroundPrimary) + .animation(.spring(response: 0.3, dampingFraction: 0.7), value: viewModel.isSpeaking) + } + + /// Minimal waveform visualization for speaking state + private var speakingWaveform: some View { + HStack(spacing: 4) { + ForEach(0..<7) { index in + RoundedRectangle(cornerRadius: 8) + .fill( + LinearGradient( + colors: [ + AppColors.primaryPurple.opacity(0.8), + AppColors.primaryPurple.opacity(0.4) + ], + startPoint: .top, + endPoint: .bottom + ) + ) + .frame(width: 5, height: waveHeight(for: index)) + .animation( + .easeInOut(duration: 0.6) + .repeatForever(autoreverses: true) + .delay(Double(index) * 0.08), + value: waveAnimation + ) + } + } + .frame(height: 40) + .onAppear { + waveAnimation = true + } + .onDisappear { + waveAnimation = false + } + } + + /// Calculate waveform bar heights with variation + private func waveHeight(for index: Int) -> CGFloat { + let heights: [CGFloat] = [20, 32, 28, 36, 28, 32, 20] + let animatedHeights: [CGFloat] = [28, 40, 36, 44, 36, 40, 28] + + return waveAnimation ? animatedHeights[index] : heights[index] + } + + /// Speak button - synthesizes and plays audio instantly + private var speakButton: some View { + Group { + if #available(iOS 26.0, *) { + Button( + action: { + Task { + if viewModel.isSpeaking { + await viewModel.stopSpeaking() + } else { + await viewModel.speak(text: inputText) + } + } + }, + label: { + HStack { + if viewModel.isSpeaking { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .scaleEffect(0.8) + Text("Speaking...") + .fontWeight(.semibold) + } else { + Image(systemName: "speaker.wave.2.fill") + .font(.system(size: 20)) + Text("Speak") + .fontWeight(.semibold) + } + } + .frame(minWidth: 160, idealWidth: 200, maxWidth: 240) + .frame(height: DeviceFormFactor.current == .desktop ? 56 : 50) + .background(speakButtonColor) + .foregroundColor(.white) + .cornerRadius(25) + } + ) + .disabled(inputText.isEmpty || viewModel.selectedModelName == nil) + .glassEffect(.regular.interactive()) + } else { + Button( + action: { + Task { + if viewModel.isSpeaking { + await viewModel.stopSpeaking() + } else { + await viewModel.speak(text: inputText) + } + } + }, + label: { + HStack { + if viewModel.isSpeaking { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .scaleEffect(0.8) + Text("Speaking...") + .fontWeight(.semibold) + } else { + Image(systemName: "speaker.wave.2.fill") + .font(.system(size: 20)) + Text("Speak") + .fontWeight(.semibold) + } + } + .frame(minWidth: 160, idealWidth: 200, maxWidth: 240) + .frame(height: DeviceFormFactor.current == .desktop ? 56 : 50) + .background(speakButtonColor) + .foregroundColor(.white) + .cornerRadius(25) + } + ) + .disabled(inputText.isEmpty || viewModel.selectedModelName == nil) + } + } + } + + /// Model button for navigation bar with logo + private var modelButton: some View { + Button { + showModelPicker = true + } label: { + HStack(spacing: 6) { + // Model logo instead of cube icon + if let modelName = viewModel.selectedModelName { + Image(getModelLogo(for: modelName)) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 36, height: 36) + .cornerRadius(4) + } else { + Image(systemName: "cube") + .font(.system(size: 14)) + } + + if let modelName = viewModel.selectedModelName { + VStack(alignment: .leading, spacing: 2) { + Text(modelName.shortModelName()) + .font(.caption) + .fontWeight(.medium) + .lineLimit(1) + + // Framework indicator + if let framework = viewModel.selectedFramework { + HStack(spacing: 3) { + Image(systemName: frameworkIcon(for: framework)) + .font(.system(size: 7)) + Text(framework.displayName) + .font(.system(size: 8, weight: .medium)) + } + .foregroundColor(frameworkColor(for: framework)) + } + } + } else { + Text("Select Model") + .font(.caption) + } + } + } + } + + + // MARK: - Helper Views + + /// Metadata row with icon, label, and value + @ViewBuilder + private func metadataRow(icon: String, label: String, value: String) -> some View { + HStack { + Image(systemName: icon) + .frame(width: 16) + Text(label + ":") + Spacer() + Text(value) + .fontWeight(.medium) + } + } + + // MARK: - Computed UI Properties + + /// Status text based on current state + private var statusText: String { + if viewModel.isSpeaking { + return "Speaking..." + } else if viewModel.lastResult != nil { + return "Tap Speak to hear it again" + } else { + return "Ready" + } + } + + /// Speak button color based on state + private var speakButtonColor: Color { + if inputText.isEmpty || viewModel.selectedModelName == nil { + return AppColors.statusGray + } else if viewModel.isSpeaking { + return AppColors.statusOrange + } else { + return AppColors.primaryPurple + } + } + + private func frameworkIcon(for framework: InferenceFramework) -> String { + switch framework { + case .foundationModels: return "apple.logo" + default: return "cube" + } + } + + private func frameworkColor(for framework: InferenceFramework) -> Color { + switch framework { + case .foundationModels: return .primary + default: return .gray + } + } +} + +// MARK: - Preview + +struct TextToSpeechView_Previews: PreviewProvider { + static var previews: some View { + TextToSpeechView() + } +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Voice/VoiceAgentTypes.swift b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Voice/VoiceAgentTypes.swift new file mode 100644 index 000000000..73ab92061 --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Voice/VoiceAgentTypes.swift @@ -0,0 +1,85 @@ +// +// VoiceAgentTypes.swift +// RunAnywhereAI +// +// Supporting types for VoiceAgentViewModel. +// Extracted to reduce file length and improve organization. +// + +import SwiftUI +import RunAnywhere + +// MARK: - Model Selection State + +/// Represents a selected model with its framework, name, and ID +/// Used instead of tuple to comply with SwiftLint large_tuple rule +struct SelectedModelInfo: Equatable { + let framework: InferenceFramework + let name: String + let id: String +} + +// MARK: - Session State + +/// Represents the current state of the voice session +enum VoiceSessionState: Equatable { + case disconnected // Not connected, ready to start + case connecting // Initializing session + case connected // Session established, idle + case listening // Actively listening for speech + case processing // Processing transcribed speech + case speaking // Playing back TTS response + case error(String) // Error state + + var displayName: String { + switch self { + case .disconnected: return "Ready" + case .connecting: return "Connecting" + case .connected: return "Ready" + case .listening: return "Listening" + case .processing: return "Thinking" + case .speaking: return "Speaking" + case .error: return "Error" + } + } +} + +// MARK: - Status Colors + +/// Color indicator for status +enum StatusColor { + case gray, orange, green, red, blue + + var swiftUIColor: Color { + switch self { + case .gray: return .gray + case .orange: return AppColors.primaryAccent + case .green: return .green + case .red: return .red + case .blue: return AppColors.primaryAccent + } + } +} + +/// Color for microphone button +enum MicButtonColor { + case orange, red, blue, green + + var swiftUIColor: Color { + switch self { + case .orange: return AppColors.primaryAccent + case .red: return .red + case .blue: return AppColors.primaryAccent + case .green: return .green + } + } +} + +// MARK: - Model Type + +/// Enum for identifying model types in VoiceAgentViewModel +enum ModelTypeEnum { + case stt + case llm + case tts +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Voice/VoiceAgentViewModel.swift b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Voice/VoiceAgentViewModel.swift new file mode 100644 index 000000000..ce876682c --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Voice/VoiceAgentViewModel.swift @@ -0,0 +1,470 @@ +// +// VoiceAgentViewModel.swift +// RunAnywhereAI +// +// A clean, refactored ViewModel for Voice Assistant functionality. +// Orchestrates the complete STT → LLM → TTS pipeline with proper state management. +// +// MVVM Principles: +// - ALL business logic lives in this ViewModel +// - Views only observe state and call ViewModel methods +// - No SDK calls or business logic in views +// + +import Foundation +import SwiftUI +import RunAnywhere +import Combine +import os + +/// A clean ViewModel for voice assistant using SDK's VoiceSession API. +/// +/// This ViewModel orchestrates the complete voice AI pipeline: +/// - Audio capture and VAD (Voice Activity Detection) +/// - Speech-to-Text (STT) transcription +/// - Large Language Model (LLM) response generation +/// - Text-to-Speech (TTS) synthesis +/// - Audio playback coordination +/// +/// The SDK handles the actual orchestration; this ViewModel bridges SDK events to UI state. +@MainActor +final class VoiceAgentViewModel: ObservableObject { + // MARK: - Dependencies + + private let logger = Logger(subsystem: "com.runanywhere.RunAnywhereAI", category: "VoiceAgent") + private var cancellables = Set() + + // MARK: - Published State (Observable by Views) + + /// Current session state + @Published private(set) var sessionState: VoiceSessionState = .disconnected + + /// Initialization state + @Published private(set) var isInitialized = false + + /// Audio level (0.0 to 1.0) for visual feedback + @Published private(set) var audioLevel: Float = 0.0 + + /// Current status message + @Published private(set) var currentStatus = "Initializing..." + + /// Error message to display to user + @Published private(set) var errorMessage: String? + + /// Current transcript from STT + @Published private(set) var currentTranscript = "" + + /// Assistant's response from LLM + @Published private(set) var assistantResponse = "" + + /// Whether speech is currently detected (for pulsing animation) + @Published private(set) var isSpeechDetected = false + + // MARK: - Model Selection State + + /// Selected STT model + @Published var sttModel: SelectedModelInfo? + + /// Selected LLM model + @Published var llmModel: SelectedModelInfo? + + /// Selected TTS model + @Published var ttsModel: SelectedModelInfo? + + /// STT model loading state + @Published private(set) var sttModelState: ModelLoadState = .notLoaded + + /// LLM model loading state + @Published private(set) var llmModelState: ModelLoadState = .notLoaded + + /// TTS model loading state + @Published private(set) var ttsModelState: ModelLoadState = .notLoaded + + // MARK: - Computed Properties (for View) + + /// Whether all required models are loaded + var allModelsLoaded: Bool { + sttModelState.isLoaded && llmModelState.isLoaded && ttsModelState.isLoaded + } + + /// Whether currently listening + var isListening: Bool { + sessionState == .listening + } + + /// Whether currently processing + var isProcessing: Bool { + sessionState == .processing + } + + /// Whether currently speaking + var isSpeaking: Bool { + sessionState == .speaking + } + + /// Whether the session is active (any state except disconnected/connected) + var isActive: Bool { + switch sessionState { + case .listening, .processing, .speaking, .connecting: + return true + default: + return false + } + } + + /// Status color for UI indicators + var statusColor: StatusColor { + switch sessionState { + case .disconnected: return .gray + case .connecting: return .orange + case .connected: return .green + case .listening: return .red + case .processing: return .orange + case .speaking: return .green + case .error: return .red + } + } + + /// Microphone button color + var micButtonColor: MicButtonColor { + switch sessionState { + case .connecting: return .orange + case .listening: return .red + case .processing: return .orange + case .speaking: return .green + default: return .orange + } + } + + /// Microphone button icon + var micButtonIcon: String { + switch sessionState { + case .listening: return "mic.fill" + case .speaking: return "speaker.wave.2.fill" + case .processing: return "waveform" + default: return "mic" + } + } + + /// Instruction text for current state + var instructionText: String { + switch sessionState { + case .listening: + return "Listening... Pause to send" + case .processing: + return "Processing your message..." + case .speaking: + return "Speaking..." + case .connecting: + return "Connecting..." + default: + return "Tap to start conversation" + } + } + + // MARK: - Private State + + private var session: VoiceSessionHandle? + private var eventTask: Task? + + // MARK: - Initialization State (for idempotency) + + private var isViewModelInitialized = false + private var hasSubscribedToSDKEvents = false + + // MARK: - Initialization + + /// Initialize the ViewModel and subscribe to SDK events + /// This method is idempotent - calling it multiple times is safe + func initialize() async { + guard !isViewModelInitialized else { + logger.debug("Voice agent already initialized, skipping") + return + } + isViewModelInitialized = true + + logger.info("Initializing voice agent...") + + // Subscribe to SDK component events for model state tracking + subscribeToSDKEvents() + + // Sync current model states from SDK + await syncModelStates() + + currentStatus = "Ready" + isInitialized = true + logger.info("Voice agent initialized successfully") + } + + // MARK: - Model State Management + + /// Refresh component states from SDK (useful after model loading in another view) + func refreshComponentStatesFromSDK() { + Task { + await syncModelStates() + } + } + + /// Sync model states from SDK + private func syncModelStates() async { + let states = await RunAnywhere.getVoiceAgentComponentStates() + + sttModelState = mapState(states.stt) + llmModelState = mapState(states.llm) + ttsModelState = mapState(states.tts) + + if case .loaded(let id) = states.stt { updateModel(.stt, id: id) } + if case .loaded(let id) = states.llm { updateModel(.llm, id: id) } + if case .loaded(let id) = states.tts { updateModel(.tts, id: id) } + + logger.info("Model states synced - STT: \(states.stt.isLoaded), LLM: \(states.llm.isLoaded), TTS: \(states.tts.isLoaded)") + } + + private func mapState(_ state: ComponentLoadState) -> ModelLoadState { + switch state { + case .notLoaded: return .notLoaded + case .loading: return .loading + case .loaded: return .loaded + case .error(let message): return .error(message) + } + } + + private enum ModelType { case stt, llm, tts } + + private func updateModel(_ type: ModelType, id: String) { + // Find model info from shared model list + let model = ModelListViewModel.shared.availableModels.first { $0.id == id } + let name = model?.name ?? id + let framework = model?.framework ?? (type == .llm ? .llamaCpp : .onnx) // Fallback only if no model selected + let selectedModel = SelectedModelInfo(framework: framework, name: name, id: id) + + switch type { + case .stt: + sttModel = selectedModel + case .llm: + llmModel = selectedModel + case .tts: + ttsModel = selectedModel + } + } + + // MARK: - SDK Event Subscription + + private func subscribeToSDKEvents() { + guard !hasSubscribedToSDKEvents else { + logger.debug("Already subscribed to SDK events, skipping") + return + } + hasSubscribedToSDKEvents = true + + RunAnywhere.events.events + .receive(on: DispatchQueue.main) + .sink { [weak self] event in + // Defer state modifications to avoid "Publishing changes within view updates" warning + Task { @MainActor in + self?.handleSDKEvent(event) + } + } + .store(in: &cancellables) + } + + private func handleSDKEvent(_ event: any SDKEvent) { + // Events now come from C++ via generic BridgedEvent + // Handle by event type string and category + switch event.category { + case .llm: + handleLLMEvent(event) + case .stt: + handleSTTEvent(event) + case .tts: + handleTTSEvent(event) + default: + break + } + } + + private func handleLLMEvent(_ event: any SDKEvent) { + let modelId = event.properties["model_id"] ?? "" + let errorMessage = event.properties["error_message"] + + switch event.type { + case "llm_model_load_started": + llmModelState = .loading + case "llm_model_load_completed": + llmModelState = .loaded + updateModel(.llm, id: modelId) + case "llm_model_load_failed": + llmModelState = .error(errorMessage ?? "Unknown error") + case "llm_model_unloaded": + llmModelState = .notLoaded + llmModel = nil + default: + break + } + } + + private func handleSTTEvent(_ event: any SDKEvent) { + let modelId = event.properties["model_id"] ?? "" + let errorMessage = event.properties["error_message"] + + switch event.type { + case "stt_model_load_started": + sttModelState = .loading + case "stt_model_load_completed": + sttModelState = .loaded + updateModel(.stt, id: modelId) + case "stt_model_load_failed": + sttModelState = .error(errorMessage ?? "Unknown error") + case "stt_model_unloaded": + sttModelState = .notLoaded + sttModel = nil + default: + break + } + } + + private func handleTTSEvent(_ event: any SDKEvent) { + let modelId = event.properties["model_id"] ?? "" + let errorMessage = event.properties["error_message"] + + switch event.type { + case "tts_voice_load_started": + ttsModelState = .loading + case "tts_voice_load_completed": + ttsModelState = .loaded + updateModel(.tts, id: modelId) + case "tts_voice_load_failed": + ttsModelState = .error(errorMessage ?? "Unknown error") + case "tts_voice_unloaded": + ttsModelState = .notLoaded + ttsModel = nil + default: + break + } + } + + // MARK: - Model Selection + + /// Set the STT model + func setSTTModel(_ model: ModelInfo) { + sttModel = SelectedModelInfo(framework: model.framework, name: model.name, id: model.id) + Task { + await syncModelStates() + } + } + + /// Set the LLM model + func setLLMModel(_ model: ModelInfo) { + llmModel = SelectedModelInfo(framework: model.framework, name: model.name, id: model.id) + Task { + await syncModelStates() + } + } + + /// Set the TTS model + func setTTSModel(_ model: ModelInfo) { + ttsModel = SelectedModelInfo(framework: model.framework, name: model.name, id: model.id) + Task { await syncModelStates() } + } + + // MARK: - Conversation Control + + /// Start a voice conversation session + func startConversation() async { + guard allModelsLoaded else { + sessionState = .error("Models not ready") + errorMessage = "Please ensure all models (STT, LLM, TTS) are loaded before starting" + logger.warning("Attempted to start conversation without all models loaded") + return + } + + sessionState = .connecting + currentStatus = "Connecting..." + errorMessage = nil + + // Clear previous conversation when starting a new one + currentTranscript = "" + assistantResponse = "" + + do { + session = try await RunAnywhere.startVoiceSession() + sessionState = .listening + currentStatus = "Listening..." + eventTask = Task { [weak self] in + guard let session = self?.session else { return } + for await event in session.events { + await MainActor.run { self?.handleSessionEvent(event) } + } + } + logger.info("Voice session started successfully") + } catch { + sessionState = .error(error.localizedDescription) + currentStatus = "Error" + errorMessage = "Failed to start session: \(error.localizedDescription)" + logger.error("Failed to start voice session: \(error.localizedDescription)") + } + } + + /// Stop the current voice conversation + func stopConversation() async { + logger.info("Stopping voice session...") + eventTask?.cancel() + eventTask = nil + await session?.stop() + session = nil + sessionState = .disconnected + currentStatus = "Ready" + audioLevel = 0.0 + isSpeechDetected = false + logger.info("Voice session stopped") + } + + /// Force send current audio buffer (for push-to-talk mode) + func sendAudioNow() async { + await session?.sendNow() + logger.debug("Forced audio send") + } + + // MARK: - Session Event Handling + + private func handleSessionEvent(_ event: VoiceSessionEvent) { + switch event { + case .started: sessionState = .listening; currentStatus = "Listening..." + case .listening(let level): audioLevel = level + case .speechStarted: isSpeechDetected = true; currentStatus = "Listening..." + case .processing: sessionState = .processing; currentStatus = "Processing..."; isSpeechDetected = false + case .transcribed(let text): currentTranscript = text + case .responded(let text): assistantResponse = text + case .speaking: sessionState = .speaking; currentStatus = "Speaking..." + case let .turnCompleted(transcript, response, _): + currentTranscript = transcript; assistantResponse = response + sessionState = .listening; currentStatus = "Listening..." + case .stopped: sessionState = .disconnected; currentStatus = "Ready" + case .error(let message): logger.error("Session error: \(message)"); errorMessage = message + } + } + + // MARK: - Cleanup + + func cleanup() { + eventTask?.cancel() + eventTask = nil + cancellables.removeAll() + isViewModelInitialized = false + hasSubscribedToSDKEvents = false + logger.info("VoiceAgentViewModel cleanup completed") + } + + // MARK: - Helper Properties + + var currentSTTModel: String { + sttModel?.name.modelNameFromID() ?? "Not loaded" + } + var currentLLMModel: String { + llmModel?.name.modelNameFromID() ?? "Not loaded" + } + var currentTTSModel: String { + ttsModel?.name.modelNameFromID() ?? "Not loaded" + } + var whisperModel: String { currentSTTModel } +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Voice/VoiceAssistantComponents.swift b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Voice/VoiceAssistantComponents.swift new file mode 100644 index 000000000..3343531b4 --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Voice/VoiceAssistantComponents.swift @@ -0,0 +1,70 @@ +// +// VoiceAssistantComponents.swift +// RunAnywhereAI +// +// Reusable UI components for VoiceAssistantView +// + +import SwiftUI + +// MARK: - ConversationBubble + +struct ConversationBubble: View { + let speaker: String + let message: String + let isUser: Bool + + private func fillColor(isUser: Bool) -> Color { + if isUser { + #if os(macOS) + return Color(NSColor.controlBackgroundColor) + #else + return Color(.secondarySystemBackground) + #endif + } else { + return AppColors.primaryAccent.opacity(0.08) + } + } + + var body: some View { + Text(message) + .font(.body) + .foregroundColor(.primary) + .padding(12) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(fillColor(isUser: isUser)) + ) + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +// MARK: - ModelBadge + +struct ModelBadge: View { + let icon: String + let label: String + let value: String + let color: Color + + var body: some View { + HStack(spacing: 4) { + Image(systemName: icon) + .font(.system(size: AdaptiveSizing.badgeFontSize)) + .foregroundColor(color) + VStack(alignment: .leading, spacing: 0) { + Text(label) + .font(.system(size: AdaptiveSizing.badgeFontSize - 1)) + .foregroundColor(.secondary) + Text(value.shortModelName(maxLength: 15)) + .font(.system(size: AdaptiveSizing.badgeFontSize)) + .fontWeight(.medium) + .lineLimit(1) + } + } + .padding(.horizontal, AdaptiveSizing.badgePaddingH) + .padding(.vertical, AdaptiveSizing.badgePaddingV) + .background(color.opacity(0.1)) + .cornerRadius(6) + } +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Voice/VoiceAssistantParticleView.swift b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Voice/VoiceAssistantParticleView.swift new file mode 100644 index 000000000..c180f875b --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Voice/VoiceAssistantParticleView.swift @@ -0,0 +1,566 @@ +import SwiftUI +import MetalKit +import Metal +import Combine + +// MARK: - Metal Shader with Localized Touch Scatter +private let shaderSource = """ +#include +using namespace metal; + +struct Vertex { + float3 position [[attribute(0)]]; + float index [[attribute(1)]]; + float radiusOffset [[attribute(2)]]; + float seed [[attribute(3)]]; +}; + +struct VertexOut { + float4 position [[position]]; + float pointSize [[point_size]]; + float3 color; + float alpha; +}; + +struct Uniforms { + float time; + float aspectRatio; + float amplitude; + float morphProgress; + float scatterAmount; + float2 touchPoint; // Normalized touch position (-1 to 1) + float3 baseColor; + float isDarkMode; // 1.0 for dark, 0.0 for light +}; + +float hash(float n) { + return fract(sin(n) * 43758.5453123); +} + +float noise(float3 x) { + float3 p = floor(x); + float3 f = fract(x); + f = f * f * (3.0 - 2.0 * f); + float n = p.x + p.y * 57.0 + 113.0 * p.z; + return mix(mix(mix(hash(n), hash(n + 1.0), f.x), + mix(hash(n + 57.0), hash(n + 58.0), f.x), f.y), + mix(mix(hash(n + 113.0), hash(n + 114.0), f.x), + mix(hash(n + 170.0), hash(n + 171.0), f.x), f.y), f.z); +} + +vertex VertexOut vertexShader(const device Vertex* vertices [[buffer(0)]], + constant Uniforms& u [[buffer(1)]], + uint vid [[vertex_id]]) { + VertexOut out; + + Vertex v = vertices[vid]; + float3 spherePos = v.position; + float t = u.time; + float morph = u.morphProgress; + float seed = v.seed; + + // === SPHERE STATE === + float sphereAngle = -t * 0.2; + float cosA = cos(sphereAngle); + float sinA = sin(sphereAngle); + + float3 rotatedSphere; + rotatedSphere.x = spherePos.x * cosA - spherePos.z * sinA; + rotatedSphere.y = spherePos.y; + rotatedSphere.z = spherePos.x * sinA + spherePos.z * cosA; + + float sphereBreath = 1.0 + sin(t * 1.0) * 0.025; + rotatedSphere *= sphereBreath; + + // === RING STATE === + float ringAngle = v.index * 3.14159 * 2.0 + t * 0.25; + float baseRingRadius = 1.3; + float audioPulse = u.amplitude * 0.4; + float ringRadius = baseRingRadius + audioPulse + sin(t * 1.5) * 0.03; + ringRadius += v.radiusOffset * 0.18; + + float3 ringPos; + ringPos.x = cos(ringAngle) * ringRadius; + ringPos.y = sin(ringAngle) * ringRadius; + ringPos.z = 0.0; + + // === MORPH === + float personalSpeed = 0.6 + seed * 0.8; + float personalMorph = clamp(morph * personalSpeed + (seed - 0.5) * 0.3, 0.0, 1.0); + float smoothMorph = personalMorph * personalMorph * (3.0 - 2.0 * personalMorph); + smoothMorph = smoothMorph * smoothMorph * (3.0 - 2.0 * smoothMorph); + + float wanderPhase = morph * (1.0 - morph) * 4.0; + + float3 wander; + wander.x = noise(float3(seed * 100.0, t * 0.3, 0.0)) - 0.5; + wander.y = noise(float3(seed * 100.0 + 50.0, t * 0.3, 0.0)) - 0.5; + wander.z = noise(float3(seed * 100.0 + 100.0, t * 0.3, 0.0)) - 0.5; + wander *= wanderPhase * 0.6; + + float spiralAngle = seed * 6.28 + t * 0.5; + float spiralRadius = wanderPhase * 0.25; + float3 spiral = float3(cos(spiralAngle) * spiralRadius, sin(spiralAngle) * spiralRadius, 0.0); + + float3 basePos = mix(rotatedSphere, ringPos, smoothMorph); + float3 morphedPos = basePos + wander + spiral; + + // === LOCALIZED TOUCH SCATTER === + // Calculate screen position of this particle (matching touch coordinate system) + float scale = 0.85; + float tempZ = morphedPos.z + 2.5; + float2 screenPos; + screenPos.x = (morphedPos.x / tempZ) * scale; + screenPos.y = (morphedPos.y / tempZ) * scale; // No aspect ratio here - touch coords are square + + // Distance from touch point + float touchDist = length(screenPos - u.touchPoint); + + // Only affect particles near touch (radius ~0.3 in screen space) + float touchRadius = 0.35; + float touchInfluence = 1.0 - smoothstep(0.0, touchRadius, touchDist); + touchInfluence *= u.scatterAmount; + + // Gentle outward push from touch point + float2 pushDir = normalize(screenPos - u.touchPoint + 0.001); + float pushAmount = touchInfluence * 0.15; // Subtle push + + morphedPos.x += pushDir.x * pushAmount; + morphedPos.y += pushDir.y * pushAmount; + + // Tiny random jitter for organic feel + morphedPos.x += (noise(float3(seed * 200.0, t * 2.0, 0.0)) - 0.5) * touchInfluence * 0.08; + morphedPos.y += (noise(float3(seed * 200.0 + 100.0, t * 2.0, 0.0)) - 0.5) * touchInfluence * 0.08; + + float3 finalPos = morphedPos; + + // === PROJECTION === + float z = finalPos.z + 2.5; + + out.position.x = (finalPos.x / z) * scale; + out.position.y = (finalPos.y / z) * scale * u.aspectRatio; + out.position.z = 0.0; + out.position.w = 1.0; + + // Size + float baseSize = 7.0; + float transitionGlow = 1.0 + wanderPhase * 0.4; + out.pointSize = baseSize * (2.8 / z) * transitionGlow; + out.pointSize *= (1.0 + touchInfluence * 0.3); // Slight size boost on touch + out.pointSize = clamp(out.pointSize, 4.0, 14.0); + + // Color - Vibrant gold-orange dust (bright and luminous) + float energy = smoothMorph * (0.5 + u.amplitude * 0.5); + // Active: warm amber-orange + float3 activeColor = float3(1.0, 0.55, 0.15); // Warm amber + out.color = mix(u.baseColor, activeColor, energy); + + // Light mode: moderate brightness for rich saturated color, Dark mode: standard brightness + float brightMultiplier = u.isDarkMode > 0.5 ? (1.5 + energy * 0.5) : (2.2 + energy * 0.6); + out.color *= (brightMultiplier + touchInfluence * 0.3); + + // Full alpha for both modes for solid appearance + float depthShade = 0.5 + 0.5 * (1.0 - (z - 1.8) / 2.0); + float baseAlpha = 1.0; + out.alpha = mix(depthShade * 0.8, baseAlpha, smoothMorph); + + return out; +} + +fragment float4 fragmentShader(VertexOut in [[stage_in]], + float2 pointCoord [[point_coord]]) { + float dist = length(pointCoord - 0.5) * 2.0; + if (dist > 1.0) discard_fragment(); + + float core = 1.0 - smoothstep(0.0, 0.4, dist); + float glow = 1.0 - smoothstep(0.2, 1.0, dist); + float alpha = core * 0.8 + glow * 0.4; + + float3 color = in.color * (0.7 + core * 0.3); + + return float4(color * alpha, alpha * in.alpha); +} +""" + +// MARK: - Vertex Structure +private struct ParticleVertex { + var position: SIMD3 + var index: Float + var radiusOffset: Float + var seed: Float +} + +// MARK: - Uniforms +private struct ShaderUniforms { + var time: Float + var aspectRatio: Float + var amplitude: Float + var morphProgress: Float + var scatterAmount: Float + var touchPoint: SIMD2 + var baseColor: SIMD3 + var isDarkMode: Float +} + +// MARK: - Renderer +final class SphereRenderer: NSObject, MTKViewDelegate { + private let device: MTLDevice + private let commandQueue: MTLCommandQueue + private var pipelineState: MTLRenderPipelineState? + private var vertexBuffer: MTLBuffer? + + private let startTime = Date() + private let particleCount = 2000 + + var amplitude: Float = 0.0 + var morphProgress: Float = 0.0 + var scatterAmount: Float = 0.0 + var touchPoint: SIMD2 = .zero + var isDarkMode: Bool = true + // Vibrant logo orange - brighter and more saturated + var baseColor: SIMD3 = SIMD3(1.0, 0.5, 0.15) // Bright vivid orange + private var aspectRatio: Float = 1.0 + + init?(device: MTLDevice) { + self.device = device + guard let queue = device.makeCommandQueue() else { return nil } + self.commandQueue = queue + super.init() + + setupPipeline() + generateParticles() + } + + private func setupPipeline() { + do { + let library = try device.makeLibrary(source: shaderSource, options: nil) + + let vertexDescriptor = MTLVertexDescriptor() + vertexDescriptor.attributes[0].format = .float3 + vertexDescriptor.attributes[0].offset = 0 + vertexDescriptor.attributes[0].bufferIndex = 0 + vertexDescriptor.attributes[1].format = .float + vertexDescriptor.attributes[1].offset = MemoryLayout>.stride + vertexDescriptor.attributes[1].bufferIndex = 0 + vertexDescriptor.attributes[2].format = .float + vertexDescriptor.attributes[2].offset = MemoryLayout>.stride + MemoryLayout.stride + vertexDescriptor.attributes[2].bufferIndex = 0 + vertexDescriptor.attributes[3].format = .float + vertexDescriptor.attributes[3].offset = MemoryLayout>.stride + MemoryLayout.stride * 2 + vertexDescriptor.attributes[3].bufferIndex = 0 + vertexDescriptor.layouts[0].stride = MemoryLayout.stride + + let desc = MTLRenderPipelineDescriptor() + desc.vertexFunction = library.makeFunction(name: "vertexShader") + desc.fragmentFunction = library.makeFunction(name: "fragmentShader") + desc.vertexDescriptor = vertexDescriptor + desc.colorAttachments[0].pixelFormat = .bgra8Unorm + + desc.colorAttachments[0].isBlendingEnabled = true + desc.colorAttachments[0].rgbBlendOperation = .add + desc.colorAttachments[0].alphaBlendOperation = .add + desc.colorAttachments[0].sourceRGBBlendFactor = .sourceAlpha + desc.colorAttachments[0].sourceAlphaBlendFactor = .one + desc.colorAttachments[0].destinationRGBBlendFactor = .one + desc.colorAttachments[0].destinationAlphaBlendFactor = .oneMinusSourceAlpha + + pipelineState = try device.makeRenderPipelineState(descriptor: desc) + } catch { + print("Pipeline error: \(error)") + } + } + + private func generateParticles() { + var vertices: [ParticleVertex] = [] + + let goldenRatio = (1.0 + sqrt(5.0)) / 2.0 + let angleIncrement = Float.pi * 2.0 * Float(goldenRatio) + + for i in 0..(x, y, z), + index: index, + radiusOffset: radiusOffset, + seed: seed + )) + } + + vertexBuffer = device.makeBuffer( + bytes: vertices, + length: vertices.count * MemoryLayout.stride, + options: .storageModeShared + ) + } + + func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) { + aspectRatio = Float(size.width / size.height) + } + + func draw(in view: MTKView) { + guard let drawable = view.currentDrawable, + let descriptor = view.currentRenderPassDescriptor, + let pipelineState = pipelineState, + let vertexBuffer = vertexBuffer else { return } + + descriptor.colorAttachments[0].loadAction = .clear + descriptor.colorAttachments[0].clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 0) + + let time = Float(Date().timeIntervalSince(startTime)) + + var uniforms = ShaderUniforms( + time: time, + aspectRatio: aspectRatio, + amplitude: amplitude, + morphProgress: morphProgress, + scatterAmount: scatterAmount, + touchPoint: touchPoint, + // Light mode: darker/richer orange for saturation, Dark mode: brighter golden + baseColor: isDarkMode ? SIMD3(1.0, 0.6, 0.1) : SIMD3(0.9, 0.4, 0.05), + isDarkMode: isDarkMode ? 1.0 : 0.0 + ) + + guard let commandBuffer = commandQueue.makeCommandBuffer(), + let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: descriptor) else { return } + + encoder.setRenderPipelineState(pipelineState) + encoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0) + encoder.setVertexBytes(&uniforms, length: MemoryLayout.stride, index: 1) + encoder.drawPrimitives(type: .point, vertexStart: 0, vertexCount: particleCount) + encoder.endEncoding() + + commandBuffer.present(drawable) + commandBuffer.commit() + } +} + +// MARK: - SwiftUI View +struct VoiceAssistantParticleView: UIViewRepresentable { + var amplitude: Float + var morphProgress: Float + var scatterAmount: Float + var touchPoint: SIMD2 + var isDarkMode: Bool + + func makeUIView(context: Context) -> MTKView { + let view = MTKView() + view.device = MTLCreateSystemDefaultDevice() + view.clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 0) + view.colorPixelFormat = .bgra8Unorm + view.preferredFramesPerSecond = 60 + view.isPaused = false + view.enableSetNeedsDisplay = false + view.isOpaque = false + view.layer.isOpaque = false + view.backgroundColor = .clear + + if let device = view.device { + context.coordinator.renderer = SphereRenderer(device: device) + view.delegate = context.coordinator.renderer + } + + return view + } + + func updateUIView(_ uiView: MTKView, context: Context) { + context.coordinator.renderer?.amplitude = amplitude + context.coordinator.renderer?.morphProgress = morphProgress + context.coordinator.renderer?.scatterAmount = scatterAmount + context.coordinator.renderer?.touchPoint = touchPoint + context.coordinator.renderer?.isDarkMode = isDarkMode + } + + func makeCoordinator() -> Coordinator { Coordinator() } + + class Coordinator { + var renderer: SphereRenderer? + } +} + +// MARK: - Preview +struct VoiceAssistantMainPreview: View { + @Environment(\.colorScheme) var colorScheme + @State private var amplitude: Float = 0.0 + @State private var isListening = false + @State private var morphProgress: Float = 0.0 + @State private var scatterAmount: Float = 0.0 + @State private var touchPoint: SIMD2 = .zero + + private let timer = Timer.publish(every: 0.016, on: .main, in: .common).autoconnect() + + // Logo colors + private let logoOrange = Color(red: 1.0, green: 0.42, blue: 0.21) // #FF6B35 + private let logoCoral = Color(red: 1.0, green: 0.27, blue: 0.27) // #FF4444 + + var body: some View { + GeometryReader { geometry in + ZStack { + // Adaptive background + (colorScheme == .dark ? Color.black : Color.white) + .ignoresSafeArea() + + // ✨ Magical multi-layer ambient glow + + // Layer 1: Deep inner glow (warm amber core) + Circle() + .fill( + RadialGradient( + colors: [ + Color(red: 1.0, green: 0.6, blue: 0.2).opacity(colorScheme == .dark ? 0.15 : 0.08), + Color(red: 1.0, green: 0.4, blue: 0.1).opacity(colorScheme == .dark ? 0.08 : 0.04), + Color.clear + ], + center: .center, + startRadius: 30, + endRadius: 120 + ) + ) + .frame(width: 300, height: 300) + .blur(radius: 25) + .position(x: geometry.size.width / 2, y: geometry.size.height / 2) + + // Layer 2: Middle magical shimmer (golden dust halo) + Circle() + .fill( + RadialGradient( + colors: [ + Color(red: 1.0, green: 0.75, blue: 0.3).opacity(colorScheme == .dark ? Double(0.06 + morphProgress * 0.08) : Double(0.03 + morphProgress * 0.04)), + Color(red: 1.0, green: 0.5, blue: 0.15).opacity(colorScheme == .dark ? 0.04 : 0.02), + Color.clear + ], + center: .center, + startRadius: 80, + endRadius: 180 + ) + ) + .frame(width: 400, height: 400) + .blur(radius: 35) + .position(x: geometry.size.width / 2, y: geometry.size.height / 2) + + // Layer 3: Outer subtle magic (rose-gold edge for mystical feel) + Circle() + .fill( + RadialGradient( + colors: [ + Color.clear, + Color(red: 1.0, green: 0.6, blue: 0.5).opacity(colorScheme == .dark ? Double(0.02 + morphProgress * 0.03) : 0.01), + Color(red: 0.95, green: 0.5, blue: 0.4).opacity(colorScheme == .dark ? 0.015 : 0.008), + Color.clear + ], + center: .center, + startRadius: 100, + endRadius: 250 + ) + ) + .frame(width: 500, height: 500) + .blur(radius: 50) + .position(x: geometry.size.width / 2, y: geometry.size.height / 2) + + + // Particle view - explicitly centered + VoiceAssistantParticleView( + amplitude: amplitude, + morphProgress: morphProgress, + scatterAmount: scatterAmount, + touchPoint: touchPoint, + isDarkMode: colorScheme == .dark + ) + .frame(width: 500, height: 500) + .position(x: geometry.size.width / 2, y: geometry.size.height / 2) + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + // Convert touch to normalized coordinates (-1 to 1) + // Frame is 500x500 centered in geometry + let frameSize: CGFloat = 500 + let frameCenterX = geometry.size.width / 2 + let frameCenterY = geometry.size.height / 2 + + // Position relative to frame center, normalized to -1...1 + let normalizedX = Float((value.location.x - frameCenterX) / (frameSize / 2)) * 0.85 + let normalizedY = Float((value.location.y - frameCenterY) / (frameSize / 2)) * -0.85 // Flip Y, apply scale + + touchPoint = SIMD2(normalizedX, normalizedY) + scatterAmount = 1.0 + } + .onEnded { _ in + // Let it fade naturally + } + ) + + // Controls + VStack { + Spacer() + + Text(isListening ? "Listening..." : "Touch to interact") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.gray) + .padding(.bottom, 16) + + Button(action: { + isListening.toggle() + }) { + ZStack { + if isListening { + Circle() + .fill(Color.orange.opacity(0.25)) + .frame(width: 80, height: 80) + .blur(radius: 12) + } + + Circle() + .fill(Color.white.opacity(isListening ? 0.15 : 0.1)) + .frame(width: 56, height: 56) + .overlay( + Image(systemName: isListening ? "waveform" : "mic.fill") + .font(.system(size: 22)) + .foregroundColor(.white) + ) + } + } + .padding(.bottom, 50) + } + } + } + .onReceive(timer) { _ in + // Morph animation + let targetMorph: Float = isListening ? 1.0 : 0.0 + let morphDiff = targetMorph - morphProgress + morphProgress += morphDiff * 0.04 + morphProgress = max(0, min(1, morphProgress)) + + // Gentle scatter decay + if scatterAmount > 0.001 { + scatterAmount *= 0.92 + } else { + scatterAmount = 0 + } + + // Audio + if isListening { + let base: Float = 0.3 + let wave = sin(Float(Date().timeIntervalSince1970) * 5.0) * 0.2 + let random = Float.random(in: 0...0.2) + amplitude = amplitude * 0.8 + (base + abs(wave) + random) * 0.2 + } else { + amplitude = amplitude * 0.95 + } + } + } +} + +#Preview { + VoiceAssistantMainPreview() +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Voice/VoiceAssistantView.swift b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Voice/VoiceAssistantView.swift new file mode 100644 index 000000000..e2ab6d945 --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Voice/VoiceAssistantView.swift @@ -0,0 +1,558 @@ +import SwiftUI +import RunAnywhere +#if canImport(UIKit) +import UIKit +#endif + +struct VoiceAssistantView: View { + @StateObject private var viewModel = VoiceAgentViewModel() + @State private var showModelInfo = false + @State private var showModelSelection = false + @State private var showSTTModelSelection = false + @State private var showLLMModelSelection = false + @State private var showTTSModelSelection = false + + // Particle animation states + @State private var amplitude: Float = 0.0 + @State private var morphProgress: Float = 0.0 + @State private var scatterAmount: Float = 0.0 + @State private var touchPoint: SIMD2 = .zero + @Environment(\.colorScheme) var colorScheme + + private let animationTimer = Timer.publish(every: 0.016, on: .main, in: .common).autoconnect() + + var body: some View { + Group { + #if os(macOS) + macOSContent + #else + iOSContent + #endif + } + .sheet(isPresented: $showModelSelection) { + modelSelectionSheet + } + .sheet(isPresented: $showSTTModelSelection) { + ModelSelectionSheet(context: .stt) { model in + viewModel.setSTTModel(model) + } + } + .sheet(isPresented: $showLLMModelSelection) { + ModelSelectionSheet(context: .llm) { model in + viewModel.setLLMModel(model) + } + } + .sheet(isPresented: $showTTSModelSelection) { + ModelSelectionSheet(context: .tts) { model in + viewModel.setTTSModel(model) + } + } + .onAppear { + Task { + if !viewModel.isInitialized { + await viewModel.initialize() + } else { + viewModel.refreshComponentStatesFromSDK() + } + } + } + .onDisappear { + viewModel.cleanup() + } + } +} + +#if os(macOS) +// MARK: - macOS Content +extension VoiceAssistantView { + private var macOSContent: some View { + VStack(spacing: 0) { + macOSToolbar + Divider() + if showModelInfo { + modelInfoSection + } + macOSConversationArea + Spacer() + controlArea + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(NSColor.windowBackgroundColor)) + } + + private var macOSToolbar: some View { + HStack { + Button(action: { + showModelSelection = true + }, label: { + Label("Models", systemImage: "cube") + }) + .buttonStyle(.bordered) + .tint(AppColors.primaryAccent) + + Spacer() + + HStack(spacing: AppSpacing.small) { + Circle() + .fill(viewModel.statusColor.swiftUIColor) + .frame(width: 8, height: 8) + Text(viewModel.sessionState.displayName) + .font(AppTypography.caption) + .foregroundColor(AppColors.textSecondary) + } + + Spacer() + + Button(action: { + withAnimation(.spring(response: 0.3)) { + showModelInfo.toggle() + } + }, label: { + Label( + showModelInfo ? "Hide Info" : "Show Info", + systemImage: "info.circle" + ) + }) + .buttonStyle(.bordered) + .tint(AppColors.primaryAccent) + } + .padding(.horizontal) + .padding(.vertical, 8) + .background(Color(NSColor.windowBackgroundColor)) + } + + private var macOSConversationArea: some View { + ScrollViewReader { proxy in + ScrollView { + VStack(alignment: .leading, spacing: 20) { + if !viewModel.currentTranscript.isEmpty { + ConversationBubble( + speaker: "You", + message: viewModel.currentTranscript, + isUser: true + ) + .id("user") + } + + if !viewModel.assistantResponse.isEmpty { + ConversationBubble( + speaker: "Assistant", + message: viewModel.assistantResponse, + isUser: false + ) + .id("assistant") + } + + if viewModel.currentTranscript.isEmpty && viewModel.assistantResponse.isEmpty { + emptyStatePlaceholder(text: "Click the microphone to start") + } + } + .padding(.horizontal, AdaptiveSizing.contentPadding) + .padding(.vertical, 20) + .adaptiveConversationWidth() + } + .onChange(of: viewModel.assistantResponse) { _ in + withAnimation { + proxy.scrollTo("assistant", anchor: .bottom) + } + } + } + } +} +#endif + +#if os(iOS) +// MARK: - iOS Content +extension VoiceAssistantView { + private var iOSContent: some View { + ZStack { + if !viewModel.allModelsLoaded { + setupView + } else { + mainVoiceUI + } + } + } + + private var setupView: some View { + VoicePipelineSetupView( + sttModel: Binding( + get: { viewModel.sttModel }, + set: { viewModel.sttModel = $0 } + ), + llmModel: Binding( + get: { viewModel.llmModel }, + set: { viewModel.llmModel = $0 } + ), + ttsModel: Binding( + get: { viewModel.ttsModel }, + set: { viewModel.ttsModel = $0 } + ), + sttLoadState: viewModel.sttModelState, + llmLoadState: viewModel.llmModelState, + ttsLoadState: viewModel.ttsModelState, + onSelectSTT: { showSTTModelSelection = true }, + onSelectLLM: { showLLMModelSelection = true }, + onSelectTTS: { showTTSModelSelection = true }, + onStartVoice: { + // All models loaded, nothing to do here + } + ) + } + + private var mainVoiceUI: some View { + ZStack { + // Background particles animation - centered + GeometryReader { geometry in + VoiceAssistantParticleView( + amplitude: amplitude, + morphProgress: morphProgress, + scatterAmount: scatterAmount, + touchPoint: touchPoint, + isDarkMode: colorScheme == .dark + ) + .frame(width: min(geometry.size.width, geometry.size.height) * 0.9) + .frame(width: min(geometry.size.width, geometry.size.height) * 0.9) + .position(x: geometry.size.width / 2, y: geometry.size.height / 2 - 50) + .allowsHitTesting(false) + } + + // Main UI overlay + VStack(spacing: 0) { + iOSHeader + if showModelInfo { + modelInfoSection + } + iOSConversationArea + Spacer() + iOSControlArea + } + } + .background(Color(.systemBackground)) + .onReceive(animationTimer) { _ in + updateAnimation() + } + } + + private var iOSHeader: some View { + HStack { + Button(action: { + showModelSelection = true + }, label: { + Image(systemName: "cube") + .font(.system(size: 18)) + .foregroundColor(.secondary) + .padding(10) + .background(Color(.tertiarySystemBackground)) + .clipShape(Circle()) + }) + + Spacer() + + Button(action: { + withAnimation(.spring(response: 0.3)) { + showModelInfo.toggle() + } + }, label: { + Image(systemName: showModelInfo ? "info.circle.fill" : "info.circle") + .font(.system(size: 18)) + .foregroundColor(.secondary) + .padding(10) + .background(Color(.tertiarySystemBackground)) + .clipShape(Circle()) + }) + } + .padding(.horizontal, 20) + .padding(.top, 20) + .padding(.bottom, 10) + } + + private var iOSConversationArea: some View { + // Conversation area is now hidden - messages shown as toast at bottom + Spacer() + } + + private var iOSControlArea: some View { + VStack(spacing: 20) { + if let error = viewModel.errorMessage { + Text(error) + .font(.caption) + .foregroundColor(AppColors.statusRed) + .multilineTextAlignment(.center) + .padding(.horizontal, 20) + } + + // Scrollable markdown response - streaming real-time + if !viewModel.assistantResponse.isEmpty { + ScrollView { + ScrollViewReader { proxy in + VStack { + AdaptiveMarkdownText( + viewModel.assistantResponse, + font: .body, + color: .primary + ) + .multilineTextAlignment(.center) + .id("responseEnd") + } + .padding(.horizontal, 30) + .onChange(of: viewModel.assistantResponse) { _ in + withAnimation { + proxy.scrollTo("responseEnd", anchor: .bottom) + } + } + } + } + .frame(maxHeight: 150) + .animation(.none, value: viewModel.assistantResponse) + } + + micButtonSection + + Text(viewModel.instructionText) + .font(.caption2) + .foregroundColor(.secondary.opacity(0.7)) + .multilineTextAlignment(.center) + } + .padding(.bottom, 30) + } + + private var audioLevelIndicator: some View { + VStack(spacing: 8) { + HStack(spacing: 6) { + Circle() + .fill(AppColors.statusRed) + .frame(width: 8, height: 8) + Text("RECORDING") + .font(.caption2) + .fontWeight(.bold) + .foregroundColor(AppColors.statusRed) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(AppColors.statusRed.opacity(0.1)) + .cornerRadius(4) + + AdaptiveAudioLevelIndicator(level: viewModel.audioLevel) + } + .padding(.bottom, 8) + .animation(.easeInOut(duration: 0.2), value: viewModel.audioLevel) + } +} +#endif + +// MARK: - Shared Components + +extension VoiceAssistantView { + private var modelInfoSection: some View { + VStack(spacing: 8) { + HStack(spacing: 15) { + ModelBadge( + icon: "brain", + label: "LLM", + value: viewModel.currentLLMModel, + color: AppColors.primaryAccent + ) + ModelBadge( + icon: "waveform", + label: "STT", + value: viewModel.currentSTTModel, + color: AppColors.statusGreen + ) + ModelBadge( + icon: "speaker.wave.2", + label: "TTS", + value: viewModel.currentTTSModel, + color: AppColors.primaryPurple + ) + } + .padding(.horizontal, 20) + } + .padding(.bottom, 15) + .transition(.opacity.combined(with: .move(edge: .top))) + } + + private func emptyStatePlaceholder(text: String) -> some View { + VStack(spacing: 12) { + Image(systemName: "mic.circle") + .font(.system(size: 48)) + .foregroundColor(.secondary.opacity(0.3)) + Text(text) + .font(.subheadline) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.top, 100) + } + + private var controlArea: some View { + VStack(spacing: 20) { + if let error = viewModel.errorMessage { + Text(error) + .font(.caption) + .foregroundColor(AppColors.statusRed) + .multilineTextAlignment(.center) + .padding(.horizontal, 20) + } + + micButtonSection + + Text(viewModel.instructionText) + .font(.caption2) + .foregroundColor(.secondary.opacity(0.7)) + .multilineTextAlignment(.center) + } + .padding(.bottom, 30) + } + + private var micButtonSection: some View { + let isLoading = viewModel.sessionState == .connecting + || (viewModel.isProcessing && !viewModel.isListening) + + return HStack { + Spacer() + + AdaptiveMicButton( + isActive: viewModel.isListening, + isPulsing: viewModel.isSpeechDetected, + isLoading: isLoading, + activeColor: viewModel.micButtonColor.swiftUIColor, + inactiveColor: viewModel.micButtonColor.swiftUIColor, + icon: viewModel.micButtonIcon + ) { + Task { + if viewModel.isActive { + await viewModel.stopConversation() + } else { + await viewModel.startConversation() + } + } + } + + Spacer() + } + } +} + +// MARK: - Model Selection Sheet + +extension VoiceAssistantView { + private var modelSelectionSheet: some View { + NavigationView { + VoicePipelineSetupView( + sttModel: Binding( + get: { viewModel.sttModel }, + set: { viewModel.sttModel = $0 } + ), + llmModel: Binding( + get: { viewModel.llmModel }, + set: { viewModel.llmModel = $0 } + ), + ttsModel: Binding( + get: { viewModel.ttsModel }, + set: { viewModel.ttsModel = $0 } + ), + sttLoadState: viewModel.sttModelState, + llmLoadState: viewModel.llmModelState, + ttsLoadState: viewModel.ttsModelState, + onSelectSTT: { + showModelSelection = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + showSTTModelSelection = true + } + }, + onSelectLLM: { + showModelSelection = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + showLLMModelSelection = true + } + }, + onSelectTTS: { + showModelSelection = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + showTTSModelSelection = true + } + }, + onStartVoice: { + showModelSelection = false + } + ) + .navigationTitle("Voice Models") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { + showModelSelection = false + } + } + } + #else + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { + showModelSelection = false + } + } + } + #endif + } + .navigationViewStyle(.stack) + } + + // MARK: - Animation Helpers + private func updateAnimation() { + // Target morph: 0 = sphere (idle/thinking), 1 = ring (listening/speaking) + let isListening = viewModel.sessionState == .listening + let isSpeaking = viewModel.sessionState == .speaking + let isActive = isListening || isSpeaking + let targetMorph: Float = isActive ? 1.0 : 0.0 + + // Smooth morph transition + let morphDiff = targetMorph - morphProgress + morphProgress += morphDiff * 0.04 + morphProgress = max(0, min(1, morphProgress)) + + // Scatter decay + if scatterAmount > 0.001 { + scatterAmount *= 0.92 + } else { + scatterAmount = 0 + } + + // Audio amplitude - reactive to both input (listening) and output (speaking) + if isListening { + // Use real audio level from microphone + let realAudioLevel = viewModel.audioLevel + // Smooth interpolation for natural movement + amplitude = amplitude * 0.7 + realAudioLevel * 0.3 + // Clamp to reasonable range + amplitude = max(0.0, min(1.0, amplitude)) + } else if isSpeaking { + // TTS output - realistic speech-like pulse simulation + let time = Float(Date().timeIntervalSinceReferenceDate) + + // Multiple frequency components for natural speech rhythm + let basePulse: Float = 0.35 + let primaryWave = sin(time * 3.5) * 0.2 // Main speech rhythm + let secondaryWave = sin(time * 7.0) * 0.1 // Phoneme-like variation + let randomNoise = Float.random(in: -0.05...0.15) // Natural variation + + let targetAmplitude = basePulse + abs(primaryWave) + abs(secondaryWave) * 0.5 + randomNoise + + // Smooth interpolation to avoid jarring changes + amplitude = amplitude * 0.75 + targetAmplitude * 0.25 + amplitude = max(0.0, min(1.0, amplitude)) + } else { + // Gentle decay when not active + amplitude = amplitude * 0.95 + } + } +} + +// MARK: - Preview +struct VoiceAssistantView_Previews: PreviewProvider { + static var previews: some View { + VoiceAssistantView() + } +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Helpers/AdaptiveLayout.swift b/examples/ios/RunAnywhereAI/RunAnywhereAI/Helpers/AdaptiveLayout.swift new file mode 100644 index 000000000..3aca7af5b --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Helpers/AdaptiveLayout.swift @@ -0,0 +1,669 @@ +// +// AdaptiveLayout.swift +// RunAnywhereAI +// +// Cross-platform adaptive layout helpers for iOS, iPadOS, and macOS +// + +import SwiftUI + +// MARK: - Platform Detection + +/// Enum representing the current device form factor +enum DeviceFormFactor { + case phone + case tablet + case desktop + + static var current: DeviceFormFactor { + #if os(macOS) + return .desktop + #else + if UIDevice.current.userInterfaceIdiom == .pad { + return .tablet + } + return .phone + #endif + } +} + +// MARK: - Adaptive Sizing + +/// Provides adaptive sizes that scale appropriately for different platforms +struct AdaptiveSizing { + /// Microphone/main action button size + static var micButtonSize: CGFloat { + switch DeviceFormFactor.current { + case .phone: return 72 + case .tablet: return 80 + case .desktop: return 88 + } + } + + /// Icon size inside the mic button + static var micIconSize: CGFloat { + switch DeviceFormFactor.current { + case .phone: return 28 + case .tablet: return 32 + case .desktop: return 36 + } + } + + /// Secondary action button size + static var actionButtonSize: CGFloat { + switch DeviceFormFactor.current { + case .phone: return 44 + case .tablet: return 50 + case .desktop: return 56 + } + } + + /// Maximum content width for readable text + static var maxContentWidth: CGFloat { + switch DeviceFormFactor.current { + case .phone: return .infinity + case .tablet: return 700 + case .desktop: return 800 + } + } + + /// Conversation area max width + static var conversationMaxWidth: CGFloat { + switch DeviceFormFactor.current { + case .phone: return .infinity + case .tablet: return 800 + case .desktop: return 900 + } + } + + /// Horizontal padding for main content + static var contentPadding: CGFloat { + switch DeviceFormFactor.current { + case .phone: return 16 + case .tablet: return 24 + case .desktop: return 32 + } + } + + /// Toolbar button minimum hit target + static var toolbarButtonSize: CGFloat { + switch DeviceFormFactor.current { + case .phone: return 44 + case .tablet: return 44 + case .desktop: return 36 + } + } + + /// Audio level bar width + static var audioBarWidth: CGFloat { + switch DeviceFormFactor.current { + case .phone: return 25 + case .tablet: return 30 + case .desktop: return 35 + } + } + + /// Audio level bar height + static var audioBarHeight: CGFloat { + switch DeviceFormFactor.current { + case .phone: return 8 + case .tablet: return 10 + case .desktop: return 12 + } + } + + /// Modal/sheet minimum width + static var sheetMinWidth: CGFloat { + switch DeviceFormFactor.current { + case .phone: return 320 + case .tablet: return 500 + case .desktop: return 550 + } + } + + /// Modal/sheet ideal width + static var sheetIdealWidth: CGFloat { + switch DeviceFormFactor.current { + case .phone: return 375 + case .tablet: return 600 + case .desktop: return 700 + } + } + + /// Modal/sheet max width + static var sheetMaxWidth: CGFloat { + switch DeviceFormFactor.current { + case .phone: return 428 + case .tablet: return 700 + case .desktop: return 850 + } + } + + /// Modal/sheet minimum height + static var sheetMinHeight: CGFloat { + switch DeviceFormFactor.current { + case .phone: return 400 + case .tablet: return 500 + case .desktop: return 550 + } + } + + /// Modal/sheet ideal height + static var sheetIdealHeight: CGFloat { + switch DeviceFormFactor.current { + case .phone: return 600 + case .tablet: return 650 + case .desktop: return 700 + } + } + + /// Modal/sheet max height + static var sheetMaxHeight: CGFloat { + switch DeviceFormFactor.current { + case .phone: return 800 + case .tablet: return 800 + case .desktop: return 850 + } + } + + /// Model badge font size + static var badgeFontSize: CGFloat { + switch DeviceFormFactor.current { + case .phone: return 9 + case .tablet: return 10 + case .desktop: return 11 + } + } + + /// Badge horizontal padding + static var badgePaddingH: CGFloat { + switch DeviceFormFactor.current { + case .phone: return 8 + case .tablet: return 10 + case .desktop: return 12 + } + } + + /// Badge vertical padding + static var badgePaddingV: CGFloat { + switch DeviceFormFactor.current { + case .phone: return 4 + case .tablet: return 5 + case .desktop: return 6 + } + } +} + +// MARK: - Adaptive Modal/Sheet Wrapper +struct AdaptiveSheet: ViewModifier { + @Binding var isPresented: Bool + let sheetContent: () -> SheetContent + + func body(content: Content) -> some View { + #if os(macOS) + content + .sheet(isPresented: $isPresented) { + self.sheetContent() + .frame( + minWidth: AdaptiveSizing.sheetMinWidth, + idealWidth: AdaptiveSizing.sheetIdealWidth, + maxWidth: AdaptiveSizing.sheetMaxWidth, + minHeight: AdaptiveSizing.sheetMinHeight, + idealHeight: AdaptiveSizing.sheetIdealHeight, + maxHeight: AdaptiveSizing.sheetMaxHeight + ) + } + #else + content + .sheet(isPresented: $isPresented) { + self.sheetContent() + } + #endif + } +} + +// MARK: - Adaptive Form Style +struct AdaptiveFormStyle: ViewModifier { + func body(content: Content) -> some View { + #if os(macOS) + content + .formStyle(.grouped) + .scrollContentBackground(.visible) + #else + content + .formStyle(.automatic) + #endif + } +} + +// MARK: - Adaptive Navigation +struct AdaptiveNavigation: View { + let title: String + let content: () -> Content + + var body: some View { + #if os(macOS) + VStack(spacing: 0) { + // Custom title bar for macOS + HStack { + Text(title) + .font(.headline) + Spacer() + } + .padding() + .background(Color(NSColor.windowBackgroundColor)) + + Divider() + + content() + } + #else + NavigationView { + content() + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + } + #endif + } +} + +// MARK: - Adaptive Button Style +struct AdaptiveButtonStyle: ButtonStyle { + let isPrimary: Bool + + func makeBody(configuration: Configuration) -> some View { + #if os(macOS) + if isPrimary { + configuration.label + .buttonStyle(.borderedProminent) + .tint(AppColors.primaryAccent) + .controlSize(.regular) + } else { + configuration.label + .buttonStyle(.bordered) + .tint(AppColors.primaryAccent) + .controlSize(.regular) + } + #else + configuration.label + .padding(.horizontal, isPrimary ? 16 : 12) + .padding(.vertical, isPrimary ? 12 : 8) + .background(isPrimary ? AppColors.primaryAccent : Color.secondary.opacity(0.2)) + .foregroundColor(isPrimary ? .white : .primary) + .cornerRadius(8) + .opacity(configuration.isPressed ? 0.8 : 1.0) + #endif + } +} + +// MARK: - View Extensions +extension View { + func adaptiveSheet( + isPresented: Binding, + @ViewBuilder content: @escaping () -> Content + ) -> some View { + modifier(AdaptiveSheet(isPresented: isPresented, sheetContent: content)) + } + + func adaptiveFormStyle() -> some View { + modifier(AdaptiveFormStyle()) + } + + func adaptiveButtonStyle(isPrimary: Bool = false) -> some View { + buttonStyle(AdaptiveButtonStyle(isPrimary: isPrimary)) + } + + func adaptiveFrame() -> some View { + #if os(macOS) + self.frame( + minWidth: 400, + idealWidth: 600, + maxWidth: 900, + minHeight: 300, + idealHeight: 500, + maxHeight: 800 + ) + #else + self + #endif + } + + func adaptiveToolbar( + @ViewBuilder leading: () -> Leading, + @ViewBuilder trailing: () -> Trailing + ) -> some View { + #if os(macOS) + self.toolbar { + ToolbarItem(placement: .cancellationAction) { + leading() + } + ToolbarItem(placement: .confirmationAction) { + trailing() + } + } + #else + self.toolbar { + ToolbarItem(placement: .navigationBarLeading) { + leading() + } + ToolbarItem(placement: .navigationBarTrailing) { + trailing() + } + } + #endif + } +} + +// MARK: - Platform-Specific Colors +extension Color { + static var adaptiveBackground: Color { + #if os(macOS) + Color(NSColor.windowBackgroundColor) + #else + Color(.systemBackground) + #endif + } + + static var adaptiveSecondaryBackground: Color { + #if os(macOS) + Color(NSColor.controlBackgroundColor) + #else + Color(.secondarySystemBackground) + #endif + } + + static var adaptiveTertiaryBackground: Color { + #if os(macOS) + Color(NSColor.textBackgroundColor) + #else + Color(.tertiarySystemBackground) + #endif + } + + static var adaptiveGroupedBackground: Color { + #if os(macOS) + Color(NSColor.controlBackgroundColor) + #else + Color(.systemGroupedBackground) + #endif + } + + static var adaptiveSeparator: Color { + #if os(macOS) + Color(NSColor.separatorColor) + #else + Color(.separator) + #endif + } + + static var adaptiveLabel: Color { + #if os(macOS) + Color(NSColor.labelColor) + #else + Color(.label) + #endif + } + + static var adaptiveSecondaryLabel: Color { + #if os(macOS) + Color(NSColor.secondaryLabelColor) + #else + Color(.secondaryLabel) + #endif + } +} + +// MARK: - Adaptive Text Field +struct AdaptiveTextField: View { + let title: String + @Binding var text: String + var isURL: Bool = false + var isSecure: Bool = false + var isNumeric: Bool = false + + var body: some View { + Group { + if isSecure { + SecureField(title, text: $text) + } else { + TextField(title, text: $text) + #if os(iOS) + .keyboardType(isURL ? .URL : (isNumeric ? .numberPad : .default)) + .autocapitalization(isURL ? .none : .sentences) + #endif + } + } + .textFieldStyle(.roundedBorder) + .autocorrectionDisabled(isURL) + } +} + +// MARK: - Adaptive Mic Button + +/// A reusable microphone/action button that scales appropriately for all platforms +struct AdaptiveMicButton: View { + let isActive: Bool + let isPulsing: Bool + let isLoading: Bool + let activeColor: Color + let inactiveColor: Color + let icon: String + let action: () -> Void + + init( + isActive: Bool = false, + isPulsing: Bool = false, + isLoading: Bool = false, + activeColor: Color = .red, + inactiveColor: Color = AppColors.primaryAccent, + icon: String = "mic.fill", + action: @escaping () -> Void + ) { + self.isActive = isActive + self.isPulsing = isPulsing + self.isLoading = isLoading + self.activeColor = activeColor + self.inactiveColor = inactiveColor + self.icon = icon + self.action = action + } + + var body: some View { + Group { + if #available(iOS 26.0, *) { + Button(action: action) { + ZStack { + // Background circle + Circle() + .fill(isActive ? activeColor : inactiveColor) + .frame(width: AdaptiveSizing.micButtonSize, height: AdaptiveSizing.micButtonSize) + + // Pulsing effect when active + if isPulsing { + Circle() + .stroke(Color.white.opacity(0.4), lineWidth: 2) + .frame(width: AdaptiveSizing.micButtonSize, height: AdaptiveSizing.micButtonSize) + .scaleEffect(1.3) + .opacity(0) + .animation( + .easeOut(duration: 1.0).repeatForever(autoreverses: false), + value: isPulsing + ) + } + + // Icon or loading indicator + if isLoading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .scaleEffect(1.2) + } else { + Image(systemName: icon) + .font(.system(size: AdaptiveSizing.micIconSize)) + .foregroundColor(.white) + .contentTransition(.symbolEffect(.replace)) + .animation(.smooth(duration: 0.3), value: icon) + } + } + } + .buttonStyle(.plain) + .glassEffect(.regular.interactive()) + } else { + Button(action: action) { + ZStack { + // Background circle + Circle() + .fill(isActive ? activeColor : inactiveColor) + .frame(width: AdaptiveSizing.micButtonSize, height: AdaptiveSizing.micButtonSize) + + // Pulsing effect when active + if isPulsing { + Circle() + .stroke(Color.white.opacity(0.4), lineWidth: 2) + .frame(width: AdaptiveSizing.micButtonSize, height: AdaptiveSizing.micButtonSize) + .scaleEffect(1.3) + .opacity(0) + .animation( + .easeOut(duration: 1.0).repeatForever(autoreverses: false), + value: isPulsing + ) + } + + // Icon or loading indicator + if isLoading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .scaleEffect(1.2) + } else { + Image(systemName: icon) + .font(.system(size: AdaptiveSizing.micIconSize)) + .foregroundColor(.white) + .contentTransition(.symbolEffect(.replace)) + .animation(.smooth(duration: 0.3), value: icon) + } + } + } + .buttonStyle(.plain) + } + } + } +} + +// MARK: - Adaptive Audio Level Indicator + +/// Audio level visualization that scales for different platforms +struct AdaptiveAudioLevelIndicator: View { + let level: Float + let barCount: Int + + init(level: Float, barCount: Int = 10) { + self.level = level + self.barCount = barCount + } + + var body: some View { + HStack(spacing: 4) { + ForEach(0.. some View { + #if os(macOS) + content + .frame( + minWidth: minWidth ?? AdaptiveSizing.sheetMinWidth, + idealWidth: idealWidth ?? AdaptiveSizing.sheetIdealWidth, + maxWidth: maxWidth ?? AdaptiveSizing.sheetMaxWidth, + minHeight: minHeight ?? AdaptiveSizing.sheetMinHeight, + idealHeight: idealHeight ?? AdaptiveSizing.sheetIdealHeight, + maxHeight: maxHeight ?? AdaptiveSizing.sheetMaxHeight + ) + #else + content + #endif + } +} + +// MARK: - Additional View Extensions + +extension View { + /// Applies adaptive sheet frame constraints (macOS only) + func adaptiveSheetFrame( + minWidth: CGFloat? = nil, + idealWidth: CGFloat? = nil, + maxWidth: CGFloat? = nil, + minHeight: CGFloat? = nil, + idealHeight: CGFloat? = nil, + maxHeight: CGFloat? = nil + ) -> some View { + modifier(AdaptiveSheetFrameModifier( + minWidth: minWidth, + idealWidth: idealWidth, + maxWidth: maxWidth, + minHeight: minHeight, + idealHeight: idealHeight, + maxHeight: maxHeight + )) + } + + /// Constrains the view to a maximum readable width, centered + func adaptiveContentWidth(_ maxWidth: CGFloat? = nil) -> some View { + frame(maxWidth: maxWidth ?? AdaptiveSizing.maxContentWidth) + } + + /// Applies padding appropriate for the current platform + func adaptiveContentPadding() -> some View { + padding(.horizontal, AdaptiveSizing.contentPadding) + } + + /// Constrains to conversation area width + func adaptiveConversationWidth() -> some View { + frame(maxWidth: AdaptiveSizing.conversationMaxWidth, alignment: .leading) + } +} + +// MARK: - Adaptive Model Badge + +/// A model info badge that scales appropriately for different platforms +struct AdaptiveModelBadge: View { + let icon: String + let label: String + let value: String + let color: Color + + var body: some View { + HStack(spacing: 4) { + Image(systemName: icon) + .font(.system(size: AdaptiveSizing.badgeFontSize)) + .foregroundColor(color) + VStack(alignment: .leading, spacing: 0) { + Text(label) + .font(.system(size: AdaptiveSizing.badgeFontSize - 1)) + .foregroundColor(.secondary) + Text(value) + .font(.system(size: AdaptiveSizing.badgeFontSize)) + .fontWeight(.medium) + .lineLimit(1) + } + } + .padding(.horizontal, AdaptiveSizing.badgePaddingH) + .padding(.vertical, AdaptiveSizing.badgePaddingV) + .background(color.opacity(0.1)) + .cornerRadius(6) + } +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Helpers/CodeBlockMarkdownRenderer.swift b/examples/ios/RunAnywhereAI/RunAnywhereAI/Helpers/CodeBlockMarkdownRenderer.swift new file mode 100644 index 000000000..2302a97ac --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Helpers/CodeBlockMarkdownRenderer.swift @@ -0,0 +1,370 @@ +// + +// CodeBlockMarkdownRenderer.swift + +// RunAnywhereAI + +// + +// Specialized renderer for markdown with code blocks (```language) + +// Extracts code blocks → Renders in styled containers with copy button + +// Delegates text portions to InlineMarkdownRenderer + +// + + +import SwiftUI + + +/// Code block markdown renderer + +/// Parses ```language blocks and renders them with syntax highlighting UI + +/// Text between code blocks is rendered using InlineMarkdownRenderer + +struct RichMarkdownText: View { + let content: String + + let baseFont: Font + + let textColor: Color + + + init(_ content: String, font: Font = .body, color: Color = .primary) { + self.content = content + + self.baseFont = font + + self.textColor = color + } + + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + ForEach(Array(parseContent().enumerated()), id: \.offset) { _, block in + switch block { + case .text(let text): + + MarkdownText(text, font: baseFont, color: textColor) + + case let .codeBlock(code, language): + + CodeBlockView(code: code, language: language) + } + } + } + } + + + /// Parse content into text and code blocks + + private func parseContent() -> [ContentBlock] { + var blocks: [ContentBlock] = [] + + var currentText = "" + + var inCodeBlock = false + + var currentCode = "" + + var currentLanguage: String? + + + let lines = content.components(separatedBy: .newlines) + + + for line in lines { + // Detect code block markers - must be at start of line (trimmed) + + let trimmedLine = line.trimmingCharacters(in: .whitespaces) + + + if trimmedLine.hasPrefix("```") { + if inCodeBlock { + // End of code block - only if it's exactly ``` (or ```language on same line as start) + + if !currentCode.isEmpty { + blocks.append(.codeBlock(currentCode, currentLanguage)) + + currentCode = "" + + currentLanguage = nil + } + + inCodeBlock = false + } else { + // Start of code block + + if !currentText.isEmpty { + blocks.append(.text(currentText)) + + currentText = "" + } + + // Extract language if specified (everything after ```) + + let langPart = trimmedLine.dropFirst(3).trimmingCharacters(in: .whitespaces) + + currentLanguage = langPart.isEmpty ? nil : langPart + + inCodeBlock = true + } + } else { + if inCodeBlock { + currentCode += line + "\n" + } else { + currentText += line + "\n" + } + } + } + + + // Add remaining content (handle unclosed blocks gracefully) + + if !currentText.isEmpty { + blocks.append(.text(currentText)) + } + + if !currentCode.isEmpty { + // Trim only trailing newlines, preserve code formatting + + let trimmedCode = currentCode.trimmingCharacters(in: .newlines) + + blocks.append(.codeBlock(trimmedCode, currentLanguage)) + } + + + return blocks + } +} + + +// MARK: - Content Block Types + + +enum ContentBlock { + case text(String) + + case codeBlock(String, String?) // code, language + +} + + +// MARK: - Code Block View + + +struct CodeBlockView: View { + let code: String + + let language: String? + + @State private var isCopied = false + + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Header with language and copy button + + HStack { + // Language badge + + if let lang = language { + Text(lang.uppercased()) + + .font(.system(.caption2, design: .monospaced)) + + .fontWeight(.semibold) + + .foregroundColor(AppColors.textWhite) + + .padding(.horizontal, 8) + + .padding(.vertical, 4) + + .background( + RoundedRectangle(cornerRadius: 4) + + .fill(syntaxColor(for: lang)) + ) + } + + + Spacer() + + + // Copy button + + Button(action: copyToClipboard) { + HStack(spacing: 4) { + Image(systemName: isCopied ? "checkmark" : "doc.on.doc") + + .font(.caption) + + Text(isCopied ? "Copied!" : "Copy") + + .font(.caption) + } + + .foregroundColor(isCopied ? AppColors.statusGreen : AppColors.textSecondary) + } + + .buttonStyle(.plain) + } + + .padding(.horizontal, 12) + + .padding(.vertical, 8) + + .background(AppColors.backgroundGray6.opacity(0.5)) + + + // Code content with syntax highlighting + + ScrollView(.horizontal, showsIndicators: false) { + Text(highlightedCode) + + .font(.system(.body, design: .monospaced)) + + .padding(12) + + .textSelection(.enabled) + } + + .background(Color(red: 0.97, green: 0.97, blue: 0.98)) + } + + .cornerRadius(8) + + .overlay( + RoundedRectangle(cornerRadius: 8) + + .strokeBorder(AppColors.borderMedium, lineWidth: 1) + ) + } + + + /// Simple syntax highlighting using AttributedString + + private var highlightedCode: AttributedString { + // For now, just use monospace font with basic coloring + + // Advanced syntax highlighting can be added later with a dedicated library + + var result = AttributedString(code) + + + // Apply monospace font and code color + + result.font = .system(.body, design: .monospaced) + + result.foregroundColor = Color(red: 0.2, green: 0.2, blue: 0.3) + + + return result + } + + + /// Get color for language badge + + private func syntaxColor(for language: String) -> Color { + switch language.lowercased() { + case "swift": return Color(red: 0.95, green: 0.38, blue: 0.21) + + case "python", "py": return Color(red: 0.25, green: 0.53, blue: 0.76) + + case "javascript", "js", "typescript", "ts": return Color(red: 0.94, green: 0.76, blue: 0.16) + + case "kotlin", "kt": return Color(red: 0.49, green: 0.40, blue: 0.93) + + case "java": return Color(red: 0.87, green: 0.27, blue: 0.22) + + default: return AppColors.primaryPurple + } + } + + + /// Copy code to clipboard + + private func copyToClipboard() { + #if os(iOS) + + UIPasteboard.general.string = code + + #elseif os(macOS) + + NSPasteboard.general.clearContents() + + NSPasteboard.general.setString(code, forType: .string) + + #endif + + + withAnimation { + isCopied = true + } + + + // Reset after 2 seconds + + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + withAnimation { + isCopied = false + } + } + } +} + + +// MARK: - Preview + +#if DEBUG + +struct RichMarkdownText_Previews: PreviewProvider { + static var previews: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + RichMarkdownText(""" + + Here's a simple Swift program: + + + + ```swift + + import Foundation + + + + func sumTwoIntegers(_ a: Int, _ b: Int) -> Int { + + return a + b // Return sum + + } + + + + let num1 = 5 + + let num2 = 10 + + let result = sumTwoIntegers(num1, num2) + + print("The sum is \\(result)") + + ``` + + + + This program defines a function **sumTwoIntegers** that takes two parameters. + + """) + + .padding() + } + } + } +} + +#endif diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Helpers/InlineMarkdownRenderer.swift b/examples/ios/RunAnywhereAI/RunAnywhereAI/Helpers/InlineMarkdownRenderer.swift new file mode 100644 index 000000000..520a87efb --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Helpers/InlineMarkdownRenderer.swift @@ -0,0 +1,237 @@ +// + +// InlineMarkdownRenderer.swift + +// RunAnywhereAI + +// + +// Renders inline markdown: **bold**, *italic*, `code`, headings, lists + +// Uses AttributedString for native iOS markdown parsing + +// Pre-processes to fix markdown conflicts (list bullets + bold text) + +// + + +import SwiftUI + + +/// Inline markdown renderer for text with basic formatting + +/// Handles: bold, italic, inline code, headings (as bold), hierarchical lists + +struct MarkdownText: View { + let content: String + + let baseFont: Font + + let textColor: Color + + + init(_ content: String, font: Font = .body, color: Color = .primary) { + self.content = content + + self.baseFont = font + + self.textColor = color + } + + + var body: some View { + Text(attributedString) + + .textSelection(.enabled) // Allow text selection + + } + + + /// Convert markdown string to AttributedString with custom styling + + private var attributedString: AttributedString { + do { + // Pre-process to fix list markers conflicting with bold syntax + + // Replace "* **text**" with "• **text**" to avoid markdown conflicts + + let processedContent = preprocessListMarkers(content) + + + // Parse with inlineOnly to preserve text structure + + // This prevents numbers and periods from being interpreted as lists + + var attributedString = try AttributedString( + markdown: processedContent, options: AttributedString.MarkdownParsingOptions( + interpretedSyntax: .inlineOnlyPreservingWhitespace, failurePolicy: .returnPartiallyParsedIfPossible + ) + ) + + + // Apply custom styling to the entire string + + attributedString.foregroundColor = textColor + + + // Enhanced styling for specific markdown elements + + for run in attributedString.runs { + var container = AttributeContainer() + + + // Inline presentation intent (bold, italic, code) + + if let inlineIntent = run.inlinePresentationIntent { + // Bold text (**text**) - make it semibold + + if inlineIntent.contains(.stronglyEmphasized) { + container.font = fontToUIFont(baseFont.weight(.semibold)) + } + + + // Italic text (*text*) - make it italic + + if inlineIntent.contains(.emphasized) { + container.font = fontToUIFont(baseFont.italic()) + } + + + // Code (`code`) - monospaced font with background + + if inlineIntent.contains(.code) { + container.font = .system(.body, design: .monospaced) + + container.foregroundColor = .purple + + container.backgroundColor = Color.purple.opacity(0.1) + } + } + + + // Apply base font if no specific styling was set + + if container.font == nil { + container.font = fontToUIFont(baseFont) + } + + + // Apply custom styling + + // Note: AttributedString's markdown parser already handles bold+italic merging correctly + + attributedString[run.range].mergeAttributes(container) + } + + + return attributedString + } catch { + // Fallback to plain text if markdown parsing fails + + var fallback = AttributedString(content) + + fallback.foregroundColor = textColor + + fallback.font = fontToUIFont(baseFont) + + return fallback + } + } + + + /// Preprocess list markers and headings to avoid conflicts with bold syntax + + private func preprocessListMarkers(_ text: String) -> String { + let lines = text.components(separatedBy: .newlines) + + let processedLines = lines.map { line -> String in + // 1. Remove heading symbols (###) and make text bold + + if let range = line.range(of: "^\\s*#{1,6}\\s+", options: .regularExpression) { + let leadingSpaces = line.prefix { $0 == " " } + + let headingText = line[range.upperBound...] + + + // Make heading text bold + + return String(leadingSpaces) + "**\(headingText)**" + } + + + // 2. Replace markdown list markers (* or -) with hierarchical bullet points + + // This prevents conflicts when list items contain bold text: "* **Bold**" + + if let range = line.range(of: "^\\s*[*-]\\s+", options: .regularExpression) { + let leadingSpaces = String(line.prefix { $0 == " " }) + + let restOfLine = line[range.upperBound...] + + + // Determine bullet style based on indentation level + + // Support both 2-space and 4-space indents (common markdown standards) + + let spaceCount = leadingSpaces.count + + let indentLevel: Int + + if spaceCount == 0 { + indentLevel = 0 + } else if spaceCount <= 3 { + indentLevel = 1 // 1-3 spaces = level 1 + + } else if spaceCount <= 6 { + indentLevel = 2 // 4-6 spaces = level 2 + + } else { + indentLevel = 3 // 7+ spaces = level 3+ + + } + + + let bullet: String + + switch indentLevel { + case 0: + + bullet = "•" // Main level: filled bullet (U+2022) + + case 1: + + bullet = "◦" // Second level: hollow bullet (U+25E6) + + case 2: + + bullet = "‣" // Third level: triangular bullet (U+2023) - cleaner than squares + + default: + + bullet = "·" // Deeper levels: middle dot (U+00B7) - subtle + + } + + + // Replace with appropriate bullet point + + return leadingSpaces + bullet + " " + restOfLine + } + + + return line + } + + + return processedLines.joined(separator: "\n") + } + + + /// Convert SwiftUI Font to UIKit/AppKit font + + private func fontToUIFont(_ font: Font) -> Font { + // SwiftUI Font is already the right type for AttributedString + + font + } +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Helpers/SmartMarkdownRenderer.swift b/examples/ios/RunAnywhereAI/RunAnywhereAI/Helpers/SmartMarkdownRenderer.swift new file mode 100644 index 000000000..e42305642 --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Helpers/SmartMarkdownRenderer.swift @@ -0,0 +1,99 @@ +// +// SmartMarkdownRenderer.swift +// RunAnywhereAI +// +// Intelligent router that analyzes content and selects optimal renderer +// Automatically chooses between inline, code block, or plain text rendering +// + +import SwiftUI + +/// Smart markdown renderer router +/// Analyzes content complexity → Routes to appropriate renderer +/// No configuration needed - just pass the content! +struct AdaptiveMarkdownText: View { + let content: String + let baseFont: Font + let textColor: Color + + init(_ content: String, font: Font = .body, color: Color = .primary) { + self.content = content + self.baseFont = font + self.textColor = color + } + + var body: some View { + let strategy = MarkdownDetector.shared.detectRenderingStrategy(from: content) + renderContent(with: strategy) + } + + /// Render content based on detected strategy + @ViewBuilder + private func renderContent(with strategy: RenderingStrategy) -> some View { + switch strategy { + case .rich: + // Full markdown with code blocks + RichMarkdownText(content, font: baseFont, color: textColor) + + case .basic, .light: + // Standard markdown (bold, italic, headings, inline code) + MarkdownText(content, font: baseFont, color: textColor) + + case .plain: + // Plain text - no markdown processing + Text(content) + .font(baseFont) + .foregroundColor(textColor) + .textSelection(.enabled) + } + } +} + +// MARK: - Preview +#if DEBUG +struct AdaptiveMarkdownText_Previews: PreviewProvider { + static var previews: some View { + ScrollView { + VStack(spacing: 20) { + // Rich markdown example (code blocks) + VStack(alignment: .leading) { + Text("Rich Markdown (Code Blocks)") + .font(.headline) + + AdaptiveMarkdownText(""" + #### Stock Market + **Definition:** A marketplace + ```swift + let x = 5 + ``` + """) + } + + Divider() + + // Basic markdown example + VStack(alignment: .leading) { + Text("Basic Markdown") + .font(.headline) + + AdaptiveMarkdownText(""" + **Stock Market:** A place to trade stocks. + It shows *economic health*. + """) + } + + Divider() + + // Plain text example + VStack(alignment: .leading) { + Text("Plain Text") + .font(.headline) + + AdaptiveMarkdownText("This is plain text without any formatting.") + } + } + .padding() + } + } +} +#endif diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Resources/RunAnywhereConfig-Debug.plist b/examples/ios/RunAnywhereAI/RunAnywhereAI/Resources/RunAnywhereConfig-Debug.plist new file mode 100644 index 000000000..be250861e --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Resources/RunAnywhereConfig-Debug.plist @@ -0,0 +1,42 @@ + + + + + environment + debug + + logging + + enableConsoleLogging + + enableFileLogging + + enableRemoteLogging + + minimumLogLevel + debug + enableSensitiveDataLogging + + maxLogFileSizeMB + 50 + logRetentionDays + 7 + + + api + + baseURL + https://api-dev.runanywhere.ai + timeoutSeconds + 30 + enableRequestLogging + + + + enablePerformanceMonitoring + + + enableCrashReporting + + + diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Resources/RunAnywhereConfig-Release.plist b/examples/ios/RunAnywhereAI/RunAnywhereAI/Resources/RunAnywhereConfig-Release.plist new file mode 100644 index 000000000..e76b8ad95 --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Resources/RunAnywhereConfig-Release.plist @@ -0,0 +1,42 @@ + + + + + environment + production + + logging + + enableConsoleLogging + + enableFileLogging + + enableRemoteLogging + + minimumLogLevel + warning + enableSensitiveDataLogging + + maxLogFileSizeMB + 10 + logRetentionDays + 90 + + + api + + baseURL + https://api.runanywhere.ai + timeoutSeconds + 15 + enableRequestLogging + + + + enablePerformanceMonitoring + + + enableCrashReporting + + + diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/RunAnywhereAI-Bridging-Header.h b/examples/ios/RunAnywhereAI/RunAnywhereAI/RunAnywhereAI-Bridging-Header.h new file mode 100644 index 000000000..9c34fe85b --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/RunAnywhereAI-Bridging-Header.h @@ -0,0 +1,15 @@ +// +// RunAnywhereAI-Bridging-Header.h +// RunAnywhereAI +// +// Bridging header for C/C++ libraries +// + +#ifndef RunAnywhereAI_Bridging_Header_h +#define RunAnywhereAI_Bridging_Header_h + +// llama.cpp headers +// Note: You'll need to add llama.cpp as a dependency and configure the header search paths +// #import "llama.h" + +#endif /* RunAnywhereAI_Bridging_Header_h */ diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/RunAnywhereAI.entitlements b/examples/ios/RunAnywhereAI/RunAnywhereAI/RunAnywhereAI.entitlements new file mode 100644 index 000000000..cfbb1a7ba --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/RunAnywhereAI.entitlements @@ -0,0 +1,19 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-only + + + com.apple.security.network.client + + + com.apple.security.device.microphone + + + com.apple.security.files.user-selected.read-write + + + diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Utilities/ModelLogoHelper.swift b/examples/ios/RunAnywhereAI/RunAnywhereAI/Utilities/ModelLogoHelper.swift new file mode 100644 index 000000000..db16c4aa0 --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Utilities/ModelLogoHelper.swift @@ -0,0 +1,41 @@ +// +// ModelLogoHelper.swift +// RunAnywhereAI +// +// Helper function for getting model logo when only model name is available +// + +import Foundation + +/// Returns the asset name for a model logo based on model name +/// Falls back to Hugging Face logo if no specific logo is available +/// - Parameter modelName: The name of the model +/// - Returns: Asset name for the model logo +func getModelLogo(for modelName: String) -> String { + let name = modelName.lowercased() + + // Check for system/platform models + if name.contains("system") || name.contains("platform") || name.contains("foundation") { + return "foundation_models_logo" + } + + // Check for vendor-specific logos + if name.contains("llama") { + return "llama_logo" + } else if name.contains("mistral") { + return "mistral_logo" + } else if name.contains("qwen") { + return "qwen_logo" + } else if name.contains("liquid") { + return "liquid_ai_logo" + } else if name.contains("piper") { + return "hugging_face_logo" + } else if name.contains("whisper") { + return "hugging_face_logo" + } else if name.contains("sherpa") { + return "hugging_face_logo" + } + + // Default fallback for all other models + return "hugging_face_logo" +} diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAIUITests/RunAnywhereAIUITestsLaunchTests.swift b/examples/ios/RunAnywhereAI/RunAnywhereAIUITests/RunAnywhereAIUITestsLaunchTests.swift new file mode 100644 index 000000000..b8b5b604e --- /dev/null +++ b/examples/ios/RunAnywhereAI/RunAnywhereAIUITests/RunAnywhereAIUITestsLaunchTests.swift @@ -0,0 +1,32 @@ +// +// RunAnywhereAIUITestsLaunchTests.swift +// RunAnywhereAIUITests +// +// Created by Sanchit Monga on 7/21/25. +// + +import XCTest + +final class RunAnywhereAIUITestsLaunchTests: XCTestCase { + override static var runsForEachTargetApplicationUIConfiguration: Bool { + true + } + + override func setUpWithError() throws { + continueAfterFailure = false + } + + @MainActor + func testLaunch() throws { + let app = XCUIApplication() + app.launch() + + // Insert steps here to perform after app launch but before taking a screenshot, + // such as logging into a test account or navigating somewhere in the app + + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "Launch Screen" + attachment.lifetime = .keepAlways + add(attachment) + } +} diff --git a/examples/ios/RunAnywhereAI/docs/RELEASE_INSTRUCTIONS.md b/examples/ios/RunAnywhereAI/docs/RELEASE_INSTRUCTIONS.md new file mode 100644 index 000000000..5f599a11e --- /dev/null +++ b/examples/ios/RunAnywhereAI/docs/RELEASE_INSTRUCTIONS.md @@ -0,0 +1,298 @@ +# RunAnywhereAI iOS App - Release Instructions + +This document outlines the steps required to release a new version of the RunAnywhereAI iOS app to the App Store. + +## Pre-Release Checklist + +### 1. Update Production Credentials + +Before releasing, ensure the production API key and base URL are configured in the app: + +**File:** `RunAnywhereAI/App/RunAnywhereAIApp.swift` + +In the `#else` block (production mode), update: +```swift +let apiKey = "" +let baseURL = "" +``` + +The app uses compile-time flags: +- `#if DEBUG` - Development mode (no API key needed, uses Supabase) +- `#else` - Production mode (requires API key and backend URL) + +### 2. Verify iOS Deployment Target + +**CRITICAL:** The app's deployment target MUST match the SDK requirements. + +| Component | Required Value | +|-----------|----------------| +| App Deployment Target | **iOS 17.0** | +| SDK Requirement | iOS 17.0 (defined in `Package.swift`) | +| Framework MinimumOSVersion | 17.0 | + +**File:** `RunAnywhereAI.xcodeproj/project.pbxproj` + +Verify all `IPHONEOS_DEPLOYMENT_TARGET` entries are set to `17.0`: +``` +IPHONEOS_DEPLOYMENT_TARGET = 17.0; +``` + +**Warning:** Do NOT set the deployment target higher than what the SDK supports. Setting it to iOS 18.x when frameworks were built for iOS 17 will cause validation errors. + +### 3. Bump Version Number + +Update the marketing version in Xcode project settings. The version must be higher than any previously submitted version. + +**File:** `RunAnywhereAI.xcodeproj/project.pbxproj` + +Search for `MARKETING_VERSION` and update all occurrences: +``` +MARKETING_VERSION = X.Y.Z; +``` + +Alternatively, update via Xcode: +1. Select the project in Navigator +2. Select the RunAnywhereAI target +3. Go to "General" tab +4. Update "Version" field + +### 4. Framework Info.plist Requirements + +Apple requires all embedded frameworks to have specific keys in their Info.plist files with **matching values** to the app's deployment target. + +#### Required Keys: +- `CFBundleVersion` - Build version string +- `MinimumOSVersion` - Must be set to **17.0** (matching SDK requirement) + +#### Frameworks to Check: + +**RunAnywhere SDK Frameworks** (in `sdks/sdk/runanywhere-swift/Binaries/`): +- `RACommons.xcframework/ios-arm64/RACommons.framework/Info.plist` +- `RACommons.xcframework/ios-arm64_x86_64-simulator/RACommons.framework/Info.plist` +- `RABackendLLAMACPP.xcframework/ios-arm64/RABackendLLAMACPP.framework/Info.plist` +- `RABackendLLAMACPP.xcframework/ios-arm64_x86_64-simulator/RABackendLLAMACPP.framework/Info.plist` +- `RABackendONNX.xcframework/ios-arm64/RABackendONNX.framework/Info.plist` +- `RABackendONNX.xcframework/ios-arm64_x86_64-simulator/RABackendONNX.framework/Info.plist` + +**ONNX Runtime Framework** (third-party binary): +- `sdks/sdk/runanywhere-commons/third_party/onnxruntime-ios/onnxruntime.xcframework/ios-arm64/onnxruntime.framework/Info.plist` +- `sdks/sdk/runanywhere-commons/third_party/onnxruntime-ios/onnxruntime.xcframework/ios-arm64_x86_64-simulator/onnxruntime.framework/Info.plist` + +### 5. Automated Fix: patch-framework-plist.sh + +We've created a script to automatically fix framework Info.plist files in DerivedData. + +**Location:** `scripts/patch-framework-plist.sh` + +**Usage:** +```bash +# Run from the RunAnywhereAI app directory +./scripts/patch-framework-plist.sh +``` + +**What it does:** +- Searches all DerivedData directories for framework Info.plist files +- Patches `onnxruntime.framework`, `RACommons.framework`, `RABackendLLAMACPP.framework`, `RABackendONNX.framework` +- Adds or updates `MinimumOSVersion=17.0` +- Reports which files were patched vs. already correct + +**When to run:** +- After cleaning DerivedData (`rm -rf ~/Library/Developer/Xcode/DerivedData/RunAnywhereAI-*`) +- After resetting SPM packages (File > Packages > Reset Package Caches) +- Before archiving if you've done a clean build + +### 6. Manual Fix (Alternative) + +If you prefer to fix manually using PlistBuddy: + +```bash +# Add or update MinimumOSVersion for all frameworks in DerivedData +for framework in onnxruntime RACommons RABackendLLAMACPP RABackendONNX; do + for plist in $(find ~/Library/Developer/Xcode/DerivedData -path "*${framework}.framework/Info.plist" -type f 2>/dev/null); do + /usr/libexec/PlistBuddy -c "Set :MinimumOSVersion 17.0" "$plist" 2>/dev/null || \ + /usr/libexec/PlistBuddy -c "Add :MinimumOSVersion string 17.0" "$plist" 2>/dev/null + echo "Patched: $plist" + done +done +``` + +## Build & Archive + +### 1. Build First (Required) + +Build the project to populate DerivedData: +``` +Cmd+B (or Product > Build) +``` + +### 2. Run Patch Script + +```bash +cd sdks/examples/ios/RunAnywhereAI +./scripts/patch-framework-plist.sh +``` + +### 3. Archive (Without Cleaning!) + +**Important:** Do NOT clean after running the patch script. + +1. Select "RunAnywhereAI" scheme +2. Set destination to "Any iOS Device (arm64)" +3. Product > Archive +4. Wait for archive to complete +5. Organizer window will open automatically + +## Upload to App Store Connect + +### 1. Validate Archive + +In Organizer: +1. Select the new archive +2. Click "Validate App" +3. Follow the prompts +4. Fix any validation errors before proceeding + +### 2. Distribute App + +1. Click "Distribute App" +2. Select "App Store Connect" +3. Select "Upload" +4. Follow the prompts +5. Wait for upload to complete + +### 3. App Store Connect + +1. Go to [App Store Connect](https://appstoreconnect.apple.com) +2. Select "RunAnywhereAI" app +3. Create new version or select existing +4. Add the uploaded build +5. Fill in release notes +6. Submit for review + +## Troubleshooting + +### Error: "Invalid Bundle - does not support the minimum OS Version" + +**Error Message:** +``` +Invalid Bundle. The bundle RunAnywhereAI.app/Frameworks/onnxruntime.framework does not support the minimum OS Version specified in the Info.plist. +``` + +**Cause:** Mismatch between the app's deployment target and the framework's MinimumOSVersion. This happens when: +- App deployment target is set higher than what frameworks support (e.g., iOS 18.5 when frameworks were built for iOS 17) +- Framework MinimumOSVersion doesn't match the SDK requirements + +**Solution:** +1. Verify app deployment target is `17.0` (not higher!) +2. Run the patch script: `./scripts/patch-framework-plist.sh` +3. Re-archive **without cleaning** + +### Error: "Invalid MinimumOSVersion - is ''" + +**Error Message:** +``` +Invalid MinimumOSVersion. Apps that only support 64-bit devices must specify a deployment target of 8.0 or later. MinimumOSVersion in 'RunAnywhereAI.app/Frameworks/onnxruntime.framework' is '' +``` + +**Cause:** The ONNX Runtime binary downloaded via SPM from Microsoft's servers (`download.onnxruntime.ai`) doesn't include the `MinimumOSVersion` key in its Info.plist. + +**Solution:** +1. Run the patch script: `./scripts/patch-framework-plist.sh` +2. Re-archive **without cleaning** + +### dSYM Upload Warnings + +**Warning:** +``` +Upload Symbols Failed - The archive did not include a dSYM for the framework +``` + +**Cause:** Third-party frameworks (Sentry, onnxruntime) don't include dSYM files in their binary distributions. + +**Solution:** These warnings can be safely ignored. The frameworks either: +- Have their own symbol upload mechanisms (Sentry) +- Don't provide dSYMs in their binary distributions (onnxruntime) + +### Build Fails After SPM Clean + +If build fails after cleaning SPM cache: + +1. Resolve packages: File > Packages > Resolve Package Versions +2. Build the project once (Cmd+B) +3. Run the patch script: `./scripts/patch-framework-plist.sh` +4. Archive the app (Product > Archive) + +### Code Signing Issues + +Ensure your Apple Developer account has: + +- Valid distribution certificate +- App Store provisioning profile for `com.runanywhere.RunAnywhere` + +## Quick Release Checklist + +``` +[ ] 1. Update production API key and base URL +[ ] 2. Verify deployment target is iOS 17.0 (NOT higher!) +[ ] 3. Bump MARKETING_VERSION +[ ] 4. Build project (Cmd+B) +[ ] 5. Run patch script: ./scripts/patch-framework-plist.sh +[ ] 6. Archive (Product > Archive) - DO NOT CLEAN! +[ ] 7. Validate in Organizer +[ ] 8. Upload to App Store Connect +[ ] 9. Submit for review +``` + +## Environment Configuration + +The app automatically selects the environment based on build configuration: + +| Build | Environment | API Key Required | +|-------|-------------|------------------| +| Debug | Development | No | +| Release | Production | Yes | + +### Verifying Production Mode + +To verify the app is in production mode: +1. Archive the app (Release build) +2. Check logs for: `SDK initialized in PRODUCTION mode` + +## Version History + +| Version | Date | Notes | +|---------|------------|----------------------------------------------------------| +| 0.17.2 | 2025-01-24 | Fixed deployment target to iOS 17.0, updated MinimumOSVersion to 17.0 for all frameworks | +| 0.17.1 | 2025-01-24 | Added patch script for ONNX Runtime MinimumOSVersion fix | +| 0.17.0 | 2025-01-24 | Production release with backend integration | +| 0.16.0 | - | Previous release | + +## Technical Details + +### Why iOS 17.0? + +The RunAnywhere SDK requires iOS 17.0 minimum (defined in `Package.swift`): +```swift +platforms: [ + .iOS(.v17), + .macOS(.v14), + ... +] +``` + +All framework `MinimumOSVersion` values and the app's `IPHONEOS_DEPLOYMENT_TARGET` must be consistent with this requirement. + +### Framework Version Alignment + +| Framework | MinimumOSVersion | +|-----------|------------------| +| App (RunAnywhereAI) | 17.0 | +| RACommons | 17.0 | +| RABackendLLAMACPP | 17.0 | +| RABackendONNX | 17.0 | +| onnxruntime | 17.0 | + +## Contacts + +- **App Store Connect Team ID:** L86FH3K93L +- **Bundle Identifier:** com.runanywhere.RunAnywhere diff --git a/examples/ios/RunAnywhereAI/docs/screenshots/chat-interface.png b/examples/ios/RunAnywhereAI/docs/screenshots/chat-interface.png new file mode 100644 index 000000000..b47d3e253 Binary files /dev/null and b/examples/ios/RunAnywhereAI/docs/screenshots/chat-interface.png differ diff --git a/examples/ios/RunAnywhereAI/docs/screenshots/quiz-flow.png b/examples/ios/RunAnywhereAI/docs/screenshots/quiz-flow.png new file mode 100644 index 000000000..10f9720e6 Binary files /dev/null and b/examples/ios/RunAnywhereAI/docs/screenshots/quiz-flow.png differ diff --git a/examples/ios/RunAnywhereAI/docs/screenshots/voice-ai.png b/examples/ios/RunAnywhereAI/docs/screenshots/voice-ai.png new file mode 100644 index 000000000..d310149ad Binary files /dev/null and b/examples/ios/RunAnywhereAI/docs/screenshots/voice-ai.png differ diff --git a/examples/ios/RunAnywhereAI/scripts/build_and_run_ios_sample.sh b/examples/ios/RunAnywhereAI/scripts/build_and_run_ios_sample.sh new file mode 100755 index 000000000..f6b5ecd6a --- /dev/null +++ b/examples/ios/RunAnywhereAI/scripts/build_and_run_ios_sample.sh @@ -0,0 +1,334 @@ +#!/bin/bash +# ============================================================================= +# RunAnywhereAI - Build & Run iOS Sample App +# ============================================================================= +# +# Builds and runs the RunAnywhereAI sample app. +# +# PROJECT STRUCTURE: +# ───────────────────────────────────────────────────────────────────────────── +# runanywhere-commons/ Unified C++ library with backends +# scripts/build-all-ios.sh Build everything for iOS +# +# runanywhere-swift/ Swift SDK wrapper +# scripts/build-swift.sh Build Swift SDK +# ───────────────────────────────────────────────────────────────────────────── +# +# USAGE: +# ./build_and_run_ios_sample.sh [target] [options] +# +# TARGETS: +# simulator "Device Name" Build and run on iOS Simulator +# device Build and run on connected iOS device +# mac Build and run on macOS +# +# BUILD OPTIONS: +# --build-commons Build runanywhere-commons (all frameworks) +# --build-sdk Build runanywhere-swift (Swift SDK) +# --build-all Build everything (commons + sdk) +# --skip-app Only build SDK components, skip Xcode app build +# --local Use local builds +# --clean Clean build artifacts +# --help Show this help message +# +# EXAMPLES: +# ./build_and_run_ios_sample.sh device # Run app +# ./build_and_run_ios_sample.sh device --build-all --local # Full local build +# ./build_and_run_ios_sample.sh simulator --build-commons # Rebuild commons +# ./build_and_run_ios_sample.sh --build-all --skip-app # Build SDK only +# +# ============================================================================= + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WORKSPACE_ROOT="$(cd "$SCRIPT_DIR/../../../../" && pwd)" + +# Project directories +COMMONS_DIR="$WORKSPACE_ROOT/sdk/runanywhere-commons" +SWIFT_SDK_DIR="$WORKSPACE_ROOT/sdk/runanywhere-swift" +APP_DIR="$SCRIPT_DIR/.." + +# Build scripts +COMMONS_BUILD_SCRIPT="$COMMONS_DIR/scripts/build-ios.sh" +SWIFT_BUILD_SCRIPT="$SWIFT_SDK_DIR/scripts/build-swift.sh" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +BOLD='\033[1m' +NC='\033[0m' + +log_info() { echo -e "${GREEN}[✓]${NC} $1"; } +log_warn() { echo -e "${YELLOW}[!]${NC} $1"; } +log_error() { echo -e "${RED}[✗]${NC} $1"; } +log_step() { echo -e "${BLUE}==>${NC} $1"; } +log_time() { echo -e "${CYAN}[⏱]${NC} $1"; } +log_header() { + echo -e "\n${GREEN}═══════════════════════════════════════════${NC}" + echo -e "${GREEN} $1${NC}" + echo -e "${GREEN}═══════════════════════════════════════════${NC}" +} + +show_help() { + head -40 "$0" | tail -35 + exit 0 +} + +# Timing +TOTAL_START_TIME=0 +TIME_COMMONS=0 +TIME_SWIFT=0 +TIME_APP=0 +TIME_DEPLOY=0 + +format_duration() { + local seconds=$1 + if (( seconds >= 60 )); then + echo "$((seconds / 60))m $((seconds % 60))s" + else + echo "${seconds}s" + fi +} + +# ============================================================================= +# Parse Arguments +# ============================================================================= + +TARGET="device" +DEVICE_NAME="" +BUILD_COMMONS=false +BUILD_SDK=false +SKIP_APP=false +CLEAN_BUILD=false +LOCAL_MODE=false + +[[ "$1" == "--help" || "$1" == "-h" ]] && show_help + +for arg in "$@"; do + case "$arg" in + simulator|device|mac) + TARGET="$arg" + ;; + --build-all) + BUILD_COMMONS=true + BUILD_SDK=true + ;; + --build-commons) + BUILD_COMMONS=true + BUILD_SDK=true + ;; + --build-sdk) + BUILD_SDK=true + ;; + --skip-app) + SKIP_APP=true + ;; + --local) + LOCAL_MODE=true + ;; + --clean) + CLEAN_BUILD=true + ;; + --*) + ;; + *) + [[ "$arg" != "simulator" && "$arg" != "device" && "$arg" != "mac" ]] && DEVICE_NAME="$arg" + ;; + esac +done + +# ============================================================================= +# Build Functions +# ============================================================================= + +build_commons() { + log_header "Building runanywhere-commons" + local start_time=$(date +%s) + + if [[ ! -x "$COMMONS_BUILD_SCRIPT" ]]; then + log_error "Commons build script not found: $COMMONS_BUILD_SCRIPT" + exit 1 + fi + + local FLAGS="" + [[ "$CLEAN_BUILD" == true ]] && FLAGS="$FLAGS --clean" + + log_step "Running: build-all-ios.sh $FLAGS" + "$COMMONS_BUILD_SCRIPT" $FLAGS + + TIME_COMMONS=$(($(date +%s) - start_time)) + log_time "Commons build time: $(format_duration $TIME_COMMONS)" +} + +build_swift_sdk() { + log_header "Building runanywhere-swift" + local start_time=$(date +%s) + + if [[ ! -x "$SWIFT_BUILD_SCRIPT" ]]; then + log_error "Swift build script not found: $SWIFT_BUILD_SCRIPT" + exit 1 + fi + + local FLAGS="" + $LOCAL_MODE && FLAGS="$FLAGS --local" + $CLEAN_BUILD && FLAGS="$FLAGS --clean" + + log_step "Running: build-swift.sh $FLAGS" + "$SWIFT_BUILD_SCRIPT" $FLAGS + + TIME_SWIFT=$(($(date +%s) - start_time)) + log_time "Swift SDK build time: $(format_duration $TIME_SWIFT)" +} + +# ============================================================================= +# App Build & Deploy +# ============================================================================= + +build_app() { + log_header "Building RunAnywhereAI App" + local start_time=$(date +%s) + + cd "$APP_DIR" + + local DESTINATION + case "$TARGET" in + simulator) + DESTINATION="platform=iOS Simulator,name=${DEVICE_NAME:-iPhone 16}" + ;; + mac) + DESTINATION="platform=macOS" + ;; + device|*) + local DEVICE_ID=$(xcodebuild -project RunAnywhereAI.xcodeproj -scheme RunAnywhereAI -showdestinations 2>/dev/null | grep "platform:iOS" | grep -v "Simulator" | head -1 | sed -n 's/.*id:\([^,]*\).*/\1/p') + [[ -z "$DEVICE_ID" ]] && { log_error "No connected iOS device found"; exit 1; } + DESTINATION="platform=iOS,id=$DEVICE_ID" + ;; + esac + + log_step "Building for: $DESTINATION" + + $CLEAN_BUILD && xcodebuild clean -project RunAnywhereAI.xcodeproj -scheme RunAnywhereAI -configuration Debug >/dev/null 2>&1 || true + + if xcodebuild -project RunAnywhereAI.xcodeproj -scheme RunAnywhereAI -configuration Debug -destination "$DESTINATION" -allowProvisioningUpdates build > /tmp/xcodebuild.log 2>&1; then + TIME_APP=$(($(date +%s) - start_time)) + log_info "App build succeeded" + log_time "App build time: $(format_duration $TIME_APP)" + else + log_error "App build failed! Check /tmp/xcodebuild.log" + tail -30 /tmp/xcodebuild.log + exit 1 + fi +} + +deploy_and_run() { + log_header "Deploying to $TARGET" + local start_time=$(date +%s) + + cd "$APP_DIR" + + local APP_PATH + case "$TARGET" in + simulator) + APP_PATH=$(find ~/Library/Developer/Xcode/DerivedData -name "RunAnywhereAI.app" -path "*Debug-iphonesimulator*" -not -path "*/Index.noindex/*" 2>/dev/null | head -1) + ;; + mac) + APP_PATH=$(find ~/Library/Developer/Xcode/DerivedData -name "RunAnywhereAI.app" -path "*/Debug/*" -not -path "*-iphone*" -not -path "*/Index.noindex/*" 2>/dev/null | head -1) + ;; + device|*) + APP_PATH=$(find ~/Library/Developer/Xcode/DerivedData -name "RunAnywhereAI.app" -path "*Debug-iphoneos*" -not -path "*/Index.noindex/*" 2>/dev/null | head -1) + ;; + esac + + [[ ! -d "$APP_PATH" ]] && { log_error "Could not find built app"; exit 1; } + + log_info "Found app: $APP_PATH" + + case "$TARGET" in + simulator) + local SIM_ID=$(xcrun simctl list devices | grep "${DEVICE_NAME:-iPhone}" | grep -v "unavailable" | head -1 | sed 's/.*(\([^)]*\)).*/\1/') + xcrun simctl boot "$SIM_ID" 2>/dev/null || true + xcrun simctl install "$SIM_ID" "$APP_PATH" + xcrun simctl launch "$SIM_ID" "com.runanywhere.RunAnywhere" + open -a Simulator + log_info "App launched on simulator" + ;; + mac) + open "$APP_PATH" + log_info "App launched on macOS" + ;; + device|*) + local DEVICE_ID=$(xcrun devicectl list devices 2>/dev/null | grep "connected" | grep -oE '[A-F0-9]{8}-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{12}' | head -1) + [[ -z "$DEVICE_ID" ]] && { log_error "No connected iOS device found"; exit 1; } + log_step "Installing on device: $DEVICE_ID" + xcrun devicectl device install app --device "$DEVICE_ID" "$APP_PATH" + xcrun devicectl device process launch --device "$DEVICE_ID" "com.runanywhere.RunAnywhere" || log_warn "Launch failed - unlock device and tap the app icon." + log_info "App installed on device" + ;; + esac + + TIME_DEPLOY=$(($(date +%s) - start_time)) + log_time "Deploy time: $(format_duration $TIME_DEPLOY)" +} + +# ============================================================================= +# Summary +# ============================================================================= + +print_summary() { + local total_time=$(($(date +%s) - TOTAL_START_TIME)) + + echo "" + echo -e "${BOLD}${GREEN}═══════════════════════════════════════════${NC}" + echo -e "${BOLD}${GREEN} BUILD TIME SUMMARY ${NC}" + echo -e "${BOLD}${GREEN}═══════════════════════════════════════════${NC}" + echo "" + + (( TIME_COMMONS > 0 )) && printf " %-25s %s\n" "runanywhere-commons:" "$(format_duration $TIME_COMMONS)" + (( TIME_SWIFT > 0 )) && printf " %-25s %s\n" "runanywhere-swift:" "$(format_duration $TIME_SWIFT)" + (( TIME_APP > 0 )) && printf " %-25s %s\n" "iOS App:" "$(format_duration $TIME_APP)" + (( TIME_DEPLOY > 0 )) && printf " %-25s %s\n" "Deploy:" "$(format_duration $TIME_DEPLOY)" + + echo " ─────────────────────────────────────────" + printf " ${BOLD}%-25s %s${NC}\n" "TOTAL:" "$(format_duration $total_time)" + echo "" +} + +# ============================================================================= +# Main +# ============================================================================= + +main() { + TOTAL_START_TIME=$(date +%s) + + log_header "RunAnywhereAI Build Pipeline" + echo "Target: $TARGET" + echo "Build: commons=$BUILD_COMMONS sdk=$BUILD_SDK" + echo "Local Mode: $LOCAL_MODE" + echo "Skip App: $SKIP_APP" + echo "" + + # Build commons if requested (and in local mode or explicitly requested) + if $BUILD_COMMONS && $LOCAL_MODE; then + build_commons + fi + + # Build Swift SDK + $BUILD_SDK && build_swift_sdk + + # Build and deploy app + if ! $SKIP_APP; then + build_app + deploy_and_run + else + log_info "App build skipped (--skip-app)" + fi + + print_summary + log_header "Done!" +} + +main "$@" diff --git a/examples/ios/RunAnywhereAI/scripts/patch-framework-plist.sh b/examples/ios/RunAnywhereAI/scripts/patch-framework-plist.sh new file mode 100755 index 000000000..16da0b363 --- /dev/null +++ b/examples/ios/RunAnywhereAI/scripts/patch-framework-plist.sh @@ -0,0 +1,129 @@ +#!/bin/bash +# ============================================================================= +# patch-framework-plist.sh +# ============================================================================= +# Patches framework Info.plist files to ensure MinimumOSVersion is set to 17.0. +# This fixes App Store validation errors for frameworks that either: +# - Don't include the required MinimumOSVersion key +# - Have a mismatched MinimumOSVersion value +# +# Usage: +# ./scripts/patch-framework-plist.sh +# +# This script patches the following frameworks in DerivedData: +# - onnxruntime.framework (Microsoft ONNX Runtime - downloaded via SPM) +# - RACommons.framework (RunAnywhere SDK) +# - RABackendLLAMACPP.framework (RunAnywhere SDK) +# - RABackendONNX.framework (RunAnywhere SDK) +# +# When to run: +# - After cleaning DerivedData +# - After resetting SPM packages +# - Before archiving if you've done a clean build +# ============================================================================= + +set -e + +MIN_OS_VERSION="17.0" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +echo "========================================" +echo "Framework Info.plist Patcher" +echo "========================================" +echo "Target MinimumOSVersion: $MIN_OS_VERSION" +echo "" + +# Function to patch MinimumOSVersion in a plist +patch_plist() { + local plist_path="$1" + + if [ ! -f "$plist_path" ]; then + return 1 + fi + + # Check current MinimumOSVersion value + local current_version + current_version=$(/usr/libexec/PlistBuddy -c "Print :MinimumOSVersion" "$plist_path" 2>/dev/null || echo "") + + if [ "$current_version" = "$MIN_OS_VERSION" ]; then + echo -e "${GREEN}[OK]${NC} Already set to $MIN_OS_VERSION: $plist_path" + return 0 + elif [ -n "$current_version" ]; then + # Update existing value + /usr/libexec/PlistBuddy -c "Set :MinimumOSVersion $MIN_OS_VERSION" "$plist_path" 2>/dev/null + if [ $? -eq 0 ]; then + echo -e "${YELLOW}[UPDATED]${NC} $current_version -> $MIN_OS_VERSION: $plist_path" + else + echo -e "${RED}[ERROR]${NC} Failed to update: $plist_path" + return 1 + fi + else + # Add missing key + /usr/libexec/PlistBuddy -c "Add :MinimumOSVersion string $MIN_OS_VERSION" "$plist_path" 2>/dev/null + if [ $? -eq 0 ]; then + echo -e "${YELLOW}[ADDED]${NC} MinimumOSVersion=$MIN_OS_VERSION: $plist_path" + else + echo -e "${RED}[ERROR]${NC} Failed to add: $plist_path" + return 1 + fi + fi + return 0 +} + +# Find DerivedData directory for this project +DERIVED_DATA_DIR="$HOME/Library/Developer/Xcode/DerivedData" + +if [ ! -d "$DERIVED_DATA_DIR" ]; then + echo -e "${RED}Error: DerivedData directory not found${NC}" + exit 1 +fi + +# List of frameworks to patch +FRAMEWORKS=( + "onnxruntime.framework" + "RACommons.framework" + "RABackendLLAMACPP.framework" + "RABackendONNX.framework" +) + +TOTAL_PATCHED=0 +TOTAL_FOUND=0 + +for framework in "${FRAMEWORKS[@]}"; do + echo -e "${BLUE}Searching for ${framework}...${NC}" + + FRAMEWORK_COUNT=0 + while IFS= read -r plist_path; do + if patch_plist "$plist_path"; then + ((FRAMEWORK_COUNT++)) || true + fi + ((TOTAL_FOUND++)) || true + done < <(find "$DERIVED_DATA_DIR" -path "*/${framework}/Info.plist" -type f 2>/dev/null) + + if [ $FRAMEWORK_COUNT -eq 0 ]; then + echo -e "${YELLOW} No ${framework} found in DerivedData${NC}" + fi + echo "" +done + +echo "========================================" +if [ $TOTAL_FOUND -eq 0 ]; then + echo -e "${YELLOW}No framework Info.plist files found.${NC}" + echo "" + echo "Make sure to:" + echo " 1. Build the project first (Cmd+B)" + echo " 2. Then run this script" + echo " 3. Then archive (Product > Archive)" +else + echo -e "${GREEN}Done! Processed $TOTAL_FOUND plist file(s).${NC}" + echo "" + echo "You can now archive the app without cleaning." + echo " Product > Archive" +fi +echo "========================================" diff --git a/examples/logo.svg b/examples/logo.svg new file mode 100644 index 000000000..c848b9d54 --- /dev/null +++ b/examples/logo.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/examples/react-native/RunAnywhereAI/.gitignore b/examples/react-native/RunAnywhereAI/.gitignore new file mode 100644 index 000000000..f481834b8 --- /dev/null +++ b/examples/react-native/RunAnywhereAI/.gitignore @@ -0,0 +1,65 @@ +# Dependencies +node_modules/ + +# React Native +.expo/ +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision +*.orig.* + +# Metro +.metro-health-check* + +# Bundle artifacts +*.jsbundle +*.hbc + +# iOS +ios/Pods/ +ios/build/ +ios/*.xcworkspace +!ios/*.xcworkspace/ +ios/*.xcodeproj/xcuserdata/ +ios/*.xcodeproj/project.xcworkspace/xcuserdata/ +ios/Podfile.lock + +# Xcode environment (machine-specific Node.js paths) +ios/.xcode.env +ios/.xcode.env.local + +# Android +android/.gradle/ +android/app/build/ +android/build/ +android/local.properties +android/*.iml +android/.idea/ + +# Testing +coverage/ + +# Debug +npm-debug.* +yarn-debug.* +yarn-error.* + +# macOS +.DS_Store +*.pem + +# Temporary files +*.log +*.tmp + +# TypeScript +*.tsbuildinfo + +# Environment +.env +.env.local +.env.development.local +.env.test.local +.env.production.local diff --git a/examples/react-native/RunAnywhereAI/.yarn/install-state.gz b/examples/react-native/RunAnywhereAI/.yarn/install-state.gz new file mode 100644 index 000000000..e204cd28d Binary files /dev/null and b/examples/react-native/RunAnywhereAI/.yarn/install-state.gz differ diff --git a/examples/react-native/RunAnywhereAI/.yarnrc.yml b/examples/react-native/RunAnywhereAI/.yarnrc.yml new file mode 100644 index 000000000..35f85e38b --- /dev/null +++ b/examples/react-native/RunAnywhereAI/.yarnrc.yml @@ -0,0 +1,2 @@ +nodeLinker: node-modules +enableImmutableInstalls: false diff --git a/examples/react-native/RunAnywhereAI/App.tsx b/examples/react-native/RunAnywhereAI/App.tsx new file mode 100644 index 000000000..9da41b7df --- /dev/null +++ b/examples/react-native/RunAnywhereAI/App.tsx @@ -0,0 +1,365 @@ +/** + * RunAnywhere AI Example App + * + * React Native demonstration app for the RunAnywhere on-device AI SDK. + * + * Architecture Pattern: + * - Two-phase SDK initialization (matching iOS pattern) + * - Module registration with models (LlamaCPP, ONNX, FluidAudio) + * - Tab-based navigation with 5 tabs (Chat, STT, TTS, Voice, Settings) + * + * Reference: iOS examples/ios/RunAnywhereAI/RunAnywhereAI/App/RunAnywhereAIApp.swift + */ + +import React, { useCallback, useEffect, useState } from 'react'; +import { + View, + Text, + StyleSheet, + ActivityIndicator, + TouchableOpacity, +} from 'react-native'; +import { NavigationContainer } from '@react-navigation/native'; +import Icon from 'react-native-vector-icons/Ionicons'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; +import TabNavigator from './src/navigation/TabNavigator'; +import { Colors } from './src/theme/colors'; +import { Typography } from './src/theme/typography'; +import { + Spacing, + Padding, + BorderRadius, + IconSize, + ButtonHeight, +} from './src/theme/spacing'; + +// Import RunAnywhere SDK (Multi-Package Architecture) +import { RunAnywhere, SDKEnvironment, ModelCategory } from '@runanywhere/core'; +import { LlamaCPP } from '@runanywhere/llamacpp'; +import { ONNX, ModelArtifactType } from '@runanywhere/onnx'; +import { getStoredApiKey, getStoredBaseURL, hasCustomConfiguration } from './src/screens/SettingsScreen'; + +/** + * App initialization state + */ +type InitState = 'loading' | 'ready' | 'error'; + +/** + * Initialization Loading View + */ +const InitializationLoadingView: React.FC = () => ( + + + + + + RunAnywhere AI + Initializing SDK... + + + +); + +/** + * Initialization Error View + */ +const InitializationErrorView: React.FC<{ + error: string; + onRetry: () => void; +}> = ({ error, onRetry }) => ( + + + + + + Initialization Failed + {error} + + + Retry + + + +); + +/** + * Main App Component + */ +const App: React.FC = () => { + const [initState, setInitState] = useState('loading'); + const [error, setError] = useState(null); + + /** + * Register modules and their models + * Matches iOS registerModulesAndModels() in RunAnywhereAIApp.swift + * + * Note: Model registration is async, so we need to wait for all registrations + * to complete before the UI queries models. + */ + const registerModulesAndModels = async () => { + // LlamaCPP module with LLM models + // Using explicit IDs ensures models are recognized after download across app restarts + LlamaCPP.register(); + await LlamaCPP.addModel({ + id: 'smollm2-360m-q8_0', + name: 'SmolLM2 360M Q8_0', + url: 'https://huggingface.co/prithivMLmods/SmolLM2-360M-GGUF/resolve/main/SmolLM2-360M.Q8_0.gguf', + memoryRequirement: 500_000_000, + }); + await LlamaCPP.addModel({ + id: 'llama-2-7b-chat-q4_k_m', + name: 'Llama 2 7B Chat Q4_K_M', + url: 'https://huggingface.co/TheBloke/Llama-2-7B-Chat-GGUF/resolve/main/llama-2-7b-chat.Q4_K_M.gguf', + memoryRequirement: 4_000_000_000, + }); + await LlamaCPP.addModel({ + id: 'mistral-7b-instruct-q4_k_m', + name: 'Mistral 7B Instruct Q4_K_M', + url: 'https://huggingface.co/TheBloke/Mistral-7B-Instruct-v0.1-GGUF/resolve/main/mistral-7b-instruct-v0.1.Q4_K_M.gguf', + memoryRequirement: 4_000_000_000, + }); + await LlamaCPP.addModel({ + id: 'qwen2.5-0.5b-instruct-q6_k', + name: 'Qwen 2.5 0.5B Instruct Q6_K', + url: 'https://huggingface.co/Triangle104/Qwen2.5-0.5B-Instruct-Q6_K-GGUF/resolve/main/qwen2.5-0.5b-instruct-q6_k.gguf', + memoryRequirement: 600_000_000, + }); + await LlamaCPP.addModel({ + id: 'lfm2-350m-q4_k_m', + name: 'LiquidAI LFM2 350M Q4_K_M', + url: 'https://huggingface.co/LiquidAI/LFM2-350M-GGUF/resolve/main/LFM2-350M-Q4_K_M.gguf', + memoryRequirement: 250_000_000, + }); + await LlamaCPP.addModel({ + id: 'lfm2-350m-q8_0', + name: 'LiquidAI LFM2 350M Q8_0', + url: 'https://huggingface.co/LiquidAI/LFM2-350M-GGUF/resolve/main/LFM2-350M-Q8_0.gguf', + memoryRequirement: 400_000_000, + }); + + // ONNX module with STT and TTS models + // Using tar.gz format hosted on RunanywhereAI/sherpa-onnx for fast native extraction + // Using explicit IDs ensures models are recognized after download across app restarts + ONNX.register(); + // STT Models (Sherpa-ONNX Whisper) + await ONNX.addModel({ + id: 'sherpa-onnx-whisper-tiny.en', + name: 'Sherpa Whisper Tiny (ONNX)', + url: 'https://github.com/RunanywhereAI/sherpa-onnx/releases/download/runanywhere-models-v1/sherpa-onnx-whisper-tiny.en.tar.gz', + modality: ModelCategory.SpeechRecognition, + artifactType: ModelArtifactType.TarGzArchive, + memoryRequirement: 75_000_000, + }); + // NOTE: whisper-small.en not included to match iOS/Android examples + // All ONNX models use tar.gz from RunanywhereAI/sherpa-onnx fork for fast native extraction + // If you need whisper-small, convert to tar.gz and upload to the fork + // TTS Models (Piper VITS) + await ONNX.addModel({ + id: 'vits-piper-en_US-lessac-medium', + name: 'Piper TTS (US English - Medium)', + url: 'https://github.com/RunanywhereAI/sherpa-onnx/releases/download/runanywhere-models-v1/vits-piper-en_US-lessac-medium.tar.gz', + modality: ModelCategory.SpeechSynthesis, + artifactType: ModelArtifactType.TarGzArchive, + memoryRequirement: 65_000_000, + }); + await ONNX.addModel({ + id: 'vits-piper-en_GB-alba-medium', + name: 'Piper TTS (British English)', + url: 'https://github.com/RunanywhereAI/sherpa-onnx/releases/download/runanywhere-models-v1/vits-piper-en_GB-alba-medium.tar.gz', + modality: ModelCategory.SpeechSynthesis, + artifactType: ModelArtifactType.TarGzArchive, + memoryRequirement: 65_000_000, + }); + + console.warn('[App] All models registered'); + }; + + /** + * Initialize the SDK + * Matches iOS initializeSDK() in RunAnywhereAIApp.swift + */ + const initializeSDK = useCallback(async () => { + setInitState('loading'); + setError(null); + + try { + const startTime = Date.now(); + + // Check for custom API configuration (stored via Settings screen) + const customApiKey = await getStoredApiKey(); + const customBaseURL = await getStoredBaseURL(); + const hasCustomConfig = await hasCustomConfiguration(); + + if (hasCustomConfig && customApiKey && customBaseURL) { + console.log('🔧 Found custom API configuration'); + console.log(` Base URL: ${customBaseURL}`); + + // Custom configuration mode - use stored API key and base URL + await RunAnywhere.initialize({ + apiKey: customApiKey, + baseURL: customBaseURL, + environment: SDKEnvironment.Production, + }); + console.log('✅ SDK initialized with CUSTOM configuration (production)'); + } else { + // DEVELOPMENT mode (default) - uses Supabase directly + // Credentials come from runanywhere-commons/development_config.cpp (git-ignored) + // This is the safest option for committing to git + await RunAnywhere.initialize({ + apiKey: '', // Empty in development mode - uses C++ dev config + baseURL: 'https://api.runanywhere.ai', + environment: SDKEnvironment.Development, + }); + console.log('✅ SDK initialized in DEVELOPMENT mode (Supabase via C++ config)'); + } + + // Register modules and models (await to ensure models are ready before UI) + await registerModulesAndModels(); + + const initTime = Date.now() - startTime; + + // Get SDK info for debugging + const isInit = await RunAnywhere.isInitialized(); + const version = await RunAnywhere.getVersion(); + const backendInfo = await RunAnywhere.getBackendInfo(); + + // Log initialization summary + // eslint-disable-next-line no-console + console.log( + `[App] SDK initialized: v${version}, ${isInit ? 'Active' : 'Inactive'}, ${initTime}ms, env: ${JSON.stringify(backendInfo)}` + ); + + setInitState('ready'); + } catch (err) { + console.error('[App] SDK initialization failed:', err); + const errorMessage = + err instanceof Error ? err.message : 'Unknown error occurred'; + setError(errorMessage); + setInitState('error'); + } + }, []); + + useEffect(() => { + initializeSDK(); + }, [initializeSDK]); + + // Render based on state + if (initState === 'loading') { + return ( + + + + ); + } + + if (initState === 'error') { + return ( + + + + ); + } + + return ( + + + + + + ); +}; + +const styles = StyleSheet.create({ + // Loading View + loadingContainer: { + flex: 1, + backgroundColor: Colors.backgroundPrimary, + justifyContent: 'center', + alignItems: 'center', + }, + loadingContent: { + alignItems: 'center', + }, + iconContainer: { + width: IconSize.huge, + height: IconSize.huge, + borderRadius: IconSize.huge / 2, + backgroundColor: Colors.badgeBlue, + justifyContent: 'center', + alignItems: 'center', + marginBottom: Spacing.xLarge, + }, + loadingTitle: { + ...Typography.title, + color: Colors.textPrimary, + marginBottom: Spacing.small, + }, + loadingSubtitle: { + ...Typography.body, + color: Colors.textSecondary, + marginBottom: Spacing.xLarge, + }, + spinner: { + marginTop: Spacing.large, + }, + + // Error View + errorContainer: { + flex: 1, + backgroundColor: Colors.backgroundPrimary, + justifyContent: 'center', + alignItems: 'center', + padding: Padding.padding24, + }, + errorContent: { + alignItems: 'center', + maxWidth: 300, + }, + errorIconContainer: { + width: IconSize.huge, + height: IconSize.huge, + borderRadius: IconSize.huge / 2, + backgroundColor: Colors.badgeRed, + justifyContent: 'center', + alignItems: 'center', + marginBottom: Spacing.xLarge, + }, + errorTitle: { + ...Typography.title2, + color: Colors.textPrimary, + marginBottom: Spacing.medium, + }, + errorMessage: { + ...Typography.body, + color: Colors.textSecondary, + textAlign: 'center', + marginBottom: Spacing.xLarge, + }, + retryButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: Spacing.smallMedium, + backgroundColor: Colors.primaryBlue, + paddingHorizontal: Padding.padding24, + height: ButtonHeight.regular, + borderRadius: BorderRadius.large, + }, + retryButtonText: { + ...Typography.headline, + color: Colors.textWhite, + }, +}); + +export default App; diff --git a/examples/react-native/RunAnywhereAI/Gemfile b/examples/react-native/RunAnywhereAI/Gemfile new file mode 100644 index 000000000..8d72c37a8 --- /dev/null +++ b/examples/react-native/RunAnywhereAI/Gemfile @@ -0,0 +1,9 @@ +source 'https://rubygems.org' + +# You may use http://rbenv.org/ or https://rvm.io/ to install and use this version +ruby ">= 2.6.10" + +# Cocoapods 1.15 introduced a bug which break the build. We will remove the upper +# bound in the template on Cocoapods with next React Native release. +gem 'cocoapods', '>= 1.13', '< 1.15' +gem 'activesupport', '>= 6.1.7.5', '< 7.1.0' diff --git a/examples/react-native/RunAnywhereAI/README.md b/examples/react-native/RunAnywhereAI/README.md new file mode 100644 index 000000000..6609cd7ad --- /dev/null +++ b/examples/react-native/RunAnywhereAI/README.md @@ -0,0 +1,765 @@ +# RunAnywhere AI - React Native Example + +

+ RunAnywhere Logo +

+ +

+ + Download on the App Store + + + Get it on Google Play + +

+ +

+ iOS 15.1+ + Android 7.0+ + React Native 0.81 + TypeScript 5.5 + License +

+ +**A production-ready reference app demonstrating the [RunAnywhere React Native SDK](../../../sdk/runanywhere-react-native/) capabilities for on-device AI.** This cross-platform app showcases how to build privacy-first, offline-capable AI features with LLM chat, speech-to-text, text-to-speech, and a complete voice assistant pipeline—all running locally on your device. + +--- + +## 🚀 Running This App (Local Development) + +> **Important:** This sample app consumes the [RunAnywhere React Native SDK](../../../sdk/runanywhere-react-native/) as local workspace dependencies. Before opening this project, you must first build the SDK's native libraries. + +### First-Time Setup + +```bash +# 1. Navigate to the React Native SDK directory +cd runanywhere-sdks/sdk/runanywhere-react-native + +# 2. Run the setup script (~15-20 minutes on first run) +# This builds the native C++ frameworks/libraries and enables local mode +./scripts/build-react-native.sh --setup + +# 3. Navigate to this sample app +cd ../../examples/react-native/RunAnywhereAI + +# 4. Install dependencies +npm install + +# 5. For iOS: Install pods +cd ios && pod install && cd .. + +# 6a. Run on iOS +npx react-native run-ios + +# 6b. Or run on Android +npx react-native run-android + +# Or open in VS Code / Cursor and run from there +``` + +### How It Works + +This sample app's `package.json` uses workspace dependencies to reference the local React Native SDK packages: + +``` +This Sample App → Local RN SDK packages (sdk/runanywhere-react-native/packages/) + ↓ + Local XCFrameworks/JNI libs (in each package's ios/ and android/ directories) + ↑ + Built by: ./scripts/build-react-native.sh --setup +``` + +The `build-react-native.sh --setup` script: +1. Downloads dependencies (ONNX Runtime, Sherpa-ONNX) +2. Builds the native C++ libraries from `runanywhere-commons` +3. Copies XCFrameworks to `packages/*/ios/Binaries/` and `packages/*/ios/Frameworks/` +4. Copies JNI `.so` files to `packages/*/android/src/main/jniLibs/` +5. Creates `.testlocal` marker files (enables local library consumption) + +### After Modifying the SDK + +- **TypeScript SDK code changes**: Metro bundler picks them up automatically (Fast Refresh) +- **C++ code changes** (in `runanywhere-commons`): + ```bash + cd sdk/runanywhere-react-native + ./scripts/build-react-native.sh --local --rebuild-commons + ``` + +--- + +## Try It Now + +

+ + Download on the App Store + +      + + Get it on Google Play + +

+ +Download the app from the App Store or Google Play Store to try it out. + +--- + +## Screenshots + +

+ RunAnywhere AI Chat Interface +

+ +--- + +## Features + +This sample app demonstrates the full power of the RunAnywhere React Native SDK: + +| Feature | Description | SDK Integration | +|---------|-------------|-----------------| +| **AI Chat** | Interactive LLM conversations with streaming responses | `RunAnywhere.generateStream()` | +| **Conversation Management** | Create, switch, and delete chat conversations | `ConversationStore` | +| **Real-time Analytics** | Token speed, generation time, inference metrics | Message analytics display | +| **Speech-to-Text** | Voice transcription with batch & live modes | `RunAnywhere.transcribeFile()` | +| **Text-to-Speech** | Neural voice synthesis with Piper TTS | `RunAnywhere.synthesize()` | +| **Voice Assistant** | Full STT → LLM → TTS pipeline | Voice pipeline orchestration | +| **Model Management** | Download, load, and manage multiple AI models | `RunAnywhere.downloadModel()` | +| **Storage Management** | View storage usage and delete models | `RunAnywhere.getStorageInfo()` | +| **Offline Support** | All features work without internet | On-device inference | +| **Cross-Platform** | Single codebase for iOS and Android | React Native + Nitrogen/Nitro | + +--- + +## Architecture + +The app follows modern React Native architecture patterns with a multi-package SDK structure: + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ React Native UI Layer │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────────────┐ │ +│ │ Chat │ │ STT │ │ TTS │ │ Voice │ │ Settings │ │ +│ │ Screen │ │ Screen │ │ Screen │ │ Assistant│ │ Screen │ │ +│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ └───────┬────────┘ │ +├───────┼────────────┼────────────┼────────────┼───────────────┼──────────┤ +│ │ │ │ │ │ │ +│ ┌────▼────────────▼────────────▼────────────▼───────────────▼────────┐ │ +│ │ @runanywhere/core (TypeScript API) │ │ +│ │ RunAnywhere.initialize(), loadModel(), generate(), etc. │ │ +│ └──────────────────────────────┬──────────────────────────────────────┘ │ +│ │ │ +│ ┌───────────────────────┼───────────────────────┐ │ +│ │ │ │ │ +│ ┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐ │ +│ │@runanywhere │ │@runanywhere │ │ Native │ │ +│ │ /llamacpp │ │ /onnx │ │ Bridges │ │ +│ │ (LLM/GGUF) │ │ (STT/TTS) │ │ (JSI/Nitro)│ │ +│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ +├─────────┼───────────────────────┼───────────────────────┼───────────────┤ +│ │ │ │ │ +│ ┌──────▼───────────────────────▼───────────────────────▼──────────────┐│ +│ │ runanywhere-commons (C++) ││ +│ │ Core inference engine, model management ││ +│ └──────────────────────────────────────────────────────────────────────┘│ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### Key Architecture Decisions + +- **Multi-Package SDK** — Core API, LlamaCPP, and ONNX as separate packages for modularity +- **TypeScript First** — Full type safety across the entire SDK API surface +- **JSI/Nitro Bridges** — Direct native module communication for performance +- **Zustand State Management** — Lightweight, performant state for conversations +- **Tab-Based Navigation** — React Navigation bottom tabs matching iOS/Android patterns +- **Theme System** — Consistent design tokens across all components + +--- + +## Project Structure + +``` +RunAnywhereAI/ +├── App.tsx # App entry, SDK initialization, model registration +├── index.js # React Native entry point +├── package.json # Dependencies and scripts +├── tsconfig.json # TypeScript configuration +│ +├── src/ +│ ├── screens/ +│ │ ├── ChatScreen.tsx # LLM chat with streaming & conversation management +│ │ ├── ChatAnalyticsScreen.tsx # Message analytics and performance metrics +│ │ ├── ConversationListScreen.tsx # Conversation history management +│ │ ├── STTScreen.tsx # Speech-to-text with batch/live modes +│ │ ├── TTSScreen.tsx # Text-to-speech synthesis & playback +│ │ ├── VoiceAssistantScreen.tsx # Full STT → LLM → TTS pipeline +│ │ └── SettingsScreen.tsx # Model & storage management +│ │ +│ ├── components/ +│ │ ├── chat/ +│ │ │ ├── ChatInput.tsx # Message input with send button +│ │ │ ├── MessageBubble.tsx # Message display with analytics +│ │ │ ├── TypingIndicator.tsx # AI thinking animation +│ │ │ └── index.ts # Component exports +│ │ ├── common/ +│ │ │ ├── ModelStatusBanner.tsx # Shows loaded model and framework +│ │ │ ├── ModelRequiredOverlay.tsx # Prompts model selection +│ │ │ └── index.ts +│ │ └── model/ +│ │ ├── ModelSelectionSheet.tsx # Model picker with download progress +│ │ └── index.ts +│ │ +│ ├── navigation/ +│ │ └── TabNavigator.tsx # Bottom tab navigation (5 tabs) +│ │ +│ ├── stores/ +│ │ └── conversationStore.ts # Zustand store for chat persistence +│ │ +│ ├── theme/ +│ │ ├── colors.ts # Color palette matching iOS design +│ │ ├── typography.ts # Font styles and text variants +│ │ └── spacing.ts # Layout constants and dimensions +│ │ +│ ├── types/ +│ │ ├── chat.ts # Message and conversation types +│ │ ├── model.ts # Model info and framework types +│ │ ├── settings.ts # Settings and storage types +│ │ ├── voice.ts # Voice pipeline types +│ │ └── index.ts # Root navigation types +│ │ +│ └── utils/ +│ └── AudioService.ts # Native audio recording abstraction +│ +├── ios/ +│ ├── RunAnywhereAI/ +│ │ ├── AppDelegate.swift # iOS app delegate +│ │ ├── NativeAudioModule.swift # Native audio recording/playback +│ │ └── Images.xcassets/ # iOS app icons and images +│ ├── Podfile # CocoaPods dependencies +│ └── RunAnywhereAI.xcworkspace/ # Xcode workspace +│ +└── android/ + ├── app/ + │ ├── src/main/ + │ │ ├── java/.../MainActivity.kt + │ │ ├── res/ # Android resources + │ │ └── AndroidManifest.xml + │ └── build.gradle + └── settings.gradle +``` + +--- + +## Quick Start + +### Prerequisites + +- **Node.js** 18+ +- **React Native CLI** or **npx** +- **Xcode** 15+ (iOS development) +- **Android Studio** Hedgehog+ (Android development) +- **CocoaPods** (iOS) +- **~2GB** free storage for AI models + +### Clone & Install + +```bash +# Clone the repository +git clone https://github.com/RunanywhereAI/runanywhere-sdks.git +cd runanywhere-sdks/examples/react-native/RunAnywhereAI + +# Install JavaScript dependencies +npm install + +# Install iOS dependencies +cd ios && pod install && cd .. +``` + +### Run on iOS + +```bash +# Start Metro bundler +npm start + +# In another terminal, run on iOS +npx react-native run-ios + +# Or run on a specific simulator +npx react-native run-ios --simulator="iPhone 15 Pro" +``` + +### Run on Android + +```bash +# Start Metro bundler +npm start + +# In another terminal, run on Android +npx react-native run-android +``` + +### Run via Command Line + +```bash +# iOS - Build and run +npx react-native run-ios --mode Release + +# Android - Build and run +npx react-native run-android --mode release +``` + +--- + +## SDK Integration Examples + +### Initialize the SDK + +The SDK is initialized in `App.tsx` with a two-phase initialization pattern: + +```typescript +import { RunAnywhere, SDKEnvironment, ModelCategory } from '@runanywhere/core'; +import { LlamaCPP } from '@runanywhere/llamacpp'; +import { ONNX, ModelArtifactType } from '@runanywhere/onnx'; + +// Phase 1: Initialize SDK +await RunAnywhere.initialize({ + apiKey: '', // Empty in development mode + baseURL: 'https://api.runanywhere.ai', + environment: SDKEnvironment.Development, +}); + +// Phase 2: Register backends and models +LlamaCPP.register(); +await LlamaCPP.addModel({ + id: 'smollm2-360m-q8_0', + name: 'SmolLM2 360M Q8_0', + url: 'https://huggingface.co/prithivMLmods/SmolLM2-360M-GGUF/...', + memoryRequirement: 500_000_000, +}); + +ONNX.register(); +await ONNX.addModel({ + id: 'sherpa-onnx-whisper-tiny.en', + name: 'Sherpa Whisper Tiny (ONNX)', + url: 'https://github.com/RunanywhereAI/sherpa-onnx/releases/...', + modality: ModelCategory.SpeechRecognition, + artifactType: ModelArtifactType.TarGzArchive, + memoryRequirement: 75_000_000, +}); +``` + +### Download & Load a Model + +```typescript +// Download with progress tracking +await RunAnywhere.downloadModel(modelId, (progress) => { + console.log(`Download: ${(progress.progress * 100).toFixed(1)}%`); +}); + +// Load LLM model into memory +const success = await RunAnywhere.loadModel(modelPath); + +// Check if model is loaded +const isLoaded = await RunAnywhere.isModelLoaded(); +``` + +### Stream Text Generation + +```typescript +// Generate with streaming +const streamResult = await RunAnywhere.generateStream(prompt, { + maxTokens: 1000, + temperature: 0.7, +}); + +let fullResponse = ''; +for await (const token of streamResult.stream) { + fullResponse += token; + // Update UI in real-time + updateMessage(fullResponse); +} + +// Get final metrics +const result = await streamResult.result; +console.log(`Speed: ${result.tokensPerSecond} tok/s`); +console.log(`Latency: ${result.latencyMs}ms`); +``` + +### Non-Streaming Generation + +```typescript +const result = await RunAnywhere.generate(prompt, { + maxTokens: 256, + temperature: 0.7, +}); + +console.log('Response:', result.text); +console.log('Tokens:', result.tokensUsed); +console.log('Model:', result.modelUsed); +``` + +### Speech-to-Text + +```typescript +// Load STT model +await RunAnywhere.loadSTTModel(modelPath, 'whisper'); + +// Check if loaded +const isLoaded = await RunAnywhere.isSTTModelLoaded(); + +// Transcribe audio file +const result = await RunAnywhere.transcribeFile(audioPath, { + language: 'en', +}); + +console.log('Transcription:', result.text); +console.log('Confidence:', result.confidence); +``` + +### Text-to-Speech + +```typescript +// Load TTS voice model +await RunAnywhere.loadTTSModel(modelPath, 'piper'); + +// Synthesize speech +const result = await RunAnywhere.synthesize(text, { + voice: 'default', + rate: 1.0, + pitch: 1.0, + volume: 1.0, +}); + +// result.audio contains base64-encoded float32 PCM +// result.sampleRate, result.numSamples, result.duration +``` + +### Voice Pipeline (STT → LLM → TTS) + +```typescript +// 1. Record audio using AudioService +const audioPath = await AudioService.startRecording(); + +// 2. Stop and get audio +const { uri } = await AudioService.stopRecording(); + +// 3. Transcribe +const sttResult = await RunAnywhere.transcribeFile(uri, { language: 'en' }); + +// 4. Generate LLM response +const llmResult = await RunAnywhere.generate(sttResult.text, { + maxTokens: 500, + temperature: 0.7, +}); + +// 5. Synthesize speech +const ttsResult = await RunAnywhere.synthesize(llmResult.text); + +// 6. Play audio (using native audio module) +``` + +### Model Management + +```typescript +// Get available models +const models = await RunAnywhere.getAvailableModels(); +const downloaded = await RunAnywhere.getDownloadedModels(); + +// Get storage info +const storage = await RunAnywhere.getStorageInfo(); +console.log('Used:', storage.usedSpace); +console.log('Free:', storage.freeSpace); +console.log('Models:', storage.modelsSize); + +// Delete a model +await RunAnywhere.deleteModel(modelId); + +// Clear cache +await RunAnywhere.clearCache(); +await RunAnywhere.cleanTempFiles(); +``` + +--- + +## Key Screens Explained + +### 1. Chat Screen (`ChatScreen.tsx`) + +**What it demonstrates:** +- Streaming text generation with real-time token display +- Conversation management (create, switch, delete) +- Message analytics (tokens/sec, generation time, time to first token) +- Model selection bottom sheet integration +- Model status banner showing loaded model + +**Key SDK APIs:** +- `RunAnywhere.generateStream()` — Streaming generation +- `RunAnywhere.loadModel()` — Load LLM model +- `RunAnywhere.isModelLoaded()` — Check model status +- `RunAnywhere.getAvailableModels()` — List models + +### 2. Speech-to-Text Screen (`STTScreen.tsx`) + +**What it demonstrates:** +- **Batch mode**: Record full audio, then transcribe +- **Live mode**: Pseudo-streaming with interval-based transcription +- Audio level visualization during recording +- Transcription metrics (confidence percentage) +- Microphone permission handling + +**Key SDK APIs:** +- `RunAnywhere.loadSTTModel()` — Load Whisper model +- `RunAnywhere.isSTTModelLoaded()` — Check STT model status +- `RunAnywhere.transcribeFile()` — Transcribe audio file +- Native audio recording via `AudioService` + +### 3. Text-to-Speech Screen (`TTSScreen.tsx`) + +**What it demonstrates:** +- Neural voice synthesis with Piper TTS models +- Speed, pitch, and volume controls +- Audio playback with progress tracking +- System TTS fallback support +- WAV file generation from float32 PCM + +**Key SDK APIs:** +- `RunAnywhere.loadTTSModel()` — Load TTS model +- `RunAnywhere.isTTSModelLoaded()` — Check TTS model status +- `RunAnywhere.synthesize()` — Generate speech audio +- Native audio playback via `NativeAudioModule` (iOS) + +### 4. Voice Assistant Screen (`VoiceAssistantScreen.tsx`) + +**What it demonstrates:** +- Complete voice AI pipeline (STT → LLM → TTS) +- Push-to-talk interaction with visual feedback +- Model status tracking for all 3 components +- Pipeline state machine (Idle, Listening, Processing, Thinking, Speaking) +- Conversation history display + +**Key SDK APIs:** +- Full integration of STT, LLM, and TTS APIs +- `AudioService.startRecording()` / `stopRecording()` +- Sequential pipeline execution with error handling + +### 5. Settings Screen (`SettingsScreen.tsx`) + +**What it demonstrates:** +- Model catalog with download/delete functionality +- Download progress tracking +- Storage usage overview (total, models, cache, free) +- Generation settings (temperature, max tokens) +- SDK version and backend information + +**Key SDK APIs:** +- `RunAnywhere.getAvailableModels()` — List all models +- `RunAnywhere.getDownloadedModels()` — List downloaded models +- `RunAnywhere.downloadModel()` — Download with progress +- `RunAnywhere.deleteModel()` — Remove model +- `RunAnywhere.getStorageInfo()` — Storage metrics +- `RunAnywhere.clearCache()` — Clear temporary files + +--- + +## Development + +### Run Linting + +```bash +# ESLint check +npm run lint + +# ESLint with auto-fix +npm run lint:fix +``` + +### Run Type Checking + +```bash +npm run typecheck +``` + +### Run Formatting + +```bash +# Check formatting +npm run format + +# Auto-fix formatting +npm run format:fix +``` + +### Check for Unused Code + +```bash +npm run unused +``` + +### Clean Build + +```bash +# Full clean (removes node_modules and Pods) +npm run clean + +# Just reinstall pods +npm run pod-install +``` + +--- + +## Debugging + +### Enable Verbose Logging + +The app uses `console.warn` with tags for debugging: + +```bash +# iOS: View logs in Xcode console or use: +npx react-native log-ios + +# Android: View logs with: +npx react-native log-android + +# Or filter with adb: +adb logcat -s ReactNative:D +``` + +### Common Log Tags + +| Tag | Description | +|-----|-------------| +| `[App]` | SDK initialization, model registration | +| `[ChatScreen]` | LLM generation, model loading | +| `[STTScreen]` | Speech transcription, audio recording | +| `[TTSScreen]` | Speech synthesis, audio playback | +| `[VoiceAssistant]` | Voice pipeline orchestration | +| `[Settings]` | Storage info, model management | + +### Metro Bundler Issues + +```bash +# Reset Metro cache +npx react-native start --reset-cache + +# Clear watchman +watchman watch-del-all +``` + +--- + +## Configuration + +### Environment Variables + +For production builds, configure via environment variables: + +```bash +# Create .env file (git-ignored) +RUNANYWHERE_API_KEY=your-api-key +RUNANYWHERE_BASE_URL=https://api.runanywhere.ai +``` + +### iOS Specific + +- **Minimum iOS**: 15.1 +- **Bridgeless Mode**: Disabled (for Nitrogen compatibility) +- **Architectures**: arm64 (device), x86_64/arm64 (simulator) + +### Android Specific + +- **Minimum SDK**: 24 (Android 7.0) +- **Target SDK**: 36 +- **Architectures**: arm64-v8a, armeabi-v7a + +--- + +## Supported Models + +### LLM Models (LlamaCpp/GGUF) + +| Model | Size | Memory | Description | +|-------|------|--------|-------------| +| SmolLM2 360M Q8_0 | ~400MB | 500MB | Fast, lightweight chat | +| Qwen 2.5 0.5B Q6_K | ~500MB | 600MB | Multilingual, efficient | +| LFM2 350M Q4_K_M | ~200MB | 250MB | LiquidAI, ultra-compact | +| LFM2 350M Q8_0 | ~350MB | 400MB | LiquidAI, higher quality | +| Llama 2 7B Chat Q4_K_M | ~4GB | 4GB | Powerful, larger model | +| Mistral 7B Instruct Q4_K_M | ~4GB | 4GB | High quality responses | + +### STT Models (ONNX/Whisper) + +| Model | Size | Description | +|-------|------|-------------| +| Sherpa Whisper Tiny (EN) | ~75MB | English transcription | + +### TTS Models (ONNX/Piper) + +| Model | Size | Description | +|-------|------|-------------| +| Piper US English (Medium) | ~65MB | Natural American voice | +| Piper British English (Medium) | ~65MB | British accent | + +--- + +## Known Limitations + +- **ARM64 Preferred** — Native libraries optimized for arm64; x86 emulators may have issues +- **Memory Usage** — Large models (7B+) require devices with 6GB+ RAM +- **First Load** — Initial model loading takes 1-3 seconds +- **iOS Bridgeless** — Disabled for Nitrogen/Nitro module compatibility +- **Live STT** — Uses pseudo-streaming (interval-based) since Whisper is batch-only + +--- + +## Contributing + +See [CONTRIBUTING.md](../../../CONTRIBUTING.md) for guidelines. + +### Development Setup + +```bash +# Fork and clone +git clone https://github.com/YOUR_USERNAME/runanywhere-sdks.git +cd runanywhere-sdks/examples/react-native/RunAnywhereAI + +# Install dependencies +npm install +cd ios && pod install && cd .. + +# Create feature branch +git checkout -b feature/your-feature + +# Make changes and test +npm run lint +npm run typecheck +npm run ios # or npm run android + +# Commit and push +git commit -m "feat: your feature description" +git push origin feature/your-feature + +# Open Pull Request +``` + +--- + +## License + +This project is licensed under the Apache License 2.0 - see [LICENSE](../../../LICENSE) for details. + +--- + +## Support + +- **Discord**: [Join our community](https://discord.gg/N359FBbDVd) +- **GitHub Issues**: [Report bugs](https://github.com/RunanywhereAI/runanywhere-sdks/issues) +- **Email**: san@runanywhere.ai +- **Twitter**: [@RunanywhereAI](https://twitter.com/RunanywhereAI) + +--- + +## Related Documentation + +- [RunAnywhere React Native SDK](../../../sdk/runanywhere-react-native/README.md) — Full SDK documentation +- [iOS Example App](../../ios/RunAnywhereAI/README.md) — iOS native counterpart +- [Android Example App](../../android/RunAnywhereAI/README.md) — Android native counterpart +- [Main README](../../../README.md) — Project overview diff --git a/examples/react-native/RunAnywhereAI/android/app/build.gradle b/examples/react-native/RunAnywhereAI/android/app/build.gradle new file mode 100644 index 000000000..bc0933878 --- /dev/null +++ b/examples/react-native/RunAnywhereAI/android/app/build.gradle @@ -0,0 +1,96 @@ +apply plugin: "com.android.application" +apply plugin: "org.jetbrains.kotlin.android" +apply plugin: "com.facebook.react" + +/** + * This is the configuration block to customize your React Native Android app. + * By default you don't need to apply any configuration, just uncomment the lines you need. + */ +react { + /* Autolinking */ + autolinkLibrariesWithApp() +} + +/** + * Set this to true to Run Proguard on Release builds to minify the Java bytecode. + */ +def enableProguardInReleaseBuilds = false + +/** + * The preferred build flavor of JavaScriptCore (JSC) + */ +def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+' + +android { + ndkVersion rootProject.ext.ndkVersion + buildToolsVersion rootProject.ext.buildToolsVersion + compileSdk rootProject.ext.compileSdkVersion + + namespace "com.runanywhereaI" + defaultConfig { + applicationId "com.runanywhereaI" + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + versionCode 1 + versionName "1.0" + + // 16KB page size support for Android 15+ (API 35) + externalNativeBuild { + cmake { + arguments "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON" + } + } + } + signingConfigs { + debug { + storeFile file('debug.keystore') + storePassword 'android' + keyAlias 'androiddebugkey' + keyPassword 'android' + } + } + buildTypes { + debug { + signingConfig signingConfigs.debug + } + release { + // Caution! In production, you need to generate your own keystore file. + // see https://reactnative.dev/docs/signed-apk-android. + signingConfig signingConfigs.debug + minifyEnabled enableProguardInReleaseBuilds + proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" + } + } + + // Handle duplicate native libraries from different sources + packagingOptions { + pickFirsts = [ + "**/libc++_shared.so", + "**/libjsi.so", + "**/libfbjni.so", + "**/libfolly_runtime.so", + "**/libreactnative.so" + ] + } + + // 16KB page size alignment for Android 15+ (API 35) compliance + // Required starting November 1, 2025 for Google Play submissions + // This ensures native libraries are properly aligned within the APK + packaging { + jniLibs { + useLegacyPackaging = false + } + } +} + +dependencies { + // The version of react-native is set by the React Native Gradle Plugin + implementation("com.facebook.react:react-android") + + def isHermesEnabled = project.hasProperty("hermesEnabled") ? project.hermesEnabled.toBoolean() : true + if (isHermesEnabled) { + implementation("com.facebook.react:hermes-android") + } else { + implementation jscFlavor + } +} diff --git a/examples/react-native/RunAnywhereAI/android/app/proguard-rules.pro b/examples/react-native/RunAnywhereAI/android/app/proguard-rules.pro new file mode 100644 index 000000000..11b025724 --- /dev/null +++ b/examples/react-native/RunAnywhereAI/android/app/proguard-rules.pro @@ -0,0 +1,10 @@ +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the proguardFiles +# directive in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# Add any project specific keep options here: diff --git a/examples/react-native/RunAnywhereAI/android/app/src/debug/AndroidManifest.xml b/examples/react-native/RunAnywhereAI/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 000000000..eb98c01af --- /dev/null +++ b/examples/react-native/RunAnywhereAI/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/examples/react-native/RunAnywhereAI/android/app/src/main/AndroidManifest.xml b/examples/react-native/RunAnywhereAI/android/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000..bdbed100c --- /dev/null +++ b/examples/react-native/RunAnywhereAI/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + diff --git a/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/AntDesign.ttf b/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/AntDesign.ttf new file mode 100644 index 000000000..2abf03542 Binary files /dev/null and b/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/AntDesign.ttf differ diff --git a/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/Entypo.ttf b/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/Entypo.ttf new file mode 100644 index 000000000..76d91cb98 Binary files /dev/null and b/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/Entypo.ttf differ diff --git a/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/EvilIcons.ttf b/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/EvilIcons.ttf new file mode 100644 index 000000000..6868f7bb6 Binary files /dev/null and b/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/EvilIcons.ttf differ diff --git a/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/Feather.ttf b/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/Feather.ttf new file mode 100644 index 000000000..49698e742 Binary files /dev/null and b/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/Feather.ttf differ diff --git a/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/FontAwesome.ttf b/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/FontAwesome.ttf new file mode 100644 index 000000000..35acda2fa Binary files /dev/null and b/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/FontAwesome.ttf differ diff --git a/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/FontAwesome5_Brands.ttf b/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/FontAwesome5_Brands.ttf new file mode 100644 index 000000000..fc567cd2f Binary files /dev/null and b/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/FontAwesome5_Brands.ttf differ diff --git a/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/FontAwesome5_Regular.ttf b/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/FontAwesome5_Regular.ttf new file mode 100644 index 000000000..d1ac9ba11 Binary files /dev/null and b/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/FontAwesome5_Regular.ttf differ diff --git a/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/FontAwesome5_Solid.ttf b/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/FontAwesome5_Solid.ttf new file mode 100644 index 000000000..f33e81629 Binary files /dev/null and b/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/FontAwesome5_Solid.ttf differ diff --git a/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/FontAwesome6_Brands.ttf b/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/FontAwesome6_Brands.ttf new file mode 100644 index 000000000..08362f342 Binary files /dev/null and b/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/FontAwesome6_Brands.ttf differ diff --git a/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/FontAwesome6_Regular.ttf b/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/FontAwesome6_Regular.ttf new file mode 100644 index 000000000..7f9b53c1d Binary files /dev/null and b/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/FontAwesome6_Regular.ttf differ diff --git a/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/FontAwesome6_Solid.ttf b/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/FontAwesome6_Solid.ttf new file mode 100644 index 000000000..e7e2ecfa3 Binary files /dev/null and b/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/FontAwesome6_Solid.ttf differ diff --git a/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/Fontisto.ttf b/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/Fontisto.ttf new file mode 100755 index 000000000..96e2e81a3 Binary files /dev/null and b/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/Fontisto.ttf differ diff --git a/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/Foundation.ttf b/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/Foundation.ttf new file mode 100644 index 000000000..6cce217dd Binary files /dev/null and b/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/Foundation.ttf differ diff --git a/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/Ionicons.ttf b/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/Ionicons.ttf new file mode 100644 index 000000000..c8700858c Binary files /dev/null and b/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/Ionicons.ttf differ diff --git a/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/MaterialCommunityIcons.ttf b/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/MaterialCommunityIcons.ttf new file mode 100644 index 000000000..ba8735957 Binary files /dev/null and b/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/MaterialCommunityIcons.ttf differ diff --git a/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/MaterialIcons.ttf b/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/MaterialIcons.ttf new file mode 100644 index 000000000..9d09b0feb Binary files /dev/null and b/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/MaterialIcons.ttf differ diff --git a/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/Octicons.ttf b/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/Octicons.ttf new file mode 100644 index 000000000..f8daedca4 Binary files /dev/null and b/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/Octicons.ttf differ diff --git a/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/SimpleLineIcons.ttf b/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/SimpleLineIcons.ttf new file mode 100644 index 000000000..6ecb68683 Binary files /dev/null and b/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/SimpleLineIcons.ttf differ diff --git a/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/Zocial.ttf b/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/Zocial.ttf new file mode 100644 index 000000000..e2b5fbb02 Binary files /dev/null and b/examples/react-native/RunAnywhereAI/android/app/src/main/assets/fonts/Zocial.ttf differ diff --git a/examples/react-native/RunAnywhereAI/android/app/src/main/java/com/runanywhereaI/MainActivity.kt b/examples/react-native/RunAnywhereAI/android/app/src/main/java/com/runanywhereaI/MainActivity.kt new file mode 100644 index 000000000..c17faf5e6 --- /dev/null +++ b/examples/react-native/RunAnywhereAI/android/app/src/main/java/com/runanywhereaI/MainActivity.kt @@ -0,0 +1,22 @@ +package com.runanywhereaI + +import com.facebook.react.ReactActivity +import com.facebook.react.ReactActivityDelegate +import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled +import com.facebook.react.defaults.DefaultReactActivityDelegate + +class MainActivity : ReactActivity() { + + /** + * Returns the name of the main component registered from JavaScript. This is used to schedule + * rendering of the component. + */ + override fun getMainComponentName(): String = "RunAnywhereAI" + + /** + * Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate] + * which allows you to enable New Architecture with a single boolean flags [fabricEnabled] + */ + override fun createReactActivityDelegate(): ReactActivityDelegate = + DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled) +} diff --git a/examples/react-native/RunAnywhereAI/android/app/src/main/java/com/runanywhereaI/MainApplication.kt b/examples/react-native/RunAnywhereAI/android/app/src/main/java/com/runanywhereaI/MainApplication.kt new file mode 100644 index 000000000..f2f5ffa1f --- /dev/null +++ b/examples/react-native/RunAnywhereAI/android/app/src/main/java/com/runanywhereaI/MainApplication.kt @@ -0,0 +1,48 @@ +package com.runanywhereaI + +import android.app.Application +import com.facebook.react.PackageList +import com.facebook.react.ReactApplication +import com.facebook.react.ReactHost +import com.facebook.react.ReactNativeHost +import com.facebook.react.ReactPackage +import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint +import com.facebook.react.defaults.DefaultReactHost +import com.facebook.react.defaults.DefaultReactNativeHost +import com.facebook.soloader.SoLoader +import com.facebook.react.soloader.OpenSourceMergedSoMapping + +class MainApplication : Application(), ReactApplication { + + override val reactNativeHost: ReactNativeHost = + object : DefaultReactNativeHost(this) { + override fun getPackages(): List = + PackageList(this).packages.apply { + // Packages that cannot be autolinked yet can be added manually here, for example: + // add(MyReactNativePackage()) + } + + override fun getJSMainModuleName(): String = "index" + + override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG + + override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED + override val isHermesEnabled: Boolean = BuildConfig.IS_HERMES_ENABLED + } + + override val reactHost: ReactHost + get() = DefaultReactHost.getDefaultReactHost(applicationContext, reactNativeHost) + + override fun onCreate() { + super.onCreate() + + // Initialize SoLoader first + SoLoader.init(this, OpenSourceMergedSoMapping) + + // Load New Architecture (required for React Native 0.83+) + // Note: bridgeless mode is now required and enabled by default + if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { + DefaultNewArchitectureEntryPoint.load() + } + } +} diff --git a/examples/react-native/RunAnywhereAI/android/app/src/main/res/drawable/rn_edit_text_material.xml b/examples/react-native/RunAnywhereAI/android/app/src/main/res/drawable/rn_edit_text_material.xml new file mode 100644 index 000000000..5c25e728e --- /dev/null +++ b/examples/react-native/RunAnywhereAI/android/app/src/main/res/drawable/rn_edit_text_material.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + diff --git a/examples/react-native/RunAnywhereAI/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/examples/react-native/RunAnywhereAI/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..cb0cbf6d5 Binary files /dev/null and b/examples/react-native/RunAnywhereAI/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/examples/react-native/RunAnywhereAI/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/examples/react-native/RunAnywhereAI/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 000000000..67c4925ac Binary files /dev/null and b/examples/react-native/RunAnywhereAI/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/examples/react-native/RunAnywhereAI/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/examples/react-native/RunAnywhereAI/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..c6f59d484 Binary files /dev/null and b/examples/react-native/RunAnywhereAI/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/examples/react-native/RunAnywhereAI/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/examples/react-native/RunAnywhereAI/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 000000000..54c9d85d5 Binary files /dev/null and b/examples/react-native/RunAnywhereAI/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/examples/react-native/RunAnywhereAI/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/examples/react-native/RunAnywhereAI/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..33df0290c Binary files /dev/null and b/examples/react-native/RunAnywhereAI/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/examples/react-native/RunAnywhereAI/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/examples/react-native/RunAnywhereAI/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 000000000..faa037166 Binary files /dev/null and b/examples/react-native/RunAnywhereAI/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/examples/react-native/RunAnywhereAI/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/examples/react-native/RunAnywhereAI/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..172a969f0 Binary files /dev/null and b/examples/react-native/RunAnywhereAI/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/examples/react-native/RunAnywhereAI/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/examples/react-native/RunAnywhereAI/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 000000000..f605f26aa Binary files /dev/null and b/examples/react-native/RunAnywhereAI/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/examples/react-native/RunAnywhereAI/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/examples/react-native/RunAnywhereAI/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..21617fdbf Binary files /dev/null and b/examples/react-native/RunAnywhereAI/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/examples/react-native/RunAnywhereAI/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/examples/react-native/RunAnywhereAI/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 000000000..2501997c0 Binary files /dev/null and b/examples/react-native/RunAnywhereAI/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/examples/react-native/RunAnywhereAI/android/app/src/main/res/values/strings.xml b/examples/react-native/RunAnywhereAI/android/app/src/main/res/values/strings.xml new file mode 100644 index 000000000..7474c39a6 --- /dev/null +++ b/examples/react-native/RunAnywhereAI/android/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + RunAnywhere + diff --git a/examples/react-native/RunAnywhereAI/android/app/src/main/res/values/styles.xml b/examples/react-native/RunAnywhereAI/android/app/src/main/res/values/styles.xml new file mode 100644 index 000000000..7ba83a2ad --- /dev/null +++ b/examples/react-native/RunAnywhereAI/android/app/src/main/res/values/styles.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/examples/react-native/RunAnywhereAI/android/build.gradle b/examples/react-native/RunAnywhereAI/android/build.gradle new file mode 100644 index 000000000..fd3965f9d --- /dev/null +++ b/examples/react-native/RunAnywhereAI/android/build.gradle @@ -0,0 +1,36 @@ +buildscript { + ext { + buildToolsVersion = "36.0.0" + minSdkVersion = 24 + compileSdkVersion = 36 + targetSdkVersion = 36 + ndkVersion = "28.0.13004108" + kotlinVersion = "2.1.20" + } + repositories { + google() + mavenCentral() + } + dependencies { + classpath("com.android.tools.build:gradle") + classpath("com.facebook.react:react-native-gradle-plugin") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin") + } +} + +apply plugin: "com.facebook.react.rootproject" + +// ============================================================================= +// Fix: Node.js not found when running from Android Studio +// Android Studio doesn't inherit terminal PATH +// ============================================================================= +gradle.taskGraph.whenReady { taskGraph -> + taskGraph.allTasks.each { task -> + if (task.name.contains("generateCodegenSchemaFromJavaScript") || + task.name.contains("Codegen")) { + if (task.hasProperty("environment")) { + task.environment("PATH", "/opt/homebrew/bin:/usr/local/bin:/usr/bin:" + (System.getenv("PATH") ?: "")) + } + } + } +} diff --git a/examples/react-native/RunAnywhereAI/android/gradle.properties.example b/examples/react-native/RunAnywhereAI/android/gradle.properties.example new file mode 100644 index 000000000..b0b6b4bf2 --- /dev/null +++ b/examples/react-native/RunAnywhereAI/android/gradle.properties.example @@ -0,0 +1,32 @@ +# Gradle properties for RunAnywhere React Native Sample App +# +# Copy this file to gradle.properties: +# cp gradle.properties.example gradle.properties + +# AndroidX Migration +android.useAndroidX=true +android.enableJetifier=false + +# RunAnywhere SDK - Local Development +# Set to true to use locally built native libraries +# Set to false to download from GitHub releases +runanywhere.testLocal=true + +# Gradle JVM Settings +# Increase memory for React Native builds +org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError + +# React Native +# Enable Hermes engine +hermesEnabled=true + +# Android Build Settings +android.defaults.buildfeatures.buildconfig=true +android.nonTransitiveRClass=false +android.nonFinalResIds=false + +# ============================================================================= +# 16KB Page Alignment for Android 15+ (API 35) Compliance +# Required for Google Play submissions starting November 2025 +# ============================================================================= +android.bundle.pageAlignSharedLibs=true diff --git a/examples/react-native/RunAnywhereAI/android/gradle/wrapper/gradle-wrapper.properties b/examples/react-native/RunAnywhereAI/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..d4081da47 --- /dev/null +++ b/examples/react-native/RunAnywhereAI/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/examples/react-native/RunAnywhereAI/android/gradlew b/examples/react-native/RunAnywhereAI/android/gradlew new file mode 100755 index 000000000..1aa94a426 --- /dev/null +++ b/examples/react-native/RunAnywhereAI/android/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/examples/react-native/RunAnywhereAI/android/gradlew.bat b/examples/react-native/RunAnywhereAI/android/gradlew.bat new file mode 100644 index 000000000..25da30dbd --- /dev/null +++ b/examples/react-native/RunAnywhereAI/android/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/examples/react-native/RunAnywhereAI/android/gradlew.env b/examples/react-native/RunAnywhereAI/android/gradlew.env new file mode 100644 index 000000000..06dc11d23 --- /dev/null +++ b/examples/react-native/RunAnywhereAI/android/gradlew.env @@ -0,0 +1,15 @@ +# This file is sourced by gradlew to set environment variables +# It helps Android Studio find node when not launched from terminal + +# Try common node locations +if [ -x "/opt/homebrew/bin/node" ]; then + export PATH="/opt/homebrew/bin:$PATH" +elif [ -x "/usr/local/bin/node" ]; then + export PATH="/usr/local/bin:$PATH" +elif [ -x "$HOME/.nvm/current/bin/node" ]; then + export PATH="$HOME/.nvm/current/bin:$PATH" +elif [ -d "$HOME/.nvm" ]; then + # Load NVM if available + export NVM_DIR="$HOME/.nvm" + [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" +fi diff --git a/examples/react-native/RunAnywhereAI/android/settings.gradle b/examples/react-native/RunAnywhereAI/android/settings.gradle new file mode 100644 index 000000000..d9ae8013c --- /dev/null +++ b/examples/react-native/RunAnywhereAI/android/settings.gradle @@ -0,0 +1,6 @@ +pluginManagement { includeBuild("../node_modules/@react-native/gradle-plugin") } +plugins { id("com.facebook.react.settings") } +extensions.configure(com.facebook.react.ReactSettingsExtension){ ex -> ex.autolinkLibrariesFromCommand() } +rootProject.name = 'RunAnywhereAI' +include ':app' +includeBuild('../node_modules/@react-native/gradle-plugin') diff --git a/examples/react-native/RunAnywhereAI/app.json b/examples/react-native/RunAnywhereAI/app.json new file mode 100644 index 000000000..3832eb893 --- /dev/null +++ b/examples/react-native/RunAnywhereAI/app.json @@ -0,0 +1,4 @@ +{ + "name": "RunAnywhereAI", + "displayName": "RunAnywhere" +} diff --git a/examples/react-native/RunAnywhereAI/babel.config.js b/examples/react-native/RunAnywhereAI/babel.config.js new file mode 100644 index 000000000..f7b3da3b3 --- /dev/null +++ b/examples/react-native/RunAnywhereAI/babel.config.js @@ -0,0 +1,3 @@ +module.exports = { + presets: ['module:@react-native/babel-preset'], +}; diff --git a/examples/react-native/RunAnywhereAI/index.js b/examples/react-native/RunAnywhereAI/index.js new file mode 100644 index 000000000..9b7393291 --- /dev/null +++ b/examples/react-native/RunAnywhereAI/index.js @@ -0,0 +1,9 @@ +/** + * @format + */ + +import { AppRegistry } from 'react-native'; +import App from './App'; +import { name as appName } from './app.json'; + +AppRegistry.registerComponent(appName, () => App); diff --git a/examples/react-native/RunAnywhereAI/ios/Podfile b/examples/react-native/RunAnywhereAI/ios/Podfile new file mode 100644 index 000000000..e32a6abda --- /dev/null +++ b/examples/react-native/RunAnywhereAI/ios/Podfile @@ -0,0 +1,111 @@ +# CRITICAL: Enable React Native New Architecture (required for RunAnywhere SDK) +ENV['RCT_NEW_ARCH_ENABLED'] = '1' + +# Resolve react_native_pods.rb with node to allow for hoisting +require Pod::Executable.execute_command('node', ['-p', + 'require.resolve( + "react-native/scripts/react_native_pods.rb", + {paths: [process.argv[1]]}, + )', __dir__]).strip + +# RunAnywhere SDK requires iOS 14.0 minimum +platform :ios, '15.1' +prepare_react_native_project! + +# react-native-permissions setup +def node_require(script) + # Resolve script with node to allow for hoisting + require Pod::Executable.execute_command('node', ['-p', + "require.resolve( + '#{script}', + {paths: [process.argv[1]]}, + )", __dir__]).strip +end + +node_require('react-native-permissions/scripts/setup.rb') + +# Only include the permissions we need +setup_permissions([ + 'Microphone', +]) + +linkage = ENV['USE_FRAMEWORKS'] +if linkage != nil + Pod::UI.puts "Configuring Pod with #{linkage}ally linked Frameworks".green + use_frameworks! :linkage => linkage.to_sym +end + +target 'RunAnywhereAI' do + config = use_native_modules! + + use_react_native!( + :path => config[:reactNativePath], + # An absolute path to your application root. + :app_path => "#{Pod::Config.instance.installation_root}/..", + # Enable New Architecture (TurboModules + Fabric) + :hermes_enabled => true, + :fabric_enabled => true + ) + + target 'RunAnywhereAITests' do + inherit! :complete + # Pods for testing + end + + post_install do |installer| + # https://github.com/facebook/react-native/blob/main/packages/react-native/scripts/react_native_pods.rb#L197-L202 + react_native_post_install( + installer, + config[:reactNativePath], + :mac_catalyst_enabled => false + # :ccache_enabled => true + ) + + # Force all pods to use iOS 15.1 (required for react-native-audio-api) + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.1' + end + end + + # Fix Xcode 16 sandbox issue for script phases + installer.pods_project.targets.each do |target| + target.build_phases.each do |phase| + if phase.is_a?(Xcodeproj::Project::Object::PBXShellScriptBuildPhase) + phase.always_out_of_date = "1" + end + end + end + + # Fix RNZipArchive compiler flags issue (Xcode 16+ compatibility) + # The RNZipArchive podspec uses -G flag which is unsupported on arm64-apple-ios targets + # in Xcode 16+. We must fix this at two levels: + # 1. Build settings (OTHER_CFLAGS) - full replacement with known required flags + # 2. Per-file compiler flags - surgical removal of -G only + installer.pods_project.targets.each do |target| + if target.name == 'RNZipArchive' + target.build_configurations.each do |config| + # Full replacement of OTHER_CFLAGS for RNZipArchive. + # We intentionally discard any existing flags because: + # - RNZipArchive's podspec sets problematic flags including -G + # - The required flags are well-known (minizip compile definitions) + # - $(inherited) ensures project-level flags are still included + config.build_settings['OTHER_CFLAGS'] = '$(inherited) -DHAVE_INTTYPES_H -DHAVE_PKCRYPT -DHAVE_STDINT_H -DHAVE_WZAES -DHAVE_ZLIB -DMZ_ZIP_NO_SIGNING' + end + + # Completely clear per-file COMPILER_FLAGS. + # The RNZipArchive podspec sets malformed flags like: + # -G CC_PREPROCESSOR_DEFINITIONS="..." + # Both -G and CC_PREPROCESSOR_DEFINITIONS= syntax are invalid. + # Since we set proper flags via OTHER_CFLAGS above, we can safely clear these. + target.build_phases.each do |phase| + next unless phase.respond_to?(:files) + phase.files.each do |file| + next unless file.settings && file.settings['COMPILER_FLAGS'] + file.settings.delete('COMPILER_FLAGS') + end + end + end + end + end +end diff --git a/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI.xcodeproj/project.pbxproj b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI.xcodeproj/project.pbxproj new file mode 100644 index 000000000..c7e0ac532 --- /dev/null +++ b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI.xcodeproj/project.pbxproj @@ -0,0 +1,723 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 0006D45B6AD5438EA3B175BC /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = FF375136ECCEFB06442E8E31 /* PrivacyInfo.xcprivacy */; }; + 00E356F31AD99517003FC87E /* RunAnywhereAITests.m in Sources */ = {isa = PBXBuildFile; fileRef = 00E356F21AD99517003FC87E /* RunAnywhereAITests.m */; }; + 131BD3E69C56FB3D2407D933 /* libPods-RunAnywhereAI-RunAnywhereAITests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 22B57BB26B1AA1AAAD263C4E /* libPods-RunAnywhereAI-RunAnywhereAITests.a */; }; + 13B07FBC1A68108700A75B9A /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB01A68108700A75B9A /* AppDelegate.swift */; }; + 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; + 5B7EAD8CABC3ABC09C3EBCB0 /* libPods-RunAnywhereAI.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 9E75FBBD9CE4CFEFC37E3C16 /* libPods-RunAnywhereAI.a */; }; + 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; }; + AABB001122334455 /* NativeAudioModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABB001122334456 /* NativeAudioModule.swift */; }; + AABB001122334457 /* NativeAudioModule.m in Sources */ = {isa = PBXBuildFile; fileRef = AABB001122334458 /* NativeAudioModule.m */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 00E356F41AD99517003FC87E /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 13B07F861A680F5B00A75B9A; + remoteInfo = RunAnywhereAI; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 00E356EE1AD99517003FC87E /* RunAnywhereAITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunAnywhereAITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 00E356F11AD99517003FC87E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 00E356F21AD99517003FC87E /* RunAnywhereAITests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RunAnywhereAITests.m; sourceTree = ""; }; + 10B9ADCD8B3BA157659426EF /* Pods-RunAnywhereAI.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunAnywhereAI.release.xcconfig"; path = "Target Support Files/Pods-RunAnywhereAI/Pods-RunAnywhereAI.release.xcconfig"; sourceTree = ""; }; + 13B07F961A680F5B00A75B9A /* RunAnywhereAI.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = RunAnywhereAI.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 13B07FB01A68108700A75B9A /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = RunAnywhereAI/AppDelegate.swift; sourceTree = ""; }; + 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = RunAnywhereAI/Images.xcassets; sourceTree = ""; }; + 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = RunAnywhereAI/Info.plist; sourceTree = ""; }; + 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = PrivacyInfo.xcprivacy; path = RunAnywhereAI/PrivacyInfo.xcprivacy; sourceTree = ""; }; + 22B57BB26B1AA1AAAD263C4E /* libPods-RunAnywhereAI-RunAnywhereAITests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-RunAnywhereAI-RunAnywhereAITests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 2B933AC90856DEE721474248 /* Pods-RunAnywhereAI-RunAnywhereAITests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunAnywhereAI-RunAnywhereAITests.release.xcconfig"; path = "Target Support Files/Pods-RunAnywhereAI-RunAnywhereAITests/Pods-RunAnywhereAI-RunAnywhereAITests.release.xcconfig"; sourceTree = ""; }; + 5A502ADF42182DE7D087E596 /* Pods-RunAnywhereAI.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunAnywhereAI.debug.xcconfig"; path = "Target Support Files/Pods-RunAnywhereAI/Pods-RunAnywhereAI.debug.xcconfig"; sourceTree = ""; }; + 7B92E6D53088A71A1B54CCAB /* Pods-RunAnywhereAI-RunAnywhereAITests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunAnywhereAI-RunAnywhereAITests.debug.xcconfig"; path = "Target Support Files/Pods-RunAnywhereAI-RunAnywhereAITests/Pods-RunAnywhereAI-RunAnywhereAITests.debug.xcconfig"; sourceTree = ""; }; + 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = RunAnywhereAI/LaunchScreen.storyboard; sourceTree = ""; }; + 9E75FBBD9CE4CFEFC37E3C16 /* libPods-RunAnywhereAI.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-RunAnywhereAI.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + AABB001122334456 /* NativeAudioModule.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = NativeAudioModule.swift; path = RunAnywhereAI/NativeAudioModule.swift; sourceTree = ""; }; + AABB001122334458 /* NativeAudioModule.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = NativeAudioModule.m; path = RunAnywhereAI/NativeAudioModule.m; sourceTree = ""; }; + AABB001122334459 /* RunAnywhereAI-Bridging-Header.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = "RunAnywhereAI-Bridging-Header.h"; path = "RunAnywhereAI/RunAnywhereAI-Bridging-Header.h"; sourceTree = ""; }; + ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; + FF375136ECCEFB06442E8E31 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = RunAnywhereAI/PrivacyInfo.xcprivacy; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 00E356EB1AD99517003FC87E /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 131BD3E69C56FB3D2407D933 /* libPods-RunAnywhereAI-RunAnywhereAITests.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 13B07F8C1A680F5B00A75B9A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 5B7EAD8CABC3ABC09C3EBCB0 /* libPods-RunAnywhereAI.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 00E356EF1AD99517003FC87E /* RunAnywhereAITests */ = { + isa = PBXGroup; + children = ( + 00E356F21AD99517003FC87E /* RunAnywhereAITests.m */, + 00E356F01AD99517003FC87E /* Supporting Files */, + ); + path = RunAnywhereAITests; + sourceTree = ""; + }; + 00E356F01AD99517003FC87E /* Supporting Files */ = { + isa = PBXGroup; + children = ( + 00E356F11AD99517003FC87E /* Info.plist */, + ); + name = "Supporting Files"; + sourceTree = ""; + }; + 13B07FAE1A68108700A75B9A /* RunAnywhereAI */ = { + isa = PBXGroup; + children = ( + 13B07FB01A68108700A75B9A /* AppDelegate.swift */, + AABB001122334456 /* NativeAudioModule.swift */, + AABB001122334458 /* NativeAudioModule.m */, + AABB001122334459 /* RunAnywhereAI-Bridging-Header.h */, + 13B07FB51A68108700A75B9A /* Images.xcassets */, + 13B07FB61A68108700A75B9A /* Info.plist */, + 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */, + 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */, + FF375136ECCEFB06442E8E31 /* PrivacyInfo.xcprivacy */, + ); + name = RunAnywhereAI; + sourceTree = ""; + }; + 2D16E6871FA4F8E400B85C8A /* Frameworks */ = { + isa = PBXGroup; + children = ( + ED297162215061F000B7C4FE /* JavaScriptCore.framework */, + 9E75FBBD9CE4CFEFC37E3C16 /* libPods-RunAnywhereAI.a */, + 22B57BB26B1AA1AAAD263C4E /* libPods-RunAnywhereAI-RunAnywhereAITests.a */, + ); + name = Frameworks; + sourceTree = ""; + }; + 832341AE1AAA6A7D00B99B32 /* Libraries */ = { + isa = PBXGroup; + children = ( + ); + name = Libraries; + sourceTree = ""; + }; + 83CBB9F61A601CBA00E9B192 = { + isa = PBXGroup; + children = ( + 13B07FAE1A68108700A75B9A /* RunAnywhereAI */, + 832341AE1AAA6A7D00B99B32 /* Libraries */, + 00E356EF1AD99517003FC87E /* RunAnywhereAITests */, + 83CBBA001A601CBA00E9B192 /* Products */, + 2D16E6871FA4F8E400B85C8A /* Frameworks */, + BBD78D7AC51CEA395F1C20DB /* Pods */, + ); + indentWidth = 2; + sourceTree = ""; + tabWidth = 2; + usesTabs = 0; + }; + 83CBBA001A601CBA00E9B192 /* Products */ = { + isa = PBXGroup; + children = ( + 13B07F961A680F5B00A75B9A /* RunAnywhereAI.app */, + 00E356EE1AD99517003FC87E /* RunAnywhereAITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + BBD78D7AC51CEA395F1C20DB /* Pods */ = { + isa = PBXGroup; + children = ( + 5A502ADF42182DE7D087E596 /* Pods-RunAnywhereAI.debug.xcconfig */, + 10B9ADCD8B3BA157659426EF /* Pods-RunAnywhereAI.release.xcconfig */, + 7B92E6D53088A71A1B54CCAB /* Pods-RunAnywhereAI-RunAnywhereAITests.debug.xcconfig */, + 2B933AC90856DEE721474248 /* Pods-RunAnywhereAI-RunAnywhereAITests.release.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 00E356ED1AD99517003FC87E /* RunAnywhereAITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 00E357021AD99517003FC87E /* Build configuration list for PBXNativeTarget "RunAnywhereAITests" */; + buildPhases = ( + 5ED11DDB6470C92E349A100F /* [CP] Check Pods Manifest.lock */, + 00E356EA1AD99517003FC87E /* Sources */, + 00E356EB1AD99517003FC87E /* Frameworks */, + 00E356EC1AD99517003FC87E /* Resources */, + 8EDE8A0D9D2E572F7D2CEDE9 /* [CP] Embed Pods Frameworks */, + 704AF4CDF7CECE1170F9C981 /* [CP] Copy Pods Resources */, + ); + buildRules = ( + ); + dependencies = ( + 00E356F51AD99517003FC87E /* PBXTargetDependency */, + ); + name = RunAnywhereAITests; + productName = RunAnywhereAITests; + productReference = 00E356EE1AD99517003FC87E /* RunAnywhereAITests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 13B07F861A680F5B00A75B9A /* RunAnywhereAI */ = { + isa = PBXNativeTarget; + buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "RunAnywhereAI" */; + buildPhases = ( + 22CCBFE3F3F9156F12874F4B /* [CP] Check Pods Manifest.lock */, + 13B07F871A680F5B00A75B9A /* Sources */, + 13B07F8C1A680F5B00A75B9A /* Frameworks */, + 13B07F8E1A680F5B00A75B9A /* Resources */, + 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */, + 940A9F151B6ED38FA5AB9E70 /* [CP] Embed Pods Frameworks */, + E817622BE9880BEF5A0AC05C /* [CP] Copy Pods Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = RunAnywhereAI; + productName = RunAnywhereAI; + productReference = 13B07F961A680F5B00A75B9A /* RunAnywhereAI.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 83CBB9F71A601CBA00E9B192 /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 1210; + TargetAttributes = { + 00E356ED1AD99517003FC87E = { + CreatedOnToolsVersion = 6.2; + TestTargetID = 13B07F861A680F5B00A75B9A; + }; + 13B07F861A680F5B00A75B9A = { + LastSwiftMigration = 1120; + }; + }; + }; + buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "RunAnywhereAI" */; + compatibilityVersion = "Xcode 12.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 83CBB9F61A601CBA00E9B192; + productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 13B07F861A680F5B00A75B9A /* RunAnywhereAI */, + 00E356ED1AD99517003FC87E /* RunAnywhereAITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 00E356EC1AD99517003FC87E /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 13B07F8E1A680F5B00A75B9A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */, + 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, + 0006D45B6AD5438EA3B175BC /* PrivacyInfo.xcprivacy in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "$(SRCROOT)/.xcode.env.local", + "$(SRCROOT)/.xcode.env", + ); + name = "Bundle React Native code and images"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "set -e\n\nWITH_ENVIRONMENT=\"$REACT_NATIVE_PATH/scripts/xcode/with-environment.sh\"\nREACT_NATIVE_XCODE=\"$REACT_NATIVE_PATH/scripts/react-native-xcode.sh\"\n\n/bin/sh -c \"$WITH_ENVIRONMENT $REACT_NATIVE_XCODE\"\n"; + }; + 22CCBFE3F3F9156F12874F4B /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunAnywhereAI-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 5ED11DDB6470C92E349A100F /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunAnywhereAI-RunAnywhereAITests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 704AF4CDF7CECE1170F9C981 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-RunAnywhereAI-RunAnywhereAITests/Pods-RunAnywhereAI-RunAnywhereAITests-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-RunAnywhereAI-RunAnywhereAITests/Pods-RunAnywhereAI-RunAnywhereAITests-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-RunAnywhereAI-RunAnywhereAITests/Pods-RunAnywhereAI-RunAnywhereAITests-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + 8EDE8A0D9D2E572F7D2CEDE9 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-RunAnywhereAI-RunAnywhereAITests/Pods-RunAnywhereAI-RunAnywhereAITests-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-RunAnywhereAI-RunAnywhereAITests/Pods-RunAnywhereAI-RunAnywhereAITests-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-RunAnywhereAI-RunAnywhereAITests/Pods-RunAnywhereAI-RunAnywhereAITests-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 940A9F151B6ED38FA5AB9E70 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-RunAnywhereAI/Pods-RunAnywhereAI-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-RunAnywhereAI/Pods-RunAnywhereAI-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-RunAnywhereAI/Pods-RunAnywhereAI-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + E817622BE9880BEF5A0AC05C /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-RunAnywhereAI/Pods-RunAnywhereAI-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-RunAnywhereAI/Pods-RunAnywhereAI-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-RunAnywhereAI/Pods-RunAnywhereAI-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 00E356EA1AD99517003FC87E /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 00E356F31AD99517003FC87E /* RunAnywhereAITests.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 13B07F871A680F5B00A75B9A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 13B07FBC1A68108700A75B9A /* AppDelegate.swift in Sources */, + AABB001122334455 /* NativeAudioModule.swift in Sources */, + AABB001122334457 /* NativeAudioModule.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 00E356F51AD99517003FC87E /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 13B07F861A680F5B00A75B9A /* RunAnywhereAI */; + targetProxy = 00E356F41AD99517003FC87E /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 00E356F61AD99517003FC87E /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7B92E6D53088A71A1B54CCAB /* Pods-RunAnywhereAI-RunAnywhereAITests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + INFOPLIST_FILE = RunAnywhereAITests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 13.4; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + OTHER_LDFLAGS = ( + "-ObjC", + "-lc++", + "$(inherited)", + ); + PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.$(PRODUCT_NAME:rfc1034identifier)"; + PRODUCT_NAME = "$(TARGET_NAME)"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/RunAnywhereAI.app/RunAnywhereAI"; + }; + name = Debug; + }; + 00E356F71AD99517003FC87E /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 2B933AC90856DEE721474248 /* Pods-RunAnywhereAI-RunAnywhereAITests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + COPY_PHASE_STRIP = NO; + INFOPLIST_FILE = RunAnywhereAITests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 13.4; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + OTHER_LDFLAGS = ( + "-ObjC", + "-lc++", + "$(inherited)", + ); + PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.$(PRODUCT_NAME:rfc1034identifier)"; + PRODUCT_NAME = "$(TARGET_NAME)"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/RunAnywhereAI.app/RunAnywhereAI"; + }; + name = Release; + }; + 13B07F941A680F5B00A75B9A /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 5A502ADF42182DE7D087E596 /* Pods-RunAnywhereAI.debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = AFAL2647U9; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = RunAnywhereAI/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lc++", + ); + PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.$(PRODUCT_NAME:rfc1034identifier)"; + PRODUCT_NAME = RunAnywhereAI; + SWIFT_OBJC_BRIDGING_HEADER = "RunAnywhereAI/RunAnywhereAI-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 13B07F951A680F5B00A75B9A /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 10B9ADCD8B3BA157659426EF /* Pods-RunAnywhereAI.release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = AFAL2647U9; + INFOPLIST_FILE = RunAnywhereAI/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lc++", + ); + PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.$(PRODUCT_NAME:rfc1034identifier)"; + PRODUCT_NAME = RunAnywhereAI; + SWIFT_OBJC_BRIDGING_HEADER = "RunAnywhereAI/RunAnywhereAI-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; + 83CBBA201A601CBA00E9B192 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CC = ""; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_CXX_LANGUAGE_STANDARD = "c++20"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + CXX = ""; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = ""; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_SYMBOLS_PRIVATE_EXTERN = NO; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.4; + LD = ""; + LDPLUSPLUS = ""; + LD_RUNPATH_SEARCH_PATHS = ( + /usr/lib/swift, + "$(inherited)", + ); + LIBRARY_SEARCH_PATHS = ( + "\"$(SDKROOT)/usr/lib/swift\"", + "\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"", + "\"$(inherited)\"", + ); + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + OTHER_CFLAGS = "$(inherited)"; + OTHER_CPLUSPLUSFLAGS = ( + "$(OTHER_CFLAGS)", + "-DFOLLY_NO_CONFIG", + "-DFOLLY_MOBILE=1", + "-DFOLLY_USE_LIBCPP=1", + "-DFOLLY_CFG_NO_COROUTINES=1", + "-DFOLLY_HAVE_CLOCK_GETTIME=1", + ); + OTHER_LDFLAGS = ( + "$(inherited)", + " ", + ); + REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; + USE_HERMES = true; + }; + name = Debug; + }; + 83CBBA211A601CBA00E9B192 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CC = ""; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_CXX_LANGUAGE_STANDARD = "c++20"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = YES; + CXX = ""; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = ""; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.4; + LD = ""; + LDPLUSPLUS = ""; + LD_RUNPATH_SEARCH_PATHS = ( + /usr/lib/swift, + "$(inherited)", + ); + LIBRARY_SEARCH_PATHS = ( + "\"$(SDKROOT)/usr/lib/swift\"", + "\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"", + "\"$(inherited)\"", + ); + MTL_ENABLE_DEBUG_INFO = NO; + OTHER_CFLAGS = "$(inherited)"; + OTHER_CPLUSPLUSFLAGS = ( + "$(OTHER_CFLAGS)", + "-DFOLLY_NO_CONFIG", + "-DFOLLY_MOBILE=1", + "-DFOLLY_USE_LIBCPP=1", + "-DFOLLY_CFG_NO_COROUTINES=1", + "-DFOLLY_HAVE_CLOCK_GETTIME=1", + ); + OTHER_LDFLAGS = ( + "$(inherited)", + " ", + ); + REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; + SDKROOT = iphoneos; + USE_HERMES = true; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 00E357021AD99517003FC87E /* Build configuration list for PBXNativeTarget "RunAnywhereAITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 00E356F61AD99517003FC87E /* Debug */, + 00E356F71AD99517003FC87E /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "RunAnywhereAI" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 13B07F941A680F5B00A75B9A /* Debug */, + 13B07F951A680F5B00A75B9A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "RunAnywhereAI" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 83CBBA201A601CBA00E9B192 /* Debug */, + 83CBBA211A601CBA00E9B192 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */; +} diff --git a/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI.xcodeproj/xcshareddata/xcschemes/RunAnywhereAI.xcscheme b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI.xcodeproj/xcshareddata/xcschemes/RunAnywhereAI.xcscheme new file mode 100644 index 000000000..ad9000a85 --- /dev/null +++ b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI.xcodeproj/xcshareddata/xcschemes/RunAnywhereAI.xcscheme @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI.xcworkspace/contents.xcworkspacedata b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..ec7877520 --- /dev/null +++ b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/AppDelegate.swift b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/AppDelegate.swift new file mode 100644 index 000000000..3026f2e9f --- /dev/null +++ b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/AppDelegate.swift @@ -0,0 +1,53 @@ +import UIKit +import React +import React_RCTAppDelegate +import ReactAppDependencyProvider + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + var window: UIWindow? + + var reactNativeDelegate: ReactNativeDelegate? + var reactNativeFactory: RCTReactNativeFactory? + + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + let delegate = ReactNativeDelegate() + let factory = RCTReactNativeFactory(delegate: delegate) + delegate.dependencyProvider = RCTAppDependencyProvider() + + reactNativeDelegate = delegate + reactNativeFactory = factory + + window = UIWindow(frame: UIScreen.main.bounds) + + factory.startReactNative( + withModuleName: "RunAnywhereAI", + in: window, + launchOptions: launchOptions + ) + + return true + } +} + +class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate { + override func sourceURL(for bridge: RCTBridge) -> URL? { + self.bundleURL() + } + + override func bundleURL() -> URL? { +#if DEBUG + RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index") +#else + Bundle.main.url(forResource: "main", withExtension: "jsbundle") +#endif + } + + // CRITICAL: Disable bridgeless mode for Nitrogen/NitroModules compatibility + override func bridgelessEnabled() -> Bool { + return false + } +} diff --git a/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/100.png b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/100.png new file mode 100644 index 000000000..4b160f3e8 Binary files /dev/null and b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/100.png differ diff --git a/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/102.png b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/102.png new file mode 100644 index 000000000..f1f0593bc Binary files /dev/null and b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/102.png differ diff --git a/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/1024.png b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/1024.png new file mode 100644 index 000000000..6b309914a Binary files /dev/null and b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/1024.png differ diff --git a/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/108.png b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/108.png new file mode 100644 index 000000000..188cb8756 Binary files /dev/null and b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/108.png differ diff --git a/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/114.png b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/114.png new file mode 100644 index 000000000..1100d2793 Binary files /dev/null and b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/114.png differ diff --git a/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/120.png b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/120.png new file mode 100644 index 000000000..288e27187 Binary files /dev/null and b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/120.png differ diff --git a/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/128.png b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/128.png new file mode 100644 index 000000000..bf846f2c6 Binary files /dev/null and b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/128.png differ diff --git a/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/144.png b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/144.png new file mode 100644 index 000000000..2986a6c3c Binary files /dev/null and b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/144.png differ diff --git a/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/152.png b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/152.png new file mode 100644 index 000000000..3639f91de Binary files /dev/null and b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/152.png differ diff --git a/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/16.png b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/16.png new file mode 100644 index 000000000..4d2ff68da Binary files /dev/null and b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/16.png differ diff --git a/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/167.png b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/167.png new file mode 100644 index 000000000..5a3e425d8 Binary files /dev/null and b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/167.png differ diff --git a/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/172.png b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/172.png new file mode 100644 index 000000000..b009bfa7a Binary files /dev/null and b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/172.png differ diff --git a/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/180.png b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/180.png new file mode 100644 index 000000000..cf12e1f2c Binary files /dev/null and b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/180.png differ diff --git a/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/196.png b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/196.png new file mode 100644 index 000000000..3f1e68eca Binary files /dev/null and b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/196.png differ diff --git a/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/20.png b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/20.png new file mode 100644 index 000000000..39f76a6eb Binary files /dev/null and b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/20.png differ diff --git a/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/216.png b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/216.png new file mode 100644 index 000000000..9488ece10 Binary files /dev/null and b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/216.png differ diff --git a/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/234.png b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/234.png new file mode 100644 index 000000000..024d7ed54 Binary files /dev/null and b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/234.png differ diff --git a/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/256.png b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/256.png new file mode 100644 index 000000000..f55af4eef Binary files /dev/null and b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/256.png differ diff --git a/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/258.png b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/258.png new file mode 100644 index 000000000..1dee051c4 Binary files /dev/null and b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/258.png differ diff --git a/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/29.png b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/29.png new file mode 100644 index 000000000..7c59b6b50 Binary files /dev/null and b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/29.png differ diff --git a/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/32.png b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/32.png new file mode 100644 index 000000000..5ad0ba3f1 Binary files /dev/null and b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/32.png differ diff --git a/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/40.png b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/40.png new file mode 100644 index 000000000..d7b83779c Binary files /dev/null and b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/40.png differ diff --git a/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/48.png b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/48.png new file mode 100644 index 000000000..157914137 Binary files /dev/null and b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/48.png differ diff --git a/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/50.png b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/50.png new file mode 100644 index 000000000..9d5334505 Binary files /dev/null and b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/50.png differ diff --git a/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/512.png b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/512.png new file mode 100644 index 000000000..4482531ee Binary files /dev/null and b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/512.png differ diff --git a/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/55.png b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/55.png new file mode 100644 index 000000000..76667e765 Binary files /dev/null and b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/55.png differ diff --git a/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/57.png b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/57.png new file mode 100644 index 000000000..0fc697f52 Binary files /dev/null and b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/57.png differ diff --git a/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/58.png b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/58.png new file mode 100644 index 000000000..0b031c9cf Binary files /dev/null and b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/58.png differ diff --git a/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/60.png b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/60.png new file mode 100644 index 000000000..43cb176db Binary files /dev/null and b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/60.png differ diff --git a/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/64.png b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/64.png new file mode 100644 index 000000000..edf3a217c Binary files /dev/null and b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/64.png differ diff --git a/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/66.png b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/66.png new file mode 100644 index 000000000..5853a299d Binary files /dev/null and b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/66.png differ diff --git a/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/72.png b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/72.png new file mode 100644 index 000000000..444ecfb64 Binary files /dev/null and b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/72.png differ diff --git a/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/76.png b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/76.png new file mode 100644 index 000000000..197ac0ddc Binary files /dev/null and b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/76.png differ diff --git a/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/80.png b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/80.png new file mode 100644 index 000000000..6b82926fd Binary files /dev/null and b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/80.png differ diff --git a/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/87.png b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/87.png new file mode 100644 index 000000000..350e0ae13 Binary files /dev/null and b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/87.png differ diff --git a/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/88.png b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/88.png new file mode 100644 index 000000000..7e1ebe3ac Binary files /dev/null and b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/88.png differ diff --git a/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/92.png b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/92.png new file mode 100644 index 000000000..47b1720cc Binary files /dev/null and b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/92.png differ diff --git a/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/Contents.json b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..853263563 --- /dev/null +++ b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1 @@ +{"images":[{"size":"60x60","expected-size":"180","filename":"180.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"40x40","expected-size":"80","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"40x40","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"60x60","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"57x57","expected-size":"57","filename":"57.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"29x29","expected-size":"29","filename":"29.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"87","filename":"87.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"57x57","expected-size":"114","filename":"114.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"60","filename":"60.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"1024x1024","filename":"1024.png","expected-size":"1024","idiom":"ios-marketing","folder":"Assets.xcassets/AppIcon.appiconset/","scale":"1x"},{"size":"40x40","expected-size":"80","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"72x72","expected-size":"72","filename":"72.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"76x76","expected-size":"152","filename":"152.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"50x50","expected-size":"100","filename":"100.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"76x76","expected-size":"76","filename":"76.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"29x29","expected-size":"29","filename":"29.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"50x50","expected-size":"50","filename":"50.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"72x72","expected-size":"144","filename":"144.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"40x40","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"83.5x83.5","expected-size":"167","filename":"167.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"20x20","expected-size":"20","filename":"20.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"20x20","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"idiom":"watch","filename":"172.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"38mm","scale":"2x","size":"86x86","expected-size":"172","role":"quickLook"},{"idiom":"watch","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"38mm","scale":"2x","size":"40x40","expected-size":"80","role":"appLauncher"},{"idiom":"watch","filename":"88.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"40mm","scale":"2x","size":"44x44","expected-size":"88","role":"appLauncher"},{"idiom":"watch","filename":"102.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"45mm","scale":"2x","size":"51x51","expected-size":"102","role":"appLauncher"},{"idiom":"watch","filename":"108.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"49mm","scale":"2x","size":"54x54","expected-size":"108","role":"appLauncher"},{"idiom":"watch","filename":"92.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"41mm","scale":"2x","size":"46x46","expected-size":"92","role":"appLauncher"},{"idiom":"watch","filename":"100.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"44mm","scale":"2x","size":"50x50","expected-size":"100","role":"appLauncher"},{"idiom":"watch","filename":"196.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"42mm","scale":"2x","size":"98x98","expected-size":"196","role":"quickLook"},{"idiom":"watch","filename":"216.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"44mm","scale":"2x","size":"108x108","expected-size":"216","role":"quickLook"},{"idiom":"watch","filename":"234.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"45mm","scale":"2x","size":"117x117","expected-size":"234","role":"quickLook"},{"idiom":"watch","filename":"258.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"49mm","scale":"2x","size":"129x129","expected-size":"258","role":"quickLook"},{"idiom":"watch","filename":"48.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"38mm","scale":"2x","size":"24x24","expected-size":"48","role":"notificationCenter"},{"idiom":"watch","filename":"55.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"42mm","scale":"2x","size":"27.5x27.5","expected-size":"55","role":"notificationCenter"},{"idiom":"watch","filename":"66.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"45mm","scale":"2x","size":"33x33","expected-size":"66","role":"notificationCenter"},{"size":"29x29","expected-size":"87","filename":"87.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"watch","role":"companionSettings","scale":"3x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"watch","role":"companionSettings","scale":"2x"},{"size":"1024x1024","expected-size":"1024","filename":"1024.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"watch-marketing","scale":"1x"},{"size":"128x128","expected-size":"128","filename":"128.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"256x256","expected-size":"256","filename":"256.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"128x128","expected-size":"256","filename":"256.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"},{"size":"256x256","expected-size":"512","filename":"512.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"},{"size":"32x32","expected-size":"32","filename":"32.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"512x512","expected-size":"512","filename":"512.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"16x16","expected-size":"16","filename":"16.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"16x16","expected-size":"32","filename":"32.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"},{"size":"32x32","expected-size":"64","filename":"64.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"},{"size":"512x512","expected-size":"1024","filename":"1024.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"}]} diff --git a/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/Contents.json b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/Contents.json new file mode 100644 index 000000000..2d92bd53f --- /dev/null +++ b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Images.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Info.plist b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Info.plist new file mode 100644 index 000000000..3e91ca4a0 --- /dev/null +++ b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/Info.plist @@ -0,0 +1,61 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + RunAnywhere + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleSignature + ???? + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSAllowsLocalNetworking + + + NSLocationWhenInUseUsageDescription + + NSMicrophoneUsageDescription + RunAnywhere needs access to your microphone for speech-to-text transcription. + NSSpeechRecognitionUsageDescription + RunAnywhere uses on-device speech recognition to transcribe your voice. + RCTNewArchEnabled + + UIAppFonts + + Ionicons.ttf + + UILaunchStoryboardName + LaunchScreen + UIRequiredDeviceCapabilities + + arm64 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + + diff --git a/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/LaunchScreen.storyboard b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/LaunchScreen.storyboard new file mode 100644 index 000000000..64db951bd --- /dev/null +++ b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/LaunchScreen.storyboard @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/NativeAudioModule.m b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/NativeAudioModule.m new file mode 100644 index 000000000..ae0795b66 --- /dev/null +++ b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/NativeAudioModule.m @@ -0,0 +1,52 @@ +#import + +@interface RCT_EXTERN_MODULE(NativeAudioModule, NSObject) + +// Recording +RCT_EXTERN_METHOD(startRecording:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(stopRecording:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(cancelRecording:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(getAudioLevel:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) + +// Playback +RCT_EXTERN_METHOD(playAudio:(NSString *)filePath + withResolver:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(stopPlayback:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(pausePlayback:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(resumePlayback:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(getPlaybackStatus:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(setVolume:(float)volume + withResolver:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) + +// System TTS +RCT_EXTERN_METHOD(speak:(NSString *)text + withRate:(float)rate + withPitch:(float)pitch + withResolver:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(stopSpeaking:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(isSpeaking:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) + +@end diff --git a/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/NativeAudioModule.swift b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/NativeAudioModule.swift new file mode 100644 index 000000000..1103538a5 --- /dev/null +++ b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/NativeAudioModule.swift @@ -0,0 +1,288 @@ +import Foundation +import AVFoundation +import React + +/// Native iOS Audio Module for recording, playback, and TTS +/// Uses AVFoundation directly - compatible with New Architecture +@objc(NativeAudioModule) +class NativeAudioModule: NSObject, AVSpeechSynthesizerDelegate { + + private var audioEngine: AVAudioEngine? + private var audioPlayer: AVAudioPlayer? + private var audioRecorder: AVAudioRecorder? + private var recordingURL: URL? + private var pcmBuffer: [Float] = [] + private var isRecording = false + + // System TTS + private var speechSynthesizer: AVSpeechSynthesizer? + private var ttsResolve: RCTPromiseResolveBlock? + private var ttsReject: RCTPromiseRejectBlock? + private var isSpeaking = false + + override init() { + super.init() + speechSynthesizer = AVSpeechSynthesizer() + speechSynthesizer?.delegate = self + } + + @objc static func requiresMainQueueSetup() -> Bool { + return false + } + + // MARK: - Audio Recording + + @objc(startRecording:withRejecter:) + func startRecording(resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { + DispatchQueue.main.async { + self.startRecordingImpl(resolve: resolve, reject: reject) + } + } + + private func startRecordingImpl(resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { + // Set up audio session + let audioSession = AVAudioSession.sharedInstance() + do { + try audioSession.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker, .allowBluetooth]) + try audioSession.setActive(true) + } catch { + reject("AUDIO_SESSION_ERROR", "Failed to configure audio session: \(error.localizedDescription)", error) + return + } + + // Create recording URL + let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + let timestamp = Int(Date().timeIntervalSince1970 * 1000) + recordingURL = documentsPath.appendingPathComponent("recording_\(timestamp).wav") + + // Recording settings for WAV format at 16kHz mono (optimal for STT) + let settings: [String: Any] = [ + AVFormatIDKey: Int(kAudioFormatLinearPCM), + AVSampleRateKey: 16000.0, + AVNumberOfChannelsKey: 1, + AVLinearPCMBitDepthKey: 16, + AVLinearPCMIsFloatKey: false, + AVLinearPCMIsBigEndianKey: false, + AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue + ] + + do { + audioRecorder = try AVAudioRecorder(url: recordingURL!, settings: settings) + audioRecorder?.isMeteringEnabled = true + audioRecorder?.prepareToRecord() + audioRecorder?.record() + isRecording = true + + print("[NativeAudioModule] Recording started: \(recordingURL!.path)") + resolve(["status": "recording", "path": recordingURL!.path]) + } catch { + reject("RECORDING_ERROR", "Failed to start recording: \(error.localizedDescription)", error) + } + } + + @objc(stopRecording:withRejecter:) + func stopRecording(resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { + guard isRecording, let recorder = audioRecorder, let url = recordingURL else { + reject("NOT_RECORDING", "No recording in progress", nil) + return + } + + recorder.stop() + isRecording = false + + // Get file info + if FileManager.default.fileExists(atPath: url.path) { + do { + let attributes = try FileManager.default.attributesOfItem(atPath: url.path) + let fileSize = attributes[.size] as? Int64 ?? 0 + print("[NativeAudioModule] Recording stopped: \(url.path), size: \(fileSize) bytes") + resolve([ + "status": "stopped", + "path": url.path, + "fileSize": fileSize + ]) + } catch { + resolve([ + "status": "stopped", + "path": url.path, + "fileSize": 0 + ]) + } + } else { + reject("FILE_NOT_FOUND", "Recording file not found", nil) + } + } + + @objc(cancelRecording:withRejecter:) + func cancelRecording(resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { + audioRecorder?.stop() + isRecording = false + + if let url = recordingURL { + try? FileManager.default.removeItem(at: url) + } + + recordingURL = nil + resolve(["status": "cancelled"]) + } + + @objc(getAudioLevel:withRejecter:) + func getAudioLevel(resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { + guard let recorder = audioRecorder, isRecording else { + resolve(["level": 0.0]) + return + } + + recorder.updateMeters() + let averagePower = recorder.averagePower(forChannel: 0) + // Convert dB to linear scale (0-1) + let level = pow(10, averagePower / 20) + resolve(["level": min(1.0, max(0.0, level))]) + } + + // MARK: - Audio Playback + + @objc(playAudio:withResolver:withRejecter:) + func playAudio(filePath: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { + let url = URL(fileURLWithPath: filePath) + + guard FileManager.default.fileExists(atPath: filePath) else { + reject("FILE_NOT_FOUND", "Audio file not found: \(filePath)", nil) + return + } + + do { + // Set up audio session for playback + let audioSession = AVAudioSession.sharedInstance() + try audioSession.setCategory(.playback, mode: .default) + try audioSession.setActive(true) + + audioPlayer = try AVAudioPlayer(contentsOf: url) + audioPlayer?.prepareToPlay() + audioPlayer?.play() + + print("[NativeAudioModule] Playing audio: \(filePath)") + resolve([ + "status": "playing", + "duration": audioPlayer?.duration ?? 0 + ]) + } catch { + reject("PLAYBACK_ERROR", "Failed to play audio: \(error.localizedDescription)", error) + } + } + + @objc(stopPlayback:withRejecter:) + func stopPlayback(resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { + audioPlayer?.stop() + audioPlayer = nil + resolve(["status": "stopped"]) + } + + @objc(pausePlayback:withRejecter:) + func pausePlayback(resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { + audioPlayer?.pause() + resolve(["status": "paused"]) + } + + @objc(resumePlayback:withRejecter:) + func resumePlayback(resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { + audioPlayer?.play() + resolve(["status": "playing"]) + } + + @objc(getPlaybackStatus:withRejecter:) + func getPlaybackStatus(resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { + guard let player = audioPlayer else { + resolve([ + "isPlaying": false, + "currentTime": 0, + "duration": 0 + ]) + return + } + + resolve([ + "isPlaying": player.isPlaying, + "currentTime": player.currentTime, + "duration": player.duration + ]) + } + + @objc(setVolume:withResolver:withRejecter:) + func setVolume(volume: Float, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { + audioPlayer?.volume = volume + resolve(["volume": volume]) + } + + // MARK: - System TTS (AVSpeechSynthesizer) + + @objc(speak:withRate:withPitch:withResolver:withRejecter:) + func speak(text: String, rate: Float, pitch: Float, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { + // Stop any ongoing speech + speechSynthesizer?.stopSpeaking(at: .immediate) + + // Set up audio session + do { + let audioSession = AVAudioSession.sharedInstance() + try audioSession.setCategory(.playback, mode: .default) + try audioSession.setActive(true) + } catch { + reject("AUDIO_SESSION_ERROR", "Failed to configure audio session: \(error.localizedDescription)", error) + return + } + + // Create utterance + let utterance = AVSpeechUtterance(string: text) + + // Rate: AVSpeechUtterance rate is 0.0 to 1.0, with 0.5 being normal + // User rate is typically 0.5 to 2.0, so we map it + let mappedRate = min(1.0, max(0.0, (rate - 0.5) / 1.5 * 0.5 + 0.5)) + utterance.rate = Float(mappedRate) + + // Pitch: AVSpeechUtterance pitch is 0.5 to 2.0, with 1.0 being normal + utterance.pitchMultiplier = min(2.0, max(0.5, pitch)) + + // Use default voice for the device's language + utterance.voice = AVSpeechSynthesisVoice(language: AVSpeechSynthesisVoice.currentLanguageCode()) + + // Store callbacks for delegate + ttsResolve = resolve + ttsReject = reject + isSpeaking = true + + print("[NativeAudioModule] Speaking: \(text.prefix(50))... rate: \(mappedRate), pitch: \(pitch)") + speechSynthesizer?.speak(utterance) + } + + @objc(stopSpeaking:withRejecter:) + func stopSpeaking(resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { + speechSynthesizer?.stopSpeaking(at: .immediate) + isSpeaking = false + ttsResolve = nil + ttsReject = nil + resolve(["status": "stopped"]) + } + + @objc(isSpeaking:withRejecter:) + func checkIsSpeaking(resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { + resolve(["isSpeaking": speechSynthesizer?.isSpeaking ?? false]) + } + + // MARK: - AVSpeechSynthesizerDelegate + + func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) { + print("[NativeAudioModule] Speech finished") + isSpeaking = false + ttsResolve?(["status": "finished"]) + ttsResolve = nil + ttsReject = nil + } + + func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) { + print("[NativeAudioModule] Speech cancelled") + isSpeaking = false + ttsResolve?(["status": "cancelled"]) + ttsResolve = nil + ttsReject = nil + } +} diff --git a/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/PrivacyInfo.xcprivacy b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/PrivacyInfo.xcprivacy new file mode 100644 index 000000000..bad327615 --- /dev/null +++ b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/PrivacyInfo.xcprivacy @@ -0,0 +1,37 @@ + + + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + C617.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategorySystemBootTime + NSPrivacyAccessedAPITypeReasons + + 35F9.1 + + + + NSPrivacyCollectedDataTypes + + NSPrivacyTracking + + + diff --git a/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/RunAnywhereAI-Bridging-Header.h b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/RunAnywhereAI-Bridging-Header.h new file mode 100644 index 000000000..5415f0278 --- /dev/null +++ b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAI/RunAnywhereAI-Bridging-Header.h @@ -0,0 +1,9 @@ +// +// RunAnywhereAI-Bridging-Header.h +// RunAnywhereAI +// +// Bridging header for Swift to access Objective-C modules +// + +#import +#import diff --git a/examples/react-native/RunAnywhereAI/ios/RunAnywhereAITests/Info.plist b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAITests/Info.plist new file mode 100644 index 000000000..ba72822e8 --- /dev/null +++ b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAITests/Info.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + BNDL + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1 + + diff --git a/examples/react-native/RunAnywhereAI/ios/RunAnywhereAITests/RunAnywhereAITempTests.m b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAITests/RunAnywhereAITempTests.m new file mode 100644 index 000000000..d0a143467 --- /dev/null +++ b/examples/react-native/RunAnywhereAI/ios/RunAnywhereAITests/RunAnywhereAITempTests.m @@ -0,0 +1,66 @@ +#import +#import + +#import +#import + +#define TIMEOUT_SECONDS 600 +#define TEXT_TO_LOOK_FOR @"Welcome to React" + +@interface RunAnywhereAITests : XCTestCase + +@end + +@implementation RunAnywhereAITests + +- (BOOL)findSubviewInView:(UIView *)view matching:(BOOL (^)(UIView *view))test +{ + if (test(view)) { + return YES; + } + for (UIView *subview in [view subviews]) { + if ([self findSubviewInView:subview matching:test]) { + return YES; + } + } + return NO; +} + +- (void)testRendersWelcomeScreen +{ + UIViewController *vc = [[[RCTSharedApplication() delegate] window] rootViewController]; + NSDate *date = [NSDate dateWithTimeIntervalSinceNow:TIMEOUT_SECONDS]; + BOOL foundElement = NO; + + __block NSString *redboxError = nil; +#ifdef DEBUG + RCTSetLogFunction( + ^(RCTLogLevel level, RCTLogSource source, NSString *fileName, NSNumber *lineNumber, NSString *message) { + if (level >= RCTLogLevelError) { + redboxError = message; + } + }); +#endif + + while ([date timeIntervalSinceNow] > 0 && !foundElement && !redboxError) { + [[NSRunLoop mainRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.1]]; + [[NSRunLoop mainRunLoop] runMode:NSRunLoopCommonModes beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.1]]; + + foundElement = [self findSubviewInView:vc.view + matching:^BOOL(UIView *view) { + if ([view.accessibilityLabel isEqualToString:TEXT_TO_LOOK_FOR]) { + return YES; + } + return NO; + }]; + } + +#ifdef DEBUG + RCTSetLogFunction(RCTDefaultLogFunction); +#endif + + XCTAssertNil(redboxError, @"RedBox error: %@", redboxError); + XCTAssertTrue(foundElement, @"Couldn't find element with text '%@' in %d seconds", TEXT_TO_LOOK_FOR, TIMEOUT_SECONDS); +} + +@end diff --git a/examples/react-native/RunAnywhereAI/knip.json b/examples/react-native/RunAnywhereAI/knip.json new file mode 100644 index 000000000..548e1f523 --- /dev/null +++ b/examples/react-native/RunAnywhereAI/knip.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://unpkg.com/knip@5/schema.json", + "entry": [ + "App.tsx", + "index.js", + "src/theme/index.ts" + ], + "project": [ + "src/**/*.{ts,tsx}" + ], + "ignore": [ + "node_modules/**", + "android/**", + "ios/**", + "**/__tests__/**", + "**/*.test.ts", + "**/*.test.tsx" + ], + "ignoreDependencies": [ + "@react-native-community/cli", + "@react-native-community/cli-platform-android", + "@react-native-community/cli-platform-ios", + "@react-native/typescript-config", + "react-native-monorepo-config", + "@babel/runtime", + "react-native-nitro-modules", + "runanywhere-react-native", + "rn-fetch-blob", + "@react-navigation/native-stack", + "react-native-audio-recorder-player" + ], + "ignoreBinaries": [ + "jest", + "pod", + "watchman" + ], + "rules": { + "files": "error", + "exports": "warn", + "types": "warn", + "unlisted": "off", + "binaries": "off", + "unresolved": "off", + "duplicates": "off" + } +} diff --git a/examples/react-native/RunAnywhereAI/metro.config.js b/examples/react-native/RunAnywhereAI/metro.config.js new file mode 100644 index 000000000..5404366b7 --- /dev/null +++ b/examples/react-native/RunAnywhereAI/metro.config.js @@ -0,0 +1,28 @@ +const path = require('path'); +const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config'); + +// Path to the SDK package (symlinked via node_modules) +const sdkPath = path.resolve(__dirname, '../../../sdk/runanywhere-react-native'); + +/** + * Metro configuration + * https://reactnative.dev/docs/metro + * + * @type {import('metro-config').MetroConfig} + */ +const config = { + watchFolders: [sdkPath], + resolver: { + // Allow Metro to resolve modules from the SDK + nodeModulesPaths: [ + path.resolve(__dirname, 'node_modules'), + path.resolve(sdkPath, 'node_modules'), + ], + // Don't hoist packages from the SDK - ensure local node_modules takes precedence + disableHierarchicalLookup: false, + // Ensure symlinks are followed + unstable_enableSymlinks: true, + }, +}; + +module.exports = mergeConfig(getDefaultConfig(__dirname), config); diff --git a/examples/react-native/RunAnywhereAI/package-lock.json b/examples/react-native/RunAnywhereAI/package-lock.json new file mode 100644 index 000000000..f66fde1ca --- /dev/null +++ b/examples/react-native/RunAnywhereAI/package-lock.json @@ -0,0 +1,11850 @@ +{ + "name": "runanywhere-ai-example", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "runanywhere-ai-example", + "version": "0.1.0", + "dependencies": { + "@react-native-async-storage/async-storage": "^2.2.0", + "@react-navigation/bottom-tabs": "^7.8.11", + "@react-navigation/native": "^7.1.24", + "@react-navigation/native-stack": "^7.8.5", + "@runanywhere/core": "file:../../../sdk/runanywhere-react-native/packages/core", + "@runanywhere/llamacpp": "file:../../../sdk/runanywhere-react-native/packages/llamacpp", + "@runanywhere/onnx": "file:../../../sdk/runanywhere-react-native/packages/onnx", + "react": "19.2.0", + "react-native": "0.83.1", + "react-native-audio-recorder-player": "^3.6.14", + "react-native-fs": "^2.20.0", + "react-native-gesture-handler": "~2.30.0", + "react-native-live-audio-stream": "^1.1.1", + "react-native-nitro-modules": "^0.31.10", + "react-native-permissions": "^5.4.4", + "react-native-reanimated": "~4.2.1", + "react-native-safe-area-context": "~5.6.2", + "react-native-screens": "~4.19.0", + "react-native-sound": "^0.13.0", + "react-native-tts": "^4.1.1", + "react-native-vector-icons": "^10.1.0", + "react-native-worklets": "0.7.1", + "react-native-zip-archive": "^6.1.2", + "rn-fetch-blob": "^0.12.0", + "zustand": "^5.0.0" + }, + "devDependencies": { + "@babel/core": "^7.25.2", + "@babel/runtime": "^7.25.0", + "@react-native-community/cli": "latest", + "@react-native-community/cli-platform-android": "latest", + "@react-native-community/cli-platform-ios": "latest", + "@react-native/babel-preset": "0.83.1", + "@react-native/eslint-config": "0.83.1", + "@react-native/metro-config": "0.83.1", + "@react-native/typescript-config": "0.83.1", + "@types/react": "~19.1.0", + "@types/react-native-vector-icons": "^6.4.18", + "@typescript-eslint/eslint-plugin": "^7.18.0", + "@typescript-eslint/parser": "^7.18.0", + "eslint": "^8.57.0", + "eslint-config-prettier": "^9.0.0", + "eslint-plugin-prettier": "^5.0.1", + "eslint-plugin-unused-imports": "^4.3.0", + "knip": "^5.76.0", + "prettier": "^3.3.2", + "react-native-monorepo-config": "^0.3.0", + "typescript": "~5.9.2" + }, + "engines": { + "node": ">=18" + } + }, + "../../../sdk/runanywhere-react-native/packages/core": { + "name": "@runanywhere/core", + "version": "0.17.6", + "license": "MIT", + "devDependencies": { + "@types/react": "~19.1.0", + "nitrogen": "^0.31.10", + "react-native-nitro-modules": "^0.31.10", + "typescript": "~5.9.2" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-native": ">=0.74.0", + "react-native-blob-util": ">=0.19.0", + "react-native-device-info": ">=11.0.0", + "react-native-fs": ">=2.20.0", + "react-native-nitro-modules": ">=0.31.3", + "react-native-zip-archive": ">=6.1.0" + }, + "peerDependenciesMeta": { + "react-native-blob-util": { + "optional": true + }, + "react-native-device-info": { + "optional": true + }, + "react-native-fs": { + "optional": true + }, + "react-native-zip-archive": { + "optional": true + } + } + }, + "../../../sdk/runanywhere-react-native/packages/llamacpp": { + "name": "@runanywhere/llamacpp", + "version": "0.17.6", + "license": "MIT", + "devDependencies": { + "nitrogen": "^0.31.10", + "react-native-nitro-modules": "^0.31.10", + "typescript": "~5.9.2" + }, + "peerDependencies": { + "@runanywhere/core": ">=0.16.0", + "react": ">=18.0.0", + "react-native": ">=0.74.0", + "react-native-nitro-modules": ">=0.31.3" + } + }, + "../../../sdk/runanywhere-react-native/packages/onnx": { + "name": "@runanywhere/onnx", + "version": "0.17.6", + "license": "MIT", + "devDependencies": { + "nitrogen": "^0.31.10", + "react-native-nitro-modules": "^0.31.10", + "typescript": "~5.9.2" + }, + "peerDependencies": { + "@runanywhere/core": ">=0.16.0", + "react": ">=18.0.0", + "react-native": ">=0.74.0", + "react-native-nitro-modules": ">=0.31.3" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", + "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz", + "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", + "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/eslint-parser": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.28.6.tgz", + "integrity": "sha512-QGmsKi2PBO/MHSQk+AAgA9R6OHQr+VqnniFE0eMWZcVcfBZoA2dKn2hUsl3Csg/Plt9opRUWdY7//VXsrIlEiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nicolo-ribaudo/eslint-scope-5-internals": "5.1.1-v1", + "eslint-visitor-keys": "^2.1.0", + "semver": "^6.3.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || >=14.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0", + "eslint": "^7.5.0 || ^8.0.0 || ^9.0.0" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz", + "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz", + "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/helper-replace-supers": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/traverse": "^7.28.6", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.28.5.tgz", + "integrity": "sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "regexpu-core": "^6.3.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.5.tgz", + "integrity": "sha512-uJnGFcPsWQK8fvjgGP5LZUZZsYGIoPeRjSF5PGwrelYgq7Q15/Ft9NGFp1zglwgIv//W0uG4BevRuSJRyylZPg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-plugin-utils": "^7.27.1", + "debug": "^4.4.1", + "lodash.debounce": "^4.0.8", + "resolve": "^1.22.10" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", + "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", + "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz", + "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-wrap-function": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", + "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", + "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.6.tgz", + "integrity": "sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", + "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.6" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-proposal-export-default-from": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-export-default-from/-/plugin-proposal-export-default-from-7.27.1.tgz", + "integrity": "sha512-hjlsMBl1aJc5lp8MoCDEZCiYzlgdRAShOjAfRw6X+GlpLpUPU7c3XNLsKFZbQk/1cRzBlJ7CXg3xJAJMrFa1Uw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-dynamic-import": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", + "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-export-default-from": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-default-from/-/plugin-syntax-export-default-from-7.28.6.tgz", + "integrity": "sha512-Svlx1fjJFnNz0LZeUaybRukSxZI3KkpApUmIRzEdXC5k8ErTOz0OD0kNrICi5Vc3GlpP5ZCeRyRO+mfWTSz+iQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-flow": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.28.6.tgz", + "integrity": "sha512-D+OrJumc9McXNEBI/JmFnc/0uCM2/Y3PEBG3gfV3QIYkKv5pvnpzFrl1kYCrcHJP8nOeFB/SHi1IHz29pNGuew==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", + "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", + "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", + "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.28.6.tgz", + "integrity": "sha512-9knsChgsMzBV5Yh3kkhrZNxH3oCYAfMBkNNaVN4cP2RVlFPe8wYdwwcnOsAbkdDoV9UjFtOXWrWB52M8W4jNeA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-remap-async-to-generator": "^7.27.1", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.28.6.tgz", + "integrity": "sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-remap-async-to-generator": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.6.tgz", + "integrity": "sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz", + "integrity": "sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz", + "integrity": "sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-globals": "^7.28.0", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-replace-supers": "^7.28.6", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-computed-properties": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.28.6.tgz", + "integrity": "sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/template": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz", + "integrity": "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-flow-strip-types": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.27.1.tgz", + "integrity": "sha512-G5eDKsu50udECw7DL2AcsysXiQyB7Nfg521t2OAJ4tbfTJ27doHLeF/vlI1NZGlLdbb/v+ibvtL1YBQqYOwJGg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-syntax-flow": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", + "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-function-name": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz", + "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz", + "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.6.tgz", + "integrity": "sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz", + "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.27.1.tgz", + "integrity": "sha512-SstR5JYy8ddZvD6MhV0tM/j16Qds4mIpJTOd1Yu9J9pJjH93bxHECF7pgtc28XvkzTD6Pxcm/0Z73Hvk7kb3Ng==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz", + "integrity": "sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-numeric-separator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.28.6.tgz", + "integrity": "sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.6.tgz", + "integrity": "sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-transform-destructuring": "^7.28.5", + "@babel/plugin-transform-parameters": "^7.27.7", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.28.6.tgz", + "integrity": "sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.6.tgz", + "integrity": "sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.27.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz", + "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.28.6.tgz", + "integrity": "sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.28.6.tgz", + "integrity": "sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-display-name": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.28.0.tgz", + "integrity": "sha512-D6Eujc2zMxKjfa4Zxl4GHMsmhKKZ9VpcqIchJLvwTxad9zWIYulwYItBovpDOoNLISpcZSXoDJ5gaGbQUDqViA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.28.6.tgz", + "integrity": "sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-syntax-jsx": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regenerator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.28.6.tgz", + "integrity": "sha512-eZhoEZHYQLL5uc1gS5e9/oTknS0sSSAtd5TkKMUp3J+S/CaUjagc0kOUPsEbDmMeva0nC3WWl4SxVY6+OBuxfw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-runtime": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.28.5.tgz", + "integrity": "sha512-20NUVgOrinudkIBzQ2bNxP08YpKprUkRTiRSd2/Z5GOdPImJGkoN4Z7IQe1T5AdyKI1i5L6RBmluqdSzvaq9/w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "babel-plugin-polyfill-corejs2": "^0.4.14", + "babel-plugin-polyfill-corejs3": "^0.13.0", + "babel-plugin-polyfill-regenerator": "^0.6.5", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", + "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-spread": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.28.6.tgz", + "integrity": "sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-sticky-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz", + "integrity": "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", + "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.6.tgz", + "integrity": "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz", + "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.27.1.tgz", + "integrity": "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.27.1", + "@babel/plugin-transform-typescript": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz", + "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse--for-generate-function-map": { + "name": "@babel/traverse", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz", + "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", + "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@egjs/hammerjs": { + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz", + "integrity": "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==", + "license": "MIT", + "dependencies": { + "@types/hammerjs": "^2.0.36" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", + "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "devOptional": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "devOptional": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@isaacs/ttlcache": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-1.4.1.tgz", + "integrity": "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/create-cache-key-function": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/create-cache-key-function/-/create-cache-key-function-29.7.0.tgz", + "integrity": "sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA==", + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", + "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@nicolo-ribaudo/eslint-scope-5-internals": { + "version": "5.1.1-v1", + "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz", + "integrity": "sha512-54/JRvkLIzzDWshCWfuhadfrfZVPiElY8Fcgmg1HroEly/EDSszzhBAsarCux+D/kOslTRquNzuyGSmUSTTHGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-scope": "5.1.1" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@oxc-resolver/binding-android-arm-eabi": { + "version": "11.16.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.16.3.tgz", + "integrity": "sha512-CVyWHu6ACDqDcJxR4nmGiG8vDF4TISJHqRNzac5z/gPQycs/QrP/1pDsJBy0MD7jSw8nVq2E5WqeHQKabBG/Jg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@oxc-resolver/binding-android-arm64": { + "version": "11.16.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.16.3.tgz", + "integrity": "sha512-tTIoB7plLeh2o6Ay7NnV5CJb6QUXdxI7Shnsp2ECrLSV81k+oVE3WXYrQSh4ltWL75i0OgU5Bj3bsuyg5SMepw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@oxc-resolver/binding-darwin-arm64": { + "version": "11.16.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.16.3.tgz", + "integrity": "sha512-OXKVH7uwYd3Rbw1s2yJZd6/w+6b01iaokZubYhDAq4tOYArr+YCS+lr81q1hsTPPRZeIsWE+rJLulmf1qHdYZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxc-resolver/binding-darwin-x64": { + "version": "11.16.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.16.3.tgz", + "integrity": "sha512-WwjQ4WdnCxVYZYd3e3oY5XbV3JeLy9pPMK+eQQ2m8DtqUtbxnvPpAYC2Knv/2bS6q5JiktqOVJ2Hfia3OSo0/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxc-resolver/binding-freebsd-x64": { + "version": "11.16.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.16.3.tgz", + "integrity": "sha512-4OHKFGJBBfOnuJnelbCS4eBorI6cj54FUxcZJwEXPeoLc8yzORBoJ2w+fQbwjlQcUUZLEg92uGhKCRiUoqznjg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm-gnueabihf": { + "version": "11.16.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.16.3.tgz", + "integrity": "sha512-OM3W0NLt9u7uKwG/yZbeXABansZC0oZeDF1nKgvcZoRw4/Yak6/l4S0onBfDFeYMY94eYeAt2bl60e30lgsb5A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm-musleabihf": { + "version": "11.16.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.16.3.tgz", + "integrity": "sha512-MRs7D7i1t7ACsAdTuP81gLZES918EpBmiUyEl8fu302yQB+4L7L7z0Ui8BWnthUTQd3nAU9dXvENLK/SqRVH8A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm64-gnu": { + "version": "11.16.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.16.3.tgz", + "integrity": "sha512-0eVYZxSceNqGADzhlV4ZRqkHF0fjWxRXQOB7Qwl5y1gN/XYUDvMfip+ngtzj4dM7zQT4U97hUhJ7PUKSy/JIGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm64-musl": { + "version": "11.16.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.16.3.tgz", + "integrity": "sha512-B1BvLeZbgDdVN0FvU40l5Q7lej8310WlabCBaouk8jY7H7xbI8phtomTtk3Efmevgfy5hImaQJu6++OmcFb2NQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-ppc64-gnu": { + "version": "11.16.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.16.3.tgz", + "integrity": "sha512-q7khglic3Jqak7uDgA3MFnjDeI7krQT595GDZpvFq785fmFYSx8rlTkoHzmhQtUisYtl4XG7WUscwsoidFUI4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-riscv64-gnu": { + "version": "11.16.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.16.3.tgz", + "integrity": "sha512-aFRNmQNPzDgQEbw2s3c8yJYRimacSDI+u9df8rn5nSKzTVitHmbEpZqfxpwNLCKIuLSNmozHR1z1OT+oZVeYqg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-riscv64-musl": { + "version": "11.16.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.16.3.tgz", + "integrity": "sha512-vZI85SvSMADcEL9G1TIrV0Rlkc1fY5Mup0DdlVC5EHPysZB4hXXHpr+h09pjlK5y+5om5foIzDRxE1baUCaWOA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-s390x-gnu": { + "version": "11.16.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.16.3.tgz", + "integrity": "sha512-xiLBnaUlddFEzRHiHiSGEMbkg8EwZY6VD8F+3GfnFsiK3xg/4boaUV2bwXd+nUzl3UDQOMW1QcZJ4jJSb0qiJA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-x64-gnu": { + "version": "11.16.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.16.3.tgz", + "integrity": "sha512-6y0b05wIazJJgwu7yU/AYGFswzQQudYJBOb/otDhiDacp1+6ye8egoxx63iVo9lSpDbipL++54AJQFlcOHCB+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-x64-musl": { + "version": "11.16.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.16.3.tgz", + "integrity": "sha512-RmMgwuMa42c9logS7Pjprf5KCp8J1a1bFiuBFtG9/+yMu0BhY2t+0VR/um7pwtkNFvIQqAVh6gDOg/PnoKRcdQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-openharmony-arm64": { + "version": "11.16.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.16.3.tgz", + "integrity": "sha512-/7AYRkjjW7xu1nrHgWUFy99Duj4/ydOBVaHtODie9/M6fFngo+8uQDFFnzmr4q//sd/cchIerISp/8CQ5TsqIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@oxc-resolver/binding-wasm32-wasi": { + "version": "11.16.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.16.3.tgz", + "integrity": "sha512-urM6aIPbi5di4BSlnpd/TWtDJgG6RD06HvLBuNM+qOYuFtY1/xPbzQ2LanBI2ycpqIoIZwsChyplALwAMdyfCQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@oxc-resolver/binding-win32-arm64-msvc": { + "version": "11.16.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.16.3.tgz", + "integrity": "sha512-QuvLqGKf7frxWHQ5TnrcY0C/hJpANsaez99Q4dAk1hen7lDTD4FBPtBzPnntLFXeaVG3PnSmnVjlv0vMILwU7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oxc-resolver/binding-win32-ia32-msvc": { + "version": "11.16.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-11.16.3.tgz", + "integrity": "sha512-QR/witXK6BmYTlEP8CCjC5fxeG5U9A6a50pNpC1nLnhAcJjtzFG8KcQ5etVy/XvCLiDc7fReaAWRNWtCaIhM8Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oxc-resolver/binding-win32-x64-msvc": { + "version": "11.16.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.16.3.tgz", + "integrity": "sha512-bFuJRKOscsDAEZ/a8BezcTMAe2BQ/OBRfuMLFUuINfTR5qGVcm4a3xBIrQVepBaPxFj16SJdRjGe05vDiwZmFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@pkgr/core": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "node_modules/@react-native-async-storage/async-storage": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz", + "integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==", + "license": "MIT", + "dependencies": { + "merge-options": "^3.0.4" + }, + "peerDependencies": { + "react-native": "^0.0.0-0 || >=0.65 <1.0" + } + }, + "node_modules/@react-native-community/cli": { + "version": "20.1.0", + "resolved": "https://registry.npmjs.org/@react-native-community/cli/-/cli-20.1.0.tgz", + "integrity": "sha512-441WsVtRe4nGJ9OzA+QMU1+22lA6Q2hRWqqIMKD0wjEMLqcSfOZyu2UL9a/yRpL/dRpyUsU4n7AxqKfTKO/Csg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@react-native-community/cli-clean": "20.1.0", + "@react-native-community/cli-config": "20.1.0", + "@react-native-community/cli-doctor": "20.1.0", + "@react-native-community/cli-server-api": "20.1.0", + "@react-native-community/cli-tools": "20.1.0", + "@react-native-community/cli-types": "20.1.0", + "commander": "^9.4.1", + "deepmerge": "^4.3.0", + "execa": "^5.0.0", + "find-up": "^5.0.0", + "fs-extra": "^8.1.0", + "graceful-fs": "^4.1.3", + "picocolors": "^1.1.1", + "prompts": "^2.4.2", + "semver": "^7.5.2" + }, + "bin": { + "rnc-cli": "build/bin.js" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/@react-native-community/cli-clean": { + "version": "20.1.0", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-clean/-/cli-clean-20.1.0.tgz", + "integrity": "sha512-77L4DifWfxAT8ByHnkypge7GBMYpbJAjBGV+toowt5FQSGaTBDcBHCX+FFqFRukD5fH6i8sZ41Gtw+nbfCTTIA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@react-native-community/cli-tools": "20.1.0", + "execa": "^5.0.0", + "fast-glob": "^3.3.2", + "picocolors": "^1.1.1" + } + }, + "node_modules/@react-native-community/cli-config": { + "version": "20.1.0", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-config/-/cli-config-20.1.0.tgz", + "integrity": "sha512-1x9rhLLR/dKKb92Lb5O0l0EmUG08FHf+ZVyVEf9M+tX+p5QIm52MRiy43R0UAZ2jJnFApxRk+N3sxoYK4Dtnag==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@react-native-community/cli-tools": "20.1.0", + "cosmiconfig": "^9.0.0", + "deepmerge": "^4.3.0", + "fast-glob": "^3.3.2", + "joi": "^17.2.1", + "picocolors": "^1.1.1" + } + }, + "node_modules/@react-native-community/cli-config-android": { + "version": "20.1.0", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-config-android/-/cli-config-android-20.1.0.tgz", + "integrity": "sha512-3A01ZDyFeCALzzPcwP/fleHoP3sGNq1UX7FzxkTrOFX8RRL9ntXNXQd27E56VU4BBxGAjAJT4Utw8pcOjJceIA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@react-native-community/cli-tools": "20.1.0", + "fast-glob": "^3.3.2", + "fast-xml-parser": "^4.4.1", + "picocolors": "^1.1.1" + } + }, + "node_modules/@react-native-community/cli-config-apple": { + "version": "20.1.0", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-config-apple/-/cli-config-apple-20.1.0.tgz", + "integrity": "sha512-n6JVs8Q3yxRbtZQOy05ofeb1kGtspGN3SgwPmuaqvURF9fsuS7c4/9up2Kp9C+1D2J1remPJXiZLNGOcJvfpOA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@react-native-community/cli-tools": "20.1.0", + "execa": "^5.0.0", + "fast-glob": "^3.3.2", + "picocolors": "^1.1.1" + } + }, + "node_modules/@react-native-community/cli-doctor": { + "version": "20.1.0", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-doctor/-/cli-doctor-20.1.0.tgz", + "integrity": "sha512-QfJF1GVjA4PBrIT3SJ0vFFIu0km1vwOmLDlOYVqfojajZJ+Dnvl0f94GN1il/jT7fITAxom///XH3/URvi7YTQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@react-native-community/cli-config": "20.1.0", + "@react-native-community/cli-platform-android": "20.1.0", + "@react-native-community/cli-platform-apple": "20.1.0", + "@react-native-community/cli-platform-ios": "20.1.0", + "@react-native-community/cli-tools": "20.1.0", + "command-exists": "^1.2.8", + "deepmerge": "^4.3.0", + "envinfo": "^7.13.0", + "execa": "^5.0.0", + "node-stream-zip": "^1.9.1", + "ora": "^5.4.1", + "picocolors": "^1.1.1", + "semver": "^7.5.2", + "wcwidth": "^1.0.1", + "yaml": "^2.2.1" + } + }, + "node_modules/@react-native-community/cli-doctor/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "devOptional": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@react-native-community/cli-platform-android": { + "version": "20.1.0", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-platform-android/-/cli-platform-android-20.1.0.tgz", + "integrity": "sha512-TeHPDThOwDppQRpndm9kCdRCBI8AMy3HSIQ+iy7VYQXL5BtZ5LfmGdusoj7nVN/ZGn0Lc6Gwts5qowyupXdeKg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@react-native-community/cli-config-android": "20.1.0", + "@react-native-community/cli-tools": "20.1.0", + "execa": "^5.0.0", + "logkitty": "^0.7.1", + "picocolors": "^1.1.1" + } + }, + "node_modules/@react-native-community/cli-platform-apple": { + "version": "20.1.0", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-platform-apple/-/cli-platform-apple-20.1.0.tgz", + "integrity": "sha512-0ih1hrYezSM2cuOlVnwBEFtMwtd8YgpTLmZauDJCv50rIumtkI1cQoOgLoS4tbPCj9U/Vn2a9BFH0DLFOOIacg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@react-native-community/cli-config-apple": "20.1.0", + "@react-native-community/cli-tools": "20.1.0", + "execa": "^5.0.0", + "fast-xml-parser": "^4.4.1", + "picocolors": "^1.1.1" + } + }, + "node_modules/@react-native-community/cli-platform-ios": { + "version": "20.1.0", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-platform-ios/-/cli-platform-ios-20.1.0.tgz", + "integrity": "sha512-XN7Da9z4WsJxtqVtEzY8q2bv22OsvzaFP5zy5+phMWNoJlU4lf7IvBSxqGYMpQ9XhYP7arDw5vmW4W34s06rnA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@react-native-community/cli-platform-apple": "20.1.0" + } + }, + "node_modules/@react-native-community/cli-server-api": { + "version": "20.1.0", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-server-api/-/cli-server-api-20.1.0.tgz", + "integrity": "sha512-Tb415Oh8syXNT2zOzLzFkBXznzGaqKCiaichxKzGCDKg6JGHp3jSuCmcTcaPeYC7oc32n/S3Psw7798r4Q/7lA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@react-native-community/cli-tools": "20.1.0", + "body-parser": "^1.20.3", + "compression": "^1.7.1", + "connect": "^3.6.5", + "errorhandler": "^1.5.1", + "nocache": "^3.0.1", + "open": "^6.2.0", + "pretty-format": "^29.7.0", + "serve-static": "^1.13.1", + "ws": "^6.2.3" + } + }, + "node_modules/@react-native-community/cli-tools": { + "version": "20.1.0", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-tools/-/cli-tools-20.1.0.tgz", + "integrity": "sha512-/YmzHGOkY6Bgrv4OaA1L8rFqsBlQd1EB2/ipAoKPiieV0EcB5PUamUSuNeFU3sBZZTYQCUENwX4wgOHgFUlDnQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@vscode/sudo-prompt": "^9.0.0", + "appdirsjs": "^1.2.4", + "execa": "^5.0.0", + "find-up": "^5.0.0", + "launch-editor": "^2.9.1", + "mime": "^2.4.1", + "ora": "^5.4.1", + "picocolors": "^1.1.1", + "prompts": "^2.4.2", + "semver": "^7.5.2" + } + }, + "node_modules/@react-native-community/cli-tools/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "devOptional": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@react-native-community/cli-types": { + "version": "20.1.0", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-types/-/cli-types-20.1.0.tgz", + "integrity": "sha512-D0kDspcwgbVXyNjwicT7Bb1JgXjijTw1JJd+qxyF/a9+sHv7TU4IchV+gN38QegeXqVyM4Ym7YZIvXMFBmyJqA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "joi": "^17.2.1" + } + }, + "node_modules/@react-native-community/cli/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "devOptional": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@react-native/assets-registry": { + "version": "0.83.1", + "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.83.1.tgz", + "integrity": "sha512-AT7/T6UwQqO39bt/4UL5EXvidmrddXrt0yJa7ENXndAv+8yBzMsZn6fyiax6+ERMt9GLzAECikv3lj22cn2wJA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.4" + } + }, + "node_modules/@react-native/babel-plugin-codegen": { + "version": "0.83.1", + "resolved": "https://registry.npmjs.org/@react-native/babel-plugin-codegen/-/babel-plugin-codegen-0.83.1.tgz", + "integrity": "sha512-VPj8O3pG1ESjZho9WVKxqiuryrotAECPHGF5mx46zLUYNTWR5u9OMUXYk7LeLy+JLWdGEZ2Gn3KoXeFZbuqE+g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.25.3", + "@react-native/codegen": "0.83.1" + }, + "engines": { + "node": ">= 20.19.4" + } + }, + "node_modules/@react-native/babel-preset": { + "version": "0.83.1", + "resolved": "https://registry.npmjs.org/@react-native/babel-preset/-/babel-preset-0.83.1.tgz", + "integrity": "sha512-xI+tbsD4fXcI6PVU4sauRCh0a5fuLQC849SINmU2J5wP8kzKu4Ye0YkGjUW3mfGrjaZcjkWmF6s33jpyd3gdTw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/plugin-proposal-export-default-from": "^7.24.7", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-syntax-export-default-from": "^7.24.7", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-transform-arrow-functions": "^7.24.7", + "@babel/plugin-transform-async-generator-functions": "^7.25.4", + "@babel/plugin-transform-async-to-generator": "^7.24.7", + "@babel/plugin-transform-block-scoping": "^7.25.0", + "@babel/plugin-transform-class-properties": "^7.25.4", + "@babel/plugin-transform-classes": "^7.25.4", + "@babel/plugin-transform-computed-properties": "^7.24.7", + "@babel/plugin-transform-destructuring": "^7.24.8", + "@babel/plugin-transform-flow-strip-types": "^7.25.2", + "@babel/plugin-transform-for-of": "^7.24.7", + "@babel/plugin-transform-function-name": "^7.25.1", + "@babel/plugin-transform-literals": "^7.25.2", + "@babel/plugin-transform-logical-assignment-operators": "^7.24.7", + "@babel/plugin-transform-modules-commonjs": "^7.24.8", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", + "@babel/plugin-transform-numeric-separator": "^7.24.7", + "@babel/plugin-transform-object-rest-spread": "^7.24.7", + "@babel/plugin-transform-optional-catch-binding": "^7.24.7", + "@babel/plugin-transform-optional-chaining": "^7.24.8", + "@babel/plugin-transform-parameters": "^7.24.7", + "@babel/plugin-transform-private-methods": "^7.24.7", + "@babel/plugin-transform-private-property-in-object": "^7.24.7", + "@babel/plugin-transform-react-display-name": "^7.24.7", + "@babel/plugin-transform-react-jsx": "^7.25.2", + "@babel/plugin-transform-react-jsx-self": "^7.24.7", + "@babel/plugin-transform-react-jsx-source": "^7.24.7", + "@babel/plugin-transform-regenerator": "^7.24.7", + "@babel/plugin-transform-runtime": "^7.24.7", + "@babel/plugin-transform-shorthand-properties": "^7.24.7", + "@babel/plugin-transform-spread": "^7.24.7", + "@babel/plugin-transform-sticky-regex": "^7.24.7", + "@babel/plugin-transform-typescript": "^7.25.2", + "@babel/plugin-transform-unicode-regex": "^7.24.7", + "@babel/template": "^7.25.0", + "@react-native/babel-plugin-codegen": "0.83.1", + "babel-plugin-syntax-hermes-parser": "0.32.0", + "babel-plugin-transform-flow-enums": "^0.0.2", + "react-refresh": "^0.14.0" + }, + "engines": { + "node": ">= 20.19.4" + }, + "peerDependencies": { + "@babel/core": "*" + } + }, + "node_modules/@react-native/codegen": { + "version": "0.83.1", + "resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.83.1.tgz", + "integrity": "sha512-FpRxenonwH+c2a5X5DZMKUD7sCudHxB3eSQPgV9R+uxd28QWslyAWrpnJM/Az96AEksHnymDzEmzq2HLX5nb+g==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/parser": "^7.25.3", + "glob": "^7.1.1", + "hermes-parser": "0.32.0", + "invariant": "^2.2.4", + "nullthrows": "^1.1.1", + "yargs": "^17.6.2" + }, + "engines": { + "node": ">= 20.19.4" + }, + "peerDependencies": { + "@babel/core": "*" + } + }, + "node_modules/@react-native/community-cli-plugin": { + "version": "0.83.1", + "resolved": "https://registry.npmjs.org/@react-native/community-cli-plugin/-/community-cli-plugin-0.83.1.tgz", + "integrity": "sha512-FqR1ftydr08PYlRbrDF06eRiiiGOK/hNmz5husv19sK6iN5nHj1SMaCIVjkH/a5vryxEddyFhU6PzO/uf4kOHg==", + "license": "MIT", + "dependencies": { + "@react-native/dev-middleware": "0.83.1", + "debug": "^4.4.0", + "invariant": "^2.2.4", + "metro": "^0.83.3", + "metro-config": "^0.83.3", + "metro-core": "^0.83.3", + "semver": "^7.1.3" + }, + "engines": { + "node": ">= 20.19.4" + }, + "peerDependencies": { + "@react-native-community/cli": "*", + "@react-native/metro-config": "*" + }, + "peerDependenciesMeta": { + "@react-native-community/cli": { + "optional": true + }, + "@react-native/metro-config": { + "optional": true + } + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@react-native/debugger-frontend": { + "version": "0.83.1", + "resolved": "https://registry.npmjs.org/@react-native/debugger-frontend/-/debugger-frontend-0.83.1.tgz", + "integrity": "sha512-01Rn3goubFvPjHXONooLmsW0FLxJDKIUJNOlOS0cPtmmTIx9YIjxhe/DxwHXGk7OnULd7yl3aYy7WlBsEd5Xmg==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 20.19.4" + } + }, + "node_modules/@react-native/debugger-shell": { + "version": "0.83.1", + "resolved": "https://registry.npmjs.org/@react-native/debugger-shell/-/debugger-shell-0.83.1.tgz", + "integrity": "sha512-d+0w446Hxth5OP/cBHSSxOEpbj13p2zToUy6e5e3tTERNJ8ueGlW7iGwGTrSymNDgXXFjErX+dY4P4/3WokPIQ==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.6", + "fb-dotslash": "0.5.8" + }, + "engines": { + "node": ">= 20.19.4" + } + }, + "node_modules/@react-native/dev-middleware": { + "version": "0.83.1", + "resolved": "https://registry.npmjs.org/@react-native/dev-middleware/-/dev-middleware-0.83.1.tgz", + "integrity": "sha512-QJaSfNRzj3Lp7MmlCRgSBlt1XZ38xaBNXypXAp/3H3OdFifnTZOeYOpFmcpjcXYnDqkxetuwZg8VL65SQhB8dg==", + "license": "MIT", + "dependencies": { + "@isaacs/ttlcache": "^1.4.1", + "@react-native/debugger-frontend": "0.83.1", + "@react-native/debugger-shell": "0.83.1", + "chrome-launcher": "^0.15.2", + "chromium-edge-launcher": "^0.2.0", + "connect": "^3.6.5", + "debug": "^4.4.0", + "invariant": "^2.2.4", + "nullthrows": "^1.1.1", + "open": "^7.0.3", + "serve-static": "^1.16.2", + "ws": "^7.5.10" + }, + "engines": { + "node": ">= 20.19.4" + } + }, + "node_modules/@react-native/dev-middleware/node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@react-native/dev-middleware/node_modules/open": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@react-native/dev-middleware/node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@react-native/eslint-config": { + "version": "0.83.1", + "resolved": "https://registry.npmjs.org/@react-native/eslint-config/-/eslint-config-0.83.1.tgz", + "integrity": "sha512-fo3DmFywzkpVZgIji9vR93kN7sSAY122ZIB7VcudgKlmD/YFxJ5Yi+ZNiWYl6aprLexxOWjROgHXNP0B0XaAng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/eslint-parser": "^7.25.1", + "@react-native/eslint-plugin": "0.83.1", + "@typescript-eslint/eslint-plugin": "^8.36.0", + "@typescript-eslint/parser": "^8.36.0", + "eslint-config-prettier": "^8.5.0", + "eslint-plugin-eslint-comments": "^3.2.0", + "eslint-plugin-ft-flow": "^2.0.1", + "eslint-plugin-jest": "^29.0.1", + "eslint-plugin-react": "^7.30.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-native": "^4.0.0" + }, + "engines": { + "node": ">= 20.19.4" + }, + "peerDependencies": { + "eslint": ">=8", + "prettier": ">=2" + } + }, + "node_modules/@react-native/eslint-config/node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.53.0.tgz", + "integrity": "sha512-eEXsVvLPu8Z4PkFibtuFJLJOTAV/nPdgtSjkGoPpddpFk3/ym2oy97jynY6ic2m6+nc5M8SE1e9v/mHKsulcJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.53.0", + "@typescript-eslint/type-utils": "8.53.0", + "@typescript-eslint/utils": "8.53.0", + "@typescript-eslint/visitor-keys": "8.53.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.53.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@react-native/eslint-config/node_modules/@typescript-eslint/parser": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.53.0.tgz", + "integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.53.0", + "@typescript-eslint/types": "8.53.0", + "@typescript-eslint/typescript-estree": "8.53.0", + "@typescript-eslint/visitor-keys": "8.53.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@react-native/eslint-config/node_modules/@typescript-eslint/scope-manager": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.53.0.tgz", + "integrity": "sha512-kWNj3l01eOGSdVBnfAF2K1BTh06WS0Yet6JUgb9Cmkqaz3Jlu0fdVUjj9UI8gPidBWSMqDIglmEXifSgDT/D0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.53.0", + "@typescript-eslint/visitor-keys": "8.53.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@react-native/eslint-config/node_modules/@typescript-eslint/type-utils": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.53.0.tgz", + "integrity": "sha512-BBAUhlx7g4SmcLhn8cnbxoxtmS7hcq39xKCgiutL3oNx1TaIp+cny51s8ewnKMpVUKQUGb41RAUWZ9kxYdovuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.53.0", + "@typescript-eslint/typescript-estree": "8.53.0", + "@typescript-eslint/utils": "8.53.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@react-native/eslint-config/node_modules/@typescript-eslint/types": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.53.0.tgz", + "integrity": "sha512-Bmh9KX31Vlxa13+PqPvt4RzKRN1XORYSLlAE+sO1i28NkisGbTtSLFVB3l7PWdHtR3E0mVMuC7JilWJ99m2HxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@react-native/eslint-config/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.53.0.tgz", + "integrity": "sha512-pw0c0Gdo7Z4xOG987u3nJ8akL9093yEEKv8QTJ+Bhkghj1xyj8cgPaavlr9rq8h7+s6plUJ4QJYw2gCZodqmGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.53.0", + "@typescript-eslint/tsconfig-utils": "8.53.0", + "@typescript-eslint/types": "8.53.0", + "@typescript-eslint/visitor-keys": "8.53.0", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@react-native/eslint-config/node_modules/@typescript-eslint/utils": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.53.0.tgz", + "integrity": "sha512-XDY4mXTez3Z1iRDI5mbRhH4DFSt46oaIFsLg+Zn97+sYrXACziXSQcSelMybnVZ5pa1P6xYkPr5cMJyunM1ZDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.53.0", + "@typescript-eslint/types": "8.53.0", + "@typescript-eslint/typescript-estree": "8.53.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@react-native/eslint-config/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.53.0.tgz", + "integrity": "sha512-LZ2NqIHFhvFwxG0qZeLL9DvdNAHPGCY5dIRwBhyYeU+LfLhcStE1ImjsuTG/WaVh3XysGaeLW8Rqq7cGkPCFvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.53.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@react-native/eslint-config/node_modules/eslint-config-prettier": { + "version": "8.10.2", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.10.2.tgz", + "integrity": "sha512-/IGJ6+Dka158JnP5n5YFMOszjDWrXggGz1LaK/guZq9vZTmniaKlHcsscvkAhn9y4U+BU3JuUdYvtAMcv30y4A==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/@react-native/eslint-config/node_modules/eslint-plugin-jest": { + "version": "29.12.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-29.12.1.tgz", + "integrity": "sha512-Rxo7r4jSANMBkXLICJKS0gjacgyopfNAsoS0e3R9AHnjoKuQOaaPfmsDJPi8UWwygI099OV/K/JhpYRVkxD4AA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^8.0.0" + }, + "engines": { + "node": "^20.12.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@typescript-eslint/eslint-plugin": "^8.0.0", + "eslint": "^8.57.0 || ^9.0.0", + "jest": "*" + }, + "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { + "optional": true + }, + "jest": { + "optional": true + } + } + }, + "node_modules/@react-native/eslint-config/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@react-native/eslint-config/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@react-native/eslint-config/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@react-native/eslint-config/node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/@react-native/eslint-plugin": { + "version": "0.83.1", + "resolved": "https://registry.npmjs.org/@react-native/eslint-plugin/-/eslint-plugin-0.83.1.tgz", + "integrity": "sha512-nKd/FONY8aIIjtjEqI2ScvgJYeblBgdnwseRHlIC+Nm3f3tuOifUrHFtWBJznlrKFJcme31Tl7qiryE2SruLYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.4" + } + }, + "node_modules/@react-native/gradle-plugin": { + "version": "0.83.1", + "resolved": "https://registry.npmjs.org/@react-native/gradle-plugin/-/gradle-plugin-0.83.1.tgz", + "integrity": "sha512-6ESDnwevp1CdvvxHNgXluil5OkqbjkJAkVy7SlpFsMGmVhrSxNAgD09SSRxMNdKsnLtzIvMsFCzyHLsU/S4PtQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.4" + } + }, + "node_modules/@react-native/js-polyfills": { + "version": "0.83.1", + "resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.83.1.tgz", + "integrity": "sha512-qgPpdWn/c5laA+3WoJ6Fak8uOm7CG50nBsLlPsF8kbT7rUHIVB9WaP6+GPsoKV/H15koW7jKuLRoNVT7c3Ht3w==", + "license": "MIT", + "engines": { + "node": ">= 20.19.4" + } + }, + "node_modules/@react-native/metro-babel-transformer": { + "version": "0.83.1", + "resolved": "https://registry.npmjs.org/@react-native/metro-babel-transformer/-/metro-babel-transformer-0.83.1.tgz", + "integrity": "sha512-fqt6DHWX1GBGDKa5WJOjDtPPy2M9lkYVLn59fBeFQ0GXhBRzNbUh8JzWWI/Q2CLDZ2tgKCcwaiXJ1OHWVd2BCQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@react-native/babel-preset": "0.83.1", + "hermes-parser": "0.32.0", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": ">= 20.19.4" + }, + "peerDependencies": { + "@babel/core": "*" + } + }, + "node_modules/@react-native/metro-config": { + "version": "0.83.1", + "resolved": "https://registry.npmjs.org/@react-native/metro-config/-/metro-config-0.83.1.tgz", + "integrity": "sha512-1rjYZf62fCm6QAinHmRAKnJxIypX0VF/zBPd0qWvWABMZugrS0eACuIbk9Wk0StBod4yL8KnwEJyg77ak8xYzQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@react-native/js-polyfills": "0.83.1", + "@react-native/metro-babel-transformer": "0.83.1", + "metro-config": "^0.83.3", + "metro-runtime": "^0.83.3" + }, + "engines": { + "node": ">= 20.19.4" + } + }, + "node_modules/@react-native/normalize-colors": { + "version": "0.83.1", + "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.83.1.tgz", + "integrity": "sha512-84feABbmeWo1kg81726UOlMKAhcQyFXYz2SjRKYkS78QmfhVDhJ2o/ps1VjhFfBz0i/scDwT1XNv9GwmRIghkg==", + "license": "MIT" + }, + "node_modules/@react-native/typescript-config": { + "version": "0.83.1", + "resolved": "https://registry.npmjs.org/@react-native/typescript-config/-/typescript-config-0.83.1.tgz", + "integrity": "sha512-y83qd7fmlZG+EJoOyKEmAXifdjN1csNhcfpyxDvgaIUNO/pw2ws3MV/wp+ERQ8F6JIuAu1zcfyCy1/pEA7tC9g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@react-native/virtualized-lists": { + "version": "0.83.1", + "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.83.1.tgz", + "integrity": "sha512-MdmoAbQUTOdicCocm5XAFDJWsswxk7hxa6ALnm6Y88p01HFML0W593hAn6qOt9q6IM1KbAcebtH6oOd4gcQy8w==", + "license": "MIT", + "dependencies": { + "invariant": "^2.2.4", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": ">= 20.19.4" + }, + "peerDependencies": { + "@types/react": "^19.2.0", + "react": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@react-navigation/bottom-tabs": { + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/@react-navigation/bottom-tabs/-/bottom-tabs-7.10.0.tgz", + "integrity": "sha512-4YPB3cAtt5hwNnR3cpU4c85g1CXd8BJ9Eop1D/hls0zC2rAwbFrTk/jMCSxCvXJzDrYam0cgvcN+jk03jLmkog==", + "license": "MIT", + "dependencies": { + "@react-navigation/elements": "^2.9.5", + "color": "^4.2.3", + "sf-symbols-typescript": "^2.1.0" + }, + "peerDependencies": { + "@react-navigation/native": "^7.1.28", + "react": ">= 18.2.0", + "react-native": "*", + "react-native-safe-area-context": ">= 4.0.0", + "react-native-screens": ">= 4.0.0" + } + }, + "node_modules/@react-navigation/core": { + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/@react-navigation/core/-/core-7.14.0.tgz", + "integrity": "sha512-tMpzskBzVp0E7CRNdNtJIdXjk54Kwe/TF9ViXAef+YFM1kSfGv4e/B2ozfXE+YyYgmh4WavTv8fkdJz1CNyu+g==", + "license": "MIT", + "dependencies": { + "@react-navigation/routers": "^7.5.3", + "escape-string-regexp": "^4.0.0", + "fast-deep-equal": "^3.1.3", + "nanoid": "^3.3.11", + "query-string": "^7.1.3", + "react-is": "^19.1.0", + "use-latest-callback": "^0.2.4", + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "react": ">= 18.2.0" + } + }, + "node_modules/@react-navigation/elements": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.9.5.tgz", + "integrity": "sha512-iHZU8rRN1014Upz73AqNVXDvSMZDh5/ktQ1CMe21rdgnOY79RWtHHBp9qOS3VtqlUVYGkuX5GEw5mDt4tKdl0g==", + "license": "MIT", + "dependencies": { + "color": "^4.2.3", + "use-latest-callback": "^0.2.4", + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "@react-native-masked-view/masked-view": ">= 0.2.0", + "@react-navigation/native": "^7.1.28", + "react": ">= 18.2.0", + "react-native": "*", + "react-native-safe-area-context": ">= 4.0.0" + }, + "peerDependenciesMeta": { + "@react-native-masked-view/masked-view": { + "optional": true + } + } + }, + "node_modules/@react-navigation/native": { + "version": "7.1.28", + "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.1.28.tgz", + "integrity": "sha512-d1QDn+KNHfHGt3UIwOZvupvdsDdiHYZBEj7+wL2yDVo3tMezamYy60H9s3EnNVE1Ae1ty0trc7F2OKqo/RmsdQ==", + "license": "MIT", + "dependencies": { + "@react-navigation/core": "^7.14.0", + "escape-string-regexp": "^4.0.0", + "fast-deep-equal": "^3.1.3", + "nanoid": "^3.3.11", + "use-latest-callback": "^0.2.4" + }, + "peerDependencies": { + "react": ">= 18.2.0", + "react-native": "*" + } + }, + "node_modules/@react-navigation/native-stack": { + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/@react-navigation/native-stack/-/native-stack-7.10.0.tgz", + "integrity": "sha512-kFoQa3qaDKEHLwI95rIhri51DwN/d2Yin/K5T2VhSuL/2vZQjdR//U+Y6MfYUj2PrGJu7pM57RM3elTlvzyPqQ==", + "license": "MIT", + "dependencies": { + "@react-navigation/elements": "^2.9.5", + "color": "^4.2.3", + "sf-symbols-typescript": "^2.1.0", + "warn-once": "^0.1.1" + }, + "peerDependencies": { + "@react-navigation/native": "^7.1.28", + "react": ">= 18.2.0", + "react-native": "*", + "react-native-safe-area-context": ">= 4.0.0", + "react-native-screens": ">= 4.0.0" + } + }, + "node_modules/@react-navigation/routers": { + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/@react-navigation/routers/-/routers-7.5.3.tgz", + "integrity": "sha512-1tJHg4KKRJuQ1/EvJxatrMef3NZXEPzwUIUZ3n1yJ2t7Q97siwRtbynRpQG9/69ebbtiZ8W3ScOZF/OmhvM4Rg==", + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11" + } + }, + "node_modules/@runanywhere/core": { + "resolved": "../../../sdk/runanywhere-react-native/packages/core", + "link": true + }, + "node_modules/@runanywhere/llamacpp": { + "resolved": "../../../sdk/runanywhere-react-native/packages/llamacpp", + "link": true + }, + "node_modules/@runanywhere/onnx": { + "resolved": "../../../sdk/runanywhere-react-native/packages/onnx", + "link": true + }, + "node_modules/@sideway/address": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", + "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", + "devOptional": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@sideway/formula": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", + "devOptional": true, + "license": "BSD-3-Clause" + }, + "node_modules/@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", + "devOptional": true, + "license": "BSD-3-Clause" + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/hammerjs": { + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz", + "integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==", + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/node": { + "version": "25.0.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.9.tgz", + "integrity": "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.1.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.17.tgz", + "integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-native": { + "version": "0.70.19", + "resolved": "https://registry.npmjs.org/@types/react-native/-/react-native-0.70.19.tgz", + "integrity": "sha512-c6WbyCgWTBgKKMESj/8b4w+zWcZSsCforson7UdXtXMecG3MxCinYi6ihhrHVPyUrVzORsvEzK8zg32z4pK6Sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/react-native-vector-icons": { + "version": "6.4.18", + "resolved": "https://registry.npmjs.org/@types/react-native-vector-icons/-/react-native-vector-icons-6.4.18.tgz", + "integrity": "sha512-YGlNWb+k5laTBHd7+uZowB9DpIK3SXUneZqAiKQaj1jnJCZM0x71GDim5JCTMi4IFkhc9m8H/Gm28T5BjyivUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*", + "@types/react-native": "^0.70" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz", + "integrity": "sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/type-utils": "7.18.0", + "@typescript-eslint/utils": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^7.0.0", + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.18.0.tgz", + "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/typescript-estree": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.53.0.tgz", + "integrity": "sha512-Bl6Gdr7NqkqIP5yP9z1JU///Nmes4Eose6L1HwpuVHwScgDPPuEWbUVhvlZmb8hy0vX9syLk5EGNL700WcBlbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.53.0", + "@typescript-eslint/types": "^8.53.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service/node_modules/@typescript-eslint/types": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.53.0.tgz", + "integrity": "sha512-Bmh9KX31Vlxa13+PqPvt4RzKRN1XORYSLlAE+sO1i28NkisGbTtSLFVB3l7PWdHtR3E0mVMuC7JilWJ99m2HxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.18.0.tgz", + "integrity": "sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.53.0.tgz", + "integrity": "sha512-K6Sc0R5GIG6dNoPdOooQ+KtvT5KCKAvTcY8h2rIuul19vxH5OTQk7ArKkd4yTzkw66WnNY0kPPzzcmWA+XRmiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.18.0.tgz", + "integrity": "sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "7.18.0", + "@typescript-eslint/utils": "7.18.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/types": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.18.0.tgz", + "integrity": "sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.18.0.tgz", + "integrity": "sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.18.0.tgz", + "integrity": "sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/typescript-estree": "7.18.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.18.0.tgz", + "integrity": "sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/@vscode/sudo-prompt": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/@vscode/sudo-prompt/-/sudo-prompt-9.3.2.tgz", + "integrity": "sha512-gcXoCN00METUNFeQOFJ+C9xUI0DKB+0EGMVg7wbVYRHBw2Eq3fKisDZOkRdOz3kqXRKOENMfShPOmypw1/8nOw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/anser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/anser/-/anser-1.4.10.tgz", + "integrity": "sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==", + "license": "MIT" + }, + "node_modules/ansi-fragments": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/ansi-fragments/-/ansi-fragments-0.2.1.tgz", + "integrity": "sha512-DykbNHxuXQwUDRv5ibc2b0x7uw7wmwOGLBUd5RmaQ5z8Lhx19vwvKV+FAsM5rEA6dEcHxX+/Ad5s9eF2k2bB+w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "colorette": "^1.0.7", + "slice-ansi": "^2.0.0", + "strip-ansi": "^5.0.0" + } + }, + "node_modules/ansi-fragments/node_modules/ansi-regex": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", + "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-fragments/node_modules/strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^4.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/appdirsjs": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/appdirsjs/-/appdirsjs-1.2.7.tgz", + "integrity": "sha512-Quji6+8kLBC3NnBeo14nPDq0+2jUs5s3/xEye+udFHumHhRk4M7aAMXp/PBJqkKYGuuyR9M/6Dq7d2AViiGmhw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "devOptional": true, + "license": "Python-2.0" + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "license": "MIT" + }, + "node_modules/astral-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-1.0.0.tgz", + "integrity": "sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/async-limiter": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", + "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "license": "MIT", + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.14", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.14.tgz", + "integrity": "sha512-Co2Y9wX854ts6U8gAAPXfn0GmAyctHuK8n0Yhfjd6t30g7yvKjspvvOo9yG+z52PZRgFErt7Ka2pYnXCjLKEpg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.7", + "@babel/helper-define-polyfill-provider": "^0.6.5", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz", + "integrity": "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.5", + "core-js-compat": "^3.43.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.5.tgz", + "integrity": "sha512-ISqQ2frbiNU9vIJkzg7dlPpznPZ4jOiUQ1uSmB0fEHeowtN3COYRsXr/xexn64NpU13P06jc/L5TgiJXOgrbEg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.5" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-syntax-hermes-parser": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.32.0.tgz", + "integrity": "sha512-m5HthL++AbyeEA2FcdwOLfVFvWYECOBObLHNqdR8ceY4TsEdn4LdX2oTvbB2QJSSElE2AWA/b2MXZ/PF/CqLZg==", + "license": "MIT", + "dependencies": { + "hermes-parser": "0.32.0" + } + }, + "node_modules/babel-plugin-transform-flow-enums": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-flow-enums/-/babel-plugin-transform-flow-enums-0.0.2.tgz", + "integrity": "sha512-g4aaCrDDOsWjbm0PUUeVnkcVd6AKJsVc/MbnPhEotEpkeJQP6b8nzewohQi7+QS8UyPehOhGWn0nOwjvWpmMvQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-flow": "^7.12.1" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base-64": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/base-64/-/base-64-0.1.0.tgz", + "integrity": "sha512-Y5gU45svrR5tI2Vt/X9GPd3L0HNIKzGu202EjxrXMpuc2V2CiKgemAbUUsqYmZJvPtCXoUKjNZwBJzsNScUbXA==" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.15", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.15.tgz", + "integrity": "sha512-kX8h7K2srmDyYnXRIppo4AH/wYgzWVCs+eKr3RusRSQ5PvRYoEFmR/I0PbdTjKFAoKqp5+kbxnNTFO9jOfSVJg==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001765", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001765.tgz", + "integrity": "sha512-LWcNtSyZrakjECqmpP4qdg0MMGdN368D7X8XvvAqOcqMv0RxnlqVKZl2V6/mBR68oYMxOZPLw/gO7DuisMHUvQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chrome-launcher": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-0.15.2.tgz", + "integrity": "sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ==", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "*", + "escape-string-regexp": "^4.0.0", + "is-wsl": "^2.2.0", + "lighthouse-logger": "^1.0.0" + }, + "bin": { + "print-chrome-path": "bin/print-chrome-path.js" + }, + "engines": { + "node": ">=12.13.0" + } + }, + "node_modules/chrome-launcher/node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chromium-edge-launcher": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/chromium-edge-launcher/-/chromium-edge-launcher-0.2.0.tgz", + "integrity": "sha512-JfJjUnq25y9yg4FABRRVPmBGWPZZi+AQXT4mxupb67766/0UlhG8PAZCz6xzEMXTbW3CsSoE8PcCWA49n35mKg==", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "*", + "escape-string-regexp": "^4.0.0", + "is-wsl": "^2.2.0", + "lighthouse-logger": "^1.0.0", + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + } + }, + "node_modules/chromium-edge-launcher/node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ci-info": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", + "license": "MIT" + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/colorette": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/command-exists": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/command-exists/-/command-exists-1.2.9.tgz", + "integrity": "sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/connect": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", + "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "finalhandler": "1.1.2", + "parseurl": "~1.3.3", + "utils-merge": "1.0.1" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/connect/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/connect/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, + "node_modules/core-js-compat": { + "version": "3.47.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.47.0.tgz", + "integrity": "sha512-IGfuznZ/n7Kp9+nypamBhvwdwLsW6KC8IOaURw2doAK5e98AG3acVLdh0woOnEqCfUtS+Vu882JE4k/DAm3ItQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/cosmiconfig": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", + "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/dayjs": { + "version": "1.11.19", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decode-uri-component": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", + "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/envinfo": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.21.0.tgz", + "integrity": "sha512-Lw7I8Zp5YKHFCXL7+Dz95g4CcbMEpgvqZNNq3AmlT5XAV6CgAAk6gyAMqn2zjw08K9BHfcNuKrMiCPLByGafow==", + "devOptional": true, + "license": "MIT", + "bin": { + "envinfo": "dist/cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/error-stack-parser": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", + "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", + "license": "MIT", + "dependencies": { + "stackframe": "^1.3.4" + } + }, + "node_modules/errorhandler": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/errorhandler/-/errorhandler-1.5.2.tgz", + "integrity": "sha512-kNAL7hESndBCrWwS72QyV3IVOTrVmj9D062FV5BQswNL5zEdeRmz/WJFyh6Aj/plvvSOrzddkxW57HgkZcR9Fw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "escape-html": "~1.0.3" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/es-abstract": { + "version": "1.24.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", + "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.2.tgz", + "integrity": "sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.1", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "safe-array-concat": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-config-prettier": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.2.tgz", + "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-eslint-comments": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-eslint-comments/-/eslint-plugin-eslint-comments-3.2.0.tgz", + "integrity": "sha512-0jkOl0hfojIHHmEHgmNdqv4fmh7300NdpA9FFpF7zaoLvB/QeXOGNLIo86oAveJFrfB1p05kC8hpEMHM8DwWVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^1.0.5", + "ignore": "^5.0.5" + }, + "engines": { + "node": ">=6.5.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": ">=4.19.1" + } + }, + "node_modules/eslint-plugin-eslint-comments/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/eslint-plugin-ft-flow": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-ft-flow/-/eslint-plugin-ft-flow-2.0.3.tgz", + "integrity": "sha512-Vbsd/b+LYA99jUbsL6viEUWShFaYQt2YQs3QN3f+aeszOhh2sgdcU0mjzDyD4yyBvMc8qy2uwvBBWfMzEX06tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21", + "string-natural-compare": "^3.0.1" + }, + "engines": { + "node": ">=12.22.0" + }, + "peerDependencies": { + "@babel/eslint-parser": "^7.12.0", + "eslint": "^8.1.0" + } + }, + "node_modules/eslint-plugin-prettier": { + "version": "5.5.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz", + "integrity": "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "prettier-linter-helpers": "^1.0.1", + "synckit": "^0.11.12" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-hooks/node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-plugin-react-hooks/node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/eslint-plugin-react-native": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-native/-/eslint-plugin-react-native-4.1.0.tgz", + "integrity": "sha512-QLo7rzTBOl43FvVqDdq5Ql9IoElIuTdjrz9SKAXCvULvBoRZ44JGSkx9z4999ZusCsb4rK3gjS8gOGyeYqZv2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-plugin-react-native-globals": "^0.1.1" + }, + "peerDependencies": { + "eslint": "^3.17.0 || ^4 || ^5 || ^6 || ^7 || ^8" + } + }, + "node_modules/eslint-plugin-react-native-globals": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-native-globals/-/eslint-plugin-react-native-globals-0.1.2.tgz", + "integrity": "sha512-9aEPf1JEpiTjcFAmmyw8eiIXmcNZOqaZyHO77wgm0/dWfT/oxC1SrIq8ET38pMxHYrcB6Uew+TzUVsBeczF88g==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-plugin-react/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-react/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint-plugin-react/node_modules/resolve": { + "version": "2.0.0-next.5", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", + "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-plugin-unused-imports": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.3.0.tgz", + "integrity": "sha512-ZFBmXMGBYfHttdRtOG9nFFpmUvMtbHSjsKrS20vdWdbfiVYsO3yA2SGYy9i9XmZJDfMGBflZGBCm70SEnFQtOA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@typescript-eslint/eslint-plugin": "^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0", + "eslint": "^9.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/eslint-scope/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", + "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "license": "Apache-2.0" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-xml-parser": { + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz", + "integrity": "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^1.1.1" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fb-dotslash": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/fb-dotslash/-/fb-dotslash-0.5.8.tgz", + "integrity": "sha512-XHYLKk9J4BupDxi9bSEhkfss0m+Vr9ChTrjhf9l2iw3jB5C7BnY4GVPoMcqbrTutsKJso6yj2nAB6BI/F2oZaA==", + "license": "(MIT OR Apache-2.0)", + "bin": { + "dotslash": "bin/dotslash" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/fd-package-json": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fd-package-json/-/fd-package-json-2.0.0.tgz", + "integrity": "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "walk-up-path": "^4.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/filter-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz", + "integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/finalhandler": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", + "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "statuses": "~1.5.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/finalhandler/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/flow-enums-runtime": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz", + "integrity": "sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==", + "license": "MIT" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/formatly": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/formatly/-/formatly-0.3.0.tgz", + "integrity": "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "fd-package-json": "^2.0.0" + }, + "bin": { + "formatly": "bin/index.mjs" + }, + "engines": { + "node": ">=18.3.0" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "devOptional": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-compiler": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/hermes-compiler/-/hermes-compiler-0.14.0.tgz", + "integrity": "sha512-clxa193o+GYYwykWVFfpHduCATz8fR5jvU7ngXpfKHj+E9hr9vjLNtdLSEe8MUbObvVexV3wcyxQ00xTPIrB1Q==", + "license": "MIT" + }, + "node_modules/hermes-estree": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.32.0.tgz", + "integrity": "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ==", + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.32.0.tgz", + "integrity": "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.32.0" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-errors/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/hyochan-welcome": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hyochan-welcome/-/hyochan-welcome-1.0.1.tgz", + "integrity": "sha512-WRZNH5grESkOXP/r7xc7TMhO9cUqxaJIuZcQDAjzHWs6viGP+sWtVbiBigxc9YVRrw3hnkESQWwzqg+oOga65A==", + "hasInstallScript": true, + "license": "ISC", + "bin": { + "hyochan-welcome": "bin/hello.js" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/image-size": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz", + "integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==", + "license": "MIT", + "dependencies": { + "queue": "6.0.2" + }, + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=16.x" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-wsl": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-1.1.0.tgz", + "integrity": "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-util/node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-util/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/joi": { + "version": "17.13.3", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", + "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", + "devOptional": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.3.0", + "@hapi/topo": "^5.1.0", + "@sideway/address": "^4.1.5", + "@sideway/formula": "^3.0.1", + "@sideway/pinpoint": "^2.0.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsc-safe-url": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/jsc-safe-url/-/jsc-safe-url-0.2.4.tgz", + "integrity": "sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==", + "license": "0BSD" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "devOptional": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/knip": { + "version": "5.82.0", + "resolved": "https://registry.npmjs.org/knip/-/knip-5.82.0.tgz", + "integrity": "sha512-LNOR/TcauMdJLGZ9jdniIUpt0yy8aG/v8g31UJlb6qBvMNFY31w02hnwS8KMHEGy/X+pfxqsOLMFdm0NAJ3wWg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/webpro" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/knip" + } + ], + "license": "ISC", + "dependencies": { + "@nodelib/fs.walk": "^1.2.3", + "fast-glob": "^3.3.3", + "formatly": "^0.3.0", + "jiti": "^2.6.0", + "js-yaml": "^4.1.1", + "minimist": "^1.2.8", + "oxc-resolver": "^11.15.0", + "picocolors": "^1.1.1", + "picomatch": "^4.0.1", + "smol-toml": "^1.5.2", + "strip-json-comments": "5.0.3", + "zod": "^4.1.11" + }, + "bin": { + "knip": "bin/knip.js", + "knip-bun": "bin/knip-bun.js" + }, + "engines": { + "node": ">=18.18.0" + }, + "peerDependencies": { + "@types/node": ">=18", + "typescript": ">=5.0.4 <7" + } + }, + "node_modules/knip/node_modules/strip-json-comments": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/launch-editor": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.12.0.tgz", + "integrity": "sha512-giOHXoOtifjdHqUamwKq6c49GzBdLjvxrd2D+Q4V6uOHopJv7p9VJxikDsQ/CBXZbEITgUqSVHXLTG3VhPP1Dg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.1.1", + "shell-quote": "^1.8.3" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lighthouse-logger": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-1.4.2.tgz", + "integrity": "sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==", + "license": "Apache-2.0", + "dependencies": { + "debug": "^2.6.9", + "marky": "^1.2.2" + } + }, + "node_modules/lighthouse-logger/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/lighthouse-logger/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.throttle": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", + "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==", + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/logkitty": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/logkitty/-/logkitty-0.7.1.tgz", + "integrity": "sha512-/3ER20CTTbahrCrpYfPn7Xavv9diBROZpoXGVZDWMw4b/X4uuUwAC0ki85tgsdMRONURyIJbcOvS94QsUBYPbQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "ansi-fragments": "^0.2.1", + "dayjs": "^1.8.15", + "yargs": "^15.1.0" + }, + "bin": { + "logkitty": "bin/logkitty.js" + } + }, + "node_modules/logkitty/node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/logkitty/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/logkitty/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/logkitty/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/logkitty/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/logkitty/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/logkitty/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/logkitty/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/logkitty/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/logkitty/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/marky": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz", + "integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==", + "license": "Apache-2.0" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/memoize-one": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", + "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", + "license": "MIT" + }, + "node_modules/merge-options": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz", + "integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==", + "license": "MIT", + "dependencies": { + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/metro": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro/-/metro-0.83.3.tgz", + "integrity": "sha512-+rP+/GieOzkt97hSJ0MrPOuAH/jpaS21ZDvL9DJ35QYRDlQcwzcvUlGUf79AnQxq/2NPiS/AULhhM4TKutIt8Q==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.24.7", + "@babel/core": "^7.25.2", + "@babel/generator": "^7.25.0", + "@babel/parser": "^7.25.3", + "@babel/template": "^7.25.0", + "@babel/traverse": "^7.25.3", + "@babel/types": "^7.25.2", + "accepts": "^1.3.7", + "chalk": "^4.0.0", + "ci-info": "^2.0.0", + "connect": "^3.6.5", + "debug": "^4.4.0", + "error-stack-parser": "^2.0.6", + "flow-enums-runtime": "^0.0.6", + "graceful-fs": "^4.2.4", + "hermes-parser": "0.32.0", + "image-size": "^1.0.2", + "invariant": "^2.2.4", + "jest-worker": "^29.7.0", + "jsc-safe-url": "^0.2.2", + "lodash.throttle": "^4.1.1", + "metro-babel-transformer": "0.83.3", + "metro-cache": "0.83.3", + "metro-cache-key": "0.83.3", + "metro-config": "0.83.3", + "metro-core": "0.83.3", + "metro-file-map": "0.83.3", + "metro-resolver": "0.83.3", + "metro-runtime": "0.83.3", + "metro-source-map": "0.83.3", + "metro-symbolicate": "0.83.3", + "metro-transform-plugins": "0.83.3", + "metro-transform-worker": "0.83.3", + "mime-types": "^2.1.27", + "nullthrows": "^1.1.1", + "serialize-error": "^2.1.0", + "source-map": "^0.5.6", + "throat": "^5.0.0", + "ws": "^7.5.10", + "yargs": "^17.6.2" + }, + "bin": { + "metro": "src/cli.js" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-babel-transformer": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-babel-transformer/-/metro-babel-transformer-0.83.3.tgz", + "integrity": "sha512-1vxlvj2yY24ES1O5RsSIvg4a4WeL7PFXgKOHvXTXiW0deLvQr28ExXj6LjwCCDZ4YZLhq6HddLpZnX4dEdSq5g==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "flow-enums-runtime": "^0.0.6", + "hermes-parser": "0.32.0", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-cache": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-cache/-/metro-cache-0.83.3.tgz", + "integrity": "sha512-3jo65X515mQJvKqK3vWRblxDEcgY55Sk3w4xa6LlfEXgQ9g1WgMh9m4qVZVwgcHoLy0a2HENTPCCX4Pk6s8c8Q==", + "license": "MIT", + "dependencies": { + "exponential-backoff": "^3.1.1", + "flow-enums-runtime": "^0.0.6", + "https-proxy-agent": "^7.0.5", + "metro-core": "0.83.3" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-cache-key": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-cache-key/-/metro-cache-key-0.83.3.tgz", + "integrity": "sha512-59ZO049jKzSmvBmG/B5bZ6/dztP0ilp0o988nc6dpaDsU05Cl1c/lRf+yx8m9WW/JVgbmfO5MziBU559XjI5Zw==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-config": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-config/-/metro-config-0.83.3.tgz", + "integrity": "sha512-mTel7ipT0yNjKILIan04bkJkuCzUUkm2SeEaTads8VfEecCh+ltXchdq6DovXJqzQAXuR2P9cxZB47Lg4klriA==", + "license": "MIT", + "dependencies": { + "connect": "^3.6.5", + "flow-enums-runtime": "^0.0.6", + "jest-validate": "^29.7.0", + "metro": "0.83.3", + "metro-cache": "0.83.3", + "metro-core": "0.83.3", + "metro-runtime": "0.83.3", + "yaml": "^2.6.1" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-core": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-core/-/metro-core-0.83.3.tgz", + "integrity": "sha512-M+X59lm7oBmJZamc96usuF1kusd5YimqG/q97g4Ac7slnJ3YiGglW5CsOlicTR5EWf8MQFxxjDoB6ytTqRe8Hw==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "lodash.throttle": "^4.1.1", + "metro-resolver": "0.83.3" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-file-map": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-file-map/-/metro-file-map-0.83.3.tgz", + "integrity": "sha512-jg5AcyE0Q9Xbbu/4NAwwZkmQn7doJCKGW0SLeSJmzNB9Z24jBe0AL2PHNMy4eu0JiKtNWHz9IiONGZWq7hjVTA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "fb-watchman": "^2.0.0", + "flow-enums-runtime": "^0.0.6", + "graceful-fs": "^4.2.4", + "invariant": "^2.2.4", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "nullthrows": "^1.1.1", + "walker": "^1.0.7" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-minify-terser": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-minify-terser/-/metro-minify-terser-0.83.3.tgz", + "integrity": "sha512-O2BmfWj6FSfzBLrNCXt/rr2VYZdX5i6444QJU0fFoc7Ljg+Q+iqebwE3K0eTvkI6TRjELsXk1cjU+fXwAR4OjQ==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "terser": "^5.15.0" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-resolver": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-resolver/-/metro-resolver-0.83.3.tgz", + "integrity": "sha512-0js+zwI5flFxb1ktmR///bxHYg7OLpRpWZlBBruYG8OKYxeMP7SV0xQ/o/hUelrEMdK4LJzqVtHAhBm25LVfAQ==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-runtime": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-runtime/-/metro-runtime-0.83.3.tgz", + "integrity": "sha512-JHCJb9ebr9rfJ+LcssFYA2x1qPYuSD/bbePupIGhpMrsla7RCwC/VL3yJ9cSU+nUhU4c9Ixxy8tBta+JbDeZWw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.0", + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-source-map": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-source-map/-/metro-source-map-0.83.3.tgz", + "integrity": "sha512-xkC3qwUBh2psVZgVavo8+r2C9Igkk3DibiOXSAht1aYRRcztEZNFtAMtfSB7sdO2iFMx2Mlyu++cBxz/fhdzQg==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.25.3", + "@babel/traverse--for-generate-function-map": "npm:@babel/traverse@^7.25.3", + "@babel/types": "^7.25.2", + "flow-enums-runtime": "^0.0.6", + "invariant": "^2.2.4", + "metro-symbolicate": "0.83.3", + "nullthrows": "^1.1.1", + "ob1": "0.83.3", + "source-map": "^0.5.6", + "vlq": "^1.0.0" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-symbolicate": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-symbolicate/-/metro-symbolicate-0.83.3.tgz", + "integrity": "sha512-F/YChgKd6KbFK3eUR5HdUsfBqVsanf5lNTwFd4Ca7uuxnHgBC3kR/Hba/RGkenR3pZaGNp5Bu9ZqqP52Wyhomw==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "invariant": "^2.2.4", + "metro-source-map": "0.83.3", + "nullthrows": "^1.1.1", + "source-map": "^0.5.6", + "vlq": "^1.0.0" + }, + "bin": { + "metro-symbolicate": "src/index.js" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-transform-plugins": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-transform-plugins/-/metro-transform-plugins-0.83.3.tgz", + "integrity": "sha512-eRGoKJU6jmqOakBMH5kUB7VitEWiNrDzBHpYbkBXW7C5fUGeOd2CyqrosEzbMK5VMiZYyOcNFEphvxk3OXey2A==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/generator": "^7.25.0", + "@babel/template": "^7.25.0", + "@babel/traverse": "^7.25.3", + "flow-enums-runtime": "^0.0.6", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-transform-worker": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-transform-worker/-/metro-transform-worker-0.83.3.tgz", + "integrity": "sha512-Ztekew9t/gOIMZX1tvJOgX7KlSLL5kWykl0Iwu2cL2vKMKVALRl1hysyhUw0vjpAvLFx+Kfq9VLjnHIkW32fPA==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/generator": "^7.25.0", + "@babel/parser": "^7.25.3", + "@babel/types": "^7.25.2", + "flow-enums-runtime": "^0.0.6", + "metro": "0.83.3", + "metro-babel-transformer": "0.83.3", + "metro-cache": "0.83.3", + "metro-cache-key": "0.83.3", + "metro-minify-terser": "0.83.3", + "metro-source-map": "0.83.3", + "metro-transform-plugins": "0.83.3", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro/node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "devOptional": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nocache": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/nocache/-/nocache-3.0.4.tgz", + "integrity": "sha512-WDD0bdg9mbq6F4mRxEYcPWwfA1vxd0mrvKOyxI7Xj/atfRHVeutzuWByG//jfm4uPzp0y4Kj051EORCBSQMycw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "license": "MIT" + }, + "node_modules/node-stream-zip": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/node-stream-zip/-/node-stream-zip-1.15.0.tgz", + "integrity": "sha512-LN4fydt9TqhZhThkZIVQnF9cwjU3qmUH9h78Mx/K7d3VvfRqqwthLwJEUOEL0QPZ0XQmNN7be5Ggit5+4dq3Bw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/antelle" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nullthrows": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", + "integrity": "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==", + "license": "MIT" + }, + "node_modules/ob1": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/ob1/-/ob1-0.83.3.tgz", + "integrity": "sha512-egUxXCDwoWG06NGCS5s5AdcpnumHKJlfd3HH06P3m9TEMwwScfcY35wpQxbm9oHof+dM/lVH9Rfyu1elTVelSA==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/open": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/open/-/open-6.4.0.tgz", + "integrity": "sha512-IFenVPgF70fSm1keSd2iDBIDIBZkroLeuffXq+wKTzTJlBpesFWojV9lb8mzOfaAzM1sr7HQHuO0vtV0zYekGg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^1.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/oxc-resolver": { + "version": "11.16.3", + "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.16.3.tgz", + "integrity": "sha512-goLOJH3x69VouGWGp5CgCIHyksmOZzXr36lsRmQz1APg3SPFORrvV2q7nsUHMzLVa6ZJgNwkgUSJFsbCpAWkCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-resolver/binding-android-arm-eabi": "11.16.3", + "@oxc-resolver/binding-android-arm64": "11.16.3", + "@oxc-resolver/binding-darwin-arm64": "11.16.3", + "@oxc-resolver/binding-darwin-x64": "11.16.3", + "@oxc-resolver/binding-freebsd-x64": "11.16.3", + "@oxc-resolver/binding-linux-arm-gnueabihf": "11.16.3", + "@oxc-resolver/binding-linux-arm-musleabihf": "11.16.3", + "@oxc-resolver/binding-linux-arm64-gnu": "11.16.3", + "@oxc-resolver/binding-linux-arm64-musl": "11.16.3", + "@oxc-resolver/binding-linux-ppc64-gnu": "11.16.3", + "@oxc-resolver/binding-linux-riscv64-gnu": "11.16.3", + "@oxc-resolver/binding-linux-riscv64-musl": "11.16.3", + "@oxc-resolver/binding-linux-s390x-gnu": "11.16.3", + "@oxc-resolver/binding-linux-x64-gnu": "11.16.3", + "@oxc-resolver/binding-linux-x64-musl": "11.16.3", + "@oxc-resolver/binding-openharmony-arm64": "11.16.3", + "@oxc-resolver/binding-wasm32-wasi": "11.16.3", + "@oxc-resolver/binding-win32-arm64-msvc": "11.16.3", + "@oxc-resolver/binding-win32-ia32-msvc": "11.16.3", + "@oxc-resolver/binding-win32-x64-msvc": "11.16.3" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.0.tgz", + "integrity": "sha512-yEPsovQfpxYfgWNhCfECjG5AQaO+K3dp6XERmOepyPDVqcJm+bjyCVO3pmU+nAPe0N5dDvekfGezt/EIiRe1TA==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz", + "integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/promise": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/promise/-/promise-8.3.0.tgz", + "integrity": "sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg==", + "license": "MIT", + "dependencies": { + "asap": "~2.0.6" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "devOptional": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/query-string": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz", + "integrity": "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==", + "license": "MIT", + "dependencies": { + "decode-uri-component": "^0.2.2", + "filter-obj": "^1.1.0", + "split-on-first": "^1.0.0", + "strict-uri-encode": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/queue": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", + "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", + "license": "MIT", + "dependencies": { + "inherits": "~2.0.3" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/react": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", + "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-devtools-core": { + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/react-devtools-core/-/react-devtools-core-6.1.5.tgz", + "integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==", + "license": "MIT", + "dependencies": { + "shell-quote": "^1.6.1", + "ws": "^7" + } + }, + "node_modules/react-devtools-core/node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/react-freeze": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/react-freeze/-/react-freeze-1.0.4.tgz", + "integrity": "sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=17.0.0" + } + }, + "node_modules/react-is": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.3.tgz", + "integrity": "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA==", + "license": "MIT" + }, + "node_modules/react-native": { + "version": "0.83.1", + "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.83.1.tgz", + "integrity": "sha512-mL1q5HPq5cWseVhWRLl+Fwvi5z1UO+3vGOpjr+sHFwcUletPRZ5Kv+d0tUfqHmvi73/53NjlQqX1Pyn4GguUfA==", + "license": "MIT", + "dependencies": { + "@jest/create-cache-key-function": "^29.7.0", + "@react-native/assets-registry": "0.83.1", + "@react-native/codegen": "0.83.1", + "@react-native/community-cli-plugin": "0.83.1", + "@react-native/gradle-plugin": "0.83.1", + "@react-native/js-polyfills": "0.83.1", + "@react-native/normalize-colors": "0.83.1", + "@react-native/virtualized-lists": "0.83.1", + "abort-controller": "^3.0.0", + "anser": "^1.4.9", + "ansi-regex": "^5.0.0", + "babel-jest": "^29.7.0", + "babel-plugin-syntax-hermes-parser": "0.32.0", + "base64-js": "^1.5.1", + "commander": "^12.0.0", + "flow-enums-runtime": "^0.0.6", + "glob": "^7.1.1", + "hermes-compiler": "0.14.0", + "invariant": "^2.2.4", + "jest-environment-node": "^29.7.0", + "memoize-one": "^5.0.0", + "metro-runtime": "^0.83.3", + "metro-source-map": "^0.83.3", + "nullthrows": "^1.1.1", + "pretty-format": "^29.7.0", + "promise": "^8.3.0", + "react-devtools-core": "^6.1.5", + "react-refresh": "^0.14.0", + "regenerator-runtime": "^0.13.2", + "scheduler": "0.27.0", + "semver": "^7.1.3", + "stacktrace-parser": "^0.1.10", + "whatwg-fetch": "^3.0.0", + "ws": "^7.5.10", + "yargs": "^17.6.2" + }, + "bin": { + "react-native": "cli.js" + }, + "engines": { + "node": ">= 20.19.4" + }, + "peerDependencies": { + "@types/react": "^19.1.1", + "react": "^19.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-native-audio-recorder-player": { + "version": "3.6.14", + "resolved": "https://registry.npmjs.org/react-native-audio-recorder-player/-/react-native-audio-recorder-player-3.6.14.tgz", + "integrity": "sha512-F6SvHbuLvsbhBytR4+vaGIL6LFqC1cnB+SX3v191aHNvGDt63BX56w/Y19nIzxaLnG0b0vbxx/UZ1nzIvDyqWA==", + "deprecated": "This package has been deprecated. Please use react-native-nitro-sound instead.", + "license": "MIT", + "dependencies": { + "hyochan-welcome": "^1.0.0" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-fs": { + "version": "2.20.0", + "resolved": "https://registry.npmjs.org/react-native-fs/-/react-native-fs-2.20.0.tgz", + "integrity": "sha512-VkTBzs7fIDUiy/XajOSNk0XazFE9l+QlMAce7lGuebZcag5CnjszB+u4BdqzwaQOdcYb5wsJIsqq4kxInIRpJQ==", + "license": "MIT", + "dependencies": { + "base-64": "^0.1.0", + "utf8": "^3.0.0" + }, + "peerDependencies": { + "react-native": "*", + "react-native-windows": "*" + }, + "peerDependenciesMeta": { + "react-native-windows": { + "optional": true + } + } + }, + "node_modules/react-native-gesture-handler": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.30.0.tgz", + "integrity": "sha512-5YsnKHGa0X9C8lb5oCnKm0fLUPM6CRduvUUw2Bav4RIj/C3HcFh4RIUnF8wgG6JQWCL1//gRx4v+LVWgcIQdGA==", + "license": "MIT", + "dependencies": { + "@egjs/hammerjs": "^2.0.17", + "hoist-non-react-statics": "^3.3.0", + "invariant": "^2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-is-edge-to-edge": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.2.1.tgz", + "integrity": "sha512-FLbPWl/MyYQWz+KwqOZsSyj2JmLKglHatd3xLZWskXOpRaio4LfEDEz8E/A6uD8QoTHW6Aobw1jbEwK7KMgR7Q==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-live-audio-stream": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/react-native-live-audio-stream/-/react-native-live-audio-stream-1.1.1.tgz", + "integrity": "sha512-Yk0O51hY7eFMUv1umYxGDs4SJVPHyhUX6uz4jI+GiowOwSqIzLLRNh03hJjCVZRFXTWLPCntqOKZ+N8fVAc6BQ==", + "license": "MIT" + }, + "node_modules/react-native-monorepo-config": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/react-native-monorepo-config/-/react-native-monorepo-config-0.3.2.tgz", + "integrity": "sha512-Cl21GRCN/ZH3cEVtG7yY84NO2G6Bn57yEXReikOKFkFRUo6PFTAWfanEZReGqdAkhY5L/ORIml8abE1q83CZYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^5.0.0", + "fast-glob": "^3.3.3" + } + }, + "node_modules/react-native-monorepo-config/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/react-native-nitro-modules": { + "version": "0.31.10", + "resolved": "https://registry.npmjs.org/react-native-nitro-modules/-/react-native-nitro-modules-0.31.10.tgz", + "integrity": "sha512-hcvjTu9YJE9fMmnAUvhG8CxvYLpOuMQ/2eyi/S6GyrecezF6Rmk/uRQEL6v09BRFWA/xRVZNQVulQPS+2HS3mQ==", + "hasInstallScript": true, + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-permissions": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/react-native-permissions/-/react-native-permissions-5.4.4.tgz", + "integrity": "sha512-WB5lRCBGXETfuaUhem2vgOceb9+URCeyfKpLGFSwoOffLuyJCA6+NTR3l1KLkrK4Ykxsig37z16/shUVufmt7A==", + "license": "MIT", + "peerDependencies": { + "react": ">=18.1.0", + "react-native": ">=0.70.0", + "react-native-windows": ">=0.70.0" + }, + "peerDependenciesMeta": { + "react-native-windows": { + "optional": true + } + } + }, + "node_modules/react-native-reanimated": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.2.1.tgz", + "integrity": "sha512-/NcHnZMyOvsD/wYXug/YqSKw90P9edN0kEPL5lP4PFf1aQ4F1V7MKe/E0tvfkXKIajy3Qocp5EiEnlcrK/+BZg==", + "license": "MIT", + "dependencies": { + "react-native-is-edge-to-edge": "1.2.1", + "semver": "7.7.3" + }, + "peerDependencies": { + "react": "*", + "react-native": "*", + "react-native-worklets": ">=0.7.0" + } + }, + "node_modules/react-native-reanimated/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/react-native-safe-area-context": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz", + "integrity": "sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-screens": { + "version": "4.19.0", + "resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.19.0.tgz", + "integrity": "sha512-qSDAO3AL5bti0Ri7KZRSVmWlhDr8MV86N5GruiKVQfEL7Zx2nUi3Dl62lqHUAD/LnDvOPuDDsMHCfIpYSv3hPQ==", + "license": "MIT", + "dependencies": { + "react-freeze": "^1.0.0", + "warn-once": "^0.1.0" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-sound": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/react-native-sound/-/react-native-sound-0.13.0.tgz", + "integrity": "sha512-SnREzaV0fmpYNuDV1Y8M7FutmaYei0pKBgpldULKKJMkoA3DBv5ppyRxY+oxRQ7HwEpt6LsonrKgM+13GH/tCw==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-tts": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/react-native-tts/-/react-native-tts-4.1.1.tgz", + "integrity": "sha512-VL0TgCwkUWggbbFGIXAPKC3rM1baluAYtgOdgnaTm7UYsWf/y8n5VgmVB0J2Wa8qt1dldZ1cSsdQY9iz3evcAg==", + "license": "MIT" + }, + "node_modules/react-native-vector-icons": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/react-native-vector-icons/-/react-native-vector-icons-10.3.0.tgz", + "integrity": "sha512-IFQ0RE57819hOUdFvgK4FowM5aMXg7C7XKsuGLevqXkkIJatc3QopN0wYrb2IrzUgmdpfP+QVIbI3S6h7M0btw==", + "deprecated": "react-native-vector-icons package has moved to a new model of per-icon-family packages. See the https://github.com/oblador/react-native-vector-icons/blob/master/MIGRATION.md on how to migrate", + "license": "MIT", + "dependencies": { + "prop-types": "^15.7.2", + "yargs": "^16.1.1" + }, + "bin": { + "fa-upgrade.sh": "bin/fa-upgrade.sh", + "fa5-upgrade": "bin/fa5-upgrade.sh", + "fa6-upgrade": "bin/fa6-upgrade.sh", + "generate-icon": "bin/generate-icon.js" + } + }, + "node_modules/react-native-vector-icons/node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/react-native-vector-icons/node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "license": "MIT", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/react-native-vector-icons/node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/react-native-worklets": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.7.1.tgz", + "integrity": "sha512-KNsvR48ULg73QhTlmwPbdJLPsWcyBotrGPsrDRDswb5FYpQaJEThUKc2ncXE4UM5dn/ewLoQHjSjLaKUVPxPhA==", + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-arrow-functions": "7.27.1", + "@babel/plugin-transform-class-properties": "7.27.1", + "@babel/plugin-transform-classes": "7.28.4", + "@babel/plugin-transform-nullish-coalescing-operator": "7.27.1", + "@babel/plugin-transform-optional-chaining": "7.27.1", + "@babel/plugin-transform-shorthand-properties": "7.27.1", + "@babel/plugin-transform-template-literals": "7.27.1", + "@babel/plugin-transform-unicode-regex": "7.27.1", + "@babel/preset-typescript": "7.27.1", + "convert-source-map": "2.0.0", + "semver": "7.7.3" + }, + "peerDependencies": { + "@babel/core": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-worklets/node_modules/@babel/plugin-transform-class-properties": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.27.1.tgz", + "integrity": "sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/react-native-worklets/node_modules/@babel/plugin-transform-classes": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.4.tgz", + "integrity": "sha512-cFOlhIYPBv/iBoc+KS3M6et2XPtbT2HiCRfBXWtfpc9OAyostldxIf9YAYB6ypURBBbx+Qv6nyrLzASfJe+hBA==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-globals": "^7.28.0", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1", + "@babel/traverse": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/react-native-worklets/node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.27.1.tgz", + "integrity": "sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/react-native-worklets/node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.27.1.tgz", + "integrity": "sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/react-native-worklets/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/react-native-zip-archive": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/react-native-zip-archive/-/react-native-zip-archive-6.1.2.tgz", + "integrity": "sha512-LcJomSY/6O3KHy/LF6Gb7F/yRJiZJ0lTlPQPbfeOHBQzfvqNJFJZ8x6HrdeYeokFf/UGB5bY7jfh4es6Y/PhBA==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.6", + "react-native": ">=0.60.0" + } + }, + "node_modules/react-native/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/react-native/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/react-native/node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/react-refresh": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", + "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "license": "MIT" + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", + "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT" + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexpu-core": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", + "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.2.2", + "regjsgen": "^0.8.0", + "regjsparser": "^0.13.0", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.2.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", + "license": "MIT" + }, + "node_modules/regjsparser": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.0.tgz", + "integrity": "sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==", + "license": "BSD-2-Clause", + "dependencies": { + "jsesc": "~3.1.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "devOptional": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rn-fetch-blob": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/rn-fetch-blob/-/rn-fetch-blob-0.12.0.tgz", + "integrity": "sha512-+QnR7AsJ14zqpVVUbzbtAjq0iI8c9tCg49tIoKO2ezjzRunN7YL6zFSFSWZm6d+mE/l9r+OeDM3jmb2tBb2WbA==", + "license": "MIT", + "dependencies": { + "base-64": "0.1.0", + "glob": "7.0.6" + } + }, + "node_modules/rn-fetch-blob/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/rn-fetch-blob/node_modules/glob": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.0.6.tgz", + "integrity": "sha512-f8c0rE8JiCxpa52kWPAOa3ZaYEnzofDzCQLCn3Vdk0Z5OVLq3BsRFJI4S4ykpeVW6QMGBUkMeUpoEgWnMTnw5Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.2", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + } + }, + "node_modules/rn-fetch-blob/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/send/node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/send/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serialize-error": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-2.1.0.tgz", + "integrity": "sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/serve-static/node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sf-symbols-typescript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/sf-symbols-typescript/-/sf-symbols-typescript-2.2.0.tgz", + "integrity": "sha512-TPbeg0b7ylrswdGCji8FRGFAKuqbpQlLbL8SOle3j1iHSs5Ob5mhvMAxWN2UItOjgALAB5Zp3fmMfj8mbWvXKw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/simple-swizzle/node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT" + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/slice-ansi": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-2.1.0.tgz", + "integrity": "sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.0", + "astral-regex": "^1.0.0", + "is-fullwidth-code-point": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/slice-ansi/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/slice-ansi/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/smol-toml": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.0.tgz", + "integrity": "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split-on-first": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz", + "integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/stackframe": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", + "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", + "license": "MIT" + }, + "node_modules/stacktrace-parser": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/stacktrace-parser/-/stacktrace-parser-0.1.11.tgz", + "integrity": "sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.7.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/stacktrace-parser/node_modules/type-fest": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.7.1.tgz", + "integrity": "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=8" + } + }, + "node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/strict-uri-encode": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz", + "integrity": "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-natural-compare": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/string-natural-compare/-/string-natural-compare-3.0.1.tgz", + "integrity": "sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strnum": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz", + "integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/synckit": { + "version": "0.11.12", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", + "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.2.9" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, + "node_modules/terser": { + "version": "5.46.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz", + "integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==", + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/throat": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/throat/-/throat-5.0.0.tgz", + "integrity": "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "license": "BSD-3-Clause" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/ts-api-utils": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", + "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "license": "MIT" + }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "license": "MIT", + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", + "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", + "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-latest-callback": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/use-latest-callback/-/use-latest-callback-0.2.6.tgz", + "integrity": "sha512-FvRG9i1HSo0wagmX63Vrm8SnlUU3LMM3WyZkQ76RnslpBrX694AdG4A0zQBx2B3ZifFA0yv/BaEHGBnEax5rZg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/utf8": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/utf8/-/utf8-3.0.0.tgz", + "integrity": "sha512-E8VjFIQ/TyQgp+TZfS6l8yp/xWppSAHzidGiRrqe4bK4XP9pTRyKFgGJpO3SN7zdX4DeomTrwaseCHovfpFcqQ==", + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vlq": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/vlq/-/vlq-1.0.1.tgz", + "integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==", + "license": "MIT" + }, + "node_modules/walk-up-path": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-4.0.0.tgz", + "integrity": "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==", + "dev": true, + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/warn-once": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/warn-once/-/warn-once-0.1.1.tgz", + "integrity": "sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q==", + "license": "MIT" + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/ws": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.3.tgz", + "integrity": "sha512-jmTjYU0j60B+vHey6TfR3Z7RD61z/hmxBS3VMSGIrroOWXQEneK1zNuotOUrGyBHQj0yrpsLHPWtigEFd13ndA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "async-limiter": "~1.0.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", + "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/zustand": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.10.tgz", + "integrity": "sha512-U1AiltS1O9hSy3rul+Ub82ut2fqIAefiSuwECWt6jlMVUGejvf+5omLcRBSzqbRagSM3hQZbtzdeRc6QVScXTg==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/examples/react-native/RunAnywhereAI/package.json b/examples/react-native/RunAnywhereAI/package.json new file mode 100644 index 000000000..5f1bcb5c7 --- /dev/null +++ b/examples/react-native/RunAnywhereAI/package.json @@ -0,0 +1,146 @@ +{ + "name": "runanywhere-ai-example", + "version": "0.1.0", + "private": true, + "scripts": { + "android": "react-native run-android", + "ios": "react-native run-ios", + "start": "react-native start", + "test": "jest", + "lint": "eslint \"src/**/*.{ts,tsx}\" \"App.tsx\"", + "lint:fix": "eslint \"src/**/*.{ts,tsx}\" \"App.tsx\" --fix", + "typecheck": "tsc --noEmit", + "format": "prettier \"src/**/*.{ts,tsx}\" \"App.tsx\" --check", + "format:fix": "prettier \"src/**/*.{ts,tsx}\" \"App.tsx\" --write", + "unused": "knip", + "pod-install": "cd ios && pod install", + "clean": "watchman watch-del-all && rm -rf node_modules && rm -rf ios/Pods && npm install && cd ios && pod install" + }, + "dependencies": { + "@react-native-async-storage/async-storage": "^2.2.0", + "@react-navigation/bottom-tabs": "^7.8.11", + "@react-navigation/native": "^7.1.24", + "@react-navigation/native-stack": "^7.8.5", + "@runanywhere/core": "file:../../../sdk/runanywhere-react-native/packages/core", + "@runanywhere/llamacpp": "file:../../../sdk/runanywhere-react-native/packages/llamacpp", + "@runanywhere/onnx": "file:../../../sdk/runanywhere-react-native/packages/onnx", + "react": "19.2.0", + "react-native": "0.83.1", + "react-native-audio-recorder-player": "^3.6.14", + "react-native-fs": "^2.20.0", + "react-native-gesture-handler": "~2.30.0", + "react-native-live-audio-stream": "^1.1.1", + "react-native-nitro-modules": "^0.31.10", + "react-native-permissions": "^5.4.4", + "react-native-reanimated": "~4.2.1", + "react-native-safe-area-context": "~5.6.2", + "react-native-screens": "~4.19.0", + "react-native-sound": "^0.13.0", + "react-native-tts": "^4.1.1", + "react-native-vector-icons": "^10.1.0", + "react-native-worklets": "0.7.1", + "react-native-zip-archive": "^6.1.2", + "rn-fetch-blob": "^0.12.0", + "zustand": "^5.0.0" + }, + "devDependencies": { + "@babel/core": "^7.25.2", + "@babel/runtime": "^7.25.0", + "@react-native-community/cli": "latest", + "@react-native-community/cli-platform-android": "latest", + "@react-native-community/cli-platform-ios": "latest", + "@react-native/babel-preset": "0.83.1", + "@react-native/eslint-config": "0.83.1", + "@react-native/metro-config": "0.83.1", + "@react-native/typescript-config": "0.83.1", + "@types/react": "~19.1.0", + "@types/react-native-vector-icons": "^6.4.18", + "@typescript-eslint/eslint-plugin": "^7.18.0", + "@typescript-eslint/parser": "^7.18.0", + "eslint": "^8.57.0", + "eslint-config-prettier": "^9.0.0", + "eslint-plugin-prettier": "^5.0.1", + "eslint-plugin-unused-imports": "^4.3.0", + "knip": "^5.76.0", + "prettier": "^3.3.2", + "react-native-monorepo-config": "^0.3.0", + "typescript": "~5.9.2" + }, + "engines": { + "node": ">=18" + }, + "eslintConfig": { + "root": true, + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": 2020, + "sourceType": "module", + "project": "./tsconfig.json", + "ecmaFeatures": { + "jsx": true + } + }, + "plugins": [ + "@typescript-eslint", + "unused-imports" + ], + "extends": [ + "@react-native", + "plugin:@typescript-eslint/recommended", + "plugin:prettier/recommended" + ], + "rules": { + "unused-imports/no-unused-imports": "error", + "unused-imports/no-unused-vars": [ + "warn", + { + "vars": "all", + "varsIgnorePattern": "^_", + "args": "after-used", + "argsIgnorePattern": "^_" + } + ], + "@typescript-eslint/no-unused-vars": "off", + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/consistent-type-imports": [ + "warn", + { + "prefer": "type-imports", + "disallowTypeAnnotations": false + } + ], + "@typescript-eslint/no-require-imports": "off", + "@typescript-eslint/no-this-alias": "warn", + "@typescript-eslint/no-empty-object-type": "off", + "@typescript-eslint/no-non-null-assertion": "warn", + "no-console": [ + "warn", + { + "allow": [ + "warn", + "error" + ] + } + ], + "react/react-in-jsx-scope": "off", + "react-native/no-inline-styles": "warn" + } + }, + "eslintIgnore": [ + "node_modules/", + "android/", + "ios/", + "**/__tests__/**", + "**/*.test.ts", + "**/*.test.tsx", + "babel.config.js", + "metro.config.js" + ], + "prettier": { + "quoteProps": "consistent", + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "useTabs": false + } +} diff --git a/examples/react-native/RunAnywhereAI/react-native.config.js b/examples/react-native/RunAnywhereAI/react-native.config.js new file mode 100644 index 000000000..760887e4d --- /dev/null +++ b/examples/react-native/RunAnywhereAI/react-native.config.js @@ -0,0 +1,34 @@ +/** + * React Native configuration for RunAnywhere + */ +module.exports = { + project: { + ios: { + automaticPodsInstallation: true, + }, + }, + dependencies: { + // Disable audio libraries on iOS - they're incompatible with New Architecture + 'react-native-live-audio-stream': { + platforms: { + ios: null, + }, + }, + 'react-native-audio-recorder-player': { + platforms: { + ios: null, + android: null, + }, + }, + 'react-native-sound': { + platforms: { + ios: null, + }, + }, + 'react-native-tts': { + platforms: { + ios: null, + }, + }, + }, +}; diff --git a/examples/react-native/RunAnywhereAI/src/components/chat/ChatInput.tsx b/examples/react-native/RunAnywhereAI/src/components/chat/ChatInput.tsx new file mode 100644 index 000000000..d5e030040 --- /dev/null +++ b/examples/react-native/RunAnywhereAI/src/components/chat/ChatInput.tsx @@ -0,0 +1,158 @@ +/** + * ChatInput Component + * + * Text input with send button for chat messages. + * + * Reference: iOS ChatInterfaceView input area + */ + +import React, { useState } from 'react'; +import { + View, + TextInput, + TouchableOpacity, + StyleSheet, + KeyboardAvoidingView, + Platform, +} from 'react-native'; +import Icon from 'react-native-vector-icons/Ionicons'; +import { Colors } from '../../theme/colors'; +import { Typography } from '../../theme/typography'; +import { + Spacing, + BorderRadius, + Padding, + ButtonHeight, + Layout, +} from '../../theme/spacing'; + +interface ChatInputProps { + /** Current input value */ + value: string; + /** Callback when value changes */ + onChangeText: (text: string) => void; + /** Callback when send button pressed */ + onSend: () => void; + /** Whether input is disabled */ + disabled?: boolean; + /** Placeholder text */ + placeholder?: string; + /** Whether currently sending/generating */ + isLoading?: boolean; +} + +export const ChatInput: React.FC = ({ + value, + onChangeText, + onSend, + disabled = false, + placeholder = 'Type a message...', + isLoading = false, +}) => { + const [inputHeight, setInputHeight] = useState(Layout.inputMinHeight); + const canSend = value.trim().length > 0 && !disabled && !isLoading; + + const handleContentSizeChange = (event: { + nativeEvent: { contentSize: { height: number } }; + }) => { + const height = event.nativeEvent.contentSize.height; + // Clamp between min and max (4 lines max) + const clampedHeight = Math.min( + Math.max(height, Layout.inputMinHeight), + 120 + ); + setInputHeight(clampedHeight); + }; + + const handleSend = () => { + if (canSend) { + onSend(); + } + }; + + return ( + + + + + + + + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + backgroundColor: Colors.backgroundPrimary, + borderTopWidth: 1, + borderTopColor: Colors.borderLight, + paddingHorizontal: Padding.padding16, + paddingVertical: Padding.padding10, + paddingBottom: + Platform.OS === 'ios' ? Padding.padding20 : Padding.padding10, + }, + inputContainer: { + flexDirection: 'row', + alignItems: 'flex-end', + gap: Spacing.smallMedium, + }, + input: { + flex: 1, + backgroundColor: Colors.backgroundSecondary, + borderRadius: BorderRadius.large, + paddingHorizontal: Padding.padding16, + paddingTop: Padding.padding12, + paddingBottom: Padding.padding12, + ...Typography.body, + color: Colors.textPrimary, + maxHeight: 120, + }, + sendButton: { + width: ButtonHeight.regular, + height: ButtonHeight.regular, + borderRadius: ButtonHeight.regular / 2, + justifyContent: 'center', + alignItems: 'center', + }, + sendButtonActive: { + backgroundColor: Colors.primaryBlue, + }, + sendButtonInactive: { + backgroundColor: Colors.backgroundGray5, + }, +}); + +export default ChatInput; diff --git a/examples/react-native/RunAnywhereAI/src/components/chat/MessageBubble.tsx b/examples/react-native/RunAnywhereAI/src/components/chat/MessageBubble.tsx new file mode 100644 index 000000000..99657c112 --- /dev/null +++ b/examples/react-native/RunAnywhereAI/src/components/chat/MessageBubble.tsx @@ -0,0 +1,276 @@ +/** + * MessageBubble Component + * + * Displays a single chat message with role-specific styling. + * + * Reference: iOS MessageBubbleView.swift + */ + +import React, { useState } from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + LayoutAnimation, +} from 'react-native'; +import Icon from 'react-native-vector-icons/Ionicons'; +import { Colors } from '../../theme/colors'; +import { Typography } from '../../theme/typography'; +import { Spacing, BorderRadius, Padding, Layout } from '../../theme/spacing'; +import type { Message } from '../../types/chat'; +import { MessageRole } from '../../types/chat'; + +interface MessageBubbleProps { + message: Message; + /** Maximum width as fraction of screen */ + maxWidthFraction?: number; +} + +/** + * Format timestamp to relative or time string + */ +const formatTimestamp = (date: Date): string => { + const now = new Date(); + const diff = now.getTime() - date.getTime(); + const minutes = Math.floor(diff / 60000); + + if (minutes < 1) return 'Just now'; + if (minutes < 60) return `${minutes}m ago`; + + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +}; + +/** + * Format tokens per second + */ +const formatTPS = (tps: number): string => { + if (tps >= 100) return `${Math.round(tps)} tok/s`; + return `${tps.toFixed(1)} tok/s`; +}; + +export const MessageBubble: React.FC = ({ + message, + maxWidthFraction = Layout.messageBubbleMaxWidth, +}) => { + const [showThinking, setShowThinking] = useState(false); + const isUser = message.role === MessageRole.User; + const isAssistant = message.role === MessageRole.Assistant; + const hasThinking = !!message.thinkingContent; + + const toggleThinking = () => { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); + setShowThinking(!showThinking); + }; + + return ( + + {/* Message Bubble */} + + {/* Model Info Badge (for assistant messages) */} + {isAssistant && + message.modelInfo && + message.modelInfo.frameworkDisplayName && ( + + + + {message.modelInfo.frameworkDisplayName} + + + )} + + {/* Thinking Section (expandable) */} + {hasThinking && ( + + + Thinking + {message.analytics?.thinkingTime && ( + + {(message.analytics.thinkingTime / 1000).toFixed(1)}s + + )} + + )} + + {showThinking && message.thinkingContent && ( + + {message.thinkingContent} + + )} + + {/* Message Content */} + + {message.content} + + + {/* Streaming Indicator */} + {message.isStreaming && ( + + + + )} + + {/* Footer: Timestamp & Analytics */} + + + {formatTimestamp(message.timestamp)} + + + {/* Analytics (for assistant messages) */} + {isAssistant && + message.analytics && + message.analytics.averageTokensPerSecond != null && + message.analytics.averageTokensPerSecond > 0 && ( + + + {formatTPS(message.analytics.averageTokensPerSecond)} + + + )} + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + marginVertical: Spacing.xSmall, + paddingHorizontal: Padding.padding16, + }, + userContainer: { + alignItems: 'flex-end', + }, + assistantContainer: { + alignItems: 'flex-start', + }, + bubble: { + borderRadius: BorderRadius.xLarge, + paddingHorizontal: Padding.padding14, + paddingVertical: Padding.padding10, + }, + userBubble: { + backgroundColor: Colors.primaryBlue, + borderBottomRightRadius: BorderRadius.small, + }, + assistantBubble: { + backgroundColor: Colors.assistantBubbleBg, + borderBottomLeftRadius: BorderRadius.small, + }, + modelBadge: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.xSmall, + marginBottom: Spacing.small, + backgroundColor: Colors.badgeBlue, + alignSelf: 'flex-start', + paddingHorizontal: Spacing.small, + paddingVertical: Spacing.xxSmall, + borderRadius: BorderRadius.small, + }, + modelBadgeText: { + ...Typography.caption2, + color: Colors.primaryBlue, + fontWeight: '600', + }, + thinkingHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.xSmall, + marginBottom: Spacing.small, + paddingVertical: Spacing.xSmall, + }, + thinkingLabel: { + ...Typography.caption, + color: Colors.textSecondary, + fontWeight: '600', + }, + thinkingTime: { + ...Typography.caption, + color: Colors.textTertiary, + }, + thinkingContent: { + backgroundColor: Colors.backgroundSecondary, + borderRadius: BorderRadius.regular, + padding: Padding.padding10, + marginBottom: Spacing.smallMedium, + }, + thinkingText: { + ...Typography.footnote, + color: Colors.textSecondary, + fontStyle: 'italic', + }, + messageText: { + ...Typography.body, + }, + userText: { + color: Colors.textWhite, + }, + assistantText: { + color: Colors.textPrimary, + }, + streamingIndicator: { + marginTop: Spacing.xSmall, + }, + cursor: { + width: 8, + height: 16, + backgroundColor: Colors.textSecondary, + opacity: 0.5, + }, + footer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginTop: Spacing.small, + }, + timestamp: { + ...Typography.caption2, + }, + userTimestamp: { + color: 'rgba(255, 255, 255, 0.7)', + }, + assistantTimestamp: { + color: Colors.textTertiary, + }, + analytics: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.small, + }, + analyticsText: { + ...Typography.caption2, + color: Colors.textTertiary, + }, +}); + +export default MessageBubble; diff --git a/examples/react-native/RunAnywhereAI/src/components/chat/TypingIndicator.tsx b/examples/react-native/RunAnywhereAI/src/components/chat/TypingIndicator.tsx new file mode 100644 index 000000000..419334565 --- /dev/null +++ b/examples/react-native/RunAnywhereAI/src/components/chat/TypingIndicator.tsx @@ -0,0 +1,122 @@ +/** + * TypingIndicator Component + * + * Animated dots to show AI is thinking/generating. + * + * Reference: iOS TypingIndicatorView.swift + */ + +import React, { useEffect, useRef } from 'react'; +import { View, Text, StyleSheet, Animated } from 'react-native'; +import { Colors } from '../../theme/colors'; +import { Typography } from '../../theme/typography'; +import { Spacing, BorderRadius, Padding } from '../../theme/spacing'; + +interface TypingIndicatorProps { + /** Label text */ + label?: string; +} + +export const TypingIndicator: React.FC = ({ + label = 'AI is thinking...', +}) => { + // Animation values for each dot + const dot1 = useRef(new Animated.Value(0)).current; + const dot2 = useRef(new Animated.Value(0)).current; + const dot3 = useRef(new Animated.Value(0)).current; + + useEffect(() => { + const createDotAnimation = (dot: Animated.Value, delay: number) => { + return Animated.loop( + Animated.sequence([ + Animated.delay(delay), + Animated.timing(dot, { + toValue: 1, + duration: 300, + useNativeDriver: true, + }), + Animated.timing(dot, { + toValue: 0, + duration: 300, + useNativeDriver: true, + }), + Animated.delay(600 - delay), + ]) + ); + }; + + const animation = Animated.parallel([ + createDotAnimation(dot1, 0), + createDotAnimation(dot2, 150), + createDotAnimation(dot3, 300), + ]); + + animation.start(); + + return () => { + animation.stop(); + }; + }, [dot1, dot2, dot3]); + + const createDotStyle = (animatedValue: Animated.Value) => ({ + transform: [ + { + scale: animatedValue.interpolate({ + inputRange: [0, 1], + outputRange: [1, 1.3], + }), + }, + ], + opacity: animatedValue.interpolate({ + inputRange: [0, 1], + outputRange: [0.4, 1], + }), + }); + + return ( + + + + + + + + {label} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + alignItems: 'flex-start', + paddingHorizontal: Padding.padding16, + marginVertical: Spacing.xSmall, + }, + bubble: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.smallMedium, + backgroundColor: Colors.backgroundGray5, + borderRadius: BorderRadius.xLarge, + borderBottomLeftRadius: BorderRadius.small, + paddingHorizontal: Padding.padding14, + paddingVertical: Padding.padding10, + }, + dotsContainer: { + flexDirection: 'row', + gap: Spacing.xSmall, + }, + dot: { + width: 8, + height: 8, + borderRadius: 4, + backgroundColor: Colors.textSecondary, + }, + label: { + ...Typography.footnote, + color: Colors.textSecondary, + }, +}); + +export default TypingIndicator; diff --git a/examples/react-native/RunAnywhereAI/src/components/chat/index.ts b/examples/react-native/RunAnywhereAI/src/components/chat/index.ts new file mode 100644 index 000000000..aa4f443d9 --- /dev/null +++ b/examples/react-native/RunAnywhereAI/src/components/chat/index.ts @@ -0,0 +1,7 @@ +/** + * Chat Components Export + */ + +export { MessageBubble } from './MessageBubble'; +export { TypingIndicator } from './TypingIndicator'; +export { ChatInput } from './ChatInput'; diff --git a/examples/react-native/RunAnywhereAI/src/components/common/LoadingOverlay.tsx b/examples/react-native/RunAnywhereAI/src/components/common/LoadingOverlay.tsx new file mode 100644 index 000000000..77af4fffb --- /dev/null +++ b/examples/react-native/RunAnywhereAI/src/components/common/LoadingOverlay.tsx @@ -0,0 +1,119 @@ +/** + * LoadingOverlay Component + * + * Full-screen loading overlay with optional progress. + * + * Reference: iOS loading states + */ + +import React from 'react'; +import { View, Text, StyleSheet, ActivityIndicator, Modal } from 'react-native'; +import { Colors } from '../../theme/colors'; +import { Typography } from '../../theme/typography'; +import { Spacing, BorderRadius, Padding } from '../../theme/spacing'; + +interface LoadingOverlayProps { + /** Whether to show the overlay */ + visible: boolean; + /** Loading message */ + message?: string; + /** Progress (0-1), shows progress bar if provided */ + progress?: number; + /** Whether to use modal (blocks interaction) */ + modal?: boolean; +} + +export const LoadingOverlay: React.FC = ({ + visible, + message = 'Loading...', + progress, + modal = true, +}) => { + if (!visible) { + return null; + } + + const content = ( + + + + + {message && {message}} + + {progress !== undefined && ( + + + + + + {Math.round(progress * 100)}% + + + )} + + + ); + + if (modal) { + return ( + + {content} + + ); + } + + return content; +}; + +const styles = StyleSheet.create({ + container: { + ...StyleSheet.absoluteFillObject, + backgroundColor: Colors.overlayLight, + justifyContent: 'center', + alignItems: 'center', + }, + card: { + backgroundColor: Colors.backgroundPrimary, + borderRadius: BorderRadius.xLarge, + padding: Padding.padding30, + alignItems: 'center', + minWidth: 200, + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.15, + shadowRadius: 12, + elevation: 8, + }, + message: { + ...Typography.body, + color: Colors.textPrimary, + marginTop: Spacing.large, + textAlign: 'center', + }, + progressContainer: { + width: '100%', + marginTop: Spacing.large, + alignItems: 'center', + }, + progressBar: { + width: '100%', + height: 6, + backgroundColor: Colors.backgroundGray5, + borderRadius: 3, + overflow: 'hidden', + }, + progressFill: { + height: '100%', + backgroundColor: Colors.primaryBlue, + borderRadius: 3, + }, + progressText: { + ...Typography.caption, + color: Colors.textSecondary, + marginTop: Spacing.small, + }, +}); + +export default LoadingOverlay; diff --git a/examples/react-native/RunAnywhereAI/src/components/common/ModelRequiredOverlay.tsx b/examples/react-native/RunAnywhereAI/src/components/common/ModelRequiredOverlay.tsx new file mode 100644 index 000000000..6bcdd6290 --- /dev/null +++ b/examples/react-native/RunAnywhereAI/src/components/common/ModelRequiredOverlay.tsx @@ -0,0 +1,181 @@ +/** + * ModelRequiredOverlay Component + * + * Full-screen overlay shown when a model is required but not selected. + * + * Reference: iOS ModelRequiredOverlay + */ + +import React from 'react'; +import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'; +import Icon from 'react-native-vector-icons/Ionicons'; +import { Colors } from '../../theme/colors'; +import { Typography } from '../../theme/typography'; +import { + Spacing, + BorderRadius, + Padding, + IconSize, + ButtonHeight, +} from '../../theme/spacing'; +import { ModelModality } from '../../types/model'; + +interface ModelRequiredOverlayProps { + /** Modality context for icon and text */ + modality: ModelModality; + /** Title text */ + title?: string; + /** Description text */ + description?: string; + /** Callback when select model button pressed */ + onSelectModel: () => void; +} + +/** + * Get icon name based on modality + */ +const getModalityIcon = (modality: ModelModality): string => { + switch (modality) { + case ModelModality.LLM: + return 'chatbubble-ellipses-outline'; + case ModelModality.STT: + return 'mic-outline'; + case ModelModality.TTS: + return 'volume-high-outline'; + case ModelModality.VLM: + return 'eye-outline'; + default: + return 'cube-outline'; + } +}; + +/** + * Get default title based on modality + */ +const getDefaultTitle = (modality: ModelModality): string => { + switch (modality) { + case ModelModality.LLM: + return 'No Language Model Selected'; + case ModelModality.STT: + return 'No Speech Model Selected'; + case ModelModality.TTS: + return 'No Voice Model Selected'; + case ModelModality.VLM: + return 'No Vision Model Selected'; + default: + return 'No Model Selected'; + } +}; + +/** + * Get default description based on modality + */ +const getDefaultDescription = (modality: ModelModality): string => { + switch (modality) { + case ModelModality.LLM: + return 'Select a language model to start chatting with AI on your device.'; + case ModelModality.STT: + return 'Select a speech recognition model to transcribe audio.'; + case ModelModality.TTS: + return 'Select a text-to-speech model to generate audio.'; + case ModelModality.VLM: + return 'Select a vision model to analyze images.'; + default: + return 'Select a model to get started.'; + } +}; + +export const ModelRequiredOverlay: React.FC = ({ + modality, + title, + description, + onSelectModel, +}) => { + const iconName = getModalityIcon(modality); + const displayTitle = title || getDefaultTitle(modality); + const displayDescription = description || getDefaultDescription(modality); + + return ( + + + {/* Icon */} + + + + + {/* Title */} + {displayTitle} + + {/* Description */} + {displayDescription} + + {/* Select Model Button */} + + + Select a Model + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + ...StyleSheet.absoluteFillObject, + backgroundColor: Colors.backgroundPrimary, + justifyContent: 'center', + alignItems: 'center', + padding: Padding.padding40, + }, + content: { + alignItems: 'center', + maxWidth: 300, + }, + iconContainer: { + width: IconSize.huge, + height: IconSize.huge, + borderRadius: IconSize.huge / 2, + backgroundColor: Colors.backgroundSecondary, + justifyContent: 'center', + alignItems: 'center', + marginBottom: Spacing.xLarge, + }, + title: { + ...Typography.title3, + color: Colors.textPrimary, + textAlign: 'center', + marginBottom: Spacing.medium, + }, + description: { + ...Typography.body, + color: Colors.textSecondary, + textAlign: 'center', + marginBottom: Spacing.xxLarge, + lineHeight: 24, + }, + button: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: Spacing.smallMedium, + backgroundColor: Colors.primaryBlue, + paddingHorizontal: Padding.padding24, + height: ButtonHeight.regular, + borderRadius: BorderRadius.large, + minWidth: 200, + }, + buttonText: { + ...Typography.headline, + color: Colors.textWhite, + }, +}); + +export default ModelRequiredOverlay; diff --git a/examples/react-native/RunAnywhereAI/src/components/common/ModelStatusBanner.tsx b/examples/react-native/RunAnywhereAI/src/components/common/ModelStatusBanner.tsx new file mode 100644 index 000000000..214b8c411 --- /dev/null +++ b/examples/react-native/RunAnywhereAI/src/components/common/ModelStatusBanner.tsx @@ -0,0 +1,270 @@ +/** + * ModelStatusBanner Component + * + * Shows the current model status with options to select or change model. + * + * Reference: iOS ModelStatusBanner equivalent + */ + +import React from 'react'; +import { + View, + Text, + TouchableOpacity, + StyleSheet, + ActivityIndicator, +} from 'react-native'; +import Icon from 'react-native-vector-icons/Ionicons'; +import { Colors } from '../../theme/colors'; +import { Typography } from '../../theme/typography'; +import { Spacing, BorderRadius, Padding } from '../../theme/spacing'; +import { LLMFramework, FrameworkDisplayNames } from '../../types/model'; + +interface ModelStatusBannerProps { + /** Model name if loaded */ + modelName?: string; + /** Framework being used */ + framework?: LLMFramework; + /** Whether model is loading */ + isLoading?: boolean; + /** Loading progress (0-1) */ + loadProgress?: number; + /** Callback when select/change button pressed */ + onSelectModel: () => void; + /** Placeholder text when no model */ + placeholder?: string; +} + +/** + * Get framework-specific icon name + */ +const getFrameworkIcon = (framework: LLMFramework): string => { + switch (framework) { + case LLMFramework.LlamaCpp: + return 'cube-outline'; + case LLMFramework.WhisperKit: + return 'mic-outline'; + case LLMFramework.PiperTTS: + return 'volume-high-outline'; + case LLMFramework.FoundationModels: + return 'sparkles-outline'; + case LLMFramework.CoreML: + return 'hardware-chip-outline'; + case LLMFramework.ONNX: + return 'git-network-outline'; + default: + return 'cube-outline'; + } +}; + +/** + * Get framework-specific color + */ +const getFrameworkColor = (framework: LLMFramework): string => { + switch (framework) { + case LLMFramework.LlamaCpp: + return Colors.frameworkLlamaCpp; + case LLMFramework.WhisperKit: + return Colors.frameworkWhisperKit; + case LLMFramework.PiperTTS: + return Colors.frameworkPiperTTS; + case LLMFramework.FoundationModels: + return Colors.frameworkFoundationModels; + case LLMFramework.CoreML: + return Colors.frameworkCoreML; + case LLMFramework.ONNX: + return Colors.frameworkONNX; + default: + return Colors.primaryBlue; + } +}; + +export const ModelStatusBanner: React.FC = ({ + modelName, + framework, + isLoading = false, + loadProgress, + onSelectModel, + placeholder = 'Select a model to get started', +}) => { + // Loading state + if (isLoading) { + return ( + + + + + Loading model... + {loadProgress !== undefined && + ` ${Math.round(loadProgress * 100)}%`} + + + {loadProgress !== undefined && ( + + + + )} + + ); + } + + // No model state + if (!modelName || !framework) { + return ( + + + + {placeholder} + + + Select Model + + + + ); + } + + // Model loaded state + const frameworkColor = getFrameworkColor(framework); + const frameworkIcon = getFrameworkIcon(framework); + const frameworkName = FrameworkDisplayNames[framework] || framework; + + return ( + + + {/* Framework Badge */} + + + + {frameworkName} + + + + {/* Model Name */} + + {modelName} + + + + {/* Change Button */} + + Change + + + ); +}; + +const styles = StyleSheet.create({ + container: { + backgroundColor: Colors.backgroundSecondary, + borderRadius: BorderRadius.medium, + padding: Padding.padding12, + marginHorizontal: Padding.padding16, + marginVertical: Spacing.small, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + emptyContainer: { + borderWidth: 1, + borderColor: Colors.borderLight, + borderStyle: 'dashed', + }, + emptyContent: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.smallMedium, + }, + emptyText: { + ...Typography.subheadline, + color: Colors.textSecondary, + }, + selectButton: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.xSmall, + }, + selectButtonText: { + ...Typography.subheadline, + color: Colors.primaryBlue, + fontWeight: '600', + }, + loadingContent: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.smallMedium, + flex: 1, + }, + loadingText: { + ...Typography.subheadline, + color: Colors.textSecondary, + }, + progressBar: { + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + height: 3, + backgroundColor: Colors.backgroundGray5, + borderBottomLeftRadius: BorderRadius.medium, + borderBottomRightRadius: BorderRadius.medium, + overflow: 'hidden', + }, + progressFill: { + height: '100%', + backgroundColor: Colors.primaryBlue, + }, + loadedContent: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.smallMedium, + flex: 1, + }, + frameworkBadge: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.xSmall, + paddingHorizontal: Spacing.smallMedium, + paddingVertical: Spacing.xSmall, + borderRadius: BorderRadius.small, + }, + frameworkText: { + ...Typography.caption, + fontWeight: '600', + }, + modelName: { + ...Typography.subheadline, + color: Colors.textPrimary, + flex: 1, + }, + changeButton: { + paddingHorizontal: Spacing.medium, + paddingVertical: Spacing.small, + }, + changeButtonText: { + ...Typography.subheadline, + color: Colors.primaryBlue, + fontWeight: '600', + }, +}); + +export default ModelStatusBanner; diff --git a/examples/react-native/RunAnywhereAI/src/components/common/index.ts b/examples/react-native/RunAnywhereAI/src/components/common/index.ts new file mode 100644 index 000000000..90213af9e --- /dev/null +++ b/examples/react-native/RunAnywhereAI/src/components/common/index.ts @@ -0,0 +1,7 @@ +/** + * Common Components Export + */ + +export { ModelStatusBanner } from './ModelStatusBanner'; +export { ModelRequiredOverlay } from './ModelRequiredOverlay'; +export { LoadingOverlay } from './LoadingOverlay'; diff --git a/examples/react-native/RunAnywhereAI/src/components/model/ModelSelectionSheet.tsx b/examples/react-native/RunAnywhereAI/src/components/model/ModelSelectionSheet.tsx new file mode 100644 index 000000000..24bf3a380 --- /dev/null +++ b/examples/react-native/RunAnywhereAI/src/components/model/ModelSelectionSheet.tsx @@ -0,0 +1,1205 @@ +/** + * ModelSelectionSheet - Reusable model selection component + * + * Reference: iOS Features/Models/ModelSelectionSheet.swift + * + * Features: + * - Device status section + * - Framework list with expansion + * - Model list with download/select actions + * - Loading overlay for model loading + * - Context-based filtering (LLM, STT, TTS, Voice) + */ + +import React, { useState, useEffect, useCallback } from 'react'; +import { + View, + Text, + Modal, + StyleSheet, + TouchableOpacity, + ScrollView, + ActivityIndicator, + SafeAreaView, +} from 'react-native'; +import Icon from 'react-native-vector-icons/Ionicons'; +import { Colors } from '../../theme/colors'; +import { Typography, FontWeight } from '../../theme/typography'; +import { Spacing, Padding, BorderRadius } from '../../theme/spacing'; +import type { DeviceInfo } from '../../types/model'; +import { + ModelCategory, + LLMFramework, + FrameworkDisplayNames, +} from '../../types/model'; + +// Import SDK types and values +// Import RunAnywhere SDK (Multi-Package Architecture) +import { + RunAnywhere, + type ModelInfo as SDKModelInfo, + LLMFramework as SDKLLMFramework, + ModelCategory as SDKModelCategory, + requireDeviceInfoModule, +} from '@runanywhere/core'; + +/** + * Context for filtering frameworks and models based on the current experience/modality + */ +export enum ModelSelectionContext { + LLM = 'llm', // Chat experience - show LLM frameworks + STT = 'stt', // Speech-to-Text - show STT frameworks + TTS = 'tts', // Text-to-Speech - show TTS frameworks + Voice = 'voice', // Voice Assistant - show all voice-related +} + +/** + * Get title for context + */ +const getContextTitle = (context: ModelSelectionContext): string => { + switch (context) { + case ModelSelectionContext.LLM: + return 'Select LLM Model'; + case ModelSelectionContext.STT: + return 'Select STT Model'; + case ModelSelectionContext.TTS: + return 'Select TTS Model'; + case ModelSelectionContext.Voice: + return 'Select Model'; + } +}; + +/** + * Get relevant categories for context (kept for reference) + */ +const _getRelevantCategories = ( + context: ModelSelectionContext +): Set => { + switch (context) { + case ModelSelectionContext.LLM: + return new Set([ModelCategory.Language, ModelCategory.Multimodal]); + case ModelSelectionContext.STT: + return new Set([ModelCategory.SpeechRecognition]); + case ModelSelectionContext.TTS: + return new Set([ModelCategory.SpeechSynthesis]); + case ModelSelectionContext.Voice: + return new Set([ + ModelCategory.Language, + ModelCategory.Multimodal, + ModelCategory.SpeechRecognition, + ModelCategory.SpeechSynthesis, + ]); + } +}; + +/** + * Get category string for SDK filtering (uses SDK's ModelCategory enum values) + */ +const getCategoryForContext = ( + context: ModelSelectionContext +): string | null => { + switch (context) { + case ModelSelectionContext.LLM: + return SDKModelCategory.Language; // 'language' + case ModelSelectionContext.STT: + return SDKModelCategory.SpeechRecognition; // 'speech-recognition' + case ModelSelectionContext.TTS: + return SDKModelCategory.SpeechSynthesis; // 'speech-synthesis' + case ModelSelectionContext.Voice: + return null; // Show all + } +}; + +/** + * Framework info for display + */ +interface FrameworkDisplayInfo { + framework: LLMFramework; + displayName: string; + iconName: string; + color: string; + modelCount: number; +} + +/** + * Get framework display info + */ +const getFrameworkInfo = ( + framework: LLMFramework, + modelCount: number +): FrameworkDisplayInfo => { + const colorMap: Record = { + [LLMFramework.LlamaCpp]: Colors.frameworkLlamaCpp, + [LLMFramework.WhisperKit]: Colors.frameworkWhisperKit, + [LLMFramework.ONNX]: Colors.frameworkONNX, + [LLMFramework.CoreML]: Colors.frameworkCoreML, + [LLMFramework.FoundationModels]: Colors.frameworkFoundationModels, + [LLMFramework.TensorFlowLite]: Colors.frameworkTFLite, + [LLMFramework.PiperTTS]: Colors.frameworkPiperTTS, + [LLMFramework.SystemTTS]: Colors.frameworkSystemTTS, + [LLMFramework.MLX]: Colors.primaryPurple, + [LLMFramework.SwiftTransformers]: Colors.primaryBlue, + [LLMFramework.ExecuTorch]: Colors.primaryOrange, + [LLMFramework.PicoLLM]: Colors.primaryGreen, + [LLMFramework.MLC]: Colors.primaryBlue, + [LLMFramework.MediaPipe]: Colors.primaryOrange, + [LLMFramework.OpenAIWhisper]: Colors.primaryGreen, + }; + + const iconMap: Record = { + [LLMFramework.LlamaCpp]: 'terminal-outline', + [LLMFramework.WhisperKit]: 'mic-outline', + [LLMFramework.ONNX]: 'cube-outline', + [LLMFramework.CoreML]: 'hardware-chip-outline', + [LLMFramework.FoundationModels]: 'sparkles-outline', + [LLMFramework.TensorFlowLite]: 'layers-outline', + [LLMFramework.PiperTTS]: 'volume-high-outline', + [LLMFramework.SystemTTS]: 'megaphone-outline', + [LLMFramework.MLX]: 'flash-outline', + [LLMFramework.SwiftTransformers]: 'code-slash-outline', + [LLMFramework.ExecuTorch]: 'flame-outline', + [LLMFramework.PicoLLM]: 'radio-outline', + [LLMFramework.MLC]: 'git-branch-outline', + [LLMFramework.MediaPipe]: 'videocam-outline', + [LLMFramework.OpenAIWhisper]: 'ear-outline', + }; + + return { + framework, + displayName: FrameworkDisplayNames[framework] || framework, + iconName: iconMap[framework] || 'extension-puzzle-outline', + color: colorMap[framework] || Colors.primaryBlue, + modelCount, + }; +}; + +/** + * Format bytes to human-readable string + */ +const formatBytes = (bytes: number): string => { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; +}; + +interface ModelSelectionSheetProps { + visible: boolean; + context: ModelSelectionContext; + onClose: () => void; + onModelSelected: (model: SDKModelInfo) => Promise; +} + +export const ModelSelectionSheet: React.FC = ({ + visible, + context, + onClose, + onModelSelected, +}) => { + // State + const [availableModels, setAvailableModels] = useState([]); + const [expandedFramework, setExpandedFramework] = + useState(null); + const [isLoadingModel, setIsLoadingModel] = useState(false); + const [loadingProgress, _setLoadingProgress] = useState(''); + const [selectedModelId, setSelectedModelId] = useState(null); + // Track multiple downloads: modelId -> progress (0-1) + const [downloadingModels, setDownloadingModels] = useState< + Record + >({}); + const [deviceInfo, setDeviceInfo] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + /** + * Load available models and device info + */ + const loadData = useCallback(async () => { + setIsLoading(true); + try { + // Load models from SDK + const allModels = await RunAnywhere.getAvailableModels(); + const categoryFilter = getCategoryForContext(context); + + console.warn('[ModelSelectionSheet] All models count:', allModels.length); + console.warn('[ModelSelectionSheet] Category filter:', categoryFilter); + if (allModels.length > 0) { + console.warn( + '[ModelSelectionSheet] First model:', + JSON.stringify(allModels[0], null, 2) + ); + } + + // Filter models based on context (using category field) + // Check both enum string value and direct comparison + let filteredModels = categoryFilter + ? allModels.filter((m: SDKModelInfo) => { + const modelCategory = m.category; + const matches = + modelCategory === categoryFilter || + String(modelCategory).toLowerCase() === + String(categoryFilter).toLowerCase(); + return matches; + }) + : allModels; + + console.warn( + '[ModelSelectionSheet] Filtered models count:', + filteredModels.length + ); + + // Fallback: if no models found after filtering for LLM, show models with LlamaCpp framework + if ( + filteredModels.length === 0 && + context === ModelSelectionContext.LLM + ) { + console.warn( + '[ModelSelectionSheet] No category matches, trying framework fallback' + ); + filteredModels = allModels.filter((m: SDKModelInfo) => { + const hasLlamaFramework = + m.preferredFramework === SDKLLMFramework.LlamaCpp || + m.compatibleFrameworks?.includes(SDKLLMFramework.LlamaCpp); + return hasLlamaFramework; + }); + console.warn( + '[ModelSelectionSheet] Framework fallback models:', + filteredModels.length + ); + } + + // Ultimate fallback: just show all models for LLM context if nothing else works + if ( + filteredModels.length === 0 && + context === ModelSelectionContext.LLM && + allModels.length > 0 + ) { + console.warn( + '[ModelSelectionSheet] Using all models as final fallback' + ); + filteredModels = allModels; + } + + setAvailableModels(filteredModels); + + // Load real device info from native module + try { + const deviceInfoModule = requireDeviceInfoModule(); + const [ + modelName, + chipName, + totalMemory, + availableMemory, + hasNeuralEngine, + osVersion, + hasGPU, + cpuCores, + ] = await Promise.all([ + deviceInfoModule.getDeviceModel(), + deviceInfoModule.getChipName(), + deviceInfoModule.getTotalRAM(), + deviceInfoModule.getAvailableRAM(), + deviceInfoModule.hasNPU(), + deviceInfoModule.getOSVersion(), + deviceInfoModule.hasGPU(), + deviceInfoModule.getCPUCores(), + ]); + + setDeviceInfo({ + modelName, + chipName: chipName || 'Unknown', + totalMemory, + availableMemory, + hasNeuralEngine, + osVersion, + hasGPU, + cpuCores, + }); + } catch (error) { + console.warn( + '[ModelSelectionSheet] Failed to load device info:', + error + ); + // Fallback to basic info + setDeviceInfo({ + modelName: 'Unknown Device', + chipName: 'Unknown', + totalMemory: 0, + availableMemory: 0, + hasNeuralEngine: false, + osVersion: 'Unknown', + }); + } + } catch (error) { + console.error('[ModelSelectionSheet] Error loading data:', error); + } finally { + setIsLoading(false); + } + }, [context]); + + // Load data when visible or on mount + // This ensures models are loaded even if the sheet renders before becoming visible + useEffect(() => { + loadData(); + }, [loadData]); + + // Reload data when visibility changes to ensure fresh data + useEffect(() => { + if (visible) { + loadData(); + } + }, [visible, loadData]); + + /** + * Get frameworks with their model counts + */ + const getFrameworks = useCallback((): FrameworkDisplayInfo[] => { + const frameworkCounts = new Map(); + + console.warn( + '[ModelSelectionSheet] getFrameworks called, availableModels count:', + availableModels.length + ); + + availableModels.forEach((model: SDKModelInfo, index: number) => { + // Determine framework from model - use preferredFramework or first compatibleFramework + const frameworkValue = + model.preferredFramework || model.compatibleFrameworks?.[0]; + + if (index < 3) { + console.warn( + `[ModelSelectionSheet] Model ${index}: preferredFramework=${model.preferredFramework}, compatibleFrameworks=${JSON.stringify(model.compatibleFrameworks)}` + ); + } + + // Map string to enum if needed + let framework: LLMFramework; + if ( + typeof frameworkValue === 'string' && + frameworkValue in LLMFramework + ) { + framework = LLMFramework[frameworkValue as keyof typeof LLMFramework]; + } else if (Object.values(LLMFramework).includes(frameworkValue)) { + framework = frameworkValue as LLMFramework; + } else { + framework = LLMFramework.LlamaCpp; // Default + } + + const count = frameworkCounts.get(framework) || 0; + frameworkCounts.set(framework, count + 1); + }); + + // Add System TTS for TTS context + if (context === ModelSelectionContext.TTS) { + frameworkCounts.set(LLMFramework.SystemTTS, 1); + } + + console.warn( + '[ModelSelectionSheet] Framework counts:', + Array.from(frameworkCounts.entries()) + ); + + return Array.from(frameworkCounts.entries()) + .map(([framework, count]) => getFrameworkInfo(framework, count)) + .sort((a, b) => b.modelCount - a.modelCount); + }, [availableModels, context]); + + /** + * Get models for a specific framework + */ + const getModelsForFramework = useCallback( + (framework: LLMFramework): SDKModelInfo[] => { + return availableModels.filter((model: SDKModelInfo) => { + // Check preferredFramework first, then compatibleFrameworks + const modelFramework = + (model.preferredFramework as LLMFramework) || + (model.compatibleFrameworks?.[0] as LLMFramework) || + LLMFramework.LlamaCpp; + + // Also check if this framework is in compatibleFrameworks + const isCompatible = model.compatibleFrameworks?.includes(framework); + + return modelFramework === framework || isCompatible; + }); + }, + [availableModels] + ); + + /** + * Toggle framework expansion + */ + const toggleFramework = (framework: LLMFramework) => { + setExpandedFramework(expandedFramework === framework ? null : framework); + }; + + /** + * Handle model selection + */ + const handleSelectModel = async (model: SDKModelInfo) => { + if (!model.isDownloaded && !model.localPath) { + // Model needs to be downloaded first + return; + } + + // Don't show loading overlay - parent will close modal and show loading state + // Just call the callback and let parent handle the rest + try { + await onModelSelected(model); + // Parent is responsible for closing the modal + } catch (error) { + console.error('[ModelSelectionSheet] Error selecting model:', error); + } + }; + + /** + * Handle model download with real-time progress + * Supports multiple concurrent downloads + */ + const handleDownloadModel = async (model: SDKModelInfo) => { + // Add this model to downloading set + setDownloadingModels((prev) => ({ ...prev, [model.id]: 0 })); + + try { + // Use real download API with progress callback + await RunAnywhere.downloadModel(model.id, (progress) => { + // Update progress for this specific model + setDownloadingModels((prev) => ({ + ...prev, + [model.id]: progress.progress, + })); + console.warn( + `[Download] ${model.id}: ${Math.round(progress.progress * 100)}% (${formatBytes(progress.bytesDownloaded)} / ${formatBytes(progress.totalBytes)})` + ); + }); + + // Refresh models after download + await loadData(); + } catch (error) { + console.error('[ModelSelectionSheet] Error downloading model:', error); + } finally { + // Remove this model from downloading set + setDownloadingModels((prev) => { + const updated = { ...prev }; + delete updated[model.id]; + return updated; + }); + } + }; + + /** + * Handle System TTS selection + */ + const handleSelectSystemTTS = async () => { + try { + // Create a pseudo model for System TTS + const systemTTSModel = { + id: 'system-tts', + name: 'System TTS', + category: ModelCategory.SpeechSynthesis, + preferredFramework: LLMFramework.SystemTTS, + compatibleFrameworks: [LLMFramework.SystemTTS], + isDownloaded: true, + isAvailable: true, + downloadSize: 0, + memoryRequired: 0, + format: 'system', + } as unknown as SDKModelInfo; + + // Parent is responsible for closing the modal + await onModelSelected(systemTTSModel); + } catch (error) { + console.error('[ModelSelectionSheet] Error selecting System TTS:', error); + } + }; + + /** + * Render device status section + */ + const renderDeviceStatus = () => ( + + Device Status + {deviceInfo ? ( + + + + + Model + + {deviceInfo.modelName} + + + + + + Chip + + {deviceInfo.chipName} + + + + + + Memory + + + {formatBytes(deviceInfo.totalMemory)} + + + + {deviceInfo.cpuCores != null && deviceInfo.cpuCores > 0 && ( + + + + CPU Cores + + {deviceInfo.cpuCores} + + )} + + + + + GPU + + + + + + + + NPU/Neural Engine + + + + + ) : ( + + + Loading device info... + + )} + + ); + + /** + * Render framework row + */ + const renderFrameworkRow = (info: FrameworkDisplayInfo) => { + const isExpanded = expandedFramework === info.framework; + + return ( + + toggleFramework(info.framework)} + > + + + + + + {info.displayName} + + {info.modelCount} {info.modelCount === 1 ? 'model' : 'models'} + + + + + + + {isExpanded && renderExpandedModels(info.framework)} + + ); + }; + + /** + * Render expanded models for a framework + */ + const renderExpandedModels = (framework: LLMFramework) => { + if (framework === LLMFramework.SystemTTS) { + return renderSystemTTSRow(); + } + + const models = getModelsForFramework(framework); + + if (models.length === 0) { + return ( + + + No models available for this framework + + + ); + } + + return ( + + {models.map((model) => renderModelRow(model))} + + ); + }; + + /** + * Render System TTS row + */ + const renderSystemTTSRow = () => ( + + + + Default System Voice + + + Built-in + + AVSpeechSynthesizer + + + + + Always available + + + + + + Select + + + + ); + + /** + * Render model row + */ + const renderModelRow = (model: SDKModelInfo) => { + const isDownloading = model.id in downloadingModels; + const downloadProgress = downloadingModels[model.id] ?? 0; + const isSelected = selectedModelId === model.id; + const canSelect = model.isDownloaded || model.localPath; + + return ( + + + + {model.name} + + + + {model.downloadSize != null && model.downloadSize > 0 && ( + + + + {formatBytes(model.downloadSize)} + + + )} + + + + {(model.format || 'GGUF').toUpperCase()} + + + + + {/* Download/Status indicator */} + + {isDownloading ? ( + + + + + Downloading... {Math.round(downloadProgress * 100)}% + + + {/* Progress bar */} + + + + + ) : canSelect ? ( + <> + + + Downloaded + + + ) : ( + <> + + + Available for download + + + )} + + + + {/* Action button */} + + {isDownloading ? ( + + + + ) : canSelect ? ( + handleSelectModel(model)} + disabled={isLoadingModel || isSelected} + > + Select + + ) : ( + handleDownloadModel(model)} + disabled={isLoadingModel} + > + Download + + )} + + + ); + }; + + /** + * Render loading overlay + */ + const renderLoadingOverlay = () => { + if (!isLoadingModel) return null; + + return ( + + + + Loading Model + {loadingProgress} + + + ); + }; + + const frameworks = getFrameworks(); + + return ( + { + // Ensure state is cleaned up when modal is dismissed + setIsLoadingModel(false); + setSelectedModelId(null); + // Don't clear downloads - they continue in background + }} + > + + {/* Header */} + + + + Cancel + + + + {getContextTitle(context)} + + + + + {/* Content */} + + {isLoading ? ( + + + Loading models... + + ) : ( + <> + {renderDeviceStatus()} + + {/* Frameworks Section */} + + Available Frameworks + + {frameworks.length > 0 ? ( + frameworks.map(renderFrameworkRow) + ) : ( + + + No frameworks available + + + )} + + + + )} + + + {/* Loading Overlay */} + {renderLoadingOverlay()} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: Colors.backgroundSecondary, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: Padding.padding16, + paddingVertical: Padding.padding12, + backgroundColor: Colors.backgroundPrimary, + borderBottomWidth: 1, + borderBottomColor: Colors.borderLight, + }, + cancelButton: { + minWidth: 60, + }, + cancelText: { + ...Typography.body, + color: Colors.primaryBlue, + }, + textDisabled: { + opacity: 0.5, + }, + title: { + ...Typography.headline, + color: Colors.textPrimary, + }, + headerSpacer: { + minWidth: 60, + }, + content: { + flex: 1, + }, + loadingContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingVertical: Padding.padding60, + }, + loadingText: { + ...Typography.subheadline, + color: Colors.textSecondary, + marginTop: Spacing.medium, + }, + section: { + marginBottom: Spacing.large, + }, + sectionTitle: { + ...Typography.footnote, + color: Colors.textSecondary, + textTransform: 'uppercase', + marginHorizontal: Padding.padding16, + marginBottom: Spacing.small, + marginTop: Spacing.large, + }, + card: { + backgroundColor: Colors.backgroundPrimary, + marginHorizontal: Padding.padding16, + borderRadius: BorderRadius.medium, + overflow: 'hidden', + }, + infoRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: Padding.padding12, + paddingHorizontal: Padding.padding16, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: Colors.borderLight, + }, + infoLabel: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.smallMedium, + }, + infoLabelText: { + ...Typography.body, + color: Colors.textPrimary, + }, + infoValue: { + ...Typography.body, + color: Colors.textSecondary, + }, + frameworkRow: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: Padding.padding12, + paddingHorizontal: Padding.padding16, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: Colors.borderLight, + }, + frameworkIcon: { + width: 36, + height: 36, + borderRadius: BorderRadius.regular, + alignItems: 'center', + justifyContent: 'center', + }, + frameworkInfo: { + flex: 1, + marginLeft: Spacing.mediumLarge, + }, + frameworkName: { + ...Typography.body, + color: Colors.textPrimary, + }, + frameworkCount: { + ...Typography.caption, + color: Colors.textSecondary, + }, + modelsList: { + backgroundColor: Colors.backgroundSecondary, + paddingHorizontal: Padding.padding16, + }, + modelRow: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: Colors.backgroundPrimary, + marginVertical: Spacing.xSmall, + paddingVertical: Padding.padding12, + paddingHorizontal: Padding.padding12, + borderRadius: BorderRadius.regular, + }, + dimmed: { + opacity: 0.6, + }, + modelInfo: { + flex: 1, + }, + modelName: { + ...Typography.subheadline, + color: Colors.textPrimary, + }, + modelNameSelected: { + fontWeight: FontWeight.semibold, + }, + modelMeta: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.small, + marginTop: Spacing.xSmall, + }, + sizeTag: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.xxSmall, + }, + sizeText: { + ...Typography.caption2, + color: Colors.textSecondary, + }, + badge: { + backgroundColor: Colors.badgeGray, + paddingHorizontal: Spacing.small, + paddingVertical: Spacing.xxSmall, + borderRadius: BorderRadius.small, + }, + badgeText: { + ...Typography.caption2, + color: Colors.textSecondary, + }, + modelMetaText: { + ...Typography.caption2, + color: Colors.textSecondary, + }, + statusRow: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.xSmall, + marginTop: Spacing.xSmall, + }, + statusText: { + ...Typography.caption2, + color: Colors.textSecondary, + }, + downloadProgressContainer: { + flex: 1, + }, + downloadProgressRow: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.xSmall, + }, + progressBarBackground: { + height: 4, + backgroundColor: Colors.backgroundGray5, + borderRadius: 2, + marginTop: 6, + overflow: 'hidden', + }, + progressBarFill: { + height: '100%', + backgroundColor: Colors.primaryBlue, + borderRadius: 2, + }, + actionButtons: { + marginLeft: Spacing.medium, + }, + selectButton: { + backgroundColor: Colors.primaryBlue, + paddingHorizontal: Padding.padding12, + paddingVertical: Padding.padding6, + borderRadius: BorderRadius.regular, + }, + selectButtonText: { + ...Typography.caption, + color: Colors.textWhite, + fontWeight: FontWeight.semibold, + }, + downloadButton: { + backgroundColor: Colors.primaryBlue, + paddingHorizontal: Padding.padding12, + paddingVertical: Padding.padding6, + borderRadius: BorderRadius.regular, + }, + downloadButtonText: { + ...Typography.caption, + color: Colors.textWhite, + fontWeight: FontWeight.semibold, + }, + buttonDisabled: { + opacity: 0.5, + }, + downloadingIndicator: { + padding: Padding.padding8, + }, + emptyModels: { + padding: Padding.padding16, + alignItems: 'center', + }, + emptyText: { + ...Typography.subheadline, + color: Colors.textSecondary, + }, + loadingOverlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: Colors.overlayMedium, + alignItems: 'center', + justifyContent: 'center', + }, + loadingCard: { + backgroundColor: Colors.backgroundPrimary, + paddingHorizontal: Padding.padding40, + paddingVertical: Padding.padding30, + borderRadius: BorderRadius.large, + alignItems: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.15, + shadowRadius: 12, + elevation: 8, + }, + loadingTitle: { + ...Typography.headline, + color: Colors.textPrimary, + marginTop: Spacing.large, + }, + loadingMessage: { + ...Typography.subheadline, + color: Colors.textSecondary, + marginTop: Spacing.small, + textAlign: 'center', + }, +}); + +export default ModelSelectionSheet; diff --git a/examples/react-native/RunAnywhereAI/src/components/model/index.ts b/examples/react-native/RunAnywhereAI/src/components/model/index.ts new file mode 100644 index 000000000..5232dca55 --- /dev/null +++ b/examples/react-native/RunAnywhereAI/src/components/model/index.ts @@ -0,0 +1,9 @@ +/** + * Model Components - Exports for model-related UI components + */ + +export { + ModelSelectionSheet, + ModelSelectionContext, +} from './ModelSelectionSheet'; +export type { default as ModelSelectionSheetType } from './ModelSelectionSheet'; diff --git a/examples/react-native/RunAnywhereAI/src/navigation/TabNavigator.tsx b/examples/react-native/RunAnywhereAI/src/navigation/TabNavigator.tsx new file mode 100644 index 000000000..b4354430b --- /dev/null +++ b/examples/react-native/RunAnywhereAI/src/navigation/TabNavigator.tsx @@ -0,0 +1,121 @@ +/** + * TabNavigator - Bottom Tab Navigation + * + * Reference: iOS ContentView.swift with 5 tabs: + * - Chat (LLM) + * - STT (Speech-to-Text) + * - TTS (Text-to-Speech) + * - Voice (Voice Assistant - STT + LLM + TTS) + * - Settings + */ + +import React from 'react'; +import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; +import Icon from 'react-native-vector-icons/Ionicons'; +import { Colors } from '../theme/colors'; +import { Typography } from '../theme/typography'; +import type { RootTabParamList } from '../types'; + +// Screens +import ChatScreen from '../screens/ChatScreen'; +import STTScreen from '../screens/STTScreen'; +import TTSScreen from '../screens/TTSScreen'; +import VoiceAssistantScreen from '../screens/VoiceAssistantScreen'; +import SettingsScreen from '../screens/SettingsScreen'; + +const Tab = createBottomTabNavigator(); + +/** + * Tab icon mapping - matching Swift sample app (ContentView.swift) + */ +const tabIcons: Record< + keyof RootTabParamList, + { focused: string; unfocused: string } +> = { + Chat: { focused: 'chatbubble', unfocused: 'chatbubble-outline' }, + STT: { focused: 'pulse', unfocused: 'pulse-outline' }, // waveform equivalent + TTS: { focused: 'volume-high', unfocused: 'volume-high-outline' }, // speaker.wave.2 + Voice: { focused: 'mic', unfocused: 'mic-outline' }, // mic for voice assistant + Settings: { focused: 'settings', unfocused: 'settings-outline' }, +}; + +/** + * Tab display names - matching iOS Swift sample app (ContentView.swift) + * iOS uses: Chat, Transcribe, Speak, Voice, Settings + */ +const tabLabels: Record = { + Chat: 'Chat', + STT: 'Transcribe', + TTS: 'Speak', + Voice: 'Voice', + Settings: 'Settings', +}; + +/** + * Stable tab bar icon component to avoid react/no-unstable-nested-components + */ +const renderTabBarIcon = ( + routeName: keyof RootTabParamList, + focused: boolean, + color: string, + size: number +) => { + const iconName = focused + ? tabIcons[routeName].focused + : tabIcons[routeName].unfocused; + return ; +}; + +export const TabNavigator: React.FC = () => { + return ( + ({ + tabBarIcon: ({ focused, color, size }) => + renderTabBarIcon(route.name, focused, color, size), + tabBarActiveTintColor: Colors.primaryBlue, + tabBarInactiveTintColor: Colors.textSecondary, + tabBarStyle: { + backgroundColor: Colors.backgroundPrimary, + borderTopColor: Colors.borderLight, + }, + tabBarLabelStyle: { + ...Typography.caption2, + }, + headerShown: false, + })} + > + {/* Tab 0: Chat (LLM) */} + + {/* Tab 1: Speech-to-Text */} + + {/* Tab 2: Text-to-Speech */} + + {/* Tab 3: Voice Assistant (STT + LLM + TTS) */} + + {/* Tab 4: Settings */} + + + ); +}; + +export default TabNavigator; diff --git a/examples/react-native/RunAnywhereAI/src/screens/ChatAnalyticsScreen.tsx b/examples/react-native/RunAnywhereAI/src/screens/ChatAnalyticsScreen.tsx new file mode 100644 index 000000000..3adadb70c --- /dev/null +++ b/examples/react-native/RunAnywhereAI/src/screens/ChatAnalyticsScreen.tsx @@ -0,0 +1,763 @@ +/** + * ChatAnalyticsScreen - Chat Analytics Details + * + * Reference: iOS Features/Chat/ChatInterfaceView.swift (ChatDetailsView) + * + * Displays comprehensive analytics for a chat conversation including: + * - Overview: Conversation summary, performance highlights + * - Messages: Per-message analytics + * - Performance: Models used, thinking mode analysis + */ + +import React, { useState, useMemo } from 'react'; +import { + View, + Text, + StyleSheet, + SafeAreaView, + ScrollView, + TouchableOpacity, + FlatList, +} from 'react-native'; +import Icon from 'react-native-vector-icons/Ionicons'; +import { Colors } from '../theme/colors'; +import { Typography } from '../theme/typography'; +import { Spacing, Padding, BorderRadius } from '../theme/spacing'; +import type { Message, MessageAnalytics, Conversation } from '../types/chat'; +import { MessageRole } from '../types/chat'; + +/** + * Tab type for navigation + */ +type AnalyticsTab = 'overview' | 'messages' | 'performance'; + +interface ChatAnalyticsScreenProps { + messages: Message[]; + conversation?: Conversation; + onClose: () => void; +} + +/** + * Performance Card Component + */ +interface PerformanceCardProps { + title: string; + value: string; + icon: string; + color: string; +} + +const PerformanceCard: React.FC = ({ + title, + value, + icon, + color, +}) => ( + + + + + {value} + {title} + +); + +/** + * Metric View Component + */ +interface MetricViewProps { + label: string; + value: string; + color: string; +} + +const MetricView: React.FC = ({ label, value, color }) => ( + + {value} + {label} + +); + +/** + * Message Analytics Row Component + */ +interface MessageAnalyticsRowProps { + messageNumber: number; + message: Message; + analytics: MessageAnalytics; +} + +const MessageAnalyticsRow: React.FC = ({ + messageNumber, + message, + analytics, +}) => ( + + + Message #{messageNumber} + + {message.modelInfo && ( + + + {message.modelInfo.modelName} + + + )} + {message.modelInfo?.framework && ( + + + {message.modelInfo.framework} + + + )} + + + + + + {analytics.timeToFirstToken && ( + + )} + {analytics.averageTokensPerSecond != null && + analytics.averageTokensPerSecond > 0 && ( + + )} + {analytics.wasThinkingMode && ( + + )} + + + + {message.content.slice(0, 100)} + + +); + +export const ChatAnalyticsScreen: React.FC = ({ + messages, + conversation, + onClose, +}) => { + const [activeTab, setActiveTab] = useState('overview'); + + // Extract analytics from messages + const analyticsMessages = useMemo(() => { + return messages + .filter( + (m): m is Message & { analytics: MessageAnalytics } => + m.analytics != null + ) + .map((m) => ({ message: m, analytics: m.analytics })); + }, [messages]); + + // Computed metrics + const metrics = useMemo(() => { + if (analyticsMessages.length === 0) { + return { + averageResponseTime: 0, + averageTokensPerSecond: 0, + totalTokens: 0, + completionRate: 0, + thinkingModeCount: 0, + thinkingModePercentage: 0, + modelsUsed: new Map< + string, + { count: number; avgSpeed: number; avgTime: number } + >(), + }; + } + + const totalResponseTime = analyticsMessages.reduce( + (sum, { analytics }) => sum + analytics.totalGenerationTime, + 0 + ); + const totalTPS = analyticsMessages.reduce( + (sum, { analytics }) => sum + (analytics.averageTokensPerSecond || 0), + 0 + ); + const totalTokens = analyticsMessages.reduce( + (sum, { analytics }) => + sum + analytics.inputTokens + analytics.outputTokens, + 0 + ); + const completedCount = analyticsMessages.filter( + ({ analytics }) => analytics.completionStatus === 'completed' + ).length; + const thinkingModeCount = analyticsMessages.filter( + ({ analytics }) => analytics.wasThinkingMode + ).length; + + // Group by model + const modelGroups = new Map< + string, + { times: number[]; speeds: number[] } + >(); + analyticsMessages.forEach(({ message, analytics }) => { + const modelName = message.modelInfo?.modelName || 'Unknown'; + if (!modelGroups.has(modelName)) { + modelGroups.set(modelName, { times: [], speeds: [] }); + } + const group = modelGroups.get(modelName); + if (group) { + group.times.push(analytics.totalGenerationTime); + group.speeds.push(analytics.averageTokensPerSecond || 0); + } + }); + + const modelsUsed = new Map< + string, + { count: number; avgSpeed: number; avgTime: number } + >(); + modelGroups.forEach((data, modelName) => { + modelsUsed.set(modelName, { + count: data.times.length, + avgSpeed: data.speeds.reduce((a, b) => a + b, 0) / data.speeds.length, + avgTime: data.times.reduce((a, b) => a + b, 0) / data.times.length, + }); + }); + + return { + averageResponseTime: totalResponseTime / analyticsMessages.length / 1000, + averageTokensPerSecond: totalTPS / analyticsMessages.length, + totalTokens, + completionRate: (completedCount / analyticsMessages.length) * 100, + thinkingModeCount, + thinkingModePercentage: + (thinkingModeCount / analyticsMessages.length) * 100, + modelsUsed, + }; + }, [analyticsMessages]); + + // Conversation summary + const conversationSummary = useMemo(() => { + const userMessages = messages.filter( + (m) => m.role === MessageRole.User + ).length; + const assistantMessages = messages.filter( + (m) => m.role === MessageRole.Assistant + ).length; + return `${messages.length} messages \u2022 ${userMessages} from you, ${assistantMessages} from AI`; + }, [messages]); + + /** + * Render tab buttons + */ + const renderTabs = () => ( + + setActiveTab('overview')} + > + + + Overview + + + + setActiveTab('messages')} + > + + + Messages + + + + setActiveTab('performance')} + > + + + Performance + + + + ); + + /** + * Render Overview Tab + */ + const renderOverviewTab = () => ( + + {/* Conversation Summary Card */} + + Conversation Summary + + + {conversationSummary} + + {conversation && ( + + + + Created {new Date(conversation.createdAt).toLocaleDateString()} + + + )} + {analyticsMessages.length > 0 && ( + + + + {metrics.modelsUsed.size} model + {metrics.modelsUsed.size === 1 ? '' : 's'} used + + + )} + + + {/* Performance Highlights */} + {analyticsMessages.length > 0 && ( + + Performance Highlights + + + + + + + + )} + + {analyticsMessages.length === 0 && ( + + + No analytics data available yet + + Start a conversation to see performance metrics + + + )} + + ); + + /** + * Render Messages Tab + */ + const renderMessagesTab = () => ( + `${item.message.id}-${index}`} + renderItem={({ item, index }) => ( + + )} + contentContainerStyle={styles.messagesList} + showsVerticalScrollIndicator={false} + ListEmptyComponent={ + + + No messages with analytics + + } + /> + ); + + /** + * Render Performance Tab + */ + const renderPerformanceTab = () => ( + + {/* Models Used */} + {metrics.modelsUsed.size > 0 && ( + + Models Used + {Array.from(metrics.modelsUsed.entries()).map(([modelName, data]) => ( + + + {modelName} + + {data.count} message{data.count === 1 ? '' : 's'} + + + + + {(data.avgTime / 1000).toFixed(1)}s avg + + + {Math.round(data.avgSpeed)} tok/s + + + + ))} + + )} + + {/* Thinking Mode Analysis */} + {metrics.thinkingModeCount > 0 && ( + + Thinking Mode Analysis + + + + Used in {metrics.thinkingModeCount} messages ( + {Math.round(metrics.thinkingModePercentage)}%) + + + + )} + + {analyticsMessages.length === 0 && ( + + + No performance data available + + )} + + ); + + /** + * Render current tab content + */ + const renderTabContent = () => { + switch (activeTab) { + case 'overview': + return renderOverviewTab(); + case 'messages': + return renderMessagesTab(); + case 'performance': + return renderPerformanceTab(); + default: + return renderOverviewTab(); + } + }; + + return ( + + {/* Header */} + + Chat Analytics + + Done + + + + {/* Tabs */} + {renderTabs()} + + {/* Tab Content */} + {renderTabContent()} + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: Colors.backgroundGrouped, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: Padding.padding16, + paddingVertical: Padding.padding12, + backgroundColor: Colors.backgroundPrimary, + borderBottomWidth: 1, + borderBottomColor: Colors.borderLight, + }, + title: { + ...Typography.headline, + color: Colors.textPrimary, + }, + closeButton: { + paddingVertical: Spacing.small, + paddingHorizontal: Spacing.medium, + }, + closeButtonText: { + ...Typography.body, + color: Colors.primaryBlue, + fontWeight: '600', + }, + tabsContainer: { + flexDirection: 'row', + backgroundColor: Colors.backgroundPrimary, + borderBottomWidth: 1, + borderBottomColor: Colors.borderLight, + }, + tab: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: Padding.padding12, + gap: Spacing.xSmall, + }, + tabActive: { + borderBottomWidth: 2, + borderBottomColor: Colors.primaryBlue, + }, + tabText: { + ...Typography.footnote, + color: Colors.textSecondary, + }, + tabTextActive: { + color: Colors.primaryBlue, + fontWeight: '600', + }, + tabContent: { + flex: 1, + padding: Padding.padding16, + }, + card: { + backgroundColor: Colors.backgroundPrimary, + borderRadius: BorderRadius.medium, + padding: Padding.padding16, + marginBottom: Spacing.medium, + }, + cardTitle: { + ...Typography.headline, + color: Colors.textPrimary, + marginBottom: Spacing.medium, + }, + summaryRow: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.smallMedium, + marginBottom: Spacing.small, + }, + summaryText: { + ...Typography.subheadline, + color: Colors.textPrimary, + }, + performanceGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: Spacing.medium, + }, + performanceCard: { + flex: 1, + minWidth: '45%', + backgroundColor: Colors.backgroundSecondary, + borderRadius: BorderRadius.regular, + padding: Padding.padding12, + borderWidth: 1, + }, + performanceCardHeader: { + marginBottom: Spacing.small, + }, + performanceCardValue: { + ...Typography.title2, + color: Colors.textPrimary, + marginBottom: Spacing.xxSmall, + }, + performanceCardTitle: { + ...Typography.caption, + color: Colors.textSecondary, + }, + metricView: { + alignItems: 'center', + }, + metricValue: { + ...Typography.footnote, + fontWeight: '600', + }, + metricLabel: { + ...Typography.caption2, + color: Colors.textSecondary, + }, + messagesList: { + padding: Padding.padding16, + }, + messageRow: { + backgroundColor: Colors.backgroundPrimary, + borderRadius: BorderRadius.regular, + padding: Padding.padding16, + marginBottom: Spacing.medium, + }, + messageRowHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: Spacing.small, + }, + messageRowTitle: { + ...Typography.subheadline, + color: Colors.textPrimary, + fontWeight: '600', + }, + messageRowBadges: { + flexDirection: 'row', + gap: Spacing.small, + }, + badge: { + paddingHorizontal: Spacing.small, + paddingVertical: Spacing.xxSmall, + borderRadius: BorderRadius.small, + }, + badgeBlue: { + backgroundColor: Colors.badgeBlue, + }, + badgePurple: { + backgroundColor: Colors.badgePurple, + }, + badgeTextBlue: { + ...Typography.caption2, + color: Colors.primaryBlue, + fontWeight: '600', + }, + badgeTextPurple: { + ...Typography.caption2, + color: Colors.primaryPurple, + fontWeight: '600', + }, + metricsRow: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.large, + marginBottom: Spacing.small, + }, + messagePreview: { + ...Typography.caption, + color: Colors.textSecondary, + }, + modelRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + backgroundColor: Colors.backgroundSecondary, + borderRadius: BorderRadius.regular, + padding: Padding.padding12, + marginBottom: Spacing.small, + }, + modelInfo: { + flex: 1, + }, + modelName: { + ...Typography.subheadline, + color: Colors.textPrimary, + fontWeight: '500', + }, + modelMessages: { + ...Typography.caption, + color: Colors.textSecondary, + }, + modelStats: { + alignItems: 'flex-end', + }, + modelStatValue: { + ...Typography.caption, + color: Colors.statusGreen, + }, + modelStatSpeed: { + ...Typography.caption, + color: Colors.primaryBlue, + }, + thinkingAnalysis: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.smallMedium, + backgroundColor: `${Colors.primaryPurple}10`, + borderRadius: BorderRadius.regular, + padding: Padding.padding12, + }, + thinkingText: { + ...Typography.subheadline, + color: Colors.textPrimary, + }, + emptyState: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingVertical: Padding.padding40, + }, + emptyText: { + ...Typography.body, + color: Colors.textSecondary, + marginTop: Spacing.medium, + }, + emptySubtext: { + ...Typography.footnote, + color: Colors.textTertiary, + marginTop: Spacing.xSmall, + }, +}); + +export default ChatAnalyticsScreen; diff --git a/examples/react-native/RunAnywhereAI/src/screens/ChatScreen.tsx b/examples/react-native/RunAnywhereAI/src/screens/ChatScreen.tsx new file mode 100644 index 000000000..0af9c5bc9 --- /dev/null +++ b/examples/react-native/RunAnywhereAI/src/screens/ChatScreen.tsx @@ -0,0 +1,632 @@ +/** + * ChatScreen - Tab 0: Language Model Chat + * + * Provides LLM-powered chat interface with conversation management. + * Matches iOS ChatInterfaceView architecture and patterns. + * + * Features: + * - Conversation management (create, switch, delete) + * - Streaming LLM text generation + * - Message analytics (tokens/sec, generation time) + * - Model selection sheet + * - Model status banner (shows loaded model) + * + * Architecture: + * - Uses ConversationStore for state management (matches iOS) + * - Separates UI from business logic (View + ViewModel pattern) + * - Model loading via RunAnywhere.loadModel() + * - Text generation via RunAnywhere.generate() + * + * Reference: iOS examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Chat/Views/ChatInterfaceView.swift + */ + +import React, { useState, useRef, useCallback, useEffect } from 'react'; +import { + View, + Text, + FlatList, + StyleSheet, + SafeAreaView, + TouchableOpacity, + Alert, + Modal, +} from 'react-native'; +import Icon from 'react-native-vector-icons/Ionicons'; +import { Colors } from '../theme/colors'; +import { Typography } from '../theme/typography'; +import { Spacing, Padding, IconSize } from '../theme/spacing'; +import { ModelStatusBanner, ModelRequiredOverlay } from '../components/common'; +import { MessageBubble, TypingIndicator, ChatInput } from '../components/chat'; +import { ChatAnalyticsScreen } from './ChatAnalyticsScreen'; +import { ConversationListScreen } from './ConversationListScreen'; +import type { Message, Conversation } from '../types/chat'; +import { MessageRole } from '../types/chat'; +import type { ModelInfo } from '../types/model'; +import { ModelModality, LLMFramework, ModelCategory } from '../types/model'; +import { useConversationStore } from '../stores/conversationStore'; +import { + ModelSelectionSheet, + ModelSelectionContext, +} from '../components/model'; + +// Import RunAnywhere SDK (Multi-Package Architecture) +import { RunAnywhere, type ModelInfo as SDKModelInfo } from '@runanywhere/core'; + +// Generate unique ID +const generateId = () => Math.random().toString(36).substring(2, 15); + +export const ChatScreen: React.FC = () => { + // Conversation store + const { + conversations, + currentConversation, + initialize: initializeStore, + createConversation, + setCurrentConversation, + addMessage, + updateMessage, + } = useConversationStore(); + + // Local state + const [inputText, setInputText] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [isModelLoading, setIsModelLoading] = useState(false); + const [currentModel, setCurrentModel] = useState(null); + const [_availableModels, setAvailableModels] = useState([]); + const [showAnalytics, setShowAnalytics] = useState(false); + const [showConversationList, setShowConversationList] = useState(false); + const [showModelSelection, setShowModelSelection] = useState(false); + const [isInitialized, setIsInitialized] = useState(false); + + // Refs + const flatListRef = useRef(null); + + // Initialize conversation store and create first conversation + useEffect(() => { + const init = async () => { + await initializeStore(); + setIsInitialized(true); + }; + init(); + }, [initializeStore]); + + // Create initial conversation if none exists + useEffect(() => { + if (isInitialized && conversations.length === 0 && !currentConversation) { + createConversation(); + } else if ( + isInitialized && + !currentConversation && + conversations.length > 0 + ) { + // Set most recent conversation as current + setCurrentConversation(conversations[0] || null); + } + }, [ + isInitialized, + conversations, + currentConversation, + createConversation, + setCurrentConversation, + ]); + + // Check for loaded model and load available models on mount + useEffect(() => { + checkModelStatus(); + loadAvailableModels(); + }, []); + + // Messages from current conversation + const messages = currentConversation?.messages || []; + + /** + * Load available LLM models from catalog + */ + const loadAvailableModels = async () => { + try { + const allModels = await RunAnywhere.getAvailableModels(); + const llmModels = allModels.filter( + (m: SDKModelInfo) => m.category === ModelCategory.Language + ); + setAvailableModels(llmModels); + console.warn( + '[ChatScreen] Available LLM models:', + llmModels.map( + (m: SDKModelInfo) => + `${m.id} (${m.isDownloaded ? 'downloaded' : 'not downloaded'})` + ) + ); + } catch (error) { + console.warn('[ChatScreen] Error loading models:', error); + } + }; + + /** + * Check if a model is loaded + */ + const checkModelStatus = async () => { + try { + const isLoaded = await RunAnywhere.isModelLoaded(); + console.warn('[ChatScreen] Text model loaded:', isLoaded); + if (isLoaded) { + setCurrentModel({ + id: 'loaded-model', + name: 'Loaded Model', + category: ModelCategory.Language, + compatibleFrameworks: [LLMFramework.LlamaCpp], + preferredFramework: LLMFramework.LlamaCpp, + isDownloaded: true, + isAvailable: true, + supportsThinking: false, + }); + } + } catch (error) { + console.warn('[ChatScreen] Error checking model status:', error); + } + }; + + /** + * Handle model selection - opens the model selection sheet + */ + const handleSelectModel = useCallback(() => { + setShowModelSelection(true); + }, []); + + /** + * Handle model selected from the sheet + */ + const handleModelSelected = useCallback(async (model: SDKModelInfo) => { + // Close the modal first to prevent UI issues + setShowModelSelection(false); + // Then load the model + await loadModel(model); + }, []); + + /** + * Load a model using the SDK + */ + const loadModel = async (model: SDKModelInfo) => { + try { + setIsModelLoading(true); + console.warn( + `[ChatScreen] Loading model: ${model.id} from ${model.localPath}` + ); + + if (!model.localPath) { + Alert.alert( + 'Error', + 'Model path not found. Please re-download the model.' + ); + return; + } + + const success = await RunAnywhere.loadModel(model.localPath); + + if (success) { + setCurrentModel({ + id: model.id, + name: model.name, + category: ModelCategory.Language, + compatibleFrameworks: [LLMFramework.LlamaCpp], + preferredFramework: LLMFramework.LlamaCpp, + isDownloaded: true, + isAvailable: true, + supportsThinking: false, + }); + console.warn('[ChatScreen] Model loaded successfully'); + } else { + const lastError = await RunAnywhere.getLastError(); + Alert.alert( + 'Error', + `Failed to load model: ${lastError || 'Unknown error'}` + ); + } + } catch (error) { + console.error('[ChatScreen] Error loading model:', error); + Alert.alert('Error', `Failed to load model: ${error}`); + } finally { + setIsModelLoading(false); + } + }; + + /** + * Send a message using the real SDK with streaming (matches Swift SDK) + * Uses RunAnywhere.generateStream() for real-time token display + */ + const handleSend = useCallback(async () => { + if (!inputText.trim() || !currentConversation) return; + + const userMessage: Message = { + id: generateId(), + role: MessageRole.User, + content: inputText.trim(), + timestamp: new Date(), + }; + + // Add user message to conversation + await addMessage(userMessage, currentConversation.id); + const prompt = inputText.trim(); + setInputText(''); + setIsLoading(true); + + // Create placeholder assistant message for streaming + const assistantMessageId = generateId(); + const assistantMessage: Message = { + id: assistantMessageId, + role: MessageRole.Assistant, + content: '', + timestamp: new Date(), + }; + await addMessage(assistantMessage, currentConversation.id); + + // Scroll to bottom + setTimeout(() => { + flatListRef.current?.scrollToEnd({ animated: true }); + }, 100); + + try { + console.log('[ChatScreen] Starting streaming generation for:', prompt); + + // Use streaming generation (matches Swift SDK: RunAnywhere.generateStream) + const streamingResult = await RunAnywhere.generateStream(prompt, { + maxTokens: 1000, + temperature: 0.7, + }); + + let fullResponse = ''; + + // Stream tokens in real-time (matches Swift's for await loop) + for await (const token of streamingResult.stream) { + fullResponse += token; + + // Update assistant message content as tokens arrive + updateMessage( + { + ...assistantMessage, + content: fullResponse, + }, + currentConversation.id + ); + + // Scroll to keep up with new content + flatListRef.current?.scrollToEnd({ animated: false }); + } + + // Get final metrics after streaming completes + const result = await streamingResult.result; + console.log('[ChatScreen] Streaming complete, metrics:', result); + + // Update with final message including analytics + const finalMessage: Message = { + id: assistantMessageId, + role: MessageRole.Assistant, + content: fullResponse || '(No response generated)', + timestamp: new Date(), + modelInfo: { + modelId: result.modelUsed || 'unknown', + modelName: result.modelUsed || 'Unknown Model', + framework: result.framework || 'llama.cpp', + frameworkDisplayName: 'llama.cpp', + }, + analytics: { + totalGenerationTime: result.latencyMs, + inputTokens: result.inputTokens || Math.ceil(prompt.length / 4), + outputTokens: result.tokensUsed, + averageTokensPerSecond: result.tokensPerSecond || 0, + timeToFirstToken: result.timeToFirstTokenMs, + completionStatus: 'completed', + wasThinkingMode: false, + wasInterrupted: false, + retryCount: 0, + }, + }; + + updateMessage(finalMessage, currentConversation.id); + + // Final scroll to bottom + setTimeout(() => { + flatListRef.current?.scrollToEnd({ animated: true }); + }, 100); + } catch (error) { + console.error('[ChatScreen] Generation error:', error); + + // Update the placeholder message with error + updateMessage( + { + id: assistantMessageId, + role: MessageRole.Assistant, + content: `Error: ${error}\n\nThis likely means no LLM model is loaded. Use demo mode to test the interface, or provide a GGUF model.`, + timestamp: new Date(), + }, + currentConversation.id + ); + } finally { + setIsLoading(false); + } + }, [inputText, currentConversation, addMessage, updateMessage]); + + /** + * Create a new conversation (clears current chat) + */ + const handleNewChat = useCallback(async () => { + await createConversation(); + }, [createConversation]); + + /** + * Handle selecting a conversation from the list + */ + const handleSelectConversation = useCallback( + (conversation: Conversation) => { + setCurrentConversation(conversation); + }, + [setCurrentConversation] + ); + + /** + * Render a message + */ + const renderMessage = ({ item }: { item: Message }) => ( + + ); + + /** + * Render empty state + */ + const renderEmptyState = () => ( + + + + + Start a conversation + + Type a message below to begin chatting with the AI + + + ); + + /** + * Handle opening analytics + */ + const handleShowAnalytics = useCallback(() => { + setShowAnalytics(true); + }, []); + + /** + * Render header with actions + */ + const renderHeader = () => ( + + {/* Conversations list button */} + setShowConversationList(true)} + > + + + + {/* Title with conversation count */} + + Chat + {conversations.length > 1 && ( + + {conversations.length} conversations + + )} + + + + {/* New chat button */} + + + + + {/* Info button for chat analytics */} + + 0 ? Colors.primaryBlue : Colors.textTertiary + } + /> + + + + ); + + // Show model required overlay if no model + if (!currentModel && !isModelLoading) { + return ( + + {renderHeader()} + + + {/* Conversation List Modal */} + setShowConversationList(false)} + > + setShowConversationList(false)} + onSelectConversation={handleSelectConversation} + /> + + + {/* Model Selection Sheet */} + setShowModelSelection(false)} + onModelSelected={handleModelSelected} + /> + + ); + } + + return ( + + {renderHeader()} + + {/* Model Status Banner */} + + + {/* Messages List */} + item.id} + contentContainerStyle={[ + styles.messagesList, + messages.length === 0 && styles.emptyList, + ]} + ListEmptyComponent={renderEmptyState} + showsVerticalScrollIndicator={false} + /> + + {/* Typing Indicator */} + {isLoading && } + + {/* Input Area */} + + + {/* Analytics Modal */} + setShowAnalytics(false)} + > + setShowAnalytics(false)} + /> + + + {/* Conversation List Modal */} + setShowConversationList(false)} + > + setShowConversationList(false)} + onSelectConversation={handleSelectConversation} + /> + + + {/* Model Selection Sheet */} + setShowModelSelection(false)} + onModelSelected={handleModelSelected} + /> + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: Colors.backgroundPrimary, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: Padding.padding16, + paddingVertical: Padding.padding12, + borderBottomWidth: 1, + borderBottomColor: Colors.borderLight, + }, + titleContainer: { + alignItems: 'center', + }, + title: { + ...Typography.title2, + color: Colors.textPrimary, + }, + conversationCount: { + ...Typography.caption2, + color: Colors.textTertiary, + marginTop: 2, + }, + headerActions: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.small, + }, + headerButton: { + padding: Spacing.small, + }, + headerButtonDisabled: { + opacity: 0.5, + }, + messagesList: { + paddingVertical: Spacing.medium, + }, + emptyList: { + flex: 1, + justifyContent: 'center', + }, + emptyState: { + alignItems: 'center', + padding: Padding.padding40, + }, + emptyIconContainer: { + width: IconSize.huge, + height: IconSize.huge, + borderRadius: IconSize.huge / 2, + backgroundColor: Colors.backgroundSecondary, + justifyContent: 'center', + alignItems: 'center', + marginBottom: Spacing.large, + }, + emptyTitle: { + ...Typography.title3, + color: Colors.textPrimary, + marginBottom: Spacing.small, + }, + emptySubtitle: { + ...Typography.body, + color: Colors.textSecondary, + textAlign: 'center', + maxWidth: 280, + }, +}); + +export default ChatScreen; diff --git a/examples/react-native/RunAnywhereAI/src/screens/ConversationListScreen.tsx b/examples/react-native/RunAnywhereAI/src/screens/ConversationListScreen.tsx new file mode 100644 index 000000000..781847781 --- /dev/null +++ b/examples/react-native/RunAnywhereAI/src/screens/ConversationListScreen.tsx @@ -0,0 +1,404 @@ +/** + * ConversationListScreen - Modal for managing conversations + * + * Reference: iOS Core/Services/ConversationStore.swift (ConversationListView) + * + * Features: + * - List all conversations with search + * - Create new conversation + * - Delete conversation with confirmation + * - Switch between conversations + */ + +import React, { useState, useCallback, useMemo } from 'react'; +import { + View, + Text, + FlatList, + StyleSheet, + SafeAreaView, + TouchableOpacity, + TextInput, + Alert, +} from 'react-native'; +import Icon from 'react-native-vector-icons/Ionicons'; +import { Colors } from '../theme/colors'; +import { Typography } from '../theme/typography'; +import { Spacing, Padding, BorderRadius, IconSize } from '../theme/spacing'; +import type { Conversation } from '../types/chat'; +import { + useConversationStore, + getConversationSummary, + getLastMessagePreview, + formatRelativeDate, +} from '../stores/conversationStore'; + +interface ConversationListScreenProps { + onClose: () => void; + onSelectConversation: (conversation: Conversation) => void; +} + +/** + * ConversationRow - Individual conversation item in list + * Matches iOS ConversationRow struct + */ +interface ConversationRowProps { + conversation: Conversation; + onPress: () => void; + onDelete: () => void; +} + +/** + * Stable separator component to avoid react/no-unstable-nested-components + */ +const ItemSeparator: React.FC = () => ; + +const ConversationRow: React.FC = ({ + conversation, + onPress, + onDelete, +}) => { + const handleDelete = useCallback(() => { + Alert.alert( + 'Delete Conversation', + `Are you sure you want to delete "${conversation.title}"? This cannot be undone.`, + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete', + style: 'destructive', + onPress: onDelete, + }, + ] + ); + }, [conversation.title, onDelete]); + + return ( + + + {/* Title */} + + {conversation.title} + + + {/* Last message preview */} + + {getLastMessagePreview(conversation)} + + + {/* Summary line */} + + {getConversationSummary(conversation)} + + + {/* Bottom row: date and framework badge */} + + + {formatRelativeDate(conversation.updatedAt)} + + + {conversation.frameworkName && ( + + + {conversation.frameworkName} + + + )} + + + + {/* Delete button */} + + + + + ); +}; + +export const ConversationListScreen: React.FC = ({ + onClose, + onSelectConversation, +}) => { + const [searchQuery, setSearchQuery] = useState(''); + + const { + conversations, + createConversation, + deleteConversation, + searchConversations, + } = useConversationStore(); + + // Filter conversations based on search + const filteredConversations = useMemo(() => { + if (!searchQuery.trim()) { + return conversations; + } + return searchConversations(searchQuery); + }, [conversations, searchQuery, searchConversations]); + + /** + * Handle creating a new conversation + */ + const handleCreateConversation = useCallback(async () => { + const newConversation = await createConversation(); + onSelectConversation(newConversation); + onClose(); + }, [createConversation, onSelectConversation, onClose]); + + /** + * Handle selecting a conversation + */ + const handleSelectConversation = useCallback( + (conversation: Conversation) => { + onSelectConversation(conversation); + onClose(); + }, + [onSelectConversation, onClose] + ); + + /** + * Handle deleting a conversation + */ + const handleDeleteConversation = useCallback( + async (conversationId: string) => { + await deleteConversation(conversationId); + }, + [deleteConversation] + ); + + /** + * Render a conversation item + */ + const renderConversation = ({ item }: { item: Conversation }) => ( + handleSelectConversation(item)} + onDelete={() => handleDeleteConversation(item.id)} + /> + ); + + /** + * Render empty state + */ + const renderEmptyState = () => ( + + + + + + {searchQuery ? 'No conversations found' : 'No conversations yet'} + + + {searchQuery + ? 'Try a different search term' + : 'Tap the + button to start a new conversation'} + + + ); + + return ( + + {/* Header */} + + + Done + + + Conversations + + + + + + + {/* Search Bar */} + + + + {searchQuery.length > 0 && ( + setSearchQuery('')} + style={styles.clearButton} + > + + + )} + + + {/* Conversations List */} + item.id} + contentContainerStyle={[ + styles.listContent, + filteredConversations.length === 0 && styles.emptyListContent, + ]} + ListEmptyComponent={renderEmptyState} + showsVerticalScrollIndicator={false} + ItemSeparatorComponent={ItemSeparator} + /> + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: Colors.backgroundPrimary, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: Padding.padding16, + paddingVertical: Padding.padding12, + borderBottomWidth: 1, + borderBottomColor: Colors.borderLight, + }, + headerButton: { + minWidth: 60, + }, + doneText: { + ...Typography.body, + color: Colors.primaryBlue, + fontWeight: '600', + }, + title: { + ...Typography.title2, + color: Colors.textPrimary, + textAlign: 'center', + }, + searchContainer: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: Colors.backgroundSecondary, + borderRadius: BorderRadius.medium, + marginHorizontal: Padding.padding16, + marginVertical: Padding.padding12, + paddingHorizontal: Padding.padding12, + }, + searchIcon: { + marginRight: Spacing.small, + }, + searchInput: { + flex: 1, + ...Typography.body, + color: Colors.textPrimary, + paddingVertical: Padding.padding12, + }, + clearButton: { + padding: Spacing.small, + }, + listContent: { + paddingBottom: Spacing.large, + }, + emptyListContent: { + flex: 1, + justifyContent: 'center', + }, + separator: { + height: 1, + backgroundColor: Colors.borderLight, + marginHorizontal: Padding.padding16, + }, + conversationRow: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: Padding.padding16, + paddingVertical: Padding.padding12, + }, + conversationContent: { + flex: 1, + marginRight: Spacing.medium, + }, + conversationTitle: { + ...Typography.headline, + color: Colors.textPrimary, + marginBottom: Spacing.xSmall, + }, + conversationPreview: { + ...Typography.subheadline, + color: Colors.textSecondary, + marginBottom: Spacing.xSmall, + lineHeight: 20, + }, + conversationSummary: { + ...Typography.caption, + color: Colors.textTertiary, + marginBottom: Spacing.xSmall, + }, + conversationBottom: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.small, + }, + conversationDate: { + ...Typography.caption2, + color: Colors.textTertiary, + }, + frameworkBadge: { + backgroundColor: Colors.primaryBlue + '20', + paddingHorizontal: Spacing.small, + paddingVertical: 2, + borderRadius: BorderRadius.small, + }, + frameworkBadgeText: { + ...Typography.caption2, + color: Colors.primaryBlue, + fontWeight: '600', + }, + deleteButton: { + padding: Spacing.small, + }, + emptyState: { + alignItems: 'center', + padding: Padding.padding40, + }, + emptyIconContainer: { + width: IconSize.huge, + height: IconSize.huge, + borderRadius: IconSize.huge / 2, + backgroundColor: Colors.backgroundSecondary, + justifyContent: 'center', + alignItems: 'center', + marginBottom: Spacing.large, + }, + emptyTitle: { + ...Typography.title3, + color: Colors.textPrimary, + marginBottom: Spacing.small, + }, + emptySubtitle: { + ...Typography.body, + color: Colors.textSecondary, + textAlign: 'center', + maxWidth: 280, + }, +}); + +export default ConversationListScreen; diff --git a/examples/react-native/RunAnywhereAI/src/screens/STTScreen.tsx b/examples/react-native/RunAnywhereAI/src/screens/STTScreen.tsx new file mode 100644 index 000000000..bb2306f43 --- /dev/null +++ b/examples/react-native/RunAnywhereAI/src/screens/STTScreen.tsx @@ -0,0 +1,1200 @@ +/** + * STTScreen - Tab 1: Speech-to-Text + * + * Provides on-device speech recognition with real-time transcription. + * Matches iOS SpeechToTextView architecture and patterns. + * + * Features: + * - Batch mode: Record first, then transcribe + * - Live mode: Real-time transcription (streaming) + * - Model selection sheet + * - Audio level visualization + * - Model status banner + * + * Architecture: + * - Uses native audio recording (AudioService) + * - Model loading via RunAnywhere.loadSTTModel() + * - Transcription via RunAnywhere.transcribeAudio() + * - Supports ONNX-based Whisper models + * + * Reference: iOS examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Voice/SpeechToTextView.swift + */ + +import React, { useState, useCallback, useEffect, useRef } from 'react'; +import { + View, + Text, + StyleSheet, + SafeAreaView, + TouchableOpacity, + ScrollView, + Alert, + Platform, + Animated, + PermissionsAndroid, + Linking, +} from 'react-native'; +import Icon from 'react-native-vector-icons/Ionicons'; +import { useFocusEffect } from '@react-navigation/native'; +import RNFS from 'react-native-fs'; +import { check, request, PERMISSIONS, RESULTS } from 'react-native-permissions'; +import { Colors } from '../theme/colors'; +import { Typography } from '../theme/typography'; +import { Spacing, Padding, BorderRadius, ButtonHeight } from '../theme/spacing'; +import { ModelStatusBanner, ModelRequiredOverlay } from '../components/common'; +import { + ModelSelectionSheet, + ModelSelectionContext, +} from '../components/model'; +import type { ModelInfo } from '../types/model'; +import { ModelModality, LLMFramework } from '../types/model'; +import { STTMode } from '../types/voice'; + +// Import RunAnywhere SDK (Multi-Package Architecture) +import { RunAnywhere, type ModelInfo as SDKModelInfo } from '@runanywhere/core'; + +// STT Model IDs (kept for reference, uses SDK model registry) +const _STT_MODEL_IDS = ['whisper-tiny-en', 'whisper-base-en']; + +export const STTScreen: React.FC = () => { + // State + const [mode, setMode] = useState(STTMode.Batch); + const [isRecording, setIsRecording] = useState(false); + const [isProcessing, setIsProcessing] = useState(false); + const [transcript, setTranscript] = useState(''); + const [partialTranscript, setPartialTranscript] = useState(''); // For live mode - current chunk + const [confidence, setConfidence] = useState(null); + const [currentModel, setCurrentModel] = useState(null); + const [isModelLoading, setIsModelLoading] = useState(false); + const [_availableModels, setAvailableModels] = useState([]); + const [recordingDuration, setRecordingDuration] = useState(0); + const [audioLevel, setAudioLevel] = useState(0); + const [showModelSelection, setShowModelSelection] = useState(false); + + // Audio recording path ref (for batch mode only) + const recordingPath = useRef(null); + + // Live mode accumulated transcript ref + const accumulatedTranscriptRef = useRef(''); + + // Live mode interval-based recording refs + const liveRecordingIntervalRef = useRef(null); + const isLiveRecordingRef = useRef(false); + const liveChunkCountRef = useRef(0); + + // Animation for recording indicator + const pulseAnim = useRef(new Animated.Value(1)).current; + + // Start pulse animation when recording + useEffect(() => { + if (isRecording) { + const pulse = Animated.loop( + Animated.sequence([ + Animated.timing(pulseAnim, { + toValue: 1.3, + duration: 500, + useNativeDriver: true, + }), + Animated.timing(pulseAnim, { + toValue: 1, + duration: 500, + useNativeDriver: true, + }), + ]) + ); + pulse.start(); + return () => pulse.stop(); + } else { + pulseAnim.setValue(1); + } + }, [isRecording, pulseAnim]); + + // Cleanup on unmount + useEffect(() => { + return () => { + // Stop live recording if active + isLiveRecordingRef.current = false; + if (liveRecordingIntervalRef.current) { + clearTimeout(liveRecordingIntervalRef.current); + liveRecordingIntervalRef.current = null; + } + // Stop SDK streaming if active (method may not be exposed on public API) + const sdk = RunAnywhere as unknown as Record; + if (typeof sdk.stopStreamingSTT === 'function') { + (sdk.stopStreamingSTT as () => Promise)().catch(() => {}); + } + // Stop batch mode recorder + RunAnywhere.Audio.cleanup().catch(() => {}); + // Clean up temp audio file + if (recordingPath.current) { + RNFS.unlink(recordingPath.current).catch(() => {}); + } + }; + }, []); + + /** + * Load available models and check for loaded model + * Called on mount and when screen comes into focus + */ + const loadModels = useCallback(async () => { + try { + // Get available STT models from catalog + const allModels = await RunAnywhere.getAvailableModels(); + // Filter by category (speech-recognition) matching SDK's ModelCategory + const sttModels = allModels.filter( + (m: SDKModelInfo) => m.category === 'speech-recognition' + ); + setAvailableModels(sttModels); + + // Log downloaded status for debugging + const downloadedModels = sttModels.filter((m) => m.isDownloaded); + console.warn( + '[STTScreen] Available STT models:', + sttModels.map((m) => `${m.id} (downloaded: ${m.isDownloaded})`) + ); + console.warn( + '[STTScreen] Downloaded STT models:', + downloadedModels.map((m) => m.id) + ); + + // Check if model is already loaded + const isLoaded = await RunAnywhere.isSTTModelLoaded(); + console.warn('[STTScreen] isSTTModelLoaded:', isLoaded); + if (isLoaded && !currentModel) { + // Try to find which model is loaded from downloaded models + const downloadedStt = sttModels.filter((m) => m.isDownloaded); + if (downloadedStt.length > 0) { + const firstModel = downloadedStt[0]; + if (firstModel) { + setCurrentModel({ + id: firstModel.id, + name: firstModel.name, + preferredFramework: LLMFramework.ONNX, + } as ModelInfo); + console.warn( + '[STTScreen] Set currentModel from downloaded:', + firstModel.name + ); + } + } else { + setCurrentModel({ + id: 'stt-model', + name: 'STT Model (Loaded)', + preferredFramework: LLMFramework.ONNX, + } as ModelInfo); + console.warn('[STTScreen] Set currentModel as generic STT Model'); + } + } + } catch (error) { + console.warn('[STTScreen] Error loading models:', error); + } + }, [currentModel]); + + // Refresh models when screen comes into focus + // This ensures we pick up any models downloaded in the Settings tab + useFocusEffect( + useCallback(() => { + console.warn('[STTScreen] Screen focused - refreshing models'); + loadModels(); + }, [loadModels]) + ); + + /** + * Handle model selection - opens model selection sheet + */ + const handleSelectModel = useCallback(() => { + setShowModelSelection(true); + }, []); + + /** + * Handle model selected from the sheet + */ + const handleModelSelected = useCallback(async (model: SDKModelInfo) => { + // Close the modal first to prevent UI issues + setShowModelSelection(false); + // Then load the model + await loadModel(model); + }, []); + + /** + * Load a model from its info + */ + const loadModel = async (model: SDKModelInfo) => { + try { + setIsModelLoading(true); + console.warn( + `[STTScreen] Loading model: ${model.id} from ${model.localPath}` + ); + + if (!model.localPath) { + Alert.alert( + 'Error', + 'Model path not found. Please re-download the model.' + ); + return; + } + + // Pass the path directly - C++ extractArchiveIfNeeded handles archive extraction + // and finding the correct nested model folder + const success = await RunAnywhere.loadSTTModel( + model.localPath, + model.category || 'whisper' + ); + + if (success) { + const isLoaded = await RunAnywhere.isSTTModelLoaded(); + if (isLoaded) { + // Set model with framework so ModelStatusBanner shows it properly + // Use ONNX since STT uses Sherpa-ONNX (ONNX Runtime) + setCurrentModel({ + id: model.id, + name: model.name, + preferredFramework: LLMFramework.ONNX, + } as ModelInfo); + console.warn( + `[STTScreen] Model ${model.name} loaded successfully, currentModel set` + ); + } else { + console.warn( + `[STTScreen] Model reported success but isSTTModelLoaded() returned false` + ); + } + } else { + const error = await RunAnywhere.getLastError(); + Alert.alert( + 'Error', + `Failed to load model: ${error || 'Unknown error'}` + ); + } + } catch (error) { + console.error('[STTScreen] Error loading model:', error); + Alert.alert('Error', `Failed to load model: ${error}`); + } finally { + setIsModelLoading(false); + } + }; + + /** + * Format duration in MM:SS + */ + const formatDuration = (ms: number): string => { + const totalSeconds = Math.floor(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}:${seconds.toString().padStart(2, '0')}`; + }; + + /** + * Request microphone permission + */ + const requestMicrophonePermission = async (): Promise => { + try { + if (Platform.OS === 'ios') { + const status = await check(PERMISSIONS.IOS.MICROPHONE); + console.warn('[STTScreen] iOS microphone permission status:', status); + + if (status === RESULTS.GRANTED) { + return true; + } + + if (status === RESULTS.DENIED) { + const result = await request(PERMISSIONS.IOS.MICROPHONE); + console.warn( + '[STTScreen] iOS microphone permission request result:', + result + ); + return result === RESULTS.GRANTED; + } + + if (status === RESULTS.BLOCKED) { + Alert.alert( + 'Microphone Permission Required', + 'Please enable microphone access in Settings to use speech-to-text.', + [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Open Settings', onPress: () => Linking.openSettings() }, + ] + ); + return false; + } + + return false; + } else { + // Android + const granted = await PermissionsAndroid.request( + PermissionsAndroid.PERMISSIONS.RECORD_AUDIO, + { + title: 'Microphone Permission', + message: + 'RunAnywhereAI needs access to your microphone for speech-to-text.', + buttonNeutral: 'Ask Me Later', + buttonNegative: 'Cancel', + buttonPositive: 'OK', + } + ); + return granted === PermissionsAndroid.RESULTS.GRANTED; + } + } catch (error) { + console.error('[STTScreen] Permission request error:', error); + return false; + } + }; + + /** + * Start recording audio + */ + const startRecording = async () => { + try { + console.warn('[STTScreen] Starting recording...'); + + // Request microphone permission first + const hasPermission = await requestMicrophonePermission(); + if (!hasPermission) { + console.warn('[STTScreen] Microphone permission denied'); + return; + } + + // Start recording using expo-av + console.warn('[STTScreen] Starting recorder...'); + const uri = await RunAnywhere.Audio.startRecording({ + onProgress: (currentPositionMs, metering) => { + setRecordingDuration(currentPositionMs); + // Convert metering level to 0-1 range (metering is typically negative dB) + const level = metering + ? Math.max(0, Math.min(1, (metering + 60) / 60)) + : 0; + setAudioLevel(level); + }, + }); + + // Store the returned URI as the recording path + recordingPath.current = uri; + console.warn('[STTScreen] Recording started at:', uri); + + setIsRecording(true); + setTranscript(''); + setConfidence(null); + } catch (error) { + console.error('[STTScreen] Error starting recording:', error); + Alert.alert('Recording Error', `Failed to start recording: ${error}`); + } + }; + + /** + * Stop recording and transcribe + * Native module handles audio format conversion using iOS AudioToolbox + */ + const stopRecordingAndTranscribe = async () => { + try { + console.warn('[STTScreen] Stopping recording...'); + + // Stop recording + const { uri } = await RunAnywhere.Audio.stopRecording(); + setIsRecording(false); + setIsProcessing(true); + + console.warn('[STTScreen] Recording stopped, file at:', uri); + + // Use the URI returned by stopRecorder + const filePath = uri || recordingPath.current; + if (!filePath) { + throw new Error('Recording path not found'); + } + + // Normalize path - remove file:// prefix for RNFS and native module + const normalizedPath = filePath.startsWith('file://') + ? filePath.substring(7) + : filePath; + + console.warn('[STTScreen] Normalized path:', normalizedPath); + + const exists = await RNFS.exists(normalizedPath); + if (!exists) { + throw new Error('Recorded file not found at: ' + normalizedPath); + } + + const stat = await RNFS.stat(normalizedPath); + console.warn('[STTScreen] Recording file size:', stat.size, 'bytes'); + + if (stat.size < 1000) { + throw new Error('Recording too short'); + } + + // Check if model is loaded + const isLoaded = await RunAnywhere.isSTTModelLoaded(); + if (!isLoaded) { + throw new Error('STT model not loaded'); + } + + // Transcribe the audio file - native module handles format conversion + // iOS AudioToolbox converts M4A/CAF/WAV to 16kHz mono float32 PCM + console.warn('[STTScreen] Starting transcription...'); + const result = await RunAnywhere.transcribeFile(normalizedPath, { + language: 'en', + }); + + console.warn('[STTScreen] Transcription result:', result); + + if (result.text) { + setTranscript(result.text); + setConfidence(result.confidence); + } else { + setTranscript('(No speech detected)'); + } + + // Clean up temp file + await RNFS.unlink(normalizedPath).catch(() => {}); + recordingPath.current = null; + } catch (error: unknown) { + console.error('[STTScreen] Transcription error:', error); + const errorMessage = + error instanceof Error ? error.message : String(error); + Alert.alert('Transcription Error', errorMessage); + setTranscript(''); + } finally { + setIsProcessing(false); + setRecordingDuration(0); + setAudioLevel(0); + } + }; + + /** + * Start live transcription mode + * + * Implements pseudo-streaming for Whisper models (which are batch-only): + * Records audio in intervals (3 seconds), transcribes each chunk, and + * accumulates results for a live-like experience matching Swift SDK. + */ + const startLiveTranscription = async () => { + try { + console.warn( + '[STTScreen] Starting live transcription (pseudo-streaming)...' + ); + + // Request microphone permission first + const hasPermission = await requestMicrophonePermission(); + if (!hasPermission) { + console.warn('[STTScreen] Microphone permission denied'); + return; + } + + // Check if model is loaded + const isLoaded = await RunAnywhere.isSTTModelLoaded(); + if (!isLoaded) { + Alert.alert('Model Not Loaded', 'Please load an STT model first.'); + return; + } + + // Reset state + accumulatedTranscriptRef.current = ''; + setTranscript(''); + setPartialTranscript('Listening...'); + setConfidence(null); + setRecordingDuration(0); + isLiveRecordingRef.current = true; + liveChunkCountRef.current = 0; + + // Start initial recording chunk + await startLiveChunk(); + setIsRecording(true); + + console.warn('[STTScreen] Live transcription started'); + } catch (error) { + console.error('[STTScreen] Error starting live transcription:', error); + Alert.alert( + 'Recording Error', + `Failed to start live transcription: ${error}` + ); + isLiveRecordingRef.current = false; + } + }; + + /** + * Start recording a live chunk (called repeatedly for pseudo-streaming) + */ + const startLiveChunk = async () => { + if (!isLiveRecordingRef.current) { + console.warn( + '[STTScreen] Live recording stopped, not starting new chunk' + ); + return; + } + + try { + liveChunkCountRef.current++; + const chunkNum = liveChunkCountRef.current; + console.warn(`[STTScreen] Starting live chunk #${chunkNum}...`); + + // Record with expo-av + const path = await RunAnywhere.Audio.startRecording({ + onProgress: (currentPositionMs, metering) => { + const duration = Math.floor(currentPositionMs / 1000); + setRecordingDuration(duration); + // Update audio level from metering + if (metering !== undefined) { + const normalized = Math.max(0, Math.min(1, (metering + 60) / 60)); + setAudioLevel(normalized); + } + }, + }); + recordingPath.current = path; + console.warn(`[STTScreen] Live chunk #${chunkNum} recording at:`, path); + + // Schedule transcription after interval (3 seconds for each chunk) + liveRecordingIntervalRef.current = setTimeout(async () => { + if (isLiveRecordingRef.current) { + await transcribeLiveChunk(); + } + }, 3000); + } catch (error) { + console.error('[STTScreen] Error starting live chunk:', error); + } + }; + + /** + * Transcribe the current live chunk and start the next one + * Uses react-native-audio-api for audio decoding + */ + const transcribeLiveChunk = async () => { + if (!isLiveRecordingRef.current) { + return; + } + + try { + console.warn('[STTScreen] Transcribing live chunk...'); + setPartialTranscript('Processing...'); + + // Stop current recording + const { uri: resultPath } = await RunAnywhere.Audio.stopRecording(); + + // Get the path + let audioPath = resultPath; + if (audioPath.startsWith('file://')) { + audioPath = audioPath.replace('file://', ''); + } + + // Check file exists + const exists = await RNFS.exists(audioPath); + if (!exists) { + console.warn('[STTScreen] Live chunk file not found'); + setPartialTranscript('Listening...'); + if (isLiveRecordingRef.current) { + await startLiveChunk(); + } + return; + } + + // Check file size (skip very small files) + const stat = await RNFS.stat(audioPath); + if (stat.size < 5000) { + console.warn('[STTScreen] Chunk too small, skipping transcription'); + setPartialTranscript('Listening...'); + if (isLiveRecordingRef.current) { + await startLiveChunk(); + } + return; + } + + // Transcribe using native module (handles audio decoding) + const result = await RunAnywhere.transcribeFile(audioPath, { + language: 'en', + }); + console.warn('[STTScreen] Live chunk transcription:', result.text); + + // Append to accumulated transcript if we got text + if (result.text && result.text.trim() && result.text.trim() !== '') { + const newText = result.text.trim(); + if (accumulatedTranscriptRef.current) { + accumulatedTranscriptRef.current += ' ' + newText; + } else { + accumulatedTranscriptRef.current = newText; + } + setTranscript(accumulatedTranscriptRef.current); + setConfidence(result.confidence || null); + } + + // Clean up chunk file + await RNFS.unlink(audioPath).catch(() => {}); + + // Update partial transcript for next chunk + setPartialTranscript('Listening...'); + + // Start next chunk if still recording + if (isLiveRecordingRef.current) { + await startLiveChunk(); + } + } catch (error) { + console.error('[STTScreen] Error transcribing live chunk:', error); + setPartialTranscript('Listening...'); + // Try to continue with next chunk + if (isLiveRecordingRef.current) { + await startLiveChunk(); + } + } + }; + + /** + * Stop live transcription + * Uses react-native-audio-api for final chunk decoding + */ + const stopLiveTranscription = async () => { + console.warn('[STTScreen] Stopping live transcription...'); + isLiveRecordingRef.current = false; + + // Clear any pending interval + if (liveRecordingIntervalRef.current) { + clearTimeout(liveRecordingIntervalRef.current); + liveRecordingIntervalRef.current = null; + } + + try { + setIsProcessing(true); + setPartialTranscript('Processing final chunk...'); + + // Stop current recording + const { uri: resultPath } = await RunAnywhere.Audio.stopRecording().catch( + () => ({ uri: '', durationMs: 0 }) + ); + + // Transcribe final chunk if there's audio + if (resultPath && recordingPath.current) { + let audioPath = resultPath; + if (audioPath.startsWith('file://')) { + audioPath = audioPath.replace('file://', ''); + } + + const exists = await RNFS.exists(audioPath); + if (exists) { + const stat = await RNFS.stat(audioPath); + if (stat.size >= 5000) { + console.warn('[STTScreen] Transcribing final live chunk...'); + // Transcribe using native module (handles audio decoding) + const result = await RunAnywhere.transcribeFile(audioPath, { + language: 'en', + }); + if (result.text && result.text.trim()) { + const newText = result.text.trim(); + if (accumulatedTranscriptRef.current) { + accumulatedTranscriptRef.current += ' ' + newText; + } else { + accumulatedTranscriptRef.current = newText; + } + setTranscript(accumulatedTranscriptRef.current); + setConfidence(result.confidence || null); + } + } + await RNFS.unlink(audioPath).catch(() => {}); + } + } + + console.warn('[STTScreen] Live transcription stopped'); + console.warn( + '[STTScreen] Final transcript:', + accumulatedTranscriptRef.current + ); + } catch (error) { + console.error('[STTScreen] Error stopping live transcription:', error); + } finally { + setIsRecording(false); + setIsProcessing(false); + setPartialTranscript(''); + setRecordingDuration(0); + setAudioLevel(0); + recordingPath.current = null; + } + }; + + /** + * Toggle recording + */ + const handleToggleRecording = useCallback(async () => { + if (isRecording) { + // Stop recording based on mode + if (mode === STTMode.Live) { + await stopLiveTranscription(); + } else { + await stopRecordingAndTranscribe(); + } + } else { + if (!currentModel) { + Alert.alert('Model Required', 'Please select an STT model first.'); + return; + } + // Start recording based on mode + if (mode === STTMode.Live) { + await startLiveTranscription(); + } else { + await startRecording(); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isRecording, currentModel, mode]); + + /** + * Clear transcript + */ + const handleClear = useCallback(() => { + setTranscript(''); + setPartialTranscript(''); + setConfidence(null); + accumulatedTranscriptRef.current = ''; + }, []); + + /** + * Render mode selector + */ + const renderModeSelector = () => ( + + setMode(STTMode.Batch)} + activeOpacity={0.7} + > + + Batch + + + setMode(STTMode.Live)} + activeOpacity={0.7} + > + + Live + + + + ); + + /** + * Render mode description + */ + const renderModeDescription = () => ( + + + + {mode === STTMode.Batch + ? 'Record audio, then transcribe all at once for best accuracy.' + : 'Transcribes every few seconds while you speak.'} + + + ); + + /** + * Render header + */ + const renderHeader = () => ( + + Speech to Text + {transcript && ( + + + + )} + + ); + + /** + * Render audio level indicator + */ + const renderAudioLevel = () => { + if (!isRecording) return null; + + return ( + + + + + + {formatDuration(recordingDuration)} + + + ); + }; + + // Show model required overlay if no model + if (!currentModel && !isModelLoading) { + return ( + + {renderHeader()} + + {/* Model Selection Sheet */} + setShowModelSelection(false)} + onModelSelected={handleModelSelected} + /> + + ); + } + + return ( + + {renderHeader()} + + {/* Model Status Banner */} + + + {/* Mode Selector */} + {renderModeSelector()} + + {/* Mode Description */} + {renderModeDescription()} + + {/* Audio Level Indicator */} + {renderAudioLevel()} + + {/* Transcription Area */} + + {transcript || partialTranscript ? ( + <> + + {transcript} + {partialTranscript ? ( + + {' '} + {partialTranscript} + + ) : null} + + {confidence !== null && !isRecording && ( + + Confidence: + + {Math.round(confidence * 100)}% + + + )} + {isRecording && mode === STTMode.Live && ( + + + Live transcribing... + + )} + + ) : isProcessing ? ( + + + Transcribing audio... + + ) : isRecording ? ( + + + + {mode === STTMode.Live ? 'Live transcribing...' : 'Listening...'} + + + {mode === STTMode.Live + ? 'Text will appear as you speak' + : 'Tap the button when done speaking'} + + + ) : ( + + + Tap the microphone to start + + )} + + + {/* Record Button */} + + + + + + {isRecording ? 'Tap to stop' : 'Tap to record'} + + + + {/* Model Selection Sheet */} + setShowModelSelection(false)} + onModelSelected={handleModelSelected} + /> + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: Colors.backgroundPrimary, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: Padding.padding16, + paddingVertical: Padding.padding12, + borderBottomWidth: 1, + borderBottomColor: Colors.borderLight, + }, + title: { + ...Typography.title2, + color: Colors.textPrimary, + }, + clearButton: { + padding: Spacing.small, + }, + modeSelector: { + flexDirection: 'row', + marginHorizontal: Padding.padding16, + marginTop: Spacing.medium, + backgroundColor: Colors.backgroundSecondary, + borderRadius: BorderRadius.regular, + padding: Spacing.xSmall, + }, + modeButton: { + flex: 1, + paddingVertical: Spacing.smallMedium, + alignItems: 'center', + borderRadius: BorderRadius.small, + }, + modeButtonActive: { + backgroundColor: Colors.backgroundPrimary, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.1, + shadowRadius: 2, + elevation: 2, + }, + modeButtonText: { + ...Typography.subheadline, + color: Colors.textSecondary, + }, + modeButtonTextActive: { + color: Colors.textPrimary, + fontWeight: '600', + }, + modeDescription: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.smallMedium, + marginHorizontal: Padding.padding16, + marginTop: Spacing.medium, + padding: Padding.padding12, + backgroundColor: Colors.badgeBlue, + borderRadius: BorderRadius.regular, + }, + modeDescriptionText: { + ...Typography.footnote, + color: Colors.primaryBlue, + flex: 1, + }, + audioLevelContainer: { + flexDirection: 'row', + alignItems: 'center', + marginHorizontal: Padding.padding16, + marginTop: Spacing.medium, + gap: Spacing.medium, + }, + audioLevelTrack: { + flex: 1, + height: 4, + backgroundColor: Colors.backgroundGray5, + borderRadius: 2, + overflow: 'hidden', + }, + audioLevelFill: { + height: '100%', + backgroundColor: Colors.primaryGreen, + }, + recordingTime: { + ...Typography.caption, + color: Colors.textSecondary, + minWidth: 40, + textAlign: 'right', + }, + transcriptContainer: { + flex: 1, + marginHorizontal: Padding.padding16, + marginTop: Spacing.large, + }, + transcriptContent: { + flex: 1, + }, + transcriptText: { + ...Typography.body, + color: Colors.textPrimary, + lineHeight: 26, + }, + partialTranscript: { + color: Colors.textSecondary, + fontStyle: 'italic', + }, + liveIndicator: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.small, + marginTop: Spacing.medium, + paddingTop: Spacing.medium, + borderTopWidth: 1, + borderTopColor: Colors.borderLight, + }, + liveDot: { + width: 8, + height: 8, + borderRadius: 4, + backgroundColor: Colors.primaryRed, + }, + liveText: { + ...Typography.footnote, + color: Colors.textSecondary, + }, + confidenceContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.small, + marginTop: Spacing.medium, + paddingTop: Spacing.medium, + borderTopWidth: 1, + borderTopColor: Colors.borderLight, + }, + confidenceLabel: { + ...Typography.footnote, + color: Colors.textSecondary, + }, + confidenceValue: { + ...Typography.footnote, + color: Colors.primaryGreen, + fontWeight: '600', + }, + processingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + gap: Spacing.medium, + }, + processingText: { + ...Typography.body, + color: Colors.textSecondary, + }, + recordingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + gap: Spacing.medium, + }, + recordingIndicator: { + width: 20, + height: 20, + borderRadius: 10, + backgroundColor: Colors.primaryRed, + }, + recordingText: { + ...Typography.body, + color: Colors.textPrimary, + }, + recordingHint: { + ...Typography.footnote, + color: Colors.textSecondary, + }, + emptyState: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + gap: Spacing.medium, + }, + emptyText: { + ...Typography.body, + color: Colors.textSecondary, + }, + controlsContainer: { + alignItems: 'center', + paddingVertical: Padding.padding20, + paddingBottom: Padding.padding40, + }, + recordButton: { + width: ButtonHeight.large, + height: ButtonHeight.large, + borderRadius: ButtonHeight.large / 2, + backgroundColor: Colors.primaryBlue, + justifyContent: 'center', + alignItems: 'center', + shadowColor: Colors.primaryBlue, + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 8, + elevation: 8, + }, + recordButtonActive: { + backgroundColor: Colors.primaryRed, + shadowColor: Colors.primaryRed, + }, + recordButtonLabel: { + ...Typography.footnote, + color: Colors.textSecondary, + marginTop: Spacing.smallMedium, + }, +}); + +export default STTScreen; diff --git a/examples/react-native/RunAnywhereAI/src/screens/SettingsScreen.tsx b/examples/react-native/RunAnywhereAI/src/screens/SettingsScreen.tsx new file mode 100644 index 000000000..be8f6a851 --- /dev/null +++ b/examples/react-native/RunAnywhereAI/src/screens/SettingsScreen.tsx @@ -0,0 +1,1525 @@ +/** + * SettingsScreen - Tab 4: Settings & Storage + * + * Provides SDK configuration, model management, and storage overview. + * Matches iOS CombinedSettingsView architecture and patterns. + * + * Features: + * - Generation settings (temperature, max tokens) + * - API configuration + * - Storage overview (total usage, available space, models storage) + * - Downloaded models list with delete functionality + * - Storage management (clear cache, clean temp files) + * - SDK info (version, capabilities, loaded models) + * + * Architecture: + * - Fetches SDK state via RunAnywhere methods + * - Shows available vs downloaded models + * - Manages model downloads and deletions + * - Displays backend info and capabilities + * + * Reference: iOS examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Settings/CombinedSettingsView.swift + */ + +import React, { useState, useCallback, useEffect } from 'react'; +import { + View, + Text, + StyleSheet, + SafeAreaView, + ScrollView, + TouchableOpacity, + Alert, + TextInput, + Modal, +} from 'react-native'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import Icon from 'react-native-vector-icons/Ionicons'; +import { Colors } from '../theme/colors'; +import { Typography } from '../theme/typography'; +import { Spacing, Padding, BorderRadius } from '../theme/spacing'; +import type { StorageInfo } from '../types/settings'; +import { + RoutingPolicy, + RoutingPolicyDisplayNames, + SETTINGS_CONSTRAINTS, +} from '../types/settings'; +import { LLMFramework, FrameworkDisplayNames } from '../types/model'; + +// Import RunAnywhere SDK (Multi-Package Architecture) +import { RunAnywhere, type ModelInfo } from '@runanywhere/core'; + +// Storage keys for API configuration +const STORAGE_KEYS = { + API_KEY: '@runanywhere_api_key', + BASE_URL: '@runanywhere_base_url', + DEVICE_REGISTERED: '@runanywhere_device_registered', +}; + +/** + * Get stored API key (for use at app launch) + */ +export const getStoredApiKey = async (): Promise => { + try { + return await AsyncStorage.getItem(STORAGE_KEYS.API_KEY); + } catch { + return null; + } +}; + +/** + * Get stored base URL (for use at app launch) + * Automatically adds https:// if no scheme is present + */ +export const getStoredBaseURL = async (): Promise => { + try { + const value = await AsyncStorage.getItem(STORAGE_KEYS.BASE_URL); + if (!value) return null; + const trimmed = value.trim(); + if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { + return trimmed; + } + return `https://${trimmed}`; + } catch { + return null; + } +}; + +/** + * Check if custom configuration is set + */ +export const hasCustomConfiguration = async (): Promise => { + const apiKey = await getStoredApiKey(); + const baseURL = await getStoredBaseURL(); + return apiKey !== null && baseURL !== null && apiKey !== '' && baseURL !== ''; +}; + +// Default storage info +const DEFAULT_STORAGE_INFO: StorageInfo = { + totalStorage: 256 * 1024 * 1024 * 1024, + appStorage: 0, + modelsStorage: 0, + cacheSize: 0, + freeSpace: 100 * 1024 * 1024 * 1024, +}; + +/** + * Format bytes to human readable + */ +const formatBytes = (bytes: number): string => { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; +}; + +export const SettingsScreen: React.FC = () => { + // Settings state + const [routingPolicy, setRoutingPolicy] = useState( + RoutingPolicy.Automatic + ); + const [temperature, setTemperature] = useState(0.7); + const [maxTokens, setMaxTokens] = useState(10000); + const [apiKeyConfigured, setApiKeyConfigured] = useState(false); + + // API Configuration state + const [apiKey, setApiKey] = useState(''); + const [baseURL, setBaseURL] = useState(''); + const [isBaseURLConfigured, setIsBaseURLConfigured] = useState(false); + const [showApiConfigModal, setShowApiConfigModal] = useState(false); + const [showPassword, setShowPassword] = useState(false); + + // Storage state + const [storageInfo, setStorageInfo] = + useState(DEFAULT_STORAGE_INFO); + const [_isRefreshing, setIsRefreshing] = useState(false); + const [sdkVersion, setSdkVersion] = useState('0.1.0'); + + // SDK State + const [capabilities, setCapabilities] = useState([]); + const [backendInfoData, setBackendInfoData] = useState< + Record + >({}); + const [isSTTLoaded, setIsSTTLoaded] = useState(false); + const [isTTSLoaded, setIsTTSLoaded] = useState(false); + const [isTextLoaded, setIsTextLoaded] = useState(false); + const [isVADLoaded, setIsVADLoaded] = useState(false); + const [_memoryUsage, _setMemoryUsage] = useState(0); + + // Model catalog state + const [availableModels, setAvailableModels] = useState([]); + const [downloadingModels, setDownloadingModels] = useState< + Record + >({}); + const [downloadedModels, setDownloadedModels] = useState([]); + + // Capability names mapping + const capabilityNames: Record = { + 0: 'STT (Speech-to-Text)', + 1: 'TTS (Text-to-Speech)', + 2: 'Text Generation', + 3: 'Embeddings', + 4: 'VAD (Voice Activity)', + 5: 'Diarization', + }; + + // Load data on mount + useEffect(() => { + loadData(); + loadApiConfiguration(); + }, []); + + /** + * Load API configuration from AsyncStorage + */ + const loadApiConfiguration = async () => { + try { + const storedApiKey = await AsyncStorage.getItem(STORAGE_KEYS.API_KEY); + const storedBaseURL = await AsyncStorage.getItem(STORAGE_KEYS.BASE_URL); + + setApiKey(storedApiKey || ''); + setBaseURL(storedBaseURL || ''); + setApiKeyConfigured(!!storedApiKey && storedApiKey !== ''); + setIsBaseURLConfigured(!!storedBaseURL && storedBaseURL !== ''); + } catch (error) { + console.error('[Settings] Failed to load API configuration:', error); + } + }; + + /** + * Normalize base URL by adding https:// if no scheme is present + */ + const normalizeBaseURL = (url: string): string => { + const trimmed = url.trim(); + if (!trimmed) return trimmed; + if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { + return trimmed; + } + return `https://${trimmed}`; + }; + + /** + * Save API configuration to AsyncStorage + */ + const saveApiConfiguration = async () => { + try { + const normalizedURL = normalizeBaseURL(baseURL); + await AsyncStorage.setItem(STORAGE_KEYS.API_KEY, apiKey); + await AsyncStorage.setItem(STORAGE_KEYS.BASE_URL, normalizedURL); + + setBaseURL(normalizedURL); + setApiKeyConfigured(!!apiKey); + setIsBaseURLConfigured(!!normalizedURL); + setShowApiConfigModal(false); + + Alert.alert( + 'Restart Required', + 'API configuration has been updated. Please restart the app for changes to take effect.', + [{ text: 'OK' }] + ); + } catch (error) { + Alert.alert('Error', `Failed to save API configuration: ${error}`); + } + }; + + /** + * Clear API configuration from AsyncStorage + */ + const clearApiConfiguration = async () => { + try { + await AsyncStorage.multiRemove([ + STORAGE_KEYS.API_KEY, + STORAGE_KEYS.BASE_URL, + STORAGE_KEYS.DEVICE_REGISTERED, + ]); + + setApiKey(''); + setBaseURL(''); + setApiKeyConfigured(false); + setIsBaseURLConfigured(false); + + Alert.alert( + 'Restart Required', + 'API configuration has been cleared. Please restart the app for changes to take effect.', + [{ text: 'OK' }] + ); + } catch (error) { + Alert.alert('Error', `Failed to clear API configuration: ${error}`); + } + }; + + const loadData = async () => { + setIsRefreshing(true); + try { + // Get SDK version + const version = await RunAnywhere.getVersion(); + setSdkVersion(version); + + // Check if SDK is initialized first + const isInit = await RunAnywhere.isInitialized(); + console.log('[Settings] SDK isInitialized:', isInit); + + // Get backend info for storage data + const backendInfo = await RunAnywhere.getBackendInfo(); + console.log('[Settings] Backend info:', backendInfo); + + // Override name with actual init status + const updatedBackendInfo = { + ...backendInfo, + name: isInit ? 'RunAnywhere Core' : 'Not initialized', + version: version, + initialized: isInit, + }; + setBackendInfoData(updatedBackendInfo); + + // Get capabilities (returns string[], not number[]) + const caps = await RunAnywhere.getCapabilities(); + console.warn('[Settings] Capabilities:', caps); + // Convert string capabilities to numbers for display mapping + const capNumbers = caps.map((cap, index) => index); + setCapabilities(capNumbers); + + // Check loaded models + const sttLoaded = await RunAnywhere.isSTTModelLoaded(); + const ttsLoaded = await RunAnywhere.isTTSModelLoaded(); + const textLoaded = await RunAnywhere.isModelLoaded(); + const vadLoaded = await RunAnywhere.isVADModelLoaded(); + + setIsSTTLoaded(sttLoaded); + setIsTTSLoaded(ttsLoaded); + setIsTextLoaded(textLoaded); + setIsVADLoaded(vadLoaded); + + console.warn( + '[Settings] Models loaded - STT:', + sttLoaded, + 'TTS:', + ttsLoaded, + 'Text:', + textLoaded, + 'VAD:', + vadLoaded + ); + + // Get available models from catalog + try { + const available = await RunAnywhere.getAvailableModels(); + console.warn('[Settings] Available models:', available); + setAvailableModels(available); + } catch (err) { + console.warn('[Settings] Failed to get available models:', err); + } + + // Get downloaded models + try { + const downloaded = await RunAnywhere.getDownloadedModels(); + console.warn('[Settings] Downloaded models:', downloaded); + setDownloadedModels(downloaded); + } catch (err) { + console.warn('[Settings] Failed to get downloaded models:', err); + } + + // Get storage info using new SDK API + try { + const storage = await RunAnywhere.getStorageInfo(); + console.warn('[Settings] Storage info:', storage); + setStorageInfo({ + totalStorage: storage.deviceStorage.totalSpace, + appStorage: storage.appStorage.totalSize, + modelsStorage: storage.modelStorage.totalSize, + cacheSize: storage.cacheSize, + freeSpace: storage.deviceStorage.freeSpace, + }); + } catch (err) { + console.warn('[Settings] Failed to get storage info:', err); + } + } catch (error) { + console.error('Failed to load data:', error); + } finally { + setIsRefreshing(false); + } + }; + + /** + * Handle routing policy change + */ + const handleRoutingPolicyChange = useCallback(() => { + const policies = Object.values(RoutingPolicy); + Alert.alert( + 'Routing Policy', + 'Choose how requests are routed', + policies.map((policy) => ({ + text: RoutingPolicyDisplayNames[policy], + onPress: () => { + setRoutingPolicy(policy); + }, + })) + ); + }, []); + + /** + * Handle API key configuration - open modal + */ + const handleConfigureApiKey = useCallback(() => { + setShowApiConfigModal(true); + }, []); + + /** + * Cancel API configuration modal + */ + const handleCancelApiConfig = useCallback(() => { + loadApiConfiguration(); // Reset to stored values + setShowApiConfigModal(false); + }, []); + + + + /** + * Handle clear cache + */ + const handleClearCache = useCallback(() => { + Alert.alert( + 'Clear Cache', + 'This will clear temporary files. Models will not be deleted.', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Clear', + style: 'destructive', + onPress: async () => { + try { + // Clear SDK cache using new Storage API + await RunAnywhere.clearCache(); + await RunAnywhere.cleanTempFiles(); + Alert.alert('Success', 'Cache cleared successfully'); + loadData(); + } catch (err) { + console.error('[Settings] Failed to clear cache:', err); + Alert.alert('Error', `Failed to clear cache: ${err}`); + } + }, + }, + ] + ); + }, []); + + /** + * Handle model download + */ + const handleDownloadModel = useCallback( + async (model: ModelInfo) => { + if (downloadingModels[model.id] !== undefined) { + // Already downloading, cancel it + try { + await RunAnywhere.cancelDownload(model.id); + setDownloadingModels((prev) => { + const updated = { ...prev }; + delete updated[model.id]; + return updated; + }); + } catch (err) { + console.error('Failed to cancel download:', err); + } + return; + } + + // Start download with progress tracking + setDownloadingModels((prev) => ({ ...prev, [model.id]: 0 })); + + try { + await RunAnywhere.downloadModel(model.id, (progress) => { + console.warn( + `[Settings] Download progress for ${model.id}: ${(progress.progress * 100).toFixed(1)}%` + ); + setDownloadingModels((prev) => ({ + ...prev, + [model.id]: progress.progress, + })); + }); + + // Download complete + setDownloadingModels((prev) => { + const updated = { ...prev }; + delete updated[model.id]; + return updated; + }); + + Alert.alert('Success', `${model.name} downloaded successfully!`); + loadData(); // Refresh to show downloaded model + } catch (err) { + setDownloadingModels((prev) => { + const updated = { ...prev }; + delete updated[model.id]; + return updated; + }); + Alert.alert( + 'Download Failed', + `Failed to download ${model.name}: ${err}` + ); + } + }, + [downloadingModels] + ); + + /** + * Handle delete downloaded model + */ + const handleDeleteDownloadedModel = useCallback(async (model: ModelInfo) => { + const downloadedModel = downloadedModels.find((m) => m.id === model.id); + // Prefer downloaded model's size (actual disk usage) over catalog downloadSize (expected size) + // TODO: Replace with actual disk size once SDK exposes it (e.g., sizeOnDisk or actualSize) + const freedSize = + downloadedModel?.downloadSize ?? // Use downloaded model's size when available + model.downloadSize ?? + 0; + + Alert.alert( + 'Delete Model', + `Are you sure you want to delete ${model.name}? This will free up ${formatBytes(freedSize)}.`, + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete', + style: 'destructive', + onPress: async () => { + try { + await RunAnywhere.deleteModel(model.id); + Alert.alert('Deleted', `${model.name} has been deleted.`); + loadData(); // Refresh list + } catch (err) { + Alert.alert('Error', `Failed to delete: ${err}`); + } + }, + }, + ] + ); + }, [downloadedModels]); + + /** + * Handle clear all data + */ + const handleClearAllData = useCallback(() => { + Alert.alert( + 'Clear All Data', + 'This will delete all models and reset the app. This action cannot be undone.', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Clear All', + style: 'destructive', + onPress: async () => { + try { + // Unload all models + await RunAnywhere.unloadModel(); + await RunAnywhere.unloadSTTModel(); + await RunAnywhere.unloadTTSModel(); + // Destroy SDK + await RunAnywhere.destroy(); + Alert.alert('Success', 'All data cleared'); + } catch (error) { + Alert.alert('Error', `Failed to clear data: ${error}`); + } + loadData(); + }, + }, + ] + ); + }, []); + + /** + * Render section header + */ + const renderSectionHeader = (title: string) => ( + {title} + ); + + /** + * Render setting row + */ + const renderSettingRow = ( + icon: string, + title: string, + value: string, + onPress?: () => void, + showChevron: boolean = true + ) => ( + + + + {title} + + + {value} + {showChevron && onPress && ( + + )} + + + ); + + /** + * Render slider setting + */ + const renderSliderSetting = ( + title: string, + value: number, + onChange: (value: number) => void, + min: number, + max: number, + step: number, + formatValue: (v: number) => string + ) => ( + + + {title} + {formatValue(value)} + + + onChange(Math.max(min, value - step))} + > + + + + + + onChange(Math.min(max, value + step))} + > + + + + + ); + + /** + * Render storage bar + * Matches iOS: shows app storage usage relative to device free space + */ + const renderStorageBar = () => { + // Show app storage as portion of (app storage + free space) + const totalAvailable = storageInfo.appStorage + storageInfo.freeSpace; + const usedPercent = totalAvailable > 0 + ? (storageInfo.appStorage / totalAvailable) * 100 + : 0; + return ( + + + + + + {formatBytes(storageInfo.appStorage)} of{' '} + {formatBytes(storageInfo.freeSpace)} available + + + ); + }; + + + + /** + * Render catalog model row + */ + const renderCatalogModelRow = (model: ModelInfo) => { + const isDownloading = downloadingModels[model.id] !== undefined; + const downloadProgress = downloadingModels[model.id] || 0; + const isDownloaded = downloadedModels.some((m) => m.id === model.id); + + // Determine framework based on format + const framework = model.format === 'onnx' ? LLMFramework.ONNX : LLMFramework.LlamaCpp; + const frameworkName = FrameworkDisplayNames[framework] || framework; + + // Get model size estimate based on download size (may differ from actual on-disk size) + const downloadedModel = downloadedModels.find((m) => m.id === model.id); + const modelSize = + downloadedModel?.downloadSize ?? // Prefer size from downloaded model when available + model.downloadSize ?? // Fall back to catalog's expected download size + 0; + return ( + + + + {model.name} + + {model.category} + + + {model.metadata?.description && ( + + {model.metadata.description} + + )} + + + {formatBytes(modelSize)} + + {frameworkName} + + {isDownloading && ( + + + + + + {(downloadProgress * 100).toFixed(0)}% + + + )} + + + isDownloaded + ? handleDeleteDownloadedModel(model) + : handleDownloadModel(model) + } + > + + + + ); + }; + + return ( + + {/* Header */} + + Settings + + + + + + + {/* Generation Settings - Matches iOS CombinedSettingsView order */} + {renderSectionHeader('Generation Settings')} + + {renderSliderSetting( + 'Temperature', + temperature, + setTemperature, + SETTINGS_CONSTRAINTS.temperature.min, + SETTINGS_CONSTRAINTS.temperature.max, + SETTINGS_CONSTRAINTS.temperature.step, + (v) => v.toFixed(1) + )} + {renderSliderSetting( + 'Max Tokens', + maxTokens, + setMaxTokens, + SETTINGS_CONSTRAINTS.maxTokens.min, + SETTINGS_CONSTRAINTS.maxTokens.max, + SETTINGS_CONSTRAINTS.maxTokens.step, + (v) => v.toLocaleString() + )} + + + {/* API Configuration (Testing) */} + {renderSectionHeader('API Configuration (Testing)')} + + + API Key + + {apiKeyConfigured ? 'Configured' : 'Not Set'} + + + + + Base URL + + {isBaseURLConfigured ? 'Configured' : 'Not Set'} + + + + + + Configure + + {apiKeyConfigured && isBaseURLConfigured && ( + + Clear + + )} + + + Configure custom API key and base URL for testing. Requires app restart. + + + + {/* Storage Overview - Matches iOS CombinedSettingsView */} + {renderSectionHeader('Storage Overview')} + + {renderStorageBar()} + + {/* Total Storage - App's total storage usage */} + + Total Storage + + {formatBytes(storageInfo.appStorage)} + + + {/* Models Storage - Downloaded models size */} + + Models + + {formatBytes(storageInfo.modelsStorage)} + + + {/* Cache Size */} + + Cache + + {formatBytes(storageInfo.cacheSize)} + + + {/* Available - Device free space */} + + Available + + {formatBytes(storageInfo.freeSpace)} + + + + + + {/* Model Catalog */} + {renderSectionHeader('Model Catalog')} + + {availableModels.length === 0 ? ( + Loading models... + ) : ( + availableModels.map(renderCatalogModelRow) + )} + + + {/* Storage Management */} + {renderSectionHeader('Storage Management')} + + + + Clear Cache + + + + + Clear All Data + + + + + {/* Version Info */} + + RunAnywhere AI + SDK v{sdkVersion} + + + + {/* API Configuration Modal */} + + + + API Configuration + + {/* API Key Input */} + + API Key + + + setShowPassword(!showPassword)} + > + + + + + Your API key for authenticating with the backend + + + + {/* Base URL Input */} + + Base URL + + + The backend API URL (https:// added automatically if missing) + + + + {/* Warning */} + + + + After saving, you must restart the app for changes to take effect. The SDK will reinitialize with your custom configuration. + + + + {/* Buttons */} + + + Cancel + + + Save + + + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: Colors.backgroundGrouped, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: Padding.padding16, + paddingVertical: Padding.padding12, + backgroundColor: Colors.backgroundPrimary, + borderBottomWidth: 1, + borderBottomColor: Colors.borderLight, + }, + title: { + ...Typography.title2, + color: Colors.textPrimary, + }, + refreshButton: { + padding: Spacing.small, + }, + content: { + flex: 1, + }, + sectionHeader: { + ...Typography.footnote, + color: Colors.textSecondary, + textTransform: 'uppercase', + marginTop: Spacing.xLarge, + marginBottom: Spacing.small, + marginHorizontal: Padding.padding16, + }, + section: { + backgroundColor: Colors.backgroundPrimary, + borderRadius: BorderRadius.medium, + marginHorizontal: Padding.padding16, + overflow: 'hidden', + }, + settingRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + padding: Padding.padding16, + borderBottomWidth: 1, + borderBottomColor: Colors.borderLight, + }, + settingRowLeft: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.medium, + }, + settingRowRight: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.small, + }, + settingLabel: { + ...Typography.body, + color: Colors.textPrimary, + }, + settingValue: { + ...Typography.body, + color: Colors.textSecondary, + }, + sliderSetting: { + padding: Padding.padding16, + borderBottomWidth: 1, + borderBottomColor: Colors.borderLight, + }, + sliderHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: Spacing.medium, + }, + sliderValue: { + ...Typography.body, + color: Colors.primaryBlue, + fontWeight: '600', + }, + sliderControls: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.medium, + }, + sliderButton: { + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: Colors.backgroundSecondary, + justifyContent: 'center', + alignItems: 'center', + }, + sliderTrack: { + flex: 1, + height: 6, + backgroundColor: Colors.backgroundGray5, + borderRadius: 3, + }, + sliderFill: { + height: '100%', + backgroundColor: Colors.primaryBlue, + borderRadius: 3, + }, + storageBar: { + padding: Padding.padding16, + }, + storageBarTrack: { + height: 8, + backgroundColor: Colors.backgroundGray5, + borderRadius: 4, + overflow: 'hidden', + }, + storageBarFill: { + height: '100%', + backgroundColor: Colors.primaryBlue, + borderRadius: 4, + }, + storageText: { + ...Typography.footnote, + color: Colors.textSecondary, + marginTop: Spacing.small, + textAlign: 'center', + }, + storageDetails: { + borderTopWidth: 1, + borderTopColor: Colors.borderLight, + padding: Padding.padding16, + gap: Spacing.small, + }, + storageDetailRow: { + flexDirection: 'row', + justifyContent: 'space-between', + }, + storageDetailLabel: { + ...Typography.subheadline, + color: Colors.textSecondary, + }, + storageDetailValue: { + ...Typography.subheadline, + color: Colors.textPrimary, + }, + modelRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + padding: Padding.padding16, + borderBottomWidth: 1, + borderBottomColor: Colors.borderLight, + }, + modelInfo: { + flex: 1, + }, + modelName: { + ...Typography.body, + color: Colors.textPrimary, + }, + modelMeta: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.small, + marginTop: Spacing.xSmall, + }, + frameworkBadge: { + backgroundColor: Colors.badgeBlue, + paddingHorizontal: Spacing.small, + paddingVertical: Spacing.xxSmall, + borderRadius: BorderRadius.small, + }, + frameworkBadgeText: { + ...Typography.caption2, + color: Colors.primaryBlue, + fontWeight: '600', + }, + modelSize: { + ...Typography.caption, + color: Colors.textSecondary, + }, + deleteButton: { + padding: Spacing.small, + }, + emptyText: { + ...Typography.body, + color: Colors.textSecondary, + textAlign: 'center', + padding: Padding.padding24, + }, + dangerButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: Spacing.smallMedium, + padding: Padding.padding16, + borderBottomWidth: 1, + borderBottomColor: Colors.borderLight, + }, + dangerButtonRed: { + borderBottomWidth: 0, + }, + dangerButtonText: { + ...Typography.body, + color: Colors.primaryOrange, + }, + dangerButtonTextRed: { + color: Colors.primaryRed, + }, + versionContainer: { + alignItems: 'center', + padding: Padding.padding24, + marginTop: Spacing.large, + marginBottom: Spacing.xxxLarge, + }, + versionText: { + ...Typography.footnote, + color: Colors.textTertiary, + }, + versionSubtext: { + ...Typography.caption, + color: Colors.textTertiary, + marginTop: Spacing.xSmall, + }, + // SDK Status styles + statusRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + padding: Padding.padding16, + borderBottomWidth: 1, + borderBottomColor: Colors.borderLight, + }, + statusLabel: { + ...Typography.body, + color: Colors.textPrimary, + }, + statusValue: { + ...Typography.body, + color: Colors.primaryBlue, + fontWeight: '600', + }, + capabilitiesContainer: { + padding: Padding.padding16, + borderBottomWidth: 1, + borderBottomColor: Colors.borderLight, + }, + capabilitiesLabel: { + ...Typography.subheadline, + color: Colors.textSecondary, + marginBottom: Spacing.small, + }, + capabilitiesList: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: Spacing.small, + }, + capabilityBadge: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.xSmall, + backgroundColor: Colors.badgeGreen, + paddingHorizontal: Spacing.smallMedium, + paddingVertical: Spacing.xSmall, + borderRadius: BorderRadius.small, + }, + capabilityText: { + ...Typography.caption, + color: Colors.primaryGreen, + fontWeight: '600', + }, + noCapabilities: { + ...Typography.body, + color: Colors.textTertiary, + fontStyle: 'italic', + }, + modelStatusContainer: { + padding: Padding.padding16, + }, + modelStatusGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: Spacing.small, + marginTop: Spacing.small, + }, + modelStatusItem: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.xSmall, + backgroundColor: Colors.backgroundSecondary, + paddingHorizontal: Spacing.medium, + paddingVertical: Spacing.small, + borderRadius: BorderRadius.small, + }, + modelStatusItemLoaded: { + backgroundColor: Colors.badgeGreen, + }, + modelStatusText: { + ...Typography.footnote, + color: Colors.textSecondary, + fontWeight: '600', + }, + // Model catalog styles + catalogModelRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + padding: Padding.padding16, + borderBottomWidth: 1, + borderBottomColor: Colors.borderLight, + }, + catalogModelInfo: { + flex: 1, + marginRight: Spacing.medium, + }, + catalogModelHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.small, + marginBottom: Spacing.xSmall, + }, + catalogModelName: { + ...Typography.body, + color: Colors.textPrimary, + fontWeight: '600', + }, + catalogModelBadge: { + backgroundColor: Colors.badgeBlue, + paddingHorizontal: Spacing.small, + paddingVertical: Spacing.xxSmall, + borderRadius: BorderRadius.small, + }, + catalogModelBadgeText: { + ...Typography.caption2, + color: Colors.primaryBlue, + fontWeight: '600', + textTransform: 'uppercase', + }, + catalogModelDescription: { + ...Typography.footnote, + color: Colors.textSecondary, + marginBottom: Spacing.xSmall, + }, + catalogModelMeta: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.small, + }, + catalogModelSize: { + ...Typography.caption, + color: Colors.textTertiary, + }, + catalogModelFormat: { + ...Typography.caption, + color: Colors.textTertiary, + }, + catalogModelButton: { + width: 44, + height: 44, + borderRadius: 22, + backgroundColor: Colors.backgroundSecondary, + justifyContent: 'center', + alignItems: 'center', + }, + catalogModelButtonDownloaded: { + backgroundColor: Colors.badgeGreen, + }, + catalogModelButtonDownloading: { + backgroundColor: Colors.badgeOrange, + }, + downloadProgressContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.small, + marginTop: Spacing.small, + }, + downloadProgressTrack: { + flex: 1, + height: 4, + backgroundColor: Colors.backgroundGray5, + borderRadius: 2, + overflow: 'hidden', + }, + downloadProgressFill: { + height: '100%', + backgroundColor: Colors.primaryBlue, + borderRadius: 2, + }, + downloadProgressText: { + ...Typography.caption, + color: Colors.primaryBlue, + fontWeight: '600', + minWidth: 40, + textAlign: 'right', + }, + // API Configuration styles + apiConfigRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + padding: Padding.padding16, + }, + apiConfigLabel: { + ...Typography.body, + color: Colors.textPrimary, + }, + apiConfigValue: { + ...Typography.body, + fontWeight: '500', + }, + apiConfigDivider: { + height: 1, + backgroundColor: Colors.borderLight, + marginHorizontal: Padding.padding16, + }, + apiConfigButtons: { + flexDirection: 'row', + padding: Padding.padding16, + gap: Spacing.small, + }, + apiConfigButton: { + paddingHorizontal: Padding.padding16, + paddingVertical: Spacing.small, + borderRadius: BorderRadius.small, + borderWidth: 1, + borderColor: Colors.primaryBlue, + }, + apiConfigButtonClear: { + borderColor: Colors.primaryRed, + }, + apiConfigButtonText: { + ...Typography.subheadline, + color: Colors.primaryBlue, + fontWeight: '600', + }, + apiConfigButtonTextClear: { + color: Colors.primaryRed, + }, + apiConfigHint: { + ...Typography.footnote, + color: Colors.textSecondary, + paddingHorizontal: Padding.padding16, + paddingBottom: Padding.padding16, + }, + // Modal styles + modalOverlay: { + flex: 1, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + justifyContent: 'center', + alignItems: 'center', + padding: Padding.padding24, + }, + modalContent: { + backgroundColor: Colors.backgroundPrimary, + borderRadius: BorderRadius.large, + padding: Padding.padding24, + width: '100%', + maxWidth: 400, + }, + modalTitle: { + ...Typography.title2, + color: Colors.textPrimary, + marginBottom: Spacing.large, + textAlign: 'center', + }, + inputGroup: { + marginBottom: Spacing.large, + }, + inputLabel: { + ...Typography.subheadline, + color: Colors.textSecondary, + marginBottom: Spacing.small, + }, + input: { + backgroundColor: Colors.backgroundSecondary, + borderRadius: BorderRadius.small, + padding: Padding.padding12, + ...Typography.body, + color: Colors.textPrimary, + borderWidth: 1, + borderColor: Colors.borderLight, + }, + passwordInputContainer: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: Colors.backgroundSecondary, + borderRadius: BorderRadius.small, + borderWidth: 1, + borderColor: Colors.borderLight, + }, + passwordInput: { + flex: 1, + padding: Padding.padding12, + ...Typography.body, + color: Colors.textPrimary, + }, + passwordToggle: { + padding: Padding.padding12, + }, + inputHint: { + ...Typography.caption, + color: Colors.textTertiary, + marginTop: Spacing.xSmall, + }, + warningBox: { + flexDirection: 'row', + backgroundColor: Colors.badgeOrange, + borderRadius: BorderRadius.small, + padding: Padding.padding12, + gap: Spacing.small, + marginBottom: Spacing.large, + }, + warningText: { + ...Typography.footnote, + color: Colors.textSecondary, + flex: 1, + }, + modalButtons: { + flexDirection: 'row', + justifyContent: 'flex-end', + gap: Spacing.medium, + }, + modalButton: { + paddingHorizontal: Padding.padding16, + paddingVertical: Spacing.smallMedium, + borderRadius: BorderRadius.small, + minWidth: 80, + alignItems: 'center', + }, + modalButtonCancel: { + backgroundColor: 'transparent', + }, + modalButtonSave: { + backgroundColor: Colors.primaryBlue, + }, + modalButtonDisabled: { + backgroundColor: Colors.backgroundGray5, + }, + modalButtonTextCancel: { + ...Typography.body, + color: Colors.textSecondary, + }, + modalButtonTextSave: { + ...Typography.body, + color: Colors.textWhite, + fontWeight: '600', + }, + modalButtonTextDisabled: { + color: Colors.textTertiary, + }, +}); + +export default SettingsScreen; diff --git a/examples/react-native/RunAnywhereAI/src/screens/TTSScreen.tsx b/examples/react-native/RunAnywhereAI/src/screens/TTSScreen.tsx new file mode 100644 index 000000000..2b98d970b --- /dev/null +++ b/examples/react-native/RunAnywhereAI/src/screens/TTSScreen.tsx @@ -0,0 +1,1297 @@ +/** + * TTSScreen - Tab 2: Text-to-Speech + * + * Provides on-device text-to-speech synthesis with voice selection. + * Matches iOS TextToSpeechView architecture and patterns. + * + * Features: + * - Text input for synthesis + * - Voice/model selection + * - Audio playback controls + * - Model status banner + * - System TTS fallback + * + * Architecture: + * - Model loading via RunAnywhere.loadTTSModel() + * - Speech synthesis via RunAnywhere.synthesizeSpeech() + * - Audio playback via native audio player + * - Supports ONNX-based Piper TTS models + * + * Reference: iOS examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Voice/TextToSpeechView.swift + */ + +import React, { useState, useCallback, useEffect, useRef } from 'react'; +import { + View, + Text, + TextInput, + StyleSheet, + SafeAreaView, + TouchableOpacity, + ScrollView, + Alert, + Platform, + NativeModules, +} from 'react-native'; +import Icon from 'react-native-vector-icons/Ionicons'; +import { useFocusEffect } from '@react-navigation/native'; +import RNFS from 'react-native-fs'; + +// Native iOS Audio Module +const NativeAudioModule = + Platform.OS === 'ios' ? NativeModules.NativeAudioModule : null; + +// Audio playback using react-native-sound (Android only - iOS uses NativeAudioModule) +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let Sound: any = null; +let soundInitialized = false; + +function getSound() { + if (Platform.OS === 'ios') { + return null; // iOS uses NativeAudioModule instead + } + if (!Sound) { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + Sound = require('react-native-sound').default; + if (!soundInitialized) { + Sound.setCategory('Playback'); + soundInitialized = true; + } + } catch (e) { + console.warn('[TTSScreen] react-native-sound not available'); + return null; + } + } + return Sound; +} + +// Lazy load Tts for System TTS (Android only) +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let Tts: any = null; +function getTts() { + if (Platform.OS === 'ios') { + return null; // Disabled on iOS due to bridgeless=NO requirement for Nitrogen + } + if (!Tts) { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + Tts = require('react-native-tts').default; + } catch (e) { + console.warn('[TTSScreen] react-native-tts not available'); + return null; + } + } + return Tts; +} +import { Colors } from '../theme/colors'; +import { Typography } from '../theme/typography'; +import { + Spacing, + Padding, + BorderRadius, + ButtonHeight, + Layout, +} from '../theme/spacing'; +import { ModelStatusBanner, ModelRequiredOverlay } from '../components/common'; +import { + ModelSelectionSheet, + ModelSelectionContext, +} from '../components/model'; +import type { ModelInfo } from '../types/model'; +import { ModelModality, LLMFramework } from '../types/model'; + +// Import RunAnywhere SDK (Multi-Package Architecture) +import { RunAnywhere, type ModelInfo as SDKModelInfo } from '@runanywhere/core'; + +export const TTSScreen: React.FC = () => { + // State + const [text, setText] = useState(''); + const [speed, setSpeed] = useState(1.0); + const [pitch, setPitch] = useState(1.0); + const [volume, setVolume] = useState(1.0); + const [isGenerating, setIsGenerating] = useState(false); + const [isPlaying, setIsPlaying] = useState(false); + const [audioGenerated, setAudioGenerated] = useState(false); + const [duration, setDuration] = useState(0); + const [currentModel, setCurrentModel] = useState(null); + const [isModelLoading, setIsModelLoading] = useState(false); + const [_availableModels, setAvailableModels] = useState([]); + const [_lastGeneratedAudio, setLastGeneratedAudio] = useState( + null + ); + const [currentTime, setCurrentTime] = useState(0); + const [playbackProgress, setPlaybackProgress] = useState(0); + const [audioFilePath, setAudioFilePath] = useState(null); + const [sampleRate, setSampleRate] = useState(22050); + const [showModelSelection, setShowModelSelection] = useState(false); + + // Audio player refs - using react-native-sound directly + const soundRef = useRef(null); + const progressIntervalRef = useRef(null); + + // Character count + const charCount = text.length; + const maxChars = 1000; + + // Helper to stop progress updates + const stopProgressUpdates = useCallback(() => { + if (progressIntervalRef.current) { + clearInterval(progressIntervalRef.current); + progressIntervalRef.current = null; + } + }, []); + + // Helper to stop sound playback + const stopSound = useCallback(async () => { + stopProgressUpdates(); + + // iOS: Stop NativeAudioModule + if (Platform.OS === 'ios' && NativeAudioModule) { + try { + await NativeAudioModule.stopPlayback(); + } catch (e) { + // Ignore errors + } + } + + // Stop react-native-sound (Android) + if (soundRef.current) { + soundRef.current.stop(); + soundRef.current.release(); + soundRef.current = null; + } + }, [stopProgressUpdates]); + + // Cleanup on unmount + useEffect(() => { + return () => { + stopSound(); + // Also stop System TTS + if (Platform.OS === 'ios' && NativeAudioModule) { + // Stop iOS native TTS + NativeAudioModule.stopSpeaking().catch(() => {}); + } else { + // Stop Android react-native-tts + try { + getTts()?.stop(); + } catch { + // Ignore + } + } + // Clean up temp audio file + if (audioFilePath) { + RNFS.unlink(audioFilePath).catch(() => {}); + } + }; + }, [audioFilePath, stopSound]); + + /** + * Load available models and check for loaded model + * Called on mount and when screen comes into focus + */ + const loadModels = useCallback(async () => { + try { + // Get available TTS models from catalog + const allModels = await RunAnywhere.getAvailableModels(); + // Filter by category (speech-synthesis) matching SDK's ModelCategory + const ttsModels = allModels.filter( + (m: SDKModelInfo) => m.category === 'speech-synthesis' + ); + setAvailableModels(ttsModels); + + // Log downloaded status for debugging + const downloadedModels = ttsModels.filter((m) => m.isDownloaded); + console.warn( + '[TTSScreen] Available TTS models:', + ttsModels.map((m) => `${m.id} (downloaded: ${m.isDownloaded})`) + ); + console.warn( + '[TTSScreen] Downloaded TTS models:', + downloadedModels.map((m) => m.id) + ); + + // Check if model is already loaded + const isLoaded = await RunAnywhere.isTTSModelLoaded(); + console.warn('[TTSScreen] isTTSModelLoaded:', isLoaded); + if (isLoaded && !currentModel) { + // Try to find which model is loaded from downloaded models + const downloadedTts = ttsModels.filter((m) => m.isDownloaded); + if (downloadedTts.length > 0) { + // Use the first downloaded model as the likely loaded one + const firstModel = downloadedTts[0]; + if (firstModel) { + setCurrentModel({ + id: firstModel.id, + name: firstModel.name, + preferredFramework: LLMFramework.ONNX, + } as ModelInfo); + console.warn( + '[TTSScreen] Set currentModel from downloaded:', + firstModel.name + ); + } + } else { + setCurrentModel({ + id: 'tts-model', + name: 'TTS Model (Loaded)', + preferredFramework: LLMFramework.ONNX, + } as ModelInfo); + console.warn('[TTSScreen] Set currentModel as generic TTS Model'); + } + } + } catch (error) { + console.warn('[TTSScreen] Error loading models:', error); + } + }, [currentModel]); + + // Refresh models when screen comes into focus + // This ensures we pick up any models downloaded in the Settings tab + useFocusEffect( + useCallback(() => { + console.warn('[TTSScreen] Screen focused - refreshing models'); + loadModels(); + }, [loadModels]) + ); + + /** + * Handle model selection - opens model selection sheet + */ + const handleSelectModel = useCallback(() => { + setShowModelSelection(true); + }, []); + + /** + * Load a model from its info + */ + const loadModel = useCallback( + async (model: SDKModelInfo) => { + try { + setIsModelLoading(true); + + // Reset audio state when switching models + setAudioGenerated(false); + setAudioFilePath(null); + stopSound(); + + console.warn( + `[TTSScreen] Loading model: ${model.id} from ${model.localPath}` + ); + + // Handle System TTS specially - it's always available, no download needed + const isSystemTTS = + model.id === 'system-tts' || + model.preferredFramework === LLMFramework.SystemTTS || + model.localPath?.startsWith('builtin://'); + + if (isSystemTTS) { + console.warn( + `[TTSScreen] Using System TTS - no model loading required` + ); + // System TTS doesn't need to load a model, just mark it as ready + setCurrentModel({ + id: 'system-tts', + name: 'System TTS', + preferredFramework: LLMFramework.SystemTTS, + } as ModelInfo); + return; + } + + if (!model.localPath) { + Alert.alert( + 'Error', + 'Model path not found. Please download the model first.' + ); + return; + } + + // Unload any existing TTS model first + try { + const wasLoaded = await RunAnywhere.isTTSModelLoaded(); + if (wasLoaded) { + console.warn('[TTSScreen] Unloading previous TTS model...'); + await RunAnywhere.unloadTTSModel(); + } + } catch (unloadError) { + console.warn( + '[TTSScreen] Error unloading previous model (ignoring):', + unloadError + ); + } + + // Pass the path directly - C++ extractArchiveIfNeeded handles archive extraction + // and finding the correct nested model folder + const modelType = model.category || 'piper'; + console.warn( + `[TTSScreen] Calling loadTTSModel with path: ${model.localPath}, type: ${modelType}` + ); + + const success = await RunAnywhere.loadTTSModel( + model.localPath, + modelType + ); + + if (success) { + const isLoaded = await RunAnywhere.isTTSModelLoaded(); + if (isLoaded) { + // Set model with framework so ModelStatusBanner shows it properly + // Use ONNX since TTS uses Sherpa-ONNX (ONNX Runtime) + setCurrentModel({ + id: model.id, + name: model.name, + preferredFramework: LLMFramework.ONNX, + } as ModelInfo); + console.warn( + `[TTSScreen] Model ${model.name} loaded successfully, currentModel set` + ); + } else { + console.warn( + `[TTSScreen] Model reported success but isTTSModelLoaded() returned false` + ); + Alert.alert( + 'Warning', + 'Model may not have loaded correctly. Try generating speech to verify.' + ); + } + } else { + const error = await RunAnywhere.getLastError(); + console.error( + '[TTSScreen] loadTTSModel returned false, error:', + error + ); + Alert.alert( + 'Error', + `Failed to load model: ${error || 'Unknown error'}` + ); + } + } catch (error) { + console.error('[TTSScreen] Error loading model:', error); + Alert.alert('Error', `Failed to load model: ${error}`); + } finally { + setIsModelLoading(false); + } + }, + [stopSound] + ); + + /** + * Handle model selected from the sheet + */ + const handleModelSelected = useCallback( + async (model: SDKModelInfo) => { + // Close the modal first to prevent UI issues + setShowModelSelection(false); + // Then load the model + await loadModel(model); + }, + [loadModel] + ); + + /** + * Generate speech using System TTS (AVSpeechSynthesizer on iOS) + * iOS: Uses NativeAudioModule directly + * Android: Uses react-native-tts + */ + const handleSystemTTSGenerate = useCallback(async () => { + console.warn('[TTSScreen] Using System TTS (native speech synthesizer)'); + + try { + // iOS: Use NativeAudioModule for System TTS + if (Platform.OS === 'ios' && NativeAudioModule) { + console.warn('[TTSScreen] iOS: Using NativeAudioModule.speak()'); + + setIsPlaying(true); + + // Estimate duration based on text length and speed + const estimatedDuration = (text.length * 0.06) / speed; + setDuration(estimatedDuration); + setSampleRate(0); // System TTS doesn't expose sample rate + setAudioGenerated(false); // No audio file for System TTS + + try { + const result = await NativeAudioModule.speak(text, speed, pitch); + console.warn('[TTSScreen] iOS System TTS result:', result); + setIsPlaying(false); + } catch (speakError: unknown) { + console.error('[TTSScreen] iOS System TTS error:', speakError); + const errorMessage = + speakError instanceof Error + ? speakError.message + : String(speakError); + Alert.alert('Error', `System TTS failed: ${errorMessage}`); + setIsPlaying(false); + } + return; + } + + // Android: Use react-native-tts + const tts = getTts(); + + if (!tts) { + Alert.alert('TTS Not Available', 'System TTS is not available.'); + return; + } + + // Listen for finish event first + const finishListener = tts.addListener('tts-finish', () => { + console.warn('[TTSScreen] System TTS finished'); + setIsPlaying(false); + finishListener.remove(); + }); + + // Listen for cancel event + const cancelListener = tts.addListener('tts-cancel', () => { + console.warn('[TTSScreen] System TTS cancelled'); + setIsPlaying(false); + cancelListener.remove(); + }); + + // Speak the text with options + // iOS rate: 0.0-1.0, Android rate: 0.01-0.99 + const androidRate = Math.min(0.99, Math.max(0.01, speed * 0.5)); + + console.warn( + '[TTSScreen] Android System TTS speaking with rate:', + androidRate, + 'pitch:', + pitch + ); + + // Just speak with default settings - avoid setDefaultRate issue + // The speak function itself should work + tts.speak(text, { + rate: androidRate, + pitch: pitch, + }); + + // Estimate duration based on text length and speed + const estimatedDuration = (text.length * 0.06) / speed; + setDuration(estimatedDuration); + setSampleRate(0); // System TTS doesn't expose sample rate + setAudioGenerated(false); // No audio file for System TTS + setIsPlaying(true); + } catch (error) { + console.error('[TTSScreen] System TTS error:', error); + Alert.alert('Error', `System TTS failed: ${error}`); + setIsPlaying(false); + } + }, [text, speed, pitch]); + + /** + * Generate speech + */ + const handleGenerate = useCallback(async () => { + if (!text.trim() || !currentModel) return; + + setIsGenerating(true); + setAudioGenerated(false); + + // Stop any existing playback + stopSound(); + // Also stop any System TTS + if (Platform.OS === 'ios' && NativeAudioModule) { + try { + await NativeAudioModule.stopSpeaking(); + } catch { + /* ignore */ + } + } else { + try { + getTts()?.stop(); + } catch { + /* ignore */ + } + } + + // Check if using System TTS + const isSystemTTS = + currentModel.id === 'system-tts' || + currentModel.preferredFramework === LLMFramework.SystemTTS; + + try { + // For System TTS, use native AVSpeechSynthesizer + if (isSystemTTS) { + console.warn('[TTSScreen] Synthesizing with System TTS (native)'); + await handleSystemTTSGenerate(); + setIsGenerating(false); + return; + } + + // For ONNX models, check if model is loaded + const isLoaded = await RunAnywhere.isTTSModelLoaded(); + if (!isLoaded) { + Alert.alert('Model Not Loaded', 'Please load a TTS model first.'); + setIsGenerating(false); + return; + } + + // SDK uses simple TTSConfiguration with rate/pitch/volume + const sdkConfig = { + voice: 'default', + rate: speed, + pitch: pitch, + volume: volume, + }; + + console.warn( + '[TTSScreen] Synthesizing text with ONNX:', + text.substring(0, 50) + '...' + ); + + // SDK returns TTSResult with audio, sampleRate, numSamples, duration + const result = await RunAnywhere.synthesize(text, sdkConfig); + + console.warn('[TTSScreen] Synthesis result:', { + sampleRate: result.sampleRate, + numSamples: result.numSamples, + duration: result.duration, + audioLength: result.audio?.length || 0, + }); + + // Use actual duration from result, or estimate if not available + const audioDuration = + result.duration || + result.numSamples / result.sampleRate || + text.length * 0.05; + setDuration(audioDuration); + setSampleRate(result.sampleRate || 22050); + setLastGeneratedAudio(result.audio); + + // Convert to WAV and save to file for playback + if (result.audio && result.audio.length > 0) { + try { + // Clean up previous file + if (audioFilePath) { + await RNFS.unlink(audioFilePath).catch(() => {}); + } + + const wavPath = await RunAnywhere.Audio.createWavFromPCMFloat32( + result.audio, + result.sampleRate || 22050 + ); + setAudioFilePath(wavPath); + setAudioGenerated(true); + setCurrentTime(0); + setPlaybackProgress(0); + setIsPlaying(false); + + console.warn('[TTSScreen] WAV file created:', wavPath); + } catch (wavError) { + console.error('[TTSScreen] Error creating WAV file:', wavError); + Alert.alert( + 'Audio Generated', + `Duration: ${audioDuration.toFixed(2)}s\n` + + `Sample Rate: ${result.sampleRate} Hz\n` + + `Samples: ${result.numSamples.toLocaleString()}\n\n` + + 'Audio file creation failed. Tap play to try again.', + [{ text: 'OK' }] + ); + setAudioGenerated(true); + } + } + } catch (error) { + console.error('[TTSScreen] Synthesis error:', error); + Alert.alert('Error', `Failed to generate speech: ${error}`); + } finally { + setIsGenerating(false); + } + }, [ + text, + speed, + pitch, + volume, + currentModel, + audioFilePath, + handleSystemTTSGenerate, + stopSound, + ]); + + /** + * Format time for display (MM:SS) + */ + const formatTime = (seconds: number): string => { + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, '0')}`; + }; + + /** + * Toggle playback - plays or pauses audio using react-native-sound + */ + const handleTogglePlayback = useCallback(async () => { + console.warn('[TTSScreen] handleTogglePlayback called', { + audioGenerated, + audioFilePath, + isPlaying, + currentTime, + playbackProgress, + }); + + if (!audioGenerated || !audioFilePath) { + console.warn( + '[TTSScreen] No audio to play - audioGenerated:', + audioGenerated, + 'audioFilePath:', + audioFilePath + ); + Alert.alert('No Audio', 'Please generate speech first.'); + return; + } + + // Verify file exists + try { + const fileExists = await RNFS.exists(audioFilePath); + console.warn( + '[TTSScreen] Audio file exists:', + fileExists, + 'path:', + audioFilePath + ); + if (!fileExists) { + Alert.alert( + 'File Not Found', + 'Audio file was not found. Please regenerate.' + ); + return; + } + const fileStat = await RNFS.stat(audioFilePath); + console.warn('[TTSScreen] Audio file size:', fileStat.size, 'bytes'); + } catch (statError) { + console.error('[TTSScreen] Error checking file:', statError); + } + + try { + if (isPlaying) { + // Pause playback + console.warn('[TTSScreen] Pausing playback...'); + + // iOS: Use NativeAudioModule + if (Platform.OS === 'ios' && NativeAudioModule) { + try { + await NativeAudioModule.pausePlayback(); + } catch (e) { + console.warn('[TTSScreen] iOS pause error:', e); + } + } else if (soundRef.current) { + soundRef.current.pause(); + } + + stopProgressUpdates(); + setIsPlaying(false); + console.warn('[TTSScreen] Playback paused'); + } else { + // Check if we should resume on iOS + if (Platform.OS === 'ios' && NativeAudioModule && currentTime > 0) { + // Resume iOS playback + console.warn('[TTSScreen] Resuming iOS playback from:', currentTime); + try { + await NativeAudioModule.resumePlayback(); + setIsPlaying(true); + + // Restart progress updates + progressIntervalRef.current = setInterval(async () => { + try { + const status = await NativeAudioModule.getPlaybackStatus(); + const currentSec = status.currentTime || 0; + const totalDuration = status.duration || duration; + setCurrentTime(currentSec); + if (totalDuration > 0) { + setPlaybackProgress(currentSec / totalDuration); + } + + if (!status.isPlaying && currentSec >= totalDuration - 0.1) { + stopProgressUpdates(); + setIsPlaying(false); + setCurrentTime(0); + setPlaybackProgress(0); + } + } catch (e) { + // Ignore + } + }, 100); + + return; + } catch (e) { + console.warn('[TTSScreen] iOS resume error, starting fresh:', e); + } + } + + // Android: Use react-native-sound + if (soundRef.current && currentTime > 0) { + // Resume existing sound + console.warn('[TTSScreen] Resuming playback from:', currentTime); + soundRef.current.setVolume(volume); + soundRef.current.play((success: boolean) => { + if (success) { + console.warn('[TTSScreen] Playback finished'); + } + stopProgressUpdates(); + setIsPlaying(false); + setCurrentTime(0); + setPlaybackProgress(0); + }); + + // Start progress updates + progressIntervalRef.current = setInterval(() => { + soundRef.current?.getCurrentTime((seconds: number) => { + const totalDuration = soundRef.current?.getDuration() || duration; + setCurrentTime(seconds); + if (totalDuration > 0) { + setPlaybackProgress(seconds / totalDuration); + } + }); + }, 100); + + setIsPlaying(true); + } else { + // Start fresh playback + console.warn('[TTSScreen] Starting fresh playback...'); + await stopSound(); // Clean up any existing sound + + // iOS: Use NativeAudioModule + if (Platform.OS === 'ios' && NativeAudioModule) { + console.warn( + '[TTSScreen] Using NativeAudioModule for iOS playback' + ); + try { + const result = await NativeAudioModule.playAudio(audioFilePath); + console.warn('[TTSScreen] iOS playback started:', result); + setIsPlaying(true); + + // Start progress updates for iOS + progressIntervalRef.current = setInterval(async () => { + try { + const status = await NativeAudioModule.getPlaybackStatus(); + const currentSec = status.currentTime || 0; + const totalDuration = status.duration || duration; + setCurrentTime(currentSec); + if (totalDuration > 0) { + setPlaybackProgress(currentSec / totalDuration); + } + + // Check if playback finished + if (!status.isPlaying && currentSec >= totalDuration - 0.1) { + stopProgressUpdates(); + setIsPlaying(false); + setCurrentTime(0); + setPlaybackProgress(0); + console.warn('[TTSScreen] iOS playback finished'); + } + } catch (e) { + // Ignore errors during polling + } + }, 100); + + return; + } catch (error: unknown) { + console.error('[TTSScreen] iOS playback error:', error); + const errorMessage = + error instanceof Error ? error.message : String(error); + Alert.alert( + 'Playback Error', + `Failed to play audio: ${errorMessage}` + ); + return; + } + } + + const SoundClass = getSound(); + if (!SoundClass) { + Alert.alert('Playback Error', 'Sound player not available'); + return; + } + const sound = new SoundClass( + audioFilePath, + '', + (error: Error | null) => { + if (error) { + console.error('[TTSScreen] Failed to load sound:', error); + Alert.alert( + 'Playback Error', + `Failed to load audio: ${error.message}` + ); + return; + } + + console.warn( + '[TTSScreen] Sound loaded, duration:', + sound.getDuration(), + 'seconds' + ); + soundRef.current = sound; + sound.setVolume(volume); + + sound.play((success: boolean) => { + if (success) { + console.warn('[TTSScreen] Playback finished successfully'); + } else { + console.warn('[TTSScreen] Playback interrupted'); + } + stopProgressUpdates(); + setIsPlaying(false); + setCurrentTime(0); + setPlaybackProgress(0); + }); + + // Start progress updates + progressIntervalRef.current = setInterval(() => { + sound.getCurrentTime((seconds: number) => { + const totalDuration = sound.getDuration(); + setCurrentTime(seconds); + if (totalDuration > 0) { + setPlaybackProgress(seconds / totalDuration); + } + }); + }, 100); + + setIsPlaying(true); + console.warn('[TTSScreen] Playback started successfully'); + } + ); + } + } + } catch (error) { + console.error('[TTSScreen] Playback error:', error); + Alert.alert('Playback Error', `Failed to play audio: ${error}`); + setIsPlaying(false); + } + }, [ + audioGenerated, + audioFilePath, + isPlaying, + currentTime, + playbackProgress, + volume, + duration, + stopSound, + stopProgressUpdates, + ]); + + /** + * Stop playback completely + */ + const handleStop = useCallback(async () => { + await stopSound(); + // Also stop System TTS if playing + if (Platform.OS === 'ios' && NativeAudioModule) { + try { + await NativeAudioModule.stopSpeaking(); + } catch { + /* ignore */ + } + } else { + const tts = getTts(); + if (tts) { + try { + tts.stop(); + } catch { + /* ignore */ + } + } + } + setIsPlaying(false); + setCurrentTime(0); + setPlaybackProgress(0); + }, [stopSound]); + + /** + * Clear text + */ + const handleClear = useCallback(() => { + setText(''); + setAudioGenerated(false); + setIsPlaying(false); + }, []); + + /** + * Render slider with label + */ + const renderSlider = ( + label: string, + value: number, + onValueChange: (value: number) => void, + min: number = 0.5, + max: number = 2.0, + step: number = 0.1, + formatValue: (v: number) => string = (v) => `${v.toFixed(1)}x` + ) => ( + + + {label} + {formatValue(value)} + + {/* TODO: Add @react-native-community/slider package */} + + + { + // Simple increment for demo + const newValue = value + step > max ? min : value + step; + onValueChange(Math.round(newValue * 10) / 10); + }} + /> + + + ); + + /** + * Render header + */ + const renderHeader = () => ( + + Text to Speech + {text && ( + + + + )} + + ); + + // Show model required overlay if no model + if (!currentModel && !isModelLoading) { + return ( + + {renderHeader()} + + {/* Model Selection Sheet */} + setShowModelSelection(false)} + onModelSelected={handleModelSelected} + /> + + ); + } + + return ( + + {renderHeader()} + + {/* Model Status Banner */} + + + + {/* Text Input */} + + Text to speak + + + {charCount}/{maxChars} characters + + + + {/* Voice Settings */} + + Voice Settings + {renderSlider('Speed', speed, setSpeed)} + {renderSlider('Pitch', pitch, setPitch)} + {renderSlider( + 'Volume', + volume, + setVolume, + 0, + 1, + 0.1, + (v) => `${Math.round(v * 100)}%` + )} + + + {/* Playback Controls */} + {audioGenerated && ( + + Generated Audio + + {/* Progress bar */} + + {formatTime(currentTime)} + + + + {formatTime(duration)} + + + {/* Audio info */} + + + + {duration.toFixed(1)}s @ {sampleRate} Hz + + + + {/* Playback controls */} + + + + + + + + + + )} + + + {/* Generate Button */} + + + {isGenerating ? ( + <> + + Generating... + + ) : ( + <> + + Generate Speech + + )} + + + + {/* Model Selection Sheet */} + setShowModelSelection(false)} + onModelSelected={handleModelSelected} + /> + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: Colors.backgroundPrimary, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: Padding.padding16, + paddingVertical: Padding.padding12, + borderBottomWidth: 1, + borderBottomColor: Colors.borderLight, + }, + title: { + ...Typography.title2, + color: Colors.textPrimary, + }, + clearButton: { + padding: Spacing.small, + }, + content: { + flex: 1, + paddingHorizontal: Padding.padding16, + }, + inputSection: { + marginTop: Spacing.large, + }, + sectionLabel: { + ...Typography.headline, + color: Colors.textPrimary, + marginBottom: Spacing.smallMedium, + }, + textInput: { + backgroundColor: Colors.backgroundSecondary, + borderRadius: BorderRadius.medium, + padding: Padding.padding14, + minHeight: Layout.textAreaMinHeight, + ...Typography.body, + color: Colors.textPrimary, + textAlignVertical: 'top', + }, + charCount: { + ...Typography.caption, + color: Colors.textTertiary, + textAlign: 'right', + marginTop: Spacing.small, + }, + settingsSection: { + marginTop: Spacing.xLarge, + }, + sliderContainer: { + marginBottom: Spacing.large, + }, + sliderHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: Spacing.small, + }, + sliderLabel: { + ...Typography.subheadline, + color: Colors.textPrimary, + }, + sliderValue: { + ...Typography.subheadline, + color: Colors.primaryBlue, + fontWeight: '600', + }, + sliderTrack: { + height: 6, + backgroundColor: Colors.backgroundGray5, + borderRadius: 3, + position: 'relative', + }, + sliderFill: { + height: '100%', + backgroundColor: Colors.primaryBlue, + borderRadius: 3, + }, + sliderThumb: { + position: 'absolute', + top: -7, + width: 20, + height: 20, + borderRadius: 10, + backgroundColor: Colors.backgroundPrimary, + borderWidth: 2, + borderColor: Colors.primaryBlue, + marginLeft: -10, + }, + playbackSection: { + marginTop: Spacing.xLarge, + padding: Padding.padding16, + backgroundColor: Colors.backgroundSecondary, + borderRadius: BorderRadius.medium, + }, + progressContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.smallMedium, + marginBottom: Spacing.medium, + paddingVertical: Spacing.smallMedium, + }, + timeText: { + ...Typography.caption, + color: Colors.textSecondary, + minWidth: 40, + textAlign: 'center', + }, + progressBar: { + flex: 1, + height: 4, + backgroundColor: Colors.backgroundGray5, + borderRadius: 2, + overflow: 'hidden', + }, + progressFill: { + height: '100%', + backgroundColor: Colors.primaryBlue, + borderRadius: 2, + }, + playbackInfo: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.smallMedium, + marginBottom: Spacing.medium, + }, + durationText: { + ...Typography.subheadline, + color: Colors.textSecondary, + }, + playbackControls: { + flexDirection: 'row', + gap: Spacing.medium, + }, + controlButton: { + width: ButtonHeight.regular, + height: ButtonHeight.regular, + borderRadius: ButtonHeight.regular / 2, + backgroundColor: Colors.backgroundPrimary, + justifyContent: 'center', + alignItems: 'center', + borderWidth: 2, + borderColor: Colors.primaryBlue, + }, + controlButtonActive: { + backgroundColor: Colors.primaryBlue, + borderColor: Colors.primaryBlue, + }, + footer: { + paddingHorizontal: Padding.padding16, + paddingVertical: Padding.padding16, + paddingBottom: Padding.padding30, + borderTopWidth: 1, + borderTopColor: Colors.borderLight, + }, + generateButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: Spacing.smallMedium, + backgroundColor: Colors.primaryBlue, + height: ButtonHeight.regular, + borderRadius: BorderRadius.large, + }, + generateButtonDisabled: { + backgroundColor: Colors.backgroundGray5, + }, + generateButtonText: { + ...Typography.headline, + color: Colors.textWhite, + }, +}); + +export default TTSScreen; diff --git a/examples/react-native/RunAnywhereAI/src/screens/VoiceAssistantScreen.tsx b/examples/react-native/RunAnywhereAI/src/screens/VoiceAssistantScreen.tsx new file mode 100644 index 000000000..7c25ac3ee --- /dev/null +++ b/examples/react-native/RunAnywhereAI/src/screens/VoiceAssistantScreen.tsx @@ -0,0 +1,719 @@ +/** + * VoiceAssistantScreen - Tab 3: Voice Assistant + * + * Complete voice AI pipeline combining speech recognition, language model, and synthesis. + * Uses the SDK's VoiceSession API which handles all the complexity internally: + * - Audio capture with VAD (Voice Activity Detection) + * - Automatic speech end detection + * - STT → LLM → TTS pipeline + * - Audio playback + * + * Reference: iOS examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Voice/VoiceAssistantView.swift + */ + +import React, { useState, useCallback, useEffect, useRef } from 'react'; +import { + View, + Text, + StyleSheet, + SafeAreaView, + TouchableOpacity, + ScrollView, + Alert, +} from 'react-native'; +import Icon from 'react-native-vector-icons/Ionicons'; +import { Colors } from '../theme/colors'; +import { Typography } from '../theme/typography'; +import { Spacing, Padding, BorderRadius } from '../theme/spacing'; +import { + ModelSelectionSheet, + ModelSelectionContext, +} from '../components/model'; +import type { ModelInfo } from '../types/model'; +import { LLMFramework } from '../types/model'; +import type { VoiceConversationEntry } from '../types/voice'; +import { VoicePipelineStatus } from '../types/voice'; + +// Import RunAnywhere SDK +import { + RunAnywhere, + type ModelInfo as SDKModelInfo, + type VoiceSessionHandle, + type VoiceSessionEvent, +} from '@runanywhere/core'; + +// Generate unique ID +const generateId = () => Math.random().toString(36).substring(2, 15); + +export const VoiceAssistantScreen: React.FC = () => { + // Model states + const [sttModel, setSTTModel] = useState(null); + const [llmModel, setLLMModel] = useState(null); + const [ttsModel, setTTSModel] = useState(null); + const [_availableModels, setAvailableModels] = useState([]); + + // Session state + const [status, setStatus] = useState(VoicePipelineStatus.Idle); + const [conversation, setConversation] = useState([]); + const [audioLevel, setAudioLevel] = useState(0); + const [isSessionActive, setIsSessionActive] = useState(false); + const [showModelInfo, setShowModelInfo] = useState(true); + const [showModelSelection, setShowModelSelection] = useState(false); + const [modelSelectionType, setModelSelectionType] = useState<'stt' | 'llm' | 'tts'>('stt'); + + // Voice session handle ref + const sessionRef = useRef(null); + + // Check if all models are loaded + const allModelsLoaded = sttModel && llmModel && ttsModel; + + // Check model status on mount + useEffect(() => { + checkModelStatus(); + loadAvailableModels(); + }, []); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (sessionRef.current) { + sessionRef.current.stop(); + sessionRef.current = null; + } + }; + }, []); + + /** + * Load available models from catalog + */ + const loadAvailableModels = async () => { + try { + const models = await RunAnywhere.getAvailableModels(); + setAvailableModels(models); + } catch (error) { + console.warn('[VoiceAssistant] Error loading models:', error); + } + }; + + /** + * Check which models are already loaded + */ + const checkModelStatus = async () => { + try { + const sttLoaded = await RunAnywhere.isSTTModelLoaded(); + const llmLoaded = await RunAnywhere.isModelLoaded(); + const ttsLoaded = await RunAnywhere.isTTSModelLoaded(); + + if (sttLoaded) { + setSTTModel({ id: 'stt-loaded', name: 'STT Model (Loaded)' } as ModelInfo); + } + if (llmLoaded) { + setLLMModel({ id: 'llm-loaded', name: 'LLM Model (Loaded)' } as ModelInfo); + } + if (ttsLoaded) { + setTTSModel({ id: 'tts-loaded', name: 'TTS Model (Loaded)' } as ModelInfo); + } + } catch (error) { + console.warn('[VoiceAssistant] Error checking model status:', error); + } + }; + + /** + * Handle voice session events from the SDK + */ + const handleVoiceEvent = useCallback((event: VoiceSessionEvent) => { + switch (event.type) { + case 'listening': + setStatus(VoicePipelineStatus.Listening); + setAudioLevel(event.audioLevel ?? 0); + break; + + case 'speechStarted': + console.warn('[VoiceAssistant] 🎙️ Speech started'); + break; + + case 'speechEnded': + console.warn('[VoiceAssistant] 🔇 Speech ended - processing...'); + break; + + case 'processing': + setStatus(VoicePipelineStatus.Processing); + break; + + case 'transcribed': + if (event.transcription) { + console.warn('[VoiceAssistant] User said:', event.transcription); + const userEntry: VoiceConversationEntry = { + id: generateId(), + speaker: 'user', + text: event.transcription, + timestamp: new Date(), + }; + setConversation(prev => [...prev, userEntry]); + } + setStatus(VoicePipelineStatus.Thinking); + break; + + case 'responded': + if (event.response) { + console.warn('[VoiceAssistant] Assistant:', event.response); + const assistantEntry: VoiceConversationEntry = { + id: generateId(), + speaker: 'assistant', + text: event.response, + timestamp: new Date(), + }; + setConversation(prev => [...prev, assistantEntry]); + } + break; + + case 'speaking': + setStatus(VoicePipelineStatus.Speaking); + break; + + case 'turnCompleted': + console.warn('[VoiceAssistant] ✅ Turn completed'); + setStatus(VoicePipelineStatus.Listening); + break; + + case 'stopped': + console.warn('[VoiceAssistant] Session stopped'); + setStatus(VoicePipelineStatus.Idle); + setIsSessionActive(false); + setAudioLevel(0); + break; + + case 'error': + console.error('[VoiceAssistant] Error:', event.error); + setStatus(VoicePipelineStatus.Error); + Alert.alert('Error', event.error || 'An error occurred'); + setTimeout(() => setStatus(VoicePipelineStatus.Idle), 2000); + setIsSessionActive(false); + break; + } + }, []); + + /** + * Start or stop the voice session + */ + const handleToggleSession = useCallback(async () => { + if (isSessionActive) { + // Stop the session + if (sessionRef.current) { + sessionRef.current.stop(); + sessionRef.current = null; + } + setIsSessionActive(false); + setStatus(VoicePipelineStatus.Idle); + } else { + // Start the session + if (!allModelsLoaded) { + Alert.alert( + 'Models Required', + 'Please load all required models (STT, LLM, TTS) to use the voice assistant.' + ); + return; + } + + try { + console.warn('[VoiceAssistant] Starting voice session...'); + + // Use the SDK's voice session API + const session = await RunAnywhere.startVoiceSession({ + silenceDuration: 1.5, + speechThreshold: 0.1, + autoPlayTTS: true, + continuousMode: true, + language: 'en', + onEvent: handleVoiceEvent, + }); + + sessionRef.current = session; + setIsSessionActive(true); + setStatus(VoicePipelineStatus.Listening); + + console.warn('[VoiceAssistant] Voice session started'); + } catch (error) { + console.error('[VoiceAssistant] Failed to start session:', error); + Alert.alert('Error', `Failed to start voice session: ${error}`); + } + } + }, [isSessionActive, allModelsLoaded, handleVoiceEvent]); + + /** + * Handle model selection - opens model selection sheet + */ + const handleSelectModel = useCallback((type: 'stt' | 'llm' | 'tts') => { + setModelSelectionType(type); + setShowModelSelection(true); + }, []); + + /** + * Get context for model selection + */ + const getSelectionContext = (type: 'stt' | 'llm' | 'tts'): ModelSelectionContext => { + switch (type) { + case 'stt': return ModelSelectionContext.STT; + case 'llm': return ModelSelectionContext.LLM; + case 'tts': return ModelSelectionContext.TTS; + } + }; + + /** + * Handle model selected from the sheet + */ + const handleModelSelected = useCallback(async (model: SDKModelInfo) => { + setShowModelSelection(false); + + try { + switch (modelSelectionType) { + case 'stt': + if (model.localPath) { + const sttSuccess = await RunAnywhere.loadSTTModel(model.localPath, model.category || 'whisper'); + if (sttSuccess) { + setSTTModel({ id: model.id, name: model.name, preferredFramework: LLMFramework.ONNX } as ModelInfo); + } + } + break; + case 'llm': + if (model.localPath) { + const llmSuccess = await RunAnywhere.loadModel(model.localPath); + if (llmSuccess) { + setLLMModel({ id: model.id, name: model.name, preferredFramework: LLMFramework.LlamaCpp } as ModelInfo); + } + } + break; + case 'tts': + if (model.localPath) { + const ttsSuccess = await RunAnywhere.loadTTSModel(model.localPath, model.category || 'piper'); + if (ttsSuccess) { + setTTSModel({ id: model.id, name: model.name, preferredFramework: LLMFramework.PiperTTS } as ModelInfo); + } + } + break; + } + } catch (error) { + Alert.alert('Error', `Failed to load model: ${error}`); + } + }, [modelSelectionType]); + + /** + * Clear conversation + */ + const handleClear = useCallback(() => { + setConversation([]); + }, []); + + /** + * Render model badge + */ + const renderModelBadge = ( + icon: string, + label: string, + model: ModelInfo | null, + color: string, + onPress: () => void + ) => ( + + + + + + {label} + + {model?.name || 'Not selected'} + + + + + ); + + /** + * Render status indicator + */ + const renderStatusIndicator = () => { + const statusConfig = { + [VoicePipelineStatus.Idle]: { color: Colors.statusGray, text: 'Ready' }, + [VoicePipelineStatus.Listening]: { color: Colors.statusGreen, text: 'Listening...' }, + [VoicePipelineStatus.Processing]: { color: Colors.statusOrange, text: 'Processing...' }, + [VoicePipelineStatus.Thinking]: { color: Colors.primaryBlue, text: 'Thinking...' }, + [VoicePipelineStatus.Speaking]: { color: Colors.primaryPurple, text: 'Speaking...' }, + [VoicePipelineStatus.Error]: { color: Colors.statusRed, text: 'Error' }, + }; + + const config = statusConfig[status]; + + return ( + + + {config.text} + + ); + }; + + /** + * Render conversation bubble + */ + const renderConversationBubble = (entry: VoiceConversationEntry) => { + const isUser = entry.speaker === 'user'; + return ( + + {isUser ? 'You' : 'AI'} + {entry.text} + + ); + }; + + /** + * Render setup view (when models not loaded) + */ + const renderSetupView = () => ( + + + + Voice Assistant Setup + + Load all required models to enable voice conversations + + + + + {renderModelBadge('mic-outline', 'Speech Recognition', sttModel, Colors.primaryGreen, () => handleSelectModel('stt'))} + {renderModelBadge('chatbubble-outline', 'Language Model', llmModel, Colors.primaryBlue, () => handleSelectModel('llm'))} + {renderModelBadge('volume-high-outline', 'Text-to-Speech', ttsModel, Colors.primaryPurple, () => handleSelectModel('tts'))} + + + + + Experimental Feature + + + ); + + return ( + + {/* Header */} + + Voice Assistant + + {allModelsLoaded && ( + setShowModelInfo(!showModelInfo)}> + + + )} + {conversation.length > 0 && ( + + + + )} + + + + {/* Status Indicator */} + {allModelsLoaded && renderStatusIndicator()} + + {/* Model Info (collapsible) */} + {allModelsLoaded && showModelInfo && ( + + {renderModelBadge('mic-outline', 'STT', sttModel, Colors.primaryGreen, () => handleSelectModel('stt'))} + {renderModelBadge('chatbubble-outline', 'LLM', llmModel, Colors.primaryBlue, () => handleSelectModel('llm'))} + {renderModelBadge('volume-high-outline', 'TTS', ttsModel, Colors.primaryPurple, () => handleSelectModel('tts'))} + + )} + + {/* Main Content */} + {!allModelsLoaded ? ( + renderSetupView() + ) : ( + <> + {/* Conversation */} + + {conversation.length === 0 ? ( + + + Tap the microphone to start a conversation + + ) : ( + conversation.map(renderConversationBubble) + )} + + + {/* Microphone Control */} + + {isSessionActive && ( + + {/* Audio Level Indicator */} + + 0.1 ? Colors.primaryGreen : Colors.primaryBlue, + }, + ]} + /> + + + {status === VoicePipelineStatus.Listening + ? audioLevel > 0.1 ? '🎙️ Speaking...' : '👂 Listening...' + : status === VoicePipelineStatus.Processing ? '⚙️ Processing...' + : status === VoicePipelineStatus.Thinking ? '💭 Thinking...' + : status === VoicePipelineStatus.Speaking ? '🔊 Speaking...' + : ''} + + + )} + + + + + {isSessionActive ? 'Tap to stop (auto-detects silence)' : 'Tap to speak'} + + + + )} + + {/* Model Selection Sheet */} + setShowModelSelection(false)} + onModelSelected={handleModelSelected} + /> + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: Colors.backgroundPrimary, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: Padding.padding16, + paddingVertical: Padding.padding12, + borderBottomWidth: 1, + borderBottomColor: Colors.borderLight, + }, + title: { + ...Typography.title2, + color: Colors.textPrimary, + }, + headerActions: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.medium, + }, + headerButton: { + padding: Spacing.small, + }, + statusContainer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: Spacing.small, + paddingVertical: Spacing.smallMedium, + backgroundColor: Colors.backgroundSecondary, + }, + statusDot: { + width: 8, + height: 8, + borderRadius: 4, + }, + statusText: { + ...Typography.footnote, + fontWeight: '600', + }, + modelInfoContainer: { + paddingHorizontal: Padding.padding16, + paddingVertical: Spacing.medium, + gap: Spacing.small, + backgroundColor: Colors.backgroundSecondary, + }, + modelBadge: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.medium, + padding: Padding.padding12, + backgroundColor: Colors.backgroundPrimary, + borderRadius: BorderRadius.medium, + borderWidth: 1, + }, + modelBadgeIcon: { + width: 32, + height: 32, + borderRadius: 16, + justifyContent: 'center', + alignItems: 'center', + }, + modelBadgeContent: { + flex: 1, + }, + modelBadgeLabel: { + ...Typography.caption, + color: Colors.textSecondary, + }, + modelBadgeValue: { + ...Typography.subheadline, + color: Colors.textPrimary, + fontWeight: '500', + }, + setupContainer: { + flex: 1, + padding: Padding.padding24, + }, + setupHeader: { + alignItems: 'center', + marginBottom: Spacing.xxLarge, + }, + setupTitle: { + ...Typography.title2, + color: Colors.textPrimary, + marginTop: Spacing.large, + }, + setupSubtitle: { + ...Typography.body, + color: Colors.textSecondary, + textAlign: 'center', + marginTop: Spacing.small, + }, + modelsContainer: { + gap: Spacing.medium, + }, + experimentalBadge: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: Spacing.small, + marginTop: Spacing.xxLarge, + padding: Padding.padding12, + backgroundColor: Colors.badgeOrange, + borderRadius: BorderRadius.regular, + }, + experimentalText: { + ...Typography.footnote, + color: Colors.primaryOrange, + fontWeight: '600', + }, + conversationContainer: { + flex: 1, + }, + conversationContent: { + padding: Padding.padding16, + flexGrow: 1, + }, + emptyConversation: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + gap: Spacing.medium, + }, + emptyText: { + ...Typography.body, + color: Colors.textSecondary, + textAlign: 'center', + }, + conversationBubble: { + marginBottom: Spacing.medium, + padding: Padding.padding14, + borderRadius: BorderRadius.xLarge, + maxWidth: '80%', + }, + userBubble: { + alignSelf: 'flex-end', + backgroundColor: Colors.primaryBlue, + borderBottomRightRadius: BorderRadius.small, + }, + assistantBubble: { + alignSelf: 'flex-start', + backgroundColor: Colors.backgroundSecondary, + borderBottomLeftRadius: BorderRadius.small, + }, + speakerLabel: { + ...Typography.caption, + color: 'rgba(255, 255, 255, 0.7)', + marginBottom: Spacing.xSmall, + }, + bubbleText: { + ...Typography.body, + color: Colors.textWhite, + }, + controlsContainer: { + alignItems: 'center', + paddingVertical: Padding.padding24, + paddingBottom: Padding.padding40, + }, + recordingInfo: { + alignItems: 'center', + marginBottom: Spacing.medium, + width: '100%', + }, + audioLevelContainer: { + width: 200, + height: 8, + backgroundColor: Colors.backgroundGray5, + borderRadius: 4, + overflow: 'hidden', + marginBottom: Spacing.small, + }, + audioLevelBar: { + height: '100%', + borderRadius: 4, + }, + vadStatus: { + ...Typography.footnote, + color: Colors.textSecondary, + }, + micButton: { + width: 80, + height: 80, + borderRadius: 40, + backgroundColor: Colors.primaryBlue, + justifyContent: 'center', + alignItems: 'center', + shadowColor: Colors.primaryBlue, + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 8, + elevation: 8, + }, + micButtonRecording: { + backgroundColor: Colors.primaryRed, + shadowColor: Colors.primaryRed, + }, + micButtonDisabled: { + backgroundColor: Colors.backgroundGray5, + shadowOpacity: 0, + }, + micLabel: { + ...Typography.footnote, + color: Colors.textSecondary, + marginTop: Spacing.medium, + }, +}); + +export default VoiceAssistantScreen; diff --git a/examples/react-native/RunAnywhereAI/src/stores/conversationStore.ts b/examples/react-native/RunAnywhereAI/src/stores/conversationStore.ts new file mode 100644 index 000000000..12634550c --- /dev/null +++ b/examples/react-native/RunAnywhereAI/src/stores/conversationStore.ts @@ -0,0 +1,474 @@ +/** + * ConversationStore - Zustand store for conversation management + * + * Reference: iOS Core/Services/ConversationStore.swift + * + * Uses file-based JSON persistence matching iOS implementation. + * Conversations are stored in Documents/Conversations/{id}.json + */ + +import { create } from 'zustand'; +import RNFS from 'react-native-fs'; +import type { Conversation, Message } from '../types/chat'; +import { MessageRole } from '../types/chat'; + +// Generate unique ID matching iOS UUID approach +/* eslint-disable no-bitwise */ +const generateId = (): string => + 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + const v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +/* eslint-enable no-bitwise */ + +// Directory for storing conversations +const CONVERSATIONS_DIR = `${RNFS.DocumentDirectoryPath}/Conversations`; + +/** + * Serialize a conversation for JSON storage + */ +const serializeConversation = (conversation: Conversation): string => { + return JSON.stringify( + conversation, + (key, value) => { + // Convert Date objects to ISO strings + if (value instanceof Date) { + return value.toISOString(); + } + return value; + }, + 2 + ); +}; + +/** + * Deserialize a conversation from JSON storage + */ +const deserializeConversation = (json: string): Conversation => { + const parsed = JSON.parse(json); + return { + ...parsed, + createdAt: new Date(parsed.createdAt), + updatedAt: new Date(parsed.updatedAt), + messages: parsed.messages.map((msg: Message & { timestamp: string }) => ({ + ...msg, + timestamp: new Date(msg.timestamp), + })), + }; +}; + +interface ConversationState { + // State + conversations: Conversation[]; + currentConversation: Conversation | null; + isLoading: boolean; + + // Actions + initialize: () => Promise; + createConversation: (title?: string) => Promise; + updateConversation: (conversation: Conversation) => Promise; + deleteConversation: (conversationId: string) => Promise; + loadConversation: (conversationId: string) => Promise; + setCurrentConversation: (conversation: Conversation | null) => void; + addMessage: (message: Message, conversationId?: string) => Promise; + updateMessage: (message: Message, conversationId?: string) => void; + searchConversations: (query: string) => Conversation[]; + clearAllConversations: () => Promise; +} + +export const useConversationStore = create((set, get) => ({ + conversations: [], + currentConversation: null, + isLoading: false, + + /** + * Initialize the store - load conversations from disk + * Matches iOS loadConversations() behavior + */ + initialize: async () => { + set({ isLoading: true }); + try { + // Ensure directory exists + const dirExists = await RNFS.exists(CONVERSATIONS_DIR); + if (!dirExists) { + await RNFS.mkdir(CONVERSATIONS_DIR); + console.warn('[ConversationStore] Created conversations directory'); + } + + // Load all conversation files + const files = await RNFS.readDir(CONVERSATIONS_DIR); + const jsonFiles = files.filter((f: { name: string; path: string }) => + f.name.endsWith('.json') + ); + + const loadedConversations: Conversation[] = []; + + for (const file of jsonFiles) { + try { + const content = await RNFS.readFile(file.path, 'utf8'); + const conversation = deserializeConversation(content); + loadedConversations.push(conversation); + } catch (error) { + console.warn( + `[ConversationStore] Failed to load ${file.name}:`, + error + ); + } + } + + // Sort by updatedAt descending (newest first) - matches iOS + loadedConversations.sort( + (a, b) => b.updatedAt.getTime() - a.updatedAt.getTime() + ); + + console.warn( + `[ConversationStore] Loaded ${loadedConversations.length} conversations` + ); + set({ conversations: loadedConversations, isLoading: false }); + } catch (error) { + console.error('[ConversationStore] Failed to initialize:', error); + set({ isLoading: false }); + } + }, + + /** + * Create a new conversation + * Matches iOS createConversation(title:) behavior + */ + createConversation: async (title?: string) => { + const now = new Date(); + const conversation: Conversation = { + id: generateId(), + title: title || 'New Chat', + createdAt: now, + updatedAt: now, + messages: [], + }; + + // Save to disk + const filePath = `${CONVERSATIONS_DIR}/${conversation.id}.json`; + await RNFS.writeFile(filePath, serializeConversation(conversation), 'utf8'); + + // Insert at beginning (newest first) - matches iOS + set((state) => ({ + conversations: [conversation, ...state.conversations], + currentConversation: conversation, + })); + + console.warn( + `[ConversationStore] Created conversation: ${conversation.id}` + ); + return conversation; + }, + + /** + * Update an existing conversation + * Matches iOS updateConversation(_:) behavior + */ + updateConversation: async (conversation: Conversation) => { + const updatedConversation = { + ...conversation, + updatedAt: new Date(), + }; + + // Save to disk + const filePath = `${CONVERSATIONS_DIR}/${conversation.id}.json`; + await RNFS.writeFile( + filePath, + serializeConversation(updatedConversation), + 'utf8' + ); + + // Update in state + set((state) => ({ + conversations: state.conversations.map((c) => + c.id === conversation.id ? updatedConversation : c + ), + currentConversation: + state.currentConversation?.id === conversation.id + ? updatedConversation + : state.currentConversation, + })); + + console.warn( + `[ConversationStore] Updated conversation: ${conversation.id}` + ); + }, + + /** + * Delete a conversation + * Matches iOS deleteConversation(_:) behavior + */ + deleteConversation: async (conversationId: string) => { + // Delete from disk + const filePath = `${CONVERSATIONS_DIR}/${conversationId}.json`; + try { + await RNFS.unlink(filePath); + } catch { + console.warn( + `[ConversationStore] File not found for deletion: ${conversationId}` + ); + } + + // Remove from state and handle current conversation + set((state) => { + const filtered = state.conversations.filter( + (c) => c.id !== conversationId + ); + const newCurrent = + state.currentConversation?.id === conversationId + ? filtered[0] || null + : state.currentConversation; + return { + conversations: filtered, + currentConversation: newCurrent, + }; + }); + + console.warn(`[ConversationStore] Deleted conversation: ${conversationId}`); + }, + + /** + * Load a specific conversation + * Matches iOS loadConversation(_:) behavior + */ + loadConversation: async (conversationId: string) => { + // First check memory + const { conversations } = get(); + let conversation = conversations.find((c) => c.id === conversationId); + + if (!conversation) { + // Try loading from disk + const filePath = `${CONVERSATIONS_DIR}/${conversationId}.json`; + try { + const exists = await RNFS.exists(filePath); + if (exists) { + const content = await RNFS.readFile(filePath, 'utf8'); + conversation = deserializeConversation(content); + } + } catch (error) { + console.warn( + `[ConversationStore] Failed to load conversation ${conversationId}:`, + error + ); + return null; + } + } + + if (conversation) { + set({ currentConversation: conversation }); + } + + return conversation || null; + }, + + /** + * Set the current conversation (without loading from disk) + */ + setCurrentConversation: (conversation: Conversation | null) => { + set({ currentConversation: conversation }); + }, + + /** + * Add a message to a conversation + * Matches iOS addMessage(_:to:) behavior with auto-title generation + */ + addMessage: async (message: Message, conversationId?: string) => { + const { currentConversation, conversations } = get(); + const targetId = conversationId || currentConversation?.id; + + if (!targetId) { + console.warn('[ConversationStore] No conversation to add message to'); + return; + } + + const conversation = conversations.find((c) => c.id === targetId); + if (!conversation) { + console.warn(`[ConversationStore] Conversation not found: ${targetId}`); + return; + } + + // Auto-generate title from first user message (matches iOS) + let newTitle = conversation.title; + if ( + conversation.title === 'New Chat' && + message.role === MessageRole.User && + conversation.messages.length === 0 + ) { + // Take first 50 characters of the message as title + newTitle = + message.content.length > 50 + ? message.content.substring(0, 50) + '...' + : message.content; + } + + const updatedConversation: Conversation = { + ...conversation, + title: newTitle, + messages: [...conversation.messages, message], + updatedAt: new Date(), + modelName: message.modelInfo?.modelName || conversation.modelName, + frameworkName: + message.modelInfo?.frameworkDisplayName || conversation.frameworkName, + }; + + // Save to disk + const filePath = `${CONVERSATIONS_DIR}/${targetId}.json`; + await RNFS.writeFile( + filePath, + serializeConversation(updatedConversation), + 'utf8' + ); + + // Update state - move updated conversation to top + set((state) => { + const filtered = state.conversations.filter((c) => c.id !== targetId); + return { + conversations: [updatedConversation, ...filtered], + currentConversation: + state.currentConversation?.id === targetId + ? updatedConversation + : state.currentConversation, + }; + }); + }, + + /** + * Update an existing message in a conversation (for streaming updates) + * Matches iOS updateMessage(at:with:) behavior + */ + updateMessage: (message: Message, conversationId?: string) => { + const { currentConversation, conversations } = get(); + const targetId = conversationId || currentConversation?.id; + + if (!targetId) { + return; + } + + const conversation = conversations.find((c) => c.id === targetId); + if (!conversation) { + return; + } + + // Find and update the message by ID + const updatedMessages = conversation.messages.map((m) => + m.id === message.id ? message : m + ); + + const updatedConversation: Conversation = { + ...conversation, + messages: updatedMessages, + updatedAt: new Date(), + modelName: message.modelInfo?.modelName || conversation.modelName, + frameworkName: + message.modelInfo?.frameworkDisplayName || conversation.frameworkName, + }; + + // Update state (don't persist to disk during streaming - final update will persist) + set((state) => ({ + conversations: state.conversations.map((c) => + c.id === targetId ? updatedConversation : c + ), + currentConversation: + state.currentConversation?.id === targetId + ? updatedConversation + : state.currentConversation, + })); + }, + + /** + * Search conversations by title and message content + * Matches iOS searchConversations(query:) behavior + */ + searchConversations: (query: string) => { + const { conversations } = get(); + const lowerQuery = query.toLowerCase(); + + return conversations.filter((conversation) => { + // Match title + if (conversation.title.toLowerCase().includes(lowerQuery)) { + return true; + } + // Match message content + return conversation.messages.some((message) => + message.content.toLowerCase().includes(lowerQuery) + ); + }); + }, + + /** + * Clear all conversations (for Settings) + */ + clearAllConversations: async () => { + try { + // Delete directory and recreate + await RNFS.unlink(CONVERSATIONS_DIR); + await RNFS.mkdir(CONVERSATIONS_DIR); + set({ conversations: [], currentConversation: null }); + console.warn('[ConversationStore] Cleared all conversations'); + } catch (error) { + console.error( + '[ConversationStore] Failed to clear conversations:', + error + ); + } + }, +})); + +/** + * Helper hooks for common operations + */ + +/** + * Get summary text for a conversation (matches iOS Conversation.summary computed property) + */ +export const getConversationSummary = (conversation: Conversation): string => { + const userMessages = conversation.messages.filter( + (m) => m.role === MessageRole.User + ).length; + const assistantMessages = conversation.messages.filter( + (m) => m.role === MessageRole.Assistant + ).length; + return `${conversation.messages.length} messages • ${userMessages} from you, ${assistantMessages} from AI`; +}; + +/** + * Get last message preview (matches iOS Conversation.lastMessagePreview computed property) + */ +export const getLastMessagePreview = (conversation: Conversation): string => { + if (conversation.messages.length === 0) { + return 'No messages yet'; + } + const lastMessage = conversation.messages[conversation.messages.length - 1]; + if (!lastMessage) { + return 'No messages yet'; + } + return lastMessage.content.length > 100 + ? lastMessage.content.substring(0, 100) + '...' + : lastMessage.content; +}; + +/** + * Format relative date (matches iOS relativeDate helper) + */ +export const formatRelativeDate = (date: Date): string => { + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffSecs = Math.floor(diffMs / 1000); + const diffMins = Math.floor(diffSecs / 60); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffSecs < 60) { + return 'Just now'; + } else if (diffMins < 60) { + return `${diffMins} minute${diffMins === 1 ? '' : 's'} ago`; + } else if (diffHours < 24) { + return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`; + } else if (diffDays < 7) { + return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`; + } else { + return date.toLocaleDateString(); + } +}; diff --git a/examples/react-native/RunAnywhereAI/src/theme/colors.ts b/examples/react-native/RunAnywhereAI/src/theme/colors.ts new file mode 100644 index 000000000..419c5cda0 --- /dev/null +++ b/examples/react-native/RunAnywhereAI/src/theme/colors.ts @@ -0,0 +1,107 @@ +/** + * Color System - Matching iOS AppColors.swift + * + * Reference: examples/ios/RunAnywhereAI/RunAnywhereAI/Design/AppColors.swift + */ + +export const Colors = { + // Primary Colors + primaryAccent: '#007AFF', + primaryBlue: '#007AFF', + primaryGreen: '#34C759', + primaryRed: '#FF3B30', + primaryOrange: '#FF9500', + primaryPurple: '#AF52DE', + + // Text Colors + textPrimary: '#000000', + textSecondary: '#8E8E93', + textTertiary: '#C7C7CC', + textWhite: '#FFFFFF', + + // Background Colors - Light Mode + backgroundPrimary: '#FFFFFF', + backgroundSecondary: '#F2F2F7', + backgroundTertiary: '#FFFFFF', + backgroundGrouped: '#F2F2F7', + backgroundGray5: '#E5E5EA', + backgroundGray6: '#F2F2F7', + + // Component Badges + badgeBlue: 'rgba(0, 122, 255, 0.12)', + badgeGreen: 'rgba(52, 199, 89, 0.12)', + badgePurple: 'rgba(175, 82, 222, 0.12)', + badgeOrange: 'rgba(255, 149, 0, 0.12)', + badgeRed: 'rgba(255, 59, 48, 0.12)', + badgeGray: 'rgba(142, 142, 147, 0.12)', + + // Status Colors + statusGreen: '#34C759', + statusOrange: '#FF9500', + statusRed: '#FF3B30', + statusGray: '#8E8E93', + statusBlue: '#007AFF', + + // Shadows & Overlays + shadowLight: 'rgba(0, 0, 0, 0.04)', + shadowMedium: 'rgba(0, 0, 0, 0.08)', + shadowDark: 'rgba(0, 0, 0, 0.15)', + overlayLight: 'rgba(0, 0, 0, 0.3)', + overlayMedium: 'rgba(0, 0, 0, 0.5)', + + // Borders + borderLight: 'rgba(60, 60, 67, 0.12)', + borderMedium: 'rgba(60, 60, 67, 0.29)', + + // Message Bubbles + userBubbleGradientStart: '#007AFF', + userBubbleGradientEnd: '#5856D6', + assistantBubbleBg: '#E5E5EA', + + // Framework-specific colors (from iOS) + frameworkLlamaCpp: '#FF6B35', + frameworkWhisperKit: '#00C853', + frameworkONNX: '#1E88E5', + frameworkCoreML: '#FF9500', + frameworkFoundationModels: '#AF52DE', + frameworkTFLite: '#FFC107', + frameworkPiperTTS: '#E91E63', + frameworkSystemTTS: '#8E8E93', +} as const; + +/** + * Dark mode color overrides + */ +export const DarkColors: Record = { + // Primary Colors (adjusted for dark mode) + primaryAccent: '#0A84FF', + primaryBlue: '#0A84FF', + primaryGreen: '#30D158', + primaryRed: '#FF453A', + primaryOrange: '#FF9F0A', + primaryPurple: '#BF5AF2', + + // Text Colors + textPrimary: '#FFFFFF', + textSecondary: '#8E8E93', + textTertiary: '#48484A', + + // Background Colors - Dark Mode + backgroundPrimary: '#000000', + backgroundSecondary: '#1C1C1E', + backgroundTertiary: '#2C2C2E', + backgroundGrouped: '#1C1C1E', + backgroundGray5: '#3A3A3C', + backgroundGray6: '#2C2C2E', + + // Message Bubbles + userBubbleGradientStart: '#0A84FF', + userBubbleGradientEnd: '#5E5CE6', + assistantBubbleBg: '#3A3A3C', + + // Borders + borderLight: 'rgba(84, 84, 88, 0.65)', + borderMedium: 'rgba(84, 84, 88, 0.90)', +}; + +export type ColorKey = keyof typeof Colors; diff --git a/examples/react-native/RunAnywhereAI/src/theme/index.ts b/examples/react-native/RunAnywhereAI/src/theme/index.ts new file mode 100644 index 000000000..4fc99cdcb --- /dev/null +++ b/examples/react-native/RunAnywhereAI/src/theme/index.ts @@ -0,0 +1,53 @@ +/** + * Theme System - Unified export + * + * Reference: examples/ios/RunAnywhereAI/RunAnywhereAI/Design/ + */ + +import { Colors, DarkColors } from './colors'; +import { Typography } from './typography'; +import { + Spacing, + Padding, + IconSize, + ButtonHeight, + BorderRadius, + ShadowRadius, + AnimationDuration, + Layout, +} from './spacing'; + +export { Colors, DarkColors } from './colors'; +export type { ColorKey } from './colors'; + +export { Typography, FontWeight, fontSize } from './typography'; +export type { TypographyKey } from './typography'; + +export { + Spacing, + Padding, + IconSize, + ButtonHeight, + BorderRadius, + ShadowRadius, + AnimationDuration, + Layout, +} from './spacing'; +export type { SpacingKey, IconSizeKey } from './spacing'; + +/** + * Combined theme object for convenience + */ +export const Theme = { + colors: Colors, + darkColors: DarkColors, + typography: Typography, + spacing: Spacing, + padding: Padding, + iconSize: IconSize, + buttonHeight: ButtonHeight, + borderRadius: BorderRadius, + shadowRadius: ShadowRadius, + animationDuration: AnimationDuration, + layout: Layout, +}; diff --git a/examples/react-native/RunAnywhereAI/src/theme/spacing.ts b/examples/react-native/RunAnywhereAI/src/theme/spacing.ts new file mode 100644 index 000000000..3b17d1d3d --- /dev/null +++ b/examples/react-native/RunAnywhereAI/src/theme/spacing.ts @@ -0,0 +1,135 @@ +/** + * Spacing System - Matching iOS AppSpacing.swift + * + * Reference: examples/ios/RunAnywhereAI/RunAnywhereAI/Design/AppSpacing.swift + */ + +/** + * Semantic spacing values + */ +export const Spacing = { + // Extra small values + xxSmall: 2, + xSmall: 4, + small: 6, + smallMedium: 8, + + // Medium values + medium: 10, + mediumLarge: 12, + regular: 14, + + // Large values + large: 16, + xLarge: 20, + xxLarge: 30, + xxxLarge: 40, + + // Extra large values + huge: 48, + massive: 60, +} as const; + +/** + * Padding presets + */ +export const Padding = { + padding4: 4, + padding6: 6, + padding8: 8, + padding10: 10, + padding12: 12, + padding14: 14, + padding16: 16, + padding20: 20, + padding24: 24, + padding30: 30, + padding40: 40, + padding48: 48, + padding60: 60, + padding80: 80, + padding100: 100, +} as const; + +/** + * Icon sizes + */ +export const IconSize = { + small: 8, + regular: 18, + medium: 28, + large: 48, + xLarge: 60, + xxLarge: 72, + huge: 80, +} as const; + +/** + * Button heights + */ +export const ButtonHeight = { + small: 28, + regular: 44, + large: 72, +} as const; + +/** + * Corner radius values + */ +export const BorderRadius = { + small: 4, + regular: 8, + medium: 10, + large: 12, + xLarge: 16, + pill: 20, + circle: 9999, +} as const; + +/** + * Shadow radius values + */ +export const ShadowRadius = { + small: 2, + regular: 4, + medium: 6, + large: 8, + xLarge: 10, +} as const; + +/** + * Animation durations (in milliseconds) + */ +export const AnimationDuration = { + fast: 250, + regular: 300, + slow: 500, + verySlow: 600, + loop: 1000, + loopSlow: 2000, +} as const; + +/** + * Layout constants + */ +export const Layout = { + // Message bubble max width (75% of screen) + messageBubbleMaxWidth: 0.75, + + // Modal dimensions + modalMinWidth: 320, + modalIdealWidth: 400, + modalMaxWidth: 500, + + // Sheet dimensions + sheetMinHeight: 400, + sheetIdealHeight: 600, + sheetMaxHeight: 800, + + // Input heights + inputMinHeight: 44, + textAreaMinHeight: 120, +} as const; + +export type SpacingKey = keyof typeof Spacing; +export type IconSizeKey = keyof typeof IconSize; diff --git a/examples/react-native/RunAnywhereAI/src/theme/typography.ts b/examples/react-native/RunAnywhereAI/src/theme/typography.ts new file mode 100644 index 000000000..1a7899ce9 --- /dev/null +++ b/examples/react-native/RunAnywhereAI/src/theme/typography.ts @@ -0,0 +1,157 @@ +/** + * Typography System - Matching iOS AppTypography.swift + * + * Reference: examples/ios/RunAnywhereAI/RunAnywhereAI/Design/AppTypography.swift + */ + +import type { TextStyle } from 'react-native'; +import { Platform } from 'react-native'; + +const fontFamily = Platform.select({ + ios: 'System', + android: 'Roboto', + default: 'System', +}); + +/** + * Font weights mapped to numeric values + */ +export const FontWeight = { + regular: '400' as const, + medium: '500' as const, + semibold: '600' as const, + bold: '700' as const, +}; + +/** + * Typography styles matching iOS system fonts + */ +export const Typography = { + // Large Title - used for primary headings + largeTitle: { + fontSize: 34, + fontWeight: FontWeight.bold, + lineHeight: 41, + letterSpacing: 0.37, + fontFamily, + } satisfies TextStyle, + + // Title - main titles + title: { + fontSize: 28, + fontWeight: FontWeight.bold, + lineHeight: 34, + letterSpacing: 0.36, + fontFamily, + } satisfies TextStyle, + + // Title 2 - secondary titles + title2: { + fontSize: 22, + fontWeight: FontWeight.bold, + lineHeight: 28, + letterSpacing: 0.35, + fontFamily, + } satisfies TextStyle, + + // Title 3 - tertiary titles + title3: { + fontSize: 20, + fontWeight: FontWeight.semibold, + lineHeight: 25, + letterSpacing: 0.38, + fontFamily, + } satisfies TextStyle, + + // Headline - section headers + headline: { + fontSize: 17, + fontWeight: FontWeight.semibold, + lineHeight: 22, + letterSpacing: -0.41, + fontFamily, + } satisfies TextStyle, + + // Body - main text content + body: { + fontSize: 17, + fontWeight: FontWeight.regular, + lineHeight: 22, + letterSpacing: -0.41, + fontFamily, + } satisfies TextStyle, + + // Callout - emphasized text + callout: { + fontSize: 16, + fontWeight: FontWeight.regular, + lineHeight: 21, + letterSpacing: -0.32, + fontFamily, + } satisfies TextStyle, + + // Subheadline - secondary text + subheadline: { + fontSize: 15, + fontWeight: FontWeight.regular, + lineHeight: 20, + letterSpacing: -0.24, + fontFamily, + } satisfies TextStyle, + + // Footnote - small text + footnote: { + fontSize: 13, + fontWeight: FontWeight.regular, + lineHeight: 18, + letterSpacing: -0.08, + fontFamily, + } satisfies TextStyle, + + // Caption - smallest readable text + caption: { + fontSize: 12, + fontWeight: FontWeight.regular, + lineHeight: 16, + letterSpacing: 0, + fontFamily, + } satisfies TextStyle, + + // Caption 2 - very small text + caption2: { + fontSize: 11, + fontWeight: FontWeight.regular, + lineHeight: 13, + letterSpacing: 0.06, + fontFamily, + } satisfies TextStyle, + + // Monospaced caption - for code/metrics + monospacedCaption: { + fontSize: 12, + fontWeight: FontWeight.bold, + lineHeight: 16, + letterSpacing: 0, + fontFamily: Platform.select({ + ios: 'Menlo', + android: 'monospace', + default: 'monospace', + }), + } satisfies TextStyle, +} as const; + +/** + * Create a text style with a specific size + */ +export function fontSize( + size: number, + weight: keyof typeof FontWeight = 'regular' +): TextStyle { + return { + fontSize: size, + fontWeight: FontWeight[weight], + fontFamily, + }; +} + +export type TypographyKey = keyof typeof Typography; diff --git a/examples/react-native/RunAnywhereAI/src/types/chat.ts b/examples/react-native/RunAnywhereAI/src/types/chat.ts new file mode 100644 index 000000000..9fca6f10b --- /dev/null +++ b/examples/react-native/RunAnywhereAI/src/types/chat.ts @@ -0,0 +1,163 @@ +/** + * Chat Types - Matching iOS Message models + * + * Reference: examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Chat/Models/ + */ + +/** + * Message role in conversation + */ +export enum MessageRole { + User = 'user', + Assistant = 'assistant', + System = 'system', +} + +/** + * Message analytics data + */ +export interface MessageAnalytics { + /** Time to first token in milliseconds */ + timeToFirstToken?: number; + + /** Total generation time in milliseconds */ + totalGenerationTime: number; + + /** Time spent on thinking/reasoning */ + thinkingTime?: number; + + /** Time for actual response */ + responseTime?: number; + + /** Number of input tokens */ + inputTokens: number; + + /** Number of output tokens */ + outputTokens: number; + + /** Number of thinking tokens */ + thinkingTokens?: number; + + /** Number of response tokens */ + responseTokens?: number; + + /** Average tokens per second */ + averageTokensPerSecond?: number; + + /** History of tokens per second over time */ + tokensPerSecondHistory?: number[]; + + /** Whether generation completed successfully */ + completionStatus: 'completed' | 'interrupted' | 'error'; + + /** Whether thinking mode was used */ + wasThinkingMode: boolean; + + /** Whether generation was interrupted */ + wasInterrupted: boolean; + + /** Number of retry attempts */ + retryCount: number; + + /** Generation parameters used */ + generationParameters?: { + temperature: number; + maxTokens: number; + topP?: number; + topK?: number; + }; +} + +/** + * Model info attached to a message + */ +export interface MessageModelInfo { + /** Model ID */ + modelId: string; + + /** Model display name */ + modelName: string; + + /** Framework used */ + framework: string; + + /** Framework display name */ + frameworkDisplayName: string; +} + +/** + * Chat message + */ +export interface Message { + /** Unique identifier */ + id: string; + + /** Message role (user, assistant, system) */ + role: MessageRole; + + /** Message content */ + content: string; + + /** Thinking/reasoning content (for models with reasoning) */ + thinkingContent?: string; + + /** Timestamp */ + timestamp: Date; + + /** Analytics data */ + analytics?: MessageAnalytics; + + /** Model info */ + modelInfo?: MessageModelInfo; + + /** Whether the message is still streaming */ + isStreaming?: boolean; +} + +/** + * Conversation data + */ +export interface Conversation { + /** Unique identifier */ + id: string; + + /** Conversation title */ + title: string; + + /** Creation timestamp */ + createdAt: Date; + + /** Last update timestamp */ + updatedAt: Date; + + /** Messages in the conversation */ + messages: Message[]; + + /** Model name used */ + modelName?: string; + + /** Framework name used */ + frameworkName?: string; +} + +/** + * Conversation summary for list view + */ +export interface ConversationSummary { + id: string; + title: string; + lastMessage: string; + messageCount: number; + updatedAt: Date; +} + +/** + * Chat performance summary + */ +export interface ChatPerformanceSummary { + totalMessages: number; + averageResponseTime: number; + averageTokensPerSecond: number; + totalTokensGenerated: number; + thinkingModeUsed: boolean; +} diff --git a/examples/react-native/RunAnywhereAI/src/types/index.ts b/examples/react-native/RunAnywhereAI/src/types/index.ts new file mode 100644 index 000000000..b08583cc5 --- /dev/null +++ b/examples/react-native/RunAnywhereAI/src/types/index.ts @@ -0,0 +1,35 @@ +/** + * Type Exports + * + * Reference: Swift sample app structure + * Tabs: Chat, STT, TTS, Voice (VoiceAssistant), Settings + */ + +// Chat types +export * from './chat'; + +// Model types +export * from './model'; + +// Voice types +export * from './voice'; + +// Settings types +export * from './settings'; + +// Navigation types - matching Swift sample app (ContentView.swift) +// Tab 0: Chat (LLM) +// Tab 1: Speech-to-Text +// Tab 2: Text-to-Speech +// Tab 3: Voice Assistant (STT + LLM + TTS) +// Tab 4: Settings +export type RootTabParamList = { + Chat: undefined; + STT: undefined; + TTS: undefined; + Voice: undefined; + Settings: undefined; +}; + +// Common utility types +export type Optional = Omit & Partial>; diff --git a/examples/react-native/RunAnywhereAI/src/types/model.ts b/examples/react-native/RunAnywhereAI/src/types/model.ts new file mode 100644 index 000000000..fcc3c540e --- /dev/null +++ b/examples/react-native/RunAnywhereAI/src/types/model.ts @@ -0,0 +1,252 @@ +/** + * Model Types - Matching iOS and SDK model definitions + * + * Reference: sdk/runanywhere-react-native/src/types/models.ts + */ + +/** + * LLM Framework types + */ +export enum LLMFramework { + CoreML = 'CoreML', + TensorFlowLite = 'TFLite', + MLX = 'MLX', + SwiftTransformers = 'SwiftTransformers', + ONNX = 'ONNX', + ExecuTorch = 'ExecuTorch', + LlamaCpp = 'LlamaCpp', + FoundationModels = 'FoundationModels', + PicoLLM = 'PicoLLM', + MLC = 'MLC', + MediaPipe = 'MediaPipe', + WhisperKit = 'WhisperKit', + OpenAIWhisper = 'OpenAIWhisper', + SystemTTS = 'SystemTTS', + PiperTTS = 'PiperTTS', +} + +/** + * Model category + */ +export enum ModelCategory { + Language = 'language', + SpeechRecognition = 'speech-recognition', + SpeechSynthesis = 'speech-synthesis', + Vision = 'vision', + ImageGeneration = 'image-generation', + Multimodal = 'multimodal', + Audio = 'audio', +} + +/** + * Model modality for filtering + */ +export enum ModelModality { + LLM = 'llm', + STT = 'stt', + TTS = 'tts', + VLM = 'vlm', +} + +/** + * Model load state + */ +export enum ModelLoadState { + NotLoaded = 'notLoaded', + Loading = 'loading', + Loaded = 'loaded', + Error = 'error', + Unloading = 'unloading', +} + +/** + * Model download state + */ +export enum ModelDownloadState { + NotDownloaded = 'notDownloaded', + Downloading = 'downloading', + Downloaded = 'downloaded', + Error = 'error', +} + +/** + * Model info + */ +export interface ModelInfo { + /** Unique identifier */ + id: string; + + /** Human-readable name */ + name: string; + + /** Model category */ + category: ModelCategory; + + /** Compatible frameworks */ + compatibleFrameworks: LLMFramework[]; + + /** Preferred framework */ + preferredFramework?: LLMFramework; + + /** Download size in bytes */ + downloadSize?: number; + + /** Memory required in bytes */ + memoryRequired?: number; + + /** Context length */ + contextLength?: number; + + /** Whether model supports thinking/reasoning */ + supportsThinking: boolean; + + /** Download URL */ + downloadURL?: string; + + /** Local path if downloaded */ + localPath?: string; + + /** Whether downloaded */ + isDownloaded: boolean; + + /** Whether available for use */ + isAvailable: boolean; + + /** Description */ + description?: string; +} + +/** + * Framework info + */ +export interface FrameworkInfo { + /** Framework identifier */ + framework: LLMFramework; + + /** Display name */ + displayName: string; + + /** Whether available on this device */ + isAvailable: boolean; + + /** Reason if not available */ + unavailableReason?: string; + + /** Modalities supported */ + modalities: ModelModality[]; + + /** Icon name for display */ + iconName: string; + + /** Theme color */ + color: string; +} + +/** + * Current model state for a modality + */ +export interface CurrentModelState { + /** Modality */ + modality: ModelModality; + + /** Model info if loaded */ + model?: ModelInfo; + + /** Framework being used */ + framework?: LLMFramework; + + /** Load state */ + loadState: ModelLoadState; + + /** Error message if any */ + error?: string; + + /** Load progress (0-1) */ + loadProgress?: number; +} + +/** + * Stored model info + */ +export interface StoredModel { + /** Model ID */ + id: string; + + /** Model name */ + name: string; + + /** Framework */ + framework: LLMFramework; + + /** Size on disk in bytes */ + sizeOnDisk: number; + + /** Download date */ + downloadedAt: Date; + + /** Last used date */ + lastUsed?: Date; +} + +/** + * Device info for model selection + */ +export interface DeviceInfo { + /** Device model name */ + modelName: string; + + /** Chip name */ + chipName: string; + + /** Total memory in bytes */ + totalMemory: number; + + /** Available memory in bytes */ + availableMemory: number; + + /** Whether device has Neural Engine / NPU */ + hasNeuralEngine: boolean; + + /** OS version */ + osVersion: string; + + /** Whether device has GPU */ + hasGPU?: boolean; + + /** Number of CPU cores */ + cpuCores?: number; +} + +/** + * Framework display name mapping + */ +export const FrameworkDisplayNames: Record = { + [LLMFramework.CoreML]: 'Core ML', + [LLMFramework.TensorFlowLite]: 'TensorFlow Lite', + [LLMFramework.MLX]: 'MLX', + [LLMFramework.SwiftTransformers]: 'Swift Transformers', + [LLMFramework.ONNX]: 'ONNX Runtime', + [LLMFramework.ExecuTorch]: 'ExecuTorch', + [LLMFramework.LlamaCpp]: 'llama.cpp', + [LLMFramework.FoundationModels]: 'Foundation Models', + [LLMFramework.PicoLLM]: 'Pico LLM', + [LLMFramework.MLC]: 'MLC', + [LLMFramework.MediaPipe]: 'MediaPipe', + [LLMFramework.WhisperKit]: 'WhisperKit', + [LLMFramework.OpenAIWhisper]: 'OpenAI Whisper', + [LLMFramework.SystemTTS]: 'System TTS', + [LLMFramework.PiperTTS]: 'Piper TTS', +}; + +/** + * Category display name mapping + */ +export const CategoryDisplayNames: Record = { + [ModelCategory.Language]: 'Language Model', + [ModelCategory.SpeechRecognition]: 'Speech Recognition', + [ModelCategory.SpeechSynthesis]: 'Text-to-Speech', + [ModelCategory.Vision]: 'Vision Model', + [ModelCategory.ImageGeneration]: 'Image Generation', + [ModelCategory.Multimodal]: 'Multimodal', + [ModelCategory.Audio]: 'Audio Processing', +}; diff --git a/examples/react-native/RunAnywhereAI/src/types/settings.ts b/examples/react-native/RunAnywhereAI/src/types/settings.ts new file mode 100644 index 000000000..ac95be54a --- /dev/null +++ b/examples/react-native/RunAnywhereAI/src/types/settings.ts @@ -0,0 +1,125 @@ +/** + * Settings Types + * + * Reference: examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Settings/ + */ + +/** + * Routing policy for execution decisions + */ +export enum RoutingPolicy { + Automatic = 'automatic', + DeviceOnly = 'deviceOnly', + PreferDevice = 'preferDevice', + PreferCloud = 'preferCloud', +} + +/** + * Generation settings + */ +export interface GenerationSettings { + /** Temperature (0.0 - 2.0) */ + temperature: number; + + /** Max tokens (500 - 20000) */ + maxTokens: number; + + /** Top P (optional) */ + topP?: number; + + /** Top K (optional) */ + topK?: number; +} + +/** + * App settings + */ +export interface AppSettings { + /** Routing policy */ + routingPolicy: RoutingPolicy; + + /** Generation settings */ + generation: GenerationSettings; + + /** API key (if set) */ + apiKey?: string; + + /** Whether API key is configured */ + isApiKeyConfigured: boolean; + + /** Enable debug mode */ + debugMode: boolean; +} + +/** + * Storage info + */ +export interface StorageInfo { + /** Total device storage in bytes */ + totalStorage: number; + + /** Storage used by app in bytes */ + appStorage: number; + + /** Storage used by models in bytes */ + modelsStorage: number; + + /** Cache size in bytes */ + cacheSize: number; + + /** Free space in bytes */ + freeSpace: number; +} + +/** + * Default settings values + */ +export const DEFAULT_SETTINGS: AppSettings = { + routingPolicy: RoutingPolicy.Automatic, + generation: { + temperature: 0.7, + maxTokens: 10000, + }, + isApiKeyConfigured: false, + debugMode: false, +}; + +/** + * Settings constraints + */ +export const SETTINGS_CONSTRAINTS = { + temperature: { + min: 0, + max: 2, + step: 0.1, + }, + maxTokens: { + min: 500, + max: 20000, + step: 500, + }, +}; + +/** + * Routing policy display names + */ +export const RoutingPolicyDisplayNames: Record = { + [RoutingPolicy.Automatic]: 'Automatic', + [RoutingPolicy.DeviceOnly]: 'Device Only', + [RoutingPolicy.PreferDevice]: 'Prefer Device', + [RoutingPolicy.PreferCloud]: 'Prefer Cloud', +}; + +/** + * Routing policy descriptions + */ +export const RoutingPolicyDescriptions: Record = { + [RoutingPolicy.Automatic]: + 'Automatically chooses between device and cloud based on model availability and performance.', + [RoutingPolicy.DeviceOnly]: + 'Only use on-device models. Requests will fail if no device model is available.', + [RoutingPolicy.PreferDevice]: + 'Prefer on-device execution, fall back to cloud if needed.', + [RoutingPolicy.PreferCloud]: + 'Prefer cloud execution, fall back to device if offline.', +}; diff --git a/examples/react-native/RunAnywhereAI/src/types/voice.ts b/examples/react-native/RunAnywhereAI/src/types/voice.ts new file mode 100644 index 000000000..ab9dfd4ae --- /dev/null +++ b/examples/react-native/RunAnywhereAI/src/types/voice.ts @@ -0,0 +1,222 @@ +/** + * Voice Types - STT, TTS, and Voice Assistant + * + * Reference: examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Voice/ + */ + +/** + * STT mode + */ +export enum STTMode { + Batch = 'batch', + Live = 'live', +} + +/** + * Voice pipeline status + */ +export enum VoicePipelineStatus { + Idle = 'idle', + Listening = 'listening', + Processing = 'processing', + Thinking = 'thinking', + Speaking = 'speaking', + Error = 'error', +} + +/** + * STT segment with timing + */ +export interface STTSegment { + /** Transcribed text */ + text: string; + + /** Start time in seconds */ + startTime: number; + + /** End time in seconds */ + endTime: number; + + /** Speaker ID if diarization enabled */ + speakerId?: string; + + /** Confidence score */ + confidence: number; +} + +/** + * STT result + */ +export interface STTResult { + /** Full transcription text */ + text: string; + + /** Segments with timing */ + segments: STTSegment[]; + + /** Detected language */ + language?: string; + + /** Overall confidence */ + confidence: number; + + /** Audio duration in seconds */ + duration: number; + + /** Processing time in milliseconds */ + processingTime: number; +} + +/** + * TTS configuration + */ +export interface TTSConfiguration { + /** Voice ID */ + voice: string; + + /** Speech rate (0.5 - 2.0) */ + rate: number; + + /** Pitch (0.5 - 2.0) */ + pitch: number; + + /** Volume (0.0 - 1.0) */ + volume: number; +} + +/** + * TTS result + */ +export interface TTSResult { + /** Audio data (base64 encoded) */ + audioData: string; + + /** Audio duration in seconds */ + duration: number; + + /** Sample rate */ + sampleRate: number; + + /** Generation time in milliseconds */ + generationTime: number; +} + +/** + * Available voice + */ +export interface Voice { + /** Voice ID */ + id: string; + + /** Display name */ + name: string; + + /** Language code */ + language: string; + + /** Voice gender */ + gender: 'male' | 'female' | 'neutral'; + + /** Sample audio URL */ + sampleURL?: string; +} + +/** + * Recording state + */ +export interface RecordingState { + /** Whether currently recording */ + isRecording: boolean; + + /** Recording duration in seconds */ + duration: number; + + /** Audio level (0-1) */ + audioLevel: number; + + /** Whether speech is detected */ + isSpeechDetected: boolean; +} + +/** + * Voice assistant conversation entry + */ +export interface VoiceConversationEntry { + /** Unique ID */ + id: string; + + /** Speaker (user or assistant) */ + speaker: 'user' | 'assistant'; + + /** Transcript text */ + text: string; + + /** Audio data if available */ + audioData?: string; + + /** Timestamp */ + timestamp: Date; + + /** Duration in seconds */ + duration?: number; +} + +/** + * Voice assistant state + */ +export interface VoiceAssistantState { + /** Pipeline status */ + status: VoicePipelineStatus; + + /** Current user transcript (live) */ + currentTranscript: string; + + /** Conversation history */ + conversation: VoiceConversationEntry[]; + + /** Recording state */ + recording: RecordingState; + + /** Is processing */ + isProcessing: boolean; + + /** Error message */ + error?: string; +} + +/** + * Audio settings + */ +export interface AudioSettings { + /** Sample rate */ + sampleRate: number; + + /** Number of channels */ + channels: number; + + /** Bits per sample */ + bitsPerSample: number; + + /** Enable noise suppression */ + noiseSuppression: boolean; + + /** Enable echo cancellation */ + echoCancellation: boolean; +} + +/** + * VAD (Voice Activity Detection) settings + */ +export interface VADSettings { + /** Energy threshold */ + energyThreshold: number; + + /** Silence duration to trigger end of speech (ms) */ + silenceDuration: number; + + /** Minimum speech duration (ms) */ + minSpeechDuration: number; + + /** Enable auto calibration */ + autoCalibration: boolean; +} diff --git a/examples/react-native/RunAnywhereAI/tsconfig.json b/examples/react-native/RunAnywhereAI/tsconfig.json new file mode 100644 index 000000000..08ef0d5d9 --- /dev/null +++ b/examples/react-native/RunAnywhereAI/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "strict": true, + "target": "esnext", + "module": "commonjs", + "lib": ["es2022"], + "allowJs": true, + "jsx": "react-native", + "noEmit": true, + "isolatedModules": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "allowSyntheticDefaultImports": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"], + "@components/*": ["src/components/*"], + "@screens/*": ["src/screens/*"], + "@hooks/*": ["src/hooks/*"], + "@theme/*": ["src/theme/*"], + "@types/*": ["src/types/*"], + "@services/*": ["src/services/*"], + "@store/*": ["src/store/*"], + "@utils/*": ["src/utils/*"] + } + }, + "include": ["src/**/*", "App.tsx", "index.js"], + "exclude": ["node_modules", "babel.config.js", "metro.config.js", "ios", "android"] +} diff --git a/examples/react-native/RunAnywhereAI/yarn.lock b/examples/react-native/RunAnywhereAI/yarn.lock new file mode 100644 index 000000000..26647f64c --- /dev/null +++ b/examples/react-native/RunAnywhereAI/yarn.lock @@ -0,0 +1,6375 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.24.7", "@babel/code-frame@^7.28.6": + version "7.28.6" + resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz" + integrity sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q== + dependencies: + "@babel/helper-validator-identifier" "^7.28.5" + js-tokens "^4.0.0" + picocolors "^1.1.1" + +"@babel/compat-data@^7.27.7", "@babel/compat-data@^7.28.6": + version "7.28.6" + resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz" + integrity sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg== + +"@babel/core@*", "@babel/core@^7.0.0", "@babel/core@^7.0.0 || ^8.0.0-0", "@babel/core@^7.0.0-0", "@babel/core@^7.11.0", "@babel/core@^7.11.6", "@babel/core@^7.12.3", "@babel/core@^7.24.4", "@babel/core@^7.25.2", "@babel/core@^7.4.0 || ^8.0.0-0 <8.0.0", "@babel/core@^7.8.0": + version "7.28.6" + resolved "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz" + integrity sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw== + dependencies: + "@babel/code-frame" "^7.28.6" + "@babel/generator" "^7.28.6" + "@babel/helper-compilation-targets" "^7.28.6" + "@babel/helper-module-transforms" "^7.28.6" + "@babel/helpers" "^7.28.6" + "@babel/parser" "^7.28.6" + "@babel/template" "^7.28.6" + "@babel/traverse" "^7.28.6" + "@babel/types" "^7.28.6" + "@jridgewell/remapping" "^2.3.5" + convert-source-map "^2.0.0" + debug "^4.1.0" + gensync "^1.0.0-beta.2" + json5 "^2.2.3" + semver "^6.3.1" + +"@babel/eslint-parser@^7.12.0", "@babel/eslint-parser@^7.25.1": + version "7.28.6" + resolved "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.28.6.tgz" + integrity sha512-QGmsKi2PBO/MHSQk+AAgA9R6OHQr+VqnniFE0eMWZcVcfBZoA2dKn2hUsl3Csg/Plt9opRUWdY7//VXsrIlEiA== + dependencies: + "@nicolo-ribaudo/eslint-scope-5-internals" "5.1.1-v1" + eslint-visitor-keys "^2.1.0" + semver "^6.3.1" + +"@babel/generator@^7.25.0", "@babel/generator@^7.28.6": + version "7.28.6" + resolved "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz" + integrity sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw== + dependencies: + "@babel/parser" "^7.28.6" + "@babel/types" "^7.28.6" + "@jridgewell/gen-mapping" "^0.3.12" + "@jridgewell/trace-mapping" "^0.3.28" + jsesc "^3.0.2" + +"@babel/helper-annotate-as-pure@^7.27.1", "@babel/helper-annotate-as-pure@^7.27.3": + version "7.27.3" + resolved "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz" + integrity sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg== + dependencies: + "@babel/types" "^7.27.3" + +"@babel/helper-compilation-targets@^7.27.1", "@babel/helper-compilation-targets@^7.27.2", "@babel/helper-compilation-targets@^7.28.6": + version "7.28.6" + resolved "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz" + integrity sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA== + dependencies: + "@babel/compat-data" "^7.28.6" + "@babel/helper-validator-option" "^7.27.1" + browserslist "^4.24.0" + lru-cache "^5.1.1" + semver "^6.3.1" + +"@babel/helper-create-class-features-plugin@^7.27.1", "@babel/helper-create-class-features-plugin@^7.28.6": + version "7.28.6" + resolved "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz" + integrity sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow== + dependencies: + "@babel/helper-annotate-as-pure" "^7.27.3" + "@babel/helper-member-expression-to-functions" "^7.28.5" + "@babel/helper-optimise-call-expression" "^7.27.1" + "@babel/helper-replace-supers" "^7.28.6" + "@babel/helper-skip-transparent-expression-wrappers" "^7.27.1" + "@babel/traverse" "^7.28.6" + semver "^6.3.1" + +"@babel/helper-create-regexp-features-plugin@^7.27.1": + version "7.28.5" + resolved "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.28.5.tgz" + integrity sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw== + dependencies: + "@babel/helper-annotate-as-pure" "^7.27.3" + regexpu-core "^6.3.1" + semver "^6.3.1" + +"@babel/helper-define-polyfill-provider@^0.6.5": + version "0.6.5" + resolved "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.5.tgz" + integrity sha512-uJnGFcPsWQK8fvjgGP5LZUZZsYGIoPeRjSF5PGwrelYgq7Q15/Ft9NGFp1zglwgIv//W0uG4BevRuSJRyylZPg== + dependencies: + "@babel/helper-compilation-targets" "^7.27.2" + "@babel/helper-plugin-utils" "^7.27.1" + debug "^4.4.1" + lodash.debounce "^4.0.8" + resolve "^1.22.10" + +"@babel/helper-globals@^7.28.0": + version "7.28.0" + resolved "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz" + integrity sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw== + +"@babel/helper-member-expression-to-functions@^7.28.5": + version "7.28.5" + resolved "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz" + integrity sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg== + dependencies: + "@babel/traverse" "^7.28.5" + "@babel/types" "^7.28.5" + +"@babel/helper-module-imports@^7.27.1", "@babel/helper-module-imports@^7.28.6": + version "7.28.6" + resolved "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz" + integrity sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw== + dependencies: + "@babel/traverse" "^7.28.6" + "@babel/types" "^7.28.6" + +"@babel/helper-module-transforms@^7.28.6": + version "7.28.6" + resolved "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz" + integrity sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA== + dependencies: + "@babel/helper-module-imports" "^7.28.6" + "@babel/helper-validator-identifier" "^7.28.5" + "@babel/traverse" "^7.28.6" + +"@babel/helper-optimise-call-expression@^7.27.1": + version "7.27.1" + resolved "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz" + integrity sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw== + dependencies: + "@babel/types" "^7.27.1" + +"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.27.1", "@babel/helper-plugin-utils@^7.28.6", "@babel/helper-plugin-utils@^7.8.0": + version "7.28.6" + resolved "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz" + integrity sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug== + +"@babel/helper-remap-async-to-generator@^7.27.1": + version "7.27.1" + resolved "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz" + integrity sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA== + dependencies: + "@babel/helper-annotate-as-pure" "^7.27.1" + "@babel/helper-wrap-function" "^7.27.1" + "@babel/traverse" "^7.27.1" + +"@babel/helper-replace-supers@^7.27.1", "@babel/helper-replace-supers@^7.28.6": + version "7.28.6" + resolved "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz" + integrity sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg== + dependencies: + "@babel/helper-member-expression-to-functions" "^7.28.5" + "@babel/helper-optimise-call-expression" "^7.27.1" + "@babel/traverse" "^7.28.6" + +"@babel/helper-skip-transparent-expression-wrappers@^7.27.1": + version "7.27.1" + resolved "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz" + integrity sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg== + dependencies: + "@babel/traverse" "^7.27.1" + "@babel/types" "^7.27.1" + +"@babel/helper-string-parser@^7.27.1": + version "7.27.1" + resolved "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz" + integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA== + +"@babel/helper-validator-identifier@^7.28.5": + version "7.28.5" + resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz" + integrity sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q== + +"@babel/helper-validator-option@^7.27.1": + version "7.27.1" + resolved "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz" + integrity sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg== + +"@babel/helper-wrap-function@^7.27.1": + version "7.28.6" + resolved "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.6.tgz" + integrity sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ== + dependencies: + "@babel/template" "^7.28.6" + "@babel/traverse" "^7.28.6" + "@babel/types" "^7.28.6" + +"@babel/helpers@^7.28.6": + version "7.28.6" + resolved "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz" + integrity sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw== + dependencies: + "@babel/template" "^7.28.6" + "@babel/types" "^7.28.6" + +"@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.20.7", "@babel/parser@^7.24.4", "@babel/parser@^7.25.3", "@babel/parser@^7.28.6": + version "7.28.6" + resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz" + integrity sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ== + dependencies: + "@babel/types" "^7.28.6" + +"@babel/plugin-proposal-export-default-from@^7.24.7": + version "7.27.1" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-export-default-from/-/plugin-proposal-export-default-from-7.27.1.tgz" + integrity sha512-hjlsMBl1aJc5lp8MoCDEZCiYzlgdRAShOjAfRw6X+GlpLpUPU7c3XNLsKFZbQk/1cRzBlJ7CXg3xJAJMrFa1Uw== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + +"@babel/plugin-syntax-async-generators@^7.8.4": + version "7.8.4" + resolved "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz" + integrity sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-bigint@^7.8.3": + version "7.8.3" + resolved "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz" + integrity sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-class-properties@^7.12.13": + version "7.12.13" + resolved "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz" + integrity sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA== + dependencies: + "@babel/helper-plugin-utils" "^7.12.13" + +"@babel/plugin-syntax-class-static-block@^7.14.5": + version "7.14.5" + resolved "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz" + integrity sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-syntax-dynamic-import@^7.8.3": + version "7.8.3" + resolved "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz" + integrity sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-export-default-from@^7.24.7": + version "7.28.6" + resolved "https://registry.npmjs.org/@babel/plugin-syntax-export-default-from/-/plugin-syntax-export-default-from-7.28.6.tgz" + integrity sha512-Svlx1fjJFnNz0LZeUaybRukSxZI3KkpApUmIRzEdXC5k8ErTOz0OD0kNrICi5Vc3GlpP5ZCeRyRO+mfWTSz+iQ== + dependencies: + "@babel/helper-plugin-utils" "^7.28.6" + +"@babel/plugin-syntax-flow@^7.12.1", "@babel/plugin-syntax-flow@^7.27.1": + version "7.28.6" + resolved "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.28.6.tgz" + integrity sha512-D+OrJumc9McXNEBI/JmFnc/0uCM2/Y3PEBG3gfV3QIYkKv5pvnpzFrl1kYCrcHJP8nOeFB/SHi1IHz29pNGuew== + dependencies: + "@babel/helper-plugin-utils" "^7.28.6" + +"@babel/plugin-syntax-import-attributes@^7.24.7": + version "7.28.6" + resolved "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz" + integrity sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw== + dependencies: + "@babel/helper-plugin-utils" "^7.28.6" + +"@babel/plugin-syntax-import-meta@^7.10.4": + version "7.10.4" + resolved "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz" + integrity sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-syntax-json-strings@^7.8.3": + version "7.8.3" + resolved "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz" + integrity sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-jsx@^7.27.1", "@babel/plugin-syntax-jsx@^7.28.6": + version "7.28.6" + resolved "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz" + integrity sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w== + dependencies: + "@babel/helper-plugin-utils" "^7.28.6" + +"@babel/plugin-syntax-logical-assignment-operators@^7.10.4": + version "7.10.4" + resolved "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz" + integrity sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-syntax-nullish-coalescing-operator@^7.8.3": + version "7.8.3" + resolved "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz" + integrity sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-numeric-separator@^7.10.4": + version "7.10.4" + resolved "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz" + integrity sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-syntax-object-rest-spread@^7.8.3": + version "7.8.3" + resolved "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz" + integrity sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-optional-catch-binding@^7.8.3": + version "7.8.3" + resolved "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz" + integrity sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-optional-chaining@^7.8.3": + version "7.8.3" + resolved "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz" + integrity sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-private-property-in-object@^7.14.5": + version "7.14.5" + resolved "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz" + integrity sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-syntax-top-level-await@^7.14.5": + version "7.14.5" + resolved "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz" + integrity sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-syntax-typescript@^7.28.6": + version "7.28.6" + resolved "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz" + integrity sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A== + dependencies: + "@babel/helper-plugin-utils" "^7.28.6" + +"@babel/plugin-transform-arrow-functions@^7.24.7", "@babel/plugin-transform-arrow-functions@7.27.1": + version "7.27.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz" + integrity sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + +"@babel/plugin-transform-async-generator-functions@^7.25.4": + version "7.28.6" + resolved "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.28.6.tgz" + integrity sha512-9knsChgsMzBV5Yh3kkhrZNxH3oCYAfMBkNNaVN4cP2RVlFPe8wYdwwcnOsAbkdDoV9UjFtOXWrWB52M8W4jNeA== + dependencies: + "@babel/helper-plugin-utils" "^7.28.6" + "@babel/helper-remap-async-to-generator" "^7.27.1" + "@babel/traverse" "^7.28.6" + +"@babel/plugin-transform-async-to-generator@^7.24.7": + version "7.28.6" + resolved "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.28.6.tgz" + integrity sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g== + dependencies: + "@babel/helper-module-imports" "^7.28.6" + "@babel/helper-plugin-utils" "^7.28.6" + "@babel/helper-remap-async-to-generator" "^7.27.1" + +"@babel/plugin-transform-block-scoping@^7.25.0": + version "7.28.6" + resolved "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.6.tgz" + integrity sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw== + dependencies: + "@babel/helper-plugin-utils" "^7.28.6" + +"@babel/plugin-transform-class-properties@^7.25.4": + version "7.28.6" + resolved "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz" + integrity sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw== + dependencies: + "@babel/helper-create-class-features-plugin" "^7.28.6" + "@babel/helper-plugin-utils" "^7.28.6" + +"@babel/plugin-transform-class-properties@7.27.1": + version "7.27.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.27.1.tgz" + integrity sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA== + dependencies: + "@babel/helper-create-class-features-plugin" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" + +"@babel/plugin-transform-classes@^7.25.4": + version "7.28.6" + resolved "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz" + integrity sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q== + dependencies: + "@babel/helper-annotate-as-pure" "^7.27.3" + "@babel/helper-compilation-targets" "^7.28.6" + "@babel/helper-globals" "^7.28.0" + "@babel/helper-plugin-utils" "^7.28.6" + "@babel/helper-replace-supers" "^7.28.6" + "@babel/traverse" "^7.28.6" + +"@babel/plugin-transform-classes@7.28.4": + version "7.28.4" + resolved "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.4.tgz" + integrity sha512-cFOlhIYPBv/iBoc+KS3M6et2XPtbT2HiCRfBXWtfpc9OAyostldxIf9YAYB6ypURBBbx+Qv6nyrLzASfJe+hBA== + dependencies: + "@babel/helper-annotate-as-pure" "^7.27.3" + "@babel/helper-compilation-targets" "^7.27.2" + "@babel/helper-globals" "^7.28.0" + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/helper-replace-supers" "^7.27.1" + "@babel/traverse" "^7.28.4" + +"@babel/plugin-transform-computed-properties@^7.24.7": + version "7.28.6" + resolved "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.28.6.tgz" + integrity sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ== + dependencies: + "@babel/helper-plugin-utils" "^7.28.6" + "@babel/template" "^7.28.6" + +"@babel/plugin-transform-destructuring@^7.24.8", "@babel/plugin-transform-destructuring@^7.28.5": + version "7.28.5" + resolved "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz" + integrity sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/traverse" "^7.28.5" + +"@babel/plugin-transform-flow-strip-types@^7.25.2": + version "7.27.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.27.1.tgz" + integrity sha512-G5eDKsu50udECw7DL2AcsysXiQyB7Nfg521t2OAJ4tbfTJ27doHLeF/vlI1NZGlLdbb/v+ibvtL1YBQqYOwJGg== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/plugin-syntax-flow" "^7.27.1" + +"@babel/plugin-transform-for-of@^7.24.7": + version "7.27.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz" + integrity sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/helper-skip-transparent-expression-wrappers" "^7.27.1" + +"@babel/plugin-transform-function-name@^7.25.1": + version "7.27.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz" + integrity sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ== + dependencies: + "@babel/helper-compilation-targets" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/traverse" "^7.27.1" + +"@babel/plugin-transform-literals@^7.25.2": + version "7.27.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz" + integrity sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + +"@babel/plugin-transform-logical-assignment-operators@^7.24.7": + version "7.28.6" + resolved "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.6.tgz" + integrity sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A== + dependencies: + "@babel/helper-plugin-utils" "^7.28.6" + +"@babel/plugin-transform-modules-commonjs@^7.24.8", "@babel/plugin-transform-modules-commonjs@^7.27.1": + version "7.28.6" + resolved "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz" + integrity sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA== + dependencies: + "@babel/helper-module-transforms" "^7.28.6" + "@babel/helper-plugin-utils" "^7.28.6" + +"@babel/plugin-transform-named-capturing-groups-regex@^7.24.7": + version "7.27.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.27.1.tgz" + integrity sha512-SstR5JYy8ddZvD6MhV0tM/j16Qds4mIpJTOd1Yu9J9pJjH93bxHECF7pgtc28XvkzTD6Pxcm/0Z73Hvk7kb3Ng== + dependencies: + "@babel/helper-create-regexp-features-plugin" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" + +"@babel/plugin-transform-nullish-coalescing-operator@^7.24.7": + version "7.28.6" + resolved "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz" + integrity sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg== + dependencies: + "@babel/helper-plugin-utils" "^7.28.6" + +"@babel/plugin-transform-nullish-coalescing-operator@7.27.1": + version "7.27.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.27.1.tgz" + integrity sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + +"@babel/plugin-transform-numeric-separator@^7.24.7": + version "7.28.6" + resolved "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.28.6.tgz" + integrity sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w== + dependencies: + "@babel/helper-plugin-utils" "^7.28.6" + +"@babel/plugin-transform-object-rest-spread@^7.24.7": + version "7.28.6" + resolved "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.6.tgz" + integrity sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA== + dependencies: + "@babel/helper-compilation-targets" "^7.28.6" + "@babel/helper-plugin-utils" "^7.28.6" + "@babel/plugin-transform-destructuring" "^7.28.5" + "@babel/plugin-transform-parameters" "^7.27.7" + "@babel/traverse" "^7.28.6" + +"@babel/plugin-transform-optional-catch-binding@^7.24.7": + version "7.28.6" + resolved "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.28.6.tgz" + integrity sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ== + dependencies: + "@babel/helper-plugin-utils" "^7.28.6" + +"@babel/plugin-transform-optional-chaining@^7.24.8": + version "7.28.6" + resolved "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.6.tgz" + integrity sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w== + dependencies: + "@babel/helper-plugin-utils" "^7.28.6" + "@babel/helper-skip-transparent-expression-wrappers" "^7.27.1" + +"@babel/plugin-transform-optional-chaining@7.27.1": + version "7.27.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.27.1.tgz" + integrity sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/helper-skip-transparent-expression-wrappers" "^7.27.1" + +"@babel/plugin-transform-parameters@^7.24.7", "@babel/plugin-transform-parameters@^7.27.7": + version "7.27.7" + resolved "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz" + integrity sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + +"@babel/plugin-transform-private-methods@^7.24.7": + version "7.28.6" + resolved "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.28.6.tgz" + integrity sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg== + dependencies: + "@babel/helper-create-class-features-plugin" "^7.28.6" + "@babel/helper-plugin-utils" "^7.28.6" + +"@babel/plugin-transform-private-property-in-object@^7.24.7": + version "7.28.6" + resolved "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.28.6.tgz" + integrity sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA== + dependencies: + "@babel/helper-annotate-as-pure" "^7.27.3" + "@babel/helper-create-class-features-plugin" "^7.28.6" + "@babel/helper-plugin-utils" "^7.28.6" + +"@babel/plugin-transform-react-display-name@^7.24.7": + version "7.28.0" + resolved "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.28.0.tgz" + integrity sha512-D6Eujc2zMxKjfa4Zxl4GHMsmhKKZ9VpcqIchJLvwTxad9zWIYulwYItBovpDOoNLISpcZSXoDJ5gaGbQUDqViA== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + +"@babel/plugin-transform-react-jsx-self@^7.24.7": + version "7.27.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz" + integrity sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + +"@babel/plugin-transform-react-jsx-source@^7.24.7": + version "7.27.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz" + integrity sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + +"@babel/plugin-transform-react-jsx@^7.25.2": + version "7.28.6" + resolved "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.28.6.tgz" + integrity sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow== + dependencies: + "@babel/helper-annotate-as-pure" "^7.27.3" + "@babel/helper-module-imports" "^7.28.6" + "@babel/helper-plugin-utils" "^7.28.6" + "@babel/plugin-syntax-jsx" "^7.28.6" + "@babel/types" "^7.28.6" + +"@babel/plugin-transform-regenerator@^7.24.7": + version "7.28.6" + resolved "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.28.6.tgz" + integrity sha512-eZhoEZHYQLL5uc1gS5e9/oTknS0sSSAtd5TkKMUp3J+S/CaUjagc0kOUPsEbDmMeva0nC3WWl4SxVY6+OBuxfw== + dependencies: + "@babel/helper-plugin-utils" "^7.28.6" + +"@babel/plugin-transform-runtime@^7.24.7": + version "7.28.5" + resolved "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.28.5.tgz" + integrity sha512-20NUVgOrinudkIBzQ2bNxP08YpKprUkRTiRSd2/Z5GOdPImJGkoN4Z7IQe1T5AdyKI1i5L6RBmluqdSzvaq9/w== + dependencies: + "@babel/helper-module-imports" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" + babel-plugin-polyfill-corejs2 "^0.4.14" + babel-plugin-polyfill-corejs3 "^0.13.0" + babel-plugin-polyfill-regenerator "^0.6.5" + semver "^6.3.1" + +"@babel/plugin-transform-shorthand-properties@^7.24.7", "@babel/plugin-transform-shorthand-properties@7.27.1": + version "7.27.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz" + integrity sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + +"@babel/plugin-transform-spread@^7.24.7": + version "7.28.6" + resolved "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.28.6.tgz" + integrity sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA== + dependencies: + "@babel/helper-plugin-utils" "^7.28.6" + "@babel/helper-skip-transparent-expression-wrappers" "^7.27.1" + +"@babel/plugin-transform-sticky-regex@^7.24.7": + version "7.27.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz" + integrity sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + +"@babel/plugin-transform-template-literals@7.27.1": + version "7.27.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz" + integrity sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + +"@babel/plugin-transform-typescript@^7.25.2", "@babel/plugin-transform-typescript@^7.27.1": + version "7.28.6" + resolved "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.6.tgz" + integrity sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw== + dependencies: + "@babel/helper-annotate-as-pure" "^7.27.3" + "@babel/helper-create-class-features-plugin" "^7.28.6" + "@babel/helper-plugin-utils" "^7.28.6" + "@babel/helper-skip-transparent-expression-wrappers" "^7.27.1" + "@babel/plugin-syntax-typescript" "^7.28.6" + +"@babel/plugin-transform-unicode-regex@^7.24.7", "@babel/plugin-transform-unicode-regex@7.27.1": + version "7.27.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz" + integrity sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw== + dependencies: + "@babel/helper-create-regexp-features-plugin" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" + +"@babel/preset-typescript@7.27.1": + version "7.27.1" + resolved "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.27.1.tgz" + integrity sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/helper-validator-option" "^7.27.1" + "@babel/plugin-syntax-jsx" "^7.27.1" + "@babel/plugin-transform-modules-commonjs" "^7.27.1" + "@babel/plugin-transform-typescript" "^7.27.1" + +"@babel/runtime@^7.25.0": + version "7.28.6" + resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz" + integrity sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA== + +"@babel/template@^7.25.0", "@babel/template@^7.28.6", "@babel/template@^7.3.3": + version "7.28.6" + resolved "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz" + integrity sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ== + dependencies: + "@babel/code-frame" "^7.28.6" + "@babel/parser" "^7.28.6" + "@babel/types" "^7.28.6" + +"@babel/traverse--for-generate-function-map@npm:@babel/traverse@^7.25.3": + version "7.28.6" + resolved "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz" + integrity sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg== + dependencies: + "@babel/code-frame" "^7.28.6" + "@babel/generator" "^7.28.6" + "@babel/helper-globals" "^7.28.0" + "@babel/parser" "^7.28.6" + "@babel/template" "^7.28.6" + "@babel/types" "^7.28.6" + debug "^4.3.1" + +"@babel/traverse@^7.25.3", "@babel/traverse@^7.27.1", "@babel/traverse@^7.28.4", "@babel/traverse@^7.28.5", "@babel/traverse@^7.28.6": + version "7.28.6" + resolved "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz" + integrity sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg== + dependencies: + "@babel/code-frame" "^7.28.6" + "@babel/generator" "^7.28.6" + "@babel/helper-globals" "^7.28.0" + "@babel/parser" "^7.28.6" + "@babel/template" "^7.28.6" + "@babel/types" "^7.28.6" + debug "^4.3.1" + +"@babel/types@^7.0.0", "@babel/types@^7.20.7", "@babel/types@^7.25.2", "@babel/types@^7.27.1", "@babel/types@^7.27.3", "@babel/types@^7.28.2", "@babel/types@^7.28.5", "@babel/types@^7.28.6", "@babel/types@^7.3.3": + version "7.28.6" + resolved "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz" + integrity sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg== + dependencies: + "@babel/helper-string-parser" "^7.27.1" + "@babel/helper-validator-identifier" "^7.28.5" + +"@egjs/hammerjs@^2.0.17": + version "2.0.17" + resolved "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz" + integrity sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A== + dependencies: + "@types/hammerjs" "^2.0.36" + +"@emnapi/core@^1.7.1": + version "1.8.1" + resolved "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz" + integrity sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg== + dependencies: + "@emnapi/wasi-threads" "1.1.0" + tslib "^2.4.0" + +"@emnapi/runtime@^1.7.1": + version "1.8.1" + resolved "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz" + integrity sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg== + dependencies: + tslib "^2.4.0" + +"@emnapi/wasi-threads@1.1.0": + version "1.1.0" + resolved "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz" + integrity sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ== + dependencies: + tslib "^2.4.0" + +"@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.4.0", "@eslint-community/eslint-utils@^4.9.1": + version "4.9.1" + resolved "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz" + integrity sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ== + dependencies: + eslint-visitor-keys "^3.4.3" + +"@eslint-community/regexpp@^4.10.0", "@eslint-community/regexpp@^4.12.2", "@eslint-community/regexpp@^4.6.1": + version "4.12.2" + resolved "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz" + integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew== + +"@eslint/eslintrc@^2.1.4": + version "2.1.4" + resolved "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz" + integrity sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ== + dependencies: + ajv "^6.12.4" + debug "^4.3.2" + espree "^9.6.0" + globals "^13.19.0" + ignore "^5.2.0" + import-fresh "^3.2.1" + js-yaml "^4.1.0" + minimatch "^3.1.2" + strip-json-comments "^3.1.1" + +"@eslint/js@8.57.1": + version "8.57.1" + resolved "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz" + integrity sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q== + +"@hapi/hoek@^9.0.0", "@hapi/hoek@^9.3.0": + version "9.3.0" + resolved "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz" + integrity sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ== + +"@hapi/topo@^5.1.0": + version "5.1.0" + resolved "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz" + integrity sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg== + dependencies: + "@hapi/hoek" "^9.0.0" + +"@humanwhocodes/config-array@^0.13.0": + version "0.13.0" + resolved "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz" + integrity sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw== + dependencies: + "@humanwhocodes/object-schema" "^2.0.3" + debug "^4.3.1" + minimatch "^3.0.5" + +"@humanwhocodes/module-importer@^1.0.1": + version "1.0.1" + resolved "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz" + integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== + +"@humanwhocodes/object-schema@^2.0.3": + version "2.0.3" + resolved "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz" + integrity sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA== + +"@isaacs/ttlcache@^1.4.1": + version "1.4.1" + resolved "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-1.4.1.tgz" + integrity sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA== + +"@istanbuljs/load-nyc-config@^1.0.0": + version "1.1.0" + resolved "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz" + integrity sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ== + dependencies: + camelcase "^5.3.1" + find-up "^4.1.0" + get-package-type "^0.1.0" + js-yaml "^3.13.1" + resolve-from "^5.0.0" + +"@istanbuljs/schema@^0.1.2": + version "0.1.3" + resolved "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz" + integrity sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA== + +"@jest/create-cache-key-function@^29.7.0": + version "29.7.0" + resolved "https://registry.npmjs.org/@jest/create-cache-key-function/-/create-cache-key-function-29.7.0.tgz" + integrity sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA== + dependencies: + "@jest/types" "^29.6.3" + +"@jest/environment@^29.7.0": + version "29.7.0" + resolved "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz" + integrity sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw== + dependencies: + "@jest/fake-timers" "^29.7.0" + "@jest/types" "^29.6.3" + "@types/node" "*" + jest-mock "^29.7.0" + +"@jest/fake-timers@^29.7.0": + version "29.7.0" + resolved "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz" + integrity sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ== + dependencies: + "@jest/types" "^29.6.3" + "@sinonjs/fake-timers" "^10.0.2" + "@types/node" "*" + jest-message-util "^29.7.0" + jest-mock "^29.7.0" + jest-util "^29.7.0" + +"@jest/schemas@^29.6.3": + version "29.6.3" + resolved "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz" + integrity sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA== + dependencies: + "@sinclair/typebox" "^0.27.8" + +"@jest/transform@^29.7.0": + version "29.7.0" + resolved "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz" + integrity sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw== + dependencies: + "@babel/core" "^7.11.6" + "@jest/types" "^29.6.3" + "@jridgewell/trace-mapping" "^0.3.18" + babel-plugin-istanbul "^6.1.1" + chalk "^4.0.0" + convert-source-map "^2.0.0" + fast-json-stable-stringify "^2.1.0" + graceful-fs "^4.2.9" + jest-haste-map "^29.7.0" + jest-regex-util "^29.6.3" + jest-util "^29.7.0" + micromatch "^4.0.4" + pirates "^4.0.4" + slash "^3.0.0" + write-file-atomic "^4.0.2" + +"@jest/types@^29.6.3": + version "29.6.3" + resolved "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz" + integrity sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw== + dependencies: + "@jest/schemas" "^29.6.3" + "@types/istanbul-lib-coverage" "^2.0.0" + "@types/istanbul-reports" "^3.0.0" + "@types/node" "*" + "@types/yargs" "^17.0.8" + chalk "^4.0.0" + +"@jridgewell/gen-mapping@^0.3.12", "@jridgewell/gen-mapping@^0.3.5": + version "0.3.13" + resolved "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz" + integrity sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.0" + "@jridgewell/trace-mapping" "^0.3.24" + +"@jridgewell/remapping@^2.3.5": + version "2.3.5" + resolved "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz" + integrity sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ== + dependencies: + "@jridgewell/gen-mapping" "^0.3.5" + "@jridgewell/trace-mapping" "^0.3.24" + +"@jridgewell/resolve-uri@^3.1.0": + version "3.1.2" + resolved "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz" + integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== + +"@jridgewell/source-map@^0.3.3": + version "0.3.11" + resolved "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz" + integrity sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA== + dependencies: + "@jridgewell/gen-mapping" "^0.3.5" + "@jridgewell/trace-mapping" "^0.3.25" + +"@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.5.0": + version "1.5.5" + resolved "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz" + integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== + +"@jridgewell/trace-mapping@^0.3.18", "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25", "@jridgewell/trace-mapping@^0.3.28": + version "0.3.31" + resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz" + integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + +"@napi-rs/wasm-runtime@^1.1.1": + version "1.1.1" + resolved "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz" + integrity sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A== + dependencies: + "@emnapi/core" "^1.7.1" + "@emnapi/runtime" "^1.7.1" + "@tybys/wasm-util" "^0.10.1" + +"@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1": + version "5.1.1-v1" + resolved "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz" + integrity sha512-54/JRvkLIzzDWshCWfuhadfrfZVPiElY8Fcgmg1HroEly/EDSszzhBAsarCux+D/kOslTRquNzuyGSmUSTTHGg== + dependencies: + eslint-scope "5.1.1" + +"@nodelib/fs.scandir@2.1.5": + version "2.1.5" + resolved "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz" + integrity sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g== + dependencies: + "@nodelib/fs.stat" "2.0.5" + run-parallel "^1.1.9" + +"@nodelib/fs.stat@^2.0.2", "@nodelib/fs.stat@2.0.5": + version "2.0.5" + resolved "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz" + integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== + +"@nodelib/fs.walk@^1.2.3", "@nodelib/fs.walk@^1.2.8": + version "1.2.8" + resolved "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz" + integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg== + dependencies: + "@nodelib/fs.scandir" "2.1.5" + fastq "^1.6.0" + +"@oxc-resolver/binding-android-arm-eabi@11.16.3": + version "11.16.3" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.16.3.tgz" + integrity sha512-CVyWHu6ACDqDcJxR4nmGiG8vDF4TISJHqRNzac5z/gPQycs/QrP/1pDsJBy0MD7jSw8nVq2E5WqeHQKabBG/Jg== + +"@oxc-resolver/binding-android-arm64@11.16.3": + version "11.16.3" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.16.3.tgz" + integrity sha512-tTIoB7plLeh2o6Ay7NnV5CJb6QUXdxI7Shnsp2ECrLSV81k+oVE3WXYrQSh4ltWL75i0OgU5Bj3bsuyg5SMepw== + +"@oxc-resolver/binding-darwin-arm64@11.16.3": + version "11.16.3" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.16.3.tgz" + integrity sha512-OXKVH7uwYd3Rbw1s2yJZd6/w+6b01iaokZubYhDAq4tOYArr+YCS+lr81q1hsTPPRZeIsWE+rJLulmf1qHdYZA== + +"@oxc-resolver/binding-darwin-x64@11.16.3": + version "11.16.3" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.16.3.tgz" + integrity sha512-WwjQ4WdnCxVYZYd3e3oY5XbV3JeLy9pPMK+eQQ2m8DtqUtbxnvPpAYC2Knv/2bS6q5JiktqOVJ2Hfia3OSo0/A== + +"@oxc-resolver/binding-freebsd-x64@11.16.3": + version "11.16.3" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.16.3.tgz" + integrity sha512-4OHKFGJBBfOnuJnelbCS4eBorI6cj54FUxcZJwEXPeoLc8yzORBoJ2w+fQbwjlQcUUZLEg92uGhKCRiUoqznjg== + +"@oxc-resolver/binding-linux-arm-gnueabihf@11.16.3": + version "11.16.3" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.16.3.tgz" + integrity sha512-OM3W0NLt9u7uKwG/yZbeXABansZC0oZeDF1nKgvcZoRw4/Yak6/l4S0onBfDFeYMY94eYeAt2bl60e30lgsb5A== + +"@oxc-resolver/binding-linux-arm-musleabihf@11.16.3": + version "11.16.3" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.16.3.tgz" + integrity sha512-MRs7D7i1t7ACsAdTuP81gLZES918EpBmiUyEl8fu302yQB+4L7L7z0Ui8BWnthUTQd3nAU9dXvENLK/SqRVH8A== + +"@oxc-resolver/binding-linux-arm64-gnu@11.16.3": + version "11.16.3" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.16.3.tgz" + integrity sha512-0eVYZxSceNqGADzhlV4ZRqkHF0fjWxRXQOB7Qwl5y1gN/XYUDvMfip+ngtzj4dM7zQT4U97hUhJ7PUKSy/JIGQ== + +"@oxc-resolver/binding-linux-arm64-musl@11.16.3": + version "11.16.3" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.16.3.tgz" + integrity sha512-B1BvLeZbgDdVN0FvU40l5Q7lej8310WlabCBaouk8jY7H7xbI8phtomTtk3Efmevgfy5hImaQJu6++OmcFb2NQ== + +"@oxc-resolver/binding-linux-ppc64-gnu@11.16.3": + version "11.16.3" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.16.3.tgz" + integrity sha512-q7khglic3Jqak7uDgA3MFnjDeI7krQT595GDZpvFq785fmFYSx8rlTkoHzmhQtUisYtl4XG7WUscwsoidFUI4w== + +"@oxc-resolver/binding-linux-riscv64-gnu@11.16.3": + version "11.16.3" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.16.3.tgz" + integrity sha512-aFRNmQNPzDgQEbw2s3c8yJYRimacSDI+u9df8rn5nSKzTVitHmbEpZqfxpwNLCKIuLSNmozHR1z1OT+oZVeYqg== + +"@oxc-resolver/binding-linux-riscv64-musl@11.16.3": + version "11.16.3" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.16.3.tgz" + integrity sha512-vZI85SvSMADcEL9G1TIrV0Rlkc1fY5Mup0DdlVC5EHPysZB4hXXHpr+h09pjlK5y+5om5foIzDRxE1baUCaWOA== + +"@oxc-resolver/binding-linux-s390x-gnu@11.16.3": + version "11.16.3" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.16.3.tgz" + integrity sha512-xiLBnaUlddFEzRHiHiSGEMbkg8EwZY6VD8F+3GfnFsiK3xg/4boaUV2bwXd+nUzl3UDQOMW1QcZJ4jJSb0qiJA== + +"@oxc-resolver/binding-linux-x64-gnu@11.16.3": + version "11.16.3" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.16.3.tgz" + integrity sha512-6y0b05wIazJJgwu7yU/AYGFswzQQudYJBOb/otDhiDacp1+6ye8egoxx63iVo9lSpDbipL++54AJQFlcOHCB+g== + +"@oxc-resolver/binding-linux-x64-musl@11.16.3": + version "11.16.3" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.16.3.tgz" + integrity sha512-RmMgwuMa42c9logS7Pjprf5KCp8J1a1bFiuBFtG9/+yMu0BhY2t+0VR/um7pwtkNFvIQqAVh6gDOg/PnoKRcdQ== + +"@oxc-resolver/binding-openharmony-arm64@11.16.3": + version "11.16.3" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.16.3.tgz" + integrity sha512-/7AYRkjjW7xu1nrHgWUFy99Duj4/ydOBVaHtODie9/M6fFngo+8uQDFFnzmr4q//sd/cchIerISp/8CQ5TsqIA== + +"@oxc-resolver/binding-wasm32-wasi@11.16.3": + version "11.16.3" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.16.3.tgz" + integrity sha512-urM6aIPbi5di4BSlnpd/TWtDJgG6RD06HvLBuNM+qOYuFtY1/xPbzQ2LanBI2ycpqIoIZwsChyplALwAMdyfCQ== + dependencies: + "@napi-rs/wasm-runtime" "^1.1.1" + +"@oxc-resolver/binding-win32-arm64-msvc@11.16.3": + version "11.16.3" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.16.3.tgz" + integrity sha512-QuvLqGKf7frxWHQ5TnrcY0C/hJpANsaez99Q4dAk1hen7lDTD4FBPtBzPnntLFXeaVG3PnSmnVjlv0vMILwU7Q== + +"@oxc-resolver/binding-win32-ia32-msvc@11.16.3": + version "11.16.3" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-11.16.3.tgz" + integrity sha512-QR/witXK6BmYTlEP8CCjC5fxeG5U9A6a50pNpC1nLnhAcJjtzFG8KcQ5etVy/XvCLiDc7fReaAWRNWtCaIhM8Q== + +"@oxc-resolver/binding-win32-x64-msvc@11.16.3": + version "11.16.3" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.16.3.tgz" + integrity sha512-bFuJRKOscsDAEZ/a8BezcTMAe2BQ/OBRfuMLFUuINfTR5qGVcm4a3xBIrQVepBaPxFj16SJdRjGe05vDiwZmFw== + +"@pkgr/core@^0.2.9": + version "0.2.9" + resolved "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz" + integrity sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA== + +"@react-native-async-storage/async-storage@^2.2.0": + version "2.2.0" + resolved "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz" + integrity sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw== + dependencies: + merge-options "^3.0.4" + +"@react-native-community/cli-clean@20.1.0": + version "20.1.0" + resolved "https://registry.npmjs.org/@react-native-community/cli-clean/-/cli-clean-20.1.0.tgz" + integrity sha512-77L4DifWfxAT8ByHnkypge7GBMYpbJAjBGV+toowt5FQSGaTBDcBHCX+FFqFRukD5fH6i8sZ41Gtw+nbfCTTIA== + dependencies: + "@react-native-community/cli-tools" "20.1.0" + execa "^5.0.0" + fast-glob "^3.3.2" + picocolors "^1.1.1" + +"@react-native-community/cli-config-android@20.1.0": + version "20.1.0" + resolved "https://registry.npmjs.org/@react-native-community/cli-config-android/-/cli-config-android-20.1.0.tgz" + integrity sha512-3A01ZDyFeCALzzPcwP/fleHoP3sGNq1UX7FzxkTrOFX8RRL9ntXNXQd27E56VU4BBxGAjAJT4Utw8pcOjJceIA== + dependencies: + "@react-native-community/cli-tools" "20.1.0" + fast-glob "^3.3.2" + fast-xml-parser "^4.4.1" + picocolors "^1.1.1" + +"@react-native-community/cli-config-apple@20.1.0": + version "20.1.0" + resolved "https://registry.npmjs.org/@react-native-community/cli-config-apple/-/cli-config-apple-20.1.0.tgz" + integrity sha512-n6JVs8Q3yxRbtZQOy05ofeb1kGtspGN3SgwPmuaqvURF9fsuS7c4/9up2Kp9C+1D2J1remPJXiZLNGOcJvfpOA== + dependencies: + "@react-native-community/cli-tools" "20.1.0" + execa "^5.0.0" + fast-glob "^3.3.2" + picocolors "^1.1.1" + +"@react-native-community/cli-config@20.1.0": + version "20.1.0" + resolved "https://registry.npmjs.org/@react-native-community/cli-config/-/cli-config-20.1.0.tgz" + integrity sha512-1x9rhLLR/dKKb92Lb5O0l0EmUG08FHf+ZVyVEf9M+tX+p5QIm52MRiy43R0UAZ2jJnFApxRk+N3sxoYK4Dtnag== + dependencies: + "@react-native-community/cli-tools" "20.1.0" + cosmiconfig "^9.0.0" + deepmerge "^4.3.0" + fast-glob "^3.3.2" + joi "^17.2.1" + picocolors "^1.1.1" + +"@react-native-community/cli-doctor@20.1.0": + version "20.1.0" + resolved "https://registry.npmjs.org/@react-native-community/cli-doctor/-/cli-doctor-20.1.0.tgz" + integrity sha512-QfJF1GVjA4PBrIT3SJ0vFFIu0km1vwOmLDlOYVqfojajZJ+Dnvl0f94GN1il/jT7fITAxom///XH3/URvi7YTQ== + dependencies: + "@react-native-community/cli-config" "20.1.0" + "@react-native-community/cli-platform-android" "20.1.0" + "@react-native-community/cli-platform-apple" "20.1.0" + "@react-native-community/cli-platform-ios" "20.1.0" + "@react-native-community/cli-tools" "20.1.0" + command-exists "^1.2.8" + deepmerge "^4.3.0" + envinfo "^7.13.0" + execa "^5.0.0" + node-stream-zip "^1.9.1" + ora "^5.4.1" + picocolors "^1.1.1" + semver "^7.5.2" + wcwidth "^1.0.1" + yaml "^2.2.1" + +"@react-native-community/cli-platform-android@20.1.0", "@react-native-community/cli-platform-android@latest": + version "20.1.0" + resolved "https://registry.npmjs.org/@react-native-community/cli-platform-android/-/cli-platform-android-20.1.0.tgz" + integrity sha512-TeHPDThOwDppQRpndm9kCdRCBI8AMy3HSIQ+iy7VYQXL5BtZ5LfmGdusoj7nVN/ZGn0Lc6Gwts5qowyupXdeKg== + dependencies: + "@react-native-community/cli-config-android" "20.1.0" + "@react-native-community/cli-tools" "20.1.0" + execa "^5.0.0" + logkitty "^0.7.1" + picocolors "^1.1.1" + +"@react-native-community/cli-platform-apple@20.1.0": + version "20.1.0" + resolved "https://registry.npmjs.org/@react-native-community/cli-platform-apple/-/cli-platform-apple-20.1.0.tgz" + integrity sha512-0ih1hrYezSM2cuOlVnwBEFtMwtd8YgpTLmZauDJCv50rIumtkI1cQoOgLoS4tbPCj9U/Vn2a9BFH0DLFOOIacg== + dependencies: + "@react-native-community/cli-config-apple" "20.1.0" + "@react-native-community/cli-tools" "20.1.0" + execa "^5.0.0" + fast-xml-parser "^4.4.1" + picocolors "^1.1.1" + +"@react-native-community/cli-platform-ios@20.1.0", "@react-native-community/cli-platform-ios@latest": + version "20.1.0" + resolved "https://registry.npmjs.org/@react-native-community/cli-platform-ios/-/cli-platform-ios-20.1.0.tgz" + integrity sha512-XN7Da9z4WsJxtqVtEzY8q2bv22OsvzaFP5zy5+phMWNoJlU4lf7IvBSxqGYMpQ9XhYP7arDw5vmW4W34s06rnA== + dependencies: + "@react-native-community/cli-platform-apple" "20.1.0" + +"@react-native-community/cli-server-api@20.1.0": + version "20.1.0" + resolved "https://registry.npmjs.org/@react-native-community/cli-server-api/-/cli-server-api-20.1.0.tgz" + integrity sha512-Tb415Oh8syXNT2zOzLzFkBXznzGaqKCiaichxKzGCDKg6JGHp3jSuCmcTcaPeYC7oc32n/S3Psw7798r4Q/7lA== + dependencies: + "@react-native-community/cli-tools" "20.1.0" + body-parser "^1.20.3" + compression "^1.7.1" + connect "^3.6.5" + errorhandler "^1.5.1" + nocache "^3.0.1" + open "^6.2.0" + pretty-format "^29.7.0" + serve-static "^1.13.1" + ws "^6.2.3" + +"@react-native-community/cli-tools@20.1.0": + version "20.1.0" + resolved "https://registry.npmjs.org/@react-native-community/cli-tools/-/cli-tools-20.1.0.tgz" + integrity sha512-/YmzHGOkY6Bgrv4OaA1L8rFqsBlQd1EB2/ipAoKPiieV0EcB5PUamUSuNeFU3sBZZTYQCUENwX4wgOHgFUlDnQ== + dependencies: + "@vscode/sudo-prompt" "^9.0.0" + appdirsjs "^1.2.4" + execa "^5.0.0" + find-up "^5.0.0" + launch-editor "^2.9.1" + mime "^2.4.1" + ora "^5.4.1" + picocolors "^1.1.1" + prompts "^2.4.2" + semver "^7.5.2" + +"@react-native-community/cli-types@20.1.0": + version "20.1.0" + resolved "https://registry.npmjs.org/@react-native-community/cli-types/-/cli-types-20.1.0.tgz" + integrity sha512-D0kDspcwgbVXyNjwicT7Bb1JgXjijTw1JJd+qxyF/a9+sHv7TU4IchV+gN38QegeXqVyM4Ym7YZIvXMFBmyJqA== + dependencies: + joi "^17.2.1" + +"@react-native-community/cli@*", "@react-native-community/cli@latest": + version "20.1.0" + resolved "https://registry.npmjs.org/@react-native-community/cli/-/cli-20.1.0.tgz" + integrity sha512-441WsVtRe4nGJ9OzA+QMU1+22lA6Q2hRWqqIMKD0wjEMLqcSfOZyu2UL9a/yRpL/dRpyUsU4n7AxqKfTKO/Csg== + dependencies: + "@react-native-community/cli-clean" "20.1.0" + "@react-native-community/cli-config" "20.1.0" + "@react-native-community/cli-doctor" "20.1.0" + "@react-native-community/cli-server-api" "20.1.0" + "@react-native-community/cli-tools" "20.1.0" + "@react-native-community/cli-types" "20.1.0" + commander "^9.4.1" + deepmerge "^4.3.0" + execa "^5.0.0" + find-up "^5.0.0" + fs-extra "^8.1.0" + graceful-fs "^4.1.3" + picocolors "^1.1.1" + prompts "^2.4.2" + semver "^7.5.2" + +"@react-native/assets-registry@0.83.1": + version "0.83.1" + resolved "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.83.1.tgz" + integrity sha512-AT7/T6UwQqO39bt/4UL5EXvidmrddXrt0yJa7ENXndAv+8yBzMsZn6fyiax6+ERMt9GLzAECikv3lj22cn2wJA== + +"@react-native/babel-plugin-codegen@0.83.1": + version "0.83.1" + resolved "https://registry.npmjs.org/@react-native/babel-plugin-codegen/-/babel-plugin-codegen-0.83.1.tgz" + integrity sha512-VPj8O3pG1ESjZho9WVKxqiuryrotAECPHGF5mx46zLUYNTWR5u9OMUXYk7LeLy+JLWdGEZ2Gn3KoXeFZbuqE+g== + dependencies: + "@babel/traverse" "^7.25.3" + "@react-native/codegen" "0.83.1" + +"@react-native/babel-preset@0.83.1": + version "0.83.1" + resolved "https://registry.npmjs.org/@react-native/babel-preset/-/babel-preset-0.83.1.tgz" + integrity sha512-xI+tbsD4fXcI6PVU4sauRCh0a5fuLQC849SINmU2J5wP8kzKu4Ye0YkGjUW3mfGrjaZcjkWmF6s33jpyd3gdTw== + dependencies: + "@babel/core" "^7.25.2" + "@babel/plugin-proposal-export-default-from" "^7.24.7" + "@babel/plugin-syntax-dynamic-import" "^7.8.3" + "@babel/plugin-syntax-export-default-from" "^7.24.7" + "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3" + "@babel/plugin-syntax-optional-chaining" "^7.8.3" + "@babel/plugin-transform-arrow-functions" "^7.24.7" + "@babel/plugin-transform-async-generator-functions" "^7.25.4" + "@babel/plugin-transform-async-to-generator" "^7.24.7" + "@babel/plugin-transform-block-scoping" "^7.25.0" + "@babel/plugin-transform-class-properties" "^7.25.4" + "@babel/plugin-transform-classes" "^7.25.4" + "@babel/plugin-transform-computed-properties" "^7.24.7" + "@babel/plugin-transform-destructuring" "^7.24.8" + "@babel/plugin-transform-flow-strip-types" "^7.25.2" + "@babel/plugin-transform-for-of" "^7.24.7" + "@babel/plugin-transform-function-name" "^7.25.1" + "@babel/plugin-transform-literals" "^7.25.2" + "@babel/plugin-transform-logical-assignment-operators" "^7.24.7" + "@babel/plugin-transform-modules-commonjs" "^7.24.8" + "@babel/plugin-transform-named-capturing-groups-regex" "^7.24.7" + "@babel/plugin-transform-nullish-coalescing-operator" "^7.24.7" + "@babel/plugin-transform-numeric-separator" "^7.24.7" + "@babel/plugin-transform-object-rest-spread" "^7.24.7" + "@babel/plugin-transform-optional-catch-binding" "^7.24.7" + "@babel/plugin-transform-optional-chaining" "^7.24.8" + "@babel/plugin-transform-parameters" "^7.24.7" + "@babel/plugin-transform-private-methods" "^7.24.7" + "@babel/plugin-transform-private-property-in-object" "^7.24.7" + "@babel/plugin-transform-react-display-name" "^7.24.7" + "@babel/plugin-transform-react-jsx" "^7.25.2" + "@babel/plugin-transform-react-jsx-self" "^7.24.7" + "@babel/plugin-transform-react-jsx-source" "^7.24.7" + "@babel/plugin-transform-regenerator" "^7.24.7" + "@babel/plugin-transform-runtime" "^7.24.7" + "@babel/plugin-transform-shorthand-properties" "^7.24.7" + "@babel/plugin-transform-spread" "^7.24.7" + "@babel/plugin-transform-sticky-regex" "^7.24.7" + "@babel/plugin-transform-typescript" "^7.25.2" + "@babel/plugin-transform-unicode-regex" "^7.24.7" + "@babel/template" "^7.25.0" + "@react-native/babel-plugin-codegen" "0.83.1" + babel-plugin-syntax-hermes-parser "0.32.0" + babel-plugin-transform-flow-enums "^0.0.2" + react-refresh "^0.14.0" + +"@react-native/codegen@0.83.1": + version "0.83.1" + resolved "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.83.1.tgz" + integrity sha512-FpRxenonwH+c2a5X5DZMKUD7sCudHxB3eSQPgV9R+uxd28QWslyAWrpnJM/Az96AEksHnymDzEmzq2HLX5nb+g== + dependencies: + "@babel/core" "^7.25.2" + "@babel/parser" "^7.25.3" + glob "^7.1.1" + hermes-parser "0.32.0" + invariant "^2.2.4" + nullthrows "^1.1.1" + yargs "^17.6.2" + +"@react-native/community-cli-plugin@0.83.1": + version "0.83.1" + resolved "https://registry.npmjs.org/@react-native/community-cli-plugin/-/community-cli-plugin-0.83.1.tgz" + integrity sha512-FqR1ftydr08PYlRbrDF06eRiiiGOK/hNmz5husv19sK6iN5nHj1SMaCIVjkH/a5vryxEddyFhU6PzO/uf4kOHg== + dependencies: + "@react-native/dev-middleware" "0.83.1" + debug "^4.4.0" + invariant "^2.2.4" + metro "^0.83.3" + metro-config "^0.83.3" + metro-core "^0.83.3" + semver "^7.1.3" + +"@react-native/debugger-frontend@0.83.1": + version "0.83.1" + resolved "https://registry.npmjs.org/@react-native/debugger-frontend/-/debugger-frontend-0.83.1.tgz" + integrity sha512-01Rn3goubFvPjHXONooLmsW0FLxJDKIUJNOlOS0cPtmmTIx9YIjxhe/DxwHXGk7OnULd7yl3aYy7WlBsEd5Xmg== + +"@react-native/debugger-shell@0.83.1": + version "0.83.1" + resolved "https://registry.npmjs.org/@react-native/debugger-shell/-/debugger-shell-0.83.1.tgz" + integrity sha512-d+0w446Hxth5OP/cBHSSxOEpbj13p2zToUy6e5e3tTERNJ8ueGlW7iGwGTrSymNDgXXFjErX+dY4P4/3WokPIQ== + dependencies: + cross-spawn "^7.0.6" + fb-dotslash "0.5.8" + +"@react-native/dev-middleware@0.83.1": + version "0.83.1" + resolved "https://registry.npmjs.org/@react-native/dev-middleware/-/dev-middleware-0.83.1.tgz" + integrity sha512-QJaSfNRzj3Lp7MmlCRgSBlt1XZ38xaBNXypXAp/3H3OdFifnTZOeYOpFmcpjcXYnDqkxetuwZg8VL65SQhB8dg== + dependencies: + "@isaacs/ttlcache" "^1.4.1" + "@react-native/debugger-frontend" "0.83.1" + "@react-native/debugger-shell" "0.83.1" + chrome-launcher "^0.15.2" + chromium-edge-launcher "^0.2.0" + connect "^3.6.5" + debug "^4.4.0" + invariant "^2.2.4" + nullthrows "^1.1.1" + open "^7.0.3" + serve-static "^1.16.2" + ws "^7.5.10" + +"@react-native/eslint-config@0.83.1": + version "0.83.1" + resolved "https://registry.npmjs.org/@react-native/eslint-config/-/eslint-config-0.83.1.tgz" + integrity sha512-fo3DmFywzkpVZgIji9vR93kN7sSAY122ZIB7VcudgKlmD/YFxJ5Yi+ZNiWYl6aprLexxOWjROgHXNP0B0XaAng== + dependencies: + "@babel/core" "^7.25.2" + "@babel/eslint-parser" "^7.25.1" + "@react-native/eslint-plugin" "0.83.1" + "@typescript-eslint/eslint-plugin" "^8.36.0" + "@typescript-eslint/parser" "^8.36.0" + eslint-config-prettier "^8.5.0" + eslint-plugin-eslint-comments "^3.2.0" + eslint-plugin-ft-flow "^2.0.1" + eslint-plugin-jest "^29.0.1" + eslint-plugin-react "^7.30.1" + eslint-plugin-react-hooks "^7.0.1" + eslint-plugin-react-native "^4.0.0" + +"@react-native/eslint-plugin@0.83.1": + version "0.83.1" + resolved "https://registry.npmjs.org/@react-native/eslint-plugin/-/eslint-plugin-0.83.1.tgz" + integrity sha512-nKd/FONY8aIIjtjEqI2ScvgJYeblBgdnwseRHlIC+Nm3f3tuOifUrHFtWBJznlrKFJcme31Tl7qiryE2SruLYw== + +"@react-native/gradle-plugin@0.83.1": + version "0.83.1" + resolved "https://registry.npmjs.org/@react-native/gradle-plugin/-/gradle-plugin-0.83.1.tgz" + integrity sha512-6ESDnwevp1CdvvxHNgXluil5OkqbjkJAkVy7SlpFsMGmVhrSxNAgD09SSRxMNdKsnLtzIvMsFCzyHLsU/S4PtQ== + +"@react-native/js-polyfills@0.83.1": + version "0.83.1" + resolved "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.83.1.tgz" + integrity sha512-qgPpdWn/c5laA+3WoJ6Fak8uOm7CG50nBsLlPsF8kbT7rUHIVB9WaP6+GPsoKV/H15koW7jKuLRoNVT7c3Ht3w== + +"@react-native/metro-babel-transformer@0.83.1": + version "0.83.1" + resolved "https://registry.npmjs.org/@react-native/metro-babel-transformer/-/metro-babel-transformer-0.83.1.tgz" + integrity sha512-fqt6DHWX1GBGDKa5WJOjDtPPy2M9lkYVLn59fBeFQ0GXhBRzNbUh8JzWWI/Q2CLDZ2tgKCcwaiXJ1OHWVd2BCQ== + dependencies: + "@babel/core" "^7.25.2" + "@react-native/babel-preset" "0.83.1" + hermes-parser "0.32.0" + nullthrows "^1.1.1" + +"@react-native/metro-config@*", "@react-native/metro-config@0.83.1": + version "0.83.1" + resolved "https://registry.npmjs.org/@react-native/metro-config/-/metro-config-0.83.1.tgz" + integrity sha512-1rjYZf62fCm6QAinHmRAKnJxIypX0VF/zBPd0qWvWABMZugrS0eACuIbk9Wk0StBod4yL8KnwEJyg77ak8xYzQ== + dependencies: + "@react-native/js-polyfills" "0.83.1" + "@react-native/metro-babel-transformer" "0.83.1" + metro-config "^0.83.3" + metro-runtime "^0.83.3" + +"@react-native/normalize-colors@0.83.1": + version "0.83.1" + resolved "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.83.1.tgz" + integrity sha512-84feABbmeWo1kg81726UOlMKAhcQyFXYz2SjRKYkS78QmfhVDhJ2o/ps1VjhFfBz0i/scDwT1XNv9GwmRIghkg== + +"@react-native/typescript-config@0.83.1": + version "0.83.1" + resolved "https://registry.npmjs.org/@react-native/typescript-config/-/typescript-config-0.83.1.tgz" + integrity sha512-y83qd7fmlZG+EJoOyKEmAXifdjN1csNhcfpyxDvgaIUNO/pw2ws3MV/wp+ERQ8F6JIuAu1zcfyCy1/pEA7tC9g== + +"@react-native/virtualized-lists@0.83.1": + version "0.83.1" + resolved "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.83.1.tgz" + integrity sha512-MdmoAbQUTOdicCocm5XAFDJWsswxk7hxa6ALnm6Y88p01HFML0W593hAn6qOt9q6IM1KbAcebtH6oOd4gcQy8w== + dependencies: + invariant "^2.2.4" + nullthrows "^1.1.1" + +"@react-navigation/bottom-tabs@^7.8.11": + version "7.10.0" + resolved "https://registry.npmjs.org/@react-navigation/bottom-tabs/-/bottom-tabs-7.10.0.tgz" + integrity sha512-4YPB3cAtt5hwNnR3cpU4c85g1CXd8BJ9Eop1D/hls0zC2rAwbFrTk/jMCSxCvXJzDrYam0cgvcN+jk03jLmkog== + dependencies: + "@react-navigation/elements" "^2.9.5" + color "^4.2.3" + sf-symbols-typescript "^2.1.0" + +"@react-navigation/core@^7.14.0": + version "7.14.0" + resolved "https://registry.npmjs.org/@react-navigation/core/-/core-7.14.0.tgz" + integrity sha512-tMpzskBzVp0E7CRNdNtJIdXjk54Kwe/TF9ViXAef+YFM1kSfGv4e/B2ozfXE+YyYgmh4WavTv8fkdJz1CNyu+g== + dependencies: + "@react-navigation/routers" "^7.5.3" + escape-string-regexp "^4.0.0" + fast-deep-equal "^3.1.3" + nanoid "^3.3.11" + query-string "^7.1.3" + react-is "^19.1.0" + use-latest-callback "^0.2.4" + use-sync-external-store "^1.5.0" + +"@react-navigation/elements@^2.9.5": + version "2.9.5" + resolved "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.9.5.tgz" + integrity sha512-iHZU8rRN1014Upz73AqNVXDvSMZDh5/ktQ1CMe21rdgnOY79RWtHHBp9qOS3VtqlUVYGkuX5GEw5mDt4tKdl0g== + dependencies: + color "^4.2.3" + use-latest-callback "^0.2.4" + use-sync-external-store "^1.5.0" + +"@react-navigation/native-stack@^7.8.5": + version "7.10.0" + resolved "https://registry.npmjs.org/@react-navigation/native-stack/-/native-stack-7.10.0.tgz" + integrity sha512-kFoQa3qaDKEHLwI95rIhri51DwN/d2Yin/K5T2VhSuL/2vZQjdR//U+Y6MfYUj2PrGJu7pM57RM3elTlvzyPqQ== + dependencies: + "@react-navigation/elements" "^2.9.5" + color "^4.2.3" + sf-symbols-typescript "^2.1.0" + warn-once "^0.1.1" + +"@react-navigation/native@^7.1.24", "@react-navigation/native@^7.1.28": + version "7.1.28" + resolved "https://registry.npmjs.org/@react-navigation/native/-/native-7.1.28.tgz" + integrity sha512-d1QDn+KNHfHGt3UIwOZvupvdsDdiHYZBEj7+wL2yDVo3tMezamYy60H9s3EnNVE1Ae1ty0trc7F2OKqo/RmsdQ== + dependencies: + "@react-navigation/core" "^7.14.0" + escape-string-regexp "^4.0.0" + fast-deep-equal "^3.1.3" + nanoid "^3.3.11" + use-latest-callback "^0.2.4" + +"@react-navigation/routers@^7.5.3": + version "7.5.3" + resolved "https://registry.npmjs.org/@react-navigation/routers/-/routers-7.5.3.tgz" + integrity sha512-1tJHg4KKRJuQ1/EvJxatrMef3NZXEPzwUIUZ3n1yJ2t7Q97siwRtbynRpQG9/69ebbtiZ8W3ScOZF/OmhvM4Rg== + dependencies: + nanoid "^3.3.11" + +"@runanywhere/core@file:../../../sdk/runanywhere-react-native/packages/core": + version "0.17.6" + resolved "file:../../../sdk/runanywhere-react-native/packages/core" + +"@runanywhere/llamacpp@file:../../../sdk/runanywhere-react-native/packages/llamacpp": + version "0.17.6" + resolved "file:../../../sdk/runanywhere-react-native/packages/llamacpp" + +"@runanywhere/onnx@file:../../../sdk/runanywhere-react-native/packages/onnx": + version "0.17.6" + resolved "file:../../../sdk/runanywhere-react-native/packages/onnx" + +"@sideway/address@^4.1.5": + version "4.1.5" + resolved "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz" + integrity sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q== + dependencies: + "@hapi/hoek" "^9.0.0" + +"@sideway/formula@^3.0.1": + version "3.0.1" + resolved "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz" + integrity sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg== + +"@sideway/pinpoint@^2.0.0": + version "2.0.0" + resolved "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz" + integrity sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ== + +"@sinclair/typebox@^0.27.8": + version "0.27.8" + resolved "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz" + integrity sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA== + +"@sinonjs/commons@^3.0.0": + version "3.0.1" + resolved "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz" + integrity sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ== + dependencies: + type-detect "4.0.8" + +"@sinonjs/fake-timers@^10.0.2": + version "10.3.0" + resolved "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz" + integrity sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA== + dependencies: + "@sinonjs/commons" "^3.0.0" + +"@tybys/wasm-util@^0.10.1": + version "0.10.1" + resolved "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz" + integrity sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg== + dependencies: + tslib "^2.4.0" + +"@types/babel__core@^7.1.14": + version "7.20.5" + resolved "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz" + integrity sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA== + dependencies: + "@babel/parser" "^7.20.7" + "@babel/types" "^7.20.7" + "@types/babel__generator" "*" + "@types/babel__template" "*" + "@types/babel__traverse" "*" + +"@types/babel__generator@*": + version "7.27.0" + resolved "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz" + integrity sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg== + dependencies: + "@babel/types" "^7.0.0" + +"@types/babel__template@*": + version "7.4.4" + resolved "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz" + integrity sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A== + dependencies: + "@babel/parser" "^7.1.0" + "@babel/types" "^7.0.0" + +"@types/babel__traverse@*", "@types/babel__traverse@^7.0.6": + version "7.28.0" + resolved "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz" + integrity sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q== + dependencies: + "@babel/types" "^7.28.2" + +"@types/graceful-fs@^4.1.3": + version "4.1.9" + resolved "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz" + integrity sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ== + dependencies: + "@types/node" "*" + +"@types/hammerjs@^2.0.36": + version "2.0.46" + resolved "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz" + integrity sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw== + +"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0": + version "2.0.6" + resolved "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz" + integrity sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w== + +"@types/istanbul-lib-report@*": + version "3.0.3" + resolved "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz" + integrity sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA== + dependencies: + "@types/istanbul-lib-coverage" "*" + +"@types/istanbul-reports@^3.0.0": + version "3.0.4" + resolved "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz" + integrity sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ== + dependencies: + "@types/istanbul-lib-report" "*" + +"@types/node@*", "@types/node@>=18": + version "25.0.9" + resolved "https://registry.npmjs.org/@types/node/-/node-25.0.9.tgz" + integrity sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw== + dependencies: + undici-types "~7.16.0" + +"@types/react-native-vector-icons@^6.4.18": + version "6.4.18" + resolved "https://registry.npmjs.org/@types/react-native-vector-icons/-/react-native-vector-icons-6.4.18.tgz" + integrity sha512-YGlNWb+k5laTBHd7+uZowB9DpIK3SXUneZqAiKQaj1jnJCZM0x71GDim5JCTMi4IFkhc9m8H/Gm28T5BjyivUw== + dependencies: + "@types/react" "*" + "@types/react-native" "^0.70" + +"@types/react-native@^0.70": + version "0.70.19" + resolved "https://registry.npmjs.org/@types/react-native/-/react-native-0.70.19.tgz" + integrity sha512-c6WbyCgWTBgKKMESj/8b4w+zWcZSsCforson7UdXtXMecG3MxCinYi6ihhrHVPyUrVzORsvEzK8zg32z4pK6Sg== + dependencies: + "@types/react" "*" + +"@types/react@*", "@types/react@^19.1.1", "@types/react@^19.2.0", "@types/react@>=18.0.0", "@types/react@~19.1.0": + version "19.1.17" + resolved "https://registry.npmjs.org/@types/react/-/react-19.1.17.tgz" + integrity sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA== + dependencies: + csstype "^3.0.2" + +"@types/stack-utils@^2.0.0": + version "2.0.3" + resolved "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz" + integrity sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw== + +"@types/yargs-parser@*": + version "21.0.3" + resolved "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz" + integrity sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ== + +"@types/yargs@^17.0.8": + version "17.0.35" + resolved "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz" + integrity sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg== + dependencies: + "@types/yargs-parser" "*" + +"@typescript-eslint/eslint-plugin@^7.18.0", "@typescript-eslint/eslint-plugin@^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0": + version "7.18.0" + resolved "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz" + integrity sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw== + dependencies: + "@eslint-community/regexpp" "^4.10.0" + "@typescript-eslint/scope-manager" "7.18.0" + "@typescript-eslint/type-utils" "7.18.0" + "@typescript-eslint/utils" "7.18.0" + "@typescript-eslint/visitor-keys" "7.18.0" + graphemer "^1.4.0" + ignore "^5.3.1" + natural-compare "^1.4.0" + ts-api-utils "^1.3.0" + +"@typescript-eslint/eslint-plugin@^8.0.0", "@typescript-eslint/eslint-plugin@^8.36.0": + version "8.53.0" + resolved "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.53.0.tgz" + integrity sha512-eEXsVvLPu8Z4PkFibtuFJLJOTAV/nPdgtSjkGoPpddpFk3/ym2oy97jynY6ic2m6+nc5M8SE1e9v/mHKsulcJg== + dependencies: + "@eslint-community/regexpp" "^4.12.2" + "@typescript-eslint/scope-manager" "8.53.0" + "@typescript-eslint/type-utils" "8.53.0" + "@typescript-eslint/utils" "8.53.0" + "@typescript-eslint/visitor-keys" "8.53.0" + ignore "^7.0.5" + natural-compare "^1.4.0" + ts-api-utils "^2.4.0" + +"@typescript-eslint/parser@^7.0.0", "@typescript-eslint/parser@^7.18.0": + version "7.18.0" + resolved "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.18.0.tgz" + integrity sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg== + dependencies: + "@typescript-eslint/scope-manager" "7.18.0" + "@typescript-eslint/types" "7.18.0" + "@typescript-eslint/typescript-estree" "7.18.0" + "@typescript-eslint/visitor-keys" "7.18.0" + debug "^4.3.4" + +"@typescript-eslint/parser@^8.36.0", "@typescript-eslint/parser@^8.53.0": + version "8.53.0" + resolved "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.53.0.tgz" + integrity sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg== + dependencies: + "@typescript-eslint/scope-manager" "8.53.0" + "@typescript-eslint/types" "8.53.0" + "@typescript-eslint/typescript-estree" "8.53.0" + "@typescript-eslint/visitor-keys" "8.53.0" + debug "^4.4.3" + +"@typescript-eslint/project-service@8.53.0": + version "8.53.0" + resolved "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.53.0.tgz" + integrity sha512-Bl6Gdr7NqkqIP5yP9z1JU///Nmes4Eose6L1HwpuVHwScgDPPuEWbUVhvlZmb8hy0vX9syLk5EGNL700WcBlbg== + dependencies: + "@typescript-eslint/tsconfig-utils" "^8.53.0" + "@typescript-eslint/types" "^8.53.0" + debug "^4.4.3" + +"@typescript-eslint/scope-manager@7.18.0": + version "7.18.0" + resolved "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.18.0.tgz" + integrity sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA== + dependencies: + "@typescript-eslint/types" "7.18.0" + "@typescript-eslint/visitor-keys" "7.18.0" + +"@typescript-eslint/scope-manager@8.53.0": + version "8.53.0" + resolved "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.53.0.tgz" + integrity sha512-kWNj3l01eOGSdVBnfAF2K1BTh06WS0Yet6JUgb9Cmkqaz3Jlu0fdVUjj9UI8gPidBWSMqDIglmEXifSgDT/D0g== + dependencies: + "@typescript-eslint/types" "8.53.0" + "@typescript-eslint/visitor-keys" "8.53.0" + +"@typescript-eslint/tsconfig-utils@^8.53.0", "@typescript-eslint/tsconfig-utils@8.53.0": + version "8.53.0" + resolved "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.53.0.tgz" + integrity sha512-K6Sc0R5GIG6dNoPdOooQ+KtvT5KCKAvTcY8h2rIuul19vxH5OTQk7ArKkd4yTzkw66WnNY0kPPzzcmWA+XRmiA== + +"@typescript-eslint/type-utils@7.18.0": + version "7.18.0" + resolved "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.18.0.tgz" + integrity sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA== + dependencies: + "@typescript-eslint/typescript-estree" "7.18.0" + "@typescript-eslint/utils" "7.18.0" + debug "^4.3.4" + ts-api-utils "^1.3.0" + +"@typescript-eslint/type-utils@8.53.0": + version "8.53.0" + resolved "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.53.0.tgz" + integrity sha512-BBAUhlx7g4SmcLhn8cnbxoxtmS7hcq39xKCgiutL3oNx1TaIp+cny51s8ewnKMpVUKQUGb41RAUWZ9kxYdovuw== + dependencies: + "@typescript-eslint/types" "8.53.0" + "@typescript-eslint/typescript-estree" "8.53.0" + "@typescript-eslint/utils" "8.53.0" + debug "^4.4.3" + ts-api-utils "^2.4.0" + +"@typescript-eslint/types@^8.53.0": + version "8.53.0" + resolved "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.53.0.tgz" + integrity sha512-Bmh9KX31Vlxa13+PqPvt4RzKRN1XORYSLlAE+sO1i28NkisGbTtSLFVB3l7PWdHtR3E0mVMuC7JilWJ99m2HxQ== + +"@typescript-eslint/types@7.18.0": + version "7.18.0" + resolved "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.18.0.tgz" + integrity sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ== + +"@typescript-eslint/types@8.53.0": + version "8.53.0" + resolved "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.53.0.tgz" + integrity sha512-Bmh9KX31Vlxa13+PqPvt4RzKRN1XORYSLlAE+sO1i28NkisGbTtSLFVB3l7PWdHtR3E0mVMuC7JilWJ99m2HxQ== + +"@typescript-eslint/typescript-estree@7.18.0": + version "7.18.0" + resolved "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.18.0.tgz" + integrity sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA== + dependencies: + "@typescript-eslint/types" "7.18.0" + "@typescript-eslint/visitor-keys" "7.18.0" + debug "^4.3.4" + globby "^11.1.0" + is-glob "^4.0.3" + minimatch "^9.0.4" + semver "^7.6.0" + ts-api-utils "^1.3.0" + +"@typescript-eslint/typescript-estree@8.53.0": + version "8.53.0" + resolved "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.53.0.tgz" + integrity sha512-pw0c0Gdo7Z4xOG987u3nJ8akL9093yEEKv8QTJ+Bhkghj1xyj8cgPaavlr9rq8h7+s6plUJ4QJYw2gCZodqmGw== + dependencies: + "@typescript-eslint/project-service" "8.53.0" + "@typescript-eslint/tsconfig-utils" "8.53.0" + "@typescript-eslint/types" "8.53.0" + "@typescript-eslint/visitor-keys" "8.53.0" + debug "^4.4.3" + minimatch "^9.0.5" + semver "^7.7.3" + tinyglobby "^0.2.15" + ts-api-utils "^2.4.0" + +"@typescript-eslint/utils@^8.0.0", "@typescript-eslint/utils@8.53.0": + version "8.53.0" + resolved "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.53.0.tgz" + integrity sha512-XDY4mXTez3Z1iRDI5mbRhH4DFSt46oaIFsLg+Zn97+sYrXACziXSQcSelMybnVZ5pa1P6xYkPr5cMJyunM1ZDA== + dependencies: + "@eslint-community/eslint-utils" "^4.9.1" + "@typescript-eslint/scope-manager" "8.53.0" + "@typescript-eslint/types" "8.53.0" + "@typescript-eslint/typescript-estree" "8.53.0" + +"@typescript-eslint/utils@7.18.0": + version "7.18.0" + resolved "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.18.0.tgz" + integrity sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw== + dependencies: + "@eslint-community/eslint-utils" "^4.4.0" + "@typescript-eslint/scope-manager" "7.18.0" + "@typescript-eslint/types" "7.18.0" + "@typescript-eslint/typescript-estree" "7.18.0" + +"@typescript-eslint/visitor-keys@7.18.0": + version "7.18.0" + resolved "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.18.0.tgz" + integrity sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg== + dependencies: + "@typescript-eslint/types" "7.18.0" + eslint-visitor-keys "^3.4.3" + +"@typescript-eslint/visitor-keys@8.53.0": + version "8.53.0" + resolved "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.53.0.tgz" + integrity sha512-LZ2NqIHFhvFwxG0qZeLL9DvdNAHPGCY5dIRwBhyYeU+LfLhcStE1ImjsuTG/WaVh3XysGaeLW8Rqq7cGkPCFvw== + dependencies: + "@typescript-eslint/types" "8.53.0" + eslint-visitor-keys "^4.2.1" + +"@ungap/structured-clone@^1.2.0": + version "1.3.0" + resolved "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz" + integrity sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g== + +"@vscode/sudo-prompt@^9.0.0": + version "9.3.2" + resolved "https://registry.npmjs.org/@vscode/sudo-prompt/-/sudo-prompt-9.3.2.tgz" + integrity sha512-gcXoCN00METUNFeQOFJ+C9xUI0DKB+0EGMVg7wbVYRHBw2Eq3fKisDZOkRdOz3kqXRKOENMfShPOmypw1/8nOw== + +abort-controller@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz" + integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg== + dependencies: + event-target-shim "^5.0.0" + +accepts@^1.3.7, accepts@~1.3.8: + version "1.3.8" + resolved "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz" + integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw== + dependencies: + mime-types "~2.1.34" + negotiator "0.6.3" + +acorn-jsx@^5.3.2: + version "5.3.2" + resolved "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz" + integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== + +"acorn@^6.0.0 || ^7.0.0 || ^8.0.0", acorn@^8.15.0, acorn@^8.9.0: + version "8.15.0" + resolved "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz" + integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg== + +agent-base@^7.1.2: + version "7.1.4" + resolved "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz" + integrity sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ== + +ajv@^6.12.4: + version "6.12.6" + resolved "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz" + integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== + dependencies: + fast-deep-equal "^3.1.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + +anser@^1.4.9: + version "1.4.10" + resolved "https://registry.npmjs.org/anser/-/anser-1.4.10.tgz" + integrity sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww== + +ansi-fragments@^0.2.1: + version "0.2.1" + resolved "https://registry.npmjs.org/ansi-fragments/-/ansi-fragments-0.2.1.tgz" + integrity sha512-DykbNHxuXQwUDRv5ibc2b0x7uw7wmwOGLBUd5RmaQ5z8Lhx19vwvKV+FAsM5rEA6dEcHxX+/Ad5s9eF2k2bB+w== + dependencies: + colorette "^1.0.7" + slice-ansi "^2.0.0" + strip-ansi "^5.0.0" + +ansi-regex@^4.1.0: + version "4.1.1" + resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz" + integrity sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g== + +ansi-regex@^5.0.0, ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== + +ansi-styles@^3.2.0: + version "3.2.1" + resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz" + integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== + dependencies: + color-convert "^1.9.0" + +ansi-styles@^4.0.0, ansi-styles@^4.1.0: + version "4.3.0" + resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== + dependencies: + color-convert "^2.0.1" + +ansi-styles@^5.0.0: + version "5.2.0" + resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz" + integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== + +anymatch@^3.0.3: + version "3.1.3" + resolved "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz" + integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== + dependencies: + normalize-path "^3.0.0" + picomatch "^2.0.4" + +appdirsjs@^1.2.4: + version "1.2.7" + resolved "https://registry.npmjs.org/appdirsjs/-/appdirsjs-1.2.7.tgz" + integrity sha512-Quji6+8kLBC3NnBeo14nPDq0+2jUs5s3/xEye+udFHumHhRk4M7aAMXp/PBJqkKYGuuyR9M/6Dq7d2AViiGmhw== + +argparse@^1.0.7: + version "1.0.10" + resolved "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz" + integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== + dependencies: + sprintf-js "~1.0.2" + +argparse@^2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz" + integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== + +array-buffer-byte-length@^1.0.1, array-buffer-byte-length@^1.0.2: + version "1.0.2" + resolved "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz" + integrity sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw== + dependencies: + call-bound "^1.0.3" + is-array-buffer "^3.0.5" + +array-includes@^3.1.6, array-includes@^3.1.8: + version "3.1.9" + resolved "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz" + integrity sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.4" + define-properties "^1.2.1" + es-abstract "^1.24.0" + es-object-atoms "^1.1.1" + get-intrinsic "^1.3.0" + is-string "^1.1.1" + math-intrinsics "^1.1.0" + +array-union@^2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz" + integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw== + +array.prototype.findlast@^1.2.5: + version "1.2.5" + resolved "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz" + integrity sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-abstract "^1.23.2" + es-errors "^1.3.0" + es-object-atoms "^1.0.0" + es-shim-unscopables "^1.0.2" + +array.prototype.flat@^1.3.1: + version "1.3.3" + resolved "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz" + integrity sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg== + dependencies: + call-bind "^1.0.8" + define-properties "^1.2.1" + es-abstract "^1.23.5" + es-shim-unscopables "^1.0.2" + +array.prototype.flatmap@^1.3.3: + version "1.3.3" + resolved "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz" + integrity sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg== + dependencies: + call-bind "^1.0.8" + define-properties "^1.2.1" + es-abstract "^1.23.5" + es-shim-unscopables "^1.0.2" + +array.prototype.tosorted@^1.1.4: + version "1.1.4" + resolved "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz" + integrity sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-abstract "^1.23.3" + es-errors "^1.3.0" + es-shim-unscopables "^1.0.2" + +arraybuffer.prototype.slice@^1.0.4: + version "1.0.4" + resolved "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz" + integrity sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ== + dependencies: + array-buffer-byte-length "^1.0.1" + call-bind "^1.0.8" + define-properties "^1.2.1" + es-abstract "^1.23.5" + es-errors "^1.3.0" + get-intrinsic "^1.2.6" + is-array-buffer "^3.0.4" + +asap@~2.0.6: + version "2.0.6" + resolved "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz" + integrity sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA== + +astral-regex@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/astral-regex/-/astral-regex-1.0.0.tgz" + integrity sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg== + +async-function@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz" + integrity sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA== + +async-limiter@~1.0.0: + version "1.0.1" + resolved "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz" + integrity sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ== + +available-typed-arrays@^1.0.7: + version "1.0.7" + resolved "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz" + integrity sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ== + dependencies: + possible-typed-array-names "^1.0.0" + +babel-jest@^29.7.0: + version "29.7.0" + resolved "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz" + integrity sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg== + dependencies: + "@jest/transform" "^29.7.0" + "@types/babel__core" "^7.1.14" + babel-plugin-istanbul "^6.1.1" + babel-preset-jest "^29.6.3" + chalk "^4.0.0" + graceful-fs "^4.2.9" + slash "^3.0.0" + +babel-plugin-istanbul@^6.1.1: + version "6.1.1" + resolved "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz" + integrity sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@istanbuljs/load-nyc-config" "^1.0.0" + "@istanbuljs/schema" "^0.1.2" + istanbul-lib-instrument "^5.0.4" + test-exclude "^6.0.0" + +babel-plugin-jest-hoist@^29.6.3: + version "29.6.3" + resolved "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz" + integrity sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg== + dependencies: + "@babel/template" "^7.3.3" + "@babel/types" "^7.3.3" + "@types/babel__core" "^7.1.14" + "@types/babel__traverse" "^7.0.6" + +babel-plugin-polyfill-corejs2@^0.4.14: + version "0.4.14" + resolved "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.14.tgz" + integrity sha512-Co2Y9wX854ts6U8gAAPXfn0GmAyctHuK8n0Yhfjd6t30g7yvKjspvvOo9yG+z52PZRgFErt7Ka2pYnXCjLKEpg== + dependencies: + "@babel/compat-data" "^7.27.7" + "@babel/helper-define-polyfill-provider" "^0.6.5" + semver "^6.3.1" + +babel-plugin-polyfill-corejs3@^0.13.0: + version "0.13.0" + resolved "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz" + integrity sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A== + dependencies: + "@babel/helper-define-polyfill-provider" "^0.6.5" + core-js-compat "^3.43.0" + +babel-plugin-polyfill-regenerator@^0.6.5: + version "0.6.5" + resolved "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.5.tgz" + integrity sha512-ISqQ2frbiNU9vIJkzg7dlPpznPZ4jOiUQ1uSmB0fEHeowtN3COYRsXr/xexn64NpU13P06jc/L5TgiJXOgrbEg== + dependencies: + "@babel/helper-define-polyfill-provider" "^0.6.5" + +babel-plugin-syntax-hermes-parser@0.32.0: + version "0.32.0" + resolved "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.32.0.tgz" + integrity sha512-m5HthL++AbyeEA2FcdwOLfVFvWYECOBObLHNqdR8ceY4TsEdn4LdX2oTvbB2QJSSElE2AWA/b2MXZ/PF/CqLZg== + dependencies: + hermes-parser "0.32.0" + +babel-plugin-transform-flow-enums@^0.0.2: + version "0.0.2" + resolved "https://registry.npmjs.org/babel-plugin-transform-flow-enums/-/babel-plugin-transform-flow-enums-0.0.2.tgz" + integrity sha512-g4aaCrDDOsWjbm0PUUeVnkcVd6AKJsVc/MbnPhEotEpkeJQP6b8nzewohQi7+QS8UyPehOhGWn0nOwjvWpmMvQ== + dependencies: + "@babel/plugin-syntax-flow" "^7.12.1" + +babel-preset-current-node-syntax@^1.0.0: + version "1.2.0" + resolved "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz" + integrity sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg== + dependencies: + "@babel/plugin-syntax-async-generators" "^7.8.4" + "@babel/plugin-syntax-bigint" "^7.8.3" + "@babel/plugin-syntax-class-properties" "^7.12.13" + "@babel/plugin-syntax-class-static-block" "^7.14.5" + "@babel/plugin-syntax-import-attributes" "^7.24.7" + "@babel/plugin-syntax-import-meta" "^7.10.4" + "@babel/plugin-syntax-json-strings" "^7.8.3" + "@babel/plugin-syntax-logical-assignment-operators" "^7.10.4" + "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3" + "@babel/plugin-syntax-numeric-separator" "^7.10.4" + "@babel/plugin-syntax-object-rest-spread" "^7.8.3" + "@babel/plugin-syntax-optional-catch-binding" "^7.8.3" + "@babel/plugin-syntax-optional-chaining" "^7.8.3" + "@babel/plugin-syntax-private-property-in-object" "^7.14.5" + "@babel/plugin-syntax-top-level-await" "^7.14.5" + +babel-preset-jest@^29.6.3: + version "29.6.3" + resolved "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz" + integrity sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA== + dependencies: + babel-plugin-jest-hoist "^29.6.3" + babel-preset-current-node-syntax "^1.0.0" + +balanced-match@^1.0.0: + version "1.0.2" + resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== + +base-64@^0.1.0, base-64@0.1.0: + version "0.1.0" + resolved "https://registry.npmjs.org/base-64/-/base-64-0.1.0.tgz" + integrity sha512-Y5gU45svrR5tI2Vt/X9GPd3L0HNIKzGu202EjxrXMpuc2V2CiKgemAbUUsqYmZJvPtCXoUKjNZwBJzsNScUbXA== + +base64-js@^1.3.1, base64-js@^1.5.1: + version "1.5.1" + resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz" + integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== + +baseline-browser-mapping@^2.9.0: + version "2.9.15" + resolved "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.15.tgz" + integrity sha512-kX8h7K2srmDyYnXRIppo4AH/wYgzWVCs+eKr3RusRSQ5PvRYoEFmR/I0PbdTjKFAoKqp5+kbxnNTFO9jOfSVJg== + +bl@^4.1.0: + version "4.1.0" + resolved "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz" + integrity sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w== + dependencies: + buffer "^5.5.0" + inherits "^2.0.4" + readable-stream "^3.4.0" + +body-parser@^1.20.3: + version "1.20.4" + resolved "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz" + integrity sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA== + dependencies: + bytes "~3.1.2" + content-type "~1.0.5" + debug "2.6.9" + depd "2.0.0" + destroy "~1.2.0" + http-errors "~2.0.1" + iconv-lite "~0.4.24" + on-finished "~2.4.1" + qs "~6.14.0" + raw-body "~2.5.3" + type-is "~1.6.18" + unpipe "~1.0.0" + +brace-expansion@^1.1.7: + version "1.1.12" + resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz" + integrity sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg== + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +brace-expansion@^2.0.1: + version "2.0.2" + resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz" + integrity sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ== + dependencies: + balanced-match "^1.0.0" + +braces@^3.0.3: + version "3.0.3" + resolved "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz" + integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== + dependencies: + fill-range "^7.1.1" + +browserslist@^4.24.0, browserslist@^4.28.0, "browserslist@>= 4.21.0": + version "4.28.1" + resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz" + integrity sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA== + dependencies: + baseline-browser-mapping "^2.9.0" + caniuse-lite "^1.0.30001759" + electron-to-chromium "^1.5.263" + node-releases "^2.0.27" + update-browserslist-db "^1.2.0" + +bser@2.1.1: + version "2.1.1" + resolved "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz" + integrity sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ== + dependencies: + node-int64 "^0.4.0" + +buffer-from@^1.0.0: + version "1.1.2" + resolved "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz" + integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== + +buffer@^5.5.0: + version "5.7.1" + resolved "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz" + integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== + dependencies: + base64-js "^1.3.1" + ieee754 "^1.1.13" + +bytes@~3.1.2, bytes@3.1.2: + version "3.1.2" + resolved "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz" + integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== + +call-bind-apply-helpers@^1.0.0, call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: + version "1.0.2" + resolved "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz" + integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ== + dependencies: + es-errors "^1.3.0" + function-bind "^1.1.2" + +call-bind@^1.0.7, call-bind@^1.0.8: + version "1.0.8" + resolved "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz" + integrity sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww== + dependencies: + call-bind-apply-helpers "^1.0.0" + es-define-property "^1.0.0" + get-intrinsic "^1.2.4" + set-function-length "^1.2.2" + +call-bound@^1.0.2, call-bound@^1.0.3, call-bound@^1.0.4: + version "1.0.4" + resolved "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz" + integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg== + dependencies: + call-bind-apply-helpers "^1.0.2" + get-intrinsic "^1.3.0" + +callsites@^3.0.0: + version "3.1.0" + resolved "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz" + integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== + +camelcase@^5.0.0: + version "5.3.1" + resolved "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz" + integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== + +camelcase@^5.3.1: + version "5.3.1" + resolved "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz" + integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== + +camelcase@^6.2.0: + version "6.3.0" + resolved "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz" + integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== + +caniuse-lite@^1.0.30001759: + version "1.0.30001765" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001765.tgz" + integrity sha512-LWcNtSyZrakjECqmpP4qdg0MMGdN368D7X8XvvAqOcqMv0RxnlqVKZl2V6/mBR68oYMxOZPLw/gO7DuisMHUvQ== + +chalk@^4.0.0, chalk@^4.1.0: + version "4.1.2" + resolved "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz" + integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +chrome-launcher@^0.15.2: + version "0.15.2" + resolved "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-0.15.2.tgz" + integrity sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ== + dependencies: + "@types/node" "*" + escape-string-regexp "^4.0.0" + is-wsl "^2.2.0" + lighthouse-logger "^1.0.0" + +chromium-edge-launcher@^0.2.0: + version "0.2.0" + resolved "https://registry.npmjs.org/chromium-edge-launcher/-/chromium-edge-launcher-0.2.0.tgz" + integrity sha512-JfJjUnq25y9yg4FABRRVPmBGWPZZi+AQXT4mxupb67766/0UlhG8PAZCz6xzEMXTbW3CsSoE8PcCWA49n35mKg== + dependencies: + "@types/node" "*" + escape-string-regexp "^4.0.0" + is-wsl "^2.2.0" + lighthouse-logger "^1.0.0" + mkdirp "^1.0.4" + rimraf "^3.0.2" + +ci-info@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz" + integrity sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ== + +ci-info@^3.2.0: + version "3.9.0" + resolved "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz" + integrity sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ== + +cli-cursor@^3.1.0: + version "3.1.0" + resolved "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz" + integrity sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw== + dependencies: + restore-cursor "^3.1.0" + +cli-spinners@^2.5.0: + version "2.9.2" + resolved "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz" + integrity sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg== + +cliui@^6.0.0: + version "6.0.0" + resolved "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz" + integrity sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.0" + wrap-ansi "^6.2.0" + +cliui@^7.0.2: + version "7.0.4" + resolved "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz" + integrity sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.0" + wrap-ansi "^7.0.0" + +cliui@^8.0.1: + version "8.0.1" + resolved "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz" + integrity sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.1" + wrap-ansi "^7.0.0" + +clone@^1.0.2: + version "1.0.4" + resolved "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz" + integrity sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg== + +color-convert@^1.9.0: + version "1.9.3" + resolved "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz" + integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== + dependencies: + color-name "1.1.3" + +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + +color-name@^1.0.0, color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +color-name@1.1.3: + version "1.1.3" + resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz" + integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== + +color-string@^1.9.0: + version "1.9.1" + resolved "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz" + integrity sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg== + dependencies: + color-name "^1.0.0" + simple-swizzle "^0.2.2" + +color@^4.2.3: + version "4.2.3" + resolved "https://registry.npmjs.org/color/-/color-4.2.3.tgz" + integrity sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A== + dependencies: + color-convert "^2.0.1" + color-string "^1.9.0" + +colorette@^1.0.7: + version "1.4.0" + resolved "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz" + integrity sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g== + +command-exists@^1.2.8: + version "1.2.9" + resolved "https://registry.npmjs.org/command-exists/-/command-exists-1.2.9.tgz" + integrity sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w== + +commander@^12.0.0: + version "12.1.0" + resolved "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz" + integrity sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA== + +commander@^2.20.0: + version "2.20.3" + resolved "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz" + integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== + +commander@^9.4.1: + version "9.5.0" + resolved "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz" + integrity sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ== + +compressible@~2.0.18: + version "2.0.18" + resolved "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz" + integrity sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg== + dependencies: + mime-db ">= 1.43.0 < 2" + +compression@^1.7.1: + version "1.8.1" + resolved "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz" + integrity sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w== + dependencies: + bytes "3.1.2" + compressible "~2.0.18" + debug "2.6.9" + negotiator "~0.6.4" + on-headers "~1.1.0" + safe-buffer "5.2.1" + vary "~1.1.2" + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz" + integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== + +connect@^3.6.5: + version "3.7.0" + resolved "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz" + integrity sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ== + dependencies: + debug "2.6.9" + finalhandler "1.1.2" + parseurl "~1.3.3" + utils-merge "1.0.1" + +content-type@~1.0.5: + version "1.0.5" + resolved "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz" + integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== + +convert-source-map@^2.0.0, convert-source-map@2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz" + integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== + +core-js-compat@^3.43.0: + version "3.47.0" + resolved "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.47.0.tgz" + integrity sha512-IGfuznZ/n7Kp9+nypamBhvwdwLsW6KC8IOaURw2doAK5e98AG3acVLdh0woOnEqCfUtS+Vu882JE4k/DAm3ItQ== + dependencies: + browserslist "^4.28.0" + +cosmiconfig@^9.0.0: + version "9.0.0" + resolved "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz" + integrity sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg== + dependencies: + env-paths "^2.2.1" + import-fresh "^3.3.0" + js-yaml "^4.1.0" + parse-json "^5.2.0" + +cross-spawn@^7.0.2, cross-spawn@^7.0.3, cross-spawn@^7.0.6: + version "7.0.6" + resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz" + integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + +csstype@^3.0.2: + version "3.2.3" + resolved "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz" + integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ== + +data-view-buffer@^1.0.2: + version "1.0.2" + resolved "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz" + integrity sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ== + dependencies: + call-bound "^1.0.3" + es-errors "^1.3.0" + is-data-view "^1.0.2" + +data-view-byte-length@^1.0.2: + version "1.0.2" + resolved "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz" + integrity sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ== + dependencies: + call-bound "^1.0.3" + es-errors "^1.3.0" + is-data-view "^1.0.2" + +data-view-byte-offset@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz" + integrity sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + is-data-view "^1.0.1" + +dayjs@^1.8.15: + version "1.11.19" + resolved "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz" + integrity sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw== + +debug@^2.6.9: + version "2.6.9" + resolved "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz" + integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== + dependencies: + ms "2.0.0" + +debug@^4.1.0, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.4.0, debug@^4.4.1, debug@^4.4.3, debug@4: + version "4.4.3" + resolved "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== + dependencies: + ms "^2.1.3" + +debug@2.6.9: + version "2.6.9" + resolved "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz" + integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== + dependencies: + ms "2.0.0" + +decamelize@^1.2.0: + version "1.2.0" + resolved "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz" + integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA== + +decode-uri-component@^0.2.2: + version "0.2.2" + resolved "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz" + integrity sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ== + +deep-is@^0.1.3: + version "0.1.4" + resolved "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz" + integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== + +deepmerge@^4.3.0: + version "4.3.1" + resolved "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz" + integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A== + +defaults@^1.0.3: + version "1.0.4" + resolved "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz" + integrity sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A== + dependencies: + clone "^1.0.2" + +define-data-property@^1.0.1, define-data-property@^1.1.4: + version "1.1.4" + resolved "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz" + integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A== + dependencies: + es-define-property "^1.0.0" + es-errors "^1.3.0" + gopd "^1.0.1" + +define-properties@^1.1.3, define-properties@^1.2.1: + version "1.2.1" + resolved "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz" + integrity sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg== + dependencies: + define-data-property "^1.0.1" + has-property-descriptors "^1.0.0" + object-keys "^1.1.1" + +depd@~2.0.0, depd@2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz" + integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== + +destroy@~1.2.0, destroy@1.2.0: + version "1.2.0" + resolved "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz" + integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== + +dir-glob@^3.0.1: + version "3.0.1" + resolved "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz" + integrity sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA== + dependencies: + path-type "^4.0.0" + +doctrine@^2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz" + integrity sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw== + dependencies: + esutils "^2.0.2" + +doctrine@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz" + integrity sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w== + dependencies: + esutils "^2.0.2" + +dunder-proto@^1.0.0, dunder-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz" + integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A== + dependencies: + call-bind-apply-helpers "^1.0.1" + es-errors "^1.3.0" + gopd "^1.2.0" + +ee-first@1.1.1: + version "1.1.1" + resolved "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz" + integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== + +electron-to-chromium@^1.5.263: + version "1.5.267" + resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz" + integrity sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw== + +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + +encodeurl@~1.0.2: + version "1.0.2" + resolved "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz" + integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w== + +encodeurl@~2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz" + integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg== + +env-paths@^2.2.1: + version "2.2.1" + resolved "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz" + integrity sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A== + +envinfo@^7.13.0: + version "7.21.0" + resolved "https://registry.npmjs.org/envinfo/-/envinfo-7.21.0.tgz" + integrity sha512-Lw7I8Zp5YKHFCXL7+Dz95g4CcbMEpgvqZNNq3AmlT5XAV6CgAAk6gyAMqn2zjw08K9BHfcNuKrMiCPLByGafow== + +error-ex@^1.3.1: + version "1.3.4" + resolved "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz" + integrity sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ== + dependencies: + is-arrayish "^0.2.1" + +error-stack-parser@^2.0.6: + version "2.1.4" + resolved "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz" + integrity sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ== + dependencies: + stackframe "^1.3.4" + +errorhandler@^1.5.1: + version "1.5.2" + resolved "https://registry.npmjs.org/errorhandler/-/errorhandler-1.5.2.tgz" + integrity sha512-kNAL7hESndBCrWwS72QyV3IVOTrVmj9D062FV5BQswNL5zEdeRmz/WJFyh6Aj/plvvSOrzddkxW57HgkZcR9Fw== + dependencies: + accepts "~1.3.8" + escape-html "~1.0.3" + +es-abstract@^1.17.5, es-abstract@^1.23.2, es-abstract@^1.23.3, es-abstract@^1.23.5, es-abstract@^1.23.6, es-abstract@^1.23.9, es-abstract@^1.24.0, es-abstract@^1.24.1: + version "1.24.1" + resolved "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz" + integrity sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw== + dependencies: + array-buffer-byte-length "^1.0.2" + arraybuffer.prototype.slice "^1.0.4" + available-typed-arrays "^1.0.7" + call-bind "^1.0.8" + call-bound "^1.0.4" + data-view-buffer "^1.0.2" + data-view-byte-length "^1.0.2" + data-view-byte-offset "^1.0.1" + es-define-property "^1.0.1" + es-errors "^1.3.0" + es-object-atoms "^1.1.1" + es-set-tostringtag "^2.1.0" + es-to-primitive "^1.3.0" + function.prototype.name "^1.1.8" + get-intrinsic "^1.3.0" + get-proto "^1.0.1" + get-symbol-description "^1.1.0" + globalthis "^1.0.4" + gopd "^1.2.0" + has-property-descriptors "^1.0.2" + has-proto "^1.2.0" + has-symbols "^1.1.0" + hasown "^2.0.2" + internal-slot "^1.1.0" + is-array-buffer "^3.0.5" + is-callable "^1.2.7" + is-data-view "^1.0.2" + is-negative-zero "^2.0.3" + is-regex "^1.2.1" + is-set "^2.0.3" + is-shared-array-buffer "^1.0.4" + is-string "^1.1.1" + is-typed-array "^1.1.15" + is-weakref "^1.1.1" + math-intrinsics "^1.1.0" + object-inspect "^1.13.4" + object-keys "^1.1.1" + object.assign "^4.1.7" + own-keys "^1.0.1" + regexp.prototype.flags "^1.5.4" + safe-array-concat "^1.1.3" + safe-push-apply "^1.0.0" + safe-regex-test "^1.1.0" + set-proto "^1.0.0" + stop-iteration-iterator "^1.1.0" + string.prototype.trim "^1.2.10" + string.prototype.trimend "^1.0.9" + string.prototype.trimstart "^1.0.8" + typed-array-buffer "^1.0.3" + typed-array-byte-length "^1.0.3" + typed-array-byte-offset "^1.0.4" + typed-array-length "^1.0.7" + unbox-primitive "^1.1.0" + which-typed-array "^1.1.19" + +es-define-property@^1.0.0, es-define-property@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz" + integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g== + +es-errors@^1.3.0: + version "1.3.0" + resolved "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz" + integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== + +es-iterator-helpers@^1.2.1: + version "1.2.2" + resolved "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.2.tgz" + integrity sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.4" + define-properties "^1.2.1" + es-abstract "^1.24.1" + es-errors "^1.3.0" + es-set-tostringtag "^2.1.0" + function-bind "^1.1.2" + get-intrinsic "^1.3.0" + globalthis "^1.0.4" + gopd "^1.2.0" + has-property-descriptors "^1.0.2" + has-proto "^1.2.0" + has-symbols "^1.1.0" + internal-slot "^1.1.0" + iterator.prototype "^1.1.5" + safe-array-concat "^1.1.3" + +es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: + version "1.1.1" + resolved "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz" + integrity sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA== + dependencies: + es-errors "^1.3.0" + +es-set-tostringtag@^2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz" + integrity sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA== + dependencies: + es-errors "^1.3.0" + get-intrinsic "^1.2.6" + has-tostringtag "^1.0.2" + hasown "^2.0.2" + +es-shim-unscopables@^1.0.2: + version "1.1.0" + resolved "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz" + integrity sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw== + dependencies: + hasown "^2.0.2" + +es-to-primitive@^1.3.0: + version "1.3.0" + resolved "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz" + integrity sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g== + dependencies: + is-callable "^1.2.7" + is-date-object "^1.0.5" + is-symbol "^1.0.4" + +escalade@^3.1.1, escalade@^3.2.0: + version "3.2.0" + resolved "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz" + integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== + +escape-html@~1.0.3: + version "1.0.3" + resolved "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz" + integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== + +escape-string-regexp@^1.0.5: + version "1.0.5" + resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz" + integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg== + +escape-string-regexp@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz" + integrity sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w== + +escape-string-regexp@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz" + integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== + +escape-string-regexp@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz" + integrity sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw== + +eslint-config-prettier@^8.5.0: + version "8.10.2" + resolved "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.10.2.tgz" + integrity sha512-/IGJ6+Dka158JnP5n5YFMOszjDWrXggGz1LaK/guZq9vZTmniaKlHcsscvkAhn9y4U+BU3JuUdYvtAMcv30y4A== + +eslint-config-prettier@^9.0.0, "eslint-config-prettier@>= 7.0.0 <10.0.0 || >=10.1.0": + version "9.1.2" + resolved "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.2.tgz" + integrity sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ== + +eslint-plugin-eslint-comments@^3.2.0: + version "3.2.0" + resolved "https://registry.npmjs.org/eslint-plugin-eslint-comments/-/eslint-plugin-eslint-comments-3.2.0.tgz" + integrity sha512-0jkOl0hfojIHHmEHgmNdqv4fmh7300NdpA9FFpF7zaoLvB/QeXOGNLIo86oAveJFrfB1p05kC8hpEMHM8DwWVQ== + dependencies: + escape-string-regexp "^1.0.5" + ignore "^5.0.5" + +eslint-plugin-ft-flow@^2.0.1: + version "2.0.3" + resolved "https://registry.npmjs.org/eslint-plugin-ft-flow/-/eslint-plugin-ft-flow-2.0.3.tgz" + integrity sha512-Vbsd/b+LYA99jUbsL6viEUWShFaYQt2YQs3QN3f+aeszOhh2sgdcU0mjzDyD4yyBvMc8qy2uwvBBWfMzEX06tg== + dependencies: + lodash "^4.17.21" + string-natural-compare "^3.0.1" + +eslint-plugin-jest@^29.0.1: + version "29.12.1" + resolved "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-29.12.1.tgz" + integrity sha512-Rxo7r4jSANMBkXLICJKS0gjacgyopfNAsoS0e3R9AHnjoKuQOaaPfmsDJPi8UWwygI099OV/K/JhpYRVkxD4AA== + dependencies: + "@typescript-eslint/utils" "^8.0.0" + +eslint-plugin-prettier@^5.0.1: + version "5.5.5" + resolved "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz" + integrity sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw== + dependencies: + prettier-linter-helpers "^1.0.1" + synckit "^0.11.12" + +eslint-plugin-react-hooks@^7.0.1: + version "7.0.1" + resolved "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz" + integrity sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA== + dependencies: + "@babel/core" "^7.24.4" + "@babel/parser" "^7.24.4" + hermes-parser "^0.25.1" + zod "^3.25.0 || ^4.0.0" + zod-validation-error "^3.5.0 || ^4.0.0" + +eslint-plugin-react-native-globals@^0.1.1: + version "0.1.2" + resolved "https://registry.npmjs.org/eslint-plugin-react-native-globals/-/eslint-plugin-react-native-globals-0.1.2.tgz" + integrity sha512-9aEPf1JEpiTjcFAmmyw8eiIXmcNZOqaZyHO77wgm0/dWfT/oxC1SrIq8ET38pMxHYrcB6Uew+TzUVsBeczF88g== + +eslint-plugin-react-native@^4.0.0: + version "4.1.0" + resolved "https://registry.npmjs.org/eslint-plugin-react-native/-/eslint-plugin-react-native-4.1.0.tgz" + integrity sha512-QLo7rzTBOl43FvVqDdq5Ql9IoElIuTdjrz9SKAXCvULvBoRZ44JGSkx9z4999ZusCsb4rK3gjS8gOGyeYqZv2Q== + dependencies: + eslint-plugin-react-native-globals "^0.1.1" + +eslint-plugin-react@^7.30.1: + version "7.37.5" + resolved "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz" + integrity sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA== + dependencies: + array-includes "^3.1.8" + array.prototype.findlast "^1.2.5" + array.prototype.flatmap "^1.3.3" + array.prototype.tosorted "^1.1.4" + doctrine "^2.1.0" + es-iterator-helpers "^1.2.1" + estraverse "^5.3.0" + hasown "^2.0.2" + jsx-ast-utils "^2.4.1 || ^3.0.0" + minimatch "^3.1.2" + object.entries "^1.1.9" + object.fromentries "^2.0.8" + object.values "^1.2.1" + prop-types "^15.8.1" + resolve "^2.0.0-next.5" + semver "^6.3.1" + string.prototype.matchall "^4.0.12" + string.prototype.repeat "^1.0.0" + +eslint-plugin-unused-imports@^4.3.0: + version "4.3.0" + resolved "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.3.0.tgz" + integrity sha512-ZFBmXMGBYfHttdRtOG9nFFpmUvMtbHSjsKrS20vdWdbfiVYsO3yA2SGYy9i9XmZJDfMGBflZGBCm70SEnFQtOA== + +eslint-scope@^7.2.2: + version "7.2.2" + resolved "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz" + integrity sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg== + dependencies: + esrecurse "^4.3.0" + estraverse "^5.2.0" + +eslint-scope@5.1.1: + version "5.1.1" + resolved "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz" + integrity sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw== + dependencies: + esrecurse "^4.3.0" + estraverse "^4.1.1" + +eslint-visitor-keys@^2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz" + integrity sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw== + +eslint-visitor-keys@^3.4.1: + version "3.4.3" + resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz" + integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== + +eslint-visitor-keys@^3.4.3: + version "3.4.3" + resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz" + integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== + +eslint-visitor-keys@^4.2.1: + version "4.2.1" + resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz" + integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ== + +"eslint@^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7", "eslint@^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0", "eslint@^3.17.0 || ^4 || ^5 || ^6 || ^7 || ^8", "eslint@^6.0.0 || ^7.0.0 || >=8.0.0", "eslint@^7.5.0 || ^8.0.0 || ^9.0.0", eslint@^8.1.0, eslint@^8.56.0, eslint@^8.57.0, "eslint@^8.57.0 || ^9.0.0", "eslint@^9.0.0 || ^8.0.0", eslint@>=4.19.1, eslint@>=7.0.0, eslint@>=8, eslint@>=8.0.0: + version "8.57.1" + resolved "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz" + integrity sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA== + dependencies: + "@eslint-community/eslint-utils" "^4.2.0" + "@eslint-community/regexpp" "^4.6.1" + "@eslint/eslintrc" "^2.1.4" + "@eslint/js" "8.57.1" + "@humanwhocodes/config-array" "^0.13.0" + "@humanwhocodes/module-importer" "^1.0.1" + "@nodelib/fs.walk" "^1.2.8" + "@ungap/structured-clone" "^1.2.0" + ajv "^6.12.4" + chalk "^4.0.0" + cross-spawn "^7.0.2" + debug "^4.3.2" + doctrine "^3.0.0" + escape-string-regexp "^4.0.0" + eslint-scope "^7.2.2" + eslint-visitor-keys "^3.4.3" + espree "^9.6.1" + esquery "^1.4.2" + esutils "^2.0.2" + fast-deep-equal "^3.1.3" + file-entry-cache "^6.0.1" + find-up "^5.0.0" + glob-parent "^6.0.2" + globals "^13.19.0" + graphemer "^1.4.0" + ignore "^5.2.0" + imurmurhash "^0.1.4" + is-glob "^4.0.0" + is-path-inside "^3.0.3" + js-yaml "^4.1.0" + json-stable-stringify-without-jsonify "^1.0.1" + levn "^0.4.1" + lodash.merge "^4.6.2" + minimatch "^3.1.2" + natural-compare "^1.4.0" + optionator "^0.9.3" + strip-ansi "^6.0.1" + text-table "^0.2.0" + +espree@^9.6.0, espree@^9.6.1: + version "9.6.1" + resolved "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz" + integrity sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ== + dependencies: + acorn "^8.9.0" + acorn-jsx "^5.3.2" + eslint-visitor-keys "^3.4.1" + +esprima@^4.0.0: + version "4.0.1" + resolved "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz" + integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== + +esquery@^1.4.2: + version "1.7.0" + resolved "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz" + integrity sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g== + dependencies: + estraverse "^5.1.0" + +esrecurse@^4.3.0: + version "4.3.0" + resolved "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz" + integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== + dependencies: + estraverse "^5.2.0" + +estraverse@^4.1.1: + version "4.3.0" + resolved "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz" + integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw== + +estraverse@^5.1.0, estraverse@^5.2.0, estraverse@^5.3.0: + version "5.3.0" + resolved "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz" + integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== + +esutils@^2.0.2: + version "2.0.3" + resolved "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz" + integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== + +etag@~1.8.1: + version "1.8.1" + resolved "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz" + integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== + +event-target-shim@^5.0.0: + version "5.0.1" + resolved "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz" + integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== + +execa@^5.0.0: + version "5.1.1" + resolved "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz" + integrity sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg== + dependencies: + cross-spawn "^7.0.3" + get-stream "^6.0.0" + human-signals "^2.1.0" + is-stream "^2.0.0" + merge-stream "^2.0.0" + npm-run-path "^4.0.1" + onetime "^5.1.2" + signal-exit "^3.0.3" + strip-final-newline "^2.0.0" + +exponential-backoff@^3.1.1: + version "3.1.3" + resolved "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz" + integrity sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA== + +fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: + version "3.1.3" + resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz" + integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== + +fast-diff@^1.1.2: + version "1.3.0" + resolved "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz" + integrity sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw== + +fast-glob@^3.2.9, fast-glob@^3.3.2, fast-glob@^3.3.3: + version "3.3.3" + resolved "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz" + integrity sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg== + dependencies: + "@nodelib/fs.stat" "^2.0.2" + "@nodelib/fs.walk" "^1.2.3" + glob-parent "^5.1.2" + merge2 "^1.3.0" + micromatch "^4.0.8" + +fast-json-stable-stringify@^2.0.0, fast-json-stable-stringify@^2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz" + integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== + +fast-levenshtein@^2.0.6: + version "2.0.6" + resolved "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz" + integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== + +fast-xml-parser@^4.4.1: + version "4.5.3" + resolved "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz" + integrity sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig== + dependencies: + strnum "^1.1.1" + +fastq@^1.6.0: + version "1.20.1" + resolved "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz" + integrity sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw== + dependencies: + reusify "^1.0.4" + +fb-dotslash@0.5.8: + version "0.5.8" + resolved "https://registry.npmjs.org/fb-dotslash/-/fb-dotslash-0.5.8.tgz" + integrity sha512-XHYLKk9J4BupDxi9bSEhkfss0m+Vr9ChTrjhf9l2iw3jB5C7BnY4GVPoMcqbrTutsKJso6yj2nAB6BI/F2oZaA== + +fb-watchman@^2.0.0: + version "2.0.2" + resolved "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz" + integrity sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA== + dependencies: + bser "2.1.1" + +fd-package-json@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/fd-package-json/-/fd-package-json-2.0.0.tgz" + integrity sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ== + dependencies: + walk-up-path "^4.0.0" + +fdir@^6.5.0: + version "6.5.0" + resolved "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz" + integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== + +file-entry-cache@^6.0.1: + version "6.0.1" + resolved "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz" + integrity sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg== + dependencies: + flat-cache "^3.0.4" + +fill-range@^7.1.1: + version "7.1.1" + resolved "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz" + integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg== + dependencies: + to-regex-range "^5.0.1" + +filter-obj@^1.1.0: + version "1.1.0" + resolved "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz" + integrity sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ== + +finalhandler@1.1.2: + version "1.1.2" + resolved "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz" + integrity sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA== + dependencies: + debug "2.6.9" + encodeurl "~1.0.2" + escape-html "~1.0.3" + on-finished "~2.3.0" + parseurl "~1.3.3" + statuses "~1.5.0" + unpipe "~1.0.0" + +find-up@^4.1.0: + version "4.1.0" + resolved "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz" + integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw== + dependencies: + locate-path "^5.0.0" + path-exists "^4.0.0" + +find-up@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz" + integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== + dependencies: + locate-path "^6.0.0" + path-exists "^4.0.0" + +flat-cache@^3.0.4: + version "3.2.0" + resolved "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz" + integrity sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw== + dependencies: + flatted "^3.2.9" + keyv "^4.5.3" + rimraf "^3.0.2" + +flatted@^3.2.9: + version "3.3.3" + resolved "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz" + integrity sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg== + +flow-enums-runtime@^0.0.6: + version "0.0.6" + resolved "https://registry.npmjs.org/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz" + integrity sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw== + +for-each@^0.3.3, for-each@^0.3.5: + version "0.3.5" + resolved "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz" + integrity sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg== + dependencies: + is-callable "^1.2.7" + +formatly@^0.3.0: + version "0.3.0" + resolved "https://registry.npmjs.org/formatly/-/formatly-0.3.0.tgz" + integrity sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w== + dependencies: + fd-package-json "^2.0.0" + +fresh@~0.5.2: + version "0.5.2" + resolved "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz" + integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== + +fs-extra@^8.1.0: + version "8.1.0" + resolved "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz" + integrity sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g== + dependencies: + graceful-fs "^4.2.0" + jsonfile "^4.0.0" + universalify "^0.1.0" + +fs.realpath@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz" + integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== + +fsevents@^2.3.2: + version "2.3.3" + resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + +function-bind@^1.1.2: + version "1.1.2" + resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz" + integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== + +function.prototype.name@^1.1.6, function.prototype.name@^1.1.8: + version "1.1.8" + resolved "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz" + integrity sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.3" + define-properties "^1.2.1" + functions-have-names "^1.2.3" + hasown "^2.0.2" + is-callable "^1.2.7" + +functions-have-names@^1.2.3: + version "1.2.3" + resolved "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz" + integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ== + +generator-function@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz" + integrity sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g== + +gensync@^1.0.0-beta.2: + version "1.0.0-beta.2" + resolved "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz" + integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== + +get-caller-file@^2.0.1, get-caller-file@^2.0.5: + version "2.0.5" + resolved "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz" + integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== + +get-intrinsic@^1.2.4, get-intrinsic@^1.2.5, get-intrinsic@^1.2.6, get-intrinsic@^1.2.7, get-intrinsic@^1.3.0: + version "1.3.0" + resolved "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz" + integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== + dependencies: + call-bind-apply-helpers "^1.0.2" + es-define-property "^1.0.1" + es-errors "^1.3.0" + es-object-atoms "^1.1.1" + function-bind "^1.1.2" + get-proto "^1.0.1" + gopd "^1.2.0" + has-symbols "^1.1.0" + hasown "^2.0.2" + math-intrinsics "^1.1.0" + +get-package-type@^0.1.0: + version "0.1.0" + resolved "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz" + integrity sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q== + +get-proto@^1.0.0, get-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz" + integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g== + dependencies: + dunder-proto "^1.0.1" + es-object-atoms "^1.0.0" + +get-stream@^6.0.0: + version "6.0.1" + resolved "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz" + integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg== + +get-symbol-description@^1.1.0: + version "1.1.0" + resolved "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz" + integrity sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg== + dependencies: + call-bound "^1.0.3" + es-errors "^1.3.0" + get-intrinsic "^1.2.6" + +glob-parent@^5.1.2: + version "5.1.2" + resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz" + integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== + dependencies: + is-glob "^4.0.1" + +glob-parent@^6.0.2: + version "6.0.2" + resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz" + integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== + dependencies: + is-glob "^4.0.3" + +glob@^7.1.1, glob@^7.1.3, glob@^7.1.4: + version "7.2.3" + resolved "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz" + integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.1.1" + once "^1.3.0" + path-is-absolute "^1.0.0" + +glob@7.0.6: + version "7.0.6" + resolved "https://registry.npmjs.org/glob/-/glob-7.0.6.tgz" + integrity sha512-f8c0rE8JiCxpa52kWPAOa3ZaYEnzofDzCQLCn3Vdk0Z5OVLq3BsRFJI4S4ykpeVW6QMGBUkMeUpoEgWnMTnw5Q== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.2" + once "^1.3.0" + path-is-absolute "^1.0.0" + +globals@^13.19.0: + version "13.24.0" + resolved "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz" + integrity sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ== + dependencies: + type-fest "^0.20.2" + +globalthis@^1.0.4: + version "1.0.4" + resolved "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz" + integrity sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ== + dependencies: + define-properties "^1.2.1" + gopd "^1.0.1" + +globby@^11.1.0: + version "11.1.0" + resolved "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz" + integrity sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g== + dependencies: + array-union "^2.1.0" + dir-glob "^3.0.1" + fast-glob "^3.2.9" + ignore "^5.2.0" + merge2 "^1.4.1" + slash "^3.0.0" + +gopd@^1.0.1, gopd@^1.2.0: + version "1.2.0" + resolved "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz" + integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== + +graceful-fs@^4.1.3, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.4, graceful-fs@^4.2.9: + version "4.2.11" + resolved "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz" + integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== + +graphemer@^1.4.0: + version "1.4.0" + resolved "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz" + integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== + +has-bigints@^1.0.2: + version "1.1.0" + resolved "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz" + integrity sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg== + +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + +has-property-descriptors@^1.0.0, has-property-descriptors@^1.0.2: + version "1.0.2" + resolved "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz" + integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg== + dependencies: + es-define-property "^1.0.0" + +has-proto@^1.2.0: + version "1.2.0" + resolved "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz" + integrity sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ== + dependencies: + dunder-proto "^1.0.0" + +has-symbols@^1.0.3, has-symbols@^1.1.0: + version "1.1.0" + resolved "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz" + integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== + +has-tostringtag@^1.0.2: + version "1.0.2" + resolved "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz" + integrity sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw== + dependencies: + has-symbols "^1.0.3" + +hasown@^2.0.2: + version "2.0.2" + resolved "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz" + integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== + dependencies: + function-bind "^1.1.2" + +hermes-compiler@0.14.0: + version "0.14.0" + resolved "https://registry.npmjs.org/hermes-compiler/-/hermes-compiler-0.14.0.tgz" + integrity sha512-clxa193o+GYYwykWVFfpHduCATz8fR5jvU7ngXpfKHj+E9hr9vjLNtdLSEe8MUbObvVexV3wcyxQ00xTPIrB1Q== + +hermes-estree@0.25.1: + version "0.25.1" + resolved "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz" + integrity sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw== + +hermes-estree@0.32.0: + version "0.32.0" + resolved "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.32.0.tgz" + integrity sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ== + +hermes-parser@^0.25.1: + version "0.25.1" + resolved "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz" + integrity sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA== + dependencies: + hermes-estree "0.25.1" + +hermes-parser@0.32.0: + version "0.32.0" + resolved "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.32.0.tgz" + integrity sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw== + dependencies: + hermes-estree "0.32.0" + +hoist-non-react-statics@^3.3.0: + version "3.3.2" + resolved "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz" + integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== + dependencies: + react-is "^16.7.0" + +http-errors@~2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz" + integrity sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ== + dependencies: + depd "~2.0.0" + inherits "~2.0.4" + setprototypeof "~1.2.0" + statuses "~2.0.2" + toidentifier "~1.0.1" + +https-proxy-agent@^7.0.5: + version "7.0.6" + resolved "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz" + integrity sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw== + dependencies: + agent-base "^7.1.2" + debug "4" + +human-signals@^2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz" + integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== + +hyochan-welcome@^1.0.0: + version "1.0.1" + resolved "https://registry.npmjs.org/hyochan-welcome/-/hyochan-welcome-1.0.1.tgz" + integrity sha512-WRZNH5grESkOXP/r7xc7TMhO9cUqxaJIuZcQDAjzHWs6viGP+sWtVbiBigxc9YVRrw3hnkESQWwzqg+oOga65A== + +iconv-lite@~0.4.24: + version "0.4.24" + resolved "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz" + integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== + dependencies: + safer-buffer ">= 2.1.2 < 3" + +ieee754@^1.1.13: + version "1.2.1" + resolved "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz" + integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== + +ignore@^5.0.5, ignore@^5.2.0, ignore@^5.3.1: + version "5.3.2" + resolved "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz" + integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g== + +ignore@^7.0.5: + version "7.0.5" + resolved "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz" + integrity sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg== + +image-size@^1.0.2: + version "1.2.1" + resolved "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz" + integrity sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw== + dependencies: + queue "6.0.2" + +import-fresh@^3.2.1, import-fresh@^3.3.0: + version "3.3.1" + resolved "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz" + integrity sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ== + dependencies: + parent-module "^1.0.0" + resolve-from "^4.0.0" + +imurmurhash@^0.1.4: + version "0.1.4" + resolved "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz" + integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== + +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz" + integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3, inherits@~2.0.4, inherits@2: + version "2.0.4" + resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +internal-slot@^1.1.0: + version "1.1.0" + resolved "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz" + integrity sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw== + dependencies: + es-errors "^1.3.0" + hasown "^2.0.2" + side-channel "^1.1.0" + +invariant@^2.2.4: + version "2.2.4" + resolved "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz" + integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA== + dependencies: + loose-envify "^1.0.0" + +is-array-buffer@^3.0.4, is-array-buffer@^3.0.5: + version "3.0.5" + resolved "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz" + integrity sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.3" + get-intrinsic "^1.2.6" + +is-arrayish@^0.2.1: + version "0.2.1" + resolved "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz" + integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg== + +is-arrayish@^0.3.1: + version "0.3.4" + resolved "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz" + integrity sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA== + +is-async-function@^2.0.0: + version "2.1.1" + resolved "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz" + integrity sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ== + dependencies: + async-function "^1.0.0" + call-bound "^1.0.3" + get-proto "^1.0.1" + has-tostringtag "^1.0.2" + safe-regex-test "^1.1.0" + +is-bigint@^1.1.0: + version "1.1.0" + resolved "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz" + integrity sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ== + dependencies: + has-bigints "^1.0.2" + +is-boolean-object@^1.2.1: + version "1.2.2" + resolved "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz" + integrity sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A== + dependencies: + call-bound "^1.0.3" + has-tostringtag "^1.0.2" + +is-callable@^1.2.7: + version "1.2.7" + resolved "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz" + integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA== + +is-core-module@^2.13.0, is-core-module@^2.16.1: + version "2.16.1" + resolved "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz" + integrity sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w== + dependencies: + hasown "^2.0.2" + +is-data-view@^1.0.1, is-data-view@^1.0.2: + version "1.0.2" + resolved "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz" + integrity sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw== + dependencies: + call-bound "^1.0.2" + get-intrinsic "^1.2.6" + is-typed-array "^1.1.13" + +is-date-object@^1.0.5, is-date-object@^1.1.0: + version "1.1.0" + resolved "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz" + integrity sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg== + dependencies: + call-bound "^1.0.2" + has-tostringtag "^1.0.2" + +is-docker@^2.0.0: + version "2.2.1" + resolved "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz" + integrity sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ== + +is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz" + integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== + +is-finalizationregistry@^1.1.0: + version "1.1.1" + resolved "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz" + integrity sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg== + dependencies: + call-bound "^1.0.3" + +is-fullwidth-code-point@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz" + integrity sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w== + +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + +is-generator-function@^1.0.10: + version "1.1.2" + resolved "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz" + integrity sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA== + dependencies: + call-bound "^1.0.4" + generator-function "^2.0.0" + get-proto "^1.0.1" + has-tostringtag "^1.0.2" + safe-regex-test "^1.1.0" + +is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3: + version "4.0.3" + resolved "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz" + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== + dependencies: + is-extglob "^2.1.1" + +is-interactive@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz" + integrity sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w== + +is-map@^2.0.3: + version "2.0.3" + resolved "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz" + integrity sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw== + +is-negative-zero@^2.0.3: + version "2.0.3" + resolved "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz" + integrity sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw== + +is-number-object@^1.1.1: + version "1.1.1" + resolved "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz" + integrity sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw== + dependencies: + call-bound "^1.0.3" + has-tostringtag "^1.0.2" + +is-number@^7.0.0: + version "7.0.0" + resolved "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz" + integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== + +is-path-inside@^3.0.3: + version "3.0.3" + resolved "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz" + integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ== + +is-plain-obj@^2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz" + integrity sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA== + +is-regex@^1.2.1: + version "1.2.1" + resolved "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz" + integrity sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g== + dependencies: + call-bound "^1.0.2" + gopd "^1.2.0" + has-tostringtag "^1.0.2" + hasown "^2.0.2" + +is-set@^2.0.3: + version "2.0.3" + resolved "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz" + integrity sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg== + +is-shared-array-buffer@^1.0.4: + version "1.0.4" + resolved "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz" + integrity sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A== + dependencies: + call-bound "^1.0.3" + +is-stream@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz" + integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg== + +is-string@^1.1.1: + version "1.1.1" + resolved "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz" + integrity sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA== + dependencies: + call-bound "^1.0.3" + has-tostringtag "^1.0.2" + +is-symbol@^1.0.4, is-symbol@^1.1.1: + version "1.1.1" + resolved "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz" + integrity sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w== + dependencies: + call-bound "^1.0.2" + has-symbols "^1.1.0" + safe-regex-test "^1.1.0" + +is-typed-array@^1.1.13, is-typed-array@^1.1.14, is-typed-array@^1.1.15: + version "1.1.15" + resolved "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz" + integrity sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ== + dependencies: + which-typed-array "^1.1.16" + +is-unicode-supported@^0.1.0: + version "0.1.0" + resolved "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz" + integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw== + +is-weakmap@^2.0.2: + version "2.0.2" + resolved "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz" + integrity sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w== + +is-weakref@^1.0.2, is-weakref@^1.1.1: + version "1.1.1" + resolved "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz" + integrity sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew== + dependencies: + call-bound "^1.0.3" + +is-weakset@^2.0.3: + version "2.0.4" + resolved "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz" + integrity sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ== + dependencies: + call-bound "^1.0.3" + get-intrinsic "^1.2.6" + +is-wsl@^1.1.0: + version "1.1.0" + resolved "https://registry.npmjs.org/is-wsl/-/is-wsl-1.1.0.tgz" + integrity sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw== + +is-wsl@^2.1.1: + version "2.2.0" + resolved "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz" + integrity sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww== + dependencies: + is-docker "^2.0.0" + +is-wsl@^2.2.0: + version "2.2.0" + resolved "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz" + integrity sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww== + dependencies: + is-docker "^2.0.0" + +isarray@^2.0.5: + version "2.0.5" + resolved "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz" + integrity sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw== + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz" + integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== + +istanbul-lib-coverage@^3.2.0: + version "3.2.2" + resolved "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz" + integrity sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg== + +istanbul-lib-instrument@^5.0.4: + version "5.2.1" + resolved "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz" + integrity sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg== + dependencies: + "@babel/core" "^7.12.3" + "@babel/parser" "^7.14.7" + "@istanbuljs/schema" "^0.1.2" + istanbul-lib-coverage "^3.2.0" + semver "^6.3.0" + +iterator.prototype@^1.1.5: + version "1.1.5" + resolved "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz" + integrity sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g== + dependencies: + define-data-property "^1.1.4" + es-object-atoms "^1.0.0" + get-intrinsic "^1.2.6" + get-proto "^1.0.0" + has-symbols "^1.1.0" + set-function-name "^2.0.2" + +jest-environment-node@^29.7.0: + version "29.7.0" + resolved "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz" + integrity sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw== + dependencies: + "@jest/environment" "^29.7.0" + "@jest/fake-timers" "^29.7.0" + "@jest/types" "^29.6.3" + "@types/node" "*" + jest-mock "^29.7.0" + jest-util "^29.7.0" + +jest-get-type@^29.6.3: + version "29.6.3" + resolved "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz" + integrity sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw== + +jest-haste-map@^29.7.0: + version "29.7.0" + resolved "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz" + integrity sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA== + dependencies: + "@jest/types" "^29.6.3" + "@types/graceful-fs" "^4.1.3" + "@types/node" "*" + anymatch "^3.0.3" + fb-watchman "^2.0.0" + graceful-fs "^4.2.9" + jest-regex-util "^29.6.3" + jest-util "^29.7.0" + jest-worker "^29.7.0" + micromatch "^4.0.4" + walker "^1.0.8" + optionalDependencies: + fsevents "^2.3.2" + +jest-message-util@^29.7.0: + version "29.7.0" + resolved "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz" + integrity sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w== + dependencies: + "@babel/code-frame" "^7.12.13" + "@jest/types" "^29.6.3" + "@types/stack-utils" "^2.0.0" + chalk "^4.0.0" + graceful-fs "^4.2.9" + micromatch "^4.0.4" + pretty-format "^29.7.0" + slash "^3.0.0" + stack-utils "^2.0.3" + +jest-mock@^29.7.0: + version "29.7.0" + resolved "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz" + integrity sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw== + dependencies: + "@jest/types" "^29.6.3" + "@types/node" "*" + jest-util "^29.7.0" + +jest-regex-util@^29.6.3: + version "29.6.3" + resolved "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz" + integrity sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg== + +jest-util@^29.7.0: + version "29.7.0" + resolved "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz" + integrity sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA== + dependencies: + "@jest/types" "^29.6.3" + "@types/node" "*" + chalk "^4.0.0" + ci-info "^3.2.0" + graceful-fs "^4.2.9" + picomatch "^2.2.3" + +jest-validate@^29.7.0: + version "29.7.0" + resolved "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz" + integrity sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw== + dependencies: + "@jest/types" "^29.6.3" + camelcase "^6.2.0" + chalk "^4.0.0" + jest-get-type "^29.6.3" + leven "^3.1.0" + pretty-format "^29.7.0" + +jest-worker@^29.7.0: + version "29.7.0" + resolved "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz" + integrity sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw== + dependencies: + "@types/node" "*" + jest-util "^29.7.0" + merge-stream "^2.0.0" + supports-color "^8.0.0" + +jiti@^2.6.0: + version "2.6.1" + resolved "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz" + integrity sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ== + +joi@^17.2.1: + version "17.13.3" + resolved "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz" + integrity sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA== + dependencies: + "@hapi/hoek" "^9.3.0" + "@hapi/topo" "^5.1.0" + "@sideway/address" "^4.1.5" + "@sideway/formula" "^3.0.1" + "@sideway/pinpoint" "^2.0.0" + +"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz" + integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== + +js-yaml@^3.13.1: + version "3.14.2" + resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz" + integrity sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg== + dependencies: + argparse "^1.0.7" + esprima "^4.0.0" + +js-yaml@^4.1.0, js-yaml@^4.1.1: + version "4.1.1" + resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz" + integrity sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA== + dependencies: + argparse "^2.0.1" + +jsc-safe-url@^0.2.2: + version "0.2.4" + resolved "https://registry.npmjs.org/jsc-safe-url/-/jsc-safe-url-0.2.4.tgz" + integrity sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q== + +jsesc@^3.0.2, jsesc@~3.1.0: + version "3.1.0" + resolved "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz" + integrity sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA== + +json-buffer@3.0.1: + version "3.0.1" + resolved "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz" + integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== + +json-parse-even-better-errors@^2.3.0: + version "2.3.1" + resolved "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz" + integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== + +json-schema-traverse@^0.4.1: + version "0.4.1" + resolved "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz" + integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== + +json-stable-stringify-without-jsonify@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz" + integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== + +json5@^2.2.3: + version "2.2.3" + resolved "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz" + integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== + +jsonfile@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz" + integrity sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg== + optionalDependencies: + graceful-fs "^4.1.6" + +"jsx-ast-utils@^2.4.1 || ^3.0.0": + version "3.3.5" + resolved "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz" + integrity sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ== + dependencies: + array-includes "^3.1.6" + array.prototype.flat "^1.3.1" + object.assign "^4.1.4" + object.values "^1.1.6" + +keyv@^4.5.3: + version "4.5.4" + resolved "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz" + integrity sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw== + dependencies: + json-buffer "3.0.1" + +kleur@^3.0.3: + version "3.0.3" + resolved "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz" + integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w== + +knip@^5.76.0: + version "5.82.0" + resolved "https://registry.npmjs.org/knip/-/knip-5.82.0.tgz" + integrity sha512-LNOR/TcauMdJLGZ9jdniIUpt0yy8aG/v8g31UJlb6qBvMNFY31w02hnwS8KMHEGy/X+pfxqsOLMFdm0NAJ3wWg== + dependencies: + "@nodelib/fs.walk" "^1.2.3" + fast-glob "^3.3.3" + formatly "^0.3.0" + jiti "^2.6.0" + js-yaml "^4.1.1" + minimist "^1.2.8" + oxc-resolver "^11.15.0" + picocolors "^1.1.1" + picomatch "^4.0.1" + smol-toml "^1.5.2" + strip-json-comments "5.0.3" + zod "^4.1.11" + +launch-editor@^2.9.1: + version "2.12.0" + resolved "https://registry.npmjs.org/launch-editor/-/launch-editor-2.12.0.tgz" + integrity sha512-giOHXoOtifjdHqUamwKq6c49GzBdLjvxrd2D+Q4V6uOHopJv7p9VJxikDsQ/CBXZbEITgUqSVHXLTG3VhPP1Dg== + dependencies: + picocolors "^1.1.1" + shell-quote "^1.8.3" + +leven@^3.1.0: + version "3.1.0" + resolved "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz" + integrity sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A== + +levn@^0.4.1: + version "0.4.1" + resolved "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz" + integrity sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ== + dependencies: + prelude-ls "^1.2.1" + type-check "~0.4.0" + +lighthouse-logger@^1.0.0: + version "1.4.2" + resolved "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-1.4.2.tgz" + integrity sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g== + dependencies: + debug "^2.6.9" + marky "^1.2.2" + +lines-and-columns@^1.1.6: + version "1.2.4" + resolved "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz" + integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== + +locate-path@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz" + integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g== + dependencies: + p-locate "^4.1.0" + +locate-path@^6.0.0: + version "6.0.0" + resolved "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz" + integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== + dependencies: + p-locate "^5.0.0" + +lodash.debounce@^4.0.8: + version "4.0.8" + resolved "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz" + integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow== + +lodash.merge@^4.6.2: + version "4.6.2" + resolved "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz" + integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== + +lodash.throttle@^4.1.1: + version "4.1.1" + resolved "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz" + integrity sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ== + +lodash@^4.17.21: + version "4.17.21" + resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + +log-symbols@^4.1.0: + version "4.1.0" + resolved "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz" + integrity sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg== + dependencies: + chalk "^4.1.0" + is-unicode-supported "^0.1.0" + +logkitty@^0.7.1: + version "0.7.1" + resolved "https://registry.npmjs.org/logkitty/-/logkitty-0.7.1.tgz" + integrity sha512-/3ER20CTTbahrCrpYfPn7Xavv9diBROZpoXGVZDWMw4b/X4uuUwAC0ki85tgsdMRONURyIJbcOvS94QsUBYPbQ== + dependencies: + ansi-fragments "^0.2.1" + dayjs "^1.8.15" + yargs "^15.1.0" + +loose-envify@^1.0.0, loose-envify@^1.4.0: + version "1.4.0" + resolved "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz" + integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== + dependencies: + js-tokens "^3.0.0 || ^4.0.0" + +lru-cache@^5.1.1: + version "5.1.1" + resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz" + integrity sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w== + dependencies: + yallist "^3.0.2" + +makeerror@1.0.12: + version "1.0.12" + resolved "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz" + integrity sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg== + dependencies: + tmpl "1.0.5" + +marky@^1.2.2: + version "1.3.0" + resolved "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz" + integrity sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ== + +math-intrinsics@^1.1.0: + version "1.1.0" + resolved "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz" + integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== + +media-typer@0.3.0: + version "0.3.0" + resolved "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz" + integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== + +memoize-one@^5.0.0: + version "5.2.1" + resolved "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz" + integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q== + +merge-options@^3.0.4: + version "3.0.4" + resolved "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz" + integrity sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ== + dependencies: + is-plain-obj "^2.1.0" + +merge-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz" + integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== + +merge2@^1.3.0, merge2@^1.4.1: + version "1.4.1" + resolved "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz" + integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== + +metro-babel-transformer@0.83.3: + version "0.83.3" + resolved "https://registry.npmjs.org/metro-babel-transformer/-/metro-babel-transformer-0.83.3.tgz" + integrity sha512-1vxlvj2yY24ES1O5RsSIvg4a4WeL7PFXgKOHvXTXiW0deLvQr28ExXj6LjwCCDZ4YZLhq6HddLpZnX4dEdSq5g== + dependencies: + "@babel/core" "^7.25.2" + flow-enums-runtime "^0.0.6" + hermes-parser "0.32.0" + nullthrows "^1.1.1" + +metro-cache-key@0.83.3: + version "0.83.3" + resolved "https://registry.npmjs.org/metro-cache-key/-/metro-cache-key-0.83.3.tgz" + integrity sha512-59ZO049jKzSmvBmG/B5bZ6/dztP0ilp0o988nc6dpaDsU05Cl1c/lRf+yx8m9WW/JVgbmfO5MziBU559XjI5Zw== + dependencies: + flow-enums-runtime "^0.0.6" + +metro-cache@0.83.3: + version "0.83.3" + resolved "https://registry.npmjs.org/metro-cache/-/metro-cache-0.83.3.tgz" + integrity sha512-3jo65X515mQJvKqK3vWRblxDEcgY55Sk3w4xa6LlfEXgQ9g1WgMh9m4qVZVwgcHoLy0a2HENTPCCX4Pk6s8c8Q== + dependencies: + exponential-backoff "^3.1.1" + flow-enums-runtime "^0.0.6" + https-proxy-agent "^7.0.5" + metro-core "0.83.3" + +metro-config@^0.83.3, metro-config@0.83.3: + version "0.83.3" + resolved "https://registry.npmjs.org/metro-config/-/metro-config-0.83.3.tgz" + integrity sha512-mTel7ipT0yNjKILIan04bkJkuCzUUkm2SeEaTads8VfEecCh+ltXchdq6DovXJqzQAXuR2P9cxZB47Lg4klriA== + dependencies: + connect "^3.6.5" + flow-enums-runtime "^0.0.6" + jest-validate "^29.7.0" + metro "0.83.3" + metro-cache "0.83.3" + metro-core "0.83.3" + metro-runtime "0.83.3" + yaml "^2.6.1" + +metro-core@^0.83.3, metro-core@0.83.3: + version "0.83.3" + resolved "https://registry.npmjs.org/metro-core/-/metro-core-0.83.3.tgz" + integrity sha512-M+X59lm7oBmJZamc96usuF1kusd5YimqG/q97g4Ac7slnJ3YiGglW5CsOlicTR5EWf8MQFxxjDoB6ytTqRe8Hw== + dependencies: + flow-enums-runtime "^0.0.6" + lodash.throttle "^4.1.1" + metro-resolver "0.83.3" + +metro-file-map@0.83.3: + version "0.83.3" + resolved "https://registry.npmjs.org/metro-file-map/-/metro-file-map-0.83.3.tgz" + integrity sha512-jg5AcyE0Q9Xbbu/4NAwwZkmQn7doJCKGW0SLeSJmzNB9Z24jBe0AL2PHNMy4eu0JiKtNWHz9IiONGZWq7hjVTA== + dependencies: + debug "^4.4.0" + fb-watchman "^2.0.0" + flow-enums-runtime "^0.0.6" + graceful-fs "^4.2.4" + invariant "^2.2.4" + jest-worker "^29.7.0" + micromatch "^4.0.4" + nullthrows "^1.1.1" + walker "^1.0.7" + +metro-minify-terser@0.83.3: + version "0.83.3" + resolved "https://registry.npmjs.org/metro-minify-terser/-/metro-minify-terser-0.83.3.tgz" + integrity sha512-O2BmfWj6FSfzBLrNCXt/rr2VYZdX5i6444QJU0fFoc7Ljg+Q+iqebwE3K0eTvkI6TRjELsXk1cjU+fXwAR4OjQ== + dependencies: + flow-enums-runtime "^0.0.6" + terser "^5.15.0" + +metro-resolver@0.83.3: + version "0.83.3" + resolved "https://registry.npmjs.org/metro-resolver/-/metro-resolver-0.83.3.tgz" + integrity sha512-0js+zwI5flFxb1ktmR///bxHYg7OLpRpWZlBBruYG8OKYxeMP7SV0xQ/o/hUelrEMdK4LJzqVtHAhBm25LVfAQ== + dependencies: + flow-enums-runtime "^0.0.6" + +metro-runtime@^0.83.3, metro-runtime@0.83.3: + version "0.83.3" + resolved "https://registry.npmjs.org/metro-runtime/-/metro-runtime-0.83.3.tgz" + integrity sha512-JHCJb9ebr9rfJ+LcssFYA2x1qPYuSD/bbePupIGhpMrsla7RCwC/VL3yJ9cSU+nUhU4c9Ixxy8tBta+JbDeZWw== + dependencies: + "@babel/runtime" "^7.25.0" + flow-enums-runtime "^0.0.6" + +metro-source-map@^0.83.3, metro-source-map@0.83.3: + version "0.83.3" + resolved "https://registry.npmjs.org/metro-source-map/-/metro-source-map-0.83.3.tgz" + integrity sha512-xkC3qwUBh2psVZgVavo8+r2C9Igkk3DibiOXSAht1aYRRcztEZNFtAMtfSB7sdO2iFMx2Mlyu++cBxz/fhdzQg== + dependencies: + "@babel/traverse" "^7.25.3" + "@babel/traverse--for-generate-function-map" "npm:@babel/traverse@^7.25.3" + "@babel/types" "^7.25.2" + flow-enums-runtime "^0.0.6" + invariant "^2.2.4" + metro-symbolicate "0.83.3" + nullthrows "^1.1.1" + ob1 "0.83.3" + source-map "^0.5.6" + vlq "^1.0.0" + +metro-symbolicate@0.83.3: + version "0.83.3" + resolved "https://registry.npmjs.org/metro-symbolicate/-/metro-symbolicate-0.83.3.tgz" + integrity sha512-F/YChgKd6KbFK3eUR5HdUsfBqVsanf5lNTwFd4Ca7uuxnHgBC3kR/Hba/RGkenR3pZaGNp5Bu9ZqqP52Wyhomw== + dependencies: + flow-enums-runtime "^0.0.6" + invariant "^2.2.4" + metro-source-map "0.83.3" + nullthrows "^1.1.1" + source-map "^0.5.6" + vlq "^1.0.0" + +metro-transform-plugins@0.83.3: + version "0.83.3" + resolved "https://registry.npmjs.org/metro-transform-plugins/-/metro-transform-plugins-0.83.3.tgz" + integrity sha512-eRGoKJU6jmqOakBMH5kUB7VitEWiNrDzBHpYbkBXW7C5fUGeOd2CyqrosEzbMK5VMiZYyOcNFEphvxk3OXey2A== + dependencies: + "@babel/core" "^7.25.2" + "@babel/generator" "^7.25.0" + "@babel/template" "^7.25.0" + "@babel/traverse" "^7.25.3" + flow-enums-runtime "^0.0.6" + nullthrows "^1.1.1" + +metro-transform-worker@0.83.3: + version "0.83.3" + resolved "https://registry.npmjs.org/metro-transform-worker/-/metro-transform-worker-0.83.3.tgz" + integrity sha512-Ztekew9t/gOIMZX1tvJOgX7KlSLL5kWykl0Iwu2cL2vKMKVALRl1hysyhUw0vjpAvLFx+Kfq9VLjnHIkW32fPA== + dependencies: + "@babel/core" "^7.25.2" + "@babel/generator" "^7.25.0" + "@babel/parser" "^7.25.3" + "@babel/types" "^7.25.2" + flow-enums-runtime "^0.0.6" + metro "0.83.3" + metro-babel-transformer "0.83.3" + metro-cache "0.83.3" + metro-cache-key "0.83.3" + metro-minify-terser "0.83.3" + metro-source-map "0.83.3" + metro-transform-plugins "0.83.3" + nullthrows "^1.1.1" + +metro@^0.83.3, metro@0.83.3: + version "0.83.3" + resolved "https://registry.npmjs.org/metro/-/metro-0.83.3.tgz" + integrity sha512-+rP+/GieOzkt97hSJ0MrPOuAH/jpaS21ZDvL9DJ35QYRDlQcwzcvUlGUf79AnQxq/2NPiS/AULhhM4TKutIt8Q== + dependencies: + "@babel/code-frame" "^7.24.7" + "@babel/core" "^7.25.2" + "@babel/generator" "^7.25.0" + "@babel/parser" "^7.25.3" + "@babel/template" "^7.25.0" + "@babel/traverse" "^7.25.3" + "@babel/types" "^7.25.2" + accepts "^1.3.7" + chalk "^4.0.0" + ci-info "^2.0.0" + connect "^3.6.5" + debug "^4.4.0" + error-stack-parser "^2.0.6" + flow-enums-runtime "^0.0.6" + graceful-fs "^4.2.4" + hermes-parser "0.32.0" + image-size "^1.0.2" + invariant "^2.2.4" + jest-worker "^29.7.0" + jsc-safe-url "^0.2.2" + lodash.throttle "^4.1.1" + metro-babel-transformer "0.83.3" + metro-cache "0.83.3" + metro-cache-key "0.83.3" + metro-config "0.83.3" + metro-core "0.83.3" + metro-file-map "0.83.3" + metro-resolver "0.83.3" + metro-runtime "0.83.3" + metro-source-map "0.83.3" + metro-symbolicate "0.83.3" + metro-transform-plugins "0.83.3" + metro-transform-worker "0.83.3" + mime-types "^2.1.27" + nullthrows "^1.1.1" + serialize-error "^2.1.0" + source-map "^0.5.6" + throat "^5.0.0" + ws "^7.5.10" + yargs "^17.6.2" + +micromatch@^4.0.4, micromatch@^4.0.8: + version "4.0.8" + resolved "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz" + integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA== + dependencies: + braces "^3.0.3" + picomatch "^2.3.1" + +"mime-db@>= 1.43.0 < 2": + version "1.54.0" + resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz" + integrity sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ== + +mime-db@1.52.0: + version "1.52.0" + resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz" + integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== + +mime-types@^2.1.27, mime-types@~2.1.24, mime-types@~2.1.34: + version "2.1.35" + resolved "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz" + integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== + dependencies: + mime-db "1.52.0" + +mime@^2.4.1: + version "2.6.0" + resolved "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz" + integrity sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg== + +mime@1.6.0: + version "1.6.0" + resolved "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz" + integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== + +mimic-fn@^2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz" + integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== + +minimatch@^3.0.2: + version "3.1.2" + resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz" + integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== + dependencies: + brace-expansion "^1.1.7" + +minimatch@^3.0.4: + version "3.1.2" + resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz" + integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== + dependencies: + brace-expansion "^1.1.7" + +minimatch@^3.0.5: + version "3.1.2" + resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz" + integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== + dependencies: + brace-expansion "^1.1.7" + +minimatch@^3.1.1: + version "3.1.2" + resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz" + integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== + dependencies: + brace-expansion "^1.1.7" + +minimatch@^3.1.2: + version "3.1.2" + resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz" + integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== + dependencies: + brace-expansion "^1.1.7" + +minimatch@^9.0.4, minimatch@^9.0.5: + version "9.0.5" + resolved "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz" + integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== + dependencies: + brace-expansion "^2.0.1" + +minimist@^1.2.8: + version "1.2.8" + resolved "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz" + integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== + +mkdirp@^1.0.4: + version "1.0.4" + resolved "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz" + integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== + +ms@^2.1.3, ms@2.1.3: + version "2.1.3" + resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +ms@2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz" + integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A== + +nanoid@^3.3.11: + version "3.3.11" + resolved "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz" + integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== + +natural-compare@^1.4.0: + version "1.4.0" + resolved "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz" + integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== + +negotiator@~0.6.4: + version "0.6.4" + resolved "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz" + integrity sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w== + +negotiator@0.6.3: + version "0.6.3" + resolved "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz" + integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== + +nocache@^3.0.1: + version "3.0.4" + resolved "https://registry.npmjs.org/nocache/-/nocache-3.0.4.tgz" + integrity sha512-WDD0bdg9mbq6F4mRxEYcPWwfA1vxd0mrvKOyxI7Xj/atfRHVeutzuWByG//jfm4uPzp0y4Kj051EORCBSQMycw== + +node-int64@^0.4.0: + version "0.4.0" + resolved "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz" + integrity sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw== + +node-releases@^2.0.27: + version "2.0.27" + resolved "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz" + integrity sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA== + +node-stream-zip@^1.9.1: + version "1.15.0" + resolved "https://registry.npmjs.org/node-stream-zip/-/node-stream-zip-1.15.0.tgz" + integrity sha512-LN4fydt9TqhZhThkZIVQnF9cwjU3qmUH9h78Mx/K7d3VvfRqqwthLwJEUOEL0QPZ0XQmNN7be5Ggit5+4dq3Bw== + +normalize-path@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz" + integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== + +npm-run-path@^4.0.1: + version "4.0.1" + resolved "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz" + integrity sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw== + dependencies: + path-key "^3.0.0" + +nullthrows@^1.1.1: + version "1.1.1" + resolved "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz" + integrity sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw== + +ob1@0.83.3: + version "0.83.3" + resolved "https://registry.npmjs.org/ob1/-/ob1-0.83.3.tgz" + integrity sha512-egUxXCDwoWG06NGCS5s5AdcpnumHKJlfd3HH06P3m9TEMwwScfcY35wpQxbm9oHof+dM/lVH9Rfyu1elTVelSA== + dependencies: + flow-enums-runtime "^0.0.6" + +object-assign@^4.1.1: + version "4.1.1" + resolved "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz" + integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== + +object-inspect@^1.13.3, object-inspect@^1.13.4: + version "1.13.4" + resolved "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz" + integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew== + +object-keys@^1.1.1: + version "1.1.1" + resolved "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz" + integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== + +object.assign@^4.1.4, object.assign@^4.1.7: + version "4.1.7" + resolved "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz" + integrity sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.3" + define-properties "^1.2.1" + es-object-atoms "^1.0.0" + has-symbols "^1.1.0" + object-keys "^1.1.1" + +object.entries@^1.1.9: + version "1.1.9" + resolved "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz" + integrity sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.4" + define-properties "^1.2.1" + es-object-atoms "^1.1.1" + +object.fromentries@^2.0.8: + version "2.0.8" + resolved "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz" + integrity sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-abstract "^1.23.2" + es-object-atoms "^1.0.0" + +object.values@^1.1.6, object.values@^1.2.1: + version "1.2.1" + resolved "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz" + integrity sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.3" + define-properties "^1.2.1" + es-object-atoms "^1.0.0" + +on-finished@~2.3.0: + version "2.3.0" + resolved "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz" + integrity sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww== + dependencies: + ee-first "1.1.1" + +on-finished@~2.4.1: + version "2.4.1" + resolved "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz" + integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== + dependencies: + ee-first "1.1.1" + +on-headers@~1.1.0: + version "1.1.0" + resolved "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz" + integrity sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A== + +once@^1.3.0: + version "1.4.0" + resolved "https://registry.npmjs.org/once/-/once-1.4.0.tgz" + integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== + dependencies: + wrappy "1" + +onetime@^5.1.0, onetime@^5.1.2: + version "5.1.2" + resolved "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz" + integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg== + dependencies: + mimic-fn "^2.1.0" + +open@^6.2.0: + version "6.4.0" + resolved "https://registry.npmjs.org/open/-/open-6.4.0.tgz" + integrity sha512-IFenVPgF70fSm1keSd2iDBIDIBZkroLeuffXq+wKTzTJlBpesFWojV9lb8mzOfaAzM1sr7HQHuO0vtV0zYekGg== + dependencies: + is-wsl "^1.1.0" + +open@^7.0.3: + version "7.4.2" + resolved "https://registry.npmjs.org/open/-/open-7.4.2.tgz" + integrity sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q== + dependencies: + is-docker "^2.0.0" + is-wsl "^2.1.1" + +optionator@^0.9.3: + version "0.9.4" + resolved "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz" + integrity sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g== + dependencies: + deep-is "^0.1.3" + fast-levenshtein "^2.0.6" + levn "^0.4.1" + prelude-ls "^1.2.1" + type-check "^0.4.0" + word-wrap "^1.2.5" + +ora@^5.4.1: + version "5.4.1" + resolved "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz" + integrity sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ== + dependencies: + bl "^4.1.0" + chalk "^4.1.0" + cli-cursor "^3.1.0" + cli-spinners "^2.5.0" + is-interactive "^1.0.0" + is-unicode-supported "^0.1.0" + log-symbols "^4.1.0" + strip-ansi "^6.0.0" + wcwidth "^1.0.1" + +own-keys@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz" + integrity sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg== + dependencies: + get-intrinsic "^1.2.6" + object-keys "^1.1.1" + safe-push-apply "^1.0.0" + +oxc-resolver@^11.15.0: + version "11.16.3" + resolved "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.16.3.tgz" + integrity sha512-goLOJH3x69VouGWGp5CgCIHyksmOZzXr36lsRmQz1APg3SPFORrvV2q7nsUHMzLVa6ZJgNwkgUSJFsbCpAWkCA== + optionalDependencies: + "@oxc-resolver/binding-android-arm-eabi" "11.16.3" + "@oxc-resolver/binding-android-arm64" "11.16.3" + "@oxc-resolver/binding-darwin-arm64" "11.16.3" + "@oxc-resolver/binding-darwin-x64" "11.16.3" + "@oxc-resolver/binding-freebsd-x64" "11.16.3" + "@oxc-resolver/binding-linux-arm-gnueabihf" "11.16.3" + "@oxc-resolver/binding-linux-arm-musleabihf" "11.16.3" + "@oxc-resolver/binding-linux-arm64-gnu" "11.16.3" + "@oxc-resolver/binding-linux-arm64-musl" "11.16.3" + "@oxc-resolver/binding-linux-ppc64-gnu" "11.16.3" + "@oxc-resolver/binding-linux-riscv64-gnu" "11.16.3" + "@oxc-resolver/binding-linux-riscv64-musl" "11.16.3" + "@oxc-resolver/binding-linux-s390x-gnu" "11.16.3" + "@oxc-resolver/binding-linux-x64-gnu" "11.16.3" + "@oxc-resolver/binding-linux-x64-musl" "11.16.3" + "@oxc-resolver/binding-openharmony-arm64" "11.16.3" + "@oxc-resolver/binding-wasm32-wasi" "11.16.3" + "@oxc-resolver/binding-win32-arm64-msvc" "11.16.3" + "@oxc-resolver/binding-win32-ia32-msvc" "11.16.3" + "@oxc-resolver/binding-win32-x64-msvc" "11.16.3" + +p-limit@^2.2.0: + version "2.3.0" + resolved "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz" + integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w== + dependencies: + p-try "^2.0.0" + +p-limit@^3.0.2: + version "3.1.0" + resolved "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz" + integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== + dependencies: + yocto-queue "^0.1.0" + +p-locate@^4.1.0: + version "4.1.0" + resolved "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz" + integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A== + dependencies: + p-limit "^2.2.0" + +p-locate@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz" + integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== + dependencies: + p-limit "^3.0.2" + +p-try@^2.0.0: + version "2.2.0" + resolved "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz" + integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== + +parent-module@^1.0.0: + version "1.0.1" + resolved "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz" + integrity sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== + dependencies: + callsites "^3.0.0" + +parse-json@^5.2.0: + version "5.2.0" + resolved "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz" + integrity sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg== + dependencies: + "@babel/code-frame" "^7.0.0" + error-ex "^1.3.1" + json-parse-even-better-errors "^2.3.0" + lines-and-columns "^1.1.6" + +parseurl@~1.3.3: + version "1.3.3" + resolved "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz" + integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== + +path-exists@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz" + integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== + +path-is-absolute@^1.0.0: + version "1.0.1" + resolved "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz" + integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== + +path-key@^3.0.0, path-key@^3.1.0: + version "3.1.1" + resolved "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz" + integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== + +path-parse@^1.0.7: + version "1.0.7" + resolved "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz" + integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== + +path-type@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz" + integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== + +picocolors@^1.1.1: + version "1.1.1" + resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + +picomatch@^2.0.4: + version "2.3.1" + resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz" + integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== + +picomatch@^2.2.3: + version "2.3.1" + resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz" + integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== + +picomatch@^2.3.1: + version "2.3.1" + resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz" + integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== + +"picomatch@^3 || ^4", picomatch@^4.0.1, picomatch@^4.0.3: + version "4.0.3" + resolved "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz" + integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== + +pirates@^4.0.4: + version "4.0.7" + resolved "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz" + integrity sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA== + +possible-typed-array-names@^1.0.0: + version "1.1.0" + resolved "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz" + integrity sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg== + +prelude-ls@^1.2.1: + version "1.2.1" + resolved "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz" + integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== + +prettier-linter-helpers@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz" + integrity sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg== + dependencies: + fast-diff "^1.1.2" + +prettier@^3.3.2, prettier@>=2, prettier@>=3.0.0: + version "3.8.0" + resolved "https://registry.npmjs.org/prettier/-/prettier-3.8.0.tgz" + integrity sha512-yEPsovQfpxYfgWNhCfECjG5AQaO+K3dp6XERmOepyPDVqcJm+bjyCVO3pmU+nAPe0N5dDvekfGezt/EIiRe1TA== + +pretty-format@^29.7.0: + version "29.7.0" + resolved "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz" + integrity sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ== + dependencies: + "@jest/schemas" "^29.6.3" + ansi-styles "^5.0.0" + react-is "^18.0.0" + +promise@^8.3.0: + version "8.3.0" + resolved "https://registry.npmjs.org/promise/-/promise-8.3.0.tgz" + integrity sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg== + dependencies: + asap "~2.0.6" + +prompts@^2.4.2: + version "2.4.2" + resolved "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz" + integrity sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q== + dependencies: + kleur "^3.0.3" + sisteransi "^1.0.5" + +prop-types@^15.7.2, prop-types@^15.8.1: + version "15.8.1" + resolved "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz" + integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== + dependencies: + loose-envify "^1.4.0" + object-assign "^4.1.1" + react-is "^16.13.1" + +punycode@^2.1.0: + version "2.3.1" + resolved "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz" + integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== + +qs@~6.14.0: + version "6.14.1" + resolved "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz" + integrity sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ== + dependencies: + side-channel "^1.1.0" + +query-string@^7.1.3: + version "7.1.3" + resolved "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz" + integrity sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg== + dependencies: + decode-uri-component "^0.2.2" + filter-obj "^1.1.0" + split-on-first "^1.0.0" + strict-uri-encode "^2.0.0" + +queue-microtask@^1.2.2: + version "1.2.3" + resolved "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz" + integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== + +queue@6.0.2: + version "6.0.2" + resolved "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz" + integrity sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA== + dependencies: + inherits "~2.0.3" + +range-parser@~1.2.1: + version "1.2.1" + resolved "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz" + integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== + +raw-body@~2.5.3: + version "2.5.3" + resolved "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz" + integrity sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA== + dependencies: + bytes "~3.1.2" + http-errors "~2.0.1" + iconv-lite "~0.4.24" + unpipe "~1.0.0" + +react-devtools-core@^6.1.5: + version "6.1.5" + resolved "https://registry.npmjs.org/react-devtools-core/-/react-devtools-core-6.1.5.tgz" + integrity sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA== + dependencies: + shell-quote "^1.6.1" + ws "^7" + +react-freeze@^1.0.0: + version "1.0.4" + resolved "https://registry.npmjs.org/react-freeze/-/react-freeze-1.0.4.tgz" + integrity sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA== + +react-is@^16.13.1: + version "16.13.1" + resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz" + integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== + +react-is@^16.7.0: + version "16.13.1" + resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz" + integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== + +react-is@^18.0.0: + version "18.3.1" + resolved "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz" + integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== + +react-is@^19.1.0: + version "19.2.3" + resolved "https://registry.npmjs.org/react-is/-/react-is-19.2.3.tgz" + integrity sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA== + +react-native-audio-recorder-player@^3.6.14: + version "3.6.14" + resolved "https://registry.npmjs.org/react-native-audio-recorder-player/-/react-native-audio-recorder-player-3.6.14.tgz" + integrity sha512-F6SvHbuLvsbhBytR4+vaGIL6LFqC1cnB+SX3v191aHNvGDt63BX56w/Y19nIzxaLnG0b0vbxx/UZ1nzIvDyqWA== + dependencies: + hyochan-welcome "^1.0.0" + +react-native-fs@^2.20.0: + version "2.20.0" + resolved "https://registry.npmjs.org/react-native-fs/-/react-native-fs-2.20.0.tgz" + integrity sha512-VkTBzs7fIDUiy/XajOSNk0XazFE9l+QlMAce7lGuebZcag5CnjszB+u4BdqzwaQOdcYb5wsJIsqq4kxInIRpJQ== + dependencies: + base-64 "^0.1.0" + utf8 "^3.0.0" + +react-native-gesture-handler@~2.30.0: + version "2.30.0" + resolved "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.30.0.tgz" + integrity sha512-5YsnKHGa0X9C8lb5oCnKm0fLUPM6CRduvUUw2Bav4RIj/C3HcFh4RIUnF8wgG6JQWCL1//gRx4v+LVWgcIQdGA== + dependencies: + "@egjs/hammerjs" "^2.0.17" + hoist-non-react-statics "^3.3.0" + invariant "^2.2.4" + +react-native-is-edge-to-edge@1.2.1: + version "1.2.1" + resolved "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.2.1.tgz" + integrity sha512-FLbPWl/MyYQWz+KwqOZsSyj2JmLKglHatd3xLZWskXOpRaio4LfEDEz8E/A6uD8QoTHW6Aobw1jbEwK7KMgR7Q== + +react-native-live-audio-stream@^1.1.1: + version "1.1.1" + resolved "https://registry.npmjs.org/react-native-live-audio-stream/-/react-native-live-audio-stream-1.1.1.tgz" + integrity sha512-Yk0O51hY7eFMUv1umYxGDs4SJVPHyhUX6uz4jI+GiowOwSqIzLLRNh03hJjCVZRFXTWLPCntqOKZ+N8fVAc6BQ== + +react-native-monorepo-config@^0.3.0: + version "0.3.2" + resolved "https://registry.npmjs.org/react-native-monorepo-config/-/react-native-monorepo-config-0.3.2.tgz" + integrity sha512-Cl21GRCN/ZH3cEVtG7yY84NO2G6Bn57yEXReikOKFkFRUo6PFTAWfanEZReGqdAkhY5L/ORIml8abE1q83CZYQ== + dependencies: + escape-string-regexp "^5.0.0" + fast-glob "^3.3.3" + +react-native-nitro-modules@^0.31.10: + version "0.31.10" + resolved "https://registry.npmjs.org/react-native-nitro-modules/-/react-native-nitro-modules-0.31.10.tgz" + integrity sha512-hcvjTu9YJE9fMmnAUvhG8CxvYLpOuMQ/2eyi/S6GyrecezF6Rmk/uRQEL6v09BRFWA/xRVZNQVulQPS+2HS3mQ== + +react-native-permissions@^5.4.4: + version "5.4.4" + resolved "https://registry.npmjs.org/react-native-permissions/-/react-native-permissions-5.4.4.tgz" + integrity sha512-WB5lRCBGXETfuaUhem2vgOceb9+URCeyfKpLGFSwoOffLuyJCA6+NTR3l1KLkrK4Ykxsig37z16/shUVufmt7A== + +react-native-reanimated@~4.2.1: + version "4.2.1" + resolved "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.2.1.tgz" + integrity sha512-/NcHnZMyOvsD/wYXug/YqSKw90P9edN0kEPL5lP4PFf1aQ4F1V7MKe/E0tvfkXKIajy3Qocp5EiEnlcrK/+BZg== + dependencies: + react-native-is-edge-to-edge "1.2.1" + semver "7.7.3" + +"react-native-safe-area-context@>= 4.0.0", react-native-safe-area-context@~5.6.2: + version "5.6.2" + resolved "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz" + integrity sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg== + +"react-native-screens@>= 4.0.0", react-native-screens@~4.19.0: + version "4.19.0" + resolved "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.19.0.tgz" + integrity sha512-qSDAO3AL5bti0Ri7KZRSVmWlhDr8MV86N5GruiKVQfEL7Zx2nUi3Dl62lqHUAD/LnDvOPuDDsMHCfIpYSv3hPQ== + dependencies: + react-freeze "^1.0.0" + warn-once "^0.1.0" + +react-native-sound@^0.13.0: + version "0.13.0" + resolved "https://registry.npmjs.org/react-native-sound/-/react-native-sound-0.13.0.tgz" + integrity sha512-SnREzaV0fmpYNuDV1Y8M7FutmaYei0pKBgpldULKKJMkoA3DBv5ppyRxY+oxRQ7HwEpt6LsonrKgM+13GH/tCw== + +react-native-tts@^4.1.1: + version "4.1.1" + resolved "https://registry.npmjs.org/react-native-tts/-/react-native-tts-4.1.1.tgz" + integrity sha512-VL0TgCwkUWggbbFGIXAPKC3rM1baluAYtgOdgnaTm7UYsWf/y8n5VgmVB0J2Wa8qt1dldZ1cSsdQY9iz3evcAg== + +react-native-vector-icons@^10.1.0: + version "10.3.0" + resolved "https://registry.npmjs.org/react-native-vector-icons/-/react-native-vector-icons-10.3.0.tgz" + integrity sha512-IFQ0RE57819hOUdFvgK4FowM5aMXg7C7XKsuGLevqXkkIJatc3QopN0wYrb2IrzUgmdpfP+QVIbI3S6h7M0btw== + dependencies: + prop-types "^15.7.2" + yargs "^16.1.1" + +react-native-worklets@>=0.7.0, react-native-worklets@0.7.1: + version "0.7.1" + resolved "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.7.1.tgz" + integrity sha512-KNsvR48ULg73QhTlmwPbdJLPsWcyBotrGPsrDRDswb5FYpQaJEThUKc2ncXE4UM5dn/ewLoQHjSjLaKUVPxPhA== + dependencies: + "@babel/plugin-transform-arrow-functions" "7.27.1" + "@babel/plugin-transform-class-properties" "7.27.1" + "@babel/plugin-transform-classes" "7.28.4" + "@babel/plugin-transform-nullish-coalescing-operator" "7.27.1" + "@babel/plugin-transform-optional-chaining" "7.27.1" + "@babel/plugin-transform-shorthand-properties" "7.27.1" + "@babel/plugin-transform-template-literals" "7.27.1" + "@babel/plugin-transform-unicode-regex" "7.27.1" + "@babel/preset-typescript" "7.27.1" + convert-source-map "2.0.0" + semver "7.7.3" + +react-native-zip-archive@^6.1.2: + version "6.1.2" + resolved "https://registry.npmjs.org/react-native-zip-archive/-/react-native-zip-archive-6.1.2.tgz" + integrity sha512-LcJomSY/6O3KHy/LF6Gb7F/yRJiZJ0lTlPQPbfeOHBQzfvqNJFJZ8x6HrdeYeokFf/UGB5bY7jfh4es6Y/PhBA== + +react-native@*, "react-native@^0.0.0-0 || >=0.65 <1.0", react-native@>=0.60.0, react-native@>=0.70.0, react-native@0.83.1: + version "0.83.1" + resolved "https://registry.npmjs.org/react-native/-/react-native-0.83.1.tgz" + integrity sha512-mL1q5HPq5cWseVhWRLl+Fwvi5z1UO+3vGOpjr+sHFwcUletPRZ5Kv+d0tUfqHmvi73/53NjlQqX1Pyn4GguUfA== + dependencies: + "@jest/create-cache-key-function" "^29.7.0" + "@react-native/assets-registry" "0.83.1" + "@react-native/codegen" "0.83.1" + "@react-native/community-cli-plugin" "0.83.1" + "@react-native/gradle-plugin" "0.83.1" + "@react-native/js-polyfills" "0.83.1" + "@react-native/normalize-colors" "0.83.1" + "@react-native/virtualized-lists" "0.83.1" + abort-controller "^3.0.0" + anser "^1.4.9" + ansi-regex "^5.0.0" + babel-jest "^29.7.0" + babel-plugin-syntax-hermes-parser "0.32.0" + base64-js "^1.5.1" + commander "^12.0.0" + flow-enums-runtime "^0.0.6" + glob "^7.1.1" + hermes-compiler "0.14.0" + invariant "^2.2.4" + jest-environment-node "^29.7.0" + memoize-one "^5.0.0" + metro-runtime "^0.83.3" + metro-source-map "^0.83.3" + nullthrows "^1.1.1" + pretty-format "^29.7.0" + promise "^8.3.0" + react-devtools-core "^6.1.5" + react-refresh "^0.14.0" + regenerator-runtime "^0.13.2" + scheduler "0.27.0" + semver "^7.1.3" + stacktrace-parser "^0.1.10" + whatwg-fetch "^3.0.0" + ws "^7.5.10" + yargs "^17.6.2" + +react-refresh@^0.14.0: + version "0.14.2" + resolved "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz" + integrity sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA== + +react@*, "react@^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", react@^19.2.0, "react@>= 18.2.0", react@>=16.8, react@>=16.8.6, react@>=17.0.0, react@>=18.0.0, react@>=18.1.0, react@19.2.0: + version "19.2.0" + resolved "https://registry.npmjs.org/react/-/react-19.2.0.tgz" + integrity sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ== + +readable-stream@^3.4.0: + version "3.6.2" + resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz" + integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== + dependencies: + inherits "^2.0.3" + string_decoder "^1.1.1" + util-deprecate "^1.0.1" + +reflect.getprototypeof@^1.0.6, reflect.getprototypeof@^1.0.9: + version "1.0.10" + resolved "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz" + integrity sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw== + dependencies: + call-bind "^1.0.8" + define-properties "^1.2.1" + es-abstract "^1.23.9" + es-errors "^1.3.0" + es-object-atoms "^1.0.0" + get-intrinsic "^1.2.7" + get-proto "^1.0.1" + which-builtin-type "^1.2.1" + +regenerate-unicode-properties@^10.2.2: + version "10.2.2" + resolved "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz" + integrity sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g== + dependencies: + regenerate "^1.4.2" + +regenerate@^1.4.2: + version "1.4.2" + resolved "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz" + integrity sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A== + +regenerator-runtime@^0.13.2: + version "0.13.11" + resolved "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz" + integrity sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg== + +regexp.prototype.flags@^1.5.3, regexp.prototype.flags@^1.5.4: + version "1.5.4" + resolved "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz" + integrity sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA== + dependencies: + call-bind "^1.0.8" + define-properties "^1.2.1" + es-errors "^1.3.0" + get-proto "^1.0.1" + gopd "^1.2.0" + set-function-name "^2.0.2" + +regexpu-core@^6.3.1: + version "6.4.0" + resolved "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz" + integrity sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA== + dependencies: + regenerate "^1.4.2" + regenerate-unicode-properties "^10.2.2" + regjsgen "^0.8.0" + regjsparser "^0.13.0" + unicode-match-property-ecmascript "^2.0.0" + unicode-match-property-value-ecmascript "^2.2.1" + +regjsgen@^0.8.0: + version "0.8.0" + resolved "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz" + integrity sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q== + +regjsparser@^0.13.0: + version "0.13.0" + resolved "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.0.tgz" + integrity sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q== + dependencies: + jsesc "~3.1.0" + +require-directory@^2.1.1: + version "2.1.1" + resolved "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz" + integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== + +require-main-filename@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz" + integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg== + +resolve-from@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz" + integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== + +resolve-from@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz" + integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== + +resolve@^1.22.10: + version "1.22.11" + resolved "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz" + integrity sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ== + dependencies: + is-core-module "^2.16.1" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + +resolve@^2.0.0-next.5: + version "2.0.0-next.5" + resolved "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz" + integrity sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA== + dependencies: + is-core-module "^2.13.0" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + +restore-cursor@^3.1.0: + version "3.1.0" + resolved "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz" + integrity sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA== + dependencies: + onetime "^5.1.0" + signal-exit "^3.0.2" + +reusify@^1.0.4: + version "1.1.0" + resolved "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz" + integrity sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw== + +rimraf@^3.0.2: + version "3.0.2" + resolved "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz" + integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== + dependencies: + glob "^7.1.3" + +rn-fetch-blob@^0.12.0: + version "0.12.0" + resolved "https://registry.npmjs.org/rn-fetch-blob/-/rn-fetch-blob-0.12.0.tgz" + integrity sha512-+QnR7AsJ14zqpVVUbzbtAjq0iI8c9tCg49tIoKO2ezjzRunN7YL6zFSFSWZm6d+mE/l9r+OeDM3jmb2tBb2WbA== + dependencies: + base-64 "0.1.0" + glob "7.0.6" + +run-parallel@^1.1.9: + version "1.2.0" + resolved "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz" + integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA== + dependencies: + queue-microtask "^1.2.2" + +safe-array-concat@^1.1.3: + version "1.1.3" + resolved "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz" + integrity sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.2" + get-intrinsic "^1.2.6" + has-symbols "^1.1.0" + isarray "^2.0.5" + +safe-buffer@~5.2.0, safe-buffer@5.2.1: + version "5.2.1" + resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + +safe-push-apply@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz" + integrity sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA== + dependencies: + es-errors "^1.3.0" + isarray "^2.0.5" + +safe-regex-test@^1.1.0: + version "1.1.0" + resolved "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz" + integrity sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + is-regex "^1.2.1" + +"safer-buffer@>= 2.1.2 < 3": + version "2.1.2" + resolved "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz" + integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + +scheduler@0.27.0: + version "0.27.0" + resolved "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz" + integrity sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q== + +semver@^6.3.0, semver@^6.3.1: + version "6.3.1" + resolved "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz" + integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== + +semver@^7.1.3: + version "7.7.3" + resolved "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz" + integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q== + +semver@^7.5.2: + version "7.7.3" + resolved "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz" + integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q== + +semver@^7.6.0: + version "7.7.3" + resolved "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz" + integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q== + +semver@^7.7.3: + version "7.7.3" + resolved "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz" + integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q== + +semver@7.7.3: + version "7.7.3" + resolved "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz" + integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q== + +send@~0.19.1: + version "0.19.2" + resolved "https://registry.npmjs.org/send/-/send-0.19.2.tgz" + integrity sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg== + dependencies: + debug "2.6.9" + depd "2.0.0" + destroy "1.2.0" + encodeurl "~2.0.0" + escape-html "~1.0.3" + etag "~1.8.1" + fresh "~0.5.2" + http-errors "~2.0.1" + mime "1.6.0" + ms "2.1.3" + on-finished "~2.4.1" + range-parser "~1.2.1" + statuses "~2.0.2" + +serialize-error@^2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/serialize-error/-/serialize-error-2.1.0.tgz" + integrity sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw== + +serve-static@^1.13.1, serve-static@^1.16.2: + version "1.16.3" + resolved "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz" + integrity sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA== + dependencies: + encodeurl "~2.0.0" + escape-html "~1.0.3" + parseurl "~1.3.3" + send "~0.19.1" + +set-blocking@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz" + integrity sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw== + +set-function-length@^1.2.2: + version "1.2.2" + resolved "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz" + integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg== + dependencies: + define-data-property "^1.1.4" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + gopd "^1.0.1" + has-property-descriptors "^1.0.2" + +set-function-name@^2.0.2: + version "2.0.2" + resolved "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz" + integrity sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ== + dependencies: + define-data-property "^1.1.4" + es-errors "^1.3.0" + functions-have-names "^1.2.3" + has-property-descriptors "^1.0.2" + +set-proto@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz" + integrity sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw== + dependencies: + dunder-proto "^1.0.1" + es-errors "^1.3.0" + es-object-atoms "^1.0.0" + +setprototypeof@~1.2.0: + version "1.2.0" + resolved "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz" + integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== + +sf-symbols-typescript@^2.1.0: + version "2.2.0" + resolved "https://registry.npmjs.org/sf-symbols-typescript/-/sf-symbols-typescript-2.2.0.tgz" + integrity sha512-TPbeg0b7ylrswdGCji8FRGFAKuqbpQlLbL8SOle3j1iHSs5Ob5mhvMAxWN2UItOjgALAB5Zp3fmMfj8mbWvXKw== + +shebang-command@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz" + integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== + dependencies: + shebang-regex "^3.0.0" + +shebang-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz" + integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== + +shell-quote@^1.6.1, shell-quote@^1.8.3: + version "1.8.3" + resolved "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz" + integrity sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw== + +side-channel-list@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz" + integrity sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.3" + +side-channel-map@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz" + integrity sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + +side-channel-weakmap@^1.0.2: + version "1.0.2" + resolved "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz" + integrity sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + side-channel-map "^1.0.1" + +side-channel@^1.1.0: + version "1.1.0" + resolved "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz" + integrity sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.3" + side-channel-list "^1.0.0" + side-channel-map "^1.0.1" + side-channel-weakmap "^1.0.2" + +signal-exit@^3.0.2, signal-exit@^3.0.3, signal-exit@^3.0.7: + version "3.0.7" + resolved "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz" + integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== + +simple-swizzle@^0.2.2: + version "0.2.4" + resolved "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz" + integrity sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw== + dependencies: + is-arrayish "^0.3.1" + +sisteransi@^1.0.5: + version "1.0.5" + resolved "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz" + integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg== + +slash@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz" + integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== + +slice-ansi@^2.0.0: + version "2.1.0" + resolved "https://registry.npmjs.org/slice-ansi/-/slice-ansi-2.1.0.tgz" + integrity sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ== + dependencies: + ansi-styles "^3.2.0" + astral-regex "^1.0.0" + is-fullwidth-code-point "^2.0.0" + +smol-toml@^1.5.2: + version "1.6.0" + resolved "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.0.tgz" + integrity sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw== + +source-map-support@~0.5.20: + version "0.5.21" + resolved "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz" + integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w== + dependencies: + buffer-from "^1.0.0" + source-map "^0.6.0" + +source-map@^0.5.6: + version "0.5.7" + resolved "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz" + integrity sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ== + +source-map@^0.6.0: + version "0.6.1" + resolved "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz" + integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== + +split-on-first@^1.0.0: + version "1.1.0" + resolved "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz" + integrity sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw== + +sprintf-js@~1.0.2: + version "1.0.3" + resolved "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz" + integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== + +stack-utils@^2.0.3: + version "2.0.6" + resolved "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz" + integrity sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ== + dependencies: + escape-string-regexp "^2.0.0" + +stackframe@^1.3.4: + version "1.3.4" + resolved "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz" + integrity sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw== + +stacktrace-parser@^0.1.10: + version "0.1.11" + resolved "https://registry.npmjs.org/stacktrace-parser/-/stacktrace-parser-0.1.11.tgz" + integrity sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg== + dependencies: + type-fest "^0.7.1" + +statuses@~1.5.0: + version "1.5.0" + resolved "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz" + integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA== + +statuses@~2.0.2: + version "2.0.2" + resolved "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz" + integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw== + +stop-iteration-iterator@^1.1.0: + version "1.1.0" + resolved "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz" + integrity sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ== + dependencies: + es-errors "^1.3.0" + internal-slot "^1.1.0" + +strict-uri-encode@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz" + integrity sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ== + +string_decoder@^1.1.1: + version "1.3.0" + resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz" + integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== + dependencies: + safe-buffer "~5.2.0" + +string-natural-compare@^3.0.1: + version "3.0.1" + resolved "https://registry.npmjs.org/string-natural-compare/-/string-natural-compare-3.0.1.tgz" + integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw== + +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string.prototype.matchall@^4.0.12: + version "4.0.12" + resolved "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz" + integrity sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.3" + define-properties "^1.2.1" + es-abstract "^1.23.6" + es-errors "^1.3.0" + es-object-atoms "^1.0.0" + get-intrinsic "^1.2.6" + gopd "^1.2.0" + has-symbols "^1.1.0" + internal-slot "^1.1.0" + regexp.prototype.flags "^1.5.3" + set-function-name "^2.0.2" + side-channel "^1.1.0" + +string.prototype.repeat@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz" + integrity sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.17.5" + +string.prototype.trim@^1.2.10: + version "1.2.10" + resolved "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz" + integrity sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.2" + define-data-property "^1.1.4" + define-properties "^1.2.1" + es-abstract "^1.23.5" + es-object-atoms "^1.0.0" + has-property-descriptors "^1.0.2" + +string.prototype.trimend@^1.0.9: + version "1.0.9" + resolved "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz" + integrity sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.2" + define-properties "^1.2.1" + es-object-atoms "^1.0.0" + +string.prototype.trimstart@^1.0.8: + version "1.0.8" + resolved "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz" + integrity sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-object-atoms "^1.0.0" + +strip-ansi@^5.0.0: + version "5.2.0" + resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz" + integrity sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA== + dependencies: + ansi-regex "^4.1.0" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-final-newline@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz" + integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA== + +strip-json-comments@^3.1.1: + version "3.1.1" + resolved "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz" + integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== + +strip-json-comments@5.0.3: + version "5.0.3" + resolved "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz" + integrity sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw== + +strnum@^1.1.1: + version "1.1.2" + resolved "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz" + integrity sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA== + +supports-color@^7.1.0: + version "7.2.0" + resolved "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz" + integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== + dependencies: + has-flag "^4.0.0" + +supports-color@^8.0.0: + version "8.1.1" + resolved "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz" + integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== + dependencies: + has-flag "^4.0.0" + +supports-preserve-symlinks-flag@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz" + integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== + +synckit@^0.11.12: + version "0.11.12" + resolved "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz" + integrity sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ== + dependencies: + "@pkgr/core" "^0.2.9" + +terser@^5.15.0: + version "5.46.0" + resolved "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz" + integrity sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg== + dependencies: + "@jridgewell/source-map" "^0.3.3" + acorn "^8.15.0" + commander "^2.20.0" + source-map-support "~0.5.20" + +test-exclude@^6.0.0: + version "6.0.0" + resolved "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz" + integrity sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w== + dependencies: + "@istanbuljs/schema" "^0.1.2" + glob "^7.1.4" + minimatch "^3.0.4" + +text-table@^0.2.0: + version "0.2.0" + resolved "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz" + integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw== + +throat@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/throat/-/throat-5.0.0.tgz" + integrity sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA== + +tinyglobby@^0.2.15: + version "0.2.15" + resolved "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz" + integrity sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ== + dependencies: + fdir "^6.5.0" + picomatch "^4.0.3" + +tmpl@1.0.5: + version "1.0.5" + resolved "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz" + integrity sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw== + +to-regex-range@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz" + integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== + dependencies: + is-number "^7.0.0" + +toidentifier@~1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz" + integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== + +ts-api-utils@^1.3.0: + version "1.4.3" + resolved "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz" + integrity sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw== + +ts-api-utils@^2.4.0: + version "2.4.0" + resolved "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz" + integrity sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA== + +tslib@^2.4.0: + version "2.8.1" + resolved "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + +type-check@^0.4.0, type-check@~0.4.0: + version "0.4.0" + resolved "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz" + integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew== + dependencies: + prelude-ls "^1.2.1" + +type-detect@4.0.8: + version "4.0.8" + resolved "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz" + integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== + +type-fest@^0.20.2: + version "0.20.2" + resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz" + integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== + +type-fest@^0.7.1: + version "0.7.1" + resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.7.1.tgz" + integrity sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg== + +type-is@~1.6.18: + version "1.6.18" + resolved "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz" + integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== + dependencies: + media-typer "0.3.0" + mime-types "~2.1.24" + +typed-array-buffer@^1.0.3: + version "1.0.3" + resolved "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz" + integrity sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw== + dependencies: + call-bound "^1.0.3" + es-errors "^1.3.0" + is-typed-array "^1.1.14" + +typed-array-byte-length@^1.0.3: + version "1.0.3" + resolved "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz" + integrity sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg== + dependencies: + call-bind "^1.0.8" + for-each "^0.3.3" + gopd "^1.2.0" + has-proto "^1.2.0" + is-typed-array "^1.1.14" + +typed-array-byte-offset@^1.0.4: + version "1.0.4" + resolved "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz" + integrity sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ== + dependencies: + available-typed-arrays "^1.0.7" + call-bind "^1.0.8" + for-each "^0.3.3" + gopd "^1.2.0" + has-proto "^1.2.0" + is-typed-array "^1.1.15" + reflect.getprototypeof "^1.0.9" + +typed-array-length@^1.0.7: + version "1.0.7" + resolved "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz" + integrity sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg== + dependencies: + call-bind "^1.0.7" + for-each "^0.3.3" + gopd "^1.0.1" + is-typed-array "^1.1.13" + possible-typed-array-names "^1.0.0" + reflect.getprototypeof "^1.0.6" + +typescript@>=4.2.0, typescript@>=4.8.4, "typescript@>=4.8.4 <6.0.0", typescript@>=4.9.5, "typescript@>=5.0.4 <7", typescript@~5.9.2: + version "5.9.3" + resolved "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz" + integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== + +unbox-primitive@^1.1.0: + version "1.1.0" + resolved "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz" + integrity sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw== + dependencies: + call-bound "^1.0.3" + has-bigints "^1.0.2" + has-symbols "^1.1.0" + which-boxed-primitive "^1.1.1" + +undici-types@~7.16.0: + version "7.16.0" + resolved "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz" + integrity sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw== + +unicode-canonical-property-names-ecmascript@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz" + integrity sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg== + +unicode-match-property-ecmascript@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz" + integrity sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q== + dependencies: + unicode-canonical-property-names-ecmascript "^2.0.0" + unicode-property-aliases-ecmascript "^2.0.0" + +unicode-match-property-value-ecmascript@^2.2.1: + version "2.2.1" + resolved "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz" + integrity sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg== + +unicode-property-aliases-ecmascript@^2.0.0: + version "2.2.0" + resolved "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz" + integrity sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ== + +universalify@^0.1.0: + version "0.1.2" + resolved "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz" + integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== + +unpipe@~1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz" + integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== + +update-browserslist-db@^1.2.0: + version "1.2.3" + resolved "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz" + integrity sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w== + dependencies: + escalade "^3.2.0" + picocolors "^1.1.1" + +uri-js@^4.2.2: + version "4.4.1" + resolved "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz" + integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== + dependencies: + punycode "^2.1.0" + +use-latest-callback@^0.2.4: + version "0.2.6" + resolved "https://registry.npmjs.org/use-latest-callback/-/use-latest-callback-0.2.6.tgz" + integrity sha512-FvRG9i1HSo0wagmX63Vrm8SnlUU3LMM3WyZkQ76RnslpBrX694AdG4A0zQBx2B3ZifFA0yv/BaEHGBnEax5rZg== + +use-sync-external-store@^1.5.0, use-sync-external-store@>=1.2.0: + version "1.6.0" + resolved "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz" + integrity sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w== + +utf8@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/utf8/-/utf8-3.0.0.tgz" + integrity sha512-E8VjFIQ/TyQgp+TZfS6l8yp/xWppSAHzidGiRrqe4bK4XP9pTRyKFgGJpO3SN7zdX4DeomTrwaseCHovfpFcqQ== + +util-deprecate@^1.0.1: + version "1.0.2" + resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz" + integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== + +utils-merge@1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz" + integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== + +vary@~1.1.2: + version "1.1.2" + resolved "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz" + integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== + +vlq@^1.0.0: + version "1.0.1" + resolved "https://registry.npmjs.org/vlq/-/vlq-1.0.1.tgz" + integrity sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w== + +walk-up-path@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/walk-up-path/-/walk-up-path-4.0.0.tgz" + integrity sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A== + +walker@^1.0.7, walker@^1.0.8: + version "1.0.8" + resolved "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz" + integrity sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ== + dependencies: + makeerror "1.0.12" + +warn-once@^0.1.0, warn-once@^0.1.1: + version "0.1.1" + resolved "https://registry.npmjs.org/warn-once/-/warn-once-0.1.1.tgz" + integrity sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q== + +wcwidth@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz" + integrity sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg== + dependencies: + defaults "^1.0.3" + +whatwg-fetch@^3.0.0: + version "3.6.20" + resolved "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz" + integrity sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg== + +which-boxed-primitive@^1.1.0, which-boxed-primitive@^1.1.1: + version "1.1.1" + resolved "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz" + integrity sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA== + dependencies: + is-bigint "^1.1.0" + is-boolean-object "^1.2.1" + is-number-object "^1.1.1" + is-string "^1.1.1" + is-symbol "^1.1.1" + +which-builtin-type@^1.2.1: + version "1.2.1" + resolved "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz" + integrity sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q== + dependencies: + call-bound "^1.0.2" + function.prototype.name "^1.1.6" + has-tostringtag "^1.0.2" + is-async-function "^2.0.0" + is-date-object "^1.1.0" + is-finalizationregistry "^1.1.0" + is-generator-function "^1.0.10" + is-regex "^1.2.1" + is-weakref "^1.0.2" + isarray "^2.0.5" + which-boxed-primitive "^1.1.0" + which-collection "^1.0.2" + which-typed-array "^1.1.16" + +which-collection@^1.0.2: + version "1.0.2" + resolved "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz" + integrity sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw== + dependencies: + is-map "^2.0.3" + is-set "^2.0.3" + is-weakmap "^2.0.2" + is-weakset "^2.0.3" + +which-module@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz" + integrity sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ== + +which-typed-array@^1.1.16, which-typed-array@^1.1.19: + version "1.1.20" + resolved "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz" + integrity sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg== + dependencies: + available-typed-arrays "^1.0.7" + call-bind "^1.0.8" + call-bound "^1.0.4" + for-each "^0.3.5" + get-proto "^1.0.1" + gopd "^1.2.0" + has-tostringtag "^1.0.2" + +which@^2.0.1: + version "2.0.2" + resolved "https://registry.npmjs.org/which/-/which-2.0.2.tgz" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + +word-wrap@^1.2.5: + version "1.2.5" + resolved "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz" + integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== + +wrap-ansi@^6.2.0: + version "6.2.0" + resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz" + integrity sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrappy@1: + version "1.0.2" + resolved "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz" + integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== + +write-file-atomic@^4.0.2: + version "4.0.2" + resolved "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz" + integrity sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg== + dependencies: + imurmurhash "^0.1.4" + signal-exit "^3.0.7" + +ws@^6.2.3: + version "6.2.3" + resolved "https://registry.npmjs.org/ws/-/ws-6.2.3.tgz" + integrity sha512-jmTjYU0j60B+vHey6TfR3Z7RD61z/hmxBS3VMSGIrroOWXQEneK1zNuotOUrGyBHQj0yrpsLHPWtigEFd13ndA== + dependencies: + async-limiter "~1.0.0" + +ws@^7: + version "7.5.10" + resolved "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz" + integrity sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ== + +ws@^7.5.10: + version "7.5.10" + resolved "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz" + integrity sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ== + +y18n@^4.0.0: + version "4.0.3" + resolved "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz" + integrity sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ== + +y18n@^5.0.5: + version "5.0.8" + resolved "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz" + integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== + +yallist@^3.0.2: + version "3.1.1" + resolved "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz" + integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== + +yaml@^2.2.1, yaml@^2.6.1: + version "2.8.2" + resolved "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz" + integrity sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A== + +yargs-parser@^18.1.2: + version "18.1.3" + resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz" + integrity sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ== + dependencies: + camelcase "^5.0.0" + decamelize "^1.2.0" + +yargs-parser@^20.2.2: + version "20.2.9" + resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz" + integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w== + +yargs-parser@^21.1.1: + version "21.1.1" + resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz" + integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== + +yargs@^15.1.0: + version "15.4.1" + resolved "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz" + integrity sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A== + dependencies: + cliui "^6.0.0" + decamelize "^1.2.0" + find-up "^4.1.0" + get-caller-file "^2.0.1" + require-directory "^2.1.1" + require-main-filename "^2.0.0" + set-blocking "^2.0.0" + string-width "^4.2.0" + which-module "^2.0.0" + y18n "^4.0.0" + yargs-parser "^18.1.2" + +yargs@^16.1.1: + version "16.2.0" + resolved "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz" + integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw== + dependencies: + cliui "^7.0.2" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.0" + y18n "^5.0.5" + yargs-parser "^20.2.2" + +yargs@^17.6.2: + version "17.7.2" + resolved "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz" + integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== + dependencies: + cliui "^8.0.1" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.3" + y18n "^5.0.5" + yargs-parser "^21.1.1" + +yocto-queue@^0.1.0: + version "0.1.0" + resolved "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz" + integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + +"zod-validation-error@^3.5.0 || ^4.0.0": + version "4.0.2" + resolved "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz" + integrity sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ== + +"zod@^3.25.0 || ^4.0.0", zod@^4.1.11: + version "4.3.5" + resolved "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz" + integrity sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g== + +zustand@^5.0.0: + version "5.0.10" + resolved "https://registry.npmjs.org/zustand/-/zustand-5.0.10.tgz" + integrity sha512-U1AiltS1O9hSy3rul+Ub82ut2fqIAefiSuwECWt6jlMVUGejvf+5omLcRBSzqbRagSM3hQZbtzdeRc6QVScXTg== diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 000000000..2c1655bfa --- /dev/null +++ b/gradle.properties @@ -0,0 +1,13 @@ +# AndroidX Properties +android.useAndroidX=true +android.enableJetifier=true +# Build optimizations +org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8 +org.gradle.parallel=true +org.gradle.caching=true +org.gradle.configureondemand=true +# Kotlin +kotlin.code.style=official +# KMP configuration +kotlin.mpp.applyDefaultHierarchyTemplate=false +runanywhere.testLocal=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 000000000..a49c960b2 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,358 @@ +[versions] +# ============================================================================ +# Build Tools & Language +# ============================================================================ +agp = "8.11.2" +kotlin = "2.1.21" + +# ============================================================================ +# AndroidX Core Libraries +# ============================================================================ +coreKtx = "1.15.0" +appcompat = "1.7.0" +material = "1.13.0" +lifecycleRuntimeKtx = "2.8.7" +lifecycleViewModelCompose = "2.8.7" +activityCompose = "1.9.3" + +# ============================================================================ +# Jetpack Composeios h +# ============================================================================ +composeBom = "2025.08.01" + +# ============================================================================ +# Kotlin Libraries +# ============================================================================ +coroutines = "1.10.2" +datetime = "0.7.1" +kotlinxSerialization = "1.8.0" + +# ============================================================================ +# Testing +# ============================================================================ +junit = "4.13.2" +junitVersion = "1.3.0" +espressoCore = "3.7.0" +mockito = "6.0.0" +mockk = "1.13.14" + +# ============================================================================ +# Logging +# ============================================================================ +slf4j = "2.0.16" +logback = "1.5.12" +timber = "5.0.1" + +# ============================================================================ +# Error Tracking +# ============================================================================ +sentry = "8.0.0" + +# ============================================================================ +# Networking +# ============================================================================ +okhttp = "4.12.0" +retrofit = "2.11.0" +ktor = "3.0.3" + +# ============================================================================ +# JSON & Serialization +# ============================================================================ +gson = "2.11.0" + +# ============================================================================ +# Navigation +# ============================================================================ +navigationCompose = "2.8.5" + +# ============================================================================ +# Code Quality +# ============================================================================ +detekt = "1.23.8" +ktlint = "12.1.2" + +# ============================================================================ +# IDE Plugin Development +# ============================================================================ +intellij = "1.17.4" + +# ============================================================================ +# Dependency Injection +# ============================================================================ +hilt = "2.52" +hiltNavigationCompose = "1.2.0" + +# ============================================================================ +# Background Processing +# ============================================================================ +workManager = "2.10.0" + +# ============================================================================ +# File Management +# ============================================================================ +commonsIo = "2.18.0" +commonsCompress = "1.27.1" +okio = "3.9.0" + +# ============================================================================ +# AI/ML - Speech Recognition +# ============================================================================ +whisperJni = "1.7.1" + +# ============================================================================ +# AI/ML - Voice Activity Detection +# ============================================================================ +androidVad = "2.0.10" + +# ============================================================================ +# Networking - Download Manager +# ============================================================================ +prdownloader = "1.0.2" + +# ============================================================================ +# Android Core +# ============================================================================ +androidCore = "4.1.1.4" + +# ============================================================================ +# Database - Room +# ============================================================================ +room = "2.6.1" + +# ============================================================================ +# Database - SQLDelight (Cross-platform) +# ============================================================================ +sqldelight = "2.0.1" + +# ============================================================================ +# Security +# ============================================================================ +securityCrypto = "1.1.0-alpha06" + +# ============================================================================ +# DataStore +# ============================================================================ +datastore = "1.1.7" + +# ============================================================================ +# Play Services (Updated for targetSdk 34+) +# ============================================================================ +playAppUpdate = "2.1.0" +playAppUpdateKtx = "2.1.0" + +# ============================================================================ +# UI Components - Accompanist +# ============================================================================ +accompanist = "0.36.0" + +# ============================================================================ +# LIBRARIES +# ============================================================================ + +[libraries] +# ---------------------------------------------------------------------------- +# AndroidX Core +# ---------------------------------------------------------------------------- +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleViewModelCompose" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } + +# ---------------------------------------------------------------------------- +# Material Design +# ---------------------------------------------------------------------------- +material = { group = "com.google.android.material", name = "material", version.ref = "material" } + +# ---------------------------------------------------------------------------- +# Jetpack Compose +# ---------------------------------------------------------------------------- +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } +androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } +androidx-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } + +# ---------------------------------------------------------------------------- +# Navigation +# ---------------------------------------------------------------------------- +androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } + +# ---------------------------------------------------------------------------- +# Kotlin Coroutines +# ---------------------------------------------------------------------------- +kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" } +kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } + +# ---------------------------------------------------------------------------- +# Kotlin DateTime +# ---------------------------------------------------------------------------- +kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version.ref = "datetime" } + +# ---------------------------------------------------------------------------- +# Kotlin Serialization +# ---------------------------------------------------------------------------- +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" } + +# ---------------------------------------------------------------------------- +# Logging +# ---------------------------------------------------------------------------- +slf4j-api = { group = "org.slf4j", name = "slf4j-api", version.ref = "slf4j" } +logback-classic = { group = "ch.qos.logback", name = "logback-classic", version.ref = "logback" } +timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" } + +# ---------------------------------------------------------------------------- +# Error Tracking +# ---------------------------------------------------------------------------- +sentry = { group = "io.sentry", name = "sentry", version.ref = "sentry" } + +# ---------------------------------------------------------------------------- +# Networking - OkHttp & Retrofit +# ---------------------------------------------------------------------------- +okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } +okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" } +retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } +retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" } + +# ---------------------------------------------------------------------------- +# Networking - Ktor Client +# ---------------------------------------------------------------------------- +ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" } +ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" } +ktor-client-serialization = { group = "io.ktor", name = "ktor-client-serialization", version.ref = "ktor" } +ktor-client-logging = { group = "io.ktor", name = "ktor-client-logging", version.ref = "ktor" } +ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" } +ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" } +ktor-client-darwin = { group = "io.ktor", name = "ktor-client-darwin", version.ref = "ktor" } + +# ---------------------------------------------------------------------------- +# JSON +# ---------------------------------------------------------------------------- +gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } + +# ---------------------------------------------------------------------------- +# Testing +# ---------------------------------------------------------------------------- +junit = { group = "junit", name = "junit", version.ref = "junit" } +androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } +androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } +mockito-kotlin = { group = "org.mockito.kotlin", name = "mockito-kotlin", version.ref = "mockito" } +mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } + +# ---------------------------------------------------------------------------- +# Dependency Injection - Hilt +# ---------------------------------------------------------------------------- +hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } +hilt-android-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" } +hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" } + +# ---------------------------------------------------------------------------- +# Background Processing - WorkManager +# ---------------------------------------------------------------------------- +androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workManager" } + +# ---------------------------------------------------------------------------- +# File Management +# ---------------------------------------------------------------------------- +commons-io = { group = "commons-io", name = "commons-io", version.ref = "commonsIo" } +commons-compress = { group = "org.apache.commons", name = "commons-compress", version.ref = "commonsCompress" } +okio = { group = "com.squareup.okio", name = "okio", version.ref = "okio" } +okio-fakefilesystem = { group = "com.squareup.okio", name = "okio-fakefilesystem", version.ref = "okio" } + +# ---------------------------------------------------------------------------- +# AI/ML - Speech Recognition +# ---------------------------------------------------------------------------- +whisper-jni = { group = "io.github.givimad", name = "whisper-jni", version.ref = "whisperJni" } + +# ---------------------------------------------------------------------------- +# AI/ML - Voice Activity Detection +# ---------------------------------------------------------------------------- +android-vad-webrtc = { group = "com.github.gkonovalov.android-vad", name = "webrtc", version.ref = "androidVad" } + +# ---------------------------------------------------------------------------- +# Networking - Download Manager +# ---------------------------------------------------------------------------- +prdownloader = { group = "com.github.amitshekhariitbhu", name = "PRDownloader", version.ref = "prdownloader" } + +# ---------------------------------------------------------------------------- +# Android Core +# ---------------------------------------------------------------------------- +android-core = { group = "com.google.android", name = "android", version.ref = "androidCore" } + +# ---------------------------------------------------------------------------- +# Kotlin Standard Library +# ---------------------------------------------------------------------------- +kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlin" } + +# ---------------------------------------------------------------------------- +# Database - Room +# ---------------------------------------------------------------------------- +androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } +androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } +androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } + +# ---------------------------------------------------------------------------- +# Database - SQLDelight (Cross-platform) +# ---------------------------------------------------------------------------- +sqldelight-runtime = { group = "app.cash.sqldelight", name = "runtime", version.ref = "sqldelight" } +sqldelight-coroutines-extensions = { group = "app.cash.sqldelight", name = "coroutines-extensions", version.ref = "sqldelight" } +sqldelight-sqlite-driver = { group = "app.cash.sqldelight", name = "sqlite-driver", version.ref = "sqldelight" } +sqldelight-android-driver = { group = "app.cash.sqldelight", name = "android-driver", version.ref = "sqldelight" } +sqldelight-native-driver = { group = "app.cash.sqldelight", name = "native-driver", version.ref = "sqldelight" } + +# ---------------------------------------------------------------------------- +# Security +# ---------------------------------------------------------------------------- +androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" } + +# ---------------------------------------------------------------------------- +# DataStore +# ---------------------------------------------------------------------------- +androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } + +# ---------------------------------------------------------------------------- +# Play Services +# ---------------------------------------------------------------------------- +google-play-app-update = { group = "com.google.android.play", name = "app-update", version.ref = "playAppUpdate" } +google-play-app-update-ktx = { group = "com.google.android.play", name = "app-update-ktx", version.ref = "playAppUpdateKtx" } + +# ---------------------------------------------------------------------------- +# UI Components - Accompanist +# ---------------------------------------------------------------------------- +accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanist" } + +# ============================================================================ +# PLUGINS +# ============================================================================ + +[plugins] +# ---------------------------------------------------------------------------- +# Android +# ---------------------------------------------------------------------------- +android-application = { id = "com.android.application", version.ref = "agp" } +android-library = { id = "com.android.library", version.ref = "agp" } + +# ---------------------------------------------------------------------------- +# Kotlin +# ---------------------------------------------------------------------------- +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } + +# ---------------------------------------------------------------------------- +# Code Quality +# ---------------------------------------------------------------------------- +detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } +ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" } + +# ---------------------------------------------------------------------------- +# IDE Plugin Development +# ---------------------------------------------------------------------------- +intellij = { id = "org.jetbrains.intellij", version.ref = "intellij" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..980502d16 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..ed4c299ad --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 000000000..faf93008b --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 000000000..9d21a2183 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/jitpack.yml b/jitpack.yml new file mode 100644 index 000000000..e3319d753 --- /dev/null +++ b/jitpack.yml @@ -0,0 +1,31 @@ +# JitPack build configuration for RunAnywhere Kotlin SDK +# https://jitpack.io/docs/BUILDING/ +# +# The Kotlin SDK is in subdirectory: sdk/runanywhere-kotlin/ +# Build all modules (main SDK + LlamaCPP + ONNX backends) +# +# IMPORTANT: For Android libraries with native libs, we must: +# 1. Set up Android SDK (JitPack doesn't have it by default) +# 2. Download native libs FIRST (they're built by our CI/CD) +# 3. Build Android targets explicitly +# 4. Publish to Maven Local + +jdk: + - openjdk17 + +# Install Android SDK and NDK (required for AAR builds) +before_install: + - chmod +x sdk/runanywhere-kotlin/gradlew + - export ANDROID_HOME=$HOME/android-sdk + - mkdir -p $ANDROID_HOME/cmdline-tools + - wget -q https://dl.google.com/android/repository/commandlinetools-linux-9477386_latest.zip -O /tmp/cmdline-tools.zip + - unzip -q /tmp/cmdline-tools.zip -d $ANDROID_HOME/cmdline-tools + - mv $ANDROID_HOME/cmdline-tools/cmdline-tools $ANDROID_HOME/cmdline-tools/latest + - yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses > /dev/null 2>&1 || true + - $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "platforms;android-35" "platforms;android-36" "build-tools;35.0.0" > /dev/null + - echo "ANDROID_HOME=$HOME/android-sdk" >> $HOME/.bashrc + +install: + # Chain all commands so environment variables persist + # Use publishToMavenLocal (not publishAllPublicationsToMavenLocal which doesn't exist) + - export ANDROID_HOME=$HOME/android-sdk && export ANDROID_SDK_ROOT=$HOME/android-sdk && export PATH=$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools && cd sdk/runanywhere-kotlin && ./gradlew downloadJniLibs -Prunanywhere.testLocal=false --no-daemon && ./gradlew assembleRelease publishToMavenLocal -Prunanywhere.testLocal=false --no-daemon --info 2>&1 | tail -300 diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 000000000..3ac5730a0 --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,42 @@ +# EXAMPLE USAGE: +# +# Refer for explanation to following link: +# https://lefthook.dev/configuration/ +# +# pre-push: +# jobs: +# - name: packages audit +# tags: +# - frontend +# - security +# run: yarn audit +# +# - name: gems audit +# tags: +# - backend +# - security +# run: bundle audit +# +# pre-commit: +# parallel: true +# jobs: +# - run: yarn eslint {staged_files} +# glob: "*.{js,ts,jsx,tsx}" +# +# - name: rubocop +# glob: "*.rb" +# exclude: +# - config/application.rb +# - config/routes.rb +# run: bundle exec rubocop --force-exclusion {all_files} +# +# - name: govet +# files: git ls-files -m +# glob: "*.go" +# run: go vet {files} +# +# - script: "hello.js" +# runner: node +# +# - script: "hello.go" +# runner: go run diff --git a/scripts/lint-all.sh b/scripts/lint-all.sh new file mode 100755 index 000000000..df48c40b6 --- /dev/null +++ b/scripts/lint-all.sh @@ -0,0 +1,146 @@ +#!/bin/bash + +# Comprehensive linting script for all components +# This script runs all lint checks including TODO validation + +set -e # Exit on error + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +PROJECT_ROOT="$( cd "$SCRIPT_DIR/.." && pwd )" + +echo "🔍 Running comprehensive lint checks..." +echo + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Function to print colored output +print_status() { + if [ $1 -eq 0 ]; then + echo -e "${GREEN}✓ $2${NC}" + else + echo -e "${RED}✗ $2${NC}" + fi +} + +# Track overall status +OVERALL_STATUS=0 + +# Check for TODOs without issue references +echo "📝 Checking TODO comments..." +if grep -rEn "(//|/\*|#)\s*(TODO|FIXME|HACK|XXX|BUG|REFACTOR|OPTIMIZE)(?!.*#[0-9]+)" \ + --include="*.swift" \ + --include="*.kt" \ + --include="*.java" \ + --include="*.ts" \ + --include="*.tsx" \ + --include="*.js" \ + --include="*.jsx" \ + --include="*.py" \ + --include="*.rb" \ + --include="*.go" \ + --include="*.rs" \ + --include="*.cpp" \ + --include="*.c" \ + --include="*.h" \ + --include="*.hpp" \ + --include="*.cs" \ + --include="*.m" \ + --include="*.mm" \ + "$PROJECT_ROOT" 2>/dev/null | \ + grep -v ".git/" | \ + grep -v "node_modules/" | \ + grep -v ".build/" | \ + grep -v "build/" | \ + grep -v "DerivedData/" | \ + grep -v "scripts/lint-all.sh"; then + echo -e "${RED}ERROR: Found TODOs without GitHub issue references${NC}" + echo "All TODOs must reference an issue (e.g., // TODO: #123 - Description)" + OVERALL_STATUS=1 +else + print_status 0 "All TODOs have issue references" +fi +echo + +# iOS SDK Linting +echo "📱 iOS SDK Linting..." +if which swiftlint >/dev/null; then + cd "$PROJECT_ROOT/sdk/runanywhere-swift" + if swiftlint --strict --quiet; then + print_status 0 "iOS SDK SwiftLint passed" + else + print_status 1 "iOS SDK SwiftLint failed" + OVERALL_STATUS=1 + fi +else + echo -e "${YELLOW}⚠️ SwiftLint not installed, skipping iOS SDK lint${NC}" +fi +echo + +# iOS App Linting +echo "📱 iOS App Linting..." +if which swiftlint >/dev/null; then + cd "$PROJECT_ROOT/examples/ios/RunAnywhereAI" + if swiftlint --strict --quiet; then + print_status 0 "iOS App SwiftLint passed" + else + print_status 1 "iOS App SwiftLint failed" + OVERALL_STATUS=1 + fi +else + echo -e "${YELLOW}⚠️ SwiftLint not installed, skipping iOS App lint${NC}" +fi +echo + +# Android SDK Linting +echo "🤖 Android SDK Linting..." +cd "$PROJECT_ROOT/sdk/runanywhere-android" +if ./gradlew lint --quiet; then + print_status 0 "Android SDK lint passed" +else + print_status 1 "Android SDK lint failed" + OVERALL_STATUS=1 +fi + +# Android SDK Detekt +if ./gradlew detekt --quiet; then + print_status 0 "Android SDK Detekt passed" +else + print_status 1 "Android SDK Detekt failed" + OVERALL_STATUS=1 +fi +echo + +# Android App Linting +echo "🤖 Android App Linting..." +cd "$PROJECT_ROOT/examples/android/RunAnywhereAI" +if ./gradlew :app:lint --quiet; then + print_status 0 "Android App lint passed" +else + print_status 1 "Android App lint failed" + OVERALL_STATUS=1 +fi + +# Android App Detekt +if ./gradlew :app:detekt --quiet; then + print_status 0 "Android App Detekt passed" +else + print_status 1 "Android App Detekt failed" + OVERALL_STATUS=1 +fi +echo + +# Summary +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +if [ $OVERALL_STATUS -eq 0 ]; then + echo -e "${GREEN}✅ All lint checks passed!${NC}" +else + echo -e "${RED}❌ Some lint checks failed${NC}" + echo "Please fix the issues above before committing." +fi +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + +exit $OVERALL_STATUS diff --git a/scripts/lint-android.sh b/scripts/lint-android.sh new file mode 100755 index 000000000..05258f094 --- /dev/null +++ b/scripts/lint-android.sh @@ -0,0 +1,99 @@ +#!/bin/bash + +# Android-specific linting script + +set -e # Exit on error + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +PROJECT_ROOT="$( cd "$SCRIPT_DIR/.." && pwd )" + +echo "🤖 Running Android lint checks..." +echo + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Function to print colored output +print_status() { + if [ $1 -eq 0 ]; then + echo -e "${GREEN}✓ $2${NC}" + else + echo -e "${RED}✗ $2${NC}" + fi +} + +OVERALL_STATUS=0 + +# Check Android SDK +echo "📦 Linting Android SDK..." +cd "$PROJECT_ROOT/sdk/runanywhere-android" + +# Run Android Lint +echo "Running Android Lint..." +if ./gradlew lint; then + print_status 0 "Android SDK lint passed" +else + print_status 1 "Android SDK lint failed" + OVERALL_STATUS=1 + echo "Check the lint report at: sdk/runanywhere-android/build/reports/lint-results.html" +fi + +# Run Detekt +echo +echo "Running Detekt..." +if ./gradlew detekt; then + print_status 0 "Android SDK Detekt passed" +else + print_status 1 "Android SDK Detekt failed" + OVERALL_STATUS=1 + echo "Check the Detekt report at: sdk/runanywhere-android/build/reports/detekt/detekt.html" +fi + +echo +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + +# Check Android Example App +echo "📱 Linting Android Example App..." +cd "$PROJECT_ROOT/examples/android/RunAnywhereAI" + +# Run Android Lint +echo "Running Android Lint..." +if ./gradlew :app:lint; then + print_status 0 "Android App lint passed" +else + print_status 1 "Android App lint failed" + OVERALL_STATUS=1 + echo "Check the lint report at: examples/android/RunAnywhereAI/app/build/reports/lint-results.html" +fi + +# Run Detekt +echo +echo "Running Detekt..." +if ./gradlew :app:detekt; then + print_status 0 "Android App Detekt passed" +else + print_status 1 "Android App Detekt failed" + OVERALL_STATUS=1 + echo "Check the Detekt report at: examples/android/RunAnywhereAI/app/build/reports/detekt/detekt.html" +fi + +echo +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + +# Summary +if [ $OVERALL_STATUS -eq 0 ]; then + echo -e "${GREEN}✅ All Android lint checks passed!${NC}" +else + echo -e "${RED}❌ Android lint checks failed${NC}" + echo + echo "Fix suggestions:" + echo "1. For TODO issues: Add GitHub issue reference (e.g., // TODO: #123 - Description)" + echo "2. For lint issues: Check the HTML reports mentioned above" + echo "3. For Detekt issues: Some can be fixed with './gradlew detektBaseline'" + echo "4. Run './gradlew lint --fix' to auto-fix some issues" +fi + +exit $OVERALL_STATUS diff --git a/scripts/lint-ios.sh b/scripts/lint-ios.sh new file mode 100755 index 000000000..b3c8600fb --- /dev/null +++ b/scripts/lint-ios.sh @@ -0,0 +1,89 @@ +#!/bin/bash + +# iOS-specific linting script + +set -e # Exit on error + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +PROJECT_ROOT="$( cd "$SCRIPT_DIR/.." && pwd )" + +echo "🍎 Running iOS lint checks..." +echo + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Function to print colored output +print_status() { + if [ $1 -eq 0 ]; then + echo -e "${GREEN}✓ $2${NC}" + else + echo -e "${RED}✗ $2${NC}" + fi +} + +# Check if SwiftLint is installed +if ! which swiftlint >/dev/null; then + echo -e "${RED}ERROR: SwiftLint is not installed${NC}" + echo "Install using: brew install swiftlint" + exit 1 +fi + +OVERALL_STATUS=0 + +# Check iOS SDK +echo "📦 Linting iOS SDK..." +cd "$PROJECT_ROOT/sdk/runanywhere-swift" + +# Run SwiftLint +if swiftlint --strict; then + print_status 0 "iOS SDK passed all checks" +else + print_status 1 "iOS SDK has lint issues" + OVERALL_STATUS=1 +fi + +echo +echo "Detailed report:" +swiftlint --reporter json | jq -r '.[] | select(.severity == "error") | " ❌ \(.file):\(.line):\(.character) - \(.reason)"' 2>/dev/null || true +swiftlint --reporter json | jq -r '.[] | select(.severity == "warning") | " ⚠️ \(.file):\(.line):\(.character) - \(.reason)"' 2>/dev/null || true + +echo +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + +# Check iOS Example App +echo "📱 Linting iOS Example App..." +cd "$PROJECT_ROOT/examples/ios/RunAnywhereAI" + +# Run SwiftLint +if swiftlint --strict; then + print_status 0 "iOS App passed all checks" +else + print_status 1 "iOS App has lint issues" + OVERALL_STATUS=1 +fi + +echo +echo "Detailed report:" +swiftlint --reporter json | jq -r '.[] | select(.severity == "error") | " ❌ \(.file):\(.line):\(.character) - \(.reason)"' 2>/dev/null || true +swiftlint --reporter json | jq -r '.[] | select(.severity == "warning") | " ⚠️ \(.file):\(.line):\(.character) - \(.reason)"' 2>/dev/null || true + +echo +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + +# Summary +if [ $OVERALL_STATUS -eq 0 ]; then + echo -e "${GREEN}✅ All iOS lint checks passed!${NC}" +else + echo -e "${RED}❌ iOS lint checks failed${NC}" + echo + echo "Fix suggestions:" + echo "1. For TODO issues: Add GitHub issue reference (e.g., // TODO: #123 - Description)" + echo "2. For code style: Run 'swiftlint autocorrect' to fix some issues automatically" + echo "3. For complex issues: Check the detailed report above" +fi + +exit $OVERALL_STATUS diff --git a/scripts/release_ios_sdk.sh b/scripts/release_ios_sdk.sh new file mode 100755 index 000000000..f7237df4d --- /dev/null +++ b/scripts/release_ios_sdk.sh @@ -0,0 +1,618 @@ +#!/usr/bin/env bash +# RunAnywhere iOS SDK Release Script +# Combines best practices from both approaches: +# - Git worktree for clean tag commits (no spurious commits on main) +# - Portable sed for GNU/BSD compatibility +# - CLI flags for CI automation (--yes, --bump) +# - BuildToken.swift in tags only (via .gitignore) + +set -euo pipefail + +### Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +print_header() { echo -e "\n${BLUE}=== $* ===${NC}\n"; } +print_success() { echo -e "${GREEN}✓ $*${NC}"; } +print_error() { echo -e "${RED}✗ $*${NC}"; } +print_warning() { echo -e "${YELLOW}⚠ $*${NC}"; } +print_info() { echo -e "${BLUE}ℹ $*${NC}"; } + +### Configuration +SDK_DIR="sdk/runanywhere-swift" +VERSION_FILE="$SDK_DIR/VERSION" +CHANGELOG_FILE="$SDK_DIR/CHANGELOG.md" +README_ROOT="README.md" +README_SDK="$SDK_DIR/README.md" +DEV_CONFIG_REL_PATH="Sources/RunAnywhere/Foundation/Constants/DevelopmentConfig.swift" +DEV_CONFIG_ABS_PATH="$SDK_DIR/$DEV_CONFIG_REL_PATH" +SECRETS_FILE="scripts/.release-secrets" + +# These will be loaded from secrets file or environment +SUPABASE_URL="" +SUPABASE_ANON_KEY="" + +### Portable sed (GNU vs BSD) +sedi() { + if sed --version >/dev/null 2>&1; then + # GNU sed + sed -i "$@" + else + # BSD sed (macOS) + sed -i '' "$@" + fi +} + +### Load secrets from file or environment +load_secrets() { + print_header "Loading Release Secrets" + + # Try to load from secrets file first + if [[ -f "$SECRETS_FILE" ]]; then + print_info "Loading secrets from $SECRETS_FILE" + # Source the file to get variables (only SUPABASE_URL and SUPABASE_ANON_KEY) + # shellcheck source=/dev/null + source "$SECRETS_FILE" + fi + + # Environment variables override file values (useful for CI) + SUPABASE_URL="${SUPABASE_URL:-}" + SUPABASE_ANON_KEY="${SUPABASE_ANON_KEY:-}" + + # Validate required secrets + local missing_secrets=0 + + if [[ -z "$SUPABASE_URL" ]]; then + print_error "Missing SUPABASE_URL" + missing_secrets=1 + else + print_success "SUPABASE_URL: ${SUPABASE_URL:0:40}..." + fi + + if [[ -z "$SUPABASE_ANON_KEY" ]]; then + print_error "Missing SUPABASE_ANON_KEY" + missing_secrets=1 + else + print_success "SUPABASE_ANON_KEY: ${SUPABASE_ANON_KEY:0:20}..." + fi + + if [[ $missing_secrets -eq 1 ]]; then + echo "" + print_error "Missing required secrets!" + print_info "Option 1: Ensure scripts/.release-secrets exists with:" + echo " SUPABASE_URL=\"https://your-project.supabase.co\"" + echo " SUPABASE_ANON_KEY=\"your-anon-key\"" + echo "" + print_info "Option 2: Set environment variables:" + echo " export SUPABASE_URL=\"https://your-project.supabase.co\"" + echo " export SUPABASE_ANON_KEY=\"your-anon-key\"" + echo "" + exit 1 + fi +} + +### CLI flags +AUTO_YES=0 +BUMP_TYPE="" +SKIP_BUILD=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --yes|-y) + AUTO_YES=1 + shift + ;; + --bump) + BUMP_TYPE="${2:-}" + shift 2 + ;; + --skip-build) + SKIP_BUILD=1 + shift + ;; + --help|-h) + echo "Usage: $0 [OPTIONS]" + echo "" + echo "Options:" + echo " --yes, -y Auto-confirm prompts (for CI)" + echo " --bump TYPE Version bump type: major|minor|patch" + echo " --skip-build Skip build check (for testing)" + echo " --help, -h Show this help" + echo "" + echo "Secrets (required - use ONE of these methods):" + echo "" + echo " Method 1: Secrets file (for local dev)" + echo " Ensure scripts/.release-secrets exists with SUPABASE_URL and SUPABASE_ANON_KEY" + echo "" + echo " Method 2: Environment variables (for CI)" + echo " export SUPABASE_URL=\"https://your-project.supabase.co\"" + echo " export SUPABASE_ANON_KEY=\"your-anon-key\"" + echo "" + echo "Other Environment Variables:" + echo " DATABASE_URL PostgreSQL URL for auto-inserting build token" + echo "" + exit 0 + ;; + *) + print_warning "Unknown argument: $1 (use --help for usage)" + shift + ;; + esac +done + +### Validate preconditions +validate_preconditions() { + print_header "Validating Preconditions" + + # Must run at repo root + if [[ ! -f "Package.swift" || ! -d "$SDK_DIR" ]]; then + print_error "Must run from repository root (expected Package.swift and $SDK_DIR)" + exit 1 + fi + print_success "Running from repository root" + + # Git working directory must be clean + if [[ -n "$(git status --porcelain)" ]]; then + print_error "Git working directory is not clean" + print_info "Commit or stash your changes first" + git status --short + exit 1 + fi + print_success "Git working directory is clean" + + # Warn if not on main branch + CURRENT_BRANCH="$(git branch --show-current)" + if [[ "$CURRENT_BRANCH" != "main" ]]; then + print_warning "You are on branch '$CURRENT_BRANCH', not 'main'" + if [[ $AUTO_YES -ne 1 ]]; then + read -p "Continue anyway? (y/N): " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + print_info "Aborted by user" + exit 1 + fi + fi + else + print_success "On main branch" + fi + + # Check required tools + local required_tools=("gh" "git" "swift" "uuidgen") + for tool in "${required_tools[@]}"; do + if ! command -v "$tool" >/dev/null; then + print_error "Required tool not found: $tool" + exit 1 + fi + done + print_success "All required tools available" + + # Check GitHub CLI authentication + if ! gh auth status &>/dev/null; then + print_error "Not authenticated with GitHub CLI" + print_info "Run: gh auth login" + exit 1 + fi + print_success "Authenticated with GitHub CLI" + + # Check for psql if DATABASE_URL is set + if [[ -n "${DATABASE_URL:-}" && ! $(command -v psql) ]]; then + print_warning "DATABASE_URL is set but psql not found (will print SQL for manual execution)" + fi + + # Verify .gitignore contains DevelopmentConfig.swift + if ! grep -qF "$DEV_CONFIG_ABS_PATH" .gitignore 2>/dev/null; then + print_warning "DevelopmentConfig.swift not in .gitignore - add this line:" + print_info " $DEV_CONFIG_ABS_PATH" + fi +} + +### Get current version from VERSION file +get_current_version() { + if [[ -f "$VERSION_FILE" ]]; then + cat "$VERSION_FILE" + else + echo "0.14.0" # Fallback + fi +} + +### Calculate new version based on bump type +calculate_new_version() { + local current="$1" + local bump="$2" + + IFS='.' read -r major minor patch <<<"$current" + + case "$bump" in + major) + major=$((major + 1)) + minor=0 + patch=0 + ;; + minor) + minor=$((minor + 1)) + patch=0 + ;; + patch) + patch=$((patch + 1)) + ;; + *) + print_error "Invalid bump type: $bump (expected: major, minor, or patch)" + exit 1 + ;; + esac + + echo "$major.$minor.$patch" +} + +### Generate build token (format: bt__) +generate_build_token() { + local uuid + local timestamp + + uuid="$(uuidgen | tr '[:upper:]' '[:lower:]')" + timestamp="$(date +%s)" + + echo "bt_${uuid}_${timestamp}" +} + +### Generate DevelopmentConfig.swift file with all 3 values +generate_dev_config_file() { + local build_token="$1" + local output_file="$2" + + mkdir -p "$(dirname "$output_file")" + + cat > "$output_file" <_ +/// - Rotatable: Each release gets a new token +/// - Revocable: Backend can mark token as inactive +/// - Rate-limited: Backend enforces 100 req/min per device +enum DevelopmentConfig { + // MARK: - Supabase Configuration + + /// Supabase project URL for development device analytics + static let supabaseURL = "$SUPABASE_URL" + + /// Supabase anon/public API key + /// Note: Anon key is safe to include in client code - data access is controlled by RLS policies + // swiftlint:disable:next line_length + static let supabaseAnonKey = "$SUPABASE_ANON_KEY" + + // MARK: - Build Token + + /// Development mode build token + /// Generated at: $(date -u +"%Y-%m-%d %H:%M:%S UTC") + static let buildToken = "$build_token" +} +EOF + + print_success "Generated DevelopmentConfig.swift with all 3 values" +} + +### Update version references in files +update_version_references() { + local new_version="$1" + + print_header "Updating Version References" + + # Update VERSION file + echo "$new_version" > "$VERSION_FILE" + print_success "Updated $VERSION_FILE" + + # Update root README.md + if [[ -f "$README_ROOT" ]]; then + sedi "s/from: \"[0-9]*\.[0-9]*\.[0-9]*\"/from: \"$new_version\"/g" "$README_ROOT" + sedi "s/exact: \"[0-9]*\.[0-9]*\.[0-9]*\"/exact: \"$new_version\"/g" "$README_ROOT" + sedi "s/'RunAnywhere', '~> [0-9]*\.[0-9]*'/'RunAnywhere', '~> ${new_version%.*}'/g" "$README_ROOT" + sedi "s/'RunAnywhere', '[0-9]*\.[0-9]*\.[0-9]*'/'RunAnywhere', '$new_version'/g" "$README_ROOT" + print_success "Updated $README_ROOT" + fi + + # Update SDK README.md + if [[ -f "$README_SDK" ]]; then + sedi "s/from: \"[0-9]*\.[0-9]*\.[0-9]*\"/from: \"$new_version\"/g" "$README_SDK" + sedi "s/exact: \"[0-9]*\.[0-9]*\.[0-9]*\"/exact: \"$new_version\"/g" "$README_SDK" + print_success "Updated $README_SDK" + fi + + # Update CHANGELOG.md + if [[ -f "$CHANGELOG_FILE" ]]; then + local today + today="$(date +%Y-%m-%d)" + sedi "s/## \[Unreleased\]/## [Unreleased]\n\n## [$new_version] - $today/g" "$CHANGELOG_FILE" + print_success "Updated $CHANGELOG_FILE" + fi +} + +### Display SQL command for manual Supabase insertion +show_build_token_sql() { + local version="$1" + local build_token="$2" + + print_header "Manual Supabase Setup Required" + + echo "" + print_warning "⚠️ IMPORTANT: You must manually insert this build token into Supabase" + echo "" + print_info "Run this SQL command in your Supabase SQL Editor:" + echo "" + echo -e "${GREEN}-------------------------------------------------------------------${NC}" + echo -e "${YELLOW}INSERT INTO build_tokens (token, platform, label, is_active, notes)" + echo -e "VALUES (" + echo -e " '$build_token'," + echo -e " 'ios'," + echo -e " 'v$version'," + echo -e " TRUE," + echo -e " 'iOS SDK v$version - Released $(date +%Y-%m-%d)'" + echo -e ");${NC}" + echo -e "${GREEN}-------------------------------------------------------------------${NC}" + echo "" + echo "" + print_info "This ensures only valid SDKs can register devices to your database." + echo "" +} + +### Run tests +run_tests() { + if [[ $SKIP_BUILD -eq 1 ]]; then + print_warning "Skipping build check (--skip-build flag)" + return + fi + + print_header "Building Package" + + print_info "Running swift build..." + if swift build --target RunAnywhere; then + print_success "Package builds successfully" + else + print_error "Swift build failed" + exit 1 + fi + + # TODO: Add swift test when tests exist + # if swift test; then + # print_success "All tests passed" + # else + # print_error "Tests failed" + # exit 1 + # fi +} + +### Create GitHub release +create_github_release() { + local new_version="$1" + local tag_name="v$new_version" + + print_header "Creating GitHub Release" + + # Extract release notes from CHANGELOG + local release_notes="" + if [[ -f "$CHANGELOG_FILE" ]]; then + release_notes="$(sed -n "/## \[$new_version\]/,/^## \[/p" "$CHANGELOG_FILE" | sed '$d' | tail -n +2)" + fi + + # Fallback if no notes found + if [[ -z "$release_notes" ]]; then + release_notes="Release v$new_version" + fi + + # Create GitHub release + # SECURITY NOTE: DO NOT include build token in release notes (it's public) + print_info "Creating GitHub release..." + gh release create "$tag_name" \ + --title "RunAnywhere iOS SDK v$new_version" \ + --notes "$release_notes" \ + --latest + + print_success "GitHub release created: https://github.com/RunanywhereAI/sdks/releases/tag/$tag_name" +} + +### Main release process +main() { + print_header "RunAnywhere iOS SDK Release" + + # Validate everything first + validate_preconditions + + # Load secrets from file or environment + load_secrets + + # Get current version + local current_version + current_version="$(get_current_version)" + print_info "Current version: $current_version" + + # Determine bump type + if [[ -z "$BUMP_TYPE" ]]; then + echo "" + echo "Select version bump type:" + echo " 1) patch (bug fixes) - $current_version -> $(calculate_new_version "$current_version" "patch")" + echo " 2) minor (new features) - $current_version -> $(calculate_new_version "$current_version" "minor")" + echo " 3) major (breaking changes) - $current_version -> $(calculate_new_version "$current_version" "major")" + echo "" + + if [[ $AUTO_YES -ne 1 ]]; then + read -p "Enter choice (1-3): " choice + else + choice=1 # Default to patch in auto mode + fi + + case "${choice:-1}" in + 1) BUMP_TYPE="patch" ;; + 2) BUMP_TYPE="minor" ;; + 3) BUMP_TYPE="major" ;; + *) + print_error "Invalid choice" + exit 1 + ;; + esac + fi + + # Calculate new version + local new_version + new_version="$(calculate_new_version "$current_version" "$BUMP_TYPE")" + + # Confirm release + print_warning "About to release v$new_version (was $current_version)" + if [[ $AUTO_YES -ne 1 ]]; then + read -p "Continue? (y/N): " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + print_info "Release cancelled by user" + exit 0 + fi + fi + + # Generate build token + local build_token + build_token="$(generate_build_token)" + print_success "Generated build token: $build_token" + + # Show SQL command for manual Supabase insertion + show_build_token_sql "$new_version" "$build_token" + + # Run tests before making any changes + run_tests + + # Step 1: Update version files and commit to main + print_header "Step 1: Committing Version Updates to Main" + update_version_references "$new_version" + + local paths_to_add=("$VERSION_FILE") + [[ -f "$CHANGELOG_FILE" ]] && paths_to_add+=("$CHANGELOG_FILE") + [[ -f "$README_ROOT" ]] && paths_to_add+=("$README_ROOT") + [[ -f "$README_SDK" ]] && paths_to_add+=("$README_SDK") + + git add "${paths_to_add[@]}" + git commit -m "Release v$new_version + +- Updated version to $new_version +- Updated documentation +- See CHANGELOG.md for details" + + print_success "Committed version updates to main" + + # Step 2: Create worktree for tag commit (includes DevelopmentConfig.swift) + print_header "Step 2: Creating Release Tag with DevelopmentConfig.swift" + + local worktree_dir + worktree_dir="$(mktemp -d)/release-v$new_version" + local release_branch="release/v$new_version" + + # Create worktree + git worktree add -b "$release_branch" "$worktree_dir" + print_info "Created worktree at $worktree_dir" + + # Generate DevelopmentConfig.swift in worktree (contains all 3 values) + local worktree_config_path="$worktree_dir/$DEV_CONFIG_ABS_PATH" + generate_dev_config_file "$build_token" "$worktree_config_path" + + # Commit DevelopmentConfig.swift in worktree + pushd "$worktree_dir" >/dev/null + git add -f "$DEV_CONFIG_ABS_PATH" + git commit -m "Add DevelopmentConfig.swift for release v$new_version + +SECURITY: DevelopmentConfig.swift is in .gitignore and NOT in main branch. +This file is ONLY included in release tags for SPM distribution. + +Contains all 3 development mode values: +- Supabase URL: $SUPABASE_URL +- Supabase anon key: [included] +- Build token: $build_token + +Generated: $(date -u +"%Y-%m-%d %H:%M:%S UTC")" + + print_success "Committed DevelopmentConfig.swift in worktree" + + # Create annotated tag + local tag_name="v$new_version" + git tag -a "$tag_name" -m "Release v$new_version" + print_success "Created tag $tag_name" + + # Push tag to GitHub + git push origin "$tag_name" + print_success "Pushed tag to GitHub" + + popd >/dev/null + + # Clean up worktree + git worktree remove "$worktree_dir" --force + git branch -D "$release_branch" + print_success "Cleaned up worktree" + + # Step 3: Push main branch + print_header "Step 3: Pushing Main Branch" + git push origin HEAD + print_success "Pushed main branch" + + # Step 4: Create GitHub release + create_github_release "$new_version" + + # Success summary + print_header "Release Complete!" + print_success "Released v$new_version successfully" + print_success "Build token: $build_token" + echo "" + print_info "Main branch: Contains version updates (NO DevelopmentConfig.swift)" + print_info "Tag $tag_name: Contains DevelopmentConfig.swift with all 3 values:" + print_info " - Supabase URL: $SUPABASE_URL" + print_info " - Supabase anon key: [included]" + print_info " - Build token: $build_token" + print_info "SPM users downloading v$new_version will get the real configuration" + echo "" + print_info "Users can now install with:" + echo "" + echo " dependencies: [" + echo " .package(url: \"https://github.com/RunanywhereAI/sdks\", from: \"$new_version\")" + echo " ]" + echo "" + + # Final reminder to insert build token + print_warning "═══════════════════════════════════════════════════════════" + print_warning "⚠️ FINAL REMINDER: Insert build token into Supabase!" + print_warning "═══════════════════════════════════════════════════════════" + echo "" + echo -e "${YELLOW}INSERT INTO build_tokens (token, platform, label, is_active, notes)" + echo -e "VALUES (" + echo -e " '$build_token'," + echo -e " 'ios'," + echo -e " 'v$new_version'," + echo -e " TRUE," + echo -e " 'iOS SDK v$new_version - Released $(date +%Y-%m-%d)'" + echo -e ");${NC}" + echo "" + print_warning "Without this, the new SDK release will NOT be able to register devices!" + echo "" + print_warning "SECURITY REMINDER: Build token shown above is for internal use only" + print_warning "DO NOT include the token in public GitHub release notes" + echo "" + print_info "View release: https://github.com/RunanywhereAI/sdks/releases/tag/v$new_version" +} + +# Run main function +main "$@" diff --git a/sdk/runanywhere-commons/.clang-format b/sdk/runanywhere-commons/.clang-format new file mode 100644 index 000000000..a9242e92c --- /dev/null +++ b/sdk/runanywhere-commons/.clang-format @@ -0,0 +1,101 @@ +# RunAnywhere Commons - Clang Format Configuration +# Based on Google style with customizations for consistency with runanywhere-core + +BasedOnStyle: Google + +# Indentation +IndentWidth: 4 +TabWidth: 4 +UseTab: Never +ContinuationIndentWidth: 4 + +# Line length +ColumnLimit: 100 + +# Braces +BreakBeforeBraces: Attach +BraceWrapping: + AfterClass: false + AfterControlStatement: Never + AfterEnum: false + AfterFunction: false + AfterNamespace: false + AfterStruct: false + AfterUnion: false + BeforeCatch: false + BeforeElse: false + IndentBraces: false + +# Functions +AllowShortFunctionsOnASingleLine: Inline +AllowShortIfStatementsOnASingleLine: Never +AllowShortLoopsOnASingleLine: false +AllowShortBlocksOnASingleLine: Empty + +# Include sorting +IncludeBlocks: Regroup +IncludeCategories: + # Main header (same name as source file) + - Regex: '"[^/]*\.h"' + Priority: 1 + # Project headers (rac_*) + - Regex: '"rac_.*\.h"' + Priority: 2 + # runanywhere-core headers (ra_*) + - Regex: '"(ra_|runanywhere).*\.h"' + Priority: 3 + # Third-party headers + - Regex: '<[^/]*\.h>' + Priority: 4 + # System headers + - Regex: '<.*>' + Priority: 5 +SortIncludes: CaseSensitive + +# Alignment +AlignAfterOpenBracket: Align +AlignConsecutiveAssignments: None +AlignConsecutiveDeclarations: None +AlignEscapedNewlines: Left +AlignOperands: Align +AlignTrailingComments: true + +# Namespaces +NamespaceIndentation: None +CompactNamespaces: false +FixNamespaceComments: true + +# Pointers and References +DerivePointerAlignment: false +PointerAlignment: Left +ReferenceAlignment: Left + +# Empty lines +KeepEmptyLinesAtTheStartOfBlocks: false +MaxEmptyLinesToKeep: 1 + +# Other +AllowAllParametersOfDeclarationOnNextLine: true +BinPackArguments: true +BinPackParameters: true +SpaceAfterCStyleCast: false +SpaceAfterTemplateKeyword: true +SpaceBeforeAssignmentOperators: true +SpaceBeforeParens: ControlStatements +SpaceInEmptyParentheses: false +SpacesInAngles: Never +SpacesInCStyleCastParentheses: false +SpacesInParentheses: false +SpacesInSquareBrackets: false + +# C/C++ specific +Language: Cpp +Standard: c++17 + +# Penalties (for line breaking decisions) +PenaltyBreakBeforeFirstCallParameter: 19 +PenaltyBreakComment: 300 +PenaltyBreakFirstLessLess: 120 +PenaltyBreakString: 1000 +PenaltyExcessCharacter: 1000000 +PenaltyReturnTypeOnItsOwnLine: 60 diff --git a/sdk/runanywhere-commons/.clang-tidy b/sdk/runanywhere-commons/.clang-tidy new file mode 100644 index 000000000..37dbc0a63 --- /dev/null +++ b/sdk/runanywhere-commons/.clang-tidy @@ -0,0 +1,73 @@ +# RunAnywhere Commons - Clang Tidy Configuration +# Static analysis for C++ code quality +# +# NOTE: This library provides a C API (extern "C") for cross-language compatibility. +# Some modernize-* checks are disabled because they would break C compatibility. + +Checks: > + -*, + bugprone-*, + -bugprone-easily-swappable-parameters, + -bugprone-narrowing-conversions, + -bugprone-branch-clone, + clang-analyzer-*, + -clang-analyzer-security.insecureAPI.DeprecatedOrUnsafeBufferHandling, + modernize-*, + -modernize-use-trailing-return-type, + -modernize-avoid-c-arrays, + -modernize-use-nodiscard, + -modernize-use-using, + -modernize-use-auto, + -modernize-use-default-member-init, + performance-*, + -performance-avoid-endl, + -performance-enum-size, + readability-const-return-type, + readability-container-size-empty, + readability-duplicate-include, + readability-implicit-bool-conversion, + readability-inconsistent-declaration-parameter-name, + readability-misleading-indentation, + readability-redundant-control-flow, + readability-redundant-string-cstr, + readability-simplify-boolean-expr, + readability-static-accessed-through-instance, + readability-string-compare, + misc-redundant-expression, + misc-unused-parameters, + misc-unused-using-decls + +# Warnings treated as errors (uncomment to enforce) +# WarningsAsErrors: '*' + +# Header filter - only check our own headers +HeaderFilterRegex: '.*rac_.*\.h$' + +# Check options +CheckOptions: + # modernize-use-nullptr + - key: modernize-use-nullptr.NullMacros + value: 'NULL' + + # readability-implicit-bool-conversion + - key: readability-implicit-bool-conversion.AllowPointerConditions + value: true + - key: readability-implicit-bool-conversion.AllowIntegerConditions + value: false + + # performance-move-const-arg + - key: performance-move-const-arg.CheckTriviallyCopyableMove + value: true + + # cppcoreguidelines-init-variables + - key: cppcoreguidelines-init-variables.IncludeStyle + value: 'llvm' + - key: cppcoreguidelines-init-variables.MathHeader + value: '' + + # misc-unused-parameters + - key: misc-unused-parameters.StrictMode + value: false + +# Format style for fix suggestions +FormatStyle: file diff --git a/sdk/runanywhere-commons/.github/workflows/build-commons.yml b/sdk/runanywhere-commons/.github/workflows/build-commons.yml new file mode 100644 index 000000000..627b6c9ad --- /dev/null +++ b/sdk/runanywhere-commons/.github/workflows/build-commons.yml @@ -0,0 +1,137 @@ +name: Build RACommons + +on: + push: + branches: [main, develop] + paths: + - 'include/**' + - 'src/**' + - 'cmake/**' + - 'CMakeLists.txt' + - '.github/workflows/build-commons.yml' + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + # =========================================================================== + # Build macOS (native) + # =========================================================================== + build-macos: + name: Build macOS + runs-on: macos-14 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '15.4' + + - name: Build + run: | + mkdir -p build && cd build + cmake .. -DCMAKE_BUILD_TYPE=Release -DRAC_BUILD_PLATFORM=ON + make -j$(sysctl -n hw.ncpu) rac_commons + + - name: Verify Build + run: | + ls -la build/librac_commons.a + echo "Build successful!" + + # =========================================================================== + # Build iOS (XCFramework) + # =========================================================================== + build-ios: + name: Build iOS + runs-on: macos-14 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '15.4' + + - name: Build iOS XCFramework + run: | + chmod +x scripts/build-rac-commons.sh + ./scripts/build-rac-commons.sh --ios --release + + - name: Verify Build + run: | + ls -la dist/RACommons.xcframework/ + echo "iOS build successful!" + + - name: Upload XCFramework + uses: actions/upload-artifact@v4 + with: + name: RACommons-xcframework + path: dist/RACommons.xcframework + retention-days: 7 + + # =========================================================================== + # Build Android + # =========================================================================== + build-android: + name: Build Android + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup JDK + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + + - name: Setup NDK + run: | + source scripts/load-versions.sh + NDK_VERSION="${ANDROID_NDK_VERSION:-27.0.12077973}" + echo "y" | sdkmanager --install "ndk;${NDK_VERSION}" --sdk_root=${ANDROID_SDK_ROOT} + echo "ANDROID_NDK_HOME=${ANDROID_SDK_ROOT}/ndk/${NDK_VERSION}" >> $GITHUB_ENV + + - name: Build Android (arm64-v8a) + run: | + chmod +x scripts/build-rac-commons.sh + ./scripts/build-rac-commons.sh --android --abi arm64-v8a --release + + - name: Verify Build + run: | + ls -la dist/android/jniLibs/arm64-v8a/ + echo "Android build successful!" + + - name: Upload Android Libraries + uses: actions/upload-artifact@v4 + with: + name: RACommons-android + path: dist/android/ + retention-days: 7 + + # =========================================================================== + # Lint + # =========================================================================== + lint: + name: Lint C++ + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Tools + run: | + sudo apt-get update + sudo apt-get install -y clang-format + + - name: Check Formatting + run: | + find include src -name '*.cpp' -o -name '*.h' | \ + xargs clang-format --dry-run --Werror 2>&1 | head -50 || true + continue-on-error: true diff --git a/sdk/runanywhere-commons/.github/workflows/release.yml b/sdk/runanywhere-commons/.github/workflows/release.yml new file mode 100644 index 000000000..c96b5274c --- /dev/null +++ b/sdk/runanywhere-commons/.github/workflows/release.yml @@ -0,0 +1,292 @@ +name: Release RACommons + +# ============================================================================= +# RACommons Release Workflow +# +# Builds and publishes: +# iOS: RACommons.xcframework +# Android: librac_commons.so (per ABI) +# +# Triggered by: +# - Push tag: commons-v* +# - Manual dispatch +# ============================================================================= + +on: + push: + tags: + - 'commons-v*' + workflow_dispatch: + inputs: + version: + description: 'Version to release (e.g., 0.2.0)' + required: true + type: string + dry_run: + description: 'Dry run (build only, do not publish)' + required: false + default: false + type: boolean + +env: + PUBLIC_REPO: 'RunanywhereAI/runanywhere-binaries' + +jobs: + # =========================================================================== + # Prepare + # =========================================================================== + prepare: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + + steps: + - name: Determine Version + id: version + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + VERSION="${{ github.event.inputs.version }}" + else + VERSION="${GITHUB_REF#refs/tags/commons-v}" + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Building RACommons v$VERSION" + + # =========================================================================== + # Build iOS + # =========================================================================== + build-ios: + name: Build iOS + needs: prepare + runs-on: macos-14 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '15.4' + + - name: Build iOS + run: | + chmod +x scripts/build-rac-commons.sh + ./scripts/build-rac-commons.sh --ios --release --package + + - name: Upload Artifact + uses: actions/upload-artifact@v4 + with: + name: ios-${{ needs.prepare.outputs.version }} + path: dist/packages/*ios*.zip* + retention-days: 7 + + # =========================================================================== + # Build Android + # =========================================================================== + build-android: + name: Build Android (${{ matrix.abi }}) + needs: prepare + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + abi: [arm64-v8a, armeabi-v7a, x86_64] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup JDK + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + + - name: Setup NDK + run: | + source scripts/load-versions.sh + NDK_VERSION="${ANDROID_NDK_VERSION:-27.0.12077973}" + echo "y" | sdkmanager --install "ndk;${NDK_VERSION}" --sdk_root=${ANDROID_SDK_ROOT} + echo "ANDROID_NDK_HOME=${ANDROID_SDK_ROOT}/ndk/${NDK_VERSION}" >> $GITHUB_ENV + + - name: Build Android ${{ matrix.abi }} + run: | + chmod +x scripts/build-rac-commons.sh + ./scripts/build-rac-commons.sh --android --abi ${{ matrix.abi }} --release + + - name: Package ABI + run: | + VERSION="${{ needs.prepare.outputs.version }}" + ABI="${{ matrix.abi }}" + mkdir -p dist/packages + cd dist/android + zip -r "../packages/RACommons-android-${ABI}-v${VERSION}.zip" jniLibs/${ABI} include + cd ../packages + shasum -a 256 "RACommons-android-${ABI}-v${VERSION}.zip" > "RACommons-android-${ABI}-v${VERSION}.zip.sha256" + + - name: Upload Artifact + uses: actions/upload-artifact@v4 + with: + name: android-${{ matrix.abi }}-${{ needs.prepare.outputs.version }} + path: dist/packages/* + retention-days: 7 + + # =========================================================================== + # Combine Android ABIs + # =========================================================================== + combine-android: + name: Combine Android + needs: [prepare, build-android] + runs-on: ubuntu-latest + + steps: + - name: Download All Android Artifacts + uses: actions/download-artifact@v4 + with: + pattern: android-* + path: artifacts + merge-multiple: false + + - name: Create Combined Package + run: | + VERSION="${{ needs.prepare.outputs.version }}" + COMBINED="RACommons-android-v${VERSION}" + + mkdir -p "${COMBINED}/jniLibs" + mkdir -p output + + # Extract each ABI + for dir in artifacts/android-*/; do + if [ -d "$dir" ]; then + for zip in "${dir}"*.zip; do + if [ -f "$zip" ] && [[ ! "$zip" == *.sha256 ]]; then + unzip -o "$zip" -d "${COMBINED}/" + fi + done + fi + done + + # Create combined package + zip -r "output/${COMBINED}.zip" "${COMBINED}" + cd output + shasum -a 256 "${COMBINED}.zip" > "${COMBINED}.zip.sha256" + + ls -la + + - name: Upload Combined Artifact + uses: actions/upload-artifact@v4 + with: + name: android-combined-${{ needs.prepare.outputs.version }} + path: output/* + retention-days: 7 + + # =========================================================================== + # Publish + # =========================================================================== + publish: + name: Publish Release + needs: [prepare, build-ios, combine-android] + if: github.event.inputs.dry_run != 'true' + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Download iOS Artifact + uses: actions/download-artifact@v4 + with: + name: ios-${{ needs.prepare.outputs.version }} + path: release-assets + + - name: Download Android Combined Artifact + uses: actions/download-artifact@v4 + with: + name: android-combined-${{ needs.prepare.outputs.version }} + path: release-assets + + - name: List Release Assets + run: | + echo "Release assets:" + ls -la release-assets/ + + - name: Create Checksums File + run: | + cd release-assets + cat *.sha256 > checksums.txt + cat checksums.txt + + - name: Checkout Public Repository + uses: actions/checkout@v4 + with: + repository: ${{ env.PUBLIC_REPO }} + token: ${{ secrets.BINARY_REPO_PAT }} + path: public-repo + + - name: Update Public Repository + run: | + VERSION="${{ needs.prepare.outputs.version }}" + cd public-repo + + mkdir -p releases/commons-v${VERSION} + cp ../release-assets/*.zip releases/commons-v${VERSION}/ + cp ../release-assets/*.sha256 releases/commons-v${VERSION}/ + cp ../release-assets/checksums.txt releases/commons-v${VERSION}/ + + echo "${VERSION}" > LATEST_COMMONS_VERSION + + git config user.name "GitHub Actions" + git config user.email "actions@github.com" + git add -A + git commit -m "Release RACommons v${VERSION}" || echo "No changes" + git tag -a "commons-v${VERSION}" -m "RACommons v${VERSION}" 2>/dev/null || true + + - name: Push to Public Repository + run: | + cd public-repo + git push origin main --tags + env: + GITHUB_TOKEN: ${{ secrets.BINARY_REPO_PAT }} + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + repository: ${{ env.PUBLIC_REPO }} + tag_name: commons-v${{ needs.prepare.outputs.version }} + name: RACommons v${{ needs.prepare.outputs.version }} + files: | + release-assets/*.zip + release-assets/*.sha256 + release-assets/checksums.txt + body: | + ## RACommons v${{ needs.prepare.outputs.version }} + + Standalone infrastructure library for RunAnywhere SDKs. + + ### Contents + - Logging, error handling, and event tracking + - Service registry and provider infrastructure + - Model management (download strategies, storage) + - Platform backend (Apple Foundation Models, System TTS) - iOS/macOS only + + ### iOS/macOS + `RACommons-ios-v${{ needs.prepare.outputs.version }}.zip` + - Contains `RACommons.xcframework` + + ### Android + `RACommons-android-v${{ needs.prepare.outputs.version }}.zip` + - Contains `jniLibs/{abi}/librac_commons.so` + - Contains `include/` headers + + ### Note + Backend libraries (LlamaCPP, ONNX, WhisperCPP) are released from `runanywhere-core`. + + --- + Built from runanywhere-commons @ ${{ github.sha }} + draft: false + prerelease: false + env: + GITHUB_TOKEN: ${{ secrets.BINARY_REPO_PAT }} diff --git a/sdk/runanywhere-commons/.github/workflows/size-check.yml b/sdk/runanywhere-commons/.github/workflows/size-check.yml new file mode 100644 index 000000000..ab9455980 --- /dev/null +++ b/sdk/runanywhere-commons/.github/workflows/size-check.yml @@ -0,0 +1,86 @@ +name: Binary Size Check + +on: + pull_request: + branches: [main] + paths: + - 'include/**' + - 'src/**' + - 'CMakeLists.txt' + workflow_dispatch: + +env: + # RACommons size limit: 3MB + LIMIT_RACOMMONS: 3145728 + +jobs: + size-check: + name: Check Binary Sizes + runs-on: macos-14 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '15.4' + + - name: Build iOS XCFramework + run: | + chmod +x scripts/build-rac-commons.sh + ./scripts/build-rac-commons.sh --ios --release + + - name: Analyze Sizes + id: sizes + run: | + echo "=== RACommons.xcframework ===" > size-report.txt + + if [ -d "dist/RACommons.xcframework" ]; then + XCFW_SIZE=$(du -sb dist/RACommons.xcframework | cut -f1) + echo "Total: $(du -sh dist/RACommons.xcframework | cut -f1)" >> size-report.txt + echo "" >> size-report.txt + + # Show breakdown + for slice in dist/RACommons.xcframework/*/; do + if [ -d "$slice" ]; then + SLICE_NAME=$(basename "$slice") + SLICE_SIZE=$(du -sh "$slice" | cut -f1) + echo "$SLICE_NAME: $SLICE_SIZE" >> size-report.txt + fi + done + + echo "xcfw_size=$XCFW_SIZE" >> $GITHUB_OUTPUT + else + echo "XCFramework not found" >> size-report.txt + echo "xcfw_size=0" >> $GITHUB_OUTPUT + fi + + cat size-report.txt + + - name: Check Size Limit + run: | + SIZE=${{ steps.sizes.outputs.xcfw_size }} + LIMIT=${{ env.LIMIT_RACOMMONS }} + + if [ "$SIZE" -eq 0 ]; then + echo "⚠️ No build output to check" + exit 0 + fi + + if [ "$SIZE" -gt "$LIMIT" ]; then + SIZE_MB=$(echo "scale=2; $SIZE / 1048576" | bc) + LIMIT_MB=$(echo "scale=2; $LIMIT / 1048576" | bc) + echo "❌ RACommons.xcframework (${SIZE_MB}MB) exceeds limit (${LIMIT_MB}MB)" + exit 1 + fi + + SIZE_MB=$(echo "scale=2; $SIZE / 1048576" | bc) + LIMIT_MB=$(echo "scale=2; $LIMIT / 1048576" | bc) + echo "✅ RACommons.xcframework: ${SIZE_MB}MB (limit: ${LIMIT_MB}MB)" + + - name: Upload Size Report + uses: actions/upload-artifact@v4 + with: + name: size-report + path: size-report.txt diff --git a/sdk/runanywhere-commons/.gitignore b/sdk/runanywhere-commons/.gitignore new file mode 100644 index 000000000..86cd9c64f --- /dev/null +++ b/sdk/runanywhere-commons/.gitignore @@ -0,0 +1,59 @@ +# Build directories +build/ +build-*/ +cmake-build-*/ +_deps/ + +# Distribution output +dist/ + +# IDE files +.idea/ +.vscode/ +*.xcodeproj/ +*.xcworkspace/ + +# Clangd cache +.cache/ + +# Compiled objects +*.o +*.obj +*.a +*.lib +*.so +*.dylib + +# Debug files +*.dSYM/ +*.pdb + +# CMake generated +CMakeCache.txt +CMakeFiles/ +cmake_install.cmake +Makefile +compile_commands.json + +# Package files +*.xcframework/ +*.framework/ +*.zip + +# Third party dependencies (downloaded separately) +third_party/ + +# Logs +*.log + +# OS files +.DS_Store +Thumbs.db + +# Temporary files +*.tmp +*.swp +*~ + +# Development config with secrets (use .template file) +src/infrastructure/network/development_config.cpp diff --git a/sdk/runanywhere-commons/CLAUDE.md b/sdk/runanywhere-commons/CLAUDE.md new file mode 100644 index 000000000..119d22ed3 --- /dev/null +++ b/sdk/runanywhere-commons/CLAUDE.md @@ -0,0 +1,573 @@ +# CLAUDE.md - AI Context for runanywhere-commons + +## Core Principles + +- Focus on **SIMPLICITY**, following Clean SOLID principles. Reusability, clean architecture, clear separation of concerns. +- Do NOT write ANY MOCK IMPLEMENTATION unless specified otherwise. +- DO NOT PLAN or WRITE any unit tests unless specified otherwise. +- Always use **structured types**, never use strings directly for consistency and scalability. +- When fixing issues focus on **SIMPLICITY** - do not add complicated logic unless necessary. +- Don't over plan it, always think **MVP**. + +## C++ Specific Rules + +- C++17 standard required +- Google C++ Style Guide with project customizations (see `.clang-format`) +- Run `./scripts/lint-cpp.sh` before committing +- Use `./scripts/lint-cpp.sh --fix` to auto-fix formatting issues +- All public symbols prefixed with `rac_` (RunAnywhere Commons) + +## Project Overview + +`runanywhere-commons` is a **unified** C/C++ library containing: +1. **Core Infrastructure** - Logging, errors, events, lifecycle management, SDK state +2. **RAC Services** - Public C APIs for LLM, STT, TTS, VAD (vtable-based abstraction) +3. **Backends** - ML inference backends (LlamaCPP, ONNX/Sherpa-ONNX, WhisperCPP) in `src/backends/` +4. **Platform Services** - Apple Foundation Models, System TTS (iOS/macOS only) +5. **Infrastructure** - Model management, network services, device management, telemetry + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Swift/Kotlin SDKs │ +└────────────────────────────┬────────────────────────────────┘ + │ uses (CRACommons / JNI) +┌────────────────────────────▼────────────────────────────────┐ +│ RAC Public C API (rac_*) │ +│ rac_llm_service.h, rac_stt_service.h, rac_tts_service.h │ +│ rac_vad_service.h, rac_voice_agent.h │ +└────────────────────────────┬────────────────────────────────┘ + │ dispatches via vtables +┌────────────────────────────▼────────────────────────────────┐ +│ Service & Module Registry │ +│ - Priority-based provider selection │ +│ - canHandle pattern for capability matching │ +│ - Lazy service instantiation │ +└────────────────────────────┬────────────────────────────────┘ + │ +┌────────────────────────────▼────────────────────────────────┐ +│ Backends (src/backends/) │ +│ ┌─────────────┐ ┌─────────────────┐ ┌───────────────┐ │ +│ │ llamacpp/ │ │ onnx/ │ │ whispercpp/ │ │ +│ │ LLM (GGUF) │ │ STT/TTS/VAD │ │ STT (GGML) │ │ +│ │ Metal GPU │ │ (Sherpa-ONNX) │ │ Whisper.cpp │ │ +│ └─────────────┘ └─────────────────┘ └───────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ platform/ │ │ +│ │ Apple Foundation Models (LLM) + System TTS │ │ +│ │ (Swift callbacks, iOS/macOS only) │ │ +│ └─────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Directory Structure + +``` +runanywhere-commons/ +├── include/rac/ # Public C headers (rac_* prefix) +│ ├── core/ # Core infrastructure +│ │ ├── rac_core.h # Main SDK initialization +│ │ ├── rac_error.h # Error codes (-100 to -999) +│ │ ├── rac_types.h # Basic types, handles, strings +│ │ ├── rac_logger.h # Logging interface +│ │ ├── rac_events.h # Event system +│ │ ├── rac_audio_utils.h # Audio processing utilities +│ │ ├── rac_sdk_state.h # SDK state management +│ │ ├── rac_structured_error.h # Structured error handling +│ │ ├── rac_platform_adapter.h # Platform callbacks +│ │ └── capabilities/ +│ │ └── rac_lifecycle.h # Component lifecycle states +│ ├── features/ # Service interfaces +│ │ ├── llm/ # Large Language Models +│ │ │ ├── rac_llm_service.h # LLM vtable interface +│ │ │ ├── rac_llm_types.h # LLM data structures +│ │ │ ├── rac_llm_component.h # Component lifecycle +│ │ │ ├── rac_llm_metrics.h # Metrics collection +│ │ │ ├── rac_llm_analytics.h # Analytics integration +│ │ │ └── rac_llm.h # Public API wrapper +│ │ ├── stt/ # Speech-to-Text +│ │ │ ├── rac_stt_service.h # STT vtable interface +│ │ │ ├── rac_stt_types.h # STT data structures +│ │ │ ├── rac_stt_component.h # Component lifecycle +│ │ │ └── rac_stt.h # Public API +│ │ ├── tts/ # Text-to-Speech +│ │ │ ├── rac_tts_service.h # TTS vtable interface +│ │ │ ├── rac_tts_types.h # TTS data structures +│ │ │ ├── rac_tts_component.h # Component lifecycle +│ │ │ └── rac_tts.h # Public API +│ │ ├── vad/ # Voice Activity Detection +│ │ │ ├── rac_vad_service.h # VAD vtable interface +│ │ │ ├── rac_vad_types.h # VAD data structures +│ │ │ ├── rac_vad_energy.h # Energy-based VAD (built-in) +│ │ │ └── rac_vad.h # Public API +│ │ ├── voice_agent/ # Complete voice pipeline +│ │ │ └── rac_voice_agent.h # STT+LLM+TTS+VAD orchestration +│ │ └── platform/ # Platform-specific backends +│ │ ├── rac_llm_platform.h # Apple Foundation Models +│ │ └── rac_tts_platform.h # Apple System TTS +│ ├── infrastructure/ # Support services +│ │ ├── model_management/ # Model registry and lifecycle +│ │ │ ├── rac_model_registry.h +│ │ │ ├── rac_model_types.h +│ │ │ ├── rac_model_paths.h +│ │ │ └── rac_download.h +│ │ ├── network/ # Network services +│ │ │ ├── rac_http_client.h +│ │ │ ├── rac_endpoints.h +│ │ │ ├── rac_environment.h +│ │ │ └── rac_auth_manager.h +│ │ ├── device/ +│ │ │ └── rac_device_manager.h +│ │ ├── storage/ +│ │ │ └── rac_storage_analyzer.h +│ │ └── telemetry/ +│ │ └── rac_telemetry_manager.h +│ └── backends/ # Backend-specific public headers +│ ├── rac_llm_llamacpp.h # LlamaCPP backend API +│ ├── rac_stt_whispercpp.h # WhisperCPP backend API +│ ├── rac_stt_onnx.h # ONNX STT API +│ ├── rac_tts_onnx.h # ONNX TTS API +│ └── rac_vad_onnx.h # ONNX VAD API +│ +├── src/ # Implementation files +│ ├── core/ # Core implementations +│ │ ├── rac_core.cpp # SDK initialization +│ │ ├── rac_error.cpp # Error message mappings +│ │ ├── rac_logger.cpp # Logging implementation +│ │ ├── rac_audio_utils.cpp # Audio processing +│ │ ├── sdk_state.cpp # SDK state management +│ │ └── capabilities/ +│ │ └── lifecycle_manager.cpp +│ ├── infrastructure/ # Infrastructure implementations +│ │ ├── registry/ +│ │ │ ├── service_registry.cpp +│ │ │ └── module_registry.cpp +│ │ ├── model_management/ +│ │ │ ├── model_registry.cpp +│ │ │ ├── model_paths.cpp +│ │ │ └── model_strategy.cpp +│ │ ├── network/ +│ │ │ ├── http_client.cpp +│ │ │ └── auth_manager.cpp +│ │ └── telemetry/ +│ │ └── telemetry_manager.cpp +│ ├── features/ # Feature implementations +│ │ ├── llm/ +│ │ │ ├── llm_component.cpp +│ │ │ ├── rac_llm_service.cpp +│ │ │ └── llm_analytics.cpp +│ │ ├── stt/ +│ │ │ ├── stt_component.cpp +│ │ │ └── rac_stt_service.cpp +│ │ ├── tts/ +│ │ │ ├── tts_component.cpp +│ │ │ └── rac_tts_service.cpp +│ │ ├── vad/ +│ │ │ ├── vad_component.cpp +│ │ │ └── energy_vad.cpp +│ │ ├── voice_agent/ +│ │ │ └── voice_agent.cpp +│ │ └── platform/ +│ │ ├── rac_llm_platform.cpp +│ │ ├── rac_tts_platform.cpp +│ │ └── rac_backend_platform_register.cpp +│ └── backends/ # ML backend implementations +│ ├── llamacpp/ +│ │ ├── llamacpp_backend.cpp +│ │ ├── rac_llm_llamacpp.cpp +│ │ ├── rac_backend_llamacpp_register.cpp +│ │ ├── jni/ +│ │ │ └── rac_backend_llamacpp_jni.cpp +│ │ └── CMakeLists.txt +│ ├── onnx/ +│ │ ├── onnx_backend.cpp +│ │ ├── rac_onnx.cpp +│ │ ├── rac_backend_onnx_register.cpp +│ │ ├── jni/ +│ │ │ └── rac_backend_onnx_jni.cpp +│ │ └── CMakeLists.txt +│ ├── whispercpp/ +│ │ ├── whispercpp_backend.cpp +│ │ ├── rac_stt_whispercpp.cpp +│ │ ├── rac_backend_whispercpp_register.cpp +│ │ ├── jni/ +│ │ │ └── rac_backend_whispercpp_jni.cpp +│ │ └── CMakeLists.txt +│ └── jni/ +│ └── runanywhere_commons_jni.cpp +│ +├── cmake/ # CMake modules +│ ├── FetchONNXRuntime.cmake +│ ├── ios.toolchain.cmake +│ └── LoadVersions.cmake +│ +├── scripts/ # Build automation +│ ├── build-ios.sh # iOS build orchestration +│ ├── build-android.sh # Android build orchestration +│ ├── lint-cpp.sh # C++ linting +│ ├── load-versions.sh # Version loading utility +│ ├── ios/ +│ │ ├── download-onnx.sh +│ │ └── download-sherpa-onnx.sh +│ └── android/ +│ ├── download-sherpa-onnx.sh +│ └── generate-maven-package.sh +│ +├── third_party/ # Pre-built dependencies +│ ├── onnxruntime-ios/ +│ ├── sherpa-onnx-ios/ +│ └── sherpa-onnx-android/ +│ +├── dist/ # Build outputs +│ ├── RACommons.xcframework +│ ├── RABackendLLAMACPP.xcframework +│ ├── RABackendONNX.xcframework +│ └── android/ +│ └── jni/{abi}/librac_*.so +│ +├── exports/ # Symbol visibility lists +├── tests/ # Unit tests +├── CMakeLists.txt # Main CMake configuration +├── VERSION # Project version +└── VERSIONS # Centralized dependency versions +``` + +## Key Concepts + +### Vtable-Based Service Abstraction + +Each service uses a vtable pattern for polymorphic dispatch: + +```c +// Example: LLM Service Vtable +typedef struct rac_llm_service_ops { + rac_result_t (*initialize)(void* impl, const char* model_path); + rac_result_t (*generate)(void* impl, const char* prompt, + const rac_llm_options_t* options, + rac_llm_result_t* out_result); + rac_result_t (*generate_stream)(void* impl, const char* prompt, + const rac_llm_options_t* options, + rac_llm_stream_callback_fn callback, + void* user_data); + rac_result_t (*cancel)(void* impl); + void (*destroy)(void* impl); +} rac_llm_service_ops_t; + +typedef struct rac_llm_service { + const rac_llm_service_ops_t* ops; // Function pointers + void* impl; // Backend-specific handle + const char* model_id; +} rac_llm_service_t; +``` + +**Key principle:** Backends implement vtables directly - NO intermediate C++ capability layer. + +### Service Registry + +- Priority-based provider selection +- `canHandle` pattern: providers declare what requests they can serve +- Factory functions create service instances on demand + +``` +Client: rac_llm_create("model-id") + → ServiceRegistry queries all LLM providers + → First provider returning canHandle=true creates service + → Service wraps backend handle + vtable + → Return to client +``` + +### Module Registry + +- Central registry for AI backend modules +- Modules declare capabilities: LLM, STT, TTS, VAD +- Thread-safe singleton pattern + +### Capabilities Enumeration + +```c +typedef enum rac_capability { + RAC_CAPABILITY_UNKNOWN = 0, + RAC_CAPABILITY_TEXT_GENERATION = 1, // LLM + RAC_CAPABILITY_EMBEDDINGS = 2, + RAC_CAPABILITY_STT = 3, // Speech-to-Text + RAC_CAPABILITY_TTS = 4, // Text-to-Speech + RAC_CAPABILITY_VAD = 5, // Voice Activity Detection + RAC_CAPABILITY_DIARIZATION = 6, // Speaker Diarization +} rac_capability_t; +``` + +### Component Lifecycle States + +```c +typedef enum rac_lifecycle_state { + RAC_LIFECYCLE_STATE_UNINITIALIZED, + RAC_LIFECYCLE_STATE_INITIALIZING, + RAC_LIFECYCLE_STATE_READY, + RAC_LIFECYCLE_STATE_LOADING, + RAC_LIFECYCLE_STATE_LOADED, + RAC_LIFECYCLE_STATE_ERROR, + RAC_LIFECYCLE_STATE_DESTROYING, +} rac_lifecycle_state_t; +``` + +### Logging + +- Single logging system: `RAC_LOG_INFO`, `RAC_LOG_ERROR`, `RAC_LOG_WARNING`, `RAC_LOG_DEBUG` +- Backends use RAC logger (include `rac/core/rac_logger.h`) +- Routes through platform adapter to native logging (NSLog, Logcat) + +## API Naming Convention + +| Category | Pattern | Example | +|----------|---------|---------| +| All public symbols | `rac_` prefix | `rac_llm_create()` | +| Error codes | `RAC_ERROR_*` | `RAC_ERROR_MODEL_NOT_FOUND` | +| Types | `rac_*_t` | `rac_handle_t`, `rac_llm_options_t` | +| Boolean | `RAC_TRUE` / `RAC_FALSE` | `rac_bool_t` | +| Components | `rac_*_component_*` | `rac_llm_component_initialize()` | +| Backends | `rac_backend_*` | `rac_backend_llamacpp_register()` | + +## Error Code Ranges + +| Range | Category | +|-------|----------| +| 0 | Success | +| -100 to -109 | Initialization errors | +| -110 to -129 | Model errors | +| -130 to -149 | Generation errors | +| -150 to -179 | Network errors | +| -180 to -219 | Storage errors | +| -220 to -229 | Hardware errors | +| -230 to -249 | Component state errors | +| -250 to -279 | Validation errors | +| -280 to -299 | Audio errors | +| -300 to -319 | Language/Voice errors | +| -400 to -499 | Module/Service errors | +| -600 to -699 | Backend errors | +| -700 to -799 | Event errors | + +## Backend Details + +### LlamaCPP Backend + +- **Capability:** LLM text generation +- **Models:** GGUF format (quantized models) +- **Inference Engine:** llama.cpp (fetched via FetchContent) +- **GPU Acceleration:** Metal (iOS/macOS), CPU NEON (Android) +- **Public API:** `include/rac/backends/rac_llm_llamacpp.h` +- **Registration:** `rac_backend_llamacpp_register()` + +### ONNX Backend (via Sherpa-ONNX) + +- **Capabilities:** STT, TTS, VAD +- **Models:** ONNX format +- **Framework:** Sherpa-ONNX C API +- **Public APIs:** `rac_stt_onnx.h`, `rac_tts_onnx.h`, `rac_vad_onnx.h` +- **Registration:** `rac_backend_onnx_register()` + +### WhisperCPP Backend + +- **Capability:** STT (speech-to-text) +- **Models:** GGML format (quantized Whisper) +- **Inference Engine:** whisper.cpp (fetched via FetchContent) +- **Public API:** `include/rac/backends/rac_stt_whispercpp.h` +- **Registration:** `rac_backend_whispercpp_register()` + +### Platform Backend (Apple-only) + +- **Capabilities:** LLM (Apple Foundation Models), TTS (System TTS) +- **Implementation:** Swift callbacks (no C++ inference) +- **Pattern:** C++ provides vtable registration, Swift provides callbacks +- **Public APIs:** `rac_llm_platform.h`, `rac_tts_platform.h` +- **Registration:** `rac_backend_platform_register()` + +```c +// Swift registers callbacks for platform backends +rac_platform_llm_set_callbacks(callbacks); +rac_backend_platform_register(); +``` + +## Building + +### CMake Options + +```cmake +RAC_BUILD_JNI # Enable JNI bridge (Android/JVM) +RAC_BUILD_TESTS # Build unit tests +RAC_BUILD_SHARED # Shared libraries (default: static) +RAC_BUILD_PLATFORM # Platform backend (Apple only, ON by default) +RAC_BUILD_BACKENDS # ML backend compilation (OFF by default) + RAC_BACKEND_LLAMACPP # LlamaCPP backend + RAC_BACKEND_ONNX # ONNX backend + RAC_BACKEND_WHISPERCPP # WhisperCPP backend +``` + +### Build Commands + +```bash +# Desktop/macOS build +cmake -B build -DCMAKE_BUILD_TYPE=Release +cmake --build build + +# Build with backends +cmake -B build -DRAC_BUILD_BACKENDS=ON +cmake --build build + +# iOS build (uses scripts) +./scripts/build-ios.sh # Full build +./scripts/build-ios.sh --skip-download # Use cached deps +./scripts/build-ios.sh --backend llamacpp # Specific backend +./scripts/build-ios.sh --clean # Clean build +./scripts/build-ios.sh --package # Create ZIPs + +# Android build +./scripts/build-android.sh # All backends, all ABIs +./scripts/build-android.sh llamacpp # LlamaCPP only +./scripts/build-android.sh onnx arm64-v8a # Specific backend + ABI +./scripts/build-android.sh --check # Verify 16KB alignment + +# Linting +./scripts/lint-cpp.sh # Check formatting +./scripts/lint-cpp.sh --fix # Auto-fix issues +``` + +### Version Management + +All versions are centralized in the `VERSIONS` file: + +``` +PROJECT_VERSION=1.0.0 +IOS_DEPLOYMENT_TARGET=13.0 +ANDROID_MIN_SDK=24 +ONNX_VERSION_IOS=1.17.1 +SHERPA_ONNX_VERSION_IOS=1.12.18 +LLAMACPP_VERSION=b7658 +``` + +Usage: +- Shell scripts: `source scripts/load-versions.sh` +- CMake: `include(LoadVersions)` + +## Outputs + +### iOS/macOS + +``` +dist/ +├── RACommons.xcframework # Core library +├── RABackendLLAMACPP.xcframework # LLM backend +└── RABackendONNX.xcframework # STT/TTS/VAD backend +``` + +### Android + +``` +dist/android/ +├── jni/{abi}/ # JNI libraries +│ ├── librac_commons_jni.so +│ ├── librac_backend_llamacpp_jni.so +│ ├── librac_backend_onnx_jni.so +│ └── librac_backend_whispercpp_jni.so +├── onnx/{abi}/ # ONNX runtime +│ ├── libonnxruntime.so +│ └── libsherpa-onnx.so +└── llamacpp/{abi}/ # LlamaCPP static lib + └── libllama.a +``` + +ABIs: `arm64-v8a` (primary), `x86_64`, `armeabi-v7a`, `x86` + +## Integration with SDKs + +### Swift SDK + +1. Swift imports `CRACommons` module +2. `SwiftPlatformAdapter` provides platform callbacks (storage, logging) +3. `CommonsErrorMapping` converts `rac_result_t` to `SDKError` +4. `EventBridge` subscribes to C++ events, republishes to Swift `EventBus` + +### Kotlin SDK + +1. JNI bridge: `librac_*_jni.so` for each backend +2. Platform adapter via JNI callbacks +3. Type marshaling between Java and C + +## Common Tasks + +### Adding a new error code + +1. Add `#define RAC_ERROR_*` to `rac_error.h` (within -100 to -999) +2. Add case to `rac_error_message()` in `rac_error.cpp` +3. Add mapping in platform SDK error converters + +### Adding a new backend + +1. Create directory under `src/backends/` +2. Implement internal C++ class (no capability inheritance needed) +3. Create RAC API wrapper implementing vtable ops +4. Create registration file with `can_handle` and `create_service` functions +5. Add to CMakeLists.txt with `RAC_BACKEND_*` option +6. Add JNI wrapper in `jni/` subdirectory for Android support + +### Adding a new capability interface + +1. Add enum value to `rac_capability_t` in `rac_types.h` +2. Create interface headers in `include/rac/features//`: + - `_types.h` - Data structures + - `rac__service.h` - Vtable and service interface + - `rac__component.h` - Component lifecycle + - `rac_.h` - Public API wrapper +3. Create implementations in `src/features//` + +## Voice Agent Pattern + +The voice agent orchestrates a complete voice pipeline: + +```cpp +struct rac_voice_agent { + bool is_configured; + bool owns_components; + rac_handle_t llm_handle; + rac_handle_t stt_handle; + rac_handle_t tts_handle; + rac_handle_t vad_handle; + std::mutex mutex; // Thread safety +}; +``` + +**Pipeline Flow:** +1. VAD detects voice activity +2. STT transcribes speech to text +3. LLM generates response +4. TTS synthesizes audio output +5. Events published at each stage + +## Testing + +- Binary size checks in CI (see `size-check.yml`) +- Integration tests via platform SDKs +- Swift E2E tests verify full stack integration + +## CI/CD + +- **Build**: `.github/workflows/build-commons.yml` +- **Release**: `.github/workflows/release.yml` (triggered by `commons-v*` tags) +- **Size Check**: `.github/workflows/size-check.yml` + +## Platform-Specific Notes + +### iOS/macOS + +- Metal GPU acceleration for LlamaCPP +- Apple Accelerate framework for BLAS +- ARM NEON vectorization +- Deployment target: iOS 13.0 + +### Android + +- ARM NEON for vectorization +- 16KB page alignment required for Play Store +- NDK toolchain for cross-compilation +- Min SDK: 24 diff --git a/sdk/runanywhere-commons/CMakeLists.txt b/sdk/runanywhere-commons/CMakeLists.txt new file mode 100644 index 000000000..75ee1339f --- /dev/null +++ b/sdk/runanywhere-commons/CMakeLists.txt @@ -0,0 +1,349 @@ +cmake_minimum_required(VERSION 3.22) + +# Read version from VERSION file +file(READ "${CMAKE_CURRENT_SOURCE_DIR}/VERSION" VERSION_CONTENT) +string(STRIP "${VERSION_CONTENT}" RAC_VERSION) + +project(RunAnywhereCommons + VERSION ${RAC_VERSION} + LANGUAGES CXX C + DESCRIPTION "RunAnywhere Commons - Standalone infrastructure library for ML inference SDKs" +) + +# ============================================================================= +# ABOUT THIS LIBRARY +# ============================================================================= +# +# rac_commons is a STANDALONE library that provides: +# - Logging, error handling, and event tracking +# - Service registry and provider infrastructure +# - Model management (download strategies, storage) +# - Platform backend (Apple Foundation Models, System TTS) on Apple platforms +# ============================================================================= + +# ============================================================================= +# OPTIONS +# ============================================================================= + +option(RAC_BUILD_JNI "Build JNI bridge for Android/JVM" OFF) +option(RAC_BUILD_TESTS "Build unit tests" OFF) +option(RAC_BUILD_SHARED "Build shared libraries" OFF) +option(RAC_BUILD_PLATFORM "Build platform backend (Apple Foundation Models, System TTS)" ON) +option(RAC_BUILD_BACKENDS "Build ML backends (LlamaCPP, ONNX, WhisperCPP)" OFF) +option(RAC_BACKEND_LLAMACPP "Build LlamaCPP backend" ON) +option(RAC_BACKEND_ONNX "Build ONNX backend" ON) +option(RAC_BACKEND_WHISPERCPP "Build WhisperCPP backend" ON) + +# ============================================================================= +# C++ CONFIGURATION +# ============================================================================= + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) +set(CMAKE_POSITION_INDEPENDENT_CODE ON) + +# Export compile commands for clang-tidy +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +# Add cmake modules path +list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake") + +# Load versions from VERSIONS file (single source of truth) +include(LoadVersions) + +# Make project version available to subdirectories +set(RAC_PROJECT_VERSION "${RAC_VERSION}" CACHE STRING "Project version" FORCE) + +# ============================================================================= +# PLATFORM DETECTION +# ============================================================================= + +if(IOS OR CMAKE_SYSTEM_NAME STREQUAL "iOS") + set(RAC_PLATFORM_IOS TRUE) + set(RAC_PLATFORM_NAME "iOS") +elseif(ANDROID) + set(RAC_PLATFORM_ANDROID TRUE) + set(RAC_PLATFORM_NAME "Android") +elseif(APPLE) + set(RAC_PLATFORM_MACOS TRUE) + set(RAC_PLATFORM_NAME "macOS") +elseif(UNIX) + set(RAC_PLATFORM_LINUX TRUE) + set(RAC_PLATFORM_NAME "Linux") +else() + set(RAC_PLATFORM_NAME "Unknown") +endif() + +message(STATUS "Platform: ${RAC_PLATFORM_NAME}") + +# ============================================================================= +# COMPILER FLAGS +# ============================================================================= + +if(CMAKE_CXX_COMPILER_ID MATCHES "Clang|GNU") + add_compile_options(-Wall -Wextra -Wpedantic -Wno-unused-parameter) + if(CMAKE_BUILD_TYPE STREQUAL "Release") + add_compile_options(-O3 -DNDEBUG) + add_compile_options(-fvisibility=hidden) + add_compile_options(-ffunction-sections -fdata-sections) + endif() +endif() + +# ============================================================================= +# 16KB Page Alignment for Android 15+ (API 35) Compliance +# Required starting November 1, 2025 for Google Play submissions +# All shared libraries must have PT_LOAD segments aligned to 16KB +# ============================================================================= +if(ANDROID) + set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -Wl,-z,max-page-size=16384") + message(STATUS "Enabled 16KB page alignment for Android (Google Play requirement)") +endif() + +# ============================================================================= +# AUTO-GENERATE DEVELOPMENT CONFIG IF MISSING +# ============================================================================= +# development_config.cpp is git-ignored because it can contain secrets. +# For CI builds or fresh checkouts, we auto-generate a stub from the template. + +set(DEV_CONFIG_FILE "${CMAKE_CURRENT_SOURCE_DIR}/src/infrastructure/network/development_config.cpp") +set(DEV_CONFIG_TEMPLATE "${CMAKE_CURRENT_SOURCE_DIR}/src/infrastructure/network/development_config.cpp.template") + +if(NOT EXISTS "${DEV_CONFIG_FILE}") + if(EXISTS "${DEV_CONFIG_TEMPLATE}") + message(STATUS "development_config.cpp not found, generating stub from template...") + file(READ "${DEV_CONFIG_TEMPLATE}" DEV_CONFIG_CONTENT) + file(WRITE "${DEV_CONFIG_FILE}" "${DEV_CONFIG_CONTENT}") + message(STATUS "Generated: ${DEV_CONFIG_FILE}") + else() + message(FATAL_ERROR "Neither development_config.cpp nor .template found!") + endif() +endif() + +# ============================================================================= +# SOURCE FILES +# ============================================================================= + +# Core sources - logging, errors, time, memory, events +set(RAC_CORE_SOURCES + src/core/rac_core.cpp + src/core/rac_error.cpp + src/core/rac_time.cpp + src/core/rac_benchmark.cpp + src/core/rac_benchmark_metrics.cpp + src/core/rac_benchmark_log.cpp + src/core/rac_benchmark_stats.cpp + src/core/rac_memory.cpp + src/core/rac_logger.cpp + src/core/rac_audio_utils.cpp + src/core/component_types.cpp + src/core/events.cpp + src/core/sdk_state.cpp + src/core/rac_structured_error.cpp + src/core/capabilities/lifecycle_manager.cpp +) + +# Infrastructure sources - registry, model management, network, telemetry +set(RAC_INFRASTRUCTURE_SOURCES + src/infrastructure/events/event_publisher.cpp + src/infrastructure/registry/module_registry.cpp + src/infrastructure/registry/service_registry.cpp + src/infrastructure/download/download_manager.cpp + src/infrastructure/model_management/model_registry.cpp + src/infrastructure/model_management/model_types.cpp + src/infrastructure/model_management/model_paths.cpp + src/infrastructure/model_management/model_strategy.cpp + src/infrastructure/model_management/model_assignment.cpp + src/infrastructure/storage/storage_analyzer.cpp + src/infrastructure/network/environment.cpp + src/infrastructure/network/endpoints.cpp + src/infrastructure/network/api_types.cpp + src/infrastructure/network/http_client.cpp + src/infrastructure/network/auth_manager.cpp + src/infrastructure/network/development_config.cpp + src/infrastructure/telemetry/telemetry_types.cpp + src/infrastructure/telemetry/telemetry_json.cpp + src/infrastructure/telemetry/telemetry_manager.cpp + src/infrastructure/device/rac_device_manager.cpp +) + +# Feature sources - LLM, STT, TTS, VAD service interfaces +set(RAC_FEATURES_SOURCES + # LLM + src/features/llm/llm_component.cpp + src/features/llm/rac_llm_service.cpp + src/features/llm/streaming_metrics.cpp + src/features/llm/llm_analytics.cpp + src/features/llm/structured_output.cpp + # STT + src/features/stt/stt_component.cpp + src/features/stt/rac_stt_service.cpp + src/features/stt/stt_analytics.cpp + # TTS + src/features/tts/tts_component.cpp + src/features/tts/rac_tts_service.cpp + src/features/tts/tts_analytics.cpp + # VAD + src/features/vad/vad_component.cpp + src/features/vad/energy_vad.cpp + src/features/vad/vad_analytics.cpp + # Voice Agent + src/features/voice_agent/voice_agent.cpp + # Result memory management + src/features/result_free.cpp +) + +# Platform services (Apple Foundation Models + System TTS) +if(APPLE AND RAC_BUILD_PLATFORM) + set(RAC_PLATFORM_SOURCES + src/features/platform/rac_llm_platform.cpp + src/features/platform/rac_tts_platform.cpp + src/features/platform/rac_backend_platform_register.cpp + ) + message(STATUS "Building platform services: Apple Foundation Models, System TTS") +else() + set(RAC_PLATFORM_SOURCES "") +endif() + +# Combine all sources +set(RAC_COMMONS_SOURCES + ${RAC_CORE_SOURCES} + ${RAC_INFRASTRUCTURE_SOURCES} + ${RAC_FEATURES_SOURCES} + ${RAC_PLATFORM_SOURCES} +) + +# ============================================================================= +# RAC COMMONS LIBRARY +# ============================================================================= + +if(RAC_BUILD_SHARED) + add_library(rac_commons SHARED ${RAC_COMMONS_SOURCES}) +else() + add_library(rac_commons STATIC ${RAC_COMMONS_SOURCES}) +endif() + +# Public headers +target_include_directories(rac_commons PUBLIC + $ + $ +) + +# Private includes for implementation +target_include_directories(rac_commons PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/src +) + +# Symbol visibility for shared builds +if(RAC_BUILD_SHARED) + target_compile_definitions(rac_commons PRIVATE RAC_BUILDING_SHARED=1) + set_target_properties(rac_commons PROPERTIES + C_VISIBILITY_PRESET hidden + CXX_VISIBILITY_PRESET hidden + VISIBILITY_INLINES_HIDDEN ON + ) +endif() + +# Platform-specific linking +if(APPLE) + target_link_libraries(rac_commons PUBLIC + "-framework Foundation" + ) + if(RAC_BUILD_PLATFORM) + target_link_libraries(rac_commons PUBLIC + "-framework AVFoundation" + ) + endif() +endif() + +if(RAC_PLATFORM_ANDROID) + target_link_libraries(rac_commons PUBLIC log) +endif() + +target_compile_features(rac_commons PUBLIC cxx_std_17) + +# ============================================================================= +# JNI BRIDGE (Android/JVM) +# ============================================================================= + +if(RAC_BUILD_JNI) + if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/src/jni/CMakeLists.txt") + message(STATUS "Building JNI bridge for Android/JVM") + add_subdirectory(src/jni) + endif() +endif() + +# ============================================================================= +# BACKENDS (LlamaCPP, ONNX, WhisperCPP) +# ============================================================================= + +if(RAC_BUILD_BACKENDS) + message(STATUS "Building ML backends...") + + if(RAC_BACKEND_LLAMACPP AND EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/src/backends/llamacpp/CMakeLists.txt") + message(STATUS " - LlamaCPP backend") + add_subdirectory(src/backends/llamacpp) + endif() + + if(RAC_BACKEND_ONNX AND EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/src/backends/onnx/CMakeLists.txt") + message(STATUS " - ONNX backend") + add_subdirectory(src/backends/onnx) + endif() + + if(RAC_BACKEND_WHISPERCPP AND EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/src/backends/whispercpp/CMakeLists.txt") + message(STATUS " - WhisperCPP backend") + add_subdirectory(src/backends/whispercpp) + endif() +endif() + +# ============================================================================= +# TESTS +# ============================================================================= + +if(RAC_BUILD_TESTS) + enable_testing() + add_subdirectory(tests) +endif() + +# ============================================================================= +# INSTALLATION +# ============================================================================= + +install(TARGETS rac_commons + LIBRARY DESTINATION lib + ARCHIVE DESTINATION lib +) + +install(DIRECTORY include/ + DESTINATION include + FILES_MATCHING PATTERN "*.h" +) + +# ============================================================================= +# CONFIGURATION SUMMARY +# ============================================================================= + +message(STATUS "") +message(STATUS "====================================") +message(STATUS "RunAnywhere Commons v${RAC_VERSION}") +message(STATUS "====================================") +message(STATUS "Platform: ${RAC_PLATFORM_NAME}") +message(STATUS "Build type: ${CMAKE_BUILD_TYPE}") +message(STATUS "Compiler: ${CMAKE_CXX_COMPILER_ID} ${CMAKE_CXX_COMPILER_VERSION}") +message(STATUS "Shared libs: ${RAC_BUILD_SHARED}") +message(STATUS "") +message(STATUS "Components:") +message(STATUS " Core: Logging, errors, events, memory") +message(STATUS " Registry: Service & module registration") +message(STATUS " Models: Download strategies, storage") +message(STATUS " Services: LLM, STT, TTS, VAD interfaces") +if(APPLE AND RAC_BUILD_PLATFORM) + message(STATUS " Platform: Apple Foundation Models, System TTS") +endif() +if(RAC_BUILD_BACKENDS) + message(STATUS " Backends: LlamaCPP=${RAC_BACKEND_LLAMACPP}, ONNX=${RAC_BACKEND_ONNX}, WhisperCPP=${RAC_BACKEND_WHISPERCPP}") +endif() +message(STATUS "") +message(STATUS "JNI bridge: ${RAC_BUILD_JNI}") +message(STATUS "====================================") +message(STATUS "") diff --git a/sdk/runanywhere-commons/README.md b/sdk/runanywhere-commons/README.md new file mode 100644 index 000000000..c01b3a00d --- /dev/null +++ b/sdk/runanywhere-commons/README.md @@ -0,0 +1,510 @@ +# RunAnywhere Commons + +**runanywhere-commons** is a unified C/C++ library that serves as the foundation for the RunAnywhere SDK ecosystem. It provides the core infrastructure, service abstraction layer, and ML backend integrations that power on-device AI capabilities across iOS, Android, macOS, and Linux platforms. + +--- + +## Table of Contents + +- [Overview](#overview) +- [Key Features](#key-features) +- [Architecture Overview](#architecture-overview) +- [Supported Capabilities](#supported-capabilities) +- [Backend Integrations](#backend-integrations) +- [Getting Started](#getting-started) +- [Building](#building) +- [API Reference](#api-reference) +- [Platform Integration](#platform-integration) +- [Dependencies](#dependencies) +- [Version Management](#version-management) + +--- + +## Overview + +RunAnywhere Commons is the shared C++ layer that sits between platform SDKs (Swift, Kotlin, Flutter) and ML inference backends (LlamaCPP, ONNX/Sherpa-ONNX, WhisperCPP). It provides: + +- **Unified C API** - All public functions use the `rac_` prefix and follow a consistent vtable-based abstraction pattern +- **Backend Abstraction** - Multiple ML backends can be registered and selected at runtime based on model requirements +- **Service Registry** - Priority-based provider selection matching the Swift SDK's `ServiceRegistry` pattern +- **Cross-Platform Support** - Single codebase targeting iOS, Android, macOS, and Linux +- **Platform Services** - Native integration with Apple Foundation Models and System TTS on Apple platforms + +### Design Principles + +- **C++ Core, C API Surface** - C++17 internally, pure C API for FFI compatibility +- **Vtable-Based Polymorphism** - No C++ virtual inheritance at API boundaries +- **Priority-Based Dispatch** - Service providers register with priority; first capable handler wins +- **Lazy Initialization** - Services created on-demand, not at startup +- **Single Source of Truth** - Events, analytics, and state managed centrally in C++ + +--- + +## Key Features + +### Core Infrastructure +- **Logging System** - Platform-bridged logging with categories (`RAC_LOG_INFO`, `RAC_LOG_ERROR`) +- **Error Handling** - Comprehensive error codes (-100 to -999 range) with detailed messages +- **Event System** - Cross-platform analytics events emitted from C++ to platform SDKs +- **Memory Management** - Consistent allocation/deallocation patterns (`rac_alloc`, `rac_free`) + +### Service Layer +- **Module Registry** - Backend modules register capabilities at startup +- **Service Registry** - Priority-based factory pattern for service creation +- **Lifecycle Management** - Consistent state machine for component lifecycle +- **Model Registry** - Central model metadata and path management + +### AI Capabilities +- **LLM (Text Generation)** - Streaming and batch generation with metrics +- **STT (Speech-to-Text)** - Real-time and batch transcription +- **TTS (Text-to-Speech)** - High-quality speech synthesis +- **VAD (Voice Activity Detection)** - Energy-based voice detection +- **Voice Agent** - Orchestrated pipeline (VAD → STT → LLM → TTS) + +--- + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Swift/Kotlin SDKs │ +│ (RunAnywhere, RunAnywhereKotlin) │ +└────────────────────────────┬────────────────────────────────┘ + │ C API (rac_*) +┌────────────────────────────▼────────────────────────────────┐ +│ RAC Public C API (rac_*) │ +│ rac_llm_service.h, rac_stt_service.h, rac_tts_service.h │ +│ rac_vad_service.h, rac_voice_agent.h │ +└────────────────────────────┬────────────────────────────────┘ + │ vtable dispatch +┌────────────────────────────▼────────────────────────────────┐ +│ Service & Module Registry │ +│ - Priority-based provider selection │ +│ - canHandle pattern for capability matching │ +│ - Lazy service instantiation │ +└────────────────────────────┬────────────────────────────────┘ + │ +┌────────────────────────────▼────────────────────────────────┐ +│ Backends (src/backends/) │ +│ ┌─────────────┐ ┌─────────────────┐ ┌───────────────┐ │ +│ │ llamacpp/ │ │ onnx/ │ │ whispercpp/ │ │ +│ │ LLM (GGUF) │ │ STT/TTS/VAD │ │ STT (GGML) │ │ +│ │ Metal GPU │ │ (Sherpa-ONNX) │ │ Whisper.cpp │ │ +│ └─────────────┘ └─────────────────┘ └───────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ platform/ │ │ +│ │ Apple Foundation Models (LLM) + System TTS │ │ +│ │ (Swift callbacks, iOS/macOS only) │ │ +│ └─────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## Supported Capabilities + +| Capability | Description | Backends | +|------------|-------------|----------| +| **TEXT_GENERATION** | LLM text generation with streaming | LlamaCPP, Platform (Apple FM) | +| **STT** | Speech-to-text transcription | ONNX (Sherpa), WhisperCPP | +| **TTS** | Text-to-speech synthesis | ONNX (Sherpa), Platform (System TTS) | +| **VAD** | Voice activity detection | ONNX (Silero), Built-in (Energy-based) | +| **VOICE_AGENT** | Full voice pipeline orchestration | Composite (STT+LLM+TTS+VAD) | + +--- + +## Backend Integrations + +### LlamaCPP Backend +- **Capability**: LLM text generation +- **Model Format**: GGUF (quantized models) +- **GPU Acceleration**: Metal (iOS/macOS), CPU NEON (Android) +- **Features**: Streaming generation, chat templates, cancellation +- **Header**: `include/rac/backends/rac_llm_llamacpp.h` + +```c +// Create and load a GGUF model +rac_handle_t handle; +rac_llm_llamacpp_create("/path/to/model.gguf", NULL, &handle); + +// Generate text with streaming +rac_llm_options_t options = RAC_LLM_OPTIONS_DEFAULT; +options.max_tokens = 256; +options.temperature = 0.7f; + +rac_llm_llamacpp_generate_stream(handle, "Hello, world!", &options, + token_callback, user_data); +``` + +### ONNX Backend (via Sherpa-ONNX) +- **Capabilities**: STT, TTS, VAD +- **Model Format**: ONNX +- **Supported Models**: Whisper, Zipformer, Paraformer (STT); VITS/Piper (TTS); Silero (VAD) +- **Headers**: `rac_stt_onnx.h`, `rac_tts_onnx.h`, `rac_vad_onnx.h` + +```c +// Create STT service +rac_handle_t stt; +rac_stt_onnx_create("/path/to/whisper", NULL, &stt); + +// Transcribe audio +rac_stt_result_t result; +rac_stt_onnx_transcribe(stt, audio_samples, num_samples, NULL, &result); +printf("Transcription: %s\n", result.text); +``` + +### WhisperCPP Backend +- **Capability**: STT (speech-to-text) +- **Model Format**: GGML (quantized Whisper models) +- **Features**: Fast CPU inference, multiple languages +- **Header**: `include/rac/backends/rac_stt_whispercpp.h` + +### Platform Backend (Apple-only) +- **Capabilities**: LLM (Apple Foundation Models), TTS (System TTS) +- **Pattern**: C++ provides vtable registration, Swift provides actual implementation via callbacks +- **Features**: On-device Apple Intelligence models, system voices +- **Headers**: `rac_llm_platform.h`, `rac_tts_platform.h` + +--- + +## Getting Started + +### Prerequisites + +- **CMake** 3.22 or higher +- **C++17** compatible compiler (Clang, GCC) +- **Platform-specific**: Xcode 15+ (iOS/macOS), Android NDK r25+ (Android) + +### Quick Start + +```bash +# Clone the repository +cd runanywhere-all/sdks/sdk/runanywhere-commons + +# Configure and build (macOS/Linux) +cmake -B build -DCMAKE_BUILD_TYPE=Release +cmake --build build + +# Build with backends +cmake -B build -DRAC_BUILD_BACKENDS=ON +cmake --build build +``` + +### Basic Usage + +```c +#include "rac/core/rac_core.h" +#include "rac/features/llm/rac_llm_service.h" + +// Initialize the library +rac_config_t config = { + .platform_adapter = &my_platform_adapter, + .log_level = RAC_LOG_INFO, + .log_tag = "MyApp" +}; +rac_init(&config); + +// Register backends +rac_backend_llamacpp_register(); +rac_backend_onnx_register(); + +// Create an LLM service +rac_handle_t llm; +rac_llm_create("my-model-id", &llm); + +// Generate text +rac_llm_result_t result; +rac_llm_generate(llm, "Hello!", NULL, &result); +printf("Response: %s\n", result.text); + +// Cleanup +rac_llm_result_free(&result); +rac_llm_destroy(llm); +rac_shutdown(); +``` + +--- + +## Building + +### CMake Options + +| Option | Default | Description | +|--------|---------|-------------| +| `RAC_BUILD_JNI` | OFF | Build JNI bridge for Android/JVM | +| `RAC_BUILD_TESTS` | OFF | Build unit tests | +| `RAC_BUILD_SHARED` | OFF | Build shared libraries (default: static) | +| `RAC_BUILD_PLATFORM` | ON | Build platform backend (Apple FM, System TTS) | +| `RAC_BUILD_BACKENDS` | OFF | Build ML backends | +| `RAC_BACKEND_LLAMACPP` | ON | Build LlamaCPP backend (when BACKENDS=ON) | +| `RAC_BACKEND_ONNX` | ON | Build ONNX backend (when BACKENDS=ON) | +| `RAC_BACKEND_WHISPERCPP` | ON | Build WhisperCPP backend (when BACKENDS=ON) | + +### Platform-Specific Builds + +#### iOS +```bash +./scripts/build-ios.sh # Full build +./scripts/build-ios.sh --skip-download # Use cached dependencies +./scripts/build-ios.sh --backend llamacpp # Specific backend only +./scripts/build-ios.sh --package # Create XCFramework ZIPs +``` + +#### Android +```bash +./scripts/build-android.sh # All backends, all ABIs +./scripts/build-android.sh llamacpp # LlamaCPP only +./scripts/build-android.sh onnx arm64-v8a # Specific backend + ABI +./scripts/build-android.sh --check # Verify 16KB alignment +``` + +### Build Outputs + +#### iOS/macOS +``` +dist/ +├── RACommons.xcframework # Core library +├── RABackendLLAMACPP.xcframework # LLM backend +└── RABackendONNX.xcframework # STT/TTS/VAD backend +``` + +#### Android +``` +dist/android/ +├── jni/{abi}/ # JNI libraries +│ ├── librac_commons_jni.so +│ ├── librac_backend_llamacpp_jni.so +│ └── librac_backend_onnx_jni.so +└── onnx/{abi}/ # ONNX runtime + └── libonnxruntime.so +``` + +--- + +## API Reference + +### Core API + +```c +// Initialization +rac_result_t rac_init(const rac_config_t* config); +void rac_shutdown(void); +rac_bool_t rac_is_initialized(void); +rac_version_t rac_get_version(void); + +// Module Registration +rac_result_t rac_module_register(const rac_module_info_t* info); +rac_result_t rac_module_unregister(const char* module_id); +rac_result_t rac_module_list(const rac_module_info_t** out_modules, size_t* out_count); + +// Service Creation +rac_result_t rac_service_register_provider(const rac_service_provider_t* provider); +rac_result_t rac_service_create(rac_capability_t capability, + const rac_service_request_t* request, + rac_handle_t* out_handle); +``` + +### LLM Service + +```c +rac_result_t rac_llm_create(const char* model_id, rac_handle_t* out_handle); +rac_result_t rac_llm_generate(rac_handle_t handle, const char* prompt, + const rac_llm_options_t* options, rac_llm_result_t* out_result); +rac_result_t rac_llm_generate_stream(rac_handle_t handle, const char* prompt, + const rac_llm_options_t* options, + rac_llm_stream_callback_fn callback, void* user_data); +rac_result_t rac_llm_cancel(rac_handle_t handle); +void rac_llm_destroy(rac_handle_t handle); +``` + +### STT Service + +```c +rac_result_t rac_stt_create(const char* model_path, rac_handle_t* out_handle); +rac_result_t rac_stt_transcribe(rac_handle_t handle, const void* audio_data, size_t audio_size, + const rac_stt_options_t* options, rac_stt_result_t* out_result); +rac_result_t rac_stt_transcribe_stream(rac_handle_t handle, const void* audio_data, + size_t audio_size, const rac_stt_options_t* options, + rac_stt_stream_callback_t callback, void* user_data); +void rac_stt_destroy(rac_handle_t handle); +``` + +### TTS Service + +```c +rac_result_t rac_tts_create(const char* voice_id, rac_handle_t* out_handle); +rac_result_t rac_tts_synthesize(rac_handle_t handle, const char* text, + const rac_tts_options_t* options, rac_tts_result_t* out_result); +rac_result_t rac_tts_stop(rac_handle_t handle); +void rac_tts_destroy(rac_handle_t handle); +``` + +### VAD Service + +```c +rac_result_t rac_vad_create(rac_handle_t* out_handle); +rac_result_t rac_vad_start(rac_handle_t handle); +rac_result_t rac_vad_stop(rac_handle_t handle); +rac_result_t rac_vad_process_samples(rac_handle_t handle, const float* samples, + size_t num_samples, rac_bool_t* out_is_speech); +void rac_vad_destroy(rac_handle_t handle); +``` + +### Voice Agent + +```c +rac_result_t rac_voice_agent_create_standalone(rac_voice_agent_handle_t* out_handle); +rac_result_t rac_voice_agent_load_stt_model(rac_voice_agent_handle_t handle, + const char* model_path, const char* model_id, + const char* model_name); +rac_result_t rac_voice_agent_load_llm_model(rac_voice_agent_handle_t handle, + const char* model_path, const char* model_id, + const char* model_name); +rac_result_t rac_voice_agent_load_tts_voice(rac_voice_agent_handle_t handle, + const char* voice_path, const char* voice_id, + const char* voice_name); +rac_result_t rac_voice_agent_process_voice_turn(rac_voice_agent_handle_t handle, + const void* audio_data, size_t audio_size, + rac_voice_agent_result_t* out_result); +void rac_voice_agent_destroy(rac_voice_agent_handle_t handle); +``` + +--- + +## Platform Integration + +### Swift SDK Integration + +The Swift SDK (`runanywhere-swift`) integrates via the `CRACommons` module: + +1. Swift imports C headers via module map +2. `SwiftPlatformAdapter` provides platform callbacks (storage, logging) +3. `CommonsErrorMapping` converts `rac_result_t` to Swift `SDKError` +4. `EventBridge` subscribes to C++ events, republishes to Swift `EventBus` + +### Kotlin SDK Integration + +The Kotlin SDK (`runanywhere-kotlin`) integrates via JNI: + +1. JNI bridge libraries: `librac_*_jni.so` +2. Platform adapter implemented via JNI callbacks +3. Type marshaling between Java and C types +4. Coroutine-based async wrappers around blocking C calls + +### Platform Adapter + +Platform SDKs must provide a `rac_platform_adapter_t` with callbacks for: + +```c +typedef struct rac_platform_adapter { + // File operations + rac_bool_t (*file_exists)(const char* path, void* user_data); + rac_result_t (*file_read)(const char* path, void** out_data, size_t* out_size, void* user_data); + rac_result_t (*file_write)(const char* path, const void* data, size_t size, void* user_data); + + // Secure storage + rac_result_t (*secure_get)(const char* key, char** out_value, void* user_data); + rac_result_t (*secure_set)(const char* key, const char* value, void* user_data); + + // Logging + void (*log)(rac_log_level_t level, const char* category, const char* message, void* user_data); + + // Time + int64_t (*now_ms)(void* user_data); + + // Optional: HTTP download, archive extraction + // ... + + void* user_data; +} rac_platform_adapter_t; +``` + +--- + +## Dependencies + +### External Dependencies + +| Dependency | Version | Purpose | +|------------|---------|---------| +| **llama.cpp** | b7650 | LLM inference engine | +| **Sherpa-ONNX** | 1.12.18+ | STT/TTS/VAD via ONNX Runtime | +| **ONNX Runtime** | 1.17.1+ | Neural network inference | +| **nlohmann/json** | 3.11.3 | JSON parsing | + +### Binary Outputs + +| Framework | Size | Provides | +|-----------|------|----------| +| `RACommons.xcframework` | ~2MB | Core infrastructure, registries, events | +| `RABackendLLAMACPP.xcframework` | ~15-25MB | LLM capability (GGUF models) | +| `RABackendONNX.xcframework` | ~50-70MB | STT, TTS, VAD (ONNX models) | + +--- + +## Version Management + +All versions are centralized in the `VERSIONS` file: + +```bash +PROJECT_VERSION=1.0.0 +IOS_DEPLOYMENT_TARGET=13.0 +ANDROID_MIN_SDK=24 +ONNX_VERSION_IOS=1.17.1 +SHERPA_ONNX_VERSION_IOS=1.12.18 +LLAMACPP_VERSION=b7650 +``` + +Load versions in scripts: +```bash +source scripts/load-versions.sh +echo "Using llama.cpp version: $LLAMACPP_VERSION" +``` + +Load versions in CMake: +```cmake +include(LoadVersions) +message(STATUS "ONNX Runtime version: ${ONNX_VERSION_IOS}") +``` + +--- + +## Error Codes + +Error codes are organized by range: + +| Range | Category | +|-------|----------| +| 0 | Success | +| -100 to -109 | Initialization errors | +| -110 to -129 | Model errors | +| -130 to -149 | Generation errors | +| -150 to -179 | Network errors | +| -180 to -219 | Storage errors | +| -220 to -229 | Hardware errors | +| -230 to -249 | Component state errors | +| -250 to -279 | Validation errors | +| -280 to -299 | Audio errors | +| -300 to -319 | Language/Voice errors | +| -400 to -499 | Module/Service errors | +| -600 to -699 | Backend errors | +| -700 to -799 | Event errors | + +--- + +## Contributing + +See the main repository [CONTRIBUTING.md](../../CONTRIBUTING.md) for guidelines. + +### Code Style + +- Follow Google C++ Style Guide with project customizations +- Run `./scripts/lint-cpp.sh --fix` before committing +- All public symbols use `rac_` prefix + +--- + +## License + +See [LICENSE](../../LICENSE) for details. diff --git a/sdk/runanywhere-commons/VERSION b/sdk/runanywhere-commons/VERSION new file mode 100644 index 000000000..9faa1b7a7 --- /dev/null +++ b/sdk/runanywhere-commons/VERSION @@ -0,0 +1 @@ +0.1.5 diff --git a/sdk/runanywhere-commons/VERSIONS b/sdk/runanywhere-commons/VERSIONS new file mode 100644 index 000000000..a8cf68d28 --- /dev/null +++ b/sdk/runanywhere-commons/VERSIONS @@ -0,0 +1,79 @@ +# ============================================================================= +# RunAnywhere Commons - Dependency Versions +# ============================================================================= +# This file is the SINGLE SOURCE OF TRUTH for all dependency versions. +# All scripts and CMake files should read from this file via: +# - Shell scripts: source scripts/load-versions.sh +# - CMake: include(LoadVersions) +# +# IMPORTANT: These versions MUST match runanywhere-core/VERSIONS +# The ONNX Runtime version used here must match what's in runanywhere-swift/Binaries/ +# +# Format: KEY=VALUE (no spaces around =) +# Lines starting with # are comments +# ============================================================================= + +# Project version (read from VERSION file, but also defined here for scripts) +PROJECT_VERSION=1.0.0 + +# ============================================================================= +# Platform Deployment Targets +# ============================================================================= +# iOS minimum deployment target +IOS_DEPLOYMENT_TARGET=13.0 + +# Android minimum SDK version +ANDROID_MIN_SDK=24 + +# ============================================================================= +# Build Tools +# ============================================================================= +# Xcode version for CI/CD builds +XCODE_VERSION=15.4 + +# ============================================================================= +# ONNX Runtime +# ============================================================================= +# iOS version MUST match: +# 1. What sherpa-onnx was built against +# 2. What's in runanywhere-swift/Binaries/onnxruntime.xcframework +# sherpa-onnx 1.12.17+ requires ONNX Runtime >= 1.17.1 +ONNX_VERSION_IOS=1.17.1 + +# Android version - used by Sherpa-ONNX, must be compatible +ONNX_VERSION_ANDROID=1.17.1 + +# macOS version (can use latest) +ONNX_VERSION_MACOS=1.23.2 + +# Linux version (can use latest) +ONNX_VERSION_LINUX=1.23.2 + +# ============================================================================= +# Sherpa-ONNX (via runanywhere-core) +# ============================================================================= +# iOS version +SHERPA_ONNX_VERSION_IOS=1.12.18 + +# Android version (v1.12.20+ has 16KB alignment for Play Store) +SHERPA_ONNX_VERSION_ANDROID=1.12.20 + +# macOS version +SHERPA_ONNX_VERSION_MACOS=1.12.18 + +# ============================================================================= +# llama.cpp (LLM inference) +# ============================================================================= +# b7650 - recent stable release (8 versions before b7658) +# NOTE: b7658 has Android cross-compilation issues, needs investigation +LLAMACPP_VERSION=b7650 + +# ============================================================================= +# nlohmann/json +# ============================================================================= +NLOHMANN_JSON_VERSION=3.11.3 + +# ============================================================================= +# RAC Commons Version (for remote builds/CI) +# ============================================================================= +RAC_COMMONS_VERSION=0.1.1 diff --git a/sdk/runanywhere-commons/cmake/FetchONNXRuntime.cmake b/sdk/runanywhere-commons/cmake/FetchONNXRuntime.cmake new file mode 100644 index 000000000..7f8340e84 --- /dev/null +++ b/sdk/runanywhere-commons/cmake/FetchONNXRuntime.cmake @@ -0,0 +1,215 @@ +# FetchONNXRuntime.cmake +# Downloads and configures ONNX Runtime pre-built binaries + +include(FetchContent) + +# Load versions from centralized VERSIONS file (SINGLE SOURCE OF TRUTH) +# All versions are defined in VERSIONS file - no hardcoded fallbacks needed +include(LoadVersions) + +# Validate required versions are loaded +if(NOT DEFINED ONNX_VERSION_IOS OR "${ONNX_VERSION_IOS}" STREQUAL "") + message(FATAL_ERROR "ONNX_VERSION_IOS not defined in VERSIONS file") +endif() +if(NOT DEFINED ONNX_VERSION_MACOS OR "${ONNX_VERSION_MACOS}" STREQUAL "") + message(FATAL_ERROR "ONNX_VERSION_MACOS not defined in VERSIONS file") +endif() +if(NOT DEFINED ONNX_VERSION_LINUX OR "${ONNX_VERSION_LINUX}" STREQUAL "") + message(FATAL_ERROR "ONNX_VERSION_LINUX not defined in VERSIONS file") +endif() + +message(STATUS "ONNX Runtime versions: iOS=${ONNX_VERSION_IOS}, Android=${ONNX_VERSION_ANDROID}, macOS=${ONNX_VERSION_MACOS}, Linux=${ONNX_VERSION_LINUX}") + +if(IOS OR CMAKE_SYSTEM_NAME STREQUAL "iOS") + # iOS: Use local ONNX Runtime xcframework from third_party + # Downloaded by: ./scripts/ios/download-onnx.sh + # NOTE: Version must match what sherpa-onnx was built against + + set(ONNX_IOS_VERSION "${ONNX_VERSION_IOS}") + + # third_party is inside runanywhere-commons + set(ONNX_LOCAL_PATH "${CMAKE_SOURCE_DIR}/third_party/onnxruntime-ios") + + message(STATUS "Using local ONNX Runtime iOS xcframework v${ONNX_IOS_VERSION}") + message(STATUS "ONNX Runtime path: ${ONNX_LOCAL_PATH}") + + # Verify the xcframework exists + if(NOT EXISTS "${ONNX_LOCAL_PATH}/onnxruntime.xcframework") + message(FATAL_ERROR "ONNX Runtime xcframework not found at ${ONNX_LOCAL_PATH}/onnxruntime.xcframework. " + "Please download it from https://download.onnxruntime.ai/pod-archive-onnxruntime-c-${ONNX_IOS_VERSION}.zip " + "and extract to ${ONNX_LOCAL_PATH}/") + endif() + + # Set onnxruntime_SOURCE_DIR to point to our local copy + set(onnxruntime_SOURCE_DIR "${ONNX_LOCAL_PATH}") + + # Create imported target for the static framework + add_library(onnxruntime STATIC IMPORTED GLOBAL) + + # Determine architecture-specific library path + if(CMAKE_OSX_SYSROOT MATCHES "simulator") + if(CMAKE_OSX_ARCHITECTURES MATCHES "arm64") + set(ONNX_FRAMEWORK_ARCH "ios-arm64_x86_64-simulator") + else() + set(ONNX_FRAMEWORK_ARCH "ios-arm64_x86_64-simulator") + endif() + else() + set(ONNX_FRAMEWORK_ARCH "ios-arm64") + endif() + + set(ONNX_XCFRAMEWORK_DIR "${onnxruntime_SOURCE_DIR}/onnxruntime.xcframework") + set(ONNX_ARCH_DIR "${ONNX_XCFRAMEWORK_DIR}/${ONNX_FRAMEWORK_ARCH}") + + # The xcframework may have different structures: + # 1. Static lib directly in arch folder: ios-arm64/libonnxruntime.a + # 2. Inside framework folder: ios-arm64/onnxruntime.framework/onnxruntime + if(EXISTS "${ONNX_ARCH_DIR}/libonnxruntime.a") + set(ONNX_LIB_PATH "${ONNX_ARCH_DIR}/libonnxruntime.a") + elseif(EXISTS "${ONNX_ARCH_DIR}/onnxruntime.a") + set(ONNX_LIB_PATH "${ONNX_ARCH_DIR}/onnxruntime.a") + elseif(EXISTS "${ONNX_ARCH_DIR}/onnxruntime.framework/onnxruntime") + set(ONNX_LIB_PATH "${ONNX_ARCH_DIR}/onnxruntime.framework/onnxruntime") + else() + message(FATAL_ERROR "Could not find ONNX Runtime library in ${ONNX_ARCH_DIR}") + endif() + + # Headers can be at xcframework root, arch folder, or in the local path + set(ONNX_HEADER_DIRS "") + if(EXISTS "${ONNX_XCFRAMEWORK_DIR}/Headers") + list(APPEND ONNX_HEADER_DIRS "${ONNX_XCFRAMEWORK_DIR}/Headers") + endif() + if(EXISTS "${ONNX_LOCAL_PATH}/Headers") + list(APPEND ONNX_HEADER_DIRS "${ONNX_LOCAL_PATH}/Headers") + endif() + + set_target_properties(onnxruntime PROPERTIES + IMPORTED_LOCATION "${ONNX_LIB_PATH}" + INTERFACE_INCLUDE_DIRECTORIES "${ONNX_HEADER_DIRS}" + ) + + # Also set linker flags for the framework + set_target_properties(onnxruntime PROPERTIES + INTERFACE_LINK_LIBRARIES "-framework Foundation;-framework CoreML" + ) + + message(STATUS "ONNX Runtime iOS arch dir: ${ONNX_ARCH_DIR}") + message(STATUS "ONNX Runtime static library: ${ONNX_LIB_PATH}") + message(STATUS "ONNX Runtime headers: ${ONNX_HEADER_DIRS}") + +elseif(ANDROID) + # Android: Use ONNX Runtime from Sherpa-ONNX (16KB aligned in v1.12.20+) + # Sherpa-ONNX version is defined in VERSIONS file: SHERPA_ONNX_VERSION_ANDROID + # Sherpa-ONNX bundles a compatible version of ONNX Runtime + # Downloaded by: ./scripts/android/download-sherpa-onnx.sh + set(SHERPA_ONNX_DIR "${CMAKE_SOURCE_DIR}/third_party/sherpa-onnx-android") + + # Check if Sherpa-ONNX libraries exist + if(EXISTS "${SHERPA_ONNX_DIR}/jniLibs/${ANDROID_ABI}/libonnxruntime.so") + message(STATUS "Using ONNX Runtime from Sherpa-ONNX (16KB aligned)") + + set(ONNX_LIB_PATH "${SHERPA_ONNX_DIR}/jniLibs/${ANDROID_ABI}/libonnxruntime.so") + set(ONNX_HEADER_PATH "${SHERPA_ONNX_DIR}/include") + + add_library(onnxruntime SHARED IMPORTED GLOBAL) + set_target_properties(onnxruntime PROPERTIES + IMPORTED_LOCATION "${ONNX_LIB_PATH}" + ) + target_include_directories(onnxruntime INTERFACE "${ONNX_HEADER_PATH}") + + message(STATUS "ONNX Runtime Android library: ${ONNX_LIB_PATH}") + message(STATUS "ONNX Runtime Android headers: ${ONNX_HEADER_PATH}") + else() + message(FATAL_ERROR "Sherpa-ONNX not found. Please run: ./scripts/android/download-sherpa-onnx.sh") + endif() + +elseif(APPLE) + # macOS: Use local ONNX Runtime from third_party if available, otherwise download + # Downloaded by: ./scripts/macos/download-onnx.sh + + set(ONNX_MACOS_VERSION "${ONNX_VERSION_MACOS}") + set(ONNX_MACOS_DIR "${CMAKE_SOURCE_DIR}/third_party/onnxruntime-macos") + + if(EXISTS "${ONNX_MACOS_DIR}/lib/libonnxruntime.dylib") + # Use local ONNX Runtime + message(STATUS "Using local ONNX Runtime macOS from ${ONNX_MACOS_DIR}") + + set(onnxruntime_SOURCE_DIR "${ONNX_MACOS_DIR}") + + add_library(onnxruntime SHARED IMPORTED GLOBAL) + + # Get the versioned dylib name for proper linking + file(GLOB ONNX_DYLIB_FILES "${ONNX_MACOS_DIR}/lib/libonnxruntime*.dylib") + list(GET ONNX_DYLIB_FILES 0 ONNX_DYLIB_PATH) + + set_target_properties(onnxruntime PROPERTIES + IMPORTED_LOCATION "${ONNX_MACOS_DIR}/lib/libonnxruntime.dylib" + ) + + target_include_directories(onnxruntime INTERFACE + "${ONNX_MACOS_DIR}/include" + ) + + # Add rpath for finding the dylib at runtime + set_target_properties(onnxruntime PROPERTIES + INTERFACE_LINK_LIBRARIES "-Wl,-rpath,@executable_path/../Frameworks" + ) + + message(STATUS "ONNX Runtime macOS library: ${ONNX_MACOS_DIR}/lib/libonnxruntime.dylib") + message(STATUS "ONNX Runtime macOS headers: ${ONNX_MACOS_DIR}/include") + else() + # Download ONNX Runtime if not present + message(STATUS "Local ONNX Runtime not found, downloading...") + set(ONNX_URL "https://github.com/microsoft/onnxruntime/releases/download/v${ONNX_MACOS_VERSION}/onnxruntime-osx-universal2-${ONNX_MACOS_VERSION}.tgz") + + FetchContent_Declare( + onnxruntime + URL ${ONNX_URL} + DOWNLOAD_EXTRACT_TIMESTAMP TRUE + ) + + FetchContent_MakeAvailable(onnxruntime) + + add_library(onnxruntime SHARED IMPORTED GLOBAL) + + set_target_properties(onnxruntime PROPERTIES + IMPORTED_LOCATION "${onnxruntime_SOURCE_DIR}/lib/libonnxruntime.dylib" + ) + + target_include_directories(onnxruntime INTERFACE + "${onnxruntime_SOURCE_DIR}/include" + ) + + message(STATUS "ONNX Runtime macOS library: ${onnxruntime_SOURCE_DIR}/lib/libonnxruntime.dylib") + endif() + +elseif(UNIX) + # Linux: Download Linux binaries + if(CMAKE_SYSTEM_PROCESSOR MATCHES "aarch64") + set(ONNX_URL "https://github.com/microsoft/onnxruntime/releases/download/v${ONNX_VERSION_LINUX}/onnxruntime-linux-aarch64-${ONNX_VERSION_LINUX}.tgz") + else() + set(ONNX_URL "https://github.com/microsoft/onnxruntime/releases/download/v${ONNX_VERSION_LINUX}/onnxruntime-linux-x64-${ONNX_VERSION_LINUX}.tgz") + endif() + + FetchContent_Declare( + onnxruntime + URL ${ONNX_URL} + DOWNLOAD_EXTRACT_TIMESTAMP TRUE + ) + + FetchContent_MakeAvailable(onnxruntime) + + add_library(onnxruntime SHARED IMPORTED GLOBAL) + + set_target_properties(onnxruntime PROPERTIES + IMPORTED_LOCATION "${onnxruntime_SOURCE_DIR}/lib/libonnxruntime.so" + ) + + target_include_directories(onnxruntime INTERFACE + "${onnxruntime_SOURCE_DIR}/include" + ) + + message(STATUS "ONNX Runtime Linux library: ${onnxruntime_SOURCE_DIR}/lib/libonnxruntime.so") + +else() + message(FATAL_ERROR "Unsupported platform for ONNX Runtime") +endif() diff --git a/sdk/runanywhere-commons/cmake/LoadVersions.cmake b/sdk/runanywhere-commons/cmake/LoadVersions.cmake new file mode 100644 index 000000000..56bee13fa --- /dev/null +++ b/sdk/runanywhere-commons/cmake/LoadVersions.cmake @@ -0,0 +1,65 @@ +# ============================================================================= +# LoadVersions.cmake +# ============================================================================= +# Reads version definitions from the VERSIONS file at the project root. +# This ensures CMake and shell scripts use the same version values. +# +# Usage: +# include(LoadVersions) +# # Then use: ${RAC_ONNX_VERSION_IOS}, ${RAC_SHERPA_ONNX_VERSION_IOS}, etc. +# +# All variables are also set without the RAC_ prefix for backward compatibility. +# ============================================================================= + +# Find VERSIONS file relative to this cmake module +set(_VERSIONS_FILE "${CMAKE_CURRENT_LIST_DIR}/../VERSIONS") + +if(NOT EXISTS "${_VERSIONS_FILE}") + message(FATAL_ERROR "VERSIONS file not found at ${_VERSIONS_FILE}") +endif() + +# Read the file +file(READ "${_VERSIONS_FILE}" _VERSIONS_CONTENT) + +# Convert to list of lines +string(REPLACE "\n" ";" _VERSIONS_LINES "${_VERSIONS_CONTENT}") + +# Parse each line +foreach(_LINE IN LISTS _VERSIONS_LINES) + # Skip empty lines and comments + string(STRIP "${_LINE}" _LINE) + if("${_LINE}" STREQUAL "" OR "${_LINE}" MATCHES "^#") + continue() + endif() + + # Parse KEY=VALUE + string(REGEX MATCH "^([A-Za-z_][A-Za-z0-9_]*)=(.*)$" _MATCH "${_LINE}") + if(_MATCH) + set(_KEY "${CMAKE_MATCH_1}") + set(_VALUE "${CMAKE_MATCH_2}") + + # Set as CMake variable with RAC_ prefix + set(RAC_${_KEY} "${_VALUE}" CACHE STRING "Version from VERSIONS file" FORCE) + + # Also set without prefix for backward compatibility + set(${_KEY} "${_VALUE}" CACHE STRING "Version from VERSIONS file" FORCE) + endif() +endforeach() + +# Log loaded versions +message(STATUS "Loaded versions from ${_VERSIONS_FILE}:") +message(STATUS " Platform targets:") +message(STATUS " IOS_DEPLOYMENT_TARGET: ${RAC_IOS_DEPLOYMENT_TARGET}") +message(STATUS " ANDROID_MIN_SDK: ${RAC_ANDROID_MIN_SDK}") +message(STATUS " ONNX Runtime:") +message(STATUS " ONNX_VERSION_IOS: ${RAC_ONNX_VERSION_IOS}") +message(STATUS " ONNX_VERSION_ANDROID: ${RAC_ONNX_VERSION_ANDROID}") +message(STATUS " ONNX_VERSION_MACOS: ${RAC_ONNX_VERSION_MACOS}") +message(STATUS " ONNX_VERSION_LINUX: ${RAC_ONNX_VERSION_LINUX}") +message(STATUS " Sherpa-ONNX:") +message(STATUS " SHERPA_ONNX_VERSION_IOS: ${RAC_SHERPA_ONNX_VERSION_IOS}") +message(STATUS " SHERPA_ONNX_VERSION_ANDROID: ${RAC_SHERPA_ONNX_VERSION_ANDROID}") +message(STATUS " SHERPA_ONNX_VERSION_MACOS: ${RAC_SHERPA_ONNX_VERSION_MACOS}") +message(STATUS " Other:") +message(STATUS " LLAMACPP_VERSION: ${RAC_LLAMACPP_VERSION}") +message(STATUS " NLOHMANN_JSON_VERSION: ${RAC_NLOHMANN_JSON_VERSION}") diff --git a/sdk/runanywhere-commons/cmake/ios.toolchain.cmake b/sdk/runanywhere-commons/cmake/ios.toolchain.cmake new file mode 100644 index 000000000..79e2f9e71 --- /dev/null +++ b/sdk/runanywhere-commons/cmake/ios.toolchain.cmake @@ -0,0 +1,139 @@ +# ios.toolchain.cmake +# CMake toolchain file for iOS cross-compilation +# +# Usage: +# cmake -DCMAKE_TOOLCHAIN_FILE=cmake/ios.toolchain.cmake \ +# -DIOS_PLATFORM=OS|SIMULATOR|SIMULATORARM64 \ +# -DIOS_DEPLOYMENT_TARGET=13.0 \ +# .. + +# Platform selection (must be set before project()) +if(NOT DEFINED IOS_PLATFORM) + set(IOS_PLATFORM "OS" CACHE STRING "iOS platform: OS, SIMULATOR, SIMULATORARM64") +endif() + +# Deployment target +# The VERSIONS file is the SINGLE SOURCE OF TRUTH for this value. +# This can be set via: +# 1. CMake variable: -DIOS_DEPLOYMENT_TARGET=13.0 +# 2. Environment variable (set by build scripts via: source scripts/load-versions.sh) +# +# IMPORTANT: Build scripts should always source load-versions.sh which exports +# IOS_DEPLOYMENT_TARGET from VERSIONS file to the environment. +if(NOT DEFINED IOS_DEPLOYMENT_TARGET) + if(DEFINED ENV{IOS_DEPLOYMENT_TARGET}) + set(IOS_DEPLOYMENT_TARGET "$ENV{IOS_DEPLOYMENT_TARGET}" CACHE STRING "iOS deployment target version") + else() + # Fallback value - should match VERSIONS file (IOS_DEPLOYMENT_TARGET=13.0) + # This is only used if build scripts don't source load-versions.sh + message(WARNING "IOS_DEPLOYMENT_TARGET not set via environment. Using fallback value. " + "Build scripts should source scripts/load-versions.sh to get version from VERSIONS file.") + set(IOS_DEPLOYMENT_TARGET "13.0" CACHE STRING "iOS deployment target version") + endif() +endif() + +# Enable bitcode (deprecated in iOS 16, but still needed for older targets) +if(NOT DEFINED IOS_ENABLE_BITCODE) + set(IOS_ENABLE_BITCODE OFF CACHE BOOL "Enable bitcode") +endif() + +# Configure based on platform +if(IOS_PLATFORM STREQUAL "OS") + set(CMAKE_SYSTEM_NAME iOS) + set(CMAKE_OSX_ARCHITECTURES "arm64") + set(CMAKE_OSX_SYSROOT "iphoneos") + set(IOS_PLATFORM_SUFFIX "iphoneos") +elseif(IOS_PLATFORM STREQUAL "SIMULATOR") + set(CMAKE_SYSTEM_NAME iOS) + set(CMAKE_OSX_ARCHITECTURES "x86_64") + set(CMAKE_OSX_SYSROOT "iphonesimulator") + set(IOS_PLATFORM_SUFFIX "iphonesimulator") +elseif(IOS_PLATFORM STREQUAL "SIMULATORARM64") + set(CMAKE_SYSTEM_NAME iOS) + set(CMAKE_OSX_ARCHITECTURES "arm64") + set(CMAKE_OSX_SYSROOT "iphonesimulator") + set(IOS_PLATFORM_SUFFIX "iphonesimulator") +elseif(IOS_PLATFORM STREQUAL "MACCATALYST") + set(CMAKE_SYSTEM_NAME Darwin) + set(CMAKE_OSX_ARCHITECTURES "arm64;x86_64") + set(IOS_PLATFORM_SUFFIX "maccatalyst") +else() + message(FATAL_ERROR "Invalid IOS_PLATFORM: ${IOS_PLATFORM}") +endif() + +# Set deployment target +set(CMAKE_OSX_DEPLOYMENT_TARGET "${IOS_DEPLOYMENT_TARGET}") + +# Find SDK path +execute_process( + COMMAND xcrun --sdk ${CMAKE_OSX_SYSROOT} --show-sdk-path + OUTPUT_VARIABLE CMAKE_OSX_SYSROOT_PATH + OUTPUT_STRIP_TRAILING_WHITESPACE +) + +if(NOT CMAKE_OSX_SYSROOT_PATH) + message(FATAL_ERROR "Could not find iOS SDK for ${CMAKE_OSX_SYSROOT}") +endif() + +set(CMAKE_OSX_SYSROOT "${CMAKE_OSX_SYSROOT_PATH}") + +# Compiler flags (bitcode is deprecated in iOS 14+ and removed in iOS 16) +set(CMAKE_C_FLAGS_INIT "") +set(CMAKE_CXX_FLAGS_INIT "") + +# Skip RPATH handling (not applicable for static libraries) +set(CMAKE_MACOSX_RPATH OFF) +set(CMAKE_SKIP_RPATH TRUE) + +# Use static libraries by default +set(BUILD_SHARED_LIBS OFF) + +# Set compiler (use Clang from Xcode) +execute_process( + COMMAND xcrun --find clang + OUTPUT_VARIABLE CMAKE_C_COMPILER + OUTPUT_STRIP_TRAILING_WHITESPACE +) + +execute_process( + COMMAND xcrun --find clang++ + OUTPUT_VARIABLE CMAKE_CXX_COMPILER + OUTPUT_STRIP_TRAILING_WHITESPACE +) + +# AR and RANLIB +execute_process( + COMMAND xcrun --find ar + OUTPUT_VARIABLE CMAKE_AR + OUTPUT_STRIP_TRAILING_WHITESPACE +) + +execute_process( + COMMAND xcrun --find ranlib + OUTPUT_VARIABLE CMAKE_RANLIB + OUTPUT_STRIP_TRAILING_WHITESPACE +) + +# Set minimum iOS version flag +if(IOS_PLATFORM STREQUAL "SIMULATOR" OR IOS_PLATFORM STREQUAL "SIMULATORARM64") + set(IOS_MIN_VERSION_FLAG "-mios-simulator-version-min=${IOS_DEPLOYMENT_TARGET}") +else() + set(IOS_MIN_VERSION_FLAG "-miphoneos-version-min=${IOS_DEPLOYMENT_TARGET}") +endif() + +set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS_INIT} ${IOS_MIN_VERSION_FLAG}" CACHE STRING "" FORCE) +set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS_INIT} ${IOS_MIN_VERSION_FLAG}" CACHE STRING "" FORCE) + +# Don't search in system paths +set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) +set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) +set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + +# Output configuration +message(STATUS "iOS Toolchain Configuration:") +message(STATUS " Platform: ${IOS_PLATFORM}") +message(STATUS " Architectures: ${CMAKE_OSX_ARCHITECTURES}") +message(STATUS " SDK: ${CMAKE_OSX_SYSROOT}") +message(STATUS " Deployment Target: ${IOS_DEPLOYMENT_TARGET}") +message(STATUS " Bitcode: ${IOS_ENABLE_BITCODE}") diff --git a/sdk/runanywhere-commons/docs/ARCHITECTURE.md b/sdk/runanywhere-commons/docs/ARCHITECTURE.md new file mode 100644 index 000000000..c902e82fc --- /dev/null +++ b/sdk/runanywhere-commons/docs/ARCHITECTURE.md @@ -0,0 +1,1069 @@ +# RunAnywhere Commons - Architecture + +## Table of Contents + +- [Overview](#overview) +- [Design Philosophy](#design-philosophy) +- [Layer Architecture](#layer-architecture) +- [Directory Structure](#directory-structure) +- [Core Components](#core-components) +- [Service Abstraction Layer](#service-abstraction-layer) +- [Backend Implementations](#backend-implementations) +- [Data Flow](#data-flow) +- [Concurrency Model](#concurrency-model) +- [Memory Management](#memory-management) +- [Event System](#event-system) +- [Platform Adapter](#platform-adapter) +- [Error Handling](#error-handling) +- [Extensibility](#extensibility) +- [Testing](#testing) +- [Design Decisions](#design-decisions) + +--- + +## Overview + +RunAnywhere Commons (`runanywhere-commons`) is the shared C++ foundation for the RunAnywhere SDK ecosystem. It provides a unified abstraction layer over multiple ML inference backends, enabling platform SDKs (Swift, Kotlin, Flutter) to access on-device AI capabilities through a consistent C API. + +### Key Architectural Goals + +1. **Cross-Platform Consistency** - Single C++ codebase, identical API semantics across iOS, Android, macOS, Linux +2. **Backend Agnosticism** - Pluggable backends registered at runtime; SDK code doesn't know which backend is used +3. **FFI Compatibility** - Pure C API surface for easy binding to Swift, Kotlin, Dart, and other languages +4. **Performance** - Minimal abstraction overhead; backends operate at native speed +5. **Modularity** - Separate XCFrameworks for each backend allows apps to include only what they need + +--- + +## Design Philosophy + +### Vtable-Based Polymorphism + +Unlike traditional C++ virtual inheritance, all service abstractions use C-style vtables: + +```c +// Service interface = struct with ops pointer + implementation handle +typedef struct rac_llm_service { + const rac_llm_service_ops_t* ops; // Function pointers + void* impl; // Backend-specific handle + const char* model_id; +} rac_llm_service_t; + +// Operations vtable - each backend provides one +typedef struct rac_llm_service_ops { + rac_result_t (*initialize)(void* impl, const char* model_path); + rac_result_t (*generate)(void* impl, const char* prompt, + const rac_llm_options_t* options, + rac_llm_result_t* out_result); + rac_result_t (*generate_stream)(void* impl, const char* prompt, + const rac_llm_options_t* options, + rac_llm_stream_callback_fn callback, + void* user_data); + rac_result_t (*cancel)(void* impl); + void (*destroy)(void* impl); +} rac_llm_service_ops_t; +``` + +**Rationale:** +- No C++ RTTI or exceptions cross FFI boundaries +- Compatible with C FFI (Swift, JNI, Dart FFI) +- Backend can be statically or dynamically linked +- Service instance is a simple POD struct + +### Priority-Based Provider Selection + +Service creation mirrors Swift's `ServiceRegistry` pattern: + +```c +// Provider declares capability + priority + canHandle function +rac_service_provider_t provider = { + .name = "LlamaCPPService", + .capability = RAC_CAPABILITY_TEXT_GENERATION, + .priority = 100, + .can_handle = llamacpp_can_handle, + .create = llamacpp_create_service, +}; +rac_service_register_provider(&provider); + +// Service creation queries providers in priority order +// First provider where canHandle returns true creates the service +rac_service_create(RAC_CAPABILITY_TEXT_GENERATION, &request, &handle); +``` + +**Resolution Flow:** +1. Registry sorts providers by priority (higher first) +2. For each provider, call `can_handle(request)` +3. First provider returning `true` calls its `create` function +4. Created service handle returned to caller + +--- + +## Layer Architecture + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ Platform SDK Layer │ +│ │ +│ ┌──────────────────┐ ┌──────────────────┐ ┌────────────────────────┐ │ +│ │ Swift SDK │ │ Kotlin SDK │ │ Flutter SDK │ │ +│ │ (CRACommons) │ │ (JNI Bridge) │ │ (Dart FFI) │ │ +│ └────────┬─────────┘ └────────┬─────────┘ └───────────┬────────────┘ │ +└───────────│──────────────────────│───────────────────────│──────────────┘ + │ │ │ + └──────────────────────┼───────────────────────┘ + │ + C API (rac_*) + │ +┌──────────────────────────────────▼──────────────────────────────────────┐ +│ RAC Public API Layer │ +│ │ +│ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ ┌───────────┐ │ +│ │ rac_llm.h │ │ rac_stt.h │ │ rac_tts.h │ │ rac_vad.h │ │ +│ │ LLM Service │ │ STT Service │ │ TTS Service │ │ VAD Svc │ │ +│ └───────┬───────┘ └───────┬───────┘ └───────┬───────┘ └─────┬─────┘ │ +│ │ │ │ │ │ +│ ┌───────▼──────────────────▼──────────────────▼────────────────▼─────┐ │ +│ │ Service Registry │ │ +│ │ Priority-based provider selection │ │ +│ │ canHandle → create → service handle │ │ +│ └────────────────────────────────┬────────────────────────────────────┘ │ +└───────────────────────────────────│─────────────────────────────────────┘ + │ + │ vtable dispatch + │ +┌───────────────────────────────────▼─────────────────────────────────────┐ +│ Backend Layer │ +│ │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ LlamaCPP │ │ ONNX │ │ WhisperCPP │ │ +│ │ Backend │ │ Backend │ │ Backend │ │ +│ │ │ │ │ │ │ │ +│ │ • GGUF models │ │ • STT (Sherpa) │ │ • STT (GGML) │ │ +│ │ • Metal GPU │ │ • TTS (Piper) │ │ • Multi-lang │ │ +│ │ • Streaming │ │ • VAD (Silero) │ │ • Fast CPU │ │ +│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────────┐│ +│ │ Platform Backend (Apple only) ││ +│ │ • Apple Foundation Models (LLM via Swift callbacks) ││ +│ │ • System TTS (AVSpeechSynthesizer via Swift callbacks) ││ +│ └─────────────────────────────────────────────────────────────────────┘│ +└─────────────────────────────────────────────────────────────────────────┘ + │ + │ +┌───────────────────────────────────▼─────────────────────────────────────┐ +│ Infrastructure Layer │ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │ +│ │ Logging │ │ Events │ │ Errors │ │ Platform Adapter│ │ +│ │ System │ │ System │ │ Handling │ │ (Callbacks) │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────────┘ │ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────────────┐ │ +│ │ Module │ │ Model │ │ Telemetry Manager │ │ +│ │ Registry │ │ Registry │ │ (Analytics events to SDK) │ │ +│ └─────────────┘ └─────────────┘ └─────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Directory Structure + +``` +runanywhere-commons/ +├── include/rac/ # Public C headers (rac_* prefix) +│ ├── core/ # Core infrastructure +│ │ ├── rac_core.h # Main SDK initialization +│ │ ├── rac_error.h # Error codes (-100 to -999) +│ │ ├── rac_types.h # Basic types, handles, strings +│ │ ├── rac_logger.h # Logging interface +│ │ ├── rac_events.h # Event system +│ │ ├── rac_platform_adapter.h # Platform callbacks +│ │ └── capabilities/ +│ │ └── rac_lifecycle.h # Component lifecycle states +│ │ +│ ├── features/ # Service interfaces +│ │ ├── llm/ # Large Language Models +│ │ │ ├── rac_llm_service.h # LLM vtable interface +│ │ │ ├── rac_llm_types.h # LLM data structures +│ │ │ └── rac_llm.h # Public API wrapper +│ │ ├── stt/ # Speech-to-Text +│ │ │ ├── rac_stt_service.h # STT vtable interface +│ │ │ ├── rac_stt_types.h # STT data structures +│ │ │ └── rac_stt.h # Public API +│ │ ├── tts/ # Text-to-Speech +│ │ │ ├── rac_tts_service.h # TTS vtable interface +│ │ │ ├── rac_tts_types.h # TTS data structures +│ │ │ └── rac_tts.h # Public API +│ │ ├── vad/ # Voice Activity Detection +│ │ │ ├── rac_vad_service.h # VAD vtable interface +│ │ │ ├── rac_vad_types.h # VAD data structures +│ │ │ └── rac_vad.h # Public API +│ │ ├── voice_agent/ # Complete voice pipeline +│ │ │ └── rac_voice_agent.h # STT+LLM+TTS+VAD orchestration +│ │ └── platform/ # Platform-specific backends +│ │ ├── rac_llm_platform.h # Apple Foundation Models +│ │ └── rac_tts_platform.h # Apple System TTS +│ │ +│ ├── infrastructure/ # Support services +│ │ ├── model_management/ # Model registry and lifecycle +│ │ ├── network/ # Network types and endpoints +│ │ ├── device/ # Device management +│ │ ├── storage/ # Storage analysis +│ │ └── telemetry/ # Analytics +│ │ +│ └── backends/ # Backend-specific public headers +│ ├── rac_llm_llamacpp.h # LlamaCPP backend API +│ ├── rac_stt_whispercpp.h # WhisperCPP backend API +│ ├── rac_stt_onnx.h # ONNX STT API +│ ├── rac_tts_onnx.h # ONNX TTS API +│ └── rac_vad_onnx.h # ONNX VAD API +│ +├── src/ # Implementation files +│ ├── core/ # Core implementations +│ ├── infrastructure/ # Infrastructure implementations +│ │ ├── registry/ # Module & service registries +│ │ ├── model_management/ # Model handling +│ │ ├── network/ # HTTP client, auth +│ │ └── telemetry/ # Telemetry manager +│ ├── features/ # Feature implementations +│ │ ├── llm/ # LLM component & service +│ │ ├── stt/ # STT component & service +│ │ ├── tts/ # TTS component & service +│ │ ├── vad/ # VAD component & energy VAD +│ │ ├── voice_agent/ # Voice agent orchestration +│ │ └── platform/ # Platform backend stubs +│ ├── backends/ # ML backend implementations +│ │ ├── llamacpp/ # LlamaCPP integration +│ │ ├── onnx/ # ONNX/Sherpa-ONNX integration +│ │ └── whispercpp/ # WhisperCPP integration +│ └── jni/ # JNI bridge for Android +│ +├── cmake/ # CMake modules +├── scripts/ # Build automation +├── third_party/ # Pre-built dependencies +├── dist/ # Build outputs (xcframeworks) +├── CMakeLists.txt # Main CMake configuration +├── VERSION # Project version +└── VERSIONS # Dependency versions +``` + +--- + +## Core Components + +### Initialization (rac_core.h) + +The library must be initialized before use: + +```c +// Required: Platform adapter with callbacks +rac_platform_adapter_t adapter = { + .file_exists = my_file_exists, + .file_read = my_file_read, + .log = my_log_callback, + .now_ms = my_get_time_ms, + .user_data = my_context +}; + +rac_config_t config = { + .platform_adapter = &adapter, + .log_level = RAC_LOG_INFO, + .log_tag = "MyApp" +}; + +rac_result_t result = rac_init(&config); +``` + +**Initialization Flow:** +1. Validate platform adapter (required callbacks) +2. Initialize logging system +3. Initialize module registry +4. Initialize service registry +5. Initialize model registry +6. Set initialized flag + +### Module Registry + +Backends register as modules declaring their capabilities: + +```c +rac_module_info_t info = { + .id = "llamacpp", + .name = "LlamaCPP", + .version = "1.0.0", + .description = "LLM backend using llama.cpp", + .capabilities = (rac_capability_t[]){RAC_CAPABILITY_TEXT_GENERATION}, + .num_capabilities = 1 +}; +rac_module_register(&info); +``` + +**Module Registry Responsibilities:** +- Track registered modules +- Query modules by capability +- Support runtime module discovery + +### Service Registry + +Services are created through registered providers: + +```c +// Backend registers provider +rac_service_provider_t provider = { + .name = "LlamaCPPService", + .capability = RAC_CAPABILITY_TEXT_GENERATION, + .priority = 100, + .can_handle = llamacpp_can_handle, + .create = llamacpp_create_service, + .user_data = NULL +}; +rac_service_register_provider(&provider); + +// SDK creates service +rac_service_request_t request = { + .identifier = "my-model", + .capability = RAC_CAPABILITY_TEXT_GENERATION, + .framework = RAC_FRAMEWORK_LLAMA_CPP, + .model_path = "/path/to/model.gguf" +}; + +rac_handle_t service; +rac_service_create(RAC_CAPABILITY_TEXT_GENERATION, &request, &service); +``` + +**Provider Selection Algorithm:** +1. Filter providers by capability +2. Sort by priority (descending) +3. For each provider: if `can_handle(request)` → call `create(request)` +4. Return first successful service handle + +### Logging System + +Unified logging through platform adapter: + +```c +// Logging macros +RAC_LOG_DEBUG("LLM.LlamaCpp", "Loading model: %s", model_path); +RAC_LOG_INFO("ServiceRegistry", "Provider registered: %s", name); +RAC_LOG_WARNING("VAD", "Energy threshold too low: %f", threshold); +RAC_LOG_ERROR("STT", "Transcription failed: %s", rac_error_message(result)); + +// Implementation routes to platform +void rac_log(rac_log_level_t level, const char* category, const char* message) { + const rac_platform_adapter_t* adapter = rac_get_platform_adapter(); + if (adapter && adapter->log) { + adapter->log(level, category, message, adapter->user_data); + } +} +``` + +**Log Levels:** +- `RAC_LOG_TRACE` (0) - Verbose debugging +- `RAC_LOG_DEBUG` (1) - Debug information +- `RAC_LOG_INFO` (2) - General information +- `RAC_LOG_WARNING` (3) - Warnings +- `RAC_LOG_ERROR` (4) - Errors +- `RAC_LOG_FATAL` (5) - Fatal errors + +--- + +## Service Abstraction Layer + +### Service Interface Pattern + +Each capability (LLM, STT, TTS, VAD) follows the same pattern: + +```c +// 1. Types header (rac__types.h) +typedef struct rac__options { ... } rac__options_t; +typedef struct rac__result { ... } rac__result_t; + +// 2. Service interface (rac__service.h) +typedef struct rac__service_ops { + rac_result_t (*initialize)(void* impl, ...); + rac_result_t (*process)(void* impl, ...); + void (*destroy)(void* impl); +} rac__service_ops_t; + +typedef struct rac__service { + const rac__service_ops_t* ops; + void* impl; + const char* model_id; +} rac__service_t; + +// 3. Public API (rac_.h) +RAC_API rac_result_t rac__create(const char* id, rac_handle_t* out); +RAC_API rac_result_t rac__process(rac_handle_t h, ...); +RAC_API void rac__destroy(rac_handle_t h); +``` + +### LLM Service + +**Types:** +```c +typedef struct rac_llm_options { + int32_t max_tokens; // Maximum tokens to generate + float temperature; // Sampling temperature (0.0-2.0) + float top_p; // Nucleus sampling threshold + int32_t top_k; // Top-k sampling + const char* system_prompt; + rac_bool_t streaming_enabled; +} rac_llm_options_t; + +typedef struct rac_llm_result { + char* text; // Generated text (owned) + int32_t input_tokens; // Prompt tokens + int32_t output_tokens; // Generated tokens + double duration_ms; // Generation time + double tokens_per_second; + double time_to_first_token_ms; + char* thinking_content; // Reasoning (if supported) +} rac_llm_result_t; +``` + +**Streaming Callback:** +```c +typedef rac_bool_t (*rac_llm_stream_callback_fn)( + const char* token, // Generated token + rac_bool_t is_final, // Is this the last token? + void* user_data +); +// Return RAC_FALSE to stop generation +``` + +### STT Service + +**Types:** +```c +typedef struct rac_stt_options { + const char* language; // Language code (e.g., "en") + rac_bool_t detect_language; + rac_bool_t enable_timestamps; + int32_t sample_rate; // Audio sample rate +} rac_stt_options_t; + +typedef struct rac_stt_result { + char* text; // Transcribed text + float confidence; // 0.0-1.0 + const char* language; // Detected language + double duration_ms; // Processing time + // Word timestamps (optional) +} rac_stt_result_t; +``` + +### TTS Service + +**Types:** +```c +typedef struct rac_tts_options { + const char* voice; // Voice identifier + const char* language; // Language code + float rate; // Speaking rate (0.5-2.0) + float pitch; // Voice pitch (0.5-2.0) + int32_t sample_rate; // Output sample rate +} rac_tts_options_t; + +typedef struct rac_tts_result { + void* audio_data; // PCM audio (owned) + size_t audio_size; // Size in bytes + double duration_seconds; // Audio duration + int32_t sample_rate; // Sample rate +} rac_tts_result_t; +``` + +### VAD Service + +**Types:** +```c +typedef struct rac_vad_result { + rac_bool_t has_speech; // Speech detected + float confidence; // Detection confidence + double speech_start_ms; // Speech start time + double speech_end_ms; // Speech end time +} rac_vad_result_t; +``` + +--- + +## Backend Implementations + +### LlamaCPP Backend + +**Architecture:** +``` +┌─────────────────────────────────────────────────────────────┐ +│ rac_llm_llamacpp.h │ +│ Public API + Registration │ +└────────────────────────────┬────────────────────────────────┘ + │ +┌────────────────────────────▼────────────────────────────────┐ +│ rac_backend_llamacpp_register.cpp │ +│ • Registers module with capabilities │ +│ • Registers service provider │ +│ • Implements can_handle (checks .gguf extension) │ +│ • Implements create (creates service with vtable) │ +└────────────────────────────┬────────────────────────────────┘ + │ +┌────────────────────────────▼────────────────────────────────┐ +│ llamacpp_backend.cpp │ +│ LlamaCppBackend class: │ +│ • initialize() - Init llama.cpp backend │ +│ • cleanup() - Free resources │ +│ LlamaCppTextGeneration class: │ +│ • load_model() - Load GGUF model │ +│ • generate() - Blocking generation │ +│ • generate_stream() - Streaming generation │ +│ • cancel() - Abort generation │ +└────────────────────────────┬────────────────────────────────┘ + │ + llama.cpp library +``` + +**Key Implementation Details:** + +1. **Model Loading:** + - Uses `llama_model_load_from_file()` + - Auto-detects context size from model metadata + - Configures GPU layers for Metal acceleration + +2. **Text Generation:** + - Tokenizes prompt with `common_tokenize()` + - Applies chat template via `llama_chat_apply_template()` + - Samples tokens with configurable sampler chain + - Supports streaming via callback + +3. **Cancellation:** + - Atomic boolean flag checked in generation loop + - Graceful abort with partial result + +### ONNX Backend (Sherpa-ONNX) + +**Architecture:** +``` +┌─────────────────────────────────────────────────────────────┐ +│ rac_stt_onnx.h rac_tts_onnx.h rac_vad_onnx.h │ +│ Public APIs for STT/TTS/VAD │ +└────────────────────────────┬────────────────────────────────┘ + │ +┌────────────────────────────▼────────────────────────────────┐ +│ rac_backend_onnx_register.cpp │ +│ • Registers ONNX module │ +│ • Registers STT, TTS, VAD providers │ +│ • Implements vtables wrapping ONNX APIs │ +└────────────────────────────┬────────────────────────────────┘ + │ +┌────────────────────────────▼────────────────────────────────┐ +│ onnx_backend.cpp │ +│ Wraps Sherpa-ONNX C API: │ +│ • STT: SherpaOnnxOfflineRecognizer │ +│ • TTS: SherpaOnnxOfflineTts │ +│ • VAD: SherpaOnnxVoiceActivityDetector │ +└────────────────────────────┬────────────────────────────────┘ + │ + Sherpa-ONNX + ONNX Runtime libraries +``` + +**Supported Models:** +- **STT**: Whisper, Zipformer, Paraformer +- **TTS**: VITS/Piper voices +- **VAD**: Silero VAD + +### Platform Backend (Apple) + +**Pattern:** C++ provides registration and vtable stubs; Swift provides actual implementation via callbacks. + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Swift SDK (RunAnywhere) │ +│ Implements callbacks for Foundation Models + TTS │ +└────────────────────────────┬────────────────────────────────┘ + │ sets callbacks +┌────────────────────────────▼────────────────────────────────┐ +│ rac_llm_platform.h / rac_tts_platform.h │ +│ • rac_platform_llm_set_callbacks() │ +│ • rac_platform_tts_set_callbacks() │ +└────────────────────────────┬────────────────────────────────┘ + │ +┌────────────────────────────▼────────────────────────────────┐ +│ rac_backend_platform_register.cpp │ +│ • Registers "platform" module │ +│ • Provider calls Swift callbacks via stored pointers │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## Data Flow + +### LLM Generation Flow + +``` +App calls RunAnywhere.generate(prompt) + │ + ▼ + Swift SDK validates state + Calls rac_llm_generate(handle, prompt, options, &result) + │ + ▼ + rac_llm_generate() extracts service from handle + Calls service->ops->generate(service->impl, prompt, options, &result) + │ + ▼ + LlamaCPP vtable generate(): + 1. Build chat-templated prompt + 2. Tokenize prompt + 3. Decode prompt tokens + 4. Sample generation loop: + - Sample next token + - Check stop conditions + - Accumulate output + 5. Populate result struct + │ + ▼ + Return to Swift + Swift maps rac_llm_result_t → LLMGenerationResult + │ + ▼ + Return to App +``` + +### Streaming Generation Flow + +``` +App calls RunAnywhere.generateStream(prompt) + │ + ▼ + Swift SDK calls rac_llm_generate_stream() + with Swift callback wrapper + │ + ▼ + Backend generation loop: + for each token: + callback(token, is_final, user_data) + │ + ▼ + Swift callback wrapper: + - Maps token to Swift String + - Yields to AsyncStream + │ + ▼ + App receives token via AsyncStream +``` + +### Voice Agent Pipeline + +``` +Audio Input + │ + ▼ +┌───────────────┐ +│ VAD │ ──► Speech detected? No → Continue listening +│ (Energy/AI) │ +└───────┬───────┘ + │ Speech detected + ▼ +┌───────────────┐ +│ STT │ ──► Transcribe audio to text +│ (ONNX/Whisper)│ +└───────┬───────┘ + │ Transcription + ▼ +┌───────────────┐ +│ LLM │ ──► Generate response +│ (LlamaCPP) │ +└───────┬───────┘ + │ Response text + ▼ +┌───────────────┐ +│ TTS │ ──► Synthesize speech +│ (ONNX/System)│ +└───────┬───────┘ + │ + ▼ + Audio Output +``` + +--- + +## Concurrency Model + +### Thread Safety + +- **Service Registry**: Protected by `std::mutex` +- **Module Registry**: Protected by `std::mutex` +- **Backend State**: Each backend manages its own synchronization +- **Generation**: One generation per service handle at a time + +### Cancellation + +```c +// Atomic flag pattern +std::atomic cancel_requested_{false}; + +// In generation loop +while (generating) { + if (cancel_requested_.load()) { + break; // Graceful exit + } + // ... sample next token +} + +// Cancel API +void cancel() { + cancel_requested_.store(true); +} +``` + +### Callback Invocation + +- Callbacks invoked on the calling thread +- No async dispatch within C++ layer +- Platform SDKs handle async conversion (Swift actors, Kotlin coroutines) + +--- + +## Memory Management + +### Ownership Rules + +1. **OUT parameters with `*` suffix**: Caller owns, must free + ```c + rac_llm_result_t result; // Caller allocates struct + rac_llm_generate(..., &result); + // result.text is owned, must free with rac_llm_result_free(&result) + ``` + +2. **Static strings**: Library owns, do not free + ```c + const char* msg = rac_error_message(code); // Static, do not free + ``` + +3. **Handles**: Created by library, destroyed by caller + ```c + rac_handle_t handle; + rac_llm_create(..., &handle); + // ... use handle ... + rac_llm_destroy(handle); // Required + ``` + +### Memory Allocation + +```c +// Library allocation functions +RAC_API void* rac_alloc(size_t size); +RAC_API void rac_free(void* ptr); +RAC_API char* rac_strdup(const char* str); + +// Result free functions +RAC_API void rac_llm_result_free(rac_llm_result_t* result); +RAC_API void rac_stt_result_free(rac_stt_result_t* result); +RAC_API void rac_tts_result_free(rac_tts_result_t* result); +``` + +--- + +## Event System + +### Event Types + +```c +typedef enum rac_event_type { + // LLM Events + RAC_EVENT_LLM_MODEL_LOAD_STARTED = 100, + RAC_EVENT_LLM_MODEL_LOAD_COMPLETED = 101, + RAC_EVENT_LLM_GENERATION_STARTED = 110, + RAC_EVENT_LLM_GENERATION_COMPLETED = 111, + RAC_EVENT_LLM_FIRST_TOKEN = 113, + + // STT Events + RAC_EVENT_STT_TRANSCRIPTION_STARTED = 210, + RAC_EVENT_STT_TRANSCRIPTION_COMPLETED = 211, + + // TTS Events + RAC_EVENT_TTS_SYNTHESIS_STARTED = 310, + RAC_EVENT_TTS_SYNTHESIS_COMPLETED = 311, + + // VAD Events + RAC_EVENT_VAD_SPEECH_STARTED = 402, + RAC_EVENT_VAD_SPEECH_ENDED = 403, +} rac_event_type_t; +``` + +### Event Flow + +``` +C++ Component (e.g., LLM generation) + │ + ▼ + rac_event_emit(type, &data) + │ + ▼ + Platform callback (if registered) + │ + ▼ + Swift EventBridge / Kotlin EventBus + │ + ▼ + App event subscription +``` + +### Event Registration + +```c +// Platform SDK registers callback +rac_result_t rac_events_set_callback( + rac_event_callback_fn callback, + void* user_data +); + +// Callback signature +typedef void (*rac_event_callback_fn)( + rac_event_type_t type, + const rac_event_data_t* data, + void* user_data +); +``` + +--- + +## Platform Adapter + +### Required Callbacks + +```c +typedef struct rac_platform_adapter { + // File System (Required) + rac_bool_t (*file_exists)(const char* path, void* user_data); + + // Logging (Required) + void (*log)(rac_log_level_t level, const char* category, + const char* message, void* user_data); + + // Time (Required) + int64_t (*now_ms)(void* user_data); + + // Optional + rac_result_t (*file_read)(...); + rac_result_t (*file_write)(...); + rac_result_t (*secure_get)(...); // Keychain + rac_result_t (*secure_set)(...); + rac_result_t (*http_download)(...); + rac_result_t (*extract_archive)(...); + + void* user_data; // Passed to all callbacks +} rac_platform_adapter_t; +``` + +### Swift Implementation Example + +```swift +// SwiftPlatformAdapter.swift +private func createPlatformAdapter() -> rac_platform_adapter_t { + var adapter = rac_platform_adapter_t() + + adapter.file_exists = { path, userData in + guard let path = path.map(String.init(cString:)) else { return RAC_FALSE } + return FileManager.default.fileExists(atPath: path) ? RAC_TRUE : RAC_FALSE + } + + adapter.log = { level, category, message, userData in + guard let msg = message.map(String.init(cString:)) else { return } + SDKLogger.shared.log(level: LogLevel(rawValue: level), message: msg) + } + + adapter.now_ms = { userData in + Int64(Date().timeIntervalSince1970 * 1000) + } + + return adapter +} +``` + +--- + +## Error Handling + +### Error Code Structure + +```c +// Success +#define RAC_SUCCESS ((rac_result_t)0) + +// Error ranges +// -100 to -109: Initialization errors +#define RAC_ERROR_NOT_INITIALIZED -100 +#define RAC_ERROR_ALREADY_INITIALIZED -101 + +// -110 to -129: Model errors +#define RAC_ERROR_MODEL_NOT_FOUND -110 +#define RAC_ERROR_MODEL_LOAD_FAILED -111 + +// -130 to -149: Generation errors +#define RAC_ERROR_GENERATION_FAILED -130 +#define RAC_ERROR_CONTEXT_TOO_LONG -132 + +// -400 to -499: Service errors +#define RAC_ERROR_NO_CAPABLE_PROVIDER -422 +``` + +### Error Details + +```c +// Get error message +const char* msg = rac_error_message(result); + +// Set detailed error context +rac_error_set_details("Model file not found at: /path/to/model.gguf"); + +// Get detailed error +const char* details = rac_error_get_details(); +``` + +### Error Propagation + +```c +rac_result_t my_function() { + rac_result_t result = some_operation(); + if (RAC_FAILED(result)) { + rac_error_set_details("Operation failed during my_function"); + return result; // Propagate error code + } + return RAC_SUCCESS; +} +``` + +--- + +## Extensibility + +### Adding a New Backend + +1. **Create directory**: `src/backends//` + +2. **Implement backend class**: + ```cpp + // _backend.cpp + class MyBackend { + bool load_model(const std::string& path, const nlohmann::json& config); + Result generate(const std::string& prompt, const Options& options); + }; + ``` + +3. **Create RAC API wrapper**: + ```c + // rac__.h + RAC_API rac_result_t rac___create(..., rac_handle_t* out); + RAC_API rac_result_t rac___process(...); + RAC_API void rac___destroy(rac_handle_t handle); + ``` + +4. **Implement vtable and registration**: + ```cpp + // rac_backend__register.cpp + static const rac__service_ops_t g__ops = { + .initialize = ..., + .process = ..., + .destroy = ... + }; + + rac_result_t rac_backend__register() { + // Register module + // Register service provider with can_handle + create + } + ``` + +5. **Add to CMakeLists.txt**: + ```cmake + option(RAC_BACKEND_ "Build backend" ON) + if(RAC_BACKEND_) + add_subdirectory(src/backends/) + endif() + ``` + +### Adding a New Capability + +1. Create type definitions in `include/rac/features//rac__types.h` +2. Create service interface in `include/rac/features//rac__service.h` +3. Create public API in `include/rac/features//rac_.h` +4. Add capability enum value to `rac_capability_t` +5. Implement service in `src/features//` + +--- + +## Testing + +### Unit Testing + +- Tests in `tests/` directory +- CMake option: `RAC_BUILD_TESTS=ON` +- Uses platform SDK integration tests for E2E validation + +### Manual Testing + +```bash +# Build with test support +cmake -B build -DRAC_BUILD_TESTS=ON +cmake --build build +ctest --test-dir build +``` + +### Integration Testing + +Integration tests run through platform SDKs: +- Swift: `Tests/RunAnywhereTests/` +- Kotlin: `sdk/runanywhere-kotlin/src/test/` + +--- + +## Design Decisions + +### Why C API Instead of C++? + +**Decision**: Pure C API surface with C++ implementation + +**Rationale:** +- Swift/Kotlin FFI bindings work better with C +- No C++ name mangling issues +- Easier to maintain ABI stability +- Compatible with Dart FFI for Flutter + +### Why Vtable Instead of Virtual Functions? + +**Decision**: C-style vtables instead of C++ virtual inheritance + +**Rationale:** +- No C++ RTTI needed at API boundaries +- POD structs are simpler for FFI +- Backend libraries can be statically linked without issues +- Explicit ownership model + +### Why Priority-Based Provider Selection? + +**Decision**: Multiple providers can register for same capability with priority + +**Rationale:** +- Mirrors successful Swift SDK pattern +- Allows platform-specific optimizations (Apple FM for LLM on iOS) +- Graceful fallback if primary provider can't handle request +- Runtime flexibility without code changes + +### Why Separate XCFrameworks? + +**Decision**: RACommons + RABackendLLAMACPP + RABackendONNX as separate frameworks + +**Rationale:** +- Apps include only what they need +- Significant binary size savings (82% for LLM-only apps) +- Independent versioning possible +- Matches App Store best practices + +--- + +## See Also + +- [README.md](./README.md) - Getting started guide +- [../CLAUDE.md](../CLAUDE.md) - AI context and coding guidelines +- [../../runanywhere-swift/docs/ARCHITECTURE.md](../../runanywhere-swift/docs/ARCHITECTURE.md) - Swift SDK architecture +- [../../runanywhere-kotlin/docs/ARCHITECTURE.md](../../runanywhere-kotlin/docs/ARCHITECTURE.md) - Kotlin SDK architecture diff --git a/sdk/runanywhere-commons/exports/RACommons.exports b/sdk/runanywhere-commons/exports/RACommons.exports new file mode 100644 index 000000000..6620aad34 --- /dev/null +++ b/sdk/runanywhere-commons/exports/RACommons.exports @@ -0,0 +1,389 @@ +# RunAnywhere Commons - Exported Symbols +# Auto-generated from librac_commons.a + +# Audio Utilities +_rac_audio_float32_to_wav +_rac_audio_int16_to_wav +_rac_audio_wav_header_size + +# Memory and Core +_rac_alloc +_rac_free +_rac_strdup +_rac_init +_rac_is_initialized +_rac_shutdown +_rac_get_version +_rac_log + +# Time +_rac_get_current_time_ms + +# Error Handling +_rac_error_clear_details +_rac_error_get_details +_rac_error_is_commons_error +_rac_error_is_core_error +_rac_error_is_expected +_rac_error_message +_rac_error_set_details + +# Analytics Events (Cross-Platform) +_rac_analytics_events_set_callback +_rac_analytics_event_emit +_rac_analytics_events_has_callback + +# Platform Adapter +_rac_get_platform_adapter +_rac_set_platform_adapter + +# Archive Utilities +_rac_archive_type_extension +_rac_archive_type_from_path +_rac_artifact_infer_from_url +_rac_artifact_requires_download +_rac_artifact_requires_extraction +_rac_extract_archive + +# Component Types +_rac_capability_resource_type_raw_value +_rac_component_to_resource_type +_rac_resource_type_name +_rac_resource_type_to_component +_rac_sdk_component_display_name +_rac_sdk_component_raw_value + +# Download Manager +_rac_download_manager_cancel +_rac_download_manager_create +_rac_download_manager_destroy +_rac_download_manager_get_active_tasks +_rac_download_manager_get_progress +_rac_download_manager_is_healthy +_rac_download_manager_mark_complete +_rac_download_manager_mark_failed +_rac_download_manager_pause_all +_rac_download_manager_resume_all +_rac_download_manager_start +_rac_download_manager_update_progress +_rac_download_stage_display_name +_rac_download_stage_progress_range +_rac_download_task_free +_rac_download_task_ids_free +_rac_http_download +_rac_http_download_cancel + +# Events +_rac_event_category_name +_rac_event_publish +_rac_event_subscribe +_rac_event_subscribe_all +_rac_event_track +_rac_event_unsubscribe + +# Lifecycle +_rac_lifecycle_create +_rac_lifecycle_destroy +_rac_lifecycle_get_metrics +_rac_lifecycle_get_model_id +_rac_lifecycle_get_model_name +_rac_lifecycle_get_service +_rac_lifecycle_get_state +_rac_lifecycle_is_loaded +_rac_lifecycle_load +_rac_lifecycle_require_service +_rac_lifecycle_reset +_rac_lifecycle_state_name +_rac_lifecycle_track_error +_rac_lifecycle_unload + +# Model Management +_rac_expected_model_files_alloc +_rac_expected_model_files_free +_rac_model_category_from_framework +_rac_model_category_requires_context_length +_rac_model_category_supports_thinking +_rac_model_detect_format_from_extension +_rac_model_detect_framework_from_format +_rac_model_file_descriptors_alloc +_rac_model_file_descriptors_free +_rac_model_filter_models +_rac_model_format_extension +_rac_model_generate_id +_rac_model_generate_name +_rac_model_infer_artifact_type +_rac_model_info_alloc +_rac_model_info_array_free +_rac_model_info_copy +_rac_model_info_free +_rac_model_info_is_downloaded +_rac_model_matches_filter + +# Model Paths +_rac_model_paths_extract_framework +_rac_model_paths_extract_model_id +_rac_model_paths_get_base_dir +_rac_model_paths_get_base_directory +_rac_model_paths_get_cache_directory +_rac_model_paths_get_downloads_directory +_rac_model_paths_get_expected_model_path +_rac_model_paths_get_framework_directory +_rac_model_paths_get_model_file_path +_rac_model_paths_get_model_folder +_rac_model_paths_get_model_path +_rac_model_paths_get_models_directory +_rac_model_paths_get_temp_directory +_rac_model_paths_is_model_path +_rac_model_paths_set_base_dir + +# Model Registry +_rac_model_registry_create +_rac_model_registry_destroy +_rac_model_registry_get +_rac_model_registry_get_all +_rac_model_registry_get_by_frameworks +_rac_model_registry_get_downloaded +_rac_model_registry_remove +_rac_model_registry_save +_rac_model_registry_update_download_status +_rac_model_registry_update_last_used + +# Model Assignment +_rac_model_assignment_set_callbacks +_rac_model_assignment_fetch +_rac_model_assignment_get_by_framework +_rac_model_assignment_get_by_category +_rac_model_assignment_clear_cache +_rac_model_assignment_set_cache_timeout + +# Framework Utilities +_rac_framework_analytics_key +_rac_framework_display_name +_rac_framework_get_supported_formats +_rac_framework_raw_value +_rac_framework_supports_format +_rac_framework_supports_llm +_rac_framework_supports_stt +_rac_framework_supports_tts +_rac_framework_uses_directory_based_models + +# Module/Service Registry +_rac_module_get_info +_rac_module_list +_rac_module_register +_rac_module_unregister +_rac_modules_for_capability +_rac_service_create +_rac_service_list_providers +_rac_service_register_provider +_rac_service_unregister_provider + +# LLM Component +_rac_llm_component_cancel +_rac_llm_component_cleanup +_rac_llm_component_configure +_rac_llm_component_create +_rac_llm_component_destroy +_rac_llm_component_generate +_rac_llm_component_generate_stream +_rac_llm_component_get_metrics +_rac_llm_component_get_model_id +_rac_llm_component_get_state +_rac_llm_component_is_loaded +_rac_llm_component_load_model +_rac_llm_component_supports_streaming +_rac_llm_component_unload + +# LLM Analytics +_rac_llm_analytics_complete_generation +_rac_llm_analytics_create +_rac_llm_analytics_destroy +_rac_llm_analytics_get_metrics +_rac_llm_analytics_start_generation +_rac_llm_analytics_start_streaming_generation +_rac_llm_analytics_track_error +_rac_llm_analytics_track_first_token +_rac_llm_analytics_track_generation_failed +_rac_llm_analytics_track_streaming_update + +# LLM Generation Analytics (alternative API) +_rac_generation_analytics_complete +_rac_generation_analytics_create +_rac_generation_analytics_destroy +_rac_generation_analytics_get_metrics +_rac_generation_analytics_reset +_rac_generation_analytics_start +_rac_generation_analytics_start_streaming +_rac_generation_analytics_track_failed +_rac_generation_analytics_track_first_token +_rac_generation_analytics_track_streaming_update + +# LLM Streaming Metrics +_rac_streaming_metrics_create +_rac_streaming_metrics_destroy +_rac_streaming_metrics_get_result +_rac_streaming_metrics_get_text +_rac_streaming_metrics_get_token_count +_rac_streaming_metrics_get_ttft +_rac_streaming_metrics_mark_complete +_rac_streaming_metrics_mark_failed +_rac_streaming_metrics_mark_start +_rac_streaming_metrics_record_token +_rac_streaming_metrics_set_token_counts +_rac_streaming_result_free + +# STT Component +_rac_stt_component_cleanup +_rac_stt_component_configure +_rac_stt_component_create +_rac_stt_component_destroy +_rac_stt_component_get_metrics +_rac_stt_component_get_model_id +_rac_stt_component_get_state +_rac_stt_component_is_loaded +_rac_stt_component_load_model +_rac_stt_component_supports_streaming +_rac_stt_component_transcribe +_rac_stt_component_transcribe_stream +_rac_stt_component_unload + +# STT Analytics +_rac_stt_analytics_complete_transcription +_rac_stt_analytics_create +_rac_stt_analytics_destroy +_rac_stt_analytics_get_metrics +_rac_stt_analytics_start_transcription +_rac_stt_analytics_track_error +_rac_stt_analytics_track_final_transcript +_rac_stt_analytics_track_language_detection +_rac_stt_analytics_track_partial_transcript +_rac_stt_analytics_track_transcription_failed + +# TTS Component +_rac_tts_component_cleanup +_rac_tts_component_configure +_rac_tts_component_create +_rac_tts_component_destroy +_rac_tts_component_get_metrics +_rac_tts_component_get_state +_rac_tts_component_get_voice_id +_rac_tts_component_is_loaded +_rac_tts_component_load_voice +_rac_tts_component_stop +_rac_tts_component_synthesize +_rac_tts_component_synthesize_stream +_rac_tts_component_unload + +# TTS Analytics +_rac_tts_analytics_complete_synthesis +_rac_tts_analytics_create +_rac_tts_analytics_destroy +_rac_tts_analytics_get_metrics +_rac_tts_analytics_start_synthesis +_rac_tts_analytics_track_error +_rac_tts_analytics_track_synthesis_chunk +_rac_tts_analytics_track_synthesis_failed + +# VAD Component +_rac_vad_component_cleanup +_rac_vad_component_configure +_rac_vad_component_create +_rac_vad_component_destroy +_rac_vad_component_get_energy_threshold +_rac_vad_component_get_metrics +_rac_vad_component_get_state +_rac_vad_component_initialize +_rac_vad_component_is_initialized +_rac_vad_component_is_speech_active +_rac_vad_component_process +_rac_vad_component_reset +_rac_vad_component_set_activity_callback +_rac_vad_component_set_audio_callback +_rac_vad_component_set_energy_threshold +_rac_vad_component_start +_rac_vad_component_stop + +# VAD Analytics +_rac_vad_analytics_create +_rac_vad_analytics_destroy +_rac_vad_analytics_get_metrics +_rac_vad_analytics_track_cleaned_up +_rac_vad_analytics_track_initialization_failed +_rac_vad_analytics_track_initialized +_rac_vad_analytics_track_model_load_completed +_rac_vad_analytics_track_model_load_failed +_rac_vad_analytics_track_model_load_started +_rac_vad_analytics_track_model_unloaded +_rac_vad_analytics_track_paused +_rac_vad_analytics_track_resumed +_rac_vad_analytics_track_speech_end +_rac_vad_analytics_track_speech_start +_rac_vad_analytics_track_started +_rac_vad_analytics_track_stopped + +# Energy VAD +_rac_energy_vad_calculate_rms +_rac_energy_vad_create +_rac_energy_vad_destroy +_rac_energy_vad_get_frame_length_samples +_rac_energy_vad_get_sample_rate +_rac_energy_vad_get_statistics +_rac_energy_vad_get_threshold +_rac_energy_vad_initialize +_rac_energy_vad_is_calibrating +_rac_energy_vad_is_speech_active +_rac_energy_vad_notify_tts_finish +_rac_energy_vad_notify_tts_start +_rac_energy_vad_pause +_rac_energy_vad_process_audio +_rac_energy_vad_reset +_rac_energy_vad_resume +_rac_energy_vad_set_audio_callback +_rac_energy_vad_set_calibration_multiplier +_rac_energy_vad_set_speech_callback +_rac_energy_vad_set_threshold +_rac_energy_vad_set_tts_multiplier +_rac_energy_vad_start +_rac_energy_vad_start_calibration +_rac_energy_vad_stop + +# Voice Agent +_rac_voice_agent_cleanup +_rac_voice_agent_create +_rac_voice_agent_create_standalone +_rac_voice_agent_destroy +_rac_voice_agent_detect_speech +_rac_voice_agent_generate_response +_rac_voice_agent_get_llm_model_id +_rac_voice_agent_get_stt_model_id +_rac_voice_agent_get_tts_voice_id +_rac_voice_agent_initialize +_rac_voice_agent_initialize_with_loaded_models +_rac_voice_agent_is_llm_loaded +_rac_voice_agent_is_ready +_rac_voice_agent_is_stt_loaded +_rac_voice_agent_is_tts_loaded +_rac_voice_agent_load_llm_model +_rac_voice_agent_load_stt_model +_rac_voice_agent_load_tts_voice +_rac_voice_agent_process_stream +_rac_voice_agent_process_voice_turn +_rac_voice_agent_result_free +_rac_voice_agent_synthesize_speech +_rac_voice_agent_transcribe + +# Audio Pipeline State (VoiceAgent) +_rac_audio_pipeline_can_activate_microphone +_rac_audio_pipeline_can_play_tts +_rac_audio_pipeline_is_valid_transition +_rac_audio_pipeline_state_name + +# LLM Structured Output +_rac_structured_output_extract_json +_rac_structured_output_find_complete_json +_rac_structured_output_find_matching_brace +_rac_structured_output_find_matching_bracket +_rac_structured_output_get_system_prompt +_rac_structured_output_prepare_prompt +_rac_structured_output_validate +_rac_structured_output_validation_free diff --git a/sdk/runanywhere-commons/include/rac/backends/rac_llm_llamacpp.h b/sdk/runanywhere-commons/include/rac/backends/rac_llm_llamacpp.h new file mode 100644 index 000000000..bce490311 --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/backends/rac_llm_llamacpp.h @@ -0,0 +1,245 @@ +/** + * @file rac_llm_llamacpp.h + * @brief RunAnywhere Core - LlamaCPP Backend RAC API + * + * Direct RAC API export from runanywhere-core's LlamaCPP backend. + * This header defines the public C API for LLM inference using llama.cpp. + * + * Mirrors Swift's LlamaCPPService implementation pattern. + */ + +#ifndef RAC_LLM_LLAMACPP_H +#define RAC_LLM_LLAMACPP_H + +#include "rac/core/rac_benchmark.h" +#include "rac/core/rac_error.h" +#include "rac/core/rac_types.h" +#include "rac/features/llm/rac_llm.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// EXPORT MACRO +// ============================================================================= + +#if defined(RAC_LLAMACPP_BUILDING) +#if defined(_WIN32) +#define RAC_LLAMACPP_API __declspec(dllexport) +#elif defined(__GNUC__) || defined(__clang__) +#define RAC_LLAMACPP_API __attribute__((visibility("default"))) +#else +#define RAC_LLAMACPP_API +#endif +#else +#define RAC_LLAMACPP_API +#endif + +// ============================================================================= +// CONFIGURATION - Mirrors Swift's LlamaCPPGenerationConfig +// ============================================================================= + +/** + * LlamaCPP-specific configuration. + * + * Mirrors Swift's LlamaCPPGenerationConfig. + */ +typedef struct rac_llm_llamacpp_config { + /** Context size (0 = auto-detect from model) */ + int32_t context_size; + + /** Number of threads (0 = auto-detect) */ + int32_t num_threads; + + /** Number of layers to offload to GPU (Metal on iOS/macOS) */ + int32_t gpu_layers; + + /** Batch size for prompt processing */ + int32_t batch_size; +} rac_llm_llamacpp_config_t; + +/** + * Default LlamaCPP configuration. + */ +static const rac_llm_llamacpp_config_t RAC_LLM_LLAMACPP_CONFIG_DEFAULT = { + .context_size = 0, // Auto-detect + .num_threads = 0, // Auto-detect + .gpu_layers = -1, // All layers on GPU + .batch_size = 512}; + +// ============================================================================= +// LLAMACPP-SPECIFIC API +// ============================================================================= + +/** + * Creates a LlamaCPP LLM service. + * + * Mirrors Swift's LlamaCPPService.initialize(modelPath:) + * + * @param model_path Path to the GGUF model file + * @param config LlamaCPP-specific configuration (can be NULL for defaults) + * @param out_handle Output: Handle to the created service + * @return RAC_SUCCESS or error code + */ +RAC_LLAMACPP_API rac_result_t rac_llm_llamacpp_create(const char* model_path, + const rac_llm_llamacpp_config_t* config, + rac_handle_t* out_handle); + +/** + * Loads a GGUF model into an existing service. + * + * Mirrors Swift's LlamaCPPService.loadModel(path:config:) + * + * @param handle Service handle + * @param model_path Path to the GGUF model file + * @param config LlamaCPP configuration (can be NULL) + * @return RAC_SUCCESS or error code + */ +RAC_LLAMACPP_API rac_result_t rac_llm_llamacpp_load_model(rac_handle_t handle, + const char* model_path, + const rac_llm_llamacpp_config_t* config); + +/** + * Unloads the current model. + * + * Mirrors Swift's LlamaCPPService.unloadModel() + * + * @param handle Service handle + * @return RAC_SUCCESS or error code + */ +RAC_LLAMACPP_API rac_result_t rac_llm_llamacpp_unload_model(rac_handle_t handle); + +/** + * Checks if a model is loaded. + * + * Mirrors Swift's LlamaCPPService.isModelLoaded + * + * @param handle Service handle + * @return RAC_TRUE if model is loaded, RAC_FALSE otherwise + */ +RAC_LLAMACPP_API rac_bool_t rac_llm_llamacpp_is_model_loaded(rac_handle_t handle); + +/** + * Generates text completion. + * + * Mirrors Swift's LlamaCPPService.generate(prompt:config:) + * + * @param handle Service handle + * @param prompt Input prompt text + * @param options Generation options (can be NULL for defaults) + * @param out_result Output: Generation result (caller must free text with rac_free) + * @return RAC_SUCCESS or error code + */ +RAC_LLAMACPP_API rac_result_t rac_llm_llamacpp_generate(rac_handle_t handle, const char* prompt, + const rac_llm_options_t* options, + rac_llm_result_t* out_result); + +/** + * Streaming text generation callback. + * + * Mirrors Swift's streaming callback pattern. + * + * @param token Generated token string + * @param is_final Whether this is the final token + * @param user_data User-provided context + * @return RAC_TRUE to continue, RAC_FALSE to stop + */ +typedef rac_bool_t (*rac_llm_llamacpp_stream_callback_fn)(const char* token, rac_bool_t is_final, + void* user_data); + +/** + * Generates text with streaming callback. + * + * Mirrors Swift's LlamaCPPService.generateStream(prompt:config:) + * + * @param handle Service handle + * @param prompt Input prompt text + * @param options Generation options + * @param callback Callback for each token + * @param user_data User context passed to callback + * @return RAC_SUCCESS or error code + */ +RAC_LLAMACPP_API rac_result_t rac_llm_llamacpp_generate_stream( + rac_handle_t handle, const char* prompt, const rac_llm_options_t* options, + rac_llm_llamacpp_stream_callback_fn callback, void* user_data); + +/** + * Generates text with streaming callback and benchmark timing. + * + * Same as rac_llm_llamacpp_generate_stream but captures benchmark timing: + * - t2: Before prefill (llama_decode for prompt batch) + * - t3: After prefill completes + * - t5: When decode loop exits (last token) + * + * @param handle Service handle + * @param prompt Input prompt text + * @param options Generation options + * @param callback Callback for each token + * @param user_data User context passed to callback + * @param timing_out Output: Benchmark timing struct, caller-allocated. + * Must remain valid for the duration of the call. + * Caller should initialize via rac_benchmark_timing_init() before passing. + * On success, all t2/t3/t5 fields are populated. + * On failure, status is set but timing fields may be partial. + * Pass NULL to skip timing (zero overhead). + * @return RAC_SUCCESS or error code + */ +RAC_LLAMACPP_API rac_result_t rac_llm_llamacpp_generate_stream_with_timing( + rac_handle_t handle, const char* prompt, const rac_llm_options_t* options, + rac_llm_llamacpp_stream_callback_fn callback, void* user_data, + rac_benchmark_timing_t* timing_out); + +/** + * Cancels ongoing generation. + * + * Mirrors Swift's LlamaCPPService.cancel() + * + * @param handle Service handle + */ +RAC_LLAMACPP_API void rac_llm_llamacpp_cancel(rac_handle_t handle); + +/** + * Gets model information as JSON. + * + * @param handle Service handle + * @param out_json Output: JSON string (caller must free with rac_free) + * @return RAC_SUCCESS or error code + */ +RAC_LLAMACPP_API rac_result_t rac_llm_llamacpp_get_model_info(rac_handle_t handle, char** out_json); + +/** + * Destroys a LlamaCPP LLM service. + * + * @param handle Service handle to destroy + */ +RAC_LLAMACPP_API void rac_llm_llamacpp_destroy(rac_handle_t handle); + +// ============================================================================= +// BACKEND REGISTRATION +// ============================================================================= + +/** + * Registers the LlamaCPP backend with the commons module and service registries. + * + * Should be called once during SDK initialization. + * This registers: + * - Module: "llamacpp" with TEXT_GENERATION capability + * - Service provider: LlamaCPP LLM provider + * + * @return RAC_SUCCESS or error code + */ +RAC_LLAMACPP_API rac_result_t rac_backend_llamacpp_register(void); + +/** + * Unregisters the LlamaCPP backend. + * + * @return RAC_SUCCESS or error code + */ +RAC_LLAMACPP_API rac_result_t rac_backend_llamacpp_unregister(void); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_LLM_LLAMACPP_H */ diff --git a/sdk/runanywhere-commons/include/rac/backends/rac_stt_onnx.h b/sdk/runanywhere-commons/include/rac/backends/rac_stt_onnx.h new file mode 100644 index 000000000..1a7ef814f --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/backends/rac_stt_onnx.h @@ -0,0 +1,99 @@ +/** + * @file rac_stt_onnx.h + * @brief RunAnywhere Core - ONNX Backend RAC API for STT + * + * Direct RAC API export from runanywhere-core's ONNX STT backend. + * Mirrors Swift's ONNXSTTService implementation pattern. + */ + +#ifndef RAC_STT_ONNX_H +#define RAC_STT_ONNX_H + +#include "rac/core/rac_error.h" +#include "rac/core/rac_types.h" +#include "rac/features/stt/rac_stt.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// EXPORT MACRO +// ============================================================================= + +#if defined(RAC_ONNX_BUILDING) +#if defined(_WIN32) +#define RAC_ONNX_API __declspec(dllexport) +#elif defined(__GNUC__) || defined(__clang__) +#define RAC_ONNX_API __attribute__((visibility("default"))) +#else +#define RAC_ONNX_API +#endif +#else +#define RAC_ONNX_API +#endif + +// ============================================================================= +// CONFIGURATION +// ============================================================================= + +/** + * ONNX STT model types. + */ +typedef enum rac_stt_onnx_model_type { + RAC_STT_ONNX_MODEL_WHISPER = 0, + RAC_STT_ONNX_MODEL_ZIPFORMER = 1, + RAC_STT_ONNX_MODEL_PARAFORMER = 2, + RAC_STT_ONNX_MODEL_AUTO = 99 +} rac_stt_onnx_model_type_t; + +/** + * ONNX STT configuration. + */ +typedef struct rac_stt_onnx_config { + rac_stt_onnx_model_type_t model_type; + int32_t num_threads; + rac_bool_t use_coreml; +} rac_stt_onnx_config_t; + +static const rac_stt_onnx_config_t RAC_STT_ONNX_CONFIG_DEFAULT = { + .model_type = RAC_STT_ONNX_MODEL_AUTO, .num_threads = 0, .use_coreml = RAC_TRUE}; + +// ============================================================================= +// ONNX STT API +// ============================================================================= + +RAC_ONNX_API rac_result_t rac_stt_onnx_create(const char* model_path, + const rac_stt_onnx_config_t* config, + rac_handle_t* out_handle); + +RAC_ONNX_API rac_result_t rac_stt_onnx_transcribe(rac_handle_t handle, const float* audio_samples, + size_t num_samples, + const rac_stt_options_t* options, + rac_stt_result_t* out_result); + +RAC_ONNX_API rac_bool_t rac_stt_onnx_supports_streaming(rac_handle_t handle); + +RAC_ONNX_API rac_result_t rac_stt_onnx_create_stream(rac_handle_t handle, rac_handle_t* out_stream); + +RAC_ONNX_API rac_result_t rac_stt_onnx_feed_audio(rac_handle_t handle, rac_handle_t stream, + const float* audio_samples, size_t num_samples); + +RAC_ONNX_API rac_bool_t rac_stt_onnx_stream_is_ready(rac_handle_t handle, rac_handle_t stream); + +RAC_ONNX_API rac_result_t rac_stt_onnx_decode_stream(rac_handle_t handle, rac_handle_t stream, + char** out_text); + +RAC_ONNX_API void rac_stt_onnx_input_finished(rac_handle_t handle, rac_handle_t stream); + +RAC_ONNX_API rac_bool_t rac_stt_onnx_is_endpoint(rac_handle_t handle, rac_handle_t stream); + +RAC_ONNX_API void rac_stt_onnx_destroy_stream(rac_handle_t handle, rac_handle_t stream); + +RAC_ONNX_API void rac_stt_onnx_destroy(rac_handle_t handle); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_STT_ONNX_H */ diff --git a/sdk/runanywhere-commons/include/rac/backends/rac_stt_whispercpp.h b/sdk/runanywhere-commons/include/rac/backends/rac_stt_whispercpp.h new file mode 100644 index 000000000..9ec8f2c7d --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/backends/rac_stt_whispercpp.h @@ -0,0 +1,153 @@ +/** + * @file rac_stt_whispercpp.h + * @brief RunAnywhere Core - WhisperCPP Backend for STT + * + * RAC API for WhisperCPP-based speech-to-text. + * Provides high-quality transcription using whisper.cpp. + * + * NOTE: WhisperCPP and LlamaCPP both use GGML, which can cause symbol + * conflicts if linked together. Use ONNX Whisper for STT when also + * using LlamaCPP for LLM, or build with symbol prefixing. + */ + +#ifndef RAC_STT_WHISPERCPP_H +#define RAC_STT_WHISPERCPP_H + +#include "rac/core/rac_error.h" +#include "rac/core/rac_types.h" +#include "rac/features/stt/rac_stt.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// EXPORT MACRO +// ============================================================================= + +#if defined(RAC_WHISPERCPP_BUILDING) +#if defined(_WIN32) +#define RAC_WHISPERCPP_API __declspec(dllexport) +#elif defined(__GNUC__) || defined(__clang__) +#define RAC_WHISPERCPP_API __attribute__((visibility("default"))) +#else +#define RAC_WHISPERCPP_API +#endif +#else +#define RAC_WHISPERCPP_API +#endif + +// ============================================================================= +// CONFIGURATION +// ============================================================================= + +/** + * WhisperCPP-specific configuration. + */ +typedef struct rac_stt_whispercpp_config { + /** Number of threads (0 = auto) */ + int32_t num_threads; + + /** Enable GPU acceleration (Metal on Apple) */ + rac_bool_t use_gpu; + + /** Enable CoreML acceleration (Apple only) */ + rac_bool_t use_coreml; + + /** Language code for transcription (NULL = auto-detect) */ + const char* language; + + /** Translate to English (when source is non-English) */ + rac_bool_t translate; +} rac_stt_whispercpp_config_t; + +/** + * Default WhisperCPP configuration. + */ +static const rac_stt_whispercpp_config_t RAC_STT_WHISPERCPP_CONFIG_DEFAULT = { + .num_threads = 0, + .use_gpu = RAC_TRUE, + .use_coreml = RAC_TRUE, + .language = NULL, + .translate = RAC_FALSE}; + +// ============================================================================= +// WHISPERCPP STT API +// ============================================================================= + +/** + * Creates a WhisperCPP STT service. + * + * @param model_path Path to the Whisper GGML model file (.bin) + * @param config WhisperCPP-specific configuration (can be NULL for defaults) + * @param out_handle Output: Handle to the created service + * @return RAC_SUCCESS or error code + */ +RAC_WHISPERCPP_API rac_result_t rac_stt_whispercpp_create(const char* model_path, + const rac_stt_whispercpp_config_t* config, + rac_handle_t* out_handle); + +/** + * Transcribes audio data. + * + * @param handle Service handle + * @param audio_samples Float32 PCM samples (16kHz mono) + * @param num_samples Number of samples + * @param options STT options (can be NULL for defaults) + * @param out_result Output: Transcription result + * @return RAC_SUCCESS or error code + */ +RAC_WHISPERCPP_API rac_result_t rac_stt_whispercpp_transcribe(rac_handle_t handle, + const float* audio_samples, + size_t num_samples, + const rac_stt_options_t* options, + rac_stt_result_t* out_result); + +/** + * Gets detected language after transcription. + * + * @param handle Service handle + * @param out_language Output: Language code (caller must free) + * @return RAC_SUCCESS or error code + */ +RAC_WHISPERCPP_API rac_result_t rac_stt_whispercpp_get_language(rac_handle_t handle, + char** out_language); + +/** + * Checks if model is loaded and ready. + * + * @param handle Service handle + * @return RAC_TRUE if ready + */ +RAC_WHISPERCPP_API rac_bool_t rac_stt_whispercpp_is_ready(rac_handle_t handle); + +/** + * Destroys a WhisperCPP STT service. + * + * @param handle Service handle to destroy + */ +RAC_WHISPERCPP_API void rac_stt_whispercpp_destroy(rac_handle_t handle); + +// ============================================================================= +// BACKEND REGISTRATION +// ============================================================================= + +/** + * Registers the WhisperCPP backend with the commons module and service registries. + * + * @return RAC_SUCCESS or error code + */ +RAC_WHISPERCPP_API rac_result_t rac_backend_whispercpp_register(void); + +/** + * Unregisters the WhisperCPP backend. + * + * @return RAC_SUCCESS or error code + */ +RAC_WHISPERCPP_API rac_result_t rac_backend_whispercpp_unregister(void); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_STT_WHISPERCPP_H */ diff --git a/sdk/runanywhere-commons/include/rac/backends/rac_tts_onnx.h b/sdk/runanywhere-commons/include/rac/backends/rac_tts_onnx.h new file mode 100644 index 000000000..8e8ae79a4 --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/backends/rac_tts_onnx.h @@ -0,0 +1,71 @@ +/** + * @file rac_tts_onnx.h + * @brief RunAnywhere Core - ONNX Backend RAC API for TTS + * + * Direct RAC API export from runanywhere-core's ONNX TTS backend. + */ + +#ifndef RAC_TTS_ONNX_H +#define RAC_TTS_ONNX_H + +#include "rac/core/rac_error.h" +#include "rac/core/rac_types.h" +#include "rac/features/tts/rac_tts.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// EXPORT MACRO +// ============================================================================= + +#if defined(RAC_ONNX_BUILDING) +#if defined(_WIN32) +#define RAC_ONNX_API __declspec(dllexport) +#elif defined(__GNUC__) || defined(__clang__) +#define RAC_ONNX_API __attribute__((visibility("default"))) +#else +#define RAC_ONNX_API +#endif +#else +#define RAC_ONNX_API +#endif + +// ============================================================================= +// CONFIGURATION +// ============================================================================= + +typedef struct rac_tts_onnx_config { + int32_t num_threads; + rac_bool_t use_coreml; + int32_t sample_rate; +} rac_tts_onnx_config_t; + +static const rac_tts_onnx_config_t RAC_TTS_ONNX_CONFIG_DEFAULT = { + .num_threads = 0, .use_coreml = RAC_TRUE, .sample_rate = 22050}; + +// ============================================================================= +// ONNX TTS API +// ============================================================================= + +RAC_ONNX_API rac_result_t rac_tts_onnx_create(const char* model_path, + const rac_tts_onnx_config_t* config, + rac_handle_t* out_handle); + +RAC_ONNX_API rac_result_t rac_tts_onnx_synthesize(rac_handle_t handle, const char* text, + const rac_tts_options_t* options, + rac_tts_result_t* out_result); + +RAC_ONNX_API rac_result_t rac_tts_onnx_get_voices(rac_handle_t handle, char*** out_voices, + size_t* out_count); + +RAC_ONNX_API void rac_tts_onnx_stop(rac_handle_t handle); + +RAC_ONNX_API void rac_tts_onnx_destroy(rac_handle_t handle); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_TTS_ONNX_H */ diff --git a/sdk/runanywhere-commons/include/rac/backends/rac_vad_onnx.h b/sdk/runanywhere-commons/include/rac/backends/rac_vad_onnx.h new file mode 100644 index 000000000..fb3c51658 --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/backends/rac_vad_onnx.h @@ -0,0 +1,84 @@ +/** + * @file rac_vad_onnx.h + * @brief RunAnywhere Core - ONNX Backend RAC API for VAD + * + * Direct RAC API export from runanywhere-core's ONNX VAD backend. + */ + +#ifndef RAC_VAD_ONNX_H +#define RAC_VAD_ONNX_H + +#include "rac/core/rac_error.h" +#include "rac/core/rac_types.h" +#include "rac/features/vad/rac_vad.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// EXPORT MACRO +// ============================================================================= + +#if defined(RAC_ONNX_BUILDING) +#if defined(_WIN32) +#define RAC_ONNX_API __declspec(dllexport) +#elif defined(__GNUC__) || defined(__clang__) +#define RAC_ONNX_API __attribute__((visibility("default"))) +#else +#define RAC_ONNX_API +#endif +#else +#define RAC_ONNX_API +#endif + +// ============================================================================= +// CONFIGURATION +// ============================================================================= + +typedef struct rac_vad_onnx_config { + int32_t sample_rate; + float energy_threshold; + float frame_length; + int32_t num_threads; +} rac_vad_onnx_config_t; + +static const rac_vad_onnx_config_t RAC_VAD_ONNX_CONFIG_DEFAULT = { + .sample_rate = 16000, .energy_threshold = 0.5f, .frame_length = 0.032f, .num_threads = 0}; + +// ============================================================================= +// ONNX VAD API +// ============================================================================= + +RAC_ONNX_API rac_result_t rac_vad_onnx_create(const char* model_path, + const rac_vad_onnx_config_t* config, + rac_handle_t* out_handle); + +RAC_ONNX_API rac_result_t rac_vad_onnx_process(rac_handle_t handle, const float* samples, + size_t num_samples, rac_bool_t* out_is_speech); + +RAC_ONNX_API rac_result_t rac_vad_onnx_start(rac_handle_t handle); + +RAC_ONNX_API rac_result_t rac_vad_onnx_stop(rac_handle_t handle); + +RAC_ONNX_API rac_result_t rac_vad_onnx_reset(rac_handle_t handle); + +RAC_ONNX_API rac_result_t rac_vad_onnx_set_threshold(rac_handle_t handle, float threshold); + +RAC_ONNX_API rac_bool_t rac_vad_onnx_is_speech_active(rac_handle_t handle); + +RAC_ONNX_API void rac_vad_onnx_destroy(rac_handle_t handle); + +// ============================================================================= +// BACKEND REGISTRATION +// ============================================================================= + +RAC_ONNX_API rac_result_t rac_backend_onnx_register(void); + +RAC_ONNX_API rac_result_t rac_backend_onnx_unregister(void); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_VAD_ONNX_H */ diff --git a/sdk/runanywhere-commons/include/rac/core/capabilities/rac_lifecycle.h b/sdk/runanywhere-commons/include/rac/core/capabilities/rac_lifecycle.h new file mode 100644 index 000000000..754d691ff --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/core/capabilities/rac_lifecycle.h @@ -0,0 +1,290 @@ +/** + * @file rac_lifecycle.h + * @brief RunAnywhere Commons - Lifecycle Management API + * + * C port of Swift's ManagedLifecycle.swift from: + * Sources/RunAnywhere/Core/Capabilities/ManagedLifecycle.swift + * + * Provides unified lifecycle management with integrated event tracking. + * Tracks lifecycle events (load, unload) via EventPublisher. + */ + +#ifndef RAC_LIFECYCLE_H +#define RAC_LIFECYCLE_H + +#include "rac/core/rac_error.h" +#include "rac/core/rac_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// TYPES - Mirrors Swift's CapabilityLoadingState +// ============================================================================= + +/** + * @brief Capability loading state + * + * Mirrors Swift's CapabilityLoadingState enum. + */ +typedef enum rac_lifecycle_state { + RAC_LIFECYCLE_STATE_IDLE = 0, /**< Not loaded */ + RAC_LIFECYCLE_STATE_LOADING = 1, /**< Currently loading */ + RAC_LIFECYCLE_STATE_LOADED = 2, /**< Successfully loaded */ + RAC_LIFECYCLE_STATE_FAILED = 3 /**< Load failed */ +} rac_lifecycle_state_t; + +/** + * @brief Resource type for lifecycle tracking + * + * Mirrors Swift's CapabilityResourceType enum. + */ +typedef enum rac_resource_type { + RAC_RESOURCE_TYPE_LLM_MODEL = 0, + RAC_RESOURCE_TYPE_STT_MODEL = 1, + RAC_RESOURCE_TYPE_TTS_VOICE = 2, + RAC_RESOURCE_TYPE_VAD_MODEL = 3, + RAC_RESOURCE_TYPE_DIARIZATION_MODEL = 4 +} rac_resource_type_t; + +/** + * @brief Lifecycle metrics + * + * Mirrors Swift's ModelLifecycleMetrics struct. + */ +typedef struct rac_lifecycle_metrics { + /** Total lifecycle events */ + int32_t total_events; + + /** Start time (ms since epoch) */ + int64_t start_time_ms; + + /** Last event time (ms since epoch, 0 if none) */ + int64_t last_event_time_ms; + + /** Total load attempts */ + int32_t total_loads; + + /** Successful loads */ + int32_t successful_loads; + + /** Failed loads */ + int32_t failed_loads; + + /** Average load time in milliseconds */ + double average_load_time_ms; + + /** Total unloads */ + int32_t total_unloads; +} rac_lifecycle_metrics_t; + +/** + * @brief Lifecycle configuration + */ +typedef struct rac_lifecycle_config { + /** Resource type for event tracking */ + rac_resource_type_t resource_type; + + /** Logger category (can be NULL for default) */ + const char* logger_category; + + /** User data for callbacks */ + void* user_data; +} rac_lifecycle_config_t; + +/** + * @brief Service creation callback + * + * Called by the lifecycle manager to create a service for a given model ID. + * + * @param model_id The model ID to load + * @param user_data User-provided context + * @param out_service Output: Handle to the created service + * @return RAC_SUCCESS or error code + */ +typedef rac_result_t (*rac_lifecycle_create_service_fn)(const char* model_id, void* user_data, + rac_handle_t* out_service); + +/** + * @brief Service destroy callback + * + * Called by the lifecycle manager to destroy a service. + * + * @param service Handle to the service to destroy + * @param user_data User-provided context + */ +typedef void (*rac_lifecycle_destroy_service_fn)(rac_handle_t service, void* user_data); + +// ============================================================================= +// LIFECYCLE API - Mirrors Swift's ManagedLifecycle +// ============================================================================= + +/** + * @brief Create a lifecycle manager + * + * @param config Lifecycle configuration + * @param create_fn Service creation callback + * @param destroy_fn Service destruction callback (can be NULL) + * @param out_handle Output: Handle to the lifecycle manager + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_lifecycle_create(const rac_lifecycle_config_t* config, + rac_lifecycle_create_service_fn create_fn, + rac_lifecycle_destroy_service_fn destroy_fn, + rac_handle_t* out_handle); + +/** + * @brief Load a model with automatic event tracking + * + * Mirrors Swift's ManagedLifecycle.load(_:) + * If already loaded with same ID, skips duplicate load. + * + * @param handle Lifecycle manager handle + * @param model_path File path to the model (used for loading) - REQUIRED + * @param model_id Model identifier for telemetry (e.g., "sherpa-onnx-whisper-tiny.en") + * Optional: if NULL, defaults to model_path + * @param model_name Human-readable model name (e.g., "Sherpa Whisper Tiny (ONNX)") + * Optional: if NULL, defaults to model_id + * @param out_service Output: Handle to the loaded service + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_lifecycle_load(rac_handle_t handle, const char* model_path, + const char* model_id, const char* model_name, + rac_handle_t* out_service); + +/** + * @brief Unload the currently loaded model + * + * Mirrors Swift's ManagedLifecycle.unload() + * + * @param handle Lifecycle manager handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_lifecycle_unload(rac_handle_t handle); + +/** + * @brief Reset all state + * + * Mirrors Swift's ManagedLifecycle.reset() + * + * @param handle Lifecycle manager handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_lifecycle_reset(rac_handle_t handle); + +/** + * @brief Get current lifecycle state + * + * Mirrors Swift's ManagedLifecycle.state + * + * @param handle Lifecycle manager handle + * @return Current state + */ +RAC_API rac_lifecycle_state_t rac_lifecycle_get_state(rac_handle_t handle); + +/** + * @brief Check if a model is loaded + * + * Mirrors Swift's ManagedLifecycle.isLoaded + * + * @param handle Lifecycle manager handle + * @return RAC_TRUE if loaded, RAC_FALSE otherwise + */ +RAC_API rac_bool_t rac_lifecycle_is_loaded(rac_handle_t handle); + +/** + * @brief Get current model ID + * + * Mirrors Swift's ManagedLifecycle.currentModelId + * + * @param handle Lifecycle manager handle + * @return Current model ID (may be NULL if not loaded) + */ +RAC_API const char* rac_lifecycle_get_model_id(rac_handle_t handle); + +/** + * @brief Get current model name (human-readable) + * + * @param handle Lifecycle manager handle + * @return Current model name (may be NULL if not loaded) + */ +RAC_API const char* rac_lifecycle_get_model_name(rac_handle_t handle); + +/** + * @brief Get current service handle + * + * Mirrors Swift's ManagedLifecycle.currentService + * + * @param handle Lifecycle manager handle + * @return Current service handle (may be NULL if not loaded) + */ +RAC_API rac_handle_t rac_lifecycle_get_service(rac_handle_t handle); + +/** + * @brief Require service or return error + * + * Mirrors Swift's ManagedLifecycle.requireService() + * + * @param handle Lifecycle manager handle + * @param out_service Output: Service handle + * @return RAC_SUCCESS or RAC_ERROR_NOT_INITIALIZED if not loaded + */ +RAC_API rac_result_t rac_lifecycle_require_service(rac_handle_t handle, rac_handle_t* out_service); + +/** + * @brief Track an operation error + * + * Mirrors Swift's ManagedLifecycle.trackOperationError(_:operation:) + * + * @param handle Lifecycle manager handle + * @param error_code Error code + * @param operation Operation name + */ +RAC_API void rac_lifecycle_track_error(rac_handle_t handle, rac_result_t error_code, + const char* operation); + +/** + * @brief Get lifecycle metrics + * + * Mirrors Swift's ManagedLifecycle.getLifecycleMetrics() + * + * @param handle Lifecycle manager handle + * @param out_metrics Output: Lifecycle metrics + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_lifecycle_get_metrics(rac_handle_t handle, + rac_lifecycle_metrics_t* out_metrics); + +/** + * @brief Destroy a lifecycle manager + * + * @param handle Lifecycle manager handle + */ +RAC_API void rac_lifecycle_destroy(rac_handle_t handle); + +// ============================================================================= +// CONVENIENCE STATE HELPERS +// ============================================================================= + +/** + * @brief Get state name string + * + * @param state Lifecycle state + * @return Human-readable state name + */ +RAC_API const char* rac_lifecycle_state_name(rac_lifecycle_state_t state); + +/** + * @brief Get resource type name string + * + * @param type Resource type + * @return Human-readable resource type name + */ +RAC_API const char* rac_resource_type_name(rac_resource_type_t type); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_LIFECYCLE_H */ diff --git a/sdk/runanywhere-commons/include/rac/core/rac_analytics_events.h b/sdk/runanywhere-commons/include/rac/core/rac_analytics_events.h new file mode 100644 index 000000000..5f157c124 --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/core/rac_analytics_events.h @@ -0,0 +1,610 @@ +/** + * @file rac_events.h + * @brief RunAnywhere Commons - Cross-Platform Event System + * + * C++ is the canonical source of truth for all analytics events. + * Platform SDKs (Swift, Kotlin, Flutter) register callbacks to receive + * these events and forward them to their native event systems. + * + * Usage: + * 1. Platform SDK registers callback via rac_events_set_callback() + * 2. C++ components emit events via rac_event_emit() + * 3. Platform SDK receives events in callback and converts to native events + */ + +#ifndef RAC_ANALYTICS_EVENTS_H +#define RAC_ANALYTICS_EVENTS_H + +#include "rac/core/rac_types.h" +#include "rac/infrastructure/model_management/rac_model_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// EVENT DESTINATION +// ============================================================================= + +// Include the event publishing header for destination types +#include "rac/infrastructure/events/rac_events.h" + +// Alias the existing enum values for convenience in analytics context +#define RAC_EVENT_DEST_PUBLIC_ONLY RAC_EVENT_DESTINATION_PUBLIC_ONLY +#define RAC_EVENT_DEST_TELEMETRY_ONLY RAC_EVENT_DESTINATION_ANALYTICS_ONLY +#define RAC_EVENT_DEST_ALL RAC_EVENT_DESTINATION_ALL + +// ============================================================================= +// EVENT TYPES +// ============================================================================= + +/** + * @brief Event type enumeration + */ +typedef enum rac_event_type { + // LLM Events (100-199) + RAC_EVENT_LLM_MODEL_LOAD_STARTED = 100, + RAC_EVENT_LLM_MODEL_LOAD_COMPLETED = 101, + RAC_EVENT_LLM_MODEL_LOAD_FAILED = 102, + RAC_EVENT_LLM_MODEL_UNLOADED = 103, + RAC_EVENT_LLM_GENERATION_STARTED = 110, + RAC_EVENT_LLM_GENERATION_COMPLETED = 111, + RAC_EVENT_LLM_GENERATION_FAILED = 112, + RAC_EVENT_LLM_FIRST_TOKEN = 113, + RAC_EVENT_LLM_STREAMING_UPDATE = 114, + + // STT Events (200-299) + RAC_EVENT_STT_MODEL_LOAD_STARTED = 200, + RAC_EVENT_STT_MODEL_LOAD_COMPLETED = 201, + RAC_EVENT_STT_MODEL_LOAD_FAILED = 202, + RAC_EVENT_STT_MODEL_UNLOADED = 203, + RAC_EVENT_STT_TRANSCRIPTION_STARTED = 210, + RAC_EVENT_STT_TRANSCRIPTION_COMPLETED = 211, + RAC_EVENT_STT_TRANSCRIPTION_FAILED = 212, + RAC_EVENT_STT_PARTIAL_TRANSCRIPT = 213, + + // TTS Events (300-399) + RAC_EVENT_TTS_VOICE_LOAD_STARTED = 300, + RAC_EVENT_TTS_VOICE_LOAD_COMPLETED = 301, + RAC_EVENT_TTS_VOICE_LOAD_FAILED = 302, + RAC_EVENT_TTS_VOICE_UNLOADED = 303, + RAC_EVENT_TTS_SYNTHESIS_STARTED = 310, + RAC_EVENT_TTS_SYNTHESIS_COMPLETED = 311, + RAC_EVENT_TTS_SYNTHESIS_FAILED = 312, + RAC_EVENT_TTS_SYNTHESIS_CHUNK = 313, + + // VAD Events (400-499) + RAC_EVENT_VAD_STARTED = 400, + RAC_EVENT_VAD_STOPPED = 401, + RAC_EVENT_VAD_SPEECH_STARTED = 402, + RAC_EVENT_VAD_SPEECH_ENDED = 403, + RAC_EVENT_VAD_PAUSED = 404, + RAC_EVENT_VAD_RESUMED = 405, + + // VoiceAgent Events (500-599) + RAC_EVENT_VOICE_AGENT_TURN_STARTED = 500, + RAC_EVENT_VOICE_AGENT_TURN_COMPLETED = 501, + RAC_EVENT_VOICE_AGENT_TURN_FAILED = 502, + // Voice Agent Component State Events + RAC_EVENT_VOICE_AGENT_STT_STATE_CHANGED = 510, + RAC_EVENT_VOICE_AGENT_LLM_STATE_CHANGED = 511, + RAC_EVENT_VOICE_AGENT_TTS_STATE_CHANGED = 512, + RAC_EVENT_VOICE_AGENT_ALL_READY = 513, + + // SDK Lifecycle Events (600-699) + RAC_EVENT_SDK_INIT_STARTED = 600, + RAC_EVENT_SDK_INIT_COMPLETED = 601, + RAC_EVENT_SDK_INIT_FAILED = 602, + RAC_EVENT_SDK_MODELS_LOADED = 603, + + // Model Download Events (700-719) + RAC_EVENT_MODEL_DOWNLOAD_STARTED = 700, + RAC_EVENT_MODEL_DOWNLOAD_PROGRESS = 701, + RAC_EVENT_MODEL_DOWNLOAD_COMPLETED = 702, + RAC_EVENT_MODEL_DOWNLOAD_FAILED = 703, + RAC_EVENT_MODEL_DOWNLOAD_CANCELLED = 704, + + // Model Extraction Events (710-719) + RAC_EVENT_MODEL_EXTRACTION_STARTED = 710, + RAC_EVENT_MODEL_EXTRACTION_PROGRESS = 711, + RAC_EVENT_MODEL_EXTRACTION_COMPLETED = 712, + RAC_EVENT_MODEL_EXTRACTION_FAILED = 713, + + // Model Deletion Events (720-729) + RAC_EVENT_MODEL_DELETED = 720, + + // Storage Events (800-899) + RAC_EVENT_STORAGE_CACHE_CLEARED = 800, + RAC_EVENT_STORAGE_CACHE_CLEAR_FAILED = 801, + RAC_EVENT_STORAGE_TEMP_CLEANED = 802, + + // Device Events (900-999) + RAC_EVENT_DEVICE_REGISTERED = 900, + RAC_EVENT_DEVICE_REGISTRATION_FAILED = 901, + + // Network Events (1000-1099) + RAC_EVENT_NETWORK_CONNECTIVITY_CHANGED = 1000, + + // Error Events (1100-1199) + RAC_EVENT_SDK_ERROR = 1100, + + // Framework Events (1200-1299) + RAC_EVENT_FRAMEWORK_MODELS_REQUESTED = 1200, + RAC_EVENT_FRAMEWORK_MODELS_RETRIEVED = 1201, +} rac_event_type_t; + +/** + * @brief Get the destination for an event type + * + * @param type Event type + * @return Event destination + */ +RAC_API rac_event_destination_t rac_event_get_destination(rac_event_type_t type); + +// ============================================================================= +// EVENT DATA STRUCTURES +// ============================================================================= + +/** + * @brief LLM generation analytics event data + * Used for: GENERATION_STARTED, GENERATION_COMPLETED, GENERATION_FAILED + */ +typedef struct rac_analytics_llm_generation { + /** Unique generation identifier */ + const char* generation_id; + /** Model ID used for generation */ + const char* model_id; + /** Human-readable model name */ + const char* model_name; + /** Number of input/prompt tokens */ + int32_t input_tokens; + /** Number of output/completion tokens */ + int32_t output_tokens; + /** Total duration in milliseconds */ + double duration_ms; + /** Tokens generated per second */ + double tokens_per_second; + /** Whether this was a streaming generation */ + rac_bool_t is_streaming; + /** Time to first token in ms (0 if not streaming or not yet received) */ + double time_to_first_token_ms; + /** Inference framework used */ + rac_inference_framework_t framework; + /** Generation temperature (0 if not set) */ + float temperature; + /** Max tokens setting (0 if not set) */ + int32_t max_tokens; + /** Context length (0 if not set) */ + int32_t context_length; + /** Error code (RAC_SUCCESS if no error) */ + rac_result_t error_code; + /** Error message (NULL if no error) */ + const char* error_message; +} rac_analytics_llm_generation_t; + +/** + * @brief LLM model load analytics event data + * Used for: MODEL_LOAD_STARTED, MODEL_LOAD_COMPLETED, MODEL_LOAD_FAILED + */ +typedef struct rac_analytics_llm_model { + /** Model ID */ + const char* model_id; + /** Human-readable model name */ + const char* model_name; + /** Model size in bytes (0 if unknown) */ + int64_t model_size_bytes; + /** Load duration in milliseconds (for completed event) */ + double duration_ms; + /** Inference framework */ + rac_inference_framework_t framework; + /** Error code (RAC_SUCCESS if no error) */ + rac_result_t error_code; + /** Error message (NULL if no error) */ + const char* error_message; +} rac_analytics_llm_model_t; + +/** + * @brief STT transcription event data + * Used for: TRANSCRIPTION_STARTED, TRANSCRIPTION_COMPLETED, TRANSCRIPTION_FAILED + */ +typedef struct rac_analytics_stt_transcription { + /** Unique transcription identifier */ + const char* transcription_id; + /** Model ID used */ + const char* model_id; + /** Human-readable model name */ + const char* model_name; + /** Transcribed text (for completed event) */ + const char* text; + /** Confidence score (0.0 - 1.0) */ + float confidence; + /** Processing duration in milliseconds */ + double duration_ms; + /** Audio length in milliseconds */ + double audio_length_ms; + /** Audio size in bytes */ + int32_t audio_size_bytes; + /** Word count in result */ + int32_t word_count; + /** Real-time factor (audio_length / processing_time) */ + double real_time_factor; + /** Language code */ + const char* language; + /** Sample rate */ + int32_t sample_rate; + /** Whether streaming transcription */ + rac_bool_t is_streaming; + /** Inference framework */ + rac_inference_framework_t framework; + /** Error code (RAC_SUCCESS if no error) */ + rac_result_t error_code; + /** Error message (NULL if no error) */ + const char* error_message; +} rac_analytics_stt_transcription_t; + +/** + * @brief TTS synthesis event data + * Used for: SYNTHESIS_STARTED, SYNTHESIS_COMPLETED, SYNTHESIS_FAILED + */ +typedef struct rac_analytics_tts_synthesis { + /** Unique synthesis identifier */ + const char* synthesis_id; + /** Voice/Model ID used */ + const char* model_id; + /** Human-readable voice/model name */ + const char* model_name; + /** Character count of input text */ + int32_t character_count; + /** Audio duration in milliseconds */ + double audio_duration_ms; + /** Audio size in bytes */ + int32_t audio_size_bytes; + /** Processing duration in milliseconds */ + double processing_duration_ms; + /** Characters processed per second */ + double characters_per_second; + /** Sample rate */ + int32_t sample_rate; + /** Inference framework */ + rac_inference_framework_t framework; + /** Error code (RAC_SUCCESS if no error) */ + rac_result_t error_code; + /** Error message (NULL if no error) */ + const char* error_message; +} rac_analytics_tts_synthesis_t; + +/** + * @brief VAD event data + * Used for: VAD_STARTED, VAD_STOPPED, VAD_SPEECH_STARTED, VAD_SPEECH_ENDED + */ +typedef struct rac_analytics_vad { + /** Speech duration in milliseconds (for SPEECH_ENDED) */ + double speech_duration_ms; + /** Energy level (for speech events) */ + float energy_level; +} rac_analytics_vad_t; + +/** + * @brief Model download event data + * Used for: MODEL_DOWNLOAD_*, MODEL_EXTRACTION_*, MODEL_DELETED + */ +typedef struct rac_analytics_model_download { + /** Model identifier */ + const char* model_id; + /** Download progress (0.0 - 100.0) */ + double progress; + /** Bytes downloaded so far */ + int64_t bytes_downloaded; + /** Total bytes to download */ + int64_t total_bytes; + /** Duration in milliseconds */ + double duration_ms; + /** Final size in bytes (for completed event) */ + int64_t size_bytes; + /** Archive type (e.g., "zip", "tar.gz", "none") */ + const char* archive_type; + /** Error code (RAC_SUCCESS if no error) */ + rac_result_t error_code; + /** Error message (NULL if no error) */ + const char* error_message; +} rac_analytics_model_download_t; + +/** + * @brief SDK lifecycle event data + * Used for: SDK_INIT_*, SDK_MODELS_LOADED + */ +typedef struct rac_analytics_sdk_lifecycle { + /** Duration in milliseconds */ + double duration_ms; + /** Count (e.g., number of models loaded) */ + int32_t count; + /** Error code (RAC_SUCCESS if no error) */ + rac_result_t error_code; + /** Error message (NULL if no error) */ + const char* error_message; +} rac_analytics_sdk_lifecycle_t; + +/** + * @brief Storage event data + * Used for: STORAGE_CACHE_CLEARED, STORAGE_TEMP_CLEANED + */ +typedef struct rac_analytics_storage { + /** Bytes freed */ + int64_t freed_bytes; + /** Error code (RAC_SUCCESS if no error) */ + rac_result_t error_code; + /** Error message (NULL if no error) */ + const char* error_message; +} rac_analytics_storage_t; + +/** + * @brief Device event data + * Used for: DEVICE_REGISTERED, DEVICE_REGISTRATION_FAILED + */ +typedef struct rac_analytics_device { + /** Device identifier */ + const char* device_id; + /** Error code (RAC_SUCCESS if no error) */ + rac_result_t error_code; + /** Error message (NULL if no error) */ + const char* error_message; +} rac_analytics_device_t; + +/** + * @brief Network event data + * Used for: NETWORK_CONNECTIVITY_CHANGED + */ +typedef struct rac_analytics_network { + /** Whether the device is online */ + rac_bool_t is_online; +} rac_analytics_network_t; + +/** + * @brief SDK error event data + * Used for: SDK_ERROR + */ +typedef struct rac_analytics_sdk_error { + /** Error code */ + rac_result_t error_code; + /** Error message */ + const char* error_message; + /** Operation that failed */ + const char* operation; + /** Additional context */ + const char* context; +} rac_analytics_sdk_error_t; + +/** + * @brief Voice agent component state + * Used for: VOICE_AGENT_*_STATE_CHANGED events + */ +typedef enum rac_voice_agent_component_state { + RAC_VOICE_AGENT_STATE_NOT_LOADED = 0, + RAC_VOICE_AGENT_STATE_LOADING = 1, + RAC_VOICE_AGENT_STATE_LOADED = 2, + RAC_VOICE_AGENT_STATE_ERROR = 3, +} rac_voice_agent_component_state_t; + +/** + * @brief Voice agent state change event data + * Used for: VOICE_AGENT_STT_STATE_CHANGED, VOICE_AGENT_LLM_STATE_CHANGED, + * VOICE_AGENT_TTS_STATE_CHANGED, VOICE_AGENT_ALL_READY + */ +typedef struct rac_analytics_voice_agent_state { + /** Component name: "stt", "llm", "tts", or "all" */ + const char* component; + /** New state */ + rac_voice_agent_component_state_t state; + /** Model ID (if loaded) */ + const char* model_id; + /** Error message (if state is ERROR) */ + const char* error_message; +} rac_analytics_voice_agent_state_t; + +/** + * @brief Union of all event data types + */ +typedef struct rac_analytics_event_data { + rac_event_type_t type; + union { + rac_analytics_llm_generation_t llm_generation; + rac_analytics_llm_model_t llm_model; + rac_analytics_stt_transcription_t stt_transcription; + rac_analytics_tts_synthesis_t tts_synthesis; + rac_analytics_vad_t vad; + rac_analytics_model_download_t model_download; + rac_analytics_sdk_lifecycle_t sdk_lifecycle; + rac_analytics_storage_t storage; + rac_analytics_device_t device; + rac_analytics_network_t network; + rac_analytics_sdk_error_t sdk_error; + rac_analytics_voice_agent_state_t voice_agent_state; + } data; +} rac_analytics_event_data_t; + +// ============================================================================= +// EVENT CALLBACK API +// ============================================================================= + +/** + * @brief Event callback function type + * + * Platform SDKs implement this callback to receive events from C++. + * + * @param type Event type + * @param data Event data (lifetime: only valid during callback) + * @param user_data User data provided during registration + */ +typedef void (*rac_analytics_callback_fn)(rac_event_type_t type, + const rac_analytics_event_data_t* data, void* user_data); + +/** + * @brief Register analytics event callback + * + * Called by platform SDKs at initialization to receive analytics events. + * Only one callback can be registered at a time. + * + * @param callback Callback function (NULL to unregister) + * @param user_data User data passed to callback + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_analytics_events_set_callback(rac_analytics_callback_fn callback, + void* user_data); + +/** + * @brief Emit an analytics event + * + * Called internally by C++ components to emit analytics events. + * If no callback is registered, event is silently discarded. + * + * @param type Event type + * @param data Event data + */ +RAC_API void rac_analytics_event_emit(rac_event_type_t type, + const rac_analytics_event_data_t* data); + +/** + * @brief Check if analytics event callback is registered + * + * @return RAC_TRUE if callback is registered, RAC_FALSE otherwise + */ +RAC_API rac_bool_t rac_analytics_events_has_callback(void); + +// ============================================================================= +// PUBLIC EVENT CALLBACK API +// ============================================================================= + +/** + * @brief Public event callback function type + * + * Platform SDKs implement this callback to receive public events from C++. + * Public events are intended for app developers (UI updates, user feedback). + * + * @param type Event type + * @param data Event data (lifetime: only valid during callback) + * @param user_data User data provided during registration + */ +typedef void (*rac_public_event_callback_fn)(rac_event_type_t type, + const rac_analytics_event_data_t* data, + void* user_data); + +/** + * @brief Register public event callback + * + * Called by platform SDKs to receive public events (for app developers). + * Events are routed based on their destination: + * - PUBLIC_ONLY: Only sent to this callback + * - ALL: Sent to both this callback and telemetry + * + * @param callback Callback function (NULL to unregister) + * @param user_data User data passed to callback + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_analytics_events_set_public_callback(rac_public_event_callback_fn callback, + void* user_data); + +/** + * @brief Check if public event callback is registered + * + * @return RAC_TRUE if callback is registered, RAC_FALSE otherwise + */ +RAC_API rac_bool_t rac_analytics_events_has_public_callback(void); + +// ============================================================================= +// DEFAULT EVENT DATA +// ============================================================================= + +/** Default LLM generation event */ +static const rac_analytics_llm_generation_t RAC_ANALYTICS_LLM_GENERATION_DEFAULT = { + .generation_id = RAC_NULL, + .model_id = RAC_NULL, + .model_name = RAC_NULL, + .input_tokens = 0, + .output_tokens = 0, + .duration_ms = 0.0, + .tokens_per_second = 0.0, + .is_streaming = RAC_FALSE, + .time_to_first_token_ms = 0.0, + .framework = RAC_FRAMEWORK_UNKNOWN, + .temperature = 0.0f, + .max_tokens = 0, + .context_length = 0, + .error_code = RAC_SUCCESS, + .error_message = RAC_NULL}; + +/** Default STT transcription event */ +static const rac_analytics_stt_transcription_t RAC_ANALYTICS_STT_TRANSCRIPTION_DEFAULT = { + .transcription_id = RAC_NULL, + .model_id = RAC_NULL, + .model_name = RAC_NULL, + .text = RAC_NULL, + .confidence = 0.0f, + .duration_ms = 0.0, + .audio_length_ms = 0.0, + .audio_size_bytes = 0, + .word_count = 0, + .real_time_factor = 0.0, + .language = RAC_NULL, + .sample_rate = 0, + .is_streaming = RAC_FALSE, + .framework = RAC_FRAMEWORK_UNKNOWN, + .error_code = RAC_SUCCESS, + .error_message = RAC_NULL}; + +/** Default TTS synthesis event */ +static const rac_analytics_tts_synthesis_t RAC_ANALYTICS_TTS_SYNTHESIS_DEFAULT = { + .synthesis_id = RAC_NULL, + .model_id = RAC_NULL, + .model_name = RAC_NULL, + .character_count = 0, + .audio_duration_ms = 0.0, + .audio_size_bytes = 0, + .processing_duration_ms = 0.0, + .characters_per_second = 0.0, + .sample_rate = 0, + .framework = RAC_FRAMEWORK_UNKNOWN, + .error_code = RAC_SUCCESS, + .error_message = RAC_NULL}; + +/** Default VAD event */ +static const rac_analytics_vad_t RAC_ANALYTICS_VAD_DEFAULT = {.speech_duration_ms = 0.0, + .energy_level = 0.0f}; + +/** Default model download event */ +static const rac_analytics_model_download_t RAC_ANALYTICS_MODEL_DOWNLOAD_DEFAULT = { + .model_id = RAC_NULL, + .progress = 0.0, + .bytes_downloaded = 0, + .total_bytes = 0, + .duration_ms = 0.0, + .size_bytes = 0, + .archive_type = RAC_NULL, + .error_code = RAC_SUCCESS, + .error_message = RAC_NULL}; + +/** Default SDK lifecycle event */ +static const rac_analytics_sdk_lifecycle_t RAC_ANALYTICS_SDK_LIFECYCLE_DEFAULT = { + .duration_ms = 0.0, .count = 0, .error_code = RAC_SUCCESS, .error_message = RAC_NULL}; + +/** Default storage event */ +static const rac_analytics_storage_t RAC_ANALYTICS_STORAGE_DEFAULT = { + .freed_bytes = 0, .error_code = RAC_SUCCESS, .error_message = RAC_NULL}; + +/** Default device event */ +static const rac_analytics_device_t RAC_ANALYTICS_DEVICE_DEFAULT = { + .device_id = RAC_NULL, .error_code = RAC_SUCCESS, .error_message = RAC_NULL}; + +/** Default network event */ +static const rac_analytics_network_t RAC_ANALYTICS_NETWORK_DEFAULT = {.is_online = RAC_FALSE}; + +/** Default SDK error event */ +static const rac_analytics_sdk_error_t RAC_ANALYTICS_SDK_ERROR_DEFAULT = {.error_code = RAC_SUCCESS, + .error_message = RAC_NULL, + .operation = RAC_NULL, + .context = RAC_NULL}; + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_ANALYTICS_EVENTS_H */ diff --git a/sdk/runanywhere-commons/include/rac/core/rac_audio_utils.h b/sdk/runanywhere-commons/include/rac/core/rac_audio_utils.h new file mode 100644 index 000000000..7bf6b8407 --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/core/rac_audio_utils.h @@ -0,0 +1,88 @@ +/** + * @file rac_audio_utils.h + * @brief RunAnywhere Commons - Audio Utility Functions + * + * Provides audio format conversion utilities used across the SDK. + * This centralizes audio processing logic that was previously duplicated + * in Swift/Kotlin SDKs. + */ + +#ifndef RAC_AUDIO_UTILS_H +#define RAC_AUDIO_UTILS_H + +#include "rac/core/rac_types.h" +#include "rac/features/tts/rac_tts_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// AUDIO CONVERSION API +// ============================================================================= + +/** + * @brief Convert Float32 PCM samples to WAV format (Int16 PCM with header) + * + * TTS backends typically output raw Float32 PCM samples in range [-1.0, 1.0]. + * This function converts them to a complete WAV file that can be played by + * standard audio players (AVAudioPlayer on iOS, MediaPlayer on Android, etc.). + * + * WAV format details: + * - RIFF header with WAVE format + * - fmt chunk: PCM format (1), mono (1 channel), Int16 samples + * - data chunk: Int16 samples (scaled from Float32) + * + * @param pcm_data Input Float32 PCM samples + * @param pcm_size Size of pcm_data in bytes (must be multiple of 4) + * @param sample_rate Sample rate in Hz (e.g., 22050 for Piper TTS) + * @param out_wav_data Output: WAV file data (owned, must be freed with rac_free) + * @param out_wav_size Output: Size of WAV data in bytes + * @return RAC_SUCCESS or error code + * + * @note The caller owns the returned wav_data and must free it with rac_free() + * + * Example usage: + * @code + * void* wav_data = NULL; + * size_t wav_size = 0; + * rac_result_t result = rac_audio_float32_to_wav( + * pcm_samples, pcm_size, RAC_TTS_DEFAULT_SAMPLE_RATE, &wav_data, &wav_size); + * if (result == RAC_SUCCESS) { + * // Use wav_data... + * rac_free(wav_data); + * } + * @endcode + */ +RAC_API rac_result_t rac_audio_float32_to_wav(const void* pcm_data, size_t pcm_size, + int32_t sample_rate, void** out_wav_data, + size_t* out_wav_size); + +/** + * @brief Convert Int16 PCM samples to WAV format + * + * Similar to rac_audio_float32_to_wav but for Int16 input samples. + * + * @param pcm_data Input Int16 PCM samples + * @param pcm_size Size of pcm_data in bytes (must be multiple of 2) + * @param sample_rate Sample rate in Hz + * @param out_wav_data Output: WAV file data (owned, must be freed with rac_free) + * @param out_wav_size Output: Size of WAV data in bytes + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_audio_int16_to_wav(const void* pcm_data, size_t pcm_size, + int32_t sample_rate, void** out_wav_data, + size_t* out_wav_size); + +/** + * @brief Get WAV header size in bytes + * + * @return WAV header size (always 44 bytes for standard PCM WAV) + */ +RAC_API size_t rac_audio_wav_header_size(void); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_AUDIO_UTILS_H */ diff --git a/sdk/runanywhere-commons/include/rac/core/rac_benchmark.h b/sdk/runanywhere-commons/include/rac/core/rac_benchmark.h new file mode 100644 index 000000000..4a6b50a70 --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/core/rac_benchmark.h @@ -0,0 +1,136 @@ +/** + * @file rac_benchmark.h + * @brief RunAnywhere Commons - Benchmark Timing Support + * + * This header provides types and functions for benchmark timing instrumentation. + * The timing struct captures key timestamps during LLM inference for performance + * measurement and analysis. + * + * Design principles: + * - Zero overhead when not benchmarking: timing is opt-in via pointer parameter + * - Monotonic clock: uses steady_clock for accurate cross-platform timing + * - All timestamps are relative to a process-local epoch (not wall-clock) + */ + +#ifndef RAC_BENCHMARK_H +#define RAC_BENCHMARK_H + +#include "rac/core/rac_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// BENCHMARK TIMING STRUCT +// ============================================================================= + +/** + * Benchmark timing structure for LLM inference. + * + * Captures timestamps at key points during inference: + * - t0: Request start (component API entry) + * - t2: Prefill start (backend, before llama_decode for prompt) + * - t3: Prefill end (backend, after llama_decode returns) + * - t4: First token (component, first token callback) + * - t5: Last token (backend, decode loop exits) + * - t6: Request end (component, before complete callback) + * + * All timestamps are in milliseconds from a process-local epoch. + * Use rac_monotonic_now_ms() to get comparable timestamps. + * + * Note: t1 is intentionally skipped to match the specification. + */ +typedef struct rac_benchmark_timing { + /** t0: Request start - recorded at component API entry */ + int64_t t0_request_start_ms; + + /** t2: Prefill start - recorded before llama_decode for prompt batch */ + int64_t t2_prefill_start_ms; + + /** t3: Prefill end - recorded after llama_decode returns for prompt */ + int64_t t3_prefill_end_ms; + + /** t4: First token - recorded when first token callback is invoked */ + int64_t t4_first_token_ms; + + /** t5: Last token - recorded when decode loop exits */ + int64_t t5_last_token_ms; + + /** t6: Request end - recorded before complete callback */ + int64_t t6_request_end_ms; + + /** Number of tokens in the prompt */ + int32_t prompt_tokens; + + /** Number of tokens generated */ + int32_t output_tokens; + + /** + * Status of the benchmark request. + * Uses RAC_BENCHMARK_STATUS_* codes: + * - RAC_BENCHMARK_STATUS_SUCCESS (0): Completed successfully + * - RAC_BENCHMARK_STATUS_ERROR (1): Failed + * - RAC_BENCHMARK_STATUS_TIMEOUT (2): Timed out + * - RAC_BENCHMARK_STATUS_CANCELLED (3): Cancelled + */ + int32_t status; + + /** + * Specific error code when status is not RAC_BENCHMARK_STATUS_SUCCESS. + * Uses rac_result_t error codes (e.g., RAC_ERROR_NOT_SUPPORTED). + * Set to RAC_SUCCESS (0) when status is RAC_BENCHMARK_STATUS_SUCCESS. + */ + rac_result_t error_code; + +} rac_benchmark_timing_t; + +// ============================================================================= +// BENCHMARK STATUS CODES +// ============================================================================= + +/** Benchmark request completed successfully */ +#define RAC_BENCHMARK_STATUS_SUCCESS ((int32_t)0) + +/** Benchmark request failed due to error */ +#define RAC_BENCHMARK_STATUS_ERROR ((int32_t)1) + +/** Benchmark request timed out */ +#define RAC_BENCHMARK_STATUS_TIMEOUT ((int32_t)2) + +/** Benchmark request was cancelled */ +#define RAC_BENCHMARK_STATUS_CANCELLED ((int32_t)3) + +// ============================================================================= +// MONOTONIC TIME API +// ============================================================================= + +/** + * Gets the current monotonic time in milliseconds. + * + * Uses std::chrono::steady_clock for accurate, monotonic timing that is not + * affected by system clock changes. The returned value is relative to a + * process-local epoch (the first call to this function). + * + * This function is thread-safe and lock-free on all supported platforms. + * + * @return Current monotonic time in milliseconds from process-local epoch + */ +RAC_API int64_t rac_monotonic_now_ms(void); + +// ============================================================================= +// UTILITY FUNCTIONS +// ============================================================================= + +/** + * Initializes a benchmark timing struct to zero values. + * + * @param timing Pointer to timing struct to initialize + */ +RAC_API void rac_benchmark_timing_init(rac_benchmark_timing_t* timing); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_BENCHMARK_H */ diff --git a/sdk/runanywhere-commons/include/rac/core/rac_benchmark_log.h b/sdk/runanywhere-commons/include/rac/core/rac_benchmark_log.h new file mode 100644 index 000000000..97bd86970 --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/core/rac_benchmark_log.h @@ -0,0 +1,87 @@ +/** + * @file rac_benchmark_log.h + * @brief RunAnywhere Commons - Benchmark Logging and Serialization + * + * Provides functions to serialize benchmark timing data as JSON or CSV, + * and to log benchmark results via the RAC logging system. + * + * Usage: + * // Log timing summary + * rac_benchmark_timing_log(&timing, "inference_run_1"); + * + * // Export as JSON + * char* json = rac_benchmark_timing_to_json(&timing); + * // ... use json ... + * free(json); + * + * // Export as CSV + * char* header = rac_benchmark_timing_to_csv(NULL, RAC_TRUE); + * char* row = rac_benchmark_timing_to_csv(&timing, RAC_FALSE); + * free(header); + * free(row); + */ + +#ifndef RAC_BENCHMARK_LOG_H +#define RAC_BENCHMARK_LOG_H + +#include "rac/core/rac_benchmark.h" +#include "rac/core/rac_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// JSON SERIALIZATION +// ============================================================================= + +/** + * Serializes a benchmark timing struct as a JSON string. + * + * Includes all timing fields plus derived metrics: + * - ttft_ms: Time to first token (t4 - t0) + * - prefill_ms: Prefill duration (t3 - t2) + * - decode_ms: Decode duration (t5 - t3) + * - e2e_ms: End-to-end latency (t6 - t0) + * - decode_tps: Decode throughput (output_tokens / decode_ms * 1000) + * + * @param timing Timing struct to serialize (NULL returns NULL) + * @return Heap-allocated JSON string (caller must free()), or NULL on error + */ +RAC_API char* rac_benchmark_timing_to_json(const rac_benchmark_timing_t* timing); + +// ============================================================================= +// CSV SERIALIZATION +// ============================================================================= + +/** + * Serializes a benchmark timing struct as a CSV row. + * + * @param timing Timing struct to serialize (ignored when header is RAC_TRUE) + * @param header If RAC_TRUE, returns the CSV header row instead of data + * @return Heap-allocated CSV string (caller must free()), or NULL on error + */ +RAC_API char* rac_benchmark_timing_to_csv(const rac_benchmark_timing_t* timing, rac_bool_t header); + +// ============================================================================= +// LOGGING +// ============================================================================= + +/** + * Logs a benchmark timing summary via the RAC logging system. + * + * Outputs key metrics at INFO level under the "Benchmark" category: + * - TTFT, prefill time, decode time, E2E latency + * - Token counts and throughput + * - Status and error code + * + * @param timing Timing struct to log (NULL is a no-op) + * @param label Optional label for this benchmark run (can be NULL) + */ +RAC_API void rac_benchmark_timing_log(const rac_benchmark_timing_t* timing, const char* label); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_BENCHMARK_LOG_H */ diff --git a/sdk/runanywhere-commons/include/rac/core/rac_benchmark_metrics.h b/sdk/runanywhere-commons/include/rac/core/rac_benchmark_metrics.h new file mode 100644 index 000000000..7964fd6ca --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/core/rac_benchmark_metrics.h @@ -0,0 +1,119 @@ +/** + * @file rac_benchmark_metrics.h + * @brief RunAnywhere Commons - Extended Benchmark Metrics + * + * Defines extended device/platform metrics captured alongside benchmark timing. + * Actual metric collection is platform-specific (iOS/Android) and provided + * via a callback provider pattern. The C++ layer defines interfaces only. + * + * Usage: + * // Platform SDK registers a provider during init: + * rac_benchmark_set_metrics_provider(my_provider_fn, my_context); + * + * // Commons layer captures metrics at t0 and t6: + * rac_benchmark_extended_metrics_t metrics; + * rac_benchmark_capture_metrics(&metrics); + */ + +#ifndef RAC_BENCHMARK_METRICS_H +#define RAC_BENCHMARK_METRICS_H + +#include "rac/core/rac_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// EXTENDED METRICS STRUCT +// ============================================================================= + +/** + * Extended device/platform metrics captured during benchmark. + * + * All fields default to -1 (unavailable) unless the platform provider + * populates them. This allows partial metric support across platforms. + */ +typedef struct rac_benchmark_extended_metrics { + /** Resident memory usage in bytes at capture time (-1 if unavailable) */ + int64_t memory_usage_bytes; + + /** Peak memory usage in bytes during request (-1 if unavailable) */ + int64_t memory_peak_bytes; + + /** CPU temperature in Celsius (-1.0 if unavailable) */ + float cpu_temperature_celsius; + + /** Battery level 0.0-1.0 (-1.0 if unavailable) */ + float battery_level; + + /** GPU utilization 0-100% (-1.0 if unavailable) */ + float gpu_utilization_percent; + + /** + * Thermal state of the device. + * 0 = nominal + * 1 = fair + * 2 = serious + * 3 = critical + * -1 = unavailable + */ + int32_t thermal_state; + +} rac_benchmark_extended_metrics_t; + +// ============================================================================= +// METRICS PROVIDER CALLBACK +// ============================================================================= + +/** + * Callback type for platform-specific metrics collection. + * + * The platform SDK (Swift/Kotlin) implements this to fill in + * whatever device metrics are available on that platform. + * + * @param out Metrics struct to populate (pre-initialized to unavailable values) + * @param user_data Platform context passed during registration + */ +typedef void (*rac_benchmark_metrics_provider_fn)(rac_benchmark_extended_metrics_t* out, + void* user_data); + +// ============================================================================= +// METRICS API +// ============================================================================= + +/** + * Registers a platform-specific metrics provider. + * + * Call this during SDK initialization. Only one provider can be active. + * Setting a new provider replaces the previous one. + * Pass NULL to unregister. + * + * @param provider Metrics provider callback (NULL to unregister) + * @param user_data Platform context passed to provider calls + */ +RAC_API void rac_benchmark_set_metrics_provider(rac_benchmark_metrics_provider_fn provider, + void* user_data); + +/** + * Captures current device metrics using the registered provider. + * + * If no provider is registered, all fields are set to unavailable (-1). + * Thread-safe: can be called from any thread. + * + * @param out Metrics struct to populate (must not be NULL) + */ +RAC_API void rac_benchmark_capture_metrics(rac_benchmark_extended_metrics_t* out); + +/** + * Initializes an extended metrics struct to unavailable values. + * + * @param metrics Metrics struct to initialize (must not be NULL) + */ +RAC_API void rac_benchmark_extended_metrics_init(rac_benchmark_extended_metrics_t* metrics); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_BENCHMARK_METRICS_H */ diff --git a/sdk/runanywhere-commons/include/rac/core/rac_benchmark_stats.h b/sdk/runanywhere-commons/include/rac/core/rac_benchmark_stats.h new file mode 100644 index 000000000..370f88b85 --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/core/rac_benchmark_stats.h @@ -0,0 +1,157 @@ +/** + * @file rac_benchmark_stats.h + * @brief RunAnywhere Commons - Benchmark Statistical Analysis + * + * Collects benchmark timing observations and computes statistical summaries + * including percentiles (P50/P95/P99), mean, stddev, and outlier detection. + * + * Usage: + * rac_benchmark_stats_handle_t stats; + * rac_benchmark_stats_create(&stats); + * + * // Record observations + * rac_benchmark_stats_record(stats, &timing1); + * rac_benchmark_stats_record(stats, &timing2); + * + * // Get summary + * rac_benchmark_summary_t summary; + * rac_benchmark_stats_get_summary(stats, &summary); + * + * // Export as JSON + * char* json = rac_benchmark_stats_summary_to_json(&summary); + * free(json); + * + * rac_benchmark_stats_destroy(stats); + */ + +#ifndef RAC_BENCHMARK_STATS_H +#define RAC_BENCHMARK_STATS_H + +#include "rac/core/rac_benchmark.h" +#include "rac/core/rac_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// STATS HANDLE (OPAQUE) +// ============================================================================= + +/** Opaque handle for a benchmark stats collector */ +typedef void* rac_benchmark_stats_handle_t; + +// ============================================================================= +// SUMMARY STRUCT +// ============================================================================= + +/** + * Statistical summary of collected benchmark observations. + * + * All time values are in milliseconds. Throughput is in tokens/second. + * Fields are 0 if no valid observations were recorded for that metric. + */ +typedef struct rac_benchmark_summary { + /** Number of observations recorded */ + int32_t count; + + // Time to First Token stats (t4 - t0) + double ttft_p50_ms; + double ttft_p95_ms; + double ttft_p99_ms; + double ttft_min_ms; + double ttft_max_ms; + double ttft_mean_ms; + double ttft_stddev_ms; + + // Prefill duration stats (t3 - t2) + double prefill_p50_ms; + double prefill_p95_ms; + double prefill_p99_ms; + + // Decode throughput stats (output_tokens / (t5 - t3) * 1000) + double decode_tps_p50; + double decode_tps_p95; + double decode_tps_p99; + + // End-to-end latency stats (t6 - t0) + double e2e_p50_ms; + double e2e_p95_ms; + double e2e_p99_ms; + + /** Number of observations where E2E > mean + 2*stddev */ + int32_t outlier_count; + +} rac_benchmark_summary_t; + +// ============================================================================= +// STATS COLLECTOR API +// ============================================================================= + +/** + * Creates a new benchmark stats collector. + * + * @param out_handle Output: collector handle + * @return RAC_SUCCESS or RAC_ERROR_NULL_POINTER + */ +RAC_API rac_result_t rac_benchmark_stats_create(rac_benchmark_stats_handle_t* out_handle); + +/** + * Destroys a stats collector and frees all associated memory. + * + * @param handle Collector handle (NULL is a no-op) + */ +RAC_API void rac_benchmark_stats_destroy(rac_benchmark_stats_handle_t handle); + +/** + * Records a benchmark timing observation. + * + * Only observations with status == RAC_BENCHMARK_STATUS_SUCCESS are recorded. + * Derived metrics (TTFT, prefill, decode TPS, E2E) are extracted and stored. + * + * Thread-safe: can be called from any thread. + * + * @param handle Collector handle + * @param timing Timing struct to record + */ +RAC_API void rac_benchmark_stats_record(rac_benchmark_stats_handle_t handle, + const rac_benchmark_timing_t* timing); + +/** + * Resets the collector, discarding all recorded observations. + * + * @param handle Collector handle + */ +RAC_API void rac_benchmark_stats_reset(rac_benchmark_stats_handle_t handle); + +/** + * Returns the number of recorded observations. + * + * @param handle Collector handle + * @return Observation count (0 if handle is NULL) + */ +RAC_API int32_t rac_benchmark_stats_count(rac_benchmark_stats_handle_t handle); + +/** + * Computes a statistical summary of all recorded observations. + * + * @param handle Collector handle + * @param out_summary Output: summary struct + * @return RAC_SUCCESS, RAC_ERROR_NULL_POINTER, or RAC_ERROR_INVALID_STATE (no data) + */ +RAC_API rac_result_t rac_benchmark_stats_get_summary(rac_benchmark_stats_handle_t handle, + rac_benchmark_summary_t* out_summary); + +/** + * Serializes a summary struct as a JSON string. + * + * @param summary Summary struct to serialize (NULL returns NULL) + * @return Heap-allocated JSON string (caller must free()), or NULL on error + */ +RAC_API char* rac_benchmark_stats_summary_to_json(const rac_benchmark_summary_t* summary); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_BENCHMARK_STATS_H */ diff --git a/sdk/runanywhere-commons/include/rac/core/rac_component_types.h b/sdk/runanywhere-commons/include/rac/core/rac_component_types.h new file mode 100644 index 000000000..1198c9600 --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/core/rac_component_types.h @@ -0,0 +1,160 @@ +/** + * @file rac_component_types.h + * @brief RunAnywhere Commons - Core Component Types + * + * C port of Swift's component types from: + * Sources/RunAnywhere/Core/Types/ComponentTypes.swift + * Sources/RunAnywhere/Core/Capabilities/Analytics/ResourceTypes.swift + * + * These types define SDK components, their configurations, and resource types. + */ + +#ifndef RAC_COMPONENT_TYPES_H +#define RAC_COMPONENT_TYPES_H + +#include "rac/core/rac_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// SDK COMPONENT - Mirrors Swift's SDKComponent enum +// ============================================================================= + +/** + * @brief SDK component types for identification + * + * Mirrors Swift's SDKComponent enum exactly. + * See: Sources/RunAnywhere/Core/Types/ComponentTypes.swift + */ +typedef enum rac_sdk_component { + RAC_COMPONENT_LLM = 0, /**< Large Language Model */ + RAC_COMPONENT_STT = 1, /**< Speech-to-Text */ + RAC_COMPONENT_TTS = 2, /**< Text-to-Speech */ + RAC_COMPONENT_VAD = 3, /**< Voice Activity Detection */ + RAC_COMPONENT_VOICE = 4, /**< Voice Agent */ + RAC_COMPONENT_EMBEDDING = 5, /**< Embedding generation */ +} rac_sdk_component_t; + +/** + * @brief Get human-readable display name for SDK component + * + * @param component The SDK component type + * @return Display name string (static, do not free) + */ +RAC_API const char* rac_sdk_component_display_name(rac_sdk_component_t component); + +/** + * @brief Get raw string value for SDK component + * + * Mirrors Swift's rawValue property. + * + * @param component The SDK component type + * @return Raw string value (static, do not free) + */ +RAC_API const char* rac_sdk_component_raw_value(rac_sdk_component_t component); + +// ============================================================================= +// CAPABILITY RESOURCE TYPE - Mirrors Swift's CapabilityResourceType enum +// ============================================================================= + +/** + * @brief Types of resources that can be loaded by capabilities + * + * Mirrors Swift's CapabilityResourceType enum exactly. + * See: Sources/RunAnywhere/Core/Capabilities/Analytics/ResourceTypes.swift + */ +typedef enum rac_capability_resource_type { + RAC_RESOURCE_LLM_MODEL = 0, /**< LLM model */ + RAC_RESOURCE_STT_MODEL = 1, /**< STT model */ + RAC_RESOURCE_TTS_VOICE = 2, /**< TTS voice */ + RAC_RESOURCE_VAD_MODEL = 3, /**< VAD model */ + RAC_RESOURCE_DIARIZATION_MODEL = 4, /**< Diarization model */ +} rac_capability_resource_type_t; + +/** + * @brief Get raw string value for capability resource type + * + * Mirrors Swift's rawValue property. + * + * @param type The capability resource type + * @return Raw string value (static, do not free) + */ +RAC_API const char* rac_capability_resource_type_raw_value(rac_capability_resource_type_t type); + +// ============================================================================= +// COMPONENT CONFIGURATION - Mirrors Swift's ComponentConfiguration protocol +// ============================================================================= + +/** + * @brief Base component configuration + * + * Mirrors Swift's ComponentConfiguration protocol. + * See: Sources/RunAnywhere/Core/Types/ComponentTypes.swift + * + * Note: In C, we use a struct with common fields instead of a protocol. + * Specific configurations (LLM, STT, TTS, VAD) extend this with their own fields. + */ +typedef struct rac_component_config_base { + /** Model identifier (optional - uses default if NULL) */ + const char* model_id; + + /** Preferred inference framework (use -1 for auto/none) */ + int32_t preferred_framework; +} rac_component_config_base_t; + +/** + * @brief Default base component configuration + */ +static const rac_component_config_base_t RAC_COMPONENT_CONFIG_BASE_DEFAULT = { + .model_id = RAC_NULL, .preferred_framework = -1 /* No preference */ +}; + +// ============================================================================= +// COMPONENT INPUT/OUTPUT - Mirrors Swift's ComponentInput/ComponentOutput protocols +// ============================================================================= + +/** + * @brief Base component output with timestamp + * + * Mirrors Swift's ComponentOutput protocol requirement. + * All outputs include a timestamp in milliseconds since epoch. + */ +typedef struct rac_component_output_base { + /** Timestamp in milliseconds since epoch (1970-01-01 00:00:00 UTC) */ + int64_t timestamp_ms; +} rac_component_output_base_t; + +// ============================================================================= +// INFERENCE FRAMEWORK - Mirrors Swift's InferenceFramework enum +// (Typically defined in model_types, but included here for completeness) +// ============================================================================= + +/** + * @brief Get SDK component type from capability resource type + * + * Maps resource types to their corresponding SDK components. + * + * @param resource_type The capability resource type + * @return Corresponding SDK component type + */ +RAC_API rac_sdk_component_t +rac_resource_type_to_component(rac_capability_resource_type_t resource_type); + +/** + * @brief Get capability resource type from SDK component type + * + * Maps SDK components to their corresponding resource types. + * + * @param component The SDK component type + * @return Corresponding capability resource type, or -1 if no mapping exists + */ +RAC_API rac_capability_resource_type_t +rac_component_to_resource_type(rac_sdk_component_t component); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_COMPONENT_TYPES_H */ diff --git a/sdk/runanywhere-commons/include/rac/core/rac_core.h b/sdk/runanywhere-commons/include/rac/core/rac_core.h new file mode 100644 index 000000000..6f6314df1 --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/core/rac_core.h @@ -0,0 +1,331 @@ +/** + * @file rac_core.h + * @brief RunAnywhere Commons - Core Initialization and Module Management + * + * This header provides the core API for initializing and shutting down + * the commons library, as well as module registration and discovery. + */ + +#ifndef RAC_CORE_H +#define RAC_CORE_H + +#include "rac/core/rac_error.h" +#include "rac/core/rac_types.h" +#include "rac/infrastructure/model_management/rac_model_types.h" +#include "rac/infrastructure/network/rac_environment.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// FORWARD DECLARATIONS +// ============================================================================= + +/** Platform adapter (see rac_platform_adapter.h) */ +typedef struct rac_platform_adapter rac_platform_adapter_t; + +// ============================================================================= +// CONFIGURATION +// ============================================================================= + +/** + * Configuration for initializing the commons library. + */ +typedef struct rac_config { + /** Platform adapter providing file, logging, and other platform callbacks */ + const rac_platform_adapter_t* platform_adapter; + + /** Log level for internal logging */ + rac_log_level_t log_level; + + /** Application-specific tag for logging */ + const char* log_tag; + + /** Reserved for future use (set to NULL) */ + void* reserved; +} rac_config_t; + +// ============================================================================= +// INITIALIZATION API +// ============================================================================= + +/** + * Initializes the commons library. + * + * This must be called before any other RAC functions. The platform adapter + * is required and provides callbacks for platform-specific operations. + * + * @param config Configuration options (platform_adapter is required) + * @return RAC_SUCCESS on success, or an error code on failure + * + * @note HTTP requests return RAC_ERROR_NOT_SUPPORTED - networking should be + * handled by the SDK layer (Swift/Kotlin), not the C++ layer. + */ +RAC_API rac_result_t rac_init(const rac_config_t* config); + +/** + * Shuts down the commons library. + * + * This releases all resources and unregisters all modules. Any active + * handles become invalid after this call. + */ +RAC_API void rac_shutdown(void); + +/** + * Checks if the commons library is initialized. + * + * @return RAC_TRUE if initialized, RAC_FALSE otherwise + */ +RAC_API rac_bool_t rac_is_initialized(void); + +/** + * Gets the version of the commons library. + * + * @return Version information structure + */ +RAC_API rac_version_t rac_get_version(void); + +/** + * Configures logging based on the environment. + * + * This configures C++ local logging (stderr) based on the environment: + * - Development: stderr ON, min level DEBUG + * - Staging: stderr ON, min level INFO + * - Production: stderr OFF, min level WARNING (logs only go to Swift bridge) + * + * Call this during SDK initialization after setting the platform adapter. + * + * @param environment The current SDK environment + * @return RAC_SUCCESS on success + */ +RAC_API rac_result_t rac_configure_logging(rac_environment_t environment); + +// ============================================================================= +// MODULE INFORMATION +// ============================================================================= + +/** + * Information about a registered module (backend). + */ +typedef struct rac_module_info { + const char* id; /**< Unique module identifier */ + const char* name; /**< Human-readable name */ + const char* version; /**< Module version string */ + const char* description; /**< Module description */ + + /** Capabilities provided by this module */ + const rac_capability_t* capabilities; + size_t num_capabilities; +} rac_module_info_t; + +// ============================================================================= +// MODULE REGISTRATION API +// ============================================================================= + +/** + * Registers a module with the registry. + * + * Modules (backends) call this to register themselves with the commons layer. + * This allows the SDK to discover available backends at runtime. + * + * @param info Module information (copied internally) + * @return RAC_SUCCESS on success, or an error code on failure + */ +RAC_API rac_result_t rac_module_register(const rac_module_info_t* info); + +/** + * Unregisters a module from the registry. + * + * @param module_id The unique ID of the module to unregister + * @return RAC_SUCCESS on success, or an error code on failure + */ +RAC_API rac_result_t rac_module_unregister(const char* module_id); + +/** + * Gets the list of registered modules. + * + * @param out_modules Pointer to receive the module list (do not free) + * @param out_count Pointer to receive the number of modules + * @return RAC_SUCCESS on success, or an error code on failure + * + * @note The returned list is valid until the next module registration/unregistration. + */ +RAC_API rac_result_t rac_module_list(const rac_module_info_t** out_modules, size_t* out_count); + +/** + * Gets modules that provide a specific capability. + * + * @param capability The capability to search for + * @param out_modules Pointer to receive the module list (do not free) + * @param out_count Pointer to receive the number of modules + * @return RAC_SUCCESS on success, or an error code on failure + */ +RAC_API rac_result_t rac_modules_for_capability(rac_capability_t capability, + const rac_module_info_t** out_modules, + size_t* out_count); + +/** + * Gets information about a specific module. + * + * @param module_id The unique ID of the module + * @param out_info Pointer to receive the module info (do not free) + * @return RAC_SUCCESS on success, or RAC_ERROR_MODULE_NOT_FOUND if not found + */ +RAC_API rac_result_t rac_module_get_info(const char* module_id, const rac_module_info_t** out_info); + +// ============================================================================= +// SERVICE PROVIDER API - Mirrors Swift's ServiceRegistry +// ============================================================================= + +/** + * Service request for creating services. + * Passed to canHandle and create functions. + * + * Mirrors Swift's approach where canHandle receives a model/voice ID. + */ +typedef struct rac_service_request { + /** Model or voice ID to check/create for (can be NULL for default) */ + const char* identifier; + + /** Configuration JSON string (can be NULL) */ + const char* config_json; + + /** The capability being requested */ + rac_capability_t capability; + + /** Framework hint for routing (from model registry) */ + rac_inference_framework_t framework; + + /** Local path to model file (can be NULL if using identifier lookup) */ + const char* model_path; +} rac_service_request_t; + +/** + * canHandle function type. + * Mirrors Swift's `canHandle: @Sendable (String?) -> Bool` + * + * @param request The service request + * @param user_data Provider-specific context + * @return RAC_TRUE if this provider can handle the request + */ +typedef rac_bool_t (*rac_service_can_handle_fn)(const rac_service_request_t* request, + void* user_data); + +/** + * Service factory function type. + * Mirrors Swift's factory closure. + * + * @param request The service request + * @param user_data Provider-specific context + * @return Handle to created service, or NULL on failure + */ +typedef rac_handle_t (*rac_service_create_fn)(const rac_service_request_t* request, + void* user_data); + +/** + * Service provider registration. + * Mirrors Swift's ServiceRegistration struct. + */ +typedef struct rac_service_provider { + /** Provider name (e.g., "LlamaCPPService") */ + const char* name; + + /** Capability this provider offers */ + rac_capability_t capability; + + /** Priority (higher = preferred, default 100) */ + int32_t priority; + + /** Function to check if provider can handle request */ + rac_service_can_handle_fn can_handle; + + /** Function to create service instance */ + rac_service_create_fn create; + + /** User data passed to callbacks */ + void* user_data; +} rac_service_provider_t; + +/** + * Registers a service provider. + * + * Mirrors Swift's ServiceRegistry.registerSTT/LLM/TTS/VAD methods. + * Providers are sorted by priority (higher first). + * + * @param provider Provider information (copied internally) + * @return RAC_SUCCESS on success, or an error code on failure + */ +RAC_API rac_result_t rac_service_register_provider(const rac_service_provider_t* provider); + +/** + * Unregisters a service provider. + * + * @param name The name of the provider to unregister + * @param capability The capability the provider was registered for + * @return RAC_SUCCESS on success, or an error code on failure + */ +RAC_API rac_result_t rac_service_unregister_provider(const char* name, rac_capability_t capability); + +/** + * Creates a service for a specific capability. + * + * Mirrors Swift's createSTT/LLM/TTS/VAD methods. + * Finds first provider that canHandle the request (sorted by priority). + * + * @param capability The capability needed + * @param request The service request (can have identifier and config) + * @param out_handle Pointer to receive the service handle + * @return RAC_SUCCESS on success, or an error code on failure + */ +RAC_API rac_result_t rac_service_create(rac_capability_t capability, + const rac_service_request_t* request, + rac_handle_t* out_handle); + +/** + * Lists registered providers for a capability. + * + * @param capability The capability to list providers for + * @param out_names Pointer to receive array of provider names + * @param out_count Pointer to receive count + * @return RAC_SUCCESS on success + */ +RAC_API rac_result_t rac_service_list_providers(rac_capability_t capability, + const char*** out_names, size_t* out_count); + +// ============================================================================= +// GLOBAL MODEL REGISTRY API +// ============================================================================= + +/** + * Gets the global model registry instance. + * The registry is created automatically on first access. + * + * @return Handle to the global model registry + */ +RAC_API struct rac_model_registry* rac_get_model_registry(void); + +/** + * Registers a model with the global registry. + * Convenience function that calls rac_model_registry_save on the global registry. + * + * @param model Model info to register + * @return RAC_SUCCESS on success, or error code + */ +RAC_API rac_result_t rac_register_model(const struct rac_model_info* model); + +/** + * Gets model info from the global registry. + * Convenience function that calls rac_model_registry_get on the global registry. + * + * @param model_id Model identifier + * @param out_model Output: Model info (owned, must be freed with rac_model_info_free) + * @return RAC_SUCCESS on success, RAC_ERROR_NOT_FOUND if not registered + */ +RAC_API rac_result_t rac_get_model(const char* model_id, struct rac_model_info** out_model); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_CORE_H */ diff --git a/sdk/runanywhere-commons/include/rac/core/rac_error.h b/sdk/runanywhere-commons/include/rac/core/rac_error.h new file mode 100644 index 000000000..0f708ff4c --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/core/rac_error.h @@ -0,0 +1,469 @@ +/** + * @file rac_error.h + * @brief RunAnywhere Commons - Error Codes and Error Handling + * + * C port of Swift's ErrorCode enum from Foundation/Errors/ErrorCode.swift. + * + * Error codes for runanywhere-commons use the range -100 to -999 to avoid + * collision with runanywhere-core error codes (0 to -99). + * + * IMPORTANT: This is a direct translation of the Swift implementation. + * Do NOT add error codes not present in the Swift code. + */ + +#ifndef RAC_ERROR_H +#define RAC_ERROR_H + +#include "rac/core/rac_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// ERROR CODE RANGES +// ============================================================================= +// +// runanywhere-core (ra_*): 0 to -99 +// runanywhere-commons (rac_*): -100 to -999 +// - Initialization errors: -100 to -109 +// - Model errors: -110 to -129 +// - Generation errors: -130 to -149 +// - Network errors: -150 to -179 +// - Storage errors: -180 to -219 +// - Hardware errors: -220 to -229 +// - Component state errors: -230 to -249 +// - Validation errors: -250 to -279 +// - Audio errors: -280 to -299 +// - Language/Voice errors: -300 to -319 +// - Authentication errors: -320 to -329 +// - Security errors: -330 to -349 +// - Extraction errors: -350 to -369 +// - Calibration errors: -370 to -379 +// - Module/Service errors: -400 to -499 +// - Platform adapter errors: -500 to -599 +// - Backend errors: -600 to -699 +// - Event errors: -700 to -799 +// - Other errors: -800 to -899 +// - Reserved: -900 to -999 + +// ============================================================================= +// INITIALIZATION ERRORS (-100 to -109) +// Mirrors Swift's ErrorCode: Initialization Errors +// ============================================================================= + +/** Component or service has not been initialized */ +#define RAC_ERROR_NOT_INITIALIZED ((rac_result_t) - 100) +/** Component or service is already initialized */ +#define RAC_ERROR_ALREADY_INITIALIZED ((rac_result_t) - 101) +/** Initialization failed */ +#define RAC_ERROR_INITIALIZATION_FAILED ((rac_result_t) - 102) +/** Configuration is invalid */ +#define RAC_ERROR_INVALID_CONFIGURATION ((rac_result_t) - 103) +/** API key is invalid or missing */ +#define RAC_ERROR_INVALID_API_KEY ((rac_result_t) - 104) +/** Environment mismatch (e.g., dev vs prod) */ +#define RAC_ERROR_ENVIRONMENT_MISMATCH ((rac_result_t) - 105) +/** Invalid parameter value passed to a function */ +#define RAC_ERROR_INVALID_PARAMETER ((rac_result_t) - 106) + +// ============================================================================= +// MODEL ERRORS (-110 to -129) +// Mirrors Swift's ErrorCode: Model Errors +// ============================================================================= + +/** Requested model was not found */ +#define RAC_ERROR_MODEL_NOT_FOUND ((rac_result_t) - 110) +/** Failed to load the model */ +#define RAC_ERROR_MODEL_LOAD_FAILED ((rac_result_t) - 111) +/** Model validation failed */ +#define RAC_ERROR_MODEL_VALIDATION_FAILED ((rac_result_t) - 112) +/** Model is incompatible with current runtime */ +#define RAC_ERROR_MODEL_INCOMPATIBLE ((rac_result_t) - 113) +/** Model format is invalid */ +#define RAC_ERROR_INVALID_MODEL_FORMAT ((rac_result_t) - 114) +/** Model storage is corrupted */ +#define RAC_ERROR_MODEL_STORAGE_CORRUPTED ((rac_result_t) - 115) +/** Model not loaded (alias for backward compatibility) */ +#define RAC_ERROR_MODEL_NOT_LOADED ((rac_result_t) - 116) + +// ============================================================================= +// GENERATION ERRORS (-130 to -149) +// Mirrors Swift's ErrorCode: Generation Errors +// ============================================================================= + +/** Text/audio generation failed */ +#define RAC_ERROR_GENERATION_FAILED ((rac_result_t) - 130) +/** Generation timed out */ +#define RAC_ERROR_GENERATION_TIMEOUT ((rac_result_t) - 131) +/** Context length exceeded maximum */ +#define RAC_ERROR_CONTEXT_TOO_LONG ((rac_result_t) - 132) +/** Token limit exceeded */ +#define RAC_ERROR_TOKEN_LIMIT_EXCEEDED ((rac_result_t) - 133) +/** Cost limit exceeded */ +#define RAC_ERROR_COST_LIMIT_EXCEEDED ((rac_result_t) - 134) +/** Inference failed */ +#define RAC_ERROR_INFERENCE_FAILED ((rac_result_t) - 135) + +// ============================================================================= +// NETWORK ERRORS (-150 to -179) +// Mirrors Swift's ErrorCode: Network Errors +// ============================================================================= + +/** Network is unavailable */ +#define RAC_ERROR_NETWORK_UNAVAILABLE ((rac_result_t) - 150) +/** Generic network error */ +#define RAC_ERROR_NETWORK_ERROR ((rac_result_t) - 151) +/** Request failed */ +#define RAC_ERROR_REQUEST_FAILED ((rac_result_t) - 152) +/** Download failed */ +#define RAC_ERROR_DOWNLOAD_FAILED ((rac_result_t) - 153) +/** Server returned an error */ +#define RAC_ERROR_SERVER_ERROR ((rac_result_t) - 154) +/** Request timed out */ +#define RAC_ERROR_TIMEOUT ((rac_result_t) - 155) +/** Invalid response from server */ +#define RAC_ERROR_INVALID_RESPONSE ((rac_result_t) - 156) +/** HTTP error with status code */ +#define RAC_ERROR_HTTP_ERROR ((rac_result_t) - 157) +/** Connection was lost */ +#define RAC_ERROR_CONNECTION_LOST ((rac_result_t) - 158) +/** Partial download (incomplete) */ +#define RAC_ERROR_PARTIAL_DOWNLOAD ((rac_result_t) - 159) +/** HTTP request failed */ +#define RAC_ERROR_HTTP_REQUEST_FAILED ((rac_result_t) - 160) +/** HTTP not supported */ +#define RAC_ERROR_HTTP_NOT_SUPPORTED ((rac_result_t) - 161) + +// ============================================================================= +// STORAGE ERRORS (-180 to -219) +// Mirrors Swift's ErrorCode: Storage Errors +// ============================================================================= + +/** Insufficient storage space */ +#define RAC_ERROR_INSUFFICIENT_STORAGE ((rac_result_t) - 180) +/** Storage is full */ +#define RAC_ERROR_STORAGE_FULL ((rac_result_t) - 181) +/** Generic storage error */ +#define RAC_ERROR_STORAGE_ERROR ((rac_result_t) - 182) +/** File was not found */ +#define RAC_ERROR_FILE_NOT_FOUND ((rac_result_t) - 183) +/** Failed to read file */ +#define RAC_ERROR_FILE_READ_FAILED ((rac_result_t) - 184) +/** Failed to write file */ +#define RAC_ERROR_FILE_WRITE_FAILED ((rac_result_t) - 185) +/** Permission denied for file operation */ +#define RAC_ERROR_PERMISSION_DENIED ((rac_result_t) - 186) +/** Failed to delete file or directory */ +#define RAC_ERROR_DELETE_FAILED ((rac_result_t) - 187) +/** Failed to move file */ +#define RAC_ERROR_MOVE_FAILED ((rac_result_t) - 188) +/** Failed to create directory */ +#define RAC_ERROR_DIRECTORY_CREATION_FAILED ((rac_result_t) - 189) +/** Directory not found */ +#define RAC_ERROR_DIRECTORY_NOT_FOUND ((rac_result_t) - 190) +/** Invalid file path */ +#define RAC_ERROR_INVALID_PATH ((rac_result_t) - 191) +/** Invalid file name */ +#define RAC_ERROR_INVALID_FILE_NAME ((rac_result_t) - 192) +/** Failed to create temporary file */ +#define RAC_ERROR_TEMP_FILE_CREATION_FAILED ((rac_result_t) - 193) +/** File delete failed (alias) */ +#define RAC_ERROR_FILE_DELETE_FAILED ((rac_result_t) - 187) + +// ============================================================================= +// HARDWARE ERRORS (-220 to -229) +// Mirrors Swift's ErrorCode: Hardware Errors +// ============================================================================= + +/** Hardware is unsupported */ +#define RAC_ERROR_HARDWARE_UNSUPPORTED ((rac_result_t) - 220) +/** Insufficient memory */ +#define RAC_ERROR_INSUFFICIENT_MEMORY ((rac_result_t) - 221) +/** Out of memory (alias) */ +#define RAC_ERROR_OUT_OF_MEMORY ((rac_result_t) - 221) + +// ============================================================================= +// COMPONENT STATE ERRORS (-230 to -249) +// Mirrors Swift's ErrorCode: Component State Errors +// ============================================================================= + +/** Component is not ready */ +#define RAC_ERROR_COMPONENT_NOT_READY ((rac_result_t) - 230) +/** Component is in invalid state */ +#define RAC_ERROR_INVALID_STATE ((rac_result_t) - 231) +/** Service is not available */ +#define RAC_ERROR_SERVICE_NOT_AVAILABLE ((rac_result_t) - 232) +/** Service is busy */ +#define RAC_ERROR_SERVICE_BUSY ((rac_result_t) - 233) +/** Processing failed */ +#define RAC_ERROR_PROCESSING_FAILED ((rac_result_t) - 234) +/** Start operation failed */ +#define RAC_ERROR_START_FAILED ((rac_result_t) - 235) +/** Feature/operation is not supported */ +#define RAC_ERROR_NOT_SUPPORTED ((rac_result_t) - 236) + +// ============================================================================= +// VALIDATION ERRORS (-250 to -279) +// Mirrors Swift's ErrorCode: Validation Errors +// ============================================================================= + +/** Validation failed */ +#define RAC_ERROR_VALIDATION_FAILED ((rac_result_t) - 250) +/** Input is invalid */ +#define RAC_ERROR_INVALID_INPUT ((rac_result_t) - 251) +/** Format is invalid */ +#define RAC_ERROR_INVALID_FORMAT ((rac_result_t) - 252) +/** Input is empty */ +#define RAC_ERROR_EMPTY_INPUT ((rac_result_t) - 253) +/** Text is too long */ +#define RAC_ERROR_TEXT_TOO_LONG ((rac_result_t) - 254) +/** Invalid SSML markup */ +#define RAC_ERROR_INVALID_SSML ((rac_result_t) - 255) +/** Invalid speaking rate */ +#define RAC_ERROR_INVALID_SPEAKING_RATE ((rac_result_t) - 256) +/** Invalid pitch */ +#define RAC_ERROR_INVALID_PITCH ((rac_result_t) - 257) +/** Invalid volume */ +#define RAC_ERROR_INVALID_VOLUME ((rac_result_t) - 258) +/** Invalid argument */ +#define RAC_ERROR_INVALID_ARGUMENT ((rac_result_t) - 259) +/** Null pointer */ +#define RAC_ERROR_NULL_POINTER ((rac_result_t) - 260) +/** Buffer too small */ +#define RAC_ERROR_BUFFER_TOO_SMALL ((rac_result_t) - 261) + +// ============================================================================= +// AUDIO ERRORS (-280 to -299) +// Mirrors Swift's ErrorCode: Audio Errors +// ============================================================================= + +/** Audio format is not supported */ +#define RAC_ERROR_AUDIO_FORMAT_NOT_SUPPORTED ((rac_result_t) - 280) +/** Audio session configuration failed */ +#define RAC_ERROR_AUDIO_SESSION_FAILED ((rac_result_t) - 281) +/** Microphone permission denied */ +#define RAC_ERROR_MICROPHONE_PERMISSION_DENIED ((rac_result_t) - 282) +/** Insufficient audio data */ +#define RAC_ERROR_INSUFFICIENT_AUDIO_DATA ((rac_result_t) - 283) +/** Audio buffer is empty */ +#define RAC_ERROR_EMPTY_AUDIO_BUFFER ((rac_result_t) - 284) +/** Audio session activation failed */ +#define RAC_ERROR_AUDIO_SESSION_ACTIVATION_FAILED ((rac_result_t) - 285) + +// ============================================================================= +// LANGUAGE/VOICE ERRORS (-300 to -319) +// Mirrors Swift's ErrorCode: Language/Voice Errors +// ============================================================================= + +/** Language is not supported */ +#define RAC_ERROR_LANGUAGE_NOT_SUPPORTED ((rac_result_t) - 300) +/** Voice is not available */ +#define RAC_ERROR_VOICE_NOT_AVAILABLE ((rac_result_t) - 301) +/** Streaming is not supported */ +#define RAC_ERROR_STREAMING_NOT_SUPPORTED ((rac_result_t) - 302) +/** Stream was cancelled */ +#define RAC_ERROR_STREAM_CANCELLED ((rac_result_t) - 303) + +// ============================================================================= +// AUTHENTICATION ERRORS (-320 to -329) +// Mirrors Swift's ErrorCode: Authentication Errors +// ============================================================================= + +/** Authentication failed */ +#define RAC_ERROR_AUTHENTICATION_FAILED ((rac_result_t) - 320) +/** Unauthorized access */ +#define RAC_ERROR_UNAUTHORIZED ((rac_result_t) - 321) +/** Access forbidden */ +#define RAC_ERROR_FORBIDDEN ((rac_result_t) - 322) + +// ============================================================================= +// SECURITY ERRORS (-330 to -349) +// Mirrors Swift's ErrorCode: Security Errors +// ============================================================================= + +/** Keychain operation failed */ +#define RAC_ERROR_KEYCHAIN_ERROR ((rac_result_t) - 330) +/** Encoding error */ +#define RAC_ERROR_ENCODING_ERROR ((rac_result_t) - 331) +/** Decoding error */ +#define RAC_ERROR_DECODING_ERROR ((rac_result_t) - 332) +/** Secure storage failed */ +#define RAC_ERROR_SECURE_STORAGE_FAILED ((rac_result_t) - 333) + +// ============================================================================= +// EXTRACTION ERRORS (-350 to -369) +// Mirrors Swift's ErrorCode: Extraction Errors +// ============================================================================= + +/** Extraction failed (JSON, archive, etc.) */ +#define RAC_ERROR_EXTRACTION_FAILED ((rac_result_t) - 350) +/** Checksum mismatch */ +#define RAC_ERROR_CHECKSUM_MISMATCH ((rac_result_t) - 351) +/** Unsupported archive format */ +#define RAC_ERROR_UNSUPPORTED_ARCHIVE ((rac_result_t) - 352) + +// ============================================================================= +// CALIBRATION ERRORS (-370 to -379) +// Mirrors Swift's ErrorCode: Calibration Errors +// ============================================================================= + +/** Calibration failed */ +#define RAC_ERROR_CALIBRATION_FAILED ((rac_result_t) - 370) +/** Calibration timed out */ +#define RAC_ERROR_CALIBRATION_TIMEOUT ((rac_result_t) - 371) + +// ============================================================================= +// CANCELLATION (-380 to -389) +// Mirrors Swift's ErrorCode: Cancellation +// ============================================================================= + +/** Operation was cancelled */ +#define RAC_ERROR_CANCELLED ((rac_result_t) - 380) + +// ============================================================================= +// MODULE/SERVICE ERRORS (-400 to -499) +// ============================================================================= + +/** Module not found */ +#define RAC_ERROR_MODULE_NOT_FOUND ((rac_result_t) - 400) +/** Module already registered */ +#define RAC_ERROR_MODULE_ALREADY_REGISTERED ((rac_result_t) - 401) +/** Module load failed */ +#define RAC_ERROR_MODULE_LOAD_FAILED ((rac_result_t) - 402) +/** Service not found */ +#define RAC_ERROR_SERVICE_NOT_FOUND ((rac_result_t) - 410) +/** Service already registered */ +#define RAC_ERROR_SERVICE_ALREADY_REGISTERED ((rac_result_t) - 411) +/** Service create failed */ +#define RAC_ERROR_SERVICE_CREATE_FAILED ((rac_result_t) - 412) +/** Capability not found */ +#define RAC_ERROR_CAPABILITY_NOT_FOUND ((rac_result_t) - 420) +/** Provider not found */ +#define RAC_ERROR_PROVIDER_NOT_FOUND ((rac_result_t) - 421) +/** No capable provider */ +#define RAC_ERROR_NO_CAPABLE_PROVIDER ((rac_result_t) - 422) +/** Generic not found */ +#define RAC_ERROR_NOT_FOUND ((rac_result_t) - 423) + +// ============================================================================= +// PLATFORM ADAPTER ERRORS (-500 to -599) +// ============================================================================= + +/** Adapter not set */ +#define RAC_ERROR_ADAPTER_NOT_SET ((rac_result_t) - 500) + +// ============================================================================= +// BACKEND ERRORS (-600 to -699) +// ============================================================================= + +/** Backend not found */ +#define RAC_ERROR_BACKEND_NOT_FOUND ((rac_result_t) - 600) +/** Backend not ready */ +#define RAC_ERROR_BACKEND_NOT_READY ((rac_result_t) - 601) +/** Backend init failed */ +#define RAC_ERROR_BACKEND_INIT_FAILED ((rac_result_t) - 602) +/** Backend busy */ +#define RAC_ERROR_BACKEND_BUSY ((rac_result_t) - 603) +/** Invalid handle */ +#define RAC_ERROR_INVALID_HANDLE ((rac_result_t) - 610) + +// ============================================================================= +// EVENT ERRORS (-700 to -799) +// ============================================================================= + +/** Invalid event category */ +#define RAC_ERROR_EVENT_INVALID_CATEGORY ((rac_result_t) - 700) +/** Event subscription failed */ +#define RAC_ERROR_EVENT_SUBSCRIPTION_FAILED ((rac_result_t) - 701) +/** Event publish failed */ +#define RAC_ERROR_EVENT_PUBLISH_FAILED ((rac_result_t) - 702) + +// ============================================================================= +// OTHER ERRORS (-800 to -899) +// Mirrors Swift's ErrorCode: Other Errors +// ============================================================================= + +/** Feature is not implemented */ +#define RAC_ERROR_NOT_IMPLEMENTED ((rac_result_t) - 800) +/** Feature is not available */ +#define RAC_ERROR_FEATURE_NOT_AVAILABLE ((rac_result_t) - 801) +/** Framework is not available */ +#define RAC_ERROR_FRAMEWORK_NOT_AVAILABLE ((rac_result_t) - 802) +/** Unsupported modality */ +#define RAC_ERROR_UNSUPPORTED_MODALITY ((rac_result_t) - 803) +/** Unknown error */ +#define RAC_ERROR_UNKNOWN ((rac_result_t) - 804) +/** Internal error */ +#define RAC_ERROR_INTERNAL ((rac_result_t) - 805) + +// ============================================================================= +// ERROR MESSAGE API +// ============================================================================= + +/** + * Gets a human-readable error message for an error code. + * + * @param error_code The error code to get a message for + * @return A static string describing the error (never NULL) + */ +RAC_API const char* rac_error_message(rac_result_t error_code); + +/** + * Gets the last detailed error message. + * + * This returns additional context beyond the error code, such as file paths + * or specific failure reasons. Returns NULL if no detailed message is set. + * + * @return The last error detail string, or NULL + * + * @note The returned string is thread-local and valid until the next + * RAC function call on the same thread. + */ +RAC_API const char* rac_error_get_details(void); + +/** + * Sets the detailed error message for the current thread. + * + * This is typically called internally by RAC functions to provide + * additional context for errors. + * + * @param details The detail string (will be copied internally) + */ +RAC_API void rac_error_set_details(const char* details); + +/** + * Clears the detailed error message for the current thread. + */ +RAC_API void rac_error_clear_details(void); + +/** + * Checks if an error code is in the commons range (-100 to -999). + * + * @param error_code The error code to check + * @return RAC_TRUE if the error is from commons, RAC_FALSE otherwise + */ +RAC_API rac_bool_t rac_error_is_commons_error(rac_result_t error_code); + +/** + * Checks if an error code is in the core range (0 to -99). + * + * @param error_code The error code to check + * @return RAC_TRUE if the error is from core, RAC_FALSE otherwise + */ +RAC_API rac_bool_t rac_error_is_core_error(rac_result_t error_code); + +/** + * Checks if an error is expected/routine (like cancellation). + * Mirrors Swift's ErrorCode.isExpected property. + * + * @param error_code The error code to check + * @return RAC_TRUE if expected, RAC_FALSE otherwise + */ +RAC_API rac_bool_t rac_error_is_expected(rac_result_t error_code); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_ERROR_H */ diff --git a/sdk/runanywhere-commons/include/rac/core/rac_events.h b/sdk/runanywhere-commons/include/rac/core/rac_events.h new file mode 100644 index 000000000..395cdcde9 --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/core/rac_events.h @@ -0,0 +1,334 @@ +/** + * @file rac_events.h + * @brief RunAnywhere Commons - Cross-Platform Event System + * + * C++ is the canonical source of truth for all analytics events. + * Platform SDKs (Swift, Kotlin, Flutter) register callbacks to receive + * these events and forward them to their native event systems. + * + * Usage: + * 1. Platform SDK registers callback via rac_events_set_callback() + * 2. C++ components emit events via rac_event_emit() + * 3. Platform SDK receives events in callback and converts to native events + */ + +#ifndef RAC_EVENTS_H +#define RAC_EVENTS_H + +#include "rac/core/rac_types.h" +#include "rac/infrastructure/model_management/rac_model_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// EVENT TYPES +// ============================================================================= + +/** + * @brief Event type enumeration + */ +typedef enum rac_event_type { + // LLM Events + RAC_EVENT_LLM_MODEL_LOAD_STARTED = 100, + RAC_EVENT_LLM_MODEL_LOAD_COMPLETED = 101, + RAC_EVENT_LLM_MODEL_LOAD_FAILED = 102, + RAC_EVENT_LLM_MODEL_UNLOADED = 103, + RAC_EVENT_LLM_GENERATION_STARTED = 110, + RAC_EVENT_LLM_GENERATION_COMPLETED = 111, + RAC_EVENT_LLM_GENERATION_FAILED = 112, + RAC_EVENT_LLM_FIRST_TOKEN = 113, + RAC_EVENT_LLM_STREAMING_UPDATE = 114, + + // STT Events + RAC_EVENT_STT_MODEL_LOAD_STARTED = 200, + RAC_EVENT_STT_MODEL_LOAD_COMPLETED = 201, + RAC_EVENT_STT_MODEL_LOAD_FAILED = 202, + RAC_EVENT_STT_MODEL_UNLOADED = 203, + RAC_EVENT_STT_TRANSCRIPTION_STARTED = 210, + RAC_EVENT_STT_TRANSCRIPTION_COMPLETED = 211, + RAC_EVENT_STT_TRANSCRIPTION_FAILED = 212, + RAC_EVENT_STT_PARTIAL_TRANSCRIPT = 213, + + // TTS Events + RAC_EVENT_TTS_VOICE_LOAD_STARTED = 300, + RAC_EVENT_TTS_VOICE_LOAD_COMPLETED = 301, + RAC_EVENT_TTS_VOICE_LOAD_FAILED = 302, + RAC_EVENT_TTS_VOICE_UNLOADED = 303, + RAC_EVENT_TTS_SYNTHESIS_STARTED = 310, + RAC_EVENT_TTS_SYNTHESIS_COMPLETED = 311, + RAC_EVENT_TTS_SYNTHESIS_FAILED = 312, + RAC_EVENT_TTS_SYNTHESIS_CHUNK = 313, + + // VAD Events + RAC_EVENT_VAD_STARTED = 400, + RAC_EVENT_VAD_STOPPED = 401, + RAC_EVENT_VAD_SPEECH_STARTED = 402, + RAC_EVENT_VAD_SPEECH_ENDED = 403, + RAC_EVENT_VAD_PAUSED = 404, + RAC_EVENT_VAD_RESUMED = 405, + + // VoiceAgent Events + RAC_EVENT_VOICE_AGENT_TURN_STARTED = 500, + RAC_EVENT_VOICE_AGENT_TURN_COMPLETED = 501, + RAC_EVENT_VOICE_AGENT_TURN_FAILED = 502, +} rac_event_type_t; + +// ============================================================================= +// EVENT DATA STRUCTURES +// ============================================================================= + +/** + * @brief LLM generation event data + * Used for: GENERATION_STARTED, GENERATION_COMPLETED, GENERATION_FAILED + */ +typedef struct rac_llm_generation_event { + /** Unique generation identifier */ + const char* generation_id; + /** Model ID used for generation */ + const char* model_id; + /** Number of input/prompt tokens */ + int32_t input_tokens; + /** Number of output/completion tokens */ + int32_t output_tokens; + /** Total duration in milliseconds */ + double duration_ms; + /** Tokens generated per second */ + double tokens_per_second; + /** Whether this was a streaming generation */ + rac_bool_t is_streaming; + /** Time to first token in ms (0 if not streaming or not yet received) */ + double time_to_first_token_ms; + /** Inference framework used */ + rac_inference_framework_t framework; + /** Generation temperature (0 if not set) */ + float temperature; + /** Max tokens setting (0 if not set) */ + int32_t max_tokens; + /** Context length (0 if not set) */ + int32_t context_length; + /** Error code (RAC_SUCCESS if no error) */ + rac_result_t error_code; + /** Error message (NULL if no error) */ + const char* error_message; +} rac_llm_generation_event_t; + +/** + * @brief LLM model load event data + * Used for: MODEL_LOAD_STARTED, MODEL_LOAD_COMPLETED, MODEL_LOAD_FAILED + */ +typedef struct rac_llm_model_event { + /** Model ID */ + const char* model_id; + /** Model size in bytes (0 if unknown) */ + int64_t model_size_bytes; + /** Load duration in milliseconds (for completed event) */ + double duration_ms; + /** Inference framework */ + rac_inference_framework_t framework; + /** Error code (RAC_SUCCESS if no error) */ + rac_result_t error_code; + /** Error message (NULL if no error) */ + const char* error_message; +} rac_llm_model_event_t; + +/** + * @brief STT transcription event data + * Used for: TRANSCRIPTION_STARTED, TRANSCRIPTION_COMPLETED, TRANSCRIPTION_FAILED + */ +typedef struct rac_stt_transcription_event { + /** Unique transcription identifier */ + const char* transcription_id; + /** Model ID used */ + const char* model_id; + /** Transcribed text (for completed event) */ + const char* text; + /** Confidence score (0.0 - 1.0) */ + float confidence; + /** Processing duration in milliseconds */ + double duration_ms; + /** Audio length in milliseconds */ + double audio_length_ms; + /** Audio size in bytes */ + int32_t audio_size_bytes; + /** Word count in result */ + int32_t word_count; + /** Real-time factor (audio_length / processing_time) */ + double real_time_factor; + /** Language code */ + const char* language; + /** Sample rate */ + int32_t sample_rate; + /** Whether streaming transcription */ + rac_bool_t is_streaming; + /** Inference framework */ + rac_inference_framework_t framework; + /** Error code (RAC_SUCCESS if no error) */ + rac_result_t error_code; + /** Error message (NULL if no error) */ + const char* error_message; +} rac_stt_transcription_event_t; + +/** + * @brief TTS synthesis event data + * Used for: SYNTHESIS_STARTED, SYNTHESIS_COMPLETED, SYNTHESIS_FAILED + */ +typedef struct rac_tts_synthesis_event { + /** Unique synthesis identifier */ + const char* synthesis_id; + /** Voice/Model ID used */ + const char* model_id; + /** Character count of input text */ + int32_t character_count; + /** Audio duration in milliseconds */ + double audio_duration_ms; + /** Audio size in bytes */ + int32_t audio_size_bytes; + /** Processing duration in milliseconds */ + double processing_duration_ms; + /** Characters processed per second */ + double characters_per_second; + /** Sample rate */ + int32_t sample_rate; + /** Inference framework */ + rac_inference_framework_t framework; + /** Error code (RAC_SUCCESS if no error) */ + rac_result_t error_code; + /** Error message (NULL if no error) */ + const char* error_message; +} rac_tts_synthesis_event_t; + +/** + * @brief VAD event data + * Used for: VAD_STARTED, VAD_STOPPED, VAD_SPEECH_STARTED, VAD_SPEECH_ENDED + */ +typedef struct rac_vad_event { + /** Speech duration in milliseconds (for SPEECH_ENDED) */ + double speech_duration_ms; + /** Energy level (for speech events) */ + float energy_level; +} rac_vad_event_t; + +/** + * @brief Union of all event data types + */ +typedef struct rac_event_data { + rac_event_type_t type; + union { + rac_llm_generation_event_t llm_generation; + rac_llm_model_event_t llm_model; + rac_stt_transcription_event_t stt_transcription; + rac_tts_synthesis_event_t tts_synthesis; + rac_vad_event_t vad; + } data; +} rac_event_data_t; + +// ============================================================================= +// EVENT CALLBACK API +// ============================================================================= + +/** + * @brief Event callback function type + * + * Platform SDKs implement this callback to receive events from C++. + * + * @param type Event type + * @param data Event data (lifetime: only valid during callback) + * @param user_data User data provided during registration + */ +typedef void (*rac_event_callback_fn)(rac_event_type_t type, const rac_event_data_t* data, + void* user_data); + +/** + * @brief Register event callback + * + * Called by platform SDKs at initialization to receive events. + * Only one callback can be registered at a time. + * + * @param callback Callback function (NULL to unregister) + * @param user_data User data passed to callback + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_events_set_callback(rac_event_callback_fn callback, void* user_data); + +/** + * @brief Emit an event + * + * Called internally by C++ components to emit events. + * If no callback is registered, event is silently discarded. + * + * @param type Event type + * @param data Event data + */ +RAC_API void rac_event_emit(rac_event_type_t type, const rac_event_data_t* data); + +/** + * @brief Check if event callback is registered + * + * @return RAC_TRUE if callback is registered, RAC_FALSE otherwise + */ +RAC_API rac_bool_t rac_events_has_callback(void); + +// ============================================================================= +// DEFAULT EVENT DATA +// ============================================================================= + +/** Default LLM generation event */ +static const rac_llm_generation_event_t RAC_LLM_GENERATION_EVENT_DEFAULT = { + .generation_id = RAC_NULL, + .model_id = RAC_NULL, + .input_tokens = 0, + .output_tokens = 0, + .duration_ms = 0.0, + .tokens_per_second = 0.0, + .is_streaming = RAC_FALSE, + .time_to_first_token_ms = 0.0, + .framework = RAC_FRAMEWORK_UNKNOWN, + .temperature = 0.0f, + .max_tokens = 0, + .context_length = 0, + .error_code = RAC_SUCCESS, + .error_message = RAC_NULL}; + +/** Default STT transcription event */ +static const rac_stt_transcription_event_t RAC_STT_TRANSCRIPTION_EVENT_DEFAULT = { + .transcription_id = RAC_NULL, + .model_id = RAC_NULL, + .text = RAC_NULL, + .confidence = 0.0f, + .duration_ms = 0.0, + .audio_length_ms = 0.0, + .audio_size_bytes = 0, + .word_count = 0, + .real_time_factor = 0.0, + .language = RAC_NULL, + .sample_rate = 0, + .is_streaming = RAC_FALSE, + .framework = RAC_FRAMEWORK_UNKNOWN, + .error_code = RAC_SUCCESS, + .error_message = RAC_NULL}; + +/** Default TTS synthesis event */ +static const rac_tts_synthesis_event_t RAC_TTS_SYNTHESIS_EVENT_DEFAULT = { + .synthesis_id = RAC_NULL, + .model_id = RAC_NULL, + .character_count = 0, + .audio_duration_ms = 0.0, + .audio_size_bytes = 0, + .processing_duration_ms = 0.0, + .characters_per_second = 0.0, + .sample_rate = 0, + .framework = RAC_FRAMEWORK_UNKNOWN, + .error_code = RAC_SUCCESS, + .error_message = RAC_NULL}; + +/** Default VAD event */ +static const rac_vad_event_t RAC_VAD_EVENT_DEFAULT = {.speech_duration_ms = 0.0, + .energy_level = 0.0f}; + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_EVENTS_H */ diff --git a/sdk/runanywhere-commons/include/rac/core/rac_logger.h b/sdk/runanywhere-commons/include/rac/core/rac_logger.h new file mode 100644 index 000000000..6315b43e2 --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/core/rac_logger.h @@ -0,0 +1,416 @@ +/** + * @file rac_logger.h + * @brief RunAnywhere Commons - Structured Logging System + * + * Provides a structured logging system that: + * - Routes logs through the platform adapter to Swift/Kotlin + * - Captures source location metadata (file, line, function) + * - Supports log levels, categories, and structured metadata + * - Enables remote telemetry for production error tracking + * + * Usage: + * RAC_LOG_INFO("LLM", "Model loaded successfully"); + * RAC_LOG_ERROR("STT", "Failed to load model: %s", error_msg); + * RAC_LOG_DEBUG("VAD", "Energy level: %.2f", energy); + * + * With metadata: + * rac_log_with_metadata(RAC_LOG_ERROR, "ONNX", "Load failed", + * (rac_log_metadata_t){ + * .model_id = "whisper-tiny", + * .error_code = -100, + * .file = __FILE__, + * .line = __LINE__, + * .function = __func__ + * }); + */ + +#ifndef RAC_LOGGER_H +#define RAC_LOGGER_H + +#include +#include +#include +#include + +#include "rac/core/rac_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// LOG METADATA STRUCTURE +// ============================================================================= + +/** + * @brief Metadata attached to a log entry. + * + * All fields are optional - set to NULL/0 if not applicable. + * This metadata flows through to Swift/Kotlin for remote telemetry. + */ +typedef struct rac_log_metadata { + // Source location (auto-populated by macros) + const char* file; /**< Source file name (use __FILE__) */ + int32_t line; /**< Source line number (use __LINE__) */ + const char* function; /**< Function name (use __func__) */ + + // Error context + int32_t error_code; /**< Error code if applicable (0 = none) */ + const char* error_msg; /**< Additional error message */ + + // Model context + const char* model_id; /**< Model ID if applicable */ + const char* framework; /**< Framework name (e.g., "sherpa-onnx") */ + + // Custom key-value pairs (for extensibility) + const char* custom_key1; + const char* custom_value1; + const char* custom_key2; + const char* custom_value2; +} rac_log_metadata_t; + +/** Default empty metadata */ +#define RAC_LOG_METADATA_EMPTY \ + (rac_log_metadata_t) { \ + NULL, 0, NULL, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL \ + } + +// ============================================================================= +// CORE LOGGING API +// ============================================================================= + +/** + * @brief Initialize the logging system. + * + * Call this after rac_set_platform_adapter() to enable logging. + * If not called, logs will fall back to stderr. + * + * @param min_level Minimum log level to output + * @return RAC_SUCCESS on success + */ +RAC_API rac_result_t rac_logger_init(rac_log_level_t min_level); + +/** + * @brief Shutdown the logging system. + * + * Flushes any pending logs. + */ +RAC_API void rac_logger_shutdown(void); + +/** + * @brief Set the minimum log level. + * + * Messages below this level will be filtered out. + * + * @param level Minimum log level + */ +RAC_API void rac_logger_set_min_level(rac_log_level_t level); + +/** + * @brief Get the current minimum log level. + * + * @return Current minimum log level + */ +RAC_API rac_log_level_t rac_logger_get_min_level(void); + +/** + * @brief Enable or disable fallback to stderr when platform adapter unavailable. + * + * @param enabled Whether to fallback to stderr (default: true) + */ +RAC_API void rac_logger_set_stderr_fallback(rac_bool_t enabled); + +/** + * @brief Enable or disable ALWAYS logging to stderr (in addition to platform adapter). + * + * When enabled (default: true), logs are ALWAYS written to stderr first, + * then forwarded to the platform adapter if available. This is essential + * for debugging crashes during static initialization before Swift/Kotlin + * is ready to receive logs. + * + * Set to false in production to reduce duplicate logging overhead. + * + * @param enabled Whether to always log to stderr (default: true) + */ +RAC_API void rac_logger_set_stderr_always(rac_bool_t enabled); + +/** + * @brief Log a message with metadata. + * + * This is the main logging function. Use the RAC_LOG_* macros for convenience. + * + * @param level Log level + * @param category Log category (e.g., "LLM", "STT.ONNX") + * @param message Log message (can include printf-style format specifiers) + * @param metadata Optional metadata (can be NULL) + */ +RAC_API void rac_logger_log(rac_log_level_t level, const char* category, const char* message, + const rac_log_metadata_t* metadata); + +/** + * @brief Log a formatted message with metadata. + * + * @param level Log level + * @param category Log category + * @param metadata Optional metadata (can be NULL) + * @param format Printf-style format string + * @param ... Format arguments + */ +RAC_API void rac_logger_logf(rac_log_level_t level, const char* category, + const rac_log_metadata_t* metadata, const char* format, ...); + +/** + * @brief Log a formatted message (variadic version). + * + * @param level Log level + * @param category Log category + * @param metadata Optional metadata + * @param format Printf-style format string + * @param args Variadic arguments + */ +RAC_API void rac_logger_logv(rac_log_level_t level, const char* category, + const rac_log_metadata_t* metadata, const char* format, va_list args); + +// ============================================================================= +// CONVENIENCE MACROS +// ============================================================================= + +/** + * Helper to create metadata with source location. + */ +#define RAC_LOG_META_HERE() \ + (rac_log_metadata_t) { \ + __FILE__, __LINE__, __func__, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL \ + } + +/** + * Helper to create metadata with source location and error code. + */ +#define RAC_LOG_META_ERROR(code, msg) \ + (rac_log_metadata_t) { \ + __FILE__, __LINE__, __func__, (code), (msg), NULL, NULL, NULL, NULL, NULL, NULL \ + } + +/** + * Helper to create metadata with model context. + */ +#define RAC_LOG_META_MODEL(mid, fw) \ + (rac_log_metadata_t) { \ + __FILE__, __LINE__, __func__, 0, NULL, (mid), (fw), NULL, NULL, NULL, NULL \ + } + +// --- Level-specific logging macros with automatic source location --- + +#define RAC_LOG_TRACE(category, ...) \ + do { \ + rac_log_metadata_t _meta = RAC_LOG_META_HERE(); \ + rac_logger_logf(RAC_LOG_TRACE, category, &_meta, __VA_ARGS__); \ + } while (0) + +#define RAC_LOG_DEBUG(category, ...) \ + do { \ + rac_log_metadata_t _meta = RAC_LOG_META_HERE(); \ + rac_logger_logf(RAC_LOG_DEBUG, category, &_meta, __VA_ARGS__); \ + } while (0) + +#define RAC_LOG_INFO(category, ...) \ + do { \ + rac_log_metadata_t _meta = RAC_LOG_META_HERE(); \ + rac_logger_logf(RAC_LOG_INFO, category, &_meta, __VA_ARGS__); \ + } while (0) + +#define RAC_LOG_WARNING(category, ...) \ + do { \ + rac_log_metadata_t _meta = RAC_LOG_META_HERE(); \ + rac_logger_logf(RAC_LOG_WARNING, category, &_meta, __VA_ARGS__); \ + } while (0) + +#define RAC_LOG_ERROR(category, ...) \ + do { \ + rac_log_metadata_t _meta = RAC_LOG_META_HERE(); \ + rac_logger_logf(RAC_LOG_ERROR, category, &_meta, __VA_ARGS__); \ + } while (0) + +#define RAC_LOG_FATAL(category, ...) \ + do { \ + rac_log_metadata_t _meta = RAC_LOG_META_HERE(); \ + rac_logger_logf(RAC_LOG_FATAL, category, &_meta, __VA_ARGS__); \ + } while (0) + +// --- Error logging with code --- + +#define RAC_LOG_ERROR_CODE(category, code, ...) \ + do { \ + rac_log_metadata_t _meta = RAC_LOG_META_ERROR(code, NULL); \ + rac_logger_logf(RAC_LOG_ERROR, category, &_meta, __VA_ARGS__); \ + } while (0) + +// --- Model context logging --- + +#define RAC_LOG_MODEL_INFO(category, model_id, framework, ...) \ + do { \ + rac_log_metadata_t _meta = RAC_LOG_META_MODEL(model_id, framework); \ + rac_logger_logf(RAC_LOG_INFO, category, &_meta, __VA_ARGS__); \ + } while (0) + +#define RAC_LOG_MODEL_ERROR(category, model_id, framework, ...) \ + do { \ + rac_log_metadata_t _meta = RAC_LOG_META_MODEL(model_id, framework); \ + rac_logger_logf(RAC_LOG_ERROR, category, &_meta, __VA_ARGS__); \ + } while (0) + +// ============================================================================= +// LEGACY COMPATIBILITY (maps to new logging system) +// ============================================================================= + +/** + * Legacy log_info macro - maps to RAC_LOG_INFO. + * @deprecated Use RAC_LOG_INFO instead. + */ +#define log_info(category, ...) RAC_LOG_INFO(category, __VA_ARGS__) + +/** + * Legacy log_debug macro - maps to RAC_LOG_DEBUG. + * @deprecated Use RAC_LOG_DEBUG instead. + */ +#define log_debug(category, ...) RAC_LOG_DEBUG(category, __VA_ARGS__) + +/** + * Legacy log_warning macro - maps to RAC_LOG_WARNING. + * @deprecated Use RAC_LOG_WARNING instead. + */ +#define log_warning(category, ...) RAC_LOG_WARNING(category, __VA_ARGS__) + +/** + * Legacy log_error macro - maps to RAC_LOG_ERROR. + * @deprecated Use RAC_LOG_ERROR instead. + */ +#define log_error(category, ...) RAC_LOG_ERROR(category, __VA_ARGS__) + +#ifdef __cplusplus +} +#endif + +// ============================================================================= +// C++ CONVENIENCE CLASS +// ============================================================================= + +#ifdef __cplusplus + +#include +#include + +namespace rac { + +/** + * @brief C++ Logger class for convenient logging with RAII. + * + * Usage: + * rac::Logger log("STT.ONNX"); + * log.info("Model loaded: %s", model_id); + * log.error("Failed with code %d", error_code); + */ +class Logger { + public: + explicit Logger(const char* category) : category_(category) {} + explicit Logger(const std::string& category) : category_(category.c_str()) {} + + void trace(const char* format, ...) const { + va_list args; + va_start(args, format); + rac_logger_logv(RAC_LOG_TRACE, category_, nullptr, format, args); + va_end(args); + } + + void debug(const char* format, ...) const { + va_list args; + va_start(args, format); + rac_logger_logv(RAC_LOG_DEBUG, category_, nullptr, format, args); + va_end(args); + } + + void info(const char* format, ...) const { + va_list args; + va_start(args, format); + rac_logger_logv(RAC_LOG_INFO, category_, nullptr, format, args); + va_end(args); + } + + void warning(const char* format, ...) const { + va_list args; + va_start(args, format); + rac_logger_logv(RAC_LOG_WARNING, category_, nullptr, format, args); + va_end(args); + } + + void error(const char* format, ...) const { + va_list args; + va_start(args, format); + rac_logger_logv(RAC_LOG_ERROR, category_, nullptr, format, args); + va_end(args); + } + + void error(int32_t code, const char* format, ...) const { + rac_log_metadata_t meta = RAC_LOG_METADATA_EMPTY; + meta.error_code = code; + + va_list args; + va_start(args, format); + rac_logger_logv(RAC_LOG_ERROR, category_, &meta, format, args); + va_end(args); + } + + void fatal(const char* format, ...) const { + va_list args; + va_start(args, format); + rac_logger_logv(RAC_LOG_FATAL, category_, nullptr, format, args); + va_end(args); + } + + // Log with model context + void modelInfo(const char* model_id, const char* framework, const char* format, ...) const { + rac_log_metadata_t meta = RAC_LOG_METADATA_EMPTY; + meta.model_id = model_id; + meta.framework = framework; + + va_list args; + va_start(args, format); + rac_logger_logv(RAC_LOG_INFO, category_, &meta, format, args); + va_end(args); + } + + void modelError(const char* model_id, const char* framework, int32_t code, const char* format, + ...) const { + rac_log_metadata_t meta = RAC_LOG_METADATA_EMPTY; + meta.model_id = model_id; + meta.framework = framework; + meta.error_code = code; + + va_list args; + va_start(args, format); + rac_logger_logv(RAC_LOG_ERROR, category_, &meta, format, args); + va_end(args); + } + + private: + const char* category_; +}; + +// Predefined loggers for common categories +namespace log { +inline Logger llm("LLM"); +inline Logger stt("STT"); +inline Logger tts("TTS"); +inline Logger vad("VAD"); +inline Logger onnx("ONNX"); +inline Logger llamacpp("LlamaCpp"); +inline Logger download("Download"); +inline Logger models("Models"); +inline Logger core("Core"); +} // namespace log + +} // namespace rac + +#endif // __cplusplus + +#endif /* RAC_LOGGER_H */ diff --git a/sdk/runanywhere-commons/include/rac/core/rac_platform_adapter.h b/sdk/runanywhere-commons/include/rac/core/rac_platform_adapter.h new file mode 100644 index 000000000..a85296ff4 --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/core/rac_platform_adapter.h @@ -0,0 +1,340 @@ +/** + * @file rac_platform_adapter.h + * @brief RunAnywhere Commons - Platform Adapter Interface + * + * Platform adapter provides callbacks for platform-specific operations. + * Swift/Kotlin SDK implements these callbacks and passes them during init. + * + * NOTE: HTTP networking is delegated to the platform layer (Swift/Kotlin). + * The C++ layer only handles orchestration logic. + */ + +#ifndef RAC_PLATFORM_ADAPTER_H +#define RAC_PLATFORM_ADAPTER_H + +#include "rac/core/rac_error.h" +#include "rac/core/rac_types.h" +#include "rac/infrastructure/model_management/rac_model_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// CALLBACK TYPES (defined outside struct for C compatibility) +// ============================================================================= + +/** + * HTTP download progress callback type. + * @param bytes_downloaded Bytes downloaded so far + * @param total_bytes Total bytes to download (0 if unknown) + * @param callback_user_data Context passed to http_download + */ +typedef void (*rac_http_progress_callback_fn)(int64_t bytes_downloaded, int64_t total_bytes, + void* callback_user_data); + +/** + * HTTP download completion callback type. + * @param result RAC_SUCCESS or error code + * @param downloaded_path Path to downloaded file (NULL on failure) + * @param callback_user_data Context passed to http_download + */ +typedef void (*rac_http_complete_callback_fn)(rac_result_t result, const char* downloaded_path, + void* callback_user_data); + +/** + * Archive extraction progress callback type. + * @param files_extracted Number of files extracted so far + * @param total_files Total files to extract + * @param callback_user_data Context passed to extract_archive + */ +typedef void (*rac_extract_progress_callback_fn)(int32_t files_extracted, int32_t total_files, + void* callback_user_data); + +// ============================================================================= +// PLATFORM ADAPTER STRUCTURE +// ============================================================================= + +/** + * Platform adapter structure. + * + * Implements platform-specific operations via callbacks. + * The SDK layer (Swift/Kotlin) provides these implementations. + */ +typedef struct rac_platform_adapter { + // ------------------------------------------------------------------------- + // File System Operations + // ------------------------------------------------------------------------- + + /** + * Check if a file exists. + * @param path File path + * @param user_data Platform context + * @return RAC_TRUE if file exists, RAC_FALSE otherwise + */ + rac_bool_t (*file_exists)(const char* path, void* user_data); + + /** + * Read file contents. + * @param path File path + * @param out_data Output buffer (caller must free with rac_free) + * @param out_size Output file size + * @param user_data Platform context + * @return RAC_SUCCESS on success, error code on failure + */ + rac_result_t (*file_read)(const char* path, void** out_data, size_t* out_size, void* user_data); + + /** + * Write file contents. + * @param path File path + * @param data Data to write + * @param size Data size + * @param user_data Platform context + * @return RAC_SUCCESS on success, error code on failure + */ + rac_result_t (*file_write)(const char* path, const void* data, size_t size, void* user_data); + + /** + * Delete a file. + * @param path File path + * @param user_data Platform context + * @return RAC_SUCCESS on success, error code on failure + */ + rac_result_t (*file_delete)(const char* path, void* user_data); + + // ------------------------------------------------------------------------- + // Secure Storage (Keychain/KeyStore) + // ------------------------------------------------------------------------- + + /** + * Get a value from secure storage. + * @param key Key name + * @param out_value Output value (caller must free with rac_free) + * @param user_data Platform context + * @return RAC_SUCCESS on success, RAC_ERROR_FILE_NOT_FOUND if not found + */ + rac_result_t (*secure_get)(const char* key, char** out_value, void* user_data); + + /** + * Set a value in secure storage. + * @param key Key name + * @param value Value to store + * @param user_data Platform context + * @return RAC_SUCCESS on success, error code on failure + */ + rac_result_t (*secure_set)(const char* key, const char* value, void* user_data); + + /** + * Delete a value from secure storage. + * @param key Key name + * @param user_data Platform context + * @return RAC_SUCCESS on success, error code on failure + */ + rac_result_t (*secure_delete)(const char* key, void* user_data); + + // ------------------------------------------------------------------------- + // Logging + // ------------------------------------------------------------------------- + + /** + * Log a message. + * @param level Log level + * @param category Log category (e.g., "ModuleRegistry") + * @param message Log message + * @param user_data Platform context + */ + void (*log)(rac_log_level_t level, const char* category, const char* message, void* user_data); + + // ------------------------------------------------------------------------- + // Error Tracking (Optional - for Sentry/crash reporting) + // ------------------------------------------------------------------------- + + /** + * Track a structured error for telemetry/crash reporting. + * Can be NULL - errors will still be logged but not sent to Sentry. + * + * Called for non-expected errors (i.e., not cancellations). + * The JSON string contains full error details including stack trace. + * + * @param error_json JSON representation of the structured error + * @param user_data Platform context + */ + void (*track_error)(const char* error_json, void* user_data); + + // ------------------------------------------------------------------------- + // Clock + // ------------------------------------------------------------------------- + + /** + * Get current time in milliseconds since Unix epoch. + * @param user_data Platform context + * @return Current time in milliseconds + */ + int64_t (*now_ms)(void* user_data); + + // ------------------------------------------------------------------------- + // Memory Info + // ------------------------------------------------------------------------- + + /** + * Get memory information. + * @param out_info Output memory info structure + * @param user_data Platform context + * @return RAC_SUCCESS on success, error code on failure + */ + rac_result_t (*get_memory_info)(rac_memory_info_t* out_info, void* user_data); + + // ------------------------------------------------------------------------- + // HTTP Download (Optional - can be NULL) + // ------------------------------------------------------------------------- + + /** + * Start an HTTP download. + * Can be NULL - download orchestration in C++ will call back to Swift/Kotlin. + * + * @param url URL to download from + * @param destination_path Where to save the downloaded file + * @param progress_callback Progress callback (can be NULL) + * @param complete_callback Completion callback + * @param callback_user_data User context for callbacks + * @param out_task_id Output: Task ID for cancellation (owned, must be freed) + * @param user_data Platform context + * @return RAC_SUCCESS if download started, error code otherwise + */ + rac_result_t (*http_download)(const char* url, const char* destination_path, + rac_http_progress_callback_fn progress_callback, + rac_http_complete_callback_fn complete_callback, + void* callback_user_data, char** out_task_id, void* user_data); + + /** + * Cancel an HTTP download. + * Can be NULL. + * + * @param task_id Task ID returned from http_download + * @param user_data Platform context + * @return RAC_SUCCESS if cancelled, error code otherwise + */ + rac_result_t (*http_download_cancel)(const char* task_id, void* user_data); + + // ------------------------------------------------------------------------- + // Archive Extraction (Optional - can be NULL) + // ------------------------------------------------------------------------- + + /** + * Extract an archive (ZIP or TAR). + * Can be NULL - extraction will be handled by Swift/Kotlin. + * + * @param archive_path Path to the archive + * @param destination_dir Where to extract files + * @param progress_callback Progress callback (can be NULL) + * @param callback_user_data User context for callback + * @param user_data Platform context + * @return RAC_SUCCESS if extracted, error code otherwise + */ + rac_result_t (*extract_archive)(const char* archive_path, const char* destination_dir, + rac_extract_progress_callback_fn progress_callback, + void* callback_user_data, void* user_data); + + // ------------------------------------------------------------------------- + // User Data + // ------------------------------------------------------------------------- + + /** Platform-specific context passed to all callbacks */ + void* user_data; + +} rac_platform_adapter_t; + +// ============================================================================= +// PLATFORM ADAPTER API +// ============================================================================= + +/** + * Sets the platform adapter. + * + * Called during rac_init() - the adapter pointer must remain valid + * until rac_shutdown() is called. + * + * @param adapter Platform adapter (must not be NULL) + * @return RAC_SUCCESS on success, error code on failure + */ +RAC_API rac_result_t rac_set_platform_adapter(const rac_platform_adapter_t* adapter); + +/** + * Gets the current platform adapter. + * + * @return The current adapter, or NULL if not set + */ +RAC_API const rac_platform_adapter_t* rac_get_platform_adapter(void); + +// ============================================================================= +// CONVENIENCE FUNCTIONS (use platform adapter internally) +// ============================================================================= + +/** + * Log a message using the platform adapter. + * @param level Log level + * @param category Category string + * @param message Message string + */ +RAC_API void rac_log(rac_log_level_t level, const char* category, const char* message); + +/** + * Get current time in milliseconds. + * @return Current time in milliseconds since epoch + */ +RAC_API int64_t rac_get_current_time_ms(void); + +/** + * Start an HTTP download using the platform adapter. + * Returns RAC_ERROR_NOT_SUPPORTED if http_download callback is NULL. + * + * @param url URL to download + * @param destination_path Where to save + * @param progress_callback Progress callback (can be NULL) + * @param complete_callback Completion callback + * @param callback_user_data User data for callbacks + * @param out_task_id Output: Task ID (owned, must be freed) + * @return RAC_SUCCESS if started, error code otherwise + */ +RAC_API rac_result_t rac_http_download(const char* url, const char* destination_path, + rac_http_progress_callback_fn progress_callback, + rac_http_complete_callback_fn complete_callback, + void* callback_user_data, char** out_task_id); + +/** + * Cancel an HTTP download. + * Returns RAC_ERROR_NOT_SUPPORTED if http_download_cancel callback is NULL. + * + * @param task_id Task ID to cancel + * @return RAC_SUCCESS if cancelled, error code otherwise + */ +RAC_API rac_result_t rac_http_download_cancel(const char* task_id); + +/** + * Extract an archive using the platform adapter. + * Returns RAC_ERROR_NOT_SUPPORTED if extract_archive callback is NULL. + * + * @param archive_path Path to archive + * @param destination_dir Where to extract + * @param progress_callback Progress callback (can be NULL) + * @param callback_user_data User data for callback + * @return RAC_SUCCESS if extracted, error code otherwise + */ +RAC_API rac_result_t rac_extract_archive(const char* archive_path, const char* destination_dir, + rac_extract_progress_callback_fn progress_callback, + void* callback_user_data); + +/** + * Check if a model framework is a platform service (Swift-native). + * Platform services are handled via service registry callbacks, not C++ backends. + * + * @param framework Framework to check + * @return RAC_TRUE if platform service, RAC_FALSE if C++ backend + */ +RAC_API rac_bool_t rac_framework_is_platform_service(rac_inference_framework_t framework); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_PLATFORM_ADAPTER_H */ diff --git a/sdk/runanywhere-commons/include/rac/core/rac_sdk_state.h b/sdk/runanywhere-commons/include/rac/core/rac_sdk_state.h new file mode 100644 index 000000000..1c688dbc6 --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/core/rac_sdk_state.h @@ -0,0 +1,292 @@ +/** + * @file rac_sdk_state.h + * @brief Centralized SDK state management (C++ equivalent of ServiceContainer) + * + * This is the single source of truth for all SDK runtime state. + * Platform SDKs (Swift, Kotlin, Flutter) should query state from here + * rather than maintaining their own copies. + * + * Pattern mirrors Swift's ServiceContainer: + * - Singleton access via rac_state_get_instance() + * - Lazy initialization for sub-components + * - Thread-safe access via internal mutex + * - Reset capability for testing + * + * State Categories: + * 1. Auth State - Tokens, user/org IDs, authentication status + * 2. Device State - Device ID, registration status + * 3. Environment - SDK environment, API key, base URL + * 4. Services - Telemetry manager, model registry handles + */ + +#ifndef RAC_SDK_STATE_H +#define RAC_SDK_STATE_H + +#include +#include + +#include "rac/core/rac_types.h" // For rac_result_t, RAC_SUCCESS +#include "rac/infrastructure/network/rac_environment.h" // For rac_environment_t + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// State Structure (Opaque - internal structure hidden from C API) +// ============================================================================= + +/** + * @brief Opaque handle to SDK state + * + * The internal structure is hidden to allow C++ implementation + * while exposing a clean C API for platform interop. + */ +typedef struct rac_sdk_state* rac_sdk_state_handle_t; + +// ============================================================================= +// Auth Data Input Structure (Public - for platform to populate) +// ============================================================================= + +/** + * @brief Authentication data input + * + * Platforms use this to set auth state after successful HTTP authentication. + * C++ copies the data internally and manages lifetime. + * + * Note: This is distinct from rac_auth_state_t in rac_auth_manager.h which + * is the internal state structure. + */ +typedef struct { + const char* access_token; + const char* refresh_token; + int64_t expires_at_unix; // Unix timestamp (seconds) + const char* user_id; // Nullable + const char* organization_id; + const char* device_id; +} rac_auth_data_t; + +// ============================================================================= +// Singleton Access +// ============================================================================= + +/** + * @brief Get the singleton SDK state instance + * + * Creates the instance on first call (lazy initialization). + * Thread-safe. + * + * @return Handle to the SDK state (never NULL after first call) + */ +rac_sdk_state_handle_t rac_state_get_instance(void); + +// ============================================================================= +// Initialization & Lifecycle +// ============================================================================= + +/** + * @brief Initialize SDK state with configuration + * + * Called during SDK initialization. Sets up environment and base config. + * + * @param env The SDK environment (development, staging, production) + * @param api_key The API key (copied internally) + * @param base_url The base URL (copied internally) + * @param device_id The persistent device ID (copied internally) + * @return RAC_SUCCESS on success + */ +rac_result_t rac_state_initialize(rac_environment_t env, const char* api_key, const char* base_url, + const char* device_id); + +/** + * @brief Check if SDK state is initialized + * @return true if initialized + */ +bool rac_state_is_initialized(void); + +/** + * @brief Reset all state (for testing or re-initialization) + * + * Clears all state including auth tokens, handles, etc. + * Does NOT free the singleton - just resets to initial state. + */ +void rac_state_reset(void); + +/** + * @brief Shutdown and free all resources + * + * Called during SDK shutdown. Frees all memory and destroys handles. + */ +void rac_state_shutdown(void); + +// ============================================================================= +// Environment Queries +// ============================================================================= + +/** + * @brief Get current environment + * @return The SDK environment + */ +rac_environment_t rac_state_get_environment(void); + +/** + * @brief Get base URL + * @return The base URL string (do not free) + */ +const char* rac_state_get_base_url(void); + +/** + * @brief Get API key + * @return The API key string (do not free) + */ +const char* rac_state_get_api_key(void); + +/** + * @brief Get device ID + * @return The device ID string (do not free) + */ +const char* rac_state_get_device_id(void); + +// ============================================================================= +// Auth State Management +// ============================================================================= + +/** + * @brief Set authentication state after successful auth + * + * Called by platform after HTTP auth response is received. + * Copies all strings internally. + * + * @param auth The auth data to set + * @return RAC_SUCCESS on success + */ +rac_result_t rac_state_set_auth(const rac_auth_data_t* auth); + +/** + * @brief Get current access token + * @return Access token string or NULL if not authenticated (do not free) + */ +const char* rac_state_get_access_token(void); + +/** + * @brief Get current refresh token + * @return Refresh token string or NULL (do not free) + */ +const char* rac_state_get_refresh_token(void); + +/** + * @brief Check if currently authenticated + * @return true if authenticated with valid (non-expired) token + */ +bool rac_state_is_authenticated(void); + +/** + * @brief Check if token needs refresh + * + * Returns true if token expires within the next 60 seconds. + * + * @return true if refresh is needed + */ +bool rac_state_token_needs_refresh(void); + +/** + * @brief Get token expiry timestamp + * @return Unix timestamp (seconds) when token expires, or 0 if not set + */ +int64_t rac_state_get_token_expires_at(void); + +/** + * @brief Get user ID + * @return User ID string or NULL (do not free) + */ +const char* rac_state_get_user_id(void); + +/** + * @brief Get organization ID + * @return Organization ID string or NULL (do not free) + */ +const char* rac_state_get_organization_id(void); + +/** + * @brief Clear authentication state + * + * Called on logout or auth failure. Clears tokens but not device/env config. + */ +void rac_state_clear_auth(void); + +// ============================================================================= +// Device State Management +// ============================================================================= + +/** + * @brief Set device registration status + * @param registered Whether device is registered with backend + */ +void rac_state_set_device_registered(bool registered); + +/** + * @brief Check if device is registered + * @return true if device has been registered + */ +bool rac_state_is_device_registered(void); + +// ============================================================================= +// State Change Callbacks (for platform observers) +// ============================================================================= + +/** + * @brief Callback type for auth state changes + * @param is_authenticated Current auth status + * @param user_data User-provided context + */ +typedef void (*rac_auth_changed_callback_t)(bool is_authenticated, void* user_data); + +/** + * @brief Register callback for auth state changes + * + * Called whenever auth state changes (login, logout, token refresh). + * + * @param callback The callback function (NULL to unregister) + * @param user_data Context passed to callback + */ +void rac_state_on_auth_changed(rac_auth_changed_callback_t callback, void* user_data); + +// ============================================================================= +// Persistence Bridge (Platform implements secure storage) +// ============================================================================= + +/** + * @brief Callback type for persisting state to secure storage + * @param key The key to store under + * @param value The value to store (NULL to delete) + * @param user_data User-provided context + */ +typedef void (*rac_persist_callback_t)(const char* key, const char* value, void* user_data); + +/** + * @brief Callback type for loading state from secure storage + * @param key The key to load + * @param user_data User-provided context + * @return The stored value or NULL (caller must NOT free) + */ +typedef const char* (*rac_load_callback_t)(const char* key, void* user_data); + +/** + * @brief Register callbacks for secure storage + * + * Platform implements these to persist to Keychain/KeyStore. + * C++ calls persist_callback when state changes. + * C++ calls load_callback during initialization. + * + * @param persist Callback to persist a value + * @param load Callback to load a value + * @param user_data Context passed to callbacks + */ +void rac_state_set_persistence_callbacks(rac_persist_callback_t persist, rac_load_callback_t load, + void* user_data); + +#ifdef __cplusplus +} +#endif + +#endif // RAC_SDK_STATE_H diff --git a/sdk/runanywhere-commons/include/rac/core/rac_structured_error.h b/sdk/runanywhere-commons/include/rac/core/rac_structured_error.h new file mode 100644 index 000000000..233413531 --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/core/rac_structured_error.h @@ -0,0 +1,594 @@ +/** + * @file rac_structured_error.h + * @brief RunAnywhere Commons - Structured Error System + * + * Provides a comprehensive structured error type that mirrors Swift's SDKError. + * This is the source of truth for error structures across all platforms + * (Swift, Kotlin, React Native, Flutter). + * + * Features: + * - Error codes and categories matching Swift's ErrorCode and ErrorCategory + * - Stack trace capture (platform-dependent) + * - Structured metadata for telemetry + * - Serialization to JSON for remote logging + * + * Usage: + * rac_error_t* error = rac_error_create(RAC_ERROR_MODEL_NOT_FOUND, + * RAC_CATEGORY_STT, + * "Model not found: whisper-tiny.en"); + * rac_error_set_model_context(error, "whisper-tiny.en", "sherpa-onnx"); + * rac_error_capture_stack_trace(error); + * // ... use error ... + * rac_error_destroy(error); + */ + +#ifndef RAC_STRUCTURED_ERROR_H +#define RAC_STRUCTURED_ERROR_H + +#include + +#include "rac/core/rac_error.h" +#include "rac/core/rac_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// ERROR CATEGORIES +// ============================================================================= + +/** + * @brief Error categories matching Swift's ErrorCategory. + * + * These define which component/modality an error belongs to. + */ +typedef enum rac_error_category { + RAC_CATEGORY_GENERAL = 0, /**< General SDK errors */ + RAC_CATEGORY_STT = 1, /**< Speech-to-Text errors */ + RAC_CATEGORY_TTS = 2, /**< Text-to-Speech errors */ + RAC_CATEGORY_LLM = 3, /**< Large Language Model errors */ + RAC_CATEGORY_VAD = 4, /**< Voice Activity Detection errors */ + RAC_CATEGORY_VLM = 5, /**< Vision Language Model errors */ + RAC_CATEGORY_SPEAKER_DIARIZATION = 6, /**< Speaker Diarization errors */ + RAC_CATEGORY_WAKE_WORD = 7, /**< Wake Word Detection errors */ + RAC_CATEGORY_VOICE_AGENT = 8, /**< Voice Agent errors */ + RAC_CATEGORY_DOWNLOAD = 9, /**< Download errors */ + RAC_CATEGORY_FILE_MANAGEMENT = 10, /**< File management errors */ + RAC_CATEGORY_NETWORK = 11, /**< Network errors */ + RAC_CATEGORY_AUTHENTICATION = 12, /**< Authentication errors */ + RAC_CATEGORY_SECURITY = 13, /**< Security errors */ + RAC_CATEGORY_RUNTIME = 14, /**< Runtime/backend errors */ +} rac_error_category_t; + +// ============================================================================= +// STACK FRAME +// ============================================================================= + +/** + * @brief A single frame in a stack trace. + */ +typedef struct rac_stack_frame { + const char* function; /**< Function name */ + const char* file; /**< Source file name */ + int32_t line; /**< Line number */ + void* address; /**< Memory address (for symbolication) */ +} rac_stack_frame_t; + +// ============================================================================= +// STRUCTURED ERROR +// ============================================================================= + +/** + * @brief Maximum number of stack frames to capture. + */ +#define RAC_MAX_STACK_FRAMES 32 + +/** + * @brief Maximum length of error message. + */ +#define RAC_MAX_ERROR_MESSAGE 1024 + +/** + * @brief Maximum length of metadata strings. + */ +#define RAC_MAX_METADATA_STRING 256 + +/** + * @brief Structured error type matching Swift's SDKError. + * + * Contains all information needed for error reporting, logging, and telemetry. + */ +typedef struct rac_error { + // Core error info + rac_result_t code; /**< Error code (RAC_ERROR_*) */ + rac_error_category_t category; /**< Error category */ + char message[RAC_MAX_ERROR_MESSAGE]; /**< Human-readable message */ + + // Source location where error occurred + char source_file[RAC_MAX_METADATA_STRING]; /**< Source file name */ + int32_t source_line; /**< Source line number */ + char source_function[RAC_MAX_METADATA_STRING]; /**< Function name */ + + // Stack trace + rac_stack_frame_t stack_frames[RAC_MAX_STACK_FRAMES]; + int32_t stack_frame_count; + + // Underlying error (optional) + rac_result_t underlying_code; /**< Underlying error code (0 if none) */ + char underlying_message[RAC_MAX_ERROR_MESSAGE]; /**< Underlying error message */ + + // Context metadata + char model_id[RAC_MAX_METADATA_STRING]; /**< Model ID if applicable */ + char framework[RAC_MAX_METADATA_STRING]; /**< Framework (e.g., "sherpa-onnx") */ + char session_id[RAC_MAX_METADATA_STRING]; /**< Session ID for correlation */ + + // Timing + int64_t timestamp_ms; /**< When error occurred (unix ms) */ + + // Custom metadata (key-value pairs for extensibility) + char custom_key1[64]; + char custom_value1[RAC_MAX_METADATA_STRING]; + char custom_key2[64]; + char custom_value2[RAC_MAX_METADATA_STRING]; + char custom_key3[64]; + char custom_value3[RAC_MAX_METADATA_STRING]; +} rac_error_t; + +// ============================================================================= +// ERROR CREATION & DESTRUCTION +// ============================================================================= + +/** + * @brief Creates a new structured error. + * + * @param code Error code (RAC_ERROR_*) + * @param category Error category + * @param message Human-readable error message + * @return New error instance (caller must call rac_error_destroy) + */ +RAC_API rac_error_t* rac_error_create(rac_result_t code, rac_error_category_t category, + const char* message); + +/** + * @brief Creates an error with source location. + * + * Use the RAC_ERROR_HERE macro for convenient source location capture. + * + * @param code Error code + * @param category Error category + * @param message Error message + * @param file Source file (__FILE__) + * @param line Source line (__LINE__) + * @param function Function name (__func__) + * @return New error instance + */ +RAC_API rac_error_t* rac_error_create_at(rac_result_t code, rac_error_category_t category, + const char* message, const char* file, int32_t line, + const char* function); + +/** + * @brief Creates an error with formatted message. + * + * @param code Error code + * @param category Error category + * @param format Printf-style format string + * @param ... Format arguments + * @return New error instance + */ +RAC_API rac_error_t* rac_error_createf(rac_result_t code, rac_error_category_t category, + const char* format, ...); + +/** + * @brief Destroys a structured error and frees memory. + * + * @param error Error to destroy (can be NULL) + */ +RAC_API void rac_error_destroy(rac_error_t* error); + +/** + * @brief Creates a copy of an error. + * + * @param error Error to copy + * @return New copy of the error (caller must destroy) + */ +RAC_API rac_error_t* rac_error_copy(const rac_error_t* error); + +// ============================================================================= +// ERROR CONFIGURATION +// ============================================================================= + +/** + * @brief Sets the source location for an error. + * + * @param error Error to modify + * @param file Source file name + * @param line Source line number + * @param function Function name + */ +RAC_API void rac_error_set_source(rac_error_t* error, const char* file, int32_t line, + const char* function); + +/** + * @brief Sets the underlying error. + * + * @param error Error to modify + * @param underlying_code Underlying error code + * @param underlying_message Underlying error message + */ +RAC_API void rac_error_set_underlying(rac_error_t* error, rac_result_t underlying_code, + const char* underlying_message); + +/** + * @brief Sets model context for the error. + * + * @param error Error to modify + * @param model_id Model ID + * @param framework Framework name (e.g., "sherpa-onnx", "llama.cpp") + */ +RAC_API void rac_error_set_model_context(rac_error_t* error, const char* model_id, + const char* framework); + +/** + * @brief Sets session ID for correlation. + * + * @param error Error to modify + * @param session_id Session ID + */ +RAC_API void rac_error_set_session(rac_error_t* error, const char* session_id); + +/** + * @brief Sets custom metadata on the error. + * + * @param error Error to modify + * @param index Custom slot (0-2) + * @param key Metadata key + * @param value Metadata value + */ +RAC_API void rac_error_set_custom(rac_error_t* error, int32_t index, const char* key, + const char* value); + +// ============================================================================= +// STACK TRACE +// ============================================================================= + +/** + * @brief Captures the current stack trace into the error. + * + * Platform-dependent. On some platforms, only addresses may be captured + * and symbolication happens later. + * + * @param error Error to capture stack trace into + * @return Number of frames captured + */ +RAC_API int32_t rac_error_capture_stack_trace(rac_error_t* error); + +/** + * @brief Adds a manual stack frame to the error. + * + * Use this when automatic stack capture is not available. + * + * @param error Error to modify + * @param function Function name + * @param file File name + * @param line Line number + */ +RAC_API void rac_error_add_frame(rac_error_t* error, const char* function, const char* file, + int32_t line); + +// ============================================================================= +// ERROR INFORMATION +// ============================================================================= + +/** + * @brief Gets the error code name as a string. + * + * @param code Error code + * @return Static string with code name (e.g., "MODEL_NOT_FOUND") + */ +RAC_API const char* rac_error_code_name(rac_result_t code); + +/** + * @brief Gets the category name as a string. + * + * @param category Error category + * @return Static string with category name (e.g., "stt", "llm") + */ +RAC_API const char* rac_error_category_name(rac_error_category_t category); + +/** + * @brief Gets a recovery suggestion for the error. + * + * Mirrors Swift's SDKError.recoverySuggestion. + * + * @param code Error code + * @return Static string with suggestion, or NULL if none + */ +RAC_API const char* rac_error_recovery_suggestion(rac_result_t code); + +/** + * @brief Checks if an error is expected (like cancellation). + * + * Expected errors should typically not be logged as errors. + * + * @param error Error to check + * @return RAC_TRUE if expected, RAC_FALSE otherwise + */ +RAC_API rac_bool_t rac_error_is_expected_error(const rac_error_t* error); + +// ============================================================================= +// SERIALIZATION +// ============================================================================= + +/** + * @brief Serializes error to JSON string for telemetry. + * + * Returns a compact JSON representation suitable for sending to analytics. + * The returned string must be freed with rac_free(). + * + * @param error Error to serialize + * @return JSON string (caller must free), or NULL on failure + */ +RAC_API char* rac_error_to_json(const rac_error_t* error); + +/** + * @brief Gets telemetry properties as key-value pairs. + * + * Returns essential fields for analytics/telemetry events. + * Keys and values must be freed by caller. + * + * @param error Error to get properties from + * @param out_keys Output array of keys (caller allocates, at least 10 slots) + * @param out_values Output array of values (caller allocates, at least 10 slots) + * @return Number of properties written + */ +RAC_API int32_t rac_error_get_telemetry_properties(const rac_error_t* error, char** out_keys, + char** out_values); + +/** + * @brief Formats error as a human-readable string. + * + * Format: "SDKError[category.code]: message" + * The returned string must be freed with rac_free(). + * + * @param error Error to format + * @return Formatted string (caller must free) + */ +RAC_API char* rac_error_to_string(const rac_error_t* error); + +/** + * @brief Formats error with full debug info including stack trace. + * + * The returned string must be freed with rac_free(). + * + * @param error Error to format + * @return Debug string (caller must free) + */ +RAC_API char* rac_error_to_debug_string(const rac_error_t* error); + +// ============================================================================= +// CONVENIENCE MACROS +// ============================================================================= + +/** + * @brief Creates an error with automatic source location capture. + */ +#define RAC_ERROR(code, category, message) \ + rac_error_create_at(code, category, message, __FILE__, __LINE__, __func__) + +/** + * @brief Creates an error with formatted message and source location. + */ +#define RAC_ERRORF(code, category, ...) \ + rac_error_create_at_f(code, category, __FILE__, __LINE__, __func__, __VA_ARGS__) + +/** + * @brief Category-specific error macros. + */ +#define RAC_ERROR_STT(code, msg) RAC_ERROR(code, RAC_CATEGORY_STT, msg) +#define RAC_ERROR_TTS(code, msg) RAC_ERROR(code, RAC_CATEGORY_TTS, msg) +#define RAC_ERROR_LLM(code, msg) RAC_ERROR(code, RAC_CATEGORY_LLM, msg) +#define RAC_ERROR_VAD(code, msg) RAC_ERROR(code, RAC_CATEGORY_VAD, msg) +#define RAC_ERROR_GENERAL(code, msg) RAC_ERROR(code, RAC_CATEGORY_GENERAL, msg) +#define RAC_ERROR_NETWORK(code, msg) RAC_ERROR(code, RAC_CATEGORY_NETWORK, msg) +#define RAC_ERROR_DOWNLOAD(code, msg) RAC_ERROR(code, RAC_CATEGORY_DOWNLOAD, msg) + +// ============================================================================= +// GLOBAL ERROR (Thread-Local Last Error) +// ============================================================================= + +/** + * @brief Sets the last error for the current thread. + * + * This copies the error into thread-local storage. The original error + * can be destroyed after this call. + * + * @param error Error to set (can be NULL to clear) + */ +RAC_API void rac_set_last_error(const rac_error_t* error); + +/** + * @brief Gets the last error for the current thread. + * + * @return Pointer to thread-local error (do not free), or NULL if none + */ +RAC_API const rac_error_t* rac_get_last_error(void); + +/** + * @brief Clears the last error for the current thread. + */ +RAC_API void rac_clear_last_error(void); + +/** + * @brief Convenience: creates, logs, and sets last error in one call. + * + * @param code Error code + * @param category Error category + * @param message Error message + * @return The error code (for easy return statements) + */ +RAC_API rac_result_t rac_set_error(rac_result_t code, rac_error_category_t category, + const char* message); + +/** + * @brief Convenience macro to set error and return. + */ +#define RAC_RETURN_ERROR(code, category, msg) return rac_set_error(code, category, msg) + +// ============================================================================= +// UNIFIED ERROR HANDLING (Log + Track) +// ============================================================================= + +/** + * @brief Creates, logs, and tracks a structured error. + * + * This is the recommended way to handle errors in C++ code. It: + * 1. Creates a structured error with source location + * 2. Captures stack trace (if available) + * 3. Logs the error via the logging system + * 4. Sends to error tracking (Sentry) via platform adapter + * 5. Sets as last error for retrieval + * + * @param code Error code + * @param category Error category + * @param message Error message + * @param file Source file (__FILE__) + * @param line Source line (__LINE__) + * @param function Function name (__func__) + * @return The error code (for easy return statements) + */ +RAC_API rac_result_t rac_error_log_and_track(rac_result_t code, rac_error_category_t category, + const char* message, const char* file, int32_t line, + const char* function); + +/** + * @brief Creates, logs, and tracks a structured error with model context. + * + * Same as rac_error_log_and_track but includes model information. + * + * @param code Error code + * @param category Error category + * @param message Error message + * @param model_id Model ID + * @param framework Framework name + * @param file Source file + * @param line Source line + * @param function Function name + * @return The error code + */ +RAC_API rac_result_t rac_error_log_and_track_model(rac_result_t code, rac_error_category_t category, + const char* message, const char* model_id, + const char* framework, const char* file, + int32_t line, const char* function); + +/** + * @brief Convenience macro to create, log, track error and return. + * + * Usage: + * if (model == NULL) { + * RAC_RETURN_TRACKED_ERROR(RAC_ERROR_MODEL_NOT_FOUND, RAC_CATEGORY_LLM, "Model not found"); + * } + */ +#define RAC_RETURN_TRACKED_ERROR(code, category, msg) \ + return rac_error_log_and_track(code, category, msg, __FILE__, __LINE__, __func__) + +/** + * @brief Convenience macro with model context. + */ +#define RAC_RETURN_TRACKED_ERROR_MODEL(code, category, msg, model_id, framework) \ + return rac_error_log_and_track_model(code, category, msg, model_id, framework, __FILE__, \ + __LINE__, __func__) + +#ifdef __cplusplus +} +#endif + +// ============================================================================= +// C++ CONVENIENCE CLASS +// ============================================================================= + +#ifdef __cplusplus + +#include +#include + +namespace rac { + +/** + * @brief RAII wrapper for rac_error_t. + */ +class Error { + public: + Error(rac_result_t code, rac_error_category_t category, const char* message) + : error_(rac_error_create(code, category, message), rac_error_destroy) {} + + Error(rac_error_t* error) : error_(error, rac_error_destroy) {} + + // Factory methods + static Error stt(rac_result_t code, const char* msg) { return {code, RAC_CATEGORY_STT, msg}; } + static Error tts(rac_result_t code, const char* msg) { return {code, RAC_CATEGORY_TTS, msg}; } + static Error llm(rac_result_t code, const char* msg) { return {code, RAC_CATEGORY_LLM, msg}; } + static Error vad(rac_result_t code, const char* msg) { return {code, RAC_CATEGORY_VAD, msg}; } + static Error network(rac_result_t code, const char* msg) { + return {code, RAC_CATEGORY_NETWORK, msg}; + } + + // Accessors + rac_result_t code() const { return error_ ? error_->code : RAC_SUCCESS; } + rac_error_category_t category() const { + return error_ ? error_->category : RAC_CATEGORY_GENERAL; + } + const char* message() const { return error_ ? error_->message : ""; } + + // Configuration + Error& setModelContext(const char* model_id, const char* framework) { + if (error_) + rac_error_set_model_context(error_.get(), model_id, framework); + return *this; + } + + Error& setSession(const char* session_id) { + if (error_) + rac_error_set_session(error_.get(), session_id); + return *this; + } + + Error& captureStackTrace() { + if (error_) + rac_error_capture_stack_trace(error_.get()); + return *this; + } + + // Conversion + std::string toString() const { + if (!error_) + return ""; + char* str = rac_error_to_string(error_.get()); + std::string result(str ? str : ""); + rac_free(str); + return result; + } + + std::string toJson() const { + if (!error_) + return "{}"; + char* json = rac_error_to_json(error_.get()); + std::string result(json ? json : "{}"); + rac_free(json); + return result; + } + + // Raw access + rac_error_t* get() { return error_.get(); } + const rac_error_t* get() const { return error_.get(); } + operator bool() const { return error_ != nullptr; } + + private: + std::unique_ptr error_; +}; + +} // namespace rac + +#endif // __cplusplus + +#endif /* RAC_STRUCTURED_ERROR_H */ diff --git a/sdk/runanywhere-commons/include/rac/core/rac_types.h b/sdk/runanywhere-commons/include/rac/core/rac_types.h new file mode 100644 index 000000000..bd03a66c4 --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/core/rac_types.h @@ -0,0 +1,264 @@ +/** + * @file rac_types.h + * @brief RunAnywhere Commons - Common Types and Definitions + * + * This header defines common types, handle types, and macros used throughout + * the runanywhere-commons library. All types use the RAC_ prefix to distinguish + * from the underlying runanywhere-core (ra_*) types. + */ + +#ifndef RAC_TYPES_H +#define RAC_TYPES_H + +#include +#include + +/** + * Null pointer macro for use in static initializers. + * Uses nullptr in C++ (preferred by clang-tidy modernize-use-nullptr) + * and NULL in C for compatibility. + */ +#ifdef __cplusplus +#define RAC_NULL nullptr +#else +#define RAC_NULL NULL +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// API VISIBILITY MACROS +// ============================================================================= +// +// RAC_API marks functions that must be visible to FFI (dlsym). +// +// CRITICAL: For iOS/Android Flutter FFI, symbols MUST have public visibility +// even when statically linked. dlsym(RTLD_DEFAULT, ...) can only find symbols +// with "external" visibility, not "private external". +// +// Without visibility("default"), static library symbols get "private external" +// visibility (due to -fvisibility=hidden), which becomes "non-external" (local) +// in the final binary - breaking FFI symbol lookup. +// ============================================================================= + +#if defined(_WIN32) +#if defined(RAC_BUILDING_SHARED) +#define RAC_API __declspec(dllexport) +#elif defined(RAC_USING_SHARED) +#define RAC_API __declspec(dllimport) +#else +#define RAC_API +#endif +#elif defined(__GNUC__) || defined(__clang__) +// Always use default visibility for FFI compatibility +// This ensures dlsym() can find symbols even in static libraries +#define RAC_API __attribute__((visibility("default"))) +#else +#define RAC_API +#endif + +// ============================================================================= +// RESULT TYPE +// ============================================================================= + +/** + * Result type for all RAC functions. + * - 0 indicates success + * - Negative values indicate errors (see rac_error.h) + * + * Error code ranges: + * - runanywhere-core (ra_*): 0 to -99 + * - runanywhere-commons (rac_*): -100 to -999 + */ +typedef int32_t rac_result_t; + +/** Success result */ +#define RAC_SUCCESS ((rac_result_t)0) + +// ============================================================================= +// BOOLEAN TYPE +// ============================================================================= + +/** Boolean type for C compatibility */ +typedef int32_t rac_bool_t; + +#define RAC_TRUE ((rac_bool_t)1) +#define RAC_FALSE ((rac_bool_t)0) + +// ============================================================================= +// HANDLE TYPES +// ============================================================================= + +/** + * Opaque handle for internal objects. + * Handles should be treated as opaque pointers. + */ +typedef void* rac_handle_t; + +/** Invalid handle value */ +#define RAC_INVALID_HANDLE ((rac_handle_t)NULL) + +// ============================================================================= +// STRING TYPES +// ============================================================================= + +/** + * String view (non-owning reference to a string). + * The string is NOT guaranteed to be null-terminated. + */ +typedef struct rac_string_view { + const char* data; /**< Pointer to string data */ + size_t length; /**< Length in bytes (not including any null terminator) */ +} rac_string_view_t; + +/** + * Creates a string view from a null-terminated C string. + */ +#define RAC_STRING_VIEW(s) ((rac_string_view_t){(s), (s) ? strlen(s) : 0}) + +// ============================================================================= +// AUDIO TYPES +// ============================================================================= + +/** + * Audio buffer for STT/VAD operations. + * Contains PCM float samples in the range [-1.0, 1.0]. + */ +typedef struct rac_audio_buffer { + const float* samples; /**< PCM float samples */ + size_t num_samples; /**< Number of samples */ + int32_t sample_rate; /**< Sample rate in Hz (e.g., 16000) */ + int32_t channels; /**< Number of channels (1 = mono, 2 = stereo) */ +} rac_audio_buffer_t; + +/** + * Audio format specification. + */ +typedef struct rac_audio_format { + int32_t sample_rate; /**< Sample rate in Hz */ + int32_t channels; /**< Number of channels */ + int32_t bits_per_sample; /**< Bits per sample (16 or 32) */ +} rac_audio_format_t; + +// ============================================================================= +// MEMORY INFO +// ============================================================================= + +/** + * Memory information structure. + * Used by the platform adapter to report available memory. + */ +typedef struct rac_memory_info { + uint64_t total_bytes; /**< Total physical memory in bytes */ + uint64_t available_bytes; /**< Available memory in bytes */ + uint64_t used_bytes; /**< Used memory in bytes */ +} rac_memory_info_t; + +// ============================================================================= +// CAPABILITY TYPES +// ============================================================================= + +/** + * Capability types supported by backends. + * These match the capabilities defined in runanywhere-core. + */ +typedef enum rac_capability { + RAC_CAPABILITY_UNKNOWN = 0, + RAC_CAPABILITY_TEXT_GENERATION = 1, /**< LLM text generation */ + RAC_CAPABILITY_EMBEDDINGS = 2, /**< Text embeddings */ + RAC_CAPABILITY_STT = 3, /**< Speech-to-text */ + RAC_CAPABILITY_TTS = 4, /**< Text-to-speech */ + RAC_CAPABILITY_VAD = 5, /**< Voice activity detection */ + RAC_CAPABILITY_DIARIZATION = 6, /**< Speaker diarization */ +} rac_capability_t; + +/** + * Device type for backend execution. + */ +typedef enum rac_device { + RAC_DEVICE_CPU = 0, + RAC_DEVICE_GPU = 1, + RAC_DEVICE_NPU = 2, + RAC_DEVICE_AUTO = 3, +} rac_device_t; + +// ============================================================================= +// LOG LEVELS +// ============================================================================= + +/** + * Log level for the logging callback. + */ +typedef enum rac_log_level { + RAC_LOG_TRACE = 0, + RAC_LOG_DEBUG = 1, + RAC_LOG_INFO = 2, + RAC_LOG_WARNING = 3, + RAC_LOG_ERROR = 4, + RAC_LOG_FATAL = 5, +} rac_log_level_t; + +// ============================================================================= +// VERSION INFO +// ============================================================================= + +/** + * Version information structure. + */ +typedef struct rac_version { + uint16_t major; + uint16_t minor; + uint16_t patch; + const char* string; /**< Version string (e.g., "1.0.0") */ +} rac_version_t; + +// ============================================================================= +// UTILITY MACROS +// ============================================================================= + +/** Check if a result is a success */ +#define RAC_SUCCEEDED(result) ((result) >= 0) + +/** Check if a result is an error */ +#define RAC_FAILED(result) ((result) < 0) + +/** Check if a handle is valid */ +#define RAC_IS_VALID_HANDLE(handle) ((handle) != RAC_INVALID_HANDLE) + +// ============================================================================= +// MEMORY MANAGEMENT +// ============================================================================= + +/** + * Frees memory allocated by RAC functions. + * + * Use this to free strings and buffers returned by RAC functions that + * are marked as "must be freed with rac_free". + * + * @param ptr Pointer to memory to free (can be NULL) + */ +RAC_API void rac_free(void* ptr); + +/** + * Allocates memory using the RAC allocator. + * + * @param size Number of bytes to allocate + * @return Pointer to allocated memory, or NULL on failure + */ +RAC_API void* rac_alloc(size_t size); + +/** + * Duplicates a null-terminated string. + * + * @param str String to duplicate (can be NULL) + * @return Duplicated string (must be freed with rac_free), or NULL if str is NULL + */ +RAC_API char* rac_strdup(const char* str); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_TYPES_H */ diff --git a/sdk/runanywhere-commons/include/rac/features/llm/rac_llm.h b/sdk/runanywhere-commons/include/rac/features/llm/rac_llm.h new file mode 100644 index 000000000..818f0c263 --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/features/llm/rac_llm.h @@ -0,0 +1,17 @@ +/** + * @file rac_llm.h + * @brief RunAnywhere Commons - LLM API (Convenience Header) + * + * This header includes both types and service interface for convenience. + * For better separation of concerns, prefer including: + * - rac_llm_types.h for data structures only + * - rac_llm_service.h for the service interface + */ + +#ifndef RAC_LLM_H +#define RAC_LLM_H + +#include "rac/features/llm/rac_llm_service.h" +#include "rac/features/llm/rac_llm_types.h" + +#endif /* RAC_LLM_H */ diff --git a/sdk/runanywhere-commons/include/rac/features/llm/rac_llm_analytics.h b/sdk/runanywhere-commons/include/rac/features/llm/rac_llm_analytics.h new file mode 100644 index 000000000..392587a34 --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/features/llm/rac_llm_analytics.h @@ -0,0 +1,188 @@ +/** + * @file rac_llm_analytics.h + * @brief LLM Generation analytics service - 1:1 port of GenerationAnalyticsService.swift + * + * Tracks generation operations and metrics. + * Lifecycle events are handled by the lifecycle manager. + * + * NOTE: Token estimation uses ~4 chars/token (approximation, not exact tokenizer count). + * Actual token counts may vary depending on the model's tokenizer and input content. + * + * Swift Source: Sources/RunAnywhere/Features/LLM/Analytics/GenerationAnalyticsService.swift + */ + +#ifndef RAC_LLM_ANALYTICS_H +#define RAC_LLM_ANALYTICS_H + +#include "rac/core/rac_types.h" +#include "rac/features/llm/rac_llm_metrics.h" +#include "rac/features/llm/rac_llm_types.h" +#include "rac/infrastructure/model_management/rac_model_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// TYPES +// ============================================================================= + +/** + * @brief Opaque handle for LLM analytics service + */ +typedef struct rac_llm_analytics_s* rac_llm_analytics_handle_t; + +// Note: rac_generation_metrics_t is defined in rac_llm_metrics.h + +// ============================================================================= +// LIFECYCLE +// ============================================================================= + +/** + * @brief Create an LLM analytics service instance + * + * @param out_handle Output: Handle to the created service + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_analytics_create(rac_llm_analytics_handle_t* out_handle); + +/** + * @brief Destroy an LLM analytics service instance + * + * @param handle Handle to destroy + */ +RAC_API void rac_llm_analytics_destroy(rac_llm_analytics_handle_t handle); + +// ============================================================================= +// GENERATION TRACKING +// ============================================================================= + +/** + * @brief Start tracking a non-streaming generation + * + * Mirrors Swift's startGeneration() + * + * @param handle Analytics service handle + * @param model_id The model ID being used + * @param framework The inference framework type (can be RAC_INFERENCE_FRAMEWORK_UNKNOWN) + * @param temperature Generation temperature (NULL for default) + * @param max_tokens Maximum tokens to generate (NULL for default) + * @param context_length Context window size (NULL for default) + * @param out_generation_id Output: Generated unique ID (owned, must be freed with rac_free) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_analytics_start_generation( + rac_llm_analytics_handle_t handle, const char* model_id, rac_inference_framework_t framework, + const float* temperature, const int32_t* max_tokens, const int32_t* context_length, + char** out_generation_id); + +/** + * @brief Start tracking a streaming generation + * + * Mirrors Swift's startStreamingGeneration() + * + * @param handle Analytics service handle + * @param model_id The model ID being used + * @param framework The inference framework type + * @param temperature Generation temperature (NULL for default) + * @param max_tokens Maximum tokens to generate (NULL for default) + * @param context_length Context window size (NULL for default) + * @param out_generation_id Output: Generated unique ID (owned, must be freed with rac_free) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_analytics_start_streaming_generation( + rac_llm_analytics_handle_t handle, const char* model_id, rac_inference_framework_t framework, + const float* temperature, const int32_t* max_tokens, const int32_t* context_length, + char** out_generation_id); + +/** + * @brief Track first token for streaming generation (TTFT metric) + * + * Only applicable for streaming generations. Call is ignored for non-streaming. + * + * @param handle Analytics service handle + * @param generation_id The generation ID from start_streaming_generation + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_analytics_track_first_token(rac_llm_analytics_handle_t handle, + const char* generation_id); + +/** + * @brief Track streaming update (analytics only) + * + * Only applicable for streaming generations. + * + * @param handle Analytics service handle + * @param generation_id The generation ID + * @param tokens_generated Number of tokens generated so far + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_analytics_track_streaming_update(rac_llm_analytics_handle_t handle, + const char* generation_id, + int32_t tokens_generated); + +/** + * @brief Complete a generation (works for both streaming and non-streaming) + * + * @param handle Analytics service handle + * @param generation_id The generation ID + * @param input_tokens Number of input tokens processed + * @param output_tokens Number of output tokens generated + * @param model_id The model ID used + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_analytics_complete_generation(rac_llm_analytics_handle_t handle, + const char* generation_id, + int32_t input_tokens, + int32_t output_tokens, + const char* model_id); + +/** + * @brief Track generation failure + * + * @param handle Analytics service handle + * @param generation_id The generation ID + * @param error_code Error code + * @param error_message Error message + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_analytics_track_generation_failed(rac_llm_analytics_handle_t handle, + const char* generation_id, + rac_result_t error_code, + const char* error_message); + +/** + * @brief Track an error during LLM operations + * + * @param handle Analytics service handle + * @param error_code Error code + * @param error_message Error message + * @param operation Operation that failed + * @param model_id Model ID (can be NULL) + * @param generation_id Generation ID (can be NULL) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_analytics_track_error(rac_llm_analytics_handle_t handle, + rac_result_t error_code, + const char* error_message, const char* operation, + const char* model_id, const char* generation_id); + +// ============================================================================= +// METRICS +// ============================================================================= + +/** + * @brief Get current analytics metrics + * + * @param handle Analytics service handle + * @param out_metrics Output: Metrics structure + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_analytics_get_metrics(rac_llm_analytics_handle_t handle, + rac_generation_metrics_t* out_metrics); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_LLM_ANALYTICS_H */ diff --git a/sdk/runanywhere-commons/include/rac/features/llm/rac_llm_component.h b/sdk/runanywhere-commons/include/rac/features/llm/rac_llm_component.h new file mode 100644 index 000000000..82ef249d7 --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/features/llm/rac_llm_component.h @@ -0,0 +1,265 @@ +/** + * @file rac_llm_component.h + * @brief RunAnywhere Commons - LLM Capability Component + * + * C port of Swift's LLMCapability.swift from: + * Sources/RunAnywhere/Features/LLM/LLMCapability.swift + * + * Actor-based LLM capability that owns model lifecycle and generation. + * Uses lifecycle manager for unified lifecycle + analytics handling. + */ + +#ifndef RAC_LLM_COMPONENT_H +#define RAC_LLM_COMPONENT_H + +#include "rac/core/capabilities/rac_lifecycle.h" +#include "rac/core/rac_benchmark.h" +#include "rac/core/rac_error.h" +#include "rac/features/llm/rac_llm_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// NOTE: rac_llm_config_t is defined in rac_llm_types.h (included above) + +// ============================================================================= +// STREAMING CALLBACKS - For component-level streaming +// ============================================================================= + +/** + * @brief Streaming callback for token-by-token generation + * + * @param token The generated token + * @param user_data User-provided context + * @return RAC_TRUE to continue, RAC_FALSE to stop + */ +typedef rac_bool_t (*rac_llm_component_token_callback_fn)(const char* token, void* user_data); + +/** + * @brief Streaming completion callback + * + * Called when streaming is complete with final metrics. + * + * @param result Final generation result with metrics + * @param user_data User-provided context + */ +typedef void (*rac_llm_component_complete_callback_fn)(const rac_llm_result_t* result, + void* user_data); + +/** + * @brief Streaming error callback + * + * Called if streaming fails. + * + * @param error_code Error code + * @param error_message Error message + * @param user_data User-provided context + */ +typedef void (*rac_llm_component_error_callback_fn)(rac_result_t error_code, + const char* error_message, void* user_data); + +// ============================================================================= +// LLM COMPONENT API - Mirrors Swift's LLMCapability +// ============================================================================= + +/** + * @brief Create an LLM capability component + * + * Mirrors Swift's LLMCapability.init() + * + * @param out_handle Output: Handle to the component + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_component_create(rac_handle_t* out_handle); + +/** + * @brief Configure the LLM component + * + * Mirrors Swift's LLMCapability.configure(_:) + * + * @param handle Component handle + * @param config Configuration + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_component_configure(rac_handle_t handle, + const rac_llm_config_t* config); + +/** + * @brief Check if model is loaded + * + * Mirrors Swift's LLMCapability.isModelLoaded + * + * @param handle Component handle + * @return RAC_TRUE if loaded, RAC_FALSE otherwise + */ +RAC_API rac_bool_t rac_llm_component_is_loaded(rac_handle_t handle); + +/** + * @brief Get current model ID + * + * Mirrors Swift's LLMCapability.currentModelId + * + * @param handle Component handle + * @return Current model ID (NULL if not loaded) + */ +RAC_API const char* rac_llm_component_get_model_id(rac_handle_t handle); + +/** + * @brief Load a model + * + * Mirrors Swift's LLMCapability.loadModel(_:) + * + * @param handle Component handle + * @param model_path File path to the model (used for loading) - REQUIRED + * @param model_id Model identifier for telemetry (e.g., "smollm2-360m-q8_0") + * Optional: if NULL, defaults to model_path + * @param model_name Human-readable model name (e.g., "SmolLM2 360M Q8_0") + * Optional: if NULL, defaults to model_id + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_component_load_model(rac_handle_t handle, const char* model_path, + const char* model_id, const char* model_name); + +/** + * @brief Unload the current model + * + * Mirrors Swift's LLMCapability.unload() + * + * @param handle Component handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_component_unload(rac_handle_t handle); + +/** + * @brief Cleanup and reset the component + * + * Mirrors Swift's LLMCapability.cleanup() + * + * @param handle Component handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_component_cleanup(rac_handle_t handle); + +/** + * @brief Cancel ongoing generation + * + * Mirrors Swift's LLMCapability.cancel() + * Best-effort cancellation. + * + * @param handle Component handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_component_cancel(rac_handle_t handle); + +/** + * @brief Generate text (non-streaming) + * + * Mirrors Swift's LLMCapability.generate(_:options:) + * + * @param handle Component handle + * @param prompt Input prompt + * @param options Generation options (can be NULL for defaults) + * @param out_result Output: Generation result + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_component_generate(rac_handle_t handle, const char* prompt, + const rac_llm_options_t* options, + rac_llm_result_t* out_result); + +/** + * @brief Check if streaming is supported + * + * Mirrors Swift's LLMCapability.supportsStreaming + * + * @param handle Component handle + * @return RAC_TRUE if streaming supported, RAC_FALSE otherwise + */ +RAC_API rac_bool_t rac_llm_component_supports_streaming(rac_handle_t handle); + +/** + * @brief Generate text with streaming + * + * Mirrors Swift's LLMCapability.generateStream(_:options:) + * + * @param handle Component handle + * @param prompt Input prompt + * @param options Generation options (can be NULL for defaults) + * @param token_callback Called for each generated token + * @param complete_callback Called when generation completes + * @param error_callback Called on error + * @param user_data User context passed to callbacks + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_component_generate_stream( + rac_handle_t handle, const char* prompt, const rac_llm_options_t* options, + rac_llm_component_token_callback_fn token_callback, + rac_llm_component_complete_callback_fn complete_callback, + rac_llm_component_error_callback_fn error_callback, void* user_data); + +/** + * @brief Generate text with streaming and benchmark timing + * + * Same as rac_llm_component_generate_stream but with optional benchmark timing. + * When timing_out is non-NULL, captures detailed timing information: + * - t0: Request start (set at API entry) + * - t4: First token (set in token callback) + * - t6: Request end (set before complete callback) + * + * Backend timestamps (t2, t3, t5) are captured by the backend if it supports timing. + * + * Zero overhead when timing_out is NULL - behaves exactly like generate_stream. + * + * @param handle Component handle + * @param prompt Input prompt + * @param options Generation options (can be NULL for defaults) + * @param token_callback Called for each generated token + * @param complete_callback Called when generation completes + * @param error_callback Called on error + * @param user_data User context passed to callbacks + * @param timing_out Output: Benchmark timing struct, caller-allocated. + * Must remain valid for the duration of the call. + * Caller should initialize via rac_benchmark_timing_init() before passing. + * Component fills t0/t4/t6, backend fills t2/t3/t5. + * On success, all timing fields are populated. + * On failure, status is set but timing fields may be partial. + * Pass NULL to skip timing (zero overhead). + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_component_generate_stream_with_timing( + rac_handle_t handle, const char* prompt, const rac_llm_options_t* options, + rac_llm_component_token_callback_fn token_callback, + rac_llm_component_complete_callback_fn complete_callback, + rac_llm_component_error_callback_fn error_callback, void* user_data, + rac_benchmark_timing_t* timing_out); + +/** + * @brief Get lifecycle state + * + * @param handle Component handle + * @return Current lifecycle state + */ +RAC_API rac_lifecycle_state_t rac_llm_component_get_state(rac_handle_t handle); + +/** + * @brief Get lifecycle metrics + * + * @param handle Component handle + * @param out_metrics Output: Lifecycle metrics + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_component_get_metrics(rac_handle_t handle, + rac_lifecycle_metrics_t* out_metrics); + +/** + * @brief Destroy the LLM component + * + * @param handle Component handle + */ +RAC_API void rac_llm_component_destroy(rac_handle_t handle); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_LLM_COMPONENT_H */ diff --git a/sdk/runanywhere-commons/include/rac/features/llm/rac_llm_events.h b/sdk/runanywhere-commons/include/rac/features/llm/rac_llm_events.h new file mode 100644 index 000000000..74cb5b197 --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/features/llm/rac_llm_events.h @@ -0,0 +1,215 @@ +/** + * @file rac_llm_events.h + * @brief LLM-specific event types - 1:1 port of LLMEvent.swift + * + * All LLM-related events in one place. + * Each event declares its destination (public, analytics, or both). + * + * Swift Source: Sources/RunAnywhere/Features/LLM/Analytics/LLMEvent.swift + */ + +#ifndef RAC_LLM_EVENTS_H +#define RAC_LLM_EVENTS_H + +#include "rac/core/rac_types.h" +#include "rac/infrastructure/events/rac_events.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// LLM EVENT TYPES +// ============================================================================= + +/** + * @brief LLM event types enumeration + * Mirrors Swift's LLMEvent cases + */ +typedef enum rac_llm_event_type { + RAC_LLM_EVENT_MODEL_LOAD_STARTED = 0, + RAC_LLM_EVENT_MODEL_LOAD_COMPLETED, + RAC_LLM_EVENT_MODEL_LOAD_FAILED, + RAC_LLM_EVENT_MODEL_UNLOADED, + RAC_LLM_EVENT_MODEL_UNLOAD_STARTED, + RAC_LLM_EVENT_GENERATION_STARTED, + RAC_LLM_EVENT_FIRST_TOKEN, + RAC_LLM_EVENT_STREAMING_UPDATE, + RAC_LLM_EVENT_GENERATION_COMPLETED, + RAC_LLM_EVENT_GENERATION_FAILED, +} rac_llm_event_type_t; + +// ============================================================================= +// LLM EVENT DATA STRUCTURES +// ============================================================================= + +/** + * @brief Model load event data + */ +typedef struct rac_llm_model_load_event { + const char* model_id; + int64_t model_size_bytes; + rac_inference_framework_t framework; + double duration_ms; /**< Only for completed events */ + rac_result_t error_code; /**< Only for failed events */ + const char* error_message; /**< Only for failed events */ +} rac_llm_model_load_event_t; + +/** + * @brief Generation event data + */ +typedef struct rac_llm_generation_event { + const char* generation_id; + const char* model_id; + rac_bool_t is_streaming; + rac_inference_framework_t framework; + + /** For completed events */ + int32_t input_tokens; + int32_t output_tokens; + double duration_ms; + double tokens_per_second; + double time_to_first_token_ms; /**< -1 if not applicable */ + float temperature; + int32_t max_tokens; + int32_t context_length; + + /** For streaming updates */ + int32_t tokens_generated; + + /** For failed events */ + rac_result_t error_code; + const char* error_message; +} rac_llm_generation_event_t; + +// ============================================================================= +// EVENT PUBLISHING FUNCTIONS +// ============================================================================= + +/** + * @brief Publish a model load started event + * + * @param model_id Model identifier + * @param model_size_bytes Size of model in bytes (0 if unknown) + * @param framework Inference framework + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_event_model_load_started(const char* model_id, + int64_t model_size_bytes, + rac_inference_framework_t framework); + +/** + * @brief Publish a model load completed event + * + * @param model_id Model identifier + * @param duration_ms Load duration in milliseconds + * @param model_size_bytes Size of model in bytes + * @param framework Inference framework + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_event_model_load_completed(const char* model_id, double duration_ms, + int64_t model_size_bytes, + rac_inference_framework_t framework); + +/** + * @brief Publish a model load failed event + * + * @param model_id Model identifier + * @param error_code Error code + * @param error_message Error message + * @param framework Inference framework + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_event_model_load_failed(const char* model_id, rac_result_t error_code, + const char* error_message, + rac_inference_framework_t framework); + +/** + * @brief Publish a model unloaded event + * + * @param model_id Model identifier + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_event_model_unloaded(const char* model_id); + +/** + * @brief Publish a generation started event + * + * @param generation_id Generation identifier + * @param model_id Model identifier + * @param is_streaming Whether this is streaming generation + * @param framework Inference framework + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_event_generation_started(const char* generation_id, + const char* model_id, rac_bool_t is_streaming, + rac_inference_framework_t framework); + +/** + * @brief Publish a first token event (streaming only) + * + * @param generation_id Generation identifier + * @param model_id Model identifier + * @param time_to_first_token_ms Time to first token in milliseconds + * @param framework Inference framework + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_event_first_token(const char* generation_id, const char* model_id, + double time_to_first_token_ms, + rac_inference_framework_t framework); + +/** + * @brief Publish a streaming update event + * + * @param generation_id Generation identifier + * @param tokens_generated Number of tokens generated so far + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_event_streaming_update(const char* generation_id, + int32_t tokens_generated); + +/** + * @brief Publish a generation completed event + * + * @param event Generation event data + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_event_generation_completed(const rac_llm_generation_event_t* event); + +/** + * @brief Publish a generation failed event + * + * @param generation_id Generation identifier + * @param error_code Error code + * @param error_message Error message + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_event_generation_failed(const char* generation_id, + rac_result_t error_code, + const char* error_message); + +// ============================================================================= +// UTILITY FUNCTIONS +// ============================================================================= + +/** + * @brief Get the event type string for an LLM event type + * + * @param event_type The LLM event type + * @return Event type string (never NULL) + */ +RAC_API const char* rac_llm_event_type_string(rac_llm_event_type_t event_type); + +/** + * @brief Get the event destination for an LLM event type + * + * @param event_type The LLM event type + * @return Event destination + */ +RAC_API rac_event_destination_t rac_llm_event_destination(rac_llm_event_type_t event_type); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_LLM_EVENTS_H */ diff --git a/sdk/runanywhere-commons/include/rac/features/llm/rac_llm_metrics.h b/sdk/runanywhere-commons/include/rac/features/llm/rac_llm_metrics.h new file mode 100644 index 000000000..11abcd850 --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/features/llm/rac_llm_metrics.h @@ -0,0 +1,402 @@ +/** + * @file rac_llm_metrics.h + * @brief LLM Streaming Metrics - TTFT and Token Rate Tracking + * + * C port of Swift's StreamingMetricsCollector and GenerationAnalyticsService. + * Swift Source: Sources/RunAnywhere/Features/LLM/LLMCapability.swift (StreamingMetricsCollector) + * Swift Source: Sources/RunAnywhere/Features/LLM/Analytics/GenerationAnalyticsService.swift + * + * IMPORTANT: This is a direct translation of the Swift implementation. + * Do NOT add features not present in the Swift code. + */ + +#ifndef RAC_LLM_METRICS_H +#define RAC_LLM_METRICS_H + +#include "rac/core/rac_error.h" +#include "rac/core/rac_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// TYPES - Mirrors Swift's GenerationMetrics and StreamingMetricsCollector +// ============================================================================= + +/** + * @brief Generation metrics snapshot. + * Mirrors Swift's GenerationMetrics struct. + */ +typedef struct rac_generation_metrics { + /** Total generation count */ + int32_t total_generations; + + /** Streaming generation count */ + int32_t streaming_generations; + + /** Non-streaming generation count */ + int32_t non_streaming_generations; + + /** Average time-to-first-token in ms (streaming only) */ + double average_ttft_ms; + + /** Average tokens per second */ + double average_tokens_per_second; + + /** Total input tokens processed */ + int64_t total_input_tokens; + + /** Total output tokens generated */ + int64_t total_output_tokens; + + /** Service start time (Unix timestamp ms) */ + int64_t start_time_ms; + + /** Last event time (Unix timestamp ms) */ + int64_t last_event_time_ms; +} rac_generation_metrics_t; + +/** + * @brief Default generation metrics. + */ +static const rac_generation_metrics_t RAC_GENERATION_METRICS_DEFAULT = { + .total_generations = 0, + .streaming_generations = 0, + .non_streaming_generations = 0, + .average_ttft_ms = 0.0, + .average_tokens_per_second = 0.0, + .total_input_tokens = 0, + .total_output_tokens = 0, + .start_time_ms = 0, + .last_event_time_ms = 0}; + +/** + * @brief Streaming generation result. + * Mirrors Swift's LLMGenerationResult for streaming. + */ +typedef struct rac_streaming_result { + /** Generated text (owned, must be freed) */ + char* text; + + /** Thinking/reasoning content if any (owned, must be freed, can be NULL) */ + char* thinking_content; + + /** Input tokens processed */ + int32_t input_tokens; + + /** Output tokens generated */ + int32_t output_tokens; + + /** Model ID used (owned, must be freed) */ + char* model_id; + + /** Total latency in milliseconds */ + double latency_ms; + + /** Tokens generated per second */ + double tokens_per_second; + + /** Time-to-first-token in milliseconds (0 if not streaming) */ + double ttft_ms; + + /** Thinking tokens (for reasoning models) */ + int32_t thinking_tokens; + + /** Response tokens (excluding thinking) */ + int32_t response_tokens; +} rac_streaming_result_t; + +/** + * @brief Default streaming result. + */ +static const rac_streaming_result_t RAC_STREAMING_RESULT_DEFAULT = {.text = RAC_NULL, + .thinking_content = RAC_NULL, + .input_tokens = 0, + .output_tokens = 0, + .model_id = RAC_NULL, + .latency_ms = 0.0, + .tokens_per_second = 0.0, + .ttft_ms = 0.0, + .thinking_tokens = 0, + .response_tokens = 0}; + +// ============================================================================= +// OPAQUE HANDLES +// ============================================================================= + +/** + * @brief Opaque handle for streaming metrics collector. + */ +typedef struct rac_streaming_metrics_collector* rac_streaming_metrics_handle_t; + +/** + * @brief Opaque handle for generation analytics service. + */ +typedef struct rac_generation_analytics* rac_generation_analytics_handle_t; + +// ============================================================================= +// STREAMING METRICS COLLECTOR API - Mirrors Swift's StreamingMetricsCollector +// ============================================================================= + +/** + * @brief Create a streaming metrics collector. + * + * @param model_id Model ID being used + * @param generation_id Unique generation identifier + * @param prompt_length Length of input prompt (for token estimation) + * @param out_handle Output: Handle to the created collector + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_streaming_metrics_create(const char* model_id, const char* generation_id, + int32_t prompt_length, + rac_streaming_metrics_handle_t* out_handle); + +/** + * @brief Destroy a streaming metrics collector. + * + * @param handle Collector handle + */ +RAC_API void rac_streaming_metrics_destroy(rac_streaming_metrics_handle_t handle); + +/** + * @brief Mark the start of generation. + * + * Mirrors Swift's StreamingMetricsCollector.markStart(). + * + * @param handle Collector handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_streaming_metrics_mark_start(rac_streaming_metrics_handle_t handle); + +/** + * @brief Record a token received during streaming. + * + * Mirrors Swift's StreamingMetricsCollector.recordToken(_:). + * First call records TTFT. + * + * @param handle Collector handle + * @param token Token string received + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_streaming_metrics_record_token(rac_streaming_metrics_handle_t handle, + const char* token); + +/** + * @brief Mark generation as complete. + * + * Mirrors Swift's StreamingMetricsCollector.markComplete(). + * + * @param handle Collector handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_streaming_metrics_mark_complete(rac_streaming_metrics_handle_t handle); + +/** + * @brief Mark generation as failed. + * + * Mirrors Swift's StreamingMetricsCollector.recordError(_:). + * + * @param handle Collector handle + * @param error_code Error code + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_streaming_metrics_mark_failed(rac_streaming_metrics_handle_t handle, + rac_result_t error_code); + +/** + * @brief Get the generation result. + * + * Mirrors Swift's StreamingMetricsCollector.buildResult(). + * Only valid after markComplete() is called. + * + * @param handle Collector handle + * @param out_result Output: Streaming result (must be freed with rac_streaming_result_free) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_streaming_metrics_get_result(rac_streaming_metrics_handle_t handle, + rac_streaming_result_t* out_result); + +/** + * @brief Get current TTFT in milliseconds. + * + * @param handle Collector handle + * @param out_ttft_ms Output: TTFT in ms (0 if first token not yet received) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_streaming_metrics_get_ttft(rac_streaming_metrics_handle_t handle, + double* out_ttft_ms); + +/** + * @brief Get current token count. + * + * @param handle Collector handle + * @param out_token_count Output: Number of tokens recorded + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_streaming_metrics_get_token_count(rac_streaming_metrics_handle_t handle, + int32_t* out_token_count); + +/** + * @brief Get accumulated text. + * + * @param handle Collector handle + * @param out_text Output: Accumulated text (owned, must be freed) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_streaming_metrics_get_text(rac_streaming_metrics_handle_t handle, + char** out_text); + +/** + * @brief Set actual token counts from backend. + * + * Call this with actual token counts from the LLM backend's tokenizer + * to get accurate telemetry instead of character-based estimation. + * + * @param handle Collector handle + * @param input_tokens Actual input/prompt token count (0 to use estimation) + * @param output_tokens Actual output/completion token count (0 to use estimation) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_streaming_metrics_set_token_counts(rac_streaming_metrics_handle_t handle, + int32_t input_tokens, + int32_t output_tokens); + +// ============================================================================= +// GENERATION ANALYTICS SERVICE API - Mirrors Swift's GenerationAnalyticsService +// ============================================================================= + +/** + * @brief Create a generation analytics service. + * + * @param out_handle Output: Handle to the created service + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_generation_analytics_create(rac_generation_analytics_handle_t* out_handle); + +/** + * @brief Destroy a generation analytics service. + * + * @param handle Service handle + */ +RAC_API void rac_generation_analytics_destroy(rac_generation_analytics_handle_t handle); + +/** + * @brief Start tracking a non-streaming generation. + * + * Mirrors Swift's GenerationAnalyticsService.startGeneration(). + * + * @param handle Service handle + * @param generation_id Unique generation identifier + * @param model_id Model ID + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_generation_analytics_start(rac_generation_analytics_handle_t handle, + const char* generation_id, + const char* model_id); + +/** + * @brief Start tracking a streaming generation. + * + * Mirrors Swift's GenerationAnalyticsService.startStreamingGeneration(). + * + * @param handle Service handle + * @param generation_id Unique generation identifier + * @param model_id Model ID + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_generation_analytics_start_streaming( + rac_generation_analytics_handle_t handle, const char* generation_id, const char* model_id); + +/** + * @brief Track first token received (streaming only). + * + * Mirrors Swift's GenerationAnalyticsService.trackFirstToken(). + * + * @param handle Service handle + * @param generation_id Generation identifier + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_generation_analytics_track_first_token( + rac_generation_analytics_handle_t handle, const char* generation_id); + +/** + * @brief Track streaming update. + * + * Mirrors Swift's GenerationAnalyticsService.trackStreamingUpdate(). + * + * @param handle Service handle + * @param generation_id Generation identifier + * @param tokens_generated Number of tokens generated so far + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_generation_analytics_track_streaming_update( + rac_generation_analytics_handle_t handle, const char* generation_id, int32_t tokens_generated); + +/** + * @brief Complete a generation. + * + * Mirrors Swift's GenerationAnalyticsService.completeGeneration(). + * + * @param handle Service handle + * @param generation_id Generation identifier + * @param input_tokens Number of input tokens + * @param output_tokens Number of output tokens + * @param model_id Model ID used + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_generation_analytics_complete(rac_generation_analytics_handle_t handle, + const char* generation_id, + int32_t input_tokens, int32_t output_tokens, + const char* model_id); + +/** + * @brief Track generation failure. + * + * Mirrors Swift's GenerationAnalyticsService.trackGenerationFailed(). + * + * @param handle Service handle + * @param generation_id Generation identifier + * @param error_code Error code + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_generation_analytics_track_failed(rac_generation_analytics_handle_t handle, + const char* generation_id, + rac_result_t error_code); + +/** + * @brief Get aggregated metrics. + * + * Mirrors Swift's GenerationAnalyticsService.getMetrics(). + * + * @param handle Service handle + * @param out_metrics Output: Generation metrics + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_generation_analytics_get_metrics(rac_generation_analytics_handle_t handle, + rac_generation_metrics_t* out_metrics); + +/** + * @brief Reset metrics. + * + * @param handle Service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_generation_analytics_reset(rac_generation_analytics_handle_t handle); + +// ============================================================================= +// MEMORY MANAGEMENT +// ============================================================================= + +/** + * @brief Free a streaming result. + * + * @param result Result to free + */ +RAC_API void rac_streaming_result_free(rac_streaming_result_t* result); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_LLM_METRICS_H */ diff --git a/sdk/runanywhere-commons/include/rac/features/llm/rac_llm_service.h b/sdk/runanywhere-commons/include/rac/features/llm/rac_llm_service.h new file mode 100644 index 000000000..9b9960db1 --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/features/llm/rac_llm_service.h @@ -0,0 +1,205 @@ +/** + * @file rac_llm_service.h + * @brief RunAnywhere Commons - LLM Service Interface + * + * Defines the generic LLM service API and vtable for multi-backend dispatch. + * Backends (LlamaCpp, Platform, ONNX) implement the vtable and register + * with the service registry. + */ + +#ifndef RAC_LLM_SERVICE_H +#define RAC_LLM_SERVICE_H + +#include "rac/core/rac_benchmark.h" +#include "rac/core/rac_error.h" +#include "rac/features/llm/rac_llm_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// SERVICE VTABLE - Backend implementations provide this +// ============================================================================= + +/** + * LLM Service operations vtable. + * Each backend implements these functions and provides a static vtable. + */ +typedef struct rac_llm_service_ops { + /** Initialize the service with a model path */ + rac_result_t (*initialize)(void* impl, const char* model_path); + + /** Generate text (blocking) */ + rac_result_t (*generate)(void* impl, const char* prompt, const rac_llm_options_t* options, + rac_llm_result_t* out_result); + + /** Generate text with streaming callback */ + rac_result_t (*generate_stream)(void* impl, const char* prompt, + const rac_llm_options_t* options, + rac_llm_stream_callback_fn callback, void* user_data); + + /** + * Generate text with streaming callback and benchmark timing. + * Optional: backends that don't support timing can leave this NULL. + * If NULL, rac_llm_generate_stream_with_timing falls back to generate_stream. + * + * Backends that implement this should capture: + * - t2: Before prefill (llama_decode for prompt) + * - t3: After prefill completes + * - t5: When decode loop exits (last token) + */ + rac_result_t (*generate_stream_with_timing)(void* impl, const char* prompt, + const rac_llm_options_t* options, + rac_llm_stream_callback_fn callback, void* user_data, + rac_benchmark_timing_t* timing_out); + + /** Get service info */ + rac_result_t (*get_info)(void* impl, rac_llm_info_t* out_info); + + /** Cancel ongoing generation */ + rac_result_t (*cancel)(void* impl); + + /** Cleanup/unload model (keeps service alive) */ + rac_result_t (*cleanup)(void* impl); + + /** Destroy the service */ + void (*destroy)(void* impl); +} rac_llm_service_ops_t; + +/** + * LLM Service instance. + * Contains vtable pointer and backend-specific implementation. + */ +typedef struct rac_llm_service { + /** Vtable with backend operations */ + const rac_llm_service_ops_t* ops; + + /** Backend-specific implementation handle */ + void* impl; + + /** Model ID for reference */ + const char* model_id; +} rac_llm_service_t; + +// ============================================================================= +// PUBLIC API - Generic service functions +// ============================================================================= + +/** + * @brief Create an LLM service + * + * Routes through service registry to find appropriate backend. + * + * @param model_id Model identifier (registry ID or path to model file) + * @param out_handle Output: Handle to the created service + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_create(const char* model_id, rac_handle_t* out_handle); + +/** + * @brief Initialize an LLM service + * + * @param handle Service handle + * @param model_path Path to the model file (can be NULL) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_initialize(rac_handle_t handle, const char* model_path); + +/** + * @brief Generate text from prompt + * + * @param handle Service handle + * @param prompt Input prompt + * @param options Generation options (can be NULL for defaults) + * @param out_result Output: Generation result (caller must free with rac_llm_result_free) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_generate(rac_handle_t handle, const char* prompt, + const rac_llm_options_t* options, + rac_llm_result_t* out_result); + +/** + * @brief Stream generate text token by token + * + * @param handle Service handle + * @param prompt Input prompt + * @param options Generation options (can be NULL for defaults) + * @param callback Callback for each token + * @param user_data User context passed to callback + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_generate_stream(rac_handle_t handle, const char* prompt, + const rac_llm_options_t* options, + rac_llm_stream_callback_fn callback, void* user_data); + +/** + * @brief Stream generate text with benchmark timing + * + * Same as rac_llm_generate_stream but with optional benchmark timing. + * If timing_out is non-NULL and the backend supports timing, captures: + * - t2: Before prefill + * - t3: After prefill + * - t5: Last token generated + * + * If the backend doesn't implement generate_stream_with_timing, falls back + * to generate_stream (timing_out will have t2/t3/t5 as zeros). + * + * @param handle Service handle + * @param prompt Input prompt + * @param options Generation options (can be NULL for defaults) + * @param callback Callback for each token + * @param user_data User context passed to callback + * @param timing_out Output: Benchmark timing (can be NULL for no timing) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_generate_stream_with_timing(rac_handle_t handle, const char* prompt, + const rac_llm_options_t* options, + rac_llm_stream_callback_fn callback, + void* user_data, + rac_benchmark_timing_t* timing_out); + +/** + * @brief Get service information + * + * @param handle Service handle + * @param out_info Output: Service information + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_get_info(rac_handle_t handle, rac_llm_info_t* out_info); + +/** + * @brief Cancel ongoing generation + * + * @param handle Service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_cancel(rac_handle_t handle); + +/** + * @brief Cleanup and release model resources + * + * @param handle Service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_cleanup(rac_handle_t handle); + +/** + * @brief Destroy an LLM service instance + * + * @param handle Service handle to destroy + */ +RAC_API void rac_llm_destroy(rac_handle_t handle); + +/** + * @brief Free an LLM result + * + * @param result Result to free + */ +RAC_API void rac_llm_result_free(rac_llm_result_t* result); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_LLM_SERVICE_H */ diff --git a/sdk/runanywhere-commons/include/rac/features/llm/rac_llm_structured_output.h b/sdk/runanywhere-commons/include/rac/features/llm/rac_llm_structured_output.h new file mode 100644 index 000000000..b50958275 --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/features/llm/rac_llm_structured_output.h @@ -0,0 +1,141 @@ +/** + * @file rac_llm_structured_output.h + * @brief RunAnywhere Commons - LLM Structured Output JSON Parsing + * + * C port of Swift's StructuredOutputHandler.swift from: + * Sources/RunAnywhere/Features/LLM/StructuredOutput/StructuredOutputHandler.swift + * + * IMPORTANT: This is a direct translation of the Swift implementation. + * Do NOT add features not present in the Swift code. + * + * Provides JSON extraction and parsing functions for structured output generation. + */ + +#ifndef RAC_LLM_STRUCTURED_OUTPUT_H +#define RAC_LLM_STRUCTURED_OUTPUT_H + +#include "rac/core/rac_error.h" +#include "rac/core/rac_types.h" +#include "rac/features/llm/rac_llm_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// STRUCTURED OUTPUT API +// ============================================================================= + +/** + * @brief Extract JSON from potentially mixed text + * + * Ported from Swift StructuredOutputHandler.extractJSON(from:) (lines 102-132) + * + * Searches for complete JSON objects or arrays in the given text, + * handling cases where the text contains additional content before/after JSON. + * + * @param text Input text that may contain JSON mixed with other content + * @param out_json Output: Allocated JSON string (caller must free with rac_free) + * @param out_length Output: Length of extracted JSON string (can be NULL) + * @return RAC_SUCCESS if JSON found and extracted, error code otherwise + */ +RAC_API rac_result_t rac_structured_output_extract_json(const char* text, char** out_json, + size_t* out_length); + +/** + * @brief Find complete JSON boundaries in text + * + * Ported from Swift StructuredOutputHandler.findCompleteJSON(in:) (lines 135-176) + * + * Uses a character-by-character state machine to find matching braces/brackets + * while properly handling string escapes and nesting. + * + * @param text Text to search for JSON + * @param out_start Output: Start position of JSON (0-indexed) + * @param out_end Output: End position of JSON (exclusive) + * @return RAC_TRUE if complete JSON found, RAC_FALSE otherwise + */ +RAC_API rac_bool_t rac_structured_output_find_complete_json(const char* text, size_t* out_start, + size_t* out_end); + +/** + * @brief Find matching closing brace for an opening brace + * + * Ported from Swift StructuredOutputHandler.findMatchingBrace(in:startingFrom:) (lines 179-212) + * + * @param text Text to search + * @param start_pos Position of opening brace '{' + * @param out_end_pos Output: Position of matching closing brace '}' + * @return RAC_TRUE if matching brace found, RAC_FALSE otherwise + */ +RAC_API rac_bool_t rac_structured_output_find_matching_brace(const char* text, size_t start_pos, + size_t* out_end_pos); + +/** + * @brief Find matching closing bracket for an opening bracket + * + * Ported from Swift StructuredOutputHandler.findMatchingBracket(in:startingFrom:) (lines 215-248) + * + * @param text Text to search + * @param start_pos Position of opening bracket '[' + * @param out_end_pos Output: Position of matching closing bracket ']' + * @return RAC_TRUE if matching bracket found, RAC_FALSE otherwise + */ +RAC_API rac_bool_t rac_structured_output_find_matching_bracket(const char* text, size_t start_pos, + size_t* out_end_pos); + +/** + * @brief Prepare prompt with structured output instructions + * + * Ported from Swift StructuredOutputHandler.preparePrompt(originalPrompt:config:) (lines 43-82) + * + * Adds JSON schema and generation instructions to the prompt. + * + * @param original_prompt Original user prompt + * @param config Structured output configuration with JSON schema + * @param out_prompt Output: Allocated prepared prompt (caller must free with rac_free) + * @return RAC_SUCCESS on success, error code otherwise + */ +RAC_API rac_result_t rac_structured_output_prepare_prompt( + const char* original_prompt, const rac_structured_output_config_t* config, char** out_prompt); + +/** + * @brief Get system prompt for structured output generation + * + * Ported from Swift StructuredOutputHandler.getSystemPrompt(for:) (lines 10-30) + * + * Generates a system prompt instructing the model to output only valid JSON. + * + * @param json_schema JSON schema describing expected output structure + * @param out_prompt Output: Allocated system prompt (caller must free with rac_free) + * @return RAC_SUCCESS on success, error code otherwise + */ +RAC_API rac_result_t rac_structured_output_get_system_prompt(const char* json_schema, + char** out_prompt); + +/** + * @brief Validate that text contains valid structured output + * + * Ported from Swift StructuredOutputHandler.validateStructuredOutput(text:config:) (lines 264-282) + * + * @param text Text to validate + * @param config Structured output configuration (can be NULL for basic validation) + * @param out_validation Output: Validation result (caller must free extracted_json with rac_free) + * @return RAC_SUCCESS on success, error code otherwise + */ +RAC_API rac_result_t +rac_structured_output_validate(const char* text, const rac_structured_output_config_t* config, + rac_structured_output_validation_t* out_validation); + +/** + * @brief Free structured output validation result + * + * @param validation Validation result to free + */ +RAC_API void rac_structured_output_validation_free(rac_structured_output_validation_t* validation); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_LLM_STRUCTURED_OUTPUT_H */ diff --git a/sdk/runanywhere-commons/include/rac/features/llm/rac_llm_types.h b/sdk/runanywhere-commons/include/rac/features/llm/rac_llm_types.h new file mode 100644 index 000000000..e3755f851 --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/features/llm/rac_llm_types.h @@ -0,0 +1,384 @@ +/** + * @file rac_llm_types.h + * @brief RunAnywhere Commons - LLM Types and Data Structures + * + * C port of Swift's LLM Models from: + * Sources/RunAnywhere/Features/LLM/Models/LLMGenerationOptions.swift + * Sources/RunAnywhere/Features/LLM/Models/LLMGenerationResult.swift + * + * This header defines data structures only. For the service interface, + * see rac_llm_service.h. + */ + +#ifndef RAC_LLM_TYPES_H +#define RAC_LLM_TYPES_H + +#include "rac/core/rac_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// CONFIGURATION - Mirrors Swift's LLMConfiguration +// ============================================================================= + +/** + * @brief LLM component configuration + * + * Mirrors Swift's LLMConfiguration struct exactly. + * See: Sources/RunAnywhere/Features/LLM/Models/LLMConfiguration.swift + */ +typedef struct rac_llm_config { + /** Model ID (optional - uses default if NULL) */ + const char* model_id; + + /** Preferred framework for generation (use RAC_FRAMEWORK_UNKNOWN for auto) */ + int32_t preferred_framework; + + /** Context length - max tokens the model can handle (default: 2048) */ + int32_t context_length; + + /** Temperature for sampling (0.0 - 2.0, default: 0.7) */ + float temperature; + + /** Maximum tokens to generate (default: 100) */ + int32_t max_tokens; + + /** System prompt for generation (can be NULL) */ + const char* system_prompt; + + /** Enable streaming mode (default: true) */ + rac_bool_t streaming_enabled; +} rac_llm_config_t; + +/** + * @brief Default LLM configuration + */ +static const rac_llm_config_t RAC_LLM_CONFIG_DEFAULT = {.model_id = RAC_NULL, + .preferred_framework = + 99, // RAC_FRAMEWORK_UNKNOWN + .context_length = 2048, + .temperature = 0.7f, + .max_tokens = 100, + .system_prompt = RAC_NULL, + .streaming_enabled = RAC_TRUE}; + +// ============================================================================= +// OPTIONS - Mirrors Swift's LLMGenerationOptions +// ============================================================================= + +/** + * @brief LLM generation options + * + * Mirrors Swift's LLMGenerationOptions struct exactly. + * See: Sources/RunAnywhere/Features/LLM/Models/LLMGenerationOptions.swift + */ +typedef struct rac_llm_options { + /** Maximum number of tokens to generate (default: 100) */ + int32_t max_tokens; + + /** Temperature for sampling (0.0 - 2.0, default: 0.8) */ + float temperature; + + /** Top-p sampling parameter (default: 1.0) */ + float top_p; + + /** Stop sequences (null-terminated array, can be NULL) */ + const char* const* stop_sequences; + size_t num_stop_sequences; + + /** Enable streaming mode (default: false) */ + rac_bool_t streaming_enabled; + + /** System prompt (can be NULL) */ + const char* system_prompt; +} rac_llm_options_t; + +/** + * @brief Default LLM generation options + */ +static const rac_llm_options_t RAC_LLM_OPTIONS_DEFAULT = {.max_tokens = 100, + .temperature = 0.8f, + .top_p = 1.0f, + .stop_sequences = RAC_NULL, + .num_stop_sequences = 0, + .streaming_enabled = RAC_FALSE, + .system_prompt = RAC_NULL}; + +// ============================================================================= +// RESULT - Mirrors Swift's LLMGenerationResult +// ============================================================================= + +/** + * @brief LLM generation result + */ +typedef struct rac_llm_result { + /** Generated text (owned, must be freed with rac_free) */ + char* text; + + /** Number of tokens in prompt */ + int32_t prompt_tokens; + + /** Number of tokens generated */ + int32_t completion_tokens; + + /** Total tokens (prompt + completion) */ + int32_t total_tokens; + + /** Time to first token in milliseconds */ + int64_t time_to_first_token_ms; + + /** Total generation time in milliseconds */ + int64_t total_time_ms; + + /** Tokens per second */ + float tokens_per_second; +} rac_llm_result_t; + +// ============================================================================= +// INFO - Mirrors Swift's LLMService properties +// ============================================================================= + +/** + * @brief LLM service handle info + * + * Mirrors Swift's LLMService properties. + */ +typedef struct rac_llm_info { + /** Whether the service is ready for generation (isReady) */ + rac_bool_t is_ready; + + /** Current model identifier (currentModel, can be NULL) */ + const char* current_model; + + /** Context length (contextLength, 0 if unknown) */ + int32_t context_length; + + /** Whether streaming is supported (supportsStreaming) */ + rac_bool_t supports_streaming; +} rac_llm_info_t; + +// ============================================================================= +// CALLBACKS +// ============================================================================= + +/** + * @brief LLM streaming callback + * + * Called for each generated token during streaming. + * Mirrors Swift's onToken callback pattern. + * + * @param token The generated token string + * @param user_data User-provided context + * @return RAC_TRUE to continue, RAC_FALSE to stop generation + */ +typedef rac_bool_t (*rac_llm_stream_callback_fn)(const char* token, void* user_data); + +// ============================================================================= +// THINKING TAG PATTERN - Mirrors Swift's ThinkingTagPattern +// ============================================================================= + +/** + * @brief Pattern for extracting thinking/reasoning content from model output + * + * Mirrors Swift's ThinkingTagPattern struct exactly. + * See: Sources/RunAnywhere/Features/LLM/Models/ThinkingTagPattern.swift + */ +typedef struct rac_thinking_tag_pattern { + /** Opening tag for thinking content (e.g., "") */ + const char* opening_tag; + + /** Closing tag for thinking content (e.g., "") */ + const char* closing_tag; +} rac_thinking_tag_pattern_t; + +/** + * @brief Default thinking tag pattern (DeepSeek/Hermes style) + */ +static const rac_thinking_tag_pattern_t RAC_THINKING_TAG_DEFAULT = {.opening_tag = "", + .closing_tag = ""}; + +/** + * @brief Alternative thinking pattern with full word + */ +static const rac_thinking_tag_pattern_t RAC_THINKING_TAG_FULL = {.opening_tag = "", + .closing_tag = ""}; + +// ============================================================================= +// STRUCTURED OUTPUT - Mirrors Swift's StructuredOutputConfig +// ============================================================================= + +/** + * @brief Structured output configuration + * + * Mirrors Swift's StructuredOutputConfig struct. + * See: Sources/RunAnywhere/Features/LLM/StructuredOutput/Generatable.swift + * + * Note: In C, we pass the JSON schema directly instead of using reflection. + */ +typedef struct rac_structured_output_config { + /** JSON schema for the expected output structure */ + const char* json_schema; + + /** Whether to include the schema in the prompt */ + rac_bool_t include_schema_in_prompt; +} rac_structured_output_config_t; + +/** + * @brief Default structured output configuration + */ +static const rac_structured_output_config_t RAC_STRUCTURED_OUTPUT_DEFAULT = { + .json_schema = RAC_NULL, .include_schema_in_prompt = RAC_TRUE}; + +/** + * @brief Structured output validation result + * + * Mirrors Swift's StructuredOutputValidation struct. + */ +typedef struct rac_structured_output_validation { + /** Whether the output is valid according to the schema */ + rac_bool_t is_valid; + + /** Error message if validation failed (can be NULL) */ + const char* error_message; + + /** Extracted JSON string (can be NULL) */ + char* extracted_json; +} rac_structured_output_validation_t; + +// ============================================================================= +// STREAMING RESULT - Mirrors Swift's LLMStreamingResult +// ============================================================================= + +/** + * @brief Token event during streaming + * + * Provides detailed information about each token during streaming generation. + */ +typedef struct rac_llm_token_event { + /** The generated token text */ + const char* token; + + /** Token index in the sequence */ + int32_t token_index; + + /** Is this the final token? */ + rac_bool_t is_final; + + /** Tokens generated per second so far */ + float tokens_per_second; +} rac_llm_token_event_t; + +/** + * @brief Extended streaming callback with token event details + * + * @param event Token event details + * @param user_data User-provided context + * @return RAC_TRUE to continue, RAC_FALSE to stop generation + */ +typedef rac_bool_t (*rac_llm_token_event_callback_fn)(const rac_llm_token_event_t* event, + void* user_data); + +/** + * @brief Streaming result handle + * + * Opaque handle for managing streaming generation. + * In C++, this wraps the streaming state and provides synchronization. + * + * Note: LLMStreamingResult in Swift returns an AsyncThrowingStream and a Task. + * In C, we use callbacks instead of async streams. + */ +typedef void* rac_llm_stream_handle_t; + +/** + * @brief Streaming generation parameters + * + * Configuration for starting a streaming generation. + */ +typedef struct rac_llm_stream_params { + /** Prompt to generate from */ + const char* prompt; + + /** Generation options */ + rac_llm_options_t options; + + /** Callback for each token */ + rac_llm_stream_callback_fn on_token; + + /** Extended callback with token event details (optional, can be NULL) */ + rac_llm_token_event_callback_fn on_token_event; + + /** User data passed to callbacks */ + void* user_data; + + /** Optional thinking tag pattern to extract thinking content */ + const rac_thinking_tag_pattern_t* thinking_pattern; +} rac_llm_stream_params_t; + +/** + * @brief Streaming generation metrics + * + * Metrics collected during streaming generation. + */ +typedef struct rac_llm_stream_metrics { + /** Time to first token in milliseconds */ + int64_t time_to_first_token_ms; + + /** Total generation time in milliseconds */ + int64_t total_time_ms; + + /** Number of tokens generated */ + int32_t tokens_generated; + + /** Tokens per second */ + float tokens_per_second; + + /** Number of tokens in the prompt */ + int32_t prompt_tokens; + + /** Thinking tokens if thinking pattern was used */ + int32_t thinking_tokens; + + /** Response tokens (excluding thinking) */ + int32_t response_tokens; +} rac_llm_stream_metrics_t; + +/** + * @brief Complete streaming result + * + * Final result after streaming generation is complete. + */ +typedef struct rac_llm_stream_result { + /** Full generated text (owned, must be freed with rac_free) */ + char* text; + + /** Extracted thinking content if pattern was provided (can be NULL) */ + char* thinking_content; + + /** Generation metrics */ + rac_llm_stream_metrics_t metrics; + + /** Error code if generation failed (RAC_SUCCESS on success) */ + rac_result_t error_code; + + /** Error message if generation failed (can be NULL) */ + char* error_message; +} rac_llm_stream_result_t; + +// ============================================================================= +// MEMORY MANAGEMENT +// ============================================================================= + +/** + * @brief Free LLM result resources + * + * @param result Result to free (can be NULL) + */ +RAC_API void rac_llm_result_free(rac_llm_result_t* result); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_LLM_TYPES_H */ diff --git a/sdk/runanywhere-commons/include/rac/features/platform/rac_llm_platform.h b/sdk/runanywhere-commons/include/rac/features/platform/rac_llm_platform.h new file mode 100644 index 000000000..c1f9d5bd2 --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/features/platform/rac_llm_platform.h @@ -0,0 +1,204 @@ +/** + * @file rac_llm_platform.h + * @brief RunAnywhere Commons - Platform LLM Backend (Apple Foundation Models) + * + * C API for platform-native LLM services. On Apple platforms, this uses + * Foundation Models (Apple Intelligence). The actual implementation is in + * Swift, with C++ providing the registration and callback infrastructure. + * + * This backend follows the same pattern as LlamaCPP and ONNX backends, + * but delegates to Swift via function pointer callbacks since Foundation + * Models is a Swift-only framework. + */ + +#ifndef RAC_LLM_PLATFORM_H +#define RAC_LLM_PLATFORM_H + +#include "rac/core/rac_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// TYPES +// ============================================================================= + +/** Opaque handle to platform LLM service */ +typedef struct rac_llm_platform* rac_llm_platform_handle_t; + +/** + * Platform LLM configuration. + * Passed during initialization. + */ +typedef struct rac_llm_platform_config { + /** Reserved for future use */ + void* reserved; +} rac_llm_platform_config_t; + +/** + * Generation options for platform LLM. + */ +typedef struct rac_llm_platform_options { + /** Temperature for sampling (0.0 = deterministic, 1.0 = creative) */ + float temperature; + /** Maximum tokens to generate */ + int32_t max_tokens; + /** Reserved for future options */ + void* reserved; +} rac_llm_platform_options_t; + +// ============================================================================= +// SWIFT CALLBACK TYPES +// ============================================================================= + +/** + * Callback to check if platform LLM can handle a model ID. + * Implemented in Swift. + * + * @param model_id Model identifier to check (can be NULL) + * @param user_data User-provided context + * @return RAC_TRUE if this backend can handle the model + */ +typedef rac_bool_t (*rac_platform_llm_can_handle_fn)(const char* model_id, void* user_data); + +/** + * Callback to create platform LLM service. + * Implemented in Swift. + * + * @param model_path Path to model (ignored for built-in) + * @param config Configuration options + * @param user_data User-provided context + * @return Handle to created service (Swift object pointer), or NULL on failure + */ +typedef rac_handle_t (*rac_platform_llm_create_fn)(const char* model_path, + const rac_llm_platform_config_t* config, + void* user_data); + +/** + * Callback to generate text. + * Implemented in Swift. + * + * @param handle Service handle from create + * @param prompt Input prompt + * @param options Generation options + * @param out_response Output: Generated text (caller must free) + * @param user_data User-provided context + * @return RAC_SUCCESS or error code + */ +typedef rac_result_t (*rac_platform_llm_generate_fn)(rac_handle_t handle, const char* prompt, + const rac_llm_platform_options_t* options, + char** out_response, void* user_data); + +/** + * Callback to destroy platform LLM service. + * Implemented in Swift. + * + * @param handle Service handle to destroy + * @param user_data User-provided context + */ +typedef void (*rac_platform_llm_destroy_fn)(rac_handle_t handle, void* user_data); + +/** + * Swift callbacks for platform LLM operations. + */ +typedef struct rac_platform_llm_callbacks { + rac_platform_llm_can_handle_fn can_handle; + rac_platform_llm_create_fn create; + rac_platform_llm_generate_fn generate; + rac_platform_llm_destroy_fn destroy; + void* user_data; +} rac_platform_llm_callbacks_t; + +// ============================================================================= +// CALLBACK REGISTRATION +// ============================================================================= + +/** + * Sets the Swift callbacks for platform LLM operations. + * Must be called before using platform LLM services. + * + * @param callbacks Callback functions (copied internally) + * @return RAC_SUCCESS on success + */ +RAC_API rac_result_t rac_platform_llm_set_callbacks(const rac_platform_llm_callbacks_t* callbacks); + +/** + * Gets the current Swift callbacks. + * + * @return Pointer to callbacks, or NULL if not set + */ +RAC_API const rac_platform_llm_callbacks_t* rac_platform_llm_get_callbacks(void); + +/** + * Checks if Swift callbacks are registered. + * + * @return RAC_TRUE if callbacks are available + */ +RAC_API rac_bool_t rac_platform_llm_is_available(void); + +// ============================================================================= +// SERVICE API +// ============================================================================= + +/** + * Creates a platform LLM service. + * + * @param model_path Path to model (ignored for built-in, can be NULL) + * @param config Configuration options (can be NULL for defaults) + * @param out_handle Output: Service handle + * @return RAC_SUCCESS on success, or error code + */ +RAC_API rac_result_t rac_llm_platform_create(const char* model_path, + const rac_llm_platform_config_t* config, + rac_llm_platform_handle_t* out_handle); + +/** + * Destroys a platform LLM service. + * + * @param handle Service handle to destroy + */ +RAC_API void rac_llm_platform_destroy(rac_llm_platform_handle_t handle); + +/** + * Generates text using platform LLM. + * + * @param handle Service handle + * @param prompt Input prompt + * @param options Generation options (can be NULL for defaults) + * @param out_response Output: Generated text (caller must free with free()) + * @return RAC_SUCCESS on success, or error code + */ +RAC_API rac_result_t rac_llm_platform_generate(rac_llm_platform_handle_t handle, const char* prompt, + const rac_llm_platform_options_t* options, + char** out_response); + +// ============================================================================= +// BACKEND REGISTRATION +// ============================================================================= + +/** + * Registers the Platform backend with the module and service registries. + * + * This registers: + * - Module: "platform" with TEXT_GENERATION and TTS capabilities + * - LLM Provider: "AppleFoundationModels" (priority 50) + * - TTS Provider: "SystemTTS" (priority 10) + * - Built-in model entries for Foundation Models and System TTS + * + * @return RAC_SUCCESS on success, or an error code + */ +RAC_API rac_result_t rac_backend_platform_register(void); + +/** + * Unregisters the Platform backend. + * + * @return RAC_SUCCESS on success, or an error code + */ +RAC_API rac_result_t rac_backend_platform_unregister(void); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_LLM_PLATFORM_H */ diff --git a/sdk/runanywhere-commons/include/rac/features/platform/rac_tts_platform.h b/sdk/runanywhere-commons/include/rac/features/platform/rac_tts_platform.h new file mode 100644 index 000000000..5cfad832f --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/features/platform/rac_tts_platform.h @@ -0,0 +1,197 @@ +/** + * @file rac_tts_platform.h + * @brief RunAnywhere Commons - Platform TTS Backend (System TTS) + * + * C API for platform-native TTS services. On Apple platforms, this uses + * AVSpeechSynthesizer. The actual implementation is in Swift, with C++ + * providing the registration and callback infrastructure. + * + * This backend follows the same pattern as ONNX TTS backend, but delegates + * to Swift via function pointer callbacks since AVSpeechSynthesizer is + * an Apple-only framework. + */ + +#ifndef RAC_TTS_PLATFORM_H +#define RAC_TTS_PLATFORM_H + +#include "rac/core/rac_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// TYPES +// ============================================================================= + +/** Opaque handle to platform TTS service */ +typedef struct rac_tts_platform* rac_tts_platform_handle_t; + +/** + * Platform TTS configuration. + */ +typedef struct rac_tts_platform_config { + /** Voice identifier (can be NULL for default) */ + const char* voice_id; + /** Language code (e.g., "en-US") */ + const char* language; + /** Reserved for future use */ + void* reserved; +} rac_tts_platform_config_t; + +/** + * Synthesis options for platform TTS. + */ +typedef struct rac_tts_platform_options { + /** Speech rate (0.5 = half speed, 1.0 = normal, 2.0 = double) */ + float rate; + /** Pitch multiplier (0.5 = low, 1.0 = normal, 2.0 = high) */ + float pitch; + /** Volume (0.0 = silent, 1.0 = full) */ + float volume; + /** Voice identifier override (can be NULL) */ + const char* voice_id; + /** Reserved for future options */ + void* reserved; +} rac_tts_platform_options_t; + +// ============================================================================= +// SWIFT CALLBACK TYPES +// ============================================================================= + +/** + * Callback to check if platform TTS can handle a voice ID. + * Implemented in Swift. + * + * @param voice_id Voice identifier to check (can be NULL) + * @param user_data User-provided context + * @return RAC_TRUE if this backend can handle the voice + */ +typedef rac_bool_t (*rac_platform_tts_can_handle_fn)(const char* voice_id, void* user_data); + +/** + * Callback to create platform TTS service. + * Implemented in Swift. + * + * @param config Configuration options + * @param user_data User-provided context + * @return Handle to created service (Swift object pointer), or NULL on failure + */ +typedef rac_handle_t (*rac_platform_tts_create_fn)(const rac_tts_platform_config_t* config, + void* user_data); + +/** + * Callback to synthesize speech. + * Implemented in Swift. + * + * @param handle Service handle from create + * @param text Text to synthesize + * @param options Synthesis options + * @param user_data User-provided context + * @return RAC_SUCCESS or error code + */ +typedef rac_result_t (*rac_platform_tts_synthesize_fn)(rac_handle_t handle, const char* text, + const rac_tts_platform_options_t* options, + void* user_data); + +/** + * Callback to stop speech. + * Implemented in Swift. + * + * @param handle Service handle + * @param user_data User-provided context + */ +typedef void (*rac_platform_tts_stop_fn)(rac_handle_t handle, void* user_data); + +/** + * Callback to destroy platform TTS service. + * Implemented in Swift. + * + * @param handle Service handle to destroy + * @param user_data User-provided context + */ +typedef void (*rac_platform_tts_destroy_fn)(rac_handle_t handle, void* user_data); + +/** + * Swift callbacks for platform TTS operations. + */ +typedef struct rac_platform_tts_callbacks { + rac_platform_tts_can_handle_fn can_handle; + rac_platform_tts_create_fn create; + rac_platform_tts_synthesize_fn synthesize; + rac_platform_tts_stop_fn stop; + rac_platform_tts_destroy_fn destroy; + void* user_data; +} rac_platform_tts_callbacks_t; + +// ============================================================================= +// CALLBACK REGISTRATION +// ============================================================================= + +/** + * Sets the Swift callbacks for platform TTS operations. + * Must be called before using platform TTS services. + * + * @param callbacks Callback functions (copied internally) + * @return RAC_SUCCESS on success + */ +RAC_API rac_result_t rac_platform_tts_set_callbacks(const rac_platform_tts_callbacks_t* callbacks); + +/** + * Gets the current Swift callbacks. + * + * @return Pointer to callbacks, or NULL if not set + */ +RAC_API const rac_platform_tts_callbacks_t* rac_platform_tts_get_callbacks(void); + +/** + * Checks if Swift callbacks are registered. + * + * @return RAC_TRUE if callbacks are available + */ +RAC_API rac_bool_t rac_platform_tts_is_available(void); + +// ============================================================================= +// SERVICE API +// ============================================================================= + +/** + * Creates a platform TTS service. + * + * @param config Configuration options (can be NULL for defaults) + * @param out_handle Output: Service handle + * @return RAC_SUCCESS on success, or error code + */ +RAC_API rac_result_t rac_tts_platform_create(const rac_tts_platform_config_t* config, + rac_tts_platform_handle_t* out_handle); + +/** + * Destroys a platform TTS service. + * + * @param handle Service handle to destroy + */ +RAC_API void rac_tts_platform_destroy(rac_tts_platform_handle_t handle); + +/** + * Synthesizes speech using platform TTS. + * + * @param handle Service handle + * @param text Text to synthesize + * @param options Synthesis options (can be NULL for defaults) + * @return RAC_SUCCESS on success, or error code + */ +RAC_API rac_result_t rac_tts_platform_synthesize(rac_tts_platform_handle_t handle, const char* text, + const rac_tts_platform_options_t* options); + +/** + * Stops current speech synthesis. + * + * @param handle Service handle + */ +RAC_API void rac_tts_platform_stop(rac_tts_platform_handle_t handle); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_TTS_PLATFORM_H */ diff --git a/sdk/runanywhere-commons/include/rac/features/stt/rac_stt.h b/sdk/runanywhere-commons/include/rac/features/stt/rac_stt.h new file mode 100644 index 000000000..a65f62805 --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/features/stt/rac_stt.h @@ -0,0 +1,17 @@ +/** + * @file rac_stt.h + * @brief RunAnywhere Commons - STT API (Convenience Header) + * + * This header includes both types and service interface for convenience. + * For better separation of concerns, prefer including: + * - rac_stt_types.h for data structures only + * - rac_stt_service.h for the service interface + */ + +#ifndef RAC_STT_H +#define RAC_STT_H + +#include "rac/features/stt/rac_stt_service.h" +#include "rac/features/stt/rac_stt_types.h" + +#endif /* RAC_STT_H */ diff --git a/sdk/runanywhere-commons/include/rac/features/stt/rac_stt_analytics.h b/sdk/runanywhere-commons/include/rac/features/stt/rac_stt_analytics.h new file mode 100644 index 000000000..191dfc3ee --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/features/stt/rac_stt_analytics.h @@ -0,0 +1,204 @@ +/** + * @file rac_stt_analytics.h + * @brief STT analytics service - 1:1 port of STTAnalyticsService.swift + * + * Tracks transcription operations and metrics. + * Lifecycle events are handled by the lifecycle manager. + * + * NOTE: Audio length estimation assumes 16-bit PCM @ 16kHz (standard for STT). + * Formula: audioLengthMs = (bytes / 2) / 16000 * 1000 + * + * NOTE: Real-Time Factor (RTF) will be 0 or undefined for streaming transcription + * since audioLengthMs = 0 when audio is processed in chunks of unknown total length. + * + * Swift Source: Sources/RunAnywhere/Features/STT/Analytics/STTAnalyticsService.swift + */ + +#ifndef RAC_STT_ANALYTICS_H +#define RAC_STT_ANALYTICS_H + +#include "rac/core/rac_types.h" +#include "rac/features/stt/rac_stt_types.h" +#include "rac/infrastructure/model_management/rac_model_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// TYPES +// ============================================================================= + +/** + * @brief Opaque handle for STT analytics service + */ +typedef struct rac_stt_analytics_s* rac_stt_analytics_handle_t; + +/** + * @brief STT metrics structure + * Mirrors Swift's STTMetrics struct + */ +typedef struct rac_stt_metrics { + /** Total number of events tracked */ + int32_t total_events; + + /** Start time (milliseconds since epoch) */ + int64_t start_time_ms; + + /** Last event time (milliseconds since epoch, 0 if no events) */ + int64_t last_event_time_ms; + + /** Total number of transcriptions */ + int32_t total_transcriptions; + + /** Average confidence score across all transcriptions (0.0 to 1.0) */ + float average_confidence; + + /** Average processing latency in milliseconds */ + double average_latency_ms; + + /** Average real-time factor (processing time / audio length) */ + double average_real_time_factor; + + /** Total audio processed in milliseconds */ + double total_audio_processed_ms; +} rac_stt_metrics_t; + +// ============================================================================= +// LIFECYCLE +// ============================================================================= + +/** + * @brief Create an STT analytics service instance + * + * @param out_handle Output: Handle to the created service + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_stt_analytics_create(rac_stt_analytics_handle_t* out_handle); + +/** + * @brief Destroy an STT analytics service instance + * + * @param handle Handle to destroy + */ +RAC_API void rac_stt_analytics_destroy(rac_stt_analytics_handle_t handle); + +// ============================================================================= +// TRANSCRIPTION TRACKING +// ============================================================================= + +/** + * @brief Start tracking a transcription + * + * @param handle Analytics service handle + * @param model_id The STT model identifier + * @param audio_length_ms Duration of audio in milliseconds + * @param audio_size_bytes Size of audio data in bytes + * @param language Language code for transcription + * @param is_streaming Whether this is a streaming transcription + * @param sample_rate Audio sample rate in Hz (default: 16000) + * @param framework The inference framework being used + * @param out_transcription_id Output: Generated unique ID (owned, must be freed with rac_free) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_stt_analytics_start_transcription( + rac_stt_analytics_handle_t handle, const char* model_id, double audio_length_ms, + int32_t audio_size_bytes, const char* language, rac_bool_t is_streaming, int32_t sample_rate, + rac_inference_framework_t framework, char** out_transcription_id); + +/** + * @brief Track partial transcript (for streaming transcription) + * + * @param handle Analytics service handle + * @param text Partial transcript text + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_stt_analytics_track_partial_transcript(rac_stt_analytics_handle_t handle, + const char* text); + +/** + * @brief Track final transcript (for streaming transcription) + * + * @param handle Analytics service handle + * @param text Final transcript text + * @param confidence Confidence score (0.0 to 1.0) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_stt_analytics_track_final_transcript(rac_stt_analytics_handle_t handle, + const char* text, float confidence); + +/** + * @brief Complete a transcription + * + * @param handle Analytics service handle + * @param transcription_id The transcription ID + * @param text The transcribed text + * @param confidence Confidence score (0.0 to 1.0) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_stt_analytics_complete_transcription(rac_stt_analytics_handle_t handle, + const char* transcription_id, + const char* text, float confidence); + +/** + * @brief Track transcription failure + * + * @param handle Analytics service handle + * @param transcription_id The transcription ID + * @param error_code Error code + * @param error_message Error message + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_stt_analytics_track_transcription_failed(rac_stt_analytics_handle_t handle, + const char* transcription_id, + rac_result_t error_code, + const char* error_message); + +/** + * @brief Track language detection + * + * @param handle Analytics service handle + * @param language Detected language code + * @param confidence Detection confidence (0.0 to 1.0) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_stt_analytics_track_language_detection(rac_stt_analytics_handle_t handle, + const char* language, + float confidence); + +/** + * @brief Track an error during STT operations + * + * @param handle Analytics service handle + * @param error_code Error code + * @param error_message Error message + * @param operation Operation that failed + * @param model_id Model ID (can be NULL) + * @param transcription_id Transcription ID (can be NULL) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_stt_analytics_track_error(rac_stt_analytics_handle_t handle, + rac_result_t error_code, + const char* error_message, const char* operation, + const char* model_id, + const char* transcription_id); + +// ============================================================================= +// METRICS +// ============================================================================= + +/** + * @brief Get current analytics metrics + * + * @param handle Analytics service handle + * @param out_metrics Output: Metrics structure + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_stt_analytics_get_metrics(rac_stt_analytics_handle_t handle, + rac_stt_metrics_t* out_metrics); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_STT_ANALYTICS_H */ diff --git a/sdk/runanywhere-commons/include/rac/features/stt/rac_stt_component.h b/sdk/runanywhere-commons/include/rac/features/stt/rac_stt_component.h new file mode 100644 index 000000000..b3bd3d4e2 --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/features/stt/rac_stt_component.h @@ -0,0 +1,162 @@ +/** + * @file rac_stt_component.h + * @brief RunAnywhere Commons - STT Capability Component + * + * C port of Swift's STTCapability.swift from: + * Sources/RunAnywhere/Features/STT/STTCapability.swift + * + * Actor-based STT capability that owns model lifecycle and transcription. + * Uses lifecycle manager for unified lifecycle + analytics handling. + */ + +#ifndef RAC_STT_COMPONENT_H +#define RAC_STT_COMPONENT_H + +#include "rac/core/capabilities/rac_lifecycle.h" +#include "rac/core/rac_error.h" +#include "rac/features/stt/rac_stt_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// NOTE: rac_stt_config_t is defined in rac_stt_types.h (included above) + +// ============================================================================= +// STT COMPONENT API - Mirrors Swift's STTCapability +// ============================================================================= + +/** + * @brief Create an STT capability component + * + * @param out_handle Output: Handle to the component + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_stt_component_create(rac_handle_t* out_handle); + +/** + * @brief Configure the STT component + * + * @param handle Component handle + * @param config Configuration + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_stt_component_configure(rac_handle_t handle, + const rac_stt_config_t* config); + +/** + * @brief Check if model is loaded + * + * @param handle Component handle + * @return RAC_TRUE if loaded, RAC_FALSE otherwise + */ +RAC_API rac_bool_t rac_stt_component_is_loaded(rac_handle_t handle); + +/** + * @brief Get current model ID + * + * @param handle Component handle + * @return Current model ID (NULL if not loaded) + */ +RAC_API const char* rac_stt_component_get_model_id(rac_handle_t handle); + +/** + * @brief Load a model + * + * @param handle Component handle + * @param model_path File path to the model (used for loading) - REQUIRED + * @param model_id Model identifier for telemetry (e.g., "sherpa-onnx-whisper-tiny.en") + * Optional: if NULL, defaults to model_path + * @param model_name Human-readable model name (e.g., "Sherpa Whisper Tiny (ONNX)") + * Optional: if NULL, defaults to model_id + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_stt_component_load_model(rac_handle_t handle, const char* model_path, + const char* model_id, const char* model_name); + +/** + * @brief Unload the current model + * + * @param handle Component handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_stt_component_unload(rac_handle_t handle); + +/** + * @brief Cleanup and reset the component + * + * @param handle Component handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_stt_component_cleanup(rac_handle_t handle); + +/** + * @brief Transcribe audio data (batch mode) + * + * @param handle Component handle + * @param audio_data Audio data buffer + * @param audio_size Size of audio data in bytes + * @param options Transcription options (can be NULL for defaults) + * @param out_result Output: Transcription result + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_stt_component_transcribe(rac_handle_t handle, const void* audio_data, + size_t audio_size, + const rac_stt_options_t* options, + rac_stt_result_t* out_result); + +/** + * @brief Check if streaming is supported + * + * @param handle Component handle + * @return RAC_TRUE if streaming supported, RAC_FALSE otherwise + */ +RAC_API rac_bool_t rac_stt_component_supports_streaming(rac_handle_t handle); + +/** + * @brief Transcribe audio with streaming + * + * @param handle Component handle + * @param audio_data Audio chunk data + * @param audio_size Size of audio chunk + * @param options Transcription options + * @param callback Callback for partial results + * @param user_data User context passed to callback + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_stt_component_transcribe_stream(rac_handle_t handle, + const void* audio_data, size_t audio_size, + const rac_stt_options_t* options, + rac_stt_stream_callback_t callback, + void* user_data); + +/** + * @brief Get lifecycle state + * + * @param handle Component handle + * @return Current lifecycle state + */ +RAC_API rac_lifecycle_state_t rac_stt_component_get_state(rac_handle_t handle); + +/** + * @brief Get lifecycle metrics + * + * @param handle Component handle + * @param out_metrics Output: Lifecycle metrics + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_stt_component_get_metrics(rac_handle_t handle, + rac_lifecycle_metrics_t* out_metrics); + +/** + * @brief Destroy the STT component + * + * @param handle Component handle + */ +RAC_API void rac_stt_component_destroy(rac_handle_t handle); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_STT_COMPONENT_H */ diff --git a/sdk/runanywhere-commons/include/rac/features/stt/rac_stt_events.h b/sdk/runanywhere-commons/include/rac/features/stt/rac_stt_events.h new file mode 100644 index 000000000..680d8123a --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/features/stt/rac_stt_events.h @@ -0,0 +1,62 @@ +/** + * @file rac_stt_events.h + * @brief STT-specific event types - 1:1 port of STTEvent.swift + * + * Swift Source: Sources/RunAnywhere/Features/STT/Analytics/STTEvent.swift + */ + +#ifndef RAC_STT_EVENTS_H +#define RAC_STT_EVENTS_H + +#include "rac/core/rac_types.h" +#include "rac/infrastructure/events/rac_events.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// STT EVENT TYPES +// ============================================================================= + +typedef enum rac_stt_event_type { + RAC_STT_EVENT_TRANSCRIPTION_STARTED = 0, + RAC_STT_EVENT_PARTIAL_TRANSCRIPT, + RAC_STT_EVENT_FINAL_TRANSCRIPT, + RAC_STT_EVENT_TRANSCRIPTION_COMPLETED, + RAC_STT_EVENT_TRANSCRIPTION_FAILED, + RAC_STT_EVENT_LANGUAGE_DETECTED, +} rac_stt_event_type_t; + +// ============================================================================= +// EVENT PUBLISHING FUNCTIONS +// ============================================================================= + +RAC_API rac_result_t rac_stt_event_transcription_started( + const char* transcription_id, const char* model_id, double audio_length_ms, + int32_t audio_size_bytes, const char* language, rac_bool_t is_streaming, + rac_inference_framework_t framework); + +RAC_API rac_result_t rac_stt_event_partial_transcript(const char* text, int32_t word_count); + +RAC_API rac_result_t rac_stt_event_final_transcript(const char* text, float confidence); + +RAC_API rac_result_t rac_stt_event_transcription_completed( + const char* transcription_id, const char* model_id, const char* text, float confidence, + double duration_ms, double audio_length_ms, int32_t word_count, double real_time_factor, + const char* language, rac_bool_t is_streaming, rac_inference_framework_t framework); + +RAC_API rac_result_t rac_stt_event_transcription_failed(const char* transcription_id, + const char* model_id, + rac_result_t error_code, + const char* error_message); + +RAC_API rac_result_t rac_stt_event_language_detected(const char* language, float confidence); + +RAC_API const char* rac_stt_event_type_string(rac_stt_event_type_t event_type); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_STT_EVENTS_H */ diff --git a/sdk/runanywhere-commons/include/rac/features/stt/rac_stt_service.h b/sdk/runanywhere-commons/include/rac/features/stt/rac_stt_service.h new file mode 100644 index 000000000..521d5dfad --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/features/stt/rac_stt_service.h @@ -0,0 +1,154 @@ +/** + * @file rac_stt_service.h + * @brief RunAnywhere Commons - STT Service Interface + * + * Defines the generic STT service API and vtable for multi-backend dispatch. + * Backends (ONNX, Whisper, etc.) implement the vtable and register + * with the service registry. + */ + +#ifndef RAC_STT_SERVICE_H +#define RAC_STT_SERVICE_H + +#include "rac/core/rac_error.h" +#include "rac/features/stt/rac_stt_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// SERVICE VTABLE - Backend implementations provide this +// ============================================================================= + +/** + * STT Service operations vtable. + * Each backend implements these functions and provides a static vtable. + */ +typedef struct rac_stt_service_ops { + /** Initialize the service with a model path */ + rac_result_t (*initialize)(void* impl, const char* model_path); + + /** Transcribe audio (batch mode) */ + rac_result_t (*transcribe)(void* impl, const void* audio_data, size_t audio_size, + const rac_stt_options_t* options, rac_stt_result_t* out_result); + + /** Stream transcription for real-time processing */ + rac_result_t (*transcribe_stream)(void* impl, const void* audio_data, size_t audio_size, + const rac_stt_options_t* options, + rac_stt_stream_callback_t callback, void* user_data); + + /** Get service info */ + rac_result_t (*get_info)(void* impl, rac_stt_info_t* out_info); + + /** Cleanup/unload model (keeps service alive) */ + rac_result_t (*cleanup)(void* impl); + + /** Destroy the service */ + void (*destroy)(void* impl); +} rac_stt_service_ops_t; + +/** + * STT Service instance. + * Contains vtable pointer and backend-specific implementation. + */ +typedef struct rac_stt_service { + /** Vtable with backend operations */ + const rac_stt_service_ops_t* ops; + + /** Backend-specific implementation handle */ + void* impl; + + /** Model ID for reference */ + const char* model_id; +} rac_stt_service_t; + +// ============================================================================= +// PUBLIC API - Generic service functions +// ============================================================================= + +/** + * @brief Create an STT service + * + * Routes through service registry to find appropriate backend. + * + * @param model_path Path to the model file (can be NULL for some providers) + * @param out_handle Output: Handle to the created service + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_stt_create(const char* model_path, rac_handle_t* out_handle); + +/** + * @brief Initialize an STT service + * + * @param handle Service handle + * @param model_path Path to the model file (can be NULL) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_stt_initialize(rac_handle_t handle, const char* model_path); + +/** + * @brief Transcribe audio data (batch mode) + * + * @param handle Service handle + * @param audio_data Audio data buffer + * @param audio_size Size of audio data in bytes + * @param options Transcription options (can be NULL for defaults) + * @param out_result Output: Transcription result (caller must free with rac_stt_result_free) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_stt_transcribe(rac_handle_t handle, const void* audio_data, + size_t audio_size, const rac_stt_options_t* options, + rac_stt_result_t* out_result); + +/** + * @brief Stream transcription for real-time processing + * + * @param handle Service handle + * @param audio_data Audio chunk data + * @param audio_size Size of audio chunk + * @param options Transcription options + * @param callback Callback for partial results + * @param user_data User context passed to callback + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_stt_transcribe_stream(rac_handle_t handle, const void* audio_data, + size_t audio_size, const rac_stt_options_t* options, + rac_stt_stream_callback_t callback, void* user_data); + +/** + * @brief Get service information + * + * @param handle Service handle + * @param out_info Output: Service information + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_stt_get_info(rac_handle_t handle, rac_stt_info_t* out_info); + +/** + * @brief Cleanup and release resources + * + * @param handle Service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_stt_cleanup(rac_handle_t handle); + +/** + * @brief Destroy an STT service instance + * + * @param handle Service handle to destroy + */ +RAC_API void rac_stt_destroy(rac_handle_t handle); + +/** + * @brief Free an STT result + * + * @param result Result to free + */ +RAC_API void rac_stt_result_free(rac_stt_result_t* result); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_STT_SERVICE_H */ diff --git a/sdk/runanywhere-commons/include/rac/features/stt/rac_stt_types.h b/sdk/runanywhere-commons/include/rac/features/stt/rac_stt_types.h new file mode 100644 index 000000000..d1e8d9e87 --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/features/stt/rac_stt_types.h @@ -0,0 +1,389 @@ +/** + * @file rac_stt_types.h + * @brief RunAnywhere Commons - STT Types and Data Structures + * + * C port of Swift's STT Models from: + * Sources/RunAnywhere/Features/STT/Models/STTConfiguration.swift + * Sources/RunAnywhere/Features/STT/Models/STTOptions.swift + * Sources/RunAnywhere/Features/STT/Models/STTInput.swift + * Sources/RunAnywhere/Features/STT/Models/STTOutput.swift + * Sources/RunAnywhere/Features/STT/Models/STTTranscriptionResult.swift + * + * This header defines data structures only. For the service interface, + * see rac_stt_service.h. + */ + +#ifndef RAC_STT_TYPES_H +#define RAC_STT_TYPES_H + +#include "rac/core/rac_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// CONSTANTS - Single Source of Truth for STT +// Swift references these via CRACommons import +// ============================================================================= + +// Audio Format Constants +#define RAC_STT_DEFAULT_SAMPLE_RATE 16000 +#define RAC_STT_MAX_SAMPLE_RATE 48000 +#define RAC_STT_MIN_SAMPLE_RATE 8000 +#define RAC_STT_BYTES_PER_SAMPLE 2 +#define RAC_STT_CHANNELS 1 + +// Confidence Scores +#define RAC_STT_DEFAULT_CONFIDENCE 0.9f +#define RAC_STT_MIN_ACCEPTABLE_CONFIDENCE 0.5f + +// Streaming Constants +#define RAC_STT_DEFAULT_STREAMING_CHUNK_MS 100 +#define RAC_STT_MIN_STREAMING_CHUNK_MS 50 +#define RAC_STT_MAX_STREAMING_CHUNK_MS 1000 + +// Language +#define RAC_STT_DEFAULT_LANGUAGE "en" + +// ============================================================================= +// AUDIO FORMAT - Mirrors Swift's AudioFormat +// ============================================================================= + +/** + * @brief Audio format enumeration + * Mirrors Swift's AudioFormat from AudioTypes.swift + */ +typedef enum rac_audio_format_enum { + RAC_AUDIO_FORMAT_PCM = 0, + RAC_AUDIO_FORMAT_WAV = 1, + RAC_AUDIO_FORMAT_MP3 = 2, + RAC_AUDIO_FORMAT_OPUS = 3, + RAC_AUDIO_FORMAT_AAC = 4, + RAC_AUDIO_FORMAT_FLAC = 5 +} rac_audio_format_enum_t; + +// ============================================================================= +// CONFIGURATION - Mirrors Swift's STTConfiguration +// ============================================================================= + +/** + * @brief STT component configuration + * + * Mirrors Swift's STTConfiguration struct exactly. + * See: Sources/RunAnywhere/Features/STT/Models/STTConfiguration.swift + */ +typedef struct rac_stt_config { + /** Model ID (optional - uses default if NULL) */ + const char* model_id; + + /** Preferred framework for transcription (use -1 for auto) */ + int32_t preferred_framework; + + /** Language code for transcription (e.g., "en-US") */ + const char* language; + + /** Sample rate in Hz (default: 16000) */ + int32_t sample_rate; + + /** Enable automatic punctuation in transcription */ + rac_bool_t enable_punctuation; + + /** Enable speaker diarization */ + rac_bool_t enable_diarization; + + /** Vocabulary list for improved recognition (NULL-terminated array, can be NULL) */ + const char* const* vocabulary_list; + size_t num_vocabulary; + + /** Maximum number of alternative transcriptions (default: 1) */ + int32_t max_alternatives; + + /** Enable word-level timestamps */ + rac_bool_t enable_timestamps; +} rac_stt_config_t; + +/** + * @brief Default STT configuration + */ +static const rac_stt_config_t RAC_STT_CONFIG_DEFAULT = {.model_id = RAC_NULL, + .preferred_framework = -1, + .language = "en-US", + .sample_rate = RAC_STT_DEFAULT_SAMPLE_RATE, + .enable_punctuation = RAC_TRUE, + .enable_diarization = RAC_FALSE, + .vocabulary_list = RAC_NULL, + .num_vocabulary = 0, + .max_alternatives = 1, + .enable_timestamps = RAC_TRUE}; + +// ============================================================================= +// OPTIONS - Mirrors Swift's STTOptions +// ============================================================================= + +/** + * @brief STT transcription options + * + * Mirrors Swift's STTOptions struct. + * See: Sources/RunAnywhere/Features/STT/Models/STTOptions.swift + */ +typedef struct rac_stt_options { + /** Language code for transcription (e.g., "en", "es", "fr") */ + const char* language; + + /** Whether to auto-detect the spoken language */ + rac_bool_t detect_language; + + /** Enable automatic punctuation in transcription */ + rac_bool_t enable_punctuation; + + /** Enable speaker diarization */ + rac_bool_t enable_diarization; + + /** Maximum number of speakers (0 = auto) */ + int32_t max_speakers; + + /** Enable word-level timestamps */ + rac_bool_t enable_timestamps; + + /** Audio format of input data */ + rac_audio_format_enum_t audio_format; + + /** Sample rate of input audio (default: 16000 Hz) */ + int32_t sample_rate; +} rac_stt_options_t; + +/** + * @brief Default STT options + */ +static const rac_stt_options_t RAC_STT_OPTIONS_DEFAULT = {.language = "en", + .detect_language = RAC_FALSE, + .enable_punctuation = RAC_TRUE, + .enable_diarization = RAC_FALSE, + .max_speakers = 0, + .enable_timestamps = RAC_TRUE, + .audio_format = RAC_AUDIO_FORMAT_PCM, + .sample_rate = 16000}; + +// ============================================================================= +// RESULT - Mirrors Swift's STTTranscriptionResult +// ============================================================================= + +/** + * @brief Word timestamp information + */ +typedef struct rac_stt_word { + /** The word text */ + const char* text; + /** Start time in milliseconds */ + int64_t start_ms; + /** End time in milliseconds */ + int64_t end_ms; + /** Confidence score (0.0 to 1.0) */ + float confidence; +} rac_stt_word_t; + +/** + * @brief STT transcription result + * + * Mirrors Swift's STTTranscriptionResult struct. + */ +typedef struct rac_stt_result { + /** Full transcribed text (owned, must be freed with rac_free) */ + char* text; + + /** Detected language code (can be NULL) */ + char* detected_language; + + /** Word-level timestamps (can be NULL) */ + rac_stt_word_t* words; + size_t num_words; + + /** Overall confidence score (0.0 to 1.0) */ + float confidence; + + /** Processing time in milliseconds */ + int64_t processing_time_ms; +} rac_stt_result_t; + +// ============================================================================= +// INFO - Mirrors Swift's STTService properties +// ============================================================================= + +/** + * @brief STT service info + */ +typedef struct rac_stt_info { + /** Whether the service is ready */ + rac_bool_t is_ready; + + /** Current model identifier (can be NULL) */ + const char* current_model; + + /** Whether streaming is supported */ + rac_bool_t supports_streaming; +} rac_stt_info_t; + +// ============================================================================= +// CALLBACKS +// ============================================================================= + +/** + * @brief STT streaming callback + * + * Called for partial transcription results during streaming. + * + * @param partial_text Partial transcription text + * @param is_final Whether this is a final result + * @param user_data User-provided context + */ +typedef void (*rac_stt_stream_callback_t)(const char* partial_text, rac_bool_t is_final, + void* user_data); + +// ============================================================================= +// INPUT - Mirrors Swift's STTInput +// ============================================================================= + +/** + * @brief STT input data + * + * Mirrors Swift's STTInput struct. + * See: Sources/RunAnywhere/Features/STT/Models/STTInput.swift + */ +typedef struct rac_stt_input { + /** Audio data bytes (raw audio data) */ + const uint8_t* audio_data; + size_t audio_data_size; + + /** Alternative: audio buffer (PCM float samples) */ + const float* audio_samples; + size_t num_samples; + + /** Audio format of input data */ + rac_audio_format_enum_t format; + + /** Language code override (can be NULL to use config default) */ + const char* language; + + /** Sample rate of the audio (default: 16000) */ + int32_t sample_rate; + + /** Custom options override (can be NULL) */ + const rac_stt_options_t* options; +} rac_stt_input_t; + +/** + * @brief Default STT input + */ +static const rac_stt_input_t RAC_STT_INPUT_DEFAULT = {.audio_data = RAC_NULL, + .audio_data_size = 0, + .audio_samples = RAC_NULL, + .num_samples = 0, + .format = RAC_AUDIO_FORMAT_PCM, + .language = RAC_NULL, + .sample_rate = RAC_STT_DEFAULT_SAMPLE_RATE, + .options = RAC_NULL}; + +// ============================================================================= +// TRANSCRIPTION METADATA - Mirrors Swift's TranscriptionMetadata +// ============================================================================= + +/** + * @brief Transcription metadata + * + * Mirrors Swift's TranscriptionMetadata struct. + * See: Sources/RunAnywhere/Features/STT/Models/STTOutput.swift + */ +typedef struct rac_transcription_metadata { + /** Model ID used for transcription */ + const char* model_id; + + /** Processing time in milliseconds */ + int64_t processing_time_ms; + + /** Audio length in milliseconds */ + int64_t audio_length_ms; + + /** Real-time factor (processing_time / audio_length) */ + float real_time_factor; +} rac_transcription_metadata_t; + +// ============================================================================= +// TRANSCRIPTION ALTERNATIVE - Mirrors Swift's TranscriptionAlternative +// ============================================================================= + +/** + * @brief Alternative transcription + * + * Mirrors Swift's TranscriptionAlternative struct. + */ +typedef struct rac_transcription_alternative { + /** Alternative transcription text */ + const char* text; + + /** Confidence score (0.0 to 1.0) */ + float confidence; +} rac_transcription_alternative_t; + +// ============================================================================= +// OUTPUT - Mirrors Swift's STTOutput +// ============================================================================= + +/** + * @brief STT output data + * + * Mirrors Swift's STTOutput struct. + * See: Sources/RunAnywhere/Features/STT/Models/STTOutput.swift + */ +typedef struct rac_stt_output { + /** Transcribed text (owned, must be freed with rac_free) */ + char* text; + + /** Confidence score (0.0 to 1.0) */ + float confidence; + + /** Word-level timestamps (can be NULL) */ + rac_stt_word_t* word_timestamps; + size_t num_word_timestamps; + + /** Detected language if auto-detected (can be NULL) */ + char* detected_language; + + /** Alternative transcriptions (can be NULL) */ + rac_transcription_alternative_t* alternatives; + size_t num_alternatives; + + /** Processing metadata */ + rac_transcription_metadata_t metadata; + + /** Timestamp in milliseconds since epoch */ + int64_t timestamp_ms; +} rac_stt_output_t; + +// ============================================================================= +// TRANSCRIPTION RESULT - Alias for compatibility +// ============================================================================= + +/** + * @brief STT transcription result (alias for rac_stt_output_t) + * + * For compatibility with existing code that uses "result" terminology. + */ +typedef rac_stt_output_t rac_stt_transcription_result_t; + +// ============================================================================= +// MEMORY MANAGEMENT +// ============================================================================= + +/** + * @brief Free STT result resources + * + * @param result Result to free (can be NULL) + */ +RAC_API void rac_stt_result_free(rac_stt_result_t* result); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_STT_TYPES_H */ diff --git a/sdk/runanywhere-commons/include/rac/features/tts/rac_tts.h b/sdk/runanywhere-commons/include/rac/features/tts/rac_tts.h new file mode 100644 index 000000000..60282d784 --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/features/tts/rac_tts.h @@ -0,0 +1,17 @@ +/** + * @file rac_tts.h + * @brief RunAnywhere Commons - TTS API (Convenience Header) + * + * This header includes both types and service interface for convenience. + * For better separation of concerns, prefer including: + * - rac_tts_types.h for data structures only + * - rac_tts_service.h for the service interface + */ + +#ifndef RAC_TTS_H +#define RAC_TTS_H + +#include "rac/features/tts/rac_tts_service.h" +#include "rac/features/tts/rac_tts_types.h" + +#endif /* RAC_TTS_H */ diff --git a/sdk/runanywhere-commons/include/rac/features/tts/rac_tts_analytics.h b/sdk/runanywhere-commons/include/rac/features/tts/rac_tts_analytics.h new file mode 100644 index 000000000..83b0d0305 --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/features/tts/rac_tts_analytics.h @@ -0,0 +1,181 @@ +/** + * @file rac_tts_analytics.h + * @brief TTS analytics service - 1:1 port of TTSAnalyticsService.swift + * + * Tracks synthesis operations and metrics. + * Lifecycle events are handled by the lifecycle manager. + * + * NOTE: Audio duration estimation assumes 16-bit PCM @ 22050Hz (standard for TTS). + * Formula: audioDurationMs = (bytes / 2) / 22050 * 1000 + * + * Swift Source: Sources/RunAnywhere/Features/TTS/Analytics/TTSAnalyticsService.swift + */ + +#ifndef RAC_TTS_ANALYTICS_H +#define RAC_TTS_ANALYTICS_H + +#include "rac/core/rac_types.h" +#include "rac/features/tts/rac_tts_types.h" +#include "rac/infrastructure/model_management/rac_model_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// TYPES +// ============================================================================= + +/** + * @brief Opaque handle for TTS analytics service + */ +typedef struct rac_tts_analytics_s* rac_tts_analytics_handle_t; + +/** + * @brief TTS metrics structure + * Mirrors Swift's TTSMetrics struct + */ +typedef struct rac_tts_metrics { + /** Total number of events tracked */ + int32_t total_events; + + /** Start time (milliseconds since epoch) */ + int64_t start_time_ms; + + /** Last event time (milliseconds since epoch, 0 if no events) */ + int64_t last_event_time_ms; + + /** Total number of syntheses */ + int32_t total_syntheses; + + /** Average synthesis speed (characters processed per second) */ + double average_characters_per_second; + + /** Average processing time in milliseconds */ + double average_processing_time_ms; + + /** Average audio duration in milliseconds */ + double average_audio_duration_ms; + + /** Total characters processed across all syntheses */ + int32_t total_characters_processed; + + /** Total audio size generated in bytes */ + int64_t total_audio_size_bytes; +} rac_tts_metrics_t; + +// ============================================================================= +// LIFECYCLE +// ============================================================================= + +/** + * @brief Create a TTS analytics service instance + * + * @param out_handle Output: Handle to the created service + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_tts_analytics_create(rac_tts_analytics_handle_t* out_handle); + +/** + * @brief Destroy a TTS analytics service instance + * + * @param handle Handle to destroy + */ +RAC_API void rac_tts_analytics_destroy(rac_tts_analytics_handle_t handle); + +// ============================================================================= +// SYNTHESIS TRACKING +// ============================================================================= + +/** + * @brief Start tracking a synthesis + * + * @param handle Analytics service handle + * @param text The text to synthesize + * @param voice The voice ID being used + * @param sample_rate Audio sample rate in Hz (default: 22050) + * @param framework The inference framework being used + * @param out_synthesis_id Output: Generated unique ID (owned, must be freed with rac_free) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_tts_analytics_start_synthesis(rac_tts_analytics_handle_t handle, + const char* text, const char* voice, + int32_t sample_rate, + rac_inference_framework_t framework, + char** out_synthesis_id); + +/** + * @brief Track synthesis chunk (for streaming synthesis) + * + * @param handle Analytics service handle + * @param synthesis_id The synthesis ID + * @param chunk_size Size of the chunk in bytes + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_tts_analytics_track_synthesis_chunk(rac_tts_analytics_handle_t handle, + const char* synthesis_id, + int32_t chunk_size); + +/** + * @brief Complete a synthesis + * + * @param handle Analytics service handle + * @param synthesis_id The synthesis ID + * @param audio_duration_ms Duration of the generated audio in milliseconds + * @param audio_size_bytes Size of the generated audio in bytes + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_tts_analytics_complete_synthesis(rac_tts_analytics_handle_t handle, + const char* synthesis_id, + double audio_duration_ms, + int32_t audio_size_bytes); + +/** + * @brief Track synthesis failure + * + * @param handle Analytics service handle + * @param synthesis_id The synthesis ID + * @param error_code Error code + * @param error_message Error message + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_tts_analytics_track_synthesis_failed(rac_tts_analytics_handle_t handle, + const char* synthesis_id, + rac_result_t error_code, + const char* error_message); + +/** + * @brief Track an error during TTS operations + * + * @param handle Analytics service handle + * @param error_code Error code + * @param error_message Error message + * @param operation Operation that failed + * @param model_id Model ID (can be NULL) + * @param synthesis_id Synthesis ID (can be NULL) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_tts_analytics_track_error(rac_tts_analytics_handle_t handle, + rac_result_t error_code, + const char* error_message, const char* operation, + const char* model_id, const char* synthesis_id); + +// ============================================================================= +// METRICS +// ============================================================================= + +/** + * @brief Get current analytics metrics + * + * @param handle Analytics service handle + * @param out_metrics Output: Metrics structure + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_tts_analytics_get_metrics(rac_tts_analytics_handle_t handle, + rac_tts_metrics_t* out_metrics); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_TTS_ANALYTICS_H */ diff --git a/sdk/runanywhere-commons/include/rac/features/tts/rac_tts_component.h b/sdk/runanywhere-commons/include/rac/features/tts/rac_tts_component.h new file mode 100644 index 000000000..6bff4c5a0 --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/features/tts/rac_tts_component.h @@ -0,0 +1,158 @@ +/** + * @file rac_tts_component.h + * @brief RunAnywhere Commons - TTS Capability Component + * + * C port of Swift's TTSCapability.swift from: + * Sources/RunAnywhere/Features/TTS/TTSCapability.swift + * + * Actor-based TTS capability that owns voice lifecycle and synthesis. + * Uses lifecycle manager for unified lifecycle + analytics handling. + */ + +#ifndef RAC_TTS_COMPONENT_H +#define RAC_TTS_COMPONENT_H + +#include "rac/core/capabilities/rac_lifecycle.h" +#include "rac/core/rac_error.h" +#include "rac/features/tts/rac_tts_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// NOTE: rac_tts_config_t is defined in rac_tts_types.h (included above) + +// ============================================================================= +// TTS COMPONENT API - Mirrors Swift's TTSCapability +// ============================================================================= + +/** + * @brief Create a TTS capability component + * + * @param out_handle Output: Handle to the component + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_tts_component_create(rac_handle_t* out_handle); + +/** + * @brief Configure the TTS component + * + * @param handle Component handle + * @param config Configuration + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_tts_component_configure(rac_handle_t handle, + const rac_tts_config_t* config); + +/** + * @brief Check if voice is loaded + * + * @param handle Component handle + * @return RAC_TRUE if loaded, RAC_FALSE otherwise + */ +RAC_API rac_bool_t rac_tts_component_is_loaded(rac_handle_t handle); + +/** + * @brief Get current voice ID + * + * @param handle Component handle + * @return Current voice ID (NULL if not loaded) + */ +RAC_API const char* rac_tts_component_get_voice_id(rac_handle_t handle); + +/** + * @brief Load a voice + * + * @param handle Component handle + * @param voice_path File path to the voice (used for loading) - REQUIRED + * @param voice_id Voice identifier for telemetry (e.g., "vits-piper-en_GB-alba-medium") + * Optional: if NULL, defaults to voice_path + * @param voice_name Human-readable voice name (e.g., "Piper TTS (British English)") + * Optional: if NULL, defaults to voice_id + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_tts_component_load_voice(rac_handle_t handle, const char* voice_path, + const char* voice_id, const char* voice_name); + +/** + * @brief Unload the current voice + * + * @param handle Component handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_tts_component_unload(rac_handle_t handle); + +/** + * @brief Cleanup and reset the component + * + * @param handle Component handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_tts_component_cleanup(rac_handle_t handle); + +/** + * @brief Stop current synthesis + * + * @param handle Component handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_tts_component_stop(rac_handle_t handle); + +/** + * @brief Synthesize text to audio + * + * @param handle Component handle + * @param text Text to synthesize + * @param options Synthesis options (can be NULL for defaults) + * @param out_result Output: Synthesis result + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_tts_component_synthesize(rac_handle_t handle, const char* text, + const rac_tts_options_t* options, + rac_tts_result_t* out_result); + +/** + * @brief Synthesize text with streaming + * + * @param handle Component handle + * @param text Text to synthesize + * @param options Synthesis options + * @param callback Callback for audio chunks + * @param user_data User context passed to callback + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_tts_component_synthesize_stream(rac_handle_t handle, const char* text, + const rac_tts_options_t* options, + rac_tts_stream_callback_t callback, + void* user_data); + +/** + * @brief Get lifecycle state + * + * @param handle Component handle + * @return Current lifecycle state + */ +RAC_API rac_lifecycle_state_t rac_tts_component_get_state(rac_handle_t handle); + +/** + * @brief Get lifecycle metrics + * + * @param handle Component handle + * @param out_metrics Output: Lifecycle metrics + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_tts_component_get_metrics(rac_handle_t handle, + rac_lifecycle_metrics_t* out_metrics); + +/** + * @brief Destroy the TTS component + * + * @param handle Component handle + */ +RAC_API void rac_tts_component_destroy(rac_handle_t handle); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_TTS_COMPONENT_H */ diff --git a/sdk/runanywhere-commons/include/rac/features/tts/rac_tts_events.h b/sdk/runanywhere-commons/include/rac/features/tts/rac_tts_events.h new file mode 100644 index 000000000..fbf985904 --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/features/tts/rac_tts_events.h @@ -0,0 +1,54 @@ +/** + * @file rac_tts_events.h + * @brief TTS-specific event types - 1:1 port of TTSEvent.swift + * + * Swift Source: Sources/RunAnywhere/Features/TTS/Analytics/TTSEvent.swift + */ + +#ifndef RAC_TTS_EVENTS_H +#define RAC_TTS_EVENTS_H + +#include "rac/core/rac_types.h" +#include "rac/infrastructure/events/rac_events.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// TTS EVENT TYPES +// ============================================================================= + +typedef enum rac_tts_event_type { + RAC_TTS_EVENT_SYNTHESIS_STARTED = 0, + RAC_TTS_EVENT_SYNTHESIS_CHUNK, + RAC_TTS_EVENT_SYNTHESIS_COMPLETED, + RAC_TTS_EVENT_SYNTHESIS_FAILED, +} rac_tts_event_type_t; + +// ============================================================================= +// EVENT PUBLISHING FUNCTIONS +// ============================================================================= + +RAC_API rac_result_t rac_tts_event_synthesis_started(const char* synthesis_id, const char* model_id, + int32_t character_count, int32_t sample_rate, + rac_inference_framework_t framework); + +RAC_API rac_result_t rac_tts_event_synthesis_chunk(const char* synthesis_id, int32_t chunk_size); + +RAC_API rac_result_t rac_tts_event_synthesis_completed( + const char* synthesis_id, const char* model_id, int32_t character_count, + double audio_duration_ms, int32_t audio_size_bytes, double processing_duration_ms, + double characters_per_second, int32_t sample_rate, rac_inference_framework_t framework); + +RAC_API rac_result_t rac_tts_event_synthesis_failed(const char* synthesis_id, const char* model_id, + rac_result_t error_code, + const char* error_message); + +RAC_API const char* rac_tts_event_type_string(rac_tts_event_type_t event_type); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_TTS_EVENTS_H */ diff --git a/sdk/runanywhere-commons/include/rac/features/tts/rac_tts_service.h b/sdk/runanywhere-commons/include/rac/features/tts/rac_tts_service.h new file mode 100644 index 000000000..30d3e22b5 --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/features/tts/rac_tts_service.h @@ -0,0 +1,162 @@ +/** + * @file rac_tts_service.h + * @brief RunAnywhere Commons - TTS Service Interface + * + * Defines the generic TTS service API and vtable for multi-backend dispatch. + * Backends (ONNX, Platform/System TTS, etc.) implement the vtable and register + * with the service registry. + */ + +#ifndef RAC_TTS_SERVICE_H +#define RAC_TTS_SERVICE_H + +#include "rac/core/rac_error.h" +#include "rac/features/tts/rac_tts_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// SERVICE VTABLE - Backend implementations provide this +// ============================================================================= + +/** + * TTS Service operations vtable. + * Each backend implements these functions and provides a static vtable. + */ +typedef struct rac_tts_service_ops { + /** Initialize the service */ + rac_result_t (*initialize)(void* impl); + + /** Synthesize text to audio (blocking) */ + rac_result_t (*synthesize)(void* impl, const char* text, const rac_tts_options_t* options, + rac_tts_result_t* out_result); + + /** Stream synthesis for long text */ + rac_result_t (*synthesize_stream)(void* impl, const char* text, + const rac_tts_options_t* options, + rac_tts_stream_callback_t callback, void* user_data); + + /** Stop current synthesis */ + rac_result_t (*stop)(void* impl); + + /** Get service info */ + rac_result_t (*get_info)(void* impl, rac_tts_info_t* out_info); + + /** Cleanup/release resources (keeps service alive) */ + rac_result_t (*cleanup)(void* impl); + + /** Destroy the service */ + void (*destroy)(void* impl); +} rac_tts_service_ops_t; + +/** + * TTS Service instance. + * Contains vtable pointer and backend-specific implementation. + */ +typedef struct rac_tts_service { + /** Vtable with backend operations */ + const rac_tts_service_ops_t* ops; + + /** Backend-specific implementation handle */ + void* impl; + + /** Model/voice ID for reference */ + const char* model_id; +} rac_tts_service_t; + +// ============================================================================= +// PUBLIC API - Generic service functions +// ============================================================================= + +/** + * @brief Create a TTS service + * + * Routes through service registry to find appropriate backend. + * + * @param voice_id Voice/model identifier (registry ID or path) + * @param out_handle Output: Handle to the created service + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_tts_create(const char* voice_id, rac_handle_t* out_handle); + +/** + * @brief Initialize a TTS service + * + * @param handle Service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_tts_initialize(rac_handle_t handle); + +/** + * @brief Synthesize text to audio + * + * @param handle Service handle + * @param text Text to synthesize + * @param options Synthesis options (can be NULL for defaults) + * @param out_result Output: Synthesis result (caller must free with rac_tts_result_free) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_tts_synthesize(rac_handle_t handle, const char* text, + const rac_tts_options_t* options, + rac_tts_result_t* out_result); + +/** + * @brief Stream synthesis for long text + * + * @param handle Service handle + * @param text Text to synthesize + * @param options Synthesis options + * @param callback Callback for each audio chunk + * @param user_data User context passed to callback + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_tts_synthesize_stream(rac_handle_t handle, const char* text, + const rac_tts_options_t* options, + rac_tts_stream_callback_t callback, void* user_data); + +/** + * @brief Stop current synthesis + * + * @param handle Service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_tts_stop(rac_handle_t handle); + +/** + * @brief Get service information + * + * @param handle Service handle + * @param out_info Output: Service information + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_tts_get_info(rac_handle_t handle, rac_tts_info_t* out_info); + +/** + * @brief Cleanup and release resources + * + * @param handle Service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_tts_cleanup(rac_handle_t handle); + +/** + * @brief Destroy a TTS service instance + * + * @param handle Service handle to destroy + */ +RAC_API void rac_tts_destroy(rac_handle_t handle); + +/** + * @brief Free a TTS result + * + * @param result Result to free + */ +RAC_API void rac_tts_result_free(rac_tts_result_t* result); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_TTS_SERVICE_H */ diff --git a/sdk/runanywhere-commons/include/rac/features/tts/rac_tts_types.h b/sdk/runanywhere-commons/include/rac/features/tts/rac_tts_types.h new file mode 100644 index 000000000..8694c2b65 --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/features/tts/rac_tts_types.h @@ -0,0 +1,374 @@ +/** + * @file rac_tts_types.h + * @brief RunAnywhere Commons - TTS Types and Data Structures + * + * C port of Swift's TTS Models from: + * Sources/RunAnywhere/Features/TTS/Models/TTSConfiguration.swift + * Sources/RunAnywhere/Features/TTS/Models/TTSOptions.swift + * Sources/RunAnywhere/Features/TTS/Models/TTSInput.swift + * Sources/RunAnywhere/Features/TTS/Models/TTSOutput.swift + * + * This header defines data structures only. For the service interface, + * see rac_tts_service.h. + */ + +#ifndef RAC_TTS_TYPES_H +#define RAC_TTS_TYPES_H + +#include "rac/core/rac_types.h" +#include "rac/features/stt/rac_stt_types.h" // For rac_audio_format_enum_t + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// CONSTANTS - Single Source of Truth for TTS +// Swift references these via CRACommons import +// ============================================================================= + +// Audio Format Constants +#define RAC_TTS_DEFAULT_SAMPLE_RATE 22050 +#define RAC_TTS_HIGH_QUALITY_SAMPLE_RATE 24000 +#define RAC_TTS_CD_QUALITY_SAMPLE_RATE 44100 +#define RAC_TTS_MAX_SAMPLE_RATE 48000 +#define RAC_TTS_BYTES_PER_SAMPLE 2 +#define RAC_TTS_CHANNELS 1 + +// Speaking Rate Constants +#define RAC_TTS_DEFAULT_SPEAKING_RATE 1.0f +#define RAC_TTS_MIN_SPEAKING_RATE 0.5f +#define RAC_TTS_MAX_SPEAKING_RATE 2.0f + +// Streaming Constants +#define RAC_TTS_DEFAULT_STREAMING_CHUNK_BYTES 4096 + +// ============================================================================= +// CONFIGURATION - Mirrors Swift's TTSConfiguration +// ============================================================================= + +/** + * @brief TTS component configuration + * + * Mirrors Swift's TTSConfiguration struct exactly. + * See: Sources/RunAnywhere/Features/TTS/Models/TTSConfiguration.swift + */ +typedef struct rac_tts_config { + /** Model ID (voice identifier for TTS, optional) */ + const char* model_id; + + /** Preferred framework (use -1 for auto) */ + int32_t preferred_framework; + + /** Voice identifier to use for synthesis */ + const char* voice; + + /** Language for synthesis (BCP-47 format, e.g., "en-US") */ + const char* language; + + /** Speaking rate (0.5 to 2.0, 1.0 is normal) */ + float speaking_rate; + + /** Speech pitch (0.5 to 2.0, 1.0 is normal) */ + float pitch; + + /** Speech volume (0.0 to 1.0) */ + float volume; + + /** Audio format for output */ + rac_audio_format_enum_t audio_format; + + /** Whether to use neural/premium voice if available */ + rac_bool_t use_neural_voice; + + /** Whether to enable SSML markup support */ + rac_bool_t enable_ssml; +} rac_tts_config_t; + +/** + * @brief Default TTS configuration + */ +static const rac_tts_config_t RAC_TTS_CONFIG_DEFAULT = {.model_id = RAC_NULL, + .preferred_framework = -1, + .voice = RAC_NULL, + .language = "en-US", + .speaking_rate = 1.0f, + .pitch = 1.0f, + .volume = 1.0f, + .audio_format = RAC_AUDIO_FORMAT_PCM, + .use_neural_voice = RAC_TRUE, + .enable_ssml = RAC_FALSE}; + +// ============================================================================= +// OPTIONS - Mirrors Swift's TTSOptions +// ============================================================================= + +/** + * @brief TTS synthesis options + * + * Mirrors Swift's TTSOptions struct exactly. + * See: Sources/RunAnywhere/Features/TTS/Models/TTSOptions.swift + */ +typedef struct rac_tts_options { + /** Voice to use for synthesis (can be NULL for default) */ + const char* voice; + + /** Language for synthesis (BCP-47 format, e.g., "en-US") */ + const char* language; + + /** Speech rate (0.0 to 2.0, 1.0 is normal) */ + float rate; + + /** Speech pitch (0.0 to 2.0, 1.0 is normal) */ + float pitch; + + /** Speech volume (0.0 to 1.0) */ + float volume; + + /** Audio format for output */ + rac_audio_format_enum_t audio_format; + + /** Sample rate for output audio in Hz */ + int32_t sample_rate; + + /** Whether to use SSML markup */ + rac_bool_t use_ssml; +} rac_tts_options_t; + +/** + * @brief Default TTS options + */ +static const rac_tts_options_t RAC_TTS_OPTIONS_DEFAULT = {.voice = RAC_NULL, + .language = "en-US", + .rate = 1.0f, + .pitch = 1.0f, + .volume = 1.0f, + .audio_format = RAC_AUDIO_FORMAT_PCM, + .sample_rate = + RAC_TTS_DEFAULT_SAMPLE_RATE, + .use_ssml = RAC_FALSE}; + +// ============================================================================= +// INPUT - Mirrors Swift's TTSInput +// ============================================================================= + +/** + * @brief TTS input data + * + * Mirrors Swift's TTSInput struct exactly. + * See: Sources/RunAnywhere/Features/TTS/Models/TTSInput.swift + */ +typedef struct rac_tts_input { + /** Text to synthesize */ + const char* text; + + /** Optional SSML markup (overrides text if provided, can be NULL) */ + const char* ssml; + + /** Voice ID override (can be NULL) */ + const char* voice_id; + + /** Language override (can be NULL) */ + const char* language; + + /** Custom options override (can be NULL) */ + const rac_tts_options_t* options; +} rac_tts_input_t; + +/** + * @brief Default TTS input + */ +static const rac_tts_input_t RAC_TTS_INPUT_DEFAULT = {.text = RAC_NULL, + .ssml = RAC_NULL, + .voice_id = RAC_NULL, + .language = RAC_NULL, + .options = RAC_NULL}; + +// ============================================================================= +// RESULT - Mirrors Swift's TTS result +// ============================================================================= + +/** + * @brief TTS synthesis result + */ +typedef struct rac_tts_result { + /** Audio data (owned, must be freed with rac_free) */ + void* audio_data; + + /** Size of audio data in bytes */ + size_t audio_size; + + /** Audio format */ + rac_audio_format_enum_t audio_format; + + /** Sample rate */ + int32_t sample_rate; + + /** Duration in milliseconds */ + int64_t duration_ms; + + /** Processing time in milliseconds */ + int64_t processing_time_ms; +} rac_tts_result_t; + +// ============================================================================= +// INFO - Mirrors Swift's TTSService properties +// ============================================================================= + +/** + * @brief TTS service info + */ +typedef struct rac_tts_info { + /** Whether the service is ready */ + rac_bool_t is_ready; + + /** Whether currently synthesizing */ + rac_bool_t is_synthesizing; + + /** Available voices (null-terminated array) */ + const char* const* available_voices; + size_t num_voices; +} rac_tts_info_t; + +// ============================================================================= +// CALLBACKS +// ============================================================================= + +/** + * @brief TTS streaming callback + * + * Called for each audio chunk during streaming synthesis. + * + * @param audio_data Audio chunk data + * @param audio_size Size of audio chunk + * @param user_data User-provided context + */ +typedef void (*rac_tts_stream_callback_t)(const void* audio_data, size_t audio_size, + void* user_data); + +// ============================================================================= +// PHONEME TIMESTAMP - Mirrors Swift's TTSPhonemeTimestamp +// ============================================================================= + +/** + * @brief Phoneme timestamp information + * + * Mirrors Swift's TTSPhonemeTimestamp struct. + * See: Sources/RunAnywhere/Features/TTS/Models/TTSOutput.swift + */ +typedef struct rac_tts_phoneme_timestamp { + /** The phoneme */ + const char* phoneme; + + /** Start time in milliseconds */ + int64_t start_time_ms; + + /** End time in milliseconds */ + int64_t end_time_ms; +} rac_tts_phoneme_timestamp_t; + +// ============================================================================= +// SYNTHESIS METADATA - Mirrors Swift's TTSSynthesisMetadata +// ============================================================================= + +/** + * @brief Synthesis metadata + * + * Mirrors Swift's TTSSynthesisMetadata struct. + * See: Sources/RunAnywhere/Features/TTS/Models/TTSOutput.swift + */ +typedef struct rac_tts_synthesis_metadata { + /** Voice used for synthesis */ + const char* voice; + + /** Language used for synthesis */ + const char* language; + + /** Processing time in milliseconds */ + int64_t processing_time_ms; + + /** Number of characters synthesized */ + int32_t character_count; + + /** Characters processed per second */ + float characters_per_second; +} rac_tts_synthesis_metadata_t; + +// ============================================================================= +// OUTPUT - Mirrors Swift's TTSOutput +// ============================================================================= + +/** + * @brief TTS output data + * + * Mirrors Swift's TTSOutput struct exactly. + * See: Sources/RunAnywhere/Features/TTS/Models/TTSOutput.swift + */ +typedef struct rac_tts_output { + /** Synthesized audio data (owned, must be freed with rac_free) */ + void* audio_data; + + /** Size of audio data in bytes */ + size_t audio_size; + + /** Audio format of the output */ + rac_audio_format_enum_t format; + + /** Duration of the audio in milliseconds */ + int64_t duration_ms; + + /** Phoneme timestamps if available (can be NULL) */ + rac_tts_phoneme_timestamp_t* phoneme_timestamps; + size_t num_phoneme_timestamps; + + /** Processing metadata */ + rac_tts_synthesis_metadata_t metadata; + + /** Timestamp in milliseconds since epoch */ + int64_t timestamp_ms; +} rac_tts_output_t; + +// ============================================================================= +// SPEAK RESULT - Mirrors Swift's TTSSpeakResult +// ============================================================================= + +/** + * @brief Speak result (metadata only, no audio data) + * + * Mirrors Swift's TTSSpeakResult struct. + * The SDK handles audio playback internally when using speak(). + * See: Sources/RunAnywhere/Features/TTS/Models/TTSOutput.swift + */ +typedef struct rac_tts_speak_result { + /** Duration of the spoken audio in milliseconds */ + int64_t duration_ms; + + /** Audio format used */ + rac_audio_format_enum_t format; + + /** Audio size in bytes (0 for system TTS which plays directly) */ + size_t audio_size_bytes; + + /** Synthesis metadata */ + rac_tts_synthesis_metadata_t metadata; + + /** Timestamp when speech completed (milliseconds since epoch) */ + int64_t timestamp_ms; +} rac_tts_speak_result_t; + +// ============================================================================= +// MEMORY MANAGEMENT +// ============================================================================= + +/** + * @brief Free TTS result resources + * + * @param result Result to free (can be NULL) + */ +RAC_API void rac_tts_result_free(rac_tts_result_t* result); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_TTS_TYPES_H */ diff --git a/sdk/runanywhere-commons/include/rac/features/vad/rac_vad.h b/sdk/runanywhere-commons/include/rac/features/vad/rac_vad.h new file mode 100644 index 000000000..1a5a2f47b --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/features/vad/rac_vad.h @@ -0,0 +1,17 @@ +/** + * @file rac_vad.h + * @brief RunAnywhere Commons - VAD API (Convenience Header) + * + * This header includes both types and service interface for convenience. + * For better separation of concerns, prefer including: + * - rac_vad_types.h for data structures only + * - rac_vad_service.h for the service interface + */ + +#ifndef RAC_VAD_H +#define RAC_VAD_H + +#include "rac/features/vad/rac_vad_service.h" +#include "rac/features/vad/rac_vad_types.h" + +#endif /* RAC_VAD_H */ diff --git a/sdk/runanywhere-commons/include/rac/features/vad/rac_vad_analytics.h b/sdk/runanywhere-commons/include/rac/features/vad/rac_vad_analytics.h new file mode 100644 index 000000000..ac4490e3b --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/features/vad/rac_vad_analytics.h @@ -0,0 +1,236 @@ +/** + * @file rac_vad_analytics.h + * @brief VAD analytics service - 1:1 port of VADAnalyticsService.swift + * + * Tracks VAD operations and metrics. + * + * Swift Source: Sources/RunAnywhere/Features/VAD/Analytics/VADAnalyticsService.swift + */ + +#ifndef RAC_VAD_ANALYTICS_H +#define RAC_VAD_ANALYTICS_H + +#include "rac/core/rac_types.h" +#include "rac/features/vad/rac_vad_types.h" +#include "rac/infrastructure/model_management/rac_model_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// TYPES +// ============================================================================= + +/** + * @brief Opaque handle for VAD analytics service + */ +typedef struct rac_vad_analytics_s* rac_vad_analytics_handle_t; + +/** + * @brief VAD metrics structure + * Mirrors Swift's VADMetrics struct + */ +typedef struct rac_vad_metrics { + /** Total number of events tracked */ + int32_t total_events; + + /** Start time (milliseconds since epoch) */ + int64_t start_time_ms; + + /** Last event time (milliseconds since epoch, 0 if no events) */ + int64_t last_event_time_ms; + + /** Total number of speech segments detected */ + int32_t total_speech_segments; + + /** Total speech duration in milliseconds */ + double total_speech_duration_ms; + + /** Average speech duration in milliseconds (-1 if no segments) */ + double average_speech_duration_ms; + + /** Current framework being used */ + rac_inference_framework_t framework; +} rac_vad_metrics_t; + +// ============================================================================= +// LIFECYCLE +// ============================================================================= + +/** + * @brief Create a VAD analytics service instance + * + * @param out_handle Output: Handle to the created service + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_analytics_create(rac_vad_analytics_handle_t* out_handle); + +/** + * @brief Destroy a VAD analytics service instance + * + * @param handle Handle to destroy + */ +RAC_API void rac_vad_analytics_destroy(rac_vad_analytics_handle_t handle); + +// ============================================================================= +// LIFECYCLE TRACKING +// ============================================================================= + +/** + * @brief Track VAD initialization + * + * @param handle Analytics service handle + * @param framework The inference framework being used + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_analytics_track_initialized(rac_vad_analytics_handle_t handle, + rac_inference_framework_t framework); + +/** + * @brief Track VAD initialization failure + * + * @param handle Analytics service handle + * @param error_code Error code + * @param error_message Error message + * @param framework The inference framework + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_analytics_track_initialization_failed( + rac_vad_analytics_handle_t handle, rac_result_t error_code, const char* error_message, + rac_inference_framework_t framework); + +/** + * @brief Track VAD cleanup + * + * @param handle Analytics service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_analytics_track_cleaned_up(rac_vad_analytics_handle_t handle); + +// ============================================================================= +// DETECTION TRACKING +// ============================================================================= + +/** + * @brief Track VAD started + * + * @param handle Analytics service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_analytics_track_started(rac_vad_analytics_handle_t handle); + +/** + * @brief Track VAD stopped + * + * @param handle Analytics service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_analytics_track_stopped(rac_vad_analytics_handle_t handle); + +/** + * @brief Track speech detected (start of speech/voice activity) + * + * @param handle Analytics service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_analytics_track_speech_start(rac_vad_analytics_handle_t handle); + +/** + * @brief Track speech ended (silence detected after speech) + * + * @param handle Analytics service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_analytics_track_speech_end(rac_vad_analytics_handle_t handle); + +/** + * @brief Track VAD paused + * + * @param handle Analytics service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_analytics_track_paused(rac_vad_analytics_handle_t handle); + +/** + * @brief Track VAD resumed + * + * @param handle Analytics service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_analytics_track_resumed(rac_vad_analytics_handle_t handle); + +// ============================================================================= +// MODEL LIFECYCLE (for model-based VAD) +// ============================================================================= + +/** + * @brief Track model load started + * + * @param handle Analytics service handle + * @param model_id The model identifier + * @param model_size_bytes Size of the model in bytes + * @param framework The inference framework + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_analytics_track_model_load_started( + rac_vad_analytics_handle_t handle, const char* model_id, int64_t model_size_bytes, + rac_inference_framework_t framework); + +/** + * @brief Track model load completed + * + * @param handle Analytics service handle + * @param model_id The model identifier + * @param duration_ms Time taken to load in milliseconds + * @param model_size_bytes Size of the model in bytes + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_analytics_track_model_load_completed(rac_vad_analytics_handle_t handle, + const char* model_id, + double duration_ms, + int64_t model_size_bytes); + +/** + * @brief Track model load failed + * + * @param handle Analytics service handle + * @param model_id The model identifier + * @param error_code Error code + * @param error_message Error message + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_analytics_track_model_load_failed(rac_vad_analytics_handle_t handle, + const char* model_id, + rac_result_t error_code, + const char* error_message); + +/** + * @brief Track model unloaded + * + * @param handle Analytics service handle + * @param model_id The model identifier + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_analytics_track_model_unloaded(rac_vad_analytics_handle_t handle, + const char* model_id); + +// ============================================================================= +// METRICS +// ============================================================================= + +/** + * @brief Get current analytics metrics + * + * @param handle Analytics service handle + * @param out_metrics Output: Metrics structure + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_analytics_get_metrics(rac_vad_analytics_handle_t handle, + rac_vad_metrics_t* out_metrics); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_VAD_ANALYTICS_H */ diff --git a/sdk/runanywhere-commons/include/rac/features/vad/rac_vad_component.h b/sdk/runanywhere-commons/include/rac/features/vad/rac_vad_component.h new file mode 100644 index 000000000..0fcbd8ef6 --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/features/vad/rac_vad_component.h @@ -0,0 +1,185 @@ +/** + * @file rac_vad_component.h + * @brief RunAnywhere Commons - VAD Capability Component + * + * C port of Swift's VADCapability.swift from: + * Sources/RunAnywhere/Features/VAD/VADCapability.swift + * + * Actor-based VAD capability that owns model lifecycle and voice detection. + * Uses lifecycle manager for unified lifecycle + analytics handling. + */ + +#ifndef RAC_VAD_COMPONENT_H +#define RAC_VAD_COMPONENT_H + +#include "rac/core/capabilities/rac_lifecycle.h" +#include "rac/core/rac_error.h" +#include "rac/features/vad/rac_vad_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// NOTE: rac_vad_config_t is defined in rac_vad_types.h (included above) + +// ============================================================================= +// VAD COMPONENT API - Mirrors Swift's VADCapability +// ============================================================================= + +/** + * @brief Create a VAD capability component + * + * @param out_handle Output: Handle to the component + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_component_create(rac_handle_t* out_handle); + +/** + * @brief Configure the VAD component + * + * @param handle Component handle + * @param config Configuration + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_component_configure(rac_handle_t handle, + const rac_vad_config_t* config); + +/** + * @brief Check if VAD is initialized + * + * @param handle Component handle + * @return RAC_TRUE if initialized, RAC_FALSE otherwise + */ +RAC_API rac_bool_t rac_vad_component_is_initialized(rac_handle_t handle); + +/** + * @brief Initialize the VAD + * + * @param handle Component handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_component_initialize(rac_handle_t handle); + +/** + * @brief Cleanup and reset the component + * + * @param handle Component handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_component_cleanup(rac_handle_t handle); + +/** + * @brief Set speech activity callback + * + * @param handle Component handle + * @param callback Activity callback + * @param user_data User context passed to callback + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_component_set_activity_callback(rac_handle_t handle, + rac_vad_activity_callback_fn callback, + void* user_data); + +/** + * @brief Set audio buffer callback + * + * @param handle Component handle + * @param callback Audio buffer callback + * @param user_data User context passed to callback + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_component_set_audio_callback(rac_handle_t handle, + rac_vad_audio_callback_fn callback, + void* user_data); + +/** + * @brief Start VAD processing + * + * @param handle Component handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_component_start(rac_handle_t handle); + +/** + * @brief Stop VAD processing + * + * @param handle Component handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_component_stop(rac_handle_t handle); + +/** + * @brief Reset VAD state + * + * @param handle Component handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_component_reset(rac_handle_t handle); + +/** + * @brief Process audio samples + * + * @param handle Component handle + * @param samples Float audio samples (PCM) + * @param num_samples Number of samples + * @param out_is_speech Output: Whether speech is detected + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_component_process(rac_handle_t handle, const float* samples, + size_t num_samples, rac_bool_t* out_is_speech); + +/** + * @brief Get current speech activity state + * + * @param handle Component handle + * @return RAC_TRUE if speech is active, RAC_FALSE otherwise + */ +RAC_API rac_bool_t rac_vad_component_is_speech_active(rac_handle_t handle); + +/** + * @brief Get current energy threshold + * + * @param handle Component handle + * @return Current energy threshold + */ +RAC_API float rac_vad_component_get_energy_threshold(rac_handle_t handle); + +/** + * @brief Set energy threshold + * + * @param handle Component handle + * @param threshold New threshold (0.0 to 1.0) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_component_set_energy_threshold(rac_handle_t handle, float threshold); + +/** + * @brief Get lifecycle state + * + * @param handle Component handle + * @return Current lifecycle state + */ +RAC_API rac_lifecycle_state_t rac_vad_component_get_state(rac_handle_t handle); + +/** + * @brief Get lifecycle metrics + * + * @param handle Component handle + * @param out_metrics Output: Lifecycle metrics + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_component_get_metrics(rac_handle_t handle, + rac_lifecycle_metrics_t* out_metrics); + +/** + * @brief Destroy the VAD component + * + * @param handle Component handle + */ +RAC_API void rac_vad_component_destroy(rac_handle_t handle); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_VAD_COMPONENT_H */ diff --git a/sdk/runanywhere-commons/include/rac/features/vad/rac_vad_energy.h b/sdk/runanywhere-commons/include/rac/features/vad/rac_vad_energy.h new file mode 100644 index 000000000..8f146da5f --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/features/vad/rac_vad_energy.h @@ -0,0 +1,443 @@ +/** + * @file rac_vad_energy.h + * @brief Energy-based Voice Activity Detection + * + * C port of Swift's SimpleEnergyVADService.swift + * Swift Source: Sources/RunAnywhere/Features/VAD/Services/SimpleEnergyVADService.swift + * + * IMPORTANT: This is a direct translation of the Swift implementation. + * Do NOT add features not present in the Swift code. + */ + +#ifndef RAC_VAD_ENERGY_H +#define RAC_VAD_ENERGY_H + +#include "rac/core/rac_error.h" +#include "rac/core/rac_types.h" +#include "rac/features/vad/rac_vad_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// CONSTANTS - Mirrors Swift's VADConstants +// NOTE: Core constants (RAC_VAD_DEFAULT_SAMPLE_RATE, RAC_VAD_DEFAULT_FRAME_LENGTH, +// RAC_VAD_DEFAULT_ENERGY_THRESHOLD, RAC_VAD_DEFAULT_CALIBRATION_MULTIPLIER) +// are defined in rac_vad_types.h +// ============================================================================= + +/** Frames of voice needed to start speech (normal mode) */ +#define RAC_VAD_VOICE_START_THRESHOLD 1 + +/** Frames of silence needed to end speech (normal mode) */ +#define RAC_VAD_VOICE_END_THRESHOLD 12 + +/** Frames of voice needed during TTS (prevents feedback) */ +#define RAC_VAD_TTS_VOICE_START_THRESHOLD 10 + +/** Frames of silence needed during TTS */ +#define RAC_VAD_TTS_VOICE_END_THRESHOLD 5 + +/** Number of calibration frames needed (~2 seconds at 100ms) */ +#define RAC_VAD_CALIBRATION_FRAMES_NEEDED 20 + +/** Default calibration multiplier */ +#define RAC_VAD_DEFAULT_CALIBRATION_MULTIPLIER 2.0f + +/** Default TTS threshold multiplier */ +#define RAC_VAD_DEFAULT_TTS_THRESHOLD_MULTIPLIER 3.0f + +/** Maximum threshold cap */ +#define RAC_VAD_MAX_THRESHOLD 0.020f + +/** Minimum threshold */ +#define RAC_VAD_MIN_THRESHOLD 0.003f + +/** Maximum recent values for statistics */ +#define RAC_VAD_MAX_RECENT_VALUES 50 + +// ============================================================================= +// TYPES +// ============================================================================= + +/** + * @brief Opaque handle for energy VAD service. + */ +typedef struct rac_energy_vad* rac_energy_vad_handle_t; + +/** + * @brief Speech activity event types. + * Mirrors Swift's SpeechActivityEvent enum. + */ +typedef enum rac_speech_activity_event { + RAC_SPEECH_ACTIVITY_STARTED = 0, /**< Speech has started */ + RAC_SPEECH_ACTIVITY_ENDED = 1 /**< Speech has ended */ +} rac_speech_activity_event_t; + +/** + * @brief Configuration for energy VAD. + * Mirrors Swift's SimpleEnergyVADService init parameters. + */ +typedef struct rac_energy_vad_config { + /** Audio sample rate (default: 16000) */ + int32_t sample_rate; + + /** Frame length in seconds (default: 0.1 = 100ms) */ + float frame_length; + + /** Energy threshold for voice detection (default: 0.005) */ + float energy_threshold; +} rac_energy_vad_config_t; + +/** + * @brief Default energy VAD configuration. + */ +static const rac_energy_vad_config_t RAC_ENERGY_VAD_CONFIG_DEFAULT = { + .sample_rate = RAC_VAD_DEFAULT_SAMPLE_RATE, + .frame_length = RAC_VAD_DEFAULT_FRAME_LENGTH, + .energy_threshold = RAC_VAD_DEFAULT_ENERGY_THRESHOLD}; + +/** + * @brief Energy VAD statistics for debugging. + * Mirrors Swift's SimpleEnergyVADService.getStatistics(). + * Note: This is separate from rac_vad_statistics_t in rac_vad_types.h + */ +typedef struct rac_energy_vad_stats { + /** Current energy value */ + float current; + + /** Current threshold value */ + float threshold; + + /** Ambient noise level from calibration */ + float ambient; + + /** Recent average energy */ + float recent_avg; + + /** Recent maximum energy */ + float recent_max; +} rac_energy_vad_stats_t; + +/** + * @brief Callback for speech activity events. + * Mirrors Swift's onSpeechActivity callback. + * + * @param event The speech activity event type + * @param user_data User-provided context + */ +typedef void (*rac_speech_activity_callback_fn)(rac_speech_activity_event_t event, void* user_data); + +/** + * @brief Callback for processed audio buffers. + * Mirrors Swift's onAudioBuffer callback. + * + * @param audio_data Audio data buffer + * @param audio_size Size of audio data in bytes + * @param user_data User-provided context + */ +typedef void (*rac_audio_buffer_callback_fn)(const void* audio_data, size_t audio_size, + void* user_data); + +// ============================================================================= +// LIFECYCLE API - Mirrors Swift's VADService protocol +// ============================================================================= + +/** + * @brief Create an energy VAD service. + * + * Mirrors Swift's SimpleEnergyVADService init. + * + * @param config Configuration (can be NULL for defaults) + * @param out_handle Output: Handle to the created service + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_energy_vad_create(const rac_energy_vad_config_t* config, + rac_energy_vad_handle_t* out_handle); + +/** + * @brief Destroy an energy VAD service. + * + * @param handle Service handle to destroy + */ +RAC_API void rac_energy_vad_destroy(rac_energy_vad_handle_t handle); + +/** + * @brief Initialize the VAD service. + * + * Mirrors Swift's VADService.initialize(). + * This starts the service and begins calibration. + * + * @param handle Service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_energy_vad_initialize(rac_energy_vad_handle_t handle); + +/** + * @brief Start voice activity detection. + * + * Mirrors Swift's SimpleEnergyVADService.start(). + * + * @param handle Service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_energy_vad_start(rac_energy_vad_handle_t handle); + +/** + * @brief Stop voice activity detection. + * + * Mirrors Swift's SimpleEnergyVADService.stop(). + * + * @param handle Service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_energy_vad_stop(rac_energy_vad_handle_t handle); + +/** + * @brief Reset the VAD state. + * + * Mirrors Swift's VADService.reset(). + * + * @param handle Service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_energy_vad_reset(rac_energy_vad_handle_t handle); + +// ============================================================================= +// PROCESSING API +// ============================================================================= + +/** + * @brief Process raw audio data for voice activity detection. + * + * Mirrors Swift's SimpleEnergyVADService.processAudioData(_:). + * + * @param handle Service handle + * @param audio_data Array of audio samples (float32) + * @param sample_count Number of samples + * @param out_has_voice Output: Whether voice was detected + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_energy_vad_process_audio(rac_energy_vad_handle_t handle, + const float* audio_data, size_t sample_count, + rac_bool_t* out_has_voice); + +/** + * @brief Calculate RMS energy of an audio signal. + * + * Mirrors Swift's calculateAverageEnergy(of:) using vDSP_rmsqv. + * + * @param audio_data Array of audio samples (float32) + * @param sample_count Number of samples + * @return RMS energy value, or 0.0 if empty + */ +RAC_API float rac_energy_vad_calculate_rms(const float* __restrict audio_data,size_t sample_count); + +// ============================================================================= +// PAUSE/RESUME API +// ============================================================================= + +/** + * @brief Pause VAD processing. + * + * Mirrors Swift's SimpleEnergyVADService.pause(). + * + * @param handle Service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_energy_vad_pause(rac_energy_vad_handle_t handle); + +/** + * @brief Resume VAD processing. + * + * Mirrors Swift's SimpleEnergyVADService.resume(). + * + * @param handle Service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_energy_vad_resume(rac_energy_vad_handle_t handle); + +// ============================================================================= +// CALIBRATION API +// ============================================================================= + +/** + * @brief Start automatic calibration to determine ambient noise level. + * + * Mirrors Swift's SimpleEnergyVADService.startCalibration(). + * Non-blocking; call rac_energy_vad_is_calibrating() to check status. + * + * @param handle Service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_energy_vad_start_calibration(rac_energy_vad_handle_t handle); + +/** + * @brief Check if calibration is in progress. + * + * @param handle Service handle + * @param out_is_calibrating Output: RAC_TRUE if calibrating + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_energy_vad_is_calibrating(rac_energy_vad_handle_t handle, + rac_bool_t* out_is_calibrating); + +/** + * @brief Set calibration parameters. + * + * Mirrors Swift's setCalibrationParameters(multiplier:). + * + * @param handle Service handle + * @param multiplier Calibration multiplier (clamped to 1.5-4.0) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_energy_vad_set_calibration_multiplier(rac_energy_vad_handle_t handle, + float multiplier); + +// ============================================================================= +// TTS FEEDBACK PREVENTION API +// ============================================================================= + +/** + * @brief Notify VAD that TTS is about to start playing. + * + * Mirrors Swift's notifyTTSWillStart(). + * Increases threshold to prevent TTS audio from triggering VAD. + * + * @param handle Service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_energy_vad_notify_tts_start(rac_energy_vad_handle_t handle); + +/** + * @brief Notify VAD that TTS has finished playing. + * + * Mirrors Swift's notifyTTSDidFinish(). + * Restores threshold to base value. + * + * @param handle Service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_energy_vad_notify_tts_finish(rac_energy_vad_handle_t handle); + +/** + * @brief Set TTS threshold multiplier. + * + * Mirrors Swift's setTTSThresholdMultiplier(_:). + * + * @param handle Service handle + * @param multiplier TTS threshold multiplier (clamped to 2.0-5.0) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_energy_vad_set_tts_multiplier(rac_energy_vad_handle_t handle, + float multiplier); + +// ============================================================================= +// STATE QUERY API +// ============================================================================= + +/** + * @brief Check if speech is currently active. + * + * Mirrors Swift's VADService.isSpeechActive property. + * + * @param handle Service handle + * @param out_is_active Output: RAC_TRUE if speech is active + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_energy_vad_is_speech_active(rac_energy_vad_handle_t handle, + rac_bool_t* out_is_active); + +/** + * @brief Get current energy threshold. + * + * @param handle Service handle + * @param out_threshold Output: Current threshold value + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_energy_vad_get_threshold(rac_energy_vad_handle_t handle, + float* out_threshold); + +/** + * @brief Set energy threshold. + * + * @param handle Service handle + * @param threshold New threshold value + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_energy_vad_set_threshold(rac_energy_vad_handle_t handle, float threshold); + +/** + * @brief Get VAD statistics for debugging. + * + * Mirrors Swift's getStatistics(). + * + * @param handle Service handle + * @param out_stats Output: VAD statistics + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_energy_vad_get_statistics(rac_energy_vad_handle_t handle, + rac_energy_vad_stats_t* out_stats); + +/** + * @brief Get sample rate. + * + * Mirrors Swift's sampleRate property. + * + * @param handle Service handle + * @param out_sample_rate Output: Sample rate + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_energy_vad_get_sample_rate(rac_energy_vad_handle_t handle, + int32_t* out_sample_rate); + +/** + * @brief Get frame length in samples. + * + * Mirrors Swift's frameLengthSamples property. + * + * @param handle Service handle + * @param out_frame_length Output: Frame length in samples + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_energy_vad_get_frame_length_samples(rac_energy_vad_handle_t handle, + int32_t* out_frame_length); + +// ============================================================================= +// CALLBACK API +// ============================================================================= + +/** + * @brief Set speech activity callback. + * + * Mirrors Swift's onSpeechActivity property. + * + * @param handle Service handle + * @param callback Callback function (can be NULL to clear) + * @param user_data User-provided context + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_energy_vad_set_speech_callback(rac_energy_vad_handle_t handle, + rac_speech_activity_callback_fn callback, + void* user_data); + +/** + * @brief Set audio buffer callback. + * + * Mirrors Swift's onAudioBuffer property. + * + * @param handle Service handle + * @param callback Callback function (can be NULL to clear) + * @param user_data User-provided context + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_energy_vad_set_audio_callback(rac_energy_vad_handle_t handle, + rac_audio_buffer_callback_fn callback, + void* user_data); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_VAD_ENERGY_H */ diff --git a/sdk/runanywhere-commons/include/rac/features/vad/rac_vad_events.h b/sdk/runanywhere-commons/include/rac/features/vad/rac_vad_events.h new file mode 100644 index 000000000..9c78e9370 --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/features/vad/rac_vad_events.h @@ -0,0 +1,76 @@ +/** + * @file rac_vad_events.h + * @brief VAD-specific event types - 1:1 port of VADEvent.swift + * + * Swift Source: Sources/RunAnywhere/Features/VAD/Analytics/VADEvent.swift + */ + +#ifndef RAC_VAD_EVENTS_H +#define RAC_VAD_EVENTS_H + +#include "rac/core/rac_types.h" +#include "rac/infrastructure/events/rac_events.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// VAD EVENT TYPES +// ============================================================================= + +typedef enum rac_vad_event_type { + RAC_VAD_EVENT_INITIALIZED = 0, + RAC_VAD_EVENT_INITIALIZATION_FAILED, + RAC_VAD_EVENT_CLEANED_UP, + RAC_VAD_EVENT_STARTED, + RAC_VAD_EVENT_STOPPED, + RAC_VAD_EVENT_SPEECH_STARTED, + RAC_VAD_EVENT_SPEECH_ENDED, + RAC_VAD_EVENT_PAUSED, + RAC_VAD_EVENT_RESUMED, + RAC_VAD_EVENT_MODEL_LOAD_STARTED, + RAC_VAD_EVENT_MODEL_LOAD_COMPLETED, + RAC_VAD_EVENT_MODEL_LOAD_FAILED, + RAC_VAD_EVENT_MODEL_UNLOADED, +} rac_vad_event_type_t; + +// ============================================================================= +// EVENT PUBLISHING FUNCTIONS +// ============================================================================= + +RAC_API rac_result_t rac_vad_event_initialized(rac_inference_framework_t framework); + +RAC_API rac_result_t rac_vad_event_initialization_failed(rac_result_t error_code, + const char* error_message, + rac_inference_framework_t framework); + +RAC_API rac_result_t rac_vad_event_cleaned_up(void); +RAC_API rac_result_t rac_vad_event_started(void); +RAC_API rac_result_t rac_vad_event_stopped(void); +RAC_API rac_result_t rac_vad_event_speech_started(void); +RAC_API rac_result_t rac_vad_event_speech_ended(double duration_ms); +RAC_API rac_result_t rac_vad_event_paused(void); +RAC_API rac_result_t rac_vad_event_resumed(void); + +RAC_API rac_result_t rac_vad_event_model_load_started(const char* model_id, + int64_t model_size_bytes, + rac_inference_framework_t framework); + +RAC_API rac_result_t rac_vad_event_model_load_completed(const char* model_id, double duration_ms, + int64_t model_size_bytes, + rac_inference_framework_t framework); + +RAC_API rac_result_t rac_vad_event_model_load_failed(const char* model_id, rac_result_t error_code, + const char* error_message, + rac_inference_framework_t framework); + +RAC_API rac_result_t rac_vad_event_model_unloaded(const char* model_id); + +RAC_API const char* rac_vad_event_type_string(rac_vad_event_type_t event_type); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_VAD_EVENTS_H */ diff --git a/sdk/runanywhere-commons/include/rac/features/vad/rac_vad_service.h b/sdk/runanywhere-commons/include/rac/features/vad/rac_vad_service.h new file mode 100644 index 000000000..5874cf219 --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/features/vad/rac_vad_service.h @@ -0,0 +1,167 @@ +/** + * @file rac_vad_service.h + * @brief RunAnywhere Commons - VAD Service Interface (Protocol) + * + * C port of Swift's VADService protocol from: + * Sources/RunAnywhere/Features/VAD/Protocol/VADService.swift + * + * This header defines the service interface. For data types, + * see rac_vad_types.h. + */ + +#ifndef RAC_VAD_SERVICE_H +#define RAC_VAD_SERVICE_H + +#include "rac/core/rac_error.h" +#include "rac/features/vad/rac_vad_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// SERVICE INTERFACE - Mirrors Swift's VADService protocol +// ============================================================================= + +/** + * @brief Create a VAD service + * + * @param out_handle Output: Handle to the created service + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_create(rac_handle_t* out_handle); + +/** + * @brief Initialize the VAD service + * + * Mirrors Swift's VADService.initialize() + * + * @param handle Service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_initialize(rac_handle_t handle); + +/** + * @brief Set speech activity callback + * + * Mirrors Swift's VADService.onSpeechActivity property. + * + * @param handle Service handle + * @param callback Activity callback (can be NULL to unset) + * @param user_data User context passed to callback + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_set_activity_callback(rac_handle_t handle, + rac_vad_activity_callback_fn callback, + void* user_data); + +/** + * @brief Set audio buffer callback + * + * Mirrors Swift's VADService.onAudioBuffer property. + * + * @param handle Service handle + * @param callback Audio callback (can be NULL to unset) + * @param user_data User context passed to callback + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_set_audio_callback(rac_handle_t handle, + rac_vad_audio_callback_fn callback, + void* user_data); + +/** + * @brief Start VAD processing + * + * Mirrors Swift's VADService.start() + * + * @param handle Service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_start(rac_handle_t handle); + +/** + * @brief Stop VAD processing + * + * Mirrors Swift's VADService.stop() + * + * @param handle Service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_stop(rac_handle_t handle); + +/** + * @brief Reset VAD state + * + * Mirrors Swift's VADService.reset() + * + * @param handle Service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_reset(rac_handle_t handle); + +/** + * @brief Pause VAD processing + * + * Mirrors Swift's VADService.pause() (optional, default no-op) + * + * @param handle Service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_pause(rac_handle_t handle); + +/** + * @brief Resume VAD processing + * + * Mirrors Swift's VADService.resume() (optional, default no-op) + * + * @param handle Service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_resume(rac_handle_t handle); + +/** + * @brief Process audio samples + * + * Mirrors Swift's VADService.processAudioData(_:) + * + * @param handle Service handle + * @param samples Float audio samples (PCM) + * @param num_samples Number of samples + * @param out_is_speech Output: Whether speech is detected + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_process_samples(rac_handle_t handle, const float* samples, + size_t num_samples, rac_bool_t* out_is_speech); + +/** + * @brief Set energy threshold + * + * Mirrors Swift's VADService.energyThreshold setter. + * + * @param handle Service handle + * @param threshold New threshold (0.0 to 1.0) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_set_energy_threshold(rac_handle_t handle, float threshold); + +/** + * @brief Get service information + * + * @param handle Service handle + * @param out_info Output: Service information + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_get_info(rac_handle_t handle, rac_vad_info_t* out_info); + +/** + * @brief Destroy a VAD service instance + * + * @param handle Service handle to destroy + */ +RAC_API void rac_vad_destroy(rac_handle_t handle); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_VAD_SERVICE_H */ diff --git a/sdk/runanywhere-commons/include/rac/features/vad/rac_vad_types.h b/sdk/runanywhere-commons/include/rac/features/vad/rac_vad_types.h new file mode 100644 index 000000000..cdbf67fb6 --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/features/vad/rac_vad_types.h @@ -0,0 +1,244 @@ +/** + * @file rac_vad_types.h + * @brief RunAnywhere Commons - VAD Types and Data Structures + * + * C port of Swift's VAD Models from: + * Sources/RunAnywhere/Features/VAD/Models/VADConfiguration.swift + * Sources/RunAnywhere/Features/VAD/Models/VADInput.swift + * Sources/RunAnywhere/Features/VAD/Models/VADOutput.swift + * Sources/RunAnywhere/Features/VAD/VADConstants.swift + * + * This header defines data structures only. For the service interface, + * see rac_vad_service.h. + */ + +#ifndef RAC_VAD_TYPES_H +#define RAC_VAD_TYPES_H + +#include "rac/core/rac_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// CONSTANTS - Single Source of Truth for VAD +// Swift references these via CRACommons import +// ============================================================================= + +// Audio Format Constants +#define RAC_VAD_DEFAULT_SAMPLE_RATE 16000 +#define RAC_VAD_MAX_SAMPLE_RATE 48000 +#define RAC_VAD_MIN_SAMPLE_RATE 8000 + +// Energy Thresholds +#define RAC_VAD_DEFAULT_ENERGY_THRESHOLD 0.015f +#define RAC_VAD_MIN_ENERGY_THRESHOLD 0.001f +#define RAC_VAD_MAX_ENERGY_THRESHOLD 0.5f + +// Frame Processing +#define RAC_VAD_DEFAULT_FRAME_LENGTH 0.1f +#define RAC_VAD_MIN_FRAME_LENGTH 0.02f +#define RAC_VAD_MAX_FRAME_LENGTH 0.5f + +// Calibration +#define RAC_VAD_DEFAULT_CALIBRATION_MULTIPLIER 2.0f +#define RAC_VAD_MIN_CALIBRATION_MULTIPLIER 1.2f +#define RAC_VAD_MAX_CALIBRATION_MULTIPLIER 5.0f + +// Speech Detection +#define RAC_VAD_MIN_SPEECH_DURATION_MS 100 +#define RAC_VAD_MIN_SILENCE_DURATION_MS 300 + +// ============================================================================= +// CONFIGURATION - Mirrors Swift's VADConfiguration +// ============================================================================= + +/** + * @brief VAD component configuration + * + * Mirrors Swift's VADConfiguration struct exactly. + * See: Sources/RunAnywhere/Features/VAD/Models/VADConfiguration.swift + */ +typedef struct rac_vad_config { + /** Model ID (not used for VAD, can be NULL) */ + const char* model_id; + + /** Preferred framework (use -1 for auto) */ + int32_t preferred_framework; + + /** Energy threshold for voice detection (0.0 to 1.0) */ + float energy_threshold; + + /** Sample rate in Hz (default: 16000) */ + int32_t sample_rate; + + /** Frame length in seconds (default: 0.1 = 100ms) */ + float frame_length; + + /** Enable automatic calibration */ + rac_bool_t enable_auto_calibration; + + /** Calibration multiplier (threshold = ambient noise * multiplier) */ + float calibration_multiplier; +} rac_vad_config_t; + +/** + * @brief Default VAD configuration + */ +static const rac_vad_config_t RAC_VAD_CONFIG_DEFAULT = { + .model_id = RAC_NULL, + .preferred_framework = -1, + .energy_threshold = RAC_VAD_DEFAULT_ENERGY_THRESHOLD, + .sample_rate = RAC_VAD_DEFAULT_SAMPLE_RATE, + .frame_length = RAC_VAD_DEFAULT_FRAME_LENGTH, + .enable_auto_calibration = RAC_FALSE, + .calibration_multiplier = RAC_VAD_DEFAULT_CALIBRATION_MULTIPLIER}; + +// ============================================================================= +// SPEECH ACTIVITY - Mirrors Swift's SpeechActivityEvent +// ============================================================================= + +/** + * @brief Speech activity event type + * + * Mirrors Swift's SpeechActivityEvent. + */ +typedef enum rac_speech_activity { + RAC_SPEECH_STARTED = 0, + RAC_SPEECH_ENDED = 1, + RAC_SPEECH_ONGOING = 2 +} rac_speech_activity_t; + +// ============================================================================= +// INPUT - Mirrors Swift's VADInput +// ============================================================================= + +/** + * @brief VAD input data + * + * Mirrors Swift's VADInput struct exactly. + * See: Sources/RunAnywhere/Features/VAD/Models/VADInput.swift + */ +typedef struct rac_vad_input { + /** Audio samples as float array (PCM float samples in range [-1.0, 1.0]) */ + const float* audio_samples; + size_t num_samples; + + /** Optional override for energy threshold (use -1 for no override) */ + float energy_threshold_override; +} rac_vad_input_t; + +/** + * @brief Default VAD input + */ +static const rac_vad_input_t RAC_VAD_INPUT_DEFAULT = { + .audio_samples = RAC_NULL, + .num_samples = 0, + .energy_threshold_override = -1.0f /* No override */ +}; + +// ============================================================================= +// OUTPUT - Mirrors Swift's VADOutput +// ============================================================================= + +/** + * @brief VAD output data + * + * Mirrors Swift's VADOutput struct exactly. + * See: Sources/RunAnywhere/Features/VAD/Models/VADOutput.swift + */ +typedef struct rac_vad_output { + /** Whether speech is detected in the current frame */ + rac_bool_t is_speech_detected; + + /** Current audio energy level (RMS value) */ + float energy_level; + + /** Timestamp in milliseconds since epoch */ + int64_t timestamp_ms; +} rac_vad_output_t; + +// ============================================================================= +// INFO - Mirrors Swift's VADService properties +// ============================================================================= + +/** + * @brief VAD service info + * + * Mirrors Swift's VADService properties. + */ +typedef struct rac_vad_info { + /** Whether speech is currently active (isSpeechActive) */ + rac_bool_t is_speech_active; + + /** Energy threshold for voice detection (energyThreshold) */ + float energy_threshold; + + /** Sample rate of the audio in Hz (sampleRate) */ + int32_t sample_rate; + + /** Frame length in seconds (frameLength) */ + float frame_length; +} rac_vad_info_t; + +// ============================================================================= +// STATISTICS - Mirrors Swift's VADStatistics +// ============================================================================= + +/** + * @brief VAD statistics + * + * Mirrors Swift's VADStatistics struct from SimpleEnergyVADService. + */ +typedef struct rac_vad_statistics { + /** Current calibrated threshold */ + float current_threshold; + + /** Ambient noise level */ + float ambient_noise_level; + + /** Total speech segments detected */ + int32_t total_speech_segments; + + /** Total duration of speech in milliseconds */ + int64_t total_speech_duration_ms; + + /** Average energy level */ + float average_energy; + + /** Peak energy level */ + float peak_energy; +} rac_vad_statistics_t; + +// ============================================================================= +// CALLBACKS +// ============================================================================= + +/** + * @brief Speech activity callback + * + * Mirrors Swift's VADService.onSpeechActivity callback. + * + * @param activity The speech activity event + * @param user_data User-provided context + */ +typedef void (*rac_vad_activity_callback_fn)(rac_speech_activity_t activity, void* user_data); + +/** + * @brief Audio buffer callback + * + * Mirrors Swift's VADService.onAudioBuffer callback. + * + * @param audio_data Audio data buffer (PCM float samples) + * @param num_samples Number of samples + * @param user_data User-provided context + */ +typedef void (*rac_vad_audio_callback_fn)(const float* audio_data, size_t num_samples, + void* user_data); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_VAD_TYPES_H */ diff --git a/sdk/runanywhere-commons/include/rac/features/voice_agent/rac_voice_agent.h b/sdk/runanywhere-commons/include/rac/features/voice_agent/rac_voice_agent.h new file mode 100644 index 000000000..98ff23b76 --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/features/voice_agent/rac_voice_agent.h @@ -0,0 +1,612 @@ +/** + * @file rac_voice_agent.h + * @brief Voice Agent Capability - Full Voice Conversation Pipeline + * + * C port of Swift's VoiceAgentCapability.swift + * Swift Source: Sources/RunAnywhere/Features/VoiceAgent/VoiceAgentCapability.swift + * + * IMPORTANT: This is a direct translation of the Swift implementation. + * Do NOT add features not present in the Swift code. + * + * Composes STT, LLM, TTS, and VAD capabilities for end-to-end voice processing. + */ + +#ifndef RAC_VOICE_AGENT_H +#define RAC_VOICE_AGENT_H + +#include "rac/core/rac_error.h" +#include "rac/core/rac_types.h" +#include "rac/features/llm/rac_llm_types.h" +#include "rac/features/stt/rac_stt_types.h" +#include "rac/features/tts/rac_tts_types.h" +#include "rac/features/vad/rac_vad_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// CONSTANTS - Voice Agent Timing Defaults +// ============================================================================= + +/** Default timeout for waiting for speech input (seconds) */ +#define RAC_VOICE_AGENT_DEFAULT_SPEECH_TIMEOUT_SEC 10.0 + +/** Default maximum recording duration (seconds) */ +#define RAC_VOICE_AGENT_DEFAULT_MAX_RECORDING_DURATION_SEC 30.0 + +/** Default pause duration to end recording (seconds) */ +#define RAC_VOICE_AGENT_DEFAULT_END_OF_SPEECH_PAUSE_SEC 1.5 + +/** Maximum time to wait for LLM response (seconds) */ +#define RAC_VOICE_AGENT_LLM_RESPONSE_TIMEOUT_SEC 30.0 + +/** Maximum time to wait for TTS synthesis (seconds) */ +#define RAC_VOICE_AGENT_TTS_RESPONSE_TIMEOUT_SEC 15.0 + +// ============================================================================= +// TYPES - Mirrors Swift's VoiceAgentConfiguration and VoiceAgentResult +// ============================================================================= + +/** + * @brief Audio pipeline state - Mirrors Swift's AudioPipelineState enum + * + * Represents the current state of the audio pipeline to prevent feedback loops. + * See: Sources/RunAnywhere/Features/VoiceAgent/Models/AudioPipelineState.swift + */ +typedef enum rac_audio_pipeline_state { + RAC_AUDIO_PIPELINE_IDLE = 0, /**< System is idle, ready to start listening */ + RAC_AUDIO_PIPELINE_LISTENING = 1, /**< Actively listening for speech via VAD */ + RAC_AUDIO_PIPELINE_PROCESSING_SPEECH = 2, /**< Processing detected speech with STT */ + RAC_AUDIO_PIPELINE_GENERATING_RESPONSE = 3, /**< Generating response with LLM */ + RAC_AUDIO_PIPELINE_PLAYING_TTS = 4, /**< Playing TTS output */ + RAC_AUDIO_PIPELINE_COOLDOWN = 5, /**< Cooldown period after TTS to prevent feedback */ + RAC_AUDIO_PIPELINE_ERROR = 6 /**< Error state requiring reset */ +} rac_audio_pipeline_state_t; + +/** + * @brief Get string representation of audio pipeline state + * + * @param state The pipeline state + * @return State name string (static, do not free) + */ +RAC_API const char* rac_audio_pipeline_state_name(rac_audio_pipeline_state_t state); + +/** + * @brief Voice agent event types. + * Mirrors Swift's VoiceAgentEvent enum. + */ +typedef enum rac_voice_agent_event_type { + RAC_VOICE_AGENT_EVENT_PROCESSED = 0, /**< Complete processing result */ + RAC_VOICE_AGENT_EVENT_VAD_TRIGGERED = 1, /**< VAD triggered (speech detected/ended) */ + RAC_VOICE_AGENT_EVENT_TRANSCRIPTION = 2, /**< Transcription available from STT */ + RAC_VOICE_AGENT_EVENT_RESPONSE = 3, /**< Response generated from LLM */ + RAC_VOICE_AGENT_EVENT_AUDIO_SYNTHESIZED = 4, /**< Audio synthesized from TTS */ + RAC_VOICE_AGENT_EVENT_ERROR = 5 /**< Error occurred during processing */ +} rac_voice_agent_event_type_t; + +/** + * @brief VAD configuration for voice agent. + * Mirrors Swift's VADConfiguration. + */ +typedef struct rac_voice_agent_vad_config { + /** Sample rate (default: 16000) */ + int32_t sample_rate; + + /** Frame length in seconds (default: 0.1) */ + float frame_length; + + /** Energy threshold (default: 0.005) */ + float energy_threshold; +} rac_voice_agent_vad_config_t; + +/** + * @brief Default VAD configuration. + */ +static const rac_voice_agent_vad_config_t RAC_VOICE_AGENT_VAD_CONFIG_DEFAULT = { + .sample_rate = 16000, .frame_length = 0.1f, .energy_threshold = 0.005f}; + +/** + * @brief STT configuration for voice agent. + * Mirrors Swift's STTConfiguration. + */ +typedef struct rac_voice_agent_stt_config { + /** Model path - file path used for loading (can be NULL to use already-loaded model) */ + const char* model_path; + /** Model ID - identifier for telemetry (e.g., "whisper-base") */ + const char* model_id; + /** Model name - human-readable name (e.g., "Whisper Base") */ + const char* model_name; +} rac_voice_agent_stt_config_t; + +/** + * @brief LLM configuration for voice agent. + * Mirrors Swift's LLMConfiguration. + */ +typedef struct rac_voice_agent_llm_config { + /** Model path - file path used for loading (can be NULL to use already-loaded model) */ + const char* model_path; + /** Model ID - identifier for telemetry (e.g., "llama-3.2-1b") */ + const char* model_id; + /** Model name - human-readable name (e.g., "Llama 3.2 1B Instruct") */ + const char* model_name; +} rac_voice_agent_llm_config_t; + +/** + * @brief TTS configuration for voice agent. + * Mirrors Swift's TTSConfiguration. + */ +typedef struct rac_voice_agent_tts_config { + /** Voice path - file path used for loading (can be NULL/empty to use already-loaded voice) */ + const char* voice_path; + /** Voice ID - identifier for telemetry (e.g., "vits-piper-en_GB-alba-medium") */ + const char* voice_id; + /** Voice name - human-readable name (e.g., "Piper TTS (British English)") */ + const char* voice_name; +} rac_voice_agent_tts_config_t; + +/** + * @brief Voice agent configuration. + * Mirrors Swift's VoiceAgentConfiguration. + */ +typedef struct rac_voice_agent_config { + /** VAD configuration */ + rac_voice_agent_vad_config_t vad_config; + + /** STT configuration */ + rac_voice_agent_stt_config_t stt_config; + + /** LLM configuration */ + rac_voice_agent_llm_config_t llm_config; + + /** TTS configuration */ + rac_voice_agent_tts_config_t tts_config; +} rac_voice_agent_config_t; + +/** + * @brief Default voice agent configuration. + */ +static const rac_voice_agent_config_t RAC_VOICE_AGENT_CONFIG_DEFAULT = { + .vad_config = {.sample_rate = 16000, .frame_length = 0.1f, .energy_threshold = 0.005f}, + .stt_config = {.model_path = RAC_NULL, .model_id = RAC_NULL, .model_name = RAC_NULL}, + .llm_config = {.model_path = RAC_NULL, .model_id = RAC_NULL, .model_name = RAC_NULL}, + .tts_config = {.voice_path = RAC_NULL, .voice_id = RAC_NULL, .voice_name = RAC_NULL}}; + +// ============================================================================= +// AUDIO PIPELINE STATE MANAGER CONFIG - Mirrors Swift's AudioPipelineStateManager.Configuration +// ============================================================================= + +/** + * @brief Audio pipeline state manager configuration + * + * Mirrors Swift's AudioPipelineStateManager.Configuration struct. + * See: Sources/RunAnywhere/Features/VoiceAgent/Models/AudioPipelineState.swift + */ +typedef struct rac_audio_pipeline_config { + /** Duration to wait after TTS before allowing microphone (seconds) */ + float cooldown_duration; + + /** Whether to enforce strict state transitions */ + rac_bool_t strict_transitions; + + /** Maximum TTS duration before forced timeout (seconds) */ + float max_tts_duration; +} rac_audio_pipeline_config_t; + +/** + * @brief Default audio pipeline configuration + */ +static const rac_audio_pipeline_config_t RAC_AUDIO_PIPELINE_CONFIG_DEFAULT = { + .cooldown_duration = 0.8f, /* 800ms - better feedback prevention */ + .strict_transitions = RAC_TRUE, + .max_tts_duration = 30.0f}; + +// ============================================================================= +// AUDIO PIPELINE STATE MANAGER API +// ============================================================================= + +/** + * @brief Check if microphone can be activated in current state + * + * @param current_state Current pipeline state + * @param last_tts_end_time_ms Last TTS end time in milliseconds since epoch (0 if none) + * @param cooldown_duration_ms Cooldown duration in milliseconds + * @return RAC_TRUE if microphone can be activated + */ +RAC_API rac_bool_t rac_audio_pipeline_can_activate_microphone( + rac_audio_pipeline_state_t current_state, int64_t last_tts_end_time_ms, + int64_t cooldown_duration_ms); + +/** + * @brief Check if TTS can be played in current state + * + * @param current_state Current pipeline state + * @return RAC_TRUE if TTS can be played + */ +RAC_API rac_bool_t rac_audio_pipeline_can_play_tts(rac_audio_pipeline_state_t current_state); + +/** + * @brief Check if a state transition is valid + * + * @param from_state Current state + * @param to_state Target state + * @return RAC_TRUE if transition is valid + */ +RAC_API rac_bool_t rac_audio_pipeline_is_valid_transition(rac_audio_pipeline_state_t from_state, + rac_audio_pipeline_state_t to_state); + +/** + * @brief Voice agent processing result. + * Mirrors Swift's VoiceAgentResult. + */ +typedef struct rac_voice_agent_result { + /** Whether speech was detected in the input audio */ + rac_bool_t speech_detected; + + /** Transcribed text from STT (owned, must be freed with rac_free) */ + char* transcription; + + /** Generated response text from LLM (owned, must be freed with rac_free) */ + char* response; + + /** Synthesized audio data from TTS (owned, must be freed with rac_free) */ + void* synthesized_audio; + + /** Size of synthesized audio data in bytes */ + size_t synthesized_audio_size; +} rac_voice_agent_result_t; + +/** + * @brief Voice agent event data. + * Contains union for different event types. + */ +typedef struct rac_voice_agent_event { + /** Event type */ + rac_voice_agent_event_type_t type; + + union { + /** For PROCESSED event */ + rac_voice_agent_result_t result; + + /** For VAD_TRIGGERED event: true if speech started, false if ended */ + rac_bool_t vad_speech_active; + + /** For TRANSCRIPTION event */ + const char* transcription; + + /** For RESPONSE event */ + const char* response; + + /** For AUDIO_SYNTHESIZED event */ + struct { + const void* audio_data; + size_t audio_size; + } audio; + + /** For ERROR event */ + rac_result_t error_code; + } data; +} rac_voice_agent_event_t; + +/** + * @brief Callback for voice agent events during streaming. + * + * @param event The event that occurred + * @param user_data User-provided context + */ +typedef void (*rac_voice_agent_event_callback_fn)(const rac_voice_agent_event_t* event, + void* user_data); + +// ============================================================================= +// OPAQUE HANDLE +// ============================================================================= + +/** + * @brief Opaque handle for voice agent instance. + */ +typedef struct rac_voice_agent* rac_voice_agent_handle_t; + +// ============================================================================= +// LIFECYCLE API +// ============================================================================= + +/** + * @brief Create a standalone voice agent that owns its component handles. + * + * This is the recommended API. The voice agent creates and manages its own + * STT, LLM, TTS, and VAD component handles internally. Use the model loading + * APIs to load models after creation. + * + * @param out_handle Output: Handle to the created voice agent + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_voice_agent_create_standalone(rac_voice_agent_handle_t* out_handle); + +/** + * @brief Create a voice agent instance with external component handles. + * + * DEPRECATED: Prefer rac_voice_agent_create_standalone(). + * This API is for backward compatibility when you need to share handles. + * + * @param llm_component_handle Handle to LLM component (rac_llm_component) + * @param stt_component_handle Handle to STT component (rac_stt_component) + * @param tts_component_handle Handle to TTS component (rac_tts_component) + * @param vad_component_handle Handle to VAD component (rac_vad_component) + * @param out_handle Output: Handle to the created voice agent + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_voice_agent_create(rac_handle_t llm_component_handle, + rac_handle_t stt_component_handle, + rac_handle_t tts_component_handle, + rac_handle_t vad_component_handle, + rac_voice_agent_handle_t* out_handle); + +/** + * @brief Destroy a voice agent instance. + * + * If created with rac_voice_agent_create_standalone(), this also destroys + * the owned component handles. + * + * @param handle Voice agent handle + */ +RAC_API void rac_voice_agent_destroy(rac_voice_agent_handle_t handle); + +// ============================================================================= +// MODEL LOADING API (for standalone voice agent) +// ============================================================================= + +/** + * @brief Load an STT model into the voice agent. + * + * @param handle Voice agent handle + * @param model_path File path to the model (used for loading) + * @param model_id Model identifier (used for telemetry, e.g., "whisper-base") + * @param model_name Human-readable model name (e.g., "Whisper Base") + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_voice_agent_load_stt_model(rac_voice_agent_handle_t handle, + const char* model_path, const char* model_id, + const char* model_name); + +/** + * @brief Load an LLM model into the voice agent. + * + * @param handle Voice agent handle + * @param model_path File path to the model (used for loading) + * @param model_id Model identifier (used for telemetry, e.g., "llama-3.2-1b") + * @param model_name Human-readable model name (e.g., "Llama 3.2 1B Instruct") + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_voice_agent_load_llm_model(rac_voice_agent_handle_t handle, + const char* model_path, const char* model_id, + const char* model_name); + +/** + * @brief Load a TTS voice into the voice agent. + * + * @param handle Voice agent handle + * @param voice_path File path to the voice (used for loading) + * @param voice_id Voice identifier (used for telemetry, e.g., "vits-piper-en_GB-alba-medium") + * @param voice_name Human-readable voice name (e.g., "Piper TTS (British English)") + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_voice_agent_load_tts_voice(rac_voice_agent_handle_t handle, + const char* voice_path, const char* voice_id, + const char* voice_name); + +/** + * @brief Check if STT model is loaded. + * + * @param handle Voice agent handle + * @param out_loaded Output: RAC_TRUE if loaded + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_voice_agent_is_stt_loaded(rac_voice_agent_handle_t handle, + rac_bool_t* out_loaded); + +/** + * @brief Check if LLM model is loaded. + * + * @param handle Voice agent handle + * @param out_loaded Output: RAC_TRUE if loaded + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_voice_agent_is_llm_loaded(rac_voice_agent_handle_t handle, + rac_bool_t* out_loaded); + +/** + * @brief Check if TTS voice is loaded. + * + * @param handle Voice agent handle + * @param out_loaded Output: RAC_TRUE if loaded + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_voice_agent_is_tts_loaded(rac_voice_agent_handle_t handle, + rac_bool_t* out_loaded); + +/** + * @brief Get the currently loaded STT model ID. + * + * @param handle Voice agent handle + * @return Model ID string (static, do not free) or NULL if not loaded + */ +RAC_API const char* rac_voice_agent_get_stt_model_id(rac_voice_agent_handle_t handle); + +/** + * @brief Get the currently loaded LLM model ID. + * + * @param handle Voice agent handle + * @return Model ID string (static, do not free) or NULL if not loaded + */ +RAC_API const char* rac_voice_agent_get_llm_model_id(rac_voice_agent_handle_t handle); + +/** + * @brief Get the currently loaded TTS voice ID. + * + * @param handle Voice agent handle + * @return Voice ID string (static, do not free) or NULL if not loaded + */ +RAC_API const char* rac_voice_agent_get_tts_voice_id(rac_voice_agent_handle_t handle); + +/** + * @brief Initialize the voice agent with configuration. + * + * Mirrors Swift's VoiceAgentCapability.initialize(_:). + * This method is smart about reusing already-loaded models. + * + * @param handle Voice agent handle + * @param config Configuration (can be NULL for defaults) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_voice_agent_initialize(rac_voice_agent_handle_t handle, + const rac_voice_agent_config_t* config); + +/** + * @brief Initialize using already-loaded models. + * + * Mirrors Swift's VoiceAgentCapability.initializeWithLoadedModels(). + * Verifies all required components are loaded and marks the voice agent as ready. + * + * @param handle Voice agent handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_voice_agent_initialize_with_loaded_models(rac_voice_agent_handle_t handle); + +/** + * @brief Cleanup voice agent resources. + * + * Mirrors Swift's VoiceAgentCapability.cleanup(). + * + * @param handle Voice agent handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_voice_agent_cleanup(rac_voice_agent_handle_t handle); + +/** + * @brief Check if voice agent is ready. + * + * Mirrors Swift's VoiceAgentCapability.isReady property. + * + * @param handle Voice agent handle + * @param out_is_ready Output: RAC_TRUE if ready + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_voice_agent_is_ready(rac_voice_agent_handle_t handle, + rac_bool_t* out_is_ready); + +// ============================================================================= +// VOICE PROCESSING API +// ============================================================================= + +/** + * @brief Process a complete voice turn: audio → transcription → LLM response → synthesized speech. + * + * Mirrors Swift's VoiceAgentCapability.processVoiceTurn(_:). + * + * @param handle Voice agent handle + * @param audio_data Audio data from user + * @param audio_size Size of audio data in bytes + * @param out_result Output: Voice agent result (caller owns memory, must free with + * rac_voice_agent_result_free) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_voice_agent_process_voice_turn(rac_voice_agent_handle_t handle, + const void* audio_data, size_t audio_size, + rac_voice_agent_result_t* out_result); + +/** + * @brief Process audio with streaming events. + * + * Mirrors Swift's VoiceAgentCapability.processStream(_:). + * Events are delivered via the callback as processing progresses. + * + * @param handle Voice agent handle + * @param audio_data Audio data from user + * @param audio_size Size of audio data in bytes + * @param callback Event callback function + * @param user_data User context passed to callback + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_voice_agent_process_stream(rac_voice_agent_handle_t handle, + const void* audio_data, size_t audio_size, + rac_voice_agent_event_callback_fn callback, + void* user_data); + +// ============================================================================= +// INDIVIDUAL COMPONENT ACCESS API +// ============================================================================= + +/** + * @brief Transcribe audio only (without LLM/TTS). + * + * Mirrors Swift's VoiceAgentCapability.transcribe(_:). + * + * @param handle Voice agent handle + * @param audio_data Audio data + * @param audio_size Size of audio data in bytes + * @param out_transcription Output: Transcribed text (owned, must be freed with rac_free) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_voice_agent_transcribe(rac_voice_agent_handle_t handle, + const void* audio_data, size_t audio_size, + char** out_transcription); + +/** + * @brief Generate LLM response only. + * + * Mirrors Swift's VoiceAgentCapability.generateResponse(_:). + * + * @param handle Voice agent handle + * @param prompt Input prompt + * @param out_response Output: Generated response (owned, must be freed with rac_free) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_voice_agent_generate_response(rac_voice_agent_handle_t handle, + const char* prompt, char** out_response); + +/** + * @brief Synthesize speech only. + * + * Mirrors Swift's VoiceAgentCapability.synthesizeSpeech(_:). + * + * @param handle Voice agent handle + * @param text Text to synthesize + * @param out_audio Output: Synthesized audio data (owned, must be freed with rac_free) + * @param out_audio_size Output: Size of audio data in bytes + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_voice_agent_synthesize_speech(rac_voice_agent_handle_t handle, + const char* text, void** out_audio, + size_t* out_audio_size); + +/** + * @brief Check if VAD detects speech. + * + * Mirrors Swift's VoiceAgentCapability.detectSpeech(_:). + * + * @param handle Voice agent handle + * @param samples Audio samples (float32) + * @param sample_count Number of samples + * @param out_speech_detected Output: RAC_TRUE if speech detected + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_voice_agent_detect_speech(rac_voice_agent_handle_t handle, + const float* samples, size_t sample_count, + rac_bool_t* out_speech_detected); + +// ============================================================================= +// MEMORY MANAGEMENT +// ============================================================================= + +/** + * @brief Free a voice agent result. + * + * @param result Result to free + */ +RAC_API void rac_voice_agent_result_free(rac_voice_agent_result_t* result); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_VOICE_AGENT_H */ diff --git a/sdk/runanywhere-commons/include/rac/infrastructure/device/rac_device_manager.h b/sdk/runanywhere-commons/include/rac/infrastructure/device/rac_device_manager.h new file mode 100644 index 000000000..134a090ba --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/infrastructure/device/rac_device_manager.h @@ -0,0 +1,176 @@ +/** + * @file rac_device_manager.h + * @brief Device Registration Manager - C++ Business Logic Layer + * + * Handles device registration orchestration with all business logic in C++. + * Platform SDKs (Swift, Kotlin) provide callbacks for: + * - Device info gathering (platform-specific APIs) + * - Device ID retrieval (Keychain/Keystore) + * - Registration persistence (UserDefaults/SharedPreferences) + * - HTTP transport (URLSession/OkHttp) + * + * Events are emitted via rac_analytics_event_emit(). + */ + +#ifndef RAC_DEVICE_MANAGER_H +#define RAC_DEVICE_MANAGER_H + +#include "rac/core/rac_types.h" +#include "rac/infrastructure/network/rac_environment.h" +#include "rac/infrastructure/telemetry/rac_telemetry_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// CALLBACK TYPES +// ============================================================================= + +/** + * @brief HTTP response for device registration + */ +typedef struct rac_device_http_response { + rac_result_t result; // RAC_SUCCESS on success + int32_t status_code; // HTTP status code (200, 400, etc.) + const char* response_body; // Response JSON (can be NULL) + const char* error_message; // Error message (can be NULL) +} rac_device_http_response_t; + +/** + * @brief Callback function types for platform-specific operations + */ + +/** + * Get device information (Swift calls DeviceInfo.current) + * @param out_info Output parameter for device info + * @param user_data User-provided context + */ +typedef void (*rac_device_get_info_fn)(rac_device_registration_info_t* out_info, void* user_data); + +/** + * Get persistent device ID (Swift calls DeviceIdentity.persistentUUID) + * @param user_data User-provided context + * @return Device ID string (must remain valid during callback) + */ +typedef const char* (*rac_device_get_id_fn)(void* user_data); + +/** + * Check if device is already registered (Swift checks UserDefaults) + * @param user_data User-provided context + * @return RAC_TRUE if registered, RAC_FALSE otherwise + */ +typedef rac_bool_t (*rac_device_is_registered_fn)(void* user_data); + +/** + * Mark device as registered/unregistered (Swift sets UserDefaults) + * @param registered RAC_TRUE to mark as registered, RAC_FALSE to clear + * @param user_data User-provided context + */ +typedef void (*rac_device_set_registered_fn)(rac_bool_t registered, void* user_data); + +/** + * Make HTTP POST request for device registration + * @param endpoint Full endpoint URL + * @param json_body JSON body to POST + * @param requires_auth Whether authentication header is required + * @param out_response Output parameter for response + * @param user_data User-provided context + * @return RAC_SUCCESS on success, error code otherwise + */ +typedef rac_result_t (*rac_device_http_post_fn)(const char* endpoint, const char* json_body, + rac_bool_t requires_auth, + rac_device_http_response_t* out_response, + void* user_data); + +/** + * @brief Callback structure for platform-specific operations + * + * Platform SDKs set these callbacks at initialization. + * C++ device manager calls these to access platform services. + */ +typedef struct rac_device_callbacks { + /** Get device hardware/OS information */ + rac_device_get_info_fn get_device_info; + + /** Get persistent device UUID (Keychain/Keystore) */ + rac_device_get_id_fn get_device_id; + + /** Check if device is registered (UserDefaults/SharedPreferences) */ + rac_device_is_registered_fn is_registered; + + /** Set registration status */ + rac_device_set_registered_fn set_registered; + + /** Make HTTP POST request */ + rac_device_http_post_fn http_post; + + /** User data passed to all callbacks */ + void* user_data; +} rac_device_callbacks_t; + +// ============================================================================= +// DEVICE MANAGER API +// ============================================================================= + +/** + * @brief Set callbacks for device manager operations + * + * Must be called before any other device manager functions. + * Typically called during SDK initialization. + * + * @param callbacks Callback structure (copied internally) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_device_manager_set_callbacks(const rac_device_callbacks_t* callbacks); + +/** + * @brief Register device with backend if not already registered + * + * This is the main entry point for device registration. + * Business logic: + * 1. Check if already registered (via callback) + * 2. If not, gather device info (via callback) + * 3. Build JSON payload (C++ implementation) + * 4. POST to backend (via callback) + * 5. On success, mark as registered (via callback) + * 6. Emit appropriate analytics event + * + * @param env Current SDK environment + * @param build_token Optional build token for development mode (can be NULL) + * @return RAC_SUCCESS on success or if already registered, error code otherwise + */ +RAC_API rac_result_t rac_device_manager_register_if_needed(rac_environment_t env, + const char* build_token); + +/** + * @brief Check if device is registered + * + * Delegates to the is_registered callback. + * + * @return RAC_TRUE if registered, RAC_FALSE otherwise + */ +RAC_API rac_bool_t rac_device_manager_is_registered(void); + +/** + * @brief Clear device registration status + * + * Delegates to the set_registered callback with RAC_FALSE. + * Useful for testing or user-initiated reset. + */ +RAC_API void rac_device_manager_clear_registration(void); + +/** + * @brief Get the current device ID + * + * Delegates to the get_device_id callback. + * + * @return Device ID string or NULL if callbacks not set + */ +RAC_API const char* rac_device_manager_get_device_id(void); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_DEVICE_MANAGER_H */ diff --git a/sdk/runanywhere-commons/include/rac/infrastructure/download/rac_download.h b/sdk/runanywhere-commons/include/rac/infrastructure/download/rac_download.h new file mode 100644 index 000000000..fc9bdffef --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/infrastructure/download/rac_download.h @@ -0,0 +1,418 @@ +/** + * @file rac_download.h + * @brief Download Manager - Model Download Orchestration + * + * C port of Swift's DownloadService protocol and related types. + * Swift Source: Sources/RunAnywhere/Infrastructure/Download/ + * + * IMPORTANT: This is a direct translation of the Swift implementation. + * Do NOT add features not present in the Swift code. + * + * NOTE: The actual HTTP download is delegated to the platform adapter + * (Swift/Kotlin/etc). This C layer handles orchestration logic: + * - Progress tracking + * - State management + * - Retry logic + * - Post-download extraction + */ + +#ifndef RAC_DOWNLOAD_H +#define RAC_DOWNLOAD_H + +#include "rac/core/rac_error.h" +#include "rac/core/rac_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// TYPES - Mirrors Swift's DownloadState, DownloadStage, DownloadProgress +// ============================================================================= + +/** + * @brief Download state enumeration. + * Mirrors Swift's DownloadState enum. + */ +typedef enum rac_download_state { + RAC_DOWNLOAD_STATE_PENDING = 0, /**< Download is pending */ + RAC_DOWNLOAD_STATE_DOWNLOADING = 1, /**< Currently downloading */ + RAC_DOWNLOAD_STATE_EXTRACTING = 2, /**< Extracting archive contents */ + RAC_DOWNLOAD_STATE_RETRYING = 3, /**< Retrying after failure */ + RAC_DOWNLOAD_STATE_COMPLETED = 4, /**< Download completed successfully */ + RAC_DOWNLOAD_STATE_FAILED = 5, /**< Download failed */ + RAC_DOWNLOAD_STATE_CANCELLED = 6 /**< Download was cancelled */ +} rac_download_state_t; + +/** + * @brief Download stage enumeration. + * Mirrors Swift's DownloadStage enum. + */ +typedef enum rac_download_stage { + RAC_DOWNLOAD_STAGE_DOWNLOADING = 0, /**< Downloading the file(s) */ + RAC_DOWNLOAD_STAGE_EXTRACTING = 1, /**< Extracting archive contents */ + RAC_DOWNLOAD_STAGE_VALIDATING = 2, /**< Validating downloaded files */ + RAC_DOWNLOAD_STAGE_COMPLETED = 3 /**< Download and processing complete */ +} rac_download_stage_t; + +/** + * @brief Get display name for download stage. + * + * @param stage The download stage + * @return Display name string (static, do not free) + */ +RAC_API const char* rac_download_stage_display_name(rac_download_stage_t stage); + +/** + * @brief Get progress range for download stage. + * Download: 0-80%, Extraction: 80-95%, Validation: 95-99%, Completed: 100% + * + * @param stage The download stage + * @param out_start Output: Start of progress range (0.0-1.0) + * @param out_end Output: End of progress range (0.0-1.0) + */ +RAC_API void rac_download_stage_progress_range(rac_download_stage_t stage, double* out_start, + double* out_end); + +/** + * @brief Download progress information. + * Mirrors Swift's DownloadProgress struct. + */ +typedef struct rac_download_progress { + /** Current stage of the download pipeline */ + rac_download_stage_t stage; + + /** Bytes downloaded (for download stage) */ + int64_t bytes_downloaded; + + /** Total bytes to download */ + int64_t total_bytes; + + /** Progress within current stage (0.0 to 1.0) */ + double stage_progress; + + /** Overall progress across all stages (0.0 to 1.0) */ + double overall_progress; + + /** Current state */ + rac_download_state_t state; + + /** Download speed in bytes per second (0 if unknown) */ + double speed; + + /** Estimated time remaining in seconds (-1 if unknown) */ + double estimated_time_remaining; + + /** Retry attempt number (for RETRYING state) */ + int32_t retry_attempt; + + /** Error code (for FAILED state) */ + rac_result_t error_code; + + /** Error message (for FAILED state, can be NULL) */ + const char* error_message; +} rac_download_progress_t; + +/** + * @brief Default download progress values. + */ +static const rac_download_progress_t RAC_DOWNLOAD_PROGRESS_DEFAULT = { + .stage = RAC_DOWNLOAD_STAGE_DOWNLOADING, + .bytes_downloaded = 0, + .total_bytes = 0, + .stage_progress = 0.0, + .overall_progress = 0.0, + .state = RAC_DOWNLOAD_STATE_PENDING, + .speed = 0.0, + .estimated_time_remaining = -1.0, + .retry_attempt = 0, + .error_code = RAC_SUCCESS, + .error_message = RAC_NULL}; + +/** + * @brief Download task information. + * Mirrors Swift's DownloadTask struct. + */ +typedef struct rac_download_task { + /** Unique task ID */ + char* task_id; + + /** Model ID being downloaded */ + char* model_id; + + /** Download URL */ + char* url; + + /** Destination path */ + char* destination_path; + + /** Whether extraction is required */ + rac_bool_t requires_extraction; + + /** Current progress */ + rac_download_progress_t progress; +} rac_download_task_t; + +/** + * @brief Download configuration. + * Mirrors Swift's DownloadConfiguration struct. + */ +typedef struct rac_download_config { + /** Maximum concurrent downloads (default: 1) */ + int32_t max_concurrent_downloads; + + /** Request timeout in seconds (default: 60) */ + int32_t request_timeout_seconds; + + /** Maximum retry attempts (default: 3) */ + int32_t max_retry_attempts; + + /** Retry delay in seconds (default: 5) */ + int32_t retry_delay_seconds; + + /** Whether to allow cellular downloads (default: true) */ + rac_bool_t allow_cellular; + + /** Whether to allow downloads on low data mode (default: false) */ + rac_bool_t allow_constrained_network; +} rac_download_config_t; + +/** + * @brief Default download configuration. + */ +static const rac_download_config_t RAC_DOWNLOAD_CONFIG_DEFAULT = {.max_concurrent_downloads = 1, + .request_timeout_seconds = 60, + .max_retry_attempts = 3, + .retry_delay_seconds = 5, + .allow_cellular = RAC_TRUE, + .allow_constrained_network = + RAC_FALSE}; + +// ============================================================================= +// CALLBACKS +// ============================================================================= + +/** + * @brief Callback for download progress updates. + * Mirrors Swift's AsyncStream pattern. + * + * @param progress Current progress information + * @param user_data User-provided context + */ +typedef void (*rac_download_progress_callback_fn)(const rac_download_progress_t* progress, + void* user_data); + +/** + * @brief Callback for download completion. + * + * @param task_id The task ID + * @param result RAC_SUCCESS or error code + * @param final_path Path to the downloaded/extracted file (NULL on failure) + * @param user_data User-provided context + */ +typedef void (*rac_download_complete_callback_fn)(const char* task_id, rac_result_t result, + const char* final_path, void* user_data); + +// ============================================================================= +// OPAQUE HANDLE +// ============================================================================= + +/** + * @brief Opaque handle for download manager instance. + */ +typedef struct rac_download_manager* rac_download_manager_handle_t; + +// ============================================================================= +// LIFECYCLE API +// ============================================================================= + +/** + * @brief Create a download manager instance. + * + * @param config Configuration (can be NULL for defaults) + * @param out_handle Output: Handle to the created manager + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_download_manager_create(const rac_download_config_t* config, + rac_download_manager_handle_t* out_handle); + +/** + * @brief Destroy a download manager instance. + * + * @param handle Manager handle + */ +RAC_API void rac_download_manager_destroy(rac_download_manager_handle_t handle); + +// ============================================================================= +// DOWNLOAD API +// ============================================================================= + +/** + * @brief Start downloading a model. + * + * Mirrors Swift's DownloadService.downloadModel(_:). + * The actual HTTP download is performed by the platform adapter. + * + * @param handle Manager handle + * @param model_id Model identifier + * @param url Download URL + * @param destination_path Path where the model should be saved + * @param requires_extraction Whether the download needs to be extracted + * @param progress_callback Callback for progress updates (can be NULL) + * @param complete_callback Callback for completion (can be NULL) + * @param user_data User context passed to callbacks + * @param out_task_id Output: Task ID for tracking (owned, must be freed with rac_free) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_download_manager_start(rac_download_manager_handle_t handle, + const char* model_id, const char* url, + const char* destination_path, + rac_bool_t requires_extraction, + rac_download_progress_callback_fn progress_callback, + rac_download_complete_callback_fn complete_callback, + void* user_data, char** out_task_id); + +/** + * @brief Cancel a download. + * + * Mirrors Swift's DownloadService.cancelDownload(taskId:). + * + * @param handle Manager handle + * @param task_id Task ID to cancel + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_download_manager_cancel(rac_download_manager_handle_t handle, + const char* task_id); + +/** + * @brief Pause all active downloads. + * + * Mirrors Swift's AlamofireDownloadService.pauseAll(). + * + * @param handle Manager handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_download_manager_pause_all(rac_download_manager_handle_t handle); + +/** + * @brief Resume all paused downloads. + * + * Mirrors Swift's AlamofireDownloadService.resumeAll(). + * + * @param handle Manager handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_download_manager_resume_all(rac_download_manager_handle_t handle); + +// ============================================================================= +// STATUS API +// ============================================================================= + +/** + * @brief Get current progress for a download task. + * + * @param handle Manager handle + * @param task_id Task ID + * @param out_progress Output: Current progress + * @return RAC_SUCCESS or error code (RAC_ERROR_NOT_FOUND if task doesn't exist) + */ +RAC_API rac_result_t rac_download_manager_get_progress(rac_download_manager_handle_t handle, + const char* task_id, + rac_download_progress_t* out_progress); + +/** + * @brief Get list of active download task IDs. + * + * @param handle Manager handle + * @param out_task_ids Output: Array of task IDs (owned, each must be freed with rac_free) + * @param out_count Output: Number of tasks + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_download_manager_get_active_tasks(rac_download_manager_handle_t handle, + char*** out_task_ids, size_t* out_count); + +/** + * @brief Check if the download service is healthy. + * + * Mirrors Swift's AlamofireDownloadService.isHealthy(). + * + * @param handle Manager handle + * @param out_is_healthy Output: RAC_TRUE if healthy + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_download_manager_is_healthy(rac_download_manager_handle_t handle, + rac_bool_t* out_is_healthy); + +// ============================================================================= +// PROGRESS HELPERS +// ============================================================================= + +/** + * @brief Update download progress from HTTP callback. + * + * Called by platform adapter when download progress updates. + * + * @param handle Manager handle + * @param task_id Task ID + * @param bytes_downloaded Bytes downloaded so far + * @param total_bytes Total bytes to download + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_download_manager_update_progress(rac_download_manager_handle_t handle, + const char* task_id, + int64_t bytes_downloaded, + int64_t total_bytes); + +/** + * @brief Mark download as completed. + * + * Called by platform adapter when HTTP download finishes. + * + * @param handle Manager handle + * @param task_id Task ID + * @param downloaded_path Path to the downloaded file + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_download_manager_mark_complete(rac_download_manager_handle_t handle, + const char* task_id, + const char* downloaded_path); + +/** + * @brief Mark download as failed. + * + * Called by platform adapter when HTTP download fails. + * + * @param handle Manager handle + * @param task_id Task ID + * @param error_code Error code + * @param error_message Error message (can be NULL) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_download_manager_mark_failed(rac_download_manager_handle_t handle, + const char* task_id, rac_result_t error_code, + const char* error_message); + +// ============================================================================= +// MEMORY MANAGEMENT +// ============================================================================= + +/** + * @brief Free a download task. + * + * @param task Task to free + */ +RAC_API void rac_download_task_free(rac_download_task_t* task); + +/** + * @brief Free an array of task IDs. + * + * @param task_ids Array of task IDs + * @param count Number of task IDs + */ +RAC_API void rac_download_task_ids_free(char** task_ids, size_t count); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_DOWNLOAD_H */ diff --git a/sdk/runanywhere-commons/include/rac/infrastructure/events/rac_events.h b/sdk/runanywhere-commons/include/rac/infrastructure/events/rac_events.h new file mode 100644 index 000000000..7a9a56c5b --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/infrastructure/events/rac_events.h @@ -0,0 +1,177 @@ +/** + * @file rac_events.h + * @brief RunAnywhere Commons - Event Publishing and Subscription + * + * C port of Swift's SDKEvent protocol and EventPublisher from: + * Sources/RunAnywhere/Infrastructure/Events/SDKEvent.swift + * Sources/RunAnywhere/Infrastructure/Events/EventPublisher.swift + * + * Events are categorized and can be routed to different destinations + * (public EventBus or analytics). + */ + +#ifndef RAC_EVENTS_H +#define RAC_EVENTS_H + +#include "rac/core/rac_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// EVENT DESTINATION - Mirrors Swift's EventDestination +// ============================================================================= + +/** + * Where an event should be routed. + * Mirrors Swift's EventDestination enum. + */ +typedef enum rac_event_destination { + /** Only to public EventBus (app developers) */ + RAC_EVENT_DESTINATION_PUBLIC_ONLY = 0, + /** Only to analytics/telemetry (backend) */ + RAC_EVENT_DESTINATION_ANALYTICS_ONLY = 1, + /** Both destinations (default) */ + RAC_EVENT_DESTINATION_ALL = 2, +} rac_event_destination_t; + +// ============================================================================= +// EVENT CATEGORY - Mirrors Swift's EventCategory +// ============================================================================= + +/** + * Event categories for filtering/grouping. + * Mirrors Swift's EventCategory enum. + */ +typedef enum rac_event_category { + RAC_EVENT_CATEGORY_SDK = 0, + RAC_EVENT_CATEGORY_MODEL = 1, + RAC_EVENT_CATEGORY_LLM = 2, + RAC_EVENT_CATEGORY_STT = 3, + RAC_EVENT_CATEGORY_TTS = 4, + RAC_EVENT_CATEGORY_VOICE = 5, + RAC_EVENT_CATEGORY_STORAGE = 6, + RAC_EVENT_CATEGORY_DEVICE = 7, + RAC_EVENT_CATEGORY_NETWORK = 8, + RAC_EVENT_CATEGORY_ERROR = 9, +} rac_event_category_t; + +// ============================================================================= +// EVENT STRUCTURE - Mirrors Swift's SDKEvent protocol +// ============================================================================= + +/** + * Event data structure. + * Mirrors Swift's SDKEvent protocol properties. + */ +typedef struct rac_event { + /** Unique identifier for this event instance */ + const char* id; + + /** Event type string (used for analytics categorization) */ + const char* type; + + /** Category for filtering/routing */ + rac_event_category_t category; + + /** Timestamp in milliseconds since epoch */ + int64_t timestamp_ms; + + /** Optional session ID for grouping related events (can be NULL) */ + const char* session_id; + + /** Where to route this event */ + rac_event_destination_t destination; + + /** Event properties as JSON string (can be NULL) */ + const char* properties_json; +} rac_event_t; + +// ============================================================================= +// EVENT CALLBACK +// ============================================================================= + +/** + * Event callback function type. + * + * @param event The event data (valid only during the callback) + * @param user_data User-provided context data + */ +typedef void (*rac_event_callback_fn)(const rac_event_t* event, void* user_data); + +// ============================================================================= +// EVENT API +// ============================================================================= + +/** + * Subscribes to events of a specific category. + * + * @param category The category to subscribe to + * @param callback The callback function to invoke + * @param user_data User data passed to the callback + * @return Subscription ID (0 on failure), use with rac_event_unsubscribe + * + * @note The callback is invoked on the thread that publishes the event. + * Keep callback execution fast to avoid blocking. + */ +RAC_API uint64_t rac_event_subscribe(rac_event_category_t category, rac_event_callback_fn callback, + void* user_data); + +/** + * Subscribes to all events regardless of category. + * + * @param callback The callback function to invoke + * @param user_data User data passed to the callback + * @return Subscription ID (0 on failure) + */ +RAC_API uint64_t rac_event_subscribe_all(rac_event_callback_fn callback, void* user_data); + +/** + * Unsubscribes from events. + * + * @param subscription_id The subscription ID returned from subscribe + */ +RAC_API void rac_event_unsubscribe(uint64_t subscription_id); + +/** + * Publishes an event to all subscribers. + * + * This is called by the commons library to publish events. + * Swift's EventBridge subscribes to receive and re-publish to Swift consumers. + * + * @param event The event to publish + * @return RAC_SUCCESS on success, or an error code on failure + */ +RAC_API rac_result_t rac_event_publish(const rac_event_t* event); + +/** + * Track an event (convenience function matching Swift's EventPublisher.track). + * + * @param type Event type string + * @param category Event category + * @param destination Where to route this event + * @param properties_json Event properties as JSON (can be NULL) + * @return RAC_SUCCESS on success, or an error code on failure + */ +RAC_API rac_result_t rac_event_track(const char* type, rac_event_category_t category, + rac_event_destination_t destination, + const char* properties_json); + +// ============================================================================= +// UTILITY FUNCTIONS +// ============================================================================= + +/** + * Gets a string name for an event category. + * + * @param category The event category + * @return A string name (never NULL) + */ +RAC_API const char* rac_event_category_name(rac_event_category_t category); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_EVENTS_H */ diff --git a/sdk/runanywhere-commons/include/rac/infrastructure/model_management/rac_model_assignment.h b/sdk/runanywhere-commons/include/rac/infrastructure/model_management/rac_model_assignment.h new file mode 100644 index 000000000..04036257c --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/infrastructure/model_management/rac_model_assignment.h @@ -0,0 +1,153 @@ +/** + * @file rac_model_assignment.h + * @brief Model Assignment Manager - Fetches models assigned to device from backend + * + * Handles fetching model assignments from the backend API. + * Business logic (caching, JSON parsing, registry saving) is in C++. + * Platform SDKs provide HTTP GET callback for network transport. + * + * Events are emitted via rac_analytics_event_emit(). + */ + +#ifndef RAC_MODEL_ASSIGNMENT_H +#define RAC_MODEL_ASSIGNMENT_H + +#include "rac/core/rac_types.h" +#include "rac/infrastructure/model_management/rac_model_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// CALLBACK TYPES +// ============================================================================= + +/** + * @brief HTTP response for model assignment fetch + */ +typedef struct rac_assignment_http_response { + rac_result_t result; // RAC_SUCCESS on success + int32_t status_code; // HTTP status code (200, 400, etc.) + const char* response_body; // Response JSON (must remain valid during processing) + size_t response_length; // Length of response body + const char* error_message; // Error message (can be NULL) +} rac_assignment_http_response_t; + +/** + * Make HTTP GET request for model assignments + * @param endpoint Endpoint path (e.g., "/api/v1/model-assignments/for-sdk") + * @param requires_auth Whether authentication header is required + * @param out_response Output parameter for response + * @param user_data User-provided context + * @return RAC_SUCCESS on success, error code otherwise + */ +typedef rac_result_t (*rac_assignment_http_get_fn)(const char* endpoint, rac_bool_t requires_auth, + rac_assignment_http_response_t* out_response, + void* user_data); + +/** + * @brief Callback structure for model assignment operations + */ +typedef struct rac_assignment_callbacks { + /** Make HTTP GET request */ + rac_assignment_http_get_fn http_get; + + /** User data passed to all callbacks */ + void* user_data; + + /** If true, automatically fetch models after callbacks are registered */ + rac_bool_t auto_fetch; +} rac_assignment_callbacks_t; + +// ============================================================================= +// MODEL ASSIGNMENT API +// ============================================================================= + +/** + * @brief Set callbacks for model assignment operations + * + * Must be called before any other model assignment functions. + * Typically called during SDK initialization. + * + * @param callbacks Callback structure (copied internally) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t +rac_model_assignment_set_callbacks(const rac_assignment_callbacks_t* callbacks); + +/** + * @brief Fetch model assignments from backend + * + * Fetches models assigned to this device from the backend API. + * Results are cached for cache_timeout_seconds. + * + * Business logic: + * 1. Check cache if not force_refresh + * 2. Get device info (via callback) + * 3. Build endpoint URL + * 4. Make HTTP GET (via callback) + * 5. Parse JSON response + * 6. Save models to registry + * 7. Update cache + * 8. Emit analytics event + * + * @param force_refresh If true, bypass cache + * @param out_models Output array of model infos (caller must free with rac_model_info_array_free) + * @param out_count Number of models returned + * @return RAC_SUCCESS on success, error code otherwise + */ +RAC_API rac_result_t rac_model_assignment_fetch(rac_bool_t force_refresh, + rac_model_info_t*** out_models, size_t* out_count); + +/** + * @brief Get cached model assignments for a specific framework + * + * Filters cached models by framework. Does not make network request. + * Call rac_model_assignment_fetch first to populate cache. + * + * @param framework Framework to filter by + * @param out_models Output array of model infos (caller must free) + * @param out_count Number of models returned + * @return RAC_SUCCESS on success, error code otherwise + */ +RAC_API rac_result_t rac_model_assignment_get_by_framework(rac_inference_framework_t framework, + rac_model_info_t*** out_models, + size_t* out_count); + +/** + * @brief Get cached model assignments for a specific category + * + * Filters cached models by category. Does not make network request. + * Call rac_model_assignment_fetch first to populate cache. + * + * @param category Category to filter by + * @param out_models Output array of model infos (caller must free) + * @param out_count Number of models returned + * @return RAC_SUCCESS on success, error code otherwise + */ +RAC_API rac_result_t rac_model_assignment_get_by_category(rac_model_category_t category, + rac_model_info_t*** out_models, + size_t* out_count); + +/** + * @brief Clear model assignment cache + * + * Clears the in-memory cache. Next fetch will make network request. + */ +RAC_API void rac_model_assignment_clear_cache(void); + +/** + * @brief Set cache timeout in seconds + * + * Default is 3600 (1 hour). + * + * @param timeout_seconds Cache timeout in seconds + */ +RAC_API void rac_model_assignment_set_cache_timeout(uint32_t timeout_seconds); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_MODEL_ASSIGNMENT_H */ diff --git a/sdk/runanywhere-commons/include/rac/infrastructure/model_management/rac_model_paths.h b/sdk/runanywhere-commons/include/rac/infrastructure/model_management/rac_model_paths.h new file mode 100644 index 000000000..b759dccae --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/infrastructure/model_management/rac_model_paths.h @@ -0,0 +1,258 @@ +/** + * @file rac_model_paths.h + * @brief Model Path Utilities - Centralized Path Calculation + * + * C port of Swift's ModelPathUtils from: + * Sources/RunAnywhere/Infrastructure/ModelManagement/Utilities/ModelPathUtils.swift + * + * Path structure: `{base_dir}/RunAnywhere/Models/{framework}/{modelId}/` + * + * IMPORTANT: This is a direct translation of the Swift implementation. + * Do NOT add features not present in the Swift code. + */ + +#ifndef RAC_MODEL_PATHS_H +#define RAC_MODEL_PATHS_H + +#include + +#include "rac/core/rac_error.h" +#include "rac/core/rac_types.h" +#include "rac/infrastructure/model_management/rac_model_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// CONFIGURATION +// ============================================================================= + +/** + * @brief Set the base directory for model storage. + * + * This must be called before using any path utilities. + * On iOS, this would typically be the Documents directory. + * The Swift platform adapter should call this during initialization. + * + * @param base_dir Base directory path (e.g., + * "/var/mobile/Containers/Data/Application/.../Documents") + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_model_paths_set_base_dir(const char* base_dir); + +/** + * @brief Get the configured base directory. + * + * @return Base directory path, or NULL if not configured + */ +RAC_API const char* rac_model_paths_get_base_dir(void); + +// ============================================================================= +// BASE DIRECTORIES - Mirrors ModelPathUtils base directory methods +// ============================================================================= + +/** + * @brief Get the base RunAnywhere directory. + * Mirrors Swift's ModelPathUtils.getBaseDirectory(). + * + * @param out_path Output buffer for path + * @param path_size Size of output buffer + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_model_paths_get_base_directory(char* out_path, size_t path_size); + +/** + * @brief Get the models directory. + * Mirrors Swift's ModelPathUtils.getModelsDirectory(). + * + * Returns: `{base_dir}/RunAnywhere/Models/` + * + * @param out_path Output buffer for path + * @param path_size Size of output buffer + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_model_paths_get_models_directory(char* out_path, size_t path_size); + +// ============================================================================= +// FRAMEWORK-SPECIFIC PATHS - Mirrors ModelPathUtils framework methods +// ============================================================================= + +/** + * @brief Get the directory for a specific framework. + * Mirrors Swift's ModelPathUtils.getFrameworkDirectory(framework:). + * + * Returns: `{base_dir}/RunAnywhere/Models/{framework}/` + * + * @param framework Inference framework + * @param out_path Output buffer for path + * @param path_size Size of output buffer + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_model_paths_get_framework_directory(rac_inference_framework_t framework, + char* out_path, size_t path_size); + +/** + * @brief Get the folder for a specific model. + * Mirrors Swift's ModelPathUtils.getModelFolder(modelId:framework:). + * + * Returns: `{base_dir}/RunAnywhere/Models/{framework}/{modelId}/` + * + * @param model_id Model identifier + * @param framework Inference framework + * @param out_path Output buffer for path + * @param path_size Size of output buffer + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_model_paths_get_model_folder(const char* model_id, + rac_inference_framework_t framework, + char* out_path, size_t path_size); + +// ============================================================================= +// MODEL FILE PATHS - Mirrors ModelPathUtils file path methods +// ============================================================================= + +/** + * @brief Get the full path to a model file. + * Mirrors Swift's ModelPathUtils.getModelFilePath(modelId:framework:format:). + * + * Returns: `{base_dir}/RunAnywhere/Models/{framework}/{modelId}/{modelId}.{format}` + * + * @param model_id Model identifier + * @param framework Inference framework + * @param format Model format + * @param out_path Output buffer for path + * @param path_size Size of output buffer + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_model_paths_get_model_file_path(const char* model_id, + rac_inference_framework_t framework, + rac_model_format_t format, char* out_path, + size_t path_size); + +/** + * @brief Get the expected model path for a model. + * Mirrors Swift's ModelPathUtils.getExpectedModelPath(modelId:framework:format:). + * + * For directory-based frameworks (e.g., ONNX), returns the model folder. + * For single-file frameworks (e.g., LlamaCpp), returns the model file path. + * + * @param model_id Model identifier + * @param framework Inference framework + * @param format Model format + * @param out_path Output buffer for path + * @param path_size Size of output buffer + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_model_paths_get_expected_model_path(const char* model_id, + rac_inference_framework_t framework, + rac_model_format_t format, + char* out_path, size_t path_size); + +/** + * @brief Get the model path from model info. + * Mirrors Swift's ModelPathUtils.getModelPath(modelInfo:). + * + * @param model_info Model information + * @param out_path Output buffer for path + * @param path_size Size of output buffer + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_model_paths_get_model_path(const rac_model_info_t* model_info, + char* out_path, size_t path_size); + +// ============================================================================= +// OTHER DIRECTORIES - Mirrors ModelPathUtils other directory methods +// ============================================================================= + +/** + * @brief Get the cache directory. + * Mirrors Swift's ModelPathUtils.getCacheDirectory(). + * + * Returns: `{base_dir}/RunAnywhere/Cache/` + * + * @param out_path Output buffer for path + * @param path_size Size of output buffer + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_model_paths_get_cache_directory(char* out_path, size_t path_size); + +/** + * @brief Get the temporary files directory. + * Mirrors Swift's ModelPathUtils.getTempDirectory(). + * + * Returns: `{base_dir}/RunAnywhere/Temp/` + * + * @param out_path Output buffer for path + * @param path_size Size of output buffer + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_model_paths_get_temp_directory(char* out_path, size_t path_size); + +/** + * @brief Get the downloads directory. + * Mirrors Swift's ModelPathUtils.getDownloadsDirectory(). + * + * Returns: `{base_dir}/RunAnywhere/Downloads/` + * + * @param out_path Output buffer for path + * @param path_size Size of output buffer + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_model_paths_get_downloads_directory(char* out_path, size_t path_size); + +// ============================================================================= +// PATH ANALYSIS - Mirrors ModelPathUtils analysis methods +// ============================================================================= + +/** + * @brief Extract model ID from a file path. + * Mirrors Swift's ModelPathUtils.extractModelId(from:). + * + * @param path File path + * @param out_model_id Output buffer for model ID (can be NULL to just check if valid) + * @param model_id_size Size of output buffer + * @return RAC_SUCCESS if model ID found, RAC_ERROR_NOT_FOUND otherwise + */ +RAC_API rac_result_t rac_model_paths_extract_model_id(const char* path, char* out_model_id, + size_t model_id_size); + +/** + * @brief Extract framework from a file path. + * Mirrors Swift's ModelPathUtils.extractFramework(from:). + * + * @param path File path + * @param out_framework Output: The framework if found + * @return RAC_SUCCESS if framework found, RAC_ERROR_NOT_FOUND otherwise + */ +RAC_API rac_result_t rac_model_paths_extract_framework(const char* path, + rac_inference_framework_t* out_framework); + +/** + * @brief Check if a path is within the models directory. + * Mirrors Swift's ModelPathUtils.isModelPath(_:). + * + * @param path File path to check + * @return RAC_TRUE if path is within models directory, RAC_FALSE otherwise + */ +RAC_API rac_bool_t rac_model_paths_is_model_path(const char* path); + +// ============================================================================= +// PATH UTILITIES +// ============================================================================= + +// NOTE: rac_model_format_extension is declared in rac_model_types.h + +/** + * @brief Get raw value string for a framework. + * + * @param framework Inference framework + * @return Raw value string (e.g., "LlamaCpp", "ONNX") + */ +RAC_API const char* rac_framework_raw_value(rac_inference_framework_t framework); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_MODEL_PATHS_H */ diff --git a/sdk/runanywhere-commons/include/rac/infrastructure/model_management/rac_model_registry.h b/sdk/runanywhere-commons/include/rac/infrastructure/model_management/rac_model_registry.h new file mode 100644 index 000000000..7def004bd --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/infrastructure/model_management/rac_model_registry.h @@ -0,0 +1,357 @@ +/** + * @file rac_model_registry.h + * @brief Model Information Registry - In-Memory Model Metadata Management + * + * C port of Swift's ModelInfoService and ModelInfo structures. + * Swift Source: Sources/RunAnywhere/Infrastructure/ModelManagement/Services/ModelInfoService.swift + * Swift Source: Sources/RunAnywhere/Infrastructure/ModelManagement/Models/Domain/ModelInfo.swift + * + * IMPORTANT: This is a direct translation of the Swift implementation. + * Do NOT add features not present in the Swift code. + */ + +#ifndef RAC_MODEL_REGISTRY_H +#define RAC_MODEL_REGISTRY_H + +#include "rac/core/rac_error.h" +#include "rac/core/rac_types.h" +#include "rac/infrastructure/model_management/rac_model_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// TYPES - Uses types from rac_model_types.h +// ============================================================================= + +// NOTE: All model types (rac_model_category_t, rac_model_format_t, +// rac_inference_framework_t, rac_model_source_t, rac_artifact_type_kind_t, +// rac_model_info_t) are defined in rac_model_types.h + +// ============================================================================= +// OPAQUE HANDLE +// ============================================================================= + +/** + * @brief Opaque handle for model registry instance. + */ +typedef struct rac_model_registry* rac_model_registry_handle_t; + +// ============================================================================= +// LIFECYCLE API +// ============================================================================= + +/** + * @brief Create a model registry instance. + * + * @param out_handle Output: Handle to the created registry + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_model_registry_create(rac_model_registry_handle_t* out_handle); + +/** + * @brief Destroy a model registry instance. + * + * @param handle Registry handle + */ +RAC_API void rac_model_registry_destroy(rac_model_registry_handle_t handle); + +// ============================================================================= +// MODEL INFO API - Mirrors Swift's ModelInfoService +// ============================================================================= + +/** + * @brief Save model metadata. + * + * Mirrors Swift's ModelInfoService.saveModel(_:). + * + * @param handle Registry handle + * @param model Model info to save + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_model_registry_save(rac_model_registry_handle_t handle, + const rac_model_info_t* model); + +/** + * @brief Get model metadata by ID. + * + * Mirrors Swift's ModelInfoService.getModel(by:). + * + * @param handle Registry handle + * @param model_id Model identifier + * @param out_model Output: Model info (owned, must be freed with rac_model_info_free) + * @return RAC_SUCCESS, RAC_ERROR_NOT_FOUND, or other error code + */ +RAC_API rac_result_t rac_model_registry_get(rac_model_registry_handle_t handle, + const char* model_id, rac_model_info_t** out_model); + +/** + * @brief Load all stored models. + * + * Mirrors Swift's ModelInfoService.loadStoredModels(). + * + * @param handle Registry handle + * @param out_models Output: Array of model info (owned, each must be freed) + * @param out_count Output: Number of models + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_model_registry_get_all(rac_model_registry_handle_t handle, + rac_model_info_t*** out_models, size_t* out_count); + +/** + * @brief Load models for specific frameworks. + * + * Mirrors Swift's ModelInfoService.loadModels(for:). + * + * @param handle Registry handle + * @param frameworks Array of frameworks to filter by + * @param framework_count Number of frameworks + * @param out_models Output: Array of model info (owned, each must be freed) + * @param out_count Output: Number of models + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_model_registry_get_by_frameworks( + rac_model_registry_handle_t handle, const rac_inference_framework_t* frameworks, + size_t framework_count, rac_model_info_t*** out_models, size_t* out_count); + +/** + * @brief Update model last used date. + * + * Mirrors Swift's ModelInfoService.updateLastUsed(for:). + * Also increments usage count. + * + * @param handle Registry handle + * @param model_id Model identifier + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_model_registry_update_last_used(rac_model_registry_handle_t handle, + const char* model_id); + +/** + * @brief Remove model metadata. + * + * Mirrors Swift's ModelInfoService.removeModel(_:). + * + * @param handle Registry handle + * @param model_id Model identifier + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_model_registry_remove(rac_model_registry_handle_t handle, + const char* model_id); + +/** + * @brief Get downloaded models. + * + * Mirrors Swift's ModelInfoService.getDownloadedModels(). + * + * @param handle Registry handle + * @param out_models Output: Array of model info (owned, each must be freed) + * @param out_count Output: Number of models + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_model_registry_get_downloaded(rac_model_registry_handle_t handle, + rac_model_info_t*** out_models, + size_t* out_count); + +/** + * @brief Update download status for a model. + * + * Mirrors Swift's ModelInfoService.updateDownloadStatus(for:localPath:). + * + * @param handle Registry handle + * @param model_id Model identifier + * @param local_path Path to downloaded model (can be NULL to clear) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_model_registry_update_download_status(rac_model_registry_handle_t handle, + const char* model_id, + const char* local_path); + +// ============================================================================= +// QUERY HELPERS +// ============================================================================= + +/** + * @brief Check if a model is downloaded and available. + * + * Mirrors Swift's ModelInfo.isDownloaded computed property. + * + * @param model Model info + * @return RAC_TRUE if downloaded, RAC_FALSE otherwise + */ +RAC_API rac_bool_t rac_model_info_is_downloaded(const rac_model_info_t* model); + +/** + * @brief Check if model category requires context length. + * + * @param category Model category + * @return RAC_TRUE if requires context length + */ +RAC_API rac_bool_t rac_model_category_requires_context_length(rac_model_category_t category); + +/** + * @brief Check if model category supports thinking. + * + * @param category Model category + * @return RAC_TRUE if supports thinking + */ +RAC_API rac_bool_t rac_model_category_supports_thinking(rac_model_category_t category); + +/** + * @brief Infer artifact type from URL and format. + * + * Mirrors Swift's ModelArtifactType.infer(from:format:). + * + * @param url Download URL (can be NULL) + * @param format Model format + * @return Inferred artifact type kind + */ +RAC_API rac_artifact_type_kind_t rac_model_infer_artifact_type(const char* url, + rac_model_format_t format); + +// ============================================================================= +// MEMORY MANAGEMENT +// ============================================================================= + +/** + * @brief Allocate a new model info struct. + * + * @return Allocated model info (must be freed with rac_model_info_free) + */ +RAC_API rac_model_info_t* rac_model_info_alloc(void); + +/** + * @brief Free a model info struct and its contents. + * + * @param model Model info to free + */ +RAC_API void rac_model_info_free(rac_model_info_t* model); + +/** + * @brief Free an array of model info structs. + * + * @param models Array of model info pointers + * @param count Number of models + */ +RAC_API void rac_model_info_array_free(rac_model_info_t** models, size_t count); + +/** + * @brief Copy a model info struct. + * + * @param model Model info to copy + * @return Deep copy (must be freed with rac_model_info_free) + */ +RAC_API rac_model_info_t* rac_model_info_copy(const rac_model_info_t* model); + +// ============================================================================= +// MODEL DISCOVERY - Scan file system for downloaded models +// ============================================================================= + +/** + * @brief Callback to list directory contents + * @param path Directory path + * @param out_entries Output: Array of entry names (allocated by callback) + * @param out_count Output: Number of entries + * @param user_data User context + * @return RAC_SUCCESS or error code + */ +typedef rac_result_t (*rac_list_directory_fn)(const char* path, char*** out_entries, + size_t* out_count, void* user_data); + +/** + * @brief Callback to free directory entries + * @param entries Array of entry names + * @param count Number of entries + * @param user_data User context + */ +typedef void (*rac_free_directory_entries_fn)(char** entries, size_t count, void* user_data); + +/** + * @brief Callback to check if path is a directory + * @param path Path to check + * @param user_data User context + * @return RAC_TRUE if directory, RAC_FALSE otherwise + */ +typedef rac_bool_t (*rac_is_directory_fn)(const char* path, void* user_data); + +/** + * @brief Callback to check if path exists + * @param path Path to check + * @param user_data User context + * @return RAC_TRUE if exists + */ +typedef rac_bool_t (*rac_path_exists_discovery_fn)(const char* path, void* user_data); + +/** + * @brief Callback to check if file has model extension + * @param path File path + * @param framework Expected framework + * @param user_data User context + * @return RAC_TRUE if valid model file + */ +typedef rac_bool_t (*rac_is_model_file_fn)(const char* path, rac_inference_framework_t framework, + void* user_data); + +/** + * @brief Callbacks for model discovery file operations + */ +typedef struct { + rac_list_directory_fn list_directory; + rac_free_directory_entries_fn free_entries; + rac_is_directory_fn is_directory; + rac_path_exists_discovery_fn path_exists; + rac_is_model_file_fn is_model_file; + void* user_data; +} rac_discovery_callbacks_t; + +/** + * @brief Discovery result for a single model + */ +typedef struct { + /** Model ID that was discovered */ + const char* model_id; + /** Path where model was found */ + const char* local_path; + /** Framework of the model */ + rac_inference_framework_t framework; +} rac_discovered_model_t; + +/** + * @brief Result of model discovery scan + */ +typedef struct { + /** Number of models discovered as downloaded */ + size_t discovered_count; + /** Array of discovered models */ + rac_discovered_model_t* discovered_models; + /** Number of unregistered model folders found */ + size_t unregistered_count; +} rac_discovery_result_t; + +/** + * @brief Discover downloaded models on the file system. + * + * Scans the models directory for each framework, checks if folders + * contain valid model files, and updates the registry for registered models. + * + * @param handle Registry handle + * @param callbacks Platform file operation callbacks + * @param out_result Output: Discovery result (caller must call rac_discovery_result_free) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_model_registry_discover_downloaded( + rac_model_registry_handle_t handle, const rac_discovery_callbacks_t* callbacks, + rac_discovery_result_t* out_result); + +/** + * @brief Free discovery result + * @param result Discovery result to free + */ +RAC_API void rac_discovery_result_free(rac_discovery_result_t* result); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_MODEL_REGISTRY_H */ diff --git a/sdk/runanywhere-commons/include/rac/infrastructure/model_management/rac_model_strategy.h b/sdk/runanywhere-commons/include/rac/infrastructure/model_management/rac_model_strategy.h new file mode 100644 index 000000000..f4835bfe8 --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/infrastructure/model_management/rac_model_strategy.h @@ -0,0 +1,374 @@ +/** + * @file rac_model_strategy.h + * @brief Model Storage and Download Strategy Protocols + * + * Defines callback-based protocols for backend-specific model handling: + * - Storage Strategy: How models are stored, detected, and validated + * - Download Strategy: How models are downloaded and post-processed + * + * Each backend (ONNX, LlamaCPP, etc.) registers its strategies during + * backend registration. The SDK uses these strategies for model management. + * + * Architecture: + * - Strategies are registered per-framework via rac_model_strategy_register() + * - Swift/platform code provides file system operations via callbacks + * - Business logic (path resolution, validation, extraction) lives in C++ + */ + +#ifndef RAC_MODEL_STRATEGY_H +#define RAC_MODEL_STRATEGY_H + +#include +#include + +#include "rac/core/rac_error.h" +#include "rac/core/rac_types.h" +#include "rac/infrastructure/model_management/rac_model_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// STORAGE STRATEGY - How models are stored and detected on disk +// ============================================================================= + +/** + * @brief Model storage details returned by storage strategy + */ +typedef struct { + /** Model format detected */ + rac_model_format_t format; + + /** Total size on disk in bytes */ + int64_t total_size; + + /** Number of files in the model directory */ + int file_count; + + /** Primary model file name (e.g., "model.onnx") - owned, must free */ + char* primary_file; + + /** Whether this is a directory-based model (vs single file) */ + rac_bool_t is_directory_based; + + /** Whether the model storage is valid/complete */ + rac_bool_t is_valid; +} rac_model_storage_details_t; + +/** + * @brief Free storage details resources + */ +RAC_API void rac_model_storage_details_free(rac_model_storage_details_t* details); + +/** + * @brief Storage strategy callbacks - implemented by backend + * + * These callbacks define how a backend handles model storage detection. + * Each backend registers these during rac_backend_xxx_register(). + */ +typedef struct { + /** + * @brief Find the primary model path within a model folder + * + * For single-file models: returns path to the model file + * For directory-based models: returns path to primary model file or directory + * + * @param model_id Model identifier + * @param model_folder Path to the model's folder + * @param out_path Output buffer for the resolved path + * @param path_size Size of output buffer + * @param user_data Backend-specific context + * @return RAC_SUCCESS if found, RAC_ERROR_NOT_FOUND otherwise + */ + rac_result_t (*find_model_path)(const char* model_id, const char* model_folder, char* out_path, + size_t path_size, void* user_data); + + /** + * @brief Detect model format and size in a folder + * + * @param model_folder Path to check + * @param out_details Output storage details + * @param user_data Backend-specific context + * @return RAC_SUCCESS if model detected, RAC_ERROR_NOT_FOUND otherwise + */ + rac_result_t (*detect_model)(const char* model_folder, rac_model_storage_details_t* out_details, + void* user_data); + + /** + * @brief Validate that model storage is complete and usable + * + * @param model_folder Path to the model folder + * @param user_data Backend-specific context + * @return RAC_TRUE if valid, RAC_FALSE otherwise + */ + rac_bool_t (*is_valid_storage)(const char* model_folder, void* user_data); + + /** + * @brief Get list of expected file patterns for this backend + * + * @param out_patterns Output array of pattern strings (owned by backend) + * @param out_count Number of patterns + * @param user_data Backend-specific context + */ + void (*get_expected_patterns)(const char*** out_patterns, size_t* out_count, void* user_data); + + /** Backend-specific context passed to all callbacks */ + void* user_data; + + /** Human-readable name for logging */ + const char* name; +} rac_storage_strategy_t; + +// ============================================================================= +// DOWNLOAD STRATEGY - How models are downloaded and post-processed +// ============================================================================= + +/** + * @brief Model download task configuration (strategy-specific) + * + * Note: This is separate from rac_model_download_config_t in rac_download.h which + * is used for the download manager. This struct is strategy-specific. + */ +typedef struct rac_model_download_config { + /** Model ID being downloaded */ + const char* model_id; + + /** Source URL for download */ + const char* source_url; + + /** Destination folder path */ + const char* destination_folder; + + /** Expected archive type (or RAC_ARCHIVE_TYPE_NONE for direct files) */ + rac_archive_type_t archive_type; + + /** Expected total size in bytes (0 if unknown) */ + int64_t expected_size; + + /** Whether to resume partial downloads */ + rac_bool_t allow_resume; +} rac_model_download_config_t; + +/** + * @brief Download result information + */ +typedef struct { + /** Final path to the downloaded/extracted model */ + char* final_path; + + /** Actual size downloaded in bytes */ + int64_t downloaded_size; + + /** Whether extraction was performed */ + rac_bool_t was_extracted; + + /** Number of files after extraction (1 for single file) */ + int file_count; +} rac_download_result_t; + +/** + * @brief Free download result resources + */ +RAC_API void rac_download_result_free(rac_download_result_t* result); + +/** + * @brief Download strategy callbacks - implemented by backend + * + * These callbacks define how a backend handles model downloads. + * Actual HTTP transport is provided by platform (Swift/Kotlin). + */ +typedef struct { + /** + * @brief Prepare download - validate and configure + * + * Called before download starts to validate config and prepare destination. + * + * @param config Download configuration + * @param user_data Backend-specific context + * @return RAC_SUCCESS if ready to download + */ + rac_result_t (*prepare_download)(const rac_model_download_config_t* config, void* user_data); + + /** + * @brief Get the destination file path for download + * + * @param config Download configuration + * @param out_path Output buffer for destination path + * @param path_size Size of output buffer + * @param user_data Backend-specific context + * @return RAC_SUCCESS on success + */ + rac_result_t (*get_destination_path)(const rac_model_download_config_t* config, char* out_path, + size_t path_size, void* user_data); + + /** + * @brief Post-process after download (extraction, validation) + * + * Called after download completes. Handles extraction and validation. + * + * @param config Original download configuration + * @param downloaded_path Path to downloaded file + * @param out_result Output result information + * @param user_data Backend-specific context + * @return RAC_SUCCESS if post-processing succeeded + */ + rac_result_t (*post_process)(const rac_model_download_config_t* config, + const char* downloaded_path, rac_download_result_t* out_result, + void* user_data); + + /** + * @brief Cleanup failed or cancelled download + * + * @param config Download configuration + * @param user_data Backend-specific context + */ + void (*cleanup)(const rac_model_download_config_t* config, void* user_data); + + /** Backend-specific context passed to all callbacks */ + void* user_data; + + /** Human-readable name for logging */ + const char* name; +} rac_download_strategy_t; + +// ============================================================================= +// STRATEGY REGISTRATION API +// ============================================================================= + +/** + * @brief Register storage strategy for a framework + * + * Called by backends during rac_backend_xxx_register(). + * + * @param framework Framework this strategy applies to + * @param strategy Storage strategy callbacks + * @return RAC_SUCCESS on success + */ +RAC_API rac_result_t rac_storage_strategy_register(rac_inference_framework_t framework, + const rac_storage_strategy_t* strategy); + +/** + * @brief Register download strategy for a framework + * + * Called by backends during rac_backend_xxx_register(). + * + * @param framework Framework this strategy applies to + * @param strategy Download strategy callbacks + * @return RAC_SUCCESS on success + */ +RAC_API rac_result_t rac_download_strategy_register(rac_inference_framework_t framework, + const rac_download_strategy_t* strategy); + +/** + * @brief Unregister strategies for a framework + * + * Called by backends during unregistration. + * + * @param framework Framework to unregister + */ +RAC_API void rac_model_strategy_unregister(rac_inference_framework_t framework); + +// ============================================================================= +// STRATEGY LOOKUP API - Used by SDK core +// ============================================================================= + +/** + * @brief Get storage strategy for a framework + * + * @param framework Framework to query + * @return Strategy pointer or NULL if not registered + */ +RAC_API const rac_storage_strategy_t* rac_storage_strategy_get(rac_inference_framework_t framework); + +/** + * @brief Get download strategy for a framework + * + * @param framework Framework to query + * @return Strategy pointer or NULL if not registered + */ +RAC_API const rac_download_strategy_t* +rac_download_strategy_get(rac_inference_framework_t framework); + +// ============================================================================= +// CONVENIENCE API - High-level operations using registered strategies +// ============================================================================= + +/** + * @brief Find model path using framework's storage strategy + * + * @param framework Inference framework + * @param model_id Model identifier + * @param model_folder Model folder path + * @param out_path Output buffer for resolved path + * @param path_size Size of output buffer + * @return RAC_SUCCESS if found + */ +RAC_API rac_result_t rac_model_strategy_find_path(rac_inference_framework_t framework, + const char* model_id, const char* model_folder, + char* out_path, size_t path_size); + +/** + * @brief Detect model using framework's storage strategy + * + * @param framework Inference framework + * @param model_folder Model folder path + * @param out_details Output storage details + * @return RAC_SUCCESS if model detected + */ +RAC_API rac_result_t rac_model_strategy_detect(rac_inference_framework_t framework, + const char* model_folder, + rac_model_storage_details_t* out_details); + +/** + * @brief Validate model storage using framework's strategy + * + * @param framework Inference framework + * @param model_folder Model folder path + * @return RAC_TRUE if valid + */ +RAC_API rac_bool_t rac_model_strategy_is_valid(rac_inference_framework_t framework, + const char* model_folder); + +/** + * @brief Prepare download using framework's strategy + * + * @param framework Inference framework + * @param config Download configuration + * @return RAC_SUCCESS if ready + */ +RAC_API rac_result_t rac_model_strategy_prepare_download(rac_inference_framework_t framework, + const rac_model_download_config_t* config); + +/** + * @brief Get download destination using framework's strategy + * + * @param framework Inference framework + * @param config Download configuration + * @param out_path Output buffer for path + * @param path_size Size of output buffer + * @return RAC_SUCCESS on success + */ +RAC_API rac_result_t rac_model_strategy_get_download_dest(rac_inference_framework_t framework, + const rac_model_download_config_t* config, + char* out_path, size_t path_size); + +/** + * @brief Post-process download using framework's strategy + * + * @param framework Inference framework + * @param config Download configuration + * @param downloaded_path Path to downloaded file + * @param out_result Output result + * @return RAC_SUCCESS if successful + */ +RAC_API rac_result_t rac_model_strategy_post_process(rac_inference_framework_t framework, + const rac_model_download_config_t* config, + const char* downloaded_path, + rac_download_result_t* out_result); + +#ifdef __cplusplus +} +#endif + +#endif // RAC_MODEL_STRATEGY_H diff --git a/sdk/runanywhere-commons/include/rac/infrastructure/model_management/rac_model_types.h b/sdk/runanywhere-commons/include/rac/infrastructure/model_management/rac_model_types.h new file mode 100644 index 000000000..e69641fe6 --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/infrastructure/model_management/rac_model_types.h @@ -0,0 +1,613 @@ +/** + * @file rac_model_types.h + * @brief Model Types - Comprehensive Type Definitions for Model Management + * + * C port of Swift's model type definitions from: + * - ModelCategory.swift + * - ModelFormat.swift + * - ModelArtifactType.swift + * - InferenceFramework.swift + * - ModelInfo.swift + * + * IMPORTANT: This is a direct translation of the Swift implementation. + * Do NOT add features not present in the Swift code. + */ + +#ifndef RAC_MODEL_TYPES_H +#define RAC_MODEL_TYPES_H + +#include +#include + +#include "rac/core/rac_error.h" +#include "rac/core/rac_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// ARCHIVE TYPES - From ModelArtifactType.swift +// ============================================================================= + +/** + * @brief Supported archive formats for model packaging. + * Mirrors Swift's ArchiveType enum. + */ +typedef enum rac_archive_type { + RAC_ARCHIVE_TYPE_NONE = -1, /**< No archive - direct file */ + RAC_ARCHIVE_TYPE_ZIP = 0, /**< ZIP archive */ + RAC_ARCHIVE_TYPE_TAR_BZ2 = 1, /**< tar.bz2 archive */ + RAC_ARCHIVE_TYPE_TAR_GZ = 2, /**< tar.gz archive */ + RAC_ARCHIVE_TYPE_TAR_XZ = 3 /**< tar.xz archive */ +} rac_archive_type_t; + +/** + * @brief Internal structure of an archive after extraction. + * Mirrors Swift's ArchiveStructure enum. + */ +typedef enum rac_archive_structure { + RAC_ARCHIVE_STRUCTURE_SINGLE_FILE_NESTED = + 0, /**< Single model file at root or nested in one directory */ + RAC_ARCHIVE_STRUCTURE_DIRECTORY_BASED = 1, /**< Multiple files in a directory */ + RAC_ARCHIVE_STRUCTURE_NESTED_DIRECTORY = 2, /**< Subdirectory structure */ + RAC_ARCHIVE_STRUCTURE_UNKNOWN = 99 /**< Unknown - detected after extraction */ +} rac_archive_structure_t; + +// ============================================================================= +// EXPECTED MODEL FILES - From ModelArtifactType.swift +// ============================================================================= + +/** + * @brief Expected model files after extraction/download. + * Mirrors Swift's ExpectedModelFiles struct. + */ +typedef struct rac_expected_model_files { + /** File patterns that must be present (e.g., "*.onnx", "encoder*.onnx") */ + const char** required_patterns; + size_t required_pattern_count; + + /** File patterns that may be present but are optional */ + const char** optional_patterns; + size_t optional_pattern_count; + + /** Description of the model files for documentation */ + const char* description; +} rac_expected_model_files_t; + +/** + * @brief Multi-file model descriptor. + * Mirrors Swift's ModelFileDescriptor struct. + */ +typedef struct rac_model_file_descriptor { + /** Relative path from base URL to this file */ + const char* relative_path; + + /** Destination path relative to model folder */ + const char* destination_path; + + /** Whether this file is required (vs optional) */ + rac_bool_t is_required; +} rac_model_file_descriptor_t; + +// ============================================================================= +// MODEL ARTIFACT TYPE - From ModelArtifactType.swift +// ============================================================================= + +/** + * @brief Model artifact type enumeration. + * Mirrors Swift's ModelArtifactType enum (simplified for C). + */ +typedef enum rac_artifact_type_kind { + RAC_ARTIFACT_KIND_SINGLE_FILE = 0, /**< Single file download */ + RAC_ARTIFACT_KIND_ARCHIVE = 1, /**< Archive requiring extraction */ + RAC_ARTIFACT_KIND_MULTI_FILE = 2, /**< Multiple files */ + RAC_ARTIFACT_KIND_CUSTOM = 3, /**< Custom download strategy */ + RAC_ARTIFACT_KIND_BUILT_IN = 4 /**< Built-in model (no download) */ +} rac_artifact_type_kind_t; + +/** + * @brief Full model artifact type with associated data. + * Mirrors Swift's ModelArtifactType enum with associated values. + */ +typedef struct rac_model_artifact_info { + /** The kind of artifact */ + rac_artifact_type_kind_t kind; + + /** For archive type: the archive format */ + rac_archive_type_t archive_type; + + /** For archive type: the internal structure */ + rac_archive_structure_t archive_structure; + + /** Expected files after extraction (can be NULL) */ + rac_expected_model_files_t* expected_files; + + /** For multi-file: descriptors array (can be NULL) */ + rac_model_file_descriptor_t* file_descriptors; + size_t file_descriptor_count; + + /** For custom: strategy identifier */ + const char* strategy_id; +} rac_model_artifact_info_t; + +// ============================================================================= +// MODEL CATEGORY - From ModelCategory.swift +// ============================================================================= + +/** + * @brief Model category based on input/output modality. + * Mirrors Swift's ModelCategory enum. + */ +typedef enum rac_model_category { + RAC_MODEL_CATEGORY_LANGUAGE = 0, /**< Text-to-text models (LLMs) */ + RAC_MODEL_CATEGORY_SPEECH_RECOGNITION = 1, /**< Voice-to-text models (ASR/STT) */ + RAC_MODEL_CATEGORY_SPEECH_SYNTHESIS = 2, /**< Text-to-voice models (TTS) */ + RAC_MODEL_CATEGORY_VISION = 3, /**< Image understanding models */ + RAC_MODEL_CATEGORY_IMAGE_GENERATION = 4, /**< Text-to-image models */ + RAC_MODEL_CATEGORY_MULTIMODAL = 5, /**< Multi-modality models */ + RAC_MODEL_CATEGORY_AUDIO = 6, /**< Audio processing (diarization, etc.) */ + RAC_MODEL_CATEGORY_UNKNOWN = 99 /**< Unknown category */ +} rac_model_category_t; + +// ============================================================================= +// MODEL FORMAT - From ModelFormat.swift +// ============================================================================= + +/** + * @brief Supported model file formats. + * Mirrors Swift's ModelFormat enum. + */ +typedef enum rac_model_format { + RAC_MODEL_FORMAT_ONNX = 0, /**< ONNX format */ + RAC_MODEL_FORMAT_ORT = 1, /**< ONNX Runtime format */ + RAC_MODEL_FORMAT_GGUF = 2, /**< GGUF format (llama.cpp) */ + RAC_MODEL_FORMAT_BIN = 3, /**< Binary format */ + RAC_MODEL_FORMAT_UNKNOWN = 99 /**< Unknown format */ +} rac_model_format_t; + +// ============================================================================= +// INFERENCE FRAMEWORK - From InferenceFramework.swift +// ============================================================================= + +/** + * @brief Supported inference frameworks/runtimes. + * Mirrors Swift's InferenceFramework enum. + */ +typedef enum rac_inference_framework { + RAC_FRAMEWORK_ONNX = 0, /**< ONNX Runtime */ + RAC_FRAMEWORK_LLAMACPP = 1, /**< llama.cpp */ + RAC_FRAMEWORK_FOUNDATION_MODELS = 2, /**< Apple Foundation Models */ + RAC_FRAMEWORK_SYSTEM_TTS = 3, /**< System TTS */ + RAC_FRAMEWORK_FLUID_AUDIO = 4, /**< FluidAudio */ + RAC_FRAMEWORK_BUILTIN = 5, /**< Built-in (e.g., energy VAD) */ + RAC_FRAMEWORK_NONE = 6, /**< No framework needed */ + RAC_FRAMEWORK_UNKNOWN = 99 /**< Unknown framework */ +} rac_inference_framework_t; + +// ============================================================================= +// MODEL SOURCE +// ============================================================================= + +/** + * @brief Model source enumeration. + * Mirrors Swift's ModelSource enum. + */ +typedef enum rac_model_source { + RAC_MODEL_SOURCE_REMOTE = 0, /**< Model from remote API/catalog */ + RAC_MODEL_SOURCE_LOCAL = 1 /**< Model provided locally */ +} rac_model_source_t; + +// ============================================================================= +// MODEL INFO - From ModelInfo.swift +// ============================================================================= + +/** + * @brief Complete model information structure. + * Mirrors Swift's ModelInfo struct. + */ +typedef struct rac_model_info { + /** Unique model identifier */ + char* id; + + /** Human-readable model name */ + char* name; + + /** Model category */ + rac_model_category_t category; + + /** Model format */ + rac_model_format_t format; + + /** Inference framework */ + rac_inference_framework_t framework; + + /** Download URL (can be NULL) */ + char* download_url; + + /** Local path (can be NULL) */ + char* local_path; + + /** Artifact information */ + rac_model_artifact_info_t artifact_info; + + /** Download size in bytes (0 if unknown) */ + int64_t download_size; + + /** Memory required in bytes (0 if unknown) */ + int64_t memory_required; + + /** Context length (for language models, 0 if not applicable) */ + int32_t context_length; + + /** Whether model supports thinking/reasoning */ + rac_bool_t supports_thinking; + + /** Tags (NULL-terminated array of strings, can be NULL) */ + char** tags; + size_t tag_count; + + /** Description (can be NULL) */ + char* description; + + /** Model source */ + rac_model_source_t source; + + /** Created timestamp (Unix timestamp) */ + int64_t created_at; + + /** Updated timestamp (Unix timestamp) */ + int64_t updated_at; + + /** Last used timestamp (0 if never used) */ + int64_t last_used; + + /** Usage count */ + int32_t usage_count; +} rac_model_info_t; + +// ============================================================================= +// HELPER FUNCTIONS +// ============================================================================= + +/** + * @brief Get file extension for archive type. + * Mirrors Swift's ArchiveType.fileExtension. + * + * @param type Archive type + * @return File extension string (e.g., "zip", "tar.bz2") + */ +RAC_API const char* rac_archive_type_extension(rac_archive_type_t type); + +/** + * @brief Detect archive type from URL path. + * Mirrors Swift's ArchiveType.from(url:). + * + * @param url_path URL path string + * @param out_type Output: Detected archive type + * @return RAC_TRUE if archive detected, RAC_FALSE otherwise + */ +RAC_API rac_bool_t rac_archive_type_from_path(const char* url_path, rac_archive_type_t* out_type); + +/** + * @brief Check if model category requires context length. + * Mirrors Swift's ModelCategory.requiresContextLength. + * + * @param category Model category + * @return RAC_TRUE if requires context length + */ +RAC_API rac_bool_t rac_model_category_requires_context_length(rac_model_category_t category); + +/** + * @brief Check if model category supports thinking/reasoning. + * Mirrors Swift's ModelCategory.supportsThinking. + * + * @param category Model category + * @return RAC_TRUE if supports thinking + */ +RAC_API rac_bool_t rac_model_category_supports_thinking(rac_model_category_t category); + +/** + * @brief Get model category from framework. + * Mirrors Swift's ModelCategory.from(framework:). + * + * @param framework Inference framework + * @return Model category + */ +RAC_API rac_model_category_t rac_model_category_from_framework(rac_inference_framework_t framework); + +/** + * @brief Get supported formats for a framework. + * Mirrors Swift's InferenceFramework.supportedFormats. + * + * @param framework Inference framework + * @param out_formats Output: Array of supported formats + * @param out_count Output: Number of formats + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_framework_get_supported_formats(rac_inference_framework_t framework, + rac_model_format_t** out_formats, + size_t* out_count); + +/** + * @brief Check if framework supports a format. + * Mirrors Swift's InferenceFramework.supports(format:). + * + * @param framework Inference framework + * @param format Model format + * @return RAC_TRUE if supported + */ +RAC_API rac_bool_t rac_framework_supports_format(rac_inference_framework_t framework, + rac_model_format_t format); + +/** + * @brief Check if framework uses directory-based models. + * Mirrors Swift's InferenceFramework.usesDirectoryBasedModels. + * + * @param framework Inference framework + * @return RAC_TRUE if uses directory-based models + */ +RAC_API rac_bool_t rac_framework_uses_directory_based_models(rac_inference_framework_t framework); + +/** + * @brief Check if framework supports LLM. + * Mirrors Swift's InferenceFramework.supportsLLM. + * + * @param framework Inference framework + * @return RAC_TRUE if supports LLM + */ +RAC_API rac_bool_t rac_framework_supports_llm(rac_inference_framework_t framework); + +/** + * @brief Check if framework supports STT. + * Mirrors Swift's InferenceFramework.supportsSTT. + * + * @param framework Inference framework + * @return RAC_TRUE if supports STT + */ +RAC_API rac_bool_t rac_framework_supports_stt(rac_inference_framework_t framework); + +/** + * @brief Check if framework supports TTS. + * Mirrors Swift's InferenceFramework.supportsTTS. + * + * @param framework Inference framework + * @return RAC_TRUE if supports TTS + */ +RAC_API rac_bool_t rac_framework_supports_tts(rac_inference_framework_t framework); + +/** + * @brief Get framework display name. + * Mirrors Swift's InferenceFramework.displayName. + * + * @param framework Inference framework + * @return Display name string + */ +RAC_API const char* rac_framework_display_name(rac_inference_framework_t framework); + +/** + * @brief Get framework analytics key. + * Mirrors Swift's InferenceFramework.analyticsKey. + * + * @param framework Inference framework + * @return Analytics key string (snake_case) + */ +RAC_API const char* rac_framework_analytics_key(rac_inference_framework_t framework); + +/** + * @brief Check if artifact requires extraction. + * Mirrors Swift's ModelArtifactType.requiresExtraction. + * + * @param artifact Artifact info + * @return RAC_TRUE if requires extraction + */ +RAC_API rac_bool_t rac_artifact_requires_extraction(const rac_model_artifact_info_t* artifact); + +/** + * @brief Check if artifact requires download. + * Mirrors Swift's ModelArtifactType.requiresDownload. + * + * @param artifact Artifact info + * @return RAC_TRUE if requires download + */ +RAC_API rac_bool_t rac_artifact_requires_download(const rac_model_artifact_info_t* artifact); + +/** + * @brief Infer artifact type from URL. + * Mirrors Swift's ModelArtifactType.infer(from:format:). + * + * @param url Download URL (can be NULL) + * @param format Model format + * @param out_artifact Output: Inferred artifact info + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_artifact_infer_from_url(const char* url, rac_model_format_t format, + rac_model_artifact_info_t* out_artifact); + +/** + * @brief Check if model is downloaded and available. + * Mirrors Swift's ModelInfo.isDownloaded. + * + * @param model Model info + * @return RAC_TRUE if downloaded + */ +RAC_API rac_bool_t rac_model_info_is_downloaded(const rac_model_info_t* model); + +// ============================================================================= +// FORMAT DETECTION - From RegistryService.swift +// ============================================================================= + +/** + * @brief Detect model format from file extension. + * Ported from Swift RegistryService.detectFormatFromExtension() (lines 330-338) + * + * @param extension File extension (without dot, e.g., "onnx", "gguf") + * @param out_format Output: Detected format + * @return RAC_TRUE if format detected, RAC_FALSE if unknown + */ +RAC_API rac_bool_t rac_model_detect_format_from_extension(const char* extension, + rac_model_format_t* out_format); + +/** + * @brief Detect framework from model format. + * Ported from Swift RegistryService.detectFramework(for:) (lines 340-343) + * + * @param format Model format + * @param out_framework Output: Detected framework + * @return RAC_TRUE if framework detected, RAC_FALSE if unknown + */ +RAC_API rac_bool_t rac_model_detect_framework_from_format(rac_model_format_t format, + rac_inference_framework_t* out_framework); + +/** + * @brief Get file extension string for a model format. + * Mirrors Swift's ModelFormat.fileExtension. + * + * @param format Model format + * @return Extension string (e.g., "onnx", "gguf") or NULL if unknown + */ +RAC_API const char* rac_model_format_extension(rac_model_format_t format); + +// ============================================================================= +// MODEL ID/NAME GENERATION - From RegistryService.swift +// ============================================================================= + +/** + * @brief Generate model ID from URL by stripping known extensions. + * Ported from Swift RegistryService.generateModelId(from:) (lines 311-318) + * + * @param url URL path string (e.g., "model.tar.gz", "llama-7b.gguf") + * @param out_id Output buffer for model ID + * @param max_len Maximum length of output buffer + */ +RAC_API void rac_model_generate_id(const char* url, char* out_id, size_t max_len); + +/** + * @brief Generate human-readable model name from URL. + * Ported from Swift RegistryService.generateModelName(from:) (lines 320-324) + * Replaces underscores and dashes with spaces. + * + * @param url URL path string + * @param out_name Output buffer for model name + * @param max_len Maximum length of output buffer + */ +RAC_API void rac_model_generate_name(const char* url, char* out_name, size_t max_len); + +// ============================================================================= +// MODEL FILTERING - From RegistryService.swift +// ============================================================================= + +/** + * @brief Model filtering criteria. + * Mirrors Swift's ModelCriteria struct. + */ +typedef struct rac_model_filter { + /** Filter by framework (RAC_FRAMEWORK_UNKNOWN = any) */ + rac_inference_framework_t framework; + + /** Filter by format (RAC_MODEL_FORMAT_UNKNOWN = any) */ + rac_model_format_t format; + + /** Maximum download size in bytes (0 = no limit) */ + int64_t max_size; + + /** Search query for name/id/description (NULL = no search filter) */ + const char* search_query; +} rac_model_filter_t; + +/** + * @brief Filter models by criteria. + * Ported from Swift RegistryService.filterModels(by:) (lines 104-126) + * + * @param models Array of models to filter + * @param models_count Number of models in input array + * @param filter Filter criteria (NULL = no filtering, return all) + * @param out_models Output array for filtered models (caller allocates) + * @param out_capacity Maximum capacity of output array + * @return Number of models that passed the filter (may exceed out_capacity) + */ +RAC_API size_t rac_model_filter_models(const rac_model_info_t* models, size_t models_count, + const rac_model_filter_t* filter, + rac_model_info_t* out_models, size_t out_capacity); + +/** + * @brief Check if a model matches filter criteria. + * Helper function for filtering. + * + * @param model Model to check + * @param filter Filter criteria + * @return RAC_TRUE if model matches, RAC_FALSE otherwise + */ +RAC_API rac_bool_t rac_model_matches_filter(const rac_model_info_t* model, + const rac_model_filter_t* filter); + +// ============================================================================= +// MEMORY MANAGEMENT +// ============================================================================= + +/** + * @brief Allocate expected model files structure. + * + * @return Allocated structure (must be freed with rac_expected_model_files_free) + */ +RAC_API rac_expected_model_files_t* rac_expected_model_files_alloc(void); + +/** + * @brief Free expected model files structure. + * + * @param files Structure to free + */ +RAC_API void rac_expected_model_files_free(rac_expected_model_files_t* files); + +/** + * @brief Allocate model file descriptor array. + * + * @param count Number of descriptors + * @return Allocated array (must be freed with rac_model_file_descriptors_free) + */ +RAC_API rac_model_file_descriptor_t* rac_model_file_descriptors_alloc(size_t count); + +/** + * @brief Free model file descriptor array. + * + * @param descriptors Array to free + * @param count Number of descriptors + */ +RAC_API void rac_model_file_descriptors_free(rac_model_file_descriptor_t* descriptors, + size_t count); + +/** + * @brief Allocate model info structure. + * + * @return Allocated structure (must be freed with rac_model_info_free) + */ +RAC_API rac_model_info_t* rac_model_info_alloc(void); + +/** + * @brief Free model info structure. + * + * @param model Model info to free + */ +RAC_API void rac_model_info_free(rac_model_info_t* model); + +/** + * @brief Free array of model info pointers. + * + * @param models Array of model info pointers + * @param count Number of models + */ +RAC_API void rac_model_info_array_free(rac_model_info_t** models, size_t count); + +/** + * @brief Deep copy model info structure. + * + * @param model Model info to copy + * @return Deep copy (must be freed with rac_model_info_free) + */ +RAC_API rac_model_info_t* rac_model_info_copy(const rac_model_info_t* model); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_MODEL_TYPES_H */ diff --git a/sdk/runanywhere-commons/include/rac/infrastructure/network/rac_api_types.h b/sdk/runanywhere-commons/include/rac/infrastructure/network/rac_api_types.h new file mode 100644 index 000000000..9f969b0af --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/infrastructure/network/rac_api_types.h @@ -0,0 +1,335 @@ +/** + * @file rac_api_types.h + * @brief API request and response data types + * + * Defines all data structures for API communication. + * This is the canonical source of truth - platform SDKs create thin wrappers. + */ + +#ifndef RAC_API_TYPES_H +#define RAC_API_TYPES_H + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// Authentication Types +// ============================================================================= + +/** + * @brief Authentication request payload + * Sent to POST /api/v1/auth/sdk/authenticate + */ +typedef struct { + const char* api_key; + const char* device_id; + const char* platform; // "ios", "android", etc. + const char* sdk_version; +} rac_auth_request_t; + +/** + * @brief Authentication response payload + * Received from authentication and refresh endpoints + */ +typedef struct { + char* access_token; + char* refresh_token; + char* device_id; + char* user_id; // Can be NULL (org-level auth) + char* organization_id; + char* token_type; // Usually "bearer" + int32_t expires_in; // Seconds until expiry +} rac_auth_response_t; + +/** + * @brief Refresh token request payload + * Sent to POST /api/v1/auth/sdk/refresh + */ +typedef struct { + const char* device_id; + const char* refresh_token; +} rac_refresh_request_t; + +// ============================================================================= +// Health Check Types +// ============================================================================= + +/** + * @brief Health status enum + */ +typedef enum { + RAC_HEALTH_HEALTHY = 0, + RAC_HEALTH_DEGRADED = 1, + RAC_HEALTH_UNHEALTHY = 2 +} rac_health_status_t; + +/** + * @brief Health check response + * Received from GET /v1/health + */ +typedef struct { + rac_health_status_t status; + char* version; + int64_t timestamp; // Unix timestamp +} rac_health_response_t; + +// ============================================================================= +// Device Registration Types +// ============================================================================= + +/** + * @brief Device hardware information + */ +typedef struct { + const char* device_fingerprint; + const char* device_model; // e.g., "iPhone15,2" + const char* os_version; // e.g., "17.0" + const char* platform; // "ios", "android", etc. + const char* architecture; // "arm64", "x86_64", etc. + int64_t total_memory; // Bytes + int32_t cpu_cores; + bool has_neural_engine; + bool has_gpu; +} rac_device_info_t; + +/** + * @brief Device registration request + * Sent to POST /api/v1/devices/register + */ +typedef struct { + rac_device_info_t device_info; + const char* sdk_version; + const char* build_token; + int64_t last_seen_at; // Unix timestamp +} rac_device_reg_request_t; + +/** + * @brief Device registration response + */ +typedef struct { + char* device_id; + char* status; // "registered" or "updated" + char* sync_status; // "synced" or "pending" +} rac_device_reg_response_t; + +// ============================================================================= +// Telemetry Types +// ============================================================================= + +/** + * @brief Telemetry event payload + * Contains all possible fields for LLM, STT, TTS, VAD events + */ +typedef struct { + // Required fields + const char* id; + const char* event_type; + int64_t timestamp; // Unix timestamp ms + int64_t created_at; // Unix timestamp ms + + // Event classification + const char* modality; // "llm", "stt", "tts", "model", "system" + + // Device identification + const char* device_id; + const char* session_id; + + // Model info + const char* model_id; + const char* model_name; + const char* framework; + + // Device info + const char* device; + const char* os_version; + const char* platform; + const char* sdk_version; + + // Common metrics + double processing_time_ms; + bool success; + bool has_success; // Whether success field is set + const char* error_message; + const char* error_code; + + // LLM-specific + int32_t input_tokens; + int32_t output_tokens; + int32_t total_tokens; + double tokens_per_second; + double time_to_first_token_ms; + double prompt_eval_time_ms; + double generation_time_ms; + int32_t context_length; + double temperature; + int32_t max_tokens; + + // STT-specific + double audio_duration_ms; + double real_time_factor; + int32_t word_count; + double confidence; + const char* language; + bool is_streaming; + int32_t segment_index; + + // TTS-specific + int32_t character_count; + double characters_per_second; + int32_t audio_size_bytes; + int32_t sample_rate; + const char* voice; + double output_duration_ms; + + // Model lifecycle + int64_t model_size_bytes; + const char* archive_type; + + // VAD-specific + double speech_duration_ms; + + // SDK lifecycle + int32_t count; + + // Storage + int64_t freed_bytes; + + // Network + bool is_online; + bool has_is_online; +} rac_telemetry_event_t; + +/** + * @brief Telemetry batch request + * Sent to POST /api/v1/sdk/telemetry + */ +typedef struct { + rac_telemetry_event_t* events; + size_t event_count; + const char* device_id; + int64_t timestamp; + const char* modality; // Can be NULL for V1 path +} rac_telemetry_batch_t; + +/** + * @brief Telemetry batch response + */ +typedef struct { + bool success; + int32_t events_received; + int32_t events_stored; + int32_t events_skipped; + char** errors; + size_t error_count; + char* storage_version; // "V1" or "V2" +} rac_telemetry_response_t; + +// ============================================================================= +// API Error Types +// ============================================================================= + +/** + * @brief API error information + */ +typedef struct { + int32_t status_code; + char* message; + char* code; + char* raw_body; + char* request_url; +} rac_api_error_t; + +// ============================================================================= +// Memory Management +// ============================================================================= + +/** + * @brief Free authentication response + */ +void rac_auth_response_free(rac_auth_response_t* response); + +/** + * @brief Free health response + */ +void rac_health_response_free(rac_health_response_t* response); + +/** + * @brief Free device registration response + */ +void rac_device_reg_response_free(rac_device_reg_response_t* response); + +/** + * @brief Free telemetry response + */ +void rac_telemetry_response_free(rac_telemetry_response_t* response); + +/** + * @brief Free API error + */ +void rac_api_error_free(rac_api_error_t* error); + +// ============================================================================= +// JSON Serialization +// ============================================================================= + +/** + * @brief Serialize auth request to JSON + * @param request The request to serialize + * @return JSON string (caller must free), or NULL on error + */ +char* rac_auth_request_to_json(const rac_auth_request_t* request); + +/** + * @brief Parse auth response from JSON + * @param json The JSON string + * @param out_response Output response (caller must free with rac_auth_response_free) + * @return 0 on success, -1 on error + */ +int rac_auth_response_from_json(const char* json, rac_auth_response_t* out_response); + +/** + * @brief Serialize refresh request to JSON + */ +char* rac_refresh_request_to_json(const rac_refresh_request_t* request); + +/** + * @brief Serialize device registration request to JSON + */ +char* rac_device_reg_request_to_json(const rac_device_reg_request_t* request); + +/** + * @brief Parse device registration response from JSON + */ +int rac_device_reg_response_from_json(const char* json, rac_device_reg_response_t* out_response); + +/** + * @brief Serialize telemetry event to JSON + */ +char* rac_telemetry_event_to_json(const rac_telemetry_event_t* event); + +/** + * @brief Serialize telemetry batch to JSON + */ +char* rac_telemetry_batch_to_json(const rac_telemetry_batch_t* batch); + +/** + * @brief Parse telemetry response from JSON + */ +int rac_telemetry_response_from_json(const char* json, rac_telemetry_response_t* out_response); + +/** + * @brief Parse API error from HTTP response + */ +int rac_api_error_from_response(int status_code, const char* body, const char* url, + rac_api_error_t* out_error); + +#ifdef __cplusplus +} +#endif + +#endif // RAC_API_TYPES_H diff --git a/sdk/runanywhere-commons/include/rac/infrastructure/network/rac_auth_manager.h b/sdk/runanywhere-commons/include/rac/infrastructure/network/rac_auth_manager.h new file mode 100644 index 000000000..d06a6c75a --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/infrastructure/network/rac_auth_manager.h @@ -0,0 +1,252 @@ +/** + * @file rac_auth_manager.h + * @brief Authentication state management + * + * Manages authentication state including tokens, expiry, and refresh logic. + * Platform SDKs provide HTTP transport and secure storage callbacks. + */ + +#ifndef RAC_AUTH_MANAGER_H +#define RAC_AUTH_MANAGER_H + +#include +#include + +#include "rac/infrastructure/network/rac_api_types.h" +#include "rac/infrastructure/network/rac_environment.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// Auth State +// ============================================================================= + +/** + * @brief Authentication state structure + * + * Managed internally - use accessor functions. + */ +typedef struct { + char* access_token; + char* refresh_token; + char* device_id; + char* user_id; // Can be NULL + char* organization_id; + int64_t token_expires_at; // Unix timestamp (seconds) + bool is_authenticated; +} rac_auth_state_t; + +// ============================================================================= +// Platform Callbacks +// ============================================================================= + +/** + * @brief Callback for secure storage operations + * + * Platform implements to store tokens in Keychain/KeyStore. + */ +typedef struct { + /** + * @brief Store string value securely + * @param key Storage key + * @param value Value to store + * @return 0 on success, -1 on error + */ + int (*store)(const char* key, const char* value, void* context); + + /** + * @brief Retrieve string value + * @param key Storage key + * @param out_value Output buffer (caller provides) + * @param buffer_size Size of output buffer + * @return Length of value, or -1 on error/not found + */ + int (*retrieve)(const char* key, char* out_value, size_t buffer_size, void* context); + + /** + * @brief Delete stored value + * @param key Storage key + * @return 0 on success, -1 on error + */ + int (*delete_key)(const char* key, void* context); + + /** + * @brief Context pointer passed to all callbacks + */ + void* context; +} rac_secure_storage_t; + +// ============================================================================= +// Keychain Keys (for platform implementations) +// ============================================================================= + +#define RAC_KEY_ACCESS_TOKEN "com.runanywhere.sdk.accessToken" +#define RAC_KEY_REFRESH_TOKEN "com.runanywhere.sdk.refreshToken" +#define RAC_KEY_DEVICE_ID "com.runanywhere.sdk.deviceId" +#define RAC_KEY_USER_ID "com.runanywhere.sdk.userId" +#define RAC_KEY_ORGANIZATION_ID "com.runanywhere.sdk.organizationId" + +// ============================================================================= +// Initialization +// ============================================================================= + +/** + * @brief Initialize auth manager + * @param storage Secure storage callbacks (can be NULL for in-memory only) + */ +void rac_auth_init(const rac_secure_storage_t* storage); + +/** + * @brief Reset auth manager state + */ +void rac_auth_reset(void); + +// ============================================================================= +// Token State +// ============================================================================= + +/** + * @brief Check if currently authenticated + * @return true if valid access token exists + */ +bool rac_auth_is_authenticated(void); + +/** + * @brief Check if token needs refresh + * + * Returns true if token expires within 60 seconds. + * + * @return true if token should be refreshed + */ +bool rac_auth_needs_refresh(void); + +/** + * @brief Get current access token + * @return Access token string, or NULL if not authenticated + */ +const char* rac_auth_get_access_token(void); + +/** + * @brief Get current device ID + * @return Device ID string, or NULL if not set + */ +const char* rac_auth_get_device_id(void); + +/** + * @brief Get current user ID + * @return User ID string, or NULL if not set + */ +const char* rac_auth_get_user_id(void); + +/** + * @brief Get current organization ID + * @return Organization ID string, or NULL if not set + */ +const char* rac_auth_get_organization_id(void); + +// ============================================================================= +// Request Building +// ============================================================================= + +/** + * @brief Build authentication request JSON + * + * Creates JSON payload for POST /api/v1/auth/sdk/authenticate + * + * @param config SDK configuration with credentials + * @return JSON string (caller must free), or NULL on error + */ +char* rac_auth_build_authenticate_request(const rac_sdk_config_t* config); + +/** + * @brief Build token refresh request JSON + * + * Creates JSON payload for POST /api/v1/auth/sdk/refresh + * + * @return JSON string (caller must free), or NULL if no refresh token + */ +char* rac_auth_build_refresh_request(void); + +// ============================================================================= +// Response Handling +// ============================================================================= + +/** + * @brief Parse and store authentication response + * + * Updates internal auth state and optionally persists to secure storage. + * + * @param json JSON response body + * @return 0 on success, -1 on parse error + */ +int rac_auth_handle_authenticate_response(const char* json); + +/** + * @brief Parse and store refresh response + * + * Updates internal auth state and optionally persists to secure storage. + * + * @param json JSON response body + * @return 0 on success, -1 on parse error + */ +int rac_auth_handle_refresh_response(const char* json); + +// ============================================================================= +// Token Management +// ============================================================================= + +/** + * @brief Get valid access token, triggering refresh if needed + * + * This is the main entry point for getting a token. If the current token + * is expired or about to expire, it will: + * 1. Build a refresh request + * 2. Return a pending state indicating refresh is needed + * + * Platform must then: + * 1. Execute the HTTP request + * 2. Call rac_auth_handle_refresh_response with result + * 3. Call this function again to get the new token + * + * @param out_token Output pointer for token string + * @param out_needs_refresh Set to true if refresh HTTP call is needed + * @return 0 on success (token valid), 1 if refresh needed, -1 on error + */ +int rac_auth_get_valid_token(const char** out_token, bool* out_needs_refresh); + +/** + * @brief Clear all authentication state + * + * Clears in-memory state and secure storage. + */ +void rac_auth_clear(void); + +// ============================================================================= +// Persistence +// ============================================================================= + +/** + * @brief Load tokens from secure storage + * + * Call during initialization to restore saved auth state. + * + * @return 0 on success (tokens loaded), -1 if not found or error + */ +int rac_auth_load_stored_tokens(void); + +/** + * @brief Save current tokens to secure storage + * + * Called automatically by response handlers, but can be called manually. + * + * @return 0 on success, -1 on error + */ +int rac_auth_save_tokens(void); + +#ifdef __cplusplus +} +#endif + +#endif // RAC_AUTH_MANAGER_H diff --git a/sdk/runanywhere-commons/include/rac/infrastructure/network/rac_dev_config.h b/sdk/runanywhere-commons/include/rac/infrastructure/network/rac_dev_config.h new file mode 100644 index 000000000..9bf1288cb --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/infrastructure/network/rac_dev_config.h @@ -0,0 +1,85 @@ +/** + * @file rac_dev_config.h + * @brief Development mode configuration API + * + * Provides access to development mode configuration values. + * The actual values are defined in development_config.cpp which is git-ignored. + * + * This allows: + * - Cross-platform sharing of dev config (iOS, Android, Flutter) + * - Git-ignored secrets with template for developers + * - Consistent development environment across SDKs + * + * Security Model: + * - development_config.cpp is in .gitignore (not committed to main branch) + * - Real values are ONLY in release tags (for SPM/Maven distribution) + * - Used ONLY when SDK is in .development mode + * - Backend validates build token via POST /api/v1/devices/register/dev + */ + +#ifndef RAC_DEV_CONFIG_H +#define RAC_DEV_CONFIG_H + +#include + +#include "rac/core/rac_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// Development Configuration API +// ============================================================================= + +/** + * @brief Check if development config is available + * @return true if development config is properly configured + */ +RAC_API bool rac_dev_config_is_available(void); + +/** + * @brief Get Supabase project URL for development mode + * @return URL string (static, do not free) + */ +RAC_API const char* rac_dev_config_get_supabase_url(void); + +/** + * @brief Get Supabase anon key for development mode + * @return API key string (static, do not free) + */ +RAC_API const char* rac_dev_config_get_supabase_key(void); + +/** + * @brief Get build token for development mode + * @return Build token string (static, do not free) + */ +RAC_API const char* rac_dev_config_get_build_token(void); + +/** + * @brief Get Sentry DSN for crash reporting (optional) + * @return Sentry DSN string, or NULL if not configured + */ +RAC_API const char* rac_dev_config_get_sentry_dsn(void); + +// ============================================================================= +// Convenience Functions +// ============================================================================= + +/** + * @brief Check if Supabase config is valid + * @return true if URL and key are non-empty + */ +RAC_API bool rac_dev_config_has_supabase(void); + +/** + * @brief Check if build token is valid + * @return true if build token is non-empty + */ +RAC_API bool rac_dev_config_has_build_token(void); + +#ifdef __cplusplus +} +#endif + +#endif // RAC_DEV_CONFIG_H diff --git a/sdk/runanywhere-commons/include/rac/infrastructure/network/rac_endpoints.h b/sdk/runanywhere-commons/include/rac/infrastructure/network/rac_endpoints.h new file mode 100644 index 000000000..433a973b1 --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/infrastructure/network/rac_endpoints.h @@ -0,0 +1,88 @@ +/** + * @file rac_endpoints.h + * @brief API endpoint definitions + * + * Defines all API endpoint paths as constants. + * This is the canonical source of truth - platform SDKs should not duplicate these. + */ + +#ifndef RAC_ENDPOINTS_H +#define RAC_ENDPOINTS_H + +#include "rac/infrastructure/network/rac_environment.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// Authentication & Health Endpoints +// ============================================================================= + +#define RAC_ENDPOINT_AUTHENTICATE "/api/v1/auth/sdk/authenticate" +#define RAC_ENDPOINT_REFRESH "/api/v1/auth/sdk/refresh" +#define RAC_ENDPOINT_HEALTH "/v1/health" + +// ============================================================================= +// Device Management - Production/Staging +// ============================================================================= + +#define RAC_ENDPOINT_DEVICE_REGISTER "/api/v1/devices/register" +#define RAC_ENDPOINT_TELEMETRY "/api/v1/sdk/telemetry" + +// ============================================================================= +// Device Management - Development (Supabase REST API) +// ============================================================================= + +#define RAC_ENDPOINT_DEV_DEVICE_REGISTER "/rest/v1/sdk_devices" +#define RAC_ENDPOINT_DEV_TELEMETRY "/rest/v1/telemetry_events" + +// ============================================================================= +// Model Management +// ============================================================================= + +#define RAC_ENDPOINT_MODELS_AVAILABLE "/api/v1/models/available" + +// ============================================================================= +// Environment-Based Endpoint Selection +// ============================================================================= + +/** + * @brief Get device registration endpoint for environment + * @param env The environment + * @return Endpoint path string + */ +const char* rac_endpoint_device_registration(rac_environment_t env); + +/** + * @brief Get telemetry endpoint for environment + * @param env The environment + * @return Endpoint path string + */ +const char* rac_endpoint_telemetry(rac_environment_t env); + +/** + * @brief Get model assignments endpoint + * @return Endpoint path string + */ +const char* rac_endpoint_model_assignments(void); + +// ============================================================================= +// Full URL Building +// ============================================================================= + +/** + * @brief Build full URL from base URL and endpoint + * @param base_url The base URL (e.g., "https://api.runanywhere.ai") + * @param endpoint The endpoint path (e.g., "/api/v1/health") + * @param out_buffer Buffer to write full URL + * @param buffer_size Size of buffer + * @return Length of written string, or -1 on error + */ +int rac_build_url(const char* base_url, const char* endpoint, char* out_buffer, size_t buffer_size); + +#ifdef __cplusplus +} +#endif + +#endif // RAC_ENDPOINTS_H diff --git a/sdk/runanywhere-commons/include/rac/infrastructure/network/rac_environment.h b/sdk/runanywhere-commons/include/rac/infrastructure/network/rac_environment.h new file mode 100644 index 000000000..b08da1bb6 --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/infrastructure/network/rac_environment.h @@ -0,0 +1,220 @@ +/** + * @file rac_environment.h + * @brief SDK environment configuration + * + * Defines environment types (development, staging, production) and their + * associated settings like authentication requirements, log levels, etc. + * This is the canonical source of truth - platform SDKs create thin wrappers. + */ + +#ifndef RAC_ENVIRONMENT_H +#define RAC_ENVIRONMENT_H + +#include +#include + +#include "rac/core/rac_types.h" // For rac_log_level_t + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// Environment Types +// ============================================================================= + +/** + * @brief SDK environment mode + * + * - DEVELOPMENT: Local/testing mode, no auth required, uses Supabase + * - STAGING: Testing with real services, requires API key + URL + * - PRODUCTION: Live environment, requires API key + HTTPS URL + */ +typedef enum { + RAC_ENV_DEVELOPMENT = 0, + RAC_ENV_STAGING = 1, + RAC_ENV_PRODUCTION = 2 +} rac_environment_t; + +// Note: rac_log_level_t is defined in rac_types.h +// We use the existing definition for consistency + +// ============================================================================= +// SDK Configuration +// ============================================================================= + +/** + * @brief SDK initialization configuration + * + * Contains all parameters needed to initialize the SDK. + * Platform SDKs populate this from their native config types. + */ +typedef struct { + rac_environment_t environment; + const char* api_key; // Required for staging/production + const char* base_url; // Required for staging/production + const char* device_id; // Set by platform (Keychain UUID, etc.) + const char* platform; // "ios", "android", "flutter", etc. + const char* sdk_version; // SDK version string +} rac_sdk_config_t; + +/** + * @brief Development network configuration + * + * Contains Supabase credentials for development mode. + * These are built into the SDK binary. + */ +typedef struct { + const char* base_url; // Supabase project URL + const char* api_key; // Supabase anon key + const char* build_token; // SDK build token for validation +} rac_dev_config_t; + +// ============================================================================= +// Environment Query Functions +// ============================================================================= + +/** + * @brief Check if environment requires API authentication + * @param env The environment to check + * @return true for staging/production, false for development + */ +bool rac_env_requires_auth(rac_environment_t env); + +/** + * @brief Check if environment requires a backend URL + * @param env The environment to check + * @return true for staging/production, false for development + */ +bool rac_env_requires_backend_url(rac_environment_t env); + +/** + * @brief Check if environment is production + * @param env The environment to check + * @return true only for production + */ +bool rac_env_is_production(rac_environment_t env); + +/** + * @brief Check if environment is a testing environment + * @param env The environment to check + * @return true for development and staging + */ +bool rac_env_is_testing(rac_environment_t env); + +/** + * @brief Get the default log level for an environment + * @param env The environment + * @return DEBUG for development, INFO for staging, WARNING for production + */ +rac_log_level_t rac_env_default_log_level(rac_environment_t env); + +/** + * @brief Check if telemetry should be sent for this environment + * @param env The environment + * @return true only for production + */ +bool rac_env_should_send_telemetry(rac_environment_t env); + +/** + * @brief Check if environment should sync with backend + * @param env The environment + * @return true for staging/production, false for development + */ +bool rac_env_should_sync_with_backend(rac_environment_t env); + +/** + * @brief Get human-readable environment description + * @param env The environment + * @return String like "Development Environment" + */ +const char* rac_env_description(rac_environment_t env); + +// ============================================================================= +// Validation Functions +// ============================================================================= + +/** + * @brief Validation result codes + */ +typedef enum { + RAC_VALIDATION_OK = 0, + RAC_VALIDATION_API_KEY_REQUIRED, + RAC_VALIDATION_API_KEY_TOO_SHORT, + RAC_VALIDATION_URL_REQUIRED, + RAC_VALIDATION_URL_INVALID_SCHEME, + RAC_VALIDATION_URL_HTTPS_REQUIRED, + RAC_VALIDATION_URL_INVALID_HOST, + RAC_VALIDATION_URL_LOCALHOST_NOT_ALLOWED, + RAC_VALIDATION_PRODUCTION_DEBUG_BUILD +} rac_validation_result_t; + +/** + * @brief Validate API key for the given environment + * @param api_key The API key to validate (can be NULL) + * @param env The target environment + * @return RAC_VALIDATION_OK if valid, error code otherwise + */ +rac_validation_result_t rac_validate_api_key(const char* api_key, rac_environment_t env); + +/** + * @brief Validate base URL for the given environment + * @param url The URL to validate (can be NULL) + * @param env The target environment + * @return RAC_VALIDATION_OK if valid, error code otherwise + */ +rac_validation_result_t rac_validate_base_url(const char* url, rac_environment_t env); + +/** + * @brief Validate complete SDK configuration + * @param config The configuration to validate + * @return RAC_VALIDATION_OK if valid, first error code otherwise + */ +rac_validation_result_t rac_validate_config(const rac_sdk_config_t* config); + +/** + * @brief Get error message for validation result + * @param result The validation result code + * @return Human-readable error message + */ +const char* rac_validation_error_message(rac_validation_result_t result); + +// ============================================================================= +// Global SDK State +// ============================================================================= + +/** + * @brief Initialize SDK with configuration + * @param config The SDK configuration + * @return RAC_VALIDATION_OK on success, error code on validation failure + */ +RAC_API rac_validation_result_t rac_sdk_init(const rac_sdk_config_t* config); + +/** + * @brief Get current SDK configuration + * @return Pointer to current config, or NULL if not initialized + */ +RAC_API const rac_sdk_config_t* rac_sdk_get_config(void); + +/** + * @brief Get current environment + * @return Current environment, or RAC_ENV_DEVELOPMENT if not initialized + */ +RAC_API rac_environment_t rac_sdk_get_environment(void); + +/** + * @brief Check if SDK is initialized + * @return true if rac_sdk_init has been called successfully + */ +RAC_API bool rac_sdk_is_initialized(void); + +/** + * @brief Reset SDK state (for testing) + */ +RAC_API void rac_sdk_reset(void); + +#ifdef __cplusplus +} +#endif + +#endif // RAC_ENVIRONMENT_H diff --git a/sdk/runanywhere-commons/include/rac/infrastructure/network/rac_http_client.h b/sdk/runanywhere-commons/include/rac/infrastructure/network/rac_http_client.h new file mode 100644 index 000000000..c93c303ce --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/infrastructure/network/rac_http_client.h @@ -0,0 +1,233 @@ +/** + * @file rac_http_client.h + * @brief HTTP client abstraction + * + * Defines a platform-agnostic HTTP interface. Platform SDKs implement + * the actual HTTP transport (URLSession, OkHttp, etc.) and register + * it via callback. + */ + +#ifndef RAC_HTTP_CLIENT_H +#define RAC_HTTP_CLIENT_H + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// HTTP Types +// ============================================================================= + +/** + * @brief HTTP method enum + */ +typedef enum { + RAC_HTTP_GET = 0, + RAC_HTTP_POST = 1, + RAC_HTTP_PUT = 2, + RAC_HTTP_DELETE = 3, + RAC_HTTP_PATCH = 4 +} rac_http_method_t; + +/** + * @brief HTTP header key-value pair + */ +typedef struct { + const char* key; + const char* value; +} rac_http_header_t; + +/** + * @brief HTTP request structure + */ +typedef struct { + rac_http_method_t method; + const char* url; // Full URL + const char* body; // JSON body (can be NULL for GET) + size_t body_length; + rac_http_header_t* headers; + size_t header_count; + int32_t timeout_ms; // Request timeout in milliseconds +} rac_http_request_t; + +/** + * @brief HTTP response structure + */ +typedef struct { + int32_t status_code; // HTTP status code (200, 401, etc.) + char* body; // Response body (caller frees) + size_t body_length; + rac_http_header_t* headers; + size_t header_count; + char* error_message; // Non-HTTP error (network failure, etc.) +} rac_http_response_t; + +// ============================================================================= +// Response Memory Management +// ============================================================================= + +/** + * @brief Free HTTP response + */ +void rac_http_response_free(rac_http_response_t* response); + +// ============================================================================= +// Platform Callback Interface +// ============================================================================= + +/** + * @brief Callback type for receiving HTTP response + * + * @param response The HTTP response (platform must free after callback returns) + * @param user_data Opaque user data passed to request + */ +typedef void (*rac_http_callback_t)(const rac_http_response_t* response, void* user_data); + +/** + * @brief HTTP executor function type + * + * Platform implements this to perform actual HTTP requests. + * Must call callback when request completes (success or failure). + * + * @param request The HTTP request to execute + * @param callback Callback to invoke with response + * @param user_data Opaque user data to pass to callback + */ +typedef void (*rac_http_executor_t)(const rac_http_request_t* request, rac_http_callback_t callback, + void* user_data); + +/** + * @brief Register platform HTTP executor + * + * Platform SDKs must call this during initialization to provide + * their HTTP implementation. + * + * @param executor The executor function + */ +void rac_http_set_executor(rac_http_executor_t executor); + +/** + * @brief Check if HTTP executor is registered + * @return true if executor has been set + */ +bool rac_http_has_executor(void); + +// ============================================================================= +// Request Building Helpers +// ============================================================================= + +/** + * @brief Create a new HTTP request + * @param method HTTP method + * @param url Full URL + * @return New request (caller must free with rac_http_request_free) + */ +rac_http_request_t* rac_http_request_create(rac_http_method_t method, const char* url); + +/** + * @brief Set request body + * @param request The request + * @param body JSON body string + */ +void rac_http_request_set_body(rac_http_request_t* request, const char* body); + +/** + * @brief Add header to request + * @param request The request + * @param key Header key + * @param value Header value + */ +void rac_http_request_add_header(rac_http_request_t* request, const char* key, const char* value); + +/** + * @brief Set request timeout + * @param request The request + * @param timeout_ms Timeout in milliseconds + */ +void rac_http_request_set_timeout(rac_http_request_t* request, int32_t timeout_ms); + +/** + * @brief Free HTTP request + */ +void rac_http_request_free(rac_http_request_t* request); + +// ============================================================================= +// Standard Headers +// ============================================================================= + +/** + * @brief Add standard SDK headers to request + * + * Adds: Content-Type, X-SDK-Client, X-SDK-Version, X-Platform + * + * @param request The request + * @param sdk_version SDK version string + * @param platform Platform string + */ +void rac_http_add_sdk_headers(rac_http_request_t* request, const char* sdk_version, + const char* platform); + +/** + * @brief Add authorization header + * @param request The request + * @param token Bearer token + */ +void rac_http_add_auth_header(rac_http_request_t* request, const char* token); + +/** + * @brief Add API key header (for Supabase compatibility) + * @param request The request + * @param api_key API key + */ +void rac_http_add_api_key_header(rac_http_request_t* request, const char* api_key); + +// ============================================================================= +// High-Level Request Functions +// ============================================================================= + +/** + * @brief Context for async HTTP operations + */ +typedef struct { + void* user_data; + void (*on_success)(const char* response_body, void* user_data); + void (*on_error)(int status_code, const char* error_message, void* user_data); +} rac_http_context_t; + +/** + * @brief Execute HTTP request asynchronously + * + * Uses the registered platform executor. + * + * @param request The request to execute + * @param context Callback context + */ +void rac_http_execute(const rac_http_request_t* request, rac_http_context_t* context); + +/** + * @brief Helper: POST JSON to endpoint + * @param url Full URL + * @param json_body JSON body + * @param auth_token Bearer token (can be NULL) + * @param context Callback context + */ +void rac_http_post_json(const char* url, const char* json_body, const char* auth_token, + rac_http_context_t* context); + +/** + * @brief Helper: GET from endpoint + * @param url Full URL + * @param auth_token Bearer token (can be NULL) + * @param context Callback context + */ +void rac_http_get(const char* url, const char* auth_token, rac_http_context_t* context); + +#ifdef __cplusplus +} +#endif + +#endif // RAC_HTTP_CLIENT_H diff --git a/sdk/runanywhere-commons/include/rac/infrastructure/storage/rac_storage_analyzer.h b/sdk/runanywhere-commons/include/rac/infrastructure/storage/rac_storage_analyzer.h new file mode 100644 index 000000000..94bc05464 --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/infrastructure/storage/rac_storage_analyzer.h @@ -0,0 +1,286 @@ +/** + * @file rac_storage_analyzer.h + * @brief Storage Analyzer - Centralized Storage Analysis Logic + * + * Business logic for storage analysis lives here in C++. + * Platform-specific file operations are provided via callbacks. + * + * Storage structure: `{base_dir}/RunAnywhere/Models/{framework}/{modelId}/` + */ + +#ifndef RAC_STORAGE_ANALYZER_H +#define RAC_STORAGE_ANALYZER_H + +#include +#include + +#include "rac/core/rac_error.h" +#include "rac/core/rac_types.h" +#include "rac/infrastructure/model_management/rac_model_registry.h" +#include "rac/infrastructure/model_management/rac_model_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// DATA STRUCTURES +// ============================================================================= + +/** + * @brief Storage metrics for a single model + */ +typedef struct { + /** Model ID */ + const char* model_id; + + /** Model name */ + const char* model_name; + + /** Inference framework */ + rac_inference_framework_t framework; + + /** Local path to model */ + const char* local_path; + + /** Actual size on disk in bytes */ + int64_t size_on_disk; + + /** Model format */ + rac_model_format_t format; + + /** Artifact type info */ + rac_model_artifact_info_t artifact_info; +} rac_model_storage_metrics_t; + +/** + * @brief Device storage information + */ +typedef struct { + /** Total device storage in bytes */ + int64_t total_space; + + /** Free space in bytes */ + int64_t free_space; + + /** Used space in bytes */ + int64_t used_space; +} rac_device_storage_t; + +/** + * @brief App storage information + */ +typedef struct { + /** Documents directory size in bytes */ + int64_t documents_size; + + /** Cache directory size in bytes */ + int64_t cache_size; + + /** App support directory size in bytes */ + int64_t app_support_size; + + /** Total app storage */ + int64_t total_size; +} rac_app_storage_t; + +/** + * @brief Storage availability result + */ +typedef struct { + /** Whether storage is available */ + rac_bool_t is_available; + + /** Required space in bytes */ + int64_t required_space; + + /** Available space in bytes */ + int64_t available_space; + + /** Whether there's a warning (low space) */ + rac_bool_t has_warning; + + /** Recommendation message (may be NULL) */ + const char* recommendation; +} rac_storage_availability_t; + +/** + * @brief Overall storage info + */ +typedef struct { + /** App storage */ + rac_app_storage_t app_storage; + + /** Device storage */ + rac_device_storage_t device_storage; + + /** Array of model storage metrics */ + rac_model_storage_metrics_t* models; + + /** Number of models */ + size_t model_count; + + /** Total size of all models */ + int64_t total_models_size; +} rac_storage_info_t; + +// ============================================================================= +// PLATFORM CALLBACKS - Swift/Kotlin implements these +// ============================================================================= + +/** + * @brief Callback to calculate directory size + * @param path Directory path + * @param user_data User context + * @return Size in bytes + */ +typedef int64_t (*rac_calculate_dir_size_fn)(const char* path, void* user_data); + +/** + * @brief Callback to get file size + * @param path File path + * @param user_data User context + * @return Size in bytes, or -1 if not found + */ +typedef int64_t (*rac_get_file_size_fn)(const char* path, void* user_data); + +/** + * @brief Callback to check if path exists + * @param path Path to check + * @param is_directory Output: true if directory + * @param user_data User context + * @return true if exists + */ +typedef rac_bool_t (*rac_path_exists_fn)(const char* path, rac_bool_t* is_directory, + void* user_data); + +/** + * @brief Callback to get available disk space + * @param user_data User context + * @return Available space in bytes + */ +typedef int64_t (*rac_get_available_space_fn)(void* user_data); + +/** + * @brief Callback to get total disk space + * @param user_data User context + * @return Total space in bytes + */ +typedef int64_t (*rac_get_total_space_fn)(void* user_data); + +/** + * @brief Platform callbacks for file operations + */ +typedef struct { + rac_calculate_dir_size_fn calculate_dir_size; + rac_get_file_size_fn get_file_size; + rac_path_exists_fn path_exists; + rac_get_available_space_fn get_available_space; + rac_get_total_space_fn get_total_space; + void* user_data; +} rac_storage_callbacks_t; + +// ============================================================================= +// STORAGE ANALYZER API +// ============================================================================= + +/** Opaque handle to storage analyzer */ +typedef struct rac_storage_analyzer* rac_storage_analyzer_handle_t; + +/** + * @brief Create a storage analyzer with platform callbacks + * + * @param callbacks Platform-specific file operation callbacks + * @param out_handle Output: Created analyzer handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_storage_analyzer_create(const rac_storage_callbacks_t* callbacks, + rac_storage_analyzer_handle_t* out_handle); + +/** + * @brief Destroy a storage analyzer + * + * @param handle Analyzer handle to destroy + */ +RAC_API void rac_storage_analyzer_destroy(rac_storage_analyzer_handle_t handle); + +/** + * @brief Analyze overall storage + * + * Business logic in C++: + * - Gets models from rac_model_registry + * - Calculates paths via rac_model_paths + * - Calls platform callbacks for sizes + * - Aggregates results + * + * @param handle Analyzer handle + * @param registry_handle Model registry handle + * @param out_info Output: Storage info (caller must call rac_storage_info_free) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_storage_analyzer_analyze(rac_storage_analyzer_handle_t handle, + rac_model_registry_handle_t registry_handle, + rac_storage_info_t* out_info); + +/** + * @brief Get storage metrics for a specific model + * + * @param handle Analyzer handle + * @param registry_handle Model registry handle + * @param model_id Model identifier + * @param framework Inference framework + * @param out_metrics Output: Model metrics + * @return RAC_SUCCESS or RAC_ERROR_NOT_FOUND + */ +RAC_API rac_result_t rac_storage_analyzer_get_model_metrics( + rac_storage_analyzer_handle_t handle, rac_model_registry_handle_t registry_handle, + const char* model_id, rac_inference_framework_t framework, + rac_model_storage_metrics_t* out_metrics); + +/** + * @brief Check if storage is available for a download + * + * @param handle Analyzer handle + * @param model_size Size of model to download + * @param safety_margin Safety margin (e.g., 0.1 for 10% extra) + * @param out_availability Output: Availability result + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_storage_analyzer_check_available( + rac_storage_analyzer_handle_t handle, int64_t model_size, double safety_margin, + rac_storage_availability_t* out_availability); + +/** + * @brief Calculate size at a path (file or directory) + * + * @param handle Analyzer handle + * @param path Path to calculate size for + * @param out_size Output: Size in bytes + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_storage_analyzer_calculate_size(rac_storage_analyzer_handle_t handle, + const char* path, int64_t* out_size); + +// ============================================================================= +// CLEANUP +// ============================================================================= + +/** + * @brief Free storage info returned by rac_storage_analyzer_analyze + * + * @param info Storage info to free + */ +RAC_API void rac_storage_info_free(rac_storage_info_t* info); + +/** + * @brief Free storage availability result + * + * @param availability Availability result to free + */ +RAC_API void rac_storage_availability_free(rac_storage_availability_t* availability); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_STORAGE_ANALYZER_H */ diff --git a/sdk/runanywhere-commons/include/rac/infrastructure/telemetry/rac_telemetry_manager.h b/sdk/runanywhere-commons/include/rac/infrastructure/telemetry/rac_telemetry_manager.h new file mode 100644 index 000000000..ab606695c --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/infrastructure/telemetry/rac_telemetry_manager.h @@ -0,0 +1,206 @@ +/** + * @file rac_telemetry_manager.h + * @brief Telemetry manager - handles event queuing, batching, and serialization + * + * C++ handles all telemetry logic: + * - Convert analytics events to telemetry payloads + * - Queue and batch events + * - Group by modality for production + * - Serialize to JSON (environment-aware) + * - Callback to platform SDK for HTTP calls + * + * Platform SDKs only need to: + * - Provide device info + * - Make HTTP calls when callback is invoked + */ + +#ifndef RAC_TELEMETRY_MANAGER_H +#define RAC_TELEMETRY_MANAGER_H + +#include "rac/core/rac_analytics_events.h" +#include "rac/core/rac_types.h" +#include "rac/infrastructure/network/rac_environment.h" +#include "rac/infrastructure/telemetry/rac_telemetry_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// TELEMETRY MANAGER +// ============================================================================= + +/** + * @brief Opaque telemetry manager handle + */ +typedef struct rac_telemetry_manager rac_telemetry_manager_t; + +/** + * @brief HTTP request callback from C++ to platform SDK + * + * C++ builds the JSON and determines the endpoint. + * Platform SDK just makes the HTTP call. + * + * @param user_data User data provided at registration + * @param endpoint The API endpoint path (e.g., "/api/v1/sdk/telemetry") + * @param json_body The JSON request body (null-terminated string) + * @param json_length Length of JSON body + * @param requires_auth Whether request needs authentication + */ +typedef void (*rac_telemetry_http_callback_t)(void* user_data, const char* endpoint, + const char* json_body, size_t json_length, + rac_bool_t requires_auth); + +/** + * @brief HTTP response callback from platform SDK to C++ + * + * Platform SDK calls this after HTTP completes. + * + * @param manager The telemetry manager + * @param success Whether HTTP call succeeded + * @param response_json Response JSON (can be NULL on failure) + * @param error_message Error message if failed (can be NULL) + */ +RAC_API void rac_telemetry_manager_http_complete(rac_telemetry_manager_t* manager, + rac_bool_t success, const char* response_json, + const char* error_message); + +// ============================================================================= +// LIFECYCLE +// ============================================================================= + +/** + * @brief Create telemetry manager + * + * @param env SDK environment (determines endpoint and encoding) + * @param device_id Persistent device UUID (from Keychain) + * @param platform Platform string ("ios", "android", etc.) + * @param sdk_version SDK version string + * @return Manager handle or NULL on failure + */ +RAC_API rac_telemetry_manager_t* rac_telemetry_manager_create(rac_environment_t env, + const char* device_id, + const char* platform, + const char* sdk_version); + +/** + * @brief Destroy telemetry manager + */ +RAC_API void rac_telemetry_manager_destroy(rac_telemetry_manager_t* manager); + +/** + * @brief Set device info for telemetry payloads + * + * Call this after creating the manager to set device details. + */ +RAC_API void rac_telemetry_manager_set_device_info(rac_telemetry_manager_t* manager, + const char* device_model, + const char* os_version); + +/** + * @brief Register HTTP callback + * + * Platform SDK must register this to receive HTTP requests. + */ +RAC_API void rac_telemetry_manager_set_http_callback(rac_telemetry_manager_t* manager, + rac_telemetry_http_callback_t callback, + void* user_data); + +// ============================================================================= +// EVENT TRACKING +// ============================================================================= + +/** + * @brief Track a telemetry payload directly + * + * Queues the payload for batching and sending. + */ +RAC_API rac_result_t rac_telemetry_manager_track(rac_telemetry_manager_t* manager, + const rac_telemetry_payload_t* payload); + +/** + * @brief Track from analytics event data + * + * Converts analytics event to telemetry payload and queues it. + */ +RAC_API rac_result_t rac_telemetry_manager_track_analytics(rac_telemetry_manager_t* manager, + rac_event_type_t event_type, + const rac_analytics_event_data_t* data); + +/** + * @brief Flush queued events immediately + * + * Sends all queued events to the backend. + */ +RAC_API rac_result_t rac_telemetry_manager_flush(rac_telemetry_manager_t* manager); + +// ============================================================================= +// JSON SERIALIZATION +// ============================================================================= + +/** + * @brief Serialize telemetry payload to JSON + * + * @param payload The payload to serialize + * @param env Environment (affects field names and which fields to include) + * @param out_json Output: JSON string (caller must free with rac_free) + * @param out_length Output: Length of JSON string + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_telemetry_manager_payload_to_json(const rac_telemetry_payload_t* payload, + rac_environment_t env, char** out_json, + size_t* out_length); + +/** + * @brief Serialize batch request to JSON + * + * @param request The batch request + * @param env Environment + * @param out_json Output: JSON string (caller must free with rac_free) + * @param out_length Output: Length of JSON string + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t +rac_telemetry_manager_batch_to_json(const rac_telemetry_batch_request_t* request, + rac_environment_t env, char** out_json, size_t* out_length); + +/** + * @brief Parse batch response from JSON + * + * @param json JSON response string + * @param out_response Output: Parsed response (caller must free) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_telemetry_manager_parse_response( + const char* json, rac_telemetry_batch_response_t* out_response); + +// ============================================================================= +// DEVICE REGISTRATION +// ============================================================================= + +/** + * @brief Serialize device registration request to JSON + * + * @param request The registration request + * @param env Environment + * @param out_json Output: JSON string (caller must free with rac_free) + * @param out_length Output: Length of JSON string + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t +rac_device_registration_to_json(const rac_device_registration_request_t* request, + rac_environment_t env, char** out_json, size_t* out_length); + +/** + * @brief Get device registration endpoint for environment + * + * @param env Environment + * @return Endpoint path string (static, do not free) + */ +RAC_API const char* rac_device_registration_endpoint(rac_environment_t env); + +#ifdef __cplusplus +} +#endif + +#endif // RAC_TELEMETRY_MANAGER_H diff --git a/sdk/runanywhere-commons/include/rac/infrastructure/telemetry/rac_telemetry_types.h b/sdk/runanywhere-commons/include/rac/infrastructure/telemetry/rac_telemetry_types.h new file mode 100644 index 000000000..a48dc9694 --- /dev/null +++ b/sdk/runanywhere-commons/include/rac/infrastructure/telemetry/rac_telemetry_types.h @@ -0,0 +1,234 @@ +/** + * @file rac_telemetry_types.h + * @brief Telemetry data structures - canonical source of truth + * + * All telemetry payloads are defined here. Platform SDKs (Swift, Kotlin, Flutter) + * use these types directly or create thin wrappers. + * + * Mirrors Swift's TelemetryEventPayload.swift structure. + */ + +#ifndef RAC_TELEMETRY_TYPES_H +#define RAC_TELEMETRY_TYPES_H + +#include "rac/core/rac_types.h" +#include "rac/infrastructure/network/rac_environment.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// TELEMETRY EVENT PAYLOAD +// ============================================================================= + +/** + * @brief Complete telemetry event payload + * + * Maps to backend SDKTelemetryEvent schema with all fields for: + * - LLM events (tokens, generation times, etc.) + * - STT events (audio duration, word count, etc.) + * - TTS events (character count, audio size, etc.) + * - VAD events (speech duration) + * - Model lifecycle events (size, archive type) + * - SDK lifecycle events (count) + * - Storage events (freed bytes) + * - Network events (online status) + */ +typedef struct rac_telemetry_payload { + // Required fields + const char* id; // Unique event ID (UUID) + const char* event_type; // Event type string + int64_t timestamp_ms; // Unix timestamp in milliseconds + int64_t created_at_ms; // When payload was created + + // Event classification + const char* modality; // "llm", "stt", "tts", "model", "system" + + // Device identification + const char* device_id; // Persistent device UUID + const char* session_id; // Optional session ID + + // Model info + const char* model_id; + const char* model_name; + const char* framework; // "llamacpp", "onnx", "mlx", etc. + + // Device info + const char* device; // Device model (e.g., "iPhone15,2") + const char* os_version; // OS version (e.g., "17.0") + const char* platform; // "ios", "android", "flutter" + const char* sdk_version; // SDK version string + + // Common performance metrics + double processing_time_ms; + rac_bool_t success; + rac_bool_t has_success; // Whether success field is set + const char* error_message; + const char* error_code; + + // LLM-specific fields + int32_t input_tokens; + int32_t output_tokens; + int32_t total_tokens; + double tokens_per_second; + double time_to_first_token_ms; + double prompt_eval_time_ms; + double generation_time_ms; + int32_t context_length; + double temperature; + int32_t max_tokens; + + // STT-specific fields + double audio_duration_ms; + double real_time_factor; + int32_t word_count; + double confidence; + const char* language; + rac_bool_t is_streaming; + rac_bool_t has_is_streaming; + int32_t segment_index; + + // TTS-specific fields + int32_t character_count; + double characters_per_second; + int32_t audio_size_bytes; + int32_t sample_rate; + const char* voice; + double output_duration_ms; + + // Model lifecycle fields + int64_t model_size_bytes; + const char* archive_type; + + // VAD fields + double speech_duration_ms; + + // SDK lifecycle fields + int32_t count; + + // Storage fields + int64_t freed_bytes; + + // Network fields + rac_bool_t is_online; + rac_bool_t has_is_online; +} rac_telemetry_payload_t; + +/** + * @brief Default/empty telemetry payload + */ +RAC_API rac_telemetry_payload_t rac_telemetry_payload_default(void); + +/** + * @brief Free any allocated strings in a telemetry payload + */ +RAC_API void rac_telemetry_payload_free(rac_telemetry_payload_t* payload); + +// ============================================================================= +// TELEMETRY BATCH REQUEST +// ============================================================================= + +/** + * @brief Batch telemetry request for API + * + * Supports both V1 and V2 storage paths: + * - V1 (legacy): modality = NULL → stores in sdk_telemetry_events table + * - V2 (normalized): modality = "llm"/"stt"/"tts"/"model" → normalized tables + */ +typedef struct rac_telemetry_batch_request { + rac_telemetry_payload_t* events; + size_t events_count; + const char* device_id; + int64_t timestamp_ms; + const char* modality; // NULL for V1, "llm"/"stt"/"tts"/"model" for V2 +} rac_telemetry_batch_request_t; + +/** + * @brief Batch telemetry response from API + */ +typedef struct rac_telemetry_batch_response { + rac_bool_t success; + int32_t events_received; + int32_t events_stored; + int32_t events_skipped; // Duplicates skipped + const char** errors; // Array of error messages + size_t errors_count; + const char* storage_version; // "V1" or "V2" +} rac_telemetry_batch_response_t; + +/** + * @brief Free batch response + */ +RAC_API void rac_telemetry_batch_response_free(rac_telemetry_batch_response_t* response); + +// ============================================================================= +// DEVICE REGISTRATION TYPES +// ============================================================================= + +/** + * @brief Device information for registration (telemetry-specific) + * + * Platform-specific values are passed in from Swift/Kotlin. + * Matches backend schemas/device.py DeviceInfo schema. + * Note: Named differently from rac_device_info_t to avoid conflict. + */ +typedef struct rac_device_registration_info { + // Required fields (backend schema) + const char* device_id; // Persistent UUID from Keychain/secure storage + const char* device_model; // "iPhone 16 Pro Max", "Pixel 7", etc. + const char* device_name; // User-assigned device name + const char* platform; // "ios", "android" + const char* os_version; // "17.0", "14" + const char* form_factor; // "phone", "tablet", "laptop", etc. + const char* architecture; // "arm64", "x86_64", etc. + const char* chip_name; // "A18 Pro", "Snapdragon 888", etc. + int64_t total_memory; // Total RAM in bytes + int64_t available_memory; // Available RAM in bytes + rac_bool_t has_neural_engine; // true if device has Neural Engine / NPU + int32_t neural_engine_cores; // Number of Neural Engine cores (0 if none) + const char* gpu_family; // "apple", "adreno", etc. + double battery_level; // 0.0-1.0, negative if unavailable + const char* battery_state; // "charging", "full", "unplugged", NULL if unavailable + rac_bool_t is_low_power_mode; // Low power mode enabled + int32_t core_count; // Total CPU cores + int32_t performance_cores; // Performance (P) cores + int32_t efficiency_cores; // Efficiency (E) cores + const char* device_fingerprint; // Unique device fingerprint (may be same as device_id) + + // Legacy fields (for backward compatibility) + const char* device_type; // "smartphone", "tablet", etc. (deprecated - use form_factor) + const char* os_name; // "iOS", "Android" (deprecated - use platform) + int64_t total_disk_bytes; + int64_t available_disk_bytes; + const char* processor_info; + int32_t processor_count; // Deprecated - use core_count + rac_bool_t is_simulator; + const char* locale; + const char* timezone; +} rac_device_registration_info_t; + +/** + * @brief Device registration request + */ +typedef struct rac_device_registration_request { + rac_device_registration_info_t device_info; + const char* sdk_version; + const char* build_token; // For development mode + int64_t last_seen_at_ms; +} rac_device_registration_request_t; + +/** + * @brief Device registration response + */ +typedef struct rac_device_registration_response { + const char* device_id; + const char* status; // "registered" or "updated" + const char* sync_status; // "synced" or "pending" +} rac_device_registration_response_t; + +#ifdef __cplusplus +} +#endif + +#endif // RAC_TELEMETRY_TYPES_H diff --git a/sdk/runanywhere-commons/scripts/android/download-sherpa-onnx.sh b/sdk/runanywhere-commons/scripts/android/download-sherpa-onnx.sh new file mode 100755 index 000000000..670464baf --- /dev/null +++ b/sdk/runanywhere-commons/scripts/android/download-sherpa-onnx.sh @@ -0,0 +1,308 @@ +#!/bin/bash +# ============================================================================= +# download-sherpa-onnx.sh +# Download Sherpa-ONNX Android native libraries +# +# Sherpa-ONNX provides pre-built Android AAR/native libraries. +# This script downloads them for STT, TTS, and VAD support. +# +# 16KB Page Size Alignment (Google Play requirement) +# -------------------------------------------------- +# Starting November 1, 2025, Google Play requires all apps targeting +# Android 15+ (API 35+) to have 16KB-aligned native libraries. +# +# ✅ Sherpa-ONNX v1.12.20+ pre-built binaries ARE 16KB aligned! +# (Fixed in https://github.com/k2-fsa/sherpa-onnx/pull/2520) +# +# Usage: +# ./download-sherpa-onnx.sh # Download pre-built (16KB aligned) +# ./download-sherpa-onnx.sh --check # Verify library alignment +# ============================================================================= + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)" +SHERPA_DIR="${ROOT_DIR}/third_party/sherpa-onnx-android" + +# Load versions from centralized VERSIONS file (SINGLE SOURCE OF TRUTH) +source "${SCRIPT_DIR}/../load-versions.sh" + +# Use version from VERSIONS file - no hardcoded fallbacks +if [ -z "${SHERPA_ONNX_VERSION_ANDROID:-}" ]; then + echo "ERROR: SHERPA_ONNX_VERSION_ANDROID not loaded from VERSIONS file" >&2 + exit 1 +fi +SHERPA_VERSION="${SHERPA_ONNX_VERSION_ANDROID}" +# Official Sherpa-ONNX Android release +DOWNLOAD_URL="https://github.com/k2-fsa/sherpa-onnx/releases/download/v${SHERPA_VERSION}/sherpa-onnx-v${SHERPA_VERSION}-android.tar.bz2" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +NC='\033[0m' + +# Parse arguments +CHECK_ONLY=false + +for arg in "$@"; do + case $arg in + --check) + CHECK_ONLY=true + ;; + --help|-h) + echo "Usage: $0 [options]" + echo "" + echo "Options:" + echo " --check Verify alignment of existing libraries" + echo " --help Show this help message" + echo "" + echo "Note: Sherpa-ONNX v1.12.20+ pre-built binaries ARE 16KB aligned." + exit 0 + ;; + esac +done + +# Function to check 16KB alignment +check_alignment() { + local so_file="$1" + local filename=$(basename "$so_file") + + # Find readelf + local READELF="" + if command -v llvm-readelf &> /dev/null; then + READELF="llvm-readelf" + elif [ -d "$HOME/Library/Android/sdk/ndk" ]; then + NDK_PATH=$(ls -d "$HOME/Library/Android/sdk/ndk"/*/ 2>/dev/null | sort -V | tail -1) + if [ -f "$NDK_PATH/toolchains/llvm/prebuilt/darwin-x86_64/bin/llvm-readelf" ]; then + READELF="$NDK_PATH/toolchains/llvm/prebuilt/darwin-x86_64/bin/llvm-readelf" + fi + fi + + if [ -z "$READELF" ]; then + echo "unknown" + return + fi + + local LOAD_OUTPUT=$("$READELF" -l "$so_file" 2>/dev/null | grep "LOAD" || true) + + local HAS_4KB=false + local HAS_16KB=false + + while IFS= read -r line; do + local ALIGN_VAL=$(echo "$line" | grep -oE '0x[0-9a-fA-F]+' | tail -1) + case "$ALIGN_VAL" in + 0x1000|0x001000) + HAS_4KB=true + ;; + 0x4000|0x004000) + HAS_16KB=true + ;; + esac + done <<< "$LOAD_OUTPUT" + + if [ "$HAS_4KB" = true ] && [ "$HAS_16KB" = false ]; then + echo "4KB" + elif [ "$HAS_16KB" = true ]; then + echo "16KB" + else + echo "unknown" + fi +} + +# Check alignment of existing libraries +if [ "$CHECK_ONLY" = true ]; then + echo -e "${BLUE}=======================================${NC}" + echo -e "${BLUE}Checking Sherpa-ONNX Library Alignment${NC}" + echo -e "${BLUE}=======================================${NC}" + echo "" + + if [ ! -d "${SHERPA_DIR}/jniLibs" ]; then + echo -e "${RED}No libraries found at ${SHERPA_DIR}/jniLibs${NC}" + exit 1 + fi + + ALL_16KB=true + for so_file in "${SHERPA_DIR}/jniLibs"/*/*.so; do + if [ -f "$so_file" ]; then + alignment=$(check_alignment "$so_file") + filename=$(basename "$so_file") + abi=$(basename $(dirname "$so_file")) + + if [ "$alignment" = "16KB" ]; then + echo -e "${GREEN}✅ $abi/$filename - 16KB aligned${NC}" + elif [ "$alignment" = "4KB" ]; then + echo -e "${RED}❌ $abi/$filename - 4KB aligned (NOT Play Store ready)${NC}" + ALL_16KB=false + else + echo -e "${YELLOW}⚠️ $abi/$filename - Unknown alignment${NC}" + fi + fi + done + + echo "" + if [ "$ALL_16KB" = true ]; then + echo -e "${GREEN}All libraries are 16KB aligned - Play Store ready!${NC}" + else + echo -e "${RED}Some libraries are NOT 16KB aligned.${NC}" + echo -e "${RED}Please re-download with: rm -rf ${SHERPA_DIR} && $0${NC}" + fi + exit 0 +fi + +# Default: Download pre-built libraries +echo -e "${BLUE}=======================================${NC}" +echo -e "${BLUE}📦 Sherpa-ONNX Android Downloader${NC}" +echo -e "${BLUE}=======================================${NC}" +echo "" +echo "Version: ${SHERPA_VERSION}" +echo -e "${GREEN}✅ Pre-built libraries are 16KB aligned (Play Store ready)${NC}" +echo "" + +# Check if already exists +if [ -d "${SHERPA_DIR}/jniLibs" ]; then + if [ -f "${SHERPA_DIR}/jniLibs/arm64-v8a/libsherpa-onnx-jni.so" ]; then + echo "✅ Sherpa-ONNX Android libraries already exist" + echo " Location: ${SHERPA_DIR}" + echo "" + echo "To force re-download, remove the directory first:" + echo " rm -rf ${SHERPA_DIR}" + exit 0 + else + echo "⚠️ Existing directory appears incomplete, re-downloading..." + rm -rf "${SHERPA_DIR}" + fi +fi + +# Create temp directory for download +TEMP_DIR=$(mktemp -d) +TEMP_ARCHIVE="${TEMP_DIR}/sherpa-onnx-android.tar.bz2" + +echo "" +echo "Downloading from ${DOWNLOAD_URL}..." + +# Download +HTTP_CODE=$(curl -L -w "%{http_code}" -o "${TEMP_ARCHIVE}" "${DOWNLOAD_URL}" 2>/dev/null) || true + +if [ "${HTTP_CODE}" = "200" ] && [ -f "${TEMP_ARCHIVE}" ] && [ -s "${TEMP_ARCHIVE}" ]; then + echo "Download complete. Size: $(du -h "${TEMP_ARCHIVE}" | cut -f1)" + + # Extract + echo "Extracting..." + mkdir -p "${SHERPA_DIR}" + tar -xjf "${TEMP_ARCHIVE}" -C "${TEMP_DIR}" + + # Find the extracted directory - check multiple possible structures + EXTRACTED_DIR=$(find "${TEMP_DIR}" -maxdepth 1 -type d -name "sherpa-onnx-*-android" | head -1) + if [ -z "${EXTRACTED_DIR}" ]; then + EXTRACTED_DIR=$(find "${TEMP_DIR}" -maxdepth 1 -type d -name "build-android*" | head -1) + fi + + # Copy JNI libraries - handle different extraction structures + if [ -n "${EXTRACTED_DIR}" ] && [ -d "${EXTRACTED_DIR}/jniLibs" ]; then + cp -R "${EXTRACTED_DIR}/jniLibs" "${SHERPA_DIR}/" + elif [ -n "${EXTRACTED_DIR}" ] && [ -d "${EXTRACTED_DIR}/lib" ]; then + mkdir -p "${SHERPA_DIR}/jniLibs" + # Copy each ABI directory + for abi_dir in "${EXTRACTED_DIR}/lib"/*; do + if [ -d "$abi_dir" ]; then + abi_name=$(basename "$abi_dir") + mkdir -p "${SHERPA_DIR}/jniLibs/${abi_name}" + cp "${abi_dir}"/*.so "${SHERPA_DIR}/jniLibs/${abi_name}/" 2>/dev/null || true + fi + done + elif [ -d "${TEMP_DIR}/jniLibs" ]; then + # jniLibs extracted directly to temp dir + cp -R "${TEMP_DIR}/jniLibs" "${SHERPA_DIR}/" + else + echo "Error: Could not find jniLibs in extracted archive" + ls -la "${TEMP_DIR}" + rm -rf "${TEMP_DIR}" + exit 1 + fi + + # Copy headers if present + if [ -n "${EXTRACTED_DIR}" ] && [ -d "${EXTRACTED_DIR}/include" ]; then + cp -R "${EXTRACTED_DIR}/include" "${SHERPA_DIR}/" + elif [ -d "${TEMP_DIR}/include" ]; then + cp -R "${TEMP_DIR}/include" "${SHERPA_DIR}/" + fi + + # Clean up + rm -rf "${TEMP_DIR}" + + # Download headers if not present (Android release doesn't include them) + if [ ! -d "${SHERPA_DIR}/include/sherpa-onnx" ]; then + echo "" + echo "Downloading Sherpa-ONNX headers..." + mkdir -p "${SHERPA_DIR}/include" + + # Try to download from iOS since headers are platform-independent + IOS_SHERPA_HEADERS="${ROOT_DIR}/third_party/sherpa-onnx-ios/sherpa-onnx.xcframework/ios-arm64/Headers" + if [ -d "${IOS_SHERPA_HEADERS}/sherpa-onnx" ]; then + echo "Using headers from iOS Sherpa-ONNX..." + cp -R "${IOS_SHERPA_HEADERS}"/* "${SHERPA_DIR}/include/" + else + # Fallback: Download headers from source repo + echo "Downloading headers from Sherpa-ONNX source..." + curl -sL "https://raw.githubusercontent.com/k2-fsa/sherpa-onnx/v${SHERPA_VERSION}/sherpa-onnx/c-api/c-api.h" \ + -o "${SHERPA_DIR}/include/sherpa-onnx/c-api/c-api.h" --create-dirs + curl -sL "https://raw.githubusercontent.com/k2-fsa/sherpa-onnx/v${SHERPA_VERSION}/sherpa-onnx/c-api/cxx-api.h" \ + -o "${SHERPA_DIR}/include/sherpa-onnx/c-api/cxx-api.h" --create-dirs + fi + echo "✅ Sherpa-ONNX headers installed" + fi + + # Download ONNX Runtime C API header (required for ONNX backend compilation) + # Uses ONNX_VERSION_ANDROID from VERSIONS file (Sherpa-ONNX must be compatible) + if [ ! -f "${SHERPA_DIR}/include/onnxruntime_c_api.h" ]; then + echo "" + echo "Downloading ONNX Runtime C API header..." + if [ -z "${ONNX_VERSION_ANDROID:-}" ]; then + echo "ERROR: ONNX_VERSION_ANDROID not loaded from VERSIONS file" >&2 + exit 1 + fi + ONNX_RT_VERSION="${ONNX_VERSION_ANDROID}" + curl -sL "https://raw.githubusercontent.com/microsoft/onnxruntime/v${ONNX_RT_VERSION}/include/onnxruntime/core/session/onnxruntime_c_api.h" \ + -o "${SHERPA_DIR}/include/onnxruntime_c_api.h" + echo "✅ ONNX Runtime header installed (v${ONNX_RT_VERSION})" + fi + + echo "" + echo "✅ Sherpa-ONNX Android libraries downloaded to ${SHERPA_DIR}" + echo "" + echo "Contents:" + ls -lh "${SHERPA_DIR}" + if [ -d "${SHERPA_DIR}/jniLibs" ]; then + echo "" + echo "JNI Libraries:" + find "${SHERPA_DIR}/jniLibs" -name "*.so" -exec ls -lh {} \; + fi + if [ -d "${SHERPA_DIR}/include" ]; then + echo "" + echo "Headers:" + find "${SHERPA_DIR}/include" -name "*.h" + fi +else + echo "" + echo "⚠️ Download failed (HTTP: ${HTTP_CODE})" + echo "" + rm -rf "${TEMP_DIR}" + + echo "==============================================" + echo "❌ Sherpa-ONNX download failed" + echo "==============================================" + echo "" + echo "Manual download options:" + echo "" + echo "1. Download directly from Sherpa-ONNX releases:" + echo " ${DOWNLOAD_URL}" + echo "" + echo "2. Extract and copy jniLibs to:" + echo " ${SHERPA_DIR}/jniLibs/" + echo "" + exit 1 +fi diff --git a/sdk/runanywhere-commons/scripts/build-android.sh b/sdk/runanywhere-commons/scripts/build-android.sh new file mode 100755 index 000000000..1570106ec --- /dev/null +++ b/sdk/runanywhere-commons/scripts/build-android.sh @@ -0,0 +1,942 @@ +#!/bin/bash + +# ============================================================================= +# build-android.sh +# Unified Android build script - builds JNI bridge + selected backends +# +# Usage: ./build-android.sh [options] [backends] [abis] +# backends: onnx | llamacpp | all (default: all) +# - onnx: STT/TTS/VAD (Sherpa-ONNX models) +# - llamacpp: LLM text generation (GGUF models) +# - all: onnx + llamacpp (default) +# NOTE: whispercpp is deprecated (use onnx for STT) +# abis: comma-separated list (default: arm64-v8a) +# Supported: arm64-v8a, armeabi-v7a, x86_64, x86 +# +# Options: +# --check Check 16KB alignment of existing libraries in dist/ +# --help Show this help message +# +# ABI Guide: +# arm64-v8a 64-bit ARM (modern devices, ~85% coverage) +# armeabi-v7a 32-bit ARM (older devices, ~12% coverage) +# x86_64 64-bit Intel (emulators on Intel Macs, ~2%) +# x86 32-bit Intel (old emulators, ~1%) +# +# Examples: +# # Quick start (modern devices only, ~4min build) +# ./build-android.sh +# +# # RECOMMENDED for production (97% device coverage, ~7min build) +# ./build-android.sh all arm64-v8a,armeabi-v7a +# +# # Full compatibility (all devices + emulators, ~12min build) +# ./build-android.sh all arm64-v8a,armeabi-v7a,x86_64,x86 +# +# # Development with emulator support (device + emulator) +# ./build-android.sh all arm64-v8a,x86_64 +# +# # Single backend with multiple ABIs +# ./build-android.sh llamacpp arm64-v8a,armeabi-v7a +# ./build-android.sh onnx arm64-v8a,armeabi-v7a +# +# # Verify 16KB alignment +# ./build-android.sh --check +# +# 16KB Page Size Alignment (Google Play deadline: November 1, 2025): +# ✅ Sherpa-ONNX v1.12.20+ pre-built binaries ARE 16KB aligned! +# (Fixed in https://github.com/k2-fsa/sherpa-onnx/pull/2520) +# ✅ This script uses Sherpa-ONNX's bundled libonnxruntime.so for ONNX backend +# ✅ CMake builds runanywhere_*.so with 16KB alignment flags +# ============================================================================= + +set -e # Exit on error + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +BUILD_DIR="${ROOT_DIR}/build/android" +DIST_DIR="${ROOT_DIR}/dist/android" + +# Load centralized versions +source "${SCRIPT_DIR}/load-versions.sh" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +NC='\033[0m' + +print_header() { + echo "" + echo -e "${BLUE}========================================${NC}" + echo -e "${BLUE}$1${NC}" + echo -e "${BLUE}========================================${NC}" + echo "" +} + +print_step() { + echo -e "${YELLOW}-> $1${NC}" +} + +print_success() { + echo -e "${GREEN}[OK] $1${NC}" +} + +print_error() { + echo -e "${RED}[ERROR] $1${NC}" +} + +print_warning() { + echo -e "${YELLOW}[WARN] $1${NC}" +} + +print_info() { + echo -e "${CYAN}[INFO] $1${NC}" +} + +# ============================================================================= +# Parse Options (before positional arguments) +# ============================================================================= + +CHECK_ONLY=false + +while [[ "$1" == --* ]]; do + case "$1" in + --check) + CHECK_ONLY=true + shift + ;; + --help|-h) + head -55 "$0" | tail -50 + exit 0 + ;; + *) + print_error "Unknown option: $1" + echo "Use --help for usage information" + exit 1 + ;; + esac +done + +# ============================================================================= +# Check Alignment Mode +# ============================================================================= + +if [ "$CHECK_ONLY" = true ]; then + print_header "Checking 16KB Alignment" + + # Find readelf + READELF="" + if command -v llvm-readelf &> /dev/null; then + READELF="llvm-readelf" + elif [ -d "$HOME/Library/Android/sdk/ndk" ]; then + NDK_PATH=$(ls -d "$HOME/Library/Android/sdk/ndk"/*/ 2>/dev/null | sort -V | tail -1) + if [ -f "$NDK_PATH/toolchains/llvm/prebuilt/darwin-x86_64/bin/llvm-readelf" ]; then + READELF="$NDK_PATH/toolchains/llvm/prebuilt/darwin-x86_64/bin/llvm-readelf" + fi + fi + + if [ -z "$READELF" ]; then + print_error "readelf not found. Install Android NDK." + exit 1 + fi + + ALL_ALIGNED=true + ALIGNED_COUNT=0 + MISALIGNED_COUNT=0 + + for so_file in $(find "${DIST_DIR}" -name "*.so" -type f 2>/dev/null); do + filename=$(basename "$so_file") + LOAD_OUTPUT=$("$READELF" -l "$so_file" 2>/dev/null | grep "LOAD" || true) + + HAS_4KB=false + HAS_16KB=false + + while IFS= read -r line; do + ALIGN_VAL=$(echo "$line" | grep -oE '0x[0-9a-fA-F]+' | tail -1) + case "$ALIGN_VAL" in + 0x1000|0x001000) HAS_4KB=true ;; + 0x4000|0x004000) HAS_16KB=true ;; + esac + done <<< "$LOAD_OUTPUT" + + if [ "$HAS_4KB" = true ] && [ "$HAS_16KB" = false ]; then + print_error "$filename - 4KB aligned (NOT Play Store ready)" + ALL_ALIGNED=false + MISALIGNED_COUNT=$((MISALIGNED_COUNT + 1)) + elif [ "$HAS_16KB" = true ]; then + print_success "$filename - 16KB aligned" + ALIGNED_COUNT=$((ALIGNED_COUNT + 1)) + fi + done + + echo "" + echo "16KB aligned: $ALIGNED_COUNT" + echo "Misaligned: $MISALIGNED_COUNT" + + if [ "$ALL_ALIGNED" = true ] && [ "$ALIGNED_COUNT" -gt 0 ]; then + echo "" + print_success "All libraries are 16KB aligned - Play Store ready!" + exit 0 + else + echo "" + print_error "Some libraries are NOT 16KB aligned!" + echo "" + echo "Re-download Sherpa-ONNX v1.12.20+:" + echo " ./scripts/android/download-sherpa-onnx.sh" + exit 1 + fi +fi + +# ============================================================================= +# Parse Positional Arguments +# ============================================================================= + +BACKENDS="${1:-all}" +ABIS="${2:-arm64-v8a}" + +# Use version from VERSIONS file (loaded via load-versions.sh) +# ANDROID_MIN_SDK is the canonical name from VERSIONS file +if [ -z "${ANDROID_MIN_SDK:-}" ]; then + echo "ERROR: ANDROID_MIN_SDK not loaded from VERSIONS file" >&2 + exit 1 +fi +ANDROID_API_LEVEL="${ANDROID_MIN_SDK}" + +# Determine which backends to build +BUILD_ONNX=OFF +BUILD_LLAMACPP=OFF +BUILD_WHISPERCPP=OFF +BUILD_TFLITE=OFF + +case "$BACKENDS" in + all) + # NOTE: WhisperCPP is deprecated - use ONNX for STT instead + # WhisperCPP has build issues with newer ggml versions (GGML_KQ_MASK_PAD) + BUILD_ONNX=ON + BUILD_LLAMACPP=ON + BUILD_WHISPERCPP=OFF + DIST_SUBDIR="unified" + ;; + onnx) + BUILD_ONNX=ON + DIST_SUBDIR="onnx" + ;; + llamacpp) + BUILD_LLAMACPP=ON + DIST_SUBDIR="llamacpp" + ;; + whispercpp) + BUILD_WHISPERCPP=ON + DIST_SUBDIR="whispercpp" + ;; + tflite) + BUILD_TFLITE=ON + DIST_SUBDIR="tflite" + ;; + onnx,llamacpp|llamacpp,onnx) + BUILD_ONNX=ON + BUILD_LLAMACPP=ON + DIST_SUBDIR="unified" + ;; + onnx,whispercpp|whispercpp,onnx) + BUILD_ONNX=ON + BUILD_WHISPERCPP=ON + DIST_SUBDIR="unified" + ;; + llamacpp,whispercpp|whispercpp,llamacpp) + BUILD_LLAMACPP=ON + BUILD_WHISPERCPP=ON + DIST_SUBDIR="unified" + ;; + onnx,llamacpp,whispercpp|*) + # Handle any other combination containing onnx,llamacpp,whispercpp + if [[ "$BACKENDS" == *"onnx"* ]] && [[ "$BACKENDS" == *"llamacpp"* ]] && [[ "$BACKENDS" == *"whispercpp"* ]]; then + BUILD_ONNX=ON + BUILD_LLAMACPP=ON + BUILD_WHISPERCPP=ON + DIST_SUBDIR="unified" + else + print_error "Unknown backend(s): $BACKENDS" + echo "Usage: $0 [backends] [abis]" + echo " backends: onnx | llamacpp | whispercpp | tflite | all" + echo " abis: comma-separated list (default: arm64-v8a)" + exit 1 + fi + ;; +esac + +print_header "RunAnywhere Android Build (Unified)" +echo "Backends: ONNX=$BUILD_ONNX, LlamaCPP=$BUILD_LLAMACPP, WhisperCPP=$BUILD_WHISPERCPP, TFLite=$BUILD_TFLITE" +echo "ABIs: ${ABIS}" +echo "Android API Level: ${ANDROID_API_LEVEL}" +echo "Output: dist/android/${DIST_SUBDIR}/" + +# ============================================================================= +# Prerequisites +# ============================================================================= + +print_step "Checking prerequisites..." + +if ! command -v cmake &> /dev/null; then + print_error "cmake not found. Install with: brew install cmake (macOS) or apt install cmake (Linux)" + exit 1 +fi +print_success "Found cmake" + +# Find Android NDK +if [ -z "$ANDROID_NDK_HOME" ] && [ -z "$NDK_HOME" ]; then + if [ -d "$HOME/Library/Android/sdk/ndk" ]; then + ANDROID_NDK_HOME=$(ls -d "$HOME/Library/Android/sdk/ndk"/*/ 2>/dev/null | sort -V | tail -1) + elif [ -d "$HOME/Android/Sdk/ndk" ]; then + ANDROID_NDK_HOME=$(ls -d "$HOME/Android/Sdk/ndk"/*/ 2>/dev/null | sort -V | tail -1) + elif [ -d "$ANDROID_HOME/ndk" ]; then + ANDROID_NDK_HOME=$(ls -d "$ANDROID_HOME/ndk"/*/ 2>/dev/null | sort -V | tail -1) + elif [ -d "$ANDROID_SDK_ROOT/ndk" ]; then + ANDROID_NDK_HOME=$(ls -d "$ANDROID_SDK_ROOT/ndk"/*/ 2>/dev/null | sort -V | tail -1) + fi +fi + +NDK_PATH="${ANDROID_NDK_HOME:-$NDK_HOME}" +if [ -z "$NDK_PATH" ] || [ ! -d "$NDK_PATH" ]; then + print_error "Android NDK not found. Set ANDROID_NDK_HOME or NDK_HOME environment variable." + exit 1 +fi +print_success "Found Android NDK: $NDK_PATH" + +TOOLCHAIN_FILE="$NDK_PATH/build/cmake/android.toolchain.cmake" +if [ ! -f "$TOOLCHAIN_FILE" ]; then + print_error "Android toolchain file not found at: $TOOLCHAIN_FILE" + exit 1 +fi +print_success "Found toolchain file" + +# Backend-specific checks +if [ "$BUILD_ONNX" = "ON" ]; then + # Sherpa-ONNX is REQUIRED for ONNX backend (provides 16KB-aligned libonnxruntime.so) + if [ ! -d "${ROOT_DIR}/third_party/sherpa-onnx-android/jniLibs" ]; then + print_step "Sherpa-ONNX not found. Downloading..." + "${ROOT_DIR}/scripts/android/download-sherpa-onnx.sh" + fi + print_success "Found Sherpa-ONNX (provides 16KB-aligned ONNX Runtime + STT/TTS/VAD)" +fi + +if [ "$BUILD_LLAMACPP" = "ON" ]; then + print_success "LlamaCPP will be fetched via CMake FetchContent" +fi + +if [ "$BUILD_WHISPERCPP" = "ON" ]; then + print_success "WhisperCPP will be fetched via CMake FetchContent" +fi + +# ============================================================================= +# Clean Previous Build +# ============================================================================= + +print_step "Cleaning previous builds..." +BACKEND_BUILD_DIR="${BUILD_DIR}/${DIST_SUBDIR}" +BACKEND_DIST_DIR="${DIST_DIR}/${DIST_SUBDIR}" +rm -rf "${BACKEND_BUILD_DIR}" +rm -rf "${BACKEND_DIST_DIR}" +mkdir -p "${BACKEND_BUILD_DIR}" +mkdir -p "${BACKEND_DIST_DIR}" + +# Also create jni distribution directory (always contains jni + bridge) +JNI_DIST_DIR="${DIST_DIR}/jni" +rm -rf "${JNI_DIST_DIR}" +mkdir -p "${JNI_DIST_DIR}" + +# ============================================================================= +# Build for Each ABI +# ============================================================================= + +IFS=',' read -ra ABI_ARRAY <<< "$ABIS" + +for ABI in "${ABI_ARRAY[@]}"; do + print_header "Building for ${ABI}" + + ABI_BUILD_DIR="${BACKEND_BUILD_DIR}/${ABI}" + mkdir -p "${ABI_BUILD_DIR}" + + cmake -B "${ABI_BUILD_DIR}" \ + -DCMAKE_TOOLCHAIN_FILE="${TOOLCHAIN_FILE}" \ + -DANDROID_ABI="${ABI}" \ + -DANDROID_PLATFORM="android-${ANDROID_API_LEVEL}" \ + -DANDROID_STL=c++_shared \ + -DCMAKE_BUILD_TYPE=Release \ + -DRAC_BUILD_BACKENDS=ON \ + -DRAC_BUILD_JNI=ON \ + -DRAC_BACKEND_ONNX=${BUILD_ONNX} \ + -DRAC_BACKEND_LLAMACPP=${BUILD_LLAMACPP} \ + -DRAC_BACKEND_WHISPERCPP=${BUILD_WHISPERCPP} \ + -DRAC_BUILD_TESTS=OFF \ + -DRAC_BUILD_SHARED=ON \ + -DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON \ + -DCMAKE_SHARED_LINKER_FLAGS="-Wl,-z,max-page-size=16384 -Wl,-z,common-page-size=16384" \ + "${ROOT_DIR}" + + cmake --build "${ABI_BUILD_DIR}" \ + --config Release \ + -j$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4) + + print_success "${ABI} build complete" + + # Create distribution directories + mkdir -p "${BACKEND_DIST_DIR}/${ABI}" + mkdir -p "${JNI_DIST_DIR}/${ABI}" + + # Copy JNI bridge libraries (always to jni/ directory) + print_step "Copying JNI bridge libraries for ${ABI}..." + + # Core JNI library (from src/jni subdirectory) + if [ -f "${ABI_BUILD_DIR}/src/jni/librunanywhere_jni.so" ]; then + cp "${ABI_BUILD_DIR}/src/jni/librunanywhere_jni.so" "${JNI_DIST_DIR}/${ABI}/" + echo " Copied: librunanywhere_jni.so -> jni/${ABI}/" + elif [ -f "${ABI_BUILD_DIR}/librunanywhere_jni.so" ]; then + cp "${ABI_BUILD_DIR}/librunanywhere_jni.so" "${JNI_DIST_DIR}/${ABI}/" + echo " Copied: librunanywhere_jni.so -> jni/${ABI}/" + fi + + # Legacy loader/bridge libraries (if present) + if [ -f "${ABI_BUILD_DIR}/librunanywhere_loader.so" ]; then + cp "${ABI_BUILD_DIR}/librunanywhere_loader.so" "${JNI_DIST_DIR}/${ABI}/" + echo " Copied: librunanywhere_loader.so -> jni/${ABI}/" + fi + if [ -f "${ABI_BUILD_DIR}/librunanywhere_bridge.so" ]; then + cp "${ABI_BUILD_DIR}/librunanywhere_bridge.so" "${JNI_DIST_DIR}/${ABI}/" + echo " Copied: librunanywhere_bridge.so -> jni/${ABI}/" + fi + + # Detect NDK prebuilt directory (works across all platforms: darwin-x86_64, darwin-arm64, linux-x86_64) + PREBUILT_DIR="" + if [ -d "$NDK_PATH/toolchains/llvm/prebuilt" ]; then + PREBUILT_DIR=$(ls -d "$NDK_PATH/toolchains/llvm/prebuilt"/*/ 2>/dev/null | head -1 | xargs basename 2>/dev/null) + fi + + # Determine arch-specific search pattern for libomp.so + case "$ABI" in + arm64-v8a) + ARCH_PATTERN="aarch64" + ;; + armeabi-v7a) + ARCH_PATTERN="arm" + ;; + x86_64) + ARCH_PATTERN="x86_64" + ;; + x86) + ARCH_PATTERN="i686" + ;; + esac + + # Copy libomp.so using find (robust across NDK versions and directory structures) + # libomp.so is required by librac_backend_llamacpp_jni.so when OpenMP is enabled + LIBOMP_FOUND=$(find "$NDK_PATH/toolchains/llvm/prebuilt" -name "libomp.so" -path "*/${ARCH_PATTERN}/*" 2>/dev/null | head -1) + if [ -n "$LIBOMP_FOUND" ] && [ -f "$LIBOMP_FOUND" ]; then + cp "$LIBOMP_FOUND" "${JNI_DIST_DIR}/${ABI}/" + echo " Copied: libomp.so -> jni/${ABI}/ (from $LIBOMP_FOUND)" + else + # Fallback: try to find any libomp.so for this architecture + LIBOMP_FOUND=$(find "$NDK_PATH" -name "libomp.so" -path "*linux*${ARCH_PATTERN}*" 2>/dev/null | head -1) + if [ -n "$LIBOMP_FOUND" ] && [ -f "$LIBOMP_FOUND" ]; then + cp "$LIBOMP_FOUND" "${JNI_DIST_DIR}/${ABI}/" + echo " Copied: libomp.so -> jni/${ABI}/ (fallback from $LIBOMP_FOUND)" + else + echo " WARNING: libomp.so not found for ${ABI} (${ARCH_PATTERN}). LlamaCPP/WhisperCPP may fail at runtime!" + echo " Searched in: $NDK_PATH/toolchains/llvm/prebuilt" + fi + fi + + # Copy libc++_shared.so using find (robust across NDK versions) + if [ -n "$PREBUILT_DIR" ]; then + LIBCXX_FOUND=$(find "$NDK_PATH/toolchains/llvm/prebuilt/$PREBUILT_DIR/sysroot/usr/lib" -name "libc++_shared.so" -path "*${ARCH_PATTERN}*" 2>/dev/null | head -1) + if [ -n "$LIBCXX_FOUND" ] && [ -f "$LIBCXX_FOUND" ]; then + cp "$LIBCXX_FOUND" "${JNI_DIST_DIR}/${ABI}/" + echo " Copied: libc++_shared.so -> jni/${ABI}/" + fi + fi + + # Copy backend-specific libraries + print_step "Copying backend libraries for ${ABI}..." + + # ONNX backend + if [ "$BUILD_ONNX" = "ON" ]; then + mkdir -p "${DIST_DIR}/onnx/${ABI}" + # Check both paths (backends/ for older builds, src/backends/ for current) + if [ -f "${ABI_BUILD_DIR}/src/backends/onnx/librac_backend_onnx.so" ]; then + cp "${ABI_BUILD_DIR}/src/backends/onnx/librac_backend_onnx.so" "${DIST_DIR}/onnx/${ABI}/" + echo " Copied: librac_backend_onnx.so -> onnx/${ABI}/" + elif [ -f "${ABI_BUILD_DIR}/backends/onnx/librunanywhere_onnx.so" ]; then + cp "${ABI_BUILD_DIR}/backends/onnx/librunanywhere_onnx.so" "${DIST_DIR}/onnx/${ABI}/" + echo " Copied: librunanywhere_onnx.so -> onnx/${ABI}/" + fi + + # Copy JNI bridge library (required for Kotlin SDK) + if [ -f "${ABI_BUILD_DIR}/src/backends/onnx/librac_backend_onnx_jni.so" ]; then + cp "${ABI_BUILD_DIR}/src/backends/onnx/librac_backend_onnx_jni.so" "${DIST_DIR}/onnx/${ABI}/" + echo " Copied: librac_backend_onnx_jni.so -> onnx/${ABI}/" + elif [ -f "${ABI_BUILD_DIR}/backends/onnx/librac_backend_onnx_jni.so" ]; then + cp "${ABI_BUILD_DIR}/backends/onnx/librac_backend_onnx_jni.so" "${DIST_DIR}/onnx/${ABI}/" + echo " Copied: librac_backend_onnx_jni.so -> onnx/${ABI}/" + else + print_warning "librac_backend_onnx_jni.so not found - JNI bridge not built" + fi + + # Copy libonnxruntime.so from Sherpa-ONNX (16KB aligned in v1.12.20+) + # Sherpa-ONNX bundles a compatible version of ONNX Runtime + SHERPA_DIR="${ROOT_DIR}/third_party/sherpa-onnx-android/jniLibs/${ABI}" + if [ -d "$SHERPA_DIR" ]; then + # Copy libonnxruntime.so from Sherpa-ONNX (16KB aligned) + if [ -f "${SHERPA_DIR}/libonnxruntime.so" ]; then + cp "${SHERPA_DIR}/libonnxruntime.so" "${DIST_DIR}/onnx/${ABI}/" + echo " Copied: libonnxruntime.so -> onnx/${ABI}/ (from Sherpa-ONNX, 16KB aligned)" + fi + + # Copy all sherpa-onnx libraries (c-api, cxx-api, jni) + for lib in "${SHERPA_DIR}"/libsherpa-onnx-*.so; do + if [ -f "$lib" ]; then + cp "$lib" "${DIST_DIR}/onnx/${ABI}/" + echo " Copied: $(basename "$lib") -> onnx/${ABI}/" + fi + done + else + print_warning "Sherpa-ONNX not found - libonnxruntime.so will not be copied" + print_warning "Run: ./scripts/android/download-sherpa-onnx.sh to download" + fi + fi + + # LlamaCPP backend + if [ "$BUILD_LLAMACPP" = "ON" ]; then + mkdir -p "${DIST_DIR}/llamacpp/${ABI}" + # Check both paths (backends/ for older builds, src/backends/ for current) + if [ -f "${ABI_BUILD_DIR}/src/backends/llamacpp/librac_backend_llamacpp.so" ]; then + cp "${ABI_BUILD_DIR}/src/backends/llamacpp/librac_backend_llamacpp.so" "${DIST_DIR}/llamacpp/${ABI}/" + echo " Copied: librac_backend_llamacpp.so -> llamacpp/${ABI}/" + elif [ -f "${ABI_BUILD_DIR}/backends/llamacpp/librunanywhere_llamacpp.so" ]; then + cp "${ABI_BUILD_DIR}/backends/llamacpp/librunanywhere_llamacpp.so" "${DIST_DIR}/llamacpp/${ABI}/" + echo " Copied: librunanywhere_llamacpp.so -> llamacpp/${ABI}/" + fi + + # Copy JNI bridge library (required for Kotlin SDK) + if [ -f "${ABI_BUILD_DIR}/src/backends/llamacpp/librac_backend_llamacpp_jni.so" ]; then + cp "${ABI_BUILD_DIR}/src/backends/llamacpp/librac_backend_llamacpp_jni.so" "${DIST_DIR}/llamacpp/${ABI}/" + echo " Copied: librac_backend_llamacpp_jni.so -> llamacpp/${ABI}/" + elif [ -f "${ABI_BUILD_DIR}/backends/llamacpp/librac_backend_llamacpp_jni.so" ]; then + cp "${ABI_BUILD_DIR}/backends/llamacpp/librac_backend_llamacpp_jni.so" "${DIST_DIR}/llamacpp/${ABI}/" + echo " Copied: librac_backend_llamacpp_jni.so -> llamacpp/${ABI}/" + else + print_warning "librac_backend_llamacpp_jni.so not found - JNI bridge not built" + fi + + # Copy OpenMP and C++ shared library for LlamaCPP + # Note: ARCH_PATTERN is already set above in the ABI detection + LIBOMP_FOUND=$(find "$NDK_PATH/toolchains/llvm/prebuilt" -name "libomp.so" -path "*/${ARCH_PATTERN}/*" 2>/dev/null | head -1) + if [ -n "$LIBOMP_FOUND" ] && [ -f "$LIBOMP_FOUND" ]; then + cp "$LIBOMP_FOUND" "${DIST_DIR}/llamacpp/${ABI}/" + echo " Copied: libomp.so -> llamacpp/${ABI}/ (from $LIBOMP_FOUND)" + else + # Fallback: try to find any libomp.so for this architecture + LIBOMP_FOUND=$(find "$NDK_PATH" -name "libomp.so" -path "*linux*${ARCH_PATTERN}*" 2>/dev/null | head -1) + if [ -n "$LIBOMP_FOUND" ] && [ -f "$LIBOMP_FOUND" ]; then + cp "$LIBOMP_FOUND" "${DIST_DIR}/llamacpp/${ABI}/" + echo " Copied: libomp.so -> llamacpp/${ABI}/ (fallback from $LIBOMP_FOUND)" + else + echo " WARNING: libomp.so not found for ${ABI}. LlamaCPP may fail at runtime!" + fi + fi + + # Copy libc++_shared.so for LlamaCPP + if [ -n "$PREBUILT_DIR" ]; then + LIBCXX_FOUND=$(find "$NDK_PATH/toolchains/llvm/prebuilt/$PREBUILT_DIR/sysroot/usr/lib" -name "libc++_shared.so" -path "*${ARCH_PATTERN}*" 2>/dev/null | head -1) + if [ -n "$LIBCXX_FOUND" ] && [ -f "$LIBCXX_FOUND" ]; then + cp "$LIBCXX_FOUND" "${DIST_DIR}/llamacpp/${ABI}/" + echo " Copied: libc++_shared.so -> llamacpp/${ABI}/" + fi + fi + fi + + # WhisperCPP backend + if [ "$BUILD_WHISPERCPP" = "ON" ]; then + mkdir -p "${DIST_DIR}/whispercpp/${ABI}" + if [ -f "${ABI_BUILD_DIR}/backends/whispercpp/librunanywhere_whispercpp.so" ]; then + cp "${ABI_BUILD_DIR}/backends/whispercpp/librunanywhere_whispercpp.so" "${DIST_DIR}/whispercpp/${ABI}/" + echo " Copied: librunanywhere_whispercpp.so -> whispercpp/${ABI}/" + fi + + # Copy JNI bridge library (required for Kotlin SDK) + if [ -f "${ABI_BUILD_DIR}/backends/whispercpp/librac_backend_whispercpp_jni.so" ]; then + cp "${ABI_BUILD_DIR}/backends/whispercpp/librac_backend_whispercpp_jni.so" "${DIST_DIR}/whispercpp/${ABI}/" + echo " Copied: librac_backend_whispercpp_jni.so -> whispercpp/${ABI}/" + else + print_warning "librac_backend_whispercpp_jni.so not found - JNI bridge not built" + fi + + # Copy OpenMP and C++ shared library for WhisperCPP + # Note: ARCH_PATTERN is already set above in the ABI detection + LIBOMP_FOUND=$(find "$NDK_PATH/toolchains/llvm/prebuilt" -name "libomp.so" -path "*/${ARCH_PATTERN}/*" 2>/dev/null | head -1) + if [ -n "$LIBOMP_FOUND" ] && [ -f "$LIBOMP_FOUND" ]; then + cp "$LIBOMP_FOUND" "${DIST_DIR}/whispercpp/${ABI}/" + echo " Copied: libomp.so -> whispercpp/${ABI}/ (from $LIBOMP_FOUND)" + else + # Fallback: try to find any libomp.so for this architecture + LIBOMP_FOUND=$(find "$NDK_PATH" -name "libomp.so" -path "*linux*${ARCH_PATTERN}*" 2>/dev/null | head -1) + if [ -n "$LIBOMP_FOUND" ] && [ -f "$LIBOMP_FOUND" ]; then + cp "$LIBOMP_FOUND" "${DIST_DIR}/whispercpp/${ABI}/" + echo " Copied: libomp.so -> whispercpp/${ABI}/ (fallback from $LIBOMP_FOUND)" + else + echo " WARNING: libomp.so not found for ${ABI}. WhisperCPP may fail at runtime!" + fi + fi + + # Copy libc++_shared.so for WhisperCPP + if [ -n "$PREBUILT_DIR" ]; then + LIBCXX_FOUND=$(find "$NDK_PATH/toolchains/llvm/prebuilt/$PREBUILT_DIR/sysroot/usr/lib" -name "libc++_shared.so" -path "*${ARCH_PATTERN}*" 2>/dev/null | head -1) + if [ -n "$LIBCXX_FOUND" ] && [ -f "$LIBCXX_FOUND" ]; then + cp "$LIBCXX_FOUND" "${DIST_DIR}/whispercpp/${ABI}/" + echo " Copied: libc++_shared.so -> whispercpp/${ABI}/" + fi + fi + fi + + # TFLite backend + if [ "$BUILD_TFLITE" = "ON" ]; then + mkdir -p "${DIST_DIR}/tflite/${ABI}" + if [ -f "${ABI_BUILD_DIR}/backends/tflite/librunanywhere_tflite.so" ]; then + cp "${ABI_BUILD_DIR}/backends/tflite/librunanywhere_tflite.so" "${DIST_DIR}/tflite/${ABI}/" + echo " Copied: librunanywhere_tflite.so -> tflite/${ABI}/" + fi + fi + + # RAC Commons (shared library for logging, error handling, events) + # This is built from runanywhere-commons and linked by all backends + # CMake outputs to build dir root (not a subdirectory) + RAC_COMMONS_LIB="${ABI_BUILD_DIR}/librac_commons.so" + if [ -f "${RAC_COMMONS_LIB}" ]; then + mkdir -p "${DIST_DIR}/commons/${ABI}" + cp "${RAC_COMMONS_LIB}" "${DIST_DIR}/commons/${ABI}/" + echo " Copied: librac_commons.so -> commons/${ABI}/" + + # Also copy to each backend directory since they depend on it + for backend in onnx llamacpp whispercpp tflite; do + if [ -d "${DIST_DIR}/${backend}/${ABI}" ]; then + cp "${RAC_COMMONS_LIB}" "${DIST_DIR}/${backend}/${ABI}/" + echo " Copied: librac_commons.so -> ${backend}/${ABI}/" + fi + done + fi + + print_success "${ABI} libraries copied" +done + +# ============================================================================= +# Copy Headers +# ============================================================================= + +print_step "Copying headers..." +HEADERS_DIR="${DIST_DIR}/include" +mkdir -p "${HEADERS_DIR}" + +# Copy RAC headers from commons +COMMONS_DIR="${ROOT_DIR}/../sdk/runanywhere-commons" +if [ -d "${COMMONS_DIR}/include/rac" ]; then + cp -r "${COMMONS_DIR}/include/rac" "${HEADERS_DIR}/" + print_success "RAC Commons headers copied" +fi + +# Copy backend-specific RAC headers +if [ -d "${ROOT_DIR}/include" ]; then + cp "${ROOT_DIR}/include/"*.h "${HEADERS_DIR}/" 2>/dev/null || true + print_success "Backend RAC headers copied" +fi + +# Copy capabilities headers +if [ -d "${ROOT_DIR}/backends/capabilities" ]; then + cp "${ROOT_DIR}/backends/capabilities/"*.h "${HEADERS_DIR}/" 2>/dev/null || true + print_success "Capabilities headers copied" +fi + +# ============================================================================= +# Summary +# ============================================================================= + +print_header "Build Complete!" + +echo "Distribution structure:" +echo "" +echo "dist/android/" +echo "├── commons/ # RAC Commons library" +for ABI in "${ABI_ARRAY[@]}"; do + echo "│ └── ${ABI}/" + echo "│ └── librac_commons.so" +done +echo "├── include/ # Headers" +echo "│ ├── rac/ # RAC Commons headers" +echo "│ └── *.h # Backend headers" + +if [ "$BUILD_ONNX" = "ON" ]; then + echo "├── onnx/ # ONNX backend libraries" + for ABI in "${ABI_ARRAY[@]}"; do + echo "│ └── ${ABI}/" + echo "│ ├── librunanywhere_onnx.so" + echo "│ ├── libonnxruntime.so" + if [ -f "${DIST_DIR}/onnx/${ABI}/libsherpa-onnx-jni.so" ]; then + echo "│ └── libsherpa-onnx-jni.so # STT/TTS/VAD" + fi + done +fi + +if [ "$BUILD_LLAMACPP" = "ON" ]; then + echo "├── llamacpp/ # LlamaCPP backend libraries" + for ABI in "${ABI_ARRAY[@]}"; do + echo "│ └── ${ABI}/" + echo "│ ├── librunanywhere_llamacpp.so" + echo "│ ├── libomp.so" + echo "│ └── libc++_shared.so" + done +fi + +if [ "$BUILD_WHISPERCPP" = "ON" ]; then + echo "├── whispercpp/ # WhisperCPP backend libraries (STT)" + for ABI in "${ABI_ARRAY[@]}"; do + echo "│ └── ${ABI}/" + echo "│ ├── librunanywhere_whispercpp.so" + echo "│ ├── libomp.so" + echo "│ └── libc++_shared.so" + done +fi + +if [ "$BUILD_TFLITE" = "ON" ]; then + echo "└── tflite/ # TFLite backend libraries" + for ABI in "${ABI_ARRAY[@]}"; do + echo " └── ${ABI}/" + echo " └── librunanywhere_tflite.so" + done +fi + +echo "" +echo "Library sizes:" +echo " Commons:" +ls -lh "${DIST_DIR}/commons"/*/*.so 2>/dev/null | awk '{print " " $NF ": " $5}' || echo " (no files)" + +if [ "$BUILD_ONNX" = "ON" ]; then + echo " ONNX:" + ls -lh "${DIST_DIR}/onnx"/*/*.so 2>/dev/null | awk '{print " " $NF ": " $5}' || echo " (no files)" +fi + +if [ "$BUILD_LLAMACPP" = "ON" ]; then + echo " LlamaCPP:" + ls -lh "${DIST_DIR}/llamacpp"/*/*.so 2>/dev/null | awk '{print " " $NF ": " $5}' || echo " (no files)" +fi + +if [ "$BUILD_WHISPERCPP" = "ON" ]; then + echo " WhisperCPP:" + ls -lh "${DIST_DIR}/whispercpp"/*/*.so 2>/dev/null | awk '{print " " $NF ": " $5}' || echo " (no files)" +fi + +echo "" +echo -e "${GREEN}Build complete!${NC}" + +# ============================================================================= +# Create Distribution Packages +# ============================================================================= + +# Auto-detect version +VERSION_FILE="${ROOT_DIR}/VERSION" +if [ -f "$VERSION_FILE" ]; then + VERSION=$(cat "$VERSION_FILE" | tr -d '[:space:]') +elif command -v git &> /dev/null && [ -d "${ROOT_DIR}/.git" ]; then + VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo "0.0.1-dev") +else + VERSION="0.0.1-dev" +fi + +print_header "Creating Distribution Packages" +echo "Version: ${VERSION}" + +PACKAGES_DIR="${DIST_DIR}/packages" +mkdir -p "${PACKAGES_DIR}" + +# ============================================================================= +# Create Unified Package (Recommended) +# ============================================================================= + +if [ "$DIST_SUBDIR" = "unified" ]; then + print_step "Creating unified package with all backends..." + + # Create temporary unified directory + UNIFIED_TEMP="${DIST_DIR}/temp-unified" + rm -rf "${UNIFIED_TEMP}" + mkdir -p "${UNIFIED_TEMP}" + + # Copy all libraries for each ABI + for ABI in "${ABI_ARRAY[@]}"; do + mkdir -p "${UNIFIED_TEMP}/${ABI}" + + # Copy JNI bridge libraries (required) + if [ -d "${JNI_DIST_DIR}/${ABI}" ]; then + cp "${JNI_DIST_DIR}/${ABI}"/*.so "${UNIFIED_TEMP}/${ABI}/" 2>/dev/null || true + fi + + # Copy ONNX backend libraries + if [ -d "${DIST_DIR}/onnx/${ABI}" ]; then + cp "${DIST_DIR}/onnx/${ABI}"/*.so "${UNIFIED_TEMP}/${ABI}/" 2>/dev/null || true + fi + + # Copy LlamaCPP backend libraries + if [ -d "${DIST_DIR}/llamacpp/${ABI}" ]; then + cp "${DIST_DIR}/llamacpp/${ABI}"/*.so "${UNIFIED_TEMP}/${ABI}/" 2>/dev/null || true + fi + + # Copy WhisperCPP backend libraries + if [ -d "${DIST_DIR}/whispercpp/${ABI}" ]; then + cp "${DIST_DIR}/whispercpp/${ABI}"/*.so "${UNIFIED_TEMP}/${ABI}/" 2>/dev/null || true + fi + + # Copy TFLite backend libraries + if [ -d "${DIST_DIR}/tflite/${ABI}" ]; then + cp "${DIST_DIR}/tflite/${ABI}"/*.so "${UNIFIED_TEMP}/${ABI}/" 2>/dev/null || true + fi + done + + # Copy headers + if [ -d "${JNI_DIST_DIR}/include" ]; then + cp -r "${JNI_DIST_DIR}/include" "${UNIFIED_TEMP}/" + fi + + # Create ZIP archive + ARCHIVE_NAME="RunAnywhereUnified-android-${VERSION}.zip" + rm -f "${PACKAGES_DIR}/${ARCHIVE_NAME}" + + cd "${UNIFIED_TEMP}" + zip -r "${PACKAGES_DIR}/${ARCHIVE_NAME}" . > /dev/null + cd "${DIST_DIR}" + + # Generate checksum + cd "${PACKAGES_DIR}" + shasum -a 256 "${ARCHIVE_NAME}" > "${ARCHIVE_NAME}.sha256" + cd "${DIST_DIR}" + + # Clean up + rm -rf "${UNIFIED_TEMP}" + + print_success "Unified package: ${PACKAGES_DIR}/${ARCHIVE_NAME}" + echo "Size: $(du -sh "${PACKAGES_DIR}/${ARCHIVE_NAME}" | awk '{print $1}')" +fi + +# ============================================================================= +# Create Separate Backend Packages (For backwards compatibility) +# ============================================================================= + +# ONNX package +if [ "$BUILD_ONNX" = "ON" ]; then + print_step "Creating ONNX backend package..." + + ONNX_TEMP="${DIST_DIR}/temp-onnx" + rm -rf "${ONNX_TEMP}" + mkdir -p "${ONNX_TEMP}" + + for ABI in "${ABI_ARRAY[@]}"; do + mkdir -p "${ONNX_TEMP}/${ABI}" + [ -d "${JNI_DIST_DIR}/${ABI}" ] && cp "${JNI_DIST_DIR}/${ABI}"/*.so "${ONNX_TEMP}/${ABI}/" 2>/dev/null || true + [ -d "${DIST_DIR}/onnx/${ABI}" ] && cp "${DIST_DIR}/onnx/${ABI}"/*.so "${ONNX_TEMP}/${ABI}/" 2>/dev/null || true + done + [ -d "${JNI_DIST_DIR}/include" ] && cp -r "${JNI_DIST_DIR}/include" "${ONNX_TEMP}/" || true + + ONNX_ARCHIVE="RunAnywhereONNX-android-${VERSION}.zip" + cd "${ONNX_TEMP}" + zip -r "${PACKAGES_DIR}/${ONNX_ARCHIVE}" . > /dev/null + cd "${DIST_DIR}" + rm -rf "${ONNX_TEMP}" + + cd "${PACKAGES_DIR}" + shasum -a 256 "${ONNX_ARCHIVE}" > "${ONNX_ARCHIVE}.sha256" + cd "${DIST_DIR}" + + print_success "ONNX package: ${PACKAGES_DIR}/${ONNX_ARCHIVE}" +fi + +# LlamaCPP package +if [ "$BUILD_LLAMACPP" = "ON" ]; then + print_step "Creating LlamaCPP backend package..." + + LLAMA_TEMP="${DIST_DIR}/temp-llamacpp" + rm -rf "${LLAMA_TEMP}" + mkdir -p "${LLAMA_TEMP}" + + for ABI in "${ABI_ARRAY[@]}"; do + mkdir -p "${LLAMA_TEMP}/${ABI}" + [ -d "${JNI_DIST_DIR}/${ABI}" ] && cp "${JNI_DIST_DIR}/${ABI}"/*.so "${LLAMA_TEMP}/${ABI}/" 2>/dev/null || true + [ -d "${DIST_DIR}/llamacpp/${ABI}" ] && cp "${DIST_DIR}/llamacpp/${ABI}"/*.so "${LLAMA_TEMP}/${ABI}/" 2>/dev/null || true + done + [ -d "${JNI_DIST_DIR}/include" ] && cp -r "${JNI_DIST_DIR}/include" "${LLAMA_TEMP}/" || true + + LLAMA_ARCHIVE="RunAnywhereLlamaCPP-android-${VERSION}.zip" + cd "${LLAMA_TEMP}" + zip -r "${PACKAGES_DIR}/${LLAMA_ARCHIVE}" . > /dev/null + cd "${DIST_DIR}" + rm -rf "${LLAMA_TEMP}" + + cd "${PACKAGES_DIR}" + shasum -a 256 "${LLAMA_ARCHIVE}" > "${LLAMA_ARCHIVE}.sha256" + cd "${DIST_DIR}" + + print_success "LlamaCPP package: ${PACKAGES_DIR}/${LLAMA_ARCHIVE}" +fi + +# WhisperCPP package +if [ "$BUILD_WHISPERCPP" = "ON" ]; then + print_step "Creating WhisperCPP backend package..." + + WHISPER_TEMP="${DIST_DIR}/temp-whispercpp" + rm -rf "${WHISPER_TEMP}" + mkdir -p "${WHISPER_TEMP}" + + for ABI in "${ABI_ARRAY[@]}"; do + mkdir -p "${WHISPER_TEMP}/${ABI}" + [ -d "${JNI_DIST_DIR}/${ABI}" ] && cp "${JNI_DIST_DIR}/${ABI}"/*.so "${WHISPER_TEMP}/${ABI}/" 2>/dev/null || true + [ -d "${DIST_DIR}/whispercpp/${ABI}" ] && cp "${DIST_DIR}/whispercpp/${ABI}"/*.so "${WHISPER_TEMP}/${ABI}/" 2>/dev/null || true + done + [ -d "${JNI_DIST_DIR}/include" ] && cp -r "${JNI_DIST_DIR}/include" "${WHISPER_TEMP}/" || true + + WHISPER_ARCHIVE="RunAnywhereWhisperCPP-android-${VERSION}.zip" + cd "${WHISPER_TEMP}" + zip -r "${PACKAGES_DIR}/${WHISPER_ARCHIVE}" . > /dev/null + cd "${DIST_DIR}" + rm -rf "${WHISPER_TEMP}" + + cd "${PACKAGES_DIR}" + shasum -a 256 "${WHISPER_ARCHIVE}" > "${WHISPER_ARCHIVE}.sha256" + cd "${DIST_DIR}" + + print_success "WhisperCPP package: ${PACKAGES_DIR}/${WHISPER_ARCHIVE}" +fi + +# ============================================================================= +# Package Summary +# ============================================================================= + +print_header "Packages Ready for Distribution" + +echo "Output directory: ${PACKAGES_DIR}" +echo "" +echo "Packages created:" +ls -lh "${PACKAGES_DIR}"/*.zip 2>/dev/null | awk '{print " " $9 ": " $5}' || echo " (none)" +echo "" + +if [ -f "${PACKAGES_DIR}/RunAnywhereUnified-android-${VERSION}.zip" ]; then + echo -e "${YELLOW}RECOMMENDED FOR RELEASE:${NC}" + echo " ${PACKAGES_DIR}/RunAnywhereUnified-android-${VERSION}.zip" + echo "" + echo "This unified package contains ALL backends with a single bridge library" + echo "that has ONNX, LlamaCPP, and WhisperCPP support enabled." + echo "" +fi + +echo "To upload to GitHub releases:" +echo " gh release create v${VERSION} --title \"v${VERSION}\" --notes \"Release v${VERSION}\"" +echo " gh release upload v${VERSION} ${PACKAGES_DIR}/*.zip" +echo "" +echo -e "${GREEN}Done!${NC}" +# Force rebuild to include OpenMP diff --git a/sdk/runanywhere-commons/scripts/build-ios.sh b/sdk/runanywhere-commons/scripts/build-ios.sh new file mode 100755 index 000000000..41b21b0b0 --- /dev/null +++ b/sdk/runanywhere-commons/scripts/build-ios.sh @@ -0,0 +1,530 @@ +#!/bin/bash +# ============================================================================= +# RunAnywhere Commons - iOS Build Script +# ============================================================================= +# +# Builds everything for iOS: RACommons + Backend frameworks. +# +# USAGE: +# ./scripts/build-ios.sh [options] +# +# OPTIONS: +# --skip-download Skip downloading dependencies +# --skip-backends Build RACommons only, skip backend frameworks +# --backend NAME Build specific backend: llamacpp, onnx, all (default: all) +# - llamacpp: LLM text generation (GGUF models) +# - onnx: STT/TTS/VAD (Sherpa-ONNX models) +# - all: Both backends (default) +# --clean Clean build directories first +# --release Release build (default) +# --debug Debug build +# --package Create release ZIP packages +# --help Show this help +# +# OUTPUTS: +# dist/RACommons.xcframework (always built) +# dist/RABackendLLAMACPP.xcframework (if --backend llamacpp or all) +# dist/RABackendONNX.xcframework (if --backend onnx or all) +# +# EXAMPLES: +# # Full build (all backends) +# ./scripts/build-ios.sh +# +# # Build only LlamaCPP backend (LLM/text generation) +# ./scripts/build-ios.sh --backend llamacpp +# +# # Build only ONNX backend (speech-to-text/text-to-speech) +# ./scripts/build-ios.sh --backend onnx +# +# # Build only RACommons (no backends) +# ./scripts/build-ios.sh --skip-backends +# +# # Other useful combinations +# ./scripts/build-ios.sh --skip-download # Use cached dependencies +# ./scripts/build-ios.sh --clean --package # Clean build with packaging +# +# ============================================================================= + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +BUILD_DIR="${PROJECT_ROOT}/build/ios" +DIST_DIR="${PROJECT_ROOT}/dist" + +# Load versions +source "${SCRIPT_DIR}/load-versions.sh" + +# Get version +VERSION=$(cat "${PROJECT_ROOT}/VERSION" 2>/dev/null | head -1 || echo "0.1.0") + +# Options +SKIP_DOWNLOAD=false +SKIP_BACKENDS=false +BUILD_BACKEND="all" +CLEAN_BUILD=false +BUILD_TYPE="Release" +CREATE_PACKAGE=false + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +NC='\033[0m' + +log_info() { echo -e "${GREEN}[✓]${NC} $1"; } +log_warn() { echo -e "${YELLOW}[!]${NC} $1"; } +log_error() { echo -e "${RED}[✗]${NC} $1"; exit 1; } +log_step() { echo -e "${BLUE}==>${NC} $1"; } +log_time() { echo -e "${CYAN}[⏱]${NC} $1"; } +log_header() { echo -e "\n${GREEN}═══════════════════════════════════════════${NC}"; echo -e "${GREEN} $1${NC}"; echo -e "${GREEN}═══════════════════════════════════════════${NC}"; } +require_cmd() { + local cmd="$1" + local hint="$2" + if ! command -v "${cmd}" >/dev/null 2>&1; then + log_warn "Required tool '${cmd}' is not installed or not in PATH." + [[ -n "${hint}" ]] && log_warn "${hint}" + log_error "Cannot continue without '${cmd}'." + fi +} + +show_help() { + head -45 "$0" | tail -40 + exit 0 +} + +# ============================================================================= +# Parse Arguments +# ============================================================================= + +while [[ $# -gt 0 ]]; do + case $1 in + --skip-download) SKIP_DOWNLOAD=true; shift ;; + --skip-backends) SKIP_BACKENDS=true; shift ;; + --backend) BUILD_BACKEND="$2"; shift 2 ;; + --clean) CLEAN_BUILD=true; shift ;; + --release) BUILD_TYPE="Release"; shift ;; + --debug) BUILD_TYPE="Debug"; shift ;; + --package) CREATE_PACKAGE=true; shift ;; + --help|-h) show_help ;; + *) log_error "Unknown option: $1" ;; + esac +done + +# Timing +TOTAL_START=$(date +%s) + +# ============================================================================= +# Download Dependencies +# ============================================================================= + +download_deps() { + log_header "Downloading iOS Dependencies" + + # ONNX Runtime + if [[ ! -d "${PROJECT_ROOT}/third_party/onnxruntime-ios/onnxruntime.xcframework" ]]; then + log_step "Downloading ONNX Runtime..." + "${SCRIPT_DIR}/ios/download-onnx.sh" + else + log_info "ONNX Runtime already present" + fi + + # Sherpa-ONNX + if [[ ! -d "${PROJECT_ROOT}/third_party/sherpa-onnx-ios/sherpa-onnx.xcframework" ]]; then + log_step "Downloading Sherpa-ONNX..." + "${SCRIPT_DIR}/ios/download-sherpa-onnx.sh" + else + log_info "Sherpa-ONNX already present" + fi +} + +# ============================================================================= +# Build for iOS Platform +# ============================================================================= + +build_platform() { + local PLATFORM=$1 + local PLATFORM_DIR="${BUILD_DIR}/${PLATFORM}" + + log_step "Building for ${PLATFORM}..." + require_cmd "cmake" "Install it with: brew install cmake (macOS) or: sudo apt-get install cmake (Debian/Ubuntu)" + mkdir -p "${PLATFORM_DIR}" + cd "${PLATFORM_DIR}" + + # Determine backend flags + local BACKEND_FLAGS="" + if [[ "$SKIP_BACKENDS" == true ]]; then + BACKEND_FLAGS="-DRAC_BUILD_BACKENDS=OFF" + else + BACKEND_FLAGS="-DRAC_BUILD_BACKENDS=ON" + case "$BUILD_BACKEND" in + llamacpp) + BACKEND_FLAGS="$BACKEND_FLAGS -DRAC_BACKEND_LLAMACPP=ON -DRAC_BACKEND_ONNX=OFF -DRAC_BACKEND_WHISPERCPP=OFF" + ;; + onnx) + BACKEND_FLAGS="$BACKEND_FLAGS -DRAC_BACKEND_LLAMACPP=OFF -DRAC_BACKEND_ONNX=ON -DRAC_BACKEND_WHISPERCPP=OFF" + ;; + all|*) + BACKEND_FLAGS="$BACKEND_FLAGS -DRAC_BACKEND_LLAMACPP=ON -DRAC_BACKEND_ONNX=ON -DRAC_BACKEND_WHISPERCPP=OFF" + ;; + esac + fi + + cmake "${PROJECT_ROOT}" \ + -DCMAKE_TOOLCHAIN_FILE="${PROJECT_ROOT}/cmake/ios.toolchain.cmake" \ + -DIOS_PLATFORM="${PLATFORM}" \ + -DIOS_DEPLOYMENT_TARGET="${IOS_DEPLOYMENT_TARGET}" \ + -DCMAKE_BUILD_TYPE="${BUILD_TYPE}" \ + -DRAC_BUILD_PLATFORM=ON \ + -DRAC_BUILD_SHARED=OFF \ + -DRAC_BUILD_JNI=OFF \ + $BACKEND_FLAGS + + cmake --build . --config "${BUILD_TYPE}" -j$(sysctl -n hw.ncpu) + + cd "${PROJECT_ROOT}" + log_info "Built ${PLATFORM}" +} + +# ============================================================================= +# Create XCFramework +# ============================================================================= + +create_xcframework() { + local LIB_NAME=$1 + local FRAMEWORK_NAME=$2 + + log_step "Creating ${FRAMEWORK_NAME}.xcframework..." + + # Create framework for each platform + for PLATFORM in OS SIMULATORARM64 SIMULATOR; do + local PLATFORM_DIR="${BUILD_DIR}/${PLATFORM}" + local FRAMEWORK_DIR="${PLATFORM_DIR}/${FRAMEWORK_NAME}.framework" + + mkdir -p "${FRAMEWORK_DIR}/Headers" + mkdir -p "${FRAMEWORK_DIR}/Modules" + + # Find the library + local LIB_PATH="${PLATFORM_DIR}/lib${LIB_NAME}.a" + [[ ! -f "${LIB_PATH}" ]] && LIB_PATH="${PLATFORM_DIR}/src/backends/${BUILD_BACKEND}/lib${LIB_NAME}.a" + + if [[ ! -f "${LIB_PATH}" ]]; then + log_warn "Library not found: ${LIB_PATH}" + return 1 + fi + + cp "${LIB_PATH}" "${FRAMEWORK_DIR}/${FRAMEWORK_NAME}" + + # Copy headers + if [[ "$FRAMEWORK_NAME" == "RACommons" ]]; then + find "${PROJECT_ROOT}/include/rac" -name "*.h" | while read -r header; do + local filename=$(basename "$header") + sed -e 's|#include "rac/[^"]*\/\([^"]*\)"|#include |g' \ + "$header" > "${FRAMEWORK_DIR}/Headers/${filename}" + done + else + # Backend headers + local backend_name=$(echo "$LIB_NAME" | sed 's/rac_backend_//') + local header_src="${PROJECT_ROOT}/include/rac/backends/rac_${backend_name}.h" + [[ -f "$header_src" ]] && cp "$header_src" "${FRAMEWORK_DIR}/Headers/" + fi + + # Module map + cat > "${FRAMEWORK_DIR}/Modules/module.modulemap" << EOF +framework module ${FRAMEWORK_NAME} { + umbrella header "${FRAMEWORK_NAME}.h" + export * + module * { export * } +} +EOF + + # Umbrella header + echo "// ${FRAMEWORK_NAME} Umbrella Header" > "${FRAMEWORK_DIR}/Headers/${FRAMEWORK_NAME}.h" + echo "#ifndef ${FRAMEWORK_NAME}_h" >> "${FRAMEWORK_DIR}/Headers/${FRAMEWORK_NAME}.h" + echo "#define ${FRAMEWORK_NAME}_h" >> "${FRAMEWORK_DIR}/Headers/${FRAMEWORK_NAME}.h" + for h in "${FRAMEWORK_DIR}/Headers/"*.h; do + [[ "$(basename "$h")" != "${FRAMEWORK_NAME}.h" ]] && \ + echo "#include \"$(basename "$h")\"" >> "${FRAMEWORK_DIR}/Headers/${FRAMEWORK_NAME}.h" + done + echo "#endif" >> "${FRAMEWORK_DIR}/Headers/${FRAMEWORK_NAME}.h" + + # Info.plist + cat > "${FRAMEWORK_DIR}/Info.plist" << EOF + + + + + CFBundleExecutable${FRAMEWORK_NAME} + CFBundleIdentifierai.runanywhere.${FRAMEWORK_NAME} + CFBundlePackageTypeFMWK + CFBundleShortVersionString${VERSION} + MinimumOSVersion${IOS_DEPLOYMENT_TARGET} + + +EOF + done + + # Create fat simulator + local SIM_FAT="${BUILD_DIR}/SIMULATOR_FAT_${FRAMEWORK_NAME}" + mkdir -p "${SIM_FAT}" + cp -R "${BUILD_DIR}/SIMULATORARM64/${FRAMEWORK_NAME}.framework" "${SIM_FAT}/" + + lipo -create \ + "${BUILD_DIR}/SIMULATORARM64/${FRAMEWORK_NAME}.framework/${FRAMEWORK_NAME}" \ + "${BUILD_DIR}/SIMULATOR/${FRAMEWORK_NAME}.framework/${FRAMEWORK_NAME}" \ + -output "${SIM_FAT}/${FRAMEWORK_NAME}.framework/${FRAMEWORK_NAME}" + + # Create XCFramework + local XCFW_PATH="${DIST_DIR}/${FRAMEWORK_NAME}.xcframework" + rm -rf "${XCFW_PATH}" + + xcodebuild -create-xcframework \ + -framework "${BUILD_DIR}/OS/${FRAMEWORK_NAME}.framework" \ + -framework "${SIM_FAT}/${FRAMEWORK_NAME}.framework" \ + -output "${XCFW_PATH}" + + log_info "Created: ${XCFW_PATH}" + echo " Size: $(du -sh "${XCFW_PATH}" | cut -f1)" +} + +# ============================================================================= +# Create Backend XCFramework (bundles dependencies) +# ============================================================================= + +create_backend_xcframework() { + local BACKEND_NAME=$1 + local FRAMEWORK_NAME=$2 + + log_step "Creating ${FRAMEWORK_NAME}.xcframework (bundled)..." + + local FOUND_ANY=false + + for PLATFORM in OS SIMULATORARM64 SIMULATOR; do + local PLATFORM_DIR="${BUILD_DIR}/${PLATFORM}" + local FRAMEWORK_DIR="${PLATFORM_DIR}/${FRAMEWORK_NAME}.framework" + + mkdir -p "${FRAMEWORK_DIR}/Headers" + mkdir -p "${FRAMEWORK_DIR}/Modules" + + # Collect all libraries to bundle + local LIBS_TO_BUNDLE=() + + # Backend library - check multiple possible locations + local BACKEND_LIB="" + for possible_path in \ + "${PLATFORM_DIR}/src/backends/${BACKEND_NAME}/librac_backend_${BACKEND_NAME}.a" \ + "${PLATFORM_DIR}/librac_backend_${BACKEND_NAME}.a" \ + "${PLATFORM_DIR}/backends/${BACKEND_NAME}/librac_backend_${BACKEND_NAME}.a"; do + if [[ -f "$possible_path" ]]; then + BACKEND_LIB="$possible_path" + break + fi + done + [[ -n "$BACKEND_LIB" ]] && LIBS_TO_BUNDLE+=("$BACKEND_LIB") + + if [[ "$BACKEND_NAME" == "llamacpp" ]]; then + # Bundle llama.cpp libraries + local LLAMA_BUILD="${PLATFORM_DIR}/src/backends/llamacpp/_deps/llamacpp-build" + [[ ! -d "$LLAMA_BUILD" ]] && LLAMA_BUILD="${PLATFORM_DIR}/_deps/llamacpp-build" + + for lib in llama common ggml ggml-base ggml-cpu ggml-metal ggml-blas; do + local lib_path="" + for possible in \ + "${LLAMA_BUILD}/src/lib${lib}.a" \ + "${LLAMA_BUILD}/common/lib${lib}.a" \ + "${LLAMA_BUILD}/ggml/src/lib${lib}.a" \ + "${LLAMA_BUILD}/ggml/src/ggml-metal/lib${lib}.a" \ + "${LLAMA_BUILD}/ggml/src/ggml-blas/lib${lib}.a" \ + "${LLAMA_BUILD}/ggml/src/ggml-cpu/lib${lib}.a"; do + if [[ -f "$possible" ]]; then + lib_path="$possible" + break + fi + done + [[ -n "$lib_path" ]] && LIBS_TO_BUNDLE+=("$lib_path") + done + elif [[ "$BACKEND_NAME" == "onnx" ]]; then + # Bundle Sherpa-ONNX + local SHERPA_XCFW="${PROJECT_ROOT}/third_party/sherpa-onnx-ios/sherpa-onnx.xcframework" + local SHERPA_ARCH + case $PLATFORM in + OS) SHERPA_ARCH="ios-arm64" ;; + *) SHERPA_ARCH="ios-arm64_x86_64-simulator" ;; + esac + # Try both .a and framework binary + for possible in \ + "${SHERPA_XCFW}/${SHERPA_ARCH}/libsherpa-onnx.a" \ + "${SHERPA_XCFW}/${SHERPA_ARCH}/sherpa-onnx.framework/sherpa-onnx"; do + if [[ -f "$possible" ]]; then + LIBS_TO_BUNDLE+=("$possible") + break + fi + done + fi + + # Bundle all libraries + if [[ ${#LIBS_TO_BUNDLE[@]} -gt 0 ]]; then + log_info " ${PLATFORM}: Bundling ${#LIBS_TO_BUNDLE[@]} libraries" + libtool -static -o "${FRAMEWORK_DIR}/${FRAMEWORK_NAME}" "${LIBS_TO_BUNDLE[@]}" + FOUND_ANY=true + else + log_warn "No libraries found for ${BACKEND_NAME} on ${PLATFORM}" + continue + fi + + # Headers + local header_src="${PROJECT_ROOT}/include/rac/backends/rac_${BACKEND_NAME}.h" + [[ -f "$header_src" ]] && cp "$header_src" "${FRAMEWORK_DIR}/Headers/" + + # Module map and umbrella header + cat > "${FRAMEWORK_DIR}/Modules/module.modulemap" << EOF +framework module ${FRAMEWORK_NAME} { + umbrella header "${FRAMEWORK_NAME}.h" + export * + module * { export * } +} +EOF + echo "// ${FRAMEWORK_NAME}" > "${FRAMEWORK_DIR}/Headers/${FRAMEWORK_NAME}.h" + echo "#include \"rac_${BACKEND_NAME}.h\"" >> "${FRAMEWORK_DIR}/Headers/${FRAMEWORK_NAME}.h" + + # Info.plist + cat > "${FRAMEWORK_DIR}/Info.plist" << EOF + + + + + CFBundleExecutable${FRAMEWORK_NAME} + CFBundleIdentifierai.runanywhere.${FRAMEWORK_NAME} + CFBundlePackageTypeFMWK + CFBundleShortVersionString${VERSION} + MinimumOSVersion${IOS_DEPLOYMENT_TARGET} + + +EOF + done + + if [[ "$FOUND_ANY" == false ]]; then + log_warn "Skipping ${FRAMEWORK_NAME}.xcframework - no libraries found" + return 0 + fi + + # Create fat simulator + local SIM_FAT="${BUILD_DIR}/SIMULATOR_FAT_${FRAMEWORK_NAME}" + rm -rf "${SIM_FAT}" + mkdir -p "${SIM_FAT}" + cp -R "${BUILD_DIR}/SIMULATORARM64/${FRAMEWORK_NAME}.framework" "${SIM_FAT}/" + + lipo -create \ + "${BUILD_DIR}/SIMULATORARM64/${FRAMEWORK_NAME}.framework/${FRAMEWORK_NAME}" \ + "${BUILD_DIR}/SIMULATOR/${FRAMEWORK_NAME}.framework/${FRAMEWORK_NAME}" \ + -output "${SIM_FAT}/${FRAMEWORK_NAME}.framework/${FRAMEWORK_NAME}" 2>/dev/null || true + + # Create XCFramework + local XCFW_PATH="${DIST_DIR}/${FRAMEWORK_NAME}.xcframework" + rm -rf "${XCFW_PATH}" + + if [[ -f "${BUILD_DIR}/OS/${FRAMEWORK_NAME}.framework/${FRAMEWORK_NAME}" ]]; then + xcodebuild -create-xcframework \ + -framework "${BUILD_DIR}/OS/${FRAMEWORK_NAME}.framework" \ + -framework "${SIM_FAT}/${FRAMEWORK_NAME}.framework" \ + -output "${XCFW_PATH}" + + log_info "Created: ${XCFW_PATH}" + echo " Size: $(du -sh "${XCFW_PATH}" | cut -f1)" + else + log_warn "Could not create ${FRAMEWORK_NAME}.xcframework" + fi +} + +# ============================================================================= +# Package for Release +# ============================================================================= + +create_packages() { + log_header "Creating Release Packages" + + local PKG_DIR="${DIST_DIR}/packages" + mkdir -p "${PKG_DIR}" + + for xcfw in "${DIST_DIR}"/*.xcframework; do + if [[ -d "$xcfw" ]]; then + local name=$(basename "$xcfw" .xcframework) + local pkg_name="${name}-ios-v${VERSION}.zip" + log_step "Packaging ${name}..." + cd "${DIST_DIR}" + zip -r "packages/${pkg_name}" "$(basename "$xcfw")" + cd "${PKG_DIR}" + shasum -a 256 "${pkg_name}" > "${pkg_name}.sha256" + cd "${PROJECT_ROOT}" + log_info "Created: ${pkg_name}" + fi + done +} + +# ============================================================================= +# Main +# ============================================================================= + +main() { + log_header "RunAnywhere Commons - iOS Build" + echo "Version: ${VERSION}" + echo "Build Type: ${BUILD_TYPE}" + echo "Backends: ${BUILD_BACKEND}" + echo "Skip Download: ${SKIP_DOWNLOAD}" + echo "Skip Backends: ${SKIP_BACKENDS}" + echo "" + + # Clean if requested + if [[ "$CLEAN_BUILD" == true ]]; then + log_step "Cleaning build directory..." + rm -rf "${BUILD_DIR}" + rm -rf "${DIST_DIR}" + fi + + mkdir -p "${DIST_DIR}" + + # Step 1: Download dependencies + if [[ "$SKIP_DOWNLOAD" != true ]]; then + download_deps + fi + + # Step 2: Build for all platforms + log_header "Building for iOS" + build_platform "OS" + build_platform "SIMULATORARM64" + build_platform "SIMULATOR" + + # Step 3: Create RACommons.xcframework + log_header "Creating XCFrameworks" + create_xcframework "rac_commons" "RACommons" + + # Step 4: Create backend XCFrameworks + if [[ "$SKIP_BACKENDS" != true ]]; then + if [[ "$BUILD_BACKEND" == "all" || "$BUILD_BACKEND" == "llamacpp" ]]; then + create_backend_xcframework "llamacpp" "RABackendLLAMACPP" + fi + if [[ "$BUILD_BACKEND" == "all" || "$BUILD_BACKEND" == "onnx" ]]; then + create_backend_xcframework "onnx" "RABackendONNX" + fi + fi + + # Step 5: Package if requested + if [[ "$CREATE_PACKAGE" == true ]]; then + create_packages + fi + + # Summary + local TOTAL_TIME=$(($(date +%s) - TOTAL_START)) + log_header "Build Complete!" + echo "" + echo "Output: ${DIST_DIR}/" + for xcfw in "${DIST_DIR}"/*.xcframework; do + [[ -d "$xcfw" ]] && echo " $(du -sh "$xcfw" | cut -f1) $(basename "$xcfw")" + done + echo "" + log_time "Total build time: ${TOTAL_TIME}s" +} + +main "$@" diff --git a/sdk/runanywhere-commons/scripts/ios/download-onnx.sh b/sdk/runanywhere-commons/scripts/ios/download-onnx.sh new file mode 100755 index 000000000..757d3bb89 --- /dev/null +++ b/sdk/runanywhere-commons/scripts/ios/download-onnx.sh @@ -0,0 +1,69 @@ +#!/bin/bash +# Download ONNX Runtime iOS xcframework directly from onnxruntime.ai + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)" +ONNX_DIR="${ROOT_DIR}/third_party/onnxruntime-ios" + +# Load versions from centralized VERSIONS file (SINGLE SOURCE OF TRUTH) +source "${SCRIPT_DIR}/../load-versions.sh" + +# Use version from VERSIONS file - no hardcoded fallbacks +if [ -z "${ONNX_VERSION_IOS:-}" ]; then + echo "ERROR: ONNX_VERSION_IOS not loaded from VERSIONS file" >&2 + exit 1 +fi +ONNX_VERSION="${ONNX_VERSION_IOS}" +DOWNLOAD_URL="https://download.onnxruntime.ai/pod-archive-onnxruntime-c-${ONNX_VERSION}.zip" + +echo "Downloading ONNX Runtime iOS xcframework v${ONNX_VERSION}..." + +# Create temp directory for download +TEMP_DIR=$(mktemp -d) +TEMP_ZIP="${TEMP_DIR}/onnxruntime.zip" + +# Download the xcframework directly +echo "Downloading from ${DOWNLOAD_URL}..." +curl -L --progress-bar -o "${TEMP_ZIP}" "${DOWNLOAD_URL}" + +# Verify download +if [ ! -f "${TEMP_ZIP}" ]; then + echo "Error: Download failed" + exit 1 +fi + +echo "Download complete. Size: $(du -h "${TEMP_ZIP}" | cut -f1)" + +# Extract the xcframework +echo "Extracting xcframework..." +rm -rf "${ONNX_DIR}" +mkdir -p "${ONNX_DIR}" + +# Unzip to temp directory first +unzip -q "${TEMP_ZIP}" -d "${TEMP_DIR}/extracted" + +# Find and copy the xcframework +XCFRAMEWORK=$(find "${TEMP_DIR}/extracted" -name "onnxruntime.xcframework" -type d | head -1) +if [ -z "${XCFRAMEWORK}" ]; then + echo "Error: onnxruntime.xcframework not found in archive" + ls -R "${TEMP_DIR}/extracted" + exit 1 +fi + +cp -R "${XCFRAMEWORK}" "${ONNX_DIR}/" + +# Also copy headers if they exist at the top level +if [ -d "${TEMP_DIR}/extracted/Headers" ]; then + cp -R "${TEMP_DIR}/extracted/Headers" "${ONNX_DIR}/" +fi + +# Clean up +rm -rf "${TEMP_DIR}" + +echo "" +echo "✅ ONNX Runtime xcframework downloaded to ${ONNX_DIR}/onnxruntime.xcframework" +echo "" +echo "Contents:" +ls -lh "${ONNX_DIR}/onnxruntime.xcframework" diff --git a/sdk/runanywhere-commons/scripts/ios/download-sherpa-onnx.sh b/sdk/runanywhere-commons/scripts/ios/download-sherpa-onnx.sh new file mode 100755 index 000000000..eaeb5404c --- /dev/null +++ b/sdk/runanywhere-commons/scripts/ios/download-sherpa-onnx.sh @@ -0,0 +1,133 @@ +#!/bin/bash +# Download Sherpa-ONNX iOS xcframework +# +# Since Sherpa-ONNX doesn't provide pre-built iOS binaries, we host our own +# built version on the runanywhere-binaries releases. +# +# To update: Build locally with build-sherpa-onnx-ios.sh and upload to releases + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)" +SHERPA_DIR="${ROOT_DIR}/third_party/sherpa-onnx-ios" + +# Load versions from centralized VERSIONS file (SINGLE SOURCE OF TRUTH) +source "${SCRIPT_DIR}/../load-versions.sh" + +# Use version from VERSIONS file - no hardcoded fallbacks +if [ -z "${SHERPA_ONNX_VERSION_IOS:-}" ]; then + echo "ERROR: SHERPA_ONNX_VERSION_IOS not loaded from VERSIONS file" >&2 + exit 1 +fi +SHERPA_VERSION="${SHERPA_ONNX_VERSION_IOS}" +# Try runanywhere-sdks first, fallback to runanywhere-binaries +DOWNLOAD_URL="https://github.com/RunanywhereAI/runanywhere-binaries/releases/download/sherpa-onnx-v${SHERPA_VERSION}/sherpa-onnx.xcframework.zip" + +# Alternative: Build from source if download fails +BUILD_FROM_SOURCE=false + +echo "=======================================" +echo "📦 Sherpa-ONNX iOS XCFramework Downloader" +echo "=======================================" +echo "" +echo "Version: ${SHERPA_VERSION}" + +# Check if already exists and is valid +if [ -d "${SHERPA_DIR}/sherpa-onnx.xcframework" ]; then + # Verify it has the static libraries + if [ -f "${SHERPA_DIR}/sherpa-onnx.xcframework/ios-arm64/libsherpa-onnx.a" ] && \ + [ -f "${SHERPA_DIR}/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/libsherpa-onnx.a" ]; then + echo "✅ Sherpa-ONNX xcframework already exists and appears valid" + echo " Location: ${SHERPA_DIR}/sherpa-onnx.xcframework" + echo "" + echo "To force re-download, remove the directory first:" + echo " rm -rf ${SHERPA_DIR}/sherpa-onnx.xcframework" + exit 0 + else + echo "⚠️ Existing xcframework appears incomplete, re-downloading..." + rm -rf "${SHERPA_DIR}/sherpa-onnx.xcframework" + fi +fi + +# Create temp directory for download +TEMP_DIR=$(mktemp -d) +TEMP_ZIP="${TEMP_DIR}/sherpa-onnx.xcframework.zip" + +echo "" +echo "Downloading from ${DOWNLOAD_URL}..." + +# Try to download pre-built version +HTTP_CODE=$(curl -L -w "%{http_code}" -o "${TEMP_ZIP}" "${DOWNLOAD_URL}" 2>/dev/null) || true + +if [ "${HTTP_CODE}" = "200" ] && [ -f "${TEMP_ZIP}" ] && [ -s "${TEMP_ZIP}" ]; then + echo "Download complete. Size: $(du -h "${TEMP_ZIP}" | cut -f1)" + + # Extract the xcframework + echo "Extracting xcframework..." + mkdir -p "${SHERPA_DIR}" + + # Unzip to temp directory first + unzip -q "${TEMP_ZIP}" -d "${TEMP_DIR}/extracted" + + # Find and copy the xcframework + XCFRAMEWORK=$(find "${TEMP_DIR}/extracted" -name "sherpa-onnx.xcframework" -type d | head -1) + if [ -z "${XCFRAMEWORK}" ]; then + echo "Error: sherpa-onnx.xcframework not found in archive" + ls -R "${TEMP_DIR}/extracted" + rm -rf "${TEMP_DIR}" + exit 1 + fi + + cp -R "${XCFRAMEWORK}" "${SHERPA_DIR}/" + + # Clean up + rm -rf "${TEMP_DIR}" + + echo "" + echo "✅ Sherpa-ONNX xcframework downloaded to ${SHERPA_DIR}/sherpa-onnx.xcframework" + echo "" + echo "Contents:" + ls -lh "${SHERPA_DIR}/sherpa-onnx.xcframework" +else + echo "" + echo "⚠️ Pre-built Sherpa-ONNX not available for download (HTTP: ${HTTP_CODE})" + echo "" + + # Clean up failed download + rm -rf "${TEMP_DIR}" + + if [ "${BUILD_FROM_SOURCE}" = "true" ]; then + echo "Falling back to building from source..." + echo "This will take several minutes..." + echo "" + + # Check if the build script exists + BUILD_SCRIPT="${SCRIPT_DIR}/build-sherpa-onnx-ios.sh" + if [ -f "${BUILD_SCRIPT}" ]; then + exec "${BUILD_SCRIPT}" + else + echo "Error: Build script not found at ${BUILD_SCRIPT}" + exit 1 + fi + else + echo "==============================================" + echo "❌ Sherpa-ONNX download failed" + echo "==============================================" + echo "" + echo "Options:" + echo "" + echo "1. Upload pre-built Sherpa-ONNX to runanywhere-binaries:" + echo " - Build locally: ./scripts/build-sherpa-onnx-ios.sh" + echo " - Create zip: cd third_party/sherpa-onnx-ios && zip -r sherpa-onnx.xcframework.zip sherpa-onnx.xcframework" + echo " - Create release: sherpa-onnx-v${SHERPA_VERSION} on runanywhere-binaries" + echo " - Upload the zip file" + echo "" + echo "2. Build from source (slow, ~10-15 minutes):" + echo " ./src/backends/onnx/scripts/build-sherpa-onnx-ios.sh" + echo "" + echo "3. Set BUILD_FROM_SOURCE=true in this script to auto-build" + echo "" + exit 1 + fi +fi diff --git a/sdk/runanywhere-commons/scripts/load-versions.sh b/sdk/runanywhere-commons/scripts/load-versions.sh new file mode 100755 index 000000000..10b105552 --- /dev/null +++ b/sdk/runanywhere-commons/scripts/load-versions.sh @@ -0,0 +1,75 @@ +#!/bin/bash +# ============================================================================= +# Load versions from VERSIONS file +# ============================================================================= +# Usage: source scripts/load-versions.sh +# +# This script exports all version variables from the VERSIONS file. +# After sourcing, you can use variables like: +# $ONNX_VERSION_IOS +# $SHERPA_ONNX_VERSION_IOS +# $IOS_DEPLOYMENT_TARGET +# $ANDROID_MIN_SDK +# etc. +# +# The VERSIONS file is the SINGLE SOURCE OF TRUTH for all versions. +# DO NOT hardcode version fallbacks in scripts - always source this file. +# ============================================================================= + +# Find the VERSIONS file - look relative to this script's location +# Handle being sourced from any directory +_SCRIPT_PATH="${BASH_SOURCE[0]:-$0}" +if [[ "$_SCRIPT_PATH" != /* ]]; then + # Relative path - make it absolute + _SCRIPT_PATH="$(pwd)/$_SCRIPT_PATH" +fi +_SCRIPT_DIR="$(cd "$(dirname "$_SCRIPT_PATH")" && pwd)" +_ROOT_DIR="$(cd "$_SCRIPT_DIR/.." && pwd)" +VERSIONS_FILE="$_ROOT_DIR/VERSIONS" + +if [ ! -f "${VERSIONS_FILE}" ]; then + echo "ERROR: VERSIONS file not found at ${VERSIONS_FILE}" >&2 + return 1 2>/dev/null || exit 1 +fi + +# Read and export all KEY=VALUE pairs (skip comments and empty lines) +while IFS='=' read -r key value; do + # Skip comments and empty lines + [[ "$key" =~ ^#.*$ ]] && continue + [[ -z "$key" ]] && continue + + # Remove leading/trailing whitespace + key=$(echo "$key" | xargs) + value=$(echo "$value" | xargs) + + # Skip if key is empty after trimming + [[ -z "$key" ]] && continue + + # Export the variable + export "$key"="$value" +done < "${VERSIONS_FILE}" + +# Print loaded versions if VERBOSE is set +if [ "${VERBOSE:-}" = "1" ]; then + echo "Loaded versions from ${VERSIONS_FILE}:" + echo " Platform targets:" + echo " IOS_DEPLOYMENT_TARGET=${IOS_DEPLOYMENT_TARGET}" + echo " ANDROID_MIN_SDK=${ANDROID_MIN_SDK}" + echo " XCODE_VERSION=${XCODE_VERSION}" + echo " ONNX Runtime:" + echo " ONNX_VERSION_IOS=${ONNX_VERSION_IOS}" + echo " ONNX_VERSION_ANDROID=${ONNX_VERSION_ANDROID}" + echo " ONNX_VERSION_MACOS=${ONNX_VERSION_MACOS}" + echo " ONNX_VERSION_LINUX=${ONNX_VERSION_LINUX}" + echo " Sherpa-ONNX:" + echo " SHERPA_ONNX_VERSION_IOS=${SHERPA_ONNX_VERSION_IOS}" + echo " SHERPA_ONNX_VERSION_ANDROID=${SHERPA_ONNX_VERSION_ANDROID}" + echo " SHERPA_ONNX_VERSION_MACOS=${SHERPA_ONNX_VERSION_MACOS}" + echo " Other:" + echo " LLAMACPP_VERSION=${LLAMACPP_VERSION}" + echo " NLOHMANN_JSON_VERSION=${NLOHMANN_JSON_VERSION}" + echo " RAC_COMMONS_VERSION=${RAC_COMMONS_VERSION}" +fi + +# Clean up temporary variables +unset _SCRIPT_PATH _SCRIPT_DIR _ROOT_DIR diff --git a/sdk/runanywhere-commons/src/backends/llamacpp/CMakeLists.txt b/sdk/runanywhere-commons/src/backends/llamacpp/CMakeLists.txt new file mode 100644 index 000000000..70f3204ae --- /dev/null +++ b/sdk/runanywhere-commons/src/backends/llamacpp/CMakeLists.txt @@ -0,0 +1,194 @@ +# ============================================================================= +# LlamaCPP Backend - Text Generation via llama.cpp +# ============================================================================= + +message(STATUS "Configuring LlamaCPP backend...") + +# ============================================================================= +# Fetch llama.cpp +# ============================================================================= + +include(FetchContent) +include(LoadVersions) + +if(NOT DEFINED LLAMACPP_VERSION) + set(LLAMACPP_VERSION "b7199") +endif() +set(LLAMA_CPP_VERSION "${LLAMACPP_VERSION}") + +FetchContent_Declare( + llamacpp + GIT_REPOSITORY https://github.com/ggerganov/llama.cpp.git + GIT_TAG ${LLAMA_CPP_VERSION} + GIT_SHALLOW TRUE + GIT_PROGRESS TRUE +) + +# Configure llama.cpp build options +set(LLAMA_BUILD_TESTS OFF CACHE BOOL "" FORCE) +set(LLAMA_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) +set(LLAMA_BUILD_SERVER OFF CACHE BOOL "" FORCE) +set(LLAMA_CURL OFF CACHE BOOL "" FORCE) +set(LLAMA_HTTPLIB OFF CACHE BOOL "" FORCE) +set(LLAMA_BUILD_COMMON ON CACHE BOOL "" FORCE) +set(GGML_LLAMAFILE OFF CACHE BOOL "" FORCE) + +# Platform-specific optimizations +if(RAC_PLATFORM_IOS) + set(GGML_METAL ON CACHE BOOL "" FORCE) + set(GGML_ACCELERATE ON CACHE BOOL "" FORCE) + set(GGML_NEON ON CACHE BOOL "" FORCE) + set(GGML_METAL_EMBED_LIBRARY ON CACHE BOOL "" FORCE) # Embed precompiled Metal shaders +elseif(RAC_PLATFORM_ANDROID) + # Disable features not available on Android + set(GGML_METAL OFF CACHE BOOL "" FORCE) + set(GGML_VULKAN OFF CACHE BOOL "" FORCE) + set(GGML_CUDA OFF CACHE BOOL "" FORCE) + set(GGML_OPENCL OFF CACHE BOOL "" FORCE) + set(GGML_HIPBLAS OFF CACHE BOOL "" FORCE) + set(GGML_SYCL OFF CACHE BOOL "" FORCE) + set(GGML_KOMPUTE OFF CACHE BOOL "" FORCE) + set(GGML_RPC OFF CACHE BOOL "" FORCE) + + # CRITICAL: Disable native CPU detection (fails during cross-compilation) + set(GGML_NATIVE OFF CACHE BOOL "" FORCE) + + # Enable ARM NEON only for ARM architectures (not x86/x86_64) + if(ANDROID_ABI MATCHES "arm64-v8a|armeabi-v7a") + set(GGML_NEON ON CACHE BOOL "" FORCE) + message(STATUS "Enabling NEON for ARM ABI: ${ANDROID_ABI}") + else() + set(GGML_NEON OFF CACHE BOOL "" FORCE) + # x86/x86_64 will use SSE/AVX automatically + message(STATUS "Disabling NEON for non-ARM ABI: ${ANDROID_ABI}") + endif() + + # Android-specific settings + set(ANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES ON CACHE BOOL "" FORCE) + set(GGML_CPU_HBM OFF CACHE BOOL "" FORCE) + + # Disable openmp to avoid Android threading issues + set(GGML_OPENMP OFF CACHE BOOL "" FORCE) +elseif(RAC_PLATFORM_MACOS) + set(GGML_METAL ON CACHE BOOL "" FORCE) + set(GGML_ACCELERATE ON CACHE BOOL "" FORCE) + set(GGML_METAL_EMBED_LIBRARY ON CACHE BOOL "" FORCE) # Embed precompiled Metal shaders +endif() + +set(BUILD_SHARED_LIBS OFF CACHE BOOL "Force static libraries for llama.cpp" FORCE) + +FetchContent_MakeAvailable(llamacpp) + +# ============================================================================= +# LlamaCPP Backend Library +# ============================================================================= + +set(LLAMACPP_BACKEND_SOURCES + llamacpp_backend.cpp + rac_llm_llamacpp.cpp + rac_backend_llamacpp_register.cpp +) + +set(LLAMACPP_BACKEND_HEADERS + llamacpp_backend.h +) + +if(RAC_BUILD_SHARED) + add_library(rac_backend_llamacpp SHARED + ${LLAMACPP_BACKEND_SOURCES} + ${LLAMACPP_BACKEND_HEADERS} + ) +else() + add_library(rac_backend_llamacpp STATIC + ${LLAMACPP_BACKEND_SOURCES} + ${LLAMACPP_BACKEND_HEADERS} + ) +endif() + +target_include_directories(rac_backend_llamacpp PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_SOURCE_DIR}/include + ${CMAKE_SOURCE_DIR}/include/rac/backends + ${llamacpp_SOURCE_DIR}/include + ${llamacpp_SOURCE_DIR}/common + ${llamacpp_SOURCE_DIR}/ggml/include + ${llamacpp_SOURCE_DIR}/vendor # nlohmann/json.hpp +) + +target_compile_definitions(rac_backend_llamacpp PRIVATE RAC_LLAMACPP_BUILDING) + +target_link_libraries(rac_backend_llamacpp PUBLIC + rac_commons + llama + common +) + +target_compile_features(rac_backend_llamacpp PUBLIC cxx_std_17) + +# ============================================================================= +# Platform-specific configuration +# ============================================================================= + +if(RAC_PLATFORM_IOS) + message(STATUS "Configuring LlamaCPP backend for iOS") + target_link_libraries(rac_backend_llamacpp PUBLIC + "-framework Foundation" + "-framework Accelerate" + "-framework Metal" + "-framework MetalKit" + ) + target_compile_definitions(rac_backend_llamacpp PRIVATE GGML_USE_METAL=1) + +elseif(RAC_PLATFORM_ANDROID) + message(STATUS "Configuring LlamaCPP backend for Android") + target_link_libraries(rac_backend_llamacpp PRIVATE log) + # Don't use -fvisibility=hidden here - JNI bridge needs these symbols + target_compile_options(rac_backend_llamacpp PRIVATE -O3 -ffunction-sections -fdata-sections) + # 16KB page alignment for Android 15+ (API 35) compliance - required Nov 2025 + target_link_options(rac_backend_llamacpp PRIVATE -Wl,--gc-sections -Wl,-z,max-page-size=16384) + +elseif(RAC_PLATFORM_MACOS) + message(STATUS "Configuring LlamaCPP backend for macOS") + target_link_libraries(rac_backend_llamacpp PUBLIC + "-framework Foundation" + "-framework Accelerate" + "-framework Metal" + "-framework MetalKit" + ) +endif() + +# ============================================================================= +# JNI TARGET (Android) +# ============================================================================= + +if(RAC_PLATFORM_ANDROID AND RAC_BUILD_SHARED) + if(ANDROID) + message(STATUS "Building LlamaCPP JNI bridge for Android") + + add_library(rac_backend_llamacpp_jni SHARED + jni/rac_backend_llamacpp_jni.cpp + ) + + target_include_directories(rac_backend_llamacpp_jni PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_SOURCE_DIR}/include + ) + + target_link_libraries(rac_backend_llamacpp_jni PRIVATE + rac_backend_llamacpp + log + ) + + target_compile_options(rac_backend_llamacpp_jni PRIVATE -O3 -fvisibility=hidden -ffunction-sections -fdata-sections) + # 16KB page alignment for Android 15+ (API 35) compliance - required Nov 2025 + target_link_options(rac_backend_llamacpp_jni PRIVATE -Wl,--gc-sections -Wl,-z,max-page-size=16384) + endif() +endif() + +# ============================================================================= +# Summary +# ============================================================================= + +message(STATUS "LlamaCPP Backend Configuration:") +message(STATUS " llama.cpp version: ${LLAMA_CPP_VERSION}") +message(STATUS " Platform: ${RAC_PLATFORM_NAME}") diff --git a/sdk/runanywhere-commons/src/backends/llamacpp/jni/rac_backend_llamacpp_jni.cpp b/sdk/runanywhere-commons/src/backends/llamacpp/jni/rac_backend_llamacpp_jni.cpp new file mode 100644 index 000000000..1b32267ac --- /dev/null +++ b/sdk/runanywhere-commons/src/backends/llamacpp/jni/rac_backend_llamacpp_jni.cpp @@ -0,0 +1,264 @@ +/** + * LlamaCPP Backend JNI Bridge + * + * JNI layer for the LlamaCPP backend. Links against rac_commons for the + * full service registry and infrastructure support. + * + * This JNI library is linked by: + * Kotlin: runanywhere-kotlin/modules/runanywhere-core-llamacpp + * + * Package: com.runanywhere.sdk.llm.llamacpp + * Class: LlamaCPPBridge + */ + +#include +#include +#include + +#ifdef __ANDROID__ +#include +#define TAG "RACLlamaCPPJNI" +#define LOGi(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__) +#define LOGe(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__) +#define LOGw(...) __android_log_print(ANDROID_LOG_WARN, TAG, __VA_ARGS__) +#else +#include +#define LOGi(...) fprintf(stdout, "[INFO] " __VA_ARGS__); fprintf(stdout, "\n") +#define LOGe(...) fprintf(stderr, "[ERROR] " __VA_ARGS__); fprintf(stderr, "\n") +#define LOGw(...) fprintf(stdout, "[WARN] " __VA_ARGS__); fprintf(stdout, "\n") +#endif + +// Include LlamaCPP backend header (direct API) +#include "rac_llm_llamacpp.h" + +// Include commons for registration and service lookup +#include "rac/core/rac_core.h" +#include "rac/core/rac_error.h" + +// Forward declaration for registration function +extern "C" rac_result_t rac_backend_llamacpp_register(void); +extern "C" rac_result_t rac_backend_llamacpp_unregister(void); + +extern "C" { + +// ============================================================================= +// JNI_OnLoad - Called when native library is loaded +// ============================================================================= + +JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) { + (void)vm; + (void)reserved; + LOGi("JNI_OnLoad: rac_backend_llamacpp_jni loaded"); + return JNI_VERSION_1_6; +} + +// ============================================================================= +// Backend Registration +// ============================================================================= + +/** + * Register the LlamaCPP backend with the C++ service registry. + */ +JNIEXPORT jint JNICALL +Java_com_runanywhere_sdk_llm_llamacpp_LlamaCPPBridge_nativeRegister(JNIEnv* env, jclass clazz) { + (void)env; + (void)clazz; + LOGi("LlamaCPP nativeRegister called"); + + rac_result_t result = rac_backend_llamacpp_register(); + + if (result != RAC_SUCCESS && result != RAC_ERROR_MODULE_ALREADY_REGISTERED) { + LOGe("Failed to register LlamaCPP backend: %d", result); + return static_cast(result); + } + + LOGi("LlamaCPP backend registered successfully"); + return RAC_SUCCESS; +} + +/** + * Unregister the LlamaCPP backend from the C++ service registry. + */ +JNIEXPORT jint JNICALL +Java_com_runanywhere_sdk_llm_llamacpp_LlamaCPPBridge_nativeUnregister(JNIEnv* env, jclass clazz) { + (void)env; + (void)clazz; + LOGi("LlamaCPP nativeUnregister called"); + + rac_result_t result = rac_backend_llamacpp_unregister(); + + if (result != RAC_SUCCESS) { + LOGe("Failed to unregister LlamaCPP backend: %d", result); + } else { + LOGi("LlamaCPP backend unregistered"); + } + + return static_cast(result); +} + +/** + * Check if the LlamaCPP backend is registered. + */ +JNIEXPORT jboolean JNICALL +Java_com_runanywhere_sdk_llm_llamacpp_LlamaCPPBridge_nativeIsRegistered(JNIEnv* env, jclass clazz) { + (void)env; + (void)clazz; + // Check by attempting to use the service + // For now, return true if the native library is loaded + return JNI_TRUE; +} + +/** + * Get the LlamaCPP library version. + */ +JNIEXPORT jstring JNICALL +Java_com_runanywhere_sdk_llm_llamacpp_LlamaCPPBridge_nativeGetVersion(JNIEnv* env, jclass clazz) { + (void)clazz; + return env->NewStringUTF("b7199"); +} + +// ============================================================================= +// LLM Operations - Direct API calls +// ============================================================================= + +/** + * Create a LlamaCPP instance and load a model + */ +JNIEXPORT jlong JNICALL +Java_com_runanywhere_sdk_llm_llamacpp_LlamaCPPBridge_nativeCreate( + JNIEnv* env, jclass clazz, + jstring modelPath, jint contextSize, jint numThreads, jint gpuLayers) { + (void)clazz; + + const char* path = env->GetStringUTFChars(modelPath, nullptr); + if (!path) { + LOGe("nativeCreate: Failed to get model path"); + return 0; + } + + LOGi("nativeCreate: model=%s, ctx=%d, threads=%d, gpu=%d", path, contextSize, numThreads, gpuLayers); + + rac_llm_llamacpp_config_t config = RAC_LLM_LLAMACPP_CONFIG_DEFAULT; + config.context_size = contextSize; + config.num_threads = numThreads; + config.gpu_layers = gpuLayers; + + rac_handle_t handle = nullptr; + rac_result_t result = rac_llm_llamacpp_create(path, &config, &handle); + + env->ReleaseStringUTFChars(modelPath, path); + + if (result != RAC_SUCCESS) { + LOGe("nativeCreate: Failed with result %d", result); + return 0; + } + + LOGi("nativeCreate: Success, handle=%p", handle); + return reinterpret_cast(handle); +} + +/** + * Destroy a LlamaCPP instance + */ +JNIEXPORT void JNICALL +Java_com_runanywhere_sdk_llm_llamacpp_LlamaCPPBridge_nativeDestroy( + JNIEnv* env, jclass clazz, jlong handle) { + (void)env; + (void)clazz; + + if (handle == 0) return; + + LOGi("nativeDestroy: handle=%p", reinterpret_cast(handle)); + rac_llm_llamacpp_destroy(reinterpret_cast(handle)); +} + +/** + * Generate text (blocking) + */ +JNIEXPORT jstring JNICALL +Java_com_runanywhere_sdk_llm_llamacpp_LlamaCPPBridge_nativeGenerate( + JNIEnv* env, jclass clazz, + jlong handle, jstring prompt, jint maxTokens, jfloat temperature) { + (void)clazz; + + if (handle == 0) { + LOGe("nativeGenerate: Invalid handle"); + return nullptr; + } + + const char* promptStr = env->GetStringUTFChars(prompt, nullptr); + if (!promptStr) { + LOGe("nativeGenerate: Failed to get prompt"); + return nullptr; + } + + LOGi("nativeGenerate: prompt_len=%zu, max_tokens=%d, temp=%.2f", + strlen(promptStr), maxTokens, temperature); + + rac_llm_options_t options = RAC_LLM_OPTIONS_DEFAULT; + options.max_tokens = maxTokens; + options.temperature = temperature; + + rac_llm_result_t result = {}; + rac_result_t status = rac_llm_llamacpp_generate( + reinterpret_cast(handle), + promptStr, &options, &result); + + env->ReleaseStringUTFChars(prompt, promptStr); + + if (status != RAC_SUCCESS) { + LOGe("nativeGenerate: Failed with status %d", status); + return nullptr; + } + + jstring output = nullptr; + if (result.text) { + output = env->NewStringUTF(result.text); + LOGi("nativeGenerate: Success, output_len=%zu", strlen(result.text)); + // Free the allocated text + free((void*)result.text); + } + + return output; +} + +/** + * Cancel ongoing generation + */ +JNIEXPORT void JNICALL +Java_com_runanywhere_sdk_llm_llamacpp_LlamaCPPBridge_nativeCancel( + JNIEnv* env, jclass clazz, jlong handle) { + (void)env; + (void)clazz; + + if (handle == 0) return; + + LOGi("nativeCancel: handle=%p", reinterpret_cast(handle)); + rac_llm_llamacpp_cancel(reinterpret_cast(handle)); +} + +/** + * Get model info as JSON + */ +JNIEXPORT jstring JNICALL +Java_com_runanywhere_sdk_llm_llamacpp_LlamaCPPBridge_nativeGetModelInfo( + JNIEnv* env, jclass clazz, jlong handle) { + (void)clazz; + + if (handle == 0) return nullptr; + + char* json = nullptr; + rac_result_t status = rac_llm_llamacpp_get_model_info( + reinterpret_cast(handle), &json); + + if (status != RAC_SUCCESS || !json) { + return nullptr; + } + + jstring result = env->NewStringUTF(json); + free(json); + + return result; +} + +} // extern "C" diff --git a/sdk/runanywhere-commons/src/backends/llamacpp/llamacpp_backend.cpp b/sdk/runanywhere-commons/src/backends/llamacpp/llamacpp_backend.cpp new file mode 100644 index 000000000..addc354b5 --- /dev/null +++ b/sdk/runanywhere-commons/src/backends/llamacpp/llamacpp_backend.cpp @@ -0,0 +1,803 @@ +#include "llamacpp_backend.h" + +#include "common.h" + +#include +#include +#include +#include +#include + +#include "rac/core/rac_logger.h" + +// Use the RAC logging system +#define LOGI(...) RAC_LOG_INFO("LLM.LlamaCpp", __VA_ARGS__) +#define LOGE(...) RAC_LOG_ERROR("LLM.LlamaCpp", __VA_ARGS__) + +namespace runanywhere { + +// UTF-8 STATE MACHINE (DFA) + +struct Utf8State { + + uint32_t state = 0; + + // Bjoern Hoehrmann LUT + bool process(uint8_t byte) { + static const uint8_t utf8d[] = { + 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, // 00..1f + 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, // 20..3f + 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, // 40..5f + 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, // 60..7f + 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9, // 80..9f + 7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7, // a0..bf + 8,8,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2, // c0..df + 0xa,0x3,0x3,0x3,0x3,0x3,0x3,0x3,0x3,0x3,0x3,0x3,0x3,0x4,0x3,0x3, // e0..ef + 0xb,0x6,0x6,0x6,0x5,0x8,0x8,0x8,0x8,0x8,0x8,0x8,0x8,0x8,0x8,0x8, // f0..ff + 0x0,0x1,0x2,0x3,0x5,0x8,0x7,0x1,0x1,0x1,0x4,0x6,0x1,0x1,0x1,0x1, // s0..s0 + 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,1,1,1,1,1,0,1,0,1,1,1,1,1,1, // s1..s2 + 1,2,1,1,1,1,1,2,1,2,1,1,1,1,1,1,1,1,1,1,1,1,1,2,1,1,1,1,1,1,1,1, // s3..s4 + 1,2,1,1,1,1,1,1,1,2,1,1,1,1,1,1,1,1,1,1,1,1,1,3,1,3,1,1,1,1,1,1, // s5..s6 + 1,3,1,1,1,1,1,3,1,3,1,1,1,1,1,1,1,3,1,1,1,1,1,1,1,1,1,1,1,1,1,1, // s7..s8 + }; + + uint32_t type = utf8d[byte]; + state = utf8d[256 + state * 16 + type]; + return (state == 0); + } + + void reset() { state = 0; } +}; + +// ============================================================================= +// LOG CALLBACK +// ============================================================================= + +static void llama_log_callback(ggml_log_level level, const char* fmt, void* data) { + (void)data; + + std::string msg(fmt ? fmt : ""); + while (!msg.empty() && (msg.back() == '\n' || msg.back() == '\r')) { + msg.pop_back(); + } + if (msg.empty()) + return; + + if (level == GGML_LOG_LEVEL_ERROR) { + RAC_LOG_ERROR("LLM.LlamaCpp.GGML", "%s", msg.c_str()); + } else if (level == GGML_LOG_LEVEL_WARN) { + RAC_LOG_WARNING("LLM.LlamaCpp.GGML", "%s", msg.c_str()); + } else if (level == GGML_LOG_LEVEL_INFO) { + RAC_LOG_DEBUG("LLM.LlamaCpp.GGML", "%s", msg.c_str()); + } +} + +// ============================================================================= +// LLAMACPP BACKEND IMPLEMENTATION +// ============================================================================= + +LlamaCppBackend::LlamaCppBackend() { + LOGI("LlamaCppBackend created"); +} + +LlamaCppBackend::~LlamaCppBackend() { + cleanup(); + LOGI("LlamaCppBackend destroyed"); +} + +bool LlamaCppBackend::initialize(const nlohmann::json& config) { + std::lock_guard lock(mutex_); + + if (initialized_) { + LOGI("LlamaCppBackend already initialized"); + return true; + } + + config_ = config; + + llama_backend_init(); + llama_log_set(llama_log_callback, nullptr); + + if (config.contains("num_threads")) { + num_threads_ = config["num_threads"].get(); + } + + if (num_threads_ <= 0) { +#ifdef _SC_NPROCESSORS_ONLN + num_threads_ = std::max(1, std::min(8, (int)sysconf(_SC_NPROCESSORS_ONLN) - 2)); +#else + num_threads_ = 4; +#endif + } + + LOGI("LlamaCppBackend initialized with %d threads", num_threads_); + + create_text_generation(); + + initialized_ = true; + return true; +} + +bool LlamaCppBackend::is_initialized() const { + return initialized_; +} + +void LlamaCppBackend::cleanup() { + std::lock_guard lock(mutex_); + + if (!initialized_) { + return; + } + + text_gen_.reset(); + llama_backend_free(); + + initialized_ = false; + LOGI("LlamaCppBackend cleaned up"); +} + +DeviceType LlamaCppBackend::get_device_type() const { +#if defined(GGML_USE_METAL) + return DeviceType::METAL; +#elif defined(GGML_USE_CUDA) + return DeviceType::CUDA; +#else + return DeviceType::CPU; +#endif +} + +size_t LlamaCppBackend::get_memory_usage() const { + return 0; +} + +void LlamaCppBackend::create_text_generation() { + text_gen_ = std::make_unique(this); + LOGI("Created text generation component"); +} + +// ============================================================================= +// TEXT GENERATION IMPLEMENTATION +// ============================================================================= + +LlamaCppTextGeneration::LlamaCppTextGeneration(LlamaCppBackend* backend) : backend_(backend) { + LOGI("LlamaCppTextGeneration created"); +} + +LlamaCppTextGeneration::~LlamaCppTextGeneration() { + unload_model(); + LOGI("LlamaCppTextGeneration destroyed"); +} + +bool LlamaCppTextGeneration::is_ready() const { + return model_loaded_ && model_ != nullptr && context_ != nullptr; +} + +bool LlamaCppTextGeneration::load_model(const std::string& model_path, + const nlohmann::json& config) { + std::lock_guard lock(mutex_); + + if (model_loaded_) { + LOGI("Unloading previous model before loading new one"); + unload_model_internal(); + } + + LOGI("Loading model from: %s", model_path.c_str()); + + int user_context_size = 0; + if (config.contains("context_size")) { + user_context_size = config["context_size"].get(); + } + if (config.contains("max_context_size")) { + max_default_context_ = config["max_context_size"].get(); + } + if (config.contains("temperature")) { + temperature_ = config["temperature"].get(); + } + if (config.contains("min_p")) { + min_p_ = config["min_p"].get(); + } + if (config.contains("top_p")) { + top_p_ = config["top_p"].get(); + } + if (config.contains("top_k")) { + top_k_ = config["top_k"].get(); + } + + model_config_ = config; + model_path_ = model_path; + + llama_model_params model_params = llama_model_default_params(); + model_ = llama_model_load_from_file(model_path.c_str(), model_params); + + if (!model_) { + LOGE("Failed to load model from: %s", model_path.c_str()); + return false; + } + + int model_train_ctx = llama_model_n_ctx_train(model_); + LOGI("Model training context size: %d", model_train_ctx); + + if (user_context_size > 0) { + context_size_ = std::min(user_context_size, model_train_ctx); + LOGI("Using user-provided context size: %d (requested: %d, model max: %d)", context_size_, + user_context_size, model_train_ctx); + } else { + context_size_ = std::min(model_train_ctx, max_default_context_); + LOGI("Auto-detected context size: %d (model: %d, cap: %d)", context_size_, model_train_ctx, + max_default_context_); + } + + llama_context_params ctx_params = llama_context_default_params(); + ctx_params.n_ctx = context_size_; + ctx_params.n_batch = std::min(context_size_, 512); + ctx_params.n_threads = backend_->get_num_threads(); + ctx_params.n_threads_batch = backend_->get_num_threads(); + ctx_params.no_perf = true; + + context_ = llama_init_from_model(model_, ctx_params); + + if (!context_) { + LOGE("Failed to create context"); + llama_model_free(model_); + model_ = nullptr; + return false; + } + + auto sparams = llama_sampler_chain_default_params(); + sparams.no_perf = true; + sampler_ = llama_sampler_chain_init(sparams); + + if (temperature_ > 0.0f) { + llama_sampler_chain_add(sampler_, llama_sampler_init_penalties(64, 1.2f, 0.0f, 0.0f)); + + if (top_k_ > 0) { + llama_sampler_chain_add(sampler_, llama_sampler_init_top_k(top_k_)); + } + + llama_sampler_chain_add(sampler_, llama_sampler_init_top_p(top_p_, 1)); + llama_sampler_chain_add(sampler_, llama_sampler_init_temp(temperature_)); + llama_sampler_chain_add(sampler_, llama_sampler_init_dist(LLAMA_DEFAULT_SEED)); + } else { + llama_sampler_chain_add(sampler_, llama_sampler_init_greedy()); + } + + LOGI("Sampler chain: penalties(64,1.2) -> top_k(%d) -> top_p(%.2f) -> temp(%.2f) -> dist", + top_k_, top_p_, temperature_); + + model_loaded_ = true; + LOGI("Model loaded successfully: context_size=%d, temp=%.2f", context_size_, temperature_); + + return true; +} + +bool LlamaCppTextGeneration::is_model_loaded() const { + return model_loaded_; +} + +bool LlamaCppTextGeneration::unload_model_internal() { + if (!model_loaded_) { + return true; + } + + LOGI("Unloading model"); + + if (sampler_) { + llama_sampler_free(sampler_); + sampler_ = nullptr; + } + + if (context_) { + llama_free(context_); + context_ = nullptr; + } + + if (model_) { + llama_model_free(model_); + model_ = nullptr; + } + + model_loaded_ = false; + model_path_.clear(); + + LOGI("Model unloaded"); + return true; +} + +bool LlamaCppTextGeneration::unload_model() { + std::lock_guard lock(mutex_); + return unload_model_internal(); +} + +std::string LlamaCppTextGeneration::build_prompt(const TextGenerationRequest& request) { + std::vector> messages; + + if (!request.messages.empty()) { + messages = request.messages; + } else if (!request.prompt.empty()) { + messages.push_back({"user", request.prompt}); + LOGI("Converted prompt to user message for chat template"); + } else { + LOGE("No prompt or messages provided"); + return ""; + } + + std::string formatted = apply_chat_template(messages, request.system_prompt, true); + LOGI("Applied chat template, formatted prompt length: %zu", formatted.length()); + + return formatted; +} + +std::string LlamaCppTextGeneration::apply_chat_template( + const std::vector>& messages, + const std::string& system_prompt, bool add_assistant_token) { + std::vector chat_messages; + + std::vector role_storage; + role_storage.reserve(messages.size()); + + if (!system_prompt.empty()) { + chat_messages.push_back({"system", system_prompt.c_str()}); + } + + for (const auto& [role, content] : messages) { + std::string role_lower = role; + std::transform(role_lower.begin(), role_lower.end(), role_lower.begin(), ::tolower); + role_storage.push_back(std::move(role_lower)); + chat_messages.push_back({role_storage.back().c_str(), content.c_str()}); + } + + std::string model_template; + model_template.resize(2048); + int32_t template_len = llama_model_meta_val_str(model_, "tokenizer.chat_template", + model_template.data(), model_template.size()); + + const char* tmpl_to_use = nullptr; + if (template_len > 0) { + model_template.resize(template_len); + tmpl_to_use = model_template.c_str(); + } + + std::string formatted; + formatted.resize(1024 * 256); + + int32_t result = + llama_chat_apply_template(tmpl_to_use, chat_messages.data(), chat_messages.size(), + add_assistant_token, formatted.data(), formatted.size()); + + if (result < 0) { + LOGE("llama_chat_apply_template failed: %d", result); + std::string fallback; + for (const auto& msg : chat_messages) { + fallback += std::string(msg.role) + ": " + msg.content + "\n"; + } + if (add_assistant_token) { + fallback += "assistant: "; + } + return fallback; + } + + if (result > (int32_t)formatted.size()) { + formatted.resize(result + 1024); + result = llama_chat_apply_template(tmpl_to_use, chat_messages.data(), chat_messages.size(), + add_assistant_token, formatted.data(), formatted.size()); + } + + if (result > 0) { + formatted.resize(result); + } + + return formatted; +} + +TextGenerationResult LlamaCppTextGeneration::generate(const TextGenerationRequest& request) { + TextGenerationResult result; + result.finish_reason = "error"; + + std::string generated_text; + int tokens_generated = 0; + int prompt_tokens = 0; + + auto start_time = std::chrono::high_resolution_clock::now(); + + bool success = generate_stream( + request, + [&](const std::string& token) -> bool { + generated_text += token; + tokens_generated++; + return !cancel_requested_.load(); + }, + &prompt_tokens); + + auto end_time = std::chrono::high_resolution_clock::now(); + auto duration = std::chrono::duration_cast(end_time - start_time); + + result.text = generated_text; + result.tokens_generated = tokens_generated; + result.prompt_tokens = prompt_tokens; + result.inference_time_ms = duration.count(); + + if (cancel_requested_.load()) { + result.finish_reason = "cancelled"; + } else if (success) { + result.finish_reason = tokens_generated >= request.max_tokens ? "length" : "stop"; + } + + return result; +} + +bool LlamaCppTextGeneration::generate_stream(const TextGenerationRequest& request, + TextStreamCallback callback, + int* out_prompt_tokens) { + std::lock_guard lock(mutex_); + + if (!is_ready()) { + LOGE("Model not ready for generation"); + return false; + } + + cancel_requested_.store(false); + + std::string prompt = build_prompt(request); + LOGI("Generating with prompt length: %zu", prompt.length()); + + const auto tokens_list = common_tokenize(context_, prompt, true, true); + + int n_ctx = llama_n_ctx(context_); + int prompt_tokens = static_cast(tokens_list.size()); + + if (out_prompt_tokens) { + *out_prompt_tokens = prompt_tokens; + } + + int available_tokens = n_ctx - prompt_tokens - 4; + + if (available_tokens <= 0) { + LOGE("Prompt too long: %d tokens, context size: %d", prompt_tokens, n_ctx); + return false; + } + + int effective_max_tokens = std::min(request.max_tokens, available_tokens); + LOGI("Generation: prompt_tokens=%d, max_tokens=%d, context=%d", + prompt_tokens, effective_max_tokens, n_ctx); + + llama_batch batch = llama_batch_init(n_ctx, 0, 1); + + batch.n_tokens = 0; + for (size_t i = 0; i < tokens_list.size(); i++) { + common_batch_add(batch, tokens_list[i], i, {0}, false); + } + batch.logits[batch.n_tokens - 1] = true; + + if (llama_decode(context_, batch) != 0) { + LOGE("llama_decode failed for prompt"); + llama_batch_free(batch); + return false; + } + + llama_sampler_reset(sampler_); + + const auto vocab = llama_model_get_vocab(model_); + + static const std::vector STOP_SEQUENCES = { + "<|im_end|>", "<|eot_id|>", "", "<|end|>", "<|endoftext|>", + "\n\nUser:", "\n\nHuman:", + }; + + static const size_t MAX_STOP_LEN = []{ + size_t m = 0; + for (const auto& s : STOP_SEQUENCES) m = std::max(m, s.size()); + return m; + }(); + + std::string stop_window; + stop_window.reserve(MAX_STOP_LEN * 2); + + std::string partial_utf8_buffer; + partial_utf8_buffer.reserve(8); + + int n_cur = batch.n_tokens; + int tokens_generated = 0; + bool stop_sequence_hit = false; + + while (tokens_generated < effective_max_tokens && !cancel_requested_.load()) { + const llama_token new_token_id = llama_sampler_sample(sampler_, context_, -1); + + llama_sampler_accept(sampler_, new_token_id); + + if (llama_vocab_is_eog(vocab, new_token_id)) { + LOGI("End of generation token received"); + break; + } + + const std::string new_token_chars = + common_token_to_piece(context_, new_token_id); + + partial_utf8_buffer.append(new_token_chars); + + Utf8State scanner_state; + size_t valid_upto = 0; + for (size_t i = 0; i < partial_utf8_buffer.size(); ++i) { + scanner_state.process(static_cast(partial_utf8_buffer[i])); + if (scanner_state.state == 0) { + valid_upto = i + 1; + } + } + + if (valid_upto > 0) { + std::string valid_chunk = partial_utf8_buffer.substr(0, valid_upto); + stop_window.append(valid_chunk); + partial_utf8_buffer.erase(0, valid_upto); + + size_t found_stop_pos = std::string::npos; + for (const auto& stop_seq : STOP_SEQUENCES) { + size_t pos = stop_window.find(stop_seq); + if (pos != std::string::npos) { + if (found_stop_pos == std::string::npos || pos < found_stop_pos) { + found_stop_pos = pos; + } + } + } + + if (found_stop_pos != std::string::npos) { + LOGI("Stop sequence detected"); + stop_sequence_hit = true; + if (found_stop_pos > 0) { + if (!callback(stop_window.substr(0, found_stop_pos))) { + cancel_requested_.store(true); + } + } + break; + } + + if (stop_window.size() > MAX_STOP_LEN) { + size_t safe_len = stop_window.size() - MAX_STOP_LEN; + if (!callback(stop_window.substr(0, safe_len))) { + LOGI("Generation cancelled by callback"); + cancel_requested_.store(true); + break; + } + stop_window.erase(0, safe_len); + } + } + + batch.n_tokens = 0; + common_batch_add(batch, new_token_id, n_cur, {0}, true); + + n_cur++; + tokens_generated++; + + if (llama_decode(context_, batch) != 0) { + LOGE("llama_decode failed during generation"); + break; + } + } + + if (!cancel_requested_.load() && !stop_sequence_hit && !stop_window.empty()) { + callback(stop_window); + } + + llama_memory_clear(llama_get_memory(context_), true); + + llama_batch_free(batch); + + LOGI("Generation complete: %d tokens", tokens_generated); + return !cancel_requested_.load(); +} + +bool LlamaCppTextGeneration::generate_stream_with_timing(const TextGenerationRequest& request, + TextStreamCallback callback, + int* out_prompt_tokens, + rac_benchmark_timing_t* timing_out) { + std::lock_guard lock(mutex_); + + if (!is_ready()) { + LOGE("Model not ready for generation"); + return false; + } + + cancel_requested_.store(false); + + std::string prompt = build_prompt(request); + LOGI("Generating with timing, prompt length: %zu", prompt.length()); + + const auto tokens_list = common_tokenize(context_, prompt, true, true); + + int n_ctx = llama_n_ctx(context_); + int prompt_tokens = static_cast(tokens_list.size()); + + if (out_prompt_tokens) { + *out_prompt_tokens = prompt_tokens; + } + + int available_tokens = n_ctx - prompt_tokens - 4; + + if (available_tokens <= 0) { + LOGE("Prompt too long: %d tokens, context size: %d", prompt_tokens, n_ctx); + return false; + } + + int effective_max_tokens = std::min(request.max_tokens, available_tokens); + if (effective_max_tokens < request.max_tokens) { + LOGI("Capping max_tokens: %d → %d (context=%d, prompt=%d tokens)", request.max_tokens, + effective_max_tokens, n_ctx, prompt_tokens); + } + LOGI("Generation with timing: prompt_tokens=%d, max_tokens=%d, context=%d", prompt_tokens, + effective_max_tokens, n_ctx); + + llama_batch batch = llama_batch_init(n_ctx, 0, 1); + + batch.n_tokens = 0; + for (size_t i = 0; i < tokens_list.size(); i++) { + common_batch_add(batch, tokens_list[i], i, {0}, false); + } + batch.logits[batch.n_tokens - 1] = true; + + // t2: Record prefill start (before llama_decode for prompt) + if (timing_out != nullptr) { + timing_out->t2_prefill_start_ms = rac_monotonic_now_ms(); + } + + if (llama_decode(context_, batch) != 0) { + LOGE("llama_decode failed for prompt"); + if (timing_out != nullptr) { + int64_t now = rac_monotonic_now_ms(); + timing_out->t3_prefill_end_ms = now; + timing_out->t5_last_token_ms = now; + } + llama_batch_free(batch); + return false; + } + + // t3: Record prefill end (after llama_decode returns) + if (timing_out != nullptr) { + timing_out->t3_prefill_end_ms = rac_monotonic_now_ms(); + } + + llama_sampler_reset(sampler_); + + const auto vocab = llama_model_get_vocab(model_); + + static const std::vector STOP_SEQUENCES = { + "<|im_end|>", "<|eot_id|>", "", "<|end|>", "<|endoftext|>", + "\n\nUser:", "\n\nHuman:", + }; + + static const size_t MAX_STOP_LEN = []{ + size_t m = 0; + for (const auto& s : STOP_SEQUENCES) m = std::max(m, s.size()); + return m; + }(); + + std::string stop_window; + stop_window.reserve(MAX_STOP_LEN * 2); + + std::string partial_utf8_buffer; + partial_utf8_buffer.reserve(8); + + int n_cur = batch.n_tokens; + int tokens_generated = 0; + bool stop_sequence_hit = false; + + while (tokens_generated < effective_max_tokens && !cancel_requested_.load()) { + const llama_token new_token_id = llama_sampler_sample(sampler_, context_, -1); + + llama_sampler_accept(sampler_, new_token_id); + + if (llama_vocab_is_eog(vocab, new_token_id)) { + LOGI("End of generation token received"); + break; + } + + const std::string new_token_chars = + common_token_to_piece(context_, new_token_id); + + partial_utf8_buffer.append(new_token_chars); + + Utf8State scanner_state; + size_t valid_upto = 0; + for (size_t i = 0; i < partial_utf8_buffer.size(); ++i) { + scanner_state.process(static_cast(partial_utf8_buffer[i])); + if (scanner_state.state == 0) { + valid_upto = i + 1; + } + } + + if (valid_upto > 0) { + std::string valid_chunk = partial_utf8_buffer.substr(0, valid_upto); + stop_window.append(valid_chunk); + partial_utf8_buffer.erase(0, valid_upto); + + size_t found_stop_pos = std::string::npos; + for (const auto& stop_seq : STOP_SEQUENCES) { + size_t pos = stop_window.find(stop_seq); + if (pos != std::string::npos) { + if (found_stop_pos == std::string::npos || pos < found_stop_pos) { + found_stop_pos = pos; + } + } + } + + if (found_stop_pos != std::string::npos) { + LOGI("Stop sequence detected"); + stop_sequence_hit = true; + if (found_stop_pos > 0) { + if (!callback(stop_window.substr(0, found_stop_pos))) { + cancel_requested_.store(true); + } + } + break; + } + + if (stop_window.size() > MAX_STOP_LEN) { + size_t safe_len = stop_window.size() - MAX_STOP_LEN; + if (!callback(stop_window.substr(0, safe_len))) { + LOGI("Generation cancelled by callback"); + cancel_requested_.store(true); + break; + } + stop_window.erase(0, safe_len); + } + } + + batch.n_tokens = 0; + common_batch_add(batch, new_token_id, n_cur, {0}, true); + + n_cur++; + tokens_generated++; + + if (llama_decode(context_, batch) != 0) { + LOGE("llama_decode failed during generation"); + break; + } + } + + // t5: Record last token time (decode loop exit) + if (timing_out != nullptr) { + timing_out->t5_last_token_ms = rac_monotonic_now_ms(); + timing_out->output_tokens = static_cast(tokens_generated); + } + + if (!cancel_requested_.load() && !stop_sequence_hit && !stop_window.empty()) { + callback(stop_window); + } + + llama_memory_clear(llama_get_memory(context_), true); + + llama_batch_free(batch); + + LOGI("Generation with timing complete: %d tokens", tokens_generated); + return !cancel_requested_.load(); +} + +void LlamaCppTextGeneration::cancel() { + cancel_requested_.store(true); + LOGI("Generation cancel requested"); +} + +nlohmann::json LlamaCppTextGeneration::get_model_info() const { + if (!model_loaded_ || !model_) { + return {}; + } + + nlohmann::json info; + info["path"] = model_path_; + info["context_size"] = context_size_; + info["model_training_context"] = llama_model_n_ctx_train(model_); + info["max_default_context"] = max_default_context_; + info["temperature"] = temperature_; + info["top_k"] = top_k_; + info["top_p"] = top_p_; + info["min_p"] = min_p_; + + char buf[256]; + if (llama_model_meta_val_str(model_, "general.name", buf, sizeof(buf)) > 0) { + info["name"] = std::string(buf); + } + if (llama_model_meta_val_str(model_, "general.architecture", buf, sizeof(buf)) > 0) { + info["architecture"] = std::string(buf); + } + + return info; +} + +} // namespace runanywhere diff --git a/sdk/runanywhere-commons/src/backends/llamacpp/llamacpp_backend.h b/sdk/runanywhere-commons/src/backends/llamacpp/llamacpp_backend.h new file mode 100644 index 000000000..29fa4de20 --- /dev/null +++ b/sdk/runanywhere-commons/src/backends/llamacpp/llamacpp_backend.h @@ -0,0 +1,165 @@ +#ifndef RUNANYWHERE_LLAMACPP_BACKEND_H +#define RUNANYWHERE_LLAMACPP_BACKEND_H + +/** + * LlamaCPP Backend - Text Generation via llama.cpp + * + * This backend uses llama.cpp for on-device LLM inference with GGUF/GGML models. + * Internal C++ implementation that is wrapped by the RAC API (rac_llm_llamacpp.cpp). + */ + +#include + +#include +#include +#include +#include +#include + +#include + +#include "rac/core/rac_benchmark.h" + +namespace runanywhere { + +// ============================================================================= +// DEVICE TYPES (internal use only) +// ============================================================================= + +enum class DeviceType { + CPU = 0, + GPU = 1, + METAL = 3, + CUDA = 4, +}; + +// ============================================================================= +// TEXT GENERATION TYPES (internal use only) +// ============================================================================= + +struct TextGenerationRequest { + std::string prompt; + std::string system_prompt; + std::vector> messages; // role, content pairs + int max_tokens = 256; + float temperature = 0.7f; + float top_p = 0.9f; + int top_k = 40; + float repetition_penalty = 1.1f; + std::vector stop_sequences; +}; + +struct TextGenerationResult { + std::string text; + int tokens_generated = 0; + int prompt_tokens = 0; + double inference_time_ms = 0.0; + std::string finish_reason; // "stop", "length", "cancelled" +}; + +// Streaming callback: receives token, returns false to cancel +using TextStreamCallback = std::function; + +// ============================================================================= +// FORWARD DECLARATIONS +// ============================================================================= + +class LlamaCppTextGeneration; + +// ============================================================================= +// LLAMACPP BACKEND +// ============================================================================= + +class LlamaCppBackend { + public: + LlamaCppBackend(); + ~LlamaCppBackend(); + + // Initialize the backend + bool initialize(const nlohmann::json& config = {}); + bool is_initialized() const; + void cleanup(); + + DeviceType get_device_type() const; + size_t get_memory_usage() const; + + // Get number of threads to use + int get_num_threads() const { return num_threads_; } + + // Get text generation capability + LlamaCppTextGeneration* get_text_generation() { return text_gen_.get(); } + + private: + void create_text_generation(); + + bool initialized_ = false; + nlohmann::json config_; + int num_threads_ = 0; + std::unique_ptr text_gen_; + mutable std::mutex mutex_; +}; + +// ============================================================================= +// TEXT GENERATION IMPLEMENTATION +// ============================================================================= + +class LlamaCppTextGeneration { + public: + explicit LlamaCppTextGeneration(LlamaCppBackend* backend); + ~LlamaCppTextGeneration(); + + bool is_ready() const; + bool load_model(const std::string& model_path, const nlohmann::json& config = {}); + bool is_model_loaded() const; + bool unload_model(); + + TextGenerationResult generate(const TextGenerationRequest& request); + bool generate_stream(const TextGenerationRequest& request, TextStreamCallback callback) { + return generate_stream(request, callback, nullptr); + } + bool generate_stream(const TextGenerationRequest& request, TextStreamCallback callback, + int* out_prompt_tokens); + + /** + * Generate text with streaming and benchmark timing. + * Captures t2 (prefill start), t3 (prefill end), t5 (last token). + * @param timing_out Benchmark timing struct (can be NULL for no timing) + */ + bool generate_stream_with_timing(const TextGenerationRequest& request, + TextStreamCallback callback, int* out_prompt_tokens, + rac_benchmark_timing_t* timing_out); + + void cancel(); + nlohmann::json get_model_info() const; + + private: + bool unload_model_internal(); + std::string build_prompt(const TextGenerationRequest& request); + std::string apply_chat_template(const std::vector>& messages, + const std::string& system_prompt, bool add_assistant_token); + + LlamaCppBackend* backend_; + llama_model* model_ = nullptr; + llama_context* context_ = nullptr; + llama_sampler* sampler_ = nullptr; + + bool model_loaded_ = false; + std::atomic cancel_requested_{false}; + + std::string model_path_; + nlohmann::json model_config_; + + int context_size_ = 0; + int max_default_context_ = 8192; + + float temperature_ = 0.8f; + float top_p_ = 0.95f; + float min_p_ = 0.05f; + int top_k_ = 40; + + mutable std::mutex mutex_; +}; + +} // namespace runanywhere + +#endif // RUNANYWHERE_LLAMACPP_BACKEND_H diff --git a/sdk/runanywhere-commons/src/backends/llamacpp/rac_backend_llamacpp_register.cpp b/sdk/runanywhere-commons/src/backends/llamacpp/rac_backend_llamacpp_register.cpp new file mode 100644 index 000000000..5aeeee662 --- /dev/null +++ b/sdk/runanywhere-commons/src/backends/llamacpp/rac_backend_llamacpp_register.cpp @@ -0,0 +1,313 @@ +/** + * @file rac_backend_llamacpp_register.cpp + * @brief RunAnywhere Core - LlamaCPP Backend Registration + * + * Registers the LlamaCPP backend with the module and service registries. + * Provides vtable implementation for the generic LLM service interface. + */ + +#include "rac_llm_llamacpp.h" + +#include +#include +#include + +#include + +#include "rac/core/rac_core.h" +#include "rac/core/rac_error.h" +#include "rac/core/rac_logger.h" +#include "rac/features/llm/rac_llm_service.h" + +static const char* LOG_CAT = "LlamaCPP"; + +// ============================================================================= +// VTABLE IMPLEMENTATION - Adapters for generic service interface +// ============================================================================= + +namespace { + +// Initialize (model already loaded during create for LlamaCpp) +static rac_result_t llamacpp_vtable_initialize(void* impl, const char* model_path) { + return rac_llm_llamacpp_load_model(impl, model_path, nullptr); +} + +// Generate (blocking) +static rac_result_t llamacpp_vtable_generate(void* impl, const char* prompt, + const rac_llm_options_t* options, + rac_llm_result_t* out_result) { + return rac_llm_llamacpp_generate(impl, prompt, options, out_result); +} + +// Streaming callback adapter +struct StreamAdapter { + rac_llm_stream_callback_fn callback; + void* user_data; +}; + +static rac_bool_t stream_adapter_callback(const char* token, rac_bool_t is_final, void* ctx) { + auto* adapter = static_cast(ctx); + (void)is_final; + if (adapter && adapter->callback) { + return adapter->callback(token, adapter->user_data); + } + return RAC_TRUE; +} + +// Generate stream +static rac_result_t llamacpp_vtable_generate_stream(void* impl, const char* prompt, + const rac_llm_options_t* options, + rac_llm_stream_callback_fn callback, + void* user_data) { + StreamAdapter adapter = {callback, user_data}; + return rac_llm_llamacpp_generate_stream(impl, prompt, options, stream_adapter_callback, + &adapter); +} + +// Generate stream with benchmark timing +static rac_result_t llamacpp_vtable_generate_stream_with_timing(void* impl, const char* prompt, + const rac_llm_options_t* options, + rac_llm_stream_callback_fn callback, + void* user_data, + rac_benchmark_timing_t* timing_out) { + StreamAdapter adapter = {callback, user_data}; + return rac_llm_llamacpp_generate_stream_with_timing(impl, prompt, options, + stream_adapter_callback, &adapter, + timing_out); +} + +// Get info +static rac_result_t llamacpp_vtable_get_info(void* impl, rac_llm_info_t* out_info) { + if (!out_info) + return RAC_ERROR_NULL_POINTER; + + out_info->is_ready = rac_llm_llamacpp_is_model_loaded(impl); + out_info->supports_streaming = RAC_TRUE; + out_info->current_model = nullptr; + out_info->context_length = 0; // Default if model not loaded or info unavailable + + // Get actual context_length from model info JSON when model is loaded + if (out_info->is_ready) { + char* json_str = nullptr; + if (rac_llm_llamacpp_get_model_info(impl, &json_str) == RAC_SUCCESS && json_str) { + try { + auto json = nlohmann::json::parse(json_str); + if (json.contains("context_size") && json["context_size"].is_number()) { + out_info->context_length = json["context_size"].get(); + } + } catch (...) { + // JSON parse error - context_length remains 0 + } + free(json_str); + } + } + + return RAC_SUCCESS; +} + +// Cancel +static rac_result_t llamacpp_vtable_cancel(void* impl) { + rac_llm_llamacpp_cancel(impl); + return RAC_SUCCESS; +} + +// Cleanup +static rac_result_t llamacpp_vtable_cleanup(void* impl) { + return rac_llm_llamacpp_unload_model(impl); +} + +// Destroy +static void llamacpp_vtable_destroy(void* impl) { + rac_llm_llamacpp_destroy(impl); +} + +// Static vtable for LlamaCpp +static const rac_llm_service_ops_t g_llamacpp_ops = { + .initialize = llamacpp_vtable_initialize, + .generate = llamacpp_vtable_generate, + .generate_stream = llamacpp_vtable_generate_stream, + .generate_stream_with_timing = llamacpp_vtable_generate_stream_with_timing, + .get_info = llamacpp_vtable_get_info, + .cancel = llamacpp_vtable_cancel, + .cleanup = llamacpp_vtable_cleanup, + .destroy = llamacpp_vtable_destroy, +}; + +// ============================================================================= +// REGISTRY STATE +// ============================================================================= + +struct LlamaCPPRegistryState { + std::mutex mutex; + bool registered = false; + char provider_name[32] = "LlamaCPPService"; + char module_id[16] = "llamacpp"; +}; + +LlamaCPPRegistryState& get_state() { + static LlamaCPPRegistryState state; + return state; +} + +// ============================================================================= +// SERVICE PROVIDER IMPLEMENTATION +// ============================================================================= + +rac_bool_t llamacpp_can_handle(const rac_service_request_t* request, void* user_data) { + (void)user_data; + + if (request == nullptr) { + RAC_LOG_DEBUG(LOG_CAT, "can_handle: request is NULL"); + return RAC_FALSE; + } + + RAC_LOG_DEBUG(LOG_CAT, "can_handle: framework=%d, model_path=%s, identifier=%s", + static_cast(request->framework), + request->model_path ? request->model_path : "NULL", + request->identifier ? request->identifier : "NULL"); + + // Framework hint from model registry + if (request->framework == RAC_FRAMEWORK_LLAMACPP) { + RAC_LOG_DEBUG(LOG_CAT, "can_handle: YES (framework match)"); + return RAC_TRUE; + } + + // If framework is explicitly set to something else, don't handle + if (request->framework != RAC_FRAMEWORK_UNKNOWN) { + RAC_LOG_DEBUG(LOG_CAT, + "can_handle: NO (framework mismatch, expected LLAMACPP=%d or UNKNOWN=%d, got %d)", + RAC_FRAMEWORK_LLAMACPP, RAC_FRAMEWORK_UNKNOWN, static_cast(request->framework)); + return RAC_FALSE; + } + + // Framework unknown - check file extension + const char* path = request->model_path ? request->model_path : request->identifier; + if (path == nullptr || path[0] == '\0') { + RAC_LOG_DEBUG(LOG_CAT, "can_handle: NO (no path)"); + return RAC_FALSE; + } + + size_t len = strlen(path); + if (len >= 5) { + const char* ext = path + len - 5; + if (strcmp(ext, ".gguf") == 0 || strcmp(ext, ".GGUF") == 0) { + RAC_LOG_DEBUG(LOG_CAT, "can_handle: YES (gguf extension)"); + return RAC_TRUE; + } + } + + RAC_LOG_DEBUG(LOG_CAT, "can_handle: NO (no gguf extension in path: %s)", path); + return RAC_FALSE; +} + +/** + * Create a LlamaCPP LLM service with vtable. + * Returns an rac_llm_service_t* that the generic API can dispatch through. + */ +rac_handle_t llamacpp_create_service(const rac_service_request_t* request, void* user_data) { + (void)user_data; + + if (request == nullptr) { + return nullptr; + } + + const char* model_path = request->model_path ? request->model_path : request->identifier; + if (model_path == nullptr || model_path[0] == '\0') { + RAC_LOG_ERROR(LOG_CAT, "No model path provided"); + return nullptr; + } + + RAC_LOG_INFO(LOG_CAT, "Creating LlamaCPP service for: %s", model_path); + + // Create backend-specific handle + rac_handle_t backend_handle = nullptr; + rac_result_t result = rac_llm_llamacpp_create(model_path, nullptr, &backend_handle); + if (result != RAC_SUCCESS) { + RAC_LOG_ERROR(LOG_CAT, "Failed to create LlamaCPP backend: %d", result); + return nullptr; + } + + // Allocate service struct with vtable + auto* service = static_cast(malloc(sizeof(rac_llm_service_t))); + if (!service) { + rac_llm_llamacpp_destroy(backend_handle); + return nullptr; + } + + service->ops = &g_llamacpp_ops; + service->impl = backend_handle; + service->model_id = request->identifier ? strdup(request->identifier) : nullptr; + + RAC_LOG_INFO(LOG_CAT, "LlamaCPP service created successfully"); + return service; +} + +} // namespace + +// ============================================================================= +// REGISTRATION API +// ============================================================================= + +extern "C" { + +rac_result_t rac_backend_llamacpp_register(void) { + auto& state = get_state(); + std::lock_guard lock(state.mutex); + + if (state.registered) { + return RAC_ERROR_MODULE_ALREADY_REGISTERED; + } + + // Register module + rac_module_info_t module_info = {}; + module_info.id = state.module_id; + module_info.name = "LlamaCPP"; + module_info.version = "1.0.0"; + module_info.description = "LLM backend using llama.cpp for GGUF models"; + + rac_capability_t capabilities[] = {RAC_CAPABILITY_TEXT_GENERATION}; + module_info.capabilities = capabilities; + module_info.num_capabilities = 1; + + rac_result_t result = rac_module_register(&module_info); + if (result != RAC_SUCCESS && result != RAC_ERROR_MODULE_ALREADY_REGISTERED) { + return result; + } + + // Register service provider + rac_service_provider_t provider = {}; + provider.name = state.provider_name; + provider.capability = RAC_CAPABILITY_TEXT_GENERATION; + provider.priority = 100; + provider.can_handle = llamacpp_can_handle; + provider.create = llamacpp_create_service; + provider.user_data = nullptr; + + result = rac_service_register_provider(&provider); + if (result != RAC_SUCCESS) { + rac_module_unregister(state.module_id); + return result; + } + + state.registered = true; + RAC_LOG_INFO(LOG_CAT, "Backend registered successfully"); + return RAC_SUCCESS; +} + +rac_result_t rac_backend_llamacpp_unregister(void) { + auto& state = get_state(); + std::lock_guard lock(state.mutex); + + if (!state.registered) { + return RAC_ERROR_MODULE_NOT_FOUND; + } + + rac_service_unregister_provider(state.provider_name, RAC_CAPABILITY_TEXT_GENERATION); + rac_module_unregister(state.module_id); + + state.registered = false; + RAC_LOG_INFO(LOG_CAT, "Backend unregistered"); + return RAC_SUCCESS; +} + +} // extern "C" diff --git a/sdk/runanywhere-commons/src/backends/llamacpp/rac_llm_llamacpp.cpp b/sdk/runanywhere-commons/src/backends/llamacpp/rac_llm_llamacpp.cpp new file mode 100644 index 000000000..8b8815250 --- /dev/null +++ b/sdk/runanywhere-commons/src/backends/llamacpp/rac_llm_llamacpp.cpp @@ -0,0 +1,333 @@ +/** + * @file rac_llm_llamacpp.cpp + * @brief RunAnywhere Core - LlamaCPP Backend RAC API Implementation + * + * Direct RAC API implementation that calls C++ classes. + * No intermediate ra_* layer - this is the final C API export. + */ + +#include "rac_llm_llamacpp.h" + +#include +#include +#include +#include + +#include "llamacpp_backend.h" + +#include "rac/core/rac_error.h" +#include "rac/infrastructure/events/rac_events.h" + +// ============================================================================= +// INTERNAL HANDLE STRUCTURE +// ============================================================================= + +// Internal handle - wraps C++ objects directly (no intermediate ra_* layer) +struct rac_llm_llamacpp_handle_impl { + std::unique_ptr backend; + runanywhere::LlamaCppTextGeneration* text_gen; // Owned by backend + + rac_llm_llamacpp_handle_impl() : backend(nullptr), text_gen(nullptr) {} +}; + +// ============================================================================= +// LLAMACPP API IMPLEMENTATION +// ============================================================================= + +extern "C" { + +rac_result_t rac_llm_llamacpp_create(const char* model_path, + const rac_llm_llamacpp_config_t* config, + rac_handle_t* out_handle) { + if (out_handle == nullptr) { + return RAC_ERROR_NULL_POINTER; + } + + auto* handle = new (std::nothrow) rac_llm_llamacpp_handle_impl(); + if (!handle) { + rac_error_set_details("Out of memory allocating handle"); + return RAC_ERROR_OUT_OF_MEMORY; + } + + // Create backend + handle->backend = std::make_unique(); + + // Build init config + nlohmann::json init_config; + if (config != nullptr && config->num_threads > 0) { + init_config["num_threads"] = config->num_threads; + } + + // Initialize backend + if (!handle->backend->initialize(init_config)) { + delete handle; + rac_error_set_details("Failed to initialize LlamaCPP backend"); + return RAC_ERROR_BACKEND_INIT_FAILED; + } + + // Get text generation component + handle->text_gen = handle->backend->get_text_generation(); + if (!handle->text_gen) { + delete handle; + rac_error_set_details("Failed to get text generation component"); + return RAC_ERROR_BACKEND_INIT_FAILED; + } + + // Build model config + nlohmann::json model_config; + if (config != nullptr) { + if (config->context_size > 0) { + model_config["context_size"] = config->context_size; + } + if (config->gpu_layers != 0) { + model_config["gpu_layers"] = config->gpu_layers; + } + if (config->batch_size > 0) { + model_config["batch_size"] = config->batch_size; + } + } + + // Load model + if (!handle->text_gen->load_model(model_path, model_config)) { + delete handle; + rac_error_set_details("Failed to load model"); + return RAC_ERROR_MODEL_LOAD_FAILED; + } + + *out_handle = static_cast(handle); + + // Publish event + rac_event_track("llm.backend.created", RAC_EVENT_CATEGORY_LLM, RAC_EVENT_DESTINATION_ALL, + R"({"backend":"llamacpp"})"); + + return RAC_SUCCESS; +} + +rac_result_t rac_llm_llamacpp_load_model(rac_handle_t handle, const char* model_path, + const rac_llm_llamacpp_config_t* config) { + // LlamaCPP loads model during rac_llm_llamacpp_create(), so this is a no-op. + // This matches the pattern used by ONNX backends (STT/TTS) where initialize is a no-op. + (void)handle; + (void)model_path; + (void)config; + return RAC_SUCCESS; +} + +rac_result_t rac_llm_llamacpp_unload_model(rac_handle_t handle) { + // LlamaCPP doesn't support unloading without destroying + // Caller should call destroy instead + (void)handle; + return RAC_ERROR_NOT_SUPPORTED; +} + +rac_bool_t rac_llm_llamacpp_is_model_loaded(rac_handle_t handle) { + if (handle == nullptr) { + return RAC_FALSE; + } + + auto* h = static_cast(handle); + if (!h->text_gen) { + return RAC_FALSE; + } + + return h->text_gen->is_model_loaded() ? RAC_TRUE : RAC_FALSE; +} + +rac_result_t rac_llm_llamacpp_generate(rac_handle_t handle, const char* prompt, + const rac_llm_options_t* options, + rac_llm_result_t* out_result) { + if (handle == nullptr || prompt == nullptr || out_result == nullptr) { + return RAC_ERROR_NULL_POINTER; + } + + auto* h = static_cast(handle); + if (!h->text_gen) { + return RAC_ERROR_INVALID_HANDLE; + } + + // Build request from RAC options + runanywhere::TextGenerationRequest request; + request.prompt = prompt; + if (options != nullptr) { + request.max_tokens = options->max_tokens; + request.temperature = options->temperature; + request.top_p = options->top_p; + // Handle stop sequences if available + if (options->stop_sequences != nullptr && options->num_stop_sequences > 0) { + for (int32_t i = 0; i < options->num_stop_sequences; i++) { + if (options->stop_sequences[i]) { + request.stop_sequences.push_back(options->stop_sequences[i]); + } + } + } + } + + // Generate using C++ class + auto result = h->text_gen->generate(request); + + // Fill RAC result struct + out_result->text = result.text.empty() ? nullptr : strdup(result.text.c_str()); + out_result->completion_tokens = result.tokens_generated; + out_result->prompt_tokens = result.prompt_tokens; + out_result->total_tokens = result.prompt_tokens + result.tokens_generated; + out_result->time_to_first_token_ms = 0; + out_result->total_time_ms = result.inference_time_ms; + out_result->tokens_per_second = result.tokens_generated > 0 && result.inference_time_ms > 0 + ? (float)result.tokens_generated / + (result.inference_time_ms / 1000.0f) + : 0.0f; + + // Publish event + rac_event_track("llm.generation.completed", RAC_EVENT_CATEGORY_LLM, RAC_EVENT_DESTINATION_ALL, + nullptr); + + return RAC_SUCCESS; +} + +rac_result_t rac_llm_llamacpp_generate_stream(rac_handle_t handle, const char* prompt, + const rac_llm_options_t* options, + rac_llm_llamacpp_stream_callback_fn callback, + void* user_data) { + if (handle == nullptr || prompt == nullptr || callback == nullptr) { + return RAC_ERROR_NULL_POINTER; + } + + auto* h = static_cast(handle); + if (!h->text_gen) { + return RAC_ERROR_INVALID_HANDLE; + } + + runanywhere::TextGenerationRequest request; + request.prompt = prompt; + if (options != nullptr) { + request.max_tokens = options->max_tokens; + request.temperature = options->temperature; + request.top_p = options->top_p; + if (options->stop_sequences != nullptr && options->num_stop_sequences > 0) { + for (int32_t i = 0; i < options->num_stop_sequences; i++) { + if (options->stop_sequences[i]) { + request.stop_sequences.push_back(options->stop_sequences[i]); + } + } + } + } + + // Stream using C++ class + bool success = + h->text_gen->generate_stream(request, [callback, user_data](const std::string& token) -> bool { + return callback(token.c_str(), RAC_FALSE, user_data) == RAC_TRUE; + }); + + if (success) { + callback("", RAC_TRUE, user_data); // Final token + } + + return success ? RAC_SUCCESS : RAC_ERROR_INFERENCE_FAILED; +} + +rac_result_t rac_llm_llamacpp_generate_stream_with_timing(rac_handle_t handle, const char* prompt, + const rac_llm_options_t* options, + rac_llm_llamacpp_stream_callback_fn callback, + void* user_data, + rac_benchmark_timing_t* timing_out) { + if (handle == nullptr || prompt == nullptr || callback == nullptr) { + return RAC_ERROR_NULL_POINTER; + } + + auto* h = static_cast(handle); + if (!h->text_gen) { + return RAC_ERROR_INVALID_HANDLE; + } + + runanywhere::TextGenerationRequest request; + request.prompt = prompt; + if (options != nullptr) { + request.max_tokens = options->max_tokens; + request.temperature = options->temperature; + request.top_p = options->top_p; + if (options->stop_sequences != nullptr && options->num_stop_sequences > 0) { + for (int32_t i = 0; i < options->num_stop_sequences; i++) { + if (options->stop_sequences[i]) { + request.stop_sequences.push_back(options->stop_sequences[i]); + } + } + } + } + + // Stream using C++ class with timing + int prompt_tokens = 0; + bool success = h->text_gen->generate_stream_with_timing( + request, + [callback, user_data](const std::string& token) -> bool { + return callback(token.c_str(), RAC_FALSE, user_data) == RAC_TRUE; + }, + &prompt_tokens, + timing_out + ); + + // Capture prompt token count in timing struct + if (timing_out != nullptr && prompt_tokens > 0) { + timing_out->prompt_tokens = static_cast(prompt_tokens); + } + + if (success) { + callback("", RAC_TRUE, user_data); // Final token + } + + return success ? RAC_SUCCESS : RAC_ERROR_INFERENCE_FAILED; +} + +void rac_llm_llamacpp_cancel(rac_handle_t handle) { + if (handle == nullptr) { + return; + } + + auto* h = static_cast(handle); + if (h->text_gen) { + h->text_gen->cancel(); + } + + rac_event_track("llm.generation.cancelled", RAC_EVENT_CATEGORY_LLM, RAC_EVENT_DESTINATION_ALL, + nullptr); +} + +rac_result_t rac_llm_llamacpp_get_model_info(rac_handle_t handle, char** out_json) { + if (handle == nullptr || out_json == nullptr) { + return RAC_ERROR_NULL_POINTER; + } + + auto* h = static_cast(handle); + if (!h->text_gen) { + return RAC_ERROR_INVALID_HANDLE; + } + + auto info = h->text_gen->get_model_info(); + if (info.empty()) { + return RAC_ERROR_BACKEND_NOT_READY; + } + + std::string json_str = info.dump(); + *out_json = strdup(json_str.c_str()); + + return RAC_SUCCESS; +} + +void rac_llm_llamacpp_destroy(rac_handle_t handle) { + if (handle == nullptr) { + return; + } + + auto* h = static_cast(handle); + if (h->text_gen) { + h->text_gen->unload_model(); + } + if (h->backend) { + h->backend->cleanup(); + } + delete h; + + rac_event_track("llm.backend.destroyed", RAC_EVENT_CATEGORY_LLM, RAC_EVENT_DESTINATION_ALL, + R"({"backend":"llamacpp"})"); +} + +} // extern "C" diff --git a/sdk/runanywhere-commons/src/backends/onnx/CMakeLists.txt b/sdk/runanywhere-commons/src/backends/onnx/CMakeLists.txt new file mode 100644 index 000000000..da53474e1 --- /dev/null +++ b/sdk/runanywhere-commons/src/backends/onnx/CMakeLists.txt @@ -0,0 +1,229 @@ +# ============================================================================= +# ONNX Backend - STT, TTS, VAD via ONNX Runtime and Sherpa-ONNX +# ============================================================================= + +message(STATUS "Configuring ONNX backend...") + +# ============================================================================= +# Dependencies +# ============================================================================= + +include(FetchContent) +include(LoadVersions) + +# Fetch nlohmann_json for JSON parsing +if(NOT DEFINED NLOHMANN_JSON_VERSION) + set(NLOHMANN_JSON_VERSION "3.11.3") +endif() + +FetchContent_Declare( + nlohmann_json + GIT_REPOSITORY https://github.com/nlohmann/json.git + GIT_TAG v${NLOHMANN_JSON_VERSION} + GIT_SHALLOW TRUE +) +set(JSON_BuildTests OFF CACHE BOOL "" FORCE) +FetchContent_MakeAvailable(nlohmann_json) + +# ============================================================================= +# ONNX Runtime +# ============================================================================= + +include(FetchONNXRuntime) + +# ============================================================================= +# Sherpa-ONNX (iOS and Android) +# ============================================================================= + +set(SHERPA_ONNX_AVAILABLE OFF) +get_filename_component(RUNANYWHERE_COMMONS_ROOT "${CMAKE_CURRENT_LIST_DIR}/../../.." ABSOLUTE) + +if(RAC_PLATFORM_IOS) + set(SHERPA_ONNX_ROOT "${RUNANYWHERE_COMMONS_ROOT}/third_party/sherpa-onnx-ios") + + if(EXISTS "${SHERPA_ONNX_ROOT}/sherpa-onnx.xcframework") + message(STATUS "Found Sherpa-ONNX xcframework at: ${SHERPA_ONNX_ROOT}") + set(SHERPA_ONNX_AVAILABLE ON) + + if(CMAKE_OSX_SYSROOT MATCHES "simulator") + set(SHERPA_ARCH "ios-arm64_x86_64-simulator") + else() + set(SHERPA_ARCH "ios-arm64") + endif() + + if(EXISTS "${SHERPA_ONNX_ROOT}/sherpa-onnx.xcframework/${SHERPA_ARCH}/libsherpa-onnx.a") + set(SHERPA_LIB_PATH "${SHERPA_ONNX_ROOT}/sherpa-onnx.xcframework/${SHERPA_ARCH}/libsherpa-onnx.a") + else() + file(GLOB SHERPA_LIB_CANDIDATES "${SHERPA_ONNX_ROOT}/sherpa-onnx.xcframework/${SHERPA_ARCH}/*.a") + if(SHERPA_LIB_CANDIDATES) + list(GET SHERPA_LIB_CANDIDATES 0 SHERPA_LIB_PATH) + else() + set(SHERPA_ONNX_AVAILABLE OFF) + endif() + endif() + set(SHERPA_HEADER_PATH "${SHERPA_ONNX_ROOT}/sherpa-onnx.xcframework/${SHERPA_ARCH}/Headers") + + if(SHERPA_ONNX_AVAILABLE AND SHERPA_LIB_PATH) + add_library(sherpa_onnx STATIC IMPORTED GLOBAL) + set_target_properties(sherpa_onnx PROPERTIES + IMPORTED_LOCATION "${SHERPA_LIB_PATH}" + INTERFACE_INCLUDE_DIRECTORIES "${SHERPA_HEADER_PATH}" + ) + endif() + endif() + +elseif(RAC_PLATFORM_ANDROID) + set(SHERPA_ONNX_ROOT "${RUNANYWHERE_COMMONS_ROOT}/third_party/sherpa-onnx-android") + + if(EXISTS "${SHERPA_ONNX_ROOT}/jniLibs") + set(SHERPA_ABI_DIR "${SHERPA_ONNX_ROOT}/jniLibs/${ANDROID_ABI}") + + if(EXISTS "${SHERPA_ABI_DIR}/libsherpa-onnx-c-api.so") + set(SHERPA_ONNX_AVAILABLE ON) + set(SHERPA_LIB_PATH "${SHERPA_ABI_DIR}/libsherpa-onnx-c-api.so") + + add_library(sherpa_onnx SHARED IMPORTED GLOBAL) + set_target_properties(sherpa_onnx PROPERTIES IMPORTED_LOCATION "${SHERPA_LIB_PATH}") + + if(EXISTS "${SHERPA_ONNX_ROOT}/include") + set(SHERPA_HEADER_PATH "${SHERPA_ONNX_ROOT}/include") + set_target_properties(sherpa_onnx PROPERTIES INTERFACE_INCLUDE_DIRECTORIES "${SHERPA_HEADER_PATH}") + endif() + endif() + endif() + +elseif(RAC_PLATFORM_MACOS) + set(SHERPA_ONNX_ROOT "${RUNANYWHERE_COMMONS_ROOT}/third_party/sherpa-onnx-macos") + + if(EXISTS "${SHERPA_ONNX_ROOT}/lib/libsherpa-onnx-c-api.a") + set(SHERPA_ONNX_AVAILABLE ON) + set(SHERPA_LIB_PATH "${SHERPA_ONNX_ROOT}/lib/libsherpa-onnx-c-api.a") + set(SHERPA_HEADER_PATH "${SHERPA_ONNX_ROOT}/include") + + add_library(sherpa_onnx STATIC IMPORTED GLOBAL) + set_target_properties(sherpa_onnx PROPERTIES + IMPORTED_LOCATION "${SHERPA_LIB_PATH}" + INTERFACE_INCLUDE_DIRECTORIES "${SHERPA_HEADER_PATH}" + ) + + set(SHERPA_ONNX_DEPS + "sherpa-onnx-core" "sherpa-onnx-fst" "sherpa-onnx-fstfar" + "sherpa-onnx-kaldifst-core" "kaldi-decoder-core" "kaldi-native-fbank-core" + "piper_phonemize" "espeak-ng" "ucd" "cppinyin_core" "ssentencepiece_core" "kissfft-float" + ) + + foreach(dep ${SHERPA_ONNX_DEPS}) + if(EXISTS "${SHERPA_ONNX_ROOT}/lib/lib${dep}.a") + add_library(sherpa_${dep} STATIC IMPORTED GLOBAL) + set_target_properties(sherpa_${dep} PROPERTIES IMPORTED_LOCATION "${SHERPA_ONNX_ROOT}/lib/lib${dep}.a") + target_link_libraries(sherpa_onnx INTERFACE sherpa_${dep}) + endif() + endforeach() + endif() +endif() + +# ============================================================================= +# ONNX Backend Library +# ============================================================================= + +set(ONNX_BACKEND_SOURCES + onnx_backend.cpp + rac_onnx.cpp + rac_backend_onnx_register.cpp +) + +set(ONNX_BACKEND_HEADERS + onnx_backend.h +) + +if(RAC_BUILD_SHARED) + add_library(rac_backend_onnx SHARED ${ONNX_BACKEND_SOURCES} ${ONNX_BACKEND_HEADERS}) +else() + add_library(rac_backend_onnx STATIC ${ONNX_BACKEND_SOURCES} ${ONNX_BACKEND_HEADERS}) +endif() + +target_include_directories(rac_backend_onnx PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_SOURCE_DIR}/include + ${CMAKE_SOURCE_DIR}/include/rac/backends +) + +# Define RAC_ONNX_BUILDING to export symbols with visibility("default") +target_compile_definitions(rac_backend_onnx PRIVATE RAC_ONNX_BUILDING) + +target_link_libraries(rac_backend_onnx PUBLIC + rac_commons + onnxruntime + nlohmann_json::nlohmann_json +) + +if(SHERPA_ONNX_AVAILABLE) + target_link_libraries(rac_backend_onnx PUBLIC sherpa_onnx) + target_compile_definitions(rac_backend_onnx PUBLIC SHERPA_ONNX_AVAILABLE=1) + target_include_directories(rac_backend_onnx PUBLIC ${SHERPA_HEADER_PATH}) +else() + target_compile_definitions(rac_backend_onnx PUBLIC SHERPA_ONNX_AVAILABLE=0) +endif() + +target_compile_features(rac_backend_onnx PUBLIC cxx_std_17) + +# ============================================================================= +# Platform-specific configuration +# ============================================================================= + +if(RAC_PLATFORM_IOS) + target_link_libraries(rac_backend_onnx PUBLIC + "-framework Foundation" + "-framework CoreML" + "-framework Accelerate" + ) + +elseif(RAC_PLATFORM_ANDROID) + target_link_libraries(rac_backend_onnx PRIVATE log) + # Don't use -fvisibility=hidden here - JNI bridge needs these symbols + target_compile_options(rac_backend_onnx PRIVATE -O3 -ffunction-sections -fdata-sections) + # 16KB page alignment for Android 15+ (API 35) compliance - required Nov 2025 + target_link_options(rac_backend_onnx PRIVATE -Wl,--gc-sections -Wl,-z,max-page-size=16384) + +elseif(RAC_PLATFORM_MACOS) + target_link_libraries(rac_backend_onnx PUBLIC + "-framework Foundation" + "-framework CoreML" + "-framework Accelerate" + ) +endif() + +# ============================================================================= +# JNI TARGET (Android) +# ============================================================================= + +if(RAC_PLATFORM_ANDROID AND RAC_BUILD_SHARED) + if(ANDROID) + message(STATUS "Building ONNX JNI bridge for Android") + + add_library(rac_backend_onnx_jni SHARED jni/rac_backend_onnx_jni.cpp) + + target_include_directories(rac_backend_onnx_jni PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_SOURCE_DIR}/include + ) + + target_link_libraries(rac_backend_onnx_jni PRIVATE + rac_backend_onnx + log + ) + + target_compile_options(rac_backend_onnx_jni PRIVATE -O3 -fvisibility=hidden -ffunction-sections -fdata-sections) + # 16KB page alignment for Android 15+ (API 35) compliance - required Nov 2025 + target_link_options(rac_backend_onnx_jni PRIVATE -Wl,--gc-sections -Wl,-z,max-page-size=16384) + endif() +endif() + +# ============================================================================= +# Summary +# ============================================================================= + +message(STATUS "ONNX Backend Configuration:") +message(STATUS " ONNX Runtime: Enabled") +message(STATUS " Sherpa-ONNX: ${SHERPA_ONNX_AVAILABLE}") +message(STATUS " Platform: ${RAC_PLATFORM_NAME}") diff --git a/sdk/runanywhere-commons/src/backends/onnx/jni/rac_backend_onnx_jni.cpp b/sdk/runanywhere-commons/src/backends/onnx/jni/rac_backend_onnx_jni.cpp new file mode 100644 index 000000000..fc3b997be --- /dev/null +++ b/sdk/runanywhere-commons/src/backends/onnx/jni/rac_backend_onnx_jni.cpp @@ -0,0 +1,122 @@ +/** + * @file rac_backend_onnx_jni.cpp + * @brief RunAnywhere Core - ONNX Backend JNI Bridge + * + * Self-contained JNI layer for the ONNX backend. + * + * Package: com.runanywhere.sdk.core.onnx + * Class: ONNXBridge + */ + +#include +#include +#include + +#ifdef __ANDROID__ +#include +#define TAG "RACOnnxJNI" +#define LOGi(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__) +#define LOGe(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__) +#define LOGw(...) __android_log_print(ANDROID_LOG_WARN, TAG, __VA_ARGS__) +#else +#include +#define LOGi(...) fprintf(stdout, "[INFO] " __VA_ARGS__); fprintf(stdout, "\n") +#define LOGe(...) fprintf(stderr, "[ERROR] " __VA_ARGS__); fprintf(stderr, "\n") +#define LOGw(...) fprintf(stdout, "[WARN] " __VA_ARGS__); fprintf(stdout, "\n") +#endif + +#include "rac_stt_onnx.h" +#include "rac_tts_onnx.h" +#include "rac_vad_onnx.h" + +#include "rac/core/rac_core.h" +#include "rac/core/rac_error.h" + +// Forward declaration +extern "C" rac_result_t rac_backend_onnx_register(void); +extern "C" rac_result_t rac_backend_onnx_unregister(void); + +extern "C" { + +// ============================================================================= +// JNI_OnLoad +// ============================================================================= + +JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) { + (void)vm; + (void)reserved; + LOGi("JNI_OnLoad: rac_backend_onnx_jni loaded"); + return JNI_VERSION_1_6; +} + +// ============================================================================= +// Backend Registration +// ============================================================================= + +JNIEXPORT jint JNICALL +Java_com_runanywhere_sdk_core_onnx_ONNXBridge_nativeRegister(JNIEnv* env, jclass clazz) { + (void)env; + (void)clazz; + LOGi("ONNX nativeRegister called"); + + rac_result_t result = rac_backend_onnx_register(); + + if (result != RAC_SUCCESS && result != RAC_ERROR_MODULE_ALREADY_REGISTERED) { + LOGe("Failed to register ONNX backend: %d", result); + return static_cast(result); + } + + const char** provider_names = nullptr; + size_t provider_count = 0; + rac_result_t list_result = rac_service_list_providers(RAC_CAPABILITY_STT, &provider_names, &provider_count); + LOGi("After ONNX registration - STT providers: count=%zu, result=%d", provider_count, list_result); + + LOGi("ONNX backend registered successfully (STT + TTS + VAD)"); + return RAC_SUCCESS; +} + +JNIEXPORT jint JNICALL +Java_com_runanywhere_sdk_core_onnx_ONNXBridge_nativeUnregister(JNIEnv* env, jclass clazz) { + (void)env; + (void)clazz; + LOGi("ONNX nativeUnregister called"); + + rac_result_t result = rac_backend_onnx_unregister(); + + if (result != RAC_SUCCESS) { + LOGe("Failed to unregister ONNX backend: %d", result); + } else { + LOGi("ONNX backend unregistered"); + } + + return static_cast(result); +} + +JNIEXPORT jboolean JNICALL +Java_com_runanywhere_sdk_core_onnx_ONNXBridge_nativeIsRegistered(JNIEnv* env, jclass clazz) { + (void)env; + (void)clazz; + + const char** provider_names = nullptr; + size_t provider_count = 0; + + rac_result_t result = rac_service_list_providers(RAC_CAPABILITY_STT, &provider_names, &provider_count); + + if (result == RAC_SUCCESS && provider_names && provider_count > 0) { + for (size_t i = 0; i < provider_count; i++) { + if (provider_names[i] && strstr(provider_names[i], "ONNX") != nullptr) { + return JNI_TRUE; + } + } + } + + return JNI_FALSE; +} + +JNIEXPORT jstring JNICALL +Java_com_runanywhere_sdk_core_onnx_ONNXBridge_nativeGetVersion(JNIEnv* env, jclass clazz) { + (void)clazz; + return env->NewStringUTF("1.0.0"); +} + +} // extern "C" diff --git a/sdk/runanywhere-commons/src/backends/onnx/onnx_backend.cpp b/sdk/runanywhere-commons/src/backends/onnx/onnx_backend.cpp new file mode 100644 index 000000000..d48f45b60 --- /dev/null +++ b/sdk/runanywhere-commons/src/backends/onnx/onnx_backend.cpp @@ -0,0 +1,899 @@ +/** + * ONNX Backend Implementation + * + * This file implements the ONNX backend using: + * - ONNX Runtime for general ML inference + * - Sherpa-ONNX for speech tasks (STT, TTS, VAD) + */ + +#include "onnx_backend.h" + +#include +#include + +#include + +#include "rac/core/rac_logger.h" + +namespace runanywhere { + +// ============================================================================= +// ONNXBackendNew Implementation +// ============================================================================= + +ONNXBackendNew::ONNXBackendNew() {} + +ONNXBackendNew::~ONNXBackendNew() { + cleanup(); +} + +bool ONNXBackendNew::initialize(const nlohmann::json& config) { + std::lock_guard lock(mutex_); + + if (initialized_) { + return true; + } + + config_ = config; + + if (!initialize_ort()) { + return false; + } + + create_capabilities(); + + initialized_ = true; + return true; +} + +bool ONNXBackendNew::is_initialized() const { + return initialized_; +} + +void ONNXBackendNew::cleanup() { + std::lock_guard lock(mutex_); + + stt_.reset(); + tts_.reset(); + vad_.reset(); + + if (ort_env_) { + ort_api_->ReleaseEnv(ort_env_); + ort_env_ = nullptr; + } + + initialized_ = false; +} + +DeviceType ONNXBackendNew::get_device_type() const { + return DeviceType::CPU; +} + +size_t ONNXBackendNew::get_memory_usage() const { + return 0; +} + +void ONNXBackendNew::set_telemetry_callback(TelemetryCallback callback) { + telemetry_.set_callback(callback); +} + +bool ONNXBackendNew::initialize_ort() { + ort_api_ = OrtGetApiBase()->GetApi(ORT_API_VERSION); + if (!ort_api_) { + RAC_LOG_ERROR("ONNX", "Failed to get ONNX Runtime API"); + return false; + } + + OrtStatus* status = ort_api_->CreateEnv(ORT_LOGGING_LEVEL_WARNING, "runanywhere", &ort_env_); + if (status) { + RAC_LOG_ERROR("ONNX", "Failed to create ONNX Runtime environment: %s", + ort_api_->GetErrorMessage(status)); + ort_api_->ReleaseStatus(status); + return false; + } + + return true; +} + +void ONNXBackendNew::create_capabilities() { + stt_ = std::make_unique(this); + +#if SHERPA_ONNX_AVAILABLE + tts_ = std::make_unique(this); + vad_ = std::make_unique(this); +#endif +} + +// ============================================================================= +// ONNXSTT Implementation +// ============================================================================= + +ONNXSTT::ONNXSTT(ONNXBackendNew* backend) : backend_(backend) {} + +ONNXSTT::~ONNXSTT() { + unload_model(); +} + +bool ONNXSTT::is_ready() const { +#if SHERPA_ONNX_AVAILABLE + return model_loaded_ && sherpa_recognizer_ != nullptr; +#else + return model_loaded_; +#endif +} + +bool ONNXSTT::load_model(const std::string& model_path, STTModelType model_type, + const nlohmann::json& config) { + std::lock_guard lock(mutex_); + +#if SHERPA_ONNX_AVAILABLE + if (sherpa_recognizer_) { + SherpaOnnxDestroyOfflineRecognizer(sherpa_recognizer_); + sherpa_recognizer_ = nullptr; + } + + model_type_ = model_type; + model_dir_ = model_path; + + RAC_LOG_INFO("ONNX.STT", "Loading model from: %s", model_path.c_str()); + + struct stat path_stat; + if (stat(model_path.c_str(), &path_stat) != 0) { + RAC_LOG_ERROR("ONNX.STT", "Model path does not exist: %s", model_path.c_str()); + return false; + } + + std::string encoder_path; + std::string decoder_path; + std::string tokens_path; + + if (S_ISDIR(path_stat.st_mode)) { + DIR* dir = opendir(model_path.c_str()); + if (!dir) { + RAC_LOG_ERROR("ONNX.STT", "Cannot open model directory: %s", model_path.c_str()); + return false; + } + + struct dirent* entry; + while ((entry = readdir(dir)) != nullptr) { + std::string filename = entry->d_name; + std::string full_path = model_path + "/" + filename; + + if (filename.find("encoder") != std::string::npos && filename.size() > 5 && + filename.substr(filename.size() - 5) == ".onnx") { + encoder_path = full_path; + RAC_LOG_DEBUG("ONNX.STT", "Found encoder: %s", encoder_path.c_str()); + } else if (filename.find("decoder") != std::string::npos && filename.size() > 5 && + filename.substr(filename.size() - 5) == ".onnx") { + decoder_path = full_path; + RAC_LOG_DEBUG("ONNX.STT", "Found decoder: %s", decoder_path.c_str()); + } else if (filename == "tokens.txt" || (filename.find("tokens") != std::string::npos && + filename.find(".txt") != std::string::npos)) { + tokens_path = full_path; + RAC_LOG_DEBUG("ONNX.STT", "Found tokens: %s", tokens_path.c_str()); + } + } + closedir(dir); + + if (encoder_path.empty()) { + std::string test_path = model_path + "/encoder.onnx"; + if (stat(test_path.c_str(), &path_stat) == 0) { + encoder_path = test_path; + } + } + if (decoder_path.empty()) { + std::string test_path = model_path + "/decoder.onnx"; + if (stat(test_path.c_str(), &path_stat) == 0) { + decoder_path = test_path; + } + } + if (tokens_path.empty()) { + std::string test_path = model_path + "/tokens.txt"; + if (stat(test_path.c_str(), &path_stat) == 0) { + tokens_path = test_path; + } + } + } else { + encoder_path = model_path; + size_t last_slash = model_path.find_last_of('/'); + if (last_slash != std::string::npos) { + std::string dir = model_path.substr(0, last_slash); + model_dir_ = dir; + decoder_path = dir + "/decoder.onnx"; + tokens_path = dir + "/tokens.txt"; + } + } + + language_ = "en"; + if (config.contains("language")) { + language_ = config["language"].get(); + } + + RAC_LOG_INFO("ONNX.STT", "Encoder: %s", encoder_path.c_str()); + RAC_LOG_INFO("ONNX.STT", "Decoder: %s", decoder_path.c_str()); + RAC_LOG_INFO("ONNX.STT", "Tokens: %s", tokens_path.c_str()); + RAC_LOG_INFO("ONNX.STT", "Language: %s", language_.c_str()); + + if (stat(encoder_path.c_str(), &path_stat) != 0) { + RAC_LOG_ERROR("ONNX.STT", "Encoder file not found: %s", encoder_path.c_str()); + return false; + } + if (stat(decoder_path.c_str(), &path_stat) != 0) { + RAC_LOG_ERROR("ONNX.STT", "Decoder file not found: %s", decoder_path.c_str()); + return false; + } + if (stat(tokens_path.c_str(), &path_stat) != 0) { + RAC_LOG_ERROR("ONNX.STT", "Tokens file not found: %s", tokens_path.c_str()); + return false; + } + + SherpaOnnxOfflineRecognizerConfig recognizer_config; + memset(&recognizer_config, 0, sizeof(recognizer_config)); + + recognizer_config.feat_config.sample_rate = 16000; + recognizer_config.feat_config.feature_dim = 80; + + recognizer_config.model_config.transducer.encoder = ""; + recognizer_config.model_config.transducer.decoder = ""; + recognizer_config.model_config.transducer.joiner = ""; + recognizer_config.model_config.paraformer.model = ""; + recognizer_config.model_config.nemo_ctc.model = ""; + recognizer_config.model_config.tdnn.model = ""; + + recognizer_config.model_config.whisper.encoder = encoder_path.c_str(); + recognizer_config.model_config.whisper.decoder = decoder_path.c_str(); + recognizer_config.model_config.whisper.language = language_.c_str(); + recognizer_config.model_config.whisper.task = "transcribe"; + recognizer_config.model_config.whisper.tail_paddings = -1; + + recognizer_config.model_config.tokens = tokens_path.c_str(); + recognizer_config.model_config.num_threads = 2; + recognizer_config.model_config.debug = 1; + recognizer_config.model_config.provider = "cpu"; + recognizer_config.model_config.model_type = "whisper"; + + recognizer_config.model_config.modeling_unit = "cjkchar"; + recognizer_config.model_config.bpe_vocab = ""; + recognizer_config.model_config.telespeech_ctc = ""; + + recognizer_config.model_config.sense_voice.model = ""; + recognizer_config.model_config.sense_voice.language = ""; + + recognizer_config.model_config.moonshine.preprocessor = ""; + recognizer_config.model_config.moonshine.encoder = ""; + recognizer_config.model_config.moonshine.uncached_decoder = ""; + recognizer_config.model_config.moonshine.cached_decoder = ""; + + recognizer_config.model_config.fire_red_asr.encoder = ""; + recognizer_config.model_config.fire_red_asr.decoder = ""; + + recognizer_config.model_config.dolphin.model = ""; + recognizer_config.model_config.zipformer_ctc.model = ""; + + recognizer_config.model_config.canary.encoder = ""; + recognizer_config.model_config.canary.decoder = ""; + recognizer_config.model_config.canary.src_lang = ""; + recognizer_config.model_config.canary.tgt_lang = ""; + + recognizer_config.model_config.wenet_ctc.model = ""; + recognizer_config.model_config.omnilingual.model = ""; + + recognizer_config.lm_config.model = ""; + recognizer_config.lm_config.scale = 1.0f; + + recognizer_config.decoding_method = "greedy_search"; + recognizer_config.max_active_paths = 4; + recognizer_config.hotwords_file = ""; + recognizer_config.hotwords_score = 1.5f; + recognizer_config.blank_penalty = 0.0f; + recognizer_config.rule_fsts = ""; + recognizer_config.rule_fars = ""; + + recognizer_config.hr.dict_dir = ""; + recognizer_config.hr.lexicon = ""; + recognizer_config.hr.rule_fsts = ""; + + RAC_LOG_INFO("ONNX.STT", "Creating SherpaOnnxOfflineRecognizer..."); + + sherpa_recognizer_ = SherpaOnnxCreateOfflineRecognizer(&recognizer_config); + + if (!sherpa_recognizer_) { + RAC_LOG_ERROR("ONNX.STT", "Failed to create SherpaOnnxOfflineRecognizer"); + return false; + } + + RAC_LOG_INFO("ONNX.STT", "STT model loaded successfully"); + model_loaded_ = true; + return true; + +#else + RAC_LOG_ERROR("ONNX.STT", "Sherpa-ONNX not available - streaming STT disabled"); + return false; +#endif +} + +bool ONNXSTT::is_model_loaded() const { + return model_loaded_; +} + +bool ONNXSTT::unload_model() { + std::lock_guard lock(mutex_); + +#if SHERPA_ONNX_AVAILABLE + for (auto& pair : sherpa_streams_) { + if (pair.second) { + SherpaOnnxDestroyOfflineStream(pair.second); + } + } + sherpa_streams_.clear(); + + if (sherpa_recognizer_) { + SherpaOnnxDestroyOfflineRecognizer(sherpa_recognizer_); + sherpa_recognizer_ = nullptr; + } +#endif + + model_loaded_ = false; + return true; +} + +STTModelType ONNXSTT::get_model_type() const { + return model_type_; +} + +STTResult ONNXSTT::transcribe(const STTRequest& request) { + STTResult result; + +#if SHERPA_ONNX_AVAILABLE + if (!sherpa_recognizer_ || !model_loaded_) { + RAC_LOG_ERROR("ONNX.STT", "STT not ready for transcription"); + result.text = "[Error: STT model not loaded]"; + return result; + } + + RAC_LOG_INFO("ONNX.STT", "Transcribing %zu samples at %d Hz", request.audio_samples.size(), + request.sample_rate); + + const SherpaOnnxOfflineStream* stream = SherpaOnnxCreateOfflineStream(sherpa_recognizer_); + if (!stream) { + RAC_LOG_ERROR("ONNX.STT", "Failed to create offline stream"); + result.text = "[Error: Failed to create stream]"; + return result; + } + + SherpaOnnxAcceptWaveformOffline(stream, request.sample_rate, request.audio_samples.data(), + static_cast(request.audio_samples.size())); + + RAC_LOG_DEBUG("ONNX.STT", "Decoding audio..."); + SherpaOnnxDecodeOfflineStream(sherpa_recognizer_, stream); + + const SherpaOnnxOfflineRecognizerResult* recognizer_result = + SherpaOnnxGetOfflineStreamResult(stream); + + if (recognizer_result && recognizer_result->text) { + result.text = recognizer_result->text; + RAC_LOG_INFO("ONNX.STT", "Transcription result: \"%s\"", result.text.c_str()); + + if (recognizer_result->lang) { + result.detected_language = recognizer_result->lang; + } + + SherpaOnnxDestroyOfflineRecognizerResult(recognizer_result); + } else { + result.text = ""; + RAC_LOG_DEBUG("ONNX.STT", "No transcription result (empty audio or silence)"); + } + + SherpaOnnxDestroyOfflineStream(stream); + + return result; + +#else + RAC_LOG_ERROR("ONNX.STT", "Sherpa-ONNX not available"); + result.text = "[Error: Sherpa-ONNX not available]"; + return result; +#endif +} + +bool ONNXSTT::supports_streaming() const { +#if SHERPA_ONNX_AVAILABLE + return false; +#else + return false; +#endif +} + +std::string ONNXSTT::create_stream(const nlohmann::json& config) { +#if SHERPA_ONNX_AVAILABLE + std::lock_guard lock(mutex_); + + if (!sherpa_recognizer_) { + RAC_LOG_ERROR("ONNX.STT", "Cannot create stream: recognizer not initialized"); + return ""; + } + + const SherpaOnnxOfflineStream* stream = SherpaOnnxCreateOfflineStream(sherpa_recognizer_); + if (!stream) { + RAC_LOG_ERROR("ONNX.STT", "Failed to create offline stream"); + return ""; + } + + std::string stream_id = "stt_stream_" + std::to_string(++stream_counter_); + sherpa_streams_[stream_id] = stream; + + RAC_LOG_DEBUG("ONNX.STT", "Created stream: %s", stream_id.c_str()); + return stream_id; +#else + return ""; +#endif +} + +bool ONNXSTT::feed_audio(const std::string& stream_id, const std::vector& samples, + int sample_rate) { +#if SHERPA_ONNX_AVAILABLE + std::lock_guard lock(mutex_); + + auto it = sherpa_streams_.find(stream_id); + if (it == sherpa_streams_.end() || !it->second) { + RAC_LOG_ERROR("ONNX.STT", "Stream not found: %s", stream_id.c_str()); + return false; + } + + SherpaOnnxAcceptWaveformOffline(it->second, sample_rate, samples.data(), + static_cast(samples.size())); + + return true; +#else + return false; +#endif +} + +bool ONNXSTT::is_stream_ready(const std::string& stream_id) { +#if SHERPA_ONNX_AVAILABLE + std::lock_guard lock(mutex_); + auto it = sherpa_streams_.find(stream_id); + return it != sherpa_streams_.end() && it->second != nullptr; +#else + return false; +#endif +} + +STTResult ONNXSTT::decode(const std::string& stream_id) { + STTResult result; + +#if SHERPA_ONNX_AVAILABLE + std::lock_guard lock(mutex_); + + auto it = sherpa_streams_.find(stream_id); + if (it == sherpa_streams_.end() || !it->second) { + RAC_LOG_ERROR("ONNX.STT", "Stream not found for decode: %s", stream_id.c_str()); + return result; + } + + if (!sherpa_recognizer_) { + RAC_LOG_ERROR("ONNX.STT", "Recognizer not available"); + return result; + } + + SherpaOnnxDecodeOfflineStream(sherpa_recognizer_, it->second); + + const SherpaOnnxOfflineRecognizerResult* recognizer_result = + SherpaOnnxGetOfflineStreamResult(it->second); + + if (recognizer_result && recognizer_result->text) { + result.text = recognizer_result->text; + RAC_LOG_INFO("ONNX.STT", "Decode result: \"%s\"", result.text.c_str()); + + if (recognizer_result->lang) { + result.detected_language = recognizer_result->lang; + } + + SherpaOnnxDestroyOfflineRecognizerResult(recognizer_result); + } +#endif + + return result; +} + +bool ONNXSTT::is_endpoint(const std::string& stream_id) { + return false; +} + +void ONNXSTT::input_finished(const std::string& stream_id) {} + +void ONNXSTT::reset_stream(const std::string& stream_id) { +#if SHERPA_ONNX_AVAILABLE + std::lock_guard lock(mutex_); + + auto it = sherpa_streams_.find(stream_id); + if (it != sherpa_streams_.end() && it->second) { + SherpaOnnxDestroyOfflineStream(it->second); + + if (sherpa_recognizer_) { + it->second = SherpaOnnxCreateOfflineStream(sherpa_recognizer_); + } else { + sherpa_streams_.erase(it); + } + } +#endif +} + +void ONNXSTT::destroy_stream(const std::string& stream_id) { +#if SHERPA_ONNX_AVAILABLE + std::lock_guard lock(mutex_); + + auto it = sherpa_streams_.find(stream_id); + if (it != sherpa_streams_.end()) { + if (it->second) { + SherpaOnnxDestroyOfflineStream(it->second); + } + sherpa_streams_.erase(it); + RAC_LOG_DEBUG("ONNX.STT", "Destroyed stream: %s", stream_id.c_str()); + } +#endif +} + +void ONNXSTT::cancel() { + cancel_requested_ = true; +} + +std::vector ONNXSTT::get_supported_languages() const { + return {"en", "zh", "de", "es", "ru", "ko", "fr", "ja", "pt", "tr", "pl", "ca", "nl", + "ar", "sv", "it", "id", "hi", "fi", "vi", "he", "uk", "el", "ms", "cs", "ro", + "da", "hu", "ta", "no", "th", "ur", "hr", "bg", "lt", "la", "mi", "ml", "cy", + "sk", "te", "fa", "lv", "bn", "sr", "az", "sl", "kn", "et", "mk", "br", "eu", + "is", "hy", "ne", "mn", "bs", "kk", "sq", "sw", "gl", "mr", "pa", "si", "km", + "sn", "yo", "so", "af", "oc", "ka", "be", "tg", "sd", "gu", "am", "yi", "lo", + "uz", "fo", "ht", "ps", "tk", "nn", "mt", "sa", "lb", "my", "bo", "tl", "mg", + "as", "tt", "haw", "ln", "ha", "ba", "jw", "su"}; +} + +// ============================================================================= +// ONNXTTS Implementation +// ============================================================================= + +ONNXTTS::ONNXTTS(ONNXBackendNew* backend) : backend_(backend) {} + +ONNXTTS::~ONNXTTS() { + try { + unload_model(); + } catch (...) {} +} + +bool ONNXTTS::is_ready() const { + std::lock_guard lock(mutex_); + return model_loaded_ && sherpa_tts_ != nullptr; +} + +bool ONNXTTS::load_model(const std::string& model_path, TTSModelType model_type, + const nlohmann::json& config) { + std::lock_guard lock(mutex_); + +#if SHERPA_ONNX_AVAILABLE + if (sherpa_tts_) { + SherpaOnnxDestroyOfflineTts(sherpa_tts_); + sherpa_tts_ = nullptr; + } + + model_type_ = model_type; + model_dir_ = model_path; + + RAC_LOG_INFO("ONNX.TTS", "Loading model from: %s", model_path.c_str()); + + std::string model_onnx_path; + std::string tokens_path; + std::string data_dir; + std::string lexicon_path; + + struct stat path_stat; + if (stat(model_path.c_str(), &path_stat) != 0) { + RAC_LOG_ERROR("ONNX.TTS", "Model path does not exist: %s", model_path.c_str()); + return false; + } + + if (S_ISDIR(path_stat.st_mode)) { + model_onnx_path = model_path + "/model.onnx"; + tokens_path = model_path + "/tokens.txt"; + data_dir = model_path + "/espeak-ng-data"; + lexicon_path = model_path + "/lexicon.txt"; + + if (stat(model_onnx_path.c_str(), &path_stat) != 0) { + DIR* dir = opendir(model_path.c_str()); + if (dir) { + struct dirent* entry; + while ((entry = readdir(dir)) != nullptr) { + std::string filename = entry->d_name; + if (filename.size() > 5 && filename.substr(filename.size() - 5) == ".onnx") { + model_onnx_path = model_path + "/" + filename; + RAC_LOG_DEBUG("ONNX.TTS", "Found model file: %s", model_onnx_path.c_str()); + break; + } + } + closedir(dir); + } + } + + if (stat(data_dir.c_str(), &path_stat) != 0) { + std::string alt_data_dir = model_path + "/data"; + if (stat(alt_data_dir.c_str(), &path_stat) == 0) { + data_dir = alt_data_dir; + } + } + + if (stat(lexicon_path.c_str(), &path_stat) != 0) { + std::string alt_lexicon = model_path + "/lexicon"; + if (stat(alt_lexicon.c_str(), &path_stat) == 0) { + lexicon_path = alt_lexicon; + } + } + } else { + model_onnx_path = model_path; + + size_t last_slash = model_path.find_last_of('/'); + if (last_slash != std::string::npos) { + std::string dir = model_path.substr(0, last_slash); + tokens_path = dir + "/tokens.txt"; + data_dir = dir + "/espeak-ng-data"; + lexicon_path = dir + "/lexicon.txt"; + model_dir_ = dir; + } + } + + RAC_LOG_INFO("ONNX.TTS", "Model ONNX: %s", model_onnx_path.c_str()); + RAC_LOG_INFO("ONNX.TTS", "Tokens: %s", tokens_path.c_str()); + + if (stat(model_onnx_path.c_str(), &path_stat) != 0) { + RAC_LOG_ERROR("ONNX.TTS", "Model ONNX file not found: %s", model_onnx_path.c_str()); + return false; + } + + if (stat(tokens_path.c_str(), &path_stat) != 0) { + RAC_LOG_ERROR("ONNX.TTS", "Tokens file not found: %s", tokens_path.c_str()); + return false; + } + + SherpaOnnxOfflineTtsConfig tts_config; + memset(&tts_config, 0, sizeof(tts_config)); + + tts_config.model.vits.model = model_onnx_path.c_str(); + tts_config.model.vits.tokens = tokens_path.c_str(); + + if (stat(lexicon_path.c_str(), &path_stat) == 0 && S_ISREG(path_stat.st_mode)) { + tts_config.model.vits.lexicon = lexicon_path.c_str(); + RAC_LOG_DEBUG("ONNX.TTS", "Using lexicon file: %s", lexicon_path.c_str()); + } + + if (stat(data_dir.c_str(), &path_stat) == 0 && S_ISDIR(path_stat.st_mode)) { + tts_config.model.vits.data_dir = data_dir.c_str(); + RAC_LOG_DEBUG("ONNX.TTS", "Using espeak-ng data dir: %s", data_dir.c_str()); + } + + tts_config.model.vits.noise_scale = 0.667f; + tts_config.model.vits.noise_scale_w = 0.8f; + tts_config.model.vits.length_scale = 1.0f; + + tts_config.model.provider = "cpu"; + tts_config.model.num_threads = 2; + tts_config.model.debug = 1; + + RAC_LOG_INFO("ONNX.TTS", "Creating SherpaOnnxOfflineTts..."); + + const SherpaOnnxOfflineTts* new_tts = nullptr; + try { + new_tts = SherpaOnnxCreateOfflineTts(&tts_config); + } catch (const std::exception& e) { + RAC_LOG_ERROR("ONNX.TTS", "Exception during TTS creation: %s", e.what()); + return false; + } catch (...) { + RAC_LOG_ERROR("ONNX.TTS", "Unknown exception during TTS creation"); + return false; + } + + if (!new_tts) { + RAC_LOG_ERROR("ONNX.TTS", "Failed to create SherpaOnnxOfflineTts"); + return false; + } + + sherpa_tts_ = new_tts; + + sample_rate_ = SherpaOnnxOfflineTtsSampleRate(sherpa_tts_); + int num_speakers = SherpaOnnxOfflineTtsNumSpeakers(sherpa_tts_); + + RAC_LOG_INFO("ONNX.TTS", "TTS model loaded successfully"); + RAC_LOG_INFO("ONNX.TTS", "Sample rate: %d, speakers: %d", sample_rate_, num_speakers); + + voices_.clear(); + for (int i = 0; i < num_speakers; ++i) { + VoiceInfo voice; + voice.id = std::to_string(i); + voice.name = "Speaker " + std::to_string(i); + voice.language = "en"; + voices_.push_back(voice); + } + + model_loaded_ = true; + return true; + +#else + RAC_LOG_ERROR("ONNX.TTS", "Sherpa-ONNX not available - TTS disabled"); + return false; +#endif +} + +bool ONNXTTS::is_model_loaded() const { + return model_loaded_; +} + +bool ONNXTTS::unload_model() { + std::lock_guard lock(mutex_); + +#if SHERPA_ONNX_AVAILABLE + model_loaded_ = false; + + if (active_synthesis_count_ > 0) { + RAC_LOG_WARNING("ONNX.TTS", + "Unloading model while %d synthesis operation(s) may be in progress", + active_synthesis_count_.load()); + } + + voices_.clear(); + + if (sherpa_tts_) { + SherpaOnnxDestroyOfflineTts(sherpa_tts_); + sherpa_tts_ = nullptr; + } +#else + model_loaded_ = false; + voices_.clear(); +#endif + + return true; +} + +TTSModelType ONNXTTS::get_model_type() const { + return model_type_; +} + +TTSResult ONNXTTS::synthesize(const TTSRequest& request) { + TTSResult result; + +#if SHERPA_ONNX_AVAILABLE + struct SynthesisGuard { + std::atomic& count_; + SynthesisGuard(std::atomic& count) : count_(count) { count_++; } + ~SynthesisGuard() { count_--; } + }; + SynthesisGuard guard(active_synthesis_count_); + + const SherpaOnnxOfflineTts* tts_ptr = nullptr; + { + std::lock_guard lock(mutex_); + + if (!sherpa_tts_ || !model_loaded_) { + RAC_LOG_ERROR("ONNX.TTS", "TTS not ready for synthesis"); + return result; + } + + tts_ptr = sherpa_tts_; + } + + RAC_LOG_INFO("ONNX.TTS", "Synthesizing: \"%s...\"", request.text.substr(0, 50).c_str()); + + int speaker_id = 0; + if (!request.voice_id.empty()) { + try { + speaker_id = std::stoi(request.voice_id); + } catch (...) {} + } + + float speed = request.speed_rate > 0 ? request.speed_rate : 1.0f; + + RAC_LOG_DEBUG("ONNX.TTS", "Speaker ID: %d, Speed: %.2f", speaker_id, speed); + + const SherpaOnnxGeneratedAudio* audio = + SherpaOnnxOfflineTtsGenerate(tts_ptr, request.text.c_str(), speaker_id, speed); + + if (!audio || audio->n <= 0) { + RAC_LOG_ERROR("ONNX.TTS", "Failed to generate audio"); + return result; + } + + RAC_LOG_INFO("ONNX.TTS", "Generated %d samples at %d Hz", audio->n, audio->sample_rate); + + result.audio_samples.assign(audio->samples, audio->samples + audio->n); + result.sample_rate = audio->sample_rate; + result.duration_ms = + (static_cast(audio->n) / static_cast(audio->sample_rate)) * 1000.0; + + SherpaOnnxDestroyOfflineTtsGeneratedAudio(audio); + + RAC_LOG_INFO("ONNX.TTS", "Synthesis complete. Duration: %.2fs", (result.duration_ms / 1000.0)); + +#else + RAC_LOG_ERROR("ONNX.TTS", "Sherpa-ONNX not available"); +#endif + + return result; +} + +bool ONNXTTS::supports_streaming() const { + return false; +} + +void ONNXTTS::cancel() { + cancel_requested_ = true; +} + +std::vector ONNXTTS::get_voices() const { + std::lock_guard lock(mutex_); + return voices_; +} + +std::string ONNXTTS::get_default_voice(const std::string& language) const { + return "0"; +} + +// ============================================================================= +// ONNXVAD Implementation +// ============================================================================= + +ONNXVAD::ONNXVAD(ONNXBackendNew* backend) : backend_(backend) {} + +ONNXVAD::~ONNXVAD() { + unload_model(); +} + +bool ONNXVAD::is_ready() const { + return model_loaded_; +} + +bool ONNXVAD::load_model(const std::string& model_path, VADModelType model_type, + const nlohmann::json& config) { + std::lock_guard lock(mutex_); + model_loaded_ = true; + return true; +} + +bool ONNXVAD::is_model_loaded() const { + return model_loaded_; +} + +bool ONNXVAD::unload_model() { + std::lock_guard lock(mutex_); + model_loaded_ = false; + return true; +} + +bool ONNXVAD::configure_vad(const VADConfig& config) { + config_ = config; + return true; +} + +VADResult ONNXVAD::process(const std::vector& audio_samples, int sample_rate) { + VADResult result; + return result; +} + +std::vector ONNXVAD::detect_segments(const std::vector& audio_samples, + int sample_rate) { + return {}; +} + +std::string ONNXVAD::create_stream(const VADConfig& config) { + return ""; +} + +VADResult ONNXVAD::feed_audio(const std::string& stream_id, const std::vector& samples, + int sample_rate) { + return {}; +} + +void ONNXVAD::destroy_stream(const std::string& stream_id) {} + +void ONNXVAD::reset() {} + +VADConfig ONNXVAD::get_vad_config() const { + return config_; +} + +} // namespace runanywhere diff --git a/sdk/runanywhere-commons/src/backends/onnx/onnx_backend.h b/sdk/runanywhere-commons/src/backends/onnx/onnx_backend.h new file mode 100644 index 000000000..d4db37428 --- /dev/null +++ b/sdk/runanywhere-commons/src/backends/onnx/onnx_backend.h @@ -0,0 +1,363 @@ +#ifndef RUNANYWHERE_ONNX_BACKEND_H +#define RUNANYWHERE_ONNX_BACKEND_H + +/** + * ONNX Backend - Internal implementation for STT, TTS, VAD + * + * This backend uses ONNX Runtime for general ML inference and + * Sherpa-ONNX for speech-specific tasks (STT, TTS, VAD). + * Internal C++ implementation wrapped by RAC API (rac_onnx.cpp). + */ + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include + +// Sherpa-ONNX C API for TTS/STT +#if SHERPA_ONNX_AVAILABLE +#include +#endif + +namespace runanywhere { + +// ============================================================================= +// INTERNAL TYPES +// ============================================================================= + +enum class DeviceType { + CPU = 0, + GPU = 1, + NEURAL_ENGINE = 2, + COREML = 6, +}; + +struct DeviceInfo { + DeviceType device_type = DeviceType::CPU; + std::string device_name; + std::string platform; + size_t available_memory = 0; + int cpu_cores = 0; +}; + +// ============================================================================= +// STT TYPES +// ============================================================================= + +enum class STTModelType { + WHISPER, + ZIPFORMER, + TRANSDUCER, + PARAFORMER, + CUSTOM +}; + +struct AudioSegment { + std::string text; + double start_time_ms = 0.0; + double end_time_ms = 0.0; + float confidence = 0.0f; + std::string language; +}; + +struct STTRequest { + std::vector audio_samples; + int sample_rate = 16000; + std::string language; + bool detect_language = false; + bool word_timestamps = false; +}; + +struct STTResult { + std::string text; + std::string detected_language; + std::vector segments; + double audio_duration_ms = 0.0; + double inference_time_ms = 0.0; + float confidence = 0.0f; + bool is_final = true; +}; + +// ============================================================================= +// TTS TYPES +// ============================================================================= + +enum class TTSModelType { + PIPER, + COQUI, + BARK, + ESPEAK, + CUSTOM +}; + +struct VoiceInfo { + std::string id; + std::string name; + std::string language; + std::string gender; + std::string description; + int sample_rate = 22050; +}; + +struct TTSRequest { + std::string text; + std::string voice_id; + std::string language; + float speed_rate = 1.0f; + int sample_rate = 22050; +}; + +struct TTSResult { + std::vector audio_samples; + int sample_rate = 22050; + int channels = 1; + double duration_ms = 0.0; + double inference_time_ms = 0.0; +}; + +// ============================================================================= +// VAD TYPES +// ============================================================================= + +enum class VADModelType { + SILERO, + WEBRTC, + SHERPA, + CUSTOM +}; + +struct SpeechSegment { + double start_time_ms = 0.0; + double end_time_ms = 0.0; + float confidence = 0.0f; + bool is_speech = true; +}; + +struct VADConfig { + float threshold = 0.5f; + int min_speech_duration_ms = 250; + int min_silence_duration_ms = 100; + int padding_ms = 30; + int window_size_ms = 32; + int sample_rate = 16000; +}; + +struct VADResult { + bool is_speech = false; + float probability = 0.0f; + double timestamp_ms = 0.0; + std::vector segments; +}; + +// ============================================================================= +// TELEMETRY (simple inline implementation) +// ============================================================================= + +using TelemetryCallback = std::function; + +class TelemetryCollector { + public: + void set_callback(TelemetryCallback callback) { callback_ = callback; } + + void emit(const std::string& event_type, const nlohmann::json& data = {}) { + if (callback_) { + nlohmann::json event = { + {"type", event_type}, + {"data", data}, + {"timestamp", std::chrono::system_clock::now().time_since_epoch().count()}}; + callback_(event.dump()); + } + } + + private: + TelemetryCallback callback_; +}; + +// ============================================================================= +// FORWARD DECLARATIONS +// ============================================================================= + +class ONNXSTT; +class ONNXTTS; +class ONNXVAD; + +// ============================================================================= +// ONNX BACKEND +// ============================================================================= + +class ONNXBackendNew { + public: + ONNXBackendNew(); + ~ONNXBackendNew(); + + bool initialize(const nlohmann::json& config = {}); + bool is_initialized() const; + void cleanup(); + + DeviceType get_device_type() const; + size_t get_memory_usage() const; + + const OrtApi* get_ort_api() const { return ort_api_; } + OrtEnv* get_ort_env() const { return ort_env_; } + + const DeviceInfo& get_device_info() const { return device_info_; } + + void set_telemetry_callback(TelemetryCallback callback); + + // Get capability implementations + ONNXSTT* get_stt() { return stt_.get(); } + ONNXTTS* get_tts() { return tts_.get(); } + ONNXVAD* get_vad() { return vad_.get(); } + + private: + bool initialize_ort(); + void create_capabilities(); + + bool initialized_ = false; + const OrtApi* ort_api_ = nullptr; + OrtEnv* ort_env_ = nullptr; + nlohmann::json config_; + DeviceInfo device_info_; + TelemetryCollector telemetry_; + + std::unique_ptr stt_; + std::unique_ptr tts_; + std::unique_ptr vad_; + + mutable std::mutex mutex_; +}; + +// ============================================================================= +// STT IMPLEMENTATION +// ============================================================================= + +class ONNXSTT { + public: + explicit ONNXSTT(ONNXBackendNew* backend); + ~ONNXSTT(); + + bool is_ready() const; + bool load_model(const std::string& model_path, STTModelType model_type = STTModelType::WHISPER, + const nlohmann::json& config = {}); + bool is_model_loaded() const; + bool unload_model(); + STTModelType get_model_type() const; + + STTResult transcribe(const STTRequest& request); + bool supports_streaming() const; + + std::string create_stream(const nlohmann::json& config = {}); + bool feed_audio(const std::string& stream_id, const std::vector& samples, int sample_rate); + bool is_stream_ready(const std::string& stream_id); + STTResult decode(const std::string& stream_id); + bool is_endpoint(const std::string& stream_id); + void input_finished(const std::string& stream_id); + void reset_stream(const std::string& stream_id); + void destroy_stream(const std::string& stream_id); + + void cancel(); + std::vector get_supported_languages() const; + + private: + ONNXBackendNew* backend_; + OrtSession* whisper_session_ = nullptr; +#if SHERPA_ONNX_AVAILABLE + const SherpaOnnxOfflineRecognizer* sherpa_recognizer_ = nullptr; + std::unordered_map sherpa_streams_; +#else + void* sherpa_recognizer_ = nullptr; +#endif + STTModelType model_type_ = STTModelType::WHISPER; + bool model_loaded_ = false; + std::atomic cancel_requested_{false}; + std::unordered_map streams_; + int stream_counter_ = 0; + std::string model_dir_; + std::string language_; + mutable std::mutex mutex_; +}; + +// ============================================================================= +// TTS IMPLEMENTATION +// ============================================================================= + +class ONNXTTS { + public: + explicit ONNXTTS(ONNXBackendNew* backend); + ~ONNXTTS(); + + bool is_ready() const; + bool load_model(const std::string& model_path, TTSModelType model_type = TTSModelType::PIPER, + const nlohmann::json& config = {}); + bool is_model_loaded() const; + bool unload_model(); + TTSModelType get_model_type() const; + + TTSResult synthesize(const TTSRequest& request); + bool supports_streaming() const; + + void cancel(); + std::vector get_voices() const; + std::string get_default_voice(const std::string& language) const; + + private: + ONNXBackendNew* backend_; +#if SHERPA_ONNX_AVAILABLE + const SherpaOnnxOfflineTts* sherpa_tts_ = nullptr; +#else + void* sherpa_tts_ = nullptr; +#endif + TTSModelType model_type_ = TTSModelType::PIPER; + bool model_loaded_ = false; + std::atomic cancel_requested_{false}; + std::atomic active_synthesis_count_{0}; + std::vector voices_; + std::string model_dir_; + int sample_rate_ = 22050; + mutable std::mutex mutex_; +}; + +// ============================================================================= +// VAD IMPLEMENTATION +// ============================================================================= + +class ONNXVAD { + public: + explicit ONNXVAD(ONNXBackendNew* backend); + ~ONNXVAD(); + + bool is_ready() const; + bool load_model(const std::string& model_path, VADModelType model_type = VADModelType::SILERO, + const nlohmann::json& config = {}); + bool is_model_loaded() const; + bool unload_model(); + + bool configure_vad(const VADConfig& config); + VADResult process(const std::vector& audio_samples, int sample_rate); + std::vector detect_segments(const std::vector& audio_samples, int sample_rate); + + std::string create_stream(const VADConfig& config = {}); + VADResult feed_audio(const std::string& stream_id, const std::vector& samples, int sample_rate); + void destroy_stream(const std::string& stream_id); + + void reset(); + VADConfig get_vad_config() const; + + private: + ONNXBackendNew* backend_; + void* sherpa_vad_ = nullptr; + VADConfig config_; + bool model_loaded_ = false; + mutable std::mutex mutex_; +}; + +} // namespace runanywhere + +#endif // RUNANYWHERE_ONNX_BACKEND_H diff --git a/sdk/runanywhere-commons/src/backends/onnx/rac_backend_onnx_register.cpp b/sdk/runanywhere-commons/src/backends/onnx/rac_backend_onnx_register.cpp new file mode 100644 index 000000000..20de3076f --- /dev/null +++ b/sdk/runanywhere-commons/src/backends/onnx/rac_backend_onnx_register.cpp @@ -0,0 +1,560 @@ +/** + * @file rac_backend_onnx_register.cpp + * @brief RunAnywhere Core - ONNX Backend RAC Registration + * + * Registers the ONNX backend with the module and service registries. + * Provides vtable implementations for STT, TTS, and VAD services. + */ + +#include "rac_stt_onnx.h" +#include "rac_tts_onnx.h" +#include "rac_vad_onnx.h" + +#include +#include +#include +#include + +#include "rac/core/rac_core.h" +#include "rac/core/rac_error.h" +#include "rac/core/rac_logger.h" +#include "rac/features/stt/rac_stt_service.h" +#include "rac/features/tts/rac_tts_service.h" +#include "rac/infrastructure/model_management/rac_model_strategy.h" +#include "rac/infrastructure/model_management/rac_model_types.h" + +// ============================================================================= +// STT VTABLE IMPLEMENTATION +// ============================================================================= + +namespace { + +const char* LOG_CAT = "ONNX"; + +/** + * Convert Int16 PCM audio to Float32 normalized to [-1.0, 1.0]. + * SDKs may send Int16 audio but Sherpa-ONNX expects Float32. + */ +static std::vector convert_int16_to_float32(const void* int16_data, size_t byte_count) { + const int16_t* samples = static_cast(int16_data); + size_t num_samples = byte_count / sizeof(int16_t); + + std::vector float_samples(num_samples); + for (size_t i = 0; i < num_samples; ++i) { + float_samples[i] = static_cast(samples[i]) / 32768.0f; + } + + return float_samples; +} + +// Initialize (no-op for ONNX - model loaded during create) +static rac_result_t onnx_stt_vtable_initialize(void* impl, const char* model_path) { + (void)impl; + (void)model_path; + return RAC_SUCCESS; +} + +// Transcribe - converts Int16 PCM to Float32 for Sherpa-ONNX +static rac_result_t onnx_stt_vtable_transcribe(void* impl, const void* audio_data, + size_t audio_size, const rac_stt_options_t* options, + rac_stt_result_t* out_result) { + std::vector float_samples = convert_int16_to_float32(audio_data, audio_size); + return rac_stt_onnx_transcribe(impl, float_samples.data(), float_samples.size(), options, + out_result); +} + +// Stream transcription - uses ONNX streaming API +static rac_result_t onnx_stt_vtable_transcribe_stream(void* impl, const void* audio_data, + size_t audio_size, + const rac_stt_options_t* options, + rac_stt_stream_callback_t callback, + void* user_data) { + (void)options; + + rac_handle_t stream = nullptr; + rac_result_t result = rac_stt_onnx_create_stream(impl, &stream); + if (result != RAC_SUCCESS) { + return result; + } + + std::vector float_samples = convert_int16_to_float32(audio_data, audio_size); + + result = rac_stt_onnx_feed_audio(impl, stream, float_samples.data(), float_samples.size()); + if (result != RAC_SUCCESS) { + rac_stt_onnx_destroy_stream(impl, stream); + return result; + } + + rac_stt_onnx_input_finished(impl, stream); + + char* text = nullptr; + result = rac_stt_onnx_decode_stream(impl, stream, &text); + if (result == RAC_SUCCESS && callback && text) { + callback(text, RAC_TRUE, user_data); + } + + rac_stt_onnx_destroy_stream(impl, stream); + if (text) free(text); + + return result; +} + +// Get info +static rac_result_t onnx_stt_vtable_get_info(void* impl, rac_stt_info_t* out_info) { + if (!out_info) return RAC_ERROR_NULL_POINTER; + + out_info->is_ready = RAC_TRUE; + out_info->supports_streaming = rac_stt_onnx_supports_streaming(impl); + out_info->current_model = nullptr; + + return RAC_SUCCESS; +} + +// Cleanup +static rac_result_t onnx_stt_vtable_cleanup(void* impl) { + (void)impl; + return RAC_SUCCESS; +} + +// Destroy +static void onnx_stt_vtable_destroy(void* impl) { + if (impl) { + rac_stt_onnx_destroy(impl); + } +} + +// Static vtable for ONNX STT +static const rac_stt_service_ops_t g_onnx_stt_ops = { + .initialize = onnx_stt_vtable_initialize, + .transcribe = onnx_stt_vtable_transcribe, + .transcribe_stream = onnx_stt_vtable_transcribe_stream, + .get_info = onnx_stt_vtable_get_info, + .cleanup = onnx_stt_vtable_cleanup, + .destroy = onnx_stt_vtable_destroy, +}; + +// ============================================================================= +// TTS VTABLE IMPLEMENTATION +// ============================================================================= + +static rac_result_t onnx_tts_vtable_initialize(void* impl) { + (void)impl; + return RAC_SUCCESS; +} + +static rac_result_t onnx_tts_vtable_synthesize(void* impl, const char* text, + const rac_tts_options_t* options, + rac_tts_result_t* out_result) { + return rac_tts_onnx_synthesize(impl, text, options, out_result); +} + +static rac_result_t onnx_tts_vtable_synthesize_stream(void* impl, const char* text, + const rac_tts_options_t* options, + rac_tts_stream_callback_t callback, + void* user_data) { + rac_tts_result_t result = {}; + rac_result_t status = rac_tts_onnx_synthesize(impl, text, options, &result); + if (status == RAC_SUCCESS && callback) { + callback(result.audio_data, result.audio_size, user_data); + } + return status; +} + +static rac_result_t onnx_tts_vtable_stop(void* impl) { + rac_tts_onnx_stop(impl); + return RAC_SUCCESS; +} + +static rac_result_t onnx_tts_vtable_get_info(void* impl, rac_tts_info_t* out_info) { + (void)impl; + if (!out_info) return RAC_ERROR_NULL_POINTER; + + out_info->is_ready = RAC_TRUE; + out_info->is_synthesizing = RAC_FALSE; + out_info->available_voices = nullptr; + out_info->num_voices = 0; + + return RAC_SUCCESS; +} + +static rac_result_t onnx_tts_vtable_cleanup(void* impl) { + (void)impl; + return RAC_SUCCESS; +} + +static void onnx_tts_vtable_destroy(void* impl) { + if (impl) { + rac_tts_onnx_destroy(impl); + } +} + +static const rac_tts_service_ops_t g_onnx_tts_ops = { + .initialize = onnx_tts_vtable_initialize, + .synthesize = onnx_tts_vtable_synthesize, + .synthesize_stream = onnx_tts_vtable_synthesize_stream, + .stop = onnx_tts_vtable_stop, + .get_info = onnx_tts_vtable_get_info, + .cleanup = onnx_tts_vtable_cleanup, + .destroy = onnx_tts_vtable_destroy, +}; + +// ============================================================================= +// SERVICE PROVIDERS +// ============================================================================= + +const char* const MODULE_ID = "onnx"; +const char* const STT_PROVIDER_NAME = "ONNXSTTService"; +const char* const TTS_PROVIDER_NAME = "ONNXTTSService"; +const char* const VAD_PROVIDER_NAME = "ONNXVADService"; + +// STT can_handle +rac_bool_t onnx_stt_can_handle(const rac_service_request_t* request, void* user_data) { + (void)user_data; + + RAC_LOG_INFO(LOG_CAT, "onnx_stt_can_handle called"); + + if (request == nullptr) { + RAC_LOG_INFO(LOG_CAT, "onnx_stt_can_handle: request is null -> FALSE"); + return RAC_FALSE; + } + + if (request->identifier == nullptr || request->identifier[0] == '\0') { + RAC_LOG_INFO(LOG_CAT, "onnx_stt_can_handle: no identifier -> TRUE (default)"); + return RAC_TRUE; + } + + const char* path = request->identifier; + RAC_LOG_INFO(LOG_CAT, "onnx_stt_can_handle: checking path=%s", path); + + if (strstr(path, "whisper") != nullptr || strstr(path, "zipformer") != nullptr || + strstr(path, "paraformer") != nullptr || strstr(path, ".onnx") != nullptr) { + RAC_LOG_INFO(LOG_CAT, "onnx_stt_can_handle: path matches -> TRUE"); + return RAC_TRUE; + } + + RAC_LOG_INFO(LOG_CAT, "onnx_stt_can_handle: path doesn't match -> FALSE"); + return RAC_FALSE; +} + +// STT create with vtable +rac_handle_t onnx_stt_create(const rac_service_request_t* request, void* user_data) { + (void)user_data; + + RAC_LOG_INFO(LOG_CAT, "onnx_stt_create ENTRY - provider create callback invoked"); + + if (request == nullptr) { + RAC_LOG_ERROR(LOG_CAT, "onnx_stt_create: request is null"); + return nullptr; + } + + RAC_LOG_INFO(LOG_CAT, "Creating ONNX STT service for: %s", + request->identifier ? request->identifier : "(default)"); + + rac_handle_t backend_handle = nullptr; + RAC_LOG_INFO(LOG_CAT, "Calling rac_stt_onnx_create..."); + rac_result_t result = rac_stt_onnx_create(request->identifier, nullptr, &backend_handle); + if (result != RAC_SUCCESS) { + RAC_LOG_ERROR(LOG_CAT, "rac_stt_onnx_create failed with result: %d", result); + return nullptr; + } + RAC_LOG_INFO(LOG_CAT, "rac_stt_onnx_create succeeded, backend_handle=%p", backend_handle); + + auto* service = static_cast(malloc(sizeof(rac_stt_service_t))); + if (!service) { + RAC_LOG_ERROR(LOG_CAT, "Failed to allocate rac_stt_service_t"); + rac_stt_onnx_destroy(backend_handle); + return nullptr; + } + + service->ops = &g_onnx_stt_ops; + service->impl = backend_handle; + service->model_id = request->identifier ? strdup(request->identifier) : nullptr; + + RAC_LOG_INFO(LOG_CAT, "ONNX STT service created successfully, service=%p", service); + return service; +} + +// TTS can_handle +rac_bool_t onnx_tts_can_handle(const rac_service_request_t* request, void* user_data) { + (void)user_data; + + if (request == nullptr) { + return RAC_FALSE; + } + + if (request->identifier == nullptr || request->identifier[0] == '\0') { + return RAC_TRUE; + } + + const char* path = request->identifier; + if (strstr(path, "piper") != nullptr || strstr(path, "vits") != nullptr || + strstr(path, ".onnx") != nullptr) { + return RAC_TRUE; + } + + return RAC_FALSE; +} + +// TTS create with vtable +rac_handle_t onnx_tts_create(const rac_service_request_t* request, void* user_data) { + (void)user_data; + + if (request == nullptr) { + return nullptr; + } + + RAC_LOG_INFO(LOG_CAT, "Creating ONNX TTS service for: %s", + request->identifier ? request->identifier : "(default)"); + + rac_handle_t backend_handle = nullptr; + rac_result_t result = rac_tts_onnx_create(request->identifier, nullptr, &backend_handle); + if (result != RAC_SUCCESS) { + RAC_LOG_ERROR(LOG_CAT, "Failed to create ONNX TTS backend: %d", result); + return nullptr; + } + + auto* service = static_cast(malloc(sizeof(rac_tts_service_t))); + if (!service) { + rac_tts_onnx_destroy(backend_handle); + return nullptr; + } + + service->ops = &g_onnx_tts_ops; + service->impl = backend_handle; + service->model_id = request->identifier ? strdup(request->identifier) : nullptr; + + RAC_LOG_INFO(LOG_CAT, "ONNX TTS service created successfully"); + return service; +} + +// VAD can_handle +rac_bool_t onnx_vad_can_handle(const rac_service_request_t* request, void* user_data) { + (void)user_data; + (void)request; + return RAC_TRUE; +} + +// VAD create +rac_handle_t onnx_vad_create(const rac_service_request_t* request, void* user_data) { + (void)user_data; + + const char* model_path = nullptr; + if (request != nullptr) { + model_path = request->identifier; + } + + rac_handle_t handle = nullptr; + rac_result_t result = rac_vad_onnx_create(model_path, nullptr, &handle); + return (result == RAC_SUCCESS) ? handle : nullptr; +} + +// ============================================================================= +// STORAGE AND DOWNLOAD STRATEGIES +// ============================================================================= + +rac_result_t onnx_storage_find_model_path(const char* model_id, const char* model_folder, + char* out_path, size_t path_size, void* user_data) { + (void)user_data; + + if (!model_id || !model_folder || !out_path || path_size == 0) { + return RAC_ERROR_INVALID_PARAMETER; + } + + int written = snprintf(out_path, path_size, "%s/%s.onnx", model_folder, model_id); + if (written < 0 || (size_t)written >= path_size) { + return RAC_ERROR_BUFFER_TOO_SMALL; + } + + return RAC_SUCCESS; +} + +rac_result_t onnx_storage_detect_model(const char* model_folder, + rac_model_storage_details_t* out_details, void* user_data) { + (void)user_data; + + if (!model_folder || !out_details) { + return RAC_ERROR_INVALID_PARAMETER; + } + + memset(out_details, 0, sizeof(rac_model_storage_details_t)); + out_details->format = RAC_MODEL_FORMAT_ONNX; + out_details->is_directory_based = RAC_TRUE; + out_details->is_valid = RAC_TRUE; + out_details->total_size = 0; + out_details->file_count = 1; + out_details->primary_file = nullptr; + + return RAC_SUCCESS; +} + +rac_bool_t onnx_storage_is_valid(const char* model_folder, void* user_data) { + (void)user_data; + return model_folder ? RAC_TRUE : RAC_FALSE; +} + +void onnx_storage_get_patterns(const char*** out_patterns, size_t* out_count, void* user_data) { + (void)user_data; + + static const char* patterns[] = {"*.onnx", "*.ort", "encoder*.onnx", "decoder*.onnx", + "model.onnx"}; + *out_patterns = patterns; + *out_count = sizeof(patterns) / sizeof(patterns[0]); +} + +rac_result_t onnx_download_prepare(const rac_model_download_config_t* config, void* user_data) { + (void)user_data; + return (config && config->model_id && config->destination_folder) ? RAC_SUCCESS + : RAC_ERROR_INVALID_PARAMETER; +} + +rac_result_t onnx_download_get_dest(const rac_model_download_config_t* config, char* out_path, + size_t path_size, void* user_data) { + (void)user_data; + + if (!config || !config->destination_folder || !out_path || path_size == 0) { + return RAC_ERROR_INVALID_PARAMETER; + } + + int written = + snprintf(out_path, path_size, "%s/%s", config->destination_folder, config->model_id); + return (written < 0 || (size_t)written >= path_size) ? RAC_ERROR_BUFFER_TOO_SMALL : RAC_SUCCESS; +} + +rac_result_t onnx_download_post_process(const rac_model_download_config_t* config, + const char* downloaded_path, + rac_download_result_t* out_result, void* user_data) { + (void)user_data; + + if (!config || !downloaded_path || !out_result) { + return RAC_ERROR_INVALID_PARAMETER; + } + + memset(out_result, 0, sizeof(rac_download_result_t)); + out_result->was_extracted = + (config->archive_type != RAC_ARCHIVE_TYPE_NONE) ? RAC_TRUE : RAC_FALSE; + out_result->final_path = strdup(downloaded_path); + out_result->file_count = 1; + + return RAC_SUCCESS; +} + +void onnx_download_cleanup(const rac_model_download_config_t* config, void* user_data) { + (void)user_data; + (void)config; +} + +static rac_storage_strategy_t g_onnx_storage_strategy = {onnx_storage_find_model_path, + onnx_storage_detect_model, + onnx_storage_is_valid, + onnx_storage_get_patterns, + nullptr, + "ONNXStorageStrategy"}; + +static rac_download_strategy_t g_onnx_download_strategy = {onnx_download_prepare, + onnx_download_get_dest, + onnx_download_post_process, + onnx_download_cleanup, + nullptr, + "ONNXDownloadStrategy"}; + +bool g_registered = false; + +} // namespace + +// ============================================================================= +// REGISTRATION API +// ============================================================================= + +extern "C" { + +rac_result_t rac_backend_onnx_register(void) { + if (g_registered) { + return RAC_ERROR_MODULE_ALREADY_REGISTERED; + } + + // Register module + rac_module_info_t module_info = {}; + module_info.id = MODULE_ID; + module_info.name = "ONNX Runtime"; + module_info.version = "1.0.0"; + module_info.description = "STT/TTS/VAD backend using ONNX Runtime via Sherpa-ONNX"; + + rac_capability_t capabilities[] = {RAC_CAPABILITY_STT, RAC_CAPABILITY_TTS, RAC_CAPABILITY_VAD}; + module_info.capabilities = capabilities; + module_info.num_capabilities = 3; + + rac_result_t result = rac_module_register(&module_info); + if (result != RAC_SUCCESS && result != RAC_ERROR_MODULE_ALREADY_REGISTERED) { + return result; + } + + // Register strategies + rac_storage_strategy_register(RAC_FRAMEWORK_ONNX, &g_onnx_storage_strategy); + rac_download_strategy_register(RAC_FRAMEWORK_ONNX, &g_onnx_download_strategy); + + // Register STT provider + rac_service_provider_t stt_provider = {}; + stt_provider.name = STT_PROVIDER_NAME; + stt_provider.capability = RAC_CAPABILITY_STT; + stt_provider.priority = 100; + stt_provider.can_handle = onnx_stt_can_handle; + stt_provider.create = onnx_stt_create; + + result = rac_service_register_provider(&stt_provider); + if (result != RAC_SUCCESS) { + rac_module_unregister(MODULE_ID); + return result; + } + + // Register TTS provider + rac_service_provider_t tts_provider = {}; + tts_provider.name = TTS_PROVIDER_NAME; + tts_provider.capability = RAC_CAPABILITY_TTS; + tts_provider.priority = 100; + tts_provider.can_handle = onnx_tts_can_handle; + tts_provider.create = onnx_tts_create; + + result = rac_service_register_provider(&tts_provider); + if (result != RAC_SUCCESS) { + rac_service_unregister_provider(STT_PROVIDER_NAME, RAC_CAPABILITY_STT); + rac_module_unregister(MODULE_ID); + return result; + } + + // Register VAD provider + rac_service_provider_t vad_provider = {}; + vad_provider.name = VAD_PROVIDER_NAME; + vad_provider.capability = RAC_CAPABILITY_VAD; + vad_provider.priority = 100; + vad_provider.can_handle = onnx_vad_can_handle; + vad_provider.create = onnx_vad_create; + + result = rac_service_register_provider(&vad_provider); + if (result != RAC_SUCCESS) { + rac_service_unregister_provider(TTS_PROVIDER_NAME, RAC_CAPABILITY_TTS); + rac_service_unregister_provider(STT_PROVIDER_NAME, RAC_CAPABILITY_STT); + rac_module_unregister(MODULE_ID); + return result; + } + + g_registered = true; + RAC_LOG_INFO(LOG_CAT, "ONNX backend registered (STT + TTS + VAD)"); + return RAC_SUCCESS; +} + +rac_result_t rac_backend_onnx_unregister(void) { + if (!g_registered) { + return RAC_ERROR_MODULE_NOT_FOUND; + } + + rac_model_strategy_unregister(RAC_FRAMEWORK_ONNX); + rac_service_unregister_provider(VAD_PROVIDER_NAME, RAC_CAPABILITY_VAD); + rac_service_unregister_provider(TTS_PROVIDER_NAME, RAC_CAPABILITY_TTS); + rac_service_unregister_provider(STT_PROVIDER_NAME, RAC_CAPABILITY_STT); + rac_module_unregister(MODULE_ID); + + g_registered = false; + return RAC_SUCCESS; +} + +} // extern "C" diff --git a/sdk/runanywhere-commons/src/backends/onnx/rac_onnx.cpp b/sdk/runanywhere-commons/src/backends/onnx/rac_onnx.cpp new file mode 100644 index 000000000..2b271f299 --- /dev/null +++ b/sdk/runanywhere-commons/src/backends/onnx/rac_onnx.cpp @@ -0,0 +1,555 @@ +/** + * @file rac_onnx.cpp + * @brief RunAnywhere Core - ONNX Backend RAC API Implementation + * + * Direct RAC API implementation that calls C++ classes. + * Includes STT, TTS, and VAD functionality. + */ + +#include "rac_stt_onnx.h" +#include "rac_tts_onnx.h" +#include "rac_vad_onnx.h" + +#include +#include +#include +#include + +#include "onnx_backend.h" + +#include "rac/core/rac_error.h" +#include "rac/infrastructure/events/rac_events.h" + +// ============================================================================= +// INTERNAL HANDLE STRUCTURES +// ============================================================================= + +struct rac_onnx_stt_handle_impl { + std::unique_ptr backend; + runanywhere::ONNXSTT* stt; // Owned by backend +}; + +struct rac_onnx_tts_handle_impl { + std::unique_ptr backend; + runanywhere::ONNXTTS* tts; // Owned by backend +}; + +struct rac_onnx_vad_handle_impl { + std::unique_ptr backend; + runanywhere::ONNXVAD* vad; // Owned by backend +}; + +// ============================================================================= +// STT IMPLEMENTATION +// ============================================================================= + +extern "C" { + +rac_result_t rac_stt_onnx_create(const char* model_path, const rac_stt_onnx_config_t* config, + rac_handle_t* out_handle) { + if (out_handle == nullptr) { + return RAC_ERROR_NULL_POINTER; + } + + auto* handle = new (std::nothrow) rac_onnx_stt_handle_impl(); + if (!handle) { + return RAC_ERROR_OUT_OF_MEMORY; + } + + // Create and initialize backend + handle->backend = std::make_unique(); + nlohmann::json init_config; + if (config != nullptr && config->num_threads > 0) { + init_config["num_threads"] = config->num_threads; + } + + if (!handle->backend->initialize(init_config)) { + delete handle; + rac_error_set_details("Failed to initialize ONNX backend"); + return RAC_ERROR_BACKEND_INIT_FAILED; + } + + // Get STT component + handle->stt = handle->backend->get_stt(); + if (!handle->stt) { + delete handle; + rac_error_set_details("STT component not available"); + return RAC_ERROR_BACKEND_INIT_FAILED; + } + + // Load model if path provided + if (model_path != nullptr) { + runanywhere::STTModelType model_type = runanywhere::STTModelType::WHISPER; + if (config != nullptr) { + switch (config->model_type) { + case RAC_STT_ONNX_MODEL_ZIPFORMER: + model_type = runanywhere::STTModelType::ZIPFORMER; + break; + case RAC_STT_ONNX_MODEL_PARAFORMER: + model_type = runanywhere::STTModelType::PARAFORMER; + break; + default: + model_type = runanywhere::STTModelType::WHISPER; + } + } + + if (!handle->stt->load_model(model_path, model_type)) { + delete handle; + rac_error_set_details("Failed to load STT model"); + return RAC_ERROR_MODEL_LOAD_FAILED; + } + } + + *out_handle = static_cast(handle); + + rac_event_track("stt.backend.created", RAC_EVENT_CATEGORY_STT, RAC_EVENT_DESTINATION_ALL, + R"({"backend":"onnx"})"); + + return RAC_SUCCESS; +} + +rac_result_t rac_stt_onnx_transcribe(rac_handle_t handle, const float* audio_samples, + size_t num_samples, const rac_stt_options_t* options, + rac_stt_result_t* out_result) { + if (handle == nullptr || audio_samples == nullptr || out_result == nullptr) { + return RAC_ERROR_NULL_POINTER; + } + + auto* h = static_cast(handle); + if (!h->stt) { + return RAC_ERROR_INVALID_HANDLE; + } + + runanywhere::STTRequest request; + request.audio_samples.assign(audio_samples, audio_samples + num_samples); + request.sample_rate = (options && options->sample_rate > 0) ? options->sample_rate : 16000; + if (options && options->language) { + request.language = options->language; + } + + auto result = h->stt->transcribe(request); + + out_result->text = result.text.empty() ? nullptr : strdup(result.text.c_str()); + out_result->detected_language = + result.detected_language.empty() ? nullptr : strdup(result.detected_language.c_str()); + out_result->words = nullptr; + out_result->num_words = 0; + out_result->confidence = 1.0f; + out_result->processing_time_ms = result.inference_time_ms; + + rac_event_track("stt.transcription.completed", RAC_EVENT_CATEGORY_STT, + RAC_EVENT_DESTINATION_ALL, nullptr); + + return RAC_SUCCESS; +} + +rac_bool_t rac_stt_onnx_supports_streaming(rac_handle_t handle) { + if (handle == nullptr) { + return RAC_FALSE; + } + auto* h = static_cast(handle); + return (h->stt && h->stt->supports_streaming()) ? RAC_TRUE : RAC_FALSE; +} + +rac_result_t rac_stt_onnx_create_stream(rac_handle_t handle, rac_handle_t* out_stream) { + if (handle == nullptr || out_stream == nullptr) { + return RAC_ERROR_NULL_POINTER; + } + + auto* h = static_cast(handle); + if (!h->stt) { + return RAC_ERROR_INVALID_HANDLE; + } + + std::string stream_id = h->stt->create_stream(); + if (stream_id.empty()) { + return RAC_ERROR_BACKEND_INIT_FAILED; + } + + *out_stream = static_cast(strdup(stream_id.c_str())); + return RAC_SUCCESS; +} + +rac_result_t rac_stt_onnx_feed_audio(rac_handle_t handle, rac_handle_t stream, + const float* audio_samples, size_t num_samples) { + if (handle == nullptr || stream == nullptr || audio_samples == nullptr) { + return RAC_ERROR_NULL_POINTER; + } + + auto* h = static_cast(handle); + auto* stream_id = static_cast(stream); + + std::vector samples(audio_samples, audio_samples + num_samples); + bool success = h->stt->feed_audio(stream_id, samples, 16000); + + return success ? RAC_SUCCESS : RAC_ERROR_INFERENCE_FAILED; +} + +rac_bool_t rac_stt_onnx_stream_is_ready(rac_handle_t handle, rac_handle_t stream) { + if (handle == nullptr || stream == nullptr) { + return RAC_FALSE; + } + + auto* h = static_cast(handle); + auto* stream_id = static_cast(stream); + + return h->stt->is_stream_ready(stream_id) ? RAC_TRUE : RAC_FALSE; +} + +rac_result_t rac_stt_onnx_decode_stream(rac_handle_t handle, rac_handle_t stream, char** out_text) { + if (handle == nullptr || stream == nullptr || out_text == nullptr) { + return RAC_ERROR_NULL_POINTER; + } + + auto* h = static_cast(handle); + auto* stream_id = static_cast(stream); + + auto result = h->stt->decode(stream_id); + *out_text = strdup(result.text.c_str()); + + return RAC_SUCCESS; +} + +void rac_stt_onnx_input_finished(rac_handle_t handle, rac_handle_t stream) { + if (handle == nullptr || stream == nullptr) { + return; + } + + auto* h = static_cast(handle); + auto* stream_id = static_cast(stream); + + h->stt->input_finished(stream_id); +} + +rac_bool_t rac_stt_onnx_is_endpoint(rac_handle_t handle, rac_handle_t stream) { + if (handle == nullptr || stream == nullptr) { + return RAC_FALSE; + } + + auto* h = static_cast(handle); + auto* stream_id = static_cast(stream); + + return h->stt->is_endpoint(stream_id) ? RAC_TRUE : RAC_FALSE; +} + +void rac_stt_onnx_destroy_stream(rac_handle_t handle, rac_handle_t stream) { + if (handle == nullptr || stream == nullptr) { + return; + } + + auto* h = static_cast(handle); + auto* stream_id = static_cast(stream); + + h->stt->destroy_stream(stream_id); + free(stream_id); +} + +void rac_stt_onnx_destroy(rac_handle_t handle) { + if (handle == nullptr) { + return; + } + + auto* h = static_cast(handle); + if (h->stt) { + h->stt->unload_model(); + } + if (h->backend) { + h->backend->cleanup(); + } + delete h; + + rac_event_track("stt.backend.destroyed", RAC_EVENT_CATEGORY_STT, RAC_EVENT_DESTINATION_ALL, + R"({"backend":"onnx"})"); +} + +// ============================================================================= +// TTS IMPLEMENTATION +// ============================================================================= + +rac_result_t rac_tts_onnx_create(const char* model_path, const rac_tts_onnx_config_t* config, + rac_handle_t* out_handle) { + if (out_handle == nullptr) { + return RAC_ERROR_NULL_POINTER; + } + + auto* handle = new (std::nothrow) rac_onnx_tts_handle_impl(); + if (!handle) { + return RAC_ERROR_OUT_OF_MEMORY; + } + + handle->backend = std::make_unique(); + nlohmann::json init_config; + if (config != nullptr && config->num_threads > 0) { + init_config["num_threads"] = config->num_threads; + } + + if (!handle->backend->initialize(init_config)) { + delete handle; + rac_error_set_details("Failed to initialize ONNX backend"); + return RAC_ERROR_BACKEND_INIT_FAILED; + } + + // Get TTS component + handle->tts = handle->backend->get_tts(); + if (!handle->tts) { + delete handle; + rac_error_set_details("TTS component not available"); + return RAC_ERROR_BACKEND_INIT_FAILED; + } + + if (model_path != nullptr) { + if (!handle->tts->load_model(model_path, runanywhere::TTSModelType::PIPER)) { + delete handle; + rac_error_set_details("Failed to load TTS model"); + return RAC_ERROR_MODEL_LOAD_FAILED; + } + } + + *out_handle = static_cast(handle); + + rac_event_track("tts.backend.created", RAC_EVENT_CATEGORY_TTS, RAC_EVENT_DESTINATION_ALL, + R"({"backend":"onnx"})"); + + return RAC_SUCCESS; +} + +rac_result_t rac_tts_onnx_synthesize(rac_handle_t handle, const char* text, + const rac_tts_options_t* options, + rac_tts_result_t* out_result) { + if (handle == nullptr || text == nullptr || out_result == nullptr) { + return RAC_ERROR_NULL_POINTER; + } + + auto* h = static_cast(handle); + if (!h->tts) { + return RAC_ERROR_INVALID_HANDLE; + } + + runanywhere::TTSRequest request; + request.text = text; + if (options && options->voice) { + request.voice_id = options->voice; + } + if (options && options->rate > 0) { + request.speed_rate = options->rate; + } + + auto result = h->tts->synthesize(request); + if (result.audio_samples.empty()) { + rac_error_set_details("TTS synthesis failed"); + return RAC_ERROR_INFERENCE_FAILED; + } + + float* audio_copy = static_cast(malloc(result.audio_samples.size() * sizeof(float))); + if (!audio_copy) { + return RAC_ERROR_OUT_OF_MEMORY; + } + memcpy(audio_copy, result.audio_samples.data(), result.audio_samples.size() * sizeof(float)); + + out_result->audio_data = audio_copy; + out_result->audio_size = result.audio_samples.size() * sizeof(float); + out_result->audio_format = RAC_AUDIO_FORMAT_PCM; + out_result->sample_rate = result.sample_rate; + out_result->duration_ms = result.duration_ms; + out_result->processing_time_ms = 0; + + rac_event_track("tts.synthesis.completed", RAC_EVENT_CATEGORY_TTS, RAC_EVENT_DESTINATION_ALL, + nullptr); + + return RAC_SUCCESS; +} + +rac_result_t rac_tts_onnx_get_voices(rac_handle_t handle, char*** out_voices, size_t* out_count) { + if (handle == nullptr || out_voices == nullptr || out_count == nullptr) { + return RAC_ERROR_NULL_POINTER; + } + + auto* h = static_cast(handle); + if (!h->tts) { + return RAC_ERROR_INVALID_HANDLE; + } + + auto voices = h->tts->get_voices(); + *out_count = voices.size(); + + if (voices.empty()) { + *out_voices = nullptr; + return RAC_SUCCESS; + } + + *out_voices = static_cast(malloc(voices.size() * sizeof(char*))); + for (size_t i = 0; i < voices.size(); i++) { + (*out_voices)[i] = strdup(voices[i].id.c_str()); + } + + return RAC_SUCCESS; +} + +void rac_tts_onnx_stop(rac_handle_t handle) { + if (handle == nullptr) { + return; + } + auto* h = static_cast(handle); + if (h->tts) { + h->tts->cancel(); + } +} + +void rac_tts_onnx_destroy(rac_handle_t handle) { + if (handle == nullptr) { + return; + } + + auto* h = static_cast(handle); + if (h->tts) { + h->tts->unload_model(); + } + if (h->backend) { + h->backend->cleanup(); + } + delete h; + + rac_event_track("tts.backend.destroyed", RAC_EVENT_CATEGORY_TTS, RAC_EVENT_DESTINATION_ALL, + R"({"backend":"onnx"})"); +} + +// ============================================================================= +// VAD IMPLEMENTATION +// ============================================================================= + +rac_result_t rac_vad_onnx_create(const char* model_path, const rac_vad_onnx_config_t* config, + rac_handle_t* out_handle) { + if (out_handle == nullptr) { + return RAC_ERROR_NULL_POINTER; + } + + auto* handle = new (std::nothrow) rac_onnx_vad_handle_impl(); + if (!handle) { + return RAC_ERROR_OUT_OF_MEMORY; + } + + handle->backend = std::make_unique(); + nlohmann::json init_config; + if (config != nullptr && config->num_threads > 0) { + init_config["num_threads"] = config->num_threads; + } + + if (!handle->backend->initialize(init_config)) { + delete handle; + rac_error_set_details("Failed to initialize ONNX backend"); + return RAC_ERROR_BACKEND_INIT_FAILED; + } + + // Get VAD component + handle->vad = handle->backend->get_vad(); + if (!handle->vad) { + delete handle; + rac_error_set_details("VAD component not available"); + return RAC_ERROR_BACKEND_INIT_FAILED; + } + + if (model_path != nullptr) { + nlohmann::json model_config; + if (config != nullptr) { + model_config["energy_threshold"] = config->energy_threshold; + } + if (!handle->vad->load_model(model_path, runanywhere::VADModelType::SILERO, model_config)) { + delete handle; + rac_error_set_details("Failed to load VAD model"); + return RAC_ERROR_MODEL_LOAD_FAILED; + } + } + + *out_handle = static_cast(handle); + + rac_event_track("vad.backend.created", RAC_EVENT_CATEGORY_VOICE, RAC_EVENT_DESTINATION_ALL, + R"({"backend":"onnx"})"); + + return RAC_SUCCESS; +} + +rac_result_t rac_vad_onnx_process(rac_handle_t handle, const float* samples, size_t num_samples, + rac_bool_t* out_is_speech) { + if (handle == nullptr || samples == nullptr || out_is_speech == nullptr) { + return RAC_ERROR_NULL_POINTER; + } + + auto* h = static_cast(handle); + if (!h->vad) { + return RAC_ERROR_INVALID_HANDLE; + } + + std::vector audio(samples, samples + num_samples); + auto result = h->vad->process(audio, 16000); + + *out_is_speech = result.is_speech ? RAC_TRUE : RAC_FALSE; + + return RAC_SUCCESS; +} + +rac_result_t rac_vad_onnx_start(rac_handle_t handle) { + (void)handle; + return RAC_SUCCESS; +} + +rac_result_t rac_vad_onnx_stop(rac_handle_t handle) { + (void)handle; + return RAC_SUCCESS; +} + +rac_result_t rac_vad_onnx_reset(rac_handle_t handle) { + if (handle == nullptr) { + return RAC_ERROR_NULL_POINTER; + } + + auto* h = static_cast(handle); + if (h->vad) { + h->vad->reset(); + } + + return RAC_SUCCESS; +} + +rac_result_t rac_vad_onnx_set_threshold(rac_handle_t handle, float threshold) { + if (handle == nullptr) { + return RAC_ERROR_NULL_POINTER; + } + + auto* h = static_cast(handle); + if (h->vad) { + auto config = h->vad->get_vad_config(); + config.threshold = threshold; + h->vad->configure_vad(config); + } + + return RAC_SUCCESS; +} + +rac_bool_t rac_vad_onnx_is_speech_active(rac_handle_t handle) { + if (handle == nullptr) { + return RAC_FALSE; + } + + auto* h = static_cast(handle); + return (h->vad && h->vad->is_ready()) ? RAC_TRUE : RAC_FALSE; +} + +void rac_vad_onnx_destroy(rac_handle_t handle) { + if (handle == nullptr) { + return; + } + + auto* h = static_cast(handle); + if (h->vad) { + h->vad->unload_model(); + } + if (h->backend) { + h->backend->cleanup(); + } + delete h; + + rac_event_track("vad.backend.destroyed", RAC_EVENT_CATEGORY_VOICE, RAC_EVENT_DESTINATION_ALL, + R"({"backend":"onnx"})"); +} + +} // extern "C" diff --git a/sdk/runanywhere-commons/src/backends/whispercpp/CMakeLists.txt b/sdk/runanywhere-commons/src/backends/whispercpp/CMakeLists.txt new file mode 100644 index 000000000..098291a7e --- /dev/null +++ b/sdk/runanywhere-commons/src/backends/whispercpp/CMakeLists.txt @@ -0,0 +1,184 @@ +# ============================================================================= +# WhisperCPP Backend - Speech-to-Text via whisper.cpp +# ============================================================================= + +message(STATUS "Configuring WhisperCPP backend...") + +# ============================================================================= +# Dependencies +# ============================================================================= + +include(FetchContent) +include(LoadVersions) + +# Fetch nlohmann_json for JSON parsing (if not already available) +if(NOT TARGET nlohmann_json::nlohmann_json) + if(NOT DEFINED NLOHMANN_JSON_VERSION) + set(NLOHMANN_JSON_VERSION "3.11.3") + endif() + FetchContent_Declare( + nlohmann_json + GIT_REPOSITORY https://github.com/nlohmann/json.git + GIT_TAG v${NLOHMANN_JSON_VERSION} + GIT_SHALLOW TRUE + ) + set(JSON_BuildTests OFF CACHE BOOL "" FORCE) + FetchContent_MakeAvailable(nlohmann_json) +endif() + +# ============================================================================= +# Fetch whisper.cpp +# ============================================================================= + +if(NOT DEFINED WHISPERCPP_VERSION) + set(WHISPERCPP_VERSION "v1.8.2") +endif() +set(WHISPER_CPP_VERSION "${WHISPERCPP_VERSION}") + +FetchContent_Declare( + whispercpp + GIT_REPOSITORY https://github.com/ggml-org/whisper.cpp.git + GIT_TAG ${WHISPER_CPP_VERSION} + GIT_SHALLOW TRUE + GIT_PROGRESS TRUE +) + +# Configure whisper.cpp build options +set(WHISPER_BUILD_TESTS OFF CACHE BOOL "" FORCE) +set(WHISPER_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) +set(WHISPER_BUILD_SERVER OFF CACHE BOOL "" FORCE) +set(WHISPER_CURL OFF CACHE BOOL "" FORCE) +set(WHISPER_SDL2 OFF CACHE BOOL "" FORCE) +set(WHISPER_FFMPEG OFF CACHE BOOL "" FORCE) +set(WHISPER_COREML OFF CACHE BOOL "" FORCE) +set(WHISPER_OPENVINO OFF CACHE BOOL "" FORCE) + +if(RAC_BACKEND_LLAMACPP) + message(STATUS "LlamaCPP is also enabled - whisper.cpp will use its own GGML") +endif() + +# Platform-specific optimizations +if(RAC_PLATFORM_IOS) + set(GGML_METAL ON CACHE BOOL "" FORCE) + set(GGML_ACCELERATE ON CACHE BOOL "" FORCE) + set(GGML_NEON ON CACHE BOOL "" FORCE) + set(GGML_METAL_EMBED_LIBRARY ON CACHE BOOL "" FORCE) +elseif(RAC_PLATFORM_ANDROID) + set(GGML_METAL OFF CACHE BOOL "" FORCE) + set(GGML_NEON ON CACHE BOOL "" FORCE) +elseif(RAC_PLATFORM_MACOS) + set(GGML_METAL ON CACHE BOOL "" FORCE) + set(GGML_ACCELERATE ON CACHE BOOL "" FORCE) + set(GGML_METAL_EMBED_LIBRARY ON CACHE BOOL "" FORCE) +endif() + +set(BUILD_SHARED_LIBS OFF CACHE BOOL "Force static libraries for whisper.cpp" FORCE) + +FetchContent_MakeAvailable(whispercpp) + +# ============================================================================= +# WhisperCPP Backend Library +# ============================================================================= + +set(WHISPERCPP_BACKEND_SOURCES + whispercpp_backend.cpp + rac_stt_whispercpp.cpp + rac_backend_whispercpp_register.cpp +) + +set(WHISPERCPP_BACKEND_HEADERS + whispercpp_backend.h +) + +if(RAC_BUILD_SHARED) + add_library(rac_backend_whispercpp SHARED ${WHISPERCPP_BACKEND_SOURCES} ${WHISPERCPP_BACKEND_HEADERS}) +else() + add_library(rac_backend_whispercpp STATIC ${WHISPERCPP_BACKEND_SOURCES} ${WHISPERCPP_BACKEND_HEADERS}) +endif() + +target_include_directories(rac_backend_whispercpp PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_SOURCE_DIR}/include + ${CMAKE_SOURCE_DIR}/include/rac/backends + ${whispercpp_SOURCE_DIR}/include + ${whispercpp_SOURCE_DIR}/ggml/include +) + +# Define RAC_WHISPERCPP_BUILDING to export symbols with visibility("default") +target_compile_definitions(rac_backend_whispercpp PRIVATE RAC_WHISPERCPP_BUILDING) + +target_link_libraries(rac_backend_whispercpp PUBLIC + rac_commons + whisper + nlohmann_json::nlohmann_json +) + +target_compile_features(rac_backend_whispercpp PUBLIC cxx_std_17) + +# ============================================================================= +# Platform-specific configuration +# ============================================================================= + +if(RAC_PLATFORM_IOS) + message(STATUS "Configuring WhisperCPP backend for iOS") + target_link_libraries(rac_backend_whispercpp PUBLIC + "-framework Foundation" + "-framework Accelerate" + "-framework Metal" + "-framework MetalKit" + ) + target_compile_definitions(rac_backend_whispercpp PRIVATE GGML_USE_METAL=1) + +elseif(RAC_PLATFORM_ANDROID) + message(STATUS "Configuring WhisperCPP backend for Android") + target_link_libraries(rac_backend_whispercpp PRIVATE log) + # Don't use -fvisibility=hidden here - JNI bridge needs these symbols + target_compile_options(rac_backend_whispercpp PRIVATE -O3 -ffunction-sections -fdata-sections) + # 16KB page alignment for Android 15+ (API 35) compliance - required Nov 2025 + target_link_options(rac_backend_whispercpp PRIVATE -Wl,--gc-sections -Wl,-z,max-page-size=16384) + +elseif(RAC_PLATFORM_MACOS) + message(STATUS "Configuring WhisperCPP backend for macOS") + target_link_libraries(rac_backend_whispercpp PUBLIC + "-framework Foundation" + "-framework Accelerate" + "-framework Metal" + "-framework MetalKit" + ) +endif() + +# ============================================================================= +# JNI TARGET (Android) +# ============================================================================= + +if(RAC_PLATFORM_ANDROID AND RAC_BUILD_SHARED) + if(ANDROID) + message(STATUS "Building WhisperCPP JNI bridge for Android") + + add_library(rac_backend_whispercpp_jni SHARED + jni/rac_backend_whispercpp_jni.cpp + ) + + target_include_directories(rac_backend_whispercpp_jni PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_SOURCE_DIR}/include + ) + + target_link_libraries(rac_backend_whispercpp_jni PRIVATE + rac_backend_whispercpp + log + ) + + target_compile_options(rac_backend_whispercpp_jni PRIVATE -O3 -fvisibility=hidden -ffunction-sections -fdata-sections) + # 16KB page alignment for Android 15+ (API 35) compliance - required Nov 2025 + target_link_options(rac_backend_whispercpp_jni PRIVATE -Wl,--gc-sections -Wl,-z,max-page-size=16384) + endif() +endif() + +# ============================================================================= +# Summary +# ============================================================================= + +message(STATUS "WhisperCPP Backend Configuration:") +message(STATUS " whisper.cpp version: ${WHISPER_CPP_VERSION}") +message(STATUS " Platform: ${RAC_PLATFORM_NAME}") diff --git a/sdk/runanywhere-commons/src/backends/whispercpp/jni/rac_backend_whispercpp_jni.cpp b/sdk/runanywhere-commons/src/backends/whispercpp/jni/rac_backend_whispercpp_jni.cpp new file mode 100644 index 000000000..d735c9225 --- /dev/null +++ b/sdk/runanywhere-commons/src/backends/whispercpp/jni/rac_backend_whispercpp_jni.cpp @@ -0,0 +1,116 @@ +/** + * @file rac_backend_whispercpp_jni.cpp + * @brief RunAnywhere Core - WhisperCPP Backend JNI Bridge + * + * Self-contained JNI layer for the WhisperCPP backend. + * + * Package: com.runanywhere.sdk.core.whispercpp + * Class: WhisperCPPBridge + */ + +#include +#include +#include + +#ifdef __ANDROID__ +#include +#define TAG "RACWhisperCPPJNI" +#define LOGi(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__) +#define LOGe(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__) +#define LOGw(...) __android_log_print(ANDROID_LOG_WARN, TAG, __VA_ARGS__) +#else +#include +#define LOGi(...) fprintf(stdout, "[INFO] " __VA_ARGS__); fprintf(stdout, "\n") +#define LOGe(...) fprintf(stderr, "[ERROR] " __VA_ARGS__); fprintf(stderr, "\n") +#define LOGw(...) fprintf(stdout, "[WARN] " __VA_ARGS__); fprintf(stdout, "\n") +#endif + +#include "rac_stt_whispercpp.h" + +#include "rac/core/rac_core.h" +#include "rac/core/rac_error.h" + +extern "C" { + +// ============================================================================= +// JNI_OnLoad +// ============================================================================= + +JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) { + (void)vm; + (void)reserved; + LOGi("JNI_OnLoad: rac_backend_whispercpp_jni loaded"); + return JNI_VERSION_1_6; +} + +// ============================================================================= +// Backend Registration +// ============================================================================= + +JNIEXPORT jint JNICALL +Java_com_runanywhere_sdk_core_whispercpp_WhisperCPPBridge_nativeRegister(JNIEnv* env, jclass clazz) { + (void)env; + (void)clazz; + LOGi("WhisperCPP nativeRegister called"); + + rac_result_t result = rac_backend_whispercpp_register(); + + if (result != RAC_SUCCESS && result != RAC_ERROR_MODULE_ALREADY_REGISTERED) { + LOGe("Failed to register WhisperCPP backend: %d", result); + return static_cast(result); + } + + const char** provider_names = nullptr; + size_t provider_count = 0; + rac_result_t list_result = rac_service_list_providers(RAC_CAPABILITY_STT, &provider_names, &provider_count); + LOGi("After WhisperCPP registration - STT providers: count=%zu, result=%d", provider_count, list_result); + + LOGi("WhisperCPP backend registered successfully (STT)"); + return RAC_SUCCESS; +} + +JNIEXPORT jint JNICALL +Java_com_runanywhere_sdk_core_whispercpp_WhisperCPPBridge_nativeUnregister(JNIEnv* env, jclass clazz) { + (void)env; + (void)clazz; + LOGi("WhisperCPP nativeUnregister called"); + + rac_result_t result = rac_backend_whispercpp_unregister(); + + if (result != RAC_SUCCESS) { + LOGe("Failed to unregister WhisperCPP backend: %d", result); + } else { + LOGi("WhisperCPP backend unregistered"); + } + + return static_cast(result); +} + +JNIEXPORT jboolean JNICALL +Java_com_runanywhere_sdk_core_whispercpp_WhisperCPPBridge_nativeIsRegistered(JNIEnv* env, jclass clazz) { + (void)env; + (void)clazz; + + const char** provider_names = nullptr; + size_t provider_count = 0; + + rac_result_t result = rac_service_list_providers(RAC_CAPABILITY_STT, &provider_names, &provider_count); + + if (result == RAC_SUCCESS && provider_names && provider_count > 0) { + for (size_t i = 0; i < provider_count; i++) { + if (provider_names[i] && strstr(provider_names[i], "WhisperCPP") != nullptr) { + return JNI_TRUE; + } + } + } + + return JNI_FALSE; +} + +JNIEXPORT jstring JNICALL +Java_com_runanywhere_sdk_core_whispercpp_WhisperCPPBridge_nativeGetVersion(JNIEnv* env, jclass clazz) { + (void)clazz; + return env->NewStringUTF("1.0.0"); +} + +} // extern "C" diff --git a/sdk/runanywhere-commons/src/backends/whispercpp/rac_backend_whispercpp_register.cpp b/sdk/runanywhere-commons/src/backends/whispercpp/rac_backend_whispercpp_register.cpp new file mode 100644 index 000000000..8a6c62837 --- /dev/null +++ b/sdk/runanywhere-commons/src/backends/whispercpp/rac_backend_whispercpp_register.cpp @@ -0,0 +1,245 @@ +/** + * @file rac_backend_whispercpp_register.cpp + * @brief RunAnywhere Core - WhisperCPP Backend RAC Registration + * + * Registers the WhisperCPP backend with the module and service registries. + */ + +#include "rac_stt_whispercpp.h" + +#include +#include +#include + +#include "rac/core/rac_core.h" +#include "rac/core/rac_error.h" +#include "rac/core/rac_logger.h" +#include "rac/features/stt/rac_stt_service.h" + +// ============================================================================= +// STT VTABLE IMPLEMENTATION +// ============================================================================= + +namespace { + +const char* LOG_CAT = "WhisperCPP"; + +/** + * Convert Int16 PCM audio to Float32 normalized to [-1.0, 1.0]. + */ +static std::vector convert_int16_to_float32(const void* int16_data, size_t byte_count) { + const int16_t* samples = static_cast(int16_data); + size_t num_samples = byte_count / sizeof(int16_t); + + std::vector float_samples(num_samples); + for (size_t i = 0; i < num_samples; ++i) { + float_samples[i] = static_cast(samples[i]) / 32768.0f; + } + + return float_samples; +} + +// Initialize +static rac_result_t whispercpp_stt_vtable_initialize(void* impl, const char* model_path) { + (void)impl; + (void)model_path; + return RAC_SUCCESS; +} + +// Transcribe +static rac_result_t whispercpp_stt_vtable_transcribe(void* impl, const void* audio_data, + size_t audio_size, + const rac_stt_options_t* options, + rac_stt_result_t* out_result) { + std::vector float_samples = convert_int16_to_float32(audio_data, audio_size); + return rac_stt_whispercpp_transcribe(impl, float_samples.data(), float_samples.size(), options, + out_result); +} + +// Stream transcription (not implemented for WhisperCPP - use batch) +static rac_result_t whispercpp_stt_vtable_transcribe_stream(void* impl, const void* audio_data, + size_t audio_size, + const rac_stt_options_t* options, + rac_stt_stream_callback_t callback, + void* user_data) { + // Fall back to batch transcription + rac_stt_result_t result = {}; + std::vector float_samples = convert_int16_to_float32(audio_data, audio_size); + rac_result_t status = + rac_stt_whispercpp_transcribe(impl, float_samples.data(), float_samples.size(), options, + &result); + if (status == RAC_SUCCESS && callback && result.text) { + callback(result.text, RAC_TRUE, user_data); + } + return status; +} + +// Get info +static rac_result_t whispercpp_stt_vtable_get_info(void* impl, rac_stt_info_t* out_info) { + if (!out_info) return RAC_ERROR_NULL_POINTER; + + out_info->is_ready = rac_stt_whispercpp_is_ready(impl); + out_info->supports_streaming = RAC_FALSE; // WhisperCPP streaming is limited + out_info->current_model = nullptr; + + return RAC_SUCCESS; +} + +// Cleanup +static rac_result_t whispercpp_stt_vtable_cleanup(void* impl) { + (void)impl; + return RAC_SUCCESS; +} + +// Destroy +static void whispercpp_stt_vtable_destroy(void* impl) { + if (impl) { + rac_stt_whispercpp_destroy(impl); + } +} + +// Static vtable for WhisperCPP STT +static const rac_stt_service_ops_t g_whispercpp_stt_ops = { + .initialize = whispercpp_stt_vtable_initialize, + .transcribe = whispercpp_stt_vtable_transcribe, + .transcribe_stream = whispercpp_stt_vtable_transcribe_stream, + .get_info = whispercpp_stt_vtable_get_info, + .cleanup = whispercpp_stt_vtable_cleanup, + .destroy = whispercpp_stt_vtable_destroy, +}; + +// ============================================================================= +// SERVICE PROVIDERS +// ============================================================================= + +const char* const MODULE_ID = "whispercpp"; +const char* const STT_PROVIDER_NAME = "WhisperCPPSTTService"; + +// STT can_handle +rac_bool_t whispercpp_stt_can_handle(const rac_service_request_t* request, void* user_data) { + (void)user_data; + + if (request == nullptr) { + return RAC_FALSE; + } + + // Don't be the default STT provider (let ONNX handle that) + if (request->identifier == nullptr || request->identifier[0] == '\0') { + return RAC_FALSE; + } + + // Check for whisper GGML model patterns + const char* path = request->identifier; + size_t len = strlen(path); + + // Check for .bin extension (whisper GGML format) + if (len >= 4) { + const char* ext = path + len - 4; + if (strcmp(ext, ".bin") == 0 || strcmp(ext, ".BIN") == 0) { + if (strstr(path, "whisper") != nullptr || strstr(path, "ggml") != nullptr) { + RAC_LOG_INFO(LOG_CAT, "whispercpp_stt_can_handle: path matches -> TRUE"); + return RAC_TRUE; + } + } + } + + return RAC_FALSE; +} + +// STT create with vtable +rac_handle_t whispercpp_stt_create(const rac_service_request_t* request, void* user_data) { + (void)user_data; + + if (request == nullptr) { + return nullptr; + } + + RAC_LOG_INFO(LOG_CAT, "Creating WhisperCPP STT service for: %s", + request->identifier ? request->identifier : "(default)"); + + rac_handle_t backend_handle = nullptr; + rac_result_t result = rac_stt_whispercpp_create(request->identifier, nullptr, &backend_handle); + if (result != RAC_SUCCESS) { + RAC_LOG_ERROR(LOG_CAT, "rac_stt_whispercpp_create failed with result: %d", result); + return nullptr; + } + + auto* service = static_cast(malloc(sizeof(rac_stt_service_t))); + if (!service) { + RAC_LOG_ERROR(LOG_CAT, "Failed to allocate rac_stt_service_t"); + rac_stt_whispercpp_destroy(backend_handle); + return nullptr; + } + + service->ops = &g_whispercpp_stt_ops; + service->impl = backend_handle; + service->model_id = request->identifier ? strdup(request->identifier) : nullptr; + + RAC_LOG_INFO(LOG_CAT, "WhisperCPP STT service created successfully"); + return service; +} + +bool g_registered = false; + +} // namespace + +// ============================================================================= +// REGISTRATION API +// ============================================================================= + +extern "C" { + +rac_result_t rac_backend_whispercpp_register(void) { + if (g_registered) { + return RAC_ERROR_MODULE_ALREADY_REGISTERED; + } + + // Register module + rac_module_info_t module_info = {}; + module_info.id = MODULE_ID; + module_info.name = "WhisperCPP"; + module_info.version = "1.0.0"; + module_info.description = "STT backend using whisper.cpp for GGML Whisper models"; + + rac_capability_t capabilities[] = {RAC_CAPABILITY_STT}; + module_info.capabilities = capabilities; + module_info.num_capabilities = 1; + + rac_result_t result = rac_module_register(&module_info); + if (result != RAC_SUCCESS && result != RAC_ERROR_MODULE_ALREADY_REGISTERED) { + return result; + } + + // Register STT provider with lower priority than ONNX + // (to avoid GGML symbol conflicts when LlamaCPP is also loaded) + rac_service_provider_t stt_provider = {}; + stt_provider.name = STT_PROVIDER_NAME; + stt_provider.capability = RAC_CAPABILITY_STT; + stt_provider.priority = 50; // Lower than ONNX (100) + stt_provider.can_handle = whispercpp_stt_can_handle; + stt_provider.create = whispercpp_stt_create; + + result = rac_service_register_provider(&stt_provider); + if (result != RAC_SUCCESS) { + rac_module_unregister(MODULE_ID); + return result; + } + + g_registered = true; + RAC_LOG_INFO(LOG_CAT, "WhisperCPP backend registered (STT)"); + return RAC_SUCCESS; +} + +rac_result_t rac_backend_whispercpp_unregister(void) { + if (!g_registered) { + return RAC_ERROR_MODULE_NOT_FOUND; + } + + rac_service_unregister_provider(STT_PROVIDER_NAME, RAC_CAPABILITY_STT); + rac_module_unregister(MODULE_ID); + + g_registered = false; + return RAC_SUCCESS; +} + +} // extern "C" diff --git a/sdk/runanywhere-commons/src/backends/whispercpp/rac_stt_whispercpp.cpp b/sdk/runanywhere-commons/src/backends/whispercpp/rac_stt_whispercpp.cpp new file mode 100644 index 000000000..6fa394d81 --- /dev/null +++ b/sdk/runanywhere-commons/src/backends/whispercpp/rac_stt_whispercpp.cpp @@ -0,0 +1,196 @@ +/** + * @file rac_stt_whispercpp.cpp + * @brief RunAnywhere Core - WhisperCPP RAC API Implementation + * + * Direct RAC API implementation that calls C++ classes. + */ + +#include "rac_stt_whispercpp.h" + +#include +#include +#include +#include + +#include "whispercpp_backend.h" + +#include "rac/core/rac_error.h" +#include "rac/infrastructure/events/rac_events.h" + +// ============================================================================= +// INTERNAL HANDLE STRUCTURE +// ============================================================================= + +struct rac_whispercpp_handle_impl { + std::unique_ptr backend; + runanywhere::WhisperCppSTT* stt; // Owned by backend + std::string detected_language; +}; + +// ============================================================================= +// RAC API IMPLEMENTATION +// ============================================================================= + +extern "C" { + +rac_result_t rac_stt_whispercpp_create(const char* model_path, + const rac_stt_whispercpp_config_t* config, + rac_handle_t* out_handle) { + if (out_handle == nullptr) { + return RAC_ERROR_NULL_POINTER; + } + + auto* handle = new (std::nothrow) rac_whispercpp_handle_impl(); + if (!handle) { + return RAC_ERROR_OUT_OF_MEMORY; + } + + // Create and initialize backend + handle->backend = std::make_unique(); + + nlohmann::json init_config; + if (config != nullptr) { + if (config->num_threads > 0) { + init_config["num_threads"] = config->num_threads; + } + init_config["use_gpu"] = config->use_gpu == RAC_TRUE; + } + + if (!handle->backend->initialize(init_config)) { + delete handle; + rac_error_set_details("Failed to initialize WhisperCPP backend"); + return RAC_ERROR_BACKEND_INIT_FAILED; + } + + // Get STT component + handle->stt = handle->backend->get_stt(); + if (!handle->stt) { + delete handle; + rac_error_set_details("STT component not available"); + return RAC_ERROR_BACKEND_INIT_FAILED; + } + + // Load model if path provided + if (model_path != nullptr) { + nlohmann::json model_config; + if (config != nullptr && config->translate == RAC_TRUE) { + model_config["translate"] = true; + } + + if (!handle->stt->load_model(model_path, runanywhere::STTModelType::WHISPER, model_config)) { + delete handle; + rac_error_set_details("Failed to load WhisperCPP model"); + return RAC_ERROR_MODEL_LOAD_FAILED; + } + } + + *out_handle = static_cast(handle); + + rac_event_track("stt.backend.created", RAC_EVENT_CATEGORY_STT, RAC_EVENT_DESTINATION_ALL, + R"({"backend":"whispercpp"})"); + + return RAC_SUCCESS; +} + +rac_result_t rac_stt_whispercpp_transcribe(rac_handle_t handle, const float* audio_samples, + size_t num_samples, const rac_stt_options_t* options, + rac_stt_result_t* out_result) { + if (handle == nullptr || audio_samples == nullptr || out_result == nullptr) { + return RAC_ERROR_NULL_POINTER; + } + + auto* h = static_cast(handle); + if (!h->stt) { + return RAC_ERROR_INVALID_HANDLE; + } + + // Prepare request + runanywhere::STTRequest request; + request.audio_samples.assign(audio_samples, audio_samples + num_samples); + request.sample_rate = (options && options->sample_rate > 0) ? options->sample_rate : 16000; + + if (options && options->language) { + request.language = options->language; + } + + // Perform transcription + auto result = h->stt->transcribe(request); + + // Store detected language for later retrieval + h->detected_language = result.detected_language; + + // Fill output + out_result->text = result.text.empty() ? nullptr : strdup(result.text.c_str()); + out_result->detected_language = + result.detected_language.empty() ? nullptr : strdup(result.detected_language.c_str()); + out_result->confidence = result.confidence; + out_result->processing_time_ms = result.inference_time_ms; + + // Word-level timestamps + out_result->words = nullptr; + out_result->num_words = 0; + if (!result.word_timings.empty()) { + out_result->num_words = result.word_timings.size(); + out_result->words = + static_cast(malloc(result.word_timings.size() * sizeof(rac_stt_word_t))); + if (out_result->words) { + for (size_t i = 0; i < result.word_timings.size(); i++) { + out_result->words[i].text = strdup(result.word_timings[i].word.c_str()); + out_result->words[i].start_ms = + static_cast(result.word_timings[i].start_time_ms); + out_result->words[i].end_ms = + static_cast(result.word_timings[i].end_time_ms); + out_result->words[i].confidence = result.word_timings[i].confidence; + } + } + } + + rac_event_track("stt.transcription.completed", RAC_EVENT_CATEGORY_STT, + RAC_EVENT_DESTINATION_ALL, R"({"backend":"whispercpp"})"); + + return RAC_SUCCESS; +} + +rac_result_t rac_stt_whispercpp_get_language(rac_handle_t handle, char** out_language) { + if (handle == nullptr || out_language == nullptr) { + return RAC_ERROR_NULL_POINTER; + } + + auto* h = static_cast(handle); + + if (h->detected_language.empty()) { + return RAC_ERROR_BACKEND_NOT_READY; + } + + *out_language = strdup(h->detected_language.c_str()); + return RAC_SUCCESS; +} + +rac_bool_t rac_stt_whispercpp_is_ready(rac_handle_t handle) { + if (handle == nullptr) { + return RAC_FALSE; + } + + auto* h = static_cast(handle); + return (h->stt && h->stt->is_ready()) ? RAC_TRUE : RAC_FALSE; +} + +void rac_stt_whispercpp_destroy(rac_handle_t handle) { + if (handle == nullptr) { + return; + } + + auto* h = static_cast(handle); + if (h->stt) { + h->stt->unload_model(); + } + if (h->backend) { + h->backend->cleanup(); + } + delete h; + + rac_event_track("stt.backend.destroyed", RAC_EVENT_CATEGORY_STT, RAC_EVENT_DESTINATION_ALL, + R"({"backend":"whispercpp"})"); +} + +} // extern "C" diff --git a/sdk/runanywhere-commons/src/backends/whispercpp/whispercpp_backend.cpp b/sdk/runanywhere-commons/src/backends/whispercpp/whispercpp_backend.cpp new file mode 100644 index 000000000..f51b3f803 --- /dev/null +++ b/sdk/runanywhere-commons/src/backends/whispercpp/whispercpp_backend.cpp @@ -0,0 +1,622 @@ +/** + * WhisperCPP Backend Implementation + * + * Speech-to-Text via whisper.cpp + */ + +#include "whispercpp_backend.h" + +#include +#include +#include +#include +#include + +#include "rac/core/rac_logger.h" + +// Use the RAC logging system +#define LOGI(...) RAC_LOG_INFO("STT.WhisperCpp", __VA_ARGS__) +#define LOGE(...) RAC_LOG_ERROR("STT.WhisperCpp", __VA_ARGS__) +#define LOGW(...) RAC_LOG_WARNING("STT.WhisperCpp", __VA_ARGS__) + +// Whisper sample rate constant +#ifndef WHISPER_SAMPLE_RATE +#define WHISPER_SAMPLE_RATE 16000 +#endif + +namespace runanywhere { + +// ============================================================================= +// WHISPERCPP BACKEND IMPLEMENTATION +// ============================================================================= + +WhisperCppBackend::WhisperCppBackend() { + LOGI("WhisperCppBackend created"); +} + +WhisperCppBackend::~WhisperCppBackend() { + cleanup(); + LOGI("WhisperCppBackend destroyed"); +} + +bool WhisperCppBackend::initialize(const nlohmann::json& config) { + std::lock_guard lock(mutex_); + + if (initialized_) { + LOGI("WhisperCppBackend already initialized"); + return true; + } + + config_ = config; + + if (config.contains("num_threads")) { + num_threads_ = config["num_threads"].get(); + } + if (num_threads_ <= 0) { +#if defined(_SC_NPROCESSORS_ONLN) + num_threads_ = + std::max(1, std::min(8, static_cast(sysconf(_SC_NPROCESSORS_ONLN)) - 2)); +#else + num_threads_ = 4; +#endif + } + + if (config.contains("use_gpu")) { + use_gpu_ = config["use_gpu"].get(); + } + + LOGI("WhisperCppBackend initialized with %d threads, GPU: %s", num_threads_, + use_gpu_ ? "enabled" : "disabled"); + + create_stt(); + initialized_ = true; + return true; +} + +bool WhisperCppBackend::is_initialized() const { + std::lock_guard lock(mutex_); + return initialized_; +} + +void WhisperCppBackend::cleanup() { + std::lock_guard lock(mutex_); + + if (!initialized_) { + return; + } + + stt_.reset(); + initialized_ = false; + LOGI("WhisperCppBackend cleaned up"); +} + +void WhisperCppBackend::create_stt() { + stt_ = std::make_unique(this); + LOGI("Created STT component"); +} + +DeviceType WhisperCppBackend::get_device_type() const { +#if defined(GGML_USE_METAL) + return DeviceType::METAL; +#elif defined(GGML_USE_CUDA) + return DeviceType::CUDA; +#else + return DeviceType::CPU; +#endif +} + +size_t WhisperCppBackend::get_memory_usage() const { + return 0; +} + +// ============================================================================= +// WHISPERCPP STT IMPLEMENTATION +// ============================================================================= + +WhisperCppSTT::WhisperCppSTT(WhisperCppBackend* backend) : backend_(backend) { + LOGI("WhisperCppSTT created"); +} + +WhisperCppSTT::~WhisperCppSTT() { + unload_model(); + + for (auto& [id, state] : streams_) { + if (state && state->state) { + whisper_free_state(state->state); + } + } + streams_.clear(); + + LOGI("WhisperCppSTT destroyed"); +} + +bool WhisperCppSTT::is_ready() const { + return model_loaded_ && ctx_ != nullptr; +} + +bool WhisperCppSTT::load_model(const std::string& model_path, STTModelType model_type, + const nlohmann::json& config) { + std::lock_guard lock(mutex_); + + if (model_loaded_ && ctx_) { + LOGI("Unloading previous model"); + whisper_free(ctx_); + ctx_ = nullptr; + model_loaded_ = false; + } + + LOGI("Loading whisper model from: %s", model_path.c_str()); + + whisper_context_params cparams = whisper_context_default_params(); + cparams.use_gpu = backend_->is_gpu_enabled(); + + if (config.contains("word_timestamps") && config["word_timestamps"].get()) { + cparams.dtw_token_timestamps = true; + cparams.dtw_aheads_preset = WHISPER_AHEADS_LARGE_V3; + } + + if (config.contains("flash_attention")) { + cparams.flash_attn = config["flash_attention"].get(); + } + + ctx_ = whisper_init_from_file_with_params(model_path.c_str(), cparams); + + if (!ctx_) { + LOGE("Failed to load whisper model from: %s", model_path.c_str()); + return false; + } + + model_path_ = model_path; + model_config_ = config; + model_loaded_ = true; + + LOGI("Whisper model loaded successfully. Multilingual: %s", + whisper_is_multilingual(ctx_) ? "yes" : "no"); + + return true; +} + +bool WhisperCppSTT::is_model_loaded() const { + return model_loaded_; +} + +bool WhisperCppSTT::unload_model() { + std::lock_guard lock(mutex_); + + if (!model_loaded_ || !ctx_) { + return true; + } + + for (auto& [id, state] : streams_) { + if (state && state->state) { + whisper_free_state(state->state); + } + } + streams_.clear(); + + whisper_free(ctx_); + ctx_ = nullptr; + model_loaded_ = false; + model_path_.clear(); + + LOGI("Whisper model unloaded"); + return true; +} + +STTModelType WhisperCppSTT::get_model_type() const { + return STTModelType::WHISPER; +} + +STTResult WhisperCppSTT::transcribe(const STTRequest& request) { + std::lock_guard lock(mutex_); + + STTResult result; + result.is_final = true; + + if (!model_loaded_ || !ctx_) { + LOGE("Model not loaded"); + return result; + } + + cancel_requested_.store(false); + + std::vector audio = request.audio_samples; + if (request.sample_rate != WHISPER_SAMPLE_RATE) { + audio = resample_to_16khz(request.audio_samples, request.sample_rate); + } + + return transcribe_internal(audio, request.language, + request.detect_language || request.language.empty(), + request.translate_to_english, request.word_timestamps); +} + +STTResult WhisperCppSTT::transcribe_internal(const std::vector& audio, + const std::string& language, bool detect_language, + bool translate, bool word_timestamps) { + STTResult result; + result.is_final = true; + + auto start_time = std::chrono::high_resolution_clock::now(); + + whisper_full_params wparams = whisper_full_default_params(WHISPER_SAMPLING_GREEDY); + wparams.n_threads = backend_->get_num_threads(); + wparams.print_progress = false; + wparams.print_realtime = false; + wparams.print_special = false; + wparams.print_timestamps = false; + + if (detect_language || language.empty()) { + wparams.language = nullptr; + wparams.detect_language = true; + } else { + wparams.language = language.c_str(); + wparams.detect_language = false; + } + + wparams.translate = translate; + wparams.token_timestamps = word_timestamps; + + wparams.abort_callback = [](void* user_data) -> bool { + auto* cancel_flag = static_cast*>(user_data); + return cancel_flag->load(); + }; + wparams.abort_callback_user_data = &cancel_requested_; + + int ret = whisper_full(ctx_, wparams, audio.data(), static_cast(audio.size())); + + if (ret != 0) { + LOGE("whisper_full failed with code: %d", ret); + return result; + } + + auto end_time = std::chrono::high_resolution_clock::now(); + auto duration = std::chrono::duration_cast(end_time - start_time); + + const int n_segments = whisper_full_n_segments(ctx_); + std::string full_text; + full_text.reserve(n_segments * 64); + + result.segments.reserve(n_segments); + + if (word_timestamps) { + result.word_timings.reserve(n_segments * 15); + } + + for (int i = 0; i < n_segments; ++i) { + const char* text = whisper_full_get_segment_text(ctx_, i); + if (text) { + full_text += text; + + result.segments.emplace_back(); + AudioSegment& segment = result.segments.back(); + segment.text = text; + segment.start_time_ms = whisper_full_get_segment_t0(ctx_, i) * 10.0; + segment.end_time_ms = whisper_full_get_segment_t1(ctx_, i) * 10.0; + + float no_speech_prob = whisper_full_get_segment_no_speech_prob(ctx_, i); + segment.confidence = 1.0f - no_speech_prob; + + if (word_timestamps) { + const int n_tokens = whisper_full_n_tokens(ctx_, i); + for (int j = 0; j < n_tokens; ++j) { + whisper_token_data token_data = whisper_full_get_token_data(ctx_, i, j); + const char* token_text = whisper_full_get_token_text(ctx_, i, j); + + if (token_text && token_text[0] != '\0' && token_text[0] != '<') { + result.word_timings.emplace_back(); + WordTiming& word = result.word_timings.back(); + word.word = token_text; + word.start_time_ms = token_data.t0 * 10.0; + word.end_time_ms = token_data.t1 * 10.0; + word.confidence = token_data.p; + } + } + } + } + } + + result.text = full_text; + result.audio_duration_ms = (audio.size() / static_cast(WHISPER_SAMPLE_RATE)) * 1000.0; + result.inference_time_ms = static_cast(duration.count()); + + int lang_id = whisper_full_lang_id(ctx_); + if (lang_id >= 0) { + result.detected_language = whisper_lang_str(lang_id); + } + + if (!result.segments.empty()) { + float total_conf = 0.0f; + for (const auto& seg : result.segments) { + total_conf += seg.confidence; + } + result.confidence = total_conf / static_cast(result.segments.size()); + } + + LOGI("Transcription complete: %d segments, %.0fms inference, lang=%s", n_segments, + result.inference_time_ms, + result.detected_language.empty() ? "unknown" : result.detected_language.c_str()); + + return result; +} + +bool WhisperCppSTT::supports_streaming() const { + return true; +} + +std::string WhisperCppSTT::generate_stream_id() { + std::stringstream ss; + ss << "whisper_stream_" << ++stream_counter_; + return ss.str(); +} + +std::string WhisperCppSTT::create_stream(const nlohmann::json& config) { + std::lock_guard lock(mutex_); + + if (!model_loaded_ || !ctx_) { + LOGE("Cannot create stream: model not loaded"); + return ""; + } + + std::string stream_id = generate_stream_id(); + + auto state = std::make_unique(); + state->state = whisper_init_state(ctx_); + + if (!state->state) { + LOGE("Failed to create whisper state for stream"); + return ""; + } + + if (config.contains("language")) { + state->language = config["language"].get(); + } + + if (config.contains("sample_rate")) { + state->sample_rate = config["sample_rate"].get(); + } + + streams_[stream_id] = std::move(state); + + LOGI("Created stream: %s", stream_id.c_str()); + return stream_id; +} + +bool WhisperCppSTT::feed_audio(const std::string& stream_id, const std::vector& samples, + int sample_rate) { + std::lock_guard lock(mutex_); + + auto it = streams_.find(stream_id); + if (it == streams_.end()) { + LOGE("Stream not found: %s", stream_id.c_str()); + return false; + } + + auto& state = it->second; + + std::vector resampled = samples; + if (sample_rate != WHISPER_SAMPLE_RATE) { + resampled = resample_to_16khz(samples, sample_rate); + } + + state->audio_buffer.insert(state->audio_buffer.end(), resampled.begin(), resampled.end()); + + return true; +} + +bool WhisperCppSTT::is_stream_ready(const std::string& stream_id) { + std::lock_guard lock(mutex_); + + auto it = streams_.find(stream_id); + if (it == streams_.end()) { + return false; + } + + const size_t min_samples = WHISPER_SAMPLE_RATE; + return it->second->audio_buffer.size() >= min_samples || it->second->input_finished; +} + +STTResult WhisperCppSTT::decode(const std::string& stream_id) { + std::lock_guard lock(mutex_); + + STTResult result; + + auto it = streams_.find(stream_id); + if (it == streams_.end()) { + LOGE("Stream not found: %s", stream_id.c_str()); + return result; + } + + auto& stream_state = it->second; + + if (stream_state->audio_buffer.empty()) { + result.is_final = stream_state->input_finished; + return result; + } + + whisper_full_params wparams = whisper_full_default_params(WHISPER_SAMPLING_GREEDY); + wparams.n_threads = backend_->get_num_threads(); + wparams.single_segment = !stream_state->input_finished; + wparams.no_context = false; + wparams.print_progress = false; + wparams.print_realtime = false; + wparams.print_timestamps = false; + + if (!stream_state->language.empty()) { + wparams.language = stream_state->language.c_str(); + } + + int ret = whisper_full_with_state(ctx_, stream_state->state, wparams, + stream_state->audio_buffer.data(), + static_cast(stream_state->audio_buffer.size())); + + if (ret != 0) { + LOGE("whisper_full_with_state failed: %d", ret); + return result; + } + + const int n_segments = whisper_full_n_segments_from_state(stream_state->state); + std::string full_text; + + for (int i = 0; i < n_segments; ++i) { + const char* text = whisper_full_get_segment_text_from_state(stream_state->state, i); + if (text) { + full_text += text; + + AudioSegment segment; + segment.text = text; + segment.start_time_ms = + whisper_full_get_segment_t0_from_state(stream_state->state, i) * 10.0; + segment.end_time_ms = + whisper_full_get_segment_t1_from_state(stream_state->state, i) * 10.0; + result.segments.push_back(segment); + } + } + + result.text = full_text; + result.is_final = stream_state->input_finished; + result.audio_duration_ms = + (stream_state->audio_buffer.size() / static_cast(WHISPER_SAMPLE_RATE)) * 1000.0; + + int lang_id = whisper_full_lang_id_from_state(stream_state->state); + if (lang_id >= 0) { + result.detected_language = whisper_lang_str(lang_id); + } + + stream_state->audio_buffer.clear(); + + return result; +} + +bool WhisperCppSTT::is_endpoint(const std::string& stream_id) { + std::lock_guard lock(mutex_); + + auto it = streams_.find(stream_id); + if (it == streams_.end()) { + return false; + } + + return it->second->input_finished; +} + +void WhisperCppSTT::input_finished(const std::string& stream_id) { + std::lock_guard lock(mutex_); + + auto it = streams_.find(stream_id); + if (it != streams_.end()) { + it->second->input_finished = true; + LOGI("Input finished for stream: %s", stream_id.c_str()); + } +} + +void WhisperCppSTT::reset_stream(const std::string& stream_id) { + std::lock_guard lock(mutex_); + + auto it = streams_.find(stream_id); + if (it != streams_.end()) { + it->second->audio_buffer.clear(); + it->second->input_finished = false; + LOGI("Reset stream: %s", stream_id.c_str()); + } +} + +void WhisperCppSTT::destroy_stream(const std::string& stream_id) { + std::lock_guard lock(mutex_); + + auto it = streams_.find(stream_id); + if (it != streams_.end()) { + if (it->second && it->second->state) { + whisper_free_state(it->second->state); + } + streams_.erase(it); + LOGI("Destroyed stream: %s", stream_id.c_str()); + } +} + +void WhisperCppSTT::cancel() { + cancel_requested_.store(true); + LOGI("Cancellation requested"); +} + +std::vector WhisperCppSTT::get_supported_languages() const { + std::vector languages; + + const int max_lang = whisper_lang_max_id(); + for (int i = 0; i <= max_lang; ++i) { + const char* lang = whisper_lang_str(i); + if (lang) { + languages.push_back(lang); + } + } + + return languages; +} + +std::vector WhisperCppSTT::resample_to_16khz(const std::vector& samples, + int source_rate) { + if (source_rate == WHISPER_SAMPLE_RATE || samples.empty()) { + return samples; + } + + const double step = static_cast(source_rate) / WHISPER_SAMPLE_RATE; + + size_t output_size = static_cast(samples.size() / step); + if (output_size == 0) { + output_size = 1; + } + + std::vector output; + + if (source_rate % WHISPER_SAMPLE_RATE == 0) { + const int stride = source_rate / WHISPER_SAMPLE_RATE; + const size_t out_len = std::max(1, samples.size() / stride); + + output.resize(out_len); + for (size_t i = 0; i < out_len; ++i) { + output[i] = samples[i * stride]; + } + return output; + } + + output.resize(output_size); + + const float* __restrict src_ptr = samples.data(); + const size_t src_size = samples.size(); + + const size_t safe_output_limit = (output_size > 0) ? output_size - 1 : 0; + + double pos = 0.0; + size_t i = 0; + + for (; i < safe_output_limit; ++i) { + size_t idx0 = static_cast(pos); + if (idx0 >= src_size - 1) break; + + double frac = pos - idx0; + float val0 = src_ptr[idx0]; + float val1 = src_ptr[idx0 + 1]; + + output[i] = val0 + static_cast(frac) * (val1 - val0); + pos += step; + } + + for (; i < output_size; ++i) { + size_t idx0 = static_cast(pos); + if (idx0 >= src_size) idx0 = src_size - 1; + + size_t idx1 = (idx0 + 1 < src_size) ? idx0 + 1 : src_size - 1; + + double frac = pos - static_cast(idx0); + float val0 = src_ptr[idx0]; + float val1 = src_ptr[idx1]; + + output[i] = val0 + static_cast(frac) * (val1 - val0); + pos += step; + } + + LOGI("Resampled audio from %d Hz to %d Hz (%zu -> %zu samples)", source_rate, + WHISPER_SAMPLE_RATE, samples.size(), output_size); + + return output; +} + +} // namespace runanywhere diff --git a/sdk/runanywhere-commons/src/backends/whispercpp/whispercpp_backend.h b/sdk/runanywhere-commons/src/backends/whispercpp/whispercpp_backend.h new file mode 100644 index 000000000..3be13b5f3 --- /dev/null +++ b/sdk/runanywhere-commons/src/backends/whispercpp/whispercpp_backend.h @@ -0,0 +1,187 @@ +#ifndef RUNANYWHERE_WHISPERCPP_BACKEND_H +#define RUNANYWHERE_WHISPERCPP_BACKEND_H + +/** + * WhisperCPP Backend - Speech-to-Text via whisper.cpp + * + * This backend uses whisper.cpp for on-device speech recognition with GGML Whisper models. + * Internal C++ implementation wrapped by RAC API (rac_stt_whispercpp.cpp). + */ + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace runanywhere { + +// ============================================================================= +// INTERNAL TYPES +// ============================================================================= + +enum class DeviceType { + CPU = 0, + GPU = 1, + METAL = 3, + CUDA = 4, +}; + +enum class STTModelType { + WHISPER, + ZIPFORMER, + TRANSDUCER, + PARAFORMER, + CUSTOM +}; + +// ============================================================================= +// STT RESULT TYPES +// ============================================================================= + +struct WordTiming { + std::string word; + double start_time_ms = 0.0; + double end_time_ms = 0.0; + float confidence = 0.0f; +}; + +struct AudioSegment { + std::string text; + double start_time_ms = 0.0; + double end_time_ms = 0.0; + float confidence = 0.0f; + std::string language; +}; + +struct STTRequest { + std::vector audio_samples; + int sample_rate = 16000; + std::string language; + bool detect_language = false; + bool word_timestamps = false; + bool translate_to_english = false; +}; + +struct STTResult { + std::string text; + std::string detected_language; + std::vector segments; + std::vector word_timings; + double audio_duration_ms = 0.0; + double inference_time_ms = 0.0; + float confidence = 0.0f; + bool is_final = true; +}; + +// ============================================================================= +// FORWARD DECLARATIONS +// ============================================================================= + +class WhisperCppSTT; + +// ============================================================================= +// WHISPERCPP BACKEND +// ============================================================================= + +class WhisperCppBackend { + public: + WhisperCppBackend(); + ~WhisperCppBackend(); + + bool initialize(const nlohmann::json& config = {}); + bool is_initialized() const; + void cleanup(); + + DeviceType get_device_type() const; + size_t get_memory_usage() const; + + int get_num_threads() const { return num_threads_; } + bool is_gpu_enabled() const { return use_gpu_; } + + WhisperCppSTT* get_stt() { return stt_.get(); } + + private: + void create_stt(); + + bool initialized_ = false; + nlohmann::json config_; + int num_threads_ = 0; + bool use_gpu_ = true; + std::unique_ptr stt_; + mutable std::mutex mutex_; +}; + +// ============================================================================= +// STREAMING STATE +// ============================================================================= + +struct WhisperStreamState { + whisper_state* state = nullptr; + std::vector audio_buffer; + std::string language; + bool input_finished = false; + int sample_rate = 16000; +}; + +// ============================================================================= +// STT IMPLEMENTATION +// ============================================================================= + +class WhisperCppSTT { + public: + explicit WhisperCppSTT(WhisperCppBackend* backend); + ~WhisperCppSTT(); + + bool is_ready() const; + bool load_model(const std::string& model_path, STTModelType model_type = STTModelType::WHISPER, + const nlohmann::json& config = {}); + bool is_model_loaded() const; + bool unload_model(); + STTModelType get_model_type() const; + + STTResult transcribe(const STTRequest& request); + + bool supports_streaming() const; + std::string create_stream(const nlohmann::json& config = {}); + bool feed_audio(const std::string& stream_id, const std::vector& samples, int sample_rate); + bool is_stream_ready(const std::string& stream_id); + STTResult decode(const std::string& stream_id); + bool is_endpoint(const std::string& stream_id); + void input_finished(const std::string& stream_id); + void reset_stream(const std::string& stream_id); + void destroy_stream(const std::string& stream_id); + + void cancel(); + std::vector get_supported_languages() const; + + private: + STTResult transcribe_internal(const std::vector& audio, const std::string& language, + bool detect_language, bool translate, bool word_timestamps); + std::vector resample_to_16khz(const std::vector& samples, int source_rate); + std::string generate_stream_id(); + + WhisperCppBackend* backend_; + whisper_context* ctx_ = nullptr; + + bool model_loaded_ = false; + std::atomic cancel_requested_{false}; + + std::string model_path_; + nlohmann::json model_config_; + + std::unordered_map> streams_; + int stream_counter_ = 0; + + mutable std::mutex mutex_; +}; + +} // namespace runanywhere + +#endif // RUNANYWHERE_WHISPERCPP_BACKEND_H diff --git a/sdk/runanywhere-commons/src/core/capabilities/lifecycle_manager.cpp b/sdk/runanywhere-commons/src/core/capabilities/lifecycle_manager.cpp new file mode 100644 index 000000000..21e15af86 --- /dev/null +++ b/sdk/runanywhere-commons/src/core/capabilities/lifecycle_manager.cpp @@ -0,0 +1,444 @@ +/** + * @file lifecycle_manager.cpp + * @brief RunAnywhere Commons - Lifecycle Manager Implementation + * + * C++ port of Swift's ManagedLifecycle.swift from: + * Sources/RunAnywhere/Core/Capabilities/ManagedLifecycle.swift + * + * IMPLEMENTATION NOTE: This is a direct 1:1 port of the Swift code. + * Do not add, remove, or modify any behavior that isn't in the Swift source. + */ + +#include +#include +#include +#include +#include + +#include "rac/core/capabilities/rac_lifecycle.h" +#include "rac/core/rac_logger.h" +#include "rac/core/rac_platform_adapter.h" +#include "rac/infrastructure/events/rac_events.h" + +// ============================================================================= +// INTERNAL STRUCTURES +// ============================================================================= + +namespace { + +/** + * Internal lifecycle manager state. + * Mirrors Swift's ManagedLifecycle properties. + */ +struct LifecycleManager { + // Configuration + rac_resource_type_t resource_type{RAC_RESOURCE_TYPE_LLM_MODEL}; + std::string logger_category{}; + void* user_data{nullptr}; + + // Callbacks + rac_lifecycle_create_service_fn create_fn{nullptr}; + rac_lifecycle_destroy_service_fn destroy_fn{nullptr}; + + // State (mirrors Swift's lifecycle properties) + std::atomic state{RAC_LIFECYCLE_STATE_IDLE}; + std::string current_model_path{}; // File path used for loading + std::string + current_model_id{}; // Model identifier for telemetry (e.g., "sherpa-onnx-whisper-tiny.en") + std::string current_model_name{}; // Human-readable name (e.g., "Sherpa Whisper Tiny (ONNX)") + rac_handle_t current_service{nullptr}; + + // Metrics (mirrors Swift's ManagedLifecycle metrics) + int32_t load_count{0}; + double total_load_time_ms{0.0}; + int32_t failed_loads{0}; + int32_t total_unloads{0}; + int64_t start_time_ms{0}; + int64_t last_event_time_ms{0}; + + // Thread safety + std::mutex mutex{}; + + LifecycleManager() { + // Set start time (mirrors Swift's startTime = Date()) + auto now = std::chrono::system_clock::now(); + auto duration = now.time_since_epoch(); + start_time_ms = std::chrono::duration_cast(duration).count(); + } +}; + +int64_t current_time_ms() { + auto now = std::chrono::system_clock::now(); + auto duration = now.time_since_epoch(); + return std::chrono::duration_cast(duration).count(); +} + +/** + * Track lifecycle event via EventPublisher. + * Mirrors Swift's trackEvent(type:modelId:durationMs:error:) + */ +void track_lifecycle_event(LifecycleManager* mgr, const char* event_type, const char* model_id, + double duration_ms, rac_result_t error_code) { + // Determine event category based on resource type + // Mirrors Swift's createEvent() switch on resourceType + rac_event_category_t category = RAC_EVENT_CATEGORY_MODEL; + switch (mgr->resource_type) { + case RAC_RESOURCE_TYPE_LLM_MODEL: + category = RAC_EVENT_CATEGORY_LLM; + break; + case RAC_RESOURCE_TYPE_STT_MODEL: + category = RAC_EVENT_CATEGORY_STT; + break; + case RAC_RESOURCE_TYPE_TTS_VOICE: + category = RAC_EVENT_CATEGORY_TTS; + break; + case RAC_RESOURCE_TYPE_VAD_MODEL: + case RAC_RESOURCE_TYPE_DIARIZATION_MODEL: + default: + // category already initialized to RAC_EVENT_CATEGORY_MODEL + break; + } + + // Build properties JSON (simplified version) + char properties[512]; + if (error_code != RAC_SUCCESS) { + snprintf(properties, sizeof(properties), + R"({"modelId":"%s","durationMs":%.1f,"errorCode":%d})", model_id ? model_id : "", + duration_ms, error_code); + } else if (duration_ms > 0) { + snprintf(properties, sizeof(properties), R"({"modelId":"%s","durationMs":%.1f})", + model_id ? model_id : "", duration_ms); + } else { + snprintf(properties, sizeof(properties), R"({"modelId":"%s"})", model_id ? model_id : ""); + } + + // Track event (mirrors Swift's EventPublisher.shared.track(event)) + rac_event_track(event_type, category, RAC_EVENT_DESTINATION_ALL, properties); + + mgr->last_event_time_ms = current_time_ms(); +} + +} // namespace + +// ============================================================================= +// PUBLIC API IMPLEMENTATION +// ============================================================================= + +extern "C" { + +rac_result_t rac_lifecycle_create(const rac_lifecycle_config_t* config, + rac_lifecycle_create_service_fn create_fn, + rac_lifecycle_destroy_service_fn destroy_fn, + rac_handle_t* out_handle) { + if (config == nullptr || create_fn == nullptr || out_handle == nullptr) { + return RAC_ERROR_NULL_POINTER; + } + + auto* mgr = new LifecycleManager(); + mgr->resource_type = config->resource_type; + mgr->logger_category = config->logger_category ? config->logger_category : "Lifecycle"; + mgr->user_data = config->user_data; + mgr->create_fn = create_fn; + mgr->destroy_fn = destroy_fn; + + *out_handle = static_cast(mgr); + return RAC_SUCCESS; +} + +rac_result_t rac_lifecycle_load(rac_handle_t handle, const char* model_path, const char* model_id, + const char* model_name, rac_handle_t* out_service) { + if (handle == nullptr || model_path == nullptr || out_service == nullptr) { + return RAC_ERROR_NULL_POINTER; + } + + // If model_id is null, use model_path as model_id + if (model_id == nullptr) { + model_id = model_path; + } + // If model_name is null, use model_id as model_name + if (model_name == nullptr) { + model_name = model_id; + } + + auto* mgr = static_cast(handle); + std::lock_guard lock(mgr->mutex); + + // Check if already loaded with same path - skip duplicate events + // Mirrors Swift: if await lifecycle.currentResourceId == modelId + if (mgr->state.load() == RAC_LIFECYCLE_STATE_LOADED && mgr->current_model_path == model_path && + mgr->current_service != nullptr) { + // Mirrors Swift: logger.info("Model already loaded, skipping duplicate load") + RAC_LOG_INFO(mgr->logger_category.c_str(), "Model already loaded, skipping duplicate load"); + *out_service = mgr->current_service; + return RAC_SUCCESS; + } + + // Track load started (mirrors Swift: trackEvent(type: .loadStarted)) + int64_t start_time = current_time_ms(); + mgr->state.store(RAC_LIFECYCLE_STATE_LOADING); + track_lifecycle_event(mgr, "load.started", model_id, 0.0, RAC_SUCCESS); + + RAC_LOG_INFO(mgr->logger_category.c_str(), "Loading model: %s (path: %s)", model_id, + model_path); + + // Create service via callback - pass the PATH for loading + rac_handle_t service = nullptr; + rac_result_t result = mgr->create_fn(model_path, mgr->user_data, &service); + + auto load_time_ms = static_cast(current_time_ms() - start_time); + + if (result == RAC_SUCCESS && service != nullptr) { + // Success - store path, model_id, and model_name separately + mgr->current_model_path = model_path; + mgr->current_model_id = model_id; // Model identifier for telemetry + mgr->current_model_name = model_name; // Human-readable name for telemetry + mgr->current_service = service; + mgr->state.store(RAC_LIFECYCLE_STATE_LOADED); + + // Track load completed (mirrors Swift: trackEvent(type: .loadCompleted)) + track_lifecycle_event(mgr, "load.completed", model_id, load_time_ms, RAC_SUCCESS); + + // Update metrics (mirrors Swift: loadCount += 1, totalLoadTime += loadTime) + mgr->load_count++; + mgr->total_load_time_ms += load_time_ms; + + RAC_LOG_INFO(mgr->logger_category.c_str(), "Loaded model in %dms", + static_cast(load_time_ms)); + + *out_service = service; + return RAC_SUCCESS; + } + + // Failure - mirrors Swift catch block + mgr->state.store(RAC_LIFECYCLE_STATE_FAILED); + mgr->failed_loads++; + + // Track load failed (mirrors Swift: trackEvent(type: .loadFailed)) + track_lifecycle_event(mgr, "load.failed", model_id, load_time_ms, result); + + RAC_LOG_ERROR(mgr->logger_category.c_str(), "Failed to load model"); + + return result; +} + +rac_result_t rac_lifecycle_unload(rac_handle_t handle) { + if (handle == nullptr) { + return RAC_ERROR_NULL_POINTER; + } + + auto* mgr = static_cast(handle); + std::lock_guard lock(mgr->mutex); + + // Mirrors Swift: if let modelId = await lifecycle.currentResourceId + if (!mgr->current_model_id.empty()) { + RAC_LOG_INFO(mgr->logger_category.c_str(), "Unloading model: %s", + mgr->current_model_id.c_str()); + + // Destroy service if callback provided + if (mgr->destroy_fn != nullptr && mgr->current_service != nullptr) { + mgr->destroy_fn(mgr->current_service, mgr->user_data); + } + + // Track unload event (mirrors Swift: trackEvent(type: .unloaded)) + track_lifecycle_event(mgr, "unloaded", mgr->current_model_id.c_str(), 0.0, RAC_SUCCESS); + + mgr->total_unloads++; + } + + // Reset state + mgr->current_model_path.clear(); + mgr->current_model_id.clear(); + mgr->current_model_name.clear(); + mgr->current_service = nullptr; + mgr->state.store(RAC_LIFECYCLE_STATE_IDLE); + + return RAC_SUCCESS; +} + +rac_result_t rac_lifecycle_reset(rac_handle_t handle) { + if (handle == nullptr) { + return RAC_ERROR_NULL_POINTER; + } + + auto* mgr = static_cast(handle); + std::lock_guard lock(mgr->mutex); + + // Track unload if currently loaded (mirrors Swift reset()) + if (!mgr->current_model_id.empty()) { + track_lifecycle_event(mgr, "unloaded", mgr->current_model_id.c_str(), 0.0, RAC_SUCCESS); + + // Destroy service if callback provided + if (mgr->destroy_fn != nullptr && mgr->current_service != nullptr) { + mgr->destroy_fn(mgr->current_service, mgr->user_data); + } + } + + // Reset all state + mgr->current_model_path.clear(); + mgr->current_model_id.clear(); + mgr->current_model_name.clear(); + mgr->current_service = nullptr; + mgr->state.store(RAC_LIFECYCLE_STATE_IDLE); + + return RAC_SUCCESS; +} + +rac_lifecycle_state_t rac_lifecycle_get_state(rac_handle_t handle) { + if (handle == nullptr) { + return RAC_LIFECYCLE_STATE_IDLE; + } + + auto* mgr = static_cast(handle); + return mgr->state.load(); +} + +rac_bool_t rac_lifecycle_is_loaded(rac_handle_t handle) { + if (handle == nullptr) { + return RAC_FALSE; + } + + auto* mgr = static_cast(handle); + return mgr->state.load() == RAC_LIFECYCLE_STATE_LOADED ? RAC_TRUE : RAC_FALSE; +} + +const char* rac_lifecycle_get_model_id(rac_handle_t handle) { + if (handle == nullptr) { + return nullptr; + } + + auto* mgr = static_cast(handle); + std::lock_guard lock(mgr->mutex); + + if (mgr->current_model_id.empty()) { + return nullptr; + } + return mgr->current_model_id.c_str(); +} + +const char* rac_lifecycle_get_model_name(rac_handle_t handle) { + if (handle == nullptr) { + return nullptr; + } + + auto* mgr = static_cast(handle); + std::lock_guard lock(mgr->mutex); + + if (mgr->current_model_name.empty()) { + return nullptr; + } + return mgr->current_model_name.c_str(); +} + +rac_handle_t rac_lifecycle_get_service(rac_handle_t handle) { + if (handle == nullptr) { + return nullptr; + } + + auto* mgr = static_cast(handle); + return mgr->current_service; +} + +rac_result_t rac_lifecycle_require_service(rac_handle_t handle, rac_handle_t* out_service) { + if (handle == nullptr || out_service == nullptr) { + return RAC_ERROR_NULL_POINTER; + } + + auto* mgr = static_cast(handle); + + if (mgr->state.load() != RAC_LIFECYCLE_STATE_LOADED || mgr->current_service == nullptr) { + rac_error_set_details("Service not loaded - call load() first"); + return RAC_ERROR_NOT_INITIALIZED; + } + + *out_service = mgr->current_service; + return RAC_SUCCESS; +} + +void rac_lifecycle_track_error(rac_handle_t handle, rac_result_t error_code, + const char* operation) { + if (handle == nullptr) { + return; + } + + // Note: handle parameter reserved for future use (e.g., category from mgr->resource_type) + (void)handle; + + // Build error event properties + char properties[256]; + snprintf(properties, sizeof(properties), + R"({"operation":"%s","errorCode":%d,"errorMessage":"%s"})", + operation ? operation : "unknown", error_code, rac_error_message(error_code)); + + // Track error event (mirrors Swift: EventPublisher.shared.track(errorEvent)) + rac_event_track("error.operation", RAC_EVENT_CATEGORY_ERROR, RAC_EVENT_DESTINATION_ALL, + properties); +} + +rac_result_t rac_lifecycle_get_metrics(rac_handle_t handle, rac_lifecycle_metrics_t* out_metrics) { + if (handle == nullptr || out_metrics == nullptr) { + return RAC_ERROR_NULL_POINTER; + } + + auto* mgr = static_cast(handle); + std::lock_guard lock(mgr->mutex); + + // Mirrors Swift's getLifecycleMetrics() + out_metrics->total_events = mgr->load_count + mgr->total_unloads + mgr->failed_loads; + out_metrics->start_time_ms = mgr->start_time_ms; + out_metrics->last_event_time_ms = mgr->last_event_time_ms; + out_metrics->total_loads = mgr->load_count + mgr->failed_loads; + out_metrics->successful_loads = mgr->load_count; + out_metrics->failed_loads = mgr->failed_loads; + out_metrics->average_load_time_ms = + mgr->load_count > 0 ? mgr->total_load_time_ms / static_cast(mgr->load_count) : 0.0; + out_metrics->total_unloads = mgr->total_unloads; + + return RAC_SUCCESS; +} + +void rac_lifecycle_destroy(rac_handle_t handle) { + if (handle == nullptr) { + return; + } + + auto* mgr = static_cast(handle); + + // Unload before destroy + rac_lifecycle_unload(handle); + + delete mgr; +} + +const char* rac_lifecycle_state_name(rac_lifecycle_state_t state) { + switch (state) { + case RAC_LIFECYCLE_STATE_IDLE: + return "idle"; + case RAC_LIFECYCLE_STATE_LOADING: + return "loading"; + case RAC_LIFECYCLE_STATE_LOADED: + return "loaded"; + case RAC_LIFECYCLE_STATE_FAILED: + return "failed"; + default: + return "unknown"; + } +} + +const char* rac_resource_type_name(rac_resource_type_t type) { + switch (type) { + case RAC_RESOURCE_TYPE_LLM_MODEL: + return "llmModel"; + case RAC_RESOURCE_TYPE_STT_MODEL: + return "sttModel"; + case RAC_RESOURCE_TYPE_TTS_VOICE: + return "ttsVoice"; + case RAC_RESOURCE_TYPE_VAD_MODEL: + return "vadModel"; + case RAC_RESOURCE_TYPE_DIARIZATION_MODEL: + return "diarizationModel"; + default: + return "unknown"; + } +} + +} // extern "C" diff --git a/sdk/runanywhere-commons/src/core/component_types.cpp b/sdk/runanywhere-commons/src/core/component_types.cpp new file mode 100644 index 000000000..0546c489e --- /dev/null +++ b/sdk/runanywhere-commons/src/core/component_types.cpp @@ -0,0 +1,119 @@ +/** + * @file component_types.cpp + * @brief Implementation of component types + * + * C++ implementation of component type utilities. + * 1:1 port from Swift's ComponentTypes.swift and ResourceTypes.swift + */ + +#include + +#include "rac/core/rac_component_types.h" + +// ============================================================================= +// SDK COMPONENT FUNCTIONS +// ============================================================================= + +const char* rac_sdk_component_display_name(rac_sdk_component_t component) { + // Port from Swift's SDKComponent.displayName computed property + switch (component) { + case RAC_COMPONENT_LLM: + return "LLM"; + case RAC_COMPONENT_STT: + return "Speech-to-Text"; + case RAC_COMPONENT_TTS: + return "Text-to-Speech"; + case RAC_COMPONENT_VAD: + return "Voice Activity Detection"; + case RAC_COMPONENT_VOICE: + return "Voice Agent"; + case RAC_COMPONENT_EMBEDDING: + return "Embedding"; + default: + return "Unknown"; + } +} + +const char* rac_sdk_component_raw_value(rac_sdk_component_t component) { + // Port from Swift's SDKComponent.rawValue (enum case names) + switch (component) { + case RAC_COMPONENT_LLM: + return "llm"; + case RAC_COMPONENT_STT: + return "stt"; + case RAC_COMPONENT_TTS: + return "tts"; + case RAC_COMPONENT_VAD: + return "vad"; + case RAC_COMPONENT_VOICE: + return "voice"; + case RAC_COMPONENT_EMBEDDING: + return "embedding"; + default: + return "unknown"; + } +} + +// ============================================================================= +// CAPABILITY RESOURCE TYPE FUNCTIONS +// ============================================================================= + +const char* rac_capability_resource_type_raw_value(rac_capability_resource_type_t type) { + // Port from Swift's CapabilityResourceType.rawValue + switch (type) { + case RAC_RESOURCE_LLM_MODEL: + return "llm_model"; + case RAC_RESOURCE_STT_MODEL: + return "stt_model"; + case RAC_RESOURCE_TTS_VOICE: + return "tts_voice"; + case RAC_RESOURCE_VAD_MODEL: + return "vad_model"; + case RAC_RESOURCE_DIARIZATION_MODEL: + return "diarization_model"; + default: + return "unknown"; + } +} + +// ============================================================================= +// MAPPING FUNCTIONS +// ============================================================================= + +rac_sdk_component_t rac_resource_type_to_component(rac_capability_resource_type_t resource_type) { + // Map resource types to SDK components + switch (resource_type) { + case RAC_RESOURCE_LLM_MODEL: + return RAC_COMPONENT_LLM; + case RAC_RESOURCE_STT_MODEL: + return RAC_COMPONENT_STT; + case RAC_RESOURCE_TTS_VOICE: + return RAC_COMPONENT_TTS; + case RAC_RESOURCE_VAD_MODEL: + case RAC_RESOURCE_DIARIZATION_MODEL: + return RAC_COMPONENT_VAD; + default: + return RAC_COMPONENT_LLM; // Default fallback + } +} + +rac_capability_resource_type_t rac_component_to_resource_type(rac_sdk_component_t component) { + // Map SDK components to resource types + switch (component) { + case RAC_COMPONENT_LLM: + return RAC_RESOURCE_LLM_MODEL; + case RAC_COMPONENT_STT: + return RAC_RESOURCE_STT_MODEL; + case RAC_COMPONENT_TTS: + return RAC_RESOURCE_TTS_VOICE; + case RAC_COMPONENT_VAD: + return RAC_RESOURCE_VAD_MODEL; + case RAC_COMPONENT_VOICE: + // Voice agent doesn't have a direct resource type + return static_cast(-1); + case RAC_COMPONENT_EMBEDDING: + return RAC_RESOURCE_LLM_MODEL; // Embeddings use LLM models + default: + return static_cast(-1); + } +} diff --git a/sdk/runanywhere-commons/src/core/events.cpp b/sdk/runanywhere-commons/src/core/events.cpp new file mode 100644 index 000000000..0991f283e --- /dev/null +++ b/sdk/runanywhere-commons/src/core/events.cpp @@ -0,0 +1,654 @@ +/** + * @file events.cpp + * @brief RunAnywhere Commons - Cross-Platform Event System Implementation + * + * C++ is the canonical source of truth for all analytics events. + * Platform SDKs register callbacks to receive events. + */ + +#include + +#include "rac/core/rac_analytics_events.h" +#include "rac/core/rac_logger.h" + +// ============================================================================= +// INTERNAL STATE +// ============================================================================= + +namespace { + +// Thread-safe event callback storage +struct EventCallbackState { + rac_analytics_callback_fn analytics_callback = nullptr; + void* analytics_user_data = nullptr; + rac_public_event_callback_fn public_callback = nullptr; + void* public_user_data = nullptr; + std::mutex mutex; +}; + +EventCallbackState& get_callback_state() { + static EventCallbackState state; + return state; +} + +} // namespace + +// ============================================================================= +// PUBLIC API +// ============================================================================= + +extern "C" { + +rac_event_destination_t rac_event_get_destination(rac_event_type_t type) { + switch (type) { + // Public-only events (too chatty for telemetry, needed for UI) + case RAC_EVENT_LLM_STREAMING_UPDATE: + case RAC_EVENT_STT_PARTIAL_TRANSCRIPT: + case RAC_EVENT_TTS_SYNTHESIS_CHUNK: + case RAC_EVENT_MODEL_DOWNLOAD_PROGRESS: + case RAC_EVENT_MODEL_EXTRACTION_PROGRESS: + return RAC_EVENT_DESTINATION_PUBLIC_ONLY; + + // Telemetry-only events (internal metrics, not useful for app devs) + case RAC_EVENT_VAD_SPEECH_STARTED: + case RAC_EVENT_VAD_SPEECH_ENDED: + case RAC_EVENT_VAD_PAUSED: + case RAC_EVENT_VAD_RESUMED: + case RAC_EVENT_NETWORK_CONNECTIVITY_CHANGED: + return RAC_EVENT_DESTINATION_ANALYTICS_ONLY; + + // All other events go to both destinations + default: + return RAC_EVENT_DESTINATION_ALL; + } +} + +rac_result_t rac_analytics_events_set_callback(rac_analytics_callback_fn callback, + void* user_data) { + auto& state = get_callback_state(); + std::lock_guard lock(state.mutex); + + state.analytics_callback = callback; + state.analytics_user_data = user_data; + + return RAC_SUCCESS; +} + +rac_result_t rac_analytics_events_set_public_callback(rac_public_event_callback_fn callback, + void* user_data) { + auto& state = get_callback_state(); + std::lock_guard lock(state.mutex); + + state.public_callback = callback; + state.public_user_data = user_data; + + return RAC_SUCCESS; +} + +void rac_analytics_event_emit(rac_event_type_t type, const rac_analytics_event_data_t* data) { + if (data == nullptr) { + return; + } + + auto& state = get_callback_state(); + std::lock_guard lock(state.mutex); + + // Get the destination for this event type + rac_event_destination_t dest = rac_event_get_destination(type); + + // Route to analytics callback (telemetry) + if (dest == RAC_EVENT_DESTINATION_ANALYTICS_ONLY || dest == RAC_EVENT_DESTINATION_ALL) { + if (state.analytics_callback != nullptr) { + log_debug("Events", "Invoking analytics callback for event type %d", type); + state.analytics_callback(type, data, state.analytics_user_data); + } + } + + // Route to public callback (app developers) + if (dest == RAC_EVENT_DESTINATION_PUBLIC_ONLY || dest == RAC_EVENT_DESTINATION_ALL) { + if (state.public_callback != nullptr) { + state.public_callback(type, data, state.public_user_data); + } + } +} + +rac_bool_t rac_analytics_events_has_callback(void) { + auto& state = get_callback_state(); + std::lock_guard lock(state.mutex); + + return state.analytics_callback != nullptr ? RAC_TRUE : RAC_FALSE; +} + +rac_bool_t rac_analytics_events_has_public_callback(void) { + auto& state = get_callback_state(); + std::lock_guard lock(state.mutex); + + return state.public_callback != nullptr ? RAC_TRUE : RAC_FALSE; +} + +} // extern "C" + +// ============================================================================= +// HELPER FUNCTIONS FOR C++ COMPONENTS +// ============================================================================= + +namespace rac::events { + +void emit_llm_generation_started(const char* generation_id, const char* model_id, bool is_streaming, + rac_inference_framework_t framework, float temperature, + int32_t max_tokens, int32_t context_length) { + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_LLM_GENERATION_STARTED; + event.data.llm_generation = RAC_ANALYTICS_LLM_GENERATION_DEFAULT; + event.data.llm_generation.generation_id = generation_id; + event.data.llm_generation.model_id = model_id; + event.data.llm_generation.is_streaming = is_streaming ? RAC_TRUE : RAC_FALSE; + event.data.llm_generation.framework = framework; + event.data.llm_generation.temperature = temperature; + event.data.llm_generation.max_tokens = max_tokens; + event.data.llm_generation.context_length = context_length; + + rac_analytics_event_emit(RAC_EVENT_LLM_GENERATION_STARTED, &event); +} + +void emit_llm_generation_completed(const char* generation_id, const char* model_id, + int32_t input_tokens, int32_t output_tokens, double duration_ms, + double tokens_per_second, bool is_streaming, + double time_to_first_token_ms, + rac_inference_framework_t framework, float temperature, + int32_t max_tokens, int32_t context_length) { + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_LLM_GENERATION_COMPLETED; + event.data.llm_generation.generation_id = generation_id; + event.data.llm_generation.model_id = model_id; + event.data.llm_generation.input_tokens = input_tokens; + event.data.llm_generation.output_tokens = output_tokens; + event.data.llm_generation.duration_ms = duration_ms; + event.data.llm_generation.tokens_per_second = tokens_per_second; + event.data.llm_generation.is_streaming = is_streaming ? RAC_TRUE : RAC_FALSE; + event.data.llm_generation.time_to_first_token_ms = time_to_first_token_ms; + event.data.llm_generation.framework = framework; + event.data.llm_generation.temperature = temperature; + event.data.llm_generation.max_tokens = max_tokens; + event.data.llm_generation.context_length = context_length; + event.data.llm_generation.error_code = RAC_SUCCESS; + event.data.llm_generation.error_message = nullptr; + + rac_analytics_event_emit(RAC_EVENT_LLM_GENERATION_COMPLETED, &event); +} + +void emit_llm_generation_failed(const char* generation_id, const char* model_id, + rac_result_t error_code, const char* error_message) { + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_LLM_GENERATION_FAILED; + event.data.llm_generation = RAC_ANALYTICS_LLM_GENERATION_DEFAULT; + event.data.llm_generation.generation_id = generation_id; + event.data.llm_generation.model_id = model_id; + event.data.llm_generation.error_code = error_code; + event.data.llm_generation.error_message = error_message; + + rac_analytics_event_emit(RAC_EVENT_LLM_GENERATION_FAILED, &event); +} + +void emit_llm_first_token(const char* generation_id, const char* model_id, + double time_to_first_token_ms, rac_inference_framework_t framework) { + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_LLM_FIRST_TOKEN; + event.data.llm_generation = RAC_ANALYTICS_LLM_GENERATION_DEFAULT; + event.data.llm_generation.generation_id = generation_id; + event.data.llm_generation.model_id = model_id; + event.data.llm_generation.time_to_first_token_ms = time_to_first_token_ms; + event.data.llm_generation.framework = framework; + + rac_analytics_event_emit(RAC_EVENT_LLM_FIRST_TOKEN, &event); +} + +void emit_llm_streaming_update(const char* generation_id, int32_t tokens_generated) { + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_LLM_STREAMING_UPDATE; + event.data.llm_generation = RAC_ANALYTICS_LLM_GENERATION_DEFAULT; + event.data.llm_generation.generation_id = generation_id; + event.data.llm_generation.output_tokens = tokens_generated; + + rac_analytics_event_emit(RAC_EVENT_LLM_STREAMING_UPDATE, &event); +} + +void emit_stt_transcription_started(const char* transcription_id, const char* model_id, + double audio_length_ms, int32_t audio_size_bytes, + const char* language, bool is_streaming, int32_t sample_rate, + rac_inference_framework_t framework) { + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_STT_TRANSCRIPTION_STARTED; + event.data.stt_transcription = RAC_ANALYTICS_STT_TRANSCRIPTION_DEFAULT; + event.data.stt_transcription.transcription_id = transcription_id; + event.data.stt_transcription.model_id = model_id; + event.data.stt_transcription.audio_length_ms = audio_length_ms; + event.data.stt_transcription.audio_size_bytes = audio_size_bytes; + event.data.stt_transcription.language = language; + event.data.stt_transcription.is_streaming = is_streaming ? RAC_TRUE : RAC_FALSE; + event.data.stt_transcription.sample_rate = sample_rate; + event.data.stt_transcription.framework = framework; + + rac_analytics_event_emit(RAC_EVENT_STT_TRANSCRIPTION_STARTED, &event); +} + +void emit_stt_transcription_completed(const char* transcription_id, const char* model_id, + const char* text, float confidence, double duration_ms, + double audio_length_ms, int32_t audio_size_bytes, + int32_t word_count, double real_time_factor, + const char* language, int32_t sample_rate, + rac_inference_framework_t framework) { + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_STT_TRANSCRIPTION_COMPLETED; + event.data.stt_transcription.transcription_id = transcription_id; + event.data.stt_transcription.model_id = model_id; + event.data.stt_transcription.text = text; + event.data.stt_transcription.confidence = confidence; + event.data.stt_transcription.duration_ms = duration_ms; + event.data.stt_transcription.audio_length_ms = audio_length_ms; + event.data.stt_transcription.audio_size_bytes = audio_size_bytes; + event.data.stt_transcription.word_count = word_count; + event.data.stt_transcription.real_time_factor = real_time_factor; + event.data.stt_transcription.language = language; + event.data.stt_transcription.sample_rate = sample_rate; + event.data.stt_transcription.framework = framework; + event.data.stt_transcription.error_code = RAC_SUCCESS; + + rac_analytics_event_emit(RAC_EVENT_STT_TRANSCRIPTION_COMPLETED, &event); +} + +void emit_stt_transcription_failed(const char* transcription_id, const char* model_id, + rac_result_t error_code, const char* error_message) { + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_STT_TRANSCRIPTION_FAILED; + event.data.stt_transcription = RAC_ANALYTICS_STT_TRANSCRIPTION_DEFAULT; + event.data.stt_transcription.transcription_id = transcription_id; + event.data.stt_transcription.model_id = model_id; + event.data.stt_transcription.error_code = error_code; + event.data.stt_transcription.error_message = error_message; + + rac_analytics_event_emit(RAC_EVENT_STT_TRANSCRIPTION_FAILED, &event); +} + +void emit_tts_synthesis_started(const char* synthesis_id, const char* model_id, + int32_t character_count, int32_t sample_rate, + rac_inference_framework_t framework) { + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_TTS_SYNTHESIS_STARTED; + event.data.tts_synthesis = RAC_ANALYTICS_TTS_SYNTHESIS_DEFAULT; + event.data.tts_synthesis.synthesis_id = synthesis_id; + event.data.tts_synthesis.model_id = model_id; + event.data.tts_synthesis.character_count = character_count; + event.data.tts_synthesis.sample_rate = sample_rate; + event.data.tts_synthesis.framework = framework; + + rac_analytics_event_emit(RAC_EVENT_TTS_SYNTHESIS_STARTED, &event); +} + +void emit_tts_synthesis_completed(const char* synthesis_id, const char* model_id, + int32_t character_count, double audio_duration_ms, + int32_t audio_size_bytes, double processing_duration_ms, + double characters_per_second, int32_t sample_rate, + rac_inference_framework_t framework) { + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_TTS_SYNTHESIS_COMPLETED; + event.data.tts_synthesis.synthesis_id = synthesis_id; + event.data.tts_synthesis.model_id = model_id; + event.data.tts_synthesis.character_count = character_count; + event.data.tts_synthesis.audio_duration_ms = audio_duration_ms; + event.data.tts_synthesis.audio_size_bytes = audio_size_bytes; + event.data.tts_synthesis.processing_duration_ms = processing_duration_ms; + event.data.tts_synthesis.characters_per_second = characters_per_second; + event.data.tts_synthesis.sample_rate = sample_rate; + event.data.tts_synthesis.framework = framework; + event.data.tts_synthesis.error_code = RAC_SUCCESS; + + rac_analytics_event_emit(RAC_EVENT_TTS_SYNTHESIS_COMPLETED, &event); +} + +void emit_tts_synthesis_failed(const char* synthesis_id, const char* model_id, + rac_result_t error_code, const char* error_message) { + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_TTS_SYNTHESIS_FAILED; + event.data.tts_synthesis = RAC_ANALYTICS_TTS_SYNTHESIS_DEFAULT; + event.data.tts_synthesis.synthesis_id = synthesis_id; + event.data.tts_synthesis.model_id = model_id; + event.data.tts_synthesis.error_code = error_code; + event.data.tts_synthesis.error_message = error_message; + + rac_analytics_event_emit(RAC_EVENT_TTS_SYNTHESIS_FAILED, &event); +} + +void emit_vad_started() { + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_VAD_STARTED; + event.data.vad = RAC_ANALYTICS_VAD_DEFAULT; + + rac_analytics_event_emit(RAC_EVENT_VAD_STARTED, &event); +} + +void emit_vad_stopped() { + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_VAD_STOPPED; + event.data.vad = RAC_ANALYTICS_VAD_DEFAULT; + + rac_analytics_event_emit(RAC_EVENT_VAD_STOPPED, &event); +} + +void emit_vad_speech_started(float energy_level) { + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_VAD_SPEECH_STARTED; + event.data.vad.speech_duration_ms = 0.0; + event.data.vad.energy_level = energy_level; + + rac_analytics_event_emit(RAC_EVENT_VAD_SPEECH_STARTED, &event); +} + +void emit_vad_speech_ended(double speech_duration_ms, float energy_level) { + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_VAD_SPEECH_ENDED; + event.data.vad.speech_duration_ms = speech_duration_ms; + event.data.vad.energy_level = energy_level; + + rac_analytics_event_emit(RAC_EVENT_VAD_SPEECH_ENDED, &event); +} + +// ============================================================================= +// SDK LIFECYCLE EVENTS +// ============================================================================= + +void emit_sdk_init_started() { + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_SDK_INIT_STARTED; + event.data.sdk_lifecycle = RAC_ANALYTICS_SDK_LIFECYCLE_DEFAULT; + + rac_analytics_event_emit(RAC_EVENT_SDK_INIT_STARTED, &event); +} + +void emit_sdk_init_completed(double duration_ms) { + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_SDK_INIT_COMPLETED; + event.data.sdk_lifecycle = RAC_ANALYTICS_SDK_LIFECYCLE_DEFAULT; + event.data.sdk_lifecycle.duration_ms = duration_ms; + + rac_analytics_event_emit(RAC_EVENT_SDK_INIT_COMPLETED, &event); +} + +void emit_sdk_init_failed(rac_result_t error_code, const char* error_message) { + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_SDK_INIT_FAILED; + event.data.sdk_lifecycle = RAC_ANALYTICS_SDK_LIFECYCLE_DEFAULT; + event.data.sdk_lifecycle.error_code = error_code; + event.data.sdk_lifecycle.error_message = error_message; + + rac_analytics_event_emit(RAC_EVENT_SDK_INIT_FAILED, &event); +} + +void emit_sdk_models_loaded(int32_t count, double duration_ms) { + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_SDK_MODELS_LOADED; + event.data.sdk_lifecycle = RAC_ANALYTICS_SDK_LIFECYCLE_DEFAULT; + event.data.sdk_lifecycle.count = count; + event.data.sdk_lifecycle.duration_ms = duration_ms; + + rac_analytics_event_emit(RAC_EVENT_SDK_MODELS_LOADED, &event); +} + +// ============================================================================= +// MODEL DOWNLOAD EVENTS +// ============================================================================= + +void emit_model_download_started(const char* model_id, int64_t total_bytes, + const char* archive_type) { + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_MODEL_DOWNLOAD_STARTED; + event.data.model_download = RAC_ANALYTICS_MODEL_DOWNLOAD_DEFAULT; + event.data.model_download.model_id = model_id; + event.data.model_download.total_bytes = total_bytes; + event.data.model_download.archive_type = archive_type; + + rac_analytics_event_emit(RAC_EVENT_MODEL_DOWNLOAD_STARTED, &event); +} + +void emit_model_download_progress(const char* model_id, double progress, int64_t bytes_downloaded, + int64_t total_bytes) { + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_MODEL_DOWNLOAD_PROGRESS; + event.data.model_download = RAC_ANALYTICS_MODEL_DOWNLOAD_DEFAULT; + event.data.model_download.model_id = model_id; + event.data.model_download.progress = progress; + event.data.model_download.bytes_downloaded = bytes_downloaded; + event.data.model_download.total_bytes = total_bytes; + + rac_analytics_event_emit(RAC_EVENT_MODEL_DOWNLOAD_PROGRESS, &event); +} + +void emit_model_download_completed(const char* model_id, int64_t size_bytes, double duration_ms, + const char* archive_type) { + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_MODEL_DOWNLOAD_COMPLETED; + event.data.model_download = RAC_ANALYTICS_MODEL_DOWNLOAD_DEFAULT; + event.data.model_download.model_id = model_id; + event.data.model_download.size_bytes = size_bytes; + event.data.model_download.duration_ms = duration_ms; + event.data.model_download.archive_type = archive_type; + event.data.model_download.progress = 100.0; + + rac_analytics_event_emit(RAC_EVENT_MODEL_DOWNLOAD_COMPLETED, &event); +} + +void emit_model_download_failed(const char* model_id, rac_result_t error_code, + const char* error_message) { + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_MODEL_DOWNLOAD_FAILED; + event.data.model_download = RAC_ANALYTICS_MODEL_DOWNLOAD_DEFAULT; + event.data.model_download.model_id = model_id; + event.data.model_download.error_code = error_code; + event.data.model_download.error_message = error_message; + + rac_analytics_event_emit(RAC_EVENT_MODEL_DOWNLOAD_FAILED, &event); +} + +void emit_model_download_cancelled(const char* model_id) { + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_MODEL_DOWNLOAD_CANCELLED; + event.data.model_download = RAC_ANALYTICS_MODEL_DOWNLOAD_DEFAULT; + event.data.model_download.model_id = model_id; + + rac_analytics_event_emit(RAC_EVENT_MODEL_DOWNLOAD_CANCELLED, &event); +} + +// ============================================================================= +// MODEL EXTRACTION EVENTS +// ============================================================================= + +void emit_model_extraction_started(const char* model_id, const char* archive_type) { + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_MODEL_EXTRACTION_STARTED; + event.data.model_download = RAC_ANALYTICS_MODEL_DOWNLOAD_DEFAULT; + event.data.model_download.model_id = model_id; + event.data.model_download.archive_type = archive_type; + + rac_analytics_event_emit(RAC_EVENT_MODEL_EXTRACTION_STARTED, &event); +} + +void emit_model_extraction_progress(const char* model_id, double progress) { + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_MODEL_EXTRACTION_PROGRESS; + event.data.model_download = RAC_ANALYTICS_MODEL_DOWNLOAD_DEFAULT; + event.data.model_download.model_id = model_id; + event.data.model_download.progress = progress; + + rac_analytics_event_emit(RAC_EVENT_MODEL_EXTRACTION_PROGRESS, &event); +} + +void emit_model_extraction_completed(const char* model_id, int64_t size_bytes, double duration_ms) { + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_MODEL_EXTRACTION_COMPLETED; + event.data.model_download = RAC_ANALYTICS_MODEL_DOWNLOAD_DEFAULT; + event.data.model_download.model_id = model_id; + event.data.model_download.size_bytes = size_bytes; + event.data.model_download.duration_ms = duration_ms; + + rac_analytics_event_emit(RAC_EVENT_MODEL_EXTRACTION_COMPLETED, &event); +} + +void emit_model_extraction_failed(const char* model_id, rac_result_t error_code, + const char* error_message) { + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_MODEL_EXTRACTION_FAILED; + event.data.model_download = RAC_ANALYTICS_MODEL_DOWNLOAD_DEFAULT; + event.data.model_download.model_id = model_id; + event.data.model_download.error_code = error_code; + event.data.model_download.error_message = error_message; + + rac_analytics_event_emit(RAC_EVENT_MODEL_EXTRACTION_FAILED, &event); +} + +void emit_model_deleted(const char* model_id, int64_t size_bytes) { + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_MODEL_DELETED; + event.data.model_download = RAC_ANALYTICS_MODEL_DOWNLOAD_DEFAULT; + event.data.model_download.model_id = model_id; + event.data.model_download.size_bytes = size_bytes; + + rac_analytics_event_emit(RAC_EVENT_MODEL_DELETED, &event); +} + +// ============================================================================= +// STORAGE EVENTS +// ============================================================================= + +void emit_storage_cache_cleared(int64_t freed_bytes) { + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_STORAGE_CACHE_CLEARED; + event.data.storage = RAC_ANALYTICS_STORAGE_DEFAULT; + event.data.storage.freed_bytes = freed_bytes; + + rac_analytics_event_emit(RAC_EVENT_STORAGE_CACHE_CLEARED, &event); +} + +void emit_storage_cache_clear_failed(rac_result_t error_code, const char* error_message) { + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_STORAGE_CACHE_CLEAR_FAILED; + event.data.storage = RAC_ANALYTICS_STORAGE_DEFAULT; + event.data.storage.error_code = error_code; + event.data.storage.error_message = error_message; + + rac_analytics_event_emit(RAC_EVENT_STORAGE_CACHE_CLEAR_FAILED, &event); +} + +void emit_storage_temp_cleaned(int64_t freed_bytes) { + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_STORAGE_TEMP_CLEANED; + event.data.storage = RAC_ANALYTICS_STORAGE_DEFAULT; + event.data.storage.freed_bytes = freed_bytes; + + rac_analytics_event_emit(RAC_EVENT_STORAGE_TEMP_CLEANED, &event); +} + +// ============================================================================= +// DEVICE EVENTS +// ============================================================================= + +void emit_device_registered(const char* device_id) { + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_DEVICE_REGISTERED; + event.data.device = RAC_ANALYTICS_DEVICE_DEFAULT; + event.data.device.device_id = device_id; + + rac_analytics_event_emit(RAC_EVENT_DEVICE_REGISTERED, &event); +} + +void emit_device_registration_failed(rac_result_t error_code, const char* error_message) { + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_DEVICE_REGISTRATION_FAILED; + event.data.device = RAC_ANALYTICS_DEVICE_DEFAULT; + event.data.device.error_code = error_code; + event.data.device.error_message = error_message; + + rac_analytics_event_emit(RAC_EVENT_DEVICE_REGISTRATION_FAILED, &event); +} + +// ============================================================================= +// NETWORK EVENTS +// ============================================================================= + +void emit_network_connectivity_changed(bool is_online) { + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_NETWORK_CONNECTIVITY_CHANGED; + event.data.network = RAC_ANALYTICS_NETWORK_DEFAULT; + event.data.network.is_online = is_online ? RAC_TRUE : RAC_FALSE; + + rac_analytics_event_emit(RAC_EVENT_NETWORK_CONNECTIVITY_CHANGED, &event); +} + +// ============================================================================= +// SDK ERROR EVENTS +// ============================================================================= + +void emit_sdk_error(rac_result_t error_code, const char* error_message, const char* operation, + const char* context) { + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_SDK_ERROR; + event.data.sdk_error = RAC_ANALYTICS_SDK_ERROR_DEFAULT; + event.data.sdk_error.error_code = error_code; + event.data.sdk_error.error_message = error_message; + event.data.sdk_error.operation = operation; + event.data.sdk_error.context = context; + + rac_analytics_event_emit(RAC_EVENT_SDK_ERROR, &event); +} + +// ============================================================================= +// VOICE AGENT STATE EVENTS +// ============================================================================= + +void emit_voice_agent_stt_state_changed(rac_voice_agent_component_state_t state, + const char* model_id, const char* error_message) { + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_VOICE_AGENT_STT_STATE_CHANGED; + event.data.voice_agent_state.component = "stt"; + event.data.voice_agent_state.state = state; + event.data.voice_agent_state.model_id = model_id; + event.data.voice_agent_state.error_message = error_message; + + rac_analytics_event_emit(RAC_EVENT_VOICE_AGENT_STT_STATE_CHANGED, &event); +} + +void emit_voice_agent_llm_state_changed(rac_voice_agent_component_state_t state, + const char* model_id, const char* error_message) { + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_VOICE_AGENT_LLM_STATE_CHANGED; + event.data.voice_agent_state.component = "llm"; + event.data.voice_agent_state.state = state; + event.data.voice_agent_state.model_id = model_id; + event.data.voice_agent_state.error_message = error_message; + + rac_analytics_event_emit(RAC_EVENT_VOICE_AGENT_LLM_STATE_CHANGED, &event); +} + +void emit_voice_agent_tts_state_changed(rac_voice_agent_component_state_t state, + const char* model_id, const char* error_message) { + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_VOICE_AGENT_TTS_STATE_CHANGED; + event.data.voice_agent_state.component = "tts"; + event.data.voice_agent_state.state = state; + event.data.voice_agent_state.model_id = model_id; + event.data.voice_agent_state.error_message = error_message; + + rac_analytics_event_emit(RAC_EVENT_VOICE_AGENT_TTS_STATE_CHANGED, &event); +} + +void emit_voice_agent_all_ready() { + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_VOICE_AGENT_ALL_READY; + event.data.voice_agent_state.component = "all"; + event.data.voice_agent_state.state = RAC_VOICE_AGENT_STATE_LOADED; + event.data.voice_agent_state.model_id = nullptr; + event.data.voice_agent_state.error_message = nullptr; + + rac_analytics_event_emit(RAC_EVENT_VOICE_AGENT_ALL_READY, &event); +} + +} // namespace rac::events diff --git a/sdk/runanywhere-commons/src/core/rac_audio_utils.cpp b/sdk/runanywhere-commons/src/core/rac_audio_utils.cpp new file mode 100644 index 000000000..32823564a --- /dev/null +++ b/sdk/runanywhere-commons/src/core/rac_audio_utils.cpp @@ -0,0 +1,200 @@ +/** + * @file rac_audio_utils.cpp + * @brief RunAnywhere Commons - Audio Utility Functions Implementation + * + * Provides audio format conversion utilities used across the SDK. + */ + +#include "rac/core/rac_audio_utils.h" + +#include +#include +#include +#include + +#include "rac/core/rac_error.h" +#include "rac/core/rac_logger.h" + +// WAV file constants +static constexpr size_t WAV_HEADER_SIZE = 44; +static constexpr uint16_t WAV_FORMAT_PCM = 1; +static constexpr uint16_t WAV_CHANNELS_MONO = 1; +static constexpr uint16_t WAV_BITS_PER_SAMPLE_16 = 16; + +/** + * @brief Write a little-endian uint16_t to a buffer + */ +static void write_uint16_le(uint8_t* buffer, uint16_t value) { + buffer[0] = static_cast(value & 0xFF); + buffer[1] = static_cast((value >> 8) & 0xFF); +} + +/** + * @brief Write a little-endian uint32_t to a buffer + */ +static void write_uint32_le(uint8_t* buffer, uint32_t value) { + buffer[0] = static_cast(value & 0xFF); + buffer[1] = static_cast((value >> 8) & 0xFF); + buffer[2] = static_cast((value >> 16) & 0xFF); + buffer[3] = static_cast((value >> 24) & 0xFF); +} + +/** + * @brief Build a WAV header for PCM audio + * + * @param header Buffer to write header to (must be at least 44 bytes) + * @param sample_rate Sample rate in Hz + * @param data_size Size of audio data in bytes (Int16 samples) + */ +static void build_wav_header(uint8_t* header, int32_t sample_rate, uint32_t data_size) { + // RIFF header + // Bytes 0-3: "RIFF" + header[0] = 'R'; + header[1] = 'I'; + header[2] = 'F'; + header[3] = 'F'; + + // Bytes 4-7: File size minus 8 (RIFF header size) + uint32_t file_size = data_size + WAV_HEADER_SIZE - 8; + write_uint32_le(&header[4], file_size); + + // Bytes 8-11: "WAVE" + header[8] = 'W'; + header[9] = 'A'; + header[10] = 'V'; + header[11] = 'E'; + + // fmt chunk + // Bytes 12-15: "fmt " + header[12] = 'f'; + header[13] = 'm'; + header[14] = 't'; + header[15] = ' '; + + // Bytes 16-19: fmt chunk size (16 for PCM) + write_uint32_le(&header[16], 16); + + // Bytes 20-21: Audio format (1 = PCM) + write_uint16_le(&header[20], WAV_FORMAT_PCM); + + // Bytes 22-23: Number of channels (1 = mono) + write_uint16_le(&header[22], WAV_CHANNELS_MONO); + + // Bytes 24-27: Sample rate + write_uint32_le(&header[24], static_cast(sample_rate)); + + // Bytes 28-31: Byte rate = sample_rate * channels * bytes_per_sample + uint32_t byte_rate = + static_cast(sample_rate) * WAV_CHANNELS_MONO * (WAV_BITS_PER_SAMPLE_16 / 8); + write_uint32_le(&header[28], byte_rate); + + // Bytes 32-33: Block align = channels * bytes_per_sample + uint16_t block_align = WAV_CHANNELS_MONO * (WAV_BITS_PER_SAMPLE_16 / 8); + write_uint16_le(&header[32], block_align); + + // Bytes 34-35: Bits per sample + write_uint16_le(&header[34], WAV_BITS_PER_SAMPLE_16); + + // data chunk + // Bytes 36-39: "data" + header[36] = 'd'; + header[37] = 'a'; + header[38] = 't'; + header[39] = 'a'; + + // Bytes 40-43: Data size + write_uint32_le(&header[40], data_size); +} + +rac_result_t rac_audio_float32_to_wav(const void* pcm_data, size_t pcm_size, int32_t sample_rate, + void** out_wav_data, size_t* out_wav_size) { + // Validate arguments + if (!pcm_data || pcm_size == 0 || !out_wav_data || !out_wav_size) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + // Float32 is 4 bytes per sample + if (pcm_size % 4 != 0) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + if (sample_rate <= 0) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + const size_t num_samples = pcm_size / 4; + + // Int16 data size (2 bytes per sample) + const uint32_t int16_data_size = static_cast(num_samples * 2); + + // Total WAV file size + const size_t wav_size = WAV_HEADER_SIZE + int16_data_size; + + // Allocate output buffer + uint8_t* wav_data = static_cast(malloc(wav_size)); + if (!wav_data) { + return RAC_ERROR_OUT_OF_MEMORY; + } + + // Build WAV header + build_wav_header(wav_data, sample_rate, int16_data_size); + + // Convert Float32 to Int16 + const float* float_samples = static_cast(pcm_data); + int16_t* int16_samples = reinterpret_cast(wav_data + WAV_HEADER_SIZE); + + for (size_t i = 0; i < num_samples; ++i) { + // Clamp to [-1.0, 1.0] range + float sample = std::max(-1.0f, std::min(1.0f, float_samples[i])); + // Scale to Int16 range [-32768, 32767] + int16_samples[i] = static_cast(sample * 32767.0f); + } + + *out_wav_data = wav_data; + *out_wav_size = wav_size; + + return RAC_SUCCESS; +} + +rac_result_t rac_audio_int16_to_wav(const void* pcm_data, size_t pcm_size, int32_t sample_rate, + void** out_wav_data, size_t* out_wav_size) { + // Validate arguments + if (!pcm_data || pcm_size == 0 || !out_wav_data || !out_wav_size) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + // Int16 is 2 bytes per sample + if (pcm_size % 2 != 0) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + if (sample_rate <= 0) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + const uint32_t data_size = static_cast(pcm_size); + + // Total WAV file size + const size_t wav_size = WAV_HEADER_SIZE + data_size; + + // Allocate output buffer + uint8_t* wav_data = static_cast(malloc(wav_size)); + if (!wav_data) { + return RAC_ERROR_OUT_OF_MEMORY; + } + + // Build WAV header + build_wav_header(wav_data, sample_rate, data_size); + + // Copy Int16 data directly + memcpy(wav_data + WAV_HEADER_SIZE, pcm_data, pcm_size); + + *out_wav_data = wav_data; + *out_wav_size = wav_size; + + return RAC_SUCCESS; +} + +size_t rac_audio_wav_header_size(void) { + return WAV_HEADER_SIZE; +} diff --git a/sdk/runanywhere-commons/src/core/rac_benchmark.cpp b/sdk/runanywhere-commons/src/core/rac_benchmark.cpp new file mode 100644 index 000000000..44f5840e1 --- /dev/null +++ b/sdk/runanywhere-commons/src/core/rac_benchmark.cpp @@ -0,0 +1,55 @@ +/** + * @file rac_benchmark.cpp + * @brief RunAnywhere Commons - Benchmark Timing Implementation + * + * Implements monotonic time helper and benchmark timing utilities. + * Uses std::chrono::steady_clock for accurate, cross-platform timing + * that is not affected by system clock adjustments. + */ + +#include "rac/core/rac_benchmark.h" + +#include +#include + +namespace { + +/** + * Process-local epoch for monotonic timing. + * Initialized on first call to rac_monotonic_now_ms(). + * Using a local epoch keeps timestamp values small and manageable. + */ +class MonotonicEpoch { + public: + static MonotonicEpoch& instance() { + static MonotonicEpoch epoch; + return epoch; + } + + int64_t elapsed_ms() const { + auto now = std::chrono::steady_clock::now(); + auto duration = now - epoch_; + return std::chrono::duration_cast(duration).count(); + } + + private: + MonotonicEpoch() : epoch_(std::chrono::steady_clock::now()) {} + + std::chrono::steady_clock::time_point epoch_; +}; + +} // namespace + +extern "C" { + +int64_t rac_monotonic_now_ms(void) { + return MonotonicEpoch::instance().elapsed_ms(); +} + +void rac_benchmark_timing_init(rac_benchmark_timing_t* timing) { + if (timing != nullptr) { + std::memset(timing, 0, sizeof(rac_benchmark_timing_t)); + } +} + +} // extern "C" diff --git a/sdk/runanywhere-commons/src/core/rac_benchmark_log.cpp b/sdk/runanywhere-commons/src/core/rac_benchmark_log.cpp new file mode 100644 index 000000000..60903206d --- /dev/null +++ b/sdk/runanywhere-commons/src/core/rac_benchmark_log.cpp @@ -0,0 +1,156 @@ +/** + * @file rac_benchmark_log.cpp + * @brief RunAnywhere Commons - Benchmark Logging Implementation + * + * Serializes benchmark timing data to JSON and CSV formats, + * and provides a convenience function to log via the RAC logging system. + */ + +#include "rac/core/rac_benchmark_log.h" + +#include +#include +#include +#include +#include + +#include "rac/core/rac_logger.h" + +namespace { + +/** + * Computes a derived metric (difference) safely. + * Returns 0.0 if either timestamp is 0 (not captured). + */ +double safe_diff(int64_t end_ms, int64_t start_ms) { + if (end_ms <= 0 || start_ms <= 0) { + return 0.0; + } + return static_cast(end_ms - start_ms); +} + +/** + * Computes decode throughput in tokens/second. + * Returns 0.0 if decode time is 0 or output_tokens is 0. + */ +double decode_tps(const rac_benchmark_timing_t* t) { + double decode_ms = safe_diff(t->t5_last_token_ms, t->t3_prefill_end_ms); + if (decode_ms <= 0.0 || t->output_tokens <= 0) { + return 0.0; + } + return static_cast(t->output_tokens) / decode_ms * 1000.0; +} + +} // namespace + +extern "C" { + +char* rac_benchmark_timing_to_json(const rac_benchmark_timing_t* timing) { + if (timing == nullptr) { + return nullptr; + } + + double ttft_ms = safe_diff(timing->t4_first_token_ms, timing->t0_request_start_ms); + double prefill_ms = safe_diff(timing->t3_prefill_end_ms, timing->t2_prefill_start_ms); + double decode_ms_val = safe_diff(timing->t5_last_token_ms, timing->t3_prefill_end_ms); + double e2e_ms = safe_diff(timing->t6_request_end_ms, timing->t0_request_start_ms); + double tps = decode_tps(timing); + + // Build JSON string + std::string json; + json.reserve(512); + json += "{"; + json += "\"t0_request_start_ms\":" + std::to_string(timing->t0_request_start_ms) + ","; + json += "\"t2_prefill_start_ms\":" + std::to_string(timing->t2_prefill_start_ms) + ","; + json += "\"t3_prefill_end_ms\":" + std::to_string(timing->t3_prefill_end_ms) + ","; + json += "\"t4_first_token_ms\":" + std::to_string(timing->t4_first_token_ms) + ","; + json += "\"t5_last_token_ms\":" + std::to_string(timing->t5_last_token_ms) + ","; + json += "\"t6_request_end_ms\":" + std::to_string(timing->t6_request_end_ms) + ","; + json += "\"prompt_tokens\":" + std::to_string(timing->prompt_tokens) + ","; + json += "\"output_tokens\":" + std::to_string(timing->output_tokens) + ","; + json += "\"status\":" + std::to_string(timing->status) + ","; + json += "\"error_code\":" + std::to_string(timing->error_code) + ","; + + // Derived metrics + char buf[64]; + snprintf(buf, sizeof(buf), "%.2f", ttft_ms); + json += "\"ttft_ms\":" + std::string(buf) + ","; + snprintf(buf, sizeof(buf), "%.2f", prefill_ms); + json += "\"prefill_ms\":" + std::string(buf) + ","; + snprintf(buf, sizeof(buf), "%.2f", decode_ms_val); + json += "\"decode_ms\":" + std::string(buf) + ","; + snprintf(buf, sizeof(buf), "%.2f", e2e_ms); + json += "\"e2e_ms\":" + std::string(buf) + ","; + snprintf(buf, sizeof(buf), "%.2f", tps); + json += "\"decode_tps\":" + std::string(buf); + + json += "}"; + + // Copy to heap-allocated C string + char* result = static_cast(malloc(json.size() + 1)); + if (result != nullptr) { + memcpy(result, json.c_str(), json.size() + 1); + } + return result; +} + +char* rac_benchmark_timing_to_csv(const rac_benchmark_timing_t* timing, rac_bool_t header) { + std::string csv; + csv.reserve(256); + + if (header) { + csv = "t0_request_start_ms,t2_prefill_start_ms,t3_prefill_end_ms," + "t4_first_token_ms,t5_last_token_ms,t6_request_end_ms," + "prompt_tokens,output_tokens,status,error_code," + "ttft_ms,prefill_ms,decode_ms,e2e_ms,decode_tps"; + } else { + if (timing == nullptr) { + return nullptr; + } + + double ttft_ms = safe_diff(timing->t4_first_token_ms, timing->t0_request_start_ms); + double prefill_ms = safe_diff(timing->t3_prefill_end_ms, timing->t2_prefill_start_ms); + double decode_ms_val = safe_diff(timing->t5_last_token_ms, timing->t3_prefill_end_ms); + double e2e_ms = safe_diff(timing->t6_request_end_ms, timing->t0_request_start_ms); + double tps = decode_tps(timing); + + char buf[512]; + snprintf(buf, sizeof(buf), + "%" PRId64 ",%" PRId64 ",%" PRId64 ",%" PRId64 ",%" PRId64 ",%" PRId64 + ",%" PRId32 ",%" PRId32 ",%" PRId32 ",%" PRId32 ",%.2f,%.2f,%.2f,%.2f,%.2f", + timing->t0_request_start_ms, timing->t2_prefill_start_ms, + timing->t3_prefill_end_ms, timing->t4_first_token_ms, timing->t5_last_token_ms, + timing->t6_request_end_ms, timing->prompt_tokens, timing->output_tokens, + timing->status, timing->error_code, ttft_ms, prefill_ms, decode_ms_val, e2e_ms, + tps); + csv = buf; + } + + char* result = static_cast(malloc(csv.size() + 1)); + if (result != nullptr) { + memcpy(result, csv.c_str(), csv.size() + 1); + } + return result; +} + +void rac_benchmark_timing_log(const rac_benchmark_timing_t* timing, const char* label) { + if (timing == nullptr) { + return; + } + + double ttft_ms = safe_diff(timing->t4_first_token_ms, timing->t0_request_start_ms); + double prefill_ms = safe_diff(timing->t3_prefill_end_ms, timing->t2_prefill_start_ms); + double decode_ms_val = safe_diff(timing->t5_last_token_ms, timing->t3_prefill_end_ms); + double e2e_ms = safe_diff(timing->t6_request_end_ms, timing->t0_request_start_ms); + double tps = decode_tps(timing); + + const char* tag = (label != nullptr) ? label : "run"; + + RAC_LOG_INFO("Benchmark", + "[%s] TTFT=%.1fms prefill=%.1fms decode=%.1fms E2E=%.1fms " + "prompt=%d output=%d tps=%.1f status=%d error=%d", + tag, ttft_ms, prefill_ms, decode_ms_val, e2e_ms, timing->prompt_tokens, + timing->output_tokens, tps, timing->status, timing->error_code); +} + +} // extern "C" diff --git a/sdk/runanywhere-commons/src/core/rac_benchmark_metrics.cpp b/sdk/runanywhere-commons/src/core/rac_benchmark_metrics.cpp new file mode 100644 index 000000000..c0a4dc147 --- /dev/null +++ b/sdk/runanywhere-commons/src/core/rac_benchmark_metrics.cpp @@ -0,0 +1,81 @@ +/** + * @file rac_benchmark_metrics.cpp + * @brief RunAnywhere Commons - Extended Benchmark Metrics Implementation + * + * Implements the metrics provider registry. Platform SDKs (iOS/Android) + * register a provider callback during initialization. The commons layer + * calls rac_benchmark_capture_metrics() at t0 and t6 to snapshot device state. + */ + +#include "rac/core/rac_benchmark_metrics.h" + +#include +#include +#include + +namespace { + +struct MetricsProvider { + rac_benchmark_metrics_provider_fn fn = nullptr; + void* user_data = nullptr; +}; + +// Atomic pointer for lock-free provider access. +// Provider registration is rare; reads are frequent. +std::atomic g_provider{nullptr}; + +// Storage for the current provider (swapped atomically) +MetricsProvider g_provider_storage[2]; +std::atomic g_provider_index{0}; + +} // namespace + +extern "C" { + +void rac_benchmark_extended_metrics_init(rac_benchmark_extended_metrics_t* metrics) { + if (metrics == nullptr) { + return; + } + metrics->memory_usage_bytes = -1; + metrics->memory_peak_bytes = -1; + metrics->cpu_temperature_celsius = -1.0f; + metrics->battery_level = -1.0f; + metrics->gpu_utilization_percent = -1.0f; + metrics->thermal_state = -1; +} + +void rac_benchmark_set_metrics_provider(rac_benchmark_metrics_provider_fn provider, + void* user_data) { + static std::mutex write_mutex; + + if (provider == nullptr) { + g_provider.store(nullptr, std::memory_order_release); + return; + } + + // Serialize the rare registration path to prevent torn fn/user_data pairs + std::lock_guard lock(write_mutex); + int idx = g_provider_index.load(std::memory_order_relaxed); + int next = 1 - idx; + g_provider_storage[next].fn = provider; + g_provider_storage[next].user_data = user_data; + g_provider.store(&g_provider_storage[next], std::memory_order_release); + g_provider_index.store(next, std::memory_order_relaxed); +} + +void rac_benchmark_capture_metrics(rac_benchmark_extended_metrics_t* out) { + if (out == nullptr) { + return; + } + + // Initialize to unavailable + rac_benchmark_extended_metrics_init(out); + + // Call provider if registered + MetricsProvider* provider = g_provider.load(std::memory_order_acquire); + if (provider != nullptr && provider->fn != nullptr) { + provider->fn(out, provider->user_data); + } +} + +} // extern "C" diff --git a/sdk/runanywhere-commons/src/core/rac_benchmark_stats.cpp b/sdk/runanywhere-commons/src/core/rac_benchmark_stats.cpp new file mode 100644 index 000000000..b325cbbb0 --- /dev/null +++ b/sdk/runanywhere-commons/src/core/rac_benchmark_stats.cpp @@ -0,0 +1,324 @@ +/** + * @file rac_benchmark_stats.cpp + * @brief RunAnywhere Commons - Benchmark Statistical Analysis Implementation + * + * Collects derived metrics from timing observations and computes + * percentiles, mean, stddev, and outlier counts. + */ + +#include "rac/core/rac_benchmark_stats.h" + +#include "rac/core/rac_error.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +/** + * Internal stats collector. + * Stores vectors of derived metrics extracted from timing observations. + */ +class BenchmarkStatsCollector { + public: + void record(const rac_benchmark_timing_t* timing) { + if (timing == nullptr) { + return; + } + + // Only record successful observations + if (timing->status != RAC_BENCHMARK_STATUS_SUCCESS) { + return; + } + + std::lock_guard lock(mutex_); + + // TTFT: t4 - t0 + if (timing->t4_first_token_ms > 0 && timing->t0_request_start_ms > 0) { + ttft_values_.push_back( + static_cast(timing->t4_first_token_ms - timing->t0_request_start_ms)); + } + + // Prefill: t3 - t2 + if (timing->t3_prefill_end_ms > 0 && timing->t2_prefill_start_ms > 0) { + prefill_values_.push_back( + static_cast(timing->t3_prefill_end_ms - timing->t2_prefill_start_ms)); + } + + // Decode TPS: output_tokens / (t5 - t3) * 1000 + if (timing->t5_last_token_ms > 0 && timing->t3_prefill_end_ms > 0 && + timing->output_tokens > 0) { + double decode_ms = + static_cast(timing->t5_last_token_ms - timing->t3_prefill_end_ms); + if (decode_ms > 0.0) { + decode_tps_values_.push_back( + static_cast(timing->output_tokens) / decode_ms * 1000.0); + } + } + + // E2E: t6 - t0 + if (timing->t6_request_end_ms > 0 && timing->t0_request_start_ms > 0) { + e2e_values_.push_back( + static_cast(timing->t6_request_end_ms - timing->t0_request_start_ms)); + } + + count_++; + } + + void reset() { + std::lock_guard lock(mutex_); + ttft_values_.clear(); + prefill_values_.clear(); + decode_tps_values_.clear(); + e2e_values_.clear(); + count_ = 0; + } + + int32_t count() const { + std::lock_guard lock(mutex_); + return count_; + } + + rac_result_t get_summary(rac_benchmark_summary_t* out) { + if (out == nullptr) { + return RAC_ERROR_NULL_POINTER; + } + + std::lock_guard lock(mutex_); + std::memset(out, 0, sizeof(rac_benchmark_summary_t)); + + if (count_ == 0) { + return RAC_ERROR_INVALID_STATE; + } + + out->count = count_; + + // TTFT stats + if (!ttft_values_.empty()) { + auto sorted = ttft_values_; + std::sort(sorted.begin(), sorted.end()); + out->ttft_p50_ms = percentile(sorted, 50); + out->ttft_p95_ms = percentile(sorted, 95); + out->ttft_p99_ms = percentile(sorted, 99); + out->ttft_min_ms = sorted.front(); + out->ttft_max_ms = sorted.back(); + out->ttft_mean_ms = mean(sorted); + out->ttft_stddev_ms = stddev(sorted, out->ttft_mean_ms); + } + + // Prefill stats + if (!prefill_values_.empty()) { + auto sorted = prefill_values_; + std::sort(sorted.begin(), sorted.end()); + out->prefill_p50_ms = percentile(sorted, 50); + out->prefill_p95_ms = percentile(sorted, 95); + out->prefill_p99_ms = percentile(sorted, 99); + } + + // Decode TPS stats + if (!decode_tps_values_.empty()) { + auto sorted = decode_tps_values_; + std::sort(sorted.begin(), sorted.end()); + out->decode_tps_p50 = percentile(sorted, 50); + out->decode_tps_p95 = percentile(sorted, 95); + out->decode_tps_p99 = percentile(sorted, 99); + } + + // E2E stats + outlier detection + if (!e2e_values_.empty()) { + auto sorted = e2e_values_; + std::sort(sorted.begin(), sorted.end()); + out->e2e_p50_ms = percentile(sorted, 50); + out->e2e_p95_ms = percentile(sorted, 95); + out->e2e_p99_ms = percentile(sorted, 99); + + // Outlier detection: count observations > mean + 2*stddev + double e2e_mean = mean(sorted); + double e2e_sd = stddev(sorted, e2e_mean); + double threshold = e2e_mean + 2.0 * e2e_sd; + int32_t outliers = 0; + for (double val : e2e_values_) { + if (val > threshold) { + outliers++; + } + } + out->outlier_count = outliers; + } + + return RAC_SUCCESS; + } + + private: + /** + * Nearest-rank percentile calculation. + * Assumes sorted is non-empty and sorted in ascending order. + */ + static double percentile(const std::vector& sorted, int p) { + size_t n = sorted.size(); + if (n == 1) { + return sorted[0]; + } + size_t rank = static_cast(std::ceil(static_cast(p) / 100.0 * n)); + if (rank == 0) { + rank = 1; + } + if (rank > n) { + rank = n; + } + return sorted[rank - 1]; + } + + static double mean(const std::vector& values) { + double sum = 0.0; + for (double v : values) { + sum += v; + } + return sum / static_cast(values.size()); + } + + static double stddev(const std::vector& values, double mean_val) { + if (values.size() <= 1) { + return 0.0; + } + double sum_sq = 0.0; + for (double v : values) { + double diff = v - mean_val; + sum_sq += diff * diff; + } + return std::sqrt(sum_sq / static_cast(values.size())); + } + + mutable std::mutex mutex_; + std::vector ttft_values_; + std::vector prefill_values_; + std::vector decode_tps_values_; + std::vector e2e_values_; + int32_t count_ = 0; +}; + +} // namespace + +extern "C" { + +rac_result_t rac_benchmark_stats_create(rac_benchmark_stats_handle_t* out_handle) { + if (out_handle == nullptr) { + return RAC_ERROR_NULL_POINTER; + } + + auto* collector = new (std::nothrow) BenchmarkStatsCollector(); + if (collector == nullptr) { + return RAC_ERROR_INITIALIZATION_FAILED; + } + + *out_handle = static_cast(collector); + return RAC_SUCCESS; +} + +void rac_benchmark_stats_destroy(rac_benchmark_stats_handle_t handle) { + if (handle == nullptr) { + return; + } + delete static_cast(handle); +} + +void rac_benchmark_stats_record(rac_benchmark_stats_handle_t handle, + const rac_benchmark_timing_t* timing) { + if (handle == nullptr || timing == nullptr) { + return; + } + static_cast(handle)->record(timing); +} + +void rac_benchmark_stats_reset(rac_benchmark_stats_handle_t handle) { + if (handle == nullptr) { + return; + } + static_cast(handle)->reset(); +} + +int32_t rac_benchmark_stats_count(rac_benchmark_stats_handle_t handle) { + if (handle == nullptr) { + return 0; + } + return static_cast(handle)->count(); +} + +rac_result_t rac_benchmark_stats_get_summary(rac_benchmark_stats_handle_t handle, + rac_benchmark_summary_t* out_summary) { + if (handle == nullptr || out_summary == nullptr) { + return RAC_ERROR_NULL_POINTER; + } + return static_cast(handle)->get_summary(out_summary); +} + +char* rac_benchmark_stats_summary_to_json(const rac_benchmark_summary_t* summary) { + if (summary == nullptr) { + return nullptr; + } + + std::string json; + json.reserve(1024); + + char buf[64]; + + json += "{"; + json += "\"count\":" + std::to_string(summary->count) + ","; + + // TTFT + snprintf(buf, sizeof(buf), "%.2f", summary->ttft_p50_ms); + json += "\"ttft_p50_ms\":" + std::string(buf) + ","; + snprintf(buf, sizeof(buf), "%.2f", summary->ttft_p95_ms); + json += "\"ttft_p95_ms\":" + std::string(buf) + ","; + snprintf(buf, sizeof(buf), "%.2f", summary->ttft_p99_ms); + json += "\"ttft_p99_ms\":" + std::string(buf) + ","; + snprintf(buf, sizeof(buf), "%.2f", summary->ttft_min_ms); + json += "\"ttft_min_ms\":" + std::string(buf) + ","; + snprintf(buf, sizeof(buf), "%.2f", summary->ttft_max_ms); + json += "\"ttft_max_ms\":" + std::string(buf) + ","; + snprintf(buf, sizeof(buf), "%.2f", summary->ttft_mean_ms); + json += "\"ttft_mean_ms\":" + std::string(buf) + ","; + snprintf(buf, sizeof(buf), "%.2f", summary->ttft_stddev_ms); + json += "\"ttft_stddev_ms\":" + std::string(buf) + ","; + + // Prefill + snprintf(buf, sizeof(buf), "%.2f", summary->prefill_p50_ms); + json += "\"prefill_p50_ms\":" + std::string(buf) + ","; + snprintf(buf, sizeof(buf), "%.2f", summary->prefill_p95_ms); + json += "\"prefill_p95_ms\":" + std::string(buf) + ","; + snprintf(buf, sizeof(buf), "%.2f", summary->prefill_p99_ms); + json += "\"prefill_p99_ms\":" + std::string(buf) + ","; + + // Decode TPS + snprintf(buf, sizeof(buf), "%.2f", summary->decode_tps_p50); + json += "\"decode_tps_p50\":" + std::string(buf) + ","; + snprintf(buf, sizeof(buf), "%.2f", summary->decode_tps_p95); + json += "\"decode_tps_p95\":" + std::string(buf) + ","; + snprintf(buf, sizeof(buf), "%.2f", summary->decode_tps_p99); + json += "\"decode_tps_p99\":" + std::string(buf) + ","; + + // E2E + snprintf(buf, sizeof(buf), "%.2f", summary->e2e_p50_ms); + json += "\"e2e_p50_ms\":" + std::string(buf) + ","; + snprintf(buf, sizeof(buf), "%.2f", summary->e2e_p95_ms); + json += "\"e2e_p95_ms\":" + std::string(buf) + ","; + snprintf(buf, sizeof(buf), "%.2f", summary->e2e_p99_ms); + json += "\"e2e_p99_ms\":" + std::string(buf) + ","; + + // Outliers + json += "\"outlier_count\":" + std::to_string(summary->outlier_count); + + json += "}"; + + char* result = static_cast(malloc(json.size() + 1)); + if (result != nullptr) { + memcpy(result, json.c_str(), json.size() + 1); + } + return result; +} + +} // extern "C" diff --git a/sdk/runanywhere-commons/src/core/rac_core.cpp b/sdk/runanywhere-commons/src/core/rac_core.cpp new file mode 100644 index 000000000..09c5c486a --- /dev/null +++ b/sdk/runanywhere-commons/src/core/rac_core.cpp @@ -0,0 +1,270 @@ +/** + * @file rac_core.cpp + * @brief RunAnywhere Commons - Core Initialization + * + * Migrated from Swift SDK initialization patterns. + */ + +#include "rac/core/rac_core.h" + +#include +#include +#include + +#include "rac/core/rac_error.h" +#include "rac/core/rac_logger.h" +#include "rac/core/rac_platform_adapter.h" +#include "rac/core/rac_structured_error.h" +#include "rac/infrastructure/device/rac_device_manager.h" +#include "rac/infrastructure/model_management/rac_model_registry.h" + +// ============================================================================= +// STATIC STATE +// ============================================================================= + +static std::atomic s_initialized{false}; +static std::mutex s_init_mutex; +static const rac_platform_adapter_t* s_platform_adapter = nullptr; +static rac_log_level_t s_log_level = RAC_LOG_INFO; +static std::string s_log_tag = "RAC"; + +// Global model registry +static rac_model_registry_handle_t s_model_registry = nullptr; +static std::mutex s_model_registry_mutex; + +// Version info +static const char* s_version_string = "1.0.0"; +static const rac_version_t s_version = { + .major = 1, .minor = 0, .patch = 0, .string = s_version_string}; + +// ============================================================================= +// INTERNAL LOGGING HELPER +// ============================================================================= + +static void internal_log(rac_log_level_t level, const char* message) { + if (level < s_log_level) { + return; + } + + if (s_platform_adapter != nullptr && s_platform_adapter->log != nullptr) { + s_platform_adapter->log(level, s_log_tag.c_str(), message, s_platform_adapter->user_data); + } +} + +// ============================================================================= +// PLATFORM ADAPTER +// ============================================================================= + +extern "C" { + +rac_result_t rac_set_platform_adapter(const rac_platform_adapter_t* adapter) { + if (adapter == nullptr) { + return RAC_ERROR_NULL_POINTER; + } + s_platform_adapter = adapter; + return RAC_SUCCESS; +} + +const rac_platform_adapter_t* rac_get_platform_adapter(void) { + return s_platform_adapter; +} + +void rac_log(rac_log_level_t level, const char* category, const char* message) { + if (s_platform_adapter != nullptr && s_platform_adapter->log != nullptr) { + s_platform_adapter->log(level, category, message, s_platform_adapter->user_data); + } +} + +// ============================================================================= +// INITIALIZATION API +// ============================================================================= + +rac_result_t rac_init(const rac_config_t* config) { + std::lock_guard lock(s_init_mutex); + + if (s_initialized.load()) { + return RAC_ERROR_ALREADY_INITIALIZED; + } + + if (config == nullptr) { + return RAC_ERROR_NULL_POINTER; + } + + if (config->platform_adapter == nullptr) { + rac_error_set_details("Platform adapter is required for initialization"); + return RAC_ERROR_ADAPTER_NOT_SET; + } + + // Store configuration + s_platform_adapter = config->platform_adapter; + s_log_level = config->log_level; + if (config->log_tag != nullptr) { + s_log_tag = config->log_tag; + } + + s_initialized.store(true); + + internal_log(RAC_LOG_INFO, "RunAnywhere Commons initialized"); + + return RAC_SUCCESS; +} + +void rac_shutdown(void) { + std::lock_guard lock(s_init_mutex); + + if (!s_initialized.load()) { + return; + } + + internal_log(RAC_LOG_INFO, "RunAnywhere Commons shutting down"); + + // Clear state + s_platform_adapter = nullptr; + s_log_level = RAC_LOG_INFO; + s_log_tag = "RAC"; + s_initialized.store(false); +} + +rac_bool_t rac_is_initialized(void) { + // Force link device manager symbols by referencing the function + // This ensures the device manager object file is included in the archive + (void)&rac_device_manager_is_registered; + + return s_initialized.load() ? RAC_TRUE : RAC_FALSE; +} + +rac_version_t rac_get_version(void) { + return s_version; +} + +rac_result_t rac_configure_logging(rac_environment_t environment) { + switch (environment) { + case RAC_ENV_DEVELOPMENT: + // Debug mode: print to C++ stderr + send to Swift + rac_logger_set_stderr_always(RAC_TRUE); + rac_logger_set_min_level(RAC_LOG_DEBUG); + RAC_LOG_INFO("RAC.Core", "Logging configured for development: stderr ON, level=DEBUG"); + break; + + case RAC_ENV_STAGING: + // Staging: print to C++ stderr + send to Swift + rac_logger_set_stderr_always(RAC_TRUE); + rac_logger_set_min_level(RAC_LOG_INFO); + RAC_LOG_INFO("RAC.Core", "Logging configured for staging: stderr ON, level=INFO"); + break; + + case RAC_ENV_PRODUCTION: + default: + // Production: NO C++ stderr, only send to Swift bridge + // Swift handles local console and Sentry routing + rac_logger_set_stderr_always(RAC_FALSE); + rac_logger_set_min_level(RAC_LOG_WARNING); + // Note: This log will only go to Swift, not stderr + RAC_LOG_INFO("RAC.Core", + "Logging configured for production: stderr OFF, level=WARNING"); + break; + } + + return RAC_SUCCESS; +} + +// ============================================================================= +// HTTP DOWNLOAD CONVENIENCE FUNCTIONS +// ============================================================================= + +rac_result_t rac_http_download(const char* url, const char* destination_path, + rac_http_progress_callback_fn progress_callback, + rac_http_complete_callback_fn complete_callback, + void* callback_user_data, char** out_task_id) { + if (s_platform_adapter == nullptr) { + return RAC_ERROR_ADAPTER_NOT_SET; + } + + if (s_platform_adapter->http_download == nullptr) { + return RAC_ERROR_NOT_SUPPORTED; + } + + return s_platform_adapter->http_download(url, destination_path, progress_callback, + complete_callback, callback_user_data, out_task_id, + s_platform_adapter->user_data); +} + +rac_result_t rac_http_download_cancel(const char* task_id) { + if (s_platform_adapter == nullptr) { + return RAC_ERROR_ADAPTER_NOT_SET; + } + + if (s_platform_adapter->http_download_cancel == nullptr) { + return RAC_ERROR_NOT_SUPPORTED; + } + + return s_platform_adapter->http_download_cancel(task_id, s_platform_adapter->user_data); +} + +// ============================================================================= +// ARCHIVE EXTRACTION CONVENIENCE FUNCTION +// ============================================================================= + +rac_result_t rac_extract_archive(const char* archive_path, const char* destination_dir, + rac_extract_progress_callback_fn progress_callback, + void* callback_user_data) { + if (s_platform_adapter == nullptr) { + return RAC_ERROR_ADAPTER_NOT_SET; + } + + if (s_platform_adapter->extract_archive == nullptr) { + return RAC_ERROR_NOT_SUPPORTED; + } + + return s_platform_adapter->extract_archive(archive_path, destination_dir, progress_callback, + callback_user_data, s_platform_adapter->user_data); +} + +// ============================================================================= +// GLOBAL MODEL REGISTRY +// ============================================================================= + +rac_model_registry_handle_t rac_get_model_registry(void) { + std::lock_guard lock(s_model_registry_mutex); + + if (s_model_registry == nullptr) { + rac_result_t result = rac_model_registry_create(&s_model_registry); + if (result != RAC_SUCCESS) { + RAC_LOG_ERROR("RAC.Core", "Failed to create global model registry"); + return nullptr; + } + RAC_LOG_INFO("RAC.Core", "Global model registry created"); + } + + return s_model_registry; +} + +rac_result_t rac_register_model(const rac_model_info_t* model) { + rac_model_registry_handle_t registry = rac_get_model_registry(); + if (registry == nullptr) { + return RAC_ERROR_NOT_INITIALIZED; + } + return rac_model_registry_save(registry, model); +} + +rac_result_t rac_get_model(const char* model_id, rac_model_info_t** out_model) { + rac_model_registry_handle_t registry = rac_get_model_registry(); + if (registry == nullptr) { + return RAC_ERROR_NOT_INITIALIZED; + } + return rac_model_registry_get(registry, model_id, out_model); +} + +rac_bool_t rac_framework_is_platform_service(rac_inference_framework_t framework) { + // Platform services are Swift-native implementations + // that use service registry callbacks rather than C++ backends + switch (framework) { + case RAC_FRAMEWORK_FOUNDATION_MODELS: + case RAC_FRAMEWORK_SYSTEM_TTS: + return RAC_TRUE; + default: + return RAC_FALSE; + } +} + +} // extern "C" diff --git a/sdk/runanywhere-commons/src/core/rac_error.cpp b/sdk/runanywhere-commons/src/core/rac_error.cpp new file mode 100644 index 000000000..d0e178686 --- /dev/null +++ b/sdk/runanywhere-commons/src/core/rac_error.cpp @@ -0,0 +1,383 @@ +/** + * @file rac_error.cpp + * @brief RunAnywhere Commons - Error Handling Implementation + * + * C port of Swift's ErrorCode enum messages from Foundation/Errors/ErrorCode.swift. + * + * IMPORTANT: This is a direct translation of the Swift implementation. + * Do NOT add error messages not present in the Swift code. + */ + +#include "rac/core/rac_error.h" + +#include +#include + +// Thread-local storage for detailed error messages +// Matches Swift's per-operation error context pattern +static thread_local std::string s_error_details; + +extern "C" { + +const char* rac_error_message(rac_result_t error_code) { + // Success + if (error_code == RAC_SUCCESS) { + return "Success"; + } + + switch (error_code) { + // ================================================================= + // INITIALIZATION ERRORS (-100 to -109) + // ================================================================= + case RAC_ERROR_NOT_INITIALIZED: + return "Component or service has not been initialized"; + case RAC_ERROR_ALREADY_INITIALIZED: + return "Component or service is already initialized"; + case RAC_ERROR_INITIALIZATION_FAILED: + return "Initialization failed"; + case RAC_ERROR_INVALID_CONFIGURATION: + return "Configuration is invalid"; + case RAC_ERROR_INVALID_API_KEY: + return "API key is invalid or missing"; + case RAC_ERROR_ENVIRONMENT_MISMATCH: + return "Environment mismatch"; + + // ================================================================= + // MODEL ERRORS (-110 to -129) + // ================================================================= + case RAC_ERROR_MODEL_NOT_FOUND: + return "Requested model was not found"; + case RAC_ERROR_MODEL_LOAD_FAILED: + return "Failed to load the model"; + case RAC_ERROR_MODEL_VALIDATION_FAILED: + return "Model validation failed"; + case RAC_ERROR_MODEL_INCOMPATIBLE: + return "Model is incompatible with current runtime"; + case RAC_ERROR_INVALID_MODEL_FORMAT: + return "Model format is invalid"; + case RAC_ERROR_MODEL_STORAGE_CORRUPTED: + return "Model storage is corrupted"; + case RAC_ERROR_MODEL_NOT_LOADED: + return "Model not loaded"; + + // ================================================================= + // GENERATION ERRORS (-130 to -149) + // ================================================================= + case RAC_ERROR_GENERATION_FAILED: + return "Text/audio generation failed"; + case RAC_ERROR_GENERATION_TIMEOUT: + return "Generation timed out"; + case RAC_ERROR_CONTEXT_TOO_LONG: + return "Context length exceeded maximum"; + case RAC_ERROR_TOKEN_LIMIT_EXCEEDED: + return "Token limit exceeded"; + case RAC_ERROR_COST_LIMIT_EXCEEDED: + return "Cost limit exceeded"; + case RAC_ERROR_INFERENCE_FAILED: + return "Inference failed"; + + // ================================================================= + // NETWORK ERRORS (-150 to -179) + // ================================================================= + case RAC_ERROR_NETWORK_UNAVAILABLE: + return "Network is unavailable"; + case RAC_ERROR_NETWORK_ERROR: + return "Network error"; + case RAC_ERROR_REQUEST_FAILED: + return "Request failed"; + case RAC_ERROR_DOWNLOAD_FAILED: + return "Download failed"; + case RAC_ERROR_SERVER_ERROR: + return "Server returned an error"; + case RAC_ERROR_TIMEOUT: + return "Request timed out"; + case RAC_ERROR_INVALID_RESPONSE: + return "Invalid response from server"; + case RAC_ERROR_HTTP_ERROR: + return "HTTP error"; + case RAC_ERROR_CONNECTION_LOST: + return "Connection was lost"; + case RAC_ERROR_PARTIAL_DOWNLOAD: + return "Partial download (incomplete)"; + case RAC_ERROR_HTTP_REQUEST_FAILED: + return "HTTP request failed"; + case RAC_ERROR_HTTP_NOT_SUPPORTED: + return "HTTP not supported"; + + // ================================================================= + // STORAGE ERRORS (-180 to -219) + // ================================================================= + case RAC_ERROR_INSUFFICIENT_STORAGE: + return "Insufficient storage space"; + case RAC_ERROR_STORAGE_FULL: + return "Storage is full"; + case RAC_ERROR_STORAGE_ERROR: + return "Storage error"; + case RAC_ERROR_FILE_NOT_FOUND: + return "File was not found"; + case RAC_ERROR_FILE_READ_FAILED: + return "Failed to read file"; + case RAC_ERROR_FILE_WRITE_FAILED: + return "Failed to write file"; + case RAC_ERROR_PERMISSION_DENIED: + return "Permission denied for file operation"; + case RAC_ERROR_DELETE_FAILED: + return "Failed to delete file or directory"; + case RAC_ERROR_MOVE_FAILED: + return "Failed to move file"; + case RAC_ERROR_DIRECTORY_CREATION_FAILED: + return "Failed to create directory"; + case RAC_ERROR_DIRECTORY_NOT_FOUND: + return "Directory not found"; + case RAC_ERROR_INVALID_PATH: + return "Invalid file path"; + case RAC_ERROR_INVALID_FILE_NAME: + return "Invalid file name"; + case RAC_ERROR_TEMP_FILE_CREATION_FAILED: + return "Failed to create temporary file"; + + // ================================================================= + // HARDWARE ERRORS (-220 to -229) + // ================================================================= + case RAC_ERROR_HARDWARE_UNSUPPORTED: + return "Hardware is unsupported"; + case RAC_ERROR_INSUFFICIENT_MEMORY: + return "Insufficient memory"; + + // ================================================================= + // COMPONENT STATE ERRORS (-230 to -249) + // ================================================================= + case RAC_ERROR_COMPONENT_NOT_READY: + return "Component is not ready"; + case RAC_ERROR_INVALID_STATE: + return "Component is in invalid state"; + case RAC_ERROR_SERVICE_NOT_AVAILABLE: + return "Service is not available"; + case RAC_ERROR_SERVICE_BUSY: + return "Service is busy"; + case RAC_ERROR_PROCESSING_FAILED: + return "Processing failed"; + case RAC_ERROR_START_FAILED: + return "Start operation failed"; + case RAC_ERROR_NOT_SUPPORTED: + return "Feature/operation is not supported"; + + // ================================================================= + // VALIDATION ERRORS (-250 to -279) + // ================================================================= + case RAC_ERROR_VALIDATION_FAILED: + return "Validation failed"; + case RAC_ERROR_INVALID_INPUT: + return "Input is invalid"; + case RAC_ERROR_INVALID_FORMAT: + return "Format is invalid"; + case RAC_ERROR_EMPTY_INPUT: + return "Input is empty"; + case RAC_ERROR_TEXT_TOO_LONG: + return "Text is too long"; + case RAC_ERROR_INVALID_SSML: + return "Invalid SSML markup"; + case RAC_ERROR_INVALID_SPEAKING_RATE: + return "Invalid speaking rate"; + case RAC_ERROR_INVALID_PITCH: + return "Invalid pitch"; + case RAC_ERROR_INVALID_VOLUME: + return "Invalid volume"; + case RAC_ERROR_INVALID_ARGUMENT: + return "Invalid argument"; + case RAC_ERROR_NULL_POINTER: + return "Null pointer"; + case RAC_ERROR_BUFFER_TOO_SMALL: + return "Buffer too small"; + + // ================================================================= + // AUDIO ERRORS (-280 to -299) + // ================================================================= + case RAC_ERROR_AUDIO_FORMAT_NOT_SUPPORTED: + return "Audio format is not supported"; + case RAC_ERROR_AUDIO_SESSION_FAILED: + return "Audio session configuration failed"; + case RAC_ERROR_MICROPHONE_PERMISSION_DENIED: + return "Microphone permission denied"; + case RAC_ERROR_INSUFFICIENT_AUDIO_DATA: + return "Insufficient audio data"; + case RAC_ERROR_EMPTY_AUDIO_BUFFER: + return "Audio buffer is empty"; + case RAC_ERROR_AUDIO_SESSION_ACTIVATION_FAILED: + return "Audio session activation failed"; + + // ================================================================= + // LANGUAGE/VOICE ERRORS (-300 to -319) + // ================================================================= + case RAC_ERROR_LANGUAGE_NOT_SUPPORTED: + return "Language is not supported"; + case RAC_ERROR_VOICE_NOT_AVAILABLE: + return "Voice is not available"; + case RAC_ERROR_STREAMING_NOT_SUPPORTED: + return "Streaming is not supported"; + case RAC_ERROR_STREAM_CANCELLED: + return "Stream was cancelled"; + + // ================================================================= + // AUTHENTICATION ERRORS (-320 to -329) + // ================================================================= + case RAC_ERROR_AUTHENTICATION_FAILED: + return "Authentication failed"; + case RAC_ERROR_UNAUTHORIZED: + return "Unauthorized access"; + case RAC_ERROR_FORBIDDEN: + return "Access forbidden"; + + // ================================================================= + // SECURITY ERRORS (-330 to -349) + // ================================================================= + case RAC_ERROR_KEYCHAIN_ERROR: + return "Keychain operation failed"; + case RAC_ERROR_ENCODING_ERROR: + return "Encoding error"; + case RAC_ERROR_DECODING_ERROR: + return "Decoding error"; + case RAC_ERROR_SECURE_STORAGE_FAILED: + return "Secure storage operation failed"; + + // ================================================================= + // EXTRACTION ERRORS (-350 to -369) + // ================================================================= + case RAC_ERROR_EXTRACTION_FAILED: + return "Extraction failed"; + case RAC_ERROR_CHECKSUM_MISMATCH: + return "Checksum mismatch"; + case RAC_ERROR_UNSUPPORTED_ARCHIVE: + return "Unsupported archive format"; + + // ================================================================= + // CALIBRATION ERRORS (-370 to -379) + // ================================================================= + case RAC_ERROR_CALIBRATION_FAILED: + return "Calibration failed"; + case RAC_ERROR_CALIBRATION_TIMEOUT: + return "Calibration timed out"; + + // ================================================================= + // CANCELLATION (-380 to -389) + // ================================================================= + case RAC_ERROR_CANCELLED: + return "Operation was cancelled"; + + // ================================================================= + // MODULE/SERVICE ERRORS (-400 to -499) + // ================================================================= + case RAC_ERROR_MODULE_NOT_FOUND: + return "Module not found"; + case RAC_ERROR_MODULE_ALREADY_REGISTERED: + return "Module already registered"; + case RAC_ERROR_MODULE_LOAD_FAILED: + return "Module load failed"; + case RAC_ERROR_SERVICE_NOT_FOUND: + return "Service not found"; + case RAC_ERROR_SERVICE_ALREADY_REGISTERED: + return "Service already registered"; + case RAC_ERROR_SERVICE_CREATE_FAILED: + return "Service creation failed"; + case RAC_ERROR_CAPABILITY_NOT_FOUND: + return "Capability not found"; + case RAC_ERROR_PROVIDER_NOT_FOUND: + return "Provider not found"; + case RAC_ERROR_NO_CAPABLE_PROVIDER: + return "No provider can handle the request"; + case RAC_ERROR_NOT_FOUND: + return "Not found"; + + // ================================================================= + // PLATFORM ADAPTER ERRORS (-500 to -599) + // ================================================================= + case RAC_ERROR_ADAPTER_NOT_SET: + return "Platform adapter not set"; + + // ================================================================= + // BACKEND ERRORS (-600 to -699) + // ================================================================= + case RAC_ERROR_BACKEND_NOT_FOUND: + return "Backend not found"; + case RAC_ERROR_BACKEND_NOT_READY: + return "Backend not ready"; + case RAC_ERROR_BACKEND_INIT_FAILED: + return "Backend initialization failed"; + case RAC_ERROR_BACKEND_BUSY: + return "Backend busy"; + case RAC_ERROR_INVALID_HANDLE: + return "Invalid handle"; + + // ================================================================= + // EVENT ERRORS (-700 to -799) + // ================================================================= + case RAC_ERROR_EVENT_INVALID_CATEGORY: + return "Invalid event category"; + case RAC_ERROR_EVENT_SUBSCRIPTION_FAILED: + return "Event subscription failed"; + case RAC_ERROR_EVENT_PUBLISH_FAILED: + return "Event publish failed"; + + // ================================================================= + // OTHER ERRORS (-800 to -899) + // ================================================================= + case RAC_ERROR_NOT_IMPLEMENTED: + return "Feature is not implemented"; + case RAC_ERROR_FEATURE_NOT_AVAILABLE: + return "Feature is not available"; + case RAC_ERROR_FRAMEWORK_NOT_AVAILABLE: + return "Framework is not available"; + case RAC_ERROR_UNSUPPORTED_MODALITY: + return "Unsupported modality"; + case RAC_ERROR_UNKNOWN: + return "Unknown error"; + case RAC_ERROR_INTERNAL: + return "Internal error"; + + default: + return "Unknown error code"; + } +} + +const char* rac_error_get_details(void) { + if (s_error_details.empty()) { + return nullptr; + } + return s_error_details.c_str(); +} + +void rac_error_set_details(const char* details) { + if (details != nullptr) { + s_error_details = details; + return; + } + s_error_details.clear(); +} + +void rac_error_clear_details(void) { + s_error_details.clear(); +} + +rac_bool_t rac_error_is_commons_error(rac_result_t error_code) { + // Commons errors are in range -100 to -999 + return (error_code <= -100 && error_code >= -999) ? RAC_TRUE : RAC_FALSE; +} + +rac_bool_t rac_error_is_core_error(rac_result_t error_code) { + // Core errors are in range -1 to -99 + return (error_code <= -1 && error_code >= -99) ? RAC_TRUE : RAC_FALSE; +} + +rac_bool_t rac_error_is_expected(rac_result_t error_code) { + // Mirrors Swift's ErrorCode.isExpected property + // Expected errors are routine and shouldn't be logged as errors + switch (error_code) { + case RAC_ERROR_CANCELLED: + case RAC_ERROR_STREAM_CANCELLED: + return RAC_TRUE; + default: + return RAC_FALSE; + } +} + +} // extern "C" diff --git a/sdk/runanywhere-commons/src/core/rac_logger.cpp b/sdk/runanywhere-commons/src/core/rac_logger.cpp new file mode 100644 index 000000000..98c70af40 --- /dev/null +++ b/sdk/runanywhere-commons/src/core/rac_logger.cpp @@ -0,0 +1,275 @@ +/** + * @file rac_logger.cpp + * @brief RunAnywhere Commons - Logger Implementation + * + * Implements the structured logging system that routes through the platform + * adapter to Swift/Kotlin for proper telemetry and error tracking. + */ + +#include "rac/core/rac_logger.h" + +#include +#include +#include +#include + +#include "rac/core/rac_platform_adapter.h" + +// ============================================================================= +// INTERNAL STATE +// ============================================================================= + +namespace { + +// Logger configuration +struct LoggerState { + rac_log_level_t min_level = RAC_LOG_INFO; + rac_bool_t stderr_fallback = RAC_TRUE; + rac_bool_t stderr_always = RAC_TRUE; // Always log to stderr (safe during static init) + rac_bool_t initialized = RAC_FALSE; + std::mutex mutex; +}; + +LoggerState& state() { + static LoggerState s; + return s; +} + +// Level to string +const char* level_to_string(rac_log_level_t level) { + switch (level) { + case RAC_LOG_TRACE: + return "TRACE"; + case RAC_LOG_DEBUG: + return "DEBUG"; + case RAC_LOG_INFO: + return "INFO"; + case RAC_LOG_WARNING: + return "WARN"; + case RAC_LOG_ERROR: + return "ERROR"; + case RAC_LOG_FATAL: + return "FATAL"; + default: + return "???"; + } +} + +// Extract filename from path +const char* filename_from_path(const char* path) { + if (!path) + return nullptr; + const char* last_slash = strrchr(path, '/'); + const char* last_backslash = strrchr(path, '\\'); + const char* last_sep = last_slash > last_backslash ? last_slash : last_backslash; + return last_sep ? last_sep + 1 : path; +} + +// Format message with metadata for platform adapter +void format_message_with_metadata(char* buffer, size_t buffer_size, const char* message, + const rac_log_metadata_t* metadata) { + if (!metadata) { + snprintf(buffer, buffer_size, "%s", message); + return; + } + + // Start with the message + size_t pos = snprintf(buffer, buffer_size, "%s", message); + + // Add metadata if present + bool has_meta = false; + + if (metadata->file && pos < buffer_size) { + const char* filename = filename_from_path(metadata->file); + if (filename) { + pos += snprintf(buffer + pos, buffer_size - pos, "%s file=%s:%d", has_meta ? "," : " |", + filename, metadata->line); + has_meta = true; + } + } + + if (metadata->function && pos < buffer_size) { + pos += snprintf(buffer + pos, buffer_size - pos, "%s func=%s", has_meta ? "," : " |", + metadata->function); + has_meta = true; + } + + if (metadata->error_code != 0 && pos < buffer_size) { + pos += snprintf(buffer + pos, buffer_size - pos, "%s error_code=%d", has_meta ? "," : " |", + metadata->error_code); + has_meta = true; + } + + if (metadata->error_msg && pos < buffer_size) { + pos += snprintf(buffer + pos, buffer_size - pos, "%s error=%s", has_meta ? "," : " |", + metadata->error_msg); + has_meta = true; + } + + if (metadata->model_id && pos < buffer_size) { + pos += snprintf(buffer + pos, buffer_size - pos, "%s model=%s", has_meta ? "," : " |", + metadata->model_id); + has_meta = true; + } + + if (metadata->framework && pos < buffer_size) { + pos += snprintf(buffer + pos, buffer_size - pos, "%s framework=%s", has_meta ? "," : " |", + metadata->framework); + has_meta = true; + } + + // Custom key-value pairs + if (metadata->custom_key1 && metadata->custom_value1 && pos < buffer_size) { + pos += snprintf(buffer + pos, buffer_size - pos, "%s %s=%s", has_meta ? "," : " |", + metadata->custom_key1, metadata->custom_value1); + has_meta = true; + } + + if (metadata->custom_key2 && metadata->custom_value2 && pos < buffer_size) { + snprintf(buffer + pos, buffer_size - pos, "%s %s=%s", has_meta ? "," : " |", + metadata->custom_key2, metadata->custom_value2); + } +} + +// Fallback to stderr +void log_to_stderr(rac_log_level_t level, const char* category, const char* message, + const rac_log_metadata_t* metadata) { + const char* level_str = level_to_string(level); + + // Determine output stream + FILE* stream = (level >= RAC_LOG_ERROR) ? stderr : stdout; + + // Print base message + fprintf(stream, "[RAC][%s][%s] %s", level_str, category, message); + + // Print metadata if present + if (metadata) { + if (metadata->file) { + const char* filename = filename_from_path(metadata->file); + if (filename) { + fprintf(stream, " | file=%s:%d", filename, metadata->line); + } + } + if (metadata->function) { + fprintf(stream, ", func=%s", metadata->function); + } + if (metadata->error_code != 0) { + fprintf(stream, ", error_code=%d", metadata->error_code); + } + if (metadata->model_id) { + fprintf(stream, ", model=%s", metadata->model_id); + } + if (metadata->framework) { + fprintf(stream, ", framework=%s", metadata->framework); + } + } + + fprintf(stream, "\n"); + fflush(stream); +} + +} // anonymous namespace + +// ============================================================================= +// PUBLIC API IMPLEMENTATION +// ============================================================================= + +extern "C" { + +rac_result_t rac_logger_init(rac_log_level_t min_level) { + std::lock_guard lock(state().mutex); + state().min_level = min_level; + state().initialized = RAC_TRUE; + return RAC_SUCCESS; +} + +void rac_logger_shutdown(void) { + std::lock_guard lock(state().mutex); + state().initialized = RAC_FALSE; +} + +void rac_logger_set_min_level(rac_log_level_t level) { + std::lock_guard lock(state().mutex); + state().min_level = level; +} + +rac_log_level_t rac_logger_get_min_level(void) { + std::lock_guard lock(state().mutex); + return state().min_level; +} + +void rac_logger_set_stderr_fallback(rac_bool_t enabled) { + std::lock_guard lock(state().mutex); + state().stderr_fallback = enabled; +} + +void rac_logger_set_stderr_always(rac_bool_t enabled) { + std::lock_guard lock(state().mutex); + state().stderr_always = enabled; +} + +void rac_logger_log(rac_log_level_t level, const char* category, const char* message, + const rac_log_metadata_t* metadata) { + if (!message) + return; + if (!category) + category = "RAC"; + + // Get state configuration (with lock) + rac_log_level_t min_level; + rac_bool_t stderr_always; + rac_bool_t stderr_fallback; + { + std::lock_guard lock(state().mutex); + min_level = state().min_level; + stderr_always = state().stderr_always; + stderr_fallback = state().stderr_fallback; + } + + // Check min level + if (level < min_level) + return; + + // ALWAYS log to stderr first if enabled (safe during static initialization) + // This ensures we can debug crashes even before platform adapter is ready + if (stderr_always != 0) { + log_to_stderr(level, category, message, metadata); + } + + // Also forward to platform adapter if available + const rac_platform_adapter_t* adapter = rac_get_platform_adapter(); + if (adapter && adapter->log) { + // Format message with metadata for the platform + char formatted[2048]; + format_message_with_metadata(formatted, sizeof(formatted), message, metadata); + adapter->log(level, category, formatted, adapter->user_data); + } else if (stderr_always == 0 && stderr_fallback != 0) { + // Fallback to stderr only if we haven't already logged there + log_to_stderr(level, category, message, metadata); + } +} + +void rac_logger_logf(rac_log_level_t level, const char* category, + const rac_log_metadata_t* metadata, const char* format, ...) { + if (!format) + return; + + va_list args; + va_start(args, format); + rac_logger_logv(level, category, metadata, format, args); + va_end(args); +} + +void rac_logger_logv(rac_log_level_t level, const char* category, + const rac_log_metadata_t* metadata, const char* format, va_list args) { + if (!format) + return; + + // Format the message + char buffer[2048]; + vsnprintf(buffer, sizeof(buffer), format, args); + + rac_logger_log(level, category, buffer, metadata); +} + +} // extern "C" diff --git a/sdk/runanywhere-commons/src/core/rac_memory.cpp b/sdk/runanywhere-commons/src/core/rac_memory.cpp new file mode 100644 index 000000000..93ce531df --- /dev/null +++ b/sdk/runanywhere-commons/src/core/rac_memory.cpp @@ -0,0 +1,52 @@ +/** + * @file rac_memory.cpp + * @brief RunAnywhere Commons - Memory Utilities + * + * Matches Swift's memory management patterns for C interop. + */ + +#include +#include + +#include "rac/core/rac_types.h" + +extern "C" { + +/** + * Allocate memory using the RAC allocator. + */ +void* rac_alloc(size_t size) { + if (size == 0) { + return nullptr; + } + return malloc(size); +} + +/** + * Free memory allocated by RAC functions. + * Matches the pattern from Swift's ra_free_string usage. + */ +void rac_free(void* ptr) { + if (ptr != nullptr) { + free(ptr); + } +} + +/** + * Duplicate a string (caller must free with rac_free). + * Matches Swift interop patterns. + */ +char* rac_strdup(const char* str) { + if (str == nullptr) { + return nullptr; + } + + size_t len = strlen(str) + 1; + char* copy = static_cast(malloc(len)); + if (copy != nullptr) { + memcpy(copy, str, len); + } + return copy; +} + +} // extern "C" diff --git a/sdk/runanywhere-commons/src/core/rac_structured_error.cpp b/sdk/runanywhere-commons/src/core/rac_structured_error.cpp new file mode 100644 index 000000000..ad79ac6be --- /dev/null +++ b/sdk/runanywhere-commons/src/core/rac_structured_error.cpp @@ -0,0 +1,924 @@ +/** + * @file rac_structured_error.cpp + * @brief RunAnywhere Commons - Structured Error Implementation + */ + +#include "rac/core/rac_structured_error.h" + +#include +#include +#include +#include +#include +#include + +#include "rac/core/rac_logger.h" +#include "rac/core/rac_platform_adapter.h" + +#if defined(__APPLE__) || defined(__linux__) +#include +#endif + +// ============================================================================= +// THREAD-LOCAL STORAGE +// ============================================================================= + +namespace { + +thread_local rac_error_t g_last_error; +thread_local bool g_has_last_error = false; + +// Helper to safely copy strings +void safe_strcpy(char* dest, size_t dest_size, const char* src) { + if (!dest || dest_size == 0) + return; + if (!src) { + dest[0] = '\0'; + return; + } + size_t len = strlen(src); + if (len >= dest_size) + len = dest_size - 1; + memcpy(dest, src, len); + dest[len] = '\0'; +} + +// Get current timestamp in milliseconds +int64_t current_timestamp_ms() { + const rac_platform_adapter_t* adapter = rac_get_platform_adapter(); + if (adapter && adapter->now_ms) { + return adapter->now_ms(adapter->user_data); + } + // Fallback + return static_cast(time(nullptr)) * 1000; +} + +} // anonymous namespace + +// ============================================================================= +// ERROR CREATION & DESTRUCTION +// ============================================================================= + +extern "C" { + +rac_error_t* rac_error_create(rac_result_t code, rac_error_category_t category, + const char* message) { + rac_error_t* error = static_cast(calloc(1, sizeof(rac_error_t))); + if (!error) + return nullptr; + + error->code = code; + error->category = category; + safe_strcpy(error->message, sizeof(error->message), message); + error->timestamp_ms = current_timestamp_ms(); + + return error; +} + +rac_error_t* rac_error_create_at(rac_result_t code, rac_error_category_t category, + const char* message, const char* file, int32_t line, + const char* function) { + rac_error_t* error = rac_error_create(code, category, message); + if (error) { + rac_error_set_source(error, file, line, function); + } + return error; +} + +rac_error_t* rac_error_createf(rac_result_t code, rac_error_category_t category, const char* format, + ...) { + char buffer[RAC_MAX_ERROR_MESSAGE]; + va_list args; + va_start(args, format); + vsnprintf(buffer, sizeof(buffer), format, args); + va_end(args); + + return rac_error_create(code, category, buffer); +} + +void rac_error_destroy(rac_error_t* error) { + free(error); +} + +rac_error_t* rac_error_copy(const rac_error_t* error) { + if (!error) + return nullptr; + + rac_error_t* copy = static_cast(malloc(sizeof(rac_error_t))); + if (copy) { + memcpy(copy, error, sizeof(rac_error_t)); + } + return copy; +} + +// ============================================================================= +// ERROR CONFIGURATION +// ============================================================================= + +void rac_error_set_source(rac_error_t* error, const char* file, int32_t line, + const char* function) { + if (!error) + return; + + // Extract filename from path + if (file) { + const char* last_slash = strrchr(file, '/'); + const char* last_backslash = strrchr(file, '\\'); + const char* last_sep = (last_slash > last_backslash) ? last_slash : last_backslash; + const char* filename = last_sep ? last_sep + 1 : file; + safe_strcpy(error->source_file, sizeof(error->source_file), filename); + } + error->source_line = line; + safe_strcpy(error->source_function, sizeof(error->source_function), function); +} + +void rac_error_set_underlying(rac_error_t* error, rac_result_t underlying_code, + const char* underlying_message) { + if (!error) + return; + error->underlying_code = underlying_code; + safe_strcpy(error->underlying_message, sizeof(error->underlying_message), underlying_message); +} + +void rac_error_set_model_context(rac_error_t* error, const char* model_id, const char* framework) { + if (!error) + return; + safe_strcpy(error->model_id, sizeof(error->model_id), model_id); + safe_strcpy(error->framework, sizeof(error->framework), framework); +} + +void rac_error_set_session(rac_error_t* error, const char* session_id) { + if (!error) + return; + safe_strcpy(error->session_id, sizeof(error->session_id), session_id); +} + +void rac_error_set_custom(rac_error_t* error, int32_t index, const char* key, const char* value) { + if (!error || index < 0 || index > 2) + return; + + char* key_dest = nullptr; + char* value_dest = nullptr; + size_t key_size = 64; + size_t value_size = RAC_MAX_METADATA_STRING; + + switch (index) { + case 0: + key_dest = error->custom_key1; + value_dest = error->custom_value1; + break; + case 1: + key_dest = error->custom_key2; + value_dest = error->custom_value2; + break; + case 2: + key_dest = error->custom_key3; + value_dest = error->custom_value3; + break; + default: + break; + } + + if (key_dest && value_dest) { + safe_strcpy(key_dest, key_size, key); + safe_strcpy(value_dest, value_size, value); + } +} + +// ============================================================================= +// STACK TRACE +// ============================================================================= + +int32_t rac_error_capture_stack_trace(rac_error_t* error) { + if (!error) + return 0; + +// Note: Android defines __linux__ but doesn't have execinfo.h/backtrace +#if (defined(__APPLE__) || defined(__linux__)) && !defined(__ANDROID__) + void* buffer[RAC_MAX_STACK_FRAMES]; + int frame_count = backtrace(buffer, RAC_MAX_STACK_FRAMES); + + // Skip the first few frames (this function and callers) + int skip = 2; + int captured = 0; + + for (int i = skip; i < frame_count && captured < RAC_MAX_STACK_FRAMES; i++) { + error->stack_frames[captured].address = buffer[i]; + error->stack_frames[captured].function = nullptr; + error->stack_frames[captured].file = nullptr; + error->stack_frames[captured].line = 0; + captured++; + } + + error->stack_frame_count = captured; + + // Try to symbolicate + char** symbols = backtrace_symbols(buffer + skip, captured); + if (symbols) { + // Note: We can't store these strings directly because they're freed + // For now, we just have addresses. Symbolication happens on the platform side. + free(symbols); + } + + return captured; +#else + // Platform doesn't support backtrace (Android, Windows, etc.) + error->stack_frame_count = 0; + return 0; +#endif +} + +void rac_error_add_frame(rac_error_t* error, const char* function, const char* file, int32_t line) { + if (!error || error->stack_frame_count >= RAC_MAX_STACK_FRAMES) + return; + + int idx = error->stack_frame_count; + // Note: We're storing pointers directly, caller must ensure strings outlive error + error->stack_frames[idx].function = function; + error->stack_frames[idx].file = file; + error->stack_frames[idx].line = line; + error->stack_frames[idx].address = nullptr; + error->stack_frame_count++; +} + +// ============================================================================= +// ERROR INFORMATION +// ============================================================================= + +const char* rac_error_code_name(rac_result_t code) { + switch (code) { + // Success + case RAC_SUCCESS: + return "SUCCESS"; + + // Initialization Errors (-100 to -109) + case RAC_ERROR_NOT_INITIALIZED: + return "notInitialized"; + case RAC_ERROR_ALREADY_INITIALIZED: + return "alreadyInitialized"; + case RAC_ERROR_INITIALIZATION_FAILED: + return "initializationFailed"; + case RAC_ERROR_INVALID_CONFIGURATION: + return "invalidConfiguration"; + case RAC_ERROR_INVALID_API_KEY: + return "invalidAPIKey"; + case RAC_ERROR_ENVIRONMENT_MISMATCH: + return "environmentMismatch"; + case RAC_ERROR_INVALID_PARAMETER: + return "invalidConfiguration"; + + // Model Errors (-110 to -129) + case RAC_ERROR_MODEL_NOT_FOUND: + return "modelNotFound"; + case RAC_ERROR_MODEL_LOAD_FAILED: + return "modelLoadFailed"; + case RAC_ERROR_MODEL_VALIDATION_FAILED: + return "modelValidationFailed"; + case RAC_ERROR_MODEL_INCOMPATIBLE: + return "modelIncompatible"; + case RAC_ERROR_INVALID_MODEL_FORMAT: + return "invalidModelFormat"; + case RAC_ERROR_MODEL_STORAGE_CORRUPTED: + return "modelStorageCorrupted"; + case RAC_ERROR_MODEL_NOT_LOADED: + return "notInitialized"; + + // Generation Errors (-130 to -149) + case RAC_ERROR_GENERATION_FAILED: + return "generationFailed"; + case RAC_ERROR_GENERATION_TIMEOUT: + return "generationTimeout"; + case RAC_ERROR_CONTEXT_TOO_LONG: + return "contextTooLong"; + case RAC_ERROR_TOKEN_LIMIT_EXCEEDED: + return "tokenLimitExceeded"; + case RAC_ERROR_COST_LIMIT_EXCEEDED: + return "costLimitExceeded"; + case RAC_ERROR_INFERENCE_FAILED: + return "generationFailed"; + + // Network Errors (-150 to -179) + case RAC_ERROR_NETWORK_UNAVAILABLE: + return "networkUnavailable"; + case RAC_ERROR_NETWORK_ERROR: + return "networkError"; + case RAC_ERROR_REQUEST_FAILED: + return "requestFailed"; + case RAC_ERROR_DOWNLOAD_FAILED: + return "downloadFailed"; + case RAC_ERROR_SERVER_ERROR: + return "serverError"; + case RAC_ERROR_TIMEOUT: + return "timeout"; + case RAC_ERROR_INVALID_RESPONSE: + return "invalidResponse"; + case RAC_ERROR_HTTP_ERROR: + return "httpError"; + case RAC_ERROR_CONNECTION_LOST: + return "connectionLost"; + case RAC_ERROR_PARTIAL_DOWNLOAD: + return "partialDownload"; + case RAC_ERROR_HTTP_REQUEST_FAILED: + return "requestFailed"; + case RAC_ERROR_HTTP_NOT_SUPPORTED: + return "notSupported"; + + // Storage Errors (-180 to -219) + case RAC_ERROR_INSUFFICIENT_STORAGE: + return "insufficientStorage"; + case RAC_ERROR_STORAGE_FULL: + return "storageFull"; + case RAC_ERROR_STORAGE_ERROR: + return "storageError"; + case RAC_ERROR_FILE_NOT_FOUND: + return "fileNotFound"; + case RAC_ERROR_FILE_READ_FAILED: + return "fileReadFailed"; + case RAC_ERROR_FILE_WRITE_FAILED: + return "fileWriteFailed"; + case RAC_ERROR_PERMISSION_DENIED: + return "permissionDenied"; + case RAC_ERROR_DELETE_FAILED: + return "deleteFailed"; + case RAC_ERROR_MOVE_FAILED: + return "moveFailed"; + case RAC_ERROR_DIRECTORY_CREATION_FAILED: + return "directoryCreationFailed"; + case RAC_ERROR_DIRECTORY_NOT_FOUND: + return "directoryNotFound"; + case RAC_ERROR_INVALID_PATH: + return "invalidPath"; + case RAC_ERROR_INVALID_FILE_NAME: + return "invalidFileName"; + case RAC_ERROR_TEMP_FILE_CREATION_FAILED: + return "tempFileCreationFailed"; + + // Hardware Errors (-220 to -229) + case RAC_ERROR_HARDWARE_UNSUPPORTED: + return "hardwareUnsupported"; + case RAC_ERROR_INSUFFICIENT_MEMORY: + return "insufficientMemory"; + + // Component State Errors (-230 to -249) + case RAC_ERROR_COMPONENT_NOT_READY: + return "componentNotReady"; + case RAC_ERROR_INVALID_STATE: + return "invalidState"; + case RAC_ERROR_SERVICE_NOT_AVAILABLE: + return "serviceNotAvailable"; + case RAC_ERROR_SERVICE_BUSY: + return "serviceBusy"; + case RAC_ERROR_PROCESSING_FAILED: + return "processingFailed"; + case RAC_ERROR_START_FAILED: + return "startFailed"; + case RAC_ERROR_NOT_SUPPORTED: + return "notSupported"; + + // Validation Errors (-250 to -279) + case RAC_ERROR_VALIDATION_FAILED: + return "validationFailed"; + case RAC_ERROR_INVALID_INPUT: + return "invalidInput"; + case RAC_ERROR_INVALID_FORMAT: + return "invalidFormat"; + case RAC_ERROR_EMPTY_INPUT: + return "emptyInput"; + case RAC_ERROR_TEXT_TOO_LONG: + return "textTooLong"; + case RAC_ERROR_INVALID_SSML: + return "invalidSSML"; + case RAC_ERROR_INVALID_SPEAKING_RATE: + return "invalidSpeakingRate"; + case RAC_ERROR_INVALID_PITCH: + return "invalidPitch"; + case RAC_ERROR_INVALID_VOLUME: + return "invalidVolume"; + case RAC_ERROR_INVALID_ARGUMENT: + return "invalidInput"; + case RAC_ERROR_NULL_POINTER: + return "invalidInput"; + case RAC_ERROR_BUFFER_TOO_SMALL: + return "invalidInput"; + + // Audio Errors (-280 to -299) + case RAC_ERROR_AUDIO_FORMAT_NOT_SUPPORTED: + return "audioFormatNotSupported"; + case RAC_ERROR_AUDIO_SESSION_FAILED: + return "audioSessionFailed"; + case RAC_ERROR_MICROPHONE_PERMISSION_DENIED: + return "microphonePermissionDenied"; + case RAC_ERROR_INSUFFICIENT_AUDIO_DATA: + return "insufficientAudioData"; + case RAC_ERROR_EMPTY_AUDIO_BUFFER: + return "emptyAudioBuffer"; + case RAC_ERROR_AUDIO_SESSION_ACTIVATION_FAILED: + return "audioSessionActivationFailed"; + + // Language/Voice Errors (-300 to -319) + case RAC_ERROR_LANGUAGE_NOT_SUPPORTED: + return "languageNotSupported"; + case RAC_ERROR_VOICE_NOT_AVAILABLE: + return "voiceNotAvailable"; + case RAC_ERROR_STREAMING_NOT_SUPPORTED: + return "streamingNotSupported"; + case RAC_ERROR_STREAM_CANCELLED: + return "streamCancelled"; + + // Authentication Errors (-320 to -329) + case RAC_ERROR_AUTHENTICATION_FAILED: + return "authenticationFailed"; + case RAC_ERROR_UNAUTHORIZED: + return "unauthorized"; + case RAC_ERROR_FORBIDDEN: + return "forbidden"; + + // Security Errors (-330 to -349) + case RAC_ERROR_KEYCHAIN_ERROR: + return "keychainError"; + case RAC_ERROR_ENCODING_ERROR: + return "encodingError"; + case RAC_ERROR_DECODING_ERROR: + return "decodingError"; + case RAC_ERROR_SECURE_STORAGE_FAILED: + return "keychainError"; + + // Extraction Errors (-350 to -369) + case RAC_ERROR_EXTRACTION_FAILED: + return "extractionFailed"; + case RAC_ERROR_CHECKSUM_MISMATCH: + return "checksumMismatch"; + case RAC_ERROR_UNSUPPORTED_ARCHIVE: + return "unsupportedArchive"; + + // Calibration Errors (-370 to -379) + case RAC_ERROR_CALIBRATION_FAILED: + return "calibrationFailed"; + case RAC_ERROR_CALIBRATION_TIMEOUT: + return "calibrationTimeout"; + + // Cancellation (-380 to -389) + case RAC_ERROR_CANCELLED: + return "cancelled"; + + // Module/Service Errors (-400 to -499) + case RAC_ERROR_MODULE_NOT_FOUND: + return "frameworkNotAvailable"; + case RAC_ERROR_MODULE_ALREADY_REGISTERED: + return "alreadyInitialized"; + case RAC_ERROR_MODULE_LOAD_FAILED: + return "initializationFailed"; + case RAC_ERROR_SERVICE_NOT_FOUND: + return "serviceNotAvailable"; + case RAC_ERROR_SERVICE_ALREADY_REGISTERED: + return "alreadyInitialized"; + case RAC_ERROR_SERVICE_CREATE_FAILED: + return "initializationFailed"; + case RAC_ERROR_CAPABILITY_NOT_FOUND: + return "featureNotAvailable"; + case RAC_ERROR_PROVIDER_NOT_FOUND: + return "serviceNotAvailable"; + case RAC_ERROR_NO_CAPABLE_PROVIDER: + return "serviceNotAvailable"; + case RAC_ERROR_NOT_FOUND: + return "modelNotFound"; + + // Platform Adapter Errors (-500 to -599) + case RAC_ERROR_ADAPTER_NOT_SET: + return "notInitialized"; + + // Backend Errors (-600 to -699) + case RAC_ERROR_BACKEND_NOT_FOUND: + return "frameworkNotAvailable"; + case RAC_ERROR_BACKEND_NOT_READY: + return "componentNotReady"; + case RAC_ERROR_BACKEND_INIT_FAILED: + return "initializationFailed"; + case RAC_ERROR_BACKEND_BUSY: + return "serviceBusy"; + case RAC_ERROR_INVALID_HANDLE: + return "invalidState"; + + // Event Errors (-700 to -799) + case RAC_ERROR_EVENT_INVALID_CATEGORY: + return "invalidInput"; + case RAC_ERROR_EVENT_SUBSCRIPTION_FAILED: + return "unknown"; + case RAC_ERROR_EVENT_PUBLISH_FAILED: + return "unknown"; + + // Other Errors (-800 to -899) + case RAC_ERROR_NOT_IMPLEMENTED: + return "notImplemented"; + case RAC_ERROR_FEATURE_NOT_AVAILABLE: + return "featureNotAvailable"; + case RAC_ERROR_FRAMEWORK_NOT_AVAILABLE: + return "frameworkNotAvailable"; + case RAC_ERROR_UNSUPPORTED_MODALITY: + return "unsupportedModality"; + case RAC_ERROR_UNKNOWN: + return "unknown"; + case RAC_ERROR_INTERNAL: + return "unknown"; + + default: + return "unknown"; + } +} + +const char* rac_error_category_name(rac_error_category_t category) { + switch (category) { + case RAC_CATEGORY_GENERAL: + return "general"; + case RAC_CATEGORY_STT: + return "stt"; + case RAC_CATEGORY_TTS: + return "tts"; + case RAC_CATEGORY_LLM: + return "llm"; + case RAC_CATEGORY_VAD: + return "vad"; + case RAC_CATEGORY_VLM: + return "vlm"; + case RAC_CATEGORY_SPEAKER_DIARIZATION: + return "speakerDiarization"; + case RAC_CATEGORY_WAKE_WORD: + return "wakeWord"; + case RAC_CATEGORY_VOICE_AGENT: + return "voiceAgent"; + case RAC_CATEGORY_DOWNLOAD: + return "download"; + case RAC_CATEGORY_FILE_MANAGEMENT: + return "fileManagement"; + case RAC_CATEGORY_NETWORK: + return "network"; + case RAC_CATEGORY_AUTHENTICATION: + return "authentication"; + case RAC_CATEGORY_SECURITY: + return "security"; + case RAC_CATEGORY_RUNTIME: + return "runtime"; + default: + return "unknown"; + } +} + +const char* rac_error_recovery_suggestion(rac_result_t code) { + switch (code) { + case RAC_ERROR_NOT_INITIALIZED: + return "Initialize the component before using it."; + case RAC_ERROR_MODEL_NOT_FOUND: + return "Ensure the model is downloaded and the path is correct."; + case RAC_ERROR_NETWORK_UNAVAILABLE: + return "Check your internet connection and try again."; + case RAC_ERROR_INSUFFICIENT_STORAGE: + return "Free up storage space and try again."; + case RAC_ERROR_INSUFFICIENT_MEMORY: + return "Close other applications to free up memory."; + case RAC_ERROR_MICROPHONE_PERMISSION_DENIED: + return "Grant microphone permission in Settings."; + case RAC_ERROR_TIMEOUT: + return "Try again or check your connection."; + case RAC_ERROR_INVALID_API_KEY: + return "Verify your API key is correct."; + case RAC_ERROR_CANCELLED: + return nullptr; // Expected, no suggestion + default: + return nullptr; + } +} + +rac_bool_t rac_error_is_expected_error(const rac_error_t* error) { + if (!error) + return RAC_FALSE; + return rac_error_is_expected(error->code); +} + +// ============================================================================= +// SERIALIZATION +// ============================================================================= + +char* rac_error_to_json(const rac_error_t* error) { + if (!error) + return nullptr; + + // Allocate buffer for JSON + size_t buffer_size = 4096; + char* json = static_cast(malloc(buffer_size)); + if (!json) + return nullptr; + + int pos = 0; + pos += snprintf(json + pos, buffer_size - pos, "{"); + // NOLINTNEXTLINE(modernize-raw-string-literal) + pos += snprintf(json + pos, buffer_size - pos, "\"code\":%d,", error->code); + // NOLINTNEXTLINE(modernize-raw-string-literal) + pos += snprintf(json + pos, buffer_size - pos, "\"code_name\":\"%s\",", + rac_error_code_name(error->code)); + // NOLINTNEXTLINE(modernize-raw-string-literal) + pos += snprintf(json + pos, buffer_size - pos, "\"category\":\"%s\",", + rac_error_category_name(error->category)); + + // Escape message for JSON + // NOLINTNEXTLINE(modernize-raw-string-literal) + pos += snprintf(json + pos, buffer_size - pos, "\"message\":\""); + for (const char* p = error->message; *p != '\0' && pos < (int)buffer_size - 10; p++) { + if (*p == '"' || *p == '\\') { + json[pos++] = '\\'; + } + json[pos++] = *p; + } + // NOLINTNEXTLINE(modernize-raw-string-literal) + pos += snprintf(json + pos, buffer_size - pos, "\","); + + // NOLINTNEXTLINE(modernize-raw-string-literal) + pos += snprintf(json + pos, buffer_size - pos, "\"timestamp_ms\":%lld,", + static_cast(error->timestamp_ms)); + + // Source location + if (error->source_file[0] != '\0') { + // NOLINTNEXTLINE(modernize-raw-string-literal) + pos += snprintf(json + pos, buffer_size - pos, "\"source_file\":\"%s\",\"source_line\":%d,", + error->source_file, error->source_line); + } + if (error->source_function[0] != '\0') { + // NOLINTNEXTLINE(modernize-raw-string-literal) + pos += snprintf(json + pos, buffer_size - pos, "\"source_function\":\"%s\",", + error->source_function); + } + + // Model context + if (error->model_id[0] != '\0') { + // NOLINTNEXTLINE(modernize-raw-string-literal) + pos += snprintf(json + pos, buffer_size - pos, "\"model_id\":\"%s\",", error->model_id); + } + if (error->framework[0] != '\0') { + // NOLINTNEXTLINE(modernize-raw-string-literal) + pos += snprintf(json + pos, buffer_size - pos, "\"framework\":\"%s\",", error->framework); + } + if (error->session_id[0] != '\0') { + // NOLINTNEXTLINE(modernize-raw-string-literal) + pos += snprintf(json + pos, buffer_size - pos, "\"session_id\":\"%s\",", error->session_id); + } + + // Underlying error + if (error->underlying_code != 0) { + pos += snprintf( + json + pos, buffer_size - pos, + "\"underlying_code\":%d,\"underlying_message\":\"%s\",", // NOLINT(modernize-raw-string-literal) + error->underlying_code, error->underlying_message); + } + + // Stack trace + if (error->stack_frame_count > 0) { + // NOLINTNEXTLINE(modernize-raw-string-literal) + pos += snprintf(json + pos, buffer_size - pos, "\"stack_frame_count\":%d,", + error->stack_frame_count); + } + + // Custom metadata + if (error->custom_key1[0] != '\0' && error->custom_value1[0] != '\0') { + // NOLINTNEXTLINE(modernize-raw-string-literal) + pos += snprintf(json + pos, buffer_size - pos, "\"%s\":\"%s\",", error->custom_key1, + error->custom_value1); + } + if (error->custom_key2[0] != '\0' && error->custom_value2[0] != '\0') { + // NOLINTNEXTLINE(modernize-raw-string-literal) + pos += snprintf(json + pos, buffer_size - pos, "\"%s\":\"%s\",", error->custom_key2, + error->custom_value2); + } + if (error->custom_key3[0] != '\0' && error->custom_value3[0] != '\0') { + // NOLINTNEXTLINE(modernize-raw-string-literal) + pos += snprintf(json + pos, buffer_size - pos, "\"%s\":\"%s\",", error->custom_key3, + error->custom_value3); + } + + // Remove trailing comma and close + if (json[pos - 1] == ',') + pos--; + json[pos++] = '}'; + json[pos] = '\0'; + + return json; +} + +int32_t rac_error_get_telemetry_properties(const rac_error_t* error, char** out_keys, + char** out_values) { + if (!error || !out_keys || !out_values) + return 0; + + int32_t count = 0; + + // Error code + out_keys[count] = strdup("error_code"); + out_values[count] = strdup(rac_error_code_name(error->code)); + count++; + + // Error category + out_keys[count] = strdup("error_category"); + out_values[count] = strdup(rac_error_category_name(error->category)); + count++; + + // Error message + out_keys[count] = strdup("error_message"); + out_values[count] = strdup(error->message); + count++; + + return count; +} + +char* rac_error_to_string(const rac_error_t* error) { + if (!error) + return nullptr; + + size_t size = 512; + char* str = static_cast(malloc(size)); + if (!str) + return nullptr; + + snprintf(str, size, "SDKError[%s.%s]: %s", rac_error_category_name(error->category), + rac_error_code_name(error->code), error->message); + + return str; +} + +char* rac_error_to_debug_string(const rac_error_t* error) { + if (!error) + return nullptr; + + size_t size = 2048; + char* str = static_cast(malloc(size)); + if (!str) + return nullptr; + + int pos = 0; + pos += snprintf(str + pos, size - pos, "SDKError[%s.%s]: %s", + rac_error_category_name(error->category), rac_error_code_name(error->code), + error->message); + + if (error->underlying_code != 0) { + pos += snprintf(str + pos, size - pos, "\n Caused by: %s (%d)", error->underlying_message, + error->underlying_code); + } + + if (error->source_file[0] != '\0') { + pos += snprintf(str + pos, size - pos, "\n At: %s:%d in %s", error->source_file, + error->source_line, error->source_function); + } + + if (error->model_id[0] != '\0') { + pos += snprintf(str + pos, size - pos, "\n Model: %s (%s)", error->model_id, + error->framework); + } + + if (error->stack_frame_count > 0) { + pos += snprintf(str + pos, size - pos, + "\n Stack trace (%d frames):", error->stack_frame_count); + for (int i = 0; i < error->stack_frame_count && i < 5 && pos < (int)size - 100; i++) { + if (error->stack_frames[i].function != nullptr) { + pos += snprintf( + str + pos, size - pos, "\n %s at %s:%d", error->stack_frames[i].function, + error->stack_frames[i].file != nullptr ? error->stack_frames[i].file : "?", + error->stack_frames[i].line); + } else if (error->stack_frames[i].address != nullptr) { + pos += snprintf(str + pos, size - pos, "\n %p", error->stack_frames[i].address); + } + } + } + + return str; +} + +// ============================================================================= +// GLOBAL ERROR +// ============================================================================= + +void rac_set_last_error(const rac_error_t* error) { + if (error) { + memcpy(&g_last_error, error, sizeof(rac_error_t)); + g_has_last_error = true; + } else { + rac_clear_last_error(); + } +} + +const rac_error_t* rac_get_last_error(void) { + return g_has_last_error ? &g_last_error : nullptr; +} + +void rac_clear_last_error(void) { + memset(&g_last_error, 0, sizeof(rac_error_t)); + g_has_last_error = false; +} + +rac_result_t rac_set_error(rac_result_t code, rac_error_category_t category, const char* message) { + rac_error_t* error = rac_error_create(code, category, message); + if (error) { + // Log the error + if (rac_error_is_expected(code) == 0) { + RAC_LOG_ERROR(rac_error_category_name(category), "%s (code: %d)", message, code); + } + + rac_set_last_error(error); + rac_error_destroy(error); + } + return code; +} + +// ============================================================================= +// UNIFIED ERROR HANDLING +// ============================================================================= + +rac_result_t rac_error_log_and_track(rac_result_t code, rac_error_category_t category, + const char* message, const char* file, int32_t line, + const char* function) { + // Create structured error with source location + rac_error_t* error = rac_error_create_at(code, category, message, file, line, function); + if (!error) { + return code; + } + + // Capture stack trace + rac_error_capture_stack_trace(error); + + // Set as last error + rac_set_last_error(error); + + // Skip logging and tracking for expected errors (cancellation, etc.) + if (rac_error_is_expected(code) != 0) { + rac_error_destroy(error); + return code; + } + + // Log the error + rac_log_metadata_t meta = RAC_LOG_METADATA_EMPTY; + meta.file = file; + meta.line = line; + meta.function = function; + meta.error_code = code; + rac_logger_log(RAC_LOG_ERROR, rac_error_category_name(category), message, &meta); + + // Track error via platform adapter (for Sentry) + const rac_platform_adapter_t* adapter = rac_get_platform_adapter(); + if (adapter && adapter->track_error) { + char* json = rac_error_to_json(error); + if (json) { + adapter->track_error(json, adapter->user_data); + rac_free(json); + } + } + + rac_error_destroy(error); + return code; +} + +rac_result_t rac_error_log_and_track_model(rac_result_t code, rac_error_category_t category, + const char* message, const char* model_id, + const char* framework, const char* file, int32_t line, + const char* function) { + // Create structured error with source location + rac_error_t* error = rac_error_create_at(code, category, message, file, line, function); + if (!error) { + return code; + } + + // Add model context + rac_error_set_model_context(error, model_id, framework); + + // Capture stack trace + rac_error_capture_stack_trace(error); + + // Set as last error + rac_set_last_error(error); + + // Skip logging and tracking for expected errors + if (rac_error_is_expected(code) != 0) { + rac_error_destroy(error); + return code; + } + + // Log the error with model context + rac_log_metadata_t meta = RAC_LOG_METADATA_EMPTY; + meta.file = file; + meta.line = line; + meta.function = function; + meta.error_code = code; + meta.model_id = model_id; + meta.framework = framework; + rac_logger_log(RAC_LOG_ERROR, rac_error_category_name(category), message, &meta); + + // Track error via platform adapter (for Sentry) + const rac_platform_adapter_t* adapter = rac_get_platform_adapter(); + if (adapter && adapter->track_error) { + char* json = rac_error_to_json(error); + if (json) { + adapter->track_error(json, adapter->user_data); + rac_free(json); + } + } + + rac_error_destroy(error); + return code; +} + +} // extern "C" diff --git a/sdk/runanywhere-commons/src/core/rac_time.cpp b/sdk/runanywhere-commons/src/core/rac_time.cpp new file mode 100644 index 000000000..fdb2fa1b4 --- /dev/null +++ b/sdk/runanywhere-commons/src/core/rac_time.cpp @@ -0,0 +1,26 @@ +/** + * @file rac_time.cpp + * @brief RunAnywhere Commons - Time Utilities + */ + +#include + +#include "rac/core/rac_platform_adapter.h" +#include "rac/core/rac_types.h" + +extern "C" { + +int64_t rac_get_current_time_ms(void) { + // First try platform adapter if available + const rac_platform_adapter_t* adapter = rac_get_platform_adapter(); + if (adapter != nullptr && adapter->now_ms != nullptr) { + return adapter->now_ms(adapter->user_data); + } + + // Fallback to system clock + auto now = std::chrono::system_clock::now(); + auto duration = now.time_since_epoch(); + return std::chrono::duration_cast(duration).count(); +} + +} // extern "C" diff --git a/sdk/runanywhere-commons/src/core/sdk_state.cpp b/sdk/runanywhere-commons/src/core/sdk_state.cpp new file mode 100644 index 000000000..d3c489c72 --- /dev/null +++ b/sdk/runanywhere-commons/src/core/sdk_state.cpp @@ -0,0 +1,448 @@ +/** + * @file sdk_state.cpp + * @brief Implementation of centralized SDK state management + * + * C++ implementation using: + * - Meyer's Singleton for thread-safe lazy initialization + * - std::mutex for thread-safe state access + * - std::string for automatic memory management + * - std::optional for nullable values + */ + +#include +#include +#include +#include +#include + +#include "rac/core/rac_error.h" +#include "rac/core/rac_logger.h" +#include "rac/core/rac_sdk_state.h" + +// ============================================================================= +// Internal C++ State Class +// ============================================================================= + +class SDKState { + public: + // Singleton access (Meyer's Singleton - thread-safe in C++11) + static SDKState& instance() { + static SDKState instance; + return instance; + } + + // Delete copy/move constructors + SDKState(const SDKState&) = delete; + SDKState& operator=(const SDKState&) = delete; + + // ========================================================================== + // Initialization + // ========================================================================== + + rac_result_t initialize(rac_environment_t env, const char* api_key, const char* base_url, + const char* device_id) { + std::lock_guard lock(mutex_); + + environment_ = env; + api_key_ = api_key ? api_key : ""; + base_url_ = base_url ? base_url : ""; + device_id_ = device_id ? device_id : ""; + is_initialized_ = true; + + return RAC_SUCCESS; + } + + bool isInitialized() const { + std::lock_guard lock(mutex_); + return is_initialized_; + } + + void reset() { + std::lock_guard lock(mutex_); + + // Clear auth state + access_token_.reset(); + refresh_token_.reset(); + token_expires_at_ = 0; + user_id_.reset(); + organization_id_.reset(); + is_authenticated_ = false; + + // Clear device state + is_device_registered_ = false; + + // Keep environment config (or clear it too for full reset) + // is_initialized_ = false; + // environment_ = RAC_ENV_DEVELOPMENT; + // api_key_.clear(); + // base_url_.clear(); + // device_id_.clear(); + } + + void shutdown() { + std::lock_guard lock(mutex_); + + // Clear everything + access_token_.reset(); + refresh_token_.reset(); + token_expires_at_ = 0; + user_id_.reset(); + organization_id_.reset(); + is_authenticated_ = false; + is_device_registered_ = false; + is_initialized_ = false; + environment_ = RAC_ENV_DEVELOPMENT; + api_key_.clear(); + base_url_.clear(); + device_id_.clear(); + + // Clear callbacks + auth_changed_callback_ = nullptr; + auth_changed_user_data_ = nullptr; + persist_callback_ = nullptr; + load_callback_ = nullptr; + persistence_user_data_ = nullptr; + } + + // ========================================================================== + // Environment Queries + // ========================================================================== + + rac_environment_t getEnvironment() const { + std::lock_guard lock(mutex_); + return environment_; + } + + const char* getBaseUrl() const { + std::lock_guard lock(mutex_); + return base_url_.c_str(); + } + + const char* getApiKey() const { + std::lock_guard lock(mutex_); + return api_key_.c_str(); + } + + const char* getDeviceId() const { + std::lock_guard lock(mutex_); + return device_id_.c_str(); + } + + // ========================================================================== + // Auth State + // ========================================================================== + + rac_result_t setAuth(const rac_auth_data_t* auth) { + if (!auth) + return RAC_ERROR_INVALID_ARGUMENT; + + bool was_authenticated; + { + std::lock_guard lock(mutex_); + was_authenticated = is_authenticated_; + + access_token_ = auth->access_token ? auth->access_token : ""; + refresh_token_ = auth->refresh_token ? std::optional(auth->refresh_token) + : std::nullopt; + token_expires_at_ = auth->expires_at_unix; + user_id_ = auth->user_id ? std::optional(auth->user_id) : std::nullopt; + organization_id_ = auth->organization_id + ? std::optional(auth->organization_id) + : std::nullopt; + + if (auth->device_id && strlen(auth->device_id) > 0) { + device_id_ = auth->device_id; + } + + is_authenticated_ = true; + } + + // Notify callback outside of lock + notifyAuthChanged(true); + + // Persist to secure storage if callback registered + persistAuth(); + + return RAC_SUCCESS; + } + + const char* getAccessToken() const { + std::lock_guard lock(mutex_); + if (!access_token_.has_value() || access_token_->empty()) { + return nullptr; + } + return access_token_->c_str(); + } + + const char* getRefreshToken() const { + std::lock_guard lock(mutex_); + if (!refresh_token_.has_value()) { + return nullptr; + } + return refresh_token_->c_str(); + } + + bool isAuthenticated() const { + std::lock_guard lock(mutex_); + if (!is_authenticated_ || !access_token_.has_value()) { + return false; + } + // Check if token is expired + if (token_expires_at_ > 0) { + int64_t now = static_cast(std::time(nullptr)); + if (now >= token_expires_at_) { + return false; + } + } + return true; + } + + bool tokenNeedsRefresh() const { + std::lock_guard lock(mutex_); + if (!is_authenticated_ || token_expires_at_ == 0) { + return false; + } + int64_t now = static_cast(std::time(nullptr)); + // Refresh if expires within 60 seconds + return (token_expires_at_ - now) <= 60; + } + + int64_t getTokenExpiresAt() const { + std::lock_guard lock(mutex_); + return token_expires_at_; + } + + const char* getUserId() const { + std::lock_guard lock(mutex_); + if (!user_id_.has_value()) { + return nullptr; + } + return user_id_->c_str(); + } + + const char* getOrganizationId() const { + std::lock_guard lock(mutex_); + if (!organization_id_.has_value()) { + return nullptr; + } + return organization_id_->c_str(); + } + + void clearAuth() { + { + std::lock_guard lock(mutex_); + access_token_.reset(); + refresh_token_.reset(); + token_expires_at_ = 0; + user_id_.reset(); + organization_id_.reset(); + is_authenticated_ = false; + } + + notifyAuthChanged(false); + + // Clear from secure storage + if (persist_callback_) { + persist_callback_("access_token", nullptr, persistence_user_data_); + persist_callback_("refresh_token", nullptr, persistence_user_data_); + } + } + + // ========================================================================== + // Device State + // ========================================================================== + + void setDeviceRegistered(bool registered) { + std::lock_guard lock(mutex_); + is_device_registered_ = registered; + } + + bool isDeviceRegistered() const { + std::lock_guard lock(mutex_); + return is_device_registered_; + } + + // ========================================================================== + // Callbacks + // ========================================================================== + + void setAuthChangedCallback(rac_auth_changed_callback_t callback, void* user_data) { + std::lock_guard lock(mutex_); + auth_changed_callback_ = callback; + auth_changed_user_data_ = user_data; + } + + void setPersistenceCallbacks(rac_persist_callback_t persist, rac_load_callback_t load, + void* user_data) { + std::lock_guard lock(mutex_); + persist_callback_ = persist; + load_callback_ = load; + persistence_user_data_ = user_data; + } + + private: + SDKState() = default; + ~SDKState() = default; + + void notifyAuthChanged(bool is_authenticated) { + rac_auth_changed_callback_t callback; + void* user_data; + { + std::lock_guard lock(mutex_); + callback = auth_changed_callback_; + user_data = auth_changed_user_data_; + } + if (callback) { + callback(is_authenticated, user_data); + } + } + + void persistAuth() { + rac_persist_callback_t callback; + void* user_data; + std::string access, refresh; + { + std::lock_guard lock(mutex_); + callback = persist_callback_; + user_data = persistence_user_data_; + if (access_token_.has_value()) + access = *access_token_; + if (refresh_token_.has_value()) + refresh = *refresh_token_; + } + if (callback) { + if (!access.empty()) { + callback("access_token", access.c_str(), user_data); + } + if (!refresh.empty()) { + callback("refresh_token", refresh.c_str(), user_data); + } + } + } + + // State + mutable std::mutex mutex_; + bool is_initialized_ = false; + + // Environment + rac_environment_t environment_ = RAC_ENV_DEVELOPMENT; + std::string api_key_; + std::string base_url_; + std::string device_id_; + + // Auth + std::optional access_token_; + std::optional refresh_token_; + int64_t token_expires_at_ = 0; + std::optional user_id_; + std::optional organization_id_; + bool is_authenticated_ = false; + + // Device + bool is_device_registered_ = false; + + // Callbacks + rac_auth_changed_callback_t auth_changed_callback_ = nullptr; + void* auth_changed_user_data_ = nullptr; + rac_persist_callback_t persist_callback_ = nullptr; + rac_load_callback_t load_callback_ = nullptr; + void* persistence_user_data_ = nullptr; +}; + +// ============================================================================= +// C API Implementation +// ============================================================================= + +extern "C" { + +rac_sdk_state_handle_t rac_state_get_instance(void) { + return reinterpret_cast(&SDKState::instance()); +} + +rac_result_t rac_state_initialize(rac_environment_t env, const char* api_key, const char* base_url, + const char* device_id) { + return SDKState::instance().initialize(env, api_key, base_url, device_id); +} + +bool rac_state_is_initialized(void) { + return SDKState::instance().isInitialized(); +} + +void rac_state_reset(void) { + SDKState::instance().reset(); +} + +void rac_state_shutdown(void) { + SDKState::instance().shutdown(); +} + +rac_environment_t rac_state_get_environment(void) { + return SDKState::instance().getEnvironment(); +} + +const char* rac_state_get_base_url(void) { + return SDKState::instance().getBaseUrl(); +} + +const char* rac_state_get_api_key(void) { + return SDKState::instance().getApiKey(); +} + +const char* rac_state_get_device_id(void) { + return SDKState::instance().getDeviceId(); +} + +rac_result_t rac_state_set_auth(const rac_auth_data_t* auth) { + return SDKState::instance().setAuth(auth); +} + +const char* rac_state_get_access_token(void) { + return SDKState::instance().getAccessToken(); +} + +const char* rac_state_get_refresh_token(void) { + return SDKState::instance().getRefreshToken(); +} + +bool rac_state_is_authenticated(void) { + return SDKState::instance().isAuthenticated(); +} + +bool rac_state_token_needs_refresh(void) { + return SDKState::instance().tokenNeedsRefresh(); +} + +int64_t rac_state_get_token_expires_at(void) { + return SDKState::instance().getTokenExpiresAt(); +} + +const char* rac_state_get_user_id(void) { + return SDKState::instance().getUserId(); +} + +const char* rac_state_get_organization_id(void) { + return SDKState::instance().getOrganizationId(); +} + +void rac_state_clear_auth(void) { + SDKState::instance().clearAuth(); +} + +void rac_state_set_device_registered(bool registered) { + SDKState::instance().setDeviceRegistered(registered); +} + +bool rac_state_is_device_registered(void) { + return SDKState::instance().isDeviceRegistered(); +} + +void rac_state_on_auth_changed(rac_auth_changed_callback_t callback, void* user_data) { + SDKState::instance().setAuthChangedCallback(callback, user_data); +} + +void rac_state_set_persistence_callbacks(rac_persist_callback_t persist, rac_load_callback_t load, + void* user_data) { + SDKState::instance().setPersistenceCallbacks(persist, load, user_data); +} + +} // extern "C" diff --git a/sdk/runanywhere-commons/src/features/llm/llm_analytics.cpp b/sdk/runanywhere-commons/src/features/llm/llm_analytics.cpp new file mode 100644 index 000000000..7a4d84a0a --- /dev/null +++ b/sdk/runanywhere-commons/src/features/llm/llm_analytics.cpp @@ -0,0 +1,400 @@ +/** + * @file llm_analytics.cpp + * @brief LLM Generation analytics service implementation + * + * 1:1 port of Swift's GenerationAnalyticsService.swift + * Swift Source: Sources/RunAnywhere/Features/LLM/Analytics/GenerationAnalyticsService.swift + */ + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "rac/core/rac_logger.h" +#include "rac/features/llm/rac_llm_analytics.h" + +// ============================================================================= +// INTERNAL TYPES - Mirrors Swift's GenerationTracker +// ============================================================================= + +namespace { + +struct GenerationTracker { + int64_t start_time_ms; + bool is_streaming; + rac_inference_framework_t framework; + std::string model_id; + float temperature; + bool has_temperature; + int32_t max_tokens; + bool has_max_tokens; + int32_t context_length; + bool has_context_length; + int64_t first_token_time_ms; + bool has_first_token_time; +}; + +int64_t get_current_time_ms() { + auto now = std::chrono::system_clock::now(); + auto duration = now.time_since_epoch(); + return std::chrono::duration_cast(duration).count(); +} + +std::string generate_uuid() { + static std::random_device rd; + static std::mt19937 gen(rd()); + static std::uniform_int_distribution<> dis(0, 15); + + std::stringstream ss; + ss << std::hex; + + for (int i = 0; i < 8; i++) + ss << dis(gen); + ss << "-"; + for (int i = 0; i < 4; i++) + ss << dis(gen); + ss << "-4"; // Version 4 UUID + for (int i = 0; i < 3; i++) + ss << dis(gen); + ss << "-"; + ss << (8 + dis(gen) % 4); // Variant + for (int i = 0; i < 3; i++) + ss << dis(gen); + ss << "-"; + for (int i = 0; i < 12; i++) + ss << dis(gen); + + return ss.str(); +} + +} // namespace + +// ============================================================================= +// LLM ANALYTICS SERVICE IMPLEMENTATION +// ============================================================================= + +struct rac_llm_analytics_s { + std::mutex mutex; + std::map active_generations; + + // Metrics - separated by mode (mirrors Swift) + int32_t total_generations; + int32_t streaming_generations; + int32_t non_streaming_generations; + double total_time_to_first_token_ms; + int32_t streaming_ttft_count; // Only count TTFT for streaming + double total_tokens_per_second; + int32_t total_input_tokens; + int32_t total_output_tokens; + int64_t start_time_ms; + int64_t last_event_time_ms; + bool has_last_event_time; + + rac_llm_analytics_s() + : total_generations(0), + streaming_generations(0), + non_streaming_generations(0), + total_time_to_first_token_ms(0), + streaming_ttft_count(0), + total_tokens_per_second(0), + total_input_tokens(0), + total_output_tokens(0), + start_time_ms(get_current_time_ms()), + last_event_time_ms(0), + has_last_event_time(false) {} +}; + +// ============================================================================= +// C API IMPLEMENTATION +// ============================================================================= + +extern "C" { + +rac_result_t rac_llm_analytics_create(rac_llm_analytics_handle_t* out_handle) { + if (!out_handle) { + return RAC_ERROR_INVALID_PARAMETER; + } + + try { + *out_handle = new rac_llm_analytics_s(); + log_info("LLM.Analytics", "LLM analytics service created"); + return RAC_SUCCESS; + } catch (...) { + return RAC_ERROR_OUT_OF_MEMORY; + } +} + +void rac_llm_analytics_destroy(rac_llm_analytics_handle_t handle) { + if (handle) { + delete handle; + log_info("LLM.Analytics", "LLM analytics service destroyed"); + } +} + +rac_result_t rac_llm_analytics_start_generation(rac_llm_analytics_handle_t handle, + const char* model_id, + rac_inference_framework_t framework, + const float* temperature, const int32_t* max_tokens, + const int32_t* context_length, + char** out_generation_id) { + if (!handle || !model_id || !out_generation_id) { + return RAC_ERROR_INVALID_PARAMETER; + } + + std::lock_guard lock(handle->mutex); + + std::string id = generate_uuid(); + + GenerationTracker tracker; + tracker.start_time_ms = get_current_time_ms(); + tracker.is_streaming = false; + tracker.framework = framework; + tracker.model_id = model_id; + tracker.has_temperature = temperature != nullptr; + tracker.temperature = temperature ? *temperature : 0.0f; + tracker.has_max_tokens = max_tokens != nullptr; + tracker.max_tokens = max_tokens ? *max_tokens : 0; + tracker.has_context_length = context_length != nullptr; + tracker.context_length = context_length ? *context_length : 0; + tracker.has_first_token_time = false; + tracker.first_token_time_ms = 0; + + handle->active_generations[id] = tracker; + + // Allocate and copy the ID for the caller + *out_generation_id = static_cast(malloc(id.size() + 1)); + if (!*out_generation_id) { + return RAC_ERROR_OUT_OF_MEMORY; + } + strcpy(*out_generation_id, id.c_str()); + + log_debug("LLM.Analytics", "Non-streaming generation started: %s", id.c_str()); + return RAC_SUCCESS; +} + +rac_result_t rac_llm_analytics_start_streaming_generation( + rac_llm_analytics_handle_t handle, const char* model_id, rac_inference_framework_t framework, + const float* temperature, const int32_t* max_tokens, const int32_t* context_length, + char** out_generation_id) { + if (!handle || !model_id || !out_generation_id) { + return RAC_ERROR_INVALID_PARAMETER; + } + + std::lock_guard lock(handle->mutex); + + std::string id = generate_uuid(); + + GenerationTracker tracker; + tracker.start_time_ms = get_current_time_ms(); + tracker.is_streaming = true; + tracker.framework = framework; + tracker.model_id = model_id; + tracker.has_temperature = temperature != nullptr; + tracker.temperature = temperature ? *temperature : 0.0f; + tracker.has_max_tokens = max_tokens != nullptr; + tracker.max_tokens = max_tokens ? *max_tokens : 0; + tracker.has_context_length = context_length != nullptr; + tracker.context_length = context_length ? *context_length : 0; + tracker.has_first_token_time = false; + tracker.first_token_time_ms = 0; + + handle->active_generations[id] = tracker; + + // Allocate and copy the ID for the caller + *out_generation_id = static_cast(malloc(id.size() + 1)); + if (!*out_generation_id) { + return RAC_ERROR_OUT_OF_MEMORY; + } + strcpy(*out_generation_id, id.c_str()); + + log_debug("LLM.Analytics", "Streaming generation started: %s", id.c_str()); + return RAC_SUCCESS; +} + +rac_result_t rac_llm_analytics_track_first_token(rac_llm_analytics_handle_t handle, + const char* generation_id) { + if (!handle || !generation_id) { + return RAC_ERROR_INVALID_PARAMETER; + } + + std::lock_guard lock(handle->mutex); + + auto it = handle->active_generations.find(generation_id); + if (it == handle->active_generations.end()) { + return RAC_ERROR_NOT_FOUND; + } + + GenerationTracker& tracker = it->second; + + // TTFT is only tracked for streaming generations + if (!tracker.is_streaming) { + return RAC_SUCCESS; // Silent ignore for non-streaming + } + + // Only record if not already recorded + if (tracker.has_first_token_time) { + return RAC_SUCCESS; + } + + tracker.first_token_time_ms = get_current_time_ms(); + tracker.has_first_token_time = true; + + double time_to_first_token_ms = + static_cast(tracker.first_token_time_ms - tracker.start_time_ms); + + log_debug("LLM.Analytics", "First token received for %s: %.1fms", generation_id, + time_to_first_token_ms); + + return RAC_SUCCESS; +} + +rac_result_t rac_llm_analytics_track_streaming_update(rac_llm_analytics_handle_t handle, + const char* generation_id, + int32_t tokens_generated) { + if (!handle || !generation_id) { + return RAC_ERROR_INVALID_PARAMETER; + } + + std::lock_guard lock(handle->mutex); + + auto it = handle->active_generations.find(generation_id); + if (it == handle->active_generations.end()) { + return RAC_ERROR_NOT_FOUND; + } + + // Only applicable for streaming generations + if (!it->second.is_streaming) { + return RAC_SUCCESS; + } + + // Event would be published here in full implementation + (void)tokens_generated; + + return RAC_SUCCESS; +} + +rac_result_t rac_llm_analytics_complete_generation(rac_llm_analytics_handle_t handle, + const char* generation_id, int32_t input_tokens, + int32_t output_tokens, const char* model_id) { + if (!handle || !generation_id || !model_id) { + return RAC_ERROR_INVALID_PARAMETER; + } + + std::lock_guard lock(handle->mutex); + + auto it = handle->active_generations.find(generation_id); + if (it == handle->active_generations.end()) { + return RAC_ERROR_NOT_FOUND; + } + + GenerationTracker tracker = it->second; + handle->active_generations.erase(it); + + int64_t end_time_ms = get_current_time_ms(); + double total_time_sec = static_cast(end_time_ms - tracker.start_time_ms) / 1000.0; + double tokens_per_second = + total_time_sec > 0 ? static_cast(output_tokens) / total_time_sec : 0; + + // Calculate TTFT for streaming generations + if (tracker.is_streaming && tracker.has_first_token_time) { + double ttft_ms = static_cast(tracker.first_token_time_ms - tracker.start_time_ms); + handle->total_time_to_first_token_ms += ttft_ms; + handle->streaming_ttft_count++; + } + + // Update metrics + handle->total_generations++; + if (tracker.is_streaming) { + handle->streaming_generations++; + } else { + handle->non_streaming_generations++; + } + handle->total_tokens_per_second += tokens_per_second; + handle->total_input_tokens += input_tokens; + handle->total_output_tokens += output_tokens; + handle->last_event_time_ms = end_time_ms; + handle->has_last_event_time = true; + + const char* mode_str = tracker.is_streaming ? "streaming" : "non-streaming"; + log_debug("LLM.Analytics", "Generation completed (%s): %s", mode_str, generation_id); + + return RAC_SUCCESS; +} + +rac_result_t rac_llm_analytics_track_generation_failed(rac_llm_analytics_handle_t handle, + const char* generation_id, + rac_result_t error_code, + const char* error_message) { + if (!handle || !generation_id) { + return RAC_ERROR_INVALID_PARAMETER; + } + + std::lock_guard lock(handle->mutex); + + handle->active_generations.erase(generation_id); + handle->last_event_time_ms = get_current_time_ms(); + handle->has_last_event_time = true; + + log_error("LLM.Analytics", "Generation failed %s: %d - %s", generation_id, error_code, + error_message ? error_message : ""); + + return RAC_SUCCESS; +} + +rac_result_t rac_llm_analytics_track_error(rac_llm_analytics_handle_t handle, + rac_result_t error_code, const char* error_message, + const char* operation, const char* model_id, + const char* generation_id) { + if (!handle) { + return RAC_ERROR_INVALID_PARAMETER; + } + + std::lock_guard lock(handle->mutex); + + handle->last_event_time_ms = get_current_time_ms(); + handle->has_last_event_time = true; + + log_error("LLM.Analytics", "LLM error in %s: %d - %s (model: %s, gen: %s)", + operation ? operation : "unknown", error_code, error_message ? error_message : "", + model_id ? model_id : "none", generation_id ? generation_id : "none"); + + return RAC_SUCCESS; +} + +rac_result_t rac_llm_analytics_get_metrics(rac_llm_analytics_handle_t handle, + rac_generation_metrics_t* out_metrics) { + if (!handle || !out_metrics) { + return RAC_ERROR_INVALID_PARAMETER; + } + + std::lock_guard lock(handle->mutex); + + out_metrics->total_generations = handle->total_generations; + out_metrics->streaming_generations = handle->streaming_generations; + out_metrics->non_streaming_generations = handle->non_streaming_generations; + out_metrics->start_time_ms = handle->start_time_ms; + out_metrics->last_event_time_ms = handle->has_last_event_time ? handle->last_event_time_ms : 0; + + // Average TTFT only counts streaming generations that had TTFT recorded + out_metrics->average_ttft_ms = handle->streaming_ttft_count > 0 + ? handle->total_time_to_first_token_ms / + static_cast(handle->streaming_ttft_count) + : 0; + + out_metrics->average_tokens_per_second = + handle->total_generations > 0 + ? handle->total_tokens_per_second / static_cast(handle->total_generations) + : 0; + + out_metrics->total_input_tokens = handle->total_input_tokens; + out_metrics->total_output_tokens = handle->total_output_tokens; + + return RAC_SUCCESS; +} + +} // extern "C" diff --git a/sdk/runanywhere-commons/src/features/llm/llm_component.cpp b/sdk/runanywhere-commons/src/features/llm/llm_component.cpp new file mode 100644 index 000000000..8ed5ca5cb --- /dev/null +++ b/sdk/runanywhere-commons/src/features/llm/llm_component.cpp @@ -0,0 +1,1002 @@ +/** + * @file llm_component.cpp + * @brief LLM Capability Component Implementation + * + * C++ port of Swift's LLMCapability.swift + * Swift Source: Sources/RunAnywhere/Features/LLM/LLMCapability.swift + * + * IMPORTANT: This is a direct translation of the Swift implementation. + * Do NOT add features not present in the Swift code. + */ + +#include +#include +#include +#include +#include + +#include "rac/core/capabilities/rac_lifecycle.h" +#include "rac/core/rac_analytics_events.h" +#include "rac/core/rac_benchmark.h" +#include "rac/core/rac_logger.h" +#include "rac/core/rac_platform_adapter.h" +#include "rac/core/rac_structured_error.h" +#include "rac/features/llm/rac_llm_component.h" +#include "rac/features/llm/rac_llm_service.h" +#include "rac/infrastructure/events/rac_events.h" + +// ============================================================================= +// INTERNAL STRUCTURES +// ============================================================================= + +/** + * Internal LLM component state. + * Mirrors Swift's LLMCapability actor state. + */ +struct rac_llm_component { + /** Lifecycle manager handle */ + rac_handle_t lifecycle; + + /** Current configuration */ + rac_llm_config_t config; + + /** Default generation options based on config */ + rac_llm_options_t default_options; + + /** Mutex for thread safety */ + std::mutex mtx; + + rac_llm_component() : lifecycle(nullptr) { + // Initialize with defaults - matches rac_llm_types.h rac_llm_config_t + config = RAC_LLM_CONFIG_DEFAULT; + + default_options = RAC_LLM_OPTIONS_DEFAULT; + } +}; + +// ============================================================================= +// HELPER FUNCTIONS +// ============================================================================= + +/** + * Simple token estimation (~4 chars per token). + * Mirrors Swift's token estimation in LLMCapability. + */ +static int32_t estimate_tokens(const char* text) { + if (!text) + return 1; + size_t len = strlen(text); + int32_t tokens = static_cast((len + 3) / 4); + return tokens > 0 ? tokens : 1; // Minimum 1 token +} + +/** + * Generate a unique ID for generation tracking. + */ +static std::string generate_unique_id() { + auto now = std::chrono::high_resolution_clock::now(); + auto epoch = now.time_since_epoch(); + auto ns = std::chrono::duration_cast(epoch).count(); + char buffer[64]; + snprintf(buffer, sizeof(buffer), "gen_%lld", static_cast(ns)); + return std::string(buffer); +} + +// ============================================================================= +// LIFECYCLE CALLBACKS +// ============================================================================= + +/** + * Service creation callback for lifecycle manager. + * Creates and initializes the LLM service. + */ +static rac_result_t llm_create_service(const char* model_id, void* user_data, + rac_handle_t* out_service) { + (void)user_data; + + RAC_LOG_INFO("LLM.Component", "Creating LLM service for model: %s", model_id ? model_id : ""); + + // Create LLM service + rac_result_t result = rac_llm_create(model_id, out_service); + if (result != RAC_SUCCESS) { + RAC_LOG_ERROR("LLM.Component", "Failed to create LLM service: %d", result); + return result; + } + + // Initialize with model path + result = rac_llm_initialize(*out_service, model_id); + if (result != RAC_SUCCESS) { + RAC_LOG_ERROR("LLM.Component", "Failed to initialize LLM service: %d", result); + rac_llm_destroy(*out_service); + *out_service = nullptr; + return result; + } + + RAC_LOG_INFO("LLM.Component", "LLM service created successfully"); + return RAC_SUCCESS; +} + +/** + * Service destruction callback for lifecycle manager. + * Cleans up the LLM service. + */ +static void llm_destroy_service(rac_handle_t service, void* user_data) { + (void)user_data; + + if (service) { + RAC_LOG_DEBUG("LLM.Component", "Destroying LLM service"); + rac_llm_cleanup(service); + rac_llm_destroy(service); + } +} + +// ============================================================================= +// LIFECYCLE API +// ============================================================================= + +extern "C" rac_result_t rac_llm_component_create(rac_handle_t* out_handle) { + if (!out_handle) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + auto* component = new (std::nothrow) rac_llm_component(); + if (!component) { + return RAC_ERROR_OUT_OF_MEMORY; + } + + // Create lifecycle manager + rac_lifecycle_config_t lifecycle_config = {}; + lifecycle_config.resource_type = RAC_RESOURCE_TYPE_LLM_MODEL; + lifecycle_config.logger_category = "LLM.Lifecycle"; + lifecycle_config.user_data = component; + + rac_result_t result = rac_lifecycle_create(&lifecycle_config, llm_create_service, + llm_destroy_service, &component->lifecycle); + + if (result != RAC_SUCCESS) { + delete component; + return result; + } + + *out_handle = reinterpret_cast(component); + + RAC_LOG_INFO("LLM.Component", "LLM component created"); + + return RAC_SUCCESS; +} + +extern "C" rac_result_t rac_llm_component_configure(rac_handle_t handle, + const rac_llm_config_t* config) { + if (!handle) + return RAC_ERROR_INVALID_HANDLE; + if (!config) + return RAC_ERROR_INVALID_ARGUMENT; + + auto* component = reinterpret_cast(handle); + std::lock_guard lock(component->mtx); + + // Copy configuration + // Mirrors Swift's: self.config = config + component->config = *config; + + // Update default options based on config + if (config->max_tokens > 0) { + component->default_options.max_tokens = config->max_tokens; + } + if (config->system_prompt) { + component->default_options.system_prompt = config->system_prompt; + } + + log_info("LLM.Component", "LLM component configured"); + + return RAC_SUCCESS; +} + +extern "C" rac_bool_t rac_llm_component_is_loaded(rac_handle_t handle) { + if (!handle) + return RAC_FALSE; + + auto* component = reinterpret_cast(handle); + return rac_lifecycle_is_loaded(component->lifecycle); +} + +extern "C" const char* rac_llm_component_get_model_id(rac_handle_t handle) { + if (!handle) + return nullptr; + + auto* component = reinterpret_cast(handle); + return rac_lifecycle_get_model_id(component->lifecycle); +} + +extern "C" void rac_llm_component_destroy(rac_handle_t handle) { + if (!handle) + return; + + auto* component = reinterpret_cast(handle); + + // Destroy lifecycle manager (will cleanup service if loaded) + if (component->lifecycle) { + rac_lifecycle_destroy(component->lifecycle); + } + + log_info("LLM.Component", "LLM component destroyed"); + + delete component; +} + +// ============================================================================= +// MODEL LIFECYCLE +// ============================================================================= + +extern "C" rac_result_t rac_llm_component_load_model(rac_handle_t handle, const char* model_path, + const char* model_id, const char* model_name) { + if (!handle) + return RAC_ERROR_INVALID_HANDLE; + + auto* component = reinterpret_cast(handle); + std::lock_guard lock(component->mtx); + + // Delegate to lifecycle manager with separate path, model_id, and model_name + rac_handle_t service = nullptr; + return rac_lifecycle_load(component->lifecycle, model_path, model_id, model_name, &service); +} + +extern "C" rac_result_t rac_llm_component_unload(rac_handle_t handle) { + if (!handle) + return RAC_ERROR_INVALID_HANDLE; + + auto* component = reinterpret_cast(handle); + std::lock_guard lock(component->mtx); + + return rac_lifecycle_unload(component->lifecycle); +} + +extern "C" rac_result_t rac_llm_component_cleanup(rac_handle_t handle) { + if (!handle) + return RAC_ERROR_INVALID_HANDLE; + + auto* component = reinterpret_cast(handle); + std::lock_guard lock(component->mtx); + + // Mirrors Swift's: await managedLifecycle.reset() + return rac_lifecycle_reset(component->lifecycle); +} + +// ============================================================================= +// GENERATION API +// ============================================================================= + +extern "C" rac_result_t rac_llm_component_generate(rac_handle_t handle, const char* prompt, + const rac_llm_options_t* options, + rac_llm_result_t* out_result) { + if (!handle) + return RAC_ERROR_INVALID_HANDLE; + if (!prompt) + return RAC_ERROR_INVALID_ARGUMENT; + if (!out_result) + return RAC_ERROR_INVALID_ARGUMENT; + + auto* component = reinterpret_cast(handle); + std::lock_guard lock(component->mtx); + + // Generate unique ID for this generation + std::string generation_id = generate_unique_id(); + + // Get model ID and name from lifecycle manager + const char* model_id = rac_lifecycle_get_model_id(component->lifecycle); + const char* model_name = rac_lifecycle_get_model_name(component->lifecycle); + + // Get service from lifecycle manager + rac_handle_t service = nullptr; + rac_result_t result = rac_lifecycle_require_service(component->lifecycle, &service); + if (result != RAC_SUCCESS) { + log_error("LLM.Component", "No model loaded - cannot generate"); + + // Emit generation failed event + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_LLM_GENERATION_FAILED; + event.data.llm_generation = RAC_ANALYTICS_LLM_GENERATION_DEFAULT; + event.data.llm_generation.generation_id = generation_id.c_str(); + event.data.llm_generation.model_id = model_id; + event.data.llm_generation.model_name = model_name; + event.data.llm_generation.error_code = result; + event.data.llm_generation.error_message = "No model loaded"; + rac_analytics_event_emit(RAC_EVENT_LLM_GENERATION_FAILED, &event); + + return result; + } + + // Use provided options or defaults + const rac_llm_options_t* effective_options = options ? options : &component->default_options; + + // Get service info for context_length + rac_llm_info_t service_info = {}; + int32_t context_length = 0; + if (rac_llm_get_info(service, &service_info) == RAC_SUCCESS) { + context_length = service_info.context_length; + } + + // Emit generation started event + { + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_LLM_GENERATION_STARTED; + event.data.llm_generation = RAC_ANALYTICS_LLM_GENERATION_DEFAULT; + event.data.llm_generation.generation_id = generation_id.c_str(); + event.data.llm_generation.model_id = model_id; + event.data.llm_generation.model_name = model_name; + event.data.llm_generation.is_streaming = RAC_FALSE; + event.data.llm_generation.framework = + static_cast(component->config.preferred_framework); + event.data.llm_generation.temperature = effective_options->temperature; + event.data.llm_generation.max_tokens = effective_options->max_tokens; + event.data.llm_generation.context_length = context_length; + rac_analytics_event_emit(RAC_EVENT_LLM_GENERATION_STARTED, &event); + } + + auto start_time = std::chrono::steady_clock::now(); + + // Perform generation + result = rac_llm_generate(service, prompt, effective_options, out_result); + + if (result != RAC_SUCCESS) { + log_error("LLM.Component", "Generation failed"); + rac_lifecycle_track_error(component->lifecycle, result, "generate"); + + // Emit generation failed event + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_LLM_GENERATION_FAILED; + event.data.llm_generation = RAC_ANALYTICS_LLM_GENERATION_DEFAULT; + event.data.llm_generation.generation_id = generation_id.c_str(); + event.data.llm_generation.model_id = model_id; + event.data.llm_generation.model_name = model_name; + event.data.llm_generation.error_code = result; + event.data.llm_generation.error_message = "Generation failed"; + rac_analytics_event_emit(RAC_EVENT_LLM_GENERATION_FAILED, &event); + + return result; + } + + auto end_time = std::chrono::steady_clock::now(); + auto duration = std::chrono::duration_cast(end_time - start_time); + int64_t total_time_ms = duration.count(); + + // Update result metrics + // Use actual token counts from backend if available, otherwise estimate + log_debug("LLM.Component", "Backend returned prompt_tokens=%d, completion_tokens=%d", + out_result->prompt_tokens, out_result->completion_tokens); + + if (out_result->prompt_tokens <= 0) { + out_result->prompt_tokens = estimate_tokens(prompt); + log_debug("LLM.Component", "Using estimated prompt_tokens=%d", out_result->prompt_tokens); + } + if (out_result->completion_tokens <= 0) { + out_result->completion_tokens = estimate_tokens(out_result->text); + log_debug("LLM.Component", "Using estimated completion_tokens=%d", + out_result->completion_tokens); + } + out_result->total_tokens = out_result->prompt_tokens + out_result->completion_tokens; + out_result->total_time_ms = total_time_ms; + out_result->time_to_first_token_ms = 0; // Non-streaming: no TTFT + + double tokens_per_second = 0.0; + if (total_time_ms > 0) { + tokens_per_second = static_cast(out_result->completion_tokens) / + (static_cast(total_time_ms) / 1000.0); + out_result->tokens_per_second = static_cast(tokens_per_second); + } + + log_info("LLM.Component", "Generation completed"); + + // Emit generation completed event + // Use estimated input_tokens for telemetry consistency across platforms + // (some backends return actual tokenized count including chat template, + // others return 0 - estimation ensures consistent user-facing metrics) + { + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_LLM_GENERATION_COMPLETED; + event.data.llm_generation.generation_id = generation_id.c_str(); + event.data.llm_generation.model_id = model_id; + event.data.llm_generation.model_name = model_name; + event.data.llm_generation.input_tokens = estimate_tokens(prompt); + event.data.llm_generation.output_tokens = out_result->completion_tokens; + event.data.llm_generation.duration_ms = static_cast(total_time_ms); + event.data.llm_generation.tokens_per_second = tokens_per_second; + event.data.llm_generation.is_streaming = RAC_FALSE; + event.data.llm_generation.time_to_first_token_ms = 0; + event.data.llm_generation.framework = + static_cast(component->config.preferred_framework); + event.data.llm_generation.temperature = effective_options->temperature; + event.data.llm_generation.max_tokens = effective_options->max_tokens; + event.data.llm_generation.context_length = context_length; + event.data.llm_generation.error_code = RAC_SUCCESS; + rac_analytics_event_emit(RAC_EVENT_LLM_GENERATION_COMPLETED, &event); + } + + return RAC_SUCCESS; +} + +extern "C" rac_bool_t rac_llm_component_supports_streaming(rac_handle_t handle) { + if (!handle) + return RAC_FALSE; + + auto* component = reinterpret_cast(handle); + std::lock_guard lock(component->mtx); + + rac_handle_t service = rac_lifecycle_get_service(component->lifecycle); + if (!service) { + return RAC_FALSE; + } + + rac_llm_info_t info; + rac_result_t result = rac_llm_get_info(service, &info); + if (result != RAC_SUCCESS) { + return RAC_FALSE; + } + + return info.supports_streaming; +} + +/** + * Internal structure for streaming context. + */ +struct llm_stream_context { + rac_llm_component_token_callback_fn token_callback; + rac_llm_component_complete_callback_fn complete_callback; + rac_llm_component_error_callback_fn error_callback; + void* user_data; + + // Metrics tracking + std::chrono::steady_clock::time_point start_time; + std::chrono::steady_clock::time_point first_token_time; + bool first_token_recorded; + std::string full_text; + int32_t prompt_tokens; + + // Analytics event data + std::string generation_id; + const char* model_id; + const char* model_name; + rac_inference_framework_t framework; + float temperature; + int32_t max_tokens; + int32_t token_count; // Track tokens for streaming updates + + // Benchmark timing (optional, NULL when not benchmarking) + rac_benchmark_timing_t* timing_out; +}; + +/** + * Internal token callback that wraps user callback and tracks metrics. + */ +static rac_bool_t llm_stream_token_callback(const char* token, void* user_data) { + auto* ctx = reinterpret_cast(user_data); + + // Track first token time and emit first token event + if (!ctx->first_token_recorded) { + ctx->first_token_recorded = true; + ctx->first_token_time = std::chrono::steady_clock::now(); + + // Record t4 (first token) for benchmark timing + if (ctx->timing_out != nullptr) { + ctx->timing_out->t4_first_token_ms = rac_monotonic_now_ms(); + } + + // Calculate TTFT + auto ttft_duration = std::chrono::duration_cast( + ctx->first_token_time - ctx->start_time); + double ttft_ms = static_cast(ttft_duration.count()); + + // Emit first token event + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_LLM_FIRST_TOKEN; + event.data.llm_generation = RAC_ANALYTICS_LLM_GENERATION_DEFAULT; + event.data.llm_generation.generation_id = ctx->generation_id.c_str(); + event.data.llm_generation.model_id = ctx->model_id; + event.data.llm_generation.model_name = ctx->model_name; + event.data.llm_generation.time_to_first_token_ms = ttft_ms; + event.data.llm_generation.framework = ctx->framework; + rac_analytics_event_emit(RAC_EVENT_LLM_FIRST_TOKEN, &event); + } + + // Accumulate text and track token count + if (token) { + ctx->full_text += token; + ctx->token_count++; + + // Emit streaming update event (every 10 tokens to avoid spam) + if (ctx->token_count % 10 == 0) { + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_LLM_STREAMING_UPDATE; + event.data.llm_generation = RAC_ANALYTICS_LLM_GENERATION_DEFAULT; + event.data.llm_generation.generation_id = ctx->generation_id.c_str(); + event.data.llm_generation.output_tokens = ctx->token_count; + rac_analytics_event_emit(RAC_EVENT_LLM_STREAMING_UPDATE, &event); + } + } + + // Call user callback + if (ctx->token_callback) { + return ctx->token_callback(token, ctx->user_data); + } + + return RAC_TRUE; // Continue by default +} + +extern "C" rac_result_t rac_llm_component_generate_stream( + rac_handle_t handle, const char* prompt, const rac_llm_options_t* options, + rac_llm_component_token_callback_fn token_callback, + rac_llm_component_complete_callback_fn complete_callback, + rac_llm_component_error_callback_fn error_callback, void* user_data) { + if (!handle) + return RAC_ERROR_INVALID_HANDLE; + if (!prompt) + return RAC_ERROR_INVALID_ARGUMENT; + + auto* component = reinterpret_cast(handle); + std::lock_guard lock(component->mtx); + + // Generate unique ID for this generation + std::string generation_id = generate_unique_id(); + const char* model_id = rac_lifecycle_get_model_id(component->lifecycle); + const char* model_name = rac_lifecycle_get_model_name(component->lifecycle); + + // Get service from lifecycle manager + rac_handle_t service = nullptr; + rac_result_t result = rac_lifecycle_require_service(component->lifecycle, &service); + if (result != RAC_SUCCESS) { + log_error("LLM.Component", "No model loaded - cannot generate stream"); + + // Emit generation failed event + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_LLM_GENERATION_FAILED; + event.data.llm_generation = RAC_ANALYTICS_LLM_GENERATION_DEFAULT; + event.data.llm_generation.generation_id = generation_id.c_str(); + event.data.llm_generation.model_id = model_id; + event.data.llm_generation.model_name = model_name; + event.data.llm_generation.error_code = result; + event.data.llm_generation.error_message = "No model loaded"; + rac_analytics_event_emit(RAC_EVENT_LLM_GENERATION_FAILED, &event); + + if (error_callback) { + error_callback(result, "No model loaded", user_data); + } + return result; + } + + // Check if streaming is supported + rac_llm_info_t info; + result = rac_llm_get_info(service, &info); + if (result != RAC_SUCCESS || (info.supports_streaming == 0)) { + log_error("LLM.Component", "Streaming not supported"); + + // Emit generation failed event + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_LLM_GENERATION_FAILED; + event.data.llm_generation = RAC_ANALYTICS_LLM_GENERATION_DEFAULT; + event.data.llm_generation.generation_id = generation_id.c_str(); + event.data.llm_generation.model_id = model_id; + event.data.llm_generation.model_name = model_name; + event.data.llm_generation.error_code = RAC_ERROR_NOT_SUPPORTED; + event.data.llm_generation.error_message = "Streaming not supported"; + rac_analytics_event_emit(RAC_EVENT_LLM_GENERATION_FAILED, &event); + + if (error_callback) { + error_callback(RAC_ERROR_NOT_SUPPORTED, "Streaming not supported", user_data); + } + return RAC_ERROR_NOT_SUPPORTED; + } + + log_info("LLM.Component", "Starting streaming generation"); + + // Get context_length from service info + int32_t context_length = info.context_length; + + // Use provided options or defaults + const rac_llm_options_t* effective_options = options ? options : &component->default_options; + + // Emit generation started event + { + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_LLM_GENERATION_STARTED; + event.data.llm_generation = RAC_ANALYTICS_LLM_GENERATION_DEFAULT; + event.data.llm_generation.generation_id = generation_id.c_str(); + event.data.llm_generation.model_id = model_id; + event.data.llm_generation.model_name = model_name; + event.data.llm_generation.is_streaming = RAC_TRUE; + event.data.llm_generation.framework = + static_cast(component->config.preferred_framework); + event.data.llm_generation.temperature = effective_options->temperature; + event.data.llm_generation.max_tokens = effective_options->max_tokens; + event.data.llm_generation.context_length = context_length; + rac_analytics_event_emit(RAC_EVENT_LLM_GENERATION_STARTED, &event); + } + + // Setup streaming context + llm_stream_context ctx; + ctx.token_callback = token_callback; + ctx.complete_callback = complete_callback; + ctx.error_callback = error_callback; + ctx.user_data = user_data; + ctx.start_time = std::chrono::steady_clock::now(); + ctx.first_token_recorded = false; + ctx.prompt_tokens = estimate_tokens(prompt); + ctx.generation_id = generation_id; + ctx.model_id = model_id; + ctx.model_name = model_name; + ctx.framework = static_cast(component->config.preferred_framework); + ctx.temperature = effective_options->temperature; + ctx.max_tokens = effective_options->max_tokens; + ctx.token_count = 0; + ctx.timing_out = nullptr; // No benchmark timing for regular generate_stream + + // Perform streaming generation + result = rac_llm_generate_stream(service, prompt, effective_options, llm_stream_token_callback, + &ctx); + + if (result != RAC_SUCCESS) { + log_error("LLM.Component", "Streaming generation failed"); + rac_lifecycle_track_error(component->lifecycle, result, "generateStream"); + + // Emit generation failed event + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_LLM_GENERATION_FAILED; + event.data.llm_generation = RAC_ANALYTICS_LLM_GENERATION_DEFAULT; + event.data.llm_generation.generation_id = generation_id.c_str(); + event.data.llm_generation.model_id = model_id; + event.data.llm_generation.model_name = model_name; + event.data.llm_generation.error_code = result; + event.data.llm_generation.error_message = "Streaming generation failed"; + rac_analytics_event_emit(RAC_EVENT_LLM_GENERATION_FAILED, &event); + + if (error_callback) { + error_callback(result, "Streaming generation failed", user_data); + } + return result; + } + + // Build final result for completion callback + auto end_time = std::chrono::steady_clock::now(); + auto total_duration = + std::chrono::duration_cast(end_time - ctx.start_time); + int64_t total_time_ms = total_duration.count(); + + rac_llm_result_t final_result = {}; + final_result.text = strdup(ctx.full_text.c_str()); + final_result.prompt_tokens = ctx.prompt_tokens; + final_result.completion_tokens = estimate_tokens(ctx.full_text.c_str()); + final_result.total_tokens = final_result.prompt_tokens + final_result.completion_tokens; + final_result.total_time_ms = total_time_ms; + + double ttft_ms = 0.0; + // Calculate TTFT + if (ctx.first_token_recorded) { + auto ttft_duration = std::chrono::duration_cast( + ctx.first_token_time - ctx.start_time); + final_result.time_to_first_token_ms = ttft_duration.count(); + ttft_ms = static_cast(ttft_duration.count()); + } + + // Calculate tokens per second + double tokens_per_second = 0.0; + if (final_result.total_time_ms > 0) { + tokens_per_second = static_cast(final_result.completion_tokens) / + (static_cast(final_result.total_time_ms) / 1000.0); + final_result.tokens_per_second = static_cast(tokens_per_second); + } + + if (complete_callback) { + complete_callback(&final_result, user_data); + } + + // Emit generation completed event + { + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_LLM_GENERATION_COMPLETED; + event.data.llm_generation.generation_id = generation_id.c_str(); + event.data.llm_generation.model_id = model_id; + event.data.llm_generation.model_name = model_name; + event.data.llm_generation.input_tokens = final_result.prompt_tokens; + event.data.llm_generation.output_tokens = final_result.completion_tokens; + event.data.llm_generation.duration_ms = static_cast(total_time_ms); + event.data.llm_generation.tokens_per_second = tokens_per_second; + event.data.llm_generation.is_streaming = RAC_TRUE; + event.data.llm_generation.time_to_first_token_ms = ttft_ms; + event.data.llm_generation.framework = + static_cast(component->config.preferred_framework); + event.data.llm_generation.temperature = effective_options->temperature; + event.data.llm_generation.max_tokens = effective_options->max_tokens; + event.data.llm_generation.context_length = context_length; + event.data.llm_generation.error_code = RAC_SUCCESS; + rac_analytics_event_emit(RAC_EVENT_LLM_GENERATION_COMPLETED, &event); + } + + // Free the duplicated text + free(final_result.text); + + log_info("LLM.Component", "Streaming generation completed"); + + return RAC_SUCCESS; +} + +extern "C" rac_result_t rac_llm_component_generate_stream_with_timing( + rac_handle_t handle, const char* prompt, const rac_llm_options_t* options, + rac_llm_component_token_callback_fn token_callback, + rac_llm_component_complete_callback_fn complete_callback, + rac_llm_component_error_callback_fn error_callback, void* user_data, + rac_benchmark_timing_t* timing_out) { + if (!handle) + return RAC_ERROR_INVALID_HANDLE; + if (!prompt) + return RAC_ERROR_INVALID_ARGUMENT; + + auto* component = reinterpret_cast(handle); + std::lock_guard lock(component->mtx); + + // Initialize timing if provided + if (timing_out != nullptr) { + rac_benchmark_timing_init(timing_out); + // Record t0 (request start) - first thing after validation + timing_out->t0_request_start_ms = rac_monotonic_now_ms(); + } + + // Generate unique ID for this generation + std::string generation_id = generate_unique_id(); + const char* model_id = rac_lifecycle_get_model_id(component->lifecycle); + const char* model_name = rac_lifecycle_get_model_name(component->lifecycle); + + // Get service from lifecycle manager + rac_handle_t service = nullptr; + rac_result_t result = rac_lifecycle_require_service(component->lifecycle, &service); + if (result != RAC_SUCCESS) { + log_error("LLM.Component", "No model loaded - cannot generate stream"); + + // Emit generation failed event + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_LLM_GENERATION_FAILED; + event.data.llm_generation = RAC_ANALYTICS_LLM_GENERATION_DEFAULT; + event.data.llm_generation.generation_id = generation_id.c_str(); + event.data.llm_generation.model_id = model_id; + event.data.llm_generation.model_name = model_name; + event.data.llm_generation.error_code = result; + event.data.llm_generation.error_message = "No model loaded"; + rac_analytics_event_emit(RAC_EVENT_LLM_GENERATION_FAILED, &event); + + if (timing_out != nullptr) { + timing_out->status = RAC_BENCHMARK_STATUS_ERROR; + timing_out->error_code = result; + timing_out->t6_request_end_ms = rac_monotonic_now_ms(); + } + + if (error_callback) { + error_callback(result, "No model loaded", user_data); + } + return result; + } + + // Check if streaming is supported + rac_llm_info_t info; + result = rac_llm_get_info(service, &info); + if (result != RAC_SUCCESS || (info.supports_streaming == 0)) { + log_error("LLM.Component", "Streaming not supported"); + + // Emit generation failed event + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_LLM_GENERATION_FAILED; + event.data.llm_generation = RAC_ANALYTICS_LLM_GENERATION_DEFAULT; + event.data.llm_generation.generation_id = generation_id.c_str(); + event.data.llm_generation.model_id = model_id; + event.data.llm_generation.model_name = model_name; + event.data.llm_generation.error_code = RAC_ERROR_NOT_SUPPORTED; + event.data.llm_generation.error_message = "Streaming not supported"; + rac_analytics_event_emit(RAC_EVENT_LLM_GENERATION_FAILED, &event); + + if (timing_out != nullptr) { + timing_out->status = RAC_BENCHMARK_STATUS_ERROR; + timing_out->error_code = RAC_ERROR_NOT_SUPPORTED; + timing_out->t6_request_end_ms = rac_monotonic_now_ms(); + } + + if (error_callback) { + error_callback(RAC_ERROR_NOT_SUPPORTED, "Streaming not supported", user_data); + } + return RAC_ERROR_NOT_SUPPORTED; + } + + log_info("LLM.Component", "Starting streaming generation with timing"); + + // Get context_length from service info + int32_t context_length = info.context_length; + + // Use provided options or defaults + const rac_llm_options_t* effective_options = options ? options : &component->default_options; + + // Emit generation started event + { + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_LLM_GENERATION_STARTED; + event.data.llm_generation = RAC_ANALYTICS_LLM_GENERATION_DEFAULT; + event.data.llm_generation.generation_id = generation_id.c_str(); + event.data.llm_generation.model_id = model_id; + event.data.llm_generation.model_name = model_name; + event.data.llm_generation.is_streaming = RAC_TRUE; + event.data.llm_generation.framework = + static_cast(component->config.preferred_framework); + event.data.llm_generation.temperature = effective_options->temperature; + event.data.llm_generation.max_tokens = effective_options->max_tokens; + event.data.llm_generation.context_length = context_length; + rac_analytics_event_emit(RAC_EVENT_LLM_GENERATION_STARTED, &event); + } + + // Setup streaming context + llm_stream_context ctx; + ctx.token_callback = token_callback; + ctx.complete_callback = complete_callback; + ctx.error_callback = error_callback; + ctx.user_data = user_data; + ctx.start_time = std::chrono::steady_clock::now(); + ctx.first_token_recorded = false; + ctx.prompt_tokens = estimate_tokens(prompt); + ctx.generation_id = generation_id; + ctx.model_id = model_id; + ctx.model_name = model_name; + ctx.framework = static_cast(component->config.preferred_framework); + ctx.temperature = effective_options->temperature; + ctx.max_tokens = effective_options->max_tokens; + ctx.token_count = 0; + ctx.timing_out = timing_out; // Pass timing for t4 capture in callback + + // Perform streaming generation with timing + // Note: Backend timing (t2, t3, t5) will be captured if backend supports it + result = rac_llm_generate_stream_with_timing(service, prompt, effective_options, + llm_stream_token_callback, &ctx, timing_out); + + if (result != RAC_SUCCESS) { + log_error("LLM.Component", "Streaming generation failed"); + rac_lifecycle_track_error(component->lifecycle, result, "generateStream"); + + // Emit generation failed event + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_LLM_GENERATION_FAILED; + event.data.llm_generation = RAC_ANALYTICS_LLM_GENERATION_DEFAULT; + event.data.llm_generation.generation_id = generation_id.c_str(); + event.data.llm_generation.model_id = model_id; + event.data.llm_generation.model_name = model_name; + event.data.llm_generation.error_code = result; + event.data.llm_generation.error_message = "Streaming generation failed"; + rac_analytics_event_emit(RAC_EVENT_LLM_GENERATION_FAILED, &event); + + if (timing_out != nullptr) { + timing_out->status = RAC_BENCHMARK_STATUS_ERROR; + timing_out->error_code = result; + timing_out->t6_request_end_ms = rac_monotonic_now_ms(); + } + + if (error_callback) { + error_callback(result, "Streaming generation failed", user_data); + } + return result; + } + + // Build final result for completion callback + auto end_time = std::chrono::steady_clock::now(); + auto total_duration = + std::chrono::duration_cast(end_time - ctx.start_time); + int64_t total_time_ms = total_duration.count(); + + rac_llm_result_t final_result = {}; + final_result.text = strdup(ctx.full_text.c_str()); + + // Use actual backend token counts if available, fall back to estimates + if (timing_out != nullptr && timing_out->prompt_tokens > 0) { + final_result.prompt_tokens = timing_out->prompt_tokens; + } else { + final_result.prompt_tokens = ctx.prompt_tokens; + } + + if (timing_out != nullptr && timing_out->output_tokens > 0) { + final_result.completion_tokens = timing_out->output_tokens; + } else { + final_result.completion_tokens = estimate_tokens(ctx.full_text.c_str()); + } + + final_result.total_tokens = final_result.prompt_tokens + final_result.completion_tokens; + final_result.total_time_ms = total_time_ms; + + double ttft_ms = 0.0; + // Calculate TTFT + if (ctx.first_token_recorded) { + auto ttft_duration = std::chrono::duration_cast( + ctx.first_token_time - ctx.start_time); + final_result.time_to_first_token_ms = ttft_duration.count(); + ttft_ms = static_cast(ttft_duration.count()); + } + + // Calculate tokens per second + double tokens_per_second = 0.0; + if (final_result.total_time_ms > 0) { + tokens_per_second = static_cast(final_result.completion_tokens) / + (static_cast(final_result.total_time_ms) / 1000.0); + final_result.tokens_per_second = static_cast(tokens_per_second); + } + + // Record t6 (request end) before complete callback + if (timing_out != nullptr) { + timing_out->t6_request_end_ms = rac_monotonic_now_ms(); + // prompt_tokens and output_tokens already set by backend + timing_out->status = RAC_BENCHMARK_STATUS_SUCCESS; + timing_out->error_code = RAC_SUCCESS; + } + + if (complete_callback) { + complete_callback(&final_result, user_data); + } + + // Emit generation completed event + { + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_LLM_GENERATION_COMPLETED; + event.data.llm_generation.generation_id = generation_id.c_str(); + event.data.llm_generation.model_id = model_id; + event.data.llm_generation.model_name = model_name; + event.data.llm_generation.input_tokens = final_result.prompt_tokens; + event.data.llm_generation.output_tokens = final_result.completion_tokens; + event.data.llm_generation.duration_ms = static_cast(total_time_ms); + event.data.llm_generation.tokens_per_second = tokens_per_second; + event.data.llm_generation.is_streaming = RAC_TRUE; + event.data.llm_generation.time_to_first_token_ms = ttft_ms; + event.data.llm_generation.framework = + static_cast(component->config.preferred_framework); + event.data.llm_generation.temperature = effective_options->temperature; + event.data.llm_generation.max_tokens = effective_options->max_tokens; + event.data.llm_generation.context_length = context_length; + event.data.llm_generation.error_code = RAC_SUCCESS; + rac_analytics_event_emit(RAC_EVENT_LLM_GENERATION_COMPLETED, &event); + } + + // Free the duplicated text + free(final_result.text); + + log_info("LLM.Component", "Streaming generation with timing completed"); + + return RAC_SUCCESS; +} + +extern "C" rac_result_t rac_llm_component_cancel(rac_handle_t handle) { + if (!handle) + return RAC_ERROR_INVALID_HANDLE; + + auto* component = reinterpret_cast(handle); + std::lock_guard lock(component->mtx); + + rac_handle_t service = rac_lifecycle_get_service(component->lifecycle); + if (service) { + rac_llm_cancel(service); + } + + log_info("LLM.Component", "Generation cancellation requested"); + + return RAC_SUCCESS; +} + +// ============================================================================= +// STATE QUERY API +// ============================================================================= + +extern "C" rac_lifecycle_state_t rac_llm_component_get_state(rac_handle_t handle) { + if (!handle) + return RAC_LIFECYCLE_STATE_IDLE; + + auto* component = reinterpret_cast(handle); + return rac_lifecycle_get_state(component->lifecycle); +} + +extern "C" rac_result_t rac_llm_component_get_metrics(rac_handle_t handle, + rac_lifecycle_metrics_t* out_metrics) { + if (!handle) + return RAC_ERROR_INVALID_HANDLE; + if (!out_metrics) + return RAC_ERROR_INVALID_ARGUMENT; + + auto* component = reinterpret_cast(handle); + return rac_lifecycle_get_metrics(component->lifecycle, out_metrics); +} diff --git a/sdk/runanywhere-commons/src/features/llm/rac_llm_service.cpp b/sdk/runanywhere-commons/src/features/llm/rac_llm_service.cpp new file mode 100644 index 000000000..701163393 --- /dev/null +++ b/sdk/runanywhere-commons/src/features/llm/rac_llm_service.cpp @@ -0,0 +1,220 @@ +/** + * @file rac_llm_service.cpp + * @brief LLM Service - Generic API with VTable Dispatch + * + * Simple dispatch layer that routes calls through the service vtable. + * Each backend provides its own vtable when creating a service. + * No wrappers, no switch statements - just vtable calls. + */ + +#include "rac/features/llm/rac_llm_service.h" + +#include +#include +#include + +#include "rac/core/rac_core.h" +#include "rac/core/rac_logger.h" +#include "rac/infrastructure/model_management/rac_model_registry.h" + +static const char* LOG_CAT = "LLM.Service"; + +// ============================================================================= +// SERVICE CREATION - Routes through Service Registry +// ============================================================================= + +extern "C" { + +rac_result_t rac_llm_create(const char* model_id, rac_handle_t* out_handle) { + if (!model_id || !out_handle) { + return RAC_ERROR_NULL_POINTER; + } + + *out_handle = nullptr; + + RAC_LOG_INFO(LOG_CAT, "Creating LLM service for: %s", model_id); + + // Query model registry to get framework + rac_model_info_t* model_info = nullptr; + rac_result_t result = rac_get_model(model_id, &model_info); + + rac_inference_framework_t framework = RAC_FRAMEWORK_LLAMACPP; + const char* model_path = model_id; + + if (result == RAC_SUCCESS && model_info) { + framework = model_info->framework; + model_path = model_info->local_path ? model_info->local_path : model_id; + RAC_LOG_INFO(LOG_CAT, "Found model in registry: framework=%d, local_path=%s", + static_cast(framework), model_path ? model_path : "NULL"); + } else { + RAC_LOG_WARNING(LOG_CAT, + "Model NOT found in registry (result=%d), using default framework=%d", + result, static_cast(framework)); + } + + // Build service request + rac_service_request_t request = {}; + request.identifier = model_id; + request.capability = RAC_CAPABILITY_TEXT_GENERATION; + request.framework = framework; + request.model_path = model_path; + + RAC_LOG_INFO(LOG_CAT, "Service request: framework=%d, model_path=%s", + static_cast(request.framework), + request.model_path ? request.model_path : "NULL"); + + // Service registry returns an rac_llm_service_t* with vtable already set + result = rac_service_create(RAC_CAPABILITY_TEXT_GENERATION, &request, out_handle); + + if (model_info) { + rac_model_info_free(model_info); + } + + if (result != RAC_SUCCESS) { + RAC_LOG_ERROR(LOG_CAT, "Failed to create service via registry: %d", result); + return result; + } + + RAC_LOG_INFO(LOG_CAT, "LLM service created"); + return RAC_SUCCESS; +} + +// ============================================================================= +// GENERIC API - Simple vtable dispatch +// ============================================================================= + +rac_result_t rac_llm_initialize(rac_handle_t handle, const char* model_path) { + if (!handle) + return RAC_ERROR_NULL_POINTER; + + auto* service = static_cast(handle); + if (!service->ops || !service->ops->initialize) { + return RAC_ERROR_NOT_SUPPORTED; + } + + return service->ops->initialize(service->impl, model_path); +} + +rac_result_t rac_llm_generate(rac_handle_t handle, const char* prompt, + const rac_llm_options_t* options, rac_llm_result_t* out_result) { + if (!handle || !prompt || !out_result) + return RAC_ERROR_NULL_POINTER; + + auto* service = static_cast(handle); + if (!service->ops || !service->ops->generate) { + return RAC_ERROR_NOT_SUPPORTED; + } + + return service->ops->generate(service->impl, prompt, options, out_result); +} + +rac_result_t rac_llm_generate_stream(rac_handle_t handle, const char* prompt, + const rac_llm_options_t* options, + rac_llm_stream_callback_fn callback, void* user_data) { + if (!handle || !prompt || !callback) + return RAC_ERROR_NULL_POINTER; + + auto* service = static_cast(handle); + if (!service->ops || !service->ops->generate_stream) { + return RAC_ERROR_NOT_SUPPORTED; + } + + return service->ops->generate_stream(service->impl, prompt, options, callback, user_data); +} + +rac_result_t rac_llm_generate_stream_with_timing(rac_handle_t handle, const char* prompt, + const rac_llm_options_t* options, + rac_llm_stream_callback_fn callback, + void* user_data, + rac_benchmark_timing_t* timing_out) { + if (!handle || !prompt || !callback) + return RAC_ERROR_NULL_POINTER; + + auto* service = static_cast(handle); + if (!service->ops) { + return RAC_ERROR_NOT_SUPPORTED; + } + + // If backend implements timing-aware streaming, use it + if (service->ops->generate_stream_with_timing) { + return service->ops->generate_stream_with_timing(service->impl, prompt, options, callback, + user_data, timing_out); + } + + // Fallback to regular streaming for backends that don't implement timing. + // Backend timestamps (t2/t3/t5) will remain 0 from rac_benchmark_timing_init(). + // The component layer (llm_component.cpp) is responsible for setting t0/t4/t6 + // and the final status/error_code regardless of which path is taken here. + if (service->ops->generate_stream) { + return service->ops->generate_stream(service->impl, prompt, options, callback, user_data); + } + + return RAC_ERROR_NOT_SUPPORTED; +} + +rac_result_t rac_llm_get_info(rac_handle_t handle, rac_llm_info_t* out_info) { + if (!handle || !out_info) + return RAC_ERROR_NULL_POINTER; + + auto* service = static_cast(handle); + if (!service->ops || !service->ops->get_info) { + return RAC_ERROR_NOT_SUPPORTED; + } + + return service->ops->get_info(service->impl, out_info); +} + +rac_result_t rac_llm_cancel(rac_handle_t handle) { + if (!handle) + return RAC_ERROR_NULL_POINTER; + + auto* service = static_cast(handle); + if (!service->ops || !service->ops->cancel) { + return RAC_SUCCESS; // No-op if not supported + } + + return service->ops->cancel(service->impl); +} + +rac_result_t rac_llm_cleanup(rac_handle_t handle) { + if (!handle) + return RAC_ERROR_NULL_POINTER; + + auto* service = static_cast(handle); + if (!service->ops || !service->ops->cleanup) { + return RAC_SUCCESS; // No-op if not supported + } + + return service->ops->cleanup(service->impl); +} + +void rac_llm_destroy(rac_handle_t handle) { + if (!handle) + return; + + auto* service = static_cast(handle); + + // Call backend destroy + if (service->ops && service->ops->destroy) { + service->ops->destroy(service->impl); + } + + // Free model_id if allocated + if (service->model_id) { + free(const_cast(service->model_id)); + } + + // Free service struct + free(service); +} + +void rac_llm_result_free(rac_llm_result_t* result) { + if (!result) + return; + if (result->text) { + free(result->text); + result->text = nullptr; + } +} + +} // extern "C" diff --git a/sdk/runanywhere-commons/src/features/llm/streaming_metrics.cpp b/sdk/runanywhere-commons/src/features/llm/streaming_metrics.cpp new file mode 100644 index 000000000..22a7b6d59 --- /dev/null +++ b/sdk/runanywhere-commons/src/features/llm/streaming_metrics.cpp @@ -0,0 +1,540 @@ +/** + * @file streaming_metrics.cpp + * @brief RunAnywhere Commons - LLM Streaming Metrics Implementation + * + * C++ port of Swift's StreamingMetricsCollector and GenerationAnalyticsService. + * Swift Source: Sources/RunAnywhere/Features/LLM/LLMCapability.swift + * Swift Source: Sources/RunAnywhere/Features/LLM/Analytics/GenerationAnalyticsService.swift + * + * CRITICAL: This is a direct port of Swift implementation - do NOT add custom logic! + */ + +#include +#include +#include +#include +#include + +#include "rac/core/rac_logger.h" +#include "rac/core/rac_platform_adapter.h" +#include "rac/core/rac_structured_error.h" +#include "rac/features/llm/rac_llm_metrics.h" + +// Note: rac_strdup is declared in rac_types.h and implemented in rac_memory.cpp + +// ============================================================================= +// STREAMING METRICS COLLECTOR INTERNAL STRUCTURE +// ============================================================================= + +struct rac_streaming_metrics_collector { + // Configuration + std::string model_id{}; + std::string generation_id{}; + int32_t prompt_length{0}; + + // Timing + int64_t start_time_ms{0}; + int64_t first_token_time_ms{0}; + int64_t end_time_ms{0}; + + // State + std::string full_text{}; + int32_t token_count{0}; + bool first_token_recorded{false}; + bool is_complete{false}; + rac_result_t error_code{RAC_SUCCESS}; + + // Actual token counts from backend (0 = use estimation) + int32_t actual_input_tokens{0}; + int32_t actual_output_tokens{0}; + + // Thread safety + std::mutex mutex{}; + + rac_streaming_metrics_collector() = default; +}; + +// ============================================================================= +// GENERATION TRACKER (Internal) +// ============================================================================= + +struct GenerationTracker { + std::string model_id{}; + int64_t start_time_ms{0}; + int64_t first_token_time_ms{0}; + bool is_streaming{false}; + bool first_token_recorded{false}; + + GenerationTracker() = default; +}; + +// ============================================================================= +// GENERATION ANALYTICS SERVICE INTERNAL STRUCTURE +// ============================================================================= + +struct rac_generation_analytics { + // Active generations + std::map active_generations{}; + + // Aggregated metrics + int32_t total_generations{0}; + int32_t streaming_generations{0}; + int32_t non_streaming_generations{0}; + double total_tokens_per_second{0.0}; + double total_ttft_seconds{0.0}; + int32_t ttft_count{0}; + int64_t total_input_tokens{0}; + int64_t total_output_tokens{0}; + int64_t start_time_ms{0}; + int64_t last_event_time_ms{0}; + + // Thread safety + std::mutex mutex{}; + + rac_generation_analytics() { start_time_ms = rac_get_current_time_ms(); } +}; + +// ============================================================================= +// STREAMING METRICS COLLECTOR API +// ============================================================================= + +rac_result_t rac_streaming_metrics_create(const char* model_id, const char* generation_id, + int32_t prompt_length, + rac_streaming_metrics_handle_t* out_handle) { + if (!model_id || !generation_id || !out_handle) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + rac_streaming_metrics_collector* collector = new rac_streaming_metrics_collector(); + collector->model_id = model_id; + collector->generation_id = generation_id; + collector->prompt_length = prompt_length; + + *out_handle = collector; + return RAC_SUCCESS; +} + +void rac_streaming_metrics_destroy(rac_streaming_metrics_handle_t handle) { + if (handle) { + delete handle; + } +} + +rac_result_t rac_streaming_metrics_mark_start(rac_streaming_metrics_handle_t handle) { + if (!handle) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + handle->start_time_ms = rac_get_current_time_ms(); + return RAC_SUCCESS; +} + +rac_result_t rac_streaming_metrics_record_token(rac_streaming_metrics_handle_t handle, + const char* token) { + if (!handle || !token) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + + // Record first token time + if (!handle->first_token_recorded) { + handle->first_token_time_ms = rac_get_current_time_ms(); + handle->first_token_recorded = true; + } + + // Accumulate text and count + handle->full_text += token; + handle->token_count++; + + return RAC_SUCCESS; +} + +rac_result_t rac_streaming_metrics_mark_complete(rac_streaming_metrics_handle_t handle) { + if (!handle) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + handle->end_time_ms = rac_get_current_time_ms(); + handle->is_complete = true; + return RAC_SUCCESS; +} + +rac_result_t rac_streaming_metrics_mark_failed(rac_streaming_metrics_handle_t handle, + rac_result_t error_code) { + if (!handle) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + handle->end_time_ms = rac_get_current_time_ms(); + handle->is_complete = true; + handle->error_code = error_code; + return RAC_SUCCESS; +} + +rac_result_t rac_streaming_metrics_get_result(rac_streaming_metrics_handle_t handle, + rac_streaming_result_t* out_result) { + if (!handle || !out_result) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + + // Calculate latency + int64_t end_time = handle->end_time_ms > 0 ? handle->end_time_ms : rac_get_current_time_ms(); + double latency_ms = static_cast(end_time - handle->start_time_ms); + + // Calculate TTFT + double ttft_ms = 0.0; + if (handle->first_token_recorded && handle->start_time_ms > 0) { + ttft_ms = static_cast(handle->first_token_time_ms - handle->start_time_ms); + } + + // Use actual token counts from backend if available, otherwise estimate + int32_t input_tokens; + int32_t output_tokens; + + if (handle->actual_input_tokens > 0) { + input_tokens = handle->actual_input_tokens; + } else { + // Fallback: estimate ~4 chars per token + input_tokens = handle->prompt_length > 0 ? (handle->prompt_length / 4) : 1; + if (input_tokens < 1) + input_tokens = 1; + } + + if (handle->actual_output_tokens > 0) { + output_tokens = handle->actual_output_tokens; + } else { + // Fallback: estimate ~4 chars per token + output_tokens = static_cast(handle->full_text.length() / 4); + if (output_tokens < 1) + output_tokens = 1; + } + + // Tokens per second + double tokens_per_second = 0.0; + if (latency_ms > 0) { + tokens_per_second = static_cast(output_tokens) / (latency_ms / 1000.0); + } + + // Populate result + out_result->text = rac_strdup(handle->full_text.c_str()); + out_result->thinking_content = nullptr; + out_result->input_tokens = input_tokens; + out_result->output_tokens = output_tokens; + out_result->model_id = rac_strdup(handle->model_id.c_str()); + out_result->latency_ms = latency_ms; + out_result->tokens_per_second = tokens_per_second; + out_result->ttft_ms = ttft_ms; + out_result->thinking_tokens = 0; + out_result->response_tokens = output_tokens; + + return RAC_SUCCESS; +} + +rac_result_t rac_streaming_metrics_get_ttft(rac_streaming_metrics_handle_t handle, + double* out_ttft_ms) { + if (!handle || !out_ttft_ms) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + + if (!handle->first_token_recorded || handle->start_time_ms == 0) { + *out_ttft_ms = 0.0; + } else { + *out_ttft_ms = static_cast(handle->first_token_time_ms - handle->start_time_ms); + } + + return RAC_SUCCESS; +} + +rac_result_t rac_streaming_metrics_get_token_count(rac_streaming_metrics_handle_t handle, + int32_t* out_token_count) { + if (!handle || !out_token_count) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + *out_token_count = handle->token_count; + return RAC_SUCCESS; +} + +rac_result_t rac_streaming_metrics_get_text(rac_streaming_metrics_handle_t handle, + char** out_text) { + if (!handle || !out_text) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + *out_text = rac_strdup(handle->full_text.c_str()); + return *out_text ? RAC_SUCCESS : RAC_ERROR_OUT_OF_MEMORY; +} + +rac_result_t rac_streaming_metrics_set_token_counts(rac_streaming_metrics_handle_t handle, + int32_t input_tokens, int32_t output_tokens) { + if (!handle) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + handle->actual_input_tokens = input_tokens; + handle->actual_output_tokens = output_tokens; + return RAC_SUCCESS; +} + +// ============================================================================= +// GENERATION ANALYTICS SERVICE API +// ============================================================================= + +rac_result_t rac_generation_analytics_create(rac_generation_analytics_handle_t* out_handle) { + if (!out_handle) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + rac_generation_analytics* service = new rac_generation_analytics(); + + RAC_LOG_INFO("GenerationAnalytics", "Service created"); + + *out_handle = service; + return RAC_SUCCESS; +} + +void rac_generation_analytics_destroy(rac_generation_analytics_handle_t handle) { + if (handle) { + delete handle; + RAC_LOG_DEBUG("GenerationAnalytics", "Service destroyed"); + } +} + +rac_result_t rac_generation_analytics_start(rac_generation_analytics_handle_t handle, + const char* generation_id, const char* model_id) { + if (!handle || !generation_id || !model_id) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + + GenerationTracker tracker; + tracker.model_id = model_id; + tracker.start_time_ms = rac_get_current_time_ms(); + tracker.is_streaming = false; + tracker.first_token_recorded = false; + + handle->active_generations[generation_id] = tracker; + + return RAC_SUCCESS; +} + +rac_result_t rac_generation_analytics_start_streaming(rac_generation_analytics_handle_t handle, + const char* generation_id, + const char* model_id) { + if (!handle || !generation_id || !model_id) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + + GenerationTracker tracker; + tracker.model_id = model_id; + tracker.start_time_ms = rac_get_current_time_ms(); + tracker.is_streaming = true; + tracker.first_token_recorded = false; + + handle->active_generations[generation_id] = tracker; + + return RAC_SUCCESS; +} + +rac_result_t rac_generation_analytics_track_first_token(rac_generation_analytics_handle_t handle, + const char* generation_id) { + if (!handle || !generation_id) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + + auto it = handle->active_generations.find(generation_id); + if (it == handle->active_generations.end()) { + return RAC_ERROR_NOT_FOUND; + } + + GenerationTracker& tracker = it->second; + + // Only track for streaming, only once + if (!tracker.is_streaming || tracker.first_token_recorded) { + return RAC_SUCCESS; + } + + tracker.first_token_time_ms = rac_get_current_time_ms(); + tracker.first_token_recorded = true; + + return RAC_SUCCESS; +} + +rac_result_t rac_generation_analytics_track_streaming_update( + rac_generation_analytics_handle_t handle, const char* generation_id, int32_t tokens_generated) { + if (!handle || !generation_id) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + + auto it = handle->active_generations.find(generation_id); + if (it == handle->active_generations.end()) { + return RAC_ERROR_NOT_FOUND; + } + + // Update last event time + handle->last_event_time_ms = rac_get_current_time_ms(); + + // Events could be published here if needed + (void)tokens_generated; + + return RAC_SUCCESS; +} + +rac_result_t rac_generation_analytics_complete(rac_generation_analytics_handle_t handle, + const char* generation_id, int32_t input_tokens, + int32_t output_tokens, const char* model_id) { + if (!handle || !generation_id || !model_id) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + + auto it = handle->active_generations.find(generation_id); + if (it == handle->active_generations.end()) { + return RAC_ERROR_NOT_FOUND; + } + + GenerationTracker tracker = it->second; + handle->active_generations.erase(it); + + int64_t end_time = rac_get_current_time_ms(); + double total_time_seconds = static_cast(end_time - tracker.start_time_ms) / 1000.0; + double tokens_per_second = + total_time_seconds > 0 ? static_cast(output_tokens) / total_time_seconds : 0.0; + + // Calculate TTFT for streaming generations + if (tracker.is_streaming && tracker.first_token_recorded) { + double ttft_seconds = + static_cast(tracker.first_token_time_ms - tracker.start_time_ms) / 1000.0; + handle->total_ttft_seconds += ttft_seconds; + handle->ttft_count++; + } + + // Update aggregated metrics + handle->total_generations++; + if (tracker.is_streaming) { + handle->streaming_generations++; + } else { + handle->non_streaming_generations++; + } + handle->total_tokens_per_second += tokens_per_second; + handle->total_input_tokens += input_tokens; + handle->total_output_tokens += output_tokens; + handle->last_event_time_ms = end_time; + + return RAC_SUCCESS; +} + +rac_result_t rac_generation_analytics_track_failed(rac_generation_analytics_handle_t handle, + const char* generation_id, + rac_result_t error_code) { + if (!handle || !generation_id) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + + // Remove from active generations + handle->active_generations.erase(generation_id); + handle->last_event_time_ms = rac_get_current_time_ms(); + + (void)error_code; + + return RAC_SUCCESS; +} + +rac_result_t rac_generation_analytics_get_metrics(rac_generation_analytics_handle_t handle, + rac_generation_metrics_t* out_metrics) { + if (!handle || !out_metrics) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + + // Average TTFT only for streaming generations with TTFT recorded + double avg_ttft_ms = + handle->ttft_count > 0 + ? (handle->total_ttft_seconds / static_cast(handle->ttft_count)) * 1000.0 + : 0.0; + + // Average tokens per second + double avg_tps = + handle->total_generations > 0 + ? handle->total_tokens_per_second / static_cast(handle->total_generations) + : 0.0; + + out_metrics->total_generations = handle->total_generations; + out_metrics->streaming_generations = handle->streaming_generations; + out_metrics->non_streaming_generations = handle->non_streaming_generations; + out_metrics->average_ttft_ms = avg_ttft_ms; + out_metrics->average_tokens_per_second = avg_tps; + out_metrics->total_input_tokens = handle->total_input_tokens; + out_metrics->total_output_tokens = handle->total_output_tokens; + out_metrics->start_time_ms = handle->start_time_ms; + out_metrics->last_event_time_ms = handle->last_event_time_ms; + + return RAC_SUCCESS; +} + +rac_result_t rac_generation_analytics_reset(rac_generation_analytics_handle_t handle) { + if (!handle) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + + handle->active_generations.clear(); + handle->total_generations = 0; + handle->streaming_generations = 0; + handle->non_streaming_generations = 0; + handle->total_tokens_per_second = 0.0; + handle->total_ttft_seconds = 0.0; + handle->ttft_count = 0; + handle->total_input_tokens = 0; + handle->total_output_tokens = 0; + handle->start_time_ms = rac_get_current_time_ms(); + handle->last_event_time_ms = 0; + + return RAC_SUCCESS; +} + +// ============================================================================= +// MEMORY MANAGEMENT +// ============================================================================= + +void rac_streaming_result_free(rac_streaming_result_t* result) { + if (!result) { + return; + } + + if (result->text) { + free(result->text); + result->text = nullptr; + } + if (result->thinking_content) { + free(result->thinking_content); + result->thinking_content = nullptr; + } + if (result->model_id) { + free(result->model_id); + result->model_id = nullptr; + } +} diff --git a/sdk/runanywhere-commons/src/features/llm/structured_output.cpp b/sdk/runanywhere-commons/src/features/llm/structured_output.cpp new file mode 100644 index 000000000..b93702581 --- /dev/null +++ b/sdk/runanywhere-commons/src/features/llm/structured_output.cpp @@ -0,0 +1,504 @@ +/** + * @file structured_output.cpp + * @brief LLM Structured Output JSON Parsing Implementation + * + * C++ port of Swift's StructuredOutputHandler.swift from: + * Sources/RunAnywhere/Features/LLM/StructuredOutput/StructuredOutputHandler.swift + * + * IMPORTANT: This is a direct translation of the Swift implementation. + * Do NOT add features not present in the Swift code. + */ + +#include +#include +#include + +#include "rac/core/rac_logger.h" +#include "rac/core/rac_platform_adapter.h" +#include "rac/features/llm/rac_llm_structured_output.h" + +// ============================================================================= +// HELPER FUNCTIONS +// ============================================================================= + +/** + * @brief Trim whitespace from the beginning and end of a string + * + * @param str Input string + * @param out_start Output: Start position after leading whitespace + * @param out_end Output: End position before trailing whitespace (exclusive) + */ +static void trim_whitespace(const char* str, size_t* out_start, size_t* out_end) { + size_t len = strlen(str); + size_t start = 0; + size_t end = len; + + // Skip leading whitespace + while (start < len && + (str[start] == ' ' || str[start] == '\t' || str[start] == '\n' || str[start] == '\r')) { + start++; + } + + // Skip trailing whitespace + while (end > start && (str[end - 1] == ' ' || str[end - 1] == '\t' || str[end - 1] == '\n' || + str[end - 1] == '\r')) { + end--; + } + + *out_start = start; + *out_end = end; +} + +/** + * @brief Find the first occurrence of a character in a string starting from a position + * + * @param str Input string + * @param ch Character to find + * @param start_pos Starting position + * @param out_pos Output: Position of character if found + * @return true if found, false otherwise + */ +static bool find_char(const char* str, char ch, size_t start_pos, size_t* out_pos) { + size_t len = strlen(str); + for (size_t i = start_pos; i < len; i++) { + if (str[i] == ch) { + *out_pos = i; + return true; + } + } + return false; +} + +// ============================================================================= +// FIND MATCHING BRACE - Ported from Swift lines 179-212 +// ============================================================================= + +extern "C" rac_bool_t rac_structured_output_find_matching_brace(const char* text, size_t start_pos, + size_t* out_end_pos) { + if (!text || !out_end_pos) { + return RAC_FALSE; + } + + size_t len = strlen(text); + if (start_pos >= len || text[start_pos] != '{') { + return RAC_FALSE; + } + + int depth = 0; + bool in_string = false; + bool escaped = false; + + for (size_t i = start_pos; i < len; i++) { + char ch = text[i]; + + if (escaped) { + escaped = false; + continue; + } + + if (ch == '\\') { + escaped = true; + continue; + } + + if (ch == '"' && !escaped) { + in_string = !in_string; + continue; + } + + if (!in_string) { + if (ch == '{') { + depth++; + } else if (ch == '}') { + depth--; + if (depth == 0) { + *out_end_pos = i; + return RAC_TRUE; + } + } + } + } + + return RAC_FALSE; +} + +// ============================================================================= +// FIND MATCHING BRACKET - Ported from Swift lines 215-248 +// ============================================================================= + +extern "C" rac_bool_t rac_structured_output_find_matching_bracket(const char* text, + size_t start_pos, + size_t* out_end_pos) { + if (!text || !out_end_pos) { + return RAC_FALSE; + } + + size_t len = strlen(text); + if (start_pos >= len || text[start_pos] != '[') { + return RAC_FALSE; + } + + int depth = 0; + bool in_string = false; + bool escaped = false; + + for (size_t i = start_pos; i < len; i++) { + char ch = text[i]; + + if (escaped) { + escaped = false; + continue; + } + + if (ch == '\\') { + escaped = true; + continue; + } + + if (ch == '"' && !escaped) { + in_string = !in_string; + continue; + } + + if (!in_string) { + if (ch == '[') { + depth++; + } else if (ch == ']') { + depth--; + if (depth == 0) { + *out_end_pos = i; + return RAC_TRUE; + } + } + } + } + + return RAC_FALSE; +} + +// ============================================================================= +// FIND COMPLETE JSON - Ported from Swift lines 135-176 +// ============================================================================= + +extern "C" rac_bool_t rac_structured_output_find_complete_json(const char* text, size_t* out_start, + size_t* out_end) { + if (!text || !out_start || !out_end) { + return RAC_FALSE; + } + + size_t len = strlen(text); + if (len == 0) { + return RAC_FALSE; + } + + // Try to find JSON object or array + const char start_chars[] = {'{', '['}; + const char end_chars[] = {'}', ']'}; + + for (int type = 0; type < 2; type++) { + char start_char = start_chars[type]; + char end_char = end_chars[type]; + + size_t start_pos; + if (!find_char(text, start_char, 0, &start_pos)) { + continue; + } + + int depth = 0; + bool in_string = false; + bool escaped = false; + + for (size_t i = start_pos; i < len; i++) { + char ch = text[i]; + + if (escaped) { + escaped = false; + continue; + } + + if (ch == '\\') { + escaped = true; + continue; + } + + if (ch == '"' && !escaped) { + in_string = !in_string; + continue; + } + + if (!in_string) { + if (ch == start_char) { + depth++; + } else if (ch == end_char) { + depth--; + if (depth == 0) { + *out_start = start_pos; + *out_end = i + 1; // Exclusive end + return RAC_TRUE; + } + } + } + } + } + + return RAC_FALSE; +} + +// ============================================================================= +// EXTRACT JSON - Ported from Swift lines 102-132 +// ============================================================================= + +extern "C" rac_result_t rac_structured_output_extract_json(const char* text, char** out_json, + size_t* out_length) { + if (!text || !out_json) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + // Trim whitespace + size_t trim_start, trim_end; + trim_whitespace(text, &trim_start, &trim_end); + + if (trim_start >= trim_end) { + RAC_LOG_ERROR("StructuredOutput", "Empty text provided"); + return RAC_ERROR_INVALID_ARGUMENT; + } + + size_t trimmed_len = trim_end - trim_start; + const char* trimmed = text + trim_start; + + // First, try to find a complete JSON object + size_t json_start, json_end; + if (rac_structured_output_find_complete_json(trimmed, &json_start, &json_end) != 0) { + size_t json_len = json_end - json_start; + char* result = static_cast(malloc(json_len + 1)); + if (!result) { + return RAC_ERROR_OUT_OF_MEMORY; + } + memcpy(result, trimmed + json_start, json_len); + result[json_len] = '\0'; + *out_json = result; + if (out_length) { + *out_length = json_len; + } + return RAC_SUCCESS; + } + + // Fallback: Try to find JSON object boundaries with findMatchingBrace + size_t brace_start; + if (find_char(trimmed, '{', 0, &brace_start)) { + size_t brace_end; + if (rac_structured_output_find_matching_brace(trimmed, brace_start, &brace_end) != 0) { + size_t json_len = brace_end - brace_start + 1; + char* result = static_cast(malloc(json_len + 1)); + if (!result) { + return RAC_ERROR_OUT_OF_MEMORY; + } + memcpy(result, trimmed + brace_start, json_len); + result[json_len] = '\0'; + *out_json = result; + if (out_length) { + *out_length = json_len; + } + return RAC_SUCCESS; + } + } + + // Try to find JSON array boundaries + size_t bracket_start; + if (find_char(trimmed, '[', 0, &bracket_start)) { + size_t bracket_end; + if (rac_structured_output_find_matching_bracket(trimmed, bracket_start, &bracket_end) != + 0) { + size_t json_len = bracket_end - bracket_start + 1; + char* result = static_cast(malloc(json_len + 1)); + if (!result) { + return RAC_ERROR_OUT_OF_MEMORY; + } + memcpy(result, trimmed + bracket_start, json_len); + result[json_len] = '\0'; + *out_json = result; + if (out_length) { + *out_length = json_len; + } + return RAC_SUCCESS; + } + } + + // If no clear JSON boundaries, check if the entire text might be JSON + if (trimmed[0] == '{' || trimmed[0] == '[') { + char* result = static_cast(malloc(trimmed_len + 1)); + if (!result) { + return RAC_ERROR_OUT_OF_MEMORY; + } + memcpy(result, trimmed, trimmed_len); + result[trimmed_len] = '\0'; + *out_json = result; + if (out_length) { + *out_length = trimmed_len; + } + return RAC_SUCCESS; + } + + // Log the text that couldn't be parsed + RAC_LOG_ERROR("StructuredOutput", "No valid JSON found in the response"); + return RAC_ERROR_INVALID_FORMAT; +} + +// ============================================================================= +// GET SYSTEM PROMPT - Ported from Swift lines 10-30 +// ============================================================================= + +extern "C" rac_result_t rac_structured_output_get_system_prompt(const char* json_schema, + char** out_prompt) { + if (!out_prompt) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + const char* schema = json_schema ? json_schema : "{}"; + + // Build the system prompt - matches Swift getSystemPrompt(for:) + const char* format = + "You are a JSON generator that outputs ONLY valid JSON without any additional text.\n" + "\n" + "CRITICAL RULES:\n" + "1. Your entire response must be valid JSON that can be parsed\n" + "2. Start with { and end with }\n" + "3. No text before the opening {\n" + "4. No text after the closing }\n" + "5. Follow the provided schema exactly\n" + "6. Include all required fields\n" + "7. Use proper JSON syntax (quotes, commas, etc.)\n" + "\n" + "Expected JSON Schema:\n" + "%s\n" + "\n" + "Remember: Output ONLY the JSON object, nothing else."; + + size_t needed = snprintf(NULL, 0, format, schema) + 1; + char* result = static_cast(malloc(needed)); + if (!result) { + return RAC_ERROR_OUT_OF_MEMORY; + } + + snprintf(result, needed, format, schema); + *out_prompt = result; + + return RAC_SUCCESS; +} + +// ============================================================================= +// PREPARE PROMPT - Ported from Swift lines 43-82 +// ============================================================================= + +extern "C" rac_result_t rac_structured_output_prepare_prompt( + const char* original_prompt, const rac_structured_output_config_t* config, char** out_prompt) { + if (!original_prompt || !out_prompt) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + // If no config or schema not included in prompt, return original + if (config == nullptr || config->include_schema_in_prompt == 0) { + size_t len = strlen(original_prompt); + char* result = static_cast(malloc(len + 1)); + if (!result) { + return RAC_ERROR_OUT_OF_MEMORY; + } + memcpy(result, original_prompt, len + 1); + *out_prompt = result; + return RAC_SUCCESS; + } + + const char* schema = config->json_schema ? config->json_schema : "{}"; + + // Build structured output instructions - matches Swift preparePrompt() + const char* format = + "System: You are a JSON generator. You must output only valid JSON.\n" + "\n" + "%s\n" + "\n" + "CRITICAL INSTRUCTION: You MUST respond with ONLY a valid JSON object. No other text is " + "allowed.\n" + "\n" + "JSON Schema:\n" + "%s\n" + "\n" + "RULES:\n" + "1. Start your response with { and end with }\n" + "2. Include NO text before the opening {\n" + "3. Include NO text after the closing }\n" + "4. Follow the schema exactly\n" + "5. All required fields must be present\n" + "6. Use exact field names from the schema\n" + "7. Ensure proper JSON syntax (quotes, commas, etc.)\n" + "\n" + "IMPORTANT: Your entire response must be valid JSON that can be parsed. Do not include any " + "explanations, comments, or additional text.\n" + "\n" + "Remember: Output ONLY the JSON object, nothing else."; + + size_t needed = snprintf(NULL, 0, format, original_prompt, schema) + 1; + char* result = static_cast(malloc(needed)); + if (!result) { + return RAC_ERROR_OUT_OF_MEMORY; + } + + snprintf(result, needed, format, original_prompt, schema); + *out_prompt = result; + + return RAC_SUCCESS; +} + +// ============================================================================= +// VALIDATE STRUCTURED OUTPUT - Ported from Swift lines 264-282 +// ============================================================================= + +extern "C" rac_result_t +rac_structured_output_validate(const char* text, const rac_structured_output_config_t* config, + rac_structured_output_validation_t* out_validation) { + (void)config; // Currently unused, reserved for future schema validation + + if (!text || !out_validation) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + // Initialize output + out_validation->is_valid = RAC_FALSE; + out_validation->error_message = nullptr; + out_validation->extracted_json = nullptr; + + // Try to extract JSON + char* extracted = nullptr; + rac_result_t result = rac_structured_output_extract_json(text, &extracted, nullptr); + + if (result == RAC_SUCCESS && extracted) { + out_validation->is_valid = RAC_TRUE; + out_validation->extracted_json = extracted; + return RAC_SUCCESS; + } + + // Extraction failed + out_validation->is_valid = RAC_FALSE; + out_validation->error_message = "No valid JSON found in the response"; + + return RAC_SUCCESS; // Function succeeded, validation just returned false +} + +// ============================================================================= +// MEMORY MANAGEMENT +// ============================================================================= + +extern "C" void +rac_structured_output_validation_free(rac_structured_output_validation_t* validation) { + if (!validation) { + return; + } + + if (validation->extracted_json) { + free(validation->extracted_json); + validation->extracted_json = nullptr; + } + + // error_message is static, don't free it + validation->error_message = nullptr; + validation->is_valid = RAC_FALSE; +} diff --git a/sdk/runanywhere-commons/src/features/platform/rac_backend_platform_register.cpp b/sdk/runanywhere-commons/src/features/platform/rac_backend_platform_register.cpp new file mode 100644 index 000000000..2c281726c --- /dev/null +++ b/sdk/runanywhere-commons/src/features/platform/rac_backend_platform_register.cpp @@ -0,0 +1,607 @@ +/** + * @file rac_backend_platform_register.cpp + * @brief RunAnywhere Commons - Platform Backend Registration + * + * Registers the Platform backend (Apple Foundation Models + System TTS) with + * the module and service registries. Provides vtable implementations for + * the generic service APIs. + */ + +#include +#include +#include + +#include "rac/core/rac_core.h" +#include "rac/core/rac_error.h" +#include "rac/core/rac_logger.h" +#include "rac/features/llm/rac_llm_service.h" +#include "rac/features/platform/rac_llm_platform.h" +#include "rac/features/platform/rac_tts_platform.h" +#include "rac/features/tts/rac_tts_service.h" +#include "rac/infrastructure/model_management/rac_model_registry.h" + +static const char* LOG_CAT = "Platform"; + +// ============================================================================= +// LLM VTABLE IMPLEMENTATION - Foundation Models +// ============================================================================= + +namespace { + +// Initialize (no-op for Foundation Models - already initialized during create) +static rac_result_t platform_llm_vtable_initialize(void* impl, const char* model_path) { + (void)impl; + (void)model_path; + RAC_LOG_DEBUG(LOG_CAT, "LLM initialize (no-op for Foundation Models)"); + return RAC_SUCCESS; +} + +// Generate (blocking) +static rac_result_t platform_llm_vtable_generate(void* impl, const char* prompt, + const rac_llm_options_t* options, + rac_llm_result_t* out_result) { + if (!impl || !prompt || !out_result) + return RAC_ERROR_NULL_POINTER; + + RAC_LOG_DEBUG(LOG_CAT, "LLM generate via Swift"); + + // Convert options + rac_llm_platform_options_t platform_options = {}; + if (options) { + platform_options.temperature = options->temperature; + platform_options.max_tokens = options->max_tokens; + } else { + platform_options.temperature = 0.7f; + platform_options.max_tokens = 1000; + } + + auto handle = static_cast(impl); + char* response = nullptr; + rac_result_t result = rac_llm_platform_generate(handle, prompt, &platform_options, &response); + + if (result == RAC_SUCCESS && response) { + out_result->text = response; + out_result->prompt_tokens = 0; + out_result->completion_tokens = 0; + } + + return result; +} + +// Generate stream - Platform handles streaming at Swift level +static rac_result_t platform_llm_vtable_generate_stream(void* impl, const char* prompt, + const rac_llm_options_t* options, + rac_llm_stream_callback_fn callback, + void* user_data) { + if (!impl || !prompt || !callback) + return RAC_ERROR_NULL_POINTER; + + RAC_LOG_DEBUG(LOG_CAT, "LLM generate_stream via Swift"); + + // Convert options + rac_llm_platform_options_t platform_options = {}; + if (options) { + platform_options.temperature = options->temperature; + platform_options.max_tokens = options->max_tokens; + } else { + platform_options.temperature = 0.7f; + platform_options.max_tokens = 1000; + } + + // For Foundation Models, streaming is handled at Swift level + // We call generate and emit the response + auto handle = static_cast(impl); + char* response = nullptr; + rac_result_t result = rac_llm_platform_generate(handle, prompt, &platform_options, &response); + + if (result == RAC_SUCCESS && response) { + callback(response, user_data); + free(response); + } + + return result; +} + +// Get info +static rac_result_t platform_llm_vtable_get_info(void* impl, rac_llm_info_t* out_info) { + (void)impl; + if (!out_info) + return RAC_ERROR_NULL_POINTER; + + out_info->is_ready = RAC_TRUE; // Always ready (built-in) + out_info->supports_streaming = RAC_TRUE; + out_info->current_model = nullptr; + out_info->context_length = 4096; + + return RAC_SUCCESS; +} + +// Cancel (handled at Swift level) +static rac_result_t platform_llm_vtable_cancel(void* impl) { + (void)impl; + RAC_LOG_DEBUG(LOG_CAT, "LLM cancel (handled at Swift level)"); + return RAC_SUCCESS; +} + +// Cleanup (no-op) +static rac_result_t platform_llm_vtable_cleanup(void* impl) { + (void)impl; + return RAC_SUCCESS; +} + +// Destroy +static void platform_llm_vtable_destroy(void* impl) { + if (impl) { + RAC_LOG_DEBUG(LOG_CAT, "LLM destroy via Swift"); + rac_llm_platform_destroy(static_cast(impl)); + } +} + +// Static vtable for Platform LLM +static const rac_llm_service_ops_t g_platform_llm_ops = { + .initialize = platform_llm_vtable_initialize, + .generate = platform_llm_vtable_generate, + .generate_stream = platform_llm_vtable_generate_stream, + .get_info = platform_llm_vtable_get_info, + .cancel = platform_llm_vtable_cancel, + .cleanup = platform_llm_vtable_cleanup, + .destroy = platform_llm_vtable_destroy, +}; + +// ============================================================================= +// TTS VTABLE IMPLEMENTATION - System TTS +// ============================================================================= + +// Initialize (no-op - System TTS is always ready) +static rac_result_t platform_tts_vtable_initialize(void* impl) { + (void)impl; + RAC_LOG_DEBUG(LOG_CAT, "TTS initialize (no-op for System TTS)"); + return RAC_SUCCESS; +} + +// Synthesize (blocking) +static rac_result_t platform_tts_vtable_synthesize(void* impl, const char* text, + const rac_tts_options_t* options, + rac_tts_result_t* out_result) { + if (!impl || !text || !out_result) + return RAC_ERROR_NULL_POINTER; + + RAC_LOG_DEBUG(LOG_CAT, "TTS synthesize via Swift"); + + // Convert options + rac_tts_platform_options_t platform_options = {}; + if (options) { + platform_options.rate = options->rate; + platform_options.pitch = options->pitch; + platform_options.volume = options->volume; + platform_options.voice_id = options->voice; + } else { + platform_options.rate = 1.0f; + platform_options.pitch = 1.0f; + platform_options.volume = 1.0f; + } + + const auto* callbacks = rac_platform_tts_get_callbacks(); + if (!callbacks || !callbacks->synthesize) { + return RAC_ERROR_NOT_SUPPORTED; + } + + rac_result_t result = + callbacks->synthesize(impl, text, &platform_options, callbacks->user_data); + + // System TTS doesn't return audio data - it plays directly + // Set result to indicate success but no audio data + out_result->audio_data = nullptr; + out_result->audio_size = 0; + + return result; +} + +// Stream synthesis (System TTS handles streaming internally) +static rac_result_t platform_tts_vtable_synthesize_stream(void* impl, const char* text, + const rac_tts_options_t* options, + rac_tts_stream_callback_t callback, + void* user_data) { + (void)callback; + (void)user_data; + + // System TTS doesn't support streaming to a callback - it plays directly + // Fall back to regular synthesis + rac_tts_result_t result = {}; + return platform_tts_vtable_synthesize(impl, text, options, &result); +} + +// Stop +static rac_result_t platform_tts_vtable_stop(void* impl) { + if (!impl) + return RAC_ERROR_NULL_POINTER; + + const auto* callbacks = rac_platform_tts_get_callbacks(); + if (callbacks && callbacks->stop) { + callbacks->stop(impl, callbacks->user_data); + } + + return RAC_SUCCESS; +} + +// Get info +static rac_result_t platform_tts_vtable_get_info(void* impl, rac_tts_info_t* out_info) { + (void)impl; + if (!out_info) + return RAC_ERROR_NULL_POINTER; + + out_info->is_ready = RAC_TRUE; + out_info->is_synthesizing = RAC_FALSE; + out_info->available_voices = nullptr; + out_info->num_voices = 0; + + return RAC_SUCCESS; +} + +// Cleanup (no-op) +static rac_result_t platform_tts_vtable_cleanup(void* impl) { + (void)impl; + return RAC_SUCCESS; +} + +// Destroy +static void platform_tts_vtable_destroy(void* impl) { + if (!impl) + return; + + RAC_LOG_DEBUG(LOG_CAT, "TTS destroy via Swift"); + + const auto* callbacks = rac_platform_tts_get_callbacks(); + if (callbacks && callbacks->destroy) { + callbacks->destroy(impl, callbacks->user_data); + } +} + +// Static vtable for Platform TTS +static const rac_tts_service_ops_t g_platform_tts_ops = { + .initialize = platform_tts_vtable_initialize, + .synthesize = platform_tts_vtable_synthesize, + .synthesize_stream = platform_tts_vtable_synthesize_stream, + .stop = platform_tts_vtable_stop, + .get_info = platform_tts_vtable_get_info, + .cleanup = platform_tts_vtable_cleanup, + .destroy = platform_tts_vtable_destroy, +}; + +// ============================================================================= +// REGISTRY STATE +// ============================================================================= + +struct PlatformRegistryState { + std::mutex mutex; + bool registered = false; + char provider_llm_name[32] = "AppleFoundationModels"; + char provider_tts_name[32] = "SystemTTS"; + char module_id[16] = "platform"; +}; + +PlatformRegistryState& get_state() { + static PlatformRegistryState state; + return state; +} + +// ============================================================================= +// LLM SERVICE PROVIDER - Apple Foundation Models +// ============================================================================= + +rac_bool_t platform_llm_can_handle(const rac_service_request_t* request, void* user_data) { + (void)user_data; + + if (request == nullptr) { + return RAC_FALSE; + } + + // Check framework hint first + if (request->framework == RAC_FRAMEWORK_FOUNDATION_MODELS) { + RAC_LOG_DEBUG(LOG_CAT, "LLM can_handle: framework match -> true"); + return RAC_TRUE; + } + + // If framework explicitly set to something else, don't handle + if (request->framework != RAC_FRAMEWORK_UNKNOWN) { + return RAC_FALSE; + } + + // Check if Swift callbacks are available + const auto* callbacks = rac_platform_llm_get_callbacks(); + if (callbacks == nullptr || callbacks->can_handle == nullptr) { + return RAC_FALSE; + } + + // Delegate to Swift + return callbacks->can_handle(request->identifier, callbacks->user_data); +} + +/** + * Create Foundation Models LLM service with vtable. + * Returns an rac_llm_service_t* that the generic API can dispatch through. + */ +rac_handle_t platform_llm_create(const rac_service_request_t* request, void* user_data) { + (void)user_data; + + if (request == nullptr) { + RAC_LOG_ERROR(LOG_CAT, "LLM create: null request"); + return nullptr; + } + + const auto* callbacks = rac_platform_llm_get_callbacks(); + if (callbacks == nullptr || callbacks->create == nullptr) { + RAC_LOG_ERROR(LOG_CAT, "LLM create: Swift callbacks not registered"); + return nullptr; + } + + RAC_LOG_INFO(LOG_CAT, "Creating Foundation Models LLM service via Swift"); + + const char* model_path = request->model_path ? request->model_path : request->identifier; + rac_llm_platform_config_t config = {}; + + // Create backend-specific handle via Swift + rac_handle_t backend_handle = callbacks->create(model_path, &config, callbacks->user_data); + if (!backend_handle) { + RAC_LOG_ERROR(LOG_CAT, "Swift create callback returned null"); + return nullptr; + } + + // Allocate service struct with vtable + auto* service = static_cast(malloc(sizeof(rac_llm_service_t))); + if (!service) { + rac_llm_platform_destroy(static_cast(backend_handle)); + return nullptr; + } + + service->ops = &g_platform_llm_ops; + service->impl = backend_handle; + service->model_id = request->identifier ? strdup(request->identifier) : nullptr; + + RAC_LOG_INFO(LOG_CAT, "Foundation Models LLM service created successfully"); + return service; +} + +// ============================================================================= +// TTS SERVICE PROVIDER - System TTS +// ============================================================================= + +rac_bool_t platform_tts_can_handle(const rac_service_request_t* request, void* user_data) { + (void)user_data; + + if (request == nullptr) { + return RAC_FALSE; + } + + // Check framework hint first + if (request->framework == RAC_FRAMEWORK_SYSTEM_TTS) { + RAC_LOG_DEBUG(LOG_CAT, "TTS can_handle: framework match -> true"); + return RAC_TRUE; + } + + // If framework explicitly set to something else, don't handle + if (request->framework != RAC_FRAMEWORK_UNKNOWN) { + return RAC_FALSE; + } + + // Check if Swift callbacks are available + const auto* callbacks = rac_platform_tts_get_callbacks(); + if (callbacks == nullptr || callbacks->can_handle == nullptr) { + return RAC_FALSE; + } + + // Delegate to Swift + return callbacks->can_handle(request->identifier, callbacks->user_data); +} + +/** + * Create System TTS service with vtable. + * Returns an rac_tts_service_t* that the generic API can dispatch through. + */ +rac_handle_t platform_tts_create(const rac_service_request_t* request, void* user_data) { + (void)user_data; + + const auto* callbacks = rac_platform_tts_get_callbacks(); + if (callbacks == nullptr || callbacks->create == nullptr) { + RAC_LOG_ERROR(LOG_CAT, "TTS create: Swift callbacks not registered"); + return nullptr; + } + + RAC_LOG_INFO(LOG_CAT, "Creating System TTS service via Swift"); + + rac_tts_platform_config_t config = {}; + if (request != nullptr && request->identifier != nullptr) { + config.voice_id = request->identifier; + } + + // Create backend-specific handle via Swift + rac_handle_t backend_handle = callbacks->create(&config, callbacks->user_data); + if (!backend_handle) { + RAC_LOG_ERROR(LOG_CAT, "Swift TTS create callback returned null"); + return nullptr; + } + + // Allocate service struct with vtable + auto* service = static_cast(malloc(sizeof(rac_tts_service_t))); + if (!service) { + if (callbacks->destroy) { + callbacks->destroy(backend_handle, callbacks->user_data); + } + return nullptr; + } + + service->ops = &g_platform_tts_ops; + service->impl = backend_handle; + service->model_id = (request && request->identifier) ? strdup(request->identifier) : nullptr; + + RAC_LOG_INFO(LOG_CAT, "System TTS service created successfully"); + return service; +} + +// ============================================================================= +// BUILT-IN MODEL REGISTRATION +// ============================================================================= + +void register_foundation_models_entry() { + rac_model_registry* registry = rac_get_model_registry(); + if (registry == nullptr) { + RAC_LOG_WARNING(LOG_CAT, "Cannot register built-in model: registry not available"); + return; + } + + rac_model_info_t model = {}; + model.id = strdup("foundation-models-default"); + model.name = strdup("Platform LLM"); + model.category = RAC_MODEL_CATEGORY_LANGUAGE; + model.format = RAC_MODEL_FORMAT_UNKNOWN; + model.framework = RAC_FRAMEWORK_FOUNDATION_MODELS; + model.download_url = nullptr; + model.local_path = strdup("builtin://foundation-models"); + model.artifact_info.kind = RAC_ARTIFACT_KIND_BUILT_IN; + model.download_size = 0; + model.memory_required = 0; + model.context_length = 4096; + model.supports_thinking = RAC_FALSE; + model.tags = nullptr; + model.tag_count = 0; + model.description = strdup( + "Platform's built-in language model. " + "Uses the device's native AI capabilities when available."); + model.source = RAC_MODEL_SOURCE_LOCAL; + + rac_result_t result = rac_model_registry_save(registry, &model); + if (result == RAC_SUCCESS) { + RAC_LOG_INFO(LOG_CAT, "Registered built-in model: %s", model.id); + } + + free(model.id); + free(model.name); + free(model.local_path); + free(model.description); +} + +void register_system_tts_entry() { + rac_model_registry* registry = rac_get_model_registry(); + if (registry == nullptr) { + return; + } + + rac_model_info_t model = {}; + model.id = strdup("system-tts"); + model.name = strdup("Platform TTS"); + model.category = RAC_MODEL_CATEGORY_SPEECH_SYNTHESIS; + model.format = RAC_MODEL_FORMAT_UNKNOWN; + model.framework = RAC_FRAMEWORK_SYSTEM_TTS; + model.download_url = nullptr; + model.local_path = strdup("builtin://system-tts"); + model.artifact_info.kind = RAC_ARTIFACT_KIND_BUILT_IN; + model.download_size = 0; + model.memory_required = 0; + model.context_length = 0; + model.supports_thinking = RAC_FALSE; + model.tags = nullptr; + model.tag_count = 0; + model.description = strdup("Platform's built-in Text-to-Speech using native synthesis."); + model.source = RAC_MODEL_SOURCE_LOCAL; + + rac_result_t result = rac_model_registry_save(registry, &model); + if (result == RAC_SUCCESS) { + RAC_LOG_INFO(LOG_CAT, "Registered built-in model: %s", model.id); + } + + free(model.id); + free(model.name); + free(model.local_path); + free(model.description); +} + +} // namespace + +// ============================================================================= +// REGISTRATION API +// ============================================================================= + +extern "C" { + +rac_result_t rac_backend_platform_register(void) { + auto& state = get_state(); + std::lock_guard lock(state.mutex); + + if (state.registered) { + return RAC_ERROR_MODULE_ALREADY_REGISTERED; + } + + // Register module + rac_module_info_t module_info = {}; + module_info.id = state.module_id; + module_info.name = "Platform Services"; + module_info.version = "1.0.0"; + module_info.description = "Apple platform services (Foundation Models, System TTS)"; + + rac_capability_t capabilities[] = {RAC_CAPABILITY_TEXT_GENERATION, RAC_CAPABILITY_TTS}; + module_info.capabilities = capabilities; + module_info.num_capabilities = 2; + + rac_result_t result = rac_module_register(&module_info); + if (result != RAC_SUCCESS && result != RAC_ERROR_MODULE_ALREADY_REGISTERED) { + return result; + } + + // Register LLM provider + rac_service_provider_t llm_provider = {}; + llm_provider.name = state.provider_llm_name; + llm_provider.capability = RAC_CAPABILITY_TEXT_GENERATION; + llm_provider.priority = 50; + llm_provider.can_handle = platform_llm_can_handle; + llm_provider.create = platform_llm_create; + llm_provider.user_data = nullptr; + + result = rac_service_register_provider(&llm_provider); + if (result != RAC_SUCCESS) { + rac_module_unregister(state.module_id); + return result; + } + + // Register TTS provider + rac_service_provider_t tts_provider = {}; + tts_provider.name = state.provider_tts_name; + tts_provider.capability = RAC_CAPABILITY_TTS; + tts_provider.priority = 10; + tts_provider.can_handle = platform_tts_can_handle; + tts_provider.create = platform_tts_create; + tts_provider.user_data = nullptr; + + result = rac_service_register_provider(&tts_provider); + if (result != RAC_SUCCESS) { + rac_service_unregister_provider(state.provider_llm_name, RAC_CAPABILITY_TEXT_GENERATION); + rac_module_unregister(state.module_id); + return result; + } + + // Register built-in models + register_foundation_models_entry(); + register_system_tts_entry(); + + state.registered = true; + RAC_LOG_INFO(LOG_CAT, "Platform backend registered successfully"); + return RAC_SUCCESS; +} + +rac_result_t rac_backend_platform_unregister(void) { + auto& state = get_state(); + std::lock_guard lock(state.mutex); + + if (!state.registered) { + return RAC_ERROR_MODULE_NOT_FOUND; + } + + rac_service_unregister_provider(state.provider_tts_name, RAC_CAPABILITY_TTS); + rac_service_unregister_provider(state.provider_llm_name, RAC_CAPABILITY_TEXT_GENERATION); + rac_module_unregister(state.module_id); + + state.registered = false; + RAC_LOG_INFO(LOG_CAT, "Platform backend unregistered"); + return RAC_SUCCESS; +} + +} // extern "C" diff --git a/sdk/runanywhere-commons/src/features/platform/rac_llm_platform.cpp b/sdk/runanywhere-commons/src/features/platform/rac_llm_platform.cpp new file mode 100644 index 000000000..fbb8444e7 --- /dev/null +++ b/sdk/runanywhere-commons/src/features/platform/rac_llm_platform.cpp @@ -0,0 +1,132 @@ +/** + * @file rac_llm_platform.cpp + * @brief RunAnywhere Commons - Platform LLM Implementation + * + * C++ implementation of platform LLM API. This is a thin wrapper that + * delegates all operations to Swift via registered callbacks. + */ + +#include "rac/features/platform/rac_llm_platform.h" + +#include +#include +#include + +#include "rac/core/rac_error.h" +#include "rac/core/rac_logger.h" + +static const char* LOG_CAT = "Platform.LLM"; + +// ============================================================================= +// CALLBACK STORAGE +// ============================================================================= + +namespace { + +std::mutex g_callbacks_mutex; +rac_platform_llm_callbacks_t g_callbacks = {}; +bool g_callbacks_set = false; + +} // namespace + +// ============================================================================= +// CALLBACK REGISTRATION +// ============================================================================= + +extern "C" { + +rac_result_t rac_platform_llm_set_callbacks(const rac_platform_llm_callbacks_t* callbacks) { + if (callbacks == nullptr) { + return RAC_ERROR_INVALID_PARAMETER; + } + + std::lock_guard lock(g_callbacks_mutex); + g_callbacks = *callbacks; + g_callbacks_set = true; + + RAC_LOG_INFO(LOG_CAT, "Swift callbacks registered for platform LLM"); + return RAC_SUCCESS; +} + +const rac_platform_llm_callbacks_t* rac_platform_llm_get_callbacks(void) { + std::lock_guard lock(g_callbacks_mutex); + if (!g_callbacks_set) { + return nullptr; + } + return &g_callbacks; +} + +rac_bool_t rac_platform_llm_is_available(void) { + std::lock_guard lock(g_callbacks_mutex); + return g_callbacks_set && g_callbacks.can_handle != nullptr && g_callbacks.create != nullptr + ? RAC_TRUE + : RAC_FALSE; +} + +// ============================================================================= +// SERVICE API +// ============================================================================= + +rac_result_t rac_llm_platform_create(const char* model_path, + const rac_llm_platform_config_t* config, + rac_llm_platform_handle_t* out_handle) { + if (out_handle == nullptr) { + return RAC_ERROR_INVALID_PARAMETER; + } + + *out_handle = nullptr; + + std::lock_guard lock(g_callbacks_mutex); + if (!g_callbacks_set || g_callbacks.create == nullptr) { + RAC_LOG_ERROR(LOG_CAT, "Swift callbacks not registered"); + return RAC_ERROR_NOT_INITIALIZED; + } + + RAC_LOG_DEBUG(LOG_CAT, "Creating platform LLM via Swift"); + + rac_handle_t handle = g_callbacks.create(model_path, config, g_callbacks.user_data); + if (handle == nullptr) { + RAC_LOG_ERROR(LOG_CAT, "Swift create callback returned null"); + return RAC_ERROR_INTERNAL; + } + + *out_handle = reinterpret_cast(handle); + RAC_LOG_INFO(LOG_CAT, "Platform LLM service created"); + return RAC_SUCCESS; +} + +void rac_llm_platform_destroy(rac_llm_platform_handle_t handle) { + if (handle == nullptr) { + return; + } + + std::lock_guard lock(g_callbacks_mutex); + if (!g_callbacks_set || g_callbacks.destroy == nullptr) { + RAC_LOG_WARNING(LOG_CAT, "Cannot destroy: Swift callbacks not registered"); + return; + } + + RAC_LOG_DEBUG(LOG_CAT, "Destroying platform LLM via Swift"); + g_callbacks.destroy(handle, g_callbacks.user_data); +} + +rac_result_t rac_llm_platform_generate(rac_llm_platform_handle_t handle, const char* prompt, + const rac_llm_platform_options_t* options, + char** out_response) { + if (handle == nullptr || prompt == nullptr || out_response == nullptr) { + return RAC_ERROR_INVALID_PARAMETER; + } + + *out_response = nullptr; + + std::lock_guard lock(g_callbacks_mutex); + if (!g_callbacks_set || g_callbacks.generate == nullptr) { + RAC_LOG_ERROR(LOG_CAT, "Swift callbacks not registered"); + return RAC_ERROR_NOT_INITIALIZED; + } + + RAC_LOG_DEBUG(LOG_CAT, "Generating via platform LLM"); + return g_callbacks.generate(handle, prompt, options, out_response, g_callbacks.user_data); +} + +} // extern "C" diff --git a/sdk/runanywhere-commons/src/features/platform/rac_tts_platform.cpp b/sdk/runanywhere-commons/src/features/platform/rac_tts_platform.cpp new file mode 100644 index 000000000..5b40ca651 --- /dev/null +++ b/sdk/runanywhere-commons/src/features/platform/rac_tts_platform.cpp @@ -0,0 +1,143 @@ +/** + * @file rac_tts_platform.cpp + * @brief RunAnywhere Commons - Platform TTS Implementation + * + * C++ implementation of platform TTS API. This is a thin wrapper that + * delegates all operations to Swift via registered callbacks. + */ + +#include "rac/features/platform/rac_tts_platform.h" + +#include +#include +#include + +#include "rac/core/rac_error.h" +#include "rac/core/rac_logger.h" + +static const char* LOG_CAT = "Platform.TTS"; + +// ============================================================================= +// CALLBACK STORAGE +// ============================================================================= + +namespace { + +std::mutex g_callbacks_mutex; +rac_platform_tts_callbacks_t g_callbacks = {}; +bool g_callbacks_set = false; + +} // namespace + +// ============================================================================= +// CALLBACK REGISTRATION +// ============================================================================= + +extern "C" { + +rac_result_t rac_platform_tts_set_callbacks(const rac_platform_tts_callbacks_t* callbacks) { + if (callbacks == nullptr) { + return RAC_ERROR_INVALID_PARAMETER; + } + + std::lock_guard lock(g_callbacks_mutex); + g_callbacks = *callbacks; + g_callbacks_set = true; + + RAC_LOG_INFO(LOG_CAT, "Swift callbacks registered for platform TTS"); + return RAC_SUCCESS; +} + +const rac_platform_tts_callbacks_t* rac_platform_tts_get_callbacks(void) { + std::lock_guard lock(g_callbacks_mutex); + if (!g_callbacks_set) { + return nullptr; + } + return &g_callbacks; +} + +rac_bool_t rac_platform_tts_is_available(void) { + std::lock_guard lock(g_callbacks_mutex); + return g_callbacks_set && g_callbacks.can_handle != nullptr && g_callbacks.create != nullptr + ? RAC_TRUE + : RAC_FALSE; +} + +// ============================================================================= +// SERVICE API +// ============================================================================= + +rac_result_t rac_tts_platform_create(const rac_tts_platform_config_t* config, + rac_tts_platform_handle_t* out_handle) { + if (out_handle == nullptr) { + return RAC_ERROR_INVALID_PARAMETER; + } + + *out_handle = nullptr; + + std::lock_guard lock(g_callbacks_mutex); + if (!g_callbacks_set || g_callbacks.create == nullptr) { + RAC_LOG_ERROR(LOG_CAT, "Swift callbacks not registered"); + return RAC_ERROR_NOT_INITIALIZED; + } + + RAC_LOG_DEBUG(LOG_CAT, "Creating platform TTS via Swift"); + + rac_handle_t handle = g_callbacks.create(config, g_callbacks.user_data); + if (handle == nullptr) { + RAC_LOG_ERROR(LOG_CAT, "Swift create callback returned null"); + return RAC_ERROR_INTERNAL; + } + + *out_handle = reinterpret_cast(handle); + RAC_LOG_INFO(LOG_CAT, "Platform TTS service created"); + return RAC_SUCCESS; +} + +void rac_tts_platform_destroy(rac_tts_platform_handle_t handle) { + if (handle == nullptr) { + return; + } + + std::lock_guard lock(g_callbacks_mutex); + if (!g_callbacks_set || g_callbacks.destroy == nullptr) { + RAC_LOG_WARNING(LOG_CAT, "Cannot destroy: Swift callbacks not registered"); + return; + } + + RAC_LOG_DEBUG(LOG_CAT, "Destroying platform TTS via Swift"); + g_callbacks.destroy(handle, g_callbacks.user_data); +} + +rac_result_t rac_tts_platform_synthesize(rac_tts_platform_handle_t handle, const char* text, + const rac_tts_platform_options_t* options) { + if (handle == nullptr || text == nullptr) { + return RAC_ERROR_INVALID_PARAMETER; + } + + std::lock_guard lock(g_callbacks_mutex); + if (!g_callbacks_set || g_callbacks.synthesize == nullptr) { + RAC_LOG_ERROR(LOG_CAT, "Swift callbacks not registered"); + return RAC_ERROR_NOT_INITIALIZED; + } + + RAC_LOG_DEBUG(LOG_CAT, "Synthesizing via platform TTS"); + return g_callbacks.synthesize(handle, text, options, g_callbacks.user_data); +} + +void rac_tts_platform_stop(rac_tts_platform_handle_t handle) { + if (handle == nullptr) { + return; + } + + std::lock_guard lock(g_callbacks_mutex); + if (!g_callbacks_set || g_callbacks.stop == nullptr) { + RAC_LOG_WARNING(LOG_CAT, "Cannot stop: Swift callbacks not registered"); + return; + } + + RAC_LOG_DEBUG(LOG_CAT, "Stopping platform TTS via Swift"); + g_callbacks.stop(handle, g_callbacks.user_data); +} + +} // extern "C" diff --git a/sdk/runanywhere-commons/src/features/result_free.cpp b/sdk/runanywhere-commons/src/features/result_free.cpp new file mode 100644 index 000000000..e2d4d5049 --- /dev/null +++ b/sdk/runanywhere-commons/src/features/result_free.cpp @@ -0,0 +1,60 @@ +/** + * @file result_free.cpp + * @brief Result free function implementations + * + * Implements memory management for result structures. + * These are weak symbols that can be overridden by backend implementations. + */ + +#include + +#include "rac/features/llm/rac_llm_types.h" +#include "rac/features/stt/rac_stt_types.h" +#include "rac/features/tts/rac_tts_types.h" + +extern "C" { + +__attribute__((weak)) void rac_llm_result_free(rac_llm_result_t* result) { + if (result) { + if (result->text) { + free(const_cast(result->text)); + result->text = nullptr; + } + } +} + +__attribute__((weak)) void rac_stt_result_free(rac_stt_result_t* result) { + if (result) { + if (result->text) { + free(const_cast(result->text)); + result->text = nullptr; + } + if (result->detected_language) { + free(result->detected_language); + result->detected_language = nullptr; + } + if (result->words) { + // Free individual word allocations + for (size_t i = 0; i < result->num_words; i++) { + if (result->words[i].text) { + free(const_cast(result->words[i].text)); + } + } + free(result->words); + result->words = nullptr; + result->num_words = 0; + } + } +} + +__attribute__((weak)) void rac_tts_result_free(rac_tts_result_t* result) { + if (result) { + if (result->audio_data) { + free(result->audio_data); + result->audio_data = nullptr; + } + result->audio_size = 0; + } +} + +} // extern "C" diff --git a/sdk/runanywhere-commons/src/features/stt/rac_stt_service.cpp b/sdk/runanywhere-commons/src/features/stt/rac_stt_service.cpp new file mode 100644 index 000000000..9cb72e3f9 --- /dev/null +++ b/sdk/runanywhere-commons/src/features/stt/rac_stt_service.cpp @@ -0,0 +1,151 @@ +/** + * @file rac_stt_service.cpp + * @brief STT Service - Generic API with VTable Dispatch + * + * Simple dispatch layer that routes calls through the service vtable. + * Each backend provides its own vtable when creating a service. + */ + +#include "rac/features/stt/rac_stt_service.h" + +#include +#include + +#include "rac/core/rac_core.h" +#include "rac/core/rac_logger.h" +#include "rac/infrastructure/model_management/rac_model_registry.h" + +static const char* LOG_CAT = "STT.Service"; + +// ============================================================================= +// SERVICE CREATION - Routes through Service Registry +// ============================================================================= + +extern "C" { + +rac_result_t rac_stt_create(const char* model_path, rac_handle_t* out_handle) { + if (!out_handle) { + return RAC_ERROR_NULL_POINTER; + } + + *out_handle = nullptr; + + RAC_LOG_INFO(LOG_CAT, "Creating STT service"); + + // Build service request + rac_service_request_t request = {}; + request.identifier = model_path; + request.capability = RAC_CAPABILITY_STT; + request.framework = RAC_FRAMEWORK_ONNX; // Default to ONNX for STT + request.model_path = model_path; + + // Service registry returns an rac_stt_service_t* with vtable already set + rac_result_t result = rac_service_create(RAC_CAPABILITY_STT, &request, out_handle); + + if (result != RAC_SUCCESS) { + RAC_LOG_ERROR(LOG_CAT, "Failed to create service via registry"); + return result; + } + + RAC_LOG_INFO(LOG_CAT, "STT service created"); + return RAC_SUCCESS; +} + +// ============================================================================= +// GENERIC API - Simple vtable dispatch +// ============================================================================= + +rac_result_t rac_stt_initialize(rac_handle_t handle, const char* model_path) { + if (!handle) + return RAC_ERROR_NULL_POINTER; + + auto* service = static_cast(handle); + if (!service->ops || !service->ops->initialize) { + return RAC_ERROR_NOT_SUPPORTED; + } + + return service->ops->initialize(service->impl, model_path); +} + +rac_result_t rac_stt_transcribe(rac_handle_t handle, const void* audio_data, size_t audio_size, + const rac_stt_options_t* options, rac_stt_result_t* out_result) { + if (!handle || !audio_data || !out_result) + return RAC_ERROR_NULL_POINTER; + + auto* service = static_cast(handle); + if (!service->ops || !service->ops->transcribe) { + return RAC_ERROR_NOT_SUPPORTED; + } + + return service->ops->transcribe(service->impl, audio_data, audio_size, options, out_result); +} + +rac_result_t rac_stt_transcribe_stream(rac_handle_t handle, const void* audio_data, + size_t audio_size, const rac_stt_options_t* options, + rac_stt_stream_callback_t callback, void* user_data) { + if (!handle || !audio_data || !callback) + return RAC_ERROR_NULL_POINTER; + + auto* service = static_cast(handle); + if (!service->ops || !service->ops->transcribe_stream) { + return RAC_ERROR_NOT_SUPPORTED; + } + + return service->ops->transcribe_stream(service->impl, audio_data, audio_size, options, callback, + user_data); +} + +rac_result_t rac_stt_get_info(rac_handle_t handle, rac_stt_info_t* out_info) { + if (!handle || !out_info) + return RAC_ERROR_NULL_POINTER; + + auto* service = static_cast(handle); + if (!service->ops || !service->ops->get_info) { + return RAC_ERROR_NOT_SUPPORTED; + } + + return service->ops->get_info(service->impl, out_info); +} + +rac_result_t rac_stt_cleanup(rac_handle_t handle) { + if (!handle) + return RAC_ERROR_NULL_POINTER; + + auto* service = static_cast(handle); + if (!service->ops || !service->ops->cleanup) { + return RAC_SUCCESS; // No-op if not supported + } + + return service->ops->cleanup(service->impl); +} + +void rac_stt_destroy(rac_handle_t handle) { + if (!handle) + return; + + auto* service = static_cast(handle); + + // Call backend destroy + if (service->ops && service->ops->destroy) { + service->ops->destroy(service->impl); + } + + // Free model_id if allocated + if (service->model_id) { + free(const_cast(service->model_id)); + } + + // Free service struct + free(service); +} + +void rac_stt_result_free(rac_stt_result_t* result) { + if (!result) + return; + if (result->text) { + free(result->text); + result->text = nullptr; + } +} + +} // extern "C" diff --git a/sdk/runanywhere-commons/src/features/stt/stt_analytics.cpp b/sdk/runanywhere-commons/src/features/stt/stt_analytics.cpp new file mode 100644 index 000000000..ec377061d --- /dev/null +++ b/sdk/runanywhere-commons/src/features/stt/stt_analytics.cpp @@ -0,0 +1,311 @@ +/** + * @file stt_analytics.cpp + * @brief STT analytics service implementation + * + * 1:1 port of Swift's STTAnalyticsService.swift + * Swift Source: Sources/RunAnywhere/Features/STT/Analytics/STTAnalyticsService.swift + */ + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "rac/core/rac_logger.h" +#include "rac/features/stt/rac_stt_analytics.h" + +// ============================================================================= +// INTERNAL TYPES - Mirrors Swift's TranscriptionTracker +// ============================================================================= + +namespace { + +struct TranscriptionTracker { + int64_t start_time_ms; + std::string model_id; + double audio_length_ms; + int32_t audio_size_bytes; + std::string language; + bool is_streaming; + int32_t sample_rate; + rac_inference_framework_t framework; +}; + +int64_t get_current_time_ms() { + auto now = std::chrono::system_clock::now(); + auto duration = now.time_since_epoch(); + return std::chrono::duration_cast(duration).count(); +} + +std::string generate_uuid() { + static std::random_device rd; + static std::mt19937 gen(rd()); + static std::uniform_int_distribution<> dis(0, 15); + + std::stringstream ss; + ss << std::hex; + + for (int i = 0; i < 8; i++) + ss << dis(gen); + ss << "-"; + for (int i = 0; i < 4; i++) + ss << dis(gen); + ss << "-4"; + for (int i = 0; i < 3; i++) + ss << dis(gen); + ss << "-"; + ss << (8 + dis(gen) % 4); + for (int i = 0; i < 3; i++) + ss << dis(gen); + ss << "-"; + for (int i = 0; i < 12; i++) + ss << dis(gen); + + return ss.str(); +} + +} // namespace + +// ============================================================================= +// STT ANALYTICS SERVICE IMPLEMENTATION +// ============================================================================= + +struct rac_stt_analytics_s { + std::mutex mutex; + std::map active_transcriptions; + + // Metrics (mirrors Swift) + int32_t transcription_count; + float total_confidence; + double total_latency_ms; + double total_audio_processed_ms; + double total_real_time_factor; + int64_t start_time_ms; + int64_t last_event_time_ms; + bool has_last_event_time; + + rac_stt_analytics_s() + : transcription_count(0), + total_confidence(0.0f), + total_latency_ms(0), + total_audio_processed_ms(0), + total_real_time_factor(0), + start_time_ms(get_current_time_ms()), + last_event_time_ms(0), + has_last_event_time(false) {} +}; + +// ============================================================================= +// C API IMPLEMENTATION +// ============================================================================= + +extern "C" { + +rac_result_t rac_stt_analytics_create(rac_stt_analytics_handle_t* out_handle) { + if (!out_handle) { + return RAC_ERROR_INVALID_PARAMETER; + } + + try { + *out_handle = new rac_stt_analytics_s(); + log_info("STT.Analytics", "STT analytics service created"); + return RAC_SUCCESS; + } catch (...) { + return RAC_ERROR_OUT_OF_MEMORY; + } +} + +void rac_stt_analytics_destroy(rac_stt_analytics_handle_t handle) { + if (handle) { + delete handle; + log_info("STT.Analytics", "STT analytics service destroyed"); + } +} + +rac_result_t rac_stt_analytics_start_transcription(rac_stt_analytics_handle_t handle, + const char* model_id, double audio_length_ms, + int32_t audio_size_bytes, const char* language, + rac_bool_t is_streaming, int32_t sample_rate, + rac_inference_framework_t framework, + char** out_transcription_id) { + if (!handle || !model_id || !language || !out_transcription_id) { + return RAC_ERROR_INVALID_PARAMETER; + } + + std::lock_guard lock(handle->mutex); + + std::string id = generate_uuid(); + + TranscriptionTracker tracker; + tracker.start_time_ms = get_current_time_ms(); + tracker.model_id = model_id; + tracker.audio_length_ms = audio_length_ms; + tracker.audio_size_bytes = audio_size_bytes; + tracker.language = language; + tracker.is_streaming = is_streaming == RAC_TRUE; + tracker.sample_rate = sample_rate; + tracker.framework = framework; + + handle->active_transcriptions[id] = tracker; + + *out_transcription_id = static_cast(malloc(id.size() + 1)); + if (!*out_transcription_id) { + return RAC_ERROR_OUT_OF_MEMORY; + } + strcpy(*out_transcription_id, id.c_str()); + + log_debug("STT.Analytics", "Transcription started: %s, model: %s, audio: %.1fms, %d bytes", + id.c_str(), model_id, audio_length_ms, audio_size_bytes); + + return RAC_SUCCESS; +} + +rac_result_t rac_stt_analytics_track_partial_transcript(rac_stt_analytics_handle_t handle, + const char* text) { + if (!handle || !text) { + return RAC_ERROR_INVALID_PARAMETER; + } + + // Event would be published here in full implementation + log_debug("STT.Analytics", "Partial transcript received"); + return RAC_SUCCESS; +} + +rac_result_t rac_stt_analytics_track_final_transcript(rac_stt_analytics_handle_t handle, + const char* text, float confidence) { + if (!handle || !text) { + return RAC_ERROR_INVALID_PARAMETER; + } + + // Event would be published here in full implementation + log_debug("STT.Analytics", "Final transcript: confidence=%.2f", confidence); + return RAC_SUCCESS; +} + +rac_result_t rac_stt_analytics_complete_transcription(rac_stt_analytics_handle_t handle, + const char* transcription_id, + const char* text, float confidence) { + if (!handle || !transcription_id || !text) { + return RAC_ERROR_INVALID_PARAMETER; + } + + std::lock_guard lock(handle->mutex); + + auto it = handle->active_transcriptions.find(transcription_id); + if (it == handle->active_transcriptions.end()) { + return RAC_ERROR_NOT_FOUND; + } + + TranscriptionTracker tracker = it->second; + handle->active_transcriptions.erase(it); + + int64_t end_time_ms = get_current_time_ms(); + double processing_time_ms = static_cast(end_time_ms - tracker.start_time_ms); + + // Calculate real-time factor (RTF): processing time / audio length + double real_time_factor = + tracker.audio_length_ms > 0 ? processing_time_ms / tracker.audio_length_ms : 0; + + // Update metrics + handle->transcription_count++; + handle->total_confidence += confidence; + handle->total_latency_ms += processing_time_ms; + handle->total_audio_processed_ms += tracker.audio_length_ms; + handle->total_real_time_factor += real_time_factor; + handle->last_event_time_ms = end_time_ms; + handle->has_last_event_time = true; + + log_debug("STT.Analytics", "Transcription completed: %s, model: %s, RTF: %.3f", + transcription_id, tracker.model_id.c_str(), real_time_factor); + + return RAC_SUCCESS; +} + +rac_result_t rac_stt_analytics_track_transcription_failed(rac_stt_analytics_handle_t handle, + const char* transcription_id, + rac_result_t error_code, + const char* error_message) { + if (!handle || !transcription_id) { + return RAC_ERROR_INVALID_PARAMETER; + } + + std::lock_guard lock(handle->mutex); + + handle->active_transcriptions.erase(transcription_id); + handle->last_event_time_ms = get_current_time_ms(); + handle->has_last_event_time = true; + + log_error("STT.Analytics", "Transcription failed %s: %d - %s", transcription_id, error_code, + error_message ? error_message : ""); + + return RAC_SUCCESS; +} + +rac_result_t rac_stt_analytics_track_language_detection(rac_stt_analytics_handle_t handle, + const char* language, float confidence) { + if (!handle || !language) { + return RAC_ERROR_INVALID_PARAMETER; + } + + log_debug("STT.Analytics", "Language detected: %s (%.2f)", language, confidence); + return RAC_SUCCESS; +} + +rac_result_t rac_stt_analytics_track_error(rac_stt_analytics_handle_t handle, + rac_result_t error_code, const char* error_message, + const char* operation, const char* model_id, + const char* transcription_id) { + if (!handle) { + return RAC_ERROR_INVALID_PARAMETER; + } + + std::lock_guard lock(handle->mutex); + + handle->last_event_time_ms = get_current_time_ms(); + handle->has_last_event_time = true; + + log_error("STT.Analytics", "STT error in %s: %d - %s (model: %s, trans: %s)", + operation ? operation : "unknown", error_code, error_message ? error_message : "", + model_id ? model_id : "none", transcription_id ? transcription_id : "none"); + + return RAC_SUCCESS; +} + +rac_result_t rac_stt_analytics_get_metrics(rac_stt_analytics_handle_t handle, + rac_stt_metrics_t* out_metrics) { + if (!handle || !out_metrics) { + return RAC_ERROR_INVALID_PARAMETER; + } + + std::lock_guard lock(handle->mutex); + + out_metrics->total_events = handle->transcription_count; + out_metrics->start_time_ms = handle->start_time_ms; + out_metrics->last_event_time_ms = handle->has_last_event_time ? handle->last_event_time_ms : 0; + out_metrics->total_transcriptions = handle->transcription_count; + + out_metrics->average_confidence = + handle->transcription_count > 0 + ? handle->total_confidence / static_cast(handle->transcription_count) + : 0; + + out_metrics->average_latency_ms = + handle->transcription_count > 0 + ? handle->total_latency_ms / static_cast(handle->transcription_count) + : 0; + + out_metrics->average_real_time_factor = + handle->transcription_count > 0 + ? handle->total_real_time_factor / static_cast(handle->transcription_count) + : 0; + + out_metrics->total_audio_processed_ms = handle->total_audio_processed_ms; + + return RAC_SUCCESS; +} + +} // extern "C" diff --git a/sdk/runanywhere-commons/src/features/stt/stt_component.cpp b/sdk/runanywhere-commons/src/features/stt/stt_component.cpp new file mode 100644 index 000000000..a79ccfaf1 --- /dev/null +++ b/sdk/runanywhere-commons/src/features/stt/stt_component.cpp @@ -0,0 +1,560 @@ +/** + * @file stt_component.cpp + * @brief STT Capability Component Implementation + * + * C++ port of Swift's STTCapability.swift + * Swift Source: Sources/RunAnywhere/Features/STT/STTCapability.swift + * + * IMPORTANT: This is a direct translation of the Swift implementation. + * Do NOT add features not present in the Swift code. + */ + +#include +#include +#include +#include +#include + +#include "rac/core/capabilities/rac_lifecycle.h" +#include "rac/core/rac_analytics_events.h" +#include "rac/core/rac_logger.h" +#include "rac/core/rac_platform_adapter.h" +#include "rac/core/rac_structured_error.h" +#include "rac/features/stt/rac_stt_component.h" +#include "rac/features/stt/rac_stt_service.h" + +// ============================================================================= +// INTERNAL STRUCTURES +// ============================================================================= + +/** + * Internal STT component state. + * Mirrors Swift's STTCapability actor state. + */ +struct rac_stt_component { + /** Lifecycle manager handle */ + rac_handle_t lifecycle; + + /** Current configuration */ + rac_stt_config_t config; + + /** Default transcription options based on config */ + rac_stt_options_t default_options; + + /** Mutex for thread safety */ + std::mutex mtx; + + rac_stt_component() : lifecycle(nullptr) { + // Initialize with defaults - matches rac_stt_types.h rac_stt_config_t + config = RAC_STT_CONFIG_DEFAULT; + + default_options = RAC_STT_OPTIONS_DEFAULT; + } +}; + +// ============================================================================= +// HELPER FUNCTIONS +// ============================================================================= + +/** + * Generate a unique ID for transcription tracking. + */ +static std::string generate_unique_id() { + auto now = std::chrono::high_resolution_clock::now(); + auto epoch = now.time_since_epoch(); + auto ns = std::chrono::duration_cast(epoch).count(); + char buffer[64]; + snprintf(buffer, sizeof(buffer), "trans_%lld", static_cast(ns)); + return std::string(buffer); +} + +/** + * Count words in text. + */ +static int32_t count_words(const char* text) { + if (!text) + return 0; + int32_t count = 0; + bool in_word = false; + while (*text != '\0') { + if (*text == ' ' || *text == '\t' || *text == '\n') { + in_word = false; + } else if (!in_word) { + in_word = true; + count++; + } + text++; + } + return count; +} + +// ============================================================================= +// LIFECYCLE CALLBACKS +// ============================================================================= + +static rac_result_t stt_create_service(const char* model_id, void* user_data, + rac_handle_t* out_service) { + (void)user_data; + + log_info("STT.Component", "Creating STT service"); + + // Create STT service + rac_result_t result = rac_stt_create(model_id, out_service); + if (result != RAC_SUCCESS) { + log_error("STT.Component", "Failed to create STT service"); + return result; + } + + // Initialize with model path + result = rac_stt_initialize(*out_service, model_id); + if (result != RAC_SUCCESS) { + log_error("STT.Component", "Failed to initialize STT service"); + rac_stt_destroy(*out_service); + *out_service = nullptr; + return result; + } + + log_info("STT.Component", "STT service created successfully"); + return RAC_SUCCESS; +} + +static void stt_destroy_service(rac_handle_t service, void* user_data) { + (void)user_data; + + if (service) { + log_info("STT.Component", "Destroying STT service"); + rac_stt_cleanup(service); + rac_stt_destroy(service); + } +} + +// ============================================================================= +// LIFECYCLE API +// ============================================================================= + +extern "C" rac_result_t rac_stt_component_create(rac_handle_t* out_handle) { + if (!out_handle) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + auto* component = new (std::nothrow) rac_stt_component(); + if (!component) { + return RAC_ERROR_OUT_OF_MEMORY; + } + + rac_lifecycle_config_t lifecycle_config = {}; + lifecycle_config.resource_type = RAC_RESOURCE_TYPE_STT_MODEL; + lifecycle_config.logger_category = "STT.Lifecycle"; + lifecycle_config.user_data = component; + + rac_result_t result = rac_lifecycle_create(&lifecycle_config, stt_create_service, + stt_destroy_service, &component->lifecycle); + + if (result != RAC_SUCCESS) { + delete component; + return result; + } + + *out_handle = reinterpret_cast(component); + + log_info("STT.Component", "STT component created"); + + return RAC_SUCCESS; +} + +extern "C" rac_result_t rac_stt_component_configure(rac_handle_t handle, + const rac_stt_config_t* config) { + if (!handle) + return RAC_ERROR_INVALID_HANDLE; + if (!config) + return RAC_ERROR_INVALID_ARGUMENT; + + auto* component = reinterpret_cast(handle); + std::lock_guard lock(component->mtx); + + component->config = *config; + + // Update default options based on config + if (config->language) { + component->default_options.language = config->language; + } + component->default_options.sample_rate = config->sample_rate; + component->default_options.enable_punctuation = config->enable_punctuation; + component->default_options.enable_timestamps = config->enable_timestamps; + + log_info("STT.Component", "STT component configured"); + + return RAC_SUCCESS; +} + +extern "C" rac_bool_t rac_stt_component_is_loaded(rac_handle_t handle) { + if (!handle) + return RAC_FALSE; + + auto* component = reinterpret_cast(handle); + return rac_lifecycle_is_loaded(component->lifecycle); +} + +extern "C" const char* rac_stt_component_get_model_id(rac_handle_t handle) { + if (!handle) + return nullptr; + + auto* component = reinterpret_cast(handle); + return rac_lifecycle_get_model_id(component->lifecycle); +} + +extern "C" void rac_stt_component_destroy(rac_handle_t handle) { + if (!handle) + return; + + auto* component = reinterpret_cast(handle); + + if (component->lifecycle) { + rac_lifecycle_destroy(component->lifecycle); + } + + log_info("STT.Component", "STT component destroyed"); + + delete component; +} + +// ============================================================================= +// MODEL LIFECYCLE +// ============================================================================= + +extern "C" rac_result_t rac_stt_component_load_model(rac_handle_t handle, const char* model_path, + const char* model_id, const char* model_name) { + if (!handle) + return RAC_ERROR_INVALID_HANDLE; + + auto* component = reinterpret_cast(handle); + std::lock_guard lock(component->mtx); + + rac_handle_t service = nullptr; + return rac_lifecycle_load(component->lifecycle, model_path, model_id, model_name, &service); +} + +extern "C" rac_result_t rac_stt_component_unload(rac_handle_t handle) { + if (!handle) + return RAC_ERROR_INVALID_HANDLE; + + auto* component = reinterpret_cast(handle); + std::lock_guard lock(component->mtx); + + return rac_lifecycle_unload(component->lifecycle); +} + +extern "C" rac_result_t rac_stt_component_cleanup(rac_handle_t handle) { + if (!handle) + return RAC_ERROR_INVALID_HANDLE; + + auto* component = reinterpret_cast(handle); + std::lock_guard lock(component->mtx); + + return rac_lifecycle_reset(component->lifecycle); +} + +// ============================================================================= +// TRANSCRIPTION API +// ============================================================================= + +extern "C" rac_result_t rac_stt_component_transcribe(rac_handle_t handle, const void* audio_data, + size_t audio_size, + const rac_stt_options_t* options, + rac_stt_result_t* out_result) { + if (!handle) + return RAC_ERROR_INVALID_HANDLE; + if (!audio_data || audio_size == 0) + return RAC_ERROR_INVALID_ARGUMENT; + if (!out_result) + return RAC_ERROR_INVALID_ARGUMENT; + + auto* component = reinterpret_cast(handle); + std::lock_guard lock(component->mtx); + + // Generate unique ID for this transcription + std::string transcription_id = generate_unique_id(); + const char* model_id = rac_lifecycle_get_model_id(component->lifecycle); + const char* model_name = rac_lifecycle_get_model_name(component->lifecycle); + + // Debug: Log if model_id is null + if (!model_id) { + log_warning( + "STT.Component", + "rac_lifecycle_get_model_id returned null - model_id may not be set in telemetry"); + } else { + log_debug("STT.Component", "STT transcription using model_id: %s", model_id); + } + + // Estimate audio length (assuming 16kHz mono 16-bit audio) + double audio_length_ms = (audio_size / 2.0 / 16000.0) * 1000.0; + + rac_handle_t service = nullptr; + rac_result_t result = rac_lifecycle_require_service(component->lifecycle, &service); + if (result != RAC_SUCCESS) { + log_error("STT.Component", "No model loaded - cannot transcribe"); + + // Emit transcription failed event + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_STT_TRANSCRIPTION_FAILED; + event.data.stt_transcription = RAC_ANALYTICS_STT_TRANSCRIPTION_DEFAULT; + event.data.stt_transcription.transcription_id = transcription_id.c_str(); + event.data.stt_transcription.model_id = model_id; + event.data.stt_transcription.model_name = model_name; + event.data.stt_transcription.error_code = result; + event.data.stt_transcription.error_message = "No model loaded"; + rac_analytics_event_emit(RAC_EVENT_STT_TRANSCRIPTION_FAILED, &event); + + return result; + } + + log_info("STT.Component", "Transcribing audio"); + + const rac_stt_options_t* effective_options = options ? options : &component->default_options; + + // Emit transcription started event + { + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_STT_TRANSCRIPTION_STARTED; + event.data.stt_transcription = RAC_ANALYTICS_STT_TRANSCRIPTION_DEFAULT; + event.data.stt_transcription.transcription_id = transcription_id.c_str(); + event.data.stt_transcription.model_id = model_id; + event.data.stt_transcription.model_name = model_name; + event.data.stt_transcription.audio_length_ms = audio_length_ms; + event.data.stt_transcription.audio_size_bytes = static_cast(audio_size); + event.data.stt_transcription.language = effective_options->language; + event.data.stt_transcription.is_streaming = RAC_FALSE; + event.data.stt_transcription.sample_rate = component->config.sample_rate; + event.data.stt_transcription.framework = + static_cast(component->config.preferred_framework); + rac_analytics_event_emit(RAC_EVENT_STT_TRANSCRIPTION_STARTED, &event); + } + + auto start_time = std::chrono::steady_clock::now(); + + result = rac_stt_transcribe(service, audio_data, audio_size, effective_options, out_result); + + if (result != RAC_SUCCESS) { + log_error("STT.Component", "Transcription failed"); + rac_lifecycle_track_error(component->lifecycle, result, "transcribe"); + + // Emit transcription failed event + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_STT_TRANSCRIPTION_FAILED; + event.data.stt_transcription = RAC_ANALYTICS_STT_TRANSCRIPTION_DEFAULT; + event.data.stt_transcription.transcription_id = transcription_id.c_str(); + event.data.stt_transcription.model_id = model_id; + event.data.stt_transcription.model_name = model_name; + event.data.stt_transcription.error_code = result; + event.data.stt_transcription.error_message = "Transcription failed"; + rac_analytics_event_emit(RAC_EVENT_STT_TRANSCRIPTION_FAILED, &event); + + return result; + } + + auto end_time = std::chrono::steady_clock::now(); + auto duration = std::chrono::duration_cast(end_time - start_time); + double duration_ms = static_cast(duration.count()); + + // Update metrics if not already set + if (out_result->processing_time_ms == 0) { + out_result->processing_time_ms = duration.count(); + } + + // Calculate word count and real-time factor + int32_t word_count = count_words(out_result->text); + double real_time_factor = + (audio_length_ms > 0 && duration_ms > 0) ? (audio_length_ms / duration_ms) : 0.0; + + log_info("STT.Component", "Transcription completed"); + + // Emit transcription completed event + { + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_STT_TRANSCRIPTION_COMPLETED; + event.data.stt_transcription.transcription_id = transcription_id.c_str(); + event.data.stt_transcription.model_id = model_id; + event.data.stt_transcription.model_name = model_name; + event.data.stt_transcription.text = out_result->text; + event.data.stt_transcription.confidence = out_result->confidence; + event.data.stt_transcription.duration_ms = duration_ms; + event.data.stt_transcription.audio_length_ms = audio_length_ms; + event.data.stt_transcription.audio_size_bytes = static_cast(audio_size); + event.data.stt_transcription.word_count = word_count; + event.data.stt_transcription.real_time_factor = real_time_factor; + event.data.stt_transcription.language = effective_options->language; + event.data.stt_transcription.sample_rate = component->config.sample_rate; + event.data.stt_transcription.framework = + static_cast(component->config.preferred_framework); + event.data.stt_transcription.error_code = RAC_SUCCESS; + rac_analytics_event_emit(RAC_EVENT_STT_TRANSCRIPTION_COMPLETED, &event); + } + + return RAC_SUCCESS; +} + +extern "C" rac_bool_t rac_stt_component_supports_streaming(rac_handle_t handle) { + if (!handle) + return RAC_FALSE; + + auto* component = reinterpret_cast(handle); + std::lock_guard lock(component->mtx); + + rac_handle_t service = rac_lifecycle_get_service(component->lifecycle); + if (!service) { + return RAC_FALSE; + } + + rac_stt_info_t info; + rac_result_t result = rac_stt_get_info(service, &info); + if (result != RAC_SUCCESS) { + return RAC_FALSE; + } + + return info.supports_streaming; +} + +extern "C" rac_result_t +rac_stt_component_transcribe_stream(rac_handle_t handle, const void* audio_data, size_t audio_size, + const rac_stt_options_t* options, + rac_stt_stream_callback_t callback, void* user_data) { + if (!handle) + return RAC_ERROR_INVALID_HANDLE; + if (!audio_data || audio_size == 0) + return RAC_ERROR_INVALID_ARGUMENT; + + auto* component = reinterpret_cast(handle); + std::lock_guard lock(component->mtx); + + rac_handle_t service = nullptr; + rac_result_t result = rac_lifecycle_require_service(component->lifecycle, &service); + if (result != RAC_SUCCESS) { + log_error("STT.Component", "No model loaded - cannot transcribe stream"); + return result; + } + + // Check if streaming is supported + rac_stt_info_t info; + result = rac_stt_get_info(service, &info); + if (result != RAC_SUCCESS || (info.supports_streaming == 0)) { + log_error("STT.Component", "Streaming not supported"); + return RAC_ERROR_NOT_SUPPORTED; + } + + log_info("STT.Component", "Starting streaming transcription"); + + const rac_stt_options_t* effective_options = options ? options : &component->default_options; + + // Get model info for telemetry - use lifecycle methods for consistency with non-streaming path + const char* model_id = rac_lifecycle_get_model_id(component->lifecycle); + const char* model_name = rac_lifecycle_get_model_name(component->lifecycle); + + // Debug: Log if model_id is null + if (!model_id) { + log_warning( + "STT.Component", + "rac_lifecycle_get_model_id returned null - model_id may not be set in telemetry"); + } else { + log_debug("STT.Component", "STT streaming transcription using model_id: %s", model_id); + } + + // Calculate audio length in ms (assume 16kHz, 16-bit mono) + double audio_length_ms = (audio_size * 1000.0) / (component->config.sample_rate * 2); + + // Generate transcription ID for tracking + std::string transcription_id = generate_unique_id(); + + // Emit STT_TRANSCRIPTION_STARTED event with is_streaming = RAC_TRUE + { + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_STT_TRANSCRIPTION_STARTED; + event.data.stt_transcription = RAC_ANALYTICS_STT_TRANSCRIPTION_DEFAULT; + event.data.stt_transcription.transcription_id = transcription_id.c_str(); + event.data.stt_transcription.model_id = model_id; + event.data.stt_transcription.model_name = model_name; + event.data.stt_transcription.audio_length_ms = audio_length_ms; + event.data.stt_transcription.audio_size_bytes = static_cast(audio_size); + event.data.stt_transcription.language = effective_options->language; + event.data.stt_transcription.is_streaming = RAC_TRUE; // Streaming mode! + event.data.stt_transcription.sample_rate = component->config.sample_rate; + event.data.stt_transcription.framework = + static_cast(component->config.preferred_framework); + rac_analytics_event_emit(RAC_EVENT_STT_TRANSCRIPTION_STARTED, &event); + } + + auto start_time = std::chrono::steady_clock::now(); + + result = rac_stt_transcribe_stream(service, audio_data, audio_size, effective_options, callback, + user_data); + + auto end_time = std::chrono::steady_clock::now(); + double duration_ms = + std::chrono::duration_cast(end_time - start_time).count(); + + if (result != RAC_SUCCESS) { + log_error("STT.Component", "Streaming transcription failed"); + rac_lifecycle_track_error(component->lifecycle, result, "transcribeStream"); + + // Emit STT_TRANSCRIPTION_FAILED event + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_STT_TRANSCRIPTION_FAILED; + event.data.stt_transcription = RAC_ANALYTICS_STT_TRANSCRIPTION_DEFAULT; + event.data.stt_transcription.transcription_id = transcription_id.c_str(); + event.data.stt_transcription.model_id = model_id; + event.data.stt_transcription.model_name = model_name; + event.data.stt_transcription.is_streaming = RAC_TRUE; + event.data.stt_transcription.duration_ms = duration_ms; + event.data.stt_transcription.error_code = result; + rac_analytics_event_emit(RAC_EVENT_STT_TRANSCRIPTION_FAILED, &event); + } else { + // Emit STT_TRANSCRIPTION_COMPLETED event with is_streaming = RAC_TRUE + // Note: For streaming, we don't have final consolidated text, so word_count is not + // available. We can still compute real_time_factor from audio_length_ms and duration_ms. + double real_time_factor = + (audio_length_ms > 0 && duration_ms > 0) ? (audio_length_ms / duration_ms) : 0.0; + + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_STT_TRANSCRIPTION_COMPLETED; + event.data.stt_transcription = RAC_ANALYTICS_STT_TRANSCRIPTION_DEFAULT; + event.data.stt_transcription.transcription_id = transcription_id.c_str(); + event.data.stt_transcription.model_id = model_id; + event.data.stt_transcription.model_name = model_name; + event.data.stt_transcription.audio_length_ms = audio_length_ms; + event.data.stt_transcription.audio_size_bytes = static_cast(audio_size); + event.data.stt_transcription.language = effective_options->language; + event.data.stt_transcription.is_streaming = RAC_TRUE; // Streaming mode! + event.data.stt_transcription.duration_ms = duration_ms; + event.data.stt_transcription.real_time_factor = real_time_factor; + // word_count not available for streaming - text is delivered via callbacks + event.data.stt_transcription.sample_rate = component->config.sample_rate; + event.data.stt_transcription.framework = + static_cast(component->config.preferred_framework); + event.data.stt_transcription.error_code = RAC_SUCCESS; + rac_analytics_event_emit(RAC_EVENT_STT_TRANSCRIPTION_COMPLETED, &event); + } + + return result; +} + +// ============================================================================= +// STATE QUERY API +// ============================================================================= + +extern "C" rac_lifecycle_state_t rac_stt_component_get_state(rac_handle_t handle) { + if (!handle) + return RAC_LIFECYCLE_STATE_IDLE; + + auto* component = reinterpret_cast(handle); + return rac_lifecycle_get_state(component->lifecycle); +} + +extern "C" rac_result_t rac_stt_component_get_metrics(rac_handle_t handle, + rac_lifecycle_metrics_t* out_metrics) { + if (!handle) + return RAC_ERROR_INVALID_HANDLE; + if (!out_metrics) + return RAC_ERROR_INVALID_ARGUMENT; + + auto* component = reinterpret_cast(handle); + return rac_lifecycle_get_metrics(component->lifecycle, out_metrics); +} diff --git a/sdk/runanywhere-commons/src/features/tts/rac_tts_service.cpp b/sdk/runanywhere-commons/src/features/tts/rac_tts_service.cpp new file mode 100644 index 000000000..f0a911130 --- /dev/null +++ b/sdk/runanywhere-commons/src/features/tts/rac_tts_service.cpp @@ -0,0 +1,176 @@ +/** + * @file rac_tts_service.cpp + * @brief TTS Service - Generic API with VTable Dispatch + * + * Simple dispatch layer that routes calls through the service vtable. + * Each backend provides its own vtable when creating a service. + */ + +#include "rac/features/tts/rac_tts_service.h" + +#include +#include + +#include "rac/core/rac_core.h" +#include "rac/core/rac_logger.h" +#include "rac/infrastructure/model_management/rac_model_registry.h" + +static const char* LOG_CAT = "TTS.Service"; + +// ============================================================================= +// SERVICE CREATION - Routes through Service Registry +// ============================================================================= + +extern "C" { + +rac_result_t rac_tts_create(const char* voice_id, rac_handle_t* out_handle) { + if (!voice_id || !out_handle) { + return RAC_ERROR_NULL_POINTER; + } + + *out_handle = nullptr; + + RAC_LOG_INFO(LOG_CAT, "Creating TTS service for: %s", voice_id); + + // Query model registry to get framework + rac_model_info_t* model_info = nullptr; + rac_result_t result = rac_get_model(voice_id, &model_info); + + rac_inference_framework_t framework = RAC_FRAMEWORK_ONNX; + const char* model_path = voice_id; + + if (result == RAC_SUCCESS && model_info) { + framework = model_info->framework; + model_path = model_info->local_path ? model_info->local_path : voice_id; + RAC_LOG_DEBUG(LOG_CAT, "Found model in registry, framework=%d", framework); + } + + // Build service request + rac_service_request_t request = {}; + request.identifier = voice_id; + request.capability = RAC_CAPABILITY_TTS; + request.framework = framework; + request.model_path = model_path; + + // Service registry returns a rac_tts_service_t* with vtable already set + result = rac_service_create(RAC_CAPABILITY_TTS, &request, out_handle); + + if (model_info) { + rac_model_info_free(model_info); + } + + if (result != RAC_SUCCESS) { + RAC_LOG_ERROR(LOG_CAT, "Failed to create service via registry"); + return result; + } + + RAC_LOG_INFO(LOG_CAT, "TTS service created"); + return RAC_SUCCESS; +} + +// ============================================================================= +// GENERIC API - Simple vtable dispatch +// ============================================================================= + +rac_result_t rac_tts_initialize(rac_handle_t handle) { + if (!handle) + return RAC_ERROR_NULL_POINTER; + + auto* service = static_cast(handle); + if (!service->ops || !service->ops->initialize) { + return RAC_ERROR_NOT_SUPPORTED; + } + + return service->ops->initialize(service->impl); +} + +rac_result_t rac_tts_synthesize(rac_handle_t handle, const char* text, + const rac_tts_options_t* options, rac_tts_result_t* out_result) { + if (!handle || !text || !out_result) + return RAC_ERROR_NULL_POINTER; + + auto* service = static_cast(handle); + if (!service->ops || !service->ops->synthesize) { + return RAC_ERROR_NOT_SUPPORTED; + } + + return service->ops->synthesize(service->impl, text, options, out_result); +} + +rac_result_t rac_tts_synthesize_stream(rac_handle_t handle, const char* text, + const rac_tts_options_t* options, + rac_tts_stream_callback_t callback, void* user_data) { + if (!handle || !text || !callback) + return RAC_ERROR_NULL_POINTER; + + auto* service = static_cast(handle); + if (!service->ops || !service->ops->synthesize_stream) { + return RAC_ERROR_NOT_SUPPORTED; + } + + return service->ops->synthesize_stream(service->impl, text, options, callback, user_data); +} + +rac_result_t rac_tts_stop(rac_handle_t handle) { + if (!handle) + return RAC_ERROR_NULL_POINTER; + + auto* service = static_cast(handle); + if (!service->ops || !service->ops->stop) { + return RAC_SUCCESS; // No-op if not supported + } + + return service->ops->stop(service->impl); +} + +rac_result_t rac_tts_get_info(rac_handle_t handle, rac_tts_info_t* out_info) { + if (!handle || !out_info) + return RAC_ERROR_NULL_POINTER; + + auto* service = static_cast(handle); + if (!service->ops || !service->ops->get_info) { + return RAC_ERROR_NOT_SUPPORTED; + } + + return service->ops->get_info(service->impl, out_info); +} + +rac_result_t rac_tts_cleanup(rac_handle_t handle) { + if (!handle) + return RAC_ERROR_NULL_POINTER; + + auto* service = static_cast(handle); + if (!service->ops || !service->ops->cleanup) { + return RAC_SUCCESS; + } + + return service->ops->cleanup(service->impl); +} + +void rac_tts_destroy(rac_handle_t handle) { + if (!handle) + return; + + auto* service = static_cast(handle); + + if (service->ops && service->ops->destroy) { + service->ops->destroy(service->impl); + } + + if (service->model_id) { + free(const_cast(service->model_id)); + } + + free(service); +} + +void rac_tts_result_free(rac_tts_result_t* result) { + if (!result) + return; + if (result->audio_data) { + free(result->audio_data); + result->audio_data = nullptr; + } +} + +} // extern "C" diff --git a/sdk/runanywhere-commons/src/features/tts/tts_analytics.cpp b/sdk/runanywhere-commons/src/features/tts/tts_analytics.cpp new file mode 100644 index 000000000..276f01bbc --- /dev/null +++ b/sdk/runanywhere-commons/src/features/tts/tts_analytics.cpp @@ -0,0 +1,290 @@ +/** + * @file tts_analytics.cpp + * @brief TTS analytics service implementation + * + * 1:1 port of Swift's TTSAnalyticsService.swift + * Swift Source: Sources/RunAnywhere/Features/TTS/Analytics/TTSAnalyticsService.swift + */ + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "rac/core/rac_logger.h" +#include "rac/features/tts/rac_tts_analytics.h" + +// ============================================================================= +// INTERNAL TYPES - Mirrors Swift's SynthesisTracker +// ============================================================================= + +namespace { + +struct SynthesisTracker { + int64_t start_time_ms; + std::string model_id; + int32_t character_count; + int32_t sample_rate; + rac_inference_framework_t framework; +}; + +int64_t get_current_time_ms() { + auto now = std::chrono::system_clock::now(); + auto duration = now.time_since_epoch(); + return std::chrono::duration_cast(duration).count(); +} + +std::string generate_uuid() { + static std::random_device rd; + static std::mt19937 gen(rd()); + static std::uniform_int_distribution<> dis(0, 15); + + std::stringstream ss; + ss << std::hex; + + for (int i = 0; i < 8; i++) + ss << dis(gen); + ss << "-"; + for (int i = 0; i < 4; i++) + ss << dis(gen); + ss << "-4"; + for (int i = 0; i < 3; i++) + ss << dis(gen); + ss << "-"; + ss << (8 + dis(gen) % 4); + for (int i = 0; i < 3; i++) + ss << dis(gen); + ss << "-"; + for (int i = 0; i < 12; i++) + ss << dis(gen); + + return ss.str(); +} + +} // namespace + +// ============================================================================= +// TTS ANALYTICS SERVICE IMPLEMENTATION +// ============================================================================= + +struct rac_tts_analytics_s { + std::mutex mutex; + std::map active_syntheses; + + // Metrics (mirrors Swift) + int32_t synthesis_count; + int32_t total_characters; + double total_processing_time_ms; + double total_audio_duration_ms; + int64_t total_audio_size_bytes; + double total_characters_per_second; + int64_t start_time_ms; + int64_t last_event_time_ms; + bool has_last_event_time; + + rac_tts_analytics_s() + : synthesis_count(0), + total_characters(0), + total_processing_time_ms(0), + total_audio_duration_ms(0), + total_audio_size_bytes(0), + total_characters_per_second(0), + start_time_ms(get_current_time_ms()), + last_event_time_ms(0), + has_last_event_time(false) {} +}; + +// ============================================================================= +// C API IMPLEMENTATION +// ============================================================================= + +extern "C" { + +rac_result_t rac_tts_analytics_create(rac_tts_analytics_handle_t* out_handle) { + if (!out_handle) { + return RAC_ERROR_INVALID_PARAMETER; + } + + try { + *out_handle = new rac_tts_analytics_s(); + log_info("TTS.Analytics", "TTS analytics service created"); + return RAC_SUCCESS; + } catch (...) { + return RAC_ERROR_OUT_OF_MEMORY; + } +} + +void rac_tts_analytics_destroy(rac_tts_analytics_handle_t handle) { + if (handle) { + delete handle; + log_info("TTS.Analytics", "TTS analytics service destroyed"); + } +} + +rac_result_t rac_tts_analytics_start_synthesis(rac_tts_analytics_handle_t handle, const char* text, + const char* voice, int32_t sample_rate, + rac_inference_framework_t framework, + char** out_synthesis_id) { + if (!handle || !text || !voice || !out_synthesis_id) { + return RAC_ERROR_INVALID_PARAMETER; + } + + std::lock_guard lock(handle->mutex); + + std::string id = generate_uuid(); + int32_t character_count = static_cast(strlen(text)); + + SynthesisTracker tracker; + tracker.start_time_ms = get_current_time_ms(); + tracker.model_id = voice; + tracker.character_count = character_count; + tracker.sample_rate = sample_rate; + tracker.framework = framework; + + handle->active_syntheses[id] = tracker; + + *out_synthesis_id = static_cast(malloc(id.size() + 1)); + if (!*out_synthesis_id) { + return RAC_ERROR_OUT_OF_MEMORY; + } + strcpy(*out_synthesis_id, id.c_str()); + + log_debug("TTS.Analytics", "Synthesis started: %s, voice: %s, %d characters", id.c_str(), voice, + character_count); + + return RAC_SUCCESS; +} + +rac_result_t rac_tts_analytics_track_synthesis_chunk(rac_tts_analytics_handle_t handle, + const char* synthesis_id, int32_t chunk_size) { + if (!handle || !synthesis_id) { + return RAC_ERROR_INVALID_PARAMETER; + } + + // Event would be published here in full implementation + log_debug("TTS.Analytics", "Synthesis chunk: %s, size: %d", synthesis_id, chunk_size); + return RAC_SUCCESS; +} + +rac_result_t rac_tts_analytics_complete_synthesis(rac_tts_analytics_handle_t handle, + const char* synthesis_id, + double audio_duration_ms, + int32_t audio_size_bytes) { + if (!handle || !synthesis_id) { + return RAC_ERROR_INVALID_PARAMETER; + } + + std::lock_guard lock(handle->mutex); + + auto it = handle->active_syntheses.find(synthesis_id); + if (it == handle->active_syntheses.end()) { + return RAC_ERROR_NOT_FOUND; + } + + SynthesisTracker tracker = it->second; + handle->active_syntheses.erase(it); + + int64_t end_time_ms = get_current_time_ms(); + double processing_time_ms = static_cast(end_time_ms - tracker.start_time_ms); + int32_t character_count = tracker.character_count; + + // Calculate characters per second + double chars_per_second = processing_time_ms > 0 ? static_cast(character_count) / + (processing_time_ms / 1000.0) + : 0; + + // Update metrics + handle->synthesis_count++; + handle->total_characters += character_count; + handle->total_processing_time_ms += processing_time_ms; + handle->total_audio_duration_ms += audio_duration_ms; + handle->total_audio_size_bytes += audio_size_bytes; + handle->total_characters_per_second += chars_per_second; + handle->last_event_time_ms = end_time_ms; + handle->has_last_event_time = true; + + log_debug("TTS.Analytics", "Synthesis completed: %s, voice: %s, audio: %.1fms, %d bytes", + synthesis_id, tracker.model_id.c_str(), audio_duration_ms, audio_size_bytes); + + return RAC_SUCCESS; +} + +rac_result_t rac_tts_analytics_track_synthesis_failed(rac_tts_analytics_handle_t handle, + const char* synthesis_id, + rac_result_t error_code, + const char* error_message) { + if (!handle || !synthesis_id) { + return RAC_ERROR_INVALID_PARAMETER; + } + + std::lock_guard lock(handle->mutex); + + handle->active_syntheses.erase(synthesis_id); + handle->last_event_time_ms = get_current_time_ms(); + handle->has_last_event_time = true; + + log_error("TTS.Analytics", "Synthesis failed %s: %d - %s", synthesis_id, error_code, + error_message ? error_message : ""); + + return RAC_SUCCESS; +} + +rac_result_t rac_tts_analytics_track_error(rac_tts_analytics_handle_t handle, + rac_result_t error_code, const char* error_message, + const char* operation, const char* model_id, + const char* synthesis_id) { + if (!handle) { + return RAC_ERROR_INVALID_PARAMETER; + } + + std::lock_guard lock(handle->mutex); + + handle->last_event_time_ms = get_current_time_ms(); + handle->has_last_event_time = true; + + log_error("TTS.Analytics", "TTS error in %s: %d - %s (model: %s, syn: %s)", + operation ? operation : "unknown", error_code, error_message ? error_message : "", + model_id ? model_id : "none", synthesis_id ? synthesis_id : "none"); + + return RAC_SUCCESS; +} + +rac_result_t rac_tts_analytics_get_metrics(rac_tts_analytics_handle_t handle, + rac_tts_metrics_t* out_metrics) { + if (!handle || !out_metrics) { + return RAC_ERROR_INVALID_PARAMETER; + } + + std::lock_guard lock(handle->mutex); + + out_metrics->total_events = handle->synthesis_count; + out_metrics->start_time_ms = handle->start_time_ms; + out_metrics->last_event_time_ms = handle->has_last_event_time ? handle->last_event_time_ms : 0; + out_metrics->total_syntheses = handle->synthesis_count; + + out_metrics->average_characters_per_second = + handle->synthesis_count > 0 + ? handle->total_characters_per_second / static_cast(handle->synthesis_count) + : 0; + + out_metrics->average_processing_time_ms = + handle->synthesis_count > 0 + ? handle->total_processing_time_ms / static_cast(handle->synthesis_count) + : 0; + + out_metrics->average_audio_duration_ms = + handle->synthesis_count > 0 + ? handle->total_audio_duration_ms / static_cast(handle->synthesis_count) + : 0; + + out_metrics->total_characters_processed = handle->total_characters; + out_metrics->total_audio_size_bytes = handle->total_audio_size_bytes; + + return RAC_SUCCESS; +} + +} // extern "C" diff --git a/sdk/runanywhere-commons/src/features/tts/tts_component.cpp b/sdk/runanywhere-commons/src/features/tts/tts_component.cpp new file mode 100644 index 000000000..5041f036f --- /dev/null +++ b/sdk/runanywhere-commons/src/features/tts/tts_component.cpp @@ -0,0 +1,479 @@ +/** + * @file tts_component.cpp + * @brief TTS Capability Component Implementation + * + * C++ port of Swift's TTSCapability.swift + * Swift Source: Sources/RunAnywhere/Features/TTS/TTSCapability.swift + * + * IMPORTANT: This is a direct translation of the Swift implementation. + * Do NOT add features not present in the Swift code. + */ + +#include +#include +#include +#include +#include + +#include "rac/core/capabilities/rac_lifecycle.h" +#include "rac/core/rac_analytics_events.h" +#include "rac/core/rac_logger.h" +#include "rac/core/rac_platform_adapter.h" +#include "rac/core/rac_structured_error.h" +#include "rac/features/tts/rac_tts_component.h" +#include "rac/features/tts/rac_tts_service.h" + +// ============================================================================= +// INTERNAL STRUCTURES +// ============================================================================= + +struct rac_tts_component { + rac_handle_t lifecycle; + rac_tts_config_t config; + rac_tts_options_t default_options; + std::mutex mtx; + + rac_tts_component() : lifecycle(nullptr) { + // Initialize with defaults - matches rac_tts_types.h rac_tts_config_t + config = RAC_TTS_CONFIG_DEFAULT; + + default_options = RAC_TTS_OPTIONS_DEFAULT; + } +}; + +// ============================================================================= +// HELPER FUNCTIONS +// ============================================================================= + +// Generate a simple UUID v4-like string for event tracking +static std::string generate_uuid_v4() { + static const char* hex = "0123456789abcdef"; + std::string uuid = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx"; + for (size_t i = 0; i < uuid.size(); i++) { + if (uuid[i] == 'x') { + uuid[i] = hex[std::rand() % 16]; + } else if (uuid[i] == 'y') { + uuid[i] = hex[(std::rand() % 4) + 8]; + } + } + return uuid; +} + +// ============================================================================= +// LIFECYCLE CALLBACKS +// ============================================================================= + +static rac_result_t tts_create_service(const char* voice_id, void* user_data, + rac_handle_t* out_service) { + (void)user_data; + + log_info("TTS.Component", "Creating TTS service"); + + rac_result_t result = rac_tts_create(voice_id, out_service); + if (result != RAC_SUCCESS) { + log_error("TTS.Component", "Failed to create TTS service"); + return result; + } + + result = rac_tts_initialize(*out_service); + if (result != RAC_SUCCESS) { + log_error("TTS.Component", "Failed to initialize TTS service"); + rac_tts_destroy(*out_service); + *out_service = nullptr; + return result; + } + + log_info("TTS.Component", "TTS service created successfully"); + return RAC_SUCCESS; +} + +static void tts_destroy_service(rac_handle_t service, void* user_data) { + (void)user_data; + + if (service) { + log_info("TTS.Component", "Destroying TTS service"); + rac_tts_cleanup(service); + rac_tts_destroy(service); + } +} + +// ============================================================================= +// LIFECYCLE API +// ============================================================================= + +extern "C" rac_result_t rac_tts_component_create(rac_handle_t* out_handle) { + if (!out_handle) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + auto* component = new (std::nothrow) rac_tts_component(); + if (!component) { + return RAC_ERROR_OUT_OF_MEMORY; + } + + rac_lifecycle_config_t lifecycle_config = {}; + lifecycle_config.resource_type = RAC_RESOURCE_TYPE_TTS_VOICE; + lifecycle_config.logger_category = "TTS.Lifecycle"; + lifecycle_config.user_data = component; + + rac_result_t result = rac_lifecycle_create(&lifecycle_config, tts_create_service, + tts_destroy_service, &component->lifecycle); + + if (result != RAC_SUCCESS) { + delete component; + return result; + } + + *out_handle = reinterpret_cast(component); + + log_info("TTS.Component", "TTS component created"); + + return RAC_SUCCESS; +} + +extern "C" rac_result_t rac_tts_component_configure(rac_handle_t handle, + const rac_tts_config_t* config) { + if (!handle) + return RAC_ERROR_INVALID_HANDLE; + if (!config) + return RAC_ERROR_INVALID_ARGUMENT; + + auto* component = reinterpret_cast(handle); + std::lock_guard lock(component->mtx); + + component->config = *config; + + // Update default options based on config - matches rac_tts_config_t fields + if (config->speaking_rate > 0) { + component->default_options.rate = config->speaking_rate; + } + if (config->pitch > 0) { + component->default_options.pitch = config->pitch; + } + if (config->volume > 0) { + component->default_options.volume = config->volume; + } + if (config->language) { + component->default_options.language = config->language; + } + if (config->voice) { + component->default_options.voice = config->voice; + } + component->default_options.use_ssml = config->enable_ssml; + + log_info("TTS.Component", "TTS component configured"); + + return RAC_SUCCESS; +} + +extern "C" rac_bool_t rac_tts_component_is_loaded(rac_handle_t handle) { + if (!handle) + return RAC_FALSE; + + auto* component = reinterpret_cast(handle); + return rac_lifecycle_is_loaded(component->lifecycle); +} + +extern "C" const char* rac_tts_component_get_voice_id(rac_handle_t handle) { + if (!handle) + return nullptr; + + auto* component = reinterpret_cast(handle); + return rac_lifecycle_get_model_id(component->lifecycle); +} + +extern "C" void rac_tts_component_destroy(rac_handle_t handle) { + if (!handle) + return; + + auto* component = reinterpret_cast(handle); + + if (component->lifecycle) { + rac_lifecycle_destroy(component->lifecycle); + } + + log_info("TTS.Component", "TTS component destroyed"); + + delete component; +} + +// ============================================================================= +// VOICE LIFECYCLE +// ============================================================================= + +extern "C" rac_result_t rac_tts_component_load_voice(rac_handle_t handle, const char* voice_path, + const char* voice_id, const char* voice_name) { + if (!handle) + return RAC_ERROR_INVALID_HANDLE; + + auto* component = reinterpret_cast(handle); + std::lock_guard lock(component->mtx); + + rac_handle_t service = nullptr; + return rac_lifecycle_load(component->lifecycle, voice_path, voice_id, voice_name, &service); +} + +extern "C" rac_result_t rac_tts_component_unload(rac_handle_t handle) { + if (!handle) + return RAC_ERROR_INVALID_HANDLE; + + auto* component = reinterpret_cast(handle); + std::lock_guard lock(component->mtx); + + return rac_lifecycle_unload(component->lifecycle); +} + +extern "C" rac_result_t rac_tts_component_cleanup(rac_handle_t handle) { + if (!handle) + return RAC_ERROR_INVALID_HANDLE; + + auto* component = reinterpret_cast(handle); + std::lock_guard lock(component->mtx); + + return rac_lifecycle_reset(component->lifecycle); +} + +extern "C" rac_result_t rac_tts_component_stop(rac_handle_t handle) { + if (!handle) + return RAC_ERROR_INVALID_HANDLE; + + auto* component = reinterpret_cast(handle); + std::lock_guard lock(component->mtx); + + rac_handle_t service = rac_lifecycle_get_service(component->lifecycle); + if (service) { + rac_tts_stop(service); + } + + log_info("TTS.Component", "Synthesis stop requested"); + + return RAC_SUCCESS; +} + +// ============================================================================= +// SYNTHESIS API +// ============================================================================= + +extern "C" rac_result_t rac_tts_component_synthesize(rac_handle_t handle, const char* text, + const rac_tts_options_t* options, + rac_tts_result_t* out_result) { + if (!handle) + return RAC_ERROR_INVALID_HANDLE; + if (!text) + return RAC_ERROR_INVALID_ARGUMENT; + if (!out_result) + return RAC_ERROR_INVALID_ARGUMENT; + + auto* component = reinterpret_cast(handle); + std::lock_guard lock(component->mtx); + + // Generate synthesis ID for event tracking + std::string synthesis_id = generate_uuid_v4(); + const char* voice_id = rac_lifecycle_get_model_id(component->lifecycle); + const char* voice_name = rac_lifecycle_get_model_name(component->lifecycle); + + // Debug: Log if voice_id is null + if (!voice_id) { + log_warning("TTS.Component", + "rac_lifecycle_get_model_id returned null - voice may not be set in telemetry"); + } else { + log_debug("TTS.Component", "TTS synthesis using voice_id: %s", voice_id); + } + + // Emit SYNTHESIS_STARTED event + { + rac_analytics_event_data_t event_data; + event_data.data.tts_synthesis = RAC_ANALYTICS_TTS_SYNTHESIS_DEFAULT; + event_data.data.tts_synthesis.synthesis_id = synthesis_id.c_str(); + event_data.data.tts_synthesis.model_id = voice_id; + event_data.data.tts_synthesis.model_name = voice_name; + event_data.data.tts_synthesis.character_count = static_cast(std::strlen(text)); + rac_analytics_event_emit(RAC_EVENT_TTS_SYNTHESIS_STARTED, &event_data); + } + + rac_handle_t service = nullptr; + rac_result_t result = rac_lifecycle_require_service(component->lifecycle, &service); + if (result != RAC_SUCCESS) { + log_error("TTS.Component", "No voice loaded - cannot synthesize"); + // Emit SYNTHESIS_FAILED event + rac_analytics_event_data_t event_data; + event_data.data.tts_synthesis = RAC_ANALYTICS_TTS_SYNTHESIS_DEFAULT; + event_data.data.tts_synthesis.synthesis_id = synthesis_id.c_str(); + event_data.data.tts_synthesis.model_id = voice_id; + event_data.data.tts_synthesis.model_name = voice_name; + event_data.data.tts_synthesis.error_code = result; + event_data.data.tts_synthesis.error_message = "No voice loaded"; + rac_analytics_event_emit(RAC_EVENT_TTS_SYNTHESIS_FAILED, &event_data); + return result; + } + + log_info("TTS.Component", "Synthesizing text"); + + const rac_tts_options_t* effective_options = options ? options : &component->default_options; + + auto start_time = std::chrono::steady_clock::now(); + + result = rac_tts_synthesize(service, text, effective_options, out_result); + + auto end_time = std::chrono::steady_clock::now(); + auto duration = std::chrono::duration_cast(end_time - start_time); + + if (result != RAC_SUCCESS) { + log_error("TTS.Component", "Synthesis failed"); + rac_lifecycle_track_error(component->lifecycle, result, "synthesize"); + // Emit SYNTHESIS_FAILED event + rac_analytics_event_data_t event_data; + event_data.data.tts_synthesis = RAC_ANALYTICS_TTS_SYNTHESIS_DEFAULT; + event_data.data.tts_synthesis.synthesis_id = synthesis_id.c_str(); + event_data.data.tts_synthesis.model_id = voice_id; + event_data.data.tts_synthesis.model_name = voice_name; + event_data.data.tts_synthesis.processing_duration_ms = + static_cast(duration.count()); + event_data.data.tts_synthesis.error_code = result; + event_data.data.tts_synthesis.error_message = "Synthesis failed"; + rac_analytics_event_emit(RAC_EVENT_TTS_SYNTHESIS_FAILED, &event_data); + return result; + } + + if (out_result->processing_time_ms == 0) { + out_result->processing_time_ms = duration.count(); + } + + // Emit SYNTHESIS_COMPLETED event + { + int32_t char_count = static_cast(std::strlen(text)); + double processing_ms = static_cast(out_result->processing_time_ms); + double chars_per_sec = processing_ms > 0 ? (char_count * 1000.0 / processing_ms) : 0.0; + + rac_analytics_event_data_t event_data; + event_data.data.tts_synthesis = RAC_ANALYTICS_TTS_SYNTHESIS_DEFAULT; + event_data.data.tts_synthesis.synthesis_id = synthesis_id.c_str(); + event_data.data.tts_synthesis.model_id = voice_id; + event_data.data.tts_synthesis.model_name = voice_name; + event_data.data.tts_synthesis.character_count = char_count; + event_data.data.tts_synthesis.audio_duration_ms = + static_cast(out_result->duration_ms); + event_data.data.tts_synthesis.audio_size_bytes = + static_cast(out_result->audio_size); + event_data.data.tts_synthesis.processing_duration_ms = processing_ms; + event_data.data.tts_synthesis.characters_per_second = chars_per_sec; + event_data.data.tts_synthesis.sample_rate = static_cast(out_result->sample_rate); + rac_analytics_event_emit(RAC_EVENT_TTS_SYNTHESIS_COMPLETED, &event_data); + } + + log_info("TTS.Component", "Synthesis completed"); + + return RAC_SUCCESS; +} + +extern "C" rac_result_t rac_tts_component_synthesize_stream(rac_handle_t handle, const char* text, + const rac_tts_options_t* options, + rac_tts_stream_callback_t callback, + void* user_data) { + if (!handle) + return RAC_ERROR_INVALID_HANDLE; + if (!text) + return RAC_ERROR_INVALID_ARGUMENT; + + auto* component = reinterpret_cast(handle); + std::lock_guard lock(component->mtx); + + // Generate synthesis ID for event tracking + std::string synthesis_id = generate_uuid_v4(); + const char* voice_id = rac_lifecycle_get_model_id(component->lifecycle); + const char* voice_name = rac_lifecycle_get_model_name(component->lifecycle); + int32_t char_count = static_cast(std::strlen(text)); + + // Emit SYNTHESIS_STARTED event + { + rac_analytics_event_data_t event_data; + event_data.data.tts_synthesis = RAC_ANALYTICS_TTS_SYNTHESIS_DEFAULT; + event_data.data.tts_synthesis.synthesis_id = synthesis_id.c_str(); + event_data.data.tts_synthesis.model_id = voice_id; + event_data.data.tts_synthesis.model_name = voice_name; + event_data.data.tts_synthesis.character_count = char_count; + rac_analytics_event_emit(RAC_EVENT_TTS_SYNTHESIS_STARTED, &event_data); + } + + rac_handle_t service = nullptr; + rac_result_t result = rac_lifecycle_require_service(component->lifecycle, &service); + if (result != RAC_SUCCESS) { + log_error("TTS.Component", "No voice loaded - cannot synthesize stream"); + // Emit SYNTHESIS_FAILED event + rac_analytics_event_data_t event_data; + event_data.data.tts_synthesis = RAC_ANALYTICS_TTS_SYNTHESIS_DEFAULT; + event_data.data.tts_synthesis.synthesis_id = synthesis_id.c_str(); + event_data.data.tts_synthesis.model_id = voice_id; + event_data.data.tts_synthesis.model_name = voice_name; + event_data.data.tts_synthesis.error_code = result; + event_data.data.tts_synthesis.error_message = "No voice loaded"; + rac_analytics_event_emit(RAC_EVENT_TTS_SYNTHESIS_FAILED, &event_data); + return result; + } + + log_info("TTS.Component", "Starting streaming synthesis"); + + const rac_tts_options_t* effective_options = options ? options : &component->default_options; + + auto start_time = std::chrono::steady_clock::now(); + + result = rac_tts_synthesize_stream(service, text, effective_options, callback, user_data); + + auto end_time = std::chrono::steady_clock::now(); + auto duration = std::chrono::duration_cast(end_time - start_time); + + if (result != RAC_SUCCESS) { + log_error("TTS.Component", "Streaming synthesis failed"); + rac_lifecycle_track_error(component->lifecycle, result, "synthesizeStream"); + // Emit SYNTHESIS_FAILED event + rac_analytics_event_data_t event_data; + event_data.data.tts_synthesis = RAC_ANALYTICS_TTS_SYNTHESIS_DEFAULT; + event_data.data.tts_synthesis.synthesis_id = synthesis_id.c_str(); + event_data.data.tts_synthesis.model_id = voice_id; + event_data.data.tts_synthesis.model_name = voice_name; + event_data.data.tts_synthesis.processing_duration_ms = + static_cast(duration.count()); + event_data.data.tts_synthesis.error_code = result; + event_data.data.tts_synthesis.error_message = "Streaming synthesis failed"; + rac_analytics_event_emit(RAC_EVENT_TTS_SYNTHESIS_FAILED, &event_data); + } else { + // Emit SYNTHESIS_COMPLETED event (streaming complete) + double processing_ms = static_cast(duration.count()); + double chars_per_sec = processing_ms > 0 ? (char_count * 1000.0 / processing_ms) : 0.0; + + rac_analytics_event_data_t event_data; + event_data.data.tts_synthesis = RAC_ANALYTICS_TTS_SYNTHESIS_DEFAULT; + event_data.data.tts_synthesis.synthesis_id = synthesis_id.c_str(); + event_data.data.tts_synthesis.model_id = voice_id; + event_data.data.tts_synthesis.model_name = voice_name; + event_data.data.tts_synthesis.character_count = char_count; + event_data.data.tts_synthesis.processing_duration_ms = processing_ms; + event_data.data.tts_synthesis.characters_per_second = chars_per_sec; + rac_analytics_event_emit(RAC_EVENT_TTS_SYNTHESIS_COMPLETED, &event_data); + } + + return result; +} + +// ============================================================================= +// STATE QUERY API +// ============================================================================= + +extern "C" rac_lifecycle_state_t rac_tts_component_get_state(rac_handle_t handle) { + if (!handle) + return RAC_LIFECYCLE_STATE_IDLE; + + auto* component = reinterpret_cast(handle); + return rac_lifecycle_get_state(component->lifecycle); +} + +extern "C" rac_result_t rac_tts_component_get_metrics(rac_handle_t handle, + rac_lifecycle_metrics_t* out_metrics) { + if (!handle) + return RAC_ERROR_INVALID_HANDLE; + if (!out_metrics) + return RAC_ERROR_INVALID_ARGUMENT; + + auto* component = reinterpret_cast(handle); + return rac_lifecycle_get_metrics(component->lifecycle, out_metrics); +} diff --git a/sdk/runanywhere-commons/src/features/vad/energy_vad.cpp b/sdk/runanywhere-commons/src/features/vad/energy_vad.cpp new file mode 100644 index 000000000..0d856afbe --- /dev/null +++ b/sdk/runanywhere-commons/src/features/vad/energy_vad.cpp @@ -0,0 +1,754 @@ +/** + * @file energy_vad.cpp + * @brief RunAnywhere Commons - Energy-based VAD Service Implementation + * + * C++ port of Swift's SimpleEnergyVADService.swift from: + * Sources/RunAnywhere/Features/VAD/Services/SimpleEnergyVADService.swift + * + * CRITICAL: This is a direct port of Swift implementation - do NOT add custom logic! + */ + +#include +#include +#include +#include +#include +#include +#include + +#include "rac/core/rac_logger.h" +#include "rac/core/rac_platform_adapter.h" +#include "rac/core/rac_structured_error.h" +#include "rac/features/vad/rac_vad_energy.h" + +// ============================================================================= +// INTERNAL STRUCTURE - Mirrors Swift's SimpleEnergyVADService properties +// ============================================================================= + +struct rac_energy_vad { + + // Hot data -> accessed frequently + + bool is_active; + bool is_currently_speaking; + bool is_paused; + bool is_tts_active; + + int32_t consecutive_silent_frames; + int32_t consecutive_voice_frames; + + float energy_threshold; + float base_energy_threshold; + + int32_t voice_start_threshold; + int32_t voice_end_threshold; + int32_t tts_voice_start_threshold; + int32_t tts_voice_end_threshold; + + size_t ring_buffer_write_index; + size_t ring_buffer_count; + + // Cold data -> accessed less frequently + + int32_t sample_rate; + int32_t frame_length_samples; + float tts_threshold_multiplier; + float calibration_multiplier; + + bool is_calibrating; + float ambient_noise_level; + int32_t calibration_frame_count; + int32_t calibration_frames_needed; + std::vector calibration_samples; + + std::vector recent_energy_values; + int32_t max_recent_values; + int32_t debug_frame_count; + + rac_speech_activity_callback_fn speech_callback; + void* speech_user_data; + rac_audio_buffer_callback_fn audio_callback; + void* audio_user_data; + + std::mutex mutex; +}; + +// ============================================================================= +// HELPER FUNCTIONS - Mirrors Swift's private methods +// ============================================================================= + +/** + * Update voice activity state with hysteresis + * Mirrors Swift's updateVoiceActivityState(hasVoice:) + */ +static void update_voice_activity_state(rac_energy_vad* vad, bool has_voice) { + // Use different thresholds based on TTS state (mirrors Swift logic) + int32_t start_threshold = + vad->is_tts_active ? vad->tts_voice_start_threshold : vad->voice_start_threshold; + int32_t end_threshold = + vad->is_tts_active ? vad->tts_voice_end_threshold : vad->voice_end_threshold; + + if (has_voice) { + vad->consecutive_voice_frames++; + vad->consecutive_silent_frames = 0; + + // Start speaking if we have enough consecutive voice frames + if (!vad->is_currently_speaking && vad->consecutive_voice_frames >= start_threshold) { + // Extra validation during TTS to prevent false positives (mirrors Swift) + if (vad->is_tts_active) { + RAC_LOG_WARNING("EnergyVAD", + "Voice detected during TTS playback - likely feedback! Ignoring."); + return; + } + + vad->is_currently_speaking = true; + RAC_LOG_INFO("EnergyVAD", "VAD: SPEECH STARTED"); + + // Fire callback + if (vad->speech_callback) { + vad->speech_callback(RAC_SPEECH_ACTIVITY_STARTED, vad->speech_user_data); + } + } + } else { + vad->consecutive_silent_frames++; + vad->consecutive_voice_frames = 0; + + // Stop speaking if we have enough consecutive silent frames + if (vad->is_currently_speaking && vad->consecutive_silent_frames >= end_threshold) { + vad->is_currently_speaking = false; + RAC_LOG_INFO("EnergyVAD", "VAD: SPEECH ENDED"); + + // Fire callback + if (vad->speech_callback) { + vad->speech_callback(RAC_SPEECH_ACTIVITY_ENDED, vad->speech_user_data); + } + } + } +} + +/** + * Handle a frame during calibration + * Mirrors Swift's handleCalibrationFrame(energy:) + */ +static void handle_calibration_frame(rac_energy_vad* vad, float energy) { + if (!vad->is_calibrating) { + return; + } + + vad->calibration_samples.push_back(energy); + vad->calibration_frame_count++; + + if (vad->calibration_frame_count >= vad->calibration_frames_needed) { + // Complete calibration - mirrors Swift's completeCalibration() + if (vad->calibration_samples.empty()) { + vad->is_calibrating = false; + return; + } + + // Calculate statistics (mirrors Swift logic) + std::vector sorted_samples = vad->calibration_samples; + std::sort(sorted_samples.begin(), sorted_samples.end()); + + size_t count = sorted_samples.size(); + float percentile_90 = + sorted_samples[std::min(count - 1, static_cast(count * 0.90f))]; + + // Use 90th percentile as ambient noise level (mirrors Swift) + vad->ambient_noise_level = percentile_90; + + // Calculate dynamic threshold (mirrors Swift logic) + float minimum_threshold = std::max(vad->ambient_noise_level * 2.0f, RAC_VAD_MIN_THRESHOLD); + float calculated_threshold = vad->ambient_noise_level * vad->calibration_multiplier; + + // Apply threshold with sensible bounds + vad->energy_threshold = std::max(calculated_threshold, minimum_threshold); + + // Cap at reasonable maximum (mirrors Swift cap) + if (vad->energy_threshold > RAC_VAD_MAX_THRESHOLD) { + vad->energy_threshold = RAC_VAD_MAX_THRESHOLD; + RAC_LOG_WARNING("EnergyVAD", + "Calibration detected high ambient noise. Capping threshold."); + } + + RAC_LOG_INFO("EnergyVAD", "VAD Calibration Complete"); + + vad->is_calibrating = false; + vad->calibration_samples.clear(); + } +} + +/** + * Update debug statistics + * Mirrors Swift's updateDebugStatistics(energy:) + * Optimised to use ring buffer + */ +static void update_debug_statistics(rac_energy_vad* vad, float energy) { + if (vad->recent_energy_values.empty()) { + return; + } + + vad->recent_energy_values[vad->ring_buffer_write_index] = energy; + + vad->ring_buffer_write_index++; + if (vad->ring_buffer_write_index >= vad->recent_energy_values.size()) { + vad->ring_buffer_write_index = 0; + } + + if (vad->ring_buffer_count < vad->recent_energy_values.size()) { + vad->ring_buffer_count++; + } +} + +// ============================================================================= +// PUBLIC API - Mirrors Swift's VADService methods +// ============================================================================= + +rac_result_t rac_energy_vad_create(const rac_energy_vad_config_t* config, + rac_energy_vad_handle_t* out_handle) { + if (!out_handle) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + const rac_energy_vad_config_t* cfg = config ? config : &RAC_ENERGY_VAD_CONFIG_DEFAULT; + + rac_energy_vad* vad = new rac_energy_vad(); + + // Initialize from config (mirrors Swift init) + vad->sample_rate = cfg->sample_rate; + vad->frame_length_samples = + static_cast(cfg->frame_length * static_cast(cfg->sample_rate)); + vad->energy_threshold = cfg->energy_threshold; + vad->base_energy_threshold = cfg->energy_threshold; + vad->calibration_multiplier = RAC_VAD_DEFAULT_CALIBRATION_MULTIPLIER; + vad->tts_threshold_multiplier = RAC_VAD_DEFAULT_TTS_THRESHOLD_MULTIPLIER; + + // State tracking (mirrors Swift defaults) + vad->is_active = false; + vad->is_currently_speaking = false; + vad->consecutive_silent_frames = 0; + vad->consecutive_voice_frames = 0; + vad->is_paused = false; + vad->is_tts_active = false; + + // Hysteresis parameters (mirrors Swift constants) + vad->voice_start_threshold = RAC_VAD_VOICE_START_THRESHOLD; + vad->voice_end_threshold = RAC_VAD_VOICE_END_THRESHOLD; + vad->tts_voice_start_threshold = RAC_VAD_TTS_VOICE_START_THRESHOLD; + vad->tts_voice_end_threshold = RAC_VAD_TTS_VOICE_END_THRESHOLD; + + // Calibration (mirrors Swift defaults) + vad->is_calibrating = false; + vad->calibration_frame_count = 0; + vad->calibration_frames_needed = RAC_VAD_CALIBRATION_FRAMES_NEEDED; + vad->ambient_noise_level = 0.0f; + + // Debug Ring Buffer Init + vad->max_recent_values = RAC_VAD_MAX_RECENT_VALUES; + vad->debug_frame_count = 0; + vad->ring_buffer_write_index = 0; + vad->ring_buffer_count = 0; + + vad->recent_energy_values.resize(vad->max_recent_values, 0.0f); + + // Callbacks + vad->speech_callback = nullptr; + vad->speech_user_data = nullptr; + vad->audio_callback = nullptr; + vad->audio_user_data = nullptr; + + RAC_LOG_INFO("EnergyVAD", "SimpleEnergyVADService initialized"); + + *out_handle = vad; + return RAC_SUCCESS; +} + +void rac_energy_vad_destroy(rac_energy_vad_handle_t handle) { + if (!handle) { + return; + } + + delete handle; + RAC_LOG_DEBUG("EnergyVAD", "SimpleEnergyVADService destroyed"); +} + +rac_result_t rac_energy_vad_initialize(rac_energy_vad_handle_t handle) { + if (!handle) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + + // Mirrors Swift's initialize() - start and begin calibration + handle->is_active = true; + handle->is_currently_speaking = false; + handle->consecutive_silent_frames = 0; + handle->consecutive_voice_frames = 0; + + // Start calibration (mirrors Swift's startCalibration) + RAC_LOG_INFO("EnergyVAD", "Starting VAD calibration - measuring ambient noise"); + + handle->is_calibrating = true; + handle->calibration_samples.clear(); + handle->calibration_frame_count = 0; + + return RAC_SUCCESS; +} + +rac_result_t rac_energy_vad_start(rac_energy_vad_handle_t handle) { + if (!handle) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + + // Mirrors Swift's start() + if (handle->is_active) { + return RAC_SUCCESS; + } + + handle->is_active = true; + handle->is_currently_speaking = false; + handle->consecutive_silent_frames = 0; + handle->consecutive_voice_frames = 0; + + RAC_LOG_INFO("EnergyVAD", "SimpleEnergyVADService started"); + return RAC_SUCCESS; +} + +rac_result_t rac_energy_vad_stop(rac_energy_vad_handle_t handle) { + if (!handle) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + + // Mirrors Swift's stop() + if (!handle->is_active) { + return RAC_SUCCESS; + } + + // If currently speaking, send end event + if (handle->is_currently_speaking) { + handle->is_currently_speaking = false; + RAC_LOG_INFO("EnergyVAD", "VAD: SPEECH ENDED (stopped)"); + + if (handle->speech_callback) { + handle->speech_callback(RAC_SPEECH_ACTIVITY_ENDED, handle->speech_user_data); + } + } + + handle->is_active = false; + handle->consecutive_silent_frames = 0; + handle->consecutive_voice_frames = 0; + + RAC_LOG_INFO("EnergyVAD", "SimpleEnergyVADService stopped"); + return RAC_SUCCESS; +} + +rac_result_t rac_energy_vad_reset(rac_energy_vad_handle_t handle) { + if (!handle) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + + // Mirrors Swift's reset() + handle->is_active = false; + handle->is_currently_speaking = false; + handle->consecutive_silent_frames = 0; + handle->consecutive_voice_frames = 0; + + return RAC_SUCCESS; +} + +rac_result_t rac_energy_vad_process_audio(rac_energy_vad_handle_t handle, const float* audio_data, + size_t sample_count, rac_bool_t* out_has_voice) { + if (!handle || !audio_data || sample_count == 0) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + + // Mirrors Swift's processAudioData(_:) + if (!handle->is_active) { + if (out_has_voice) + *out_has_voice = RAC_FALSE; + return RAC_SUCCESS; + } + + // Complete audio blocking during TTS (mirrors Swift) + if (handle->is_tts_active) { + if (out_has_voice) + *out_has_voice = RAC_FALSE; + return RAC_SUCCESS; + } + + if (handle->is_paused) { + if (out_has_voice) + *out_has_voice = RAC_FALSE; + return RAC_SUCCESS; + } + + // Calculate energy using RMS + float energy = rac_energy_vad_calculate_rms(audio_data, sample_count); + + // Update debug statistics + update_debug_statistics(handle, energy); + + // Handle calibration if active (mirrors Swift) + if (handle->is_calibrating) { + handle_calibration_frame(handle, energy); + if (out_has_voice) + *out_has_voice = RAC_FALSE; + return RAC_SUCCESS; + } + + bool has_voice = energy > handle->energy_threshold; + + // Update state (mirrors Swift's updateVoiceActivityState) + update_voice_activity_state(handle, has_voice); + + // Call audio buffer callback if provided + if (handle->audio_callback) { + handle->audio_callback(audio_data, sample_count * sizeof(float), handle->audio_user_data); + } + + if (out_has_voice) { + *out_has_voice = has_voice ? RAC_TRUE : RAC_FALSE; + } + + return RAC_SUCCESS; +} + +float rac_energy_vad_calculate_rms(const float* __restrict audio_data, + size_t sample_count) { + if (sample_count == 0 || audio_data == nullptr) { + return 0.0f; + } + + float s0 = 0.0f, s1 = 0.0f, s2 = 0.0f, s3 = 0.0f; + size_t i = 0; + + for (; i + 3 < sample_count; i += 4) { + float a = audio_data[i]; + float b = audio_data[i + 1]; + float c = audio_data[i + 2]; + float d = audio_data[i + 3]; + s0 += a * a; + s1 += b * b; + s2 += c * c; + s3 += d * d; + } + + float sum_squares = (s0 + s1) + (s2 + s3); + + for (; i < sample_count; ++i) { + float x = audio_data[i]; + sum_squares += x * x; + } + return std::sqrt(sum_squares / static_cast(sample_count)); +} + +rac_result_t rac_energy_vad_pause(rac_energy_vad_handle_t handle) { + if (!handle) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + + // Mirrors Swift's pause() + if (handle->is_paused) { + return RAC_SUCCESS; + } + + handle->is_paused = true; + RAC_LOG_INFO("EnergyVAD", "VAD paused"); + + // If currently speaking, send end event + if (handle->is_currently_speaking) { + handle->is_currently_speaking = false; + if (handle->speech_callback) { + handle->speech_callback(RAC_SPEECH_ACTIVITY_ENDED, handle->speech_user_data); + } + } + + // Clear recent energy values (Reset Ring Buffer) + handle->ring_buffer_count = 0; + handle->ring_buffer_write_index = 0; + // No need to zero out vector, just reset indices + handle->consecutive_silent_frames = 0; + handle->consecutive_voice_frames = 0; + + return RAC_SUCCESS; +} + +rac_result_t rac_energy_vad_resume(rac_energy_vad_handle_t handle) { + if (!handle) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + + // Mirrors Swift's resume() + if (!handle->is_paused) { + return RAC_SUCCESS; + } + + handle->is_paused = false; + + handle->is_currently_speaking = false; + handle->consecutive_silent_frames = 0; + handle->consecutive_voice_frames = 0; + + handle->ring_buffer_count = 0; + handle->ring_buffer_write_index = 0; + + handle->debug_frame_count = 0; + + RAC_LOG_INFO("EnergyVAD", "VAD resumed"); + return RAC_SUCCESS; +} + +rac_result_t rac_energy_vad_start_calibration(rac_energy_vad_handle_t handle) { + if (!handle) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + + RAC_LOG_INFO("EnergyVAD", "Starting VAD calibration"); + + handle->is_calibrating = true; + handle->calibration_samples.clear(); + handle->calibration_frame_count = 0; + + return RAC_SUCCESS; +} + +rac_result_t rac_energy_vad_is_calibrating(rac_energy_vad_handle_t handle, + rac_bool_t* out_is_calibrating) { + if (!handle || !out_is_calibrating) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + *out_is_calibrating = handle->is_calibrating ? RAC_TRUE : RAC_FALSE; + + return RAC_SUCCESS; +} + +rac_result_t rac_energy_vad_set_calibration_multiplier(rac_energy_vad_handle_t handle, + float multiplier) { + if (!handle) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + + // Mirrors Swift's setCalibrationParameters(multiplier:) - clamp to 1.5-4.0 + handle->calibration_multiplier = std::max(1.5f, std::min(4.0f, multiplier)); + + return RAC_SUCCESS; +} + +rac_result_t rac_energy_vad_notify_tts_start(rac_energy_vad_handle_t handle) { + if (!handle) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + + // Mirrors Swift's notifyTTSWillStart() + handle->is_tts_active = true; + + // Save base threshold + handle->base_energy_threshold = handle->energy_threshold; + + // Increase threshold significantly to prevent TTS audio from triggering VAD + float new_threshold = handle->energy_threshold * handle->tts_threshold_multiplier; + handle->energy_threshold = std::min(new_threshold, 0.1f); + + RAC_LOG_INFO("EnergyVAD", "TTS starting - VAD blocked and threshold increased"); + + // End any current speech detection + if (handle->is_currently_speaking) { + handle->is_currently_speaking = false; + if (handle->speech_callback) { + handle->speech_callback(RAC_SPEECH_ACTIVITY_ENDED, handle->speech_user_data); + } + } + + // Reset counters + handle->consecutive_silent_frames = 0; + handle->consecutive_voice_frames = 0; + + return RAC_SUCCESS; +} + +rac_result_t rac_energy_vad_notify_tts_finish(rac_energy_vad_handle_t handle) { + if (!handle) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + + // Mirrors Swift's notifyTTSDidFinish() + handle->is_tts_active = false; + + // Immediately restore threshold + handle->energy_threshold = handle->base_energy_threshold; + + RAC_LOG_INFO("EnergyVAD", "TTS finished - VAD threshold restored"); + + // Reset state for immediate readiness + handle->ring_buffer_count = 0; + handle->ring_buffer_write_index = 0; + handle->consecutive_silent_frames = 0; + handle->consecutive_voice_frames = 0; + handle->is_currently_speaking = false; + handle->debug_frame_count = 0; + + return RAC_SUCCESS; +} + +rac_result_t rac_energy_vad_set_tts_multiplier(rac_energy_vad_handle_t handle, float multiplier) { + if (!handle) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + + // Mirrors Swift's setTTSThresholdMultiplier(_:) - clamp to 2.0-5.0 + handle->tts_threshold_multiplier = std::max(2.0f, std::min(5.0f, multiplier)); + + return RAC_SUCCESS; +} + +rac_result_t rac_energy_vad_is_speech_active(rac_energy_vad_handle_t handle, + rac_bool_t* out_is_active) { + if (!handle || !out_is_active) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + + // Mirrors Swift's isSpeechActive + *out_is_active = handle->is_currently_speaking ? RAC_TRUE : RAC_FALSE; + + return RAC_SUCCESS; +} + +rac_result_t rac_energy_vad_get_threshold(rac_energy_vad_handle_t handle, float* out_threshold) { + if (!handle || !out_threshold) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + *out_threshold = handle->energy_threshold; + + return RAC_SUCCESS; +} + +rac_result_t rac_energy_vad_set_threshold(rac_energy_vad_handle_t handle, float threshold) { + if (!handle) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + handle->energy_threshold = threshold; + handle->base_energy_threshold = threshold; + + return RAC_SUCCESS; +} + +rac_result_t rac_energy_vad_get_statistics(rac_energy_vad_handle_t handle, + rac_energy_vad_stats_t* out_stats) { + if (!handle || !out_stats) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + + // Mirrors Swift's getStatistics() + float recent_avg = 0.0f; + float recent_max = 0.0f; + float current = 0.0f; + + size_t count = handle->ring_buffer_count; + if (count > 0) { + + size_t last_idx = (handle->ring_buffer_write_index == 0) + ? (handle->recent_energy_values.size() - 1) + : (handle->ring_buffer_write_index - 1); + current = handle->recent_energy_values[last_idx]; + + for (size_t i = 0; i < count; ++i) { + float val = handle->recent_energy_values[i]; + recent_avg += val; + recent_max = std::max(recent_max, val); + } + recent_avg /= static_cast(count); + } + + out_stats->current = current; + out_stats->threshold = handle->energy_threshold; + out_stats->ambient = handle->ambient_noise_level; + out_stats->recent_avg = recent_avg; + out_stats->recent_max = recent_max; + + return RAC_SUCCESS; +} + +rac_result_t rac_energy_vad_get_sample_rate(rac_energy_vad_handle_t handle, + int32_t* out_sample_rate) { + if (!handle || !out_sample_rate) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + *out_sample_rate = handle->sample_rate; + + return RAC_SUCCESS; +} + +rac_result_t rac_energy_vad_get_frame_length_samples(rac_energy_vad_handle_t handle, + int32_t* out_frame_length) { + if (!handle || !out_frame_length) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + *out_frame_length = handle->frame_length_samples; + + return RAC_SUCCESS; +} + +rac_result_t rac_energy_vad_set_speech_callback(rac_energy_vad_handle_t handle, + rac_speech_activity_callback_fn callback, + void* user_data) { + if (!handle) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + + handle->speech_callback = callback; + handle->speech_user_data = user_data; + + return RAC_SUCCESS; +} + +rac_result_t rac_energy_vad_set_audio_callback(rac_energy_vad_handle_t handle, + rac_audio_buffer_callback_fn callback, + void* user_data) { + if (!handle) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + + handle->audio_callback = callback; + handle->audio_user_data = user_data; + + return RAC_SUCCESS; +} diff --git a/sdk/runanywhere-commons/src/features/vad/vad_analytics.cpp b/sdk/runanywhere-commons/src/features/vad/vad_analytics.cpp new file mode 100644 index 000000000..27ad90327 --- /dev/null +++ b/sdk/runanywhere-commons/src/features/vad/vad_analytics.cpp @@ -0,0 +1,328 @@ +/** + * @file vad_analytics.cpp + * @brief VAD analytics service implementation + * + * 1:1 port of Swift's VADAnalyticsService.swift + * Swift Source: Sources/RunAnywhere/Features/VAD/Analytics/VADAnalyticsService.swift + */ + +#include +#include +#include + +#include "rac/core/rac_logger.h" +#include "rac/features/vad/rac_vad_analytics.h" + +// ============================================================================= +// INTERNAL UTILITIES +// ============================================================================= + +namespace { + +int64_t get_current_time_ms() { + auto now = std::chrono::system_clock::now(); + auto duration = now.time_since_epoch(); + return std::chrono::duration_cast(duration).count(); +} + +} // namespace + +// ============================================================================= +// VAD ANALYTICS SERVICE IMPLEMENTATION +// ============================================================================= + +struct rac_vad_analytics_s { + std::mutex mutex; + + // Current framework + rac_inference_framework_t current_framework; + + // Speech segment tracking + int64_t speech_start_time_ms; + bool has_speech_start; + + // Metrics + int32_t total_speech_segments; + double total_speech_duration_ms; + int64_t start_time_ms; + int64_t last_event_time_ms; + bool has_last_event_time; + + rac_vad_analytics_s() + : current_framework(RAC_FRAMEWORK_BUILTIN), + speech_start_time_ms(0), + has_speech_start(false), + total_speech_segments(0), + total_speech_duration_ms(0), + start_time_ms(get_current_time_ms()), + last_event_time_ms(0), + has_last_event_time(false) {} +}; + +// ============================================================================= +// C API IMPLEMENTATION +// ============================================================================= + +extern "C" { + +rac_result_t rac_vad_analytics_create(rac_vad_analytics_handle_t* out_handle) { + if (!out_handle) { + return RAC_ERROR_INVALID_PARAMETER; + } + + try { + *out_handle = new rac_vad_analytics_s(); + log_info("VAD.Analytics", "VAD analytics service created"); + return RAC_SUCCESS; + } catch (...) { + return RAC_ERROR_OUT_OF_MEMORY; + } +} + +void rac_vad_analytics_destroy(rac_vad_analytics_handle_t handle) { + if (handle) { + delete handle; + log_info("VAD.Analytics", "VAD analytics service destroyed"); + } +} + +rac_result_t rac_vad_analytics_track_initialized(rac_vad_analytics_handle_t handle, + rac_inference_framework_t framework) { + if (!handle) { + return RAC_ERROR_INVALID_PARAMETER; + } + + std::lock_guard lock(handle->mutex); + + handle->current_framework = framework; + handle->last_event_time_ms = get_current_time_ms(); + handle->has_last_event_time = true; + + log_debug("VAD.Analytics", "VAD initialized with framework: %d", framework); + return RAC_SUCCESS; +} + +rac_result_t rac_vad_analytics_track_initialization_failed(rac_vad_analytics_handle_t handle, + rac_result_t error_code, + const char* error_message, + rac_inference_framework_t framework) { + if (!handle) { + return RAC_ERROR_INVALID_PARAMETER; + } + + std::lock_guard lock(handle->mutex); + + handle->current_framework = framework; + handle->last_event_time_ms = get_current_time_ms(); + handle->has_last_event_time = true; + + log_error("VAD.Analytics", "VAD initialization failed: %d - %s", error_code, + error_message ? error_message : ""); + return RAC_SUCCESS; +} + +rac_result_t rac_vad_analytics_track_cleaned_up(rac_vad_analytics_handle_t handle) { + if (!handle) { + return RAC_ERROR_INVALID_PARAMETER; + } + + std::lock_guard lock(handle->mutex); + + handle->last_event_time_ms = get_current_time_ms(); + handle->has_last_event_time = true; + + log_debug("VAD.Analytics", "VAD cleaned up"); + return RAC_SUCCESS; +} + +rac_result_t rac_vad_analytics_track_started(rac_vad_analytics_handle_t handle) { + if (!handle) { + return RAC_ERROR_INVALID_PARAMETER; + } + + std::lock_guard lock(handle->mutex); + + handle->last_event_time_ms = get_current_time_ms(); + handle->has_last_event_time = true; + + log_debug("VAD.Analytics", "VAD started"); + return RAC_SUCCESS; +} + +rac_result_t rac_vad_analytics_track_stopped(rac_vad_analytics_handle_t handle) { + if (!handle) { + return RAC_ERROR_INVALID_PARAMETER; + } + + std::lock_guard lock(handle->mutex); + + handle->last_event_time_ms = get_current_time_ms(); + handle->has_last_event_time = true; + + log_debug("VAD.Analytics", "VAD stopped"); + return RAC_SUCCESS; +} + +rac_result_t rac_vad_analytics_track_speech_start(rac_vad_analytics_handle_t handle) { + if (!handle) { + return RAC_ERROR_INVALID_PARAMETER; + } + + std::lock_guard lock(handle->mutex); + + int64_t now = get_current_time_ms(); + handle->speech_start_time_ms = now; + handle->has_speech_start = true; + handle->last_event_time_ms = now; + handle->has_last_event_time = true; + + log_debug("VAD.Analytics", "Speech started"); + return RAC_SUCCESS; +} + +rac_result_t rac_vad_analytics_track_speech_end(rac_vad_analytics_handle_t handle) { + if (!handle) { + return RAC_ERROR_INVALID_PARAMETER; + } + + std::lock_guard lock(handle->mutex); + + if (!handle->has_speech_start) { + return RAC_SUCCESS; // No speech start to end + } + + int64_t end_time_ms = get_current_time_ms(); + double duration_ms = static_cast(end_time_ms - handle->speech_start_time_ms); + + handle->has_speech_start = false; + handle->total_speech_segments++; + handle->total_speech_duration_ms += duration_ms; + handle->last_event_time_ms = end_time_ms; + handle->has_last_event_time = true; + + log_debug("VAD.Analytics", "Speech ended: %.1fms", duration_ms); + return RAC_SUCCESS; +} + +rac_result_t rac_vad_analytics_track_paused(rac_vad_analytics_handle_t handle) { + if (!handle) { + return RAC_ERROR_INVALID_PARAMETER; + } + + std::lock_guard lock(handle->mutex); + + handle->last_event_time_ms = get_current_time_ms(); + handle->has_last_event_time = true; + + log_debug("VAD.Analytics", "VAD paused"); + return RAC_SUCCESS; +} + +rac_result_t rac_vad_analytics_track_resumed(rac_vad_analytics_handle_t handle) { + if (!handle) { + return RAC_ERROR_INVALID_PARAMETER; + } + + std::lock_guard lock(handle->mutex); + + handle->last_event_time_ms = get_current_time_ms(); + handle->has_last_event_time = true; + + log_debug("VAD.Analytics", "VAD resumed"); + return RAC_SUCCESS; +} + +rac_result_t rac_vad_analytics_track_model_load_started(rac_vad_analytics_handle_t handle, + const char* model_id, + int64_t model_size_bytes, + rac_inference_framework_t framework) { + if (!handle || !model_id) { + return RAC_ERROR_INVALID_PARAMETER; + } + + std::lock_guard lock(handle->mutex); + + handle->current_framework = framework; + handle->last_event_time_ms = get_current_time_ms(); + handle->has_last_event_time = true; + + log_debug("VAD.Analytics", "Model load started: %s, size: %lld", model_id, model_size_bytes); + return RAC_SUCCESS; +} + +rac_result_t rac_vad_analytics_track_model_load_completed(rac_vad_analytics_handle_t handle, + const char* model_id, double duration_ms, + int64_t model_size_bytes) { + if (!handle || !model_id) { + return RAC_ERROR_INVALID_PARAMETER; + } + + std::lock_guard lock(handle->mutex); + + handle->last_event_time_ms = get_current_time_ms(); + handle->has_last_event_time = true; + + log_debug("VAD.Analytics", "Model load completed: %s, duration: %.1fms, size: %lld", model_id, + duration_ms, model_size_bytes); + return RAC_SUCCESS; +} + +rac_result_t rac_vad_analytics_track_model_load_failed(rac_vad_analytics_handle_t handle, + const char* model_id, + rac_result_t error_code, + const char* error_message) { + if (!handle || !model_id) { + return RAC_ERROR_INVALID_PARAMETER; + } + + std::lock_guard lock(handle->mutex); + + handle->last_event_time_ms = get_current_time_ms(); + handle->has_last_event_time = true; + + log_error("VAD.Analytics", "Model load failed: %s, error: %d - %s", model_id, error_code, + error_message ? error_message : ""); + return RAC_SUCCESS; +} + +rac_result_t rac_vad_analytics_track_model_unloaded(rac_vad_analytics_handle_t handle, + const char* model_id) { + if (!handle || !model_id) { + return RAC_ERROR_INVALID_PARAMETER; + } + + std::lock_guard lock(handle->mutex); + + handle->last_event_time_ms = get_current_time_ms(); + handle->has_last_event_time = true; + + log_debug("VAD.Analytics", "Model unloaded: %s", model_id); + return RAC_SUCCESS; +} + +rac_result_t rac_vad_analytics_get_metrics(rac_vad_analytics_handle_t handle, + rac_vad_metrics_t* out_metrics) { + if (!handle || !out_metrics) { + return RAC_ERROR_INVALID_PARAMETER; + } + + std::lock_guard lock(handle->mutex); + + out_metrics->total_events = handle->total_speech_segments; + out_metrics->start_time_ms = handle->start_time_ms; + out_metrics->last_event_time_ms = handle->has_last_event_time ? handle->last_event_time_ms : 0; + out_metrics->total_speech_segments = handle->total_speech_segments; + out_metrics->total_speech_duration_ms = handle->total_speech_duration_ms; + + // Average speech duration (-1 if no segments, matching Swift) + out_metrics->average_speech_duration_ms = + handle->total_speech_segments > 0 + ? handle->total_speech_duration_ms / static_cast(handle->total_speech_segments) + : -1; + + out_metrics->framework = handle->current_framework; + + return RAC_SUCCESS; +} + +} // extern "C" diff --git a/sdk/runanywhere-commons/src/features/vad/vad_component.cpp b/sdk/runanywhere-commons/src/features/vad/vad_component.cpp new file mode 100644 index 000000000..c5a449b3d --- /dev/null +++ b/sdk/runanywhere-commons/src/features/vad/vad_component.cpp @@ -0,0 +1,506 @@ +/** + * @file vad_component.cpp + * @brief VAD Capability Component Implementation + * + * C++ port of Swift's VADCapability.swift + * Swift Source: Sources/RunAnywhere/Features/VAD/VADCapability.swift + * + * IMPORTANT: This is a direct translation of the Swift implementation. + * Do NOT add features not present in the Swift code. + */ + +#include +#include +#include +#include + +#include "rac/core/capabilities/rac_lifecycle.h" +#include "rac/core/rac_analytics_events.h" +#include "rac/core/rac_logger.h" +#include "rac/core/rac_platform_adapter.h" +#include "rac/core/rac_structured_error.h" +#include "rac/features/vad/rac_vad_component.h" +#include "rac/features/vad/rac_vad_energy.h" +#include "rac/features/vad/rac_vad_service.h" + +// ============================================================================= +// INTERNAL STRUCTURES +// ============================================================================= + +struct rac_vad_component { + /** Energy VAD service handle */ + rac_energy_vad_handle_t vad_service; + + /** Configuration */ + rac_vad_config_t config; + + /** Activity callback */ + rac_vad_activity_callback_fn activity_callback; + void* activity_user_data; + + /** Audio callback */ + rac_vad_audio_callback_fn audio_callback; + void* audio_user_data; + + /** Initialization state */ + bool is_initialized; + + /** Mutex for thread safety */ + std::mutex mtx; + + rac_vad_component() + : vad_service(nullptr), + activity_callback(nullptr), + activity_user_data(nullptr), + audio_callback(nullptr), + audio_user_data(nullptr), + is_initialized(false) { + // Initialize with defaults - matches rac_vad_types.h rac_vad_config_t + config = RAC_VAD_CONFIG_DEFAULT; + } +}; + +// ============================================================================= +// HELPER FUNCTIONS +// ============================================================================= + +/** + * Internal speech activity callback wrapper. + * Routes events from energy VAD to the user callback. + */ +static void vad_speech_activity_callback(rac_speech_activity_event_t event, void* user_data) { + auto* component = reinterpret_cast(user_data); + if (!component) + return; + + // Emit analytics event for speech activity + rac_analytics_event_data_t event_data; + event_data.data.vad = RAC_ANALYTICS_VAD_DEFAULT; + + if (event == RAC_SPEECH_ACTIVITY_STARTED) { + // Emit VAD_SPEECH_STARTED event + rac_analytics_event_emit(RAC_EVENT_VAD_SPEECH_STARTED, &event_data); + } else { + // Emit VAD_SPEECH_ENDED event + rac_analytics_event_emit(RAC_EVENT_VAD_SPEECH_ENDED, &event_data); + } + + // Route to user callback + if (component->activity_callback) { + rac_speech_activity_t activity{}; + if (event == RAC_SPEECH_ACTIVITY_STARTED) { + activity = RAC_SPEECH_STARTED; + } else { + activity = RAC_SPEECH_ENDED; + } + component->activity_callback(activity, component->activity_user_data); + } +} + +// ============================================================================= +// LIFECYCLE API +// ============================================================================= + +extern "C" rac_result_t rac_vad_component_create(rac_handle_t* out_handle) { + if (!out_handle) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + auto* component = new (std::nothrow) rac_vad_component(); + if (!component) { + return RAC_ERROR_OUT_OF_MEMORY; + } + + *out_handle = reinterpret_cast(component); + + log_info("VAD.Component", "VAD component created"); + + return RAC_SUCCESS; +} + +extern "C" rac_result_t rac_vad_component_configure(rac_handle_t handle, + const rac_vad_config_t* config) { + if (!handle) + return RAC_ERROR_INVALID_HANDLE; + if (!config) + return RAC_ERROR_INVALID_ARGUMENT; + + auto* component = reinterpret_cast(handle); + std::lock_guard lock(component->mtx); + + // ========================================================================== + // VALIDATION - Ported from Swift VADConfiguration.swift:62-110 + // ========================================================================== + + // 1. Energy threshold range (Swift lines 64-69) + if (config->energy_threshold < 0.0f || config->energy_threshold > 1.0f) { + log_error("VAD.Component", + "Energy threshold must be between 0 and 1.0. Recommended range: 0.01-0.05"); + return RAC_ERROR_INVALID_PARAMETER; + } + + // 2. Warning for very low threshold (Swift lines 72-77) + if (config->energy_threshold < 0.002f) { + RAC_LOG_WARNING("VAD.Component", + "Energy threshold is very low (< 0.002) and may cause false positives"); + } + + // 3. Warning for very high threshold (Swift lines 80-85) + if (config->energy_threshold > 0.1f) { + RAC_LOG_WARNING("VAD.Component", + "Energy threshold is very high (> 0.1) and may miss speech"); + } + + // 4. Sample rate validation (Swift lines 88-93) + if (config->sample_rate < 1 || config->sample_rate > 48000) { + log_error("VAD.Component", "Sample rate must be between 1 and 48000 Hz"); + return RAC_ERROR_INVALID_PARAMETER; + } + + // 5. Frame length validation (Swift lines 96-101) + if (config->frame_length <= 0.0f || config->frame_length > 1.0f) { + log_error("VAD.Component", "Frame length must be between 0 and 1 second"); + return RAC_ERROR_INVALID_PARAMETER; + } + + // 6. Calibration multiplier validation (Swift lines 104-109) + // Note: Check if calibration_multiplier exists in config + // Swift validates calibrationMultiplier >= 1.5 && <= 5.0 + + // ========================================================================== + + component->config = *config; + + log_info("VAD.Component", "VAD component configured"); + + return RAC_SUCCESS; +} + +extern "C" rac_bool_t rac_vad_component_is_initialized(rac_handle_t handle) { + if (!handle) + return RAC_FALSE; + + auto* component = reinterpret_cast(handle); + return component->is_initialized ? RAC_TRUE : RAC_FALSE; +} + +extern "C" rac_result_t rac_vad_component_initialize(rac_handle_t handle) { + if (!handle) + return RAC_ERROR_INVALID_HANDLE; + + auto* component = reinterpret_cast(handle); + std::lock_guard lock(component->mtx); + + if (component->is_initialized) { + // Already initialized + return RAC_SUCCESS; + } + + // Create energy VAD configuration + rac_energy_vad_config_t vad_config = {}; + vad_config.sample_rate = component->config.sample_rate; + vad_config.frame_length = component->config.frame_length; + vad_config.energy_threshold = component->config.energy_threshold; + + // Create energy VAD service + rac_result_t result = rac_energy_vad_create(&vad_config, &component->vad_service); + if (result != RAC_SUCCESS) { + log_error("VAD.Component", "Failed to create energy VAD service"); + return result; + } + + // Set speech callback + result = rac_energy_vad_set_speech_callback(component->vad_service, + vad_speech_activity_callback, component); + if (result != RAC_SUCCESS) { + rac_energy_vad_destroy(component->vad_service); + component->vad_service = nullptr; + return result; + } + + // Initialize the VAD (starts calibration) + result = rac_energy_vad_initialize(component->vad_service); + if (result != RAC_SUCCESS) { + log_error("VAD.Component", "Failed to initialize energy VAD service"); + rac_energy_vad_destroy(component->vad_service); + component->vad_service = nullptr; + return result; + } + + component->is_initialized = true; + + log_info("VAD.Component", "VAD component initialized"); + + return RAC_SUCCESS; +} + +extern "C" rac_result_t rac_vad_component_cleanup(rac_handle_t handle) { + if (!handle) + return RAC_ERROR_INVALID_HANDLE; + + auto* component = reinterpret_cast(handle); + std::lock_guard lock(component->mtx); + + if (component->vad_service) { + rac_energy_vad_stop(component->vad_service); + rac_energy_vad_destroy(component->vad_service); + component->vad_service = nullptr; + } + + component->is_initialized = false; + + log_info("VAD.Component", "VAD component cleaned up"); + + return RAC_SUCCESS; +} + +extern "C" void rac_vad_component_destroy(rac_handle_t handle) { + if (!handle) + return; + + auto* component = reinterpret_cast(handle); + + // Cleanup first + rac_vad_component_cleanup(handle); + + log_info("VAD.Component", "VAD component destroyed"); + + delete component; +} + +// ============================================================================= +// CALLBACK API +// ============================================================================= + +extern "C" rac_result_t +rac_vad_component_set_activity_callback(rac_handle_t handle, rac_vad_activity_callback_fn callback, + void* user_data) { + if (!handle) + return RAC_ERROR_INVALID_HANDLE; + + auto* component = reinterpret_cast(handle); + std::lock_guard lock(component->mtx); + + component->activity_callback = callback; + component->activity_user_data = user_data; + + return RAC_SUCCESS; +} + +extern "C" rac_result_t rac_vad_component_set_audio_callback(rac_handle_t handle, + rac_vad_audio_callback_fn callback, + void* user_data) { + if (!handle) + return RAC_ERROR_INVALID_HANDLE; + + auto* component = reinterpret_cast(handle); + std::lock_guard lock(component->mtx); + + component->audio_callback = callback; + component->audio_user_data = user_data; + + return RAC_SUCCESS; +} + +// ============================================================================= +// CONTROL API +// ============================================================================= + +extern "C" rac_result_t rac_vad_component_start(rac_handle_t handle) { + if (!handle) + return RAC_ERROR_INVALID_HANDLE; + + auto* component = reinterpret_cast(handle); + std::lock_guard lock(component->mtx); + + if (!component->is_initialized || !component->vad_service) { + return RAC_ERROR_NOT_INITIALIZED; + } + + rac_result_t result = rac_energy_vad_start(component->vad_service); + + if (result == RAC_SUCCESS) { + // Emit VAD_STARTED event + rac_analytics_event_data_t event_data; + event_data.data.vad = RAC_ANALYTICS_VAD_DEFAULT; + rac_analytics_event_emit(RAC_EVENT_VAD_STARTED, &event_data); + } + + return result; +} + +extern "C" rac_result_t rac_vad_component_stop(rac_handle_t handle) { + if (!handle) + return RAC_ERROR_INVALID_HANDLE; + + auto* component = reinterpret_cast(handle); + std::lock_guard lock(component->mtx); + + if (!component->vad_service) { + return RAC_SUCCESS; // Already stopped + } + + rac_result_t result = rac_energy_vad_stop(component->vad_service); + + if (result == RAC_SUCCESS) { + // Emit VAD_STOPPED event + rac_analytics_event_data_t event_data; + event_data.data.vad = RAC_ANALYTICS_VAD_DEFAULT; + rac_analytics_event_emit(RAC_EVENT_VAD_STOPPED, &event_data); + } + + return result; +} + +extern "C" rac_result_t rac_vad_component_reset(rac_handle_t handle) { + if (!handle) + return RAC_ERROR_INVALID_HANDLE; + + auto* component = reinterpret_cast(handle); + std::lock_guard lock(component->mtx); + + if (!component->vad_service) { + return RAC_ERROR_NOT_INITIALIZED; + } + + return rac_energy_vad_reset(component->vad_service); +} + +// ============================================================================= +// PROCESSING API +// ============================================================================= + +extern "C" rac_result_t rac_vad_component_process(rac_handle_t handle, const float* samples, + size_t num_samples, rac_bool_t* out_is_speech) { + if (!handle) + return RAC_ERROR_INVALID_HANDLE; + if (!samples || num_samples == 0) + return RAC_ERROR_INVALID_ARGUMENT; + + auto* component = reinterpret_cast(handle); + std::lock_guard lock(component->mtx); + + if (!component->is_initialized || !component->vad_service) { + return RAC_ERROR_NOT_INITIALIZED; + } + + // Process audio through energy VAD + rac_bool_t has_voice = RAC_FALSE; + rac_result_t result = + rac_energy_vad_process_audio(component->vad_service, samples, num_samples, &has_voice); + + if (result != RAC_SUCCESS) { + return result; + } + + if (out_is_speech) { + *out_is_speech = has_voice; + } + + // Route audio to audio callback if set + if (component->audio_callback && samples) { + component->audio_callback(samples, num_samples * sizeof(float), component->audio_user_data); + } + + return RAC_SUCCESS; +} + +// ============================================================================= +// STATE QUERY API +// ============================================================================= + +extern "C" rac_bool_t rac_vad_component_is_speech_active(rac_handle_t handle) { + if (!handle) + return RAC_FALSE; + + auto* component = reinterpret_cast(handle); + std::lock_guard lock(component->mtx); + + if (!component->vad_service) { + return RAC_FALSE; + } + + rac_bool_t is_active = RAC_FALSE; + rac_energy_vad_is_speech_active(component->vad_service, &is_active); + return is_active; +} + +extern "C" float rac_vad_component_get_energy_threshold(rac_handle_t handle) { + if (!handle) + return 0.0f; + + auto* component = reinterpret_cast(handle); + std::lock_guard lock(component->mtx); + + if (!component->vad_service) { + return component->config.energy_threshold; + } + + float threshold = 0.0f; + rac_energy_vad_get_threshold(component->vad_service, &threshold); + return threshold; +} + +extern "C" rac_result_t rac_vad_component_set_energy_threshold(rac_handle_t handle, + float threshold) { + if (!handle) + return RAC_ERROR_INVALID_HANDLE; + + // Validation - Ported from Swift VADConfiguration.validate() + if (threshold < 0.0f || threshold > 1.0f) { + log_error("VAD.Component", "Threshold must be between 0.0 and 1.0"); + return RAC_ERROR_INVALID_PARAMETER; + } + + // Warning for edge cases + if (threshold < 0.002f) { + RAC_LOG_WARNING("VAD.Component", + "Threshold is very low (< 0.002) and may cause false positives"); + } + if (threshold > 0.1f) { + RAC_LOG_WARNING("VAD.Component", "Threshold is very high (> 0.1) and may miss speech"); + } + + auto* component = reinterpret_cast(handle); + std::lock_guard lock(component->mtx); + + component->config.energy_threshold = threshold; + + if (component->vad_service) { + return rac_energy_vad_set_threshold(component->vad_service, threshold); + } + + return RAC_SUCCESS; +} + +extern "C" rac_lifecycle_state_t rac_vad_component_get_state(rac_handle_t handle) { + if (!handle) + return RAC_LIFECYCLE_STATE_IDLE; + + auto* component = reinterpret_cast(handle); + + if (component->is_initialized) { + return RAC_LIFECYCLE_STATE_LOADED; + } + + return RAC_LIFECYCLE_STATE_IDLE; +} + +extern "C" rac_result_t rac_vad_component_get_metrics(rac_handle_t handle, + rac_lifecycle_metrics_t* out_metrics) { + if (!handle) + return RAC_ERROR_INVALID_HANDLE; + if (!out_metrics) + return RAC_ERROR_INVALID_ARGUMENT; + + // VAD doesn't use the standard lifecycle manager, so return basic metrics + memset(out_metrics, 0, sizeof(rac_lifecycle_metrics_t)); + + auto* component = reinterpret_cast(handle); + if (component->is_initialized) { + out_metrics->total_loads = 1; + out_metrics->successful_loads = 1; + } + + return RAC_SUCCESS; +} diff --git a/sdk/runanywhere-commons/src/features/voice_agent/voice_agent.cpp b/sdk/runanywhere-commons/src/features/voice_agent/voice_agent.cpp new file mode 100644 index 000000000..611ce7b04 --- /dev/null +++ b/sdk/runanywhere-commons/src/features/voice_agent/voice_agent.cpp @@ -0,0 +1,1046 @@ +/** + * @file voice_agent.cpp + * @brief RunAnywhere Commons - Voice Agent Implementation + * + * C++ port of Swift's VoiceAgentCapability.swift from: + * Sources/RunAnywhere/Features/VoiceAgent/VoiceAgentCapability.swift + * + * CRITICAL: This is a direct port of Swift implementation - do NOT add custom logic! + */ + +#include +#include +#include + +#include "rac/core/rac_analytics_events.h" +#include "rac/core/rac_audio_utils.h" +#include "rac/core/rac_logger.h" +#include "rac/core/rac_platform_adapter.h" +#include "rac/core/rac_structured_error.h" +#include "rac/features/llm/rac_llm_component.h" +#include "rac/features/llm/rac_llm_types.h" +#include "rac/features/stt/rac_stt_component.h" +#include "rac/features/stt/rac_stt_types.h" +#include "rac/features/tts/rac_tts_component.h" +#include "rac/features/tts/rac_tts_types.h" +#include "rac/features/vad/rac_vad_component.h" +#include "rac/features/vad/rac_vad_types.h" +#include "rac/features/voice_agent/rac_voice_agent.h" + +// Forward declare event helpers from events.cpp +namespace rac::events { +void emit_voice_agent_stt_state_changed(rac_voice_agent_component_state_t state, + const char* model_id, const char* error_message); +void emit_voice_agent_llm_state_changed(rac_voice_agent_component_state_t state, + const char* model_id, const char* error_message); +void emit_voice_agent_tts_state_changed(rac_voice_agent_component_state_t state, + const char* model_id, const char* error_message); +void emit_voice_agent_all_ready(); +} // namespace rac::events + +// ============================================================================= +// INTERNAL STRUCTURE - Mirrors Swift's VoiceAgentCapability properties +// ============================================================================= + +struct rac_voice_agent { + // State + bool is_configured; + + // Whether we own the component handles (and should destroy them) + bool owns_components; + + // Composed component handles + rac_handle_t llm_handle; + rac_handle_t stt_handle; + rac_handle_t tts_handle; + rac_handle_t vad_handle; + + // Thread safety + std::mutex mutex; + + rac_voice_agent() + : is_configured(false), + owns_components(false), + llm_handle(nullptr), + stt_handle(nullptr), + tts_handle(nullptr), + vad_handle(nullptr) {} +}; + +// Note: rac_strdup is declared in rac_types.h and implemented in rac_memory.cpp + +// ============================================================================= +// DEFENSIVE VALIDATION HELPERS +// ============================================================================= + +/** + * @brief Validate that a component is ready for use + * + * Performs defensive checks: + * 1. Handle is non-null + * 2. Component is in LOADED state + * + * This provides early failure with clear error messages instead of + * cryptic crashes from dangling pointers or uninitialized components. + * + * @param component_name Human-readable name for error messages + * @param handle Component handle + * @param get_state_fn Function to get component lifecycle state + * @return RAC_SUCCESS if valid, error code otherwise + */ +static rac_result_t validate_component_ready(const char* component_name, rac_handle_t handle, + rac_lifecycle_state_t (*get_state_fn)(rac_handle_t)) { + if (handle == nullptr) { + RAC_LOG_ERROR("VoiceAgent", "%s handle is null", component_name); + return RAC_ERROR_INVALID_HANDLE; + } + + rac_lifecycle_state_t state = get_state_fn(handle); + if (state != RAC_LIFECYCLE_STATE_LOADED) { + RAC_LOG_ERROR("VoiceAgent", "%s is not loaded (state: %s)", component_name, + rac_lifecycle_state_name(state)); + return RAC_ERROR_NOT_INITIALIZED; + } + + return RAC_SUCCESS; +} + +/** + * @brief Validate all voice agent components are ready for processing + * + * Checks STT, LLM, and TTS components are properly loaded before + * attempting voice processing. This provides early failure with clear + * error messages instead of cryptic crashes from dangling pointers. + * + * @param handle Voice agent handle + * @return RAC_SUCCESS if all components ready, error code otherwise + */ +static rac_result_t validate_all_components_ready(rac_voice_agent_handle_t handle) { + rac_result_t result; + + // Validate STT component + result = validate_component_ready("STT", handle->stt_handle, rac_stt_component_get_state); + if (result != RAC_SUCCESS) { + return result; + } + + // Validate LLM component + result = validate_component_ready("LLM", handle->llm_handle, rac_llm_component_get_state); + if (result != RAC_SUCCESS) { + return result; + } + + // Validate TTS component + result = validate_component_ready("TTS", handle->tts_handle, rac_tts_component_get_state); + if (result != RAC_SUCCESS) { + return result; + } + + return RAC_SUCCESS; +} + +// ============================================================================= +// LIFECYCLE API +// ============================================================================= + +rac_result_t rac_voice_agent_create_standalone(rac_voice_agent_handle_t* out_handle) { + if (!out_handle) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + RAC_LOG_INFO("VoiceAgent", "Creating standalone voice agent"); + + rac_voice_agent* agent = new rac_voice_agent(); + agent->owns_components = true; + + // Create LLM component + rac_result_t result = rac_llm_component_create(&agent->llm_handle); + if (result != RAC_SUCCESS) { + RAC_LOG_ERROR("VoiceAgent", "Failed to create LLM component"); + delete agent; + return result; + } + + // Create STT component + result = rac_stt_component_create(&agent->stt_handle); + if (result != RAC_SUCCESS) { + RAC_LOG_ERROR("VoiceAgent", "Failed to create STT component"); + rac_llm_component_destroy(agent->llm_handle); + delete agent; + return result; + } + + // Create TTS component + result = rac_tts_component_create(&agent->tts_handle); + if (result != RAC_SUCCESS) { + RAC_LOG_ERROR("VoiceAgent", "Failed to create TTS component"); + rac_stt_component_destroy(agent->stt_handle); + rac_llm_component_destroy(agent->llm_handle); + delete agent; + return result; + } + + // Create VAD component + result = rac_vad_component_create(&agent->vad_handle); + if (result != RAC_SUCCESS) { + RAC_LOG_ERROR("VoiceAgent", "Failed to create VAD component"); + rac_tts_component_destroy(agent->tts_handle); + rac_stt_component_destroy(agent->stt_handle); + rac_llm_component_destroy(agent->llm_handle); + delete agent; + return result; + } + + RAC_LOG_INFO("VoiceAgent", "Standalone voice agent created with all components"); + + *out_handle = agent; + return RAC_SUCCESS; +} + +rac_result_t rac_voice_agent_create(rac_handle_t llm_component_handle, + rac_handle_t stt_component_handle, + rac_handle_t tts_component_handle, + rac_handle_t vad_component_handle, + rac_voice_agent_handle_t* out_handle) { + if (!out_handle) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + // All component handles are required (mirrors Swift's init) + if (!llm_component_handle || !stt_component_handle || !tts_component_handle || + !vad_component_handle) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + rac_voice_agent* agent = new rac_voice_agent(); + agent->owns_components = false; // External handles, don't destroy them + agent->llm_handle = llm_component_handle; + agent->stt_handle = stt_component_handle; + agent->tts_handle = tts_component_handle; + agent->vad_handle = vad_component_handle; + + RAC_LOG_INFO("VoiceAgent", "Voice agent created with external handles"); + + *out_handle = agent; + return RAC_SUCCESS; +} + +void rac_voice_agent_destroy(rac_voice_agent_handle_t handle) { + if (!handle) { + return; + } + + // If we own the components, destroy them + if (handle->owns_components) { + RAC_LOG_DEBUG("VoiceAgent", "Destroying owned component handles"); + if (handle->vad_handle) + rac_vad_component_destroy(handle->vad_handle); + if (handle->tts_handle) + rac_tts_component_destroy(handle->tts_handle); + if (handle->stt_handle) + rac_stt_component_destroy(handle->stt_handle); + if (handle->llm_handle) + rac_llm_component_destroy(handle->llm_handle); + } + + delete handle; + RAC_LOG_DEBUG("VoiceAgent", "Voice agent destroyed"); +} + +// ============================================================================= +// MODEL LOADING API +// ============================================================================= + +rac_result_t rac_voice_agent_load_stt_model(rac_voice_agent_handle_t handle, const char* model_path, + const char* model_id, const char* model_name) { + if (!handle || !model_path) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + + RAC_LOG_INFO("VoiceAgent", "Loading STT model"); + + // Emit loading state + rac::events::emit_voice_agent_stt_state_changed(RAC_VOICE_AGENT_STATE_LOADING, model_id, + nullptr); + + rac_result_t result = + rac_stt_component_load_model(handle->stt_handle, model_path, model_id, model_name); + + if (result == RAC_SUCCESS) { + rac::events::emit_voice_agent_stt_state_changed(RAC_VOICE_AGENT_STATE_LOADED, model_id, + nullptr); + // Check if all components are now ready + if (rac_stt_component_is_loaded(handle->stt_handle) == RAC_TRUE && + rac_llm_component_is_loaded(handle->llm_handle) == RAC_TRUE && + rac_tts_component_is_loaded(handle->tts_handle) == RAC_TRUE) { + rac::events::emit_voice_agent_all_ready(); + } + } else { + rac::events::emit_voice_agent_stt_state_changed(RAC_VOICE_AGENT_STATE_ERROR, model_id, + "Failed to load STT model"); + } + + return result; +} + +rac_result_t rac_voice_agent_load_llm_model(rac_voice_agent_handle_t handle, const char* model_path, + const char* model_id, const char* model_name) { + if (!handle || !model_path) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + + RAC_LOG_INFO("VoiceAgent", "Loading LLM model"); + + // Emit loading state + rac::events::emit_voice_agent_llm_state_changed(RAC_VOICE_AGENT_STATE_LOADING, model_id, + nullptr); + + rac_result_t result = + rac_llm_component_load_model(handle->llm_handle, model_path, model_id, model_name); + + if (result == RAC_SUCCESS) { + rac::events::emit_voice_agent_llm_state_changed(RAC_VOICE_AGENT_STATE_LOADED, model_id, + nullptr); + // Check if all components are now ready + if (rac_stt_component_is_loaded(handle->stt_handle) == RAC_TRUE && + rac_llm_component_is_loaded(handle->llm_handle) == RAC_TRUE && + rac_tts_component_is_loaded(handle->tts_handle) == RAC_TRUE) { + rac::events::emit_voice_agent_all_ready(); + } + } else { + rac::events::emit_voice_agent_llm_state_changed(RAC_VOICE_AGENT_STATE_ERROR, model_id, + "Failed to load LLM model"); + } + + return result; +} + +rac_result_t rac_voice_agent_load_tts_voice(rac_voice_agent_handle_t handle, const char* voice_path, + const char* voice_id, const char* voice_name) { + if (!handle || !voice_path) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + + RAC_LOG_INFO("VoiceAgent", "Loading TTS voice"); + + // Emit loading state + rac::events::emit_voice_agent_tts_state_changed(RAC_VOICE_AGENT_STATE_LOADING, voice_id, + nullptr); + + rac_result_t result = + rac_tts_component_load_voice(handle->tts_handle, voice_path, voice_id, voice_name); + + if (result == RAC_SUCCESS) { + rac::events::emit_voice_agent_tts_state_changed(RAC_VOICE_AGENT_STATE_LOADED, voice_id, + nullptr); + // Check if all components are now ready + if (rac_stt_component_is_loaded(handle->stt_handle) == RAC_TRUE && + rac_llm_component_is_loaded(handle->llm_handle) == RAC_TRUE && + rac_tts_component_is_loaded(handle->tts_handle) == RAC_TRUE) { + rac::events::emit_voice_agent_all_ready(); + } + } else { + rac::events::emit_voice_agent_tts_state_changed(RAC_VOICE_AGENT_STATE_ERROR, voice_id, + "Failed to load TTS voice"); + } + + return result; +} + +rac_result_t rac_voice_agent_is_stt_loaded(rac_voice_agent_handle_t handle, + rac_bool_t* out_loaded) { + if (!handle || !out_loaded) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + *out_loaded = rac_stt_component_is_loaded(handle->stt_handle); + return RAC_SUCCESS; +} + +rac_result_t rac_voice_agent_is_llm_loaded(rac_voice_agent_handle_t handle, + rac_bool_t* out_loaded) { + if (!handle || !out_loaded) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + *out_loaded = rac_llm_component_is_loaded(handle->llm_handle); + return RAC_SUCCESS; +} + +rac_result_t rac_voice_agent_is_tts_loaded(rac_voice_agent_handle_t handle, + rac_bool_t* out_loaded) { + if (!handle || !out_loaded) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + *out_loaded = rac_tts_component_is_loaded(handle->tts_handle); + return RAC_SUCCESS; +} + +const char* rac_voice_agent_get_stt_model_id(rac_voice_agent_handle_t handle) { + if (!handle) + return nullptr; + return rac_stt_component_get_model_id(handle->stt_handle); +} + +const char* rac_voice_agent_get_llm_model_id(rac_voice_agent_handle_t handle) { + if (!handle) + return nullptr; + return rac_llm_component_get_model_id(handle->llm_handle); +} + +const char* rac_voice_agent_get_tts_voice_id(rac_voice_agent_handle_t handle) { + if (!handle) + return nullptr; + return rac_tts_component_get_voice_id(handle->tts_handle); +} + +rac_result_t rac_voice_agent_initialize(rac_voice_agent_handle_t handle, + const rac_voice_agent_config_t* config) { + if (!handle) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + + RAC_LOG_INFO("VoiceAgent", "Initializing Voice Agent"); + + const rac_voice_agent_config_t* cfg = config ? config : &RAC_VOICE_AGENT_CONFIG_DEFAULT; + + // Step 1: Initialize VAD (mirrors Swift's initializeVAD) + rac_result_t result = rac_vad_component_initialize(handle->vad_handle); + if (result != RAC_SUCCESS) { + RAC_LOG_ERROR("VoiceAgent", "VAD component failed to initialize"); + return result; + } + + // Step 2: Initialize STT model (mirrors Swift's initializeSTTModel) + if (cfg->stt_config.model_path && strlen(cfg->stt_config.model_path) > 0) { + // Load the specified model + RAC_LOG_INFO("VoiceAgent", "Loading STT model"); + result = rac_stt_component_load_model(handle->stt_handle, cfg->stt_config.model_path, + cfg->stt_config.model_id, cfg->stt_config.model_name); + if (result != RAC_SUCCESS) { + RAC_LOG_ERROR("VoiceAgent", "STT component failed to initialize"); + return result; + } + } + // If no model specified, we trust that one is already loaded (mirrors Swift) + + // Step 3: Initialize LLM model (mirrors Swift's initializeLLMModel) + if (cfg->llm_config.model_path && strlen(cfg->llm_config.model_path) > 0) { + RAC_LOG_INFO("VoiceAgent", "Loading LLM model"); + result = rac_llm_component_load_model(handle->llm_handle, cfg->llm_config.model_path, + cfg->llm_config.model_id, cfg->llm_config.model_name); + if (result != RAC_SUCCESS) { + RAC_LOG_ERROR("VoiceAgent", "LLM component failed to initialize"); + return result; + } + } + + // Step 4: Initialize TTS (mirrors Swift's initializeTTSVoice) + if (cfg->tts_config.voice_path && strlen(cfg->tts_config.voice_path) > 0) { + RAC_LOG_INFO("VoiceAgent", "Initializing TTS"); + result = rac_tts_component_load_voice(handle->tts_handle, cfg->tts_config.voice_path, + cfg->tts_config.voice_id, cfg->tts_config.voice_name); + if (result != RAC_SUCCESS) { + RAC_LOG_ERROR("VoiceAgent", "TTS component failed to initialize"); + return result; + } + } + + // Step 5: Verify all components ready (mirrors Swift's verifyAllComponentsReady) + // Note: In the C API, we trust initialization succeeded + + handle->is_configured = true; + RAC_LOG_INFO("VoiceAgent", "Voice Agent initialized successfully"); + + return RAC_SUCCESS; +} + +rac_result_t rac_voice_agent_initialize_with_loaded_models(rac_voice_agent_handle_t handle) { + if (!handle) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + + RAC_LOG_INFO("VoiceAgent", "Initializing Voice Agent with already-loaded models"); + + // Initialize VAD + rac_result_t result = rac_vad_component_initialize(handle->vad_handle); + if (result != RAC_SUCCESS) { + RAC_LOG_ERROR("VoiceAgent", "VAD component failed to initialize"); + return result; + } + + // Note: In C API, we trust that components are already initialized + // The Swift version checks isModelLoaded properties + + handle->is_configured = true; + RAC_LOG_INFO("VoiceAgent", "Voice Agent initialized with pre-loaded models"); + + return RAC_SUCCESS; +} + +rac_result_t rac_voice_agent_cleanup(rac_voice_agent_handle_t handle) { + if (!handle) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + + RAC_LOG_INFO("VoiceAgent", "Cleaning up Voice Agent"); + + // Cleanup all components (mirrors Swift's cleanup) + rac_llm_component_cleanup(handle->llm_handle); + rac_stt_component_cleanup(handle->stt_handle); + rac_tts_component_cleanup(handle->tts_handle); + // VAD uses stop + reset instead of cleanup + rac_vad_component_stop(handle->vad_handle); + rac_vad_component_reset(handle->vad_handle); + + handle->is_configured = false; + + return RAC_SUCCESS; +} + +rac_result_t rac_voice_agent_is_ready(rac_voice_agent_handle_t handle, rac_bool_t* out_is_ready) { + if (!handle || !out_is_ready) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + *out_is_ready = handle->is_configured ? RAC_TRUE : RAC_FALSE; + + return RAC_SUCCESS; +} + +// ============================================================================= +// VOICE PROCESSING API +// ============================================================================= + +rac_result_t rac_voice_agent_process_voice_turn(rac_voice_agent_handle_t handle, + const void* audio_data, size_t audio_size, + rac_voice_agent_result_t* out_result) { + if (!handle || !audio_data || audio_size == 0 || !out_result) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + + // Mirrors Swift's guard isConfigured + if (!handle->is_configured) { + RAC_LOG_ERROR("VoiceAgent", "Voice Agent is not initialized"); + return RAC_ERROR_NOT_INITIALIZED; + } + + // Defensive validation: Verify all components are in LOADED state before processing + // This catches issues like dangling handles or improperly initialized components + rac_result_t validation_result = validate_all_components_ready(handle); + if (validation_result != RAC_SUCCESS) { + RAC_LOG_ERROR("VoiceAgent", "Component validation failed - cannot process"); + return validation_result; + } + + RAC_LOG_INFO("VoiceAgent", "Processing voice turn"); + + // Initialize result + memset(out_result, 0, sizeof(rac_voice_agent_result_t)); + + // Step 1: Transcribe audio (mirrors Swift's Step 1) + RAC_LOG_DEBUG("VoiceAgent", "Step 1: Transcribing audio"); + + rac_stt_result_t stt_result = {}; + rac_result_t result = rac_stt_component_transcribe(handle->stt_handle, audio_data, audio_size, + nullptr, // default options + &stt_result); + + if (result != RAC_SUCCESS) { + RAC_LOG_ERROR("VoiceAgent", "STT transcription failed"); + return result; + } + + if (!stt_result.text || strlen(stt_result.text) == 0) { + RAC_LOG_WARNING("VoiceAgent", "Empty transcription, skipping processing"); + rac_stt_result_free(&stt_result); + // Return invalid state to indicate empty input (mirrors Swift's emptyInput error) + return RAC_ERROR_INVALID_STATE; + } + + RAC_LOG_INFO("VoiceAgent", "Transcription completed"); + + // Step 2: Generate LLM response (mirrors Swift's Step 2) + RAC_LOG_DEBUG("VoiceAgent", "Step 2: Generating LLM response"); + + rac_llm_result_t llm_result = {}; + result = rac_llm_component_generate(handle->llm_handle, stt_result.text, + nullptr, // default options + &llm_result); + + if (result != RAC_SUCCESS) { + RAC_LOG_ERROR("VoiceAgent", "LLM generation failed"); + rac_stt_result_free(&stt_result); + return result; + } + + RAC_LOG_INFO("VoiceAgent", "LLM response generated"); + + // Step 3: Synthesize speech (mirrors Swift's Step 3) + RAC_LOG_DEBUG("VoiceAgent", "Step 3: Synthesizing speech"); + + rac_tts_result_t tts_result = {}; + result = rac_tts_component_synthesize(handle->tts_handle, llm_result.text, + nullptr, // default options + &tts_result); + + if (result != RAC_SUCCESS) { + RAC_LOG_ERROR("VoiceAgent", "TTS synthesis failed"); + rac_stt_result_free(&stt_result); + rac_llm_result_free(&llm_result); + return result; + } + + // Step 4: Convert Float32 PCM to WAV format for playback + // TTS returns raw Float32 samples, but audio players need WAV format + void* wav_data = nullptr; + size_t wav_size = 0; + result = rac_audio_float32_to_wav(tts_result.audio_data, tts_result.audio_size, + tts_result.sample_rate > 0 ? tts_result.sample_rate + : RAC_TTS_DEFAULT_SAMPLE_RATE, + &wav_data, &wav_size); + + if (result != RAC_SUCCESS) { + RAC_LOG_ERROR("VoiceAgent", "Failed to convert audio to WAV format"); + rac_stt_result_free(&stt_result); + rac_llm_result_free(&llm_result); + rac_tts_result_free(&tts_result); + return result; + } + + RAC_LOG_DEBUG("VoiceAgent", "Converted PCM to WAV format"); + + // Build result (mirrors Swift's VoiceAgentResult) + out_result->speech_detected = RAC_TRUE; + out_result->transcription = rac_strdup(stt_result.text); + out_result->response = rac_strdup(llm_result.text); + out_result->synthesized_audio = wav_data; + out_result->synthesized_audio_size = wav_size; + + // Free intermediate results (tts_result audio data is no longer needed since we have WAV) + rac_stt_result_free(&stt_result); + rac_llm_result_free(&llm_result); + rac_tts_result_free(&tts_result); + + RAC_LOG_INFO("VoiceAgent", "Voice turn completed"); + + return RAC_SUCCESS; +} + +rac_result_t rac_voice_agent_process_stream(rac_voice_agent_handle_t handle, const void* audio_data, + size_t audio_size, + rac_voice_agent_event_callback_fn callback, + void* user_data) { + if (!handle || !audio_data || audio_size == 0 || !callback) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + + if (!handle->is_configured) { + rac_voice_agent_event_t error_event = {}; + error_event.type = RAC_VOICE_AGENT_EVENT_ERROR; + error_event.data.error_code = RAC_ERROR_NOT_INITIALIZED; + callback(&error_event, user_data); + return RAC_ERROR_NOT_INITIALIZED; + } + + // Defensive validation: Verify all components are in LOADED state before processing + rac_result_t validation_result = validate_all_components_ready(handle); + if (validation_result != RAC_SUCCESS) { + RAC_LOG_ERROR("VoiceAgent", "Component validation failed - cannot process stream"); + rac_voice_agent_event_t error_event = {}; + error_event.type = RAC_VOICE_AGENT_EVENT_ERROR; + error_event.data.error_code = validation_result; + callback(&error_event, user_data); + return validation_result; + } + + // Step 1: Transcribe + rac_stt_result_t stt_result = {}; + rac_result_t result = rac_stt_component_transcribe(handle->stt_handle, audio_data, audio_size, + nullptr, &stt_result); + + if (result != RAC_SUCCESS) { + rac_voice_agent_event_t error_event = {}; + error_event.type = RAC_VOICE_AGENT_EVENT_ERROR; + error_event.data.error_code = result; + callback(&error_event, user_data); + return result; + } + + // Emit transcription event + rac_voice_agent_event_t transcription_event = {}; + transcription_event.type = RAC_VOICE_AGENT_EVENT_TRANSCRIPTION; + transcription_event.data.transcription = stt_result.text; + callback(&transcription_event, user_data); + + // Step 2: Generate response + rac_llm_result_t llm_result = {}; + result = rac_llm_component_generate(handle->llm_handle, stt_result.text, nullptr, &llm_result); + + if (result != RAC_SUCCESS) { + rac_stt_result_free(&stt_result); + rac_voice_agent_event_t error_event = {}; + error_event.type = RAC_VOICE_AGENT_EVENT_ERROR; + error_event.data.error_code = result; + callback(&error_event, user_data); + return result; + } + + // Emit response event + rac_voice_agent_event_t response_event = {}; + response_event.type = RAC_VOICE_AGENT_EVENT_RESPONSE; + response_event.data.response = llm_result.text; + callback(&response_event, user_data); + + // Step 3: Synthesize + rac_tts_result_t tts_result = {}; + result = + rac_tts_component_synthesize(handle->tts_handle, llm_result.text, nullptr, &tts_result); + + if (result != RAC_SUCCESS) { + rac_stt_result_free(&stt_result); + rac_llm_result_free(&llm_result); + rac_voice_agent_event_t error_event = {}; + error_event.type = RAC_VOICE_AGENT_EVENT_ERROR; + error_event.data.error_code = result; + callback(&error_event, user_data); + return result; + } + + // Step 4: Convert Float32 PCM to WAV format for playback + void* wav_data = nullptr; + size_t wav_size = 0; + result = rac_audio_float32_to_wav(tts_result.audio_data, tts_result.audio_size, + tts_result.sample_rate > 0 ? tts_result.sample_rate + : RAC_TTS_DEFAULT_SAMPLE_RATE, + &wav_data, &wav_size); + + if (result != RAC_SUCCESS) { + rac_stt_result_free(&stt_result); + rac_llm_result_free(&llm_result); + rac_tts_result_free(&tts_result); + rac_voice_agent_event_t error_event = {}; + error_event.type = RAC_VOICE_AGENT_EVENT_ERROR; + error_event.data.error_code = result; + callback(&error_event, user_data); + return result; + } + + // Emit audio synthesized event (with WAV data) + rac_voice_agent_event_t audio_event = {}; + audio_event.type = RAC_VOICE_AGENT_EVENT_AUDIO_SYNTHESIZED; + audio_event.data.audio.audio_data = wav_data; + audio_event.data.audio.audio_size = wav_size; + callback(&audio_event, user_data); + + // Emit final processed event + rac_voice_agent_event_t processed_event = {}; + processed_event.type = RAC_VOICE_AGENT_EVENT_PROCESSED; + processed_event.data.result.speech_detected = RAC_TRUE; + processed_event.data.result.transcription = rac_strdup(stt_result.text); + processed_event.data.result.response = rac_strdup(llm_result.text); + processed_event.data.result.synthesized_audio = wav_data; + processed_event.data.result.synthesized_audio_size = wav_size; + callback(&processed_event, user_data); + + // Free intermediate results (WAV data ownership transferred to processed_event) + rac_stt_result_free(&stt_result); + rac_llm_result_free(&llm_result); + rac_tts_result_free(&tts_result); + + return RAC_SUCCESS; +} + +// ============================================================================= +// INDIVIDUAL COMPONENT ACCESS API +// ============================================================================= + +rac_result_t rac_voice_agent_transcribe(rac_voice_agent_handle_t handle, const void* audio_data, + size_t audio_size, char** out_transcription) { + if (!handle || !audio_data || audio_size == 0 || !out_transcription) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + + if (!handle->is_configured) { + return RAC_ERROR_NOT_INITIALIZED; + } + + rac_stt_result_t stt_result = {}; + rac_result_t result = rac_stt_component_transcribe(handle->stt_handle, audio_data, audio_size, + nullptr, &stt_result); + + if (result != RAC_SUCCESS) { + return result; + } + + *out_transcription = rac_strdup(stt_result.text); + rac_stt_result_free(&stt_result); + + return RAC_SUCCESS; +} + +rac_result_t rac_voice_agent_generate_response(rac_voice_agent_handle_t handle, const char* prompt, + char** out_response) { + if (!handle || !prompt || !out_response) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + + if (!handle->is_configured) { + return RAC_ERROR_NOT_INITIALIZED; + } + + rac_llm_result_t llm_result = {}; + rac_result_t result = + rac_llm_component_generate(handle->llm_handle, prompt, nullptr, &llm_result); + + if (result != RAC_SUCCESS) { + return result; + } + + *out_response = rac_strdup(llm_result.text); + rac_llm_result_free(&llm_result); + + return RAC_SUCCESS; +} + +rac_result_t rac_voice_agent_synthesize_speech(rac_voice_agent_handle_t handle, const char* text, + void** out_audio, size_t* out_audio_size) { + if (!handle || !text || !out_audio || !out_audio_size) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + + if (!handle->is_configured) { + return RAC_ERROR_NOT_INITIALIZED; + } + + rac_tts_result_t tts_result = {}; + rac_result_t result = + rac_tts_component_synthesize(handle->tts_handle, text, nullptr, &tts_result); + + if (result != RAC_SUCCESS) { + return result; + } + + // Convert Float32 PCM to WAV format for playback + void* wav_data = nullptr; + size_t wav_size = 0; + result = rac_audio_float32_to_wav(tts_result.audio_data, tts_result.audio_size, + tts_result.sample_rate > 0 ? tts_result.sample_rate + : RAC_TTS_DEFAULT_SAMPLE_RATE, + &wav_data, &wav_size); + + if (result != RAC_SUCCESS) { + rac_tts_result_free(&tts_result); + return result; + } + + *out_audio = wav_data; + *out_audio_size = wav_size; + + // Free the original PCM data + rac_tts_result_free(&tts_result); + + return RAC_SUCCESS; +} + +rac_result_t rac_voice_agent_detect_speech(rac_voice_agent_handle_t handle, const float* samples, + size_t sample_count, rac_bool_t* out_speech_detected) { + if (!handle || !samples || sample_count == 0 || !out_speech_detected) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + // VAD doesn't require is_configured (mirrors Swift) + rac_result_t result = + rac_vad_component_process(handle->vad_handle, samples, sample_count, out_speech_detected); + + return result; +} + +// ============================================================================= +// MEMORY MANAGEMENT +// ============================================================================= + +void rac_voice_agent_result_free(rac_voice_agent_result_t* result) { + if (!result) { + return; + } + + if (result->transcription) { + free(result->transcription); + result->transcription = nullptr; + } + + if (result->response) { + free(result->response); + result->response = nullptr; + } + + if (result->synthesized_audio) { + free(result->synthesized_audio); + result->synthesized_audio = nullptr; + } + + result->synthesized_audio_size = 0; + result->speech_detected = RAC_FALSE; +} + +// ============================================================================= +// AUDIO PIPELINE STATE API +// Ported from Swift's AudioPipelineState.swift +// ============================================================================= + +/** + * @brief Get string representation of audio pipeline state + * + * Ported from Swift AudioPipelineState enum rawValue (lines 4-24) + */ +const char* rac_audio_pipeline_state_name(rac_audio_pipeline_state_t state) { + switch (state) { + case RAC_AUDIO_PIPELINE_IDLE: + return "idle"; + case RAC_AUDIO_PIPELINE_LISTENING: + return "listening"; + case RAC_AUDIO_PIPELINE_PROCESSING_SPEECH: + return "processingSpeech"; + case RAC_AUDIO_PIPELINE_GENERATING_RESPONSE: + return "generatingResponse"; + case RAC_AUDIO_PIPELINE_PLAYING_TTS: + return "playingTTS"; + case RAC_AUDIO_PIPELINE_COOLDOWN: + return "cooldown"; + case RAC_AUDIO_PIPELINE_ERROR: + return "error"; + default: + return "unknown"; + } +} + +/** + * @brief Check if microphone can be activated in current state + * + * Ported from Swift AudioPipelineStateManager.canActivateMicrophone() (lines 75-89) + */ +rac_bool_t rac_audio_pipeline_can_activate_microphone(rac_audio_pipeline_state_t current_state, + int64_t last_tts_end_time_ms, + int64_t cooldown_duration_ms) { + // Only allow in idle or listening states + switch (current_state) { + case RAC_AUDIO_PIPELINE_IDLE: + case RAC_AUDIO_PIPELINE_LISTENING: + // Check cooldown if we recently finished TTS + if (last_tts_end_time_ms > 0) { + // Get current time in milliseconds + int64_t now_ms = rac_get_current_time_ms(); + int64_t elapsed_ms = now_ms - last_tts_end_time_ms; + if (elapsed_ms < cooldown_duration_ms) { + return RAC_FALSE; // Still in cooldown + } + } + return RAC_TRUE; + + case RAC_AUDIO_PIPELINE_PROCESSING_SPEECH: + case RAC_AUDIO_PIPELINE_GENERATING_RESPONSE: + case RAC_AUDIO_PIPELINE_PLAYING_TTS: + case RAC_AUDIO_PIPELINE_COOLDOWN: + case RAC_AUDIO_PIPELINE_ERROR: + return RAC_FALSE; + + default: + return RAC_FALSE; + } +} + +/** + * @brief Check if TTS can be played in current state + * + * Ported from Swift AudioPipelineStateManager.canPlayTTS() (lines 92-99) + */ +rac_bool_t rac_audio_pipeline_can_play_tts(rac_audio_pipeline_state_t current_state) { + // TTS can only be played when we're generating a response + return (current_state == RAC_AUDIO_PIPELINE_GENERATING_RESPONSE) ? RAC_TRUE : RAC_FALSE; +} + +/** + * @brief Check if a state transition is valid + * + * Ported from Swift AudioPipelineStateManager.isValidTransition() (lines 152-201) + */ +rac_bool_t rac_audio_pipeline_is_valid_transition(rac_audio_pipeline_state_t from_state, + rac_audio_pipeline_state_t to_state) { + // Any state can transition to error + if (to_state == RAC_AUDIO_PIPELINE_ERROR) { + return RAC_TRUE; + } + + switch (from_state) { + case RAC_AUDIO_PIPELINE_IDLE: + // From idle: can go to listening, cooldown, or error + return (to_state == RAC_AUDIO_PIPELINE_LISTENING || + to_state == RAC_AUDIO_PIPELINE_COOLDOWN) + ? RAC_TRUE + : RAC_FALSE; + + case RAC_AUDIO_PIPELINE_LISTENING: + // From listening: can go to idle, processingSpeech, or error + return (to_state == RAC_AUDIO_PIPELINE_IDLE || + to_state == RAC_AUDIO_PIPELINE_PROCESSING_SPEECH) + ? RAC_TRUE + : RAC_FALSE; + + case RAC_AUDIO_PIPELINE_PROCESSING_SPEECH: + // From processingSpeech: can go to idle, generatingResponse, listening, or error + return (to_state == RAC_AUDIO_PIPELINE_IDLE || + to_state == RAC_AUDIO_PIPELINE_GENERATING_RESPONSE || + to_state == RAC_AUDIO_PIPELINE_LISTENING) + ? RAC_TRUE + : RAC_FALSE; + + case RAC_AUDIO_PIPELINE_GENERATING_RESPONSE: + // From generatingResponse: can go to playingTTS, idle, cooldown, or error + return (to_state == RAC_AUDIO_PIPELINE_PLAYING_TTS || + to_state == RAC_AUDIO_PIPELINE_IDLE || to_state == RAC_AUDIO_PIPELINE_COOLDOWN) + ? RAC_TRUE + : RAC_FALSE; + + case RAC_AUDIO_PIPELINE_PLAYING_TTS: + // From playingTTS: can go to cooldown, idle, or error + return (to_state == RAC_AUDIO_PIPELINE_COOLDOWN || to_state == RAC_AUDIO_PIPELINE_IDLE) + ? RAC_TRUE + : RAC_FALSE; + + case RAC_AUDIO_PIPELINE_COOLDOWN: + // From cooldown: can only go to idle or error + return (to_state == RAC_AUDIO_PIPELINE_IDLE) ? RAC_TRUE : RAC_FALSE; + + case RAC_AUDIO_PIPELINE_ERROR: + // From error: can only go to idle (reset) + return (to_state == RAC_AUDIO_PIPELINE_IDLE) ? RAC_TRUE : RAC_FALSE; + + default: + return RAC_FALSE; + } +} diff --git a/sdk/runanywhere-commons/src/infrastructure/device/rac_device_manager.cpp b/sdk/runanywhere-commons/src/infrastructure/device/rac_device_manager.cpp new file mode 100644 index 000000000..3228ebc5d --- /dev/null +++ b/sdk/runanywhere-commons/src/infrastructure/device/rac_device_manager.cpp @@ -0,0 +1,240 @@ +/** + * @file rac_device_manager.cpp + * @brief Device Registration Manager Implementation + * + * All business logic for device registration lives here. + * Platform-specific operations are delegated to callbacks. + */ + +#include "rac/infrastructure/device/rac_device_manager.h" + +#include +#include +#include + +#include "rac/core/rac_analytics_events.h" +#include "rac/core/rac_logger.h" +#include "rac/core/rac_platform_adapter.h" +#include "rac/core/rac_types.h" +#include "rac/infrastructure/network/rac_endpoints.h" +#include "rac/infrastructure/telemetry/rac_telemetry_manager.h" + +// ============================================================================= +// INTERNAL STATE +// ============================================================================= + +namespace { + +// Thread-safe callback storage +struct DeviceManagerState { + rac_device_callbacks_t callbacks = {}; + bool callbacks_set = false; + std::mutex mutex; +}; + +DeviceManagerState& get_state() { + static DeviceManagerState state; + return state; +} + +// Logging category +static const char* LOG_CAT = "DeviceManager"; + +// Helper to emit device registered event +void emit_device_registered(const char* device_id) { + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_DEVICE_REGISTERED; + event.data.device = RAC_ANALYTICS_DEVICE_DEFAULT; + event.data.device.device_id = device_id; + + rac_analytics_event_emit(RAC_EVENT_DEVICE_REGISTERED, &event); +} + +// Helper to emit device registration failed event +void emit_device_registration_failed(rac_result_t error_code, const char* error_message) { + rac_analytics_event_data_t event = {}; + event.type = RAC_EVENT_DEVICE_REGISTRATION_FAILED; + event.data.device = RAC_ANALYTICS_DEVICE_DEFAULT; + event.data.device.error_code = error_code; + event.data.device.error_message = error_message; + + rac_analytics_event_emit(RAC_EVENT_DEVICE_REGISTRATION_FAILED, &event); +} + +} // namespace + +// ============================================================================= +// PUBLIC API +// ============================================================================= + +extern "C" { + +rac_result_t rac_device_manager_set_callbacks(const rac_device_callbacks_t* callbacks) { + if (!callbacks) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + // Validate required callbacks + if (!callbacks->get_device_info || !callbacks->get_device_id || !callbacks->is_registered || + !callbacks->set_registered || !callbacks->http_post) { + RAC_LOG_ERROR(LOG_CAT, "One or more required callbacks are NULL"); + return RAC_ERROR_INVALID_ARGUMENT; + } + + auto& state = get_state(); + std::lock_guard lock(state.mutex); + + state.callbacks = *callbacks; + state.callbacks_set = true; + + RAC_LOG_INFO(LOG_CAT, "Device manager callbacks configured"); + return RAC_SUCCESS; +} + +rac_result_t rac_device_manager_register_if_needed(rac_environment_t env, const char* build_token) { + auto& state = get_state(); + std::lock_guard lock(state.mutex); + + if (!state.callbacks_set) { + RAC_LOG_ERROR(LOG_CAT, "Device manager callbacks not set"); + return RAC_ERROR_NOT_INITIALIZED; + } + + // Step 1: Check if already registered + // Production behavior: Skip if already registered (performance, network efficiency) + // Development behavior: Always update via UPSERT (track active devices, update last_seen_at) + rac_bool_t was_registered = + state.callbacks.is_registered(state.callbacks.user_data) == RAC_TRUE; + if (was_registered && env != RAC_ENV_DEVELOPMENT) { + RAC_LOG_DEBUG(LOG_CAT, "Device already registered, skipping (production mode)"); + return RAC_SUCCESS; + } + + if (was_registered && env == RAC_ENV_DEVELOPMENT) { + RAC_LOG_DEBUG(LOG_CAT, + "Device marked as registered, but will update via UPSERT (development mode)"); + } + + RAC_LOG_INFO(LOG_CAT, "Starting device registration%s", + (env == RAC_ENV_DEVELOPMENT && was_registered) + ? " (UPSERT will update existing records)" + : ""); + + // Step 2: Get device ID + const char* device_id = state.callbacks.get_device_id(state.callbacks.user_data); + if (!device_id || strlen(device_id) == 0) { + RAC_LOG_ERROR(LOG_CAT, "Failed to get device ID"); + emit_device_registration_failed(RAC_ERROR_INVALID_STATE, "Failed to get device ID"); + return RAC_ERROR_INVALID_STATE; + } + RAC_LOG_INFO(LOG_CAT, "Device ID for registration: %s", device_id); + + // Step 3: Get device info + rac_device_registration_info_t device_info = {}; + state.callbacks.get_device_info(&device_info, state.callbacks.user_data); + + // Ensure device_id is set in info + device_info.device_id = device_id; + + // Step 4: Build registration request + rac_device_registration_request_t request = {}; + request.device_info = device_info; + + // Get SDK version from SDK config if available + const rac_sdk_config_t* sdk_config = rac_sdk_get_config(); + request.sdk_version = sdk_config ? sdk_config->sdk_version : "unknown"; + request.build_token = (env == RAC_ENV_DEVELOPMENT) ? build_token : nullptr; + request.last_seen_at_ms = rac_get_current_time_ms(); + + // Step 5: Serialize to JSON + char* json_ptr = nullptr; + size_t json_len = 0; + rac_result_t result = rac_device_registration_to_json(&request, env, &json_ptr, &json_len); + + if (result != RAC_SUCCESS || !json_ptr) { + RAC_LOG_ERROR(LOG_CAT, "Failed to build registration JSON"); + emit_device_registration_failed(result, "Failed to build registration JSON"); + return result; + } + + // Step 6: Get endpoint + const char* endpoint = rac_endpoint_device_registration(env); + if (!endpoint) { + RAC_LOG_ERROR(LOG_CAT, "Failed to get device registration endpoint"); + rac_free(json_ptr); + emit_device_registration_failed(RAC_ERROR_INVALID_STATE, "Failed to get endpoint"); + return RAC_ERROR_INVALID_STATE; + } + RAC_LOG_DEBUG(LOG_CAT, "Registration endpoint: %s", endpoint); + RAC_LOG_DEBUG(LOG_CAT, "Registration JSON payload (first 200 chars): %.200s", + json_ptr ? json_ptr : "(null)"); + + // Step 7: Determine if auth is required (staging/production require auth) + rac_bool_t requires_auth = (env != RAC_ENV_DEVELOPMENT) ? RAC_TRUE : RAC_FALSE; + + // Step 8: Make HTTP request via callback + rac_device_http_response_t response = {}; + result = state.callbacks.http_post(endpoint, json_ptr, requires_auth, &response, + state.callbacks.user_data); + + // Free JSON after use + rac_free(json_ptr); + + // Step 9: Handle response + if (result != RAC_SUCCESS || response.result != RAC_SUCCESS) { + std::string error_msg = "Device registration failed"; + if (response.error_message) { + error_msg = error_msg + ": " + response.error_message; + } + RAC_LOG_ERROR(LOG_CAT, "%s", error_msg.c_str()); + emit_device_registration_failed(result != RAC_SUCCESS ? result : response.result, + response.error_message ? response.error_message + : "HTTP request failed"); + return result != RAC_SUCCESS ? result : response.result; + } + + // Step 10: Mark as registered + state.callbacks.set_registered(RAC_TRUE, state.callbacks.user_data); + + // Step 11: Emit success event + emit_device_registered(device_id); + + RAC_LOG_INFO(LOG_CAT, "Device registration successful"); + return RAC_SUCCESS; +} + +rac_bool_t rac_device_manager_is_registered(void) { + auto& state = get_state(); + std::lock_guard lock(state.mutex); + + if (!state.callbacks_set) { + return RAC_FALSE; + } + + return state.callbacks.is_registered(state.callbacks.user_data); +} + +void rac_device_manager_clear_registration(void) { + auto& state = get_state(); + std::lock_guard lock(state.mutex); + + if (!state.callbacks_set) { + return; + } + + state.callbacks.set_registered(RAC_FALSE, state.callbacks.user_data); + RAC_LOG_INFO(LOG_CAT, "Device registration cleared"); +} + +const char* rac_device_manager_get_device_id(void) { + auto& state = get_state(); + std::lock_guard lock(state.mutex); + + if (!state.callbacks_set) { + return nullptr; + } + + return state.callbacks.get_device_id(state.callbacks.user_data); +} + +} // extern "C" diff --git a/sdk/runanywhere-commons/src/infrastructure/download/download_manager.cpp b/sdk/runanywhere-commons/src/infrastructure/download/download_manager.cpp new file mode 100644 index 000000000..3f58fc307 --- /dev/null +++ b/sdk/runanywhere-commons/src/infrastructure/download/download_manager.cpp @@ -0,0 +1,572 @@ +/** + * @file download_manager.cpp + * @brief RunAnywhere Commons - Download Manager Implementation + * + * C++ port of Swift's DownloadService orchestration logic. + * Swift Source: Sources/RunAnywhere/Infrastructure/Download/ + * + * CRITICAL: This is a direct port of Swift implementation - do NOT add custom logic! + * + * NOTE: The actual HTTP download is delegated to the platform adapter (Swift/Kotlin). + * This C layer handles orchestration: progress tracking, state management, retry logic. + */ + +#include +#include +#include +#include +#include +#include +#include + +#include "rac/core/rac_logger.h" +#include "rac/core/rac_platform_adapter.h" +#include "rac/core/rac_structured_error.h" +#include "rac/infrastructure/download/rac_download.h" + +// ============================================================================= +// INTERNAL STRUCTURES +// ============================================================================= + +struct download_task_internal { + std::string task_id; + std::string model_id; + std::string url; + std::string destination_path; + bool requires_extraction; + rac_download_progress_t progress; + + // Callbacks + rac_download_progress_callback_fn progress_callback; + rac_download_complete_callback_fn complete_callback; + void* user_data; + + // Internal state + std::string downloaded_file_path; + std::string error_message; + int64_t start_time_ms; +}; + +struct rac_download_manager { + // Configuration + rac_download_config_t config; + + // Task storage + std::map tasks; + + // Task ID counter + std::atomic task_counter; + + // Thread safety + std::mutex mutex; + + // Health state + bool is_healthy; + bool is_paused; +}; + +// Note: rac_strdup is declared in rac_types.h and implemented in rac_memory.cpp + +static std::string generate_task_id(rac_download_manager* mgr) { + uint64_t id = mgr->task_counter.fetch_add(1); + return "download-task-" + std::to_string(id); +} + +static double calculate_overall_progress(rac_download_stage_t stage, double stage_progress) { + // Progress ranges: Download: 0-80%, Extraction: 80-95%, Validation: 95-99%, Complete: 100% + double start = 0.0; + double end = 0.0; + + switch (stage) { + case RAC_DOWNLOAD_STAGE_DOWNLOADING: + start = 0.0; + end = 0.80; + break; + case RAC_DOWNLOAD_STAGE_EXTRACTING: + start = 0.80; + end = 0.95; + break; + case RAC_DOWNLOAD_STAGE_VALIDATING: + start = 0.95; + end = 0.99; + break; + case RAC_DOWNLOAD_STAGE_COMPLETED: + return 1.0; + } + + return start + (stage_progress * (end - start)); +} + +static void notify_progress(download_task_internal& task) { + if (task.progress_callback) { + task.progress_callback(&task.progress, task.user_data); + } +} + +static void notify_complete(download_task_internal& task, rac_result_t result, + const char* final_path) { + if (task.complete_callback) { + task.complete_callback(task.task_id.c_str(), result, final_path, task.user_data); + } +} + +// ============================================================================= +// PUBLIC API - LIFECYCLE +// ============================================================================= + +rac_result_t rac_download_manager_create(const rac_download_config_t* config, + rac_download_manager_handle_t* out_handle) { + if (!out_handle) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + rac_download_manager* mgr = new rac_download_manager(); + + // Initialize config + if (config) { + mgr->config = *config; + } else { + mgr->config = RAC_DOWNLOAD_CONFIG_DEFAULT; + } + + mgr->task_counter = 1; + mgr->is_healthy = true; + mgr->is_paused = false; + + RAC_LOG_INFO("DownloadManager", "Download manager created"); + + *out_handle = mgr; + return RAC_SUCCESS; +} + +void rac_download_manager_destroy(rac_download_manager_handle_t handle) { + if (!handle) { + return; + } + + // Cancel any active downloads + { + std::lock_guard lock(handle->mutex); + for (auto& pair : handle->tasks) { + download_task_internal& task = pair.second; + if (task.progress.state == RAC_DOWNLOAD_STATE_DOWNLOADING || + task.progress.state == RAC_DOWNLOAD_STATE_EXTRACTING) { + task.progress.state = RAC_DOWNLOAD_STATE_CANCELLED; + notify_complete(task, RAC_ERROR_CANCELLED, nullptr); + } + } + } + + delete handle; + RAC_LOG_DEBUG("DownloadManager", "Download manager destroyed"); +} + +// ============================================================================= +// PUBLIC API - DOWNLOAD OPERATIONS +// ============================================================================= + +rac_result_t rac_download_manager_start(rac_download_manager_handle_t handle, const char* model_id, + const char* url, const char* destination_path, + rac_bool_t requires_extraction, + rac_download_progress_callback_fn progress_callback, + rac_download_complete_callback_fn complete_callback, + void* user_data, char** out_task_id) { + if (!handle || !model_id || !url || !destination_path || !out_task_id) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + + if (handle->is_paused) { + RAC_LOG_WARNING("DownloadManager", "Download manager is paused"); + return RAC_ERROR_INVALID_STATE; + } + + // Create task + std::string task_id = generate_task_id(handle); + + download_task_internal task; + task.task_id = task_id; + task.model_id = model_id; + task.url = url; + task.destination_path = destination_path; + task.requires_extraction = requires_extraction == RAC_TRUE; + task.progress = RAC_DOWNLOAD_PROGRESS_DEFAULT; + task.progress.state = RAC_DOWNLOAD_STATE_PENDING; + task.progress_callback = progress_callback; + task.complete_callback = complete_callback; + task.user_data = user_data; + task.start_time_ms = rac_get_current_time_ms(); + + handle->tasks[task_id] = std::move(task); + + *out_task_id = rac_strdup(task_id.c_str()); + + RAC_LOG_INFO("DownloadManager", "Started download task"); + + // Notify initial progress + download_task_internal& stored_task = handle->tasks[task_id]; + notify_progress(stored_task); + + // Note: Actual HTTP download is triggered by platform adapter + // This function just creates the tracking state + + return RAC_SUCCESS; +} + +rac_result_t rac_download_manager_cancel(rac_download_manager_handle_t handle, + const char* task_id) { + if (!handle || !task_id) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + + auto it = handle->tasks.find(task_id); + if (it == handle->tasks.end()) { + return RAC_ERROR_NOT_FOUND; + } + + download_task_internal& task = it->second; + + if (task.progress.state == RAC_DOWNLOAD_STATE_COMPLETED || + task.progress.state == RAC_DOWNLOAD_STATE_FAILED || + task.progress.state == RAC_DOWNLOAD_STATE_CANCELLED) { + // Already in terminal state + return RAC_SUCCESS; + } + + task.progress.state = RAC_DOWNLOAD_STATE_CANCELLED; + notify_progress(task); + notify_complete(task, RAC_ERROR_CANCELLED, nullptr); + + RAC_LOG_INFO("DownloadManager", "Cancelled download task"); + + return RAC_SUCCESS; +} + +rac_result_t rac_download_manager_pause_all(rac_download_manager_handle_t handle) { + if (!handle) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + handle->is_paused = true; + + RAC_LOG_INFO("DownloadManager", "Paused all downloads"); + + return RAC_SUCCESS; +} + +rac_result_t rac_download_manager_resume_all(rac_download_manager_handle_t handle) { + if (!handle) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + handle->is_paused = false; + + RAC_LOG_INFO("DownloadManager", "Resumed all downloads"); + + return RAC_SUCCESS; +} + +// ============================================================================= +// PUBLIC API - STATUS +// ============================================================================= + +rac_result_t rac_download_manager_get_progress(rac_download_manager_handle_t handle, + const char* task_id, + rac_download_progress_t* out_progress) { + if (!handle || !task_id || !out_progress) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + + auto it = handle->tasks.find(task_id); + if (it == handle->tasks.end()) { + return RAC_ERROR_NOT_FOUND; + } + + *out_progress = it->second.progress; + return RAC_SUCCESS; +} + +rac_result_t rac_download_manager_get_active_tasks(rac_download_manager_handle_t handle, + char*** out_task_ids, size_t* out_count) { + if (!handle || !out_task_ids || !out_count) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + + std::vector active_ids; + for (const auto& pair : handle->tasks) { + const download_task_internal& task = pair.second; + if (task.progress.state == RAC_DOWNLOAD_STATE_PENDING || + task.progress.state == RAC_DOWNLOAD_STATE_DOWNLOADING || + task.progress.state == RAC_DOWNLOAD_STATE_EXTRACTING || + task.progress.state == RAC_DOWNLOAD_STATE_RETRYING) { + active_ids.push_back(task.task_id); + } + } + + *out_count = active_ids.size(); + if (active_ids.empty()) { + *out_task_ids = nullptr; + return RAC_SUCCESS; + } + + *out_task_ids = static_cast(malloc(sizeof(char*) * active_ids.size())); + for (size_t i = 0; i < active_ids.size(); ++i) { + (*out_task_ids)[i] = rac_strdup(active_ids[i].c_str()); + } + + return RAC_SUCCESS; +} + +rac_result_t rac_download_manager_is_healthy(rac_download_manager_handle_t handle, + rac_bool_t* out_is_healthy) { + if (!handle || !out_is_healthy) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + *out_is_healthy = handle->is_healthy ? RAC_TRUE : RAC_FALSE; + + return RAC_SUCCESS; +} + +// ============================================================================= +// PUBLIC API - PROGRESS HELPERS (called by platform adapter) +// ============================================================================= + +rac_result_t rac_download_manager_update_progress(rac_download_manager_handle_t handle, + const char* task_id, int64_t bytes_downloaded, + int64_t total_bytes) { + if (!handle || !task_id) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + + auto it = handle->tasks.find(task_id); + if (it == handle->tasks.end()) { + return RAC_ERROR_NOT_FOUND; + } + + download_task_internal& task = it->second; + + // Update progress + task.progress.state = RAC_DOWNLOAD_STATE_DOWNLOADING; + task.progress.stage = RAC_DOWNLOAD_STAGE_DOWNLOADING; + task.progress.bytes_downloaded = bytes_downloaded; + task.progress.total_bytes = total_bytes; + + if (total_bytes > 0) { + task.progress.stage_progress = + static_cast(bytes_downloaded) / static_cast(total_bytes); + } else { + task.progress.stage_progress = 0.0; + } + + task.progress.overall_progress = + calculate_overall_progress(task.progress.stage, task.progress.stage_progress); + + // Calculate speed + int64_t elapsed_ms = rac_get_current_time_ms() - task.start_time_ms; + if (elapsed_ms > 0) { + task.progress.speed = + static_cast(bytes_downloaded) / (static_cast(elapsed_ms) / 1000.0); + + // Calculate ETA + if (task.progress.speed > 0 && total_bytes > bytes_downloaded) { + int64_t remaining_bytes = total_bytes - bytes_downloaded; + task.progress.estimated_time_remaining = + static_cast(remaining_bytes) / task.progress.speed; + } + } + + notify_progress(task); + + return RAC_SUCCESS; +} + +rac_result_t rac_download_manager_mark_complete(rac_download_manager_handle_t handle, + const char* task_id, const char* downloaded_path) { + if (!handle || !task_id || !downloaded_path) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + + auto it = handle->tasks.find(task_id); + if (it == handle->tasks.end()) { + return RAC_ERROR_NOT_FOUND; + } + + download_task_internal& task = it->second; + task.downloaded_file_path = downloaded_path; + + if (task.requires_extraction) { + // Move to extraction stage + task.progress.state = RAC_DOWNLOAD_STATE_EXTRACTING; + task.progress.stage = RAC_DOWNLOAD_STAGE_EXTRACTING; + task.progress.stage_progress = 0.0; + task.progress.overall_progress = + calculate_overall_progress(RAC_DOWNLOAD_STAGE_EXTRACTING, 0.0); + notify_progress(task); + + // Note: Platform adapter should call extract_archive and then call + // rac_download_manager_mark_extraction_complete + } else { + // No extraction needed, mark as complete + task.progress.state = RAC_DOWNLOAD_STATE_COMPLETED; + task.progress.stage = RAC_DOWNLOAD_STAGE_COMPLETED; + task.progress.stage_progress = 1.0; + task.progress.overall_progress = 1.0; + notify_progress(task); + notify_complete(task, RAC_SUCCESS, downloaded_path); + } + + RAC_LOG_INFO("DownloadManager", "Download completed"); + + return RAC_SUCCESS; +} + +rac_result_t rac_download_manager_mark_failed(rac_download_manager_handle_t handle, + const char* task_id, rac_result_t error_code, + const char* error_message) { + if (!handle || !task_id) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + + auto it = handle->tasks.find(task_id); + if (it == handle->tasks.end()) { + return RAC_ERROR_NOT_FOUND; + } + + download_task_internal& task = it->second; + + // Check if we should retry + if (task.progress.retry_attempt < handle->config.max_retry_attempts) { + task.progress.retry_attempt++; + task.progress.state = RAC_DOWNLOAD_STATE_RETRYING; + task.progress.error_code = error_code; + if (error_message) { + task.error_message = error_message; + task.progress.error_message = task.error_message.c_str(); + } + notify_progress(task); + + RAC_LOG_WARNING("DownloadManager", "Download failed, will retry"); + + // Note: Platform adapter should retry after delay + } else { + // Max retries reached, mark as failed + task.progress.state = RAC_DOWNLOAD_STATE_FAILED; + task.progress.error_code = error_code; + if (error_message) { + task.error_message = error_message; + task.progress.error_message = task.error_message.c_str(); + } + notify_progress(task); + notify_complete(task, error_code, nullptr); + + RAC_LOG_ERROR("DownloadManager", "Download failed after all retries"); + } + + return RAC_SUCCESS; +} + +// ============================================================================= +// PUBLIC API - STAGE INFO +// ============================================================================= + +const char* rac_download_stage_display_name(rac_download_stage_t stage) { + switch (stage) { + case RAC_DOWNLOAD_STAGE_DOWNLOADING: + return "Downloading"; + case RAC_DOWNLOAD_STAGE_EXTRACTING: + return "Extracting"; + case RAC_DOWNLOAD_STAGE_VALIDATING: + return "Validating"; + case RAC_DOWNLOAD_STAGE_COMPLETED: + return "Completed"; + default: + return "Unknown"; + } +} + +void rac_download_stage_progress_range(rac_download_stage_t stage, double* out_start, + double* out_end) { + if (!out_start || !out_end) { + return; + } + + switch (stage) { + case RAC_DOWNLOAD_STAGE_DOWNLOADING: + *out_start = 0.0; + *out_end = 0.80; + break; + case RAC_DOWNLOAD_STAGE_EXTRACTING: + *out_start = 0.80; + *out_end = 0.95; + break; + case RAC_DOWNLOAD_STAGE_VALIDATING: + *out_start = 0.95; + *out_end = 0.99; + break; + case RAC_DOWNLOAD_STAGE_COMPLETED: + *out_start = 1.0; + *out_end = 1.0; + break; + default: + *out_start = 0.0; + *out_end = 0.0; + break; + } +} + +// ============================================================================= +// PUBLIC API - MEMORY MANAGEMENT +// ============================================================================= + +void rac_download_task_free(rac_download_task_t* task) { + if (!task) { + return; + } + + if (task->task_id) { + free(task->task_id); + task->task_id = nullptr; + } + if (task->model_id) { + free(task->model_id); + task->model_id = nullptr; + } + if (task->url) { + free(task->url); + task->url = nullptr; + } + if (task->destination_path) { + free(task->destination_path); + task->destination_path = nullptr; + } +} + +void rac_download_task_ids_free(char** task_ids, size_t count) { + if (!task_ids) { + return; + } + + for (size_t i = 0; i < count; ++i) { + if (task_ids[i]) { + free(task_ids[i]); + } + } + free(task_ids); +} diff --git a/sdk/runanywhere-commons/src/infrastructure/events/event_publisher.cpp b/sdk/runanywhere-commons/src/infrastructure/events/event_publisher.cpp new file mode 100644 index 000000000..1c831dfa2 --- /dev/null +++ b/sdk/runanywhere-commons/src/infrastructure/events/event_publisher.cpp @@ -0,0 +1,231 @@ +/** + * @file event_publisher.cpp + * @brief RunAnywhere Commons - Event Publisher Implementation + * + * C++ port of Swift's EventPublisher.swift + * Provides category-based event subscription matching Swift's pattern. + */ + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "rac/core/rac_error.h" +#include "rac/core/rac_logger.h" +#include "rac/core/rac_types.h" +#include "rac/infrastructure/events/rac_events.h" + +// ============================================================================= +// INTERNAL STORAGE +// ============================================================================= + +namespace { + +struct Subscription { + uint64_t id; + rac_event_callback_fn callback; + void* user_data; +}; + +std::mutex g_event_mutex; +std::atomic g_next_subscription_id{1}; + +// Subscriptions per category +std::unordered_map> g_subscriptions; + +// All-events subscriptions +std::vector g_all_subscriptions; + +// Sentinel category for "all events" +const rac_event_category_t CATEGORY_ALL_SENTINEL = static_cast(-1); + +uint64_t current_time_ms() { + using namespace std::chrono; + return duration_cast(system_clock::now().time_since_epoch()).count(); +} + +// Generate a simple UUID-like ID +std::string generate_event_id() { + static std::atomic counter{0}; + auto now = current_time_ms(); + auto count = counter.fetch_add(1); + char buffer[64]; + snprintf(buffer, sizeof(buffer), "%llu-%llu", static_cast(now), + static_cast(count)); + return buffer; +} + +} // namespace + +// ============================================================================= +// EVENT SUBSCRIPTION API +// ============================================================================= + +extern "C" { + +uint64_t rac_event_subscribe(rac_event_category_t category, rac_event_callback_fn callback, + void* user_data) { + if (callback == nullptr) { + return 0; + } + + std::lock_guard lock(g_event_mutex); + + Subscription sub; + sub.id = g_next_subscription_id.fetch_add(1); + sub.callback = callback; + sub.user_data = user_data; + + g_subscriptions[category].push_back(sub); + + return sub.id; +} + +uint64_t rac_event_subscribe_all(rac_event_callback_fn callback, void* user_data) { + if (callback == nullptr) { + return 0; + } + + std::lock_guard lock(g_event_mutex); + + Subscription sub; + sub.id = g_next_subscription_id.fetch_add(1); + sub.callback = callback; + sub.user_data = user_data; + + g_all_subscriptions.push_back(sub); + + return sub.id; +} + +void rac_event_unsubscribe(uint64_t subscription_id) { + if (subscription_id == 0) { + return; + } + + std::lock_guard lock(g_event_mutex); + + auto remove_from = [subscription_id](std::vector& subs) { + auto it = + std::remove_if(subs.begin(), subs.end(), [subscription_id](const Subscription& s) { + return s.id == subscription_id; + }); + if (it != subs.end()) { + subs.erase(it, subs.end()); + return true; + } + return false; + }; + + // Check all-events subscriptions + if (remove_from(g_all_subscriptions)) { + return; + } + + // Check category-specific subscriptions + for (auto& pair : g_subscriptions) { + if (remove_from(pair.second)) { + return; + } + } +} + +rac_result_t rac_event_publish(const rac_event_t* event) { + if (event == nullptr) { + return RAC_ERROR_NULL_POINTER; + } + + // Create a copy with timestamp if not set + rac_event_t event_copy = *event; + if (event_copy.timestamp_ms == 0) { + event_copy.timestamp_ms = static_cast(current_time_ms()); + } + + std::lock_guard lock(g_event_mutex); + + // Notify category-specific subscribers + auto it = g_subscriptions.find(event_copy.category); + if (it != g_subscriptions.end()) { + for (const auto& sub : it->second) { + sub.callback(&event_copy, sub.user_data); + } + } + + // Notify all-events subscribers + for (const auto& sub : g_all_subscriptions) { + sub.callback(&event_copy, sub.user_data); + } + + return RAC_SUCCESS; +} + +rac_result_t rac_event_track(const char* type, rac_event_category_t category, + rac_event_destination_t destination, const char* properties_json) { + if (type == nullptr) { + return RAC_ERROR_NULL_POINTER; + } + + // Generate event ID + static thread_local std::string s_event_id; + s_event_id = generate_event_id(); + + rac_event_t event = {}; + event.id = s_event_id.c_str(); + event.type = type; + event.category = category; + event.timestamp_ms = static_cast(current_time_ms()); + event.session_id = nullptr; + event.destination = destination; + event.properties_json = properties_json; + + return rac_event_publish(&event); +} + +const char* rac_event_category_name(rac_event_category_t category) { + switch (category) { + case RAC_EVENT_CATEGORY_SDK: + return "sdk"; + case RAC_EVENT_CATEGORY_MODEL: + return "model"; + case RAC_EVENT_CATEGORY_LLM: + return "llm"; + case RAC_EVENT_CATEGORY_STT: + return "stt"; + case RAC_EVENT_CATEGORY_TTS: + return "tts"; + case RAC_EVENT_CATEGORY_VOICE: + return "voice"; + case RAC_EVENT_CATEGORY_STORAGE: + return "storage"; + case RAC_EVENT_CATEGORY_DEVICE: + return "device"; + case RAC_EVENT_CATEGORY_NETWORK: + return "network"; + case RAC_EVENT_CATEGORY_ERROR: + return "error"; + default: + return "unknown"; + } +} + +} // extern "C" + +// ============================================================================= +// INTERNAL RESET (for testing) +// ============================================================================= + +namespace rac_internal { + +void reset_event_publisher() { + std::lock_guard lock(g_event_mutex); + g_subscriptions.clear(); + g_all_subscriptions.clear(); + g_next_subscription_id.store(1); +} + +} // namespace rac_internal diff --git a/sdk/runanywhere-commons/src/infrastructure/model_management/model_assignment.cpp b/sdk/runanywhere-commons/src/infrastructure/model_management/model_assignment.cpp new file mode 100644 index 000000000..a6415dd1c --- /dev/null +++ b/sdk/runanywhere-commons/src/infrastructure/model_management/model_assignment.cpp @@ -0,0 +1,492 @@ +/** + * @file model_assignment.cpp + * @brief Model Assignment Manager Implementation + */ + +#include +#include +#include +#include +#include +#include + +#include "rac/core/rac_core.h" +#include "rac/core/rac_logger.h" +#include "rac/core/rac_platform_adapter.h" +#include "rac/infrastructure/model_management/rac_model_assignment.h" +#include "rac/infrastructure/model_management/rac_model_registry.h" +#include "rac/infrastructure/network/rac_endpoints.h" + +// Simple JSON parsing (we don't want heavy dependencies) +#include +#include + +static const char* LOG_CAT = "ModelAssignment"; + +// ============================================================================= +// INTERNAL STATE +// ============================================================================= + +static rac_assignment_callbacks_t g_callbacks = {}; +static std::mutex g_mutex; + +// Cache +static std::vector g_cached_models; +static std::chrono::steady_clock::time_point g_last_fetch_time; +static uint32_t g_cache_timeout_seconds = 3600; // 1 hour default +static bool g_cache_valid = false; + +// ============================================================================= +// HELPER FUNCTIONS +// ============================================================================= + +static void clear_cache_internal() { + for (auto* model : g_cached_models) { + rac_model_info_free(model); + } + g_cached_models.clear(); + g_cache_valid = false; +} + +static bool is_cache_valid() { + if (!g_cache_valid) + return false; + + auto now = std::chrono::steady_clock::now(); + auto elapsed = + std::chrono::duration_cast(now - g_last_fetch_time).count(); + return elapsed < g_cache_timeout_seconds; +} + +// Simple JSON string extraction (finds "key": "value" or "key": number) +static std::string json_get_string(const std::string& json, const std::string& key) { + std::string search = "\"" + key + "\""; + size_t pos = json.find(search); + if (pos == std::string::npos) + return ""; + + pos = json.find(":", pos); + if (pos == std::string::npos) + return ""; + + // Skip whitespace + pos++; + while (pos < json.size() && (json[pos] == ' ' || json[pos] == '\t')) + pos++; + + if (pos >= json.size()) + return ""; + + // Check for string value + if (json[pos] == '"') { + size_t start = pos + 1; + size_t end = json.find('"', start); + if (end == std::string::npos) + return ""; + return json.substr(start, end - start); + } + + // Check for null + if (json.substr(pos, 4) == "null") + return ""; + + // Number or boolean + size_t end = json.find_first_of(",}]", pos); + if (end == std::string::npos) + return ""; + std::string val = json.substr(pos, end - pos); + // Trim whitespace + while (!val.empty() && (val.back() == ' ' || val.back() == '\t' || val.back() == '\n')) { + val.pop_back(); + } + return val; +} + +static int64_t json_get_int(const std::string& json, const std::string& key, + int64_t default_val = 0) { + std::string val = json_get_string(json, key); + if (val.empty()) + return default_val; + return std::strtoll(val.c_str(), nullptr, 10); +} + +static bool json_get_bool(const std::string& json, const std::string& key, + bool default_val = false) { + std::string val = json_get_string(json, key); + if (val.empty()) + return default_val; + return val == "true"; +} + +// Parse models array from JSON response +static std::vector parse_models_json(const char* json_str, size_t len) { + std::vector models; + if (!json_str || len == 0) + return models; + + std::string json(json_str, len); + + // Find "models" array + size_t models_pos = json.find("\"models\""); + if (models_pos == std::string::npos) { + RAC_LOG_WARNING(LOG_CAT, "No 'models' array in response"); + return models; + } + + // Find array start + size_t arr_start = json.find('[', models_pos); + if (arr_start == std::string::npos) + return models; + + // Find each object in array + size_t pos = arr_start + 1; + while (pos < json.size()) { + // Find next object start + size_t obj_start = json.find('{', pos); + if (obj_start == std::string::npos) + break; + + // Find matching close brace (simple approach, may fail on nested objects) + int depth = 1; + size_t obj_end = obj_start + 1; + while (obj_end < json.size() && depth > 0) { + if (json[obj_end] == '{') + depth++; + else if (json[obj_end] == '}') + depth--; + obj_end++; + } + + if (depth != 0) + break; + + std::string obj = json.substr(obj_start, obj_end - obj_start); + + // Parse model fields + std::string id = json_get_string(obj, "id"); + std::string name = json_get_string(obj, "name"); + std::string category = json_get_string(obj, "category"); + std::string format = json_get_string(obj, "format"); + std::string framework = json_get_string(obj, "preferred_framework"); + std::string download_url = json_get_string(obj, "download_url"); + std::string description = json_get_string(obj, "description"); + int64_t size = json_get_int(obj, "size", 0); + int context_length = static_cast(json_get_int(obj, "context_length", 0)); + bool supports_thinking = json_get_bool(obj, "supports_thinking", false); + + if (id.empty()) { + pos = obj_end; + continue; + } + + // Create model info + rac_model_info_t* model = rac_model_info_alloc(); + if (!model) + continue; + + model->id = strdup(id.c_str()); + model->name = strdup(name.c_str()); + model->download_url = download_url.empty() ? nullptr : strdup(download_url.c_str()); + model->description = description.empty() ? nullptr : strdup(description.c_str()); + model->download_size = size; + model->context_length = context_length; + model->supports_thinking = supports_thinking ? RAC_TRUE : RAC_FALSE; + model->source = RAC_MODEL_SOURCE_REMOTE; + + // Parse category + if (category == "language") + model->category = RAC_MODEL_CATEGORY_LANGUAGE; + else if (category == "speech" || category == "stt") + model->category = RAC_MODEL_CATEGORY_SPEECH_RECOGNITION; + else if (category == "tts") + model->category = RAC_MODEL_CATEGORY_SPEECH_SYNTHESIS; + else if (category == "vision") + model->category = RAC_MODEL_CATEGORY_VISION; + else if (category == "audio") + model->category = RAC_MODEL_CATEGORY_AUDIO; + else if (category == "multimodal") + model->category = RAC_MODEL_CATEGORY_MULTIMODAL; + else + model->category = RAC_MODEL_CATEGORY_LANGUAGE; + + // Parse format + if (format == "gguf") + model->format = RAC_MODEL_FORMAT_GGUF; + else if (format == "onnx") + model->format = RAC_MODEL_FORMAT_ONNX; + else if (format == "ort") + model->format = RAC_MODEL_FORMAT_ORT; + else if (format == "bin") + model->format = RAC_MODEL_FORMAT_BIN; + else + model->format = RAC_MODEL_FORMAT_UNKNOWN; + + // Parse framework + if (framework == "llama.cpp" || framework == "llamacpp") + model->framework = RAC_FRAMEWORK_LLAMACPP; + else if (framework == "onnx" || framework == "onnxruntime") + model->framework = RAC_FRAMEWORK_ONNX; + else if (framework == "foundation_models" || framework == "platform-llm-default") + model->framework = RAC_FRAMEWORK_FOUNDATION_MODELS; + else if (framework == "system_tts" || framework == "platform-tts") + model->framework = RAC_FRAMEWORK_SYSTEM_TTS; + else + model->framework = RAC_FRAMEWORK_UNKNOWN; + + models.push_back(model); + pos = obj_end; + } + + return models; +} + +// Copy models array for output +static rac_result_t copy_models_to_output(const std::vector& models, + rac_model_info_t*** out_models, size_t* out_count) { + if (!out_models || !out_count) + return RAC_ERROR_NULL_POINTER; + + *out_count = models.size(); + if (models.empty()) { + *out_models = nullptr; + return RAC_SUCCESS; + } + + *out_models = + static_cast(malloc(models.size() * sizeof(rac_model_info_t*))); + if (!*out_models) { + *out_count = 0; + return RAC_ERROR_OUT_OF_MEMORY; + } + + for (size_t i = 0; i < models.size(); i++) { + (*out_models)[i] = rac_model_info_copy(models[i]); + if (!(*out_models)[i]) { + // Cleanup on error + for (size_t j = 0; j < i; j++) { + rac_model_info_free((*out_models)[j]); + } + free(*out_models); + *out_models = nullptr; + *out_count = 0; + return RAC_ERROR_OUT_OF_MEMORY; + } + } + + return RAC_SUCCESS; +} + +// ============================================================================= +// PUBLIC API IMPLEMENTATION +// ============================================================================= + +rac_result_t rac_model_assignment_set_callbacks(const rac_assignment_callbacks_t* callbacks) { + RAC_LOG_INFO(LOG_CAT, "rac_model_assignment_set_callbacks called"); + + if (!callbacks) { + RAC_LOG_ERROR(LOG_CAT, "callbacks is NULL"); + return RAC_ERROR_NULL_POINTER; + } + + rac_bool_t should_auto_fetch = RAC_FALSE; + + { + std::lock_guard lock(g_mutex); + g_callbacks = *callbacks; + should_auto_fetch = callbacks->auto_fetch; + + char msg[128]; + snprintf(msg, sizeof(msg), "Model assignment callbacks set (http_get=%p, auto_fetch=%d)", + (void*)callbacks->http_get, callbacks->auto_fetch); + RAC_LOG_INFO(LOG_CAT, msg); + } + + // Auto-fetch if requested (outside lock to avoid deadlock with fetch) + if (should_auto_fetch == RAC_TRUE) { + RAC_LOG_INFO(LOG_CAT, "Auto-fetching model assignments..."); + rac_model_info_t** models = nullptr; + size_t count = 0; + rac_result_t fetch_result = rac_model_assignment_fetch(RAC_FALSE, &models, &count); + + if (fetch_result == RAC_SUCCESS) { + char msg[128]; + snprintf(msg, sizeof(msg), "Auto-fetch completed: %zu models", count); + RAC_LOG_INFO(LOG_CAT, msg); + } else { + char msg[128]; + snprintf(msg, sizeof(msg), "Auto-fetch failed with code: %d", fetch_result); + RAC_LOG_WARNING(LOG_CAT, msg); + } + + // Free the returned models array (data is already cached internally) + if (models) { + rac_model_info_array_free(models, count); + } + } else { + RAC_LOG_INFO(LOG_CAT, "Auto-fetch disabled, models will be fetched on demand"); + } + + return RAC_SUCCESS; +} + +rac_result_t rac_model_assignment_fetch(rac_bool_t force_refresh, rac_model_info_t*** out_models, + size_t* out_count) { + RAC_LOG_INFO(LOG_CAT, ">>> rac_model_assignment_fetch called"); + + std::lock_guard lock(g_mutex); + char msg[256]; + + if (!out_models || !out_count) { + RAC_LOG_ERROR(LOG_CAT, "out_models or out_count is NULL"); + return RAC_ERROR_NULL_POINTER; + } + + snprintf(msg, sizeof(msg), "force_refresh=%d, cache_valid=%d, cached_count=%zu", + force_refresh, is_cache_valid() ? 1 : 0, g_cached_models.size()); + RAC_LOG_INFO(LOG_CAT, msg); + + // Check cache first + if (!force_refresh && is_cache_valid()) { + snprintf(msg, sizeof(msg), "Returning cached model assignments (%zu models)", + g_cached_models.size()); + RAC_LOG_INFO(LOG_CAT, msg); + return copy_models_to_output(g_cached_models, out_models, out_count); + } + + // Need to fetch from backend + if (!g_callbacks.http_get) { + RAC_LOG_ERROR(LOG_CAT, "HTTP callback not set - cannot fetch models"); + return RAC_ERROR_INVALID_STATE; + } + + // Get endpoint path (no query params - backend uses JWT token for filtering) + const char* endpoint = rac_endpoint_model_assignments(); + + snprintf(msg, sizeof(msg), ">>> Making HTTP GET to: %s", endpoint); + RAC_LOG_INFO(LOG_CAT, msg); + + // Make HTTP request + RAC_LOG_INFO(LOG_CAT, ">>> Calling http_get callback..."); + rac_assignment_http_response_t response = {}; + rac_result_t result = + g_callbacks.http_get(endpoint, RAC_TRUE, &response, g_callbacks.user_data); + + snprintf(msg, sizeof(msg), "<<< http_get returned: result=%d, response.result=%d, status=%d, body_len=%zu", + result, response.result, response.status_code, response.response_length); + RAC_LOG_INFO(LOG_CAT, msg); + + if (result != RAC_SUCCESS || response.result != RAC_SUCCESS) { + snprintf(msg, sizeof(msg), "HTTP request failed: result=%d, response.result=%d, error=%s", + result, response.result, + response.error_message ? response.error_message : "unknown error"); + RAC_LOG_ERROR(LOG_CAT, msg); + + // Return cached data as fallback + if (!g_cached_models.empty()) { + RAC_LOG_INFO(LOG_CAT, "Using cached models as fallback"); + return copy_models_to_output(g_cached_models, out_models, out_count); + } + + return result != RAC_SUCCESS ? result : response.result; + } + + if (response.status_code != 200) { + snprintf(msg, sizeof(msg), "HTTP %d: %s", response.status_code, + response.error_message ? response.error_message : "request failed"); + RAC_LOG_ERROR(LOG_CAT, msg); + + // Return cached data as fallback + if (!g_cached_models.empty()) { + RAC_LOG_INFO(LOG_CAT, "Using cached models as fallback"); + return copy_models_to_output(g_cached_models, out_models, out_count); + } + + return RAC_ERROR_HTTP_REQUEST_FAILED; + } + + // Parse response + std::vector models = + parse_models_json(response.response_body, response.response_length); + snprintf(msg, sizeof(msg), "Parsed %zu model assignments", models.size()); + RAC_LOG_INFO(LOG_CAT, msg); + + // Save to registry + rac_model_registry_handle_t registry = rac_get_model_registry(); + if (registry) { + for (auto* model : models) { + rac_model_registry_save(registry, model); + } + RAC_LOG_DEBUG(LOG_CAT, "Saved models to registry"); + } + + // Update cache + clear_cache_internal(); + for (auto* model : models) { + g_cached_models.push_back(rac_model_info_copy(model)); + } + g_last_fetch_time = std::chrono::steady_clock::now(); + g_cache_valid = true; + + // Copy to output (models vector will be freed, so we use cached copies) + result = copy_models_to_output(g_cached_models, out_models, out_count); + + // Cleanup temporary models + for (auto* model : models) { + rac_model_info_free(model); + } + + snprintf(msg, sizeof(msg), "Successfully fetched %zu model assignments", *out_count); + RAC_LOG_INFO(LOG_CAT, msg); + + return result; +} + +rac_result_t rac_model_assignment_get_by_framework(rac_inference_framework_t framework, + rac_model_info_t*** out_models, + size_t* out_count) { + std::lock_guard lock(g_mutex); + + if (!out_models || !out_count) + return RAC_ERROR_NULL_POINTER; + + std::vector filtered; + for (auto* model : g_cached_models) { + if (model->framework == framework) { + filtered.push_back(model); + } + } + + return copy_models_to_output(filtered, out_models, out_count); +} + +rac_result_t rac_model_assignment_get_by_category(rac_model_category_t category, + rac_model_info_t*** out_models, + size_t* out_count) { + std::lock_guard lock(g_mutex); + + if (!out_models || !out_count) + return RAC_ERROR_NULL_POINTER; + + std::vector filtered; + for (auto* model : g_cached_models) { + if (model->category == category) { + filtered.push_back(model); + } + } + + return copy_models_to_output(filtered, out_models, out_count); +} + +void rac_model_assignment_clear_cache(void) { + std::lock_guard lock(g_mutex); + clear_cache_internal(); + RAC_LOG_DEBUG(LOG_CAT, "Model assignment cache cleared"); +} + +void rac_model_assignment_set_cache_timeout(uint32_t timeout_seconds) { + std::lock_guard lock(g_mutex); + g_cache_timeout_seconds = timeout_seconds; + char msg[64]; + snprintf(msg, sizeof(msg), "Cache timeout set to %u seconds", timeout_seconds); + RAC_LOG_DEBUG(LOG_CAT, msg); +} diff --git a/sdk/runanywhere-commons/src/infrastructure/model_management/model_paths.cpp b/sdk/runanywhere-commons/src/infrastructure/model_management/model_paths.cpp new file mode 100644 index 000000000..c04c55be3 --- /dev/null +++ b/sdk/runanywhere-commons/src/infrastructure/model_management/model_paths.cpp @@ -0,0 +1,416 @@ +/** + * @file model_paths.cpp + * @brief Model Path Utilities Implementation + * + * C port of Swift's ModelPathUtils from: + * Sources/RunAnywhere/Infrastructure/ModelManagement/Utilities/ModelPathUtils.swift + * + * IMPORTANT: This is a direct translation of the Swift implementation. + * Do NOT add features not present in the Swift code. + */ + +#include +#include +#include +#include +#include +#include + +#include "rac/core/rac_logger.h" +#include "rac/infrastructure/model_management/rac_model_paths.h" + +// ============================================================================= +// STATIC STATE +// ============================================================================= + +static std::mutex g_paths_mutex{}; +static std::string g_base_dir{}; + +// ============================================================================= +// CONFIGURATION +// ============================================================================= + +rac_result_t rac_model_paths_set_base_dir(const char* base_dir) { + if (!base_dir) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(g_paths_mutex); + g_base_dir = base_dir; + + // Remove trailing slash if present + while (!g_base_dir.empty() && (g_base_dir.back() == '/' || g_base_dir.back() == '\\')) { + g_base_dir.pop_back(); + } + + return RAC_SUCCESS; +} + +const char* rac_model_paths_get_base_dir(void) { + std::lock_guard lock(g_paths_mutex); + return g_base_dir.empty() ? nullptr : g_base_dir.c_str(); +} + +// ============================================================================= +// HELPER FUNCTIONS +// ============================================================================= + +static rac_result_t copy_string_to_buffer(const std::string& src, char* out_path, + size_t path_size) { + if (!out_path || path_size == 0) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + if (src.length() >= path_size) { + return RAC_ERROR_BUFFER_TOO_SMALL; + } + + strncpy(out_path, src.c_str(), path_size - 1); + out_path[path_size - 1] = '\0'; + return RAC_SUCCESS; +} + +static std::vector split_path(const std::string& path) { + std::vector components; + size_t start = 0; + size_t end = 0; + + while ((end = path.find_first_of("/\\", start)) != std::string::npos) { + if (end > start) { + components.push_back(path.substr(start, end - start)); + } + start = end + 1; + } + + if (start < path.length()) { + components.push_back(path.substr(start)); + } + + return components; +} + +// ============================================================================= +// FORMAT AND FRAMEWORK UTILITIES +// ============================================================================= + +// NOTE: rac_model_format_extension is defined in model_types.cpp + +const char* rac_framework_raw_value(rac_inference_framework_t framework) { + // Mirrors Swift's InferenceFramework.rawValue + switch (framework) { + case RAC_FRAMEWORK_ONNX: + return "ONNX"; + case RAC_FRAMEWORK_LLAMACPP: + return "LlamaCpp"; + case RAC_FRAMEWORK_FOUNDATION_MODELS: + return "FoundationModels"; + case RAC_FRAMEWORK_SYSTEM_TTS: + return "SystemTTS"; + case RAC_FRAMEWORK_FLUID_AUDIO: + return "FluidAudio"; + case RAC_FRAMEWORK_BUILTIN: + return "BuiltIn"; + case RAC_FRAMEWORK_NONE: + return "None"; + default: + return "Unknown"; + } +} + +// ============================================================================= +// BASE DIRECTORIES +// ============================================================================= + +rac_result_t rac_model_paths_get_base_directory(char* out_path, size_t path_size) { + // Mirrors Swift's ModelPathUtils.getBaseDirectory() + // Returns: {base_dir}/RunAnywhere/ + + std::lock_guard lock(g_paths_mutex); + + if (g_base_dir.empty()) { + return RAC_ERROR_NOT_INITIALIZED; + } + + std::string path = g_base_dir + "/RunAnywhere"; + return copy_string_to_buffer(path, out_path, path_size); +} + +rac_result_t rac_model_paths_get_models_directory(char* out_path, size_t path_size) { + // Mirrors Swift's ModelPathUtils.getModelsDirectory() + // Returns: {base_dir}/RunAnywhere/Models/ + + std::lock_guard lock(g_paths_mutex); + + if (g_base_dir.empty()) { + return RAC_ERROR_NOT_INITIALIZED; + } + + std::string path = g_base_dir + "/RunAnywhere/Models"; + return copy_string_to_buffer(path, out_path, path_size); +} + +// ============================================================================= +// FRAMEWORK-SPECIFIC PATHS +// ============================================================================= + +rac_result_t rac_model_paths_get_framework_directory(rac_inference_framework_t framework, + char* out_path, size_t path_size) { + // Mirrors Swift's ModelPathUtils.getFrameworkDirectory(framework:) + // Returns: {base_dir}/RunAnywhere/Models/{framework.rawValue}/ + + std::lock_guard lock(g_paths_mutex); + + if (g_base_dir.empty()) { + return RAC_ERROR_NOT_INITIALIZED; + } + + std::string path = g_base_dir + "/RunAnywhere/Models/" + rac_framework_raw_value(framework); + return copy_string_to_buffer(path, out_path, path_size); +} + +rac_result_t rac_model_paths_get_model_folder(const char* model_id, + rac_inference_framework_t framework, char* out_path, + size_t path_size) { + // Mirrors Swift's ModelPathUtils.getModelFolder(modelId:framework:) + // Returns: {base_dir}/RunAnywhere/Models/{framework.rawValue}/{modelId}/ + + if (!model_id) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(g_paths_mutex); + + if (g_base_dir.empty()) { + return RAC_ERROR_NOT_INITIALIZED; + } + + std::string path = + g_base_dir + "/RunAnywhere/Models/" + rac_framework_raw_value(framework) + "/" + model_id; + return copy_string_to_buffer(path, out_path, path_size); +} + +// ============================================================================= +// MODEL FILE PATHS +// ============================================================================= + +rac_result_t rac_model_paths_get_model_file_path(const char* model_id, + rac_inference_framework_t framework, + rac_model_format_t format, char* out_path, + size_t path_size) { + // Mirrors Swift's ModelPathUtils.getModelFilePath(modelId:framework:format:) + // Returns: + // {base_dir}/RunAnywhere/Models/{framework.rawValue}/{modelId}/{modelId}.{format.rawValue} + + if (!model_id) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(g_paths_mutex); + + if (g_base_dir.empty()) { + return RAC_ERROR_NOT_INITIALIZED; + } + + std::string path = g_base_dir + "/RunAnywhere/Models/" + rac_framework_raw_value(framework) + + "/" + model_id + "/" + model_id + "." + rac_model_format_extension(format); + return copy_string_to_buffer(path, out_path, path_size); +} + +rac_result_t rac_model_paths_get_expected_model_path(const char* model_id, + rac_inference_framework_t framework, + rac_model_format_t format, char* out_path, + size_t path_size) { + // Mirrors Swift's ModelPathUtils.getExpectedModelPath(modelId:framework:format:) + // For directory-based frameworks, returns the model folder + // For single-file frameworks, returns the model file path + + if (!model_id) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + // Check if framework uses directory-based models + // (mirrors Swift's InferenceFramework.usesDirectoryBasedModels) + if (rac_framework_uses_directory_based_models(framework) == RAC_TRUE) { + return rac_model_paths_get_model_folder(model_id, framework, out_path, path_size); + } + + return rac_model_paths_get_model_file_path(model_id, framework, format, out_path, path_size); +} + +rac_result_t rac_model_paths_get_model_path(const rac_model_info_t* model_info, char* out_path, + size_t path_size) { + // Mirrors Swift's ModelPathUtils.getModelPath(modelInfo:) + + if (!model_info || !model_info->id) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + return rac_model_paths_get_model_file_path(model_info->id, model_info->framework, + model_info->format, out_path, path_size); +} + +// ============================================================================= +// OTHER DIRECTORIES +// ============================================================================= + +rac_result_t rac_model_paths_get_cache_directory(char* out_path, size_t path_size) { + // Mirrors Swift's ModelPathUtils.getCacheDirectory() + // Returns: {base_dir}/RunAnywhere/Cache/ + + std::lock_guard lock(g_paths_mutex); + + if (g_base_dir.empty()) { + return RAC_ERROR_NOT_INITIALIZED; + } + + std::string path = g_base_dir + "/RunAnywhere/Cache"; + return copy_string_to_buffer(path, out_path, path_size); +} + +rac_result_t rac_model_paths_get_temp_directory(char* out_path, size_t path_size) { + // Mirrors Swift's ModelPathUtils.getTempDirectory() + // Returns: {base_dir}/RunAnywhere/Temp/ + + std::lock_guard lock(g_paths_mutex); + + if (g_base_dir.empty()) { + return RAC_ERROR_NOT_INITIALIZED; + } + + std::string path = g_base_dir + "/RunAnywhere/Temp"; + return copy_string_to_buffer(path, out_path, path_size); +} + +rac_result_t rac_model_paths_get_downloads_directory(char* out_path, size_t path_size) { + // Mirrors Swift's ModelPathUtils.getDownloadsDirectory() + // Returns: {base_dir}/RunAnywhere/Downloads/ + + std::lock_guard lock(g_paths_mutex); + + if (g_base_dir.empty()) { + return RAC_ERROR_NOT_INITIALIZED; + } + + std::string path = g_base_dir + "/RunAnywhere/Downloads"; + return copy_string_to_buffer(path, out_path, path_size); +} + +// ============================================================================= +// PATH ANALYSIS +// ============================================================================= + +rac_result_t rac_model_paths_extract_model_id(const char* path, char* out_model_id, + size_t model_id_size) { + // Mirrors Swift's ModelPathUtils.extractModelId(from:) + + if (!path) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::vector components = split_path(path); + + // Find "Models" component + auto it = std::find(components.begin(), components.end(), "Models"); + if (it == components.end()) { + return RAC_ERROR_NOT_FOUND; + } + + auto modelsIndex = static_cast(std::distance(components.begin(), it)); + + // Check if there's a component after "Models" + if (modelsIndex + 1 >= components.size()) { + return RAC_ERROR_NOT_FOUND; + } + + std::string nextComponent = components[modelsIndex + 1]; + + // Check if next component is a framework name + bool isFramework = false; + const char* frameworks[] = {"ONNX", "LlamaCpp", "FoundationModels", + "SystemTTS", "FluidAudio", "BuiltIn", + "None", "Unknown"}; + for (const char* fw : frameworks) { + if (nextComponent == fw) { + isFramework = true; + break; + } + } + + std::string modelId; + if (isFramework && modelsIndex + 2 < components.size()) { + // Framework structure: Models/framework/modelId + modelId = components[modelsIndex + 2]; + } else { + // Direct model folder structure: Models/modelId + modelId = nextComponent; + } + + if (out_model_id && model_id_size > 0) { + return copy_string_to_buffer(modelId, out_model_id, model_id_size); + } + + return RAC_SUCCESS; +} + +rac_result_t rac_model_paths_extract_framework(const char* path, + rac_inference_framework_t* out_framework) { + // Mirrors Swift's ModelPathUtils.extractFramework(from:) + + if (!path || !out_framework) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::vector components = split_path(path); + + // Find "Models" component + auto it = std::find(components.begin(), components.end(), "Models"); + if (it == components.end()) { + return RAC_ERROR_NOT_FOUND; + } + + auto modelsIndex = static_cast(std::distance(components.begin(), it)); + + // Check if there's a component after "Models" + if (modelsIndex + 1 >= components.size()) { + return RAC_ERROR_NOT_FOUND; + } + + std::string nextComponent = components[modelsIndex + 1]; + + // Map to framework enum + if (nextComponent == "ONNX") { + *out_framework = RAC_FRAMEWORK_ONNX; + return RAC_SUCCESS; + } else if (nextComponent == "LlamaCpp") { + *out_framework = RAC_FRAMEWORK_LLAMACPP; + return RAC_SUCCESS; + } else if (nextComponent == "FoundationModels") { + *out_framework = RAC_FRAMEWORK_FOUNDATION_MODELS; + return RAC_SUCCESS; + } else if (nextComponent == "SystemTTS") { + *out_framework = RAC_FRAMEWORK_SYSTEM_TTS; + return RAC_SUCCESS; + } else if (nextComponent == "FluidAudio") { + *out_framework = RAC_FRAMEWORK_FLUID_AUDIO; + return RAC_SUCCESS; + } else if (nextComponent == "BuiltIn") { + *out_framework = RAC_FRAMEWORK_BUILTIN; + return RAC_SUCCESS; + } else if (nextComponent == "None") { + *out_framework = RAC_FRAMEWORK_NONE; + return RAC_SUCCESS; + } + + return RAC_ERROR_NOT_FOUND; +} + +rac_bool_t rac_model_paths_is_model_path(const char* path) { + // Mirrors Swift's ModelPathUtils.isModelPath(_:) + + if (!path) { + return RAC_FALSE; + } + + // Simply check if "Models" appears in the path + return (strstr(path, "Models") != nullptr) ? RAC_TRUE : RAC_FALSE; +} diff --git a/sdk/runanywhere-commons/src/infrastructure/model_management/model_registry.cpp b/sdk/runanywhere-commons/src/infrastructure/model_management/model_registry.cpp new file mode 100644 index 000000000..09f013f62 --- /dev/null +++ b/sdk/runanywhere-commons/src/infrastructure/model_management/model_registry.cpp @@ -0,0 +1,681 @@ +/** + * @file model_registry.cpp + * @brief RunAnywhere Commons - Model Registry Implementation + * + * C++ port of Swift's ModelInfoService. + * Swift Source: Sources/RunAnywhere/Infrastructure/ModelManagement/Services/ModelInfoService.swift + * + * CRITICAL: This is a direct port of Swift implementation - do NOT add custom logic! + * + * This is an in-memory model metadata store. + */ + +#include +#include +#include +#include +#include +#include +#include + +#include "rac/core/rac_logger.h" +#include "rac/core/rac_platform_adapter.h" +#include "rac/core/rac_structured_error.h" +#include "rac/infrastructure/model_management/rac_model_paths.h" +#include "rac/infrastructure/model_management/rac_model_registry.h" + +// ============================================================================= +// INTERNAL STRUCTURES +// ============================================================================= + +struct rac_model_registry { + // Model storage (model_id -> model_info) + std::map models; + + // Thread safety + std::mutex mutex; +}; + +// Note: rac_strdup is declared in rac_types.h and implemented in rac_memory.cpp + +static rac_model_info_t* deep_copy_model(const rac_model_info_t* src) { + if (!src) + return nullptr; + + rac_model_info_t* copy = static_cast(calloc(1, sizeof(rac_model_info_t))); + if (!copy) + return nullptr; + + copy->id = rac_strdup(src->id); + copy->name = rac_strdup(src->name); + copy->category = src->category; + copy->format = src->format; + copy->framework = src->framework; + copy->download_url = rac_strdup(src->download_url); + copy->local_path = rac_strdup(src->local_path); + // Copy artifact info struct (shallow copy for basic fields, deep copy for pointers) + copy->artifact_info.kind = src->artifact_info.kind; + copy->artifact_info.archive_type = src->artifact_info.archive_type; + copy->artifact_info.archive_structure = src->artifact_info.archive_structure; + copy->artifact_info.expected_files = nullptr; // Complex structure, leave null for now + copy->artifact_info.file_descriptors = nullptr; + copy->artifact_info.file_descriptor_count = 0; + copy->artifact_info.strategy_id = rac_strdup(src->artifact_info.strategy_id); + copy->download_size = src->download_size; + copy->memory_required = src->memory_required; + copy->context_length = src->context_length; + copy->supports_thinking = src->supports_thinking; + + // Copy tags + if (src->tags && src->tag_count > 0) { + copy->tags = static_cast(malloc(sizeof(char*) * src->tag_count)); + if (copy->tags) { + for (size_t i = 0; i < src->tag_count; ++i) { + copy->tags[i] = rac_strdup(src->tags[i]); + } + copy->tag_count = src->tag_count; + } + } + + copy->description = rac_strdup(src->description); + copy->source = src->source; + copy->created_at = src->created_at; + copy->updated_at = src->updated_at; + copy->last_used = src->last_used; + copy->usage_count = src->usage_count; + + return copy; +} + +static void free_model_info(rac_model_info_t* model) { + if (!model) + return; + + if (model->id) + free(model->id); + if (model->name) + free(model->name); + if (model->download_url) + free(model->download_url); + if (model->local_path) + free(model->local_path); + if (model->description) + free(model->description); + + // Free artifact info strings + if (model->artifact_info.strategy_id) { + free(const_cast(model->artifact_info.strategy_id)); + } + + if (model->tags) { + for (size_t i = 0; i < model->tag_count; ++i) { + if (model->tags[i]) + free(model->tags[i]); + } + free(model->tags); + } + + free(model); +} + +// ============================================================================= +// PUBLIC API - LIFECYCLE +// ============================================================================= + +rac_result_t rac_model_registry_create(rac_model_registry_handle_t* out_handle) { + if (!out_handle) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + rac_model_registry* registry = new rac_model_registry(); + + RAC_LOG_INFO("ModelRegistry", "Model registry created"); + + *out_handle = registry; + return RAC_SUCCESS; +} + +void rac_model_registry_destroy(rac_model_registry_handle_t handle) { + if (!handle) { + return; + } + + // Free all stored models + for (auto& pair : handle->models) { + free_model_info(pair.second); + } + handle->models.clear(); + + delete handle; + RAC_LOG_DEBUG("ModelRegistry", "Model registry destroyed"); +} + +// ============================================================================= +// PUBLIC API - MODEL INFO +// ============================================================================= + +rac_result_t rac_model_registry_save(rac_model_registry_handle_t handle, + const rac_model_info_t* model) { + if (!handle || !model || !model->id) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + + std::string model_id = model->id; + + // If model already exists, free the old one + auto it = handle->models.find(model_id); + if (it != handle->models.end()) { + free_model_info(it->second); + } + + // Store a deep copy + rac_model_info_t* copy = deep_copy_model(model); + if (!copy) { + return RAC_ERROR_OUT_OF_MEMORY; + } + + handle->models[model_id] = copy; + + RAC_LOG_DEBUG("ModelRegistry", "Model saved"); + + return RAC_SUCCESS; +} + +rac_result_t rac_model_registry_get(rac_model_registry_handle_t handle, const char* model_id, + rac_model_info_t** out_model) { + if (!handle || !model_id || !out_model) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + + auto it = handle->models.find(model_id); + if (it == handle->models.end()) { + return RAC_ERROR_NOT_FOUND; + } + + *out_model = deep_copy_model(it->second); + if (!*out_model) { + return RAC_ERROR_OUT_OF_MEMORY; + } + + return RAC_SUCCESS; +} + +rac_result_t rac_model_registry_get_all(rac_model_registry_handle_t handle, + rac_model_info_t*** out_models, size_t* out_count) { + if (!handle || !out_models || !out_count) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + + *out_count = handle->models.size(); + if (*out_count == 0) { + *out_models = nullptr; + return RAC_SUCCESS; + } + + *out_models = static_cast(malloc(sizeof(rac_model_info_t*) * *out_count)); + if (!*out_models) { + return RAC_ERROR_OUT_OF_MEMORY; + } + + size_t i = 0; + for (const auto& pair : handle->models) { + (*out_models)[i] = deep_copy_model(pair.second); + if (!(*out_models)[i]) { + // Cleanup on error + for (size_t j = 0; j < i; ++j) { + free_model_info((*out_models)[j]); + } + free(*out_models); + *out_models = nullptr; + *out_count = 0; + return RAC_ERROR_OUT_OF_MEMORY; + } + ++i; + } + + return RAC_SUCCESS; +} + +rac_result_t rac_model_registry_get_by_frameworks(rac_model_registry_handle_t handle, + const rac_inference_framework_t* frameworks, + size_t framework_count, + rac_model_info_t*** out_models, + size_t* out_count) { + if (!handle || !frameworks || framework_count == 0 || !out_models || !out_count) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + + // Collect matching models + std::vector matches; + + for (const auto& pair : handle->models) { + for (size_t i = 0; i < framework_count; ++i) { + if (pair.second->framework == frameworks[i]) { + matches.push_back(pair.second); + break; + } + } + } + + *out_count = matches.size(); + if (*out_count == 0) { + *out_models = nullptr; + return RAC_SUCCESS; + } + + *out_models = static_cast(malloc(sizeof(rac_model_info_t*) * *out_count)); + if (!*out_models) { + return RAC_ERROR_OUT_OF_MEMORY; + } + + for (size_t i = 0; i < matches.size(); ++i) { + (*out_models)[i] = deep_copy_model(matches[i]); + if (!(*out_models)[i]) { + // Cleanup on error + for (size_t j = 0; j < i; ++j) { + free_model_info((*out_models)[j]); + } + free(*out_models); + *out_models = nullptr; + *out_count = 0; + return RAC_ERROR_OUT_OF_MEMORY; + } + } + + return RAC_SUCCESS; +} + +rac_result_t rac_model_registry_update_last_used(rac_model_registry_handle_t handle, + const char* model_id) { + if (!handle || !model_id) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + + auto it = handle->models.find(model_id); + if (it == handle->models.end()) { + return RAC_ERROR_NOT_FOUND; + } + + rac_model_info_t* model = it->second; + model->last_used = rac_get_current_time_ms() / 1000; // Convert to seconds + model->usage_count++; + + return RAC_SUCCESS; +} + +rac_result_t rac_model_registry_remove(rac_model_registry_handle_t handle, const char* model_id) { + if (!handle || !model_id) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + + auto it = handle->models.find(model_id); + if (it == handle->models.end()) { + return RAC_ERROR_NOT_FOUND; + } + + free_model_info(it->second); + handle->models.erase(it); + + RAC_LOG_DEBUG("ModelRegistry", "Model removed"); + + return RAC_SUCCESS; +} + +rac_result_t rac_model_registry_get_downloaded(rac_model_registry_handle_t handle, + rac_model_info_t*** out_models, size_t* out_count) { + if (!handle || !out_models || !out_count) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + + // Collect downloaded models + std::vector downloaded; + + for (const auto& pair : handle->models) { + if (pair.second->local_path && strlen(pair.second->local_path) > 0) { + downloaded.push_back(pair.second); + } + } + + *out_count = downloaded.size(); + if (*out_count == 0) { + *out_models = nullptr; + return RAC_SUCCESS; + } + + *out_models = static_cast(malloc(sizeof(rac_model_info_t*) * *out_count)); + if (!*out_models) { + return RAC_ERROR_OUT_OF_MEMORY; + } + + for (size_t i = 0; i < downloaded.size(); ++i) { + (*out_models)[i] = deep_copy_model(downloaded[i]); + if (!(*out_models)[i]) { + // Cleanup on error + for (size_t j = 0; j < i; ++j) { + free_model_info((*out_models)[j]); + } + free(*out_models); + *out_models = nullptr; + *out_count = 0; + return RAC_ERROR_OUT_OF_MEMORY; + } + } + + return RAC_SUCCESS; +} + +rac_result_t rac_model_registry_update_download_status(rac_model_registry_handle_t handle, + const char* model_id, + const char* local_path) { + if (!handle || !model_id) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + std::lock_guard lock(handle->mutex); + + auto it = handle->models.find(model_id); + if (it == handle->models.end()) { + return RAC_ERROR_NOT_FOUND; + } + + rac_model_info_t* model = it->second; + + // Free old local path + if (model->local_path) { + free(model->local_path); + } + + // Set new local path + model->local_path = rac_strdup(local_path); + model->updated_at = rac_get_current_time_ms() / 1000; + + return RAC_SUCCESS; +} + +// ============================================================================= +// PUBLIC API - QUERY HELPERS +// ============================================================================= + +// NOTE: rac_model_info_is_downloaded, rac_model_category_requires_context_length, +// and rac_model_category_supports_thinking are defined in model_types.cpp + +rac_artifact_type_kind_t rac_model_infer_artifact_type(const char* url, rac_model_format_t format) { + // Infer from URL extension + if (url) { + size_t len = strlen(url); + + if (len > 4 && strcmp(url + len - 4, ".zip") == 0) { + return RAC_ARTIFACT_KIND_ARCHIVE; + } + if (len > 4 && strcmp(url + len - 4, ".tar") == 0) { + return RAC_ARTIFACT_KIND_ARCHIVE; + } + if (len > 7 && strcmp(url + len - 7, ".tar.gz") == 0) { + return RAC_ARTIFACT_KIND_ARCHIVE; + } + if (len > 4 && strcmp(url + len - 4, ".tgz") == 0) { + return RAC_ARTIFACT_KIND_ARCHIVE; + } + } + + // Default to single file for most formats + switch (format) { + case RAC_MODEL_FORMAT_GGUF: + case RAC_MODEL_FORMAT_ONNX: + case RAC_MODEL_FORMAT_BIN: + return RAC_ARTIFACT_KIND_SINGLE_FILE; + default: + return RAC_ARTIFACT_KIND_SINGLE_FILE; + } +} + +// ============================================================================= +// PUBLIC API - MODEL DISCOVERY +// ============================================================================= + +// Helper to check if a folder contains valid model files for a framework +static bool is_valid_model_folder(const rac_discovery_callbacks_t* callbacks, + const char* folder_path, rac_inference_framework_t framework) { + if (!callbacks || !callbacks->list_directory || !folder_path) { + return false; + } + + char** entries = nullptr; + size_t count = 0; + + // List directory contents + if (callbacks->list_directory(folder_path, &entries, &count, callbacks->user_data) != + RAC_SUCCESS) { + return false; + } + + bool found_model_file = false; + + for (size_t i = 0; i < count && !found_model_file; i++) { + if (!entries[i]) + continue; + + // Build full path + std::string full_path = std::string(folder_path) + "/" + entries[i]; + + // Check if it's a model file for this framework + if (callbacks->is_model_file) { + if (callbacks->is_model_file(full_path.c_str(), framework, callbacks->user_data) == + RAC_TRUE) { + found_model_file = true; + } + } + + // For nested directories, recursively check (one level deep) + if (!found_model_file && callbacks->is_directory) { + if (callbacks->is_directory(full_path.c_str(), callbacks->user_data) == RAC_TRUE) { + // Check subdirectory for model files + char** sub_entries = nullptr; + size_t sub_count = 0; + if (callbacks->list_directory(full_path.c_str(), &sub_entries, &sub_count, + callbacks->user_data) == RAC_SUCCESS) { + for (size_t j = 0; j < sub_count && !found_model_file; j++) { + if (!sub_entries[j]) + continue; + std::string sub_path = full_path + "/" + sub_entries[j]; + if (callbacks->is_model_file && + callbacks->is_model_file(sub_path.c_str(), framework, + callbacks->user_data) == RAC_TRUE) { + found_model_file = true; + } + } + if (callbacks->free_entries) { + callbacks->free_entries(sub_entries, sub_count, callbacks->user_data); + } + } + } + } + } + + if (callbacks->free_entries) { + callbacks->free_entries(entries, count, callbacks->user_data); + } + + return found_model_file; +} + +rac_result_t rac_model_registry_discover_downloaded(rac_model_registry_handle_t handle, + const rac_discovery_callbacks_t* callbacks, + rac_discovery_result_t* out_result) { + if (!handle || !callbacks || !out_result) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + // Initialize result + out_result->discovered_count = 0; + out_result->discovered_models = nullptr; + out_result->unregistered_count = 0; + + // Check required callbacks + if (!callbacks->list_directory || !callbacks->path_exists || !callbacks->is_directory) { + RAC_LOG_WARNING("ModelRegistry", "Discovery: Missing required callbacks"); + return RAC_ERROR_INVALID_ARGUMENT; + } + + RAC_LOG_INFO("ModelRegistry", "Starting model discovery scan..."); + + // Get models directory path + char models_dir[1024]; + if (rac_model_paths_get_models_directory(models_dir, sizeof(models_dir)) != RAC_SUCCESS) { + RAC_LOG_WARNING("ModelRegistry", "Discovery: Base directory not configured"); + return RAC_SUCCESS; // Not an error, just nothing to discover + } + + // Check if models directory exists + if (callbacks->path_exists(models_dir, callbacks->user_data) != RAC_TRUE) { + RAC_LOG_DEBUG("ModelRegistry", "Discovery: Models directory does not exist yet"); + return RAC_SUCCESS; + } + + // Frameworks to scan + rac_inference_framework_t frameworks[] = {RAC_FRAMEWORK_LLAMACPP, RAC_FRAMEWORK_ONNX, + RAC_FRAMEWORK_FOUNDATION_MODELS, + RAC_FRAMEWORK_SYSTEM_TTS}; + size_t framework_count = sizeof(frameworks) / sizeof(frameworks[0]); + + // Collect discovered models + std::vector discovered; + size_t unregistered = 0; + + std::lock_guard lock(handle->mutex); + + for (size_t f = 0; f < framework_count; f++) { + rac_inference_framework_t framework = frameworks[f]; + + // Get framework directory path + char framework_dir[1024]; + if (rac_model_paths_get_framework_directory(framework, framework_dir, + sizeof(framework_dir)) != RAC_SUCCESS) { + continue; + } + + // Check if framework directory exists + if (callbacks->path_exists(framework_dir, callbacks->user_data) != RAC_TRUE) { + continue; + } + + // List model folders in this framework directory + char** model_folders = nullptr; + size_t folder_count = 0; + + if (callbacks->list_directory(framework_dir, &model_folders, &folder_count, + callbacks->user_data) != RAC_SUCCESS) { + continue; + } + + for (size_t i = 0; i < folder_count; i++) { + if (!model_folders[i]) + continue; + + // Skip hidden files + if (model_folders[i][0] == '.') + continue; + + const char* model_id = model_folders[i]; + + // Build full path to model folder + std::string model_path = std::string(framework_dir) + "/" + model_id; + + // Check if it's a directory + if (callbacks->is_directory(model_path.c_str(), callbacks->user_data) != RAC_TRUE) { + continue; + } + + // Check if it contains valid model files + if (!is_valid_model_folder(callbacks, model_path.c_str(), framework)) { + continue; + } + + // Check if this model is registered + auto it = handle->models.find(model_id); + if (it != handle->models.end()) { + // Model is registered - check if it needs update + rac_model_info_t* model = it->second; + + if (!model->local_path || strlen(model->local_path) == 0) { + // Update the local path + if (model->local_path) { + free(model->local_path); + } + model->local_path = rac_strdup(model_path.c_str()); + model->updated_at = rac_get_current_time_ms() / 1000; + + // Add to discovered list + rac_discovered_model_t disc; + disc.model_id = rac_strdup(model_id); + disc.local_path = rac_strdup(model_path.c_str()); + disc.framework = framework; + discovered.push_back(disc); + + RAC_LOG_INFO("ModelRegistry", "Discovered downloaded model"); + } + } else { + // Model folder exists but not registered + unregistered++; + RAC_LOG_DEBUG("ModelRegistry", "Found unregistered model folder"); + } + } + + if (callbacks->free_entries) { + callbacks->free_entries(model_folders, folder_count, callbacks->user_data); + } + } + + // Build result + out_result->discovered_count = discovered.size(); + out_result->unregistered_count = unregistered; + + if (!discovered.empty()) { + out_result->discovered_models = static_cast( + malloc(sizeof(rac_discovered_model_t) * discovered.size())); + if (out_result->discovered_models) { + for (size_t i = 0; i < discovered.size(); i++) { + out_result->discovered_models[i] = discovered[i]; + } + } + } + + RAC_LOG_INFO("ModelRegistry", "Model discovery complete"); + + return RAC_SUCCESS; +} + +void rac_discovery_result_free(rac_discovery_result_t* result) { + if (!result) + return; + + if (result->discovered_models) { + for (size_t i = 0; i < result->discovered_count; i++) { + if (result->discovered_models[i].model_id) { + free(const_cast(result->discovered_models[i].model_id)); + } + if (result->discovered_models[i].local_path) { + free(const_cast(result->discovered_models[i].local_path)); + } + } + free(result->discovered_models); + } + + result->discovered_models = nullptr; + result->discovered_count = 0; + result->unregistered_count = 0; +} diff --git a/sdk/runanywhere-commons/src/infrastructure/model_management/model_strategy.cpp b/sdk/runanywhere-commons/src/infrastructure/model_management/model_strategy.cpp new file mode 100644 index 000000000..01c633699 --- /dev/null +++ b/sdk/runanywhere-commons/src/infrastructure/model_management/model_strategy.cpp @@ -0,0 +1,248 @@ +/** + * @file model_strategy.cpp + * @brief Model Storage and Download Strategy Implementation + * + * Registry for backend-specific model handling strategies. + * Strategies are registered per-framework during backend initialization. + */ + +#include +#include +#include +#include + +#include "rac/core/rac_logger.h" +#include "rac/infrastructure/model_management/rac_model_strategy.h" + +namespace { + +const char* LOG_CAT = "ModelStrategy"; + +// Strategy registry - maps framework to strategies +struct StrategyRegistry { + std::unordered_map storage_strategies; + std::unordered_map download_strategies; + std::mutex mutex; +}; + +StrategyRegistry& get_registry() { + static StrategyRegistry registry; + return registry; +} + +} // namespace + +// ============================================================================= +// RESOURCE CLEANUP +// ============================================================================= + +void rac_model_storage_details_free(rac_model_storage_details_t* details) { + if (details && details->primary_file) { + free(details->primary_file); + details->primary_file = nullptr; + } +} + +void rac_download_result_free(rac_download_result_t* result) { + if (result && result->final_path) { + free(result->final_path); + result->final_path = nullptr; + } +} + +// ============================================================================= +// STRATEGY REGISTRATION +// ============================================================================= + +rac_result_t rac_storage_strategy_register(rac_inference_framework_t framework, + const rac_storage_strategy_t* strategy) { + if (!strategy) { + RAC_LOG_ERROR(LOG_CAT, "Cannot register null storage strategy"); + return RAC_ERROR_INVALID_PARAMETER; + } + + auto& registry = get_registry(); + std::lock_guard lock(registry.mutex); + + int key = static_cast(framework); + registry.storage_strategies[key] = *strategy; + + RAC_LOG_INFO(LOG_CAT, "Registered storage strategy '%s' for framework %d", + strategy->name ? strategy->name : "unnamed", key); + + return RAC_SUCCESS; +} + +rac_result_t rac_download_strategy_register(rac_inference_framework_t framework, + const rac_download_strategy_t* strategy) { + if (!strategy) { + RAC_LOG_ERROR(LOG_CAT, "Cannot register null download strategy"); + return RAC_ERROR_INVALID_PARAMETER; + } + + auto& registry = get_registry(); + std::lock_guard lock(registry.mutex); + + int key = static_cast(framework); + registry.download_strategies[key] = *strategy; + + RAC_LOG_INFO(LOG_CAT, "Registered download strategy '%s' for framework %d", + strategy->name ? strategy->name : "unnamed", key); + + return RAC_SUCCESS; +} + +void rac_model_strategy_unregister(rac_inference_framework_t framework) { + auto& registry = get_registry(); + std::lock_guard lock(registry.mutex); + + int key = static_cast(framework); + registry.storage_strategies.erase(key); + registry.download_strategies.erase(key); + + RAC_LOG_INFO(LOG_CAT, "Unregistered strategies for framework %d", key); +} + +// ============================================================================= +// STRATEGY LOOKUP +// ============================================================================= + +const rac_storage_strategy_t* rac_storage_strategy_get(rac_inference_framework_t framework) { + auto& registry = get_registry(); + std::lock_guard lock(registry.mutex); + + int key = static_cast(framework); + auto it = registry.storage_strategies.find(key); + + if (it != registry.storage_strategies.end()) { + return &it->second; + } + + return nullptr; +} + +const rac_download_strategy_t* rac_download_strategy_get(rac_inference_framework_t framework) { + auto& registry = get_registry(); + std::lock_guard lock(registry.mutex); + + int key = static_cast(framework); + auto it = registry.download_strategies.find(key); + + if (it != registry.download_strategies.end()) { + return &it->second; + } + + return nullptr; +} + +// ============================================================================= +// CONVENIENCE API - High-level operations +// ============================================================================= + +rac_result_t rac_model_strategy_find_path(rac_inference_framework_t framework, const char* model_id, + const char* model_folder, char* out_path, + size_t path_size) { + if (!model_id || !model_folder || !out_path || path_size == 0) { + return RAC_ERROR_INVALID_PARAMETER; + } + + const rac_storage_strategy_t* strategy = rac_storage_strategy_get(framework); + if (!strategy || !strategy->find_model_path) { + RAC_LOG_DEBUG(LOG_CAT, "No storage strategy for framework %d", framework); + return RAC_ERROR_NOT_FOUND; + } + + return strategy->find_model_path(model_id, model_folder, out_path, path_size, + strategy->user_data); +} + +rac_result_t rac_model_strategy_detect(rac_inference_framework_t framework, + const char* model_folder, + rac_model_storage_details_t* out_details) { + if (!model_folder || !out_details) { + return RAC_ERROR_INVALID_PARAMETER; + } + + const rac_storage_strategy_t* strategy = rac_storage_strategy_get(framework); + if (!strategy || !strategy->detect_model) { + RAC_LOG_DEBUG(LOG_CAT, "No storage strategy for framework %d", framework); + return RAC_ERROR_NOT_FOUND; + } + + return strategy->detect_model(model_folder, out_details, strategy->user_data); +} + +rac_bool_t rac_model_strategy_is_valid(rac_inference_framework_t framework, + const char* model_folder) { + if (!model_folder) { + return RAC_FALSE; + } + + const rac_storage_strategy_t* strategy = rac_storage_strategy_get(framework); + if (!strategy || !strategy->is_valid_storage) { + return RAC_FALSE; + } + + return strategy->is_valid_storage(model_folder, strategy->user_data); +} + +rac_result_t rac_model_strategy_prepare_download(rac_inference_framework_t framework, + const rac_model_download_config_t* config) { + if (!config) { + return RAC_ERROR_INVALID_PARAMETER; + } + + const rac_download_strategy_t* strategy = rac_download_strategy_get(framework); + if (!strategy || !strategy->prepare_download) { + // No custom strategy - use default behavior + RAC_LOG_DEBUG(LOG_CAT, "No download strategy for framework %d, using defaults", framework); + return RAC_SUCCESS; + } + + return strategy->prepare_download(config, strategy->user_data); +} + +rac_result_t rac_model_strategy_get_download_dest(rac_inference_framework_t framework, + const rac_model_download_config_t* config, + char* out_path, size_t path_size) { + if (!config || !out_path || path_size == 0) { + return RAC_ERROR_INVALID_PARAMETER; + } + + const rac_download_strategy_t* strategy = rac_download_strategy_get(framework); + if (!strategy || !strategy->get_destination_path) { + // No custom strategy - use default path from config + if (config->destination_folder) { + size_t len = strlen(config->destination_folder); + if (len >= path_size) { + return RAC_ERROR_BUFFER_TOO_SMALL; + } + strcpy(out_path, config->destination_folder); + return RAC_SUCCESS; + } + return RAC_ERROR_INVALID_PARAMETER; + } + + return strategy->get_destination_path(config, out_path, path_size, strategy->user_data); +} + +rac_result_t rac_model_strategy_post_process(rac_inference_framework_t framework, + const rac_model_download_config_t* config, + const char* downloaded_path, + rac_download_result_t* out_result) { + if (!config || !downloaded_path || !out_result) { + return RAC_ERROR_INVALID_PARAMETER; + } + + const rac_download_strategy_t* strategy = rac_download_strategy_get(framework); + if (!strategy || !strategy->post_process) { + // No custom strategy - set basic result + out_result->final_path = strdup(downloaded_path); + out_result->downloaded_size = 0; // Unknown + out_result->was_extracted = RAC_FALSE; + out_result->file_count = 1; + return RAC_SUCCESS; + } + + return strategy->post_process(config, downloaded_path, out_result, strategy->user_data); +} diff --git a/sdk/runanywhere-commons/src/infrastructure/model_management/model_types.cpp b/sdk/runanywhere-commons/src/infrastructure/model_management/model_types.cpp new file mode 100644 index 000000000..10e77db1f --- /dev/null +++ b/sdk/runanywhere-commons/src/infrastructure/model_management/model_types.cpp @@ -0,0 +1,697 @@ +/** + * @file model_types.cpp + * @brief Model Types Implementation + * + * C port of Swift's model type helper functions. + * Swift Source: ModelCategory.swift, ModelFormat.swift, ModelArtifactType.swift, + * InferenceFramework.swift + * + * IMPORTANT: This is a direct translation of the Swift implementation. + * Do NOT add features not present in the Swift code. + */ + +#include +#include +#include +#include +#include + +#include "rac/core/rac_logger.h" +#include "rac/infrastructure/model_management/rac_model_types.h" + +// ============================================================================= +// ARCHIVE TYPE FUNCTIONS +// ============================================================================= + +const char* rac_archive_type_extension(rac_archive_type_t type) { + switch (type) { + case RAC_ARCHIVE_TYPE_ZIP: + return "zip"; + case RAC_ARCHIVE_TYPE_TAR_BZ2: + return "tar.bz2"; + case RAC_ARCHIVE_TYPE_TAR_GZ: + return "tar.gz"; + case RAC_ARCHIVE_TYPE_TAR_XZ: + return "tar.xz"; + default: + return "unknown"; + } +} + +rac_bool_t rac_archive_type_from_path(const char* url_path, rac_archive_type_t* out_type) { + if (!url_path || !out_type) { + return RAC_FALSE; + } + + // Convert to lowercase for comparison + std::string path(url_path); + std::transform(path.begin(), path.end(), path.begin(), ::tolower); + + // Check suffixes (mirrors Swift's ArchiveType.from(url:)) + if (path.rfind(".tar.bz2") != std::string::npos || path.rfind(".tbz2") != std::string::npos) { + *out_type = RAC_ARCHIVE_TYPE_TAR_BZ2; + return RAC_TRUE; + } + if (path.rfind(".tar.gz") != std::string::npos || path.rfind(".tgz") != std::string::npos) { + *out_type = RAC_ARCHIVE_TYPE_TAR_GZ; + return RAC_TRUE; + } + if (path.rfind(".tar.xz") != std::string::npos || path.rfind(".txz") != std::string::npos) { + *out_type = RAC_ARCHIVE_TYPE_TAR_XZ; + return RAC_TRUE; + } + if (path.rfind(".zip") != std::string::npos) { + *out_type = RAC_ARCHIVE_TYPE_ZIP; + return RAC_TRUE; + } + + return RAC_FALSE; +} + +// ============================================================================= +// MODEL CATEGORY FUNCTIONS +// ============================================================================= + +rac_bool_t rac_model_category_requires_context_length(rac_model_category_t category) { + // Mirrors Swift's ModelCategory.requiresContextLength + switch (category) { + case RAC_MODEL_CATEGORY_LANGUAGE: + case RAC_MODEL_CATEGORY_MULTIMODAL: + return RAC_TRUE; + default: + return RAC_FALSE; + } +} + +rac_bool_t rac_model_category_supports_thinking(rac_model_category_t category) { + // Mirrors Swift's ModelCategory.supportsThinking + switch (category) { + case RAC_MODEL_CATEGORY_LANGUAGE: + case RAC_MODEL_CATEGORY_MULTIMODAL: + return RAC_TRUE; + default: + return RAC_FALSE; + } +} + +rac_model_category_t rac_model_category_from_framework(rac_inference_framework_t framework) { + // Mirrors Swift's ModelCategory.from(framework:) + switch (framework) { + case RAC_FRAMEWORK_LLAMACPP: + case RAC_FRAMEWORK_FOUNDATION_MODELS: + return RAC_MODEL_CATEGORY_LANGUAGE; + case RAC_FRAMEWORK_ONNX: + return RAC_MODEL_CATEGORY_MULTIMODAL; + case RAC_FRAMEWORK_SYSTEM_TTS: + return RAC_MODEL_CATEGORY_SPEECH_SYNTHESIS; + case RAC_FRAMEWORK_FLUID_AUDIO: + return RAC_MODEL_CATEGORY_AUDIO; + default: + return RAC_MODEL_CATEGORY_AUDIO; + } +} + +// ============================================================================= +// INFERENCE FRAMEWORK FUNCTIONS +// ============================================================================= + +rac_result_t rac_framework_get_supported_formats(rac_inference_framework_t framework, + rac_model_format_t** out_formats, + size_t* out_count) { + if (!out_formats || !out_count) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + // Mirrors Swift's InferenceFramework.supportedFormats + switch (framework) { + case RAC_FRAMEWORK_ONNX: { + *out_count = 2; + *out_formats = (rac_model_format_t*)malloc(2 * sizeof(rac_model_format_t)); + if (!*out_formats) + return RAC_ERROR_OUT_OF_MEMORY; + (*out_formats)[0] = RAC_MODEL_FORMAT_ONNX; + (*out_formats)[1] = RAC_MODEL_FORMAT_ORT; + return RAC_SUCCESS; + } + case RAC_FRAMEWORK_LLAMACPP: { + *out_count = 1; + *out_formats = (rac_model_format_t*)malloc(sizeof(rac_model_format_t)); + if (!*out_formats) + return RAC_ERROR_OUT_OF_MEMORY; + (*out_formats)[0] = RAC_MODEL_FORMAT_GGUF; + return RAC_SUCCESS; + } + case RAC_FRAMEWORK_FLUID_AUDIO: { + *out_count = 1; + *out_formats = (rac_model_format_t*)malloc(sizeof(rac_model_format_t)); + if (!*out_formats) + return RAC_ERROR_OUT_OF_MEMORY; + (*out_formats)[0] = RAC_MODEL_FORMAT_BIN; + return RAC_SUCCESS; + } + default: + *out_count = 0; + *out_formats = nullptr; + return RAC_SUCCESS; + } +} + +rac_bool_t rac_framework_supports_format(rac_inference_framework_t framework, + rac_model_format_t format) { + // Mirrors Swift's InferenceFramework.supports(format:) + switch (framework) { + case RAC_FRAMEWORK_ONNX: + return (format == RAC_MODEL_FORMAT_ONNX || format == RAC_MODEL_FORMAT_ORT) ? RAC_TRUE + : RAC_FALSE; + case RAC_FRAMEWORK_LLAMACPP: + return (format == RAC_MODEL_FORMAT_GGUF) ? RAC_TRUE : RAC_FALSE; + case RAC_FRAMEWORK_FLUID_AUDIO: + return (format == RAC_MODEL_FORMAT_BIN) ? RAC_TRUE : RAC_FALSE; + default: + return RAC_FALSE; + } +} + +rac_bool_t rac_framework_uses_directory_based_models(rac_inference_framework_t framework) { + // Mirrors Swift's InferenceFramework.usesDirectoryBasedModels + switch (framework) { + case RAC_FRAMEWORK_ONNX: + return RAC_TRUE; + default: + return RAC_FALSE; + } +} + +rac_bool_t rac_framework_supports_llm(rac_inference_framework_t framework) { + // Mirrors Swift's InferenceFramework.supportsLLM + switch (framework) { + case RAC_FRAMEWORK_LLAMACPP: + case RAC_FRAMEWORK_ONNX: + case RAC_FRAMEWORK_FOUNDATION_MODELS: + return RAC_TRUE; + default: + return RAC_FALSE; + } +} + +rac_bool_t rac_framework_supports_stt(rac_inference_framework_t framework) { + // Mirrors Swift's InferenceFramework.supportsSTT + switch (framework) { + case RAC_FRAMEWORK_ONNX: + return RAC_TRUE; + default: + return RAC_FALSE; + } +} + +rac_bool_t rac_framework_supports_tts(rac_inference_framework_t framework) { + // Mirrors Swift's InferenceFramework.supportsTTS + switch (framework) { + case RAC_FRAMEWORK_SYSTEM_TTS: + case RAC_FRAMEWORK_ONNX: + return RAC_TRUE; + default: + return RAC_FALSE; + } +} + +const char* rac_framework_display_name(rac_inference_framework_t framework) { + // Mirrors Swift's InferenceFramework.displayName + switch (framework) { + case RAC_FRAMEWORK_ONNX: + return "ONNX Runtime"; + case RAC_FRAMEWORK_LLAMACPP: + return "llama.cpp"; + case RAC_FRAMEWORK_FOUNDATION_MODELS: + return "Foundation Models"; + case RAC_FRAMEWORK_SYSTEM_TTS: + return "System TTS"; + case RAC_FRAMEWORK_FLUID_AUDIO: + return "FluidAudio"; + case RAC_FRAMEWORK_BUILTIN: + return "Built-in"; + case RAC_FRAMEWORK_NONE: + return "None"; + case RAC_FRAMEWORK_UNKNOWN: + return "Unknown"; + default: + return "Unknown"; + } +} + +const char* rac_framework_analytics_key(rac_inference_framework_t framework) { + // Mirrors Swift's InferenceFramework.analyticsKey + switch (framework) { + case RAC_FRAMEWORK_ONNX: + return "onnx"; + case RAC_FRAMEWORK_LLAMACPP: + return "llama_cpp"; + case RAC_FRAMEWORK_FOUNDATION_MODELS: + return "foundation_models"; + case RAC_FRAMEWORK_SYSTEM_TTS: + return "system_tts"; + case RAC_FRAMEWORK_FLUID_AUDIO: + return "fluid_audio"; + case RAC_FRAMEWORK_BUILTIN: + return "built_in"; + case RAC_FRAMEWORK_NONE: + return "none"; + case RAC_FRAMEWORK_UNKNOWN: + return "unknown"; + default: + return "unknown"; + } +} + +// ============================================================================= +// ARTIFACT FUNCTIONS +// ============================================================================= + +rac_bool_t rac_artifact_requires_extraction(const rac_model_artifact_info_t* artifact) { + if (!artifact) + return RAC_FALSE; + // Mirrors Swift's ModelArtifactType.requiresExtraction + return (artifact->kind == RAC_ARTIFACT_KIND_ARCHIVE) ? RAC_TRUE : RAC_FALSE; +} + +rac_bool_t rac_artifact_requires_download(const rac_model_artifact_info_t* artifact) { + if (!artifact) + return RAC_FALSE; + // Mirrors Swift's ModelArtifactType.requiresDownload + return (artifact->kind == RAC_ARTIFACT_KIND_BUILT_IN) ? RAC_FALSE : RAC_TRUE; +} + +rac_result_t rac_artifact_infer_from_url(const char* url, rac_model_format_t format, + rac_model_artifact_info_t* out_artifact) { + (void)format; // Currently unused but matches Swift signature + + if (!out_artifact) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + // Initialize to defaults + memset(out_artifact, 0, sizeof(rac_model_artifact_info_t)); + + if (!url) { + out_artifact->kind = RAC_ARTIFACT_KIND_SINGLE_FILE; + return RAC_SUCCESS; + } + + // Check if URL indicates an archive + rac_archive_type_t archive_type = + RAC_ARCHIVE_TYPE_ZIP; // Default value, will be set by function + if (rac_archive_type_from_path(url, &archive_type) == RAC_TRUE) { + out_artifact->kind = RAC_ARTIFACT_KIND_ARCHIVE; + out_artifact->archive_type = archive_type; + out_artifact->archive_structure = RAC_ARCHIVE_STRUCTURE_UNKNOWN; + return RAC_SUCCESS; + } + + // Default to single file + out_artifact->kind = RAC_ARTIFACT_KIND_SINGLE_FILE; + return RAC_SUCCESS; +} + +rac_bool_t rac_model_info_is_downloaded(const rac_model_info_t* model) { + if (!model) + return RAC_FALSE; + // Mirrors Swift's ModelInfo.isDownloaded + return (model->local_path && strlen(model->local_path) > 0) ? RAC_TRUE : RAC_FALSE; +} + +// ============================================================================= +// FORMAT DETECTION - Ported from Swift RegistryService.swift +// ============================================================================= + +rac_bool_t rac_model_detect_format_from_extension(const char* extension, + rac_model_format_t* out_format) { + if (!extension || !out_format) { + return RAC_FALSE; + } + + // Convert to lowercase for comparison + std::string ext(extension); + std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower); + + // Ported from Swift RegistryService.detectFormatFromExtension() (lines 330-338) + if (ext == "onnx") { + *out_format = RAC_MODEL_FORMAT_ONNX; + return RAC_TRUE; + } + if (ext == "ort") { + *out_format = RAC_MODEL_FORMAT_ORT; + return RAC_TRUE; + } + if (ext == "gguf") { + *out_format = RAC_MODEL_FORMAT_GGUF; + return RAC_TRUE; + } + if (ext == "bin") { + *out_format = RAC_MODEL_FORMAT_BIN; + return RAC_TRUE; + } + + return RAC_FALSE; +} + +rac_bool_t rac_model_detect_framework_from_format(rac_model_format_t format, + rac_inference_framework_t* out_framework) { + if (!out_framework) { + return RAC_FALSE; + } + + // Ported from Swift RegistryService.detectFramework(for:) (lines 340-343) + // Uses InferenceFramework.framework(for:) which checks supported formats + switch (format) { + case RAC_MODEL_FORMAT_ONNX: + case RAC_MODEL_FORMAT_ORT: + *out_framework = RAC_FRAMEWORK_ONNX; + return RAC_TRUE; + case RAC_MODEL_FORMAT_GGUF: + *out_framework = RAC_FRAMEWORK_LLAMACPP; + return RAC_TRUE; + case RAC_MODEL_FORMAT_BIN: + *out_framework = RAC_FRAMEWORK_FLUID_AUDIO; + return RAC_TRUE; + default: + return RAC_FALSE; + } +} + +const char* rac_model_format_extension(rac_model_format_t format) { + // Mirrors Swift's ModelFormat.fileExtension + switch (format) { + case RAC_MODEL_FORMAT_ONNX: + return "onnx"; + case RAC_MODEL_FORMAT_ORT: + return "ort"; + case RAC_MODEL_FORMAT_GGUF: + return "gguf"; + case RAC_MODEL_FORMAT_BIN: + return "bin"; + default: + return nullptr; + } +} + +// ============================================================================= +// MODEL ID/NAME GENERATION - Ported from Swift RegistryService.swift +// ============================================================================= + +void rac_model_generate_id(const char* url, char* out_id, size_t max_len) { + // Ported from Swift RegistryService.generateModelId(from:) (lines 311-318) + if (!url || !out_id || max_len == 0) { + if (out_id && max_len > 0) { + out_id[0] = '\0'; + } + return; + } + + // Get last path component (filename) + std::string path(url); + size_t last_slash = path.rfind('/'); + std::string filename = (last_slash != std::string::npos) ? path.substr(last_slash + 1) : path; + + // Known extensions to strip (from Swift lines 313) + const char* known_extensions[] = {"gz", "bz2", "tar", "zip", "gguf", "onnx", "ort", "bin"}; + + // Strip known extensions from the end (Swift lines 314-316) + bool found = true; + while (found && !filename.empty()) { + found = false; + size_t dot_pos = filename.rfind('.'); + if (dot_pos != std::string::npos && dot_pos < filename.size() - 1) { + std::string ext = filename.substr(dot_pos + 1); + std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower); + + for (const auto& known_extension : known_extensions) { + if (ext == known_extension) { + filename = filename.substr(0, dot_pos); + found = true; + break; + } + } + } + } + + // Copy result to output buffer + size_t copy_len = std::min(filename.size(), max_len - 1); + memcpy(out_id, filename.c_str(), copy_len); + out_id[copy_len] = '\0'; +} + +void rac_model_generate_name(const char* url, char* out_name, size_t max_len) { + // Ported from Swift RegistryService.generateModelName(from:) (lines 320-324) + if (!url || !out_name || max_len == 0) { + if (out_name && max_len > 0) { + out_name[0] = '\0'; + } + return; + } + + // Get last path component and strip single extension (Swift's deletingPathExtension()) + std::string path(url); + size_t last_slash = path.rfind('/'); + std::string filename = (last_slash != std::string::npos) ? path.substr(last_slash + 1) : path; + + // Delete path extension (last .xxx) + size_t dot_pos = filename.rfind('.'); + if (dot_pos != std::string::npos) { + filename = filename.substr(0, dot_pos); + } + + // Replace underscores and dashes with spaces (Swift lines 322-323) + for (size_t i = 0; i < filename.size(); i++) { + if (filename[i] == '_' || filename[i] == '-') { + filename[i] = ' '; + } + } + + // Copy result to output buffer + size_t copy_len = std::min(filename.size(), max_len - 1); + memcpy(out_name, filename.c_str(), copy_len); + out_name[copy_len] = '\0'; +} + +// ============================================================================= +// MODEL FILTERING - Ported from Swift RegistryService.swift +// ============================================================================= + +// Helper to check if string contains substring (case-insensitive) +static bool contains_case_insensitive(const char* haystack, const char* needle) { + if (!haystack || !needle) + return false; + + std::string h(haystack); + std::string n(needle); + std::transform(h.begin(), h.end(), h.begin(), ::tolower); + std::transform(n.begin(), n.end(), n.begin(), ::tolower); + + return h.find(n) != std::string::npos; +} + +rac_bool_t rac_model_matches_filter(const rac_model_info_t* model, + const rac_model_filter_t* filter) { + // Ported from Swift RegistryService.filterModels(by:) filter closure (lines 106-124) + if (!model) { + return RAC_FALSE; + } + + // No filter = matches all + if (!filter) { + return RAC_TRUE; + } + + // Framework filter (Swift lines 107-109) + if (filter->framework != RAC_FRAMEWORK_UNKNOWN && model->framework != filter->framework) { + return RAC_FALSE; + } + + // Format filter (Swift lines 110-112) + if (filter->format != RAC_MODEL_FORMAT_UNKNOWN && model->format != filter->format) { + return RAC_FALSE; + } + + // Max size filter (Swift lines 113-115) + if (filter->max_size > 0 && model->download_size > 0 && + model->download_size > filter->max_size) { + return RAC_FALSE; + } + + // Search query filter (Swift lines 116-122) + if (filter->search_query && strlen(filter->search_query) > 0) { + bool matches = contains_case_insensitive(model->name, filter->search_query) || + contains_case_insensitive(model->id, filter->search_query) || + contains_case_insensitive(model->description, filter->search_query); + if (!matches) { + return RAC_FALSE; + } + } + + return RAC_TRUE; +} + +size_t rac_model_filter_models(const rac_model_info_t* models, size_t models_count, + const rac_model_filter_t* filter, rac_model_info_t* out_models, + size_t out_capacity) { + // Ported from Swift RegistryService.filterModels(by:) (lines 104-126) + if (!models || models_count == 0) { + return 0; + } + + size_t matched_count = 0; + + for (size_t i = 0; i < models_count; i++) { + if (rac_model_matches_filter(&models[i], filter) == RAC_TRUE) { + // Copy to output if we have space + if (out_models && matched_count < out_capacity) { + out_models[matched_count] = models[i]; + } + matched_count++; + } + } + + return matched_count; +} + +// ============================================================================= +// MEMORY MANAGEMENT +// ============================================================================= + +// Note: rac_strdup is declared in rac_types.h and implemented in rac_memory.cpp + +rac_expected_model_files_t* rac_expected_model_files_alloc(void) { + auto* files = (rac_expected_model_files_t*)calloc(1, sizeof(rac_expected_model_files_t)); + return files; +} + +void rac_expected_model_files_free(rac_expected_model_files_t* files) { + if (!files) + return; + + if (files->required_patterns) { + for (size_t i = 0; i < files->required_pattern_count; i++) { + free((void*)files->required_patterns[i]); + } + free((void*)files->required_patterns); + } + + if (files->optional_patterns) { + for (size_t i = 0; i < files->optional_pattern_count; i++) { + free((void*)files->optional_patterns[i]); + } + free((void*)files->optional_patterns); + } + + free((void*)files->description); + free(files); +} + +rac_model_file_descriptor_t* rac_model_file_descriptors_alloc(size_t count) { + if (count == 0) + return nullptr; + return (rac_model_file_descriptor_t*)calloc(count, sizeof(rac_model_file_descriptor_t)); +} + +void rac_model_file_descriptors_free(rac_model_file_descriptor_t* descriptors, size_t count) { + if (!descriptors) + return; + for (size_t i = 0; i < count; i++) { + free((void*)descriptors[i].relative_path); + free((void*)descriptors[i].destination_path); + } + free(descriptors); +} + +rac_model_info_t* rac_model_info_alloc(void) { + return (rac_model_info_t*)calloc(1, sizeof(rac_model_info_t)); +} + +void rac_model_info_free(rac_model_info_t* model) { + if (!model) + return; + + free(model->id); + free(model->name); + free(model->download_url); + free(model->local_path); + free(model->description); + + // Free artifact info + if (model->artifact_info.expected_files) { + rac_expected_model_files_free(model->artifact_info.expected_files); + } + if (model->artifact_info.file_descriptors) { + rac_model_file_descriptors_free(model->artifact_info.file_descriptors, + model->artifact_info.file_descriptor_count); + } + free((void*)model->artifact_info.strategy_id); + + // Free tags + if (model->tags) { + for (size_t i = 0; i < model->tag_count; i++) { + free(model->tags[i]); + } + free(model->tags); + } + + free(model); +} + +void rac_model_info_array_free(rac_model_info_t** models, size_t count) { + if (!models) + return; + for (size_t i = 0; i < count; i++) { + rac_model_info_free(models[i]); + } + free(models); +} + +rac_model_info_t* rac_model_info_copy(const rac_model_info_t* model) { + if (!model) + return nullptr; + + rac_model_info_t* copy = rac_model_info_alloc(); + if (!copy) + return nullptr; + + // Copy scalar fields + copy->category = model->category; + copy->format = model->format; + copy->framework = model->framework; + copy->download_size = model->download_size; + copy->memory_required = model->memory_required; + copy->context_length = model->context_length; + copy->supports_thinking = model->supports_thinking; + copy->source = model->source; + copy->created_at = model->created_at; + copy->updated_at = model->updated_at; + copy->last_used = model->last_used; + copy->usage_count = model->usage_count; + + // Copy strings + copy->id = rac_strdup(model->id); + copy->name = rac_strdup(model->name); + copy->download_url = rac_strdup(model->download_url); + copy->local_path = rac_strdup(model->local_path); + copy->description = rac_strdup(model->description); + + // Copy artifact info (shallow for now - TODO: deep copy if needed) + copy->artifact_info = model->artifact_info; + copy->artifact_info.expected_files = nullptr; + copy->artifact_info.file_descriptors = nullptr; + copy->artifact_info.strategy_id = rac_strdup(model->artifact_info.strategy_id); + + // Copy tags + if (model->tags && model->tag_count > 0) { + copy->tags = (char**)malloc(model->tag_count * sizeof(char*)); + if (copy->tags) { + copy->tag_count = model->tag_count; + for (size_t i = 0; i < model->tag_count; i++) { + copy->tags[i] = rac_strdup(model->tags[i]); + } + } + } + + return copy; +} diff --git a/sdk/runanywhere-commons/src/infrastructure/network/api_types.cpp b/sdk/runanywhere-commons/src/infrastructure/network/api_types.cpp new file mode 100644 index 000000000..a9f0ab271 --- /dev/null +++ b/sdk/runanywhere-commons/src/infrastructure/network/api_types.cpp @@ -0,0 +1,639 @@ +/** + * @file api_types.cpp + * @brief API types implementation with JSON serialization + */ + +#include +#include +#include + +#include "rac/core/rac_logger.h" +#include "rac/infrastructure/network/rac_api_types.h" + +// Simple JSON building helpers (no external dependencies) +// For production, consider using a proper JSON library like nlohmann/json + +// ============================================================================= +// Memory Management +// ============================================================================= + +static char* str_dup(const char* src) { + if (!src) + return nullptr; + size_t len = strlen(src); + char* dst = (char*)malloc(len + 1); + if (dst) { + memcpy(dst, src, len + 1); + } + return dst; +} + +void rac_auth_response_free(rac_auth_response_t* response) { + if (!response) + return; + free(response->access_token); + free(response->refresh_token); + free(response->device_id); + free(response->user_id); + free(response->organization_id); + free(response->token_type); + memset(response, 0, sizeof(*response)); +} + +void rac_health_response_free(rac_health_response_t* response) { + if (!response) + return; + free(response->version); + memset(response, 0, sizeof(*response)); +} + +void rac_device_reg_response_free(rac_device_reg_response_t* response) { + if (!response) + return; + free(response->device_id); + free(response->status); + free(response->sync_status); + memset(response, 0, sizeof(*response)); +} + +void rac_telemetry_response_free(rac_telemetry_response_t* response) { + if (!response) + return; + if (response->errors) { + for (size_t i = 0; i < response->error_count; i++) { + free(response->errors[i]); + } + free(response->errors); + } + free(response->storage_version); + memset(response, 0, sizeof(*response)); +} + +void rac_api_error_free(rac_api_error_t* error) { + if (!error) + return; + free(error->message); + free(error->code); + free(error->raw_body); + free(error->request_url); + memset(error, 0, sizeof(*error)); +} + +// ============================================================================= +// JSON Building Helpers +// ============================================================================= + +// Escape string for JSON +static void json_escape_string(const char* src, char* dst, size_t dst_size) { + size_t di = 0; + for (const char* s = src; *s && di < dst_size - 1; s++) { + switch (*s) { + case '"': + if (di + 2 < dst_size) { + dst[di++] = '\\'; + dst[di++] = '"'; + } + break; + case '\\': + if (di + 2 < dst_size) { + dst[di++] = '\\'; + dst[di++] = '\\'; + } + break; + case '\n': + if (di + 2 < dst_size) { + dst[di++] = '\\'; + dst[di++] = 'n'; + } + break; + case '\r': + if (di + 2 < dst_size) { + dst[di++] = '\\'; + dst[di++] = 'r'; + } + break; + case '\t': + if (di + 2 < dst_size) { + dst[di++] = '\\'; + dst[di++] = 't'; + } + break; + default: + dst[di++] = *s; + break; + } + } + dst[di] = '\0'; +} + +// Add string field to JSON buffer +static int json_add_string(char* buf, size_t buf_size, size_t* pos, const char* key, + const char* value, bool comma) { + if (!value) + return 0; + + char escaped[1024]; + json_escape_string(value, escaped, sizeof(escaped)); + + int written = + snprintf(buf + *pos, buf_size - *pos, "%s\"%s\":\"%s\"", comma ? "," : "", key, escaped); + if (written < 0 || (size_t)written >= buf_size - *pos) + return -1; + *pos += written; + return 0; +} + +// Add int field to JSON buffer +static int json_add_int(char* buf, size_t buf_size, size_t* pos, const char* key, int64_t value, + bool comma) { + int written = snprintf(buf + *pos, buf_size - *pos, "%s\"%s\":%lld", comma ? "," : "", key, + (long long)value); + if (written < 0 || (size_t)written >= buf_size - *pos) + return -1; + *pos += written; + return 0; +} + +// Add double field to JSON buffer +static int json_add_double(char* buf, size_t buf_size, size_t* pos, const char* key, double value, + bool comma) { + int written = + snprintf(buf + *pos, buf_size - *pos, "%s\"%s\":%.6f", comma ? "," : "", key, value); + if (written < 0 || (size_t)written >= buf_size - *pos) + return -1; + *pos += written; + return 0; +} + +// Add bool field to JSON buffer +static int json_add_bool(char* buf, size_t buf_size, size_t* pos, const char* key, bool value, + bool comma) { + int written = snprintf(buf + *pos, buf_size - *pos, "%s\"%s\":%s", comma ? "," : "", key, + value ? "true" : "false"); + if (written < 0 || (size_t)written >= buf_size - *pos) + return -1; + *pos += written; + return 0; +} + +// ============================================================================= +// JSON Parsing Helpers (Simple hand-rolled parser) +// ============================================================================= + +// Find value for key in JSON object (returns pointer to value start) +static const char* json_find_value(const char* json, const char* key) { + if (!json || !key) + return nullptr; + + char search[128]; + snprintf(search, sizeof(search), "\"%s\"", key); + + const char* found = strstr(json, search); + if (!found) + return nullptr; + + // Skip past key and colon + found += strlen(search); + while (*found && (*found == ' ' || *found == ':')) + found++; + + return found; +} + +// Extract string value (returns malloc'd string) +static char* json_extract_string(const char* json, const char* key) { + const char* value = json_find_value(json, key); + if (!value || *value != '"') + return nullptr; + + value++; // Skip opening quote + + // Find end quote (simple - doesn't handle all escapes) + const char* end = value; + while (*end && *end != '"') { + if (*end == '\\' && *(end + 1)) + end += 2; + else + end++; + } + + size_t len = end - value; + char* result = (char*)malloc(len + 1); + if (result) { + // Simple unescape + size_t di = 0; + for (size_t si = 0; si < len && di < len; si++) { + if (value[si] == '\\' && si + 1 < len) { + si++; + switch (value[si]) { + case 'n': + result[di++] = '\n'; + break; + case 'r': + result[di++] = '\r'; + break; + case 't': + result[di++] = '\t'; + break; + default: + result[di++] = value[si]; + break; + } + } else { + result[di++] = value[si]; + } + } + result[di] = '\0'; + } + return result; +} + +// Extract integer value +static int64_t json_extract_int(const char* json, const char* key, int64_t default_val) { + const char* value = json_find_value(json, key); + if (!value) + return default_val; + + // Skip null + if (strncmp(value, "null", 4) == 0) + return default_val; + + char* end; + long long result = strtoll(value, &end, 10); + if (end == value) + return default_val; + return result; +} + +// Extract boolean value +static bool json_extract_bool(const char* json, const char* key, bool default_val) { + const char* value = json_find_value(json, key); + if (!value) + return default_val; + + if (strncmp(value, "true", 4) == 0) + return true; + if (strncmp(value, "false", 5) == 0) + return false; + return default_val; +} + +// ============================================================================= +// Auth Request/Response Serialization +// ============================================================================= + +char* rac_auth_request_to_json(const rac_auth_request_t* request) { + if (!request) + return nullptr; + + char buf[2048]; + size_t pos = 0; + + buf[pos++] = '{'; + + if (json_add_string(buf, sizeof(buf), &pos, "api_key", request->api_key, false) < 0) + return nullptr; + if (json_add_string(buf, sizeof(buf), &pos, "device_id", request->device_id, true) < 0) + return nullptr; + if (json_add_string(buf, sizeof(buf), &pos, "platform", request->platform, true) < 0) + return nullptr; + if (json_add_string(buf, sizeof(buf), &pos, "sdk_version", request->sdk_version, true) < 0) + return nullptr; + + buf[pos++] = '}'; + buf[pos] = '\0'; + + return str_dup(buf); +} + +int rac_auth_response_from_json(const char* json, rac_auth_response_t* out_response) { + if (!json || !out_response) + return -1; + + memset(out_response, 0, sizeof(*out_response)); + + out_response->access_token = json_extract_string(json, "access_token"); + out_response->refresh_token = json_extract_string(json, "refresh_token"); + out_response->device_id = json_extract_string(json, "device_id"); + out_response->user_id = json_extract_string(json, "user_id"); + out_response->organization_id = json_extract_string(json, "organization_id"); + out_response->token_type = json_extract_string(json, "token_type"); + out_response->expires_in = (int32_t)json_extract_int(json, "expires_in", 0); + + // Validate required fields + if (!out_response->access_token || !out_response->refresh_token) { + rac_auth_response_free(out_response); + return -1; + } + + return 0; +} + +char* rac_refresh_request_to_json(const rac_refresh_request_t* request) { + if (!request) + return nullptr; + + char buf[1024]; + size_t pos = 0; + + buf[pos++] = '{'; + + if (json_add_string(buf, sizeof(buf), &pos, "device_id", request->device_id, false) < 0) + return nullptr; + if (json_add_string(buf, sizeof(buf), &pos, "refresh_token", request->refresh_token, true) < 0) + return nullptr; + + buf[pos++] = '}'; + buf[pos] = '\0'; + + return str_dup(buf); +} + +// ============================================================================= +// Device Registration Serialization +// ============================================================================= + +char* rac_device_reg_request_to_json(const rac_device_reg_request_t* request) { + if (!request) + return nullptr; + + char buf[4096]; + size_t pos = 0; + + buf[pos++] = '{'; + + // Device info object + int written = snprintf(buf + pos, sizeof(buf) - pos, "\"device_info\":{"); + if (written < 0) + return nullptr; + pos += written; + + const rac_device_info_t* info = &request->device_info; + bool first = true; + + if (info->device_fingerprint) { + if (json_add_string(buf, sizeof(buf), &pos, "device_fingerprint", info->device_fingerprint, + !first) < 0) + return nullptr; + first = false; + } + if (json_add_string(buf, sizeof(buf), &pos, "device_model", info->device_model, !first) < 0) + return nullptr; + if (json_add_string(buf, sizeof(buf), &pos, "os_version", info->os_version, true) < 0) + return nullptr; + if (json_add_string(buf, sizeof(buf), &pos, "platform", info->platform, true) < 0) + return nullptr; + if (json_add_string(buf, sizeof(buf), &pos, "architecture", info->architecture, true) < 0) + return nullptr; + if (json_add_int(buf, sizeof(buf), &pos, "total_memory", info->total_memory, true) < 0) + return nullptr; + if (json_add_int(buf, sizeof(buf), &pos, "cpu_cores", info->cpu_cores, true) < 0) + return nullptr; + if (json_add_bool(buf, sizeof(buf), &pos, "has_neural_engine", info->has_neural_engine, true) < + 0) + return nullptr; + if (json_add_bool(buf, sizeof(buf), &pos, "has_gpu", info->has_gpu, true) < 0) + return nullptr; + + buf[pos++] = '}'; // Close device_info + + // SDK metadata + if (json_add_string(buf, sizeof(buf), &pos, "sdk_version", request->sdk_version, true) < 0) + return nullptr; + if (json_add_string(buf, sizeof(buf), &pos, "build_token", request->build_token, true) < 0) + return nullptr; + + // Timestamp as ISO8601 string (simplified - platform can provide proper formatting) + char timestamp[32]; + snprintf(timestamp, sizeof(timestamp), "%lld", (long long)request->last_seen_at); + if (json_add_string(buf, sizeof(buf), &pos, "last_seen_at", timestamp, true) < 0) + return nullptr; + + buf[pos++] = '}'; + buf[pos] = '\0'; + + return str_dup(buf); +} + +int rac_device_reg_response_from_json(const char* json, rac_device_reg_response_t* out_response) { + if (!json || !out_response) + return -1; + + memset(out_response, 0, sizeof(*out_response)); + + out_response->device_id = json_extract_string(json, "device_id"); + out_response->status = json_extract_string(json, "status"); + out_response->sync_status = json_extract_string(json, "sync_status"); + + return 0; +} + +// ============================================================================= +// Telemetry Serialization +// ============================================================================= + +char* rac_telemetry_event_to_json(const rac_telemetry_event_t* event) { + if (!event) + return nullptr; + + char buf[8192]; + size_t pos = 0; + + buf[pos++] = '{'; + + // Required fields + if (json_add_string(buf, sizeof(buf), &pos, "id", event->id, false) < 0) + return nullptr; + if (json_add_string(buf, sizeof(buf), &pos, "event_type", event->event_type, true) < 0) + return nullptr; + if (json_add_int(buf, sizeof(buf), &pos, "timestamp", event->timestamp, true) < 0) + return nullptr; + if (json_add_int(buf, sizeof(buf), &pos, "created_at", event->created_at, true) < 0) + return nullptr; + + // Optional fields (only add if set) + if (event->modality) + if (json_add_string(buf, sizeof(buf), &pos, "modality", event->modality, true) < 0) + return nullptr; + if (event->device_id) + if (json_add_string(buf, sizeof(buf), &pos, "device_id", event->device_id, true) < 0) + return nullptr; + if (event->session_id) + if (json_add_string(buf, sizeof(buf), &pos, "session_id", event->session_id, true) < 0) + return nullptr; + if (event->model_id) + if (json_add_string(buf, sizeof(buf), &pos, "model_id", event->model_id, true) < 0) + return nullptr; + if (event->model_name) + if (json_add_string(buf, sizeof(buf), &pos, "model_name", event->model_name, true) < 0) + return nullptr; + if (event->framework) + if (json_add_string(buf, sizeof(buf), &pos, "framework", event->framework, true) < 0) + return nullptr; + + // Device info + if (event->device) + if (json_add_string(buf, sizeof(buf), &pos, "device", event->device, true) < 0) + return nullptr; + if (event->os_version) + if (json_add_string(buf, sizeof(buf), &pos, "os_version", event->os_version, true) < 0) + return nullptr; + if (event->platform) + if (json_add_string(buf, sizeof(buf), &pos, "platform", event->platform, true) < 0) + return nullptr; + if (event->sdk_version) + if (json_add_string(buf, sizeof(buf), &pos, "sdk_version", event->sdk_version, true) < 0) + return nullptr; + + // Common metrics + if (event->processing_time_ms > 0) + if (json_add_double(buf, sizeof(buf), &pos, "processing_time_ms", event->processing_time_ms, + true) < 0) + return nullptr; + if (event->has_success) + if (json_add_bool(buf, sizeof(buf), &pos, "success", event->success, true) < 0) + return nullptr; + if (event->error_message) + if (json_add_string(buf, sizeof(buf), &pos, "error_message", event->error_message, true) < + 0) + return nullptr; + if (event->error_code) + if (json_add_string(buf, sizeof(buf), &pos, "error_code", event->error_code, true) < 0) + return nullptr; + + // LLM metrics + if (event->input_tokens > 0) + if (json_add_int(buf, sizeof(buf), &pos, "input_tokens", event->input_tokens, true) < 0) + return nullptr; + if (event->output_tokens > 0) + if (json_add_int(buf, sizeof(buf), &pos, "output_tokens", event->output_tokens, true) < 0) + return nullptr; + if (event->total_tokens > 0) + if (json_add_int(buf, sizeof(buf), &pos, "total_tokens", event->total_tokens, true) < 0) + return nullptr; + if (event->tokens_per_second > 0) + if (json_add_double(buf, sizeof(buf), &pos, "tokens_per_second", event->tokens_per_second, + true) < 0) + return nullptr; + if (event->time_to_first_token_ms > 0) + if (json_add_double(buf, sizeof(buf), &pos, "time_to_first_token_ms", + event->time_to_first_token_ms, true) < 0) + return nullptr; + + buf[pos++] = '}'; + buf[pos] = '\0'; + + return str_dup(buf); +} + +char* rac_telemetry_batch_to_json(const rac_telemetry_batch_t* batch) { + if (!batch) + return nullptr; + + // Estimate size needed + size_t buf_size = 1024 + (batch->event_count * 8192); + char* buf = (char*)malloc(buf_size); + if (!buf) + return nullptr; + + size_t pos = 0; + + buf[pos++] = '{'; + + // Events array + int written = snprintf(buf + pos, buf_size - pos, "\"events\":["); + if (written < 0) { + free(buf); + return nullptr; + } + pos += written; + + for (size_t i = 0; i < batch->event_count; i++) { + if (i > 0) + buf[pos++] = ','; + + char* event_json = rac_telemetry_event_to_json(&batch->events[i]); + if (!event_json) { + free(buf); + return nullptr; + } + + size_t event_len = strlen(event_json); + if (pos + event_len >= buf_size - 100) { + free(event_json); + free(buf); + return nullptr; + } + memcpy(buf + pos, event_json, event_len); + pos += event_len; + free(event_json); + } + + buf[pos++] = ']'; // Close events array + + // Other batch fields + if (json_add_string(buf, buf_size, &pos, "device_id", batch->device_id, true) < 0) { + free(buf); + return nullptr; + } + if (json_add_int(buf, buf_size, &pos, "timestamp", batch->timestamp, true) < 0) { + free(buf); + return nullptr; + } + if (batch->modality) + if (json_add_string(buf, buf_size, &pos, "modality", batch->modality, true) < 0) { + free(buf); + return nullptr; + } + + buf[pos++] = '}'; + buf[pos] = '\0'; + + return buf; +} + +int rac_telemetry_response_from_json(const char* json, rac_telemetry_response_t* out_response) { + if (!json || !out_response) + return -1; + + memset(out_response, 0, sizeof(*out_response)); + + out_response->success = json_extract_bool(json, "success", false); + out_response->events_received = (int32_t)json_extract_int(json, "events_received", 0); + out_response->events_stored = (int32_t)json_extract_int(json, "events_stored", 0); + out_response->events_skipped = (int32_t)json_extract_int(json, "events_skipped", 0); + out_response->storage_version = json_extract_string(json, "storage_version"); + + return 0; +} + +// ============================================================================= +// Error Parsing +// ============================================================================= + +int rac_api_error_from_response(int status_code, const char* body, const char* url, + rac_api_error_t* out_error) { + if (!out_error) + return -1; + + memset(out_error, 0, sizeof(*out_error)); + + out_error->status_code = status_code; + out_error->raw_body = str_dup(body); + out_error->request_url = str_dup(url); + + if (body) { + // Try to extract error message from various formats + out_error->message = json_extract_string(body, "detail"); + if (!out_error->message) { + out_error->message = json_extract_string(body, "message"); + } + if (!out_error->message) { + out_error->message = json_extract_string(body, "error"); + } + + out_error->code = json_extract_string(body, "code"); + } + + return 0; +} diff --git a/sdk/runanywhere-commons/src/infrastructure/network/auth_manager.cpp b/sdk/runanywhere-commons/src/infrastructure/network/auth_manager.cpp new file mode 100644 index 000000000..03243008a --- /dev/null +++ b/sdk/runanywhere-commons/src/infrastructure/network/auth_manager.cpp @@ -0,0 +1,334 @@ +/** + * @file auth_manager.cpp + * @brief Authentication state management implementation + */ + +#include +#include +#include + +#include "rac/core/rac_logger.h" +#include "rac/infrastructure/network/rac_api_types.h" +#include "rac/infrastructure/network/rac_auth_manager.h" + +// ============================================================================= +// Global State +// ============================================================================= + +static rac_auth_state_t g_auth_state = {}; +static rac_secure_storage_t g_storage = {}; +static bool g_storage_available = false; + +// ============================================================================= +// Helpers +// ============================================================================= + +static char* str_dup(const char* src) { + if (!src) + return nullptr; + size_t len = strlen(src); + char* dst = (char*)malloc(len + 1); + if (dst) { + memcpy(dst, src, len + 1); + } + return dst; +} + +static void free_auth_state_strings() { + free(g_auth_state.access_token); + free(g_auth_state.refresh_token); + free(g_auth_state.device_id); + free(g_auth_state.user_id); + free(g_auth_state.organization_id); + + g_auth_state.access_token = nullptr; + g_auth_state.refresh_token = nullptr; + g_auth_state.device_id = nullptr; + g_auth_state.user_id = nullptr; + g_auth_state.organization_id = nullptr; +} + +static int64_t current_time_seconds() { + return (int64_t)time(nullptr); +} + +// ============================================================================= +// Initialization +// ============================================================================= + +void rac_auth_init(const rac_secure_storage_t* storage) { + rac_auth_reset(); + + if (storage && storage->store && storage->retrieve && storage->delete_key) { + g_storage = *storage; + g_storage_available = true; + } else { + memset(&g_storage, 0, sizeof(g_storage)); + g_storage_available = false; + } +} + +void rac_auth_reset(void) { + free_auth_state_strings(); + memset(&g_auth_state, 0, sizeof(g_auth_state)); +} + +// ============================================================================= +// Token State +// ============================================================================= + +bool rac_auth_is_authenticated(void) { + return g_auth_state.is_authenticated && g_auth_state.access_token != nullptr && + g_auth_state.access_token[0] != '\0'; +} + +bool rac_auth_needs_refresh(void) { + if (!g_auth_state.refresh_token || g_auth_state.refresh_token[0] == '\0') { + return false; // Can't refresh without refresh token + } + + if (g_auth_state.token_expires_at <= 0) { + return true; // Unknown expiry, assume needs refresh + } + + // Check if token expires within 60 seconds + int64_t now = current_time_seconds(); + return (g_auth_state.token_expires_at - now) < 60; +} + +const char* rac_auth_get_access_token(void) { + if (!rac_auth_is_authenticated()) { + return nullptr; + } + return g_auth_state.access_token; +} + +const char* rac_auth_get_device_id(void) { + return g_auth_state.device_id; +} + +const char* rac_auth_get_user_id(void) { + return g_auth_state.user_id; +} + +const char* rac_auth_get_organization_id(void) { + return g_auth_state.organization_id; +} + +// ============================================================================= +// Request Building +// ============================================================================= + +char* rac_auth_build_authenticate_request(const rac_sdk_config_t* config) { + if (!config) + return nullptr; + + rac_auth_request_t request = {}; + request.api_key = config->api_key; + request.device_id = config->device_id; + request.platform = config->platform; + request.sdk_version = config->sdk_version; + + return rac_auth_request_to_json(&request); +} + +char* rac_auth_build_refresh_request(void) { + if (!g_auth_state.refresh_token || !g_auth_state.device_id) { + return nullptr; + } + + rac_refresh_request_t request = {}; + request.device_id = g_auth_state.device_id; + request.refresh_token = g_auth_state.refresh_token; + + return rac_refresh_request_to_json(&request); +} + +// ============================================================================= +// Response Handling +// ============================================================================= + +static int update_auth_state_from_response(const rac_auth_response_t* response) { + if (!response || !response->access_token || !response->refresh_token) { + return -1; + } + + // Free old strings + free_auth_state_strings(); + + // Copy new values + g_auth_state.access_token = str_dup(response->access_token); + g_auth_state.refresh_token = str_dup(response->refresh_token); + g_auth_state.device_id = str_dup(response->device_id); + g_auth_state.user_id = str_dup(response->user_id); // Can be NULL + g_auth_state.organization_id = str_dup(response->organization_id); + + // Calculate expiry timestamp + g_auth_state.token_expires_at = current_time_seconds() + response->expires_in; + g_auth_state.is_authenticated = true; + + return 0; +} + +int rac_auth_handle_authenticate_response(const char* json) { + if (!json) + return -1; + + rac_auth_response_t response = {}; + if (rac_auth_response_from_json(json, &response) != 0) { + return -1; + } + + int result = update_auth_state_from_response(&response); + + // Save to secure storage if available and successful + if (result == 0) { + rac_auth_save_tokens(); + } + + rac_auth_response_free(&response); + return result; +} + +int rac_auth_handle_refresh_response(const char* json) { + // Same handling as authenticate - response format is identical + return rac_auth_handle_authenticate_response(json); +} + +// ============================================================================= +// Token Management +// ============================================================================= + +int rac_auth_get_valid_token(const char** out_token, bool* out_needs_refresh) { + if (!out_token || !out_needs_refresh) + return -1; + + *out_token = nullptr; + *out_needs_refresh = false; + + // Not authenticated at all + if (!rac_auth_is_authenticated()) { + return -1; + } + + // Check if refresh is needed + if (rac_auth_needs_refresh()) { + *out_needs_refresh = true; + return 1; // Caller should refresh + } + + // Token is valid + *out_token = g_auth_state.access_token; + return 0; +} + +void rac_auth_clear(void) { + // Clear in-memory state + rac_auth_reset(); + + // Clear secure storage + if (g_storage_available) { + g_storage.delete_key(RAC_KEY_ACCESS_TOKEN, g_storage.context); + g_storage.delete_key(RAC_KEY_REFRESH_TOKEN, g_storage.context); + g_storage.delete_key(RAC_KEY_DEVICE_ID, g_storage.context); + g_storage.delete_key(RAC_KEY_USER_ID, g_storage.context); + g_storage.delete_key(RAC_KEY_ORGANIZATION_ID, g_storage.context); + } +} + +// ============================================================================= +// Persistence +// ============================================================================= + +int rac_auth_load_stored_tokens(void) { + if (!g_storage_available) { + return -1; + } + + char buffer[2048]; + + // Load access token + if (g_storage.retrieve(RAC_KEY_ACCESS_TOKEN, buffer, sizeof(buffer), g_storage.context) > 0) { + free(g_auth_state.access_token); + g_auth_state.access_token = str_dup(buffer); + } else { + return -1; // No stored token + } + + // Load refresh token + if (g_storage.retrieve(RAC_KEY_REFRESH_TOKEN, buffer, sizeof(buffer), g_storage.context) > 0) { + free(g_auth_state.refresh_token); + g_auth_state.refresh_token = str_dup(buffer); + } + + // Load device ID + if (g_storage.retrieve(RAC_KEY_DEVICE_ID, buffer, sizeof(buffer), g_storage.context) > 0) { + free(g_auth_state.device_id); + g_auth_state.device_id = str_dup(buffer); + } + + // Load user ID (optional) + if (g_storage.retrieve(RAC_KEY_USER_ID, buffer, sizeof(buffer), g_storage.context) > 0) { + free(g_auth_state.user_id); + g_auth_state.user_id = str_dup(buffer); + } + + // Load organization ID + if (g_storage.retrieve(RAC_KEY_ORGANIZATION_ID, buffer, sizeof(buffer), g_storage.context) > + 0) { + free(g_auth_state.organization_id); + g_auth_state.organization_id = str_dup(buffer); + } + + // Mark as authenticated if we have tokens + if (g_auth_state.access_token && g_auth_state.access_token[0] != '\0') { + g_auth_state.is_authenticated = true; + // Token expiry is unknown when loading, so it will trigger refresh on first use + g_auth_state.token_expires_at = 0; + } + + return 0; +} + +int rac_auth_save_tokens(void) { + if (!g_storage_available) { + return 0; // Not an error, just no-op + } + + int result = 0; + + if (g_auth_state.access_token) { + if (g_storage.store(RAC_KEY_ACCESS_TOKEN, g_auth_state.access_token, g_storage.context) != + 0) { + result = -1; + } + } + + if (g_auth_state.refresh_token) { + if (g_storage.store(RAC_KEY_REFRESH_TOKEN, g_auth_state.refresh_token, g_storage.context) != + 0) { + result = -1; + } + } + + if (g_auth_state.device_id) { + if (g_storage.store(RAC_KEY_DEVICE_ID, g_auth_state.device_id, g_storage.context) != 0) { + result = -1; + } + } + + if (g_auth_state.user_id) { + if (g_storage.store(RAC_KEY_USER_ID, g_auth_state.user_id, g_storage.context) != 0) { + result = -1; + } + } + + if (g_auth_state.organization_id) { + if (g_storage.store(RAC_KEY_ORGANIZATION_ID, g_auth_state.organization_id, + g_storage.context) != 0) { + result = -1; + } + } + + return result; +} diff --git a/sdk/runanywhere-commons/src/infrastructure/network/development_config.cpp.template b/sdk/runanywhere-commons/src/infrastructure/network/development_config.cpp.template new file mode 100644 index 000000000..cd5516000 --- /dev/null +++ b/sdk/runanywhere-commons/src/infrastructure/network/development_config.cpp.template @@ -0,0 +1,80 @@ +/** + * @file development_config.cpp.template + * @brief Template for development mode configuration + * + * SETUP INSTRUCTIONS: + * 1. Copy this file to development_config.cpp + * 2. Fill in your development credentials + * 3. development_config.cpp is git-ignored, so your secrets won't be committed + * + * For RunAnywhere team members: + * - Get credentials from the team's secure credential storage + * - Contact team lead for access to development credentials + */ + +#include + +#include "rac/core/rac_logger.h" +#include "rac/infrastructure/network/rac_dev_config.h" + +// ============================================================================= +// Configuration Values - FILL IN YOUR CREDENTIALS BELOW +// ============================================================================= + +namespace { + +// Supabase project URL for development device analytics +// Get this from: https://supabase.com/dashboard → Your Project → Settings → API +constexpr const char* SUPABASE_URL = "YOUR_SUPABASE_PROJECT_URL"; + +// Supabase anon/public API key +// Get this from: https://supabase.com/dashboard → Your Project → Settings → API → anon key +constexpr const char* SUPABASE_ANON_KEY = "YOUR_SUPABASE_ANON_KEY"; + +// Development mode build token +// Get this from your team's credential storage, or use a debug token for local dev +constexpr const char* BUILD_TOKEN = "YOUR_BUILD_TOKEN"; + +// Sentry DSN for crash reporting (optional) +// Get this from: https://sentry.io → Your Project → Settings → Client Keys (DSN) +// Set to nullptr if not using Sentry +constexpr const char* SENTRY_DSN = nullptr; + +} // anonymous namespace + +// ============================================================================= +// Public API Implementation (DO NOT MODIFY BELOW) +// ============================================================================= + +extern "C" { + +bool rac_dev_config_is_available(void) { + return SUPABASE_URL != nullptr && SUPABASE_ANON_KEY != nullptr && + std::strlen(SUPABASE_URL) > 0 && std::strlen(SUPABASE_ANON_KEY) > 0; +} + +const char* rac_dev_config_get_supabase_url(void) { + return SUPABASE_URL; +} + +const char* rac_dev_config_get_supabase_key(void) { + return SUPABASE_ANON_KEY; +} + +const char* rac_dev_config_get_build_token(void) { + return BUILD_TOKEN; +} + +const char* rac_dev_config_get_sentry_dsn(void) { + return SENTRY_DSN; +} + +bool rac_dev_config_has_supabase(void) { + return rac_dev_config_is_available(); +} + +bool rac_dev_config_has_build_token(void) { + return BUILD_TOKEN != nullptr && std::strlen(BUILD_TOKEN) > 0; +} + +} // extern "C" diff --git a/sdk/runanywhere-commons/src/infrastructure/network/endpoints.cpp b/sdk/runanywhere-commons/src/infrastructure/network/endpoints.cpp new file mode 100644 index 000000000..181d85404 --- /dev/null +++ b/sdk/runanywhere-commons/src/infrastructure/network/endpoints.cpp @@ -0,0 +1,65 @@ +/** + * @file endpoints.cpp + * @brief API endpoint implementation + */ + +#include +#include + +#include "rac/core/rac_logger.h" +#include "rac/infrastructure/network/rac_endpoints.h" + +const char* rac_endpoint_device_registration(rac_environment_t env) { + switch (env) { + case RAC_ENV_DEVELOPMENT: + return RAC_ENDPOINT_DEV_DEVICE_REGISTER; + case RAC_ENV_STAGING: + case RAC_ENV_PRODUCTION: + default: + return RAC_ENDPOINT_DEVICE_REGISTER; + } +} + +const char* rac_endpoint_telemetry(rac_environment_t env) { + switch (env) { + case RAC_ENV_DEVELOPMENT: + return RAC_ENDPOINT_DEV_TELEMETRY; + case RAC_ENV_STAGING: + case RAC_ENV_PRODUCTION: + default: + return RAC_ENDPOINT_TELEMETRY; + } +} + +const char* rac_endpoint_model_assignments(void) { + return "/api/v1/model-assignments/for-sdk"; +} + +int rac_build_url(const char* base_url, const char* endpoint, char* out_buffer, + size_t buffer_size) { + if (!base_url || !endpoint || !out_buffer || buffer_size == 0) { + return -1; + } + + // Remove trailing slash from base_url if present + size_t base_len = strlen(base_url); + while (base_len > 0 && base_url[base_len - 1] == '/') { + base_len--; + } + + // Ensure endpoint starts with / + const char* ep = endpoint; + if (*ep != '/') { + // Shouldn't happen with our constants, but handle it + int written = snprintf(out_buffer, buffer_size, "%.*s/%s", (int)base_len, base_url, ep); + return (written < 0 || (size_t)written >= buffer_size) ? -1 : written; + } + + int written = snprintf(out_buffer, buffer_size, "%.*s%s", (int)base_len, base_url, ep); + + if (written < 0 || (size_t)written >= buffer_size) { + return -1; + } + + return written; +} diff --git a/sdk/runanywhere-commons/src/infrastructure/network/environment.cpp b/sdk/runanywhere-commons/src/infrastructure/network/environment.cpp new file mode 100644 index 000000000..bbcc9a0c2 --- /dev/null +++ b/sdk/runanywhere-commons/src/infrastructure/network/environment.cpp @@ -0,0 +1,336 @@ +/** + * @file environment.cpp + * @brief SDK environment configuration implementation + */ + +#include +#include + +#include "rac/core/rac_logger.h" +#include "rac/core/rac_types.h" +#include "rac/infrastructure/network/rac_environment.h" + +// ============================================================================= +// Global State +// ============================================================================= + +static bool g_sdk_initialized = false; +static rac_sdk_config_t g_sdk_config = {}; + +// Static storage for config strings (to avoid dangling pointers) +static char g_api_key[256] = {0}; +static char g_base_url[512] = {0}; +static char g_device_id[128] = {0}; +static char g_platform[32] = {0}; +static char g_sdk_version[32] = {0}; + +// ============================================================================= +// Environment Query Functions +// ============================================================================= + +bool rac_env_requires_auth(rac_environment_t env) { + return env != RAC_ENV_DEVELOPMENT; +} + +bool rac_env_requires_backend_url(rac_environment_t env) { + return env != RAC_ENV_DEVELOPMENT; +} + +bool rac_env_is_production(rac_environment_t env) { + return env == RAC_ENV_PRODUCTION; +} + +bool rac_env_is_testing(rac_environment_t env) { + return env == RAC_ENV_DEVELOPMENT || env == RAC_ENV_STAGING; +} + +rac_log_level_t rac_env_default_log_level(rac_environment_t env) { + switch (env) { + case RAC_ENV_DEVELOPMENT: + return RAC_LOG_DEBUG; // From rac_types.h: 1 + case RAC_ENV_STAGING: + return RAC_LOG_INFO; // From rac_types.h: 2 + case RAC_ENV_PRODUCTION: + return RAC_LOG_WARNING; // From rac_types.h: 3 + default: + return RAC_LOG_INFO; + } +} + +bool rac_env_should_send_telemetry(rac_environment_t env) { + return env == RAC_ENV_PRODUCTION; +} + +bool rac_env_should_sync_with_backend(rac_environment_t env) { + return env != RAC_ENV_DEVELOPMENT; +} + +const char* rac_env_description(rac_environment_t env) { + switch (env) { + case RAC_ENV_DEVELOPMENT: + return "Development Environment"; + case RAC_ENV_STAGING: + return "Staging Environment"; + case RAC_ENV_PRODUCTION: + return "Production Environment"; + default: + return "Unknown Environment"; + } +} + +// ============================================================================= +// URL Parsing Helpers +// ============================================================================= + +// Simple URL scheme extraction +static bool extract_url_scheme(const char* url, char* scheme, size_t scheme_size) { + if (!url || !scheme || scheme_size == 0) + return false; + + const char* colon = strchr(url, ':'); + if (!colon) + return false; + + size_t len = colon - url; + if (len >= scheme_size) + return false; + + for (size_t i = 0; i < len; i++) { + scheme[i] = (char)tolower((unsigned char)url[i]); + } + scheme[len] = '\0'; + return true; +} + +// Simple URL host extraction (after ://) +static bool extract_url_host(const char* url, char* host, size_t host_size) { + if (!url || !host || host_size == 0) + return false; + + const char* start = strstr(url, "://"); + if (!start) + return false; + start += 3; // Skip "://" + + // Find end of host (port, path, or end of string) + const char* end = start; + while (*end && *end != ':' && *end != '/' && *end != '?' && *end != '#') { + end++; + } + + size_t len = end - start; + if (len == 0 || len >= host_size) + return false; + + for (size_t i = 0; i < len; i++) { + host[i] = (char)tolower((unsigned char)start[i]); + } + host[len] = '\0'; + return true; +} + +// Check if host is localhost-like +static bool is_localhost_host(const char* host) { + if (!host) + return false; + return strstr(host, "localhost") != nullptr || strstr(host, "127.0.0.1") != nullptr || + strstr(host, "example.com") != nullptr || strstr(host, ".local") != nullptr; +} + +// ============================================================================= +// Validation Functions +// ============================================================================= + +rac_validation_result_t rac_validate_api_key(const char* api_key, rac_environment_t env) { + // Development mode doesn't require API key + if (!rac_env_requires_auth(env)) { + return RAC_VALIDATION_OK; + } + + // Staging/Production require API key + if (!api_key || api_key[0] == '\0') { + return RAC_VALIDATION_API_KEY_REQUIRED; + } + + // Basic length check (at least 10 characters) + if (strlen(api_key) < 10) { + return RAC_VALIDATION_API_KEY_TOO_SHORT; + } + + return RAC_VALIDATION_OK; +} + +rac_validation_result_t rac_validate_base_url(const char* url, rac_environment_t env) { + // Development mode doesn't require URL + if (!rac_env_requires_backend_url(env)) { + return RAC_VALIDATION_OK; + } + + // Staging/Production require URL + if (!url || url[0] == '\0') { + return RAC_VALIDATION_URL_REQUIRED; + } + + // Extract and validate scheme + char scheme[16] = {0}; + if (!extract_url_scheme(url, scheme, sizeof(scheme))) { + return RAC_VALIDATION_URL_INVALID_SCHEME; + } + + // Production requires HTTPS + if (env == RAC_ENV_PRODUCTION) { + if (strcmp(scheme, "https") != 0) { + return RAC_VALIDATION_URL_HTTPS_REQUIRED; + } + } else if (env == RAC_ENV_STAGING) { + // Staging allows HTTP or HTTPS + if (strcmp(scheme, "https") != 0 && strcmp(scheme, "http") != 0) { + return RAC_VALIDATION_URL_INVALID_SCHEME; + } + } + + // Extract and validate host + char host[256] = {0}; + if (!extract_url_host(url, host, sizeof(host))) { + return RAC_VALIDATION_URL_INVALID_HOST; + } + + if (host[0] == '\0') { + return RAC_VALIDATION_URL_INVALID_HOST; + } + + // Production cannot use localhost/example URLs + if (env == RAC_ENV_PRODUCTION && is_localhost_host(host)) { + return RAC_VALIDATION_URL_LOCALHOST_NOT_ALLOWED; + } + + return RAC_VALIDATION_OK; +} + +rac_validation_result_t rac_validate_config(const rac_sdk_config_t* config) { + if (!config) { + return RAC_VALIDATION_API_KEY_REQUIRED; + } + + rac_validation_result_t result; + + // Validate API key + result = rac_validate_api_key(config->api_key, config->environment); + if (result != RAC_VALIDATION_OK) { + return result; + } + + // Validate URL + result = rac_validate_base_url(config->base_url, config->environment); + if (result != RAC_VALIDATION_OK) { + return result; + } + + return RAC_VALIDATION_OK; +} + +const char* rac_validation_error_message(rac_validation_result_t result) { + switch (result) { + case RAC_VALIDATION_OK: + return "Validation successful"; + case RAC_VALIDATION_API_KEY_REQUIRED: + return "API key is required for this environment"; + case RAC_VALIDATION_API_KEY_TOO_SHORT: + return "API key appears to be invalid (too short)"; + case RAC_VALIDATION_URL_REQUIRED: + return "Base URL is required for this environment"; + case RAC_VALIDATION_URL_INVALID_SCHEME: + return "Base URL must have a valid scheme (http or https)"; + case RAC_VALIDATION_URL_HTTPS_REQUIRED: + return "Production environment requires HTTPS"; + case RAC_VALIDATION_URL_INVALID_HOST: + return "Base URL must have a valid host"; + case RAC_VALIDATION_URL_LOCALHOST_NOT_ALLOWED: + return "Production environment cannot use localhost or example URLs"; + case RAC_VALIDATION_PRODUCTION_DEBUG_BUILD: + return "Production environment cannot be used in DEBUG builds"; + default: + return "Unknown validation error"; + } +} + +// ============================================================================= +// Global SDK State Functions +// ============================================================================= + +// Helper to safely copy string +static void safe_strcpy(char* dest, size_t dest_size, const char* src) { + if (!dest || dest_size == 0) + return; + if (!src) { + dest[0] = '\0'; + return; + } + size_t len = strlen(src); + if (len >= dest_size) { + len = dest_size - 1; + } + memcpy(dest, src, len); + dest[len] = '\0'; +} + +rac_validation_result_t rac_sdk_init(const rac_sdk_config_t* config) { + if (!config) { + return RAC_VALIDATION_API_KEY_REQUIRED; + } + + // Validate configuration + rac_validation_result_t result = rac_validate_config(config); + if (result != RAC_VALIDATION_OK) { + return result; + } + + // Store configuration with deep copy of strings + g_sdk_config.environment = config->environment; + + safe_strcpy(g_api_key, sizeof(g_api_key), config->api_key); + g_sdk_config.api_key = g_api_key; + + safe_strcpy(g_base_url, sizeof(g_base_url), config->base_url); + g_sdk_config.base_url = g_base_url; + + safe_strcpy(g_device_id, sizeof(g_device_id), config->device_id); + g_sdk_config.device_id = g_device_id; + + safe_strcpy(g_platform, sizeof(g_platform), config->platform); + g_sdk_config.platform = g_platform; + + safe_strcpy(g_sdk_version, sizeof(g_sdk_version), config->sdk_version); + g_sdk_config.sdk_version = g_sdk_version; + + g_sdk_initialized = true; + return RAC_VALIDATION_OK; +} + +const rac_sdk_config_t* rac_sdk_get_config(void) { + if (!g_sdk_initialized) { + return nullptr; + } + return &g_sdk_config; +} + +rac_environment_t rac_sdk_get_environment(void) { + if (!g_sdk_initialized) { + return RAC_ENV_DEVELOPMENT; + } + return g_sdk_config.environment; +} + +bool rac_sdk_is_initialized(void) { + return g_sdk_initialized; +} + +void rac_sdk_reset(void) { + g_sdk_initialized = false; + memset(&g_sdk_config, 0, sizeof(g_sdk_config)); + memset(g_api_key, 0, sizeof(g_api_key)); + memset(g_base_url, 0, sizeof(g_base_url)); + memset(g_device_id, 0, sizeof(g_device_id)); + memset(g_platform, 0, sizeof(g_platform)); + memset(g_sdk_version, 0, sizeof(g_sdk_version)); +} diff --git a/sdk/runanywhere-commons/src/infrastructure/network/http_client.cpp b/sdk/runanywhere-commons/src/infrastructure/network/http_client.cpp new file mode 100644 index 000000000..c77718ac1 --- /dev/null +++ b/sdk/runanywhere-commons/src/infrastructure/network/http_client.cpp @@ -0,0 +1,252 @@ +/** + * @file http_client.cpp + * @brief HTTP client implementation + */ + +#include +#include +#include +#include + +#include "rac/core/rac_logger.h" +#include "rac/infrastructure/network/rac_http_client.h" + +// ============================================================================= +// Global State +// ============================================================================= + +static rac_http_executor_t g_http_executor = nullptr; + +// ============================================================================= +// Response Management +// ============================================================================= + +void rac_http_response_free(rac_http_response_t* response) { + if (!response) + return; + + free(response->body); + free(response->error_message); + + if (response->headers) { + for (size_t i = 0; i < response->header_count; i++) { + free((void*)response->headers[i].key); + free((void*)response->headers[i].value); + } + free(response->headers); + } + + memset(response, 0, sizeof(*response)); +} + +// ============================================================================= +// Platform Callback Interface +// ============================================================================= + +void rac_http_set_executor(rac_http_executor_t executor) { + g_http_executor = executor; +} + +bool rac_http_has_executor(void) { + return g_http_executor != nullptr; +} + +// ============================================================================= +// Request Building +// ============================================================================= + +static char* str_dup(const char* src) { + if (!src) + return nullptr; + size_t len = strlen(src); + char* dst = (char*)malloc(len + 1); + if (dst) { + memcpy(dst, src, len + 1); + } + return dst; +} + +rac_http_request_t* rac_http_request_create(rac_http_method_t method, const char* url) { + rac_http_request_t* request = (rac_http_request_t*)calloc(1, sizeof(rac_http_request_t)); + if (!request) + return nullptr; + + request->method = method; + request->url = str_dup(url); + request->timeout_ms = 30000; // Default 30s timeout + + return request; +} + +void rac_http_request_set_body(rac_http_request_t* request, const char* body) { + if (!request) + return; + + free((void*)request->body); + request->body = str_dup(body); + request->body_length = body ? strlen(body) : 0; +} + +void rac_http_request_add_header(rac_http_request_t* request, const char* key, const char* value) { + if (!request || !key || !value) + return; + + // Reallocate headers array + size_t new_count = request->header_count + 1; + rac_http_header_t* new_headers = + (rac_http_header_t*)realloc(request->headers, new_count * sizeof(rac_http_header_t)); + + if (!new_headers) + return; + + request->headers = new_headers; + request->headers[request->header_count].key = str_dup(key); + request->headers[request->header_count].value = str_dup(value); + request->header_count = new_count; +} + +void rac_http_request_set_timeout(rac_http_request_t* request, int32_t timeout_ms) { + if (!request) + return; + request->timeout_ms = timeout_ms; +} + +void rac_http_request_free(rac_http_request_t* request) { + if (!request) + return; + + free((void*)request->url); + free((void*)request->body); + + if (request->headers) { + for (size_t i = 0; i < request->header_count; i++) { + free((void*)request->headers[i].key); + free((void*)request->headers[i].value); + } + free(request->headers); + } + + free(request); +} + +// ============================================================================= +// Standard Headers +// ============================================================================= + +void rac_http_add_sdk_headers(rac_http_request_t* request, const char* sdk_version, + const char* platform) { + if (!request) + return; + + rac_http_request_add_header(request, "Content-Type", "application/json"); + rac_http_request_add_header(request, "X-SDK-Client", "RunAnywhereSDK"); + + if (sdk_version) { + rac_http_request_add_header(request, "X-SDK-Version", sdk_version); + } + if (platform) { + rac_http_request_add_header(request, "X-Platform", platform); + } + + // Supabase compatibility + rac_http_request_add_header(request, "Prefer", "return=representation"); +} + +void rac_http_add_auth_header(rac_http_request_t* request, const char* token) { + if (!request || !token) + return; + + char bearer[1024]; + snprintf(bearer, sizeof(bearer), "Bearer %s", token); + rac_http_request_add_header(request, "Authorization", bearer); +} + +void rac_http_add_api_key_header(rac_http_request_t* request, const char* api_key) { + if (!request || !api_key) + return; + + // Supabase-style apikey header + rac_http_request_add_header(request, "apikey", api_key); +} + +// ============================================================================= +// High-Level Request Functions +// ============================================================================= + +// Internal callback handler +static void internal_callback(const rac_http_response_t* response, void* user_data) { + rac_http_context_t* context = (rac_http_context_t*)user_data; + if (!context) + return; + + if (response->status_code >= 200 && response->status_code < 300) { + if (context->on_success) { + context->on_success(response->body, context->user_data); + } + } else { + if (context->on_error) { + const char* error_msg = response->error_message + ? response->error_message + : (response->body ? response->body : "Unknown error"); + context->on_error(response->status_code, error_msg, context->user_data); + } + } +} + +void rac_http_execute(const rac_http_request_t* request, rac_http_context_t* context) { + if (!request || !context) + return; + + if (!g_http_executor) { + if (context->on_error) { + context->on_error(-1, "HTTP executor not registered", context->user_data); + } + return; + } + + g_http_executor(request, internal_callback, context); +} + +void rac_http_post_json(const char* url, const char* json_body, const char* auth_token, + rac_http_context_t* context) { + if (!url || !context) + return; + + rac_http_request_t* request = rac_http_request_create(RAC_HTTP_POST, url); + if (!request) { + if (context->on_error) { + context->on_error(-1, "Failed to create request", context->user_data); + } + return; + } + + rac_http_request_set_body(request, json_body); + rac_http_request_add_header(request, "Content-Type", "application/json"); + + if (auth_token) { + rac_http_add_auth_header(request, auth_token); + } + + rac_http_execute(request, context); + rac_http_request_free(request); +} + +void rac_http_get(const char* url, const char* auth_token, rac_http_context_t* context) { + if (!url || !context) + return; + + rac_http_request_t* request = rac_http_request_create(RAC_HTTP_GET, url); + if (!request) { + if (context->on_error) { + context->on_error(-1, "Failed to create request", context->user_data); + } + return; + } + + if (auth_token) { + rac_http_add_auth_header(request, auth_token); + } + + rac_http_execute(request, context); + rac_http_request_free(request); +} diff --git a/sdk/runanywhere-commons/src/infrastructure/registry/module_registry.cpp b/sdk/runanywhere-commons/src/infrastructure/registry/module_registry.cpp new file mode 100644 index 000000000..4504b7d98 --- /dev/null +++ b/sdk/runanywhere-commons/src/infrastructure/registry/module_registry.cpp @@ -0,0 +1,247 @@ +/** + * @file module_registry.cpp + * @brief RunAnywhere Commons - Module Registry Implementation + * + * C++ port of Swift's ModuleRegistry.swift + * Provides: + * - Module registration with capabilities + * - Module discovery and introspection + * - Prevention of duplicate registration + * + * Uses function-local statics to avoid static initialization order issues + * when called from Swift. + */ + +#include +#include +#include +#include +#include +#include + +#include "rac/core/rac_core.h" +#include "rac/core/rac_error.h" +#include "rac/core/rac_logger.h" +#include "rac/core/rac_platform_adapter.h" + +// Category for logging +static const char* LOG_CAT = "ModuleRegistry"; + +// ============================================================================= +// INTERNAL STORAGE - Using function-local statics for safe initialization +// ============================================================================= + +namespace { + +// Deep-copy module info to avoid dangling pointers +struct ModuleEntry { + std::string id; + std::string name; + std::string version; + std::string description; + std::vector capabilities; + + // For C API return + rac_module_info_t to_c_info() const { + rac_module_info_t info = {}; + info.id = id.c_str(); + info.name = name.c_str(); + info.version = version.c_str(); + info.description = description.c_str(); + info.capabilities = capabilities.data(); + info.num_capabilities = capabilities.size(); + return info; + } +}; + +/** + * Module registry state using function-local static to ensure proper initialization. + * This avoids the "static initialization order fiasco" when Swift calls + * into C++ code before global statics are initialized. + */ +struct ModuleRegistryState { + std::mutex mutex; + std::unordered_map modules; + std::vector module_list_cache; + std::vector capability_query_cache; + bool cache_dirty = true; +}; + +/** + * Get the module registry state singleton using Meyers' singleton pattern. + * Function-local static guarantees thread-safe initialization on first use. + * NOTE: No logging here - this is called during static initialization + */ +ModuleRegistryState& get_state() { + static ModuleRegistryState state; + return state; +} + +void rebuild_cache(ModuleRegistryState& state) { + if (!state.cache_dirty) { + return; + } + + state.module_list_cache.clear(); + state.module_list_cache.reserve(state.modules.size()); + + for (const auto& pair : state.modules) { + state.module_list_cache.push_back(pair.second.to_c_info()); + } + + state.cache_dirty = false; +} + +} // namespace + +// ============================================================================= +// MODULE REGISTRATION API +// ============================================================================= + +extern "C" { + +rac_result_t rac_module_register(const rac_module_info_t* info) { + RAC_LOG_DEBUG(LOG_CAT, "rac_module_register() - ENTRY"); + + if (info == nullptr || info->id == nullptr) { + RAC_LOG_ERROR(LOG_CAT, "rac_module_register() - NULL pointer error"); + return RAC_ERROR_NULL_POINTER; + } + + RAC_LOG_DEBUG(LOG_CAT, "Registering module: %s", info->id); + + auto& state = get_state(); + std::lock_guard lock(state.mutex); + + std::string module_id = info->id; + + // Check for duplicate registration (matches Swift's behavior) + if (state.modules.find(module_id) != state.modules.end()) { + RAC_LOG_WARNING(LOG_CAT, "Module already registered, skipping: %s", module_id.c_str()); + rac_error_set_details("Module already registered, skipping"); + return RAC_ERROR_MODULE_ALREADY_REGISTERED; + } + + // Create deep copy + ModuleEntry entry; + entry.id = info->id; + entry.name = info->name ? info->name : info->id; + entry.version = info->version ? info->version : ""; + entry.description = info->description ? info->description : ""; + + if (info->capabilities != nullptr && info->num_capabilities > 0) { + entry.capabilities.assign(info->capabilities, info->capabilities + info->num_capabilities); + } + + state.modules[module_id] = std::move(entry); + state.cache_dirty = true; + + RAC_LOG_INFO(LOG_CAT, "Module registered: %s", module_id.c_str()); + return RAC_SUCCESS; +} + +rac_result_t rac_module_unregister(const char* module_id) { + RAC_LOG_DEBUG(LOG_CAT, "rac_module_unregister() - id=%s", module_id ? module_id : "NULL"); + + if (module_id == nullptr) { + return RAC_ERROR_NULL_POINTER; + } + + auto& state = get_state(); + std::lock_guard lock(state.mutex); + + auto it = state.modules.find(module_id); + if (it == state.modules.end()) { + RAC_LOG_WARNING(LOG_CAT, "Module not found: %s", module_id); + return RAC_ERROR_MODULE_NOT_FOUND; + } + + state.modules.erase(it); + state.cache_dirty = true; + + RAC_LOG_INFO(LOG_CAT, "Module unregistered: %s", module_id); + return RAC_SUCCESS; +} + +rac_result_t rac_module_list(const rac_module_info_t** out_modules, size_t* out_count) { + if (out_modules == nullptr || out_count == nullptr) { + return RAC_ERROR_NULL_POINTER; + } + + auto& state = get_state(); + std::lock_guard lock(state.mutex); + rebuild_cache(state); + + *out_modules = state.module_list_cache.data(); + *out_count = state.module_list_cache.size(); + + return RAC_SUCCESS; +} + +rac_result_t rac_modules_for_capability(rac_capability_t capability, + const rac_module_info_t** out_modules, size_t* out_count) { + if (out_modules == nullptr || out_count == nullptr) { + return RAC_ERROR_NULL_POINTER; + } + + auto& state = get_state(); + std::lock_guard lock(state.mutex); + + // Rebuild capability query cache + state.capability_query_cache.clear(); + + for (const auto& pair : state.modules) { + const auto& entry = pair.second; + for (auto cap : entry.capabilities) { + if (cap == capability) { + state.capability_query_cache.push_back(entry.to_c_info()); + break; + } + } + } + + *out_modules = state.capability_query_cache.data(); + *out_count = state.capability_query_cache.size(); + + return RAC_SUCCESS; +} + +rac_result_t rac_module_get_info(const char* module_id, const rac_module_info_t** out_info) { + if (module_id == nullptr || out_info == nullptr) { + return RAC_ERROR_NULL_POINTER; + } + + auto& state = get_state(); + std::lock_guard lock(state.mutex); + rebuild_cache(state); + + // Find in cache + for (const auto& info : state.module_list_cache) { + if (strcmp(info.id, module_id) == 0) { + *out_info = &info; + return RAC_SUCCESS; + } + } + + return RAC_ERROR_MODULE_NOT_FOUND; +} + +} // extern "C" + +// ============================================================================= +// INTERNAL RESET (for testing) +// ============================================================================= + +namespace rac_internal { + +void reset_module_registry() { + RAC_LOG_DEBUG(LOG_CAT, "reset_module_registry()"); + auto& state = get_state(); + std::lock_guard lock(state.mutex); + state.modules.clear(); + state.module_list_cache.clear(); + state.capability_query_cache.clear(); + state.cache_dirty = true; +} + +} // namespace rac_internal diff --git a/sdk/runanywhere-commons/src/infrastructure/registry/service_registry.cpp b/sdk/runanywhere-commons/src/infrastructure/registry/service_registry.cpp new file mode 100644 index 000000000..0613eddae --- /dev/null +++ b/sdk/runanywhere-commons/src/infrastructure/registry/service_registry.cpp @@ -0,0 +1,258 @@ +/** + * @file service_registry.cpp + * @brief RunAnywhere Commons - Service Registry Implementation + * + * C++ port of Swift's ServiceRegistry.swift + * Provides: + * - Service provider registration with priority + * - canHandle-style service creation (matches Swift pattern) + * - Priority-based provider selection + * + * Uses function-local statics to avoid static initialization order issues + * when called from Swift. + */ + +#include +#include +#include +#include +#include +#include + +#include "rac/core/rac_core.h" +#include "rac/core/rac_error.h" +#include "rac/core/rac_logger.h" +#include "rac/core/rac_platform_adapter.h" + +// Category for logging +static const char* LOG_CAT = "ServiceRegistry"; + +// ============================================================================= +// INTERNAL STORAGE - Using function-local statics for safe initialization +// ============================================================================= + +namespace { + +// Provider entry - mirrors Swift's ServiceRegistration +struct ProviderEntry { + std::string name; + rac_capability_t capability; + int32_t priority; + rac_service_can_handle_fn can_handle; + rac_service_create_fn create; + void* user_data; +}; + +/** + * Service registry state using function-local static to ensure proper initialization. + * This avoids the "static initialization order fiasco" when Swift calls + * into C++ code before global statics are initialized. + */ +struct ServiceRegistryState { + std::mutex mutex; + // Providers grouped by capability + std::unordered_map> providers; +}; + +/** + * Get the service registry state singleton using Meyers' singleton pattern. + * Function-local static guarantees thread-safe initialization on first use. + * NOTE: No logging here - this is called during static initialization + */ +ServiceRegistryState& get_state() { + static ServiceRegistryState state; + return state; +} + +} // namespace + +// ============================================================================= +// SERVICE REGISTRATION API +// ============================================================================= + +extern "C" { + +rac_result_t rac_service_register_provider(const rac_service_provider_t* provider) { + RAC_LOG_DEBUG(LOG_CAT, "rac_service_register_provider() - ENTRY"); + + if (provider == nullptr || provider->name == nullptr) { + RAC_LOG_ERROR(LOG_CAT, "NULL pointer error"); + return RAC_ERROR_NULL_POINTER; + } + + RAC_LOG_DEBUG(LOG_CAT, "Registering provider: %s", provider->name); + + if (provider->can_handle == nullptr || provider->create == nullptr) { + RAC_LOG_ERROR(LOG_CAT, "can_handle or create is NULL for provider: %s", provider->name); + rac_error_set_details("can_handle and create functions are required"); + return RAC_ERROR_NULL_POINTER; + } + + auto& state = get_state(); + std::lock_guard lock(state.mutex); + + ProviderEntry entry; + entry.name = provider->name; + entry.capability = provider->capability; + entry.priority = provider->priority; + entry.can_handle = provider->can_handle; + entry.create = provider->create; + entry.user_data = provider->user_data; + + state.providers[provider->capability].push_back(std::move(entry)); + + // Sort by priority (higher first) - matches Swift's sorted(by: { $0.priority > $1.priority }) + auto& providers = state.providers[provider->capability]; + std::sort( + providers.begin(), providers.end(), + [](const ProviderEntry& a, const ProviderEntry& b) { return a.priority > b.priority; }); + + RAC_LOG_INFO(LOG_CAT, "Registered provider: %s for capability %d", provider->name, + static_cast(provider->capability)); + return RAC_SUCCESS; +} + +rac_result_t rac_service_unregister_provider(const char* name, rac_capability_t capability) { + RAC_LOG_DEBUG(LOG_CAT, "rac_service_unregister_provider() - name=%s", name ? name : "NULL"); + + if (name == nullptr) { + return RAC_ERROR_NULL_POINTER; + } + + auto& state = get_state(); + std::lock_guard lock(state.mutex); + + auto it = state.providers.find(capability); + if (it == state.providers.end()) { + RAC_LOG_WARNING(LOG_CAT, "Provider not found for capability %d", + static_cast(capability)); + return RAC_ERROR_PROVIDER_NOT_FOUND; + } + + auto& providers = it->second; + auto remove_it = + std::remove_if(providers.begin(), providers.end(), + [name](const ProviderEntry& entry) { return entry.name == name; }); + + if (remove_it == providers.end()) { + return RAC_ERROR_PROVIDER_NOT_FOUND; + } + + providers.erase(remove_it, providers.end()); + + if (providers.empty()) { + state.providers.erase(it); + } + + RAC_LOG_INFO(LOG_CAT, "Provider unregistered: %s", name); + return RAC_SUCCESS; +} + +rac_result_t rac_service_create(rac_capability_t capability, const rac_service_request_t* request, + rac_handle_t* out_handle) { + RAC_LOG_INFO(LOG_CAT, "rac_service_create called for capability=%d, identifier=%s", + static_cast(capability), + request ? (request->identifier ? request->identifier : "(null)") + : "(null request)"); + + if (request == nullptr || out_handle == nullptr) { + RAC_LOG_ERROR(LOG_CAT, "rac_service_create: null pointer"); + return RAC_ERROR_NULL_POINTER; + } + + auto& state = get_state(); + std::lock_guard lock(state.mutex); + + auto it = state.providers.find(capability); + if (it == state.providers.end() || it->second.empty()) { + RAC_LOG_ERROR(LOG_CAT, "rac_service_create: No providers registered for capability %d", + static_cast(capability)); + rac_error_set_details("No providers registered for capability"); + return RAC_ERROR_NO_CAPABLE_PROVIDER; + } + + RAC_LOG_INFO(LOG_CAT, "rac_service_create: Found %zu providers for capability %d", + it->second.size(), static_cast(capability)); + + // Find first provider that can handle the request (already sorted by priority) + // This matches Swift's pattern: registrations.sorted(by:).first(where: canHandle) + for (const auto& provider : it->second) { + RAC_LOG_INFO(LOG_CAT, "rac_service_create: Checking provider '%s' (priority=%d)", + provider.name.c_str(), provider.priority); + + bool can_handle = provider.can_handle(request, provider.user_data); + RAC_LOG_INFO(LOG_CAT, "rac_service_create: Provider '%s' can_handle=%s", + provider.name.c_str(), can_handle ? "TRUE" : "FALSE"); + + if (can_handle) { + RAC_LOG_INFO(LOG_CAT, "rac_service_create: Calling create for provider '%s'", + provider.name.c_str()); + rac_handle_t handle = provider.create(request, provider.user_data); + if (handle != nullptr) { + *out_handle = handle; + RAC_LOG_INFO(LOG_CAT, + "rac_service_create: Service created by provider '%s', handle=%p", + provider.name.c_str(), handle); + return RAC_SUCCESS; + } else { + RAC_LOG_ERROR(LOG_CAT, "rac_service_create: Provider '%s' create returned nullptr", + provider.name.c_str()); + } + } + } + + RAC_LOG_ERROR(LOG_CAT, "rac_service_create: No provider could handle the request"); + rac_error_set_details("No provider could handle the request"); + return RAC_ERROR_NO_CAPABLE_PROVIDER; +} + +rac_result_t rac_service_list_providers(rac_capability_t capability, const char*** out_names, + size_t* out_count) { + if (out_names == nullptr || out_count == nullptr) { + return RAC_ERROR_NULL_POINTER; + } + + auto& state = get_state(); + std::lock_guard lock(state.mutex); + + // Static storage for names (valid until next call) + static std::vector s_name_ptrs; + static std::vector s_names; + + s_names.clear(); + s_name_ptrs.clear(); + + auto it = state.providers.find(capability); + if (it != state.providers.end()) { + for (const auto& provider : it->second) { + s_names.push_back(provider.name); + } + } + + s_name_ptrs.reserve(s_names.size()); + for (const auto& name : s_names) { + s_name_ptrs.push_back(name.c_str()); + } + + *out_names = s_name_ptrs.data(); + *out_count = s_name_ptrs.size(); + + return RAC_SUCCESS; +} + +} // extern "C" + +// ============================================================================= +// INTERNAL RESET (for testing) +// ============================================================================= + +namespace rac_internal { + +void reset_service_registry() { + RAC_LOG_DEBUG(LOG_CAT, "reset_service_registry()"); + auto& state = get_state(); + std::lock_guard lock(state.mutex); + state.providers.clear(); +} + +} // namespace rac_internal diff --git a/sdk/runanywhere-commons/src/infrastructure/storage/storage_analyzer.cpp b/sdk/runanywhere-commons/src/infrastructure/storage/storage_analyzer.cpp new file mode 100644 index 000000000..a620af0f2 --- /dev/null +++ b/sdk/runanywhere-commons/src/infrastructure/storage/storage_analyzer.cpp @@ -0,0 +1,302 @@ +/** + * @file storage_analyzer.cpp + * @brief Storage Analyzer Implementation + * + * Business logic for storage analysis. + * - Uses rac_model_registry for model listing + * - Uses rac_model_paths for path calculations + * - Calls platform callbacks for file operations + */ + +#include +#include +#include +#include + +#include "rac/core/rac_logger.h" +#include "rac/infrastructure/model_management/rac_model_paths.h" +#include "rac/infrastructure/model_management/rac_model_registry.h" +#include "rac/infrastructure/storage/rac_storage_analyzer.h" + +// ============================================================================= +// INTERNAL STRUCTURES +// ============================================================================= + +struct rac_storage_analyzer { + rac_storage_callbacks_t callbacks; +}; + +// ============================================================================= +// LIFECYCLE +// ============================================================================= + +rac_result_t rac_storage_analyzer_create(const rac_storage_callbacks_t* callbacks, + rac_storage_analyzer_handle_t* out_handle) { + if (!callbacks || !out_handle) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + // Validate required callbacks + if (!callbacks->calculate_dir_size || !callbacks->get_available_space || + !callbacks->get_total_space) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + auto* analyzer = new (std::nothrow) rac_storage_analyzer(); + if (!analyzer) { + return RAC_ERROR_OUT_OF_MEMORY; + } + + analyzer->callbacks = *callbacks; + *out_handle = analyzer; + return RAC_SUCCESS; +} + +void rac_storage_analyzer_destroy(rac_storage_analyzer_handle_t handle) { + delete handle; +} + +// ============================================================================= +// STORAGE ANALYSIS +// ============================================================================= + +rac_result_t rac_storage_analyzer_analyze(rac_storage_analyzer_handle_t handle, + rac_model_registry_handle_t registry_handle, + rac_storage_info_t* out_info) { + if (!handle || !registry_handle || !out_info) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + // Initialize output + memset(out_info, 0, sizeof(rac_storage_info_t)); + + // Get device storage via callbacks + out_info->device_storage.free_space = + handle->callbacks.get_available_space(handle->callbacks.user_data); + out_info->device_storage.total_space = + handle->callbacks.get_total_space(handle->callbacks.user_data); + out_info->device_storage.used_space = + out_info->device_storage.total_space - out_info->device_storage.free_space; + + // Get app storage - calculate base directory size + char base_dir[1024]; + if (rac_model_paths_get_base_directory(base_dir, sizeof(base_dir)) == RAC_SUCCESS) { + out_info->app_storage.documents_size = + handle->callbacks.calculate_dir_size(base_dir, handle->callbacks.user_data); + out_info->app_storage.total_size = out_info->app_storage.documents_size; + } + + // Get downloaded models from registry + rac_model_info_t** models = nullptr; + size_t model_count = 0; + + rac_result_t result = rac_model_registry_get_downloaded(registry_handle, &models, &model_count); + if (result != RAC_SUCCESS) { + // No models is okay, just return empty + out_info->models = nullptr; + out_info->model_count = 0; + return RAC_SUCCESS; + } + + // Allocate model metrics array + if (model_count > 0) { + out_info->models = static_cast( + calloc(model_count, sizeof(rac_model_storage_metrics_t))); + if (!out_info->models) { + rac_model_info_array_free(models, model_count); + return RAC_ERROR_OUT_OF_MEMORY; + } + } + + out_info->model_count = model_count; + out_info->total_models_size = 0; + + // Calculate metrics for each model + for (size_t i = 0; i < model_count; i++) { + const rac_model_info_t* model = models[i]; + rac_model_storage_metrics_t* metrics = &out_info->models[i]; + + // Copy model info + metrics->model_id = model->id ? strdup(model->id) : nullptr; + metrics->model_name = model->name ? strdup(model->name) : nullptr; + metrics->framework = model->framework; + metrics->format = model->format; + metrics->artifact_info = model->artifact_info; + + // Get path - either from model or calculate from model_paths + char path_buffer[1024]; + const char* path_to_use = nullptr; + + if (model->local_path && strlen(model->local_path) > 0) { + path_to_use = model->local_path; + metrics->local_path = strdup(model->local_path); + } else if (model->id) { + // Calculate path using rac_model_paths + if (rac_model_paths_get_model_folder(model->id, model->framework, path_buffer, + sizeof(path_buffer)) == RAC_SUCCESS) { + path_to_use = path_buffer; + metrics->local_path = strdup(path_buffer); + } + } + + // Calculate size via callback + if (path_to_use) { + metrics->size_on_disk = + handle->callbacks.calculate_dir_size(path_to_use, handle->callbacks.user_data); + } else { + // Fallback to download size if we can't calculate + metrics->size_on_disk = model->download_size; + } + + out_info->total_models_size += metrics->size_on_disk; + } + + // Free the models array from registry + rac_model_info_array_free(models, model_count); + + return RAC_SUCCESS; +} + +rac_result_t rac_storage_analyzer_get_model_metrics(rac_storage_analyzer_handle_t handle, + rac_model_registry_handle_t registry_handle, + const char* model_id, + rac_inference_framework_t framework, + rac_model_storage_metrics_t* out_metrics) { + if (!handle || !registry_handle || !model_id || !out_metrics) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + // Get model from registry + rac_model_info_t* model = nullptr; + rac_result_t result = rac_model_registry_get(registry_handle, model_id, &model); + if (result != RAC_SUCCESS || !model) { + return RAC_ERROR_NOT_FOUND; + } + + // Initialize output + memset(out_metrics, 0, sizeof(rac_model_storage_metrics_t)); + + // Copy model info + out_metrics->model_id = model->id ? strdup(model->id) : nullptr; + out_metrics->model_name = model->name ? strdup(model->name) : nullptr; + out_metrics->framework = model->framework; + out_metrics->format = model->format; + out_metrics->artifact_info = model->artifact_info; + + // Get path + char path_buffer[1024]; + const char* path_to_use = nullptr; + + if (model->local_path && strlen(model->local_path) > 0) { + path_to_use = model->local_path; + out_metrics->local_path = strdup(model->local_path); + } else { + if (rac_model_paths_get_model_folder(model_id, framework, path_buffer, + sizeof(path_buffer)) == RAC_SUCCESS) { + path_to_use = path_buffer; + out_metrics->local_path = strdup(path_buffer); + } + } + + // Calculate size + if (path_to_use) { + out_metrics->size_on_disk = + handle->callbacks.calculate_dir_size(path_to_use, handle->callbacks.user_data); + } else { + out_metrics->size_on_disk = model->download_size; + } + + rac_model_info_free(model); + return RAC_SUCCESS; +} + +rac_result_t rac_storage_analyzer_check_available(rac_storage_analyzer_handle_t handle, + int64_t model_size, double safety_margin, + rac_storage_availability_t* out_availability) { + if (!handle || !out_availability) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + // Initialize output + memset(out_availability, 0, sizeof(rac_storage_availability_t)); + + // Get available space via callback + int64_t available = handle->callbacks.get_available_space(handle->callbacks.user_data); + int64_t required = + static_cast(static_cast(model_size) * (1.0 + safety_margin)); + + out_availability->available_space = available; + out_availability->required_space = required; + out_availability->is_available = available > required ? RAC_TRUE : RAC_FALSE; + out_availability->has_warning = available < required * 2 ? RAC_TRUE : RAC_FALSE; + + // Generate recommendation message + if (out_availability->is_available == RAC_FALSE) { + int64_t shortfall = required - available; + // Simple message - platform can format with locale-specific formatter + char msg[256]; + snprintf(msg, sizeof(msg), "Need %lld more bytes of space.", (long long)shortfall); + out_availability->recommendation = strdup(msg); + } else if (out_availability->has_warning == RAC_TRUE) { + out_availability->recommendation = strdup("Storage space is getting low."); + } + + return RAC_SUCCESS; +} + +rac_result_t rac_storage_analyzer_calculate_size(rac_storage_analyzer_handle_t handle, + const char* path, int64_t* out_size) { + if (!handle || !path || !out_size) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + // Check if path exists and is directory + rac_bool_t is_directory = RAC_FALSE; + if (handle->callbacks.path_exists) { + rac_bool_t exists = + handle->callbacks.path_exists(path, &is_directory, handle->callbacks.user_data); + if (exists == RAC_FALSE) { + return RAC_ERROR_NOT_FOUND; + } + } + + // Calculate size based on type + if (is_directory == RAC_TRUE) { + *out_size = handle->callbacks.calculate_dir_size(path, handle->callbacks.user_data); + } else if (handle->callbacks.get_file_size) { + *out_size = handle->callbacks.get_file_size(path, handle->callbacks.user_data); + } else { + // Fallback to dir size calculator for files too + *out_size = handle->callbacks.calculate_dir_size(path, handle->callbacks.user_data); + } + + return RAC_SUCCESS; +} + +// ============================================================================= +// CLEANUP +// ============================================================================= + +void rac_storage_info_free(rac_storage_info_t* info) { + if (!info) + return; + + if (info->models) { + for (size_t i = 0; i < info->model_count; i++) { + free(const_cast(info->models[i].model_id)); + free(const_cast(info->models[i].model_name)); + free(const_cast(info->models[i].local_path)); + } + free(info->models); + } + + memset(info, 0, sizeof(rac_storage_info_t)); +} + +void rac_storage_availability_free(rac_storage_availability_t* availability) { + if (!availability) + return; + + free(const_cast(availability->recommendation)); + memset(availability, 0, sizeof(rac_storage_availability_t)); +} diff --git a/sdk/runanywhere-commons/src/infrastructure/telemetry/telemetry_json.cpp b/sdk/runanywhere-commons/src/infrastructure/telemetry/telemetry_json.cpp new file mode 100644 index 000000000..fab9ec8d6 --- /dev/null +++ b/sdk/runanywhere-commons/src/infrastructure/telemetry/telemetry_json.cpp @@ -0,0 +1,509 @@ +/** + * @file telemetry_json.cpp + * @brief JSON serialization for telemetry payloads + * + * Environment-aware encoding: + * - Development (Supabase): Uses sdk_event_id, event_timestamp, includes all fields + * - Production (FastAPI): Uses id, timestamp, skips modality/device_id (batch level) + */ + +#include +#include +#include +#include +#include +#include + +#include "rac/core/rac_logger.h" +#include "rac/infrastructure/network/rac_endpoints.h" +#include "rac/infrastructure/telemetry/rac_telemetry_manager.h" + +// ============================================================================= +// JSON BUILDER HELPERS +// ============================================================================= + +namespace { + +class JsonBuilder { + public: + void start_object() { + ss_ << "{"; + first_ = true; + } + void end_object() { ss_ << "}"; } + void start_array() { + ss_ << "["; + first_ = true; + } + void end_array() { ss_ << "]"; } + + void add_string(const char* key, const char* value) { + if (!value) + return; + comma(); + ss_ << "\"" << key << "\":\"" << escape_string(value) << "\""; + } + + // Always outputs a string, using empty string if value is null + void add_string_always(const char* key, const char* value) { + comma(); + ss_ << "\"" << key << "\":\"" << escape_string(value ? value : "") << "\""; + } + + // Outputs a string if value is non-null, otherwise outputs null + void add_string_or_null(const char* key, const char* value) { + comma(); + if (value) { + ss_ << "\"" << key << "\":\"" << escape_string(value) << "\""; + } else { + ss_ << "\"" << key << "\":null"; + } + } + + void add_int(const char* key, int64_t value) { + if (value == 0) + return; // Skip zero values + comma(); + ss_ << "\"" << key << "\":" << value; + } + + void add_int_always(const char* key, int64_t value) { + comma(); + ss_ << "\"" << key << "\":" << value; + } + + // Outputs integer if is_valid is true, otherwise outputs null + void add_int_or_null(const char* key, int64_t value, bool is_valid) { + comma(); + if (is_valid) { + ss_ << "\"" << key << "\":" << value; + } else { + ss_ << "\"" << key << "\":null"; + } + } + + // Outputs double if is_valid is true, otherwise outputs null + void add_double_or_null(const char* key, double value, bool is_valid) { + comma(); + if (is_valid) { + ss_ << "\"" << key << "\":" << value; + } else { + ss_ << "\"" << key << "\":null"; + } + } + + void add_double(const char* key, double value) { + if (value == 0.0) + return; // Skip zero values + comma(); + ss_ << "\"" << key << "\":" << value; + } + + void add_bool(const char* key, rac_bool_t value, rac_bool_t has_value) { + if (!has_value) + return; + comma(); + ss_ << "\"" << key << "\":" << (value ? "true" : "false"); + } + + // Always outputs a boolean value + void add_bool_always(const char* key, bool value) { + comma(); + ss_ << "\"" << key << "\":" << (value ? "true" : "false"); + } + + // Start a nested object with a key + void start_nested(const char* key) { + comma(); + ss_ << "\"" << key << "\":{"; + first_ = true; + } + + void add_timestamp(const char* key, int64_t ms) { + // Format as ISO8601 string + time_t secs = ms / 1000; + int millis = ms % 1000; + struct tm tm_info; + gmtime_r(&secs, &tm_info); + + char buf[32]; + strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%S", &tm_info); + + comma(); + ss_ << "\"" << key << "\":\"" << buf << "." << std::setfill('0') << std::setw(3) << millis + << "Z\""; + } + + void add_raw(const char* json) { + comma(); + ss_ << json; + } + + std::string str() const { return ss_.str(); } + + private: + void comma() { + if (!first_) + ss_ << ","; + first_ = false; + } + + std::string escape_string(const char* s) { + std::string result; + while (*s) { + switch (*s) { + case '"': + result += "\\\""; + break; + case '\\': + result += "\\\\"; + break; + case '\n': + result += "\\n"; + break; + case '\r': + result += "\\r"; + break; + case '\t': + result += "\\t"; + break; + default: + result += *s; + } + s++; + } + return result; + } + + std::stringstream ss_; + bool first_ = true; +}; + +} // namespace + +// ============================================================================= +// PAYLOAD JSON SERIALIZATION +// ============================================================================= + +rac_result_t rac_telemetry_manager_payload_to_json(const rac_telemetry_payload_t* payload, + rac_environment_t env, char** out_json, + size_t* out_length) { + if (!payload || !out_json || !out_length) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + bool is_production = (env != RAC_ENV_DEVELOPMENT); + JsonBuilder json; + json.start_object(); + + // Required fields - different key names based on environment + if (is_production) { + // Production: FastAPI expects "id" and "timestamp" + json.add_string("id", payload->id); + json.add_timestamp("timestamp", payload->timestamp_ms); + } else { + // Development: Supabase expects "sdk_event_id" and "event_timestamp" + json.add_string("sdk_event_id", payload->id); + json.add_timestamp("event_timestamp", payload->timestamp_ms); + } + + json.add_string("event_type", payload->event_type); + json.add_timestamp("created_at", payload->created_at_ms); + + // Conditional fields - skip for production (FastAPI has them at batch level) + if (!is_production) { + json.add_string("modality", payload->modality); + json.add_string("device_id", payload->device_id); + } + + // Session tracking + json.add_string("session_id", payload->session_id); + + // Model info + json.add_string("model_id", payload->model_id); + json.add_string("model_name", payload->model_name); + json.add_string("framework", payload->framework); + + // Device info + json.add_string("device", payload->device); + json.add_string("os_version", payload->os_version); + json.add_string("platform", payload->platform); + json.add_string("sdk_version", payload->sdk_version); + + // Common metrics + json.add_double("processing_time_ms", payload->processing_time_ms); + json.add_bool("success", payload->success, payload->has_success); + json.add_string("error_message", payload->error_message); + json.add_string("error_code", payload->error_code); + + // LLM fields + json.add_int("input_tokens", payload->input_tokens); + json.add_int("output_tokens", payload->output_tokens); + json.add_int("total_tokens", payload->total_tokens); + json.add_double("tokens_per_second", payload->tokens_per_second); + json.add_double("time_to_first_token_ms", payload->time_to_first_token_ms); + json.add_double("prompt_eval_time_ms", payload->prompt_eval_time_ms); + json.add_double("generation_time_ms", payload->generation_time_ms); + json.add_int("context_length", payload->context_length); + json.add_double("temperature", payload->temperature); + json.add_int("max_tokens", payload->max_tokens); + + // STT fields + json.add_double("audio_duration_ms", payload->audio_duration_ms); + json.add_double("real_time_factor", payload->real_time_factor); + json.add_int("word_count", payload->word_count); + json.add_double("confidence", payload->confidence); + json.add_string("language", payload->language); + json.add_bool("is_streaming", payload->is_streaming, payload->has_is_streaming); + json.add_int("segment_index", payload->segment_index); + + // TTS fields + json.add_int("character_count", payload->character_count); + json.add_double("characters_per_second", payload->characters_per_second); + json.add_int("audio_size_bytes", payload->audio_size_bytes); + json.add_int("sample_rate", payload->sample_rate); + json.add_string("voice", payload->voice); + json.add_double("output_duration_ms", payload->output_duration_ms); + + // Model lifecycle + json.add_int("model_size_bytes", payload->model_size_bytes); + json.add_string("archive_type", payload->archive_type); + + // VAD + json.add_double("speech_duration_ms", payload->speech_duration_ms); + + // SDK lifecycle + json.add_int("count", payload->count); + + // Storage + json.add_int("freed_bytes", payload->freed_bytes); + + // Network + json.add_bool("is_online", payload->is_online, payload->has_is_online); + + json.end_object(); + + std::string result = json.str(); + *out_length = result.size(); + *out_json = (char*)malloc(*out_length + 1); + if (!*out_json) { + return RAC_ERROR_OUT_OF_MEMORY; + } + memcpy(*out_json, result.c_str(), *out_length + 1); + + return RAC_SUCCESS; +} + +// ============================================================================= +// BATCH REQUEST JSON SERIALIZATION +// ============================================================================= + +rac_result_t rac_telemetry_manager_batch_to_json(const rac_telemetry_batch_request_t* request, + rac_environment_t env, char** out_json, + size_t* out_length) { + if (!request || !out_json || !out_length) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + bool is_development = (env == RAC_ENV_DEVELOPMENT); + + if (is_development) { + // Supabase: Send array directly [{...}, {...}] + JsonBuilder json; + json.start_array(); + + for (size_t i = 0; i < request->events_count; i++) { + char* event_json = nullptr; + size_t event_len = 0; + rac_result_t result = rac_telemetry_manager_payload_to_json(&request->events[i], env, + &event_json, &event_len); + if (result == RAC_SUCCESS && event_json) { + if (i > 0) { + // Need to add comma manually since we're adding raw JSON + } + json.add_raw(event_json); + free(event_json); + } + } + + json.end_array(); + + std::string result = json.str(); + *out_length = result.size(); + *out_json = (char*)malloc(*out_length + 1); + if (!*out_json) { + return RAC_ERROR_OUT_OF_MEMORY; + } + memcpy(*out_json, result.c_str(), *out_length + 1); + } else { + // Production: Batch wrapper {"events": [...], "device_id": "...", ...} + JsonBuilder json; + json.start_object(); + + // Events array + std::stringstream events_ss; + events_ss << "\"events\":["; + for (size_t i = 0; i < request->events_count; i++) { + if (i > 0) + events_ss << ","; + + char* event_json = nullptr; + size_t event_len = 0; + rac_result_t result = rac_telemetry_manager_payload_to_json(&request->events[i], env, + &event_json, &event_len); + if (result == RAC_SUCCESS && event_json) { + events_ss << event_json; + free(event_json); + } + } + events_ss << "]"; + json.add_raw(events_ss.str().c_str()); + + json.add_string("device_id", request->device_id); + json.add_timestamp("timestamp", request->timestamp_ms); + json.add_string("modality", request->modality); + + json.end_object(); + + std::string result = json.str(); + *out_length = result.size(); + *out_json = (char*)malloc(*out_length + 1); + if (!*out_json) { + return RAC_ERROR_OUT_OF_MEMORY; + } + memcpy(*out_json, result.c_str(), *out_length + 1); + } + + return RAC_SUCCESS; +} + +// ============================================================================= +// DEVICE REGISTRATION JSON +// ============================================================================= + +rac_result_t rac_device_registration_to_json(const rac_device_registration_request_t* request, + rac_environment_t env, char** out_json, + size_t* out_length) { + if (!request || !out_json || !out_length) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + JsonBuilder json; + json.start_object(); + + // For development mode (Supabase), flatten the structure to match Supabase schema + // For production/staging, use nested device_info structure + if (env == RAC_ENV_DEVELOPMENT) { + // Flattened structure for Supabase (matches Kotlin SDK DevDeviceRegistrationRequest) + const rac_device_registration_info_t* info = &request->device_info; + + // Required fields (matching Supabase schema) + if (info->device_id) { + json.add_string("device_id", info->device_id); + } + if (info->platform) { + json.add_string("platform", info->platform); + } + if (info->os_version) { + json.add_string("os_version", info->os_version); + } + if (info->device_model) { + json.add_string("device_model", info->device_model); + } + if (request->sdk_version) { + json.add_string("sdk_version", request->sdk_version); + } + + // Optional fields + if (request->build_token) { + json.add_string("build_token", request->build_token); + } + if (info->total_memory > 0) { + json.add_int("total_memory", info->total_memory); + } + if (info->architecture) { + json.add_string("architecture", info->architecture); + } + if (info->chip_name) { + json.add_string("chip_name", info->chip_name); + } + if (info->form_factor) { + json.add_string("form_factor", info->form_factor); + } + // has_neural_engine is always set (rac_bool_t), so we can always include it + json.add_bool("has_neural_engine", info->has_neural_engine, RAC_TRUE); + // Add last_seen_at timestamp for UPSERT to update existing records + if (request->last_seen_at_ms > 0) { + json.add_timestamp("last_seen_at", request->last_seen_at_ms); + } + } else { + // Nested structure for production/staging + // Matches backend schemas/device.py DeviceInfo schema + const rac_device_registration_info_t* info = &request->device_info; + + // Build device_info as nested object with proper escaping + json.start_nested("device_info"); + + // Required string fields (use add_string_always to output empty string if null) + json.add_string_always("device_model", info->device_model); + json.add_string_always("device_name", info->device_name); + json.add_string_always("platform", info->platform); + json.add_string_always("os_version", info->os_version); + json.add_string_always("form_factor", info->form_factor ? info->form_factor : "phone"); + json.add_string_always("architecture", info->architecture); + json.add_string_always("chip_name", info->chip_name); + + // Integer fields (always present) + json.add_int_always("total_memory", info->total_memory); + json.add_int_always("available_memory", info->available_memory); + + // Boolean fields + json.add_bool_always("has_neural_engine", info->has_neural_engine); + json.add_int_always("neural_engine_cores", info->neural_engine_cores); + + // GPU family with default + json.add_string_always("gpu_family", info->gpu_family ? info->gpu_family : "unknown"); + + // Battery info (may be unavailable - use nullable methods) + // battery_level is a double (0.0-1.0), negative if unavailable + json.add_double_or_null("battery_level", info->battery_level, info->battery_level >= 0); + json.add_string_or_null("battery_state", info->battery_state); + + // More boolean and integer fields + json.add_bool_always("is_low_power_mode", info->is_low_power_mode); + json.add_int_always("core_count", info->core_count); + json.add_int_always("performance_cores", info->performance_cores); + json.add_int_always("efficiency_cores", info->efficiency_cores); + + // Device fingerprint (fallback to device_id if not set) + const char* fingerprint = info->device_fingerprint + ? info->device_fingerprint + : (info->device_id ? info->device_id : ""); + json.add_string_always("device_fingerprint", fingerprint); + + json.end_object(); // Close device_info + + json.add_string("sdk_version", request->sdk_version); + + // Add last_seen_at timestamp for UPSERT to update existing records + if (request->last_seen_at_ms > 0) { + json.add_timestamp("last_seen_at", request->last_seen_at_ms); + } + } + + json.end_object(); + + std::string result = json.str(); + *out_length = result.size(); + *out_json = (char*)malloc(*out_length + 1); + if (!*out_json) { + return RAC_ERROR_OUT_OF_MEMORY; + } + memcpy(*out_json, result.c_str(), *out_length + 1); + + return RAC_SUCCESS; +} + +const char* rac_device_registration_endpoint(rac_environment_t env) { + return rac_endpoint_device_registration(env); +} diff --git a/sdk/runanywhere-commons/src/infrastructure/telemetry/telemetry_manager.cpp b/sdk/runanywhere-commons/src/infrastructure/telemetry/telemetry_manager.cpp new file mode 100644 index 000000000..b58e55b8f --- /dev/null +++ b/sdk/runanywhere-commons/src/infrastructure/telemetry/telemetry_manager.cpp @@ -0,0 +1,782 @@ +/** + * @file telemetry_manager.cpp + * @brief Telemetry manager implementation + * + * Handles event queuing, batching by modality, and HTTP callbacks. + */ + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "rac/core/rac_logger.h" +#include "rac/infrastructure/network/rac_endpoints.h" +#include "rac/infrastructure/telemetry/rac_telemetry_manager.h" + +// ============================================================================= +// INTERNAL STRUCTURES +// ============================================================================= + +struct rac_telemetry_manager { + // Configuration + rac_environment_t environment; + std::string device_id; + std::string platform; + std::string sdk_version; + std::string device_model; + std::string os_version; + + // HTTP callback + rac_telemetry_http_callback_t http_callback; + void* http_user_data; + + // Event queue + std::vector queue; + std::mutex queue_mutex; + + // V2 modalities for grouping + std::set v2_modalities = {"llm", "stt", "tts", "model"}; + + // Batching configuration + static constexpr size_t BATCH_SIZE_PRODUCTION = 10; // Flush after 10 events in production + static constexpr int64_t BATCH_TIMEOUT_MS = 5000; // Flush after 5 seconds in production + int64_t last_flush_time_ms = 0; // Track last flush time for timeout +}; + +// ============================================================================= +// HELPER FUNCTIONS +// ============================================================================= + +namespace { + +// Get current timestamp in milliseconds +int64_t get_current_timestamp_ms() { + auto now = std::chrono::system_clock::now(); + auto duration = now.time_since_epoch(); + return std::chrono::duration_cast(duration).count(); +} + +// Thread-safe seeding flag +std::once_flag rand_seed_flag; + +// Ensure random number generator is seeded exactly once (thread-safe) +void ensure_rand_seeded() { + std::call_once(rand_seed_flag, []() { + // Seed with combination of time and memory address for better entropy + auto now = std::chrono::high_resolution_clock::now(); + auto nanos = + std::chrono::duration_cast(now.time_since_epoch()).count(); + unsigned int seed = + static_cast(nanos ^ reinterpret_cast(&rand_seed_flag)); + srand(seed); + }); +} + +// Generate UUID +std::string generate_uuid() { + // Ensure random number generator is seeded + ensure_rand_seeded(); + + // Simple UUID generation (not RFC4122 compliant, but sufficient for event IDs) + static const char hex[] = "0123456789abcdef"; + std::string uuid = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx"; + + for (char& c : uuid) { + if (c == 'x') { + c = hex[rand() % 16]; + } else if (c == 'y') { + c = hex[(rand() % 4) + 8]; // 8, 9, a, or b + } + } + + return uuid; +} + +// Duplicate string (caller must free) +char* dup_string(const char* s) { + if (!s) + return nullptr; + size_t len = strlen(s) + 1; + char* copy = (char*)malloc(len); + if (copy) + memcpy(copy, s, len); + return copy; +} + +// Convert analytics event type to modality +const char* event_type_to_modality(rac_event_type_t type) { + if (type >= RAC_EVENT_LLM_MODEL_LOAD_STARTED && type <= RAC_EVENT_LLM_STREAMING_UPDATE) { + return "llm"; + } + if (type >= RAC_EVENT_STT_MODEL_LOAD_STARTED && type <= RAC_EVENT_STT_PARTIAL_TRANSCRIPT) { + return "stt"; + } + if (type >= RAC_EVENT_TTS_VOICE_LOAD_STARTED && type <= RAC_EVENT_TTS_SYNTHESIS_CHUNK) { + return "tts"; + } + if (type >= RAC_EVENT_VAD_STARTED && type <= RAC_EVENT_VAD_RESUMED) { + return "system"; // VAD goes to system + } + // Model download/extraction/deletion events go to "model" modality (V2 base table only) + if (type >= RAC_EVENT_MODEL_DOWNLOAD_STARTED && type <= RAC_EVENT_MODEL_DELETED) { + return "model"; + } + // SDK lifecycle, storage, device, network events go to system (V1 table) + return "system"; +} + +// Check if event type is a completion/failure event that should flush immediately +bool is_completion_event(rac_event_type_t type) { + switch (type) { + case RAC_EVENT_LLM_GENERATION_COMPLETED: + case RAC_EVENT_LLM_GENERATION_FAILED: + case RAC_EVENT_STT_TRANSCRIPTION_COMPLETED: + case RAC_EVENT_STT_TRANSCRIPTION_FAILED: + case RAC_EVENT_TTS_SYNTHESIS_COMPLETED: + case RAC_EVENT_TTS_SYNTHESIS_FAILED: + return true; + default: + return false; + } +} + +// Convert analytics event type to event type string +const char* event_type_to_string(rac_event_type_t type) { + switch (type) { + // LLM + case RAC_EVENT_LLM_MODEL_LOAD_STARTED: + return "llm.model.load.started"; + case RAC_EVENT_LLM_MODEL_LOAD_COMPLETED: + return "llm.model.load.completed"; + case RAC_EVENT_LLM_MODEL_LOAD_FAILED: + return "llm.model.load.failed"; + case RAC_EVENT_LLM_MODEL_UNLOADED: + return "llm.model.unloaded"; + case RAC_EVENT_LLM_GENERATION_STARTED: + return "llm.generation.started"; + case RAC_EVENT_LLM_GENERATION_COMPLETED: + return "llm.generation.completed"; + case RAC_EVENT_LLM_GENERATION_FAILED: + return "llm.generation.failed"; + case RAC_EVENT_LLM_FIRST_TOKEN: + return "llm.generation.first_token"; + case RAC_EVENT_LLM_STREAMING_UPDATE: + return "llm.generation.streaming"; + + // STT + case RAC_EVENT_STT_MODEL_LOAD_STARTED: + return "stt.model.load.started"; + case RAC_EVENT_STT_MODEL_LOAD_COMPLETED: + return "stt.model.load.completed"; + case RAC_EVENT_STT_MODEL_LOAD_FAILED: + return "stt.model.load.failed"; + case RAC_EVENT_STT_MODEL_UNLOADED: + return "stt.model.unloaded"; + case RAC_EVENT_STT_TRANSCRIPTION_STARTED: + return "stt.transcription.started"; + case RAC_EVENT_STT_TRANSCRIPTION_COMPLETED: + return "stt.transcription.completed"; + case RAC_EVENT_STT_TRANSCRIPTION_FAILED: + return "stt.transcription.failed"; + case RAC_EVENT_STT_PARTIAL_TRANSCRIPT: + return "stt.transcription.partial"; + + // TTS + case RAC_EVENT_TTS_VOICE_LOAD_STARTED: + return "tts.voice.load.started"; + case RAC_EVENT_TTS_VOICE_LOAD_COMPLETED: + return "tts.voice.load.completed"; + case RAC_EVENT_TTS_VOICE_LOAD_FAILED: + return "tts.voice.load.failed"; + case RAC_EVENT_TTS_VOICE_UNLOADED: + return "tts.voice.unloaded"; + case RAC_EVENT_TTS_SYNTHESIS_STARTED: + return "tts.synthesis.started"; + case RAC_EVENT_TTS_SYNTHESIS_COMPLETED: + return "tts.synthesis.completed"; + case RAC_EVENT_TTS_SYNTHESIS_FAILED: + return "tts.synthesis.failed"; + case RAC_EVENT_TTS_SYNTHESIS_CHUNK: + return "tts.synthesis.chunk"; + + // VAD + case RAC_EVENT_VAD_STARTED: + return "vad.started"; + case RAC_EVENT_VAD_STOPPED: + return "vad.stopped"; + case RAC_EVENT_VAD_SPEECH_STARTED: + return "vad.speech.started"; + case RAC_EVENT_VAD_SPEECH_ENDED: + return "vad.speech.ended"; + case RAC_EVENT_VAD_PAUSED: + return "vad.paused"; + case RAC_EVENT_VAD_RESUMED: + return "vad.resumed"; + + // VoiceAgent + case RAC_EVENT_VOICE_AGENT_TURN_STARTED: + return "voice_agent.turn.started"; + case RAC_EVENT_VOICE_AGENT_TURN_COMPLETED: + return "voice_agent.turn.completed"; + case RAC_EVENT_VOICE_AGENT_TURN_FAILED: + return "voice_agent.turn.failed"; + + // SDK Lifecycle Events (600-699) + case RAC_EVENT_SDK_INIT_STARTED: + return "sdk.init.started"; + case RAC_EVENT_SDK_INIT_COMPLETED: + return "sdk.init.completed"; + case RAC_EVENT_SDK_INIT_FAILED: + return "sdk.init.failed"; + case RAC_EVENT_SDK_MODELS_LOADED: + return "sdk.models.loaded"; + + // Model Download Events (700-719) + case RAC_EVENT_MODEL_DOWNLOAD_STARTED: + return "model.download.started"; + case RAC_EVENT_MODEL_DOWNLOAD_PROGRESS: + return "model.download.progress"; + case RAC_EVENT_MODEL_DOWNLOAD_COMPLETED: + return "model.download.completed"; + case RAC_EVENT_MODEL_DOWNLOAD_FAILED: + return "model.download.failed"; + case RAC_EVENT_MODEL_DOWNLOAD_CANCELLED: + return "model.download.cancelled"; + + // Model Extraction Events (710-719) + case RAC_EVENT_MODEL_EXTRACTION_STARTED: + return "model.extraction.started"; + case RAC_EVENT_MODEL_EXTRACTION_PROGRESS: + return "model.extraction.progress"; + case RAC_EVENT_MODEL_EXTRACTION_COMPLETED: + return "model.extraction.completed"; + case RAC_EVENT_MODEL_EXTRACTION_FAILED: + return "model.extraction.failed"; + + // Model Deletion Events (720-729) + case RAC_EVENT_MODEL_DELETED: + return "model.deleted"; + + // Storage Events (800-899) + case RAC_EVENT_STORAGE_CACHE_CLEARED: + return "storage.cache.cleared"; + case RAC_EVENT_STORAGE_CACHE_CLEAR_FAILED: + return "storage.cache.clear_failed"; + case RAC_EVENT_STORAGE_TEMP_CLEANED: + return "storage.temp.cleaned"; + + // Device Events (900-999) + case RAC_EVENT_DEVICE_REGISTERED: + return "device.registered"; + case RAC_EVENT_DEVICE_REGISTRATION_FAILED: + return "device.registration.failed"; + + // Network Events (1000-1099) + case RAC_EVENT_NETWORK_CONNECTIVITY_CHANGED: + return "network.connectivity.changed"; + + // Error Events (1100-1199) + case RAC_EVENT_SDK_ERROR: + return "sdk.error"; + + // Framework Events (1200-1299) + case RAC_EVENT_FRAMEWORK_MODELS_REQUESTED: + return "framework.models.requested"; + case RAC_EVENT_FRAMEWORK_MODELS_RETRIEVED: + return "framework.models.retrieved"; + + default: + return "unknown"; + } +} + +// Convert framework enum to string +const char* framework_to_string(rac_inference_framework_t framework) { + switch (framework) { + case RAC_FRAMEWORK_ONNX: + return "onnx"; + case RAC_FRAMEWORK_LLAMACPP: + return "llamacpp"; + case RAC_FRAMEWORK_FOUNDATION_MODELS: + return "foundation_models"; + case RAC_FRAMEWORK_SYSTEM_TTS: + return "system_tts"; + case RAC_FRAMEWORK_FLUID_AUDIO: + return "fluid_audio"; + case RAC_FRAMEWORK_BUILTIN: + return "builtin"; + case RAC_FRAMEWORK_NONE: + return "none"; + case RAC_FRAMEWORK_UNKNOWN: + default: + return "unknown"; + } +} + +} // namespace + +// ============================================================================= +// LIFECYCLE +// ============================================================================= + +rac_telemetry_manager_t* rac_telemetry_manager_create(rac_environment_t env, const char* device_id, + const char* platform, + const char* sdk_version) { + auto* manager = new (std::nothrow) rac_telemetry_manager_t(); + if (!manager) + return nullptr; + + manager->environment = env; + manager->device_id = device_id ? device_id : ""; + manager->platform = platform ? platform : ""; + manager->sdk_version = sdk_version ? sdk_version : ""; + manager->http_callback = nullptr; + manager->http_user_data = nullptr; + manager->last_flush_time_ms = 0; // Initialize to 0 (will be set on first flush) + + log_debug("Telemetry", "Telemetry manager created for environment %d", env); + + return manager; +} + +void rac_telemetry_manager_destroy(rac_telemetry_manager_t* manager) { + if (!manager) + return; + + // Flush any remaining events + rac_telemetry_manager_flush(manager); + + delete manager; + log_debug("Telemetry", "Telemetry manager destroyed"); +} + +void rac_telemetry_manager_set_device_info(rac_telemetry_manager_t* manager, + const char* device_model, const char* os_version) { + if (!manager) + return; + + manager->device_model = device_model ? device_model : ""; + manager->os_version = os_version ? os_version : ""; +} + +void rac_telemetry_manager_set_http_callback(rac_telemetry_manager_t* manager, + rac_telemetry_http_callback_t callback, + void* user_data) { + if (!manager) + return; + + manager->http_callback = callback; + manager->http_user_data = user_data; +} + +// ============================================================================= +// EVENT TRACKING +// ============================================================================= + +rac_result_t rac_telemetry_manager_track(rac_telemetry_manager_t* manager, + const rac_telemetry_payload_t* payload) { + if (!manager || !payload) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + // Deep copy payload for queue + rac_telemetry_payload_t copy = *payload; + copy.id = dup_string(payload->id); + copy.event_type = dup_string(payload->event_type); + copy.modality = dup_string(payload->modality); + copy.device_id = dup_string(manager->device_id.c_str()); + copy.session_id = dup_string(payload->session_id); + copy.model_id = dup_string(payload->model_id); + copy.model_name = dup_string(payload->model_name); + copy.framework = dup_string(payload->framework); + copy.device = dup_string(manager->device_model.c_str()); + copy.os_version = dup_string(manager->os_version.c_str()); + copy.platform = dup_string(manager->platform.c_str()); + copy.sdk_version = dup_string(manager->sdk_version.c_str()); + copy.error_message = dup_string(payload->error_message); + copy.error_code = dup_string(payload->error_code); + copy.language = dup_string(payload->language); + copy.voice = dup_string(payload->voice); + copy.archive_type = dup_string(payload->archive_type); + + { + std::lock_guard lock(manager->queue_mutex); + manager->queue.push_back(copy); + } + + // Use WARN level for production visibility (INFO is filtered in production) + log_debug("Telemetry", "Telemetry event queued: %s", payload->event_type); + + // Auto-flush logic + if (!manager->http_callback) { + log_debug("Telemetry", "HTTP callback not set, skipping auto-flush"); + return RAC_SUCCESS; + } + + bool should_flush = false; + size_t queue_size = 0; + int64_t current_time = get_current_timestamp_ms(); + + { + std::lock_guard lock(manager->queue_mutex); + queue_size = manager->queue.size(); + } + + if (manager->environment == RAC_ENV_DEVELOPMENT) { + // Development: Immediate flush for real-time debugging + should_flush = true; + log_debug("Telemetry", "Development mode: auto-flushing immediately (queue size: %zu)", + queue_size); + } else { + // Production: Flush based on batch size or timeout + // (completion events are handled in rac_telemetry_manager_track_analytics) + // Flush if queue reaches batch size + if (queue_size >= manager->BATCH_SIZE_PRODUCTION) { + should_flush = true; + log_debug("Telemetry", "Auto-flushing: queue size (%zu) >= batch size (%zu)", + queue_size, manager->BATCH_SIZE_PRODUCTION); + } + // Flush if timeout reached (5 seconds since last flush) + else if (manager->last_flush_time_ms > 0 && + (current_time - manager->last_flush_time_ms) >= manager->BATCH_TIMEOUT_MS) { + should_flush = true; + log_debug("Telemetry", "Auto-flushing: timeout reached (%lld ms since last flush)", + current_time - manager->last_flush_time_ms); + } + // First flush: start the timer by flushing immediately if we have events + else if (manager->last_flush_time_ms == 0 && queue_size > 0) { + should_flush = true; + log_debug("Telemetry", "Production: first flush to start timer (queue size: %zu)", + queue_size); + } + } + + if (should_flush) { + log_debug("Telemetry", "Triggering auto-flush (queue size: %zu)", queue_size); + rac_telemetry_manager_flush(manager); + // Note: last_flush_time_ms is updated inside flush() + } + + return RAC_SUCCESS; +} + +rac_result_t rac_telemetry_manager_track_analytics(rac_telemetry_manager_t* manager, + rac_event_type_t event_type, + const rac_analytics_event_data_t* data) { + if (!manager) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + rac_telemetry_payload_t payload = rac_telemetry_payload_default(); + + // Generate ID and timestamps + std::string uuid = generate_uuid(); + payload.id = uuid.c_str(); + payload.timestamp_ms = get_current_timestamp_ms(); + payload.created_at_ms = payload.timestamp_ms; + + // Set event type and modality + payload.event_type = event_type_to_string(event_type); + payload.modality = event_type_to_modality(event_type); + + // Fill in data based on event type + if (data) { + switch (event_type) { + // LLM Generation events + case RAC_EVENT_LLM_GENERATION_STARTED: + case RAC_EVENT_LLM_GENERATION_COMPLETED: + case RAC_EVENT_LLM_GENERATION_FAILED: + case RAC_EVENT_LLM_FIRST_TOKEN: + case RAC_EVENT_LLM_STREAMING_UPDATE: { + const auto& llm = data->data.llm_generation; + // model_id and model_name come directly from the event (set by component from + // lifecycle) + payload.model_id = llm.model_id; + payload.model_name = llm.model_name ? llm.model_name : llm.model_id; + payload.session_id = llm.generation_id; + payload.input_tokens = llm.input_tokens; + payload.output_tokens = llm.output_tokens; + payload.total_tokens = llm.input_tokens + llm.output_tokens; + payload.processing_time_ms = llm.duration_ms; + payload.generation_time_ms = + llm.duration_ms; // Also set generation_time_ms for LLM events + payload.tokens_per_second = llm.tokens_per_second; + payload.time_to_first_token_ms = llm.time_to_first_token_ms; + payload.is_streaming = llm.is_streaming; + payload.has_is_streaming = RAC_TRUE; + payload.framework = framework_to_string(llm.framework); + payload.temperature = llm.temperature; + payload.max_tokens = llm.max_tokens; + payload.context_length = llm.context_length; + if (llm.error_code != RAC_SUCCESS) { + payload.success = RAC_FALSE; + payload.has_success = RAC_TRUE; + payload.error_message = llm.error_message; + } else if (event_type == RAC_EVENT_LLM_GENERATION_COMPLETED) { + payload.success = RAC_TRUE; + payload.has_success = RAC_TRUE; + } + break; + } + + // LLM Model events + case RAC_EVENT_LLM_MODEL_LOAD_STARTED: + case RAC_EVENT_LLM_MODEL_LOAD_COMPLETED: + case RAC_EVENT_LLM_MODEL_LOAD_FAILED: + case RAC_EVENT_LLM_MODEL_UNLOADED: { + const auto& model = data->data.llm_model; + // model_id and model_name come directly from the event + payload.model_id = model.model_id; + payload.model_name = model.model_name ? model.model_name : model.model_id; + payload.model_size_bytes = model.model_size_bytes; + payload.processing_time_ms = model.duration_ms; + payload.framework = framework_to_string(model.framework); + if (model.error_code != RAC_SUCCESS) { + payload.success = RAC_FALSE; + payload.has_success = RAC_TRUE; + payload.error_message = model.error_message; + } else if (event_type == RAC_EVENT_LLM_MODEL_LOAD_COMPLETED) { + payload.success = RAC_TRUE; + payload.has_success = RAC_TRUE; + } + break; + } + + // STT events + case RAC_EVENT_STT_TRANSCRIPTION_STARTED: + case RAC_EVENT_STT_TRANSCRIPTION_COMPLETED: + case RAC_EVENT_STT_TRANSCRIPTION_FAILED: + case RAC_EVENT_STT_PARTIAL_TRANSCRIPT: { + const auto& stt = data->data.stt_transcription; + // model_id and model_name come directly from the event + payload.model_id = stt.model_id; + payload.model_name = stt.model_name ? stt.model_name : stt.model_id; + payload.session_id = stt.transcription_id; + payload.processing_time_ms = stt.duration_ms; + payload.audio_duration_ms = stt.audio_length_ms; + payload.audio_size_bytes = stt.audio_size_bytes; + payload.word_count = stt.word_count; + payload.real_time_factor = stt.real_time_factor; + payload.confidence = stt.confidence; + payload.language = stt.language; + payload.sample_rate = stt.sample_rate; + payload.is_streaming = stt.is_streaming; + payload.has_is_streaming = RAC_TRUE; + payload.framework = framework_to_string(stt.framework); + if (stt.error_code != RAC_SUCCESS) { + payload.success = RAC_FALSE; + payload.has_success = RAC_TRUE; + payload.error_message = stt.error_message; + } else if (event_type == RAC_EVENT_STT_TRANSCRIPTION_COMPLETED) { + payload.success = RAC_TRUE; + payload.has_success = RAC_TRUE; + } + break; + } + + // TTS events + case RAC_EVENT_TTS_SYNTHESIS_STARTED: + case RAC_EVENT_TTS_SYNTHESIS_COMPLETED: + case RAC_EVENT_TTS_SYNTHESIS_FAILED: + case RAC_EVENT_TTS_SYNTHESIS_CHUNK: { + const auto& tts = data->data.tts_synthesis; + // model_id and model_name come directly from the event + payload.model_id = tts.model_id; + payload.model_name = tts.model_name ? tts.model_name : tts.model_id; + payload.voice = tts.model_id; // Voice is the same as model_id for TTS + payload.session_id = tts.synthesis_id; + payload.character_count = tts.character_count; + payload.output_duration_ms = tts.audio_duration_ms; + payload.audio_size_bytes = tts.audio_size_bytes; + payload.processing_time_ms = tts.processing_duration_ms; + payload.characters_per_second = tts.characters_per_second; + payload.sample_rate = tts.sample_rate; + payload.framework = framework_to_string(tts.framework); + if (tts.error_code != RAC_SUCCESS) { + payload.success = RAC_FALSE; + payload.has_success = RAC_TRUE; + payload.error_message = tts.error_message; + } else if (event_type == RAC_EVENT_TTS_SYNTHESIS_COMPLETED) { + payload.success = RAC_TRUE; + payload.has_success = RAC_TRUE; + } + // Debug: Log if voice/model_id is null + if (!payload.voice || !payload.model_id) { + log_debug( + "Telemetry", + "TTS event has null voice/model_id (voice_id from lifecycle may be null)"); + } else { + log_debug("Telemetry", "TTS event voice: %s", payload.voice); + } + break; + } + + // VAD events + case RAC_EVENT_VAD_STARTED: + case RAC_EVENT_VAD_STOPPED: + case RAC_EVENT_VAD_SPEECH_STARTED: + case RAC_EVENT_VAD_SPEECH_ENDED: + case RAC_EVENT_VAD_PAUSED: + case RAC_EVENT_VAD_RESUMED: { + const auto& vad = data->data.vad; + payload.speech_duration_ms = vad.speech_duration_ms; + break; + } + + default: + break; + } + } + + rac_result_t result = rac_telemetry_manager_track(manager, &payload); + + // For completion/failure events in production, trigger immediate flush + // This ensures important terminal events are captured before app exits + if (result == RAC_SUCCESS && manager->environment != RAC_ENV_DEVELOPMENT && + is_completion_event(event_type) && manager->http_callback) { + log_debug("Telemetry", "Completion event detected, triggering immediate flush"); + rac_telemetry_manager_flush(manager); + } + + return result; +} + +// ============================================================================= +// FLUSH +// ============================================================================= + +rac_result_t rac_telemetry_manager_flush(rac_telemetry_manager_t* manager) { + if (!manager) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + if (!manager->http_callback) { + log_debug("Telemetry", "No HTTP callback registered, cannot flush telemetry"); + return RAC_ERROR_NOT_INITIALIZED; + } + + // Get events from queue + std::vector events; + { + std::lock_guard lock(manager->queue_mutex); + events = std::move(manager->queue); + manager->queue.clear(); + } + + if (events.empty()) { + return RAC_SUCCESS; + } + + log_debug("Telemetry", "Flushing %zu telemetry events", events.size()); + + // Update last flush time + manager->last_flush_time_ms = get_current_timestamp_ms(); + + // Get endpoint + const char* endpoint = rac_endpoint_telemetry(manager->environment); + bool requires_auth = (manager->environment != RAC_ENV_DEVELOPMENT); + + if (manager->environment == RAC_ENV_DEVELOPMENT) { + // Development: Send array directly to Supabase + rac_telemetry_batch_request_t batch = {}; + batch.events = events.data(); + batch.events_count = events.size(); + batch.device_id = manager->device_id.c_str(); + batch.timestamp_ms = get_current_timestamp_ms(); + batch.modality = nullptr; // Not used for development + + char* json = nullptr; + size_t json_len = 0; + rac_result_t result = + rac_telemetry_manager_batch_to_json(&batch, manager->environment, &json, &json_len); + + if (result == RAC_SUCCESS && json) { + manager->http_callback(manager->http_user_data, endpoint, json, json_len, + requires_auth ? RAC_TRUE : RAC_FALSE); + free(json); + } + } else { + // Production: Group by modality and send batch requests + std::map> by_modality; + + for (const auto& event : events) { + std::string modality = event.modality ? event.modality : "system"; + // For "system" events, use V1 path (modality = nullptr) + if (manager->v2_modalities.find(modality) == manager->v2_modalities.end()) { + modality = "system"; + } + by_modality[modality].push_back(event); + } + + for (const auto& pair : by_modality) { + const std::string& modality = pair.first; + const auto& modality_events = pair.second; + + rac_telemetry_batch_request_t batch = {}; + batch.events = const_cast(modality_events.data()); + batch.events_count = modality_events.size(); + batch.device_id = manager->device_id.c_str(); + batch.timestamp_ms = get_current_timestamp_ms(); + batch.modality = (modality == "system") ? nullptr : modality.c_str(); + + char* json = nullptr; + size_t json_len = 0; + rac_result_t result = + rac_telemetry_manager_batch_to_json(&batch, manager->environment, &json, &json_len); + + if (result == RAC_SUCCESS && json) { + // WARN: Log production telemetry payload for debugging (first 500 chars) + log_debug("Telemetry", + "Sending production telemetry (modality=%s, %zu bytes): %.500s", + modality.c_str(), json_len, json); + manager->http_callback(manager->http_user_data, endpoint, json, json_len, + RAC_TRUE // Production always requires auth + ); + free(json); + } + } + } + + // Free duplicated strings in events + for (auto& event : events) { + free((void*)event.id); + free((void*)event.event_type); + free((void*)event.modality); + free((void*)event.device_id); + free((void*)event.session_id); + free((void*)event.model_id); + free((void*)event.model_name); + free((void*)event.framework); + free((void*)event.device); + free((void*)event.os_version); + free((void*)event.platform); + free((void*)event.sdk_version); + free((void*)event.error_message); + free((void*)event.error_code); + free((void*)event.language); + free((void*)event.voice); + free((void*)event.archive_type); + } + + return RAC_SUCCESS; +} + +void rac_telemetry_manager_http_complete(rac_telemetry_manager_t* manager, rac_bool_t success, + const char* /*response_json*/, const char* error_message) { + if (!manager) + return; + + if (success) { + log_debug("Telemetry", "Telemetry HTTP request completed successfully"); + } else { + log_warning("Telemetry", "Telemetry HTTP request failed: %s", + error_message ? error_message : "unknown"); + } + + // Could parse response and handle retries here if needed +} diff --git a/sdk/runanywhere-commons/src/infrastructure/telemetry/telemetry_types.cpp b/sdk/runanywhere-commons/src/infrastructure/telemetry/telemetry_types.cpp new file mode 100644 index 000000000..0fac70a9f --- /dev/null +++ b/sdk/runanywhere-commons/src/infrastructure/telemetry/telemetry_types.cpp @@ -0,0 +1,51 @@ +/** + * @file telemetry_types.cpp + * @brief Implementation of telemetry type utilities + */ + +#include +#include + +#include "rac/core/rac_logger.h" +#include "rac/infrastructure/telemetry/rac_telemetry_types.h" + +rac_telemetry_payload_t rac_telemetry_payload_default(void) { + rac_telemetry_payload_t payload = {}; + payload.success = RAC_FALSE; + payload.has_success = RAC_FALSE; + payload.is_streaming = RAC_FALSE; + payload.has_is_streaming = RAC_FALSE; + payload.is_online = RAC_FALSE; + payload.has_is_online = RAC_FALSE; + return payload; +} + +void rac_telemetry_payload_free(rac_telemetry_payload_t* payload) { + if (!payload) + return; + + // Note: We don't free strings here because they're typically + // either static or owned by the caller. The manager handles + // string allocation/deallocation for queued events. + + // Reset to default + *payload = rac_telemetry_payload_default(); +} + +void rac_telemetry_batch_response_free(rac_telemetry_batch_response_t* response) { + if (!response) + return; + + if (response->errors) { + for (size_t i = 0; i < response->errors_count; i++) { + free((void*)response->errors[i]); + } + free(response->errors); + } + + if (response->storage_version) { + free((void*)response->storage_version); + } + + memset(response, 0, sizeof(*response)); +} diff --git a/sdk/runanywhere-commons/src/jni/CMakeLists.txt b/sdk/runanywhere-commons/src/jni/CMakeLists.txt new file mode 100644 index 000000000..496b7e9ab --- /dev/null +++ b/sdk/runanywhere-commons/src/jni/CMakeLists.txt @@ -0,0 +1,75 @@ +# CMakeLists.txt for RunAnywhere Commons JNI Bridge +# +# This builds the CORE commons JNI layer that wraps the runanywhere-commons C API +# for Android/JVM platforms. +# +# The commons JNI library includes: +# - Core commons bindings (rac_init, rac_shutdown, etc.) +# - LLM/STT/TTS/VAD component bindings +# - Model registry bindings +# - Platform adapter callbacks +# +# NOTE: Backend registration is handled by SEPARATE JNI libraries: +# - backends/llamacpp/src/jni/ -> librac_backend_llamacpp_jni.so +# - backends/onnx/src/jni/ -> librac_backend_onnx_jni.so +# +# This mirrors the Swift SDK architecture where each backend has its own +# XCFramework (RABackendLlamaCPP, RABackendONNX). + +cmake_minimum_required(VERSION 3.14) +project(runanywhere_commons_jni) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +# Find JNI +find_package(JNI REQUIRED) +include_directories(${JNI_INCLUDE_DIRS}) + +# Include runanywhere-commons headers +include_directories(${CMAKE_CURRENT_SOURCE_DIR}/../../include) + +# Source files +set(JNI_SOURCES + runanywhere_commons_jni.cpp +) + +# Create shared library +add_library(runanywhere_commons_jni SHARED ${JNI_SOURCES}) + +# Link against runanywhere-commons core ONLY +# Backend libraries are NOT linked here - they have their own JNI libraries +target_link_libraries(runanywhere_commons_jni + rac_commons + ${JNI_LIBRARIES} +) + +# Android-specific settings +if(ANDROID) + find_library(log-lib log) + target_link_libraries(runanywhere_commons_jni ${log-lib}) + + # Symbol visibility + set_target_properties(runanywhere_commons_jni PROPERTIES + C_VISIBILITY_PRESET hidden + CXX_VISIBILITY_PRESET hidden + VISIBILITY_INLINES_HIDDEN YES + ) + + # 16KB page alignment for Android 15+ (API 35) compliance - required Nov 2025 + target_link_options(runanywhere_commons_jni PRIVATE -Wl,-z,max-page-size=16384) +endif() + +# Set output name to match what Kotlin expects +set_target_properties(runanywhere_commons_jni PROPERTIES + OUTPUT_NAME "runanywhere_jni" + VERSION 1.0.0 + SOVERSION 1 +) + +# Installation +install(TARGETS runanywhere_commons_jni + LIBRARY DESTINATION lib + ARCHIVE DESTINATION lib + RUNTIME DESTINATION bin +) diff --git a/sdk/runanywhere-commons/src/jni/runanywhere_commons_jni.cpp b/sdk/runanywhere-commons/src/jni/runanywhere_commons_jni.cpp new file mode 100644 index 000000000..9d3a2d5a1 --- /dev/null +++ b/sdk/runanywhere-commons/src/jni/runanywhere_commons_jni.cpp @@ -0,0 +1,3418 @@ +/** + * RunAnywhere Commons JNI Bridge + * + * JNI layer that wraps the runanywhere-commons C API (rac_*.h) for Android/JVM. + * This provides a thin wrapper that exposes all rac_* C API functions via JNI. + * + * Package: com.runanywhere.sdk.native.bridge + * Class: RunAnywhereBridge + * + * Design principles: + * 1. Thin wrapper - minimal logic, just data conversion + * 2. Direct mapping to C API functions + * 3. Consistent error handling + * 4. Memory safety with proper cleanup + */ + +#include + +#include +#include +#include +#include + +// Include runanywhere-commons C API headers +#include "rac/core/rac_analytics_events.h" +#include "rac/core/rac_audio_utils.h" +#include "rac/core/rac_benchmark.h" +#include "rac/core/rac_core.h" +#include "rac/core/rac_error.h" +#include "rac/core/rac_logger.h" +#include "rac/core/rac_platform_adapter.h" +#include "rac/features/llm/rac_llm_component.h" +#include "rac/features/stt/rac_stt_component.h" +#include "rac/features/tts/rac_tts_component.h" +#include "rac/features/vad/rac_vad_component.h" +#include "rac/infrastructure/device/rac_device_manager.h" +#include "rac/infrastructure/model_management/rac_model_assignment.h" +#include "rac/infrastructure/model_management/rac_model_registry.h" +#include "rac/infrastructure/model_management/rac_model_types.h" +#include "rac/infrastructure/network/rac_dev_config.h" +#include "rac/infrastructure/network/rac_environment.h" +#include "rac/infrastructure/telemetry/rac_telemetry_manager.h" +#include "rac/infrastructure/telemetry/rac_telemetry_types.h" + +// NOTE: Backend headers are NOT included here. +// Backend registration is handled by their respective JNI libraries: +// - backends/llamacpp/src/jni/rac_backend_llamacpp_jni.cpp +// - backends/onnx/src/jni/rac_backend_onnx_jni.cpp + +#ifdef __ANDROID__ +#include +#define TAG "RACCommonsJNI" +#define LOGi(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__) +#define LOGe(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__) +#define LOGw(...) __android_log_print(ANDROID_LOG_WARN, TAG, __VA_ARGS__) +#define LOGd(...) __android_log_print(ANDROID_LOG_DEBUG, TAG, __VA_ARGS__) +#else +#include +#define LOGi(...) \ + fprintf(stdout, "[INFO] " __VA_ARGS__); \ + fprintf(stdout, "\n") +#define LOGe(...) \ + fprintf(stderr, "[ERROR] " __VA_ARGS__); \ + fprintf(stderr, "\n") +#define LOGw(...) \ + fprintf(stdout, "[WARN] " __VA_ARGS__); \ + fprintf(stdout, "\n") +#define LOGd(...) \ + fprintf(stdout, "[DEBUG] " __VA_ARGS__); \ + fprintf(stdout, "\n") +#endif + +// ============================================================================= +// Global State for Platform Adapter JNI Callbacks +// ============================================================================= + +static JavaVM* g_jvm = nullptr; +static jobject g_platform_adapter = nullptr; +static std::mutex g_adapter_mutex; + +// Method IDs for platform adapter callbacks (cached) +static jmethodID g_method_log = nullptr; +static jmethodID g_method_file_exists = nullptr; +static jmethodID g_method_file_read = nullptr; +static jmethodID g_method_file_write = nullptr; +static jmethodID g_method_file_delete = nullptr; +static jmethodID g_method_secure_get = nullptr; +static jmethodID g_method_secure_set = nullptr; +static jmethodID g_method_secure_delete = nullptr; +static jmethodID g_method_now_ms = nullptr; + +// ============================================================================= +// JNI OnLoad/OnUnload +// ============================================================================= + +JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) { + LOGi("JNI_OnLoad: runanywhere_commons_jni loaded"); + g_jvm = vm; + return JNI_VERSION_1_6; +} + +JNIEXPORT void JNI_OnUnload(JavaVM* vm, void* reserved) { + LOGi("JNI_OnUnload: runanywhere_commons_jni unloading"); + + std::lock_guard lock(g_adapter_mutex); + if (g_platform_adapter != nullptr) { + JNIEnv* env = nullptr; + if (vm->GetEnv(reinterpret_cast(&env), JNI_VERSION_1_6) == JNI_OK) { + env->DeleteGlobalRef(g_platform_adapter); + } + g_platform_adapter = nullptr; + } + g_jvm = nullptr; +} + +// ============================================================================= +// Helper Functions +// ============================================================================= + +static JNIEnv* getJNIEnv() { + if (g_jvm == nullptr) + return nullptr; + + JNIEnv* env = nullptr; + int status = g_jvm->GetEnv(reinterpret_cast(&env), JNI_VERSION_1_6); + + if (status == JNI_EDETACHED) { + if (g_jvm->AttachCurrentThread(&env, nullptr) != JNI_OK) { + return nullptr; + } + } + return env; +} + +static std::string getCString(JNIEnv* env, jstring str) { + if (str == nullptr) + return ""; + const char* chars = env->GetStringUTFChars(str, nullptr); + std::string result(chars); + env->ReleaseStringUTFChars(str, chars); + return result; +} + +static const char* getNullableCString(JNIEnv* env, jstring str, std::string& storage) { + if (str == nullptr) + return nullptr; + storage = getCString(env, str); + return storage.c_str(); +} + +// ============================================================================= +// Platform Adapter C Callbacks (called by C++ library) +// ============================================================================= + +// Forward declaration of the adapter struct +static rac_platform_adapter_t g_c_adapter; + +static void jni_log_callback(rac_log_level_t level, const char* tag, const char* message, + void* user_data) { + JNIEnv* env = getJNIEnv(); + if (env == nullptr || g_platform_adapter == nullptr || g_method_log == nullptr) { + // Fallback to native logging + LOGd("[%s] %s", tag ? tag : "RAC", message ? message : ""); + return; + } + + jstring jTag = env->NewStringUTF(tag ? tag : "RAC"); + jstring jMessage = env->NewStringUTF(message ? message : ""); + + env->CallVoidMethod(g_platform_adapter, g_method_log, static_cast(level), jTag, jMessage); + + env->DeleteLocalRef(jTag); + env->DeleteLocalRef(jMessage); +} + +static rac_bool_t jni_file_exists_callback(const char* path, void* user_data) { + JNIEnv* env = getJNIEnv(); + if (env == nullptr || g_platform_adapter == nullptr || g_method_file_exists == nullptr) { + return RAC_FALSE; + } + + jstring jPath = env->NewStringUTF(path ? path : ""); + jboolean result = env->CallBooleanMethod(g_platform_adapter, g_method_file_exists, jPath); + env->DeleteLocalRef(jPath); + + return result ? RAC_TRUE : RAC_FALSE; +} + +static rac_result_t jni_file_read_callback(const char* path, void** out_data, size_t* out_size, + void* user_data) { + JNIEnv* env = getJNIEnv(); + if (env == nullptr || g_platform_adapter == nullptr || g_method_file_read == nullptr) { + return RAC_ERROR_ADAPTER_NOT_SET; + } + + jstring jPath = env->NewStringUTF(path ? path : ""); + jbyteArray result = static_cast( + env->CallObjectMethod(g_platform_adapter, g_method_file_read, jPath)); + env->DeleteLocalRef(jPath); + + if (result == nullptr) { + *out_data = nullptr; + *out_size = 0; + return RAC_ERROR_FILE_NOT_FOUND; + } + + jsize len = env->GetArrayLength(result); + *out_size = static_cast(len); + *out_data = malloc(len); + env->GetByteArrayRegion(result, 0, len, reinterpret_cast(*out_data)); + + env->DeleteLocalRef(result); + return RAC_SUCCESS; +} + +static rac_result_t jni_file_write_callback(const char* path, const void* data, size_t size, + void* user_data) { + JNIEnv* env = getJNIEnv(); + if (env == nullptr || g_platform_adapter == nullptr || g_method_file_write == nullptr) { + return RAC_ERROR_ADAPTER_NOT_SET; + } + + jstring jPath = env->NewStringUTF(path ? path : ""); + jbyteArray jData = env->NewByteArray(static_cast(size)); + env->SetByteArrayRegion(jData, 0, static_cast(size), + reinterpret_cast(data)); + + jboolean result = env->CallBooleanMethod(g_platform_adapter, g_method_file_write, jPath, jData); + + env->DeleteLocalRef(jPath); + env->DeleteLocalRef(jData); + + return result ? RAC_SUCCESS : RAC_ERROR_FILE_WRITE_FAILED; +} + +static rac_result_t jni_file_delete_callback(const char* path, void* user_data) { + JNIEnv* env = getJNIEnv(); + if (env == nullptr || g_platform_adapter == nullptr || g_method_file_delete == nullptr) { + return RAC_ERROR_ADAPTER_NOT_SET; + } + + jstring jPath = env->NewStringUTF(path ? path : ""); + jboolean result = env->CallBooleanMethod(g_platform_adapter, g_method_file_delete, jPath); + env->DeleteLocalRef(jPath); + + return result ? RAC_SUCCESS : RAC_ERROR_FILE_WRITE_FAILED; +} + +static rac_result_t jni_secure_get_callback(const char* key, char** out_value, void* user_data) { + JNIEnv* env = getJNIEnv(); + if (env == nullptr || g_platform_adapter == nullptr || g_method_secure_get == nullptr) { + return RAC_ERROR_ADAPTER_NOT_SET; + } + + jstring jKey = env->NewStringUTF(key ? key : ""); + jstring result = + static_cast(env->CallObjectMethod(g_platform_adapter, g_method_secure_get, jKey)); + env->DeleteLocalRef(jKey); + + if (result == nullptr) { + *out_value = nullptr; + return RAC_ERROR_NOT_FOUND; + } + + const char* chars = env->GetStringUTFChars(result, nullptr); + *out_value = strdup(chars); + env->ReleaseStringUTFChars(result, chars); + env->DeleteLocalRef(result); + + return RAC_SUCCESS; +} + +static rac_result_t jni_secure_set_callback(const char* key, const char* value, void* user_data) { + JNIEnv* env = getJNIEnv(); + if (env == nullptr || g_platform_adapter == nullptr || g_method_secure_set == nullptr) { + return RAC_ERROR_ADAPTER_NOT_SET; + } + + jstring jKey = env->NewStringUTF(key ? key : ""); + jstring jValue = env->NewStringUTF(value ? value : ""); + jboolean result = env->CallBooleanMethod(g_platform_adapter, g_method_secure_set, jKey, jValue); + + env->DeleteLocalRef(jKey); + env->DeleteLocalRef(jValue); + + return result ? RAC_SUCCESS : RAC_ERROR_STORAGE_ERROR; +} + +static rac_result_t jni_secure_delete_callback(const char* key, void* user_data) { + JNIEnv* env = getJNIEnv(); + if (env == nullptr || g_platform_adapter == nullptr || g_method_secure_delete == nullptr) { + return RAC_ERROR_ADAPTER_NOT_SET; + } + + jstring jKey = env->NewStringUTF(key ? key : ""); + jboolean result = env->CallBooleanMethod(g_platform_adapter, g_method_secure_delete, jKey); + env->DeleteLocalRef(jKey); + + return result ? RAC_SUCCESS : RAC_ERROR_STORAGE_ERROR; +} + +static int64_t jni_now_ms_callback(void* user_data) { + JNIEnv* env = getJNIEnv(); + if (env == nullptr || g_platform_adapter == nullptr || g_method_now_ms == nullptr) { + // Fallback to system time + return static_cast(time(nullptr)) * 1000; + } + + return env->CallLongMethod(g_platform_adapter, g_method_now_ms); +} + +// ============================================================================= +// JNI FUNCTIONS - Core Initialization +// ============================================================================= + +extern "C" { + +JNIEXPORT jint JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racInit(JNIEnv* env, jclass clazz) { + LOGi("racInit called"); + + // Check if platform adapter is set + if (g_platform_adapter == nullptr) { + LOGe("racInit: Platform adapter not set! Call racSetPlatformAdapter first."); + return RAC_ERROR_ADAPTER_NOT_SET; + } + + // Initialize with the C adapter struct + rac_config_t config = {}; + config.platform_adapter = &g_c_adapter; + config.log_level = RAC_LOG_DEBUG; + config.log_tag = "RAC"; + + rac_result_t result = rac_init(&config); + + if (result != RAC_SUCCESS) { + LOGe("racInit failed with code: %d", result); + } else { + LOGi("racInit succeeded"); + } + + return static_cast(result); +} + +JNIEXPORT jint JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racShutdown(JNIEnv* env, jclass clazz) { + LOGi("racShutdown called"); + rac_shutdown(); + return RAC_SUCCESS; +} + +JNIEXPORT jboolean JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racIsInitialized(JNIEnv* env, + jclass clazz) { + return rac_is_initialized() ? JNI_TRUE : JNI_FALSE; +} + +JNIEXPORT jint JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racSetPlatformAdapter(JNIEnv* env, + jclass clazz, + jobject adapter) { + LOGi("racSetPlatformAdapter called"); + + std::lock_guard lock(g_adapter_mutex); + + // Clean up previous adapter + if (g_platform_adapter != nullptr) { + env->DeleteGlobalRef(g_platform_adapter); + g_platform_adapter = nullptr; + } + + if (adapter == nullptr) { + LOGw("racSetPlatformAdapter: null adapter provided"); + return RAC_ERROR_INVALID_ARGUMENT; + } + + // Create global reference to adapter + g_platform_adapter = env->NewGlobalRef(adapter); + + // Cache method IDs + jclass adapterClass = env->GetObjectClass(adapter); + + g_method_log = + env->GetMethodID(adapterClass, "log", "(ILjava/lang/String;Ljava/lang/String;)V"); + g_method_file_exists = env->GetMethodID(adapterClass, "fileExists", "(Ljava/lang/String;)Z"); + g_method_file_read = env->GetMethodID(adapterClass, "fileRead", "(Ljava/lang/String;)[B"); + g_method_file_write = env->GetMethodID(adapterClass, "fileWrite", "(Ljava/lang/String;[B)Z"); + g_method_file_delete = env->GetMethodID(adapterClass, "fileDelete", "(Ljava/lang/String;)Z"); + g_method_secure_get = + env->GetMethodID(adapterClass, "secureGet", "(Ljava/lang/String;)Ljava/lang/String;"); + g_method_secure_set = + env->GetMethodID(adapterClass, "secureSet", "(Ljava/lang/String;Ljava/lang/String;)Z"); + g_method_secure_delete = + env->GetMethodID(adapterClass, "secureDelete", "(Ljava/lang/String;)Z"); + g_method_now_ms = env->GetMethodID(adapterClass, "nowMs", "()J"); + + env->DeleteLocalRef(adapterClass); + + // Initialize the C adapter struct with our JNI callbacks + memset(&g_c_adapter, 0, sizeof(g_c_adapter)); + g_c_adapter.log = jni_log_callback; + g_c_adapter.file_exists = jni_file_exists_callback; + g_c_adapter.file_read = jni_file_read_callback; + g_c_adapter.file_write = jni_file_write_callback; + g_c_adapter.file_delete = jni_file_delete_callback; + g_c_adapter.secure_get = jni_secure_get_callback; + g_c_adapter.secure_set = jni_secure_set_callback; + g_c_adapter.secure_delete = jni_secure_delete_callback; + g_c_adapter.now_ms = jni_now_ms_callback; + g_c_adapter.user_data = nullptr; + + LOGi("racSetPlatformAdapter: adapter set successfully"); + return RAC_SUCCESS; +} + +JNIEXPORT jobject JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racGetPlatformAdapter(JNIEnv* env, + jclass clazz) { + std::lock_guard lock(g_adapter_mutex); + return g_platform_adapter; +} + +JNIEXPORT jint JNICALL Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racConfigureLogging( + JNIEnv* env, jclass clazz, jint level, jstring logFilePath) { + // For now, just configure the log level + // The log file path is not used in the current implementation + rac_result_t result = rac_configure_logging(static_cast(0)); // Development + return static_cast(result); +} + +JNIEXPORT void JNICALL Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racLog( + JNIEnv* env, jclass clazz, jint level, jstring tag, jstring message) { + std::string tagStr = getCString(env, tag); + std::string msgStr = getCString(env, message); + + rac_log(static_cast(level), tagStr.c_str(), msgStr.c_str()); +} + +// ============================================================================= +// JNI FUNCTIONS - LLM Component +// ============================================================================= + +JNIEXPORT jlong JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racLlmComponentCreate(JNIEnv* env, + jclass clazz) { + rac_handle_t handle = RAC_INVALID_HANDLE; + rac_result_t result = rac_llm_component_create(&handle); + if (result != RAC_SUCCESS) { + LOGe("Failed to create LLM component: %d", result); + return 0; + } + return reinterpret_cast(handle); +} + +JNIEXPORT void JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racLlmComponentDestroy(JNIEnv* env, + jclass clazz, + jlong handle) { + if (handle != 0) { + rac_llm_component_destroy(reinterpret_cast(handle)); + } +} + +JNIEXPORT jint JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racLlmComponentLoadModel( + JNIEnv* env, jclass clazz, jlong handle, jstring modelPath, jstring modelId, + jstring modelName) { + LOGi("racLlmComponentLoadModel called with handle=%lld", (long long)handle); + if (handle == 0) + return RAC_ERROR_INVALID_HANDLE; + + std::string path = getCString(env, modelPath); + std::string id = getCString(env, modelId); + std::string name = getCString(env, modelName); + LOGi("racLlmComponentLoadModel path=%s, id=%s, name=%s", path.c_str(), id.c_str(), + name.c_str()); + + // Debug: List registered providers BEFORE loading + const char** provider_names = nullptr; + size_t provider_count = 0; + rac_result_t list_result = rac_service_list_providers(RAC_CAPABILITY_TEXT_GENERATION, + &provider_names, &provider_count); + LOGi("Before load_model - TEXT_GENERATION providers: count=%zu, list_result=%d", provider_count, + list_result); + if (provider_names && provider_count > 0) { + for (size_t i = 0; i < provider_count; i++) { + LOGi(" Provider[%zu]: %s", i, provider_names[i] ? provider_names[i] : "NULL"); + } + } else { + LOGw("NO providers registered for TEXT_GENERATION!"); + } + + // Pass model_path, model_id, and model_name separately to C++ lifecycle + rac_result_t result = rac_llm_component_load_model( + reinterpret_cast(handle), + path.c_str(), // model_path + id.c_str(), // model_id (for telemetry) + name.empty() ? nullptr : name.c_str() // model_name (optional, for telemetry) + ); + LOGi("rac_llm_component_load_model returned: %d", result); + + return static_cast(result); +} + +JNIEXPORT void JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racLlmComponentUnload(JNIEnv* env, + jclass clazz, + jlong handle) { + if (handle != 0) { + rac_llm_component_unload(reinterpret_cast(handle)); + } +} + +JNIEXPORT jstring JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racLlmComponentGenerate( + JNIEnv* env, jclass clazz, jlong handle, jstring prompt, jstring configJson) { + LOGi("racLlmComponentGenerate called with handle=%lld", (long long)handle); + + if (handle == 0) { + LOGe("racLlmComponentGenerate: invalid handle"); + return nullptr; + } + + std::string promptStr = getCString(env, prompt); + LOGi("racLlmComponentGenerate prompt length=%zu", promptStr.length()); + + std::string configStorage; + const char* config = getNullableCString(env, configJson, configStorage); + + rac_llm_options_t options = {}; + options.max_tokens = 512; + options.temperature = 0.7f; + options.top_p = 1.0f; + options.streaming_enabled = RAC_FALSE; + + rac_llm_result_t result = {}; + LOGi("racLlmComponentGenerate calling rac_llm_component_generate..."); + + rac_result_t status = rac_llm_component_generate(reinterpret_cast(handle), + promptStr.c_str(), &options, &result); + + LOGi("racLlmComponentGenerate status=%d", status); + + if (status != RAC_SUCCESS) { + LOGe("racLlmComponentGenerate failed with status=%d", status); + return nullptr; + } + + // Return result as JSON string + if (result.text != nullptr) { + LOGi("racLlmComponentGenerate result text length=%zu", strlen(result.text)); + + // Build JSON result - keys must match what Kotlin expects + std::string json = "{"; + json += "\"text\":\""; + // Escape special characters in text for JSON + for (const char* p = result.text; *p; p++) { + switch (*p) { + case '"': + json += "\\\""; + break; + case '\\': + json += "\\\\"; + break; + case '\n': + json += "\\n"; + break; + case '\r': + json += "\\r"; + break; + case '\t': + json += "\\t"; + break; + default: + json += *p; + break; + } + } + json += "\","; + // Kotlin expects these keys: + json += "\"tokens_generated\":" + std::to_string(result.completion_tokens) + ","; + json += "\"tokens_evaluated\":" + std::to_string(result.prompt_tokens) + ","; + json += "\"stop_reason\":" + std::to_string(0) + ","; // 0 = normal completion + json += "\"total_time_ms\":" + std::to_string(result.total_time_ms) + ","; + json += "\"tokens_per_second\":" + std::to_string(result.tokens_per_second); + json += "}"; + + LOGi("racLlmComponentGenerate returning JSON: %zu bytes", json.length()); + + jstring jResult = env->NewStringUTF(json.c_str()); + rac_llm_result_free(&result); + return jResult; + } + + LOGw("racLlmComponentGenerate: result.text is null"); + return env->NewStringUTF("{\"text\":\"\",\"completion_tokens\":0}"); +} + +// ======================================================================== +// STREAMING CONTEXT - for collecting tokens during stream generation +// ======================================================================== + +struct LLMStreamContext { + std::string accumulated_text; + int token_count = 0; + bool is_complete = false; + bool has_error = false; + rac_result_t error_code = RAC_SUCCESS; + std::string error_message; + rac_llm_result_t final_result = {}; + std::mutex mtx; + std::condition_variable cv; +}; + +static rac_bool_t llm_stream_token_callback(const char* token, void* user_data) { + if (!user_data || !token) + return RAC_TRUE; + + auto* ctx = static_cast(user_data); + std::lock_guard lock(ctx->mtx); + + ctx->accumulated_text += token; + ctx->token_count++; + + // Log every 10 tokens to avoid spam + if (ctx->token_count % 10 == 0) { + LOGi("Streaming: %d tokens accumulated", ctx->token_count); + } + + return RAC_TRUE; // Continue streaming +} + +static void llm_stream_complete_callback(const rac_llm_result_t* result, void* user_data) { + if (!user_data) + return; + + auto* ctx = static_cast(user_data); + std::lock_guard lock(ctx->mtx); + + LOGi("Streaming complete: %d tokens", ctx->token_count); + + // Copy final result metrics if available + if (result) { + ctx->final_result.completion_tokens = + result->completion_tokens > 0 ? result->completion_tokens : ctx->token_count; + ctx->final_result.prompt_tokens = result->prompt_tokens; + ctx->final_result.total_tokens = result->total_tokens; + ctx->final_result.total_time_ms = result->total_time_ms; + ctx->final_result.tokens_per_second = result->tokens_per_second; + } else { + ctx->final_result.completion_tokens = ctx->token_count; + } + + ctx->is_complete = true; + ctx->cv.notify_one(); +} + +static void llm_stream_error_callback(rac_result_t error_code, const char* error_message, + void* user_data) { + if (!user_data) + return; + + auto* ctx = static_cast(user_data); + std::lock_guard lock(ctx->mtx); + + LOGe("Streaming error: %d - %s", error_code, error_message ? error_message : "Unknown"); + + ctx->has_error = true; + ctx->error_code = error_code; + ctx->error_message = error_message ? error_message : "Unknown error"; + ctx->is_complete = true; + ctx->cv.notify_one(); +} + +// ======================================================================== +// STREAMING WITH CALLBACK - Real-time token streaming to Kotlin +// ======================================================================== + +struct LLMStreamCallbackContext { + JavaVM* jvm = nullptr; + jobject callback = nullptr; + jmethodID onTokenMethod = nullptr; + std::string accumulated_text; + int token_count = 0; + bool is_complete = false; + bool has_error = false; + rac_result_t error_code = RAC_SUCCESS; + std::string error_message; + rac_llm_result_t final_result = {}; +}; + +static rac_bool_t llm_stream_callback_token(const char* token, void* user_data) { + if (!user_data || !token) + return RAC_TRUE; + + auto* ctx = static_cast(user_data); + + // Accumulate token + ctx->accumulated_text += token; + ctx->token_count++; + + // Call back to Kotlin + if (ctx->jvm && ctx->callback && ctx->onTokenMethod) { + JNIEnv* env = nullptr; + bool needsDetach = false; + + jint result = ctx->jvm->GetEnv(reinterpret_cast(&env), JNI_VERSION_1_6); + if (result == JNI_EDETACHED) { + if (ctx->jvm->AttachCurrentThread(&env, nullptr) == JNI_OK) { + needsDetach = true; + } else { + LOGe("Failed to attach thread for streaming callback"); + return RAC_TRUE; + } + } + + if (env) { + jsize len = static_cast(strlen(token)); + + jbyteArray jToken = env->NewByteArray(len); + env->SetByteArrayRegion( + jToken, + 0, + len, + reinterpret_cast(token) + ); + + jboolean continueGen = + env->CallBooleanMethod(ctx->callback, ctx->onTokenMethod, jToken); + env->DeleteLocalRef(jToken); + + if (env->ExceptionCheck()) { + env->ExceptionDescribe(); + env->ExceptionClear(); + } + + if (needsDetach) { + ctx->jvm->DetachCurrentThread(); + } + + if (!continueGen) { + LOGi("Streaming cancelled by callback"); + return RAC_FALSE; // Stop streaming + } + } + } + + return RAC_TRUE; // Continue streaming +} + +static void llm_stream_callback_complete(const rac_llm_result_t* result, void* user_data) { + if (!user_data) + return; + + auto* ctx = static_cast(user_data); + + LOGi("Streaming with callback complete: %d tokens", ctx->token_count); + + if (result) { + ctx->final_result.completion_tokens = + result->completion_tokens > 0 ? result->completion_tokens : ctx->token_count; + ctx->final_result.prompt_tokens = result->prompt_tokens; + ctx->final_result.total_tokens = result->total_tokens; + ctx->final_result.total_time_ms = result->total_time_ms; + ctx->final_result.tokens_per_second = result->tokens_per_second; + } else { + ctx->final_result.completion_tokens = ctx->token_count; + } + + ctx->is_complete = true; +} + +static void llm_stream_callback_error(rac_result_t error_code, const char* error_message, + void* user_data) { + if (!user_data) + return; + + auto* ctx = static_cast(user_data); + + LOGe("Streaming with callback error: %d - %s", error_code, + error_message ? error_message : "Unknown"); + + ctx->has_error = true; + ctx->error_code = error_code; + ctx->error_message = error_message ? error_message : "Unknown error"; + ctx->is_complete = true; +} + +JNIEXPORT jstring JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racLlmComponentGenerateStream( + JNIEnv* env, jclass clazz, jlong handle, jstring prompt, jstring configJson) { + LOGi("racLlmComponentGenerateStream called with handle=%lld", (long long)handle); + + if (handle == 0) { + LOGe("racLlmComponentGenerateStream: invalid handle"); + return nullptr; + } + + std::string promptStr = getCString(env, prompt); + LOGi("racLlmComponentGenerateStream prompt length=%zu", promptStr.length()); + + std::string configStorage; + const char* config = getNullableCString(env, configJson, configStorage); + + // Parse config for options + rac_llm_options_t options = {}; + options.max_tokens = 512; + options.temperature = 0.7f; + options.top_p = 1.0f; + options.streaming_enabled = RAC_TRUE; + + // Create streaming context + LLMStreamContext ctx; + + LOGi("racLlmComponentGenerateStream calling rac_llm_component_generate_stream..."); + + rac_result_t status = rac_llm_component_generate_stream( + reinterpret_cast(handle), promptStr.c_str(), &options, + llm_stream_token_callback, llm_stream_complete_callback, llm_stream_error_callback, &ctx); + + if (status != RAC_SUCCESS) { + LOGe("rac_llm_component_generate_stream failed with status=%d", status); + return nullptr; + } + + // Wait for streaming to complete + { + std::unique_lock lock(ctx.mtx); + ctx.cv.wait(lock, [&ctx] { return ctx.is_complete; }); + } + + if (ctx.has_error) { + LOGe("Streaming failed: %s", ctx.error_message.c_str()); + return nullptr; + } + + LOGi("racLlmComponentGenerateStream result text length=%zu, tokens=%d", + ctx.accumulated_text.length(), ctx.token_count); + + // Build JSON result - keys must match what Kotlin expects + std::string json = "{"; + json += "\"text\":\""; + // Escape special characters in text for JSON + for (char c : ctx.accumulated_text) { + switch (c) { + case '"': + json += "\\\""; + break; + case '\\': + json += "\\\\"; + break; + case '\n': + json += "\\n"; + break; + case '\r': + json += "\\r"; + break; + case '\t': + json += "\\t"; + break; + default: + json += c; + break; + } + } + json += "\","; + // Kotlin expects these keys: + json += "\"tokens_generated\":" + std::to_string(ctx.final_result.completion_tokens) + ","; + json += "\"tokens_evaluated\":" + std::to_string(ctx.final_result.prompt_tokens) + ","; + json += "\"stop_reason\":" + std::to_string(0) + ","; // 0 = normal completion + json += "\"total_time_ms\":" + std::to_string(ctx.final_result.total_time_ms) + ","; + json += "\"tokens_per_second\":" + std::to_string(ctx.final_result.tokens_per_second); + json += "}"; + + LOGi("racLlmComponentGenerateStream returning JSON: %zu bytes", json.length()); + + return env->NewStringUTF(json.c_str()); +} + +// ======================================================================== +// STREAMING WITH KOTLIN CALLBACK - Real-time token-by-token streaming +// ======================================================================== + +JNIEXPORT jstring JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racLlmComponentGenerateStreamWithCallback( + JNIEnv* env, jclass clazz, jlong handle, jstring prompt, jstring configJson, + jobject tokenCallback) { + LOGi("racLlmComponentGenerateStreamWithCallback called with handle=%lld", (long long)handle); + + if (handle == 0) { + LOGe("racLlmComponentGenerateStreamWithCallback: invalid handle"); + return nullptr; + } + + if (!tokenCallback) { + LOGe("racLlmComponentGenerateStreamWithCallback: null callback"); + return nullptr; + } + + std::string promptStr = getCString(env, prompt); + LOGi("racLlmComponentGenerateStreamWithCallback prompt length=%zu", promptStr.length()); + + std::string configStorage; + const char* config = getNullableCString(env, configJson, configStorage); + + // Get JVM and callback method + JavaVM* jvm = nullptr; + env->GetJavaVM(&jvm); + + jclass callbackClass = env->GetObjectClass(tokenCallback); + jmethodID onTokenMethod = env->GetMethodID(callbackClass, "onToken", "([B)Z"); + + if (!onTokenMethod) { + LOGe("racLlmComponentGenerateStreamWithCallback: could not find onToken method"); + return nullptr; + } + + // Create global ref to callback to ensure it survives across threads + jobject globalCallback = env->NewGlobalRef(tokenCallback); + + // Parse config for options + rac_llm_options_t options = {}; + options.max_tokens = 512; + options.temperature = 0.7f; + options.top_p = 1.0f; + options.streaming_enabled = RAC_TRUE; + + // Create streaming callback context + LLMStreamCallbackContext ctx; + ctx.jvm = jvm; + ctx.callback = globalCallback; + ctx.onTokenMethod = onTokenMethod; + + LOGi("racLlmComponentGenerateStreamWithCallback calling rac_llm_component_generate_stream..."); + + rac_result_t status = rac_llm_component_generate_stream( + reinterpret_cast(handle), promptStr.c_str(), &options, + llm_stream_callback_token, llm_stream_callback_complete, llm_stream_callback_error, &ctx); + + // Clean up global ref + env->DeleteGlobalRef(globalCallback); + + if (status != RAC_SUCCESS) { + LOGe("rac_llm_component_generate_stream failed with status=%d", status); + return nullptr; + } + + if (ctx.has_error) { + LOGe("Streaming failed: %s", ctx.error_message.c_str()); + return nullptr; + } + + LOGi("racLlmComponentGenerateStreamWithCallback result text length=%zu, tokens=%d", + ctx.accumulated_text.length(), ctx.token_count); + + // Build JSON result + std::string json = "{"; + json += "\"text\":\""; + for (char c : ctx.accumulated_text) { + switch (c) { + case '"': + json += "\\\""; + break; + case '\\': + json += "\\\\"; + break; + case '\n': + json += "\\n"; + break; + case '\r': + json += "\\r"; + break; + case '\t': + json += "\\t"; + break; + default: + json += c; + break; + } + } + json += "\","; + json += "\"tokens_generated\":" + std::to_string(ctx.final_result.completion_tokens) + ","; + json += "\"tokens_evaluated\":" + std::to_string(ctx.final_result.prompt_tokens) + ","; + json += "\"stop_reason\":" + std::to_string(0) + ","; + json += "\"total_time_ms\":" + std::to_string(ctx.final_result.total_time_ms) + ","; + json += "\"tokens_per_second\":" + std::to_string(ctx.final_result.tokens_per_second); + json += "}"; + + LOGi("racLlmComponentGenerateStreamWithCallback returning JSON: %zu bytes", json.length()); + + return env->NewStringUTF(json.c_str()); +} + +// ======================================================================== +// STREAMING WITH KOTLIN CALLBACK AND BENCHMARK TIMING +// ======================================================================== + +JNIEXPORT jstring JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racLlmComponentGenerateStreamWithTiming( + JNIEnv* env, jclass clazz, jlong handle, jstring prompt, jstring configJson, + jobject tokenCallback) { + LOGi("racLlmComponentGenerateStreamWithTiming called with handle=%lld", (long long)handle); + + if (handle == 0) { + LOGe("racLlmComponentGenerateStreamWithTiming: invalid handle"); + return nullptr; + } + + if (!tokenCallback) { + LOGe("racLlmComponentGenerateStreamWithTiming: null callback"); + return nullptr; + } + + std::string promptStr = getCString(env, prompt); + LOGi("racLlmComponentGenerateStreamWithTiming prompt length=%zu", promptStr.length()); + + std::string configStorage; + const char* config = getNullableCString(env, configJson, configStorage); + + // Get JVM and callback method + JavaVM* jvm = nullptr; + env->GetJavaVM(&jvm); + + jclass callbackClass = env->GetObjectClass(tokenCallback); + jmethodID onTokenMethod = env->GetMethodID(callbackClass, "onToken", "(Ljava/lang/String;)Z"); + env->DeleteLocalRef(callbackClass); + + if (!onTokenMethod) { + LOGe("racLlmComponentGenerateStreamWithTiming: could not find onToken method"); + return nullptr; + } + + // Create global ref to callback to ensure it survives across threads + jobject globalCallback = env->NewGlobalRef(tokenCallback); + + // Parse config for options + rac_llm_options_t options = {}; + options.max_tokens = 512; + options.temperature = 0.7f; + options.top_p = 1.0f; + options.streaming_enabled = RAC_TRUE; + + // Create streaming callback context + LLMStreamCallbackContext ctx; + ctx.jvm = jvm; + ctx.callback = globalCallback; + ctx.onTokenMethod = onTokenMethod; + + // Initialize benchmark timing struct + rac_benchmark_timing_t timing = {}; + rac_benchmark_timing_init(&timing); + + LOGi("racLlmComponentGenerateStreamWithTiming calling rac_llm_component_generate_stream_with_timing..."); + + rac_result_t status = rac_llm_component_generate_stream_with_timing( + reinterpret_cast(handle), promptStr.c_str(), &options, + llm_stream_callback_token, llm_stream_callback_complete, llm_stream_callback_error, &ctx, + &timing); + + // Clean up global ref + env->DeleteGlobalRef(globalCallback); + + if (status != RAC_SUCCESS) { + LOGe("rac_llm_component_generate_stream_with_timing failed with status=%d", status); + return nullptr; + } + + if (ctx.has_error) { + LOGe("Streaming with timing failed: %s", ctx.error_message.c_str()); + return nullptr; + } + + LOGi("racLlmComponentGenerateStreamWithTiming result text length=%zu, tokens=%d", + ctx.accumulated_text.length(), ctx.token_count); + + // Build JSON result with timing + std::string json = "{"; + json += "\"text\":\""; + for (char c : ctx.accumulated_text) { + switch (c) { + case '"': + json += "\\\""; + break; + case '\\': + json += "\\\\"; + break; + case '\n': + json += "\\n"; + break; + case '\r': + json += "\\r"; + break; + case '\t': + json += "\\t"; + break; + default: + json += c; + break; + } + } + json += "\","; + json += "\"tokens_generated\":" + std::to_string(ctx.final_result.completion_tokens) + ","; + json += "\"tokens_evaluated\":" + std::to_string(ctx.final_result.prompt_tokens) + ","; + json += "\"stop_reason\":" + std::to_string(0) + ","; + json += "\"total_time_ms\":" + std::to_string(ctx.final_result.total_time_ms) + ","; + json += "\"tokens_per_second\":" + std::to_string(ctx.final_result.tokens_per_second) + ","; + // Add benchmark timing fields + json += "\"t0_request_start_ms\":" + std::to_string(timing.t0_request_start_ms) + ","; + json += "\"t2_prefill_start_ms\":" + std::to_string(timing.t2_prefill_start_ms) + ","; + json += "\"t3_prefill_end_ms\":" + std::to_string(timing.t3_prefill_end_ms) + ","; + json += "\"t4_first_token_ms\":" + std::to_string(timing.t4_first_token_ms) + ","; + json += "\"t5_last_token_ms\":" + std::to_string(timing.t5_last_token_ms) + ","; + json += "\"t6_request_end_ms\":" + std::to_string(timing.t6_request_end_ms) + ","; + json += "\"prompt_tokens\":" + std::to_string(timing.prompt_tokens) + ","; + json += "\"output_tokens\":" + std::to_string(timing.output_tokens) + ","; + json += "\"benchmark_status\":" + std::to_string(timing.status) + ","; + json += "\"benchmark_error_code\":" + std::to_string(timing.error_code); + json += "}"; + + LOGi("racLlmComponentGenerateStreamWithTiming returning JSON: %zu bytes", json.length()); + + return env->NewStringUTF(json.c_str()); +} + +JNIEXPORT void JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racLlmComponentCancel(JNIEnv* env, + jclass clazz, + jlong handle) { + if (handle != 0) { + rac_llm_component_cancel(reinterpret_cast(handle)); + } +} + +JNIEXPORT jint JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racLlmComponentGetContextSize( + JNIEnv* env, jclass clazz, jlong handle) { + // NOTE: rac_llm_component_get_context_size is not in current API, returning default + if (handle == 0) + return 0; + return 4096; // Default context size +} + +JNIEXPORT jint JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racLlmComponentTokenize(JNIEnv* env, + jclass clazz, + jlong handle, + jstring text) { + // NOTE: rac_llm_component_tokenize is not in current API, returning estimate + if (handle == 0) + return 0; + std::string textStr = getCString(env, text); + // Rough token estimate: ~4 chars per token + return static_cast(textStr.length() / 4); +} + +JNIEXPORT jint JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racLlmComponentGetState(JNIEnv* env, + jclass clazz, + jlong handle) { + if (handle == 0) + return 0; + return static_cast(rac_llm_component_get_state(reinterpret_cast(handle))); +} + +JNIEXPORT jboolean JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racLlmComponentIsLoaded(JNIEnv* env, + jclass clazz, + jlong handle) { + if (handle == 0) + return JNI_FALSE; + return rac_llm_component_is_loaded(reinterpret_cast(handle)) ? JNI_TRUE + : JNI_FALSE; +} + +JNIEXPORT void JNICALL Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racLlmSetCallbacks( + JNIEnv* env, jclass clazz, jobject streamCallback, jobject progressCallback) { + // TODO: Implement callback registration +} + +// ============================================================================= +// JNI FUNCTIONS - STT Component +// ============================================================================= + +JNIEXPORT jlong JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racSttComponentCreate(JNIEnv* env, + jclass clazz) { + rac_handle_t handle = RAC_INVALID_HANDLE; + rac_result_t result = rac_stt_component_create(&handle); + if (result != RAC_SUCCESS) { + LOGe("Failed to create STT component: %d", result); + return 0; + } + return reinterpret_cast(handle); +} + +JNIEXPORT void JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racSttComponentDestroy(JNIEnv* env, + jclass clazz, + jlong handle) { + if (handle != 0) { + rac_stt_component_destroy(reinterpret_cast(handle)); + } +} + +JNIEXPORT jint JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racSttComponentLoadModel( + JNIEnv* env, jclass clazz, jlong handle, jstring modelPath, jstring modelId, + jstring modelName) { + LOGi("racSttComponentLoadModel called with handle=%lld", (long long)handle); + if (handle == 0) + return RAC_ERROR_INVALID_HANDLE; + + std::string path = getCString(env, modelPath); + std::string id = getCString(env, modelId); + std::string name = getCString(env, modelName); + LOGi("racSttComponentLoadModel path=%s, id=%s, name=%s", path.c_str(), id.c_str(), + name.c_str()); + + // Debug: List registered providers BEFORE loading + const char** provider_names = nullptr; + size_t provider_count = 0; + rac_result_t list_result = + rac_service_list_providers(RAC_CAPABILITY_STT, &provider_names, &provider_count); + LOGi("Before load_model - STT providers: count=%zu, list_result=%d", provider_count, + list_result); + if (provider_names && provider_count > 0) { + for (size_t i = 0; i < provider_count; i++) { + LOGi(" Provider[%zu]: %s", i, provider_names[i] ? provider_names[i] : "NULL"); + } + } else { + LOGw("NO providers registered for STT!"); + } + + // Pass model_path, model_id, and model_name separately to C++ lifecycle + rac_result_t result = rac_stt_component_load_model( + reinterpret_cast(handle), + path.c_str(), // model_path + id.c_str(), // model_id (for telemetry) + name.empty() ? nullptr : name.c_str() // model_name (optional, for telemetry) + ); + LOGi("rac_stt_component_load_model returned: %d", result); + + return static_cast(result); +} + +JNIEXPORT void JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racSttComponentUnload(JNIEnv* env, + jclass clazz, + jlong handle) { + if (handle != 0) { + rac_stt_component_unload(reinterpret_cast(handle)); + } +} + +JNIEXPORT jstring JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racSttComponentTranscribe( + JNIEnv* env, jclass clazz, jlong handle, jbyteArray audioData, jstring configJson) { + if (handle == 0 || audioData == nullptr) + return nullptr; + + jsize len = env->GetArrayLength(audioData); + jbyte* data = env->GetByteArrayElements(audioData, nullptr); + + // Use default options which properly initializes sample_rate to 16000 + rac_stt_options_t options = RAC_STT_OPTIONS_DEFAULT; + + // Parse configJson to override sample_rate if provided + if (configJson != nullptr) { + const char* json = env->GetStringUTFChars(configJson, nullptr); + if (json != nullptr) { + // Simple JSON parsing for sample_rate + const char* sample_rate_key = "\"sample_rate\":"; + const char* pos = strstr(json, sample_rate_key); + if (pos != nullptr) { + pos += strlen(sample_rate_key); + int sample_rate = atoi(pos); + if (sample_rate > 0) { + options.sample_rate = sample_rate; + LOGd("Using sample_rate from config: %d", sample_rate); + } + } + env->ReleaseStringUTFChars(configJson, json); + } + } + + LOGd("STT transcribe: %d bytes, sample_rate=%d", (int)len, options.sample_rate); + + rac_stt_result_t result = {}; + + // Audio data is 16-bit PCM (ByteArray from Android AudioRecord) + // Pass the raw bytes - the audio_format in options tells C++ how to interpret it + rac_result_t status = rac_stt_component_transcribe(reinterpret_cast(handle), + data, // Pass raw bytes (void*) + static_cast(len), // Size in bytes + &options, &result); + + env->ReleaseByteArrayElements(audioData, data, JNI_ABORT); + + if (status != RAC_SUCCESS) { + LOGe("STT transcribe failed with status: %d", status); + return nullptr; + } + + // Build JSON result + std::string json_result = "{"; + json_result += "\"text\":\""; + if (result.text != nullptr) { + // Escape special characters in text + for (const char* p = result.text; *p; ++p) { + switch (*p) { + case '"': + json_result += "\\\""; + break; + case '\\': + json_result += "\\\\"; + break; + case '\n': + json_result += "\\n"; + break; + case '\r': + json_result += "\\r"; + break; + case '\t': + json_result += "\\t"; + break; + default: + json_result += *p; + break; + } + } + } + json_result += "\","; + json_result += "\"language\":\"" + + std::string(result.detected_language ? result.detected_language : "en") + "\","; + json_result += "\"duration_ms\":" + std::to_string(result.processing_time_ms) + ","; + json_result += "\"completion_reason\":1,"; // END_OF_AUDIO + json_result += "\"confidence\":" + std::to_string(result.confidence); + json_result += "}"; + + rac_stt_result_free(&result); + + LOGd("STT transcribe result: %s", json_result.c_str()); + return env->NewStringUTF(json_result.c_str()); +} + +JNIEXPORT jstring JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racSttComponentTranscribeFile( + JNIEnv* env, jclass clazz, jlong handle, jstring audioPath, jstring configJson) { + // NOTE: rac_stt_component_transcribe_file does not exist in current API + // This is a stub - actual implementation would need to read file and call transcribe + if (handle == 0) + return nullptr; + return env->NewStringUTF("{\"error\": \"transcribe_file not implemented\"}"); +} + +JNIEXPORT jstring JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racSttComponentTranscribeStream( + JNIEnv* env, jclass clazz, jlong handle, jbyteArray audioData, jstring configJson) { + return Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racSttComponentTranscribe( + env, clazz, handle, audioData, configJson); +} + +JNIEXPORT void JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racSttComponentCancel(JNIEnv* env, + jclass clazz, + jlong handle) { + // STT component doesn't have a cancel method, just unload + if (handle != 0) { + rac_stt_component_unload(reinterpret_cast(handle)); + } +} + +JNIEXPORT jint JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racSttComponentGetState(JNIEnv* env, + jclass clazz, + jlong handle) { + if (handle == 0) + return 0; + return static_cast(rac_stt_component_get_state(reinterpret_cast(handle))); +} + +JNIEXPORT jboolean JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racSttComponentIsLoaded(JNIEnv* env, + jclass clazz, + jlong handle) { + if (handle == 0) + return JNI_FALSE; + return rac_stt_component_is_loaded(reinterpret_cast(handle)) ? JNI_TRUE + : JNI_FALSE; +} + +JNIEXPORT jstring JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racSttComponentGetLanguages(JNIEnv* env, + jclass clazz, + jlong handle) { + // Return empty array for now + return env->NewStringUTF("[]"); +} + +JNIEXPORT jstring JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racSttComponentDetectLanguage( + JNIEnv* env, jclass clazz, jlong handle, jbyteArray audioData) { + // Return null for now - language detection not implemented + return nullptr; +} + +JNIEXPORT void JNICALL Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racSttSetCallbacks( + JNIEnv* env, jclass clazz, jobject partialCallback, jobject progressCallback) { + // TODO: Implement callback registration +} + +// ============================================================================= +// JNI FUNCTIONS - TTS Component +// ============================================================================= + +JNIEXPORT jlong JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racTtsComponentCreate(JNIEnv* env, + jclass clazz) { + rac_handle_t handle = RAC_INVALID_HANDLE; + rac_result_t result = rac_tts_component_create(&handle); + if (result != RAC_SUCCESS) { + LOGe("Failed to create TTS component: %d", result); + return 0; + } + return reinterpret_cast(handle); +} + +JNIEXPORT void JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racTtsComponentDestroy(JNIEnv* env, + jclass clazz, + jlong handle) { + if (handle != 0) { + rac_tts_component_destroy(reinterpret_cast(handle)); + } +} + +JNIEXPORT jint JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racTtsComponentLoadModel( + JNIEnv* env, jclass clazz, jlong handle, jstring modelPath, jstring modelId, + jstring modelName) { + if (handle == 0) + return RAC_ERROR_INVALID_HANDLE; + + std::string voicePath = getCString(env, modelPath); + std::string voiceId = getCString(env, modelId); + std::string voiceName = getCString(env, modelName); + LOGi("racTtsComponentLoadModel path=%s, id=%s, name=%s", voicePath.c_str(), voiceId.c_str(), + voiceName.c_str()); + + // TTS component uses load_voice instead of load_model + // Pass voice_path, voice_id, and voice_name separately to C++ lifecycle + return static_cast(rac_tts_component_load_voice( + reinterpret_cast(handle), + voicePath.c_str(), // voice_path + voiceId.c_str(), // voice_id (for telemetry) + voiceName.empty() ? nullptr : voiceName.c_str() // voice_name (optional, for telemetry) + )); +} + +JNIEXPORT void JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racTtsComponentUnload(JNIEnv* env, + jclass clazz, + jlong handle) { + if (handle != 0) { + rac_tts_component_unload(reinterpret_cast(handle)); + } +} + +JNIEXPORT jbyteArray JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racTtsComponentSynthesize( + JNIEnv* env, jclass clazz, jlong handle, jstring text, jstring configJson) { + if (handle == 0) + return nullptr; + + std::string textStr = getCString(env, text); + rac_tts_options_t options = {}; + rac_tts_result_t result = {}; + + rac_result_t status = rac_tts_component_synthesize(reinterpret_cast(handle), + textStr.c_str(), &options, &result); + + if (status != RAC_SUCCESS || result.audio_data == nullptr) { + return nullptr; + } + + jbyteArray jResult = env->NewByteArray(static_cast(result.audio_size)); + env->SetByteArrayRegion(jResult, 0, static_cast(result.audio_size), + reinterpret_cast(result.audio_data)); + + rac_tts_result_free(&result); + return jResult; +} + +JNIEXPORT jbyteArray JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racTtsComponentSynthesizeStream( + JNIEnv* env, jclass clazz, jlong handle, jstring text, jstring configJson) { + return Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racTtsComponentSynthesize( + env, clazz, handle, text, configJson); +} + +JNIEXPORT jlong JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racTtsComponentSynthesizeToFile( + JNIEnv* env, jclass clazz, jlong handle, jstring text, jstring outputPath, jstring configJson) { + if (handle == 0) + return -1; + + std::string textStr = getCString(env, text); + std::string pathStr = getCString(env, outputPath); + rac_tts_options_t options = {}; + rac_tts_result_t result = {}; + + rac_result_t status = rac_tts_component_synthesize(reinterpret_cast(handle), + textStr.c_str(), &options, &result); + + // TODO: Write result to file + rac_tts_result_free(&result); + + return status == RAC_SUCCESS ? 0 : -1; +} + +JNIEXPORT void JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racTtsComponentCancel(JNIEnv* env, + jclass clazz, + jlong handle) { + // TTS component doesn't have a cancel method, just unload + if (handle != 0) { + rac_tts_component_unload(reinterpret_cast(handle)); + } +} + +JNIEXPORT jint JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racTtsComponentGetState(JNIEnv* env, + jclass clazz, + jlong handle) { + if (handle == 0) + return 0; + return static_cast(rac_tts_component_get_state(reinterpret_cast(handle))); +} + +JNIEXPORT jboolean JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racTtsComponentIsLoaded(JNIEnv* env, + jclass clazz, + jlong handle) { + if (handle == 0) + return JNI_FALSE; + return rac_tts_component_is_loaded(reinterpret_cast(handle)) ? JNI_TRUE + : JNI_FALSE; +} + +JNIEXPORT jstring JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racTtsComponentGetVoices(JNIEnv* env, + jclass clazz, + jlong handle) { + return env->NewStringUTF("[]"); +} + +JNIEXPORT jint JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racTtsComponentSetVoice(JNIEnv* env, + jclass clazz, + jlong handle, + jstring voiceId) { + if (handle == 0) + return RAC_ERROR_INVALID_HANDLE; + std::string voice = getCString(env, voiceId); + // voice_path, voice_id (use path as id), voice_name (optional) + return static_cast(rac_tts_component_load_voice(reinterpret_cast(handle), + voice.c_str(), // voice_path + voice.c_str(), // voice_id + nullptr // voice_name (optional) + )); +} + +JNIEXPORT jstring JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racTtsComponentGetLanguages(JNIEnv* env, + jclass clazz, + jlong handle) { + return env->NewStringUTF("[]"); +} + +JNIEXPORT void JNICALL Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racTtsSetCallbacks( + JNIEnv* env, jclass clazz, jobject audioCallback, jobject progressCallback) { + // TODO: Implement callback registration +} + +// ============================================================================= +// JNI FUNCTIONS - VAD Component +// ============================================================================= + +JNIEXPORT jlong JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racVadComponentCreate(JNIEnv* env, + jclass clazz) { + rac_handle_t handle = RAC_INVALID_HANDLE; + rac_result_t result = rac_vad_component_create(&handle); + if (result != RAC_SUCCESS) { + LOGe("Failed to create VAD component: %d", result); + return 0; + } + return reinterpret_cast(handle); +} + +JNIEXPORT void JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racVadComponentDestroy(JNIEnv* env, + jclass clazz, + jlong handle) { + if (handle != 0) { + rac_vad_component_destroy(reinterpret_cast(handle)); + } +} + +JNIEXPORT jint JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racVadComponentLoadModel( + JNIEnv* env, jclass clazz, jlong handle, jstring modelPath, jstring configJson) { + if (handle == 0) + return RAC_ERROR_INVALID_HANDLE; + + // Initialize and configure the VAD component + return static_cast(rac_vad_component_initialize(reinterpret_cast(handle))); +} + +JNIEXPORT void JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racVadComponentUnload(JNIEnv* env, + jclass clazz, + jlong handle) { + if (handle != 0) { + rac_vad_component_cleanup(reinterpret_cast(handle)); + } +} + +JNIEXPORT jstring JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racVadComponentProcess( + JNIEnv* env, jclass clazz, jlong handle, jbyteArray audioData, jstring configJson) { + if (handle == 0 || audioData == nullptr) + return nullptr; + + jsize len = env->GetArrayLength(audioData); + jbyte* data = env->GetByteArrayElements(audioData, nullptr); + + rac_bool_t out_is_speech = RAC_FALSE; + rac_result_t status = rac_vad_component_process( + reinterpret_cast(handle), reinterpret_cast(data), + static_cast(len / sizeof(float)), &out_is_speech); + + env->ReleaseByteArrayElements(audioData, data, JNI_ABORT); + + if (status != RAC_SUCCESS) { + return nullptr; + } + + // Return JSON result + char jsonBuf[256]; + snprintf(jsonBuf, sizeof(jsonBuf), "{\"is_speech\":%s,\"probability\":%.4f}", + out_is_speech ? "true" : "false", out_is_speech ? 1.0f : 0.0f); + + return env->NewStringUTF(jsonBuf); +} + +JNIEXPORT jstring JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racVadComponentProcessStream( + JNIEnv* env, jclass clazz, jlong handle, jbyteArray audioData, jstring configJson) { + return Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racVadComponentProcess( + env, clazz, handle, audioData, configJson); +} + +JNIEXPORT jstring JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racVadComponentProcessFrame( + JNIEnv* env, jclass clazz, jlong handle, jbyteArray audioData, jstring configJson) { + return Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racVadComponentProcess( + env, clazz, handle, audioData, configJson); +} + +JNIEXPORT void JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racVadComponentCancel(JNIEnv* env, + jclass clazz, + jlong handle) { + if (handle != 0) { + rac_vad_component_stop(reinterpret_cast(handle)); + } +} + +JNIEXPORT void JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racVadComponentReset(JNIEnv* env, + jclass clazz, + jlong handle) { + if (handle != 0) { + rac_vad_component_reset(reinterpret_cast(handle)); + } +} + +JNIEXPORT jint JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racVadComponentGetState(JNIEnv* env, + jclass clazz, + jlong handle) { + if (handle == 0) + return 0; + return static_cast(rac_vad_component_get_state(reinterpret_cast(handle))); +} + +JNIEXPORT jboolean JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racVadComponentIsLoaded(JNIEnv* env, + jclass clazz, + jlong handle) { + if (handle == 0) + return JNI_FALSE; + return rac_vad_component_is_initialized(reinterpret_cast(handle)) ? JNI_TRUE + : JNI_FALSE; +} + +JNIEXPORT jint JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racVadComponentGetMinFrameSize( + JNIEnv* env, jclass clazz, jlong handle) { + // Default minimum frame size: 512 samples at 16kHz = 32ms + if (handle == 0) + return 0; + return 512; +} + +JNIEXPORT jstring JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racVadComponentGetSampleRates( + JNIEnv* env, jclass clazz, jlong handle) { + return env->NewStringUTF("[16000]"); +} + +JNIEXPORT void JNICALL Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racVadSetCallbacks( + JNIEnv* env, jclass clazz, jobject frameCallback, jobject speechStartCallback, + jobject speechEndCallback, jobject progressCallback) { + // TODO: Implement callback registration +} + +// ============================================================================= +// JNI FUNCTIONS - Model Registry (mirrors Swift CppBridge+ModelRegistry.swift) +// ============================================================================= + +// Helper to convert Java ModelInfo to C struct +static rac_model_info_t* javaModelInfoToC(JNIEnv* env, jobject modelInfo) { + if (!modelInfo) + return nullptr; + + jclass cls = env->GetObjectClass(modelInfo); + if (!cls) + return nullptr; + + rac_model_info_t* model = rac_model_info_alloc(); + if (!model) + return nullptr; + + // Get fields + jfieldID idField = env->GetFieldID(cls, "modelId", "Ljava/lang/String;"); + jfieldID nameField = env->GetFieldID(cls, "name", "Ljava/lang/String;"); + jfieldID categoryField = env->GetFieldID(cls, "category", "I"); + jfieldID formatField = env->GetFieldID(cls, "format", "I"); + jfieldID frameworkField = env->GetFieldID(cls, "framework", "I"); + jfieldID downloadUrlField = env->GetFieldID(cls, "downloadUrl", "Ljava/lang/String;"); + jfieldID localPathField = env->GetFieldID(cls, "localPath", "Ljava/lang/String;"); + jfieldID downloadSizeField = env->GetFieldID(cls, "downloadSize", "J"); + jfieldID contextLengthField = env->GetFieldID(cls, "contextLength", "I"); + jfieldID supportsThinkingField = env->GetFieldID(cls, "supportsThinking", "Z"); + jfieldID descriptionField = env->GetFieldID(cls, "description", "Ljava/lang/String;"); + + // Read and convert values + jstring jId = (jstring)env->GetObjectField(modelInfo, idField); + if (jId) { + const char* str = env->GetStringUTFChars(jId, nullptr); + model->id = strdup(str); + env->ReleaseStringUTFChars(jId, str); + } + + jstring jName = (jstring)env->GetObjectField(modelInfo, nameField); + if (jName) { + const char* str = env->GetStringUTFChars(jName, nullptr); + model->name = strdup(str); + env->ReleaseStringUTFChars(jName, str); + } + + model->category = static_cast(env->GetIntField(modelInfo, categoryField)); + model->format = static_cast(env->GetIntField(modelInfo, formatField)); + model->framework = + static_cast(env->GetIntField(modelInfo, frameworkField)); + + jstring jDownloadUrl = (jstring)env->GetObjectField(modelInfo, downloadUrlField); + if (jDownloadUrl) { + const char* str = env->GetStringUTFChars(jDownloadUrl, nullptr); + model->download_url = strdup(str); + env->ReleaseStringUTFChars(jDownloadUrl, str); + } + + jstring jLocalPath = (jstring)env->GetObjectField(modelInfo, localPathField); + if (jLocalPath) { + const char* str = env->GetStringUTFChars(jLocalPath, nullptr); + model->local_path = strdup(str); + env->ReleaseStringUTFChars(jLocalPath, str); + } + + model->download_size = env->GetLongField(modelInfo, downloadSizeField); + model->context_length = env->GetIntField(modelInfo, contextLengthField); + model->supports_thinking = + env->GetBooleanField(modelInfo, supportsThinkingField) ? RAC_TRUE : RAC_FALSE; + + jstring jDesc = (jstring)env->GetObjectField(modelInfo, descriptionField); + if (jDesc) { + const char* str = env->GetStringUTFChars(jDesc, nullptr); + model->description = strdup(str); + env->ReleaseStringUTFChars(jDesc, str); + } + + return model; +} + +// Helper to convert C model info to JSON string for Kotlin +static std::string modelInfoToJson(const rac_model_info_t* model) { + if (!model) + return "null"; + + std::string json = "{"; + json += "\"model_id\":\"" + std::string(model->id ? model->id : "") + "\","; + json += "\"name\":\"" + std::string(model->name ? model->name : "") + "\","; + json += "\"category\":" + std::to_string(static_cast(model->category)) + ","; + json += "\"format\":" + std::to_string(static_cast(model->format)) + ","; + json += "\"framework\":" + std::to_string(static_cast(model->framework)) + ","; + json += "\"download_url\":" + + (model->download_url ? ("\"" + std::string(model->download_url) + "\"") : "null") + ","; + json += "\"local_path\":" + + (model->local_path ? ("\"" + std::string(model->local_path) + "\"") : "null") + ","; + json += "\"download_size\":" + std::to_string(model->download_size) + ","; + json += "\"context_length\":" + std::to_string(model->context_length) + ","; + json += + "\"supports_thinking\":" + std::string(model->supports_thinking ? "true" : "false") + ","; + json += "\"description\":" + + (model->description ? ("\"" + std::string(model->description) + "\"") : "null"); + json += "}"; + + return json; +} + +JNIEXPORT jint JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racModelRegistrySave( + JNIEnv* env, jclass clazz, jstring modelId, jstring name, jint category, jint format, + jint framework, jstring downloadUrl, jstring localPath, jlong downloadSize, jint contextLength, + jboolean supportsThinking, jstring description) { + LOGi("racModelRegistrySave called"); + + rac_model_registry_handle_t registry = rac_get_model_registry(); + if (!registry) { + LOGe("Model registry not initialized"); + return RAC_ERROR_NOT_INITIALIZED; + } + + // Allocate and populate model info + rac_model_info_t* model = rac_model_info_alloc(); + if (!model) { + LOGe("Failed to allocate model info"); + return RAC_ERROR_OUT_OF_MEMORY; + } + + // Convert strings + const char* id_str = modelId ? env->GetStringUTFChars(modelId, nullptr) : nullptr; + const char* name_str = name ? env->GetStringUTFChars(name, nullptr) : nullptr; + const char* url_str = downloadUrl ? env->GetStringUTFChars(downloadUrl, nullptr) : nullptr; + const char* path_str = localPath ? env->GetStringUTFChars(localPath, nullptr) : nullptr; + const char* desc_str = description ? env->GetStringUTFChars(description, nullptr) : nullptr; + + model->id = id_str ? strdup(id_str) : nullptr; + model->name = name_str ? strdup(name_str) : nullptr; + model->category = static_cast(category); + model->format = static_cast(format); + model->framework = static_cast(framework); + model->download_url = url_str ? strdup(url_str) : nullptr; + model->local_path = path_str ? strdup(path_str) : nullptr; + model->download_size = downloadSize; + model->context_length = contextLength; + model->supports_thinking = supportsThinking ? RAC_TRUE : RAC_FALSE; + model->description = desc_str ? strdup(desc_str) : nullptr; + + // Release Java strings + if (id_str) + env->ReleaseStringUTFChars(modelId, id_str); + if (name_str) + env->ReleaseStringUTFChars(name, name_str); + if (url_str) + env->ReleaseStringUTFChars(downloadUrl, url_str); + if (path_str) + env->ReleaseStringUTFChars(localPath, path_str); + if (desc_str) + env->ReleaseStringUTFChars(description, desc_str); + + LOGi("Saving model to C++ registry: %s (framework=%d)", model->id, framework); + + rac_result_t result = rac_model_registry_save(registry, model); + + // Free the model info (registry makes a copy) + rac_model_info_free(model); + + if (result != RAC_SUCCESS) { + LOGe("Failed to save model to registry: %d", result); + } else { + LOGi("Model saved to C++ registry successfully"); + } + + return static_cast(result); +} + +JNIEXPORT jstring JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racModelRegistryGet(JNIEnv* env, + jclass clazz, + jstring modelId) { + if (!modelId) + return nullptr; + + rac_model_registry_handle_t registry = rac_get_model_registry(); + if (!registry) { + LOGe("Model registry not initialized"); + return nullptr; + } + + const char* id_str = env->GetStringUTFChars(modelId, nullptr); + + rac_model_info_t* model = nullptr; + rac_result_t result = rac_model_registry_get(registry, id_str, &model); + + env->ReleaseStringUTFChars(modelId, id_str); + + if (result != RAC_SUCCESS || !model) { + return nullptr; + } + + std::string json = modelInfoToJson(model); + rac_model_info_free(model); + + return env->NewStringUTF(json.c_str()); +} + +JNIEXPORT jstring JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racModelRegistryGetAll(JNIEnv* env, + jclass clazz) { + rac_model_registry_handle_t registry = rac_get_model_registry(); + if (!registry) { + LOGe("Model registry not initialized"); + return env->NewStringUTF("[]"); + } + + rac_model_info_t** models = nullptr; + size_t count = 0; + + rac_result_t result = rac_model_registry_get_all(registry, &models, &count); + + if (result != RAC_SUCCESS || !models || count == 0) { + return env->NewStringUTF("[]"); + } + + std::string json = "["; + for (size_t i = 0; i < count; i++) { + if (i > 0) + json += ","; + json += modelInfoToJson(models[i]); + } + json += "]"; + + rac_model_info_array_free(models, count); + + return env->NewStringUTF(json.c_str()); +} + +JNIEXPORT jstring JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racModelRegistryGetDownloaded( + JNIEnv* env, jclass clazz) { + rac_model_registry_handle_t registry = rac_get_model_registry(); + if (!registry) { + return env->NewStringUTF("[]"); + } + + rac_model_info_t** models = nullptr; + size_t count = 0; + + rac_result_t result = rac_model_registry_get_downloaded(registry, &models, &count); + + if (result != RAC_SUCCESS || !models || count == 0) { + return env->NewStringUTF("[]"); + } + + std::string json = "["; + for (size_t i = 0; i < count; i++) { + if (i > 0) + json += ","; + json += modelInfoToJson(models[i]); + } + json += "]"; + + rac_model_info_array_free(models, count); + + return env->NewStringUTF(json.c_str()); +} + +JNIEXPORT jint JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racModelRegistryRemove(JNIEnv* env, + jclass clazz, + jstring modelId) { + if (!modelId) + return RAC_ERROR_NULL_POINTER; + + rac_model_registry_handle_t registry = rac_get_model_registry(); + if (!registry) { + return RAC_ERROR_NOT_INITIALIZED; + } + + const char* id_str = env->GetStringUTFChars(modelId, nullptr); + rac_result_t result = rac_model_registry_remove(registry, id_str); + env->ReleaseStringUTFChars(modelId, id_str); + + return static_cast(result); +} + +JNIEXPORT jint JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racModelRegistryUpdateDownloadStatus( + JNIEnv* env, jclass clazz, jstring modelId, jstring localPath) { + if (!modelId) + return RAC_ERROR_NULL_POINTER; + + rac_model_registry_handle_t registry = rac_get_model_registry(); + if (!registry) { + return RAC_ERROR_NOT_INITIALIZED; + } + + const char* id_str = env->GetStringUTFChars(modelId, nullptr); + const char* path_str = localPath ? env->GetStringUTFChars(localPath, nullptr) : nullptr; + + LOGi("Updating download status: %s -> %s", id_str, path_str ? path_str : "null"); + + rac_result_t result = rac_model_registry_update_download_status(registry, id_str, path_str); + + env->ReleaseStringUTFChars(modelId, id_str); + if (path_str) + env->ReleaseStringUTFChars(localPath, path_str); + + return static_cast(result); +} + +// ============================================================================= +// JNI FUNCTIONS - Model Assignment (rac_model_assignment.h) +// ============================================================================= +// Mirrors Swift SDK's CppBridge+ModelAssignment.swift + +// Global state for model assignment callbacks +// NOTE: Using recursive_mutex to allow callback re-entry during auto_fetch +// The flow is: setCallbacks() -> rac_model_assignment_set_callbacks() -> fetch() -> http_get_callback() +// All on the same thread, so a recursive mutex is required +static struct { + JavaVM* jvm; + jobject callback_obj; + jmethodID http_get_method; + std::recursive_mutex mutex; // Must be recursive to allow callback during auto_fetch + bool callbacks_registered; +} g_model_assignment_state = {nullptr, nullptr, nullptr, {}, false}; + +// HTTP GET callback for model assignment (called from C++) +static rac_result_t model_assignment_http_get_callback(const char* endpoint, + rac_bool_t requires_auth, + rac_assignment_http_response_t* out_response, + void* user_data) { + std::lock_guard lock(g_model_assignment_state.mutex); + + if (!g_model_assignment_state.jvm || !g_model_assignment_state.callback_obj) { + LOGe("model_assignment_http_get_callback: callbacks not registered"); + if (out_response) { + out_response->result = RAC_ERROR_INVALID_STATE; + } + return RAC_ERROR_INVALID_STATE; + } + + JNIEnv* env = nullptr; + bool did_attach = false; + jint get_result = g_model_assignment_state.jvm->GetEnv((void**)&env, JNI_VERSION_1_6); + + if (get_result == JNI_EDETACHED) { + if (g_model_assignment_state.jvm->AttachCurrentThread(&env, nullptr) == JNI_OK) { + did_attach = true; + } else { + LOGe("model_assignment_http_get_callback: failed to attach thread"); + if (out_response) { + out_response->result = RAC_ERROR_INVALID_STATE; + } + return RAC_ERROR_INVALID_STATE; + } + } + + // Call Kotlin callback: httpGet(endpoint: String, requiresAuth: Boolean): String + jstring jEndpoint = env->NewStringUTF(endpoint ? endpoint : ""); + jboolean jRequiresAuth = requires_auth == RAC_TRUE ? JNI_TRUE : JNI_FALSE; + + jstring jResponse = + (jstring)env->CallObjectMethod(g_model_assignment_state.callback_obj, + g_model_assignment_state.http_get_method, jEndpoint, jRequiresAuth); + + if (env->ExceptionCheck()) { + env->ExceptionClear(); + LOGe("model_assignment_http_get_callback: exception in Kotlin callback"); + env->DeleteLocalRef(jEndpoint); + if (did_attach) { + g_model_assignment_state.jvm->DetachCurrentThread(); + } + if (out_response) { + out_response->result = RAC_ERROR_HTTP_REQUEST_FAILED; + } + return RAC_ERROR_HTTP_REQUEST_FAILED; + } + + rac_result_t result = RAC_SUCCESS; + if (jResponse) { + const char* response_str = env->GetStringUTFChars(jResponse, nullptr); + if (response_str && out_response) { + // Check if response is an error (starts with "ERROR:") + if (strncmp(response_str, "ERROR:", 6) == 0) { + out_response->result = RAC_ERROR_HTTP_REQUEST_FAILED; + out_response->error_message = strdup(response_str + 6); + result = RAC_ERROR_HTTP_REQUEST_FAILED; + } else { + out_response->result = RAC_SUCCESS; + out_response->status_code = 200; + out_response->response_body = strdup(response_str); + out_response->response_length = strlen(response_str); + } + } + env->ReleaseStringUTFChars(jResponse, response_str); + env->DeleteLocalRef(jResponse); + } else { + if (out_response) { + out_response->result = RAC_ERROR_HTTP_REQUEST_FAILED; + } + result = RAC_ERROR_HTTP_REQUEST_FAILED; + } + + env->DeleteLocalRef(jEndpoint); + if (did_attach) { + g_model_assignment_state.jvm->DetachCurrentThread(); + } + + return result; +} + +JNIEXPORT jint JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racModelAssignmentSetCallbacks( + JNIEnv* env, jclass clazz, jobject callback, jboolean autoFetch) { + LOGi("racModelAssignmentSetCallbacks called, autoFetch=%d", autoFetch); + + std::lock_guard lock(g_model_assignment_state.mutex); + + // Clear previous callback if any + if (g_model_assignment_state.callback_obj) { + JNIEnv* env_local = nullptr; + if (g_model_assignment_state.jvm && + g_model_assignment_state.jvm->GetEnv((void**)&env_local, JNI_VERSION_1_6) == JNI_OK) { + env_local->DeleteGlobalRef(g_model_assignment_state.callback_obj); + } + g_model_assignment_state.callback_obj = nullptr; + } + + if (!callback) { + // Just clearing callbacks + g_model_assignment_state.callbacks_registered = false; + LOGi("racModelAssignmentSetCallbacks: callbacks cleared"); + return RAC_SUCCESS; + } + + // Store JVM reference + env->GetJavaVM(&g_model_assignment_state.jvm); + + // Create global reference to callback object + g_model_assignment_state.callback_obj = env->NewGlobalRef(callback); + + // Get method IDs + jclass callback_class = env->GetObjectClass(callback); + g_model_assignment_state.http_get_method = + env->GetMethodID(callback_class, "httpGet", "(Ljava/lang/String;Z)Ljava/lang/String;"); + env->DeleteLocalRef(callback_class); + + if (!g_model_assignment_state.http_get_method) { + LOGe("racModelAssignmentSetCallbacks: failed to get httpGet method ID"); + env->DeleteGlobalRef(g_model_assignment_state.callback_obj); + g_model_assignment_state.callback_obj = nullptr; + return RAC_ERROR_INVALID_ARGUMENT; + } + + // Set up C++ callbacks + rac_assignment_callbacks_t callbacks = {}; + callbacks.http_get = model_assignment_http_get_callback; + callbacks.user_data = nullptr; + callbacks.auto_fetch = autoFetch ? RAC_TRUE : RAC_FALSE; + + rac_result_t result = rac_model_assignment_set_callbacks(&callbacks); + + if (result == RAC_SUCCESS) { + g_model_assignment_state.callbacks_registered = true; + LOGi("racModelAssignmentSetCallbacks: registered successfully"); + } else { + LOGe("racModelAssignmentSetCallbacks: failed with code %d", result); + env->DeleteGlobalRef(g_model_assignment_state.callback_obj); + g_model_assignment_state.callback_obj = nullptr; + } + + return static_cast(result); +} + +JNIEXPORT jstring JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racModelAssignmentFetch( + JNIEnv* env, jclass clazz, jboolean forceRefresh) { + LOGi("racModelAssignmentFetch called, forceRefresh=%d", forceRefresh); + + rac_model_info_t** models = nullptr; + size_t count = 0; + + rac_result_t result = + rac_model_assignment_fetch(forceRefresh ? RAC_TRUE : RAC_FALSE, &models, &count); + + if (result != RAC_SUCCESS) { + LOGe("racModelAssignmentFetch: failed with code %d", result); + return env->NewStringUTF("[]"); + } + + // Build JSON array of models + std::string json = "["; + for (size_t i = 0; i < count; i++) { + if (i > 0) json += ","; + + rac_model_info_t* m = models[i]; + json += "{"; + json += "\"id\":\"" + std::string(m->id ? m->id : "") + "\","; + json += "\"name\":\"" + std::string(m->name ? m->name : "") + "\","; + json += "\"category\":" + std::to_string(m->category) + ","; + json += "\"format\":" + std::to_string(m->format) + ","; + json += "\"framework\":" + std::to_string(m->framework) + ","; + json += "\"downloadUrl\":\"" + std::string(m->download_url ? m->download_url : "") + "\","; + json += "\"downloadSize\":" + std::to_string(m->download_size) + ","; + json += "\"contextLength\":" + std::to_string(m->context_length) + ","; + json += + "\"supportsThinking\":" + std::string(m->supports_thinking == RAC_TRUE ? "true" : "false"); + json += "}"; + } + json += "]"; + + // Free models array + if (models) { + rac_model_info_array_free(models, count); + } + + LOGi("racModelAssignmentFetch: returned %zu models", count); + return env->NewStringUTF(json.c_str()); +} + +// ============================================================================= +// JNI FUNCTIONS - Audio Utils (rac_audio_utils.h) +// ============================================================================= + +JNIEXPORT jbyteArray JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racAudioFloat32ToWav(JNIEnv* env, + jclass clazz, + jbyteArray pcmData, + jint sampleRate) { + if (pcmData == nullptr) { + LOGe("racAudioFloat32ToWav: null input data"); + return nullptr; + } + + jsize pcmSize = env->GetArrayLength(pcmData); + if (pcmSize == 0) { + LOGe("racAudioFloat32ToWav: empty input data"); + return nullptr; + } + + LOGi("racAudioFloat32ToWav: converting %d bytes at %d Hz", (int)pcmSize, sampleRate); + + // Get the input data + jbyte* pcmBytes = env->GetByteArrayElements(pcmData, nullptr); + if (pcmBytes == nullptr) { + LOGe("racAudioFloat32ToWav: failed to get byte array elements"); + return nullptr; + } + + // Convert Float32 PCM to WAV format + void* wavData = nullptr; + size_t wavSize = 0; + + rac_result_t result = rac_audio_float32_to_wav(pcmBytes, static_cast(pcmSize), + sampleRate, &wavData, &wavSize); + + env->ReleaseByteArrayElements(pcmData, pcmBytes, JNI_ABORT); + + if (result != RAC_SUCCESS || wavData == nullptr) { + LOGe("racAudioFloat32ToWav: conversion failed with code %d", result); + return nullptr; + } + + LOGi("racAudioFloat32ToWav: conversion successful, output %zu bytes", wavSize); + + // Create Java byte array for output + jbyteArray jWavData = env->NewByteArray(static_cast(wavSize)); + if (jWavData == nullptr) { + LOGe("racAudioFloat32ToWav: failed to create output byte array"); + rac_free(wavData); + return nullptr; + } + + env->SetByteArrayRegion(jWavData, 0, static_cast(wavSize), + reinterpret_cast(wavData)); + + // Free the C-allocated memory + rac_free(wavData); + + return jWavData; +} + +JNIEXPORT jbyteArray JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racAudioInt16ToWav(JNIEnv* env, + jclass clazz, + jbyteArray pcmData, + jint sampleRate) { + if (pcmData == nullptr) { + LOGe("racAudioInt16ToWav: null input data"); + return nullptr; + } + + jsize pcmSize = env->GetArrayLength(pcmData); + if (pcmSize == 0) { + LOGe("racAudioInt16ToWav: empty input data"); + return nullptr; + } + + LOGi("racAudioInt16ToWav: converting %d bytes at %d Hz", (int)pcmSize, sampleRate); + + // Get the input data + jbyte* pcmBytes = env->GetByteArrayElements(pcmData, nullptr); + if (pcmBytes == nullptr) { + LOGe("racAudioInt16ToWav: failed to get byte array elements"); + return nullptr; + } + + // Convert Int16 PCM to WAV format + void* wavData = nullptr; + size_t wavSize = 0; + + rac_result_t result = rac_audio_int16_to_wav(pcmBytes, static_cast(pcmSize), sampleRate, + &wavData, &wavSize); + + env->ReleaseByteArrayElements(pcmData, pcmBytes, JNI_ABORT); + + if (result != RAC_SUCCESS || wavData == nullptr) { + LOGe("racAudioInt16ToWav: conversion failed with code %d", result); + return nullptr; + } + + LOGi("racAudioInt16ToWav: conversion successful, output %zu bytes", wavSize); + + // Create Java byte array for output + jbyteArray jWavData = env->NewByteArray(static_cast(wavSize)); + if (jWavData == nullptr) { + LOGe("racAudioInt16ToWav: failed to create output byte array"); + rac_free(wavData); + return nullptr; + } + + env->SetByteArrayRegion(jWavData, 0, static_cast(wavSize), + reinterpret_cast(wavData)); + + // Free the C-allocated memory + rac_free(wavData); + + return jWavData; +} + +JNIEXPORT jint JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racAudioWavHeaderSize(JNIEnv* env, + jclass clazz) { + return static_cast(rac_audio_wav_header_size()); +} + +// ============================================================================= +// JNI FUNCTIONS - Device Manager (rac_device_manager.h) +// ============================================================================= +// Mirrors Swift SDK's CppBridge+Device.swift + +// Global state for device callbacks +static struct { + jobject callback_obj; + jmethodID get_device_info_method; + jmethodID get_device_id_method; + jmethodID is_registered_method; + jmethodID set_registered_method; + jmethodID http_post_method; + std::mutex mtx; +} g_device_jni_state = {}; + +// Forward declarations for device C callbacks +static void jni_device_get_info(rac_device_registration_info_t* out_info, void* user_data); +static const char* jni_device_get_id(void* user_data); +static rac_bool_t jni_device_is_registered(void* user_data); +static void jni_device_set_registered(rac_bool_t registered, void* user_data); +static rac_result_t jni_device_http_post(const char* endpoint, const char* json_body, + rac_bool_t requires_auth, + rac_device_http_response_t* out_response, void* user_data); + +// Static storage for device ID string (needs to persist across calls) +// Protected by g_device_jni_state.mtx for thread safety +static std::string g_cached_device_id; + +// Helper to extract a string value from JSON (simple parser for known keys) +// Returns allocated string that must be stored persistently, or nullptr +static std::string extract_json_string(const char* json, const char* key) { + if (!json || !key) + return ""; + + std::string search_key = "\"" + std::string(key) + "\":"; + const char* pos = strstr(json, search_key.c_str()); + if (!pos) + return ""; + + pos += search_key.length(); + while (*pos == ' ') + pos++; + + if (*pos == 'n' && strncmp(pos, "null", 4) == 0) { + return ""; + } + + if (*pos != '"') + return ""; + pos++; + + const char* end = strchr(pos, '"'); + if (!end) + return ""; + + return std::string(pos, end - pos); +} + +// Helper to extract an integer value from JSON +static int64_t extract_json_int(const char* json, const char* key) { + if (!json || !key) + return 0; + + std::string search_key = "\"" + std::string(key) + "\":"; + const char* pos = strstr(json, search_key.c_str()); + if (!pos) + return 0; + + pos += search_key.length(); + while (*pos == ' ') + pos++; + + return strtoll(pos, nullptr, 10); +} + +// Helper to extract a boolean value from JSON +static bool extract_json_bool(const char* json, const char* key) { + if (!json || !key) + return false; + + std::string search_key = "\"" + std::string(key) + "\":"; + const char* pos = strstr(json, search_key.c_str()); + if (!pos) + return false; + + pos += search_key.length(); + while (*pos == ' ') + pos++; + + return strncmp(pos, "true", 4) == 0; +} + +// Static storage for device info strings (need to persist for C callbacks) +static struct { + std::string device_id; + std::string device_model; + std::string device_name; + std::string platform; + std::string os_version; + std::string form_factor; + std::string architecture; + std::string chip_name; + std::string gpu_family; + std::string battery_state; + std::string device_fingerprint; + std::string manufacturer; + std::mutex mtx; +} g_device_info_strings = {}; + +// Device callback implementations +static void jni_device_get_info(rac_device_registration_info_t* out_info, void* user_data) { + JNIEnv* env = getJNIEnv(); + if (!env || !g_device_jni_state.callback_obj || !g_device_jni_state.get_device_info_method) { + LOGe("jni_device_get_info: JNI not ready"); + return; + } + + // Call Java getDeviceInfo() which returns a JSON string + jstring jResult = (jstring)env->CallObjectMethod(g_device_jni_state.callback_obj, + g_device_jni_state.get_device_info_method); + + // Check for Java exception after CallObjectMethod + if (env->ExceptionCheck()) { + LOGe("jni_device_get_info: Java exception occurred in getDeviceInfo()"); + env->ExceptionDescribe(); + env->ExceptionClear(); + return; + } + + if (jResult && out_info) { + const char* json = env->GetStringUTFChars(jResult, nullptr); + LOGd("jni_device_get_info: parsing JSON: %.200s...", json); + + // Parse JSON and extract all fields + std::lock_guard lock(g_device_info_strings.mtx); + + // Extract all string fields from Kotlin's getDeviceInfoCallback() JSON + g_device_info_strings.device_id = extract_json_string(json, "device_id"); + g_device_info_strings.device_model = extract_json_string(json, "device_model"); + g_device_info_strings.device_name = extract_json_string(json, "device_name"); + g_device_info_strings.platform = extract_json_string(json, "platform"); + g_device_info_strings.os_version = extract_json_string(json, "os_version"); + g_device_info_strings.form_factor = extract_json_string(json, "form_factor"); + g_device_info_strings.architecture = extract_json_string(json, "architecture"); + g_device_info_strings.chip_name = extract_json_string(json, "chip_name"); + g_device_info_strings.gpu_family = extract_json_string(json, "gpu_family"); + g_device_info_strings.battery_state = extract_json_string(json, "battery_state"); + g_device_info_strings.device_fingerprint = extract_json_string(json, "device_fingerprint"); + g_device_info_strings.manufacturer = extract_json_string(json, "manufacturer"); + + // Assign pointers to out_info (C struct uses const char*) + out_info->device_id = g_device_info_strings.device_id.empty() + ? nullptr + : g_device_info_strings.device_id.c_str(); + out_info->device_model = g_device_info_strings.device_model.empty() + ? nullptr + : g_device_info_strings.device_model.c_str(); + out_info->device_name = g_device_info_strings.device_name.empty() + ? nullptr + : g_device_info_strings.device_name.c_str(); + out_info->platform = g_device_info_strings.platform.empty() + ? "android" + : g_device_info_strings.platform.c_str(); + out_info->os_version = g_device_info_strings.os_version.empty() + ? nullptr + : g_device_info_strings.os_version.c_str(); + out_info->form_factor = g_device_info_strings.form_factor.empty() + ? nullptr + : g_device_info_strings.form_factor.c_str(); + out_info->architecture = g_device_info_strings.architecture.empty() + ? nullptr + : g_device_info_strings.architecture.c_str(); + out_info->chip_name = g_device_info_strings.chip_name.empty() + ? nullptr + : g_device_info_strings.chip_name.c_str(); + out_info->gpu_family = g_device_info_strings.gpu_family.empty() + ? nullptr + : g_device_info_strings.gpu_family.c_str(); + out_info->battery_state = g_device_info_strings.battery_state.empty() + ? nullptr + : g_device_info_strings.battery_state.c_str(); + out_info->device_fingerprint = g_device_info_strings.device_fingerprint.empty() + ? nullptr + : g_device_info_strings.device_fingerprint.c_str(); + + // Extract integer fields + out_info->total_memory = extract_json_int(json, "total_memory"); + out_info->available_memory = extract_json_int(json, "available_memory"); + out_info->neural_engine_cores = + static_cast(extract_json_int(json, "neural_engine_cores")); + out_info->core_count = static_cast(extract_json_int(json, "core_count")); + out_info->performance_cores = + static_cast(extract_json_int(json, "performance_cores")); + out_info->efficiency_cores = + static_cast(extract_json_int(json, "efficiency_cores")); + + // Extract boolean fields + out_info->has_neural_engine = + extract_json_bool(json, "has_neural_engine") ? RAC_TRUE : RAC_FALSE; + out_info->is_low_power_mode = + extract_json_bool(json, "is_low_power_mode") ? RAC_TRUE : RAC_FALSE; + + // Extract float field for battery + out_info->battery_level = static_cast(extract_json_int(json, "battery_level")); + + LOGi("jni_device_get_info: parsed device_model=%s, os_version=%s, architecture=%s", + out_info->device_model ? out_info->device_model : "(null)", + out_info->os_version ? out_info->os_version : "(null)", + out_info->architecture ? out_info->architecture : "(null)"); + + env->ReleaseStringUTFChars(jResult, json); + env->DeleteLocalRef(jResult); + } +} + +static const char* jni_device_get_id(void* user_data) { + JNIEnv* env = getJNIEnv(); + if (!env || !g_device_jni_state.callback_obj || !g_device_jni_state.get_device_id_method) { + LOGe("jni_device_get_id: JNI not ready"); + return ""; + } + + jstring jResult = (jstring)env->CallObjectMethod(g_device_jni_state.callback_obj, + g_device_jni_state.get_device_id_method); + + // Check for Java exception after CallObjectMethod + if (env->ExceptionCheck()) { + LOGe("jni_device_get_id: Java exception occurred in getDeviceId()"); + env->ExceptionDescribe(); + env->ExceptionClear(); + return ""; + } + + if (jResult) { + const char* str = env->GetStringUTFChars(jResult, nullptr); + + // Lock mutex to protect g_cached_device_id from concurrent access + std::lock_guard lock(g_device_jni_state.mtx); + g_cached_device_id = str; + env->ReleaseStringUTFChars(jResult, str); + env->DeleteLocalRef(jResult); + return g_cached_device_id.c_str(); + } + return ""; +} + +static rac_bool_t jni_device_is_registered(void* user_data) { + JNIEnv* env = getJNIEnv(); + if (!env || !g_device_jni_state.callback_obj || !g_device_jni_state.is_registered_method) { + return RAC_FALSE; + } + + jboolean result = env->CallBooleanMethod(g_device_jni_state.callback_obj, + g_device_jni_state.is_registered_method); + + // Check for Java exception after CallBooleanMethod + if (env->ExceptionCheck()) { + LOGe("jni_device_is_registered: Java exception occurred in isRegistered()"); + env->ExceptionDescribe(); + env->ExceptionClear(); + return RAC_FALSE; + } + + return result ? RAC_TRUE : RAC_FALSE; +} + +static void jni_device_set_registered(rac_bool_t registered, void* user_data) { + JNIEnv* env = getJNIEnv(); + if (!env || !g_device_jni_state.callback_obj || !g_device_jni_state.set_registered_method) { + return; + } + + env->CallVoidMethod(g_device_jni_state.callback_obj, g_device_jni_state.set_registered_method, + registered == RAC_TRUE ? JNI_TRUE : JNI_FALSE); + + // Check for Java exception after CallVoidMethod + if (env->ExceptionCheck()) { + LOGe("jni_device_set_registered: Java exception occurred in setRegistered()"); + env->ExceptionDescribe(); + env->ExceptionClear(); + } +} + +static rac_result_t jni_device_http_post(const char* endpoint, const char* json_body, + rac_bool_t requires_auth, + rac_device_http_response_t* out_response, + void* user_data) { + JNIEnv* env = getJNIEnv(); + if (!env || !g_device_jni_state.callback_obj || !g_device_jni_state.http_post_method) { + LOGe("jni_device_http_post: JNI not ready"); + if (out_response) { + out_response->result = RAC_ERROR_ADAPTER_NOT_SET; + out_response->status_code = -1; + } + return RAC_ERROR_ADAPTER_NOT_SET; + } + + jstring jEndpoint = env->NewStringUTF(endpoint ? endpoint : ""); + jstring jBody = env->NewStringUTF(json_body ? json_body : ""); + + // Check for allocation failures (can throw OutOfMemoryError) + if (env->ExceptionCheck() || !jEndpoint || !jBody) { + LOGe("jni_device_http_post: Failed to create JNI strings"); + if (env->ExceptionCheck()) { + env->ExceptionDescribe(); + env->ExceptionClear(); + } + if (jEndpoint) + env->DeleteLocalRef(jEndpoint); + if (jBody) + env->DeleteLocalRef(jBody); + if (out_response) { + out_response->result = RAC_ERROR_OUT_OF_MEMORY; + out_response->status_code = -1; + } + return RAC_ERROR_OUT_OF_MEMORY; + } + + jint statusCode = + env->CallIntMethod(g_device_jni_state.callback_obj, g_device_jni_state.http_post_method, + jEndpoint, jBody, requires_auth == RAC_TRUE ? JNI_TRUE : JNI_FALSE); + + // Check for Java exception after CallIntMethod + if (env->ExceptionCheck()) { + LOGe("jni_device_http_post: Java exception occurred in httpPost()"); + env->ExceptionDescribe(); + env->ExceptionClear(); + env->DeleteLocalRef(jEndpoint); + env->DeleteLocalRef(jBody); + if (out_response) { + out_response->result = RAC_ERROR_NETWORK_ERROR; + out_response->status_code = -1; + } + return RAC_ERROR_NETWORK_ERROR; + } + + env->DeleteLocalRef(jEndpoint); + env->DeleteLocalRef(jBody); + + if (out_response) { + out_response->status_code = statusCode; + out_response->result = + (statusCode >= 200 && statusCode < 300) ? RAC_SUCCESS : RAC_ERROR_NETWORK_ERROR; + } + + return (statusCode >= 200 && statusCode < 300) ? RAC_SUCCESS : RAC_ERROR_NETWORK_ERROR; +} + +JNIEXPORT jint JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racDeviceManagerSetCallbacks( + JNIEnv* env, jclass clazz, jobject callbacks) { + LOGi("racDeviceManagerSetCallbacks called"); + + std::lock_guard lock(g_device_jni_state.mtx); + + // Clean up previous callback + if (g_device_jni_state.callback_obj != nullptr) { + env->DeleteGlobalRef(g_device_jni_state.callback_obj); + g_device_jni_state.callback_obj = nullptr; + } + + if (callbacks == nullptr) { + LOGw("racDeviceManagerSetCallbacks: null callbacks"); + return RAC_ERROR_INVALID_ARGUMENT; + } + + // Create global reference + g_device_jni_state.callback_obj = env->NewGlobalRef(callbacks); + + // Cache method IDs + jclass cls = env->GetObjectClass(callbacks); + g_device_jni_state.get_device_info_method = + env->GetMethodID(cls, "getDeviceInfo", "()Ljava/lang/String;"); + g_device_jni_state.get_device_id_method = + env->GetMethodID(cls, "getDeviceId", "()Ljava/lang/String;"); + g_device_jni_state.is_registered_method = env->GetMethodID(cls, "isRegistered", "()Z"); + g_device_jni_state.set_registered_method = env->GetMethodID(cls, "setRegistered", "(Z)V"); + g_device_jni_state.http_post_method = + env->GetMethodID(cls, "httpPost", "(Ljava/lang/String;Ljava/lang/String;Z)I"); + env->DeleteLocalRef(cls); + + // Verify methods found + if (!g_device_jni_state.get_device_id_method || !g_device_jni_state.is_registered_method) { + LOGe("racDeviceManagerSetCallbacks: required methods not found"); + env->DeleteGlobalRef(g_device_jni_state.callback_obj); + g_device_jni_state.callback_obj = nullptr; + return RAC_ERROR_INVALID_ARGUMENT; + } + + // Set up C callbacks + rac_device_callbacks_t c_callbacks = {}; + c_callbacks.get_device_info = jni_device_get_info; + c_callbacks.get_device_id = jni_device_get_id; + c_callbacks.is_registered = jni_device_is_registered; + c_callbacks.set_registered = jni_device_set_registered; + c_callbacks.http_post = jni_device_http_post; + c_callbacks.user_data = nullptr; + + rac_result_t result = rac_device_manager_set_callbacks(&c_callbacks); + + LOGi("racDeviceManagerSetCallbacks result: %d", result); + return static_cast(result); +} + +JNIEXPORT jint JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racDeviceManagerRegisterIfNeeded( + JNIEnv* env, jclass clazz, jint environment, jstring buildToken) { + LOGi("racDeviceManagerRegisterIfNeeded called (env=%d)", environment); + + std::string tokenStorage; + const char* token = getNullableCString(env, buildToken, tokenStorage); + + rac_result_t result = + rac_device_manager_register_if_needed(static_cast(environment), token); + + LOGi("racDeviceManagerRegisterIfNeeded result: %d", result); + return static_cast(result); +} + +JNIEXPORT jboolean JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racDeviceManagerIsRegistered( + JNIEnv* env, jclass clazz) { + return rac_device_manager_is_registered() == RAC_TRUE ? JNI_TRUE : JNI_FALSE; +} + +JNIEXPORT void JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racDeviceManagerClearRegistration( + JNIEnv* env, jclass clazz) { + LOGi("racDeviceManagerClearRegistration called"); + rac_device_manager_clear_registration(); +} + +JNIEXPORT jstring JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racDeviceManagerGetDeviceId(JNIEnv* env, + jclass clazz) { + const char* deviceId = rac_device_manager_get_device_id(); + if (deviceId) { + return env->NewStringUTF(deviceId); + } + return nullptr; +} + +// ============================================================================= +// JNI FUNCTIONS - Telemetry Manager (rac_telemetry_manager.h) +// ============================================================================= +// Mirrors Swift SDK's CppBridge+Telemetry.swift + +// Global state for telemetry +static struct { + rac_telemetry_manager_t* manager; + jobject http_callback_obj; + jmethodID http_callback_method; + std::mutex mtx; +} g_telemetry_jni_state = {}; + +// Telemetry HTTP callback from C++ to Java +static void jni_telemetry_http_callback(void* user_data, const char* endpoint, + const char* json_body, size_t json_length, + rac_bool_t requires_auth) { + JNIEnv* env = getJNIEnv(); + if (!env || !g_telemetry_jni_state.http_callback_obj || + !g_telemetry_jni_state.http_callback_method) { + LOGw("jni_telemetry_http_callback: JNI not ready"); + return; + } + + jstring jEndpoint = env->NewStringUTF(endpoint ? endpoint : ""); + jstring jBody = env->NewStringUTF(json_body ? json_body : ""); + + // Check for NewStringUTF allocation failures + if (!jEndpoint || !jBody) { + LOGe("jni_telemetry_http_callback: failed to allocate JNI strings"); + if (jEndpoint) + env->DeleteLocalRef(jEndpoint); + if (jBody) + env->DeleteLocalRef(jBody); + return; + } + + env->CallVoidMethod(g_telemetry_jni_state.http_callback_obj, + g_telemetry_jni_state.http_callback_method, jEndpoint, jBody, + static_cast(json_length), + requires_auth == RAC_TRUE ? JNI_TRUE : JNI_FALSE); + + // Check for Java exception after CallVoidMethod + if (env->ExceptionCheck()) { + LOGe("jni_telemetry_http_callback: Java exception occurred in HTTP callback"); + env->ExceptionDescribe(); + env->ExceptionClear(); + } + + // Always clean up local references + env->DeleteLocalRef(jEndpoint); + env->DeleteLocalRef(jBody); +} + +JNIEXPORT jlong JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racTelemetryManagerCreate( + JNIEnv* env, jclass clazz, jint environment, jstring deviceId, jstring platform, + jstring sdkVersion) { + LOGi("racTelemetryManagerCreate called (env=%d)", environment); + + std::string deviceIdStr = getCString(env, deviceId); + std::string platformStr = getCString(env, platform); + std::string versionStr = getCString(env, sdkVersion); + + std::lock_guard lock(g_telemetry_jni_state.mtx); + + // Destroy existing manager if any + if (g_telemetry_jni_state.manager) { + rac_telemetry_manager_destroy(g_telemetry_jni_state.manager); + } + + g_telemetry_jni_state.manager = + rac_telemetry_manager_create(static_cast(environment), + deviceIdStr.c_str(), platformStr.c_str(), versionStr.c_str()); + + LOGi("racTelemetryManagerCreate: manager=%p", (void*)g_telemetry_jni_state.manager); + return reinterpret_cast(g_telemetry_jni_state.manager); +} + +JNIEXPORT void JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racTelemetryManagerDestroy(JNIEnv* env, + jclass clazz, + jlong handle) { + LOGi("racTelemetryManagerDestroy called"); + + std::lock_guard lock(g_telemetry_jni_state.mtx); + + if (handle != 0 && + reinterpret_cast(handle) == g_telemetry_jni_state.manager) { + // Flush before destroying + rac_telemetry_manager_flush(g_telemetry_jni_state.manager); + rac_telemetry_manager_destroy(g_telemetry_jni_state.manager); + g_telemetry_jni_state.manager = nullptr; + + // Clean up callback + if (g_telemetry_jni_state.http_callback_obj) { + env->DeleteGlobalRef(g_telemetry_jni_state.http_callback_obj); + g_telemetry_jni_state.http_callback_obj = nullptr; + } + } +} + +JNIEXPORT void JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racTelemetryManagerSetDeviceInfo( + JNIEnv* env, jclass clazz, jlong handle, jstring deviceModel, jstring osVersion) { + if (handle == 0) + return; + + std::string modelStr = getCString(env, deviceModel); + std::string osStr = getCString(env, osVersion); + + rac_telemetry_manager_set_device_info(reinterpret_cast(handle), + modelStr.c_str(), osStr.c_str()); + + LOGi("racTelemetryManagerSetDeviceInfo: model=%s, os=%s", modelStr.c_str(), osStr.c_str()); +} + +JNIEXPORT void JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racTelemetryManagerSetHttpCallback( + JNIEnv* env, jclass clazz, jlong handle, jobject callback) { + LOGi("racTelemetryManagerSetHttpCallback called"); + + if (handle == 0) + return; + + std::lock_guard lock(g_telemetry_jni_state.mtx); + + // Clean up previous callback + if (g_telemetry_jni_state.http_callback_obj) { + env->DeleteGlobalRef(g_telemetry_jni_state.http_callback_obj); + g_telemetry_jni_state.http_callback_obj = nullptr; + } + + if (callback) { + g_telemetry_jni_state.http_callback_obj = env->NewGlobalRef(callback); + + // Cache method ID + jclass cls = env->GetObjectClass(callback); + g_telemetry_jni_state.http_callback_method = + env->GetMethodID(cls, "onHttpRequest", "(Ljava/lang/String;Ljava/lang/String;IZ)V"); + env->DeleteLocalRef(cls); + + // Register C callback with telemetry manager + rac_telemetry_manager_set_http_callback(reinterpret_cast(handle), + jni_telemetry_http_callback, nullptr); + } +} + +JNIEXPORT jint JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racTelemetryManagerFlush(JNIEnv* env, + jclass clazz, + jlong handle) { + LOGi("racTelemetryManagerFlush called"); + + if (handle == 0) + return RAC_ERROR_INVALID_HANDLE; + + return static_cast( + rac_telemetry_manager_flush(reinterpret_cast(handle))); +} + +// ============================================================================= +// JNI FUNCTIONS - Analytics Events (rac_analytics_events.h) +// ============================================================================= + +// Global telemetry manager pointer for analytics callback routing +// The C callback routes events directly to the telemetry manager (same as Swift) +static rac_telemetry_manager_t* g_analytics_telemetry_manager = nullptr; +static std::mutex g_analytics_telemetry_mutex; + +// C callback that routes analytics events to telemetry manager +// This mirrors Swift's analyticsEventCallback -> Telemetry.trackAnalyticsEvent() +static void jni_analytics_event_callback(rac_event_type_t type, + const rac_analytics_event_data_t* data, void* user_data) { + LOGi("jni_analytics_event_callback called: event_type=%d", type); + + std::lock_guard lock(g_analytics_telemetry_mutex); + if (g_analytics_telemetry_manager && data) { + LOGi("jni_analytics_event_callback: routing to telemetry manager"); + rac_telemetry_manager_track_analytics(g_analytics_telemetry_manager, type, data); + } else { + LOGw("jni_analytics_event_callback: manager=%p, data=%p", + (void*)g_analytics_telemetry_manager, (void*)data); + } +} + +JNIEXPORT jint JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racAnalyticsEventsSetCallback( + JNIEnv* env, jclass clazz, jlong telemetryHandle) { + LOGi("racAnalyticsEventsSetCallback called (telemetryHandle=%lld)", (long long)telemetryHandle); + + std::lock_guard lock(g_analytics_telemetry_mutex); + + if (telemetryHandle != 0) { + // Store telemetry manager and register C callback + g_analytics_telemetry_manager = reinterpret_cast(telemetryHandle); + rac_result_t result = + rac_analytics_events_set_callback(jni_analytics_event_callback, nullptr); + LOGi("Analytics callback registered, result=%d", result); + return static_cast(result); + } else { + // Unregister callback + g_analytics_telemetry_manager = nullptr; + rac_result_t result = rac_analytics_events_set_callback(nullptr, nullptr); + LOGi("Analytics callback unregistered, result=%d", result); + return static_cast(result); + } +} + +// ============================================================================= +// JNI FUNCTIONS - Analytics Event Emission +// ============================================================================= +// These functions allow Kotlin to emit analytics events (e.g., SDK lifecycle events +// that originate from Kotlin code). They call rac_analytics_event_emit() which +// routes events through the registered callback to the telemetry manager. + +JNIEXPORT jint JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racAnalyticsEventEmitDownload( + JNIEnv* env, jclass clazz, jint eventType, jstring modelId, jdouble progress, + jlong bytesDownloaded, jlong totalBytes, jdouble durationMs, jlong sizeBytes, + jstring archiveType, jint errorCode, jstring errorMessage) { + std::string modelIdStr = getCString(env, modelId); + std::string archiveTypeStorage; + std::string errorMsgStorage; + const char* archiveTypePtr = getNullableCString(env, archiveType, archiveTypeStorage); + const char* errorMsgPtr = getNullableCString(env, errorMessage, errorMsgStorage); + + rac_analytics_event_data_t event_data = {}; + event_data.type = static_cast(eventType); + event_data.data.model_download.model_id = modelIdStr.c_str(); + event_data.data.model_download.progress = progress; + event_data.data.model_download.bytes_downloaded = bytesDownloaded; + event_data.data.model_download.total_bytes = totalBytes; + event_data.data.model_download.duration_ms = durationMs; + event_data.data.model_download.size_bytes = sizeBytes; + event_data.data.model_download.archive_type = archiveTypePtr; + event_data.data.model_download.error_code = static_cast(errorCode); + event_data.data.model_download.error_message = errorMsgPtr; + + rac_analytics_event_emit(event_data.type, &event_data); + return RAC_SUCCESS; +} + +JNIEXPORT jint JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racAnalyticsEventEmitSdkLifecycle( + JNIEnv* env, jclass clazz, jint eventType, jdouble durationMs, jint count, jint errorCode, + jstring errorMessage) { + std::string errorMsgStorage; + const char* errorMsgPtr = getNullableCString(env, errorMessage, errorMsgStorage); + + rac_analytics_event_data_t event_data = {}; + event_data.type = static_cast(eventType); + event_data.data.sdk_lifecycle.duration_ms = durationMs; + event_data.data.sdk_lifecycle.count = count; + event_data.data.sdk_lifecycle.error_code = static_cast(errorCode); + event_data.data.sdk_lifecycle.error_message = errorMsgPtr; + + rac_analytics_event_emit(event_data.type, &event_data); + return RAC_SUCCESS; +} + +JNIEXPORT jint JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racAnalyticsEventEmitStorage( + JNIEnv* env, jclass clazz, jint eventType, jlong freedBytes, jint errorCode, + jstring errorMessage) { + std::string errorMsgStorage; + const char* errorMsgPtr = getNullableCString(env, errorMessage, errorMsgStorage); + + rac_analytics_event_data_t event_data = {}; + event_data.type = static_cast(eventType); + event_data.data.storage.freed_bytes = freedBytes; + event_data.data.storage.error_code = static_cast(errorCode); + event_data.data.storage.error_message = errorMsgPtr; + + rac_analytics_event_emit(event_data.type, &event_data); + return RAC_SUCCESS; +} + +JNIEXPORT jint JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racAnalyticsEventEmitDevice( + JNIEnv* env, jclass clazz, jint eventType, jstring deviceId, jint errorCode, + jstring errorMessage) { + std::string deviceIdStr = getCString(env, deviceId); + std::string errorMsgStorage; + const char* errorMsgPtr = getNullableCString(env, errorMessage, errorMsgStorage); + + rac_analytics_event_data_t event_data = {}; + event_data.type = static_cast(eventType); + event_data.data.device.device_id = deviceIdStr.c_str(); + event_data.data.device.error_code = static_cast(errorCode); + event_data.data.device.error_message = errorMsgPtr; + + rac_analytics_event_emit(event_data.type, &event_data); + return RAC_SUCCESS; +} + +JNIEXPORT jint JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racAnalyticsEventEmitSdkError( + JNIEnv* env, jclass clazz, jint eventType, jint errorCode, jstring errorMessage, + jstring operation, jstring context) { + std::string errorMsgStorage, opStorage, ctxStorage; + const char* errorMsgPtr = getNullableCString(env, errorMessage, errorMsgStorage); + const char* opPtr = getNullableCString(env, operation, opStorage); + const char* ctxPtr = getNullableCString(env, context, ctxStorage); + + rac_analytics_event_data_t event_data = {}; + event_data.type = static_cast(eventType); + event_data.data.sdk_error.error_code = static_cast(errorCode); + event_data.data.sdk_error.error_message = errorMsgPtr; + event_data.data.sdk_error.operation = opPtr; + event_data.data.sdk_error.context = ctxPtr; + + rac_analytics_event_emit(event_data.type, &event_data); + return RAC_SUCCESS; +} + +JNIEXPORT jint JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racAnalyticsEventEmitNetwork( + JNIEnv* env, jclass clazz, jint eventType, jboolean isOnline) { + rac_analytics_event_data_t event_data = {}; + event_data.type = static_cast(eventType); + event_data.data.network.is_online = isOnline ? RAC_TRUE : RAC_FALSE; + + rac_analytics_event_emit(event_data.type, &event_data); + return RAC_SUCCESS; +} + +JNIEXPORT jint JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racAnalyticsEventEmitLlmGeneration( + JNIEnv* env, jclass clazz, jint eventType, jstring generationId, jstring modelId, + jstring modelName, jint inputTokens, jint outputTokens, jdouble durationMs, + jdouble tokensPerSecond, jboolean isStreaming, jdouble timeToFirstTokenMs, jint framework, + jfloat temperature, jint maxTokens, jint contextLength, jint errorCode, jstring errorMessage) { + std::string genIdStr = getCString(env, generationId); + std::string modelIdStr = getCString(env, modelId); + std::string modelNameStorage; + std::string errorMsgStorage; + const char* modelNamePtr = getNullableCString(env, modelName, modelNameStorage); + const char* errorMsgPtr = getNullableCString(env, errorMessage, errorMsgStorage); + + rac_analytics_event_data_t event_data = {}; + event_data.type = static_cast(eventType); + event_data.data.llm_generation.generation_id = genIdStr.c_str(); + event_data.data.llm_generation.model_id = modelIdStr.c_str(); + event_data.data.llm_generation.model_name = modelNamePtr; + event_data.data.llm_generation.input_tokens = inputTokens; + event_data.data.llm_generation.output_tokens = outputTokens; + event_data.data.llm_generation.duration_ms = durationMs; + event_data.data.llm_generation.tokens_per_second = tokensPerSecond; + event_data.data.llm_generation.is_streaming = isStreaming ? RAC_TRUE : RAC_FALSE; + event_data.data.llm_generation.time_to_first_token_ms = timeToFirstTokenMs; + event_data.data.llm_generation.framework = static_cast(framework); + event_data.data.llm_generation.temperature = temperature; + event_data.data.llm_generation.max_tokens = maxTokens; + event_data.data.llm_generation.context_length = contextLength; + event_data.data.llm_generation.error_code = static_cast(errorCode); + event_data.data.llm_generation.error_message = errorMsgPtr; + + rac_analytics_event_emit(event_data.type, &event_data); + return RAC_SUCCESS; +} + +JNIEXPORT jint JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racAnalyticsEventEmitLlmModel( + JNIEnv* env, jclass clazz, jint eventType, jstring modelId, jstring modelName, + jlong modelSizeBytes, jdouble durationMs, jint framework, jint errorCode, + jstring errorMessage) { + std::string modelIdStr = getCString(env, modelId); + std::string modelNameStorage; + std::string errorMsgStorage; + const char* modelNamePtr = getNullableCString(env, modelName, modelNameStorage); + const char* errorMsgPtr = getNullableCString(env, errorMessage, errorMsgStorage); + + rac_analytics_event_data_t event_data = {}; + event_data.type = static_cast(eventType); + event_data.data.llm_model.model_id = modelIdStr.c_str(); + event_data.data.llm_model.model_name = modelNamePtr; + event_data.data.llm_model.model_size_bytes = modelSizeBytes; + event_data.data.llm_model.duration_ms = durationMs; + event_data.data.llm_model.framework = static_cast(framework); + event_data.data.llm_model.error_code = static_cast(errorCode); + event_data.data.llm_model.error_message = errorMsgPtr; + + rac_analytics_event_emit(event_data.type, &event_data); + return RAC_SUCCESS; +} + +JNIEXPORT jint JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racAnalyticsEventEmitSttTranscription( + JNIEnv* env, jclass clazz, jint eventType, jstring transcriptionId, jstring modelId, + jstring modelName, jstring text, jfloat confidence, jdouble durationMs, jdouble audioLengthMs, + jint audioSizeBytes, jint wordCount, jdouble realTimeFactor, jstring language, jint sampleRate, + jboolean isStreaming, jint framework, jint errorCode, jstring errorMessage) { + std::string transIdStr = getCString(env, transcriptionId); + std::string modelIdStr = getCString(env, modelId); + std::string modelNameStorage, textStorage, langStorage, errorMsgStorage; + const char* modelNamePtr = getNullableCString(env, modelName, modelNameStorage); + const char* textPtr = getNullableCString(env, text, textStorage); + const char* langPtr = getNullableCString(env, language, langStorage); + const char* errorMsgPtr = getNullableCString(env, errorMessage, errorMsgStorage); + + rac_analytics_event_data_t event_data = {}; + event_data.type = static_cast(eventType); + event_data.data.stt_transcription.transcription_id = transIdStr.c_str(); + event_data.data.stt_transcription.model_id = modelIdStr.c_str(); + event_data.data.stt_transcription.model_name = modelNamePtr; + event_data.data.stt_transcription.text = textPtr; + event_data.data.stt_transcription.confidence = confidence; + event_data.data.stt_transcription.duration_ms = durationMs; + event_data.data.stt_transcription.audio_length_ms = audioLengthMs; + event_data.data.stt_transcription.audio_size_bytes = audioSizeBytes; + event_data.data.stt_transcription.word_count = wordCount; + event_data.data.stt_transcription.real_time_factor = realTimeFactor; + event_data.data.stt_transcription.language = langPtr; + event_data.data.stt_transcription.sample_rate = sampleRate; + event_data.data.stt_transcription.is_streaming = isStreaming ? RAC_TRUE : RAC_FALSE; + event_data.data.stt_transcription.framework = static_cast(framework); + event_data.data.stt_transcription.error_code = static_cast(errorCode); + event_data.data.stt_transcription.error_message = errorMsgPtr; + + rac_analytics_event_emit(event_data.type, &event_data); + return RAC_SUCCESS; +} + +JNIEXPORT jint JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racAnalyticsEventEmitTtsSynthesis( + JNIEnv* env, jclass clazz, jint eventType, jstring synthesisId, jstring modelId, + jstring modelName, jint characterCount, jdouble audioDurationMs, jint audioSizeBytes, + jdouble processingDurationMs, jdouble charactersPerSecond, jint sampleRate, jint framework, + jint errorCode, jstring errorMessage) { + std::string synthIdStr = getCString(env, synthesisId); + std::string modelIdStr = getCString(env, modelId); + std::string modelNameStorage, errorMsgStorage; + const char* modelNamePtr = getNullableCString(env, modelName, modelNameStorage); + const char* errorMsgPtr = getNullableCString(env, errorMessage, errorMsgStorage); + + rac_analytics_event_data_t event_data = {}; + event_data.type = static_cast(eventType); + event_data.data.tts_synthesis.synthesis_id = synthIdStr.c_str(); + event_data.data.tts_synthesis.model_id = modelIdStr.c_str(); + event_data.data.tts_synthesis.model_name = modelNamePtr; + event_data.data.tts_synthesis.character_count = characterCount; + event_data.data.tts_synthesis.audio_duration_ms = audioDurationMs; + event_data.data.tts_synthesis.audio_size_bytes = audioSizeBytes; + event_data.data.tts_synthesis.processing_duration_ms = processingDurationMs; + event_data.data.tts_synthesis.characters_per_second = charactersPerSecond; + event_data.data.tts_synthesis.sample_rate = sampleRate; + event_data.data.tts_synthesis.framework = static_cast(framework); + event_data.data.tts_synthesis.error_code = static_cast(errorCode); + event_data.data.tts_synthesis.error_message = errorMsgPtr; + + rac_analytics_event_emit(event_data.type, &event_data); + return RAC_SUCCESS; +} + +JNIEXPORT jint JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racAnalyticsEventEmitVad( + JNIEnv* env, jclass clazz, jint eventType, jdouble speechDurationMs, jfloat energyLevel) { + rac_analytics_event_data_t event_data = {}; + event_data.type = static_cast(eventType); + event_data.data.vad.speech_duration_ms = speechDurationMs; + event_data.data.vad.energy_level = energyLevel; + + rac_analytics_event_emit(event_data.type, &event_data); + return RAC_SUCCESS; +} + +JNIEXPORT jint JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racAnalyticsEventEmitVoiceAgentState( + JNIEnv* env, jclass clazz, jint eventType, jstring component, jint state, jstring modelId, + jstring errorMessage) { + std::string componentStr = getCString(env, component); + std::string modelIdStorage, errorMsgStorage; + const char* modelIdPtr = getNullableCString(env, modelId, modelIdStorage); + const char* errorMsgPtr = getNullableCString(env, errorMessage, errorMsgStorage); + + rac_analytics_event_data_t event_data = {}; + event_data.type = static_cast(eventType); + event_data.data.voice_agent_state.component = componentStr.c_str(); + event_data.data.voice_agent_state.state = static_cast(state); + event_data.data.voice_agent_state.model_id = modelIdPtr; + event_data.data.voice_agent_state.error_message = errorMsgPtr; + + rac_analytics_event_emit(event_data.type, &event_data); + return RAC_SUCCESS; +} + +// ============================================================================= +// DEV CONFIG API (rac_dev_config.h) +// Mirrors Swift SDK's CppBridge+Environment.swift DevConfig +// ============================================================================= + +JNIEXPORT jboolean JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racDevConfigIsAvailable(JNIEnv* env, + jclass clazz) { + return rac_dev_config_is_available() ? JNI_TRUE : JNI_FALSE; +} + +JNIEXPORT jstring JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racDevConfigGetSupabaseUrl(JNIEnv* env, + jclass clazz) { + const char* url = rac_dev_config_get_supabase_url(); + if (url == nullptr || strlen(url) == 0) { + return nullptr; + } + return env->NewStringUTF(url); +} + +JNIEXPORT jstring JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racDevConfigGetSupabaseKey(JNIEnv* env, + jclass clazz) { + const char* key = rac_dev_config_get_supabase_key(); + if (key == nullptr || strlen(key) == 0) { + return nullptr; + } + return env->NewStringUTF(key); +} + +JNIEXPORT jstring JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racDevConfigGetBuildToken(JNIEnv* env, + jclass clazz) { + const char* token = rac_dev_config_get_build_token(); + if (token == nullptr || strlen(token) == 0) { + return nullptr; + } + return env->NewStringUTF(token); +} + +JNIEXPORT jstring JNICALL +Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racDevConfigGetSentryDsn(JNIEnv* env, + jclass clazz) { + const char* dsn = rac_dev_config_get_sentry_dsn(); + if (dsn == nullptr || strlen(dsn) == 0) { + return nullptr; + } + return env->NewStringUTF(dsn); +} + +// ============================================================================= +// SDK Configuration Initialization +// ============================================================================= + +/** + * Initialize SDK configuration with version and platform info. + * This must be called during SDK initialization for device registration + * to include the correct sdk_version (instead of "unknown"). + * + * @param environment Environment (0=development, 1=staging, 2=production) + * @param deviceId Device ID string + * @param platform Platform string (e.g., "android") + * @param sdkVersion SDK version string (e.g., "0.1.0") + * @param apiKey API key (can be empty for development) + * @param baseUrl Base URL (can be empty for development) + * @return 0 on success, error code on failure + */ +JNIEXPORT jint JNICALL Java_com_runanywhere_sdk_native_bridge_RunAnywhereBridge_racSdkInit( + JNIEnv* env, jclass clazz, jint environment, jstring deviceId, jstring platform, + jstring sdkVersion, jstring apiKey, jstring baseUrl) { + rac_sdk_config_t config = {}; + config.environment = static_cast(environment); + + std::string deviceIdStr = getCString(env, deviceId); + std::string platformStr = getCString(env, platform); + std::string sdkVersionStr = getCString(env, sdkVersion); + std::string apiKeyStr = getCString(env, apiKey); + std::string baseUrlStr = getCString(env, baseUrl); + + config.device_id = deviceIdStr.empty() ? nullptr : deviceIdStr.c_str(); + config.platform = platformStr.empty() ? "android" : platformStr.c_str(); + config.sdk_version = sdkVersionStr.empty() ? nullptr : sdkVersionStr.c_str(); + config.api_key = apiKeyStr.empty() ? nullptr : apiKeyStr.c_str(); + config.base_url = baseUrlStr.empty() ? nullptr : baseUrlStr.c_str(); + + LOGi("racSdkInit: env=%d, platform=%s, sdk_version=%s", environment, + config.platform ? config.platform : "(null)", + config.sdk_version ? config.sdk_version : "(null)"); + + rac_validation_result_t result = rac_sdk_init(&config); + + if (result == RAC_VALIDATION_OK) { + LOGi("racSdkInit: SDK config initialized successfully"); + } else { + LOGe("racSdkInit: Failed with result %d", result); + } + + return static_cast(result); +} + +} // extern "C" + +// ============================================================================= +// NOTE: Backend registration functions have been MOVED to their respective +// backend JNI libraries: +// +// LlamaCPP: backends/llamacpp/src/jni/rac_backend_llamacpp_jni.cpp +// -> Java class: com.runanywhere.sdk.llm.llamacpp.LlamaCPPBridge +// +// ONNX: backends/onnx/src/jni/rac_backend_onnx_jni.cpp +// -> Java class: com.runanywhere.sdk.core.onnx.ONNXBridge +// +// This mirrors the Swift SDK architecture where each backend has its own +// XCFramework (RABackendLlamaCPP, RABackendONNX). +// ============================================================================= diff --git a/sdk/runanywhere-commons/tests/CMakeLists.txt b/sdk/runanywhere-commons/tests/CMakeLists.txt new file mode 100644 index 000000000..b9fb8d538 --- /dev/null +++ b/sdk/runanywhere-commons/tests/CMakeLists.txt @@ -0,0 +1,46 @@ +# ============================================================================= +# RunAnywhere Commons - Benchmark Unit Tests +# ============================================================================= + +include(FetchContent) + +FetchContent_Declare( + googletest + GIT_REPOSITORY https://github.com/google/googletest.git + GIT_TAG v1.14.0 +) + +# Prevent GoogleTest from overriding compiler/linker options +set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) +set(INSTALL_GTEST OFF CACHE BOOL "" FORCE) + +FetchContent_MakeAvailable(googletest) + +# ============================================================================= +# TEST EXECUTABLE +# ============================================================================= + +add_executable(rac_benchmark_tests + benchmark/test_monotonic_clock.cpp + benchmark/test_timing_struct.cpp + benchmark/test_benchmark_log.cpp + benchmark/test_benchmark_stats.cpp +) + +target_link_libraries(rac_benchmark_tests + PRIVATE + rac_commons + GTest::gtest_main +) + +target_include_directories(rac_benchmark_tests + PRIVATE + ${CMAKE_SOURCE_DIR}/include +) + +# ============================================================================= +# TEST DISCOVERY +# ============================================================================= + +include(GoogleTest) +gtest_discover_tests(rac_benchmark_tests) diff --git a/sdk/runanywhere-commons/tests/benchmark/test_benchmark_log.cpp b/sdk/runanywhere-commons/tests/benchmark/test_benchmark_log.cpp new file mode 100644 index 000000000..a1c3f9834 --- /dev/null +++ b/sdk/runanywhere-commons/tests/benchmark/test_benchmark_log.cpp @@ -0,0 +1,134 @@ +/** + * @file test_benchmark_log.cpp + * @brief Tests for benchmark JSON/CSV serialization and logging + */ + +#include + +#include +#include +#include + +#include "rac/core/rac_benchmark.h" +#include "rac/core/rac_benchmark_log.h" + +namespace { + +// Helper: create a populated timing struct for testing +rac_benchmark_timing_t make_test_timing() { + rac_benchmark_timing_t timing; + rac_benchmark_timing_init(&timing); + + timing.t0_request_start_ms = 1000; + timing.t2_prefill_start_ms = 1010; + timing.t3_prefill_end_ms = 1060; + timing.t4_first_token_ms = 1065; + timing.t5_last_token_ms = 2065; + timing.t6_request_end_ms = 2070; + timing.prompt_tokens = 50; + timing.output_tokens = 100; + timing.status = RAC_BENCHMARK_STATUS_SUCCESS; + timing.error_code = 0; + + return timing; +} + +} // namespace + +// ============================================================================= +// JSON SERIALIZATION +// ============================================================================= + +TEST(BenchmarkLog, TimingToJsonContainsAllFields) { + auto timing = make_test_timing(); + char* json = rac_benchmark_timing_to_json(&timing); + + ASSERT_NE(json, nullptr); + + std::string s(json); + + // Verify raw timing fields + EXPECT_NE(s.find("\"t0_request_start_ms\":1000"), std::string::npos); + EXPECT_NE(s.find("\"t2_prefill_start_ms\":1010"), std::string::npos); + EXPECT_NE(s.find("\"t3_prefill_end_ms\":1060"), std::string::npos); + EXPECT_NE(s.find("\"t4_first_token_ms\":1065"), std::string::npos); + EXPECT_NE(s.find("\"t5_last_token_ms\":2065"), std::string::npos); + EXPECT_NE(s.find("\"t6_request_end_ms\":2070"), std::string::npos); + EXPECT_NE(s.find("\"prompt_tokens\":50"), std::string::npos); + EXPECT_NE(s.find("\"output_tokens\":100"), std::string::npos); + EXPECT_NE(s.find("\"status\":0"), std::string::npos); + EXPECT_NE(s.find("\"error_code\":0"), std::string::npos); + + // Verify derived metrics exist + EXPECT_NE(s.find("\"ttft_ms\":"), std::string::npos); + EXPECT_NE(s.find("\"prefill_ms\":"), std::string::npos); + EXPECT_NE(s.find("\"decode_ms\":"), std::string::npos); + EXPECT_NE(s.find("\"e2e_ms\":"), std::string::npos); + EXPECT_NE(s.find("\"decode_tps\":"), std::string::npos); + + // Verify it's valid JSON (starts with { and ends with }) + EXPECT_EQ(s.front(), '{'); + EXPECT_EQ(s.back(), '}'); + + free(json); +} + +TEST(BenchmarkLog, TimingToJsonNullReturnsNull) { + char* json = rac_benchmark_timing_to_json(nullptr); + EXPECT_EQ(json, nullptr); +} + +// ============================================================================= +// CSV SERIALIZATION +// ============================================================================= + +TEST(BenchmarkLog, TimingToCsvHeader) { + char* header = rac_benchmark_timing_to_csv(nullptr, RAC_TRUE); + + ASSERT_NE(header, nullptr); + + std::string s(header); + EXPECT_NE(s.find("t0_request_start_ms"), std::string::npos); + EXPECT_NE(s.find("ttft_ms"), std::string::npos); + EXPECT_NE(s.find("decode_tps"), std::string::npos); + + free(header); +} + +TEST(BenchmarkLog, TimingToCsvRow) { + auto timing = make_test_timing(); + char* row = rac_benchmark_timing_to_csv(&timing, RAC_FALSE); + + ASSERT_NE(row, nullptr); + + std::string s(row); + // Should contain the t0 value + EXPECT_NE(s.find("1000"), std::string::npos); + // Should contain commas separating fields + size_t comma_count = 0; + for (char c : s) { + if (c == ',') comma_count++; + } + // CSV header has 14 commas (15 fields), data row should match + EXPECT_EQ(comma_count, 14u); + + free(row); +} + +TEST(BenchmarkLog, TimingToCsvNullDataReturnsNull) { + char* row = rac_benchmark_timing_to_csv(nullptr, RAC_FALSE); + EXPECT_EQ(row, nullptr); +} + +// ============================================================================= +// LOGGING +// ============================================================================= + +TEST(BenchmarkLog, TimingLogNoCrash) { + auto timing = make_test_timing(); + + // Should not crash even without platform adapter + rac_benchmark_timing_log(&timing, "test_run"); + rac_benchmark_timing_log(&timing, nullptr); + rac_benchmark_timing_log(nullptr, "test_run"); +} diff --git a/sdk/runanywhere-commons/tests/benchmark/test_benchmark_stats.cpp b/sdk/runanywhere-commons/tests/benchmark/test_benchmark_stats.cpp new file mode 100644 index 000000000..e0633bb22 --- /dev/null +++ b/sdk/runanywhere-commons/tests/benchmark/test_benchmark_stats.cpp @@ -0,0 +1,247 @@ +/** + * @file test_benchmark_stats.cpp + * @brief Tests for benchmark statistical analysis + */ + +#include + +#include +#include +#include +#include + +#include "rac/core/rac_benchmark.h" +#include "rac/core/rac_benchmark_stats.h" + +namespace { + +// Helper: create a timing with known derived metric values +rac_benchmark_timing_t make_timing(int64_t ttft_ms, int64_t prefill_ms, double decode_tps_target, + int32_t output_tokens, int64_t e2e_ms) { + rac_benchmark_timing_t timing; + rac_benchmark_timing_init(&timing); + + timing.t0_request_start_ms = 1000; + timing.t2_prefill_start_ms = 1010; + timing.t3_prefill_end_ms = 1010 + prefill_ms; + timing.t4_first_token_ms = 1000 + ttft_ms; + timing.output_tokens = output_tokens; + + // Compute t5 from target decode_tps: t5 - t3 = output_tokens / decode_tps * 1000 + if (decode_tps_target > 0.0 && output_tokens > 0) { + int64_t decode_ms = + static_cast(static_cast(output_tokens) / decode_tps_target * 1000.0); + timing.t5_last_token_ms = timing.t3_prefill_end_ms + decode_ms; + } + + timing.t6_request_end_ms = 1000 + e2e_ms; + timing.prompt_tokens = 50; + timing.status = RAC_BENCHMARK_STATUS_SUCCESS; + timing.error_code = 0; + + return timing; +} + +} // namespace + +// ============================================================================= +// CREATE / DESTROY +// ============================================================================= + +TEST(BenchmarkStats, CreateDestroy) { + rac_benchmark_stats_handle_t handle = nullptr; + rac_result_t result = rac_benchmark_stats_create(&handle); + + EXPECT_EQ(result, RAC_SUCCESS); + EXPECT_NE(handle, nullptr); + + rac_benchmark_stats_destroy(handle); +} + +TEST(BenchmarkStats, CreateNullReturnsError) { + rac_result_t result = rac_benchmark_stats_create(nullptr); + EXPECT_NE(result, RAC_SUCCESS); +} + +TEST(BenchmarkStats, DestroyNullNoCrash) { + rac_benchmark_stats_destroy(nullptr); +} + +// ============================================================================= +// RECORD AND COUNT +// ============================================================================= + +TEST(BenchmarkStats, RecordAndCount) { + rac_benchmark_stats_handle_t handle = nullptr; + rac_benchmark_stats_create(&handle); + + for (int i = 0; i < 10; ++i) { + auto timing = make_timing(65, 50, 100.0, 100, 1070); + rac_benchmark_stats_record(handle, &timing); + } + + EXPECT_EQ(rac_benchmark_stats_count(handle), 10); + + rac_benchmark_stats_destroy(handle); +} + +TEST(BenchmarkStats, OnlySuccessfulObservationsRecorded) { + rac_benchmark_stats_handle_t handle = nullptr; + rac_benchmark_stats_create(&handle); + + auto timing = make_timing(65, 50, 100.0, 100, 1070); + rac_benchmark_stats_record(handle, &timing); + + // Error observation should be skipped + auto error_timing = timing; + error_timing.status = RAC_BENCHMARK_STATUS_ERROR; + rac_benchmark_stats_record(handle, &error_timing); + + EXPECT_EQ(rac_benchmark_stats_count(handle), 1); + + rac_benchmark_stats_destroy(handle); +} + +// ============================================================================= +// RESET +// ============================================================================= + +TEST(BenchmarkStats, Reset) { + rac_benchmark_stats_handle_t handle = nullptr; + rac_benchmark_stats_create(&handle); + + auto timing = make_timing(65, 50, 100.0, 100, 1070); + rac_benchmark_stats_record(handle, &timing); + EXPECT_EQ(rac_benchmark_stats_count(handle), 1); + + rac_benchmark_stats_reset(handle); + EXPECT_EQ(rac_benchmark_stats_count(handle), 0); + + rac_benchmark_stats_destroy(handle); +} + +// ============================================================================= +// SUMMARY +// ============================================================================= + +TEST(BenchmarkStats, EmptyDataReturnsError) { + rac_benchmark_stats_handle_t handle = nullptr; + rac_benchmark_stats_create(&handle); + + rac_benchmark_summary_t summary; + rac_result_t result = rac_benchmark_stats_get_summary(handle, &summary); + EXPECT_NE(result, RAC_SUCCESS); + + rac_benchmark_stats_destroy(handle); +} + +TEST(BenchmarkStats, SingleObservation) { + rac_benchmark_stats_handle_t handle = nullptr; + rac_benchmark_stats_create(&handle); + + auto timing = make_timing(65, 50, 100.0, 100, 1070); + rac_benchmark_stats_record(handle, &timing); + + rac_benchmark_summary_t summary; + rac_result_t result = rac_benchmark_stats_get_summary(handle, &summary); + EXPECT_EQ(result, RAC_SUCCESS); + EXPECT_EQ(summary.count, 1); + + // For a single observation, P50=P95=P99=that value + EXPECT_DOUBLE_EQ(summary.ttft_p50_ms, summary.ttft_p95_ms); + EXPECT_DOUBLE_EQ(summary.ttft_p95_ms, summary.ttft_p99_ms); + EXPECT_EQ(summary.ttft_p50_ms, 65.0); + + // Stddev should be 0 for a single observation + EXPECT_DOUBLE_EQ(summary.ttft_stddev_ms, 0.0); + + rac_benchmark_stats_destroy(handle); +} + +TEST(BenchmarkStats, PercentilesBasic) { + rac_benchmark_stats_handle_t handle = nullptr; + rac_benchmark_stats_create(&handle); + + // Record 100 observations with TTFT values 1,2,3,...,100 + for (int i = 1; i <= 100; ++i) { + auto timing = make_timing(i, 50, 100.0, 100, 100 + i); + rac_benchmark_stats_record(handle, &timing); + } + + rac_benchmark_summary_t summary; + rac_result_t result = rac_benchmark_stats_get_summary(handle, &summary); + EXPECT_EQ(result, RAC_SUCCESS); + EXPECT_EQ(summary.count, 100); + + // P50 should be 50 (nearest rank: ceil(50/100 * 100) = 50th element = 50) + EXPECT_DOUBLE_EQ(summary.ttft_p50_ms, 50.0); + + // P95 should be 95 + EXPECT_DOUBLE_EQ(summary.ttft_p95_ms, 95.0); + + // P99 should be 99 + EXPECT_DOUBLE_EQ(summary.ttft_p99_ms, 99.0); + + // Min and max + EXPECT_DOUBLE_EQ(summary.ttft_min_ms, 1.0); + EXPECT_DOUBLE_EQ(summary.ttft_max_ms, 100.0); + + // Mean should be 50.5 + EXPECT_NEAR(summary.ttft_mean_ms, 50.5, 0.01); + + rac_benchmark_stats_destroy(handle); +} + +TEST(BenchmarkStats, OutlierDetection) { + rac_benchmark_stats_handle_t handle = nullptr; + rac_benchmark_stats_create(&handle); + + // Record 99 normal observations (E2E = 100ms) + 1 extreme (E2E = 10000ms) + for (int i = 0; i < 99; ++i) { + auto timing = make_timing(10, 10, 100.0, 100, 100); + rac_benchmark_stats_record(handle, &timing); + } + + auto extreme = make_timing(10, 10, 100.0, 100, 10000); + rac_benchmark_stats_record(handle, &extreme); + + rac_benchmark_summary_t summary; + rac_result_t result = rac_benchmark_stats_get_summary(handle, &summary); + EXPECT_EQ(result, RAC_SUCCESS); + EXPECT_GE(summary.outlier_count, 1); + + rac_benchmark_stats_destroy(handle); +} + +// ============================================================================= +// JSON EXPORT +// ============================================================================= + +TEST(BenchmarkStats, SummaryToJson) { + rac_benchmark_stats_handle_t handle = nullptr; + rac_benchmark_stats_create(&handle); + + auto timing = make_timing(65, 50, 100.0, 100, 1070); + rac_benchmark_stats_record(handle, &timing); + + rac_benchmark_summary_t summary; + rac_benchmark_stats_get_summary(handle, &summary); + + char* json = rac_benchmark_stats_summary_to_json(&summary); + ASSERT_NE(json, nullptr); + + std::string s(json); + EXPECT_EQ(s.front(), '{'); + EXPECT_EQ(s.back(), '}'); + EXPECT_NE(s.find("\"count\":1"), std::string::npos); + EXPECT_NE(s.find("\"ttft_p50_ms\":"), std::string::npos); + EXPECT_NE(s.find("\"outlier_count\":"), std::string::npos); + + free(json); + rac_benchmark_stats_destroy(handle); +} + +TEST(BenchmarkStats, SummaryToJsonNullReturnsNull) { + char* json = rac_benchmark_stats_summary_to_json(nullptr); + EXPECT_EQ(json, nullptr); +} diff --git a/sdk/runanywhere-commons/tests/benchmark/test_monotonic_clock.cpp b/sdk/runanywhere-commons/tests/benchmark/test_monotonic_clock.cpp new file mode 100644 index 000000000..08d68318c --- /dev/null +++ b/sdk/runanywhere-commons/tests/benchmark/test_monotonic_clock.cpp @@ -0,0 +1,88 @@ +/** + * @file test_monotonic_clock.cpp + * @brief Tests for rac_monotonic_now_ms() monotonic clock + */ + +#include + +#include +#include +#include +#include + +#include "rac/core/rac_benchmark.h" + +// ============================================================================= +// BASIC FUNCTIONALITY +// ============================================================================= + +TEST(MonotonicClock, ReturnsNonNegative) { + int64_t now = rac_monotonic_now_ms(); + EXPECT_GE(now, 0); +} + +TEST(MonotonicClock, MonotonicallyNonDecreasing) { + int64_t prev = rac_monotonic_now_ms(); + for (int i = 0; i < 1000; ++i) { + int64_t curr = rac_monotonic_now_ms(); + EXPECT_GE(curr, prev) << "Clock went backwards at iteration " << i; + prev = curr; + } +} + +TEST(MonotonicClock, ElapsedTimeAccuracy) { + int64_t before = rac_monotonic_now_ms(); + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + int64_t after = rac_monotonic_now_ms(); + + int64_t elapsed = after - before; + // Allow generous range for CI environments: 80ms to 300ms + EXPECT_GE(elapsed, 80) << "Elapsed time too short: " << elapsed << "ms"; + EXPECT_LE(elapsed, 300) << "Elapsed time too long: " << elapsed << "ms"; +} + +TEST(MonotonicClock, DistinctOverTime) { + int64_t first = rac_monotonic_now_ms(); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + int64_t second = rac_monotonic_now_ms(); + + EXPECT_GT(second, first) << "Two calls 10ms apart should produce distinct values"; +} + +// ============================================================================= +// THREAD SAFETY +// ============================================================================= + +TEST(MonotonicClock, ThreadSafety) { + constexpr int kNumThreads = 8; + constexpr int kCallsPerThread = 10000; + + std::atomic any_negative{false}; + std::atomic any_decreasing{false}; + + auto worker = [&]() { + int64_t prev = rac_monotonic_now_ms(); + for (int i = 0; i < kCallsPerThread; ++i) { + int64_t curr = rac_monotonic_now_ms(); + if (curr < 0) { + any_negative.store(true, std::memory_order_relaxed); + } + if (curr < prev) { + any_decreasing.store(true, std::memory_order_relaxed); + } + prev = curr; + } + }; + + std::vector threads; + threads.reserve(kNumThreads); + for (int i = 0; i < kNumThreads; ++i) { + threads.emplace_back(worker); + } + for (auto& t : threads) { + t.join(); + } + + EXPECT_FALSE(any_negative.load()) << "Got negative timestamp from thread"; + EXPECT_FALSE(any_decreasing.load()) << "Clock went backwards in thread"; +} diff --git a/sdk/runanywhere-commons/tests/benchmark/test_timing_struct.cpp b/sdk/runanywhere-commons/tests/benchmark/test_timing_struct.cpp new file mode 100644 index 000000000..184016061 --- /dev/null +++ b/sdk/runanywhere-commons/tests/benchmark/test_timing_struct.cpp @@ -0,0 +1,133 @@ +/** + * @file test_timing_struct.cpp + * @brief Tests for rac_benchmark_timing_t struct and initialization + */ + +#include + +#include + +#include "rac/core/rac_benchmark.h" + +// ============================================================================= +// INITIALIZATION +// ============================================================================= + +TEST(TimingStruct, InitZeroesAllFields) { + rac_benchmark_timing_t timing; + + // Fill with non-zero to ensure init actually clears + std::memset(&timing, 0xFF, sizeof(timing)); + + rac_benchmark_timing_init(&timing); + + EXPECT_EQ(timing.t0_request_start_ms, 0); + EXPECT_EQ(timing.t2_prefill_start_ms, 0); + EXPECT_EQ(timing.t3_prefill_end_ms, 0); + EXPECT_EQ(timing.t4_first_token_ms, 0); + EXPECT_EQ(timing.t5_last_token_ms, 0); + EXPECT_EQ(timing.t6_request_end_ms, 0); + EXPECT_EQ(timing.prompt_tokens, 0); + EXPECT_EQ(timing.output_tokens, 0); + EXPECT_EQ(timing.status, 0); + EXPECT_EQ(timing.error_code, 0); +} + +TEST(TimingStruct, InitNullPointerNoCrash) { + // Should not crash + rac_benchmark_timing_init(nullptr); +} + +// ============================================================================= +// STATUS CODES +// ============================================================================= + +TEST(TimingStruct, StatusCodeValues) { + EXPECT_EQ(RAC_BENCHMARK_STATUS_SUCCESS, 0); + EXPECT_EQ(RAC_BENCHMARK_STATUS_ERROR, 1); + EXPECT_EQ(RAC_BENCHMARK_STATUS_TIMEOUT, 2); + EXPECT_EQ(RAC_BENCHMARK_STATUS_CANCELLED, 3); +} + +// ============================================================================= +// FIELD ORDERING AND USAGE PATTERNS +// ============================================================================= + +TEST(TimingStruct, TimestampOrdering) { + rac_benchmark_timing_t timing; + rac_benchmark_timing_init(&timing); + + // Simulate a successful inference with ordered timestamps + timing.t0_request_start_ms = 100; + timing.t2_prefill_start_ms = 110; + timing.t3_prefill_end_ms = 150; + timing.t4_first_token_ms = 155; + timing.t5_last_token_ms = 500; + timing.t6_request_end_ms = 510; + + EXPECT_LE(timing.t0_request_start_ms, timing.t2_prefill_start_ms); + EXPECT_LE(timing.t2_prefill_start_ms, timing.t3_prefill_end_ms); + EXPECT_LE(timing.t3_prefill_end_ms, timing.t4_first_token_ms); + EXPECT_LE(timing.t4_first_token_ms, timing.t5_last_token_ms); + EXPECT_LE(timing.t5_last_token_ms, timing.t6_request_end_ms); +} + +TEST(TimingStruct, ErrorPathTimestamps) { + rac_benchmark_timing_t timing; + rac_benchmark_timing_init(&timing); + + // Simulate error: only t0 and t6 captured + timing.t0_request_start_ms = 100; + timing.t6_request_end_ms = 105; + timing.status = RAC_BENCHMARK_STATUS_ERROR; + timing.error_code = -130; // Some error code + + // Middle timestamps should remain 0 + EXPECT_EQ(timing.t2_prefill_start_ms, 0); + EXPECT_EQ(timing.t3_prefill_end_ms, 0); + EXPECT_EQ(timing.t4_first_token_ms, 0); + EXPECT_EQ(timing.t5_last_token_ms, 0); + + // But t0, t6, status, error_code should be set + EXPECT_GT(timing.t0_request_start_ms, 0); + EXPECT_GT(timing.t6_request_end_ms, 0); + EXPECT_EQ(timing.status, RAC_BENCHMARK_STATUS_ERROR); + EXPECT_NE(timing.error_code, RAC_SUCCESS); +} + +TEST(TimingStruct, DerivedMetrics) { + rac_benchmark_timing_t timing; + rac_benchmark_timing_init(&timing); + + timing.t0_request_start_ms = 1000; + timing.t2_prefill_start_ms = 1010; + timing.t3_prefill_end_ms = 1060; + timing.t4_first_token_ms = 1065; + timing.t5_last_token_ms = 2065; + timing.t6_request_end_ms = 2070; + timing.prompt_tokens = 50; + timing.output_tokens = 100; + + // TTFT: t4 - t0 = 65ms + EXPECT_EQ(timing.t4_first_token_ms - timing.t0_request_start_ms, 65); + + // Prefill: t3 - t2 = 50ms + EXPECT_EQ(timing.t3_prefill_end_ms - timing.t2_prefill_start_ms, 50); + + // Decode: t5 - t3 = 1005ms + int64_t decode_ms = timing.t5_last_token_ms - timing.t3_prefill_end_ms; + EXPECT_EQ(decode_ms, 1005); + + // Decode TPS: 100 tokens / 1.005s ≈ 99.50 tokens/s + double tps = static_cast(timing.output_tokens) / static_cast(decode_ms) * 1000.0; + EXPECT_NEAR(tps, 99.50, 0.1); + + // E2E: t6 - t0 = 1070ms + EXPECT_EQ(timing.t6_request_end_ms - timing.t0_request_start_ms, 1070); + + // Component overhead: E2E - decode - prefill + int64_t overhead = (timing.t6_request_end_ms - timing.t0_request_start_ms) - + decode_ms - + (timing.t3_prefill_end_ms - timing.t2_prefill_start_ms); + EXPECT_EQ(overhead, 15); // 1070 - 1005 - 50 = 15ms +} diff --git a/sdk/runanywhere-flutter/.gitignore b/sdk/runanywhere-flutter/.gitignore new file mode 100644 index 000000000..eb4e9c58b --- /dev/null +++ b/sdk/runanywhere-flutter/.gitignore @@ -0,0 +1,79 @@ +# ============================================================================= +# RunAnywhere Flutter SDK - Git Ignore +# ============================================================================= +# This file ignores native binaries that are downloaded/built locally. +# These binaries should NOT be committed - they are fetched automatically +# during build or downloaded from GitHub releases. +# ============================================================================= + +# ============================================================================= +# Native Binaries - iOS XCFrameworks +# Downloaded from GitHub releases or copied from runanywhere-commons builds +# ============================================================================= +packages/*/ios/Frameworks/*.xcframework/ +packages/*/ios/Frameworks/*.xcframework.zip + +# ============================================================================= +# Native Binaries - Android JNI Libraries +# Downloaded from GitHub releases or copied from runanywhere-commons builds +# ============================================================================= +packages/*/android/src/main/jniLibs/arm64-v8a/ +packages/*/android/src/main/jniLibs/armeabi-v7a/ +packages/*/android/src/main/jniLibs/x86/ +packages/*/android/src/main/jniLibs/x86_64/ +packages/*/android/src/main/jniLibs/include/ + +# ============================================================================= +# Local Mode Markers +# Created by build-flutter.sh to indicate local binary usage +# ============================================================================= +packages/*/ios/.testlocal +packages/*/android/.testlocal + +# ============================================================================= +# Version Files +# Created by build scripts/podspecs to track downloaded versions +# ============================================================================= +packages/*/ios/Frameworks/.*_version +packages/*/ios/Frameworks/.racommons_version +packages/*/ios/Frameworks/.llamacpp_version +packages/*/ios/Frameworks/.onnx_version + +# ============================================================================= +# Downloaded Headers (ONNX Runtime) +# These come with the onnxruntime.xcframework +# ============================================================================= +packages/*/ios/Frameworks/Headers/ +packages/*/ios/Frameworks/LICENSE + +# ============================================================================= +# Build artifacts +# ============================================================================= +packages/*/android/build/ +packages/*/ios/build/ +packages/*/.dart_tool/ +packages/*/build/ + +# ============================================================================= +# IDE and editor files +# ============================================================================= +.idea/ +*.iml +.vscode/ +*.swp +*.swo +*~ + +# ============================================================================= +# Flutter/Dart +# ============================================================================= +.dart_tool/ +.packages +.flutter-plugins +.flutter-plugins-dependencies +pubspec.lock + +# ============================================================================= +# macOS +# ============================================================================= +.DS_Store diff --git a/sdk/runanywhere-flutter/README.md b/sdk/runanywhere-flutter/README.md new file mode 100644 index 000000000..9bc93c7c4 --- /dev/null +++ b/sdk/runanywhere-flutter/README.md @@ -0,0 +1,800 @@ +# RunAnywhere Flutter SDK + +

+ RunAnywhere Logo +

+ +

+ On-Device AI for Flutter Applications
+ Run LLMs, Speech-to-Text, Text-to-Speech, and Voice AI pipelines locally—privacy-first, offline-capable, production-ready. +

+ +

+ Flutter 3.10+ + Dart 3.0+ + iOS 14.0+ + Android API 24+ + License +

+ +--- + +## Quick Links + +- [Architecture Overview](#architecture-overview) — How the SDK works +- [Quick Start](#quick-start) — Get running in 5 minutes +- [API Reference](Documentation.md) — Complete public API documentation +- [Flutter Starter Example](https://github.com/RunanywhereAI/flutter-starter-example) — Minimal starter project +- [FAQ](#faq) — Common questions answered +- [Troubleshooting](#troubleshooting) — Problems & solutions +- [Contributing](#contributing) — How to contribute + +--- + +## Features + +### Large Language Models (LLM) +- On-device text generation with streaming support +- **LlamaCPP** backend for GGUF models with Metal/GPU acceleration +- Customizable generation parameters (temperature, max tokens, etc.) +- Support for thinking/reasoning models (`...` patterns) +- Token-by-token streaming for responsive UX + +### Speech-to-Text (STT) +- Real-time streaming transcription +- Batch audio transcription with Whisper models via ONNX Runtime +- Multi-language support +- Confidence scores and timestamps + +### Text-to-Speech (TTS) +- Neural voice synthesis with Piper TTS +- System voices fallback via `flutter_tts` +- Customizable voice, pitch, rate, and volume +- PCM audio output for flexible playback + +### Voice Activity Detection (VAD) +- Energy-based speech detection with Silero VAD +- Configurable sensitivity thresholds +- Real-time audio stream processing + +### Voice Agent Pipeline +- Full VAD → STT → LLM → TTS orchestration +- Complete voice conversation flow +- Session-based management with events + +### Infrastructure +- Automatic model discovery and download with progress tracking +- Comprehensive event system via `EventBus` +- Structured logging with `SDKLogger` +- Platform-optimized native binaries (XCFrameworks + JNI) + +--- + +## System Requirements + +| Component | Minimum | Recommended | +|-----------|---------|-------------| +| **Flutter** | 3.10.0+ | 3.24.0+ | +| **Dart** | 3.0.0+ | 3.5.0+ | +| **iOS** | 14.0+ | 15.0+ | +| **Android** | API 24 (7.0) | API 28+ | +| **Xcode** | 14.0+ | 15.0+ | +| **RAM** | 2GB | 4GB+ for larger models | +| **Storage** | Variable | Models: 100MB–8GB | + +> **Note:** ARM64 devices are recommended for best performance. Metal GPU acceleration on iOS and NEON SIMD on Android provide significant speedups over CPU-only inference. + +--- + +## Installation + +### Add Dependencies + +Add the packages you need to your `pubspec.yaml`: + +**Core + LlamaCpp (LLM):** + +```yaml +dependencies: + runanywhere: ^0.15.11 + runanywhere_llamacpp: ^0.15.11 +``` + +**Core + ONNX (STT/TTS/VAD):** + +```yaml +dependencies: + runanywhere: ^0.15.11 + runanywhere_onnx: ^0.15.11 +``` + +**All Backends (LLM + STT + TTS + VAD):** + +```yaml +dependencies: + runanywhere: ^0.15.11 + runanywhere_llamacpp: ^0.15.11 + runanywhere_onnx: ^0.15.11 +``` + +Then run: + +```bash +flutter pub get +``` + +--- + +## Platform Setup + +### iOS Setup (Required) + +After adding the packages, update your iOS Podfile: + +**1. Update `ios/Podfile`:** + +```ruby +# Set minimum iOS version to 14.0 +platform :ios, '14.0' + +target 'Runner' do + # REQUIRED: Add static linkage + use_frameworks! :linkage => :static + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + target.build_configurations.each do |config| + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '14.0' + # Required for microphone permission (STT/Voice features) + config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [ + '$(inherited)', + 'PERMISSION_MICROPHONE=1', + ] + end + end +end +``` + +> **Important:** Without `use_frameworks! :linkage => :static`, you will see "symbol not found" errors at runtime. + +**2. Update `ios/Runner/Info.plist`:** + +Add microphone permission for STT/Voice features: + +```xml +NSMicrophoneUsageDescription +This app needs microphone access for speech recognition +``` + +**3. Run pod install:** + +```bash +cd ios && pod install && cd .. +``` + +### Android Setup + +Add microphone permission to `android/app/src/main/AndroidManifest.xml`: + +```xml + +``` + +--- + +## Quick Start + +### 1. Initialize the SDK + +```dart +import 'package:runanywhere/runanywhere.dart'; +import 'package:runanywhere_llamacpp/runanywhere_llamacpp.dart'; +import 'package:runanywhere_onnx/runanywhere_onnx.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + // 1. Initialize SDK (development mode - no API key needed) + await RunAnywhere.initialize(); + + // 2. Register backend modules + await LlamaCpp.register(); // LLM backend (GGUF models) + await Onnx.register(); // STT/TTS backend (Whisper, Piper) + + print('RunAnywhere SDK initialized: v${RunAnywhere.version}'); + + runApp(const MyApp()); +} +``` + +### 2. Register Models + +```dart +// Register an LLM model +LlamaCpp.addModel( + id: 'smollm2-360m-q8_0', + name: 'SmolLM2 360M Q8_0', + url: 'https://huggingface.co/prithivMLmods/SmolLM2-360M-GGUF/resolve/main/SmolLM2-360M.Q8_0.gguf', + memoryRequirement: 500000000, +); + +// Register an STT model +Onnx.addModel( + id: 'sherpa-onnx-whisper-tiny.en', + name: 'Whisper Tiny English', + url: 'https://github.com/RunanywhereAI/sherpa-onnx/releases/download/runanywhere-models-v1/sherpa-onnx-whisper-tiny.en.tar.gz', + modality: ModelCategory.speechRecognition, +); + +// Register a TTS voice +Onnx.addModel( + id: 'vits-piper-en_US-lessac-medium', + name: 'Piper US English', + url: 'https://github.com/RunanywhereAI/sherpa-onnx/releases/download/runanywhere-models-v1/vits-piper-en_US-lessac-medium.tar.bz2', + modality: ModelCategory.textToSpeech, +); +``` + +### 3. Download & Load Models + +```dart +// Download with progress +await for (final progress in RunAnywhere.downloadModel('smollm2-360m-q8_0')) { + print('Download: ${(progress.bytesDownloaded / progress.totalBytes * 100).toStringAsFixed(1)}%'); + if (progress.state == DownloadProgressState.completed) break; +} + +// Load the model +await RunAnywhere.loadModel('smollm2-360m-q8_0'); +print('Model loaded: ${RunAnywhere.currentModelId}'); +``` + +### 4. Generate Text + +```dart +// Simple chat interface +final response = await RunAnywhere.chat('What is the capital of France?'); +print(response); // "The capital of France is Paris." + +// Full generation with metrics +final result = await RunAnywhere.generate( + 'Explain quantum computing in simple terms', + options: LLMGenerationOptions( + maxTokens: 200, + temperature: 0.7, + ), +); +print('Response: ${result.text}'); +print('Speed: ${result.tokensPerSecond.toStringAsFixed(1)} tok/s'); +print('Latency: ${result.latencyMs.toStringAsFixed(0)}ms'); +``` + +### 5. Streaming Generation + +```dart +final streamResult = await RunAnywhere.generateStream( + 'Write a short poem about AI', + options: LLMGenerationOptions(maxTokens: 150), +); + +// Display tokens in real-time +await for (final token in streamResult.stream) { + print(token, terminator: ''); +} + +// Get final metrics +final metrics = await streamResult.result; +print('\nSpeed: ${metrics.tokensPerSecond.toStringAsFixed(1)} tok/s'); + +// Cancel if needed +// streamResult.cancel(); +``` + +### 6. Speech-to-Text + +```dart +// Load STT model +await RunAnywhere.loadSTTModel('sherpa-onnx-whisper-tiny.en'); + +// Transcribe audio data (PCM16 at 16kHz mono) +final transcription = await RunAnywhere.transcribe(audioBytes); +print('Transcription: $transcription'); + +// With detailed result +final result = await RunAnywhere.transcribeWithResult(audioBytes); +print('Text: ${result.text}'); +print('Confidence: ${result.confidence}'); +``` + +### 7. Text-to-Speech + +```dart +// Load TTS voice +await RunAnywhere.loadTTSVoice('vits-piper-en_US-lessac-medium'); + +// Synthesize speech +final ttsResult = await RunAnywhere.synthesize( + 'Hello! Welcome to RunAnywhere.', + rate: 1.0, + pitch: 1.0, +); +// ttsResult.samples contains PCM Float32 audio +// ttsResult.sampleRate is typically 22050 Hz +``` + +### 8. Voice Agent Pipeline + +```dart +// Ensure all components are loaded +if (!RunAnywhere.isVoiceAgentReady) { + await RunAnywhere.loadSTTModel('sherpa-onnx-whisper-tiny.en'); + await RunAnywhere.loadModel('smollm2-360m-q8_0'); + await RunAnywhere.loadTTSVoice('vits-piper-en_US-lessac-medium'); +} + +// Start voice session +final session = await RunAnywhere.startVoiceSession(); + +// Listen to session events +session.events.listen((event) { + switch (event.runtimeType) { + case VoiceSessionListening: + print('Listening... Level: ${(event as VoiceSessionListening).audioLevel}'); + case VoiceSessionTurnCompleted: + final completed = event as VoiceSessionTurnCompleted; + print('User: ${completed.transcript}'); + print('AI: ${completed.response}'); + } +}); + +// Stop when done +await session.stop(); +``` + +--- + +## Architecture Overview + +The RunAnywhere Flutter SDK follows a **modular, provider-based architecture** with a C++ commons layer for cross-platform performance: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Your Flutter Application │ +├─────────────────────────────────────────────────────────────────┤ +│ RunAnywhere Flutter SDK │ +│ ┌──────────────┐ ┌───────────────┐ ┌──────────────────────┐ │ +│ │ Public APIs │ │ EventBus │ │ ModelRegistry │ │ +│ │ (generate, │ │ (events, │ │ (model discovery, │ │ +│ │ transcribe) │ │ lifecycle) │ │ download) │ │ +│ └──────────────┘ └───────────────┘ └──────────────────────┘ │ +├─────────────────────────────────────────────────────────────────┤ +│ Native Bridge Layer (FFI) │ +│ DartBridge → C++ Commons APIs │ +├────────────┬─────────────┬──────────────────────────────────────┤ +│ LlamaCpp │ ONNX │ Future Backends... │ +│ Backend │ Backend │ │ +│ (LLM) │ (STT/TTS) │ │ +└────────────┴─────────────┴──────────────────────────────────────┘ +``` + +### Key Components + +| Component | Description | +|-----------|-------------| +| **RunAnywhere** | Static class providing all public SDK methods | +| **EventBus** | Dart Stream-based event subscription for reactive UI | +| **DartBridge** | FFI bridge to C++ native libraries | +| **ModelRegistry** | Model discovery, registration, and persistence | + +### Package Composition + +| Package | Size | Provides | +|---------|------|----------| +| `runanywhere` | ~5MB | Core SDK, APIs, infrastructure | +| `runanywhere_llamacpp` | ~15-25MB | LLM capability (GGUF models) | +| `runanywhere_onnx` | ~50-70MB | STT, TTS, VAD (ONNX models) | + +> For detailed architecture documentation, see [ARCHITECTURE.md](ARCHITECTURE.md). + +--- + +## Configuration + +### SDK Initialization Parameters + +```dart +// Development mode (default) - no API key needed +await RunAnywhere.initialize(); + +// Production mode - requires API key and backend URL +await RunAnywhere.initialize( + apiKey: '', + baseURL: 'https://api.runanywhere.ai', + environment: SDKEnvironment.production, +); +``` + +### Environment Modes + +| Environment | Description | +|-------------|-------------| +| `.development` | Verbose logging, local-only, no auth required | +| `.staging` | Testing with real services | +| `.production` | Minimal logging, full authentication, telemetry | + +### Generation Options + +```dart +final options = LLMGenerationOptions( + maxTokens: 256, // Maximum tokens to generate + temperature: 0.7, // Sampling temperature (0.0–2.0) + topP: 0.95, // Top-p sampling parameter + stopSequences: ['END'], // Stop generation at these sequences + systemPrompt: 'You are a helpful assistant.', +); +``` + +--- + +## Error Handling + +The SDK provides comprehensive error handling through `SDKError`: + +```dart +try { + final response = await RunAnywhere.generate('Hello!'); +} on SDKError catch (error) { + switch (error.code) { + case SDKErrorCode.notInitialized: + print('SDK not initialized. Call RunAnywhere.initialize() first.'); + case SDKErrorCode.modelNotFound: + print('Model not found. Download it first.'); + case SDKErrorCode.modelNotDownloaded: + print('Model not downloaded. Call downloadModel() first.'); + case SDKErrorCode.componentNotReady: + print('Component not ready. Load the model first.'); + default: + print('Error: ${error.message}'); + } +} +``` + +### Error Categories + +| Category | Description | +|----------|-------------| +| `general` | General SDK errors | +| `llm` | LLM generation errors | +| `stt` | Speech-to-text errors | +| `tts` | Text-to-speech errors | +| `voiceAgent` | Voice pipeline errors | +| `download` | Model download errors | +| `validation` | Input validation errors | + +--- + +## Logging & Observability + +### Subscribe to Events + +```dart +// Subscribe to all events +RunAnywhere.events.events.listen((event) { + print('Event: ${event.type}'); +}); + +// Subscribe to specific event types +RunAnywhere.events.events + .where((e) => e is SDKModelEvent) + .listen((event) { + print('Model Event: ${event.type}'); + }); +``` + +### Event Types + +| Event | Description | +|-------|-------------| +| `SDKInitializationStarted` | SDK initialization began | +| `SDKInitializationCompleted` | SDK initialized successfully | +| `SDKModelEvent.loadStarted` | Model loading started | +| `SDKModelEvent.loadCompleted` | Model loaded successfully | +| `SDKModelEvent.downloadProgress` | Download progress update | + +--- + +## Performance & Best Practices + +### Model Selection + +| Model Size | RAM Required | Use Case | +|------------|--------------|----------| +| 360M–500M (Q8) | ~500MB | Fast, lightweight chat | +| 1B–3B (Q4/Q6) | 1–2GB | Balanced quality/speed | +| 7B (Q4) | 4–5GB | High quality, slower | + +### Memory Management + +```dart +// Unload models when not in use +await RunAnywhere.unloadModel(); +await RunAnywhere.unloadSTTModel(); +await RunAnywhere.unloadTTSVoice(); + +// Check storage before downloading +final storageInfo = await RunAnywhere.getStorageInfo(); +print('Available: ${storageInfo.deviceStorage.freeSpace} bytes'); + +// Delete unused models +await RunAnywhere.deleteStoredModel('old-model-id'); +``` + +### Best Practices + +1. **Prefer streaming** for better perceived latency +2. **Unload unused models** to free memory +3. **Handle errors gracefully** with user-friendly messages +4. **Test on physical devices** — emulators may be slow +5. **Use smaller models** for faster iteration during development +6. **Register models at startup** before calling `availableModels()` + +--- + +## Troubleshooting + +### Model Download Fails + +**Symptoms:** Download stuck or fails with network error + +**Solutions:** +1. Check internet connection +2. Verify sufficient storage (need 2x model size for extraction) +3. Try on WiFi instead of cellular +4. Check if model URL is accessible + +### Out of Memory + +**Symptoms:** App crashes during model loading or inference + +**Solutions:** +1. Use a smaller model (360M instead of 7B) +2. Unload unused models first +3. Close other memory-intensive apps +4. Test on device with more RAM + +### iOS: Symbol Not Found + +**Symptoms:** Runtime crash with "symbol not found" error + +**Solutions:** +1. Ensure `use_frameworks! :linkage => :static` in Podfile +2. Run `cd ios && pod install --repo-update` +3. Clean and rebuild: `flutter clean && flutter run` + +### Android: Library Load Failed + +**Symptoms:** `UnsatisfiedLinkError` or library load failure + +**Solutions:** +1. Ensure NDK is properly installed +2. Check that `jniLibs` folder contains `.so` files +3. Rebuild native libraries with `./scripts/build-flutter.sh --setup` + +### Model Not Found After Download + +**Symptoms:** `modelNotFound` error even though download completed + +**Solutions:** +1. Call `await RunAnywhere.refreshDiscoveredModels()` to refresh registry +2. Check model path in storage +3. Delete and re-download the model + +--- + +## FAQ + +### Q: Do I need an internet connection? +**A:** Only for initial model download. Once downloaded, all inference runs 100% on-device with no network required. + +### Q: How much storage do models need? +**A:** Varies by model: +- Small LLMs (360M–1B): 200MB–1GB +- Medium LLMs (3B–7B Q4): 2–5GB +- STT models (Whisper): 50–250MB +- TTS voices (Piper): 20–100MB + +### Q: Is user data sent to the cloud? +**A:** No. All inference happens on-device. Only anonymous analytics (latency, error rates) are collected in production mode, and this can be disabled. + +### Q: Which devices are supported? +**A:** iOS 14+ and Android API 24+. ARM64 devices are recommended for best performance. + +### Q: Can I use custom models? +**A:** Yes! Any GGUF model works with LlamaCpp backend. ONNX models work for STT/TTS with the appropriate format. + +### Q: How do I test on iOS Simulator? +**A:** The SDK supports both arm64 and x86_64 simulators, but performance will be significantly slower than physical devices. + +--- + +## Local Development & Contributing + +Contributions are welcome. This section explains how to set up your development environment to build the SDK from source and test your changes with the sample app. + +### Prerequisites + +- **Flutter** 3.10.0 or later +- **Xcode** 14+ (for iOS builds) +- **Android Studio** with NDK (for Android builds) +- **CMake** 3.21+ + +### First-Time Setup (Build from Source) + +The SDK depends on native C++ libraries from `runanywhere-commons`. The setup script builds these locally so you can develop and test the SDK end-to-end. + +```bash +# 1. Clone the repository +git clone https://github.com/RunanywhereAI/runanywhere-sdks.git +cd runanywhere-sdks/sdk/runanywhere-flutter + +# 2. Run first-time setup (~10-20 minutes) +./scripts/build-flutter.sh --setup + +# 3. Bootstrap Flutter packages +melos bootstrap # If melos is installed +# OR manually: +cd packages/runanywhere && flutter pub get && cd .. +cd packages/runanywhere_llamacpp && flutter pub get && cd .. +cd packages/runanywhere_onnx && flutter pub get && cd .. +``` + +**What the setup script does:** +1. Downloads dependencies (ONNX Runtime, Sherpa-ONNX) +2. Builds `RACommons.xcframework` and JNI libraries +3. Builds `RABackendLLAMACPP` (LLM backend) +4. Builds `RABackendONNX` (STT/TTS/VAD backend) +5. Copies frameworks to `ios/Frameworks/` and JNI libs to `android/src/main/jniLibs/` +6. Creates `.testlocal` marker files (enables local library consumption) + +### Understanding testLocal + +The SDK has two modes: + +| Mode | Description | +|------|-------------| +| **Local** | Uses frameworks/JNI libs from package directories (for development) | +| **Remote** | Downloads from GitHub releases during `pod install`/Gradle sync (for end users) | + +When you run `--setup`, the script automatically enables local mode via: +- **iOS**: `.testlocal` marker files in `ios/` directories +- **Android**: `testLocal = true` in `binary_config.gradle` files + +### Testing with the Flutter Sample App + +The recommended way to test SDK changes is with the sample app: + +```bash +# 1. Ensure SDK is set up (from previous step) + +# 2. Navigate to the sample app +cd ../../examples/flutter/RunAnywhereAI + +# 3. Install dependencies +flutter pub get + +# 4. Run on iOS +cd ios && pod install && cd .. +flutter run + +# 5. Or run on Android +flutter run +``` + +You can open the sample app in **Android Studio** or **VS Code** for development. + +The sample app's `pubspec.yaml` uses path dependencies to reference the local SDK packages: + +``` +Sample App → Local Flutter SDK Packages → Local Frameworks/JNI libs + ↑ + Built by build-flutter.sh --setup +``` + +### Development Workflow + +**After modifying Dart SDK code:** +- Changes are picked up automatically when you run `flutter run` + +**After modifying runanywhere-commons (C++ code):** + +```bash +cd sdk/runanywhere-flutter +./scripts/build-flutter.sh --local --rebuild-commons +``` + +### Build Script Reference + +| Command | Description | +|---------|-------------| +| `--setup` | First-time setup: downloads deps, builds all libraries, enables local mode | +| `--local` | Use local libraries from package directories | +| `--remote` | Use remote libraries from GitHub releases | +| `--rebuild-commons` | Rebuild runanywhere-commons from source | +| `--ios` | Build for iOS only | +| `--android` | Build for Android only | +| `--clean` | Clean build artifacts before building | +| `--abis=ABIS` | Android ABIs to build (default: `arm64-v8a`) | + +### Code Style + +We follow standard Dart style guidelines: + +```bash +# Format code +dart format lib/ test/ + +# Analyze code +flutter analyze + +# Fix issues automatically +dart fix --apply +``` + +### Pull Request Process + +1. Fork the repository +2. Create a feature branch: `git checkout -b feature/my-feature` +3. Make your changes with tests +4. Ensure all tests pass: `flutter test` +5. Run analyzer: `flutter analyze` +6. Commit with a descriptive message +7. Push and open a Pull Request + +### Reporting Issues + +Open an issue on GitHub with: +- SDK version: `RunAnywhere.version` +- Flutter version: `flutter --version` +- Platform and OS version +- Device model +- Steps to reproduce +- Expected vs actual behavior +- Relevant logs (with sensitive info redacted) + +--- + +## Support + +- **Discord**: [discord.gg/N359FBbDVd](https://discord.gg/N359FBbDVd) +- **GitHub Issues**: [github.com/RunanywhereAI/runanywhere-sdks/issues](https://github.com/RunanywhereAI/runanywhere-sdks/issues) +- **Email**: san@runanywhere.ai +- **Twitter**: [@RunanywhereAI](https://twitter.com/RunanywhereAI) + +--- + +## License + +Apache License 2.0 — See [LICENSE](../../LICENSE) for details. + +For commercial licensing inquiries, contact san@runanywhere.ai. + +--- + +## Related Documentation + +- [Architecture Overview](ARCHITECTURE.md) — Detailed system design +- [API Reference](Documentation.md) — Complete public API documentation +- [Flutter Starter Example](https://github.com/RunanywhereAI/flutter-starter-example) — Minimal starter project +- [Swift SDK](../runanywhere-swift/) — iOS/macOS native SDK +- [Kotlin SDK](../runanywhere-kotlin/) — Android native SDK +- [React Native SDK](../runanywhere-react-native/) — Cross-platform option + +## Packages on pub.dev + +- [runanywhere](https://pub.dev/packages/runanywhere) — Core SDK +- [runanywhere_llamacpp](https://pub.dev/packages/runanywhere_llamacpp) — LLM backend +- [runanywhere_onnx](https://pub.dev/packages/runanywhere_onnx) — STT/TTS/VAD backend diff --git a/sdk/runanywhere-flutter/analysis_options.yaml b/sdk/runanywhere-flutter/analysis_options.yaml new file mode 100644 index 000000000..64f78a3f7 --- /dev/null +++ b/sdk/runanywhere-flutter/analysis_options.yaml @@ -0,0 +1,122 @@ +# Include flutter_lints as base (available via package dependency in each package) +# Note: When analyzing from monorepo root, this may show a warning since flutter_lints +# is a dev_dependency in packages, not at root level. Run 'flutter analyze' from +# individual package directories for clean output. +include: package:flutter_lints/flutter.yaml + +linter: + rules: + # ========================================================================== + # STRONG TYPING (Matches iOS force_cast, force_unwrapping rules) + # ========================================================================== + - always_declare_return_types # All functions must have explicit return types + - avoid_types_as_parameter_names # Don't use type names as parameter names + - type_annotate_public_apis # All public APIs must be typed + - avoid_dynamic_calls # No calling methods on dynamic types + - always_use_package_imports # Use package: imports, not relative + - prefer_generic_function_type_aliases # Type safety for function types + + # ========================================================================== + # RELIABILITY (Matches iOS unused_* and error handling rules) + # ========================================================================== + - avoid_print # No print statements in production + - cancel_subscriptions # Always cancel stream subscriptions + - close_sinks # Always close stream sinks + - unawaited_futures # Catch unhandled futures + - discarded_futures # Don't discard futures + - empty_catches # No empty catch blocks (matches iOS philosophy) + # avoid_slow_async_io disabled - we use async File methods correctly + - throw_in_finally # Don't throw in finally blocks + + # ========================================================================== + # NULL SAFETY (Matches iOS force_unwrapping rules) + # ========================================================================== + - avoid_returning_null_for_void # Don't return null from void functions + # avoid_returning_null_for_future removed in Dart 3.3.0 + - avoid_null_checks_in_equality_operators # Use == null pattern correctly + - null_check_on_nullable_type_parameter # Proper null checking + + # ========================================================================== + # CODE ORGANIZATION (Matches iOS sorted_imports, multiline_* rules) + # ========================================================================== + - avoid_relative_lib_imports # Use package imports + - directives_ordering # Organize imports (dart, package, relative) + # always_put_required_named_parameters_first disabled - would require breaking API changes + # cascade_invocations disabled - can reduce code readability in many cases + - leading_newlines_in_multiline_strings # Consistent multiline strings + - curly_braces_in_flow_control_structures # Always use braces + + # ========================================================================== + # IMMUTABILITY & PERFORMANCE (Matches iOS const usage philosophy) + # ========================================================================== + - prefer_const_constructors # Use const where possible + - prefer_const_constructors_in_immutables # Const in immutable classes + - prefer_const_literals_to_create_immutables + - prefer_const_declarations # Const variables where possible + - prefer_final_fields # Fields should be final when possible + - prefer_final_locals # Local variables should be final + - prefer_final_in_for_each # Final in for-each loops + + # ========================================================================== + # STYLE & CONSISTENCY (Matches iOS style rules) + # ========================================================================== + - prefer_single_quotes # Use single quotes for strings + - prefer_void_to_null # Use void instead of Null + - use_key_in_widget_constructors # Keys in widget constructors + - avoid_catching_errors # Catch Exception, not Error + - no_duplicate_case_values # No duplicate switch cases + - avoid_void_async # Async functions should return Future + - avoid_empty_else # No empty else blocks + - prefer_is_empty # Use isEmpty instead of length == 0 + - prefer_is_not_empty # Use isNotEmpty instead of !isEmpty + - unnecessary_await_in_return # Don't await in return statements + - unnecessary_lambdas # Don't use lambdas when not needed + - unnecessary_null_aware_assignments # Don't use ??= when not needed + - use_string_buffers # Use StringBuffer for string concatenation + + # ========================================================================== + # DOCUMENTATION (Matches iOS public_member_api_docs philosophy) + # ========================================================================== + # - public_member_api_docs # Uncomment to require docs on public APIs + +analyzer: + exclude: + - '**/*.g.dart' + - '**/*.freezed.dart' + - 'lib/generated/**' + language: + strict-casts: true # No implicit casts from dynamic + strict-inference: true # Infer types strictly + strict-raw-types: true # Require type arguments for generic types + errors: + # ========================================================================== + # ERROR LEVEL - Must be fixed (Matches iOS error-level rules) + # ========================================================================== + dead_code: error + unused_import: error + unused_local_variable: error + unused_element: error + unused_field: error + + # ========================================================================== + # WARNING LEVEL - Should be fixed (Matches iOS warning-level rules) + # ========================================================================== + avoid_dynamic_calls: warning + avoid_print: warning + prefer_const_constructors: warning + prefer_const_declarations: warning + prefer_final_locals: warning + prefer_final_fields: warning + empty_catches: warning + + # ========================================================================== + # INFO LEVEL - Nice to have + # ========================================================================== + prefer_single_quotes: info + unnecessary_lambdas: info + use_string_buffers: info + + # ========================================================================== + # IGNORED - Generated code or special cases + # ========================================================================== + invalid_annotation_target: ignore diff --git a/sdk/runanywhere-flutter/docs/ARCHITECTURE.md b/sdk/runanywhere-flutter/docs/ARCHITECTURE.md new file mode 100644 index 000000000..f32fec2b4 --- /dev/null +++ b/sdk/runanywhere-flutter/docs/ARCHITECTURE.md @@ -0,0 +1,726 @@ +# RunAnywhere Flutter SDK – Architecture + +## 1. Overview + +The RunAnywhere Flutter SDK is a production-grade, on-device AI SDK designed to provide modular, low-latency AI capabilities for Flutter applications on iOS and Android. The SDK follows a capability-based architecture with a **modular backend design** using `runanywhere-commons` (C++) for shared functionality and platform-specific bindings. + +The architecture emphasizes: +- **Modular Backends**: Separate packages for each backend (LlamaCPP, ONNX) - only include what you need +- **C++ Commons Layer**: Shared C++ library (`runanywhere-commons`) handles backend registration, events, and common APIs +- **Dart Orchestration**: Dart SDK provides public APIs and coordinates native operations via FFI +- **Low Latency**: All inference runs on-device with Metal (iOS) and NEON (Android) acceleration +- **Lazy Initialization**: Network services and model discovery happen lazily on first use +- **Event-Driven Design**: Comprehensive event system for UI reactivity and analytics + +--- + +## 2. Multi-Package Architecture + +### 2.1 Package Structure + +``` +runanywhere-flutter/ +├── packages/ +│ ├── runanywhere/ # Core SDK (required) +│ │ ├── lib/ +│ │ │ ├── public/ # Public API surface +│ │ │ ├── core/ # Core types, protocols +│ │ │ ├── features/ # LLM, STT, TTS, VAD implementations +│ │ │ ├── foundation/ # Configuration, DI, errors, logging +│ │ │ ├── infrastructure/ # Device, download, events, files +│ │ │ ├── data/ # Network layer +│ │ │ ├── native/ # FFI bindings to C++ +│ │ │ └── capabilities/ # Voice session handling +│ │ ├── ios/ # iOS plugin + RACommons.xcframework +│ │ └── android/ # Android plugin + JNI libraries +│ │ +│ ├── runanywhere_llamacpp/ # LlamaCpp backend (LLM) +│ │ ├── lib/ # Dart bindings + model registration +│ │ ├── ios/ # RABackendLLAMACPP.xcframework +│ │ └── android/ # librac_backend_llamacpp.so +│ │ +│ └── runanywhere_onnx/ # ONNX backend (STT/TTS/VAD) +│ ├── lib/ # Dart bindings + model registration +│ ├── ios/ # RABackendONNX.xcframework + onnxruntime +│ └── android/ # librac_backend_onnx.so + ONNX Runtime +│ +├── melos.yaml # Multi-package management +├── scripts/ # Build scripts +└── analysis_options.yaml # Shared lint rules +``` + +### 2.2 Layer Overview + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Your Flutter Application │ +├─────────────────────────────────────────────────────────────────┤ +│ RunAnywhere Flutter SDK │ +│ ┌─────────────┐ ┌────────────────┐ ┌─────────────────────┐ │ +│ │ RunAnywhere │ │ EventBus │ │ ModelRegistry │ │ +│ │ (Public API)│ │ (Events) │ │ (Discovery) │ │ +│ └─────────────┘ └────────────────┘ └─────────────────────┘ │ +├─────────────────────────────────────────────────────────────────┤ +│ DartBridge (FFI Layer) │ +│ ┌─────────────┐ ┌────────────────┐ ┌─────────────────────┐ │ +│ │DartBridgeLLM│ │ DartBridgeSTT │ │ DartBridgeTTS │ │ +│ └─────────────┘ └────────────────┘ └─────────────────────┘ │ +├─────────────────────────────────────────────────────────────────┤ +│ runanywhere-commons (C++) │ +│ ┌─────────────┐ ┌────────────────┐ ┌─────────────────────┐ │ +│ │ModuleRegistry│ │ServiceRegistry │ │ EventPublisher │ │ +│ └─────────────┘ └────────────────┘ └─────────────────────┘ │ +├────────────┬─────────────┬──────────────────────────────────────┤ +│ LlamaCPP │ ONNX │ (Future Backends...) │ +│ Backend │ Backend │ │ +└────────────┴─────────────┴──────────────────────────────────────┘ +``` + +### 2.3 Binary Size Composition + +| Package | iOS Size | Android Size | Provides | +|---------|----------|--------------|----------| +| `runanywhere` | ~5MB | ~3MB | Core SDK, registries, events | +| `runanywhere_llamacpp` | ~15-25MB | ~10-15MB | LLM capability (GGUF) | +| `runanywhere_onnx` | ~50-70MB | ~40-60MB | STT, TTS, VAD (ONNX) | + +### 2.4 App Configuration Scenarios + +| App Configuration | iOS Total | Android Total | Use Case | +|-------------------|-----------|---------------|----------| +| LLM only | ~20-30MB | ~13-18MB | Chat apps without voice | +| STT/TTS only | ~55-75MB | ~43-63MB | Voice apps without LLM | +| Full (all) | ~70-100MB | ~53-78MB | Complete AI features | + +--- + +## 3. Core SDK Structure + +### 3.1 Public API Layer (`lib/public/`) + +``` +public/ +├── runanywhere.dart # Main entry point (RunAnywhere class) +├── configuration/ +│ └── sdk_environment.dart # SDKEnvironment enum +├── errors/ +│ └── errors.dart # SDKError, error codes +├── events/ +│ ├── event_bus.dart # EventBus singleton +│ └── sdk_event.dart # SDKEvent base class +├── extensions/ +│ ├── runanywhere_frameworks.dart # Framework extensions +│ ├── runanywhere_logging.dart # Logging extensions +│ └── runanywhere_storage.dart # Storage extensions +└── types/ + ├── types.dart # Re-exports all types + ├── capability_types.dart # STTCapability, TTSCapability + ├── configuration_types.dart # SDKInitParams + ├── download_types.dart # DownloadProgress, DownloadProgressState + ├── generation_types.dart # LLMGenerationOptions, LLMGenerationResult + ├── message_types.dart # ChatMessage types + └── voice_agent_types.dart # VoiceSession types +``` + +### 3.2 Core Types Layer (`lib/core/`) + +``` +core/ +├── models/ +│ └── audio_format.dart # AudioFormat enum +├── module/ +│ └── runanywhere_module.dart # RunAnywhereModule protocol +├── protocols/ +│ └── component/ +│ ├── component.dart # Component protocol +│ └── component_configuration.dart +└── types/ + ├── component_state.dart # ComponentState enum + ├── model_types.dart # ModelInfo, ModelCategory, InferenceFramework + ├── sdk_component.dart # SDKComponent enum (llm, stt, tts, vad) + └── storage_types.dart # StorageInfo, StoredModel +``` + +### 3.3 Features Layer (`lib/features/`) + +``` +features/ +├── llm/ +│ └── structured_output/ +│ ├── generatable.dart # Generatable mixin +│ ├── generation_hints.dart # GenerationHints +│ ├── stream_accumulator.dart +│ ├── stream_token.dart +│ ├── structured_output.dart +│ └── structured_output_handler.dart +├── stt/ +│ └── services/ +│ └── audio_capture_manager.dart +├── tts/ +│ ├── services/ +│ │ └── audio_playback_manager.dart +│ └── system_tts_service.dart # Fallback to flutter_tts +└── vad/ + ├── simple_energy_vad.dart # Energy-based VAD + └── vad_configuration.dart # VADConfiguration +``` + +### 3.4 Native Bridge Layer (`lib/native/`) + +``` +native/ +├── dart_bridge.dart # Main bridge coordinator +├── dart_bridge_device.dart # Device info bridge +├── dart_bridge_llm.dart # LLM operations bridge +├── dart_bridge_model_paths.dart # Model path resolution +├── dart_bridge_model_registry.dart # Model registry bridge +├── dart_bridge_stt.dart # STT operations bridge +├── dart_bridge_tts.dart # TTS operations bridge +├── dart_bridge_voice_agent.dart # Voice agent bridge +├── native_backend.dart # NativeBackend class +├── platform_loader.dart # Platform-specific loading +├── ffi_types.dart # FFI type definitions +└── ... (additional bridge files) +``` + +--- + +## 4. Core Components & Responsibilities + +### 4.1 RunAnywhere (Public API) + +**Purpose**: Single entry point for all SDK operations as a static class. + +**Location**: `lib/public/runanywhere.dart` + +**Key Responsibilities**: +- SDK initialization with environment configuration +- Model management (register, download, load, unload, delete) +- Text generation (chat, generate, generateStream) +- Speech operations (transcribe, synthesize) +- Voice agent session management +- Storage and analytics info + +**Pattern**: All public methods delegate to DartBridge for native operations. + +```dart +class RunAnywhere { + // Initialization + static Future initialize({...}) async { ... } + + // LLM Operations + static Future chat(String prompt) async { ... } + static Future generate(String prompt, {...}) async { ... } + static Future generateStream(String prompt, {...}) async { ... } + + // STT Operations + static Future transcribe(Uint8List audioData) async { ... } + + // TTS Operations + static Future synthesize(String text, {...}) async { ... } + + // Voice Agent + static Future startVoiceSession({...}) async { ... } + + // Model Management + static Future> availableModels() async { ... } + static Stream downloadModel(String modelId) async* { ... } + static Future loadModel(String modelId) async { ... } +} +``` + +### 4.2 DartBridge (FFI Layer) + +**Purpose**: Coordinates all FFI calls to C++ native libraries. + +**Location**: `lib/native/dart_bridge*.dart` + +**Sub-components**: + +| Bridge | Purpose | +|--------|---------| +| `DartBridgeLLM` | LLM model loading, generation, streaming | +| `DartBridgeSTT` | STT model loading, transcription | +| `DartBridgeTTS` | TTS voice loading, synthesis | +| `DartBridgeModelRegistry` | Model discovery, registration | +| `DartBridgeModelPaths` | Model path resolution | +| `DartBridgeDevice` | Device info retrieval | +| `DartBridgeVoiceAgent` | Voice pipeline orchestration | + +**Pattern**: Each bridge manages its own native handle and state. + +```dart +class DartBridgeLLM { + String? _currentModelId; + bool get isLoaded => _currentModelId != null; + + Future loadModel(String path, String modelId, String name) async { + final result = _bindings.rac_llm_component_load_model(path, modelId, name); + if (result != RacResultCode.success) { + throw NativeBackendException('Failed to load model'); + } + _currentModelId = modelId; + } + + Stream generateStream(String prompt, {...}) async* { + // Yields tokens as they're generated + } +} +``` + +### 4.3 Module System + +**Purpose**: Pluggable backend modules that provide AI capabilities. + +**Protocol**: `RunAnywhereModule` (in `lib/core/module/`) + +```dart +abstract class RunAnywhereModule { + String get moduleId; + String get moduleName; + Set get capabilities; + int get defaultPriority; + InferenceFramework get inferenceFramework; +} +``` + +**Registration Flow**: +1. App imports module package (`runanywhere_llamacpp`) +2. App calls `LlamaCpp.register()` +3. Module calls C++ `rac_backend_*_register()` via FFI +4. Backend registers its service providers with C++ registry +5. SDK routes operations to registered backends + +### 4.4 Event System + +**Purpose**: Unified event routing for UI reactivity and analytics. + +**Key Types**: +- `EventBus`: Singleton providing public event stream +- `SDKEvent`: Base class for all events +- Various event types (SDKInitializationStarted, SDKModelEvent, etc.) + +**Pattern**: +```dart +class EventBus { + static final EventBus shared = EventBus._(); + final StreamController _controller = + StreamController.broadcast(); + + Stream get events => _controller.stream; + + void publish(SDKEvent event) { + _controller.add(event); + } +} +``` + +### 4.5 Model Management + +**Purpose**: Model discovery, registration, download, and persistence. + +**Key Types**: +- `ModelInfo`: Immutable model metadata +- `ModelDownloadService`: Download with progress and extraction +- `DartBridgeModelRegistry`: C++ registry bridge + +**Model Flow**: +1. Models registered via `RunAnywhere.registerModel()` or `LlamaCpp.addModel()` +2. Registration saves to C++ global registry +3. Download handled by `ModelDownloadService` +4. After download, `localPath` is set in registry +5. Load operations use resolved path from registry + +--- + +## 5. Data & Control Flow + +### 5.1 Scenario: Text Generation Request + +**App calls**: `await RunAnywhere.chat('Hello!')` + +**Flow**: + +``` +1. RunAnywhere.chat(prompt) + ├─ Validates SDK is initialized + ├─ Checks DartBridge.llm.isLoaded + └─ Calls DartBridge.llm.generate(prompt, options) + +2. DartBridge.llm.generate() + ├─ Calls FFI: rac_llm_component_generate(prompt, maxTokens, temp) + ├─ C++ processes request via registered LlamaCPP backend + ├─ Returns LLMGenerationResult with text and metrics + └─ Publishes SDKModelEvent.generationCompleted + +3. Events Published: + └─ SDKModelEvent (captured by EventBus subscribers) +``` + +### 5.2 Scenario: Streaming Generation + +**App calls**: `await RunAnywhere.generateStream('Write a poem')` + +**Flow**: + +``` +1. RunAnywhere.generateStream(prompt, options) + ├─ Creates StreamController.broadcast() + ├─ Calls DartBridge.llm.generateStream(prompt, options) + └─ Returns LLMStreamingResult(stream, result future, cancel fn) + +2. DartBridge.llm.generateStream() + ├─ Calls FFI: rac_llm_component_generate_stream_start() + ├─ Polls for tokens in isolate/async loop + ├─ Yields tokens to StreamController + └─ Completes when generation ends or cancelled + +3. App consumes: + await for (final token in result.stream) { + updateUI(token); // Real-time token display + } + final metrics = await result.result; // Final stats +``` + +### 5.3 Scenario: Model Loading + +**App calls**: `await RunAnywhere.loadModel('smollm2-360m-q8_0')` + +**Flow**: + +``` +1. RunAnywhere.loadModel(modelId) + ├─ Validates SDK initialized + ├─ Gets model from availableModels() + ├─ Verifies model.localPath is set (downloaded) + ├─ Resolves actual file path via DartBridge.modelPaths + └─ Calls DartBridge.llm.loadModel(resolvedPath, modelId, name) + +2. DartBridge.llm.loadModel() + ├─ Unloads current model if any + ├─ Calls FFI: rac_llm_component_load_model(path, id, name) + ├─ C++ LlamaCPP backend loads GGUF model + └─ Updates _currentModelId on success + +3. Events Published: + ├─ SDKModelEvent.loadStarted(modelId) + └─ SDKModelEvent.loadCompleted(modelId) or loadFailed +``` + +### 5.4 Scenario: Voice Agent Turn + +**App calls**: `session.processVoiceTurn(audioData)` + +**Flow**: + +``` +1. VoiceSessionHandle receives audio + ├─ Validates voice agent is ready (STT + LLM + TTS loaded) + └─ Calls DartBridge.voiceAgent.processVoiceTurn(audioData) + +2. DartBridge.voiceAgent.processVoiceTurn() + ├─ Step 1: STT - rac_stt_component_transcribe(audioData) → text + ├─ Step 2: LLM - rac_llm_component_generate(text) → response + ├─ Step 3: TTS - rac_tts_component_synthesize(response) → audio + └─ Returns VoiceAgentProcessResult + +3. Session emits events: + ├─ VoiceSessionTranscribed(text) + ├─ VoiceSessionResponded(response) + └─ VoiceSessionTurnCompleted(transcript, response, audio) +``` + +--- + +## 6. Concurrency & Threading Model + +### 6.1 Isolate Usage + +Flutter's single-threaded UI model requires careful handling of CPU-intensive operations: + +- **FFI calls** run on the platform thread (iOS main, Android main/JNI) +- **Long operations** (model loading, inference) block the calling thread +- **Streaming** uses async polling with `Future.microtask`/`Timer` + +### 6.2 Async Patterns + +| Pattern | Usage | +|---------|-------| +| `async/await` | All public API methods | +| `Stream` | Streaming generation, download progress | +| `StreamController.broadcast()` | Token streams, event bus | +| `Completer` | Bridging callbacks to futures | + +### 6.3 Native Thread Safety + +- C++ backends handle their own threading +- FFI calls are serialized by Dart +- Model state protected by single-threaded access pattern + +--- + +## 7. Dependencies & Boundaries + +### 7.1 Core Package Dependencies + +| Dependency | Purpose | +|------------|---------| +| `ffi` | Foreign Function Interface for C++ | +| `http` | Network requests | +| `rxdart` | Advanced stream operations | +| `path_provider` | App directory paths | +| `shared_preferences` | Preferences storage | +| `flutter_secure_storage` | Secure data storage | +| `sqflite` | Local database | +| `device_info_plus` | Device information | +| `archive` | Model extraction (tar.bz2, zip) | +| `flutter_tts` | System TTS fallback | +| `record` | Audio recording | +| `audioplayers` | Audio playback | +| `permission_handler` | Permission management | + +### 7.2 Backend Dependencies + +**LlamaCpp Package**: +- `runanywhere` (core SDK) +- `ffi` (FFI bindings) + +**ONNX Package**: +- `runanywhere` (core SDK) +- `ffi` (FFI bindings) +- `http` (download strategy) +- `archive` (model extraction) + +### 7.3 Native Binary Dependencies + +| Platform | Libraries | +|----------|-----------| +| **iOS** | RACommons.xcframework, RABackendLLAMACPP.xcframework, RABackendONNX.xcframework, onnxruntime.xcframework | +| **Android** | librunanywhere_jni.so, librac_backend_llamacpp.so, librac_backend_onnx.so, libonnxruntime.so, libc++_shared.so, libomp.so | + +--- + +## 8. Extensibility Points + +### 8.1 Creating a New Backend Module + +1. Create a new Flutter package +2. Add `runanywhere` as dependency +3. Implement C++ backend with standard RAC API +4. Create Dart bindings via FFI +5. Implement registration: + +```dart +class MyBackend implements RunAnywhereModule { + static final MyBackend _instance = MyBackend._internal(); + + @override + String get moduleId => 'my-backend'; + + @override + Set get capabilities => {SDKComponent.llm}; + + static Future register() async { + final bindings = MyBackendBindings(); + bindings.rac_backend_mybackend_register(); + _isRegistered = true; + } + + static void addModel({required String name, required String url}) { + RunAnywhere.registerModel( + name: name, + url: Uri.parse(url), + framework: InferenceFramework.myBackend, + ); + } +} +``` + +### 8.2 Custom Download Strategies + +Implement custom download logic for special model sources: + +```dart +class MyCustomDownloadStrategy implements DownloadStrategy { + @override + bool canHandle(Uri url) => url.host == 'my-custom-host.com'; + + @override + Stream download(String modelId, Uri url, String destPath) async* { + // Custom download implementation + } +} +``` + +### 8.3 Event Subscriptions + +Apps can subscribe to SDK events for custom handling: + +```dart +RunAnywhere.events.events.listen((event) { + if (event is SDKModelEvent) { + analytics.track('model_event', {'type': event.type}); + } +}); +``` + +--- + +## 9. Build System + +### 9.1 Build Script + +The `scripts/build-flutter.sh` handles all native library building: + +| Flag | Action | +|------|--------| +| `--setup` | Full first-time setup | +| `--local` | Use locally built libraries | +| `--remote` | Use GitHub releases | +| `--rebuild-commons` | Rebuild C++ commons | +| `--ios` | iOS only | +| `--android` | Android only | +| `--clean` | Clean before build | + +### 9.2 Native Library Sources + +Libraries come from `runanywhere-commons`: +- Built via CMake for each platform +- iOS: XCFrameworks with device + simulator slices +- Android: JNI libraries for arm64-v8a, armeabi-v7a, x86_64 + +### 9.3 Melos Workflow + +Multi-package management via melos: + +```bash +melos bootstrap # Install all package dependencies +melos analyze # Run flutter analyze on all packages +melos format # Run dart format on all packages +melos test # Run tests on all packages +melos clean # Clean all packages +``` + +--- + +## 10. Known Trade-offs & Design Rationale + +### 10.1 Static Class vs Instance + +**Choice**: `RunAnywhere` is a static class, not instantiable. + +**Rationale**: + +Advantages: +- Simple, discoverable API (`RunAnywhere.generate()`) +- Singleton-like without explicit initialization + +Trade-offs: +- Harder to support multiple SDK instances +- Global state complicates testing + +### 10.2 FFI vs Platform Channels + +**Choice**: Direct FFI to C++ instead of MethodChannel. + +**Rationale**: + +Advantages: +- Lower latency (no serialization overhead) +- Direct memory access for audio/binary data +- Consistent with iOS/Android native SDKs + +Trade-offs: +- More complex error handling +- Platform-specific binary management + +### 10.3 Thin Backend Wrappers + +**Choice**: Backend packages (llamacpp, onnx) are thin wrappers. + +**Rationale**: + +Advantages: +- All logic lives in C++ (shared with Swift/Kotlin) +- Dart layer just registers and delegates +- Consistent behavior across all platforms + +Trade-offs: +- Debugging requires native tooling + +### 10.4 Lazy Model Discovery + +**Choice**: Model discovery runs on first `availableModels()` call. + +**Rationale**: + +Advantages: +- Fast SDK initialization +- Models can be registered before discovery + +Trade-offs: +- First `availableModels()` call is slower + +--- + +## 11. Future Considerations + +### 11.1 Potential Improvements + +- **Compute Isolates**: Move inference to separate isolate +- **Model Caching**: LRU cache for multiple loaded models +- **Streaming TTS**: Token-by-token speech synthesis +- **Background Download**: Download models while app is backgrounded + +### 11.2 Platform Expansions + +- **Web Support**: WebAssembly backend (experimental) +- **Desktop**: macOS/Windows/Linux support +- **Wear OS**: Minimal SDK for wearables + +--- + +## 12. Appendix: Key Types Reference + +### Public Types + +| Type | Description | +|------|-------------| +| `RunAnywhere` | Main entry point, all public SDK methods | +| `LLMGenerationResult` | Text generation result with metrics | +| `LLMGenerationOptions` | Options for text generation | +| `LLMStreamingResult` | Stream + result for streaming generation | +| `STTResult` | Transcription result with confidence | +| `TTSResult` | Synthesis result with audio samples | +| `ModelInfo` | Model metadata (id, name, category, path) | +| `DownloadProgress` | Download progress with state | +| `VoiceSessionHandle` | Voice session controller | +| `SDKEnvironment` | Environment enum | +| `SDKError` | SDK error with code and message | + +### Internal Types + +| Type | Description | +|------|-------------| +| `DartBridge` | FFI coordination | +| `DartBridgeLLM` | LLM native bridge | +| `DartBridgeSTT` | STT native bridge | +| `DartBridgeTTS` | TTS native bridge | +| `DartBridgeModelRegistry` | Model registry bridge | +| `ModelDownloadService` | Download management | +| `EventBus` | Event publishing | +| `SDKLogger` | Logging utility | + +### Protocols + +| Protocol | Description | +|----------|-------------| +| `RunAnywhereModule` | Backend module contract | +| `SDKEvent` | Base event protocol | + +### Backend Modules + +| Module | Package | Capabilities | +|--------|---------|--------------| +| LlamaCpp | `runanywhere_llamacpp` | LLM | +| ONNX | `runanywhere_onnx` | STT, TTS, VAD | diff --git a/sdk/runanywhere-flutter/docs/Documentation.md b/sdk/runanywhere-flutter/docs/Documentation.md new file mode 100644 index 000000000..331594953 --- /dev/null +++ b/sdk/runanywhere-flutter/docs/Documentation.md @@ -0,0 +1,1162 @@ +# RunAnywhere Flutter SDK – API Reference + +Complete API documentation for the RunAnywhere Flutter SDK. + +--- + +## Table of Contents + +1. [Core API](#core-api) + - [RunAnywhere](#runanywhere) +2. [Model Types](#model-types) + - [ModelInfo](#modelinfo) + - [ModelCategory](#modelcategory) + - [ModelFormat](#modelformat) + - [InferenceFramework](#inferenceframework) +3. [Generation Types](#generation-types) + - [LLMGenerationOptions](#llmgenerationoptions) + - [LLMGenerationResult](#llmgenerationresult) + - [LLMStreamingResult](#llmstreamingresult) + - [STTResult](#sttresult) + - [TTSResult](#ttsresult) +4. [Download Types](#download-types) + - [DownloadProgress](#downloadprogress) + - [DownloadProgressState](#downloadprogressstate) +5. [Voice Agent Types](#voice-agent-types) + - [VoiceSessionHandle](#voicesessionhandle) + - [VoiceSessionConfig](#voicesessionconfig) + - [VoiceSessionEvent](#voicesessionevent) + - [VoiceAgentComponentStates](#voiceagentcomponentstates) +6. [Event System](#event-system) + - [EventBus](#eventbus) + - [SDKEvent](#sdkevent) +7. [Error Handling](#error-handling) + - [SDKError](#sdkerror) +8. [Backend Modules](#backend-modules) + - [LlamaCpp](#llamacpp) + - [Onnx](#onnx) +9. [Configuration](#configuration) + - [SDKEnvironment](#sdkenvironment) + +--- + +## Core API + +### RunAnywhere + +The main entry point for all SDK operations. All methods are static. + +**Import:** +```dart +import 'package:runanywhere/runanywhere.dart'; +``` + +#### Properties + +| Property | Type | Description | +|----------|------|-------------| +| `version` | `String` | SDK version string | +| `isSDKInitialized` | `bool` | Whether SDK has been initialized | +| `isActive` | `bool` | Whether SDK is initialized and ready | +| `environment` | `SDKEnvironment?` | Current environment | +| `events` | `EventBus` | Event bus for SDK events | +| `currentModelId` | `String?` | Currently loaded LLM model ID | +| `isModelLoaded` | `bool` | Whether an LLM model is loaded | +| `currentSTTModelId` | `String?` | Currently loaded STT model ID | +| `isSTTModelLoaded` | `bool` | Whether an STT model is loaded | +| `currentTTSVoiceId` | `String?` | Currently loaded TTS voice ID | +| `isTTSVoiceLoaded` | `bool` | Whether a TTS voice is loaded | +| `isVoiceAgentReady` | `bool` | Whether all voice components are ready | + +#### Initialization + +##### `initialize()` + +Initialize the SDK with optional configuration. + +```dart +static Future initialize({ + String? apiKey, + String? baseURL, + SDKEnvironment environment = SDKEnvironment.development, +}) +``` + +**Parameters:** +- `apiKey` – API key for production mode (optional for development) +- `baseURL` – Base URL for API calls (optional for development) +- `environment` – SDK environment (defaults to development) + +**Example:** +```dart +// Development mode (no API key needed) +await RunAnywhere.initialize(); + +// Production mode +await RunAnywhere.initialize( + apiKey: 'your-api-key', + baseURL: 'https://api.runanywhere.ai', + environment: SDKEnvironment.production, +); +``` + +--- + +#### Model Management + +##### `availableModels()` + +Get all available models from the registry. + +```dart +static Future> availableModels() +``` + +**Returns:** List of `ModelInfo` objects for all registered models. + +**Example:** +```dart +final models = await RunAnywhere.availableModels(); +for (final model in models) { + print('${model.name}: ${model.isDownloaded ? "Downloaded" : "Not downloaded"}'); +} +``` + +##### `registerModel()` + +Register a model with the SDK. + +```dart +static ModelInfo registerModel({ + String? id, + required String name, + required Uri url, + required InferenceFramework framework, + ModelCategory modality = ModelCategory.language, + ModelArtifactType? artifactType, + int? memoryRequirement, + bool supportsThinking = false, +}) +``` + +**Parameters:** +- `id` – Unique model ID (auto-generated from name if not provided) +- `name` – Human-readable model name +- `url` – Download URL for the model +- `framework` – Inference framework (e.g., `InferenceFramework.llamaCpp`) +- `modality` – Model category (default: `language`) +- `artifactType` – Artifact type (auto-inferred from URL if not provided) +- `memoryRequirement` – Memory requirement in bytes +- `supportsThinking` – Whether model supports thinking tokens + +**Example:** +```dart +RunAnywhere.registerModel( + id: 'my-model', + name: 'My Custom Model', + url: Uri.parse('https://example.com/model.gguf'), + framework: InferenceFramework.llamaCpp, + memoryRequirement: 500000000, +); +``` + +##### `downloadModel()` + +Download a model by ID. + +```dart +static Stream downloadModel(String modelId) +``` + +**Parameters:** +- `modelId` – ID of the model to download + +**Returns:** Stream of `DownloadProgress` objects. + +**Example:** +```dart +await for (final progress in RunAnywhere.downloadModel('my-model')) { + print('Progress: ${(progress.percentage * 100).toStringAsFixed(1)}%'); + if (progress.state.isCompleted) break; +} +``` + +##### `loadModel()` + +Load an LLM model by ID. + +```dart +static Future loadModel(String modelId) +``` + +**Parameters:** +- `modelId` – ID of the model to load + +**Throws:** `SDKError` if model not found or not downloaded. + +**Example:** +```dart +await RunAnywhere.loadModel('smollm2-360m-q8_0'); +print('Model loaded: ${RunAnywhere.isModelLoaded}'); +``` + +##### `unloadModel()` + +Unload the currently loaded LLM model. + +```dart +static Future unloadModel() +``` + +##### `deleteStoredModel()` + +Delete a stored model from disk. + +```dart +static Future deleteStoredModel(String modelId) +``` + +--- + +#### LLM Generation + +##### `chat()` + +Simple text generation – returns only the generated text. + +```dart +static Future chat(String prompt) +``` + +**Parameters:** +- `prompt` – Input prompt for generation + +**Returns:** Generated text response. + +**Example:** +```dart +final response = await RunAnywhere.chat('Hello, how are you?'); +print(response); +``` + +##### `generate()` + +Full text generation with metrics. + +```dart +static Future generate( + String prompt, { + LLMGenerationOptions? options, +}) +``` + +**Parameters:** +- `prompt` – Input prompt for generation +- `options` – Generation options (optional) + +**Returns:** `LLMGenerationResult` with text and metrics. + +**Example:** +```dart +final result = await RunAnywhere.generate( + 'Explain quantum computing in simple terms', + options: LLMGenerationOptions(maxTokens: 200, temperature: 0.7), +); +print('Response: ${result.text}'); +print('Tokens: ${result.tokensUsed}'); +print('Latency: ${result.latencyMs}ms'); +``` + +##### `generateStream()` + +Streaming text generation. + +```dart +static Future generateStream( + String prompt, { + LLMGenerationOptions? options, +}) +``` + +**Parameters:** +- `prompt` – Input prompt for generation +- `options` – Generation options (optional) + +**Returns:** `LLMStreamingResult` containing stream, result future, and cancel function. + +**Example:** +```dart +final result = await RunAnywhere.generateStream('Tell me a story'); + +// Consume tokens as they arrive +await for (final token in result.stream) { + stdout.write(token); // Real-time output +} + +// Get final metrics +final metrics = await result.result; +print('\nTokens: ${metrics.tokensUsed}'); + +// Or cancel early if needed +// result.cancel(); +``` + +##### `cancelGeneration()` + +Cancel ongoing generation. + +```dart +static Future cancelGeneration() +``` + +--- + +#### Speech-to-Text (STT) + +##### `loadSTTModel()` + +Load an STT model by ID. + +```dart +static Future loadSTTModel(String modelId) +``` + +##### `unloadSTTModel()` + +Unload the currently loaded STT model. + +```dart +static Future unloadSTTModel() +``` + +##### `transcribe()` + +Transcribe audio data to text. + +```dart +static Future transcribe(Uint8List audioData) +``` + +**Parameters:** +- `audioData` – Raw audio bytes (PCM16 at 16kHz mono expected) + +**Returns:** Transcribed text. + +**Example:** +```dart +final text = await RunAnywhere.transcribe(audioBytes); +print('Transcription: $text'); +``` + +##### `transcribeWithResult()` + +Transcribe audio data with detailed result. + +```dart +static Future transcribeWithResult(Uint8List audioData) +``` + +**Returns:** `STTResult` with text, confidence, and metadata. + +**Example:** +```dart +final result = await RunAnywhere.transcribeWithResult(audioBytes); +print('Text: ${result.text}'); +print('Confidence: ${result.confidence}'); +print('Language: ${result.language}'); +``` + +--- + +#### Text-to-Speech (TTS) + +##### `loadTTSVoice()` + +Load a TTS voice by ID. + +```dart +static Future loadTTSVoice(String voiceId) +``` + +##### `unloadTTSVoice()` + +Unload the currently loaded TTS voice. + +```dart +static Future unloadTTSVoice() +``` + +##### `synthesize()` + +Synthesize speech from text. + +```dart +static Future synthesize( + String text, { + double rate = 1.0, + double pitch = 1.0, + double volume = 1.0, +}) +``` + +**Parameters:** +- `text` – Text to synthesize +- `rate` – Speech rate (0.5 to 2.0, default 1.0) +- `pitch` – Speech pitch (0.5 to 2.0, default 1.0) +- `volume` – Speech volume (0.0 to 1.0, default 1.0) + +**Returns:** `TTSResult` with audio samples and metadata. + +**Example:** +```dart +final result = await RunAnywhere.synthesize('Hello world'); +print('Samples: ${result.samples.length}'); +print('Sample rate: ${result.sampleRate} Hz'); +print('Duration: ${result.durationSeconds}s'); +``` + +--- + +#### Voice Agent + +##### `startVoiceSession()` + +Start a voice session with audio capture, VAD, and full voice pipeline. + +```dart +static Future startVoiceSession({ + VoiceSessionConfig config = VoiceSessionConfig.defaultConfig, +}) +``` + +**Parameters:** +- `config` – Voice session configuration (optional) + +**Returns:** `VoiceSessionHandle` to control the session. + +**Prerequisites:** STT, LLM, and TTS models must be loaded. + +**Example:** +```dart +final session = await RunAnywhere.startVoiceSession(); + +session.events.listen((event) { + if (event is VoiceSessionListening) { + print('Audio level: ${event.audioLevel}'); + } else if (event is VoiceSessionTranscribed) { + print('User said: ${event.text}'); + } else if (event is VoiceSessionResponded) { + print('AI response: ${event.text}'); + } else if (event is VoiceSessionTurnCompleted) { + print('Turn completed'); + } +}); + +// Later... +session.stop(); +``` + +##### `getVoiceAgentComponentStates()` + +Get the current state of all voice agent components. + +```dart +static VoiceAgentComponentStates getVoiceAgentComponentStates() +``` + +**Returns:** `VoiceAgentComponentStates` with STT, LLM, and TTS states. + +##### `cleanupVoiceAgent()` + +Cleanup voice agent resources. + +```dart +static void cleanupVoiceAgent() +``` + +--- + +#### Storage Information + +##### `getStorageInfo()` + +Get storage information including device storage, app storage, and downloaded models. + +```dart +static Future getStorageInfo() +``` + +**Returns:** `StorageInfo` with storage metrics. + +##### `getDownloadedModelsWithInfo()` + +Get downloaded models with their file sizes. + +```dart +static Future> getDownloadedModelsWithInfo() +``` + +--- + +#### Lifecycle + +##### `reset()` + +Reset SDK state (for testing or reinitialization). + +```dart +static void reset() +``` + +--- + +## Model Types + +### ModelInfo + +Information about a model. + +**Properties:** + +| Property | Type | Description | +|----------|------|-------------| +| `id` | `String` | Unique model identifier | +| `name` | `String` | Human-readable name | +| `category` | `ModelCategory` | Model category | +| `format` | `ModelFormat` | Model format | +| `framework` | `InferenceFramework` | Inference framework | +| `downloadURL` | `Uri?` | Download URL | +| `localPath` | `Uri?` | Local path (if downloaded) | +| `artifactType` | `ModelArtifactType` | Artifact type | +| `downloadSize` | `int?` | Download size in bytes | +| `contextLength` | `int?` | Context length (for LLMs) | +| `supportsThinking` | `bool` | Whether model supports thinking tokens | +| `description` | `String?` | Model description | +| `isDownloaded` | `bool` | Whether model is downloaded | +| `isAvailable` | `bool` | Whether model is available for use | +| `isBuiltIn` | `bool` | Whether model is built-in | + +### ModelCategory + +Model category/type. + +```dart +enum ModelCategory { + language, // Language Model (LLM) + speechRecognition, // Speech-to-Text + speechSynthesis, // Text-to-Speech + vision, // Vision Model + imageGeneration, // Image Generation + multimodal, // Multimodal + audio, // Audio Processing +} +``` + +### ModelFormat + +Supported model formats. + +```dart +enum ModelFormat { + onnx, // ONNX format + ort, // ONNX Runtime format + gguf, // GGUF format (llama.cpp) + bin, // Binary format + unknown, +} +``` + +### InferenceFramework + +Inference frameworks/runtimes. + +```dart +enum InferenceFramework { + onnx, // ONNX Runtime + llamaCpp, // llama.cpp + foundationModels, // Foundation Models + systemTTS, // System TTS + fluidAudio, // FluidAudio + builtIn, // Built-in + none, + unknown, +} +``` + +--- + +## Generation Types + +### LLMGenerationOptions + +Options for LLM text generation. + +```dart +class LLMGenerationOptions { + final int maxTokens; // Maximum tokens to generate (default: 100) + final double temperature; // Randomness (default: 0.8) + final double topP; // Nucleus sampling (default: 1.0) + final List stopSequences; // Stop sequences + final bool streamingEnabled; // Enable streaming + final InferenceFramework? preferredFramework; + final String? systemPrompt; // System prompt +} +``` + +**Example:** +```dart +const options = LLMGenerationOptions( + maxTokens: 200, + temperature: 0.7, + systemPrompt: 'You are a helpful assistant.', +); +``` + +### LLMGenerationResult + +Result of LLM text generation. + +**Properties:** + +| Property | Type | Description | +|----------|------|-------------| +| `text` | `String` | Generated text | +| `thinkingContent` | `String?` | Thinking content (if model supports it) | +| `inputTokens` | `int` | Number of input tokens | +| `tokensUsed` | `int` | Number of output tokens | +| `modelUsed` | `String` | Model ID used | +| `latencyMs` | `double` | Total latency in milliseconds | +| `framework` | `String?` | Framework used | +| `tokensPerSecond` | `double` | Generation speed | +| `timeToFirstTokenMs` | `double?` | Time to first token | +| `thinkingTokens` | `int` | Thinking tokens count | +| `responseTokens` | `int` | Response tokens count | + +### LLMStreamingResult + +Result of streaming LLM generation. + +**Properties:** + +| Property | Type | Description | +|----------|------|-------------| +| `stream` | `Stream` | Stream of tokens | +| `result` | `Future` | Final result future | +| `cancel` | `void Function()` | Cancel function | + +### STTResult + +Result of STT transcription. + +**Properties:** + +| Property | Type | Description | +|----------|------|-------------| +| `text` | `String` | Transcribed text | +| `confidence` | `double` | Confidence score (0.0 to 1.0) | +| `durationMs` | `int` | Audio duration in milliseconds | +| `language` | `String?` | Detected language | + +### TTSResult + +Result of TTS synthesis. + +**Properties:** + +| Property | Type | Description | +|----------|------|-------------| +| `samples` | `Float32List` | Audio samples (PCM float) | +| `sampleRate` | `int` | Sample rate in Hz | +| `durationMs` | `int` | Duration in milliseconds | +| `durationSeconds` | `double` | Duration in seconds | +| `numSamples` | `int` | Number of samples | + +--- + +## Download Types + +### DownloadProgress + +Download progress information. + +**Properties:** + +| Property | Type | Description | +|----------|------|-------------| +| `bytesDownloaded` | `int` | Bytes downloaded | +| `totalBytes` | `int` | Total bytes | +| `state` | `DownloadProgressState` | Current state | +| `stage` | `DownloadProgressStage` | Current stage | +| `overallProgress` | `double` | Progress 0.0 to 1.0 | +| `percentage` | `double` | Alias for overallProgress | + +### DownloadProgressState + +Download state enum. + +```dart +enum DownloadProgressState { + downloading, // Currently downloading + completed, // Download completed + failed, // Download failed + cancelled, // Download cancelled +} +``` + +**Helper properties:** +- `isCompleted` – Whether download completed successfully +- `isFailed` – Whether download failed + +--- + +## Voice Agent Types + +### VoiceSessionHandle + +Handle to control an active voice session. + +**Properties:** + +| Property | Type | Description | +|----------|------|-------------| +| `config` | `VoiceSessionConfig` | Session configuration | +| `events` | `Stream` | Event stream | +| `isRunning` | `bool` | Whether session is running | +| `isProcessing` | `bool` | Whether processing audio | + +**Methods:** + +##### `start()` + +Start the voice session. + +```dart +Future start() +``` + +##### `stop()` + +Stop the voice session. + +```dart +void stop() +``` + +##### `sendNow()` + +Force process current audio (push-to-talk mode). + +```dart +Future sendNow() +``` + +##### `feedAudio()` + +Feed audio data to the session (for external audio sources). + +```dart +void feedAudio(Uint8List data, double audioLevel) +``` + +##### `dispose()` + +Dispose resources. + +```dart +Future dispose() +``` + +### VoiceSessionConfig + +Configuration for voice session behavior. + +```dart +class VoiceSessionConfig { + final double silenceDuration; // Seconds before processing (default: 1.5) + final double speechThreshold; // Audio level threshold (default: 0.03) + final bool autoPlayTTS; // Auto-play TTS response (default: true) + final bool continuousMode; // Resume listening after TTS (default: true) +} +``` + +**Example:** +```dart +final config = VoiceSessionConfig( + silenceDuration: 2.0, // Wait 2 seconds of silence + speechThreshold: 0.1, // Higher threshold for noisy environments + autoPlayTTS: true, + continuousMode: true, +); + +final session = await RunAnywhere.startVoiceSession(config: config); +``` + +### VoiceSessionEvent + +Events emitted during a voice session. + +| Event | Description | +|-------|-------------| +| `VoiceSessionStarted` | Session started and ready | +| `VoiceSessionListening` | Listening with audio level | +| `VoiceSessionSpeechStarted` | Speech detected | +| `VoiceSessionProcessing` | Processing audio | +| `VoiceSessionTranscribed` | Got transcription | +| `VoiceSessionResponded` | Got LLM response | +| `VoiceSessionSpeaking` | Playing TTS | +| `VoiceSessionTurnCompleted` | Complete turn result | +| `VoiceSessionStopped` | Session stopped | +| `VoiceSessionError` | Error occurred | + +**Example:** +```dart +session.events.listen((event) { + switch (event) { + case VoiceSessionListening(:final audioLevel): + updateMeter(audioLevel); + case VoiceSessionTranscribed(:final text): + showUserText(text); + case VoiceSessionResponded(:final text): + showAssistantText(text); + case VoiceSessionTurnCompleted(:final transcript, :final response): + addToHistory(transcript, response); + case VoiceSessionError(:final message): + showError(message); + default: + break; + } +}); +``` + +### VoiceAgentComponentStates + +States of all voice agent components. + +**Properties:** + +| Property | Type | Description | +|----------|------|-------------| +| `stt` | `ComponentLoadState` | STT component state | +| `llm` | `ComponentLoadState` | LLM component state | +| `tts` | `ComponentLoadState` | TTS component state | +| `isFullyReady` | `bool` | All components loaded | +| `hasAnyLoaded` | `bool` | Any component loaded | + +--- + +## Event System + +### EventBus + +Central event bus for SDK-wide event distribution. + +**Access:** +```dart +final events = RunAnywhere.events; +``` + +**Streams:** + +| Stream | Type | Description | +|--------|------|-------------| +| `allEvents` | `Stream` | All SDK events | +| `initializationEvents` | `Stream` | Init events | +| `generationEvents` | `Stream` | LLM events | +| `modelEvents` | `Stream` | Model events | +| `voiceEvents` | `Stream` | Voice events | +| `storageEvents` | `Stream` | Storage events | +| `deviceEvents` | `Stream` | Device events | + +**Example:** +```dart +RunAnywhere.events.allEvents.listen((event) { + print('Event: ${event.type}'); +}); + +RunAnywhere.events.modelEvents.listen((event) { + if (event is SDKModelLoadCompleted) { + print('Model loaded: ${event.modelId}'); + } +}); +``` + +### SDKEvent + +Base class for all SDK events. + +**Properties:** + +| Property | Type | Description | +|----------|------|-------------| +| `id` | `String` | Unique event ID | +| `type` | `String` | Event type string | +| `category` | `EventCategory` | Event category | +| `timestamp` | `DateTime` | When event occurred | +| `sessionId` | `String?` | Optional session ID | +| `properties` | `Map` | Event properties | + +**Event Categories:** + +| Category | Events | +|----------|--------| +| `sdk` | Initialization, configuration | +| `llm` | Generation events | +| `stt` | Transcription events | +| `tts` | Synthesis events | +| `vad` | Voice activity detection | +| `voice` | Voice session events | +| `model` | Model load/download events | +| `device` | Device registration | +| `storage` | Cache/storage events | +| `error` | Error events | + +--- + +## Error Handling + +### SDKError + +SDK error with code and message. + +**Factory Methods:** + +| Method | Description | +|--------|-------------| +| `SDKError.notInitialized()` | SDK not initialized | +| `SDKError.validationFailed(message)` | Validation error | +| `SDKError.modelNotFound(message)` | Model not found | +| `SDKError.modelNotDownloaded(message)` | Model not downloaded | +| `SDKError.modelLoadFailed(modelId, message)` | Model load failed | +| `SDKError.generationFailed(message)` | Generation failed | +| `SDKError.componentNotReady(message)` | Component not ready | +| `SDKError.sttNotAvailable(message)` | STT not available | +| `SDKError.ttsNotAvailable(message)` | TTS not available | +| `SDKError.voiceAgentNotReady(message)` | Voice agent not ready | + +**Example:** +```dart +try { + await RunAnywhere.loadModel('nonexistent'); +} on SDKError catch (e) { + print('Error: ${e.message}'); + print('Code: ${e.code}'); +} +``` + +--- + +## Backend Modules + +### LlamaCpp + +LlamaCpp backend module for LLM text generation. + +**Import:** +```dart +import 'package:runanywhere_llamacpp/runanywhere_llamacpp.dart'; +``` + +##### `register()` + +Register the LlamaCpp backend. + +```dart +static Future register({int priority = 100}) +``` + +##### `addModel()` + +Add an LLM model to the registry. + +```dart +static void addModel({ + required String id, + required String name, + required String url, + int memoryRequirement = 0, + bool supportsThinking = false, +}) +``` + +**Example:** +```dart +await LlamaCpp.register(); + +LlamaCpp.addModel( + id: 'smollm2-360m-q8_0', + name: 'SmolLM2 360M Q8_0', + url: 'https://huggingface.co/.../SmolLM2-360M.Q8_0.gguf', + memoryRequirement: 500000000, +); +``` + +### Onnx + +ONNX backend module for STT, TTS, and VAD. + +**Import:** +```dart +import 'package:runanywhere_onnx/runanywhere_onnx.dart'; +``` + +##### `register()` + +Register the ONNX backend. + +```dart +static Future register({int priority = 100}) +``` + +##### `addModel()` + +Add an ONNX model to the registry. + +```dart +static void addModel({ + required String id, + required String name, + required String url, + required ModelCategory modality, + int memoryRequirement = 0, +}) +``` + +**Example:** +```dart +await Onnx.register(); + +// Add STT model +Onnx.addModel( + id: 'sherpa-onnx-whisper-tiny.en', + name: 'Whisper Tiny English', + url: 'https://github.com/.../sherpa-onnx-whisper-tiny.en.tar.gz', + modality: ModelCategory.speechRecognition, + memoryRequirement: 75000000, +); + +// Add TTS model +Onnx.addModel( + id: 'vits-piper-en_US-amy-medium', + name: 'Piper Amy (English)', + url: 'https://github.com/.../vits-piper-en_US-amy-medium.tar.gz', + modality: ModelCategory.speechSynthesis, + memoryRequirement: 50000000, +); +``` + +--- + +## Configuration + +### SDKEnvironment + +SDK environment enum. + +```dart +enum SDKEnvironment { + development, // Development mode (no API key needed) + staging, // Staging environment + production, // Production environment +} +``` + +**Properties:** +- `description` – Human-readable description + +--- + +## Complete Example + +```dart +import 'package:runanywhere/runanywhere.dart'; +import 'package:runanywhere_llamacpp/runanywhere_llamacpp.dart'; +import 'package:runanywhere_onnx/runanywhere_onnx.dart'; + +Future main() async { + // 1. Initialize SDK + await RunAnywhere.initialize(); + + // 2. Register backends + await LlamaCpp.register(); + await Onnx.register(); + + // 3. Add models + LlamaCpp.addModel( + id: 'smollm2', + name: 'SmolLM2 360M', + url: 'https://huggingface.co/.../SmolLM2-360M.Q8_0.gguf', + memoryRequirement: 500000000, + ); + + Onnx.addModel( + id: 'whisper-tiny', + name: 'Whisper Tiny', + url: 'https://github.com/.../sherpa-onnx-whisper-tiny.en.tar.gz', + modality: ModelCategory.speechRecognition, + memoryRequirement: 75000000, + ); + + Onnx.addModel( + id: 'piper-amy', + name: 'Piper Amy', + url: 'https://github.com/.../vits-piper-en_US-amy-medium.tar.gz', + modality: ModelCategory.speechSynthesis, + memoryRequirement: 50000000, + ); + + // 4. Download models + await for (final p in RunAnywhere.downloadModel('smollm2')) { + print('LLM: ${(p.percentage * 100).toStringAsFixed(1)}%'); + if (p.state.isCompleted) break; + } + + await for (final p in RunAnywhere.downloadModel('whisper-tiny')) { + print('STT: ${(p.percentage * 100).toStringAsFixed(1)}%'); + if (p.state.isCompleted) break; + } + + await for (final p in RunAnywhere.downloadModel('piper-amy')) { + print('TTS: ${(p.percentage * 100).toStringAsFixed(1)}%'); + if (p.state.isCompleted) break; + } + + // 5. Load models + await RunAnywhere.loadModel('smollm2'); + await RunAnywhere.loadSTTModel('whisper-tiny'); + await RunAnywhere.loadTTSVoice('piper-amy'); + + // 6. Use LLM + final response = await RunAnywhere.chat('Hello!'); + print('AI: $response'); + + // 7. Use Voice Agent + if (RunAnywhere.isVoiceAgentReady) { + final session = await RunAnywhere.startVoiceSession(); + + session.events.listen((event) { + if (event is VoiceSessionTurnCompleted) { + print('User: ${event.transcript}'); + print('AI: ${event.response}'); + } + }); + + // Run for a while... + await Future.delayed(Duration(seconds: 30)); + session.stop(); + } +} +``` + +--- + +## See Also + +- [README.md](README.md) – Getting started guide +- [ARCHITECTURE.md](ARCHITECTURE.md) – Architecture overview +- [Flutter Starter Example](https://github.com/RunanywhereAI/flutter-starter-example) – Minimal starter project + +## Packages on pub.dev + +- [runanywhere](https://pub.dev/packages/runanywhere) – Core SDK +- [runanywhere_llamacpp](https://pub.dev/packages/runanywhere_llamacpp) – LLM backend +- [runanywhere_onnx](https://pub.dev/packages/runanywhere_onnx) – STT/TTS/VAD backend diff --git a/sdk/runanywhere-flutter/melos.yaml b/sdk/runanywhere-flutter/melos.yaml new file mode 100644 index 000000000..b54a19ad3 --- /dev/null +++ b/sdk/runanywhere-flutter/melos.yaml @@ -0,0 +1,28 @@ +name: runanywhere_flutter + +packages: + - packages/** + +command: + version: + workspaceChangelog: true + + bootstrap: + runPubGetInParallel: true + +scripts: + analyze: + run: melos exec -- flutter analyze . + description: Run flutter analyze in all packages + + format: + run: melos exec -- dart format . + description: Run dart format in all packages + + test: + run: melos exec -- flutter test + description: Run tests in all packages + + clean: + run: melos exec -- flutter clean + description: Run flutter clean in all packages diff --git a/sdk/runanywhere-flutter/packages/runanywhere/CHANGELOG.md b/sdk/runanywhere-flutter/packages/runanywhere/CHANGELOG.md new file mode 100644 index 000000000..a31e566fc --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/CHANGELOG.md @@ -0,0 +1,55 @@ +# Changelog + +All notable changes to the RunAnywhere Flutter SDK will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.15.11] - 2025-01-11 + +### Fixed +- **iOS**: Updated RACommons.xcframework to v0.1.5 with correct symbol visibility + - The v0.1.4 xcframework had symbols that became local during linking + - v0.1.5 xcframework properly exports all symbols as global + - Combined with `DynamicLibrary.executable()` from 0.15.10, iOS now works correctly + +## [0.15.10] - 2025-01-11 + +### Fixed +- **iOS**: Fixed symbol lookup by using `DynamicLibrary.executable()` instead of `DynamicLibrary.process()` + - `process()` uses `dlsym(RTLD_DEFAULT)` which only finds GLOBAL symbols + - `executable()` can find both global and LOCAL symbols in the main binary + - With static linkage, xcframework symbols become local - this is the correct fix + +## [0.15.9] - 2025-01-11 + +### Fixed +- **iOS**: Added linker flags (partial fix, superseded by 0.15.10) + +### Important +- **iOS Podfile Configuration Required**: Users must configure their Podfile with `use_frameworks! :linkage => :static` for the SDK to work correctly. See README.md for complete setup instructions. + +## [0.15.8] - 2025-01-10 + +### Added +- Initial public release on pub.dev +- Core SDK infrastructure with modular backend support +- Event bus for component communication +- Service container for dependency injection +- Native FFI bridge for on-device AI inference +- Audio capture and playback management +- Model download and management system +- Voice session management +- Structured output handling for LLM responses + +### Features +- Speech-to-Text (STT) interface +- Text-to-Speech (TTS) interface with system TTS fallback +- Voice Activity Detection (VAD) interface +- LLM text generation interface with streaming support +- Voice agent orchestration +- Secure storage for API keys and credentials + +### Platforms +- iOS 13.0+ support +- Android API 24+ support diff --git a/sdk/runanywhere-flutter/packages/runanywhere/LICENSE b/sdk/runanywhere-flutter/packages/runanywhere/LICENSE new file mode 100644 index 000000000..f58b44f54 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/LICENSE @@ -0,0 +1,316 @@ +RunAnywhere License +Version 1.0, December 2025 + +Copyright (c) 2025 RunAnywhere, Inc. All Rights Reserved. + +This software and associated documentation files (the "Software") are made +available under the terms of this License. By using, copying, modifying, or +distributing the Software, you agree to be bound by the terms of this License. + + +PART I - GRANT OF PERMISSION +============================= + +Subject to the conditions in Part II, permission is hereby granted, free of +charge, to use, copy, modify, merge, publish, and distribute the Software, and +to permit persons to whom the Software is furnished to do so, under the terms +of the Apache License 2.0 (included in Part III below). + + +PART II - CONDITIONS AND RESTRICTIONS +===================================== + +1. PERMITTED USERS + + This free license grant applies only to: + + (a) Individual persons using the Software for personal, educational, + research, or non-commercial purposes; + + (b) Organizations (including parent companies, subsidiaries, and affiliates) + that meet BOTH of the following criteria: + (i) Less than $1,000,000 USD in total funding (including but not + limited to equity investments, debt financing, grants, and loans); + AND + (ii) Less than $1,000,000 USD in gross annual revenue; + + (c) Educational institutions, including but not limited to universities, + colleges, schools, and students enrolled in such institutions; + + (d) Non-profit organizations registered under section 501(c)(3) of the + United States Internal Revenue Code, or equivalent charitable status + in other jurisdictions; + + (e) Government agencies and public sector organizations; + + (f) Open source projects that are themselves licensed under an OSI-approved + open source license. + +2. COMMERCIAL LICENSE REQUIRED + + Any person or organization not meeting the criteria in Section 1 must obtain + a separate commercial license from RunAnywhere, Inc. + + Contact: san@runanywhere.ai for commercial licensing terms. + +3. THRESHOLD TRANSITION + + If an organization initially qualifies under Section 1(b) but subsequently + exceeds either threshold: + + (a) This free license automatically terminates upon exceeding the threshold; + + (b) A commercial license must be obtained within thirty (30) days of + exceeding either threshold; + + (c) For purposes of this license, "gross annual revenue" means total + revenue in the preceding twelve (12) months, calculated on a rolling + basis. + +4. ATTRIBUTION REQUIREMENTS + + All copies or substantial portions of the Software must include: + + (a) This License notice, or a prominent link to it; + + (b) The copyright notice: "Copyright (c) 2025 RunAnywhere, Inc." + + (c) If modifications are made, a statement that the Software has been + modified, including a description of the nature of modifications. + +5. TRADEMARK NOTICE + + This License does not grant permission to use the trade names, trademarks, + service marks, or product names of RunAnywhere, Inc., including "RunAnywhere", + except as required for reasonable and customary use in describing the origin + of the Software. + + +PART III - APACHE LICENSE 2.0 +============================= + +For users meeting the conditions in Part II, the following Apache License 2.0 +terms apply: + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF APACHE LICENSE 2.0 TERMS AND CONDITIONS + + +PART IV - GENERAL PROVISIONS +============================ + +1. ENTIRE AGREEMENT + + This License constitutes the entire agreement between the parties with + respect to the Software and supersedes all prior or contemporaneous + understandings regarding such subject matter. + +2. SEVERABILITY + + If any provision of this License is held to be unenforceable, such + provision shall be reformed only to the extent necessary to make it + enforceable, and the remaining provisions shall continue in full force + and effect. + +3. WAIVER + + No waiver of any term of this License shall be deemed a further or + continuing waiver of such term or any other term. + +4. GOVERNING LAW + + This License shall be governed by and construed in accordance with the + laws of the State of Delaware, United States, without regard to its + conflict of laws provisions. + +5. CONTACT + + For commercial licensing inquiries, questions about this License, or + to report violations, please contact: + + RunAnywhere, Inc. + Email: san@runanywhere.ai + +--- + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +RUNANYWHERE, INC. OR ANY CONTRIBUTORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +IN THE SOFTWARE. diff --git a/sdk/runanywhere-flutter/packages/runanywhere/README.md b/sdk/runanywhere-flutter/packages/runanywhere/README.md new file mode 100644 index 000000000..578e52170 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/README.md @@ -0,0 +1,147 @@ +# RunAnywhere Flutter SDK + +[![pub package](https://img.shields.io/pub/v/runanywhere.svg)](https://pub.dev/packages/runanywhere) +[![License](https://img.shields.io/badge/License-RunAnywhere-blue.svg)](https://github.com/RunanywhereAI/runanywhere-sdks/blob/main/LICENSE) + +Privacy-first, on-device AI SDK for Flutter. Run LLMs, Speech-to-Text, Text-to-Speech, and Voice AI directly on user devices. + +## Installation + +**Step 1:** Add packages to `pubspec.yaml`: + +```yaml +dependencies: + runanywhere: ^0.15.9 + runanywhere_onnx: ^0.15.9 # STT, TTS, VAD + runanywhere_llamacpp: ^0.15.9 # LLM text generation +``` + +**Step 2:** Configure platforms (see below). + +--- + +## iOS Setup (Required) + +After adding the packages, you **must** update your iOS Podfile for the SDK to work. + +### 1. Update `ios/Podfile` + +Make these **two critical changes**: + +```ruby +# Change 1: Set minimum iOS version to 14.0 +platform :ios, '14.0' + +# ... (keep existing flutter_root function and setup) ... + +target 'Runner' do + # Change 2: Add static linkage - THIS IS REQUIRED + use_frameworks! :linkage => :static + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + target.build_configurations.each do |config| + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '14.0' + # Required for microphone permission (STT/Voice features) + config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [ + '$(inherited)', + 'PERMISSION_MICROPHONE=1', + ] + end + end +end +``` + +> **Important:** Without `use_frameworks! :linkage => :static`, you will see "symbol not found" errors at runtime. + +### 2. Update `ios/Runner/Info.plist` + +Add microphone permission for STT/Voice features: + +```xml +NSMicrophoneUsageDescription +This app needs microphone access for speech recognition +``` + +### 3. Run pod install + +```bash +cd ios && pod install +``` + +--- + +## Android Setup + +Add microphone permission to `android/app/src/main/AndroidManifest.xml`: + +```xml + +``` + +--- + +## Quick Start + +```dart +import 'package:runanywhere/runanywhere.dart'; +import 'package:runanywhere_onnx/runanywhere_onnx.dart'; +import 'package:runanywhere_llamacpp/runanywhere_llamacpp.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + // Initialize SDK and register backends + await RunAnywhere.initialize(); + await Onnx.register(); + await LlamaCpp.register(); + + runApp(MyApp()); +} +``` + +### Text Generation (LLM) + +```dart +final stream = RunAnywhere.generateStream('Tell me a joke'); +await for (final token in stream) { + print(token); +} +``` + +### Speech-to-Text + +```dart +final result = await RunAnywhere.transcribe(audioData); +print(result.text); +``` + +--- + +## Platform Support + +| Platform | Minimum Version | +|----------|-----------------| +| iOS | 14.0+ | +| Android | API 24+ | + +## Documentation + +- [Full Documentation](https://runanywhere.ai) +- [Flutter Starter Example](https://github.com/RunanywhereAI/flutter-starter-example) + +## Related Packages + +- [runanywhere](https://pub.dev/packages/runanywhere) — Core SDK (this package) +- [runanywhere_llamacpp](https://pub.dev/packages/runanywhere_llamacpp) — LLM backend +- [runanywhere_onnx](https://pub.dev/packages/runanywhere_onnx) — STT/TTS/VAD backend + +## License + +RunAnywhere License (Apache 2.0 based). See [LICENSE](https://github.com/RunanywhereAI/runanywhere-sdks/blob/main/LICENSE). + +Commercial licensing: san@runanywhere.ai diff --git a/sdk/runanywhere-flutter/packages/runanywhere/android/binary_config.gradle b/sdk/runanywhere-flutter/packages/runanywhere/android/binary_config.gradle new file mode 100644 index 000000000..13d34a599 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/android/binary_config.gradle @@ -0,0 +1,50 @@ +// ============================================================================= +// BINARY CONFIGURATION FOR RUNANYWHERE FLUTTER SDK - ANDROID (Core Package) +// ============================================================================= +// This file controls whether to use local or remote native libraries (.so files). +// Similar to Swift Package.swift's testLocal flag. +// +// Set to `true` to use local binaries from android/src/main/jniLibs/ +// Set to `false` to download binaries from GitHub releases (production mode) +// ============================================================================= + +ext { + // Set this to true for local development/testing + // Set to false for production builds (downloads from GitHub releases) + testLocal = true + + // ============================================================================= + // Version Configuration (MUST match Swift Package.swift and Kotlin build.gradle.kts) + // ============================================================================= + commonsVersion = "0.1.4" + + // ============================================================================= + // Remote binary URLs + // RACommons from runanywhere-sdks (NOT runanywhere-binaries) + // ============================================================================= + commonsGitHubOrg = "RunanywhereAI" + commonsRepo = "runanywhere-sdks" + commonsBaseUrl = "https://github.com/${commonsGitHubOrg}/${commonsRepo}/releases/download" + + // Android native libraries package + commonsAndroidUrl = "${commonsBaseUrl}/commons-v${commonsVersion}/RACommons-android-v${commonsVersion}.zip" + + // Helper method to check if we should download + shouldDownloadAndroidLibs = { -> + return !testLocal + } + + // Helper method to check if local libs exist + checkLocalLibsExist = { -> + def jniLibsDir = project.file('src/main/jniLibs') + def arm64Dir = new File(jniLibsDir, 'arm64-v8a') + + if (!arm64Dir.exists() || !arm64Dir.isDirectory()) { + return false + } + + // Check for RACommons library + def commonsLib = new File(arm64Dir, 'librac_commons.so') + return commonsLib.exists() + } +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/android/build.gradle b/sdk/runanywhere-flutter/packages/runanywhere/android/build.gradle new file mode 100644 index 000000000..1b651ac17 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/android/build.gradle @@ -0,0 +1,184 @@ +// RunAnywhere Core SDK - Android +// +// This plugin bundles RACommons native libraries (.so files) for Android. +// RACommons provides the core infrastructure for on-device AI capabilities. +// +// Binary Configuration: +// Edit binary_config.gradle to toggle between local and remote binaries: +// - testLocal = true: Use local .so files from android/src/main/jniLibs/ (for development) +// - testLocal = false: Download from GitHub releases (for production) +// +// Version: Must match Swift SDK's Package.swift and Kotlin SDK's build.gradle.kts + +group 'ai.runanywhere.sdk' +version '0.15.8' + +// Load binary configuration +apply from: 'binary_config.gradle' + +buildscript { + ext.kotlin_version = '1.9.10' + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:8.1.0' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + namespace 'ai.runanywhere.sdk' + compileSdk 34 + + // Use NDK for native library support + ndkVersion "25.2.9519653" + + defaultConfig { + minSdk 24 + targetSdk 34 + + // ABI filters for native libraries + ndk { + abiFilters 'arm64-v8a', 'armeabi-v7a', 'x86_64' + } + + // Consumer proguard rules + consumerProguardFiles 'proguard-rules.pro' + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = '1.8' + } + + sourceSets { + main { + // Native libraries location - use downloaded libs or local libs based on config + jniLibs.srcDirs = [testLocal ? 'src/main/jniLibs' : 'build/jniLibs'] + } + } + + buildTypes { + release { + minifyEnabled false + } + } +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" +} + +// ============================================================================= +// Helper Functions +// ============================================================================= +import java.security.MessageDigest + +def calculateChecksum(File file) { + MessageDigest digest = MessageDigest.getInstance("SHA-256") + file.withInputStream { stream -> + byte[] buffer = new byte[8192] + int read + while ((read = stream.read(buffer)) > 0) { + digest.update(buffer, 0, read) + } + } + return digest.digest().collect { String.format("%02x", it) }.join('') +} + +// ============================================================================= +// Binary Download Task (runs when testLocal = false) +// ============================================================================= +task downloadNativeLibs { + group = 'runanywhere' + description = 'Download RACommons native libraries from GitHub releases' + + doLast { + if (shouldDownloadAndroidLibs()) { + println "📦 Remote mode: Downloading RACommons Android native libraries..." + + def jniLibsDir = file('build/jniLibs') + if (jniLibsDir.exists()) { + delete(jniLibsDir) + } + jniLibsDir.mkdirs() + + // Ensure build directory exists + buildDir.mkdirs() + + def downloadUrl = commonsAndroidUrl + def zipFile = file("${buildDir}/racommons-android.zip") + + println "Downloading from: ${downloadUrl}" + + // Download the zip file + ant.get(src: downloadUrl, dest: zipFile) + + println "✅ Downloaded successfully" + + // Extract to temp directory first + def tempDir = file("${buildDir}/racommons-temp") + if (tempDir.exists()) { + delete(tempDir) + } + tempDir.mkdirs() + + copy { + from zipTree(zipFile) + into tempDir + } + + // Copy .so files from jniLibs structure + tempDir.eachFileRecurse { file -> + if (file.isDirectory() && file.name in ['arm64-v8a', 'armeabi-v7a', 'x86_64', 'x86']) { + def targetAbiDir = new File(jniLibsDir, file.name) + targetAbiDir.mkdirs() + file.eachFile { soFile -> + if (soFile.name.endsWith('.so')) { + copy { + from soFile + into targetAbiDir + } + println " ✓ ${file.name}/${soFile.name}" + } + } + } + } + + // Clean up + zipFile.delete() + if (tempDir.exists()) { + delete(tempDir) + } + + println "✅ RACommons native libraries downloaded successfully" + } else { + println "🔧 Local mode: Using native libraries from src/main/jniLibs/" + + if (!checkLocalLibsExist()) { + throw new GradleException(""" + ⚠️ Native libraries not found in src/main/jniLibs/! + For local mode, please build and copy the libraries: + 1. cd runanywhere-commons && ./scripts/build-android.sh + 2. Copy the .so files to packages/runanywhere/android/src/main/jniLibs/ + Or switch to remote mode by editing binary_config.gradle: + testLocal = false + """) + } else { + println "✅ Using local native libraries" + } + } + } +} + +// Run downloadNativeLibs before preBuild +preBuild.dependsOn downloadNativeLibs diff --git a/sdk/runanywhere-flutter/packages/runanywhere/android/proguard-rules.pro b/sdk/runanywhere-flutter/packages/runanywhere/android/proguard-rules.pro new file mode 100644 index 000000000..a306684e1 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/android/proguard-rules.pro @@ -0,0 +1,8 @@ +# RunAnywhere SDK ProGuard Rules +# Keep native method signatures +-keepclasseswithmembernames class * { + native ; +} + +# Keep RunAnywhere plugin classes +-keep class ai.runanywhere.sdk.** { *; } diff --git a/sdk/runanywhere-flutter/packages/runanywhere/android/src/main/AndroidManifest.xml b/sdk/runanywhere-flutter/packages/runanywhere/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000..b1d0eaa8c --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/android/src/main/AndroidManifest.xml @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/sdk/runanywhere-flutter/packages/runanywhere/android/src/main/kotlin/ai/runanywhere/sdk/RunAnywherePlugin.kt b/sdk/runanywhere-flutter/packages/runanywhere/android/src/main/kotlin/ai/runanywhere/sdk/RunAnywherePlugin.kt new file mode 100644 index 000000000..33e848d4e --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/android/src/main/kotlin/ai/runanywhere/sdk/RunAnywherePlugin.kt @@ -0,0 +1,60 @@ +package ai.runanywhere.sdk + +import android.os.Build +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import io.flutter.plugin.common.MethodChannel.Result + +/** + * RunAnywhere Flutter Plugin - Android Implementation + * + * This plugin provides the native bridge for the RunAnywhere SDK on Android. + * The actual AI functionality is provided by RACommons native libraries (.so files). + */ +class RunAnywherePlugin : FlutterPlugin, MethodCallHandler { + private lateinit var channel: MethodChannel + + companion object { + private const val CHANNEL_NAME = "runanywhere" + private const val SDK_VERSION = "0.15.8" + private const val COMMONS_VERSION = "0.1.4" + + init { + // Load RACommons native libraries + try { + System.loadLibrary("rac_commons") + } catch (e: UnsatisfiedLinkError) { + // Library may not be available in all configurations + android.util.Log.w("RunAnywhere", "Failed to load rac_commons: ${e.message}") + } + } + } + + override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + channel = MethodChannel(flutterPluginBinding.binaryMessenger, CHANNEL_NAME) + channel.setMethodCallHandler(this) + } + + override fun onMethodCall(call: MethodCall, result: Result) { + when (call.method) { + "getPlatformVersion" -> { + result.success("Android ${Build.VERSION.RELEASE}") + } + "getSDKVersion" -> { + result.success(SDK_VERSION) + } + "getCommonsVersion" -> { + result.success(COMMONS_VERSION) + } + else -> { + result.notImplemented() + } + } + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + channel.setMethodCallHandler(null) + } +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/ios/.testlocal b/sdk/runanywhere-flutter/packages/runanywhere/ios/.testlocal new file mode 100644 index 000000000..e69de29bb diff --git a/sdk/runanywhere-flutter/packages/runanywhere/ios/Classes/RACommons.exports b/sdk/runanywhere-flutter/packages/runanywhere/ios/Classes/RACommons.exports new file mode 100644 index 000000000..0e0372e49 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/ios/Classes/RACommons.exports @@ -0,0 +1,549 @@ +_rac_alloc +_rac_analytics_event_emit +_rac_analytics_events_has_callback +_rac_analytics_events_has_public_callback +_rac_analytics_events_set_callback +_rac_analytics_events_set_public_callback +_rac_api_error_free +_rac_api_error_from_response +_rac_archive_type_extension +_rac_archive_type_from_path +_rac_artifact_infer_from_url +_rac_artifact_requires_download +_rac_artifact_requires_extraction +_rac_audio_float32_to_wav +_rac_audio_int16_to_wav +_rac_audio_pipeline_can_activate_microphone +_rac_audio_pipeline_can_play_tts +_rac_audio_pipeline_is_valid_transition +_rac_audio_pipeline_state_name +_rac_audio_wav_header_size +_rac_auth_build_authenticate_request +_rac_auth_build_refresh_request +_rac_auth_clear +_rac_auth_get_access_token +_rac_auth_get_device_id +_rac_auth_get_organization_id +_rac_auth_get_user_id +_rac_auth_get_valid_token +_rac_auth_handle_authenticate_response +_rac_auth_handle_refresh_response +_rac_auth_init +_rac_auth_is_authenticated +_rac_auth_load_stored_tokens +_rac_auth_needs_refresh +_rac_auth_request_to_json +_rac_auth_reset +_rac_auth_response_free +_rac_auth_response_from_json +_rac_auth_save_tokens +_rac_backend_platform_register +_rac_backend_platform_unregister +_rac_build_url +_rac_capability_resource_type_raw_value +_rac_clear_last_error +_rac_component_to_resource_type +_rac_configure_logging +_rac_dev_config_get_build_token +_rac_dev_config_get_sentry_dsn +_rac_dev_config_get_supabase_key +_rac_dev_config_get_supabase_url +_rac_dev_config_has_build_token +_rac_dev_config_has_supabase +_rac_dev_config_is_available +_rac_device_manager_clear_registration +_rac_device_manager_get_device_id +_rac_device_manager_is_registered +_rac_device_manager_register_if_needed +_rac_device_manager_set_callbacks +_rac_device_reg_request_to_json +_rac_device_reg_response_free +_rac_device_reg_response_from_json +_rac_device_registration_endpoint +_rac_device_registration_to_json +_rac_discovery_result_free +_rac_download_manager_cancel +_rac_download_manager_create +_rac_download_manager_destroy +_rac_download_manager_get_active_tasks +_rac_download_manager_get_progress +_rac_download_manager_is_healthy +_rac_download_manager_mark_complete +_rac_download_manager_mark_failed +_rac_download_manager_pause_all +_rac_download_manager_resume_all +_rac_download_manager_start +_rac_download_manager_update_progress +_rac_download_result_free +_rac_download_stage_display_name +_rac_download_stage_progress_range +_rac_download_strategy_get +_rac_download_strategy_register +_rac_download_task_free +_rac_download_task_ids_free +_rac_endpoint_device_registration +_rac_endpoint_model_assignments +_rac_endpoint_telemetry +_rac_energy_vad_calculate_rms +_rac_energy_vad_create +_rac_energy_vad_destroy +_rac_energy_vad_get_frame_length_samples +_rac_energy_vad_get_sample_rate +_rac_energy_vad_get_statistics +_rac_energy_vad_get_threshold +_rac_energy_vad_initialize +_rac_energy_vad_is_calibrating +_rac_energy_vad_is_speech_active +_rac_energy_vad_notify_tts_finish +_rac_energy_vad_notify_tts_start +_rac_energy_vad_pause +_rac_energy_vad_process_audio +_rac_energy_vad_reset +_rac_energy_vad_resume +_rac_energy_vad_set_audio_callback +_rac_energy_vad_set_calibration_multiplier +_rac_energy_vad_set_speech_callback +_rac_energy_vad_set_threshold +_rac_energy_vad_set_tts_multiplier +_rac_energy_vad_start +_rac_energy_vad_start_calibration +_rac_energy_vad_stop +_rac_env_default_log_level +_rac_env_description +_rac_env_is_production +_rac_env_is_testing +_rac_env_requires_auth +_rac_env_requires_backend_url +_rac_env_should_send_telemetry +_rac_env_should_sync_with_backend +_rac_error_add_frame +_rac_error_capture_stack_trace +_rac_error_category_name +_rac_error_clear_details +_rac_error_code_name +_rac_error_copy +_rac_error_create +_rac_error_create_at +_rac_error_createf +_rac_error_destroy +_rac_error_get_details +_rac_error_get_telemetry_properties +_rac_error_is_commons_error +_rac_error_is_core_error +_rac_error_is_expected +_rac_error_is_expected_error +_rac_error_log_and_track +_rac_error_log_and_track_model +_rac_error_message +_rac_error_recovery_suggestion +_rac_error_set_custom +_rac_error_set_details +_rac_error_set_model_context +_rac_error_set_session +_rac_error_set_source +_rac_error_set_underlying +_rac_error_to_debug_string +_rac_error_to_json +_rac_error_to_string +_rac_event_category_name +_rac_event_get_destination +_rac_event_publish +_rac_event_subscribe +_rac_event_subscribe_all +_rac_event_track +_rac_event_unsubscribe +_rac_expected_model_files_alloc +_rac_expected_model_files_free +_rac_extract_archive +_rac_framework_analytics_key +_rac_framework_display_name +_rac_framework_get_supported_formats +_rac_framework_is_platform_service +_rac_framework_raw_value +_rac_framework_supports_format +_rac_framework_supports_llm +_rac_framework_supports_stt +_rac_framework_supports_tts +_rac_framework_uses_directory_based_models +_rac_free +_rac_generation_analytics_complete +_rac_generation_analytics_create +_rac_generation_analytics_destroy +_rac_generation_analytics_get_metrics +_rac_generation_analytics_reset +_rac_generation_analytics_start +_rac_generation_analytics_start_streaming +_rac_generation_analytics_track_failed +_rac_generation_analytics_track_first_token +_rac_generation_analytics_track_streaming_update +_rac_get_current_time_ms +_rac_get_last_error +_rac_get_model +_rac_get_model_registry +_rac_get_platform_adapter +_rac_get_version +_rac_health_response_free +_rac_http_add_api_key_header +_rac_http_add_auth_header +_rac_http_add_sdk_headers +_rac_http_download +_rac_http_download_cancel +_rac_http_execute +_rac_http_get +_rac_http_has_executor +_rac_http_post_json +_rac_http_request_add_header +_rac_http_request_create +_rac_http_request_free +_rac_http_request_set_body +_rac_http_request_set_timeout +_rac_http_response_free +_rac_http_set_executor +_rac_init +_rac_is_initialized +_rac_lifecycle_create +_rac_lifecycle_destroy +_rac_lifecycle_get_metrics +_rac_lifecycle_get_model_id +_rac_lifecycle_get_model_name +_rac_lifecycle_get_service +_rac_lifecycle_get_state +_rac_lifecycle_is_loaded +_rac_lifecycle_load +_rac_lifecycle_require_service +_rac_lifecycle_reset +_rac_lifecycle_state_name +_rac_lifecycle_track_error +_rac_lifecycle_unload +_rac_llm_analytics_complete_generation +_rac_llm_analytics_create +_rac_llm_analytics_destroy +_rac_llm_analytics_get_metrics +_rac_llm_analytics_start_generation +_rac_llm_analytics_start_streaming_generation +_rac_llm_analytics_track_error +_rac_llm_analytics_track_first_token +_rac_llm_analytics_track_generation_failed +_rac_llm_analytics_track_streaming_update +_rac_llm_cancel +_rac_llm_cleanup +_rac_llm_component_cancel +_rac_llm_component_cleanup +_rac_llm_component_configure +_rac_llm_component_create +_rac_llm_component_destroy +_rac_llm_component_generate +_rac_llm_component_generate_stream +_rac_llm_component_get_metrics +_rac_llm_component_get_model_id +_rac_llm_component_get_state +_rac_llm_component_is_loaded +_rac_llm_component_load_model +_rac_llm_component_supports_streaming +_rac_llm_component_unload +_rac_llm_create +_rac_llm_destroy +_rac_llm_generate +_rac_llm_generate_stream +_rac_llm_get_info +_rac_llm_initialize +_rac_llm_platform_create +_rac_llm_platform_destroy +_rac_llm_platform_generate +_rac_llm_result_free +_rac_llm_result_free +_rac_log +_rac_logger_get_min_level +_rac_logger_init +_rac_logger_log +_rac_logger_logf +_rac_logger_logv +_rac_logger_set_min_level +_rac_logger_set_stderr_always +_rac_logger_set_stderr_fallback +_rac_logger_shutdown +_rac_model_assignment_clear_cache +_rac_model_assignment_fetch +_rac_model_assignment_get_by_category +_rac_model_assignment_get_by_framework +_rac_model_assignment_set_cache_timeout +_rac_model_assignment_set_callbacks +_rac_model_category_from_framework +_rac_model_category_requires_context_length +_rac_model_category_supports_thinking +_rac_model_detect_format_from_extension +_rac_model_detect_framework_from_format +_rac_model_file_descriptors_alloc +_rac_model_file_descriptors_free +_rac_model_filter_models +_rac_model_format_extension +_rac_model_generate_id +_rac_model_generate_name +_rac_model_infer_artifact_type +_rac_model_info_alloc +_rac_model_info_array_free +_rac_model_info_copy +_rac_model_info_free +_rac_model_info_is_downloaded +_rac_model_matches_filter +_rac_model_paths_extract_framework +_rac_model_paths_extract_model_id +_rac_model_paths_get_base_dir +_rac_model_paths_get_base_directory +_rac_model_paths_get_cache_directory +_rac_model_paths_get_downloads_directory +_rac_model_paths_get_expected_model_path +_rac_model_paths_get_framework_directory +_rac_model_paths_get_model_file_path +_rac_model_paths_get_model_folder +_rac_model_paths_get_model_path +_rac_model_paths_get_models_directory +_rac_model_paths_get_temp_directory +_rac_model_paths_is_model_path +_rac_model_paths_set_base_dir +_rac_model_registry_create +_rac_model_registry_destroy +_rac_model_registry_discover_downloaded +_rac_model_registry_get +_rac_model_registry_get_all +_rac_model_registry_get_by_frameworks +_rac_model_registry_get_downloaded +_rac_model_registry_remove +_rac_model_registry_save +_rac_model_registry_update_download_status +_rac_model_registry_update_last_used +_rac_model_storage_details_free +_rac_model_strategy_detect +_rac_model_strategy_find_path +_rac_model_strategy_get_download_dest +_rac_model_strategy_is_valid +_rac_model_strategy_post_process +_rac_model_strategy_prepare_download +_rac_model_strategy_unregister +_rac_module_get_info +_rac_module_list +_rac_module_register +_rac_module_unregister +_rac_modules_for_capability +_rac_platform_llm_get_callbacks +_rac_platform_llm_is_available +_rac_platform_llm_set_callbacks +_rac_platform_tts_get_callbacks +_rac_platform_tts_is_available +_rac_platform_tts_set_callbacks +_rac_refresh_request_to_json +_rac_register_model +_rac_resource_type_name +_rac_resource_type_to_component +_rac_sdk_component_display_name +_rac_sdk_component_raw_value +_rac_sdk_get_config +_rac_sdk_get_environment +_rac_sdk_init +_rac_sdk_is_initialized +_rac_sdk_reset +_rac_service_create +_rac_service_list_providers +_rac_service_register_provider +_rac_service_unregister_provider +_rac_set_error +_rac_set_last_error +_rac_set_platform_adapter +_rac_shutdown +_rac_state_clear_auth +_rac_state_get_access_token +_rac_state_get_api_key +_rac_state_get_base_url +_rac_state_get_device_id +_rac_state_get_environment +_rac_state_get_instance +_rac_state_get_organization_id +_rac_state_get_refresh_token +_rac_state_get_token_expires_at +_rac_state_get_user_id +_rac_state_initialize +_rac_state_is_authenticated +_rac_state_is_device_registered +_rac_state_is_initialized +_rac_state_on_auth_changed +_rac_state_reset +_rac_state_set_auth +_rac_state_set_device_registered +_rac_state_set_persistence_callbacks +_rac_state_shutdown +_rac_state_token_needs_refresh +_rac_storage_analyzer_analyze +_rac_storage_analyzer_calculate_size +_rac_storage_analyzer_check_available +_rac_storage_analyzer_create +_rac_storage_analyzer_destroy +_rac_storage_analyzer_get_model_metrics +_rac_storage_availability_free +_rac_storage_info_free +_rac_storage_strategy_get +_rac_storage_strategy_register +_rac_strdup +_rac_streaming_metrics_create +_rac_streaming_metrics_destroy +_rac_streaming_metrics_get_result +_rac_streaming_metrics_get_text +_rac_streaming_metrics_get_token_count +_rac_streaming_metrics_get_ttft +_rac_streaming_metrics_mark_complete +_rac_streaming_metrics_mark_failed +_rac_streaming_metrics_mark_start +_rac_streaming_metrics_record_token +_rac_streaming_metrics_set_token_counts +_rac_streaming_result_free +_rac_structured_output_extract_json +_rac_structured_output_find_complete_json +_rac_structured_output_find_matching_brace +_rac_structured_output_find_matching_bracket +_rac_structured_output_get_system_prompt +_rac_structured_output_prepare_prompt +_rac_structured_output_validate +_rac_structured_output_validation_free +_rac_stt_analytics_complete_transcription +_rac_stt_analytics_create +_rac_stt_analytics_destroy +_rac_stt_analytics_get_metrics +_rac_stt_analytics_start_transcription +_rac_stt_analytics_track_error +_rac_stt_analytics_track_final_transcript +_rac_stt_analytics_track_language_detection +_rac_stt_analytics_track_partial_transcript +_rac_stt_analytics_track_transcription_failed +_rac_stt_cleanup +_rac_stt_component_cleanup +_rac_stt_component_configure +_rac_stt_component_create +_rac_stt_component_destroy +_rac_stt_component_get_metrics +_rac_stt_component_get_model_id +_rac_stt_component_get_state +_rac_stt_component_is_loaded +_rac_stt_component_load_model +_rac_stt_component_supports_streaming +_rac_stt_component_transcribe +_rac_stt_component_transcribe_stream +_rac_stt_component_unload +_rac_stt_create +_rac_stt_destroy +_rac_stt_get_info +_rac_stt_initialize +_rac_stt_result_free +_rac_stt_result_free +_rac_stt_transcribe +_rac_stt_transcribe_stream +_rac_telemetry_batch_response_free +_rac_telemetry_batch_to_json +_rac_telemetry_event_to_json +_rac_telemetry_manager_batch_to_json +_rac_telemetry_manager_create +_rac_telemetry_manager_destroy +_rac_telemetry_manager_flush +_rac_telemetry_manager_http_complete +_rac_telemetry_manager_payload_to_json +_rac_telemetry_manager_set_device_info +_rac_telemetry_manager_set_http_callback +_rac_telemetry_manager_track +_rac_telemetry_manager_track_analytics +_rac_telemetry_payload_default +_rac_telemetry_payload_free +_rac_telemetry_response_free +_rac_telemetry_response_from_json +_rac_tts_analytics_complete_synthesis +_rac_tts_analytics_create +_rac_tts_analytics_destroy +_rac_tts_analytics_get_metrics +_rac_tts_analytics_start_synthesis +_rac_tts_analytics_track_error +_rac_tts_analytics_track_synthesis_chunk +_rac_tts_analytics_track_synthesis_failed +_rac_tts_cleanup +_rac_tts_component_cleanup +_rac_tts_component_configure +_rac_tts_component_create +_rac_tts_component_destroy +_rac_tts_component_get_metrics +_rac_tts_component_get_state +_rac_tts_component_get_voice_id +_rac_tts_component_is_loaded +_rac_tts_component_load_voice +_rac_tts_component_stop +_rac_tts_component_synthesize +_rac_tts_component_synthesize_stream +_rac_tts_component_unload +_rac_tts_create +_rac_tts_destroy +_rac_tts_get_info +_rac_tts_initialize +_rac_tts_platform_create +_rac_tts_platform_destroy +_rac_tts_platform_stop +_rac_tts_platform_synthesize +_rac_tts_result_free +_rac_tts_result_free +_rac_tts_stop +_rac_tts_synthesize +_rac_tts_synthesize_stream +_rac_vad_analytics_create +_rac_vad_analytics_destroy +_rac_vad_analytics_get_metrics +_rac_vad_analytics_track_cleaned_up +_rac_vad_analytics_track_initialization_failed +_rac_vad_analytics_track_initialized +_rac_vad_analytics_track_model_load_completed +_rac_vad_analytics_track_model_load_failed +_rac_vad_analytics_track_model_load_started +_rac_vad_analytics_track_model_unloaded +_rac_vad_analytics_track_paused +_rac_vad_analytics_track_resumed +_rac_vad_analytics_track_speech_end +_rac_vad_analytics_track_speech_start +_rac_vad_analytics_track_started +_rac_vad_analytics_track_stopped +_rac_vad_component_cleanup +_rac_vad_component_configure +_rac_vad_component_create +_rac_vad_component_destroy +_rac_vad_component_get_energy_threshold +_rac_vad_component_get_metrics +_rac_vad_component_get_state +_rac_vad_component_initialize +_rac_vad_component_is_initialized +_rac_vad_component_is_speech_active +_rac_vad_component_process +_rac_vad_component_reset +_rac_vad_component_set_activity_callback +_rac_vad_component_set_audio_callback +_rac_vad_component_set_energy_threshold +_rac_vad_component_start +_rac_vad_component_stop +_rac_validate_api_key +_rac_validate_base_url +_rac_validate_config +_rac_validation_error_message +_rac_voice_agent_cleanup +_rac_voice_agent_create +_rac_voice_agent_create_standalone +_rac_voice_agent_destroy +_rac_voice_agent_detect_speech +_rac_voice_agent_generate_response +_rac_voice_agent_get_llm_model_id +_rac_voice_agent_get_stt_model_id +_rac_voice_agent_get_tts_voice_id +_rac_voice_agent_initialize +_rac_voice_agent_initialize_with_loaded_models +_rac_voice_agent_is_llm_loaded +_rac_voice_agent_is_ready +_rac_voice_agent_is_stt_loaded +_rac_voice_agent_is_tts_loaded +_rac_voice_agent_load_llm_model +_rac_voice_agent_load_stt_model +_rac_voice_agent_load_tts_voice +_rac_voice_agent_process_stream +_rac_voice_agent_process_voice_turn +_rac_voice_agent_result_free +_rac_voice_agent_synthesize_speech +_rac_voice_agent_transcribe diff --git a/sdk/runanywhere-flutter/packages/runanywhere/ios/Classes/RunAnywherePlugin.swift b/sdk/runanywhere-flutter/packages/runanywhere/ios/Classes/RunAnywherePlugin.swift new file mode 100644 index 000000000..dace1c67d --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/ios/Classes/RunAnywherePlugin.swift @@ -0,0 +1,119 @@ +import Flutter +import UIKit + +// MARK: - C Symbol Declarations +// These declarations force the linker to include symbols from RACommons.xcframework +// that are only called via FFI/dlsym. Without these references, the linker would +// strip the symbols as "unused" since they're not called from native code. + +// LLM Component symbols +@_silgen_name("rac_llm_component_create") +private func _rac_llm_component_create() -> UnsafeMutableRawPointer? + +@_silgen_name("rac_llm_component_destroy") +private func _rac_llm_component_destroy(_: UnsafeMutableRawPointer?) + +@_silgen_name("rac_llm_component_configure") +private func _rac_llm_component_configure(_: UnsafeMutableRawPointer?, _: UnsafePointer?, _: UnsafePointer?, _: UInt32, _: UInt32, _: Float, _: Float) -> Int32 + +@_silgen_name("rac_llm_component_generate") +private func _rac_llm_component_generate(_: UnsafeMutableRawPointer?, _: UnsafePointer?, _: UnsafeMutablePointer?, _: Int) -> Int32 + +@_silgen_name("rac_llm_component_cleanup") +private func _rac_llm_component_cleanup(_: UnsafeMutableRawPointer?) -> Int32 + +@_silgen_name("rac_llm_component_cancel") +private func _rac_llm_component_cancel(_: UnsafeMutableRawPointer?) -> Int32 + +// STT Component symbols +@_silgen_name("rac_stt_component_create") +private func _rac_stt_component_create() -> UnsafeMutableRawPointer? + +@_silgen_name("rac_stt_component_destroy") +private func _rac_stt_component_destroy(_: UnsafeMutableRawPointer?) + +// TTS Component symbols +@_silgen_name("rac_tts_component_create") +private func _rac_tts_component_create() -> UnsafeMutableRawPointer? + +@_silgen_name("rac_tts_component_destroy") +private func _rac_tts_component_destroy(_: UnsafeMutableRawPointer?) + +// VAD Component symbols +@_silgen_name("rac_vad_component_create") +private func _rac_vad_component_create() -> UnsafeMutableRawPointer? + +@_silgen_name("rac_vad_component_destroy") +private func _rac_vad_component_destroy(_: UnsafeMutableRawPointer?) + +// Model Registry symbols +@_silgen_name("rac_model_registry_create") +private func _rac_model_registry_create() -> UnsafeMutableRawPointer? + +@_silgen_name("rac_model_registry_destroy") +private func _rac_model_registry_destroy(_: UnsafeMutableRawPointer?) + +// Download Manager symbols +@_silgen_name("rac_download_manager_create") +private func _rac_download_manager_create(_: UnsafePointer?) -> UnsafeMutableRawPointer? + +@_silgen_name("rac_download_manager_destroy") +private func _rac_download_manager_destroy(_: UnsafeMutableRawPointer?) + +// Init symbol +@_silgen_name("rac_init") +private func _rac_init() + +/// Force symbol linkage by referencing C symbols. +/// This function is never called but its existence forces the linker to include +/// all referenced symbols from the static framework. +@_optimize(none) +private func _forceSymbolLinkage() { + // These pointer references prevent the compiler from optimizing away the symbols + _ = unsafeBitCast(_rac_llm_component_create as Any, to: UnsafeRawPointer.self) + _ = unsafeBitCast(_rac_llm_component_destroy as Any, to: UnsafeRawPointer.self) + _ = unsafeBitCast(_rac_llm_component_configure as Any, to: UnsafeRawPointer.self) + _ = unsafeBitCast(_rac_llm_component_generate as Any, to: UnsafeRawPointer.self) + _ = unsafeBitCast(_rac_llm_component_cleanup as Any, to: UnsafeRawPointer.self) + _ = unsafeBitCast(_rac_llm_component_cancel as Any, to: UnsafeRawPointer.self) + _ = unsafeBitCast(_rac_stt_component_create as Any, to: UnsafeRawPointer.self) + _ = unsafeBitCast(_rac_stt_component_destroy as Any, to: UnsafeRawPointer.self) + _ = unsafeBitCast(_rac_tts_component_create as Any, to: UnsafeRawPointer.self) + _ = unsafeBitCast(_rac_tts_component_destroy as Any, to: UnsafeRawPointer.self) + _ = unsafeBitCast(_rac_vad_component_create as Any, to: UnsafeRawPointer.self) + _ = unsafeBitCast(_rac_vad_component_destroy as Any, to: UnsafeRawPointer.self) + _ = unsafeBitCast(_rac_model_registry_create as Any, to: UnsafeRawPointer.self) + _ = unsafeBitCast(_rac_model_registry_destroy as Any, to: UnsafeRawPointer.self) + _ = unsafeBitCast(_rac_download_manager_create as Any, to: UnsafeRawPointer.self) + _ = unsafeBitCast(_rac_download_manager_destroy as Any, to: UnsafeRawPointer.self) + _ = unsafeBitCast(_rac_init as Any, to: UnsafeRawPointer.self) +} + +/// RunAnywhere Flutter Plugin - iOS Implementation +/// +/// This plugin provides the native bridge for the RunAnywhere SDK on iOS. +/// The actual AI functionality is provided by RACommons.xcframework. +public class RunAnywherePlugin: NSObject, FlutterPlugin { + + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel( + name: "runanywhere", + binaryMessenger: registrar.messenger() + ) + let instance = RunAnywherePlugin() + registrar.addMethodCallDelegate(instance, channel: channel) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "getPlatformVersion": + result("iOS " + UIDevice.current.systemVersion) + case "getSDKVersion": + result("0.15.8") + case "getCommonsVersion": + result("0.1.4") + default: + result(FlutterMethodNotImplemented) + } + } +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/ios/runanywhere.podspec b/sdk/runanywhere-flutter/packages/runanywhere/ios/runanywhere.podspec new file mode 100644 index 000000000..9ee29eac7 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/ios/runanywhere.podspec @@ -0,0 +1,166 @@ +# +# RunAnywhere Core SDK - iOS +# +# This podspec integrates RACommons.xcframework into Flutter iOS apps. +# RACommons provides the core infrastructure for on-device AI capabilities. +# +# Binary Configuration: +# - Set RA_TEST_LOCAL=1 or create .testlocal file to use local binaries +# - Otherwise, binaries are downloaded from GitHub releases (production mode) +# +# Version: Must match Swift SDK's Package.swift and Kotlin SDK's build.gradle.kts +# + +# ============================================================================= +# Version Constants (MUST match Swift Package.swift) +# ============================================================================= +COMMONS_VERSION = "0.1.5" + +# ============================================================================= +# Binary Source - RACommons from runanywhere-sdks +# ============================================================================= +GITHUB_ORG = "RunanywhereAI" +COMMONS_REPO = "runanywhere-sdks" + +# ============================================================================= +# testLocal Toggle +# Set RA_TEST_LOCAL=1 or create .testlocal file to use local binaries +# ============================================================================= +TEST_LOCAL = ENV['RA_TEST_LOCAL'] == '1' || File.exist?(File.join(__dir__, '.testlocal')) + +Pod::Spec.new do |s| + s.name = 'runanywhere' + s.version = '0.15.11' + s.summary = 'RunAnywhere: Privacy-first, on-device AI SDK for Flutter' + s.description = <<-DESC +Privacy-first, on-device AI SDK for Flutter. This package provides the core +infrastructure (RACommons) for speech-to-text (STT), text-to-speech (TTS), +language models (LLM), voice activity detection (VAD), and embeddings. +Pre-built binaries are downloaded from: +https://github.com/RunanywhereAI/runanywhere-sdks + DESC + s.homepage = 'https://runanywhere.ai' + s.license = { :type => 'MIT' } + s.author = { 'RunAnywhere' => 'team@runanywhere.ai' } + s.source = { :path => '.' } + + s.ios.deployment_target = '14.0' + s.swift_version = '5.0' + + # Source files (minimal - main logic is in the xcframework) + s.source_files = 'Classes/**/*' + + # Flutter dependency + s.dependency 'Flutter' + + # ============================================================================= + # RACommons XCFramework - Core infrastructure + # Downloaded from runanywhere-sdks releases (NOT runanywhere-binaries) + # ============================================================================= + if TEST_LOCAL + puts "[runanywhere] Using LOCAL RACommons from Frameworks/" + s.vendored_frameworks = 'Frameworks/RACommons.xcframework' + else + s.prepare_command = <<-CMD + set -e + + FRAMEWORK_DIR="Frameworks" + VERSION="#{COMMONS_VERSION}" + VERSION_FILE="$FRAMEWORK_DIR/.racommons_version" + + # Check if already downloaded with correct version + if [ -f "$VERSION_FILE" ] && [ -d "$FRAMEWORK_DIR/RACommons.xcframework" ]; then + CURRENT_VERSION=$(cat "$VERSION_FILE") + if [ "$CURRENT_VERSION" = "$VERSION" ]; then + echo "✅ RACommons.xcframework version $VERSION already downloaded" + exit 0 + fi + fi + + echo "📦 Downloading RACommons.xcframework version $VERSION..." + + mkdir -p "$FRAMEWORK_DIR" + rm -rf "$FRAMEWORK_DIR/RACommons.xcframework" + + # Download from runanywhere-sdks + DOWNLOAD_URL="https://github.com/#{GITHUB_ORG}/#{COMMONS_REPO}/releases/download/commons-v$VERSION/RACommons-ios-v$VERSION.zip" + ZIP_FILE="/tmp/RACommons.zip" + + echo " URL: $DOWNLOAD_URL" + + curl -L -f -o "$ZIP_FILE" "$DOWNLOAD_URL" || { + echo "❌ Failed to download RACommons from $DOWNLOAD_URL" + exit 1 + } + + echo "📂 Extracting RACommons.xcframework..." + unzip -q -o "$ZIP_FILE" -d "$FRAMEWORK_DIR/" + rm -f "$ZIP_FILE" + + echo "$VERSION" > "$VERSION_FILE" + + if [ -d "$FRAMEWORK_DIR/RACommons.xcframework" ]; then + echo "✅ RACommons.xcframework installed successfully" + else + echo "❌ RACommons.xcframework extraction failed" + exit 1 + fi + CMD + + s.vendored_frameworks = 'Frameworks/RACommons.xcframework' + end + + s.preserve_paths = 'Frameworks/**/*' + + # Required frameworks + s.frameworks = [ + 'Foundation', + 'CoreML', + 'Accelerate', + 'AVFoundation', + 'AudioToolbox' + ] + + # Weak frameworks (optional hardware acceleration) + s.weak_frameworks = [ + 'Metal', + 'MetalKit', + 'MetalPerformanceShaders' + ] + + # Build settings + # Note: -all_load forces all symbols from static libraries to be loaded + # With static linkage (use_frameworks! :linkage => :static in Podfile), + # all symbols from RACommons.xcframework will be available in the final app + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386', + 'OTHER_LDFLAGS' => '-lc++ -larchive -lbz2', + 'CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES' => 'YES', + 'ENABLE_BITCODE' => 'NO', + } + + # CRITICAL: These flags propagate to the main app target to ensure all symbols + # from vendored static frameworks are linked AND EXPORTED in the final binary. + # + # -ObjC ensures Objective-C categories are loaded. + # -all_load forces ALL object files from static libraries to be linked. + # DEAD_CODE_STRIPPING=NO prevents unused symbol removal. + # + # SYMBOL EXPORT FIX (iOS): + # When using `use_frameworks! :linkage => :static`, symbols from static frameworks + # become LOCAL in the final dylib, making them invisible to dlsym() at runtime. + # Flutter FFI uses dlsym() to find symbols, so we MUST explicitly export them. + # + # -Wl,-export_dynamic exports ALL symbols from the dylib, making them accessible + # via dlsym(). This is broader than -exported_symbols_list but ensures Flutter's + # own symbols are not accidentally hidden. + s.user_target_xcconfig = { + 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386', + 'OTHER_LDFLAGS' => '-lc++ -larchive -lbz2 -ObjC -all_load -Wl,-export_dynamic', + 'DEAD_CODE_STRIPPING' => 'NO', + } + + # Mark static framework for proper linking + s.static_framework = true +end diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/capabilities/voice/models/voice_session.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/capabilities/voice/models/voice_session.dart new file mode 100644 index 000000000..c664ab6f9 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/capabilities/voice/models/voice_session.dart @@ -0,0 +1,241 @@ +/// Voice Session Models +/// +/// Matches iOS VoiceSession.swift from Capabilities/Voice/Models/ +/// and RunAnywhere+VoiceSession.swift from Public/Extensions/ +library voice_session; + +import 'dart:typed_data'; + +/// Output from Speech-to-Text transcription +/// Matches Swift STTOutput from Public/Extensions/STT/STTTypes.swift +class STTOutput { + /// Transcribed text + final String text; + + /// Confidence score (0.0 to 1.0) + final double confidence; + + /// Detected language if auto-detected + final String? detectedLanguage; + + /// Timestamp of the transcription + final DateTime timestamp; + + const STTOutput({ + required this.text, + required this.confidence, + this.detectedLanguage, + required this.timestamp, + }); +} + +/// Events emitted during a voice session +/// Matches iOS VoiceSessionEvent from RunAnywhere+VoiceSession.swift +sealed class VoiceSessionEvent { + const VoiceSessionEvent(); +} + +/// Session started and ready +class VoiceSessionStarted extends VoiceSessionEvent { + const VoiceSessionStarted(); +} + +/// Listening for speech with current audio level (0.0 - 1.0) +class VoiceSessionListening extends VoiceSessionEvent { + final double audioLevel; + const VoiceSessionListening({required this.audioLevel}); +} + +/// Speech detected, started accumulating audio +class VoiceSessionSpeechStarted extends VoiceSessionEvent { + const VoiceSessionSpeechStarted(); +} + +/// Speech ended, processing audio +class VoiceSessionProcessing extends VoiceSessionEvent { + const VoiceSessionProcessing(); +} + +/// Got transcription from STT +class VoiceSessionTranscribed extends VoiceSessionEvent { + final String text; + const VoiceSessionTranscribed({required this.text}); +} + +/// Got response from LLM +class VoiceSessionResponded extends VoiceSessionEvent { + final String text; + const VoiceSessionResponded({required this.text}); +} + +/// Playing TTS audio +class VoiceSessionSpeaking extends VoiceSessionEvent { + const VoiceSessionSpeaking(); +} + +/// Complete turn result +class VoiceSessionTurnCompleted extends VoiceSessionEvent { + final String transcript; + final String response; + final Uint8List? audio; + const VoiceSessionTurnCompleted({ + required this.transcript, + required this.response, + this.audio, + }); +} + +/// Session stopped +class VoiceSessionStopped extends VoiceSessionEvent { + const VoiceSessionStopped(); +} + +/// Error occurred +class VoiceSessionError extends VoiceSessionEvent { + final String message; + const VoiceSessionError({required this.message}); +} + +/// Configuration for voice session behavior +/// Matches iOS VoiceSessionConfig from RunAnywhere+VoiceSession.swift +class VoiceSessionConfig { + /// Silence duration (seconds) before processing speech + final double silenceDuration; + + /// Minimum audio level to detect speech (0.0 - 1.0) + /// Default is 0.03 which is sensitive enough for most microphones. + /// Increase to 0.1 or higher for noisy environments. + final double speechThreshold; + + /// Whether to auto-play TTS response + final bool autoPlayTTS; + + /// Whether to auto-resume listening after TTS playback + final bool continuousMode; + + const VoiceSessionConfig({ + this.silenceDuration = 1.5, + this.speechThreshold = 0.03, + this.autoPlayTTS = true, + this.continuousMode = true, + }); + + /// Default configuration + static const VoiceSessionConfig defaultConfig = VoiceSessionConfig(); + + /// Create a copy with modified values + VoiceSessionConfig copyWith({ + double? silenceDuration, + double? speechThreshold, + bool? autoPlayTTS, + bool? continuousMode, + }) { + return VoiceSessionConfig( + silenceDuration: silenceDuration ?? this.silenceDuration, + speechThreshold: speechThreshold ?? this.speechThreshold, + autoPlayTTS: autoPlayTTS ?? this.autoPlayTTS, + continuousMode: continuousMode ?? this.continuousMode, + ); + } +} + +/// Voice session errors +/// Matches iOS VoiceSessionError from RunAnywhere+VoiceSession.swift +class VoiceSessionException implements Exception { + final VoiceSessionErrorType type; + final String message; + + const VoiceSessionException(this.type, this.message); + + @override + String toString() => message; +} + +enum VoiceSessionErrorType { + microphonePermissionDenied, + notReady, + alreadyRunning, +} + +/// Voice session state (for internal tracking) +/// Matches iOS VoiceSessionState from VoiceSession.swift +enum VoiceSessionState { + idle('idle'), + listening('listening'), + processing('processing'), + speaking('speaking'), + ended('ended'), + error('error'); + + final String value; + const VoiceSessionState(this.value); + + static VoiceSessionState fromString(String value) { + return VoiceSessionState.values.firstWhere( + (e) => e.value == value, + orElse: () => VoiceSessionState.idle, + ); + } +} + +/// Voice session state tracking (for internal use) +class VoiceSession { + /// Unique session identifier + final String id; + + /// Session configuration + final VoiceSessionConfig configuration; + + /// Current session state + VoiceSessionState state; + + /// Transcripts collected during this session + final List transcripts; + + /// When the session started + DateTime? startTime; + + /// When the session ended + DateTime? endTime; + + VoiceSession({ + required this.id, + required this.configuration, + this.state = VoiceSessionState.idle, + List? transcripts, + this.startTime, + this.endTime, + }) : transcripts = transcripts ?? []; + + /// Calculate the session duration + Duration? get duration { + if (startTime == null) return null; + final end = endTime ?? DateTime.now(); + return end.difference(startTime!); + } + + /// Check if the session is active + bool get isActive => + state == VoiceSessionState.listening || + state == VoiceSessionState.processing || + state == VoiceSessionState.speaking; + + /// Create a copy with modified values + VoiceSession copyWith({ + String? id, + VoiceSessionConfig? configuration, + VoiceSessionState? state, + List? transcripts, + DateTime? startTime, + DateTime? endTime, + }) { + return VoiceSession( + id: id ?? this.id, + configuration: configuration ?? this.configuration, + state: state ?? this.state, + transcripts: transcripts ?? List.from(this.transcripts), + startTime: startTime ?? this.startTime, + endTime: endTime ?? this.endTime, + ); + } +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/capabilities/voice/models/voice_session_handle.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/capabilities/voice/models/voice_session_handle.dart new file mode 100644 index 000000000..fb919edeb --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/capabilities/voice/models/voice_session_handle.dart @@ -0,0 +1,461 @@ +/// Voice Session Handle +/// +/// Matches iOS VoiceSessionHandle from RunAnywhere+VoiceSession.swift +/// Provides a handle to control an active voice session with built-in audio capture +library voice_session_handle; + +import 'dart:async'; +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:runanywhere/capabilities/voice/models/voice_session.dart'; +import 'package:runanywhere/features/stt/services/audio_capture_manager.dart'; +import 'package:runanywhere/features/tts/services/audio_playback_manager.dart'; +import 'package:runanywhere/foundation/logging/sdk_logger.dart'; + +/// Handle to control an active voice session +/// Matches iOS VoiceSessionHandle from RunAnywhere+VoiceSession.swift +class VoiceSessionHandle { + final SDKLogger _logger = SDKLogger('VoiceSessionHandle'); + final VoiceSessionConfig config; + + bool _isRunning = false; + bool _isProcessing = false; + Uint8List _audioBuffer = Uint8List(0); + DateTime? _lastSpeechTime; + bool _isSpeechActive = false; + + final StreamController _eventController = + StreamController.broadcast(); + + // Audio capture manager for recording + final AudioCaptureManager _audioCapture = AudioCaptureManager(); + + // Audio playback manager for TTS output + final AudioPlaybackManager _audioPlayback = AudioPlaybackManager(); + + // Callback for processing audio (injected from RunAnywhere) + final Future Function(Uint8List audioData)? + _processAudioCallback; + + // Callback for voice agent readiness check + final Future Function()? _isVoiceAgentReadyCallback; + + // Callback for initializing voice agent with loaded models + final Future Function()? _initializeVoiceAgentCallback; + + VoiceSessionHandle({ + VoiceSessionConfig? config, + Future Function(Uint8List audioData)? + processAudioCallback, + @Deprecated('Permission is now handled internally by AudioCaptureManager') + Future Function()? requestPermissionCallback, + Future Function()? isVoiceAgentReadyCallback, + Future Function()? initializeVoiceAgentCallback, + }) : config = config ?? VoiceSessionConfig.defaultConfig, + _processAudioCallback = processAudioCallback, + _isVoiceAgentReadyCallback = isVoiceAgentReadyCallback, + _initializeVoiceAgentCallback = initializeVoiceAgentCallback; + + /// Stream of session events + /// Matches iOS VoiceSessionHandle.events + Stream get events => _eventController.stream; + + /// Whether the session is currently running + bool get isRunning => _isRunning; + + /// Whether the session is currently processing audio or playing TTS + bool get isProcessing => _isProcessing; + + /// Start the voice session + /// Matches iOS VoiceSessionHandle.start() + Future start() async { + if (_isRunning) { + _logger.warning('Voice session already running'); + return; + } + + _logger.info('🚀 Starting voice session...'); + + // Check if voice agent components are ready + _logger.info('Checking if voice agent components are ready...'); + final componentsReady = await _isVoiceAgentReadyCallback?.call() ?? false; + _logger.info('Voice agent components ready: $componentsReady'); + + if (!componentsReady) { + const errorMsg = + 'Voice agent components not ready. Make sure STT, LLM, and TTS models are loaded.'; + _logger.error('❌ $errorMsg'); + _emit(const VoiceSessionError(message: errorMsg)); + throw const VoiceSessionException( + VoiceSessionErrorType.notReady, + errorMsg, + ); + } + + // Always initialize voice agent with loaded models + // This creates the voice agent handle and connects it to the shared component handles + try { + _logger.info('Initializing voice agent with loaded models...'); + await _initializeVoiceAgentCallback?.call(); + _logger.info('✅ Voice agent initialized successfully'); + } catch (e) { + _logger.error('❌ Failed to initialize voice agent: $e'); + final errorMsg = 'Voice agent initialization failed: $e'; + _emit(VoiceSessionError(message: errorMsg)); + rethrow; + } + + // Request mic permission via audio capture manager + _logger.info('Requesting microphone permission...'); + final hasPermission = await _audioCapture.requestPermission(); + if (!hasPermission) { + _logger.error('❌ Microphone permission denied'); + _emit(const VoiceSessionError(message: 'Microphone permission denied')); + throw const VoiceSessionException( + VoiceSessionErrorType.microphonePermissionDenied, + 'Microphone permission denied', + ); + } + _logger.info('✅ Microphone permission granted'); + + _isRunning = true; + _emit(const VoiceSessionStarted()); + + // Start listening - this starts the audio capture loop + await _startListening(); + + _logger.info('✅ Voice session started with audio capture'); + } + + /// Start audio capture loop + /// Matches iOS VoiceSessionHandle.startListening() + Future _startListening() async { + if (_isProcessing) { + _logger.warning('⚠️ Cannot start listening while processing'); + return; + } + + _audioBuffer = Uint8List(0); + _lastSpeechTime = null; + _isSpeechActive = false; + + _logger.info('🎙️ Starting audio capture...'); + _logger.info( + '📋 Config: speechThreshold=${config.speechThreshold}, silenceDuration=${config.silenceDuration}s'); + + try { + int chunkCount = 0; + double maxLevelSeen = 0.0; + await _audioCapture.startRecording((Uint8List audioData) { + if (!_isRunning || _isProcessing) { + return; + } + chunkCount++; + + // Log first few chunks and then periodically + if (chunkCount <= 5 || chunkCount % 50 == 0) { + final audioLevel = _calculateAudioLevel(audioData); + if (audioLevel > maxLevelSeen) maxLevelSeen = audioLevel; + _logger.info( + '📊 Audio chunk #$chunkCount: ${audioData.length} bytes, level=${audioLevel.toStringAsFixed(4)}, max=${maxLevelSeen.toStringAsFixed(4)}, threshold=${config.speechThreshold}'); + } + _handleAudioChunk(audioData); + }); + _logger.info( + '✅ Audio capture started successfully - waiting for audio data...'); + } catch (e) { + _logger.error('❌ Failed to start audio capture: $e'); + _emit(VoiceSessionError(message: 'Failed to start audio capture: $e')); + _isRunning = false; + rethrow; + } + } + + /// Stop audio capture (used during processing/playback to prevent feedback) + void _stopListening() { + unawaited(_audioCapture.stopRecording()); + _audioBuffer = Uint8List(0); + _isSpeechActive = false; + _lastSpeechTime = null; + _logger.info('🔇 Audio capture stopped'); + } + + /// Handle incoming audio chunk from capture + void _handleAudioChunk(Uint8List data) { + if (!_isRunning || _isProcessing) return; + + // Calculate audio level from the audio data + final audioLevel = _calculateAudioLevel(data); + + // Append to buffer + final newBuffer = Uint8List(_audioBuffer.length + data.length); + newBuffer.setRange(0, _audioBuffer.length, _audioBuffer); + newBuffer.setRange(_audioBuffer.length, newBuffer.length, data); + _audioBuffer = newBuffer; + + // Check speech state with calculated audio level + _checkSpeechState(audioLevel); + } + + /// Calculate audio level (RMS) from audio data + /// Returns 0.0 to 1.0 + double _calculateAudioLevel(Uint8List data) { + if (data.isEmpty) return 0.0; + + // Audio is 16-bit PCM, so read as Int16 + final samples = data.length ~/ 2; + if (samples == 0) return 0.0; + + double sumSquares = 0.0; + for (int i = 0; i < samples; i++) { + // Read little-endian Int16 + final int low = data[i * 2]; + final int high = data[i * 2 + 1]; + int sample = (high << 8) | low; + // Handle sign extension for negative values + if (sample > 32767) sample -= 65536; + + final normalized = sample / 32768.0; + sumSquares += normalized * normalized; + } + + final rms = math.sqrt(sumSquares / samples); + // Scale to 0-1 range (RMS of full-scale sine is ~0.707) + return math.min(1.0, rms * 1.4); + } + + /// Stop the voice session + /// Matches iOS VoiceSessionHandle.stop() + void stop() { + if (!_isRunning) return; + + _isRunning = false; + _isProcessing = false; + + // Stop audio capture and playback + unawaited(_audioCapture.stopRecording()); + unawaited(_audioPlayback.stop()); + + _audioBuffer = Uint8List(0); + _isSpeechActive = false; + _lastSpeechTime = null; + + _emit(const VoiceSessionStopped()); + unawaited(_eventController.close()); + + _logger.info('Voice session stopped'); + } + + /// Force process current audio (push-to-talk) + /// Matches iOS VoiceSessionHandle.sendNow() + Future sendNow() async { + if (!_isRunning) return; + _isSpeechActive = false; + await _processCurrentAudio(); + } + + /// Feed audio data to the session (for external audio sources) + /// Can be used for custom audio capture or testing + void feedAudio(Uint8List data, double audioLevel) { + if (!_isRunning || _isProcessing) return; + + // Append to buffer + final newBuffer = Uint8List(_audioBuffer.length + data.length); + newBuffer.setRange(0, _audioBuffer.length, _audioBuffer); + newBuffer.setRange(_audioBuffer.length, newBuffer.length, data); + _audioBuffer = newBuffer; + + // Check speech state + _checkSpeechState(audioLevel); + } + + void _emit(VoiceSessionEvent event) { + if (!_eventController.isClosed) { + _eventController.add(event); + } + } + + void _checkSpeechState(double level) { + if (_isProcessing) return; + + _emit(VoiceSessionListening(audioLevel: level)); + + if (level >= config.speechThreshold) { + if (!_isSpeechActive) { + _logger.info( + '🎤 Speech STARTED! level=${level.toStringAsFixed(4)} >= threshold=${config.speechThreshold}'); + _isSpeechActive = true; + _emit(const VoiceSessionSpeechStarted()); + } + _lastSpeechTime = DateTime.now(); + } else if (_isSpeechActive) { + final lastTime = _lastSpeechTime; + if (lastTime != null) { + // Use milliseconds for accurate comparison with fractional seconds + final silenceMs = DateTime.now().difference(lastTime).inMilliseconds; + final thresholdMs = (config.silenceDuration * 1000).toInt(); + + if (silenceMs >= thresholdMs) { + _logger.info( + '🔇 Speech ENDED after ${silenceMs}ms silence, buffer: ${_audioBuffer.length} bytes'); + _isSpeechActive = false; + + // Only process if we have enough audio (~0.5s at 16kHz = 16000 bytes) + if (_audioBuffer.length > 16000) { + _logger.info( + '📤 Processing ${_audioBuffer.length} bytes of audio (~${(_audioBuffer.length / 32000).toStringAsFixed(1)}s)...'); + unawaited(_processCurrentAudio()); + } else { + _logger.warning( + '⚠️ Audio buffer too small (${_audioBuffer.length} bytes < 16000), discarding'); + _audioBuffer = Uint8List(0); + } + } + } + } + } + + Future _processCurrentAudio() async { + final audio = _audioBuffer; + _audioBuffer = Uint8List(0); + + if (audio.isEmpty) { + _logger.warning('⚠️ Cannot process: audio buffer is empty'); + return; + } + + if (!_isRunning) { + _logger.warning('⚠️ Cannot process: session not running'); + return; + } + + // IMPORTANT: Stop listening during processing to prevent feedback loop + _isProcessing = true; + _stopListening(); + + final audioDuration = audio.length / 32000; // 16kHz * 2 bytes per sample + _logger.info( + '🔄 Processing ${audio.length} bytes (~${audioDuration.toStringAsFixed(1)}s) of audio...'); + _emit(const VoiceSessionProcessing()); + + try { + if (_processAudioCallback == null) { + _logger.error( + '❌ CRITICAL: No processing callback configured! This is a bug - the callback should be set when VoiceSessionHandle is created.'); + _emit(const VoiceSessionError( + message: + 'No processing callback configured. Voice agent may not be initialized.')); + return; + } + + _logger.info('📞 Calling voice agent processAudio...'); + final stopwatch = Stopwatch()..start(); + final result = await _processAudioCallback!.call(audio); + stopwatch.stop(); + _logger.info( + '⏱️ Voice agent processing took ${stopwatch.elapsedMilliseconds}ms'); + + if (!result.speechDetected) { + _logger + .info('🔇 No speech detected in audio (might be silence or noise)'); + // Resume listening + if (config.continuousMode && _isRunning) { + _logger.info('👂 Continuous mode: Resuming listening'); + _isProcessing = false; + await _startListening(); + } + return; + } + + _logger.info( + '✅ Speech detected! Transcription: "${result.transcription ?? "(empty)"}"'); + + // Emit intermediate results + if (result.transcription != null && result.transcription!.isNotEmpty) { + _emit(VoiceSessionTranscribed(text: result.transcription!)); + } else { + _logger.warning('⚠️ STT returned empty transcription'); + } + + if (result.response != null && result.response!.isNotEmpty) { + final previewLen = + result.response!.length > 100 ? 100 : result.response!.length; + _logger.info( + '💬 LLM Response (${result.response!.length} chars): "${result.response!.substring(0, previewLen)}${result.response!.length > 100 ? "..." : ""}"'); + _emit(VoiceSessionResponded(text: result.response!)); + } else { + _logger.warning('⚠️ LLM returned empty response'); + } + + // Play TTS audio if available and enabled + if (config.autoPlayTTS && + result.synthesizedAudio != null && + result.synthesizedAudio!.isNotEmpty) { + // TTS audio from ONNX Piper is typically 22050Hz mono PCM16 + final ttsDuration = result.synthesizedAudio!.length / (22050 * 2); + _logger.info( + '🔊 Playing TTS audio: ${result.synthesizedAudio!.length} bytes (~${ttsDuration.toStringAsFixed(1)}s)'); + _emit(const VoiceSessionSpeaking()); + + try { + // Play audio and wait for completion + await _audioPlayback.play( + result.synthesizedAudio!, + sampleRate: 22050, // ONNX Piper TTS default + numChannels: 1, + ); + _logger.info('🔊 TTS playback completed'); + } catch (e) { + _logger.error('❌ TTS playback failed: $e'); + // Continue even if playback fails + } + } + + // Emit complete result + _emit(VoiceSessionTurnCompleted( + transcript: result.transcription ?? '', + response: result.response ?? '', + audio: result.synthesizedAudio, + )); + _logger.info('✅ Voice turn completed successfully'); + } catch (e, stack) { + _logger.error('❌ Processing failed: $e'); + _logger.error('Stack trace: $stack'); + _emit(VoiceSessionError(message: e.toString())); + } finally { + // Resume listening if continuous mode and session still running + _isProcessing = false; + if (config.continuousMode && _isRunning) { + _logger.info('👂 Continuous mode: Resuming listening after turn'); + try { + await _startListening(); + } catch (e) { + _logger.error('❌ Failed to resume listening: $e'); + } + } + } + } + + /// Dispose resources + Future dispose() async { + stop(); + await _audioPlayback.dispose(); + _audioCapture.dispose(); + } +} + +/// Result from voice agent processing +class VoiceAgentProcessResult { + final bool speechDetected; + final String? transcription; + final String? response; + final Uint8List? synthesizedAudio; + + const VoiceAgentProcessResult({ + required this.speechDetected, + this.transcription, + this.response, + this.synthesizedAudio, + }); +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/core/models/audio_format.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/core/models/audio_format.dart new file mode 100644 index 000000000..849c4d38c --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/core/models/audio_format.dart @@ -0,0 +1,56 @@ +/// Audio format information +/// Matches iOS AudioFormat enum from SharedComponentTypes.swift +enum AudioFormat { + wav, + mp3, + m4a, + flac, + pcm, + opus; + + /// Get the default sample rate for this audio format + int get sampleRate { + switch (this) { + case AudioFormat.wav: + case AudioFormat.pcm: + case AudioFormat.flac: + return 16000; + case AudioFormat.mp3: + case AudioFormat.m4a: + return 44100; + case AudioFormat.opus: + return 48000; + } + } + + /// Get the string value representation + String get value { + switch (this) { + case AudioFormat.wav: + return 'wav'; + case AudioFormat.mp3: + return 'mp3'; + case AudioFormat.m4a: + return 'm4a'; + case AudioFormat.flac: + return 'flac'; + case AudioFormat.pcm: + return 'pcm'; + case AudioFormat.opus: + return 'opus'; + } + } +} + +/// Audio metadata +class AudioMetadata { + final int channelCount; + final int? bitDepth; + final String? codec; + + AudioMetadata({ + this.channelCount = 1, + this.bitDepth, + this.codec, + }); +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/core/module/runanywhere_module.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/core/module/runanywhere_module.dart new file mode 100644 index 000000000..879214221 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/core/module/runanywhere_module.dart @@ -0,0 +1,62 @@ +/// RunAnywhere Module Protocol +/// +/// Protocol for SDK modules that provide AI capabilities. +/// Matches Swift RunAnywhereModule from Sources/RunAnywhere/Core/Module/RunAnywhereModule.swift +/// +/// Note: Registration is now handled by the C++ platform backend via FFI. +/// Modules only need to provide metadata and call the C++ registration function. +library runanywhere_module; + +import 'package:runanywhere/core/types/model_types.dart'; +import 'package:runanywhere/core/types/sdk_component.dart'; + +/// Protocol for SDK modules that provide AI capabilities. +/// +/// Modules encapsulate backend-specific functionality for the SDK. +/// Each module typically provides one or more capabilities (LLM, STT, TTS, VAD). +/// +/// Registration with the C++ service registry is handled via FFI by calling +/// `rac_backend_*_register()` functions during module initialization. +/// +/// ## Implementing a Module (matches Swift pattern) +/// +/// ```dart +/// class LlamaCpp implements RunAnywhereModule { +/// @override +/// String get moduleId => 'llamacpp'; +/// +/// @override +/// String get moduleName => 'LlamaCpp'; +/// +/// @override +/// Set get capabilities => {SDKComponent.llm}; +/// +/// @override +/// int get defaultPriority => 100; +/// +/// @override +/// InferenceFramework get inferenceFramework => InferenceFramework.llamaCpp; +/// +/// static Future register({int priority = 100}) async { +/// // Call C++ registration via FFI +/// final result = _lib.lookupFunction<...>('rac_backend_llamacpp_register')(); +/// // ... +/// } +/// } +/// ``` +abstract class RunAnywhereModule { + /// Unique identifier for this module (e.g., "llamacpp", "onnx") + String get moduleId; + + /// Human-readable name for the module + String get moduleName; + + /// Set of capabilities this module provides + Set get capabilities; + + /// Default priority for service registration (higher = preferred) + int get defaultPriority; + + /// The inference framework this module uses + InferenceFramework get inferenceFramework; +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/core/protocols/component/component.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/core/protocols/component/component.dart new file mode 100644 index 000000000..f41d6a453 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/core/protocols/component/component.dart @@ -0,0 +1,32 @@ +import 'dart:async'; + +import 'package:runanywhere/core/protocols/component/component_configuration.dart'; +import 'package:runanywhere/core/types/component_state.dart'; +import 'package:runanywhere/core/types/sdk_component.dart'; + +/// Base protocol that all SDK components must implement +abstract class Component { + /// Unique identifier for this component type + static SDKComponent get componentType { + throw UnimplementedError('componentType must be overridden'); + } + + /// Current state of the component + ComponentState get state; + + /// Configuration parameters for this component. + /// Returns null if component has no parameters. + ComponentInitParameters? get parameters; + + /// Initialize the component + Future initialize(); + + /// Clean up and release resources + Future cleanup(); + + /// Check if component is ready for use + bool get isReady; + + /// Handle state transitions + Future transitionTo(ComponentState newState); +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/core/protocols/component/component_configuration.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/core/protocols/component/component_configuration.dart new file mode 100644 index 000000000..56636ad3c --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/core/protocols/component/component_configuration.dart @@ -0,0 +1,29 @@ +/// Base protocol for component configurations +abstract class ComponentConfiguration { + /// Validate the configuration + void validate(); +} + +/// Base protocol for component inputs +abstract class ComponentInput { + /// Validate the input + void validate(); +} + +/// Base protocol for component outputs +abstract class ComponentOutput { + /// Timestamp of when the output was generated + DateTime get timestamp; +} + +/// Base protocol for component initialization parameters +abstract class ComponentInitParameters { + /// Component type + String get componentType; + + /// Model identifier (optional) + String? get modelId; + + /// Validate the parameters + void validate(); +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/core/types/component_state.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/core/types/component_state.dart new file mode 100644 index 000000000..1da295c13 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/core/types/component_state.dart @@ -0,0 +1,35 @@ +/// Component state enumeration +enum ComponentState { + /// Component has not been initialized + notInitialized, + + /// Component is checking prerequisites + checking, + + /// Component is initializing + initializing, + + /// Component is ready for use + ready, + + /// Component initialization failed + failed, +} + +extension ComponentStateExtension on ComponentState { + /// Get string representation + String get value { + switch (this) { + case ComponentState.notInitialized: + return 'not_initialized'; + case ComponentState.checking: + return 'checking'; + case ComponentState.initializing: + return 'initializing'; + case ComponentState.ready: + return 'ready'; + case ComponentState.failed: + return 'failed'; + } + } +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/core/types/model_types.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/core/types/model_types.dart new file mode 100644 index 000000000..ef42e19a1 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/core/types/model_types.dart @@ -0,0 +1,691 @@ +/// Model Types +/// +/// Public types for model management. +/// Matches Swift ModelTypes.swift from Public/Extensions/Models/ +/// These are thin wrappers over C++ types in rac_model_types.h +library model_types; + +import 'dart:io'; + +// MARK: - Model Source + +/// Source of model data (where the model info came from) +enum ModelSource { + /// Model info came from remote API (backend model catalog) + remote('remote'), + + /// Model info was provided locally via SDK input (addModel calls) + local('local'); + + final String rawValue; + const ModelSource(this.rawValue); + + static ModelSource fromRawValue(String value) { + return ModelSource.values.firstWhere( + (s) => s.rawValue == value, + orElse: () => ModelSource.remote, + ); + } +} + +// MARK: - Model Format + +/// Model formats supported +enum ModelFormat { + onnx('onnx'), + ort('ort'), + gguf('gguf'), + bin('bin'), + unknown('unknown'); + + final String rawValue; + const ModelFormat(this.rawValue); + + static ModelFormat fromRawValue(String value) { + return ModelFormat.values.firstWhere( + (f) => f.rawValue == value.toLowerCase(), + orElse: () => ModelFormat.unknown, + ); + } +} + +// MARK: - Model Category + +/// Defines the category/type of a model based on its input/output modality +enum ModelCategory { + language('language', 'Language Model'), + speechRecognition('speech-recognition', 'Speech Recognition'), + speechSynthesis('speech-synthesis', 'Text-to-Speech'), + vision('vision', 'Vision Model'), + imageGeneration('image-generation', 'Image Generation'), + multimodal('multimodal', 'Multimodal'), + audio('audio', 'Audio Processing'); + + final String rawValue; + final String displayName; + + const ModelCategory(this.rawValue, this.displayName); + + /// Create from raw string value + static ModelCategory? fromRawValue(String value) { + return ModelCategory.values.cast().firstWhere( + (c) => c?.rawValue == value, + orElse: () => null, + ); + } + + /// Whether this category typically requires context length + /// Note: C++ equivalent is rac_model_category_requires_context_length() + bool get requiresContextLength { + switch (this) { + case ModelCategory.language: + case ModelCategory.multimodal: + return true; + default: + return false; + } + } + + /// Whether this category typically supports thinking/reasoning + /// Note: C++ equivalent is rac_model_category_supports_thinking() + bool get supportsThinking { + switch (this) { + case ModelCategory.language: + case ModelCategory.multimodal: + return true; + default: + return false; + } + } +} + +// MARK: - Inference Framework + +/// Supported inference frameworks/runtimes for executing models +enum InferenceFramework { + // Model-based frameworks + onnx('ONNX', 'ONNX Runtime', 'onnx'), + llamaCpp('LlamaCpp', 'llama.cpp', 'llama_cpp'), + foundationModels( + 'FoundationModels', 'Foundation Models', 'foundation_models'), + systemTTS('SystemTTS', 'System TTS', 'system_tts'), + fluidAudio('FluidAudio', 'FluidAudio', 'fluid_audio'), + + // Special cases + builtIn('BuiltIn', 'Built-in', 'built_in'), + none('None', 'None', 'none'), + unknown('Unknown', 'Unknown', 'unknown'); + + final String rawValue; + final String displayName; + final String analyticsKey; + + const InferenceFramework(this.rawValue, this.displayName, this.analyticsKey); + + static InferenceFramework fromRawValue(String value) { + final lowercased = value.toLowerCase(); + return InferenceFramework.values.firstWhere( + (f) => + f.rawValue.toLowerCase() == lowercased || + f.analyticsKey == lowercased, + orElse: () => InferenceFramework.unknown, + ); + } +} + +// MARK: - Archive Types + +/// Supported archive formats for model packaging +enum ArchiveType { + zip('zip'), + tarBz2('tar.bz2'), + tarGz('tar.gz'), + tarXz('tar.xz'); + + final String rawValue; + const ArchiveType(this.rawValue); + + /// File extension for this archive type + String get fileExtension => rawValue; + + /// Detect archive type from URL path + static ArchiveType? fromPath(String path) { + final lowered = path.toLowerCase(); + if (lowered.endsWith('.tar.bz2') || lowered.endsWith('.tbz2')) { + return ArchiveType.tarBz2; + } else if (lowered.endsWith('.tar.gz') || lowered.endsWith('.tgz')) { + return ArchiveType.tarGz; + } else if (lowered.endsWith('.tar.xz') || lowered.endsWith('.txz')) { + return ArchiveType.tarXz; + } else if (lowered.endsWith('.zip')) { + return ArchiveType.zip; + } + return null; + } +} + +/// Describes the internal structure of an archive after extraction +enum ArchiveStructure { + singleFileNested('singleFileNested'), + directoryBased('directoryBased'), + nestedDirectory('nestedDirectory'), + unknown('unknown'); + + final String rawValue; + const ArchiveStructure(this.rawValue); +} + +// MARK: - Expected Model Files + +/// Describes what files are expected after model extraction/download +class ExpectedModelFiles { + final List requiredPatterns; + final List optionalPatterns; + final String? description; + + const ExpectedModelFiles({ + this.requiredPatterns = const [], + this.optionalPatterns = const [], + this.description, + }); + + static const ExpectedModelFiles none = ExpectedModelFiles(); + + Map toJson() => { + 'requiredPatterns': requiredPatterns, + 'optionalPatterns': optionalPatterns, + if (description != null) 'description': description, + }; + + factory ExpectedModelFiles.fromJson(Map json) { + return ExpectedModelFiles( + requiredPatterns: + (json['requiredPatterns'] as List?)?.cast() ?? [], + optionalPatterns: + (json['optionalPatterns'] as List?)?.cast() ?? [], + description: json['description'] as String?, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ExpectedModelFiles && + requiredPatterns.length == other.requiredPatterns.length && + optionalPatterns.length == other.optionalPatterns.length; + + @override + int get hashCode => + Object.hash(requiredPatterns.length, optionalPatterns.length); +} + +/// Describes a file that needs to be downloaded as part of a multi-file model +class ModelFileDescriptor { + final String relativePath; + final String destinationPath; + final bool isRequired; + + const ModelFileDescriptor({ + required this.relativePath, + required this.destinationPath, + this.isRequired = true, + }); + + Map toJson() => { + 'relativePath': relativePath, + 'destinationPath': destinationPath, + 'isRequired': isRequired, + }; + + factory ModelFileDescriptor.fromJson(Map json) { + return ModelFileDescriptor( + relativePath: json['relativePath'] as String, + destinationPath: json['destinationPath'] as String, + isRequired: json['isRequired'] as bool? ?? true, + ); + } +} + +// MARK: - Model Artifact Type + +/// Describes how a model is packaged and what processing is needed after download +sealed class ModelArtifactType { + const ModelArtifactType(); + + bool get requiresExtraction => false; + bool get requiresDownload => true; + ExpectedModelFiles get expectedFiles => ExpectedModelFiles.none; + String get displayName; + + Map toJson(); + + // ============================================================================ + // Convenience Constructors (matches Swift pattern) + // ============================================================================ + + /// Create a tar.gz archive artifact + static ArchiveArtifact tarGzArchive({ + ArchiveStructure structure = ArchiveStructure.unknown, + ExpectedModelFiles expectedFiles = ExpectedModelFiles.none, + }) { + return ArchiveArtifact( + archiveType: ArchiveType.tarGz, + structure: structure, + expectedFiles: expectedFiles, + ); + } + + /// Create a tar.bz2 archive artifact + static ArchiveArtifact tarBz2Archive({ + ArchiveStructure structure = ArchiveStructure.unknown, + ExpectedModelFiles expectedFiles = ExpectedModelFiles.none, + }) { + return ArchiveArtifact( + archiveType: ArchiveType.tarBz2, + structure: structure, + expectedFiles: expectedFiles, + ); + } + + /// Create a zip archive artifact + static ArchiveArtifact zipArchive({ + ArchiveStructure structure = ArchiveStructure.unknown, + ExpectedModelFiles expectedFiles = ExpectedModelFiles.none, + }) { + return ArchiveArtifact( + archiveType: ArchiveType.zip, + structure: structure, + expectedFiles: expectedFiles, + ); + } + + /// Create a single file artifact + static SingleFileArtifact singleFile({ + ExpectedModelFiles expectedFiles = ExpectedModelFiles.none, + }) { + return SingleFileArtifact(expectedFiles: expectedFiles); + } + + /// Create a built-in artifact (no download needed) + static const BuiltInArtifact builtIn = BuiltInArtifact(); + + factory ModelArtifactType.fromJson(Map json) { + final type = json['type'] as String?; + switch (type) { + case 'singleFile': + final expected = json['expectedFiles'] != null + ? ExpectedModelFiles.fromJson( + json['expectedFiles'] as Map) + : ExpectedModelFiles.none; + return SingleFileArtifact(expectedFiles: expected); + case 'archive': + return ArchiveArtifact( + archiveType: ArchiveType.values.firstWhere( + (t) => t.rawValue == json['archiveType'], + orElse: () => ArchiveType.zip, + ), + structure: ArchiveStructure.values.firstWhere( + (s) => s.rawValue == json['structure'], + orElse: () => ArchiveStructure.unknown, + ), + expectedFiles: json['expectedFiles'] != null + ? ExpectedModelFiles.fromJson( + json['expectedFiles'] as Map) + : ExpectedModelFiles.none, + ); + case 'multiFile': + return MultiFileArtifact( + files: (json['files'] as List) + .map((f) => + ModelFileDescriptor.fromJson(f as Map)) + .toList(), + ); + case 'custom': + return CustomArtifact(strategyId: json['strategyId'] as String); + case 'builtIn': + return const BuiltInArtifact(); + default: + return const SingleFileArtifact(); + } + } + + /// Infer artifact type from download URL + static ModelArtifactType infer(Uri? url, ModelFormat format) { + if (url == null) return const SingleFileArtifact(); + final archiveType = ArchiveType.fromPath(url.path); + if (archiveType != null) { + return ArchiveArtifact( + archiveType: archiveType, + structure: ArchiveStructure.unknown, + ); + } + return const SingleFileArtifact(); + } +} + +class SingleFileArtifact extends ModelArtifactType { + @override + final ExpectedModelFiles expectedFiles; + + const SingleFileArtifact({this.expectedFiles = ExpectedModelFiles.none}); + + @override + String get displayName => 'Single File'; + + @override + Map toJson() => { + 'type': 'singleFile', + if (expectedFiles != ExpectedModelFiles.none) + 'expectedFiles': expectedFiles.toJson(), + }; +} + +class ArchiveArtifact extends ModelArtifactType { + final ArchiveType archiveType; + final ArchiveStructure structure; + @override + final ExpectedModelFiles expectedFiles; + + const ArchiveArtifact({ + required this.archiveType, + required this.structure, + this.expectedFiles = ExpectedModelFiles.none, + }); + + @override + bool get requiresExtraction => true; + + @override + String get displayName => '${archiveType.rawValue.toUpperCase()} Archive'; + + @override + Map toJson() => { + 'type': 'archive', + 'archiveType': archiveType.rawValue, + 'structure': structure.rawValue, + if (expectedFiles != ExpectedModelFiles.none) + 'expectedFiles': expectedFiles.toJson(), + }; +} + +class MultiFileArtifact extends ModelArtifactType { + final List files; + + const MultiFileArtifact({required this.files}); + + @override + String get displayName => 'Multi-File (${files.length} files)'; + + @override + Map toJson() => { + 'type': 'multiFile', + 'files': files.map((f) => f.toJson()).toList(), + }; +} + +class CustomArtifact extends ModelArtifactType { + final String strategyId; + + const CustomArtifact({required this.strategyId}); + + @override + String get displayName => 'Custom ($strategyId)'; + + @override + Map toJson() => { + 'type': 'custom', + 'strategyId': strategyId, + }; +} + +class BuiltInArtifact extends ModelArtifactType { + const BuiltInArtifact(); + + @override + bool get requiresDownload => false; + + @override + String get displayName => 'Built-in'; + + @override + Map toJson() => {'type': 'builtIn'}; +} + +// MARK: - Thinking Tag Pattern + +/// Pattern for extracting thinking tags from model output +class ThinkingTagPattern { + final String openTag; + final String closeTag; + + const ThinkingTagPattern({ + required this.openTag, + required this.closeTag, + }); + + static const ThinkingTagPattern defaultPattern = ThinkingTagPattern( + openTag: '', + closeTag: '', + ); + + Map toJson() => { + 'openTag': openTag, + 'closeTag': closeTag, + }; + + factory ThinkingTagPattern.fromJson(Map json) { + return ThinkingTagPattern( + openTag: json['openTag'] as String? ?? '', + closeTag: json['closeTag'] as String? ?? '', + ); + } +} + +// MARK: - Model Info + +/// Information about a model - in-memory entity +/// Matches Swift ModelInfo from Public/Extensions/Models/ModelTypes.swift +class ModelInfo { + // Essential identifiers + final String id; + final String name; + final ModelCategory category; + + // Format and location + final ModelFormat format; + final Uri? downloadURL; + Uri? localPath; + + // Artifact type + final ModelArtifactType artifactType; + + // Size information + final int? downloadSize; + + // Framework + final InferenceFramework framework; + + // Model-specific capabilities + final int? contextLength; + final bool supportsThinking; + final ThinkingTagPattern? thinkingPattern; + + // Optional metadata + final String? description; + + // Tracking fields + final ModelSource source; + final DateTime createdAt; + DateTime updatedAt; + + ModelInfo({ + required this.id, + required this.name, + required this.category, + required this.format, + required this.framework, + this.downloadURL, + this.localPath, + ModelArtifactType? artifactType, + this.downloadSize, + int? contextLength, + bool supportsThinking = false, + ThinkingTagPattern? thinkingPattern, + this.description, + ModelSource? source, + DateTime? createdAt, + DateTime? updatedAt, + }) : artifactType = + artifactType ?? ModelArtifactType.infer(downloadURL, format), + contextLength = category.requiresContextLength + ? (contextLength ?? 2048) + : contextLength, + supportsThinking = category.supportsThinking ? supportsThinking : false, + thinkingPattern = (category.supportsThinking && supportsThinking) + ? (thinkingPattern ?? ThinkingTagPattern.defaultPattern) + : null, + source = source ?? ModelSource.remote, + createdAt = createdAt ?? DateTime.now(), + updatedAt = updatedAt ?? DateTime.now(); + + /// Whether this model is downloaded and available locally + bool get isDownloaded { + final path = localPath; + if (path == null) return false; + + // Built-in models are always available + if (path.scheme == 'builtin') return true; + + // Check if file or directory exists + final localFile = File(path.toFilePath()); + final localDir = Directory(path.toFilePath()); + + if (localFile.existsSync()) return true; + + if (localDir.existsSync()) { + final contents = localDir.listSync(); + return contents.isNotEmpty; + } + + return false; + } + + /// Whether this model is available for use + bool get isAvailable => isDownloaded; + + /// Whether this is a built-in platform model + bool get isBuiltIn { + if (artifactType is BuiltInArtifact) return true; + if (localPath?.scheme == 'builtin') return true; + return framework == InferenceFramework.foundationModels || + framework == InferenceFramework.systemTTS; + } + + /// JSON serialization + Map toJson() => { + 'id': id, + 'name': name, + 'category': category.rawValue, + 'format': format.rawValue, + if (downloadURL != null) 'downloadURL': downloadURL.toString(), + if (localPath != null) 'localPath': localPath.toString(), + 'artifactType': artifactType.toJson(), + if (downloadSize != null) 'downloadSize': downloadSize, + 'framework': framework.rawValue, + if (contextLength != null) 'contextLength': contextLength, + 'supportsThinking': supportsThinking, + if (thinkingPattern != null) + 'thinkingPattern': thinkingPattern!.toJson(), + if (description != null) 'description': description, + 'source': source.rawValue, + 'createdAt': createdAt.toIso8601String(), + 'updatedAt': updatedAt.toIso8601String(), + }; + + factory ModelInfo.fromJson(Map json) { + return ModelInfo( + id: json['id'] as String, + name: json['name'] as String, + category: ModelCategory.fromRawValue(json['category'] as String) ?? + ModelCategory.language, + format: ModelFormat.fromRawValue(json['format'] as String? ?? 'unknown'), + framework: InferenceFramework.fromRawValue( + json['framework'] as String? ?? 'unknown'), + downloadURL: json['downloadURL'] != null + ? Uri.parse(json['downloadURL'] as String) + : null, + localPath: json['localPath'] != null + ? Uri.parse(json['localPath'] as String) + : null, + artifactType: json['artifactType'] != null + ? ModelArtifactType.fromJson( + json['artifactType'] as Map) + : null, + downloadSize: json['downloadSize'] as int?, + contextLength: json['contextLength'] as int?, + supportsThinking: json['supportsThinking'] as bool? ?? false, + thinkingPattern: json['thinkingPattern'] != null + ? ThinkingTagPattern.fromJson( + json['thinkingPattern'] as Map) + : null, + description: json['description'] as String?, + source: ModelSource.fromRawValue(json['source'] as String? ?? 'remote'), + createdAt: json['createdAt'] != null + ? DateTime.parse(json['createdAt'] as String) + : null, + updatedAt: json['updatedAt'] != null + ? DateTime.parse(json['updatedAt'] as String) + : null, + ); + } + + /// Copy with modifications + ModelInfo copyWith({ + String? id, + String? name, + ModelCategory? category, + ModelFormat? format, + InferenceFramework? framework, + Uri? downloadURL, + Uri? localPath, + ModelArtifactType? artifactType, + int? downloadSize, + int? contextLength, + bool? supportsThinking, + ThinkingTagPattern? thinkingPattern, + String? description, + ModelSource? source, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return ModelInfo( + id: id ?? this.id, + name: name ?? this.name, + category: category ?? this.category, + format: format ?? this.format, + framework: framework ?? this.framework, + downloadURL: downloadURL ?? this.downloadURL, + localPath: localPath ?? this.localPath, + artifactType: artifactType ?? this.artifactType, + downloadSize: downloadSize ?? this.downloadSize, + contextLength: contextLength ?? this.contextLength, + supportsThinking: supportsThinking ?? this.supportsThinking, + thinkingPattern: thinkingPattern ?? this.thinkingPattern, + description: description ?? this.description, + source: source ?? this.source, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ModelInfo && runtimeType == other.runtimeType && id == other.id; + + @override + int get hashCode => id.hashCode; + + @override + String toString() => 'ModelInfo(id: $id, name: $name, category: $category)'; +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/core/types/sdk_component.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/core/types/sdk_component.dart new file mode 100644 index 000000000..4c4f581ad --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/core/types/sdk_component.dart @@ -0,0 +1,67 @@ +/// Component Types +/// +/// Core type definitions for component models. +/// Matches Swift ComponentTypes.swift from Core/Types/ +library component_types; + +import 'package:runanywhere/core/types/model_types.dart'; + +// MARK: - Component Protocols + +/// Protocol for component configuration and initialization +/// +/// All component configurations (LLM, STT, TTS, VAD, etc.) implement this. +/// Provides common properties needed for model selection and framework preference. +abstract class ComponentConfiguration { + /// Model identifier (optional - uses default if not specified) + String? get modelId; + + /// Preferred inference framework for this component (optional) + InferenceFramework? get preferredFramework => null; + + /// Validates the configuration + void validate(); +} + +/// Protocol for component output data +abstract class ComponentOutput { + DateTime get timestamp; +} + +// MARK: - SDK Component Enum + +/// SDK component types for identification. +/// +/// This enum consolidates what was previously `CapabilityType` and provides +/// a unified type for all AI capabilities in the SDK. +/// +/// ## Usage +/// +/// ```dart +/// // Check what capabilities a module provides +/// final capabilities = MyModule.capabilities; +/// if (capabilities.contains(SDKComponent.llm)) { +/// // Module provides LLM services +/// } +/// ``` +enum SDKComponent { + llm('LLM', 'Language Model', 'llm'), + stt('STT', 'Speech to Text', 'stt'), + tts('TTS', 'Text to Speech', 'tts'), + vad('VAD', 'Voice Activity Detection', 'vad'), + voice('VOICE', 'Voice Agent', 'voice'), + embedding('EMBEDDING', 'Embedding', 'embedding'); + + final String rawValue; + final String displayName; + final String analyticsKey; + + const SDKComponent(this.rawValue, this.displayName, this.analyticsKey); + + static SDKComponent? fromRawValue(String value) { + return SDKComponent.values.cast().firstWhere( + (c) => c?.rawValue == value || c?.analyticsKey == value.toLowerCase(), + orElse: () => null, + ); + } +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/core/types/storage_types.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/core/types/storage_types.dart new file mode 100644 index 000000000..6cff2f2bb --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/core/types/storage_types.dart @@ -0,0 +1,285 @@ +/// Storage Types +/// +/// Consolidated storage-related types for public API. +/// Matches Swift StorageTypes.swift from Public/Extensions/Storage/ +/// Includes: storage info, configuration, availability, and model storage metrics. +library storage_types; + +import 'package:runanywhere/core/types/model_types.dart'; + +// MARK: - Device Storage + +/// Device storage information +class DeviceStorageInfo { + /// Total device storage space in bytes + final int totalSpace; + + /// Free space available in bytes + final int freeSpace; + + /// Used space in bytes + final int usedSpace; + + const DeviceStorageInfo({ + required this.totalSpace, + required this.freeSpace, + required this.usedSpace, + }); + + /// Percentage of storage used (0-100) + double get usagePercentage { + if (totalSpace == 0) return 0; + return (usedSpace / totalSpace) * 100; + } + + Map toJson() => { + 'totalSpace': totalSpace, + 'freeSpace': freeSpace, + 'usedSpace': usedSpace, + }; + + factory DeviceStorageInfo.fromJson(Map json) { + return DeviceStorageInfo( + totalSpace: (json['totalSpace'] as num?)?.toInt() ?? 0, + freeSpace: (json['freeSpace'] as num?)?.toInt() ?? 0, + usedSpace: (json['usedSpace'] as num?)?.toInt() ?? 0, + ); + } +} + +// MARK: - App Storage + +/// App storage breakdown by directory type +class AppStorageInfo { + /// Documents directory size in bytes + final int documentsSize; + + /// Cache directory size in bytes + final int cacheSize; + + /// Application Support directory size in bytes + final int appSupportSize; + + /// Total app storage in bytes + final int totalSize; + + const AppStorageInfo({ + required this.documentsSize, + required this.cacheSize, + required this.appSupportSize, + required this.totalSize, + }); + + Map toJson() => { + 'documentsSize': documentsSize, + 'cacheSize': cacheSize, + 'appSupportSize': appSupportSize, + 'totalSize': totalSize, + }; + + factory AppStorageInfo.fromJson(Map json) { + return AppStorageInfo( + documentsSize: (json['documentsSize'] as num?)?.toInt() ?? 0, + cacheSize: (json['cacheSize'] as num?)?.toInt() ?? 0, + appSupportSize: (json['appSupportSize'] as num?)?.toInt() ?? 0, + totalSize: (json['totalSize'] as num?)?.toInt() ?? 0, + ); + } +} + +// MARK: - Model Storage Metrics + +/// Storage metrics for a single model +/// All model metadata (id, name, framework, artifactType, etc.) is in ModelInfo +/// This class adds the on-disk storage size +class ModelStorageMetrics { + /// The model info (contains id, framework, localPath, artifactType, etc.) + final ModelInfo model; + + /// Actual size on disk in bytes (may differ from downloadSize after extraction) + final int sizeOnDisk; + + const ModelStorageMetrics({ + required this.model, + required this.sizeOnDisk, + }); +} + +// MARK: - Stored Model (Backward Compatible) + +/// Backward-compatible stored model view +/// Provides a simple view of a stored model with computed properties +class StoredModel { + /// Underlying model info + final ModelInfo modelInfo; + + /// Size on disk in bytes + final int size; + + const StoredModel({ + required this.modelInfo, + required this.size, + }); + + /// Model ID + String get id => modelInfo.id; + + /// Model name + String get name => modelInfo.name; + + /// Model format + ModelFormat get format => modelInfo.format; + + /// Inference framework + InferenceFramework get framework => modelInfo.framework; + + /// Model description + String? get description => modelInfo.description; + + /// Path to the model on disk + Uri get path => modelInfo.localPath ?? Uri.parse('file:///unknown'); + + /// Created date (use current date as fallback) + DateTime get createdDate => modelInfo.createdAt; + + /// Create from ModelStorageMetrics + factory StoredModel.fromMetrics(ModelStorageMetrics metrics) { + return StoredModel( + modelInfo: metrics.model, + size: metrics.sizeOnDisk, + ); + } + + Map toJson() => { + 'id': id, + 'name': name, + 'path': path.toString(), + 'size': size, + 'format': format.rawValue, + 'framework': framework.rawValue, + 'createdDate': createdDate.toIso8601String(), + if (description != null) 'description': description, + }; + + factory StoredModel.fromJson(Map json) { + return StoredModel( + modelInfo: ModelInfo( + id: json['id'] as String, + name: json['name'] as String, + category: ModelCategory.language, + format: + ModelFormat.fromRawValue(json['format'] as String? ?? 'unknown'), + framework: InferenceFramework.fromRawValue( + json['framework'] as String? ?? 'unknown'), + localPath: + json['path'] != null ? Uri.parse(json['path'] as String) : null, + description: json['description'] as String?, + createdAt: json['createdDate'] != null + ? DateTime.parse(json['createdDate'] as String) + : null, + ), + size: (json['size'] as num?)?.toInt() ?? 0, + ); + } +} + +// MARK: - Storage Info (Aggregate) + +/// Complete storage information including device, app, and model storage +class StorageInfo { + /// App storage usage + final AppStorageInfo appStorage; + + /// Device storage capacity + final DeviceStorageInfo deviceStorage; + + /// Storage metrics for each downloaded model + final List models; + + const StorageInfo({ + required this.appStorage, + required this.deviceStorage, + required this.models, + }); + + /// Total size of all models + int get totalModelsSize { + return models.fold(0, (sum, m) => sum + m.sizeOnDisk); + } + + /// Number of stored models + int get modelCount => models.length; + + /// Stored models array (backward compatible) + List get storedModels { + return models.map(StoredModel.fromMetrics).toList(); + } + + /// Empty storage info + static const StorageInfo empty = StorageInfo( + appStorage: AppStorageInfo( + documentsSize: 0, + cacheSize: 0, + appSupportSize: 0, + totalSize: 0, + ), + deviceStorage: DeviceStorageInfo( + totalSpace: 0, + freeSpace: 0, + usedSpace: 0, + ), + models: [], + ); + + Map toJson() => { + 'appStorage': appStorage.toJson(), + 'deviceStorage': deviceStorage.toJson(), + 'models': storedModels.map((m) => m.toJson()).toList(), + }; + + factory StorageInfo.fromJson(Map json) { + final storedModels = (json['models'] as List?) + ?.map((m) => StoredModel.fromJson(m as Map)) + .toList() ?? + []; + + return StorageInfo( + appStorage: + AppStorageInfo.fromJson(json['appStorage'] as Map), + deviceStorage: DeviceStorageInfo.fromJson( + json['deviceStorage'] as Map), + models: storedModels + .map((s) => + ModelStorageMetrics(model: s.modelInfo, sizeOnDisk: s.size)) + .toList(), + ); + } +} + +// MARK: - Storage Availability + +/// Storage availability check result +class StorageAvailability { + /// Whether storage is available for the requested operation + final bool isAvailable; + + /// Required space in bytes + final int requiredSpace; + + /// Available space in bytes + final int availableSpace; + + /// Whether there's a warning (e.g., low space) + final bool hasWarning; + + /// Recommendation message if any + final String? recommendation; + + const StorageAvailability({ + required this.isAvailable, + required this.requiredSpace, + required this.availableSpace, + required this.hasWarning, + this.recommendation, + }); +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/data/network/api_client.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/data/network/api_client.dart new file mode 100644 index 000000000..9b7bed026 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/data/network/api_client.dart @@ -0,0 +1,261 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:http/http.dart' as http; +import 'package:runanywhere/data/network/api_endpoint.dart'; +import 'package:runanywhere/data/network/network_service.dart'; +import 'package:runanywhere/foundation/configuration/sdk_constants.dart'; +import 'package:runanywhere/foundation/error_types/sdk_error.dart'; +import 'package:runanywhere/foundation/logging/sdk_logger.dart'; + +/// Production API client for backend operations. +/// +/// Matches iOS `APIClient` actor. +/// Implements NetworkService protocol for real network calls. +class APIClient implements NetworkService { + // MARK: - Properties + + final Uri baseURL; + final String apiKey; + final http.Client _httpClient; + final SDKLogger _logger; + + /// Optional auth service for getting access tokens. + /// Set via `setAuthenticationService` after init. + AuthTokenProvider? _authTokenProvider; + + // MARK: - Default Headers + + Map get _defaultHeaders => { + 'Content-Type': 'application/json', + 'X-SDK-Client': 'RunAnywhereFlutterSDK', + 'X-SDK-Version': SDKConstants.version, + 'X-Platform': SDKConstants.platform, + // Supabase-compatible headers (also works with standard backends) + 'apikey': apiKey, + // Supabase: Request to return the created/updated row + 'Prefer': 'return=representation', + }; + + // MARK: - Initialization + + APIClient({ + required this.baseURL, + required this.apiKey, + http.Client? httpClient, + }) : _httpClient = httpClient ?? http.Client(), + _logger = SDKLogger('APIClient'); + + /// Set the authentication token provider (called after AuthenticationService is created). + void setAuthTokenProvider(AuthTokenProvider provider) { + _authTokenProvider = provider; + } + + // MARK: - NetworkService Implementation + + @override + Future post( + APIEndpoint endpoint, + Object payload, { + required bool requiresAuth, + required T Function(Map) fromJson, + }) async { + final responseData = await postRaw( + endpoint, + _encodePayload(payload), + requiresAuth: requiresAuth, + ); + return _decodeResponse(responseData, fromJson); + } + + @override + Future get( + APIEndpoint endpoint, { + required bool requiresAuth, + required T Function(Map) fromJson, + }) async { + final responseData = await getRaw( + endpoint, + requiresAuth: requiresAuth, + ); + return _decodeResponse(responseData, fromJson); + } + + @override + Future postRaw( + APIEndpoint endpoint, + Uint8List payload, { + required bool requiresAuth, + }) async { + return _postRawWithPath(endpoint.path, payload, requiresAuth: requiresAuth); + } + + @override + Future getRaw( + APIEndpoint endpoint, { + required bool requiresAuth, + }) async { + return _getRawWithPath(endpoint.path, requiresAuth: requiresAuth); + } + + @override + Future postWithPath( + String path, + Object payload, { + required bool requiresAuth, + required T Function(Map) fromJson, + }) async { + final responseData = await _postRawWithPath( + path, + _encodePayload(payload), + requiresAuth: requiresAuth, + ); + return _decodeResponse(responseData, fromJson); + } + + @override + Future getWithPath( + String path, { + required bool requiresAuth, + required T Function(Map) fromJson, + }) async { + final responseData = + await _getRawWithPath(path, requiresAuth: requiresAuth); + return _decodeResponse(responseData, fromJson); + } + + // MARK: - Private Methods + + Future _postRawWithPath( + String path, + Uint8List payload, { + required bool requiresAuth, + }) async { + final token = await _getToken(requiresAuth); + final url = baseURL.resolve(path); + + _logger.debug('POST $path'); + + final headers = Map.from(_defaultHeaders); + headers['Authorization'] = 'Bearer $token'; + + try { + final response = await _httpClient + .post( + url, + headers: headers, + body: payload, + ) + .timeout(const Duration(seconds: 30)); + + _validateResponse(response); + return response.bodyBytes; + } catch (e) { + if (e is SDKError) rethrow; + _logger.error('POST $path failed: $e'); + throw SDKError.networkError(e.toString()); + } + } + + Future _getRawWithPath( + String path, { + required bool requiresAuth, + }) async { + final token = await _getToken(requiresAuth); + final url = baseURL.resolve(path); + + _logger.debug('GET $path'); + + final headers = Map.from(_defaultHeaders); + headers['Authorization'] = 'Bearer $token'; + + try { + final response = await _httpClient + .get( + url, + headers: headers, + ) + .timeout(const Duration(seconds: 30)); + + _validateResponse(response); + return response.bodyBytes; + } catch (e) { + if (e is SDKError) rethrow; + _logger.error('GET $path failed: $e'); + throw SDKError.networkError(e.toString()); + } + } + + Future _getToken(bool requiresAuth) async { + if (requiresAuth && _authTokenProvider != null) { + return _authTokenProvider!.getAccessToken(); + } + // No auth service or not required - use API key as bearer token (Supabase dev mode) + return apiKey; + } + + Uint8List _encodePayload(Object payload) { + if (payload is Uint8List) return payload; + if (payload is Map || payload is List) { + return Uint8List.fromList(utf8.encode(json.encode(payload))); + } + // For objects with toJson method + try { + final jsonable = (payload as dynamic).toJson(); + return Uint8List.fromList(utf8.encode(json.encode(jsonable))); + } catch (_) { + throw ArgumentError('Payload must be Map, List, or have toJson() method'); + } + } + + T _decodeResponse( + Uint8List data, T Function(Map) fromJson) { + final jsonStr = utf8.decode(data); + final jsonMap = json.decode(jsonStr) as Map; + return fromJson(jsonMap); + } + + void _validateResponse(http.Response response) { + if (response.statusCode == 200 || response.statusCode == 201) { + return; + } + + // Try to parse error response + var errorMessage = 'HTTP ${response.statusCode}'; + + try { + final errorData = json.decode(response.body) as Map; + + // Try different error message formats + if (errorData.containsKey('detail')) { + final detail = errorData['detail']; + if (detail is String) { + errorMessage = detail; + } else if (detail is List) { + final errors = detail + .whereType>() + .map((e) => e['msg'] as String?) + .whereType() + .join(', '); + if (errors.isNotEmpty) errorMessage = errors; + } + } else if (errorData.containsKey('message')) { + errorMessage = errorData['message'] as String; + } else if (errorData.containsKey('error')) { + errorMessage = errorData['error'] as String; + } + } catch (_) { + // Keep default error message if parsing fails + } + + _logger.warning('Request failed: $errorMessage'); + throw SDKError.networkError(errorMessage); + } +} + +/// Protocol for providing authentication tokens. +/// Implemented by AuthenticationService. +abstract class AuthTokenProvider { + Future getAccessToken(); +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/data/network/api_endpoint.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/data/network/api_endpoint.dart new file mode 100644 index 000000000..f7533cc3c --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/data/network/api_endpoint.dart @@ -0,0 +1,132 @@ +import 'package:runanywhere/public/configuration/sdk_environment.dart'; + +/// API endpoints matching iOS APIEndpoint.swift exactly. +/// +/// Provides typed endpoint definitions for all backend API routes. +enum APIEndpoint { + // Authentication & Health + authenticate, + refreshToken, + healthCheck, + + // Device Management - Production/Staging + deviceRegistration, + analytics, + + // Device Management - Development + devDeviceRegistration, + devAnalytics, + + // Telemetry - Production/Staging + telemetry, + // Telemetry - Development + devTelemetry, + + // Core endpoints + models, + deviceInfo, + generationHistory, + userPreferences, + modelAssignments, + + // Development-specific + devModelAssignments, +} + +extension APIEndpointPath on APIEndpoint { + /// Get the URL path for this endpoint. + String get path { + switch (this) { + // Authentication & Health + case APIEndpoint.authenticate: + return '/api/v1/auth/sdk/authenticate'; + case APIEndpoint.refreshToken: + return '/api/v1/auth/sdk/refresh'; + case APIEndpoint.healthCheck: + return '/v1/health'; + + // Device Management - Production/Staging + case APIEndpoint.deviceRegistration: + return '/api/v1/devices/register'; + case APIEndpoint.analytics: + return '/api/v1/analytics'; + + // Device Management - Development (Supabase REST API format) + case APIEndpoint.devDeviceRegistration: + return '/rest/v1/sdk_devices'; + case APIEndpoint.devAnalytics: + return '/rest/v1/analytics_events'; + + // Telemetry - Production/Staging + case APIEndpoint.telemetry: + return '/api/v1/sdk/telemetry'; + // Telemetry - Development (Supabase REST API format) + case APIEndpoint.devTelemetry: + return '/rest/v1/telemetry_events'; + + // Core endpoints + case APIEndpoint.models: + return '/api/v1/models'; + case APIEndpoint.deviceInfo: + return '/api/v1/device'; + case APIEndpoint.generationHistory: + return '/api/v1/history'; + case APIEndpoint.userPreferences: + return '/api/v1/preferences'; + case APIEndpoint.modelAssignments: + return '/api/v1/model-assignments/for-sdk'; + + // Development-specific (Supabase REST API format) + case APIEndpoint.devModelAssignments: + return '/rest/v1/sdk_model_assignments'; + } + } +} + +// MARK: - Environment-Based Endpoint Selection + +extension APIEndpointEnvironment on APIEndpoint { + /// Get the device registration endpoint for the given environment. + static APIEndpoint deviceRegistrationEndpoint(SDKEnvironment environment) { + switch (environment) { + case SDKEnvironment.development: + return APIEndpoint.devDeviceRegistration; + case SDKEnvironment.staging: + case SDKEnvironment.production: + return APIEndpoint.deviceRegistration; + } + } + + /// Get the analytics endpoint for the given environment. + static APIEndpoint analyticsEndpoint(SDKEnvironment environment) { + switch (environment) { + case SDKEnvironment.development: + return APIEndpoint.devAnalytics; + case SDKEnvironment.staging: + case SDKEnvironment.production: + return APIEndpoint.analytics; + } + } + + /// Get the telemetry endpoint for the given environment. + static APIEndpoint telemetryEndpoint(SDKEnvironment environment) { + switch (environment) { + case SDKEnvironment.development: + return APIEndpoint.devTelemetry; + case SDKEnvironment.staging: + case SDKEnvironment.production: + return APIEndpoint.telemetry; + } + } + + /// Get the model assignments endpoint for the given environment. + static APIEndpoint modelAssignmentsEndpoint(SDKEnvironment environment) { + switch (environment) { + case SDKEnvironment.development: + return APIEndpoint.devModelAssignments; + case SDKEnvironment.staging: + case SDKEnvironment.production: + return APIEndpoint.modelAssignments; + } + } +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/data/network/http_service.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/data/network/http_service.dart new file mode 100644 index 000000000..605350cb6 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/data/network/http_service.dart @@ -0,0 +1,634 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:http/http.dart' as http; +import 'package:runanywhere/data/network/network_configuration.dart'; +import 'package:runanywhere/foundation/configuration/sdk_constants.dart'; +import 'package:runanywhere/foundation/error_types/sdk_error.dart'; +import 'package:runanywhere/foundation/logging/sdk_logger.dart'; +import 'package:runanywhere/native/dart_bridge_auth.dart'; +import 'package:runanywhere/public/configuration/sdk_environment.dart'; + +/// HTTP Service - Core network implementation using dart:http +/// +/// Centralized HTTP transport layer matching Swift/React Native HTTPService. +/// Uses the http package as the HTTP client. +/// +/// Features: +/// - Environment-aware routing (Supabase for dev, Railway for prod) +/// - Automatic header management +/// - Proper timeout and error handling +/// - Device registration with Supabase UPSERT support +/// +/// Usage: +/// ```dart +/// // Configure (called during SDK init) +/// HTTPService.shared.configure(HTTPServiceConfig( +/// baseURL: 'https://api.runanywhere.ai', +/// apiKey: 'your-api-key', +/// environment: SDKEnvironment.production, +/// )); +/// +/// // Make requests +/// final response = await HTTPService.shared.post('/api/v1/devices/register', deviceData); +/// ``` +class HTTPService { + // ============================================================================ + // Singleton + // ============================================================================ + + static HTTPService? _instance; + + /// Get shared HTTPService instance + static HTTPService get shared { + _instance ??= HTTPService._(); + return _instance!; + } + + // ============================================================================ + // Configuration + // ============================================================================ + + String _baseURL = ''; + String _apiKey = ''; + SDKEnvironment _environment = SDKEnvironment.production; + String? _accessToken; + Duration _timeout = const Duration(seconds: 30); + + // Development mode (Supabase) + String _supabaseURL = ''; + String _supabaseKey = ''; + + final http.Client _httpClient; + final SDKLogger _logger; + + // ============================================================================ + // Initialization + // ============================================================================ + + HTTPService._() + : _httpClient = http.Client(), + _logger = SDKLogger('HTTPService'); + + Map get _defaultHeaders => { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-SDK-Client': 'RunAnywhereFlutterSDK', + 'X-SDK-Version': SDKConstants.version, + 'X-Platform': SDKConstants.platform, + }; + + // ============================================================================ + // Configuration Methods + // ============================================================================ + + /// Configure HTTP service with base URL and API key + void configure(HTTPServiceConfig config) { + _baseURL = config.baseURL; + _apiKey = config.apiKey; + _environment = config.environment; + _timeout = Duration(milliseconds: config.timeoutMs); + + _logger.info( + 'Configured for ${_getEnvironmentName()} environment: ${_getHostname(config.baseURL)}', + ); + } + + /// Configure development mode with Supabase credentials + /// + /// When in development mode, SDK makes calls directly to Supabase + /// instead of going through the Railway backend. + void configureDev(DevModeConfig config) { + _supabaseURL = config.supabaseURL; + _supabaseKey = config.supabaseKey; + + _logger.info('Development mode configured with Supabase'); + } + + /// Set authorization token + void setToken(String token) { + _accessToken = token; + _logger.debug('Access token set'); + } + + /// Clear authorization token + void clearToken() { + _accessToken = null; + _logger.debug('Access token cleared'); + } + + /// Check if HTTP service is configured + bool get isConfigured { + if (_environment == SDKEnvironment.development) { + return _supabaseURL.isNotEmpty; + } + return _baseURL.isNotEmpty && _apiKey.isNotEmpty; + } + + // ============================================================================ + // Token Resolution (matches Swift's resolveToken) + // ============================================================================ + + /// Resolve valid token for request, refreshing if needed. + /// Matches Swift's HTTPService.resolveToken(requiresAuth:) + Future _resolveToken({required bool requiresAuth}) async { + if (_environment == SDKEnvironment.development) { + // Development mode - use Supabase key directly + return _supabaseKey; + } + + if (!requiresAuth) { + // Non-auth requests use API key + return _apiKey; + } + + // Production/Staging - check for valid token, refresh if needed + final authBridge = DartBridgeAuth.instance; + + // Check if we have a valid token + final currentToken = authBridge.getAccessToken(); + if (currentToken != null && !authBridge.needsRefresh()) { + return currentToken; + } + + // Try refresh if we have a refresh token + if (authBridge.isAuthenticated()) { + _logger.debug('Token needs refresh, attempting refresh...'); + final result = await authBridge.refreshToken(); + if (result.isSuccess) { + final newToken = authBridge.getAccessToken(); + if (newToken != null) { + // Update internal access token + _accessToken = newToken; + _logger.info('Token refreshed successfully'); + return newToken; + } + } else { + _logger.warning('Token refresh failed: ${result.error}'); + } + } + + // Fallback to access token or API key + if (_accessToken != null && _accessToken!.isNotEmpty) { + return _accessToken!; + } + if (_apiKey.isNotEmpty) { + return _apiKey; + } + + throw SDKError.authenticationFailed('No valid authentication token'); + } + + /// Get current base URL + String get currentBaseURL { + if (_environment == SDKEnvironment.development && _supabaseURL.isNotEmpty) { + return _supabaseURL; + } + return _baseURL; + } + + /// Get current environment + SDKEnvironment get environment => _environment; + + // ============================================================================ + // HTTP Methods + // ============================================================================ + + /// POST request with JSON body + /// + /// [path] - API endpoint path + /// [data] - Request body (will be JSON serialized) + /// Returns parsed response data + Future post( + String path, + Object? data, { + T Function(Map)? fromJson, + bool requiresAuth = false, + }) async { + var url = _buildFullURL(path); + + // Handle device registration - add UPSERT for Supabase + final isDeviceReg = _isDeviceRegistrationPath(path); + final headers = _buildHeaders(isDeviceReg, requiresAuth); + + if (isDeviceReg && _environment == SDKEnvironment.development) { + final separator = url.contains('?') ? '&' : '?'; + url = '$url${separator}on_conflict=device_id'; + } + + final response = await _executeRequest( + 'POST', + url, + headers, + data, + requiresAuth: requiresAuth, + ); + + // Handle 409 as success for device registration (device already exists) + if (isDeviceReg && response.statusCode == 409) { + _logger.info('Device already registered (409) - treating as success'); + return _parseResponse(response, fromJson); + } + + return _handleResponse(response, path, fromJson); + } + + /// POST request returning raw bytes + Future postRaw( + String path, + Uint8List payload, { + bool requiresAuth = false, + }) async { + var url = _buildFullURL(path); + + final isDeviceReg = _isDeviceRegistrationPath(path); + final headers = _buildHeaders(isDeviceReg, requiresAuth); + + if (isDeviceReg && _environment == SDKEnvironment.development) { + final separator = url.contains('?') ? '&' : '?'; + url = '$url${separator}on_conflict=device_id'; + } + + final uri = Uri.parse(url); + _logger.debug('POST $path'); + + try { + final response = await _httpClient + .post( + uri, + headers: headers, + body: payload, + ) + .timeout(_timeout); + + if (isDeviceReg && response.statusCode == 409) { + _logger.info('Device already registered (409) - treating as success'); + return response.bodyBytes; + } + + _validateResponse(response, path); + return response.bodyBytes; + } catch (e) { + if (e is SDKError) rethrow; + _logger.error('POST $path failed: $e'); + throw SDKError.networkError(e.toString()); + } + } + + /// GET request + /// + /// [path] - API endpoint path + /// Returns parsed response data + Future get( + String path, { + T Function(Map)? fromJson, + bool requiresAuth = false, + }) async { + final url = _buildFullURL(path); + final headers = _buildHeaders(false, requiresAuth); + + final response = await _executeRequest( + 'GET', + url, + headers, + null, + requiresAuth: requiresAuth, + ); + return _handleResponse(response, path, fromJson); + } + + /// GET request returning raw bytes + Future getRaw( + String path, { + bool requiresAuth = false, + }) async { + final url = _buildFullURL(path); + final headers = _buildHeaders(false, requiresAuth); + + final uri = Uri.parse(url); + _logger.debug('GET $path'); + + try { + final response = await _httpClient + .get( + uri, + headers: headers, + ) + .timeout(_timeout); + + _validateResponse(response, path); + return response.bodyBytes; + } catch (e) { + if (e is SDKError) rethrow; + _logger.error('GET $path failed: $e'); + throw SDKError.networkError(e.toString()); + } + } + + /// PUT request + /// + /// [path] - API endpoint path + /// [data] - Request body + /// Returns parsed response data + Future put( + String path, + Object? data, { + T Function(Map)? fromJson, + bool requiresAuth = false, + }) async { + final url = _buildFullURL(path); + final headers = _buildHeaders(false, requiresAuth); + + final response = await _executeRequest( + 'PUT', + url, + headers, + data, + requiresAuth: requiresAuth, + ); + return _handleResponse(response, path, fromJson); + } + + /// DELETE request + /// + /// [path] - API endpoint path + /// Returns parsed response data + Future delete( + String path, { + T Function(Map)? fromJson, + bool requiresAuth = false, + }) async { + final url = _buildFullURL(path); + final headers = _buildHeaders(false, requiresAuth); + + final response = await _executeRequest( + 'DELETE', + url, + headers, + null, + requiresAuth: requiresAuth, + ); + return _handleResponse(response, path, fromJson); + } + + // ============================================================================ + // Private Implementation + // ============================================================================ + + Future _executeRequest( + String method, + String url, + Map headers, + Object? data, { + bool requiresAuth = false, + bool isRetry = false, + }) async { + final uri = Uri.parse(url); + _logger.debug('$method $url'); + + try { + // Resolve auth token if required (matches Swift's resolveToken pattern) + if (requiresAuth && _environment != SDKEnvironment.development) { + final token = await _resolveToken(requiresAuth: requiresAuth); + if (token.isNotEmpty) { + headers['Authorization'] = 'Bearer $token'; + } + } + + late http.Response response; + + switch (method) { + case 'GET': + response = await _httpClient.get(uri, headers: headers).timeout(_timeout); + break; + case 'POST': + final body = data != null ? json.encode(data) : null; + // Debug: Log request body for telemetry debugging + if (url.contains('telemetry')) { + _logger.debug('POST body: $body'); + } + response = await _httpClient + .post( + uri, + headers: headers, + body: body, + ) + .timeout(_timeout); + // Debug: Log response for telemetry debugging + if (url.contains('telemetry')) { + _logger.debug('Response status: ${response.statusCode}'); + _logger.debug('Response body: ${response.body}'); + } + break; + case 'PUT': + response = await _httpClient + .put( + uri, + headers: headers, + body: data != null ? json.encode(data) : null, + ) + .timeout(_timeout); + break; + case 'DELETE': + response = await _httpClient.delete(uri, headers: headers).timeout(_timeout); + break; + default: + throw SDKError.networkError('Unsupported HTTP method: $method'); + } + + // Handle 401 Unauthorized - attempt token refresh and retry once + if (response.statusCode == 401 && requiresAuth && !isRetry) { + _logger.debug('Received 401, attempting token refresh and retry...'); + + final authBridge = DartBridgeAuth.instance; + final refreshResult = await authBridge.refreshToken(); + + if (refreshResult.isSuccess) { + final newToken = authBridge.getAccessToken(); + if (newToken != null) { + _accessToken = newToken; + _logger.info('Token refreshed, retrying request...'); + + // Retry the request with new token + final retryHeaders = Map.from(headers); + return _executeRequest( + method, + url, + retryHeaders, + data, + requiresAuth: requiresAuth, + isRetry: true, + ); + } + } else { + _logger.warning('Token refresh failed: ${refreshResult.error}'); + } + } + + return response; + } on TimeoutException { + _logger.error('$method $url timed out'); + throw SDKError.timeout('Request timed out'); + } catch (e) { + if (e is SDKError) rethrow; + _logger.error('$method $url failed: $e'); + throw SDKError.networkError(e.toString()); + } + } + + Map _buildHeaders(bool isDeviceRegistration, bool requiresAuth) { + final headers = Map.from(_defaultHeaders); + + if (_environment == SDKEnvironment.development) { + // Development mode - use Supabase headers + // Supabase requires BOTH apikey AND Authorization: Bearer headers + if (_supabaseKey.isNotEmpty) { + headers['apikey'] = _supabaseKey; + headers['Authorization'] = 'Bearer $_supabaseKey'; + headers['Prefer'] = isDeviceRegistration + ? 'resolution=merge-duplicates' + : 'return=representation'; + } + } else { + // Production/Staging - use Bearer token + final token = _accessToken ?? _apiKey; + if (token.isNotEmpty) { + headers['Authorization'] = 'Bearer $token'; + } + // Also add apikey for production (Railway backend) + if (_apiKey.isNotEmpty) { + headers['apikey'] = _apiKey; + } + } + + return headers; + } + + String _buildFullURL(String path) { + // Handle full URLs + if (path.startsWith('http://') || path.startsWith('https://')) { + return path; + } + + final base = currentBaseURL.endsWith('/') + ? currentBaseURL.substring(0, currentBaseURL.length - 1) + : currentBaseURL; + final endpoint = path.startsWith('/') ? path : '/$path'; + return '$base$endpoint'; + } + + bool _isDeviceRegistrationPath(String path) { + return path.contains('sdk_devices') || + path.contains('devices/register') || + path.contains('rest/v1/sdk_devices'); + } + + T _parseResponse( + http.Response response, + T Function(Map)? fromJson, + ) { + final text = response.body; + if (text.isEmpty) { + return {} as T; + } + try { + final decoded = json.decode(text); + if (fromJson != null && decoded is Map) { + return fromJson(decoded); + } + return decoded as T; + } catch (_) { + return text as T; + } + } + + T _handleResponse( + http.Response response, + String path, + T Function(Map)? fromJson, + ) { + if (response.statusCode >= 200 && response.statusCode < 300) { + return _parseResponse(response, fromJson); + } + + // Parse error response + var errorMessage = 'HTTP ${response.statusCode}'; + try { + final errorData = json.decode(response.body) as Map; + errorMessage = (errorData['message'] as String?) ?? + (errorData['error'] as String?) ?? + (errorData['hint'] as String?) ?? + errorMessage; + } catch (_) { + // Ignore JSON parse errors + } + + _logger.error('HTTP ${response.statusCode}: $path'); + throw _createError(response.statusCode, errorMessage, path); + } + + void _validateResponse(http.Response response, String path) { + if (response.statusCode >= 200 && response.statusCode < 300) { + return; + } + + var errorMessage = 'HTTP ${response.statusCode}'; + try { + final errorData = json.decode(response.body) as Map; + errorMessage = (errorData['message'] as String?) ?? + (errorData['error'] as String?) ?? + (errorData['hint'] as String?) ?? + errorMessage; + } catch (_) { + // Keep default error message if parsing fails + } + + _logger.error('HTTP ${response.statusCode}: $path - $errorMessage'); + throw _createError(response.statusCode, errorMessage, path); + } + + SDKError _createError(int statusCode, String message, String path) { + switch (statusCode) { + case 400: + return SDKError.networkError('Bad request: $message'); + case 401: + return SDKError.authenticationFailed(message); + case 403: + return SDKError.authenticationFailed('Forbidden: $message'); + case 404: + return SDKError.networkError('Not found: $path'); + case 429: + return SDKError.rateLimitExceeded('Rate limited: $message'); + case 500: + case 502: + case 503: + case 504: + return SDKError.serverError('Server error ($statusCode): $message'); + default: + return SDKError.networkError('HTTP $statusCode: $message'); + } + } + + String _getEnvironmentName() { + switch (_environment) { + case SDKEnvironment.development: + return 'development'; + case SDKEnvironment.staging: + return 'staging'; + case SDKEnvironment.production: + return 'production'; + } + } + + String _getHostname(String url) { + // Simple hostname extraction + final match = RegExp(r'^https?://([^/:]+)').firstMatch(url); + return match != null ? match.group(1)! : url.substring(0, 30.clamp(0, url.length)); + } + + /// Reset for testing + static void resetForTesting() { + _instance = null; + } +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/data/network/models/auth/authentication_response.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/data/network/models/auth/authentication_response.dart new file mode 100644 index 000000000..e9d960a04 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/data/network/models/auth/authentication_response.dart @@ -0,0 +1,48 @@ +/// Response model for authentication. +/// +/// Matches iOS `AuthenticationResponse` from RunAnywhere SDK. +class AuthenticationResponse { + final String accessToken; + final String deviceId; + final int expiresIn; + final String organizationId; + final String refreshToken; + final String tokenType; + final String? userId; + + const AuthenticationResponse({ + required this.accessToken, + required this.deviceId, + required this.expiresIn, + required this.organizationId, + required this.refreshToken, + required this.tokenType, + this.userId, + }); + + factory AuthenticationResponse.fromJson(Map json) { + return AuthenticationResponse( + accessToken: json['access_token'] as String, + deviceId: json['device_id'] as String, + expiresIn: json['expires_in'] as int, + organizationId: json['organization_id'] as String, + refreshToken: json['refresh_token'] as String, + tokenType: json['token_type'] as String, + userId: json['user_id'] as String?, + ); + } + + Map toJson() => { + 'access_token': accessToken, + 'device_id': deviceId, + 'expires_in': expiresIn, + 'organization_id': organizationId, + 'refresh_token': refreshToken, + 'token_type': tokenType, + if (userId != null) 'user_id': userId, + }; +} + +/// Response model for token refresh (same as AuthenticationResponse). +/// Matches iOS `RefreshTokenResponse` typealias. +typedef RefreshTokenResponse = AuthenticationResponse; diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/data/network/network.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/data/network/network.dart new file mode 100644 index 000000000..84a6191f7 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/data/network/network.dart @@ -0,0 +1,41 @@ +/// Network Services +/// +/// Centralized network layer for RunAnywhere Flutter SDK. +/// Uses the http package for HTTP requests. +/// +/// Matches React Native SDK network layer structure. +library network; + +// Core HTTP service +export 'http_service.dart' show HTTPService; + +// Configuration utilities +export 'network_configuration.dart' + show + HTTPServiceConfig, + DevModeConfig, + NetworkConfig, + SupabaseNetworkConfig, + createNetworkConfig, + getEnvironmentName, + isDevelopment, + isProduction, + isStaging; + +// API endpoints +export 'api_endpoint.dart' + show APIEndpoint, APIEndpointPath, APIEndpointEnvironment; + +// Network service protocol +export 'network_service.dart' show NetworkService; + +// API client +export 'api_client.dart' show APIClient, AuthTokenProvider; + +// Telemetry +export 'telemetry_service.dart' + show TelemetryService, TelemetryCategory, TelemetryEvent; + +// Models +export 'models/auth/authentication_response.dart' + show AuthenticationResponse, RefreshTokenResponse; diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/data/network/network_configuration.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/data/network/network_configuration.dart new file mode 100644 index 000000000..3e3c21608 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/data/network/network_configuration.dart @@ -0,0 +1,172 @@ +import 'package:runanywhere/public/configuration/sdk_environment.dart'; + +export 'package:runanywhere/public/configuration/sdk_environment.dart' + show SDKEnvironment; + +/// HTTP Service Configuration +/// +/// Matches React Native HTTPServiceConfig interface. +class HTTPServiceConfig { + /// Base URL for API requests + final String baseURL; + + /// API key for authentication + final String apiKey; + + /// SDK environment + final SDKEnvironment environment; + + /// Request timeout in milliseconds + final int timeoutMs; + + const HTTPServiceConfig({ + required this.baseURL, + required this.apiKey, + this.environment = SDKEnvironment.production, + this.timeoutMs = defaultTimeoutMs, + }); + + /// Default timeout in milliseconds + static const int defaultTimeoutMs = 30000; +} + +/// Development (Supabase) Configuration +/// +/// Matches React Native DevModeConfig interface. +class DevModeConfig { + /// Supabase project URL + final String supabaseURL; + + /// Supabase anon key + final String supabaseKey; + + const DevModeConfig({ + required this.supabaseURL, + required this.supabaseKey, + }); +} + +/// Network configuration options for SDK initialization +/// +/// Matches React Native NetworkConfig interface. +class NetworkConfig { + /// Base URL for API requests + /// - Production: Railway endpoint (e.g., "https://api.runanywhere.ai") + /// - Development: Can be left empty if supabase config is provided + final String? baseURL; + + /// API key for authentication + /// - Production: RunAnywhere API key + /// - Development: Build token + final String apiKey; + + /// SDK environment + final SDKEnvironment environment; + + /// Supabase configuration for development mode + /// When provided in development mode, SDK makes calls directly to Supabase + final SupabaseNetworkConfig? supabase; + + /// Request timeout in milliseconds + final int timeoutMs; + + const NetworkConfig({ + this.baseURL, + required this.apiKey, + this.environment = SDKEnvironment.production, + this.supabase, + this.timeoutMs = defaultTimeoutMs, + }); + + /// Default production base URL + static const String defaultBaseURL = 'https://api.runanywhere.ai'; + + /// Default timeout in milliseconds + static const int defaultTimeoutMs = 30000; +} + +/// Supabase network configuration +class SupabaseNetworkConfig { + /// Supabase project URL + final String url; + + /// Supabase anon key + final String anonKey; + + const SupabaseNetworkConfig({ + required this.url, + required this.anonKey, + }); +} + +/// Create network configuration from SDK init options +/// +/// Matches React Native createNetworkConfig function. +NetworkConfig createNetworkConfig({ + required String apiKey, + String? baseURL, + String? environmentStr, + SDKEnvironment? environment, + String? supabaseURL, + String? supabaseKey, + int? timeoutMs, +}) { + // Map string environment to enum if provided + SDKEnvironment env = environment ?? SDKEnvironment.production; + if (environmentStr != null) { + switch (environmentStr.toLowerCase()) { + case 'development': + env = SDKEnvironment.development; + break; + case 'staging': + env = SDKEnvironment.staging; + break; + case 'production': + env = SDKEnvironment.production; + break; + } + } + + // Build supabase config if provided + final supabase = supabaseURL != null && supabaseKey != null + ? SupabaseNetworkConfig( + url: supabaseURL, + anonKey: supabaseKey, + ) + : null; + + return NetworkConfig( + baseURL: baseURL ?? NetworkConfig.defaultBaseURL, + apiKey: apiKey, + environment: env, + supabase: supabase, + timeoutMs: timeoutMs ?? NetworkConfig.defaultTimeoutMs, + ); +} + +/// Get environment name string +String getEnvironmentName(SDKEnvironment env) { + switch (env) { + case SDKEnvironment.development: + return 'development'; + case SDKEnvironment.staging: + return 'staging'; + case SDKEnvironment.production: + return 'production'; + } +} + +/// Check if environment is development +bool isDevelopment(SDKEnvironment env) { + return env == SDKEnvironment.development; +} + +/// Check if environment is production +bool isProduction(SDKEnvironment env) { + return env == SDKEnvironment.production; +} + +/// Check if environment is staging +bool isStaging(SDKEnvironment env) { + return env == SDKEnvironment.staging; +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/data/network/network_service.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/data/network/network_service.dart new file mode 100644 index 000000000..a8542fb58 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/data/network/network_service.dart @@ -0,0 +1,53 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:runanywhere/data/network/api_endpoint.dart'; + +/// Protocol defining the network service interface. +/// +/// Matches iOS `NetworkService` protocol. +/// Allows for environment-based implementations (real vs mock). +abstract class NetworkService { + /// Perform a POST request with typed payload and response. + Future post( + APIEndpoint endpoint, + Object payload, { + required bool requiresAuth, + required T Function(Map) fromJson, + }); + + /// Perform a GET request with typed response. + Future get( + APIEndpoint endpoint, { + required bool requiresAuth, + required T Function(Map) fromJson, + }); + + /// Perform a raw POST request (returns raw bytes). + Future postRaw( + APIEndpoint endpoint, + Uint8List payload, { + required bool requiresAuth, + }); + + /// Perform a raw GET request (returns raw bytes). + Future getRaw( + APIEndpoint endpoint, { + required bool requiresAuth, + }); + + /// Perform a POST with custom path (for parameterized endpoints). + Future postWithPath( + String path, + Object payload, { + required bool requiresAuth, + required T Function(Map) fromJson, + }); + + /// Perform a GET with custom path (for parameterized endpoints). + Future getWithPath( + String path, { + required bool requiresAuth, + required T Function(Map) fromJson, + }); +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/data/network/telemetry_service.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/data/network/telemetry_service.dart new file mode 100644 index 000000000..30c389623 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/data/network/telemetry_service.dart @@ -0,0 +1,814 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:runanywhere/data/network/http_service.dart'; +import 'package:runanywhere/foundation/configuration/sdk_constants.dart'; +import 'package:runanywhere/foundation/logging/sdk_logger.dart'; +import 'package:runanywhere/public/configuration/sdk_environment.dart'; +import 'package:uuid/uuid.dart'; + +/// Telemetry event categories (matches C++/Swift/React Native categories) +enum TelemetryCategory { + sdk, + model, + llm, + stt, + tts, + vad, + voiceAgent, + error, +} + +extension TelemetryCategoryExtension on TelemetryCategory { + String get value { + switch (this) { + case TelemetryCategory.sdk: + return 'sdk'; + case TelemetryCategory.model: + return 'model'; + case TelemetryCategory.llm: + return 'llm'; + case TelemetryCategory.stt: + return 'stt'; + case TelemetryCategory.tts: + return 'tts'; + case TelemetryCategory.vad: + return 'vad'; + case TelemetryCategory.voiceAgent: + return 'voice_agent'; + case TelemetryCategory.error: + return 'error'; + } + } +} + +/// Telemetry event model +class TelemetryEvent { + final String id; + final String type; + final TelemetryCategory category; + final Map properties; + final DateTime timestamp; + final DateTime createdAt; + + TelemetryEvent({ + String? id, + required this.type, + required this.category, + Map? properties, + DateTime? timestamp, + }) : id = id ?? _generateEventId(), + properties = properties ?? {}, + timestamp = timestamp ?? DateTime.now(), + createdAt = DateTime.now(); + + /// Generate a proper UUID v4 string for event IDs. + /// Backend requires UUID format (xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx). + /// Uses the uuid package for proper RFC 4122 compliance. + static String _generateEventId() { + return const Uuid().v4(); + } + + /// Convert to JSON for Supabase (development) + /// Uses column names expected by Supabase telemetry_events table + /// Schema matches C++ telemetry_json.cpp rac_telemetry_manager_payload_to_json() + /// + /// Only includes non-null values to match C++ behavior (add_string skips null). + /// Events are sent one at a time, so each can have different keys. + Map toSupabaseJson({ + required String deviceId, + required String sdkVersion, + required String platform, + }) { + final json = { + // Required fields (Supabase-specific key names) + 'sdk_event_id': id, + 'event_type': type, + 'event_timestamp': timestamp.toUtc().toIso8601String(), + 'created_at': createdAt.toUtc().toIso8601String(), + // Development-only fields + 'modality': category.value, + 'device_id': deviceId, + // Device info + 'platform': platform, + 'sdk_version': sdkVersion, + }; + + // Helper to add non-null values only (matches C++ add_string/add_int behavior) + void addIfNotNull(String supabaseKey, dynamic value) { + if (value != null) { + json[supabaseKey] = value; + } + } + + // Helper to get value from properties with fallback key + dynamic getValue(String key, [String? fallbackKey]) { + return properties[key] ?? (fallbackKey != null ? properties[fallbackKey] : null); + } + + // Session tracking + addIfNotNull('session_id', getValue('session_id')); + + // Model info + addIfNotNull('model_id', getValue('model_id')); + addIfNotNull('model_name', getValue('model_name')); + addIfNotNull('framework', getValue('framework')); + + // Common metrics + addIfNotNull( + 'processing_time_ms', + getValue('processing_time_ms') ?? + getValue('load_time_ms') ?? + getValue('latency_ms') ?? + getValue('download_time_ms')); + addIfNotNull('success', getValue('success')); + addIfNotNull('error_message', getValue('error_message')); + addIfNotNull('error_code', getValue('error_code')); + + // LLM fields + addIfNotNull('input_tokens', getValue('input_tokens', 'prompt_tokens')); + addIfNotNull('output_tokens', getValue('output_tokens', 'completion_tokens')); + addIfNotNull('total_tokens', getValue('total_tokens')); + addIfNotNull('tokens_per_second', getValue('tokens_per_second')); + addIfNotNull('time_to_first_token_ms', getValue('time_to_first_token_ms')); + addIfNotNull('generation_time_ms', getValue('generation_time_ms')); + addIfNotNull('context_length', getValue('context_length')); + addIfNotNull('temperature', getValue('temperature')); + addIfNotNull('max_tokens', getValue('max_tokens')); + addIfNotNull('is_streaming', getValue('is_streaming')); + + // STT fields + addIfNotNull('audio_duration_ms', getValue('audio_duration_ms')); + addIfNotNull('real_time_factor', getValue('real_time_factor')); + addIfNotNull('word_count', getValue('word_count')); + addIfNotNull('confidence', getValue('confidence')); + addIfNotNull('language', getValue('language')); + + // TTS fields + addIfNotNull('character_count', getValue('character_count', 'text_length')); + addIfNotNull('voice', getValue('voice', 'voice_id')); + addIfNotNull('sample_rate', getValue('sample_rate')); + addIfNotNull('characters_per_second', getValue('characters_per_second')); + // TTS uses output_duration_ms in Supabase (same as audio_duration_ms) + addIfNotNull('output_duration_ms', getValue('audio_duration_ms')); + addIfNotNull('audio_size_bytes', getValue('audio_size_bytes')); + + return json; + } + + /// Convert to JSON for Production (Railway) + /// Matches C++ telemetry_json.cpp rac_telemetry_manager_payload_to_json() + /// + /// Key differences from development: + /// - Uses "id" (not "sdk_event_id") + /// - Uses "timestamp" (not "event_timestamp") + /// - Does NOT include modality or device_id at event level (they're at batch level) + /// - All fields flattened (no 'properties' nesting) + /// - No 'category' field (extra='forbid') + Map toProductionJson() { + final json = { + // Required fields - match C++ exactly + 'id': id, // UUID format + 'timestamp': timestamp.toUtc().toIso8601String(), + 'event_type': type, + 'created_at': createdAt.toUtc().toIso8601String(), + // NOTE: modality and device_id are at BATCH level in production, not here + }; + + // Helper to add non-null/non-zero values only (matches C++ add_string/add_int behavior) + void addIfNotNull(String key, dynamic value) { + if (value == null) return; + // Skip zero values for integers (matches C++ add_int behavior) + // But keep zero for doubles (confidence, real_time_factor, etc. can be 0.0) + if (value is int && value == 0) return; + json[key] = value; + } + + // Helper to always add a value, even if zero (for fields that must be present) + void addAlways(String key, dynamic value) { + if (value != null) { + json[key] = value; + } + } + + // Helper to get value from properties + dynamic getValue(String key, [String? fallbackKey]) { + return properties[key] ?? + (fallbackKey != null ? properties[fallbackKey] : null); + } + + // Session tracking + addIfNotNull('session_id', getValue('session_id')); + + // Model info + addIfNotNull('model_id', getValue('model_id')); + addIfNotNull('model_name', getValue('model_name')); + addIfNotNull('framework', getValue('framework')); + + // Device info (included at event level in production per C++ telemetry_json.cpp:218-221) + addIfNotNull('device', getValue('device')); + addIfNotNull('os_version', getValue('os_version')); + addIfNotNull('platform', getValue('platform')); + addIfNotNull('sdk_version', getValue('sdk_version')); + + // Common metrics + addIfNotNull( + 'processing_time_ms', + getValue('processing_time_ms') ?? + getValue('load_time_ms') ?? + getValue('latency_ms') ?? + getValue('download_time_ms')); + addIfNotNull('success', getValue('success')); + addIfNotNull('error_message', getValue('error_message')); + addIfNotNull('error_code', getValue('error_code')); + + // LLM fields (match C++ telemetry_json.cpp:229-239) + addIfNotNull('input_tokens', getValue('input_tokens', 'prompt_tokens')); + addIfNotNull('output_tokens', getValue('output_tokens', 'completion_tokens')); + addIfNotNull('total_tokens', getValue('total_tokens')); + addIfNotNull('tokens_per_second', getValue('tokens_per_second')); + addIfNotNull('time_to_first_token_ms', getValue('time_to_first_token_ms')); + addIfNotNull('prompt_eval_time_ms', getValue('prompt_eval_time_ms')); + addIfNotNull('generation_time_ms', getValue('generation_time_ms')); + addIfNotNull('context_length', getValue('context_length')); + addIfNotNull('temperature', getValue('temperature')); + addIfNotNull('max_tokens', getValue('max_tokens')); + + // STT fields (match C++ telemetry_json.cpp:242-248) + addIfNotNull('audio_duration_ms', getValue('audio_duration_ms')); + addIfNotNull('real_time_factor', getValue('real_time_factor')); + addIfNotNull('word_count', getValue('word_count')); + addIfNotNull('confidence', getValue('confidence')); + addIfNotNull('language', getValue('language')); + addIfNotNull('is_streaming', getValue('is_streaming')); + addIfNotNull('segment_index', getValue('segment_index')); + + // TTS fields (match C++ telemetry_json.cpp:251-256) + addIfNotNull('character_count', getValue('character_count', 'text_length')); + addIfNotNull('characters_per_second', getValue('characters_per_second')); + addIfNotNull('audio_size_bytes', getValue('audio_size_bytes')); + addIfNotNull('sample_rate', getValue('sample_rate')); + addIfNotNull('voice', getValue('voice', 'voice_id')); + // TTS stores audio_duration_ms but backend expects output_duration_ms + addIfNotNull('output_duration_ms', getValue('output_duration_ms', 'audio_duration_ms')); + + // Model lifecycle (match C++ telemetry_json.cpp:259-260) + addIfNotNull('model_size_bytes', getValue('model_size_bytes')); + addIfNotNull('archive_type', getValue('archive_type')); + + // VAD (match C++ telemetry_json.cpp:263) + addIfNotNull('speech_duration_ms', getValue('speech_duration_ms')); + + // SDK lifecycle (match C++ telemetry_json.cpp:266) + addIfNotNull('count', getValue('count')); + + // Storage (match C++ telemetry_json.cpp:269) + addIfNotNull('freed_bytes', getValue('freed_bytes')); + + // Network (match C++ telemetry_json.cpp:272) + addIfNotNull('is_online', getValue('is_online')); + + return json; + } +} + +/// TelemetryService - Event tracking for RunAnywhere SDK +/// +/// This service provides telemetry tracking for the Flutter SDK, +/// aligned with Swift/Kotlin/React Native SDKs. +/// +/// ARCHITECTURE: +/// - C++ telemetry manager handles core event logic (batching, JSON building, routing) +/// - Platform SDK provides HTTP transport via HTTPService +/// - Events are automatically tracked by C++ when using LLM/STT/TTS/VAD capabilities +/// +/// This Dart service provides: +/// - A wrapper to send telemetry events via HTTPService +/// - Convenience methods that match the Swift/Kotlin/React Native API +/// - SDK-level events that Dart code can emit +/// +/// Usage: +/// ```dart +/// // Configure (called during SDK init) +/// TelemetryService.shared.configure( +/// deviceId: 'device-123', +/// environment: SDKEnvironment.production, +/// ); +/// +/// // Track events +/// TelemetryService.shared.trackSDKInit( +/// environment: 'production', +/// success: true, +/// ); +/// +/// // Flush pending events +/// await TelemetryService.shared.flush(); +/// ``` +class TelemetryService { + // ============================================================================ + // Singleton + // ============================================================================ + + static TelemetryService? _instance; + + /// Get shared TelemetryService instance + static TelemetryService get shared { + _instance ??= TelemetryService._(); + return _instance!; + } + + // ============================================================================ + // State + // ============================================================================ + + bool _enabled = true; + String? _deviceId; + SDKEnvironment _environment = SDKEnvironment.production; + final List _eventQueue = []; + Timer? _flushTimer; + bool _isFlushInProgress = false; + + final SDKLogger _logger; + + // ============================================================================ + // Configuration + // ============================================================================ + + /// Batch size before auto-flush + static const int _batchSize = 10; + + /// Flush interval in seconds + static const int _flushIntervalSeconds = 30; + + // ============================================================================ + // Initialization + // ============================================================================ + + TelemetryService._() : _logger = SDKLogger('TelemetryService'); + + /// Configure telemetry service + /// + /// [deviceId] - Unique device identifier + /// [environment] - SDK environment (development, staging, production) + void configure({ + required String deviceId, + required SDKEnvironment environment, + }) { + _deviceId = deviceId; + _environment = environment; + + // Start periodic flush timer + _startFlushTimer(); + + _logger.debug('Configured for ${environment.description}'); + } + + /// Enable or disable telemetry + void setEnabled(bool enabled) { + _enabled = enabled; + _logger.debug('Telemetry ${enabled ? 'enabled' : 'disabled'}'); + + if (!enabled) { + _stopFlushTimer(); + _eventQueue.clear(); + } else { + _startFlushTimer(); + } + } + + /// Check if telemetry is enabled + bool get isEnabled => _enabled; + + /// Check if telemetry is initialized (configured with device ID) + bool get isInitialized => _deviceId != null; + + // ============================================================================ + // Core Telemetry Operations + // ============================================================================ + + /// Track a generic event + /// + /// [type] - Event type identifier + /// [category] - Event category for grouping + /// [properties] - Additional event properties + void track( + String type, { + TelemetryCategory category = TelemetryCategory.sdk, + Map? properties, + }) { + if (!_enabled) return; + + final event = TelemetryEvent( + type: type, + category: category, + properties: _enrichProperties(properties), + ); + + _eventQueue.add(event); + _logger.debug('Event tracked: $type'); + + // Auto-flush if batch size reached + if (_eventQueue.length >= _batchSize) { + unawaited(flush()); + } + } + + /// Flush pending telemetry events + /// + /// Sends all queued events to the backend immediately. + /// Call this on app background/exit to ensure events are sent. + Future flush() async { + if (!_enabled || _eventQueue.isEmpty || _isFlushInProgress) { + return; + } + + _isFlushInProgress = true; + + try { + // Take current batch + final batch = List.from(_eventQueue); + _eventQueue.clear(); + + // Get telemetry endpoint based on environment + final endpoint = _getTelemetryEndpoint(); + + if (_environment == SDKEnvironment.development) { + // Supabase: Send events ONE AT A TIME to avoid "All object keys must match" error + // Each event can have different keys based on its properties + int successCount = 0; + for (final event in batch) { + try { + final payload = event.toSupabaseJson( + deviceId: _deviceId ?? 'unknown', + sdkVersion: SDKConstants.version, + platform: SDKConstants.platform, + ); + + // Debug: Log the payload being sent + _logger.debug('Sending telemetry event: ${event.type}'); + _logger.debug('Payload: $payload'); + + final response = await HTTPService.shared.post( + endpoint, + payload, + requiresAuth: false, + ); + + // Debug: Log the response + _logger.debug('Response for ${event.type}: $response'); + + successCount++; + } catch (e) { + _logger.error('Failed to send event ${event.type}: $e'); + } + } + _logger.debug('Flushed $successCount/${batch.length} events'); + } else { + // Production/Staging: Group events by modality and send separate batches + // This matches the C++ telemetry manager which groups by modality + // V2 modalities: llm, stt, tts, model (get modality field at batch level) + // V1 modality: system/null (SDK lifecycle, storage, device, network events) + final v2Modalities = {'llm', 'stt', 'tts', 'model'}; + final Map> byModality = {}; + + // Group events by modality + for (final event in batch) { + final modality = event.category.value; + // V2 modalities get their modality name, V1 events get null + final key = v2Modalities.contains(modality) ? modality : null; + byModality.putIfAbsent(key, () => []).add(event); + } + + // Send batches by modality (matching C++ telemetry_manager.cpp) + int successCount = 0; + for (final entry in byModality.entries) { + final modality = entry.key; + final modalityEvents = entry.value; + + final payload = { + 'events': modalityEvents.map((e) => e.toProductionJson()).toList(), + 'device_id': _deviceId, + 'timestamp': DateTime.now().toUtc().toIso8601String(), + }; + + // Include modality at batch level for V2 events + if (modality != null) { + payload['modality'] = modality; + } + + try { + await HTTPService.shared.post( + endpoint, + payload, + requiresAuth: true, + ); + successCount += modalityEvents.length; + _logger.debug( + 'Flushed ${modalityEvents.length} ${modality ?? "system"} events'); + } catch (e) { + _logger.error( + 'Failed to flush ${modality ?? "system"} events: $e'); + } + } + _logger.debug('Flushed $successCount/${batch.length} events total'); + } + } catch (e) { + _logger.error('Failed to flush telemetry: $e'); + // Events are already removed from queue, so they're lost on failure + // This is acceptable for telemetry to avoid memory buildup + } finally { + _isFlushInProgress = false; + } + } + + /// Shutdown telemetry service + /// + /// Flushes any pending events before stopping. + Future shutdown() async { + _stopFlushTimer(); + + try { + await flush(); + _logger.debug('Telemetry shutdown complete'); + } catch (e) { + _logger.error('Telemetry shutdown error: $e'); + } + } + + // ============================================================================ + // Convenience Methods + // ============================================================================ + + /// Track SDK initialization + void trackSDKInit({ + required String environment, + required bool success, + }) { + track( + 'sdk_initialized', + category: TelemetryCategory.sdk, + properties: { + 'environment': environment, + 'success': success, + 'sdk_version': SDKConstants.version, + 'platform': SDKConstants.platform, + }, + ); + } + + /// Track model loading + void trackModelLoad({ + required String modelId, + required String modelType, + required bool success, + int? loadTimeMs, + }) { + track( + 'model_loaded', + category: TelemetryCategory.model, + properties: { + 'model_id': modelId, + 'model_type': modelType, + 'success': success, + if (loadTimeMs != null) 'load_time_ms': loadTimeMs, + }, + ); + } + + /// Track model download + void trackModelDownload({ + required String modelId, + required bool success, + int? downloadTimeMs, + int? sizeBytes, + }) { + track( + 'model_downloaded', + category: TelemetryCategory.model, + properties: { + 'model_id': modelId, + 'success': success, + if (downloadTimeMs != null) 'download_time_ms': downloadTimeMs, + if (sizeBytes != null) 'size_bytes': sizeBytes, + }, + ); + } + + /// Track text generation + void trackGeneration({ + required String modelId, + required int promptTokens, + required int completionTokens, + required int latencyMs, + String? modelName, + double? temperature, + int? maxTokens, + int? contextLength, + double? tokensPerSecond, + int? timeToFirstTokenMs, + bool isStreaming = false, + }) { + final totalTokens = promptTokens + completionTokens; + final calculatedTps = tokensPerSecond ?? + (latencyMs > 0 ? (completionTokens / latencyMs) * 1000 : 0.0); + + track( + 'generation_completed', + category: TelemetryCategory.llm, + properties: { + 'model_id': modelId, + 'model_name': modelName, + 'prompt_tokens': promptTokens, + 'completion_tokens': completionTokens, + 'total_tokens': totalTokens, + 'latency_ms': latencyMs, + 'generation_time_ms': latencyMs, + 'tokens_per_second': calculatedTps, + 'temperature': temperature, + 'max_tokens': maxTokens, + 'context_length': contextLength, + 'time_to_first_token_ms': timeToFirstTokenMs, + 'is_streaming': isStreaming, + }, + ); + } + + /// Track transcription + void trackTranscription({ + required String modelId, + required int audioDurationMs, + required int latencyMs, + String? modelName, + int? wordCount, + double? confidence, + String? language, + bool isStreaming = false, + }) { + // Calculate real-time factor (RTF) - how fast transcription is vs audio length + // RTF < 1 means faster than real-time + final realTimeFactor = audioDurationMs > 0 + ? latencyMs / audioDurationMs + : null; + + // Infer language from model ID if not provided (e.g., "whisper-tiny.en" → "en") + String? detectedLanguage = language; + if (detectedLanguage == null || detectedLanguage.isEmpty) { + // Try to extract language from model ID (e.g., ".en", "-en", "_en") + final langMatch = RegExp(r'[._-](en|zh|de|fr|es|ja|ko|ru|pt|it|nl|pl|ar|tr|sv|da|no|fi|cs|el|he|hu|id|ms|ro|th|uk|vi)$', caseSensitive: false).firstMatch(modelId); + if (langMatch != null) { + detectedLanguage = langMatch.group(1)?.toLowerCase(); + } + } + + // Preserve original confidence value - don't fabricate estimates + // Track source to let analytics distinguish model-provided vs unknown confidence + final double? effectiveConfidence = confidence; + // 'model' = model returned a value (including 0.0), 'unknown' = null/not provided + final String confidenceSource = confidence != null ? 'model' : 'unknown'; + + track( + 'transcription_completed', + category: TelemetryCategory.stt, + properties: { + 'model_id': modelId, + 'model_name': modelName, + 'audio_duration_ms': audioDurationMs, + 'latency_ms': latencyMs, + 'word_count': wordCount, + 'confidence': effectiveConfidence, + 'confidence_source': confidenceSource, + 'language': detectedLanguage, + 'real_time_factor': realTimeFactor, + 'is_streaming': isStreaming, + }, + ); + } + + /// Track speech synthesis + void trackSynthesis({ + required String voiceId, + required int textLength, + required int audioDurationMs, + required int latencyMs, + String? modelName, + int? sampleRate, + int? audioSizeBytes, + }) { + // Calculate characters per second + final charactersPerSecond = latencyMs > 0 + ? (textLength / latencyMs) * 1000 + : null; + + track( + 'synthesis_completed', + category: TelemetryCategory.tts, + properties: { + 'model_id': voiceId, // Use voice ID as model ID for TTS + 'voice_id': voiceId, + 'model_name': modelName, + 'text_length': textLength, + 'character_count': textLength, // Alias for backend compatibility + 'audio_duration_ms': audioDurationMs, + 'output_duration_ms': audioDurationMs, // Alias for TTS backend field + 'latency_ms': latencyMs, + 'sample_rate': sampleRate, + 'characters_per_second': charactersPerSecond, + 'audio_size_bytes': audioSizeBytes, + }, + ); + } + + /// Track VAD event + void trackVAD({ + required String eventType, + Map? properties, + }) { + track( + 'vad_$eventType', + category: TelemetryCategory.vad, + properties: properties, + ); + } + + /// Track voice agent turn + void trackVoiceAgentTurn({ + required String transcription, + required String response, + required int totalLatencyMs, + int? sttLatencyMs, + int? llmLatencyMs, + int? ttsLatencyMs, + }) { + track( + 'voice_turn_completed', + category: TelemetryCategory.voiceAgent, + properties: { + 'transcription_length': transcription.length, + 'response_length': response.length, + 'total_latency_ms': totalLatencyMs, + if (sttLatencyMs != null) 'stt_latency_ms': sttLatencyMs, + if (llmLatencyMs != null) 'llm_latency_ms': llmLatencyMs, + if (ttsLatencyMs != null) 'tts_latency_ms': ttsLatencyMs, + }, + ); + } + + /// Track error + void trackError({ + required String errorCode, + required String errorMessage, + Map? context, + }) { + track( + 'error', + category: TelemetryCategory.error, + properties: { + 'error_code': errorCode, + 'error_message': errorMessage, + if (context != null) ...context, + }, + ); + } + + // ============================================================================ + // Private Methods + // ============================================================================ + + Map _enrichProperties(Map? properties) { + return { + 'device_id': _deviceId, + 'sdk_version': SDKConstants.version, + 'platform': SDKConstants.platform, + if (properties != null) ...properties, + }; + } + + String _getTelemetryEndpoint() { + switch (_environment) { + case SDKEnvironment.development: + return '/rest/v1/telemetry_events'; + case SDKEnvironment.staging: + case SDKEnvironment.production: + return '/api/v1/sdk/telemetry'; + } + } + + void _startFlushTimer() { + _stopFlushTimer(); + _flushTimer = Timer.periodic( + const Duration(seconds: _flushIntervalSeconds), + (_) => flush(), + ); + } + + void _stopFlushTimer() { + _flushTimer?.cancel(); + _flushTimer = null; + } + + /// Reset for testing + static void resetForTesting() { + _instance?._stopFlushTimer(); + _instance = null; + } +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/features/llm/structured_output/generatable.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/features/llm/structured_output/generatable.dart new file mode 100644 index 000000000..bcf5f47c0 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/features/llm/structured_output/generatable.dart @@ -0,0 +1,22 @@ +/// Protocol for types that can be generated as structured output from LLMs +/// Matches iOS Generatable from Features/LLM/StructuredOutput/Generatable.swift +abstract class Generatable { + /// The JSON schema for this type + static String get jsonSchema => ''' +{ + "type": "object", + "additionalProperties": false +} +'''; + + /// Convert from JSON map + factory Generatable.fromJson(Map json) { + throw UnimplementedError('Subclasses must implement fromJson'); + } + + /// Convert to JSON map + Map toJson(); +} + +// Note: StructuredOutputConfig is now defined in structured_output_handler.dart +// to avoid duplication and maintain iOS parity with a more complete implementation diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/features/llm/structured_output/generation_hints.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/features/llm/structured_output/generation_hints.dart new file mode 100644 index 000000000..f6c1e354d --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/features/llm/structured_output/generation_hints.dart @@ -0,0 +1,46 @@ +/// Hints for structured output generation +/// Matches iOS GenerationHints from Features/LLM/StructuredOutput/GenerationHints.swift +class GenerationHints { + /// Temperature for generation (0.0 - 1.0) + final double temperature; + + /// Maximum tokens to generate + final int? maxTokens; + + /// Top-p sampling + final double? topP; + + /// Top-k sampling + final int? topK; + + /// Whether to stop at first valid JSON + final bool stopAtFirstValidJSON; + + /// Whether to include reasoning/thinking + final bool includeReasoning; + + const GenerationHints({ + this.temperature = 0.7, + this.maxTokens, + this.topP, + this.topK, + this.stopAtFirstValidJSON = true, + this.includeReasoning = false, + }); + + /// Create hints optimized for JSON output + factory GenerationHints.forJSON() { + return const GenerationHints( + temperature: 0.3, + stopAtFirstValidJSON: true, + ); + } + + /// Create hints for creative output + factory GenerationHints.forCreative() { + return const GenerationHints( + temperature: 0.9, + topP: 0.95, + ); + } +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/features/llm/structured_output/stream_accumulator.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/features/llm/structured_output/stream_accumulator.dart new file mode 100644 index 000000000..231b2ccf9 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/features/llm/structured_output/stream_accumulator.dart @@ -0,0 +1,37 @@ +import 'package:runanywhere/features/llm/structured_output/stream_token.dart'; + +/// Accumulates tokens from a stream into a complete response +/// Matches iOS StreamAccumulator from Features/LLM/StructuredOutput/StreamAccumulator.swift +class StreamAccumulator { + final StringBuffer _buffer = StringBuffer(); + final List _tokens = []; + bool _isComplete = false; + + /// Get the accumulated text + String get text => _buffer.toString(); + + /// Get all accumulated tokens + List get tokens => List.unmodifiable(_tokens); + + /// Whether the stream is complete + bool get isComplete => _isComplete; + + /// Add a token to the accumulator + void addToken(StreamToken token) { + _tokens.add(token); + _buffer.write(token.text); + if (token.isFinal) { + _isComplete = true; + } + } + + /// Clear the accumulator + void reset() { + _buffer.clear(); + _tokens.clear(); + _isComplete = false; + } + + /// Get token count + int get tokenCount => _tokens.length; +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/features/llm/structured_output/stream_token.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/features/llm/structured_output/stream_token.dart new file mode 100644 index 000000000..8c2eef2c2 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/features/llm/structured_output/stream_token.dart @@ -0,0 +1,21 @@ +/// Token from structured output stream +/// Matches iOS StreamToken from Features/LLM/StructuredOutput/StreamToken.swift +class StreamToken { + /// The token text + final String text; + + /// Whether this is the final token + final bool isFinal; + + /// Token index in the stream + final int? index; + + const StreamToken({ + required this.text, + this.isFinal = false, + this.index, + }); + + @override + String toString() => 'StreamToken(text: "$text", isFinal: $isFinal)'; +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/features/llm/structured_output/structured_output.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/features/llm/structured_output/structured_output.dart new file mode 100644 index 000000000..bfc148ca7 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/features/llm/structured_output/structured_output.dart @@ -0,0 +1,9 @@ +/// Structured output feature barrel export +/// Matches iOS StructuredOutput module structure +library structured_output; + +export 'generatable.dart'; +export 'generation_hints.dart'; +export 'stream_accumulator.dart'; +export 'stream_token.dart'; +export 'structured_output_handler.dart'; diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/features/llm/structured_output/structured_output_handler.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/features/llm/structured_output/structured_output_handler.dart new file mode 100644 index 000000000..3de699c18 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/features/llm/structured_output/structured_output_handler.dart @@ -0,0 +1,328 @@ +import 'dart:async'; + +import 'dart:convert'; +import 'package:runanywhere/foundation/logging/sdk_logger.dart'; + +/// Handles structured output generation and validation +/// Matches iOS StructuredOutputHandler from Features/LLM/StructuredOutput/StructuredOutputHandler.swift +class StructuredOutputHandler { + final SDKLogger _logger = SDKLogger('StructuredOutputHandler'); + + StructuredOutputHandler(); + + /// Get system prompt for structured output generation + String getSystemPrompt(String schema) { + return ''' +You are a JSON generator that outputs ONLY valid JSON without any additional text. + +CRITICAL RULES: +1. Your entire response must be valid JSON that can be parsed +2. Start with { and end with } +3. No text before the opening { +4. No text after the closing } +5. Follow the provided schema exactly +6. Include all required fields +7. Use proper JSON syntax (quotes, commas, etc.) + +Expected JSON Schema: +$schema + +Remember: Output ONLY the JSON object, nothing else. +'''; + } + + /// Build user prompt for structured output (simplified without instructions) + String buildUserPrompt(String content) { + // Return clean user prompt without JSON instructions + // The instructions are now in the system prompt + return content; + } + + /// Prepare prompt with structured output instructions + String preparePrompt({ + required String originalPrompt, + required StructuredOutputConfig config, + }) { + if (!config.includeSchemaInPrompt) { + return originalPrompt; + } + + final instructions = ''' +CRITICAL INSTRUCTION: You MUST respond with ONLY a valid JSON object. No other text is allowed. + +JSON Schema: +${config.schema} + +RULES: +1. Start your response with { and end with } +2. Include NO text before the opening { +3. Include NO text after the closing } +4. Follow the schema exactly +5. All required fields must be present +6. Use exact field names from the schema +7. Ensure proper JSON syntax (quotes, commas, etc.) + +IMPORTANT: Your entire response must be valid JSON that can be parsed. Do not include any explanations, comments, or additional text. +'''; + + return ''' +System: You are a JSON generator. You must output only valid JSON. + +$originalPrompt + +$instructions + +Remember: Output ONLY the JSON object, nothing else. +'''; + } + + /// Parse and validate structured output from generated text + T parseStructuredOutput( + String text, T Function(Map) fromJson) { + // Extract JSON from the response + final jsonString = extractJSON(text); + + // Parse JSON + final jsonData = jsonDecode(jsonString); + + if (jsonData is! Map) { + throw StructuredOutputError.validationFailed( + 'Expected JSON object, got ${jsonData.runtimeType}', + ); + } + + return fromJson(jsonData); + } + + /// Extract JSON from potentially mixed text + String extractJSON(String text) { + final trimmed = text.trim(); + + // First, try to find a complete JSON object + final completeJson = _findCompleteJSON(trimmed); + if (completeJson != null) { + return completeJson; + } + + // Fallback: Try to find JSON object boundaries + final startIndex = trimmed.indexOf('{'); + final endIndex = trimmed.lastIndexOf('}'); + + if (startIndex != -1 && endIndex != -1 && startIndex < endIndex) { + final jsonSubstring = trimmed.substring(startIndex, endIndex + 1); + try { + jsonDecode(jsonSubstring); + return jsonSubstring; + } catch (_) { + // Not valid JSON, continue to other methods + } + } + + // Try to find JSON array boundaries + final arrayStartIndex = trimmed.indexOf('['); + final arrayEndIndex = trimmed.lastIndexOf(']'); + + if (arrayStartIndex != -1 && + arrayEndIndex != -1 && + arrayStartIndex < arrayEndIndex) { + final jsonSubstring = + trimmed.substring(arrayStartIndex, arrayEndIndex + 1); + try { + jsonDecode(jsonSubstring); + return jsonSubstring; + } catch (_) { + // Not valid JSON + } + } + + // If no clear JSON boundaries, check if the entire text might be JSON + if (trimmed.startsWith('{') || trimmed.startsWith('[')) { + try { + jsonDecode(trimmed); + return trimmed; + } catch (_) { + // Not valid JSON + } + } + + // Log the text that couldn't be parsed + _logger.error( + 'Failed to extract JSON from text: ${trimmed.length > 200 ? trimmed.substring(0, 200) : trimmed}...'); + throw StructuredOutputError.extractionFailed( + 'No valid JSON found in the response'); + } + + /// Find a complete JSON object or array in the text + String? _findCompleteJSON(String text) { + for (final startChar in ['{', '[']) { + final startIndex = text.indexOf(startChar); + if (startIndex == -1) continue; + + final endChar = startChar == '{' ? '}' : ']'; + final match = _findMatchingBrace(text, startIndex, startChar, endChar); + if (match != null) { + final jsonSubstring = text.substring(match.start, match.end); + try { + jsonDecode(jsonSubstring); + return jsonSubstring; + } catch (_) { + // Not valid JSON, continue + } + } + } + return null; + } + + /// Find matching closing brace/bracket + _BraceMatch? _findMatchingBrace( + String text, int startIndex, String startChar, String endChar) { + int depth = 0; + bool inString = false; + bool escaped = false; + + for (int i = startIndex; i < text.length; i++) { + final char = text[i]; + + if (escaped) { + escaped = false; + continue; + } + + if (char == '\\') { + escaped = true; + continue; + } + + if (char == '"' && !escaped) { + inString = !inString; + continue; + } + + if (!inString) { + if (char == startChar) { + depth++; + } else if (char == endChar) { + depth--; + if (depth == 0) { + return _BraceMatch(start: startIndex, end: i + 1); + } + } + } + } + return null; + } + + /// Validate that generated text contains valid structured output + StructuredOutputValidation validateStructuredOutput({ + required String text, + required StructuredOutputConfig config, + }) { + try { + final jsonString = extractJSON(text); + jsonDecode(jsonString); + return const StructuredOutputValidation( + isValid: true, + containsJSON: true, + error: null, + ); + } catch (e) { + return StructuredOutputValidation( + isValid: false, + containsJSON: false, + error: e.toString(), + ); + } + } +} + +/// Brace match result +class _BraceMatch { + final int start; + final int end; + _BraceMatch({required this.start, required this.end}); +} + +/// Structured output validation result +/// Matches iOS StructuredOutputValidation +class StructuredOutputValidation { + final bool isValid; + final bool containsJSON; + final String? error; + + const StructuredOutputValidation({ + required this.isValid, + required this.containsJSON, + this.error, + }); +} + +/// Structured output errors +/// Matches iOS StructuredOutputError +class StructuredOutputError implements Exception { + final String message; + + StructuredOutputError(this.message); + + factory StructuredOutputError.invalidJSON(String detail) { + return StructuredOutputError('Invalid JSON: $detail'); + } + + factory StructuredOutputError.validationFailed(String detail) { + return StructuredOutputError('Validation failed: $detail'); + } + + factory StructuredOutputError.extractionFailed(String detail) { + return StructuredOutputError( + 'Failed to extract structured output: $detail'); + } + + factory StructuredOutputError.unsupportedType(String type) { + return StructuredOutputError( + 'Unsupported type for structured output: $type'); + } + + @override + String toString() => message; +} + +/// Configuration for structured output generation +/// Matches iOS StructuredOutputConfig from Features/LLM/StructuredOutput/ +class StructuredOutputConfig { + /// The type being generated + final Type type; + + /// JSON schema describing the expected output + final String schema; + + /// Whether to include schema instructions in the prompt + final bool includeSchemaInPrompt; + + /// Name for the structured output (optional) + final String? name; + + /// Whether to enforce strict schema validation + final bool strict; + + const StructuredOutputConfig({ + required this.type, + required this.schema, + this.includeSchemaInPrompt = true, + this.name, + this.strict = false, + }); +} + +/// Result container for streaming structured output +/// Matches iOS StructuredOutputStreamResult from Features/LLM/StructuredOutput/ +class StructuredOutputStreamResult { + /// Stream of individual tokens as they are generated + final Stream stream; + + /// Future that resolves to the final parsed object + final Future result; + + const StructuredOutputStreamResult({ + required this.stream, + required this.result, + }); +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/features/stt/services/audio_capture_manager.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/features/stt/services/audio_capture_manager.dart new file mode 100644 index 000000000..8c8833589 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/features/stt/services/audio_capture_manager.dart @@ -0,0 +1,339 @@ +import 'dart:async'; +import 'dart:typed_data'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:record/record.dart'; +import 'package:runanywhere/foundation/logging/sdk_logger.dart'; + +/// Manages audio capture from microphone for STT services. +/// +/// This is a shared utility that works with any STT backend (ONNX, WhisperKit, etc.). +/// It captures audio at 16kHz mono Int16 format, which is the standard input format +/// for speech recognition models like Whisper. +/// +/// Matches iOS AudioCaptureManager from Features/STT/Services/AudioCaptureManager.swift +/// +/// ## Usage +/// ```dart +/// final capture = AudioCaptureManager(); +/// final granted = await capture.requestPermission(); +/// if (granted) { +/// await capture.startRecording((audioData) { +/// // Feed audioData to your STT service +/// }); +/// } +/// ``` +class AudioCaptureManager { + final SDKLogger _logger = SDKLogger('AudioCapture'); + + /// Audio recorder instance + final AudioRecorder _recorder = AudioRecorder(); + + /// Whether audio is currently being recorded + bool _isRecording = false; + + /// Current audio level (0.0 to 1.0) + double _audioLevel = 0.0; + + /// Target sample rate for Whisper models + static const int targetSampleRate = 16000; + + /// Audio data callback + void Function(Uint8List audioData)? _onAudioData; + + /// Audio stream subscription + StreamSubscription? _audioStreamSubscription; + + /// Stream controller for recording state changes + final _recordingStateController = StreamController.broadcast(); + + /// Stream controller for audio level updates + final _audioLevelController = StreamController.broadcast(); + + /// Stream of recording state changes + Stream get recordingStateStream => _recordingStateController.stream; + + /// Stream of audio level updates (0.0 to 1.0) + Stream get audioLevelStream => _audioLevelController.stream; + + /// Whether audio is currently being recorded + bool get isRecording => _isRecording; + + /// Current audio level (0.0 to 1.0) + double get audioLevel => _audioLevel; + + AudioCaptureManager() { + _logger.info('AudioCaptureManager initialized'); + } + + /// Request microphone permission + /// + /// Returns true if permission was granted, false otherwise. + Future requestPermission() async { + try { + _logger.info('Requesting microphone permission'); + + // Check and request microphone permission + final status = await Permission.microphone.request(); + if (status.isGranted) { + _logger.info('Microphone permission granted'); + return true; + } else if (status.isPermanentlyDenied) { + _logger.warning( + 'Microphone permission permanently denied - user should enable in settings'); + return false; + } else { + _logger.warning('Microphone permission denied: $status'); + return false; + } + } catch (e) { + _logger.error('Failed to request microphone permission: $e'); + return false; + } + } + + /// Start recording audio from microphone + /// + /// [onAudioData] Callback for audio data chunks (16kHz mono Int16 PCM) + /// + /// Throws [AudioCaptureError] if recording cannot be started. + Future startRecording( + void Function(Uint8List audioData) onAudioData) async { + if (_isRecording) { + _logger.warning('Already recording'); + return; + } + + try { + _onAudioData = onAudioData; + + // Check if we can record + _logger.info('Checking microphone permission...'); + if (!await _recorder.hasPermission()) { + _logger.error('No microphone permission'); + throw AudioCaptureError.permissionDenied(); + } + _logger.info('✅ Microphone permission granted'); + + // Start streaming audio in PCM 16-bit format + _logger.info( + 'Starting audio stream: ${targetSampleRate}Hz, mono, PCM16bits...'); + final stream = await _recorder.startStream( + const RecordConfig( + encoder: AudioEncoder.pcm16bits, + sampleRate: targetSampleRate, + numChannels: 1, // Mono + bitRate: 256000, + ), + ); + _logger.info('✅ Audio stream created, setting up listener...'); + + // Track data delivery + int chunkCount = 0; + + // Listen to audio stream + _audioStreamSubscription = stream.listen( + (data) { + chunkCount++; + // Log first few chunks to verify data flow + if (chunkCount <= 3) { + _logger.info( + '🎵 Audio data received: chunk #$chunkCount, ${data.length} bytes'); + } + if (_isRecording && _onAudioData != null) { + _onAudioData!(data); + } else { + _logger.warning( + '⚠️ Audio data ignored: isRecording=$_isRecording, hasCallback=${_onAudioData != null}'); + } + }, + onError: (Object error) { + _logger.error('❌ Audio stream error: $error'); + }, + onDone: () { + _logger.info('🛑 Audio stream ended (total chunks: $chunkCount)'); + }, + ); + + _isRecording = true; + _recordingStateController.add(true); + + _logger.info( + '✅ Recording started successfully (16kHz mono PCM) - waiting for audio data...'); + } catch (e) { + _logger.error('❌ Failed to start recording: $e'); + _onAudioData = null; + throw AudioCaptureError.engineStartFailed(); + } + } + + /// Stop recording + Future stopRecording() async { + if (!_isRecording) return; + + try { + // Cancel stream subscription + await _audioStreamSubscription?.cancel(); + _audioStreamSubscription = null; + + // Stop the recorder + await _recorder.stop(); + + _isRecording = false; + _audioLevel = 0.0; + _onAudioData = null; + + _recordingStateController.add(false); + _audioLevelController.add(0.0); + + _logger.info('Recording stopped'); + } catch (e) { + _logger.error('Error stopping recording: $e'); + } + } + + /// Update audio level for visualization + /// + /// This would be called from platform-specific audio buffer callbacks. + /// Calculates RMS (root mean square) and normalizes to 0-1 range. + /// + /// [buffer] Float audio samples + void updateAudioLevel(Float32List buffer) { + if (buffer.isEmpty) return; + + // Calculate RMS (root mean square) for audio level + double sum = 0.0; + for (final sample in buffer) { + sum += sample * sample; + } + + final rms = _sqrt(sum / buffer.length); + final dbLevel = + 20 * _log10(rms + 0.0001); // Add small value to avoid log(0) + + // Normalize to 0-1 range (-60dB to 0dB) + final normalizedLevel = (dbLevel + 60) / 60; + _audioLevel = normalizedLevel.clamp(0.0, 1.0); + + _audioLevelController.add(_audioLevel); + } + + /// Convert audio buffer to PCM data + /// + /// This would be called from platform-specific audio buffer callbacks + /// to convert audio samples to the format expected by STT models. + /// + /// [buffer] Int16 audio samples (16-bit PCM) + /// Returns PCM data as bytes + Uint8List bufferToData(Int16List buffer) { + // Convert Int16 samples to bytes (little-endian) + final bytes = BytesBuilder(); + for (final sample in buffer) { + bytes.add([ + sample & 0xFF, // Low byte + (sample >> 8) & 0xFF, // High byte + ]); + } + return bytes.toBytes(); + } + + /// Called when audio data is available from the platform + /// + /// This would be called from platform-specific audio buffer callbacks. + /// + /// [audioData] Raw PCM audio data (16kHz mono Int16) + void onAudioDataAvailable(Uint8List audioData) { + final callback = _onAudioData; + if (callback != null && _isRecording) { + callback(audioData); + } + } + + /// Dispose resources + void dispose() { + unawaited(stopRecording()); + unawaited(_recordingStateController.close()); + unawaited(_audioLevelController.close()); + } + + // MARK: - Private Math Helpers + + /// Square root helper + double _sqrt(double x) { + if (x <= 0) return 0.0; + // Use Newton's method for square root approximation + double guess = x / 2; + for (int i = 0; i < 10; i++) { + guess = (guess + x / guess) / 2; + } + return guess; + } + + /// Base-10 logarithm helper + double _log10(double x) { + if (x <= 0) return -60.0; // Return minimum dB level + // Use natural log and convert to log10 + // log10(x) = ln(x) / ln(10) + return _ln(x) / 2.302585092994046; // ln(10) + } + + /// Natural logarithm helper (using Taylor series) + double _ln(double x) { + if (x <= 0) return double.negativeInfinity; + if (x == 1) return 0.0; + + // For better convergence, use ln(x) = 2 * atanh((x-1)/(x+1)) + final y = (x - 1) / (x + 1); + double result = 0.0; + double term = y; + const maxIterations = 20; + + for (int i = 0; i < maxIterations; i++) { + result += term / (2 * i + 1); + term *= y * y; + } + + return 2 * result; + } +} + +// MARK: - Errors + +/// Audio capture errors +/// Matches iOS AudioCaptureError from AudioCaptureManager.swift +class AudioCaptureError implements Exception { + final String message; + final AudioCaptureErrorType type; + + AudioCaptureError._(this.message, this.type); + + factory AudioCaptureError.permissionDenied() { + return AudioCaptureError._( + 'Microphone permission denied', + AudioCaptureErrorType.permissionDenied, + ); + } + + factory AudioCaptureError.formatConversionFailed() { + return AudioCaptureError._( + 'Failed to convert audio format', + AudioCaptureErrorType.formatConversionFailed, + ); + } + + factory AudioCaptureError.engineStartFailed() { + return AudioCaptureError._( + 'Failed to start audio engine', + AudioCaptureErrorType.engineStartFailed, + ); + } + + @override + String toString() => 'AudioCaptureError: $message'; +} + +/// Audio capture error types +enum AudioCaptureErrorType { + permissionDenied, + formatConversionFailed, + engineStartFailed, +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/features/tts/services/audio_playback_manager.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/features/tts/services/audio_playback_manager.dart new file mode 100644 index 000000000..a856f87a3 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/features/tts/services/audio_playback_manager.dart @@ -0,0 +1,396 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:audioplayers/audioplayers.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:runanywhere/foundation/logging/sdk_logger.dart'; + +/// Manages audio playback for TTS services +/// Matches iOS AudioPlaybackManager from Features/TTS/Services/AudioPlaybackManager.swift +/// +/// This is a shared utility that works with any TTS backend. +/// It plays audio data generated by TTS synthesis. +class AudioPlaybackManager { + final SDKLogger _logger = SDKLogger('AudioPlayback'); + + /// Audio player instance + final AudioPlayer _player = AudioPlayer(); + + /// Whether audio is currently playing + bool _isPlaying = false; + + /// Current playback time in seconds + double _currentTime = 0.0; + + /// Total duration of current audio in seconds + double _duration = 0.0; + + /// Playback completion callback + void Function(bool success)? _playbackCompletion; + + /// Completer for async playback + Completer? _playbackCompleter; + + /// Stream subscriptions + StreamSubscription? _playerStateSubscription; + StreamSubscription? _durationSubscription; + StreamSubscription? _positionSubscription; + + /// Stream controller for playback state changes + final _stateController = StreamController.broadcast(); + + /// Track temp files for cleanup + File? _currentTempFile; + + /// Stream of playback state changes + Stream get stateStream => _stateController.stream; + + /// Whether audio is currently playing + bool get isPlaying => _isPlaying; + + /// Current playback time in seconds + double get currentTime => _currentTime; + + /// Total duration of current audio in seconds + double get duration => _duration; + + AudioPlaybackManager() { + _logger.info('AudioPlaybackManager initialized'); + _setupListeners(); + } + + /// Set up audio player listeners + void _setupListeners() { + // Listen to player state changes + _playerStateSubscription = _player.onPlayerStateChanged.listen((state) { + final wasPlaying = _isPlaying; + _isPlaying = state == PlayerState.playing; + + if (wasPlaying != _isPlaying) { + _stateController.add(_isPlaying + ? AudioPlaybackState.playing + : AudioPlaybackState.stopped); + } + + // Handle playback completion + if (state == PlayerState.completed) { + _logger.info('🔊 Playback completed'); + _currentTime = 0.0; + _cleanupPlayback(success: true); + } + }); + + // Listen to duration changes + _durationSubscription = _player.onDurationChanged.listen((duration) { + _duration = duration.inMilliseconds / 1000.0; + _logger.info('🔊 Audio duration: ${_duration.toStringAsFixed(1)}s'); + }); + + // Listen to position changes + _positionSubscription = _player.onPositionChanged.listen((position) { + _currentTime = position.inMilliseconds / 1000.0; + }); + } + + /// Play audio data asynchronously (async/await) + /// [audioData] PCM16 or WAV audio data to play + /// [sampleRate] Sample rate of the audio (default: 22050 for TTS) + /// [numChannels] Number of audio channels (default: 1 for mono) + Future play( + Uint8List audioData, { + int sampleRate = 22050, + int numChannels = 1, + }) async { + if (audioData.isEmpty) { + throw AudioPlaybackError.emptyAudioData(); + } + + final completer = Completer(); + _playbackCompleter = completer; + + try { + await _startPlayback(audioData, + sampleRate: sampleRate, numChannels: numChannels); + await completer.future; + } catch (e) { + _playbackCompleter = null; + rethrow; + } + } + + /// Play audio data with completion callback + void playWithCompletion( + Uint8List audioData, + void Function(bool success) completion, { + int sampleRate = 22050, + int numChannels = 1, + }) { + if (audioData.isEmpty) { + _logger.warning('Empty audio data, skipping playback'); + completion(false); + return; + } + + _playbackCompletion = completion; + + try { + unawaited(_startPlayback(audioData, + sampleRate: sampleRate, numChannels: numChannels)); + } catch (e) { + _logger.error('Failed to start playback: $e'); + _playbackCompletion = null; + completion(false); + } + } + + /// Stop current playback + Future stop() async { + if (!_isPlaying) return; + + await _player.stop(); + _currentTime = 0.0; + _cleanupPlayback(success: false); + _logger.info('Playback stopped by user'); + } + + /// Pause current playback + Future pause() async { + if (!_isPlaying) return; + await _player.pause(); + _stateController.add(AudioPlaybackState.paused); + _logger.info('Playback paused'); + } + + /// Resume paused playback + Future resume() async { + await _player.resume(); + _logger.info('Playback resumed'); + } + + /// Start playback of audio data + Future _startPlayback( + Uint8List audioData, { + required int sampleRate, + required int numChannels, + }) async { + // Stop any existing playback + if (_isPlaying) { + await stop(); + } + + // Clean up previous temp file if it exists + await _cleanupTempFile(); + + _logger.info( + '🔊 Starting playback: ${audioData.length} bytes, ${sampleRate}Hz, ${numChannels}ch'); + + try { + // Create a temporary WAV file + final tempDir = await getTemporaryDirectory(); + final timestamp = DateTime.now().millisecondsSinceEpoch; + final tempFile = File('${tempDir.path}/tts_audio_$timestamp.wav'); + _currentTempFile = tempFile; + + // Check if data is already WAV format (starts with "RIFF" and contains "WAVE") + Uint8List wavData; + if (_isWavFormat(audioData)) { + // Data is already WAV format - use directly + wavData = audioData; + _logger.info('🔊 Audio is already WAV format, using directly'); + } else { + // Convert PCM16 to proper WAV file with headers + wavData = _createWavFile(audioData, sampleRate, numChannels); + _logger.info('🔊 Converted PCM16 to WAV format'); + } + + // Write WAV data to temp file + await tempFile.writeAsBytes(wavData); + _logger.info( + '🔊 Wrote ${wavData.length} bytes to temp file: ${tempFile.path}'); + + // Play the audio file + _isPlaying = true; + _stateController.add(AudioPlaybackState.playing); + + await _player.play(DeviceFileSource(tempFile.path)); + + _logger.info('🔊 Playback started'); + } catch (e) { + _logger.error('❌ Failed to start playback: $e'); + _isPlaying = false; + _stateController.add(AudioPlaybackState.stopped); + _cleanupPlayback(success: false); + rethrow; + } + } + + /// Check if audio data is already in WAV format + bool _isWavFormat(Uint8List data) { + if (data.length < 12) return false; + // Check for RIFF header (bytes 0-3) and WAVE format (bytes 8-11) + return data[0] == 0x52 && // 'R' + data[1] == 0x49 && // 'I' + data[2] == 0x46 && // 'F' + data[3] == 0x46 && // 'F' + data[8] == 0x57 && // 'W' + data[9] == 0x41 && // 'A' + data[10] == 0x56 && // 'V' + data[11] == 0x45; // 'E' + } + + /// Create a proper WAV file from PCM16 data + /// Returns WAV file bytes with proper headers + Uint8List _createWavFile( + Uint8List pcm16Data, int sampleRate, int numChannels) { + final int byteRate = + sampleRate * numChannels * 2; // 2 bytes per sample (16-bit) + final int blockAlign = numChannels * 2; + final int dataSize = pcm16Data.length; + final int fileSize = 36 + dataSize; // 44 byte header - 8 + data size + + final ByteData header = ByteData(44); + + // RIFF header + header.setUint8(0, 0x52); // 'R' + header.setUint8(1, 0x49); // 'I' + header.setUint8(2, 0x46); // 'F' + header.setUint8(3, 0x46); // 'F' + header.setUint32(4, fileSize, Endian.little); // File size - 8 + + // WAVE header + header.setUint8(8, 0x57); // 'W' + header.setUint8(9, 0x41); // 'A' + header.setUint8(10, 0x56); // 'V' + header.setUint8(11, 0x45); // 'E' + + // fmt subchunk + header.setUint8(12, 0x66); // 'f' + header.setUint8(13, 0x6D); // 'm' + header.setUint8(14, 0x74); // 't' + header.setUint8(15, 0x20); // ' ' + header.setUint32(16, 16, Endian.little); // Subchunk1Size (16 for PCM) + header.setUint16(20, 1, Endian.little); // AudioFormat (1 for PCM) + header.setUint16(22, numChannels, Endian.little); // NumChannels + header.setUint32(24, sampleRate, Endian.little); // SampleRate + header.setUint32(28, byteRate, Endian.little); // ByteRate + header.setUint16(32, blockAlign, Endian.little); // BlockAlign + header.setUint16(34, 16, Endian.little); // BitsPerSample + + // data subchunk + header.setUint8(36, 0x64); // 'd' + header.setUint8(37, 0x61); // 'a' + header.setUint8(38, 0x74); // 't' + header.setUint8(39, 0x61); // 'a' + header.setUint32(40, dataSize, Endian.little); // Subchunk2Size + + // Combine header and PCM data + final wavFile = Uint8List(44 + dataSize); + wavFile.setAll(0, header.buffer.asUint8List()); + wavFile.setAll(44, pcm16Data); + + return wavFile; + } + + /// Clean up after playback + void _cleanupPlayback({required bool success}) { + _isPlaying = false; + _currentTime = 0.0; + _stateController.add(AudioPlaybackState.stopped); + + // Complete async playback if present + final completer = _playbackCompleter; + if (completer != null && !completer.isCompleted) { + _playbackCompleter = null; + if (success) { + completer.complete(); + } else { + completer.completeError(AudioPlaybackError.playbackInterrupted()); + } + } + + // Call completion handler if present + final completion = _playbackCompletion; + if (completion != null) { + _playbackCompletion = null; + completion(success); + } + + // Clean up temp file after a small delay to ensure player has released it + unawaited( + Future.delayed(const Duration(milliseconds: 100), _cleanupTempFile)); + } + + /// Clean up temporary audio file + Future _cleanupTempFile() async { + if (_currentTempFile != null) { + try { + if (await _currentTempFile!.exists()) { + await _currentTempFile!.delete(); + _logger.info('🗑️ Cleaned up temp audio file'); + } + } catch (e) { + _logger.warning('⚠️ Failed to cleanup temp file: $e'); + } + _currentTempFile = null; + } + } + + /// Called when playback finishes naturally + void onPlaybackComplete(bool success) { + _logger.info('Playback finished: ${success ? "success" : "failed"}'); + _cleanupPlayback(success: success); + } + + /// Called when a decode error occurs + void onDecodeError(Object? error) { + _logger.error('Playback decode error: ${error ?? "unknown"}'); + _cleanupPlayback(success: false); + } + + /// Dispose resources + Future dispose() async { + await stop(); + await _playerStateSubscription?.cancel(); + await _durationSubscription?.cancel(); + await _positionSubscription?.cancel(); + unawaited(_stateController.close()); + await _player.dispose(); + await _cleanupTempFile(); + _logger.info('AudioPlaybackManager disposed'); + } +} + +/// Audio playback state +enum AudioPlaybackState { + stopped, + playing, + paused, +} + +/// Audio playback errors +/// Matches iOS AudioPlaybackError +class AudioPlaybackError implements Exception { + final String message; + + AudioPlaybackError(this.message); + + factory AudioPlaybackError.emptyAudioData() { + return AudioPlaybackError('Audio data is empty'); + } + + factory AudioPlaybackError.playbackFailed() { + return AudioPlaybackError('Failed to start audio playback'); + } + + factory AudioPlaybackError.playbackInterrupted() { + return AudioPlaybackError('Audio playback was interrupted'); + } + + factory AudioPlaybackError.invalidAudioFormat() { + return AudioPlaybackError('Invalid audio format'); + } + + @override + String toString() => 'AudioPlaybackError: $message'; +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/features/tts/system_tts_service.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/features/tts/system_tts_service.dart new file mode 100644 index 000000000..c0087f2e5 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/features/tts/system_tts_service.dart @@ -0,0 +1,244 @@ +/// System TTS Service +/// +/// Implementation using flutter_tts for platform Text-to-Speech. +/// Matches iOS SystemTTSService from Features/TTS/System/. +library system_tts_service; + +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter_tts/flutter_tts.dart'; + +/// Configuration for TTS synthesis +class TTSConfiguration { + final String voice; + final String language; + final double speakingRate; + final double pitch; + final double volume; + final String audioFormat; + + const TTSConfiguration({ + this.voice = 'system', + this.language = 'en-US', + this.speakingRate = 0.5, + this.pitch = 1.0, + this.volume = 1.0, + this.audioFormat = 'pcm', + }); +} + +/// Input for TTS synthesis +class TTSSynthesisInput { + final String? text; + final String? ssml; + final String? voiceId; + final String? language; + + const TTSSynthesisInput({ + this.text, + this.ssml, + this.voiceId, + this.language, + }); +} + +/// Voice information +class TTSVoice { + final String id; + final String name; + final String language; + + const TTSVoice({ + required this.id, + required this.name, + required this.language, + }); +} + +/// Synthesis metadata +class SynthesisMetadata { + final String voice; + final String language; + final double processingTime; + final int characterCount; + + const SynthesisMetadata({ + required this.voice, + required this.language, + required this.processingTime, + required this.characterCount, + }); +} + +/// Extended TTS output +class TTSSynthesisOutput { + final Uint8List audioData; + final String format; + final double duration; + final SynthesisMetadata metadata; + + const TTSSynthesisOutput({ + required this.audioData, + required this.format, + required this.duration, + required this.metadata, + }); +} + +/// Basic TTS input (simplified interface) +class TTSInput { + final String text; + final String? voiceId; + final double rate; + final double pitch; + + const TTSInput({ + required this.text, + this.voiceId, + this.rate = 1.0, + this.pitch = 1.0, + }); +} + +/// Basic TTS output (simplified interface) +class TTSOutput { + final List audioData; + final String format; + final int sampleRate; + + const TTSOutput({ + required this.audioData, + this.format = 'pcm', + this.sampleRate = 22050, + }); +} + +/// System TTS Service implementation using flutter_tts +/// Matches iOS SystemTTSService from TTSComponent.swift +class SystemTTSService { + final FlutterTts _flutterTts = FlutterTts(); + List _availableVoicesList = []; + TTSConfiguration? _configuration; + bool _isSynthesizing = false; + + SystemTTSService(); + + String get inferenceFramework => 'system'; + + bool get isReady => _configuration != null; + + bool get isSynthesizing => _isSynthesizing; + + List get availableVoices => + _availableVoicesList.map((v) => v.id).toList(); + + Future initialize({String? modelPath}) async { + _configuration = const TTSConfiguration(); + + // Configure TTS engine + await _flutterTts.setSharedInstance(true); + + // Get available voices + final voices = await _flutterTts.getVoices; + if (voices is List) { + _availableVoicesList = voices + .map((v) { + if (v is Map) { + final locale = + v['locale']?.toString() ?? v['name']?.toString() ?? 'en-US'; + final name = v['name']?.toString() ?? 'System Voice'; + return TTSVoice( + id: locale, + name: name, + language: locale, + ); + } + return null; + }) + .whereType() + .toList(); + } + + // Set up completion handlers + _flutterTts.setCompletionHandler(() { + _isSynthesizing = false; + }); + + _flutterTts.setErrorHandler((msg) { + _isSynthesizing = false; + }); + + _flutterTts.setStartHandler(() { + _isSynthesizing = true; + }); + } + + Future synthesize(TTSInput input) async { + if (_configuration == null) { + throw StateError('SystemTTSService not initialized'); + } + + final completer = Completer(); + // Note: startTime could be used for telemetry/metrics in the future + + // Set up completion handlers for this synthesis + _flutterTts.setCompletionHandler(() { + if (!completer.isCompleted) completer.complete(); + }); + + _flutterTts.setErrorHandler((msg) { + if (!completer.isCompleted) completer.complete(); + }); + + // Get text to synthesize + final text = input.text; + + // Configure voice + final voice = input.voiceId ?? _configuration!.voice; + final language = _configuration!.language; + + if (voice != 'system') { + await _flutterTts.setVoice({ + 'name': voice, + 'locale': language, + }); + } else { + await _flutterTts.setLanguage(language); + } + + // Configure speech parameters + await _flutterTts.setSpeechRate(_configuration!.speakingRate); + await _flutterTts.setPitch(_configuration!.pitch); + await _flutterTts.setVolume(_configuration!.volume); + + // Speak the text + await _flutterTts.speak(text); + + // Wait for synthesis to complete + await completer.future; + + // Note: flutter_tts doesn't provide direct audio data access + // It plays audio directly through the system + return TTSOutput( + audioData: const [], + format: _configuration!.audioFormat, + sampleRate: 22050, + ); + } + + Future stop() async { + await _flutterTts.stop(); + _isSynthesizing = false; + } + + Future> getAvailableVoices() async { + return _availableVoicesList; + } + + Future cleanup() async { + await _flutterTts.stop(); + _isSynthesizing = false; + _configuration = null; + } +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/features/vad/simple_energy_vad.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/features/vad/simple_energy_vad.dart new file mode 100644 index 000000000..f050a9012 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/features/vad/simple_energy_vad.dart @@ -0,0 +1,400 @@ +/// Simple Energy VAD +/// +/// Simple energy-based Voice Activity Detection. +/// Based on iOS WhisperKit's EnergyVAD implementation. +library simple_energy_vad; + +import 'dart:async'; +import 'dart:math' as math; + +import 'package:runanywhere/foundation/logging/sdk_logger.dart'; + +/// Speech activity events +enum SpeechActivityEvent { + started, + ended, +} + +/// Result of Voice Activity Detection +class VADResult { + final bool isSpeech; + final double confidence; + final double startTime; + final double endTime; + + const VADResult({ + required this.isSpeech, + required this.confidence, + this.startTime = 0, + this.endTime = 0, + }); +} + +/// Simple energy-based Voice Activity Detection +/// Based on iOS WhisperKit's EnergyVAD implementation but simplified for real-time audio processing +class SimpleEnergyVAD { + final SDKLogger _logger = SDKLogger('SimpleEnergyVAD'); + + /// Energy threshold for voice activity detection (0.0 to 1.0) + double energyThreshold = 0.005; + + /// Base threshold before any adjustments + double _baseEnergyThreshold = 0.005; + + /// Multiplier applied during TTS playback to prevent feedback + final double _ttsThresholdMultiplier = 3.0; + + /// Sample rate of the audio (typically 16000 Hz) + final int sampleRate; + + /// Length of each analysis frame in samples + final int frameLengthSamples; + + /// Frame length in seconds + double get frameLength => frameLengthSamples / sampleRate; + + /// Speech activity callback + void Function(SpeechActivityEvent)? onSpeechActivity; + + /// Optional callback for processed audio buffers + void Function(List)? onAudioBuffer; + + // State tracking + bool _isActive = false; + bool _isCurrentlySpeaking = false; + int _consecutiveSilentFrames = 0; + int _consecutiveVoiceFrames = 0; + bool _isPaused = false; + bool _isTTSActive = false; + + // Hysteresis parameters + final int _voiceStartThreshold = 1; + final int _voiceEndThreshold = 8; + final int _ttsVoiceStartThreshold = 10; + final int _ttsVoiceEndThreshold = 5; + + // Calibration properties + bool _isCalibrating = false; + final List _calibrationSamples = []; + int _calibrationFrameCount = 0; + final int _calibrationFramesNeeded = 20; + double _ambientNoiseLevel = 0.0; + final double _calibrationMultiplier = 2.5; + + // Debug statistics + final List _recentEnergyValues = []; + final int _maxRecentValues = 50; + int _debugFrameCount = 0; + double _lastEnergyLevel = 0.0; + + /// Initialize the VAD with specified parameters + SimpleEnergyVAD({ + this.sampleRate = 16000, + double frameLength = 0.1, + this.energyThreshold = 0.005, + }) : frameLengthSamples = (frameLength * sampleRate).toInt() { + _logger.info( + 'SimpleEnergyVAD initialized - sampleRate: $sampleRate, frameLength: $frameLengthSamples samples, threshold: $energyThreshold', + ); + } + + Future initialize({String? modelPath}) async { + start(); + await startCalibration(); + } + + bool get isReady => _isActive; + + Future process(List audioData) async { + processAudioBuffer(audioData); + final confidence = _calculateConfidence(_lastEnergyLevel); + return VADResult( + isSpeech: _isCurrentlySpeaking, + confidence: confidence, + ); + } + + Future cleanup() async { + stop(); + _recentEnergyValues.clear(); + _calibrationSamples.clear(); + } + + /// Current speech activity state + bool get isSpeechActive => _isCurrentlySpeaking; + + /// Reset the VAD state + void reset() { + stop(); + _isCurrentlySpeaking = false; + _consecutiveSilentFrames = 0; + _consecutiveVoiceFrames = 0; + } + + /// Start voice activity detection + void start() { + if (_isActive) return; + + _isActive = true; + _isCurrentlySpeaking = false; + _consecutiveSilentFrames = 0; + _consecutiveVoiceFrames = 0; + + _logger.info('SimpleEnergyVAD started'); + } + + /// Stop voice activity detection + void stop() { + if (!_isActive) return; + + if (_isCurrentlySpeaking) { + _isCurrentlySpeaking = false; + _logger.info('🎙️ VAD: SPEECH ENDED (stopped)'); + onSpeechActivity?.call(SpeechActivityEvent.ended); + } + + _isActive = false; + _consecutiveSilentFrames = 0; + _consecutiveVoiceFrames = 0; + + _logger.info('SimpleEnergyVAD stopped'); + } + + /// Process an audio buffer for voice activity detection + void processAudioBuffer(List buffer) { + if (!_isActive) return; + if (_isTTSActive) return; + if (_isPaused) return; + if (buffer.isEmpty) return; + + final audioData = _convertPCMToFloat(buffer); + final energy = _calculateAverageEnergy(audioData); + _lastEnergyLevel = energy; + + _updateDebugStatistics(energy); + + if (_isCalibrating) { + _handleCalibrationFrame(energy); + return; + } + + final hasVoice = energy > energyThreshold; + + if (_debugFrameCount % 10 == 0) { + final avgRecent = _recentEnergyValues.isEmpty + ? 0.0 + : _recentEnergyValues.reduce((a, b) => a + b) / + _recentEnergyValues.length; + final maxRecent = _recentEnergyValues.isEmpty + ? 0.0 + : _recentEnergyValues.reduce(math.max); + + _logger.info( + '📊 VAD Stats - Current: ${energy.toStringAsFixed(6)} | ' + 'Threshold: ${energyThreshold.toStringAsFixed(6)} | ' + 'Voice: ${hasVoice ? "✅" : "❌"} | ' + 'Avg: ${avgRecent.toStringAsFixed(6)} | ' + 'Max: ${maxRecent.toStringAsFixed(6)}', + ); + } + _debugFrameCount++; + + _updateVoiceActivityState(hasVoice); + onAudioBuffer?.call(buffer); + } + + /// Calculate the RMS energy of an audio signal + double _calculateAverageEnergy(List signal) { + if (signal.isEmpty) return 0.0; + + double sumSquares = 0.0; + for (final sample in signal) { + sumSquares += sample * sample; + } + + return math.sqrt(sumSquares / signal.length); + } + + /// Calculate confidence value (0.0 to 1.0) + double _calculateConfidence(double energyLevel) { + if (energyThreshold == 0.0) return 0.0; + + final ratio = energyLevel / energyThreshold; + + if (ratio < 0.5) { + return ratio * 0.6; + } else if (ratio < 2.0) { + return 0.3 + (ratio - 0.5) * 0.267; + } else { + final normalized = math.min((ratio - 2.0) / 3.0, 1.0); + return 0.7 + normalized * 0.3; + } + } + + /// Update voice activity state with hysteresis + void _updateVoiceActivityState(bool hasVoice) { + final startThreshold = + _isTTSActive ? _ttsVoiceStartThreshold : _voiceStartThreshold; + final endThreshold = + _isTTSActive ? _ttsVoiceEndThreshold : _voiceEndThreshold; + + if (hasVoice) { + _consecutiveVoiceFrames++; + _consecutiveSilentFrames = 0; + + if (!_isCurrentlySpeaking && _consecutiveVoiceFrames >= startThreshold) { + if (_isTTSActive) { + _logger.warning('⚠️ Voice detected during TTS - ignoring.'); + return; + } + + _isCurrentlySpeaking = true; + _logger.info('🎙️ VAD: SPEECH STARTED'); + onSpeechActivity?.call(SpeechActivityEvent.started); + } + } else { + _consecutiveSilentFrames++; + _consecutiveVoiceFrames = 0; + + if (_isCurrentlySpeaking && _consecutiveSilentFrames >= endThreshold) { + _isCurrentlySpeaking = false; + _logger.info('🎙️ VAD: SPEECH ENDED'); + onSpeechActivity?.call(SpeechActivityEvent.ended); + } + } + } + + /// Convert 16-bit PCM samples to Float32 + List _convertPCMToFloat(List pcmSamples) { + final floatSamples = []; + for (final sample in pcmSamples) { + floatSamples.add(sample / 32768.0); + } + return floatSamples; + } + + /// Start automatic calibration + Future startCalibration() async { + _logger.info('🎯 Starting VAD calibration...'); + + _isCalibrating = true; + _calibrationSamples.clear(); + _calibrationFrameCount = 0; + + final timeoutSeconds = _calibrationFramesNeeded * frameLength + 2.0; + await Future.delayed( + Duration(milliseconds: (timeoutSeconds * 1000).toInt())); + + if (_isCalibrating) { + _completeCalibration(); + } + } + + void _handleCalibrationFrame(double energy) { + if (!_isCalibrating) return; + + _calibrationSamples.add(energy); + _calibrationFrameCount++; + + if (_calibrationFrameCount >= _calibrationFramesNeeded) { + _completeCalibration(); + } + } + + void _completeCalibration() { + if (!_isCalibrating || _calibrationSamples.isEmpty) return; + + final sortedSamples = List.from(_calibrationSamples)..sort(); + final percentile90 = sortedSamples[math.min( + sortedSamples.length - 1, (sortedSamples.length * 0.90).toInt())]; + + _ambientNoiseLevel = percentile90; + + final oldThreshold = energyThreshold; + final minimumThreshold = math.max(_ambientNoiseLevel * 2.5, 0.006); + final calculatedThreshold = _ambientNoiseLevel * _calibrationMultiplier; + + energyThreshold = math.max(calculatedThreshold, minimumThreshold); + + if (energyThreshold > 0.020) { + energyThreshold = 0.020; + } + + _logger.info( + '✅ VAD Calibration Complete: ${oldThreshold.toStringAsFixed(6)} → ${energyThreshold.toStringAsFixed(6)}', + ); + + _isCalibrating = false; + _calibrationSamples.clear(); + } + + /// Pause VAD processing + void pause() { + if (_isPaused) return; + _isPaused = true; + _logger.info('⏸️ VAD paused'); + + if (_isCurrentlySpeaking) { + _isCurrentlySpeaking = false; + onSpeechActivity?.call(SpeechActivityEvent.ended); + } + + _recentEnergyValues.clear(); + _consecutiveSilentFrames = 0; + _consecutiveVoiceFrames = 0; + } + + /// Resume VAD processing + void resume() { + if (!_isPaused) return; + + _isPaused = false; + _isCurrentlySpeaking = false; + _consecutiveSilentFrames = 0; + _consecutiveVoiceFrames = 0; + _recentEnergyValues.clear(); + _debugFrameCount = 0; + + _logger.info('▶️ VAD resumed'); + } + + /// Notify VAD that TTS is about to start + void notifyTTSWillStart() { + _isTTSActive = true; + _baseEnergyThreshold = energyThreshold; + + final newThreshold = energyThreshold * _ttsThresholdMultiplier; + energyThreshold = math.min(newThreshold, 0.1); + + _logger.info('🔊 TTS starting - VAD blocked'); + + if (_isCurrentlySpeaking) { + _isCurrentlySpeaking = false; + onSpeechActivity?.call(SpeechActivityEvent.ended); + } + + _consecutiveSilentFrames = 0; + _consecutiveVoiceFrames = 0; + } + + /// Notify VAD that TTS has finished + void notifyTTSDidFinish() { + _isTTSActive = false; + energyThreshold = _baseEnergyThreshold; + + _logger.info('🔇 TTS finished - VAD restored'); + + _recentEnergyValues.clear(); + _consecutiveSilentFrames = 0; + _consecutiveVoiceFrames = 0; + _isCurrentlySpeaking = false; + _debugFrameCount = 0; + } + + void _updateDebugStatistics(double energy) { + _recentEnergyValues.add(energy); + if (_recentEnergyValues.length > _maxRecentValues) { + _recentEnergyValues.removeAt(0); + } + } +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/features/vad/vad_configuration.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/features/vad/vad_configuration.dart new file mode 100644 index 000000000..882baeafa --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/features/vad/vad_configuration.dart @@ -0,0 +1,69 @@ +import 'package:runanywhere/core/protocols/component/component_configuration.dart'; +import 'package:runanywhere/foundation/error_types/sdk_error.dart'; + +/// Configuration for VAD component +class VADConfiguration implements ComponentConfiguration { + /// Energy threshold for voice detection (0.0 to 1.0) + final double energyThreshold; + + /// Sample rate in Hz + final int sampleRate; + + /// Frame length in seconds + final double frameLength; + + /// Enable automatic calibration + final bool enableAutoCalibration; + + /// Calibration multiplier (threshold = ambient noise * multiplier) + final double calibrationMultiplier; + + const VADConfiguration({ + this.energyThreshold = 0.015, + this.sampleRate = 16000, + this.frameLength = 0.1, + this.enableAutoCalibration = false, + this.calibrationMultiplier = 2.0, + }); + + @override + void validate() { + // Validate threshold range with better guidance + if (energyThreshold < 0 || energyThreshold > 1.0) { + throw SDKError.validationFailed( + 'Energy threshold must be between 0 and 1.0. Recommended range: 0.01-0.05', + ); + } + + // Warn if threshold is too low or too high + if (energyThreshold < 0.002) { + throw SDKError.validationFailed( + 'Energy threshold $energyThreshold is very low and may cause false positives. Recommended minimum: 0.002', + ); + } + if (energyThreshold > 0.1) { + throw SDKError.validationFailed( + 'Energy threshold $energyThreshold is very high and may miss speech. Recommended maximum: 0.1', + ); + } + + if (sampleRate <= 0 || sampleRate > 48000) { + throw SDKError.validationFailed( + 'Sample rate must be between 1 and 48000 Hz', + ); + } + + if (frameLength <= 0 || frameLength > 1.0) { + throw SDKError.validationFailed( + 'Frame length must be between 0 and 1 second', + ); + } + + // Validate calibration multiplier + if (calibrationMultiplier < 1.5 || calibrationMultiplier > 5.0) { + throw SDKError.validationFailed( + 'Calibration multiplier must be between 1.5 and 5.0', + ); + } + } +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/foundation/configuration/sdk_constants.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/foundation/configuration/sdk_constants.dart new file mode 100644 index 000000000..d188ea8ad --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/foundation/configuration/sdk_constants.dart @@ -0,0 +1,40 @@ +import 'dart:io'; + +/// SDK constants +/// +/// Version constants must match: +/// - Swift SDK: Package.swift (commonsVersion, coreVersion) +/// - Kotlin SDK: build.gradle.kts (commonsVersion, coreVersion) +/// - React Native SDK: package.json (commonsVersion, coreVersion) +class SDKConstants { + /// SDK version + static const String version = '0.15.8'; + + // ========================================================================== + // Binary Version Constants + // These MUST match the GitHub releases: + // - RACommons: https://github.com/RunanywhereAI/runanywhere-sdks/releases/tag/commons-v{commonsVersion} + // - Backends: https://github.com/RunanywhereAI/runanywhere-binaries/releases/tag/core-v{coreVersion} + // ========================================================================== + + /// RACommons version (core infrastructure) + /// Source: https://github.com/RunanywhereAI/runanywhere-sdks/releases + static const String commonsVersion = '0.1.4'; + + /// RAC Core/Backends version (LlamaCPP, ONNX) + /// Source: https://github.com/RunanywhereAI/runanywhere-binaries/releases + static const String coreVersion = '0.1.4'; + + /// Platform identifier + static String get platform { + if (Platform.isAndroid) return 'android'; + if (Platform.isIOS) return 'ios'; + if (Platform.isLinux) return 'linux'; + if (Platform.isMacOS) return 'macos'; + if (Platform.isWindows) return 'windows'; + return 'unknown'; + } + + /// SDK name + static const String name = 'RunAnywhere Flutter SDK'; +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/foundation/dependency_injection/service_container.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/foundation/dependency_injection/service_container.dart new file mode 100644 index 000000000..c656a24fc --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/foundation/dependency_injection/service_container.dart @@ -0,0 +1,175 @@ +/// Service Container +/// +/// Dependency injection container for SDK services. +/// Matches iOS ServiceContainer from Foundation/DependencyInjection/ServiceContainer.swift +/// +/// Note: Most services are now handled via FFI through DartBridge. +/// This container provides minimal DI for platform-specific services. +library service_container; + +import 'dart:async'; + +import 'package:runanywhere/data/network/api_client.dart'; +import 'package:runanywhere/data/network/http_service.dart'; +import 'package:runanywhere/data/network/network_configuration.dart'; +import 'package:runanywhere/data/network/network_service.dart'; +import 'package:runanywhere/data/network/telemetry_service.dart'; +import 'package:runanywhere/foundation/logging/sdk_logger.dart'; +import 'package:runanywhere/native/dart_bridge_device.dart'; +import 'package:runanywhere/public/configuration/sdk_environment.dart'; + +/// Service container for dependency injection +/// Matches iOS ServiceContainer from Foundation/DependencyInjection/ServiceContainer.swift +class ServiceContainer { + /// Shared instance + static final ServiceContainer shared = ServiceContainer._(); + + ServiceContainer._(); + + // Network services + APIClient? _apiClient; + NetworkService? _networkService; + + // Logger + SDKLogger? _logger; + + // Internal state - reserved for future use + // ignore: unused_field + SDKInitParams? _initParams; + + /// Logger + SDKLogger get logger { + return _logger ??= SDKLogger(); + } + + /// API client + APIClient? get apiClient => _apiClient; + + /// Network service for HTTP operations + NetworkService? get networkService => _networkService; + + /// HTTP service (new centralized network layer) + HTTPService get httpService => HTTPService.shared; + + /// Telemetry service + TelemetryService get telemetryService => TelemetryService.shared; + + /// Set network service (called during initialization) + void setNetworkService(NetworkService service) { + _networkService = service; + } + + /// Create an API client with the given configuration + APIClient createAPIClient({ + required Uri baseURL, + required String apiKey, + }) { + final client = APIClient(baseURL: baseURL, apiKey: apiKey); + _apiClient = client; + _networkService = client; + return client; + } + + /// Setup local services (no network calls) + Future setupLocalServices({ + required String apiKey, + required Uri baseURL, + required SDKEnvironment environment, + }) async { + // Store init params + _initParams = SDKInitParams( + apiKey: apiKey, + baseURL: baseURL, + environment: environment, + ); + + // Configure HTTPService (new centralized network layer) + _configureHTTPService( + apiKey: apiKey, + baseURL: baseURL, + environment: environment, + ); + + // Configure TelemetryService (fetch device ID properly) + await _configureTelemetryService( + environment: environment, + ); + + // Create API client for network services (legacy support) + _apiClient = APIClient( + baseURL: baseURL, + apiKey: apiKey, + ); + _networkService = _apiClient; + } + + /// Configure the centralized HTTP service + void _configureHTTPService({ + required String apiKey, + required Uri baseURL, + required SDKEnvironment environment, + }) { + // Configure main HTTP service + HTTPService.shared.configure(HTTPServiceConfig( + baseURL: baseURL.toString(), + apiKey: apiKey, + environment: environment, + )); + + // Configure development mode with Supabase if applicable + if (environment == SDKEnvironment.development) { + final supabaseConfig = SupabaseConfig.configuration(environment); + if (supabaseConfig != null) { + HTTPService.shared.configureDev(DevModeConfig( + supabaseURL: supabaseConfig.projectURL.toString(), + supabaseKey: supabaseConfig.anonKey, + )); + } + } + } + + /// Configure the telemetry service + Future _configureTelemetryService({ + required SDKEnvironment environment, + }) async { + // Properly fetch device ID - don't use "unknown" + // This matches Swift/Kotlin which use real device IDs for telemetry + final deviceId = await DartBridgeDevice.instance.getDeviceId(); + + TelemetryService.shared.configure( + deviceId: deviceId, + environment: environment, + ); + + // Enable telemetry for both development and production + // - Development: sends to Supabase /rest/v1/telemetry_events + // - Production: sends to Railway /api/v1/sdk/telemetry + // Staging is disabled by default (can be overridden by the app) + final shouldEnable = environment == SDKEnvironment.development || + environment == SDKEnvironment.production; + TelemetryService.shared.setEnabled(shouldEnable); + } + + /// Reset all services (for testing) + void reset() { + _apiClient = null; + _networkService = null; + _logger = null; + _initParams = null; + HTTPService.resetForTesting(); + TelemetryService.resetForTesting(); + } +} + +/// SDK initialization parameters +class SDKInitParams { + final String apiKey; + final Uri baseURL; + final SDKEnvironment environment; + + const SDKInitParams({ + required this.apiKey, + required this.baseURL, + required this.environment, + }); +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/foundation/error_types/error_category.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/foundation/error_types/error_category.dart new file mode 100644 index 000000000..194a43c9d --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/foundation/error_types/error_category.dart @@ -0,0 +1,60 @@ +/// Error categories for logical grouping and filtering +/// Matches iOS ErrorCategory from Foundation/ErrorTypes/ErrorCategory.swift +enum ErrorCategory { + initialization, + model, + generation, + network, + storage, + memory, + hardware, + validation, + authentication, + component, + framework, + unknown; + + /// Initialize from an error by analyzing its type and message + static ErrorCategory fromError(Object error) { + final description = error.toString().toLowerCase(); + + if (description.contains('memory') || + description.contains('out of memory')) { + return ErrorCategory.memory; + } else if (description.contains('download') || + description.contains('network') || + description.contains('connection')) { + return ErrorCategory.network; + } else if (description.contains('validation') || + description.contains('invalid') || + description.contains('checksum')) { + return ErrorCategory.validation; + } else if (description.contains('hardware') || + description.contains('device') || + description.contains('thermal')) { + return ErrorCategory.hardware; + } else if (description.contains('auth') || + description.contains('credential') || + description.contains('api key')) { + return ErrorCategory.authentication; + } else if (description.contains('model') || description.contains('load')) { + return ErrorCategory.model; + } else if (description.contains('storage') || + description.contains('disk') || + description.contains('space')) { + return ErrorCategory.storage; + } else if (description.contains('initialize') || + description.contains('not initialized')) { + return ErrorCategory.initialization; + } else if (description.contains('component')) { + return ErrorCategory.component; + } else if (description.contains('framework')) { + return ErrorCategory.framework; + } else if (description.contains('generation') || + description.contains('generate')) { + return ErrorCategory.generation; + } + + return ErrorCategory.unknown; + } +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/foundation/error_types/error_code.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/foundation/error_types/error_code.dart new file mode 100644 index 000000000..476d083fd --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/foundation/error_types/error_code.dart @@ -0,0 +1,129 @@ +/// SDK error codes +/// Matches iOS ErrorCode from Foundation/ErrorTypes/ErrorCodes.swift +enum ErrorCode { + // General errors (1000-1099) + unknown(1000), + invalidInput(1001), + notInitialized(1002), + alreadyInitialized(1003), + operationCancelled(1004), + + // Model errors (1100-1199) + modelNotFound(1100), + modelLoadFailed(1101), + modelValidationFailed(1102), + modelFormatUnsupported(1103), + modelCorrupted(1104), + modelIncompatible(1105), + + // Network errors (1200-1299) + networkUnavailable(1200), + networkTimeout(1201), + downloadFailed(1202), + uploadFailed(1203), + apiError(1204), + + // Storage errors (1300-1399) + insufficientStorage(1300), + storageFull(1301), + fileNotFound(1302), + fileAccessDenied(1303), + fileCorrupted(1304), + + // Hardware errors (1500-1599) + hardwareUnsupported(1500), + hardwareUnavailable(1501), + + // Authentication errors (1600-1699) + authenticationFailed(1600), + authenticationExpired(1601), + authorizationDenied(1602), + apiKeyInvalid(1603), + + // Generation errors (1700-1799) + generationFailed(1700), + generationTimeout(1701), + tokenLimitExceeded(1702), + costLimitExceeded(1703), + contextTooLong(1704); + + final int rawValue; + + const ErrorCode(this.rawValue); + + /// Get user-friendly error message + String get message { + switch (this) { + case ErrorCode.unknown: + return 'An unknown error occurred'; + case ErrorCode.invalidInput: + return 'Invalid input provided'; + case ErrorCode.notInitialized: + return 'SDK not initialized'; + case ErrorCode.alreadyInitialized: + return 'SDK already initialized'; + case ErrorCode.operationCancelled: + return 'Operation was cancelled'; + + case ErrorCode.modelNotFound: + return 'Model not found'; + case ErrorCode.modelLoadFailed: + return 'Failed to load model'; + case ErrorCode.modelValidationFailed: + return 'Model validation failed'; + case ErrorCode.modelFormatUnsupported: + return 'Model format not supported'; + case ErrorCode.modelCorrupted: + return 'Model file is corrupted'; + case ErrorCode.modelIncompatible: + return 'Model incompatible with device'; + + case ErrorCode.networkUnavailable: + return 'Network unavailable'; + case ErrorCode.networkTimeout: + return 'Network request timed out'; + case ErrorCode.downloadFailed: + return 'Download failed'; + case ErrorCode.uploadFailed: + return 'Upload failed'; + case ErrorCode.apiError: + return 'API request failed'; + + case ErrorCode.insufficientStorage: + return 'Insufficient storage space'; + case ErrorCode.storageFull: + return 'Storage is full'; + case ErrorCode.fileNotFound: + return 'File not found'; + case ErrorCode.fileAccessDenied: + return 'File access denied'; + case ErrorCode.fileCorrupted: + return 'File is corrupted'; + + case ErrorCode.hardwareUnsupported: + return 'Hardware not supported'; + case ErrorCode.hardwareUnavailable: + return 'Hardware unavailable'; + + case ErrorCode.authenticationFailed: + return 'Authentication failed'; + case ErrorCode.authenticationExpired: + return 'Authentication expired'; + case ErrorCode.authorizationDenied: + return 'Authorization denied'; + case ErrorCode.apiKeyInvalid: + return 'Invalid API key'; + + case ErrorCode.generationFailed: + return 'Text generation failed'; + case ErrorCode.generationTimeout: + return 'Generation timed out'; + case ErrorCode.tokenLimitExceeded: + return 'Token limit exceeded'; + case ErrorCode.costLimitExceeded: + return 'Cost limit exceeded'; + case ErrorCode.contextTooLong: + return 'Context too long'; + } + } +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/foundation/error_types/error_context.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/foundation/error_types/error_context.dart new file mode 100644 index 000000000..198a3859b --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/foundation/error_types/error_context.dart @@ -0,0 +1,173 @@ +/// Context information captured when an error occurs +/// Includes stack trace, source location, and timing information +/// Matches iOS ErrorContext from Foundation/ErrorTypes/ErrorContext.swift +class ErrorContext { + /// The stack trace at the point of error capture + final StackTrace? stackTrace; + + /// The file where the error was captured + final String file; + + /// The line number where the error was captured + final int line; + + /// The function where the error was captured + final String function; + + /// Timestamp when the error was captured + final DateTime timestamp; + + /// Thread name/ID where the error occurred + final String threadInfo; + + /// Initialize with automatic capture of current context + ErrorContext({ + this.stackTrace, + this.file = '', + this.line = 0, + this.function = '', + DateTime? timestamp, + String? threadInfo, + }) : timestamp = timestamp ?? DateTime.now(), + threadInfo = threadInfo ?? 'main'; + + /// Create context from stack trace + factory ErrorContext.capture() { + final trace = StackTrace.current; + return ErrorContext( + stackTrace: trace, + timestamp: DateTime.now(), + threadInfo: 'main', + ); + } + + /// Initialize with explicit values (for testing or deserialization) + factory ErrorContext.explicit({ + required StackTrace? stackTrace, + required String file, + required int line, + required String function, + required DateTime timestamp, + required String threadInfo, + }) { + return ErrorContext( + stackTrace: stackTrace, + file: file, + line: line, + function: function, + timestamp: timestamp, + threadInfo: threadInfo, + ); + } + + /// A formatted string representation of the stack trace + String get formattedStackTrace { + if (stackTrace == null) return ''; + + final lines = stackTrace.toString().split('\n'); + final relevantFrames = lines + .where( + (frame) => frame.contains('runanywhere') || frame.contains('lib/')) + .take(15) + .toList(); + + if (relevantFrames.isEmpty) { + return lines.take(10).join('\n'); + } + + return relevantFrames + .asMap() + .entries + .map((e) => ' ${e.key}. ${e.value}') + .join('\n'); + } + + /// A compact single-line location string + String get locationString => '$file:$line in $function'; + + /// Full formatted context for logging + String get formattedContext => ''' +Location: $locationString +Thread: $threadInfo +Time: ${timestamp.toIso8601String()} +Stack Trace: +$formattedStackTrace +'''; + + /// Convert to map for serialization + Map toJson() => { + 'file': file, + 'line': line, + 'function': function, + 'timestamp': timestamp.toIso8601String(), + 'threadInfo': threadInfo, + 'stackTrace': stackTrace?.toString(), + }; + + /// Create from map + factory ErrorContext.fromJson(Map json) { + return ErrorContext( + file: json['file'] as String? ?? '', + line: json['line'] as int? ?? 0, + function: json['function'] as String? ?? '', + timestamp: json['timestamp'] != null + ? DateTime.parse(json['timestamp'] as String) + : DateTime.now(), + threadInfo: json['threadInfo'] as String? ?? 'main', + ); + } +} + +/// Global function to capture error context at the call site +/// Use this when throwing errors to capture the stack trace +ErrorContext captureErrorContext() => ErrorContext.capture(); + +/// A wrapper that attaches context to any error +class ContextualError implements Exception { + /// The underlying error + final Object error; + + /// The captured context + final ErrorContext context; + + /// Initialize with an error and automatically capture context + ContextualError(this.error, {ErrorContext? context}) + : context = context ?? ErrorContext.capture(); + + @override + String toString() { + final errorDesc = + error is Exception ? (error as Exception).toString() : error.toString(); + return errorDesc; + } + + /// Get error description + String? get errorDescription { + if (error is Exception) { + return error.toString(); + } + return error.toString(); + } +} + +/// Extension to add context to any error +extension ErrorContextExtension on Object { + /// Wrap this error with context information + ContextualError withContext() => ContextualError(this); + + /// Extract context if this is a ContextualError + ErrorContext? get errorContext { + if (this is ContextualError) { + return (this as ContextualError).context; + } + return null; + } + + /// Get the underlying error if wrapped + Object get underlyingErrorValue { + if (this is ContextualError) { + return (this as ContextualError).error; + } + return this; + } +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/foundation/error_types/sdk_error.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/foundation/error_types/sdk_error.dart new file mode 100644 index 000000000..20ce11bf7 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/foundation/error_types/sdk_error.dart @@ -0,0 +1,712 @@ +import 'package:runanywhere/foundation/error_types/error_category.dart'; +import 'package:runanywhere/foundation/error_types/error_code.dart'; +import 'package:runanywhere/foundation/error_types/error_context.dart'; + +export 'error_category.dart'; +export 'error_code.dart'; +export 'error_context.dart'; + +/// Main SDK error type +/// Matches iOS RunAnywhereError from Public/Errors/RunAnywhereError.swift +/// +/// Note: Also exported as [RunAnywhereError] for iOS parity +class SDKError implements Exception { + final String message; + final SDKErrorType type; + + /// The underlying error that caused this SDK error (if any) + /// Matches iOS RunAnywhereError.underlyingError + final Object? underlyingError; + + /// Error context with stack trace and location info + final ErrorContext? context; + + SDKError( + this.message, + this.type, { + this.underlyingError, + this.context, + }); + + @override + String toString() => 'SDKError($type): $message'; + + /// The error code for machine-readable identification + /// Matches iOS SDKErrorProtocol.code + ErrorCode get code { + switch (type) { + case SDKErrorType.notInitialized: + return ErrorCode.notInitialized; + case SDKErrorType.alreadyInitialized: + return ErrorCode.alreadyInitialized; + case SDKErrorType.invalidAPIKey: + return ErrorCode.apiKeyInvalid; + case SDKErrorType.invalidConfiguration: + return ErrorCode.invalidInput; + case SDKErrorType.environmentMismatch: + return ErrorCode.invalidInput; + case SDKErrorType.modelNotFound: + return ErrorCode.modelNotFound; + case SDKErrorType.modelNotDownloaded: + return ErrorCode.modelNotFound; + case SDKErrorType.modelLoadFailed: + return ErrorCode.modelLoadFailed; + case SDKErrorType.loadingFailed: + return ErrorCode.modelLoadFailed; + case SDKErrorType.modelValidationFailed: + return ErrorCode.modelValidationFailed; + case SDKErrorType.modelIncompatible: + return ErrorCode.modelIncompatible; + case SDKErrorType.frameworkNotAvailable: + return ErrorCode.hardwareUnavailable; + case SDKErrorType.sttNotAvailable: + return ErrorCode.hardwareUnavailable; + case SDKErrorType.ttsNotAvailable: + return ErrorCode.hardwareUnavailable; + case SDKErrorType.generationFailed: + return ErrorCode.generationFailed; + case SDKErrorType.generationTimeout: + return ErrorCode.generationTimeout; + case SDKErrorType.contextTooLong: + return ErrorCode.contextTooLong; + case SDKErrorType.tokenLimitExceeded: + return ErrorCode.tokenLimitExceeded; + case SDKErrorType.costLimitExceeded: + return ErrorCode.costLimitExceeded; + case SDKErrorType.networkError: + return ErrorCode.apiError; + case SDKErrorType.networkUnavailable: + return ErrorCode.networkUnavailable; + case SDKErrorType.requestFailed: + return ErrorCode.apiError; + case SDKErrorType.downloadFailed: + return ErrorCode.downloadFailed; + case SDKErrorType.timeout: + return ErrorCode.networkTimeout; + case SDKErrorType.storageError: + return ErrorCode.fileAccessDenied; + case SDKErrorType.insufficientStorage: + return ErrorCode.insufficientStorage; + case SDKErrorType.storageFull: + return ErrorCode.storageFull; + case SDKErrorType.hardwareUnsupported: + return ErrorCode.hardwareUnsupported; + case SDKErrorType.memoryPressure: + return ErrorCode.hardwareUnavailable; + case SDKErrorType.thermalStateExceeded: + return ErrorCode.hardwareUnavailable; + case SDKErrorType.componentNotReady: + return ErrorCode.notInitialized; + case SDKErrorType.componentNotInitialized: + return ErrorCode.notInitialized; + case SDKErrorType.authenticationFailed: + return ErrorCode.authenticationFailed; + case SDKErrorType.databaseInitializationFailed: + return ErrorCode.unknown; + case SDKErrorType.featureNotAvailable: + return ErrorCode.unknown; + case SDKErrorType.notImplemented: + return ErrorCode.unknown; + case SDKErrorType.validationFailed: + return ErrorCode.invalidInput; + case SDKErrorType.unsupportedModality: + return ErrorCode.invalidInput; + case SDKErrorType.invalidState: + return ErrorCode.invalidInput; + case SDKErrorType.serverError: + return ErrorCode.apiError; + case SDKErrorType.rateLimitExceeded: + return ErrorCode.apiError; + case SDKErrorType.serviceUnavailable: + return ErrorCode.apiError; + case SDKErrorType.invalidInput: + return ErrorCode.invalidInput; + case SDKErrorType.resourceExhausted: + return ErrorCode.insufficientStorage; + case SDKErrorType.voiceAgentNotReady: + return ErrorCode.notInitialized; + case SDKErrorType.internalError: + return ErrorCode.unknown; + } + } + + /// The category of this error for grouping/filtering + /// Matches iOS SDKErrorProtocol.category + ErrorCategory get category { + switch (type) { + case SDKErrorType.notInitialized: + case SDKErrorType.alreadyInitialized: + case SDKErrorType.invalidAPIKey: + case SDKErrorType.invalidConfiguration: + case SDKErrorType.environmentMismatch: + return ErrorCategory.initialization; + case SDKErrorType.modelNotFound: + case SDKErrorType.modelNotDownloaded: + case SDKErrorType.modelLoadFailed: + case SDKErrorType.loadingFailed: + case SDKErrorType.modelValidationFailed: + case SDKErrorType.modelIncompatible: + case SDKErrorType.sttNotAvailable: + case SDKErrorType.ttsNotAvailable: + return ErrorCategory.model; + case SDKErrorType.generationFailed: + case SDKErrorType.generationTimeout: + case SDKErrorType.contextTooLong: + case SDKErrorType.tokenLimitExceeded: + case SDKErrorType.costLimitExceeded: + return ErrorCategory.generation; + case SDKErrorType.networkError: + case SDKErrorType.networkUnavailable: + case SDKErrorType.requestFailed: + case SDKErrorType.downloadFailed: + case SDKErrorType.timeout: + case SDKErrorType.serverError: + case SDKErrorType.rateLimitExceeded: + case SDKErrorType.serviceUnavailable: + return ErrorCategory.network; + case SDKErrorType.storageError: + case SDKErrorType.insufficientStorage: + case SDKErrorType.storageFull: + case SDKErrorType.resourceExhausted: + return ErrorCategory.storage; + case SDKErrorType.hardwareUnsupported: + case SDKErrorType.memoryPressure: + case SDKErrorType.thermalStateExceeded: + return ErrorCategory.hardware; + case SDKErrorType.componentNotReady: + case SDKErrorType.componentNotInitialized: + case SDKErrorType.invalidState: + return ErrorCategory.component; + case SDKErrorType.authenticationFailed: + return ErrorCategory.authentication; + case SDKErrorType.frameworkNotAvailable: + case SDKErrorType.databaseInitializationFailed: + return ErrorCategory.framework; + case SDKErrorType.validationFailed: + case SDKErrorType.unsupportedModality: + case SDKErrorType.invalidInput: + return ErrorCategory.validation; + case SDKErrorType.voiceAgentNotReady: + return ErrorCategory.component; + case SDKErrorType.featureNotAvailable: + case SDKErrorType.notImplemented: + case SDKErrorType.internalError: + return ErrorCategory.unknown; + } + } + + /// Recovery suggestion for the error + /// Matches iOS RunAnywhereError.recoverySuggestion + String? get recoverySuggestion { + switch (type) { + case SDKErrorType.notInitialized: + return 'Call RunAnywhere.initialize() before using the SDK.'; + case SDKErrorType.alreadyInitialized: + return 'The SDK is already initialized. You can use it directly.'; + case SDKErrorType.invalidAPIKey: + return 'Provide a valid API key in the configuration.'; + case SDKErrorType.invalidConfiguration: + return 'Check your configuration settings and ensure all required fields are provided.'; + case SDKErrorType.environmentMismatch: + return 'Use .development or .staging for DEBUG builds. Production environment requires a Release build.'; + + case SDKErrorType.modelNotFound: + return 'Check the model identifier or download the model first.'; + case SDKErrorType.modelNotDownloaded: + return 'Download the model first using RunAnywhere.downloadModel().'; + case SDKErrorType.modelLoadFailed: + return 'Ensure the model file is not corrupted and is compatible with your device.'; + case SDKErrorType.sttNotAvailable: + return 'Register an STT provider (e.g., ONNX) before using speech recognition.'; + case SDKErrorType.ttsNotAvailable: + return 'Register a TTS provider (e.g., ONNX) before using text-to-speech.'; + case SDKErrorType.loadingFailed: + return 'The loading operation failed. Check logs for details.'; + case SDKErrorType.modelValidationFailed: + return 'The model file may be corrupted or incompatible. Try re-downloading.'; + case SDKErrorType.modelIncompatible: + return 'Use a different model that is compatible with your device.'; + case SDKErrorType.frameworkNotAvailable: + return 'Use a different model or device that supports this feature.'; + + case SDKErrorType.generationFailed: + return 'Check your input and try again.'; + case SDKErrorType.generationTimeout: + return 'Try with a shorter prompt or fewer tokens.'; + case SDKErrorType.contextTooLong: + return 'Reduce the context size or use a model with larger context window.'; + case SDKErrorType.tokenLimitExceeded: + return 'Reduce the number of tokens requested.'; + case SDKErrorType.costLimitExceeded: + return 'Increase your cost limit or use a more cost-effective model.'; + + case SDKErrorType.networkError: + case SDKErrorType.networkUnavailable: + case SDKErrorType.requestFailed: + case SDKErrorType.serverError: + return 'Check your internet connection and try again.'; + case SDKErrorType.downloadFailed: + return 'Check your internet connection and available storage space.'; + case SDKErrorType.timeout: + return 'The operation timed out. Try again or check your network connection.'; + case SDKErrorType.rateLimitExceeded: + return 'You have exceeded the rate limit. Please wait before trying again.'; + case SDKErrorType.serviceUnavailable: + return 'The service is temporarily unavailable. Please try again later.'; + + case SDKErrorType.storageError: + case SDKErrorType.insufficientStorage: + case SDKErrorType.resourceExhausted: + return 'Free up storage space on your device.'; + case SDKErrorType.storageFull: + return 'Delete unnecessary files to free up space.'; + + case SDKErrorType.hardwareUnsupported: + return 'Use a different model or device that supports this feature.'; + case SDKErrorType.memoryPressure: + return 'Close other apps to free up memory.'; + case SDKErrorType.thermalStateExceeded: + return 'Wait for the device to cool down before trying again.'; + + case SDKErrorType.componentNotReady: + case SDKErrorType.componentNotInitialized: + return 'Ensure the component is properly initialized before use.'; + case SDKErrorType.invalidState: + return 'Check the current state and ensure operations are called in the correct order.'; + + case SDKErrorType.authenticationFailed: + return 'Check your credentials and try again.'; + + case SDKErrorType.databaseInitializationFailed: + return 'Try reinstalling the app or clearing app data.'; + + case SDKErrorType.validationFailed: + case SDKErrorType.unsupportedModality: + case SDKErrorType.invalidInput: + return 'Check your input parameters and ensure they are valid.'; + + case SDKErrorType.featureNotAvailable: + case SDKErrorType.notImplemented: + return 'This feature may be available in a future update.'; + + case SDKErrorType.voiceAgentNotReady: + return 'Load all required voice agent components (STT, LLM, TTS) before starting a voice session.'; + + case SDKErrorType.internalError: + return 'An internal error occurred. Please report this issue.'; + } + } + + // Factory constructors for common errors + static SDKError notInitialized([String? message]) { + return SDKError( + message ?? 'RunAnywhere SDK is not initialized. Call initialize() first.', + SDKErrorType.notInitialized, + ); + } + + static SDKError alreadyInitialized([String? message]) { + return SDKError( + message ?? 'RunAnywhere SDK is already initialized.', + SDKErrorType.alreadyInitialized, + ); + } + + static SDKError invalidAPIKey([String? message]) { + return SDKError( + message ?? 'Invalid or missing API key.', + SDKErrorType.invalidAPIKey, + ); + } + + static SDKError invalidConfiguration(String detail) { + return SDKError( + 'Invalid configuration: $detail', + SDKErrorType.invalidConfiguration, + ); + } + + static SDKError environmentMismatch(String reason) { + return SDKError( + 'Environment configuration mismatch: $reason', + SDKErrorType.environmentMismatch, + ); + } + + static SDKError modelNotFound(String modelId) { + return SDKError( + 'Model \'$modelId\' not found.', + SDKErrorType.modelNotFound, + ); + } + + static SDKError modelLoadFailed(String modelId, Object? error) { + return SDKError( + error != null + ? 'Failed to load model \'$modelId\': $error' + : 'Failed to load model \'$modelId\'', + SDKErrorType.modelLoadFailed, + underlyingError: error, + ); + } + + static SDKError loadingFailed(String reason) { + return SDKError( + 'Failed to load: $reason', + SDKErrorType.loadingFailed, + ); + } + + static SDKError modelValidationFailed(String modelId, List errors) { + return SDKError( + 'Model \'$modelId\' validation failed: ${errors.join(', ')}', + SDKErrorType.modelValidationFailed, + ); + } + + static SDKError modelIncompatible(String modelId, String reason) { + return SDKError( + 'Model \'$modelId\' is incompatible: $reason', + SDKErrorType.modelIncompatible, + ); + } + + /// Model not downloaded error + static SDKError modelNotDownloaded(String message) { + return SDKError( + message, + SDKErrorType.modelNotDownloaded, + ); + } + + /// STT service not available + static SDKError sttNotAvailable(String message) { + return SDKError( + message, + SDKErrorType.sttNotAvailable, + ); + } + + /// TTS service not available + static SDKError ttsNotAvailable(String message) { + return SDKError( + message, + SDKErrorType.ttsNotAvailable, + ); + } + + static SDKError generationFailed(String reason) { + return SDKError( + 'Text generation failed: $reason', + SDKErrorType.generationFailed, + ); + } + + static SDKError generationTimeout([String? reason]) { + return SDKError( + reason != null + ? 'Generation timed out: $reason' + : 'Text generation timed out.', + SDKErrorType.generationTimeout, + ); + } + + static SDKError contextTooLong(int provided, int maximum) { + return SDKError( + 'Context too long: $provided tokens (maximum: $maximum)', + SDKErrorType.contextTooLong, + ); + } + + static SDKError tokenLimitExceeded(int requested, int maximum) { + return SDKError( + 'Token limit exceeded: requested $requested, maximum $maximum', + SDKErrorType.tokenLimitExceeded, + ); + } + + static SDKError costLimitExceeded(double estimated, double limit) { + return SDKError( + 'Cost limit exceeded: estimated \$${estimated.toStringAsFixed(2)}, limit \$${limit.toStringAsFixed(2)}', + SDKErrorType.costLimitExceeded, + ); + } + + static SDKError networkUnavailable([String? message]) { + return SDKError( + message ?? 'Network connection unavailable.', + SDKErrorType.networkUnavailable, + ); + } + + static SDKError networkError(String reason) { + return SDKError( + 'Network error: $reason', + SDKErrorType.networkError, + ); + } + + static SDKError requestFailed(Object error) { + return SDKError( + 'Request failed: $error', + SDKErrorType.requestFailed, + underlyingError: error, + ); + } + + static SDKError downloadFailed(String url, Object? error) { + return SDKError( + error != null + ? 'Failed to download from \'$url\': $error' + : 'Failed to download from \'$url\'', + SDKErrorType.downloadFailed, + underlyingError: error, + ); + } + + static SDKError serverError(String reason) { + return SDKError( + 'Server error: $reason', + SDKErrorType.serverError, + ); + } + + static SDKError timeout(String reason) { + return SDKError( + 'Operation timed out: $reason', + SDKErrorType.timeout, + ); + } + + static SDKError insufficientStorage(int required, int available) { + return SDKError( + 'Insufficient storage: ${_formatBytes(required)} required, ${_formatBytes(available)} available', + SDKErrorType.insufficientStorage, + ); + } + + static SDKError storageFull([String? message]) { + return SDKError( + message ?? 'Device storage is full.', + SDKErrorType.storageFull, + ); + } + + static SDKError storageError(String reason) { + return SDKError( + 'Storage error: $reason', + SDKErrorType.storageError, + ); + } + + static SDKError hardwareUnsupported(String feature) { + return SDKError( + 'Hardware does not support $feature.', + SDKErrorType.hardwareUnsupported, + ); + } + + static SDKError componentNotInitialized(String component) { + return SDKError( + 'Component not initialized: $component', + SDKErrorType.componentNotInitialized, + ); + } + + static SDKError componentNotReady(String component) { + return SDKError( + 'Component not ready: $component', + SDKErrorType.componentNotReady, + ); + } + + static SDKError invalidState(String reason) { + return SDKError( + 'Invalid state: $reason', + SDKErrorType.invalidState, + ); + } + + static SDKError validationFailed(String reason) { + return SDKError( + 'Validation failed: $reason', + SDKErrorType.validationFailed, + ); + } + + static SDKError unsupportedModality(String modality) { + return SDKError( + 'Unsupported modality: $modality', + SDKErrorType.unsupportedModality, + ); + } + + static SDKError authenticationFailed(String reason) { + return SDKError( + 'Authentication failed: $reason', + SDKErrorType.authenticationFailed, + ); + } + + static SDKError frameworkNotAvailable(String framework) { + return SDKError( + 'Framework $framework not available', + SDKErrorType.frameworkNotAvailable, + ); + } + + static SDKError databaseInitializationFailed(Object error) { + return SDKError( + 'Database initialization failed: $error', + SDKErrorType.databaseInitializationFailed, + underlyingError: error, + ); + } + + static SDKError featureNotAvailable(String feature) { + return SDKError( + 'Feature \'$feature\' is not available.', + SDKErrorType.featureNotAvailable, + ); + } + + static SDKError notImplemented(String feature) { + return SDKError( + 'Feature \'$feature\' is not yet implemented.', + SDKErrorType.notImplemented, + ); + } + + static SDKError rateLimitExceeded([String? message]) { + return SDKError( + message ?? 'Rate limit exceeded.', + SDKErrorType.rateLimitExceeded, + ); + } + + static SDKError serviceUnavailable([String? message]) { + return SDKError( + message ?? 'Service is currently unavailable.', + SDKErrorType.serviceUnavailable, + ); + } + + static SDKError invalidInput(String reason) { + return SDKError( + 'Invalid input: $reason', + SDKErrorType.invalidInput, + ); + } + + static SDKError resourceExhausted([String? message]) { + return SDKError( + message ?? 'Resource exhausted.', + SDKErrorType.resourceExhausted, + ); + } + + static SDKError internalError([String? message]) { + return SDKError( + message ?? 'An internal error occurred.', + SDKErrorType.internalError, + ); + } + + /// Voice agent not ready error + static SDKError voiceAgentNotReady(String message) { + return SDKError( + message, + SDKErrorType.voiceAgentNotReady, + ); + } + + /// Helper to format bytes + static String _formatBytes(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + if (bytes < 1024 * 1024 * 1024) { + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB'; + } +} + +/// SDK error types +/// Matches iOS RunAnywhereError cases +enum SDKErrorType { + // Initialization errors + notInitialized, + alreadyInitialized, + invalidAPIKey, + invalidConfiguration, + environmentMismatch, + + // Model errors + modelNotFound, + modelNotDownloaded, + modelLoadFailed, + loadingFailed, + modelValidationFailed, + modelIncompatible, + frameworkNotAvailable, + sttNotAvailable, + ttsNotAvailable, + + // Generation errors + generationFailed, + generationTimeout, + contextTooLong, + tokenLimitExceeded, + costLimitExceeded, + + // Network errors + networkError, + networkUnavailable, + requestFailed, + downloadFailed, + timeout, + serverError, + rateLimitExceeded, + serviceUnavailable, + + // Storage errors + storageError, + insufficientStorage, + storageFull, + resourceExhausted, + + // Hardware errors + hardwareUnsupported, + memoryPressure, + thermalStateExceeded, + + // Component errors + componentNotReady, + componentNotInitialized, + invalidState, + + // Validation errors + validationFailed, + unsupportedModality, + invalidInput, + + // Authentication errors + authenticationFailed, + + // Database errors + databaseInitializationFailed, + + // Feature errors + featureNotAvailable, + notImplemented, + + // Voice agent errors + voiceAgentNotReady, + + // General errors + internalError, +} + +/// Type alias for iOS parity +/// iOS uses RunAnywhereError; this alias provides compatibility +typedef RunAnywhereError = SDKError; diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/foundation/logging/sdk_logger.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/foundation/logging/sdk_logger.dart new file mode 100644 index 000000000..453301a44 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/foundation/logging/sdk_logger.dart @@ -0,0 +1,104 @@ +/// SDK Logger +/// +/// Centralized logging utility. +/// Matches iOS SDKLogger from Foundation/Logging/SDKLogger.swift +library sdk_logger; + +/// Log levels +enum LogLevel { + debug, + info, + warning, + error, + fault, +} + +/// Centralized logging utility +/// Aligned with iOS: Sources/RunAnywhere/Foundation/Logging/Logger/SDKLogger.swift +class SDKLogger { + final String category; + + /// Create a logger with the specified category + /// [category] - The log category (e.g., 'DartBridge.Auth') + SDKLogger([this.category = 'SDK']); + + // MARK: - Standard Logging Methods + + /// Log a debug message + void debug(String message, {Map? metadata}) { + _log(LogLevel.debug, message, metadata: metadata); + } + + /// Log an info message + void info(String message, {Map? metadata}) { + _log(LogLevel.info, message, metadata: metadata); + } + + /// Log a warning message + void warning(String message, {Map? metadata}) { + _log(LogLevel.warning, message, metadata: metadata); + } + + /// Log an error message + void error(String message, + {Object? error, StackTrace? stackTrace, Map? metadata}) { + final enrichedMetadata = metadata ?? {}; + if (error != null) { + enrichedMetadata['error'] = error.toString(); + } + if (stackTrace != null) { + enrichedMetadata['stackTrace'] = stackTrace.toString(); + } + + _log(LogLevel.error, message, metadata: enrichedMetadata); + } + + /// Log a fault message (highest severity) + void fault(String message, + {Object? error, StackTrace? stackTrace, Map? metadata}) { + final enrichedMetadata = metadata ?? {}; + if (error != null) { + enrichedMetadata['error'] = error.toString(); + } + if (stackTrace != null) { + enrichedMetadata['stackTrace'] = stackTrace.toString(); + } + + _log(LogLevel.fault, message, metadata: enrichedMetadata); + } + + /// Log a message with a specific level + void log(LogLevel level, String message, {Map? metadata}) { + _log(level, message, metadata: metadata); + } + + // MARK: - Performance Logging + + /// Log performance metrics + void performance(String metric, double value, + {Map? metadata}) { + final enrichedMetadata = metadata ?? {}; + enrichedMetadata['metric'] = metric; + enrichedMetadata['value'] = value; + enrichedMetadata['type'] = 'performance'; + + _log(LogLevel.info, '$metric: $value', metadata: enrichedMetadata); + } + + // MARK: - Private Methods + + void _log(LogLevel level, String message, {Map? metadata}) { + final timestamp = DateTime.now().toIso8601String(); + final levelStr = level.name.toUpperCase(); + + // For now, just print to console + // In production, this would route to native logging via FFI + // ignore: avoid_print + print('[$timestamp] [$levelStr] [$category] $message'); + + if (metadata != null && metadata.isNotEmpty) { + // ignore: avoid_print + print(' metadata: $metadata'); + } + } +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/foundation/security/keychain_manager.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/foundation/security/keychain_manager.dart new file mode 100644 index 000000000..fa7071c86 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/foundation/security/keychain_manager.dart @@ -0,0 +1,64 @@ +import 'dart:async'; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +/// Secure storage manager for API keys and sensitive data +class KeychainManager { + static final KeychainManager shared = KeychainManager._(); + + final FlutterSecureStorage _storage = const FlutterSecureStorage( + aOptions: AndroidOptions( + encryptedSharedPreferences: true, + ), + iOptions: IOSOptions( + accessibility: KeychainAccessibility.first_unlock_this_device, + ), + ); + + KeychainManager._(); + + /// Store a value securely + Future store(String key, String value) async { + await _storage.write(key: key, value: value); + } + + /// Retrieve a value + Future retrieve(String key) async { + return _storage.read(key: key); + } + + /// Delete a value + Future delete(String key) async { + await _storage.delete(key: key); + } + + /// Store device UUID + Future storeDeviceUUID(String deviceId) async { + await store('com.runanywhere.sdk.device.uuid', deviceId); + } + + /// Retrieve device UUID + Future retrieveDeviceUUID() async { + return retrieve('com.runanywhere.sdk.device.uuid'); + } + + /// Store SDK initialization parameters + Future storeSDKParams({ + required String apiKey, + required Uri baseURL, + required String environment, + }) async { + await store('com.runanywhere.sdk.apiKey', apiKey); + await store('com.runanywhere.sdk.baseURL', baseURL.toString()); + await store('com.runanywhere.sdk.environment', environment); + } + + /// Retrieve SDK initialization parameters + Future> retrieveSDKParams() async { + return { + 'apiKey': await retrieve('com.runanywhere.sdk.apiKey'), + 'baseURL': await retrieve('com.runanywhere.sdk.baseURL'), + 'environment': await retrieve('com.runanywhere.sdk.environment'), + }; + } +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/foundation/security/secure_storage_keys.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/foundation/security/secure_storage_keys.dart new file mode 100644 index 000000000..22b023aa1 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/foundation/security/secure_storage_keys.dart @@ -0,0 +1,32 @@ +/// SecureStorageKeys +/// +/// Keychain/secure storage key constants +/// +/// Reference: sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Security/KeychainManager.swift +/// Reference: sdk/runanywhere-react-native/packages/core/src/Foundation/Security/SecureStorageKeys.ts +/// +/// These keys are used for: +/// - iOS: Keychain (survives app reinstalls) +/// - Android: EncryptedSharedPreferences (survives app reinstalls) +class SecureStorageKeys { + SecureStorageKeys._(); // Prevent instantiation + + // SDK Core + static const apiKey = 'com.runanywhere.sdk.apiKey'; + static const baseURL = 'com.runanywhere.sdk.baseURL'; + static const environment = 'com.runanywhere.sdk.environment'; + + // Device Identity (survives app reinstalls) + static const deviceUUID = 'com.runanywhere.sdk.device.uuid'; + static const deviceRegistered = 'com.runanywhere.sdk.device.isRegistered'; + + // Authentication Tokens + static const accessToken = 'com.runanywhere.sdk.accessToken'; + static const refreshToken = 'com.runanywhere.sdk.refreshToken'; + static const tokenExpiresAt = 'com.runanywhere.sdk.tokenExpiresAt'; + + // User/Org Identity + static const deviceId = 'com.runanywhere.sdk.deviceId'; + static const userId = 'com.runanywhere.sdk.userId'; + static const organizationId = 'com.runanywhere.sdk.organizationId'; +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/infrastructure/device/models/device_info.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/infrastructure/device/models/device_info.dart new file mode 100644 index 000000000..94d31e322 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/infrastructure/device/models/device_info.dart @@ -0,0 +1,200 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:runanywhere/infrastructure/device/services/device_identity.dart'; + +/// Core device hardware information. +/// +/// Mirrors iOS `DeviceInfo` from RunAnywhere SDK. +/// This is embedded in DeviceRegistrationRequest and also available standalone +/// via DeviceRegistrationService.currentDeviceInfo. +class DeviceInfo { + // MARK: - Device Identity + + /// Persistent device UUID (survives app reinstalls via Keychain) + final String deviceId; + + // MARK: - Device Hardware + + /// Device model identifier (e.g., "iPhone16,2" for iPhone 15 Pro Max) + final String modelIdentifier; + + /// User-friendly device name (e.g., "iPhone 15 Pro Max") + final String modelName; + + /// CPU architecture (e.g., "arm64", "x86_64") + final String architecture; + + // MARK: - Operating System + + /// Operating system version string (e.g., "17.2") + final String osVersion; + + /// Platform identifier (e.g., "iOS", "android") + final String platform; + + // MARK: - Device Classification + + /// Device type for API requests (mobile, tablet, desktop, tv, watch, vr) + final String deviceType; + + /// Form factor (phone, tablet, laptop, desktop, tv, watch, headset) + final String formFactor; + + // MARK: - Hardware Specs + + /// Total physical memory in bytes + final int totalMemory; + + /// Number of processor cores + final int processorCount; + + // MARK: - Initialization + + const DeviceInfo({ + required this.deviceId, + required this.modelIdentifier, + required this.modelName, + required this.architecture, + required this.osVersion, + required this.platform, + required this.deviceType, + required this.formFactor, + required this.totalMemory, + required this.processorCount, + }); + + // MARK: - JSON Serialization + + Map toJson() => { + 'device_id': deviceId, + 'model_identifier': modelIdentifier, + 'model_name': modelName, + 'architecture': architecture, + 'os_version': osVersion, + 'platform': platform, + 'device_type': deviceType, + 'form_factor': formFactor, + 'total_memory': totalMemory, + 'processor_count': processorCount, + }; + + factory DeviceInfo.fromJson(Map json) { + return DeviceInfo( + deviceId: json['device_id'] as String, + modelIdentifier: json['model_identifier'] as String, + modelName: json['model_name'] as String, + architecture: json['architecture'] as String, + osVersion: json['os_version'] as String, + platform: json['platform'] as String, + deviceType: json['device_type'] as String, + formFactor: json['form_factor'] as String, + totalMemory: json['total_memory'] as int, + processorCount: json['processor_count'] as int, + ); + } + + // MARK: - Computed Properties + + /// Clean OS version (e.g., "17.2" instead of "Version 17.2 (Build 21C52)") + String get cleanOSVersion { + final regex = RegExp(r'(\d+\.\d+(?:\.\d+)?)'); + final match = regex.firstMatch(osVersion); + return match?.group(1) ?? osVersion; + } + + // MARK: - Current Device Info + + /// Get current device info - called fresh each time. + /// Note: deviceId is async, so use DeviceInfo.fetchCurrent() for full info. + static DeviceInfo current(String deviceId) { + // Architecture + String architecture; + if (Platform.isIOS || Platform.isMacOS) { + // ARM64 on Apple Silicon, x86_64 on Intel + architecture = 'arm64'; // Assume ARM64 for modern devices + } else if (Platform.isAndroid) { + architecture = 'arm64'; // Most Android devices are ARM64 + } else { + architecture = 'x86_64'; + } + + // Platform and device type + String platformName; + String deviceType; + String formFactor; + String modelIdentifier; + String modelName; + + if (Platform.isIOS) { + platformName = 'iOS'; + deviceType = 'mobile'; + formFactor = 'phone'; + modelIdentifier = 'iPhone'; // Would need platform channel for real value + modelName = 'iPhone'; + } else if (Platform.isAndroid) { + platformName = 'android'; + deviceType = 'mobile'; + formFactor = 'phone'; + modelIdentifier = 'Android'; // Would need platform channel for real value + modelName = 'Android Device'; + } else if (Platform.isMacOS) { + platformName = 'macOS'; + deviceType = 'desktop'; + formFactor = 'desktop'; + modelIdentifier = 'Mac'; + modelName = 'Mac'; + } else if (Platform.isLinux) { + platformName = 'linux'; + deviceType = 'desktop'; + formFactor = 'desktop'; + modelIdentifier = 'Linux'; + modelName = 'Linux Device'; + } else if (Platform.isWindows) { + platformName = 'windows'; + deviceType = 'desktop'; + formFactor = 'desktop'; + modelIdentifier = 'Windows'; + modelName = 'Windows Device'; + } else { + platformName = 'unknown'; + deviceType = 'unknown'; + formFactor = 'unknown'; + modelIdentifier = 'Unknown'; + modelName = 'Unknown Device'; + } + + return DeviceInfo( + deviceId: deviceId, + modelIdentifier: modelIdentifier, + modelName: modelName, + architecture: architecture, + osVersion: Platform.operatingSystemVersion, + platform: platformName, + deviceType: deviceType, + formFactor: formFactor, + totalMemory: 0, // Would need platform channel + processorCount: Platform.numberOfProcessors, + ); + } + + /// Fetch current device info asynchronously (includes persistent deviceId). + static Future fetchCurrent() async { + final deviceId = await DeviceIdentity.persistentUUID; + return current(deviceId); + } + + @override + String toString() => + 'DeviceInfo(deviceId: ${deviceId.substring(0, 8)}..., model: $modelName, platform: $platform)'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is DeviceInfo && + runtimeType == other.runtimeType && + deviceId == other.deviceId; + + @override + int get hashCode => deviceId.hashCode; +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/infrastructure/device/services/device_identity.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/infrastructure/device/services/device_identity.dart new file mode 100644 index 000000000..b244b2c8f --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/infrastructure/device/services/device_identity.dart @@ -0,0 +1,54 @@ +import 'dart:async'; + +import 'package:runanywhere/foundation/logging/sdk_logger.dart'; +import 'package:runanywhere/foundation/security/keychain_manager.dart'; +import 'package:uuid/uuid.dart'; + +/// Simple utility for device identity management. +/// +/// Mirrors iOS `DeviceIdentity` from RunAnywhere SDK. +/// Provides persistent UUID that survives app reinstalls. +class DeviceIdentity { + static final _logger = SDKLogger('DeviceIdentity'); + static const _uuid = Uuid(); + + // Cached value for performance + static String? _cachedUUID; + + /// Get a persistent device UUID that survives app reinstalls. + /// Uses secure storage for persistence, generates new UUID if none exists. + static Future get persistentUUID async { + // Return cached value if available + if (_cachedUUID != null) { + return _cachedUUID!; + } + + // Strategy 1: Try to get from secure storage (survives app reinstalls) + final storedUUID = await KeychainManager.shared.retrieveDeviceUUID(); + if (storedUUID != null && storedUUID.isNotEmpty) { + _cachedUUID = storedUUID; + return storedUUID; + } + + // Strategy 2: Generate new UUID and store it + final newUUID = _uuid.v4(); + try { + await KeychainManager.shared.storeDeviceUUID(newUUID); + _logger.debug('Generated and stored new device UUID'); + } catch (e) { + _logger.warning('Failed to store device UUID: $e'); + } + _cachedUUID = newUUID; + return newUUID; + } + + /// Validate if a device UUID is properly formatted. + static bool validateUUID(String uuid) { + return uuid.length == 36 && uuid.contains('-'); + } + + /// Clear cached UUID (for testing). + static void clearCache() { + _cachedUUID = null; + } +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/infrastructure/download/download_service.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/infrastructure/download/download_service.dart new file mode 100644 index 000000000..b1b58a498 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/infrastructure/download/download_service.dart @@ -0,0 +1,341 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:archive/archive.dart'; +import 'package:http/http.dart' as http; +import 'package:path/path.dart' as p; +import 'package:runanywhere/core/types/model_types.dart'; +import 'package:runanywhere/foundation/logging/sdk_logger.dart'; +import 'package:runanywhere/native/dart_bridge_model_paths.dart'; +import 'package:runanywhere/public/events/event_bus.dart'; +import 'package:runanywhere/public/events/sdk_event.dart'; +import 'package:runanywhere/public/runanywhere.dart'; + +/// Download progress information +class ModelDownloadProgress { + final String modelId; + final int bytesDownloaded; + final int totalBytes; + final ModelDownloadStage stage; + final double overallProgress; + final String? error; + + const ModelDownloadProgress({ + required this.modelId, + required this.bytesDownloaded, + required this.totalBytes, + required this.stage, + required this.overallProgress, + this.error, + }); + + factory ModelDownloadProgress.started(String modelId, int totalBytes) => + ModelDownloadProgress( + modelId: modelId, + bytesDownloaded: 0, + totalBytes: totalBytes, + stage: ModelDownloadStage.downloading, + overallProgress: 0, + ); + + factory ModelDownloadProgress.downloading( + String modelId, + int downloaded, + int total, + ) => + ModelDownloadProgress( + modelId: modelId, + bytesDownloaded: downloaded, + totalBytes: total, + stage: ModelDownloadStage.downloading, + overallProgress: total > 0 ? downloaded / total * 0.9 : 0, + ); + + factory ModelDownloadProgress.extracting(String modelId) => + ModelDownloadProgress( + modelId: modelId, + bytesDownloaded: 0, + totalBytes: 0, + stage: ModelDownloadStage.extracting, + overallProgress: 0.92, + ); + + factory ModelDownloadProgress.completed(String modelId) => + ModelDownloadProgress( + modelId: modelId, + bytesDownloaded: 0, + totalBytes: 0, + stage: ModelDownloadStage.completed, + overallProgress: 1.0, + ); + + factory ModelDownloadProgress.failed(String modelId, String error) => + ModelDownloadProgress( + modelId: modelId, + bytesDownloaded: 0, + totalBytes: 0, + stage: ModelDownloadStage.failed, + overallProgress: 0, + error: error, + ); +} + +/// Download stages +enum ModelDownloadStage { + downloading, + extracting, + verifying, + completed, + failed, + cancelled; + + bool get isCompleted => this == ModelDownloadStage.completed; + bool get isFailed => this == ModelDownloadStage.failed; +} + +/// Model download service - handles actual file downloads +class ModelDownloadService { + static final ModelDownloadService shared = ModelDownloadService._(); + ModelDownloadService._(); + + final _logger = SDKLogger('ModelDownloadService'); + final Map _activeDownloads = {}; + + /// Download a model by ID + /// + /// Returns a stream of download progress updates. + Stream downloadModel(String modelId) async* { + _logger.info('Starting download for model: $modelId'); + + // Find the model + final models = await RunAnywhere.availableModels(); + final model = models.where((m) => m.id == modelId).firstOrNull; + + if (model == null) { + _logger.error('Model not found: $modelId'); + yield ModelDownloadProgress.failed(modelId, 'Model not found: $modelId'); + return; + } + + if (model.downloadURL == null) { + _logger.error('Model has no download URL: $modelId'); + yield ModelDownloadProgress.failed( + modelId, 'Model has no download URL: $modelId'); + return; + } + + // Emit download started event + EventBus.shared.publish(SDKModelEvent.downloadStarted(modelId: modelId)); + + try { + // Get destination directory + final destDir = await _getModelDirectory(model); + await destDir.create(recursive: true); + _logger.info('Download destination: ${destDir.path}'); + + // Determine if extraction is needed + final requiresExtraction = model.artifactType.requiresExtraction; + _logger.info('Requires extraction: $requiresExtraction'); + + // Determine the download file name + final downloadUrl = model.downloadURL!; + final fileName = p.basename(downloadUrl.path); + final downloadPath = p.join(destDir.path, fileName); + + // Create HTTP client + final client = http.Client(); + _activeDownloads[modelId] = client; + + try { + // Send HEAD request to get content length + final headResponse = await client.head(downloadUrl); + final totalBytes = + int.tryParse(headResponse.headers['content-length'] ?? '0') ?? + model.downloadSize ?? + 0; + + _logger.info('Total bytes to download: $totalBytes'); + yield ModelDownloadProgress.started(modelId, totalBytes); + + // Start download + final request = http.Request('GET', downloadUrl); + final response = await client.send(request); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception( + 'HTTP ${response.statusCode}: ${response.reasonPhrase}'); + } + + // Download with progress tracking + final file = File(downloadPath); + final sink = file.openWrite(); + var downloaded = 0; + + await for (final chunk in response.stream) { + sink.add(chunk); + downloaded += chunk.length; + + yield ModelDownloadProgress.downloading( + modelId, + downloaded, + totalBytes > 0 ? totalBytes : downloaded, + ); + } + + await sink.flush(); + await sink.close(); + + _logger.info('Download complete: ${file.path}'); + + // Handle extraction if needed + String finalModelPath = downloadPath; + if (requiresExtraction) { + yield ModelDownloadProgress.extracting(modelId); + + final extractedPath = await _extractArchive( + downloadPath, + destDir.path, + model.artifactType, + ); + finalModelPath = extractedPath; + + // Clean up archive file after extraction + try { + await File(downloadPath).delete(); + } catch (e) { + _logger.warning('Failed to delete archive: $e'); + } + } + + // Update model's local path + await _updateModelLocalPath(model, finalModelPath); + + // Emit completion + EventBus.shared.publish(SDKModelEvent.downloadCompleted( + modelId: modelId, + )); + + yield ModelDownloadProgress.completed(modelId); + _logger.info('Model download completed: $modelId -> $finalModelPath'); + } finally { + client.close(); + _activeDownloads.remove(modelId); + } + } catch (e, stack) { + _logger + .error('Download failed: $e', metadata: {'stack': stack.toString()}); + EventBus.shared.publish(SDKModelEvent.downloadFailed( + modelId: modelId, + error: e.toString(), + )); + yield ModelDownloadProgress.failed(modelId, e.toString()); + } + } + + /// Cancel an active download + void cancelDownload(String modelId) { + final client = _activeDownloads[modelId]; + if (client != null) { + client.close(); + _activeDownloads.remove(modelId); + _logger.info('Download cancelled: $modelId'); + } + } + + /// Get the model storage directory. + /// Uses C++ path functions to ensure consistency with discovery. + /// Matches Swift: CppBridge.ModelPaths.getModelFolder() + Future _getModelDirectory(ModelInfo model) async { + // Use C++ path functions - this creates the directory if needed + final modelPath = + await DartBridgeModelPaths.instance.getModelFolderAndCreate( + model.id, + model.framework, + ); + return Directory(modelPath); + } + + /// Extract an archive to the destination + Future _extractArchive( + String archivePath, + String destDir, + ModelArtifactType artifactType, + ) async { + _logger.info('Extracting archive: $archivePath'); + + final archiveFile = File(archivePath); + final bytes = await archiveFile.readAsBytes(); + + Archive? archive; + + // Determine archive type + if (archivePath.endsWith('.tar.gz') || archivePath.endsWith('.tgz')) { + final gzDecoded = GZipDecoder().decodeBytes(bytes); + archive = TarDecoder().decodeBytes(gzDecoded); + } else if (archivePath.endsWith('.tar.bz2') || + archivePath.endsWith('.tbz2')) { + final bz2Decoded = BZip2Decoder().decodeBytes(bytes); + archive = TarDecoder().decodeBytes(bz2Decoded); + } else if (archivePath.endsWith('.zip')) { + archive = ZipDecoder().decodeBytes(bytes); + } else if (archivePath.endsWith('.tar')) { + archive = TarDecoder().decodeBytes(bytes); + } else { + _logger.warning('Unknown archive format: $archivePath'); + return archivePath; + } + + // Extract files + String? rootDir; + for (final file in archive) { + final filePath = p.join(destDir, file.name); + + if (file.isFile) { + final outFile = File(filePath); + await outFile.create(recursive: true); + await outFile.writeAsBytes(file.content as List); + _logger.debug('Extracted: ${file.name}'); + + // Track root directory + final parts = file.name.split('/'); + if (parts.isNotEmpty && rootDir == null) { + rootDir = parts.first; + } + } else { + await Directory(filePath).create(recursive: true); + } + } + + _logger.info('Extraction complete: $destDir'); + + // Return the model directory (could be a nested directory) + if (rootDir != null) { + final nestedPath = p.join(destDir, rootDir); + if (await Directory(nestedPath).exists()) { + return nestedPath; + } + } + + return destDir; + } + + /// Update model's local path after download + Future _updateModelLocalPath(ModelInfo model, String path) async { + model.localPath = Uri.file(path); + _logger.info('Updated model local path: ${model.id} -> $path'); + + // Also update the C++ registry so model is discoverable + await _updateModelRegistry(model.id, path); + } + + /// Update the C++ model registry (for persistence across app restarts) + Future _updateModelRegistry(String modelId, String path) async { + try { + // Update the C++ registry so model is discoverable + // Matches Swift: CppBridge.ModelRegistry.shared.updateDownloadStatus() + await RunAnywhere.updateModelDownloadStatus(modelId, path); + } catch (e) { + _logger.debug('Could not update C++ registry: $e'); + } + } +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/infrastructure/events/event_publisher.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/infrastructure/events/event_publisher.dart new file mode 100644 index 000000000..2fbb248fe --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/infrastructure/events/event_publisher.dart @@ -0,0 +1,319 @@ +/// Event Publisher +/// +/// Routes events to public EventBus and/or C++ telemetry based on destination. +/// Mirrors iOS SDK's event routing pattern where C++ handles telemetry. +library event_publisher; + +import 'dart:async'; + +import 'package:runanywhere/native/dart_bridge_telemetry.dart'; +import 'package:runanywhere/public/events/event_bus.dart'; +import 'package:runanywhere/public/events/sdk_event.dart'; + +/// Routes SDK events to appropriate destinations. +/// +/// Mirrors iOS pattern where: +/// - Public events go to EventBus (Dart streams) +/// - Analytics events go to C++ telemetry via DartBridge FFI +/// +/// Usage: +/// ```dart +/// EventPublisher.shared.track(LLMEvent.generationCompleted(...)); +/// ``` +class EventPublisher { + // MARK: - Singleton + + /// Shared instance + static final EventPublisher shared = EventPublisher._(); + + EventPublisher._({EventBus? eventBus}) + : _eventBus = eventBus ?? EventBus.shared; + + // MARK: - Dependencies + + final EventBus _eventBus; + + // MARK: - Track + + /// Track an event. Routes automatically based on event.destination. + /// + /// - all: To both EventBus and C++ telemetry (default) + /// - publicOnly: Only to EventBus (app developers can subscribe) + /// - analyticsOnly: Only to C++ telemetry (backend) + void track(SDKEvent event) { + final destination = event.destination; + + // Route to EventBus (public) - app developers subscribe here + if (destination == EventDestination.all || + destination == EventDestination.publicOnly) { + _eventBus.publish(event); + } + + // Route to C++ telemetry via DartBridge FFI + if (destination == EventDestination.all || + destination == EventDestination.analyticsOnly) { + _trackToTelemetry(event); + } + } + + /// Track an event asynchronously (for use in async contexts). + Future trackAsync(SDKEvent event) async { + track(event); + } + + // MARK: - Internal + + /// Route event to C++ telemetry system via DartBridge. + /// C++ handles JSON serialization, batching, and HTTP transport. + void _trackToTelemetry(SDKEvent event) { + // Map event to C++ telemetry call + // The DartBridgeTelemetry provides typed emit methods matching iOS pattern + switch (event.category) { + case EventCategory.model: + _trackModelEvent(event); + break; + case EventCategory.llm: + _trackLLMEvent(event); + break; + case EventCategory.stt: + _trackSTTEvent(event); + break; + case EventCategory.tts: + _trackTTSEvent(event); + break; + case EventCategory.sdk: + _trackSDKEvent(event); + break; + case EventCategory.storage: + _trackStorageEvent(event); + break; + case EventCategory.device: + _trackDeviceEvent(event); + break; + case EventCategory.voice: + _trackVoiceEvent(event); + break; + case EventCategory.vad: + _trackVADEvent(event); + break; + case EventCategory.network: + case EventCategory.error: + // These are logged but not sent to telemetry + break; + } + } + + void _trackModelEvent(SDKEvent event) { + final props = event.properties; + final modelId = props['modelId'] ?? ''; + final modelName = props['modelName'] ?? ''; + final framework = props['framework'] ?? ''; + + switch (event.type) { + case 'model.download.started': + unawaited(DartBridgeTelemetry.instance.emitDownloadStarted( + modelId: modelId, + modelName: modelName, + modelSize: int.tryParse(props['modelSize'] ?? '0') ?? 0, + framework: framework, + )); + break; + case 'model.download.completed': + unawaited(DartBridgeTelemetry.instance.emitDownloadCompleted( + modelId: modelId, + modelName: modelName, + modelSize: int.tryParse(props['modelSize'] ?? '0') ?? 0, + framework: framework, + durationMs: int.tryParse(props['durationMs'] ?? '0') ?? 0, + )); + break; + case 'model.download.failed': + unawaited(DartBridgeTelemetry.instance.emitDownloadFailed( + modelId: modelId, + modelName: modelName, + error: props['error'] ?? 'Unknown error', + framework: framework, + )); + break; + case 'model.extraction.started': + unawaited(DartBridgeTelemetry.instance.emitExtractionStarted( + modelId: modelId, + modelName: modelName, + framework: framework, + )); + break; + case 'model.extraction.completed': + unawaited(DartBridgeTelemetry.instance.emitExtractionCompleted( + modelId: modelId, + modelName: modelName, + framework: framework, + durationMs: int.tryParse(props['durationMs'] ?? '0') ?? 0, + )); + break; + case 'model.loaded': + unawaited(DartBridgeTelemetry.instance.emitModelLoaded( + modelId: modelId, + modelName: modelName, + framework: framework, + durationMs: int.tryParse(props['durationMs'] ?? '0') ?? 0, + )); + break; + } + } + + void _trackLLMEvent(SDKEvent event) { + final props = event.properties; + final modelId = props['modelId'] ?? ''; + final modelName = props['modelName'] ?? ''; + + switch (event.type) { + case 'llm.generation.completed': + unawaited(DartBridgeTelemetry.instance.emitInferenceCompleted( + modelId: modelId, + modelName: modelName, + modality: 'llm', + durationMs: int.tryParse(props['durationMs'] ?? '0') ?? 0, + tokensGenerated: int.tryParse(props['tokensGenerated'] ?? ''), + tokensPerSecond: double.tryParse(props['tokensPerSecond'] ?? ''), + )); + break; + } + } + + void _trackSTTEvent(SDKEvent event) { + final props = event.properties; + final modelId = props['modelId'] ?? ''; + final modelName = props['modelName'] ?? ''; + + switch (event.type) { + case 'stt.transcription.completed': + unawaited(DartBridgeTelemetry.instance.emitInferenceCompleted( + modelId: modelId, + modelName: modelName, + modality: 'stt', + durationMs: int.tryParse(props['durationMs'] ?? '0') ?? 0, + )); + break; + } + } + + void _trackTTSEvent(SDKEvent event) { + final props = event.properties; + final modelId = props['modelId'] ?? ''; + final modelName = props['modelName'] ?? ''; + + switch (event.type) { + case 'tts.synthesis.completed': + unawaited(DartBridgeTelemetry.instance.emitInferenceCompleted( + modelId: modelId, + modelName: modelName, + modality: 'tts', + durationMs: int.tryParse(props['durationMs'] ?? '0') ?? 0, + )); + break; + } + } + + void _trackSDKEvent(SDKEvent event) { + final props = event.properties; + + switch (event.type) { + case 'sdk.initialized': + unawaited(DartBridgeTelemetry.instance.emitSDKInitialized( + durationMs: int.tryParse(props['durationMs'] ?? '0') ?? 0, + environment: props['environment'] ?? 'production', + )); + break; + } + } + + void _trackStorageEvent(SDKEvent event) { + final props = event.properties; + + switch (event.type) { + case 'storage.cache.cleared': + unawaited(DartBridgeTelemetry.instance.emitStorageCacheCleared( + freedBytes: int.tryParse(props['freedBytes'] ?? '0') ?? 0, + )); + break; + case 'storage.cache.clear_failed': + unawaited(DartBridgeTelemetry.instance.emitStorageCacheClearFailed( + error: props['error'] ?? 'Unknown error', + )); + break; + case 'storage.temp.cleaned': + unawaited(DartBridgeTelemetry.instance.emitStorageTempCleaned( + freedBytes: int.tryParse(props['freedBytes'] ?? '0') ?? 0, + )); + break; + } + } + + void _trackDeviceEvent(SDKEvent event) { + final props = event.properties; + + switch (event.type) { + case 'device.registered': + unawaited(DartBridgeTelemetry.instance.emitDeviceRegistered( + deviceId: props['deviceId'] ?? '', + )); + break; + case 'device.registration_failed': + unawaited(DartBridgeTelemetry.instance.emitDeviceRegistrationFailed( + error: props['error'] ?? 'Unknown error', + )); + break; + } + } + + void _trackVoiceEvent(SDKEvent event) { + final props = event.properties; + + switch (event.type) { + case 'voice.turn.started': + unawaited(DartBridgeTelemetry.instance.emitVoiceAgentTurnStarted()); + break; + case 'voice.turn.completed': + unawaited(DartBridgeTelemetry.instance.emitVoiceAgentTurnCompleted( + durationMs: int.tryParse(props['durationMs'] ?? '0') ?? 0, + )); + break; + case 'voice.turn.failed': + unawaited(DartBridgeTelemetry.instance.emitVoiceAgentTurnFailed( + error: props['error'] ?? 'Unknown error', + )); + break; + case 'voice.stt.state_changed': + unawaited(DartBridgeTelemetry.instance.emitVoiceAgentSttStateChanged( + state: props['state'] ?? 'unknown', + )); + break; + case 'voice.llm.state_changed': + unawaited(DartBridgeTelemetry.instance.emitVoiceAgentLlmStateChanged( + state: props['state'] ?? 'unknown', + )); + break; + case 'voice.tts.state_changed': + unawaited(DartBridgeTelemetry.instance.emitVoiceAgentTtsStateChanged( + state: props['state'] ?? 'unknown', + )); + break; + case 'voice.all_ready': + unawaited(DartBridgeTelemetry.instance.emitVoiceAgentAllReady()); + break; + } + } + + void _trackVADEvent(SDKEvent event) { + // VAD events are part of voice pipeline, tracked as voice events + // Individual VAD detections are typically not telemetered (too frequent) + // Only aggregate stats would be tracked if needed + switch (event.type) { + case 'vad.speech_started': + case 'vad.speech_ended': + // These are high-frequency events, logged locally but not sent to telemetry + // to avoid overwhelming the backend + break; + } + } +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/infrastructure/file_management/services/simplified_file_manager.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/infrastructure/file_management/services/simplified_file_manager.dart new file mode 100644 index 000000000..424649a47 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/infrastructure/file_management/services/simplified_file_manager.dart @@ -0,0 +1,228 @@ +/// Simplified File Manager +/// +/// File manager for RunAnywhere SDK. +/// Matches iOS SimplifiedFileManager from Infrastructure/FileManagement/Services/. +library simplified_file_manager; + +import 'dart:async'; +import 'dart:io'; + +import 'package:path/path.dart' as path; +import 'package:path_provider/path_provider.dart'; +import 'package:runanywhere/core/types/storage_types.dart'; +import 'package:runanywhere/foundation/logging/sdk_logger.dart'; + +/// File manager for RunAnywhere SDK +/// Matches iOS SimplifiedFileManager from Infrastructure/FileManagement/Services/SimplifiedFileManager.swift +/// +/// Directory Structure: +/// ``` +/// Documents/RunAnywhere/ +/// Models/ +/// {framework}/ # e.g., "onnx", "llamacpp" +/// {modelId}/ # e.g., "sherpa-onnx-whisper-tiny.en" +/// [model files] +/// Cache/ +/// Temp/ +/// Downloads/ +/// ``` +class SimplifiedFileManager { + final SDKLogger _logger = SDKLogger('FileManager'); + + Directory? _baseDirectory; + + SimplifiedFileManager(); + + /// Initialize the file manager + Future initialize() async { + final documentsDir = await getApplicationDocumentsDirectory(); + _baseDirectory = Directory(path.join(documentsDir.path, 'RunAnywhere')); + await _createDirectoryStructure(); + } + + Future _createDirectoryStructure() async { + if (_baseDirectory == null) return; + + final subdirs = ['Models', 'Cache', 'Temp', 'Downloads']; + for (final subdir in subdirs) { + final dir = Directory(path.join(_baseDirectory!.path, subdir)); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + } + } + + /// Get the model folder path, creating it if necessary + Future getModelFolder({ + required String modelId, + required String framework, + }) async { + _ensureInitialized(); + final folderPath = path.join( + _baseDirectory!.path, + 'Models', + framework, + modelId, + ); + final folder = Directory(folderPath); + if (!await folder.exists()) { + await folder.create(recursive: true); + } + return folderPath; + } + + /// Get the model folder path without creating + String getModelFolderPath({ + required String modelId, + required String framework, + }) { + _ensureInitialized(); + return path.join(_baseDirectory!.path, 'Models', framework, modelId); + } + + /// Check if model folder exists + bool modelFolderExists({ + required String modelId, + required String framework, + }) { + _ensureInitialized(); + final folderPath = path.join( + _baseDirectory!.path, + 'Models', + framework, + modelId, + ); + return Directory(folderPath).existsSync(); + } + + /// Get the models root directory + Future getModelsDirectory() async { + _ensureInitialized(); + return path.join(_baseDirectory!.path, 'Models'); + } + + /// Get the downloads directory + Future getDownloadsDirectory() async { + _ensureInitialized(); + return path.join(_baseDirectory!.path, 'Downloads'); + } + + /// Get the cache directory + Future getCacheDirectory() async { + _ensureInitialized(); + return path.join(_baseDirectory!.path, 'Cache'); + } + + /// Get the temp directory + Future getTempDirectory() async { + _ensureInitialized(); + return path.join(_baseDirectory!.path, 'Temp'); + } + + /// Check if a file exists + Future fileExists(String filePath) async { + return File(filePath).exists(); + } + + /// Get file size in bytes + Future getFileSize(String filePath) async { + final file = File(filePath); + if (await file.exists()) { + return file.length(); + } + return 0; + } + + /// Delete a file + Future deleteFile(String filePath) async { + final file = File(filePath); + if (await file.exists()) { + await file.delete(); + _logger.info('Deleted file: $filePath'); + } + } + + /// Delete a model folder + Future deleteModelFolder({ + required String modelId, + required String framework, + }) async { + _ensureInitialized(); + final folderPath = path.join( + _baseDirectory!.path, + 'Models', + framework, + modelId, + ); + final folder = Directory(folderPath); + if (await folder.exists()) { + await folder.delete(recursive: true); + _logger.info('Deleted model folder: $folderPath'); + } + } + + /// Calculate total size of all models + Future calculateModelsSize() async { + _ensureInitialized(); + final modelsDir = Directory(path.join(_baseDirectory!.path, 'Models')); + if (!await modelsDir.exists()) return 0; + + int totalSize = 0; + await for (final entity in modelsDir.list(recursive: true)) { + if (entity is File) { + totalSize += await entity.length(); + } + } + return totalSize; + } + + /// Get device storage info + DeviceStorageInfo getDeviceStorageInfo() { + // Get device storage stats + // Note: This is a simplified implementation + return const DeviceStorageInfo( + totalSpace: 0, + freeSpace: 0, + usedSpace: 0, + ); + } + + /// Clear all cache + Future clearCache() async { + _ensureInitialized(); + final cacheDir = Directory(path.join(_baseDirectory!.path, 'Cache')); + if (await cacheDir.exists()) { + await for (final entity in cacheDir.list()) { + if (entity is File) { + await entity.delete(); + } else if (entity is Directory) { + await entity.delete(recursive: true); + } + } + _logger.info('Cache cleared'); + } + } + + /// Clear all temporary files + Future clearTemp() async { + _ensureInitialized(); + final tempDir = Directory(path.join(_baseDirectory!.path, 'Temp')); + if (await tempDir.exists()) { + await for (final entity in tempDir.list()) { + if (entity is File) { + await entity.delete(); + } else if (entity is Directory) { + await entity.delete(recursive: true); + } + } + _logger.info('Temp directory cleared'); + } + } + + void _ensureInitialized() { + if (_baseDirectory == null) { + throw StateError( + 'SimplifiedFileManager not initialized. Call initialize() first.'); + } + } +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge.dart new file mode 100644 index 000000000..c831a7462 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge.dart @@ -0,0 +1,390 @@ +// ignore_for_file: avoid_classes_with_only_static_members + +import 'dart:async'; +import 'dart:ffi'; + +import 'package:ffi/ffi.dart'; +import 'package:runanywhere/foundation/configuration/sdk_constants.dart'; +import 'package:runanywhere/foundation/logging/sdk_logger.dart'; +import 'package:runanywhere/native/dart_bridge_auth.dart' + hide RacSdkConfigStruct; +import 'package:runanywhere/native/dart_bridge_device.dart'; +import 'package:runanywhere/native/dart_bridge_download.dart'; +import 'package:runanywhere/native/dart_bridge_environment.dart' + show RacSdkConfigStruct; +import 'package:runanywhere/native/dart_bridge_events.dart'; +import 'package:runanywhere/native/dart_bridge_http.dart'; +import 'package:runanywhere/native/dart_bridge_llm.dart'; +import 'package:runanywhere/native/dart_bridge_model_assignment.dart'; +import 'package:runanywhere/native/dart_bridge_model_paths.dart'; +import 'package:runanywhere/native/dart_bridge_model_registry.dart'; +import 'package:runanywhere/native/dart_bridge_platform.dart'; +import 'package:runanywhere/native/dart_bridge_platform_services.dart'; +import 'package:runanywhere/native/dart_bridge_state.dart'; +import 'package:runanywhere/native/dart_bridge_storage.dart'; +import 'package:runanywhere/native/dart_bridge_stt.dart'; +import 'package:runanywhere/native/dart_bridge_telemetry.dart'; +import 'package:runanywhere/native/dart_bridge_tts.dart'; +import 'package:runanywhere/native/dart_bridge_vad.dart'; +import 'package:runanywhere/native/dart_bridge_voice_agent.dart'; +import 'package:runanywhere/native/platform_loader.dart'; +import 'package:runanywhere/public/configuration/sdk_environment.dart'; + +/// Central coordinator for all C++ bridges. +/// +/// Matches Swift's `CppBridge` pattern exactly: +/// - 2-phase initialization (core sync + services async) +/// - Platform adapter registration (file ops, logging, keychain) +/// - Event callback registration +/// - Module registration coordination +/// +/// Usage: +/// ```dart +/// // Phase 1: Core init (sync, ~1-5ms) +/// DartBridge.initialize(SDKEnvironment.production); +/// +/// // Phase 2: Services init (async, ~100-500ms) +/// await DartBridge.initializeServices(); +/// ``` +class DartBridge { + DartBridge._(); + + static final _logger = SDKLogger('DartBridge'); + + // ------------------------------------------------------------------------- + // State + // ------------------------------------------------------------------------- + + static SDKEnvironment _environment = SDKEnvironment.development; + static bool _isInitialized = false; + static bool _servicesInitialized = false; + static DynamicLibrary? _lib; + + /// Current environment + static SDKEnvironment get environment => _environment; + + /// Whether Phase 1 (core) initialization is complete + static bool get isInitialized => _isInitialized; + + /// Whether Phase 2 (services) initialization is complete + static bool get servicesInitialized => _servicesInitialized; + + /// Native library reference + static DynamicLibrary get lib { + _lib ??= PlatformLoader.load(); + return _lib!; + } + + // ------------------------------------------------------------------------- + // Phase 1: Core Initialization (Sync) + // ------------------------------------------------------------------------- + + /// Initialize the core bridge layer. + /// + /// This is Phase 1 of 2-phase initialization (matches Swift CppBridge.initialize exactly): + /// 1. Load native library + /// 2. Register platform adapter FIRST (file ops, logging, keychain) + /// 3. Configure C++ logging level (rac_configure_logging) + /// 4. Initialize SDK config (rac_sdk_init) - sets platform, version + /// 5. Register events callback (analytics routing) + /// 6. Initialize telemetry manager + /// 7. Register device callbacks + /// + /// Call this FIRST during SDK init. Must complete before Phase 2. + /// + /// [environment] The SDK environment (development/staging/production) + static void initialize(SDKEnvironment environment) { + if (_isInitialized) { + _logger.debug('Already initialized, skipping'); + return; + } + + _environment = environment; + _logger.debug('Starting Phase 1 initialization', metadata: { + 'environment': environment.name, + }); + + // Step 1: Load native library + _lib = PlatformLoader.load(); + _logger.debug('Native library loaded'); + + // Step 2: Register platform adapter FIRST (file ops, logging, keychain) + // C++ needs these callbacks before any other operations + // Matches Swift: PlatformAdapter.register() + DartBridgePlatform.register(); + _logger.debug('Platform adapter registered'); + + // Step 3: Configure C++ logging level + // Matches Swift: rac_configure_logging(environment.cEnvironment) + _configureLogging(environment); + _logger.debug('C++ logging configured'); + + // Step 4: Initialize SDK with configuration + // Matches Swift: rac_sdk_init(&sdkConfig) in CppBridge.State.initialize() + // This is CRITICAL - the LlamaCPP backend needs this to be set + _initializeSdkConfig(environment); + _logger.debug('SDK config initialized'); + + // Step 5: Register events callback (analytics routing) + // Matches Swift: Events.register() + DartBridgeEvents.register(); + _logger.debug('Events callback registered'); + + // Step 6: Initialize telemetry manager (sync part) + // Matches Swift: Telemetry.initialize(environment: environment) + // Note: Full telemetry init with HTTP is in Phase 2 + DartBridgeTelemetry.initializeSync(environment: environment); + _logger.debug('Telemetry initialized (sync)'); + + // Step 7: Register device callbacks + // Matches Swift: Device.register() + DartBridgeDevice.registerCallbacks(); + _logger.debug('Device callbacks registered'); + + _isInitialized = true; + _logger.info('Phase 1 initialization complete'); + } + + // ------------------------------------------------------------------------- + // Phase 2: Services Initialization (Async) + // ------------------------------------------------------------------------- + + /// Initialize service bridges. + /// + /// This is Phase 2 of 2-phase initialization (matches Swift completeServicesInitialization): + /// 1. Setup HTTP transport (if needed) + /// 2. Initialize C++ state (rac_state_initialize with apiKey, baseURL, deviceId) + /// 3. Initialize service bridges (ModelAssignment, Platform) + /// 4. Model paths base directory (done in RunAnywhere.initializeWithParams) + /// 5. Device registration (if needed) + /// 6. Flush telemetry + /// + /// Call this AFTER Phase 1. Can be called in background. + /// + /// [apiKey] API key for production/staging + /// [baseURL] Backend URL for production/staging + /// [deviceId] Device identifier + static Future initializeServices({ + String? apiKey, + String? baseURL, + String? deviceId, + }) async { + if (!_isInitialized) { + throw StateError('Must call initialize() before initializeServices()'); + } + + if (_servicesInitialized) { + _logger.debug('Services already initialized, skipping'); + return; + } + + _logger.debug('Starting Phase 2 services initialization'); + + // Step 1: Get device ID if not provided + final effectiveDeviceId = + deviceId ?? DartBridgeDevice.cachedDeviceId ?? 'unknown-device'; + + // Step 2: Initialize C++ state with credentials + // Matches Swift: CppBridge.State.initialize(environment:apiKey:baseURL:deviceId:) + await DartBridgeState.instance.initialize( + environment: _environment, + apiKey: apiKey, + baseURL: baseURL, + deviceId: effectiveDeviceId, + ); + _logger.debug('C++ state initialized'); + + // Step 3: Initialize service bridges + // Matches Swift: CppBridge.initializeServices() + + // Step 3a: Model assignment callbacks + // Only auto-fetch in staging/production, not development + final shouldAutoFetch = _environment != SDKEnvironment.development; + await DartBridgeModelAssignment.register( + environment: _environment, + autoFetch: shouldAutoFetch, + ); + _logger.debug( + 'Model assignment callbacks registered (autoFetch: $shouldAutoFetch)'); + + // Step 3b: Platform services (Foundation Models, System TTS) + await DartBridgePlatformServices.register(); + _logger.debug('Platform services registered'); + + // Step 4: Flush telemetry (if any queued events) + // Matches Swift: CppBridge.Telemetry.flush() + DartBridgeTelemetry.flush(); + _logger.debug('Telemetry flushed'); + + _servicesInitialized = true; + _logger.info('Phase 2 services initialization complete'); + } + + // ------------------------------------------------------------------------- + // Shutdown + // ------------------------------------------------------------------------- + + /// Shutdown all bridges and release resources. + static void shutdown() { + if (!_isInitialized) { + _logger.debug('Not initialized, nothing to shutdown'); + return; + } + + _logger.debug('Shutting down DartBridge'); + + // Shutdown in reverse order of initialization + DartBridgeTelemetry.shutdown(); + DartBridgeEvents.unregister(); + + _isInitialized = false; + _servicesInitialized = false; + + _logger.info('DartBridge shutdown complete'); + } + + // ------------------------------------------------------------------------- + // Bridge Extensions (static accessors matching Swift pattern) + // ------------------------------------------------------------------------- + + /// Authentication bridge + static DartBridgeAuth get auth => DartBridgeAuth.instance; + + /// Device bridge + static DartBridgeDevice get device => DartBridgeDevice.instance; + + /// Download bridge + static DartBridgeDownload get download => DartBridgeDownload.instance; + + /// Events bridge + static DartBridgeEvents get events => DartBridgeEvents.instance; + + /// HTTP bridge + static DartBridgeHTTP get http => DartBridgeHTTP.instance; + + /// LLM bridge + static DartBridgeLLM get llm => DartBridgeLLM.shared; + + /// Model assignment bridge + static DartBridgeModelAssignment get modelAssignment => + DartBridgeModelAssignment.instance; + + /// Model paths bridge + static DartBridgeModelPaths get modelPaths => DartBridgeModelPaths.instance; + + /// Model registry bridge + static DartBridgeModelRegistry get modelRegistry => + DartBridgeModelRegistry.instance; + + /// Platform bridge + static DartBridgePlatform get platform => DartBridgePlatform.instance; + + /// Platform services bridge + static DartBridgePlatformServices get platformServices => + DartBridgePlatformServices.instance; + + /// State bridge + static DartBridgeState get state => DartBridgeState.instance; + + /// Storage bridge + static DartBridgeStorage get storage => DartBridgeStorage.instance; + + /// STT bridge + static DartBridgeSTT get stt => DartBridgeSTT.shared; + + /// Telemetry bridge + static DartBridgeTelemetry get telemetry => DartBridgeTelemetry.instance; + + /// TTS bridge + static DartBridgeTTS get tts => DartBridgeTTS.shared; + + /// VAD bridge + static DartBridgeVAD get vad => DartBridgeVAD.shared; + + /// Voice agent bridge + static DartBridgeVoiceAgent get voiceAgent => DartBridgeVoiceAgent.shared; + + // ------------------------------------------------------------------------- + // Private Helpers + // ------------------------------------------------------------------------- + + /// Configure C++ logging based on environment + static void _configureLogging(SDKEnvironment environment) { + int logLevel; + switch (environment) { + case SDKEnvironment.development: + logLevel = RacLogLevel.debug; + break; + case SDKEnvironment.staging: + logLevel = RacLogLevel.info; + break; + case SDKEnvironment.production: + logLevel = RacLogLevel.warning; + break; + } + + try { + final configureLogging = + lib.lookupFunction( + 'rac_configure_logging'); + configureLogging(logLevel); + } catch (e) { + _logger.warning('Failed to configure C++ logging: $e'); + } + } + + /// Initialize SDK configuration in C++ (matches Swift's rac_sdk_init call) + /// This is critical for the LlamaCPP backend to function correctly. + static void _initializeSdkConfig(SDKEnvironment environment) { + try { + final sdkInit = lib.lookupFunction< + Int32 Function(Pointer), + int Function(Pointer)>('rac_sdk_init'); + + final config = calloc(); + final platformPtr = 'flutter'.toNativeUtf8(); + final sdkVersionPtr = SDKConstants.version.toNativeUtf8(); + + try { + config.ref.environment = _environmentToInt(environment); + config.ref.apiKey = nullptr; // Set later if available + config.ref.baseURL = nullptr; // Set later if available + config.ref.deviceId = nullptr; // Set later if available + config.ref.platform = platformPtr; + config.ref.sdkVersion = sdkVersionPtr; + + final result = sdkInit(config); + if (result != 0) { + _logger.warning('rac_sdk_init returned: $result'); + } + } finally { + calloc.free(platformPtr); + calloc.free(sdkVersionPtr); + calloc.free(config); + } + } catch (e) { + _logger.debug('rac_sdk_init not available: $e'); + } + } + + /// Convert environment to C int value + static int _environmentToInt(SDKEnvironment env) { + switch (env) { + case SDKEnvironment.development: + return 0; + case SDKEnvironment.staging: + return 1; + case SDKEnvironment.production: + return 2; + } + } +} + +/// Log level constants matching rac_log_level_t +abstract class RacLogLevel { + static const int error = 0; + static const int warning = 1; + static const int info = 2; + static const int debug = 3; + static const int trace = 4; +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_auth.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_auth.dart new file mode 100644 index 000000000..d7562d80c --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_auth.dart @@ -0,0 +1,941 @@ +// ignore_for_file: avoid_classes_with_only_static_members + +import 'dart:async'; +import 'dart:convert'; +import 'dart:ffi'; +import 'dart:io' show Platform; + +import 'package:ffi/ffi.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:http/http.dart' as http; + +import 'package:runanywhere/foundation/configuration/sdk_constants.dart'; +import 'package:runanywhere/foundation/logging/sdk_logger.dart'; +import 'package:runanywhere/native/dart_bridge_device.dart'; +import 'package:runanywhere/native/dart_bridge_state.dart'; +import 'package:runanywhere/native/platform_loader.dart'; +import 'package:runanywhere/public/configuration/sdk_environment.dart'; + +// ============================================================================= +// Secure Storage Callbacks +// ============================================================================= + +const int _exceptionalReturnInt = -1; + +// ============================================================================= +// Auth Manager Bridge +// ============================================================================= + +/// Authentication bridge for C++ auth operations. +/// Matches Swift's `CppBridge+Auth.swift`. +/// +/// C++ handles: +/// - Token expiry/refresh logic +/// - JSON building for auth requests +/// - Auth state management +/// +/// Dart provides: +/// - Secure storage (via flutter_secure_storage) +/// - HTTP transport for auth requests +class DartBridgeAuth { + DartBridgeAuth._(); + + static final _logger = SDKLogger('DartBridge.Auth'); + static final DartBridgeAuth instance = DartBridgeAuth._(); + + static bool _isInitialized = false; + static String? _baseURL; + static SDKEnvironment _environment = SDKEnvironment.development; + + /// Secure storage callbacks pointer + static Pointer? _storagePtr; + + // ============================================================================ + // Initialization + // ============================================================================ + + /// Initialize auth manager with secure storage callbacks + static Future initialize({ + required SDKEnvironment environment, + String? baseURL, + }) async { + if (_isInitialized) return; + + _environment = environment; + _baseURL = baseURL; + + try { + final lib = PlatformLoader.loadCommons(); + + // Allocate and set up secure storage callbacks + _storagePtr = calloc(); + _storagePtr!.ref.store = + Pointer.fromFunction( + _secureStoreCallback, _exceptionalReturnInt); + _storagePtr!.ref.retrieve = + Pointer.fromFunction( + _secureRetrieveCallback, _exceptionalReturnInt); + _storagePtr!.ref.deleteKey = + Pointer.fromFunction( + _secureDeleteCallback, _exceptionalReturnInt); + _storagePtr!.ref.context = nullptr; + + // Initialize auth with storage + final initAuth = lib.lookupFunction< + Void Function(Pointer), + void Function( + Pointer)>('rac_auth_init'); + + initAuth(_storagePtr!); + + // Load stored tokens + await instance._loadStoredTokens(); + + _isInitialized = true; + _logger.debug('Auth manager initialized'); + } catch (e, stack) { + _logger.debug('Auth initialization error: $e', metadata: { + 'stack': stack.toString(), + }); + _isInitialized = true; // Avoid retry loops + } + } + + /// Reset auth manager + static void reset() { + try { + final lib = PlatformLoader.loadCommons(); + final resetFn = + lib.lookupFunction('rac_auth_reset'); + resetFn(); + } catch (e) { + _logger.debug('rac_auth_reset not available: $e'); + } + } + + // ============================================================================ + // Authentication Flow + // ============================================================================ + + /// Authenticate with API key + /// Returns auth response with tokens + Future authenticate({ + required String apiKey, + required String deviceId, + String? buildToken, + }) async { + try { + // Build authenticate request JSON via C++ + final requestJson = _buildAuthenticateRequestJSON( + apiKey: apiKey, + deviceId: deviceId, + buildToken: buildToken, + ); + + if (requestJson == null) { + return AuthResult.failure('Failed to build auth request'); + } + + // Make HTTP request + final endpoint = _getAuthEndpoint(); + final baseURL = _baseURL ?? _getDefaultBaseURL(); + final url = Uri.parse('$baseURL$endpoint'); + + _logger.debug('Auth POST to: $url'); + _logger.debug('Auth body: $requestJson'); + + final response = await http.post( + url, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: requestJson, + ); + + _logger.debug('Auth response status: ${response.statusCode}'); + _logger.debug('Auth response body: ${response.body}'); + + if (response.statusCode == 200 || response.statusCode == 201) { + // Parse response and store tokens + final authData = _parseAuthResponse(response.body); + + // Try C++ handler first + final cppSuccess = _handleAuthenticateResponse(response.body); + + // Also manually store tokens in secure storage (in case C++ handler unavailable) + await _storeAuthTokens(authData); + + if (cppSuccess || authData.accessToken != null) { + _logger.info('Authentication successful'); + return AuthResult.success(authData); + } else { + return AuthResult.failure('Failed to parse auth response'); + } + } else { + // Parse API error + final errorMsg = _parseAPIError(response.body, response.statusCode); + return AuthResult.failure(errorMsg); + } + } catch (e) { + _logger.error('Authentication error', metadata: {'error': e.toString()}); + return AuthResult.failure(e.toString()); + } + } + + /// Refresh access token + Future refreshToken() async { + try { + // Get refresh token (try all sources) + final refreshToken = await getRefreshTokenAsync(); + if (refreshToken == null || refreshToken.isEmpty) { + _logger.debug('No refresh token available'); + return AuthResult.failure('No refresh token available'); + } + + // Get device ID (try all sources, matching getRefreshTokenAsync pattern) + var deviceId = getDeviceId(); + if (deviceId == null || deviceId.isEmpty) { + // Try cache + deviceId = _secureCache['com.runanywhere.sdk.deviceId']; + } + if (deviceId == null || deviceId.isEmpty) { + // Try secure storage directly + try { + const storage = FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock), + ); + deviceId = await storage.read(key: 'com.runanywhere.sdk.deviceId'); + if (deviceId != null && deviceId.isNotEmpty) { + _secureCache['com.runanywhere.sdk.deviceId'] = deviceId; + } + } catch (e) { + _logger.debug('Failed to read device ID from secure storage: $e'); + } + } + if (deviceId == null || deviceId.isEmpty) { + // Fallback: get from DartBridgeDevice + deviceId = await DartBridgeDevice.instance.getDeviceId(); + if (deviceId.isNotEmpty) { + _secureCache['com.runanywhere.sdk.deviceId'] = deviceId; + } + } + if (deviceId == null || deviceId.isEmpty) { + _logger.debug('No device ID available'); + return AuthResult.failure('No device ID available'); + } + + // Build refresh request JSON + final requestJson = jsonEncode({ + 'device_id': deviceId, + 'refresh_token': refreshToken, + }); + + _logger.debug('Refreshing token for device: $deviceId'); + + // Make HTTP request + final endpoint = _getRefreshEndpoint(); + final baseURL = _baseURL ?? _getDefaultBaseURL(); + final url = Uri.parse('$baseURL$endpoint'); + + final headers = { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }; + + final response = await http.post(url, headers: headers, body: requestJson); + + if (response.statusCode == 200 || response.statusCode == 201) { + final authData = _parseAuthResponse(response.body); + + // Try C++ handler first + final cppSuccess = _handleRefreshResponse(response.body); + + // Also manually store tokens in secure storage (in case C++ handler unavailable) + await _storeAuthTokens(authData); + + if (cppSuccess || authData.accessToken != null) { + _logger.info('Token refresh successful'); + return AuthResult.success(authData); + } else { + return AuthResult.failure('Failed to parse refresh response'); + } + } else { + final errorMsg = _parseAPIError(response.body, response.statusCode); + _logger.warning('Token refresh failed: $errorMsg'); + return AuthResult.failure(errorMsg); + } + } catch (e) { + _logger.error('Token refresh error', metadata: {'error': e.toString()}); + return AuthResult.failure(e.toString()); + } + } + + /// Get valid access token, refreshing if needed + Future getValidToken() async { + // Check if we have a cached token first + final cachedToken = _secureCache['com.runanywhere.sdk.accessToken']; + + if (!isAuthenticated() && cachedToken == null) { + return null; + } + + if (needsRefresh()) { + _logger.debug('Token needs refresh'); + final result = await refreshToken(); + if (!result.isSuccess) { + _logger.warning('Token refresh failed', metadata: {'error': result.error}); + // Return cached token anyway, server will reject if invalid + return cachedToken ?? getAccessToken(); + } + // Return new token from refresh result + return result.data?.accessToken ?? getAccessToken() ?? cachedToken; + } + + return getAccessToken() ?? cachedToken; + } + + /// Clear all auth state + Future clearAuth() async { + try { + final lib = PlatformLoader.loadCommons(); + final clearFn = + lib.lookupFunction('rac_auth_clear'); + clearFn(); + + // Also clear via state bridge + await DartBridgeState.instance.clearAuth(); + } catch (e) { + _logger.debug('rac_auth_clear not available: $e'); + } + } + + // ============================================================================ + // Token Accessors + // ============================================================================ + + /// Check if authenticated + bool isAuthenticated() { + try { + final lib = PlatformLoader.loadCommons(); + final isAuth = lib.lookupFunction( + 'rac_auth_is_authenticated'); + return isAuth() != 0; + } catch (e) { + return false; + } + } + + /// Check if token needs refresh + bool needsRefresh() { + try { + final lib = PlatformLoader.loadCommons(); + final needsRefreshFn = lib.lookupFunction( + 'rac_auth_needs_refresh'); + return needsRefreshFn() != 0; + } catch (e) { + return false; + } + } + + /// Get current access token + String? getAccessToken() { + try { + final lib = PlatformLoader.loadCommons(); + final getToken = lib.lookupFunction Function(), + Pointer Function()>('rac_auth_get_access_token'); + + final result = getToken(); + if (result == nullptr) return null; + return result.toDartString(); + } catch (e) { + return null; + } + } + + /// Get device ID + String? getDeviceId() { + try { + final lib = PlatformLoader.loadCommons(); + final getId = lib.lookupFunction Function(), + Pointer Function()>('rac_auth_get_device_id'); + + final result = getId(); + if (result == nullptr) return null; + return result.toDartString(); + } catch (e) { + return null; + } + } + + /// Get user ID + String? getUserId() { + try { + final lib = PlatformLoader.loadCommons(); + final getId = lib.lookupFunction Function(), + Pointer Function()>('rac_auth_get_user_id'); + + final result = getId(); + if (result == nullptr) return null; + return result.toDartString(); + } catch (e) { + return null; + } + } + + /// Get organization ID + String? getOrganizationId() { + try { + final lib = PlatformLoader.loadCommons(); + final getId = lib.lookupFunction Function(), + Pointer Function()>('rac_auth_get_organization_id'); + + final result = getId(); + if (result == nullptr) return null; + return result.toDartString(); + } catch (e) { + return null; + } + } + + // ============================================================================ + // Internal Helpers + // ============================================================================ + + /// Build authenticate request JSON via C++ + String? _buildAuthenticateRequestJSON({ + required String apiKey, + required String deviceId, + String? buildToken, + }) { + try { + final lib = PlatformLoader.loadCommons(); + final buildRequest = lib.lookupFunction< + Pointer Function(Pointer), + Pointer Function( + Pointer)>('rac_auth_build_authenticate_request'); + + final config = calloc(); + final apiKeyPtr = apiKey.toNativeUtf8(); + final deviceIdPtr = deviceId.toNativeUtf8(); + final buildTokenPtr = buildToken?.toNativeUtf8() ?? nullptr; + + try { + config.ref.apiKey = apiKeyPtr; + config.ref.deviceId = deviceIdPtr; + config.ref.buildToken = buildTokenPtr.cast(); + + final result = buildRequest(config); + if (result == nullptr) return null; + + final json = result.toDartString(); + + // Free C++ allocated string + final freeFn = lib.lookupFunction), + void Function(Pointer)>('rac_free'); + freeFn(result.cast()); + + return json; + } finally { + calloc.free(apiKeyPtr); + calloc.free(deviceIdPtr); + if (buildTokenPtr != nullptr) calloc.free(buildTokenPtr); + calloc.free(config); + } + } catch (e) { + _logger.debug('rac_auth_build_authenticate_request error: $e'); + // Fallback: build JSON manually (must match C++ rac_auth_request_to_json format) + // Backend expects snake_case keys: api_key, device_id, platform, sdk_version + final platform = Platform.isAndroid ? 'android' : 'ios'; + final json = { + 'api_key': apiKey, + 'device_id': deviceId, + 'platform': platform, + 'sdk_version': SDKConstants.version, + }; + _logger.debug('Auth request JSON: $json'); + return jsonEncode(json); + } + } + + /// Build refresh request JSON + /// Builds the JSON manually (same approach as React Native SDK) + String? _buildRefreshRequestJSON() { + try { + // Get refresh token from C++ state or secure storage + final refreshToken = _getRefreshToken(); + if (refreshToken == null || refreshToken.isEmpty) { + _logger.debug('No refresh token available for refresh request'); + return null; + } + + // Get device ID + final deviceId = getDeviceId(); + if (deviceId == null || deviceId.isEmpty) { + _logger.debug('No device ID available for refresh request'); + return null; + } + + // Build JSON manually (matches Swift/React Native format) + final requestBody = { + 'device_id': deviceId, + 'refresh_token': refreshToken, + }; + + return jsonEncode(requestBody); + } catch (e) { + _logger.debug('Failed to build refresh request: $e'); + return null; + } + } + + /// Get refresh token from C++ state or secure storage + String? _getRefreshToken() { + // First try C++ state + try { + final lib = PlatformLoader.loadCommons(); + final getToken = lib.lookupFunction Function(), + Pointer Function()>('rac_auth_get_refresh_token'); + + final result = getToken(); + if (result != nullptr) { + final token = result.toDartString(); + if (token.isNotEmpty) { + return token; + } + } + } catch (e) { + _logger.debug('rac_auth_get_refresh_token not available: $e'); + } + + // Fallback: try to get from secure storage cache + final cachedToken = _secureCache['com.runanywhere.sdk.refreshToken']; + if (cachedToken != null && cachedToken.isNotEmpty) { + return cachedToken; + } + + return null; + } + + /// Get refresh token asynchronously from secure storage + Future getRefreshTokenAsync() async { + // Try sync method first + final syncToken = _getRefreshToken(); + if (syncToken != null && syncToken.isNotEmpty) { + return syncToken; + } + + // Fallback: read directly from secure storage + try { + const storage = FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock), + ); + final token = await storage.read(key: 'com.runanywhere.sdk.refreshToken'); + if (token != null && token.isNotEmpty) { + // Update cache for next time + _secureCache['com.runanywhere.sdk.refreshToken'] = token; + return token; + } + } catch (e) { + _logger.debug('Failed to read refresh token from secure storage: $e'); + } + + return null; + } + + /// Handle authenticate response via C++ + bool _handleAuthenticateResponse(String json) { + try { + final lib = PlatformLoader.loadCommons(); + final handleResponse = lib.lookupFunction), + int Function(Pointer)>('rac_auth_handle_authenticate_response'); + + final jsonPtr = json.toNativeUtf8(); + try { + final result = handleResponse(jsonPtr); + return result == 0; + } finally { + calloc.free(jsonPtr); + } + } catch (e) { + _logger.debug('rac_auth_handle_authenticate_response error: $e'); + return false; + } + } + + /// Handle refresh response via C++ + bool _handleRefreshResponse(String json) { + try { + final lib = PlatformLoader.loadCommons(); + final handleResponse = lib.lookupFunction), + int Function(Pointer)>('rac_auth_handle_refresh_response'); + + final jsonPtr = json.toNativeUtf8(); + try { + final result = handleResponse(jsonPtr); + return result == 0; + } finally { + calloc.free(jsonPtr); + } + } catch (e) { + _logger.debug('rac_auth_handle_refresh_response error: $e'); + return false; + } + } + + /// Parse auth response for return value + AuthData _parseAuthResponse(String json) { + try { + final data = jsonDecode(json) as Map; + + // Parse tokens - API returns snake_case + final accessToken = + data['access_token'] as String? ?? data['accessToken'] as String?; + final refreshToken = + data['refresh_token'] as String? ?? data['refreshToken'] as String?; + final deviceId = data['device_id'] as String? ?? data['deviceId'] as String?; + final userId = data['user_id'] as String? ?? data['userId'] as String?; + final organizationId = + data['organization_id'] as String? ?? data['organizationId'] as String?; + + // Parse expiry - API returns expires_in (seconds until expiry) + int? expiresAt; + final expiresIn = data['expires_in'] as int?; + if (expiresIn != null) { + expiresAt = + DateTime.now().millisecondsSinceEpoch ~/ 1000 + expiresIn; + } else { + expiresAt = data['expires_at'] as int? ?? data['expiresAt'] as int?; + } + + _logger.debug( + 'Parsed auth response: accessToken=${accessToken != null}, refreshToken=${refreshToken != null}, deviceId=$deviceId'); + + return AuthData( + accessToken: accessToken, + refreshToken: refreshToken, + deviceId: deviceId, + userId: userId, + organizationId: organizationId, + expiresAt: expiresAt, + ); + } catch (e) { + _logger.debug('Failed to parse auth response: $e'); + return const AuthData(); + } + } + + /// Parse API error response + String _parseAPIError(String json, int statusCode) { + try { + final data = jsonDecode(json) as Map; + final message = data['message'] as String? ?? + data['error'] as String? ?? + data['detail'] as String? ?? + 'Unknown error'; + return '$message (HTTP $statusCode)'; + } catch (e) { + return 'HTTP error $statusCode'; + } + } + + /// Store auth tokens in secure storage + /// This ensures tokens are available even if C++ handler fails + Future _storeAuthTokens(AuthData authData) async { + try { + const storage = FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock), + ); + + var storedCount = 0; + + if (authData.accessToken != null && authData.accessToken!.isNotEmpty) { + await storage.write( + key: 'com.runanywhere.sdk.accessToken', value: authData.accessToken); + _secureCache['com.runanywhere.sdk.accessToken'] = authData.accessToken!; + storedCount++; + _logger.debug('Stored access token (${authData.accessToken!.length} chars)'); + } + if (authData.refreshToken != null && authData.refreshToken!.isNotEmpty) { + await storage.write( + key: 'com.runanywhere.sdk.refreshToken', value: authData.refreshToken); + _secureCache['com.runanywhere.sdk.refreshToken'] = authData.refreshToken!; + storedCount++; + _logger.debug('Stored refresh token (${authData.refreshToken!.length} chars)'); + } + if (authData.deviceId != null && authData.deviceId!.isNotEmpty) { + await storage.write( + key: 'com.runanywhere.sdk.deviceId', value: authData.deviceId); + _secureCache['com.runanywhere.sdk.deviceId'] = authData.deviceId!; + storedCount++; + _logger.debug('Stored device ID: ${authData.deviceId}'); + } + if (authData.userId != null && authData.userId!.isNotEmpty) { + await storage.write(key: 'com.runanywhere.sdk.userId', value: authData.userId); + _secureCache['com.runanywhere.sdk.userId'] = authData.userId!; + storedCount++; + } + if (authData.organizationId != null && authData.organizationId!.isNotEmpty) { + await storage.write( + key: 'com.runanywhere.sdk.organizationId', value: authData.organizationId); + _secureCache['com.runanywhere.sdk.organizationId'] = + authData.organizationId!; + storedCount++; + } + + _logger.debug('Auth tokens stored in secure storage ($storedCount items)'); + } catch (e) { + _logger.debug('Failed to store auth tokens: $e'); + } + } + + /// Load stored tokens from secure storage + Future _loadStoredTokens() async { + // Try C++ method first + try { + final lib = PlatformLoader.loadCommons(); + final loadFn = lib.lookupFunction( + 'rac_auth_load_stored_tokens'); + loadFn(); + } catch (e) { + _logger.debug('rac_auth_load_stored_tokens not available: $e'); + } + + // Also pre-load tokens into cache from Flutter secure storage + try { + const storage = FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock), + ); + + final accessToken = await storage.read(key: 'com.runanywhere.sdk.accessToken'); + final refreshToken = await storage.read(key: 'com.runanywhere.sdk.refreshToken'); + final deviceId = await storage.read(key: 'com.runanywhere.sdk.deviceId'); + final userId = await storage.read(key: 'com.runanywhere.sdk.userId'); + final organizationId = await storage.read(key: 'com.runanywhere.sdk.organizationId'); + + if (accessToken != null) { + _secureCache['com.runanywhere.sdk.accessToken'] = accessToken; + } + if (refreshToken != null) { + _secureCache['com.runanywhere.sdk.refreshToken'] = refreshToken; + } + if (deviceId != null) { + _secureCache['com.runanywhere.sdk.deviceId'] = deviceId; + } + if (userId != null) { + _secureCache['com.runanywhere.sdk.userId'] = userId; + } + if (organizationId != null) { + _secureCache['com.runanywhere.sdk.organizationId'] = organizationId; + } + + _logger.debug('Loaded tokens from secure storage', metadata: { + 'hasAccessToken': accessToken != null, + 'hasRefreshToken': refreshToken != null, + 'hasDeviceId': deviceId != null, + }); + } catch (e) { + _logger.debug('Failed to pre-load tokens from secure storage: $e'); + } + } + + String _getAuthEndpoint() { + return '/api/v1/auth/sdk/authenticate'; + } + + String _getRefreshEndpoint() { + return '/api/v1/auth/sdk/refresh'; + } + + String _getDefaultBaseURL() { + switch (_environment) { + case SDKEnvironment.development: + return 'https://dev-api.runanywhere.ai'; + case SDKEnvironment.staging: + return 'https://staging-api.runanywhere.ai'; + case SDKEnvironment.production: + return 'https://api.runanywhere.ai'; + } + } +} + +// ============================================================================= +// Secure Storage Callbacks +// ============================================================================= + +/// Cached secure storage values for sync access +final Map _secureCache = {}; + +/// Store callback +int _secureStoreCallback( + Pointer key, Pointer value, Pointer context) { + if (key == nullptr || value == nullptr) return -1; + + try { + final keyStr = key.toDartString(); + final valueStr = value.toDartString(); + + // Update cache + _secureCache[keyStr] = valueStr; + + // Schedule async write (fire-and-forget, cache is authoritative) + unawaited(_writeToSecureStorage(keyStr, valueStr)); + + return 0; + } catch (e) { + return -1; + } +} + +/// Retrieve callback +int _secureRetrieveCallback( + Pointer key, Pointer outValue, int bufferSize, Pointer context) { + if (key == nullptr || outValue == nullptr) return -1; + + try { + final keyStr = key.toDartString(); + final value = _secureCache[keyStr]; + + if (value == null) return -1; + + // Copy to output buffer + final bytes = value.codeUnits; + final maxLen = bufferSize - 1; // Leave room for null terminator + + if (bytes.length > maxLen) { + return -1; // Buffer too small + } + + final outPtr = outValue.cast(); + for (var i = 0; i < bytes.length; i++) { + outPtr[i] = bytes[i]; + } + outPtr[bytes.length] = 0; // Null terminator + + return bytes.length; + } catch (e) { + return -1; + } +} + +/// Delete callback +int _secureDeleteCallback(Pointer key, Pointer context) { + if (key == nullptr) return -1; + + try { + final keyStr = key.toDartString(); + + // Update cache + _secureCache.remove(keyStr); + + // Schedule async delete (fire-and-forget, cache is authoritative) + unawaited(_deleteFromSecureStorage(keyStr)); + + return 0; + } catch (e) { + return -1; + } +} + +/// Async write to secure storage +Future _writeToSecureStorage(String key, String value) async { + try { + const storage = FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock), + ); + await storage.write(key: key, value: value); + } catch (e) { + // Ignore - cache is authoritative + } +} + +/// Async delete from secure storage +Future _deleteFromSecureStorage(String key) async { + try { + const storage = FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock), + ); + await storage.delete(key: key); + } catch (e) { + // Ignore + } +} + +// ============================================================================= +// FFI Types +// ============================================================================= + +/// Secure storage store callback +typedef RacSecureStoreCallbackNative = Int32 Function( + Pointer key, Pointer value, Pointer context); + +/// Secure storage retrieve callback +typedef RacSecureRetrieveCallbackNative = Int32 Function( + Pointer key, Pointer outValue, IntPtr bufferSize, Pointer context); + +/// Secure storage delete callback +typedef RacSecureDeleteCallbackNative = Int32 Function( + Pointer key, Pointer context); + +/// Secure storage callbacks struct +base class RacSecureStorageCallbacksStruct extends Struct { + external Pointer> store; + external Pointer> retrieve; + external Pointer> deleteKey; + external Pointer context; +} + +/// SDK config struct for auth requests +base class RacSdkConfigStruct extends Struct { + external Pointer apiKey; + external Pointer deviceId; + external Pointer buildToken; +} + +// ============================================================================= +// Result Types +// ============================================================================= + +/// Authentication result +class AuthResult { + final bool isSuccess; + final AuthData? data; + final String? error; + + const AuthResult._({ + required this.isSuccess, + this.data, + this.error, + }); + + factory AuthResult.success(AuthData data) => + AuthResult._(isSuccess: true, data: data); + + factory AuthResult.failure(String error) => + AuthResult._(isSuccess: false, error: error); +} + +/// Authentication data +class AuthData { + final String? accessToken; + final String? refreshToken; + final String? deviceId; + final String? userId; + final String? organizationId; + final int? expiresAt; + + const AuthData({ + this.accessToken, + this.refreshToken, + this.deviceId, + this.userId, + this.organizationId, + this.expiresAt, + }); +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_dev_config.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_dev_config.dart new file mode 100644 index 000000000..84897dfac --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_dev_config.dart @@ -0,0 +1,109 @@ +import 'dart:ffi'; + +import 'package:ffi/ffi.dart'; +import 'package:runanywhere/foundation/logging/sdk_logger.dart'; +import 'package:runanywhere/native/platform_loader.dart'; + +/// Development configuration bridge +/// +/// Wraps C++ rac_dev_config.h functions for development mode with Supabase backend. +/// Credentials are stored ONLY in C++ development_config.cpp (git-ignored). +class DartBridgeDevConfig { + static final _logger = SDKLogger('DartBridge.DevConfig'); + + /// Check if development config is available + static bool get isAvailable { + try { + final lib = PlatformLoader.loadCommons(); + final isAvailable = lib.lookupFunction( + 'rac_dev_config_is_available', + ); + return isAvailable(); + } catch (e) { + _logger.debug('rac_dev_config_is_available not available: $e'); + return false; + } + } + + /// Get Supabase URL for development mode + /// Returns null if not configured + static String? get supabaseURL { + if (!isAvailable) return null; + + try { + final lib = PlatformLoader.loadCommons(); + final getUrl = lib.lookupFunction Function(), Pointer Function()>( + 'rac_dev_config_get_supabase_url', + ); + + final result = getUrl(); + if (result == nullptr) return null; + return result.toDartString(); + } catch (e) { + _logger.debug('rac_dev_config_get_supabase_url not available: $e'); + return null; + } + } + + /// Get Supabase anon key for development mode + /// Returns null if not configured + static String? get supabaseKey { + if (!isAvailable) return null; + + try { + final lib = PlatformLoader.loadCommons(); + final getKey = lib.lookupFunction Function(), Pointer Function()>( + 'rac_dev_config_get_supabase_key', + ); + + final result = getKey(); + if (result == nullptr) return null; + return result.toDartString(); + } catch (e) { + _logger.debug('rac_dev_config_get_supabase_key not available: $e'); + return null; + } + } + + /// Get build token for development mode + /// Returns null if not configured + static String? get buildToken { + try { + final lib = PlatformLoader.loadCommons(); + final hasBuildToken = lib.lookupFunction( + 'rac_dev_config_has_build_token', + ); + + if (!hasBuildToken()) return null; + + final getToken = lib.lookupFunction Function(), Pointer Function()>( + 'rac_dev_config_get_build_token', + ); + + final result = getToken(); + if (result == nullptr) return null; + return result.toDartString(); + } catch (e) { + _logger.debug('rac_dev_config_get_build_token not available: $e'); + return null; + } + } + + /// Get Sentry DSN for crash reporting (optional) + /// Returns null if not configured + static String? get sentryDSN { + try { + final lib = PlatformLoader.loadCommons(); + final getDsn = lib.lookupFunction Function(), Pointer Function()>( + 'rac_dev_config_get_sentry_dsn', + ); + + final result = getDsn(); + if (result == nullptr) return null; + return result.toDartString(); + } catch (e) { + _logger.debug('rac_dev_config_get_sentry_dsn not available: $e'); + return null; + } + } +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_device.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_device.dart new file mode 100644 index 000000000..38bbe44fe --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_device.dart @@ -0,0 +1,628 @@ +// ignore_for_file: avoid_classes_with_only_static_members + +import 'dart:async'; +import 'dart:ffi'; +import 'dart:io'; + +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:ffi/ffi.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:http/http.dart' as http; +import 'package:runanywhere/foundation/logging/sdk_logger.dart'; +import 'package:runanywhere/native/ffi_types.dart'; +import 'package:runanywhere/native/platform_loader.dart'; +import 'package:runanywhere/public/configuration/sdk_environment.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:uuid/uuid.dart'; + +// ============================================================================= +// Exceptional return constants for FFI callbacks +// ============================================================================= + +const int _exceptionalReturnNull = 0; +const int _exceptionalReturnInt32 = -1; + +// ============================================================================= +// Device Manager Bridge +// ============================================================================= + +/// Device bridge for C++ device manager operations. +/// Matches Swift's `CppBridge+Device.swift`. +/// +/// Provides callbacks for: +/// - Device info gathering (via device_info_plus) +/// - Device ID retrieval (via shared_preferences + unique ID) +/// - Registration persistence (via shared_preferences) +/// - HTTP transport (via http package) +class DartBridgeDevice { + DartBridgeDevice._(); + + static final _logger = SDKLogger('DartBridge.Device'); + static final DartBridgeDevice instance = DartBridgeDevice._(); + + static bool _isRegistered = false; + static String? _cachedDeviceId; + static Pointer? _callbacksPtr; + + /// SharedPreferences key for registration status + static const _keyIsRegistered = 'com.runanywhere.sdk.device.isRegistered'; + + /// SharedPreferences instance (lazily initialized) + static SharedPreferences? _prefs; + + /// SDK environment for HTTP calls + static SDKEnvironment _environment = SDKEnvironment.development; + + /// Base URL for HTTP calls + static String? _baseURL; + + /// Access token for authenticated requests + static String? _accessToken; + + // ============================================================================ + // Public API + // ============================================================================ + + /// Register device callbacks synchronously (Phase 1). + /// Matches Swift: Device.register() in CppBridge.initialize() + /// This registers the C++ callbacks without initializing SharedPreferences + /// or caching device ID (those happen in Phase 2). + static void registerCallbacks() { + if (_callbacksRegistered) { + _logger.debug('Device callbacks already registered'); + return; + } + + try { + final lib = PlatformLoader.loadCommons(); + + // Allocate callbacks struct + _callbacksPtr = calloc(); + final callbacks = _callbacksPtr!; + + // Set callback function pointers + callbacks.ref.getDeviceInfo = + Pointer.fromFunction( + _getDeviceInfoCallback); + callbacks.ref.getDeviceId = + Pointer.fromFunction( + _getDeviceIdCallback, _exceptionalReturnNull); + callbacks.ref.isRegistered = + Pointer.fromFunction( + _isRegisteredCallback, _exceptionalReturnInt32); + callbacks.ref.setRegistered = + Pointer.fromFunction( + _setRegisteredCallback); + callbacks.ref.httpPost = + Pointer.fromFunction( + _httpPostCallback, _exceptionalReturnInt32); + callbacks.ref.userData = nullptr; + + // Register with C++ + final setCallbacks = lib.lookupFunction< + Int32 Function(Pointer), + int Function( + Pointer)>('rac_device_set_callbacks'); + + final result = setCallbacks(callbacks); + if (result != RacResultCode.success) { + _logger.warning('Failed to set device callbacks', metadata: { + 'error_code': result, + }); + calloc.free(callbacks); + _callbacksPtr = null; + return; + } + + _callbacksRegistered = true; + _logger.debug('Device callbacks registered (sync)'); + } catch (e) { + _logger.debug('registerCallbacks error: $e'); + } + } + + static bool _callbacksRegistered = false; + + /// Register device callbacks with C++ (full async init, Phase 2) + /// Must be called during SDK init after platform adapter + static Future register({ + required SDKEnvironment environment, + String? baseURL, + String? accessToken, + }) async { + _environment = environment; + _baseURL = baseURL; + _accessToken = accessToken; + + // Register callbacks if not already done in Phase 1 + if (!_callbacksRegistered) { + registerCallbacks(); + } + + if (_isRegistered) { + _logger.debug('Device already fully registered'); + return; + } + + // Initialize SharedPreferences + _prefs = await SharedPreferences.getInstance(); + + // Pre-cache device ID + await _getOrCreateDeviceId(); + + try { + final lib = PlatformLoader.loadCommons(); + + // Allocate callbacks struct + _callbacksPtr = calloc(); + final callbacks = _callbacksPtr!; + + // Set callback function pointers + callbacks.ref.getDeviceInfo = + Pointer.fromFunction( + _getDeviceInfoCallback); + callbacks.ref.getDeviceId = + Pointer.fromFunction( + _getDeviceIdCallback, _exceptionalReturnNull); + callbacks.ref.isRegistered = + Pointer.fromFunction( + _isRegisteredCallback, _exceptionalReturnInt32); + callbacks.ref.setRegistered = + Pointer.fromFunction( + _setRegisteredCallback); + callbacks.ref.httpPost = + Pointer.fromFunction( + _httpPostCallback, _exceptionalReturnInt32); + callbacks.ref.userData = nullptr; + + // Register with C++ + final setCallbacks = lib.lookupFunction< + Int32 Function(Pointer), + int Function(Pointer)>( + 'rac_device_manager_set_callbacks'); + + final result = setCallbacks(callbacks); + if (result != RacResultCode.success) { + _logger.warning('Failed to register device callbacks', + metadata: {'code': result}); + calloc.free(callbacks); + _callbacksPtr = null; + return; + } + + _isRegistered = true; + _logger.debug('Device callbacks registered successfully'); + } catch (e, stack) { + _logger.debug('Device registration not available: $e', metadata: { + 'stack': stack.toString(), + }); + _isRegistered = true; // Mark as registered to avoid retry loops + } + } + + /// Update access token (called after authentication) + static void setAccessToken(String? token) { + _accessToken = token; + } + + /// Register device with backend if not already registered + Future registerIfNeeded() async { + if (!_isRegistered) { + _logger.warning('Device callbacks not registered'); + return; + } + + try { + final lib = PlatformLoader.loadCommons(); + final registerFn = lib.lookupFunction< + Int32 Function(Int32, Pointer), + int Function( + int, Pointer)>('rac_device_manager_register_if_needed'); + + final envValue = _environmentToInt(_environment); + final buildTokenPtr = nullptr; // Build token not used in Flutter + + final result = registerFn(envValue, buildTokenPtr.cast()); + if (result != RacResultCode.success) { + _logger.debug('Device registration returned: $result'); + } + } catch (e) { + _logger.debug('rac_device_manager_register_if_needed not available: $e'); + } + } + + /// Check if device is registered with backend + bool isDeviceRegistered() { + return _prefs?.getBool(_keyIsRegistered) ?? false; + } + + /// Clear device registration (for testing) + Future clearRegistration() async { + try { + final lib = PlatformLoader.loadCommons(); + final clearFn = lib.lookupFunction( + 'rac_device_manager_clear_registration'); + clearFn(); + } catch (e) { + // Also clear locally + await _prefs?.setBool(_keyIsRegistered, false); + } + } + + /// Get the cached or generated device ID + Future getDeviceId() async { + return _cachedDeviceId ?? await _getOrCreateDeviceId(); + } + + /// Get the cached device ID synchronously (null if not yet cached) + static String? get cachedDeviceId => _cachedDeviceId; + + // ============================================================================ + // Internal Helpers + // ============================================================================ + + /// Key for storing persistent device UUID in Keychain/EncryptedSharedPreferences + /// Matches Swift KeychainManager.KeychainKey.deviceUUID and React Native SecureStorageKeys.deviceUUID + static const _keyDeviceUUID = 'com.runanywhere.sdk.device.uuid'; + + /// Secure storage for device UUID persistence + /// - iOS: Keychain (survives app reinstalls) + /// - Android: EncryptedSharedPreferences (survives app reinstalls) + static const _secureStorage = FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock), + ); + + /// Get or create a persistent device UUID. + /// Matches Swift's DeviceIdentity.persistentUUID behavior: + /// 1. Try to retrieve stored UUID from Keychain/EncryptedSharedPreferences (survives reinstalls) + /// 2. If not found, try iOS vendor ID + /// 3. If still not found, generate new UUID + /// The UUID format is required by the backend for device registration. + static Future _getOrCreateDeviceId() async { + if (_cachedDeviceId != null) return _cachedDeviceId!; + + try { + // Strategy 1: Try to get stored UUID from secure storage (Keychain/EncryptedSharedPreferences) + // This persists across app reinstalls (matches Swift KeychainManager behavior) + final storedUUID = await _secureStorage.read(key: _keyDeviceUUID); + if (storedUUID != null && _isValidUUID(storedUUID)) { + _cachedDeviceId = storedUUID; + _logger.debug('Using stored device UUID from secure storage'); + return _cachedDeviceId!; + } + + // Strategy 2: On iOS, try to use identifierForVendor (already a UUID) + // Matches Swift: DeviceIdentity.vendorUUID fallback + if (Platform.isIOS) { + try { + final deviceInfo = DeviceInfoPlugin(); + final iosInfo = await deviceInfo.iosInfo; + final vendorId = iosInfo.identifierForVendor; + if (vendorId != null && _isValidUUID(vendorId)) { + _cachedDeviceId = vendorId; + await _secureStorage.write(key: _keyDeviceUUID, value: vendorId); + _logger.debug('Stored iOS vendor UUID in secure storage'); + return _cachedDeviceId!; + } + } catch (e) { + _logger.debug('Failed to get iOS vendor ID: $e'); + } + } + + // Strategy 3: Generate a new UUID (matches Swift's UUID().uuidString) + final newUUID = _generateUUID(); + _cachedDeviceId = newUUID; + await _secureStorage.write(key: _keyDeviceUUID, value: newUUID); + _logger.debug('Generated and stored new device UUID in secure storage'); + return _cachedDeviceId!; + } catch (e) { + _logger.warning('Failed to get device ID from secure storage: $e'); + + // Fallback: try SharedPreferences (less secure, doesn't survive reinstalls) + try { + _prefs ??= await SharedPreferences.getInstance(); + final prefsUUID = _prefs?.getString(_keyDeviceUUID); + if (prefsUUID != null && _isValidUUID(prefsUUID)) { + _cachedDeviceId = prefsUUID; + // Try to migrate to secure storage + try { + await _secureStorage.write(key: _keyDeviceUUID, value: prefsUUID); + _logger.debug('Migrated device UUID to secure storage'); + } catch (_) {} + return _cachedDeviceId!; + } + + final newUUID = _generateUUID(); + _cachedDeviceId = newUUID; + await _prefs?.setString(_keyDeviceUUID, newUUID); + _logger.debug('Stored device UUID in SharedPreferences (fallback)'); + return _cachedDeviceId!; + } catch (e2) { + _logger.warning('SharedPreferences fallback failed: $e2'); + // Last resort: generate UUID without storing + _cachedDeviceId = _generateUUID(); + return _cachedDeviceId!; + } + } + } + + /// Generate a proper UUID v4 string (matches backend expectations) + /// Format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx + /// Uses cryptographically secure random bytes via the uuid package + static String _generateUUID() { + return const Uuid().v4(); + } + + /// Validate UUID format (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) + static bool _isValidUUID(String uuid) { + if (uuid.length != 36) return false; + if (!uuid.contains('-')) return false; + final parts = uuid.split('-'); + if (parts.length != 5) return false; + if (parts[0].length != 8 || + parts[1].length != 4 || + parts[2].length != 4 || + parts[3].length != 4 || + parts[4].length != 12) { + return false; + } + return true; + } + + static int _environmentToInt(SDKEnvironment env) { + switch (env) { + case SDKEnvironment.development: + return 0; + case SDKEnvironment.staging: + return 1; + case SDKEnvironment.production: + return 2; + } + } +} + +// ============================================================================= +// FFI Callback Functions +// ============================================================================= + +/// Get device info callback +void _getDeviceInfoCallback( + Pointer outInfo, Pointer userData) { + if (outInfo == nullptr) return; + + try { + // Fill in device info synchronously from cached values + // Note: Real values are populated asynchronously during registration + + // Device type + final deviceType = Platform.isIOS + ? 'iphone' + : Platform.isAndroid + ? 'android' + : Platform.isMacOS + ? 'macos' + : 'unknown'; + final deviceTypePtr = deviceType.toNativeUtf8(); + outInfo.ref.deviceType = deviceTypePtr; + + // OS name + final osName = Platform.operatingSystem; + final osNamePtr = osName.toNativeUtf8(); + outInfo.ref.osName = osNamePtr; + + // OS version + final osVersion = Platform.operatingSystemVersion; + final osVersionPtr = osVersion.toNativeUtf8(); + outInfo.ref.osVersion = osVersionPtr; + + // SDK version + const sdkVersion = '0.1.4'; + final sdkVersionPtr = sdkVersion.toNativeUtf8(); + outInfo.ref.sdkVersion = sdkVersionPtr; + + // App version (not available in Flutter without package_info) + final appVersionPtr = '1.0.0'.toNativeUtf8(); + outInfo.ref.appVersion = appVersionPtr; + + // App identifier + final appIdPtr = 'com.runanywhere.flutter'.toNativeUtf8(); + outInfo.ref.appIdentifier = appIdPtr; + + // Platform + final platformPtr = 'flutter'.toNativeUtf8(); + outInfo.ref.platform = platformPtr; + } catch (e) { + SDKLogger('DartBridge.Device').error('Error in device info callback: $e'); + } +} + +/// Cached device ID pointer (must persist for C++ to read) +Pointer? _cachedDeviceIdPtr; + +/// Get device ID callback +int _getDeviceIdCallback(Pointer userData) { + try { + final deviceId = DartBridgeDevice._cachedDeviceId; + if (deviceId == null) { + return 0; + } + + // Free previous pointer if exists + if (_cachedDeviceIdPtr != null) { + calloc.free(_cachedDeviceIdPtr!); + } + + // Allocate and cache new pointer + _cachedDeviceIdPtr = deviceId.toNativeUtf8(); + return _cachedDeviceIdPtr!.address; + } catch (e) { + return 0; + } +} + +/// Check if device is registered callback +int _isRegisteredCallback(Pointer userData) { + try { + final isReg = + DartBridgeDevice._prefs?.getBool(DartBridgeDevice._keyIsRegistered) ?? + false; + return isReg ? RAC_TRUE : RAC_FALSE; + } catch (e) { + return RAC_FALSE; + } +} + +/// Set device registered status callback +void _setRegisteredCallback(int registered, Pointer userData) { + try { + unawaited(DartBridgeDevice._prefs + ?.setBool(DartBridgeDevice._keyIsRegistered, registered != 0)); + } catch (e) { + SDKLogger('DartBridge.Device').error('Error setting registration: $e'); + } +} + +/// HTTP POST callback for device registration +int _httpPostCallback( + Pointer endpoint, + Pointer jsonBody, + int requiresAuth, + Pointer outResponse, + Pointer userData, +) { + if (endpoint == nullptr || outResponse == nullptr) { + return RacResultCode.errorInvalidParameter; + } + + try { + final endpointStr = endpoint.toDartString(); + final bodyStr = jsonBody != nullptr ? jsonBody.toDartString() : ''; + + // Perform sync HTTP (via Isolate in production, blocking here for simplicity) + // Note: In production, use an async pattern with completion callback + _performHttpPost( + endpointStr, + bodyStr, + requiresAuth != 0, + outResponse, + ); + + return RacResultCode.success; + } catch (e) { + SDKLogger('DartBridge.Device').error('HTTP POST error: $e'); + return RacResultCode.errorNetworkError; + } +} + +/// Perform HTTP POST (simplified synchronous wrapper) +void _performHttpPost( + String endpoint, + String body, + bool requiresAuth, + Pointer outResponse, +) { + // Note: This is a simplified implementation + // In production, use proper async handling with callbacks + + // Build URL + final baseURL = DartBridgeDevice._baseURL ?? 'https://api.runanywhere.ai'; + final url = Uri.parse('$baseURL$endpoint'); + + // Build headers + final headers = { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }; + + if (requiresAuth && DartBridgeDevice._accessToken != null) { + headers['Authorization'] = 'Bearer ${DartBridgeDevice._accessToken}'; + } + + // Schedule async HTTP call (fire and forget for now) + // The C++ layer will retry if needed + unawaited(Future.microtask(() async { + try { + final response = await http.post(url, headers: headers, body: body); + + outResponse.ref.result = + response.statusCode >= 200 && response.statusCode < 300 + ? RacResultCode.success + : RacResultCode.errorNetworkError; + outResponse.ref.statusCode = response.statusCode; + + if (response.body.isNotEmpty) { + final bodyPtr = response.body.toNativeUtf8(); + outResponse.ref.responseBody = bodyPtr; + } + } catch (e) { + outResponse.ref.result = RacResultCode.errorNetworkError; + outResponse.ref.statusCode = 0; + + final errorPtr = e.toString().toNativeUtf8(); + outResponse.ref.errorMessage = errorPtr; + } + })); + + // Return immediately with pending state + outResponse.ref.result = RacResultCode.success; + outResponse.ref.statusCode = 200; +} + +// ============================================================================= +// FFI Types +// ============================================================================= + +/// Callback type: void (*get_device_info)(rac_device_registration_info_t*, void*) +typedef RacDeviceGetInfoCallbackNative = Void Function( + Pointer, Pointer); + +/// Callback type: const char* (*get_device_id)(void*) +typedef RacDeviceGetIdCallbackNative = IntPtr Function(Pointer); + +/// Callback type: rac_bool_t (*is_registered)(void*) +typedef RacDeviceIsRegisteredCallbackNative = Int32 Function(Pointer); + +/// Callback type: void (*set_registered)(rac_bool_t, void*) +typedef RacDeviceSetRegisteredCallbackNative = Void Function( + Int32, Pointer); + +/// Callback type: rac_result_t (*http_post)(const char*, const char*, rac_bool_t, rac_device_http_response_t*, void*) +typedef RacDeviceHttpPostCallbackNative = Int32 Function(Pointer, + Pointer, Int32, Pointer, Pointer); + +/// Device callbacks struct matching rac_device_callbacks_t +base class RacDeviceCallbacksStruct extends Struct { + external Pointer> + getDeviceInfo; + external Pointer> getDeviceId; + external Pointer> + isRegistered; + external Pointer> + setRegistered; + external Pointer> httpPost; + external Pointer userData; +} + +/// Device registration info struct matching rac_device_registration_info_t +base class RacDeviceRegistrationInfoStruct extends Struct { + external Pointer deviceType; + external Pointer osName; + external Pointer osVersion; + external Pointer sdkVersion; + external Pointer appVersion; + external Pointer appIdentifier; + external Pointer platform; +} + +/// HTTP response struct matching rac_device_http_response_t +base class RacDeviceHttpResponseStruct extends Struct { + @Int32() + external int result; + + @Int32() + external int statusCode; + + external Pointer responseBody; + external Pointer errorMessage; +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_download.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_download.dart new file mode 100644 index 000000000..d7028d7a3 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_download.dart @@ -0,0 +1,131 @@ +import 'dart:async'; +// ignore_for_file: avoid_classes_with_only_static_members + +import 'dart:ffi'; + +import 'package:ffi/ffi.dart'; +import 'package:runanywhere/foundation/logging/sdk_logger.dart'; +import 'package:runanywhere/native/ffi_types.dart'; +import 'package:runanywhere/native/platform_loader.dart'; + +/// Download bridge for C++ download operations. +/// Matches Swift's `CppBridge+Download.swift`. +class DartBridgeDownload { + DartBridgeDownload._(); + + static final _logger = SDKLogger('DartBridge.Download'); + static final DartBridgeDownload instance = DartBridgeDownload._(); + + /// Active download tasks + final Map _activeTasks = {}; + + /// Start a download via C++ + Future startDownload({ + required String url, + required String destinationPath, + void Function(int downloaded, int total)? onProgress, + void Function(int result, String? path)? onComplete, + }) async { + try { + final lib = PlatformLoader.load(); + final startFn = lib.lookupFunction< + Int32 Function( + Pointer, + Pointer, + Pointer)>>, + Pointer, Pointer)>>, + Pointer, + Pointer>, + ), + int Function( + Pointer, + Pointer, + Pointer)>>, + Pointer, Pointer)>>, + Pointer, + Pointer>, + )>('rac_http_download'); + + final urlPtr = url.toNativeUtf8(); + final destPtr = destinationPath.toNativeUtf8(); + final taskIdPtr = calloc>(); + + try { + final result = startFn( + urlPtr, + destPtr, + nullptr, // Progress callback (implement if needed) + nullptr, // Complete callback (implement if needed) + nullptr, // User data + taskIdPtr, + ); + + if (result != RacResultCode.success) { + _logger.warning('Download start failed', metadata: {'code': result}); + return null; + } + + final taskId = taskIdPtr.value != nullptr + ? taskIdPtr.value.toDartString() + : null; + + if (taskId != null) { + _activeTasks[taskId] = _DownloadTask( + url: url, + destinationPath: destinationPath, + onProgress: onProgress, + onComplete: onComplete, + ); + } + + return taskId; + } finally { + calloc.free(urlPtr); + calloc.free(destPtr); + calloc.free(taskIdPtr); + } + } catch (e) { + _logger.debug('rac_http_download not available: $e'); + return null; + } + } + + /// Cancel a download + Future cancelDownload(String taskId) async { + try { + final lib = PlatformLoader.load(); + final cancelFn = lib.lookupFunction< + Int32 Function(Pointer), + int Function(Pointer)>('rac_http_download_cancel'); + + final taskIdPtr = taskId.toNativeUtf8(); + try { + final result = cancelFn(taskIdPtr); + _activeTasks.remove(taskId); + return result == RacResultCode.success; + } finally { + calloc.free(taskIdPtr); + } + } catch (e) { + _logger.debug('rac_http_download_cancel not available: $e'); + return false; + } + } + + /// Get active download count + int get activeDownloadCount => _activeTasks.length; +} + +class _DownloadTask { + final String url; + final String destinationPath; + final void Function(int downloaded, int total)? onProgress; + final void Function(int result, String? path)? onComplete; + + _DownloadTask({ + required this.url, + required this.destinationPath, + this.onProgress, + this.onComplete, + }); +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_environment.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_environment.dart new file mode 100644 index 000000000..a9e67db45 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_environment.dart @@ -0,0 +1,365 @@ +// ignore_for_file: avoid_classes_with_only_static_members + +import 'dart:ffi'; + +import 'package:ffi/ffi.dart'; +import 'package:runanywhere/foundation/logging/sdk_logger.dart'; +import 'package:runanywhere/native/ffi_types.dart'; +import 'package:runanywhere/native/platform_loader.dart'; +import 'package:runanywhere/public/configuration/sdk_environment.dart'; + +// ============================================================================= +// Environment Bridge +// ============================================================================= + +/// Environment bridge for C++ environment validation and configuration. +/// Matches Swift's `CppBridge+Environment.swift`. +/// +/// C++ provides: +/// - Environment validation (API key, URL) +/// - Environment-specific settings (log level, telemetry) +/// - Configuration validation +class DartBridgeEnvironment { + DartBridgeEnvironment._(); + + // ignore: unused_field + static final _logger = SDKLogger('DartBridge.Environment'); + static final DartBridgeEnvironment instance = DartBridgeEnvironment._(); + + // ============================================================================ + // Environment Queries + // ============================================================================ + + /// Check if environment requires API authentication + bool requiresAuth(SDKEnvironment environment) { + try { + final lib = PlatformLoader.loadCommons(); + final requiresAuthFn = lib.lookupFunction('rac_env_requires_auth'); + + return requiresAuthFn(_environmentToInt(environment)) != 0; + } catch (e) { + // Fallback: dev doesn't require auth + return environment != SDKEnvironment.development; + } + } + + /// Check if environment requires a backend URL + bool requiresBackendURL(SDKEnvironment environment) { + try { + final lib = PlatformLoader.loadCommons(); + final requiresUrlFn = lib.lookupFunction('rac_env_requires_backend_url'); + + return requiresUrlFn(_environmentToInt(environment)) != 0; + } catch (e) { + // Fallback: dev doesn't require URL + return environment != SDKEnvironment.development; + } + } + + /// Check if environment is production + bool isProduction(SDKEnvironment environment) { + try { + final lib = PlatformLoader.loadCommons(); + final isProdFn = lib.lookupFunction('rac_env_is_production'); + + return isProdFn(_environmentToInt(environment)) != 0; + } catch (e) { + return environment == SDKEnvironment.production; + } + } + + /// Check if environment is a testing environment + bool isTesting(SDKEnvironment environment) { + try { + final lib = PlatformLoader.loadCommons(); + final isTestFn = lib.lookupFunction('rac_env_is_testing'); + + return isTestFn(_environmentToInt(environment)) != 0; + } catch (e) { + return environment != SDKEnvironment.production; + } + } + + /// Get default log level for environment + int getDefaultLogLevel(SDKEnvironment environment) { + try { + final lib = PlatformLoader.loadCommons(); + final getLogLevelFn = lib.lookupFunction('rac_env_default_log_level'); + + return getLogLevelFn(_environmentToInt(environment)); + } catch (e) { + // Fallback defaults + switch (environment) { + case SDKEnvironment.development: + return RacLogLevel.debug; + case SDKEnvironment.staging: + return RacLogLevel.info; + case SDKEnvironment.production: + return RacLogLevel.warning; + } + } + } + + /// Check if telemetry should be sent + bool shouldSendTelemetry(SDKEnvironment environment) { + try { + final lib = PlatformLoader.loadCommons(); + final shouldSendFn = lib.lookupFunction('rac_env_should_send_telemetry'); + + return shouldSendFn(_environmentToInt(environment)) != 0; + } catch (e) { + // Only production sends telemetry + return environment == SDKEnvironment.production; + } + } + + /// Check if should sync with backend + bool shouldSyncWithBackend(SDKEnvironment environment) { + try { + final lib = PlatformLoader.loadCommons(); + final shouldSyncFn = lib.lookupFunction('rac_env_should_sync_with_backend'); + + return shouldSyncFn(_environmentToInt(environment)) != 0; + } catch (e) { + return environment != SDKEnvironment.development; + } + } + + /// Get environment description + String getDescription(SDKEnvironment environment) { + try { + final lib = PlatformLoader.loadCommons(); + final getDescFn = lib.lookupFunction Function(Int32), + Pointer Function(int)>('rac_env_description'); + + final result = getDescFn(_environmentToInt(environment)); + if (result == nullptr) return 'Unknown Environment'; + return result.toDartString(); + } catch (e) { + switch (environment) { + case SDKEnvironment.development: + return 'Development Environment'; + case SDKEnvironment.staging: + return 'Staging Environment'; + case SDKEnvironment.production: + return 'Production Environment'; + } + } + } + + // ============================================================================ + // Validation + // ============================================================================ + + /// Validate API key for environment + ValidationResult validateApiKey(String? apiKey, SDKEnvironment environment) { + try { + final lib = PlatformLoader.loadCommons(); + final validateFn = lib.lookupFunction< + Int32 Function(Pointer, Int32), + int Function(Pointer, int)>('rac_validate_api_key'); + + final apiKeyPtr = apiKey?.toNativeUtf8() ?? nullptr; + try { + final result = validateFn(apiKeyPtr.cast(), _environmentToInt(environment)); + return ValidationResult.fromCode(result); + } finally { + if (apiKeyPtr != nullptr) calloc.free(apiKeyPtr); + } + } catch (e) { + // Fallback validation + if (environment == SDKEnvironment.development) { + return ValidationResult.ok; + } + if (apiKey == null || apiKey.isEmpty) { + return ValidationResult.apiKeyRequired; + } + if (apiKey.length < 10) { + return ValidationResult.apiKeyTooShort; + } + return ValidationResult.ok; + } + } + + /// Validate base URL for environment + ValidationResult validateBaseURL(String? url, SDKEnvironment environment) { + try { + final lib = PlatformLoader.loadCommons(); + final validateFn = lib.lookupFunction< + Int32 Function(Pointer, Int32), + int Function(Pointer, int)>('rac_validate_base_url'); + + final urlPtr = url?.toNativeUtf8() ?? nullptr; + try { + final result = validateFn(urlPtr.cast(), _environmentToInt(environment)); + return ValidationResult.fromCode(result); + } finally { + if (urlPtr != nullptr) calloc.free(urlPtr); + } + } catch (e) { + // Fallback validation + if (environment == SDKEnvironment.development) { + return ValidationResult.ok; + } + if (url == null || url.isEmpty) { + return ValidationResult.urlRequired; + } + if (!url.startsWith('http://') && !url.startsWith('https://')) { + return ValidationResult.urlInvalidScheme; + } + if (environment == SDKEnvironment.production && !url.startsWith('https://')) { + return ValidationResult.urlHttpsRequired; + } + return ValidationResult.ok; + } + } + + /// Validate complete configuration + ValidationResult validateConfig({ + required SDKEnvironment environment, + String? apiKey, + String? baseURL, + }) { + try { + final lib = PlatformLoader.loadCommons(); + final validateFn = lib.lookupFunction< + Int32 Function(Pointer), + int Function(Pointer)>('rac_validate_config'); + + final config = calloc(); + final apiKeyPtr = apiKey?.toNativeUtf8() ?? nullptr; + final baseURLPtr = baseURL?.toNativeUtf8() ?? nullptr; + + try { + config.ref.environment = _environmentToInt(environment); + config.ref.apiKey = apiKeyPtr.cast(); + config.ref.baseURL = baseURLPtr.cast(); + + final result = validateFn(config); + return ValidationResult.fromCode(result); + } finally { + if (apiKeyPtr != nullptr) calloc.free(apiKeyPtr); + if (baseURLPtr != nullptr) calloc.free(baseURLPtr); + calloc.free(config); + } + } catch (e) { + // Fallback: validate each part + final apiKeyResult = validateApiKey(apiKey, environment); + if (!apiKeyResult.isValid) return apiKeyResult; + + final urlResult = validateBaseURL(baseURL, environment); + if (!urlResult.isValid) return urlResult; + + return ValidationResult.ok; + } + } + + /// Get error message for validation result + String getValidationErrorMessage(ValidationResult result) { + try { + final lib = PlatformLoader.loadCommons(); + final getMsgFn = lib.lookupFunction Function(Int32), + Pointer Function(int)>('rac_validation_error_message'); + + final msgResult = getMsgFn(result.code); + if (msgResult == nullptr) return result.message; + return msgResult.toDartString(); + } catch (e) { + return result.message; + } + } + + // ============================================================================ + // Internal Helpers + // ============================================================================ + + int _environmentToInt(SDKEnvironment env) { + switch (env) { + case SDKEnvironment.development: + return 0; + case SDKEnvironment.staging: + return 1; + case SDKEnvironment.production: + return 2; + } + } +} + +// ============================================================================= +// SDK Config Struct for FFI +// ============================================================================= + +/// SDK config struct for validation (simplified) +base class RacSdkConfigStruct extends Struct { + @Int32() + external int environment; + + external Pointer apiKey; + external Pointer baseURL; + external Pointer deviceId; + external Pointer platform; + external Pointer sdkVersion; +} + +// ============================================================================= +// Validation Result +// ============================================================================= + +/// Validation result enum matching rac_validation_result_t +class ValidationResult { + final int code; + final String message; + + const ValidationResult._(this.code, this.message); + + bool get isValid => code == 0; + + static const ok = ValidationResult._(0, 'Configuration is valid'); + static const apiKeyRequired = + ValidationResult._(1, 'API key is required for this environment'); + static const apiKeyTooShort = ValidationResult._(2, 'API key is too short'); + static const urlRequired = + ValidationResult._(3, 'Backend URL is required for this environment'); + static const urlInvalidScheme = + ValidationResult._(4, 'URL must start with http:// or https://'); + static const urlHttpsRequired = + ValidationResult._(5, 'HTTPS is required for production'); + static const urlInvalidHost = ValidationResult._(6, 'Invalid URL host'); + static const urlLocalhostNotAllowed = + ValidationResult._(7, 'localhost is not allowed in production'); + static const productionDebugBuild = + ValidationResult._(8, 'Debug builds not allowed in production'); + static const unknown = ValidationResult._(-1, 'Unknown validation error'); + + factory ValidationResult.fromCode(int code) { + switch (code) { + case 0: + return ok; + case 1: + return apiKeyRequired; + case 2: + return apiKeyTooShort; + case 3: + return urlRequired; + case 4: + return urlInvalidScheme; + case 5: + return urlHttpsRequired; + case 6: + return urlInvalidHost; + case 7: + return urlLocalhostNotAllowed; + case 8: + return productionDebugBuild; + default: + return unknown; + } + } +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_events.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_events.dart new file mode 100644 index 000000000..60fb44b5b --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_events.dart @@ -0,0 +1,139 @@ +// ignore_for_file: avoid_classes_with_only_static_members + +import 'dart:async'; +import 'dart:convert'; +import 'dart:ffi'; + +import 'package:ffi/ffi.dart'; +import 'package:runanywhere/foundation/logging/sdk_logger.dart'; +import 'package:runanywhere/native/ffi_types.dart'; +import 'package:runanywhere/native/platform_loader.dart'; + +/// Events bridge for C++ event routing. +/// Matches Swift's `CppBridge+Events.swift`. +class DartBridgeEvents { + DartBridgeEvents._(); + + static final _logger = SDKLogger('DartBridge.Events'); + static final DartBridgeEvents instance = DartBridgeEvents._(); + + static bool _isRegistered = false; + + /// Event stream controller for SDK events + static final _eventController = StreamController.broadcast(); + + /// Stream of SDK events from C++ + static Stream get eventStream => _eventController.stream; + + /// Register events callback with C++ + static void register() { + if (_isRegistered) return; + + try { + final lib = PlatformLoader.load(); + + // Look up event registration function + final registerCallback = lib.lookupFunction< + Int32 Function(Pointer, Pointer)>>), + int Function(Pointer, Pointer)>>)>( + 'rac_events_register_callback', + ); + + // Register the callback + final callbackPtr = Pointer.fromFunction, Pointer)>( + _eventsCallback, + ); + + final result = registerCallback(callbackPtr); + if (result != RacResultCode.success) { + _logger.warning('Failed to register events callback', metadata: {'code': result}); + } + + _isRegistered = true; + _logger.debug('Events callback registered'); + } catch (e) { + _logger.debug('Events registration not available: $e'); + _isRegistered = true; // Mark as registered to avoid retry + } + } + + /// Unregister events callback + static void unregister() { + if (!_isRegistered) return; + + try { + final lib = PlatformLoader.load(); + final unregisterCallback = lib.lookupFunction< + Void Function(), + void Function()>('rac_events_unregister_callback'); + + unregisterCallback(); + _isRegistered = false; + _logger.debug('Events callback unregistered'); + } catch (e) { + _logger.debug('Events unregistration not available: $e'); + } + } + + /// Emit an event to subscribers + void emit(SDKEvent event) { + _eventController.add(event); + } + + /// Subscribe to events of a specific type + StreamSubscription subscribe( + void Function(SDKEvent event) onEvent, { + String? eventType, + }) { + if (eventType != null) { + return eventStream + .where((e) => e.type == eventType) + .listen(onEvent); + } + return eventStream.listen(onEvent); + } +} + +/// Events callback from C++ +void _eventsCallback(Pointer eventJson, Pointer userData) { + if (eventJson == nullptr) return; + + try { + final jsonString = eventJson.toDartString(); + final data = jsonDecode(jsonString) as Map; + + final event = SDKEvent( + type: data['type'] as String? ?? 'unknown', + data: data['data'] as Map? ?? {}, + timestamp: DateTime.fromMillisecondsSinceEpoch( + data['timestamp'] as int? ?? DateTime.now().millisecondsSinceEpoch, + ), + ); + + DartBridgeEvents.instance.emit(event); + } catch (e) { + SDKLogger('DartBridge.Events').warning('Failed to parse event: $e'); + } +} + +/// SDK event from C++ or Dart +class SDKEvent { + final String type; + final Map data; + final DateTime timestamp; + + SDKEvent({ + required this.type, + required this.data, + DateTime? timestamp, + }) : timestamp = timestamp ?? DateTime.now(); + + Map toJson() => { + 'type': type, + 'data': data, + 'timestamp': timestamp.millisecondsSinceEpoch, + }; + + @override + String toString() => 'SDKEvent($type, $data)'; +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_http.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_http.dart new file mode 100644 index 000000000..33d49b17f --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_http.dart @@ -0,0 +1,485 @@ +// ignore_for_file: avoid_classes_with_only_static_members + +import 'dart:async'; +import 'dart:convert'; +import 'dart:ffi'; + +import 'package:ffi/ffi.dart'; +import 'package:http/http.dart' as http; +import 'package:runanywhere/foundation/logging/sdk_logger.dart'; +import 'package:runanywhere/native/dart_bridge_auth.dart'; +import 'package:runanywhere/native/ffi_types.dart'; +import 'package:runanywhere/native/platform_loader.dart'; +import 'package:runanywhere/public/configuration/sdk_environment.dart'; + +// ============================================================================= +// HTTP Bridge +// ============================================================================= + +/// HTTP bridge - provides HTTP transport for C++ callbacks. +/// Matches Swift's `CppBridge+HTTP.swift` and `HTTPService.swift`. +/// +/// This is the central HTTP transport layer that other bridges use. +/// C++ can request HTTP calls via callbacks, and this bridge executes them. +class DartBridgeHTTP { + DartBridgeHTTP._(); + + static final _logger = SDKLogger('DartBridge.HTTP'); + static final DartBridgeHTTP instance = DartBridgeHTTP._(); + + String? _baseURL; + String? _apiKey; + String? _accessToken; + final Map _defaultHeaders = {}; + bool _isConfigured = false; + + /// Check if HTTP is configured + bool get isConfigured => _isConfigured; + + /// Get base URL + String? get baseURL => _baseURL; + + /// Configure HTTP settings + Future configure({ + required SDKEnvironment environment, + String? apiKey, + String? baseURL, + String? accessToken, + Map? defaultHeaders, + }) async { + _apiKey = apiKey; + _accessToken = accessToken; + _baseURL = baseURL ?? _getDefaultBaseURL(environment); + + if (defaultHeaders != null) { + _defaultHeaders.addAll(defaultHeaders); + } + + // Configure in C++ layer if available + try { + final lib = PlatformLoader.loadCommons(); + final configureFn = lib.lookupFunction< + Int32 Function(Pointer, Pointer), + int Function(Pointer, Pointer)>('rac_http_configure'); + + final basePtr = (_baseURL ?? '').toNativeUtf8(); + final keyPtr = (_apiKey ?? '').toNativeUtf8(); + + try { + final result = configureFn(basePtr, keyPtr); + if (result != RacResultCode.success) { + _logger.warning('HTTP configure failed', metadata: {'code': result}); + } + } finally { + calloc.free(basePtr); + calloc.free(keyPtr); + } + } catch (e) { + _logger.debug('rac_http_configure not available: $e'); + } + + _isConfigured = true; + _logger.debug('HTTP configured', metadata: {'baseURL': _baseURL}); + } + + /// Update access token + void setAccessToken(String? token) { + _accessToken = token; + } + + /// Set API key + void setApiKey(String key) { + _apiKey = key; + } + + /// Add default header + void addHeader(String key, String value) { + _defaultHeaders[key] = value; + } + + /// Remove default header + void removeHeader(String key) { + _defaultHeaders.remove(key); + } + + /// Get all default headers + Map get headers => Map.unmodifiable(_defaultHeaders); + + // ============================================================================ + // HTTP Methods + // ============================================================================ + + /// Perform GET request + Future get( + String endpoint, { + Map? headers, + bool requiresAuth = true, + Duration? timeout, + }) async { + return _request( + method: 'GET', + endpoint: endpoint, + headers: headers, + requiresAuth: requiresAuth, + timeout: timeout, + ); + } + + /// Perform POST request + Future post( + String endpoint, { + Object? body, + Map? headers, + bool requiresAuth = true, + Duration? timeout, + }) async { + return _request( + method: 'POST', + endpoint: endpoint, + body: body, + headers: headers, + requiresAuth: requiresAuth, + timeout: timeout, + ); + } + + /// Perform PUT request + Future put( + String endpoint, { + Object? body, + Map? headers, + bool requiresAuth = true, + Duration? timeout, + }) async { + return _request( + method: 'PUT', + endpoint: endpoint, + body: body, + headers: headers, + requiresAuth: requiresAuth, + timeout: timeout, + ); + } + + /// Perform DELETE request + Future delete( + String endpoint, { + Map? headers, + bool requiresAuth = true, + Duration? timeout, + }) async { + return _request( + method: 'DELETE', + endpoint: endpoint, + headers: headers, + requiresAuth: requiresAuth, + timeout: timeout, + ); + } + + /// Internal request handler + Future _request({ + required String method, + required String endpoint, + Object? body, + Map? headers, + bool requiresAuth = true, + Duration? timeout, + bool isRetry = false, + }) async { + if (!_isConfigured || _baseURL == null) { + return HTTPResult.failure('HTTP not configured'); + } + + try { + final url = Uri.parse('$_baseURL$endpoint'); + + // Build headers + final requestHeaders = { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + ..._defaultHeaders, + if (headers != null) ...headers, + }; + + // Resolve token if auth is required (matches Swift's resolveToken pattern) + if (requiresAuth) { + final token = await _resolveToken(requiresAuth: true); + if (token != null && token.isNotEmpty) { + requestHeaders['Authorization'] = 'Bearer $token'; + } else if (_apiKey != null) { + requestHeaders['X-API-Key'] = _apiKey!; + } + } + + // Encode body + String? bodyString; + if (body != null) { + if (body is String) { + bodyString = body; + } else { + bodyString = jsonEncode(body); + } + } + + // Make request with timeout + final client = http.Client(); + http.Response response; + + try { + final request = http.Request(method, url); + request.headers.addAll(requestHeaders); + if (bodyString != null) { + request.body = bodyString; + } + + final streamedResponse = await client + .send(request) + .timeout(timeout ?? const Duration(seconds: 30)); + response = await http.Response.fromStream(streamedResponse); + } finally { + client.close(); + } + + // Handle 401 Unauthorized - attempt token refresh and retry once + if (response.statusCode == 401 && requiresAuth && !isRetry) { + _logger.debug('Received 401, attempting token refresh and retry...'); + + final authBridge = DartBridgeAuth.instance; + final refreshResult = await authBridge.refreshToken(); + + if (refreshResult.isSuccess) { + final newToken = authBridge.getAccessToken(); + if (newToken != null) { + _accessToken = newToken; + _logger.info('Token refreshed, retrying request...'); + + // Retry the request with new token + return _request( + method: method, + endpoint: endpoint, + body: body, + headers: headers, + requiresAuth: requiresAuth, + timeout: timeout, + isRetry: true, + ); + } + } else { + _logger.warning('Token refresh failed: ${refreshResult.error}'); + } + } + + // Parse response + if (response.statusCode >= 200 && response.statusCode < 300) { + return HTTPResult.success( + statusCode: response.statusCode, + body: response.body, + headers: response.headers, + ); + } else { + return HTTPResult( + isSuccess: false, + statusCode: response.statusCode, + body: response.body, + headers: response.headers, + error: _parseError(response.body, response.statusCode), + ); + } + } catch (e) { + _logger.error('HTTP request failed', metadata: { + 'method': method, + 'endpoint': endpoint, + 'error': e.toString(), + }); + return HTTPResult.failure(e.toString()); + } + } + + /// Resolve valid token for request, refreshing if needed. + /// Matches Swift's HTTPService.resolveToken(requiresAuth:) + Future _resolveToken({required bool requiresAuth}) async { + if (!requiresAuth) { + return _apiKey; + } + + final authBridge = DartBridgeAuth.instance; + + // Check if we have a valid token + final currentToken = authBridge.getAccessToken(); + if (currentToken != null && !authBridge.needsRefresh()) { + return currentToken; + } + + // Try refresh if authenticated + if (authBridge.isAuthenticated()) { + _logger.debug('Token needs refresh, attempting refresh...'); + final result = await authBridge.refreshToken(); + if (result.isSuccess) { + final newToken = authBridge.getAccessToken(); + if (newToken != null) { + _accessToken = newToken; + _logger.info('Token refreshed successfully'); + return newToken; + } + } else { + _logger.warning('Token refresh failed: ${result.error}'); + } + } + + // Fallback to access token or API key + if (_accessToken != null && _accessToken!.isNotEmpty) { + return _accessToken; + } + return _apiKey; + } + + /// Download file + Future download( + String url, + String destinationPath, { + void Function(int downloaded, int total)? onProgress, + Duration? timeout, + }) async { + try { + final uri = url.startsWith('http') ? Uri.parse(url) : Uri.parse('$_baseURL$url'); + + final client = http.Client(); + try { + final request = http.Request('GET', uri); + if (_accessToken != null) { + request.headers['Authorization'] = 'Bearer $_accessToken'; + } + + final streamedResponse = await client.send(request); + + if (streamedResponse.statusCode >= 200 && streamedResponse.statusCode < 300) { + final file = await _saveStreamToFile( + streamedResponse.stream, + destinationPath, + streamedResponse.contentLength ?? 0, + onProgress, + ); + + return HTTPResult.success( + statusCode: streamedResponse.statusCode, + body: file, + ); + } else { + return HTTPResult( + isSuccess: false, + statusCode: streamedResponse.statusCode, + error: 'Download failed with status ${streamedResponse.statusCode}', + ); + } + } finally { + client.close(); + } + } catch (e) { + return HTTPResult.failure(e.toString()); + } + } + + // ============================================================================ + // Internal Helpers + // ============================================================================ + + String _getDefaultBaseURL(SDKEnvironment environment) { + switch (environment) { + case SDKEnvironment.development: + return 'https://dev-api.runanywhere.ai'; + case SDKEnvironment.staging: + return 'https://staging-api.runanywhere.ai'; + case SDKEnvironment.production: + return 'https://api.runanywhere.ai'; + } + } + + String _parseError(String body, int statusCode) { + try { + final data = jsonDecode(body) as Map; + return data['message'] as String? ?? + data['error'] as String? ?? + 'HTTP error $statusCode'; + } catch (e) { + return 'HTTP error $statusCode'; + } + } + + Future _saveStreamToFile( + Stream> stream, + String path, + int totalBytes, + void Function(int, int)? onProgress, + ) async { + // Note: In a real implementation, use dart:io File to save + // For now, just consume the stream + var downloaded = 0; + final chunks = >[]; + + await for (final chunk in stream) { + chunks.add(chunk); + downloaded += chunk.length; + onProgress?.call(downloaded, totalBytes); + } + + // Would save to file here + return path; + } +} + +// ============================================================================= +// HTTP Result +// ============================================================================= + +/// HTTP request result +class HTTPResult { + final bool isSuccess; + final int? statusCode; + final String? body; + final Map? headers; + final String? error; + + const HTTPResult({ + required this.isSuccess, + this.statusCode, + this.body, + this.headers, + this.error, + }); + + factory HTTPResult.success({ + required int statusCode, + String? body, + Map? headers, + }) => + HTTPResult( + isSuccess: true, + statusCode: statusCode, + body: body, + headers: headers, + ); + + factory HTTPResult.failure(String error) => + HTTPResult(isSuccess: false, error: error); + + /// Parse JSON body + Map? get json { + if (body == null) return null; + try { + return jsonDecode(body!) as Map; + } catch (e) { + return null; + } + } + + /// Parse JSON array body + List? get jsonArray { + if (body == null) return null; + try { + return jsonDecode(body!) as List; + } catch (e) { + return null; + } + } +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_llm.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_llm.dart new file mode 100644 index 000000000..de303e452 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_llm.dart @@ -0,0 +1,629 @@ +/// DartBridge+LLM +/// +/// LLM component bridge - manages C++ LLM component lifecycle. +/// Mirrors Swift's CppBridge+LLM.swift pattern exactly. +/// +/// This is a thin wrapper around C++ LLM component functions. +/// All business logic is in C++ - Dart only manages the handle. +/// +/// STREAMING ARCHITECTURE: +/// Streaming runs in a background isolate to prevent ANR (Application Not Responding). +/// The C++ logger callback uses NativeCallable.listener which is thread-safe and +/// can be called from any isolate. Token callbacks in the background isolate send +/// messages to the main isolate via a SendPort. +library dart_bridge_llm; + +import 'dart:async'; +import 'dart:ffi'; +import 'dart:isolate'; // Keep for non-streaming generation + +import 'package:ffi/ffi.dart'; + +import 'package:runanywhere/foundation/logging/sdk_logger.dart'; +import 'package:runanywhere/native/ffi_types.dart'; +import 'package:runanywhere/native/platform_loader.dart'; + +/// LLM component bridge for C++ interop. +/// +/// Provides access to the C++ LLM component. +/// Handles model loading, generation, and lifecycle. +/// +/// Matches Swift's CppBridge.LLM actor pattern. +/// +/// Usage: +/// ```dart +/// final llm = DartBridgeLLM.shared; +/// await llm.loadModel('/path/to/model.gguf', 'model-id', 'Model Name'); +/// final result = await llm.generate('Hello', maxTokens: 100); +/// ``` +class DartBridgeLLM { + // MARK: - Singleton + + /// Shared instance + static final DartBridgeLLM shared = DartBridgeLLM._(); + + DartBridgeLLM._(); + + // MARK: - State (matches Swift CppBridge.LLM exactly) + + RacHandle? _handle; + String? _loadedModelId; + final _logger = SDKLogger('DartBridge.LLM'); + + /// Active stream subscription for cancellation + StreamSubscription? _activeStreamSubscription; + + /// Cancel any active generation + void cancelGeneration() { + unawaited(_activeStreamSubscription?.cancel()); + _activeStreamSubscription = null; + // Cancel at native level + cancel(); + } + + /// Set active stream subscription for cancellation + void setActiveStreamSubscription(StreamSubscription? sub) { + _activeStreamSubscription = sub; + } + + // MARK: - Handle Management + + /// Get or create the LLM component handle. + /// + /// Lazily creates the C++ LLM component on first access. + /// Throws if creation fails. + RacHandle getHandle() { + if (_handle != null) { + return _handle!; + } + + try { + final lib = PlatformLoader.loadCommons(); + final create = lib.lookupFunction), + int Function(Pointer)>('rac_llm_component_create'); + + final handlePtr = calloc(); + try { + final result = create(handlePtr); + + if (result != RAC_SUCCESS) { + throw StateError( + 'Failed to create LLM component: ${RacResultCode.getMessage(result)}', + ); + } + + _handle = handlePtr.value; + _logger.debug('LLM component created'); + return _handle!; + } finally { + calloc.free(handlePtr); + } + } catch (e) { + _logger.error('Failed to create LLM handle: $e'); + rethrow; + } + } + + // MARK: - State Queries + + /// Check if a model is loaded. + bool get isLoaded { + if (_handle == null) return false; + + try { + final lib = PlatformLoader.loadCommons(); + final isLoadedFn = lib.lookupFunction('rac_llm_component_is_loaded'); + + return isLoadedFn(_handle!) == RAC_TRUE; + } catch (e) { + _logger.debug('isLoaded check failed: $e'); + return false; + } + } + + /// Get the currently loaded model ID. + String? get currentModelId => _loadedModelId; + + /// Check if streaming is supported. + bool get supportsStreaming { + if (_handle == null) return false; + + try { + final lib = PlatformLoader.loadCommons(); + final supportsStreamingFn = lib.lookupFunction('rac_llm_component_supports_streaming'); + + return supportsStreamingFn(_handle!) == RAC_TRUE; + } catch (e) { + return false; + } + } + + // MARK: - Model Lifecycle + + /// Load an LLM model. + /// + /// [modelPath] - Full path to the model file. + /// [modelId] - Unique identifier for the model. + /// [modelName] - Human-readable name. + /// + /// Throws on failure. + Future loadModel( + String modelPath, + String modelId, + String modelName, + ) async { + final handle = getHandle(); + + final pathPtr = modelPath.toNativeUtf8(); + final idPtr = modelId.toNativeUtf8(); + final namePtr = modelName.toNativeUtf8(); + + try { + final lib = PlatformLoader.loadCommons(); + final loadModelFn = lib.lookupFunction< + Int32 Function( + RacHandle, Pointer, Pointer, Pointer), + int Function(RacHandle, Pointer, Pointer, + Pointer)>('rac_llm_component_load_model'); + + _logger.debug( + 'Calling rac_llm_component_load_model with handle: $_handle, path: $modelPath'); + final result = loadModelFn(handle, pathPtr, idPtr, namePtr); + _logger.debug( + 'rac_llm_component_load_model returned: $result (${RacResultCode.getMessage(result)})'); + + if (result != RAC_SUCCESS) { + throw StateError( + 'Failed to load LLM model: Error (code: $result)', + ); + } + + _loadedModelId = modelId; + _logger.info('LLM model loaded: $modelId'); + } finally { + calloc.free(pathPtr); + calloc.free(idPtr); + calloc.free(namePtr); + } + } + + /// Unload the current model. + void unload() { + if (_handle == null) return; + + try { + final lib = PlatformLoader.loadCommons(); + final cleanupFn = lib.lookupFunction('rac_llm_component_cleanup'); + + cleanupFn(_handle!); + _loadedModelId = null; + _logger.info('LLM model unloaded'); + } catch (e) { + _logger.error('Failed to unload LLM model: $e'); + } + } + + /// Cancel ongoing generation. + void cancel() { + if (_handle == null) return; + + try { + final lib = PlatformLoader.loadCommons(); + final cancelFn = lib.lookupFunction('rac_llm_component_cancel'); + + cancelFn(_handle!); + _logger.debug('LLM generation cancelled'); + } catch (e) { + _logger.error('Failed to cancel generation: $e'); + } + } + + // MARK: - Generation + + /// Generate text from a prompt. + /// + /// [prompt] - Input prompt. + /// [maxTokens] - Maximum tokens to generate (default: 512). + /// [temperature] - Sampling temperature (default: 0.7). + /// + /// Returns the generated text and metrics. + /// + /// IMPORTANT: This runs in a separate isolate to prevent heap corruption + /// from C++ Metal/GPU background threads. + Future generate( + String prompt, { + int maxTokens = 512, + double temperature = 0.7, + }) async { + final handle = getHandle(); + + if (!isLoaded) { + throw StateError('No LLM model loaded. Call loadModel() first.'); + } + + // Run FFI call in a separate isolate to avoid heap corruption + // from C++ background threads (Metal GPU operations) + final handleAddress = handle.address; + final tokens = maxTokens; + final temp = temperature; + + final result = await Isolate.run(() { + return _generateInIsolate(handleAddress, prompt, tokens, temp); + }); + + if (result.error != null) { + throw StateError(result.error!); + } + + return LLMComponentResult( + text: result.text ?? '', + promptTokens: result.promptTokens, + completionTokens: result.completionTokens, + totalTimeMs: result.totalTimeMs, + ); + } + + /// Generate text with streaming. + /// + /// Returns a stream of tokens as they are generated. + /// + /// ARCHITECTURE: Runs in a background isolate to prevent ANR. + /// The logger callback uses NativeCallable.listener which is thread-safe. + /// Tokens are sent back to the main isolate via SendPort for UI updates. + Stream generateStream( + String prompt, { + int maxTokens = 512, // Can use higher values now since it's non-blocking + double temperature = 0.7, + }) { + final handle = getHandle(); + + if (!isLoaded) { + throw StateError('No LLM model loaded. Call loadModel() first.'); + } + + // Create stream controller for emitting tokens to the caller + final controller = StreamController(); + + // Start streaming generation in a background isolate + unawaited(_startBackgroundStreaming( + handle.address, + prompt, + maxTokens, + temperature, + controller, + )); + + return controller.stream; + } + + /// Start streaming generation in a background isolate. + /// + /// ARCHITECTURE NOTE: + /// The logger callback now uses NativeCallable.listener which is thread-safe. + /// This allows us to run the FFI streaming call in a background isolate + /// without crashing when C++ logs. Tokens are sent back to the main isolate + /// via a ReceivePort/SendPort pair. + Future _startBackgroundStreaming( + int handleAddress, + String prompt, + int maxTokens, + double temperature, + StreamController controller, + ) async { + // Create a ReceivePort to receive tokens from the background isolate + final receivePort = ReceivePort(); + + // Listen for messages from the background isolate + receivePort.listen((message) { + if (controller.isClosed) return; + + if (message is String) { + // It's a token + controller.add(message); + } else if (message is _StreamingMessage) { + if (message.isComplete) { + controller.close(); + receivePort.close(); + } else if (message.error != null) { + controller.addError(StateError(message.error!)); + controller.close(); + receivePort.close(); + } + } + }); + + // Spawn background isolate for streaming + try { + await Isolate.spawn( + _streamingIsolateEntry, + _StreamingIsolateParams( + sendPort: receivePort.sendPort, + handleAddress: handleAddress, + prompt: prompt, + maxTokens: maxTokens, + temperature: temperature, + ), + ); + } catch (e) { + if (!controller.isClosed) { + controller.addError(e); + await controller.close(); + } + receivePort.close(); + } + } + + // MARK: - Cleanup + + /// Destroy the component and release resources. + void destroy() { + if (_handle != null) { + try { + final lib = PlatformLoader.loadCommons(); + final destroyFn = lib.lookupFunction('rac_llm_component_destroy'); + + destroyFn(_handle!); + _handle = null; + _loadedModelId = null; + _logger.debug('LLM component destroyed'); + } catch (e) { + _logger.error('Failed to destroy LLM component: $e'); + } + } + } +} + +/// Result from LLM generation. +class LLMComponentResult { + final String text; + final int promptTokens; + final int completionTokens; + final int totalTimeMs; + + const LLMComponentResult({ + required this.text, + required this.promptTokens, + required this.completionTokens, + required this.totalTimeMs, + }); + + double get tokensPerSecond { + if (totalTimeMs <= 0) return 0; + return completionTokens / (totalTimeMs / 1000.0); + } +} + +// ============================================================================= +// Isolate Helper for FFI Generation +// ============================================================================= + +/// Result container for isolate communication (must be simple types). +class _IsolateGenerationResult { + final String? text; + final int promptTokens; + final int completionTokens; + final int totalTimeMs; + final String? error; + + const _IsolateGenerationResult({ + this.text, + this.promptTokens = 0, + this.completionTokens = 0, + this.totalTimeMs = 0, + this.error, + }); +} + +// ============================================================================= +// Background Isolate Streaming Support +// ============================================================================= + +/// Parameters for the streaming isolate +class _StreamingIsolateParams { + final SendPort sendPort; + final int handleAddress; + final String prompt; + final int maxTokens; + final double temperature; + + _StreamingIsolateParams({ + required this.sendPort, + required this.handleAddress, + required this.prompt, + required this.maxTokens, + required this.temperature, + }); +} + +/// Message sent from streaming isolate to main isolate +class _StreamingMessage { + final bool isComplete; + final String? error; + + _StreamingMessage({this.isComplete = false, this.error}); +} + +/// SendPort for the current streaming operation in the background isolate +SendPort? _isolateSendPort; + +/// Entry point for the streaming isolate +@pragma('vm:entry-point') +void _streamingIsolateEntry(_StreamingIsolateParams params) { + // Store the SendPort for callbacks to use + _isolateSendPort = params.sendPort; + + final handle = Pointer.fromAddress(params.handleAddress); + final promptPtr = params.prompt.toNativeUtf8(); + final optionsPtr = calloc(); + + try { + // Set options + optionsPtr.ref.maxTokens = params.maxTokens; + optionsPtr.ref.temperature = params.temperature; + optionsPtr.ref.topP = 1.0; + optionsPtr.ref.stopSequences = nullptr; + optionsPtr.ref.numStopSequences = 0; + optionsPtr.ref.streamingEnabled = RAC_TRUE; + optionsPtr.ref.systemPrompt = nullptr; + + final lib = PlatformLoader.loadCommons(); + + // Get callback function pointers + final tokenCallbackPtr = + Pointer.fromFunction, Pointer)>( + _isolateTokenCallback, 1); + final completeCallbackPtr = Pointer.fromFunction< + Void Function( + Pointer, Pointer)>(_isolateCompleteCallback); + final errorCallbackPtr = Pointer.fromFunction< + Void Function(Int32, Pointer, Pointer)>(_isolateErrorCallback); + + final generateStreamFn = lib.lookupFunction< + Int32 Function( + RacHandle, + Pointer, + Pointer, + Pointer, Pointer)>>, + Pointer< + NativeFunction< + Void Function(Pointer, Pointer)>>, + Pointer< + NativeFunction< + Void Function(Int32, Pointer, Pointer)>>, + Pointer, + ), + int Function( + RacHandle, + Pointer, + Pointer, + Pointer, Pointer)>>, + Pointer< + NativeFunction< + Void Function(Pointer, Pointer)>>, + Pointer< + NativeFunction< + Void Function(Int32, Pointer, Pointer)>>, + Pointer, + )>('rac_llm_component_generate_stream'); + + // This FFI call blocks until generation is complete + final status = generateStreamFn( + handle, + promptPtr, + optionsPtr, + tokenCallbackPtr, + completeCallbackPtr, + errorCallbackPtr, + nullptr, + ); + + if (status != RAC_SUCCESS) { + params.sendPort.send(_StreamingMessage( + error: 'Failed to start streaming: ${RacResultCode.getMessage(status)}', + )); + } + } catch (e) { + params.sendPort.send(_StreamingMessage(error: 'Streaming exception: $e')); + } finally { + calloc.free(promptPtr); + calloc.free(optionsPtr); + _isolateSendPort = null; + } +} + +/// Token callback for background isolate streaming +@pragma('vm:entry-point') +int _isolateTokenCallback(Pointer token, Pointer userData) { + try { + if (_isolateSendPort != null && token != nullptr) { + final tokenStr = token.toDartString(); + _isolateSendPort!.send(tokenStr); + } + return 1; // RAC_TRUE = continue generation + } catch (e) { + return 1; // Continue even on error + } +} + +/// Completion callback for background isolate streaming +@pragma('vm:entry-point') +void _isolateCompleteCallback( + Pointer result, Pointer userData) { + _isolateSendPort?.send(_StreamingMessage(isComplete: true)); +} + +/// Error callback for background isolate streaming +@pragma('vm:entry-point') +void _isolateErrorCallback( + int errorCode, Pointer errorMsg, Pointer userData) { + final message = errorMsg != nullptr ? errorMsg.toDartString() : 'Unknown error'; + _isolateSendPort?.send(_StreamingMessage(error: 'Generation error ($errorCode): $message')); +} + +// ============================================================================= +// Isolate Helper for Non-Streaming Generation +// ============================================================================= + +/// Run LLM generation in an isolate. +/// +/// This function is called from Isolate.run() and performs the actual FFI call. +/// Running in a separate isolate prevents heap corruption from C++ background +/// threads (Metal GPU operations on iOS). +_IsolateGenerationResult _generateInIsolate( + int handleAddress, + String prompt, + int maxTokens, + double temperature, +) { + final handle = Pointer.fromAddress(handleAddress); + final promptPtr = prompt.toNativeUtf8(); + final optionsPtr = calloc(); + final resultPtr = calloc(); + + try { + // Set options - matching C++ rac_llm_options_t + optionsPtr.ref.maxTokens = maxTokens; + optionsPtr.ref.temperature = temperature; + optionsPtr.ref.topP = 1.0; + optionsPtr.ref.stopSequences = nullptr; + optionsPtr.ref.numStopSequences = 0; + optionsPtr.ref.streamingEnabled = RAC_FALSE; + optionsPtr.ref.systemPrompt = nullptr; + + final lib = PlatformLoader.loadCommons(); + final generateFn = lib.lookupFunction< + Int32 Function(RacHandle, Pointer, Pointer, + Pointer), + int Function(RacHandle, Pointer, Pointer, + Pointer)>('rac_llm_component_generate'); + + final status = generateFn(handle, promptPtr, optionsPtr, resultPtr); + + if (status != RAC_SUCCESS) { + return _IsolateGenerationResult( + error: 'LLM generation failed: ${RacResultCode.getMessage(status)}', + ); + } + + final result = resultPtr.ref; + final text = result.text != nullptr ? result.text.toDartString() : ''; + + return _IsolateGenerationResult( + text: text, + promptTokens: result.promptTokens, + completionTokens: result.completionTokens, + totalTimeMs: result.totalTimeMs, + ); + } catch (e) { + return _IsolateGenerationResult(error: 'Generation exception: $e'); + } finally { + calloc.free(promptPtr); + calloc.free(optionsPtr); + calloc.free(resultPtr); + } +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_model_assignment.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_model_assignment.dart new file mode 100644 index 000000000..493bf5386 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_model_assignment.dart @@ -0,0 +1,374 @@ +// ignore_for_file: avoid_classes_with_only_static_members + +import 'dart:async'; +import 'dart:ffi'; +import 'dart:io'; + +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:ffi/ffi.dart'; +import 'package:http/http.dart' as http; + +import 'package:runanywhere/foundation/logging/sdk_logger.dart'; +import 'package:runanywhere/native/dart_bridge_model_registry.dart'; +import 'package:runanywhere/native/ffi_types.dart'; +import 'package:runanywhere/native/platform_loader.dart'; +import 'package:runanywhere/public/configuration/sdk_environment.dart'; + +// ============================================================================= +// Exception Return Constants +// ============================================================================= + +const int _exceptionalReturnInt32 = -1; + +// ============================================================================= +// Model Assignment Bridge +// ============================================================================= + +/// Model assignment bridge for C++ model assignment operations. +/// Matches Swift's `CppBridge+ModelAssignment.swift`. +/// +/// Fetches model assignments from backend API and caches them. +/// Provides filtering by framework and category. +class DartBridgeModelAssignment { + DartBridgeModelAssignment._(); + + static final _logger = SDKLogger('DartBridge.ModelAssignment'); + static final DartBridgeModelAssignment instance = + DartBridgeModelAssignment._(); + + static bool _isRegistered = false; + static Pointer? _callbacksPtr; + static String? _baseURL; + static String? _accessToken; + // ignore: unused_field + static SDKEnvironment _environment = SDKEnvironment.development; + + // ============================================================================ + // Registration + // ============================================================================ + + /// Register model assignment callbacks with C++ + /// + /// [autoFetch] Whether to auto-fetch models after registration. + /// Should be false for development mode, true for staging/production. + static Future register({ + required SDKEnvironment environment, + bool autoFetch = false, + String? baseURL, + String? accessToken, + }) async { + if (_isRegistered) return; + + _environment = environment; + _baseURL = baseURL; + _accessToken = accessToken; + + try { + final lib = PlatformLoader.loadCommons(); + + // Allocate callbacks struct + _callbacksPtr = calloc(); + _callbacksPtr!.ref.httpGet = + Pointer.fromFunction( + _httpGetCallback, _exceptionalReturnInt32); + _callbacksPtr!.ref.userData = nullptr; + // Only auto-fetch in staging/production, not development + _callbacksPtr!.ref.autoFetch = autoFetch ? 1 : 0; + + // Register with C++ + final setCallbacks = lib.lookupFunction< + Int32 Function(Pointer), + int Function(Pointer)>( + 'rac_model_assignment_set_callbacks'); + + final result = setCallbacks(_callbacksPtr!); + if (result != RacResultCode.success) { + _logger.warning('Failed to register assignment callbacks', + metadata: {'code': result}); + calloc.free(_callbacksPtr!); + _callbacksPtr = null; + return; + } + + _isRegistered = true; + _logger.debug( + 'Model assignment callbacks registered (autoFetch: $autoFetch)'); + } catch (e) { + _logger.debug('Model assignment registration error: $e'); + _isRegistered = true; // Avoid retry loops + } + } + + /// Update access token + static void setAccessToken(String? token) { + _accessToken = token; + } + + // ============================================================================ + // Fetch Operations + // ============================================================================ + + /// Fetch model assignments from backend + Future> fetchAssignments({bool forceRefresh = false}) async { + try { + final lib = PlatformLoader.loadCommons(); + final fetchFn = lib.lookupFunction< + Int32 Function(Int32, Pointer>>, + Pointer), + int Function(int, Pointer>>, + Pointer)>('rac_model_assignment_fetch'); + + final outModelsPtr = calloc>>(); + final outCountPtr = calloc(); + + try { + final result = fetchFn(forceRefresh ? 1 : 0, outModelsPtr, outCountPtr); + if (result != RacResultCode.success) { + _logger + .warning('Fetch assignments failed', metadata: {'code': result}); + return []; + } + + final count = outCountPtr.value; + if (count == 0) return []; + + final models = []; + final modelsArray = outModelsPtr.value; + + for (var i = 0; i < count; i++) { + final modelPtr = modelsArray[i]; + if (modelPtr != nullptr) { + models.add(_structToModelInfo(modelPtr)); + } + } + + // Free the array + final freeFn = lib.lookupFunction< + Void Function(Pointer>, IntPtr), + void Function(Pointer>, + int)>('rac_model_info_array_free'); + freeFn(modelsArray, count); + + return models; + } finally { + calloc.free(outModelsPtr); + calloc.free(outCountPtr); + } + } catch (e) { + _logger.debug('rac_model_assignment_fetch error: $e'); + return []; + } + } + + /// Get assignments by framework + Future> getByFramework(int framework) async { + try { + final lib = PlatformLoader.loadCommons(); + final getByFn = lib.lookupFunction< + Int32 Function(Int32, Pointer>>, + Pointer), + int Function(int, Pointer>>, + Pointer)>('rac_model_assignment_get_by_framework'); + + final outModelsPtr = calloc>>(); + final outCountPtr = calloc(); + + try { + final result = getByFn(framework, outModelsPtr, outCountPtr); + if (result != RacResultCode.success) return []; + + final count = outCountPtr.value; + if (count == 0) return []; + + final models = []; + final modelsArray = outModelsPtr.value; + + for (var i = 0; i < count; i++) { + final modelPtr = modelsArray[i]; + if (modelPtr != nullptr) { + models.add(_structToModelInfo(modelPtr)); + } + } + + return models; + } finally { + calloc.free(outModelsPtr); + calloc.free(outCountPtr); + } + } catch (e) { + _logger.debug('rac_model_assignment_get_by_framework error: $e'); + return []; + } + } + + /// Get assignments by category + Future> getByCategory(int category) async { + try { + final lib = PlatformLoader.loadCommons(); + final getByFn = lib.lookupFunction< + Int32 Function(Int32, Pointer>>, + Pointer), + int Function(int, Pointer>>, + Pointer)>('rac_model_assignment_get_by_category'); + + final outModelsPtr = calloc>>(); + final outCountPtr = calloc(); + + try { + final result = getByFn(category, outModelsPtr, outCountPtr); + if (result != RacResultCode.success) return []; + + final count = outCountPtr.value; + if (count == 0) return []; + + final models = []; + final modelsArray = outModelsPtr.value; + + for (var i = 0; i < count; i++) { + final modelPtr = modelsArray[i]; + if (modelPtr != nullptr) { + models.add(_structToModelInfo(modelPtr)); + } + } + + return models; + } finally { + calloc.free(outModelsPtr); + calloc.free(outCountPtr); + } + } catch (e) { + _logger.debug('rac_model_assignment_get_by_category error: $e'); + return []; + } + } + + // ============================================================================ + // Helpers + // ============================================================================ + + ModelInfo _structToModelInfo(Pointer struct) { + return ModelInfo( + id: struct.ref.id.toDartString(), + name: struct.ref.name.toDartString(), + category: struct.ref.category, + format: struct.ref.format, + framework: struct.ref.framework, + source: struct.ref.source, + sizeBytes: struct.ref.sizeBytes, + downloadURL: struct.ref.downloadURL != nullptr + ? struct.ref.downloadURL.toDartString() + : null, + localPath: struct.ref.localPath != nullptr + ? struct.ref.localPath.toDartString() + : null, + version: struct.ref.version != nullptr + ? struct.ref.version.toDartString() + : null, + ); + } +} + +// ============================================================================= +// HTTP Callback +// ============================================================================= + +int _httpGetCallback( + Pointer endpoint, + int requiresAuth, + Pointer outResponse, + Pointer userData, +) { + if (endpoint == nullptr || outResponse == nullptr) { + return RacResultCode.errorInvalidParameter; + } + + try { + final endpointStr = endpoint.toDartString(); + + // Schedule async HTTP call + _performHttpGet(endpointStr, requiresAuth != 0, outResponse); + + return RacResultCode.success; + } catch (e) { + return RacResultCode.errorNetworkError; + } +} + +/// Perform HTTP GET (simplified) +void _performHttpGet( + String endpoint, + bool requiresAuth, + Pointer outResponse, +) { + final baseURL = + DartBridgeModelAssignment._baseURL ?? 'https://api.runanywhere.ai'; + final url = Uri.parse('$baseURL$endpoint'); + + final headers = { + 'Accept': 'application/json', + }; + + if (requiresAuth && DartBridgeModelAssignment._accessToken != null) { + headers['Authorization'] = + 'Bearer ${DartBridgeModelAssignment._accessToken}'; + } + + unawaited(Future.microtask(() async { + try { + final response = await http.get(url, headers: headers); + + outResponse.ref.result = + response.statusCode >= 200 && response.statusCode < 300 + ? RacResultCode.success + : RacResultCode.errorNetworkError; + outResponse.ref.statusCode = response.statusCode; + + if (response.body.isNotEmpty) { + final bodyPtr = response.body.toNativeUtf8(); + outResponse.ref.responseBody = bodyPtr; + outResponse.ref.responseLength = response.body.length; + } + } catch (e) { + outResponse.ref.result = RacResultCode.errorNetworkError; + outResponse.ref.statusCode = 0; + final errorPtr = e.toString().toNativeUtf8(); + outResponse.ref.errorMessage = errorPtr; + } + })); + + // Return immediately with pending state + outResponse.ref.result = RacResultCode.success; + outResponse.ref.statusCode = 200; +} + +// ============================================================================= +// FFI Types +// ============================================================================= + +/// HTTP GET callback +typedef RacAssignmentHttpGetCallbackNative = Int32 Function(Pointer, + Int32, Pointer, Pointer); + +/// Callbacks struct +base class RacAssignmentCallbacksStruct extends Struct { + external Pointer> httpGet; + external Pointer userData; + @Int32() + external int autoFetch; // If non-zero, auto-fetch models after registration +} + +/// HTTP response struct +base class RacAssignmentHttpResponseStruct extends Struct { + @Int32() + external int result; + + @Int32() + external int statusCode; + + external Pointer responseBody; + + @IntPtr() + external int responseLength; + + external Pointer errorMessage; +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_model_paths.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_model_paths.dart new file mode 100644 index 000000000..5bba19572 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_model_paths.dart @@ -0,0 +1,328 @@ +import 'dart:ffi'; +import 'dart:io'; + +import 'package:ffi/ffi.dart'; +import 'package:path_provider/path_provider.dart'; + +import 'package:runanywhere/core/types/model_types.dart'; +import 'package:runanywhere/foundation/logging/sdk_logger.dart'; +import 'package:runanywhere/native/ffi_types.dart'; +import 'package:runanywhere/native/platform_loader.dart'; + +/// Model path utilities bridge. +/// Wraps C++ rac_model_paths.h functions. +/// Matches Swift's CppBridge.ModelPaths exactly. +class DartBridgeModelPaths { + DartBridgeModelPaths._(); + + static final _logger = SDKLogger('DartBridge.ModelPaths'); + static final DartBridgeModelPaths instance = DartBridgeModelPaths._(); + static const _pathBufferSize = 1024; + + // MARK: - Configuration + + /// Set the base directory for model storage. + /// Must be called during SDK initialization. + /// Matches Swift: CppBridge.ModelPaths.setBaseDirectory() + Future setBaseDirectory([String? path]) async { + final dir = path ?? (await getApplicationDocumentsDirectory()).path; + + try { + final lib = PlatformLoader.loadCommons(); + final setBase = lib.lookupFunction), + int Function(Pointer)>('rac_model_paths_set_base_dir'); + + final dirPtr = dir.toNativeUtf8(); + try { + final result = setBase(dirPtr); + if (result == RacResultCode.success) { + _logger.debug('C++ base directory set to: $dir'); + } else { + _logger.warning('Failed to set C++ base directory: $result'); + } + } finally { + calloc.free(dirPtr); + } + } catch (e) { + _logger.warning('rac_model_paths_set_base_dir error: $e'); + } + } + + // MARK: - Directory Paths (C++ wrappers) + + /// Get the models directory from C++. + /// Returns: `{base_dir}/RunAnywhere/Models/` + /// Matches Swift: CppBridge.ModelPaths.getModelsDirectory() + String? getModelsDirectory() { + try { + final lib = PlatformLoader.loadCommons(); + final getDir = lib.lookupFunction< + Int32 Function(Pointer, IntPtr), + int Function( + Pointer, int)>('rac_model_paths_get_models_directory'); + + final buffer = calloc(_pathBufferSize).cast(); + try { + final result = getDir(buffer, _pathBufferSize); + if (result == RacResultCode.success) { + return buffer.toDartString(); + } + } finally { + calloc.free(buffer); + } + } catch (e) { + _logger.debug('rac_model_paths_get_models_directory error: $e'); + } + return null; + } + + /// Get framework directory from C++. + /// Returns: `{base_dir}/RunAnywhere/Models/{framework}/` + /// Matches Swift: CppBridge.ModelPaths.getFrameworkDirectory() + String? getFrameworkDirectory(InferenceFramework framework) { + try { + final lib = PlatformLoader.loadCommons(); + final getDir = lib.lookupFunction< + Int32 Function(Int32, Pointer, IntPtr), + int Function(int, Pointer, + int)>('rac_model_paths_get_framework_directory'); + + final buffer = calloc(_pathBufferSize).cast(); + try { + final result = + getDir(_frameworkToCValue(framework), buffer, _pathBufferSize); + if (result == RacResultCode.success) { + return buffer.toDartString(); + } + } finally { + calloc.free(buffer); + } + } catch (e) { + _logger.debug('rac_model_paths_get_framework_directory error: $e'); + } + return null; + } + + /// Get model folder from C++. + /// Returns: `{base_dir}/RunAnywhere/Models/{framework}/{modelId}/` + /// Matches Swift: CppBridge.ModelPaths.getModelFolder() + String? getModelFolder(String modelId, InferenceFramework framework) { + try { + final lib = PlatformLoader.loadCommons(); + final getFolder = lib.lookupFunction< + Int32 Function(Pointer, Int32, Pointer, IntPtr), + int Function(Pointer, int, Pointer, + int)>('rac_model_paths_get_model_folder'); + + final modelIdPtr = modelId.toNativeUtf8(); + final buffer = calloc(_pathBufferSize).cast(); + try { + final result = getFolder( + modelIdPtr, _frameworkToCValue(framework), buffer, _pathBufferSize); + if (result == RacResultCode.success) { + return buffer.toDartString(); + } + } finally { + calloc.free(modelIdPtr); + calloc.free(buffer); + } + } catch (e) { + _logger.debug('rac_model_paths_get_model_folder error: $e'); + } + return null; + } + + // MARK: - Helper: Get model folder and create if needed + // Matches Swift: SimplifiedFileManager.getModelFolder() + + /// Get model folder, creating it if it doesn't exist. + /// This is the main method for download service to use. + Future getModelFolderAndCreate( + String modelId, InferenceFramework framework) async { + // Get path from C++ + final path = getModelFolder(modelId, framework); + if (path != null) { + _ensureDirectoryExists(path); + return path; + } + + // C++ not configured - throw error (SDK not initialized) + throw StateError( + 'Model paths not configured. Call RunAnywhere.initialize() first.'); + } + + /// Ensure a directory exists, creating it if needed. + void _ensureDirectoryExists(String path) { + final dir = Directory(path); + if (!dir.existsSync()) { + dir.createSync(recursive: true); + } + } + + // MARK: - Model File Resolution + // Matches Swift: resolveModelFilePath(for:) + + /// Resolve the actual model file path for loading. + /// For single-file models (LlamaCpp), finds the actual .gguf file. + /// For directory-based models (ONNX), returns the folder. + Future resolveModelFilePath(ModelInfo model) async { + final modelFolder = getModelFolder(model.id, model.framework); + if (modelFolder == null) return null; + + // For ONNX models (directory-based), find the model directory + if (model.framework == InferenceFramework.onnx) { + return _resolveONNXModelPath(modelFolder, model.id); + } + + // For single-file models (LlamaCpp), find the actual file + return _resolveSingleFileModelPath(modelFolder, model); + } + + /// Resolve ONNX model directory path + String _resolveONNXModelPath(String modelFolder, String modelId) { + // Check if there's a nested folder with the model name + final nestedFolder = '$modelFolder/$modelId'; + if (Directory(nestedFolder).existsSync()) { + if (_hasONNXModelFiles(nestedFolder)) { + _logger.info('Found ONNX model at nested path: $nestedFolder'); + return nestedFolder; + } + } + + // Check if model files exist directly in the folder + if (_hasONNXModelFiles(modelFolder)) { + _logger.info('Found ONNX model at folder: $modelFolder'); + return modelFolder; + } + + // Scan for any subdirectory with model files + final dir = Directory(modelFolder); + if (dir.existsSync()) { + for (final entity in dir.listSync()) { + if (entity is Directory && _hasONNXModelFiles(entity.path)) { + _logger.info('Found ONNX model in subdirectory: ${entity.path}'); + return entity.path; + } + } + } + + // Fallback + _logger.warning('No ONNX model files found, using: $modelFolder'); + return modelFolder; + } + + /// Check if directory contains ONNX model files + bool _hasONNXModelFiles(String directory) { + final dir = Directory(directory); + if (!dir.existsSync()) return false; + + try { + return dir.listSync().any((entity) { + if (entity is! File) return false; + final name = entity.path.toLowerCase(); + return name.endsWith('.onnx') || + name.endsWith('.ort') || + name.contains('encoder') || + name.contains('decoder') || + name.contains('tokens'); + }); + } catch (e) { + return false; + } + } + + /// Resolve single-file model path (LlamaCpp .gguf files) + String? _resolveSingleFileModelPath(String modelFolder, ModelInfo model) { + final dir = Directory(modelFolder); + if (!dir.existsSync()) { + _logger.warning('Model folder does not exist: $modelFolder'); + return null; + } + + // Find the model file + try { + for (final entity in dir.listSync()) { + if (entity is File) { + final name = entity.path.toLowerCase(); + if (name.endsWith('.gguf') || name.endsWith('.bin')) { + _logger.info('Found model file: ${entity.path}'); + return entity.path; + } + } + } + } catch (e) { + _logger.warning('Error scanning model folder: $e'); + } + + _logger.warning('No model file found in: $modelFolder'); + return null; + } + + // MARK: - Path Analysis + + /// Extract model ID from a file path + String? extractModelId(String path) { + try { + final lib = PlatformLoader.loadCommons(); + final extractFn = lib.lookupFunction< + Int32 Function(Pointer, Pointer, IntPtr), + int Function(Pointer, Pointer, + int)>('rac_model_paths_extract_model_id'); + + final pathPtr = path.toNativeUtf8(); + final buffer = calloc(256).cast(); + try { + final result = extractFn(pathPtr, buffer, 256); + if (result == RacResultCode.success) { + return buffer.toDartString(); + } + } finally { + calloc.free(pathPtr); + calloc.free(buffer); + } + } catch (e) { + _logger.debug('rac_model_paths_extract_model_id error: $e'); + } + return null; + } + + /// Check if a path is within the models directory + bool isModelPath(String path) { + try { + final lib = PlatformLoader.loadCommons(); + final checkFn = lib.lookupFunction), + int Function(Pointer)>('rac_model_paths_is_model_path'); + + final pathPtr = path.toNativeUtf8(); + try { + return checkFn(pathPtr) == 1; // RAC_TRUE + } finally { + calloc.free(pathPtr); + } + } catch (e) { + return false; + } + } +} + +/// Convert InferenceFramework to C++ RAC_FRAMEWORK int +int _frameworkToCValue(InferenceFramework framework) { + switch (framework) { + case InferenceFramework.onnx: + return 0; // RAC_FRAMEWORK_ONNX + case InferenceFramework.llamaCpp: + return 1; // RAC_FRAMEWORK_LLAMACPP + case InferenceFramework.foundationModels: + return 2; // RAC_FRAMEWORK_FOUNDATION_MODELS + case InferenceFramework.systemTTS: + return 3; // RAC_FRAMEWORK_SYSTEM_TTS + case InferenceFramework.fluidAudio: + return 4; // RAC_FRAMEWORK_FLUID_AUDIO + case InferenceFramework.builtIn: + return 5; // RAC_FRAMEWORK_BUILTIN + case InferenceFramework.none: + return 6; // RAC_FRAMEWORK_NONE + case InferenceFramework.unknown: + return 99; + } +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_model_registry.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_model_registry.dart new file mode 100644 index 000000000..48a84202e --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_model_registry.dart @@ -0,0 +1,1139 @@ +// ignore_for_file: avoid_classes_with_only_static_members + +import 'dart:async'; +import 'dart:ffi'; +import 'dart:io'; + +import 'package:ffi/ffi.dart'; + +import 'package:runanywhere/core/types/model_types.dart' as public_types; +import 'package:runanywhere/foundation/logging/sdk_logger.dart'; +import 'package:runanywhere/native/ffi_types.dart'; +import 'package:runanywhere/native/platform_loader.dart'; + +// ============================================================================= +// Exception Return Constants +// ============================================================================= + +const int _exceptionalReturnInt32 = -1; +const int _exceptionalReturnFalse = 0; + +// ============================================================================= +// Model Registry Bridge +// ============================================================================= + +/// Model registry bridge for C++ model registry operations. +/// Matches Swift's `CppBridge+ModelRegistry.swift`. +/// +/// Provides: +/// - Model metadata storage (save, get, remove) +/// - Model queries (by framework, downloaded only) +/// - Model discovery (scan filesystem for models) +class DartBridgeModelRegistry { + DartBridgeModelRegistry._(); + + static final _logger = SDKLogger('DartBridge.ModelRegistry'); + static final DartBridgeModelRegistry instance = DartBridgeModelRegistry._(); + + /// Registry handle + static Pointer? _registryHandle; + static bool _isInitialized = false; + + /// Discovery callbacks pointer + static Pointer? _discoveryCallbacksPtr; + + // ============================================================================ + // Lifecycle + // ============================================================================ + + /// Initialize the model registry + /// + /// IMPORTANT: Uses the GLOBAL C++ model registry via rac_get_model_registry(), + /// NOT rac_model_registry_create() which would create a separate instance. + /// This matches Swift's CppBridge+ModelRegistry.swift behavior. + Future initialize() async { + if (_isInitialized) return; + + try { + final lib = PlatformLoader.loadCommons(); + + // Use the GLOBAL C++ model registry - same as Swift does + // This is critical: C++ code (rac_get_model, rac_llm_component_load_model) + // looks up models in the GLOBAL registry, not a separate instance + final getGlobalRegistryFn = lib.lookupFunction Function(), + Pointer Function()>('rac_get_model_registry'); + + final globalRegistry = getGlobalRegistryFn(); + + if (globalRegistry != nullptr) { + _registryHandle = globalRegistry; + _isInitialized = true; + _logger.debug('Using global C++ model registry'); + } else { + _logger.error('Failed to get global model registry'); + } + } catch (e) { + _logger.debug('Model registry init error: $e'); + _isInitialized = true; // Avoid retry loops + } + } + + /// Shutdown the model registry bridge + /// + /// NOTE: Does NOT destroy the global registry since it's a C++ singleton. + /// We just release our reference to it. + void shutdown() { + // Don't destroy the global registry - it's managed by C++ + // The handle is just a reference to the singleton + _registryHandle = null; + _isInitialized = false; + _logger.debug('Model registry bridge shutdown (global registry preserved)'); + } + + // ============================================================================ + // Model CRUD Operations + // ============================================================================ + + /// Save model info to registry using C allocation for safety. + /// + /// Uses rac_model_info_alloc() to allocate a properly sized struct in C++, + /// then fills in the fields using strdup for strings (allocated by C). + /// This avoids struct layout mismatches and memory allocation issues. + /// + /// Pattern matches Kotlin JNI: allocate in C++, fill fields, call save. + Future saveModel(ModelInfo model) async { + if (_registryHandle == null) return false; + + try { + final lib = PlatformLoader.loadCommons(); + + // Allocate struct in C++ with correct size (zeroed by calloc) + final allocFn = lib.lookupFunction< + Pointer Function(), + Pointer Function()>('rac_model_info_alloc'); + + // Use C's free function for the struct (rac_model_info_free frees strings + // but we're using rac_strdup which uses C's malloc) + final freeFn = lib.lookupFunction< + Void Function(Pointer), + void Function(Pointer)>('rac_model_info_free'); + + // Use C's strdup to allocate strings - this matches what Kotlin JNI does + final strdupFn = lib.lookupFunction Function(Pointer), + Pointer Function(Pointer)>('rac_strdup'); + + final saveFn = lib.lookupFunction< + Int32 Function(Pointer, Pointer), + int Function(Pointer, + Pointer)>('rac_model_registry_save'); + + final modelPtr = allocFn(); + if (modelPtr == nullptr) { + _logger.debug('rac_model_info_alloc returned null'); + return false; + } + + // Temporary Dart strings for conversion + final idDart = model.id.toNativeUtf8(); + final nameDart = model.name.toNativeUtf8(); + final urlDart = model.downloadURL?.toNativeUtf8(); + final pathDart = model.localPath?.toNativeUtf8(); + + try { + // Use strdup to allocate strings in C heap (matches Kotlin JNI pattern) + // This is critical - C's rac_model_info_free will call free() on these + modelPtr.ref.id = strdupFn(idDart); + modelPtr.ref.name = strdupFn(nameDart); + modelPtr.ref.category = model.category; + modelPtr.ref.format = model.format; + modelPtr.ref.framework = model.framework; + modelPtr.ref.downloadUrl = + urlDart != null ? strdupFn(urlDart) : nullptr; + modelPtr.ref.localPath = + pathDart != null ? strdupFn(pathDart) : nullptr; + modelPtr.ref.downloadSize = model.sizeBytes; + modelPtr.ref.source = model.source; + + final result = saveFn(_registryHandle!, modelPtr); + if (result != RacResultCode.success) { + _logger.error('Failed to save model ${model.id}: result=$result'); + } + return result == RacResultCode.success; + } finally { + // Free Dart-allocated temporary strings + calloc.free(idDart); + calloc.free(nameDart); + if (urlDart != null) calloc.free(urlDart); + if (pathDart != null) calloc.free(pathDart); + + // Free C-allocated struct and its strings + freeFn(modelPtr); + } + } catch (e) { + _logger.debug('rac_model_registry_save error: $e'); + return false; + } + } + + /// Save a public ModelInfo to the C++ registry. + /// + /// Converts the public ModelInfo (from model_types.dart) to the FFI format + /// and saves it to the C++ registry for model discovery and loading. + /// + /// Matches Swift: `CppBridge.ModelRegistry.shared.save(modelInfo)` + Future savePublicModel(public_types.ModelInfo model) async { + if (_registryHandle == null) { + _logger.debug('Registry not initialized, cannot save model'); + return false; + } + + try { + // Convert public ModelInfo to FFI ModelInfo + final ffiModel = ModelInfo( + id: model.id, + name: model.name, + category: _categoryToFfi(model.category), + format: _formatToFfi(model.format), + framework: _frameworkToFfi(model.framework), + source: _sourceToFfi(model.source), + sizeBytes: model.downloadSize ?? 0, + downloadURL: model.downloadURL?.toString(), + localPath: model.localPath?.toFilePath(), + version: null, + ); + + final result = await saveModel(ffiModel); + if (result) { + _logger.debug('Saved public model to C++ registry: ${model.id}'); + } + return result; + } catch (e) { + _logger.debug('savePublicModel error: $e'); + return false; + } + } + + // =========================================================================== + // FFI Type Conversion Helpers + // =========================================================================== + + /// Convert public ModelCategory to C++ RAC_MODEL_CATEGORY int + static int _categoryToFfi(public_types.ModelCategory category) { + switch (category) { + case public_types.ModelCategory.language: + return 0; // RAC_MODEL_CATEGORY_LANGUAGE + case public_types.ModelCategory.speechRecognition: + return 1; // RAC_MODEL_CATEGORY_SPEECH_RECOGNITION + case public_types.ModelCategory.speechSynthesis: + return 2; // RAC_MODEL_CATEGORY_SPEECH_SYNTHESIS + case public_types.ModelCategory.vision: + return 3; // RAC_MODEL_CATEGORY_VISION + case public_types.ModelCategory.imageGeneration: + return 4; // RAC_MODEL_CATEGORY_IMAGE_GENERATION + case public_types.ModelCategory.multimodal: + return 5; // RAC_MODEL_CATEGORY_MULTIMODAL + case public_types.ModelCategory.audio: + return 6; // RAC_MODEL_CATEGORY_AUDIO + } + } + + /// Convert public ModelFormat to C++ RAC_MODEL_FORMAT int + static int _formatToFfi(public_types.ModelFormat format) { + switch (format) { + case public_types.ModelFormat.onnx: + return 0; // RAC_MODEL_FORMAT_ONNX + case public_types.ModelFormat.ort: + return 1; // RAC_MODEL_FORMAT_ORT + case public_types.ModelFormat.gguf: + return 2; // RAC_MODEL_FORMAT_GGUF + case public_types.ModelFormat.bin: + return 3; // RAC_MODEL_FORMAT_BIN + case public_types.ModelFormat.unknown: + return 99; // RAC_MODEL_FORMAT_UNKNOWN + } + } + + /// Convert public InferenceFramework to C++ RAC_FRAMEWORK int + static int _frameworkToFfi(public_types.InferenceFramework framework) { + switch (framework) { + case public_types.InferenceFramework.onnx: + return 0; // RAC_FRAMEWORK_ONNX + case public_types.InferenceFramework.llamaCpp: + return 1; // RAC_FRAMEWORK_LLAMACPP + case public_types.InferenceFramework.foundationModels: + return 2; // RAC_FRAMEWORK_FOUNDATION_MODELS + case public_types.InferenceFramework.systemTTS: + return 3; // RAC_FRAMEWORK_SYSTEM_TTS + case public_types.InferenceFramework.fluidAudio: + return 4; // RAC_FRAMEWORK_FLUID_AUDIO + case public_types.InferenceFramework.builtIn: + return 5; // RAC_FRAMEWORK_BUILTIN + case public_types.InferenceFramework.none: + return 6; // RAC_FRAMEWORK_NONE + case public_types.InferenceFramework.unknown: + return 99; // RAC_FRAMEWORK_UNKNOWN + } + } + + /// Convert public ModelSource to C++ RAC_MODEL_SOURCE int + static int _sourceToFfi(public_types.ModelSource source) { + switch (source) { + case public_types.ModelSource.remote: + return 1; // RAC_MODEL_SOURCE_REMOTE + case public_types.ModelSource.local: + return 2; // RAC_MODEL_SOURCE_LOCAL + } + } + + /// Get the FFI framework value (for external use) + static int getFrameworkFfiValue(public_types.InferenceFramework framework) { + return _frameworkToFfi(framework); + } + + // =========================================================================== + // Reverse FFI Type Conversion (C++ → Dart public types) + // =========================================================================== + + /// Convert C++ RAC_MODEL_CATEGORY int to public ModelCategory + static public_types.ModelCategory _categoryFromFfi(int category) { + switch (category) { + case 0: + return public_types.ModelCategory.language; + case 1: + return public_types.ModelCategory.speechRecognition; + case 2: + return public_types.ModelCategory.speechSynthesis; + case 3: + return public_types.ModelCategory.vision; + case 4: + return public_types.ModelCategory.imageGeneration; + case 5: + return public_types.ModelCategory.multimodal; + case 6: + return public_types.ModelCategory.audio; + default: + return public_types.ModelCategory.language; + } + } + + /// Convert C++ RAC_MODEL_FORMAT int to public ModelFormat + static public_types.ModelFormat _formatFromFfi(int format) { + switch (format) { + case 0: + return public_types.ModelFormat.onnx; + case 1: + return public_types.ModelFormat.ort; + case 2: + return public_types.ModelFormat.gguf; + case 3: + return public_types.ModelFormat.bin; + default: + return public_types.ModelFormat.unknown; + } + } + + /// Convert C++ RAC_FRAMEWORK int to public InferenceFramework + static public_types.InferenceFramework _frameworkFromFfi(int framework) { + switch (framework) { + case 0: + return public_types.InferenceFramework.onnx; + case 1: + return public_types.InferenceFramework.llamaCpp; + case 2: + return public_types.InferenceFramework.foundationModels; + case 3: + return public_types.InferenceFramework.systemTTS; + case 4: + return public_types.InferenceFramework.fluidAudio; + case 5: + return public_types.InferenceFramework.builtIn; + case 6: + return public_types.InferenceFramework.none; + default: + return public_types.InferenceFramework.unknown; + } + } + + /// Convert C++ RAC_MODEL_SOURCE int to public ModelSource + static public_types.ModelSource _sourceFromFfi(int source) { + switch (source) { + case 1: + return public_types.ModelSource.remote; + case 2: + return public_types.ModelSource.local; + default: + return public_types.ModelSource.remote; + } + } + + /// Convert FFI ModelInfo to public ModelInfo + static public_types.ModelInfo _ffiModelToPublic(ModelInfo ffiModel) { + return public_types.ModelInfo( + id: ffiModel.id, + name: ffiModel.name, + category: _categoryFromFfi(ffiModel.category), + format: _formatFromFfi(ffiModel.format), + framework: _frameworkFromFfi(ffiModel.framework), + downloadURL: ffiModel.downloadURL != null + ? Uri.tryParse(ffiModel.downloadURL!) + : null, + localPath: ffiModel.localPath != null && ffiModel.localPath!.isNotEmpty + ? Uri.file(ffiModel.localPath!) + : null, + downloadSize: ffiModel.sizeBytes > 0 ? ffiModel.sizeBytes : null, + source: _sourceFromFfi(ffiModel.source), + ); + } + + // =========================================================================== + // Public Model Query Methods (returns public_types.ModelInfo) + // =========================================================================== + + /// Get all models from C++ registry as public ModelInfo objects. + /// + /// Matches Swift: `CppBridge.ModelRegistry.shared.getAll()` + Future> getAllPublicModels() async { + final ffiModels = await getAllModels(); + return ffiModels.map(_ffiModelToPublic).toList(); + } + + /// Get a single model from C++ registry as public ModelInfo. + Future getPublicModel(String modelId) async { + final ffiModel = await getModel(modelId); + if (ffiModel == null) return null; + return _ffiModelToPublic(ffiModel); + } + + /// Get model by ID + Future getModel(String modelId) async { + if (_registryHandle == null) return null; + + try { + final lib = PlatformLoader.loadCommons(); + final getFn = lib.lookupFunction< + Int32 Function(Pointer, Pointer, + Pointer>), + int Function(Pointer, Pointer, + Pointer>)>('rac_model_registry_get'); + + final modelIdPtr = modelId.toNativeUtf8(); + final outModelPtr = calloc>(); + + try { + final result = getFn(_registryHandle!, modelIdPtr, outModelPtr); + if (result == RacResultCode.success && outModelPtr.value != nullptr) { + final model = _cStructToModelInfo(outModelPtr.value); + + // Free the model struct + final freeFn = lib.lookupFunction< + Void Function(Pointer), + void Function( + Pointer)>('rac_model_info_free'); + freeFn(outModelPtr.value); + + return model; + } + return null; + } finally { + calloc.free(modelIdPtr); + calloc.free(outModelPtr); + } + } catch (e) { + _logger.debug('rac_model_registry_get error: $e'); + return null; + } + } + + /// Get all models + Future> getAllModels() async { + if (_registryHandle == null) return []; + + try { + final lib = PlatformLoader.loadCommons(); + final getAllFn = lib.lookupFunction< + Int32 Function(Pointer, + Pointer>>, Pointer), + int Function( + Pointer, + Pointer>>, + Pointer)>('rac_model_registry_get_all'); + + final outModelsPtr = calloc>>(); + final outCountPtr = calloc(); + + try { + final result = getAllFn(_registryHandle!, outModelsPtr, outCountPtr); + if (result != RacResultCode.success) return []; + + final count = outCountPtr.value; + if (count == 0) return []; + + final models = []; + final modelsArray = outModelsPtr.value; + + for (var i = 0; i < count; i++) { + final modelPtr = modelsArray[i]; + if (modelPtr != nullptr) { + models.add(_cStructToModelInfo(modelPtr)); + } + } + + // Free the array + final freeFn = lib.lookupFunction< + Void Function(Pointer>, IntPtr), + void Function(Pointer>, + int)>('rac_model_info_array_free'); + freeFn(modelsArray, count); + + return models; + } finally { + calloc.free(outModelsPtr); + calloc.free(outCountPtr); + } + } catch (e) { + _logger.debug('rac_model_registry_get_all error: $e'); + return []; + } + } + + /// Get downloaded models only + Future> getDownloadedModels() async { + if (_registryHandle == null) return []; + + try { + final lib = PlatformLoader.loadCommons(); + final getDownloadedFn = lib.lookupFunction< + Int32 Function(Pointer, + Pointer>>, Pointer), + int Function( + Pointer, + Pointer>>, + Pointer)>('rac_model_registry_get_downloaded'); + + final outModelsPtr = calloc>>(); + final outCountPtr = calloc(); + + try { + final result = + getDownloadedFn(_registryHandle!, outModelsPtr, outCountPtr); + if (result != RacResultCode.success) return []; + + final count = outCountPtr.value; + if (count == 0) return []; + + final models = []; + final modelsArray = outModelsPtr.value; + + for (var i = 0; i < count; i++) { + final modelPtr = modelsArray[i]; + if (modelPtr != nullptr) { + models.add(_cStructToModelInfo(modelPtr)); + } + } + + // Free the array + final freeFn = lib.lookupFunction< + Void Function(Pointer>, IntPtr), + void Function(Pointer>, + int)>('rac_model_info_array_free'); + freeFn(modelsArray, count); + + return models; + } finally { + calloc.free(outModelsPtr); + calloc.free(outCountPtr); + } + } catch (e) { + _logger.debug('rac_model_registry_get_downloaded error: $e'); + return []; + } + } + + /// Get models by frameworks + Future> getModelsByFrameworks(List frameworks) async { + if (_registryHandle == null || frameworks.isEmpty) return []; + + try { + final lib = PlatformLoader.loadCommons(); + final getByFrameworksFn = lib.lookupFunction< + Int32 Function(Pointer, Pointer, IntPtr, + Pointer>>, Pointer), + int Function( + Pointer, + Pointer, + int, + Pointer>>, + Pointer)>('rac_model_registry_get_by_frameworks'); + + final frameworksPtr = calloc(frameworks.length); + for (var i = 0; i < frameworks.length; i++) { + frameworksPtr[i] = frameworks[i]; + } + + final outModelsPtr = calloc>>(); + final outCountPtr = calloc(); + + try { + final result = getByFrameworksFn(_registryHandle!, frameworksPtr, + frameworks.length, outModelsPtr, outCountPtr); + + if (result != RacResultCode.success) return []; + + final count = outCountPtr.value; + if (count == 0) return []; + + final models = []; + final modelsArray = outModelsPtr.value; + + for (var i = 0; i < count; i++) { + final modelPtr = modelsArray[i]; + if (modelPtr != nullptr) { + models.add(_cStructToModelInfo(modelPtr)); + } + } + + return models; + } finally { + calloc.free(frameworksPtr); + calloc.free(outModelsPtr); + calloc.free(outCountPtr); + } + } catch (e) { + _logger.debug('rac_model_registry_get_by_frameworks error: $e'); + return []; + } + } + + /// Update download status for a model + Future updateDownloadStatus(String modelId, String? localPath) async { + if (_registryHandle == null) { + _logger.error('updateDownloadStatus: registry handle is null!'); + return false; + } + + try { + final lib = PlatformLoader.loadCommons(); + final updateFn = lib.lookupFunction< + Int32 Function(Pointer, Pointer, Pointer), + int Function(Pointer, Pointer, + Pointer)>('rac_model_registry_update_download_status'); + + final modelIdPtr = modelId.toNativeUtf8(); + final localPathPtr = localPath?.toNativeUtf8() ?? nullptr; + + try { + final result = + updateFn(_registryHandle!, modelIdPtr, localPathPtr.cast()); + if (result != RacResultCode.success) { + _logger.warning( + 'updateDownloadStatus failed for $modelId: result=$result'); + } + return result == RacResultCode.success; + } finally { + calloc.free(modelIdPtr); + if (localPathPtr != nullptr) calloc.free(localPathPtr); + } + } catch (e) { + _logger.debug('rac_model_registry_update_download_status error: $e'); + return false; + } + } + + /// Remove a model from registry + Future removeModel(String modelId) async { + if (_registryHandle == null) return false; + + try { + final lib = PlatformLoader.loadCommons(); + final removeFn = lib.lookupFunction< + Int32 Function(Pointer, Pointer), + int Function( + Pointer, Pointer)>('rac_model_registry_remove'); + + final modelIdPtr = modelId.toNativeUtf8(); + try { + final result = removeFn(_registryHandle!, modelIdPtr); + return result == RacResultCode.success; + } finally { + calloc.free(modelIdPtr); + } + } catch (e) { + _logger.debug('rac_model_registry_remove error: $e'); + return false; + } + } + + /// Update last used timestamp + Future updateLastUsed(String modelId) async { + if (_registryHandle == null) return false; + + try { + final lib = PlatformLoader.loadCommons(); + final updateFn = lib.lookupFunction< + Int32 Function(Pointer, Pointer), + int Function(Pointer, + Pointer)>('rac_model_registry_update_last_used'); + + final modelIdPtr = modelId.toNativeUtf8(); + try { + final result = updateFn(_registryHandle!, modelIdPtr); + return result == RacResultCode.success; + } finally { + calloc.free(modelIdPtr); + } + } catch (e) { + _logger.debug('rac_model_registry_update_last_used error: $e'); + return false; + } + } + + // ============================================================================ + // Model Discovery + // ============================================================================ + + /// Discover downloaded models by scanning filesystem + Future discoverDownloadedModels() async { + if (_registryHandle == null) { + return const DiscoveryResult(discoveredModels: [], unregisteredCount: 0); + } + + try { + final lib = PlatformLoader.loadCommons(); + final discoverFn = + lib.lookupFunction< + Int32 Function( + Pointer, + Pointer, + Pointer), + int Function( + Pointer, + Pointer, + Pointer)>( + 'rac_model_registry_discover_downloaded'); + + // Set up callbacks + _discoveryCallbacksPtr = calloc(); + _discoveryCallbacksPtr!.ref.listDirectory = + Pointer.fromFunction( + _listDirectoryCallback, _exceptionalReturnInt32); + _discoveryCallbacksPtr!.ref.freeEntries = + Pointer.fromFunction( + _freeEntriesCallback); + _discoveryCallbacksPtr!.ref.isDirectory = + Pointer.fromFunction( + _isDirectoryCallback, _exceptionalReturnFalse); + _discoveryCallbacksPtr!.ref.pathExists = + Pointer.fromFunction( + _pathExistsCallback, _exceptionalReturnFalse); + _discoveryCallbacksPtr!.ref.isModelFile = + Pointer.fromFunction( + _isModelFileCallback, _exceptionalReturnFalse); + _discoveryCallbacksPtr!.ref.userData = nullptr; + + final resultStruct = calloc(); + + try { + final result = + discoverFn(_registryHandle!, _discoveryCallbacksPtr!, resultStruct); + + if (result != RacResultCode.success) { + return const DiscoveryResult( + discoveredModels: [], unregisteredCount: 0); + } + + // Parse result + final discoveredModels = []; + final discoveredCount = resultStruct.ref.discoveredCount; + + for (var i = 0; i < discoveredCount; i++) { + final modelPtr = resultStruct.ref.discoveredModels + i; + discoveredModels.add(DiscoveredModel( + modelId: modelPtr.ref.modelId.toDartString(), + localPath: modelPtr.ref.localPath.toDartString(), + framework: modelPtr.ref.framework, + )); + } + + final unregisteredCount = resultStruct.ref.unregisteredCount; + + // Free result + final freeResultFn = lib.lookupFunction< + Void Function(Pointer), + void Function(Pointer)>( + 'rac_discovery_result_free'); + freeResultFn(resultStruct); + + return DiscoveryResult( + discoveredModels: discoveredModels, + unregisteredCount: unregisteredCount, + ); + } finally { + calloc.free(_discoveryCallbacksPtr!); + _discoveryCallbacksPtr = null; + calloc.free(resultStruct); + } + } catch (e) { + _logger.debug('rac_model_registry_discover_downloaded error: $e'); + return const DiscoveryResult(discoveredModels: [], unregisteredCount: 0); + } + } + + // ============================================================================ + // Struct Conversion Helpers + // ============================================================================ + + /// Convert C struct to Dart ModelInfo using correct struct layout. + /// Uses RacModelInfoCStruct which matches the actual C rac_model_info_t. + ModelInfo _cStructToModelInfo(Pointer struct) { + return ModelInfo( + id: struct.ref.id.toDartString(), + name: struct.ref.name.toDartString(), + category: struct.ref.category, + format: struct.ref.format, + framework: struct.ref.framework, + source: struct.ref.source, + sizeBytes: struct.ref.downloadSize, + downloadURL: struct.ref.downloadUrl != nullptr + ? struct.ref.downloadUrl.toDartString() + : null, + localPath: struct.ref.localPath != nullptr + ? struct.ref.localPath.toDartString() + : null, + version: null, + ); + } +} + +// ============================================================================= +// Discovery Callbacks +// ============================================================================= + +int _listDirectoryCallback( + Pointer path, + Pointer>> outEntries, + Pointer outCount, + Pointer userData) { + try { + final pathStr = path.toDartString(); + final dir = Directory(pathStr); + + if (!dir.existsSync()) { + outCount.value = 0; + return RacResultCode.success; + } + + final entries = dir.listSync().map((e) => e.path.split('/').last).toList(); + outCount.value = entries.length; + + if (entries.isEmpty) return RacResultCode.success; + + // Allocate array of string pointers + final entriesPtr = calloc>(entries.length); + for (var i = 0; i < entries.length; i++) { + entriesPtr[i] = entries[i].toNativeUtf8(); + } + outEntries.value = entriesPtr; + + return RacResultCode.success; + } catch (e) { + return RacResultCode.errorFileReadFailed; + } +} + +void _freeEntriesCallback( + Pointer> entries, int count, Pointer userData) { + for (var i = 0; i < count; i++) { + if (entries[i] != nullptr) calloc.free(entries[i]); + } + calloc.free(entries); +} + +int _isDirectoryCallback(Pointer path, Pointer userData) { + try { + return Directory(path.toDartString()).existsSync() ? RAC_TRUE : RAC_FALSE; + } catch (e) { + return RAC_FALSE; + } +} + +int _pathExistsCallback(Pointer path, Pointer userData) { + try { + final pathStr = path.toDartString(); + return (File(pathStr).existsSync() || Directory(pathStr).existsSync()) + ? RAC_TRUE + : RAC_FALSE; + } catch (e) { + return RAC_FALSE; + } +} + +int _isModelFileCallback( + Pointer path, int framework, Pointer userData) { + try { + final pathStr = path.toDartString(); + final ext = pathStr.split('.').last.toLowerCase(); + + // Check extension based on framework + // RAC_FRAMEWORK values: 0=ONNX, 1=LlamaCpp (matches Swift) + switch (framework) { + case 0: // RAC_FRAMEWORK_ONNX + return (ext == 'onnx' || ext == 'ort') ? RAC_TRUE : RAC_FALSE; + case 1: // RAC_FRAMEWORK_LLAMACPP + return (ext == 'gguf' || ext == 'bin') ? RAC_TRUE : RAC_FALSE; + case 2: // RAC_FRAMEWORK_FOUNDATION_MODELS + case 3: // RAC_FRAMEWORK_SYSTEM_TTS + return RAC_TRUE; // Built-in models don't need file check + default: + // Generic check for any model file + return (ext == 'gguf' || ext == 'onnx' || ext == 'bin' || ext == 'ort') + ? RAC_TRUE + : RAC_FALSE; + } + } catch (e) { + return RAC_FALSE; + } +} + +// ============================================================================= +// FFI Structs +// ============================================================================= + +/// Artifact info struct matching C++ rac_model_artifact_info_t +/// Used as nested struct in RacModelInfoCStruct +base class RacArtifactInfoStruct extends Struct { + @Int32() + external int kind; // rac_artifact_type_kind_t + + @Int32() + external int archiveType; // rac_archive_type_t + + @Int32() + external int archiveStructure; // rac_archive_structure_t + + external Pointer expectedFiles; // rac_expected_model_files_t* + + external Pointer fileDescriptors; // rac_model_file_descriptor_t* + + @IntPtr() + external int fileDescriptorCount; // size_t + + external Pointer strategyId; // const char* +} + +/// Model info struct matching actual C++ rac_model_info_t layout. +/// +/// IMPORTANT: Field order MUST match the C struct exactly! +/// This struct is allocated by rac_model_info_alloc() in C++ which uses +/// calloc to zero all fields, making unset fields safe. +base class RacModelInfoCStruct extends Struct { + // char* id + external Pointer id; + + // char* name + external Pointer name; + + // rac_model_category_t (int32_t) + @Int32() + external int category; + + // rac_model_format_t (int32_t) + @Int32() + external int format; + + // rac_inference_framework_t (int32_t) + @Int32() + external int framework; + + // char* download_url + external Pointer downloadUrl; + + // char* local_path + external Pointer localPath; + + // rac_model_artifact_info_t artifact_info (nested struct, ~40 bytes) + external RacArtifactInfoStruct artifactInfo; + + // int64_t download_size + @Int64() + external int downloadSize; + + // int64_t memory_required + @Int64() + external int memoryRequired; + + // int32_t context_length + @Int32() + external int contextLength; + + // rac_bool_t supports_thinking (int32_t) + @Int32() + external int supportsThinking; + + // char** tags + external Pointer> tags; + + // size_t tag_count + @IntPtr() + external int tagCount; + + // char* description + external Pointer description; + + // rac_model_source_t (int32_t) + @Int32() + external int source; + + // int64_t created_at + @Int64() + external int createdAt; + + // int64_t updated_at + @Int64() + external int updatedAt; + + // int64_t last_used + @Int64() + external int lastUsed; + + // int32_t usage_count + @Int32() + external int usageCount; +} + +/// Model info struct (simplified, for internal Dart use only) +/// NOT for direct FFI - use RacModelInfoCStruct with rac_model_info_alloc +base class RacModelInfoStruct extends Struct { + external Pointer id; + external Pointer name; + + @Int32() + external int category; + + @Int32() + external int format; + + @Int32() + external int framework; + + @Int32() + external int source; + + @Int64() + external int sizeBytes; + + external Pointer downloadURL; + external Pointer localPath; + external Pointer version; +} + +/// Discovery callbacks struct +typedef RacListDirectoryCallbackNative = Int32 Function(Pointer, + Pointer>>, Pointer, Pointer); +typedef RacFreeEntriesCallbackNative = Void Function( + Pointer>, IntPtr, Pointer); +typedef RacIsDirectoryCallbackNative = Int32 Function( + Pointer, Pointer); +typedef RacPathExistsCallbackNative = Int32 Function( + Pointer, Pointer); +typedef RacIsModelFileCallbackNative = Int32 Function( + Pointer, Int32, Pointer); + +base class RacDiscoveryCallbacksStruct extends Struct { + external Pointer> + listDirectory; + external Pointer> freeEntries; + external Pointer> isDirectory; + external Pointer> pathExists; + external Pointer> isModelFile; + external Pointer userData; +} + +/// Discovered model struct +base class RacDiscoveredModelStruct extends Struct { + external Pointer modelId; + external Pointer localPath; + + @Int32() + external int framework; +} + +/// Discovery result struct +base class RacDiscoveryResultStruct extends Struct { + @IntPtr() + external int discoveredCount; + + external Pointer discoveredModels; + + @IntPtr() + external int unregisteredCount; +} + +// ============================================================================= +// Data Classes +// ============================================================================= + +/// Model info data class +class ModelInfo { + final String id; + final String name; + final int category; + final int format; + final int framework; + final int source; + final int sizeBytes; + final String? downloadURL; + final String? localPath; + final String? version; + + const ModelInfo({ + required this.id, + required this.name, + required this.category, + required this.format, + required this.framework, + required this.source, + required this.sizeBytes, + this.downloadURL, + this.localPath, + this.version, + }); + + bool get isDownloaded => localPath != null && localPath!.isNotEmpty; + + Map toJson() => { + 'id': id, + 'name': name, + 'category': category, + 'format': format, + 'framework': framework, + 'source': source, + 'sizeBytes': sizeBytes, + if (downloadURL != null) 'downloadURL': downloadURL, + if (localPath != null) 'localPath': localPath, + if (version != null) 'version': version, + }; +} + +/// Discovered model +class DiscoveredModel { + final String modelId; + final String localPath; + final int framework; + + const DiscoveredModel({ + required this.modelId, + required this.localPath, + required this.framework, + }); +} + +/// Discovery result +class DiscoveryResult { + final List discoveredModels; + final int unregisteredCount; + + const DiscoveryResult({ + required this.discoveredModels, + required this.unregisteredCount, + }); +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_platform.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_platform.dart new file mode 100644 index 000000000..147cbdaef --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_platform.dart @@ -0,0 +1,509 @@ +// ignore_for_file: avoid_classes_with_only_static_members + +import 'dart:async'; +import 'dart:ffi'; +import 'dart:io'; + +import 'package:ffi/ffi.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:runanywhere/foundation/logging/sdk_logger.dart'; +import 'package:runanywhere/native/ffi_types.dart'; +import 'package:runanywhere/native/platform_loader.dart'; + +// ============================================================================= +// Exception Return Constants (must be compile-time constants for FFI) +// ============================================================================= + +/// Exceptional return value for file operations that return Int32 +const int _exceptionalReturnInt32 = -183; // RAC_ERROR_FILE_NOT_FOUND + +/// Exceptional return value for bool operations +const int _exceptionalReturnFalse = 0; + +/// Exceptional return value for int64 operations +const int _exceptionalReturnInt64 = 0; + +// ============================================================================= +// Platform Adapter Bridge +// ============================================================================= + +/// Platform adapter bridge for fundamental C++ → Dart operations. +/// +/// Provides: logging, file operations, secure storage, clock. +/// Matches Swift's `CppBridge+PlatformAdapter.swift` exactly. +/// +/// C++ code cannot directly: +/// - Write to disk +/// - Access secure storage (Keychain/KeyStore) +/// - Get current time +/// - Route logs to native logging system +/// +/// This bridge provides those capabilities via C function callbacks. +class DartBridgePlatform { + DartBridgePlatform._(); + + static final _logger = SDKLogger('DartBridge.Platform'); + + /// Singleton instance for bridge accessors + static final DartBridgePlatform instance = DartBridgePlatform._(); + + /// Whether the adapter has been registered + static bool _isRegistered = false; + + /// Pointer to the adapter struct (must persist for C++ to call) + static Pointer? _adapterPtr; + + /// Thread-safe logger callback using NativeCallable.listener + /// This callback can be invoked from ANY thread/isolate and posts to our event loop + /// CRITICAL: Must be kept alive to prevent garbage collection + static NativeCallable? _loggerCallable; + + /// Secure storage for keychain operations + // ignore: unused_field + static const _secureStorage = FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock), + ); + + /// Register platform adapter with C++. + /// Must be called FIRST during SDK init (before any C++ operations). + static void register() { + if (_isRegistered) { + _logger.debug('Platform adapter already registered'); + return; + } + + try { + final lib = PlatformLoader.loadCommons(); + + // Allocate the platform adapter struct + _adapterPtr = calloc(); + final adapter = _adapterPtr!; + + // Logging callback - MUST use NativeCallable.listener for thread safety + // This allows C++ to call the logger from any thread (including background + // threads used by LLM generation) without crashing with: + // "Cannot invoke native callback from a different isolate" + _loggerCallable = NativeCallable.listener( + _platformLogCallback, + ); + adapter.ref.log = _loggerCallable!.nativeFunction; + + // File operations + adapter.ref.fileExists = + Pointer.fromFunction( + _platformFileExistsCallback, + _exceptionalReturnFalse, + ); + adapter.ref.fileRead = Pointer.fromFunction( + _platformFileReadCallback, + _exceptionalReturnInt32, + ); + adapter.ref.fileWrite = Pointer.fromFunction( + _platformFileWriteCallback, + _exceptionalReturnInt32, + ); + adapter.ref.fileDelete = + Pointer.fromFunction( + _platformFileDeleteCallback, + _exceptionalReturnInt32, + ); + + // Secure storage (async operations - need special handling) + adapter.ref.secureGet = Pointer.fromFunction( + _platformSecureGetCallback, + _exceptionalReturnInt32, + ); + adapter.ref.secureSet = Pointer.fromFunction( + _platformSecureSetCallback, + _exceptionalReturnInt32, + ); + adapter.ref.secureDelete = + Pointer.fromFunction( + _platformSecureDeleteCallback, + _exceptionalReturnInt32, + ); + + // Clock - returns int64, use 0 as exceptional return + adapter.ref.nowMs = Pointer.fromFunction( + _platformNowMsCallback, + _exceptionalReturnInt64, + ); + + // Memory info callback - returns errorNotImplemented (platform-specific) + adapter.ref.getMemoryInfo = + Pointer.fromFunction( + _platformGetMemoryInfoCallback, + _exceptionalReturnInt32, + ); + + // Error tracking (Sentry) + adapter.ref.trackError = + Pointer.fromFunction( + _platformTrackErrorCallback, + ); + + // Optional callbacks (handled by Dart directly) + adapter.ref.httpDownload = nullptr; + adapter.ref.httpDownloadCancel = nullptr; + adapter.ref.extractArchive = nullptr; + adapter.ref.userData = nullptr; + + // Register with C++ + final setAdapter = lib.lookupFunction< + Int32 Function(Pointer), + int Function( + Pointer)>('rac_set_platform_adapter'); + + final result = setAdapter(adapter); + if (result != RacResultCode.success) { + _logger.error('Failed to register platform adapter', metadata: { + 'error_code': result, + }); + calloc.free(adapter); + _adapterPtr = null; + return; + } + + _isRegistered = true; + _logger.debug('Platform adapter registered successfully'); + + // Note: We don't free the adapter here as C++ holds a reference to it + // It will be valid for the lifetime of the application + } catch (e, stack) { + _logger.error('Exception registering platform adapter', metadata: { + 'error': e.toString(), + 'stack': stack.toString(), + }); + } + } + + /// Unregister platform adapter (called during shutdown). + static void unregister() { + if (!_isRegistered) return; + + // Note: We can't actually unregister from C++ since it holds a pointer + // Just mark as unregistered + _isRegistered = false; + + // Close the logger callable to release resources + // Note: Only do this during true shutdown - C++ may still try to log + // We keep it alive during normal operation + // _loggerCallable?.close(); + // _loggerCallable = null; + + // Don't free _adapterPtr - C++ may still reference it + // It will be cleaned up on process exit + } + + /// Check if the adapter is registered. + static bool get isRegistered => _isRegistered; +} + +// ============================================================================= +// C Callback Functions (must be static top-level functions) +// ============================================================================= + +/// Logging callback - routes C++ logs to Dart logger +/// +/// NOTE: This callback is registered with NativeCallable.listener for thread safety. +/// It runs asynchronously on the main isolate's event loop, which means by the time +/// it executes, the C++ log message memory may have been freed. We handle this by +/// catching any UTF-8 decoding errors gracefully. +void _platformLogCallback( + int level, + Pointer category, + Pointer message, + Pointer userData, +) { + if (message == nullptr) return; + + try { + // Try to decode the message - may fail if memory was freed + final msgString = message.toDartString(); + if (msgString.isEmpty) return; + + final categoryString = category != nullptr ? category.toDartString() : 'RAC'; + + final logger = SDKLogger(categoryString); + + switch (level) { + case RacLogLevel.error: + case RacLogLevel.fatal: + logger.error(msgString); + case RacLogLevel.warning: + logger.warning(msgString); + case RacLogLevel.info: + logger.info(msgString); + case RacLogLevel.debug: + logger.debug(msgString); + case RacLogLevel.trace: + logger.debug('[TRACE] $msgString'); + default: + logger.info(msgString); + } + } catch (e) { + // Silently ignore invalid UTF-8 or freed memory errors + // This can happen because NativeCallable.listener runs asynchronously + // and the C++ log message buffer may have been freed by then + } +} + +/// File exists callback +int _platformFileExistsCallback( + Pointer path, + Pointer userData, +) { + if (path == nullptr) return RAC_FALSE; + + try { + final pathString = path.toDartString(); + return File(pathString).existsSync() ? RAC_TRUE : RAC_FALSE; + } catch (_) { + return RAC_FALSE; + } +} + +/// File read callback +int _platformFileReadCallback( + Pointer path, + Pointer> outData, + Pointer outSize, + Pointer userData, +) { + if (path == nullptr || outData == nullptr || outSize == nullptr) { + return RacResultCode.errorInvalidParameter; + } + + try { + final pathString = path.toDartString(); + final file = File(pathString); + + if (!file.existsSync()) { + return RacResultCode.errorFileNotFound; + } + + final data = file.readAsBytesSync(); + + // Allocate buffer and copy data + final buffer = calloc(data.length); + for (var i = 0; i < data.length; i++) { + buffer[i] = data[i]; + } + + outData.value = buffer.cast(); + outSize.value = data.length; + + return RacResultCode.success; + } catch (_) { + return RacResultCode.errorFileReadFailed; + } +} + +/// File write callback +int _platformFileWriteCallback( + Pointer path, + Pointer data, + int size, + Pointer userData, +) { + if (path == nullptr || data == nullptr) { + return RacResultCode.errorInvalidParameter; + } + + try { + final pathString = path.toDartString(); + final bytes = data.cast().asTypedList(size); + + final file = File(pathString); + file.writeAsBytesSync(bytes); + + return RacResultCode.success; + } catch (_) { + return RacResultCode.errorFileWriteFailed; + } +} + +/// File delete callback +int _platformFileDeleteCallback( + Pointer path, + Pointer userData, +) { + if (path == nullptr) { + return RacResultCode.errorInvalidParameter; + } + + try { + final pathString = path.toDartString(); + final file = File(pathString); + + if (file.existsSync()) { + file.deleteSync(); + } + + return RacResultCode.success; + } catch (_) { + return RacResultCode.errorDeleteFailed; + } +} + +/// Secure storage cache for synchronous access +/// Note: flutter_secure_storage is async, so we cache values +final Map _secureStorageCache = {}; +bool _secureStorageCacheLoaded = false; + +/// Load secure storage cache (called during init) +Future loadSecureStorageCache() async { + if (_secureStorageCacheLoaded) return; + + try { + const storage = FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock), + ); + final all = await storage.readAll(); + _secureStorageCache.addAll(all); + _secureStorageCacheLoaded = true; + } catch (_) { + // Ignore errors - cache will be empty + } +} + +/// Secure get callback +int _platformSecureGetCallback( + Pointer key, + Pointer> outValue, + Pointer userData, +) { + if (key == nullptr || outValue == nullptr) { + return RacResultCode.errorInvalidParameter; + } + + try { + final keyString = key.toDartString(); + final value = _secureStorageCache[keyString]; + + if (value == null) { + return RacResultCode.errorFileNotFound; // Not found + } + + // Allocate and copy string + final cString = value.toNativeUtf8(); + outValue.value = cString; + + return RacResultCode.success; + } catch (_) { + return RacResultCode.errorStorageError; + } +} + +/// Secure set callback +int _platformSecureSetCallback( + Pointer key, + Pointer value, + Pointer userData, +) { + if (key == nullptr || value == nullptr) { + return RacResultCode.errorInvalidParameter; + } + + try { + final keyString = key.toDartString(); + final valueString = value.toDartString(); + + // Update cache immediately for sync access + _secureStorageCache[keyString] = valueString; + + // Schedule async write (fire and forget) + unawaited(_writeSecureStorage(keyString, valueString)); + + return RacResultCode.success; + } catch (_) { + return RacResultCode.errorStorageError; + } +} + +/// Async write to secure storage +Future _writeSecureStorage(String key, String value) async { + try { + const storage = FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock), + ); + await storage.write(key: key, value: value); + } catch (_) { + // Ignore errors - cache is authoritative + } +} + +/// Secure delete callback +int _platformSecureDeleteCallback( + Pointer key, + Pointer userData, +) { + if (key == nullptr) { + return RacResultCode.errorInvalidParameter; + } + + try { + final keyString = key.toDartString(); + + // Remove from cache + _secureStorageCache.remove(keyString); + + // Schedule async delete (fire and forget) + unawaited(_deleteSecureStorage(keyString)); + + return RacResultCode.success; + } catch (_) { + return RacResultCode.errorStorageError; + } +} + +/// Async delete from secure storage +Future _deleteSecureStorage(String key) async { + try { + const storage = FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock), + ); + await storage.delete(key: key); + } catch (_) { + // Ignore errors + } +} + +/// Clock callback - returns current time in milliseconds +int _platformNowMsCallback(Pointer userData) { + return DateTime.now().millisecondsSinceEpoch; +} + +/// Memory info callback - returns errorNotImplemented. +/// Memory info requires platform-specific APIs (iOS: mach_task_info, Android: ActivityManager). +int _platformGetMemoryInfoCallback( + Pointer outInfo, + Pointer userData, +) { + return RacResultCode.errorNotImplemented; +} + +/// Error tracking callback - sends to Sentry +void _platformTrackErrorCallback( + Pointer errorJson, + Pointer userData, +) { + if (errorJson == nullptr) return; + + try { + final jsonString = errorJson.toDartString(); + + // Log the error from C++ layer + // Note: For production, integrate with crash reporting (e.g., Sentry, Firebase Crashlytics) + SDKLogger('DartBridge.ErrorTracking').error( + 'C++ error received', + metadata: {'error_json': jsonString}, + ); + } catch (_) { + // Ignore errors in error handling + } +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_platform_services.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_platform_services.dart new file mode 100644 index 000000000..49ccbcc1f --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_platform_services.dart @@ -0,0 +1,80 @@ +import 'dart:async'; +// ignore_for_file: avoid_classes_with_only_static_members + +import 'dart:ffi'; + +import 'package:runanywhere/foundation/logging/sdk_logger.dart'; +import 'package:runanywhere/native/platform_loader.dart'; + +/// Platform services bridge for Foundation Models and System TTS. +/// Matches Swift's `CppBridge+Platform.swift`. +class DartBridgePlatformServices { + DartBridgePlatformServices._(); + + static final _logger = SDKLogger('DartBridge.PlatformServices'); + static final DartBridgePlatformServices instance = DartBridgePlatformServices._(); + + static bool _isRegistered = false; + + /// Register platform services with C++ + static Future register() async { + if (_isRegistered) return; + + try { + final lib = PlatformLoader.load(); + + // Register platform service availability callback + // ignore: unused_local_variable + final registerCallback = lib.lookupFunction< + Int32 Function(Pointer)>>), + int Function(Pointer)>>)>( + 'rac_platform_services_register_availability_callback', + ); + + // For now, we note that registration is available + // Full implementation would check iOS/macOS Foundation Models availability + + _isRegistered = true; + _logger.debug('Platform services registered'); + } catch (e) { + _logger.debug('Platform services registration not available: $e'); + _isRegistered = true; + } + } + + /// Check if Foundation Models are available (iOS 18+) + bool isFoundationModelsAvailable() { + // Foundation Models require iOS 18+ + // This would check platform version in a full implementation + return false; // Not available on Android or older iOS + } + + /// Check if System TTS is available + bool isSystemTTSAvailable() { + // System TTS is available on all iOS/Android versions + return true; + } + + /// Check if System STT is available + bool isSystemSTTAvailable() { + // System STT is available on iOS/Android + return true; + } + + /// Get available platform services + List getAvailableServices() { + final services = []; + + if (isFoundationModelsAvailable()) { + services.add('foundation_models'); + } + if (isSystemTTSAvailable()) { + services.add('system_tts'); + } + if (isSystemSTTAvailable()) { + services.add('system_stt'); + } + + return services; + } +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_state.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_state.dart new file mode 100644 index 000000000..e7305591b --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_state.dart @@ -0,0 +1,523 @@ +import 'dart:async'; + +// ignore_for_file: avoid_classes_with_only_static_members + +import 'dart:ffi'; + +import 'package:ffi/ffi.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +import 'package:runanywhere/foundation/logging/sdk_logger.dart'; +import 'package:runanywhere/native/dart_bridge_platform.dart'; +import 'package:runanywhere/native/ffi_types.dart'; +import 'package:runanywhere/native/platform_loader.dart'; +import 'package:runanywhere/public/configuration/sdk_environment.dart'; + +/// State bridge for C++ SDK state operations. +/// Matches Swift's `CppBridge+State.swift`. +/// +/// C++ owns runtime state; Dart handles persistence (secure storage). +class DartBridgeState { + DartBridgeState._(); + + static final _logger = SDKLogger('DartBridge.State'); + static final DartBridgeState instance = DartBridgeState._(); + + static bool _persistenceRegistered = false; + + /// Secure storage for token persistence + static const _secureStorage = FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock), + ); + + // Secure storage keys + static const _keyAccessToken = 'com.runanywhere.sdk.accessToken'; + static const _keyRefreshToken = 'com.runanywhere.sdk.refreshToken'; + static const _keyDeviceId = 'com.runanywhere.sdk.deviceId'; + static const _keyUserId = 'com.runanywhere.sdk.userId'; + static const _keyOrganizationId = 'com.runanywhere.sdk.organizationId'; + + // ============================================================================ + // Initialization + // ============================================================================ + + /// Initialize C++ state manager + Future initialize({ + required SDKEnvironment environment, + String? apiKey, + String? baseURL, + String? deviceId, + }) async { + try { + final lib = PlatformLoader.loadCommons(); + + // First load secure storage cache for platform adapter + await loadSecureStorageCache(); + + // Initialize state + final initState = lib.lookupFunction< + Int32 Function(Int32, Pointer, Pointer, Pointer), + int Function(int, Pointer, Pointer, + Pointer)>('rac_state_initialize'); + + final envValue = _environmentToInt(environment); + final apiKeyPtr = (apiKey ?? '').toNativeUtf8(); + final baseURLPtr = (baseURL ?? '').toNativeUtf8(); + final deviceIdPtr = (deviceId ?? '').toNativeUtf8(); + + try { + final result = initState(envValue, apiKeyPtr, baseURLPtr, deviceIdPtr); + if (result != RacResultCode.success) { + _logger.warning('State init failed', metadata: {'code': result}); + } + } finally { + calloc.free(apiKeyPtr); + calloc.free(baseURLPtr); + calloc.free(deviceIdPtr); + } + + // Register persistence callbacks + _registerPersistenceCallbacks(); + + // Load stored auth from secure storage into C++ state + await _loadStoredAuth(); + + _logger.debug('C++ state initialized'); + } catch (e, stack) { + _logger.debug('rac_state_initialize error: $e', metadata: { + 'stack': stack.toString(), + }); + } + } + + /// Check if state is initialized + bool get isInitialized { + try { + final lib = PlatformLoader.loadCommons(); + final isInit = lib.lookupFunction( + 'rac_state_is_initialized'); + return isInit() != 0; + } catch (e) { + return false; + } + } + + /// Reset state (for testing) + void reset() { + try { + final lib = PlatformLoader.loadCommons(); + final resetState = lib + .lookupFunction('rac_state_reset'); + resetState(); + } catch (e) { + _logger.debug('rac_state_reset not available: $e'); + } + } + + /// Shutdown state manager + void shutdown() { + try { + final lib = PlatformLoader.loadCommons(); + final shutdownState = + lib.lookupFunction( + 'rac_state_shutdown'); + shutdownState(); + _persistenceRegistered = false; + } catch (e) { + _logger.debug('rac_state_shutdown not available: $e'); + } + } + + // ============================================================================ + // Environment Queries + // ============================================================================ + + /// Get current environment from C++ state + SDKEnvironment get environment { + try { + final lib = PlatformLoader.loadCommons(); + final getEnv = lib.lookupFunction( + 'rac_state_get_environment'); + return _intToEnvironment(getEnv()); + } catch (e) { + return SDKEnvironment.development; + } + } + + /// Get base URL from C++ state + String? get baseURL { + try { + final lib = PlatformLoader.loadCommons(); + final getBaseUrl = lib.lookupFunction Function(), + Pointer Function()>('rac_state_get_base_url'); + + final result = getBaseUrl(); + if (result == nullptr) return null; + final str = result.toDartString(); + return str.isEmpty ? null : str; + } catch (e) { + return null; + } + } + + /// Get API key from C++ state + String? get apiKey { + try { + final lib = PlatformLoader.loadCommons(); + final getApiKey = lib.lookupFunction Function(), + Pointer Function()>('rac_state_get_api_key'); + + final result = getApiKey(); + if (result == nullptr) return null; + final str = result.toDartString(); + return str.isEmpty ? null : str; + } catch (e) { + return null; + } + } + + /// Get device ID from C++ state + String? get deviceId { + try { + final lib = PlatformLoader.loadCommons(); + final getDeviceId = lib.lookupFunction Function(), + Pointer Function()>('rac_state_get_device_id'); + + final result = getDeviceId(); + if (result == nullptr) return null; + final str = result.toDartString(); + return str.isEmpty ? null : str; + } catch (e) { + return null; + } + } + + // ============================================================================ + // Auth State + // ============================================================================ + + /// Set authentication state after successful HTTP auth + Future setAuth({ + required String accessToken, + required String refreshToken, + required DateTime expiresAt, + String? userId, + required String organizationId, + required String deviceId, + }) async { + try { + final lib = PlatformLoader.loadCommons(); + final setAuth = lib.lookupFunction< + Int32 Function(Pointer), + int Function(Pointer)>('rac_state_set_auth'); + + final expiresAtUnix = expiresAt.millisecondsSinceEpoch ~/ 1000; + + final accessTokenPtr = accessToken.toNativeUtf8(); + final refreshTokenPtr = refreshToken.toNativeUtf8(); + final userIdPtr = userId?.toNativeUtf8() ?? nullptr; + final organizationIdPtr = organizationId.toNativeUtf8(); + final deviceIdPtr = deviceId.toNativeUtf8(); + + final authData = calloc(); + + try { + authData.ref.accessToken = accessTokenPtr; + authData.ref.refreshToken = refreshTokenPtr; + authData.ref.expiresAtUnix = expiresAtUnix; + authData.ref.userId = userIdPtr; + authData.ref.organizationId = organizationIdPtr; + authData.ref.deviceId = deviceIdPtr; + + final result = setAuth(authData); + if (result != RacResultCode.success) { + _logger + .warning('Failed to set auth state', metadata: {'code': result}); + } + } finally { + calloc.free(accessTokenPtr); + calloc.free(refreshTokenPtr); + if (userIdPtr != nullptr) calloc.free(userIdPtr); + calloc.free(organizationIdPtr); + calloc.free(deviceIdPtr); + calloc.free(authData); + } + + // Also store in secure storage + await _storeTokensInSecureStorage( + accessToken: accessToken, + refreshToken: refreshToken, + deviceId: deviceId, + userId: userId, + organizationId: organizationId, + ); + + _logger.debug('Auth state set in C++'); + } catch (e) { + _logger.debug('rac_state_set_auth error: $e'); + } + } + + /// Get access token from C++ state + String? get accessToken { + try { + final lib = PlatformLoader.loadCommons(); + final getToken = lib.lookupFunction Function(), + Pointer Function()>('rac_state_get_access_token'); + + final result = getToken(); + if (result == nullptr) return null; + return result.toDartString(); + } catch (e) { + return null; + } + } + + /// Get refresh token from C++ state + String? get refreshToken { + try { + final lib = PlatformLoader.loadCommons(); + final getToken = lib.lookupFunction Function(), + Pointer Function()>('rac_state_get_refresh_token'); + + final result = getToken(); + if (result == nullptr) return null; + return result.toDartString(); + } catch (e) { + return null; + } + } + + /// Check if authenticated (valid non-expired token) + bool get isAuthenticated { + try { + final lib = PlatformLoader.loadCommons(); + final isAuth = lib.lookupFunction( + 'rac_state_is_authenticated'); + return isAuth() != 0; + } catch (e) { + return false; + } + } + + /// Check if token needs refresh + bool get tokenNeedsRefresh { + try { + final lib = PlatformLoader.loadCommons(); + final needsRefresh = lib.lookupFunction( + 'rac_state_token_needs_refresh'); + return needsRefresh() != 0; + } catch (e) { + return false; + } + } + + /// Get token expiry timestamp + DateTime? get tokenExpiresAt { + try { + final lib = PlatformLoader.loadCommons(); + final getExpiry = lib.lookupFunction( + 'rac_state_get_token_expires_at'); + + final unix = getExpiry(); + return unix > 0 ? DateTime.fromMillisecondsSinceEpoch(unix * 1000) : null; + } catch (e) { + return null; + } + } + + /// Get user ID from C++ state + String? get userId { + try { + final lib = PlatformLoader.loadCommons(); + final getUserId = lib.lookupFunction Function(), + Pointer Function()>('rac_state_get_user_id'); + + final result = getUserId(); + if (result == nullptr) return null; + return result.toDartString(); + } catch (e) { + return null; + } + } + + /// Get organization ID from C++ state + String? get organizationId { + try { + final lib = PlatformLoader.loadCommons(); + final getOrgId = lib.lookupFunction Function(), + Pointer Function()>('rac_state_get_organization_id'); + + final result = getOrgId(); + if (result == nullptr) return null; + return result.toDartString(); + } catch (e) { + return null; + } + } + + /// Clear authentication state + Future clearAuth() async { + try { + final lib = PlatformLoader.loadCommons(); + final clearAuthFn = lib.lookupFunction( + 'rac_state_clear_auth'); + clearAuthFn(); + + // Clear from secure storage too + await _secureStorage.delete(key: _keyAccessToken); + await _secureStorage.delete(key: _keyRefreshToken); + await _secureStorage.delete(key: _keyDeviceId); + await _secureStorage.delete(key: _keyUserId); + await _secureStorage.delete(key: _keyOrganizationId); + + _logger.debug('Auth state cleared'); + } catch (e) { + _logger.debug('Failed to clear auth: $e'); + } + } + + // ============================================================================ + // Device State + // ============================================================================ + + /// Set device registration status + void setDeviceRegistered(bool registered) { + try { + final lib = PlatformLoader.loadCommons(); + final setReg = + lib.lookupFunction( + 'rac_state_set_device_registered'); + setReg(registered ? 1 : 0); + } catch (e) { + _logger.debug('rac_state_set_device_registered not available: $e'); + } + } + + /// Check if device is registered + bool get isDeviceRegistered { + try { + final lib = PlatformLoader.loadCommons(); + final isReg = lib.lookupFunction( + 'rac_state_is_device_registered'); + return isReg() != 0; + } catch (e) { + return false; + } + } + + // ============================================================================ + // Persistence (Secure Storage Integration) + // ============================================================================ + + /// Register Keychain/secure storage persistence callbacks with C++ + void _registerPersistenceCallbacks() { + if (_persistenceRegistered) return; + + // Note: C++ expects synchronous callbacks, so we use the cache from platform adapter + // The platform adapter handles the async-to-sync bridging + + _persistenceRegistered = true; + _logger.debug('Persistence callbacks registered'); + } + + /// Load stored auth from secure storage into C++ state + Future _loadStoredAuth() async { + try { + final accessToken = await _secureStorage.read(key: _keyAccessToken); + final refreshToken = await _secureStorage.read(key: _keyRefreshToken); + + if (accessToken == null || refreshToken == null) { + _logger.debug('No stored auth data found'); + return; + } + + final userId = await _secureStorage.read(key: _keyUserId); + final orgId = await _secureStorage.read(key: _keyOrganizationId); + final deviceIdStored = await _secureStorage.read(key: _keyDeviceId); + + // Set in C++ state with unknown expiry (will be checked via API) + await setAuth( + accessToken: accessToken, + refreshToken: refreshToken, + expiresAt: + DateTime.now().add(const Duration(hours: 1)), // Default expiry + userId: userId, + organizationId: orgId ?? '', + deviceId: deviceIdStored ?? '', + ); + + _logger.debug('Loaded stored auth from secure storage'); + } catch (e) { + _logger.debug('Error loading stored auth: $e'); + } + } + + /// Store tokens in secure storage + Future _storeTokensInSecureStorage({ + required String accessToken, + required String refreshToken, + required String deviceId, + String? userId, + required String organizationId, + }) async { + try { + await _secureStorage.write(key: _keyAccessToken, value: accessToken); + await _secureStorage.write(key: _keyRefreshToken, value: refreshToken); + await _secureStorage.write(key: _keyDeviceId, value: deviceId); + if (userId != null) { + await _secureStorage.write(key: _keyUserId, value: userId); + } + await _secureStorage.write( + key: _keyOrganizationId, value: organizationId); + } catch (e) { + _logger.debug('Error storing tokens: $e'); + } + } + + // ============================================================================ + // Helper Methods + // ============================================================================ + + int _environmentToInt(SDKEnvironment env) { + switch (env) { + case SDKEnvironment.development: + return 0; + case SDKEnvironment.staging: + return 1; + case SDKEnvironment.production: + return 2; + } + } + + SDKEnvironment _intToEnvironment(int value) { + switch (value) { + case 0: + return SDKEnvironment.development; + case 1: + return SDKEnvironment.staging; + case 2: + return SDKEnvironment.production; + default: + return SDKEnvironment.development; + } + } +} + +// ============================================================================= +// Auth Data Struct (matches rac_auth_data_t) +// ============================================================================= + +/// Auth data struct for C++ interop +base class RacAuthDataStruct extends Struct { + external Pointer accessToken; + external Pointer refreshToken; + + @Int64() + external int expiresAtUnix; + + external Pointer userId; + external Pointer organizationId; + external Pointer deviceId; +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_storage.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_storage.dart new file mode 100644 index 000000000..d0ffff9d6 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_storage.dart @@ -0,0 +1,120 @@ +// ignore_for_file: avoid_classes_with_only_static_members + +import 'dart:async'; +import 'dart:ffi'; + +import 'package:ffi/ffi.dart'; +import 'package:runanywhere/foundation/logging/sdk_logger.dart'; +import 'package:runanywhere/native/ffi_types.dart'; +import 'package:runanywhere/native/platform_loader.dart'; + +/// Storage bridge for C++ storage operations. +/// Matches Swift's `CppBridge+Storage.swift`. +class DartBridgeStorage { + DartBridgeStorage._(); + + static final _logger = SDKLogger('DartBridge.Storage'); + static final DartBridgeStorage instance = DartBridgeStorage._(); + + /// Get value from storage + Future get(String key) async { + try { + final lib = PlatformLoader.load(); + final getFn = lib.lookupFunction< + Pointer Function(Pointer), + Pointer Function(Pointer)>('rac_storage_get'); + + final keyPtr = key.toNativeUtf8(); + try { + final result = getFn(keyPtr); + if (result == nullptr) return null; + return result.toDartString(); + } finally { + calloc.free(keyPtr); + } + } catch (e) { + _logger.debug('rac_storage_get not available: $e'); + return null; + } + } + + /// Set value in storage + Future set(String key, String value) async { + try { + final lib = PlatformLoader.load(); + final setFn = lib.lookupFunction< + Int32 Function(Pointer, Pointer), + int Function(Pointer, Pointer)>('rac_storage_set'); + + final keyPtr = key.toNativeUtf8(); + final valuePtr = value.toNativeUtf8(); + try { + final result = setFn(keyPtr, valuePtr); + return result == RacResultCode.success; + } finally { + calloc.free(keyPtr); + calloc.free(valuePtr); + } + } catch (e) { + _logger.debug('rac_storage_set not available: $e'); + return false; + } + } + + /// Delete value from storage + Future delete(String key) async { + try { + final lib = PlatformLoader.load(); + final deleteFn = lib.lookupFunction< + Int32 Function(Pointer), + int Function(Pointer)>('rac_storage_delete'); + + final keyPtr = key.toNativeUtf8(); + try { + final result = deleteFn(keyPtr); + return result == RacResultCode.success; + } finally { + calloc.free(keyPtr); + } + } catch (e) { + _logger.debug('rac_storage_delete not available: $e'); + return false; + } + } + + /// Check if key exists in storage + Future exists(String key) async { + try { + final lib = PlatformLoader.load(); + final existsFn = lib.lookupFunction< + Int32 Function(Pointer), + int Function(Pointer)>('rac_storage_exists'); + + final keyPtr = key.toNativeUtf8(); + try { + return existsFn(keyPtr) != 0; + } finally { + calloc.free(keyPtr); + } + } catch (e) { + _logger.debug('rac_storage_exists not available: $e'); + return false; + } + } + + /// Clear all storage + Future clear() async { + try { + final lib = PlatformLoader.load(); + final clearFn = lib.lookupFunction< + Int32 Function(), + int Function()>('rac_storage_clear'); + + final result = clearFn(); + return result == RacResultCode.success; + } catch (e) { + _logger.debug('rac_storage_clear not available: $e'); + return false; + } + } +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_stt.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_stt.dart new file mode 100644 index 000000000..ab796b6da --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_stt.dart @@ -0,0 +1,450 @@ +/// DartBridge+STT +/// +/// STT component bridge - manages C++ STT component lifecycle. +/// Mirrors Swift's CppBridge+STT.swift pattern. +library dart_bridge_stt; + +import 'dart:ffi'; +import 'dart:isolate'; +import 'dart:typed_data'; + +import 'package:ffi/ffi.dart'; + +import 'package:runanywhere/foundation/logging/sdk_logger.dart'; +import 'package:runanywhere/native/ffi_types.dart'; +import 'package:runanywhere/native/platform_loader.dart'; + +/// STT component bridge for C++ interop. +/// +/// Provides thread-safe access to the C++ STT component. +/// Handles model loading, transcription, and streaming. +/// +/// Usage: +/// ```dart +/// final stt = DartBridgeSTT.shared; +/// await stt.loadModel('/path/to/model', 'model-id', 'Model Name'); +/// final text = await stt.transcribe(audioData); +/// ``` +class DartBridgeSTT { + // MARK: - Singleton + + /// Shared instance + static final DartBridgeSTT shared = DartBridgeSTT._(); + + DartBridgeSTT._(); + + // MARK: - State + + RacHandle? _handle; + String? _loadedModelId; + final _logger = SDKLogger('DartBridge.STT'); + + // MARK: - Handle Management + + /// Get or create the STT component handle. + RacHandle getHandle() { + if (_handle != null) { + return _handle!; + } + + try { + final lib = PlatformLoader.loadCommons(); + final create = lib.lookupFunction), + int Function(Pointer)>('rac_stt_component_create'); + + final handlePtr = calloc(); + try { + final result = create(handlePtr); + + if (result != RAC_SUCCESS) { + throw StateError( + 'Failed to create STT component: ${RacResultCode.getMessage(result)}', + ); + } + + _handle = handlePtr.value; + _logger.debug('STT component created'); + return _handle!; + } finally { + calloc.free(handlePtr); + } + } catch (e) { + _logger.error('Failed to create STT handle: $e'); + rethrow; + } + } + + // MARK: - State Queries + + /// Check if a model is loaded. + bool get isLoaded { + if (_handle == null) return false; + + try { + final lib = PlatformLoader.loadCommons(); + final isLoadedFn = lib.lookupFunction('rac_stt_component_is_loaded'); + + return isLoadedFn(_handle!) == RAC_TRUE; + } catch (e) { + _logger.debug('isLoaded check failed: $e'); + return false; + } + } + + /// Get the currently loaded model ID. + String? get currentModelId => _loadedModelId; + + /// Check if streaming is supported. + bool get supportsStreaming { + if (_handle == null) return false; + + try { + final lib = PlatformLoader.loadCommons(); + final supportsStreamingFn = lib.lookupFunction('rac_stt_component_supports_streaming'); + + return supportsStreamingFn(_handle!) == RAC_TRUE; + } catch (e) { + return false; + } + } + + // MARK: - Model Lifecycle + + /// Load an STT model. + /// + /// [modelPath] - Full path to the model directory. + /// [modelId] - Unique identifier for the model. + /// [modelName] - Human-readable name. + /// + /// Throws on failure. + Future loadModel( + String modelPath, + String modelId, + String modelName, + ) async { + final handle = getHandle(); + + final pathPtr = modelPath.toNativeUtf8(); + final idPtr = modelId.toNativeUtf8(); + final namePtr = modelName.toNativeUtf8(); + + try { + final lib = PlatformLoader.loadCommons(); + final loadModelFn = lib.lookupFunction< + Int32 Function( + RacHandle, Pointer, Pointer, Pointer), + int Function(RacHandle, Pointer, Pointer, + Pointer)>('rac_stt_component_load_model'); + + final result = loadModelFn(handle, pathPtr, idPtr, namePtr); + + if (result != RAC_SUCCESS) { + throw StateError( + 'Failed to load STT model: ${RacResultCode.getMessage(result)}', + ); + } + + _loadedModelId = modelId; + _logger.info('STT model loaded: $modelId'); + } finally { + calloc.free(pathPtr); + calloc.free(idPtr); + calloc.free(namePtr); + } + } + + /// Unload the current model. + void unload() { + if (_handle == null) return; + + try { + final lib = PlatformLoader.loadCommons(); + final cleanupFn = lib.lookupFunction('rac_stt_component_cleanup'); + + cleanupFn(_handle!); + _loadedModelId = null; + _logger.info('STT model unloaded'); + } catch (e) { + _logger.error('Failed to unload STT model: $e'); + } + } + + // MARK: - Transcription + + /// Transcribe audio data. + /// + /// [audioData] - PCM16 audio data (WAV format expected with 16kHz sample rate). + /// [sampleRate] - Sample rate of the audio (default: 16000 Hz for Whisper). + /// + /// Returns the transcription result. + /// Runs in a background isolate to prevent UI blocking. + Future transcribe( + Uint8List audioData, { + int sampleRate = 16000, + }) async { + final handle = getHandle(); + + if (!isLoaded) { + throw StateError('No STT model loaded. Call loadModel() first.'); + } + + _logger.debug( + 'Transcribing ${audioData.length} bytes at $sampleRate Hz in background isolate...'); + + // Run transcription in background isolate + final result = await Isolate.run(() => _transcribeInIsolate( + handle.address, + audioData, + sampleRate, + )); + + _logger.info( + 'Transcription complete: ${result.text.length} chars, confidence: ${result.confidence}'); + + return result; + } + + /// Static helper to perform FFI transcription in isolate. + /// Must be static/top-level for Isolate.run(). + static STTComponentResult _transcribeInIsolate( + int handleAddress, + Uint8List audioData, + int sampleRate, + ) { + final lib = PlatformLoader.loadCommons(); + final handle = RacHandle.fromAddress(handleAddress); + + // Allocate native memory + final dataPtr = calloc(audioData.length); + final optionsPtr = calloc(); + final resultPtr = calloc(); + + try { + // Copy audio data + final dataList = dataPtr.asTypedList(audioData.length); + dataList.setAll(0, audioData); + + // Set up options with correct sample rate + // Matches Swift's STTOptions setup + final languagePtr = 'en'.toNativeUtf8(); + optionsPtr.ref.language = languagePtr; + optionsPtr.ref.detectLanguage = RAC_FALSE; + optionsPtr.ref.enablePunctuation = RAC_TRUE; + optionsPtr.ref.enableDiarization = RAC_FALSE; + optionsPtr.ref.maxSpeakers = 0; + optionsPtr.ref.enableTimestamps = RAC_TRUE; + optionsPtr.ref.audioFormat = racAudioFormatWav; // WAV format + optionsPtr.ref.sampleRate = sampleRate; + + // Get transcribe function + final transcribeFn = lib.lookupFunction< + Int32 Function( + RacHandle, + Pointer, + IntPtr, + Pointer, + Pointer, + ), + int Function( + RacHandle, + Pointer, + int, + Pointer, + Pointer, + )>('rac_stt_component_transcribe'); + + final status = transcribeFn( + handle, + dataPtr.cast(), + audioData.length, + optionsPtr, + resultPtr, + ); + + // Free the language string + calloc.free(languagePtr); + + if (status != RAC_SUCCESS) { + throw StateError( + 'STT transcription failed: ${RacResultCode.getMessage(status)}', + ); + } + + // Extract result before freeing + final result = resultPtr.ref; + final text = result.text != nullptr ? result.text.toDartString() : ''; + final confidence = result.confidence; + final durationMs = result.durationMs; + final language = + result.language != nullptr ? result.language.toDartString() : null; + + return STTComponentResult( + text: text, + confidence: confidence, + durationMs: durationMs, + language: language, + ); + } finally { + calloc.free(dataPtr); + calloc.free(optionsPtr); + calloc.free(resultPtr); + } + } + + /// Transcribe with streaming. + /// + /// Returns a stream of partial transcriptions. + Stream transcribeStream(Stream audioStream) { + // Create async generator for streaming transcription + return _transcribeStreamImpl(audioStream); + } + + Stream _transcribeStreamImpl( + Stream audioStream, + ) async* { + // Accumulate audio and emit partial results + final buffer = []; + + await for (final chunk in audioStream) { + buffer.addAll(chunk); + + // Process every ~0.5 seconds of audio (8000 samples at 16kHz) + if (buffer.length >= 8000) { + try { + final result = await transcribe(Uint8List.fromList(buffer)); + yield STTStreamResult( + text: result.text, + isFinal: false, + confidence: result.confidence, + ); + } catch (e) { + _logger.debug('Partial transcription failed: $e'); + } + } + } + + // Final transcription with all audio + if (buffer.isNotEmpty) { + try { + final result = await transcribe(Uint8List.fromList(buffer)); + yield STTStreamResult( + text: result.text, + isFinal: true, + confidence: result.confidence, + ); + } catch (e) { + _logger.error('Final transcription failed: $e'); + } + } + } + + // MARK: - Cleanup + + /// Destroy the component and release resources. + void destroy() { + if (_handle != null) { + try { + final lib = PlatformLoader.loadCommons(); + final destroyFn = lib.lookupFunction('rac_stt_component_destroy'); + + destroyFn(_handle!); + _handle = null; + _loadedModelId = null; + _logger.debug('STT component destroyed'); + } catch (e) { + _logger.error('Failed to destroy STT component: $e'); + } + } + } +} + +/// Result from STT transcription. +class STTComponentResult { + final String text; + final double confidence; + final int durationMs; + final String? language; + + const STTComponentResult({ + required this.text, + required this.confidence, + required this.durationMs, + this.language, + }); +} + +/// Streaming result from STT transcription. +class STTStreamResult { + final String text; + final bool isFinal; + final double confidence; + + const STTStreamResult({ + required this.text, + required this.isFinal, + required this.confidence, + }); +} + +// ============================================================================= +// FFI Structs +// ============================================================================= + +/// Audio format enum (matches rac_audio_format_enum_t) +const int racAudioFormatPcm = 0; +const int racAudioFormatWav = 1; +const int racAudioFormatMp3 = 2; +const int racAudioFormatOpus = 3; +const int racAudioFormatAac = 4; +const int racAudioFormatFlac = 5; + +/// FFI struct for STT options (matches rac_stt_options_t) +final class RacSttOptionsStruct extends Struct { + /// Language code (e.g., "en") + external Pointer language; + + /// Whether to auto-detect language + @Int32() + external int detectLanguage; + + /// Whether to add punctuation + @Int32() + external int enablePunctuation; + + /// Whether to enable speaker diarization + @Int32() + external int enableDiarization; + + /// Maximum number of speakers for diarization + @Int32() + external int maxSpeakers; + + /// Whether to include word timestamps + @Int32() + external int enableTimestamps; + + /// Audio format of input data + @Int32() + external int audioFormat; + + /// Sample rate of input audio (default: 16000 Hz) + @Int32() + external int sampleRate; +} + +/// FFI struct for STT result (matches rac_stt_result_t) +final class RacSttResultStruct extends Struct { + external Pointer text; + + @Double() + external double confidence; + + @Int32() + external int durationMs; + + external Pointer language; +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_telemetry.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_telemetry.dart new file mode 100644 index 000000000..86d38a11e --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_telemetry.dart @@ -0,0 +1,764 @@ +// ignore_for_file: avoid_classes_with_only_static_members + +import 'dart:async'; +import 'dart:convert'; +import 'dart:ffi'; +import 'dart:io'; + +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:ffi/ffi.dart'; +import 'package:http/http.dart' as http; + +import 'package:runanywhere/foundation/logging/sdk_logger.dart'; +import 'package:runanywhere/native/ffi_types.dart'; +import 'package:runanywhere/native/platform_loader.dart'; +import 'package:runanywhere/public/configuration/sdk_environment.dart'; + +// ============================================================================= +// Telemetry Manager Bridge +// ============================================================================= + +/// Telemetry bridge for C++ telemetry operations. +/// Matches Swift's `CppBridge+Telemetry.swift`. +/// +/// C++ handles all telemetry logic: +/// - Convert analytics events to telemetry payloads +/// - Queue and batch events +/// - Group by modality for production +/// - Serialize to JSON (environment-aware) +/// - Callback to Dart for HTTP calls +/// +/// Dart provides: +/// - Device info +/// - HTTP transport for sending telemetry +class DartBridgeTelemetry { + DartBridgeTelemetry._(); + + static final _logger = SDKLogger('DartBridge.Telemetry'); + static final DartBridgeTelemetry instance = DartBridgeTelemetry._(); + + static bool _isInitialized = false; + // ignore: unused_field + static SDKEnvironment? _environment; + static String? _baseURL; + static String? _accessToken; + static Pointer? _managerPtr; + static Pointer>? + _httpCallbackPtr; + + // ============================================================================ + // Lifecycle + // ============================================================================ + + /// Synchronous initialization - just stores environment. + /// Matches Swift's Telemetry.initialize() in Phase 1 (minimal setup). + /// Full initialization with device info happens in Phase 2 via initialize(). + static void initializeSync({required SDKEnvironment environment}) { + _environment = environment; + _logger.debug('Telemetry sync init for ${environment.name}'); + } + + /// Flush any queued telemetry events. + /// Static method that delegates to instance if initialized. + /// Matches Swift: CppBridge.Telemetry.flush() + static void flush() { + if (_isInitialized && _managerPtr != null) { + try { + final lib = PlatformLoader.loadCommons(); + final flushFn = lib.lookupFunction), + int Function(Pointer)>('rac_telemetry_manager_flush'); + flushFn(_managerPtr!); + _logger.debug('Telemetry flushed'); + } catch (e) { + _logger.debug('flush error: $e'); + } + } + } + + /// Initialize telemetry manager with device info (full async init) + static Future initialize({ + required SDKEnvironment environment, + required String deviceId, + String? baseURL, + String? accessToken, + }) async { + if (_isInitialized) { + _logger.debug('Telemetry already initialized'); + return; + } + + _environment = environment; + _baseURL = baseURL; + _accessToken = accessToken; + + try { + final lib = PlatformLoader.loadCommons(); + + // Get device info + final deviceModel = await _getDeviceModel(); + final osVersion = Platform.operatingSystemVersion; + const sdkVersion = '0.1.4'; + const platform = 'flutter'; + + // Create telemetry manager + final createManager = lib.lookupFunction< + Pointer Function( + Int32, Pointer, Pointer, Pointer), + Pointer Function(int, Pointer, Pointer, + Pointer)>('rac_telemetry_manager_create'); + + final envValue = _environmentToInt(environment); + final deviceIdPtr = deviceId.toNativeUtf8(); + final platformPtr = platform.toNativeUtf8(); + final sdkVersionPtr = sdkVersion.toNativeUtf8(); + + try { + _managerPtr = + createManager(envValue, deviceIdPtr, platformPtr, sdkVersionPtr); + + if (_managerPtr == nullptr || + _managerPtr == Pointer.fromAddress(0)) { + _logger.warning('Failed to create telemetry manager'); + return; + } + + // Set device info + final setDeviceInfo = lib.lookupFunction< + Void Function(Pointer, Pointer, Pointer), + void Function(Pointer, Pointer, + Pointer)>('rac_telemetry_manager_set_device_info'); + + final deviceModelPtr = deviceModel.toNativeUtf8(); + final osVersionPtr = osVersion.toNativeUtf8(); + + setDeviceInfo(_managerPtr!, deviceModelPtr, osVersionPtr); + + calloc.free(deviceModelPtr); + calloc.free(osVersionPtr); + + // Register HTTP callback + _registerHttpCallback(); + + _isInitialized = true; + _logger.debug('Telemetry manager initialized'); + } finally { + calloc.free(deviceIdPtr); + calloc.free(platformPtr); + calloc.free(sdkVersionPtr); + } + } catch (e, stack) { + _logger.debug('Telemetry initialization error: $e', metadata: { + 'stack': stack.toString(), + }); + _isInitialized = true; // Avoid retry loops + } + } + + /// Shutdown telemetry manager + static void shutdown() { + if (!_isInitialized || _managerPtr == null) return; + + try { + final lib = PlatformLoader.loadCommons(); + final destroy = lib.lookupFunction), + void Function(Pointer)>('rac_telemetry_manager_destroy'); + + destroy(_managerPtr!); + _managerPtr = null; + _isInitialized = false; + _logger.debug('Telemetry manager shutdown'); + } catch (e) { + _logger.debug('Telemetry shutdown error: $e'); + } + } + + /// Update access token + static void setAccessToken(String? token) { + _accessToken = token; + } + + // ============================================================================ + // Event Tracking + // ============================================================================ + + /// Track a telemetry event (via analytics event type) + Future trackEvent({ + required int eventType, + Map? data, + }) async { + if (!_isInitialized || _managerPtr == null) return; + + try { + final lib = PlatformLoader.loadCommons(); + final trackAnalytics = lib.lookupFunction< + Int32 Function( + Pointer, Int32, Pointer), + int Function( + Pointer, int, Pointer)>( + 'rac_telemetry_manager_track_analytics'); + + // Build event data struct + final eventData = calloc(); + _populateEventData(eventData, data); + + try { + final result = trackAnalytics(_managerPtr!, eventType, eventData); + if (result != RacResultCode.success) { + _logger.debug('Track event failed', metadata: {'code': result}); + } + } finally { + _freeEventData(eventData); + calloc.free(eventData); + } + } catch (e) { + _logger.debug('trackEvent error: $e'); + } + } + + /// Track a raw telemetry payload + Future trackPayload(Map payload) async { + if (!_isInitialized || _managerPtr == null) return; + + try { + final lib = PlatformLoader.loadCommons(); + final trackFn = lib.lookupFunction< + Int32 Function(Pointer, Pointer), + int Function(Pointer, + Pointer)>('rac_telemetry_manager_track_json'); + + final jsonStr = jsonEncode(payload); + final jsonPtr = jsonStr.toNativeUtf8(); + + try { + trackFn(_managerPtr!, jsonPtr); + } finally { + calloc.free(jsonPtr); + } + } catch (e) { + _logger.debug('trackPayload error: $e'); + } + } + + /// Flush pending telemetry (instance method, delegates to static) + Future flushAsync() async { + flush(); + } + + // ============================================================================ + // Event Helpers (like Swift's emitDownloadStarted, etc.) + // ============================================================================ + + /// Emit download started event + Future emitDownloadStarted({ + required String modelId, + required String modelName, + required int modelSize, + required String framework, + }) async { + await trackEvent( + eventType: RacEventType.downloadStarted, + data: { + 'modelId': modelId, + 'modelName': modelName, + 'modelSize': modelSize, + 'framework': framework, + }, + ); + } + + /// Emit download completed event + Future emitDownloadCompleted({ + required String modelId, + required String modelName, + required int modelSize, + required String framework, + required int durationMs, + }) async { + await trackEvent( + eventType: RacEventType.downloadCompleted, + data: { + 'modelId': modelId, + 'modelName': modelName, + 'modelSize': modelSize, + 'framework': framework, + 'durationMs': durationMs, + }, + ); + } + + /// Emit download failed event + Future emitDownloadFailed({ + required String modelId, + required String modelName, + required String error, + required String framework, + }) async { + await trackEvent( + eventType: RacEventType.downloadFailed, + data: { + 'modelId': modelId, + 'modelName': modelName, + 'error': error, + 'framework': framework, + }, + ); + } + + /// Emit extraction started event + Future emitExtractionStarted({ + required String modelId, + required String modelName, + required String framework, + }) async { + await trackEvent( + eventType: RacEventType.extractionStarted, + data: { + 'modelId': modelId, + 'modelName': modelName, + 'framework': framework, + }, + ); + } + + /// Emit extraction completed event + Future emitExtractionCompleted({ + required String modelId, + required String modelName, + required String framework, + required int durationMs, + }) async { + await trackEvent( + eventType: RacEventType.extractionCompleted, + data: { + 'modelId': modelId, + 'modelName': modelName, + 'framework': framework, + 'durationMs': durationMs, + }, + ); + } + + /// Emit SDK initialized event + Future emitSDKInitialized({ + required int durationMs, + required String environment, + }) async { + await trackEvent( + eventType: RacEventType.sdkInitialized, + data: { + 'durationMs': durationMs, + 'environment': environment, + }, + ); + } + + /// Emit model loaded event + Future emitModelLoaded({ + required String modelId, + required String modelName, + required String framework, + required int durationMs, + }) async { + await trackEvent( + eventType: RacEventType.modelLoaded, + data: { + 'modelId': modelId, + 'modelName': modelName, + 'framework': framework, + 'durationMs': durationMs, + }, + ); + } + + /// Emit inference completed event + Future emitInferenceCompleted({ + required String modelId, + required String modelName, + required String modality, + required int durationMs, + int? tokensGenerated, + double? tokensPerSecond, + }) async { + await trackEvent( + eventType: RacEventType.inferenceCompleted, + data: { + 'modelId': modelId, + 'modelName': modelName, + 'modality': modality, + 'durationMs': durationMs, + if (tokensGenerated != null) 'tokensGenerated': tokensGenerated, + if (tokensPerSecond != null) 'tokensPerSecond': tokensPerSecond, + }, + ); + } + + // ============================================================================ + // Storage Events (matches Swift CppBridge.Events) + // ============================================================================ + + /// Emit storage cache cleared event + Future emitStorageCacheCleared({required int freedBytes}) async { + await trackEvent( + eventType: RacEventType.storageCacheCleared, + data: {'freedBytes': freedBytes}, + ); + } + + /// Emit storage cache clear failed event + Future emitStorageCacheClearFailed({required String error}) async { + await trackEvent( + eventType: RacEventType.storageCacheClearFailed, + data: {'error': error}, + ); + } + + /// Emit storage temp cleaned event + Future emitStorageTempCleaned({required int freedBytes}) async { + await trackEvent( + eventType: RacEventType.storageTempCleaned, + data: {'freedBytes': freedBytes}, + ); + } + + // ============================================================================ + // Voice Agent Events (matches Swift CppBridge.Events) + // ============================================================================ + + /// Emit voice agent turn started event + Future emitVoiceAgentTurnStarted() async { + await trackEvent( + eventType: RacEventType.voiceAgentTurnStarted, + data: {}, + ); + } + + /// Emit voice agent turn completed event + Future emitVoiceAgentTurnCompleted({required int durationMs}) async { + await trackEvent( + eventType: RacEventType.voiceAgentTurnCompleted, + data: {'durationMs': durationMs}, + ); + } + + /// Emit voice agent turn failed event + Future emitVoiceAgentTurnFailed({required String error}) async { + await trackEvent( + eventType: RacEventType.voiceAgentTurnFailed, + data: {'error': error}, + ); + } + + /// Emit voice agent STT state changed event + Future emitVoiceAgentSttStateChanged({required String state}) async { + await trackEvent( + eventType: RacEventType.voiceAgentSttStateChanged, + data: {'state': state}, + ); + } + + /// Emit voice agent LLM state changed event + Future emitVoiceAgentLlmStateChanged({required String state}) async { + await trackEvent( + eventType: RacEventType.voiceAgentLlmStateChanged, + data: {'state': state}, + ); + } + + /// Emit voice agent TTS state changed event + Future emitVoiceAgentTtsStateChanged({required String state}) async { + await trackEvent( + eventType: RacEventType.voiceAgentTtsStateChanged, + data: {'state': state}, + ); + } + + /// Emit voice agent all ready event + Future emitVoiceAgentAllReady() async { + await trackEvent( + eventType: RacEventType.voiceAgentAllReady, + data: {}, + ); + } + + // ============================================================================ + // Device Events (matches Swift CppBridge.Events) + // ============================================================================ + + /// Emit device registered event + Future emitDeviceRegistered({required String deviceId}) async { + await trackEvent( + eventType: RacEventType.deviceRegistered, + data: {'deviceId': deviceId}, + ); + } + + /// Emit device registration failed event + Future emitDeviceRegistrationFailed({required String error}) async { + await trackEvent( + eventType: RacEventType.deviceRegistrationFailed, + data: {'error': error}, + ); + } + + // ============================================================================ + // HTTP Callback Registration + // ============================================================================ + + static void _registerHttpCallback() { + if (_managerPtr == null) return; + + try { + final lib = PlatformLoader.loadCommons(); + final setCallback = lib.lookupFunction< + Void Function( + Pointer, + Pointer>, + Pointer), + void Function( + Pointer, + Pointer>, + Pointer)>('rac_telemetry_manager_set_http_callback'); + + _httpCallbackPtr = Pointer.fromFunction( + _telemetryHttpCallback); + + setCallback(_managerPtr!, _httpCallbackPtr!, nullptr); + _logger.debug('Telemetry HTTP callback registered'); + } catch (e) { + _logger.debug('Failed to register HTTP callback: $e'); + } + } + + // ============================================================================ + // Internal Helpers + // ============================================================================ + + static Future _getDeviceModel() async { + try { + final deviceInfo = DeviceInfoPlugin(); + + if (Platform.isIOS) { + final iosInfo = await deviceInfo.iosInfo; + return iosInfo.model; + } else if (Platform.isAndroid) { + final androidInfo = await deviceInfo.androidInfo; + return '${androidInfo.brand} ${androidInfo.model}'; + } else if (Platform.isMacOS) { + final macInfo = await deviceInfo.macOsInfo; + return macInfo.model; + } + return 'unknown'; + } catch (e) { + return 'unknown'; + } + } + + static int _environmentToInt(SDKEnvironment env) { + switch (env) { + case SDKEnvironment.development: + return 0; + case SDKEnvironment.staging: + return 1; + case SDKEnvironment.production: + return 2; + } + } + + static void _populateEventData( + Pointer data, Map? params) { + // Initialize with zeros/nulls + data.ref.modelId = nullptr; + data.ref.modelName = nullptr; + data.ref.modelSize = 0; + data.ref.framework = nullptr; + data.ref.durationMs = 0; + data.ref.error = nullptr; + + if (params == null) return; + + if (params['modelId'] != null) { + data.ref.modelId = (params['modelId'] as String).toNativeUtf8(); + } + if (params['modelName'] != null) { + data.ref.modelName = (params['modelName'] as String).toNativeUtf8(); + } + if (params['modelSize'] != null) { + data.ref.modelSize = params['modelSize'] as int; + } + if (params['framework'] != null) { + data.ref.framework = (params['framework'] as String).toNativeUtf8(); + } + if (params['durationMs'] != null) { + data.ref.durationMs = params['durationMs'] as int; + } + if (params['error'] != null) { + data.ref.error = (params['error'] as String).toNativeUtf8(); + } + } + + static void _freeEventData(Pointer data) { + if (data.ref.modelId != nullptr) calloc.free(data.ref.modelId); + if (data.ref.modelName != nullptr) calloc.free(data.ref.modelName); + if (data.ref.framework != nullptr) calloc.free(data.ref.framework); + if (data.ref.error != nullptr) calloc.free(data.ref.error); + } +} + +// ============================================================================= +// HTTP Callback Function +// ============================================================================= + +/// HTTP callback invoked by C++ when telemetry needs to be sent +void _telemetryHttpCallback( + Pointer userData, + Pointer endpoint, + Pointer jsonBody, + int jsonLength, + int requiresAuth, +) { + if (endpoint == nullptr || jsonBody == nullptr) return; + + try { + final endpointStr = endpoint.toDartString(); + final bodyStr = jsonBody.toDartString(); + final needsAuth = requiresAuth != 0; + + // Fire and forget HTTP call + unawaited(_sendTelemetryHttp(endpointStr, bodyStr, needsAuth)); + } catch (e) { + SDKLogger('DartBridge.Telemetry').error('HTTP callback error: $e'); + } +} + +/// Send telemetry via HTTP +Future _sendTelemetryHttp( + String endpoint, String body, bool requiresAuth) async { + try { + final baseURL = + DartBridgeTelemetry._baseURL ?? 'https://api.runanywhere.ai'; + final url = Uri.parse('$baseURL$endpoint'); + + final headers = { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }; + + if (requiresAuth && DartBridgeTelemetry._accessToken != null) { + headers['Authorization'] = 'Bearer ${DartBridgeTelemetry._accessToken}'; + } + + final response = await http.post(url, headers: headers, body: body); + + // Notify C++ of completion (optional - for retry logic) + _notifyHttpComplete( + response.statusCode >= 200 && response.statusCode < 300, + response.body, + null, + ); + } catch (e) { + _notifyHttpComplete(false, null, e.toString()); + } +} + +/// Notify C++ of HTTP completion +void _notifyHttpComplete(bool success, String? responseJson, String? error) { + if (DartBridgeTelemetry._managerPtr == null) return; + + try { + final lib = PlatformLoader.loadCommons(); + final httpComplete = lib.lookupFunction< + Void Function(Pointer, Int32, Pointer, Pointer), + void Function(Pointer, int, Pointer, + Pointer)>('rac_telemetry_manager_http_complete'); + + final responsePtr = responseJson?.toNativeUtf8() ?? nullptr; + final errorPtr = error?.toNativeUtf8() ?? nullptr; + + try { + httpComplete( + DartBridgeTelemetry._managerPtr!, + success ? 1 : 0, + responsePtr.cast(), + errorPtr.cast(), + ); + } finally { + if (responsePtr != nullptr) calloc.free(responsePtr); + if (errorPtr != nullptr) calloc.free(errorPtr); + } + } catch (e) { + // Ignore - best effort notification + } +} + +// ============================================================================= +// FFI Types +// ============================================================================= + +/// HTTP callback type: void (*callback)(void*, const char*, const char*, size_t, rac_bool_t) +typedef RacTelemetryHttpCallbackNative = Void Function( + Pointer, Pointer, Pointer, IntPtr, Int32); + +/// Analytics event data struct +base class RacAnalyticsEventDataStruct extends Struct { + external Pointer modelId; + external Pointer modelName; + + @Int64() + external int modelSize; + + external Pointer framework; + + @Int64() + external int durationMs; + + external Pointer error; +} + +/// Event type constants (match rac_event_type_t from rac_analytics_events.h) +abstract class RacEventType { + // SDK lifecycle (1-9) + static const int sdkInitialized = 1; + static const int sdkShutdown = 2; + + // Download events (10-19) + static const int downloadStarted = 10; + static const int downloadProgress = 11; + static const int downloadCompleted = 12; + static const int downloadFailed = 13; + static const int downloadCancelled = 14; + + // Extraction events (20-29) + static const int extractionStarted = 20; + static const int extractionProgress = 21; + static const int extractionCompleted = 22; + static const int extractionFailed = 23; + + // Model events (30-39) + static const int modelLoaded = 30; + static const int modelUnloaded = 31; + static const int modelLoadFailed = 32; + + // Inference events (40-49) + static const int inferenceStarted = 40; + static const int inferenceCompleted = 41; + static const int inferenceFailed = 42; + static const int inferenceCancelled = 43; + + // Voice Agent events (500-519) + static const int voiceAgentTurnStarted = 500; + static const int voiceAgentTurnCompleted = 501; + static const int voiceAgentTurnFailed = 502; + static const int voiceAgentSttStateChanged = 510; + static const int voiceAgentLlmStateChanged = 511; + static const int voiceAgentTtsStateChanged = 512; + static const int voiceAgentAllReady = 513; + + // Storage events (800-809) + static const int storageCacheCleared = 800; + static const int storageCacheClearFailed = 801; + static const int storageTempCleaned = 802; + + // Device events (900-909) + static const int deviceRegistered = 900; + static const int deviceRegistrationFailed = 901; +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_tts.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_tts.dart new file mode 100644 index 000000000..68dea8c3f --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_tts.dart @@ -0,0 +1,447 @@ +/// DartBridge+TTS +/// +/// TTS component bridge - manages C++ TTS component lifecycle. +/// Mirrors Swift's CppBridge+TTS.swift pattern. +library dart_bridge_tts; + +import 'dart:ffi'; +import 'dart:isolate'; +import 'dart:typed_data'; + +import 'package:ffi/ffi.dart'; + +import 'package:runanywhere/foundation/logging/sdk_logger.dart'; +import 'package:runanywhere/native/ffi_types.dart'; +import 'package:runanywhere/native/platform_loader.dart'; + +/// TTS component bridge for C++ interop. +/// +/// Provides thread-safe access to the C++ TTS component. +/// Handles voice loading, synthesis, and streaming. +/// +/// Usage: +/// ```dart +/// final tts = DartBridgeTTS.shared; +/// await tts.loadVoice('/path/to/voice', 'voice-id', 'Voice Name'); +/// final audio = await tts.synthesize('Hello world'); +/// ``` +class DartBridgeTTS { + // MARK: - Singleton + + /// Shared instance + static final DartBridgeTTS shared = DartBridgeTTS._(); + + DartBridgeTTS._(); + + // MARK: - State + + RacHandle? _handle; + String? _loadedVoiceId; + final _logger = SDKLogger('DartBridge.TTS'); + + // MARK: - Handle Management + + /// Get or create the TTS component handle. + RacHandle getHandle() { + if (_handle != null) { + return _handle!; + } + + try { + final lib = PlatformLoader.loadCommons(); + final create = lib.lookupFunction), + int Function(Pointer)>('rac_tts_component_create'); + + final handlePtr = calloc(); + try { + final result = create(handlePtr); + + if (result != RAC_SUCCESS) { + throw StateError( + 'Failed to create TTS component: ${RacResultCode.getMessage(result)}', + ); + } + + _handle = handlePtr.value; + _logger.debug('TTS component created'); + return _handle!; + } finally { + calloc.free(handlePtr); + } + } catch (e) { + _logger.error('Failed to create TTS handle: $e'); + rethrow; + } + } + + // MARK: - State Queries + + /// Check if a voice is loaded. + bool get isLoaded { + if (_handle == null) return false; + + try { + final lib = PlatformLoader.loadCommons(); + final isLoadedFn = lib.lookupFunction('rac_tts_component_is_loaded'); + + return isLoadedFn(_handle!) == RAC_TRUE; + } catch (e) { + _logger.debug('isLoaded check failed: $e'); + return false; + } + } + + /// Get the currently loaded voice ID. + String? get currentVoiceId => _loadedVoiceId; + + // MARK: - Voice Lifecycle + + /// Load a TTS voice. + /// + /// [voicePath] - Full path to the voice model. + /// [voiceId] - Unique identifier for the voice. + /// [voiceName] - Human-readable name. + /// + /// Throws on failure. + Future loadVoice( + String voicePath, + String voiceId, + String voiceName, + ) async { + final handle = getHandle(); + + final pathPtr = voicePath.toNativeUtf8(); + final idPtr = voiceId.toNativeUtf8(); + final namePtr = voiceName.toNativeUtf8(); + + try { + final lib = PlatformLoader.loadCommons(); + final loadVoiceFn = lib.lookupFunction< + Int32 Function( + RacHandle, Pointer, Pointer, Pointer), + int Function(RacHandle, Pointer, Pointer, + Pointer)>('rac_tts_component_load_voice'); + + final result = loadVoiceFn(handle, pathPtr, idPtr, namePtr); + + if (result != RAC_SUCCESS) { + throw StateError( + 'Failed to load TTS voice: ${RacResultCode.getMessage(result)}', + ); + } + + _loadedVoiceId = voiceId; + _logger.info('TTS voice loaded: $voiceId'); + } finally { + calloc.free(pathPtr); + calloc.free(idPtr); + calloc.free(namePtr); + } + } + + /// Unload the current voice. + void unload() { + if (_handle == null) return; + + try { + final lib = PlatformLoader.loadCommons(); + final cleanupFn = lib.lookupFunction('rac_tts_component_cleanup'); + + cleanupFn(_handle!); + _loadedVoiceId = null; + _logger.info('TTS voice unloaded'); + } catch (e) { + _logger.error('Failed to unload TTS voice: $e'); + } + } + + /// Stop ongoing synthesis. + void stop() { + if (_handle == null) return; + + try { + final lib = PlatformLoader.loadCommons(); + final stopFn = lib.lookupFunction('rac_tts_component_stop'); + + stopFn(_handle!); + _logger.debug('TTS synthesis stopped'); + } catch (e) { + _logger.error('Failed to stop TTS: $e'); + } + } + + // MARK: - Synthesis + + /// Synthesize speech from text. + /// + /// [text] - Text to synthesize. + /// [rate] - Speech rate (0.5 to 2.0, 1.0 is normal). + /// [pitch] - Speech pitch (0.5 to 2.0, 1.0 is normal). + /// [volume] - Speech volume (0.0 to 1.0). + /// + /// Returns audio data and metadata. + /// Runs in a background isolate to prevent UI blocking. + Future synthesize( + String text, { + double rate = 1.0, + double pitch = 1.0, + double volume = 1.0, + }) async { + final handle = getHandle(); + + if (!isLoaded) { + throw StateError('No TTS voice loaded. Call loadVoice() first.'); + } + + _logger.debug( + 'Synthesizing "${text.substring(0, text.length.clamp(0, 50))}..." in background isolate'); + + // Run synthesis in background isolate + final result = await Isolate.run(() => _synthesizeInIsolate( + handle.address, + text, + rate, + pitch, + volume, + )); + + _logger.info( + 'Synthesis complete: ${result.samples.length} samples, ${result.sampleRate} Hz, ${result.durationMs}ms'); + + return result; + } + + /// Static helper to perform FFI synthesis in isolate. + /// Must be static/top-level for Isolate.run(). + static TTSComponentResult _synthesizeInIsolate( + int handleAddress, + String text, + double rate, + double pitch, + double volume, + ) { + final lib = PlatformLoader.loadCommons(); + final handle = RacHandle.fromAddress(handleAddress); + + // Allocate native memory + final textPtr = text.toNativeUtf8(); + final optionsPtr = calloc(); + final resultPtr = calloc(); + + try { + // Set up options (matches Swift's TTSOptions) + final languagePtr = 'en-US'.toNativeUtf8(); + optionsPtr.ref.voice = nullptr; // Use default voice + optionsPtr.ref.language = languagePtr; + optionsPtr.ref.rate = rate; + optionsPtr.ref.pitch = pitch; + optionsPtr.ref.volume = volume; + optionsPtr.ref.audioFormat = racAudioFormatPcm; + optionsPtr.ref.sampleRate = 22050; // Piper default + optionsPtr.ref.useSsml = RAC_FALSE; + + // Get synthesize function + final synthesizeFn = lib.lookupFunction< + Int32 Function( + RacHandle, + Pointer, + Pointer, + Pointer, + ), + int Function( + RacHandle, + Pointer, + Pointer, + Pointer, + )>('rac_tts_component_synthesize'); + + final status = synthesizeFn( + handle, + textPtr, + optionsPtr, + resultPtr, + ); + + // Free the language string + calloc.free(languagePtr); + + if (status != RAC_SUCCESS) { + throw StateError( + 'TTS synthesis failed: ${RacResultCode.getMessage(status)}', + ); + } + + // Extract result before freeing + final result = resultPtr.ref; + final audioSize = result.audioSize; + final sampleRate = result.sampleRate; + final durationMs = result.durationMs; + + // Convert audio data to Float32List + // The audio data is PCM float samples + Float32List samples; + if (audioSize > 0 && result.audioData != nullptr) { + // Audio size is in bytes, each float is 4 bytes + final numSamples = audioSize ~/ 4; + final floatPtr = result.audioData.cast(); + samples = Float32List.fromList(floatPtr.asTypedList(numSamples)); + } else { + samples = Float32List(0); + } + + return TTSComponentResult( + samples: samples, + sampleRate: sampleRate, + durationMs: durationMs, + ); + } finally { + calloc.free(textPtr); + calloc.free(optionsPtr); + calloc.free(resultPtr); + } + } + + /// Synthesize with streaming. + /// + /// Returns a stream of audio chunks. + Stream synthesizeStream(String text) async* { + // For now, generate all audio and emit in chunks + final result = await synthesize(text); + + // Emit in ~100ms chunks + final samplesPerChunk = (result.sampleRate * 0.1).round(); + var offset = 0; + + while (offset < result.samples.length) { + final end = (offset + samplesPerChunk).clamp(0, result.samples.length); + final chunk = result.samples.sublist(offset, end); + + yield TTSStreamResult( + samples: chunk, + sampleRate: result.sampleRate, + isFinal: end >= result.samples.length, + ); + + offset = end; + } + } + + // MARK: - Cleanup + + /// Destroy the component and release resources. + void destroy() { + if (_handle != null) { + try { + final lib = PlatformLoader.loadCommons(); + final destroyFn = lib.lookupFunction('rac_tts_component_destroy'); + + destroyFn(_handle!); + _handle = null; + _loadedVoiceId = null; + _logger.debug('TTS component destroyed'); + } catch (e) { + _logger.error('Failed to destroy TTS component: $e'); + } + } + } +} + +/// Result from TTS synthesis. +class TTSComponentResult { + final Float32List samples; + final int sampleRate; + final int durationMs; + + const TTSComponentResult({ + required this.samples, + required this.sampleRate, + required this.durationMs, + }); + + /// Duration in seconds. + double get durationSeconds => durationMs / 1000.0; +} + +/// Streaming result from TTS synthesis. +class TTSStreamResult { + final Float32List samples; + final int sampleRate; + final bool isFinal; + + const TTSStreamResult({ + required this.samples, + required this.sampleRate, + required this.isFinal, + }); +} + +// ============================================================================= +// FFI Structs +// ============================================================================= + +/// Audio format constants (matches rac_audio_format_enum_t) +const int racAudioFormatPcm = 0; +const int racAudioFormatWav = 1; + +/// FFI struct for TTS options (matches rac_tts_options_t) +final class RacTtsOptionsStruct extends Struct { + /// Voice to use for synthesis (can be NULL for default) + external Pointer voice; + + /// Language for synthesis (BCP-47 format, e.g., "en-US") + external Pointer language; + + /// Speech rate (0.0 to 2.0, 1.0 is normal) + @Float() + external double rate; + + /// Speech pitch (0.0 to 2.0, 1.0 is normal) + @Float() + external double pitch; + + /// Speech volume (0.0 to 1.0) + @Float() + external double volume; + + /// Audio format for output + @Int32() + external int audioFormat; + + /// Sample rate for output audio in Hz + @Int32() + external int sampleRate; + + /// Whether to use SSML markup + @Int32() + external int useSsml; +} + +/// FFI struct for TTS result (matches rac_tts_result_t) +final class RacTtsResultStruct extends Struct { + /// Audio data (PCM float samples) + external Pointer audioData; + + /// Size of audio data in bytes + @IntPtr() + external int audioSize; + + /// Audio format + @Int32() + external int audioFormat; + + /// Sample rate + @Int32() + external int sampleRate; + + /// Duration in milliseconds + @Int64() + external int durationMs; + + /// Processing time in milliseconds + @Int64() + external int processingTimeMs; +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_vad.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_vad.dart new file mode 100644 index 000000000..8937f6c18 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_vad.dart @@ -0,0 +1,392 @@ +/// DartBridge+VAD +/// +/// VAD component bridge - manages C++ VAD component lifecycle. +/// Mirrors Swift's CppBridge+VAD.swift pattern. +library dart_bridge_vad; + +import 'dart:async'; +import 'dart:ffi'; +import 'dart:typed_data'; + +import 'package:ffi/ffi.dart'; + +import 'package:runanywhere/foundation/logging/sdk_logger.dart'; +import 'package:runanywhere/native/ffi_types.dart'; +import 'package:runanywhere/native/platform_loader.dart'; + +/// VAD component bridge for C++ interop. +/// +/// Provides thread-safe access to the C++ VAD component. +/// Handles voice activity detection with configurable thresholds. +/// +/// Usage: +/// ```dart +/// final vad = DartBridgeVAD.shared; +/// vad.initialize(); +/// vad.start(); +/// final isSpeech = vad.process(audioSamples); +/// ``` +class DartBridgeVAD { + // MARK: - Singleton + + /// Shared instance + static final DartBridgeVAD shared = DartBridgeVAD._(); + + DartBridgeVAD._(); + + // MARK: - State + + RacHandle? _handle; + final _logger = SDKLogger('DartBridge.VAD'); + + /// Stream controller for speech activity events + final _activityController = StreamController.broadcast(); + + /// Stream of speech activity events + Stream get activityStream => _activityController.stream; + + // MARK: - Handle Management + + /// Get or create the VAD component handle. + RacHandle getHandle() { + if (_handle != null) { + return _handle!; + } + + try { + final lib = PlatformLoader.loadCommons(); + final create = lib.lookupFunction< + Int32 Function(Pointer), + int Function(Pointer)>('rac_vad_component_create'); + + final handlePtr = calloc(); + try { + final result = create(handlePtr); + + if (result != RAC_SUCCESS) { + throw StateError( + 'Failed to create VAD component: ${RacResultCode.getMessage(result)}', + ); + } + + _handle = handlePtr.value; + _logger.debug('VAD component created'); + return _handle!; + } finally { + calloc.free(handlePtr); + } + } catch (e) { + _logger.error('Failed to create VAD handle: $e'); + rethrow; + } + } + + // MARK: - State Queries + + /// Check if VAD is initialized. + bool get isInitialized { + if (_handle == null) return false; + + try { + final lib = PlatformLoader.loadCommons(); + final isInitializedFn = lib.lookupFunction('rac_vad_component_is_initialized'); + + return isInitializedFn(_handle!) == RAC_TRUE; + } catch (e) { + _logger.debug('isInitialized check failed: $e'); + return false; + } + } + + /// Check if speech is currently detected. + bool get isSpeechActive { + if (_handle == null) return false; + + try { + final lib = PlatformLoader.loadCommons(); + final isSpeechActiveFn = lib.lookupFunction('rac_vad_component_is_speech_active'); + + return isSpeechActiveFn(_handle!) == RAC_TRUE; + } catch (e) { + return false; + } + } + + /// Get current energy threshold. + double get energyThreshold { + if (_handle == null) return 0.0; + + try { + final lib = PlatformLoader.loadCommons(); + final getThresholdFn = lib.lookupFunction('rac_vad_component_get_energy_threshold'); + + return getThresholdFn(_handle!); + } catch (e) { + return 0.0; + } + } + + /// Set energy threshold. + set energyThreshold(double threshold) { + if (_handle == null) return; + + try { + final lib = PlatformLoader.loadCommons(); + final setThresholdFn = lib.lookupFunction< + Int32 Function(RacHandle, Float), + int Function( + RacHandle, double)>('rac_vad_component_set_energy_threshold'); + + setThresholdFn(_handle!, threshold); + } catch (e) { + _logger.error('Failed to set energy threshold: $e'); + } + } + + // MARK: - Lifecycle + + /// Initialize VAD. + /// + /// Throws on failure. + Future initialize() async { + final handle = getHandle(); + + try { + final lib = PlatformLoader.loadCommons(); + final initializeFn = lib.lookupFunction('rac_vad_component_initialize'); + + final result = initializeFn(handle); + + if (result != RAC_SUCCESS) { + throw StateError( + 'Failed to initialize VAD: ${RacResultCode.getMessage(result)}', + ); + } + + _logger.info('VAD initialized'); + } catch (e) { + _logger.error('Failed to initialize VAD: $e'); + rethrow; + } + } + + /// Start VAD processing. + void start() { + if (_handle == null) return; + + try { + final lib = PlatformLoader.loadCommons(); + final startFn = lib.lookupFunction('rac_vad_component_start'); + + final result = startFn(_handle!); + if (result != RAC_SUCCESS) { + throw StateError( + 'Failed to start VAD: ${RacResultCode.getMessage(result)}', + ); + } + + _logger.debug('VAD started'); + } catch (e) { + _logger.error('Failed to start VAD: $e'); + } + } + + /// Stop VAD processing. + void stop() { + if (_handle == null) return; + + try { + final lib = PlatformLoader.loadCommons(); + final stopFn = lib.lookupFunction('rac_vad_component_stop'); + + stopFn(_handle!); + _logger.debug('VAD stopped'); + } catch (e) { + _logger.error('Failed to stop VAD: $e'); + } + } + + /// Reset VAD state. + void reset() { + if (_handle == null) return; + + try { + final lib = PlatformLoader.loadCommons(); + final resetFn = lib.lookupFunction('rac_vad_component_reset'); + + resetFn(_handle!); + _logger.debug('VAD reset'); + } catch (e) { + _logger.error('Failed to reset VAD: $e'); + } + } + + /// Cleanup VAD. + void cleanup() { + if (_handle == null) return; + + try { + final lib = PlatformLoader.loadCommons(); + final cleanupFn = lib.lookupFunction('rac_vad_component_cleanup'); + + cleanupFn(_handle!); + _logger.info('VAD cleaned up'); + } catch (e) { + _logger.error('Failed to cleanup VAD: $e'); + } + } + + // MARK: - Processing + + /// Process audio samples for voice activity. + /// + /// [samples] - Float32 audio samples. + /// + /// Returns VAD result with speech/non-speech determination. + VADResult process(Float32List samples) { + final handle = getHandle(); + + if (!isInitialized) { + throw StateError('VAD not initialized. Call initialize() first.'); + } + + // Allocate native memory for samples + final samplesPtr = calloc(samples.length); + final resultPtr = calloc(); + + try { + // Copy samples to native memory + for (var i = 0; i < samples.length; i++) { + samplesPtr[i] = samples[i]; + } + + final lib = PlatformLoader.loadCommons(); + final processFn = lib.lookupFunction< + Int32 Function( + RacHandle, Pointer, IntPtr, Pointer), + int Function(RacHandle, Pointer, int, + Pointer)>('rac_vad_component_process'); + + final status = processFn(handle, samplesPtr, samples.length, resultPtr); + + if (status != RAC_SUCCESS) { + throw StateError( + 'VAD processing failed: ${RacResultCode.getMessage(status)}', + ); + } + + final result = resultPtr.ref; + final vadResult = VADResult( + isSpeech: result.isSpeech == RAC_TRUE, + energy: result.energy, + speechProbability: result.speechProbability, + ); + + // Emit activity event + if (vadResult.isSpeech) { + _activityController.add(VADActivityEvent.speechStarted( + energy: vadResult.energy, + probability: vadResult.speechProbability, + )); + } else { + _activityController.add(VADActivityEvent.speechEnded( + energy: vadResult.energy, + )); + } + + return vadResult; + } finally { + calloc.free(samplesPtr); + calloc.free(resultPtr); + } + } + + // MARK: - Cleanup + + /// Destroy the component and release resources. + void destroy() { + if (_handle != null) { + try { + final lib = PlatformLoader.loadCommons(); + final destroyFn = lib.lookupFunction('rac_vad_component_destroy'); + + destroyFn(_handle!); + _handle = null; + _logger.debug('VAD component destroyed'); + } catch (e) { + _logger.error('Failed to destroy VAD component: $e'); + } + } + } + + /// Dispose resources. + void dispose() { + destroy(); + unawaited(_activityController.close()); + } +} + +/// Result from VAD processing. +class VADResult { + final bool isSpeech; + final double energy; + final double speechProbability; + + const VADResult({ + required this.isSpeech, + required this.energy, + required this.speechProbability, + }); +} + +/// VAD activity event. +sealed class VADActivityEvent { + const VADActivityEvent(); + + factory VADActivityEvent.speechStarted({ + required double energy, + required double probability, + }) = VADSpeechStartedEvent; + + factory VADActivityEvent.speechEnded({required double energy}) = + VADSpeechEndedEvent; +} + +/// Speech started event. +class VADSpeechStartedEvent extends VADActivityEvent { + final double energy; + final double probability; + + const VADSpeechStartedEvent({ + required this.energy, + required this.probability, + }); +} + +/// Speech ended event. +class VADSpeechEndedEvent extends VADActivityEvent { + final double energy; + + const VADSpeechEndedEvent({required this.energy}); +} + +/// FFI struct for VAD result (matches rac_vad_result_t) +final class RacVadResultStruct extends Struct { + @Int32() + external int isSpeech; + + @Float() + external double energy; + + @Float() + external double speechProbability; +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_voice_agent.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_voice_agent.dart new file mode 100644 index 000000000..2dd613f43 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/native/dart_bridge_voice_agent.dart @@ -0,0 +1,680 @@ +/// DartBridge+VoiceAgent +/// +/// VoiceAgent component bridge - manages C++ VoiceAgent lifecycle. +/// Mirrors Swift's CppBridge+VoiceAgent.swift pattern. +library dart_bridge_voice_agent; + +import 'dart:async'; +import 'dart:ffi'; +import 'dart:isolate'; +import 'dart:typed_data'; + +import 'package:ffi/ffi.dart'; + +import 'package:runanywhere/foundation/logging/sdk_logger.dart'; +import 'package:runanywhere/native/dart_bridge_llm.dart'; +import 'package:runanywhere/native/dart_bridge_stt.dart'; +import 'package:runanywhere/native/dart_bridge_tts.dart'; +import 'package:runanywhere/native/dart_bridge_vad.dart'; +import 'package:runanywhere/native/ffi_types.dart'; +import 'package:runanywhere/native/platform_loader.dart'; + +/// Voice agent handle type (opaque pointer to rac_voice_agent struct). +typedef RacVoiceAgentHandle = Pointer; + +/// VoiceAgent component bridge for C++ interop. +/// +/// Orchestrates LLM, STT, TTS, and VAD components for voice conversations. +/// Provides a unified interface for voice agent operations. +/// +/// Usage: +/// ```dart +/// final voiceAgent = DartBridgeVoiceAgent.shared; +/// await voiceAgent.initialize(); +/// final session = await voiceAgent.startSession(); +/// await session.processAudio(audioData); +/// ``` +class DartBridgeVoiceAgent { + // MARK: - Singleton + + /// Shared instance + static final DartBridgeVoiceAgent shared = DartBridgeVoiceAgent._(); + + DartBridgeVoiceAgent._(); + + // MARK: - State + + RacVoiceAgentHandle? _handle; + final _logger = SDKLogger('DartBridge.VoiceAgent'); + + /// Event stream controller + final _eventController = StreamController.broadcast(); + + /// Stream of voice agent events + Stream get events => _eventController.stream; + + // MARK: - Handle Management + + /// Get or create the VoiceAgent handle. + /// + /// Requires LLM, STT, TTS, and VAD components to be available. + /// Uses shared component handles (matches Swift CppBridge+VoiceAgent.swift). + Future getHandle() async { + if (_handle != null) { + return _handle!; + } + + try { + final lib = PlatformLoader.loadCommons(); + + // Use shared component handles (matches Swift approach) + // This allows the voice agent to use already-loaded models from the + // individual component bridges (STT, LLM, TTS, VAD) + final llmHandle = DartBridgeLLM.shared.getHandle(); + final sttHandle = DartBridgeSTT.shared.getHandle(); + final ttsHandle = DartBridgeTTS.shared.getHandle(); + final vadHandle = DartBridgeVAD.shared.getHandle(); + + _logger.debug( + 'Creating voice agent with shared handles: LLM=$llmHandle, STT=$sttHandle, TTS=$ttsHandle, VAD=$vadHandle'); + + final create = lib.lookupFunction< + Int32 Function(RacHandle, RacHandle, RacHandle, RacHandle, + Pointer), + int Function(RacHandle, RacHandle, RacHandle, RacHandle, + Pointer)>('rac_voice_agent_create'); + + final handlePtr = calloc(); + try { + final result = + create(llmHandle, sttHandle, ttsHandle, vadHandle, handlePtr); + + if (result != RAC_SUCCESS) { + throw StateError( + 'Failed to create voice agent: ${RacResultCode.getMessage(result)}', + ); + } + + _handle = handlePtr.value; + _logger.info('Voice agent created with shared component handles'); + return _handle!; + } finally { + calloc.free(handlePtr); + } + } catch (e) { + _logger.error('Failed to create voice agent handle: $e'); + rethrow; + } + } + + // MARK: - State Queries + + /// Check if voice agent is ready. + bool get isReady { + if (_handle == null) return false; + + try { + final lib = PlatformLoader.loadCommons(); + final isReadyFn = lib.lookupFunction< + Int32 Function(RacVoiceAgentHandle, Pointer), + int Function( + RacVoiceAgentHandle, Pointer)>('rac_voice_agent_is_ready'); + + final readyPtr = calloc(); + try { + final result = isReadyFn(_handle!, readyPtr); + return result == RAC_SUCCESS && readyPtr.value == RAC_TRUE; + } finally { + calloc.free(readyPtr); + } + } catch (e) { + return false; + } + } + + /// Check if STT model is loaded. + bool get isSTTLoaded { + if (_handle == null) return false; + + try { + final lib = PlatformLoader.loadCommons(); + final isLoadedFn = lib.lookupFunction< + Int32 Function(RacVoiceAgentHandle, Pointer), + int Function(RacVoiceAgentHandle, + Pointer)>('rac_voice_agent_is_stt_loaded'); + + final loadedPtr = calloc(); + try { + final result = isLoadedFn(_handle!, loadedPtr); + return result == RAC_SUCCESS && loadedPtr.value == RAC_TRUE; + } finally { + calloc.free(loadedPtr); + } + } catch (e) { + return false; + } + } + + /// Check if LLM model is loaded. + bool get isLLMLoaded { + if (_handle == null) return false; + + try { + final lib = PlatformLoader.loadCommons(); + final isLoadedFn = lib.lookupFunction< + Int32 Function(RacVoiceAgentHandle, Pointer), + int Function(RacVoiceAgentHandle, + Pointer)>('rac_voice_agent_is_llm_loaded'); + + final loadedPtr = calloc(); + try { + final result = isLoadedFn(_handle!, loadedPtr); + return result == RAC_SUCCESS && loadedPtr.value == RAC_TRUE; + } finally { + calloc.free(loadedPtr); + } + } catch (e) { + return false; + } + } + + /// Check if TTS voice is loaded. + bool get isTTSLoaded { + if (_handle == null) return false; + + try { + final lib = PlatformLoader.loadCommons(); + final isLoadedFn = lib.lookupFunction< + Int32 Function(RacVoiceAgentHandle, Pointer), + int Function(RacVoiceAgentHandle, + Pointer)>('rac_voice_agent_is_tts_loaded'); + + final loadedPtr = calloc(); + try { + final result = isLoadedFn(_handle!, loadedPtr); + return result == RAC_SUCCESS && loadedPtr.value == RAC_TRUE; + } finally { + calloc.free(loadedPtr); + } + } catch (e) { + return false; + } + } + + // MARK: - Model Loading + + /// Load STT model for voice agent. + Future loadSTTModel(String modelPath, String modelId) async { + final handle = await getHandle(); + + final pathPtr = modelPath.toNativeUtf8(); + final idPtr = modelId.toNativeUtf8(); + + try { + final lib = PlatformLoader.loadCommons(); + final loadFn = lib.lookupFunction< + Int32 Function(RacVoiceAgentHandle, Pointer, Pointer), + int Function(RacVoiceAgentHandle, Pointer, + Pointer)>('rac_voice_agent_load_stt_model'); + + final result = loadFn(handle, pathPtr, idPtr); + + if (result != RAC_SUCCESS) { + throw StateError( + 'Failed to load STT model: ${RacResultCode.getMessage(result)}', + ); + } + + _logger.info('Voice agent STT model loaded: $modelId'); + _eventController.add(const VoiceAgentModelLoadedEvent(component: 'stt')); + } finally { + calloc.free(pathPtr); + calloc.free(idPtr); + } + } + + /// Load LLM model for voice agent. + Future loadLLMModel(String modelPath, String modelId) async { + final handle = await getHandle(); + + final pathPtr = modelPath.toNativeUtf8(); + final idPtr = modelId.toNativeUtf8(); + + try { + final lib = PlatformLoader.loadCommons(); + final loadFn = lib.lookupFunction< + Int32 Function(RacVoiceAgentHandle, Pointer, Pointer), + int Function(RacVoiceAgentHandle, Pointer, + Pointer)>('rac_voice_agent_load_llm_model'); + + final result = loadFn(handle, pathPtr, idPtr); + + if (result != RAC_SUCCESS) { + throw StateError( + 'Failed to load LLM model: ${RacResultCode.getMessage(result)}', + ); + } + + _logger.info('Voice agent LLM model loaded: $modelId'); + _eventController.add(const VoiceAgentModelLoadedEvent(component: 'llm')); + } finally { + calloc.free(pathPtr); + calloc.free(idPtr); + } + } + + /// Load TTS voice for voice agent. + Future loadTTSVoice(String voicePath, String voiceId) async { + final handle = await getHandle(); + + final pathPtr = voicePath.toNativeUtf8(); + final idPtr = voiceId.toNativeUtf8(); + + try { + final lib = PlatformLoader.loadCommons(); + final loadFn = lib.lookupFunction< + Int32 Function(RacVoiceAgentHandle, Pointer, Pointer), + int Function(RacVoiceAgentHandle, Pointer, + Pointer)>('rac_voice_agent_load_tts_voice'); + + final result = loadFn(handle, pathPtr, idPtr); + + if (result != RAC_SUCCESS) { + throw StateError( + 'Failed to load TTS voice: ${RacResultCode.getMessage(result)}', + ); + } + + _logger.info('Voice agent TTS voice loaded: $voiceId'); + _eventController.add(const VoiceAgentModelLoadedEvent(component: 'tts')); + } finally { + calloc.free(pathPtr); + calloc.free(idPtr); + } + } + + // MARK: - Initialization + + /// Initialize voice agent with loaded models. + /// + /// Call after loading all required models (STT, LLM, TTS). + Future initializeWithLoadedModels() async { + final handle = await getHandle(); + + try { + final lib = PlatformLoader.loadCommons(); + final initFn = lib.lookupFunction( + 'rac_voice_agent_initialize_with_loaded_models'); + + final result = initFn(handle); + + if (result != RAC_SUCCESS) { + throw StateError( + 'Failed to initialize voice agent: ${RacResultCode.getMessage(result)}', + ); + } + + _logger.info('Voice agent initialized with loaded models'); + _eventController.add(const VoiceAgentInitializedEvent()); + } catch (e) { + _logger.error('Failed to initialize voice agent: $e'); + rethrow; + } + } + + // MARK: - Voice Turn Processing + + /// Process a complete voice turn. + /// + /// [audioData] - Complete audio data for the user's utterance (PCM16 bytes). + /// + /// Returns the voice turn result with transcription, response, and audio. + /// NOTE: This runs the entire STT -> LLM -> TTS pipeline, so it should be + /// called from a background isolate to avoid blocking the UI. + Future processVoiceTurn(Uint8List audioData) async { + final handle = await getHandle(); + + if (!isReady) { + throw StateError( + 'Voice agent not ready. Load models and initialize first.'); + } + + // Run the heavy C++ processing in a background isolate + return Isolate.run( + () => _processVoiceTurnInIsolate(handle, audioData)); + } + + /// Static helper for processing voice turn in an isolate. + /// The C++ API expects raw audio bytes (PCM16), not float samples. + static Future _processVoiceTurnInIsolate( + RacVoiceAgentHandle handle, + Uint8List audioData, + ) async { + // Allocate native memory for audio data (raw PCM16 bytes) + final audioPtr = calloc(audioData.length); + final resultPtr = calloc(); + + try { + // Efficient bulk copy of audio bytes + audioPtr.asTypedList(audioData.length).setAll(0, audioData); + + final lib = PlatformLoader.loadCommons(); + final processFn = lib.lookupFunction< + Int32 Function(RacVoiceAgentHandle, Pointer, IntPtr, + Pointer), + int Function(RacVoiceAgentHandle, Pointer, int, + Pointer)>( + 'rac_voice_agent_process_voice_turn'); + + final status = + processFn(handle, audioPtr.cast(), audioData.length, resultPtr); + + if (status != RAC_SUCCESS) { + throw StateError( + 'Voice turn processing failed: ${RacResultCode.getMessage(status)}', + ); + } + + // Parse result while still in isolate (before freeing memory) + return _parseVoiceTurnResultStatic(resultPtr.ref, lib); + } finally { + // Free audio data + calloc.free(audioPtr); + + // Free result struct - the C++ side allocates strings/audio that need freeing + final lib = PlatformLoader.loadCommons(); + try { + final freeFn = lib.lookupFunction< + Void Function(Pointer), + void Function(Pointer)>( + 'rac_voice_agent_result_free', + ); + freeFn(resultPtr); + } catch (e) { + // Function may not exist, just free the struct + } + calloc.free(resultPtr); + } + } + + /// Static helper to parse voice turn result (can be called from isolate). + /// The C++ voice agent already converts TTS output to WAV format internally + /// using rac_audio_float32_to_wav, so synthesized_audio is WAV data. + static VoiceTurnResult _parseVoiceTurnResultStatic( + RacVoiceAgentResultStruct result, + DynamicLibrary lib, + ) { + final transcription = result.transcription != nullptr + ? result.transcription.toDartString() + : ''; + final response = + result.response != nullptr ? result.response.toDartString() : ''; + + // The synthesized audio is WAV format (C++ voice agent converts Float32 to WAV) + // Just copy the raw bytes - no conversion needed + Uint8List audioWavData; + if (result.synthesizedAudioSize > 0 && result.synthesizedAudio != nullptr) { + audioWavData = Uint8List.fromList( + result.synthesizedAudio.cast().asTypedList(result.synthesizedAudioSize), + ); + } else { + audioWavData = Uint8List(0); + } + + return VoiceTurnResult( + transcription: transcription, + response: response, + audioWavData: audioWavData, + // Duration fields not available in C++ struct - use 0 + sttDurationMs: 0, + llmDurationMs: 0, + ttsDurationMs: 0, + ); + } + + /// Transcribe audio using voice agent. + /// Audio data should be raw PCM16 bytes. + Future transcribe(Uint8List audioData) async { + final handle = await getHandle(); + + // Pass raw audio bytes - C++ handles conversion + final audioPtr = calloc(audioData.length); + final resultPtr = calloc>(); + + try { + // Efficient bulk copy of audio bytes + audioPtr.asTypedList(audioData.length).setAll(0, audioData); + + final lib = PlatformLoader.loadCommons(); + final transcribeFn = lib.lookupFunction< + Int32 Function(RacVoiceAgentHandle, Pointer, IntPtr, + Pointer>), + int Function(RacVoiceAgentHandle, Pointer, int, + Pointer>)>('rac_voice_agent_transcribe'); + + final status = transcribeFn( + handle, audioPtr.cast(), audioData.length, resultPtr); + + if (status != RAC_SUCCESS) { + throw StateError( + 'Transcription failed: ${RacResultCode.getMessage(status)}'); + } + + return resultPtr.value != nullptr ? resultPtr.value.toDartString() : ''; + } finally { + calloc.free(audioPtr); + calloc.free(resultPtr); + } + } + + /// Generate LLM response using voice agent. + Future generateResponse(String prompt) async { + final handle = await getHandle(); + + final promptPtr = prompt.toNativeUtf8(); + final resultPtr = calloc>(); + + try { + final lib = PlatformLoader.loadCommons(); + final generateFn = lib.lookupFunction< + Int32 Function( + RacVoiceAgentHandle, Pointer, Pointer>), + int Function(RacVoiceAgentHandle, Pointer, + Pointer>)>('rac_voice_agent_generate_response'); + + final status = generateFn(handle, promptPtr, resultPtr); + + if (status != RAC_SUCCESS) { + throw StateError( + 'Response generation failed: ${RacResultCode.getMessage(status)}'); + } + + return resultPtr.value != nullptr ? resultPtr.value.toDartString() : ''; + } finally { + calloc.free(promptPtr); + calloc.free(resultPtr); + } + } + + /// Synthesize speech using voice agent. + /// Returns Float32 audio samples. + Future synthesizeSpeech(String text) async { + final handle = await getHandle(); + + final textPtr = text.toNativeUtf8(); + final audioPtr = calloc>(); + final audioSizePtr = calloc(); + + try { + final lib = PlatformLoader.loadCommons(); + final synthesizeFn = lib.lookupFunction< + Int32 Function(RacVoiceAgentHandle, Pointer, + Pointer>, Pointer), + int Function( + RacVoiceAgentHandle, + Pointer, + Pointer>, + Pointer)>('rac_voice_agent_synthesize_speech'); + + final status = synthesizeFn(handle, textPtr, audioPtr, audioSizePtr); + + if (status != RAC_SUCCESS) { + throw StateError( + 'Speech synthesis failed: ${RacResultCode.getMessage(status)}'); + } + + // Audio data is float32 samples (4 bytes per sample) + final audioSize = audioSizePtr.value; + final numSamples = audioSize ~/ 4; + if (numSamples > 0 && audioPtr.value != nullptr) { + final samples = audioPtr.value.cast().asTypedList(numSamples); + return Float32List.fromList(samples); + } + return Float32List(0); + } finally { + calloc.free(textPtr); + // Free the audio data allocated by C++ + if (audioPtr.value != nullptr) { + final lib = PlatformLoader.loadCommons(); + try { + final freeFn = lib.lookupFunction), + void Function(Pointer)>('rac_free'); + freeFn(audioPtr.value); + } catch (_) { + // rac_free may not exist + } + } + calloc.free(audioPtr); + calloc.free(audioSizePtr); + } + } + + // MARK: - Cleanup + + /// Cleanup voice agent. + void cleanup() { + if (_handle == null) return; + + try { + final lib = PlatformLoader.loadCommons(); + final cleanupFn = lib.lookupFunction('rac_voice_agent_cleanup'); + + cleanupFn(_handle!); + _logger.info('Voice agent cleaned up'); + } catch (e) { + _logger.error('Failed to cleanup voice agent: $e'); + } + } + + /// Destroy voice agent. + void destroy() { + if (_handle != null) { + try { + final lib = PlatformLoader.loadCommons(); + final destroyFn = lib.lookupFunction('rac_voice_agent_destroy'); + + destroyFn(_handle!); + _handle = null; + _logger.debug('Voice agent destroyed'); + } catch (e) { + _logger.error('Failed to destroy voice agent: $e'); + } + } + } + + /// Dispose resources. + void dispose() { + destroy(); + unawaited(_eventController.close()); + } + + // MARK: - Helpers +} + +// MARK: - Result Types + +/// Result from a complete voice turn. +/// Audio is in WAV format (C++ voice agent converts Float32 TTS output to WAV). +class VoiceTurnResult { + final String transcription; + final String response; + /// WAV-formatted audio data ready for playback + final Uint8List audioWavData; + final int sttDurationMs; + final int llmDurationMs; + final int ttsDurationMs; + + const VoiceTurnResult({ + required this.transcription, + required this.response, + required this.audioWavData, + required this.sttDurationMs, + required this.llmDurationMs, + required this.ttsDurationMs, + }); + + int get totalDurationMs => sttDurationMs + llmDurationMs + ttsDurationMs; +} + +// MARK: - Events + +/// Voice agent event base. +sealed class VoiceAgentEvent { + const VoiceAgentEvent(); +} + +/// Voice agent initialized. +class VoiceAgentInitializedEvent extends VoiceAgentEvent { + const VoiceAgentInitializedEvent(); +} + +/// Voice agent model loaded. +class VoiceAgentModelLoadedEvent extends VoiceAgentEvent { + final String component; // 'stt', 'llm', or 'tts' + const VoiceAgentModelLoadedEvent({required this.component}); +} + +/// Voice agent turn started. +class VoiceAgentTurnStartedEvent extends VoiceAgentEvent { + const VoiceAgentTurnStartedEvent(); +} + +/// Voice agent turn completed. +class VoiceAgentTurnCompletedEvent extends VoiceAgentEvent { + final VoiceTurnResult result; + const VoiceAgentTurnCompletedEvent({required this.result}); +} + +/// Voice agent error. +class VoiceAgentErrorEvent extends VoiceAgentEvent { + final String error; + const VoiceAgentErrorEvent({required this.error}); +} + +// MARK: - FFI Structs + +/// FFI struct for voice agent result (matches rac_voice_agent_result_t). +/// MUST match exact layout of C struct: +/// typedef struct rac_voice_agent_result { +/// rac_bool_t speech_detected; +/// char* transcription; +/// char* response; +/// void* synthesized_audio; +/// size_t synthesized_audio_size; +/// } rac_voice_agent_result_t; +final class RacVoiceAgentResultStruct extends Struct { + @Int32() + external int speechDetected; // rac_bool_t + + external Pointer transcription; // char* + + external Pointer response; // char* + + external Pointer synthesizedAudio; // void* (raw audio bytes) + + @IntPtr() + external int synthesizedAudioSize; // size_t (size in bytes) +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/native/ffi_types.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/native/ffi_types.dart new file mode 100644 index 000000000..600ec0833 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/native/ffi_types.dart @@ -0,0 +1,1028 @@ +// ignore_for_file: non_constant_identifier_names, constant_identifier_names + +import 'dart:ffi'; + +import 'package:ffi/ffi.dart'; + +/// ============================================================================= +/// RunAnywhere Commons FFI Type Definitions +/// +/// Dart FFI types matching the C API defined in rac_*.h headers +/// from runanywhere-commons library. +/// ============================================================================= + +// ============================================================================= +// Basic Types (from rac_types.h) +// ============================================================================= + +/// Opaque handle for internal objects (rac_handle_t) +typedef RacHandle = Pointer; + +/// Result type for all RAC functions (rac_result_t) +/// 0 = success, negative = error +typedef RacResult = Int32; + +/// Boolean type for C compatibility (rac_bool_t) +typedef RacBool = Int32; + +/// RAC boolean values +const int RAC_TRUE = 1; +const int RAC_FALSE = 0; + +/// RAC success value +const int RAC_SUCCESS = 0; + +// ============================================================================= +// Result Codes (from rac_error.h) +// ============================================================================= + +/// Error codes matching rac_error.h +abstract class RacResultCode { + // Success + static const int success = 0; + + // Initialization errors (-100 to -109) + static const int errorNotInitialized = -100; + static const int errorAlreadyInitialized = -101; + static const int errorInitializationFailed = -102; + static const int errorInvalidConfiguration = -103; + static const int errorInvalidApiKey = -104; + static const int errorEnvironmentMismatch = -105; + static const int errorInvalidParameter = -106; + + // Model errors (-110 to -129) + static const int errorModelNotFound = -110; + static const int errorModelLoadFailed = -111; + static const int errorModelValidationFailed = -112; + static const int errorModelIncompatible = -113; + static const int errorInvalidModelFormat = -114; + static const int errorModelStorageCorrupted = -115; + static const int errorModelNotLoaded = -116; + + // Generation errors (-130 to -149) + static const int errorGenerationFailed = -130; + static const int errorGenerationTimeout = -131; + static const int errorContextTooLong = -132; + static const int errorTokenLimitExceeded = -133; + static const int errorCostLimitExceeded = -134; + static const int errorInferenceFailed = -135; + + // Network errors (-150 to -179) + static const int errorNetworkUnavailable = -150; + static const int errorNetworkError = -151; + static const int errorRequestFailed = -152; + static const int errorDownloadFailed = -153; + static const int errorServerError = -154; + static const int errorTimeout = -155; + static const int errorInvalidResponse = -156; + static const int errorHttpError = -157; + static const int errorConnectionLost = -158; + static const int errorPartialDownload = -159; + + // Storage errors (-180 to -219) + static const int errorInsufficientStorage = -180; + static const int errorStorageFull = -181; + static const int errorStorageError = -182; + static const int errorFileNotFound = -183; + static const int errorFileReadFailed = -184; + static const int errorFileWriteFailed = -185; + static const int errorPermissionDenied = -186; + static const int errorDeleteFailed = -187; + static const int errorMoveFailed = -188; + static const int errorDirectoryCreationFailed = -189; + + // Hardware errors (-220 to -229) + static const int errorHardwareUnsupported = -220; + static const int errorInsufficientMemory = -221; + + // Component state errors (-230 to -249) + static const int errorComponentNotReady = -230; + static const int errorInvalidState = -231; + static const int errorServiceNotAvailable = -232; + static const int errorServiceBusy = -233; + static const int errorProcessingFailed = -234; + static const int errorStartFailed = -235; + static const int errorNotSupported = -236; + + // Validation errors (-250 to -279) + static const int errorValidationFailed = -250; + static const int errorInvalidInput = -251; + static const int errorInvalidFormat = -252; + static const int errorEmptyInput = -253; + + // Audio errors (-280 to -299) + static const int errorAudioFormatNotSupported = -280; + static const int errorAudioSessionFailed = -281; + static const int errorMicrophonePermissionDenied = -282; + static const int errorInsufficientAudioData = -283; + + // Language/voice errors (-300 to -319) + static const int errorLanguageNotSupported = -300; + static const int errorVoiceNotAvailable = -301; + static const int errorStreamingNotSupported = -302; + static const int errorStreamCancelled = -303; + + // Cancellation (-380 to -389) + static const int errorCancelled = -380; + + // Module/service errors (-400 to -499) + static const int errorModuleNotFound = -400; + static const int errorModuleAlreadyRegistered = -401; + static const int errorModuleLoadFailed = -402; + static const int errorServiceNotFound = -410; + static const int errorServiceAlreadyRegistered = -411; + static const int errorServiceCreateFailed = -412; + static const int errorCapabilityNotFound = -420; + static const int errorProviderNotFound = -421; + static const int errorNoCapableProvider = -422; + static const int errorNotFound = -423; + + // Platform adapter errors (-500 to -599) + static const int errorAdapterNotSet = -500; + + // Backend errors (-600 to -699) + static const int errorBackendNotFound = -600; + static const int errorBackendNotReady = -601; + static const int errorBackendInitFailed = -602; + static const int errorBackendBusy = -603; + static const int errorInvalidHandle = -610; + + // Other errors (-800 to -899) + static const int errorNotImplemented = -800; + static const int errorFeatureNotAvailable = -801; + static const int errorFrameworkNotAvailable = -802; + static const int errorUnsupportedModality = -803; + static const int errorUnknown = -804; + static const int errorInternal = -805; + + /// Get human-readable message for an error code + static String getMessage(int code) { + switch (code) { + case success: + return 'Success'; + case errorNotInitialized: + return 'Not initialized'; + case errorAlreadyInitialized: + return 'Already initialized'; + case errorInitializationFailed: + return 'Initialization failed'; + case errorInvalidConfiguration: + return 'Invalid configuration'; + case errorModelNotFound: + return 'Model not found'; + case errorModelLoadFailed: + return 'Model load failed'; + case errorModelNotLoaded: + return 'Model not loaded'; + case errorGenerationFailed: + return 'Generation failed'; + case errorInferenceFailed: + return 'Inference failed'; + case errorNetworkUnavailable: + return 'Network unavailable'; + case errorDownloadFailed: + return 'Download failed'; + case errorTimeout: + return 'Timeout'; + case errorFileNotFound: + return 'File not found'; + case errorInsufficientMemory: + return 'Insufficient memory'; + case errorNotSupported: + return 'Not supported'; + case errorCancelled: + return 'Cancelled'; + case errorModuleNotFound: + return 'Module not found'; + case errorModuleAlreadyRegistered: + return 'Module already registered'; + case errorServiceNotFound: + return 'Service not found'; + case errorBackendNotFound: + return 'Backend not found'; + case errorInvalidHandle: + return 'Invalid handle'; + case errorNotImplemented: + return 'Not implemented'; + case errorUnknown: + return 'Unknown error'; + default: + return 'Error (code: $code)'; + } + } +} + +/// Alias for backward compatibility +typedef RaResultCode = RacResultCode; + +// ============================================================================= +// Capability Types (from rac_types.h) +// ============================================================================= + +/// Capability types supported by backends (rac_capability_t) +abstract class RacCapability { + static const int unknown = 0; + static const int textGeneration = 1; + static const int embeddings = 2; + static const int stt = 3; + static const int tts = 4; + static const int vad = 5; + static const int diarization = 6; + + static String getName(int type) { + switch (type) { + case textGeneration: + return 'Text Generation'; + case embeddings: + return 'Embeddings'; + case stt: + return 'Speech-to-Text'; + case tts: + return 'Text-to-Speech'; + case vad: + return 'Voice Activity Detection'; + case diarization: + return 'Speaker Diarization'; + default: + return 'Unknown'; + } + } +} + +// ============================================================================= +// Device Types (from rac_types.h) +// ============================================================================= + +/// Device type for backend execution (rac_device_t) +abstract class RacDevice { + static const int cpu = 0; + static const int gpu = 1; + static const int npu = 2; + static const int auto = 3; + + static String getName(int type) { + switch (type) { + case cpu: + return 'CPU'; + case gpu: + return 'GPU'; + case npu: + return 'NPU'; + case auto: + return 'Auto'; + default: + return 'Unknown'; + } + } +} + +// ============================================================================= +// Log Levels (from rac_types.h) +// ============================================================================= + +/// Log level for logging callback (rac_log_level_t) +abstract class RacLogLevel { + static const int trace = 0; + static const int debug = 1; + static const int info = 2; + static const int warning = 3; + static const int error = 4; + static const int fatal = 5; +} + +// ============================================================================= +// Audio Format (from rac_stt_types.h) +// ============================================================================= + +/// Audio format enumeration (rac_audio_format_enum_t) +abstract class RacAudioFormat { + static const int pcm = 0; + static const int wav = 1; + static const int mp3 = 2; + static const int opus = 3; + static const int aac = 4; + static const int flac = 5; +} + +// ============================================================================= +// Speech Activity (from rac_vad_types.h) +// ============================================================================= + +/// Speech activity event type (rac_speech_activity_t) +abstract class RacSpeechActivity { + static const int started = 0; + static const int ended = 1; + static const int ongoing = 2; +} + +// ============================================================================= +// Core API Function Signatures (from rac_core.h) +// ============================================================================= + +/// rac_result_t rac_init(const rac_config_t* config) +typedef RacInitNative = Int32 Function(Pointer config); +typedef RacInitDart = int Function(Pointer config); + +/// void rac_shutdown(void) +typedef RacShutdownNative = Void Function(); +typedef RacShutdownDart = void Function(); + +/// rac_bool_t rac_is_initialized(void) +typedef RacIsInitializedNative = Int32 Function(); +typedef RacIsInitializedDart = int Function(); + +/// rac_result_t rac_configure_logging(rac_environment_t environment) +typedef RacConfigureLoggingNative = Int32 Function(Int32 environment); +typedef RacConfigureLoggingDart = int Function(int environment); + +// ============================================================================= +// Module Registration API (from rac_core.h) +// ============================================================================= + +/// rac_result_t rac_module_register(const rac_module_info_t* info) +typedef RacModuleRegisterNative = Int32 Function(Pointer info); +typedef RacModuleRegisterDart = int Function(Pointer info); + +/// rac_result_t rac_module_unregister(const char* module_id) +typedef RacModuleUnregisterNative = Int32 Function(Pointer moduleId); +typedef RacModuleUnregisterDart = int Function(Pointer moduleId); + +/// rac_result_t rac_module_list(const rac_module_info_t** out_modules, size_t* out_count) +typedef RacModuleListNative = Int32 Function( + Pointer> outModules, + Pointer outCount, +); +typedef RacModuleListDart = int Function( + Pointer> outModules, + Pointer outCount, +); + +// ============================================================================= +// Service Provider API (from rac_core.h) +// ============================================================================= + +/// rac_result_t rac_service_register_provider(const rac_service_provider_t* provider) +typedef RacServiceRegisterProviderNative = Int32 Function( + Pointer provider); +typedef RacServiceRegisterProviderDart = int Function(Pointer provider); + +/// rac_result_t rac_service_create(rac_capability_t capability, const rac_service_request_t* request, rac_handle_t* out_handle) +typedef RacServiceCreateNative = Int32 Function( + Int32 capability, + Pointer request, + Pointer outHandle, +); +typedef RacServiceCreateDart = int Function( + int capability, + Pointer request, + Pointer outHandle, +); + +// ============================================================================= +// LLM API Function Signatures (from rac_llm_llamacpp.h) +// ============================================================================= + +/// rac_result_t rac_backend_llamacpp_register(void) +typedef RacBackendLlamacppRegisterNative = Int32 Function(); +typedef RacBackendLlamacppRegisterDart = int Function(); + +/// rac_result_t rac_backend_llamacpp_unregister(void) +typedef RacBackendLlamacppUnregisterNative = Int32 Function(); +typedef RacBackendLlamacppUnregisterDart = int Function(); + +// ============================================================================= +// LLM Component API Function Signatures (from rac_llm_component.h) +// ============================================================================= + +/// rac_result_t rac_llm_component_create(rac_handle_t* out_handle) +typedef RacLlmComponentCreateNative = Int32 Function( + Pointer outHandle, +); +typedef RacLlmComponentCreateDart = int Function( + Pointer outHandle, +); + +/// rac_result_t rac_llm_component_load_model(rac_handle_t handle, const char* model_path, const char* model_id, const char* model_name) +typedef RacLlmComponentLoadModelNative = Int32 Function( + RacHandle handle, + Pointer modelPath, + Pointer modelId, + Pointer modelName, +); +typedef RacLlmComponentLoadModelDart = int Function( + RacHandle handle, + Pointer modelPath, + Pointer modelId, + Pointer modelName, +); + +/// rac_bool_t rac_llm_component_is_loaded(rac_handle_t handle) +typedef RacLlmComponentIsLoadedNative = Int32 Function(RacHandle handle); +typedef RacLlmComponentIsLoadedDart = int Function(RacHandle handle); + +/// const char* rac_llm_component_get_model_id(rac_handle_t handle) +typedef RacLlmComponentGetModelIdNative = Pointer Function( + RacHandle handle); +typedef RacLlmComponentGetModelIdDart = Pointer Function(RacHandle handle); + +/// rac_result_t rac_llm_component_generate(rac_handle_t handle, const char* prompt, const rac_llm_options_t* options, rac_llm_result_t* out_result) +typedef RacLlmComponentGenerateNative = Int32 Function( + RacHandle handle, + Pointer prompt, + Pointer options, + Pointer outResult, +); +typedef RacLlmComponentGenerateDart = int Function( + RacHandle handle, + Pointer prompt, + Pointer options, + Pointer outResult, +); + +/// LLM streaming token callback signature +/// rac_bool_t (*rac_llm_component_token_callback_fn)(const char* token, void* user_data) +typedef RacLlmComponentTokenCallbackNative = Int32 Function( + Pointer token, + Pointer userData, +); + +/// LLM streaming complete callback signature +typedef RacLlmComponentCompleteCallbackNative = Void Function( + Pointer result, + Pointer userData, +); + +/// LLM streaming error callback signature +typedef RacLlmComponentErrorCallbackNative = Void Function( + Int32 errorCode, + Pointer errorMessage, + Pointer userData, +); + +/// rac_result_t rac_llm_component_generate_stream(...) +typedef RacLlmComponentGenerateStreamNative = Int32 Function( + RacHandle handle, + Pointer prompt, + Pointer options, + Pointer> tokenCallback, + Pointer> + completeCallback, + Pointer> errorCallback, + Pointer userData, +); +typedef RacLlmComponentGenerateStreamDart = int Function( + RacHandle handle, + Pointer prompt, + Pointer options, + Pointer> tokenCallback, + Pointer> + completeCallback, + Pointer> errorCallback, + Pointer userData, +); + +/// rac_result_t rac_llm_component_cancel(rac_handle_t handle) +typedef RacLlmComponentCancelNative = Int32 Function(RacHandle handle); +typedef RacLlmComponentCancelDart = int Function(RacHandle handle); + +/// rac_result_t rac_llm_component_unload(rac_handle_t handle) +typedef RacLlmComponentUnloadNative = Int32 Function(RacHandle handle); +typedef RacLlmComponentUnloadDart = int Function(RacHandle handle); + +/// rac_result_t rac_llm_component_cleanup(rac_handle_t handle) +typedef RacLlmComponentCleanupNative = Int32 Function(RacHandle handle); +typedef RacLlmComponentCleanupDart = int Function(RacHandle handle); + +/// void rac_llm_component_destroy(rac_handle_t handle) +typedef RacLlmComponentDestroyNative = Void Function(RacHandle handle); +typedef RacLlmComponentDestroyDart = void Function(RacHandle handle); + +// Legacy aliases for backward compatibility (unused - remove after migration) +typedef RacLlmStreamCallbackNative = RacLlmComponentTokenCallbackNative; + +// ============================================================================= +// STT ONNX API Function Signatures (from rac_stt_onnx.h) +// ============================================================================= + +/// rac_result_t rac_stt_onnx_create(const char* model_path, const rac_stt_onnx_config_t* config, rac_handle_t* out_handle) +typedef RacSttOnnxCreateNative = Int32 Function( + Pointer modelPath, + Pointer config, + Pointer outHandle, +); +typedef RacSttOnnxCreateDart = int Function( + Pointer modelPath, + Pointer config, + Pointer outHandle, +); + +/// rac_result_t rac_stt_onnx_transcribe(rac_handle_t handle, const float* audio_samples, size_t num_samples, const rac_stt_options_t* options, rac_stt_result_t* out_result) +typedef RacSttOnnxTranscribeNative = Int32 Function( + RacHandle handle, + Pointer audioSamples, + IntPtr numSamples, + Pointer options, + Pointer outResult, +); +typedef RacSttOnnxTranscribeDart = int Function( + RacHandle handle, + Pointer audioSamples, + int numSamples, + Pointer options, + Pointer outResult, +); + +/// rac_bool_t rac_stt_onnx_supports_streaming(rac_handle_t handle) +typedef RacSttOnnxSupportsStreamingNative = Int32 Function(RacHandle handle); +typedef RacSttOnnxSupportsStreamingDart = int Function(RacHandle handle); + +/// rac_result_t rac_stt_onnx_create_stream(rac_handle_t handle, rac_handle_t* out_stream) +typedef RacSttOnnxCreateStreamNative = Int32 Function( + RacHandle handle, + Pointer outStream, +); +typedef RacSttOnnxCreateStreamDart = int Function( + RacHandle handle, + Pointer outStream, +); + +/// rac_result_t rac_stt_onnx_feed_audio(rac_handle_t handle, rac_handle_t stream, const float* audio_samples, size_t num_samples) +typedef RacSttOnnxFeedAudioNative = Int32 Function( + RacHandle handle, + RacHandle stream, + Pointer audioSamples, + IntPtr numSamples, +); +typedef RacSttOnnxFeedAudioDart = int Function( + RacHandle handle, + RacHandle stream, + Pointer audioSamples, + int numSamples, +); + +/// rac_bool_t rac_stt_onnx_stream_is_ready(rac_handle_t handle, rac_handle_t stream) +typedef RacSttOnnxStreamIsReadyNative = Int32 Function( + RacHandle handle, + RacHandle stream, +); +typedef RacSttOnnxStreamIsReadyDart = int Function( + RacHandle handle, + RacHandle stream, +); + +/// rac_result_t rac_stt_onnx_decode_stream(rac_handle_t handle, rac_handle_t stream, char** out_text) +typedef RacSttOnnxDecodeStreamNative = Int32 Function( + RacHandle handle, + RacHandle stream, + Pointer> outText, +); +typedef RacSttOnnxDecodeStreamDart = int Function( + RacHandle handle, + RacHandle stream, + Pointer> outText, +); + +/// void rac_stt_onnx_input_finished(rac_handle_t handle, rac_handle_t stream) +typedef RacSttOnnxInputFinishedNative = Void Function( + RacHandle handle, + RacHandle stream, +); +typedef RacSttOnnxInputFinishedDart = void Function( + RacHandle handle, + RacHandle stream, +); + +/// rac_bool_t rac_stt_onnx_is_endpoint(rac_handle_t handle, rac_handle_t stream) +typedef RacSttOnnxIsEndpointNative = Int32 Function( + RacHandle handle, + RacHandle stream, +); +typedef RacSttOnnxIsEndpointDart = int Function( + RacHandle handle, + RacHandle stream, +); + +/// void rac_stt_onnx_destroy_stream(rac_handle_t handle, rac_handle_t stream) +typedef RacSttOnnxDestroyStreamNative = Void Function( + RacHandle handle, + RacHandle stream, +); +typedef RacSttOnnxDestroyStreamDart = void Function( + RacHandle handle, + RacHandle stream, +); + +/// void rac_stt_onnx_destroy(rac_handle_t handle) +typedef RacSttOnnxDestroyNative = Void Function(RacHandle handle); +typedef RacSttOnnxDestroyDart = void Function(RacHandle handle); + +// ============================================================================= +// TTS ONNX API Function Signatures (from rac_tts_onnx.h) +// ============================================================================= + +/// rac_result_t rac_tts_onnx_create(const char* model_path, const rac_tts_onnx_config_t* config, rac_handle_t* out_handle) +typedef RacTtsOnnxCreateNative = Int32 Function( + Pointer modelPath, + Pointer config, + Pointer outHandle, +); +typedef RacTtsOnnxCreateDart = int Function( + Pointer modelPath, + Pointer config, + Pointer outHandle, +); + +/// rac_result_t rac_tts_onnx_synthesize(rac_handle_t handle, const char* text, const rac_tts_options_t* options, rac_tts_result_t* out_result) +typedef RacTtsOnnxSynthesizeNative = Int32 Function( + RacHandle handle, + Pointer text, + Pointer options, + Pointer outResult, +); +typedef RacTtsOnnxSynthesizeDart = int Function( + RacHandle handle, + Pointer text, + Pointer options, + Pointer outResult, +); + +/// rac_result_t rac_tts_onnx_get_voices(rac_handle_t handle, char*** out_voices, size_t* out_count) +typedef RacTtsOnnxGetVoicesNative = Int32 Function( + RacHandle handle, + Pointer>> outVoices, + Pointer outCount, +); +typedef RacTtsOnnxGetVoicesDart = int Function( + RacHandle handle, + Pointer>> outVoices, + Pointer outCount, +); + +/// void rac_tts_onnx_stop(rac_handle_t handle) +typedef RacTtsOnnxStopNative = Void Function(RacHandle handle); +typedef RacTtsOnnxStopDart = void Function(RacHandle handle); + +/// void rac_tts_onnx_destroy(rac_handle_t handle) +typedef RacTtsOnnxDestroyNative = Void Function(RacHandle handle); +typedef RacTtsOnnxDestroyDart = void Function(RacHandle handle); + +// ============================================================================= +// VAD ONNX Functions (from rac_vad_onnx.h) +// ============================================================================= + +/// rac_result_t rac_vad_onnx_create(const char* model_path, const rac_vad_onnx_config_t* config, rac_handle_t* out_handle) +typedef RacVadOnnxCreateNative = Int32 Function( + Pointer modelPath, + Pointer config, + Pointer outHandle, +); +typedef RacVadOnnxCreateDart = int Function( + Pointer modelPath, + Pointer config, + Pointer outHandle, +); + +/// rac_result_t rac_vad_onnx_process(rac_handle_t handle, const float* samples, size_t num_samples, rac_vad_result_t* out_result) +typedef RacVadOnnxProcessNative = Int32 Function( + RacHandle handle, + Pointer samples, + IntPtr numSamples, + Pointer outResult, +); +typedef RacVadOnnxProcessDart = int Function( + RacHandle handle, + Pointer samples, + int numSamples, + Pointer outResult, +); + +/// void rac_vad_onnx_destroy(rac_handle_t handle) +typedef RacVadOnnxDestroyNative = Void Function(RacHandle handle); +typedef RacVadOnnxDestroyDart = void Function(RacHandle handle); + +// ============================================================================= +// Memory Management (from rac_types.h) +// ============================================================================= + +/// void rac_free(void* ptr) +typedef RacFreeNative = Void Function(Pointer ptr); +typedef RacFreeDart = void Function(Pointer ptr); + +/// void* rac_alloc(size_t size) +typedef RacAllocNative = Pointer Function(IntPtr size); +typedef RacAllocDart = Pointer Function(int size); + +/// char* rac_strdup(const char* str) +typedef RacStrdupNative = Pointer Function(Pointer str); +typedef RacStrdupDart = Pointer Function(Pointer str); + +// ============================================================================= +// Error API (from rac_error.h) +// ============================================================================= + +/// const char* rac_error_message(rac_result_t error_code) +typedef RacErrorMessageNative = Pointer Function(Int32 errorCode); +typedef RacErrorMessageDart = Pointer Function(int errorCode); + +/// const char* rac_error_get_details(void) +typedef RacErrorGetDetailsNative = Pointer Function(); +typedef RacErrorGetDetailsDart = Pointer Function(); + +/// void rac_error_set_details(const char* details) +typedef RacErrorSetDetailsNative = Void Function(Pointer details); +typedef RacErrorSetDetailsDart = void Function(Pointer details); + +/// void rac_error_clear_details(void) +typedef RacErrorClearDetailsNative = Void Function(); +typedef RacErrorClearDetailsDart = void Function(); + +// ============================================================================= +// Platform Adapter Callbacks (from rac_platform_adapter.h) +// ============================================================================= + +/// File exists callback: rac_bool_t (*file_exists)(const char* path, void* user_data) +typedef RacFileExistsCallbackNative = Int32 Function( + Pointer path, + Pointer userData, +); + +/// File read callback: rac_result_t (*file_read)(const char* path, void** out_data, size_t* out_size, void* user_data) +typedef RacFileReadCallbackNative = Int32 Function( + Pointer path, + Pointer> outData, + Pointer outSize, + Pointer userData, +); + +/// File write callback: rac_result_t (*file_write)(const char* path, const void* data, size_t size, void* user_data) +typedef RacFileWriteCallbackNative = Int32 Function( + Pointer path, + Pointer data, + IntPtr size, + Pointer userData, +); + +/// File delete callback: rac_result_t (*file_delete)(const char* path, void* user_data) +typedef RacFileDeleteCallbackNative = Int32 Function( + Pointer path, + Pointer userData, +); + +/// Secure get callback: rac_result_t (*secure_get)(const char* key, char** out_value, void* user_data) +typedef RacSecureGetCallbackNative = Int32 Function( + Pointer key, + Pointer> outValue, + Pointer userData, +); + +/// Secure set callback: rac_result_t (*secure_set)(const char* key, const char* value, void* user_data) +typedef RacSecureSetCallbackNative = Int32 Function( + Pointer key, + Pointer value, + Pointer userData, +); + +/// Secure delete callback: rac_result_t (*secure_delete)(const char* key, void* user_data) +typedef RacSecureDeleteCallbackNative = Int32 Function( + Pointer key, + Pointer userData, +); + +/// Log callback: void (*log)(rac_log_level_t level, const char* category, const char* message, void* user_data) +typedef RacLogCallbackNative = Void Function( + Int32 level, + Pointer category, + Pointer message, + Pointer userData, +); + +/// Track error callback: void (*track_error)(const char* error_json, void* user_data) +typedef RacTrackErrorCallbackNative = Void Function( + Pointer errorJson, + Pointer userData, +); + +/// Now ms callback: int64_t (*now_ms)(void* user_data) +typedef RacNowMsCallbackNative = Int64 Function(Pointer userData); + +/// Get memory info callback: rac_result_t (*get_memory_info)(rac_memory_info_t* out_info, void* user_data) +typedef RacGetMemoryInfoCallbackNative = Int32 Function( + Pointer outInfo, + Pointer userData, +); + +/// HTTP progress callback: void (*progress)(int64_t bytes_downloaded, int64_t total_bytes, void* callback_user_data) +typedef RacHttpProgressCallbackNative = Void Function( + Int64 bytesDownloaded, + Int64 totalBytes, + Pointer callbackUserData, +); + +/// HTTP complete callback: void (*complete)(rac_result_t result, const char* downloaded_path, void* callback_user_data) +typedef RacHttpCompleteCallbackNative = Void Function( + Int32 result, + Pointer downloadedPath, + Pointer callbackUserData, +); + +// ============================================================================= +// Structs (using FFI Struct for native memory layout) +// ============================================================================= + +/// Platform adapter struct matching rac_platform_adapter_t +/// Note: This is a complex struct - for simplicity we use Pointer in FFI calls +/// and manage the struct manually in Dart +base class RacPlatformAdapterStruct extends Struct { + external Pointer> fileExists; + external Pointer> fileRead; + external Pointer> fileWrite; + external Pointer> fileDelete; + external Pointer> secureGet; + external Pointer> secureSet; + external Pointer> secureDelete; + external Pointer> log; + external Pointer> trackError; + external Pointer> nowMs; + external Pointer> + getMemoryInfo; + external Pointer httpDownload; + external Pointer httpDownloadCancel; + external Pointer extractArchive; + external Pointer userData; +} + +/// Memory info struct matching rac_memory_info_t +base class RacMemoryInfoStruct extends Struct { + @Uint64() + external int totalBytes; + + @Uint64() + external int availableBytes; + + @Uint64() + external int usedBytes; +} + +/// Version info struct matching rac_version_t +base class RacVersionStruct extends Struct { + @Uint16() + external int major; + + @Uint16() + external int minor; + + @Uint16() + external int patch; + + external Pointer string; +} + +/// LlamaCPP config struct matching rac_llm_llamacpp_config_t +base class RacLlmLlamacppConfigStruct extends Struct { + @Int32() + external int contextSize; + + @Int32() + external int numThreads; + + @Int32() + external int gpuLayers; + + @Int32() + external int batchSize; +} + +/// LLM options struct matching rac_llm_options_t +base class RacLlmOptionsStruct extends Struct { + @Int32() + external int maxTokens; + + @Float() + external double temperature; + + @Float() + external double topP; + + external Pointer> stopSequences; + + @IntPtr() + external int numStopSequences; + + @Int32() + external int streamingEnabled; + + external Pointer systemPrompt; +} + +/// LLM result struct matching rac_llm_result_t +base class RacLlmResultStruct extends Struct { + external Pointer text; + + @Int32() + external int promptTokens; + + @Int32() + external int completionTokens; + + @Int32() + external int totalTokens; + + @Int64() + external int timeToFirstTokenMs; + + @Int64() + external int totalTimeMs; + + @Float() + external double tokensPerSecond; +} + +/// STT ONNX config struct matching rac_stt_onnx_config_t +base class RacSttOnnxConfigStruct extends Struct { + @Int32() + external int modelType; + + @Int32() + external int numThreads; + + @Int32() + external int useCoreml; +} + +/// TTS ONNX config struct matching rac_tts_onnx_config_t +base class RacTtsOnnxConfigStruct extends Struct { + @Int32() + external int numThreads; + + @Int32() + external int useCoreml; + + @Int32() + external int sampleRate; +} + +/// STT ONNX result struct matching rac_stt_onnx_result_t +base class RacSttOnnxResultStruct extends Struct { + external Pointer text; + + @Float() + external double confidence; + + external Pointer language; + + @Int32() + external int durationMs; +} + +/// TTS ONNX result struct matching rac_tts_onnx_result_t +base class RacTtsOnnxResultStruct extends Struct { + external Pointer audioSamples; + + @Int32() + external int numSamples; + + @Int32() + external int sampleRate; + + @Int32() + external int durationMs; +} + +/// VAD ONNX config struct matching rac_vad_onnx_config_t +base class RacVadOnnxConfigStruct extends Struct { + @Int32() + external int numThreads; + + @Int32() + external int sampleRate; + + @Int32() + external int windowSizeMs; + + @Float() + external double threshold; +} + +/// VAD ONNX result struct matching rac_vad_onnx_result_t +base class RacVadOnnxResultStruct extends Struct { + @Int32() + external int isSpeech; + + @Float() + external double probability; +} + +// ============================================================================= +// Backward Compatibility Aliases +// ============================================================================= + +/// Backward compatibility: old ra_* types map to new rac_* types +typedef RaBackendHandle = RacHandle; +typedef RaStreamHandle = RacHandle; + +// ============================================================================= +// Convenient Type Alias +// ============================================================================= + +/// Type alias for platform adapter struct +typedef RacPlatformAdapter = RacPlatformAdapterStruct; diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/native/native_backend.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/native/native_backend.dart new file mode 100644 index 000000000..3fbf12960 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/native/native_backend.dart @@ -0,0 +1,972 @@ +import 'dart:ffi'; +import 'dart:typed_data'; + +import 'package:ffi/ffi.dart'; + +import 'package:runanywhere/native/ffi_types.dart'; +import 'package:runanywhere/native/platform_loader.dart'; + +// ============================================================================= +// RAC Core - SDK Initialization and Module Management +// ============================================================================= + +/// Core RAC (RunAnywhere Commons) functionality. +/// +/// Provides SDK-level initialization, shutdown, and module management. +/// This is the Dart equivalent of the C rac_core.h API. +class RacCore { + static bool _initialized = false; + static DynamicLibrary? _lib; + + // Cached function pointers + static RacInitDart? _racInit; + static RacShutdownDart? _racShutdown; + static RacIsInitializedDart? _racIsInitialized; + static RacFreeDart? _racFree; + static RacErrorMessageDart? _racErrorMessage; + + /// Initialize the RAC commons library. + /// + /// This must be called before using any RAC functionality. + /// The platform adapter provides callbacks for platform-specific operations. + /// + /// Throws [RacException] if initialization fails. + static void init({int logLevel = RacLogLevel.info}) { + if (_initialized) { + return; // Already initialized + } + + _lib = PlatformLoader.loadCommons(); + _bindCoreFunctions(); + + // For now, pass null config (platform adapter setup done separately) + // The C++ library handles null config gracefully + final result = _racInit!(nullptr); + + if (result != RAC_SUCCESS) { + throw RacException('RAC initialization failed', code: result); + } + + _initialized = true; + } + + /// Shutdown the RAC commons library. + /// + /// This releases all resources and unregisters all modules. + static void shutdown() { + if (!_initialized || _lib == null) { + return; + } + + _racShutdown!(); + _initialized = false; + } + + /// Check if the RAC library is initialized. + static bool get isInitialized { + if (_lib == null) { + return false; + } + _bindCoreFunctions(); + return _racIsInitialized!() == RAC_TRUE; + } + + /// Free memory allocated by RAC functions. + static void free(Pointer ptr) { + if (_lib == null || ptr == nullptr) return; + _bindCoreFunctions(); + _racFree!(ptr); + } + + /// Get error message for an error code. + static String getErrorMessage(int code) { + if (_lib == null) { + return RacResultCode.getMessage(code); + } + _bindCoreFunctions(); + final ptr = _racErrorMessage!(code); + if (ptr == nullptr) { + return RacResultCode.getMessage(code); + } + return ptr.toDartString(); + } + + /// Bind core FFI functions (lazy initialization). + static void _bindCoreFunctions() { + if (_racInit != null) return; + + _racInit = _lib!.lookupFunction('rac_init'); + _racShutdown = _lib! + .lookupFunction('rac_shutdown'); + _racIsInitialized = _lib! + .lookupFunction( + 'rac_is_initialized'); + _racFree = _lib!.lookupFunction('rac_free'); + _racErrorMessage = _lib! + .lookupFunction( + 'rac_error_message'); + } + + /// Get the library for advanced operations. + static DynamicLibrary? get library => _lib; +} + +// ============================================================================= +// Native Backend - High-Level Wrapper for Backend Operations +// ============================================================================= + +/// High-level wrapper around the RunAnywhere native C API. +/// +/// This class provides a Dart-friendly interface to native backends, +/// handling memory management and type conversions. +/// +/// The new architecture supports multiple backends: +/// - LlamaCPP: LLM text generation +/// - ONNX: STT, TTS, VAD +/// +/// ## Architecture Note +/// - **RACommons** provides the generic component APIs (`rac_llm_component_*`, +/// `rac_stt_component_*`, etc.) +/// - **Backend libraries** (LlamaCPP, ONNX) register themselves with RACommons +/// +/// For LlamaCPP, component functions are loaded from RACommons, matching the +/// pattern used in Swift's CppBridge and React Native's C++ bridges. +/// +/// ## Usage +/// +/// ```dart +/// // For LlamaCPP +/// final llamacpp = NativeBackend.llamacpp(); +/// llamacpp.initialize(); +/// llamacpp.loadModel('/path/to/model.gguf'); +/// final result = llamacpp.generate('Hello, world!'); +/// llamacpp.dispose(); +/// +/// // For ONNX +/// final onnx = NativeBackend.onnx(); +/// onnx.initialize(); +/// onnx.loadSttModel('/path/to/whisper'); +/// final text = onnx.transcribe(audioSamples); +/// onnx.dispose(); +/// ``` +class NativeBackend { + final DynamicLibrary _lib; + final String _backendType; + RacHandle? _handle; + + // Cached function lookups - Memory management + // ignore: unused_field + late final RacFreeDart _freePtr; + + // State + bool _isInitialized = false; + String? _currentModel; + + NativeBackend._(this._lib, this._backendType) { + _bindBaseFunctions(); + } + + /// Create a NativeBackend using RACommons for all component operations. + /// + /// All component APIs (`rac_llm_component_*`, `rac_stt_component_*`, etc.) + /// are provided by RACommons. Backend modules (LlamaCPP, ONNX) register + /// themselves with the C++ service registry via `rac_backend_*_register()`. + /// + /// This is the standard way to create a NativeBackend - it uses the + /// RACommons library which provides all the generic component interfaces. + factory NativeBackend() { + return NativeBackend._(PlatformLoader.loadCommons(), 'commons'); + } + + /// Create a NativeBackend for LLM operations. + /// + /// Uses RACommons for `rac_llm_component_*` functions. + /// The LlamaCPP backend must be registered first via `LlamaCpp.register()`. + factory NativeBackend.llamacpp() { + return NativeBackend._(PlatformLoader.loadCommons(), 'llamacpp'); + } + + /// Create a NativeBackend for STT/TTS/VAD operations. + /// + /// Uses RACommons for component functions. + /// The ONNX backend must be registered first via `Onnx.register()`. + factory NativeBackend.onnx() { + return NativeBackend._(PlatformLoader.loadCommons(), 'onnx'); + } + + /// Try to create a native backend, returning null if it fails. + static NativeBackend? tryCreate() { + try { + return NativeBackend(); + } catch (_) { + return null; + } + } + + void _bindBaseFunctions() { + try { + _freePtr = _lib.lookupFunction('rac_free'); + } catch (_) { + // Some backends might not export rac_free directly + // Fall back to RacCore.free + _freePtr = RacCore.free; + } + } + + // ============================================================================ + // Backend Lifecycle + // ============================================================================ + + /// Create and initialize the backend. + /// + /// [backendName] - Name of the backend (for backward compatibility) + /// [config] - Optional JSON configuration + void create(String backendName, {Map? config}) { + // The new architecture doesn't require explicit create() + // Backends register themselves via their register() functions + _isInitialized = true; + } + + /// Initialize the backend (simplified for new architecture). + void initialize() { + _isInitialized = true; + } + + /// Check if the backend is initialized. + bool get isInitialized => _isInitialized; + + /// Get the backend type. + String get backendName => _backendType; + + /// Get the backend handle (for advanced operations). + RacHandle? get handle => _handle; + + /// Destroy the backend and release resources. + void dispose() { + if (_handle != null && _handle != nullptr) { + // Call appropriate destroy function based on backend type + _destroyHandle(); + _handle = null; + } + _isInitialized = false; + _currentModel = null; + } + + void _destroyHandle() { + if (_handle == null || _handle == nullptr) return; + + try { + switch (_backendType) { + case 'llamacpp': + final destroy = _lib.lookupFunction('rac_llm_component_destroy'); + destroy(_handle!); + break; + case 'onnx': + // ONNX has separate destroy functions for each service type + // Handle based on what was loaded + break; + default: + // Commons library doesn't have a generic destroy + break; + } + } catch (_) { + // Ignore errors during cleanup + } + } + + // ============================================================================ + // LLM Operations (LlamaCPP Backend) + // ============================================================================ + + /// Load a text generation model (LLM). + /// + /// Uses the `rac_llm_component_*` API from RACommons. + /// First creates the component handle, then loads the model. + void loadTextModel(String modelPath, {Map? config}) { + _ensureBackendType('llamacpp'); + + // Step 1: Create the LLM component if we don't have a handle + if (_handle == null) { + final handlePtr = calloc(); + try { + final create = _lib.lookupFunction('rac_llm_component_create'); + + final result = create(handlePtr); + + if (result != RAC_SUCCESS) { + throw NativeBackendException( + 'Failed to create LLM component: ${RacCore.getErrorMessage(result)}', + code: result, + ); + } + + _handle = handlePtr.value; + } finally { + calloc.free(handlePtr); + } + } + + // Step 2: Load the model + final pathPtr = modelPath.toNativeUtf8(); + // Use filename as model ID + final modelId = modelPath.split('/').last; + final modelIdPtr = modelId.toNativeUtf8(); + final modelNamePtr = modelId.toNativeUtf8(); + + try { + final loadModel = _lib.lookupFunction('rac_llm_component_load_model'); + + final result = loadModel(_handle!, pathPtr, modelIdPtr, modelNamePtr); + + if (result != RAC_SUCCESS) { + throw NativeBackendException( + 'Failed to load text model: ${RacCore.getErrorMessage(result)}', + code: result, + ); + } + + _currentModel = modelPath; + } finally { + calloc.free(pathPtr); + calloc.free(modelIdPtr); + calloc.free(modelNamePtr); + } + } + + /// Check if a text model is loaded. + bool get isTextModelLoaded { + if (_handle == null || _backendType != 'llamacpp') return false; + + try { + final isLoaded = _lib.lookupFunction('rac_llm_component_is_loaded'); + return isLoaded(_handle!) == RAC_TRUE; + } catch (_) { + return false; + } + } + + /// Unload the text model. + void unloadTextModel() { + if (_handle == null || _backendType != 'llamacpp') return; + + try { + final cleanup = _lib.lookupFunction('rac_llm_component_cleanup'); + cleanup(_handle!); + _currentModel = null; + } catch (e) { + throw NativeBackendException('Failed to unload text model: $e'); + } + } + + /// Generate text (non-streaming). + Map generate( + String prompt, { + String? systemPrompt, + int maxTokens = 512, + double temperature = 0.7, + }) { + _ensureBackendType('llamacpp'); + _ensureHandle(); + + final promptPtr = prompt.toNativeUtf8(); + final resultPtr = calloc(); + + // Create options struct + final optionsPtr = calloc(); + optionsPtr.ref.maxTokens = maxTokens; + optionsPtr.ref.temperature = temperature; + optionsPtr.ref.topP = 1.0; + optionsPtr.ref.streamingEnabled = RAC_FALSE; + optionsPtr.ref.systemPrompt = systemPrompt?.toNativeUtf8() ?? nullptr; + + try { + final generate = _lib.lookupFunction('rac_llm_component_generate'); + + final status = generate( + _handle!, + promptPtr, + optionsPtr.cast(), + resultPtr.cast(), + ); + + if (status != RAC_SUCCESS) { + throw NativeBackendException( + 'Text generation failed: ${RacCore.getErrorMessage(status)}', + code: status, + ); + } + + // Extract result + final result = resultPtr.ref; + final text = result.text != nullptr ? result.text.toDartString() : ''; + + return { + 'text': text, + 'prompt_tokens': result.promptTokens, + 'completion_tokens': result.completionTokens, + 'total_tokens': result.totalTokens, + 'time_to_first_token_ms': result.timeToFirstTokenMs, + 'total_time_ms': result.totalTimeMs, + 'tokens_per_second': result.tokensPerSecond, + }; + } finally { + calloc.free(promptPtr); + if (optionsPtr.ref.systemPrompt != nullptr) { + calloc.free(optionsPtr.ref.systemPrompt); + } + calloc.free(optionsPtr); + calloc.free(resultPtr); + } + } + + /// Cancel ongoing text generation. + void cancelTextGeneration() { + if (_handle == null || _backendType != 'llamacpp') return; + + try { + final cancel = _lib.lookupFunction('rac_llm_component_cancel'); + cancel(_handle!); + } catch (_) { + // Ignore errors + } + } + + // ============================================================================ + // STT Operations (ONNX Backend) + // ============================================================================ + + /// Load an STT model. + void loadSttModel( + String modelPath, { + String modelType = 'whisper', + Map? config, + }) { + _ensureBackendType('onnx'); + + final pathPtr = modelPath.toNativeUtf8(); + final handlePtr = calloc(); + final configPtr = calloc(); + + // Set config defaults + configPtr.ref.modelType = modelType == 'whisper' ? 0 : 99; // AUTO + configPtr.ref.numThreads = 0; // Auto + configPtr.ref.useCoreml = RAC_TRUE; + + try { + final create = + _lib.lookupFunction( + 'rac_stt_onnx_create'); + + final result = create(pathPtr, configPtr.cast(), handlePtr); + + if (result != RAC_SUCCESS) { + throw NativeBackendException( + 'Failed to load STT model: ${RacCore.getErrorMessage(result)}', + code: result, + ); + } + + _handle = handlePtr.value; + _currentModel = modelPath; + } finally { + calloc.free(pathPtr); + calloc.free(handlePtr); + calloc.free(configPtr); + } + } + + /// Check if an STT model is loaded. + bool get isSttModelLoaded { + return _handle != null && _backendType == 'onnx'; + } + + /// Unload the STT model. + void unloadSttModel() { + if (_handle == null || _backendType != 'onnx') return; + + try { + final destroy = + _lib.lookupFunction( + 'rac_stt_onnx_destroy'); + destroy(_handle!); + _handle = null; + _currentModel = null; + } catch (e) { + throw NativeBackendException('Failed to unload STT model: $e'); + } + } + + /// Transcribe audio samples (batch mode). + /// + /// [samples] - Float32 audio samples (-1.0 to 1.0) + /// [sampleRate] - Sample rate in Hz (typically 16000) + /// [language] - Language code (e.g., "en", "es") or null for auto-detect + /// + /// Returns a map with transcription result. + Map transcribe( + Float32List samples, { + int sampleRate = 16000, + String? language, + }) { + _ensureBackendType('onnx'); + _ensureHandle(); + + // Allocate native array + final samplesPtr = calloc(samples.length); + final nativeList = samplesPtr.asTypedList(samples.length); + nativeList.setAll(0, samples); + + final resultPtr = calloc(); + + try { + final transcribe = _lib.lookupFunction('rac_stt_onnx_transcribe'); + + final status = transcribe( + _handle!, + samplesPtr, + samples.length, + nullptr, // options + resultPtr.cast(), + ); + + if (status != RAC_SUCCESS) { + throw NativeBackendException( + 'Transcription failed: ${RacCore.getErrorMessage(status)}', + code: status, + ); + } + + // Extract result from struct + final result = resultPtr.ref; + final text = result.text != nullptr ? result.text.toDartString() : ''; + final confidence = result.confidence; + final languageOut = + result.language != nullptr ? result.language.toDartString() : null; + + return { + 'text': text, + 'confidence': confidence, + 'language': languageOut, + 'duration_ms': result.durationMs, + }; + } finally { + calloc.free(samplesPtr); + // Free result text if allocated by C++ + if (resultPtr.ref.text != nullptr) { + RacCore.free(resultPtr.ref.text.cast()); + } + calloc.free(resultPtr); + } + } + + /// Check if STT supports streaming. + bool get sttSupportsStreaming { + if (_handle == null || _backendType != 'onnx') return false; + + try { + final supports = _lib.lookupFunction('rac_stt_onnx_supports_streaming'); + return supports(_handle!) == RAC_TRUE; + } catch (_) { + return false; + } + } + + // ============================================================================ + // TTS Operations (ONNX Backend) + // ============================================================================ + + /// Load a TTS model. + void loadTtsModel( + String modelPath, { + String modelType = 'vits', + Map? config, + }) { + _ensureBackendType('onnx'); + + final pathPtr = modelPath.toNativeUtf8(); + final handlePtr = calloc(); + final configPtr = calloc(); + + // Set config defaults + configPtr.ref.numThreads = 0; // Auto + configPtr.ref.useCoreml = RAC_TRUE; + configPtr.ref.sampleRate = 22050; + + try { + final create = + _lib.lookupFunction( + 'rac_tts_onnx_create'); + + final result = create(pathPtr, configPtr.cast(), handlePtr); + + if (result != RAC_SUCCESS) { + throw NativeBackendException( + 'Failed to load TTS model: ${RacCore.getErrorMessage(result)}', + code: result, + ); + } + + _handle = handlePtr.value; + _currentModel = modelPath; + } finally { + calloc.free(pathPtr); + calloc.free(handlePtr); + calloc.free(configPtr); + } + } + + /// Check if a TTS model is loaded. + bool get isTtsModelLoaded { + return _handle != null && _backendType == 'onnx'; + } + + /// Unload the TTS model. + void unloadTtsModel() { + if (_handle == null || _backendType != 'onnx') return; + + try { + final destroy = + _lib.lookupFunction( + 'rac_tts_onnx_destroy'); + destroy(_handle!); + _handle = null; + _currentModel = null; + } catch (e) { + throw NativeBackendException('Failed to unload TTS model: $e'); + } + } + + /// Synthesize speech from text. + Map synthesize( + String text, { + String? voiceId, + double speed = 1.0, + double pitch = 0.0, + }) { + _ensureBackendType('onnx'); + _ensureHandle(); + + final textPtr = text.toNativeUtf8(); + final resultPtr = calloc(); + + try { + final synthesize = _lib.lookupFunction('rac_tts_onnx_synthesize'); + + final status = synthesize( + _handle!, + textPtr, + nullptr, // options (could include voice, speed, pitch) + resultPtr.cast(), + ); + + if (status != RAC_SUCCESS) { + throw NativeBackendException( + 'TTS synthesis failed: ${RacCore.getErrorMessage(status)}', + code: status, + ); + } + + // Extract result from struct + final result = resultPtr.ref; + final numSamples = result.numSamples; + final sampleRate = result.sampleRate; + + // Copy audio samples to Dart + Float32List samples; + if (result.audioSamples != nullptr && numSamples > 0) { + samples = Float32List.fromList( + result.audioSamples.asTypedList(numSamples), + ); + } else { + samples = Float32List(0); + } + + return { + 'samples': samples, + 'sampleRate': sampleRate, + 'durationMs': result.durationMs, + }; + } finally { + calloc.free(textPtr); + // Free audio samples if allocated by C++ + if (resultPtr.ref.audioSamples != nullptr) { + RacCore.free(resultPtr.ref.audioSamples.cast()); + } + calloc.free(resultPtr); + } + } + + /// Get available TTS voices. + List getTtsVoices() { + if (_handle == null || _backendType != 'onnx') return []; + + try { + final getVoices = _lib.lookupFunction('rac_tts_onnx_get_voices'); + + final voicesPtr = calloc>>(); + final countPtr = calloc(); + + try { + final status = getVoices(_handle!, voicesPtr, countPtr); + + if (status != RAC_SUCCESS) { + return []; + } + + final count = countPtr.value; + final voices = []; + + if (count > 0 && voicesPtr.value != nullptr) { + for (var i = 0; i < count; i++) { + final voicePtr = voicesPtr.value[i]; + if (voicePtr != nullptr) { + voices.add(voicePtr.toDartString()); + } + } + } + + return voices; + } finally { + calloc.free(voicesPtr); + calloc.free(countPtr); + } + } catch (_) { + return []; + } + } + + // ============================================================================ + // VAD Operations (ONNX Backend) + // ============================================================================ + + RacHandle? _vadHandle; + bool _vadUseNative = false; + + /// Load a VAD model. + void loadVadModel(String? modelPath, {Map? config}) { + _ensureBackendType('onnx'); + + // Try to load native VAD if model path provided + if (modelPath != null && modelPath.isNotEmpty) { + try { + final pathPtr = modelPath.toNativeUtf8(); + final handlePtr = calloc(); + final configPtr = calloc(); + + // Set config defaults + configPtr.ref.numThreads = 0; // Auto + configPtr.ref.sampleRate = 16000; + configPtr.ref.windowSizeMs = 30; + configPtr.ref.threshold = 0.5; + + try { + final create = + _lib.lookupFunction( + 'rac_vad_onnx_create'); + + final result = create(pathPtr, configPtr.cast(), handlePtr); + + if (result == RAC_SUCCESS) { + _vadHandle = handlePtr.value; + _vadUseNative = true; + } + } finally { + calloc.free(pathPtr); + calloc.free(handlePtr); + calloc.free(configPtr); + } + } catch (_) { + // Fall back to energy-based detection + _vadUseNative = false; + } + } + + _isInitialized = true; + } + + /// Check if a VAD model is loaded. + bool get isVadModelLoaded { + return _isInitialized && _backendType == 'onnx'; + } + + /// Unload the VAD model. + void unloadVadModel() { + if (_vadHandle != null && _vadUseNative) { + try { + final destroy = + _lib.lookupFunction( + 'rac_vad_onnx_destroy'); + destroy(_vadHandle!); + } catch (_) { + // Ignore cleanup errors + } + _vadHandle = null; + } + _vadUseNative = false; + } + + /// Process audio for voice activity detection. + Map processVad( + Float32List samples, { + int sampleRate = 16000, + }) { + _ensureBackendType('onnx'); + + // Use native VAD if available + if (_vadUseNative && _vadHandle != null) { + try { + final samplesPtr = calloc(samples.length); + final nativeList = samplesPtr.asTypedList(samples.length); + nativeList.setAll(0, samples); + + final resultPtr = calloc(); + + try { + final process = _lib.lookupFunction('rac_vad_onnx_process'); + + final status = process( + _vadHandle!, + samplesPtr, + samples.length, + resultPtr.cast(), + ); + + if (status == RAC_SUCCESS) { + final result = resultPtr.ref; + return { + 'isSpeech': result.isSpeech == RAC_TRUE, + 'probability': result.probability, + }; + } + } finally { + calloc.free(samplesPtr); + calloc.free(resultPtr); + } + } catch (_) { + // Fall through to energy-based detection + } + } + + // Fallback: Basic energy-based VAD + double energy = 0; + for (final sample in samples) { + energy += sample * sample; + } + energy = samples.isNotEmpty ? energy / samples.length : 0; + + const threshold = 0.01; + final isSpeech = energy > threshold; + + return { + 'isSpeech': isSpeech, + 'probability': energy.clamp(0.0, 1.0), + }; + } + + // ============================================================================ + // Utility Methods + // ============================================================================ + + /// Get backend info as a map. + Map getBackendInfo() { + return { + 'type': _backendType, + 'initialized': _isInitialized, + 'model': _currentModel, + 'hasHandle': _handle != null, + }; + } + + /// Get list of available backend names. + List getAvailableBackends() { + return ['llamacpp', 'onnx']; + } + + /// Get the library version. + String get version { + // Return SDK version + return '0.1.4'; + } + + /// Check if backend supports a specific capability. + bool supportsCapability(int capability) { + switch (_backendType) { + case 'llamacpp': + return capability == RacCapability.textGeneration; + case 'onnx': + return capability == RacCapability.stt || + capability == RacCapability.tts || + capability == RacCapability.vad; + default: + return false; + } + } + + // ============================================================================ + // Private Helpers + // ============================================================================ + + void _ensureBackendType(String expected) { + if (_backendType != expected) { + throw NativeBackendException( + 'Backend type mismatch. Expected: $expected, got: $_backendType', + ); + } + } + + void _ensureHandle() { + if (_handle == null || _handle == nullptr) { + throw NativeBackendException( + 'No model loaded. Call loadTextModel/loadSttModel first.', + ); + } + } +} + +// ============================================================================= +// Exceptions +// ============================================================================= + +/// Exception thrown by RAC operations. +class RacException implements Exception { + final String message; + final int? code; + + RacException(this.message, {this.code}); + + @override + String toString() { + if (code != null) { + return 'RacException: $message (code: $code - ${RacResultCode.getMessage(code!)})'; + } + return 'RacException: $message'; + } +} + +/// Exception thrown by native backend operations. +class NativeBackendException implements Exception { + final String message; + final int? code; + + NativeBackendException(this.message, {this.code}); + + @override + String toString() { + if (code != null) { + return 'NativeBackendException: $message (code: $code - ${RacResultCode.getMessage(code!)})'; + } + return 'NativeBackendException: $message'; + } +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/native/platform_loader.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/native/platform_loader.dart new file mode 100644 index 000000000..39fbd16d7 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/native/platform_loader.dart @@ -0,0 +1,302 @@ +import 'dart:ffi'; +import 'dart:io'; + +/// Platform-specific library loader for RunAnywhere core native library (RACommons). +/// +/// This loader is ONLY responsible for loading the core RACommons library. +/// Backend modules (LlamaCPP, ONNX, etc.) are responsible for loading their own +/// native libraries using their own loaders. +/// +/// ## Architecture +/// - Core SDK (runanywhere) only knows about RACommons +/// - Backend modules are self-contained and handle their own native loading +/// - This separation ensures modularity and prevents tight coupling +/// +/// ## iOS +/// XCFrameworks are statically linked into the app binary via CocoaPods. +/// Symbols are available via `DynamicLibrary.executable()` which can find +/// both global and local symbols in the main executable. +/// +/// ## Android +/// .so files are loaded from jniLibs via `DynamicLibrary.open()`. +class PlatformLoader { + // Cached library instance for RACommons + static DynamicLibrary? _commonsLibrary; + static String? _loadError; + + // Library name for RACommons (without platform-specific prefix/suffix) + static const String _commonsLibraryName = 'rac_commons'; + + // ============================================================================= + // Public API - RACommons Loading Only + // ============================================================================= + + /// Load the RACommons native library. + /// + /// This is the core library that provides: + /// - Module registry + /// - Service provider registry + /// - Platform adapter interface + /// - Logging and error handling + /// - LLM/STT/TTS component APIs + static DynamicLibrary loadCommons() { + if (_commonsLibrary != null) { + return _commonsLibrary!; + } + + try { + _commonsLibrary = _loadLibrary(_commonsLibraryName); + _loadError = null; + return _commonsLibrary!; + } catch (e) { + _loadError = e.toString(); + rethrow; + } + } + + /// Legacy method for backward compatibility. + /// Loads the commons library by default. + static DynamicLibrary load() => loadCommons(); + + /// Try to load the commons library, returning null if it fails. + static DynamicLibrary? tryLoad() { + try { + return loadCommons(); + } catch (_) { + return null; + } + } + + // ============================================================================= + // Platform-Specific Loading (Internal) + // ============================================================================= + + /// Load a native library by name, using platform-appropriate method. + /// + /// This is exposed for backend modules to use if they want consistent + /// platform handling, but modules can also implement their own loading. + static DynamicLibrary loadLibrary(String libraryName) { + return _loadLibrary(libraryName); + } + + static DynamicLibrary _loadLibrary(String libraryName) { + if (Platform.isAndroid) { + return _loadAndroid(libraryName); + } else if (Platform.isIOS) { + return _loadIOS(libraryName); + } else if (Platform.isMacOS) { + return _loadMacOS(libraryName); + } else if (Platform.isLinux) { + return _loadLinux(libraryName); + } else if (Platform.isWindows) { + return _loadWindows(libraryName); + } + + throw UnsupportedError( + 'Platform ${Platform.operatingSystem} is not supported. ' + 'Supported platforms: Android, iOS, macOS, Linux, Windows.', + ); + } + + /// Load on Android from jniLibs. + static DynamicLibrary _loadAndroid(String libraryName) { + final soName = 'lib$libraryName.so'; + + try { + return DynamicLibrary.open(soName); + } catch (e) { + // Try JNI wrapper naming convention as fallback + if (libraryName == 'rac_commons') { + try { + return DynamicLibrary.open('librunanywhere_jni.so'); + } catch (_) { + // Fall through + } + } + throw ArgumentError( + 'Could not load $soName on Android: $e. ' + 'Ensure the native library is built and placed in jniLibs.', + ); + } + } + + /// Load on iOS using executable() for statically linked XCFramework. + /// + /// On iOS, all XCFrameworks (RACommons, RABackendLlamaCPP, RABackendONNX) + /// are statically linked into the app binary via CocoaPods. + /// + /// IMPORTANT: We use DynamicLibrary.executable() instead of process() because: + /// - process() uses dlsym(RTLD_DEFAULT) which only finds GLOBAL symbols + /// - executable() can find both global and LOCAL symbols in the main binary + /// - With static linkage, symbols from xcframeworks become local ('t' in nm) + /// - This is the correct approach for statically linked Flutter plugins + static DynamicLibrary _loadIOS(String libraryName) { + return DynamicLibrary.executable(); + } + + /// Load on macOS for development/testing. + static DynamicLibrary _loadMacOS(String libraryName) { + // First try process() for statically linked builds (like iOS) + try { + final lib = DynamicLibrary.process(); + // Verify we can find rac_init (RACommons symbol) + lib.lookup('rac_init'); + return lib; + } catch (_) { + // Fall through to dynamic loading + } + + // Try executable() for statically linked builds + try { + final lib = DynamicLibrary.executable(); + lib.lookup('rac_init'); + return lib; + } catch (_) { + // Fall through to explicit path loading + } + + // Try explicit dylib paths for development + final dylibName = 'lib$libraryName.dylib'; + final searchPaths = _getMacOSSearchPaths(dylibName); + + for (final path in searchPaths) { + if (File(path).existsSync()) { + try { + return DynamicLibrary.open(path); + } catch (_) { + // Try next path + } + } + } + + // Last resort: let the system find it + try { + return DynamicLibrary.open(dylibName); + } catch (e) { + throw ArgumentError( + 'Could not load $dylibName on macOS. ' + 'Tried: ${searchPaths.join(", ")}. Error: $e', + ); + } + } + + /// Get macOS search paths for dylib + static List _getMacOSSearchPaths(String dylibName) { + final paths = []; + + // App bundle paths + final executablePath = Platform.resolvedExecutable; + final bundlePath = File(executablePath).parent.parent.path; + paths.addAll([ + '$bundlePath/Frameworks/$dylibName', + '$bundlePath/Resources/$dylibName', + ]); + + // Development paths relative to current directory + final currentDir = Directory.current.path; + paths.addAll([ + '$currentDir/$dylibName', + '$currentDir/build/$dylibName', + '$currentDir/build/macos/$dylibName', + ]); + + // System paths + paths.addAll([ + '/usr/local/lib/$dylibName', + '/opt/homebrew/lib/$dylibName', + ]); + + return paths; + } + + /// Load on Linux. + static DynamicLibrary _loadLinux(String libraryName) { + final soName = 'lib$libraryName.so'; + final paths = [ + soName, + './$soName', + '/usr/local/lib/$soName', + '/usr/lib/$soName', + ]; + + for (final path in paths) { + try { + return DynamicLibrary.open(path); + } catch (_) { + // Try next path + } + } + + throw ArgumentError( + 'Could not load $soName on Linux. Tried: ${paths.join(", ")}', + ); + } + + /// Load on Windows. + static DynamicLibrary _loadWindows(String libraryName) { + final dllName = '$libraryName.dll'; + final paths = [ + dllName, + './$dllName', + ]; + + for (final path in paths) { + try { + return DynamicLibrary.open(path); + } catch (_) { + // Try next path + } + } + + throw ArgumentError( + 'Could not load $dllName on Windows. Tried: ${paths.join(", ")}', + ); + } + + // ============================================================================= + // State and Utilities + // ============================================================================= + + /// Check if the commons library is loaded. + static bool get isCommonsLoaded => _commonsLibrary != null; + + /// Legacy: Check if any native library is loaded. + static bool get isLoaded => _commonsLibrary != null; + + /// Get the last load error, if any. + static String? get loadError => _loadError; + + /// Unload library reference. + /// + /// Note: The actual library may remain in memory until process exit. + static void unload() { + _commonsLibrary = null; + } + + /// Get the current platform's library file extension. + static String get libraryExtension { + if (Platform.isAndroid || Platform.isLinux) return '.so'; + if (Platform.isIOS || Platform.isMacOS) return '.dylib'; + if (Platform.isWindows) return '.dll'; + return ''; + } + + /// Get the current platform's library file prefix. + static String get libraryPrefix { + if (Platform.isWindows) return ''; + return 'lib'; + } + + /// Check if native libraries are available on this platform. + static bool get isAvailable { + try { + loadCommons(); + return true; + } catch (_) { + return false; + } + } + + /// Convenience alias for load(). + static DynamicLibrary loadNativeLibrary() => load(); +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/native/type_conversions/model_types_cpp_bridge.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/native/type_conversions/model_types_cpp_bridge.dart new file mode 100644 index 000000000..53ce5870c --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/native/type_conversions/model_types_cpp_bridge.dart @@ -0,0 +1,281 @@ +/// ModelTypes + CppBridge +/// +/// Conversion extensions for Dart model types to C++ model types. +/// Used by DartBridgeModelRegistry to convert between Dart and C++ types. +/// +/// Mirrors Swift's ModelTypes+CppBridge.swift exactly. +library model_types_cpp_bridge; + +import 'package:runanywhere/core/types/model_types.dart'; + +// ============================================================================= +// C++ Constants (from rac_model_types.h) +// ============================================================================= + +/// Model category constants (rac_model_category_t) +abstract class RacModelCategory { + static const int language = 0; + static const int speechRecognition = 1; + static const int speechSynthesis = 2; + static const int vision = 3; + static const int imageGeneration = 4; + static const int multimodal = 5; + static const int audio = 6; + static const int unknown = 99; +} + +/// Model format constants (rac_model_format_t) +abstract class RacModelFormat { + static const int onnx = 0; + static const int ort = 1; + static const int gguf = 2; + static const int bin = 3; + static const int unknown = 99; +} + +/// Inference framework constants (rac_inference_framework_t) +abstract class RacInferenceFramework { + static const int onnx = 0; + static const int llamaCpp = 1; + static const int foundationModels = 2; + static const int systemTts = 3; + static const int fluidAudio = 4; + static const int builtIn = 5; + static const int none = 6; + static const int unknown = 99; +} + +/// Model source constants (rac_model_source_t) +abstract class RacModelSource { + static const int remote = 0; + static const int local = 1; +} + +/// Artifact kind constants (rac_artifact_type_kind_t) +abstract class RacArtifactKind { + static const int singleFile = 0; + static const int archive = 1; + static const int multiFile = 2; + static const int custom = 3; + static const int builtIn = 4; +} + +/// Archive type constants (rac_archive_type_t) +abstract class RacArchiveType { + static const int none = 0; + static const int zip = 1; + static const int tarGz = 2; + static const int tarBz2 = 3; + static const int tarXz = 4; + static const int tar = 5; +} + +/// Archive structure constants (rac_archive_structure_t) +abstract class RacArchiveStructure { + static const int unknown = 0; + static const int flat = 1; + static const int nested = 2; + static const int rootFolder = 3; +} + +// ============================================================================= +// ModelCategory C++ Conversion +// ============================================================================= + +extension ModelCategoryCppBridge on ModelCategory { + /// Convert to C++ model category type + int toC() { + switch (this) { + case ModelCategory.language: + return RacModelCategory.language; + case ModelCategory.speechRecognition: + return RacModelCategory.speechRecognition; + case ModelCategory.speechSynthesis: + return RacModelCategory.speechSynthesis; + case ModelCategory.vision: + return RacModelCategory.vision; + case ModelCategory.imageGeneration: + return RacModelCategory.imageGeneration; + case ModelCategory.multimodal: + return RacModelCategory.multimodal; + case ModelCategory.audio: + return RacModelCategory.audio; + } + } + + /// Create from C++ model category type + static ModelCategory fromC(int cCategory) { + switch (cCategory) { + case RacModelCategory.language: + return ModelCategory.language; + case RacModelCategory.speechRecognition: + return ModelCategory.speechRecognition; + case RacModelCategory.speechSynthesis: + return ModelCategory.speechSynthesis; + case RacModelCategory.vision: + return ModelCategory.vision; + case RacModelCategory.imageGeneration: + return ModelCategory.imageGeneration; + case RacModelCategory.multimodal: + return ModelCategory.multimodal; + case RacModelCategory.audio: + return ModelCategory.audio; + default: + return ModelCategory.audio; // Default fallback + } + } +} + +// ============================================================================= +// ModelFormat C++ Conversion +// ============================================================================= + +extension ModelFormatCppBridge on ModelFormat { + /// Convert to C++ model format type + int toC() { + switch (this) { + case ModelFormat.onnx: + return RacModelFormat.onnx; + case ModelFormat.ort: + return RacModelFormat.ort; + case ModelFormat.gguf: + return RacModelFormat.gguf; + case ModelFormat.bin: + return RacModelFormat.bin; + case ModelFormat.unknown: + return RacModelFormat.unknown; + } + } + + /// Create from C++ model format type + static ModelFormat fromC(int cFormat) { + switch (cFormat) { + case RacModelFormat.onnx: + return ModelFormat.onnx; + case RacModelFormat.ort: + return ModelFormat.ort; + case RacModelFormat.gguf: + return ModelFormat.gguf; + case RacModelFormat.bin: + return ModelFormat.bin; + default: + return ModelFormat.unknown; + } + } +} + +// ============================================================================= +// InferenceFramework C++ Conversion +// ============================================================================= + +extension InferenceFrameworkCppBridge on InferenceFramework { + /// Convert to C++ inference framework type + int toC() { + switch (this) { + case InferenceFramework.onnx: + return RacInferenceFramework.onnx; + case InferenceFramework.llamaCpp: + return RacInferenceFramework.llamaCpp; + case InferenceFramework.foundationModels: + return RacInferenceFramework.foundationModels; + case InferenceFramework.systemTTS: + return RacInferenceFramework.systemTts; + case InferenceFramework.fluidAudio: + return RacInferenceFramework.fluidAudio; + case InferenceFramework.builtIn: + return RacInferenceFramework.builtIn; + case InferenceFramework.none: + return RacInferenceFramework.none; + case InferenceFramework.unknown: + return RacInferenceFramework.unknown; + } + } + + /// Create from C++ inference framework type + static InferenceFramework fromC(int cFramework) { + switch (cFramework) { + case RacInferenceFramework.onnx: + return InferenceFramework.onnx; + case RacInferenceFramework.llamaCpp: + return InferenceFramework.llamaCpp; + case RacInferenceFramework.foundationModels: + return InferenceFramework.foundationModels; + case RacInferenceFramework.systemTts: + return InferenceFramework.systemTTS; + case RacInferenceFramework.fluidAudio: + return InferenceFramework.fluidAudio; + case RacInferenceFramework.builtIn: + return InferenceFramework.builtIn; + case RacInferenceFramework.none: + return InferenceFramework.none; + default: + return InferenceFramework.unknown; + } + } +} + +// ============================================================================= +// ModelSource C++ Conversion +// ============================================================================= + +extension ModelSourceCppBridge on ModelSource { + /// Convert to C++ model source type + int toC() { + switch (this) { + case ModelSource.remote: + return RacModelSource.remote; + case ModelSource.local: + return RacModelSource.local; + } + } + + /// Create from C++ model source type + static ModelSource fromC(int cSource) { + switch (cSource) { + case RacModelSource.remote: + return ModelSource.remote; + case RacModelSource.local: + return ModelSource.local; + default: + return ModelSource.local; + } + } +} + +// ============================================================================= +// ModelArtifactType C++ Conversion +// ============================================================================= + +extension ModelArtifactTypeCppBridge on ModelArtifactType { + /// Convert to C++ artifact kind type + int toC() { + return switch (this) { + SingleFileArtifact() => RacArtifactKind.singleFile, + ArchiveArtifact() => RacArtifactKind.archive, + MultiFileArtifact() => RacArtifactKind.multiFile, + CustomArtifact() => RacArtifactKind.custom, + BuiltInArtifact() => RacArtifactKind.builtIn, + }; + } + + /// Create from C++ artifact kind type + static ModelArtifactType fromC(int cKind) { + switch (cKind) { + case RacArtifactKind.singleFile: + return const SingleFileArtifact(); + case RacArtifactKind.archive: + return const ArchiveArtifact( + archiveType: ArchiveType.zip, + structure: ArchiveStructure.unknown, + ); + case RacArtifactKind.multiFile: + return const MultiFileArtifact(files: []); + case RacArtifactKind.custom: + return const CustomArtifact(strategyId: ''); + case RacArtifactKind.builtIn: + return const BuiltInArtifact(); + default: + return const SingleFileArtifact(); + } + } +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/public/configuration/sdk_environment.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/public/configuration/sdk_environment.dart new file mode 100644 index 000000000..4c0aa276d --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/public/configuration/sdk_environment.dart @@ -0,0 +1,186 @@ +import 'package:runanywhere/foundation/logging/sdk_logger.dart'; +import 'package:runanywhere/native/dart_bridge_dev_config.dart'; + +/// SDK Environment mode - determines how data is handled +enum SDKEnvironment { + /// Development/testing mode - may use local data, verbose logging + development, + + /// Staging mode - testing with real services + staging, + + /// Production mode - live environment + production, +} + +extension SDKEnvironmentExtension on SDKEnvironment { + /// Human-readable description + String get description { + switch (this) { + case SDKEnvironment.development: + return 'Development Environment'; + case SDKEnvironment.staging: + return 'Staging Environment'; + case SDKEnvironment.production: + return 'Production Environment'; + } + } + + /// Check if this is a production environment + bool get isProduction => this == SDKEnvironment.production; + + /// Check if this is a testing environment + bool get isTesting => + this == SDKEnvironment.development || this == SDKEnvironment.staging; + + /// Check if this environment requires a valid backend URL + bool get requiresBackendURL => this != SDKEnvironment.development; + + // MARK: - Build Configuration Validation + + /// Check if the current build configuration is compatible with this environment + /// Production environment is only allowed in Release builds + bool get isCompatibleWithCurrentBuild { + switch (this) { + case SDKEnvironment.development: + case SDKEnvironment.staging: + return true; + case SDKEnvironment.production: + // In Dart/Flutter, we use kDebugMode or assert() to check debug mode + // Since there's no #if DEBUG in Dart, we check via assert + bool isDebug = false; + assert(() { + isDebug = true; + return true; + }()); + return !isDebug; + } + } + + /// Returns true if we're running in a DEBUG build + static bool get isDebugBuild { + bool isDebug = false; + assert(() { + isDebug = true; + return true; + }()); + return isDebug; + } + + // MARK: - Environment-Specific Settings + + /// Determine logging verbosity based on environment + LogLevel get defaultLogLevel { + switch (this) { + case SDKEnvironment.development: + return LogLevel.debug; + case SDKEnvironment.staging: + return LogLevel.info; + case SDKEnvironment.production: + return LogLevel.warning; + } + } + + /// Should send telemetry data (production only) + bool get shouldSendTelemetry => this == SDKEnvironment.production; + + /// Should use mock data sources (development only) + bool get useMockData => this == SDKEnvironment.development; + + /// Should sync with backend (non-development) + bool get shouldSyncWithBackend => this != SDKEnvironment.development; + + /// Requires API authentication (non-development) + bool get requiresAuthentication => this != SDKEnvironment.development; +} + +/// Supabase configuration +class SupabaseConfig { + final Uri projectURL; + final String anonKey; + + SupabaseConfig({ + required this.projectURL, + required this.anonKey, + }); + + /// Get configuration for environment + /// + /// For development mode, reads from C++ dev config (development_config.cpp). + /// This ensures credentials are stored in a single git-ignored location. + static SupabaseConfig? configuration(SDKEnvironment environment) { + switch (environment) { + case SDKEnvironment.development: + // Read from C++ dev config - credentials stored in development_config.cpp (git-ignored) + final supabaseUrl = DartBridgeDevConfig.supabaseURL; + final supabaseKey = DartBridgeDevConfig.supabaseKey; + + if (supabaseUrl == null || supabaseUrl.isEmpty || + supabaseKey == null || supabaseKey.isEmpty) { + // Dev config not available - this is expected if development_config.cpp + // hasn't been filled in. Telemetry will be disabled in dev mode. + return null; + } + + return SupabaseConfig( + projectURL: Uri.parse(supabaseUrl), + anonKey: supabaseKey, + ); + case SDKEnvironment.staging: + case SDKEnvironment.production: + return null; + } + } +} + +/// SDK initialization parameters +class SDKInitParams { + /// API key for authentication + final String apiKey; + + /// Base URL for API requests + final Uri baseURL; + + /// Environment mode + final SDKEnvironment environment; + + /// Supabase configuration (for analytics in dev mode) + SupabaseConfig? get supabaseConfig => + SupabaseConfig.configuration(environment); + + SDKInitParams({ + required this.apiKey, + required this.baseURL, + this.environment = SDKEnvironment.production, + }); + + /// Create from string URL + factory SDKInitParams.fromString({ + required String apiKey, + required String baseURL, + SDKEnvironment environment = SDKEnvironment.production, + }) { + final uri = Uri.tryParse(baseURL); + if (uri == null) { + throw ArgumentError('Invalid base URL: $baseURL'); + } + return SDKInitParams( + apiKey: apiKey, + baseURL: uri, + environment: environment, + ); + } + + /// Create development mode parameters + /// Uses Supabase for analytics, no authentication required + /// Matches iOS SDKInitParams(forDevelopmentWithAPIKey:) + factory SDKInitParams.forDevelopment({String apiKey = ''}) { + final supabaseConfig = + SupabaseConfig.configuration(SDKEnvironment.development); + return SDKInitParams( + apiKey: apiKey, + baseURL: supabaseConfig?.projectURL ?? Uri.parse('http://localhost'), + environment: SDKEnvironment.development, + ); + } +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/public/errors/errors.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/public/errors/errors.dart new file mode 100644 index 000000000..f96c98698 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/public/errors/errors.dart @@ -0,0 +1 @@ +export '../../../foundation/error_types/sdk_error.dart'; diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/public/events/event_bus.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/public/events/event_bus.dart new file mode 100644 index 000000000..e0493f2d5 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/public/events/event_bus.dart @@ -0,0 +1,78 @@ +import 'dart:async'; + +import 'package:runanywhere/public/events/sdk_event.dart'; + +/// Central event bus for SDK-wide event distribution +/// Thread-safe event bus using Dart Streams +class EventBus { + /// Shared instance - thread-safe singleton + static final EventBus shared = EventBus._(); + + EventBus._(); + + // Event controllers for each event type + final _initializationController = + StreamController.broadcast(); + final _configurationController = + StreamController.broadcast(); + final _generationController = + StreamController.broadcast(); + final _modelController = StreamController.broadcast(); + final _voiceController = StreamController.broadcast(); + final _storageController = StreamController.broadcast(); + final _deviceController = StreamController.broadcast(); + final _allEventsController = StreamController.broadcast(); + + /// Public streams for subscribing to events + Stream get initializationEvents => + _initializationController.stream; + + Stream get configurationEvents => + _configurationController.stream; + + Stream get generationEvents => + _generationController.stream; + + Stream get modelEvents => _modelController.stream; + + Stream get voiceEvents => _voiceController.stream; + + Stream get storageEvents => _storageController.stream; + + Stream get deviceEvents => _deviceController.stream; + + Stream get allEvents => _allEventsController.stream; + + /// Generic event publisher - dispatches to appropriate stream + void publish(SDKEvent event) { + _allEventsController.add(event); + + if (event is SDKInitializationEvent) { + _initializationController.add(event); + } else if (event is SDKConfigurationEvent) { + _configurationController.add(event); + } else if (event is SDKGenerationEvent) { + _generationController.add(event); + } else if (event is SDKModelEvent) { + _modelController.add(event); + } else if (event is SDKVoiceEvent) { + _voiceController.add(event); + } else if (event is SDKStorageEvent) { + _storageController.add(event); + } else if (event is SDKDeviceEvent) { + _deviceController.add(event); + } + } + + /// Dispose all controllers + Future dispose() async { + await _initializationController.close(); + await _configurationController.close(); + await _generationController.close(); + await _modelController.close(); + await _voiceController.close(); + await _storageController.close(); + await _deviceController.close(); + await _allEventsController.close(); + } +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/public/events/sdk_event.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/public/events/sdk_event.dart new file mode 100644 index 000000000..fd678ec3a --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/public/events/sdk_event.dart @@ -0,0 +1,685 @@ +import 'package:uuid/uuid.dart'; + +/// Event categories for routing and filtering +enum EventCategory { + sdk, + llm, + stt, + tts, + vad, + voice, + model, + device, + network, + storage, + error, +} + +/// Event destination for routing +enum EventDestination { + /// Send to both public EventBus and analytics + all, + + /// Send only to public EventBus + publicOnly, + + /// Send only to analytics (internal) + analyticsOnly, +} + +/// Base protocol for all SDK events. +/// +/// Mirrors iOS `SDKEvent` protocol from RunAnywhere SDK. +/// Every event in the SDK should extend this class. The [destination] property +/// tells the router where to send the event: +/// - [EventDestination.all] (default) → EventBus + Analytics +/// - [EventDestination.publicOnly] → EventBus only +/// - [EventDestination.analyticsOnly] → Analytics only +/// +/// Usage: +/// ```dart +/// EventPublisher.shared.track(LLMEvent.generationCompleted(...)); +/// ``` +abstract class SDKEvent { + /// Unique identifier for this event instance + String get id; + + /// Event type string (used for analytics categorization) + String get type; + + /// Category for filtering/routing + EventCategory get category; + + /// When the event occurred + DateTime get timestamp; + + /// Optional session ID for grouping related events + String? get sessionId => null; + + /// Where to route this event + EventDestination get destination => EventDestination.all; + + /// Event properties as key-value pairs (for analytics serialization) + Map get properties => {}; +} + +/// Mixin providing default implementations for SDKEvent fields. +/// Similar to Swift protocol extensions. +mixin SDKEventDefaults implements SDKEvent { + static const _uuid = Uuid(); + + @override + String get id => _uuid.v4(); + + @override + DateTime get timestamp => DateTime.now(); + + @override + String? get sessionId => null; + + @override + EventDestination get destination => EventDestination.all; + + @override + Map get properties => {}; +} + +// ============================================================================ +// SDK Initialization Events +// ============================================================================ + +/// SDK initialization events +abstract class SDKInitializationEvent with SDKEventDefaults { + @override + EventCategory get category => EventCategory.sdk; +} + +class SDKInitializationStarted extends SDKInitializationEvent { + @override + String get type => 'sdk.initialization.started'; +} + +class SDKInitializationCompleted extends SDKInitializationEvent { + @override + String get type => 'sdk.initialization.completed'; +} + +class SDKInitializationFailed extends SDKInitializationEvent { + final Object error; + + SDKInitializationFailed(this.error); + + @override + String get type => 'sdk.initialization.failed'; + + @override + Map get properties => {'error': error.toString()}; +} + +// ============================================================================ +// SDK Configuration Events +// ============================================================================ + +/// SDK configuration events +abstract class SDKConfigurationEvent with SDKEventDefaults { + @override + EventCategory get category => EventCategory.sdk; +} + +// ============================================================================ +// SDK Generation Events (LLM) +// ============================================================================ + +/// SDK generation events +abstract class SDKGenerationEvent with SDKEventDefaults { + @override + EventCategory get category => EventCategory.llm; + + static SDKGenerationStarted started({required String prompt}) { + return SDKGenerationStarted(prompt: prompt); + } + + static SDKGenerationCompleted completed({ + required String response, + required int tokensUsed, + required int latencyMs, + }) { + return SDKGenerationCompleted( + response: response, + tokensUsed: tokensUsed, + latencyMs: latencyMs, + ); + } + + static SDKGenerationFailed failed(Object error) { + return SDKGenerationFailed(error); + } + + static SDKGenerationCostCalculated costCalculated({ + required double amount, + required double savedAmount, + }) { + return SDKGenerationCostCalculated( + amount: amount, + savedAmount: savedAmount, + ); + } +} + +class SDKGenerationStarted extends SDKGenerationEvent { + final String prompt; + + SDKGenerationStarted({required this.prompt}); + + @override + String get type => 'llm.generation.started'; + + @override + Map get properties => {'prompt_length': '${prompt.length}'}; +} + +class SDKGenerationCompleted extends SDKGenerationEvent { + final String response; + final int tokensUsed; + final int latencyMs; + + SDKGenerationCompleted({ + required this.response, + required this.tokensUsed, + required this.latencyMs, + }); + + @override + String get type => 'llm.generation.completed'; + + @override + Map get properties => { + 'response_length': '${response.length}', + 'tokens_used': '$tokensUsed', + 'latency_ms': '$latencyMs', + }; +} + +class SDKGenerationFailed extends SDKGenerationEvent { + final Object error; + + SDKGenerationFailed(this.error); + + @override + String get type => 'llm.generation.failed'; + + @override + Map get properties => {'error': error.toString()}; +} + +class SDKGenerationCostCalculated extends SDKGenerationEvent { + final double amount; + final double savedAmount; + + SDKGenerationCostCalculated({ + required this.amount, + required this.savedAmount, + }); + + @override + String get type => 'llm.generation.cost_calculated'; + + @override + Map get properties => { + 'amount': amount.toStringAsFixed(6), + 'saved_amount': savedAmount.toStringAsFixed(6), + }; +} + +// ============================================================================ +// SDK Model Events +// ============================================================================ + +/// SDK model events +abstract class SDKModelEvent with SDKEventDefaults { + @override + EventCategory get category => EventCategory.model; + + static SDKModelLoadStarted loadStarted({required String modelId}) { + return SDKModelLoadStarted(modelId: modelId); + } + + static SDKModelLoadCompleted loadCompleted({required String modelId}) { + return SDKModelLoadCompleted(modelId: modelId); + } + + static SDKModelLoadFailed loadFailed({ + required String modelId, + required Object error, + }) { + return SDKModelLoadFailed(modelId: modelId, error: error); + } + + static SDKModelUnloadStarted unloadStarted({required String modelId}) { + return SDKModelUnloadStarted(modelId: modelId); + } + + static SDKModelUnloadCompleted unloadCompleted({required String modelId}) { + return SDKModelUnloadCompleted(modelId: modelId); + } + + static SDKModelDeleted deleted({required String modelId}) { + return SDKModelDeleted(modelId: modelId); + } + + // Download events + static SDKModelDownloadStarted downloadStarted({required String modelId}) { + return SDKModelDownloadStarted(modelId: modelId); + } + + static SDKModelDownloadCompleted downloadCompleted( + {required String modelId}) { + return SDKModelDownloadCompleted(modelId: modelId); + } + + static SDKModelDownloadFailed downloadFailed({ + required String modelId, + required String error, + }) { + return SDKModelDownloadFailed(modelId: modelId, error: error); + } + + static SDKModelDownloadProgress downloadProgress({ + required String modelId, + required double progress, + }) { + return SDKModelDownloadProgress(modelId: modelId, progress: progress); + } +} + +class SDKModelLoadStarted extends SDKModelEvent { + final String modelId; + + SDKModelLoadStarted({required this.modelId}); + + @override + String get type => 'model.load.started'; + + @override + Map get properties => {'model_id': modelId}; +} + +class SDKModelLoadCompleted extends SDKModelEvent { + final String modelId; + + SDKModelLoadCompleted({required this.modelId}); + + @override + String get type => 'model.load.completed'; + + @override + Map get properties => {'model_id': modelId}; +} + +class SDKModelLoadFailed extends SDKModelEvent { + final String modelId; + final Object error; + + SDKModelLoadFailed({required this.modelId, required this.error}); + + @override + String get type => 'model.load.failed'; + + @override + Map get properties => { + 'model_id': modelId, + 'error': error.toString(), + }; +} + +class SDKModelUnloadStarted extends SDKModelEvent { + final String modelId; + + SDKModelUnloadStarted({required this.modelId}); + + @override + String get type => 'model.unload.started'; + + @override + Map get properties => {'model_id': modelId}; +} + +class SDKModelUnloadCompleted extends SDKModelEvent { + final String modelId; + + SDKModelUnloadCompleted({required this.modelId}); + + @override + String get type => 'model.unload.completed'; + + @override + Map get properties => {'model_id': modelId}; +} + +class SDKModelDeleted extends SDKModelEvent { + final String modelId; + + SDKModelDeleted({required this.modelId}); + + @override + String get type => 'model.deleted'; + + @override + Map get properties => {'model_id': modelId}; +} + +class SDKModelDownloadStarted extends SDKModelEvent { + final String modelId; + + SDKModelDownloadStarted({required this.modelId}); + + @override + String get type => 'model.download.started'; + + @override + Map get properties => {'model_id': modelId}; +} + +class SDKModelDownloadCompleted extends SDKModelEvent { + final String modelId; + + SDKModelDownloadCompleted({required this.modelId}); + + @override + String get type => 'model.download.completed'; + + @override + Map get properties => {'model_id': modelId}; +} + +class SDKModelDownloadFailed extends SDKModelEvent { + final String modelId; + final String error; + + SDKModelDownloadFailed({required this.modelId, required this.error}); + + @override + String get type => 'model.download.failed'; + + @override + Map get properties => { + 'model_id': modelId, + 'error': error, + }; +} + +class SDKModelDownloadProgress extends SDKModelEvent { + final String modelId; + final double progress; + + SDKModelDownloadProgress({required this.modelId, required this.progress}); + + @override + String get type => 'model.download.progress'; + + @override + Map get properties => { + 'model_id': modelId, + 'progress': progress.toString(), + }; +} + +// ============================================================================ +// SDK Voice Events +// ============================================================================ + +/// SDK voice events +abstract class SDKVoiceEvent with SDKEventDefaults { + @override + EventCategory get category => EventCategory.voice; + + static SDKVoiceListeningStarted listeningStarted() { + return SDKVoiceListeningStarted(); + } + + static SDKVoiceListeningEnded listeningEnded() { + return SDKVoiceListeningEnded(); + } + + static SDKVoiceSpeechDetected speechDetected() { + return SDKVoiceSpeechDetected(); + } + + static SDKVoiceTranscriptionStarted transcriptionStarted() { + return SDKVoiceTranscriptionStarted(); + } + + static SDKVoiceTranscriptionPartial transcriptionPartial( + {required String text}) { + return SDKVoiceTranscriptionPartial(text: text); + } + + static SDKVoiceTranscriptionFinal transcriptionFinal({required String text}) { + return SDKVoiceTranscriptionFinal(text: text); + } + + static SDKVoiceResponseGenerated responseGenerated({required String text}) { + return SDKVoiceResponseGenerated(text: text); + } + + static SDKVoiceSynthesisStarted synthesisStarted() { + return SDKVoiceSynthesisStarted(); + } + + static SDKVoiceAudioGenerated audioGenerated({required dynamic data}) { + return SDKVoiceAudioGenerated(data: data); + } + + static SDKVoiceSynthesisCompleted synthesisCompleted() { + return SDKVoiceSynthesisCompleted(); + } + + static SDKVoicePipelineError pipelineError(Object error) { + return SDKVoicePipelineError(error: error); + } + + static SDKVoicePipelineStarted pipelineStarted() { + return SDKVoicePipelineStarted(); + } + + static SDKVoicePipelineCompleted pipelineCompleted() { + return SDKVoicePipelineCompleted(); + } +} + +class SDKVoiceListeningStarted extends SDKVoiceEvent { + @override + String get type => 'voice.listening.started'; +} + +class SDKVoiceListeningEnded extends SDKVoiceEvent { + @override + String get type => 'voice.listening.ended'; +} + +class SDKVoiceSpeechDetected extends SDKVoiceEvent { + @override + String get type => 'voice.speech.detected'; +} + +class SDKVoiceTranscriptionStarted extends SDKVoiceEvent { + @override + String get type => 'voice.transcription.started'; + + @override + EventCategory get category => EventCategory.stt; +} + +class SDKVoiceTranscriptionPartial extends SDKVoiceEvent { + final String text; + + SDKVoiceTranscriptionPartial({required this.text}); + + @override + String get type => 'voice.transcription.partial'; + + @override + EventCategory get category => EventCategory.stt; + + @override + Map get properties => {'text': text}; +} + +class SDKVoiceTranscriptionFinal extends SDKVoiceEvent { + final String text; + + SDKVoiceTranscriptionFinal({required this.text}); + + @override + String get type => 'voice.transcription.final'; + + @override + EventCategory get category => EventCategory.stt; + + @override + Map get properties => {'text': text}; +} + +class SDKVoiceResponseGenerated extends SDKVoiceEvent { + final String text; + + SDKVoiceResponseGenerated({required this.text}); + + @override + String get type => 'voice.response.generated'; + + @override + Map get properties => {'text_length': '${text.length}'}; +} + +class SDKVoiceSynthesisStarted extends SDKVoiceEvent { + @override + String get type => 'voice.synthesis.started'; + + @override + EventCategory get category => EventCategory.tts; +} + +class SDKVoiceAudioGenerated extends SDKVoiceEvent { + final dynamic data; + + SDKVoiceAudioGenerated({required this.data}); + + @override + String get type => 'voice.audio.generated'; + + @override + EventCategory get category => EventCategory.tts; +} + +class SDKVoiceSynthesisCompleted extends SDKVoiceEvent { + @override + String get type => 'voice.synthesis.completed'; + + @override + EventCategory get category => EventCategory.tts; +} + +class SDKVoicePipelineError extends SDKVoiceEvent { + final Object error; + + SDKVoicePipelineError({required this.error}); + + @override + String get type => 'voice.pipeline.error'; + + @override + EventCategory get category => EventCategory.error; + + @override + Map get properties => {'error': error.toString()}; +} + +class SDKVoicePipelineStarted extends SDKVoiceEvent { + @override + String get type => 'voice.pipeline.started'; +} + +class SDKVoicePipelineCompleted extends SDKVoiceEvent { + @override + String get type => 'voice.pipeline.completed'; +} + +// ============================================================================ +// SDK Device Events +// ============================================================================ + +/// SDK device events. +/// +/// Mirrors iOS `DeviceEvent` from RunAnywhere SDK. +abstract class SDKDeviceEvent with SDKEventDefaults { + @override + EventCategory get category => EventCategory.device; + + /// Factory method: device registered successfully + static DeviceRegistered registered({required String deviceId}) { + return DeviceRegistered(deviceId: deviceId); + } + + /// Factory method: device registration failed + static DeviceRegistrationFailed registrationFailed({required String error}) { + return DeviceRegistrationFailed(error: error); + } +} + +class DeviceRegistered extends SDKDeviceEvent { + final String deviceId; + + DeviceRegistered({required this.deviceId}); + + @override + String get type => 'device.registered'; + + @override + Map get properties => { + 'device_id': + deviceId.length > 8 ? '${deviceId.substring(0, 8)}...' : deviceId + }; +} + +class DeviceRegistrationFailed extends SDKDeviceEvent { + final String error; + + DeviceRegistrationFailed({required this.error}); + + @override + String get type => 'device.registration.failed'; + + @override + Map get properties => {'error': error}; +} + +// ============================================================================ +// SDK Storage Events +// ============================================================================ + +/// SDK storage events +abstract class SDKStorageEvent with SDKEventDefaults { + @override + EventCategory get category => EventCategory.storage; + + /// Factory method: cache cleared + static SDKStorageCacheCleared cacheCleared() { + return SDKStorageCacheCleared(); + } + + /// Factory method: temp files cleaned + static SDKStorageTempFilesCleaned tempFilesCleaned() { + return SDKStorageTempFilesCleaned(); + } +} + +class SDKStorageCacheCleared extends SDKStorageEvent { + @override + String get type => 'storage.cache.cleared'; +} + +class SDKStorageTempFilesCleaned extends SDKStorageEvent { + @override + String get type => 'storage.temp_files.cleaned'; +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/public/extensions/runanywhere_frameworks.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/public/extensions/runanywhere_frameworks.dart new file mode 100644 index 000000000..1b9092c99 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/public/extensions/runanywhere_frameworks.dart @@ -0,0 +1,95 @@ +/// RunAnywhere + Frameworks +/// +/// Public API for framework discovery and querying. +/// Mirrors Swift's RunAnywhere+Frameworks.swift. +library runanywhere_frameworks; + +import 'package:runanywhere/core/types/model_types.dart'; +import 'package:runanywhere/core/types/sdk_component.dart'; +import 'package:runanywhere/public/runanywhere.dart'; + +// ============================================================================= +// Framework Discovery Extensions +// ============================================================================= + +/// Extension methods for framework discovery +extension RunAnywhereFrameworks on RunAnywhere { + /// Get all registered frameworks derived from available models + /// - Returns: List of available inference frameworks that have models registered + static Future> getRegisteredFrameworks() async { + // Derive frameworks from registered models - this is the source of truth + final allModels = await RunAnywhere.availableModels(); + final frameworks = {}; + + for (final model in allModels) { + // Add the model's framework (1:1 mapping) + frameworks.add(model.framework); + } + + final result = frameworks.toList(); + result.sort((a, b) => a.displayName.compareTo(b.displayName)); + return result; + } + + /// Get all registered frameworks for a specific capability + /// - Parameter capability: The capability/component type to filter by + /// - Returns: List of frameworks that provide the specified capability + static Future> getFrameworks( + SDKComponent capability) async { + final frameworks = {}; + + // Map capability to model categories + final Set relevantCategories; + switch (capability) { + case SDKComponent.llm: + relevantCategories = {ModelCategory.language, ModelCategory.multimodal}; + case SDKComponent.stt: + relevantCategories = {ModelCategory.speechRecognition}; + case SDKComponent.tts: + relevantCategories = {ModelCategory.speechSynthesis}; + case SDKComponent.vad: + relevantCategories = {ModelCategory.audio}; + case SDKComponent.voice: + relevantCategories = { + ModelCategory.language, + ModelCategory.speechRecognition, + ModelCategory.speechSynthesis + }; + case SDKComponent.embedding: + // Embedding models could be language or multimodal + relevantCategories = {ModelCategory.language, ModelCategory.multimodal}; + } + + final allModels = await RunAnywhere.availableModels(); + for (final model in allModels) { + if (relevantCategories.contains(model.category)) { + // Add the model's framework (1:1 mapping) + frameworks.add(model.framework); + } + } + + final result = frameworks.toList(); + result.sort((a, b) => a.displayName.compareTo(b.displayName)); + return result; + } + + /// Check if a framework is available + static Future isFrameworkAvailable(InferenceFramework framework) async { + final frameworks = await getRegisteredFrameworks(); + return frameworks.contains(framework); + } + + /// Get models for a specific framework + static Future> modelsForFramework( + InferenceFramework framework) async { + final allModels = await RunAnywhere.availableModels(); + return allModels.where((model) => model.framework == framework).toList(); + } + + /// Get downloaded models for a specific framework + static Future> downloadedModelsForFramework( + InferenceFramework framework) async { + final models = await modelsForFramework(framework); + return models.where((model) => model.isDownloaded).toList(); + } +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/public/extensions/runanywhere_logging.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/public/extensions/runanywhere_logging.dart new file mode 100644 index 000000000..1d688a540 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/public/extensions/runanywhere_logging.dart @@ -0,0 +1,133 @@ +/// RunAnywhere + Logging +/// +/// Public API for configuring SDK logging. +/// Mirrors Swift's RunAnywhere+Logging.swift. +library runanywhere_logging; + +import 'package:runanywhere/native/dart_bridge_telemetry.dart'; +import 'package:runanywhere/public/runanywhere.dart'; + +// ============================================================================= +// Log Level Enum +// ============================================================================= + +/// SDK Log levels +enum SDKLogLevel { + trace, + debug, + info, + warning, + error, + fatal; + + /// Convert to C++ log level + int toC() { + switch (this) { + case SDKLogLevel.trace: + return 0; + case SDKLogLevel.debug: + return 1; + case SDKLogLevel.info: + return 2; + case SDKLogLevel.warning: + return 3; + case SDKLogLevel.error: + return 4; + case SDKLogLevel.fatal: + return 5; + } + } +} + +// ============================================================================= +// Logging Configuration +// ============================================================================= + +/// Configuration for SDK logging +class LoggingConfiguration { + final SDKLogLevel minimumLevel; + final bool localLoggingEnabled; + final bool sentryEnabled; + + const LoggingConfiguration({ + this.minimumLevel = SDKLogLevel.info, + this.localLoggingEnabled = true, + this.sentryEnabled = false, + }); + + /// Development configuration - verbose logging + static const development = LoggingConfiguration( + minimumLevel: SDKLogLevel.debug, + localLoggingEnabled: true, + sentryEnabled: false, + ); + + /// Production configuration - minimal logging + static const production = LoggingConfiguration( + minimumLevel: SDKLogLevel.warning, + localLoggingEnabled: false, + sentryEnabled: true, + ); +} + +// ============================================================================= +// RunAnywhere Logging Extensions +// ============================================================================= + +/// Extension methods for logging configuration +extension RunAnywhereLogging on RunAnywhere { + /// Configure logging with a predefined configuration + static void configureLogging(LoggingConfiguration config) { + setLogLevel(config.minimumLevel); + setLocalLoggingEnabled(config.localLoggingEnabled); + // Sentry is handled by DartBridgeTelemetry + } + + /// Set minimum log level for SDK logging + static void setLogLevel(SDKLogLevel level) { + SDKLoggerConfig.shared.setMinLevel(level); + } + + /// Enable or disable local console logging + static void setLocalLoggingEnabled(bool enabled) { + SDKLoggerConfig.shared.setLocalLoggingEnabled(enabled); + } + + /// Enable verbose debugging mode + static void setDebugMode(bool enabled) { + setLogLevel(enabled ? SDKLogLevel.debug : SDKLogLevel.info); + setLocalLoggingEnabled(enabled); + } + + /// Force flush all pending logs + static void flushLogs() { + DartBridgeTelemetry.flush(); + } +} + +// ============================================================================= +// SDK Logger Configuration +// ============================================================================= + +/// Singleton for SDK logger configuration +class SDKLoggerConfig { + static final SDKLoggerConfig shared = SDKLoggerConfig._(); + + SDKLoggerConfig._(); + + SDKLogLevel _minLevel = SDKLogLevel.info; + bool _localLoggingEnabled = true; + + SDKLogLevel get minLevel => _minLevel; + bool get localLoggingEnabled => _localLoggingEnabled; + + void setMinLevel(SDKLogLevel level) { + _minLevel = level; + // C++ logging is configured during DartBridge.initialize() based on environment + // Re-initializing here is not needed as the level is set on the Dart side + } + + void setLocalLoggingEnabled(bool enabled) { + _localLoggingEnabled = enabled; + } +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/public/extensions/runanywhere_storage.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/public/extensions/runanywhere_storage.dart new file mode 100644 index 000000000..ec1151f69 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/public/extensions/runanywhere_storage.dart @@ -0,0 +1,103 @@ +/// RunAnywhere + Storage +/// +/// Public API for storage and download operations. +/// Mirrors Swift's RunAnywhere+Storage.swift. +library runanywhere_storage; + +import 'dart:io'; + +import 'package:path_provider/path_provider.dart'; +import 'package:runanywhere/infrastructure/download/download_service.dart'; +import 'package:runanywhere/native/dart_bridge_storage.dart'; +import 'package:runanywhere/public/events/event_bus.dart'; +import 'package:runanywhere/public/events/sdk_event.dart'; +import 'package:runanywhere/public/runanywhere.dart'; + +// ============================================================================= +// RunAnywhere Storage Extensions +// ============================================================================= + +/// Extension methods for storage operations +extension RunAnywhereStorage on RunAnywhere { + /// Check if storage is available for a model download + /// + /// Returns true if sufficient storage is available for the given model size. + static Future checkStorageAvailable({ + required int modelSize, + double safetyMargin = 0.1, + }) async { + try { + final directory = await getApplicationDocumentsDirectory(); + final requiredWithMargin = (modelSize * (1 + safetyMargin)).toInt(); + + // Get directory size as a proxy for available space check + final dirSize = await _getDirectorySize(directory); + + // If the SDK directory is larger than the model size, + // we assume storage is available (simplified check) + return dirSize > requiredWithMargin; + } catch (_) { + // Default to available if check fails + return true; + } + } + + /// Get value from storage + static Future getStorageValue(String key) async { + return DartBridgeStorage.instance.get(key); + } + + /// Set value in storage + static Future setStorageValue(String key, String value) async { + return DartBridgeStorage.instance.set(key, value); + } + + /// Delete value from storage + static Future deleteStorageValue(String key) async { + return DartBridgeStorage.instance.delete(key); + } + + /// Check if key exists in storage + static Future storageKeyExists(String key) async { + return DartBridgeStorage.instance.exists(key); + } + + /// Clear all storage + static Future clearStorage() async { + await DartBridgeStorage.instance.clear(); + EventBus.shared.publish(SDKStorageEvent.cacheCleared()); + } + + /// Get base directory URL for SDK files + static Future getBaseDirectoryPath() async { + final directory = await getApplicationDocumentsDirectory(); + return '${directory.path}/runanywhere'; + } + + /// Download a model by ID with progress tracking + /// + /// ```dart + /// final stream = RunAnywhereStorage.downloadModel('my-model-id'); + /// await for (final progress in stream) { + /// print('Progress: ${(progress.overallProgress * 100).toStringAsFixed(0)}%'); + /// } + /// ``` + static Stream downloadModel(String modelId) { + return ModelDownloadService.shared.downloadModel(modelId); + } + + /// Helper to get directory size + static Future _getDirectorySize(Directory directory) async { + int size = 0; + try { + await for (final entity in directory.list(recursive: true)) { + if (entity is File) { + size += await entity.length(); + } + } + } catch (_) { + // Ignore errors + } + return size; + } +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/public/runanywhere.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/public/runanywhere.dart new file mode 100644 index 000000000..7096e22ec --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/public/runanywhere.dart @@ -0,0 +1,1801 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:runanywhere/capabilities/voice/models/voice_session.dart'; +import 'package:runanywhere/capabilities/voice/models/voice_session_handle.dart'; +import 'package:runanywhere/core/types/model_types.dart'; +import 'package:runanywhere/core/types/storage_types.dart'; +import 'package:runanywhere/data/network/http_service.dart'; +import 'package:runanywhere/data/network/telemetry_service.dart'; +import 'package:runanywhere/foundation/configuration/sdk_constants.dart'; +import 'package:runanywhere/foundation/dependency_injection/service_container.dart' + hide SDKInitParams; +import 'package:runanywhere/foundation/error_types/sdk_error.dart'; +import 'package:runanywhere/foundation/logging/sdk_logger.dart'; +import 'package:runanywhere/infrastructure/download/download_service.dart'; +import 'package:runanywhere/native/dart_bridge.dart'; +import 'package:runanywhere/native/dart_bridge_auth.dart'; +import 'package:runanywhere/native/dart_bridge_device.dart'; +import 'package:runanywhere/native/dart_bridge_model_paths.dart'; +import 'package:runanywhere/native/dart_bridge_model_registry.dart' + hide ModelInfo; +import 'package:runanywhere/public/configuration/sdk_environment.dart'; +import 'package:runanywhere/public/events/event_bus.dart'; +import 'package:runanywhere/public/events/sdk_event.dart'; +import 'package:runanywhere/public/types/types.dart'; + +/// The RunAnywhere SDK entry point +/// +/// Matches Swift `RunAnywhere` enum from Public/RunAnywhere.swift +class RunAnywhere { + static SDKInitParams? _initParams; + static SDKEnvironment? _currentEnvironment; + static bool _isInitialized = false; + static bool _hasRunDiscovery = false; + static final List _registeredModels = []; + + // Note: LLM state is managed by DartBridgeLLM's native handle + // Use DartBridge.llm.currentModelId and DartBridge.llm.isLoaded + + /// Access to service container + static ServiceContainer get serviceContainer => ServiceContainer.shared; + + /// Check if SDK is initialized + static bool get isSDKInitialized => _isInitialized; + + /// Check if SDK is active + static bool get isActive => _isInitialized && _initParams != null; + + /// Get initialization parameters + static SDKInitParams? get initParams => _initParams; + + /// Current environment + static SDKEnvironment? get environment => _currentEnvironment; + + /// Get current environment (alias for environment getter) + /// Matches Swift pattern for explicit method call + static SDKEnvironment? getCurrentEnvironment() => _currentEnvironment; + + /// SDK version + static String get version => SDKConstants.version; + + /// Event bus for SDK events + static EventBus get events => EventBus.shared; + + /// Initialize the SDK + static Future initialize({ + String? apiKey, + String? baseURL, + SDKEnvironment environment = SDKEnvironment.development, + }) async { + final SDKInitParams params; + + if (environment == SDKEnvironment.development) { + params = SDKInitParams( + apiKey: apiKey ?? '', + baseURL: Uri.parse(baseURL ?? 'https://api.runanywhere.ai'), + environment: environment, + ); + } else { + if (apiKey == null || apiKey.isEmpty) { + throw SDKError.validationFailed( + 'API key is required for ${environment.description} mode', + ); + } + if (baseURL == null || baseURL.isEmpty) { + throw SDKError.validationFailed( + 'Base URL is required for ${environment.description} mode', + ); + } + final uri = Uri.tryParse(baseURL); + if (uri == null) { + throw SDKError.validationFailed('Invalid base URL: $baseURL'); + } + params = SDKInitParams( + apiKey: apiKey, + baseURL: uri, + environment: environment, + ); + } + + await initializeWithParams(params); + } + + /// Initialize with params + /// + /// Matches Swift `RunAnywhere.performCoreInit()` flow: + /// - Phase 1: DartBridge.initialize() (sync, ~1-5ms) + /// - Phase 2: DartBridge.initializeServices() (async, ~100-500ms) + static Future initializeWithParams(SDKInitParams params) async { + if (_isInitialized) return; + + final logger = SDKLogger('RunAnywhere.Init'); + EventBus.shared.publish(SDKInitializationStarted()); + + try { + _currentEnvironment = params.environment; + _initParams = params; + + // ========================================================================= + // PHASE 1: Core Init (sync, ~1-5ms, no network) + // Matches Swift: RunAnywhere.performCoreInit() → CppBridge.initialize() + // ========================================================================= + DartBridge.initialize(params.environment); + logger.debug('DartBridge initialized with platform adapter'); + + // ========================================================================= + // PHASE 2: Services Init (async, ~100-500ms, may need network) + // Matches Swift: RunAnywhere.completeServicesInitialization() + // ========================================================================= + + // Step 2.1: Initialize service bridges with credentials + // Matches Swift: CppBridge.State.initialize() + CppBridge.initializeServices() + await DartBridge.initializeServices( + apiKey: params.apiKey, + baseURL: params.baseURL.toString(), + deviceId: DartBridgeDevice.cachedDeviceId, + ); + logger.debug('Service bridges initialized'); + + // Step 2.2: Set base directory for model paths + // Matches Swift: CppBridge.ModelPaths.setBaseDirectory(documentsURL) + await DartBridge.modelPaths.setBaseDirectory(); + logger.debug('Model paths base directory configured'); + + // Step 2.3: Setup local services (HTTP, etc.) + await serviceContainer.setupLocalServices( + apiKey: params.apiKey, + baseURL: params.baseURL, + environment: params.environment, + ); + + // Step 2.4: Register device with backend (REQUIRED before authentication) + // Matches Swift: CppBridge.Device.registerIfNeeded(environment:) + // The device must be registered in the backend database before auth can work + logger.debug('Registering device with backend...'); + await _registerDeviceIfNeeded(params, logger); + + // Step 2.5: Authenticate with backend (production/staging only) + // Matches Swift: CppBridge.Auth.authenticate(apiKey:) in setupHTTP() + // This gets access_token and refresh_token from backend for subsequent API calls + if (params.environment != SDKEnvironment.development) { + logger.debug('Authenticating with backend...'); + await _authenticateWithBackend(params, logger); + } + + // Step 2.6: Initialize model registry + // CRITICAL: Uses the GLOBAL C++ registry via rac_get_model_registry() + // Models must be in the global registry for rac_llm_component_load_model to find them + logger.debug('Initializing model registry...'); + await DartBridgeModelRegistry.instance.initialize(); + + // NOTE: Discovery is NOT run here. It runs lazily on first availableModels() call. + // This matches Swift's Phase 2 behavior where discovery runs in background AFTER + // models have been registered by the app. + + _isInitialized = true; + logger.info('✅ SDK initialized (${params.environment.description})'); + EventBus.shared.publish(SDKInitializationCompleted()); + + // Track successful SDK initialization + TelemetryService.shared.trackSDKInit( + environment: params.environment.name, + success: true, + ); + } catch (e) { + logger.error('❌ SDK initialization failed: $e'); + _initParams = null; + _currentEnvironment = null; + _isInitialized = false; + _hasRunDiscovery = false; + EventBus.shared.publish(SDKInitializationFailed(e)); + + // Track failed SDK initialization + TelemetryService.shared.trackSDKInit( + environment: params.environment.name, + success: false, + ); + TelemetryService.shared.trackError( + errorCode: 'sdk_init_failed', + errorMessage: e.toString(), + ); + + rethrow; + } + } + + /// Register device with backend if not already registered. + /// Matches Swift: CppBridge.Device.registerIfNeeded(environment:) + /// This MUST happen before authentication. + static Future _registerDeviceIfNeeded( + SDKInitParams params, + SDKLogger logger, + ) async { + try { + // First ensure DartBridgeDevice is fully registered with callbacks + await DartBridgeDevice.register( + environment: params.environment, + baseURL: params.baseURL.toString(), + ); + + // Then call the C++ device registration + await DartBridgeDevice.instance.registerIfNeeded(); + logger.debug('Device registration check completed'); + } catch (e) { + // Device registration failures are non-critical + // App can still work offline with local models + logger.warning('Device registration failed (non-critical): $e'); + } + } + + /// Authenticate with backend for production/staging environments. + /// Matches Swift: CppBridge.Auth.authenticate(apiKey:) in setupHTTP() + static Future _authenticateWithBackend( + SDKInitParams params, + SDKLogger logger, + ) async { + try { + // Initialize auth manager first + await DartBridgeAuth.initialize( + environment: params.environment, + baseURL: params.baseURL.toString(), + ); + + // Get device ID - MUST fetch properly, not just check cache + // This matches Swift's DeviceIdentity.persistentUUID and Kotlin's CppBridgeDevice.getDeviceId() + final deviceId = await DartBridgeDevice.instance.getDeviceId(); + logger.debug('Authenticating with device ID: $deviceId'); + + // Authenticate with backend to get JWT tokens + final result = await DartBridgeAuth.instance.authenticate( + apiKey: params.apiKey, + deviceId: deviceId, + ); + + if (result.isSuccess) { + logger.info('Authenticated for ${params.environment.description}'); + // Set access token on HTTP service for subsequent requests + if (result.data?.accessToken != null) { + HTTPService.shared.setToken(result.data!.accessToken!); + } + } else { + // Log warning but don't fail - telemetry will fail silently + // and offline inference will still work + logger.warning( + 'Authentication failed: ${result.error}', + metadata: {'environment': params.environment.name}, + ); + } + } catch (e) { + // Log warning but don't fail initialization + logger.warning( + 'Authentication error: $e', + metadata: {'environment': params.environment.name}, + ); + } + } + + /// Get all available models from C++ registry. + /// + /// Returns all models that can be used with the SDK, including: + /// - Models registered via `registerModel()` + /// - Models discovered on filesystem during SDK init + /// + /// This reads from the C++ registry, which contains the authoritative + /// model state including localPath for downloaded models. + /// + /// Matches Swift: `return await CppBridge.ModelRegistry.shared.getAll()` + static Future> availableModels() async { + if (!_isInitialized) { + throw SDKError.notInitialized(); + } + + // Run discovery lazily on first call + // This ensures models are already registered before discovery runs + // (discovery updates local_path for registered models only) + if (!_hasRunDiscovery) { + await _runDiscovery(); + } + + // Read from C++ registry - this is the authoritative source + // Discovery populates localPath for downloaded models + final cppModels = + await DartBridgeModelRegistry.instance.getAllPublicModels(); + + // Merge with _registeredModels to include full metadata (downloadURL, etc.) + // C++ registry models may have localPath but lack some metadata + final uniqueModels = {}; + + // First add C++ registry models (have authoritative localPath) + for (final model in cppModels) { + uniqueModels[model.id] = model; + } + + // Then merge _registeredModels to fill in any missing metadata + for (final dartModel in _registeredModels) { + final existing = uniqueModels[dartModel.id]; + if (existing != null) { + // Merge: use C++ localPath but keep Dart's downloadURL and other metadata + uniqueModels[dartModel.id] = ModelInfo( + id: dartModel.id, + name: dartModel.name, + category: dartModel.category, + format: dartModel.format, + framework: dartModel.framework, + downloadURL: dartModel.downloadURL, + localPath: existing.localPath ?? dartModel.localPath, + artifactType: dartModel.artifactType, + downloadSize: dartModel.downloadSize, + contextLength: dartModel.contextLength, + supportsThinking: dartModel.supportsThinking, + thinkingPattern: dartModel.thinkingPattern, + description: dartModel.description, + source: dartModel.source, + ); + } else { + // Model only in Dart list (not yet saved to C++ registry) + uniqueModels[dartModel.id] = dartModel; + } + } + + return List.unmodifiable(uniqueModels.values.toList()); + } + + // ============================================================================ + // MARK: - LLM State (matches Swift RunAnywhere+ModelManagement.swift) + // ============================================================================ + + /// Get the currently loaded LLM model ID + /// Returns null if no LLM model is loaded. + static String? get currentModelId => DartBridge.llm.currentModelId; + + /// Check if an LLM model is currently loaded + static bool get isModelLoaded => DartBridge.llm.isLoaded; + + /// Get the currently loaded LLM model as ModelInfo + /// Matches Swift: `RunAnywhere.currentLLMModel` + static Future currentLLMModel() async { + final modelId = currentModelId; + if (modelId == null) return null; + final models = await availableModels(); + return models.cast().firstWhere( + (m) => m?.id == modelId, + orElse: () => null, + ); + } + + // ============================================================================ + // MARK: - STT State (matches Swift RunAnywhere+ModelManagement.swift) + // ============================================================================ + + /// Get the currently loaded STT model ID + /// Returns null if no STT model is loaded. + static String? get currentSTTModelId => DartBridge.stt.currentModelId; + + /// Check if an STT model is currently loaded + static bool get isSTTModelLoaded => DartBridge.stt.isLoaded; + + /// Get the currently loaded STT model as ModelInfo + /// Matches Swift: `RunAnywhere.currentSTTModel` + static Future currentSTTModel() async { + final modelId = currentSTTModelId; + if (modelId == null) return null; + final models = await availableModels(); + return models.cast().firstWhere( + (m) => m?.id == modelId, + orElse: () => null, + ); + } + + // ============================================================================ + // MARK: - TTS State (matches Swift RunAnywhere+ModelManagement.swift) + // ============================================================================ + + /// Get the currently loaded TTS voice ID + /// Returns null if no TTS voice is loaded. + static String? get currentTTSVoiceId => DartBridge.tts.currentVoiceId; + + /// Check if a TTS voice is currently loaded + static bool get isTTSVoiceLoaded => DartBridge.tts.isLoaded; + + /// Get the currently loaded TTS voice as ModelInfo + /// Matches Swift: `RunAnywhere.currentTTSVoice` (TTS uses "voice" terminology) + static Future currentTTSVoice() async { + final voiceId = currentTTSVoiceId; + if (voiceId == null) return null; + final models = await availableModels(); + return models.cast().firstWhere( + (m) => m?.id == voiceId, + orElse: () => null, + ); + } + + /// Load a model by ID + /// + /// Finds the model in the registry, gets its local path, and loads it + /// via the appropriate backend (LlamaCpp, ONNX, etc.) + static Future loadModel(String modelId) async { + if (!_isInitialized) { + throw SDKError.notInitialized(); + } + + final logger = SDKLogger('RunAnywhere.LoadModel'); + logger.info('Loading model: $modelId'); + final startTime = DateTime.now().millisecondsSinceEpoch; + + // Emit load started event + EventBus.shared.publish(SDKModelEvent.loadStarted(modelId: modelId)); + + try { + // Find the model in available models + final models = await availableModels(); + final model = models.where((m) => m.id == modelId).firstOrNull; + + if (model == null) { + throw SDKError.modelNotFound('Model not found: $modelId'); + } + + // Check if model has a local path (downloaded) + if (model.localPath == null) { + throw SDKError.modelNotDownloaded( + 'Model is not downloaded. Call downloadModel() first.', + ); + } + + // Resolve the actual model file path (matches Swift resolveModelFilePath) + // For LlamaCpp: finds the .gguf file in the model folder + // For ONNX: returns the model directory + final resolvedPath = + await DartBridge.modelPaths.resolveModelFilePath(model); + if (resolvedPath == null) { + throw SDKError.modelNotFound( + 'Could not resolve model file path for: $modelId'); + } + logger.info('Resolved model path: $resolvedPath'); + + // Unload any existing model first via the bridge + if (DartBridge.llm.isLoaded) { + logger.debug('Unloading previous model'); + DartBridge.llm.unload(); + } + + // Load model directly via DartBridgeLLM (mirrors Swift CppBridge.LLM pattern) + // The C++ layer handles finding the right backend via the service registry + logger.debug('Loading model via C++ bridge: $resolvedPath'); + await DartBridge.llm.loadModel(resolvedPath, modelId, model.name); + + // Verify the model loaded successfully + if (!DartBridge.llm.isLoaded) { + throw SDKError.modelLoadFailed( + modelId, + 'LLM model failed to load - model may not be compatible', + ); + } + + final loadTimeMs = DateTime.now().millisecondsSinceEpoch - startTime; + logger.info( + 'Model loaded successfully: ${model.name} (isLoaded=${DartBridge.llm.isLoaded})'); + + // Track model load success + TelemetryService.shared.trackModelLoad( + modelId: modelId, + modelType: 'llm', + success: true, + loadTimeMs: loadTimeMs, + ); + + // Emit load completed event + EventBus.shared.publish(SDKModelEvent.loadCompleted(modelId: modelId)); + } catch (e) { + logger.error('Failed to load model: $e'); + + // Track model load failure + TelemetryService.shared.trackModelLoad( + modelId: modelId, + modelType: 'llm', + success: false, + ); + TelemetryService.shared.trackError( + errorCode: 'model_load_failed', + errorMessage: e.toString(), + context: {'model_id': modelId}, + ); + + // Emit load failed event + EventBus.shared.publish(SDKModelEvent.loadFailed( + modelId: modelId, + error: e.toString(), + )); + + rethrow; + } + } + + /// Load an STT model + static Future loadSTTModel(String modelId) async { + if (!_isInitialized) { + throw SDKError.notInitialized(); + } + + final logger = SDKLogger('RunAnywhere.LoadSTTModel'); + logger.info('Loading STT model: $modelId'); + final startTime = DateTime.now().millisecondsSinceEpoch; + + EventBus.shared.publish(SDKModelEvent.loadStarted(modelId: modelId)); + + try { + // Find the model + final models = await availableModels(); + final model = models.where((m) => m.id == modelId).firstOrNull; + + if (model == null) { + throw SDKError.modelNotFound('STT model not found: $modelId'); + } + + if (model.localPath == null) { + throw SDKError.modelNotDownloaded( + 'STT model is not downloaded. Call downloadModel() first.', + ); + } + + // Resolve the actual model path + final resolvedPath = + await DartBridge.modelPaths.resolveModelFilePath(model); + if (resolvedPath == null) { + throw SDKError.modelNotFound( + 'Could not resolve STT model file path for: $modelId'); + } + + // Unload any existing model first + if (DartBridge.stt.isLoaded) { + DartBridge.stt.unload(); + } + + // Load model directly via DartBridgeSTT (mirrors Swift CppBridge.STT pattern) + logger.debug('Loading STT model via C++ bridge: $resolvedPath'); + await DartBridge.stt.loadModel(resolvedPath, modelId, model.name); + + if (!DartBridge.stt.isLoaded) { + throw SDKError.sttNotAvailable( + 'STT model failed to load - model may not be compatible', + ); + } + + final loadTimeMs = DateTime.now().millisecondsSinceEpoch - startTime; + + // Track STT model load success + TelemetryService.shared.trackModelLoad( + modelId: modelId, + modelType: 'stt', + success: true, + loadTimeMs: loadTimeMs, + ); + + EventBus.shared.publish(SDKModelEvent.loadCompleted(modelId: modelId)); + logger.info('STT model loaded: ${model.name}'); + } catch (e) { + logger.error('Failed to load STT model: $e'); + + // Track STT model load failure + TelemetryService.shared.trackModelLoad( + modelId: modelId, + modelType: 'stt', + success: false, + ); + TelemetryService.shared.trackError( + errorCode: 'stt_model_load_failed', + errorMessage: e.toString(), + context: {'model_id': modelId}, + ); + + EventBus.shared.publish(SDKModelEvent.loadFailed( + modelId: modelId, + error: e.toString(), + )); + rethrow; + } + } + + /// Unload the currently loaded STT model + /// Matches Swift: `RunAnywhere.unloadSTTModel()` + static Future unloadSTTModel() async { + if (!_isInitialized) { + throw SDKError.notInitialized(); + } + + DartBridge.stt.unload(); + } + + // ============================================================================ + // MARK: - STT Transcription (matches Swift RunAnywhere+STT.swift) + // ============================================================================ + + /// Transcribe audio data to text. + /// + /// [audioData] - Raw audio bytes (PCM16 at 16kHz mono expected). + /// + /// Returns the transcribed text. + /// + /// Example: + /// ```dart + /// final text = await RunAnywhere.transcribe(audioBytes); + /// ``` + /// + /// Matches Swift: `RunAnywhere.transcribe(_:)` + static Future transcribe(Uint8List audioData) async { + if (!_isInitialized) { + throw SDKError.notInitialized(); + } + + if (!DartBridge.stt.isLoaded) { + throw SDKError.sttNotAvailable( + 'No STT model loaded. Call loadSTTModel() first.', + ); + } + + final logger = SDKLogger('RunAnywhere.Transcribe'); + logger.debug('Transcribing ${audioData.length} bytes of audio...'); + final startTime = DateTime.now().millisecondsSinceEpoch; + final modelId = currentSTTModelId ?? 'unknown'; + + // Get model name for telemetry + final modelInfo = await DartBridgeModelRegistry.instance.getPublicModel(modelId); + final modelName = modelInfo?.name; + + // Calculate audio duration from bytes (PCM16 at 16kHz mono) + // Duration = bytes / 2 (16-bit = 2 bytes) / 16000 Hz * 1000 ms + final calculatedDurationMs = (audioData.length / 32).round(); + + try { + final result = await DartBridge.stt.transcribe(audioData); + final latencyMs = DateTime.now().millisecondsSinceEpoch - startTime; + + // Use calculated duration if C++ returns 0 + final audioDurationMs = result.durationMs > 0 ? result.durationMs : calculatedDurationMs; + + // Count words in transcription + final wordCount = result.text.trim().isEmpty + ? 0 + : result.text.trim().split(RegExp(r'\s+')).length; + + // Track transcription success with full metrics + TelemetryService.shared.trackTranscription( + modelId: modelId, + modelName: modelName, + audioDurationMs: audioDurationMs, + latencyMs: latencyMs, + wordCount: wordCount, + confidence: result.confidence, + language: result.language, + isStreaming: false, // Batch transcription + ); + + logger.info( + 'Transcription complete: ${result.text.length} chars, confidence: ${result.confidence}'); + return result.text; + } catch (e) { + // Track transcription failure + TelemetryService.shared.trackError( + errorCode: 'transcription_failed', + errorMessage: e.toString(), + context: {'model_id': modelId}, + ); + + logger.error('Transcription failed: $e'); + rethrow; + } + } + + /// Transcribe audio data with detailed result. + /// + /// [audioData] - Raw audio bytes (PCM16 at 16kHz mono expected). + /// + /// Returns STTResult with text, confidence, and metadata. + /// + /// Matches Swift: `RunAnywhere.transcribeWithOptions(_:options:)` + static Future transcribeWithResult(Uint8List audioData) async { + if (!_isInitialized) { + throw SDKError.notInitialized(); + } + + if (!DartBridge.stt.isLoaded) { + throw SDKError.sttNotAvailable( + 'No STT model loaded. Call loadSTTModel() first.', + ); + } + + final logger = SDKLogger('RunAnywhere.Transcribe'); + logger.debug('Transcribing ${audioData.length} bytes with details...'); + final startTime = DateTime.now().millisecondsSinceEpoch; + final modelId = currentSTTModelId ?? 'unknown'; + + // Get model name for telemetry + final modelInfo = await DartBridgeModelRegistry.instance.getPublicModel(modelId); + final modelName = modelInfo?.name; + + // Calculate audio duration from bytes (PCM16 at 16kHz mono) + final calculatedDurationMs = (audioData.length / 32).round(); + + try { + final result = await DartBridge.stt.transcribe(audioData); + final latencyMs = DateTime.now().millisecondsSinceEpoch - startTime; + + // Use calculated duration if C++ returns 0 + final audioDurationMs = result.durationMs > 0 ? result.durationMs : calculatedDurationMs; + + // Count words in transcription + final wordCount = result.text.trim().isEmpty + ? 0 + : result.text.trim().split(RegExp(r'\s+')).length; + + // Track transcription success with full metrics + TelemetryService.shared.trackTranscription( + modelId: modelId, + modelName: modelName, + audioDurationMs: audioDurationMs, + latencyMs: latencyMs, + wordCount: wordCount, + confidence: result.confidence, + language: result.language, + isStreaming: false, // Batch transcription + ); + + logger.info( + 'Transcription complete: ${result.text.length} chars, confidence: ${result.confidence}'); + return STTResult( + text: result.text, + confidence: result.confidence, + durationMs: audioDurationMs, + language: result.language, + ); + } catch (e) { + // Track transcription failure + TelemetryService.shared.trackError( + errorCode: 'transcription_failed', + errorMessage: e.toString(), + context: {'model_id': modelId}, + ); + + logger.error('Transcription failed: $e'); + rethrow; + } + } + + /// Load a TTS voice + static Future loadTTSVoice(String voiceId) async { + if (!_isInitialized) { + throw SDKError.notInitialized(); + } + + final logger = SDKLogger('RunAnywhere.LoadTTSVoice'); + logger.info('Loading TTS voice: $voiceId'); + final startTime = DateTime.now().millisecondsSinceEpoch; + + EventBus.shared.publish(SDKModelEvent.loadStarted(modelId: voiceId)); + + try { + // Find the voice model + final models = await availableModels(); + final model = models.where((m) => m.id == voiceId).firstOrNull; + + if (model == null) { + throw SDKError.modelNotFound('TTS voice not found: $voiceId'); + } + + if (model.localPath == null) { + throw SDKError.modelNotDownloaded( + 'TTS voice is not downloaded. Call downloadModel() first.', + ); + } + + // Resolve the actual voice path + final resolvedPath = + await DartBridge.modelPaths.resolveModelFilePath(model); + if (resolvedPath == null) { + throw SDKError.modelNotFound( + 'Could not resolve TTS voice path for: $voiceId'); + } + + // Unload any existing voice first + if (DartBridge.tts.isLoaded) { + DartBridge.tts.unload(); + } + + // Load voice directly via DartBridgeTTS (mirrors Swift CppBridge.TTS pattern) + logger.debug('Loading TTS voice via C++ bridge: $resolvedPath'); + await DartBridge.tts.loadVoice(resolvedPath, voiceId, model.name); + + if (!DartBridge.tts.isLoaded) { + throw SDKError.ttsNotAvailable( + 'TTS voice failed to load - voice may not be compatible', + ); + } + + final loadTimeMs = DateTime.now().millisecondsSinceEpoch - startTime; + + // Track TTS voice load success + TelemetryService.shared.trackModelLoad( + modelId: voiceId, + modelType: 'tts', + success: true, + loadTimeMs: loadTimeMs, + ); + + EventBus.shared.publish(SDKModelEvent.loadCompleted(modelId: voiceId)); + logger.info('TTS voice loaded: ${model.name}'); + } catch (e) { + logger.error('Failed to load TTS voice: $e'); + + // Track TTS voice load failure + TelemetryService.shared.trackModelLoad( + modelId: voiceId, + modelType: 'tts', + success: false, + ); + TelemetryService.shared.trackError( + errorCode: 'tts_voice_load_failed', + errorMessage: e.toString(), + context: {'voice_id': voiceId}, + ); + + EventBus.shared.publish(SDKModelEvent.loadFailed( + modelId: voiceId, + error: e.toString(), + )); + rethrow; + } + } + + /// Unload the currently loaded TTS voice + /// Matches Swift: `RunAnywhere.unloadTTSVoice()` + static Future unloadTTSVoice() async { + if (!_isInitialized) { + throw SDKError.notInitialized(); + } + + DartBridge.tts.unload(); + } + + // ============================================================================ + // MARK: - TTS Synthesis (matches Swift RunAnywhere+TTS.swift) + // ============================================================================ + + /// Synthesize speech from text. + /// + /// [text] - Text to synthesize. + /// [rate] - Speech rate (0.5 to 2.0, 1.0 is normal). Optional. + /// [pitch] - Speech pitch (0.5 to 2.0, 1.0 is normal). Optional. + /// [volume] - Speech volume (0.0 to 1.0). Optional. + /// + /// Returns audio samples as Float32List and metadata. + /// + /// Example: + /// ```dart + /// final result = await RunAnywhere.synthesize('Hello world'); + /// // result.samples contains PCM audio data + /// // result.sampleRate is typically 22050 Hz + /// ``` + /// + /// Matches Swift: `RunAnywhere.synthesize(_:)` + static Future synthesize( + String text, { + double rate = 1.0, + double pitch = 1.0, + double volume = 1.0, + }) async { + if (!_isInitialized) { + throw SDKError.notInitialized(); + } + + if (!DartBridge.tts.isLoaded) { + throw SDKError.ttsNotAvailable( + 'No TTS voice loaded. Call loadTTSVoice() first.', + ); + } + + final logger = SDKLogger('RunAnywhere.Synthesize'); + logger.debug( + 'Synthesizing: "${text.substring(0, text.length.clamp(0, 50))}..."'); + final startTime = DateTime.now().millisecondsSinceEpoch; + final voiceId = currentTTSVoiceId ?? 'unknown'; + + // Get model name for telemetry + final modelInfo = await DartBridgeModelRegistry.instance.getPublicModel(voiceId); + final modelName = modelInfo?.name; + + try { + final result = await DartBridge.tts.synthesize( + text, + rate: rate, + pitch: pitch, + volume: volume, + ); + final latencyMs = DateTime.now().millisecondsSinceEpoch - startTime; + + // Calculate audio size in bytes (Float32 samples = 4 bytes each) + final audioSizeBytes = result.samples.length * 4; + + // Track synthesis success with full metrics + TelemetryService.shared.trackSynthesis( + voiceId: voiceId, + modelName: modelName, + textLength: text.length, + audioDurationMs: result.durationMs, + latencyMs: latencyMs, + sampleRate: result.sampleRate, + audioSizeBytes: audioSizeBytes, + ); + + logger.info( + 'Synthesis complete: ${result.samples.length} samples, ${result.sampleRate} Hz'); + return TTSResult( + samples: result.samples, + sampleRate: result.sampleRate, + durationMs: result.durationMs, + ); + } catch (e) { + // Track synthesis failure + TelemetryService.shared.trackError( + errorCode: 'synthesis_failed', + errorMessage: e.toString(), + context: {'voice_id': voiceId, 'text_length': text.length}, + ); + + logger.error('Synthesis failed: $e'); + rethrow; + } + } + + /// Unload current model + static Future unloadModel() async { + if (!_isInitialized) return; + + final logger = SDKLogger('RunAnywhere.UnloadModel'); + + if (DartBridge.llm.isLoaded) { + final modelId = DartBridge.llm.currentModelId ?? 'unknown'; + logger.info('Unloading model: $modelId'); + + EventBus.shared.publish(SDKModelEvent.unloadStarted(modelId: modelId)); + + // Unload via C++ bridge (matches Swift CppBridge.LLM pattern) + DartBridge.llm.unload(); + + EventBus.shared.publish(SDKModelEvent.unloadCompleted(modelId: modelId)); + logger.info('Model unloaded'); + } + } + + // ============================================================================ + // MARK: - Voice Agent (matches Swift RunAnywhere+VoiceAgent.swift) + // ============================================================================ + + /// Check if the voice agent is ready (all required components loaded). + /// + /// Returns true if STT, LLM, and TTS are all loaded and ready. + /// + /// Matches Swift: `RunAnywhere.isVoiceAgentReady` + static bool get isVoiceAgentReady { + return DartBridge.stt.isLoaded && + DartBridge.llm.isLoaded && + DartBridge.tts.isLoaded; + } + + /// Get the current state of all voice agent components (STT, LLM, TTS). + /// + /// Use this to check which models are loaded and ready for the voice pipeline. + /// Models are loaded via the individual APIs (loadSTTModel, loadModel, loadTTSVoice). + /// + /// Matches Swift: `RunAnywhere.getVoiceAgentComponentStates()` + static VoiceAgentComponentStates getVoiceAgentComponentStates() { + final sttId = currentSTTModelId; + final llmId = currentModelId; + final ttsId = currentTTSVoiceId; + + return VoiceAgentComponentStates( + stt: sttId != null + ? ComponentLoadState.loaded(modelId: sttId) + : const ComponentLoadState.notLoaded(), + llm: llmId != null + ? ComponentLoadState.loaded(modelId: llmId) + : const ComponentLoadState.notLoaded(), + tts: ttsId != null + ? ComponentLoadState.loaded(modelId: ttsId) + : const ComponentLoadState.notLoaded(), + ); + } + + /// Start a voice session with audio capture, VAD, and full voice pipeline. + /// + /// This is the simplest way to integrate voice assistant functionality. + /// The session handles audio capture, VAD, and processing internally. + /// + /// Example: + /// ```dart + /// final session = await RunAnywhere.startVoiceSession(); + /// + /// // Consume events + /// session.events.listen((event) { + /// if (event is VoiceSessionListening) { + /// audioMeter = event.audioLevel; + /// } else if (event is VoiceSessionTurnCompleted) { + /// userText = event.transcript; + /// assistantText = event.response; + /// } + /// }); + /// + /// // Later... + /// session.stop(); + /// ``` + /// + /// Matches Swift: `RunAnywhere.startVoiceSession(config:)` + static Future startVoiceSession({ + VoiceSessionConfig config = VoiceSessionConfig.defaultConfig, + }) async { + if (!_isInitialized) { + throw SDKError.notInitialized(); + } + + final logger = SDKLogger('RunAnywhere.VoiceSession'); + + // Create the session handle with all necessary callbacks + final session = VoiceSessionHandle( + config: config, + processAudioCallback: _processVoiceAgentAudio, + isVoiceAgentReadyCallback: () async => isVoiceAgentReady, + initializeVoiceAgentCallback: _initializeVoiceAgentWithLoadedModels, + ); + + logger.info('Voice session created with callbacks'); + + // Start the session (this will verify voice agent readiness) + try { + await session.start(); + logger.info('Voice session started successfully'); + } catch (e) { + logger.error('Failed to start voice session: $e'); + rethrow; + } + + return session; + } + + /// Initialize voice agent using already-loaded models. + /// + /// This is called internally by VoiceSessionHandle when starting a session. + /// It verifies all components (STT, LLM, TTS) are loaded. + /// + /// Matches Swift: `RunAnywhere.initializeVoiceAgentWithLoadedModels()` + static Future _initializeVoiceAgentWithLoadedModels() async { + final logger = SDKLogger('RunAnywhere.VoiceAgent'); + + if (!isVoiceAgentReady) { + throw SDKError.voiceAgentNotReady( + 'Voice agent components not ready. Load STT, LLM, and TTS models first.', + ); + } + + try { + await DartBridge.voiceAgent.initializeWithLoadedModels(); + logger.info('Voice agent initialized with loaded models'); + } catch (e) { + logger.error('Failed to initialize voice agent: $e'); + rethrow; + } + } + + /// Process audio through the voice agent pipeline (STT -> LLM -> TTS). + /// + /// This is called internally by VoiceSessionHandle during audio processing. + /// + /// Matches Swift: `RunAnywhere.processVoiceTurn(_:)` + static Future _processVoiceAgentAudio( + Uint8List audioData, + ) async { + final logger = SDKLogger('RunAnywhere.VoiceAgent'); + logger.debug('Processing ${audioData.length} bytes of audio...'); + + try { + // Use the DartBridgeVoiceAgent to process the voice turn + final result = await DartBridge.voiceAgent.processVoiceTurn(audioData); + + // Audio is already in WAV format (C++ voice agent converts Float32 TTS to WAV) + // No conversion needed - pass directly to playback + final synthesizedAudio = result.audioWavData.isNotEmpty + ? result.audioWavData + : null; + + logger.info( + 'Voice turn complete: transcript="${result.transcription.substring(0, result.transcription.length.clamp(0, 50))}", ' + 'response="${result.response.substring(0, result.response.length.clamp(0, 50))}", ' + 'audio=${synthesizedAudio?.length ?? 0} bytes', + ); + + return VoiceAgentProcessResult( + speechDetected: result.transcription.isNotEmpty, + transcription: result.transcription, + response: result.response, + synthesizedAudio: synthesizedAudio, + ); + } catch (e) { + logger.error('Voice turn processing failed: $e'); + rethrow; + } + } + + /// Cleanup voice agent resources. + /// + /// Call this when you're done with voice agent functionality. + /// + /// Matches Swift: `RunAnywhere.cleanupVoiceAgent()` + static void cleanupVoiceAgent() { + DartBridge.voiceAgent.cleanup(); + } + + // ============================================================================ + // Text Generation (LLM) + // ============================================================================ + + /// Simple text generation - returns only the generated text + /// + /// Matches Swift `RunAnywhere.chat(_:)`. + /// + /// ```dart + /// final response = await RunAnywhere.chat('Hello, world!'); + /// print(response); + /// ``` + static Future chat(String prompt) async { + final result = await generate(prompt); + return result.text; + } + + /// Full text generation with metrics + /// + /// Matches Swift `RunAnywhere.generate(_:options:)`. + /// + /// ```dart + /// final result = await RunAnywhere.generate( + /// 'Explain quantum computing', + /// options: LLMGenerationOptions(maxTokens: 200, temperature: 0.7), + /// ); + /// print('Response: ${result.text}'); + /// print('Latency: ${result.latencyMs}ms'); + /// ``` + static Future generate( + String prompt, { + LLMGenerationOptions? options, + }) async { + if (!_isInitialized) { + throw SDKError.notInitialized(); + } + + final opts = options ?? const LLMGenerationOptions(); + final startTime = DateTime.now(); + + // Verify model is loaded via DartBridgeLLM (mirrors Swift CppBridge.LLM pattern) + if (!DartBridge.llm.isLoaded) { + throw SDKError.componentNotReady( + 'LLM model not loaded. Call loadModel() first.', + ); + } + + final modelId = DartBridge.llm.currentModelId ?? 'unknown'; + + // Get model name from registry for telemetry + final modelInfo = await DartBridgeModelRegistry.instance.getPublicModel(modelId); + final modelName = modelInfo?.name; + + try { + // Generate directly via DartBridgeLLM (calls rac_llm_component_generate) + final result = await DartBridge.llm.generate( + prompt, + maxTokens: opts.maxTokens, + temperature: opts.temperature, + ); + + final endTime = DateTime.now(); + final latencyMs = endTime.difference(startTime).inMicroseconds / 1000.0; + final tokensPerSecond = result.totalTimeMs > 0 + ? (result.completionTokens / result.totalTimeMs) * 1000 + : 0.0; + + // Track generation success with full metrics (mirrors other SDKs) + TelemetryService.shared.trackGeneration( + modelId: modelId, + modelName: modelName, + promptTokens: result.promptTokens, + completionTokens: result.completionTokens, + latencyMs: latencyMs.round(), + temperature: opts.temperature, + maxTokens: opts.maxTokens, + contextLength: 8192, // Default context length for LlamaCpp + tokensPerSecond: tokensPerSecond, + isStreaming: false, + ); + + return LLMGenerationResult( + text: result.text, + inputTokens: result.promptTokens, + tokensUsed: result.completionTokens, + modelUsed: modelId, + latencyMs: latencyMs, + framework: 'llamacpp', + tokensPerSecond: tokensPerSecond, + ); + } catch (e) { + // Track generation failure + TelemetryService.shared.trackError( + errorCode: 'generation_failed', + errorMessage: e.toString(), + context: {'model_id': modelId}, + ); + throw SDKError.generationFailed('$e'); + } + } + + /// Streaming text generation + /// + /// Matches Swift `RunAnywhere.generateStream(_:options:)`. + /// + /// Returns an `LLMStreamingResult` containing: + /// - `stream`: Stream of tokens as they are generated + /// - `result`: Future that completes with final generation metrics + /// - `cancel`: Function to cancel the generation + /// + /// ```dart + /// final result = await RunAnywhere.generateStream('Tell me a story'); + /// + /// // Consume tokens as they arrive + /// await for (final token in result.stream) { + /// print(token); + /// } + /// + /// // Get final metrics after stream completes + /// final metrics = await result.result; + /// print('Tokens: ${metrics.tokensUsed}'); + /// + /// // Or cancel early if needed + /// result.cancel(); + /// ``` + static Future generateStream( + String prompt, { + LLMGenerationOptions? options, + }) async { + if (!_isInitialized) { + throw SDKError.notInitialized(); + } + + final opts = options ?? const LLMGenerationOptions(); + final startTime = DateTime.now(); + DateTime? firstTokenTime; + + // Verify model is loaded via DartBridgeLLM (mirrors Swift CppBridge.LLM pattern) + if (!DartBridge.llm.isLoaded) { + throw SDKError.componentNotReady( + 'LLM model not loaded. Call loadModel() first.', + ); + } + + final modelId = DartBridge.llm.currentModelId ?? 'unknown'; + + // Get model name from registry for telemetry + final modelInfo = await DartBridgeModelRegistry.instance.getPublicModel(modelId); + final modelName = modelInfo?.name; + + // Create a broadcast stream controller for the tokens + final controller = StreamController.broadcast(); + final allTokens = []; + + // Start streaming generation via DartBridgeLLM + final tokenStream = DartBridge.llm.generateStream( + prompt, + maxTokens: opts.maxTokens, + temperature: opts.temperature, + ); + + // Forward tokens and collect them, track subscription in bridge for cancellation + DartBridge.llm.setActiveStreamSubscription( + tokenStream.listen( + (token) { + // Track first token time + firstTokenTime ??= DateTime.now(); + allTokens.add(token); + if (!controller.isClosed) { + controller.add(token); + } + }, + onError: (Object error) { + // Track streaming generation error + TelemetryService.shared.trackError( + errorCode: 'streaming_generation_failed', + errorMessage: error.toString(), + context: {'model_id': modelId}, + ); + if (!controller.isClosed) { + controller.addError(error); + } + }, + onDone: () { + if (!controller.isClosed) { + unawaited(controller.close()); + } + // Clear subscription when done + DartBridge.llm.setActiveStreamSubscription(null); + }, + ), + ); + + // Build result future that completes when stream is done + final resultFuture = controller.stream.toList().then((_) { + final endTime = DateTime.now(); + final latencyMs = endTime.difference(startTime).inMicroseconds / 1000.0; + final tokensPerSecond = + latencyMs > 0 ? allTokens.length / (latencyMs / 1000) : 0.0; + + // Calculate time to first token + int? timeToFirstTokenMs; + if (firstTokenTime != null) { + timeToFirstTokenMs = firstTokenTime!.difference(startTime).inMilliseconds; + } + + // Estimate tokens (~4 chars per token) + final promptTokens = (prompt.length / 4).ceil(); + final completionTokens = allTokens.length; + + // Track streaming generation success with full metrics (mirrors other SDKs) + TelemetryService.shared.trackGeneration( + modelId: modelId, + modelName: modelName, + promptTokens: promptTokens, + completionTokens: completionTokens, + latencyMs: latencyMs.round(), + temperature: opts.temperature, + maxTokens: opts.maxTokens, + contextLength: 8192, // Default context length for LlamaCpp + tokensPerSecond: tokensPerSecond, + timeToFirstTokenMs: timeToFirstTokenMs, + isStreaming: true, + ); + + return LLMGenerationResult( + text: allTokens.join(), + inputTokens: promptTokens, + tokensUsed: completionTokens, + modelUsed: modelId, + latencyMs: latencyMs, + framework: 'llamacpp', + tokensPerSecond: tokensPerSecond, + ); + }); + + return LLMStreamingResult( + stream: controller.stream, + result: resultFuture, + cancel: () { + // Cancel via the bridge (handles both stream subscription and native cancel) + DartBridge.llm.cancelGeneration(); + }, + ); + } + + /// Cancel ongoing generation + static Future cancelGeneration() async { + // Cancel via the bridge (handles both stream subscription and service) + DartBridge.llm.cancelGeneration(); + } + + /// Download a model by ID + /// + /// Matches Swift `RunAnywhere.downloadModel(_:)`. + /// + /// ```dart + /// await for (final progress in RunAnywhere.downloadModel('my-model-id')) { + /// print('Progress: ${(progress.percentage * 100).toStringAsFixed(1)}%'); + /// if (progress.state.isCompleted) break; + /// } + /// ``` + static Stream downloadModel(String modelId) async* { + if (!_isInitialized) { + throw SDKError.notInitialized(); + } + + final logger = SDKLogger('RunAnywhere.Download'); + logger.info('📥 Starting download for model: $modelId'); + final startTime = DateTime.now().millisecondsSinceEpoch; + + await for (final progress + in ModelDownloadService.shared.downloadModel(modelId)) { + // Convert internal progress to public DownloadProgress + yield DownloadProgress( + bytesDownloaded: progress.bytesDownloaded, + totalBytes: progress.totalBytes, + state: _mapDownloadStage(progress.stage), + ); + + // Log progress at intervals + if (progress.stage == ModelDownloadStage.downloading) { + final pct = (progress.overallProgress * 100).toStringAsFixed(1); + if (progress.bytesDownloaded % (1024 * 1024) < 10000) { + // Log every ~1MB + logger.debug('Download progress: $pct%'); + } + } else if (progress.stage == ModelDownloadStage.extracting) { + logger.info('Extracting model...'); + } else if (progress.stage == ModelDownloadStage.completed) { + final downloadTimeMs = DateTime.now().millisecondsSinceEpoch - startTime; + logger.info('✅ Download completed for model: $modelId'); + + // Track download success + TelemetryService.shared.trackModelDownload( + modelId: modelId, + success: true, + downloadTimeMs: downloadTimeMs, + sizeBytes: progress.totalBytes, + ); + } else if (progress.stage == ModelDownloadStage.failed) { + logger.error('❌ Download failed: ${progress.error}'); + + // Track download failure + TelemetryService.shared.trackModelDownload( + modelId: modelId, + success: false, + ); + TelemetryService.shared.trackError( + errorCode: 'download_failed', + errorMessage: progress.error ?? 'Unknown error', + context: {'model_id': modelId}, + ); + } + } + } + + /// Map internal download stage to public state + static DownloadProgressState _mapDownloadStage(ModelDownloadStage stage) { + switch (stage) { + case ModelDownloadStage.downloading: + case ModelDownloadStage.extracting: + case ModelDownloadStage.verifying: + return DownloadProgressState.downloading; + case ModelDownloadStage.completed: + return DownloadProgressState.completed; + case ModelDownloadStage.failed: + return DownloadProgressState.failed; + case ModelDownloadStage.cancelled: + return DownloadProgressState.cancelled; + } + } + + /// Delete a stored model + /// + /// Matches Swift `RunAnywhere.deleteStoredModel(modelId:)`. + static Future deleteStoredModel(String modelId) async { + if (!_isInitialized) { + throw SDKError.notInitialized(); + } + await DartBridgeModelRegistry.instance.removeModel(modelId); + EventBus.shared.publish(SDKModelEvent.deleted(modelId: modelId)); + } + + /// Get storage info including device storage, app storage, and downloaded models. + /// + /// Matches Swift: `RunAnywhere.getStorageInfo()` + static Future getStorageInfo() async { + if (!_isInitialized) { + return StorageInfo.empty; + } + + try { + // Get device storage info + final deviceStorage = await _getDeviceStorageInfo(); + + // Get app storage info + final appStorage = await _getAppStorageInfo(); + + // Get downloaded models with sizes + final storedModels = await getDownloadedModelsWithInfo(); + final modelMetrics = storedModels + .map((m) => + ModelStorageMetrics(model: m.modelInfo, sizeOnDisk: m.size)) + .toList(); + + return StorageInfo( + appStorage: appStorage, + deviceStorage: deviceStorage, + models: modelMetrics, + ); + } catch (e) { + SDKLogger('RunAnywhere.Storage').error('Failed to get storage info: $e'); + return StorageInfo.empty; + } + } + + /// Get device storage information. + static Future _getDeviceStorageInfo() async { + try { + // Get device storage info from documents directory + final modelsDir = DartBridgeModelPaths.instance.getModelsDirectory(); + if (modelsDir == null) { + return const DeviceStorageInfo( + totalSpace: 0, freeSpace: 0, usedSpace: 0); + } + + // Calculate total storage used by models + final modelsDirSize = await _getDirectorySize(modelsDir); + + // For iOS/Android, we can't easily get device free space without native code + // Return what we know: the models directory size + return DeviceStorageInfo( + totalSpace: modelsDirSize, + freeSpace: 0, // Would need native code to get real free space + usedSpace: modelsDirSize, + ); + } catch (e) { + return const DeviceStorageInfo(totalSpace: 0, freeSpace: 0, usedSpace: 0); + } + } + + /// Get app storage breakdown. + static Future _getAppStorageInfo() async { + try { + // Get models directory size + final modelsDir = DartBridgeModelPaths.instance.getModelsDirectory(); + final modelsDirSize = + modelsDir != null ? await _getDirectorySize(modelsDir) : 0; + + // For now, we'll estimate cache and app support as 0 + // since we don't have a dedicated cache directory + return AppStorageInfo( + documentsSize: modelsDirSize, + cacheSize: 0, + appSupportSize: 0, + totalSize: modelsDirSize, + ); + } catch (e) { + return const AppStorageInfo( + documentsSize: 0, + cacheSize: 0, + appSupportSize: 0, + totalSize: 0, + ); + } + } + + /// Calculate directory size recursively. + static Future _getDirectorySize(String path) async { + try { + final dir = Directory(path); + if (!await dir.exists()) return 0; + + int totalSize = 0; + await for (final entity + in dir.list(recursive: true, followLinks: false)) { + if (entity is File) { + try { + totalSize += await entity.length(); + } catch (_) { + // Skip files we can't read + } + } + } + return totalSize; + } catch (e) { + return 0; + } + } + + /// Get downloaded models with their file sizes. + /// + /// Returns a list of StoredModel objects with size information populated + /// from the actual files on disk. + /// + /// Matches Swift: `RunAnywhere.getDownloadedModelsWithInfo()` + static Future> getDownloadedModelsWithInfo() async { + if (!_isInitialized) { + return []; + } + + try { + // Get all models that have localPath set (are downloaded) + final allModels = await availableModels(); + final downloadedModels = + allModels.where((m) => m.localPath != null).toList(); + + final storedModels = []; + + for (final model in downloadedModels) { + // Get the actual file size + final localPath = model.localPath!.toFilePath(); + int fileSize = 0; + + try { + // Check if it's a directory (for multi-file models) or single file + final file = File(localPath); + final dir = Directory(localPath); + + if (await file.exists()) { + fileSize = await file.length(); + } else if (await dir.exists()) { + fileSize = await _getDirectorySize(localPath); + } + } catch (e) { + SDKLogger('RunAnywhere.Storage') + .debug('Could not get size for ${model.id}: $e'); + } + + storedModels.add(StoredModel( + modelInfo: model, + size: fileSize, + )); + } + + return storedModels; + } catch (e) { + SDKLogger('RunAnywhere.Storage') + .error('Failed to get downloaded models: $e'); + return []; + } + } + + /// Reset SDK state + static Future reset() async { + // Flush pending telemetry events before reset + await TelemetryService.shared.shutdown(); + + _isInitialized = false; + _hasRunDiscovery = false; + _initParams = null; + _currentEnvironment = null; + _registeredModels.clear(); + DartBridgeModelRegistry.instance.shutdown(); + serviceContainer.reset(); + } + + /// Update the download status for a model in C++ registry + /// + /// Called by ModelDownloadService after a successful download. + /// Matches Swift: CppBridge.ModelRegistry.shared.updateDownloadStatus() + static Future updateModelDownloadStatus( + String modelId, String? localPath) async { + await DartBridgeModelRegistry.instance + .updateDownloadStatus(modelId, localPath); + } + + /// Remove a model from the C++ registry + /// + /// Called when a model is deleted. + /// Matches Swift: CppBridge.ModelRegistry.shared.remove() + static Future removeModel(String modelId) async { + await DartBridgeModelRegistry.instance.removeModel(modelId); + } + + /// Internal: Run discovery once on first availableModels() call + /// This ensures models are registered before discovery runs + static Future _runDiscovery() async { + if (_hasRunDiscovery) return; + + final logger = SDKLogger('RunAnywhere.Discovery'); + logger.debug( + 'Running lazy discovery (models should already be registered)...'); + + final result = + await DartBridgeModelRegistry.instance.discoverDownloadedModels(); + + _hasRunDiscovery = true; + + if (result.discoveredModels.isNotEmpty) { + logger.info( + '📦 Discovered ${result.discoveredModels.length} downloaded models'); + for (final model in result.discoveredModels) { + logger.debug( + ' - ${model.modelId} -> ${model.localPath} (framework: ${model.framework})'); + } + } else { + logger.debug('No downloaded models discovered'); + } + } + + /// Re-discover models on the filesystem via C++ registry. + /// + /// This scans the filesystem for downloaded models and updates the + /// C++ registry with localPath for discovered models. + /// + /// Note: This is called automatically on first availableModels() call. + /// You typically don't need to call this manually unless you've done + /// manual file operations outside the SDK. + /// + /// Matches Swift: CppBridge.ModelRegistry.shared.discoverDownloadedModels() + static Future refreshDiscoveredModels() async { + if (!_isInitialized) return; + + final logger = SDKLogger('RunAnywhere.Discovery'); + final result = + await DartBridgeModelRegistry.instance.discoverDownloadedModels(); + if (result.discoveredModels.isNotEmpty) { + logger.info( + 'Discovery found ${result.discoveredModels.length} downloaded models'); + } + } + + // ============================================================================ + // Model Registration (matches Swift RunAnywhere.registerModel pattern) + // ============================================================================ + + /// Register a model with the SDK. + /// + /// Matches Swift `RunAnywhere.registerModel(id:name:url:framework:modality:artifactType:memoryRequirement:)`. + /// + /// This saves the model to the C++ registry so it can be discovered and loaded. + /// + /// ```dart + /// RunAnywhere.registerModel( + /// id: 'smollm2-360m-q8_0', + /// name: 'SmolLM2 360M Q8_0', + /// url: Uri.parse('https://huggingface.co/.../model.gguf'), + /// framework: InferenceFramework.llamaCpp, + /// memoryRequirement: 500000000, + /// ); + /// ``` + static ModelInfo registerModel({ + String? id, + required String name, + required Uri url, + required InferenceFramework framework, + ModelCategory modality = ModelCategory.language, + ModelArtifactType? artifactType, + int? memoryRequirement, + bool supportsThinking = false, + }) { + final modelId = + id ?? name.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), '-'); + + final format = _inferFormat(url.path); + + final model = ModelInfo( + id: modelId, + name: name, + category: modality, + format: format, + framework: framework, + downloadURL: url, + artifactType: artifactType ?? ModelArtifactType.infer(url, format), + downloadSize: memoryRequirement, + supportsThinking: supportsThinking, + source: ModelSource.local, + ); + + _registeredModels.add(model); + + // Save to C++ registry (fire-and-forget, matches Swift pattern) + // This is critical for model discovery and loading to work correctly + _saveToCppRegistry(model); + + return model; + } + + /// Save model to C++ registry (fire-and-forget). + /// Matches Swift: `Task { try await CppBridge.ModelRegistry.shared.save(modelInfo) }` + static void _saveToCppRegistry(ModelInfo model) { + // Fire-and-forget save to C++ registry + unawaited( + DartBridgeModelRegistry.instance.savePublicModel(model).then((success) { + final logger = SDKLogger('RunAnywhere.Models'); + if (!success) { + logger.warning('Failed to save model to C++ registry: ${model.id}'); + } + }).catchError((Object error) { + SDKLogger('RunAnywhere.Models') + .error('Error saving model to C++ registry: $error'); + }), + ); + } + + static ModelFormat _inferFormat(String path) { + final lower = path.toLowerCase(); + if (lower.endsWith('.gguf')) return ModelFormat.gguf; + if (lower.endsWith('.onnx')) return ModelFormat.onnx; + if (lower.endsWith('.bin')) return ModelFormat.bin; + if (lower.endsWith('.ort')) return ModelFormat.ort; + return ModelFormat.unknown; + } +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/public/types/capability_types.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/public/types/capability_types.dart new file mode 100644 index 000000000..95355ad5e --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/public/types/capability_types.dart @@ -0,0 +1,27 @@ +/// Capability Types +/// +/// Metadata types for loaded STT/TTS capabilities. +/// Mirrors Swift STTCapability and TTSCapability. +library capability_types; + +/// Speech-to-Text capability information +class STTCapability { + final String modelId; + final String? modelName; + + const STTCapability({ + required this.modelId, + this.modelName, + }); +} + +/// Text-to-Speech capability information +class TTSCapability { + final String voiceId; + final String? voiceName; + + const TTSCapability({ + required this.voiceId, + this.voiceName, + }); +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/public/types/configuration_types.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/public/types/configuration_types.dart new file mode 100644 index 000000000..93f112d63 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/public/types/configuration_types.dart @@ -0,0 +1,15 @@ +/// Configuration Types +/// +/// Types for SDK configuration. +library configuration_types; + +/// Supabase configuration for development mode +class SupabaseConfig { + final Uri projectURL; + final String anonKey; + + const SupabaseConfig({ + required this.projectURL, + required this.anonKey, + }); +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/public/types/download_types.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/public/types/download_types.dart new file mode 100644 index 000000000..3a66f33ca --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/public/types/download_types.dart @@ -0,0 +1,50 @@ +/// Download Types +/// +/// Types for model download progress and state. +/// Mirrors Swift DownloadProgress. +library download_types; + +/// Download progress information +/// Matches Swift `DownloadProgress`. +class DownloadProgress { + final int bytesDownloaded; + final int totalBytes; + final DownloadProgressState state; + final DownloadProgressStage stage; + + const DownloadProgress({ + required this.bytesDownloaded, + required this.totalBytes, + required this.state, + this.stage = DownloadProgressStage.downloading, + }); + + /// Overall progress from 0.0 to 1.0 + double get overallProgress => + totalBytes > 0 ? bytesDownloaded / totalBytes : 0.0; + + /// Legacy alias for overallProgress + double get percentage => overallProgress; +} + +/// Download progress state +enum DownloadProgressState { + downloading, + completed, + failed, + cancelled; + + bool get isCompleted => this == DownloadProgressState.completed; + bool get isFailed => this == DownloadProgressState.failed; +} + +/// Download progress stage (more detailed than state) +enum DownloadProgressStage { + queued, + downloading, + extracting, + verifying, + completed, + failed, + cancelled, +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/public/types/generation_types.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/public/types/generation_types.dart new file mode 100644 index 000000000..dcf39885e --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/public/types/generation_types.dart @@ -0,0 +1,145 @@ +/// Generation Types +/// +/// Types for LLM text generation, STT transcription, and TTS synthesis. +/// Mirrors Swift LLMGenerationOptions, LLMGenerationResult, STTOutput, and TTSOutput. +library generation_types; + +import 'dart:typed_data'; + +import 'package:runanywhere/core/types/model_types.dart'; + +/// Options for LLM text generation +/// Matches Swift's LLMGenerationOptions +class LLMGenerationOptions { + final int maxTokens; + final double temperature; + final double topP; + final List stopSequences; + final bool streamingEnabled; + final InferenceFramework? preferredFramework; + final String? systemPrompt; + + const LLMGenerationOptions({ + this.maxTokens = 100, + this.temperature = 0.8, + this.topP = 1.0, + this.stopSequences = const [], + this.streamingEnabled = false, + this.preferredFramework, + this.systemPrompt, + }); +} + +/// Result of LLM text generation +/// Matches Swift's LLMGenerationResult +class LLMGenerationResult { + final String text; + final String? thinkingContent; + final int inputTokens; + final int tokensUsed; + final String modelUsed; + final double latencyMs; + final String? framework; + final double tokensPerSecond; + final double? timeToFirstTokenMs; + final int thinkingTokens; + final int responseTokens; + + const LLMGenerationResult({ + required this.text, + this.thinkingContent, + required this.inputTokens, + required this.tokensUsed, + required this.modelUsed, + required this.latencyMs, + this.framework, + required this.tokensPerSecond, + this.timeToFirstTokenMs, + this.thinkingTokens = 0, + this.responseTokens = 0, + }); +} + +/// Result of streaming LLM text generation +/// Matches Swift's LLMStreamingResult +/// +/// Contains: +/// - `stream`: Stream of tokens as they are generated +/// - `result`: Future that completes with final generation metrics +/// - `cancel`: Function to cancel the generation +class LLMStreamingResult { + /// Stream of tokens as they are generated. + /// Listen to this to receive real-time token updates. + final Stream stream; + + /// Future that completes with the final generation result and metrics + /// when streaming finishes. Wait for this after consuming the stream + /// to get the complete analytics. + final Future result; + + /// Function to cancel the ongoing generation. + /// Call this to stop generation early (e.g., user pressed stop button). + final void Function() cancel; + + const LLMStreamingResult({ + required this.stream, + required this.result, + required this.cancel, + }); +} + +/// Result of STT transcription +/// Matches Swift's STTOutput +class STTResult { + /// The transcribed text + final String text; + + /// Confidence score (0.0 to 1.0) + final double confidence; + + /// Duration of audio processed in milliseconds + final int durationMs; + + /// Detected language (if available) + final String? language; + + const STTResult({ + required this.text, + required this.confidence, + required this.durationMs, + this.language, + }); + + @override + String toString() => + 'STTResult(text: "$text", confidence: $confidence, durationMs: $durationMs, language: $language)'; +} + +/// Result of TTS synthesis +/// Matches Swift's TTSOutput +class TTSResult { + /// Audio samples as PCM float data + final Float32List samples; + + /// Sample rate in Hz (typically 22050 for Piper) + final int sampleRate; + + /// Duration of audio in milliseconds + final int durationMs; + + const TTSResult({ + required this.samples, + required this.sampleRate, + required this.durationMs, + }); + + /// Duration in seconds + double get durationSeconds => durationMs / 1000.0; + + /// Number of audio samples + int get numSamples => samples.length; + + @override + String toString() => + 'TTSResult(samples: ${samples.length}, sampleRate: $sampleRate, durationMs: $durationMs)'; +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/public/types/message_types.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/public/types/message_types.dart new file mode 100644 index 000000000..26a0c93e4 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/public/types/message_types.dart @@ -0,0 +1,14 @@ +/// Message Types +/// +/// Types for conversation messages. +/// Mirrors Swift MessageRole from the RunAnywhere SDK. +library message_types; + +/// Role of a message in a conversation +enum MessageRole { + system, + user, + assistant; + + String get rawValue => name; +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/public/types/types.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/public/types/types.dart new file mode 100644 index 000000000..4449a9eac --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/public/types/types.dart @@ -0,0 +1,11 @@ +/// Public Types +/// +/// Exports all public types for the RunAnywhere SDK. +library types; + +export 'capability_types.dart'; +export 'configuration_types.dart'; +export 'download_types.dart'; +export 'generation_types.dart'; +export 'message_types.dart'; +export 'voice_agent_types.dart'; diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/public/types/voice_agent_types.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/public/types/voice_agent_types.dart new file mode 100644 index 000000000..8bc41e20d --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/public/types/voice_agent_types.dart @@ -0,0 +1,81 @@ +/// Voice Agent Types +/// +/// Types for voice agent operations. +/// Matches Swift VoiceAgentTypes.swift from Public/Extensions/VoiceAgent/ +library voice_agent_types; + +// MARK: - Component Load State + +/// State of a voice agent component +sealed class ComponentLoadState { + const ComponentLoadState(); + + /// Component is not loaded + const factory ComponentLoadState.notLoaded() = ComponentLoadStateNotLoaded; + + /// Component is loaded with the given model ID + const factory ComponentLoadState.loaded({required String modelId}) = + ComponentLoadStateLoaded; +} + +/// Component not loaded state +class ComponentLoadStateNotLoaded extends ComponentLoadState { + const ComponentLoadStateNotLoaded(); +} + +/// Component loaded state +class ComponentLoadStateLoaded extends ComponentLoadState { + /// ID of the loaded model + final String modelId; + + const ComponentLoadStateLoaded({required this.modelId}); +} + +// MARK: - Voice Agent Component States + +/// States of all voice agent components (STT, LLM, TTS) +/// +/// Matches Swift VoiceAgentComponentStates from VoiceAgentTypes.swift +class VoiceAgentComponentStates { + /// Speech-to-Text component state + final ComponentLoadState stt; + + /// Large Language Model component state + final ComponentLoadState llm; + + /// Text-to-Speech component state + final ComponentLoadState tts; + + const VoiceAgentComponentStates({ + this.stt = const ComponentLoadState.notLoaded(), + this.llm = const ComponentLoadState.notLoaded(), + this.tts = const ComponentLoadState.notLoaded(), + }); + + /// Check if all components are loaded + bool get isFullyReady => + stt is ComponentLoadStateLoaded && + llm is ComponentLoadStateLoaded && + tts is ComponentLoadStateLoaded; + + /// Check if any component is loaded + bool get hasAnyLoaded => + stt is ComponentLoadStateLoaded || + llm is ComponentLoadStateLoaded || + tts is ComponentLoadStateLoaded; + + @override + String toString() { + String stateToString(ComponentLoadState state) { + if (state is ComponentLoadStateLoaded) { + return 'loaded(${state.modelId})'; + } + return 'notLoaded'; + } + + return 'VoiceAgentComponentStates(' + 'stt: ${stateToString(stt)}, ' + 'llm: ${stateToString(llm)}, ' + 'tts: ${stateToString(tts)})'; + } +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere/lib/runanywhere.dart b/sdk/runanywhere-flutter/packages/runanywhere/lib/runanywhere.dart new file mode 100644 index 000000000..56393008f --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/lib/runanywhere.dart @@ -0,0 +1,31 @@ +/// RunAnywhere Flutter SDK - Core Package +/// +/// Privacy-first, on-device AI SDK for Flutter. +library runanywhere; + +export 'capabilities/voice/models/voice_session.dart'; +export 'capabilities/voice/models/voice_session_handle.dart'; +export 'core/module/runanywhere_module.dart'; +export 'core/types/component_state.dart'; +export 'core/types/model_types.dart'; +export 'core/types/sdk_component.dart'; +export 'core/types/storage_types.dart'; +// Network layer +export 'data/network/network.dart'; +export 'features/vad/vad_configuration.dart'; +export 'foundation/configuration/sdk_constants.dart'; +export 'foundation/error_types/sdk_error.dart'; +export 'foundation/logging/sdk_logger.dart'; +export 'infrastructure/download/download_service.dart' + show ModelDownloadService, ModelDownloadProgress, ModelDownloadStage; +export 'native/native_backend.dart' show NativeBackend, NativeBackendException; +export 'native/platform_loader.dart' show PlatformLoader; +export 'public/configuration/sdk_environment.dart'; +export 'public/errors/errors.dart'; +export 'public/events/event_bus.dart'; +export 'public/events/sdk_event.dart'; +export 'public/extensions/runanywhere_frameworks.dart'; +export 'public/extensions/runanywhere_logging.dart'; +export 'public/extensions/runanywhere_storage.dart'; +export 'public/runanywhere.dart'; +export 'public/types/types.dart' hide SupabaseConfig; diff --git a/sdk/runanywhere-flutter/packages/runanywhere/pubspec.yaml b/sdk/runanywhere-flutter/packages/runanywhere/pubspec.yaml new file mode 100644 index 000000000..40d208dab --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere/pubspec.yaml @@ -0,0 +1,69 @@ +name: runanywhere +description: Privacy-first, on-device AI SDK for Flutter. Run LLMs, STT, TTS, and VAD directly on device with no data leaving the device. +version: 0.15.11 +homepage: https://runanywhere.ai +repository: https://github.com/RunanywhereAI/runanywhere-sdks +issue_tracker: https://github.com/RunanywhereAI/runanywhere-sdks/issues +topics: + - ai + - machine-learning + - on-device + - llm + - speech-recognition + +environment: + sdk: '>=3.0.0 <4.0.0' + flutter: '>=3.10.0' + +dependencies: + flutter: + sdk: flutter + # FFI (Foreign Function Interface) + ffi: ^2.1.0 + # HTTP and networking + http: ^1.2.1 + rxdart: ^0.27.7 + # Storage + shared_preferences: ^2.2.3 + path_provider: ^2.1.3 + flutter_secure_storage: ^9.0.0 + sqflite: ^2.3.0 + # Device info + device_info_plus: ^10.0.0 + # Utilities + uuid: ^4.4.0 + logger: ^2.3.0 + collection: ^1.18.0 + json_annotation: ^4.9.0 + path: ^1.9.0 + # Archive extraction (tar.bz2, zip) + archive: ^3.6.1 + # TTS fallback (system TTS) + flutter_tts: ^3.8.0 + # Audio recording for voice sessions + record: '>=5.1.2 <7.0.0' + # Audio playback for TTS + audioplayers: ^6.0.0 + # Permissions + permission_handler: ^11.3.1 + +dev_dependencies: + flutter_test: + sdk: flutter + build_runner: ^2.4.7 + json_serializable: ^6.7.1 + test: ^1.24.0 + flutter_lints: ^3.0.0 + +flutter: + uses-material-design: true + + # Native plugin configuration + # RACommons binaries are bundled in ios/ and android/ directories + plugin: + platforms: + android: + package: ai.runanywhere.sdk + pluginClass: RunAnywherePlugin + ios: + pluginClass: RunAnywherePlugin diff --git a/sdk/runanywhere-flutter/packages/runanywhere_llamacpp/CHANGELOG.md b/sdk/runanywhere-flutter/packages/runanywhere_llamacpp/CHANGELOG.md new file mode 100644 index 000000000..b26557b13 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere_llamacpp/CHANGELOG.md @@ -0,0 +1,32 @@ +# Changelog + +All notable changes to the RunAnywhere LlamaCpp Backend will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.15.9] - 2025-01-11 + +### Changed +- Updated runanywhere dependency to ^0.15.9 for iOS symbol visibility fix +- See runanywhere 0.15.9 changelog for details on the iOS fix + +## [0.15.8] - 2025-01-10 + +### Added +- Initial public release on pub.dev +- LlamaCpp integration for on-device LLM inference +- GGUF model format support +- Streaming text generation +- Memory-efficient model loading +- Native bindings for iOS and Android + +### Features +- High-performance text generation +- Token-by-token streaming output +- Configurable generation parameters (temperature, max tokens, etc.) +- Automatic model management and caching + +### Platforms +- iOS 13.0+ support +- Android API 24+ support diff --git a/sdk/runanywhere-flutter/packages/runanywhere_llamacpp/LICENSE b/sdk/runanywhere-flutter/packages/runanywhere_llamacpp/LICENSE new file mode 100644 index 000000000..f58b44f54 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere_llamacpp/LICENSE @@ -0,0 +1,316 @@ +RunAnywhere License +Version 1.0, December 2025 + +Copyright (c) 2025 RunAnywhere, Inc. All Rights Reserved. + +This software and associated documentation files (the "Software") are made +available under the terms of this License. By using, copying, modifying, or +distributing the Software, you agree to be bound by the terms of this License. + + +PART I - GRANT OF PERMISSION +============================= + +Subject to the conditions in Part II, permission is hereby granted, free of +charge, to use, copy, modify, merge, publish, and distribute the Software, and +to permit persons to whom the Software is furnished to do so, under the terms +of the Apache License 2.0 (included in Part III below). + + +PART II - CONDITIONS AND RESTRICTIONS +===================================== + +1. PERMITTED USERS + + This free license grant applies only to: + + (a) Individual persons using the Software for personal, educational, + research, or non-commercial purposes; + + (b) Organizations (including parent companies, subsidiaries, and affiliates) + that meet BOTH of the following criteria: + (i) Less than $1,000,000 USD in total funding (including but not + limited to equity investments, debt financing, grants, and loans); + AND + (ii) Less than $1,000,000 USD in gross annual revenue; + + (c) Educational institutions, including but not limited to universities, + colleges, schools, and students enrolled in such institutions; + + (d) Non-profit organizations registered under section 501(c)(3) of the + United States Internal Revenue Code, or equivalent charitable status + in other jurisdictions; + + (e) Government agencies and public sector organizations; + + (f) Open source projects that are themselves licensed under an OSI-approved + open source license. + +2. COMMERCIAL LICENSE REQUIRED + + Any person or organization not meeting the criteria in Section 1 must obtain + a separate commercial license from RunAnywhere, Inc. + + Contact: san@runanywhere.ai for commercial licensing terms. + +3. THRESHOLD TRANSITION + + If an organization initially qualifies under Section 1(b) but subsequently + exceeds either threshold: + + (a) This free license automatically terminates upon exceeding the threshold; + + (b) A commercial license must be obtained within thirty (30) days of + exceeding either threshold; + + (c) For purposes of this license, "gross annual revenue" means total + revenue in the preceding twelve (12) months, calculated on a rolling + basis. + +4. ATTRIBUTION REQUIREMENTS + + All copies or substantial portions of the Software must include: + + (a) This License notice, or a prominent link to it; + + (b) The copyright notice: "Copyright (c) 2025 RunAnywhere, Inc." + + (c) If modifications are made, a statement that the Software has been + modified, including a description of the nature of modifications. + +5. TRADEMARK NOTICE + + This License does not grant permission to use the trade names, trademarks, + service marks, or product names of RunAnywhere, Inc., including "RunAnywhere", + except as required for reasonable and customary use in describing the origin + of the Software. + + +PART III - APACHE LICENSE 2.0 +============================= + +For users meeting the conditions in Part II, the following Apache License 2.0 +terms apply: + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF APACHE LICENSE 2.0 TERMS AND CONDITIONS + + +PART IV - GENERAL PROVISIONS +============================ + +1. ENTIRE AGREEMENT + + This License constitutes the entire agreement between the parties with + respect to the Software and supersedes all prior or contemporaneous + understandings regarding such subject matter. + +2. SEVERABILITY + + If any provision of this License is held to be unenforceable, such + provision shall be reformed only to the extent necessary to make it + enforceable, and the remaining provisions shall continue in full force + and effect. + +3. WAIVER + + No waiver of any term of this License shall be deemed a further or + continuing waiver of such term or any other term. + +4. GOVERNING LAW + + This License shall be governed by and construed in accordance with the + laws of the State of Delaware, United States, without regard to its + conflict of laws provisions. + +5. CONTACT + + For commercial licensing inquiries, questions about this License, or + to report violations, please contact: + + RunAnywhere, Inc. + Email: san@runanywhere.ai + +--- + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +RUNANYWHERE, INC. OR ANY CONTRIBUTORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +IN THE SOFTWARE. diff --git a/sdk/runanywhere-flutter/packages/runanywhere_llamacpp/README.md b/sdk/runanywhere-flutter/packages/runanywhere_llamacpp/README.md new file mode 100644 index 000000000..0c15bb49b --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere_llamacpp/README.md @@ -0,0 +1,282 @@ +# RunAnywhere LlamaCpp Backend + +[![pub package](https://img.shields.io/pub/v/runanywhere_llamacpp.svg)](https://pub.dev/packages/runanywhere_llamacpp) +[![License](https://img.shields.io/badge/License-RunAnywhere-blue.svg)](https://github.com/RunanywhereAI/runanywhere-sdks/blob/main/LICENSE) +[![Platform](https://img.shields.io/badge/platform-iOS%20%7C%20Android-lightgrey.svg)]() + +High-performance LLM text generation backend for the RunAnywhere Flutter SDK, powered by [llama.cpp](https://github.com/ggerganov/llama.cpp). + +--- + +## Features + +| Feature | Description | +|---------|-------------| +| **GGUF Model Support** | Run any GGUF-quantized model (Q4, Q5, Q8, etc.) | +| **Streaming Generation** | Token-by-token streaming for real-time UI updates | +| **Metal Acceleration** | Hardware acceleration on iOS devices | +| **NEON Acceleration** | ARM NEON optimizations on Android | +| **Privacy-First** | All processing happens locally on device | +| **Memory Efficient** | Quantized models reduce memory footprint | + +--- + +## Installation + +Add both the core SDK and this backend to your `pubspec.yaml`: + +```yaml +dependencies: + runanywhere: ^0.15.11 + runanywhere_llamacpp: ^0.15.11 +``` + +Then run: + +```bash +flutter pub get +``` + +> **Note:** This package requires the core `runanywhere` package. It won't work standalone. + +--- + +## Platform Support + +| Platform | Minimum Version | Acceleration | +|----------|-----------------|--------------| +| iOS | 14.0+ | Metal GPU | +| Android | API 24+ | NEON SIMD | + +--- + +## Quick Start + +### 1. Initialize & Register + +```dart +import 'package:runanywhere/runanywhere.dart'; +import 'package:runanywhere_llamacpp/runanywhere_llamacpp.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + // Initialize SDK + await RunAnywhere.initialize(); + + // Register LlamaCpp backend + await LlamaCpp.register(); + + runApp(MyApp()); +} +``` + +### 2. Add a Model + +```dart +LlamaCpp.addModel( + id: 'smollm2-360m-q8_0', + name: 'SmolLM2 360M Q8_0', + url: 'https://huggingface.co/prithivMLmods/SmolLM2-360M-GGUF/resolve/main/SmolLM2-360M.Q8_0.gguf', + memoryRequirement: 500000000, // ~500MB +); +``` + +### 3. Download & Load + +```dart +// Download the model +await for (final progress in RunAnywhere.downloadModel('smollm2-360m-q8_0')) { + print('Progress: ${(progress.percentage * 100).toStringAsFixed(1)}%'); + if (progress.state.isCompleted) break; +} + +// Load the model +await RunAnywhere.loadModel('smollm2-360m-q8_0'); +print('Model loaded: ${RunAnywhere.isModelLoaded}'); +``` + +### 4. Generate Text + +```dart +// Simple chat +final response = await RunAnywhere.chat('Hello! How are you?'); +print(response); + +// Streaming generation +final result = await RunAnywhere.generateStream( + 'Write a short poem about Flutter', + options: LLMGenerationOptions(maxTokens: 100, temperature: 0.7), +); + +await for (final token in result.stream) { + stdout.write(token); // Real-time output +} + +// Get metrics after completion +final metrics = await result.result; +print('\nTokens/sec: ${metrics.tokensPerSecond.toStringAsFixed(1)}'); +``` + +--- + +## API Reference + +### LlamaCpp Class + +#### `register()` + +Register the LlamaCpp backend with the SDK. + +```dart +static Future register({int priority = 100}) +``` + +**Parameters:** +- `priority` – Backend priority (higher = preferred). Default: 100. + +#### `addModel()` + +Add an LLM model to the registry. + +```dart +static void addModel({ + required String id, + required String name, + required String url, + int memoryRequirement = 0, + bool supportsThinking = false, +}) +``` + +**Parameters:** +- `id` – Unique model identifier +- `name` – Human-readable model name +- `url` – Download URL for the GGUF file +- `memoryRequirement` – Estimated memory usage in bytes +- `supportsThinking` – Whether model supports thinking tokens (e.g., DeepSeek R1) + +--- + +## Supported Models + +Any GGUF model compatible with llama.cpp: + +### Recommended Models + +| Model | Size | Memory | Use Case | +|-------|------|--------|----------| +| SmolLM2 360M Q8_0 | ~400MB | ~500MB | Fast responses, mobile | +| Qwen2.5 0.5B Q8_0 | ~600MB | ~700MB | Good quality, small | +| Qwen2.5 1.5B Q4_K_M | ~1GB | ~1.2GB | Better quality | +| Phi-3.5-mini Q4_K_M | ~2GB | ~2.5GB | High quality | +| Llama 3.2 1B Q4_K_M | ~800MB | ~1GB | Balanced | +| DeepSeek R1 1.5B Q4_K_M | ~1.2GB | ~1.5GB | Reasoning, thinking | + +### Quantization Guide + +| Format | Quality | Size | Speed | +|--------|---------|------|-------| +| Q8_0 | Highest | Largest | Slower | +| Q6_K | Very High | Large | Medium | +| Q5_K_M | High | Medium | Medium | +| Q4_K_M | Good | Small | Fast | +| Q4_0 | Lower | Smallest | Fastest | + +> **Tip:** For mobile, Q4_K_M or Q5_K_M offer the best quality/size balance. + +--- + +## Memory Management + +### Checking Memory + +```dart +// Get available models with their memory requirements +final models = await RunAnywhere.availableModels(); +for (final model in models) { + if (model.downloadSize != null) { + print('${model.name}: ${(model.downloadSize! / 1e9).toStringAsFixed(1)} GB'); + } +} +``` + +### Unloading Models + +```dart +// Unload to free memory +await RunAnywhere.unloadModel(); +``` + +--- + +## Generation Options + +```dart +final result = await RunAnywhere.generate( + 'Your prompt here', + options: LLMGenerationOptions( + maxTokens: 200, // Maximum tokens to generate + temperature: 0.7, // Randomness (0.0 = deterministic, 1.0 = creative) + topP: 0.9, // Nucleus sampling + systemPrompt: 'You are a helpful assistant.', + ), +); +``` + +| Option | Default | Range | Description | +|--------|---------|-------|-------------| +| `maxTokens` | 100 | 1-4096 | Maximum tokens to generate | +| `temperature` | 0.8 | 0.0-2.0 | Response randomness | +| `topP` | 1.0 | 0.0-1.0 | Nucleus sampling threshold | +| `systemPrompt` | null | - | System prompt prepended to input | + +--- + +## Troubleshooting + +### Model Loading Fails + +**Symptom:** `SDKError.modelLoadFailed` + +**Solutions:** +1. Verify model is fully downloaded (check `model.isDownloaded`) +2. Ensure sufficient memory available +3. Check model format is GGUF (not GGML or safetensors) + +### Slow Generation + +**Solutions:** +1. Use smaller quantization (Q4_K_M instead of Q8_0) +2. Use a smaller model +3. Reduce `maxTokens` +4. On iOS, ensure Metal is available (device not in low power mode) + +### Out of Memory + +**Solutions:** +1. Unload current model before loading new one +2. Use smaller quantization +3. Use a smaller model + +--- + +## Related Packages + +- [runanywhere](https://pub.dev/packages/runanywhere) — Core SDK (required) +- [runanywhere_llamacpp](https://pub.dev/packages/runanywhere_llamacpp) — LLM backend (this package) +- [runanywhere_onnx](https://pub.dev/packages/runanywhere_onnx) — STT/TTS/VAD backend + +## Resources + +- [Flutter Starter Example](https://github.com/RunanywhereAI/flutter-starter-example) +- [Documentation](https://runanywhere.ai/docs) +- [GitHub Issues](https://github.com/RunanywhereAI/runanywhere-sdks/issues) + +--- + +## License + +This software is licensed under the RunAnywhere License, which is based on Apache 2.0 with additional terms for commercial use. See [LICENSE](https://github.com/RunanywhereAI/runanywhere-sdks/blob/main/LICENSE) for details. + +For commercial licensing inquiries, contact: san@runanywhere.ai diff --git a/sdk/runanywhere-flutter/packages/runanywhere_llamacpp/android/binary_config.gradle b/sdk/runanywhere-flutter/packages/runanywhere_llamacpp/android/binary_config.gradle new file mode 100644 index 000000000..06c984f32 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere_llamacpp/android/binary_config.gradle @@ -0,0 +1,50 @@ +// ============================================================================= +// BINARY CONFIGURATION FOR RUNANYWHERE FLUTTER SDK - ANDROID (LlamaCPP Package) +// ============================================================================= +// This file controls whether to use local or remote native libraries (.so files). +// Similar to Swift Package.swift's testLocal flag. +// +// Set to `true` to use local binaries from android/src/main/jniLibs/ +// Set to `false` to download binaries from GitHub releases (production mode) +// ============================================================================= + +ext { + // Set this to true for local development/testing + // Set to false for production builds (downloads from GitHub releases) + testLocal = true + + // ============================================================================= + // Version Configuration (MUST match Swift Package.swift and Kotlin build.gradle.kts) + // ============================================================================= + coreVersion = "0.1.4" + + // ============================================================================= + // Remote binary URLs + // RABackendLlamaCPP from runanywhere-binaries + // ============================================================================= + binariesGitHubOrg = "RunanywhereAI" + binariesRepo = "runanywhere-binaries" + binariesBaseUrl = "https://github.com/${binariesGitHubOrg}/${binariesRepo}/releases/download" + + // Android native libraries package + llamacppAndroidUrl = "${binariesBaseUrl}/core-v${coreVersion}/RABackendLlamaCPP-android-v${coreVersion}.zip" + + // Helper method to check if we should download + shouldDownloadAndroidLibs = { -> + return !testLocal + } + + // Helper method to check if local libs exist + checkLocalLibsExist = { -> + def jniLibsDir = project.file('src/main/jniLibs') + def arm64Dir = new File(jniLibsDir, 'arm64-v8a') + + if (!arm64Dir.exists() || !arm64Dir.isDirectory()) { + return false + } + + // Check for LlamaCPP backend library + def llamacppLib = new File(arm64Dir, 'librac_backend_llamacpp_jni.so') + return llamacppLib.exists() + } +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere_llamacpp/android/build.gradle b/sdk/runanywhere-flutter/packages/runanywhere_llamacpp/android/build.gradle new file mode 100644 index 000000000..5ebfe8fb4 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere_llamacpp/android/build.gradle @@ -0,0 +1,170 @@ +// RunAnywhere LlamaCPP Backend - Android +// +// This plugin bundles RABackendLlamaCPP native libraries (.so files) for Android. +// RABackendLlamaCPP provides LLM text generation capabilities using llama.cpp. +// +// Binary Configuration: +// Edit binary_config.gradle to toggle between local and remote binaries: +// - testLocal = true: Use local .so files from android/src/main/jniLibs/ (for development) +// - testLocal = false: Download from GitHub releases (for production) +// +// Version: Must match Swift SDK's Package.swift and Kotlin SDK's build.gradle.kts + +group 'ai.runanywhere.sdk.llamacpp' +version '0.15.8' + +// Load binary configuration +apply from: 'binary_config.gradle' + +buildscript { + ext.kotlin_version = '1.9.10' + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:8.1.0' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + namespace 'ai.runanywhere.sdk.llamacpp' + compileSdk 34 + + // Use NDK for native library support + ndkVersion "25.2.9519653" + + defaultConfig { + minSdk 24 + targetSdk 34 + + // ABI filters for native libraries + ndk { + abiFilters 'arm64-v8a', 'armeabi-v7a', 'x86_64' + } + + // Consumer proguard rules + consumerProguardFiles 'proguard-rules.pro' + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = '1.8' + } + + sourceSets { + main { + // Native libraries location - use downloaded libs or local libs based on config + jniLibs.srcDirs = [testLocal ? 'src/main/jniLibs' : 'build/jniLibs'] + } + } + + buildTypes { + release { + minifyEnabled false + } + } +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" +} + +// ============================================================================= +// Binary Download Task (runs when testLocal = false) +// ============================================================================= +task downloadNativeLibs { + group = 'runanywhere' + description = 'Download RABackendLlamaCPP native libraries from GitHub releases' + + doLast { + if (shouldDownloadAndroidLibs()) { + println "📦 Remote mode: Downloading RABackendLlamaCPP Android native libraries..." + + def jniLibsDir = file('build/jniLibs') + if (jniLibsDir.exists()) { + delete(jniLibsDir) + } + jniLibsDir.mkdirs() + + // Ensure build directory exists + buildDir.mkdirs() + + def downloadUrl = llamacppAndroidUrl + def zipFile = file("${buildDir}/llamacpp-android.zip") + + println "Downloading from: ${downloadUrl}" + + // Download the zip file + ant.get(src: downloadUrl, dest: zipFile) + + println "✅ Downloaded successfully" + + // Extract to temp directory first + def tempDir = file("${buildDir}/llamacpp-temp") + if (tempDir.exists()) { + delete(tempDir) + } + tempDir.mkdirs() + + copy { + from zipTree(zipFile) + into tempDir + } + + // Common libs that should NOT be duplicated (they're in the core SDK) + def commonLibs = ['libc++_shared.so', 'librac_commons.so', 'librac_commons_jni.so'] + + // Copy .so files from jniLibs structure (excluding common libs) + tempDir.eachFileRecurse { file -> + if (file.isDirectory() && file.name in ['arm64-v8a', 'armeabi-v7a', 'x86_64', 'x86']) { + def targetAbiDir = new File(jniLibsDir, file.name) + targetAbiDir.mkdirs() + file.eachFile { soFile -> + if (soFile.name.endsWith('.so') && !(soFile.name in commonLibs)) { + copy { + from soFile + into targetAbiDir + } + println " ✓ ${file.name}/${soFile.name}" + } + } + } + } + + // Clean up + zipFile.delete() + if (tempDir.exists()) { + delete(tempDir) + } + + println "✅ RABackendLlamaCPP native libraries downloaded successfully" + } else { + println "🔧 Local mode: Using native libraries from src/main/jniLibs/" + + if (!checkLocalLibsExist()) { + throw new GradleException(""" + ⚠️ Native libraries not found in src/main/jniLibs/! + For local mode, please build and copy the libraries: + 1. cd runanywhere-core && ./scripts/build-android.sh --llamacpp + 2. Copy the .so files to packages/runanywhere_llamacpp/android/src/main/jniLibs/ + Or switch to remote mode by editing binary_config.gradle: + testLocal = false + """) + } else { + println "✅ Using local native libraries" + } + } + } +} + +// Run downloadNativeLibs before preBuild +preBuild.dependsOn downloadNativeLibs diff --git a/sdk/runanywhere-flutter/packages/runanywhere_llamacpp/android/proguard-rules.pro b/sdk/runanywhere-flutter/packages/runanywhere_llamacpp/android/proguard-rules.pro new file mode 100644 index 000000000..4dc72718d --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere_llamacpp/android/proguard-rules.pro @@ -0,0 +1,8 @@ +# RunAnywhere LlamaCPP SDK ProGuard Rules +# Keep native method signatures +-keepclasseswithmembernames class * { + native ; +} + +# Keep LlamaCPP plugin classes +-keep class ai.runanywhere.sdk.llamacpp.** { *; } diff --git a/sdk/runanywhere-flutter/packages/runanywhere_llamacpp/android/src/main/AndroidManifest.xml b/sdk/runanywhere-flutter/packages/runanywhere_llamacpp/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000..c619259e8 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere_llamacpp/android/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + diff --git a/sdk/runanywhere-flutter/packages/runanywhere_llamacpp/android/src/main/kotlin/ai/runanywhere/sdk/llamacpp/LlamaCppPlugin.kt b/sdk/runanywhere-flutter/packages/runanywhere_llamacpp/android/src/main/kotlin/ai/runanywhere/sdk/llamacpp/LlamaCppPlugin.kt new file mode 100644 index 000000000..9bddd3d4b --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere_llamacpp/android/src/main/kotlin/ai/runanywhere/sdk/llamacpp/LlamaCppPlugin.kt @@ -0,0 +1,60 @@ +package ai.runanywhere.sdk.llamacpp + +import android.os.Build +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import io.flutter.plugin.common.MethodChannel.Result + +/** + * RunAnywhere LlamaCPP Flutter Plugin - Android Implementation + * + * This plugin provides the native bridge for the LlamaCPP backend on Android. + * The actual LLM functionality is provided by RABackendLlamaCPP native libraries (.so files). + */ +class LlamaCppPlugin : FlutterPlugin, MethodCallHandler { + private lateinit var channel: MethodChannel + + companion object { + private const val CHANNEL_NAME = "runanywhere_llamacpp" + private const val BACKEND_VERSION = "0.1.4" + private const val BACKEND_NAME = "LlamaCPP" + + init { + // Load LlamaCPP backend native libraries + try { + System.loadLibrary("rac_backend_llamacpp_jni") + } catch (e: UnsatisfiedLinkError) { + // Library may not be available in all configurations + android.util.Log.w("LlamaCpp", "Failed to load rac_backend_llamacpp_jni: ${e.message}") + } + } + } + + override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + channel = MethodChannel(flutterPluginBinding.binaryMessenger, CHANNEL_NAME) + channel.setMethodCallHandler(this) + } + + override fun onMethodCall(call: MethodCall, result: Result) { + when (call.method) { + "getPlatformVersion" -> { + result.success("Android ${Build.VERSION.RELEASE}") + } + "getBackendVersion" -> { + result.success(BACKEND_VERSION) + } + "getBackendName" -> { + result.success(BACKEND_NAME) + } + else -> { + result.notImplemented() + } + } + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + channel.setMethodCallHandler(null) + } +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere_llamacpp/ios/Classes/LlamaCppPlugin.swift b/sdk/runanywhere-flutter/packages/runanywhere_llamacpp/ios/Classes/LlamaCppPlugin.swift new file mode 100644 index 000000000..84b7a7d2f --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere_llamacpp/ios/Classes/LlamaCppPlugin.swift @@ -0,0 +1,31 @@ +import Flutter +import UIKit + +/// RunAnywhere LlamaCPP Flutter Plugin - iOS Implementation +/// +/// This plugin provides the native bridge for the LlamaCPP backend on iOS. +/// The actual LLM functionality is provided by RABackendLLAMACPP.xcframework. +public class LlamaCppPlugin: NSObject, FlutterPlugin { + + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel( + name: "runanywhere_llamacpp", + binaryMessenger: registrar.messenger() + ) + let instance = LlamaCppPlugin() + registrar.addMethodCallDelegate(instance, channel: channel) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "getPlatformVersion": + result("iOS " + UIDevice.current.systemVersion) + case "getBackendVersion": + result("0.1.4") + case "getBackendName": + result("LlamaCPP") + default: + result(FlutterMethodNotImplemented) + } + } +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere_llamacpp/ios/runanywhere_llamacpp.podspec b/sdk/runanywhere-flutter/packages/runanywhere_llamacpp/ios/runanywhere_llamacpp.podspec new file mode 100644 index 000000000..9f438e71b --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere_llamacpp/ios/runanywhere_llamacpp.podspec @@ -0,0 +1,150 @@ +# +# RunAnywhere LlamaCPP Backend - iOS +# +# This podspec integrates RABackendLLAMACPP.xcframework into Flutter iOS apps. +# RABackendLLAMACPP provides LLM text generation capabilities using llama.cpp. +# +# Binary Configuration: +# - Set RA_TEST_LOCAL=1 or create .testlocal file to use local binaries +# - Otherwise, binaries are downloaded from GitHub releases (production mode) +# +# Version: Must match Swift SDK's Package.swift and Kotlin SDK's build.gradle.kts +# + +# ============================================================================= +# Version Constants (MUST match Swift Package.swift) +# ============================================================================= +LLAMACPP_VERSION = "0.1.4" + +# ============================================================================= +# Binary Source - RABackendLlamaCPP from runanywhere-binaries +# ============================================================================= +GITHUB_ORG = "RunanywhereAI" +BINARIES_REPO = "runanywhere-binaries" + +# ============================================================================= +# testLocal Toggle +# Set RA_TEST_LOCAL=1 or create .testlocal file to use local binaries +# ============================================================================= +TEST_LOCAL = ENV['RA_TEST_LOCAL'] == '1' || File.exist?(File.join(__dir__, '.testlocal')) + +Pod::Spec.new do |s| + s.name = 'runanywhere_llamacpp' + s.version = '0.15.11' + s.summary = 'RunAnywhere LlamaCPP: LLM text generation for Flutter' + s.description = <<-DESC +LlamaCPP backend for RunAnywhere Flutter SDK. Provides LLM text generation +capabilities using llama.cpp. Pre-built binaries are downloaded from: +https://github.com/RunanywhereAI/runanywhere-binaries + DESC + s.homepage = 'https://runanywhere.ai' + s.license = { :type => 'MIT' } + s.author = { 'RunAnywhere' => 'team@runanywhere.ai' } + s.source = { :path => '.' } + + s.ios.deployment_target = '14.0' + s.swift_version = '5.0' + + # Source files (minimal - main logic is in the xcframework) + s.source_files = 'Classes/**/*' + + # Flutter dependency + s.dependency 'Flutter' + + # Core SDK dependency (provides RACommons) + s.dependency 'runanywhere' + + # ============================================================================= + # RABackendLLAMACPP XCFramework - LLM text generation + # Downloaded from runanywhere-binaries releases + # ============================================================================= + if TEST_LOCAL + puts "[runanywhere_llamacpp] Using LOCAL RABackendLLAMACPP from Frameworks/" + s.vendored_frameworks = 'Frameworks/RABackendLLAMACPP.xcframework' + else + s.prepare_command = <<-CMD + set -e + + FRAMEWORK_DIR="Frameworks" + VERSION="#{LLAMACPP_VERSION}" + VERSION_FILE="$FRAMEWORK_DIR/.llamacpp_version" + + # Check if already downloaded with correct version + if [ -f "$VERSION_FILE" ] && [ -d "$FRAMEWORK_DIR/RABackendLLAMACPP.xcframework" ]; then + CURRENT_VERSION=$(cat "$VERSION_FILE") + if [ "$CURRENT_VERSION" = "$VERSION" ]; then + echo "✅ RABackendLLAMACPP.xcframework version $VERSION already downloaded" + exit 0 + fi + fi + + echo "📦 Downloading RABackendLLAMACPP.xcframework version $VERSION..." + + mkdir -p "$FRAMEWORK_DIR" + rm -rf "$FRAMEWORK_DIR/RABackendLLAMACPP.xcframework" + + # Download from runanywhere-binaries + DOWNLOAD_URL="https://github.com/#{GITHUB_ORG}/#{BINARIES_REPO}/releases/download/core-v$VERSION/RABackendLlamaCPP-ios-v$VERSION.zip" + ZIP_FILE="/tmp/RABackendLlamaCPP.zip" + + echo " URL: $DOWNLOAD_URL" + + curl -L -f -o "$ZIP_FILE" "$DOWNLOAD_URL" || { + echo "❌ Failed to download RABackendLlamaCPP from $DOWNLOAD_URL" + exit 1 + } + + echo "📂 Extracting RABackendLLAMACPP.xcframework..." + unzip -q -o "$ZIP_FILE" -d "$FRAMEWORK_DIR/" + rm -f "$ZIP_FILE" + + echo "$VERSION" > "$VERSION_FILE" + + if [ -d "$FRAMEWORK_DIR/RABackendLLAMACPP.xcframework" ]; then + echo "✅ RABackendLLAMACPP.xcframework installed successfully" + else + echo "❌ RABackendLLAMACPP.xcframework extraction failed" + exit 1 + fi + CMD + + s.vendored_frameworks = 'Frameworks/RABackendLLAMACPP.xcframework' + end + + s.preserve_paths = 'Frameworks/**/*' + + # Required frameworks + s.frameworks = [ + 'Foundation', + 'CoreML', + 'Accelerate' + ] + + # Weak frameworks (optional hardware acceleration) + s.weak_frameworks = [ + 'Metal', + 'MetalKit', + 'MetalPerformanceShaders' + ] + + # Build settings + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386', + 'OTHER_LDFLAGS' => '-lc++', + 'CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES' => 'YES', + 'ENABLE_BITCODE' => 'NO', + } + + # CRITICAL: -all_load ensures ALL object files from RABackendLLAMACPP.xcframework are linked. + # Without this, the linker won't include rac_backend_llamacpp_register and rac_llm_llamacpp_* + # functions because nothing in native code directly references them - only FFI does. + s.user_target_xcconfig = { + 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386', + 'OTHER_LDFLAGS' => '-lc++ -all_load', + 'DEAD_CODE_STRIPPING' => 'NO', + } + + # Mark static framework for proper linking + s.static_framework = true +end diff --git a/sdk/runanywhere-flutter/packages/runanywhere_llamacpp/lib/llamacpp.dart b/sdk/runanywhere-flutter/packages/runanywhere_llamacpp/lib/llamacpp.dart new file mode 100644 index 000000000..c616114c6 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere_llamacpp/lib/llamacpp.dart @@ -0,0 +1,245 @@ +/// LlamaCPP backend for RunAnywhere Flutter SDK. +/// +/// This module provides LLM (Language Model) capabilities via llama.cpp. +/// It is a **thin wrapper** that registers the C++ backend with the service registry. +/// +/// ## Architecture (matches Swift/Kotlin) +/// +/// The C++ backend (RABackendLlamaCPP) handles all business logic: +/// - Service provider registration +/// - Model loading and inference +/// - Streaming generation +/// +/// This Dart module just: +/// 1. Calls `rac_backend_llamacpp_register()` to register the backend +/// 2. The core SDK handles all LLM operations via `rac_llm_component_*` +/// +/// ## Quick Start +/// +/// ```dart +/// import 'package:runanywhere_llamacpp/runanywhere_llamacpp.dart'; +/// +/// // Register the module (matches Swift: LlamaCPP.register()) +/// await LlamaCpp.register(); +/// +/// // Add models +/// LlamaCpp.addModel( +/// name: 'SmolLM2 360M Q8_0', +/// url: 'https://huggingface.co/.../model.gguf', +/// memoryRequirement: 500000000, +/// ); +/// ``` +library runanywhere_llamacpp; + +import 'dart:async' show unawaited; + +import 'package:runanywhere/core/module/runanywhere_module.dart'; +import 'package:runanywhere/core/types/model_types.dart'; +import 'package:runanywhere/core/types/sdk_component.dart'; +import 'package:runanywhere/foundation/logging/sdk_logger.dart'; +import 'package:runanywhere/native/ffi_types.dart'; +import 'package:runanywhere/public/runanywhere.dart' show RunAnywhere; +import 'package:runanywhere_llamacpp/native/llamacpp_bindings.dart'; + +// Re-export for backward compatibility +export 'llamacpp_error.dart'; + +/// LlamaCPP module for LLM text generation. +/// +/// Provides large language model capabilities using llama.cpp +/// with GGUF models and Metal/GPU acceleration. +/// +/// Matches Swift `LlamaCPP` enum from LlamaCPPRuntime/LlamaCPP.swift. +class LlamaCpp implements RunAnywhereModule { + // ============================================================================ + // Singleton Pattern (matches Swift enum pattern) + // ============================================================================ + + static final LlamaCpp _instance = LlamaCpp._internal(); + static LlamaCpp get module => _instance; + LlamaCpp._internal(); + + // ============================================================================ + // Module Info (matches Swift exactly) + // ============================================================================ + + /// Current version of the LlamaCPP Runtime module + static const String version = '2.0.0'; + + /// LlamaCPP library version (underlying C++ library) + static const String llamaCppVersion = 'b7199'; + + // ============================================================================ + // RunAnywhereModule Conformance (matches Swift exactly) + // ============================================================================ + + @override + String get moduleId => 'llamacpp'; + + @override + String get moduleName => 'LlamaCPP'; + + @override + Set get capabilities => {SDKComponent.llm}; + + @override + int get defaultPriority => 100; + + @override + InferenceFramework get inferenceFramework => InferenceFramework.llamaCpp; + + // ============================================================================ + // Registration State + // ============================================================================ + + static bool _isRegistered = false; + static LlamaCppBindings? _bindings; + static final _logger = SDKLogger('LlamaCpp'); + + /// Internal model registry for models added via addModel + static final List _registeredModels = []; + + // ============================================================================ + // Registration (matches Swift LlamaCPP.register() exactly) + // ============================================================================ + + /// Register LlamaCPP backend with the C++ service registry. + /// + /// This calls `rac_backend_llamacpp_register()` to register the + /// LlamaCPP service provider with the C++ commons layer. + /// + /// Safe to call multiple times - subsequent calls are no-ops. + static Future register({int priority = 100}) async { + if (_isRegistered) { + _logger.debug('LlamaCpp already registered'); + return; + } + + // Check native library availability + if (!isAvailable) { + _logger.error('LlamaCpp native library not available'); + return; + } + + _logger.info('Registering LlamaCpp backend with C++ registry...'); + + try { + _bindings = LlamaCppBindings(); + _logger.debug( + 'LlamaCppBindings created, isAvailable: ${_bindings!.isAvailable}'); + + final result = _bindings!.register(); + _logger.info( + 'rac_backend_llamacpp_register() returned: $result (${RacResultCode.getMessage(result)})'); + + // RAC_SUCCESS = 0, RAC_ERROR_MODULE_ALREADY_REGISTERED = specific code + if (result != RacResultCode.success && + result != RacResultCode.errorModuleAlreadyRegistered) { + _logger.error('C++ backend registration FAILED with code: $result'); + return; + } + + // No Dart-level provider needed - all LLM operations go through + // DartBridgeLLM -> rac_llm_component_* (just like Swift CppBridge.LLM) + + _isRegistered = true; + _logger.info('LlamaCpp backend registered successfully'); + } catch (e) { + _logger.error('LlamaCppBindings not available: $e'); + } + } + + /// Unregister the LlamaCPP backend from C++ registry. + static void unregister() { + if (!_isRegistered) return; + + _bindings?.unregister(); + _isRegistered = false; + _logger.info('LlamaCpp backend unregistered'); + } + + // ============================================================================ + // Model Handling (matches Swift exactly) + // ============================================================================ + + /// Check if the native backend is available on this platform. + /// + /// On iOS: Checks DynamicLibrary.process() for statically linked symbols + /// On Android: Checks if librac_backend_llamacpp_jni.so can be loaded + static bool get isAvailable => LlamaCppBindings.checkAvailability(); + + /// Check if LlamaCPP can handle a given model. + /// Uses file extension pattern matching - actual framework info is in C++ registry. + static bool canHandle(String? modelId) { + if (modelId == null) return false; + return modelId.toLowerCase().endsWith('.gguf'); + } + + // ============================================================================ + // Model Registration (convenience API) + // ============================================================================ + + /// Add a LLM model to the registry. + /// + /// This is a convenience method that registers a model with the SDK. + /// The model will be associated with the LlamaCPP backend. + /// + /// Matches Swift pattern - models are registered globally via RunAnywhere. + static void addModel({ + String? id, + required String name, + required String url, + int? memoryRequirement, + bool supportsThinking = false, + }) { + final uri = Uri.tryParse(url); + if (uri == null) { + _logger.error('Invalid URL for model: $name'); + return; + } + + final modelId = + id ?? name.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), '-'); + + // Register with the global SDK registry (matches Swift pattern) + final model = RunAnywhere.registerModel( + id: modelId, + name: name, + url: uri, + framework: InferenceFramework.llamaCpp, + modality: ModelCategory.language, + memoryRequirement: memoryRequirement, + supportsThinking: supportsThinking, + ); + + // Keep local reference for convenience + _registeredModels.add(model); + _logger.info('Added LlamaCpp model: $name ($modelId)'); + } + + /// Get all models registered with this module + static List get registeredModels => + List.unmodifiable(_registeredModels); + + // ============================================================================ + // Cleanup + // ============================================================================ + + /// Dispose of resources + static void dispose() { + _bindings = null; + _registeredModels.clear(); + _isRegistered = false; + _logger.info('LlamaCpp disposed'); + } + + // ============================================================================ + // Auto-Registration (matches Swift exactly) + // ============================================================================ + + /// Enable auto-registration for this module. + /// Call this method to trigger C++ backend registration. + static void autoRegister() { + unawaited(register()); + } +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere_llamacpp/lib/llamacpp_error.dart b/sdk/runanywhere-flutter/packages/runanywhere_llamacpp/lib/llamacpp_error.dart new file mode 100644 index 000000000..84caaceac --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere_llamacpp/lib/llamacpp_error.dart @@ -0,0 +1,70 @@ +/// LlamaCpp specific errors. +/// +/// This is the Flutter equivalent of Swift's `LLMSwiftError`. +class LlamaCppError implements Exception { + final String message; + final LlamaCppErrorType type; + + const LlamaCppError._(this.message, this.type); + + /// Model failed to load. + factory LlamaCppError.modelLoadFailed([String? details]) { + return LlamaCppError._( + details ?? 'Failed to load the LLM model', + LlamaCppErrorType.modelLoadFailed, + ); + } + + /// Service not initialized. + factory LlamaCppError.notInitialized() { + return const LlamaCppError._( + 'LLM service not initialized', + LlamaCppErrorType.notInitialized, + ); + } + + /// Generation failed. + factory LlamaCppError.generationFailed(String reason) { + return LlamaCppError._( + 'Generation failed: $reason', + LlamaCppErrorType.generationFailed, + ); + } + + /// Template resolution failed. + factory LlamaCppError.templateResolutionFailed(String reason) { + return LlamaCppError._( + 'Template resolution failed: $reason', + LlamaCppErrorType.templateResolutionFailed, + ); + } + + /// Model not found. + factory LlamaCppError.modelNotFound(String path) { + return LlamaCppError._( + 'Model not found at: $path', + LlamaCppErrorType.modelNotFound, + ); + } + + /// Timeout error. + factory LlamaCppError.timeout(Duration duration) { + return LlamaCppError._( + 'Generation timed out after ${duration.inSeconds} seconds', + LlamaCppErrorType.timeout, + ); + } + + @override + String toString() => 'LlamaCppError: $message'; +} + +/// Types of LlamaCpp errors. +enum LlamaCppErrorType { + modelLoadFailed, + notInitialized, + generationFailed, + templateResolutionFailed, + modelNotFound, + timeout, +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere_llamacpp/lib/native/llamacpp_bindings.dart b/sdk/runanywhere-flutter/packages/runanywhere_llamacpp/lib/native/llamacpp_bindings.dart new file mode 100644 index 000000000..a4e8c07e9 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere_llamacpp/lib/native/llamacpp_bindings.dart @@ -0,0 +1,143 @@ +import 'dart:ffi'; +import 'dart:io'; + +import 'package:runanywhere/native/ffi_types.dart'; +import 'package:runanywhere/native/platform_loader.dart'; + +/// Minimal LlamaCPP backend FFI bindings. +/// +/// This is a **thin wrapper** that only provides: +/// - `register()` - calls `rac_backend_llamacpp_register()` +/// - `unregister()` - calls `rac_backend_llamacpp_unregister()` +/// +/// All other LLM operations (create, load, generate, etc.) are handled by +/// the core SDK via `rac_llm_component_*` functions in RACommons. +/// +/// ## Architecture (matches Swift/Kotlin) +/// +/// The C++ backend (RABackendLlamaCPP) handles all business logic: +/// - Service provider registration with the C++ service registry +/// - Model loading and inference +/// - Streaming generation +/// +/// This Dart code just: +/// 1. Calls `rac_backend_llamacpp_register()` to register the backend +/// 2. The core SDK's `NativeBackend` handles all LLM operations via `rac_llm_component_*` +class LlamaCppBindings { + final DynamicLibrary _lib; + + // Function pointers - only registration functions + late final RacBackendLlamacppRegisterDart? _register; + late final RacBackendLlamacppUnregisterDart? _unregister; + + /// Create bindings using the appropriate library for each platform. + /// + /// - iOS: Uses DynamicLibrary.process() for statically linked XCFramework + /// - Android: Loads librac_backend_llamacpp_jni.so separately + LlamaCppBindings() : _lib = _loadLibrary() { + _bindFunctions(); + } + + /// Create bindings with a specific library (for testing). + LlamaCppBindings.withLibrary(this._lib) { + _bindFunctions(); + } + + /// Load the correct library for the current platform. + static DynamicLibrary _loadLibrary() { + return loadBackendLibrary(); + } + + /// Load the LlamaCpp backend library. + /// + /// On iOS/macOS: Uses DynamicLibrary.process() for statically linked XCFramework + /// On Android: Loads librac_backend_llamacpp_jni.so or librunanywhere_llamacpp.so + /// + /// This is exposed as a static method so it can be used by [LlamaCpp.isAvailable]. + static DynamicLibrary loadBackendLibrary() { + if (Platform.isAndroid) { + // On Android, the LlamaCpp backend is in a separate .so file. + // We need to ensure librac_commons.so is loaded first (dependency). + try { + PlatformLoader.loadCommons(); + } catch (_) { + // Ignore - continue trying to load backend + } + + // Try different naming conventions for the backend library + final libraryNames = [ + 'librac_backend_llamacpp_jni.so', + 'librunanywhere_llamacpp.so', + ]; + + for (final name in libraryNames) { + try { + return DynamicLibrary.open(name); + } catch (_) { + // Try next name + } + } + + // If backend library not found, throw an error + throw ArgumentError( + 'Could not load LlamaCpp backend library on Android. ' + 'Tried: ${libraryNames.join(", ")}', + ); + } + + // On iOS/macOS, everything is statically linked + return PlatformLoader.loadCommons(); + } + + /// Check if the LlamaCpp backend library can be loaded on this platform. + static bool checkAvailability() { + try { + final lib = loadBackendLibrary(); + lib.lookup>( + 'rac_backend_llamacpp_register'); + return true; + } catch (_) { + return false; + } + } + + void _bindFunctions() { + // Backend registration - from RABackendLlamaCPP + try { + _register = _lib.lookupFunction('rac_backend_llamacpp_register'); + } catch (_) { + _register = null; + } + + try { + _unregister = _lib.lookupFunction('rac_backend_llamacpp_unregister'); + } catch (_) { + _unregister = null; + } + } + + /// Check if bindings are available. + bool get isAvailable => _register != null; + + /// Register the LlamaCPP backend with the C++ service registry. + /// + /// Returns RAC_SUCCESS (0) on success, or an error code. + /// Safe to call multiple times - returns RAC_ERROR_MODULE_ALREADY_REGISTERED + /// if already registered. + int register() { + if (_register == null) { + return RacResultCode.errorNotSupported; + } + return _register!(); + } + + /// Unregister the LlamaCPP backend from C++ registry. + int unregister() { + if (_unregister == null) { + return RacResultCode.errorNotSupported; + } + return _unregister!(); + } +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere_llamacpp/lib/runanywhere_llamacpp.dart b/sdk/runanywhere-flutter/packages/runanywhere_llamacpp/lib/runanywhere_llamacpp.dart new file mode 100644 index 000000000..5f7dfb465 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere_llamacpp/lib/runanywhere_llamacpp.dart @@ -0,0 +1,41 @@ +/// LlamaCpp backend for RunAnywhere Flutter SDK. +/// +/// This package provides LLM (Language Model) capabilities via llama.cpp. +/// It is a **thin wrapper** that registers the C++ backend with the service registry. +/// +/// ## Architecture (matches Swift/Kotlin exactly) +/// +/// The C++ backend (RABackendLlamaCPP) handles all business logic: +/// - Service provider registration +/// - Model loading and inference +/// - Streaming generation +/// +/// This Dart module just: +/// 1. Calls `rac_backend_llamacpp_register()` to register the backend +/// 2. The core SDK handles all LLM operations via `rac_llm_component_*` +/// +/// ## Quick Start +/// +/// ```dart +/// import 'package:runanywhere/runanywhere.dart'; +/// import 'package:runanywhere_llamacpp/runanywhere_llamacpp.dart'; +/// +/// // Initialize SDK +/// await RunAnywhere.initialize(); +/// +/// // Register LlamaCpp module (matches Swift: LlamaCPP.register()) +/// await LlamaCpp.register(); +/// ``` +/// +/// ## Capabilities +/// +/// - **LLM (Language Model)**: Text generation using GGUF models +/// - **Streaming**: Token-by-token streaming generation +/// +/// ## Supported Quantizations +/// +/// Q2_K, Q3_K_S/M/L, Q4_0/1, Q4_K_S/M, Q5_0/1, Q5_K_S/M, Q6_K, Q8_0, etc. +library runanywhere_llamacpp; + +export 'llamacpp.dart'; +export 'llamacpp_error.dart'; diff --git a/sdk/runanywhere-flutter/packages/runanywhere_llamacpp/pubspec.yaml b/sdk/runanywhere-flutter/packages/runanywhere_llamacpp/pubspec.yaml new file mode 100644 index 000000000..b6bb520b2 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere_llamacpp/pubspec.yaml @@ -0,0 +1,41 @@ +name: runanywhere_llamacpp +description: LlamaCpp backend for RunAnywhere Flutter SDK. High-performance on-device LLM text generation with GGUF model support. +version: 0.15.11 +homepage: https://runanywhere.ai +repository: https://github.com/RunanywhereAI/runanywhere-sdks +issue_tracker: https://github.com/RunanywhereAI/runanywhere-sdks/issues +topics: + - ai + - llm + - llama + - text-generation + - on-device + +environment: + sdk: '>=3.0.0 <4.0.0' + flutter: '>=3.10.0' + +dependencies: + flutter: + sdk: flutter + # Core SDK dependency (provides RACommons) + runanywhere: ^0.15.11 + ffi: ^2.1.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^3.0.0 + +flutter: + uses-material-design: true + + # Native plugin configuration + # RABackendLlamaCPP binaries are bundled in ios/ and android/ directories + plugin: + platforms: + android: + package: ai.runanywhere.sdk.llamacpp + pluginClass: LlamaCppPlugin + ios: + pluginClass: LlamaCppPlugin diff --git a/sdk/runanywhere-flutter/packages/runanywhere_onnx/CHANGELOG.md b/sdk/runanywhere-flutter/packages/runanywhere_onnx/CHANGELOG.md new file mode 100644 index 000000000..60fa92d86 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere_onnx/CHANGELOG.md @@ -0,0 +1,33 @@ +# Changelog + +All notable changes to the RunAnywhere ONNX Backend will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.15.9] - 2025-01-11 + +### Changed +- Updated runanywhere dependency to ^0.15.9 for iOS symbol visibility fix +- See runanywhere 0.15.9 changelog for details on the iOS fix + +## [0.15.8] - 2025-01-10 + +### Added +- Initial public release on pub.dev +- ONNX Runtime integration for on-device inference +- Speech-to-Text (STT) implementation using Whisper models +- Text-to-Speech (TTS) implementation +- Voice Activity Detection (VAD) implementation using Silero +- Native bindings for iOS and Android + +### Features +- Real-time speech transcription +- Neural voice synthesis +- Speech detection for voice interfaces +- Model download and extraction support +- Streaming transcription support + +### Platforms +- iOS 13.0+ support +- Android API 24+ support diff --git a/sdk/runanywhere-flutter/packages/runanywhere_onnx/LICENSE b/sdk/runanywhere-flutter/packages/runanywhere_onnx/LICENSE new file mode 100644 index 000000000..f58b44f54 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere_onnx/LICENSE @@ -0,0 +1,316 @@ +RunAnywhere License +Version 1.0, December 2025 + +Copyright (c) 2025 RunAnywhere, Inc. All Rights Reserved. + +This software and associated documentation files (the "Software") are made +available under the terms of this License. By using, copying, modifying, or +distributing the Software, you agree to be bound by the terms of this License. + + +PART I - GRANT OF PERMISSION +============================= + +Subject to the conditions in Part II, permission is hereby granted, free of +charge, to use, copy, modify, merge, publish, and distribute the Software, and +to permit persons to whom the Software is furnished to do so, under the terms +of the Apache License 2.0 (included in Part III below). + + +PART II - CONDITIONS AND RESTRICTIONS +===================================== + +1. PERMITTED USERS + + This free license grant applies only to: + + (a) Individual persons using the Software for personal, educational, + research, or non-commercial purposes; + + (b) Organizations (including parent companies, subsidiaries, and affiliates) + that meet BOTH of the following criteria: + (i) Less than $1,000,000 USD in total funding (including but not + limited to equity investments, debt financing, grants, and loans); + AND + (ii) Less than $1,000,000 USD in gross annual revenue; + + (c) Educational institutions, including but not limited to universities, + colleges, schools, and students enrolled in such institutions; + + (d) Non-profit organizations registered under section 501(c)(3) of the + United States Internal Revenue Code, or equivalent charitable status + in other jurisdictions; + + (e) Government agencies and public sector organizations; + + (f) Open source projects that are themselves licensed under an OSI-approved + open source license. + +2. COMMERCIAL LICENSE REQUIRED + + Any person or organization not meeting the criteria in Section 1 must obtain + a separate commercial license from RunAnywhere, Inc. + + Contact: san@runanywhere.ai for commercial licensing terms. + +3. THRESHOLD TRANSITION + + If an organization initially qualifies under Section 1(b) but subsequently + exceeds either threshold: + + (a) This free license automatically terminates upon exceeding the threshold; + + (b) A commercial license must be obtained within thirty (30) days of + exceeding either threshold; + + (c) For purposes of this license, "gross annual revenue" means total + revenue in the preceding twelve (12) months, calculated on a rolling + basis. + +4. ATTRIBUTION REQUIREMENTS + + All copies or substantial portions of the Software must include: + + (a) This License notice, or a prominent link to it; + + (b) The copyright notice: "Copyright (c) 2025 RunAnywhere, Inc." + + (c) If modifications are made, a statement that the Software has been + modified, including a description of the nature of modifications. + +5. TRADEMARK NOTICE + + This License does not grant permission to use the trade names, trademarks, + service marks, or product names of RunAnywhere, Inc., including "RunAnywhere", + except as required for reasonable and customary use in describing the origin + of the Software. + + +PART III - APACHE LICENSE 2.0 +============================= + +For users meeting the conditions in Part II, the following Apache License 2.0 +terms apply: + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF APACHE LICENSE 2.0 TERMS AND CONDITIONS + + +PART IV - GENERAL PROVISIONS +============================ + +1. ENTIRE AGREEMENT + + This License constitutes the entire agreement between the parties with + respect to the Software and supersedes all prior or contemporaneous + understandings regarding such subject matter. + +2. SEVERABILITY + + If any provision of this License is held to be unenforceable, such + provision shall be reformed only to the extent necessary to make it + enforceable, and the remaining provisions shall continue in full force + and effect. + +3. WAIVER + + No waiver of any term of this License shall be deemed a further or + continuing waiver of such term or any other term. + +4. GOVERNING LAW + + This License shall be governed by and construed in accordance with the + laws of the State of Delaware, United States, without regard to its + conflict of laws provisions. + +5. CONTACT + + For commercial licensing inquiries, questions about this License, or + to report violations, please contact: + + RunAnywhere, Inc. + Email: san@runanywhere.ai + +--- + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +RUNANYWHERE, INC. OR ANY CONTRIBUTORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +IN THE SOFTWARE. diff --git a/sdk/runanywhere-flutter/packages/runanywhere_onnx/README.md b/sdk/runanywhere-flutter/packages/runanywhere_onnx/README.md new file mode 100644 index 000000000..b34a15932 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere_onnx/README.md @@ -0,0 +1,372 @@ +# RunAnywhere ONNX Backend + +[![pub package](https://img.shields.io/pub/v/runanywhere_onnx.svg)](https://pub.dev/packages/runanywhere_onnx) +[![License](https://img.shields.io/badge/License-RunAnywhere-blue.svg)](https://github.com/RunanywhereAI/runanywhere-sdks/blob/main/LICENSE) +[![Platform](https://img.shields.io/badge/platform-iOS%20%7C%20Android-lightgrey.svg)]() + +ONNX Runtime backend for the RunAnywhere Flutter SDK. Provides on-device Speech-to-Text (STT), Text-to-Speech (TTS), and Voice Activity Detection (VAD) capabilities. + +--- + +## Features + +| Feature | Description | +|---------|-------------| +| **Speech-to-Text (STT)** | Transcribe audio using Whisper models | +| **Text-to-Speech (TTS)** | Neural voice synthesis with Piper models | +| **Voice Activity Detection** | Real-time speech detection with Silero VAD | +| **Streaming Support** | Real-time transcription and synthesis | +| **Privacy-First** | All processing happens locally on device | +| **Multi-Language** | Support for 100+ languages (Whisper) | + +--- + +## Installation + +Add both the core SDK and this backend to your `pubspec.yaml`: + +```yaml +dependencies: + runanywhere: ^0.15.11 + runanywhere_onnx: ^0.15.11 +``` + +Then run: + +```bash +flutter pub get +``` + +> **Note:** This package requires the core `runanywhere` package. It won't work standalone. + +--- + +## Platform Support + +| Platform | Minimum Version | Requirements | +|----------|-----------------|--------------| +| iOS | 14.0+ | Microphone permission | +| Android | API 24+ | RECORD_AUDIO permission | + +--- + +## Platform Setup + +### iOS + +Update `ios/Podfile`: + +```ruby +platform :ios, '14.0' + +target 'Runner' do + use_frameworks! :linkage => :static # Required! + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) +end +``` + +Add to `ios/Runner/Info.plist`: + +```xml +NSMicrophoneUsageDescription +Microphone access is needed for speech recognition +``` + +### Android + +Add to `android/app/src/main/AndroidManifest.xml`: + +```xml + +``` + +--- + +## Quick Start + +### 1. Initialize & Register + +```dart +import 'package:runanywhere/runanywhere.dart'; +import 'package:runanywhere_onnx/runanywhere_onnx.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + // Initialize SDK + await RunAnywhere.initialize(); + + // Register ONNX backend + await Onnx.register(); + + runApp(MyApp()); +} +``` + +### 2. Add Models + +```dart +// STT Model (Whisper) +Onnx.addModel( + id: 'whisper-tiny-en', + name: 'Whisper Tiny English', + url: 'https://github.com/RunanywhereAI/sherpa-onnx/releases/download/runanywhere-models-v1/sherpa-onnx-whisper-tiny.en.tar.gz', + modality: ModelCategory.speechRecognition, + memoryRequirement: 75000000, // ~75MB +); + +// TTS Model (Piper) +Onnx.addModel( + id: 'piper-amy-medium', + name: 'Piper Amy (English)', + url: 'https://github.com/RunanywhereAI/sherpa-onnx/releases/download/runanywhere-models-v1/vits-piper-en_US-amy-medium.tar.gz', + modality: ModelCategory.speechSynthesis, + memoryRequirement: 50000000, // ~50MB +); +``` + +### 3. Speech-to-Text + +```dart +// Download and load STT model +await for (final p in RunAnywhere.downloadModel('whisper-tiny-en')) { + if (p.state.isCompleted) break; +} +await RunAnywhere.loadSTTModel('whisper-tiny-en'); + +// Transcribe audio (PCM16 @ 16kHz mono) +final text = await RunAnywhere.transcribe(audioData); +print('Transcription: $text'); + +// With detailed result +final result = await RunAnywhere.transcribeWithResult(audioData); +print('Text: ${result.text}'); +print('Confidence: ${result.confidence}'); +print('Language: ${result.language}'); +``` + +### 4. Text-to-Speech + +```dart +// Download and load TTS model +await for (final p in RunAnywhere.downloadModel('piper-amy-medium')) { + if (p.state.isCompleted) break; +} +await RunAnywhere.loadTTSVoice('piper-amy-medium'); + +// Synthesize speech +final result = await RunAnywhere.synthesize( + 'Hello! Welcome to RunAnywhere.', + rate: 1.0, // Speech rate + pitch: 1.0, // Speech pitch +); + +print('Duration: ${result.durationSeconds}s'); +print('Sample rate: ${result.sampleRate} Hz'); +print('Samples: ${result.samples.length}'); + +// Play with audioplayers package +// await audioPlayer.play(BytesSource(wavBytes)); +``` + +--- + +## API Reference + +### Onnx Class + +#### `register()` + +Register the ONNX backend with the SDK. + +```dart +static Future register({int priority = 100}) +``` + +**Parameters:** +- `priority` – Backend priority (higher = preferred). Default: 100. + +#### `addModel()` + +Add an ONNX model to the registry. + +```dart +static void addModel({ + required String id, + required String name, + required String url, + required ModelCategory modality, + int memoryRequirement = 0, +}) +``` + +**Parameters:** +- `id` – Unique model identifier +- `name` – Human-readable model name +- `url` – Download URL (supports .tar.gz, .tar.bz2, .zip) +- `modality` – Model category (`speechRecognition`, `speechSynthesis`) +- `memoryRequirement` – Estimated memory usage in bytes + +--- + +## Supported Models + +### Speech-to-Text (Whisper) + +| Model | Size | Memory | Languages | Speed | +|-------|------|--------|-----------|-------| +| whisper-tiny.en | ~40MB | ~75MB | English only | Fastest | +| whisper-tiny | ~75MB | ~150MB | Multilingual | Fast | +| whisper-base.en | ~75MB | ~150MB | English only | Fast | +| whisper-base | ~150MB | ~300MB | Multilingual | Medium | +| whisper-small.en | ~250MB | ~500MB | English only | Slower | + +> **Recommendation:** Use `whisper-tiny.en` for English-only apps. Use `whisper-tiny` for multilingual support. + +### Text-to-Speech (Piper) + +| Voice | Language | Size | Quality | +|-------|----------|------|---------| +| amy-medium | English (US) | ~50MB | Medium | +| amy-low | English (US) | ~25MB | Lower | +| lessac-medium | English (US) | ~50MB | Medium | +| Various | 30+ languages | Varies | Medium | + +> **Recommendation:** Use `amy-medium` for good quality English TTS. + +--- + +## Voice Agent Integration + +For full voice assistant functionality, combine STT + LLM + TTS: + +```dart +import 'package:runanywhere/runanywhere.dart'; +import 'package:runanywhere_onnx/runanywhere_onnx.dart'; +import 'package:runanywhere_llamacpp/runanywhere_llamacpp.dart'; + +// Initialize all backends +await RunAnywhere.initialize(); +await Onnx.register(); +await LlamaCpp.register(); + +// Load all models +await RunAnywhere.loadSTTModel('whisper-tiny-en'); +await RunAnywhere.loadModel('smollm2-360m'); +await RunAnywhere.loadTTSVoice('piper-amy-medium'); + +// Check voice agent readiness +print('Voice agent ready: ${RunAnywhere.isVoiceAgentReady}'); + +// Start voice session +if (RunAnywhere.isVoiceAgentReady) { + final session = await RunAnywhere.startVoiceSession(); + + session.events.listen((event) { + if (event is VoiceSessionTranscribed) { + print('User: ${event.text}'); + } else if (event is VoiceSessionResponded) { + print('AI: ${event.text}'); + } + }); +} +``` + +--- + +## Audio Format Requirements + +### STT Input + +| Property | Requirement | +|----------|-------------| +| Format | PCM16 (signed 16-bit) | +| Sample Rate | 16000 Hz | +| Channels | Mono (1 channel) | +| Encoding | Little-endian | + +### TTS Output + +| Property | Value | +|----------|-------| +| Format | Float32 PCM | +| Sample Rate | 22050 Hz (Piper default) | +| Channels | Mono (1 channel) | + +--- + +## Troubleshooting + +### STT Returns Empty Text + +**Possible Causes:** +1. Audio too short (< 0.5 seconds) +2. Audio too quiet (no speech detected) +3. Wrong audio format (not PCM16 @ 16kHz) + +**Solutions:** +1. Ensure audio is at least 1 second +2. Check microphone input levels +3. Verify audio format matches requirements + +### TTS Sounds Robotic + +**Solutions:** +1. Use `*-medium` quality models instead of `*-low` +2. Adjust rate/pitch parameters +3. Try different voice models + +### Model Loading Fails + +**Solutions:** +1. Verify model is fully downloaded +2. Check model format compatibility +3. Ensure sufficient memory available + +### Permission Denied + +**iOS:** +- Add `NSMicrophoneUsageDescription` to Info.plist +- Request permission before recording + +**Android:** +- Add `RECORD_AUDIO` permission to AndroidManifest.xml +- Use `permission_handler` package to request at runtime + +--- + +## Memory Management + +```dart +// Unload STT model to free memory +await RunAnywhere.unloadSTTModel(); + +// Unload TTS voice +await RunAnywhere.unloadTTSVoice(); + +// Check current loaded models +print('STT loaded: ${RunAnywhere.isSTTModelLoaded}'); +print('TTS loaded: ${RunAnywhere.isTTSVoiceLoaded}'); +``` + +--- + +## Related Packages + +- [runanywhere](https://pub.dev/packages/runanywhere) — Core SDK (required) +- [runanywhere_llamacpp](https://pub.dev/packages/runanywhere_llamacpp) — LLM backend +- [runanywhere_onnx](https://pub.dev/packages/runanywhere_onnx) — STT/TTS/VAD backend (this package) + +## Resources + +- [Flutter Starter Example](https://github.com/RunanywhereAI/flutter-starter-example) +- [Documentation](https://runanywhere.ai/docs) +- [GitHub Issues](https://github.com/RunanywhereAI/runanywhere-sdks/issues) + +--- + +## License + +This software is licensed under the RunAnywhere License, which is based on Apache 2.0 with additional terms for commercial use. See [LICENSE](https://github.com/RunanywhereAI/runanywhere-sdks/blob/main/LICENSE) for details. + +For commercial licensing inquiries, contact: san@runanywhere.ai diff --git a/sdk/runanywhere-flutter/packages/runanywhere_onnx/android/binary_config.gradle b/sdk/runanywhere-flutter/packages/runanywhere_onnx/android/binary_config.gradle new file mode 100644 index 000000000..7b66f0d09 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere_onnx/android/binary_config.gradle @@ -0,0 +1,50 @@ +// ============================================================================= +// BINARY CONFIGURATION FOR RUNANYWHERE FLUTTER SDK - ANDROID (ONNX Package) +// ============================================================================= +// This file controls whether to use local or remote native libraries (.so files). +// Similar to Swift Package.swift's testLocal flag. +// +// Set to `true` to use local binaries from android/src/main/jniLibs/ +// Set to `false` to download binaries from GitHub releases (production mode) +// ============================================================================= + +ext { + // Set this to true for local development/testing + // Set to false for production builds (downloads from GitHub releases) + testLocal = true + + // ============================================================================= + // Version Configuration (MUST match Swift Package.swift and Kotlin build.gradle.kts) + // ============================================================================= + coreVersion = "0.1.4" + + // ============================================================================= + // Remote binary URLs + // RABackendONNX from runanywhere-binaries + // ============================================================================= + binariesGitHubOrg = "RunanywhereAI" + binariesRepo = "runanywhere-binaries" + binariesBaseUrl = "https://github.com/${binariesGitHubOrg}/${binariesRepo}/releases/download" + + // Android native libraries package + onnxAndroidUrl = "${binariesBaseUrl}/core-v${coreVersion}/RABackendONNX-android-v${coreVersion}.zip" + + // Helper method to check if we should download + shouldDownloadAndroidLibs = { -> + return !testLocal + } + + // Helper method to check if local libs exist + checkLocalLibsExist = { -> + def jniLibsDir = project.file('src/main/jniLibs') + def arm64Dir = new File(jniLibsDir, 'arm64-v8a') + + if (!arm64Dir.exists() || !arm64Dir.isDirectory()) { + return false + } + + // Check for ONNX backend library + def onnxLib = new File(arm64Dir, 'librac_backend_onnx_jni.so') + return onnxLib.exists() + } +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere_onnx/android/build.gradle b/sdk/runanywhere-flutter/packages/runanywhere_onnx/android/build.gradle new file mode 100644 index 000000000..bd5dae57d --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere_onnx/android/build.gradle @@ -0,0 +1,170 @@ +// RunAnywhere ONNX Backend - Android +// +// This plugin bundles RABackendONNX native libraries (.so files) for Android. +// RABackendONNX provides STT, TTS, VAD capabilities using ONNX Runtime and Sherpa-ONNX. +// +// Binary Configuration: +// Edit binary_config.gradle to toggle between local and remote binaries: +// - testLocal = true: Use local .so files from android/src/main/jniLibs/ (for development) +// - testLocal = false: Download from GitHub releases (for production) +// +// Version: Must match Swift SDK's Package.swift and Kotlin SDK's build.gradle.kts + +group 'ai.runanywhere.sdk.onnx' +version '0.15.8' + +// Load binary configuration +apply from: 'binary_config.gradle' + +buildscript { + ext.kotlin_version = '1.9.10' + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:8.1.0' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + namespace 'ai.runanywhere.sdk.onnx' + compileSdk 34 + + // Use NDK for native library support + ndkVersion "25.2.9519653" + + defaultConfig { + minSdk 24 + targetSdk 34 + + // ABI filters for native libraries + ndk { + abiFilters 'arm64-v8a', 'armeabi-v7a', 'x86_64' + } + + // Consumer proguard rules + consumerProguardFiles 'proguard-rules.pro' + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = '1.8' + } + + sourceSets { + main { + // Native libraries location - use downloaded libs or local libs based on config + jniLibs.srcDirs = [testLocal ? 'src/main/jniLibs' : 'build/jniLibs'] + } + } + + buildTypes { + release { + minifyEnabled false + } + } +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" +} + +// ============================================================================= +// Binary Download Task (runs when testLocal = false) +// ============================================================================= +task downloadNativeLibs { + group = 'runanywhere' + description = 'Download RABackendONNX native libraries from GitHub releases' + + doLast { + if (shouldDownloadAndroidLibs()) { + println "📦 Remote mode: Downloading RABackendONNX Android native libraries..." + + def jniLibsDir = file('build/jniLibs') + if (jniLibsDir.exists()) { + delete(jniLibsDir) + } + jniLibsDir.mkdirs() + + // Ensure build directory exists + buildDir.mkdirs() + + def downloadUrl = onnxAndroidUrl + def zipFile = file("${buildDir}/onnx-android.zip") + + println "Downloading from: ${downloadUrl}" + + // Download the zip file + ant.get(src: downloadUrl, dest: zipFile) + + println "✅ Downloaded successfully" + + // Extract to temp directory first + def tempDir = file("${buildDir}/onnx-temp") + if (tempDir.exists()) { + delete(tempDir) + } + tempDir.mkdirs() + + copy { + from zipTree(zipFile) + into tempDir + } + + // Common libs that should NOT be duplicated (they're in the core SDK) + def commonLibs = ['libc++_shared.so', 'librac_commons.so', 'librac_commons_jni.so'] + + // Copy .so files from jniLibs structure (excluding common libs) + tempDir.eachFileRecurse { file -> + if (file.isDirectory() && file.name in ['arm64-v8a', 'armeabi-v7a', 'x86_64', 'x86']) { + def targetAbiDir = new File(jniLibsDir, file.name) + targetAbiDir.mkdirs() + file.eachFile { soFile -> + if (soFile.name.endsWith('.so') && !(soFile.name in commonLibs)) { + copy { + from soFile + into targetAbiDir + } + println " ✓ ${file.name}/${soFile.name}" + } + } + } + } + + // Clean up + zipFile.delete() + if (tempDir.exists()) { + delete(tempDir) + } + + println "✅ RABackendONNX native libraries downloaded successfully" + } else { + println "🔧 Local mode: Using native libraries from src/main/jniLibs/" + + if (!checkLocalLibsExist()) { + throw new GradleException(""" + ⚠️ Native libraries not found in src/main/jniLibs/! + For local mode, please build and copy the libraries: + 1. cd runanywhere-core && ./scripts/build-android.sh --onnx + 2. Copy the .so files to packages/runanywhere_onnx/android/src/main/jniLibs/ + Or switch to remote mode by editing binary_config.gradle: + testLocal = false + """) + } else { + println "✅ Using local native libraries" + } + } + } +} + +// Run downloadNativeLibs before preBuild +preBuild.dependsOn downloadNativeLibs diff --git a/sdk/runanywhere-flutter/packages/runanywhere_onnx/android/proguard-rules.pro b/sdk/runanywhere-flutter/packages/runanywhere_onnx/android/proguard-rules.pro new file mode 100644 index 000000000..00060976c --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere_onnx/android/proguard-rules.pro @@ -0,0 +1,11 @@ +# RunAnywhere ONNX SDK ProGuard Rules +# Keep native method signatures +-keepclasseswithmembernames class * { + native ; +} + +# Keep ONNX plugin classes +-keep class ai.runanywhere.sdk.onnx.** { *; } + +# Keep ONNX Runtime classes +-keep class ai.onnxruntime.** { *; } diff --git a/sdk/runanywhere-flutter/packages/runanywhere_onnx/android/src/main/AndroidManifest.xml b/sdk/runanywhere-flutter/packages/runanywhere_onnx/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000..20118374d --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere_onnx/android/src/main/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/sdk/runanywhere-flutter/packages/runanywhere_onnx/android/src/main/kotlin/ai/runanywhere/sdk/onnx/OnnxPlugin.kt b/sdk/runanywhere-flutter/packages/runanywhere_onnx/android/src/main/kotlin/ai/runanywhere/sdk/onnx/OnnxPlugin.kt new file mode 100644 index 000000000..dfd4f04fd --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere_onnx/android/src/main/kotlin/ai/runanywhere/sdk/onnx/OnnxPlugin.kt @@ -0,0 +1,65 @@ +package ai.runanywhere.sdk.onnx + +import android.os.Build +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import io.flutter.plugin.common.MethodChannel.Result + +/** + * RunAnywhere ONNX Flutter Plugin - Android Implementation + * + * This plugin provides the native bridge for the ONNX backend on Android. + * The actual STT/TTS/VAD functionality is provided by RABackendONNX native libraries (.so files). + */ +class OnnxPlugin : FlutterPlugin, MethodCallHandler { + private lateinit var channel: MethodChannel + + companion object { + private const val CHANNEL_NAME = "runanywhere_onnx" + private const val BACKEND_VERSION = "0.1.4" + private const val BACKEND_NAME = "ONNX" + + init { + // Load ONNX backend native libraries + try { + System.loadLibrary("onnxruntime") + System.loadLibrary("sherpa-onnx-c-api") + System.loadLibrary("rac_backend_onnx_jni") + } catch (e: UnsatisfiedLinkError) { + // Library may not be available in all configurations + android.util.Log.w("ONNX", "Failed to load ONNX libraries: ${e.message}") + } + } + } + + override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + channel = MethodChannel(flutterPluginBinding.binaryMessenger, CHANNEL_NAME) + channel.setMethodCallHandler(this) + } + + override fun onMethodCall(call: MethodCall, result: Result) { + when (call.method) { + "getPlatformVersion" -> { + result.success("Android ${Build.VERSION.RELEASE}") + } + "getBackendVersion" -> { + result.success(BACKEND_VERSION) + } + "getBackendName" -> { + result.success(BACKEND_NAME) + } + "getCapabilities" -> { + result.success(listOf("stt", "tts", "vad")) + } + else -> { + result.notImplemented() + } + } + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + channel.setMethodCallHandler(null) + } +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere_onnx/ios/Classes/OnnxPlugin.swift b/sdk/runanywhere-flutter/packages/runanywhere_onnx/ios/Classes/OnnxPlugin.swift new file mode 100644 index 000000000..ca7b1ae12 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere_onnx/ios/Classes/OnnxPlugin.swift @@ -0,0 +1,33 @@ +import Flutter +import UIKit + +/// RunAnywhere ONNX Flutter Plugin - iOS Implementation +/// +/// This plugin provides the native bridge for the ONNX backend on iOS. +/// The actual STT/TTS/VAD functionality is provided by RABackendONNX.xcframework. +public class OnnxPlugin: NSObject, FlutterPlugin { + + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel( + name: "runanywhere_onnx", + binaryMessenger: registrar.messenger() + ) + let instance = OnnxPlugin() + registrar.addMethodCallDelegate(instance, channel: channel) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "getPlatformVersion": + result("iOS " + UIDevice.current.systemVersion) + case "getBackendVersion": + result("0.1.4") + case "getBackendName": + result("ONNX") + case "getCapabilities": + result(["stt", "tts", "vad"]) + default: + result(FlutterMethodNotImplemented) + } + } +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere_onnx/ios/runanywhere_onnx.podspec b/sdk/runanywhere-flutter/packages/runanywhere_onnx/ios/runanywhere_onnx.podspec new file mode 100644 index 000000000..0a25ba46b --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere_onnx/ios/runanywhere_onnx.podspec @@ -0,0 +1,196 @@ +# +# RunAnywhere ONNX Backend - iOS +# +# This podspec integrates RABackendONNX.xcframework into Flutter iOS apps. +# RABackendONNX provides STT, TTS, VAD capabilities using ONNX Runtime and Sherpa-ONNX. +# +# Binary Configuration: +# - Set RA_TEST_LOCAL=1 or create .testlocal file to use local binaries +# - Otherwise, binaries are downloaded from GitHub releases (production mode) +# +# Version: Must match Swift SDK's Package.swift and Kotlin SDK's build.gradle.kts +# +# Architecture Note: +# This follows the same pattern as React Native and Swift SDKs - bundling +# onnxruntime.xcframework directly rather than using a CocoaPods dependency. +# This ensures version consistency across all SDKs. +# + +# ============================================================================= +# Version Constants (MUST match Swift Package.swift) +# ============================================================================= +ONNX_VERSION = "0.1.4" +ONNXRUNTIME_VERSION = "1.17.1" + +# ============================================================================= +# Binary Source - RABackendONNX from runanywhere-binaries +# ============================================================================= +GITHUB_ORG = "RunanywhereAI" +BINARIES_REPO = "runanywhere-binaries" + +# ============================================================================= +# testLocal Toggle +# Set RA_TEST_LOCAL=1 or create .testlocal file to use local binaries +# ============================================================================= +TEST_LOCAL = ENV['RA_TEST_LOCAL'] == '1' || File.exist?(File.join(__dir__, '.testlocal')) + +Pod::Spec.new do |s| + s.name = 'runanywhere_onnx' + s.version = '0.15.11' + s.summary = 'RunAnywhere ONNX: STT, TTS, VAD for Flutter' + s.description = <<-DESC +ONNX Runtime backend for RunAnywhere Flutter SDK. Provides speech-to-text (STT), +text-to-speech (TTS), and voice activity detection (VAD) capabilities using +ONNX Runtime and Sherpa-ONNX. Pre-built binaries are downloaded from: +https://github.com/RunanywhereAI/runanywhere-binaries + DESC + s.homepage = 'https://runanywhere.ai' + s.license = { :type => 'MIT' } + s.author = { 'RunAnywhere' => 'team@runanywhere.ai' } + s.source = { :path => '.' } + + s.ios.deployment_target = '14.0' + s.swift_version = '5.0' + + # Source files (minimal - main logic is in the xcframework) + s.source_files = 'Classes/**/*' + + # Flutter dependency + s.dependency 'Flutter' + + # Core SDK dependency (provides RACommons) + s.dependency 'runanywhere' + + # ============================================================================= + # RABackendONNX + ONNX Runtime XCFrameworks + # + # Unlike using `s.dependency 'onnxruntime-c'`, we bundle onnxruntime.xcframework + # directly to match the architecture of other SDKs: + # - Swift SDK: Downloads via SPM binaryTarget from download.onnxruntime.ai + # - React Native: Downloads in prepare_command alongside RABackendONNX + # - Kotlin: Bundles libonnxruntime.so in jniLibs + # + # This ensures version consistency (1.17.1) across all platforms. + # ============================================================================= + if TEST_LOCAL + puts "[runanywhere_onnx] Using LOCAL frameworks from Frameworks/" + s.vendored_frameworks = [ + 'Frameworks/RABackendONNX.xcframework', + 'Frameworks/onnxruntime.xcframework' + ] + else + s.prepare_command = <<-CMD + set -e + + FRAMEWORK_DIR="Frameworks" + VERSION="#{ONNX_VERSION}" + ONNX_VERSION="#{ONNXRUNTIME_VERSION}" + VERSION_FILE="$FRAMEWORK_DIR/.onnx_version" + + # Check if already downloaded with correct version + if [ -f "$VERSION_FILE" ] && [ -d "$FRAMEWORK_DIR/RABackendONNX.xcframework" ]; then + CURRENT_VERSION=$(cat "$VERSION_FILE") + if [ "$CURRENT_VERSION" = "$VERSION" ]; then + echo "✅ RABackendONNX.xcframework version $VERSION already downloaded" + # Still need to check onnxruntime + if [ -d "$FRAMEWORK_DIR/onnxruntime.xcframework" ]; then + exit 0 + fi + fi + fi + + echo "📦 Downloading RABackendONNX.xcframework version $VERSION..." + + mkdir -p "$FRAMEWORK_DIR" + rm -rf "$FRAMEWORK_DIR/RABackendONNX.xcframework" + + # Download from runanywhere-binaries + DOWNLOAD_URL="https://github.com/#{GITHUB_ORG}/#{BINARIES_REPO}/releases/download/core-v$VERSION/RABackendONNX-ios-v$VERSION.zip" + ZIP_FILE="/tmp/RABackendONNX.zip" + + echo " URL: $DOWNLOAD_URL" + + curl -L -f -o "$ZIP_FILE" "$DOWNLOAD_URL" || { + echo "❌ Failed to download RABackendONNX from $DOWNLOAD_URL" + exit 1 + } + + echo "📂 Extracting RABackendONNX.xcframework..." + unzip -q -o "$ZIP_FILE" -d "$FRAMEWORK_DIR/" + rm -f "$ZIP_FILE" + + # Download ONNX Runtime if not present (matches Swift/React Native SDKs) + if [ ! -d "$FRAMEWORK_DIR/onnxruntime.xcframework" ]; then + echo "📦 Downloading ONNX Runtime version $ONNX_VERSION..." + ONNX_URL="https://download.onnxruntime.ai/pod-archive-onnxruntime-c-$ONNX_VERSION.zip" + ONNX_ZIP="/tmp/onnxruntime.zip" + + curl -L -f -o "$ONNX_ZIP" "$ONNX_URL" || { + echo "❌ Failed to download ONNX Runtime from $ONNX_URL" + exit 1 + } + + echo "📂 Extracting onnxruntime.xcframework..." + unzip -q -o "$ONNX_ZIP" -d "$FRAMEWORK_DIR/" + rm -f "$ONNX_ZIP" + fi + + echo "$VERSION" > "$VERSION_FILE" + + if [ -d "$FRAMEWORK_DIR/RABackendONNX.xcframework" ] && [ -d "$FRAMEWORK_DIR/onnxruntime.xcframework" ]; then + echo "✅ ONNX frameworks installed successfully" + else + echo "❌ ONNX framework extraction failed" + exit 1 + fi + CMD + + s.vendored_frameworks = [ + 'Frameworks/RABackendONNX.xcframework', + 'Frameworks/onnxruntime.xcframework' + ] + end + + s.preserve_paths = 'Frameworks/**/*' + + # Required frameworks + s.frameworks = [ + 'Foundation', + 'CoreML', + 'Accelerate', + 'AVFoundation', + 'AudioToolbox' + ] + + # Weak frameworks (optional hardware acceleration) + s.weak_frameworks = [ + 'Metal', + 'MetalKit', + 'MetalPerformanceShaders' + ] + + # Build settings + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386', + 'OTHER_LDFLAGS' => '-lc++ -larchive -lbz2', + 'CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES' => 'YES', + 'ENABLE_BITCODE' => 'NO', + # Header search paths for onnxruntime.xcframework (needed for compilation) + 'HEADER_SEARCH_PATHS' => [ + '$(PODS_TARGET_SRCROOT)/Frameworks/onnxruntime.xcframework/ios-arm64/Headers', + '$(PODS_TARGET_SRCROOT)/Frameworks/onnxruntime.xcframework/ios-arm64_x86_64-simulator/Headers', + ].join(' '), + } + + # CRITICAL: -all_load ensures ALL object files from RABackendONNX.xcframework are linked. + # This is required for Flutter FFI to find symbols at runtime via dlsym(). + s.user_target_xcconfig = { + 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386', + 'OTHER_LDFLAGS' => '-lc++ -larchive -lbz2 -all_load', + 'DEAD_CODE_STRIPPING' => 'NO', + } + + # Mark static framework for proper linking + s.static_framework = true +end diff --git a/sdk/runanywhere-flutter/packages/runanywhere_onnx/lib/native/onnx_bindings.dart b/sdk/runanywhere-flutter/packages/runanywhere_onnx/lib/native/onnx_bindings.dart new file mode 100644 index 000000000..213638cd3 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere_onnx/lib/native/onnx_bindings.dart @@ -0,0 +1,148 @@ +import 'dart:ffi'; +import 'dart:io'; + +import 'package:runanywhere/native/ffi_types.dart'; +import 'package:runanywhere/native/platform_loader.dart'; + +/// Minimal ONNX backend FFI bindings. +/// +/// This is a **thin wrapper** that only provides: +/// - `register()` - calls `rac_backend_onnx_register()` +/// - `unregister()` - calls `rac_backend_onnx_unregister()` +/// +/// All other STT/TTS/VAD operations are handled by the core SDK via +/// `rac_stt_component_*`, `rac_tts_component_*`, `rac_vad_component_*` functions. +/// +/// ## Architecture (matches Swift/Kotlin) +/// +/// The C++ backend (RABackendONNX) handles all business logic: +/// - Service provider registration with the C++ service registry +/// - Model loading and inference for STT/TTS/VAD +/// - Streaming transcription +/// +/// This Dart code just: +/// 1. Calls `rac_backend_onnx_register()` to register the backend +/// 2. The core SDK handles all operations via component APIs +class OnnxBindings { + final DynamicLibrary _lib; + + // Function pointers - only registration functions + late final RacBackendOnnxRegisterDart? _register; + late final RacBackendOnnxUnregisterDart? _unregister; + + /// Create bindings using the appropriate library for each platform. + /// + /// - iOS: Uses DynamicLibrary.process() for statically linked XCFramework + /// - Android: Loads librac_backend_onnx_jni.so separately + OnnxBindings() : _lib = _loadLibrary() { + _bindFunctions(); + } + + /// Create bindings with a specific library (for testing). + OnnxBindings.withLibrary(this._lib) { + _bindFunctions(); + } + + /// Load the correct library for the current platform. + static DynamicLibrary _loadLibrary() { + return loadBackendLibrary(); + } + + /// Load the ONNX backend library. + /// + /// On iOS/macOS: Uses DynamicLibrary.process() for statically linked XCFramework + /// On Android: Loads librac_backend_onnx_jni.so or librunanywhere_onnx.so + /// + /// This is exposed as a static method so it can be used by [Onnx.isAvailable]. + static DynamicLibrary loadBackendLibrary() { + if (Platform.isAndroid) { + // On Android, the ONNX backend is in a separate .so file. + // We need to ensure librac_commons.so is loaded first (dependency). + try { + PlatformLoader.loadCommons(); + } catch (_) { + // Ignore - continue trying to load backend + } + + // Try different naming conventions for the backend library + final libraryNames = [ + 'librac_backend_onnx_jni.so', + 'librunanywhere_onnx.so', + ]; + + for (final name in libraryNames) { + try { + return DynamicLibrary.open(name); + } catch (_) { + // Try next name + } + } + + // If backend library not found, throw an error + throw ArgumentError( + 'Could not load ONNX backend library on Android. ' + 'Tried: ${libraryNames.join(", ")}', + ); + } + + // On iOS/macOS, everything is statically linked + return PlatformLoader.loadCommons(); + } + + /// Check if the ONNX backend library can be loaded on this platform. + static bool checkAvailability() { + try { + final lib = loadBackendLibrary(); + lib.lookup>('rac_backend_onnx_register'); + return true; + } catch (_) { + return false; + } + } + + void _bindFunctions() { + // Backend registration - from RABackendONNX + try { + _register = _lib.lookupFunction('rac_backend_onnx_register'); + } catch (_) { + _register = null; + } + + try { + _unregister = _lib.lookupFunction('rac_backend_onnx_unregister'); + } catch (_) { + _unregister = null; + } + } + + /// Check if bindings are available. + bool get isAvailable => _register != null; + + /// Register the ONNX backend with the C++ service registry. + /// + /// Returns RAC_SUCCESS (0) on success, or an error code. + /// Safe to call multiple times - returns RAC_ERROR_MODULE_ALREADY_REGISTERED + /// if already registered. + int register() { + if (_register == null) { + return RacResultCode.errorNotSupported; + } + return _register!(); + } + + /// Unregister the ONNX backend from C++ registry. + int unregister() { + if (_unregister == null) { + return RacResultCode.errorNotSupported; + } + return _unregister!(); + } +} + +// FFI type definitions for ONNX backend registration +typedef RacBackendOnnxRegisterNative = Int32 Function(); +typedef RacBackendOnnxRegisterDart = int Function(); +typedef RacBackendOnnxUnregisterNative = Int32 Function(); +typedef RacBackendOnnxUnregisterDart = int Function(); diff --git a/sdk/runanywhere-flutter/packages/runanywhere_onnx/lib/onnx.dart b/sdk/runanywhere-flutter/packages/runanywhere_onnx/lib/onnx.dart new file mode 100644 index 000000000..177ca7cf9 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere_onnx/lib/onnx.dart @@ -0,0 +1,256 @@ +/// ONNX Runtime backend for RunAnywhere Flutter SDK. +/// +/// This module provides STT, TTS, and VAD capabilities via ONNX Runtime. +/// It is a **thin wrapper** that registers the C++ backend with the service registry. +/// +/// ## Architecture (matches Swift/Kotlin) +/// +/// The C++ backend (RABackendONNX) handles all business logic: +/// - Service provider registration +/// - Model loading and inference for STT/TTS/VAD +/// - Streaming transcription +/// +/// This Dart module just: +/// 1. Calls `rac_backend_onnx_register()` to register the backend +/// 2. The core SDK handles all operations via component APIs +/// +/// ## Quick Start +/// +/// ```dart +/// import 'package:runanywhere_onnx/runanywhere_onnx.dart'; +/// +/// // Register the module (matches Swift: ONNX.register()) +/// await Onnx.register(); +/// +/// // Add STT model +/// Onnx.addModel( +/// name: 'Sherpa Whisper Tiny', +/// url: 'https://github.com/.../sherpa-onnx-whisper-tiny.en.tar.gz', +/// modality: ModelCategory.speechRecognition, +/// ); +/// ``` +library runanywhere_onnx; + +import 'dart:async'; + +import 'package:runanywhere/core/module/runanywhere_module.dart'; +import 'package:runanywhere/core/types/model_types.dart'; +import 'package:runanywhere/core/types/sdk_component.dart'; +import 'package:runanywhere/foundation/logging/sdk_logger.dart'; +import 'package:runanywhere/native/ffi_types.dart'; +import 'package:runanywhere/public/runanywhere.dart' show RunAnywhere; +import 'package:runanywhere_onnx/native/onnx_bindings.dart'; + +// Re-export for backward compatibility +export 'onnx_download_strategy.dart'; + +/// ONNX Runtime module for STT, TTS, and VAD services. +/// +/// Provides speech-to-text, text-to-speech, and voice activity detection +/// capabilities using ONNX Runtime with models like Whisper, Piper, and Silero. +/// +/// Matches Swift `ONNX` enum from ONNXRuntime/ONNX.swift. +class Onnx implements RunAnywhereModule { + // ============================================================================ + // Singleton Pattern (matches Swift enum pattern) + // ============================================================================ + + static final Onnx _instance = Onnx._internal(); + static Onnx get module => _instance; + Onnx._internal(); + + // ============================================================================ + // Module Info (matches Swift exactly) + // ============================================================================ + + /// Current version of the ONNX Runtime module + static const String version = '2.0.0'; + + /// ONNX Runtime library version (underlying C library) + static const String onnxRuntimeVersion = '1.23.2'; + + // ============================================================================ + // RunAnywhereModule Conformance (matches Swift exactly) + // ============================================================================ + + @override + String get moduleId => 'onnx'; + + @override + String get moduleName => 'ONNX Runtime'; + + @override + Set get capabilities => { + SDKComponent.stt, + SDKComponent.tts, + SDKComponent.vad, + }; + + @override + int get defaultPriority => 100; + + @override + InferenceFramework get inferenceFramework => InferenceFramework.onnx; + + // ============================================================================ + // Registration State + // ============================================================================ + + static bool _isRegistered = false; + static OnnxBindings? _bindings; + static final _logger = SDKLogger('Onnx'); + + /// Internal model registry for models added via addModel + static final List _registeredModels = []; + + // ============================================================================ + // Registration (matches Swift ONNX.register() exactly) + // ============================================================================ + + /// Register ONNX backend with the C++ service registry. + /// + /// This calls `rac_backend_onnx_register()` to register all ONNX + /// service providers (STT, TTS, VAD) with the C++ commons layer. + /// + /// Safe to call multiple times - subsequent calls are no-ops. + static Future register({int priority = 100}) async { + if (_isRegistered) { + _logger.debug('ONNX already registered'); + return; + } + + // Check native library availability + if (!isAvailable) { + _logger.error('ONNX native library not available'); + return; + } + + _logger.info('Registering ONNX backend with C++ registry...'); + + try { + _bindings = OnnxBindings(); + final result = _bindings!.register(); + + // RAC_SUCCESS = 0, RAC_ERROR_MODULE_ALREADY_REGISTERED = specific code + if (result != RacResultCode.success && + result != RacResultCode.errorModuleAlreadyRegistered) { + _logger.warning('C++ backend registration returned: $result'); + return; + } + + _isRegistered = true; + _logger.info('ONNX backend registered successfully (STT + TTS + VAD)'); + } catch (e) { + _logger.error('OnnxBindings not available: $e'); + } + } + + /// Unregister the ONNX backend from C++ registry. + static void unregister() { + if (!_isRegistered) return; + + _bindings?.unregister(); + _isRegistered = false; + _logger.info('ONNX backend unregistered'); + } + + // ============================================================================ + // Model Handling (matches Swift exactly) + // ============================================================================ + + /// Check if the native backend is available on this platform. + /// + /// On iOS: Checks DynamicLibrary.process() for statically linked symbols + /// On Android: Checks if librac_backend_onnx_jni.so can be loaded + static bool get isAvailable => OnnxBindings.checkAvailability(); + + /// Check if ONNX can handle a given model for STT. + static bool canHandleSTT(String? modelId) { + if (modelId == null) return false; + final lowercased = modelId.toLowerCase(); + return lowercased.contains('whisper') || + lowercased.contains('zipformer') || + lowercased.contains('paraformer'); + } + + /// Check if ONNX can handle a given model for TTS. + static bool canHandleTTS(String? modelId) { + if (modelId == null) return false; + final lowercased = modelId.toLowerCase(); + return lowercased.contains('piper') || lowercased.contains('vits'); + } + + /// Check if ONNX can handle VAD (always true for Silero VAD). + static bool canHandleVAD(String? modelId) { + return true; // ONNX Silero VAD is the default + } + + // ============================================================================ + // Model Registration (convenience API) + // ============================================================================ + + /// Add an ONNX model to the registry. + /// + /// This is a convenience method that registers a model with the SDK. + /// The model will be associated with the ONNX backend. + /// + /// Matches Swift pattern - models are registered globally via RunAnywhere. + static void addModel({ + String? id, + required String name, + required String url, + ModelCategory modality = ModelCategory.language, + int? memoryRequirement, + bool supportsThinking = false, + }) { + final uri = Uri.tryParse(url); + if (uri == null) { + _logger.error('Invalid URL for model: $name'); + return; + } + + final modelId = + id ?? name.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), '-'); + + // Register with the global SDK registry (matches Swift pattern) + final model = RunAnywhere.registerModel( + id: modelId, + name: name, + url: uri, + framework: InferenceFramework.onnx, + modality: modality, + memoryRequirement: memoryRequirement, + supportsThinking: supportsThinking, + ); + + // Keep local reference for convenience + _registeredModels.add(model); + _logger.info('Added ONNX model: $name ($modelId) [$modality]'); + } + + /// Get all models registered with this module + static List get registeredModels => + List.unmodifiable(_registeredModels); + + // ============================================================================ + // Cleanup + // ============================================================================ + + /// Dispose of resources + static void dispose() { + _bindings = null; + _registeredModels.clear(); + _isRegistered = false; + _logger.info('ONNX disposed'); + } + + // ============================================================================ + // Auto-Registration (matches Swift exactly) + // ============================================================================ + + /// Enable auto-registration for this module. + /// Call this function to trigger C++ backend registration. + static void autoRegister() { + unawaited(register()); + } +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere_onnx/lib/onnx_download_strategy.dart b/sdk/runanywhere-flutter/packages/runanywhere_onnx/lib/onnx_download_strategy.dart new file mode 100644 index 000000000..91d595c2c --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere_onnx/lib/onnx_download_strategy.dart @@ -0,0 +1,277 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:archive/archive.dart'; +import 'package:http/http.dart' as http; +import 'package:runanywhere/foundation/error_types/sdk_error.dart'; +import 'package:runanywhere/foundation/logging/sdk_logger.dart'; + +/// ONNX download strategy for handling .onnx files and .tar.bz2 archives +/// Matches iOS ONNXDownloadStrategy pattern +/// +/// Uses pure Dart archive extraction (via `archive` package) for cross-platform support. +/// This works on both iOS and Android without requiring native libarchive. +class OnnxDownloadStrategy { + final SDKLogger logger = SDKLogger('OnnxDownloadStrategy'); + + /// Check if this strategy can handle a given URL + bool canHandle(String url) { + final urlString = url.toLowerCase(); + + // Handle tar.bz2 archives (sherpa-onnx models) + final isTarBz2 = urlString.endsWith('.tar.bz2'); + + // Handle direct .onnx files (HuggingFace Piper models) + final isDirectOnnx = urlString.endsWith('.onnx'); + + return isTarBz2 || isDirectOnnx; + } + + /// Download a model from URL to destination folder + Future download({ + required String modelId, + required Uri downloadURL, + required Uri destinationFolder, + void Function(double progress)? progressHandler, + }) async { + final urlString = downloadURL.toString().toLowerCase(); + + if (urlString.endsWith('.onnx')) { + // Handle direct ONNX files (download model + config) + return _downloadDirectOnnx( + modelId: modelId, + downloadURL: downloadURL, + destinationFolder: destinationFolder, + progressHandler: progressHandler, + ); + } else if (urlString.endsWith('.tar.bz2')) { + // Handle tar.bz2 archives + return _downloadTarBz2Archive( + modelId: modelId, + downloadURL: downloadURL, + destinationFolder: destinationFolder, + progressHandler: progressHandler, + ); + } else { + throw SDKError.downloadFailed( + urlString, + 'Unsupported ONNX model format', + ); + } + } + + /// Download direct .onnx files along with their companion .onnx.json config + Future _downloadDirectOnnx({ + required String modelId, + required Uri downloadURL, + required Uri destinationFolder, + void Function(double progress)? progressHandler, + }) async { + logger.info('Downloading direct ONNX model: $modelId'); + + // Create model folder + final modelFolder = Directory(destinationFolder.toFilePath()); + await modelFolder.create(recursive: true); + + // Get the model filename from URL + final modelFilename = downloadURL.path.split('/').last; + final modelDestination = File('${modelFolder.path}/$modelFilename'); + + // Also download the companion .onnx.json config file + final configURL = Uri.parse('${downloadURL.toString()}.json'); + final configFilename = '$modelFilename.json'; + final configDestination = File('${modelFolder.path}/$configFilename'); + + logger.info('Downloading model file: $modelFilename'); + logger.info('Downloading config file: $configFilename'); + + // Download model file (0% - 45%) + await _downloadFile( + from: downloadURL, + to: modelDestination, + progressHandler: (progress) => progressHandler?.call(progress * 0.45), + ); + + logger.info('Model file downloaded, now downloading config...'); + progressHandler?.call(0.5); + + // Download config file (50% - 95%) + try { + await _downloadFile( + from: configURL, + to: configDestination, + progressHandler: (progress) => + progressHandler?.call(0.5 + progress * 0.45), + ); + logger.info('Config file downloaded successfully'); + } catch (e) { + // Config file might not exist for some models, log warning but continue + logger.warning('Config file download failed (model may still work): $e'); + } + + progressHandler?.call(1.0); + + logger.info('Direct ONNX model download complete: ${modelFolder.path}'); + return modelFolder.uri; + } + + /// Download and extract tar.bz2 archive + Future _downloadTarBz2Archive({ + required String modelId, + required Uri downloadURL, + required Uri destinationFolder, + void Function(double progress)? progressHandler, + }) async { + logger.info('Downloading sherpa-onnx archive for model: $modelId'); + + // Use the provided destination folder + final modelFolder = Directory(destinationFolder.toFilePath()); + await modelFolder.create(recursive: true); + + // Download the .tar.bz2 archive to a temporary location + final tempDirectory = Directory.systemTemp; + final archivePath = File('${tempDirectory.path}/$modelId.tar.bz2'); + + logger.info('Downloading archive to: ${archivePath.path}'); + + // Download the archive (0% - 50%) + await _downloadFile( + from: downloadURL, + to: archivePath, + progressHandler: (progress) => progressHandler?.call(progress * 0.5), + ); + + // Report download complete (50% - download done, extraction next) + progressHandler?.call(0.5); + + logger.info('Archive downloaded, extracting to: ${modelFolder.path}'); + + // Extract the archive using pure Dart + try { + await _extractTarBz2( + archivePath: archivePath.path, + destinationPath: modelFolder.path, + onProgress: (extractProgress) { + // Map extraction progress to 50% - 95% of overall progress + final overallProgress = 0.5 + (extractProgress * 0.45); + progressHandler?.call(overallProgress); + }, + ); + + logger.info('Archive extracted successfully to: ${modelFolder.path}'); + } catch (e) { + logger.error('Archive extraction failed: $e'); + // Clean up archive on error + try { + await archivePath.delete(); + } catch (_) {} + rethrow; + } + + // Clean up the archive + try { + await archivePath.delete(); + } catch (e) { + logger.warning('Failed to delete archive file: $e'); + } + + // Find the extracted model directory + // Sherpa-ONNX archives typically extract to a subdirectory with the model name + final contents = await modelFolder.list().toList(); + logger.debug( + 'Extracted contents: ${contents.map((e) => e.path.split('/').last).join(", ")}'); + + // If there's a single subdirectory, the actual model files are in there + var modelDir = modelFolder; + if (contents.length == 1 && contents.first is Directory) { + final subdir = contents.first as Directory; + final subdirStat = await subdir.stat(); + if (subdirStat.type == FileSystemEntityType.directory) { + // Model files are in the subdirectory + modelDir = subdir; + logger.info( + 'Model files are in subdirectory: ${subdir.path.split('/').last}'); + } + } + + // Report completion (100%) + progressHandler?.call(1.0); + + logger.info('Model download and extraction complete: ${modelDir.path}'); + return modelDir.uri; + } + + /// Extract tar.bz2 archive using pure Dart + Future _extractTarBz2({ + required String archivePath, + required String destinationPath, + void Function(double progress)? onProgress, + }) async { + final archiveFile = File(archivePath); + final bytes = await archiveFile.readAsBytes(); + + // Decompress bz2 + final decompressed = BZip2Decoder().decodeBytes(bytes); + + // Extract tar + final archive = TarDecoder().decodeBytes(decompressed); + final totalFiles = archive.files.length; + + for (var i = 0; i < archive.files.length; i++) { + final file = archive.files[i]; + final filename = file.name; + + if (file.isFile) { + final outputFile = File('$destinationPath/$filename'); + await outputFile.parent.create(recursive: true); + await outputFile.writeAsBytes(file.content as List); + } + + onProgress?.call((i + 1) / totalFiles); + } + } + + /// Helper to download a single file + Future _downloadFile({ + required Uri from, + required File to, + void Function(double progress)? progressHandler, + }) async { + logger.debug('Downloading file from: $from'); + + // Ensure destination directory exists + await to.parent.create(recursive: true); + + final request = http.Request('GET', from); + final response = await http.Client().send(request); + + if (response.statusCode != 200) { + throw SDKError.downloadFailed( + from.toString(), + 'Download failed with status ${response.statusCode}', + ); + } + + final totalBytes = response.contentLength ?? 0; + int bytesDownloaded = 0; + + final sink = to.openWrite(); + + // Stream response and track progress + await for (final chunk in response.stream) { + sink.add(chunk); + bytesDownloaded += chunk.length; + + if (totalBytes > 0 && progressHandler != null) { + final progress = bytesDownloaded / totalBytes; + progressHandler(progress); + } + } + + await sink.close(); + + if (progressHandler != null) { + progressHandler(1.0); + } + } +} diff --git a/sdk/runanywhere-flutter/packages/runanywhere_onnx/lib/runanywhere_onnx.dart b/sdk/runanywhere-flutter/packages/runanywhere_onnx/lib/runanywhere_onnx.dart new file mode 100644 index 000000000..6ba32e288 --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere_onnx/lib/runanywhere_onnx.dart @@ -0,0 +1,38 @@ +/// ONNX Runtime backend for RunAnywhere Flutter SDK. +/// +/// This package provides STT, TTS, and VAD capabilities. +/// It is a **thin wrapper** that registers the C++ backend with the service registry. +/// +/// ## Architecture (matches Swift/Kotlin exactly) +/// +/// The C++ backend (RABackendONNX) handles all business logic: +/// - Service provider registration +/// - Model loading and inference for STT/TTS/VAD +/// - Streaming transcription +/// +/// This Dart module just: +/// 1. Calls `rac_backend_onnx_register()` to register the backend +/// 2. The core SDK handles all operations via component APIs +/// +/// ## Quick Start +/// +/// ```dart +/// import 'package:runanywhere/runanywhere.dart'; +/// import 'package:runanywhere_onnx/runanywhere_onnx.dart'; +/// +/// // Initialize SDK +/// await RunAnywhere.initialize(); +/// +/// // Register ONNX module (matches Swift: ONNX.register()) +/// await Onnx.register(); +/// ``` +/// +/// ## Capabilities +/// +/// - **STT (Speech-to-Text)**: Streaming and batch transcription +/// - **TTS (Text-to-Speech)**: Neural voice synthesis +/// - **VAD (Voice Activity Detection)**: Real-time speech detection +library runanywhere_onnx; + +export 'onnx.dart'; +export 'onnx_download_strategy.dart'; diff --git a/sdk/runanywhere-flutter/packages/runanywhere_onnx/pubspec.yaml b/sdk/runanywhere-flutter/packages/runanywhere_onnx/pubspec.yaml new file mode 100644 index 000000000..ff43187da --- /dev/null +++ b/sdk/runanywhere-flutter/packages/runanywhere_onnx/pubspec.yaml @@ -0,0 +1,45 @@ +name: runanywhere_onnx +description: ONNX Runtime backend for RunAnywhere Flutter SDK. On-device Speech-to-Text, Text-to-Speech, and Voice Activity Detection. +version: 0.15.11 +homepage: https://runanywhere.ai +repository: https://github.com/RunanywhereAI/runanywhere-sdks +issue_tracker: https://github.com/RunanywhereAI/runanywhere-sdks/issues +topics: + - ai + - onnx + - speech-recognition + - text-to-speech + - on-device + +environment: + sdk: '>=3.0.0 <4.0.0' + flutter: '>=3.10.0' + +dependencies: + flutter: + sdk: flutter + # Core SDK dependency (provides RACommons) + runanywhere: ^0.15.11 + ffi: ^2.1.0 + # HTTP for download strategy + http: ^1.2.1 + # Archive extraction for model downloads + archive: ^3.4.9 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^3.0.0 + +flutter: + uses-material-design: true + + # Native plugin configuration + # RABackendONNX binaries are bundled in ios/ and android/ directories + plugin: + platforms: + android: + package: ai.runanywhere.sdk.onnx + pluginClass: OnnxPlugin + ios: + pluginClass: OnnxPlugin diff --git a/sdk/runanywhere-flutter/scripts/build-flutter.sh b/sdk/runanywhere-flutter/scripts/build-flutter.sh new file mode 100755 index 000000000..dd7a90744 --- /dev/null +++ b/sdk/runanywhere-flutter/scripts/build-flutter.sh @@ -0,0 +1,656 @@ +#!/bin/bash +# ============================================================================= +# RunAnywhere Flutter SDK - Build Script +# ============================================================================= +# +# Single entry point for building the Flutter SDK and its native dependencies. +# Similar to iOS's build-swift.sh, Android's build-kotlin.sh, and RN's build-react-native.sh. +# +# USAGE: +# ./scripts/build-flutter.sh [options] +# +# OPTIONS: +# --setup First-time setup: install deps, build commons, copy frameworks/libs +# --local Use locally built native libs (sets testLocal=true) +# --remote Use remote libs from GitHub releases (sets testLocal=false) +# --rebuild-commons Force rebuild of runanywhere-commons +# --ios Build for iOS only +# --android Build for Android only (default: both) +# --clean Clean build directories before building +# --skip-build Skip native build (only setup frameworks/libs) +# --help Show this help message +# +# EXAMPLES: +# # First-time setup (downloads + builds + copies everything) +# ./scripts/build-flutter.sh --setup +# +# # Rebuild only commons (after C++ code changes) +# ./scripts/build-flutter.sh --local --rebuild-commons +# +# # Just switch to local mode (uses cached libs) +# ./scripts/build-flutter.sh --local --skip-build +# +# # iOS only setup +# ./scripts/build-flutter.sh --setup --ios +# +# ============================================================================= + +set -e + +# ============================================================================= +# Configuration +# ============================================================================= + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FLUTTER_SDK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +SDK_ROOT="$(cd "${FLUTTER_SDK_DIR}/.." && pwd)" +COMMONS_DIR="${SDK_ROOT}/runanywhere-commons" +COMMONS_IOS_SCRIPT="${COMMONS_DIR}/scripts/build-ios.sh" +COMMONS_ANDROID_SCRIPT="${COMMONS_DIR}/scripts/build-android.sh" + +# Package directories +CORE_PKG="${FLUTTER_SDK_DIR}/packages/runanywhere" +LLAMACPP_PKG="${FLUTTER_SDK_DIR}/packages/runanywhere_llamacpp" +ONNX_PKG="${FLUTTER_SDK_DIR}/packages/runanywhere_onnx" + +# iOS output directories +CORE_IOS_FRAMEWORKS="${CORE_PKG}/ios/Frameworks" +LLAMACPP_IOS_FRAMEWORKS="${LLAMACPP_PKG}/ios/Frameworks" +ONNX_IOS_FRAMEWORKS="${ONNX_PKG}/ios/Frameworks" + +# Android output directories +CORE_ANDROID_JNILIBS="${CORE_PKG}/android/src/main/jniLibs" +LLAMACPP_ANDROID_JNILIBS="${LLAMACPP_PKG}/android/src/main/jniLibs" +ONNX_ANDROID_JNILIBS="${ONNX_PKG}/android/src/main/jniLibs" + +# Defaults +MODE="local" +SETUP_MODE=false +REBUILD_COMMONS=false +CLEAN_BUILD=false +SKIP_BUILD=false +BUILD_IOS=true +BUILD_ANDROID=true +ABIS="arm64-v8a" + +# ============================================================================= +# Colors & Logging +# ============================================================================= + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +NC='\033[0m' + +log_header() { + echo "" + echo -e "${GREEN}═══════════════════════════════════════════${NC}" + echo -e "${GREEN} $1${NC}" + echo -e "${GREEN}═══════════════════════════════════════════${NC}" +} + +log_step() { + echo -e "${BLUE}==>${NC} $1" +} + +log_info() { + echo -e "${CYAN}[✓]${NC} $1" +} + +log_warn() { + echo -e "${YELLOW}[!]${NC} $1" +} + +log_error() { + echo -e "${RED}[✗]${NC} $1" +} + +# ============================================================================= +# Argument Parsing +# ============================================================================= + +show_help() { + head -40 "$0" | tail -37 + exit 0 +} + +for arg in "$@"; do + case "$arg" in + --setup) + SETUP_MODE=true + REBUILD_COMMONS=true + ;; + --local) + MODE="local" + ;; + --remote) + MODE="remote" + ;; + --rebuild-commons) + REBUILD_COMMONS=true + ;; + --ios) + BUILD_IOS=true + BUILD_ANDROID=false + ;; + --android) + BUILD_IOS=false + BUILD_ANDROID=true + ;; + --clean) + CLEAN_BUILD=true + ;; + --skip-build) + SKIP_BUILD=true + ;; + --abis=*) + ABIS="${arg#*=}" + ;; + --help|-h) + show_help + ;; + *) + log_error "Unknown option: $arg" + show_help + ;; + esac +done + +# ============================================================================= +# Setup Environment +# ============================================================================= + +setup_environment() { + log_header "Setting Up Flutter Environment" + + cd "$FLUTTER_SDK_DIR" + + # Check for flutter + if ! command -v flutter &> /dev/null; then + log_error "flutter is not installed. Please install Flutter SDK first." + exit 1 + fi + + log_info "Flutter version: $(flutter --version | head -1)" + + # Check for melos (optional but recommended) + if command -v melos &> /dev/null; then + log_step "Running melos bootstrap..." + melos bootstrap || true + else + log_warn "melos not found, running flutter pub get for each package..." + for pkg in "$CORE_PKG" "$LLAMACPP_PKG" "$ONNX_PKG"; do + if [[ -f "$pkg/pubspec.yaml" ]]; then + (cd "$pkg" && flutter pub get) + fi + done + fi + + log_info "Dependencies installed" +} + +# ============================================================================= +# Build Commons (Native Libraries) +# ============================================================================= + +build_commons_ios() { + log_header "Building runanywhere-commons for iOS" + + if [[ ! -x "$COMMONS_IOS_SCRIPT" ]]; then + log_error "iOS build script not found: $COMMONS_IOS_SCRIPT" + exit 1 + fi + + local FLAGS="" + [[ "$CLEAN_BUILD" == true ]] && FLAGS="$FLAGS --clean" + + log_step "Running: build-ios.sh $FLAGS" + "$COMMONS_IOS_SCRIPT" $FLAGS + + log_info "iOS commons build complete" +} + +build_commons_android() { + log_header "Building runanywhere-commons for Android" + + if [[ ! -x "$COMMONS_ANDROID_SCRIPT" ]]; then + log_error "Android build script not found: $COMMONS_ANDROID_SCRIPT" + exit 1 + fi + + # build-android.sh takes positional args: BACKENDS ABIS + # Use "llamacpp,onnx" for backends to get both LlamaCPP and ONNX + local BACKENDS="llamacpp,onnx" + + log_step "Running: build-android.sh $BACKENDS $ABIS" + "$COMMONS_ANDROID_SCRIPT" "$BACKENDS" "$ABIS" + + log_info "Android commons build complete" +} + +# ============================================================================= +# Copy iOS Frameworks +# ============================================================================= + +copy_ios_frameworks() { + log_header "Copying iOS XCFrameworks" + + local COMMONS_DIST="${COMMONS_DIR}/dist" + + # Create directories + mkdir -p "$CORE_IOS_FRAMEWORKS" + mkdir -p "$LLAMACPP_IOS_FRAMEWORKS" + mkdir -p "$ONNX_IOS_FRAMEWORKS" + + # Copy RACommons.xcframework to core package + if [[ -d "${COMMONS_DIST}/RACommons.xcframework" ]]; then + rm -rf "${CORE_IOS_FRAMEWORKS}/RACommons.xcframework" + cp -R "${COMMONS_DIST}/RACommons.xcframework" "${CORE_IOS_FRAMEWORKS}/" + log_info "Core: RACommons.xcframework" + else + log_warn "RACommons.xcframework not found at ${COMMONS_DIST}/" + fi + + # Copy RABackendLLAMACPP.xcframework to llamacpp package + if [[ -d "${COMMONS_DIST}/RABackendLLAMACPP.xcframework" ]]; then + rm -rf "${LLAMACPP_IOS_FRAMEWORKS}/RABackendLLAMACPP.xcframework" + cp -R "${COMMONS_DIST}/RABackendLLAMACPP.xcframework" "${LLAMACPP_IOS_FRAMEWORKS}/" + log_info "LlamaCPP: RABackendLLAMACPP.xcframework" + else + log_warn "RABackendLLAMACPP.xcframework not found at ${COMMONS_DIST}/" + fi + + # Copy RABackendONNX.xcframework to onnx package + if [[ -d "${COMMONS_DIST}/RABackendONNX.xcframework" ]]; then + rm -rf "${ONNX_IOS_FRAMEWORKS}/RABackendONNX.xcframework" + cp -R "${COMMONS_DIST}/RABackendONNX.xcframework" "${ONNX_IOS_FRAMEWORKS}/" + log_info "ONNX: RABackendONNX.xcframework" + else + log_warn "RABackendONNX.xcframework not found at ${COMMONS_DIST}/" + fi + + # Copy onnxruntime.xcframework to onnx package (required dependency) + # This matches the architecture of React Native and Swift SDKs + local ONNX_RUNTIME_PATH="${COMMONS_DIR}/third_party/onnxruntime-ios/onnxruntime.xcframework" + if [[ -d "${ONNX_RUNTIME_PATH}" ]]; then + rm -rf "${ONNX_IOS_FRAMEWORKS}/onnxruntime.xcframework" + cp -R "${ONNX_RUNTIME_PATH}" "${ONNX_IOS_FRAMEWORKS}/" + log_info "ONNX: onnxruntime.xcframework" + else + log_warn "onnxruntime.xcframework not found at ${ONNX_RUNTIME_PATH}" + fi + + # Create .testlocal markers for local mode + touch "${CORE_PKG}/ios/.testlocal" + touch "${LLAMACPP_PKG}/ios/.testlocal" + touch "${ONNX_PKG}/ios/.testlocal" + + log_info "iOS frameworks copied" +} + +# ============================================================================= +# Copy Android JNI Libraries +# ============================================================================= + +copy_android_jnilibs() { + log_header "Copying Android JNI Libraries" + + local COMMONS_DIST="${COMMONS_DIR}/dist/android" + local COMMONS_BUILD="${COMMONS_DIR}/build/android/unified" + + # Find Android NDK for runtime libraries (libc++_shared.so, libomp.so) + local NDK_PATH="${ANDROID_NDK_HOME:-$NDK_HOME}" + if [[ -z "$NDK_PATH" ]] || [[ ! -d "$NDK_PATH" ]]; then + # Try common locations + if [[ -d "$HOME/Library/Android/sdk/ndk" ]]; then + NDK_PATH=$(ls -d "$HOME/Library/Android/sdk/ndk"/*/ 2>/dev/null | sort -V | tail -1) + elif [[ -d "$HOME/Android/Sdk/ndk" ]]; then + NDK_PATH=$(ls -d "$HOME/Android/Sdk/ndk"/*/ 2>/dev/null | sort -V | tail -1) + elif [[ -n "$ANDROID_HOME" ]] && [[ -d "$ANDROID_HOME/ndk" ]]; then + NDK_PATH=$(ls -d "$ANDROID_HOME/ndk"/*/ 2>/dev/null | sort -V | tail -1) + fi + fi + + if [[ -n "$NDK_PATH" ]] && [[ -d "$NDK_PATH" ]]; then + log_info "Using Android NDK: $NDK_PATH" + else + log_warn "Android NDK not found - runtime libraries (libc++_shared.so, libomp.so) may not be copied" + fi + + IFS=',' read -ra ABI_ARRAY <<< "$ABIS" + + for ABI in "${ABI_ARRAY[@]}"; do + log_step "Copying libraries for ${ABI}..." + + # Create directories + mkdir -p "${CORE_ANDROID_JNILIBS}/${ABI}" + mkdir -p "${LLAMACPP_ANDROID_JNILIBS}/${ABI}" + mkdir -p "${ONNX_ANDROID_JNILIBS}/${ABI}" + + # Determine arch-specific search pattern for NDK libraries + local ARCH_PATTERN="" + case "$ABI" in + arm64-v8a) ARCH_PATTERN="aarch64" ;; + armeabi-v7a) ARCH_PATTERN="arm" ;; + x86_64) ARCH_PATTERN="x86_64" ;; + x86) ARCH_PATTERN="i686" ;; + esac + + # ======================================================================= + # Core Package: RACommons (librunanywhere_jni.so, librac_commons.so, libc++_shared.so) + # ======================================================================= + + # Copy librunanywhere_jni.so + if [[ -f "${COMMONS_DIST}/jni/${ABI}/librunanywhere_jni.so" ]]; then + cp "${COMMONS_DIST}/jni/${ABI}/librunanywhere_jni.so" "${CORE_ANDROID_JNILIBS}/${ABI}/" + log_info "Core: librunanywhere_jni.so" + elif [[ -f "${COMMONS_BUILD}/${ABI}/src/jni/librunanywhere_jni.so" ]]; then + cp "${COMMONS_BUILD}/${ABI}/src/jni/librunanywhere_jni.so" "${CORE_ANDROID_JNILIBS}/${ABI}/" + log_info "Core: librunanywhere_jni.so (from build)" + else + log_warn "Core: librunanywhere_jni.so NOT FOUND" + fi + + # Copy librac_commons.so + if [[ -f "${COMMONS_BUILD}/${ABI}/librac_commons.so" ]]; then + cp "${COMMONS_BUILD}/${ABI}/librac_commons.so" "${CORE_ANDROID_JNILIBS}/${ABI}/" + log_info "Core: librac_commons.so" + fi + + # Copy libc++_shared.so - try dist first, then NDK + if [[ -f "${COMMONS_DIST}/llamacpp/${ABI}/libc++_shared.so" ]]; then + cp "${COMMONS_DIST}/llamacpp/${ABI}/libc++_shared.so" "${CORE_ANDROID_JNILIBS}/${ABI}/" + log_info "Core: libc++_shared.so" + elif [[ -f "${COMMONS_DIST}/jni/${ABI}/libc++_shared.so" ]]; then + cp "${COMMONS_DIST}/jni/${ABI}/libc++_shared.so" "${CORE_ANDROID_JNILIBS}/${ABI}/" + log_info "Core: libc++_shared.so" + elif [[ -n "$NDK_PATH" ]] && [[ -n "$ARCH_PATTERN" ]]; then + # Find libc++_shared.so from NDK + local LIBCXX_FOUND=$(find "$NDK_PATH/toolchains/llvm/prebuilt" -name "libc++_shared.so" -path "*${ARCH_PATTERN}*" 2>/dev/null | head -1) + if [[ -n "$LIBCXX_FOUND" ]] && [[ -f "$LIBCXX_FOUND" ]]; then + cp "$LIBCXX_FOUND" "${CORE_ANDROID_JNILIBS}/${ABI}/" + log_info "Core: libc++_shared.so (from NDK)" + fi + fi + + # Copy libomp.so - try dist first, then NDK + if [[ -f "${COMMONS_DIST}/llamacpp/${ABI}/libomp.so" ]]; then + cp "${COMMONS_DIST}/llamacpp/${ABI}/libomp.so" "${CORE_ANDROID_JNILIBS}/${ABI}/" + log_info "Core: libomp.so" + elif [[ -f "${COMMONS_DIST}/jni/${ABI}/libomp.so" ]]; then + cp "${COMMONS_DIST}/jni/${ABI}/libomp.so" "${CORE_ANDROID_JNILIBS}/${ABI}/" + log_info "Core: libomp.so" + elif [[ -n "$NDK_PATH" ]] && [[ -n "$ARCH_PATTERN" ]]; then + # Find libomp.so from NDK + local LIBOMP_FOUND=$(find "$NDK_PATH/toolchains/llvm/prebuilt" -name "libomp.so" -path "*/${ARCH_PATTERN}/*" 2>/dev/null | head -1) + if [[ -n "$LIBOMP_FOUND" ]] && [[ -f "$LIBOMP_FOUND" ]]; then + cp "$LIBOMP_FOUND" "${CORE_ANDROID_JNILIBS}/${ABI}/" + log_info "Core: libomp.so (from NDK)" + fi + fi + + # ======================================================================= + # LlamaCPP Package: RABackendLlamaCPP + # ======================================================================= + + # Copy backend library + if [[ -f "${COMMONS_DIST}/llamacpp/${ABI}/librac_backend_llamacpp.so" ]]; then + cp "${COMMONS_DIST}/llamacpp/${ABI}/librac_backend_llamacpp.so" "${LLAMACPP_ANDROID_JNILIBS}/${ABI}/" + log_info "LlamaCPP: librac_backend_llamacpp.so" + elif [[ -f "${COMMONS_BUILD}/${ABI}/src/backends/llamacpp/librac_backend_llamacpp.so" ]]; then + cp "${COMMONS_BUILD}/${ABI}/src/backends/llamacpp/librac_backend_llamacpp.so" "${LLAMACPP_ANDROID_JNILIBS}/${ABI}/" + log_info "LlamaCPP: librac_backend_llamacpp.so (from build)" + fi + + # Copy JNI bridge (if exists) + if [[ -f "${COMMONS_DIST}/llamacpp/${ABI}/librac_backend_llamacpp_jni.so" ]]; then + cp "${COMMONS_DIST}/llamacpp/${ABI}/librac_backend_llamacpp_jni.so" "${LLAMACPP_ANDROID_JNILIBS}/${ABI}/" + log_info "LlamaCPP: librac_backend_llamacpp_jni.so" + elif [[ -f "${COMMONS_BUILD}/${ABI}/src/backends/llamacpp/librac_backend_llamacpp_jni.so" ]]; then + cp "${COMMONS_BUILD}/${ABI}/src/backends/llamacpp/librac_backend_llamacpp_jni.so" "${LLAMACPP_ANDROID_JNILIBS}/${ABI}/" + log_info "LlamaCPP: librac_backend_llamacpp_jni.so (from build)" + fi + + # Copy libomp.so to LlamaCPP package (required for OpenMP support) + if [[ -f "${COMMONS_DIST}/llamacpp/${ABI}/libomp.so" ]]; then + cp "${COMMONS_DIST}/llamacpp/${ABI}/libomp.so" "${LLAMACPP_ANDROID_JNILIBS}/${ABI}/" + log_info "LlamaCPP: libomp.so" + elif [[ -f "${COMMONS_DIST}/jni/${ABI}/libomp.so" ]]; then + cp "${COMMONS_DIST}/jni/${ABI}/libomp.so" "${LLAMACPP_ANDROID_JNILIBS}/${ABI}/" + log_info "LlamaCPP: libomp.so (from jni)" + elif [[ -n "$NDK_PATH" ]] && [[ -n "$ARCH_PATTERN" ]]; then + # Find libomp.so from NDK + local LIBOMP_FOUND=$(find "$NDK_PATH/toolchains/llvm/prebuilt" -name "libomp.so" -path "*/${ARCH_PATTERN}/*" 2>/dev/null | head -1) + if [[ -n "$LIBOMP_FOUND" ]] && [[ -f "$LIBOMP_FOUND" ]]; then + cp "$LIBOMP_FOUND" "${LLAMACPP_ANDROID_JNILIBS}/${ABI}/" + log_info "LlamaCPP: libomp.so (from NDK)" + fi + fi + + # Copy libc++_shared.so to LlamaCPP package + if [[ -f "${COMMONS_DIST}/llamacpp/${ABI}/libc++_shared.so" ]]; then + cp "${COMMONS_DIST}/llamacpp/${ABI}/libc++_shared.so" "${LLAMACPP_ANDROID_JNILIBS}/${ABI}/" + log_info "LlamaCPP: libc++_shared.so" + elif [[ -f "${COMMONS_DIST}/jni/${ABI}/libc++_shared.so" ]]; then + cp "${COMMONS_DIST}/jni/${ABI}/libc++_shared.so" "${LLAMACPP_ANDROID_JNILIBS}/${ABI}/" + log_info "LlamaCPP: libc++_shared.so (from jni)" + elif [[ -n "$NDK_PATH" ]] && [[ -n "$ARCH_PATTERN" ]]; then + # Find libc++_shared.so from NDK + local LIBCXX_FOUND=$(find "$NDK_PATH/toolchains/llvm/prebuilt" -name "libc++_shared.so" -path "*${ARCH_PATTERN}*" 2>/dev/null | head -1) + if [[ -n "$LIBCXX_FOUND" ]] && [[ -f "$LIBCXX_FOUND" ]]; then + cp "$LIBCXX_FOUND" "${LLAMACPP_ANDROID_JNILIBS}/${ABI}/" + log_info "LlamaCPP: libc++_shared.so (from NDK)" + fi + fi + + # ======================================================================= + # ONNX Package: RABackendONNX + # ======================================================================= + + # Copy backend library + if [[ -f "${COMMONS_DIST}/onnx/${ABI}/librac_backend_onnx.so" ]]; then + cp "${COMMONS_DIST}/onnx/${ABI}/librac_backend_onnx.so" "${ONNX_ANDROID_JNILIBS}/${ABI}/" + log_info "ONNX: librac_backend_onnx.so" + elif [[ -f "${COMMONS_BUILD}/${ABI}/src/backends/onnx/librac_backend_onnx.so" ]]; then + cp "${COMMONS_BUILD}/${ABI}/src/backends/onnx/librac_backend_onnx.so" "${ONNX_ANDROID_JNILIBS}/${ABI}/" + log_info "ONNX: librac_backend_onnx.so (from build)" + fi + + # Copy JNI bridge (if exists) + if [[ -f "${COMMONS_DIST}/onnx/${ABI}/librac_backend_onnx_jni.so" ]]; then + cp "${COMMONS_DIST}/onnx/${ABI}/librac_backend_onnx_jni.so" "${ONNX_ANDROID_JNILIBS}/${ABI}/" + log_info "ONNX: librac_backend_onnx_jni.so" + elif [[ -f "${COMMONS_BUILD}/${ABI}/src/backends/onnx/librac_backend_onnx_jni.so" ]]; then + cp "${COMMONS_BUILD}/${ABI}/src/backends/onnx/librac_backend_onnx_jni.so" "${ONNX_ANDROID_JNILIBS}/${ABI}/" + log_info "ONNX: librac_backend_onnx_jni.so (from build)" + fi + + # Copy ONNX Runtime - try dist first, then third_party (from Sherpa-ONNX) + local SHERPA_JNILIBS="${COMMONS_DIR}/third_party/sherpa-onnx-android/jniLibs/${ABI}" + if [[ -f "${COMMONS_DIST}/onnx/${ABI}/libonnxruntime.so" ]]; then + cp "${COMMONS_DIST}/onnx/${ABI}/libonnxruntime.so" "${ONNX_ANDROID_JNILIBS}/${ABI}/" + log_info "ONNX: libonnxruntime.so" + elif [[ -f "${SHERPA_JNILIBS}/libonnxruntime.so" ]]; then + cp "${SHERPA_JNILIBS}/libonnxruntime.so" "${ONNX_ANDROID_JNILIBS}/${ABI}/" + log_info "ONNX: libonnxruntime.so (from Sherpa-ONNX)" + fi + + # Copy Sherpa-ONNX libraries - try dist first, then third_party + for lib in libsherpa-onnx-c-api.so libsherpa-onnx-cxx-api.so libsherpa-onnx-jni.so; do + if [[ -f "${COMMONS_DIST}/onnx/${ABI}/${lib}" ]]; then + cp "${COMMONS_DIST}/onnx/${ABI}/${lib}" "${ONNX_ANDROID_JNILIBS}/${ABI}/" + log_info "ONNX: ${lib}" + elif [[ -f "${SHERPA_JNILIBS}/${lib}" ]]; then + cp "${SHERPA_JNILIBS}/${lib}" "${ONNX_ANDROID_JNILIBS}/${ABI}/" + log_info "ONNX: ${lib} (from Sherpa-ONNX)" + fi + done + done + + log_info "Android JNI libraries copied" +} + +# ============================================================================= +# Set Mode (Local/Remote) +# ============================================================================= + +set_mode() { + log_header "Setting Build Mode: $MODE" + + if [[ "$MODE" == "local" ]]; then + export RA_TEST_LOCAL=1 + + # Create .testlocal markers for iOS + touch "${CORE_PKG}/ios/.testlocal" + touch "${LLAMACPP_PKG}/ios/.testlocal" + touch "${ONNX_PKG}/ios/.testlocal" + + # Update Android binary_config.gradle files to use testLocal = true + for pkg in "$CORE_PKG" "$LLAMACPP_PKG" "$ONNX_PKG"; do + local config_file="${pkg}/android/binary_config.gradle" + if [[ -f "$config_file" ]]; then + sed -i.bak 's/testLocal = false/testLocal = true/g' "$config_file" + rm -f "${config_file}.bak" + fi + done + + log_info "Switched to LOCAL mode" + log_info " iOS: Using Frameworks/ directories" + log_info " Android: Using src/main/jniLibs/ directories" + else + unset RA_TEST_LOCAL + + # Remove .testlocal markers + rm -f "${CORE_PKG}/ios/.testlocal" + rm -f "${LLAMACPP_PKG}/ios/.testlocal" + rm -f "${ONNX_PKG}/ios/.testlocal" + + # Update Android binary_config.gradle files to use testLocal = false + for pkg in "$CORE_PKG" "$LLAMACPP_PKG" "$ONNX_PKG"; do + local config_file="${pkg}/android/binary_config.gradle" + if [[ -f "$config_file" ]]; then + sed -i.bak 's/testLocal = true/testLocal = false/g' "$config_file" + rm -f "${config_file}.bak" + fi + done + + log_info "Switched to REMOTE mode" + log_info " iOS: Will download from GitHub releases during pod install" + log_info " Android: Will download from GitHub releases during Gradle sync" + fi +} + +# ============================================================================= +# Clean +# ============================================================================= + +clean_build() { + log_header "Cleaning Build Directories" + + if [[ "$BUILD_IOS" == true ]]; then + rm -rf "${CORE_IOS_FRAMEWORKS}" + rm -rf "${LLAMACPP_IOS_FRAMEWORKS}" + rm -rf "${ONNX_IOS_FRAMEWORKS}" + log_info "Cleaned iOS frameworks" + fi + + if [[ "$BUILD_ANDROID" == true ]]; then + rm -rf "${CORE_ANDROID_JNILIBS}" + rm -rf "${LLAMACPP_ANDROID_JNILIBS}" + rm -rf "${ONNX_ANDROID_JNILIBS}" + log_info "Cleaned Android jniLibs" + fi + + # Run flutter clean on packages + log_step "Running flutter clean..." + for pkg in "$CORE_PKG" "$LLAMACPP_PKG" "$ONNX_PKG"; do + if [[ -f "$pkg/pubspec.yaml" ]]; then + (cd "$pkg" && flutter clean) || true + fi + done +} + +# ============================================================================= +# Print Summary +# ============================================================================= + +print_summary() { + log_header "Build Complete!" + + echo "" + echo "Mode: $MODE" + echo "" + + if [[ "$BUILD_IOS" == true ]]; then + echo "iOS Frameworks:" + ls -la "${CORE_IOS_FRAMEWORKS}" 2>/dev/null || echo " (none)" + ls -la "${LLAMACPP_IOS_FRAMEWORKS}" 2>/dev/null || echo " (none)" + ls -la "${ONNX_IOS_FRAMEWORKS}" 2>/dev/null || echo " (none)" + echo "" + fi + + if [[ "$BUILD_ANDROID" == true ]]; then + echo "Android JNI Libraries:" + for pkg_name in runanywhere runanywhere_llamacpp runanywhere_onnx; do + local dir="${FLUTTER_SDK_DIR}/packages/${pkg_name}/android/src/main/jniLibs" + if [[ -d "$dir" ]]; then + local count=$(find "$dir" -name "*.so" 2>/dev/null | wc -l) + local size=$(du -sh "$dir" 2>/dev/null | cut -f1) + echo " ${pkg_name}: ${count} libs (${size})" + fi + done + echo "" + fi + + echo "Next steps:" + echo " 1. Run example app: cd examples/flutter/RunAnywhereAI" + echo " 2. flutter pub get" + echo " 3. iOS: cd ios && pod install && cd .. && flutter run" + echo " 4. Android: flutter run" + echo "" + echo "To rebuild after C++ changes:" + echo " ./scripts/build-flutter.sh --local --rebuild-commons" +} + +# ============================================================================= +# Main +# ============================================================================= + +main() { + log_header "RunAnywhere Flutter SDK Build" + echo "Mode: $MODE" + echo "Setup: $SETUP_MODE" + echo "Rebuild Commons: $REBUILD_COMMONS" + echo "iOS: $BUILD_IOS" + echo "Android: $BUILD_ANDROID" + echo "ABIs: $ABIS" + echo "" + + # Clean if requested + [[ "$CLEAN_BUILD" == true ]] && clean_build + + # Setup environment (install deps) + [[ "$SETUP_MODE" == true ]] && setup_environment + + # Build native libraries if needed + if [[ "$REBUILD_COMMONS" == true ]] && [[ "$SKIP_BUILD" == false ]]; then + [[ "$BUILD_IOS" == true ]] && build_commons_ios + [[ "$BUILD_ANDROID" == true ]] && build_commons_android + fi + + # Copy frameworks/libs if in local mode + if [[ "$MODE" == "local" ]]; then + [[ "$BUILD_IOS" == true ]] && copy_ios_frameworks + [[ "$BUILD_ANDROID" == true ]] && copy_android_jnilibs + fi + + # Set mode + set_mode + + # Print summary + print_summary +} + +main "$@" diff --git a/sdk/runanywhere-kotlin/.commons-build-marker b/sdk/runanywhere-kotlin/.commons-build-marker new file mode 100644 index 000000000..e69de29bb diff --git a/sdk/runanywhere-kotlin/.editorconfig b/sdk/runanywhere-kotlin/.editorconfig new file mode 100644 index 000000000..8327fb1c8 --- /dev/null +++ b/sdk/runanywhere-kotlin/.editorconfig @@ -0,0 +1,26 @@ +# EditorConfig for ktlint +root = true + +[*.{kt,kts}] +# Disable wildcard import rule - too many existing violations +ktlint_standard_no-wildcard-imports = disabled +# Disable backing property naming - conflicts with conventional patterns +ktlint_standard_backing-property-naming = disabled +# Disable consecutive comments check - KDoc patterns conflict +ktlint_standard_no-consecutive-comments = disabled +# Disable property naming for Android-style INSTANCE singletons +ktlint_standard_property-naming = disabled +# Disable filename rule - actual implementations may have different names +ktlint_standard_filename = disabled +# Disable enum entry name case - existing code uses camelCase +ktlint_standard_enum-entry-name-case = disabled +# Disable class naming - sealed class entries use camelCase +ktlint_standard_class-naming = disabled +# Disable function signature - stylistic preference +ktlint_standard_function-signature = disabled +# Set max line length higher +max_line_length = 250 +# Disable function expression body - stylistic preference +ktlint_standard_function-expression-body = disabled +# Disable package-name rule - 'public' package matches iOS SDK structure and requires escaping +ktlint_standard_package-name = disabled diff --git a/sdk/runanywhere-kotlin/.gitignore b/sdk/runanywhere-kotlin/.gitignore new file mode 100644 index 000000000..1b87b6190 --- /dev/null +++ b/sdk/runanywhere-kotlin/.gitignore @@ -0,0 +1,48 @@ +# ============================================================================= +# RunAnywhere Kotlin SDK - Git Ignore +# ============================================================================= + +# Build outputs +build/ +.gradle/ +*.aar + +# IDE +.idea/ +*.iml +.DS_Store + +# Kotlin +*.class + +# ============================================================================= +# JNI Libraries (should NOT be committed) +# ============================================================================= +# Local builds: ./scripts/build-local.sh copies libs here +src/androidMain/jniLibs/ + +# Remote downloads: ./gradlew downloadJniLibs downloads libs here +build/jniLibs/ + +# ============================================================================= +# How JNI libs work: +# ============================================================================= +# testLocal=true → Build locally: ./scripts/build-local.sh +# Libs go to: src/androidMain/jniLibs/ +# +# testLocal=false → Download from GitHub releases (default) +# Libs go to: build/jniLibs/ +# Source: https://github.com/RunanywhereAI/runanywhere-binaries +# ============================================================================= + +# Temporary files +*.tmp +*.temp +*.log + +# Local environment +local.properties + +# Secrets (NEVER commit real credentials) +secrets.properties +*.secrets.properties diff --git a/sdk/runanywhere-kotlin/README.md b/sdk/runanywhere-kotlin/README.md new file mode 100644 index 000000000..04bbd7e93 --- /dev/null +++ b/sdk/runanywhere-kotlin/README.md @@ -0,0 +1,573 @@ +# RunAnywhere Kotlin SDK + +**Privacy-first, on-device AI for Android & JVM**. Run LLMs, speech-to-text, text-to-speech, and voice agents locally with cloud fallback, OTA updates, and production observability. + +[![Maven Central](https://img.shields.io/maven-central/v/com.runanywhere.sdk/runanywhere-kotlin?label=Maven%20Central)](https://search.maven.org/artifact/com.runanywhere.sdk/runanywhere-kotlin) +[![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) +[![Platform: Android 7.0+](https://img.shields.io/badge/Platform-Android%207.0%2B-green)](https://developer.android.com) +[![Kotlin](https://img.shields.io/badge/Kotlin-2.0%2B-blue?logo=kotlin)](https://kotlinlang.org) +[![KMP](https://img.shields.io/badge/Kotlin%20Multiplatform-Supported-purple)](https://kotlinlang.org/docs/multiplatform.html) + +--- + +## Key Features + +- **Works Offline & Instantly** – Models run locally on-device; zero network latency for inference. +- **Hybrid by Design** – Automatic fallback to cloud based on device memory, thermal status, or your custom policies. +- **Privacy First** – User data stays on-device. HIPAA/GDPR friendly. +- **One API, All Platforms** – Single SDK API across iOS, Android, React Native, Flutter. +- **OTA Model Updates** – Deploy new models without app releases via the RunAnywhere console. +- **Production Observability** – Built-in analytics: latency, token throughput, device state, and more. +- **Complete Voice AI Stack** – LLM, STT, TTS, and VAD unified under one SDK. + +--- + +## Quick Start + +### 1. Add Dependencies + +**build.gradle.kts (Module: app)** + +```kotlin +dependencies { + // Core SDK + implementation("com.runanywhere.sdk:runanywhere-kotlin:0.1.4") + + // Optional: LLM support (llama.cpp backend) - ~34MB + implementation("com.runanywhere.sdk:runanywhere-core-llamacpp:0.1.4") + + // Optional: STT/TTS/VAD support (ONNX backend) - ~25MB + implementation("com.runanywhere.sdk:runanywhere-core-onnx:0.1.4") +} +``` + +### 2. Initialize SDK (Application.onCreate) + +```kotlin +import com.runanywhere.sdk.public.RunAnywhere +import com.runanywhere.sdk.public.SDKEnvironment + +class MyApplication : Application() { + override fun onCreate() { + super.onCreate() + + // Initialize RunAnywhere (fast, ~1-5ms) + RunAnywhere.initialize( + apiKey = "your-api-key", // Optional for development + environment = SDKEnvironment.DEVELOPMENT + ) + } +} +``` + +### 3. Register & Download a Model + +```kotlin +import com.runanywhere.sdk.public.RunAnywhere +import com.runanywhere.sdk.public.extensions.* +import com.runanywhere.sdk.core.types.InferenceFramework + +// Register a model from HuggingFace +val modelInfo = RunAnywhere.registerModel( + name = "Qwen 0.5B", + url = "https://huggingface.co/Qwen/Qwen2.5-0.5B-Instruct-GGUF/resolve/main/qwen2.5-0.5b-instruct-q8_0.gguf", + framework = InferenceFramework.LLAMA_CPP +) + +// Download the model (observe progress) +RunAnywhere.downloadModel(modelInfo.id) + .collect { progress -> + println("Download: ${(progress.progress * 100).toInt()}%") + } +``` + +### 4. Run Inference + +```kotlin +// Load the model +RunAnywhere.loadLLMModel(modelInfo.id) + +// Simple chat +val response = RunAnywhere.chat("What is machine learning?") +println(response) + +// Or with full metrics +val result = RunAnywhere.generate( + prompt = "Explain quantum computing", + options = LLMGenerationOptions( + maxTokens = 150, + temperature = 0.7f + ) +) +println("Response: ${result.text}") +println("Tokens/sec: ${result.tokensPerSecond}") +println("Latency: ${result.latencyMs}ms") +``` + +### 5. Streaming Generation + +```kotlin +// Stream tokens as they're generated +RunAnywhere.generateStream("Tell me a story about AI") + .collect { token -> + print(token) // Display in real-time + } + +// With metrics +val streamResult = RunAnywhere.generateStreamWithMetrics("Write a poem") +streamResult.stream.collect { token -> print(token) } +val metrics = streamResult.result.await() +println("\nSpeed: ${metrics.tokensPerSecond} tok/s") +``` + +--- + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Your Android App │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ RunAnywhere Kotlin SDK (Public API) │ │ +│ │ │ │ +│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ +│ │ │ LLM │ │ STT │ │ TTS │ │ VAD │ │ │ +│ │ │ generate │ │transcribe│ │synthesize│ │ detect │ │ │ +│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │ +│ │ │ │ +│ │ ┌──────────────────────────────────────────────────────┐ │ │ +│ │ │ VoiceAgent (Orchestration) │ │ │ +│ │ │ VAD → STT → LLM → TTS Pipeline │ │ │ +│ │ └──────────────────────────────────────────────────────┘ │ │ +│ └───────────────────────────────────────────────────────────┘ │ +│ ↓ │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ runanywhere-commons (C++ Native Layer) │ │ +│ │ JNI bridge to shared AI inference infrastructure │ │ +│ └───────────────────────────────────────────────────────────┘ │ +│ ↓ │ +│ ┌─────────────────────┐ ┌─────────────────────────────────┐ │ +│ │ runanywhere-core- │ │ runanywhere-core-onnx │ │ +│ │ llamacpp │ │ │ │ +│ │ ┌───────────────┐ │ │ ┌───────────┐ ┌─────────────┐ │ │ +│ │ │ llama.cpp │ │ │ │ONNX Runtime│ │Sherpa-ONNX │ │ │ +│ │ │ LLM Inference │ │ │ └───────────┘ │(STT/TTS/VAD)│ │ │ +│ │ └───────────────┘ │ │ └─────────────┘ │ │ +│ └─────────────────────┘ └─────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**Key Components:** +- **Public API** - Kotlin extension functions for LLM, STT, TTS, VAD, VoiceAgent +- **runanywhere-commons** - Shared C++ infrastructure (JNI bridging) +- **runanywhere-core-llamacpp** - llama.cpp backend for LLM inference (~34MB) +- **runanywhere-core-onnx** - ONNX Runtime + Sherpa-ONNX for STT/TTS/VAD (~25MB) + +--- + +## Features + +### Text Generation (LLM) + +```kotlin +// Simple chat +val answer = RunAnywhere.chat("What is 2+2?") + +// Generation with options +val result = RunAnywhere.generate( + prompt = "Write a haiku about code", + options = LLMGenerationOptions( + maxTokens = 50, + temperature = 0.9f, + systemPrompt = "You are a creative poet" + ) +) + +// Streaming +RunAnywhere.generateStream("Tell me a joke") + .collect { token -> print(token) } + +// Cancel ongoing generation +RunAnywhere.cancelGeneration() +``` + +### Speech-to-Text (STT) + +```kotlin +// Load an STT model +RunAnywhere.loadSTTModel("whisper-tiny") + +// Transcribe audio +val text = RunAnywhere.transcribe(audioData) + +// With options +val output = RunAnywhere.transcribeWithOptions( + audioData = audioBytes, + options = STTOptions( + language = "en", + enableTimestamps = true, + enablePunctuation = true + ) +) +println("Text: ${output.text}") +println("Confidence: ${output.confidence}") + +// Streaming transcription +RunAnywhere.transcribeStream(audioData) { partial -> + println("Partial: ${partial.transcript}") +} +``` + +### Text-to-Speech (TTS) + +```kotlin +// Load a TTS voice +RunAnywhere.loadTTSVoice("en-us-default") + +// Simple speak (plays audio automatically) +RunAnywhere.speak("Hello, world!") + +// Synthesize to bytes +val output = RunAnywhere.synthesize( + text = "Welcome to RunAnywhere", + options = TTSOptions( + rate = 1.0f, + pitch = 1.0f + ) +) + +// Stream synthesis for long text +RunAnywhere.synthesizeStream(longText) { chunk -> + audioPlayer.play(chunk) +} +``` + +### Voice Activity Detection (VAD) + +```kotlin +// Detect speech in audio +val result = RunAnywhere.detectVoiceActivity(audioData) +println("Speech detected: ${result.hasSpeech}") +println("Confidence: ${result.confidence}") + +// Configure VAD +RunAnywhere.configureVAD(VADConfiguration( + threshold = 0.5f, + minSpeechDurationMs = 250, + minSilenceDurationMs = 300 +)) + +// Stream VAD +RunAnywhere.streamVAD(audioSamplesFlow) + .collect { result -> + if (result.hasSpeech) println("Speaking...") + } +``` + +### Voice Agent (Full Pipeline) + +```kotlin +// Configure voice agent +RunAnywhere.configureVoiceAgent(VoiceAgentConfiguration( + sttModelId = "whisper-tiny", + llmModelId = "qwen-0.5b", + ttsVoiceId = "en-us-default" +)) + +// Start voice session +RunAnywhere.startVoiceSession() + .collect { event -> + when (event) { + is VoiceSessionEvent.Listening -> println("Listening...") + is VoiceSessionEvent.Transcribed -> println("You: ${event.text}") + is VoiceSessionEvent.Thinking -> println("Thinking...") + is VoiceSessionEvent.Responded -> println("AI: ${event.text}") + is VoiceSessionEvent.Speaking -> println("Speaking...") + is VoiceSessionEvent.Error -> println("Error: ${event.message}") + } + } + +// Stop session +RunAnywhere.stopVoiceSession() +``` + +### Model Management + +```kotlin +// List available models +val models = RunAnywhere.availableModels() + +// Filter by category +val llmModels = RunAnywhere.models(ModelCategory.LANGUAGE) +val sttModels = RunAnywhere.models(ModelCategory.SPEECH_RECOGNITION) +val ttsModels = RunAnywhere.models(ModelCategory.SPEECH_SYNTHESIS) + +// Check download status +val isDownloaded = RunAnywhere.isModelDownloaded(modelId) + +// Delete model +RunAnywhere.deleteModel(modelId) + +// Refresh model registry +RunAnywhere.refreshModelRegistry() +``` + +### Event System + +```kotlin +// Subscribe to LLM events +RunAnywhere.events.llmEvents.collect { event -> + when (event) { + is LLMEvent -> { + println("LLM Event: ${event.type}") + println("Latency: ${event.latencyMs}ms") + } + } +} + +// Subscribe to model events +RunAnywhere.events.modelEvents.collect { event -> + when (event) { + is ModelEvent -> { + println("Model ${event.modelId}: ${event.eventType}") + } + } +} +``` + +--- + +## Supported Model Formats + +| Format | Extension | Backend | Use Case | +|--------|-----------|---------|----------| +| GGUF | `.gguf` | llama.cpp | LLM text generation | +| ONNX | `.onnx` | ONNX Runtime | STT, TTS, VAD | +| ORT | `.ort` | ONNX Runtime | Optimized STT/TTS | + +--- + +## Requirements + +- **Android**: API 24+ (Android 7.0+) +- **JVM**: Java 17+ +- **Kotlin**: 2.0+ + +--- + +## Troubleshooting + +### Q: Model loads but inference is slow. How do I debug? + +**A:** Check the generation result metrics: +```kotlin +val result = RunAnywhere.generate(prompt = "...") +println("Latency: ${result.latencyMs}ms") +println("Tokens/sec: ${result.tokensPerSecond}") +println("Model: ${result.modelUsed}") +``` + +If latency is high: +- Try a smaller quantized model (q4_0 vs q8_0) +- Check device thermal state +- Ensure sufficient RAM (model size × 1.5) + +### Q: App crashes when loading large models + +**A:** Check available memory before loading: +```kotlin +// Register smaller quantized model variant +val smallModel = RunAnywhere.registerModel( + name = "Qwen 0.5B Q4", + url = "...qwen2.5-0.5b-instruct-q4_0.gguf", + framework = InferenceFramework.LLAMA_CPP +) +``` + +### Q: Models don't download. What's wrong? + +**A:** Ensure you have: +1. Internet permission in AndroidManifest.xml: + ```xml + + ``` +2. SDK initialized before download calls +3. Valid download URL (test in browser first) + +### Q: How do I know which model is loaded? + +**A:** +```kotlin +val llmModelId = RunAnywhere.currentLLMModelId +val sttModelId = RunAnywhere.currentSTTModelId +val ttsVoiceId = RunAnywhere.currentTTSVoiceId + +println("LLM: ${llmModelId ?: "None"}") +println("STT: ${sttModelId ?: "None"}") +println("TTS: ${ttsVoiceId ?: "None"}") +``` + +--- + +## Sample Code + +See the [examples/android/RunAnywhereAI](../../examples/android/RunAnywhereAI) directory for a complete sample app demonstrating: +- LLM chat with streaming +- Voice transcription +- Text-to-speech +- Full voice agent pipeline +- Model management UI + +--- + +## Local Development & Contributing + +This section explains how to set up your development environment to build the SDK from source and test your changes with the sample app. + +### Prerequisites + +- **Android Studio** (latest stable) +- **Android NDK** (v27+ recommended, installed via Android Studio SDK Manager) +- **CMake** (installed via Android Studio SDK Manager) +- **Bash** (macOS/Linux terminal) + +### First-Time Setup (Build from Source) + +The SDK depends on native C++ libraries from `runanywhere-commons`. The setup script builds these locally so you can develop and test the SDK end-to-end. + +```bash +# 1. Clone the repository +git clone https://github.com/RunanywhereAI/runanywhere-sdks.git +cd runanywhere-sdks/sdk/runanywhere-kotlin + +# 2. Run first-time setup (~10-15 minutes) +./scripts/build-kotlin.sh --setup +``` + +**What the setup script does:** +1. Downloads dependencies (Sherpa-ONNX, ~500MB) +2. Builds `runanywhere-commons` for Android (arm64-v8a by default) +3. Copies JNI libraries (`.so` files) to module `jniLibs/` directories +4. Sets `runanywhere.testLocal=true` in `gradle.properties` + +### Understanding testLocal + +The SDK has two modes controlled by `runanywhere.testLocal` in `gradle.properties`: + +| Mode | Setting | Description | +|------|---------|-------------| +| **Local** | `runanywhere.testLocal=true` | Uses JNI libs from `src/androidMain/jniLibs/` (for development) | +| **Remote** | `runanywhere.testLocal=false` | Downloads JNI libs from GitHub releases (for end users) | + +When you run `--setup`, the script automatically sets `testLocal=true`. + +### Testing with the Android Sample App + +The recommended way to test SDK changes is with the sample app: + +```bash +# 1. Ensure SDK is set up (from previous step) + +# 2. Open Android Studio +# 3. Select Open → Navigate to examples/android/RunAnywhereAI +# 4. Wait for Gradle sync to complete +# 5. Connect an Android device (ARM64 recommended) or emulator +# 6. Click Run +``` + +The sample app's `settings.gradle.kts` references the local SDK via `includeBuild()`, which in turn uses the local JNI libraries. This creates a complete local development loop: + +``` +Sample App → Local Kotlin SDK → Local JNI Libraries (jniLibs/) + ↑ + Built by build-kotlin.sh --setup +``` + +### Development Workflow + +**After modifying Kotlin SDK code:** +- Rebuild in Android Studio or run `./gradlew assembleDebug` + +**After modifying runanywhere-commons (C++ code):** + +```bash +cd sdk/runanywhere-kotlin +./scripts/build-kotlin.sh --local --rebuild-commons +``` + +### Build Script Reference + +| Command | Description | +|---------|-------------| +| `--setup` | First-time setup: downloads deps, builds all libs, sets `testLocal=true` | +| `--local` | Use locally built libs from `jniLibs/` | +| `--remote` | Use remote libs from GitHub releases | +| `--rebuild-commons` | Force rebuild of runanywhere-commons | +| `--clean` | Clean build directories before building | +| `--abis=ABIS` | ABIs to build (default: `arm64-v8a`, use `arm64-v8a,armeabi-v7a` for 97% device coverage) | +| `--skip-build` | Skip Gradle build (only setup native libs) | + +### Project Structure + +``` +sdk/runanywhere-kotlin/ +├── src/ +│ ├── commonMain/ # Cross-platform Kotlin code +│ ├── jvmAndroidMain/ # Shared JVM/Android (JNI bridges) +│ ├── androidMain/ # Android-specific (jniLibs, platform code) +│ └── jvmMain/ # Desktop JVM support +├── modules/ +│ ├── runanywhere-core-llamacpp/ # LLM backend module +│ └── runanywhere-core-onnx/ # STT/TTS/VAD backend module +├── scripts/ +│ └── build-kotlin.sh # Build automation script +└── gradle.properties # testLocal flag controls local vs remote libs +``` + +### Code Quality + +Run linting before submitting PRs: + +```bash +# Run detekt (static analysis) +./gradlew detekt + +# Run ktlint (code formatting) +./gradlew ktlintCheck + +# Auto-fix formatting issues +./gradlew ktlintFormat +``` + +### Testing the SDK + +1. **Unit Tests:** `./gradlew jvmTest` +2. **Android Tests:** Open sample app in Android Studio → Run instrumented tests +3. **Manual Testing:** Use the sample app to test all SDK features on a real device + +### Submitting Changes + +1. Fork the repository +2. Create a feature branch: `git checkout -b feature/my-feature` +3. Make your changes +4. Run linting: `./gradlew detekt ktlintCheck` +5. Test with the sample app +6. Commit: `git commit -m 'Add my feature'` +7. Push: `git push origin feature/my-feature` +8. Open a Pull Request + +--- + +## License + +Apache 2.0. See [LICENSE](../../LICENSE). + +--- + +## Links + +- [Architecture Documentation](./ARCHITECTURE.md) +- [API Documentation](./Documentation.md) +- [Sample App](../../examples/android/RunAnywhereAI) diff --git a/sdk/runanywhere-kotlin/build.gradle.kts b/sdk/runanywhere-kotlin/build.gradle.kts new file mode 100644 index 000000000..02e1d556a --- /dev/null +++ b/sdk/runanywhere-kotlin/build.gradle.kts @@ -0,0 +1,805 @@ +// Clean Gradle script for KMP SDK + +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.detekt) + alias(libs.plugins.ktlint) + id("maven-publish") + signing +} + +// ============================================================================= +// Detekt Configuration +// ============================================================================= +detekt { + buildUponDefaultConfig = true + allRules = false + config.setFrom(files("detekt.yml")) + source.setFrom( + "src/commonMain/kotlin", + "src/jvmMain/kotlin", + "src/jvmAndroidMain/kotlin", + "src/androidMain/kotlin", + ) +} + +// ============================================================================= +// ktlint Configuration +// ============================================================================= +ktlint { + version.set("1.5.0") + android.set(true) + verbose.set(true) + outputToConsole.set(true) + enableExperimentalRules.set(false) + filter { + exclude("**/generated/**") + include("**/kotlin/**") + } +} + +// Maven Central group ID - must match verified Sonatype namespace +// Using io.github.sanchitmonga22 (verified) until com.runanywhere is verified +// Once com.runanywhere is verified, change to: "com.runanywhere" +val isJitPack = System.getenv("JITPACK") == "true" +val usePendingNamespace = System.getenv("USE_RUNANYWHERE_NAMESPACE")?.toBoolean() ?: false +group = when { + isJitPack -> "com.github.RunanywhereAI.runanywhere-sdks" + usePendingNamespace -> "com.runanywhere" // Use after DNS verification completes + else -> "io.github.sanchitmonga22" // Currently verified namespace +} + +// Version resolution priority: +// 1. SDK_VERSION env var (set by our CI/CD from git tag) +// 2. VERSION env var (set by JitPack from git tag) +// 3. Default fallback for local development +val resolvedVersion = System.getenv("SDK_VERSION")?.removePrefix("v") + ?: System.getenv("VERSION")?.removePrefix("v") + ?: "0.1.5-SNAPSHOT" +version = resolvedVersion + +// Log version for debugging +logger.lifecycle("RunAnywhere SDK version: $resolvedVersion (JitPack=$isJitPack)") + +// ============================================================================= +// Local vs Remote JNI Library Configuration +// ============================================================================= +// testLocal = true → Use locally built JNI libs from src/androidMain/jniLibs/ +// Run: ./scripts/build-kotlin.sh --setup for first-time setup +// +// testLocal = false → Download pre-built JNI libs from GitHub releases (default) +// Downloads from: https://github.com/RunanywhereAI/runanywhere-sdks/releases +// +// rebuildCommons = true → Force rebuild of runanywhere-commons C++ code +// Use when you've made changes to C++ source +// +// Mirrors Swift SDK's Package.swift testLocal pattern +// ============================================================================= +// IMPORTANT: Check rootProject first to support composite builds (e.g., when SDK is included from example app) +// This ensures the app's gradle.properties takes precedence over the SDK's default +val testLocal: Boolean = + rootProject.findProperty("runanywhere.testLocal")?.toString()?.toBoolean() + ?: project.findProperty("runanywhere.testLocal")?.toString()?.toBoolean() + ?: false + +// Force rebuild of runanywhere-commons when true +val rebuildCommons: Boolean = + rootProject.findProperty("runanywhere.rebuildCommons")?.toString()?.toBoolean() + ?: project.findProperty("runanywhere.rebuildCommons")?.toString()?.toBoolean() + ?: false + +// ============================================================================= +// Native Library Version for Downloads +// ============================================================================= +// When testLocal=false, native libraries are downloaded from GitHub unified releases. +// The native lib version should match the SDK version for consistency. +// Format: https://github.com/RunanywhereAI/runanywhere-sdks/releases/tag/v{version} +// +// Assets per ABI: +// - RACommons-android-{abi}-v{version}.zip +// - RABackendLLAMACPP-android-{abi}-v{version}.zip +// - RABackendONNX-android-{abi}-v{version}.zip +// ============================================================================= +val nativeLibVersion: String = + rootProject.findProperty("runanywhere.nativeLibVersion")?.toString() + ?: project.findProperty("runanywhere.nativeLibVersion")?.toString() + ?: resolvedVersion // Default to SDK version + +// Log the build mode +logger.lifecycle("RunAnywhere SDK: testLocal=$testLocal, nativeLibVersion=$nativeLibVersion") + +// ============================================================================= +// Project Path Resolution +// ============================================================================= +// When included as a subproject in composite builds (e.g., from example app or Android Studio), +// the module path changes. This function constructs the full absolute path for sibling modules +// based on the current project's location in the hierarchy. +// +// Examples: +// - When SDK is root project: path = ":" → module path = ":modules:$moduleName" +// - When SDK is at ":sdk:runanywhere-kotlin": path → ":sdk:runanywhere-kotlin:modules:$moduleName" +fun resolveModulePath(moduleName: String): String { + val basePath = project.path + val computedPath = + if (basePath == ":") { + ":modules:$moduleName" + } else { + "$basePath:modules:$moduleName" + } + + // Try to find the project using rootProject to handle Android Studio sync ordering + val foundProject = rootProject.findProject(computedPath) + if (foundProject != null) { + return computedPath + } + + // Fallback: Try just :modules:$moduleName (when SDK is at non-root but modules are siblings) + val simplePath = ":modules:$moduleName" + if (rootProject.findProject(simplePath) != null) { + return simplePath + } + + // Return computed path (will fail with clear error if not found) + return computedPath +} + +kotlin { + // Use Java 17 toolchain across targets + jvmToolchain(17) + + // JVM target for IntelliJ plugins and general JVM usage + jvm { + compilations.all { + compilerOptions.configure { + freeCompilerArgs.add("-Xsuppress-version-warnings") + } + } + testRuns["test"].executionTask.configure { + useJUnitPlatform() + } + } + + // Android target + androidTarget { + // Enable publishing Android AAR to Maven + publishLibraryVariants("release") + + // Set correct artifact ID for Android publication + mavenPublication { + artifactId = "runanywhere-sdk-android" + } + + compilations.all { + compilerOptions.configure { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + freeCompilerArgs.add("-Xsuppress-version-warnings") + freeCompilerArgs.add("-Xno-param-assertions") + } + } + } + + // Native targets (temporarily disabled) + // linuxX64() + // macosX64() + // macosArm64() + // mingwX64() + + sourceSets { + // Common source set + commonMain { + dependencies { + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.datetime) + + // Ktor for networking + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.client.logging) + implementation(libs.ktor.serialization.kotlinx.json) + + // Okio for file system operations (replaces Files library from iOS) + implementation(libs.okio) + } + } + + commonTest { + dependencies { + implementation(kotlin("test")) + implementation(libs.kotlinx.coroutines.test) + // Okio FakeFileSystem for testing + implementation(libs.okio.fakefilesystem) + } + } + + // JVM + Android shared + val jvmAndroidMain by creating { + dependsOn(commonMain.get()) + dependencies { + implementation(libs.whisper.jni) + implementation(libs.okhttp) + implementation(libs.okhttp.logging) + implementation(libs.gson) + implementation(libs.commons.io) + implementation(libs.commons.compress) + implementation(libs.ktor.client.okhttp) + // Error tracking - Sentry (matches iOS SDK SentryDestination) + implementation(libs.sentry) + } + } + + jvmMain { + dependsOn(jvmAndroidMain) + } + + jvmTest { + dependencies { + implementation(libs.junit) + implementation(libs.mockk) + } + } + + androidMain { + dependsOn(jvmAndroidMain) + dependencies { + // Native libs (.so files) are included directly in jniLibs/ + // Built from runanywhere-commons/scripts/build-android.sh + + implementation(libs.androidx.core.ktx) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.android.vad.webrtc) + implementation(libs.prdownloader) + implementation(libs.androidx.work.runtime.ktx) + implementation(libs.androidx.security.crypto) + implementation(libs.retrofit) + implementation(libs.retrofit.gson) + } + } + + androidUnitTest { + dependencies { + implementation(libs.junit) + implementation(libs.mockk) + } + } + } +} + +android { + namespace = "com.runanywhere.sdk.kotlin" + compileSdk = 35 + + defaultConfig { + minSdk = 24 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + // ========================================================================== + // JNI Libraries Configuration - ALL LIBS (Commons + Backends) + // ========================================================================== + // This SDK downloads and bundles all JNI libraries: + // + // From RACommons (commons-v{commonsVersion}): + // - librac_commons.so - RAC Commons infrastructure + // - librac_commons_jni.so - RAC Commons JNI bridge + // - libc++_shared.so - C++ STL (shared by all backends) + // + // From RABackendLlamaCPP (core-v{coreVersion}): + // - librac_backend_llamacpp_jni.so - LlamaCPP JNI bridge + // - librunanywhere_llamacpp.so - LlamaCPP backend + // - libllama.so, libcommon.so - llama.cpp core + // + // From RABackendONNX (core-v{coreVersion}): + // - librac_backend_onnx_jni.so - ONNX JNI bridge + // - librunanywhere_onnx.so - ONNX backend + // - libonnxruntime.so - ONNX Runtime + // - libsherpa-onnx-*.so - Sherpa ONNX (STT/TTS/VAD) + // ========================================================================== + // JNI libs are placed in src/androidMain/jniLibs/ (standard KMP location) + // This is automatically included by the KMP Android plugin + // ========================================================================== + + // Prevent packaging duplicates + packaging { + jniLibs { + // Pick first if duplicates somehow still occur + pickFirsts.add("**/*.so") + } + } +} + +// ============================================================================= +// Local JNI Build Task (for testLocal=true mode) +// ============================================================================= +// Smart build task that: +// - Skips rebuild if JNI libs exist and C++ source hasn't changed +// - Forces rebuild if rebuildCommons=true +// - Uses build-kotlin.sh --setup for first-time setup +// - Uses build-local.sh for subsequent builds +// +// Usage: +// ./gradlew buildLocalJniLibs # Build if needed +// ./gradlew buildLocalJniLibs -Prunanywhere.rebuildCommons=true # Force rebuild +// ============================================================================= +tasks.register("buildLocalJniLibs") { + group = "runanywhere" + description = "Build JNI libraries locally from runanywhere-commons (when testLocal=true)" + + val jniLibsDir = file("src/androidMain/jniLibs") + val llamaCppJniLibsDir = file("modules/runanywhere-core-llamacpp/src/androidMain/jniLibs") + val onnxJniLibsDir = file("modules/runanywhere-core-onnx/src/androidMain/jniLibs") + val buildMarker = file(".commons-build-marker") + val buildKotlinScript = file("scripts/build-kotlin.sh") + val buildLocalScript = file("scripts/build-local.sh") + + // Only enable this task when testLocal=true + onlyIf { testLocal } + + workingDir = projectDir + + // Set environment + environment( + "ANDROID_NDK_HOME", + System.getenv("ANDROID_NDK_HOME") ?: "${System.getProperty("user.home")}/Library/Android/sdk/ndk/27.0.12077973", + ) + + doFirst { + logger.lifecycle("") + logger.lifecycle("═══════════════════════════════════════════════════════════════") + logger.lifecycle(" RunAnywhere JNI Libraries (testLocal=true)") + logger.lifecycle("═══════════════════════════════════════════════════════════════") + logger.lifecycle("") + + // Check if we have existing libs + val hasMainLibs = jniLibsDir.resolve("arm64-v8a/libc++_shared.so").exists() + val hasLlamaCppLibs = llamaCppJniLibsDir.resolve("arm64-v8a/librac_backend_llamacpp_jni.so").exists() + val hasOnnxLibs = onnxJniLibsDir.resolve("arm64-v8a/librac_backend_onnx_jni.so").exists() + val allLibsExist = hasMainLibs && hasLlamaCppLibs && hasOnnxLibs + + if (allLibsExist && !rebuildCommons) { + logger.lifecycle("✅ JNI libraries already exist - skipping build") + logger.lifecycle(" (use -Prunanywhere.rebuildCommons=true to force rebuild)") + logger.lifecycle("") + // Skip the exec by setting a dummy command + commandLine("echo", "JNI libs up to date") + } else if (!allLibsExist) { + // First time setup - use build-kotlin.sh --setup + logger.lifecycle("🆕 First-time setup: Running build-kotlin.sh --setup") + logger.lifecycle(" This will download dependencies and build everything...") + logger.lifecycle("") + commandLine("bash", buildKotlinScript.absolutePath, "--setup", "--skip-build") + } else if (rebuildCommons) { + // Force rebuild - use build-kotlin.sh with --rebuild-commons + logger.lifecycle("🔄 Rebuild requested: Running build-kotlin.sh --rebuild-commons") + logger.lifecycle("") + commandLine("bash", buildKotlinScript.absolutePath, "--local", "--rebuild-commons", "--skip-build") + } + } + + doLast { + // Verify the build succeeded for all modules + fun countLibs(dir: java.io.File, moduleName: String): Int { + if (!dir.exists()) return 0 + val soFiles = dir.walkTopDown().filter { it.extension == "so" }.toList() + if (soFiles.isNotEmpty()) { + logger.lifecycle("") + logger.lifecycle("✓ $moduleName: ${soFiles.size} .so files") + soFiles.groupBy { it.parentFile.name }.forEach { (abi, files) -> + logger.lifecycle(" $abi: ${files.map { it.name }.joinToString(", ")}") + } + } + return soFiles.size + } + + val mainCount = countLibs(jniLibsDir, "Main SDK (Commons)") + val llamaCppCount = countLibs(llamaCppJniLibsDir, "LlamaCPP Module") + val onnxCount = countLibs(onnxJniLibsDir, "ONNX Module") + + if (mainCount == 0 && testLocal) { + throw GradleException( + """ + Local JNI build failed: No .so files found in $jniLibsDir + + Run first-time setup: + ./scripts/build-kotlin.sh --setup + + Or download from releases: + ./gradlew -Prunanywhere.testLocal=false assembleDebug + """.trimIndent() + ) + } + + if (mainCount > 0) { + logger.lifecycle("") + logger.lifecycle("═══════════════════════════════════════════════════════════════") + logger.lifecycle(" Total: ${mainCount + llamaCppCount + onnxCount} native libraries") + logger.lifecycle("═══════════════════════════════════════════════════════════════") + } + } +} + +// ============================================================================= +// Setup Task - First-time local development setup +// ============================================================================= +// Convenience task for first-time setup. Equivalent to: +// ./scripts/build-kotlin.sh --setup +// ============================================================================= +tasks.register("setupLocalDevelopment") { + group = "runanywhere" + description = "First-time setup: download dependencies, build commons, copy JNI libs" + + workingDir = projectDir + commandLine("bash", "scripts/build-kotlin.sh", "--setup", "--skip-build") + + environment( + "ANDROID_NDK_HOME", + System.getenv("ANDROID_NDK_HOME") ?: "${System.getProperty("user.home")}/Library/Android/sdk/ndk/27.0.12077973", + ) + + doFirst { + logger.lifecycle("") + logger.lifecycle("═══════════════════════════════════════════════════════════════") + logger.lifecycle(" RunAnywhere SDK - First-Time Local Development Setup") + logger.lifecycle("═══════════════════════════════════════════════════════════════") + logger.lifecycle("") + logger.lifecycle("This will:") + logger.lifecycle(" 1. Download dependencies (Sherpa-ONNX, etc.)") + logger.lifecycle(" 2. Build runanywhere-commons for Android") + logger.lifecycle(" 3. Copy JNI libraries to module directories") + logger.lifecycle(" 4. Set testLocal=true in gradle.properties") + logger.lifecycle("") + logger.lifecycle("This may take 10-15 minutes on first run...") + logger.lifecycle("") + } + + doLast { + logger.lifecycle("") + logger.lifecycle("✅ Setup complete! You can now build with:") + logger.lifecycle(" ./gradlew assembleDebug") + logger.lifecycle("") + } +} + +// ============================================================================= +// Rebuild Commons Task - For when C++ code changes +// ============================================================================= +tasks.register("rebuildCommons") { + group = "runanywhere" + description = "Rebuild runanywhere-commons C++ code (use after making C++ changes)" + + workingDir = projectDir + commandLine("bash", "scripts/build-kotlin.sh", "--local", "--rebuild-commons", "--skip-build") + + environment( + "ANDROID_NDK_HOME", + System.getenv("ANDROID_NDK_HOME") ?: "${System.getProperty("user.home")}/Library/Android/sdk/ndk/27.0.12077973", + ) + + doFirst { + logger.lifecycle("") + logger.lifecycle("═══════════════════════════════════════════════════════════════") + logger.lifecycle(" Rebuilding runanywhere-commons C++ code") + logger.lifecycle("═══════════════════════════════════════════════════════════════") + logger.lifecycle("") + } +} + +// ============================================================================= +// JNI Library Download Task (for testLocal=false mode) +// ============================================================================= +// Downloads ALL JNI libraries from GitHub releases: +// - Commons: https://github.com/RunanywhereAI/runanywhere-sdks/releases/tag/commons-v{version} +// - librac_commons.so - RAC Commons infrastructure +// - librac_commons_jni.so - RAC Commons JNI bridge +// - Core backends: https://github.com/RunanywhereAI/runanywhere-sdks/releases/tag/core-v{version} +// - librac_backend_llamacpp_jni.so - LLM inference (llama.cpp) +// - librac_backend_onnx_jni.so - STT/TTS/VAD (Sherpa ONNX) +// - libonnxruntime.so - ONNX Runtime +// - libsherpa-onnx-*.so - Sherpa ONNX components +// - libc++_shared.so - C++ STL (shared) +// ============================================================================= +tasks.register("downloadJniLibs") { + group = "runanywhere" + description = "Download JNI libraries from GitHub releases (when testLocal=false)" + + // Only run when NOT using local libs + onlyIf { !testLocal } + + // Use standard KMP location for jniLibs + val outputDir = file("src/androidMain/jniLibs") + val tempDir = file("${layout.buildDirectory.get()}/jni-temp") + + // GitHub unified release URL - all assets are in one release + // Format: https://github.com/RunanywhereAI/runanywhere-sdks/releases/download/v{version}/{asset} + val releaseBaseUrl = "https://github.com/RunanywhereAI/runanywhere-sdks/releases/download/v$nativeLibVersion" + + // ABIs to download - arm64-v8a covers ~85% of devices + // Add more ABIs here if needed: "armeabi-v7a", "x86_64" + val targetAbis = listOf("arm64-v8a", "armeabi-v7a", "x86_64") + + // Package types to download for each ABI + val packageTypes = listOf( + "RACommons-android", // Core infrastructure + JNI bridge + "RABackendLLAMACPP-android", // LLM inference (llama.cpp) + "RABackendONNX-android" // STT/TTS/VAD (Sherpa ONNX) + ) + + outputs.dir(outputDir) + + doLast { + if (testLocal) { + logger.lifecycle("Skipping JNI download: testLocal=true (using local libs)") + return@doLast + } + + // Check if libs already exist (CI pre-populates build/jniLibs/) + val existingLibs = outputDir.walkTopDown().filter { it.extension == "so" }.count() + if (existingLibs > 0) { + logger.lifecycle("Skipping JNI download: $existingLibs .so files already in $outputDir (CI mode)") + return@doLast + } + + // Clean output directories (only if empty) + outputDir.deleteRecursively() + tempDir.deleteRecursively() + outputDir.mkdirs() + tempDir.mkdirs() + + logger.lifecycle("") + logger.lifecycle("═══════════════════════════════════════════════════════════════") + logger.lifecycle(" Downloading JNI libraries (testLocal=false)") + logger.lifecycle("═══════════════════════════════════════════════════════════════") + logger.lifecycle("") + logger.lifecycle("Native lib version: v$nativeLibVersion") + logger.lifecycle("Target ABIs: ${targetAbis.joinToString(", ")}") + logger.lifecycle("") + + var totalDownloaded = 0 + + targetAbis.forEach { abi -> + val abiOutputDir = file("$outputDir/$abi") + abiOutputDir.mkdirs() + + packageTypes.forEach { packageType -> + // Asset naming: {PackageType}-{abi}-v{version}.zip + val packageName = "$packageType-$abi-v$nativeLibVersion.zip" + val zipUrl = "$releaseBaseUrl/$packageName" + val tempZip = file("$tempDir/$packageName") + + logger.lifecycle("▶ Downloading: $packageName") + + try { + // Download the zip + ant.withGroovyBuilder { + "get"("src" to zipUrl, "dest" to tempZip, "verbose" to false) + } + + // Extract to temp directory + val extractDir = file("$tempDir/extracted-${packageName.replace(".zip", "")}") + extractDir.mkdirs() + ant.withGroovyBuilder { + "unzip"("src" to tempZip, "dest" to extractDir) + } + + // Copy all .so files (they may be in subdirectories like jni/, onnx/, llamacpp/) + extractDir.walkTopDown() + .filter { it.extension == "so" } + .forEach { soFile -> + val targetFile = file("$abiOutputDir/${soFile.name}") + if (!targetFile.exists()) { + soFile.copyTo(targetFile, overwrite = true) + logger.lifecycle(" ✓ ${soFile.name}") + totalDownloaded++ + } + } + + // Clean up temp zip + tempZip.delete() + } catch (e: Exception) { + logger.warn(" ⚠ Failed to download $packageName: ${e.message}") + } + } + logger.lifecycle("") + } + + // Clean up temp directory + tempDir.deleteRecursively() + + // Verify output + val totalLibs = outputDir.walkTopDown().filter { it.extension == "so" }.count() + val abiDirs = outputDir.listFiles()?.filter { it.isDirectory }?.map { it.name } ?: emptyList() + + logger.lifecycle("═══════════════════════════════════════════════════════════════") + logger.lifecycle("✓ JNI libraries ready: $totalLibs .so files") + logger.lifecycle(" ABIs: ${abiDirs.joinToString(", ")}") + logger.lifecycle(" Output: $outputDir") + logger.lifecycle("═══════════════════════════════════════════════════════════════") + + // List libraries per ABI + abiDirs.forEach { abi -> + val libs = file("$outputDir/$abi").listFiles()?.filter { it.extension == "so" }?.map { it.name } ?: emptyList() + logger.lifecycle("$abi (${libs.size} libs):") + libs.sorted().forEach { lib -> + val size = file("$outputDir/$abi/$lib").length() / 1024 + logger.lifecycle(" - $lib (${size}KB)") + } + } + } +} + +// Ensure JNI libs are available before Android build +tasks.matching { it.name.contains("merge") && it.name.contains("JniLibFolders") }.configureEach { + if (testLocal) { + dependsOn("buildLocalJniLibs") + } else { + dependsOn("downloadJniLibs") + } +} + +// Also ensure preBuild triggers JNI lib preparation +tasks.matching { it.name == "preBuild" }.configureEach { + if (testLocal) { + dependsOn("buildLocalJniLibs") + } else { + dependsOn("downloadJniLibs") + } +} + +// Include third-party licenses in JVM JAR +tasks.named("jvmJar") { + from(rootProject.file("THIRD_PARTY_LICENSES.md")) { + into("META-INF") + } +} + +// ============================================================================= +// Maven Central Publishing Configuration +// ============================================================================= +// Consumer usage (after publishing): +// implementation("com.runanywhere:runanywhere-sdk:1.0.0") +// ============================================================================= + +// Get publishing credentials from environment or gradle.properties +val mavenCentralUsername: String? = System.getenv("MAVEN_CENTRAL_USERNAME") + ?: project.findProperty("mavenCentral.username") as String? +val mavenCentralPassword: String? = System.getenv("MAVEN_CENTRAL_PASSWORD") + ?: project.findProperty("mavenCentral.password") as String? + +// GPG signing configuration +val signingKeyId: String? = System.getenv("GPG_KEY_ID") + ?: project.findProperty("signing.keyId") as String? +val signingPassword: String? = System.getenv("GPG_SIGNING_PASSWORD") + ?: project.findProperty("signing.password") as String? +val signingKey: String? = System.getenv("GPG_SIGNING_KEY") + ?: project.findProperty("signing.key") as String? + +publishing { + publications.withType { + // Artifact naming for Maven Central + // Main artifact: com.runanywhere:runanywhere-sdk:1.0.0 + artifactId = when (name) { + "kotlinMultiplatform" -> "runanywhere-sdk" + "androidRelease" -> "runanywhere-sdk-android" + "jvm" -> "runanywhere-sdk-jvm" + else -> "runanywhere-sdk-$name" + } + + // POM metadata (required by Maven Central) + pom { + name.set("RunAnywhere SDK") + description.set("Privacy-first, on-device AI SDK for Kotlin/JVM and Android. Includes core infrastructure and common native libraries.") + url.set("https://runanywhere.ai") + inceptionYear.set("2024") + + licenses { + license { + name.set("The Apache License, Version 2.0") + url.set("https://www.apache.org/licenses/LICENSE-2.0.txt") + distribution.set("repo") + } + } + + developers { + developer { + id.set("runanywhere") + name.set("RunAnywhere Team") + email.set("founders@runanywhere.ai") + organization.set("RunAnywhere AI") + organizationUrl.set("https://runanywhere.ai") + } + } + + scm { + connection.set("scm:git:git://github.com/RunanywhereAI/runanywhere-sdks.git") + developerConnection.set("scm:git:ssh://github.com/RunanywhereAI/runanywhere-sdks.git") + url.set("https://github.com/RunanywhereAI/runanywhere-sdks") + } + + issueManagement { + system.set("GitHub Issues") + url.set("https://github.com/RunanywhereAI/runanywhere-sdks/issues") + } + } + } + + repositories { + // Maven Central (Sonatype Central Portal - new API) + maven { + name = "MavenCentral" + url = uri("https://ossrh-staging-api.central.sonatype.com/service/local/staging/deploy/maven2/") + credentials { + username = mavenCentralUsername + password = mavenCentralPassword + } + } + + // Sonatype Snapshots (Central Portal) + maven { + name = "SonatypeSnapshots" + url = uri("https://central.sonatype.com/repository/maven-snapshots/") + credentials { + username = mavenCentralUsername + password = mavenCentralPassword + } + } + + // GitHub Packages (backup/alternative) + maven { + name = "GitHubPackages" + url = uri("https://maven.pkg.github.com/RunanywhereAI/runanywhere-sdks") + credentials { + username = project.findProperty("gpr.user") as String? ?: System.getenv("GITHUB_ACTOR") + password = project.findProperty("gpr.token") as String? ?: System.getenv("GITHUB_TOKEN") + } + } + } +} + +// Configure signing (required for Maven Central) +signing { + // Use in-memory key if provided via environment, otherwise use system GPG + if (signingKey != null && signingKey.contains("BEGIN PGP")) { + useInMemoryPgpKeys(signingKeyId, signingKey, signingPassword) + } else { + // Use system GPG (configured via gradle.properties) + useGpgCmd() + } + // Sign all publications + sign(publishing.publications) +} + +// Only sign when publishing to Maven Central (not for local builds) +tasks.withType().configureEach { + onlyIf { + gradle.taskGraph.hasTask(":publishAllPublicationsToMavenCentralRepository") || + gradle.taskGraph.hasTask(":publish") || + project.hasProperty("signing.gnupg.keyName") || + signingKey != null + } +} + +// Disable JVM and debug publications - only publish Android release and metadata +tasks.withType().configureEach { + onlyIf { + val dominated = publication.name in listOf("jvm", "androidDebug") + !dominated + } +} diff --git a/sdk/runanywhere-kotlin/consumer-rules.pro b/sdk/runanywhere-kotlin/consumer-rules.pro new file mode 100644 index 000000000..bf9dfc89e --- /dev/null +++ b/sdk/runanywhere-kotlin/consumer-rules.pro @@ -0,0 +1,64 @@ +# ======================================================================================== +# RunAnywhere SDK - Consumer ProGuard Rules +# ======================================================================================== +# These rules are automatically applied to any app that depends on the RunAnywhere SDK. +# They ensure the SDK works correctly in release builds with R8/ProGuard enabled. + +# ======================================================================================== +# MASTER RULE: Keep ALL SDK classes +# ======================================================================================== +# The SDK uses dynamic registration, reflection-like patterns, and JNI callbacks. +# We must keep ALL classes, interfaces, enums, and their members. + +-keep class com.runanywhere.sdk.** { *; } +-keep interface com.runanywhere.sdk.** { *; } +-keep enum com.runanywhere.sdk.** { *; } + +# Keep all constructors (critical for JNI object creation like NativeTTSSynthesisResult) +-keepclassmembers class com.runanywhere.sdk.** { + (...); +} + +# Keep companion objects and their members (Kotlin singletons like LlamaCppAdapter.shared) +-keepclassmembers class com.runanywhere.sdk.** { + public static ** Companion; + public static ** INSTANCE; + public static ** shared; +} + +# Prevent obfuscation of class names (important for JNI, logging, and debugging) +-keepnames class com.runanywhere.sdk.** { *; } +-keepnames interface com.runanywhere.sdk.** { *; } +-keepnames enum com.runanywhere.sdk.** { *; } + +# Keep Kotlin metadata for reflection +-keepattributes *Annotation*, Signature, InnerClasses, EnclosingMethod +-keep class kotlin.Metadata { *; } + +# ======================================================================================== +# Native Methods (JNI) +# ======================================================================================== + +-keepclasseswithmembernames class * { + native ; +} + +# ======================================================================================== +# Third-party Dependencies Used by SDK +# ======================================================================================== + +# Whisper JNI +-keep class io.github.givimad.whisperjni.** { *; } +-dontwarn io.github.givimad.whisperjni.** + +# VAD classes +-keep class com.konovalov.vad.** { *; } +-dontwarn com.konovalov.vad.** + +# ONNX Runtime +-keep class ai.onnxruntime.** { *; } +-dontwarn ai.onnxruntime.** + +# Suppress warnings for optional dependencies +-dontwarn org.slf4j.** +-dontwarn ch.qos.logback.** diff --git a/sdk/runanywhere-kotlin/detekt.yml b/sdk/runanywhere-kotlin/detekt.yml new file mode 100644 index 000000000..af7212c99 --- /dev/null +++ b/sdk/runanywhere-kotlin/detekt.yml @@ -0,0 +1,427 @@ +# Detekt Configuration for KMP SDK Hygiene +# Focus: Unused code detection and removal + +build: + maxIssues: 0 + excludeCorrectable: false + +config: + validation: true + warningsAsErrors: true + checkExhaustiveness: false + +processors: + active: true + +console-reports: + active: true + +# ============================================================================= +# COMMENTS - Minimal +# ============================================================================= +comments: + active: false + +# ============================================================================= +# COMPLEXITY - Focus on egregious cases only +# ============================================================================= +complexity: + active: true + CognitiveComplexMethod: + active: false + ComplexCondition: + active: false + ComplexInterface: + active: false + CyclomaticComplexMethod: + active: false + LabeledExpression: + active: false + LargeClass: + active: false + LongMethod: + active: false + LongParameterList: + active: false + MethodOverloading: + active: false + NamedArguments: + active: false + NestedBlockDepth: + active: false + NestedScopeFunctions: + active: false + ReplaceSafeCallChainWithRun: + active: false + StringLiteralDuplication: + active: false + TooManyFunctions: + active: false + +# ============================================================================= +# COROUTINES +# ============================================================================= +coroutines: + active: true + GlobalCoroutineUsage: + active: true + InjectDispatcher: + active: false + RedundantSuspendModifier: + active: true + SleepInsteadOfDelay: + active: true + SuspendFunSwallowedCancellation: + active: false + SuspendFunWithCoroutineScopeReceiver: + active: false + SuspendFunWithFlowReturnType: + active: false + +# ============================================================================= +# EMPTY BLOCKS +# ============================================================================= +empty-blocks: + active: true + EmptyCatchBlock: + active: true + EmptyClassBlock: + active: true + EmptyDefaultConstructor: + active: true + EmptyDoWhileBlock: + active: true + EmptyElseBlock: + active: true + EmptyFinallyBlock: + active: true + EmptyForBlock: + active: true + EmptyFunctionBlock: + active: false # Disabled: InMemoryDatabase stub implementations are intentional + EmptyIfBlock: + active: true + EmptyInitBlock: + active: true + EmptyKtFile: + active: true + EmptySecondaryConstructor: + active: true + EmptyTryBlock: + active: true + EmptyWhenBlock: + active: true + EmptyWhileBlock: + active: true + +# ============================================================================= +# EXCEPTIONS - Minimal +# ============================================================================= +exceptions: + active: true + ExceptionRaisedInUnexpectedLocation: + active: false + InstanceOfCheckForException: + active: false + NotImplementedDeclaration: + active: true + ObjectExtendsThrowable: + active: false + PrintStackTrace: + active: true + RethrowCaughtException: + active: false + ReturnFromFinally: + active: true + SwallowedException: + active: false + ThrowingExceptionFromFinally: + active: true + ThrowingExceptionInMain: + active: false + ThrowingExceptionsWithoutMessageOrCause: + active: false + ThrowingNewInstanceOfSameException: + active: false + TooGenericExceptionCaught: + active: false + TooGenericExceptionThrown: + active: false + +# ============================================================================= +# NAMING - Minimal +# ============================================================================= +naming: + active: false + +# ============================================================================= +# PERFORMANCE +# ============================================================================= +performance: + active: true + ArrayPrimitive: + active: true + CouldBeSequence: + active: false + ForEachOnRange: + active: true + SpreadOperator: + active: false + UnnecessaryPartOfBinaryExpression: + active: true + UnnecessaryTemporaryInstantiation: + active: true + +# ============================================================================= +# POTENTIAL-BUGS +# ============================================================================= +potential-bugs: + active: true + AvoidReferentialEquality: + active: false + CastNullableToNonNullableType: + active: true + CastToNullableType: + active: false + Deprecation: + active: false + DontDowncastCollectionTypes: + active: true + DoubleMutabilityForCollection: + active: true + ElseCaseInsteadOfExhaustiveWhen: + active: false + EqualsAlwaysReturnsTrueOrFalse: + active: true + EqualsWithHashCodeExist: + active: true + ExitOutsideMain: + active: true + ExplicitGarbageCollectionCall: + active: true + HasPlatformType: + active: false + IgnoredReturnValue: + active: false + ImplicitDefaultLocale: + active: false + ImplicitUnitReturnType: + active: false + InvalidRange: + active: true + IteratorHasNextCallsNextMethod: + active: true + IteratorNotThrowingNoSuchElementException: + active: true + LateinitUsage: + active: false + MapGetWithNotNullAssertionOperator: + active: false + MissingPackageDeclaration: + active: true + NullCheckOnMutableProperty: + active: false + NullableToStringCall: + active: false + PropertyUsedBeforeDeclaration: + active: true + UnconditionalJumpStatementInLoop: + active: true + UnnecessaryNotNullCheck: + active: true + UnnecessaryNotNullOperator: + active: true + UnnecessarySafeCall: + active: true + UnreachableCatchBlock: + active: true + UnreachableCode: + active: true + UnsafeCallOnNullableType: + active: false + UnsafeCast: + active: false + UnusedUnaryOperator: + active: true + UselessPostfixExpression: + active: true + WrongEqualsTypeParameter: + active: true + +# ============================================================================= +# STYLE - Focus on unused code detection +# ============================================================================= +style: + active: true + AlsoCouldBeApply: + active: false + BracesOnIfStatements: + active: false + BracesOnWhenStatements: + active: false + CanBeNonNullable: + active: false + CascadingCallWrapping: + active: false + ClassOrdering: + active: false + CollapsibleIfStatements: + active: false + DataClassContainsFunctions: + active: false + DataClassShouldBeImmutable: + active: false + DestructuringDeclarationWithTooManyEntries: + active: false + DoubleNegativeLambda: + active: false + EqualsNullCall: + active: false + EqualsOnSignatureLine: + active: false + ExplicitCollectionElementAccessMethod: + active: false + ExplicitItLambdaParameter: + active: false + ExpressionBodySyntax: + active: false + ForbiddenAnnotation: + active: false + ForbiddenComment: + active: false + ForbiddenImport: + active: false + ForbiddenMethodCall: + active: false + ForbiddenSuppress: + active: false + ForbiddenVoid: + active: false + FunctionOnlyReturningConstant: + active: true + LoopWithTooManyJumpStatements: + active: false + MagicNumber: + active: false + MaxChainedCallsOnSameLine: + active: false + MaxLineLength: + active: false + MayBeConst: + active: true + ModifierOrder: + active: false + MultilineLambdaItParameter: + active: false + MultilineRawStringIndentation: + active: false + NestedClassesVisibility: + active: false + NewLineAtEndOfFile: + active: false + NoTabs: + active: false + NullableBooleanCheck: + active: false + ObjectLiteralToLambda: + active: true + OptionalAbstractKeyword: + active: true + OptionalUnit: + active: true + PreferToOverPairSyntax: + active: false + ProtectedMemberInFinalClass: + active: true + RedundantExplicitType: + active: false + RedundantHigherOrderMapUsage: + active: true + RedundantVisibilityModifierRule: + active: false + ReturnCount: + active: false + SafeCast: + active: false + SerialVersionUIDInSerializableClass: + active: false + SpacingBetweenPackageAndImports: + active: false + StringShouldBeRawString: + active: false + ThrowsCount: + active: false + TrailingWhitespace: + active: false + TrimMultilineRawString: + active: false + UnderscoresInNumericLiterals: + active: false + UnnecessaryAbstractClass: + active: true + UnnecessaryAnnotationUseSiteTarget: + active: false + UnnecessaryApply: + active: true + UnnecessaryBackticks: + active: false + UnnecessaryBracesAroundTrailingLambda: + active: false + UnnecessaryFilter: + active: true + UnnecessaryInheritance: + active: true + UnnecessaryInnerClass: + active: true + UnnecessaryLet: + active: true + UnnecessaryParentheses: + active: false + UntilInsteadOfRangeTo: + active: false + UnusedImports: + active: true + UnusedParameter: + active: true + UnusedPrivateClass: + active: true + UnusedPrivateMember: + active: true + UnusedPrivateProperty: + active: true + UseAnyOrNoneInsteadOfFind: + active: false + UseArrayLiteralsInAnnotations: + active: false + UseCheckNotNull: + active: false + UseCheckOrError: + active: false + UseDataClass: + active: false + UseEmptyCounterpart: + active: false + UseIfEmptyOrIfBlank: + active: false + UseIfInsteadOfWhen: + active: false + UseIsNullOrEmpty: + active: false + UseLet: + active: false + UseOrEmpty: + active: false + UseRequire: + active: false + UseRequireNotNull: + active: false + UseSumOfInsteadOfFlatMapSize: + active: false + UselessCallOnNotNull: + active: true + UtilityClassWithPublicConstructor: + active: true + VarCouldBeVal: + active: true + WildcardImport: + active: false diff --git a/sdk/runanywhere-kotlin/docs/ARCHITECTURE.md b/sdk/runanywhere-kotlin/docs/ARCHITECTURE.md new file mode 100644 index 000000000..5c1cef9ac --- /dev/null +++ b/sdk/runanywhere-kotlin/docs/ARCHITECTURE.md @@ -0,0 +1,658 @@ +# RunAnywhere Kotlin SDK Architecture + +This document describes the internal architecture, design principles, and implementation details of the RunAnywhere Kotlin SDK. + +--- + +## Table of Contents + +1. [Design Principles](#design-principles) +2. [High-Level Architecture](#high-level-architecture) +3. [Package Structure](#package-structure) +4. [Initialization Flow](#initialization-flow) +5. [Threading Model](#threading-model) +6. [Native Bridge Layer](#native-bridge-layer) +7. [Module System](#module-system) +8. [Public API Design](#public-api-design) +9. [Event System](#event-system) +10. [Model Management](#model-management) +11. [Error Handling](#error-handling) +12. [Testing Strategy](#testing-strategy) + +--- + +## Design Principles + +### 1. Single API Surface +Developers call one SDK; we abstract engine complexity. All AI capabilities (LLM, STT, TTS, VAD) are accessed through the unified `RunAnywhere` object. + +### 2. Kotlin Multiplatform First +The SDK uses Kotlin Multiplatform (KMP) to share code across: +- **commonMain** - Platform-agnostic business logic and API definitions +- **jvmAndroidMain** - Shared JVM/Android code including JNI bridges +- **androidMain** - Android-specific implementations (permissions, audio, storage) +- **jvmMain** - Desktop JVM implementations + +### 3. Async-First +All I/O operations (network, model loading, inference) are non-blocking: +- Uses Kotlin Coroutines and Flows +- Never blocks the main thread +- Streaming APIs for real-time output + +### 4. Observability Built-In +Every operation records metadata (latency, device state, model info): +- Events emitted for analytics +- Generation results include full metrics +- Production debugging enabled by default + +### 5. Memory-Conscious +Aggressive resource management for mobile devices: +- On-demand model loading +- Explicit unload APIs +- Native memory managed by C++ layer + +### 6. Platform Parity +Mirrors the iOS RunAnywhere Swift SDK exactly: +- Same API signatures +- Same event types +- Same error codes + +--- + +## High-Level Architecture + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ Application Layer │ +│ (Your Android/JVM Application) │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ RunAnywhere Public API │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ RunAnywhere Object │ │ +│ │ • initialize()/reset() │ │ +│ │ • isInitialized, areServicesReady │ │ +│ │ • events (EventBus) │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ┌────────────┬────────────┬────────────┬────────────┬────────────┐ │ +│ │ LLM API │ STT API │ TTS API │ VAD API │ VoiceAgent │ │ +│ │ (extension)│ (extension)│ (extension)│ (extension)│ (extension)│ │ +│ └────────────┴────────────┴────────────┴────────────┴────────────┘ │ +│ │ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ Model Management API │ │ +│ │ • registerModel(), downloadModel() │ │ +│ │ • loadLLMModel(), loadSTTModel(), loadTTSVoice() │ │ +│ │ • availableModels(), deleteModel() │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ Internal Layer │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ CppBridge │ │ +│ │ • JNI bindings to runanywhere-commons │ │ +│ │ • Platform adapter registration │ │ +│ │ • Callback bridges (events, telemetry) │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ Platform Services │ │ +│ │ • StoragePlatform (file system access) │ │ +│ │ • NetworkConnectivity │ │ +│ │ • SecureStorage (KeychainManager) │ │ +│ │ • DeviceInfo │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ Native Layer (C++) │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ runanywhere-commons │ │ +│ │ • librac_commons.so - Core infrastructure │ │ +│ │ • librunanywhere_jni.so - JNI bridge │ │ +│ │ • Model registry, download management │ │ +│ │ • Event system, telemetry │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ │ │ +│ ┌───────────┴───────────┐ │ │ +│ ▼ ▼ ▼ │ +│ ┌───────────────────────┐ ┌────────────────────────────────────┐ │ +│ │ runanywhere-core- │ │ runanywhere-core-onnx │ │ +│ │ llamacpp │ │ │ │ +│ │ │ │ • libonnxruntime.so │ │ +│ │ • llama.cpp engine │ │ • libsherpa-onnx-*.so │ │ +│ │ • LLM inference │ │ • STT/TTS/VAD inference │ │ +│ └───────────────────────┘ └────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Package Structure + +``` +com.runanywhere.sdk/ +├── public/ # Public API (exported) +│ ├── RunAnywhere.kt # Main SDK entry point +│ ├── events/ +│ │ ├── EventBus.kt # Event subscription system +│ │ └── SDKEvent.kt # Event type definitions +│ └── extensions/ +│ ├── RunAnywhere+TextGeneration.kt # LLM APIs +│ ├── RunAnywhere+STT.kt # Speech-to-text APIs +│ ├── RunAnywhere+TTS.kt # Text-to-speech APIs +│ ├── RunAnywhere+VAD.kt # Voice activity detection +│ ├── RunAnywhere+VoiceAgent.kt # Voice pipeline orchestration +│ ├── RunAnywhere+ModelManagement.kt # Model registration/download +│ ├── LLM/LLMTypes.kt # LLM type definitions +│ ├── STT/STTTypes.kt # STT type definitions +│ ├── TTS/TTSTypes.kt # TTS type definitions +│ ├── VAD/VADTypes.kt # VAD type definitions +│ ├── VoiceAgent/VoiceAgentTypes.kt # Voice agent types +│ └── Models/ModelTypes.kt # Model type definitions +│ +├── core/ # Core types and interfaces +│ ├── types/ +│ │ └── ComponentTypes.kt # SDKComponent, InferenceFramework +│ └── module/ +│ └── SDKModule.kt # Module registration interface +│ +├── foundation/ # Foundation utilities +│ ├── SDKLogger.kt # Logging system +│ ├── errors/ +│ │ ├── SDKError.kt # Error class +│ │ ├── ErrorCode.kt # Error codes +│ │ └── ErrorCategory.kt # Error categories +│ ├── device/ +│ │ └── DeviceCapabilities.kt # Device info +│ └── constants/ +│ └── SDKConstants.kt # SDK version, etc. +│ +├── native/ # Native bridge (internal) +│ └── bridge/ +│ ├── NativeCoreService.kt # JNI service interface +│ ├── BridgeResults.kt # Native call results +│ └── Capability.kt # Native capability types +│ +├── data/ # Data layer +│ ├── models/ +│ │ └── ModelEntity.kt # Model persistence +│ ├── network/ +│ │ └── ApiClient.kt # HTTP client +│ └── repositories/ +│ └── ModelRepository.kt # Model data access +│ +├── storage/ # Storage layer +│ ├── PlatformStorage.kt # Cross-platform storage +│ └── FileSystem.kt # File operations +│ +├── platform/ # Platform abstractions +│ ├── Checksum.kt # Hash verification +│ ├── NetworkConnectivity.kt # Network state +│ └── StoragePlatform.kt # Storage abstraction +│ +└── utils/ # Utilities + ├── SDKConstants.kt # Constants + └── Extensions.kt # Kotlin extensions +``` + +--- + +## Initialization Flow + +The SDK uses a **two-phase initialization** pattern for optimal startup performance: + +### Phase 1: Core Init (Synchronous, ~1-5ms) + +``` +RunAnywhere.initialize(environment) + │ + ├─► Store environment + │ + ├─► Set log level based on environment + │ + └─► CppBridge.initialize() + │ + ├─► Load JNI library (librunanywhere_jni.so) + │ + ├─► Register PlatformAdapter (file I/O, logging, keychain) + │ + ├─► Register Events callback (analytics) + │ + └─► Initialize Device registration + + Result: isInitialized = true +``` + +### Phase 2: Services Init (Async, ~100-500ms) + +``` +RunAnywhere.completeServicesInitialization() + │ + ├─► CppBridge.initializeServices() + │ │ + │ ├─► Register ModelAssignment callbacks + │ │ + │ └─► Register Platform service callbacks (LLM/TTS) + │ + └─► Mark: areServicesReady = true +``` + +**Key Points:** +- Phase 1 is fast and synchronous - safe to call in `Application.onCreate()` +- Phase 2 is called automatically on first API call, or can be awaited explicitly +- Both phases are idempotent - safe to call multiple times + +--- + +## Threading Model + +| Operation | Thread | Notes | +|-----------|--------|-------| +| `RunAnywhere.initialize()` | Calling thread (main) | Fast, < 5ms | +| `completeServicesInitialization()` | Calling thread | Suspending function | +| `loadLLMModel()` / `loadSTTModel()` | Dispatchers.IO | Async, returns immediately | +| `generate()` / `transcribe()` | Dispatchers.Default | CPU-bound inference | +| `generateStream()` | Dispatchers.Default | Returns Flow, collects on Default | +| `downloadModel()` | Dispatchers.IO | Network I/O | +| Event emissions | Internal event loop | Delivered to collectors' context | + +**Thread Safety:** +- All public APIs are thread-safe +- Internal state protected by `synchronized` blocks +- Native layer handles its own thread safety + +--- + +## Native Bridge Layer + +The SDK communicates with the C++ `runanywhere-commons` library via JNI: + +### CppBridge Architecture + +```kotlin +// Kotlin side (jvmAndroidMain) +object CppBridge { + // Phase 1 initialization + external fun nativeInitialize(environment: Int, apiKey: String?, baseUrl: String?): Int + + // Phase 2 services + external fun nativeInitializeServices(): Int + + // LLM operations + external fun nativeLoadModel(modelId: String, modelPath: String): Int + external fun nativeGenerate(prompt: String, options: String): String + external fun nativeGenerateStream(prompt: String, options: String, callback: StreamCallback): Int + + // STT operations + external fun nativeTranscribe(audioData: ByteArray, options: String): String + + // TTS operations + external fun nativeSynthesize(text: String, options: String): ByteArray + + // Shutdown + external fun nativeShutdown() +} +``` + +### Platform Adapter Pattern + +The SDK registers Kotlin callbacks with the C++ layer for platform-specific operations: + +```kotlin +// Registered during Phase 1 +object PlatformAdapter { + // File operations (called from C++) + fun readFile(path: String): ByteArray + fun writeFile(path: String, data: ByteArray) + fun fileExists(path: String): Boolean + + // Logging (called from C++) + fun log(level: Int, tag: String, message: String) + + // Keychain (called from C++) + fun secureStore(key: String, value: String) + fun secureRetrieve(key: String): String? +} +``` + +--- + +## Module System + +The SDK uses a modular architecture where AI backends are optional: + +### Core SDK (Required) +- `com.runanywhere.sdk:runanywhere-kotlin` +- Contains: Public API, JNI bridge, model management +- Native: `librac_commons.so`, `librunanywhere_jni.so` + +### LlamaCPP Module (Optional) +- `com.runanywhere.sdk:runanywhere-core-llamacpp` +- Provides: LLM text generation +- Native: `librunanywhere_llamacpp.so` (~34MB) +- Framework: `InferenceFramework.LLAMA_CPP` + +### ONNX Module (Optional) +- `com.runanywhere.sdk:runanywhere-core-onnx` +- Provides: STT, TTS, VAD +- Native: `libonnxruntime.so`, `libsherpa-onnx-*.so` (~25MB) +- Framework: `InferenceFramework.ONNX` + +### Module Detection + +```kotlin +// Check which modules are available at runtime +val hasLLM = CppBridge.isCapabilityAvailable(SDKComponent.LLM) +val hasSTT = CppBridge.isCapabilityAvailable(SDKComponent.STT) +val hasTTS = CppBridge.isCapabilityAvailable(SDKComponent.TTS) +val hasVAD = CppBridge.isCapabilityAvailable(SDKComponent.VAD) +``` + +--- + +## Public API Design + +### Extension Function Pattern + +All feature-specific APIs are implemented as extension functions on `RunAnywhere`: + +```kotlin +// Definition (in RunAnywhere+TextGeneration.kt) +expect suspend fun RunAnywhere.chat(prompt: String): String + +// Implementation (in RunAnywhere+TextGeneration.jvmAndroid.kt) +actual suspend fun RunAnywhere.chat(prompt: String): String { + requireInitialized() + ensureServicesReady() + return CppBridge.nativeChat(prompt) +} +``` + +**Benefits:** +- Clean separation of concerns +- Easy to add new features without modifying core +- Platform-specific implementations via expect/actual + +### Result Types + +All operations return rich result types with metadata: + +```kotlin +data class LLMGenerationResult( + val text: String, // Generated content + val thinkingContent: String?, // Reasoning (if model supports) + val inputTokens: Int, // Prompt tokens + val tokensUsed: Int, // Output tokens + val modelUsed: String, // Model ID + val latencyMs: Double, // Total time + val tokensPerSecond: Double, // Generation speed + val timeToFirstTokenMs: Double?, // TTFT (streaming) +) +``` + +--- + +## Event System + +### Event Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ C++ Event Producer │ +│ (runanywhere-commons generates events) │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ CppEventBridge (JNI) │ +│ (Callback registered during Phase 1) │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ EventBus │ +│ SharedFlow-based event distribution │ +│ ┌─────────────┬─────────────┬─────────────┬─────────────┐ │ +│ │ llmEvents │ sttEvents │ ttsEvents │ modelEvents │ │ +│ │ (Flow) │ (Flow) │ (Flow) │ (Flow) │ │ +│ └─────────────┴─────────────┴─────────────┴─────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ + App Collectors +``` + +### Event Types + +| Category | Events | +|----------|--------| +| SDK | `sdk.initialized`, `sdk.shutdown`, `sdk.error` | +| Model | `model.download_started`, `model.download_progress`, `model.download_completed`, `model.loaded`, `model.unloaded` | +| LLM | `llm.generation_started`, `llm.stream_token`, `llm.generation_completed`, `llm.generation_failed` | +| STT | `stt.transcription_started`, `stt.partial_result`, `stt.transcription_completed` | +| TTS | `tts.synthesis_started`, `tts.synthesis_completed`, `tts.playback_started` | + +### Subscribing to Events + +```kotlin +// Subscribe to LLM events +lifecycleScope.launch { + RunAnywhere.events.llmEvents.collect { event -> + Log.d("LLM", "Event: ${event.type}, Latency: ${event.latencyMs}ms") + } +} + +// Subscribe to all events +lifecycleScope.launch { + RunAnywhere.events.allEvents.collect { event -> + analytics.track(event.type, event.properties) + } +} +``` + +--- + +## Model Management + +### Model Lifecycle + +``` +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ +│ Register │ ──► │ Download │ ──► │ Load │ ──► │ Unload │ +│ (metadata) │ │ (network) │ │ (memory) │ │ (cleanup) │ +└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ + │ │ │ │ + ▼ ▼ ▼ ▼ + ModelInfo DownloadProgress Model in RAM Memory freed + in registry events emitted ready for use model cached +``` + +### Model States + +```kotlin +// 1. Registered but not downloaded +model.isDownloaded == false +model.localPath == null + +// 2. Downloaded but not loaded +model.isDownloaded == true +model.localPath != null +RunAnywhere.isLLMModelLoaded() == false + +// 3. Loaded and ready +model.isDownloaded == true +RunAnywhere.isLLMModelLoaded() == true +RunAnywhere.currentLLMModelId == model.id +``` + +### Download Flow + +```kotlin +RunAnywhere.downloadModel(modelId) + │ + ├─► Emit: ModelEvent.DOWNLOAD_STARTED + │ + ├─► Fetch URL → Write chunks to temp file + │ │ + │ └─► Emit: ModelEvent.DOWNLOAD_PROGRESS (0.0 → 1.0) + │ + ├─► Extract if archive (tar.gz, zip) + │ + ├─► Verify checksum (if provided) + │ + ├─► Move to final location + │ + ├─► Update model.localPath + │ + └─► Emit: ModelEvent.DOWNLOAD_COMPLETED +``` + +--- + +## Error Handling + +### Error Structure + +```kotlin +data class SDKError( + val code: ErrorCode, // Specific error type + val category: ErrorCategory, // Error group + val message: String, // Human-readable + val cause: Throwable? // Underlying exception +) : Exception(message, cause) +``` + +### Error Categories + +| Category | Description | Example Errors | +|----------|-------------|----------------| +| `INITIALIZATION` | SDK startup | `NOT_INITIALIZED`, `ALREADY_INITIALIZED` | +| `MODEL` | Model operations | `MODEL_NOT_FOUND`, `MODEL_LOAD_FAILED` | +| `LLM` | Text generation | `LLM_GENERATION_FAILED` | +| `STT` | Speech-to-text | `STT_TRANSCRIPTION_FAILED` | +| `TTS` | Text-to-speech | `TTS_SYNTHESIS_FAILED` | +| `NETWORK` | Network issues | `NETWORK_UNAVAILABLE`, `TIMEOUT` | +| `STORAGE` | Storage issues | `INSUFFICIENT_STORAGE`, `FILE_NOT_FOUND` | + +### Error Factory Pattern + +```kotlin +// Create errors with factory methods +throw SDKError.modelNotFound(modelId) +throw SDKError.llmGenerationFailed("Context length exceeded") +throw SDKError.networkUnavailable() + +// From C++ error codes +val error = SDKError.fromRawValue(cppErrorCode, message) +``` + +--- + +## Testing Strategy + +### Unit Tests + +Test business logic without native libraries: + +```kotlin +@Test +fun testModelRegistration() { + val modelInfo = createTestModelInfo() + + // Test URL parsing + assertEquals("qwen-0.5b", generateModelIdFromUrl(modelInfo.downloadURL)) + + // Test format detection + assertEquals(ModelFormat.GGUF, detectFormatFromUrl(modelInfo.downloadURL)) +} +``` + +### Integration Tests + +Test with mocked native layer: + +```kotlin +@Test +fun testGenerationFlow() = runTest { + // Mock CppBridge + mockkObject(CppBridge) + every { CppBridge.nativeGenerate(any(), any()) } returns """ + {"text": "Hello", "tokensUsed": 5, "latencyMs": 100} + """ + + // Test generation + val result = RunAnywhere.generate("Hi") + assertEquals("Hello", result.text) +} +``` + +### Instrumented Tests + +Test on real devices with actual models: + +```kotlin +@Test +fun testRealInference() = runTest { + // Initialize SDK + RunAnywhere.initialize(environment = SDKEnvironment.DEVELOPMENT) + + // Load a small test model + RunAnywhere.loadLLMModel("test-tiny-model") + + // Run inference + val result = RunAnywhere.generate("2+2=") + assertNotNull(result.text) + assertTrue(result.latencyMs > 0) +} +``` + +--- + +## Performance Characteristics + +### Typical Latencies (Pixel 7, 8GB RAM) + +| Operation | Latency | Notes | +|-----------|---------|-------| +| SDK Initialize (Phase 1) | 1-5ms | Synchronous | +| SDK Initialize (Phase 2) | 50-100ms | Async | +| Model Load (0.5B) | 500-800ms | First time, cached after | +| Inference (50 tokens) | 150-300ms | Depends on model size | +| Streaming TTFT | 50-100ms | Time to first token | +| STT Transcribe (5s audio) | 200-400ms | Whisper tiny | +| TTS Synthesize (100 chars) | 100-200ms | Sherpa ONNX | + +### Memory Footprint + +| Component | Memory | +|-----------|--------| +| SDK (no models) | ~5MB | +| 0.5B LLM (Q8) | ~500MB | +| 0.5B LLM (Q4) | ~300MB | +| Whisper Tiny | ~75MB | +| TTS Voice | ~50MB | + +--- + +## Future Considerations + +1. **iOS Parity** - Continue aligning with Swift SDK APIs +2. **Kotlin Native** - Potential native targets (iOS, macOS, Linux) +3. **Model Caching** - LRU eviction for multi-model scenarios +4. **Background Processing** - WorkManager integration for downloads +5. **Hybrid Routing** - Cloud fallback when on-device unavailable + +--- + +## References + +- [RunAnywhere Swift SDK](../runanywhere-swift/) - iOS implementation +- [runanywhere-commons](../runanywhere-commons/) - C++ core library +- [Sample App](../../examples/android/RunAnywhereAI/) - Reference implementation diff --git a/sdk/runanywhere-kotlin/docs/Documentation.md b/sdk/runanywhere-kotlin/docs/Documentation.md new file mode 100644 index 000000000..8cbdc6a90 --- /dev/null +++ b/sdk/runanywhere-kotlin/docs/Documentation.md @@ -0,0 +1,1781 @@ +# RunAnywhere Kotlin SDK - API Documentation + +Complete API reference for the RunAnywhere Kotlin SDK. All public APIs are accessible through the `RunAnywhere` object via extension functions. + +--- + +## Table of Contents + +1. [Quick Start](#quick-start) +2. [Core API](#core-api) +3. [Text Generation (LLM)](#text-generation-llm) +4. [Speech-to-Text (STT)](#speech-to-text-stt) +5. [Text-to-Speech (TTS)](#text-to-speech-tts) +6. [Voice Activity Detection (VAD)](#voice-activity-detection-vad) +7. [Voice Agent](#voice-agent) +8. [Model Management](#model-management) +9. [Event System](#event-system) +10. [Types & Enums](#types--enums) +11. [Error Handling](#error-handling) + +--- + +## Quick Start + +### Installation (Maven Central) + +```kotlin +// build.gradle.kts +dependencies { + // Core SDK with native libraries + implementation("io.github.sanchitmonga22:runanywhere-sdk-android:0.16.1") + + // LlamaCPP backend for LLM text generation + implementation("io.github.sanchitmonga22:runanywhere-llamacpp-android:0.16.1") + + // ONNX backend for STT/TTS/VAD + implementation("io.github.sanchitmonga22:runanywhere-onnx-android:0.16.1") +} +``` + +```kotlin +// settings.gradle.kts - add repositories +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS) + repositories { + google() + mavenCentral() + // JitPack for transitive dependencies (android-vad, PRDownloader) + maven { url = uri("https://jitpack.io") } + } +} +``` + +### Initialize SDK + +```kotlin +import com.runanywhere.sdk.public.RunAnywhere +import com.runanywhere.sdk.public.SDKEnvironment + +// In your Application.onCreate() or Activity +RunAnywhere.initialize(environment = SDKEnvironment.DEVELOPMENT) +``` + +### Register & Load Models + +The starter app uses these specific model IDs and URLs: + +```kotlin +import com.runanywhere.sdk.public.RunAnywhere +import com.runanywhere.sdk.public.extensions.registerModel +import com.runanywhere.sdk.public.extensions.downloadModel +import com.runanywhere.sdk.public.extensions.loadLLMModel +import com.runanywhere.sdk.public.extensions.loadSTTModel +import com.runanywhere.sdk.public.extensions.loadTTSVoice +import com.runanywhere.sdk.public.extensions.Models.ModelCategory +import com.runanywhere.sdk.core.types.InferenceFramework + +// LLM Model - SmolLM2 360M (small, fast, good for demos) +RunAnywhere.registerModel( + id = "smollm2-360m-instruct-q8_0", + name = "SmolLM2 360M Instruct Q8_0", + url = "https://huggingface.co/HuggingFaceTB/SmolLM2-360M-Instruct-GGUF/resolve/main/smollm2-360m-instruct-q8_0.gguf", + framework = InferenceFramework.LLAMA_CPP, + modality = ModelCategory.LANGUAGE, + memoryRequirement = 400_000_000 // ~400MB +) + +// STT Model - Whisper Tiny English (fast transcription) +RunAnywhere.registerModel( + id = "sherpa-onnx-whisper-tiny.en", + name = "Sherpa Whisper Tiny (ONNX)", + url = "https://github.com/RunanywhereAI/sherpa-onnx/releases/download/runanywhere-models-v1/sherpa-onnx-whisper-tiny.en.tar.gz", + framework = InferenceFramework.ONNX, + modality = ModelCategory.SPEECH_RECOGNITION +) + +// TTS Model - Piper TTS (US English - Medium quality) +RunAnywhere.registerModel( + id = "vits-piper-en_US-lessac-medium", + name = "Piper TTS (US English - Medium)", + url = "https://github.com/RunanywhereAI/sherpa-onnx/releases/download/runanywhere-models-v1/vits-piper-en_US-lessac-medium.tar.gz", + framework = InferenceFramework.ONNX, + modality = ModelCategory.SPEECH_SYNTHESIS +) + +// Download model (returns Flow) +RunAnywhere.downloadModel("smollm2-360m-instruct-q8_0") + .catch { e -> println("Download failed: ${e.message}") } + .collect { progress -> + println("Download: ${(progress.progress * 100).toInt()}%") + } + +// Load model +RunAnywhere.loadLLMModel("smollm2-360m-instruct-q8_0") +``` + +### Text Generation (LLM) + +```kotlin +import com.runanywhere.sdk.public.RunAnywhere +import com.runanywhere.sdk.public.extensions.chat + +// Simple chat - returns String directly +val response = RunAnywhere.chat("What is AI?") +println(response) +``` + +### Speech-to-Text (STT) + +```kotlin +import com.runanywhere.sdk.public.RunAnywhere +import com.runanywhere.sdk.public.extensions.transcribe + +// Load STT model +RunAnywhere.loadSTTModel("sherpa-onnx-whisper-tiny.en") + +// Transcribe audio (16kHz, mono, 16-bit PCM ByteArray) +val transcription = RunAnywhere.transcribe(audioData) +println("You said: $transcription") +``` + +### Text-to-Speech (TTS) + +```kotlin +import com.runanywhere.sdk.public.RunAnywhere +import com.runanywhere.sdk.public.extensions.synthesize +import com.runanywhere.sdk.public.extensions.TTS.TTSOptions + +// Load TTS voice +RunAnywhere.loadTTSVoice("vits-piper-en_US-lessac-medium") + +// Synthesize audio - returns TTSOutput with audioData +val output = RunAnywhere.synthesize("Hello, world!", TTSOptions()) +// output.audioData contains WAV audio bytes + +// Play with Android AudioTrack (see example below) +``` + +### Voice Pipeline (STT → LLM → TTS) + +#### Option 1: Streaming Voice Session (Recommended) + +The `streamVoiceSession()` API handles everything automatically: +- Audio level calculation for visualization +- Speech detection (when audio level > threshold) +- Automatic silence detection (triggers processing after 1.5s of silence) +- Full STT → LLM → TTS orchestration +- Continuous conversation mode + +```kotlin +import com.runanywhere.sdk.public.RunAnywhere +import com.runanywhere.sdk.public.extensions.streamVoiceSession +import com.runanywhere.sdk.public.extensions.VoiceAgent.VoiceSessionConfig +import com.runanywhere.sdk.public.extensions.VoiceAgent.VoiceSessionEvent +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +// Ensure all 3 models are loaded first +RunAnywhere.loadSTTModel("sherpa-onnx-whisper-tiny.en") +RunAnywhere.loadLLMModel("smollm2-360m-instruct-q8_0") +RunAnywhere.loadTTSVoice("vits-piper-en_US-lessac-medium") + +// Your audio capture Flow (16kHz, mono, 16-bit PCM) +// See AudioCaptureService example below +val audioChunks: Flow = audioCaptureService.startCapture() + +// Configure voice session +val config = VoiceSessionConfig( + silenceDuration = 1.5, // 1.5 seconds of silence triggers processing + speechThreshold = 0.1f, // Audio level threshold for speech detection + autoPlayTTS = false, // We'll handle playback ourselves + continuousMode = true // Auto-resume listening after each turn +) + +// Start the SDK voice session - all business logic is handled by the SDK +sessionJob = scope.launch { + try { + RunAnywhere.streamVoiceSession(audioChunks, config).collect { event -> + when (event) { + is VoiceSessionEvent.Started -> { + sessionState = VoiceSessionState.LISTENING + } + + is VoiceSessionEvent.Listening -> { + audioLevel = event.audioLevel + } + + is VoiceSessionEvent.SpeechStarted -> { + sessionState = VoiceSessionState.SPEECH_DETECTED + } + + is VoiceSessionEvent.Processing -> { + sessionState = VoiceSessionState.PROCESSING + audioLevel = 0f + } + + is VoiceSessionEvent.Transcribed -> { + // User's speech was transcribed + showTranscript(event.text) + } + + is VoiceSessionEvent.Responded -> { + // LLM generated a response + showResponse(event.text) + } + + is VoiceSessionEvent.Speaking -> { + sessionState = VoiceSessionState.SPEAKING + } + + is VoiceSessionEvent.TurnCompleted -> { + // Play the synthesized audio + event.audio?.let { audio -> + sessionState = VoiceSessionState.SPEAKING + playWavAudio(audio) + } + // Resume listening state + sessionState = VoiceSessionState.LISTENING + audioLevel = 0f + } + + is VoiceSessionEvent.Stopped -> { + sessionState = VoiceSessionState.IDLE + audioLevel = 0f + } + + is VoiceSessionEvent.Error -> { + errorMessage = event.message + sessionState = VoiceSessionState.IDLE + } + } + } + } catch (e: CancellationException) { + // Expected when stopping + } catch (e: Exception) { + errorMessage = "Session error: ${e.message}" + sessionState = VoiceSessionState.IDLE + } +} + +// To stop the session: +fun stopSession() { + sessionJob?.cancel() + sessionJob = null + audioCaptureService.stopCapture() + sessionState = VoiceSessionState.IDLE +} +``` + +#### Audio Capture Service (Required for Voice Pipeline) + +```kotlin +import android.media.AudioFormat +import android.media.AudioRecord +import android.media.MediaRecorder +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.* + +class AudioCaptureService { + private var audioRecord: AudioRecord? = null + + @Volatile + private var isCapturing = false + + companion object { + const val SAMPLE_RATE = 16000 + const val CHUNK_SIZE_MS = 100 // Emit chunks every 100ms + } + + fun startCapture(): Flow = callbackFlow { + val bufferSize = AudioRecord.getMinBufferSize( + SAMPLE_RATE, + AudioFormat.CHANNEL_IN_MONO, + AudioFormat.ENCODING_PCM_16BIT + ) + val chunkSize = (SAMPLE_RATE * 2 * CHUNK_SIZE_MS) / 1000 + + try { + audioRecord = AudioRecord( + MediaRecorder.AudioSource.MIC, + SAMPLE_RATE, + AudioFormat.CHANNEL_IN_MONO, + AudioFormat.ENCODING_PCM_16BIT, + maxOf(bufferSize, chunkSize * 2) + ) + + if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) { + close(IllegalStateException("AudioRecord initialization failed")) + return@callbackFlow + } + + audioRecord?.startRecording() + isCapturing = true + + val readJob = launch(Dispatchers.IO) { + val buffer = ByteArray(chunkSize) + while (isActive && isCapturing) { + val bytesRead = audioRecord?.read(buffer, 0, chunkSize) ?: -1 + if (bytesRead > 0) { + trySend(buffer.copyOf(bytesRead)) + } + } + } + + awaitClose { + readJob.cancel() + stopCapture() + } + } catch (e: Exception) { + stopCapture() + close(e) + } + } + + fun stopCapture() { + isCapturing = false + try { + audioRecord?.stop() + audioRecord?.release() + } catch (_: Exception) {} + audioRecord = null + } +} +``` + +#### Play WAV Audio (Required for Voice Pipeline) + +```kotlin +import android.media.AudioAttributes +import android.media.AudioFormat +import android.media.AudioTrack +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext + +suspend fun playWavAudio(wavData: ByteArray) = withContext(Dispatchers.IO) { + if (wavData.size < 44) return@withContext + + val headerSize = if (wavData.size > 44 && + wavData[0] == 'R'.code.toByte() && + wavData[1] == 'I'.code.toByte()) 44 else 0 + + val pcmData = wavData.copyOfRange(headerSize, wavData.size) + val sampleRate = 22050 // Piper TTS default sample rate + + val bufferSize = AudioTrack.getMinBufferSize( + sampleRate, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_16BIT + ) + + val audioTrack = AudioTrack.Builder() + .setAudioAttributes( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .build() + ) + .setAudioFormat( + AudioFormat.Builder() + .setSampleRate(sampleRate) + .setEncoding(AudioFormat.ENCODING_PCM_16BIT) + .setChannelMask(AudioFormat.CHANNEL_OUT_MONO) + .build() + ) + .setBufferSizeInBytes(maxOf(bufferSize, pcmData.size)) + .setTransferMode(AudioTrack.MODE_STATIC) + .build() + + audioTrack.write(pcmData, 0, pcmData.size) + audioTrack.play() + + val durationMs = (pcmData.size.toLong() * 1000) / (sampleRate * 2) + delay(durationMs + 100) + + audioTrack.stop() + audioTrack.release() +} +``` + +#### Option 2: Manual Processing + +For more control, use `processVoice()` with your own silence detection: + +```kotlin +import com.runanywhere.sdk.public.RunAnywhere +import com.runanywhere.sdk.public.extensions.processVoice + +// Record audio (app responsibility - use AudioRecord) +val audioData: ByteArray = recordAudio() // 16kHz, mono, 16-bit PCM + +// Process through full pipeline - SDK handles orchestration +val result = RunAnywhere.processVoice(audioData) + +if (result.speechDetected) { + println("You said: ${result.transcription}") + println("AI response: ${result.response}") + + // Play synthesized audio (app responsibility) + result.synthesizedAudio?.let { playWavAudio(it) } +} +``` + +### Voice Session Events + +| Event | Description | +|-------|-------------| +| `Started` | Session started and ready | +| `Listening(audioLevel)` | Listening with real-time audio level (0.0 - 1.0) | +| `SpeechStarted` | Speech detected, accumulating audio | +| `Processing` | Silence detected, processing audio | +| `Transcribed(text)` | STT completed | +| `Responded(text)` | LLM response generated | +| `Speaking` | Playing TTS audio | +| `TurnCompleted(transcript, response, audio)` | Full turn complete with audio | +| `Stopped` | Session ended | +| `Error(message)` | Error occurred | + +### Complete Voice Pipeline Example + +See the Kotlin Starter Example app for a complete working implementation: +`starter_apps/kotlinstarterexample/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/screens/VoicePipelineScreen.kt` + +--- + +## Core API + +### RunAnywhere Object + +The main entry point for all SDK functionality. + +```kotlin +package com.runanywhere.sdk.public + +object RunAnywhere +``` + +#### Properties + +| Property | Type | Description | +|----------|------|-------------| +| `isInitialized` | `Boolean` | Whether Phase 1 initialization is complete | +| `isSDKInitialized` | `Boolean` | Alias for `isInitialized` | +| `areServicesReady` | `Boolean` | Whether Phase 2 (services) initialization is complete | +| `isActive` | `Boolean` | Whether SDK is initialized and has an environment | +| `version` | `String` | Current SDK version string | +| `environment` | `SDKEnvironment?` | Current environment (null if not initialized) | +| `events` | `EventBus` | Event subscription system | + +#### Initialization + +```kotlin +/** + * Initialize the RunAnywhere SDK (Phase 1). + * Fast synchronous initialization (~1-5ms). + * + * @param apiKey API key (optional for development) + * @param baseURL Backend API base URL (optional) + * @param environment SDK environment (default: DEVELOPMENT) + */ +fun initialize( + apiKey: String? = null, + baseURL: String? = null, + environment: SDKEnvironment = SDKEnvironment.DEVELOPMENT +) + +/** + * Initialize SDK for development mode (convenience method). + */ +fun initializeForDevelopment(apiKey: String? = null) + +/** + * Complete services initialization (Phase 2). + * Called automatically on first API call, or can be awaited explicitly. + */ +suspend fun completeServicesInitialization() +``` + +#### Lifecycle + +```kotlin +/** + * Reset SDK state. Clears all initialization state and releases resources. + */ +suspend fun reset() + +/** + * Cleanup SDK resources without full reset. + */ +suspend fun cleanup() +``` + +### SDKEnvironment + +```kotlin +enum class SDKEnvironment { + DEVELOPMENT, // Debug logging, local testing + STAGING, // Info logging, staging backend + PRODUCTION // Warning logging only, production backend +} +``` + +--- + +## Text Generation (LLM) + +Extension functions for text generation using Large Language Models. + +### Basic Generation + +```kotlin +/** + * Simple text generation. + * + * @param prompt The text prompt + * @return Generated response text + */ +suspend fun RunAnywhere.chat(prompt: String): String + +/** + * Generate text with full metrics. + * + * @param prompt The text prompt + * @param options Generation options (optional) + * @return LLMGenerationResult with text and metrics + */ +suspend fun RunAnywhere.generate( + prompt: String, + options: LLMGenerationOptions? = null +): LLMGenerationResult +``` + +### Streaming Generation + +```kotlin +/** + * Streaming text generation. + * Returns a Flow of tokens for real-time display. + * + * @param prompt The text prompt + * @param options Generation options (optional) + * @return Flow of tokens as they are generated + */ +fun RunAnywhere.generateStream( + prompt: String, + options: LLMGenerationOptions? = null +): Flow + +/** + * Streaming with metrics. + * Returns token stream AND deferred metrics. + * + * @param prompt The text prompt + * @param options Generation options (optional) + * @return LLMStreamingResult with stream and deferred result + */ +suspend fun RunAnywhere.generateStreamWithMetrics( + prompt: String, + options: LLMGenerationOptions? = null +): LLMStreamingResult +``` + +### Generation Control + +```kotlin +/** + * Cancel any ongoing text generation. + */ +fun RunAnywhere.cancelGeneration() +``` + +### LLM Types + +#### LLMGenerationOptions + +```kotlin +data class LLMGenerationOptions( + val maxTokens: Int = 100, + val temperature: Float = 0.8f, + val topP: Float = 1.0f, + val stopSequences: List = emptyList(), + val streamingEnabled: Boolean = false, + val preferredFramework: InferenceFramework? = null, + val structuredOutput: StructuredOutputConfig? = null, + val systemPrompt: String? = null +) +``` + +#### LLMGenerationResult + +```kotlin +data class LLMGenerationResult( + val text: String, // Generated text + val thinkingContent: String?, // Reasoning content (if model supports) + val inputTokens: Int, // Prompt tokens + val tokensUsed: Int, // Output tokens + val modelUsed: String, // Model ID + val latencyMs: Double, // Total time in ms + val framework: String?, // Framework used + val tokensPerSecond: Double, // Generation speed + val timeToFirstTokenMs: Double?, // TTFT (streaming only) + val thinkingTokens: Int?, // Thinking tokens (if applicable) + val responseTokens: Int // Response tokens +) +``` + +#### LLMStreamingResult + +```kotlin +data class LLMStreamingResult( + val stream: Flow, // Token stream + val result: Deferred // Final metrics +) +``` + +#### LLMConfiguration + +```kotlin +data class LLMConfiguration( + val modelId: String? = null, + val contextLength: Int = 2048, + val temperature: Double = 0.7, + val maxTokens: Int = 100, + val systemPrompt: String? = null, + val streamingEnabled: Boolean = true, + val preferredFramework: InferenceFramework? = null +) +``` + +--- + +## Speech-to-Text (STT) + +Extension functions for speech recognition. + +### Basic Transcription + +```kotlin +/** + * Simple voice transcription using default model. + * + * @param audioData Audio data to transcribe + * @return Transcribed text + */ +suspend fun RunAnywhere.transcribe(audioData: ByteArray): String +``` + +### Model Management + +```kotlin +/** + * Load an STT model. + * + * @param modelId Model identifier + */ +suspend fun RunAnywhere.loadSTTModel(modelId: String) + +/** + * Unload the currently loaded STT model. + */ +suspend fun RunAnywhere.unloadSTTModel() + +/** + * Check if an STT model is loaded. + */ +suspend fun RunAnywhere.isSTTModelLoaded(): Boolean + +/** + * Get the currently loaded STT model ID (synchronous). + */ +val RunAnywhere.currentSTTModelId: String? + +/** + * Check if STT model is loaded (non-suspend version). + */ +val RunAnywhere.isSTTModelLoadedSync: Boolean +``` + +### Advanced Transcription + +```kotlin +/** + * Transcribe with options. + * + * @param audioData Raw audio data + * @param options Transcription options + * @return STTOutput with text and metadata + */ +suspend fun RunAnywhere.transcribeWithOptions( + audioData: ByteArray, + options: STTOptions +): STTOutput + +/** + * Streaming transcription with callbacks. + * + * @param audioData Audio data to transcribe + * @param options Transcription options + * @param onPartialResult Callback for partial results + * @return Final transcription output + */ +suspend fun RunAnywhere.transcribeStream( + audioData: ByteArray, + options: STTOptions = STTOptions(), + onPartialResult: (STTTranscriptionResult) -> Unit +): STTOutput + +/** + * Process audio samples for streaming transcription. + */ +suspend fun RunAnywhere.processStreamingAudio(samples: FloatArray) + +/** + * Stop streaming transcription. + */ +suspend fun RunAnywhere.stopStreamingTranscription() +``` + +### STT Types + +#### STTOptions + +```kotlin +data class STTOptions( + val language: String = "en", + val detectLanguage: Boolean = false, + val enablePunctuation: Boolean = true, + val enableDiarization: Boolean = false, + val maxSpeakers: Int? = null, + val enableTimestamps: Boolean = true, + val vocabularyFilter: List = emptyList(), + val audioFormat: AudioFormat = AudioFormat.PCM, + val sampleRate: Int = 16000, + val preferredFramework: InferenceFramework? = null +) +``` + +#### STTOutput + +```kotlin +data class STTOutput( + val text: String, // Transcribed text + val confidence: Float, // Confidence (0.0-1.0) + val wordTimestamps: List?, // Word-level timing + val detectedLanguage: String?, // Auto-detected language + val alternatives: List?, + val metadata: TranscriptionMetadata, + val timestamp: Long +) +``` + +#### TranscriptionMetadata + +```kotlin +data class TranscriptionMetadata( + val modelId: String, + val processingTime: Double, // Processing time in seconds + val audioLength: Double // Audio length in seconds +) { + val realTimeFactor: Double // processingTime / audioLength +} +``` + +#### WordTimestamp + +```kotlin +data class WordTimestamp( + val word: String, + val startTime: Double, // Start time in seconds + val endTime: Double, // End time in seconds + val confidence: Float +) +``` + +--- + +## Text-to-Speech (TTS) + +Extension functions for speech synthesis. + +### Voice Management + +```kotlin +/** + * Load a TTS voice. + * + * @param voiceId Voice identifier + */ +suspend fun RunAnywhere.loadTTSVoice(voiceId: String) + +/** + * Unload the currently loaded TTS voice. + */ +suspend fun RunAnywhere.unloadTTSVoice() + +/** + * Check if a TTS voice is loaded. + */ +suspend fun RunAnywhere.isTTSVoiceLoaded(): Boolean + +/** + * Get the currently loaded TTS voice ID (synchronous). + */ +val RunAnywhere.currentTTSVoiceId: String? + +/** + * Check if TTS voice is loaded (non-suspend version). + */ +val RunAnywhere.isTTSVoiceLoadedSync: Boolean + +/** + * Get available TTS voices. + */ +suspend fun RunAnywhere.availableTTSVoices(): List +``` + +### Synthesis + +```kotlin +/** + * Synthesize text to speech audio. + * + * @param text Text to synthesize + * @param options Synthesis options + * @return TTSOutput with audio data + */ +suspend fun RunAnywhere.synthesize( + text: String, + options: TTSOptions = TTSOptions() +): TTSOutput + +/** + * Stream synthesis for long text. + * + * @param text Text to synthesize + * @param options Synthesis options + * @param onAudioChunk Callback for each audio chunk + * @return TTSOutput with full audio data + */ +suspend fun RunAnywhere.synthesizeStream( + text: String, + options: TTSOptions = TTSOptions(), + onAudioChunk: (ByteArray) -> Unit +): TTSOutput + +/** + * Stop current TTS synthesis. + */ +suspend fun RunAnywhere.stopSynthesis() +``` + +### Simple Speak API + +```kotlin +/** + * Speak text aloud - handles synthesis and playback. + * + * @param text Text to speak + * @param options Synthesis options + * @return TTSSpeakResult with metadata + */ +suspend fun RunAnywhere.speak( + text: String, + options: TTSOptions = TTSOptions() +): TTSSpeakResult + +/** + * Check if speech is currently playing. + */ +suspend fun RunAnywhere.isSpeaking(): Boolean + +/** + * Stop current speech playback. + */ +suspend fun RunAnywhere.stopSpeaking() +``` + +### TTS Types + +#### TTSOptions + +```kotlin +data class TTSOptions( + val voice: String? = null, + val language: String = "en-US", + val rate: Float = 1.0f, // 0.0 to 2.0 + val pitch: Float = 1.0f, // 0.0 to 2.0 + val volume: Float = 1.0f, // 0.0 to 1.0 + val audioFormat: AudioFormat = AudioFormat.PCM, + val sampleRate: Int = 22050, + val useSSML: Boolean = false +) +``` + +#### TTSOutput + +```kotlin +data class TTSOutput( + val audioData: ByteArray, // Synthesized audio + val format: AudioFormat, // Audio format + val duration: Double, // Duration in seconds + val phonemeTimestamps: List?, + val metadata: TTSSynthesisMetadata, + val timestamp: Long +) { + val audioSizeBytes: Int + val hasPhonemeTimestamps: Boolean +} +``` + +#### TTSSynthesisMetadata + +```kotlin +data class TTSSynthesisMetadata( + val voice: String, + val language: String, + val processingTime: Double, // Processing time in seconds + val characterCount: Int +) { + val charactersPerSecond: Double +} +``` + +#### TTSSpeakResult + +```kotlin +data class TTSSpeakResult( + val duration: Double, // Duration in seconds + val format: AudioFormat, + val audioSizeBytes: Int, + val metadata: TTSSynthesisMetadata, + val timestamp: Long +) +``` + +--- + +## Voice Activity Detection (VAD) + +Extension functions for detecting speech in audio. + +### Detection + +```kotlin +/** + * Detect voice activity in audio data. + * + * @param audioData Audio data to analyze + * @return VADResult with detection info + */ +suspend fun RunAnywhere.detectVoiceActivity(audioData: ByteArray): VADResult + +/** + * Stream VAD results from audio samples. + * + * @param audioSamples Flow of audio samples + * @return Flow of VAD results + */ +fun RunAnywhere.streamVAD(audioSamples: Flow): Flow +``` + +### Configuration + +```kotlin +/** + * Configure VAD settings. + * + * @param configuration VAD configuration + */ +suspend fun RunAnywhere.configureVAD(configuration: VADConfiguration) + +/** + * Get current VAD statistics. + */ +suspend fun RunAnywhere.getVADStatistics(): VADStatistics + +/** + * Calibrate VAD with ambient noise. + * + * @param ambientAudioData Audio data of ambient noise + */ +suspend fun RunAnywhere.calibrateVAD(ambientAudioData: ByteArray) + +/** + * Reset VAD state. + */ +suspend fun RunAnywhere.resetVAD() +``` + +### VAD Types + +#### VADConfiguration + +```kotlin +data class VADConfiguration( + val threshold: Float = 0.5f, + val minSpeechDurationMs: Int = 250, + val minSilenceDurationMs: Int = 300, + val sampleRate: Int = 16000, + val frameSizeMs: Int = 30 +) +``` + +#### VADResult + +```kotlin +data class VADResult( + val hasSpeech: Boolean, // Speech detected + val confidence: Float, // Detection confidence + val speechStartMs: Long?, // Speech start time + val speechEndMs: Long?, // Speech end time + val frameIndex: Int, // Audio frame index + val timestamp: Long +) +``` + +--- + +## Voice Agent + +Extension functions for full voice conversation pipelines. + +### Configuration + +```kotlin +/** + * Configure the voice agent. + * + * @param configuration Voice agent configuration + */ +suspend fun RunAnywhere.configureVoiceAgent(configuration: VoiceAgentConfiguration) + +/** + * Get current voice agent component states. + */ +suspend fun RunAnywhere.voiceAgentComponentStates(): VoiceAgentComponentStates + +/** + * Check if voice agent is fully ready. + */ +suspend fun RunAnywhere.isVoiceAgentReady(): Boolean + +/** + * Initialize voice agent with currently loaded models. + */ +suspend fun RunAnywhere.initializeVoiceAgentWithLoadedModels() +``` + +### Voice Processing + +```kotlin +/** + * Process audio through full pipeline (VAD → STT → LLM → TTS). + * + * @param audioData Audio data to process + * @return VoiceAgentResult with full response + */ +suspend fun RunAnywhere.processVoice(audioData: ByteArray): VoiceAgentResult +``` + +### Voice Session + +```kotlin +/** + * Start a voice session. + * Returns a Flow of voice session events. + * + * @param config Session configuration + * @return Flow of VoiceSessionEvent + */ +fun RunAnywhere.startVoiceSession( + config: VoiceSessionConfig = VoiceSessionConfig.DEFAULT +): Flow + +/** + * Stop the current voice session. + */ +suspend fun RunAnywhere.stopVoiceSession() + +/** + * Check if a voice session is active. + */ +suspend fun RunAnywhere.isVoiceSessionActive(): Boolean +``` + +### Conversation History + +```kotlin +/** + * Clear the voice agent conversation history. + */ +suspend fun RunAnywhere.clearVoiceConversation() + +/** + * Set the system prompt for LLM responses. + * + * @param prompt System prompt text + */ +suspend fun RunAnywhere.setVoiceSystemPrompt(prompt: String) +``` + +### Voice Agent Types + +#### VoiceAgentConfiguration + +```kotlin +data class VoiceAgentConfiguration( + val sttModelId: String, + val llmModelId: String, + val ttsVoiceId: String, + val systemPrompt: String? = null, + val vadConfiguration: VADConfiguration? = null, + val interruptionEnabled: Boolean = true +) +``` + +#### VoiceSessionEvent + +```kotlin +sealed class VoiceSessionEvent { + /** Session started and ready */ + data object Started : VoiceSessionEvent() + + /** Listening for speech with current audio level (0.0 - 1.0) */ + data class Listening(val audioLevel: Float) : VoiceSessionEvent() + + /** Speech detected, started accumulating audio */ + data object SpeechStarted : VoiceSessionEvent() + + /** Speech ended, processing audio */ + data object Processing : VoiceSessionEvent() + + /** Got transcription from STT */ + data class Transcribed(val text: String) : VoiceSessionEvent() + + /** Got response from LLM */ + data class Responded(val text: String) : VoiceSessionEvent() + + /** Playing TTS audio */ + data object Speaking : VoiceSessionEvent() + + /** Complete turn result with transcript, response, and audio */ + data class TurnCompleted( + val transcript: String, + val response: String, + val audio: ByteArray? + ) : VoiceSessionEvent() + + /** Session stopped */ + data object Stopped : VoiceSessionEvent() + + /** Error occurred */ + data class Error(val message: String) : VoiceSessionEvent() +} +``` + +#### VoiceAgentResult + +```kotlin +data class VoiceAgentResult( + /** Whether speech was detected in the input audio */ + val speechDetected: Boolean = false, + /** Transcribed text from STT */ + val transcription: String? = null, + /** Generated response text from LLM */ + val response: String? = null, + /** Synthesized audio data from TTS (WAV format) */ + val synthesizedAudio: ByteArray? = null +) +``` + +--- + +## Model Management + +Extension functions for model registration, download, and lifecycle. + +### Model Registration + +```kotlin +/** + * Register a model from a download URL. + * + * @param id Explicit model ID (optional, generated from URL if null) + * @param name Display name for the model + * @param url Download URL + * @param framework Target inference framework + * @param modality Model category (default: LANGUAGE) + * @param artifactType How model is packaged (inferred if null) + * @param memoryRequirement Estimated memory in bytes + * @param supportsThinking Whether model supports reasoning + * @return Created ModelInfo + */ +fun RunAnywhere.registerModel( + id: String? = null, + name: String, + url: String, + framework: InferenceFramework, + modality: ModelCategory = ModelCategory.LANGUAGE, + artifactType: ModelArtifactType? = null, + memoryRequirement: Long? = null, + supportsThinking: Boolean = false +): ModelInfo +``` + +### Model Discovery + +```kotlin +/** + * Get all available models. + */ +suspend fun RunAnywhere.availableModels(): List + +/** + * Get models by category. + * + * @param category Model category to filter by + */ +suspend fun RunAnywhere.models(category: ModelCategory): List + +/** + * Get downloaded models only. + */ +suspend fun RunAnywhere.downloadedModels(): List + +/** + * Get model info by ID. + * + * @param modelId Model identifier + * @return ModelInfo or null if not found + */ +suspend fun RunAnywhere.model(modelId: String): ModelInfo? +``` + +### Model Downloads + +```kotlin +/** + * Download a model. + * + * @param modelId Model identifier + * @return Flow of DownloadProgress + */ +fun RunAnywhere.downloadModel(modelId: String): Flow + +/** + * Cancel a model download. + * + * @param modelId Model identifier + */ +suspend fun RunAnywhere.cancelDownload(modelId: String) + +/** + * Check if a model is downloaded. + * + * @param modelId Model identifier + */ +suspend fun RunAnywhere.isModelDownloaded(modelId: String): Boolean +``` + +### Model Lifecycle + +```kotlin +/** + * Delete a downloaded model. + */ +suspend fun RunAnywhere.deleteModel(modelId: String) + +/** + * Delete all downloaded models. + */ +suspend fun RunAnywhere.deleteAllModels() + +/** + * Refresh the model registry from remote. + */ +suspend fun RunAnywhere.refreshModelRegistry() +``` + +### LLM Model Loading + +```kotlin +/** + * Load an LLM model. + */ +suspend fun RunAnywhere.loadLLMModel(modelId: String) + +/** + * Unload the currently loaded LLM model. + */ +suspend fun RunAnywhere.unloadLLMModel() + +/** + * Check if an LLM model is loaded. + */ +suspend fun RunAnywhere.isLLMModelLoaded(): Boolean + +/** + * Get the currently loaded LLM model ID (synchronous). + */ +val RunAnywhere.currentLLMModelId: String? + +/** + * Get the currently loaded LLM model info. + */ +suspend fun RunAnywhere.currentLLMModel(): ModelInfo? + +/** + * Get the currently loaded STT model info. + */ +suspend fun RunAnywhere.currentSTTModel(): ModelInfo? +``` + +### Model Types + +#### ModelInfo + +```kotlin +data class ModelInfo( + val id: String, + val name: String, + val category: ModelCategory, + val format: ModelFormat, + val downloadURL: String?, + var localPath: String?, + val artifactType: ModelArtifactType, + val downloadSize: Long?, + val framework: InferenceFramework, + val contextLength: Int?, + val supportsThinking: Boolean, + val thinkingPattern: ThinkingTagPattern?, + val description: String?, + val source: ModelSource, + val createdAt: Long, + var updatedAt: Long +) { + val isDownloaded: Boolean + val isAvailable: Boolean + val isBuiltIn: Boolean +} +``` + +#### DownloadProgress + +```kotlin +data class DownloadProgress( + val modelId: String, + val progress: Float, // 0.0 to 1.0 + val bytesDownloaded: Long, + val totalBytes: Long?, + val state: DownloadState, + val error: String? +) + +enum class DownloadState { + PENDING, DOWNLOADING, EXTRACTING, COMPLETED, ERROR, CANCELLED +} +``` + +#### ModelCategory + +```kotlin +enum class ModelCategory { + LANGUAGE, // LLMs (text-to-text) + SPEECH_RECOGNITION, // STT (voice-to-text) + SPEECH_SYNTHESIS, // TTS (text-to-voice) + VISION, // Image understanding + IMAGE_GENERATION, // Text-to-image + MULTIMODAL, // Multiple modalities + AUDIO // Audio processing +} +``` + +#### ModelFormat + +```kotlin +enum class ModelFormat { + ONNX, // ONNX Runtime format + ORT, // Optimized ONNX Runtime + GGUF, // llama.cpp format + BIN, // Generic binary + UNKNOWN +} +``` + +--- + +## Event System + +### EventBus + +```kotlin +object EventBus { + val allEvents: SharedFlow + val llmEvents: SharedFlow + val sttEvents: SharedFlow + val ttsEvents: SharedFlow + val modelEvents: SharedFlow + val errorEvents: SharedFlow +} +``` + +### Event Types + +#### SDKEvent (Interface) + +```kotlin +interface SDKEvent { + val id: String + val type: String + val category: EventCategory + val timestamp: Long + val sessionId: String? + val destination: EventDestination + val properties: Map +} +``` + +#### LLMEvent + +```kotlin +data class LLMEvent( + val eventType: LLMEventType, + val modelId: String?, + val tokensGenerated: Int?, + val latencyMs: Double?, + val error: String? +) : SDKEvent + +enum class LLMEventType { + GENERATION_STARTED, GENERATION_COMPLETED, GENERATION_FAILED, + STREAM_TOKEN, STREAM_COMPLETED +} +``` + +#### STTEvent + +```kotlin +data class STTEvent( + val eventType: STTEventType, + val modelId: String?, + val transcript: String?, + val confidence: Float?, + val error: String? +) : SDKEvent + +enum class STTEventType { + TRANSCRIPTION_STARTED, TRANSCRIPTION_COMPLETED, TRANSCRIPTION_FAILED, + PARTIAL_RESULT +} +``` + +#### TTSEvent + +```kotlin +data class TTSEvent( + val eventType: TTSEventType, + val voice: String?, + val durationMs: Double?, + val error: String? +) : SDKEvent + +enum class TTSEventType { + SYNTHESIS_STARTED, SYNTHESIS_COMPLETED, SYNTHESIS_FAILED, + PLAYBACK_STARTED, PLAYBACK_COMPLETED +} +``` + +#### ModelEvent + +```kotlin +data class ModelEvent( + val eventType: ModelEventType, + val modelId: String, + val progress: Float?, + val error: String? +) : SDKEvent + +enum class ModelEventType { + DOWNLOAD_STARTED, DOWNLOAD_PROGRESS, DOWNLOAD_COMPLETED, DOWNLOAD_FAILED, + LOADED, UNLOADED, DELETED +} +``` + +--- + +## Types & Enums + +### InferenceFramework + +```kotlin +enum class InferenceFramework { + ONNX, // ONNX Runtime (STT/TTS/VAD) + LLAMA_CPP, // llama.cpp (LLM) + FOUNDATION_MODELS, // Platform foundation models + SYSTEM_TTS, // System text-to-speech + FLUID_AUDIO, // FluidAudio engine + BUILT_IN, // Simple built-in services + NONE, // No model needed + UNKNOWN +} +``` + +### SDKComponent + +```kotlin +enum class SDKComponent { + LLM, // Language Model + STT, // Speech to Text + TTS, // Text to Speech + VAD, // Voice Activity Detection + VOICE, // Voice Agent + EMBEDDING // Embedding model +} +``` + +### AudioFormat + +```kotlin +enum class AudioFormat { + PCM, WAV, MP3, AAC, OGG, OPUS, FLAC +} +``` + +--- + +## Error Handling + +### SDKError + +```kotlin +data class SDKError( + val code: ErrorCode, + val category: ErrorCategory, + override val message: String, + override val cause: Throwable? +) : Exception(message, cause) +``` + +### Error Factory Methods + +```kotlin +// General +SDKError.general(message, code?, cause?) +SDKError.unknown(message, cause?) + +// Initialization +SDKError.notInitialized(component, cause?) +SDKError.alreadyInitialized(component, cause?) + +// Model +SDKError.modelNotFound(modelId, cause?) +SDKError.modelNotLoaded(modelId?, cause?) +SDKError.modelLoadFailed(modelId, reason?, cause?) + +// LLM +SDKError.llm(message, code?, cause?) +SDKError.llmGenerationFailed(reason?, cause?) + +// STT +SDKError.stt(message, code?, cause?) +SDKError.sttTranscriptionFailed(reason?, cause?) + +// TTS +SDKError.tts(message, code?, cause?) +SDKError.ttsSynthesisFailed(reason?, cause?) + +// VAD +SDKError.vad(message, code?, cause?) +SDKError.vadDetectionFailed(reason?, cause?) + +// Network +SDKError.network(message, code?, cause?) +SDKError.networkUnavailable(cause?) +SDKError.timeout(operation, timeoutMs?, cause?) + +// Download +SDKError.downloadFailed(url, reason?, cause?) +SDKError.downloadCancelled(url, cause?) + +// Storage +SDKError.insufficientStorage(requiredBytes?, cause?) +SDKError.fileNotFound(path, cause?) + +// From C++ error codes +SDKError.fromRawValue(rawValue, message?, cause?) +SDKError.fromErrorCode(errorCode, message?, cause?) +``` + +### ErrorCategory + +```kotlin +enum class ErrorCategory { + GENERAL, CONFIGURATION, INITIALIZATION, FILE_RESOURCE, MEMORY, + STORAGE, OPERATION, NETWORK, MODEL, PLATFORM, LLM, STT, TTS, + VAD, VOICE_AGENT, DOWNLOAD, AUTHENTICATION +} +``` + +### ErrorCode + +Common error codes include: +- `SUCCESS`, `UNKNOWN`, `INVALID_ARGUMENT` +- `NOT_INITIALIZED`, `ALREADY_INITIALIZED` +- `MODEL_NOT_FOUND`, `MODEL_NOT_LOADED`, `MODEL_LOAD_FAILED` +- `LLM_GENERATION_FAILED`, `STT_TRANSCRIPTION_FAILED`, `TTS_SYNTHESIS_FAILED` +- `NETWORK_ERROR`, `NETWORK_UNAVAILABLE`, `TIMEOUT` +- `DOWNLOAD_FAILED`, `DOWNLOAD_CANCELLED` +- `INSUFFICIENT_STORAGE`, `FILE_NOT_FOUND`, `OUT_OF_MEMORY` + +--- + +## Usage Examples + +### Complete LLM Chat (Matching Starter App) + +```kotlin +import com.runanywhere.sdk.public.RunAnywhere +import com.runanywhere.sdk.public.SDKEnvironment +import com.runanywhere.sdk.public.extensions.* +import com.runanywhere.sdk.public.extensions.Models.ModelCategory +import com.runanywhere.sdk.core.types.InferenceFramework + +// Initialize +RunAnywhere.initialize(environment = SDKEnvironment.DEVELOPMENT) + +// Register model (same as starter app) +RunAnywhere.registerModel( + id = "smollm2-360m-instruct-q8_0", + name = "SmolLM2 360M Instruct Q8_0", + url = "https://huggingface.co/HuggingFaceTB/SmolLM2-360M-Instruct-GGUF/resolve/main/smollm2-360m-instruct-q8_0.gguf", + framework = InferenceFramework.LLAMA_CPP, + modality = ModelCategory.LANGUAGE, + memoryRequirement = 400_000_000 +) + +// Download model +RunAnywhere.downloadModel("smollm2-360m-instruct-q8_0") + .catch { e -> println("Download failed: ${e.message}") } + .collect { progress -> + println("Download: ${(progress.progress * 100).toInt()}%") + } + +// Load and use +RunAnywhere.loadLLMModel("smollm2-360m-instruct-q8_0") + +// Simple chat (returns String) +val response = RunAnywhere.chat("Explain AI in simple terms") +println("Response: $response") + +// Cleanup +RunAnywhere.unloadLLMModel() +``` + +### Complete STT Example (Matching Starter App) + +```kotlin +import com.runanywhere.sdk.public.RunAnywhere +import com.runanywhere.sdk.public.extensions.* + +// Register STT model +RunAnywhere.registerModel( + id = "sherpa-onnx-whisper-tiny.en", + name = "Sherpa Whisper Tiny (ONNX)", + url = "https://github.com/RunanywhereAI/sherpa-onnx/releases/download/runanywhere-models-v1/sherpa-onnx-whisper-tiny.en.tar.gz", + framework = InferenceFramework.ONNX, + modality = ModelCategory.SPEECH_RECOGNITION +) + +// Download and load +RunAnywhere.downloadModel("sherpa-onnx-whisper-tiny.en").collect { progress -> + println("Download: ${(progress.progress * 100).toInt()}%") +} +RunAnywhere.loadSTTModel("sherpa-onnx-whisper-tiny.en") + +// Transcribe audio (16kHz, mono, 16-bit PCM) +val transcription = RunAnywhere.transcribe(audioData) +println("You said: $transcription") +``` + +### Complete TTS Example (Matching Starter App) + +```kotlin +import com.runanywhere.sdk.public.RunAnywhere +import com.runanywhere.sdk.public.extensions.* +import com.runanywhere.sdk.public.extensions.TTS.TTSOptions + +// Register TTS model +RunAnywhere.registerModel( + id = "vits-piper-en_US-lessac-medium", + name = "Piper TTS (US English - Medium)", + url = "https://github.com/RunanywhereAI/sherpa-onnx/releases/download/runanywhere-models-v1/vits-piper-en_US-lessac-medium.tar.gz", + framework = InferenceFramework.ONNX, + modality = ModelCategory.SPEECH_SYNTHESIS +) + +// Download and load +RunAnywhere.downloadModel("vits-piper-en_US-lessac-medium").collect { progress -> + println("Download: ${(progress.progress * 100).toInt()}%") +} +RunAnywhere.loadTTSVoice("vits-piper-en_US-lessac-medium") + +// Synthesize audio +val output = RunAnywhere.synthesize("Hello, world!", TTSOptions()) +// output.audioData contains WAV audio bytes + +// Play with playWavAudio() helper (see Voice Pipeline section) +playWavAudio(output.audioData) +``` + +### Voice Pipeline Session (Matching Starter App) + +```kotlin +import com.runanywhere.sdk.public.RunAnywhere +import com.runanywhere.sdk.public.extensions.* +import com.runanywhere.sdk.public.extensions.VoiceAgent.VoiceSessionConfig +import com.runanywhere.sdk.public.extensions.VoiceAgent.VoiceSessionEvent + +// Ensure all 3 models are loaded +val allModelsLoaded = RunAnywhere.isLLMModelLoaded() && + RunAnywhere.isSTTModelLoaded() && + RunAnywhere.isTTSVoiceLoaded() + +if (allModelsLoaded) { + // Create audio capture flow + val audioCaptureService = AudioCaptureService() + val audioChunks = audioCaptureService.startCapture() + + // Configure and start session + val config = VoiceSessionConfig( + silenceDuration = 1.5, + speechThreshold = 0.1f, + autoPlayTTS = false, + continuousMode = true + ) + + scope.launch { + RunAnywhere.streamVoiceSession(audioChunks, config).collect { event -> + when (event) { + is VoiceSessionEvent.Listening -> updateAudioLevel(event.audioLevel) + is VoiceSessionEvent.SpeechStarted -> showSpeechDetected() + is VoiceSessionEvent.Processing -> showProcessing() + is VoiceSessionEvent.Transcribed -> showTranscript(event.text) + is VoiceSessionEvent.Responded -> showResponse(event.text) + is VoiceSessionEvent.TurnCompleted -> { + event.audio?.let { playWavAudio(it) } + } + is VoiceSessionEvent.Error -> showError(event.message) + else -> { } + } + } + } +} +``` + +--- + +## See Also + +- [README.md](./README.md) - Getting started guide +- [ARCHITECTURE.md](./ARCHITECTURE.md) - SDK architecture details +- [Sample App](../../examples/android/RunAnywhereAI/) - Working example diff --git a/sdk/runanywhere-kotlin/docs/KOTLIN_MAVEN_CENTRAL_PUBLISHING.md b/sdk/runanywhere-kotlin/docs/KOTLIN_MAVEN_CENTRAL_PUBLISHING.md new file mode 100644 index 000000000..edead0e53 --- /dev/null +++ b/sdk/runanywhere-kotlin/docs/KOTLIN_MAVEN_CENTRAL_PUBLISHING.md @@ -0,0 +1,189 @@ +# Kotlin SDK - Maven Central Publishing Guide + +Quick reference for publishing RunAnywhere Kotlin SDK to Maven Central. + +--- + +## Published Artifacts + +| Artifact | Description | +|----------|-------------| +| `io.github.sanchitmonga22:runanywhere-sdk-android` | Core SDK (AAR with native libs) | +| `io.github.sanchitmonga22:runanywhere-llamacpp-android` | LLM backend (AAR with native libs) | +| `io.github.sanchitmonga22:runanywhere-onnx-android` | ONNX/STT/TTS backend (AAR with native libs) | +| `io.github.sanchitmonga22:runanywhere-sdk` | KMP metadata module | +| `io.github.sanchitmonga22:runanywhere-llamacpp` | LlamaCPP KMP metadata | +| `io.github.sanchitmonga22:runanywhere-onnx` | ONNX KMP metadata | + +--- + +## Quick Release (CI/CD) + +1. Go to **GitHub Actions** → **Publish to Maven Central** +2. Click **Run workflow** +3. Enter version (e.g., `0.17.5`) +4. Click **Run workflow** +5. Monitor progress, then verify on [central.sonatype.com](https://central.sonatype.com/search?q=io.github.sanchitmonga22) + +--- + +## Local Release + +### 1. Prerequisites + +#### Android SDK +Create `local.properties` in the SDK root if it doesn't exist: +```properties +sdk.dir=/Users/YOUR_USERNAME/Library/Android/sdk +``` + +Or set the environment variable: +```bash +export ANDROID_HOME="$HOME/Library/Android/sdk" +``` + +#### GPG Key Import +If you have a base64-encoded GPG key, import it: +```bash +echo "" | base64 -d | gpg --batch --import + +# Verify import +gpg --list-secret-keys --keyid-format LONG +``` + +### 2. Setup Credentials (One-Time) + +Add signing config to `~/.gradle/gradle.properties`: +```properties +signing.gnupg.executable=gpg +signing.gnupg.useLegacyGpg=false +signing.gnupg.keyName=YOUR_GPG_KEY_ID +signing.gnupg.passphrase=YOUR_GPG_PASSPHRASE +``` + +### 3. Download Native Libraries + +**Important:** Native libraries must be downloaded before publishing. Use a version that has Android binaries released on GitHub. + +```bash +cd sdk/runanywhere-kotlin + +# Check available releases with Android binaries +curl -s "https://api.github.com/repos/RunanywhereAI/runanywhere-sdks/releases" | grep -E '"tag_name"|"name"' | head -20 + +# Download native libs (use version with Android binaries, e.g., 0.17.4) +./gradlew downloadJniLibs -Prunanywhere.testLocal=false -Prunanywhere.nativeLibVersion=0.17.4 + +# Verify download (should show 36 .so files across 3 ABIs) +ls -la src/androidMain/jniLibs/*/ +``` + +### 4. Publish + +```bash +cd sdk/runanywhere-kotlin + +# Set environment variables +export SDK_VERSION=0.17.5 +export MAVEN_CENTRAL_USERNAME="" +export MAVEN_CENTRAL_PASSWORD="" +export ANDROID_HOME="$HOME/Library/Android/sdk" + +# Publish all modules (single command publishes everything) +./gradlew publishAllPublicationsToMavenCentralRepository \ + -Prunanywhere.testLocal=false \ + -Prunanywhere.nativeLibVersion=0.17.4 \ + --no-daemon +``` + +### 5. Verify + +1. Check [central.sonatype.com](https://central.sonatype.com/search?q=io.github.sanchitmonga22) (may take 30 min to sync) +2. Verify native libs are in the AAR: + ```bash + unzip -l build/outputs/aar/RunAnywhereKotlinSDK-release.aar | grep "\.so$" + ``` + +--- + +## Native Library Notes + +### Version Mapping +- SDK version and native lib version can differ +- Native libs are downloaded from GitHub releases +- Use `nativeLibVersion` flag to specify which release to use +- Check GitHub releases to find versions with Android binaries (`RACommons-android-*.zip`) + +### 16KB Page Alignment (Android 15+) +Verify native libraries support 16KB page sizes: +```bash +for so in src/androidMain/jniLibs/arm64-v8a/*.so; do + name=$(basename "$so") + alignment=$(objdump -p "$so" 2>/dev/null | grep -A1 "LOAD" | grep -oE "align 2\*\*[0-9]+" | head -1 | grep -oE "[0-9]+$") + page_size=$((2**alignment)) + if [ "$page_size" -ge 16384 ]; then + echo "✅ $name: 16KB aligned" + else + echo "❌ $name: NOT 16KB aligned ($page_size bytes)" + fi +done +``` + +--- + +## GitHub Secrets Required + +| Secret | Description | +|--------|-------------| +| `MAVEN_CENTRAL_USERNAME` | Sonatype Central Portal token username | +| `MAVEN_CENTRAL_PASSWORD` | Sonatype Central Portal token | +| `GPG_KEY_ID` | Last 16 chars of GPG key fingerprint (e.g., `CC377A9928C7BB18`) | +| `GPG_SIGNING_KEY` | Base64-encoded full armored GPG private key | +| `GPG_SIGNING_PASSWORD` | GPG key passphrase | + +### Exporting GPG Key for CI +```bash +# Export and base64 encode for GitHub secrets +gpg --armor --export-secret-keys YOUR_KEY_ID | base64 +``` + +--- + +## Consumer Usage + +```kotlin +// settings.gradle.kts +repositories { + mavenCentral() +} + +// build.gradle.kts +dependencies { + implementation("io.github.sanchitmonga22:runanywhere-sdk-android:0.17.5") + // Optional modules: + // implementation("io.github.sanchitmonga22:runanywhere-llamacpp-android:0.17.5") + // implementation("io.github.sanchitmonga22:runanywhere-onnx-android:0.17.5") +} +``` + +--- + +## Troubleshooting + +| Error | Fix | +|-------|-----| +| GPG signature verification failed | Upload key to `keys.openpgp.org` AND verify email | +| 403 Forbidden | Verify namespace at central.sonatype.com | +| Missing native libs in AAR | Run `downloadJniLibs` task with correct `nativeLibVersion` | +| SDK location not found | Create `local.properties` with `sdk.dir` or set `ANDROID_HOME` | +| JNI download fails | Check GitHub releases exist for that version with Android binaries | +| 16KB alignment issues | Rebuild native libs with `-Wl,-z,max-page-size=16384` linker flag | + +--- + +## Key URLs + +- **Central Portal**: https://central.sonatype.com +- **Search Artifacts**: https://central.sonatype.com/search?q=io.github.sanchitmonga22 +- **GPG Keyserver**: https://keys.openpgp.org +- **GitHub Releases**: https://github.com/RunanywhereAI/runanywhere-sdks/releases diff --git a/sdk/runanywhere-kotlin/gradle.properties b/sdk/runanywhere-kotlin/gradle.properties new file mode 100644 index 000000000..8f049d904 --- /dev/null +++ b/sdk/runanywhere-kotlin/gradle.properties @@ -0,0 +1,54 @@ +# AndroidX Properties +android.useAndroidX=true +android.enableJetifier=true + +# Build optimizations +org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8 +org.gradle.parallel=true +org.gradle.caching=true +org.gradle.configureondemand=true + +# Kotlin +kotlin.code.style=official + +# KMP configuration +kotlin.mpp.applyDefaultHierarchyTemplate=false + +# ============================================================================= +# RunAnywhere Native Library Configuration +# ============================================================================= +# This mirrors Swift SDK's Package.swift testLocal pattern: +# +# testLocal = true → Use locally built JNI libs from src/androidMain/jniLibs/ +# First-time setup: ./scripts/build-kotlin.sh --setup +# +# testLocal = false → Download pre-built JNI libs from GitHub releases (default) +# Downloads from: https://github.com/RunanywhereAI/runanywhere-sdks +# +# rebuildCommons = true → Force rebuild of C++ code (use after C++ changes) +# +# To switch modes: +# ./gradlew -Prunanywhere.testLocal=true assembleDebug # Use local builds +# ./gradlew -Prunanywhere.testLocal=true assembleDebug # Use remote releases +# ./gradlew -Prunanywhere.rebuildCommons=true assembleDebug # Force C++ rebuild +# +# Gradle tasks for local development: +# ./gradlew setupLocalDevelopment # First-time setup (downloads + builds + copies) +# ./gradlew rebuildCommons # Rebuild C++ after changes +# ============================================================================= +runanywhere.testLocal=true + +# Force rebuild of runanywhere-commons C++ code (default: false) +# Set to true when you've made changes to C++ source files +runanywhere.rebuildCommons=false + +# Version of runanywhere-core release for JNI downloads (when testLocal=false) +# Must match a GitHub release tag at: https://github.com/RunanywhereAI/runanywhere-binaries/releases +# Contains: RABackendLlamaCPP-android, RABackendONNX-android +# This matches Swift's coreVersion in Package.swift +runanywhere.coreVersion=0.1.4 + +# Version of runanywhere-commons release for JNI downloads (when testLocal=false) +# Must match a GitHub release tag at: https://github.com/RunanywhereAI/runanywhere-sdks/releases +# Contains: RACommons-android (librac_commons.so, librac_commons_jni.so) +runanywhere.commonsVersion=0.1.4 diff --git a/sdk/runanywhere-kotlin/gradle.properties.example b/sdk/runanywhere-kotlin/gradle.properties.example new file mode 100644 index 000000000..bd458d995 --- /dev/null +++ b/sdk/runanywhere-kotlin/gradle.properties.example @@ -0,0 +1,19 @@ +# AndroidX Properties +android.useAndroidX=true +android.enableJetifier=true + +# Build optimizations +org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8 +org.gradle.parallel=true +org.gradle.caching=true +org.gradle.configureondemand=true + +# Kotlin +kotlin.code.style=official + +# KMP configuration +kotlin.mpp.applyDefaultHierarchyTemplate=false +kotlin.mpp.androidGradlePluginCompatibility.nowarn=true + +# To set Java home, add this line with your local path: +# org.gradle.java.home=/path/to/your/jdk17 diff --git a/sdk/runanywhere-kotlin/gradle/maven-central-publish.gradle.kts b/sdk/runanywhere-kotlin/gradle/maven-central-publish.gradle.kts new file mode 100644 index 000000000..908fa5405 --- /dev/null +++ b/sdk/runanywhere-kotlin/gradle/maven-central-publish.gradle.kts @@ -0,0 +1,96 @@ +/** + * Maven Central Publishing Configuration + * + * This script configures publishing to Maven Central (Sonatype) for the RunAnywhere SDK. + * + * Usage: + * 1. Set up Sonatype account at https://central.sonatype.com + * 2. Verify namespace (com.runanywhere or io.github.runanywhereai) + * 3. Generate GPG signing key + * 4. Configure secrets in CI or local gradle.properties + * + * Local testing: + * ./gradlew publishToMavenLocal + * + * Publish to Maven Central: + * ./gradlew publishToMavenCentral + */ + +// Apply signing plugin +apply(plugin = "signing") + +// Get publishing credentials from environment or gradle.properties +val mavenCentralUsername: String? = System.getenv("MAVEN_CENTRAL_USERNAME") + ?: project.findProperty("mavenCentral.username") as String? +val mavenCentralPassword: String? = System.getenv("MAVEN_CENTRAL_PASSWORD") + ?: project.findProperty("mavenCentral.password") as String? + +// GPG signing configuration +val signingKeyId: String? = System.getenv("GPG_KEY_ID") + ?: project.findProperty("signing.keyId") as String? +val signingPassword: String? = System.getenv("GPG_SIGNING_PASSWORD") + ?: project.findProperty("signing.password") as String? +val signingKey: String? = System.getenv("GPG_SIGNING_KEY") + ?: project.findProperty("signing.key") as String? + +// Determine if we should sign (required for Maven Central) +val shouldSign = signingKey != null || signingKeyId != null + +configure { + repositories { + // Maven Central (Sonatype) + maven { + name = "MavenCentral" + url = uri("https://central.sonatype.com/api/v1/publisher/deployments/download") + + credentials { + username = mavenCentralUsername + password = mavenCentralPassword + } + } + + // Sonatype staging repository (for release workflow) + maven { + name = "SonatypeStaging" + url = uri("https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/") + + credentials { + username = mavenCentralUsername + password = mavenCentralPassword + } + } + + // GitHub Packages (backup) + maven { + name = "GitHubPackages" + url = uri("https://maven.pkg.github.com/RunanywhereAI/runanywhere-sdks") + + credentials { + username = System.getenv("GITHUB_ACTOR") ?: project.findProperty("gpr.user") as String? + password = System.getenv("GITHUB_TOKEN") ?: project.findProperty("gpr.token") as String? + } + } + } +} + +// Configure signing for all publications +if (shouldSign) { + configure { + if (signingKey != null) { + // Use in-memory key (from CI environment) + useInMemoryPgpKeys(signingKeyId, signingKey, signingPassword) + } else { + // Use local GPG agent + useGpgCmd() + } + + // Sign all publications + sign(the().publications) + } +} + +// Log configuration for debugging +logger.lifecycle("Maven Central publishing configured:") +logger.lifecycle(" - Username: ${if (mavenCentralUsername != null) "✓" else "✗"}") +logger.lifecycle(" - Password: ${if (mavenCentralPassword != null) "✓" else "✗"}") +logger.lifecycle(" - Signing: ${if (shouldSign) "✓" else "✗ (artifacts will not be signed)"}") diff --git a/sdk/runanywhere-kotlin/gradle/wrapper/gradle-wrapper.jar b/sdk/runanywhere-kotlin/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..1b33c55ba Binary files /dev/null and b/sdk/runanywhere-kotlin/gradle/wrapper/gradle-wrapper.jar differ diff --git a/sdk/runanywhere-kotlin/gradle/wrapper/gradle-wrapper.properties b/sdk/runanywhere-kotlin/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..37f853b1c --- /dev/null +++ b/sdk/runanywhere-kotlin/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/sdk/runanywhere-kotlin/gradlew b/sdk/runanywhere-kotlin/gradlew new file mode 100755 index 000000000..faf93008b --- /dev/null +++ b/sdk/runanywhere-kotlin/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/sdk/runanywhere-kotlin/gradlew.bat b/sdk/runanywhere-kotlin/gradlew.bat new file mode 100644 index 000000000..9d21a2183 --- /dev/null +++ b/sdk/runanywhere-kotlin/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/sdk/runanywhere-kotlin/lint.xml b/sdk/runanywhere-kotlin/lint.xml new file mode 100644 index 000000000..b646c9065 --- /dev/null +++ b/sdk/runanywhere-kotlin/lint.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TODOs must reference a GitHub issue number (e.g., // TODO: #123 - Description) + + diff --git a/sdk/runanywhere-kotlin/modules/runanywhere-core-llamacpp/README.md b/sdk/runanywhere-kotlin/modules/runanywhere-core-llamacpp/README.md new file mode 100644 index 000000000..9bc9beaa8 --- /dev/null +++ b/sdk/runanywhere-kotlin/modules/runanywhere-core-llamacpp/README.md @@ -0,0 +1,277 @@ +# RunAnywhere Core LlamaCPP Module + +**LLM inference backend for the RunAnywhere Kotlin SDK** — powered by [llama.cpp](https://github.com/ggerganov/llama.cpp) for on-device text generation. + +[![Maven Central](https://img.shields.io/maven-central/v/com.runanywhere.sdk/runanywhere-core-llamacpp?label=Maven%20Central)](https://search.maven.org/artifact/com.runanywhere.sdk/runanywhere-core-llamacpp) +[![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) +[![Platform: Android](https://img.shields.io/badge/Platform-Android%207.0%2B-green)](https://developer.android.com) + +--- + +## Features + +This module provides the **LLM (Large Language Model)** backend, enabling on-device text generation using the industry-standard llama.cpp library. It's optimized for mobile devices with support for quantized models (GGUF format). + +**This module is optional.** Only include it if your app needs LLM/text generation capabilities. + +- **On-Device LLM Inference** — Run language models locally without network +- **GGUF Model Support** — Compatible with quantized models from HuggingFace +- **Streaming Generation** — Token-by-token output for responsive UX +- **Multiple Quantization Levels** — Q4, Q5, Q8 for memory/quality tradeoffs +- **Thinking/Reasoning Models** — Support for models with reasoning capabilities +- **ARM64 Optimized** — Native performance on modern Android devices + +--- + +## Installation + +Add to your module's `build.gradle.kts`: + +```kotlin +dependencies { + // Core SDK (required) + implementation("com.runanywhere.sdk:runanywhere-kotlin:0.1.4") + + // LlamaCPP backend (this module) + implementation("com.runanywhere.sdk:runanywhere-core-llamacpp:0.1.4") +} +``` + +--- + +## Usage + +Once included, the module automatically registers the `LLAMA_CPP` framework with the SDK. + +### Register a Model + +```kotlin +import com.runanywhere.sdk.public.RunAnywhere +import com.runanywhere.sdk.public.extensions.* +import com.runanywhere.sdk.core.types.InferenceFramework + +val model = RunAnywhere.registerModel( + name = "Qwen 0.5B Instruct", + url = "https://huggingface.co/Qwen/Qwen2.5-0.5B-Instruct-GGUF/resolve/main/qwen2.5-0.5b-instruct-q8_0.gguf", + framework = InferenceFramework.LLAMA_CPP +) +``` + +### Download & Load + +```kotlin +// Download model +RunAnywhere.downloadModel(model.id).collect { progress -> + println("Download: ${(progress.progress * 100).toInt()}%") +} + +// Load into memory +RunAnywhere.loadLLMModel(model.id) +``` + +### Generate Text + +```kotlin +// Simple chat +val response = RunAnywhere.chat("What is 2+2?") +println(response) + +// With options +val result = RunAnywhere.generate( + prompt = "Write a haiku about code", + options = LLMGenerationOptions( + maxTokens = 100, + temperature = 0.8f + ) +) +println("Response: ${result.text}") +println("Speed: ${result.tokensPerSecond} tok/s") +``` + +### Streaming + +```kotlin +RunAnywhere.generateStream("Tell me a story") + .collect { token -> + print(token) // Display tokens in real-time + } +``` + +--- + +## Supported Models + +Any GGUF-format model compatible with llama.cpp. Popular options: + +| Model | Size | Quantization | Use Case | +|-------|------|--------------|----------| +| Qwen2.5-0.5B | ~300MB | Q8_0 | General chat, fast inference | +| Qwen2.5-0.5B | ~200MB | Q4_0 | Memory-constrained devices | +| Qwen2.5-1.5B | ~900MB | Q8_0 | Higher quality responses | +| Llama-3.2-1B | ~600MB | Q8_0 | Meta's latest small model | +| Phi-3-mini | ~2.2GB | Q4_K_M | Microsoft's reasoning model | +| DeepSeek-R1-Distill | ~1.5GB | Q4_K_M | Reasoning/thinking model | + +### HuggingFace URLs + +Models can be downloaded directly from HuggingFace using the `resolve/main` URL pattern: + +``` +https://huggingface.co/{org}/{repo}/resolve/main/{filename}.gguf +``` + +--- + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ RunAnywhere SDK (Kotlin) │ +│ │ +│ RunAnywhere.generate() / chat() / generateStream() │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ runanywhere-core-llamacpp │ +│ │ +│ ┌────────────────────────────────────────────────────────┐ │ +│ │ JNI Bridge (Kotlin ↔ C++) │ │ +│ │ librac_backend_llamacpp_jni.so │ │ +│ └────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ┌────────────────────────────────────────────────────────┐ │ +│ │ librunanywhere_llamacpp.so │ │ +│ │ RunAnywhere llama.cpp wrapper │ │ +│ └────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ┌────────────────────────────────────────────────────────┐ │ +│ │ llama.cpp core │ │ +│ │ libllama.so + libcommon.so │ │ +│ └────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## Native Libraries + +This module bundles the following native libraries (~34MB total for ARM64): + +| Library | Size | Description | +|---------|------|-------------| +| `librac_backend_llamacpp_jni.so` | ~2MB | JNI bridge | +| `librunanywhere_llamacpp.so` | ~15MB | RunAnywhere llama.cpp wrapper | +| `libllama.so` | ~15MB | llama.cpp core inference | +| `libcommon.so` | ~2MB | llama.cpp utilities | + +### Supported ABIs + +- `arm64-v8a` — Primary target (modern Android devices) + +--- + +## Build Configuration + +### Remote Mode (Default) + +Native libraries are automatically downloaded from GitHub releases: + +```kotlin +// gradle.properties +runanywhere.testLocal=false // Downloads from releases +runanywhere.coreVersion=0.1.4 +``` + +### Local Development + +For developing with local C++ builds: + +```kotlin +// gradle.properties +runanywhere.testLocal=true // Uses local jniLibs/ +``` + +Then build the native libraries: + +```bash +cd ../../ # SDK root +./scripts/build-kotlin.sh --setup +``` + +--- + +## Performance + +### Typical Benchmarks (Pixel 7, 8GB RAM) + +| Model | Load Time | Tokens/sec | Memory | +|-------|-----------|------------|--------| +| Qwen2.5-0.5B Q8 | ~500ms | 15-25 tok/s | ~500MB | +| Qwen2.5-0.5B Q4 | ~400ms | 20-30 tok/s | ~300MB | +| Qwen2.5-1.5B Q8 | ~800ms | 10-15 tok/s | ~1.5GB | + +### Optimization Tips + +1. **Use quantized models** — Q4 uses ~40% less memory than Q8 +2. **Limit context length** — Reduce `contextLength` for faster inference +3. **Monitor thermal state** — Throttle if device is hot +4. **Unload when done** — Call `unloadLLMModel()` to free memory + +--- + +## Requirements + +- **Android**: API 24+ (Android 7.0+) +- **Architecture**: ARM64 (arm64-v8a) +- **Memory**: 1GB+ free RAM recommended +- **RunAnywhere SDK**: 0.1.4+ + +--- + +## Troubleshooting + +### Model fails to load + +``` +SDKError: MODEL_LOAD_FAILED - Insufficient memory +``` + +**Solution:** Use a smaller quantized model (Q4 instead of Q8) or ensure sufficient free RAM. + +### Slow inference + +Check the result metrics: +```kotlin +val result = RunAnywhere.generate(prompt) +if (result.tokensPerSecond < 5) { + // Consider a smaller model or check device state +} +``` + +### Model not recognized + +Ensure the model is GGUF format and framework is set correctly: +```kotlin +RunAnywhere.registerModel( + framework = InferenceFramework.LLAMA_CPP // Must be LLAMA_CPP for this module +) +``` + +--- + +## License + +Apache 2.0. See [LICENSE](../../../../LICENSE). + +This module includes: +- **llama.cpp** — MIT License +- **ggml** — MIT License + +--- + +## See Also + +- [RunAnywhere Kotlin SDK](../../README.md) — Main SDK documentation +- [llama.cpp](https://github.com/ggerganov/llama.cpp) — Upstream project +- [GGUF Models on HuggingFace](https://huggingface.co/models?library=gguf) — Model repository diff --git a/sdk/runanywhere-kotlin/modules/runanywhere-core-llamacpp/build.gradle.kts b/sdk/runanywhere-kotlin/modules/runanywhere-core-llamacpp/build.gradle.kts new file mode 100644 index 000000000..e7f8312b0 --- /dev/null +++ b/sdk/runanywhere-kotlin/modules/runanywhere-core-llamacpp/build.gradle.kts @@ -0,0 +1,325 @@ +/** + * RunAnywhere Core LlamaCPP Module + * + * This module provides the LlamaCPP backend for LLM text generation. + * It is SELF-CONTAINED with its own native libraries. + * + * Architecture (mirrors iOS RABackendLlamaCPP.xcframework): + * iOS: LlamaCPPRuntime.swift -> RABackendLlamaCPP.xcframework + * Android: LlamaCPP.kt -> librunanywhere_llamacpp.so + * + * Native Libraries Included: + * - librunanywhere_llamacpp.so (~34MB) - LLM inference with llama.cpp + * + * This module is OPTIONAL - only include it if your app needs LLM capabilities. + */ + +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.detekt) + alias(libs.plugins.ktlint) + `maven-publish` + signing +} + +// ============================================================================= +// Configuration +// ============================================================================= +// Note: This module does NOT handle native libs - main SDK bundles everything +val testLocal: Boolean = + rootProject.findProperty("runanywhere.testLocal")?.toString()?.toBoolean() + ?: project.findProperty("runanywhere.testLocal")?.toString()?.toBoolean() + ?: false + +logger.lifecycle("LlamaCPP Module: testLocal=$testLocal (native libs handled by main SDK)") + +// ============================================================================= +// Detekt Configuration +// ============================================================================= +detekt { + buildUponDefaultConfig = true + allRules = false + config.setFrom(files("../../detekt.yml")) + source.setFrom( + "src/commonMain/kotlin", + "src/jvmMain/kotlin", + "src/jvmAndroidMain/kotlin", + "src/androidMain/kotlin", + ) +} + +// ============================================================================= +// ktlint Configuration +// ============================================================================= +ktlint { + version.set("1.5.0") + android.set(true) + verbose.set(true) + outputToConsole.set(true) + enableExperimentalRules.set(false) + filter { + exclude("**/generated/**") + include("**/kotlin/**") + } +} + +// ============================================================================= +// Kotlin Multiplatform Configuration +// ============================================================================= + +kotlin { + jvm { + compilations.all { + kotlinOptions.jvmTarget = "17" + } + } + + androidTarget { + // Enable publishing Android AAR to Maven + publishLibraryVariants("release") + + // Set correct artifact ID for Android publication + mavenPublication { + artifactId = "runanywhere-llamacpp-android" + } + + compilations.all { + kotlinOptions.jvmTarget = "17" + } + } + + sourceSets { + val commonMain by getting { + dependencies { + // Core SDK dependency for interfaces and models + api(project.parent!!.parent!!) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) + } + } + + val commonTest by getting { + dependencies { + implementation(kotlin("test")) + implementation(libs.kotlinx.coroutines.test) + } + } + + // Shared JVM/Android code + val jvmAndroidMain by creating { + dependsOn(commonMain) + } + + val jvmMain by getting { + dependsOn(jvmAndroidMain) + } + + val androidMain by getting { + dependsOn(jvmAndroidMain) + } + + val jvmTest by getting + val androidUnitTest by getting + } +} + +// ============================================================================= +// Android Configuration +// ============================================================================= + +android { + namespace = "com.runanywhere.sdk.core.llamacpp" + compileSdk = 36 + + defaultConfig { + minSdk = 24 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + ndk { + // Support ARM64 devices and x86_64 emulators + abiFilters += listOf("arm64-v8a", "x86_64") + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } + + // ========================================================================== + // JNI Libraries - Handled by Main SDK + // ========================================================================== + // Backend modules do NOT bundle their own native libs. + // All native libs are bundled by the main SDK (runanywhere-kotlin). + // This module only contains Kotlin code for the LlamaCPP backend. + // ========================================================================== +} + +// ============================================================================= +// JNI Library Download Task - DISABLED for backend modules +// ============================================================================= +// Backend modules do NOT download their own native libs. +// The main SDK's downloadJniLibs task downloads ALL native libs (including backend libs) +// to src/androidMain/jniLibs/ which is shared across all modules. +// +// This task is kept as a no-op for backwards compatibility. +// ============================================================================= +tasks.register("downloadJniLibs") { + group = "runanywhere" + description = "No-op: Main SDK handles all native library downloads" + + doLast { + logger.lifecycle("LlamaCPP Module: Skipping downloadJniLibs (main SDK handles all native libs)") + } +} + +// Note: JNI libs are handled by the main SDK, not by backend modules + +// ============================================================================= +// Include third-party licenses in JVM JAR +// ============================================================================= + +tasks.named("jvmJar") { + from(rootProject.file("THIRD_PARTY_LICENSES.md")) { + into("META-INF") + } +} + +// ============================================================================= +// Maven Central Publishing Configuration +// ============================================================================= +// Consumer usage (after publishing): +// implementation("com.runanywhere:runanywhere-llamacpp:1.0.0") +// ============================================================================= + +// Maven Central group ID - using verified namespace +val isJitPack = System.getenv("JITPACK") == "true" +val usePendingNamespace = System.getenv("USE_RUNANYWHERE_NAMESPACE")?.toBoolean() ?: false +group = when { + isJitPack -> "com.github.RunanywhereAI.runanywhere-sdks" + usePendingNamespace -> "com.runanywhere" + else -> "io.github.sanchitmonga22" // Currently verified namespace +} + +// Version: SDK_VERSION (our CI), VERSION (JitPack), or fallback +version = System.getenv("SDK_VERSION")?.removePrefix("v") + ?: System.getenv("VERSION")?.removePrefix("v") + ?: "0.1.5-SNAPSHOT" + +// Get publishing credentials +val mavenCentralUsername: String? = System.getenv("MAVEN_CENTRAL_USERNAME") + ?: project.findProperty("mavenCentral.username") as String? +val mavenCentralPassword: String? = System.getenv("MAVEN_CENTRAL_PASSWORD") + ?: project.findProperty("mavenCentral.password") as String? +val signingKeyId: String? = System.getenv("GPG_KEY_ID") + ?: project.findProperty("signing.keyId") as String? +val signingPassword: String? = System.getenv("GPG_SIGNING_PASSWORD") + ?: project.findProperty("signing.password") as String? +val signingKey: String? = System.getenv("GPG_SIGNING_KEY") + ?: project.findProperty("signing.key") as String? + +publishing { + publications.withType { + // Maven Central artifact naming + artifactId = when (name) { + "kotlinMultiplatform" -> "runanywhere-llamacpp" + "androidRelease" -> "runanywhere-llamacpp-android" + "jvm" -> "runanywhere-llamacpp-jvm" + else -> "runanywhere-llamacpp-$name" + } + + pom { + name.set("RunAnywhere LlamaCPP Backend") + description.set("LlamaCPP backend for RunAnywhere SDK - enables on-device LLM text generation using llama.cpp. Includes LlamaCPP-specific native libraries.") + url.set("https://runanywhere.ai") + inceptionYear.set("2024") + + licenses { + license { + name.set("The Apache License, Version 2.0") + url.set("https://www.apache.org/licenses/LICENSE-2.0.txt") + distribution.set("repo") + } + } + + developers { + developer { + id.set("runanywhere") + name.set("RunAnywhere Team") + email.set("founders@runanywhere.ai") + organization.set("RunAnywhere AI") + organizationUrl.set("https://runanywhere.ai") + } + } + + scm { + connection.set("scm:git:git://github.com/RunanywhereAI/runanywhere-sdks.git") + developerConnection.set("scm:git:ssh://github.com/RunanywhereAI/runanywhere-sdks.git") + url.set("https://github.com/RunanywhereAI/runanywhere-sdks") + } + } + } + + repositories { + // Maven Central (Sonatype Central Portal - new API) + maven { + name = "MavenCentral" + url = uri("https://ossrh-staging-api.central.sonatype.com/service/local/staging/deploy/maven2/") + credentials { + username = mavenCentralUsername + password = mavenCentralPassword + } + } + + // GitHub Packages (backup) + maven { + name = "GitHubPackages" + url = uri("https://maven.pkg.github.com/RunanywhereAI/runanywhere-sdks") + credentials { + username = project.findProperty("gpr.user") as String? ?: System.getenv("GITHUB_ACTOR") + password = project.findProperty("gpr.token") as String? ?: System.getenv("GITHUB_TOKEN") + } + } + } +} + +// Configure signing +signing { + if (signingKey != null && signingKey.contains("BEGIN PGP")) { + useInMemoryPgpKeys(signingKeyId, signingKey, signingPassword) + } else { + useGpgCmd() + } + sign(publishing.publications) +} + +// Only sign when needed +tasks.withType().configureEach { + onlyIf { + project.hasProperty("signing.gnupg.keyName") || signingKey != null + } +} + +// Disable debug publications +tasks.withType().configureEach { + onlyIf { publication.name !in listOf("androidDebug") } +} diff --git a/sdk/runanywhere-kotlin/modules/runanywhere-core-llamacpp/proguard-rules.pro b/sdk/runanywhere-kotlin/modules/runanywhere-core-llamacpp/proguard-rules.pro new file mode 100644 index 000000000..5e5125444 --- /dev/null +++ b/sdk/runanywhere-kotlin/modules/runanywhere-core-llamacpp/proguard-rules.pro @@ -0,0 +1,34 @@ +# ======================================================================================== +# RunAnywhere Core LlamaCPP Module - ProGuard Rules +# ======================================================================================== + +# Keep ALL SDK classes (inherited from main SDK rules, but explicit for safety) +-keep class com.runanywhere.sdk.** { *; } +-keep interface com.runanywhere.sdk.** { *; } +-keep enum com.runanywhere.sdk.** { *; } + +# Keep all constructors (critical for JNI) +-keepclassmembers class com.runanywhere.sdk.** { + (...); +} + +# Keep companion objects and singletons +-keepclassmembers class com.runanywhere.sdk.** { + public static ** Companion; + public static ** INSTANCE; + public static ** shared; +} + +# Prevent obfuscation (class, interface, and enum names for consistency) +-keepnames class com.runanywhere.sdk.** { *; } +-keepnames interface com.runanywhere.sdk.** { *; } +-keepnames enum com.runanywhere.sdk.** { *; } + +# Keep native methods +-keepclasseswithmembernames class * { + native ; +} + +# LlamaCPP Runtime +-keep class com.runanywhere.sdk.core.llamacpp.** { *; } +-dontwarn com.runanywhere.sdk.core.llamacpp.** diff --git a/sdk/runanywhere-kotlin/modules/runanywhere-core-llamacpp/src/commonMain/kotlin/com/runanywhere/sdk/llm/llamacpp/LlamaCPP.kt b/sdk/runanywhere-kotlin/modules/runanywhere-core-llamacpp/src/commonMain/kotlin/com/runanywhere/sdk/llm/llamacpp/LlamaCPP.kt new file mode 100644 index 000000000..e7cdea1a6 --- /dev/null +++ b/sdk/runanywhere-kotlin/modules/runanywhere-core-llamacpp/src/commonMain/kotlin/com/runanywhere/sdk/llm/llamacpp/LlamaCPP.kt @@ -0,0 +1,151 @@ +package com.runanywhere.sdk.llm.llamacpp + +import com.runanywhere.sdk.core.module.RunAnywhereModule +import com.runanywhere.sdk.core.types.InferenceFramework +import com.runanywhere.sdk.core.types.SDKComponent +import com.runanywhere.sdk.foundation.SDKLogger + +/** + * LlamaCPP module for LLM text generation. + * + * Provides large language model capabilities using llama.cpp + * with GGUF models and Metal/GPU acceleration. + * + * This is a thin wrapper that calls C++ backend registration. + * All business logic is handled by the C++ commons layer. + * + * ## Registration + * + * ```kotlin + * import com.runanywhere.sdk.llm.llamacpp.LlamaCPP + * + * // Register the backend (done automatically if auto-registration is enabled) + * LlamaCPP.register() + * ``` + * + * ## Usage + * + * LLM services are accessed through the main SDK APIs - the C++ backend handles + * service creation and lifecycle internally: + * + * ```kotlin + * // Generate text via public API + * val response = RunAnywhere.chat("Hello!") + * + * // Stream text via public API + * RunAnywhere.streamChat("Tell me a story").collect { token -> + * print(token) + * } + * ``` + * + * Matches iOS LlamaCPP.swift exactly. + */ +object LlamaCPP : RunAnywhereModule { + private val logger = SDKLogger.llamacpp + + // MARK: - Module Info + + /** Current version of the LlamaCPP Runtime module */ + const val version = "2.0.0" + + /** LlamaCPP library version (underlying C++ library) */ + const val llamaCppVersion = "b7199" + + // MARK: - RunAnywhereModule Conformance + + override val moduleId: String = "llamacpp" + + override val moduleName: String = "LlamaCPP" + + override val capabilities: Set = setOf(SDKComponent.LLM) + + override val defaultPriority: Int = 100 + + /** LlamaCPP uses the llama.cpp inference framework */ + override val inferenceFramework: InferenceFramework = InferenceFramework.LLAMA_CPP + + // MARK: - Registration State + + @Volatile + private var isRegistered = false + + // MARK: - Registration + + /** + * Register LlamaCPP backend with the C++ service registry. + * + * This calls `rac_backend_llamacpp_register()` to register the + * LlamaCPP service provider with the C++ commons layer. + * + * Safe to call multiple times - subsequent calls are no-ops. + * + * @param priority Ignored (C++ uses its own priority system) + */ + @Suppress("UNUSED_PARAMETER") + @JvmStatic + @JvmOverloads + fun register(priority: Int = defaultPriority) { + if (isRegistered) { + logger.debug("LlamaCPP already registered, returning") + return + } + + logger.info("Registering LlamaCPP backend with C++ registry...") + + val result = registerNative() + + // Success or already registered is OK + if (result != 0 && result != -4) { // RAC_ERROR_MODULE_ALREADY_REGISTERED = -4 + logger.error("LlamaCPP registration failed with code: $result") + // Don't throw - registration failure shouldn't crash the app + return + } + + isRegistered = true + logger.info("LlamaCPP backend registered successfully") + } + + /** + * Unregister the LlamaCPP backend from C++ registry. + */ + fun unregister() { + if (!isRegistered) return + + unregisterNative() + isRegistered = false + logger.info("LlamaCPP backend unregistered") + } + + // MARK: - Model Handling + + /** + * Check if LlamaCPP can handle a given model. + * Uses file extension pattern matching - actual framework info is in C++ registry. + */ + fun canHandle(modelId: String?): Boolean { + if (modelId == null) return false + return modelId.lowercase().endsWith(".gguf") + } + + // MARK: - Auto-Registration + + /** + * Enable auto-registration for this module. + * Access this property to trigger C++ backend registration. + */ + val autoRegister: Unit by lazy { + register() + } +} + +/** + * Platform-specific native registration. + * Calls rac_backend_llamacpp_register() via JNI. + */ +internal expect fun LlamaCPP.registerNative(): Int + +/** + * Platform-specific native unregistration. + * Calls rac_backend_llamacpp_unregister() via JNI. + */ +internal expect fun LlamaCPP.unregisterNative(): Int diff --git a/sdk/runanywhere-kotlin/modules/runanywhere-core-llamacpp/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/llm/llamacpp/LlamaCPP.jvmAndroid.kt b/sdk/runanywhere-kotlin/modules/runanywhere-core-llamacpp/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/llm/llamacpp/LlamaCPP.jvmAndroid.kt new file mode 100644 index 000000000..e751e99b5 --- /dev/null +++ b/sdk/runanywhere-kotlin/modules/runanywhere-core-llamacpp/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/llm/llamacpp/LlamaCPP.jvmAndroid.kt @@ -0,0 +1,43 @@ +package com.runanywhere.sdk.llm.llamacpp + +import com.runanywhere.sdk.foundation.SDKLogger +import com.runanywhere.sdk.native.bridge.RunAnywhereBridge + +private val logger = SDKLogger.llamacpp + +/** + * JVM/Android implementation of LlamaCPP native registration. + * + * Uses the self-contained LlamaCPPBridge to register the backend, + * mirroring the Swift LlamaCPPBackend XCFramework architecture. + * + * The LlamaCPP module has its own JNI library (librac_backend_llamacpp_jni.so) + * that provides backend registration, separate from the main commons JNI. + */ +internal actual fun LlamaCPP.registerNative(): Int { + logger.debug("Ensuring commons JNI is loaded for service registry") + // Ensure commons JNI is loaded first (provides service registry) + RunAnywhereBridge.ensureNativeLibraryLoaded() + + logger.debug("Loading dedicated LlamaCPP JNI library") + // Load and use the dedicated LlamaCPP JNI + if (!LlamaCPPBridge.ensureNativeLibraryLoaded()) { + logger.error("Failed to load LlamaCPP native library") + throw UnsatisfiedLinkError("Failed to load LlamaCPP native library") + } + + logger.debug("Calling native register") + val result = LlamaCPPBridge.nativeRegister() + logger.debug("Native register returned: $result") + return result +} + +/** + * JVM/Android implementation of LlamaCPP native unregistration. + */ +internal actual fun LlamaCPP.unregisterNative(): Int { + logger.debug("Calling native unregister") + val result = LlamaCPPBridge.nativeUnregister() + logger.debug("Native unregister returned: $result") + return result +} diff --git a/sdk/runanywhere-kotlin/modules/runanywhere-core-llamacpp/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/llm/llamacpp/LlamaCPPBridge.kt b/sdk/runanywhere-kotlin/modules/runanywhere-core-llamacpp/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/llm/llamacpp/LlamaCPPBridge.kt new file mode 100644 index 000000000..1746a0863 --- /dev/null +++ b/sdk/runanywhere-kotlin/modules/runanywhere-core-llamacpp/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/llm/llamacpp/LlamaCPPBridge.kt @@ -0,0 +1,115 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * LlamaCPP Native Bridge + * + * Self-contained JNI bridge for the LlamaCPP backend module. + * This mirrors the Swift LlamaCPPBackend XCFramework architecture. + * + * The native library (librac_backend_llamacpp_jni.so) contains: + * - rac_backend_llamacpp_register() + * - rac_backend_llamacpp_unregister() + */ + +package com.runanywhere.sdk.llm.llamacpp + +import com.runanywhere.sdk.foundation.SDKLogger + +/** + * Native bridge for LlamaCPP backend registration. + * + * This object handles loading the LlamaCPP-specific JNI library and provides + * JNI methods for backend registration with the C++ service registry. + * + * Architecture: + * - librac_backend_llamacpp_jni.so - LlamaCPP JNI (this bridge) + * - Links to librac_backend_llamacpp.so - LlamaCPP C++ backend + * - Links to librac_commons.so - Commons library with service registry + */ +internal object LlamaCPPBridge { + private val logger = SDKLogger.llamacpp + + @Volatile + private var nativeLibraryLoaded = false + + private val loadLock = Any() + + /** + * Ensure the LlamaCPP JNI library is loaded. + * + * Loads librac_backend_llamacpp_jni.so and its dependencies: + * - librac_backend_llamacpp.so (LlamaCPP C++ backend) + * - librac_commons.so (commons library - must be loaded first) + * - librunanywhere_llamacpp.so (from runanywhere-core) + * + * @return true if loaded successfully, false otherwise + */ + fun ensureNativeLibraryLoaded(): Boolean { + if (nativeLibraryLoaded) return true + + synchronized(loadLock) { + if (nativeLibraryLoaded) return true + + logger.info("Loading LlamaCPP native library...") + + try { + // The main SDK's librunanywhere_jni.so must be loaded first + // (provides librac_commons.so with service registry). + // The LlamaCPP JNI provides backend registration functions. + System.loadLibrary("rac_backend_llamacpp_jni") + nativeLibraryLoaded = true + logger.info("LlamaCPP native library loaded successfully") + return true + } catch (e: UnsatisfiedLinkError) { + logger.error("Failed to load LlamaCPP native library: ${e.message}", throwable = e) + return false + } catch (e: Exception) { + logger.error("Unexpected error loading LlamaCPP native library: ${e.message}", throwable = e) + return false + } + } + } + + /** + * Check if the native library is loaded. + */ + val isLoaded: Boolean + get() = nativeLibraryLoaded + + // ========================================================================== + // JNI Methods + // ========================================================================== + + /** + * Register the LlamaCPP backend with the C++ service registry. + * + * @return 0 (RAC_SUCCESS) on success, error code on failure + */ + @JvmStatic + external fun nativeRegister(): Int + + /** + * Unregister the LlamaCPP backend from the C++ service registry. + * + * @return 0 (RAC_SUCCESS) on success, error code on failure + */ + @JvmStatic + external fun nativeUnregister(): Int + + /** + * Check if the LlamaCPP backend is registered. + * + * @return true if registered + */ + @JvmStatic + external fun nativeIsRegistered(): Boolean + + /** + * Get the llama.cpp library version. + * + * @return Version string + */ + @JvmStatic + external fun nativeGetVersion(): String +} diff --git a/sdk/runanywhere-kotlin/modules/runanywhere-core-onnx/.gitignore b/sdk/runanywhere-kotlin/modules/runanywhere-core-onnx/.gitignore new file mode 100644 index 000000000..42ec78fcb --- /dev/null +++ b/sdk/runanywhere-kotlin/modules/runanywhere-core-onnx/.gitignore @@ -0,0 +1,13 @@ +# Downloaded native libraries (auto-downloaded from GitHub releases) +src/main/jniLibs/ + +# Build artifacts +build/ +.gradle/ + +# IDE +*.iml +.idea/ + +# Generated files +.cxx/ diff --git a/sdk/runanywhere-kotlin/modules/runanywhere-core-onnx/README.md b/sdk/runanywhere-kotlin/modules/runanywhere-core-onnx/README.md new file mode 100644 index 000000000..922cd97f1 --- /dev/null +++ b/sdk/runanywhere-kotlin/modules/runanywhere-core-onnx/README.md @@ -0,0 +1,407 @@ +# RunAnywhere Core ONNX Module + +**Speech & Audio inference backend for the RunAnywhere Kotlin SDK** — powered by [ONNX Runtime](https://onnxruntime.ai) and [Sherpa-ONNX](https://github.com/k2-fsa/sherpa-onnx) for on-device STT, TTS, and VAD. + +[![Maven Central](https://img.shields.io/maven-central/v/com.runanywhere.sdk/runanywhere-core-onnx?label=Maven%20Central)](https://search.maven.org/artifact/com.runanywhere.sdk/runanywhere-core-onnx) +[![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) +[![Platform: Android](https://img.shields.io/badge/Platform-Android%207.0%2B-green)](https://developer.android.com) + +--- + +## Features + +This module provides the **Speech-to-Text (STT)**, **Text-to-Speech (TTS)**, and **Voice Activity Detection (VAD)** backends, enabling complete voice AI capabilities on-device using ONNX Runtime and Sherpa-ONNX. + +**This module is optional.** Only include it if your app needs STT, TTS, or VAD capabilities. + +- **Speech-to-Text (STT)** — Whisper-based transcription on-device +- **Text-to-Speech (TTS)** — Neural TTS voice synthesis +- **Voice Activity Detection (VAD)** — Silero VAD for speech detection +- **Streaming Support** — Real-time transcription and synthesis +- **Multiple Languages** — Multi-language STT and TTS support +- **ARM64 Optimized** — Native ONNX Runtime for Android + +--- + +## Installation + +Add to your module's `build.gradle.kts`: + +```kotlin +dependencies { + // Core SDK (required) + implementation("com.runanywhere.sdk:runanywhere-kotlin:0.1.4") + + // ONNX backend (this module) + implementation("com.runanywhere.sdk:runanywhere-core-onnx:0.1.4") +} +``` + +--- + +## Usage + +Once included, the module automatically registers the `ONNX` framework with the SDK. + +### Speech-to-Text (STT) + +```kotlin +import com.runanywhere.sdk.public.RunAnywhere +import com.runanywhere.sdk.public.extensions.* + +// Register and download STT model +val sttModel = RunAnywhere.registerModel( + name = "Whisper Tiny", + url = "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-whisper-tiny.tar.bz2", + framework = InferenceFramework.ONNX, + modality = ModelCategory.SPEECH_RECOGNITION +) + +RunAnywhere.downloadModel(sttModel.id).collect { progress -> + println("Download: ${(progress.progress * 100).toInt()}%") +} + +// Load and transcribe +RunAnywhere.loadSTTModel(sttModel.id) +val text = RunAnywhere.transcribe(audioData) +println("Transcription: $text") +``` + +### Advanced STT Options + +```kotlin +val output = RunAnywhere.transcribeWithOptions( + audioData = audioBytes, + options = STTOptions( + language = "en", + enablePunctuation = true, + enableTimestamps = true + ) +) + +println("Text: ${output.text}") +println("Confidence: ${output.confidence}") +output.wordTimestamps?.forEach { word -> + println("${word.word}: ${word.startTime}s - ${word.endTime}s") +} +``` + +### Streaming STT + +```kotlin +RunAnywhere.transcribeStream(audioData) { partial -> + // Update UI with partial results + println("Partial: ${partial.transcript}") +} +``` + +--- + +### Text-to-Speech (TTS) + +```kotlin +// Register and download TTS voice +val ttsVoice = RunAnywhere.registerModel( + name = "English US Voice", + url = "https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-en_US-libritts-high.tar.bz2", + framework = InferenceFramework.ONNX, + modality = ModelCategory.SPEECH_SYNTHESIS +) + +RunAnywhere.downloadModel(ttsVoice.id).collect { /* progress */ } + +// Load and synthesize +RunAnywhere.loadTTSVoice(ttsVoice.id) + +// Simple speak (handles playback) +RunAnywhere.speak("Hello, world!") + +// Or get audio bytes +val output = RunAnywhere.synthesize("Welcome to RunAnywhere") +val audioBytes = output.audioData +val duration = output.duration +``` + +### TTS Options + +```kotlin +val output = RunAnywhere.synthesize( + text = "Hello!", + options = TTSOptions( + rate = 1.2f, // Faster speech + pitch = 1.0f, + volume = 0.8f + ) +) +``` + +### Streaming TTS + +```kotlin +RunAnywhere.synthesizeStream(longText) { chunk -> + audioPlayer.play(chunk) // Play as chunks arrive +} +``` + +--- + +### Voice Activity Detection (VAD) + +```kotlin +// Detect speech in audio +val result = RunAnywhere.detectVoiceActivity(audioData) + +if (result.hasSpeech) { + println("Speech detected! Confidence: ${result.confidence}") +} +``` + +### Configure VAD + +```kotlin +RunAnywhere.configureVAD(VADConfiguration( + threshold = 0.5f, + minSpeechDurationMs = 250, + minSilenceDurationMs = 300 +)) +``` + +### Streaming VAD + +```kotlin +RunAnywhere.streamVAD(audioSamplesFlow) + .collect { result -> + when { + result.hasSpeech -> println("Speaking...") + else -> println("Silence") + } + } +``` + +--- + +## Supported Models + +### Speech-to-Text (Whisper) + +| Model | Size | Languages | Quality | +|-------|------|-----------|---------| +| whisper-tiny | ~75MB | 99 languages | Good for mobile | +| whisper-base | ~150MB | 99 languages | Better accuracy | +| whisper-small | ~500MB | 99 languages | High accuracy | + +### Text-to-Speech (VITS/Piper) + +| Voice | Size | Language | Quality | +|-------|------|----------|---------| +| vits-piper-en_US-libritts-high | ~100MB | English (US) | High quality | +| vits-piper-en_GB-* | ~100MB | English (UK) | High quality | +| vits-piper-de_DE-* | ~100MB | German | High quality | +| vits-piper-es_ES-* | ~100MB | Spanish | High quality | + +### VAD (Built-in) + +VAD uses Silero VAD which is bundled with Sherpa-ONNX (~5MB). + +--- + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ RunAnywhere SDK (Kotlin) │ +│ │ +│ RunAnywhere.transcribe() / synthesize() / detectVAD() │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ runanywhere-core-onnx │ +│ │ +│ ┌────────────────────────────────────────────────────────┐ │ +│ │ JNI Bridge (Kotlin ↔ C++) │ │ +│ │ librac_backend_onnx_jni.so │ │ +│ └────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ┌────────────────────────────────────────────────────────┐ │ +│ │ librunanywhere_onnx.so │ │ +│ │ RunAnywhere ONNX wrapper │ │ +│ └────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ┌──────────────────┐ ┌─────────────────────────────────┐ │ +│ │ libonnxruntime.so│ │ Sherpa-ONNX libs │ │ +│ │ ONNX Runtime │ │ STT / TTS / VAD inference │ │ +│ │ (~15MB) │ │ libsherpa-onnx-*.so │ │ +│ └──────────────────┘ └─────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## Native Libraries + +This module bundles the following native libraries (~25MB total for ARM64): + +| Library | Size | Description | +|---------|------|-------------| +| `librac_backend_onnx_jni.so` | ~1MB | JNI bridge | +| `librunanywhere_onnx.so` | ~2MB | RunAnywhere ONNX wrapper | +| `libonnxruntime.so` | ~15MB | ONNX Runtime | +| `libsherpa-onnx-c-api.so` | ~2MB | Sherpa-ONNX C API | +| `libsherpa-onnx-cxx-api.so` | ~3MB | Sherpa-ONNX C++ API | +| `libsherpa-onnx-jni.so` | ~2MB | Sherpa-ONNX JNI bridge | + +### Supported ABIs + +- `arm64-v8a` — Primary target (modern Android devices) + +--- + +## Build Configuration + +### Remote Mode (Default) + +Native libraries are automatically downloaded from GitHub releases: + +```kotlin +// gradle.properties +runanywhere.testLocal=false // Downloads from releases +runanywhere.coreVersion=0.1.4 +``` + +### Local Development + +For developing with local C++ builds: + +```kotlin +// gradle.properties +runanywhere.testLocal=true // Uses local jniLibs/ +``` + +Then build the native libraries: + +```bash +cd ../../ # SDK root +./scripts/build-kotlin.sh --setup +``` + +--- + +## Performance + +### Speech-to-Text (Pixel 7, 8GB RAM) + +| Model | Audio Length | Processing Time | RTF | +|-------|--------------|-----------------|-----| +| whisper-tiny | 5s | ~200ms | 0.04 | +| whisper-tiny | 30s | ~1.2s | 0.04 | +| whisper-base | 5s | ~400ms | 0.08 | + +**RTF** = Real-Time Factor (lower is better) + +### Text-to-Speech + +| Voice | Text Length | Synthesis Time | Duration | +|-------|-------------|----------------|----------| +| libritts-high | 100 chars | ~100ms | ~2s | +| libritts-high | 500 chars | ~300ms | ~10s | + +### VAD + +- Frame processing: < 5ms per 30ms frame +- Latency: < 100ms speech detection + +--- + +## Audio Format Requirements + +### STT Input + +- **Format**: PCM (16-bit signed, little-endian) +- **Sample Rate**: 16000 Hz (recommended) +- **Channels**: Mono + +```kotlin +val options = STTOptions( + audioFormat = AudioFormat.PCM, + sampleRate = 16000 +) +``` + +### TTS Output + +- **Format**: PCM (16-bit signed) +- **Sample Rate**: 22050 Hz (default) or 44100 Hz +- **Channels**: Mono + +--- + +## Requirements + +- **Android**: API 24+ (Android 7.0+) +- **Architecture**: ARM64 (arm64-v8a) +- **Memory**: 512MB+ free RAM recommended +- **RunAnywhere SDK**: 0.1.4+ + +--- + +## Troubleshooting + +### STT model fails to load + +``` +SDKError: MODEL_LOAD_FAILED - Invalid model format +``` + +**Solution:** Ensure the model is in Sherpa-ONNX format (usually `.tar.bz2` archives from the official releases). + +### TTS voice sounds robotic + +Try using a higher-quality voice model: +```kotlin +// Use "high" quality variants +val ttsVoice = RunAnywhere.registerModel( + url = "...libritts-high.tar.bz2" // Not "low" or "medium" +) +``` + +### VAD too sensitive / not sensitive enough + +Adjust the threshold: +```kotlin +RunAnywhere.configureVAD(VADConfiguration( + threshold = 0.3f, // Lower = more sensitive (0.0 - 1.0) + minSpeechDurationMs = 100 // Shorter = faster detection +)) +``` + +### Audio playback issues + +Ensure proper audio format matching: +```kotlin +val output = RunAnywhere.synthesize(text, TTSOptions( + audioFormat = AudioFormat.PCM, + sampleRate = 22050 +)) +// Configure AudioTrack with matching sample rate +``` + +--- + +## License + +Apache 2.0. See [LICENSE](../../../../LICENSE). + +This module includes: +- **ONNX Runtime** — MIT License +- **Sherpa-ONNX** — Apache 2.0 License +- **Silero VAD** — MIT License + +--- + +## See Also + +- [RunAnywhere Kotlin SDK](../../README.md) — Main SDK documentation +- [Sherpa-ONNX](https://github.com/k2-fsa/sherpa-onnx) — Upstream STT/TTS/VAD +- [ONNX Runtime](https://onnxruntime.ai) — ONNX inference engine +- [Sherpa-ONNX Models](https://github.com/k2-fsa/sherpa-onnx/releases) — Model downloads diff --git a/sdk/runanywhere-kotlin/modules/runanywhere-core-onnx/build.gradle.kts b/sdk/runanywhere-kotlin/modules/runanywhere-core-onnx/build.gradle.kts new file mode 100644 index 000000000..b26dca4dd --- /dev/null +++ b/sdk/runanywhere-kotlin/modules/runanywhere-core-onnx/build.gradle.kts @@ -0,0 +1,334 @@ +/** + * RunAnywhere Core ONNX Module + * + * This module provides the ONNX Runtime backend for STT, TTS, and VAD. + * It is SELF-CONTAINED with its own native libraries. + * + * Architecture (mirrors iOS RABackendONNX.xcframework): + * iOS: ONNXRuntime.swift -> RABackendONNX.xcframework + onnxruntime.xcframework + * Android: ONNX.kt -> librunanywhere_onnx.so + libonnxruntime.so + libsherpa-onnx-*.so + * + * Native Libraries Included (~25MB total): + * - librunanywhere_onnx.so - ONNX backend wrapper + * - libonnxruntime.so (~15MB) - ONNX Runtime + * - libsherpa-onnx-c-api.so - Sherpa-ONNX C API + * - libsherpa-onnx-cxx-api.so - Sherpa-ONNX C++ API + * - libsherpa-onnx-jni.so - Sherpa-ONNX JNI (STT/TTS/VAD) + * + * This module is OPTIONAL - only include it if your app needs STT/TTS/VAD capabilities. + */ + +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.detekt) + alias(libs.plugins.ktlint) + `maven-publish` + signing +} + +// ============================================================================= +// Configuration +// ============================================================================= +// Note: This module does NOT handle native libs - main SDK bundles everything +val testLocal: Boolean = + rootProject.findProperty("runanywhere.testLocal")?.toString()?.toBoolean() + ?: project.findProperty("runanywhere.testLocal")?.toString()?.toBoolean() + ?: false + +logger.lifecycle("ONNX Module: testLocal=$testLocal (native libs handled by main SDK)") + +// ============================================================================= +// Detekt Configuration +// ============================================================================= +detekt { + buildUponDefaultConfig = true + allRules = false + config.setFrom(files("../../detekt.yml")) + source.setFrom( + "src/commonMain/kotlin", + "src/jvmMain/kotlin", + "src/jvmAndroidMain/kotlin", + "src/androidMain/kotlin", + ) +} + +// ============================================================================= +// ktlint Configuration +// ============================================================================= +ktlint { + version.set("1.5.0") + android.set(true) + verbose.set(true) + outputToConsole.set(true) + enableExperimentalRules.set(false) + filter { + exclude("**/generated/**") + include("**/kotlin/**") + } +} + +// ============================================================================= +// Kotlin Multiplatform Configuration +// ============================================================================= + +kotlin { + jvm { + compilations.all { + kotlinOptions.jvmTarget = "17" + } + } + + androidTarget { + // Enable publishing Android AAR to Maven + publishLibraryVariants("release") + + // Set correct artifact ID for Android publication + mavenPublication { + artifactId = "runanywhere-onnx-android" + } + + compilations.all { + kotlinOptions.jvmTarget = "17" + } + } + + sourceSets { + val commonMain by getting { + dependencies { + // Core SDK dependency for interfaces and models + api(project.parent!!.parent!!) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) + } + } + + val commonTest by getting { + dependencies { + implementation(kotlin("test")) + implementation(libs.kotlinx.coroutines.test) + } + } + + // Shared JVM/Android code + val jvmAndroidMain by creating { + dependsOn(commonMain) + dependencies { + // Apache Commons Compress for tar.bz2 extraction on Android + // (native libarchive is not available on Android) + implementation("org.apache.commons:commons-compress:1.26.0") + } + } + + val jvmMain by getting { + dependsOn(jvmAndroidMain) + } + + val androidMain by getting { + dependsOn(jvmAndroidMain) + } + + val jvmTest by getting + val androidUnitTest by getting + } +} + +// ============================================================================= +// Android Configuration +// ============================================================================= + +android { + namespace = "com.runanywhere.sdk.core.onnx" + compileSdk = 36 + + defaultConfig { + minSdk = 24 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + ndk { + // Support ARM64 devices and x86_64 emulators + abiFilters += listOf("arm64-v8a", "x86_64") + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } + + // ========================================================================== + // JNI Libraries - Handled by Main SDK + // ========================================================================== + // Backend modules do NOT bundle their own native libs. + // All native libs are bundled by the main SDK (runanywhere-kotlin). + // This module only contains Kotlin code for the ONNX backend. + // ========================================================================== +} + +// ============================================================================= +// JNI Library Download Task - DISABLED for backend modules +// ============================================================================= +// Backend modules do NOT download their own native libs. +// The main SDK's downloadJniLibs task downloads ALL native libs (including backend libs) +// to src/androidMain/jniLibs/ which is shared across all modules. +// +// This task is kept as a no-op for backwards compatibility. +// ============================================================================= +tasks.register("downloadJniLibs") { + group = "runanywhere" + description = "No-op: Main SDK handles all native library downloads" + + doLast { + logger.lifecycle("ONNX Module: Skipping downloadJniLibs (main SDK handles all native libs)") + } +} + +// Note: JNI libs are handled by the main SDK, not by backend modules + +// ============================================================================= +// Include third-party licenses in JVM JAR +// ============================================================================= + +tasks.named("jvmJar") { + from(rootProject.file("THIRD_PARTY_LICENSES.md")) { + into("META-INF") + } +} + +// ============================================================================= +// Maven Central Publishing Configuration +// ============================================================================= +// Consumer usage (after publishing): +// implementation("com.runanywhere:runanywhere-onnx:1.0.0") +// ============================================================================= + +// Maven Central group ID - using verified namespace +val isJitPack = System.getenv("JITPACK") == "true" +val usePendingNamespace = System.getenv("USE_RUNANYWHERE_NAMESPACE")?.toBoolean() ?: false +group = when { + isJitPack -> "com.github.RunanywhereAI.runanywhere-sdks" + usePendingNamespace -> "com.runanywhere" + else -> "io.github.sanchitmonga22" // Currently verified namespace +} + +// Version: SDK_VERSION (our CI), VERSION (JitPack), or fallback +version = System.getenv("SDK_VERSION")?.removePrefix("v") + ?: System.getenv("VERSION")?.removePrefix("v") + ?: "0.1.5-SNAPSHOT" + +// Get publishing credentials +val mavenCentralUsername: String? = System.getenv("MAVEN_CENTRAL_USERNAME") + ?: project.findProperty("mavenCentral.username") as String? +val mavenCentralPassword: String? = System.getenv("MAVEN_CENTRAL_PASSWORD") + ?: project.findProperty("mavenCentral.password") as String? +val signingKeyId: String? = System.getenv("GPG_KEY_ID") + ?: project.findProperty("signing.keyId") as String? +val signingPassword: String? = System.getenv("GPG_SIGNING_PASSWORD") + ?: project.findProperty("signing.password") as String? +val signingKey: String? = System.getenv("GPG_SIGNING_KEY") + ?: project.findProperty("signing.key") as String? + +publishing { + publications.withType { + // Maven Central artifact naming + artifactId = when (name) { + "kotlinMultiplatform" -> "runanywhere-onnx" + "androidRelease" -> "runanywhere-onnx-android" + "jvm" -> "runanywhere-onnx-jvm" + else -> "runanywhere-onnx-$name" + } + + pom { + name.set("RunAnywhere ONNX Backend") + description.set("ONNX Runtime backend for RunAnywhere SDK - enables on-device STT, TTS, and VAD using Sherpa-ONNX. Includes ONNX-specific native libraries.") + url.set("https://runanywhere.ai") + inceptionYear.set("2024") + + licenses { + license { + name.set("The Apache License, Version 2.0") + url.set("https://www.apache.org/licenses/LICENSE-2.0.txt") + distribution.set("repo") + } + } + + developers { + developer { + id.set("runanywhere") + name.set("RunAnywhere Team") + email.set("founders@runanywhere.ai") + organization.set("RunAnywhere AI") + organizationUrl.set("https://runanywhere.ai") + } + } + + scm { + connection.set("scm:git:git://github.com/RunanywhereAI/runanywhere-sdks.git") + developerConnection.set("scm:git:ssh://github.com/RunanywhereAI/runanywhere-sdks.git") + url.set("https://github.com/RunanywhereAI/runanywhere-sdks") + } + } + } + + repositories { + // Maven Central (Sonatype Central Portal - new API) + maven { + name = "MavenCentral" + url = uri("https://ossrh-staging-api.central.sonatype.com/service/local/staging/deploy/maven2/") + credentials { + username = mavenCentralUsername + password = mavenCentralPassword + } + } + + // GitHub Packages (backup) + maven { + name = "GitHubPackages" + url = uri("https://maven.pkg.github.com/RunanywhereAI/runanywhere-sdks") + credentials { + username = project.findProperty("gpr.user") as String? ?: System.getenv("GITHUB_ACTOR") + password = project.findProperty("gpr.token") as String? ?: System.getenv("GITHUB_TOKEN") + } + } + } +} + +// Configure signing +signing { + if (signingKey != null && signingKey.contains("BEGIN PGP")) { + useInMemoryPgpKeys(signingKeyId, signingKey, signingPassword) + } else { + useGpgCmd() + } + sign(publishing.publications) +} + +// Only sign when needed +tasks.withType().configureEach { + onlyIf { + project.hasProperty("signing.gnupg.keyName") || signingKey != null + } +} + +// Disable debug publications +tasks.withType().configureEach { + onlyIf { publication.name !in listOf("androidDebug") } +} diff --git a/sdk/runanywhere-kotlin/modules/runanywhere-core-onnx/proguard-rules.pro b/sdk/runanywhere-kotlin/modules/runanywhere-core-onnx/proguard-rules.pro new file mode 100644 index 000000000..240bd0b2f --- /dev/null +++ b/sdk/runanywhere-kotlin/modules/runanywhere-core-onnx/proguard-rules.pro @@ -0,0 +1,34 @@ +# ======================================================================================== +# RunAnywhere Core ONNX Module - ProGuard Rules +# ======================================================================================== + +# Keep ALL SDK classes (inherited from main SDK rules, but explicit for safety) +-keep class com.runanywhere.sdk.** { *; } +-keep interface com.runanywhere.sdk.** { *; } +-keep enum com.runanywhere.sdk.** { *; } + +# Keep all constructors (critical for JNI) +-keepclassmembers class com.runanywhere.sdk.** { + (...); +} + +# Keep companion objects and singletons +-keepclassmembers class com.runanywhere.sdk.** { + public static ** Companion; + public static ** INSTANCE; + public static ** shared; +} + +# Prevent obfuscation (class, interface, and enum names for consistency) +-keepnames class com.runanywhere.sdk.** { *; } +-keepnames interface com.runanywhere.sdk.** { *; } +-keepnames enum com.runanywhere.sdk.** { *; } + +# Keep native methods +-keepclasseswithmembernames class * { + native ; +} + +# ONNX Runtime +-keep class ai.onnxruntime.** { *; } +-dontwarn ai.onnxruntime.** diff --git a/sdk/runanywhere-kotlin/modules/runanywhere-core-onnx/src/commonMain/kotlin/com/runanywhere/sdk/core/onnx/ONNX.kt b/sdk/runanywhere-kotlin/modules/runanywhere-core-onnx/src/commonMain/kotlin/com/runanywhere/sdk/core/onnx/ONNX.kt new file mode 100644 index 000000000..2b10ddf5a --- /dev/null +++ b/sdk/runanywhere-kotlin/modules/runanywhere-core-onnx/src/commonMain/kotlin/com/runanywhere/sdk/core/onnx/ONNX.kt @@ -0,0 +1,176 @@ +package com.runanywhere.sdk.core.onnx + +import com.runanywhere.sdk.core.module.RunAnywhereModule +import com.runanywhere.sdk.core.types.InferenceFramework +import com.runanywhere.sdk.core.types.SDKComponent +import com.runanywhere.sdk.foundation.SDKLogger + +/** + * ONNX Runtime module for STT, TTS, and VAD services. + * + * Provides speech-to-text, text-to-speech, and voice activity detection + * capabilities using ONNX Runtime with models like Whisper, Piper, and Silero. + * + * This is a thin wrapper that calls C++ backend registration. + * All business logic is handled by the C++ commons layer. + * + * ## Registration + * + * ```kotlin + * import com.runanywhere.sdk.core.onnx.ONNX + * + * // Register the backend (done automatically if auto-registration is enabled) + * ONNX.register() + * ``` + * + * ## Usage + * + * Services are accessed through the main SDK APIs - the C++ backend handles + * service creation and lifecycle internally: + * + * ```kotlin + * // STT via public API + * val text = RunAnywhere.transcribe(audioData) + * + * // TTS via public API + * RunAnywhere.speak("Hello") + * ``` + * + * Matches iOS ONNX.swift exactly. + */ +object ONNX : RunAnywhereModule { + private val logger = SDKLogger.onnx + + // MARK: - Module Info + + /** Current version of the ONNX Runtime module */ + const val version = "2.0.0" + + /** ONNX Runtime library version (underlying C library) */ + const val onnxRuntimeVersion = "1.23.2" + + // MARK: - RunAnywhereModule Conformance + + override val moduleId: String = "onnx" + + override val moduleName: String = "ONNX Runtime" + + override val capabilities: Set = + setOf( + SDKComponent.STT, + SDKComponent.TTS, + SDKComponent.VAD, + ) + + override val defaultPriority: Int = 100 + + /** ONNX uses the ONNX Runtime inference framework */ + override val inferenceFramework: InferenceFramework = InferenceFramework.ONNX + + // MARK: - Registration State + + @Volatile + private var isRegistered = false + + // MARK: - Registration + + /** + * Register ONNX backend with the C++ service registry. + * + * This calls `rac_backend_onnx_register()` to register all ONNX + * service providers (STT, TTS, VAD) with the C++ commons layer. + * + * Safe to call multiple times - subsequent calls are no-ops. + * + * @param priority Ignored (C++ uses its own priority system) + */ + @Suppress("UNUSED_PARAMETER") + @JvmStatic + @JvmOverloads + fun register(priority: Int = defaultPriority) { + if (isRegistered) { + logger.debug("ONNX already registered, returning") + return + } + + logger.info("Registering ONNX backend with C++ registry...") + + val result = registerNative() + + // Success or already registered is OK + if (result != 0 && result != -4) { // RAC_ERROR_MODULE_ALREADY_REGISTERED = -4 + logger.error("ONNX registration failed with code: $result") + // Don't throw - registration failure shouldn't crash the app + return + } + + isRegistered = true + logger.info("ONNX backend registered successfully (STT + TTS + VAD)") + } + + /** + * Unregister the ONNX backend from C++ registry. + */ + fun unregister() { + if (!isRegistered) return + + unregisterNative() + isRegistered = false + logger.info("ONNX backend unregistered") + } + + // MARK: - Model Handling + + /** + * Check if ONNX can handle a given model for STT. + * Uses model name pattern matching - actual framework info is in C++ registry. + */ + fun canHandleSTT(modelId: String?): Boolean { + if (modelId == null) return false + val lowercased = modelId.lowercase() + return lowercased.contains("whisper") || + lowercased.contains("zipformer") || + lowercased.contains("paraformer") + } + + /** + * Check if ONNX can handle a given model for TTS. + * Uses model name pattern matching - actual framework info is in C++ registry. + */ + fun canHandleTTS(modelId: String?): Boolean { + if (modelId == null) return false + val lowercased = modelId.lowercase() + return lowercased.contains("piper") || lowercased.contains("vits") + } + + /** + * Check if ONNX can handle VAD (always true for Silero VAD). + * ONNX Silero VAD is the default VAD implementation. + */ + @Suppress("UNUSED_PARAMETER", "FunctionOnlyReturningConstant") + fun canHandleVAD(modelId: String?): Boolean { + return true + } + + // MARK: - Auto-Registration + + /** + * Enable auto-registration for this module. + * Access this property to trigger C++ backend registration. + */ + val autoRegister: Unit by lazy { + register() + } +} + +/** + * Platform-specific native registration. + * Calls rac_backend_onnx_register() via JNI. + */ +internal expect fun ONNX.registerNative(): Int + +/** + * Platform-specific native unregistration. + * Calls rac_backend_onnx_unregister() via JNI. + */ +internal expect fun ONNX.unregisterNative(): Int diff --git a/sdk/runanywhere-kotlin/modules/runanywhere-core-onnx/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/core/onnx/ONNX.jvmAndroid.kt b/sdk/runanywhere-kotlin/modules/runanywhere-core-onnx/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/core/onnx/ONNX.jvmAndroid.kt new file mode 100644 index 000000000..2092b28a1 --- /dev/null +++ b/sdk/runanywhere-kotlin/modules/runanywhere-core-onnx/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/core/onnx/ONNX.jvmAndroid.kt @@ -0,0 +1,43 @@ +package com.runanywhere.sdk.core.onnx + +import com.runanywhere.sdk.foundation.SDKLogger +import com.runanywhere.sdk.native.bridge.RunAnywhereBridge + +private val logger = SDKLogger.onnx + +/** + * JVM/Android implementation of ONNX native registration. + * + * Uses the self-contained ONNXBridge to register the backend, + * mirroring the Swift ONNXBackend XCFramework architecture. + * + * The ONNX module has its own JNI library (librac_backend_onnx_jni.so) + * that provides backend registration, separate from the main commons JNI. + */ +internal actual fun ONNX.registerNative(): Int { + logger.debug("Ensuring commons JNI is loaded for service registry") + // Ensure commons JNI is loaded first (provides service registry) + RunAnywhereBridge.ensureNativeLibraryLoaded() + + logger.debug("Loading ONNX JNI library") + // Load and use the dedicated ONNX JNI + if (!ONNXBridge.ensureNativeLibraryLoaded()) { + logger.error("Failed to load ONNX native library") + throw UnsatisfiedLinkError("Failed to load ONNX native library") + } + + logger.debug("Calling native ONNX register") + val result = ONNXBridge.nativeRegister() + logger.debug("Native ONNX register returned: $result") + return result +} + +/** + * JVM/Android implementation of ONNX native unregistration. + */ +internal actual fun ONNX.unregisterNative(): Int { + logger.debug("Calling native ONNX unregister") + val result = ONNXBridge.nativeUnregister() + logger.debug("Native ONNX unregister returned: $result") + return result +} diff --git a/sdk/runanywhere-kotlin/modules/runanywhere-core-onnx/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/core/onnx/ONNXBridge.kt b/sdk/runanywhere-kotlin/modules/runanywhere-core-onnx/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/core/onnx/ONNXBridge.kt new file mode 100644 index 000000000..01daf23e7 --- /dev/null +++ b/sdk/runanywhere-kotlin/modules/runanywhere-core-onnx/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/core/onnx/ONNXBridge.kt @@ -0,0 +1,117 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * ONNX Native Bridge + * + * Self-contained JNI bridge for the ONNX backend module. + * This mirrors the Swift ONNXBackend XCFramework architecture. + * + * The native library (librac_backend_onnx_jni.so) contains: + * - rac_backend_onnx_register() + * - rac_backend_onnx_unregister() + */ + +package com.runanywhere.sdk.core.onnx + +import com.runanywhere.sdk.foundation.SDKLogger + +/** + * Native bridge for ONNX backend registration. + * + * This object handles loading the ONNX-specific JNI library and provides + * JNI methods for backend registration with the C++ service registry. + * + * Architecture: + * - librac_backend_onnx_jni.so - ONNX JNI (this bridge) + * - Links to librac_backend_onnx.so - ONNX C++ backend (STT, TTS, VAD) + * - Links to librac_commons.so - Commons library with service registry + */ +internal object ONNXBridge { + private val logger = SDKLogger.onnx + + @Volatile + private var nativeLibraryLoaded = false + + private val loadLock = Any() + + /** + * Ensure the ONNX JNI library is loaded. + * + * Loads librac_backend_onnx_jni.so and its dependencies: + * - librac_backend_onnx.so (ONNX C++ backend) + * - librac_commons.so (commons library - must be loaded first) + * - libonnxruntime.so + * - libsherpa-onnx-c-api.so + * + * @return true if loaded successfully, false otherwise + */ + fun ensureNativeLibraryLoaded(): Boolean { + if (nativeLibraryLoaded) return true + + synchronized(loadLock) { + if (nativeLibraryLoaded) return true + + logger.info("Loading ONNX native library...") + + try { + // The main SDK's librunanywhere_jni.so must be loaded first + // (provides librac_commons.so with service registry). + // The ONNX JNI provides backend registration functions. + System.loadLibrary("rac_backend_onnx_jni") + nativeLibraryLoaded = true + logger.info("ONNX native library loaded successfully") + return true + } catch (e: UnsatisfiedLinkError) { + logger.error("Failed to load ONNX native library: ${e.message}", throwable = e) + return false + } catch (e: Exception) { + logger.error("Unexpected error loading ONNX native library: ${e.message}", throwable = e) + return false + } + } + } + + /** + * Check if the native library is loaded. + */ + val isLoaded: Boolean + get() = nativeLibraryLoaded + + // ========================================================================== + // JNI Methods + // ========================================================================== + + /** + * Register the ONNX backend with the C++ service registry. + * This registers all ONNX services: STT, TTS, VAD. + * + * @return 0 (RAC_SUCCESS) on success, error code on failure + */ + @JvmStatic + external fun nativeRegister(): Int + + /** + * Unregister the ONNX backend from the C++ service registry. + * + * @return 0 (RAC_SUCCESS) on success, error code on failure + */ + @JvmStatic + external fun nativeUnregister(): Int + + /** + * Check if the ONNX backend is registered. + * + * @return true if registered + */ + @JvmStatic + external fun nativeIsRegistered(): Boolean + + /** + * Get the ONNX Runtime library version. + * + * @return Version string + */ + @JvmStatic + external fun nativeGetVersion(): String +} diff --git a/sdk/runanywhere-kotlin/proguard-rules.pro b/sdk/runanywhere-kotlin/proguard-rules.pro new file mode 100644 index 000000000..215185870 --- /dev/null +++ b/sdk/runanywhere-kotlin/proguard-rules.pro @@ -0,0 +1,63 @@ +# ======================================================================================== +# RunAnywhere SDK - ProGuard Rules +# ======================================================================================== +# These rules ensure the SDK works correctly in release builds with R8/ProGuard enabled. + +# ======================================================================================== +# MASTER RULE: Keep ALL SDK classes +# ======================================================================================== +# The SDK uses dynamic registration, reflection-like patterns, and JNI callbacks. +# We must keep ALL classes, interfaces, enums, and their members. + +-keep class com.runanywhere.sdk.** { *; } +-keep interface com.runanywhere.sdk.** { *; } +-keep enum com.runanywhere.sdk.** { *; } + +# Keep all constructors (critical for JNI object creation like NativeTTSSynthesisResult) +-keepclassmembers class com.runanywhere.sdk.** { + (...); +} + +# Keep companion objects and their members (Kotlin singletons like LlamaCppAdapter.shared) +-keepclassmembers class com.runanywhere.sdk.** { + public static ** Companion; + public static ** INSTANCE; + public static ** shared; +} + +# Prevent obfuscation of class names (important for JNI, logging, and debugging) +-keepnames class com.runanywhere.sdk.** { *; } +-keepnames interface com.runanywhere.sdk.** { *; } +-keepnames enum com.runanywhere.sdk.** { *; } + +# Keep Kotlin metadata for reflection +-keepattributes *Annotation*, Signature, InnerClasses, EnclosingMethod +-keep class kotlin.Metadata { *; } + +# ======================================================================================== +# Native Methods (JNI) +# ======================================================================================== + +-keepclasseswithmembernames class * { + native ; +} + +# ======================================================================================== +# Third-party Dependencies +# ======================================================================================== + +# Whisper JNI +-keep class io.github.givimad.whisperjni.** { *; } +-dontwarn io.github.givimad.whisperjni.** + +# VAD classes +-keep class com.konovalov.vad.** { *; } +-dontwarn com.konovalov.vad.** + +# ONNX Runtime +-keep class ai.onnxruntime.** { *; } +-dontwarn ai.onnxruntime.** + +# Suppress warnings for optional dependencies +-dontwarn org.slf4j.** +-dontwarn ch.qos.logback.** diff --git a/sdk/runanywhere-kotlin/scripts/build-kotlin.sh b/sdk/runanywhere-kotlin/scripts/build-kotlin.sh new file mode 100755 index 000000000..7059cdc2b --- /dev/null +++ b/sdk/runanywhere-kotlin/scripts/build-kotlin.sh @@ -0,0 +1,574 @@ +#!/bin/bash +# ============================================================================= +# RunAnywhere Kotlin SDK - Build Script +# ============================================================================= +# +# Single entry point for building the Kotlin SDK and its C++ dependencies. +# Similar to iOS's build-swift.sh - handles everything from download to build. +# +# USAGE: +# ./scripts/build-kotlin.sh [options] +# +# OPTIONS: +# --setup First-time setup: download deps, build commons, copy libs +# --local Use locally built libs (sets testLocal=true) +# --remote Use remote libs from GitHub releases (sets testLocal=false) +# --rebuild-commons Force rebuild of runanywhere-commons (even if cached) +# --clean Clean build directories before building +# --skip-build Skip Gradle build (only setup native libs) +# --abis=ABIS ABIs to build (default: arm64-v8a) +# Supported: arm64-v8a, armeabi-v7a, x86_64, x86 +# Multiple: Use comma-separated (e.g., arm64-v8a,armeabi-v7a) +# --help Show this help message +# +# ABI Guide: +# arm64-v8a 64-bit ARM (modern devices, ~85% coverage) +# armeabi-v7a 32-bit ARM (older devices, ~12% coverage) +# x86_64 64-bit Intel (emulators on Intel Macs, ~2%) +# +# EXAMPLES: +# # First-time setup (modern devices only, ~4min build) +# ./scripts/build-kotlin.sh --setup +# +# # RECOMMENDED for production (97% device coverage, ~7min build) +# ./scripts/build-kotlin.sh --setup --abis=arm64-v8a,armeabi-v7a +# +# # Development with emulator support (device + Intel Mac emulator) +# ./scripts/build-kotlin.sh --setup --abis=arm64-v8a,x86_64 +# +# # Rebuild only commons (after C++ code changes) +# ./scripts/build-kotlin.sh --local --rebuild-commons +# +# # Rebuild with multiple ABIs +# ./scripts/build-kotlin.sh --local --rebuild-commons --abis=arm64-v8a,armeabi-v7a +# +# # Just switch to local mode (uses cached libs) +# ./scripts/build-kotlin.sh --local --skip-build +# +# # Clean build everything +# ./scripts/build-kotlin.sh --setup --clean +# +# ============================================================================= + +set -e + +# ============================================================================= +# Configuration +# ============================================================================= + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +KOTLIN_SDK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +SDK_ROOT="$(cd "${KOTLIN_SDK_DIR}/.." && pwd)" +COMMONS_DIR="${SDK_ROOT}/runanywhere-commons" +COMMONS_BUILD_SCRIPT="${COMMONS_DIR}/scripts/build-android.sh" + +# Output directories +MAIN_JNILIBS_DIR="${KOTLIN_SDK_DIR}/src/androidMain/jniLibs" +LLAMACPP_JNILIBS_DIR="${KOTLIN_SDK_DIR}/modules/runanywhere-core-llamacpp/src/androidMain/jniLibs" +ONNX_JNILIBS_DIR="${KOTLIN_SDK_DIR}/modules/runanywhere-core-onnx/src/androidMain/jniLibs" + +# Defaults +MODE="local" +SETUP_MODE=false +REBUILD_COMMONS=false +CLEAN_BUILD=false +SKIP_BUILD=false +ABIS="arm64-v8a" + +# ============================================================================= +# Colors & Logging +# ============================================================================= + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +NC='\033[0m' + +log_header() { + echo "" + echo -e "${GREEN}═══════════════════════════════════════════${NC}" + echo -e "${GREEN} $1${NC}" + echo -e "${GREEN}═══════════════════════════════════════════${NC}" +} + +log_step() { + echo -e "${BLUE}==>${NC} $1" +} + +log_info() { + echo -e "${CYAN}[✓]${NC} $1" +} + +log_warn() { + echo -e "${YELLOW}[!]${NC} $1" +} + +log_error() { + echo -e "${RED}[✗]${NC} $1" +} + +# ============================================================================= +# Argument Parsing +# ============================================================================= + +show_help() { + head -50 "$0" | tail -45 + exit 0 +} + +for arg in "$@"; do + case $arg in + --setup) + SETUP_MODE=true + ;; + --local) + MODE="local" + ;; + --remote) + MODE="remote" + ;; + --rebuild-commons) + REBUILD_COMMONS=true + ;; + --clean) + CLEAN_BUILD=true + ;; + --skip-build) + SKIP_BUILD=true + ;; + --abis=*) + ABIS="${arg#*=}" + ;; + --help|-h) + show_help + ;; + *) + log_error "Unknown option: $arg" + echo "Use --help for usage information" + exit 1 + ;; + esac +done + +# ============================================================================= +# Validation +# ============================================================================= + +validate_environment() { + # Check NDK + if [ -z "${ANDROID_NDK_HOME}" ]; then + # Try to find NDK + if [ -d "${HOME}/Library/Android/sdk/ndk" ]; then + ANDROID_NDK_HOME=$(ls -d "${HOME}/Library/Android/sdk/ndk/"* 2>/dev/null | sort -V | tail -1) + export ANDROID_NDK_HOME + fi + fi + + if [ -z "${ANDROID_NDK_HOME}" ] || [ ! -d "${ANDROID_NDK_HOME}" ]; then + log_error "ANDROID_NDK_HOME not set or NDK not found" + echo "Please set ANDROID_NDK_HOME or install NDK via Android Studio" + exit 1 + fi + + # Check Commons + if [ ! -d "${COMMONS_DIR}" ]; then + log_error "runanywhere-commons not found at ${COMMONS_DIR}" + exit 1 + fi + + # Check build script + if [ ! -f "${COMMONS_BUILD_SCRIPT}" ]; then + log_error "Android build script not found: ${COMMONS_BUILD_SCRIPT}" + exit 1 + fi +} + +# ============================================================================= +# Check if JNI libs need to be rebuilt +# ============================================================================= + +check_libs_exist() { + local abi="$1" + + # Check main SDK libs + if [ ! -f "${MAIN_JNILIBS_DIR}/${abi}/libc++_shared.so" ]; then + return 1 + fi + + # Check LlamaCPP module + if [ ! -f "${LLAMACPP_JNILIBS_DIR}/${abi}/librac_backend_llamacpp_jni.so" ]; then + return 1 + fi + + # Check ONNX module + if [ ! -f "${ONNX_JNILIBS_DIR}/${abi}/librac_backend_onnx_jni.so" ]; then + return 1 + fi + + return 0 +} + +check_commons_changed() { + local marker_file="${KOTLIN_SDK_DIR}/.commons-build-marker" + + if [ ! -f "$marker_file" ]; then + return 0 # No marker = needs rebuild + fi + + # Check if any C++ source files are newer than the marker + local newer_files=$(find "${COMMONS_DIR}/src" -name "*.cpp" -o -name "*.h" 2>/dev/null | \ + xargs stat -f "%m %N" 2>/dev/null | \ + while read mtime file; do + marker_mtime=$(stat -f "%m" "$marker_file" 2>/dev/null || echo 0) + if [ "$mtime" -gt "$marker_mtime" ]; then + echo "$file" + fi + done | head -1) + + if [ -n "$newer_files" ]; then + return 0 # Changed + fi + + return 1 # No changes +} + +# ============================================================================= +# Build Functions +# ============================================================================= + +download_dependencies() { + log_header "Downloading Dependencies" + + cd "${COMMONS_DIR}" + + # Download Sherpa-ONNX for Android + if [ -f "scripts/android/download-sherpa-onnx.sh" ]; then + log_step "Downloading Sherpa-ONNX for Android..." + ./scripts/android/download-sherpa-onnx.sh + fi + + log_info "Dependencies downloaded" +} + +build_commons() { + log_header "Building runanywhere-commons for Android" + + cd "${COMMONS_DIR}" + + local FLAGS="" + if [ "$CLEAN_BUILD" = true ]; then + FLAGS="--clean" + # Clean Android build directory + rm -rf "${COMMONS_DIR}/build/android" + rm -rf "${COMMONS_DIR}/dist/android" + fi + + log_step "Running: build-android.sh" + log_info "Building for ABIs: ${ABIS}" + log_info "Building backends: llamacpp,onnx (WhisperCPP disabled due to ggml version conflict)" + log_info "This may take several minutes..." + echo "" + + # Build for Android - only llamacpp and onnx (WhisperCPP has ggml version conflict) + "${COMMONS_BUILD_SCRIPT}" llamacpp,onnx "${ABIS}" + + # Update build marker + touch "${KOTLIN_SDK_DIR}/.commons-build-marker" + + log_info "runanywhere-commons build complete" +} + +copy_jni_libs() { + log_header "Copying JNI Libraries" + + # Source directories from runanywhere-commons build + local COMMONS_DIST="${COMMONS_DIR}/dist/android" + local COMMONS_BUILD="${COMMONS_DIR}/build/android/unified" + local SHERPA_ONNX_LIBS="${COMMONS_DIR}/third_party/sherpa-onnx-android/jniLibs" + + # Clean output directories + if [ "$CLEAN_BUILD" = true ]; then + log_step "Cleaning JNI directories..." + rm -rf "${MAIN_JNILIBS_DIR}" + rm -rf "${LLAMACPP_JNILIBS_DIR}" + rm -rf "${ONNX_JNILIBS_DIR}" + fi + + # Parse ABIs + local ABI_LIST + if [[ "${ABIS}" == "all" ]]; then + ABI_LIST="arm64-v8a armeabi-v7a x86_64" + else + ABI_LIST=$(echo "${ABIS}" | tr ',' ' ') + fi + + for ABI in ${ABI_LIST}; do + log_step "Copying libraries for ${ABI}..." + + # Create directories + mkdir -p "${MAIN_JNILIBS_DIR}/${ABI}" + mkdir -p "${LLAMACPP_JNILIBS_DIR}/${ABI}" + mkdir -p "${ONNX_JNILIBS_DIR}/${ABI}" + + # ======================================================================= + # Main SDK (Commons): Core JNI + libc++_shared.so + librac_commons.so + # ======================================================================= + + # Copy librunanywhere_jni.so (CORE JNI BRIDGE - REQUIRED) + if [ -f "${COMMONS_DIST}/jni/${ABI}/librunanywhere_jni.so" ]; then + cp "${COMMONS_DIST}/jni/${ABI}/librunanywhere_jni.so" "${MAIN_JNILIBS_DIR}/${ABI}/" + log_info "Main SDK: librunanywhere_jni.so" + elif [ -f "${COMMONS_BUILD}/${ABI}/src/jni/librunanywhere_jni.so" ]; then + cp "${COMMONS_BUILD}/${ABI}/src/jni/librunanywhere_jni.so" "${MAIN_JNILIBS_DIR}/${ABI}/" + log_info "Main SDK: librunanywhere_jni.so (from build)" + else + log_warn "Main SDK: librunanywhere_jni.so NOT FOUND - App will crash!" + fi + + # Copy libc++_shared.so from dist or NDK + if [ -f "${COMMONS_DIST}/llamacpp/${ABI}/libc++_shared.so" ]; then + cp "${COMMONS_DIST}/llamacpp/${ABI}/libc++_shared.so" "${MAIN_JNILIBS_DIR}/${ABI}/" + log_info "Main SDK: libc++_shared.so" + elif [ -f "${COMMONS_DIST}/jni/${ABI}/libc++_shared.so" ]; then + cp "${COMMONS_DIST}/jni/${ABI}/libc++_shared.so" "${MAIN_JNILIBS_DIR}/${ABI}/" + log_info "Main SDK: libc++_shared.so" + fi + + # Copy librac_commons.so + if [ -f "${COMMONS_BUILD}/${ABI}/librac_commons.so" ]; then + cp "${COMMONS_BUILD}/${ABI}/librac_commons.so" "${MAIN_JNILIBS_DIR}/${ABI}/" + log_info "Main SDK: librac_commons.so" + fi + + # Copy libomp.so from dist + if [ -f "${COMMONS_DIST}/llamacpp/${ABI}/libomp.so" ]; then + cp "${COMMONS_DIST}/llamacpp/${ABI}/libomp.so" "${MAIN_JNILIBS_DIR}/${ABI}/" + log_info "Main SDK: libomp.so" + elif [ -f "${COMMONS_DIST}/jni/${ABI}/libomp.so" ]; then + cp "${COMMONS_DIST}/jni/${ABI}/libomp.so" "${MAIN_JNILIBS_DIR}/${ABI}/" + log_info "Main SDK: libomp.so" + fi + + # ======================================================================= + # LlamaCPP Module: Backend + JNI bridge + # ======================================================================= + # Copy backend library + if [ -f "${COMMONS_DIST}/llamacpp/${ABI}/librac_backend_llamacpp.so" ]; then + cp "${COMMONS_DIST}/llamacpp/${ABI}/librac_backend_llamacpp.so" "${LLAMACPP_JNILIBS_DIR}/${ABI}/" + log_info "LlamaCPP: librac_backend_llamacpp.so" + elif [ -f "${COMMONS_BUILD}/${ABI}/src/backends/llamacpp/librac_backend_llamacpp.so" ]; then + cp "${COMMONS_BUILD}/${ABI}/src/backends/llamacpp/librac_backend_llamacpp.so" "${LLAMACPP_JNILIBS_DIR}/${ABI}/" + log_info "LlamaCPP: librac_backend_llamacpp.so (from build)" + fi + + # Copy JNI bridge + if [ -f "${COMMONS_DIST}/llamacpp/${ABI}/librac_backend_llamacpp_jni.so" ]; then + cp "${COMMONS_DIST}/llamacpp/${ABI}/librac_backend_llamacpp_jni.so" "${LLAMACPP_JNILIBS_DIR}/${ABI}/" + log_info "LlamaCPP: librac_backend_llamacpp_jni.so" + elif [ -f "${COMMONS_BUILD}/${ABI}/src/backends/llamacpp/librac_backend_llamacpp_jni.so" ]; then + cp "${COMMONS_BUILD}/${ABI}/src/backends/llamacpp/librac_backend_llamacpp_jni.so" "${LLAMACPP_JNILIBS_DIR}/${ABI}/" + log_info "LlamaCPP: librac_backend_llamacpp_jni.so (from build)" + fi + + # ======================================================================= + # ONNX Module: ONNX Runtime + Sherpa-ONNX + JNI bridge + # ======================================================================= + # Copy backend library + if [ -f "${COMMONS_DIST}/onnx/${ABI}/librac_backend_onnx.so" ]; then + cp "${COMMONS_DIST}/onnx/${ABI}/librac_backend_onnx.so" "${ONNX_JNILIBS_DIR}/${ABI}/" + log_info "ONNX: librac_backend_onnx.so" + elif [ -f "${COMMONS_BUILD}/${ABI}/src/backends/onnx/librac_backend_onnx.so" ]; then + cp "${COMMONS_BUILD}/${ABI}/src/backends/onnx/librac_backend_onnx.so" "${ONNX_JNILIBS_DIR}/${ABI}/" + log_info "ONNX: librac_backend_onnx.so (from build)" + fi + + # Copy JNI bridge + if [ -f "${COMMONS_DIST}/onnx/${ABI}/librac_backend_onnx_jni.so" ]; then + cp "${COMMONS_DIST}/onnx/${ABI}/librac_backend_onnx_jni.so" "${ONNX_JNILIBS_DIR}/${ABI}/" + log_info "ONNX: librac_backend_onnx_jni.so" + elif [ -f "${COMMONS_BUILD}/${ABI}/src/backends/onnx/librac_backend_onnx_jni.so" ]; then + cp "${COMMONS_BUILD}/${ABI}/src/backends/onnx/librac_backend_onnx_jni.so" "${ONNX_JNILIBS_DIR}/${ABI}/" + log_info "ONNX: librac_backend_onnx_jni.so (from build)" + fi + + # Copy Sherpa-ONNX and ONNX Runtime from dist or third_party + if [ -d "${COMMONS_DIST}/onnx/${ABI}" ]; then + for lib in libonnxruntime.so libsherpa-onnx-c-api.so libsherpa-onnx-cxx-api.so libsherpa-onnx-jni.so; do + if [ -f "${COMMONS_DIST}/onnx/${ABI}/${lib}" ]; then + cp "${COMMONS_DIST}/onnx/${ABI}/${lib}" "${ONNX_JNILIBS_DIR}/${ABI}/" + log_info "ONNX: ${lib}" + fi + done + elif [ -d "${SHERPA_ONNX_LIBS}/${ABI}" ]; then + for lib in "${SHERPA_ONNX_LIBS}/${ABI}"/*.so; do + if [ -f "$lib" ]; then + cp "$lib" "${ONNX_JNILIBS_DIR}/${ABI}/" + log_info "ONNX: $(basename $lib)" + fi + done + fi + done + + log_info "JNI libraries installed" +} + +set_gradle_mode() { + local mode="$1" + local properties_file="${KOTLIN_SDK_DIR}/gradle.properties" + + log_step "Setting testLocal=${mode} in gradle.properties" + + if [ "$mode" = "local" ]; then + sed -i '' 's/runanywhere.testLocal=false/runanywhere.testLocal=true/' "$properties_file" + log_info "Switched to LOCAL mode (using jniLibs/)" + else + sed -i '' 's/runanywhere.testLocal=true/runanywhere.testLocal=false/' "$properties_file" + log_info "Switched to REMOTE mode (downloading from GitHub)" + fi +} + +build_sdk() { + log_header "Building Kotlin SDK" + + cd "${KOTLIN_SDK_DIR}" + + local FLAGS="-Prunanywhere.testLocal=${MODE:0:1}" # "true" for local, "false" for remote + if [ "$MODE" = "local" ]; then + FLAGS="-Prunanywhere.testLocal=true" + else + FLAGS="-Prunanywhere.testLocal=false" + fi + + log_step "Running: ./gradlew assembleDebug $FLAGS" + + if ./gradlew assembleDebug $FLAGS --no-daemon -q; then + log_info "Kotlin SDK built successfully" + else + log_error "Kotlin SDK build failed" + exit 1 + fi +} + +# ============================================================================= +# Main +# ============================================================================= + +main() { + log_header "RunAnywhere Kotlin SDK - Build" + + echo "Project: ${KOTLIN_SDK_DIR}" + echo "Commons: ${COMMONS_DIR}" + echo "Mode: ${MODE}" + echo "Setup: ${SETUP_MODE}" + echo "Rebuild Commons: ${REBUILD_COMMONS}" + echo "ABIs: ${ABIS}" + echo "" + + validate_environment + + # ========================================================================== + # Setup Mode: Full first-time setup + # ========================================================================== + if [ "$SETUP_MODE" = true ]; then + log_header "Running Initial Setup for Local Development" + + # 1. Download dependencies + download_dependencies + + # 2. Build commons + build_commons + + # 3. Copy JNI libs + copy_jni_libs + + # 4. Set local mode + set_gradle_mode "local" + + log_info "Initial setup complete!" + else + # ========================================================================== + # Normal Mode: Check what needs to be done + # ========================================================================== + + # Set mode if specified + if [ "$MODE" = "local" ]; then + set_gradle_mode "local" + elif [ "$MODE" = "remote" ]; then + set_gradle_mode "remote" + fi + + # In local mode, check if we need to rebuild + if [ "$MODE" = "local" ]; then + local need_rebuild=false + + # Check if libs exist + for abi in $(echo "${ABIS}" | tr ',' ' '); do + if ! check_libs_exist "$abi"; then + log_warn "JNI libs missing for $abi - need to build" + need_rebuild=true + break + fi + done + + # Check if commons changed + if [ "$REBUILD_COMMONS" = true ]; then + log_info "Forced rebuild of commons" + need_rebuild=true + elif check_commons_changed; then + log_warn "Commons source changed - need to rebuild" + need_rebuild=true + fi + + if [ "$need_rebuild" = true ]; then + download_dependencies + build_commons + copy_jni_libs + else + log_info "JNI libs up to date (use --rebuild-commons to force)" + fi + fi + fi + + # ========================================================================== + # Build SDK + # ========================================================================== + if [ "$SKIP_BUILD" = false ]; then + build_sdk + else + log_info "Skipping Gradle build (--skip-build)" + fi + + # ========================================================================== + # Summary + # ========================================================================== + log_header "Build Complete!" + + echo "" + echo "JNI Libraries:" + for dir in "$MAIN_JNILIBS_DIR" "$LLAMACPP_JNILIBS_DIR" "$ONNX_JNILIBS_DIR"; do + if [ -d "$dir" ]; then + local count=$(find "$dir" -name "*.so" 2>/dev/null | wc -l | tr -d ' ') + local size=$(du -sh "$dir" 2>/dev/null | cut -f1) + local name=$(basename "$(dirname "$dir")") + echo " $(basename "$dir"): ${count} libs (${size})" + fi + done + + echo "" + echo "gradle.properties: runanywhere.testLocal=$(grep 'runanywhere.testLocal' "${KOTLIN_SDK_DIR}/gradle.properties" | cut -d= -f2)" + echo "" + + if [ "$MODE" = "local" ]; then + echo "Next steps:" + echo " 1. Open project in Android Studio" + echo " 2. Sync Gradle" + echo " 3. Build and run on device" + echo "" + echo "To rebuild after C++ changes:" + echo " ./scripts/build-kotlin.sh --local --rebuild-commons" + fi +} + +main diff --git a/sdk/runanywhere-kotlin/secrets.template.properties b/sdk/runanywhere-kotlin/secrets.template.properties new file mode 100644 index 000000000..284c490d6 --- /dev/null +++ b/sdk/runanywhere-kotlin/secrets.template.properties @@ -0,0 +1,78 @@ +# ============================================================================= +# RunAnywhere SDK - Maven Central Publishing Secrets +# ============================================================================= +# +# INSTRUCTIONS: +# 1. Copy this file to ~/.gradle/gradle.properties (for local publishing) +# 2. Or add these as GitHub Secrets (for CI/CD publishing) +# 3. NEVER commit this file with real values to git! +# +# ============================================================================= + +# ----------------------------------------------------------------------------- +# MAVEN CENTRAL (Sonatype Central Portal) +# ----------------------------------------------------------------------------- +# Get these from: https://central.sonatype.com → Settings → Generate User Token +# +mavenCentral.username=REPLACE_WITH_SONATYPE_TOKEN_USERNAME +mavenCentral.password=REPLACE_WITH_SONATYPE_TOKEN_PASSWORD + +# ----------------------------------------------------------------------------- +# GPG SIGNING +# ----------------------------------------------------------------------------- +# Key ID: Last 8 characters of your GPG key fingerprint +# Run: gpg --list-secret-keys --keyid-format LONG +# Example output: sec rsa4096/ABCD1234EFGH5678 → Key ID is EFGH5678 +# +signing.gnupg.executable=gpg +signing.gnupg.useLegacyGpg=false +signing.gnupg.keyName=REPLACE_WITH_GPG_KEY_ID +signing.gnupg.passphrase=REPLACE_WITH_GPG_PASSPHRASE + +# ----------------------------------------------------------------------------- +# GITHUB SECRETS (for CI/CD - copy values to GitHub repo secrets) +# ----------------------------------------------------------------------------- +# Secret Name | Value +# ---------------------------|-------------------------------------------------- +# MAVEN_CENTRAL_USERNAME | (same as mavenCentral.username above) +# MAVEN_CENTRAL_PASSWORD | (same as mavenCentral.password above) +# GPG_KEY_ID | (same as signing.gnupg.keyName above) +# GPG_SIGNING_PASSWORD | (same as signing.gnupg.passphrase above) +# GPG_SIGNING_KEY | Full armored GPG private key (see below) +# +# To export GPG_SIGNING_KEY: +# gpg --armor --export-secret-keys YOUR_KEY_ID +# +# The output looks like: +# -----BEGIN PGP PRIVATE KEY BLOCK----- +# ...base64 encoded key... +# -----END PGP PRIVATE KEY BLOCK----- +# +# Copy the ENTIRE output (including BEGIN/END lines) as the secret value. + +# ----------------------------------------------------------------------------- +# GITHUB PACKAGES (Optional - backup repository) +# ----------------------------------------------------------------------------- +# gpr.user=REPLACE_WITH_GITHUB_USERNAME +# gpr.token=REPLACE_WITH_GITHUB_PAT + +# ============================================================================= +# HOW TO GET CREDENTIALS +# ============================================================================= +# +# 1. SONATYPE CENTRAL PORTAL: +# - Go to https://central.sonatype.com +# - Sign in (use GitHub OAuth recommended) +# - Settings → Generate User Token +# - Save both username and password/token +# +# 2. GPG KEY: +# - Install GPG: brew install gnupg +# - Generate key: gpg --full-generate-key (RSA 4096, no expiry) +# - List keys: gpg --list-secret-keys --keyid-format LONG +# - Upload to keyservers: +# gpg --keyserver keyserver.ubuntu.com --send-keys YOUR_KEY_ID +# gpg --keyserver keys.openpgp.org --send-keys YOUR_KEY_ID +# - IMPORTANT: Verify email at keys.openpgp.org for Maven Central +# +# ============================================================================= diff --git a/sdk/runanywhere-kotlin/settings.gradle.kts b/sdk/runanywhere-kotlin/settings.gradle.kts new file mode 100644 index 000000000..830007199 --- /dev/null +++ b/sdk/runanywhere-kotlin/settings.gradle.kts @@ -0,0 +1,47 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + maven { url = uri("https://jitpack.io") } + } + versionCatalogs { + create("libs") { + from(files("../../gradle/libs.versions.toml")) + } + } +} + +rootProject.name = "RunAnywhereKotlinSDK" + +// ============================================================================= +// RunAnywhere Backend Modules (mirrors iOS Swift Package architecture) +// ============================================================================= + +// Native libs (.so files) are built from runanywhere-commons and included +// directly in the main SDK's jniLibs folder. No separate native module needed. +// See: runanywhere-commons/scripts/build-android.sh + +// LlamaCPP module - thin wrapper that calls C++ backend registration +// Single file: LlamaCPP.kt which calls rac_backend_llamacpp_register() +// Matches iOS: Sources/LlamaCPPRuntime/LlamaCPP.swift +include(":modules:runanywhere-core-llamacpp") + +// ONNX module - thin wrapper that calls C++ backend registration +// Single file: ONNX.kt which calls rac_backend_onnx_register() +// Matches iOS: Sources/ONNXRuntime/ONNX.swift +include(":modules:runanywhere-core-onnx") diff --git a/sdk/runanywhere-kotlin/src/androidMain/AndroidManifest.xml/AndroidManifest.xml b/sdk/runanywhere-kotlin/src/androidMain/AndroidManifest.xml/AndroidManifest.xml new file mode 100644 index 000000000..6d1593d4a --- /dev/null +++ b/sdk/runanywhere-kotlin/src/androidMain/AndroidManifest.xml/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/data/models/DeviceInfoModels.kt b/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/data/models/DeviceInfoModels.kt new file mode 100644 index 000000000..cac17ae61 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/data/models/DeviceInfoModels.kt @@ -0,0 +1,7 @@ +package com.runanywhere.sdk.data.models + +import android.os.Build + +actual fun getPlatformAPILevel(): Int = Build.VERSION.SDK_INT + +actual fun getPlatformOSVersion(): String = "Android ${Build.VERSION.RELEASE}" diff --git a/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/features/stt/AndroidAudioCaptureManager.kt b/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/features/stt/AndroidAudioCaptureManager.kt new file mode 100644 index 000000000..72f7472ba --- /dev/null +++ b/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/features/stt/AndroidAudioCaptureManager.kt @@ -0,0 +1,210 @@ +package com.runanywhere.sdk.features.stt + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.media.AudioFormat +import android.media.AudioRecord +import android.media.MediaRecorder +import androidx.core.content.ContextCompat +import com.runanywhere.sdk.foundation.SDKLogger +import com.runanywhere.sdk.foundation.currentTimeMillis +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.isActive +import kotlin.coroutines.coroutineContext +import kotlin.math.sqrt + +/** + * Platform-specific factory for Android + */ +actual fun createAudioCaptureManager(): AudioCaptureManager = AndroidAudioCaptureManager() + +/** + * Android implementation of AudioCaptureManager using AudioRecord. + * Captures audio at 16kHz mono 16-bit PCM format. + * + * Matches iOS AudioCaptureManager behavior exactly. + */ +class AndroidAudioCaptureManager : AudioCaptureManager { + private val logger = SDKLogger.stt + + private var audioRecord: AudioRecord? = null + + private val _isRecording = MutableStateFlow(false) + override val isRecording: StateFlow = _isRecording.asStateFlow() + + private val _audioLevel = MutableStateFlow(0.0f) + override val audioLevel: StateFlow = _audioLevel.asStateFlow() + + override val targetSampleRate: Int = 16000 + + // Buffer size for ~100ms of audio at 16kHz (1600 samples * 2 bytes) + private val bufferSize: Int by lazy { + val minBufferSize = + AudioRecord.getMinBufferSize( + targetSampleRate, + AudioFormat.CHANNEL_IN_MONO, + AudioFormat.ENCODING_PCM_16BIT, + ) + maxOf(minBufferSize, 3200) // At least 100ms of audio + } + + init { + logger.info("AndroidAudioCaptureManager initialized") + } + + override suspend fun requestPermission(): Boolean { + // On Android, permission must be requested through the Activity + // This method checks if permission is already granted + // Actual permission request must be done via Activity.requestPermissions() + logger.info("Checking microphone permission (request must be done via Activity)") + return hasPermission() + } + + override suspend fun hasPermission(): Boolean { + return try { + val context = getApplicationContext() + if (context != null) { + ContextCompat.checkSelfPermission( + context, + Manifest.permission.RECORD_AUDIO, + ) == PackageManager.PERMISSION_GRANTED + } else { + logger.warning("Cannot check permission - no application context") + false + } + } catch (e: Exception) { + logger.error("Error checking permission: ${e.message}") + false + } + } + + override suspend fun startRecording(): Flow = + flow { + if (_isRecording.value) { + logger.warning("Already recording") + return@flow + } + + if (!hasPermission()) { + throw AudioCaptureError.PermissionDenied + } + + try { + // Create AudioRecord + val record = + AudioRecord( + MediaRecorder.AudioSource.MIC, + targetSampleRate, + AudioFormat.CHANNEL_IN_MONO, + AudioFormat.ENCODING_PCM_16BIT, + bufferSize, + ) + + if (record.state != AudioRecord.STATE_INITIALIZED) { + record.release() + throw AudioCaptureError.InitializationFailed("AudioRecord failed to initialize") + } + + audioRecord = record + record.startRecording() + _isRecording.value = true + + logger.info("Recording started - sampleRate: $targetSampleRate, bufferSize: $bufferSize") + + // Audio capture loop + val buffer = ShortArray(bufferSize / 2) // 16-bit samples + + while (coroutineContext.isActive && _isRecording.value) { + val readCount = record.read(buffer, 0, buffer.size) + + if (readCount > 0) { + // Convert shorts to bytes + val byteData = ByteArray(readCount * 2) + for (i in 0 until readCount) { + val sample = buffer[i].toInt() + byteData[i * 2] = (sample and 0xFF).toByte() + byteData[i * 2 + 1] = ((sample shr 8) and 0xFF).toByte() + } + + // Update audio level for visualization + updateAudioLevel(buffer, readCount) + + // Emit audio chunk + emit(AudioChunk(byteData, currentTimeMillis())) + } else if (readCount < 0) { + logger.error("AudioRecord.read error: $readCount") + break + } + } + } catch (e: SecurityException) { + logger.error("Security exception: ${e.message}") + throw AudioCaptureError.PermissionDenied + } catch (e: AudioCaptureError) { + throw e + } catch (e: Exception) { + logger.error("Recording error: ${e.message}", throwable = e) + throw AudioCaptureError.RecordingFailed(e.message ?: "Unknown error") + } finally { + stopRecordingInternal() + } + }.flowOn(Dispatchers.IO) + + override fun stopRecording() { + if (!_isRecording.value) return + stopRecordingInternal() + } + + private fun stopRecordingInternal() { + try { + audioRecord?.stop() + audioRecord?.release() + audioRecord = null + } catch (e: Exception) { + logger.error("Error stopping recording: ${e.message}") + } + + _isRecording.value = false + _audioLevel.value = 0.0f + logger.info("Recording stopped") + } + + override suspend fun cleanup() { + stopRecording() + } + + private fun updateAudioLevel(buffer: ShortArray, count: Int) { + if (count <= 0) return + + // Calculate RMS (root mean square) for audio level + var sum = 0.0 + for (i in 0 until count) { + val sample = buffer[i].toDouble() / 32768.0 // Normalize to -1.0 to 1.0 + sum += sample * sample + } + + val rms = sqrt(sum / count).toFloat() + val dbLevel = 20 * kotlin.math.log10((rms + 0.0001).toDouble()) // Add small value to avoid log(0) + + // Normalize to 0-1 range (-60dB to 0dB) + val normalizedLevel = ((dbLevel + 60) / 60).coerceIn(0.0, 1.0).toFloat() + _audioLevel.value = normalizedLevel + } + + private fun getApplicationContext(): Context? { + // Try to get context through reflection (common pattern in KMP) + return try { + val activityThread = Class.forName("android.app.ActivityThread") + val currentApplication = activityThread.getMethod("currentApplication") + currentApplication.invoke(null) as? Context + } catch (e: Exception) { + null + } + } +} diff --git a/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/features/tts/AudioPlaybackManager.kt b/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/features/tts/AudioPlaybackManager.kt new file mode 100644 index 000000000..ea247298b --- /dev/null +++ b/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/features/tts/AudioPlaybackManager.kt @@ -0,0 +1,265 @@ +package com.runanywhere.sdk.features.tts + +import android.media.AudioAttributes +import android.media.AudioFormat +import android.media.AudioTrack +import com.runanywhere.sdk.foundation.SDKLogger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +/** + * Manages audio playback for TTS services on Android. + * Plays WAV audio data (16-bit PCM format) generated by TTS synthesis. + * + * Matches iOS AudioPlaybackManager behavior. + */ +class AudioPlaybackManager { + private val logger = SDKLogger.tts + + private var audioTrack: AudioTrack? = null + + @Volatile + var isPlaying: Boolean = false + private set + + /** + * Play WAV audio data asynchronously. + * + * @param audioData WAV audio data to play (including WAV header) + * @throws AudioPlaybackException if playback fails + */ + suspend fun play(audioData: ByteArray) { + if (audioData.isEmpty()) { + throw AudioPlaybackException.EmptyAudioData + } + + withContext(Dispatchers.IO) { + try { + // Parse WAV header to get audio parameters + val wavInfo = parseWavHeader(audioData) + logger.info("Playing audio: ${audioData.size} bytes, ${wavInfo.sampleRate}Hz, ${wavInfo.channels}ch") + + // Get PCM data (skip WAV header) + val pcmData = audioData.copyOfRange(wavInfo.dataOffset, audioData.size) + + playPcmData(pcmData, wavInfo.sampleRate, wavInfo.channels, wavInfo.bitsPerSample) + + logger.info("Playback completed") + } catch (e: Exception) { + logger.error("Playback failed: ${e.message}") + throw if (e is AudioPlaybackException) e else AudioPlaybackException.PlaybackFailed(e.message) + } + } + } + + /** + * Stop current playback. + */ + fun stop() { + if (!isPlaying) return + + try { + audioTrack?.stop() + audioTrack?.release() + audioTrack = null + } catch (e: Exception) { + logger.error("Error stopping playback: ${e.message}") + } + + isPlaying = false + logger.info("Playback stopped") + } + + private suspend fun playPcmData( + pcmData: ByteArray, + sampleRate: Int, + channels: Int, + bitsPerSample: Int, + ) = suspendCancellableCoroutine { continuation -> + try { + val channelConfig = + if (channels == 1) { + AudioFormat.CHANNEL_OUT_MONO + } else { + AudioFormat.CHANNEL_OUT_STEREO + } + + val audioFormat = + if (bitsPerSample == 16) { + AudioFormat.ENCODING_PCM_16BIT + } else { + AudioFormat.ENCODING_PCM_8BIT + } + + val minBufferSize = + AudioTrack.getMinBufferSize( + sampleRate, + channelConfig, + audioFormat, + ) + + if (minBufferSize == AudioTrack.ERROR || minBufferSize == AudioTrack.ERROR_BAD_VALUE) { + continuation.resumeWithException(AudioPlaybackException.InvalidAudioFormat) + return@suspendCancellableCoroutine + } + + val bufferSize = maxOf(minBufferSize, pcmData.size) + + val track = + AudioTrack + .Builder() + .setAudioAttributes( + AudioAttributes + .Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .build(), + ).setAudioFormat( + AudioFormat + .Builder() + .setEncoding(audioFormat) + .setSampleRate(sampleRate) + .setChannelMask(channelConfig) + .build(), + ).setBufferSizeInBytes(bufferSize) + .setTransferMode(AudioTrack.MODE_STATIC) + .build() + + audioTrack = track + isPlaying = true + + // Write all data + val bytesWritten = track.write(pcmData, 0, pcmData.size) + if (bytesWritten < 0) { + track.release() + audioTrack = null + isPlaying = false + continuation.resumeWithException(AudioPlaybackException.PlaybackFailed("Write failed: $bytesWritten")) + return@suspendCancellableCoroutine + } + + // Set notification marker at end + track.notificationMarkerPosition = pcmData.size / (bitsPerSample / 8 * channels) + track.setPlaybackPositionUpdateListener( + object : AudioTrack.OnPlaybackPositionUpdateListener { + override fun onMarkerReached(track: AudioTrack?) { + isPlaying = false + track?.stop() + track?.release() + audioTrack = null + continuation.resume(Unit) + } + + override fun onPeriodicNotification(track: AudioTrack?) { + // Not used + } + }, + ) + + // Handle cancellation + continuation.invokeOnCancellation { + stop() + } + + // Start playback + track.play() + } catch (e: Exception) { + isPlaying = false + audioTrack?.release() + audioTrack = null + continuation.resumeWithException(e) + } + } + + private fun parseWavHeader(data: ByteArray): WavInfo { + if (data.size < 44) { + throw AudioPlaybackException.InvalidAudioFormat + } + + // Check RIFF header + val riff = String(data.copyOfRange(0, 4)) + if (riff != "RIFF") { + throw AudioPlaybackException.InvalidAudioFormat + } + + // Check WAVE format + val wave = String(data.copyOfRange(8, 12)) + if (wave != "WAVE") { + throw AudioPlaybackException.InvalidAudioFormat + } + + // Parse fmt chunk + val channels = (data[22].toInt() and 0xFF) or ((data[23].toInt() and 0xFF) shl 8) + val sampleRate = + (data[24].toInt() and 0xFF) or + ((data[25].toInt() and 0xFF) shl 8) or + ((data[26].toInt() and 0xFF) shl 16) or + ((data[27].toInt() and 0xFF) shl 24) + val bitsPerSample = (data[34].toInt() and 0xFF) or ((data[35].toInt() and 0xFF) shl 8) + + // Find data chunk (usually at offset 44 but can vary) + var dataOffset = 12 + while (dataOffset < data.size - 8) { + val chunkId = String(data.copyOfRange(dataOffset, dataOffset + 4)) + val chunkSize = + (data[dataOffset + 4].toInt() and 0xFF) or + ((data[dataOffset + 5].toInt() and 0xFF) shl 8) or + ((data[dataOffset + 6].toInt() and 0xFF) shl 16) or + ((data[dataOffset + 7].toInt() and 0xFF) shl 24) + + if (chunkId == "data") { + dataOffset += 8 // Skip chunk header + break + } + + dataOffset += 8 + chunkSize + } + + return WavInfo( + sampleRate = sampleRate, + channels = channels, + bitsPerSample = bitsPerSample, + dataOffset = dataOffset, + ) + } + + private data class WavInfo( + val sampleRate: Int, + val channels: Int, + val bitsPerSample: Int, + val dataOffset: Int, + ) +} + +/** + * Audio playback errors. + */ +sealed class AudioPlaybackException : Exception() { + data object EmptyAudioData : AudioPlaybackException() { + @Suppress("UnusedPrivateMember") + private fun readResolve(): Any = EmptyAudioData + + override val message: String = "Audio data is empty" + } + + data class PlaybackFailed( + override val message: String?, + ) : AudioPlaybackException() + + data object PlaybackInterrupted : AudioPlaybackException() { + @Suppress("UnusedPrivateMember") + private fun readResolve(): Any = PlaybackInterrupted + + override val message: String = "Audio playback was interrupted" + } + + data object InvalidAudioFormat : AudioPlaybackException() { + @Suppress("UnusedPrivateMember") + private fun readResolve(): Any = InvalidAudioFormat + + override val message: String = "Invalid audio format" + } +} diff --git a/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/features/tts/TtsAudioPlayback.android.kt b/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/features/tts/TtsAudioPlayback.android.kt new file mode 100644 index 000000000..c752111e5 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/features/tts/TtsAudioPlayback.android.kt @@ -0,0 +1,16 @@ +package com.runanywhere.sdk.features.tts + +internal actual object TtsAudioPlayback { + private val audioPlaybackManager by lazy { AudioPlaybackManager() } + + actual val isPlaying: Boolean + get() = audioPlaybackManager.isPlaying + + actual suspend fun play(audioData: ByteArray) { + audioPlaybackManager.play(audioData) + } + + actual fun stop() { + audioPlaybackManager.stop() + } +} diff --git a/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/foundation/HostAppInfo.kt b/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/foundation/HostAppInfo.kt new file mode 100644 index 000000000..b0af71bc3 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/foundation/HostAppInfo.kt @@ -0,0 +1,27 @@ +package com.runanywhere.sdk.foundation + +import com.runanywhere.sdk.storage.AndroidPlatformContext + +/** + * Android implementation of getHostAppInfo + */ +actual fun getHostAppInfo(): HostAppInfo = + try { + val context = AndroidPlatformContext.getContext() + val packageName = context.packageName + val packageManager = context.packageManager + val appInfo = context.applicationInfo + val packageInfo = packageManager.getPackageInfo(packageName, 0) + + val appName = packageManager.getApplicationLabel(appInfo).toString() + val versionName = packageInfo.versionName + + HostAppInfo( + identifier = packageName, + name = appName, + version = versionName, + ) + } catch (e: Exception) { + // Return nulls if unable to get app info + HostAppInfo(null, null, null) + } diff --git a/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/foundation/PlatformLogger.kt b/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/foundation/PlatformLogger.kt new file mode 100644 index 000000000..ca4130c23 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/foundation/PlatformLogger.kt @@ -0,0 +1,69 @@ +package com.runanywhere.sdk.foundation + +import android.util.Log + +/** + * Android implementation of PlatformLogger using Android Log. + * Supports all log levels including TRACE and FAULT. + */ +actual class PlatformLogger actual constructor( + private val tag: String, +) { + /** + * Log a trace-level message. + * Maps to Android's VERBOSE level. + */ + actual fun trace(message: String) { + Log.v(tag, message) + } + + /** + * Log a debug-level message. + */ + actual fun debug(message: String) { + Log.d(tag, message) + } + + /** + * Log an info-level message. + */ + actual fun info(message: String) { + Log.i(tag, message) + } + + /** + * Log a warning-level message. + */ + actual fun warning(message: String) { + Log.w(tag, message) + } + + /** + * Log an error-level message. + */ + actual fun error( + message: String, + throwable: Throwable?, + ) { + if (throwable != null) { + Log.e(tag, message, throwable) + } else { + Log.e(tag, message) + } + } + + /** + * Log a fault-level message (critical system errors). + * Maps to Android's WTF (What a Terrible Failure) level. + */ + actual fun fault( + message: String, + throwable: Throwable?, + ) { + if (throwable != null) { + Log.wtf(tag, message, throwable) + } else { + Log.wtf(tag, message) + } + } +} diff --git a/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/foundation/PlatformTime.kt b/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/foundation/PlatformTime.kt new file mode 100644 index 000000000..81fd4a9f7 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/foundation/PlatformTime.kt @@ -0,0 +1,21 @@ +package com.runanywhere.sdk.foundation + +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +/** + * Android implementation of time utilities + */ +actual fun currentTimeMillis(): Long = System.currentTimeMillis() + +/** + * Get current time as ISO8601 string + * Matches iOS format exactly: "2025-10-25 23:24:53+00" + */ +actual fun currentTimeISO8601(): String { + val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss'+00'", Locale.US) + sdf.timeZone = TimeZone.getTimeZone("UTC") + return sdf.format(Date()) +} diff --git a/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/AndroidSecureStorage.kt b/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/AndroidSecureStorage.kt new file mode 100644 index 000000000..b2748bb1d --- /dev/null +++ b/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/AndroidSecureStorage.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * Android-specific secure storage implementation using SharedPreferences. + */ + +package com.runanywhere.sdk.foundation.bridge.extensions + +import android.content.Context +import android.content.SharedPreferences +import android.util.Base64 + +/** + * Android implementation of PlatformSecureStorage using SharedPreferences. + * + * @param context The Android application context + */ +class AndroidSecureStorage(context: Context) : CppBridgePlatformAdapter.PlatformSecureStorage { + + private val sharedPreferences: SharedPreferences = + context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + companion object { + private const val PREFS_NAME = "runanywhere_secure_storage" + } + + override fun get(key: String): ByteArray? { + val base64Value = sharedPreferences.getString(key, null) ?: return null + return Base64.decode(base64Value, Base64.NO_WRAP) + } + + override fun set(key: String, value: ByteArray): Boolean { + val base64Value = Base64.encodeToString(value, Base64.NO_WRAP) + sharedPreferences.edit().putString(key, base64Value).apply() + return true + } + + override fun delete(key: String): Boolean { + sharedPreferences.edit().remove(key).apply() + return true + } + + override fun clear() { + sharedPreferences.edit().clear().apply() + } +} + +/** + * Extension function to easily set Android context for CppBridgePlatformAdapter. + * This is the recommended way to initialize storage on Android. + * + * @param context The Android context (will use applicationContext internally) + */ +fun CppBridgePlatformAdapter.setContext(context: Context) { + setPlatformStorage(AndroidSecureStorage(context)) +} diff --git a/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/foundation/device/DeviceInfoService.kt b/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/foundation/device/DeviceInfoService.kt new file mode 100644 index 000000000..2df860afa --- /dev/null +++ b/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/foundation/device/DeviceInfoService.kt @@ -0,0 +1,95 @@ +package com.runanywhere.sdk.foundation.device + +import android.app.ActivityManager +import android.content.Context +import android.os.Build +import com.runanywhere.sdk.storage.AndroidPlatformContext + +/** + * Android implementation of DeviceInfoService + * + * Collects device information using Android APIs + */ +actual class DeviceInfoService { + private val context: Context? by lazy { + if (AndroidPlatformContext.isInitialized()) { + AndroidPlatformContext.applicationContext + } else { + null + } + } + + actual fun getOSName(): String = "Android" + + actual fun getOSVersion(): String = Build.VERSION.RELEASE + + actual fun getDeviceModel(): String { + val manufacturer = Build.MANUFACTURER + val model = Build.MODEL + return if (model.startsWith(manufacturer, ignoreCase = true)) { + model.replaceFirstChar { it.uppercase() } + } else { + "${manufacturer.replaceFirstChar { it.uppercase() }} $model" + } + } + + actual fun getChipName(): String? = + try { + // Get primary ABI (architecture) + val abis = Build.SUPPORTED_ABIS + if (abis.isNotEmpty()) { + abis[0] + } else { + null + } + } catch (e: Exception) { + null + } + + actual fun getTotalMemoryGB(): Double? { + return try { + val ctx = context ?: return null + val activityManager = + ctx.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager + ?: return null + + val memInfo = ActivityManager.MemoryInfo() + activityManager.getMemoryInfo(memInfo) + + // Convert bytes to GB + memInfo.totalMem / (1024.0 * 1024.0 * 1024.0) + } catch (e: Exception) { + null + } + } + + actual fun getTotalMemoryBytes(): Long? { + return try { + val ctx = context ?: return null + val activityManager = + ctx.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager + ?: return null + + val memInfo = ActivityManager.MemoryInfo() + activityManager.getMemoryInfo(memInfo) + + // Return bytes directly (matching iOS) + memInfo.totalMem + } catch (e: Exception) { + null + } + } + + actual fun getArchitecture(): String? = + try { + // Get primary ABI (architecture) - same as chip name for Android + val abis = Build.SUPPORTED_ABIS + if (abis.isNotEmpty()) { + abis[0] + } else { + null + } + } catch (e: Exception) { + null + } +} diff --git a/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/infrastructure/download/AndroidSimpleDownloader.kt b/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/infrastructure/download/AndroidSimpleDownloader.kt new file mode 100644 index 000000000..fb10ecfa3 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/infrastructure/download/AndroidSimpleDownloader.kt @@ -0,0 +1,93 @@ +package com.runanywhere.sdk.infrastructure.download + +import com.runanywhere.sdk.foundation.SDKLogger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.FileOutputStream +import java.net.HttpURLConnection +import java.net.URL + +/** + * DEAD SIMPLE Android downloader - NO Ktor, NO buffering, just plain HttpURLConnection + * This is a temporary solution to avoid Ktor's memory buffering issues + */ +object AndroidSimpleDownloader { + private val logger = SDKLogger.download + + /** + * Download a file from URL to destination path + * Returns the number of bytes downloaded + */ + suspend fun download( + url: String, + destinationPath: String, + progressCallback: ((bytesDownloaded: Long, totalBytes: Long) -> Unit)? = null, + ): Long = + withContext(Dispatchers.IO) { + logger.info("Starting simple download - url: $url, destination: $destinationPath") + + val urlConnection = URL(url).openConnection() as HttpURLConnection + urlConnection.requestMethod = "GET" + urlConnection.connectTimeout = 30000 // 30 seconds + urlConnection.readTimeout = 30000 // 30 seconds + + try { + urlConnection.connect() + + val responseCode = urlConnection.responseCode + if (responseCode != HttpURLConnection.HTTP_OK) { + throw Exception("HTTP error: $responseCode") + } + + val totalBytes = urlConnection.contentLengthLong + logger.info("Download started - totalBytes: $totalBytes") + + // Create temp file + val tempPath = "$destinationPath.tmp" + + FileOutputStream(tempPath).use { output -> + urlConnection.inputStream.use { input -> + val buffer = ByteArray(8192) // 8KB buffer + var bytesDownloaded = 0L + var bytesRead: Int + var lastReportTime = System.currentTimeMillis() + + while (input.read(buffer).also { bytesRead = it } != -1) { + output.write(buffer, 0, bytesRead) + bytesDownloaded += bytesRead + + // Report progress every 100ms + val currentTime = System.currentTimeMillis() + if (currentTime - lastReportTime >= 100) { + progressCallback?.invoke(bytesDownloaded, totalBytes) + lastReportTime = currentTime + + // Log every 10% + if (totalBytes > 0) { + val percent = (bytesDownloaded.toDouble() / totalBytes * 100).toInt() + if (percent % 10 == 0) { + logger.debug("Download progress: $percent% ($bytesDownloaded / $totalBytes bytes)") + } + } + } + } + + logger.info("Download completed - bytesDownloaded: $bytesDownloaded") + } + } + + // Move temp to final destination + val tempFile = java.io.File(tempPath) + val destFile = java.io.File(destinationPath) + destFile.parentFile?.mkdirs() + tempFile.renameTo(destFile) + + val finalSize = destFile.length() + logger.info("File moved to destination - size: $finalSize") + + finalSize + } finally { + urlConnection.disconnect() + } + } +} diff --git a/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/platform/Checksum.kt b/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/platform/Checksum.kt new file mode 100644 index 000000000..3335bc836 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/platform/Checksum.kt @@ -0,0 +1,70 @@ +package com.runanywhere.sdk.platform + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.security.MessageDigest + +/** + * Android implementation of checksum calculation. + * ONLY file I/O is here - business logic stays in commonMain. + * + * Note: Android and JVM share the same implementation since Android runs on JVM. + */ + +actual suspend fun calculateSHA256(filePath: String): String = + withContext(Dispatchers.IO) { + calculateChecksumFromFile(filePath, "SHA-256") + } + +actual suspend fun calculateMD5(filePath: String): String = + withContext(Dispatchers.IO) { + calculateChecksumFromFile(filePath, "MD5") + } + +actual fun calculateSHA256Bytes(data: ByteArray): String = calculateChecksumFromBytes(data, "SHA-256") + +actual fun calculateMD5Bytes(data: ByteArray): String = calculateChecksumFromBytes(data, "MD5") + +/** + * Shared implementation for file-based checksum calculation. + * Platform-specific: Uses java.io.File for file I/O. + */ +private fun calculateChecksumFromFile( + filePath: String, + algorithm: String, +): String { + val file = File(filePath) + if (!file.exists()) { + throw IllegalArgumentException("File does not exist: $filePath") + } + + val digest = MessageDigest.getInstance(algorithm) + + file.inputStream().use { input -> + val buffer = ByteArray(8192) // 8KB buffer + var bytesRead: Int + + while (input.read(buffer).also { bytesRead = it } != -1) { + digest.update(buffer, 0, bytesRead) + } + } + + // Convert to hex string (lowercase to match Swift) + return digest.digest().joinToString("") { "%02x".format(it) } +} + +/** + * Shared implementation for byte array checksum calculation. + * Platform-specific: Uses java.security.MessageDigest. + */ +private fun calculateChecksumFromBytes( + data: ByteArray, + algorithm: String, +): String { + val digest = MessageDigest.getInstance(algorithm) + digest.update(data) + + // Convert to hex string (lowercase to match Swift) + return digest.digest().joinToString("") { "%02x".format(it) } +} diff --git a/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/platform/NetworkConnectivity.kt b/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/platform/NetworkConnectivity.kt new file mode 100644 index 000000000..1c77b22c1 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/platform/NetworkConnectivity.kt @@ -0,0 +1,253 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * Network connectivity checker for Android. + */ + +package com.runanywhere.sdk.platform + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import com.runanywhere.sdk.storage.AndroidPlatformContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Network connectivity status. + */ +enum class NetworkStatus { + /** Network is available */ + AVAILABLE, + + /** Network is unavailable (no connection) */ + UNAVAILABLE, + + /** Network status is unknown */ + UNKNOWN, +} + +/** + * Network type information. + */ +enum class NetworkType { + /** WiFi connection */ + WIFI, + + /** Cellular/mobile data connection */ + CELLULAR, + + /** Ethernet connection */ + ETHERNET, + + /** VPN connection */ + VPN, + + /** Other or unknown connection type */ + OTHER, + + /** No connection */ + NONE, +} + +/** + * Network connectivity checker for Android. + * + * Provides methods to check current network status and observe connectivity changes. + * Uses Android's ConnectivityManager for accurate network state detection. + */ +object NetworkConnectivity { + private val _networkStatus = MutableStateFlow(NetworkStatus.UNKNOWN) + + /** Observable network status flow */ + val networkStatus: StateFlow = _networkStatus.asStateFlow() + + private val _networkType = MutableStateFlow(NetworkType.NONE) + + /** Observable network type flow */ + val networkType: StateFlow = _networkType.asStateFlow() + + @Volatile + private var isMonitoring = false + + private var networkCallback: ConnectivityManager.NetworkCallback? = null + + /** + * Check if network is currently available. + * + * This performs a synchronous check of the current network state. + * + * @return true if network is available, false otherwise + */ + fun isNetworkAvailable(): Boolean { + return try { + if (!AndroidPlatformContext.isInitialized()) { + // If context not initialized, assume network is available + // (will fail later with proper error if not) + return true + } + + val context = AndroidPlatformContext.applicationContext + val connectivityManager = + context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager + ?: return true // Assume available if can't get manager + + val network = connectivityManager.activeNetwork ?: return false + val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false + + capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && + capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + } catch (e: Exception) { + // If we can't check, assume available (will fail with network error if not) + true + } + } + + /** + * Get the current network type. + * + * @return The current network type + */ + fun getCurrentNetworkType(): NetworkType { + return try { + if (!AndroidPlatformContext.isInitialized()) { + return NetworkType.OTHER + } + + val context = AndroidPlatformContext.applicationContext + val connectivityManager = + context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager + ?: return NetworkType.OTHER + + val network = connectivityManager.activeNetwork ?: return NetworkType.NONE + val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return NetworkType.NONE + + when { + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> NetworkType.WIFI + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> NetworkType.CELLULAR + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> NetworkType.ETHERNET + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> NetworkType.VPN + else -> NetworkType.OTHER + } + } catch (e: Exception) { + NetworkType.OTHER + } + } + + /** + * Start monitoring network connectivity changes. + * + * Call this during SDK initialization to enable real-time network status updates. + */ + fun startMonitoring() { + if (isMonitoring) return + + try { + if (!AndroidPlatformContext.isInitialized()) { + return + } + + val context = AndroidPlatformContext.applicationContext + val connectivityManager = + context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager + ?: return + + val networkRequest = + NetworkRequest + .Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build() + + networkCallback = + object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + _networkStatus.value = NetworkStatus.AVAILABLE + updateNetworkType() + } + + override fun onLost(network: Network) { + _networkStatus.value = NetworkStatus.UNAVAILABLE + _networkType.value = NetworkType.NONE + } + + override fun onCapabilitiesChanged( + network: Network, + networkCapabilities: NetworkCapabilities, + ) { + val hasInternet = networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + val validated = networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + + _networkStatus.value = + if (hasInternet && validated) { + NetworkStatus.AVAILABLE + } else { + NetworkStatus.UNAVAILABLE + } + + updateNetworkType() + } + + override fun onUnavailable() { + _networkStatus.value = NetworkStatus.UNAVAILABLE + _networkType.value = NetworkType.NONE + } + } + + connectivityManager.registerNetworkCallback(networkRequest, networkCallback!!) + isMonitoring = true + + // Set initial state + _networkStatus.value = if (isNetworkAvailable()) NetworkStatus.AVAILABLE else NetworkStatus.UNAVAILABLE + _networkType.value = getCurrentNetworkType() + } catch (e: Exception) { + // Silently fail - network monitoring is optional + } + } + + /** + * Stop monitoring network connectivity changes. + * + * Call this during SDK shutdown to clean up resources. + */ + fun stopMonitoring() { + if (!isMonitoring) return + + try { + if (!AndroidPlatformContext.isInitialized()) { + return + } + + val context = AndroidPlatformContext.applicationContext + val connectivityManager = + context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager + ?: return + + networkCallback?.let { + connectivityManager.unregisterNetworkCallback(it) + } + networkCallback = null + isMonitoring = false + } catch (e: Exception) { + // Silently fail + } + } + + private fun updateNetworkType() { + _networkType.value = getCurrentNetworkType() + } + + /** + * Get a human-readable description of the current network status. + * + * @return A string describing the current network state + */ + fun getNetworkDescription(): String { + val status = if (isNetworkAvailable()) "Connected" else "Disconnected" + val type = getCurrentNetworkType().name.lowercase().replaceFirstChar { it.uppercase() } + return "$status ($type)" + } +} diff --git a/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/platform/StoragePlatform.android.kt b/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/platform/StoragePlatform.android.kt new file mode 100644 index 000000000..2dcae5cb8 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/platform/StoragePlatform.android.kt @@ -0,0 +1,66 @@ +package com.runanywhere.sdk.platform + +import android.os.Build +import android.os.StatFs +import com.runanywhere.sdk.storage.AndroidPlatformContext + +/** + * Android implementation of platform-specific storage operations + * Uses Android StatFs for storage calculations + * + * Reference: Matches iOS FileManager storage calculations but uses Android APIs + */ + +actual suspend fun getPlatformStorageInfo(path: String): PlatformStorageInfo { + val statFs = StatFs(path) + + val totalSpace = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) { + statFs.totalBytes + } else { + @Suppress("DEPRECATION") + statFs.blockCount.toLong() * statFs.blockSize.toLong() + } + + val availableSpace = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) { + statFs.availableBytes + } else { + @Suppress("DEPRECATION") + statFs.availableBlocks.toLong() * statFs.blockSize.toLong() + } + + val usedSpace = totalSpace - availableSpace + + return PlatformStorageInfo( + totalSpace = totalSpace, + availableSpace = availableSpace, + usedSpace = usedSpace, + ) +} + +actual fun getPlatformBaseDirectory(): String { + val context = AndroidPlatformContext.applicationContext + + // Match iOS pattern: app-specific directory for SDK files + // iOS: .applicationSupportDirectory + // Android: filesDir/runanywhere + val baseDir = context.filesDir.resolve("runanywhere") + if (!baseDir.exists()) { + baseDir.mkdirs() + } + return baseDir.absolutePath +} + +actual fun getPlatformTempDirectory(): String { + val context = AndroidPlatformContext.applicationContext + + // Match iOS pattern: temporary directory + // iOS: .temporaryDirectory + // Android: cacheDir/runanywhere-temp + val tempDir = context.cacheDir.resolve("runanywhere-temp") + if (!tempDir.exists()) { + tempDir.mkdirs() + } + return tempDir.absolutePath +} diff --git a/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/security/KeychainManager.kt b/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/security/KeychainManager.kt new file mode 100644 index 000000000..ef1b1529d --- /dev/null +++ b/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/security/KeychainManager.kt @@ -0,0 +1,185 @@ +package com.runanywhere.sdk.security + +import android.annotation.SuppressLint +import android.content.Context +import android.content.SharedPreferences +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import com.runanywhere.sdk.data.models.StoredTokens +import com.runanywhere.sdk.foundation.SDKLogger +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.util.Date + +/** + * Keychain Manager for secure token storage + * One-to-one translation from iOS KeychainManager to Android EncryptedSharedPreferences + * Equivalent to iOS KeychainManager.shared + */ +class KeychainManager private constructor( + private val context: Context, +) { + companion object { + // Suppress: Using applicationContext which is safe (doesn't leak Activity) + @SuppressLint("StaticFieldLeak") + @Volatile + private var instance: KeychainManager? = null + + val shared: KeychainManager + get() = instance ?: throw IllegalStateException("KeychainManager not initialized. Call initialize(context) first.") + + fun initialize(context: Context) { + if (instance == null) { + synchronized(this) { + if (instance == null) { + instance = KeychainManager(context.applicationContext) + } + } + } + } + + // Keys for token storage + private const val ACCESS_TOKEN_KEY = "access_token" + private const val REFRESH_TOKEN_KEY = "refresh_token" + private const val EXPIRES_AT_KEY = "expires_at" + } + + private val logger = SDKLogger.core + private val mutex = Mutex() + + private val masterKey: MasterKey by lazy { + MasterKey + .Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + } + + private val encryptedPrefs: SharedPreferences by lazy { + EncryptedSharedPreferences.create( + context, + "runanywhere_secure_prefs", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + } + + /** + * Save authentication tokens securely + * Equivalent to iOS KeychainManager keychain operations + */ + suspend fun saveTokens( + accessToken: String, + refreshToken: String, + expiresAt: Date, + ) = mutex.withLock { + logger.debug("Saving tokens to keychain") + + try { + with(encryptedPrefs.edit()) { + putString(ACCESS_TOKEN_KEY, accessToken) + putString(REFRESH_TOKEN_KEY, refreshToken) + putLong(EXPIRES_AT_KEY, expiresAt.time) + apply() + } + + logger.debug("Tokens saved successfully") + } catch (e: Exception) { + logger.error("Failed to save tokens", throwable = e) + throw e + } + } + + /** + * Retrieve stored authentication tokens + * Equivalent to iOS KeychainManager keychain queries + */ + suspend fun getTokens(): StoredTokens? = + mutex.withLock { + logger.debug("Retrieving tokens from keychain") + + try { + val accessToken = encryptedPrefs.getString(ACCESS_TOKEN_KEY, null) + val refreshToken = encryptedPrefs.getString(REFRESH_TOKEN_KEY, null) + val expiresAtMillis = encryptedPrefs.getLong(EXPIRES_AT_KEY, -1L) + + if (accessToken != null && refreshToken != null && expiresAtMillis != -1L) { + val storedTokens = + StoredTokens( + accessToken = accessToken, + refreshToken = refreshToken, + expiresAt = expiresAtMillis, + ) + + logger.debug("Tokens retrieved successfully") + return storedTokens + } else { + logger.debug("No valid tokens found") + return null + } + } catch (e: Exception) { + logger.error("Failed to retrieve tokens", throwable = e) + return null + } + } + + /** + * Delete all stored tokens + * Equivalent to iOS KeychainManager deletion operations + */ + suspend fun deleteTokens() = + mutex.withLock { + logger.debug("Deleting tokens from keychain") + + try { + with(encryptedPrefs.edit()) { + remove(ACCESS_TOKEN_KEY) + remove(REFRESH_TOKEN_KEY) + remove(EXPIRES_AT_KEY) + apply() + } + + logger.debug("Tokens deleted successfully") + } catch (e: Exception) { + logger.error("Failed to delete tokens", throwable = e) + throw e + } + } + + /** + * Check if tokens exist in keychain + * Equivalent to iOS KeychainManager queries + */ + suspend fun hasStoredTokens(): Boolean = + mutex.withLock { + try { + val accessToken = encryptedPrefs.getString(ACCESS_TOKEN_KEY, null) + val refreshToken = encryptedPrefs.getString(REFRESH_TOKEN_KEY, null) + return accessToken != null && refreshToken != null + } catch (e: Exception) { + logger.error("Failed to check stored tokens", throwable = e) + return false + } + } + + /** + * Clear all keychain data + * Equivalent to iOS KeychainManager clear operations + */ + suspend fun clearAll() = + mutex.withLock { + logger.debug("Clearing all keychain data") + + try { + with(encryptedPrefs.edit()) { + clear() + apply() + } + + logger.info("All keychain data cleared") + } catch (e: Exception) { + logger.error("Failed to clear keychain data", throwable = e) + throw e + } + } +} diff --git a/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/security/SecureStorage.kt b/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/security/SecureStorage.kt new file mode 100644 index 000000000..7f13b1056 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/security/SecureStorage.kt @@ -0,0 +1,234 @@ +package com.runanywhere.sdk.security + +import android.annotation.SuppressLint +import android.content.Context +import android.content.SharedPreferences +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import com.runanywhere.sdk.foundation.SDKLogger +import com.runanywhere.sdk.foundation.errors.SDKError +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Android implementation of SecureStorage using EncryptedSharedPreferences + * Provides hardware-backed encryption when available (Android Keystore) + */ +class AndroidSecureStorage private constructor( + private val encryptedPrefs: SharedPreferences, + private val identifier: String, +) : SecureStorage { + private val logger = SDKLogger.core + + companion object { + // Suppress: Using applicationContext which is safe (doesn't leak Activity) + @SuppressLint("StaticFieldLeak") + private var cachedStorage: AndroidSecureStorage? = null + + @SuppressLint("StaticFieldLeak") + private var context: Context? = null + + /** + * Initialize Android secure storage with application context + * This should be called during SDK initialization + */ + fun initialize(applicationContext: Context) { + context = applicationContext.applicationContext + } + + /** + * Create secure storage instance for Android + */ + fun create(identifier: String): AndroidSecureStorage { + val appContext = + context + ?: throw SDKError.storage("AndroidSecureStorage not initialized. Call initialize(context) first.") + + // Return cached instance if available for the same identifier + cachedStorage?.let { cached -> + if (cached.identifier == identifier) { + return cached + } + } + + try { + // Create or retrieve master key for encryption + val masterKey = + MasterKey + .Builder(appContext) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + // Create encrypted shared preferences + val encryptedPrefs = + EncryptedSharedPreferences.create( + appContext, + "$identifier.secure_prefs", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + + val storage = AndroidSecureStorage(encryptedPrefs, identifier) + cachedStorage = storage + return storage + } catch (e: Exception) { + throw SDKError.storage("Failed to create secure storage: ${e.message}") + } + } + + /** + * Check if secure storage is supported on Android + */ + fun isSupported(): Boolean = + try { + context != null + } catch (e: Exception) { + false + } + } + + override suspend fun setSecureString( + key: String, + value: String, + ) = withContext(Dispatchers.IO) { + try { + encryptedPrefs + .edit() + .putString(key, value) + .apply() + logger.debug("Stored secure string for key: $key") + } catch (e: Exception) { + logger.error("Failed to store secure string for key: $key", throwable = e) + throw SDKError.storage("Failed to store secure data: ${e.message}") + } + } + + override suspend fun getSecureString(key: String): String? = + withContext(Dispatchers.IO) { + try { + val value = encryptedPrefs.getString(key, null) + if (value != null) { + logger.debug("Retrieved secure string for key: $key") + } + value + } catch (e: Exception) { + logger.error("Failed to retrieve secure string for key: $key", throwable = e) + throw SDKError.storage("Failed to retrieve secure data: ${e.message}") + } + } + + override suspend fun setSecureData( + key: String, + data: ByteArray, + ) = withContext(Dispatchers.IO) { + try { + // Convert binary data to Base64 for storage in SharedPreferences + val base64Data = android.util.Base64.encodeToString(data, android.util.Base64.DEFAULT) + encryptedPrefs + .edit() + .putString("${key}_data", base64Data) + .apply() + logger.debug("Stored secure data for key: $key (${data.size} bytes)") + } catch (e: Exception) { + logger.error("Failed to store secure data for key: $key", throwable = e) + throw SDKError.storage("Failed to store secure data: ${e.message}") + } + } + + override suspend fun getSecureData(key: String): ByteArray? = + withContext(Dispatchers.IO) { + try { + val base64Data = encryptedPrefs.getString("${key}_data", null) + if (base64Data != null) { + val data = android.util.Base64.decode(base64Data, android.util.Base64.DEFAULT) + logger.debug("Retrieved secure data for key: $key (${data.size} bytes)") + data + } else { + null + } + } catch (e: Exception) { + logger.error("Failed to retrieve secure data for key: $key", throwable = e) + throw SDKError.storage("Failed to retrieve secure data: ${e.message}") + } + } + + override suspend fun removeSecure(key: String) = + withContext(Dispatchers.IO) { + try { + encryptedPrefs + .edit() + .remove(key) + .remove("${key}_data") // Also remove binary data variant + .apply() + logger.debug("Removed secure data for key: $key") + } catch (e: Exception) { + logger.error("Failed to remove secure data for key: $key", throwable = e) + throw SDKError.storage("Failed to remove secure data: ${e.message}") + } + } + + override suspend fun containsKey(key: String): Boolean = + withContext(Dispatchers.IO) { + try { + encryptedPrefs.contains(key) || encryptedPrefs.contains("${key}_data") + } catch (e: Exception) { + logger.error("Failed to check key existence: $key", throwable = e) + false + } + } + + override suspend fun clearAll() = + withContext(Dispatchers.IO) { + try { + encryptedPrefs.edit().clear().apply() + logger.info("Cleared all secure data") + } catch (e: Exception) { + logger.error("Failed to clear all secure data", throwable = e) + throw SDKError.storage("Failed to clear secure data: ${e.message}") + } + } + + override suspend fun getAllKeys(): Set = + withContext(Dispatchers.IO) { + try { + // Filter out the "_data" suffix keys to avoid duplicates + encryptedPrefs.all.keys + .filter { !it.endsWith("_data") } + .toSet() + } catch (e: Exception) { + logger.error("Failed to get all keys", throwable = e) + emptySet() + } + } + + override suspend fun isAvailable(): Boolean = + withContext(Dispatchers.IO) { + try { + // Test by trying to read/write a test value + val testKey = "availability_test" + val testValue = "test" + + encryptedPrefs.edit().putString(testKey, testValue).apply() + val retrievedValue = encryptedPrefs.getString(testKey, null) + encryptedPrefs.edit().remove(testKey).apply() + + retrievedValue == testValue + } catch (e: Exception) { + logger.error("Secure storage availability test failed", throwable = e) + false + } + } +} + +/** + * Android implementation of SecureStorageFactory + */ +@Suppress("UtilityClassWithPublicConstructor") // KMP expect/actual pattern requires class +actual class SecureStorageFactory { + actual companion object { + actual fun create(identifier: String): SecureStorage = AndroidSecureStorage.create(identifier) + + actual fun isSupported(): Boolean = AndroidSecureStorage.isSupported() + } +} diff --git a/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/storage/AndroidFileSystem.kt b/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/storage/AndroidFileSystem.kt new file mode 100644 index 000000000..5a0faa752 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/storage/AndroidFileSystem.kt @@ -0,0 +1,22 @@ +package com.runanywhere.sdk.storage + +import android.content.Context + +/** + * Android implementation of FileSystem + * Extends shared implementation and provides Android-specific directory paths + */ +internal class AndroidFileSystem( + private val context: Context, +) : SharedFileSystem() { + override fun getCacheDirectory(): String = context.cacheDir.absolutePath + + override fun getDataDirectory(): String = context.filesDir.absolutePath + + override fun getTempDirectory(): String = context.cacheDir.absolutePath +} + +/** + * Factory function to create FileSystem for Android + */ +actual fun createFileSystem(): FileSystem = AndroidFileSystem(AndroidPlatformContext.applicationContext) diff --git a/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/storage/AndroidPlatformContext.kt b/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/storage/AndroidPlatformContext.kt new file mode 100644 index 000000000..fdb8a14e4 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/storage/AndroidPlatformContext.kt @@ -0,0 +1,64 @@ +package com.runanywhere.sdk.storage + +import android.content.Context +import com.runanywhere.sdk.foundation.bridge.extensions.CppBridgeModelPaths +import com.runanywhere.sdk.foundation.bridge.extensions.CppBridgePlatformAdapter +import com.runanywhere.sdk.foundation.bridge.extensions.setContext +import com.runanywhere.sdk.security.AndroidSecureStorage + +/** + * Android-specific context holder - should be initialized by the app + * This is shared across all Android platform implementations + */ +object AndroidPlatformContext { + private var _applicationContext: Context? = null + + val applicationContext: Context + get() = + _applicationContext ?: throw IllegalStateException( + "AndroidPlatformContext must be initialized with Context before use", + ) + + fun initialize(context: Context) { + _applicationContext = context.applicationContext + // Also initialize secure storage so DeviceIdentity can access it + AndroidSecureStorage.initialize(context.applicationContext) + + // Initialize CppBridgePlatformAdapter with context for persistent secure storage + // This ensures device ID and registration status persist across app restarts + CppBridgePlatformAdapter.setContext(context.applicationContext) + + // Set up the model path provider for CppBridgeModelPaths + // This ensures models are stored in the app's internal storage on Android + CppBridgeModelPaths.pathProvider = + object : CppBridgeModelPaths.ModelPathProvider { + override fun getFilesDirectory(): String { + return context.applicationContext.filesDir.absolutePath + } + + override fun getCacheDirectory(): String { + return context.applicationContext.cacheDir.absolutePath + } + + override fun getExternalStorageDirectory(): String? { + return context.applicationContext.getExternalFilesDir(null)?.absolutePath + } + + override fun isPathWritable(path: String): Boolean { + return try { + val file = java.io.File(path) + file.canWrite() || (file.mkdirs() && file.canWrite()) + } catch (e: Exception) { + false + } + } + } + } + + fun isInitialized(): Boolean = _applicationContext != null + + /** + * Get the application context (alias for applicationContext for compatibility) + */ + fun getContext(): Context = applicationContext +} diff --git a/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/storage/AndroidPlatformStorage.kt b/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/storage/AndroidPlatformStorage.kt new file mode 100644 index 000000000..fbd879312 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/storage/AndroidPlatformStorage.kt @@ -0,0 +1,76 @@ +package com.runanywhere.sdk.storage + +import android.content.Context +import android.content.SharedPreferences + +/** + * Android implementation of PlatformStorage using SharedPreferences + */ +internal class AndroidPlatformStorage( + context: Context, +) : PlatformStorage { + private val sharedPreferences: SharedPreferences = + context.getSharedPreferences("runanywhere_sdk_prefs", Context.MODE_PRIVATE) + + override suspend fun putString( + key: String, + value: String, + ) { + sharedPreferences.edit().putString(key, value).apply() + } + + override suspend fun getString(key: String): String? = sharedPreferences.getString(key, null) + + override suspend fun putBoolean( + key: String, + value: Boolean, + ) { + sharedPreferences.edit().putBoolean(key, value).apply() + } + + override suspend fun getBoolean( + key: String, + defaultValue: Boolean, + ): Boolean = sharedPreferences.getBoolean(key, defaultValue) + + override suspend fun putLong( + key: String, + value: Long, + ) { + sharedPreferences.edit().putLong(key, value).apply() + } + + override suspend fun getLong( + key: String, + defaultValue: Long, + ): Long = sharedPreferences.getLong(key, defaultValue) + + override suspend fun putInt( + key: String, + value: Int, + ) { + sharedPreferences.edit().putInt(key, value).apply() + } + + override suspend fun getInt( + key: String, + defaultValue: Int, + ): Int = sharedPreferences.getInt(key, defaultValue) + + override suspend fun remove(key: String) { + sharedPreferences.edit().remove(key).apply() + } + + override suspend fun clear() { + sharedPreferences.edit().clear().apply() + } + + override suspend fun contains(key: String): Boolean = sharedPreferences.contains(key) + + override suspend fun getAllKeys(): Set = sharedPreferences.all.keys +} + +/** + * Factory function to create platform storage for Android + */ +actual fun createPlatformStorage(): PlatformStorage = AndroidPlatformStorage(AndroidPlatformContext.applicationContext) diff --git a/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/utils/BuildConfig.kt b/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/utils/BuildConfig.kt new file mode 100644 index 000000000..7a21b3829 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/utils/BuildConfig.kt @@ -0,0 +1,10 @@ +package com.runanywhere.sdk.utils + +/** + * Android implementation of BuildConfig + */ +actual object BuildConfig { + actual val DEBUG: Boolean = true // Can be configured based on build type + actual val VERSION_NAME: String = SharedBuildConfig.VERSION_NAME + actual val APPLICATION_ID: String = SharedBuildConfig.APPLICATION_ID +} diff --git a/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/utils/PlatformUtils.kt b/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/utils/PlatformUtils.kt new file mode 100644 index 000000000..23386a1ef --- /dev/null +++ b/sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/utils/PlatformUtils.kt @@ -0,0 +1,102 @@ +package com.runanywhere.sdk.utils + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Build +import android.provider.Settings +import java.util.UUID + +/** + * Android implementation of platform utilities + */ +actual object PlatformUtils { + internal lateinit var applicationContext: Context + private const val PREFS_NAME = "com.runanywhere.sdk.prefs" + private const val DEVICE_ID_KEY = "device_id" + + /** + * Initialize with application context + */ + fun init(context: Context) { + applicationContext = context.applicationContext + } + + @SuppressLint("HardwareIds") + actual fun getDeviceId(): String { + if (!::applicationContext.isInitialized) { + // Fallback to random UUID if context not initialized + return UUID.randomUUID().toString() + } + + val prefs = applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + // Check if we have a stored device ID + var deviceId = prefs.getString(DEVICE_ID_KEY, null) + + if (deviceId == null) { + // Try to get Android ID + deviceId = + try { + Settings.Secure.getString( + applicationContext.contentResolver, + Settings.Secure.ANDROID_ID, + ) + } catch (e: Exception) { + null + } + + // Fallback to UUID if Android ID is not available + if (deviceId.isNullOrEmpty() || deviceId == "9774d56d682e549c") { + deviceId = UUID.randomUUID().toString() + } + + // Store for future use + prefs.edit().putString(DEVICE_ID_KEY, deviceId).apply() + } + + return deviceId + } + + actual fun getPlatformName(): String = "android" + + actual fun getDeviceInfo(): Map = + mapOf( + "platform" to getPlatformName(), + "os_version" to getOSVersion(), + "api_level" to Build.VERSION.SDK_INT.toString(), + "device_manufacturer" to Build.MANUFACTURER, + "device_model" to getDeviceModel(), + "device_brand" to Build.BRAND, + "device_product" to Build.PRODUCT, + "device_hardware" to Build.HARDWARE, + "device_board" to Build.BOARD, + "device_display" to Build.DISPLAY, + "device_fingerprint" to Build.FINGERPRINT, + "supported_abis" to Build.SUPPORTED_ABIS.joinToString(","), + ) + + actual fun getOSVersion(): String = "Android ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})" + + actual fun getDeviceModel(): String = "${Build.MANUFACTURER} ${Build.MODEL}" + + actual fun getAppVersion(): String? { + if (!::applicationContext.isInitialized) { + return null + } + + return try { + val packageInfo = + applicationContext.packageManager + .getPackageInfo(applicationContext.packageName, 0) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + packageInfo.longVersionCode.toString() + } else { + @Suppress("DEPRECATION") + packageInfo.versionCode.toString() + } + " (${packageInfo.versionName})" + } catch (e: Exception) { + null + } + } +} diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/config/SDKConfig.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/config/SDKConfig.kt new file mode 100644 index 000000000..216c9b301 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/config/SDKConfig.kt @@ -0,0 +1,74 @@ +package com.runanywhere.sdk.config + +/** + * SDK Configuration constants + * + * IMPORTANT: Production URLs must be provided via environment variables + * or SDK initialization parameters. Never hardcode production URLs in + * open source code. + */ +object SDKConfig { + /** + * Base URL - must be provided at runtime + * Set via RunAnywhere.initialize(baseURL = "your-url") + * or environment variable RUNANYWHERE_API_URL + */ + var baseURL: String = "" + internal set + + /** + * API version + */ + const val API_VERSION = "v1" + + /** + * SDK version + */ + const val SDK_VERSION = "0.1.0" + + /** + * Default timeout in milliseconds + */ + const val DEFAULT_TIMEOUT_MS = 30000L + + /** + * Token refresh buffer in milliseconds (1 minute before expiry) + */ + const val TOKEN_REFRESH_BUFFER_MS = 60000L + + /** + * Initialize the SDK configuration + */ + fun initialize(url: String?) { + baseURL = url + ?: System.getenv("RUNANYWHERE_API_URL") + ?: throw IllegalArgumentException("API URL must be provided via parameter or RUNANYWHERE_API_URL environment variable") + } + + /** + * Get full API URL for an endpoint + */ + fun getApiUrl(endpoint: String): String { + requireNotNull(baseURL.isNotEmpty()) { "SDK not configured. Call RunAnywhere.initialize() first." } + val cleanEndpoint = if (endpoint.startsWith("/")) endpoint else "/$endpoint" + return "$baseURL/api/$API_VERSION$cleanEndpoint" + } + + /** + * Get authentication URL + */ + fun getAuthUrl(endpoint: String): String { + requireNotNull(baseURL.isNotEmpty()) { "SDK not configured. Call RunAnywhere.initialize() first." } + val cleanEndpoint = if (endpoint.startsWith("/")) endpoint else "/$endpoint" + return "$baseURL/api/$API_VERSION/auth$cleanEndpoint" + } + + /** + * Get device URL + */ + fun getDeviceUrl(endpoint: String): String { + requireNotNull(baseURL.isNotEmpty()) { "SDK not configured. Call RunAnywhere.initialize() first." } + val cleanEndpoint = if (endpoint.startsWith("/")) endpoint else "/$endpoint" + return "$baseURL/api/$API_VERSION/devices$cleanEndpoint" + } +} diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/core/module/RunAnywhereModule.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/core/module/RunAnywhereModule.kt new file mode 100644 index 000000000..a1394288f --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/core/module/RunAnywhereModule.kt @@ -0,0 +1,49 @@ +package com.runanywhere.sdk.core.module + +import com.runanywhere.sdk.core.types.InferenceFramework +import com.runanywhere.sdk.core.types.SDKComponent + +/** + * Protocol for SDK modules that provide AI capabilities. + * + * Modules encapsulate backend-specific functionality for the SDK. + * Each module typically provides one or more capabilities (LLM, STT, TTS, VAD). + * + * Registration with the C++ service registry is handled automatically by the + * platform backend during SDK initialization. Modules only need to provide + * metadata and service creation methods. + * + * ## Implementing a Module + * + * ```kotlin + * object MyModule : RunAnywhereModule { + * override val moduleId = "my-module" + * override val moduleName = "My Module" + * override val capabilities = setOf(SDKComponent.LLM) + * override val defaultPriority = 100 + * override val inferenceFramework = InferenceFramework.ONNX + * + * fun register(priority: Int = defaultPriority) { + * // Register with C++ backend + * } + * } + * ``` + * + * Matches iOS RunAnywhereModule.swift exactly. + */ +interface RunAnywhereModule { + /** Unique identifier for this module (e.g., "llamacpp", "onnx") */ + val moduleId: String + + /** Human-readable name for the module */ + val moduleName: String + + /** Set of capabilities this module provides */ + val capabilities: Set + + /** Default priority for service registration (higher = preferred) */ + val defaultPriority: Int + + /** The inference framework this module uses */ + val inferenceFramework: InferenceFramework +} diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/core/types/AudioTypes.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/core/types/AudioTypes.kt new file mode 100644 index 000000000..8c58cc0ad --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/core/types/AudioTypes.kt @@ -0,0 +1,37 @@ +package com.runanywhere.sdk.core + +import kotlinx.serialization.Serializable + +/** + * Audio format enum matching iOS AudioFormat pattern exactly + * This is the single source of truth for audio formats across STT, TTS, and VAD + * + * iOS reference: Core/Types/AudioTypes.swift + */ +@Serializable +enum class AudioFormat( + val rawValue: String, +) { + PCM("pcm"), + WAV("wav"), + MP3("mp3"), + OPUS("opus"), + AAC("aac"), + FLAC("flac"), + OGG("ogg"), + PCM_16BIT("pcm_16bit"), // Android-specific raw PCM format + ; + + /** + * File extension for this format (matches iOS fileExtension) + */ + val fileExtension: String + get() = rawValue + + companion object { + /** + * Get AudioFormat from raw value string + */ + fun fromRawValue(value: String): AudioFormat? = entries.find { it.rawValue == value.lowercase() } + } +} diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/core/types/AudioUtils.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/core/types/AudioUtils.kt new file mode 100644 index 000000000..04b8b3db4 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/core/types/AudioUtils.kt @@ -0,0 +1,223 @@ +package com.runanywhere.sdk.core + +/** + * Audio conversion utilities shared across all modules. + * + * Provides common audio format conversions needed for STT and TTS operations: + * - PCM byte arrays ↔ Float sample arrays + * - Float samples → WAV format + * + * These utilities are format-agnostic and can be used by any backend + * (ONNX, LlamaCpp, WhisperKit, etc.) + */ +object AudioUtils { + // ========================================================================= + // MARK: - PCM ↔ Float Conversion + // ========================================================================= + + /** + * Convert raw 16-bit PCM audio bytes to normalized float samples. + * + * Input format: Little-endian 16-bit signed PCM (standard Android/iOS format) + * Output range: -1.0f to 1.0f + * + * @param audioData Raw PCM byte array (2 bytes per sample) + * @return Float array with normalized samples + */ + fun pcmBytesToFloatSamples(audioData: ByteArray): FloatArray { + val samples = FloatArray(audioData.size / 2) + for (i in samples.indices) { + val low = audioData[i * 2].toInt() and 0xFF + val high = audioData[i * 2 + 1].toInt() + val sample = (high shl 8) or low + samples[i] = sample / 32768.0f + } + return samples + } + + /** + * Convert normalized float samples to 16-bit PCM bytes. + * + * Input range: -1.0f to 1.0f + * Output format: Little-endian 16-bit signed PCM + * + * @param samples Float array with normalized samples + * @return Raw PCM byte array + */ + fun floatSamplesToPcmBytes(samples: FloatArray): ByteArray { + val buffer = ByteArray(samples.size * 2) + for (i in samples.indices) { + val intSample = (samples[i] * 32767).toInt().coerceIn(-32768, 32767) + buffer[i * 2] = (intSample and 0xFF).toByte() + buffer[i * 2 + 1] = ((intSample shr 8) and 0xFF).toByte() + } + return buffer + } + + // ========================================================================= + // MARK: - WAV Format Conversion + // ========================================================================= + + /** + * Convert float samples to WAV format with standard header. + * + * Creates a complete WAV file with: + * - RIFF header + * - fmt chunk (PCM format, mono, 16-bit) + * - data chunk with samples + * + * @param samples Float array with normalized samples (-1.0 to 1.0) + * @param sampleRate Sample rate in Hz (e.g., 16000, 22050, 44100) + * @return Complete WAV file as byte array + */ + fun floatSamplesToWav( + samples: FloatArray, + sampleRate: Int, + ): ByteArray { + val numSamples = samples.size + val bitsPerSample = 16 + val numChannels = 1 + val byteRate = sampleRate * numChannels * bitsPerSample / 8 + val blockAlign = numChannels * bitsPerSample / 8 + val dataSize = numSamples * blockAlign + val fileSize = 36 + dataSize + + val buffer = ByteArray(44 + dataSize) + var offset = 0 + + // RIFF header + writeString(buffer, offset, "RIFF") + offset += 4 + writeInt32LE(buffer, offset, fileSize) + offset += 4 + writeString(buffer, offset, "WAVE") + offset += 4 + + // fmt chunk + writeString(buffer, offset, "fmt ") + offset += 4 + writeInt32LE(buffer, offset, 16) // Subchunk1Size (16 for PCM) + offset += 4 + writeInt16LE(buffer, offset, 1) // AudioFormat (1 = PCM) + offset += 2 + writeInt16LE(buffer, offset, numChannels) + offset += 2 + writeInt32LE(buffer, offset, sampleRate) + offset += 4 + writeInt32LE(buffer, offset, byteRate) + offset += 4 + writeInt16LE(buffer, offset, blockAlign) + offset += 2 + writeInt16LE(buffer, offset, bitsPerSample) + offset += 2 + + // data chunk + writeString(buffer, offset, "data") + offset += 4 + writeInt32LE(buffer, offset, dataSize) + offset += 4 + + // Write samples + for (sample in samples) { + val intSample = (sample * 32767).toInt().coerceIn(-32768, 32767) + writeInt16LE(buffer, offset, intSample) + offset += 2 + } + + return buffer + } + + /** + * Extract float samples from a WAV byte array. + * + * Parses the WAV header and extracts PCM samples. + * Supports 16-bit mono PCM WAV files. + * + * @param wavData Complete WAV file as byte array + * @return Pair of (samples, sampleRate) or null if invalid + */ + fun wavToFloatSamples(wavData: ByteArray): Pair? { + if (wavData.size < 44) return null + + // Verify RIFF header + val riff = String(wavData.sliceArray(0..3)) + if (riff != "RIFF") return null + + val wave = String(wavData.sliceArray(8..11)) + if (wave != "WAVE") return null + + // Read sample rate from fmt chunk (offset 24) + val sampleRate = readInt32LE(wavData, 24) + + // Find data chunk + var dataOffset = 12 + while (dataOffset < wavData.size - 8) { + val chunkId = String(wavData.sliceArray(dataOffset until dataOffset + 4)) + val chunkSize = readInt32LE(wavData, dataOffset + 4) + + if (chunkId == "data") { + dataOffset += 8 + val numSamples = chunkSize / 2 + val samples = FloatArray(numSamples) + + for (i in 0 until numSamples) { + val byteOffset = dataOffset + i * 2 + if (byteOffset + 1 < wavData.size) { + val low = wavData[byteOffset].toInt() and 0xFF + val high = wavData[byteOffset + 1].toInt() + val sample = (high shl 8) or low + samples[i] = sample / 32768.0f + } + } + + return Pair(samples, sampleRate) + } + + dataOffset += 8 + chunkSize + } + + return null + } + + // ========================================================================= + // MARK: - Private Helpers + // ========================================================================= + + private fun writeString( + buffer: ByteArray, + offset: Int, + value: String, + ) { + value.toByteArray().copyInto(buffer, offset) + } + + private fun writeInt16LE( + buffer: ByteArray, + offset: Int, + value: Int, + ) { + buffer[offset] = (value and 0xFF).toByte() + buffer[offset + 1] = ((value shr 8) and 0xFF).toByte() + } + + private fun writeInt32LE( + buffer: ByteArray, + offset: Int, + value: Int, + ) { + buffer[offset] = (value and 0xFF).toByte() + buffer[offset + 1] = ((value shr 8) and 0xFF).toByte() + buffer[offset + 2] = ((value shr 16) and 0xFF).toByte() + buffer[offset + 3] = ((value shr 24) and 0xFF).toByte() + } + + private fun readInt32LE( + buffer: ByteArray, + offset: Int, + ): Int { + return (buffer[offset].toInt() and 0xFF) or + ((buffer[offset + 1].toInt() and 0xFF) shl 8) or + ((buffer[offset + 2].toInt() and 0xFF) shl 16) or + ((buffer[offset + 3].toInt() and 0xFF) shl 24) + } +} diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/core/types/ComponentTypes.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/core/types/ComponentTypes.kt new file mode 100644 index 000000000..98854d98e --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/core/types/ComponentTypes.kt @@ -0,0 +1,176 @@ +package com.runanywhere.sdk.core.types + +import kotlinx.serialization.Serializable + +// MARK: - Component Protocols + +/** + * Protocol for component configuration and initialization. + * + * All component configurations (LLM, STT, TTS, VAD, etc.) conform to this interface. + * Provides common properties needed for model selection and framework preference. + * + * Mirrors Swift's ComponentConfiguration protocol. + */ +interface ComponentConfiguration { + /** Model identifier (optional - uses default if not specified) */ + val modelId: String? + + /** Preferred inference framework for this component (optional) */ + val preferredFramework: InferenceFramework? +} + +/** + * Protocol for component output data. + * + * Mirrors Swift's ComponentOutput protocol. + */ +interface ComponentOutput { + val timestamp: Long +} + +// MARK: - Audio Format + +/** + * Audio format enumeration. + * Mirrors Swift's AudioFormat enum. + */ +@Serializable +enum class AudioFormat( + val rawValue: String, +) { + PCM("pcm"), + WAV("wav"), + MP3("mp3"), + AAC("aac"), + OGG("ogg"), + OPUS("opus"), + FLAC("flac"), + ; + + companion object { + fun fromRawValue(value: String): AudioFormat? { + return entries.find { it.rawValue.equals(value, ignoreCase = true) } + } + } +} + +// MARK: - SDK Component + +/** + * SDK component types for identification. + * + * This enum consolidates what was previously `CapabilityType` and provides + * a unified type for all AI capabilities in the SDK. + * + * ## Usage + * + * ```kotlin + * // Check what capabilities a module provides + * val capabilities = MyModule.capabilities + * if (SDKComponent.LLM in capabilities) { + * // Module provides LLM services + * } + * ``` + * + * Matches iOS SDKComponent exactly. + */ +enum class SDKComponent( + val rawValue: String, +) { + LLM("LLM"), + STT("STT"), + TTS("TTS"), + VAD("VAD"), + VOICE("VOICE"), + EMBEDDING("EMBEDDING"), + ; + + /** Human-readable display name */ + val displayName: String + get() = + when (this) { + LLM -> "Language Model" + STT -> "Speech to Text" + TTS -> "Text to Speech" + VAD -> "Voice Activity Detection" + VOICE -> "Voice Agent" + EMBEDDING -> "Embedding" + } + + /** Analytics key for the component (lowercase) */ + val analyticsKey: String + get() = rawValue.lowercase() + + companion object { + /** Create from raw string value */ + fun fromRawValue(value: String): SDKComponent? { + return entries.find { it.rawValue.equals(value, ignoreCase = true) } + } + } +} + +/** + * Supported inference frameworks/runtimes for executing models. + * + * Matches iOS InferenceFramework exactly. + */ +enum class InferenceFramework( + val rawValue: String, +) { + // Model-based frameworks + ONNX("ONNX"), + LLAMA_CPP("LlamaCpp"), + FOUNDATION_MODELS("FoundationModels"), + SYSTEM_TTS("SystemTTS"), + FLUID_AUDIO("FluidAudio"), + + // Special cases + BUILT_IN("BuiltIn"), // For simple services (e.g., energy-based VAD) + NONE("None"), // For services that don't use a model + UNKNOWN("Unknown"), // For unknown/unspecified frameworks + ; + + /** Human-readable display name for the framework */ + val displayName: String + get() = + when (this) { + ONNX -> "ONNX Runtime" + LLAMA_CPP -> "llama.cpp" + FOUNDATION_MODELS -> "Foundation Models" + SYSTEM_TTS -> "System TTS" + FLUID_AUDIO -> "FluidAudio" + BUILT_IN -> "Built-in" + NONE -> "None" + UNKNOWN -> "Unknown" + } + + /** Snake_case key for analytics/telemetry */ + val analyticsKey: String + get() = + when (this) { + ONNX -> "onnx" + LLAMA_CPP -> "llama_cpp" + FOUNDATION_MODELS -> "foundation_models" + SYSTEM_TTS -> "system_tts" + FLUID_AUDIO -> "fluid_audio" + BUILT_IN -> "built_in" + NONE -> "none" + UNKNOWN -> "unknown" + } + + companion object { + /** Create from raw string value, matching case-insensitively */ + fun fromRawValue(value: String): InferenceFramework { + val lowercased = value.lowercase() + + // Try exact match + entries.find { it.rawValue.equals(value, ignoreCase = true) }?.let { return it } + + // Try analytics key match + entries.find { it.analyticsKey == lowercased }?.let { return it } + + return UNKNOWN + } + } +} diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/data/models/AuthenticationModels.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/data/models/AuthenticationModels.kt new file mode 100644 index 000000000..9e8d9213f --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/data/models/AuthenticationModels.kt @@ -0,0 +1,105 @@ +package com.runanywhere.sdk.data.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Authentication data models + * One-to-one translation from iOS Swift models to Kotlin + */ + +@Serializable +data class AuthenticationRequest( + @SerialName("api_key") + val apiKey: String, + @SerialName("device_id") + val deviceId: String?, + @SerialName("sdk_version") + val sdkVersion: String, + val platform: String, + @SerialName("platform_version") + val platformVersion: String, + @SerialName("app_identifier") + val appIdentifier: String, +) + +@Serializable +data class AuthenticationResponse( + @SerialName("access_token") + val accessToken: String, + @SerialName("refresh_token") + val refreshToken: String?, + @SerialName("expires_in") + val expiresIn: Int, + @SerialName("token_type") + val tokenType: String, + @SerialName("device_id") + val deviceId: String, + @SerialName("organization_id") + val organizationId: String, + @SerialName("user_id") + val userId: String? = null, // Make nullable with default value + @SerialName("token_expires_at") + val tokenExpiresAt: Long? = null, // Make nullable - backend may not return this +) + +@Serializable +data class RefreshTokenRequest( + @SerialName("refresh_token") + val refreshToken: String, + @SerialName("grant_type") + val grantType: String = "refresh_token", +) + +@Serializable +data class RefreshTokenResponse( + @SerialName("access_token") + val accessToken: String, + @SerialName("refresh_token") + val refreshToken: String?, + @SerialName("expires_in") + val expiresIn: Int, + @SerialName("token_type") + val tokenType: String, +) + +@Serializable +data class DeviceRegistrationRequest( + @SerialName("device_model") + val deviceModel: String, + @SerialName("device_name") + val deviceName: String, + @SerialName("operating_system") + val operatingSystem: String, + @SerialName("os_version") + val osVersion: String, + @SerialName("sdk_version") + val sdkVersion: String, + @SerialName("app_identifier") + val appIdentifier: String, + @SerialName("app_version") + val appVersion: String, + @SerialName("hardware_capabilities") + val hardwareCapabilities: Map = emptyMap(), + @SerialName("privacy_settings") + val privacySettings: Map = emptyMap(), +) + +// DeviceRegistrationResponse moved to data/network/models/AuthModels.kt - use import from there + +@Serializable +data class HealthCheckResponse( + val status: String, + val version: String, + val timestamp: Long, + val services: Map = emptyMap(), +) + +/** + * Stored token data for keychain + */ +data class StoredTokens( + val accessToken: String, + val refreshToken: String, + val expiresAt: Long, +) diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/data/models/DeviceInfoModels.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/data/models/DeviceInfoModels.kt new file mode 100644 index 000000000..0cd853431 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/data/models/DeviceInfoModels.kt @@ -0,0 +1,360 @@ +package com.runanywhere.sdk.data.models + +import com.runanywhere.sdk.utils.getCurrentTimeMillis +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Device info data models + * One-to-one translation from iOS Swift device info models to Kotlin + */ + +/** + * Platform-specific API level access + */ +expect fun getPlatformAPILevel(): Int + +/** + * Platform-specific OS version string + * Returns the human-readable OS version (e.g., "14.0" for Android 14, "macOS 14.0" for JVM on macOS) + */ +expect fun getPlatformOSVersion(): String + +/** + * GPU Type enumeration + * Equivalent to iOS GPUType enum (adapted for Android) + */ +@Serializable +enum class GPUType { + @SerialName("adreno") + ADRENO, + + @SerialName("mali") + MALI, + + @SerialName("power_vr") + POWER_VR, + + @SerialName("tegra") + TEGRA, + + @SerialName("vivante") + VIVANTE, + + @SerialName("unknown") + UNKNOWN, +} + +/** + * Battery state enumeration + * Equivalent to iOS BatteryState enum + */ +@Serializable +enum class BatteryState { + @SerialName("unknown") + UNKNOWN, + + @SerialName("unplugged") + UNPLUGGED, + + @SerialName("charging") + CHARGING, + + @SerialName("full") + FULL, +} + +/** + * Thermal state enumeration + * Equivalent to iOS ThermalState enum + */ +@Serializable +enum class ThermalState { + @SerialName("nominal") + NOMINAL, + + @SerialName("fair") + FAIR, + + @SerialName("serious") + SERIOUS, + + @SerialName("critical") + CRITICAL, +} + +/** + * Device info data class + * One-to-one translation from iOS DeviceInfoData + */ +@Serializable +data class DeviceInfoData( + @SerialName("device_id") + val deviceId: String, + @SerialName("device_name") + val deviceName: String, + @SerialName("device_model") + val deviceModel: String? = null, + @SerialName("platform") + val platform: String? = null, // "ios", "android", "macos", "windows", "linux", "web" + @SerialName("os_version") + val osVersion: String? = null, + @SerialName("form_factor") + val formFactor: String? = null, // "phone", "tablet", "desktop", "laptop", "watch", "tv" + @SerialName("architecture") + val architecture: String? = null, + @SerialName("chip_name") + val chipName: String? = null, + @SerialName("core_count") + val coreCount: Int? = null, + @SerialName("performance_cores") + val performanceCores: Int? = null, + @SerialName("efficiency_cores") + val efficiencyCores: Int? = null, + @SerialName("total_memory") + val totalMemory: Long? = null, // in bytes + @SerialName("available_memory") + val availableMemory: Long? = null, // in bytes + @SerialName("has_neural_engine") + val hasNeuralEngine: Boolean? = null, + @SerialName("neural_engine_cores") + val neuralEngineCores: Int? = null, + @SerialName("gpu_family") + val gpuFamily: String? = null, + // Keep existing fields for backward compatibility + @SerialName("system_name") + val systemName: String = "Android", + @SerialName("system_version") + val systemVersion: String, + @SerialName("model_name") + val modelName: String, + @SerialName("model_identifier") + val modelIdentifier: String, + // Hardware specifications + @SerialName("cpu_type") + val cpuType: String, + @SerialName("cpu_architecture") + val cpuArchitecture: String, + @SerialName("cpu_core_count") + val cpuCoreCount: Int, + @SerialName("cpu_frequency_mhz") + val cpuFrequencyMHz: Int? = null, + @SerialName("total_memory_mb") + val totalMemoryMB: Long, + @SerialName("available_memory_mb") + val availableMemoryMB: Long, + @SerialName("total_storage_mb") + val totalStorageMB: Long, + @SerialName("available_storage_mb") + val availableStorageMB: Long, + // GPU information + @SerialName("gpu_type") + val gpuType: GPUType, + @SerialName("gpu_name") + val gpuName: String? = null, + @SerialName("gpu_vendor") + val gpuVendor: String? = null, + @SerialName("supports_metal") + val supportsMetal: Boolean = false, // Always false on Android + @SerialName("supports_vulkan") + val supportsVulkan: Boolean = false, + @SerialName("supports_opencl") + val supportsOpenCL: Boolean = false, + // Power and thermal + @SerialName("battery_level") + val batteryLevel: Float? = null, // 0.0 to 1.0 (not 0-100) + @SerialName("battery_state") + val batteryState: BatteryState = BatteryState.UNKNOWN, + @SerialName("thermal_state") + val thermalState: ThermalState = ThermalState.NOMINAL, + @SerialName("is_low_power_mode") + val isLowPowerMode: Boolean = false, + // Network capabilities + @SerialName("has_cellular") + val hasCellular: Boolean = false, + @SerialName("has_wifi") + val hasWifi: Boolean = false, + @SerialName("has_bluetooth") + val hasBluetooth: Boolean = false, + // Sensors and capabilities + @SerialName("has_camera") + val hasCamera: Boolean = false, + @SerialName("has_microphone") + val hasMicrophone: Boolean = false, + @SerialName("has_speakers") + val hasSpeakers: Boolean = false, + @SerialName("has_biometric") + val hasBiometric: Boolean = false, + // Performance indicators + @SerialName("benchmark_score") + val benchmarkScore: Int? = null, + @SerialName("memory_pressure") + val memoryPressure: Float = 0.0f, // 0.0 to 1.0 + // Timestamps + @SerialName("created_at") + val createdAt: Long = getCurrentTimeMillis(), + @SerialName("updated_at") + val updatedAt: Long = getCurrentTimeMillis(), +) { + /** + * Check if device has sufficient memory for model + * Equivalent to iOS computed property + */ + fun hasSufficientMemory(requiredMB: Long): Boolean = availableMemoryMB >= requiredMB + + /** + * Check if device has sufficient storage for model + * Equivalent to iOS computed property + */ + fun hasSufficientStorage(requiredMB: Long): Boolean = availableStorageMB >= requiredMB + + /** + * Check if device supports GPU acceleration + * Equivalent to iOS computed property + */ + val supportsGPUAcceleration: Boolean + get() = supportsVulkan || supportsOpenCL || gpuType != GPUType.UNKNOWN + + /** + * Get device capability score (0-100) + * Used for model selection recommendations + */ + val capabilityScore: Int + get() { + var score = 0 + + // Memory contribution (0-30 points) + score += + when { + totalMemoryMB >= 8192 -> 30 + totalMemoryMB >= 6144 -> 25 + totalMemoryMB >= 4096 -> 20 + totalMemoryMB >= 3072 -> 15 + totalMemoryMB >= 2048 -> 10 + else -> 5 + } + + // CPU contribution (0-25 points) + score += + when { + cpuCoreCount >= 8 -> 25 + cpuCoreCount >= 6 -> 20 + cpuCoreCount >= 4 -> 15 + cpuCoreCount >= 2 -> 10 + else -> 5 + } + + // GPU contribution (0-20 points) + score += + when (gpuType) { + GPUType.ADRENO -> 20 + GPUType.MALI -> 18 + GPUType.POWER_VR -> 15 + GPUType.TEGRA -> 17 + GPUType.VIVANTE -> 10 + GPUType.UNKNOWN -> 5 + } + + // System version contribution (0-15 points) + val apiLevel = getPlatformAPILevel() + score += + when { + apiLevel >= 34 -> 15 // Android 14+ + apiLevel >= 31 -> 12 // Android 12+ + apiLevel >= 29 -> 10 // Android 10+ + apiLevel >= 26 -> 8 // Android 8+ + else -> 5 + } + + // Storage contribution (0-10 points) + score += + when { + availableStorageMB >= 10240 -> 10 // 10GB+ + availableStorageMB >= 5120 -> 8 // 5GB+ + availableStorageMB >= 2048 -> 6 // 2GB+ + availableStorageMB >= 1024 -> 4 // 1GB+ + else -> 2 + } + + return minOf(score, 100) + } +} + +/** + * Device fingerprint for identification + * Used to uniquely identify device across app installations + */ +@Serializable +data class DeviceFingerprint( + @SerialName("device_id") + val deviceId: String, + @SerialName("hardware_fingerprint") + val hardwareFingerprint: String, + @SerialName("software_fingerprint") + val softwareFingerprint: String, + @SerialName("display_fingerprint") + val displayFingerprint: String, + @SerialName("created_at") + val createdAt: Long = getCurrentTimeMillis(), +) { + /** + * Generate comprehensive device fingerprint + * Combines hardware and software characteristics + */ + val combinedFingerprint: String + get() = "${hardwareFingerprint}_${softwareFingerprint}_$displayFingerprint".hashCode().toString() +} + +/** + * Device performance metrics + * Used for benchmarking and optimization + */ +@Serializable +data class DevicePerformanceMetrics( + @SerialName("device_id") + val deviceId: String, + // CPU metrics + @SerialName("cpu_usage_percent") + val cpuUsagePercent: Float = 0.0f, + @SerialName("cpu_temperature") + val cpuTemperature: Float? = null, + // Memory metrics + @SerialName("memory_usage_percent") + val memoryUsagePercent: Float = 0.0f, + @SerialName("memory_pressure_level") + val memoryPressureLevel: Float = 0.0f, + // GPU metrics (if available) + @SerialName("gpu_usage_percent") + val gpuUsagePercent: Float? = null, + @SerialName("gpu_temperature") + val gpuTemperature: Float? = null, + // Battery metrics + @SerialName("battery_drain_rate") + val batteryDrainRate: Float? = null, // mAh per hour + @SerialName("power_consumption_mw") + val powerConsumptionMW: Float? = null, + // Performance scores + @SerialName("single_core_score") + val singleCoreScore: Int? = null, + @SerialName("multi_core_score") + val multiCoreScore: Int? = null, + @SerialName("gpu_score") + val gpuScore: Int? = null, + @SerialName("measured_at") + val measuredAt: Long = getCurrentTimeMillis(), +) + +/** + * Device capability assessment + * Recommendations for model usage based on device specs + */ +data class DeviceCapabilityAssessment( + val deviceInfo: DeviceInfoData, + val recommendedModelSizes: List, // e.g., ["tiny", "base", "small"] + val maxModelSizeMB: Long, + val supportsGPUAcceleration: Boolean, + val supportsParallelProcessing: Boolean, + val batteryOptimized: Boolean, + val performanceRating: Int, // 1-10 scale + val recommendations: List, +) diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/data/models/DeviceRegistrationWrapper.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/data/models/DeviceRegistrationWrapper.kt new file mode 100644 index 000000000..a22a912f5 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/data/models/DeviceRegistrationWrapper.kt @@ -0,0 +1,26 @@ +package com.runanywhere.sdk.data.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Device registration request wrapper for backend API + */ +@Serializable +data class DeviceRegistrationPayload( + @SerialName("device_info") + val deviceInfo: DeviceInfoData, +) + +/** + * Device registration response from backend + */ +@Serializable +data class DeviceRegistrationResult( + @SerialName("device_id") + val deviceId: String, + @SerialName("status") + val status: String, // "registered", "updated" + @SerialName("sync_status") + val syncStatus: String, // "synced", "pending" +) diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/data/network/CircuitBreaker.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/data/network/CircuitBreaker.kt new file mode 100644 index 000000000..3baab9a54 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/data/network/CircuitBreaker.kt @@ -0,0 +1,312 @@ +package com.runanywhere.sdk.data.network + +import com.runanywhere.sdk.foundation.SDKLogger +import com.runanywhere.sdk.foundation.errors.ErrorCategory +import com.runanywhere.sdk.foundation.errors.SDKError +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * Circuit breaker pattern implementation for network resilience + * Prevents cascading failures by temporarily disabling failing services + * Matches enterprise-grade reliability patterns used in production systems + */ +class CircuitBreaker( + private val failureThreshold: Int = 5, + private val recoveryTimeoutMs: Long = 30_000, // 30 seconds + private val halfOpenMaxCalls: Int = 3, + private val name: String = "CircuitBreaker", +) { + private val logger = SDKLogger("CircuitBreaker[$name]") + private val mutex = Mutex() + + // Circuit breaker state + private var state: CircuitBreakerState = CircuitBreakerState.CLOSED + private var failureCount: Int = 0 + private var lastFailureTime: Long = 0 + private var halfOpenCallsCount: Int = 0 + + /** + * Execute a suspending function with circuit breaker protection + */ + suspend fun execute(operation: suspend () -> T): T { + mutex.withLock { + when (state) { + CircuitBreakerState.CLOSED -> { + // Normal operation - allow calls + return executeAndHandleResult(operation) + } + + CircuitBreakerState.OPEN -> { + // Circuit is open - check if we should try half-open + if (shouldAttemptReset()) { + logger.info("Circuit breaker transitioning to HALF_OPEN state") + state = CircuitBreakerState.HALF_OPEN + halfOpenCallsCount = 0 + return executeAndHandleResult(operation) + } else { + // Still in open state - reject the call + val timeUntilRetry = recoveryTimeoutMs - (System.currentTimeMillis() - lastFailureTime) + logger.warn("Circuit breaker is OPEN, rejecting call (retry in ${timeUntilRetry}ms)") + throw SDKError.network("Circuit breaker is open for $name. Service temporarily unavailable.") + } + } + + CircuitBreakerState.HALF_OPEN -> { + // Half-open state - allow limited calls to test service recovery + if (halfOpenCallsCount < halfOpenMaxCalls) { + halfOpenCallsCount++ + return executeAndHandleResult(operation) + } else { + // Too many calls in half-open state, reject + logger.warn("Circuit breaker HALF_OPEN max calls reached, rejecting call") + throw SDKError.network("Circuit breaker is in half-open state with max calls reached for $name") + } + } + } + } + } + + /** + * Execute operation and handle success/failure + */ + private suspend fun executeAndHandleResult(operation: suspend () -> T): T = + try { + val result = operation() + onSuccess() + result + } catch (e: Exception) { + onFailure(e) + throw e + } + + /** + * Handle successful operation + */ + private fun onSuccess() { + when (state) { + CircuitBreakerState.HALF_OPEN -> { + // Successful call in half-open state - transition back to closed + logger.info("Circuit breaker transitioning to CLOSED state after successful call") + state = CircuitBreakerState.CLOSED + failureCount = 0 + halfOpenCallsCount = 0 + } + CircuitBreakerState.CLOSED -> { + // Reset failure count on success + if (failureCount > 0) { + logger.debug("Circuit breaker resetting failure count after successful call") + failureCount = 0 + } + } + CircuitBreakerState.OPEN -> { + // This shouldn't happen, but handle gracefully + logger.warn("Unexpected success in OPEN state") + } + } + } + + /** + * Handle failed operation + */ + private fun onFailure(exception: Exception) { + // Only count certain types of failures + if (!isFailureCountable(exception)) { + logger.debug("Exception not counted towards circuit breaker failures: ${exception.message}") + return + } + + failureCount++ + lastFailureTime = System.currentTimeMillis() + + logger.warn("Circuit breaker failure recorded (count: $failureCount): ${exception.message}") + + when (state) { + CircuitBreakerState.CLOSED -> { + if (failureCount >= failureThreshold) { + logger.error("Circuit breaker opening due to failure threshold reached ($failureCount >= $failureThreshold)") + state = CircuitBreakerState.OPEN + } + } + CircuitBreakerState.HALF_OPEN -> { + // Any failure in half-open state goes back to open + logger.error("Circuit breaker returning to OPEN state due to failure in HALF_OPEN") + state = CircuitBreakerState.OPEN + halfOpenCallsCount = 0 + } + CircuitBreakerState.OPEN -> { + // Already open, just update failure time + logger.debug("Circuit breaker failure recorded in OPEN state") + } + } + } + + /** + * Check if enough time has passed to attempt reset + */ + private fun shouldAttemptReset(): Boolean = System.currentTimeMillis() - lastFailureTime >= recoveryTimeoutMs + + /** + * Determine if an exception should count towards circuit breaker failures + * Only network-related and server errors should trigger the circuit breaker + */ + private fun isFailureCountable(exception: Exception): Boolean = + when (exception) { + is SDKError -> { + when (exception.category) { + ErrorCategory.NETWORK -> { + val message = exception.message.lowercase() + // Count timeouts, connection errors, and server errors + message.contains("timeout") || + message.contains("connection") || + message.contains("server error") || + message.contains("rate limit") || + message.contains("service unavailable") + } + ErrorCategory.AUTHENTICATION -> false // Auth errors shouldn't trigger circuit breaker + else -> false + } + } + else -> { + // Count general network-related exceptions + val exceptionName = exception::class.simpleName?.lowercase() ?: "" + exceptionName.contains("timeout") || + exceptionName.contains("connection") || + exceptionName.contains("socket") || + exceptionName.contains("network") + } + } + + /** + * Get current circuit breaker status + */ + fun getStatus(): CircuitBreakerStatus = + CircuitBreakerStatus( + state = state, + failureCount = failureCount, + lastFailureTime = lastFailureTime, + halfOpenCallsCount = halfOpenCallsCount, + ) + + /** + * Manually reset the circuit breaker (for testing or admin purposes) + */ + suspend fun reset() { + mutex.withLock { + logger.info("Circuit breaker manually reset") + state = CircuitBreakerState.CLOSED + failureCount = 0 + lastFailureTime = 0 + halfOpenCallsCount = 0 + } + } + + /** + * Force the circuit breaker to open (for testing or maintenance) + */ + suspend fun forceOpen() { + mutex.withLock { + logger.warn("Circuit breaker manually opened") + state = CircuitBreakerState.OPEN + lastFailureTime = System.currentTimeMillis() + } + } +} + +/** + * Circuit breaker states + */ +enum class CircuitBreakerState { + CLOSED, // Normal operation + OPEN, // Blocking all calls due to failures + HALF_OPEN, // Testing if service has recovered +} + +/** + * Circuit breaker status for monitoring + */ +data class CircuitBreakerStatus( + val state: CircuitBreakerState, + val failureCount: Int, + val lastFailureTime: Long, + val halfOpenCallsCount: Int, +) { + val isHealthy: Boolean + get() = state == CircuitBreakerState.CLOSED && failureCount == 0 + + val timeUntilRetryMs: Long + get() = + if (state == CircuitBreakerState.OPEN) { + maxOf(0, 30_000 - (System.currentTimeMillis() - lastFailureTime)) + } else { + 0 + } +} + +/** + * Circuit breaker registry for managing multiple circuit breakers + * + * Thread Safety: + * All operations are synchronized to prevent race conditions during + * concurrent access. Multiple threads can safely access and create + * circuit breakers. + */ +object CircuitBreakerRegistry { + private val _circuitBreakers = mutableMapOf() + private val logger = SDKLogger("CircuitBreakerRegistry") + + /** + * Get or create a circuit breaker for a service + * Thread-safe: Can be called from any thread + */ + fun getOrCreate( + name: String, + failureThreshold: Int = 5, + recoveryTimeoutMs: Long = 30_000, + halfOpenMaxCalls: Int = 3, + ): CircuitBreaker = + synchronized(_circuitBreakers) { + _circuitBreakers.getOrPut(name) { + logger.info("Creating new circuit breaker for service: $name") + CircuitBreaker( + failureThreshold = failureThreshold, + recoveryTimeoutMs = recoveryTimeoutMs, + halfOpenMaxCalls = halfOpenMaxCalls, + name = name, + ) + } + } + + /** + * Get all circuit breaker statuses + * Thread-safe: Returns a snapshot of current statuses + */ + fun getAllStatuses(): Map = + synchronized(_circuitBreakers) { + _circuitBreakers.mapValues { it.value.getStatus() } + } + + /** + * Reset all circuit breakers + * Thread-safe: Can be called from any thread + */ + suspend fun resetAll() { + logger.info("Resetting all circuit breakers") + val breakers = + synchronized(_circuitBreakers) { + _circuitBreakers.values.toList() + } + breakers.forEach { it.reset() } + } + + /** + * Remove a circuit breaker from registry + * Thread-safe: Can be called from any thread + */ + fun remove(name: String) { + synchronized(_circuitBreakers) { + _circuitBreakers.remove(name) + } + logger.info("Removed circuit breaker: $name") + } +} diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/data/network/HttpClient.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/data/network/HttpClient.kt new file mode 100644 index 000000000..9db199fd3 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/data/network/HttpClient.kt @@ -0,0 +1,130 @@ +package com.runanywhere.sdk.data.network + +/** + * HTTP response data class + */ +data class HttpResponse( + val statusCode: Int, + val body: ByteArray, + val headers: Map> = emptyMap(), +) { + val isSuccessful: Boolean + get() = statusCode in 200..299 + + fun bodyAsString(): String = body.decodeToString() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is HttpResponse) return false + + if (statusCode != other.statusCode) return false + if (!body.contentEquals(other.body)) return false + if (headers != other.headers) return false + + return true + } + + override fun hashCode(): Int { + var result = statusCode + result = 31 * result + body.contentHashCode() + result = 31 * result + headers.hashCode() + return result + } +} + +/** + * Platform-agnostic HTTP client interface + * Provides common HTTP operations that are implemented differently on each platform + * Enhanced with multipart support and advanced features + */ +interface HttpClient { + /** + * Perform a GET request + */ + suspend fun get( + url: String, + headers: Map = emptyMap(), + ): HttpResponse + + /** + * Perform a POST request + */ + suspend fun post( + url: String, + body: ByteArray, + headers: Map = emptyMap(), + ): HttpResponse + + /** + * Perform a PUT request + */ + suspend fun put( + url: String, + body: ByteArray, + headers: Map = emptyMap(), + ): HttpResponse + + /** + * Perform a DELETE request + */ + suspend fun delete( + url: String, + headers: Map = emptyMap(), + ): HttpResponse + + /** + * Download a file with progress callback + */ + suspend fun download( + url: String, + headers: Map = emptyMap(), + onProgress: ((bytesDownloaded: Long, totalBytes: Long) -> Unit)? = null, + ): ByteArray + + /** + * Upload a file with progress callback + */ + suspend fun upload( + url: String, + data: ByteArray, + headers: Map = emptyMap(), + onProgress: ((bytesUploaded: Long, totalBytes: Long) -> Unit)? = null, + ): HttpResponse + + /** + * Set a default timeout for all requests + */ + fun setDefaultTimeout(timeoutMillis: Long) + + /** + * Set default headers that will be included in all requests + */ + fun setDefaultHeaders(headers: Map) + + /** + * Cancel all pending requests (platform-specific implementation) + */ + fun cancelAllRequests() {} +} + +/** + * Configuration for HTTP client behavior + */ +data class HttpClientConfig( + val connectTimeoutMs: Long = 30_000, + val readTimeoutMs: Long = 30_000, + val writeTimeoutMs: Long = 30_000, + val enableLogging: Boolean = false, + val maxRetries: Int = 3, + val retryDelayMs: Long = 1000, +) + +/** + * Expected to be provided by each platform + */ +expect fun createHttpClient(): HttpClient + +/** + * Expected to be provided by each platform with configuration + */ +expect fun createHttpClient(config: NetworkConfiguration): HttpClient diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/data/network/MultipartSupport.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/data/network/MultipartSupport.kt new file mode 100644 index 000000000..28b9b1095 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/data/network/MultipartSupport.kt @@ -0,0 +1,119 @@ +package com.runanywhere.sdk.data.network + +/** + * Multipart form data support for HTTP clients + */ + +/** + * Multipart form data part definitions + */ +sealed class MultipartPart { + data class FormField( + val name: String, + val value: String, + ) : MultipartPart() + + data class FileField( + val name: String, + val filename: String, + val data: ByteArray, + val contentType: String? = null, + ) : MultipartPart() { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is FileField) return false + if (name != other.name) return false + if (filename != other.filename) return false + if (!data.contentEquals(other.data)) return false + if (contentType != other.contentType) return false + return true + } + + override fun hashCode(): Int { + var result = name.hashCode() + result = 31 * result + filename.hashCode() + result = 31 * result + data.contentHashCode() + result = 31 * result + (contentType?.hashCode() ?: 0) + return result + } + } +} + +/** + * Request cancellation support + */ +interface CancellableRequest { + fun cancel() + + val isCancelled: Boolean +} + +/** + * HTTP request builder for complex requests + */ +class HttpRequestBuilder { + private val headers = mutableMapOf() + private var body: ByteArray? = null + private var multipartParts: List? = null + + fun header( + name: String, + value: String, + ) = apply { + headers[name] = value + } + + fun headers(headerMap: Map) = + apply { + headers.putAll(headerMap) + } + + fun body(data: ByteArray) = + apply { + this.body = data + this.multipartParts = null // Clear multipart if body is set + } + + fun multipart(parts: List) = + apply { + this.multipartParts = parts + this.body = null // Clear body if multipart is set + } + + internal fun build() = + HttpRequestData( + headers = headers.toMap(), + body = body, + multipartParts = multipartParts, + ) +} + +/** + * Internal request data structure + */ +internal data class HttpRequestData( + val headers: Map, + val body: ByteArray?, + val multipartParts: List?, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is HttpRequestData) return false + if (headers != other.headers) return false + if (body != null) { + if (other.body == null) return false + if (!body.contentEquals(other.body)) return false + } else if (other.body != null) { + return false + } + if (multipartParts != other.multipartParts) return false + return true + } + + override fun hashCode(): Int { + var result = headers.hashCode() + result = 31 * result + (body?.contentHashCode() ?: 0) + result = 31 * result + (multipartParts?.hashCode() ?: 0) + return result + } +} diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/data/network/NetworkCheckerInterface.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/data/network/NetworkCheckerInterface.kt new file mode 100644 index 000000000..763dde96d --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/data/network/NetworkCheckerInterface.kt @@ -0,0 +1,11 @@ +package com.runanywhere.sdk.data.network + +/** + * Interface for checking network availability + * Extracted from APIClient.kt for use by NetworkServiceFactory + */ +interface NetworkChecker { + suspend fun isNetworkAvailable(): Boolean + + suspend fun getNetworkType(): String +} diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/data/network/NetworkConfiguration.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/data/network/NetworkConfiguration.kt new file mode 100644 index 000000000..7ed526a6b --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/data/network/NetworkConfiguration.kt @@ -0,0 +1,390 @@ +package com.runanywhere.sdk.data.network + +/** + * Comprehensive network configuration for HTTP clients + * Provides all networking configuration options to achieve parity with iOS implementation + */ +data class NetworkConfiguration( + // Timeout Configuration + val connectTimeoutMs: Long = 30_000, // 30 seconds + val readTimeoutMs: Long = 30_000, // 30 seconds + val writeTimeoutMs: Long = 30_000, // 30 seconds + val callTimeoutMs: Long = 60_000, // 60 seconds total call timeout + // Retry Policy Configuration + val maxRetryAttempts: Int = 3, + val baseRetryDelayMs: Long = 1000, // 1 second + val maxRetryDelayMs: Long = 30_000, // 30 seconds cap + val retryBackoffMultiplier: Double = 2.0, + val retryJitterPercentage: Double = 0.25, // 25% jitter + // Connection Pool Configuration + val maxIdleConnections: Int = 5, + val keepAliveDurationMs: Long = 300_000, // 5 minutes + // SSL/TLS Configuration + val enableTlsVersions: List = listOf("TLSv1.2", "TLSv1.3"), + val certificatePinning: CertificatePinningConfig? = null, + val hostnameVerification: Boolean = true, + // Cache Configuration + val enableResponseCaching: Boolean = false, + val cacheSizeBytes: Long = 50 * 1024 * 1024, // 50MB + val cacheDirectory: String? = null, + // Proxy Configuration + val proxyConfig: ProxyConfig? = null, + // Request/Response Interceptor Configuration + val enableLogging: Boolean = false, + val logLevel: NetworkLogLevel = NetworkLogLevel.NONE, + val logBodySizeLimit: Int = 8192, // 8KB + // User-Agent Configuration + val userAgent: String = "RunAnywhereSDK-Kotlin/0.1.0", + val customHeaders: Map = emptyMap(), + // Progress Reporting Configuration + val progressCallbackIntervalMs: Long = 100, // 100ms intervals for progress + // Connection Behavior + val followRedirects: Boolean = true, + val maxRedirects: Int = 20, + val enableHttp2: Boolean = true, + val enableCompression: Boolean = true, + // Advanced Configuration + val enableDiskCaching: Boolean = true, + val enableMemoryCaching: Boolean = true, + val dnsCacheTimeoutMs: Long = 60_000, // 1 minute +) { + /** + * Validate configuration values + */ + fun validate(): List { + val errors = mutableListOf() + + if (connectTimeoutMs < 0) errors.add("connectTimeoutMs must be >= 0") + if (readTimeoutMs < 0) errors.add("readTimeoutMs must be >= 0") + if (writeTimeoutMs < 0) errors.add("writeTimeoutMs must be >= 0") + if (callTimeoutMs < 0) errors.add("callTimeoutMs must be >= 0") + + if (maxRetryAttempts < 0) errors.add("maxRetryAttempts must be >= 0") + if (baseRetryDelayMs < 0) errors.add("baseRetryDelayMs must be >= 0") + if (maxRetryDelayMs < baseRetryDelayMs) errors.add("maxRetryDelayMs must be >= baseRetryDelayMs") + if (retryBackoffMultiplier < 1.0) errors.add("retryBackoffMultiplier must be >= 1.0") + if (retryJitterPercentage < 0.0 || retryJitterPercentage > 1.0) { + errors.add("retryJitterPercentage must be between 0.0 and 1.0") + } + + if (maxIdleConnections < 0) errors.add("maxIdleConnections must be >= 0") + if (keepAliveDurationMs < 0) errors.add("keepAliveDurationMs must be >= 0") + + if (cacheSizeBytes < 0) errors.add("cacheSizeBytes must be >= 0") + if (logBodySizeLimit < 0) errors.add("logBodySizeLimit must be >= 0") + if (progressCallbackIntervalMs < 0) errors.add("progressCallbackIntervalMs must be >= 0") + if (maxRedirects < 0) errors.add("maxRedirects must be >= 0") + if (dnsCacheTimeoutMs < 0) errors.add("dnsCacheTimeoutMs must be >= 0") + + return errors + } + + /** + * Check if configuration is valid + */ + fun isValid(): Boolean = validate().isEmpty() + + /** + * Create a builder for this configuration + */ + fun toBuilder() = Builder(this) + + /** + * Builder pattern for NetworkConfiguration + */ + class Builder( + config: NetworkConfiguration = NetworkConfiguration(), + ) { + private var connectTimeoutMs = config.connectTimeoutMs + private var readTimeoutMs = config.readTimeoutMs + private var writeTimeoutMs = config.writeTimeoutMs + private var callTimeoutMs = config.callTimeoutMs + + private var maxRetryAttempts = config.maxRetryAttempts + private var baseRetryDelayMs = config.baseRetryDelayMs + private var maxRetryDelayMs = config.maxRetryDelayMs + private var retryBackoffMultiplier = config.retryBackoffMultiplier + private var retryJitterPercentage = config.retryJitterPercentage + + private var maxIdleConnections = config.maxIdleConnections + private var keepAliveDurationMs = config.keepAliveDurationMs + + private var enableTlsVersions = config.enableTlsVersions + private var certificatePinning = config.certificatePinning + private var hostnameVerification = config.hostnameVerification + + private var enableResponseCaching = config.enableResponseCaching + private var cacheSizeBytes = config.cacheSizeBytes + private var cacheDirectory = config.cacheDirectory + + private var proxyConfig = config.proxyConfig + + private var enableLogging = config.enableLogging + private var logLevel = config.logLevel + private var logBodySizeLimit = config.logBodySizeLimit + + private var userAgent = config.userAgent + private var customHeaders = config.customHeaders + + private var progressCallbackIntervalMs = config.progressCallbackIntervalMs + + private var followRedirects = config.followRedirects + private var maxRedirects = config.maxRedirects + private var enableHttp2 = config.enableHttp2 + private var enableCompression = config.enableCompression + + private var enableDiskCaching = config.enableDiskCaching + private var enableMemoryCaching = config.enableMemoryCaching + private var dnsCacheTimeoutMs = config.dnsCacheTimeoutMs + + fun connectTimeout(timeoutMs: Long) = apply { this.connectTimeoutMs = timeoutMs } + + fun readTimeout(timeoutMs: Long) = apply { this.readTimeoutMs = timeoutMs } + + fun writeTimeout(timeoutMs: Long) = apply { this.writeTimeoutMs = timeoutMs } + + fun callTimeout(timeoutMs: Long) = apply { this.callTimeoutMs = timeoutMs } + + fun maxRetries(attempts: Int) = apply { this.maxRetryAttempts = attempts } + + fun retryDelay( + baseDelayMs: Long, + maxDelayMs: Long = this.maxRetryDelayMs, + ) = apply { + this.baseRetryDelayMs = baseDelayMs + this.maxRetryDelayMs = maxDelayMs + } + + fun retryBackoff(multiplier: Double) = apply { this.retryBackoffMultiplier = multiplier } + + fun retryJitter(percentage: Double) = apply { this.retryJitterPercentage = percentage } + + fun connectionPool( + maxIdle: Int, + keepAlive: Long, + ) = apply { + this.maxIdleConnections = maxIdle + this.keepAliveDurationMs = keepAlive + } + + fun tls(versions: List) = apply { this.enableTlsVersions = versions } + + fun certificatePinning(config: CertificatePinningConfig?) = apply { this.certificatePinning = config } + + fun hostnameVerification(enabled: Boolean) = apply { this.hostnameVerification = enabled } + + fun caching( + enabled: Boolean, + sizeBytes: Long = this.cacheSizeBytes, + directory: String? = this.cacheDirectory, + ) = apply { + this.enableResponseCaching = enabled + this.cacheSizeBytes = sizeBytes + this.cacheDirectory = directory + } + + fun proxy(config: ProxyConfig?) = apply { this.proxyConfig = config } + + fun logging( + enabled: Boolean, + level: NetworkLogLevel = NetworkLogLevel.INFO, + ) = apply { + this.enableLogging = enabled + this.logLevel = level + } + + fun logBodyLimit(sizeLimit: Int) = apply { this.logBodySizeLimit = sizeLimit } + + fun userAgent(agent: String) = apply { this.userAgent = agent } + + fun headers(headers: Map) = apply { this.customHeaders = headers } + + fun progressInterval(intervalMs: Long) = apply { this.progressCallbackIntervalMs = intervalMs } + + fun redirects( + follow: Boolean, + maxRedirects: Int = this.maxRedirects, + ) = apply { + this.followRedirects = follow + this.maxRedirects = maxRedirects + } + + fun http2(enabled: Boolean) = apply { this.enableHttp2 = enabled } + + fun compression(enabled: Boolean) = apply { this.enableCompression = enabled } + + fun diskCaching(enabled: Boolean) = apply { this.enableDiskCaching = enabled } + + fun memoryCaching(enabled: Boolean) = apply { this.enableMemoryCaching = enabled } + + fun dnsCache(timeoutMs: Long) = apply { this.dnsCacheTimeoutMs = timeoutMs } + + fun build() = + NetworkConfiguration( + connectTimeoutMs = connectTimeoutMs, + readTimeoutMs = readTimeoutMs, + writeTimeoutMs = writeTimeoutMs, + callTimeoutMs = callTimeoutMs, + maxRetryAttempts = maxRetryAttempts, + baseRetryDelayMs = baseRetryDelayMs, + maxRetryDelayMs = maxRetryDelayMs, + retryBackoffMultiplier = retryBackoffMultiplier, + retryJitterPercentage = retryJitterPercentage, + maxIdleConnections = maxIdleConnections, + keepAliveDurationMs = keepAliveDurationMs, + enableTlsVersions = enableTlsVersions, + certificatePinning = certificatePinning, + hostnameVerification = hostnameVerification, + enableResponseCaching = enableResponseCaching, + cacheSizeBytes = cacheSizeBytes, + cacheDirectory = cacheDirectory, + proxyConfig = proxyConfig, + enableLogging = enableLogging, + logLevel = logLevel, + logBodySizeLimit = logBodySizeLimit, + userAgent = userAgent, + customHeaders = customHeaders, + progressCallbackIntervalMs = progressCallbackIntervalMs, + followRedirects = followRedirects, + maxRedirects = maxRedirects, + enableHttp2 = enableHttp2, + enableCompression = enableCompression, + enableDiskCaching = enableDiskCaching, + enableMemoryCaching = enableMemoryCaching, + dnsCacheTimeoutMs = dnsCacheTimeoutMs, + ) + } + + companion object { + /** + * Default production configuration + */ + fun production() = + NetworkConfiguration( + maxRetryAttempts = 3, + enableLogging = false, + logLevel = NetworkLogLevel.NONE, + enableResponseCaching = true, + enableHttp2 = true, + enableCompression = true, + ) + + /** + * Development configuration with enhanced logging + */ + fun development() = + NetworkConfiguration( + maxRetryAttempts = 1, // Fewer retries for faster feedback + enableLogging = true, + logLevel = NetworkLogLevel.BODY, + enableResponseCaching = false, // No caching during development + enableHttp2 = true, + enableCompression = true, + ) + + /** + * Testing configuration with minimal timeouts + */ + fun testing() = + NetworkConfiguration( + connectTimeoutMs = 5_000, // 5 seconds + readTimeoutMs = 5_000, // 5 seconds + writeTimeoutMs = 5_000, // 5 seconds + callTimeoutMs = 10_000, // 10 seconds + maxRetryAttempts = 0, // No retries in tests + enableLogging = false, + enableResponseCaching = false, + enableHttp2 = false, // Use HTTP/1.1 for simpler testing + enableCompression = false, + ) + } +} + +/** + * SSL Certificate pinning configuration + */ +data class CertificatePinningConfig( + val pins: Map>, // hostname -> list of SHA-256 pins + val enforcePinning: Boolean = true, + val includeSubdomains: Boolean = false, +) + +/** + * Proxy configuration + */ +sealed class ProxyConfig { + data class Http( + val host: String, + val port: Int, + val username: String? = null, + val password: String? = null, + ) : ProxyConfig() + + data class Socks( + val host: String, + val port: Int, + val username: String? = null, + val password: String? = null, + ) : ProxyConfig() + + object Direct : ProxyConfig() +} + +/** + * Network logging levels + */ +enum class NetworkLogLevel { + NONE, // No logging + BASIC, // Request/response line only + HEADERS, // Request/response line + headers + BODY, // Request/response line + headers + body + INFO, // HEADERS level for successful requests, BODY level for errors + DEBUG, // Everything including internal client logs +} + +/** + * Retry policy configuration + */ +data class RetryPolicy( + val maxAttempts: Int, + val baseDelayMs: Long, + val maxDelayMs: Long, + val backoffMultiplier: Double, + val jitterPercentage: Double, + val retryableStatusCodes: Set = setOf(408, 429, 502, 503, 504), + val retryableExceptions: Set = + setOf( + "java.net.SocketTimeoutException", + "java.net.ConnectException", + "java.net.UnknownHostException", + ), +) { + companion object { + val DEFAULT = + RetryPolicy( + maxAttempts = 3, + baseDelayMs = 1000, + maxDelayMs = 30_000, + backoffMultiplier = 2.0, + jitterPercentage = 0.25, + ) + + val AGGRESSIVE = + RetryPolicy( + maxAttempts = 5, + baseDelayMs = 500, + maxDelayMs = 60_000, + backoffMultiplier = 1.5, + jitterPercentage = 0.1, + ) + + val NO_RETRY = + RetryPolicy( + maxAttempts = 0, + baseDelayMs = 0, + maxDelayMs = 0, + backoffMultiplier = 1.0, + jitterPercentage = 0.0, + ) + } +} diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/data/network/NetworkService.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/data/network/NetworkService.kt new file mode 100644 index 000000000..d21097c28 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/data/network/NetworkService.kt @@ -0,0 +1,47 @@ +package com.runanywhere.sdk.data.network + +import com.runanywhere.sdk.data.network.models.APIEndpoint + +/** + * Network service interface - equivalent to iOS NetworkService protocol + * Enhanced with generic POST/GET methods and proper authentication support + */ +interface NetworkService { + /** + * POST request with JSON payload and typed response + * Equivalent to iOS: func post(_ endpoint: APIEndpoint, _ payload: T, requiresAuth: Bool) async throws -> R + */ + suspend fun post( + endpoint: APIEndpoint, + payload: T, + requiresAuth: Boolean = true, + ): R + + /** + * GET request with typed response + * Equivalent to iOS: func get(_ endpoint: APIEndpoint, requiresAuth: Bool) async throws -> R + */ + suspend fun get( + endpoint: APIEndpoint, + requiresAuth: Boolean = true, + ): R + + /** + * POST request with raw data payload + * Equivalent to iOS: func postRaw(_ endpoint: APIEndpoint, _ payload: Data, requiresAuth: Bool) async throws -> Data + */ + suspend fun postRaw( + endpoint: APIEndpoint, + payload: ByteArray, + requiresAuth: Boolean = true, + ): ByteArray + + /** + * GET request with raw data response + * Equivalent to iOS: func getRaw(_ endpoint: APIEndpoint, requiresAuth: Bool) async throws -> Data + */ + suspend fun getRaw( + endpoint: APIEndpoint, + requiresAuth: Boolean = true, + ): ByteArray +} diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/data/network/models/APIEndpoint.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/data/network/models/APIEndpoint.kt new file mode 100644 index 000000000..3ba415404 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/data/network/models/APIEndpoint.kt @@ -0,0 +1,87 @@ +package com.runanywhere.sdk.data.network.models + +import com.runanywhere.sdk.utils.SDKConstants + +/** + * API endpoints - exactly matching iOS APIEndpoint.swift + */ +enum class APIEndpoint( + val url: String, +) { + // Authentication & Health (matches iOS exactly) + authenticate("/api/v1/auth/sdk/authenticate"), + refreshToken("/api/v1/auth/sdk/refresh"), + healthCheck("/v1/health"), // Fixed: iOS uses /v1/health without /api prefix + + // Device management - Production/Staging (matches iOS exactly) + deviceRegistration("/api/v1/devices/register"), + deviceInfo("/api/v1/device"), // Fixed: iOS uses /device not /devices/info + + // Device management - Development (matches iOS Supabase REST format) + devDeviceRegistration("/rest/v1/device_registrations"), // Fixed: Supabase REST format + + // Analytics endpoints (matches iOS exactly) + /** + * POST /api/v1/analytics + * Submit analytics events (production/staging) + * Matches iOS: APIEndpoint.analytics + */ + analytics("/api/v1/analytics"), + + /** + * POST /rest/v1/analytics_events + * Submit development analytics to Supabase + * Matches iOS: APIEndpoint.devAnalytics (Supabase REST format) + */ + devAnalytics("/rest/v1/analytics_events"), // Fixed: Supabase REST format + + /** + * POST /api/v1/sdk/telemetry + * Submit batch telemetry events + * Matches iOS: APIEndpoint.telemetry + */ + telemetry("/api/v1/sdk/telemetry"), + + // Model management (matches iOS exactly) + models("/api/v1/models"), + + // Core endpoints (matches iOS exactly) + generationHistory("/api/v1/history"), + userPreferences("/api/v1/preferences"), + + // KMP-specific (not in iOS, but useful for SDK configuration) + configuration("/api/v1/configuration"), + ; + + companion object { + /** + * Get the device registration endpoint based on environment + * Matches iOS: APIEndpoint.deviceRegistrationEndpoint(for:) + */ + fun deviceRegistrationEndpoint(environment: SDKConstants.Environment): APIEndpoint = + when (environment) { + SDKConstants.Environment.DEVELOPMENT -> devDeviceRegistration + SDKConstants.Environment.STAGING, + SDKConstants.Environment.PRODUCTION, + -> deviceRegistration + } + + /** + * Get the analytics endpoint based on environment + * Matches iOS: APIEndpoint.analyticsEndpoint(for:) + */ + fun analyticsEndpoint(environment: SDKConstants.Environment): APIEndpoint = + when (environment) { + SDKConstants.Environment.DEVELOPMENT -> devAnalytics + SDKConstants.Environment.STAGING, + SDKConstants.Environment.PRODUCTION, + -> analytics + } + + /** + * Get model assignments endpoint + * Matches iOS: APIEndpoint.modelAssignments() + */ + fun modelAssignments(): String = "/api/v1/model-assignments/for-sdk" + } +} diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/data/network/models/AuthModels.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/data/network/models/AuthModels.kt new file mode 100644 index 000000000..e0c638ae1 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/data/network/models/AuthModels.kt @@ -0,0 +1,130 @@ +package com.runanywhere.sdk.data.network.models + +import kotlinx.serialization.Serializable + +/** + * Authentication request model + * Matches iOS AuthenticationRequest structure + */ +@Serializable +data class AuthenticationRequest( + val apiKey: String, + val deviceId: String, + val platform: String, + val sdkVersion: String, + val platformVersion: String? = null, + val appIdentifier: String? = null, +) + +/** + * Authentication response model + * Matches iOS AuthenticationResponse structure + */ +@Serializable +data class AuthenticationResponse( + val accessToken: String, + val refreshToken: String? = null, + val expiresIn: Int, + val tokenType: String = "Bearer", + val deviceId: String, + val organizationId: String, + val userId: String? = null, // Can be null for org-level access +) + +/** + * Refresh token request model + * Matches iOS RefreshTokenRequest structure + */ +@Serializable +data class RefreshTokenRequest( + val refreshToken: String, + val deviceId: String? = null, +) + +/** + * Refresh token response model + * Matches iOS RefreshTokenResponse structure + */ +@Serializable +data class RefreshTokenResponse( + val accessToken: String, + val refreshToken: String? = null, + val expiresIn: Int, + val tokenType: String = "Bearer", + val deviceId: String? = null, + val organizationId: String? = null, + val userId: String? = null, +) + +/** + * Health check response model + * Matches iOS HealthCheckResponse structure + */ +@Serializable +data class HealthCheckResponse( + val status: String, + val version: String, + val timestamp: Long? = null, + val uptime: Long? = null, + val environment: String? = null, +) + +/** + * Device registration request model + * Matches iOS DeviceRegistrationRequest structure + */ +@Serializable +data class DeviceRegistrationRequest( + val deviceInfo: DeviceRegistrationInfo, +) + +/** + * Device registration response model + * Matches iOS DeviceRegistrationResponse structure exactly + * Location: Infrastructure/Device/Models/Network/DeviceRegistrationResponse.swift + */ +@Serializable +data class DeviceRegistrationResponse( + val success: Boolean, + @kotlinx.serialization.SerialName("device_id") + val deviceId: String, + @kotlinx.serialization.SerialName("registered_at") + val registeredAt: String, +) + +/** + * Comprehensive device registration info + * Matches iOS DeviceRegistrationInfo structure + */ +@Serializable +data class DeviceRegistrationInfo( + val architecture: String, + val availableMemory: Long, + val batteryLevel: Double, + val batteryState: String, + val chipName: String, + val coreCount: Int, + val deviceModel: String, + val deviceName: String, + val efficiencyCores: Int, + val formFactor: String, + val gpuFamily: String, + val hasNeuralEngine: Boolean, + val isLowPowerMode: Boolean, + val neuralEngineCores: Int, + val osVersion: String, + val performanceCores: Int, + val platform: String, + val totalMemory: Long, +) + +/** + * Error response model for API errors + */ +@Serializable +data class APIErrorResponse( + val error: String, + val message: String, + val code: Int? = null, + val details: Map? = null, // Changed from Any to String for serialization +) diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/data/network/models/DevAnalyticsModels.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/data/network/models/DevAnalyticsModels.kt new file mode 100644 index 000000000..a2d99e34e --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/data/network/models/DevAnalyticsModels.kt @@ -0,0 +1,108 @@ +package com.runanywhere.sdk.data.network.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Models for development analytics submission to Supabase + * + * Reference: sdk/runanywhere-swift/Sources/RunAnywhere/Data/Network/Models/DevAnalyticsModels.swift + */ + +// MARK: - Analytics Submission + +/** + * Request model for submitting generation analytics to Supabase + */ +@Serializable +data class DevAnalyticsSubmissionRequest( + @SerialName("generation_id") + val generationId: String, + @SerialName("device_id") + val deviceId: String, + @SerialName("model_id") + val modelId: String, + @SerialName("time_to_first_token_ms") + val timeToFirstTokenMs: Double? = null, + @SerialName("tokens_per_second") + val tokensPerSecond: Double, + @SerialName("total_generation_time_ms") + val totalGenerationTimeMs: Double, + @SerialName("input_tokens") + val inputTokens: Int, + @SerialName("output_tokens") + val outputTokens: Int, + @SerialName("success") + val success: Boolean, + @SerialName("execution_target") + val executionTarget: String, // "onDevice" or "cloud" + @SerialName("build_token") + val buildToken: String, // Non-nullable to match iOS + @SerialName("sdk_version") + val sdkVersion: String, + @SerialName("timestamp") + val timestamp: String, // ISO8601 format + @SerialName("host_app_identifier") + val hostAppIdentifier: String? = null, + @SerialName("host_app_name") + val hostAppName: String? = null, + @SerialName("host_app_version") + val hostAppVersion: String? = null, +) + +/** + * Response model from Supabase analytics submission + */ +@Serializable +data class DevAnalyticsSubmissionResponse( + @SerialName("success") + val success: Boolean, + @SerialName("analytics_id") + val analyticsId: String? = null, +) + +// MARK: - Device Registration + +/** + * Request model for registering device in development mode (Supabase) + */ +@Serializable +data class DevDeviceRegistrationRequest( + @SerialName("device_id") + val deviceId: String, + @SerialName("platform") + val platform: String, // "android", "ios", etc. + @SerialName("os_version") + val osVersion: String, + @SerialName("device_model") + val deviceModel: String, + @SerialName("sdk_version") + val sdkVersion: String, + @SerialName("build_token") + val buildToken: String? = null, + @SerialName("architecture") + val architecture: String? = null, + @SerialName("chip_name") + val chipName: String? = null, + @SerialName("total_memory") + val totalMemory: Long? = null, // In bytes, not GB! + @SerialName("has_neural_engine") + val hasNeuralEngine: Boolean? = null, + @SerialName("form_factor") + val formFactor: String? = null, + @SerialName("app_version") + val appVersion: String? = null, +) + +/** + * Response model from device registration (Supabase) + */ +@Serializable +data class DevDeviceRegistrationResponse( + @SerialName("success") + val success: Boolean, + @SerialName("device_id") + val deviceId: String? = null, + @SerialName("message") + val message: String? = null, +) diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/data/repositories/DeviceInfoRepository.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/data/repositories/DeviceInfoRepository.kt new file mode 100644 index 000000000..321ea8317 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/data/repositories/DeviceInfoRepository.kt @@ -0,0 +1,15 @@ +package com.runanywhere.sdk.data.repositories + +import com.runanywhere.sdk.data.models.DeviceInfoData + +/** + * Device Info Repository Interface + * Defines operations for device information persistence + */ +interface DeviceInfoRepository { + suspend fun getCurrentDeviceInfo(): DeviceInfoData? + + suspend fun saveDeviceInfo(deviceInfo: DeviceInfoData) + + suspend fun clearDeviceInfo() +} diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/features/stt/services/AudioCaptureManager.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/features/stt/services/AudioCaptureManager.kt new file mode 100644 index 000000000..9d0e34720 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/features/stt/services/AudioCaptureManager.kt @@ -0,0 +1,130 @@ +package com.runanywhere.sdk.features.stt + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow + +/** + * Audio capture data chunk + */ +data class AudioChunk( + /** Raw PCM audio data (16-bit, 16kHz, mono) */ + val data: ByteArray, + /** Timestamp when this chunk was captured (epoch millis) */ + val timestamp: Long, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is AudioChunk) return false + return data.contentEquals(other.data) && timestamp == other.timestamp + } + + override fun hashCode(): Int { + var result = data.contentHashCode() + result = 31 * result + timestamp.hashCode() + return result + } +} + +/** + * Audio capture error types + */ +sealed class AudioCaptureError : Exception() { + object PermissionDenied : AudioCaptureError() { + override val message = "Microphone permission denied" + } + + object FormatConversionFailed : AudioCaptureError() { + override val message = "Failed to convert audio format" + } + + object DeviceNotAvailable : AudioCaptureError() { + override val message = "Audio input device not available" + } + + data class InitializationFailed( + override val message: String, + ) : AudioCaptureError() + + data class RecordingFailed( + override val message: String, + ) : AudioCaptureError() +} + +/** + * Manages audio capture from microphone for STT services. + * Matches iOS AudioCaptureManager exactly. + * + * This is a shared utility that works with any STT backend (ONNX, WhisperKit, etc.). + * It captures audio at 16kHz mono Int16 format, which is the standard input format + * for speech recognition models like Whisper. + * + * Usage: + * ```kotlin + * val capture = AudioCaptureManager.create() + * val granted = capture.requestPermission() + * if (granted) { + * capture.startRecording().collect { audioChunk -> + * // Feed audioChunk to your STT service + * } + * } + * ``` + * + * Reference: sdk/runanywhere-swift/Sources/RunAnywhere/Features/STT/Services/AudioCaptureManager.swift + */ +interface AudioCaptureManager { + /** + * Whether recording is currently active + */ + val isRecording: StateFlow + + /** + * Current audio level (0.0 to 1.0) for visualization + */ + val audioLevel: StateFlow + + /** + * Target sample rate for audio capture (default: 16000 Hz for Whisper) + */ + val targetSampleRate: Int + get() = 16000 + + /** + * Request microphone permission + * @return true if permission was granted, false otherwise + */ + suspend fun requestPermission(): Boolean + + /** + * Check if microphone permission has been granted + */ + suspend fun hasPermission(): Boolean + + /** + * Start recording audio from microphone + * @return Flow of audio chunks that can be collected + * @throws AudioCaptureError if recording fails to start + */ + suspend fun startRecording(): Flow + + /** + * Stop recording audio + */ + fun stopRecording() + + /** + * Clean up resources + */ + suspend fun cleanup() + + companion object { + /** + * Create a platform-specific AudioCaptureManager instance + */ + fun create(): AudioCaptureManager = createAudioCaptureManager() + } +} + +/** + * Platform-specific factory for AudioCaptureManager + */ +expect fun createAudioCaptureManager(): AudioCaptureManager diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/features/tts/TtsAudioPlayback.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/features/tts/TtsAudioPlayback.kt new file mode 100644 index 000000000..07d46f676 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/features/tts/TtsAudioPlayback.kt @@ -0,0 +1,12 @@ +package com.runanywhere.sdk.features.tts + +/** + * Platform audio playback for TTS speak. + */ +internal expect object TtsAudioPlayback { + suspend fun play(audioData: ByteArray) + + fun stop() + + val isPlaying: Boolean +} diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/foundation/HostAppInfo.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/foundation/HostAppInfo.kt new file mode 100644 index 000000000..1fd02df8a --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/foundation/HostAppInfo.kt @@ -0,0 +1,15 @@ +package com.runanywhere.sdk.foundation + +/** + * Host application information container + */ +data class HostAppInfo( + val identifier: String?, + val name: String?, + val version: String?, +) + +/** + * Get host application information (platform-specific implementation) + */ +expect fun getHostAppInfo(): HostAppInfo diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/foundation/PlatformTime.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/foundation/PlatformTime.kt new file mode 100644 index 000000000..4b2d46dbd --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/foundation/PlatformTime.kt @@ -0,0 +1,11 @@ +package com.runanywhere.sdk.foundation + +/** + * Platform-specific time utilities + */ +expect fun currentTimeMillis(): Long + +/** + * Get current time as ISO8601 string + */ +expect fun currentTimeISO8601(): String diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/foundation/SDKLogger.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/foundation/SDKLogger.kt new file mode 100644 index 000000000..65fb7262d --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/foundation/SDKLogger.kt @@ -0,0 +1,765 @@ +package com.runanywhere.sdk.foundation + +import com.runanywhere.sdk.utils.SimpleInstant +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +// ============================================================================= +// LOG LEVEL +// ============================================================================= + +/** + * Log severity levels matching runanywhere-commons and Swift SDK. + * Ordered from least to most severe. + */ +enum class LogLevel( + val value: Int, +) : Comparable { + TRACE(0), + DEBUG(1), + INFO(2), + WARNING(3), + ERROR(4), + FAULT(5), + ; + + override fun toString(): String = + when (this) { + TRACE -> "trace" + DEBUG -> "debug" + INFO -> "info" + WARNING -> "warning" + ERROR -> "error" + FAULT -> "fault" + } + + companion object { + fun fromValue(value: Int): LogLevel = entries.find { it.value == value } ?: INFO + } +} + +// ============================================================================= +// LOG ENTRY +// ============================================================================= + +/** + * Represents a single log message with metadata. + * Matches Swift SDK LogEntry structure. + */ +data class LogEntry( + val timestamp: SimpleInstant = SimpleInstant.now(), + val level: LogLevel, + val category: String, + val message: String, + val metadata: Map? = null, + val file: String? = null, + val line: Int? = null, + val function: String? = null, + val errorCode: Int? = null, + val modelId: String? = null, + val framework: String? = null, +) + +// ============================================================================= +// LOG DESTINATION PROTOCOL +// ============================================================================= + +/** + * Protocol for log output destinations (Console, remote services, etc.). + * Matches Swift SDK LogDestination protocol. + */ +interface LogDestination { + /** Unique identifier for this destination */ + val identifier: String + + /** Whether this destination is available for writing */ + val isAvailable: Boolean + + /** Write a log entry to this destination */ + fun write(entry: LogEntry) + + /** Flush any buffered entries */ + fun flush() +} + +// ============================================================================= +// LOGGING CONFIGURATION +// ============================================================================= + +/** + * Configuration for the logging system. + * Matches Swift SDK LoggingConfiguration structure. + */ +data class LoggingConfiguration( + val enableLocalLogging: Boolean = true, + val minLogLevel: LogLevel = LogLevel.INFO, + val includeSourceLocation: Boolean = false, + val enableRemoteLogging: Boolean = false, + val enableSentryLogging: Boolean = false, + val includeDeviceMetadata: Boolean = false, +) { + companion object { + /** + * Development environment preset. + * Enables debug logging and detailed source location. + */ + val development = + LoggingConfiguration( + enableLocalLogging = true, + minLogLevel = LogLevel.DEBUG, + includeSourceLocation = true, + enableRemoteLogging = false, + enableSentryLogging = false, + includeDeviceMetadata = true, + ) + + /** + * Staging environment preset. + * Info level with source location for debugging. + */ + val staging = + LoggingConfiguration( + enableLocalLogging = true, + minLogLevel = LogLevel.INFO, + includeSourceLocation = true, + enableRemoteLogging = false, + enableSentryLogging = true, + includeDeviceMetadata = true, + ) + + /** + * Production environment preset. + * Warning level and above, Sentry enabled for error tracking. + */ + val production = + LoggingConfiguration( + enableLocalLogging = false, + minLogLevel = LogLevel.WARNING, + includeSourceLocation = false, + enableRemoteLogging = false, + enableSentryLogging = true, + includeDeviceMetadata = true, + ) + + /** + * Get configuration for a specific environment. + */ + fun forEnvironment(environment: SDKEnvironment): LoggingConfiguration = + when (environment) { + SDKEnvironment.DEVELOPMENT -> development + SDKEnvironment.STAGING -> staging + SDKEnvironment.PRODUCTION -> production + } + } +} + +/** + * SDK Environment for configuration selection. + */ +enum class SDKEnvironment { + DEVELOPMENT, + STAGING, + PRODUCTION, +} + +// ============================================================================= +// LOGGING (CENTRAL SERVICE) +// ============================================================================= + +/** + * Central logging service that routes logs to multiple destinations. + * Thread-safe using Mutex for state management. + * Matches Swift SDK Logging class. + */ +object Logging { + private val mutex = Mutex() + + // Thread-safe state + private var _configuration: LoggingConfiguration = LoggingConfiguration.development + private val _destinations: MutableList = mutableListOf() + + // Bridge callback for forwarding logs to runanywhere-commons + private var commonsLogBridge: ((LogEntry) -> Unit)? = null + + /** + * Current logging configuration. + */ + var configuration: LoggingConfiguration + get() = _configuration + set(value) { + _configuration = value + } + + /** + * List of registered log destinations. + */ + val destinations: List + get() = _destinations.toList() + + // ============================================================================= + // CONFIGURATION + // ============================================================================= + + /** + * Configure the logging system. + */ + fun configure(config: LoggingConfiguration) { + _configuration = config + } + + /** + * Apply configuration based on SDK environment. + */ + fun applyEnvironmentConfiguration(environment: SDKEnvironment) { + configure(LoggingConfiguration.forEnvironment(environment)) + } + + /** + * Set whether local logging is enabled. + */ + fun setLocalLoggingEnabled(enabled: Boolean) { + _configuration = _configuration.copy(enableLocalLogging = enabled) + } + + /** + * Set the minimum log level. + */ + fun setMinLogLevel(level: LogLevel) { + _configuration = _configuration.copy(minLogLevel = level) + } + + /** + * Set whether to include source location in logs. + */ + fun setIncludeSourceLocation(include: Boolean) { + _configuration = _configuration.copy(includeSourceLocation = include) + } + + /** + * Set whether to include device metadata in logs. + */ + fun setIncludeDeviceMetadata(include: Boolean) { + _configuration = _configuration.copy(includeDeviceMetadata = include) + } + + /** + * Set whether Sentry logging is enabled. + * When enabled, warning+ logs are sent to Sentry for error tracking. + * + * Note: Call setupSentry() after enabling to initialize the Sentry SDK. + */ + fun setSentryLoggingEnabled(enabled: Boolean) { + val oldConfig = _configuration + _configuration = _configuration.copy(enableSentryLogging = enabled) + + // Handle Sentry state changes via the platform-specific hook + if (enabled && !oldConfig.enableSentryLogging) { + sentrySetupHook?.invoke() + } else if (!enabled && oldConfig.enableSentryLogging) { + sentryTeardownHook?.invoke() + } + } + + /** + * Hook for platform-specific Sentry setup. + * Set by the platform layer (jvmAndroidMain) during initialization. + */ + var sentrySetupHook: (() -> Unit)? = null + internal set + + /** + * Hook for platform-specific Sentry teardown. + * Set by the platform layer (jvmAndroidMain) during shutdown. + */ + var sentryTeardownHook: (() -> Unit)? = null + internal set + + /** + * Set the bridge callback for forwarding logs to runanywhere-commons. + * This enables integration with the C/C++ logging system. + */ + fun setCommonsLogBridge(bridge: ((LogEntry) -> Unit)?) { + commonsLogBridge = bridge + } + + // ============================================================================= + // CORE LOGGING + // ============================================================================= + + /** + * Log a message with optional metadata. + */ + fun log( + level: LogLevel, + category: String, + message: String, + metadata: Map? = null, + file: String? = null, + line: Int? = null, + function: String? = null, + errorCode: Int? = null, + modelId: String? = null, + framework: String? = null, + ) { + val config = _configuration + + // Check if level meets minimum threshold + if (level < config.minLogLevel) return + + // Check if any logging is enabled + if (!config.enableLocalLogging && !config.enableRemoteLogging && _destinations.isEmpty()) return + + // Create log entry + val entry = + LogEntry( + level = level, + category = category, + message = message, + metadata = sanitizeMetadata(metadata), + file = if (config.includeSourceLocation) file else null, + line = if (config.includeSourceLocation) line else null, + function = if (config.includeSourceLocation) function else null, + errorCode = errorCode, + modelId = modelId, + framework = framework, + ) + + // Write to console if local logging enabled + if (config.enableLocalLogging) { + printToConsole(entry) + } + + // Forward to runanywhere-commons bridge if set + commonsLogBridge?.invoke(entry) + + // Write to all registered destinations + for (destination in _destinations) { + if (destination.isAvailable) { + destination.write(entry) + } + } + } + + // ============================================================================= + // DESTINATION MANAGEMENT + // ============================================================================= + + /** + * Add a log destination. + */ + suspend fun addDestination(destination: LogDestination) { + mutex.withLock { + if (_destinations.none { it.identifier == destination.identifier }) { + _destinations.add(destination) + } + } + } + + /** + * Add a log destination (non-suspending version). + */ + fun addDestinationSync(destination: LogDestination) { + if (_destinations.none { it.identifier == destination.identifier }) { + _destinations.add(destination) + } + } + + /** + * Remove a log destination. + */ + suspend fun removeDestination(destination: LogDestination) { + mutex.withLock { + _destinations.removeAll { it.identifier == destination.identifier } + } + } + + /** + * Remove a log destination (non-suspending version). + */ + fun removeDestinationSync(destination: LogDestination) { + _destinations.removeAll { it.identifier == destination.identifier } + } + + /** + * Flush all destinations. + */ + fun flush() { + for (destination in _destinations) { + destination.flush() + } + } + + // ============================================================================= + // PRIVATE HELPERS + // ============================================================================= + + private fun printToConsole(entry: LogEntry) { + val levelIndicator = + when (entry.level) { + LogLevel.TRACE -> "[TRACE]" + LogLevel.DEBUG -> "[DEBUG]" + LogLevel.INFO -> "[INFO]" + LogLevel.WARNING -> "[WARN]" + LogLevel.ERROR -> "[ERROR]" + LogLevel.FAULT -> "[FAULT]" + } + + val output = + buildString { + append(levelIndicator) + append(" [") + append(entry.category) + append("] ") + append(entry.message) + + // Add metadata if present + entry.metadata?.takeIf { it.isNotEmpty() }?.let { meta -> + append(" | ") + append(meta.entries.joinToString(", ") { "${it.key}=${it.value}" }) + } + + // Add source location if present + if (entry.file != null || entry.function != null) { + append(" @ ") + entry.file?.let { append(it) } + entry.line?.let { append(":$it") } + entry.function?.let { append(" in $it") } + } + + // Add error code if present + entry.errorCode?.let { append(" [code=$it]") } + + // Add model info if present + if (entry.modelId != null || entry.framework != null) { + append(" [") + entry.modelId?.let { append("model=$it") } + if (entry.modelId != null && entry.framework != null) append(", ") + entry.framework?.let { append("framework=$it") } + append("]") + } + } + + println(output) + } + + // ============================================================================= + // METADATA SANITIZATION + // ============================================================================= + + private val sensitivePatterns = listOf("key", "secret", "password", "token", "auth", "credential") + + @Suppress("UNCHECKED_CAST") + private fun sanitizeMetadata(metadata: Map?): Map? { + if (metadata == null) return null + + return metadata.mapValues { (key, value) -> + val lowercasedKey = key.lowercase() + when { + sensitivePatterns.any { lowercasedKey.contains(it) } -> "[REDACTED]" + value is Map<*, *> -> sanitizeMetadata(value as? Map)?.toString() ?: "{}" + else -> value?.toString() ?: "null" + } + } + } +} + +// ============================================================================= +// PLATFORM LOGGER INTERFACE +// ============================================================================= + +/** + * Platform-specific logger interface. + * Implementations provided in androidMain and jvmMain. + */ +expect class PlatformLogger( + tag: String, +) { + fun trace(message: String) + + fun debug(message: String) + + fun info(message: String) + + fun warning(message: String) + + fun error(message: String, throwable: Throwable? = null) + + fun fault(message: String, throwable: Throwable? = null) +} + +// ============================================================================= +// SDK LOGGER (CONVENIENCE WRAPPER) +// ============================================================================= + +/** + * Simple logger for SDK components with category-based filtering. + * Matches Swift SDK SDKLogger struct. + */ +class SDKLogger( + val category: String = "SDK", +) { + // ============================================================================= + // LOGGING METHODS + // ============================================================================= + + /** + * Log a trace-level message. + */ + fun trace( + message: String, + metadata: Map? = null, + ) { + Logging.log( + level = LogLevel.TRACE, + category = category, + message = message, + metadata = metadata, + ) + } + + /** + * Log a debug-level message. + */ + fun debug( + message: String, + metadata: Map? = null, + ) { + Logging.log( + level = LogLevel.DEBUG, + category = category, + message = message, + metadata = metadata, + ) + } + + /** + * Log an info-level message. + */ + fun info( + message: String, + metadata: Map? = null, + ) { + Logging.log( + level = LogLevel.INFO, + category = category, + message = message, + metadata = metadata, + ) + } + + /** + * Log a warning-level message. + */ + fun warning( + message: String, + metadata: Map? = null, + ) { + Logging.log( + level = LogLevel.WARNING, + category = category, + message = message, + metadata = metadata, + ) + } + + /** + * Alias for warning to match common conventions. + */ + fun warn( + message: String, + metadata: Map? = null, + ) = warning(message, metadata) + + /** + * Log an error-level message. + */ + fun error( + message: String, + metadata: Map? = null, + throwable: Throwable? = null, + ) { + val errorMetadata = + if (throwable != null) { + (metadata ?: emptyMap()) + + mapOf( + "exception_type" to throwable::class.simpleName, + "exception_message" to throwable.message, + ) + } else { + metadata + } + + Logging.log( + level = LogLevel.ERROR, + category = category, + message = message, + metadata = errorMetadata, + ) + } + + /** + * Log a fault-level message (critical system errors). + */ + fun fault( + message: String, + metadata: Map? = null, + throwable: Throwable? = null, + ) { + val faultMetadata = + if (throwable != null) { + (metadata ?: emptyMap()) + + mapOf( + "exception_type" to throwable::class.simpleName, + "exception_message" to throwable.message, + ) + } else { + metadata + } + + Logging.log( + level = LogLevel.FAULT, + category = category, + message = message, + metadata = faultMetadata, + ) + } + + // ============================================================================= + // ERROR LOGGING WITH CONTEXT + // ============================================================================= + + /** + * Log an error with source location context. + */ + fun logError( + error: Throwable, + additionalInfo: String? = null, + file: String? = null, + line: Int? = null, + function: String? = null, + ) { + val errorMessage = + buildString { + append(error.message ?: error::class.simpleName) + if (file != null || line != null || function != null) { + append(" at ") + file?.let { append(it) } + line?.let { append(":$it") } + function?.let { append(" in $it") } + } + additionalInfo?.let { append(" | Context: $it") } + } + + val metadata = + buildMap { + file?.let { put("source_file", it) } + line?.let { put("source_line", it) } + function?.let { put("source_function", it) } + put("exception_type", error::class.simpleName) + put("exception_message", error.message) + } + + Logging.log( + level = LogLevel.ERROR, + category = category, + message = errorMessage, + metadata = metadata, + file = file, + line = line, + function = function, + ) + } + + /** + * Log with model context (for model-related operations). + */ + fun logModelInfo( + message: String, + modelId: String, + framework: String? = null, + metadata: Map? = null, + ) { + Logging.log( + level = LogLevel.INFO, + category = category, + message = message, + metadata = metadata, + modelId = modelId, + framework = framework, + ) + } + + /** + * Log model error with context. + */ + fun logModelError( + message: String, + modelId: String, + framework: String? = null, + errorCode: Int? = null, + metadata: Map? = null, + ) { + Logging.log( + level = LogLevel.ERROR, + category = category, + message = message, + metadata = metadata, + modelId = modelId, + framework = framework, + errorCode = errorCode, + ) + } + + // ============================================================================= + // COMPANION OBJECT - CONVENIENCE LOGGERS + // ============================================================================= + + companion object { + /** + * Set the global minimum log level. + */ + fun setLevel(level: LogLevel) { + Logging.setMinLogLevel(level) + } + + // ============================================================================= + // CONVENIENCE LOGGERS (matching Swift SDK and runanywhere-commons) + // ============================================================================= + + /** Shared logger for general SDK usage */ + val shared = SDKLogger("RunAnywhere") + + /** Logger for LLM operations */ + val llm = SDKLogger("LLM") + + /** Logger for STT (Speech-to-Text) operations */ + val stt = SDKLogger("STT") + + /** Logger for TTS (Text-to-Speech) operations */ + val tts = SDKLogger("TTS") + + /** Logger for VAD (Voice Activity Detection) operations */ + val vad = SDKLogger("VAD") + + /** Logger for download operations */ + val download = SDKLogger("Download") + + /** Logger for model management operations */ + val models = SDKLogger("Models") + + /** Logger for core SDK operations */ + val core = SDKLogger("Core") + + /** Logger for ONNX runtime operations */ + val onnx = SDKLogger("ONNX") + + /** Logger for LlamaCpp operations */ + val llamacpp = SDKLogger("LlamaCpp") + + /** Logger for VoiceAgent operations */ + val voiceAgent = SDKLogger("VoiceAgent") + + /** Logger for network operations */ + val network = SDKLogger("Network") + } +} diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/foundation/constants/BuildToken.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/foundation/constants/BuildToken.kt new file mode 100644 index 000000000..5f1c9264b --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/foundation/constants/BuildToken.kt @@ -0,0 +1,33 @@ +package com.runanywhere.sdk.foundation.constants + +/** + * Build token for development mode device registration + * + * ⚠️ THIS FILE IS AUTO-GENERATED DURING RELEASES + * ⚠️ DO NOT MANUALLY EDIT THIS FILE + * ⚠️ THIS FILE IS IN .gitignore AND SHOULD NOT BE COMMITTED + * + * Security Model: + * - This file is generated during release scripts + * - Contains a cohort build token (format: bt__) + * - Main branch: This file has a placeholder token + * - Release tags: This file has a real token (for Maven distribution) + * - Token is used ONLY when SDK is in DEVELOPMENT mode + * - Backend validates token and can revoke it if abused + * + * Token Properties: + * - Rotatable: Each release gets a new token + * - Revocable: Backend can mark token as inactive + * - Cohort-scoped: Not a secret, extractable but secured via backend validation + * - Rate-limited: Backend enforces 100 req/min per device + */ +object BuildToken { + /** + * Development mode build token + * Format: "bt__" + * + * This is a PLACEHOLDER token for development. + * Real tokens are injected during SDK releases via release scripts + */ + const val token = "bt_placeholder_for_development" +} diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/foundation/device/DeviceInfoService.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/foundation/device/DeviceInfoService.kt new file mode 100644 index 000000000..ddf0d290a --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/foundation/device/DeviceInfoService.kt @@ -0,0 +1,48 @@ +package com.runanywhere.sdk.foundation.device + +/** + * Service for collecting device information + * + * Platform-specific implementations provide actual device details + * Used for device registration and analytics + */ +expect class DeviceInfoService() { + /** + * Get operating system name (e.g., "Android", "Windows", "Linux") + */ + fun getOSName(): String + + /** + * Get operating system version (e.g., "13", "10.0.19044") + */ + fun getOSVersion(): String + + /** + * Get device model name (e.g., "Pixel 7", "Unknown") + */ + fun getDeviceModel(): String + + /** + * Get chip/CPU name (e.g., "ARM64", "x86_64") + * Returns null if unable to determine + */ + fun getChipName(): String? + + /** + * Get total memory in GB + * Returns null if unable to determine + */ + fun getTotalMemoryGB(): Double? + + /** + * Get total memory in bytes + * Returns null if unable to determine + */ + fun getTotalMemoryBytes(): Long? + + /** + * Get device architecture (e.g., "ARM64", "x86_64") + * Returns null if unable to determine + */ + fun getArchitecture(): String? +} diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/foundation/errors/CommonsErrorMapping.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/foundation/errors/CommonsErrorMapping.kt new file mode 100644 index 000000000..7460c1123 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/foundation/errors/CommonsErrorMapping.kt @@ -0,0 +1,430 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * Mapping utilities for C++ runanywhere-commons error codes to Kotlin SDKError. + * Provides type-safe conversion between C++ raw error codes and Kotlin error types. + */ + +package com.runanywhere.sdk.foundation.errors + +/** + * C++ runanywhere-commons error code constants. + * + * These constants match the RAC_* error codes from the runanywhere-commons C API. + * Used for mapping between C++ return values and Kotlin error types. + */ +object CommonsErrorCode { + /** Operation completed successfully */ + const val RAC_SUCCESS = 0 + + /** Generic error */ + const val RAC_ERROR = -1 + + /** Invalid argument provided */ + const val RAC_ERROR_INVALID_ARGUMENT = -2 + + /** Library not initialized */ + const val RAC_ERROR_NOT_INITIALIZED = -3 + + /** Already initialized */ + const val RAC_ERROR_ALREADY_INITIALIZED = -4 + + /** Out of memory */ + const val RAC_ERROR_OUT_OF_MEMORY = -5 + + /** File not found */ + const val RAC_ERROR_FILE_NOT_FOUND = -6 + + /** Operation timed out */ + const val RAC_ERROR_TIMEOUT = -7 + + /** Operation was cancelled */ + const val RAC_ERROR_CANCELLED = -8 + + /** Network error */ + const val RAC_ERROR_NETWORK = -9 + + /** Model not loaded */ + const val RAC_ERROR_MODEL_NOT_LOADED = -10 + + /** Model load failed */ + const val RAC_ERROR_MODEL_LOAD_FAILED = -11 + + /** Platform adapter not set */ + const val RAC_ERROR_PLATFORM_ADAPTER_NOT_SET = -12 + + /** Invalid handle */ + const val RAC_ERROR_INVALID_HANDLE = -13 + + // Component-specific errors + /** STT transcription failed */ + const val RAC_ERROR_STT_TRANSCRIPTION_FAILED = -100 + + /** TTS synthesis failed */ + const val RAC_ERROR_TTS_SYNTHESIS_FAILED = -101 + + /** LLM generation failed */ + const val RAC_ERROR_LLM_GENERATION_FAILED = -102 + + /** VAD detection failed */ + const val RAC_ERROR_VAD_DETECTION_FAILED = -103 + + /** Voice agent error */ + const val RAC_ERROR_VOICE_AGENT = -104 + + // Download errors + /** Download failed */ + const val RAC_ERROR_DOWNLOAD_FAILED = -200 + + /** Download cancelled */ + const val RAC_ERROR_DOWNLOAD_CANCELLED = -201 + + /** Insufficient storage */ + const val RAC_ERROR_INSUFFICIENT_STORAGE = -202 + + // Authentication errors + /** Authentication failed */ + const val RAC_ERROR_AUTHENTICATION_FAILED = -300 + + /** Invalid API key */ + const val RAC_ERROR_INVALID_API_KEY = -301 + + /** Unauthorized */ + const val RAC_ERROR_UNAUTHORIZED = -302 + + /** + * Check if an error code indicates success. + * + * @param code The C++ error code + * @return true if the code indicates success (>= 0) + */ + fun isSuccess(code: Int): Boolean = code >= 0 + + /** + * Check if an error code indicates failure. + * + * @param code The C++ error code + * @return true if the code indicates failure (< 0) + */ + fun isError(code: Int): Boolean = code < 0 +} + +/** + * Mapping utilities for converting C++ error codes to Kotlin SDKError instances. + * + * This object provides functions for: + * - Converting raw C++ error codes to SDKError + * - Wrapping C++ function results in Kotlin Result types + * - Providing contextual error messages for C++ operations + * + * Usage: + * ```kotlin + * // Convert a C++ error code to SDKError + * val error = CommonsErrorMapping.toSDKError(errorCode, "Failed to load model") + * + * // Check and throw if error + * CommonsErrorMapping.checkSuccess(result, "rac_init") + * + * // Wrap in Result + * val result = CommonsErrorMapping.toResult(nativeResult, operation = "model loading") + * ``` + */ +object CommonsErrorMapping { + /** + * Convert a C++ error code to the corresponding Kotlin ErrorCode enum. + * + * @param rawValue The C++ error code (RAC_* constant) + * @return The corresponding Kotlin ErrorCode enum value + */ + fun toErrorCode(rawValue: Int): ErrorCode { + return when (rawValue) { + CommonsErrorCode.RAC_SUCCESS -> ErrorCode.SUCCESS + CommonsErrorCode.RAC_ERROR -> ErrorCode.UNKNOWN + CommonsErrorCode.RAC_ERROR_INVALID_ARGUMENT -> ErrorCode.INVALID_ARGUMENT + CommonsErrorCode.RAC_ERROR_NOT_INITIALIZED -> ErrorCode.NOT_INITIALIZED + CommonsErrorCode.RAC_ERROR_ALREADY_INITIALIZED -> ErrorCode.ALREADY_INITIALIZED + CommonsErrorCode.RAC_ERROR_OUT_OF_MEMORY -> ErrorCode.OUT_OF_MEMORY + CommonsErrorCode.RAC_ERROR_FILE_NOT_FOUND -> ErrorCode.FILE_NOT_FOUND + CommonsErrorCode.RAC_ERROR_TIMEOUT -> ErrorCode.TIMEOUT + CommonsErrorCode.RAC_ERROR_CANCELLED -> ErrorCode.CANCELLED + CommonsErrorCode.RAC_ERROR_NETWORK -> ErrorCode.NETWORK_ERROR + CommonsErrorCode.RAC_ERROR_MODEL_NOT_LOADED -> ErrorCode.MODEL_NOT_LOADED + CommonsErrorCode.RAC_ERROR_MODEL_LOAD_FAILED -> ErrorCode.MODEL_LOAD_FAILED + CommonsErrorCode.RAC_ERROR_PLATFORM_ADAPTER_NOT_SET -> ErrorCode.PLATFORM_ADAPTER_NOT_SET + CommonsErrorCode.RAC_ERROR_INVALID_HANDLE -> ErrorCode.INVALID_HANDLE + CommonsErrorCode.RAC_ERROR_STT_TRANSCRIPTION_FAILED -> ErrorCode.STT_TRANSCRIPTION_FAILED + CommonsErrorCode.RAC_ERROR_TTS_SYNTHESIS_FAILED -> ErrorCode.TTS_SYNTHESIS_FAILED + CommonsErrorCode.RAC_ERROR_LLM_GENERATION_FAILED -> ErrorCode.LLM_GENERATION_FAILED + CommonsErrorCode.RAC_ERROR_VAD_DETECTION_FAILED -> ErrorCode.VAD_DETECTION_FAILED + CommonsErrorCode.RAC_ERROR_VOICE_AGENT -> ErrorCode.VOICE_AGENT_ERROR + CommonsErrorCode.RAC_ERROR_DOWNLOAD_FAILED -> ErrorCode.DOWNLOAD_FAILED + CommonsErrorCode.RAC_ERROR_DOWNLOAD_CANCELLED -> ErrorCode.DOWNLOAD_CANCELLED + CommonsErrorCode.RAC_ERROR_INSUFFICIENT_STORAGE -> ErrorCode.INSUFFICIENT_STORAGE + CommonsErrorCode.RAC_ERROR_AUTHENTICATION_FAILED -> ErrorCode.AUTHENTICATION_FAILED + CommonsErrorCode.RAC_ERROR_INVALID_API_KEY -> ErrorCode.INVALID_API_KEY + CommonsErrorCode.RAC_ERROR_UNAUTHORIZED -> ErrorCode.UNAUTHORIZED + else -> ErrorCode.fromRawValue(rawValue) + } + } + + /** + * Convert a C++ error code to an SDKError instance. + * + * @param rawValue The C++ error code (RAC_* constant) + * @param message Optional custom error message (uses default if not provided) + * @param cause Optional underlying throwable cause + * @return An SDKError representing the C++ error + */ + fun toSDKError( + rawValue: Int, + message: String? = null, + cause: Throwable? = null, + ): SDKError { + val errorCode = toErrorCode(rawValue) + val errorCategory = ErrorCategory.fromErrorCode(errorCode) + val errorMessage = message ?: errorCode.description + return SDKError( + code = errorCode, + category = errorCategory, + message = errorMessage, + cause = cause, + ) + } + + /** + * Convert a C++ error code to an SDKError with operation context. + * + * @param rawValue The C++ error code (RAC_* constant) + * @param operation The name of the operation that failed + * @param details Optional additional details about the failure + * @param cause Optional underlying throwable cause + * @return An SDKError with contextual message about the failed operation + */ + fun toSDKErrorWithContext( + rawValue: Int, + operation: String, + details: String? = null, + cause: Throwable? = null, + ): SDKError { + val errorCode = toErrorCode(rawValue) + val errorCategory = ErrorCategory.fromErrorCode(errorCode) + + val message = + buildString { + append("$operation failed") + if (details != null) { + append(": $details") + } + append(" (error code: $rawValue - ${errorCode.description})") + } + + return SDKError( + code = errorCode, + category = errorCategory, + message = message, + cause = cause, + ) + } + + /** + * Check if a C++ return code indicates success. + * + * @param rawValue The C++ return code + * @return true if the code indicates success + */ + fun isSuccess(rawValue: Int): Boolean = CommonsErrorCode.isSuccess(rawValue) + + /** + * Check if a C++ return code indicates an error. + * + * @param rawValue The C++ return code + * @return true if the code indicates an error + */ + fun isError(rawValue: Int): Boolean = CommonsErrorCode.isError(rawValue) + + /** + * Check a C++ return code and throw an SDKError if it indicates failure. + * + * @param rawValue The C++ return code to check + * @param operation The name of the operation (for error message) + * @throws SDKError if the return code indicates failure + */ + fun checkSuccess(rawValue: Int, operation: String) { + if (isError(rawValue)) { + throw toSDKErrorWithContext(rawValue, operation) + } + } + + /** + * Check a C++ return code and throw an SDKError if it indicates failure. + * + * @param rawValue The C++ return code to check + * @param operation The name of the operation (for error message) + * @param details Additional details to include in the error message + * @throws SDKError if the return code indicates failure + */ + fun checkSuccess(rawValue: Int, operation: String, details: String) { + if (isError(rawValue)) { + throw toSDKErrorWithContext(rawValue, operation, details) + } + } + + /** + * Convert a C++ function result to a Kotlin Result. + * + * For functions that only return an error code (no payload), this wraps + * the result in a Result. + * + * @param rawValue The C++ return code + * @param operation The name of the operation (for error message) + * @return Result - success if code is 0 or positive, failure otherwise + */ + fun toResult(rawValue: Int, operation: String): Result { + return if (isSuccess(rawValue)) { + Result.success(Unit) + } else { + Result.failure(toSDKErrorWithContext(rawValue, operation)) + } + } + + /** + * Convert a C++ function result to a Kotlin Result with a value. + * + * For functions that return a value on success, this wraps the result + * appropriately. The value is only used if the error code indicates success. + * + * @param rawValue The C++ return code + * @param value The value to return on success + * @param operation The name of the operation (for error message) + * @return Result - success with value if code is 0 or positive, failure otherwise + */ + fun toResult(rawValue: Int, value: T, operation: String): Result { + return if (isSuccess(rawValue)) { + Result.success(value) + } else { + Result.failure(toSDKErrorWithContext(rawValue, operation)) + } + } + + /** + * Convert a nullable value with error code to a Result. + * + * This is useful when the C++ function returns null on error along with + * an error code out-parameter. + * + * @param value The nullable value returned by the C++ function + * @param errorCode The error code (used if value is null) + * @param operation The name of the operation (for error message) + * @return Result - success with non-null value, or failure + */ + fun toResultFromNullable( + value: T?, + errorCode: Int, + operation: String, + ): Result { + return if (value != null) { + Result.success(value) + } else { + val code = if (isError(errorCode)) errorCode else CommonsErrorCode.RAC_ERROR + Result.failure(toSDKErrorWithContext(code, operation)) + } + } + + /** + * Get a descriptive error message for a C++ error code. + * + * @param rawValue The C++ error code + * @return A human-readable description of the error + */ + fun getErrorDescription(rawValue: Int): String { + val errorCode = toErrorCode(rawValue) + return errorCode.description + } + + /** + * Get the error category for a C++ error code. + * + * @param rawValue The C++ error code + * @return The error category + */ + fun getErrorCategory(rawValue: Int): ErrorCategory { + val errorCode = toErrorCode(rawValue) + return ErrorCategory.fromErrorCode(errorCode) + } +} + +// ============================================================================ +// EXTENSION FUNCTIONS FOR CONVENIENT ERROR HANDLING +// ============================================================================ + +/** + * Convert a C++ raw error code to an SDKError. + * + * Extension function for convenient error conversion. + * + * @param message Optional custom error message + * @param cause Optional underlying throwable cause + * @return An SDKError representing this error code + */ +fun Int.toSDKError(message: String? = null, cause: Throwable? = null): SDKError { + return CommonsErrorMapping.toSDKError(this, message, cause) +} + +/** + * Convert a C++ raw error code to a Kotlin ErrorCode enum. + * + * @return The corresponding Kotlin ErrorCode enum value + */ +fun Int.toErrorCode(): ErrorCode { + return CommonsErrorMapping.toErrorCode(this) +} + +/** + * Check if this C++ raw error code indicates success. + * + * @return true if this code indicates success (>= 0) + */ +fun Int.isCommonsSuccess(): Boolean { + return CommonsErrorMapping.isSuccess(this) +} + +/** + * Check if this C++ raw error code indicates failure. + * + * @return true if this code indicates failure (< 0) + */ +fun Int.isCommonsError(): Boolean { + return CommonsErrorMapping.isError(this) +} + +/** + * Throw an SDKError if this C++ raw error code indicates failure. + * + * @param operation The name of the operation (for error message) + * @throws SDKError if this code indicates failure + */ +fun Int.throwIfError(operation: String) { + CommonsErrorMapping.checkSuccess(this, operation) +} + +/** + * Convert this C++ raw error code to a Kotlin Result. + * + * @param operation The name of the operation (for error message) + * @return Result - success if code >= 0, failure otherwise + */ +fun Int.toCommonsResult(operation: String): Result { + return CommonsErrorMapping.toResult(this, operation) +} + +/** + * Convert this C++ raw error code to a Kotlin Result with a value. + * + * @param value The value to return on success + * @param operation The name of the operation (for error message) + * @return Result - success with value if code >= 0, failure otherwise + */ +fun Int.toCommonsResult(value: T, operation: String): Result { + return CommonsErrorMapping.toResult(this, value, operation) +} diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/foundation/errors/ErrorCategory.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/foundation/errors/ErrorCategory.kt new file mode 100644 index 000000000..f8f1c672f --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/foundation/errors/ErrorCategory.kt @@ -0,0 +1,228 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * Error category enum matching iOS ErrorCategory for cross-platform consistency. + */ + +package com.runanywhere.sdk.foundation.errors + +/** + * Categories for SDK errors. + * + * This enum matches the iOS SDK's ErrorCategory for cross-platform consistency. + * Error categories provide a high-level grouping of errors, making it easier + * to handle related errors uniformly. + * + * @property description A human-readable description of the error category. + */ +enum class ErrorCategory { + // ======================================================================== + // GENERAL CATEGORIES + // ======================================================================== + + /** + * General or unclassified errors. + */ + GENERAL, + + /** + * Configuration-related errors. + */ + CONFIGURATION, + + /** + * Initialization-related errors. + */ + INITIALIZATION, + + // ======================================================================== + // RESOURCE CATEGORIES + // ======================================================================== + + /** + * File and resource access errors. + */ + FILE_RESOURCE, + + /** + * Memory and resource allocation errors. + */ + MEMORY, + + /** + * Storage-related errors (insufficient space, etc.). + */ + STORAGE, + + // ======================================================================== + // OPERATION CATEGORIES + // ======================================================================== + + /** + * Operation lifecycle errors (timeout, cancelled, etc.). + */ + OPERATION, + + // ======================================================================== + // NETWORK CATEGORIES + // ======================================================================== + + /** + * Network-related errors. + */ + NETWORK, + + // ======================================================================== + // MODEL CATEGORIES + // ======================================================================== + + /** + * Model loading and management errors. + */ + MODEL, + + // ======================================================================== + // PLATFORM CATEGORIES + // ======================================================================== + + /** + * Platform adapter and integration errors. + */ + PLATFORM, + + // ======================================================================== + // AI COMPONENT CATEGORIES + // ======================================================================== + + /** + * Large Language Model (LLM) errors. + */ + LLM, + + /** + * Speech-to-Text (STT) errors. + */ + STT, + + /** + * Text-to-Speech (TTS) errors. + */ + TTS, + + /** + * Voice Activity Detection (VAD) errors. + */ + VAD, + + /** + * Voice Agent pipeline errors. + */ + VOICE_AGENT, + + // ======================================================================== + // DOWNLOAD CATEGORIES + // ======================================================================== + + /** + * Download-related errors. + */ + DOWNLOAD, + + // ======================================================================== + // AUTHENTICATION CATEGORIES + // ======================================================================== + + /** + * Authentication and authorization errors. + */ + AUTHENTICATION, + + ; + + /** + * A human-readable description of the error category. + */ + val description: String + get() = + when (this) { + GENERAL -> "General error" + CONFIGURATION -> "Configuration error" + INITIALIZATION -> "Initialization error" + FILE_RESOURCE -> "File or resource error" + MEMORY -> "Memory allocation error" + STORAGE -> "Storage error" + OPERATION -> "Operation lifecycle error" + NETWORK -> "Network error" + MODEL -> "Model error" + PLATFORM -> "Platform integration error" + LLM -> "Language model error" + STT -> "Speech-to-text error" + TTS -> "Text-to-speech error" + VAD -> "Voice activity detection error" + VOICE_AGENT -> "Voice agent error" + DOWNLOAD -> "Download error" + AUTHENTICATION -> "Authentication error" + } + + companion object { + /** + * Get the error category for a given error code. + * + * @param errorCode The error code to categorize + * @return The corresponding error category + */ + fun fromErrorCode(errorCode: ErrorCode): ErrorCategory { + return when (errorCode) { + // General errors + ErrorCode.SUCCESS -> GENERAL + ErrorCode.UNKNOWN -> GENERAL + ErrorCode.INVALID_ARGUMENT -> CONFIGURATION + + // Initialization errors + ErrorCode.NOT_INITIALIZED -> INITIALIZATION + ErrorCode.ALREADY_INITIALIZED -> INITIALIZATION + + // Memory errors + ErrorCode.OUT_OF_MEMORY -> MEMORY + + // File/resource errors + ErrorCode.FILE_NOT_FOUND -> FILE_RESOURCE + ErrorCode.MODEL_NOT_FOUND -> MODEL + + // Operation errors + ErrorCode.TIMEOUT -> OPERATION + ErrorCode.CANCELLED -> OPERATION + + // Network errors + ErrorCode.NETWORK_UNAVAILABLE -> NETWORK + ErrorCode.NETWORK_ERROR -> NETWORK + + // Model errors + ErrorCode.MODEL_NOT_LOADED -> MODEL + ErrorCode.MODEL_LOAD_FAILED -> MODEL + + // Platform errors + ErrorCode.PLATFORM_ADAPTER_NOT_SET -> PLATFORM + ErrorCode.INVALID_HANDLE -> PLATFORM + + // AI Component errors + ErrorCode.STT_TRANSCRIPTION_FAILED -> STT + ErrorCode.TTS_SYNTHESIS_FAILED -> TTS + ErrorCode.LLM_GENERATION_FAILED -> LLM + ErrorCode.VAD_DETECTION_FAILED -> VAD + ErrorCode.VOICE_AGENT_ERROR -> VOICE_AGENT + + // Download errors + ErrorCode.DOWNLOAD_FAILED -> DOWNLOAD + ErrorCode.DOWNLOAD_CANCELLED -> DOWNLOAD + ErrorCode.INSUFFICIENT_STORAGE -> STORAGE + + // Authentication errors + ErrorCode.AUTHENTICATION_FAILED -> AUTHENTICATION + ErrorCode.INVALID_API_KEY -> AUTHENTICATION + ErrorCode.UNAUTHORIZED -> AUTHENTICATION + } + } + } +} diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/foundation/errors/ErrorCode.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/foundation/errors/ErrorCode.kt new file mode 100644 index 000000000..e75a5ff72 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/foundation/errors/ErrorCode.kt @@ -0,0 +1,272 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * Error code enum matching iOS ErrorCode for cross-platform consistency. + */ + +package com.runanywhere.sdk.foundation.errors + +/** + * Error codes for SDK operations. + * + * This enum matches the iOS SDK's ErrorCode for cross-platform consistency. + * Each error code has a corresponding C++ raw value for interop with + * the runanywhere-commons C++ library. + * + * @property rawValue The C++ compatible error code value for interop. + */ +enum class ErrorCode( + val rawValue: Int, +) { + // ======================================================================== + // SUCCESS + // ======================================================================== + + /** + * Operation completed successfully. + */ + SUCCESS(0), + + // ======================================================================== + // GENERAL ERRORS + // ======================================================================== + + /** + * Unknown or unspecified error. + */ + UNKNOWN(-1), + + /** + * Invalid argument provided to a function. + */ + INVALID_ARGUMENT(-2), + + /** + * SDK or component has not been initialized. + */ + NOT_INITIALIZED(-3), + + /** + * SDK or component has already been initialized. + */ + ALREADY_INITIALIZED(-4), + + /** + * Out of memory error. + */ + OUT_OF_MEMORY(-5), + + // ======================================================================== + // FILE AND RESOURCE ERRORS + // ======================================================================== + + /** + * File not found at the specified path. + */ + FILE_NOT_FOUND(-6), + + /** + * Model not found in registry or at path. + */ + MODEL_NOT_FOUND(-6), + + // ======================================================================== + // OPERATION ERRORS + // ======================================================================== + + /** + * Operation timed out. + */ + TIMEOUT(-7), + + /** + * Operation was cancelled by the user or system. + */ + CANCELLED(-8), + + // ======================================================================== + // NETWORK ERRORS + // ======================================================================== + + /** + * Network is unavailable or network operation failed. + */ + NETWORK_UNAVAILABLE(-9), + + /** + * Network error during operation. + */ + NETWORK_ERROR(-9), + + // ======================================================================== + // MODEL ERRORS + // ======================================================================== + + /** + * Model is not loaded and operation requires a loaded model. + */ + MODEL_NOT_LOADED(-10), + + /** + * Failed to load the model. + */ + MODEL_LOAD_FAILED(-11), + + // ======================================================================== + // PLATFORM ERRORS + // ======================================================================== + + /** + * Platform adapter has not been set before initialization. + */ + PLATFORM_ADAPTER_NOT_SET(-12), + + /** + * Invalid handle provided to a function. + */ + INVALID_HANDLE(-13), + + // ======================================================================== + // COMPONENT-SPECIFIC ERRORS + // ======================================================================== + + /** + * Speech-to-text transcription failed. + */ + STT_TRANSCRIPTION_FAILED(-100), + + /** + * Text-to-speech synthesis failed. + */ + TTS_SYNTHESIS_FAILED(-101), + + /** + * LLM generation failed. + */ + LLM_GENERATION_FAILED(-102), + + /** + * Voice activity detection failed. + */ + VAD_DETECTION_FAILED(-103), + + /** + * Voice agent pipeline error. + */ + VOICE_AGENT_ERROR(-104), + + // ======================================================================== + // DOWNLOAD ERRORS + // ======================================================================== + + /** + * Download failed. + */ + DOWNLOAD_FAILED(-200), + + /** + * Download was cancelled. + */ + DOWNLOAD_CANCELLED(-201), + + /** + * Insufficient storage space for download. + */ + INSUFFICIENT_STORAGE(-202), + + // ======================================================================== + // AUTHENTICATION ERRORS + // ======================================================================== + + /** + * Authentication failed or required. + */ + AUTHENTICATION_FAILED(-300), + + /** + * API key is invalid. + */ + INVALID_API_KEY(-301), + + /** + * Access denied or unauthorized. + */ + UNAUTHORIZED(-302), + ; + + companion object { + /** + * Get the ErrorCode from a C++ raw value. + * + * @param rawValue The C++ error code value + * @return The corresponding ErrorCode, or UNKNOWN if not found + */ + fun fromRawValue(rawValue: Int): ErrorCode { + return entries.find { it.rawValue == rawValue } ?: UNKNOWN + } + + /** + * Check if a raw value indicates success. + * + * @param rawValue The C++ error code value + * @return true if the value indicates success (>= 0) + */ + fun isSuccess(rawValue: Int): Boolean = rawValue >= 0 + + /** + * Check if a raw value indicates an error. + * + * @param rawValue The C++ error code value + * @return true if the value indicates an error (< 0) + */ + fun isError(rawValue: Int): Boolean = rawValue < 0 + } + + /** + * Check if this error code represents success. + */ + val isSuccess: Boolean + get() = this == SUCCESS + + /** + * Check if this error code represents an error. + */ + val isError: Boolean + get() = this != SUCCESS + + /** + * Get a human-readable description of the error. + */ + val description: String + get() = + when (this) { + SUCCESS -> "Operation completed successfully" + UNKNOWN -> "An unknown error occurred" + INVALID_ARGUMENT -> "Invalid argument provided" + NOT_INITIALIZED -> "SDK or component not initialized" + ALREADY_INITIALIZED -> "SDK or component already initialized" + OUT_OF_MEMORY -> "Out of memory" + FILE_NOT_FOUND -> "File not found" + MODEL_NOT_FOUND -> "Model not found" + TIMEOUT -> "Operation timed out" + CANCELLED -> "Operation was cancelled" + NETWORK_UNAVAILABLE -> "Network is unavailable" + NETWORK_ERROR -> "Network error occurred" + MODEL_NOT_LOADED -> "Model not loaded" + MODEL_LOAD_FAILED -> "Failed to load model" + PLATFORM_ADAPTER_NOT_SET -> "Platform adapter not set" + INVALID_HANDLE -> "Invalid handle" + STT_TRANSCRIPTION_FAILED -> "Speech-to-text transcription failed" + TTS_SYNTHESIS_FAILED -> "Text-to-speech synthesis failed" + LLM_GENERATION_FAILED -> "LLM generation failed" + VAD_DETECTION_FAILED -> "Voice activity detection failed" + VOICE_AGENT_ERROR -> "Voice agent error" + DOWNLOAD_FAILED -> "Download failed" + DOWNLOAD_CANCELLED -> "Download cancelled" + INSUFFICIENT_STORAGE -> "Insufficient storage space" + AUTHENTICATION_FAILED -> "Authentication failed" + INVALID_API_KEY -> "Invalid API key" + UNAUTHORIZED -> "Unauthorized access" + } +} diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/foundation/errors/SDKError.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/foundation/errors/SDKError.kt new file mode 100644 index 000000000..ab5ff391e --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/foundation/errors/SDKError.kt @@ -0,0 +1,837 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * SDK error class matching iOS SDKError for cross-platform consistency. + */ + +package com.runanywhere.sdk.foundation.errors + +/** + * SDK error class representing errors from SDK operations. + * + * This data class matches the iOS SDK's SDKError struct for cross-platform consistency. + * It combines an error code, category, and message, with optional underlying cause for debugging. + * + * Use the companion object factory methods to create errors for specific categories: + * - `SDKError.stt(message)` for speech-to-text errors + * - `SDKError.llm(message)` for LLM errors + * - `SDKError.network(message)` for network errors + * - etc. + * + * @property code The specific error code identifying the error type. + * @property category The category grouping this error for easier handling. + * @property message A human-readable message describing the error. + * @property cause The underlying throwable cause, if any. + */ +data class SDKError( + val code: ErrorCode, + val category: ErrorCategory, + override val message: String, + override val cause: Throwable? = null, +) : Exception(message, cause) { + /** + * Whether this error represents success (error code is SUCCESS). + */ + val isSuccess: Boolean + get() = code == ErrorCode.SUCCESS + + /** + * Whether this error represents a failure (error code is not SUCCESS). + */ + val isError: Boolean + get() = code != ErrorCode.SUCCESS + + /** + * A detailed description combining code, category, and message. + */ + val detailedDescription: String + get() = "[$category] ${code.name}: $message" + + override fun toString(): String = detailedDescription + + companion object { + // ======================================================================== + // GENERAL ERROR FACTORIES + // ======================================================================== + + /** + * Create a general/unknown error. + * + * @param message The error message + * @param code The specific error code (defaults to UNKNOWN) + * @param cause The underlying throwable cause + * @return An SDKError with GENERAL category + */ + fun general( + message: String, + code: ErrorCode = ErrorCode.UNKNOWN, + cause: Throwable? = null, + ): SDKError = + SDKError( + code = code, + category = ErrorCategory.GENERAL, + message = message, + cause = cause, + ) + + /** + * Create an unknown error. + * + * @param message The error message + * @param cause The underlying throwable cause + * @return An SDKError with UNKNOWN code and GENERAL category + */ + fun unknown(message: String, cause: Throwable? = null): SDKError = + general(message, ErrorCode.UNKNOWN, cause) + + // ======================================================================== + // CONFIGURATION ERROR FACTORIES + // ======================================================================== + + /** + * Create a configuration error. + * + * @param message The error message + * @param code The specific error code (defaults to INVALID_ARGUMENT) + * @param cause The underlying throwable cause + * @return An SDKError with CONFIGURATION category + */ + fun configuration( + message: String, + code: ErrorCode = ErrorCode.INVALID_ARGUMENT, + cause: Throwable? = null, + ): SDKError = + SDKError( + code = code, + category = ErrorCategory.CONFIGURATION, + message = message, + cause = cause, + ) + + /** + * Create an invalid argument error. + * + * @param message The error message + * @param cause The underlying throwable cause + * @return An SDKError with INVALID_ARGUMENT code + */ + fun invalidArgument(message: String, cause: Throwable? = null): SDKError = + configuration(message, ErrorCode.INVALID_ARGUMENT, cause) + + // ======================================================================== + // INITIALIZATION ERROR FACTORIES + // ======================================================================== + + /** + * Create an initialization error. + * + * @param message The error message + * @param code The specific error code (defaults to NOT_INITIALIZED) + * @param cause The underlying throwable cause + * @return An SDKError with INITIALIZATION category + */ + fun initialization( + message: String, + code: ErrorCode = ErrorCode.NOT_INITIALIZED, + cause: Throwable? = null, + ): SDKError = + SDKError( + code = code, + category = ErrorCategory.INITIALIZATION, + message = message, + cause = cause, + ) + + /** + * Create a not initialized error. + * + * @param component The component that is not initialized + * @param cause The underlying throwable cause + * @return An SDKError with NOT_INITIALIZED code + */ + fun notInitialized(component: String, cause: Throwable? = null): SDKError = + initialization("$component is not initialized", ErrorCode.NOT_INITIALIZED, cause) + + /** + * Create an already initialized error. + * + * @param component The component that is already initialized + * @param cause The underlying throwable cause + * @return An SDKError with ALREADY_INITIALIZED code + */ + fun alreadyInitialized(component: String, cause: Throwable? = null): SDKError = + initialization("$component is already initialized", ErrorCode.ALREADY_INITIALIZED, cause) + + // ======================================================================== + // FILE/RESOURCE ERROR FACTORIES + // ======================================================================== + + /** + * Create a file/resource error. + * + * @param message The error message + * @param code The specific error code (defaults to FILE_NOT_FOUND) + * @param cause The underlying throwable cause + * @return An SDKError with FILE_RESOURCE category + */ + fun fileResource( + message: String, + code: ErrorCode = ErrorCode.FILE_NOT_FOUND, + cause: Throwable? = null, + ): SDKError = + SDKError( + code = code, + category = ErrorCategory.FILE_RESOURCE, + message = message, + cause = cause, + ) + + /** + * Create a file not found error. + * + * @param path The path of the file that was not found + * @param cause The underlying throwable cause + * @return An SDKError with FILE_NOT_FOUND code + */ + fun fileNotFound(path: String, cause: Throwable? = null): SDKError = + fileResource("File not found: $path", ErrorCode.FILE_NOT_FOUND, cause) + + // ======================================================================== + // MEMORY ERROR FACTORIES + // ======================================================================== + + /** + * Create a memory error. + * + * @param message The error message + * @param cause The underlying throwable cause + * @return An SDKError with OUT_OF_MEMORY code and MEMORY category + */ + fun memory(message: String, cause: Throwable? = null): SDKError = + SDKError( + code = ErrorCode.OUT_OF_MEMORY, + category = ErrorCategory.MEMORY, + message = message, + cause = cause, + ) + + /** + * Create an out of memory error. + * + * @param operation The operation that ran out of memory + * @param cause The underlying throwable cause + * @return An SDKError with OUT_OF_MEMORY code + */ + fun outOfMemory(operation: String, cause: Throwable? = null): SDKError = + memory("Out of memory during: $operation", cause) + + // ======================================================================== + // STORAGE ERROR FACTORIES + // ======================================================================== + + /** + * Create a storage error. + * + * @param message The error message + * @param code The specific error code (defaults to INSUFFICIENT_STORAGE) + * @param cause The underlying throwable cause + * @return An SDKError with STORAGE category + */ + fun storage( + message: String, + code: ErrorCode = ErrorCode.INSUFFICIENT_STORAGE, + cause: Throwable? = null, + ): SDKError = + SDKError( + code = code, + category = ErrorCategory.STORAGE, + message = message, + cause = cause, + ) + + /** + * Create an insufficient storage error. + * + * @param requiredBytes The number of bytes required (optional) + * @param cause The underlying throwable cause + * @return An SDKError with INSUFFICIENT_STORAGE code + */ + fun insufficientStorage(requiredBytes: Long? = null, cause: Throwable? = null): SDKError { + val message = + if (requiredBytes != null) { + "Insufficient storage space. Required: ${requiredBytes / 1024 / 1024} MB" + } else { + "Insufficient storage space" + } + return storage(message, ErrorCode.INSUFFICIENT_STORAGE, cause) + } + + // ======================================================================== + // OPERATION ERROR FACTORIES + // ======================================================================== + + /** + * Create an operation error. + * + * @param message The error message + * @param code The specific error code (defaults to CANCELLED) + * @param cause The underlying throwable cause + * @return An SDKError with OPERATION category + */ + fun operation( + message: String, + code: ErrorCode = ErrorCode.CANCELLED, + cause: Throwable? = null, + ): SDKError = + SDKError( + code = code, + category = ErrorCategory.OPERATION, + message = message, + cause = cause, + ) + + /** + * Create a timeout error. + * + * @param operation The operation that timed out + * @param timeoutMs The timeout duration in milliseconds (optional) + * @param cause The underlying throwable cause + * @return An SDKError with TIMEOUT code + */ + fun timeout(operation: String, timeoutMs: Long? = null, cause: Throwable? = null): SDKError { + val message = + if (timeoutMs != null) { + "$operation timed out after ${timeoutMs}ms" + } else { + "$operation timed out" + } + return operation(message, ErrorCode.TIMEOUT, cause) + } + + /** + * Create a cancelled error. + * + * @param operation The operation that was cancelled + * @param cause The underlying throwable cause + * @return An SDKError with CANCELLED code + */ + fun cancelled(operation: String, cause: Throwable? = null): SDKError = + operation("$operation was cancelled", ErrorCode.CANCELLED, cause) + + // ======================================================================== + // NETWORK ERROR FACTORIES + // ======================================================================== + + /** + * Create a network error. + * + * @param message The error message + * @param code The specific error code (defaults to NETWORK_ERROR) + * @param cause The underlying throwable cause + * @return An SDKError with NETWORK category + */ + fun network( + message: String, + code: ErrorCode = ErrorCode.NETWORK_ERROR, + cause: Throwable? = null, + ): SDKError = + SDKError( + code = code, + category = ErrorCategory.NETWORK, + message = message, + cause = cause, + ) + + /** + * Create a network unavailable error. + * + * @param cause The underlying throwable cause + * @return An SDKError with NETWORK_UNAVAILABLE code + */ + fun networkUnavailable(cause: Throwable? = null): SDKError = + network("Network is unavailable", ErrorCode.NETWORK_UNAVAILABLE, cause) + + // ======================================================================== + // MODEL ERROR FACTORIES + // ======================================================================== + + /** + * Create a model error. + * + * @param message The error message + * @param code The specific error code (defaults to MODEL_NOT_LOADED) + * @param cause The underlying throwable cause + * @return An SDKError with MODEL category + */ + fun model( + message: String, + code: ErrorCode = ErrorCode.MODEL_NOT_LOADED, + cause: Throwable? = null, + ): SDKError = + SDKError( + code = code, + category = ErrorCategory.MODEL, + message = message, + cause = cause, + ) + + /** + * Create a model not found error. + * + * @param modelId The ID of the model that was not found + * @param cause The underlying throwable cause + * @return An SDKError with MODEL_NOT_FOUND code + */ + fun modelNotFound(modelId: String, cause: Throwable? = null): SDKError = + model("Model not found: $modelId", ErrorCode.MODEL_NOT_FOUND, cause) + + /** + * Create a model not loaded error. + * + * @param modelId The ID of the model that is not loaded (optional) + * @param cause The underlying throwable cause + * @return An SDKError with MODEL_NOT_LOADED code + */ + fun modelNotLoaded(modelId: String? = null, cause: Throwable? = null): SDKError { + val message = + if (modelId != null) { + "Model not loaded: $modelId" + } else { + "No model is loaded" + } + return model(message, ErrorCode.MODEL_NOT_LOADED, cause) + } + + /** + * Create a model load failed error. + * + * @param modelId The ID of the model that failed to load + * @param reason The reason for the failure (optional) + * @param cause The underlying throwable cause + * @return An SDKError with MODEL_LOAD_FAILED code + */ + fun modelLoadFailed(modelId: String, reason: String? = null, cause: Throwable? = null): SDKError { + val message = + if (reason != null) { + "Failed to load model $modelId: $reason" + } else { + "Failed to load model: $modelId" + } + return model(message, ErrorCode.MODEL_LOAD_FAILED, cause) + } + + // ======================================================================== + // PLATFORM ERROR FACTORIES + // ======================================================================== + + /** + * Create a platform error. + * + * @param message The error message + * @param code The specific error code (defaults to INVALID_HANDLE) + * @param cause The underlying throwable cause + * @return An SDKError with PLATFORM category + */ + fun platform( + message: String, + code: ErrorCode = ErrorCode.INVALID_HANDLE, + cause: Throwable? = null, + ): SDKError = + SDKError( + code = code, + category = ErrorCategory.PLATFORM, + message = message, + cause = cause, + ) + + /** + * Create a platform adapter not set error. + * + * @param cause The underlying throwable cause + * @return An SDKError with PLATFORM_ADAPTER_NOT_SET code + */ + fun platformAdapterNotSet(cause: Throwable? = null): SDKError = + platform("Platform adapter not set", ErrorCode.PLATFORM_ADAPTER_NOT_SET, cause) + + /** + * Create an invalid handle error. + * + * @param component The component with the invalid handle + * @param cause The underlying throwable cause + * @return An SDKError with INVALID_HANDLE code + */ + fun invalidHandle(component: String, cause: Throwable? = null): SDKError = + platform("Invalid handle for: $component", ErrorCode.INVALID_HANDLE, cause) + + // ======================================================================== + // LLM ERROR FACTORIES + // ======================================================================== + + /** + * Create an LLM error. + * + * @param message The error message + * @param code The specific error code (defaults to LLM_GENERATION_FAILED) + * @param cause The underlying throwable cause + * @return An SDKError with LLM category + */ + fun llm( + message: String, + code: ErrorCode = ErrorCode.LLM_GENERATION_FAILED, + cause: Throwable? = null, + ): SDKError = + SDKError( + code = code, + category = ErrorCategory.LLM, + message = message, + cause = cause, + ) + + /** + * Create an LLM generation failed error. + * + * @param reason The reason for the failure (optional) + * @param cause The underlying throwable cause + * @return An SDKError with LLM_GENERATION_FAILED code + */ + fun llmGenerationFailed(reason: String? = null, cause: Throwable? = null): SDKError { + val message = + if (reason != null) { + "LLM generation failed: $reason" + } else { + "LLM generation failed" + } + return llm(message, ErrorCode.LLM_GENERATION_FAILED, cause) + } + + // ======================================================================== + // STT ERROR FACTORIES + // ======================================================================== + + /** + * Create an STT (speech-to-text) error. + * + * @param message The error message + * @param code The specific error code (defaults to STT_TRANSCRIPTION_FAILED) + * @param cause The underlying throwable cause + * @return An SDKError with STT category + */ + fun stt( + message: String, + code: ErrorCode = ErrorCode.STT_TRANSCRIPTION_FAILED, + cause: Throwable? = null, + ): SDKError = + SDKError( + code = code, + category = ErrorCategory.STT, + message = message, + cause = cause, + ) + + /** + * Create an STT transcription failed error. + * + * @param reason The reason for the failure (optional) + * @param cause The underlying throwable cause + * @return An SDKError with STT_TRANSCRIPTION_FAILED code + */ + fun sttTranscriptionFailed(reason: String? = null, cause: Throwable? = null): SDKError { + val message = + if (reason != null) { + "Speech-to-text transcription failed: $reason" + } else { + "Speech-to-text transcription failed" + } + return stt(message, ErrorCode.STT_TRANSCRIPTION_FAILED, cause) + } + + // ======================================================================== + // TTS ERROR FACTORIES + // ======================================================================== + + /** + * Create a TTS (text-to-speech) error. + * + * @param message The error message + * @param code The specific error code (defaults to TTS_SYNTHESIS_FAILED) + * @param cause The underlying throwable cause + * @return An SDKError with TTS category + */ + fun tts( + message: String, + code: ErrorCode = ErrorCode.TTS_SYNTHESIS_FAILED, + cause: Throwable? = null, + ): SDKError = + SDKError( + code = code, + category = ErrorCategory.TTS, + message = message, + cause = cause, + ) + + /** + * Create a TTS synthesis failed error. + * + * @param reason The reason for the failure (optional) + * @param cause The underlying throwable cause + * @return An SDKError with TTS_SYNTHESIS_FAILED code + */ + fun ttsSynthesisFailed(reason: String? = null, cause: Throwable? = null): SDKError { + val message = + if (reason != null) { + "Text-to-speech synthesis failed: $reason" + } else { + "Text-to-speech synthesis failed" + } + return tts(message, ErrorCode.TTS_SYNTHESIS_FAILED, cause) + } + + // ======================================================================== + // VAD ERROR FACTORIES + // ======================================================================== + + /** + * Create a VAD (voice activity detection) error. + * + * @param message The error message + * @param code The specific error code (defaults to VAD_DETECTION_FAILED) + * @param cause The underlying throwable cause + * @return An SDKError with VAD category + */ + fun vad( + message: String, + code: ErrorCode = ErrorCode.VAD_DETECTION_FAILED, + cause: Throwable? = null, + ): SDKError = + SDKError( + code = code, + category = ErrorCategory.VAD, + message = message, + cause = cause, + ) + + /** + * Create a VAD detection failed error. + * + * @param reason The reason for the failure (optional) + * @param cause The underlying throwable cause + * @return An SDKError with VAD_DETECTION_FAILED code + */ + fun vadDetectionFailed(reason: String? = null, cause: Throwable? = null): SDKError { + val message = + if (reason != null) { + "Voice activity detection failed: $reason" + } else { + "Voice activity detection failed" + } + return vad(message, ErrorCode.VAD_DETECTION_FAILED, cause) + } + + // ======================================================================== + // VOICE AGENT ERROR FACTORIES + // ======================================================================== + + /** + * Create a Voice Agent error. + * + * @param message The error message + * @param code The specific error code (defaults to VOICE_AGENT_ERROR) + * @param cause The underlying throwable cause + * @return An SDKError with VOICE_AGENT category + */ + fun voiceAgent( + message: String, + code: ErrorCode = ErrorCode.VOICE_AGENT_ERROR, + cause: Throwable? = null, + ): SDKError = + SDKError( + code = code, + category = ErrorCategory.VOICE_AGENT, + message = message, + cause = cause, + ) + + /** + * Create a Voice Agent pipeline error. + * + * @param stage The pipeline stage that failed + * @param reason The reason for the failure (optional) + * @param cause The underlying throwable cause + * @return An SDKError with VOICE_AGENT_ERROR code + */ + fun voiceAgentPipeline(stage: String, reason: String? = null, cause: Throwable? = null): SDKError { + val message = + if (reason != null) { + "Voice agent pipeline failed at $stage: $reason" + } else { + "Voice agent pipeline failed at: $stage" + } + return voiceAgent(message, ErrorCode.VOICE_AGENT_ERROR, cause) + } + + // ======================================================================== + // DOWNLOAD ERROR FACTORIES + // ======================================================================== + + /** + * Create a download error. + * + * @param message The error message + * @param code The specific error code (defaults to DOWNLOAD_FAILED) + * @param cause The underlying throwable cause + * @return An SDKError with DOWNLOAD category + */ + fun download( + message: String, + code: ErrorCode = ErrorCode.DOWNLOAD_FAILED, + cause: Throwable? = null, + ): SDKError = + SDKError( + code = code, + category = ErrorCategory.DOWNLOAD, + message = message, + cause = cause, + ) + + /** + * Create a download failed error. + * + * @param url The URL that failed to download + * @param reason The reason for the failure (optional) + * @param cause The underlying throwable cause + * @return An SDKError with DOWNLOAD_FAILED code + */ + fun downloadFailed(url: String, reason: String? = null, cause: Throwable? = null): SDKError { + val message = + if (reason != null) { + "Download failed for $url: $reason" + } else { + "Download failed: $url" + } + return download(message, ErrorCode.DOWNLOAD_FAILED, cause) + } + + /** + * Create a download cancelled error. + * + * @param url The URL whose download was cancelled + * @param cause The underlying throwable cause + * @return An SDKError with DOWNLOAD_CANCELLED code + */ + fun downloadCancelled(url: String, cause: Throwable? = null): SDKError = + download("Download cancelled: $url", ErrorCode.DOWNLOAD_CANCELLED, cause) + + // ======================================================================== + // AUTHENTICATION ERROR FACTORIES + // ======================================================================== + + /** + * Create an authentication error. + * + * @param message The error message + * @param code The specific error code (defaults to AUTHENTICATION_FAILED) + * @param cause The underlying throwable cause + * @return An SDKError with AUTHENTICATION category + */ + fun authentication( + message: String, + code: ErrorCode = ErrorCode.AUTHENTICATION_FAILED, + cause: Throwable? = null, + ): SDKError = + SDKError( + code = code, + category = ErrorCategory.AUTHENTICATION, + message = message, + cause = cause, + ) + + /** + * Create an authentication failed error. + * + * @param reason The reason for the failure (optional) + * @param cause The underlying throwable cause + * @return An SDKError with AUTHENTICATION_FAILED code + */ + fun authenticationFailed(reason: String? = null, cause: Throwable? = null): SDKError { + val message = + if (reason != null) { + "Authentication failed: $reason" + } else { + "Authentication failed" + } + return authentication(message, ErrorCode.AUTHENTICATION_FAILED, cause) + } + + /** + * Create an invalid API key error. + * + * @param cause The underlying throwable cause + * @return An SDKError with INVALID_API_KEY code + */ + fun invalidApiKey(cause: Throwable? = null): SDKError = + authentication("Invalid API key", ErrorCode.INVALID_API_KEY, cause) + + /** + * Create an unauthorized error. + * + * @param resource The resource that is unauthorized (optional) + * @param cause The underlying throwable cause + * @return An SDKError with UNAUTHORIZED code + */ + fun unauthorized(resource: String? = null, cause: Throwable? = null): SDKError { + val message = + if (resource != null) { + "Unauthorized access to: $resource" + } else { + "Unauthorized access" + } + return authentication(message, ErrorCode.UNAUTHORIZED, cause) + } + + // ======================================================================== + // C++ INTEROP FACTORIES + // ======================================================================== + + /** + * Create an SDKError from a C++ raw error code. + * + * This is used for interop with the runanywhere-commons C++ library. + * + * @param rawValue The C++ error code value + * @param message The error message (optional, will use default if not provided) + * @param cause The underlying throwable cause + * @return An SDKError corresponding to the raw error code + */ + fun fromRawValue(rawValue: Int, message: String? = null, cause: Throwable? = null): SDKError { + val errorCode = ErrorCode.fromRawValue(rawValue) + val errorCategory = ErrorCategory.fromErrorCode(errorCode) + val errorMessage = message ?: errorCode.description + return SDKError( + code = errorCode, + category = errorCategory, + message = errorMessage, + cause = cause, + ) + } + + /** + * Create an SDKError from an ErrorCode. + * + * @param errorCode The error code + * @param message The error message (optional, will use default if not provided) + * @param cause The underlying throwable cause + * @return An SDKError with the appropriate category + */ + fun fromErrorCode(errorCode: ErrorCode, message: String? = null, cause: Throwable? = null): SDKError { + val errorCategory = ErrorCategory.fromErrorCode(errorCode) + val errorMessage = message ?: errorCode.description + return SDKError( + code = errorCode, + category = errorCategory, + message = errorMessage, + cause = cause, + ) + } + } +} diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/models/DeviceInfo.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/models/DeviceInfo.kt new file mode 100644 index 000000000..aa1d6d05c --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/models/DeviceInfo.kt @@ -0,0 +1,80 @@ +package com.runanywhere.sdk.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Core device hardware information for telemetry, logging, and API requests. + * Matches iOS DeviceInfo.swift exactly. + * + * This is embedded in DeviceRegistrationRequest and also available standalone. + */ +@Serializable +data class DeviceInfo( + // MARK: - Device Identity + /** Persistent device UUID (survives app reinstalls via Keychain/SharedPreferences) */ + @SerialName("device_id") + val deviceId: String, + // MARK: - Device Hardware + /** Device model identifier (e.g., "iPhone16,2" for iPhone 15 Pro Max, "Pixel 8 Pro" for Android) */ + @SerialName("model_identifier") + val modelIdentifier: String, + /** User-friendly device name (e.g., "iPhone 15 Pro Max", "Google Pixel 8 Pro") */ + @SerialName("model_name") + val modelName: String, + /** CPU architecture (e.g., "arm64", "x86_64") */ + @SerialName("architecture") + val architecture: String, + // MARK: - Operating System + /** Operating system version string (e.g., "17.2", "14") */ + @SerialName("os_version") + val osVersion: String, + /** Platform identifier (e.g., "iOS", "Android", "JVM", "macOS") */ + @SerialName("platform") + val platform: String, + // MARK: - Device Classification + /** Device type for API requests (mobile, tablet, desktop, tv, watch, vr) */ + @SerialName("device_type") + val deviceType: String, + /** Form factor (phone, tablet, laptop, desktop, tv, watch, headset) */ + @SerialName("form_factor") + val formFactor: String, + // MARK: - Hardware Specs + /** Total physical memory in bytes */ + @SerialName("total_memory") + val totalMemory: Long, + /** Number of processor cores */ + @SerialName("processor_count") + val processorCount: Int, +) { + /** + * Human-readable description of the device + */ + val description: String + get() = "$modelName ($platform $osVersion) - $processorCount cores, ${totalMemory / (1024 * 1024)}MB RAM" + + /** + * Total memory in MB (convenience property) + */ + val totalMemoryMB: Long + get() = totalMemory / (1024 * 1024) + + /** + * Check if device meets minimum requirements for on-device AI + */ + fun meetsMinimumRequirements(): Boolean = processorCount >= 2 && totalMemoryMB >= 1024 + + companion object { + /** + * Get current device info - delegates to platform-specific implementation + */ + val current: DeviceInfo + get() = collectDeviceInfo() + } +} + +/** + * Platform-specific device info collection. + * Implemented in jvmMain, androidMain, iosMain, etc. + */ +expect fun collectDeviceInfo(): DeviceInfo diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/models/ExecutionTarget.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/models/ExecutionTarget.kt new file mode 100644 index 000000000..2a858d44e --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/models/ExecutionTarget.kt @@ -0,0 +1,25 @@ +package com.runanywhere.sdk.models + +import kotlinx.serialization.Serializable + +/** + * Execution target for model inference - exact match with iOS ExecutionTarget + */ +@Serializable +enum class ExecutionTarget( + val value: String, +) { + /** Execute on device */ + ON_DEVICE("onDevice"), + + /** Execute in the cloud */ + CLOUD("cloud"), + + /** Hybrid execution (partial on-device, partial cloud) */ + HYBRID("hybrid"), + ; + + companion object { + fun fromValue(value: String): ExecutionTarget? = values().find { it.value == value } + } +} diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/models/storage/StorageInfo.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/models/storage/StorageInfo.kt new file mode 100644 index 000000000..4129e47e3 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/models/storage/StorageInfo.kt @@ -0,0 +1,167 @@ +package com.runanywhere.sdk.models.storage + +import kotlinx.datetime.Instant +import kotlinx.serialization.Contextual +import kotlinx.serialization.Serializable + +/** + * Storage information for the SDK + * Matches iOS StorageInfo struct from RunAnywhere+Storage.swift + * + * Reference: sdk/runanywhere-swift/Sources/RunAnywhere/Data/Models/Storage/StorageInfo.swift + */ +@OptIn(kotlin.time.ExperimentalTime::class) +@Serializable +data class StorageInfo( + val appStorage: AppStorageInfo, + val deviceStorage: DeviceStorageInfo, + val modelStorage: ModelStorageInfo, + val cacheSize: Long, // bytes - matches iOS + val storedModels: List, // Matches iOS - direct access to stored models + val availability: StorageAvailability, + val recommendations: List, + @Contextual val lastUpdated: Instant, +) { + companion object { + /** + * Empty storage info for initialization + * Matches iOS StorageInfo.empty + */ + val empty = + StorageInfo( + appStorage = + AppStorageInfo( + documentsSize = 0, + cacheSize = 0, + appSupportSize = 0, + totalSize = 0, + ), + deviceStorage = + DeviceStorageInfo( + totalSpace = 0, + freeSpace = 0, + usedSpace = 0, + ), + modelStorage = + ModelStorageInfo( + totalSize = 0, + modelCount = 0, + largestModel = null, + models = emptyList(), + ), + cacheSize = 0, + storedModels = emptyList(), + availability = StorageAvailability.HEALTHY, + recommendations = emptyList(), + lastUpdated = Instant.fromEpochMilliseconds(0), + ) + } +} + +/** + * App-specific storage information + * Matches iOS AppStorageInfo struct exactly + * + * Reference: sdk/runanywhere-swift/Sources/RunAnywhere/Data/Models/Storage/AppStorageInfo.swift + */ +@Serializable +data class AppStorageInfo( + val documentsSize: Long, // bytes - app documents size + val cacheSize: Long, // bytes - cache size + val appSupportSize: Long, // bytes - app support size + val totalSize: Long, // bytes - total app storage size +) + +/** + * Device storage information + * Matches iOS DeviceStorageInfo struct exactly + * + * Reference: sdk/runanywhere-swift/Sources/RunAnywhere/Data/Models/Storage/DeviceStorageInfo.swift + */ +@Serializable +data class DeviceStorageInfo( + val totalSpace: Long, // bytes - total device storage + val freeSpace: Long, // bytes - available space + val usedSpace: Long, // bytes - used space +) { + /** + * Usage percentage (0-100) + * Matches iOS usagePercentage computed property + */ + val usagePercentage: Double + get() = + if (totalSpace > 0) { + (usedSpace.toDouble() / totalSpace.toDouble()) * 100.0 + } else { + 0.0 + } +} + +/** + * Model-specific storage information + * Matches iOS ModelStorageInfo struct + * + * Reference: sdk/runanywhere-swift/Sources/RunAnywhere/Data/Models/Storage/ModelStorageInfo.swift + */ +@Serializable +data class ModelStorageInfo( + val totalSize: Long, // bytes - total size of all models + val modelCount: Int, // number of stored models + val largestModel: StoredModel?, // largest model by size + val models: List, // all stored models +) + +/** + * Individual stored model information + * Matches iOS StoredModel struct exactly + * + * Reference: sdk/runanywhere-swift/Sources/RunAnywhere/Data/Models/Storage/StoredModel.swift + */ +@OptIn(kotlin.time.ExperimentalTime::class) +@Serializable +data class StoredModel( + val id: String, // Model ID used for operations like deletion + val name: String, // Display name + val path: String, // File path (iOS uses URL) + val size: Long, // bytes + val format: String, // Model format (e.g., "gguf", "onnx") + val framework: String?, // Framework name (e.g., "LlamaCpp", "ONNX") + @Contextual val createdDate: Instant, // When the model was downloaded + @Contextual val lastUsed: Instant?, // Last time model was used + val contextLength: Int?, // Context window size if applicable + val checksum: String? = null, // Model checksum for verification +) + +/** + * Storage availability status + * Matches iOS StorageAvailability logic + */ +@Serializable +enum class StorageAvailability { + HEALTHY, // > 20% available + LOW, // 10-20% available + CRITICAL, // 5-10% available + FULL, // < 5% available +} + +/** + * Storage recommendation + * Matches iOS StorageRecommendation struct + */ +@Serializable +data class StorageRecommendation( + val type: RecommendationType, + val message: String, // matches iOS 'message' field + val action: String, // e.g., "Clear Cache", "Delete Models" +) + +/** + * Type of storage recommendation + * Matches iOS RecommendationType enum + */ +@Serializable +enum class RecommendationType { + WARNING, // Low storage warning + CRITICAL, // Critical storage shortage + SUGGESTION, // Optimization suggestion +} diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/native/bridge/BridgeResults.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/native/bridge/BridgeResults.kt new file mode 100644 index 000000000..520105954 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/native/bridge/BridgeResults.kt @@ -0,0 +1,51 @@ +package com.runanywhere.sdk.native.bridge + +import com.runanywhere.sdk.foundation.SDKLogger + +/** + * Result from TTS synthesis operation via native backend. + * Contains audio samples and sample rate. + */ +data class NativeTTSSynthesisResult( + val samples: FloatArray, + val sampleRate: Int, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as NativeTTSSynthesisResult + + if (!samples.contentEquals(other.samples)) return false + if (sampleRate != other.sampleRate) return false + + return true + } + + override fun hashCode(): Int { + var result = samples.contentHashCode() + result = 31 * result + sampleRate + return result + } +} + +/** + * Result from VAD process operation via native backend. + * Contains speech detection status and probability. + */ +data class NativeVADResult( + val isSpeech: Boolean, + val probability: Float, +) + +/** + * Exception thrown when a native RunAnywhere operation fails. + */ +class NativeBridgeException( + val resultCode: NativeResultCode, + message: String? = null, +) : Exception(message ?: "Native operation failed with code: ${resultCode.name}") { + init { + SDKLogger.core.error("NativeBridgeException: ${this.message} (code: ${resultCode.name})", throwable = this) + } +} diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/native/bridge/Capability.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/native/bridge/Capability.kt new file mode 100644 index 000000000..4ad134aec --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/native/bridge/Capability.kt @@ -0,0 +1,74 @@ +package com.runanywhere.sdk.native.bridge + +/** + * Capability types supported by RunAnywhere Core native backends. + * Maps directly to ra_capability_type in runanywhere_bridge.h + * + * This is a generic definition used by all native backends (ONNX, TFLite, CoreML, etc.) + */ +enum class NativeCapability( + val value: Int, +) { + TEXT_GENERATION(0), + EMBEDDINGS(1), + STT(2), + TTS(3), + VAD(4), + DIARIZATION(5), + ; + + companion object { + fun fromValue(value: Int): NativeCapability? = entries.find { it.value == value } + } +} + +/** + * Device types used by native backends. + * Maps directly to ra_device_type in types.h + */ +enum class NativeDeviceType( + val value: Int, +) { + CPU(0), + GPU(1), + NEURAL_ENGINE(2), + METAL(3), + CUDA(4), + VULKAN(5), + COREML(6), + TFLITE(7), + ONNX(8), + ; + + companion object { + fun fromValue(value: Int): NativeDeviceType = entries.find { it.value == value } ?: CPU + } +} + +/** + * Result codes from C API operations. + * Maps directly to ra_result_code in types.h + */ +enum class NativeResultCode( + val value: Int, +) { + SUCCESS(0), + ERROR_INIT_FAILED(-1), + ERROR_MODEL_LOAD_FAILED(-2), + ERROR_INFERENCE_FAILED(-3), + ERROR_INVALID_HANDLE(-4), + ERROR_INVALID_PARAMS(-5), + ERROR_OUT_OF_MEMORY(-6), + ERROR_NOT_IMPLEMENTED(-7), + ERROR_CANCELLED(-8), + ERROR_TIMEOUT(-9), + ERROR_IO(-10), + ERROR_UNKNOWN(-99), + ; + + val isSuccess: Boolean get() = this == SUCCESS + + companion object { + fun fromValue(value: Int): NativeResultCode = entries.find { it.value == value } ?: ERROR_UNKNOWN + } +} diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/native/bridge/NativeCoreService.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/native/bridge/NativeCoreService.kt new file mode 100644 index 000000000..c057846f5 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/native/bridge/NativeCoreService.kt @@ -0,0 +1,255 @@ +package com.runanywhere.sdk.native.bridge + +/** + * NativeCoreService - Interface for all RunAnywhere Core native backends. + * + * This is the generic interface that ALL native backends (ONNX, TFLite, CoreML, etc.) + * must implement. It provides a unified API for ML capabilities across different runtimes. + * + * This is the Kotlin equivalent of NativeBackend protocols in the Swift SDK. + * + * Usage: + * ```kotlin + * // Create backend-specific implementation + * val service: NativeCoreService = ONNXCoreService() // or TFLiteCoreService, etc. + * service.initialize() + * + * // Load STT model + * service.loadSTTModel("/path/to/model", "zipformer") + * + * // Transcribe audio + * val result = service.transcribe(audioSamples, 16000) + * + * // Cleanup + * service.destroy() + * ``` + */ +interface NativeCoreService { + /** + * Initialize the native backend. + * Must be called before any other operations. + * + * @param configJson Optional JSON configuration + * @throws NativeBridgeException if initialization fails + */ + suspend fun initialize(configJson: String? = null) + + /** + * Check if the backend is initialized. + */ + val isInitialized: Boolean + + /** + * Get supported capabilities. + */ + val supportedCapabilities: List + + /** + * Check if a specific capability is supported. + */ + fun supportsCapability(capability: NativeCapability): Boolean + + /** + * Get device type being used. + */ + val deviceType: NativeDeviceType + + /** + * Get current memory usage in bytes. + */ + val memoryUsage: Long + + // ============================================================================= + // STT Operations + // ============================================================================= + + /** + * Load an STT model. + * + * @param modelPath Path to the model directory + * @param modelType Model type (e.g., "whisper", "zipformer", "paraformer") + * @param configJson Optional JSON configuration + * @throws NativeBridgeException if loading fails + */ + suspend fun loadSTTModel( + modelPath: String, + modelType: String, + configJson: String? = null, + ) + + /** + * Check if STT model is loaded. + */ + val isSTTModelLoaded: Boolean + + /** + * Unload STT model. + */ + suspend fun unloadSTTModel() + + /** + * Transcribe audio (batch mode). + * + * @param audioSamples Float32 audio samples [-1.0, 1.0] + * @param sampleRate Sample rate (e.g., 16000) + * @param language ISO 639-1 language code or null for auto-detect + * @return Transcription result as JSON string + * @throws NativeBridgeException if transcription fails + */ + suspend fun transcribe( + audioSamples: FloatArray, + sampleRate: Int, + language: String? = null, + ): String + + /** + * Check if STT supports streaming. + */ + val supportsSTTStreaming: Boolean + + // ============================================================================= + // TTS Operations + // ============================================================================= + + /** + * Load a TTS model. + * + * @param modelPath Path to the model directory + * @param modelType Model type (e.g., "piper", "vits") + * @param configJson Optional JSON configuration + * @throws NativeBridgeException if loading fails + */ + suspend fun loadTTSModel( + modelPath: String, + modelType: String, + configJson: String? = null, + ) + + /** + * Check if TTS model is loaded. + */ + val isTTSModelLoaded: Boolean + + /** + * Unload TTS model. + */ + suspend fun unloadTTSModel() + + /** + * Synthesize speech from text. + * + * @param text Text to synthesize + * @param voiceId Voice identifier or null for default + * @param speedRate Speed rate (1.0 = normal) + * @param pitchShift Pitch shift in semitones + * @return NativeTTSSynthesisResult with audio samples and sample rate + * @throws NativeBridgeException if synthesis fails + */ + suspend fun synthesize( + text: String, + voiceId: String? = null, + speedRate: Float = 1.0f, + pitchShift: Float = 0.0f, + ): NativeTTSSynthesisResult + + /** + * Get available TTS voices as JSON array. + */ + suspend fun getVoices(): String + + // ============================================================================= + // VAD Operations + // ============================================================================= + + /** + * Load a VAD model. + * + * @param modelPath Path to the model (or null for built-in) + * @param configJson Optional JSON configuration + */ + suspend fun loadVADModel( + modelPath: String? = null, + configJson: String? = null, + ) + + /** + * Check if VAD model is loaded. + */ + val isVADModelLoaded: Boolean + + /** + * Unload VAD model. + */ + suspend fun unloadVADModel() + + /** + * Process audio and detect speech. + * + * @param audioSamples Float32 audio samples + * @param sampleRate Sample rate + * @return NativeVADResult with speech status and probability + */ + suspend fun processVAD( + audioSamples: FloatArray, + sampleRate: Int, + ): NativeVADResult + + /** + * Detect speech segments in audio. + * + * @param audioSamples Float32 audio samples + * @param sampleRate Sample rate + * @return JSON array of speech segments + */ + suspend fun detectVADSegments( + audioSamples: FloatArray, + sampleRate: Int, + ): String + + // ============================================================================= + // Embedding Operations + // ============================================================================= + + /** + * Load an embedding model. + * + * @param modelPath Path to the model + * @param configJson Optional JSON configuration + */ + suspend fun loadEmbeddingModel( + modelPath: String, + configJson: String? = null, + ) + + /** + * Check if embedding model is loaded. + */ + val isEmbeddingModelLoaded: Boolean + + /** + * Unload embedding model. + */ + suspend fun unloadEmbeddingModel() + + /** + * Generate embedding for text. + * + * @param text Text to embed + * @return Float array of embedding values + */ + suspend fun embed(text: String): FloatArray + + /** + * Get embedding dimensions. + */ + val embeddingDimensions: Int + + // ============================================================================= + // Lifecycle + // ============================================================================= + + /** + * Destroy the backend and release all resources. + */ + fun destroy() +} diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/platform/Checksum.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/platform/Checksum.kt new file mode 100644 index 000000000..d7e96ef13 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/platform/Checksum.kt @@ -0,0 +1,38 @@ +package com.runanywhere.sdk.platform + +/** + * Platform-specific checksum calculation APIs. + * ALL business logic stays in commonMain - only file I/O is platform-specific. + * + * Matches Swift SDK's checksum verification approach. + */ + +/** + * Calculate SHA-256 checksum of a file. + * Platform-specific implementation required for file I/O. + * + * @param filePath Absolute path to file + * @return Hex string of SHA-256 hash (lowercase) + */ +expect suspend fun calculateSHA256(filePath: String): String + +/** + * Calculate MD5 checksum of a file. + * Platform-specific implementation required for file I/O. + * + * @param filePath Absolute path to file + * @return Hex string of MD5 hash (lowercase) + */ +expect suspend fun calculateMD5(filePath: String): String + +/** + * Calculate checksum of byte array (common implementation). + * Business logic in commonMain. + */ +expect fun calculateSHA256Bytes(data: ByteArray): String + +/** + * Calculate MD5 of byte array (common implementation). + * Business logic in commonMain. + */ +expect fun calculateMD5Bytes(data: ByteArray): String diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/platform/StoragePlatform.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/platform/StoragePlatform.kt new file mode 100644 index 000000000..3303ae9ff --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/platform/StoragePlatform.kt @@ -0,0 +1,43 @@ +package com.runanywhere.sdk.platform + +/** + * Platform-specific storage information + * Matches iOS platform-specific storage calculations + * + * Reference: iOS uses different APIs per platform (iOS: FileManager, macOS: URL.volumeAvailableCapacityKey) + */ +data class PlatformStorageInfo( + val totalSpace: Long, // Total storage capacity in bytes + val availableSpace: Long, // Available storage in bytes + val usedSpace: Long, // Used storage in bytes +) + +/** + * Get platform-specific storage information + * Matches iOS FileManager.default.volumeAvailableCapacity pattern + * + * Implementation varies by platform: + * - Android: Uses StatFs + * - JVM: Uses File.getTotalSpace/getUsableSpace + */ +expect suspend fun getPlatformStorageInfo(path: String): PlatformStorageInfo + +/** + * Get platform-specific base directory for RunAnywhere SDK + * Matches iOS FileManager.default.urls(for: .applicationSupportDirectory) + * + * Implementation varies by platform: + * - Android: Context.filesDir + "/runanywhere" + * - JVM: User home + "/.runanywhere" + */ +expect fun getPlatformBaseDirectory(): String + +/** + * Get platform-specific temp directory + * Matches iOS FileManager.default.temporaryDirectory + * + * Implementation varies by platform: + * - Android: Context.cacheDir + * - JVM: System.getProperty("java.io.tmpdir") + "/runanywhere" + */ +expect fun getPlatformTempDirectory(): String diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/RunAnywhere.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/RunAnywhere.kt new file mode 100644 index 000000000..8060e8679 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/RunAnywhere.kt @@ -0,0 +1,376 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * Main entry point for the RunAnywhere SDK. + * Thin wrapper that delegates to CppBridge for C++ interop. + * + * This file mirrors the iOS SDK's RunAnywhere.swift structure. + */ + +package com.runanywhere.sdk.public + +import com.runanywhere.sdk.foundation.LogLevel +import com.runanywhere.sdk.foundation.SDKLogger +import com.runanywhere.sdk.public.events.EventBus +import com.runanywhere.sdk.utils.SDKConstants + +// ═══════════════════════════════════════════════════════════════════════════ +// SDK INITIALIZATION FLOW (Two-Phase Pattern) +// ═══════════════════════════════════════════════════════════════════════════ +// +// PHASE 1: Core Init (Synchronous, ~1-5ms, No Network) +// ───────────────────────────────────────────────────── +// RunAnywhere.initialize(environment) +// ├─ CppBridge.initialize() +// │ ├─ PlatformAdapter.register() ← File ops, logging, keychain +// │ ├─ Events.register() ← Analytics callback +// │ ├─ Telemetry.initialize() ← HTTP callback +// │ └─ Device.register() ← Device registration +// └─ Mark: isInitialized = true +// +// PHASE 2: Services Init (Async, ~100-500ms, Network May Be Required) +// ──────────────────────────────────────────────────────────────────── +// RunAnywhere.completeServicesInitialization() +// ├─ CppBridge.initializeServices() +// │ ├─ ModelAssignment.register() ← Model assignment callbacks +// │ └─ Platform.register() ← LLM/TTS service callbacks +// └─ Mark: areServicesReady = true +// +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * SDK environment configuration. + */ +enum class SDKEnvironment( + val cEnvironment: Int, +) { + DEVELOPMENT(0), + STAGING(1), + PRODUCTION(2), + ; + + companion object { + fun fromCEnvironment(cEnvironment: Int): SDKEnvironment = + entries.find { it.cEnvironment == cEnvironment } ?: DEVELOPMENT + } +} + +/** + * The RunAnywhere SDK - Single entry point for on-device AI + * + * This object mirrors the iOS RunAnywhere enum pattern, providing: + * - SDK initialization (two-phase: fast sync + async services) + * - State access (isInitialized, areServicesReady, version, environment) + * - Event access via `events` property + * + * Feature-specific APIs are available through extension functions in public/extensions/: + * - STT: RunAnywhere.transcribe(), RunAnywhere.loadSTTModel() + * - TTS: RunAnywhere.synthesize(), RunAnywhere.loadTTSVoice() + * - LLM: RunAnywhere.chat(), RunAnywhere.generate(), RunAnywhere.generateStream() + * - VAD: RunAnywhere.detectSpeech() + * - VoiceAgent: RunAnywhere.startVoiceSession() + * + * All AI component logic (LLM, STT, TTS, VAD) is delegated to the C++ runanywhere-commons + * layer via CppBridge. Kotlin only handles platform-specific operations (HTTP, audio, file I/O). + */ +object RunAnywhere { + // ═══════════════════════════════════════════════════════════════════════════ + // MARK: - Private State + // ═══════════════════════════════════════════════════════════════════════════ + + private val logger = SDKLogger("RunAnywhere") + + @Volatile + private var _currentEnvironment: SDKEnvironment? = null + + @Volatile + private var _isInitialized: Boolean = false + + @Volatile + private var _areServicesReady: Boolean = false + + private val lock = Any() + + // ═══════════════════════════════════════════════════════════════════════════ + // MARK: - Public Properties + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Check if SDK is initialized (Phase 1 complete) + */ + val isInitialized: Boolean + get() = _isInitialized + + /** + * Alias for isInitialized for compatibility + */ + val isSDKInitialized: Boolean + get() = _isInitialized + + /** + * Check if services are fully ready (Phase 2 complete) + */ + val areServicesReady: Boolean + get() = _areServicesReady + + /** + * Check if SDK is active and ready for use + */ + val isActive: Boolean + get() = _isInitialized && _currentEnvironment != null + + /** + * Current SDK version + */ + val version: String + get() = SDKConstants.SDK_VERSION + + /** + * Current environment (null if not initialized) + */ + val environment: SDKEnvironment? + get() = _currentEnvironment + + // ═══════════════════════════════════════════════════════════════════════════ + // MARK: - Event Access + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Event bus for SDK event subscriptions. + * + * Example usage: + * ```kotlin + * RunAnywhere.events.llmEvents.collect { event -> + * println("LLM event: ${event.type}") + * } + * ``` + */ + val events: EventBus + get() = EventBus + + // ═══════════════════════════════════════════════════════════════════════════ + // MARK: - Phase 1: Core Initialization (Synchronous) + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Initialize the RunAnywhere SDK (Phase 1) + * + * This performs fast synchronous initialization. Services initialization + * is done separately via [completeServicesInitialization]. + * + * ## Usage Examples + * + * ```kotlin + * // Development mode (default) + * RunAnywhere.initialize() + * + * // Production mode + * RunAnywhere.initialize(environment = SDKEnvironment.PRODUCTION) + * ``` + * + * @param apiKey API key (optional for development, required for production/staging) + * @param baseURL Backend API base URL (optional) + * @param environment SDK environment (default: DEVELOPMENT) + */ + fun initialize( + apiKey: String? = null, + baseURL: String? = null, + environment: SDKEnvironment = SDKEnvironment.DEVELOPMENT, + ) { + synchronized(lock) { + if (_isInitialized) { + logger.info("SDK already initialized") + return + } + + val initStartTime = System.currentTimeMillis() + + try { + // Store environment + _currentEnvironment = environment + + // Set log level based on environment + val logLevel = + when (environment) { + SDKEnvironment.DEVELOPMENT -> LogLevel.DEBUG + SDKEnvironment.STAGING -> LogLevel.INFO + SDKEnvironment.PRODUCTION -> LogLevel.WARNING + } + SDKLogger.setLevel(logLevel) + + // Initialize CppBridge (Phase 1) + // Note: CppBridge is in jvmAndroidMain, we call it via expect/actual + initializeCppBridge(environment, apiKey, baseURL) + + // Mark Phase 1 complete + _isInitialized = true + + val initDurationMs = System.currentTimeMillis() - initStartTime + logger.info("✅ Phase 1 complete in ${initDurationMs}ms (${environment.name})") + } catch (error: Exception) { + logger.error("❌ Initialization failed: ${error.message}") + _currentEnvironment = null + _isInitialized = false + throw error + } + } + } + + /** + * Initialize SDK for development mode (convenience method) + */ + fun initializeForDevelopment(apiKey: String? = null) { + initialize(apiKey = apiKey, environment = SDKEnvironment.DEVELOPMENT) + } + + // ═══════════════════════════════════════════════════════════════════════════ + // MARK: - Phase 2: Services Initialization (Async) + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Complete services initialization (Phase 2) + * + * Called automatically on first API call, or can be awaited directly. + * Safe to call multiple times - returns immediately if already done. + */ + suspend fun completeServicesInitialization() { + // Fast path: already completed + if (_areServicesReady) { + return + } + + synchronized(lock) { + if (_areServicesReady) { + return + } + + if (!_isInitialized) { + throw IllegalStateException("SDK must be initialized before completing services initialization") + } + + logger.info("Initializing services for ${_currentEnvironment?.name} mode...") + + try { + // Initialize CppBridge services (Phase 2) + initializeCppBridgeServices() + + // Mark Phase 2 complete + _areServicesReady = true + + logger.info("✅ Services initialized for ${_currentEnvironment?.name} mode") + } catch (e: Exception) { + logger.error("Services initialization failed: ${e.message}") + throw e + } + } + } + + /** + * Ensure services are ready before API calls (internal guard) + * O(1) after first successful initialization + */ + internal suspend fun ensureServicesReady() { + if (_areServicesReady) { + return // O(1) fast path + } + completeServicesInitialization() + } + + /** + * Ensure SDK is initialized (throws if not) + */ + internal fun requireInitialized() { + if (!_isInitialized) { + throw IllegalStateException("SDK not initialized. Call RunAnywhere.initialize() first.") + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // MARK: - SDK Reset + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Reset SDK state + * Clears all initialization state and releases resources + */ + suspend fun reset() { + logger.info("Resetting SDK state...") + + synchronized(lock) { + // Shutdown CppBridge + shutdownCppBridge() + + _isInitialized = false + _areServicesReady = false + _currentEnvironment = null + } + + logger.info("SDK state reset completed") + } + + /** + * Cleanup SDK resources without full reset + */ + suspend fun cleanup() { + logger.info("Cleaning up SDK resources...") + // Cleanup logic here + } + + // ═══════════════════════════════════════════════════════════════════════════ + // MARK: - CppBridge Integration (expect/actual pattern) + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Initialize CppBridge (Phase 1) + * Implementation is in jvmAndroidMain via expect/actual + */ + private fun initializeCppBridge(environment: SDKEnvironment, apiKey: String?, baseURL: String?) { + logger.debug("CppBridge initialization requested for $environment") + initializePlatformBridge(environment, apiKey, baseURL) + } + + /** + * Initialize CppBridge services (Phase 2) + * Implementation is in jvmAndroidMain via expect/actual + */ + private fun initializeCppBridgeServices() { + logger.debug("CppBridge services initialization requested") + initializePlatformBridgeServices() + } + + /** + * Shutdown CppBridge + * Implementation is in jvmAndroidMain via expect/actual + */ + private fun shutdownCppBridge() { + logger.debug("CppBridge shutdown requested") + shutdownPlatformBridge() + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Platform-specific bridge functions (expect/actual pattern) +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Initialize platform-specific bridge (Phase 1). + * On JVM/Android, this calls CppBridge.initialize() to load native libraries. + * + * @param environment SDK environment + * @param apiKey API key for authentication (required for production/staging) + * @param baseURL Backend API base URL (required for production/staging) + */ +internal expect fun initializePlatformBridge(environment: SDKEnvironment, apiKey: String?, baseURL: String?) + +/** + * Initialize platform-specific bridge services (Phase 2). + * On JVM/Android, this calls CppBridge.initializeServices(). + */ +internal expect fun initializePlatformBridgeServices() + +/** + * Shutdown platform-specific bridge. + * On JVM/Android, this calls CppBridge.shutdown(). + */ +internal expect fun shutdownPlatformBridge() diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/events/EventBus.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/events/EventBus.kt new file mode 100644 index 000000000..2415bf2f2 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/events/EventBus.kt @@ -0,0 +1,114 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * Simple pub/sub for SDK events. + * + * Mirrors Swift EventBus.swift exactly. + */ + +package com.runanywhere.sdk.public.events + +import com.runanywhere.sdk.foundation.SDKLogger +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.filter + +/** + * Central event bus for SDK-wide event distribution. + * + * Subscribe to events by category or to all events: + * + * ```kotlin + * // Subscribe to all events + * EventBus.events.collect { event -> + * println(event.type) + * } + * + * // Subscribe to specific category + * EventBus.events(EventCategory.LLM).collect { event -> + * println(event.type) + * } + * ``` + * + * Mirrors Swift EventBus exactly. + */ +object EventBus { + // MARK: - Publishers + + private val logger = SDKLogger.shared + + private val _events = + MutableSharedFlow( + replay = 0, + extraBufferCapacity = 64, + ) + + /** All events flow */ + val events: Flow = _events.asSharedFlow() + + // MARK: - Publishing + + /** + * Publish an event to all subscribers. + */ + fun publish(event: SDKEvent) { + logger.debug("Publishing event: ${event.type} (category: ${event.category.value})") + _events.tryEmit(event) + } + + // MARK: - Filtered Subscriptions + + /** + * Get events for a specific category. + */ + fun events(category: EventCategory): Flow { + return events.filter { it.category == category } + } + + /** + * Get events of a specific type. + */ + inline fun eventsOfType(): Flow { + return events.filter { it is T } as Flow + } + + // MARK: - Convenience Methods + + /** + * Get LLM events. + */ + val llmEvents: Flow + get() = events(EventCategory.LLM) + + /** + * Get STT events. + */ + val sttEvents: Flow + get() = events(EventCategory.STT) + + /** + * Get TTS events. + */ + val ttsEvents: Flow + get() = events(EventCategory.TTS) + + /** + * Get model events. + */ + val modelEvents: Flow + get() = events(EventCategory.MODEL) + + /** + * Get error events. + */ + val errorEvents: Flow + get() = events(EventCategory.ERROR) + + /** + * Get SDK lifecycle events. + */ + val sdkEvents: Flow + get() = events(EventCategory.SDK) +} diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/events/SDKEvent.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/events/SDKEvent.kt new file mode 100644 index 000000000..b12f8d751 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/events/SDKEvent.kt @@ -0,0 +1,304 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * Minimal event protocol for SDK events. + * All event logic and definitions are in C++ (rac_analytics_events.h). + * This Kotlin interface only provides the interface for bridged events. + * + * Mirrors Swift SDKEvent.swift exactly. + */ + +package com.runanywhere.sdk.public.events + +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +// MARK: - Event Destination + +/** + * Where an event should be routed (mirrors C++ rac_event_destination_t). + * Mirrors Swift EventDestination exactly. + */ +enum class EventDestination { + /** Only to public EventBus (app developers) */ + PUBLIC_ONLY, + + /** Only to analytics/telemetry (backend) */ + ANALYTICS_ONLY, + + /** Both destinations (default) */ + ALL, +} + +// MARK: - Event Category + +/** + * Event categories for filtering/grouping (mirrors C++ categories). + * Mirrors Swift EventCategory exactly. + */ +enum class EventCategory( + val value: String, +) { + SDK("sdk"), + MODEL("model"), + LLM("llm"), + STT("stt"), + TTS("tts"), + VOICE("voice"), + STORAGE("storage"), + DEVICE("device"), + NETWORK("network"), + ERROR("error"), +} + +// MARK: - SDK Event Interface + +/** + * Minimal interface for SDK events. + * + * Events originate from C++ and are bridged to Kotlin via EventBridge. + * App developers can subscribe to events via EventBus. + * + * Mirrors Swift SDKEvent protocol exactly. + */ +interface SDKEvent { + /** Unique identifier for this event instance */ + val id: String + + /** Event type string (from C++ event types) */ + val type: String + + /** Category for filtering/routing */ + val category: EventCategory + + /** When the event occurred (epoch milliseconds) */ + val timestamp: Long + + /** Optional session ID for grouping related events */ + val sessionId: String? + + /** Where to route this event */ + val destination: EventDestination + + /** Event properties as key-value pairs */ + val properties: Map +} + +// MARK: - Base Event Implementation + +/** + * Base implementation of SDKEvent with default values. + */ +@OptIn(ExperimentalUuidApi::class) +data class BaseSDKEvent( + override val type: String, + override val category: EventCategory, + override val id: String = Uuid.random().toString(), + override val timestamp: Long = System.currentTimeMillis(), + override val sessionId: String? = null, + override val destination: EventDestination = EventDestination.ALL, + override val properties: Map = emptyMap(), +) : SDKEvent + +// MARK: - Specific Event Types + +/** + * SDK lifecycle events. + */ +@OptIn(ExperimentalUuidApi::class) +data class SDKLifecycleEvent( + val lifecycleType: LifecycleType, + val version: String? = null, + override val id: String = Uuid.random().toString(), + override val timestamp: Long = System.currentTimeMillis(), + override val sessionId: String? = null, + override val destination: EventDestination = EventDestination.ALL, +) : SDKEvent { + override val type: String get() = "sdk.${lifecycleType.value}" + override val category: EventCategory get() = EventCategory.SDK + override val properties: Map + get() = + buildMap { + version?.let { put("version", it) } + } + + enum class LifecycleType( + val value: String, + ) { + INITIALIZED("initialized"), + SHUTDOWN("shutdown"), + ERROR("error"), + } +} + +/** + * Model-related events. + */ +@OptIn(ExperimentalUuidApi::class) +data class ModelEvent( + val eventType: ModelEventType, + val modelId: String, + val progress: Float? = null, + val error: String? = null, + override val id: String = Uuid.random().toString(), + override val timestamp: Long = System.currentTimeMillis(), + override val sessionId: String? = null, + override val destination: EventDestination = EventDestination.ALL, +) : SDKEvent { + override val type: String get() = "model.${eventType.value}" + override val category: EventCategory get() = EventCategory.MODEL + override val properties: Map + get() = + buildMap { + put("model_id", modelId) + progress?.let { put("progress", it.toString()) } + error?.let { put("error", it) } + } + + enum class ModelEventType( + val value: String, + ) { + DOWNLOAD_STARTED("download_started"), + DOWNLOAD_PROGRESS("download_progress"), + DOWNLOAD_COMPLETED("download_completed"), + DOWNLOAD_FAILED("download_failed"), + LOADED("loaded"), + UNLOADED("unloaded"), + DELETED("deleted"), + } +} + +/** + * LLM-related events. + */ +@OptIn(ExperimentalUuidApi::class) +data class LLMEvent( + val eventType: LLMEventType, + val modelId: String? = null, + val tokensGenerated: Int? = null, + val latencyMs: Double? = null, + val error: String? = null, + override val id: String = Uuid.random().toString(), + override val timestamp: Long = System.currentTimeMillis(), + override val sessionId: String? = null, + override val destination: EventDestination = EventDestination.ALL, +) : SDKEvent { + override val type: String get() = "llm.${eventType.value}" + override val category: EventCategory get() = EventCategory.LLM + override val properties: Map + get() = + buildMap { + modelId?.let { put("model_id", it) } + tokensGenerated?.let { put("tokens_generated", it.toString()) } + latencyMs?.let { put("latency_ms", it.toString()) } + error?.let { put("error", it) } + } + + enum class LLMEventType( + val value: String, + ) { + GENERATION_STARTED("generation_started"), + GENERATION_COMPLETED("generation_completed"), + GENERATION_FAILED("generation_failed"), + STREAM_TOKEN("stream_token"), + STREAM_COMPLETED("stream_completed"), + } +} + +/** + * STT-related events. + */ +@OptIn(ExperimentalUuidApi::class) +data class STTEvent( + val eventType: STTEventType, + val modelId: String? = null, + val transcript: String? = null, + val confidence: Float? = null, + val error: String? = null, + override val id: String = Uuid.random().toString(), + override val timestamp: Long = System.currentTimeMillis(), + override val sessionId: String? = null, + override val destination: EventDestination = EventDestination.ALL, +) : SDKEvent { + override val type: String get() = "stt.${eventType.value}" + override val category: EventCategory get() = EventCategory.STT + override val properties: Map + get() = + buildMap { + modelId?.let { put("model_id", it) } + transcript?.let { put("transcript", it) } + confidence?.let { put("confidence", it.toString()) } + error?.let { put("error", it) } + } + + enum class STTEventType( + val value: String, + ) { + TRANSCRIPTION_STARTED("transcription_started"), + TRANSCRIPTION_COMPLETED("transcription_completed"), + TRANSCRIPTION_FAILED("transcription_failed"), + PARTIAL_RESULT("partial_result"), + } +} + +/** + * TTS-related events. + */ +@OptIn(ExperimentalUuidApi::class) +data class TTSEvent( + val eventType: TTSEventType, + val voice: String? = null, + val durationMs: Double? = null, + val error: String? = null, + override val id: String = Uuid.random().toString(), + override val timestamp: Long = System.currentTimeMillis(), + override val sessionId: String? = null, + override val destination: EventDestination = EventDestination.ALL, +) : SDKEvent { + override val type: String get() = "tts.${eventType.value}" + override val category: EventCategory get() = EventCategory.TTS + override val properties: Map + get() = + buildMap { + voice?.let { put("voice", it) } + durationMs?.let { put("duration_ms", it.toString()) } + error?.let { put("error", it) } + } + + enum class TTSEventType( + val value: String, + ) { + SYNTHESIS_STARTED("synthesis_started"), + SYNTHESIS_COMPLETED("synthesis_completed"), + SYNTHESIS_FAILED("synthesis_failed"), + PLAYBACK_STARTED("playback_started"), + PLAYBACK_COMPLETED("playback_completed"), + } +} + +/** + * Error events. + */ +@OptIn(ExperimentalUuidApi::class) +data class ErrorEvent( + val errorCode: String, + val errorMessage: String, + val errorCategory: String? = null, + val component: String? = null, + override val id: String = Uuid.random().toString(), + override val timestamp: Long = System.currentTimeMillis(), + override val sessionId: String? = null, + override val destination: EventDestination = EventDestination.ALL, +) : SDKEvent { + override val type: String get() = "error.occurred" + override val category: EventCategory get() = EventCategory.ERROR + override val properties: Map + get() = + buildMap { + put("error_code", errorCode) + put("error_message", errorMessage) + errorCategory?.let { put("error_category", it) } + component?.let { put("component", it) } + } +} diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/extensions/ExtensionTypes.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/extensions/ExtensionTypes.kt new file mode 100644 index 000000000..4065c6cb2 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/extensions/ExtensionTypes.kt @@ -0,0 +1,78 @@ +package com.runanywhere.sdk.public.extensions + +import kotlinx.serialization.Serializable + +/** + * Extension types for RunAnywhere SDK + * Simple placeholder types to satisfy interface requirements + */ + +@Serializable +data class ComponentInitializationConfig( + val componentType: String, + val modelId: String? = null, + val priority: Int = 0, +) + +@Serializable +data class ComponentInitializationResult( + val success: Boolean, + val error: String? = null, + val initTime: Long = 0, +) + +@Serializable +data class ConversationConfiguration( + val id: String, + val systemPrompt: String? = null, + val maxTokens: Int = 1000, +) + +@Serializable +data class ConversationSession( + val id: String, + val configuration: ConversationConfiguration, + val startTime: Long = System.currentTimeMillis(), +) + +@Serializable +data class CostTrackingConfig( + val enabled: Boolean = true, + val detailedBreakdown: Boolean = false, + val alertThreshold: Float? = null, +) + +@Serializable +data class CostStatistics( + val totalCost: Float = 0.0f, + val tokenCount: Int = 0, + val requestCount: Int = 0, + val period: TimePeriod = TimePeriod.DAILY, +) { + enum class TimePeriod { + HOURLY, + DAILY, + WEEKLY, + MONTHLY, + YEARLY, + } +} + +@Serializable +data class PipelineResult( + val success: Boolean, + val outputs: Map = emptyMap(), + val error: String? = null, +) + +@Serializable +data class RoutingPolicy( + val preferOnDevice: Boolean = true, + val maxLatency: Int? = null, + val costOptimization: Boolean = true, +) + +// Voice-related types are defined in their respective feature packages: +// - STTOptions, STTResult, etc. -> features/stt/STTModels.kt +// - SpeakerSegment -> features/speakerdiarization/SpeakerDiarizationModels.kt +// - WordTimestamp -> features/stt/STTModels.kt diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/extensions/LLM/LLMTypes.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/extensions/LLM/LLMTypes.kt new file mode 100644 index 000000000..5e8b549d1 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/extensions/LLM/LLMTypes.kt @@ -0,0 +1,257 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * Public types for LLM text generation. + * These are thin wrappers over C++ types in rac_llm_types.h + * + * Mirrors Swift LLMTypes.swift exactly. + */ + +package com.runanywhere.sdk.public.extensions.LLM + +import com.runanywhere.sdk.core.types.ComponentConfiguration +import com.runanywhere.sdk.core.types.InferenceFramework +import com.runanywhere.sdk.core.types.SDKComponent +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.flow.Flow +import kotlinx.serialization.Serializable + +// MARK: - LLM Configuration + +/** + * Configuration for LLM component. + * Mirrors Swift LLMConfiguration exactly. + */ +@Serializable +data class LLMConfiguration( + override val modelId: String? = null, + val contextLength: Int = 2048, + val temperature: Double = 0.7, + val maxTokens: Int = 100, + val systemPrompt: String? = null, + val streamingEnabled: Boolean = true, + override val preferredFramework: InferenceFramework? = null, +) : ComponentConfiguration { + val componentType: SDKComponent get() = SDKComponent.LLM + + /** + * Validate the configuration. + * @throws IllegalArgumentException if validation fails + */ + fun validate() { + require(contextLength in 1..32768) { + "Context length must be between 1 and 32768" + } + require(temperature in 0.0..2.0) { + "Temperature must be between 0 and 2.0" + } + require(maxTokens in 1..contextLength) { + "Max tokens must be between 1 and context length" + } + } + + /** + * Builder pattern for LLMConfiguration. + */ + class Builder( + private var modelId: String? = null, + ) { + private var contextLength: Int = 2048 + private var temperature: Double = 0.7 + private var maxTokens: Int = 100 + private var systemPrompt: String? = null + private var streamingEnabled: Boolean = true + private var preferredFramework: InferenceFramework? = null + + fun contextLength(length: Int) = apply { contextLength = length } + + fun temperature(temp: Double) = apply { temperature = temp } + + fun maxTokens(tokens: Int) = apply { maxTokens = tokens } + + fun systemPrompt(prompt: String?) = apply { systemPrompt = prompt } + + fun streamingEnabled(enabled: Boolean) = apply { streamingEnabled = enabled } + + fun preferredFramework(framework: InferenceFramework?) = apply { preferredFramework = framework } + + fun build() = + LLMConfiguration( + modelId = modelId, + contextLength = contextLength, + temperature = temperature, + maxTokens = maxTokens, + systemPrompt = systemPrompt, + streamingEnabled = streamingEnabled, + preferredFramework = preferredFramework, + ) + } + + companion object { + /** + * Create configuration with builder pattern. + */ + fun builder(modelId: String? = null) = Builder(modelId) + } +} + +// MARK: - LLM Generation Options + +/** + * Options for text generation. + * Mirrors Swift LLMGenerationOptions exactly. + */ +@Serializable +data class LLMGenerationOptions( + val maxTokens: Int = 100, + val temperature: Float = 0.8f, + val topP: Float = 1.0f, + val stopSequences: List = emptyList(), + val streamingEnabled: Boolean = false, + val preferredFramework: InferenceFramework? = null, + val structuredOutput: StructuredOutputConfig? = null, + val systemPrompt: String? = null, +) { + companion object { + val DEFAULT = LLMGenerationOptions() + } +} + +// MARK: - LLM Generation Result + +/** + * Result of a text generation request. + * Mirrors Swift LLMGenerationResult exactly. + */ +@Serializable +data class LLMGenerationResult( + /** Generated text (with thinking content removed if extracted) */ + val text: String, + /** Thinking/reasoning content extracted from the response */ + val thinkingContent: String? = null, + /** Number of input/prompt tokens (from tokenizer) */ + val inputTokens: Int = 0, + /** Number of tokens used (output tokens) */ + val tokensUsed: Int, + /** Model used for generation */ + val modelUsed: String, + /** Total latency in milliseconds */ + val latencyMs: Double, + /** Framework used for generation */ + val framework: String? = null, + /** Tokens generated per second */ + val tokensPerSecond: Double = 0.0, + /** Time to first token in milliseconds (only for streaming) */ + val timeToFirstTokenMs: Double? = null, + /** Structured output validation result */ + val structuredOutputValidation: StructuredOutputValidation? = null, + /** Number of tokens used for thinking/reasoning */ + val thinkingTokens: Int? = null, + /** Number of tokens in the actual response content */ + val responseTokens: Int = tokensUsed, +) + +// MARK: - LLM Streaming Result + +/** + * Container for streaming generation with metrics. + * Mirrors Swift LLMStreamingResult. + * + * In Kotlin, we use Flow instead of AsyncThrowingStream. + */ +data class LLMStreamingResult( + /** Flow of tokens as they are generated */ + val stream: Flow, + /** Deferred result that completes with final generation result including metrics */ + val result: Deferred, +) + +// MARK: - Thinking Tag Pattern + +/** + * Pattern for extracting thinking/reasoning content from model output. + * Mirrors Swift ThinkingTagPattern exactly. + */ +@Serializable +data class ThinkingTagPattern( + val openingTag: String, + val closingTag: String, +) { + companion object { + /** Default pattern used by models like DeepSeek and Hermes */ + val DEFAULT = ThinkingTagPattern("", "") + + /** Alternative pattern with full "thinking" word */ + val THINKING = ThinkingTagPattern("", "") + + /** Custom pattern for models that use different tags */ + fun custom(opening: String, closing: String) = ThinkingTagPattern(opening, closing) + } +} + +// MARK: - Structured Output Types + +/** + * Interface for types that can be generated as structured output from LLMs. + * Mirrors Swift Generatable protocol. + */ +interface Generatable { + companion object { + /** Default JSON schema */ + val DEFAULT_JSON_SCHEMA = + """ + { + "type": "object", + "additionalProperties": false + } + """.trimIndent() + } +} + +/** + * Structured output configuration. + * Note: In Kotlin, we use KClass instead of Type. + */ +@Serializable +data class StructuredOutputConfig( + /** The type name to generate */ + val typeName: String, + /** Whether to include schema in prompt */ + val includeSchemaInPrompt: Boolean = true, + /** JSON schema for the type */ + val jsonSchema: String = Generatable.DEFAULT_JSON_SCHEMA, +) + +/** + * Hints for customizing structured output generation. + * Mirrors Swift GenerationHints exactly. + */ +@Serializable +data class GenerationHints( + val temperature: Float? = null, + val maxTokens: Int? = null, + val systemRole: String? = null, +) + +/** + * Token emitted during streaming. + * Mirrors Swift StreamToken exactly. + */ +@Serializable +data class StreamToken( + val text: String, + val timestamp: Long = System.currentTimeMillis(), + val tokenIndex: Int, +) + +/** + * Structured output validation result. + * Mirrors Swift StructuredOutputValidation exactly. + */ +@Serializable +data class StructuredOutputValidation( + val isValid: Boolean, + val containsJSON: Boolean, + val error: String?, +) diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/extensions/Models/ModelTypes.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/extensions/Models/ModelTypes.kt new file mode 100644 index 000000000..3cc67ed5d --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/extensions/Models/ModelTypes.kt @@ -0,0 +1,379 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * Public types for model management. + * These are thin wrappers over C++ types in rac_model_types.h + * Business logic (format support, capability checks) is in C++. + * + * Mirrors Swift ModelTypes.swift exactly. + */ + +package com.runanywhere.sdk.public.extensions.Models + +import com.runanywhere.sdk.public.extensions.LLM.ThinkingTagPattern +import kotlinx.serialization.Serializable + +// MARK: - Model Source + +/** + * Source of model data (where the model info came from). + * Mirrors Swift ModelSource exactly. + */ +@Serializable +enum class ModelSource( + val value: String, +) { + /** Model info came from remote API (backend model catalog) */ + REMOTE("remote"), + + /** Model info was provided locally via SDK input (addModel calls) */ + LOCAL("local"), +} + +// MARK: - Model Format + +/** + * Model formats supported. + * Mirrors Swift ModelFormat exactly. + */ +@Serializable +enum class ModelFormat( + val value: String, +) { + ONNX("onnx"), + ORT("ort"), + GGUF("gguf"), + BIN("bin"), + UNKNOWN("unknown"), +} + +// MARK: - Model Selection Context + +/** + * Context for model selection UI - determines which models to show. + * Mirrors Swift ModelSelectionContext exactly. + */ +@Serializable +enum class ModelSelectionContext( + val value: String, +) { + /** Select a language model (LLM) */ + LLM("llm"), + + /** Select a speech-to-text model */ + STT("stt"), + + /** Select a text-to-speech model/voice */ + TTS("tts"), + + /** Select models for voice agent (all 3 types) */ + VOICE("voice"), + ; + + /** Human-readable title for the selection context */ + val title: String + get() = + when (this) { + LLM -> "Select LLM Model" + STT -> "Select STT Model" + TTS -> "Select TTS Voice" + VOICE -> "Select Voice Models" + } + + /** Check if a category is relevant for this selection context */ + fun isCategoryRelevant(category: ModelCategory): Boolean = + when (this) { + LLM -> category == ModelCategory.LANGUAGE + STT -> category == ModelCategory.SPEECH_RECOGNITION + TTS -> category == ModelCategory.SPEECH_SYNTHESIS + VOICE -> + category == ModelCategory.LANGUAGE || + category == ModelCategory.SPEECH_RECOGNITION || + category == ModelCategory.SPEECH_SYNTHESIS + } + + /** Check if a framework is relevant for this selection context */ + fun isFrameworkRelevant(framework: com.runanywhere.sdk.core.types.InferenceFramework): Boolean = + when (this) { + LLM -> + framework == com.runanywhere.sdk.core.types.InferenceFramework.LLAMA_CPP || + framework == com.runanywhere.sdk.core.types.InferenceFramework.FOUNDATION_MODELS + STT -> framework == com.runanywhere.sdk.core.types.InferenceFramework.ONNX + TTS -> + framework == com.runanywhere.sdk.core.types.InferenceFramework.ONNX || + framework == com.runanywhere.sdk.core.types.InferenceFramework.SYSTEM_TTS || + framework == com.runanywhere.sdk.core.types.InferenceFramework.FLUID_AUDIO + VOICE -> + LLM.isFrameworkRelevant(framework) || + STT.isFrameworkRelevant(framework) || + TTS.isFrameworkRelevant(framework) + } +} + +// MARK: - Model Category + +/** + * Defines the category/type of a model based on its input/output modality. + * Mirrors Swift ModelCategory exactly. + */ +@Serializable +enum class ModelCategory( + val value: String, +) { + LANGUAGE("language"), // Text-to-text models (LLMs) + SPEECH_RECOGNITION("speech-recognition"), // Voice-to-text models (ASR) + SPEECH_SYNTHESIS("speech-synthesis"), // Text-to-voice models (TTS) + VISION("vision"), // Image understanding models + IMAGE_GENERATION("image-generation"), // Text-to-image models + MULTIMODAL("multimodal"), // Models that handle multiple modalities + AUDIO("audio"), // Audio processing (diarization, etc.) + ; + + /** Whether this category typically requires context length */ + val requiresContextLength: Boolean + get() = this == LANGUAGE || this == MULTIMODAL + + /** Whether this category typically supports thinking/reasoning */ + val supportsThinking: Boolean + get() = this == LANGUAGE || this == MULTIMODAL +} + +// MARK: - Archive Types + +/** + * Supported archive formats for model packaging. + * Mirrors Swift ArchiveType exactly. + */ +@Serializable +enum class ArchiveType( + val value: String, +) { + ZIP("zip"), + TAR_BZ2("tar.bz2"), + TAR_GZ("tar.gz"), + TAR_XZ("tar.xz"), + ; + + /** File extension for this archive type */ + val fileExtension: String get() = value + + companion object { + /** Detect archive type from URL path */ + fun from(path: String): ArchiveType? { + val lowercased = path.lowercase() + return when { + lowercased.endsWith(".tar.bz2") || lowercased.endsWith(".tbz2") -> TAR_BZ2 + lowercased.endsWith(".tar.gz") || lowercased.endsWith(".tgz") -> TAR_GZ + lowercased.endsWith(".tar.xz") || lowercased.endsWith(".txz") -> TAR_XZ + lowercased.endsWith(".zip") -> ZIP + else -> null + } + } + } +} + +/** + * Describes the internal structure of an archive after extraction. + * Mirrors Swift ArchiveStructure exactly. + */ +@Serializable +enum class ArchiveStructure( + val value: String, +) { + SINGLE_FILE_NESTED("singleFileNested"), + DIRECTORY_BASED("directoryBased"), + NESTED_DIRECTORY("nestedDirectory"), + UNKNOWN("unknown"), +} + +// MARK: - Expected Model Files + +/** + * Describes what files are expected after model extraction/download. + * Mirrors Swift ExpectedModelFiles exactly. + */ +@Serializable +data class ExpectedModelFiles( + val requiredPatterns: List = emptyList(), + val optionalPatterns: List = emptyList(), + val description: String? = null, +) { + companion object { + val NONE = ExpectedModelFiles() + } +} + +/** + * Describes a file that needs to be downloaded as part of a multi-file model. + * Mirrors Swift ModelFileDescriptor exactly. + */ +@Serializable +data class ModelFileDescriptor( + val relativePath: String, + val destinationPath: String, + val isRequired: Boolean = true, +) + +// MARK: - Model Artifact Type + +/** + * Describes how a model is packaged and what processing is needed after download. + * Mirrors Swift ModelArtifactType exactly. + */ +@Serializable +sealed class ModelArtifactType { + @Serializable + data class SingleFile( + val expectedFiles: ExpectedModelFiles = ExpectedModelFiles.NONE, + ) : ModelArtifactType() + + @Serializable + data class Archive( + val archiveType: ArchiveType, + val structure: ArchiveStructure, + val expectedFiles: ExpectedModelFiles = ExpectedModelFiles.NONE, + ) : ModelArtifactType() + + @Serializable + data class MultiFile( + val files: List, + ) : ModelArtifactType() + + @Serializable + data class Custom( + val strategyId: String, + ) : ModelArtifactType() + + @Serializable + data object BuiltIn : ModelArtifactType() + + val requiresExtraction: Boolean + get() = this is Archive + + val requiresDownload: Boolean + get() = this !is BuiltIn + + val expectedFilesValue: ExpectedModelFiles + get() = + when (this) { + is SingleFile -> expectedFiles + is Archive -> expectedFiles + else -> ExpectedModelFiles.NONE + } + + val displayName: String + get() = + when (this) { + is SingleFile -> "Single File" + is Archive -> "${archiveType.value.uppercase()} Archive" + is MultiFile -> "Multi-File (${files.size} files)" + is Custom -> "Custom ($strategyId)" + is BuiltIn -> "Built-in" + } + + companion object { + /** Infer artifact type from download URL */ + @Suppress("UNUSED_PARAMETER") + fun infer(url: String?, format: ModelFormat): ModelArtifactType { + // format parameter reserved for future use when format-specific inference is needed + if (url == null) return SingleFile() + val archiveType = ArchiveType.from(url) + return if (archiveType != null) { + Archive(archiveType, ArchiveStructure.UNKNOWN) + } else { + SingleFile() + } + } + } +} + +// MARK: - Model Info + +/** + * Information about a model - in-memory entity. + * Mirrors Swift ModelInfo exactly. + */ +@Serializable +data class ModelInfo( + // Essential identifiers + val id: String, + val name: String, + val category: ModelCategory, + // Format and location + val format: ModelFormat, + val downloadURL: String? = null, + var localPath: String? = null, + // Artifact type + val artifactType: ModelArtifactType = ModelArtifactType.SingleFile(), + // Size information + val downloadSize: Long? = null, + // Framework + val framework: com.runanywhere.sdk.core.types.InferenceFramework, + // Model-specific capabilities + val contextLength: Int? = null, + val supportsThinking: Boolean = false, + val thinkingPattern: ThinkingTagPattern? = null, + // Optional metadata + val description: String? = null, + // Tracking fields + val source: ModelSource = ModelSource.REMOTE, + val createdAt: Long = System.currentTimeMillis(), + var updatedAt: Long = System.currentTimeMillis(), +) { + /** Whether this model is downloaded and available locally */ + val isDownloaded: Boolean + get() { + val path = localPath ?: return false + if (path.startsWith("builtin://")) return true + // Actual file check would be done in platform-specific code + return path.isNotEmpty() + } + + /** Whether this model is available for use */ + val isAvailable: Boolean get() = isDownloaded + + /** Whether this is a built-in platform model */ + val isBuiltIn: Boolean + get() { + if (artifactType is ModelArtifactType.BuiltIn) return true + val path = localPath + if (path != null && path.startsWith("builtin://")) return true + return framework == com.runanywhere.sdk.core.types.InferenceFramework.FOUNDATION_MODELS || + framework == com.runanywhere.sdk.core.types.InferenceFramework.SYSTEM_TTS + } +} + +// MARK: - Download Progress + +/** + * Progress information for model downloads. + */ +@Serializable +data class DownloadProgress( + /** Model ID being downloaded */ + val modelId: String, + /** Progress percentage (0.0 to 1.0) */ + val progress: Float, + /** Bytes downloaded so far */ + val bytesDownloaded: Long, + /** Total bytes to download (null if unknown) */ + val totalBytes: Long?, + /** Download state */ + val state: DownloadState, + /** Error message if state is ERROR */ + val error: String? = null, +) + +/** + * State of a model download. + */ +@Serializable +enum class DownloadState { + PENDING, + DOWNLOADING, + EXTRACTING, + COMPLETED, + ERROR, + CANCELLED, +} diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/extensions/RunAnywhere+Logging.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/extensions/RunAnywhere+Logging.kt new file mode 100644 index 000000000..c7386240c --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/extensions/RunAnywhere+Logging.kt @@ -0,0 +1,76 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * Public API for logging configuration. + * + * Mirrors Swift RunAnywhere+Logging.swift pattern. + */ + +package com.runanywhere.sdk.public.extensions + +import com.runanywhere.sdk.public.RunAnywhere + +// MARK: - Log Level + +/** + * Log level for SDK logging. + */ +enum class LogLevel( + val value: Int, +) { + /** No logging */ + NONE(0), + + /** Error level logging */ + ERROR(1), + + /** Warning level logging */ + WARNING(2), + + /** Info level logging */ + INFO(3), + + /** Debug level logging */ + DEBUG(4), + + /** Verbose level logging (all messages) */ + VERBOSE(5), +} + +// MARK: - Logging Configuration + +/** + * Set the SDK log level. + * + * @param level Log level to set + */ +fun RunAnywhere.setLogLevel(level: LogLevel) { + // Delegate to CppBridge for actual implementation + setLogLevelInternal(level) +} + +/** + * Internal function to set log level via CppBridge. + */ +internal expect fun RunAnywhere.setLogLevelInternal(level: LogLevel) + +/** + * Enable or disable file logging. + * + * @param enabled Whether to enable file logging + * @param path Optional path for log file + */ +expect fun RunAnywhere.setFileLogging(enabled: Boolean, path: String? = null) + +/** + * Get the current log level. + * + * @return Current log level + */ +expect fun RunAnywhere.getLogLevel(): LogLevel + +/** + * Flush pending log messages. + */ +expect fun RunAnywhere.flushLogs() diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/extensions/RunAnywhere+ModelManagement.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/extensions/RunAnywhere+ModelManagement.kt new file mode 100644 index 000000000..1a3706793 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/extensions/RunAnywhere+ModelManagement.kt @@ -0,0 +1,282 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * Public API for model management operations. + * Calls C++ directly via CppBridge.ModelRegistry for all operations. + * + * Mirrors Swift RunAnywhere+ModelManagement.swift pattern. + */ + +package com.runanywhere.sdk.public.extensions + +import com.runanywhere.sdk.core.types.InferenceFramework +import com.runanywhere.sdk.foundation.SDKLogger +import com.runanywhere.sdk.public.RunAnywhere +import com.runanywhere.sdk.public.extensions.Models.ArchiveStructure +import com.runanywhere.sdk.public.extensions.Models.ArchiveType +import com.runanywhere.sdk.public.extensions.Models.DownloadProgress +import com.runanywhere.sdk.public.extensions.Models.ModelArtifactType +import com.runanywhere.sdk.public.extensions.Models.ModelCategory +import com.runanywhere.sdk.public.extensions.Models.ModelInfo +import kotlinx.coroutines.flow.Flow + +// MARK: - Model Registration + +/** + * Register a model from a download URL. + * Use this to add models for development or offline use. + * + * Mirrors Swift RunAnywhere.registerModel() exactly. + * + * @param id Explicit model ID. If null, a stable ID is generated from the URL filename. + * @param name Display name for the model + * @param url Download URL for the model (e.g., HuggingFace) + * @param framework Target inference framework + * @param modality Model category (default: LANGUAGE for LLMs) + * @param artifactType How the model is packaged (archive, single file, etc.). If null, inferred from URL. + * @param memoryRequirement Estimated memory usage in bytes + * @param supportsThinking Whether the model supports reasoning/thinking + * @return The created ModelInfo + */ +fun RunAnywhere.registerModel( + id: String? = null, + name: String, + url: String, + framework: InferenceFramework, + modality: ModelCategory = ModelCategory.LANGUAGE, + artifactType: ModelArtifactType? = null, + memoryRequirement: Long? = null, + supportsThinking: Boolean = false, +): ModelInfo { + val logger = SDKLogger.models + + // Generate model ID from URL filename if not provided + val modelId = id ?: generateModelIdFromUrl(url) + logger.debug("Registering model: $modelId (name: $name)") + + // Detect format from URL extension + val format = detectFormatFromUrl(url) + logger.debug("Detected format: ${format.value} for model: $modelId") + + // Infer artifact type if not provided + val effectiveArtifactType = artifactType ?: inferArtifactType(url) + logger.debug("Artifact type: ${effectiveArtifactType.displayName} for model: $modelId") + + // Create ModelInfo + val modelInfo = + ModelInfo( + id = modelId, + name = name, + category = modality, + format = format, + downloadURL = url, + localPath = null, + artifactType = effectiveArtifactType, + downloadSize = memoryRequirement, + framework = framework, + contextLength = if (modality.requiresContextLength) 2048 else null, + supportsThinking = supportsThinking, + description = "User-added model", + source = com.runanywhere.sdk.public.extensions.Models.ModelSource.LOCAL, + ) + + // Save to registry (fire-and-forget) + registerModelInternal(modelInfo) + + logger.info("Registered model: $modelId (category: ${modality.value}, framework: ${framework.rawValue})") + return modelInfo +} + +/** + * Internal implementation to save model to registry. + * Implemented via expect/actual for platform-specific behavior. + */ +internal expect fun registerModelInternal(modelInfo: ModelInfo) + +// MARK: - Helper Functions + +private fun generateModelIdFromUrl(url: String): String { + var filename = url.substringAfterLast('/') + val knownExtensions = listOf("gz", "bz2", "tar", "zip", "gguf", "onnx", "ort", "bin") + while (true) { + val ext = filename.substringAfterLast('.', "") + if (ext.isNotEmpty() && knownExtensions.contains(ext.lowercase())) { + filename = filename.dropLast(ext.length + 1) + } else { + break + } + } + return filename +} + +private fun detectFormatFromUrl(url: String): com.runanywhere.sdk.public.extensions.Models.ModelFormat { + val ext = url.substringAfterLast('.').lowercase() + return when (ext) { + "onnx" -> com.runanywhere.sdk.public.extensions.Models.ModelFormat.ONNX + "ort" -> com.runanywhere.sdk.public.extensions.Models.ModelFormat.ORT + "gguf" -> com.runanywhere.sdk.public.extensions.Models.ModelFormat.GGUF + "bin" -> com.runanywhere.sdk.public.extensions.Models.ModelFormat.BIN + else -> com.runanywhere.sdk.public.extensions.Models.ModelFormat.UNKNOWN + } +} + +private fun inferArtifactType(url: String): ModelArtifactType { + val lowercased = url.lowercase() + return when { + lowercased.endsWith(".tar.gz") || lowercased.endsWith(".tgz") -> + ModelArtifactType.Archive(ArchiveType.TAR_GZ, ArchiveStructure.NESTED_DIRECTORY) + lowercased.endsWith(".tar.bz2") || lowercased.endsWith(".tbz2") -> + ModelArtifactType.Archive(ArchiveType.TAR_BZ2, ArchiveStructure.NESTED_DIRECTORY) + lowercased.endsWith(".tar.xz") || lowercased.endsWith(".txz") -> + ModelArtifactType.Archive(ArchiveType.TAR_XZ, ArchiveStructure.NESTED_DIRECTORY) + lowercased.endsWith(".zip") -> + ModelArtifactType.Archive(ArchiveType.ZIP, ArchiveStructure.NESTED_DIRECTORY) + else -> ModelArtifactType.SingleFile() + } +} + +// MARK: - Model Discovery + +/** + * Get all available models (both downloaded and remote). + * + * @return List of all model info + */ +expect suspend fun RunAnywhere.availableModels(): List + +/** + * Get models by category. + * + * @param category Model category to filter by + * @return List of models in the specified category + */ +expect suspend fun RunAnywhere.models(category: ModelCategory): List + +/** + * Get downloaded models. + * + * @return List of downloaded model info + */ +expect suspend fun RunAnywhere.downloadedModels(): List + +/** + * Get model info by ID. + * + * @param modelId Model identifier + * @return Model info or null if not found + */ +expect suspend fun RunAnywhere.model(modelId: String): ModelInfo? + +// MARK: - Model Downloads + +/** + * Download a model. + * + * @param modelId Model identifier to download + * @return Flow of download progress + */ +expect fun RunAnywhere.downloadModel(modelId: String): Flow + +/** + * Cancel a model download. + * + * @param modelId Model identifier + */ +expect suspend fun RunAnywhere.cancelDownload(modelId: String) + +/** + * Check if a model is downloaded. + * + * @param modelId Model identifier + * @return True if the model is downloaded + */ +expect suspend fun RunAnywhere.isModelDownloaded(modelId: String): Boolean + +// MARK: - Model Management + +/** + * Delete a downloaded model. + * + * @param modelId Model identifier + */ +expect suspend fun RunAnywhere.deleteModel(modelId: String) + +/** + * Delete all downloaded models. + */ +expect suspend fun RunAnywhere.deleteAllModels() + +/** + * Refresh the model registry from remote. + */ +expect suspend fun RunAnywhere.refreshModelRegistry() + +// MARK: - Model Loading + +/** + * Load an LLM model. + * + * @param modelId Model identifier + */ +expect suspend fun RunAnywhere.loadLLMModel(modelId: String) + +/** + * Unload the currently loaded LLM model. + */ +expect suspend fun RunAnywhere.unloadLLMModel() + +/** + * Check if an LLM model is loaded. + * + * @return True if a model is loaded + */ +expect suspend fun RunAnywhere.isLLMModelLoaded(): Boolean + +/** + * Get the currently loaded LLM model ID. + * + * This is a synchronous property that returns the ID of the currently loaded model, + * or null if no model is loaded. Mirrors iOS RunAnywhere.getCurrentModelId(). + */ +expect val RunAnywhere.currentLLMModelId: String? + +/** + * Get the currently loaded LLM model info. + * + * This is a convenience property that combines currentLLMModelId with + * a lookup in the available models registry. + * + * @return The currently loaded ModelInfo, or null if no model is loaded + */ +expect suspend fun RunAnywhere.currentLLMModel(): ModelInfo? + +/** + * Get the currently loaded STT model info. + * + * @return The currently loaded STT ModelInfo, or null if no model is loaded + */ +expect suspend fun RunAnywhere.currentSTTModel(): ModelInfo? + +/** + * Load an STT model. + * + * @param modelId Model identifier + */ +expect suspend fun RunAnywhere.loadSTTModel(modelId: String) + +// MARK: - Model Assignments + +/** + * Fetch model assignments for the current device from the backend. + * + * This method fetches models assigned to this device based on device type and platform. + * Results are cached and saved to the model registry automatically. + * + * Note: Model assignments are automatically fetched during SDK initialization + * when services are initialized (Phase 2). This method allows manual refresh. + * + * @param forceRefresh If true, bypass cache and fetch fresh data from backend + * @return List of ModelInfo objects assigned to this device + */ +expect suspend fun RunAnywhere.fetchModelAssignments(forceRefresh: Boolean = false): List diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/extensions/RunAnywhere+STT.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/extensions/RunAnywhere+STT.kt new file mode 100644 index 000000000..9090a563e --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/extensions/RunAnywhere+STT.kt @@ -0,0 +1,96 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * Public API for Speech-to-Text operations. + * Calls C++ directly via CppBridge.STT for all operations. + * Events are emitted by C++ layer via CppEventBridge. + * + * Mirrors Swift RunAnywhere+STT.swift exactly. + */ + +package com.runanywhere.sdk.public.extensions + +import com.runanywhere.sdk.public.RunAnywhere +import com.runanywhere.sdk.public.extensions.STT.STTOptions +import com.runanywhere.sdk.public.extensions.STT.STTOutput +import com.runanywhere.sdk.public.extensions.STT.STTTranscriptionResult + +// MARK: - Simple Transcription + +/** + * Simple voice transcription using default model. + * + * @param audioData Audio data to transcribe + * @return Transcribed text + */ +expect suspend fun RunAnywhere.transcribe(audioData: ByteArray): String + +// MARK: - Model Loading + +/** + * Unload the currently loaded STT model. + */ +expect suspend fun RunAnywhere.unloadSTTModel() + +/** + * Check if an STT model is loaded. + */ +expect suspend fun RunAnywhere.isSTTModelLoaded(): Boolean + +/** + * Get the currently loaded STT model ID. + * + * This is a synchronous property that returns the ID of the currently loaded STT model, + * or null if no model is loaded. + */ +expect val RunAnywhere.currentSTTModelId: String? + +/** + * Check if an STT model is loaded (non-suspend version for quick checks). + * + * This accesses cached state and doesn't require suspension. + */ +expect val RunAnywhere.isSTTModelLoadedSync: Boolean + +// MARK: - Transcription with Options + +/** + * Transcribe audio data to text with options. + * + * @param audioData Raw audio data + * @param options Transcription options + * @return Transcription output with text and metadata + */ +expect suspend fun RunAnywhere.transcribeWithOptions( + audioData: ByteArray, + options: STTOptions, +): STTOutput + +// MARK: - Streaming Transcription + +/** + * Transcribe audio with streaming callbacks. + * + * @param audioData Audio data to transcribe + * @param options Transcription options + * @param onPartialResult Callback for partial results + * @return Final transcription output + */ +expect suspend fun RunAnywhere.transcribeStream( + audioData: ByteArray, + options: STTOptions = STTOptions(), + onPartialResult: (STTTranscriptionResult) -> Unit, +): STTOutput + +/** + * Process audio samples for streaming transcription. + * + * @param samples Audio samples as float array + */ +expect suspend fun RunAnywhere.processStreamingAudio(samples: FloatArray) + +/** + * Stop streaming transcription. + */ +expect suspend fun RunAnywhere.stopStreamingTranscription() diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/extensions/RunAnywhere+Storage.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/extensions/RunAnywhere+Storage.kt new file mode 100644 index 000000000..1f1f03f53 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/extensions/RunAnywhere+Storage.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * Public API for storage operations. + * Provides storage information and management. + * + * Mirrors Swift RunAnywhere+Storage.swift pattern. + */ + +package com.runanywhere.sdk.public.extensions + +import com.runanywhere.sdk.public.RunAnywhere +import com.runanywhere.sdk.public.extensions.Storage.StorageAvailability +import com.runanywhere.sdk.public.extensions.Storage.StorageInfo + +// MARK: - Storage Information + +/** + * Get complete storage information. + * + * @return Storage info with device, app, and model storage details + */ +expect suspend fun RunAnywhere.storageInfo(): StorageInfo + +/** + * Check if storage is available for a download. + * + * @param requiredBytes Required bytes for the operation + * @return Storage availability result + */ +expect suspend fun RunAnywhere.checkStorageAvailability(requiredBytes: Long): StorageAvailability + +// MARK: - Cache Management + +/** + * Get cache size in bytes. + * + * @return Cache size + */ +expect suspend fun RunAnywhere.cacheSize(): Long + +/** + * Clear the SDK cache. + */ +expect suspend fun RunAnywhere.clearCache() + +// MARK: - Storage Limits + +/** + * Set maximum storage limit for models. + * + * @param maxBytes Maximum bytes to use for model storage + */ +expect suspend fun RunAnywhere.setMaxModelStorage(maxBytes: Long) + +/** + * Get current storage used by models. + * + * @return Total bytes used by downloaded models + */ +expect suspend fun RunAnywhere.modelStorageUsed(): Long diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/extensions/RunAnywhere+TTS.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/extensions/RunAnywhere+TTS.kt new file mode 100644 index 000000000..5c0b29b73 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/extensions/RunAnywhere+TTS.kt @@ -0,0 +1,128 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * Public API for Text-to-Speech operations. + * Calls C++ directly via CppBridge.TTS for all operations. + * Events are emitted by C++ layer via CppEventBridge. + * + * Mirrors Swift RunAnywhere+TTS.swift exactly. + */ + +package com.runanywhere.sdk.public.extensions + +import com.runanywhere.sdk.public.RunAnywhere +import com.runanywhere.sdk.public.extensions.TTS.TTSOptions +import com.runanywhere.sdk.public.extensions.TTS.TTSOutput +import com.runanywhere.sdk.public.extensions.TTS.TTSSpeakResult + +// MARK: - Voice Loading + +/** + * Load a TTS voice. + * + * @param voiceId The voice identifier + * @throws Error if loading fails + */ +expect suspend fun RunAnywhere.loadTTSVoice(voiceId: String) + +/** + * Unload the currently loaded TTS voice. + */ +expect suspend fun RunAnywhere.unloadTTSVoice() + +/** + * Check if a TTS voice is loaded. + */ +expect suspend fun RunAnywhere.isTTSVoiceLoaded(): Boolean + +/** + * Get the currently loaded TTS voice ID. + * + * This is a synchronous property that returns the ID of the currently loaded TTS voice, + * or null if no voice is loaded. + */ +expect val RunAnywhere.currentTTSVoiceId: String? + +/** + * Check if a TTS voice is loaded (non-suspend version for quick checks). + * + * This accesses cached state and doesn't require suspension. + */ +expect val RunAnywhere.isTTSVoiceLoadedSync: Boolean + +/** + * Get available TTS voices. + */ +expect suspend fun RunAnywhere.availableTTSVoices(): List + +// MARK: - Synthesis + +/** + * Synthesize text to speech. + * + * @param text Text to synthesize + * @param options Synthesis options + * @return TTS output with audio data + */ +expect suspend fun RunAnywhere.synthesize( + text: String, + options: TTSOptions = TTSOptions(), +): TTSOutput + +/** + * Stream synthesis for long text. + * + * @param text Text to synthesize + * @param options Synthesis options + * @param onAudioChunk Callback for each audio chunk + * @return TTS output with full audio data + */ +expect suspend fun RunAnywhere.synthesizeStream( + text: String, + options: TTSOptions = TTSOptions(), + onAudioChunk: (ByteArray) -> Unit, +): TTSOutput + +/** + * Stop current TTS synthesis. + */ +expect suspend fun RunAnywhere.stopSynthesis() + +// MARK: - Speak (Simple API) + +/** + * Speak text aloud - the simplest way to use TTS. + * + * The SDK handles audio synthesis and playback internally. + * Just call this method and the text will be spoken through the device speakers. + * + * Example: + * ```kotlin + * // Simple usage + * RunAnywhere.speak("Hello world") + * + * // With options + * val result = RunAnywhere.speak("Hello", TTSOptions(rate = 1.2f)) + * println("Duration: ${result.duration}s") + * ``` + * + * @param text Text to speak + * @param options Synthesis options (rate, pitch, voice, etc.) + * @return Result containing metadata about the spoken audio + * @throws Error if synthesis or playback fails + */ +expect suspend fun RunAnywhere.speak( + text: String, + options: TTSOptions = TTSOptions(), +): TTSSpeakResult + +/** + * Whether speech is currently playing. + */ +expect suspend fun RunAnywhere.isSpeaking(): Boolean + +/** + * Stop current speech playback. + */ +expect suspend fun RunAnywhere.stopSpeaking() diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/extensions/RunAnywhere+TextGeneration.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/extensions/RunAnywhere+TextGeneration.kt new file mode 100644 index 000000000..c0ac76eef --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/extensions/RunAnywhere+TextGeneration.kt @@ -0,0 +1,98 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * Public API for text generation (LLM) operations. + * Calls C++ directly via CppBridge.LLM for all operations. + * Events are emitted by C++ layer via CppEventBridge. + * + * Mirrors Swift RunAnywhere+TextGeneration.swift exactly. + */ + +package com.runanywhere.sdk.public.extensions + +import com.runanywhere.sdk.public.RunAnywhere +import com.runanywhere.sdk.public.extensions.LLM.LLMGenerationOptions +import com.runanywhere.sdk.public.extensions.LLM.LLMGenerationResult +import com.runanywhere.sdk.public.extensions.LLM.LLMStreamingResult +import kotlinx.coroutines.flow.Flow + +// MARK: - Text Generation + +/** + * Simple text generation with automatic event publishing. + * + * @param prompt The text prompt + * @return Generated response (text only) + */ +expect suspend fun RunAnywhere.chat(prompt: String): String + +/** + * Generate text with full metrics and analytics. + * + * @param prompt The text prompt + * @param options Generation options (optional) + * @return GenerationResult with full metrics including thinking tokens, timing, performance, etc. + * @note Events are automatically dispatched via C++ layer + */ +expect suspend fun RunAnywhere.generate( + prompt: String, + options: LLMGenerationOptions? = null, +): LLMGenerationResult + +/** + * Streaming text generation. + * + * Returns a Flow of tokens for real-time display. + * + * Example usage: + * ```kotlin + * RunAnywhere.generateStream("Tell me a story") + * .collect { token -> print(token) } + * ``` + * + * @param prompt The text prompt + * @param options Generation options (optional) + * @return Flow of tokens as they are generated + */ +expect fun RunAnywhere.generateStream( + prompt: String, + options: LLMGenerationOptions? = null, +): Flow + +/** + * Streaming text generation with metrics. + * + * Returns both a token stream for real-time display and a deferred result + * that resolves to complete metrics. + * + * Example usage: + * ```kotlin + * val result = RunAnywhere.generateStreamWithMetrics("Tell me a story") + * + * // Display tokens in real-time + * result.stream.collect { token -> print(token) } + * + * // Get complete analytics after streaming finishes + * val metrics = result.result.await() + * println("Speed: ${metrics.tokensPerSecond} tok/s") + * ``` + * + * @param prompt The text prompt + * @param options Generation options (optional) + * @return LLMStreamingResult containing both the token stream and final metrics deferred + */ +expect suspend fun RunAnywhere.generateStreamWithMetrics( + prompt: String, + options: LLMGenerationOptions? = null, +): LLMStreamingResult + +// MARK: - Generation Control + +/** + * Cancel any ongoing text generation. + * + * This will interrupt the current generation and stop producing tokens. + * Safe to call even if no generation is in progress. + */ +expect fun RunAnywhere.cancelGeneration() diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/extensions/RunAnywhere+VAD.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/extensions/RunAnywhere+VAD.kt new file mode 100644 index 000000000..0912ad17a --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/extensions/RunAnywhere+VAD.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * Public API for Voice Activity Detection operations. + * Calls C++ directly via CppBridge.VAD for all operations. + * Events are emitted by C++ layer via CppEventBridge. + * + * Mirrors Swift RunAnywhere+VAD.swift pattern. + */ + +package com.runanywhere.sdk.public.extensions + +import com.runanywhere.sdk.public.RunAnywhere +import com.runanywhere.sdk.public.extensions.VAD.VADConfiguration +import com.runanywhere.sdk.public.extensions.VAD.VADResult +import com.runanywhere.sdk.public.extensions.VAD.VADStatistics +import kotlinx.coroutines.flow.Flow + +// MARK: - VAD Operations + +/** + * Detect voice activity in audio data. + * + * @param audioData Audio data to analyze + * @return VAD result with speech detection and confidence + */ +expect suspend fun RunAnywhere.detectVoiceActivity(audioData: ByteArray): VADResult + +/** + * Configure VAD settings. + * + * @param configuration VAD configuration + */ +expect suspend fun RunAnywhere.configureVAD(configuration: VADConfiguration) + +/** + * Get current VAD statistics for debugging. + * + * @return Current VAD statistics + */ +expect suspend fun RunAnywhere.getVADStatistics(): VADStatistics + +/** + * Process audio samples and stream VAD results. + * + * @param audioSamples Flow of audio samples + * @return Flow of VAD results + */ +expect fun RunAnywhere.streamVAD(audioSamples: Flow): Flow + +/** + * Calibrate VAD with ambient noise. + * + * @param ambientAudioData Audio data of ambient noise + */ +expect suspend fun RunAnywhere.calibrateVAD(ambientAudioData: ByteArray) + +/** + * Reset VAD state. + */ +expect suspend fun RunAnywhere.resetVAD() diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/extensions/RunAnywhere+VoiceAgent.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/extensions/RunAnywhere+VoiceAgent.kt new file mode 100644 index 000000000..96b370c1d --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/extensions/RunAnywhere+VoiceAgent.kt @@ -0,0 +1,168 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * Public API for VoiceAgent operations. + * Provides voice conversation capabilities combining STT, LLM, and TTS. + * + * Mirrors Swift RunAnywhere+VoiceAgent.swift pattern. + */ + +package com.runanywhere.sdk.public.extensions + +import com.runanywhere.sdk.public.RunAnywhere +import com.runanywhere.sdk.public.extensions.VoiceAgent.VoiceAgentComponentStates +import com.runanywhere.sdk.public.extensions.VoiceAgent.VoiceAgentConfiguration +import com.runanywhere.sdk.public.extensions.VoiceAgent.VoiceAgentResult +import com.runanywhere.sdk.public.extensions.VoiceAgent.VoiceSessionConfig +import com.runanywhere.sdk.public.extensions.VoiceAgent.VoiceSessionEvent +import kotlinx.coroutines.flow.Flow + +// MARK: - Voice Agent Configuration + +/** + * Configure the voice agent. + * + * @param configuration Voice agent configuration + */ +expect suspend fun RunAnywhere.configureVoiceAgent(configuration: VoiceAgentConfiguration) + +/** + * Get current voice agent component states. + * + * @return Current state of all voice agent components + */ +expect suspend fun RunAnywhere.voiceAgentComponentStates(): VoiceAgentComponentStates + +/** + * Check if the voice agent is fully ready (all components loaded). + * + * @return True if ready + */ +expect suspend fun RunAnywhere.isVoiceAgentReady(): Boolean + +/** + * Initialize the voice agent with currently loaded models. + * + * This function checks that STT, LLM, and TTS models are loaded, + * then initializes the VoiceAgent orchestration component with those models. + * + * This is automatically called by startVoiceSession() if needed, + * but can be called explicitly for more control. + * + * @throws SDKError if SDK is not initialized + * @throws SDKError if any component models are not loaded + * @throws SDKError if VoiceAgent initialization fails + */ +expect suspend fun RunAnywhere.initializeVoiceAgentWithLoadedModels() + +// MARK: - Voice Processing + +/** + * Process audio through the voice pipeline (VAD -> STT -> LLM -> TTS). + * + * @param audioData Audio data to process + * @return Voice agent result with transcription, response, and synthesized audio + */ +expect suspend fun RunAnywhere.processVoice(audioData: ByteArray): VoiceAgentResult + +// MARK: - Voice Session + +/** + * Start a voice session. + * + * Returns a Flow of voice session events. + * + * Example: + * ```kotlin + * RunAnywhere.startVoiceSession() + * .collect { event -> + * when (event) { + * is VoiceSessionEvent.Listening -> // Show listening UI + * is VoiceSessionEvent.Transcribed -> println(event.text) + * is VoiceSessionEvent.Responded -> println(event.text) + * // ... + * } + * } + * ``` + * + * @param config Session configuration + * @return Flow of voice session events + */ +expect fun RunAnywhere.startVoiceSession( + config: VoiceSessionConfig = VoiceSessionConfig.DEFAULT, +): Flow + +/** + * Stream a voice session with automatic silence detection. + * + * This is the recommended API for voice pipelines. It handles: + * - Audio level calculation for visualization + * - Speech detection (when audio level > threshold) + * - Automatic silence detection (triggers processing after silence duration) + * - STT → LLM → TTS pipeline orchestration + * - Continuous conversation mode (auto-resumes listening after TTS) + * + * The app only needs to: + * 1. Capture audio and emit chunks to the input Flow + * 2. Collect events to update UI + * 3. Play audio when TurnCompleted event is received (if autoPlayTTS is false) + * + * Example: + * ```kotlin + * // Audio capture Flow from your audio service + * val audioChunks: Flow = audioCaptureService.startCapture() + * + * RunAnywhere.streamVoiceSession(audioChunks) + * .collect { event -> + * when (event) { + * is VoiceSessionEvent.Started -> showListeningUI() + * is VoiceSessionEvent.Listening -> updateAudioLevel(event.audioLevel) + * is VoiceSessionEvent.SpeechStarted -> showSpeechDetected() + * is VoiceSessionEvent.Processing -> showProcessingUI() + * is VoiceSessionEvent.Transcribed -> showTranscript(event.text) + * is VoiceSessionEvent.Responded -> showResponse(event.text) + * is VoiceSessionEvent.TurnCompleted -> { + * // Play audio if autoPlayTTS is false + * event.audio?.let { playAudio(it) } + * } + * is VoiceSessionEvent.Stopped -> showIdleUI() + * is VoiceSessionEvent.Error -> showError(event.message) + * } + * } + * ``` + * + * @param audioChunks Flow of audio chunks (16kHz, mono, 16-bit PCM) + * @param config Session configuration (silence duration, speech threshold, etc.) + * @return Flow of voice session events + */ +expect fun RunAnywhere.streamVoiceSession( + audioChunks: Flow, + config: VoiceSessionConfig = VoiceSessionConfig.DEFAULT, +): Flow + +/** + * Stop the current voice session. + */ +expect suspend fun RunAnywhere.stopVoiceSession() + +/** + * Check if a voice session is active. + * + * @return True if a session is running + */ +expect suspend fun RunAnywhere.isVoiceSessionActive(): Boolean + +// MARK: - Conversation History + +/** + * Clear the voice agent conversation history. + */ +expect suspend fun RunAnywhere.clearVoiceConversation() + +/** + * Set the system prompt for LLM responses. + * + * @param prompt System prompt text + */ +expect suspend fun RunAnywhere.setVoiceSystemPrompt(prompt: String) diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/extensions/STT/STTTypes.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/extensions/STT/STTTypes.kt new file mode 100644 index 000000000..488de2641 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/extensions/STT/STTTypes.kt @@ -0,0 +1,187 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * Public types for Speech-to-Text transcription. + * These are thin wrappers over C++ types in rac_stt_types.h + * + * Mirrors Swift STTTypes.swift exactly. + */ + +package com.runanywhere.sdk.public.extensions.STT + +import com.runanywhere.sdk.core.types.AudioFormat +import com.runanywhere.sdk.core.types.ComponentConfiguration +import com.runanywhere.sdk.core.types.ComponentOutput +import com.runanywhere.sdk.core.types.InferenceFramework +import com.runanywhere.sdk.core.types.SDKComponent +import kotlinx.serialization.Serializable + +// MARK: - STT Configuration + +/** + * Configuration for STT component. + * Mirrors Swift STTConfiguration exactly. + */ +@Serializable +data class STTConfiguration( + override val modelId: String? = null, + val language: String = "en-US", + val sampleRate: Int = DEFAULT_SAMPLE_RATE, + val enablePunctuation: Boolean = true, + val enableDiarization: Boolean = false, + val vocabularyList: List = emptyList(), + val maxAlternatives: Int = 1, + val enableTimestamps: Boolean = true, + override val preferredFramework: InferenceFramework? = null, +) : ComponentConfiguration { + val componentType: SDKComponent get() = SDKComponent.STT + + /** + * Validate the configuration. + * @throws IllegalArgumentException if validation fails + */ + fun validate() { + require(sampleRate in 1..48000) { + "Sample rate must be between 1 and 48000 Hz" + } + require(maxAlternatives in 1..10) { + "Max alternatives must be between 1 and 10" + } + } + + companion object { + const val DEFAULT_SAMPLE_RATE = 16000 + } +} + +// MARK: - STT Options + +/** + * Options for speech-to-text transcription. + * Mirrors Swift STTOptions exactly. + */ +@Serializable +data class STTOptions( + /** Language code for transcription (e.g., "en", "es", "fr") */ + val language: String = "en", + /** Whether to auto-detect the spoken language */ + val detectLanguage: Boolean = false, + /** Enable automatic punctuation in transcription */ + val enablePunctuation: Boolean = true, + /** Enable speaker diarization (identify different speakers) */ + val enableDiarization: Boolean = false, + /** Maximum number of speakers to identify (requires enableDiarization) */ + val maxSpeakers: Int? = null, + /** Enable word-level timestamps */ + val enableTimestamps: Boolean = true, + /** Custom vocabulary words to improve recognition */ + val vocabularyFilter: List = emptyList(), + /** Audio format of input data */ + val audioFormat: AudioFormat = AudioFormat.PCM, + /** Sample rate of input audio (default: 16000 Hz for STT models) */ + val sampleRate: Int = STTConfiguration.DEFAULT_SAMPLE_RATE, + /** Preferred framework for transcription (ONNX, etc.) */ + val preferredFramework: InferenceFramework? = null, +) { + companion object { + /** Create options with default settings for a specific language */ + fun default(language: String = "en") = STTOptions(language = language) + } +} + +// MARK: - STT Output + +/** + * Output from Speech-to-Text (conforms to ComponentOutput). + * Mirrors Swift STTOutput exactly. + */ +@Serializable +data class STTOutput( + /** Transcribed text */ + val text: String, + /** Confidence score (0.0 to 1.0) */ + val confidence: Float, + /** Word-level timestamps if available */ + val wordTimestamps: List? = null, + /** Detected language if auto-detected */ + val detectedLanguage: String? = null, + /** Alternative transcriptions if available */ + val alternatives: List? = null, + /** Processing metadata */ + val metadata: TranscriptionMetadata, + /** Timestamp (required by ComponentOutput) */ + override val timestamp: Long = System.currentTimeMillis(), +) : ComponentOutput + +// MARK: - Supporting Types + +/** + * Transcription metadata. + * Mirrors Swift TranscriptionMetadata exactly. + */ +@Serializable +data class TranscriptionMetadata( + val modelId: String, + /** Processing time in seconds */ + val processingTime: Double, + /** Audio length in seconds */ + val audioLength: Double, +) { + /** Processing time / audio length */ + val realTimeFactor: Double + get() = if (audioLength > 0) processingTime / audioLength else 0.0 +} + +/** + * Word timestamp information. + * Mirrors Swift WordTimestamp exactly. + */ +@Serializable +data class WordTimestamp( + val word: String, + /** Start time in seconds */ + val startTime: Double, + /** End time in seconds */ + val endTime: Double, + val confidence: Float, +) + +/** + * Alternative transcription. + * Mirrors Swift TranscriptionAlternative exactly. + */ +@Serializable +data class TranscriptionAlternative( + val text: String, + val confidence: Float, +) + +// MARK: - STT Transcription Result + +/** + * Transcription result from service. + * Mirrors Swift STTTranscriptionResult exactly. + */ +@Serializable +data class STTTranscriptionResult( + val transcript: String, + val confidence: Float? = null, + val timestamps: List? = null, + val language: String? = null, + val alternatives: List? = null, +) { + @Serializable + data class TimestampInfo( + val word: String, + val startTime: Double, + val endTime: Double, + val confidence: Float? = null, + ) + + @Serializable + data class AlternativeTranscription( + val transcript: String, + val confidence: Float, + ) +} diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/extensions/Storage/StorageTypes.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/extensions/Storage/StorageTypes.kt new file mode 100644 index 000000000..240ae3c84 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/extensions/Storage/StorageTypes.kt @@ -0,0 +1,189 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * Consolidated storage-related types for public API. + * Includes: storage info, configuration, availability, and model storage metrics. + * + * Mirrors Swift StorageTypes.swift exactly. + */ + +package com.runanywhere.sdk.public.extensions.Storage + +import com.runanywhere.sdk.core.types.InferenceFramework +import com.runanywhere.sdk.public.extensions.Models.ModelFormat +import com.runanywhere.sdk.public.extensions.Models.ModelInfo +import kotlinx.serialization.Serializable + +// MARK: - Device Storage + +/** + * Device storage information. + * Mirrors Swift DeviceStorageInfo exactly. + */ +@Serializable +data class DeviceStorageInfo( + /** Total device storage space in bytes */ + val totalSpace: Long, + /** Free space available in bytes */ + val freeSpace: Long, + /** Used space in bytes */ + val usedSpace: Long, +) { + /** Percentage of storage used (0-100) */ + val usagePercentage: Double + get() = if (totalSpace > 0) usedSpace.toDouble() / totalSpace.toDouble() * 100 else 0.0 +} + +// MARK: - App Storage + +/** + * App storage breakdown by directory type. + * Mirrors Swift AppStorageInfo exactly. + */ +@Serializable +data class AppStorageInfo( + /** Documents directory size in bytes */ + val documentsSize: Long, + /** Cache directory size in bytes */ + val cacheSize: Long, + /** Application Support directory size in bytes */ + val appSupportSize: Long, + /** Total app storage in bytes */ + val totalSize: Long, +) + +// MARK: - Model Storage Metrics + +/** + * Storage metrics for a single model. + * All model metadata (id, name, framework, artifactType, etc.) is in ModelInfo. + * This struct adds the on-disk storage size. + * + * Mirrors Swift ModelStorageMetrics exactly. + */ +@Serializable +data class ModelStorageMetrics( + /** The model info (contains id, framework, localPath, artifactType, etc.) */ + val model: ModelInfo, + /** Actual size on disk in bytes (may differ from downloadSize after extraction) */ + val sizeOnDisk: Long, +) + +// MARK: - Stored Model (Backward Compatible) + +/** + * Backward-compatible stored model view. + * Provides a simple view of a stored model with computed properties. + * + * Mirrors Swift StoredModel exactly. + */ +@Serializable +data class StoredModel( + /** Underlying model info */ + val modelInfo: ModelInfo, + /** Size on disk in bytes */ + val size: Long, +) { + /** Model ID */ + val id: String get() = modelInfo.id + + /** Model name */ + val name: String get() = modelInfo.name + + /** Model format */ + val format: ModelFormat get() = modelInfo.format + + /** Inference framework */ + val framework: InferenceFramework? get() = modelInfo.framework + + /** Model description */ + val description: String? get() = modelInfo.description + + /** Path to the model on disk */ + val path: String get() = modelInfo.localPath ?: "/unknown" + + /** Checksum (from download info if available) */ + val checksum: String? get() = null + + /** Created date (use current time as fallback) */ + val createdDate: Long get() = System.currentTimeMillis() + + companion object { + /** Create from ModelStorageMetrics */ + fun from(metrics: ModelStorageMetrics) = + StoredModel( + modelInfo = metrics.model, + size = metrics.sizeOnDisk, + ) + } +} + +// MARK: - Storage Info (Aggregate) + +/** + * Complete storage information including device, app, and model storage. + * Mirrors Swift StorageInfo exactly. + */ +@Serializable +data class StorageInfo( + /** App storage usage */ + val appStorage: AppStorageInfo, + /** Device storage capacity */ + val deviceStorage: DeviceStorageInfo, + /** Storage metrics for each downloaded model */ + val models: List, +) { + /** Total size of all models */ + val totalModelsSize: Long + get() = models.sumOf { it.sizeOnDisk } + + /** Number of stored models */ + val modelCount: Int + get() = models.size + + /** Stored models array (backward compatible) */ + val storedModels: List + get() = models.map { StoredModel.from(it) } + + companion object { + /** Empty storage info */ + val EMPTY = + StorageInfo( + appStorage = + AppStorageInfo( + documentsSize = 0, + cacheSize = 0, + appSupportSize = 0, + totalSize = 0, + ), + deviceStorage = + DeviceStorageInfo( + totalSpace = 0, + freeSpace = 0, + usedSpace = 0, + ), + models = emptyList(), + ) + } +} + +// MARK: - Storage Availability + +/** + * Storage availability check result. + * Mirrors Swift StorageAvailability exactly. + */ +@Serializable +data class StorageAvailability( + /** Whether storage is available for the requested operation */ + val isAvailable: Boolean, + /** Required space in bytes */ + val requiredSpace: Long, + /** Available space in bytes */ + val availableSpace: Long, + /** Whether there's a warning (e.g., low space) */ + val hasWarning: Boolean, + /** Recommendation message if any */ + val recommendation: String?, +) diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/extensions/TTS/TTSTypes.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/extensions/TTS/TTSTypes.kt new file mode 100644 index 000000000..0b6e2e195 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/extensions/TTS/TTSTypes.kt @@ -0,0 +1,285 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * Public types for Text-to-Speech synthesis. + * These are thin wrappers over C++ types in rac_tts_types.h + * + * Mirrors Swift TTSTypes.swift exactly. + */ + +package com.runanywhere.sdk.public.extensions.TTS + +import com.runanywhere.sdk.core.types.AudioFormat +import com.runanywhere.sdk.core.types.ComponentConfiguration +import com.runanywhere.sdk.core.types.ComponentOutput +import com.runanywhere.sdk.core.types.InferenceFramework +import com.runanywhere.sdk.core.types.SDKComponent +import com.runanywhere.sdk.foundation.errors.SDKError +import kotlinx.serialization.Serializable + +// MARK: - TTS Configuration + +/** + * Configuration for TTS component. + * Mirrors Swift TTSConfiguration exactly. + */ +@Serializable +data class TTSConfiguration( + /** Voice identifier to use for synthesis */ + val voice: String = DEFAULT_VOICE, + /** Language for synthesis (BCP-47 format, e.g., "en-US") */ + val language: String = "en-US", + /** Speaking rate (0.5 to 2.0, 1.0 is normal) */ + val speakingRate: Float = 1.0f, + /** Speech pitch (0.5 to 2.0, 1.0 is normal) */ + val pitch: Float = 1.0f, + /** Speech volume (0.0 to 1.0) */ + val volume: Float = 1.0f, + /** Audio format for output */ + val audioFormat: AudioFormat = AudioFormat.PCM, + /** Whether to use neural/premium voice if available */ + val useNeuralVoice: Boolean = true, + /** Whether to enable SSML markup support */ + val enableSSML: Boolean = false, +) : ComponentConfiguration { + override val modelId: String? get() = null + override val preferredFramework: InferenceFramework? get() = null + + val componentType: SDKComponent get() = SDKComponent.TTS + + /** + * Validate the configuration. + * @throws SDKError if validation fails + */ + fun validate() { + require(speakingRate in 0.5f..2.0f) { + throw SDKError.tts("Invalid speaking rate: $speakingRate. Must be between 0.5 and 2.0.") + } + require(pitch in 0.5f..2.0f) { + throw SDKError.tts("Invalid pitch: $pitch. Must be between 0.5 and 2.0.") + } + require(volume in 0.0f..1.0f) { + throw SDKError.tts("Invalid volume: $volume. Must be between 0.0 and 1.0.") + } + } + + /** + * Builder pattern for TTSConfiguration. + */ + class Builder( + private var voice: String = DEFAULT_VOICE, + ) { + private var language: String = "en-US" + private var speakingRate: Float = 1.0f + private var pitch: Float = 1.0f + private var volume: Float = 1.0f + private var audioFormat: AudioFormat = AudioFormat.PCM + private var useNeuralVoice: Boolean = true + private var enableSSML: Boolean = false + + fun voice(voice: String) = apply { this.voice = voice } + + fun language(language: String) = apply { this.language = language } + + fun speakingRate(rate: Float) = apply { speakingRate = rate } + + fun pitch(pitch: Float) = apply { this.pitch = pitch } + + fun volume(volume: Float) = apply { this.volume = volume } + + fun audioFormat(format: AudioFormat) = apply { audioFormat = format } + + fun useNeuralVoice(enabled: Boolean) = apply { useNeuralVoice = enabled } + + fun enableSSML(enabled: Boolean) = apply { enableSSML = enabled } + + fun build() = + TTSConfiguration( + voice = voice, + language = language, + speakingRate = speakingRate, + pitch = pitch, + volume = volume, + audioFormat = audioFormat, + useNeuralVoice = useNeuralVoice, + enableSSML = enableSSML, + ) + } + + companion object { + const val DEFAULT_VOICE = "default" + const val DEFAULT_SAMPLE_RATE = 22050 + const val CD_QUALITY_SAMPLE_RATE = 44100 + + fun builder(voice: String = DEFAULT_VOICE) = Builder(voice) + } +} + +// MARK: - TTS Options + +/** + * Options for text-to-speech synthesis. + * Mirrors Swift TTSOptions exactly. + */ +@Serializable +data class TTSOptions( + /** Voice to use for synthesis (null uses default) */ + val voice: String? = null, + /** Language for synthesis (BCP-47 format, e.g., "en-US") */ + val language: String = "en-US", + /** Speech rate (0.0 to 2.0, 1.0 is normal) */ + val rate: Float = 1.0f, + /** Speech pitch (0.0 to 2.0, 1.0 is normal) */ + val pitch: Float = 1.0f, + /** Speech volume (0.0 to 1.0) */ + val volume: Float = 1.0f, + /** Audio format for output */ + val audioFormat: AudioFormat = AudioFormat.PCM, + /** Sample rate for output audio in Hz */ + val sampleRate: Int = TTSConfiguration.DEFAULT_SAMPLE_RATE, + /** Whether to use SSML markup */ + val useSSML: Boolean = false, +) { + companion object { + val DEFAULT = TTSOptions() + + /** Create options from TTSConfiguration */ + fun from(configuration: TTSConfiguration) = + TTSOptions( + voice = configuration.voice, + language = configuration.language, + rate = configuration.speakingRate, + pitch = configuration.pitch, + volume = configuration.volume, + audioFormat = configuration.audioFormat, + sampleRate = + if (configuration.audioFormat == AudioFormat.PCM) { + TTSConfiguration.DEFAULT_SAMPLE_RATE + } else { + TTSConfiguration.CD_QUALITY_SAMPLE_RATE + }, + useSSML = configuration.enableSSML, + ) + } +} + +// MARK: - TTS Output + +/** + * Output from Text-to-Speech synthesis. + * Mirrors Swift TTSOutput exactly. + */ +@Serializable +data class TTSOutput( + /** Synthesized audio data */ + val audioData: ByteArray, + /** Audio format of the output */ + val format: AudioFormat, + /** Duration of the audio in seconds */ + val duration: Double, + /** Phoneme timestamps if available */ + val phonemeTimestamps: List? = null, + /** Processing metadata */ + val metadata: TTSSynthesisMetadata, + /** Timestamp (required by ComponentOutput) */ + override val timestamp: Long = System.currentTimeMillis(), +) : ComponentOutput { + /** Audio size in bytes */ + val audioSizeBytes: Int get() = audioData.size + + /** Whether the output has phoneme timing information */ + val hasPhonemeTimestamps: Boolean + get() = phonemeTimestamps?.isNotEmpty() == true + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + other as TTSOutput + return audioData.contentEquals(other.audioData) && + format == other.format && + duration == other.duration && + phonemeTimestamps == other.phonemeTimestamps && + metadata == other.metadata + } + + override fun hashCode(): Int { + var result = audioData.contentHashCode() + result = 31 * result + format.hashCode() + result = 31 * result + duration.hashCode() + result = 31 * result + (phonemeTimestamps?.hashCode() ?: 0) + result = 31 * result + metadata.hashCode() + return result + } +} + +// MARK: - Supporting Types + +/** + * Synthesis metadata. + * Mirrors Swift TTSSynthesisMetadata exactly. + */ +@Serializable +data class TTSSynthesisMetadata( + /** Voice used for synthesis */ + val voice: String, + /** Language used for synthesis */ + val language: String, + /** Processing time in seconds */ + val processingTime: Double, + /** Number of characters synthesized */ + val characterCount: Int, +) { + /** Characters processed per second */ + val charactersPerSecond: Double + get() = if (processingTime > 0) characterCount.toDouble() / processingTime else 0.0 +} + +/** + * Phoneme timestamp information. + * Mirrors Swift TTSPhonemeTimestamp exactly. + */ +@Serializable +data class TTSPhonemeTimestamp( + /** The phoneme */ + val phoneme: String, + /** Start time in seconds */ + val startTime: Double, + /** End time in seconds */ + val endTime: Double, +) { + /** Duration of the phoneme */ + val duration: Double get() = endTime - startTime +} + +// MARK: - Speak Result + +/** + * Result from speak() - contains metadata only, no audio data. + * Mirrors Swift TTSSpeakResult exactly. + */ +@Serializable +data class TTSSpeakResult( + /** Duration of the spoken audio in seconds */ + val duration: Double, + /** Audio format used */ + val format: AudioFormat, + /** Audio size in bytes (0 for system TTS which plays directly) */ + val audioSizeBytes: Int, + /** Synthesis metadata (voice, language, processing time, etc.) */ + val metadata: TTSSynthesisMetadata, + /** Timestamp when speech completed */ + val timestamp: Long = System.currentTimeMillis(), +) { + companion object { + /** Create from TTSOutput (internal use) */ + internal fun from(output: TTSOutput) = + TTSSpeakResult( + duration = output.duration, + format = output.format, + audioSizeBytes = output.audioSizeBytes, + metadata = output.metadata, + timestamp = output.timestamp, + ) + } +} diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/extensions/VAD/VADTypes.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/extensions/VAD/VADTypes.kt new file mode 100644 index 000000000..d083bcd8f --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/extensions/VAD/VADTypes.kt @@ -0,0 +1,179 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * Public types for Voice Activity Detection. + * These are thin wrappers over C++ types in rac_vad_types.h + * + * Mirrors Swift VADTypes.swift exactly. + */ + +package com.runanywhere.sdk.public.extensions.VAD + +import com.runanywhere.sdk.core.types.ComponentConfiguration +import com.runanywhere.sdk.core.types.InferenceFramework +import com.runanywhere.sdk.core.types.SDKComponent +import com.runanywhere.sdk.foundation.errors.SDKError +import kotlinx.serialization.Serializable + +// MARK: - VAD Configuration + +/** + * Configuration for Voice Activity Detection operations. + * Mirrors Swift VADConfiguration exactly. + */ +@Serializable +data class VADConfiguration( + /** Energy threshold for voice detection (0.0 to 1.0). Recommended range: 0.01-0.05 */ + val energyThreshold: Float = 0.015f, + /** Sample rate in Hz (default: 16000) */ + val sampleRate: Int = DEFAULT_SAMPLE_RATE, + /** Frame length in seconds (default: 0.1 = 100ms) */ + val frameLength: Float = 0.1f, + /** Enable automatic calibration */ + val enableAutoCalibration: Boolean = false, + /** Calibration multiplier (threshold = ambient noise * multiplier). Range: 1.5 to 5.0 */ + val calibrationMultiplier: Float = 2.0f, +) : ComponentConfiguration { + override val modelId: String? get() = null + override val preferredFramework: InferenceFramework? get() = null + + val componentType: SDKComponent get() = SDKComponent.VAD + + /** + * Validate the configuration. + * @throws SDKError if validation fails + */ + fun validate() { + // Validate threshold range + require(energyThreshold in 0f..1f) { + throw SDKError.vad("Energy threshold must be between 0 and 1.0. Recommended range: 0.01-0.05") + } + + // Warn if threshold is too low + if (energyThreshold < 0.002f) { + throw SDKError.vad("Energy threshold $energyThreshold is very low and may cause false positives. Recommended minimum: 0.002") + } + + // Warn if threshold is too high + if (energyThreshold > 0.1f) { + throw SDKError.vad("Energy threshold $energyThreshold is very high and may miss speech. Recommended maximum: 0.1") + } + + // Validate sample rate + require(sampleRate in 1..48000) { + throw SDKError.vad("Sample rate must be between 1 and 48000 Hz") + } + + // Validate frame length + require(frameLength in 0f..1f) { + throw SDKError.vad("Frame length must be between 0 and 1 second") + } + + // Validate calibration multiplier + require(calibrationMultiplier in 1.5f..5.0f) { + throw SDKError.vad("Calibration multiplier must be between 1.5 and 5.0") + } + } + + /** + * Builder pattern for VADConfiguration. + */ + class Builder { + private var energyThreshold: Float = 0.015f + private var sampleRate: Int = DEFAULT_SAMPLE_RATE + private var frameLength: Float = 0.1f + private var enableAutoCalibration: Boolean = false + private var calibrationMultiplier: Float = 2.0f + + fun energyThreshold(threshold: Float) = apply { energyThreshold = threshold } + + fun sampleRate(rate: Int) = apply { sampleRate = rate } + + fun frameLength(length: Float) = apply { frameLength = length } + + fun enableAutoCalibration(enabled: Boolean) = apply { enableAutoCalibration = enabled } + + fun calibrationMultiplier(multiplier: Float) = apply { calibrationMultiplier = multiplier } + + fun build() = + VADConfiguration( + energyThreshold = energyThreshold, + sampleRate = sampleRate, + frameLength = frameLength, + enableAutoCalibration = enableAutoCalibration, + calibrationMultiplier = calibrationMultiplier, + ) + } + + companion object { + const val DEFAULT_SAMPLE_RATE = 16000 + + fun builder() = Builder() + } +} + +// MARK: - VAD Statistics + +/** + * Statistics for VAD debugging and monitoring. + * Mirrors Swift VADStatistics exactly. + */ +@Serializable +data class VADStatistics( + /** Current energy level */ + val current: Float, + /** Energy threshold being used */ + val threshold: Float, + /** Ambient noise level (from calibration) */ + val ambient: Float, + /** Recent average energy level */ + val recentAvg: Float, + /** Recent maximum energy level */ + val recentMax: Float, +) { + override fun toString(): String = + """ + VADStatistics: + Current: ${String.format("%.6f", current)} + Threshold: ${String.format("%.6f", threshold)} + Ambient: ${String.format("%.6f", ambient)} + Recent Avg: ${String.format("%.6f", recentAvg)} + Recent Max: ${String.format("%.6f", recentMax)} + """.trimIndent() +} + +// MARK: - VAD Result + +/** + * Result from VAD processing. + */ +@Serializable +data class VADResult( + /** Whether speech was detected */ + val isSpeech: Boolean, + /** Confidence level (0.0 to 1.0) */ + val confidence: Float, + /** Energy level of the audio */ + val energyLevel: Float, + /** Statistics for debugging */ + val statistics: VADStatistics? = null, + /** Timestamp */ + val timestamp: Long = System.currentTimeMillis(), +) + +// MARK: - Speech Activity Event + +/** + * Events representing speech activity state changes. + * Mirrors Swift SpeechActivityEvent exactly. + */ +enum class SpeechActivityEvent( + val value: String, +) { + /** Speech has started */ + STARTED("started"), + + /** Speech has ended */ + ENDED("ended"), +} diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/extensions/VoiceAgent/VoiceAgentTypes.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/extensions/VoiceAgent/VoiceAgentTypes.kt new file mode 100644 index 000000000..e6206077e --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/public/extensions/VoiceAgent/VoiceAgentTypes.kt @@ -0,0 +1,272 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * Consolidated voice agent and voice session types for public API. + * Includes: configurations, states, results, events, and errors. + * + * Mirrors Swift VoiceAgentTypes.swift exactly. + */ + +package com.runanywhere.sdk.public.extensions.VoiceAgent + +import kotlinx.serialization.Serializable + +// MARK: - Voice Agent Result + +/** + * Result from voice agent processing. + * Contains all outputs from the voice pipeline: transcription, LLM response, and synthesized audio. + * + * Mirrors Swift VoiceAgentResult exactly. + */ +@Serializable +data class VoiceAgentResult( + /** Whether speech was detected in the input audio */ + var speechDetected: Boolean = false, + /** Transcribed text from STT */ + var transcription: String? = null, + /** Generated response text from LLM */ + var response: String? = null, + /** Synthesized audio data from TTS */ + @Serializable(with = ByteArraySerializer::class) + var synthesizedAudio: ByteArray? = null, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + other as VoiceAgentResult + return speechDetected == other.speechDetected && + transcription == other.transcription && + response == other.response && + synthesizedAudio.contentEquals(other.synthesizedAudio) + } + + override fun hashCode(): Int { + var result = speechDetected.hashCode() + result = 31 * result + (transcription?.hashCode() ?: 0) + result = 31 * result + (response?.hashCode() ?: 0) + result = 31 * result + (synthesizedAudio?.contentHashCode() ?: 0) + return result + } +} + +/** + * Custom serializer for ByteArray (null-safe). + */ +object ByteArraySerializer : kotlinx.serialization.KSerializer { + override val descriptor = + kotlinx.serialization.descriptors.PrimitiveSerialDescriptor( + "ByteArray", + kotlinx.serialization.descriptors.PrimitiveKind.STRING, + ) + + override fun serialize(encoder: kotlinx.serialization.encoding.Encoder, value: ByteArray?) { + if (value != null) { + encoder.encodeString(value.joinToString(",") { it.toString() }) + } else { + encoder.encodeString("") + } + } + + override fun deserialize(decoder: kotlinx.serialization.encoding.Decoder): ByteArray? { + val string = decoder.decodeString() + if (string.isEmpty()) return null + return string.split(",").map { it.toByte() }.toByteArray() + } +} + +// MARK: - Component Load State + +/** + * Represents the loading state of a single model/voice component. + * Mirrors Swift ComponentLoadState exactly. + */ +sealed class ComponentLoadState { + data object NotLoaded : ComponentLoadState() + + data object Loading : ComponentLoadState() + + data class Loaded( + val loadedModelId: String, + ) : ComponentLoadState() + + data class Error( + val message: String, + ) : ComponentLoadState() + + /** Whether the component is currently loaded and ready to use */ + val isLoaded: Boolean get() = this is Loaded + + /** Whether the component is currently loading */ + val isLoading: Boolean get() = this is Loading + + /** Get the model ID if loaded */ + val modelId: String? + get() = (this as? Loaded)?.loadedModelId +} + +// MARK: - Voice Agent Component States + +/** + * Unified state of all voice agent components. + * Mirrors Swift VoiceAgentComponentStates exactly. + */ +data class VoiceAgentComponentStates( + /** Speech-to-Text component state */ + val stt: ComponentLoadState = ComponentLoadState.NotLoaded, + /** Large Language Model component state */ + val llm: ComponentLoadState = ComponentLoadState.NotLoaded, + /** Text-to-Speech component state */ + val tts: ComponentLoadState = ComponentLoadState.NotLoaded, +) { + /** Whether all components are loaded and the voice agent is ready to use */ + val isFullyReady: Boolean + get() = stt.isLoaded && llm.isLoaded && tts.isLoaded + + /** Whether any component is currently loading */ + val isAnyLoading: Boolean + get() = stt.isLoading || llm.isLoading || tts.isLoading + + /** Get a summary of which components are missing */ + val missingComponents: List + get() = + buildList { + if (!stt.isLoaded) add("STT") + if (!llm.isLoaded) add("LLM") + if (!tts.isLoaded) add("TTS") + } +} + +// MARK: - Voice Agent Configuration + +/** + * Configuration for the voice agent. + * Uses C++ defaults via rac_voice_agent_config_t. + * + * Mirrors Swift VoiceAgentConfiguration exactly. + */ +@Serializable +data class VoiceAgentConfiguration( + /** STT model ID (optional - uses currently loaded model if null) */ + val sttModelId: String? = null, + /** LLM model ID (optional - uses currently loaded model if null) */ + val llmModelId: String? = null, + /** TTS voice (optional - uses currently loaded voice if null) */ + val ttsVoice: String? = null, + /** VAD sample rate */ + val vadSampleRate: Int = 16000, + /** VAD frame length in seconds */ + val vadFrameLength: Float = 0.1f, + /** VAD energy threshold */ + val vadEnergyThreshold: Float = 0.005f, +) + +// MARK: - Voice Session Events + +/** + * Events emitted during a voice session. + * Mirrors Swift VoiceSessionEvent exactly. + */ +sealed class VoiceSessionEvent { + /** Session started and ready */ + data object Started : VoiceSessionEvent() + + /** Listening for speech with current audio level (0.0 - 1.0) */ + data class Listening( + val audioLevel: Float, + ) : VoiceSessionEvent() + + /** Speech detected, started accumulating audio */ + data object SpeechStarted : VoiceSessionEvent() + + /** Speech ended, processing audio */ + data object Processing : VoiceSessionEvent() + + /** Got transcription from STT */ + data class Transcribed( + val text: String, + ) : VoiceSessionEvent() + + /** Got response from LLM */ + data class Responded( + val text: String, + ) : VoiceSessionEvent() + + /** Playing TTS audio */ + data object Speaking : VoiceSessionEvent() + + /** Complete turn result */ + data class TurnCompleted( + val transcript: String, + val response: String, + val audio: ByteArray?, + ) : VoiceSessionEvent() { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + other as TurnCompleted + return transcript == other.transcript && + response == other.response && + audio.contentEquals(other.audio) + } + + override fun hashCode(): Int { + var result = transcript.hashCode() + result = 31 * result + response.hashCode() + result = 31 * result + (audio?.contentHashCode() ?: 0) + return result + } + } + + /** Session stopped */ + data object Stopped : VoiceSessionEvent() + + /** Error occurred */ + data class Error( + val message: String, + ) : VoiceSessionEvent() +} + +// MARK: - Voice Session Configuration + +/** + * Configuration for voice session behavior. + * Mirrors Swift VoiceSessionConfig exactly. + */ +@Serializable +data class VoiceSessionConfig( + /** Silence duration (seconds) before processing speech */ + var silenceDuration: Double = 1.5, + /** Minimum audio level to detect speech (0.0 - 1.0) */ + var speechThreshold: Float = 0.1f, + /** Whether to auto-play TTS response */ + var autoPlayTTS: Boolean = true, + /** Whether to auto-resume listening after TTS playback */ + var continuousMode: Boolean = true, +) { + companion object { + /** Default configuration */ + val DEFAULT = VoiceSessionConfig() + } +} + +// MARK: - Voice Session Errors + +/** + * Errors that can occur during a voice session. + * Mirrors Swift VoiceSessionError exactly. + */ +sealed class VoiceSessionError : Exception() { + data object MicrophonePermissionDenied : VoiceSessionError() { + override val message: String = "Microphone permission denied" + } + + data object NotReady : VoiceSessionError() { + override val message: String = "Voice agent not ready. Load STT, LLM, and TTS models first." + } + + data object AlreadyRunning : VoiceSessionError() { + override val message: String = "Voice session already running" + } +} diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/security/SecureStorage.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/security/SecureStorage.kt new file mode 100644 index 000000000..0973b6385 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/security/SecureStorage.kt @@ -0,0 +1,160 @@ +package com.runanywhere.sdk.security + +import com.runanywhere.sdk.foundation.errors.SDKError + +/** + * Platform-agnostic secure storage interface + * Matches iOS KeychainManager functionality for credential storage + * Platform implementations should use: + * - Android: EncryptedSharedPreferences with AndroidKeystore + * - JVM: Encrypted file storage or system keystore + * - Native: Platform-specific secure storage APIs + */ +interface SecureStorage { + /** + * Store a string value securely + * @param key Unique identifier for the value + * @param value String value to store + * @throws SDKError.SecurityError if storage fails + */ + suspend fun setSecureString( + key: String, + value: String, + ) + + /** + * Retrieve a stored string value + * @param key Unique identifier for the value + * @return Stored string value or null if not found + * @throws SDKError.SecurityError if retrieval fails + */ + suspend fun getSecureString(key: String): String? + + /** + * Store binary data securely + * @param key Unique identifier for the data + * @param data Binary data to store + * @throws SDKError.SecurityError if storage fails + */ + suspend fun setSecureData( + key: String, + data: ByteArray, + ) + + /** + * Retrieve stored binary data + * @param key Unique identifier for the data + * @return Stored binary data or null if not found + * @throws SDKError.SecurityError if retrieval fails + */ + suspend fun getSecureData(key: String): ByteArray? + + /** + * Remove a stored value + * @param key Unique identifier for the value to remove + * @throws SDKError.SecurityError if removal fails + */ + suspend fun removeSecure(key: String) + + /** + * Check if a key exists in secure storage + * @param key Unique identifier to check + * @return true if key exists, false otherwise + */ + suspend fun containsKey(key: String): Boolean + + /** + * Clear all stored values (use with caution) + * @throws SDKError.SecurityError if clear operation fails + */ + suspend fun clearAll() + + /** + * Get all stored keys (for debugging/migration purposes) + * @return Set of all stored keys + */ + suspend fun getAllKeys(): Set + + /** + * Check if secure storage is available and properly configured + * @return true if secure storage is available, false otherwise + */ + suspend fun isAvailable(): Boolean +} + +/** + * Platform-specific secure storage factory + * Each platform provides its own implementation + */ +@Suppress("UtilityClassWithPublicConstructor") // KMP expect/actual pattern requires class +expect class SecureStorageFactory { + companion object { + /** + * Create a platform-specific secure storage instance + * @param identifier Unique identifier for this storage instance (optional) + * @return Platform-appropriate SecureStorage implementation + */ + fun create(identifier: String = "com.runanywhere.sdk"): SecureStorage + + /** + * Check if secure storage is supported on this platform + * @return true if supported, false otherwise + */ + fun isSupported(): Boolean + } +} + +/** + * Convenience functions for common secure storage operations + */ +object SecureStorageUtils { + /** + * Store authentication tokens securely + */ + suspend fun storeAuthTokens( + storage: SecureStorage, + accessToken: String, + refreshToken: String?, + expiresAt: Long, + ) { + storage.setSecureString("access_token", accessToken) + refreshToken?.let { storage.setSecureString("refresh_token", it) } + storage.setSecureString("token_expires_at", expiresAt.toString()) + } + + /** + * Retrieve authentication tokens from secure storage + */ + suspend fun getAuthTokens(storage: SecureStorage): AuthTokens? { + val accessToken = storage.getSecureString("access_token") ?: return null + val refreshToken = storage.getSecureString("refresh_token") + val expiresAt = storage.getSecureString("token_expires_at")?.toLongOrNull() ?: 0L + + return AuthTokens( + accessToken = accessToken, + refreshToken = refreshToken, + expiresAt = expiresAt, + ) + } + + /** + * Clear all authentication data + */ + suspend fun clearAuthTokens(storage: SecureStorage) { + storage.removeSecure("access_token") + storage.removeSecure("refresh_token") + storage.removeSecure("token_expires_at") + storage.removeSecure("device_id") + storage.removeSecure("organization_id") + storage.removeSecure("user_id") + } +} + +/** + * Data class for authentication tokens + */ +data class AuthTokens( + val accessToken: String, + val refreshToken: String? = null, + val expiresAt: Long, +) diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/storage/FileSystem.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/storage/FileSystem.kt new file mode 100644 index 000000000..1f45f73de --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/storage/FileSystem.kt @@ -0,0 +1,128 @@ +package com.runanywhere.sdk.storage + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.runBlocking + +/** + * Platform-agnostic file system abstraction + * Provides common file operations that are implemented differently on each platform + */ +interface FileSystem { + /** + * Write bytes to a file + */ + suspend fun writeBytes( + path: String, + data: ByteArray, + ) + + /** + * Append bytes to an existing file + * If file doesn't exist, creates it and writes the data + */ + suspend fun appendBytes( + path: String, + data: ByteArray, + ) + + /** + * Write to a file using an output stream (for efficient streaming) + * The output stream is automatically closed when the block completes + */ + suspend fun writeStream( + path: String, + block: suspend (java.io.OutputStream) -> T, + ): T + + /** + * Read bytes from a file + */ + suspend fun readBytes(path: String): ByteArray + + /** + * Check if a file or directory exists + */ + suspend fun exists(path: String): Boolean + + /** + * Check if a file or directory exists (synchronous version) + */ + fun existsSync(path: String): Boolean = runBlocking(Dispatchers.IO) { exists(path) } + + /** + * Check if path is a directory (synchronous version) + */ + fun isDirectorySync(path: String): Boolean = runBlocking(Dispatchers.IO) { isDirectory(path) } + + /** + * List files in a directory (synchronous version) + */ + fun listSync(path: String): List = runBlocking(Dispatchers.IO) { listFiles(path) } + + /** + * Delete a file or directory + */ + suspend fun delete(path: String): Boolean + + /** + * Delete a directory and all its contents recursively + */ + suspend fun deleteRecursively(path: String): Boolean + + /** + * Create a directory (including parent directories if needed) + */ + suspend fun createDirectory(path: String): Boolean + + /** + * Get the size of a file in bytes + */ + suspend fun fileSize(path: String): Long + + /** + * List files in a directory + */ + suspend fun listFiles(path: String): List + + /** + * Move/rename a file + */ + suspend fun move( + from: String, + to: String, + ): Boolean + + /** + * Copy a file + */ + suspend fun copy( + from: String, + to: String, + ): Boolean + + /** + * Check if path is a directory + */ + suspend fun isDirectory(path: String): Boolean + + /** + * Get the app's cache directory path + */ + fun getCacheDirectory(): String + + /** + * Get the app's data directory path + */ + fun getDataDirectory(): String + + /** + * Get a temporary directory path + */ + fun getTempDirectory(): String +} + +/** + * Expected to be provided by each platform + */ +expect fun createFileSystem(): FileSystem diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/storage/PlatformStorage.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/storage/PlatformStorage.kt new file mode 100644 index 000000000..c2ad56bb7 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/storage/PlatformStorage.kt @@ -0,0 +1,93 @@ +package com.runanywhere.sdk.storage + +/** + * Platform-specific storage abstraction for key-value persistence + * This will be implemented differently on each platform (SharedPreferences, UserDefaults, etc.) + */ +interface PlatformStorage { + /** + * Save a string value + */ + suspend fun putString( + key: String, + value: String, + ) + + /** + * Get a string value + */ + suspend fun getString(key: String): String? + + /** + * Save a boolean value + */ + suspend fun putBoolean( + key: String, + value: Boolean, + ) + + /** + * Get a boolean value + */ + suspend fun getBoolean( + key: String, + defaultValue: Boolean = false, + ): Boolean + + /** + * Save a long value + */ + suspend fun putLong( + key: String, + value: Long, + ) + + /** + * Get a long value + */ + suspend fun getLong( + key: String, + defaultValue: Long = 0L, + ): Long + + /** + * Save an integer value + */ + suspend fun putInt( + key: String, + value: Int, + ) + + /** + * Get an integer value + */ + suspend fun getInt( + key: String, + defaultValue: Int = 0, + ): Int + + /** + * Remove a value + */ + suspend fun remove(key: String) + + /** + * Clear all stored values + */ + suspend fun clear() + + /** + * Check if a key exists + */ + suspend fun contains(key: String): Boolean + + /** + * Get all keys + */ + suspend fun getAllKeys(): Set +} + +/** + * Expected to be provided by each platform + */ +expect fun createPlatformStorage(): PlatformStorage diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/utils/BuildConfig.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/utils/BuildConfig.kt new file mode 100644 index 000000000..52a3d31c0 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/utils/BuildConfig.kt @@ -0,0 +1,10 @@ +package com.runanywhere.sdk.utils + +/** + * Build configuration expect/actual pattern for KMP + */ +expect object BuildConfig { + val DEBUG: Boolean + val VERSION_NAME: String + val APPLICATION_ID: String +} diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/utils/PlatformUtils.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/utils/PlatformUtils.kt new file mode 100644 index 000000000..6a83ddfb2 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/utils/PlatformUtils.kt @@ -0,0 +1,36 @@ +package com.runanywhere.sdk.utils + +/** + * Platform-specific utilities + */ +expect object PlatformUtils { + /** + * Get a persistent device identifier + */ + fun getDeviceId(): String + + /** + * Get the platform name (e.g., "android", "jvm", "ios") + */ + fun getPlatformName(): String + + /** + * Get device information as key-value pairs + */ + fun getDeviceInfo(): Map + + /** + * Get OS version + */ + fun getOSVersion(): String + + /** + * Get device model + */ + fun getDeviceModel(): String + + /** + * Get app version if available + */ + fun getAppVersion(): String? +} diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/utils/SDKConstants.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/utils/SDKConstants.kt new file mode 100644 index 000000000..beb657049 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/utils/SDKConstants.kt @@ -0,0 +1,271 @@ +package com.runanywhere.sdk.utils + +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json + +/** + * SDK Constants Management + * Single source of truth for all SDK constants, URLs, and configuration + * Environment-specific values loaded from external config files + */ +object SDKConstants { + // Configuration holder - will be populated from external config + private var config: SDKConfig = SDKConfig() + + // JSON parser for config files + private val json = + Json { + ignoreUnknownKeys = true + isLenient = true + } + + /** + * Initialize constants from configuration string + * This should be called during SDK initialization with the appropriate config + */ + fun loadConfiguration(configJson: String) { + config = json.decodeFromString(configJson) + } + + /** + * Initialize with default development configuration + * Used when no config is provided + */ + fun loadDefaultConfiguration() { + config = SDKConfig() // Uses default empty values + } + + // MARK: - SDK Information + const val VERSION = "0.1.0" + + /** Alias for VERSION to match core.SDKConstants naming */ + const val SDK_VERSION = VERSION + val USER_AGENT get() = "RunAnywhere-Kotlin-SDK/$VERSION" + const val SDK_NAME = "runanywhere-kotlin" + + // Platform-specific constants matching iOS SDKConstants + const val version = VERSION + val platform: String get() = PlatformUtils.getPlatformName() + + // MARK: - Environment Configuration + enum class Environment { + DEVELOPMENT, + STAGING, + PRODUCTION, + } + + val ENVIRONMENT: Environment get() = config.environment + + // MARK: - Base URLs (loaded from config) + val BASE_URL: String get() = config.apiBaseUrl + val CDN_BASE_URL: String get() = config.cdnBaseUrl + val TELEMETRY_URL: String get() = config.telemetryUrl + val ANALYTICS_URL: String get() = config.analyticsUrl + + // MARK: - API Keys (loaded from config) + val DEFAULT_API_KEY: String get() = config.defaultApiKey + + // MARK: - Model Download URLs (loaded from config) + object ModelUrls { + // Default Speech Model - Whisper Base only + val WHISPER_BASE: String get() = config.modelUrls.whisperBase + } + + // MARK: - API Endpoints + object API { + // Authentication + const val AUTHENTICATE = "/v1/auth/token" + const val REFRESH_TOKEN = "/v1/auth/refresh" + const val LOGOUT = "/v1/auth/logout" + + // Configuration + const val CONFIGURATION = "/v1/config" + const val USER_PREFERENCES = "/v1/user/preferences" + + // Models + const val MODELS = "/v1/models" + const val MODEL_INFO = "/v1/models/{id}" + + // Device & Health + const val HEALTH_CHECK = "/v1/health" + const val DEVICE_INFO = "/v1/device" + const val DEVICE_REGISTER = "/v1/device/register" + + // Generation + const val GENERATE = "/v1/generate" + const val GENERATION_HISTORY = "/v1/user/history" + + // Telemetry + const val TELEMETRY_EVENTS = "/v1/telemetry/events" + const val TELEMETRY_BATCH = "/v1/telemetry/batch" + + // Speech-to-Text Analytics + const val STT_ANALYTICS = "/v1/analytics/stt" + const val STT_METRICS = "/v1/analytics/stt/metrics" + } + + // MARK: - Configuration Defaults + object Defaults { + // Network + const val REQUEST_TIMEOUT_MS = 30000L + const val CONNECT_TIMEOUT_MS = 15000L + const val READ_TIMEOUT_MS = 30000L + const val RETRY_ATTEMPTS = 3 + const val RETRY_DELAY_MS = 1000L + + // Database + const val DATABASE_NAME = "runanywhere.db" + const val DATABASE_VERSION = 1 + + // Telemetry + const val TELEMETRY_BATCH_SIZE = 50 + const val TELEMETRY_UPLOAD_INTERVAL_MS = 300000L + const val TELEMETRY_RETRY_ATTEMPTS = 3 + const val TELEMETRY_MAX_EVENTS = 1000 + + // Model Management + const val MAX_MODEL_SIZE_BYTES = 2000000000L + const val MODEL_DOWNLOAD_CHUNK_SIZE = 8192 + const val MAX_CONCURRENT_DOWNLOADS = 2 + + // STT Configuration + const val STT_SAMPLE_RATE = 16000 + const val STT_CHANNELS = 1 + const val STT_BITS_PER_SAMPLE = 16 + const val STT_FRAME_SIZE_MS = 20 + const val STT_BUFFER_SIZE_MS = 300 + + // VAD Configuration + const val VAD_SAMPLE_RATE = 16000 + const val VAD_FRAME_LENGTH_MS = 30 + const val VAD_MIN_SILENCE_DURATION_MS = 500 + const val VAD_MIN_SPEECH_DURATION_MS = 100 + + // Authentication + const val AUTH_TOKEN_REFRESH_THRESHOLD_SECONDS = 300L + const val MAX_AUTH_RETRY_ATTEMPTS = 3 + + // Device Info + const val DEVICE_INFO_UPDATE_INTERVAL_MS = 60000L + const val MEMORY_PRESSURE_UPDATE_INTERVAL_MS = 5000L + } + + // MARK: - Storage Paths + object Storage { + const val BASE_DIRECTORY = "runanywhere" + const val MODELS_DIRECTORY = "$BASE_DIRECTORY/models" + const val CACHE_DIRECTORY = "$BASE_DIRECTORY/cache" + const val TEMP_DIRECTORY = "$BASE_DIRECTORY/temp" + const val LOGS_DIRECTORY = "$BASE_DIRECTORY/logs" + + const val LANGUAGE_MODELS_DIR = "$MODELS_DIRECTORY/language" + const val SPEECH_MODELS_DIR = "$MODELS_DIRECTORY/speech" + const val VISION_MODELS_DIR = "$MODELS_DIRECTORY/vision" + + const val NETWORK_CACHE_DIR = "$CACHE_DIRECTORY/network" + const val MODEL_CACHE_DIR = "$CACHE_DIRECTORY/models" + const val TELEMETRY_CACHE_DIR = "$CACHE_DIRECTORY/telemetry" + } + + // MARK: - Secure Storage Keys + object SecureStorage { + const val KEYSTORE_ALIAS = "runanywhere_sdk_keystore" + const val SHARED_PREFS_NAME = "runanywhere_secure_prefs" + + const val ACCESS_TOKEN_KEY = "access_token" + const val REFRESH_TOKEN_KEY = "refresh_token" + const val API_KEY_KEY = "api_key" + const val DEVICE_ID_KEY = "device_id" + const val USER_PREFERENCES_KEY = "user_preferences" + } + + // MARK: - Development Mode + object Development { + val MOCK_DELAY_MS: Long get() = if (config.environment == Environment.DEVELOPMENT) 500L else 0L + val ENABLE_VERBOSE_LOGGING: Boolean get() = config.enableVerboseLogging + val ENABLE_MOCK_SERVICES: Boolean get() = config.enableMockServices + val USE_COMPREHENSIVE_MOCKS: Boolean get() = config.enableMockServices + + const val MOCK_DEVICE_ID_PREFIX = "dev-device-" + const val MOCK_SESSION_ID_PREFIX = "dev-session-" + const val MOCK_USER_ID_PREFIX = "dev-user-" + } + + // MARK: - Feature Flags + object Features { + val ENABLE_ON_DEVICE_INFERENCE: Boolean get() = config.features.onDeviceInference + val ENABLE_CLOUD_FALLBACK: Boolean get() = config.features.cloudFallback + val ENABLE_TELEMETRY: Boolean get() = config.features.telemetry + val ENABLE_ANALYTICS: Boolean get() = config.features.analytics + val ENABLE_DEBUG_LOGGING: Boolean get() = config.features.debugLogging + val ENABLE_PERFORMANCE_MONITORING: Boolean get() = config.features.performanceMonitoring + val ENABLE_CRASH_REPORTING: Boolean get() = config.features.crashReporting + val ENABLE_VAD: Boolean get() = config.features.vad + val ENABLE_STT_ANALYTICS: Boolean get() = config.features.sttAnalytics + val ENABLE_REAL_TIME_STT: Boolean get() = config.features.realTimeStt + val ENABLE_STT_CONFIDENCE_SCORING: Boolean get() = config.features.sttConfidenceScoring + } + + // MARK: - Error Codes + object ErrorCodes { + const val NETWORK_UNAVAILABLE = 1001 + const val REQUEST_TIMEOUT = 1002 + const val AUTHENTICATION_FAILED = 1003 + const val INVALID_API_KEY = 1004 + + const val MODEL_NOT_FOUND = 2001 + const val MODEL_DOWNLOAD_FAILED = 2002 + const val MODEL_LOAD_FAILED = 2003 + const val INSUFFICIENT_MEMORY = 2004 + + const val STT_INITIALIZATION_FAILED = 3001 + const val STT_PROCESSING_FAILED = 3002 + const val AUDIO_RECORDING_FAILED = 3003 + const val VAD_INITIALIZATION_FAILED = 3004 + + const val INITIALIZATION_FAILED = 5001 + const val CONFIGURATION_INVALID = 5002 + const val PERMISSION_DENIED = 5003 + const val STORAGE_UNAVAILABLE = 5004 + } +} + +/** + * SDK Configuration Model + * This data class represents the complete configuration for the SDK + */ +@Serializable +data class SDKConfig( + val environment: SDKConstants.Environment = SDKConstants.Environment.DEVELOPMENT, + val apiBaseUrl: String = "", + val cdnBaseUrl: String = "", + val telemetryUrl: String = "", + val analyticsUrl: String = "", + val defaultApiKey: String = "", + val enableVerboseLogging: Boolean = false, + val enableMockServices: Boolean = false, + val modelUrls: ModelUrlConfig = ModelUrlConfig(), + val features: FeatureConfig = FeatureConfig(), +) + +@Serializable +data class ModelUrlConfig( + // Default Speech Model - Whisper Base only + val whisperBase: String = "", +) + +@Serializable +data class FeatureConfig( + val onDeviceInference: Boolean = true, + val cloudFallback: Boolean = true, + val telemetry: Boolean = true, + val analytics: Boolean = true, + val debugLogging: Boolean = false, + val performanceMonitoring: Boolean = true, + val crashReporting: Boolean = false, + val vad: Boolean = true, + val sttAnalytics: Boolean = true, + val realTimeStt: Boolean = true, + val sttConfidenceScoring: Boolean = true, +) diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/utils/SimpleInstant.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/utils/SimpleInstant.kt new file mode 100644 index 000000000..f40c3d279 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/utils/SimpleInstant.kt @@ -0,0 +1,22 @@ +package com.runanywhere.sdk.utils + +import kotlinx.serialization.Serializable + +/** + * Simple Instant replacement for avoiding kotlinx-datetime issues + */ +@Serializable +data class SimpleInstant( + val millis: Long, +) { + companion object { + fun now(): SimpleInstant = SimpleInstant(getCurrentTimeMillis()) + } + + fun toEpochMilliseconds(): Long = millis +} + +/** + * Convert Long timestamp to SimpleInstant + */ +fun Long.toSimpleInstant(): SimpleInstant = SimpleInstant(this) diff --git a/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/utils/TimeUtils.kt b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/utils/TimeUtils.kt new file mode 100644 index 000000000..087a9fd0f --- /dev/null +++ b/sdk/runanywhere-kotlin/src/commonMain/kotlin/com/runanywhere/sdk/utils/TimeUtils.kt @@ -0,0 +1,10 @@ +package com.runanywhere.sdk.utils + +/** + * Platform-specific time utilities + */ + +/** + * Get current time in milliseconds since epoch + */ +expect fun getCurrentTimeMillis(): Long diff --git a/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/data/network/HttpClient.kt b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/data/network/HttpClient.kt new file mode 100644 index 000000000..33ac5cf9b --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/data/network/HttpClient.kt @@ -0,0 +1,202 @@ +package com.runanywhere.sdk.data.network + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.ByteArrayOutputStream +import java.net.HttpURLConnection +import java.net.URL + +/** + * JVM implementation of HttpClient using HttpURLConnection. + */ +class JvmHttpClient( + private val config: NetworkConfiguration = NetworkConfiguration(), +) : HttpClient { + private var defaultTimeout: Long = config.connectTimeoutMs + private var defaultHeaders: Map = emptyMap() + + override suspend fun get( + url: String, + headers: Map, + ): HttpResponse = + withContext(Dispatchers.IO) { + executeRequest(url, "GET", headers, null) + } + + override suspend fun post( + url: String, + body: ByteArray, + headers: Map, + ): HttpResponse = + withContext(Dispatchers.IO) { + executeRequest(url, "POST", headers, body) + } + + override suspend fun put( + url: String, + body: ByteArray, + headers: Map, + ): HttpResponse = + withContext(Dispatchers.IO) { + executeRequest(url, "PUT", headers, body) + } + + override suspend fun delete( + url: String, + headers: Map, + ): HttpResponse = + withContext(Dispatchers.IO) { + executeRequest(url, "DELETE", headers, null) + } + + override suspend fun download( + url: String, + headers: Map, + onProgress: ((bytesDownloaded: Long, totalBytes: Long) -> Unit)?, + ): ByteArray = + withContext(Dispatchers.IO) { + val connection = + (URL(url).openConnection() as HttpURLConnection).apply { + requestMethod = "GET" + connectTimeout = defaultTimeout.toInt() + readTimeout = defaultTimeout.toInt() + defaultHeaders.forEach { (k, v) -> setRequestProperty(k, v) } + headers.forEach { (k, v) -> setRequestProperty(k, v) } + } + + try { + val totalBytes = connection.contentLengthLong + val buffer = ByteArray(8192) + val output = ByteArrayOutputStream() + var bytesDownloaded = 0L + + connection.inputStream.use { input -> + var bytesRead: Int + while (input.read(buffer).also { bytesRead = it } != -1) { + output.write(buffer, 0, bytesRead) + bytesDownloaded += bytesRead + onProgress?.invoke(bytesDownloaded, totalBytes) + } + } + + output.toByteArray() + } finally { + connection.disconnect() + } + } + + override suspend fun upload( + url: String, + data: ByteArray, + headers: Map, + onProgress: ((bytesUploaded: Long, totalBytes: Long) -> Unit)?, + ): HttpResponse = + withContext(Dispatchers.IO) { + val connection = + (URL(url).openConnection() as HttpURLConnection).apply { + requestMethod = "POST" + doOutput = true + connectTimeout = defaultTimeout.toInt() + readTimeout = defaultTimeout.toInt() + defaultHeaders.forEach { (k, v) -> setRequestProperty(k, v) } + headers.forEach { (k, v) -> setRequestProperty(k, v) } + setFixedLengthStreamingMode(data.size) + } + + try { + connection.outputStream.use { output -> + var bytesUploaded = 0L + val chunkSize = 8192 + var offset = 0 + while (offset < data.size) { + val length = minOf(chunkSize, data.size - offset) + output.write(data, offset, length) + bytesUploaded += length + offset += length + onProgress?.invoke(bytesUploaded, data.size.toLong()) + } + output.flush() + } + + val responseBody = connection.inputStream.use { it.readBytes() } + val responseHeaders = + connection.headerFields + .filterKeys { it != null } + .mapKeys { it.key!! } + + HttpResponse( + statusCode = connection.responseCode, + body = responseBody, + headers = responseHeaders, + ) + } finally { + connection.disconnect() + } + } + + override fun setDefaultTimeout(timeoutMillis: Long) { + defaultTimeout = timeoutMillis + } + + override fun setDefaultHeaders(headers: Map) { + defaultHeaders = headers + } + + private fun executeRequest( + url: String, + method: String, + headers: Map, + body: ByteArray?, + ): HttpResponse { + val connection = + (URL(url).openConnection() as HttpURLConnection).apply { + requestMethod = method + connectTimeout = defaultTimeout.toInt() + readTimeout = defaultTimeout.toInt() + doOutput = body != null + defaultHeaders.forEach { (k, v) -> setRequestProperty(k, v) } + headers.forEach { (k, v) -> setRequestProperty(k, v) } + } + + try { + body?.let { data -> + connection.outputStream.use { it.write(data) } + } + + val responseCode = connection.responseCode + val responseBody = + try { + if (responseCode in 200..299) { + connection.inputStream.use { it.readBytes() } + } else { + connection.errorStream?.use { it.readBytes() } ?: ByteArray(0) + } + } catch (e: Exception) { + ByteArray(0) + } + + val responseHeaders = + connection.headerFields + .filterKeys { it != null } + .mapKeys { it.key!! } + + return HttpResponse( + statusCode = responseCode, + body = responseBody, + headers = responseHeaders, + ) + } finally { + connection.disconnect() + } + } +} + +/** + * JVM actual implementation for creating HttpClient. + */ +actual fun createHttpClient(): HttpClient = JvmHttpClient() + +/** + * JVM actual implementation for creating HttpClient with configuration. + */ +actual fun createHttpClient(config: NetworkConfiguration): HttpClient = JvmHttpClient(config) diff --git a/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/data/transform/IncompleteBytesToStringBuffer.kt b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/data/transform/IncompleteBytesToStringBuffer.kt new file mode 100644 index 000000000..122e6bc75 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/data/transform/IncompleteBytesToStringBuffer.kt @@ -0,0 +1,77 @@ +package com.runanywhere.sdk.data.transform + +import java.nio.ByteBuffer +import java.nio.CharBuffer +import java.nio.charset.CoderResult + +class IncompleteBytesToStringBuffer( + initialByteCapacity: Int = 8 * 1024, + private val charCapacity: Int = 8 * 1024 +) { + private val decoder = Charsets.UTF_8.newDecoder() + private var inBuf: ByteBuffer = ByteBuffer.allocate(initialByteCapacity) + private val outBuf: CharBuffer = CharBuffer.allocate(charCapacity) + + fun push(chunk: ByteArray): String { + ensureCapacity(chunk.size) + inBuf.put(chunk) // 1) collect + inBuf.flip() // switch to read mode + + val sb = StringBuilder() + + while (true) { + outBuf.clear() + val res: CoderResult = decoder.decode(inBuf, outBuf, false) // 2) opportunistic decode + outBuf.flip() + sb.append(outBuf) + + when { + res.isOverflow -> continue // outBuf too small, loop drains more + res.isUnderflow -> break // need more bytes for next char (dangling) + res.isError -> res.throwException() // invalid sequence + } + } + + inBuf.compact() // 3) drop consumed bytes, keep leftovers at start + return sb.toString() + } + + fun finish(): String { + // Call when stream ends to flush any buffered partial state + inBuf.flip() + val sb = StringBuilder() + + while (true) { + outBuf.clear() + val res = decoder.decode(inBuf, outBuf, true) + outBuf.flip() + sb.append(outBuf) + + if (res.isOverflow) continue + if (res.isUnderflow) break + if (res.isError) res.throwException() + } + + outBuf.clear() + decoder.flush(outBuf) + outBuf.flip() + sb.append(outBuf) + + inBuf.clear() + return sb.toString() + } + + private fun ensureCapacity(incoming: Int) { + if (inBuf.remaining() >= incoming) return + + // Preserve existing buffered leftovers + inBuf.flip() + val needed = inBuf.remaining() + incoming + var newCap = inBuf.capacity() + while (newCap < needed) newCap *= 2 + + val newBuf = ByteBuffer.allocate(newCap) + newBuf.put(inBuf) + inBuf = newBuf + } +} \ No newline at end of file diff --git a/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/CppBridge.kt b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/CppBridge.kt new file mode 100644 index 000000000..8d6970e8f --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/CppBridge.kt @@ -0,0 +1,619 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * Central coordinator for all C++ bridge operations. + * Follows iOS CppBridge.swift architecture with two-phase initialization. + */ + +package com.runanywhere.sdk.foundation.bridge + +import com.runanywhere.sdk.foundation.Logging +import com.runanywhere.sdk.foundation.SDKEnvironment +import com.runanywhere.sdk.foundation.SDKLogger +import com.runanywhere.sdk.foundation.bridge.extensions.CppBridgeAuth +import com.runanywhere.sdk.foundation.bridge.extensions.CppBridgeDevice +import com.runanywhere.sdk.foundation.bridge.extensions.CppBridgeEvents +import com.runanywhere.sdk.foundation.bridge.extensions.CppBridgeModelAssignment +import com.runanywhere.sdk.foundation.bridge.extensions.CppBridgePlatform +import com.runanywhere.sdk.foundation.bridge.extensions.CppBridgePlatformAdapter +import com.runanywhere.sdk.foundation.bridge.extensions.CppBridgeTelemetry +import com.runanywhere.sdk.foundation.logging.SentryDestination +import com.runanywhere.sdk.foundation.logging.SentryManager +import com.runanywhere.sdk.native.bridge.RunAnywhereBridge +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch + +/** + * CppBridge is the central coordinator for all C++ interop via JNI. + * + * Initialization follows a two-phase pattern: + * - Phase 1 (synchronous): Core initialization including platform adapter registration + * - Phase 2 (asynchronous): Service initialization for model assignment and platform services + * + * CRITICAL: Platform adapter must be registered FIRST before any C++ calls. + * + * NOTE: This SDK is backend-agnostic. Backend registration (LlamaCPP, ONNX, etc.) + * is handled by the individual backend modules, not by the core SDK. + */ +object CppBridge { + private const val TAG = "CppBridge" + private val logger = SDKLogger(TAG) + + /** + * SDK environment configuration. + */ + enum class Environment { + DEVELOPMENT, + STAGING, + PRODUCTION, + ; + + /** + * Get the C++ compatible environment value. + */ + val cValue: Int + get() = ordinal + } + + @Volatile + private var _environment: Environment = Environment.DEVELOPMENT + + @Volatile + private var _isInitialized: Boolean = false + + @Volatile + private var _servicesInitialized: Boolean = false + + @Volatile + private var _nativeLibraryLoaded: Boolean = false + + private val lock = Any() + + /** Coroutine scope for async SDK operations, cancelled on shutdown */ + private val sdkScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + /** + * Current SDK environment. + */ + val environment: Environment + get() = _environment + + /** + * Whether Phase 1 initialization is complete. + */ + val isInitialized: Boolean + get() = _isInitialized + + /** + * Whether Phase 2 services initialization is complete. + */ + val servicesInitialized: Boolean + get() = _servicesInitialized + + /** + * Whether the native commons library is loaded. + * This only indicates the core library - backend availability is separate. + */ + val isNativeLibraryLoaded: Boolean + get() = _nativeLibraryLoaded + + /** + * Phase 1: Core Initialization (Synchronous, ~1-5ms, NO network calls) + * + * This is a fast, synchronous initialization that can be safely called from any thread, + * including the main/UI thread. It does NOT make any network calls. + * + * Initializes the core SDK components in this order: + * 1. Native Library Loading - Load core JNI library (if available) + * 2. Platform Adapter - MUST be before C++ calls + * 3. Logging configuration + * 4. Events registration + * 5. Telemetry configuration (stores credentials, no network) + * + * **Important:** Authentication and device registration happen in Phase 2 ([initializeServices]), + * which MUST be called from a background thread (e.g., `Dispatchers.IO`). + * + * NOTE: Backend registration (LlamaCPP, ONNX) is NOT done here. + * Backends are registered by the app calling LlamaCPP.register() and ONNX.register() + * from the respective backend modules. + * + * Mirrors Swift SDK's initialize() which is also synchronous with no network calls. + * + * @param environment The SDK environment to use + * @param apiKey API key for authentication (required for production/staging) + * @param baseURL Backend API base URL (required for production/staging) + */ + fun initialize( + environment: Environment = Environment.DEVELOPMENT, + apiKey: String? = null, + baseURL: String? = null, + ) { + synchronized(lock) { + if (_isInitialized) { + return + } + + val initStartTime = System.currentTimeMillis() + + _environment = environment + + // Try to load native library (optional - SDK works without it for non-inference features) + tryLoadNativeLibrary() + + // CRITICAL: Register platform adapter FIRST before any C++ calls + CppBridgePlatformAdapter.register() + + // Configure logging with Sentry integration + // Setup Sentry hooks so Logging can trigger Sentry setup/teardown + setupSentryHooks(environment) + + // Initialize Sentry if enabled for this environment (staging/production) + if (environment != Environment.DEVELOPMENT) { + setupSentryLogging(environment) + } + + // Register telemetry HTTP callback (just sets isRegistered flag) + CppBridgeTelemetry.register() + + // CRITICAL: Set environment early so CppBridgeDevice.isDeviceRegisteredCallback() + // can determine correct behavior for production/staging modes + CppBridgeTelemetry.setEnvironment(environment.cValue) + + // Configure telemetry base URL and API key ONLY for production/staging mode + // In development mode, we use Supabase URL from C++ dev config + // NOTE: Authentication is deferred to Phase 2 (initializeServices) to avoid blocking + // This matches Swift SDK where authentication is done in completeServicesInitialization() + if (environment != Environment.DEVELOPMENT) { + if (!baseURL.isNullOrEmpty()) { + CppBridgeTelemetry.setBaseUrl(baseURL) + logger.debug("Telemetry base URL configured") + } + if (!apiKey.isNullOrEmpty()) { + CppBridgeTelemetry.setApiKey(apiKey) + logger.debug("Telemetry API key configured") + } + logger.debug("Production/staging mode: authentication will occur in Phase 2 (initializeServices)") + } else { + logger.debug("Development mode: using Supabase URL from C++ dev config") + } + + // Register device callbacks (sets up JNI callbacks for C++ to call) + CppBridgeDevice.register() + + // Initialize SDK config with version, platform, and auth info + // This is REQUIRED for device registration to use the correct sdk_version + // Mirrors Swift SDK's rac_sdk_init() call in CppBridge+State.swift + initializeSdkConfig(environment, apiKey, baseURL) + + // Initialize telemetry manager with device info + // This creates the C++ telemetry manager and sets up HTTP callback + initializeTelemetryManager(environment) + + // Register analytics events callback AFTER telemetry manager is initialized + // This routes C++ events (LLM/STT/TTS) to telemetry for batching and HTTP transport + val telemetryHandle = CppBridgeTelemetry.getTelemetryHandle() + if (telemetryHandle != 0L) { + CppBridgeEvents.register(telemetryHandle) + // Emit SDK init started event (mirroring Swift SDK) + CppBridgeEvents.emitSDKInitStarted() + } else { + logger.warn("Telemetry handle not available, analytics events will not be tracked") + } + + _isInitialized = true + + // Emit SDK init completed event with duration + val initDurationMs = System.currentTimeMillis() - initStartTime + CppBridgeEvents.emitSDKInitCompleted(initDurationMs.toDouble()) + logger.info("✅ Phase 1 complete in ${initDurationMs}ms (${environment.name})") + } + } + + /** + * Initialize the C++ telemetry manager with device info. + * Mirrors Swift SDK's CppBridge.Telemetry.initialize(environment:) + * + * Note: If device ID is unavailable (secure storage failure), telemetry is skipped + * to avoid creating orphaned/duplicate device records. The app continues to function. + */ + private fun initializeTelemetryManager(environment: Environment) { + try { + // Get device ID (persistent UUID) - this may initialize it if not already done + val deviceId = CppBridgeDevice.getDeviceIdCallback() + + if (deviceId.isEmpty()) { + // Device ID unavailable - likely secure storage issue + // Skip telemetry to avoid creating orphaned records with temporary IDs + logger.error( + "Device ID unavailable - telemetry will be disabled for this session. " + + "This usually indicates secure storage is not properly initialized. " + + "Ensure AndroidPlatformContext.initialize() is called before SDK initialization.", + ) + return + } + + // Get device info from provider or defaults + val provider = CppBridgeDevice.deviceInfoProvider + val deviceModel = provider?.getDeviceModel() ?: getDefaultDeviceModel() + val osVersion = provider?.getOSVersion() ?: getDefaultOsVersion() + val sdkVersion = com.runanywhere.sdk.utils.SDKConstants.VERSION + + logger.info("Initializing telemetry manager: device=$deviceId, model=$deviceModel, os=$osVersion") + + // Initialize telemetry manager with C++ via JNI + CppBridgeTelemetry.initialize( + environment = environment.cValue, + deviceId = deviceId, + deviceModel = deviceModel, + osVersion = osVersion, + sdkVersion = sdkVersion, + ) + + logger.info("✅ Telemetry manager initialized") + } catch (e: Exception) { + logger.error("Failed to initialize telemetry manager: ${e.message}") + } + } + + /** + * Initialize SDK configuration with version, platform, and auth info. + * + * This sets up the C++ rac_sdk_config which is used by device registration + * to include the correct sdk_version (instead of "unknown"). + * + * Mirrors Swift SDK's rac_sdk_init() call in CppBridge+State.swift + * + * @param environment SDK environment + * @param apiKey API key for authentication (required for production/staging) + * @param baseURL Backend API base URL (required for production/staging) + */ + private fun initializeSdkConfig(environment: Environment, apiKey: String?, baseURL: String?) { + try { + val deviceId = CppBridgeDevice.getDeviceIdCallback() + val platform = "android" + val sdkVersion = com.runanywhere.sdk.utils.SDKConstants.SDK_VERSION + + logger.info("Initializing SDK config: version=$sdkVersion, platform=$platform, env=${environment.name}") + if (!apiKey.isNullOrEmpty()) { + logger.info("API key provided: ${apiKey.take(10)}...") + } + if (!baseURL.isNullOrEmpty()) { + logger.info("Base URL: $baseURL") + } + + val result = + RunAnywhereBridge.racSdkInit( + environment = environment.cValue, + deviceId = deviceId.ifEmpty { null }, + platform = platform, + sdkVersion = sdkVersion, + apiKey = apiKey, + baseUrl = baseURL, + ) + + if (result == 0) { + logger.info("✅ SDK config initialized with version: $sdkVersion") + } else { + logger.warn("SDK config init returned: $result") + } + } catch (e: Exception) { + logger.error("Failed to initialize SDK config: ${e.message}") + } + } + + /** + * Get default device model (cross-platform fallback). + */ + private fun getDefaultDeviceModel(): String { + return try { + val buildClass = Class.forName("android.os.Build") + buildClass.getField("MODEL").get(null) as? String ?: "unknown" + } catch (e: Exception) { + System.getProperty("os.name") ?: "unknown" + } + } + + /** + * Get default OS version (cross-platform fallback). + */ + private fun getDefaultOsVersion(): String { + return try { + val versionClass = Class.forName("android.os.Build\$VERSION") + versionClass.getField("RELEASE").get(null) as? String ?: "unknown" + } catch (e: Exception) { + System.getProperty("os.version") ?: "unknown" + } + } + + /** + * Try to load the native commons library. + * This is optional - the SDK works without it for non-inference features. + * + * NOTE: Backend registration (LlamaCPP, ONNX) is NOT done here. + * Apps must call LlamaCPP.register() and ONNX.register() from the + * respective backend modules to enable AI inference. + */ + private fun tryLoadNativeLibrary() { + logger.info("Starting native library loading sequence...") + + _nativeLibraryLoaded = RunAnywhereBridge.ensureNativeLibraryLoaded() + + if (_nativeLibraryLoaded) { + logger.info("✅ Native commons library loaded successfully") + logger.info("AI inference features are AVAILABLE") + } else { + logger.warn("❌ Native commons library not available.") + logger.warn("AI inference features are DISABLED.") + logger.warn("Ensure librunanywhere_jni.so is in your APK's lib/ folder.") + } + } + + /** + * Phase 2: Services Initialization (Asynchronous) + * + * Initializes the service components: + * 1. Authentication with backend (production/staging only, makes HTTP calls) + * 2. Model Assignment registration + * 3. Platform services registration + * 4. Device registration (triggers backend call) + * + * Must be called after [initialize] completes. + * Must be called from a background thread (e.g., Dispatchers.IO) as it makes network calls. + * Mirrors Swift SDK's completeServicesInitialization() + */ + suspend fun initializeServices() { + synchronized(lock) { + if (!_isInitialized) { + throw IllegalStateException("CppBridge.initialize() must be called before initializeServices()") + } + + if (_servicesInitialized) { + return + } + + // Step 1: Authenticate with backend for production/staging mode + // This is done in Phase 2 (not Phase 1) to avoid blocking main thread + // Mirrors Swift SDK's CppBridge.Auth.authenticate() in completeServicesInitialization() + if (_environment != Environment.DEVELOPMENT) { + val baseUrl = CppBridgeTelemetry.getBaseUrl() + val apiKey = CppBridgeTelemetry.getApiKey() + + if (!apiKey.isNullOrEmpty() && !baseUrl.isNullOrEmpty()) { + try { + logger.info("Authenticating with backend...") + val deviceId = CppBridgeDevice.getDeviceId() ?: CppBridgeDevice.getDeviceIdCallback() + CppBridgeAuth.authenticate( + apiKey = apiKey, + baseUrl = baseUrl, + deviceId = deviceId, + platform = "android", + sdkVersion = com.runanywhere.sdk.utils.SDKConstants.SDK_VERSION, + ) + logger.info("Authentication successful") + } catch (e: Exception) { + logger.error("Authentication failed: ${e.message}") + // Non-fatal: continue with services initialization + } + } else { + logger.warn("Missing API key or base URL for authentication") + } + } + + // Step 2: Register model assignment callbacks + // IMPORTANT: Register WITHOUT auto-fetch first to avoid threading issues + // The C++ auto-fetch mechanism can cause state issues when called during JNI registration + // This mirrors Swift SDK which always registers with autoFetch: false + logger.info("========== STEP 2: MODEL ASSIGNMENT REGISTRATION ==========") + val shouldFetchModels = _environment != Environment.DEVELOPMENT + logger.info("📦 Environment: ${_environment.name}, shouldFetchModels: $shouldFetchModels") + logger.info("📦 Registering model assignment callbacks (autoFetch: false)") + val registrationSucceeded = CppBridgeModelAssignment.register(autoFetch = false) // Always false! + logger.info("📦 Registration result: $registrationSucceeded") + + // If auto-fetch is needed, trigger it asynchronously off the synchronized block + // This mirrors Swift SDK's Task.detached pattern: + // Task.detached { + // _ = try await ModelAssignment.fetch(forceRefresh: true) + // } + // By fetching asynchronously, we: + // 1. Avoid blocking the initialization thread + // 2. Ensure callbacks are fully registered before HTTP fetch begins + if (shouldFetchModels && registrationSucceeded) { + logger.info("📦 Will fetch model assignments asynchronously...") + sdkScope.launch { + try { + logger.info("📦 [Async] Fetching model assignments from backend (forceRefresh=true)...") + val assignmentsJson = CppBridgeModelAssignment.fetchModelAssignments(forceRefresh = true) + logger.info("📦 [Async] Model assignments fetched successfully (${assignmentsJson.length} chars)") + logger.info("📦 [Async] Response: ${assignmentsJson.take(500)}...") + } catch (e: Exception) { + logger.warn("📦 [Async] Model assignment fetch failed (non-critical): ${e.message}") + } + } + } else if (!registrationSucceeded) { + logger.error("📦 Skipping model assignment fetch - callback registration failed") + logger.error("📦 This may indicate a JNI method signature mismatch or native library issue") + } else { + logger.info("📦 Skipping model fetch (development mode)") + } + logger.info("========== STEP 2 COMPLETE ==========") + + // Register platform services callbacks + CppBridgePlatform.register() + + // Flush any queued telemetry events now that HTTP should be configured + // This ensures events queued during Phase 1 initialization are sent + CppBridgeTelemetry.flush() + + // Trigger device registration with backend (non-blocking, best-effort) + // Mirrors Swift SDK's CppBridge.Device.registerIfNeeded(environment:) + try { + val deviceId = CppBridgeDevice.getDeviceIdCallback() + + // Get build token for development mode (mirrors Swift SDK) + // Swift: let buildTokenString = environment == .development ? CppBridge.DevConfig.buildToken : nil + val buildToken = + if (_environment == Environment.DEVELOPMENT) { + try { + val token = RunAnywhereBridge.racDevConfigGetBuildToken() + if (!token.isNullOrEmpty()) { + logger.debug("Using build token from dev config for device registration") + token + } else { + logger.debug("No build token available in dev config") + null + } + } catch (e: Exception) { + logger.warn("Failed to get build token: ${e.message}") + null + } + } else { + null // Build token only used in development mode + } + + val success = + CppBridgeDevice.triggerRegistration( + environment = _environment.cValue, + buildToken = buildToken, + ) + if (success) { + logger.info("✅ Device registration triggered") + // Emit device registered event + CppBridgeEvents.emitDeviceRegistered(deviceId) + } else { + logger.warn("Device registration not triggered (may already be registered)") + } + } catch (e: Exception) { + // Non-critical failure - device registration is best-effort + logger.warn("Device registration failed (non-critical): ${e.message}") + // Emit device registration failed event + CppBridgeEvents.emitDeviceRegistrationFailed(e.message ?: "Unknown error") + } + + _servicesInitialized = true + logger.info("✅ Phase 2 services initialization complete") + } + } + + /** + * Shutdown the SDK and release all resources. + * + * Unregisters all extensions in reverse order of registration. + */ + fun shutdown() { + synchronized(lock) { + if (!_isInitialized) { + return + } + + // Cancel any pending async operations + sdkScope.cancel() + + // Unregister Phase 2 services (reverse order) + if (_servicesInitialized) { + CppBridgePlatform.unregister() + CppBridgeModelAssignment.unregister() + } + + // Unregister Phase 1 core extensions (reverse order) + CppBridgeDevice.unregister() + CppBridgeTelemetry.unregister() + CppBridgeEvents.unregister() + CppBridgePlatformAdapter.unregister() + + // Teardown Sentry logging + teardownSentryLogging() + + // Clear Sentry hooks + Logging.sentrySetupHook = null + Logging.sentryTeardownHook = null + + _servicesInitialized = false + _isInitialized = false + } + } + + /** + * Check if the C++ core is initialized. + * + * @return true if rac_is_initialized() returns true + */ + fun isNativeInitialized(): Boolean { + // TODO: Call rac_is_initialized() + return _isInitialized + } + + // ============================================================================= + // SENTRY LOGGING INTEGRATION + // ============================================================================= + + /** + * Setup Sentry hooks so Logging can trigger Sentry setup/teardown dynamically. + * + * This allows runtime enabling/disabling of Sentry logging via Logging.setSentryLoggingEnabled() + */ + private fun setupSentryHooks(environment: Environment) { + Logging.sentrySetupHook = { + setupSentryLogging(environment) + } + + Logging.sentryTeardownHook = { + teardownSentryLogging() + } + } + + /** + * Initialize Sentry logging for error tracking. + * + * Matches iOS SDK's setupSentryLogging() in Logging class. + * + * @param environment SDK environment for tagging Sentry events + */ + private fun setupSentryLogging(environment: Environment) { + val sdkEnvironment = + when (environment) { + Environment.DEVELOPMENT -> SDKEnvironment.DEVELOPMENT + Environment.STAGING -> SDKEnvironment.STAGING + Environment.PRODUCTION -> SDKEnvironment.PRODUCTION + } + + try { + // Initialize Sentry manager + SentryManager.initialize(environment = sdkEnvironment) + + if (SentryManager.isInitialized) { + // Add Sentry destination to logging system + Logging.addDestinationSync(SentryDestination()) + logger.info("✅ Sentry logging initialized") + } + } catch (e: Exception) { + logger.error("Failed to setup Sentry logging: ${e.message}") + } + } + + /** + * Teardown Sentry logging. + */ + private fun teardownSentryLogging() { + try { + // Remove Sentry destination from logging system + val sentryDestination = + Logging.destinations.find { + it.identifier == SentryDestination.DESTINATION_ID + } + if (sentryDestination != null) { + Logging.removeDestinationSync(sentryDestination) + } + + // Close Sentry manager + SentryManager.close() + logger.info("Sentry logging disabled") + } catch (e: Exception) { + logger.error("Failed to teardown Sentry logging: ${e.message}") + } + } +} diff --git a/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeAuth.kt b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeAuth.kt new file mode 100644 index 000000000..ad1eabde4 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeAuth.kt @@ -0,0 +1,542 @@ +/** + * CppBridge+Auth.kt + * RunAnywhere SDK + * + * Authentication bridge extension for production/staging mode. + * Handles full auth flow: JSON building, HTTP, parsing, state storage. + * + * Mirrors Swift SDK's CppBridge+Auth.swift implementation. + */ +package com.runanywhere.sdk.foundation.bridge.extensions + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.io.BufferedReader +import java.io.InputStreamReader +import java.io.OutputStreamWriter +import java.net.HttpURLConnection +import java.net.URL +import java.util.concurrent.atomic.AtomicReference + +/** + * Authentication response from the backend + */ +@Serializable +data class AuthenticationResponse( + @SerialName("access_token") val accessToken: String, + @SerialName("device_id") val deviceId: String, + @SerialName("expires_in") val expiresIn: Int, + @SerialName("organization_id") val organizationId: String, + @SerialName("refresh_token") val refreshToken: String, + @SerialName("token_type") val tokenType: String, + @SerialName("user_id") val userId: String? = null, +) + +/** + * Authentication request body + */ +@Serializable +data class AuthenticationRequest( + @SerialName("api_key") val apiKey: String, + @SerialName("device_id") val deviceId: String, + val platform: String, + @SerialName("sdk_version") val sdkVersion: String, +) + +/** + * Refresh token request body + */ +@Serializable +data class RefreshTokenRequest( + @SerialName("device_id") val deviceId: String, + @SerialName("refresh_token") val refreshToken: String, +) + +/** + * Authentication bridge for production/staging mode. + * Handles JWT token acquisition and management. + * + * **Threading Requirements:** + * All network operations (authenticate, refreshToken, getValidAccessToken) perform + * blocking HTTP calls and MUST be called from a background thread. Calling from the + * main/UI thread will throw [IllegalStateException] on Android to prevent ANR. + * + * Example: + * ```kotlin + * // Correct - call from background thread + * withContext(Dispatchers.IO) { + * CppBridgeAuth.authenticate(apiKey, baseUrl, deviceId) + * } + * ``` + */ +object CppBridgeAuth { + private const val TAG = "CppBridge/Auth" + private const val ENDPOINT_AUTHENTICATE = "/api/v1/auth/sdk/authenticate" + private const val ENDPOINT_REFRESH = "/api/v1/auth/sdk/refresh" + + // Authentication state + private val _accessToken = AtomicReference(null) + private val _refreshToken = AtomicReference(null) + private val _deviceId = AtomicReference(null) + private val _organizationId = AtomicReference(null) + private val _userId = AtomicReference(null) + private val _expiresAt = AtomicReference(null) + private val _baseUrl = AtomicReference(null) + private val _apiKey = AtomicReference(null) + + private val json = + Json { + ignoreUnknownKeys = true + isLenient = true + } + + /** + * Check if we're on the main thread and warn if so. + * Network operations should use coroutines with Dispatchers.IO. + * + * Note: This is a soft check - callers should use proper coroutine dispatchers. + */ + @Suppress("unused") + private fun ensureNotMainThread(operation: String) { + // Main thread check is skipped for JVM compatibility. + // Callers should use withContext(Dispatchers.IO) for network operations. + // On Android, StrictMode or ANR detection will catch main thread network calls. + } + + /** + * Current access token (JWT) for Bearer authentication. + * Use getValidToken() instead for automatic refresh handling. + */ + val accessToken: String? + get() = _accessToken.get() + + /** + * Check if token needs refresh (expires within 5 minutes) + */ + val tokenNeedsRefresh: Boolean + get() { + val expiresAt = _expiresAt.get() ?: return true + val nowMs = System.currentTimeMillis() + val fiveMinutesMs = 5 * 60 * 1000 + return nowMs >= (expiresAt - fiveMinutesMs) + } + + /** + * Check if currently authenticated + */ + val isAuthenticated: Boolean + get() = _accessToken.get() != null && !tokenNeedsRefresh + + /** + * Get a valid access token, automatically refreshing if needed. + * This is the preferred way to get the token for requests. + * + * @return Valid access token, or null if not authenticated and can't refresh + */ + fun getValidToken(): String? { + val currentToken = _accessToken.get() + + // If we have a valid token, return it + if (currentToken != null && !tokenNeedsRefresh) { + return currentToken + } + + // Try to refresh if we have refresh token and base URL + val refreshToken = _refreshToken.get() + val baseUrl = _baseUrl.get() + + if (refreshToken != null && baseUrl != null) { + try { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "🔄 Token expired or expiring soon, refreshing...", + ) + return refreshAccessToken(baseUrl) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "❌ Token refresh failed: ${e.message}", + ) + + // Try re-authenticating if we have API key + val apiKey = _apiKey.get() + val deviceId = _deviceId.get() + if (apiKey != null && deviceId != null) { + try { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "🔐 Refresh failed, re-authenticating...", + ) + authenticate(apiKey, baseUrl, deviceId) + return _accessToken.get() + } catch (authE: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "❌ Re-authentication failed: ${authE.message}", + ) + } + } + } + } + + // Return current token even if expired (caller will get 401) + return currentToken + } + + /** + * Authenticate with the backend using API key. + * Gets a JWT access token for subsequent requests. + * + * **Must be called from a background thread.** Will throw [IllegalStateException] + * if called from the main/UI thread on Android. + * + * @param apiKey The API key for authentication + * @param baseUrl The backend base URL + * @param deviceId The device ID + * @param platform Platform string (e.g., "android") + * @param sdkVersion SDK version string + * @return AuthenticationResponse on success + * @throws Exception on failure + * @throws IllegalStateException if called from main thread + */ + fun authenticate( + apiKey: String, + baseUrl: String, + deviceId: String, + platform: String = "android", + sdkVersion: String = "0.1.0", + ): AuthenticationResponse { + // Fail fast if called from main thread to prevent ANR + ensureNotMainThread("authenticate") + + // Store config for future refresh/re-auth + _baseUrl.set(baseUrl) + _apiKey.set(apiKey) + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "Starting authentication with backend...", + ) + + // Build request body + val request = + AuthenticationRequest( + apiKey = apiKey, + deviceId = deviceId, + platform = platform, + sdkVersion = sdkVersion, + ) + val requestJson = json.encodeToString(AuthenticationRequest.serializer(), request) + + // Build full URL + val fullUrl = baseUrl.trimEnd('/') + ENDPOINT_AUTHENTICATE + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Auth request to: $fullUrl", + ) + + // Make HTTP request + val connection = URL(fullUrl).openConnection() as HttpURLConnection + try { + connection.requestMethod = "POST" + connection.setRequestProperty("Content-Type", "application/json") + connection.setRequestProperty("Accept", "application/json") + connection.doOutput = true + connection.connectTimeout = 30000 + connection.readTimeout = 30000 + + // Write request body + OutputStreamWriter(connection.outputStream).use { writer -> + writer.write(requestJson) + writer.flush() + } + + val responseCode = connection.responseCode + + if (responseCode == HttpURLConnection.HTTP_OK || responseCode == HttpURLConnection.HTTP_CREATED) { + // Read response + val responseBody = + BufferedReader(InputStreamReader(connection.inputStream)).use { reader -> + reader.readText() + } + + // Parse response + val response = json.decodeFromString(AuthenticationResponse.serializer(), responseBody) + + // Store in state + storeAuthState(response) + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "Authentication successful, token expires in ${response.expiresIn}s", + ) + + return response + } else { + // Read error response + val errorBody = + try { + BufferedReader(InputStreamReader(connection.errorStream)).use { reader -> + reader.readText() + } + } catch (e: Exception) { + "No error body" + } + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "❌ Authentication failed: HTTP $responseCode - $errorBody", + ) + + throw Exception("Authentication failed: HTTP $responseCode - $errorBody") + } + } finally { + connection.disconnect() + } + } + + /** + * Refresh the access token using the refresh token. + * + * **Must be called from a background thread.** Will throw [IllegalStateException] + * if called from the main/UI thread on Android. + * + * @param baseUrl The backend base URL + * @return New access token + * @throws Exception on failure + * @throws IllegalStateException if called from main thread + */ + fun refreshAccessToken(baseUrl: String): String { + // Fail fast if called from main thread to prevent ANR + ensureNotMainThread("refreshAccessToken") + + val refreshToken = + _refreshToken.get() + ?: throw Exception("No refresh token available") + val deviceId = + _deviceId.get() + ?: throw Exception("No device ID available") + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "🔄 Refreshing access token...", + ) + + // Build request body + val request = + RefreshTokenRequest( + deviceId = deviceId, + refreshToken = refreshToken, + ) + val requestJson = json.encodeToString(RefreshTokenRequest.serializer(), request) + + // Build full URL + val fullUrl = baseUrl.trimEnd('/') + ENDPOINT_REFRESH + + // Make HTTP request + val connection = URL(fullUrl).openConnection() as HttpURLConnection + try { + connection.requestMethod = "POST" + connection.setRequestProperty("Content-Type", "application/json") + connection.setRequestProperty("Accept", "application/json") + connection.doOutput = true + connection.connectTimeout = 30000 + connection.readTimeout = 30000 + + // Write request body + OutputStreamWriter(connection.outputStream).use { writer -> + writer.write(requestJson) + writer.flush() + } + + val responseCode = connection.responseCode + + if (responseCode == HttpURLConnection.HTTP_OK || responseCode == HttpURLConnection.HTTP_CREATED) { + // Read response + val responseBody = + BufferedReader(InputStreamReader(connection.inputStream)).use { reader -> + reader.readText() + } + + // Parse response + val response = json.decodeFromString(AuthenticationResponse.serializer(), responseBody) + + // Store in state + storeAuthState(response) + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "✅ Token refresh successful!", + ) + + return response.accessToken + } else { + val errorBody = + try { + BufferedReader(InputStreamReader(connection.errorStream)).use { reader -> + reader.readText() + } + } catch (e: Exception) { + "No error body" + } + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "❌ Token refresh failed: HTTP $responseCode - $errorBody", + ) + + throw Exception("Token refresh failed: HTTP $responseCode - $errorBody") + } + } finally { + connection.disconnect() + } + } + + /** + * Get a valid access token, refreshing if needed. + * + * **Must be called from a background thread** when token refresh is needed. + * Will throw [IllegalStateException] if called from the main/UI thread on Android. + * + * @param baseUrl The backend base URL (needed for refresh) + * @return Valid access token + * @throws Exception if no valid token available + * @throws IllegalStateException if called from main thread and refresh is needed + */ + fun getValidAccessToken(baseUrl: String): String { + // Check if current token is valid (no network call needed) + val currentToken = _accessToken.get() + if (currentToken != null && !tokenNeedsRefresh) { + return currentToken + } + + // Token needs refresh - ensure we're not on main thread + ensureNotMainThread("getValidAccessToken") + + // Try to refresh + if (_refreshToken.get() != null) { + return refreshAccessToken(baseUrl) + } + + throw Exception("No valid access token - authentication required") + } + + /** + * Clear authentication state + */ + fun clearAuth() { + _accessToken.set(null) + _refreshToken.set(null) + _deviceId.set(null) + _organizationId.set(null) + _userId.set(null) + _expiresAt.set(null) + + // Also clear from secure storage + try { + CppBridgePlatformAdapter.secureSetCallback("com.runanywhere.sdk.accessToken", ByteArray(0)) + CppBridgePlatformAdapter.secureSetCallback("com.runanywhere.sdk.refreshToken", ByteArray(0)) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Failed to clear tokens from secure storage: ${e.message}", + ) + } + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "Authentication state cleared", + ) + } + + /** + * Store authentication state from response + */ + private fun storeAuthState(response: AuthenticationResponse) { + _accessToken.set(response.accessToken) + _refreshToken.set(response.refreshToken) + _deviceId.set(response.deviceId) + _organizationId.set(response.organizationId) + _userId.set(response.userId) + + // Calculate expiration time + val expiresAt = System.currentTimeMillis() + (response.expiresIn * 1000L) + _expiresAt.set(expiresAt) + + // Store in secure storage for persistence across app restarts + try { + CppBridgePlatformAdapter.secureSetCallback("com.runanywhere.sdk.accessToken", response.accessToken.toByteArray(Charsets.UTF_8)) + CppBridgePlatformAdapter.secureSetCallback("com.runanywhere.sdk.refreshToken", response.refreshToken.toByteArray(Charsets.UTF_8)) + CppBridgePlatformAdapter.secureSetCallback("com.runanywhere.sdk.deviceId", response.deviceId.toByteArray(Charsets.UTF_8)) + response.userId?.let { + CppBridgePlatformAdapter.secureSetCallback("com.runanywhere.sdk.userId", it.toByteArray(Charsets.UTF_8)) + } + CppBridgePlatformAdapter.secureSetCallback("com.runanywhere.sdk.organizationId", response.organizationId.toByteArray(Charsets.UTF_8)) + CppBridgePlatformAdapter.secureSetCallback("com.runanywhere.sdk.expiresAt", expiresAt.toString().toByteArray(Charsets.UTF_8)) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Failed to store tokens in secure storage: ${e.message}", + ) + } + } + + /** + * Restore authentication state from secure storage + */ + fun restoreAuthState() { + try { + // Convert ByteArray to String for each stored value + val accessTokenBytes = CppBridgePlatformAdapter.secureGetCallback("com.runanywhere.sdk.accessToken") + val refreshTokenBytes = CppBridgePlatformAdapter.secureGetCallback("com.runanywhere.sdk.refreshToken") + val deviceIdBytes = CppBridgePlatformAdapter.secureGetCallback("com.runanywhere.sdk.deviceId") + val userIdBytes = CppBridgePlatformAdapter.secureGetCallback("com.runanywhere.sdk.userId") + val organizationIdBytes = CppBridgePlatformAdapter.secureGetCallback("com.runanywhere.sdk.organizationId") + val expiresAtBytes = CppBridgePlatformAdapter.secureGetCallback("com.runanywhere.sdk.expiresAt") + + val accessToken = accessTokenBytes?.toString(Charsets.UTF_8)?.takeIf { it.isNotEmpty() } + val refreshToken = refreshTokenBytes?.toString(Charsets.UTF_8)?.takeIf { it.isNotEmpty() } + val deviceId = deviceIdBytes?.toString(Charsets.UTF_8)?.takeIf { it.isNotEmpty() } + val userId = userIdBytes?.toString(Charsets.UTF_8)?.takeIf { it.isNotEmpty() } + val organizationId = organizationIdBytes?.toString(Charsets.UTF_8)?.takeIf { it.isNotEmpty() } + val expiresAtStr = expiresAtBytes?.toString(Charsets.UTF_8)?.takeIf { it.isNotEmpty() } + + if (accessToken != null && refreshToken != null) { + _accessToken.set(accessToken) + _refreshToken.set(refreshToken) + _deviceId.set(deviceId) + _userId.set(userId) + _organizationId.set(organizationId) + expiresAtStr?.toLongOrNull()?.let { _expiresAt.set(it) } + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "Restored authentication state from secure storage", + ) + } + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Failed to restore auth state: ${e.message}", + ) + } + } +} diff --git a/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeDevice.kt b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeDevice.kt new file mode 100644 index 000000000..dd285ca82 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeDevice.kt @@ -0,0 +1,1282 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * Device extension for CppBridge. + * Provides device registration callbacks for C++ core. + * + * Follows iOS CppBridge+Device.swift architecture. + */ + +package com.runanywhere.sdk.foundation.bridge.extensions + +import java.util.Locale +import java.util.UUID + +/** + * Device bridge that provides device registration callbacks for C++ core. + * + * The C++ core needs device information and registration status to: + * - Track device analytics + * - Manage per-device model assignments + * - Handle device-specific configurations + * + * Usage: + * - Called during Phase 1 initialization in [CppBridge.initialize] + * - Must be registered after [CppBridgePlatformAdapter] is registered + * + * Thread Safety: + * - Registration is thread-safe via synchronized block + * - All callbacks are thread-safe + */ +object CppBridgeDevice { + /** + * Device platform type constants matching C++ RAC_PLATFORM_* values. + */ + object PlatformType { + const val UNKNOWN = 0 + const val IOS = 1 + const val ANDROID = 2 + const val JVM = 3 + const val LINUX = 4 + const val MACOS = 5 + const val WINDOWS = 6 + } + + /** + * Device registration status constants. + */ + object RegistrationStatus { + const val NOT_REGISTERED = 0 + const val REGISTERING = 1 + const val REGISTERED = 2 + const val FAILED = 3 + } + + @Volatile + private var isRegistered: Boolean = false + + @Volatile + private var registrationStatus: Int = RegistrationStatus.NOT_REGISTERED + + @Volatile + private var deviceId: String? = null + + private val lock = Any() + + /** + * Tag for logging. + */ + private const val TAG = "CppBridgeDevice" + + /** + * Secure storage key for device ID. + */ + private const val DEVICE_ID_KEY = "runanywhere_device_id" + + /** + * Secure storage key for registration status. + * Used to persist registration status across app restarts. + */ + private const val REGISTRATION_STATUS_KEY = "runanywhere_device_registered" + + /** + * Optional listener for device registration events. + * Set this before calling [register] to receive events. + */ + @Volatile + var deviceListener: DeviceListener? = null + + /** + * Optional provider for platform-specific device info. + * Set this to provide accurate device information on Android. + */ + @Volatile + var deviceInfoProvider: DeviceInfoProvider? = null + + /** + * Listener interface for device registration events. + */ + interface DeviceListener { + /** + * Called when device registration starts. + * + * @param deviceId The device ID being registered + */ + fun onRegistrationStarted(deviceId: String) + + /** + * Called when device registration completes successfully. + * + * @param deviceId The registered device ID + */ + fun onRegistrationCompleted(deviceId: String) + + /** + * Called when device registration fails. + * + * @param deviceId The device ID that failed to register + * @param errorMessage The error message + */ + fun onRegistrationFailed(deviceId: String, errorMessage: String) + } + + /** + * Provider interface for platform-specific device information. + * + * Implement this interface to provide accurate device information + * on Android (Build.MODEL, Build.VERSION.SDK_INT, etc.). + * + * This matches the Swift SDK's DeviceInfo struct fields. + */ + interface DeviceInfoProvider { + /** + * Get the device model name. + * e.g., "Pixel 8 Pro", "SM-S918U" + */ + fun getDeviceModel(): String + + /** + * Get the device manufacturer. + * e.g., "Google", "Samsung" + */ + fun getDeviceManufacturer(): String + + /** + * Get the user-assigned device name. + * e.g., "John's Phone" + */ + fun getDeviceName(): String = getDeviceModel() + + /** + * Get the OS version. + * e.g., "14" for Android 14 + */ + fun getOSVersion(): String + + /** + * Get the OS build ID. + * e.g., "UQ1A.231205.015" + */ + fun getOSBuildId(): String + + /** + * Get the SDK version (API level). + * e.g., 34 for Android 14 + */ + fun getSDKVersion(): Int + + /** + * Get the device locale. + * e.g., "en-US" + */ + fun getLocale(): String + + /** + * Get the device timezone. + * e.g., "America/Los_Angeles" + */ + fun getTimezone(): String + + /** + * Check if the device is an emulator. + */ + fun isEmulator(): Boolean + + /** + * Get the form factor. + * e.g., "phone", "tablet" + */ + fun getFormFactor(): String = "phone" + + /** + * Get the architecture. + * e.g., "arm64", "x86_64" + */ + fun getArchitecture(): String + + /** + * Get the chip/processor name. + * e.g., "Snapdragon 8 Gen 3", "Tensor G3" + */ + fun getChipName(): String = "Unknown" + + /** + * Get total memory in bytes. + */ + fun getTotalMemory(): Long + + /** + * Get available memory in bytes. + */ + fun getAvailableMemory(): Long = getTotalMemory() / 2 + + /** + * Check if device has Neural Engine / NPU. + */ + fun hasNeuralEngine(): Boolean = false + + /** + * Get number of Neural Engine cores. + */ + fun getNeuralEngineCores(): Int = 0 + + /** + * Get GPU family. + * e.g., "adreno", "mali" + */ + fun getGPUFamily(): String = "unknown" + + /** + * Get battery level (0.0 to 1.0, or negative if unavailable). + */ + fun getBatteryLevel(): Double = -1.0 + + /** + * Get battery state. + * e.g., "charging", "full", "unplugged" + */ + fun getBatteryState(): String? = null + + /** + * Check if low power mode is enabled. + */ + fun isLowPowerMode(): Boolean = false + + /** + * Get total CPU cores. + */ + fun getCoreCount(): Int + + /** + * Get performance cores. + */ + fun getPerformanceCores(): Int = getCoreCount() / 2 + + /** + * Get efficiency cores. + */ + fun getEfficiencyCores(): Int = getCoreCount() - getPerformanceCores() + } + + /** + * Register the device callbacks with C++ core. + * + * This must be called during SDK initialization, after [CppBridgePlatformAdapter.register]. + * It is safe to call multiple times; subsequent calls are no-ops. + */ + fun register() { + synchronized(lock) { + if (isRegistered) { + return + } + + // Initialize device ID if not already set + initializeDeviceId() + + // Load persisted registration status (prevents re-registering on every app start) + loadRegistrationStatus() + + // Create device callbacks object for JNI + val callbacks = + object { + @Suppress("unused") + fun getDeviceInfo(): String = getDeviceInfoCallback() + + @Suppress("unused") + fun getDeviceId(): String = getDeviceIdCallback() + + @Suppress("unused") + fun isRegistered(): Boolean = isDeviceRegisteredCallback() + + @Suppress("unused") + fun setRegistered(registered: Boolean) { + setRegistrationStatusCallback( + if (registered) RegistrationStatus.REGISTERED else RegistrationStatus.NOT_REGISTERED, + null, + ) + } + + @Suppress("unused") + fun httpPost(endpoint: String, body: String, requiresAuth: Boolean): Int { + // Get environment from telemetry (0=DEV, 1=STAGING, 2=PRODUCTION) + val env = CppBridgeTelemetry.currentEnvironment + + val baseUrl: String? + val headers = + mutableMapOf( + "Content-Type" to "application/json", + "Accept" to "application/json", + ) + + if (env == 0) { + // DEVELOPMENT mode - use Supabase + baseUrl = + try { + com.runanywhere.sdk.native.bridge.RunAnywhereBridge + .racDevConfigGetSupabaseUrl() + } catch (e: Exception) { + null + } + + // Add Supabase-specific headers + headers["Prefer"] = "resolution=merge-duplicates" + + // Add Supabase API key + try { + val apiKey = + com.runanywhere.sdk.native.bridge.RunAnywhereBridge + .racDevConfigGetSupabaseKey() + if (!apiKey.isNullOrEmpty()) { + headers["apikey"] = apiKey + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Added Supabase apikey header (dev mode)", + ) + } + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Failed to get Supabase API key: ${e.message}", + ) + } + } else { + // PRODUCTION/STAGING mode - use Railway backend + baseUrl = CppBridgeTelemetry.getBaseUrl() + + // Add Bearer auth with JWT access token + // Use getValidToken() which automatically refreshes if needed + val accessToken = CppBridgeAuth.getValidToken() + if (!accessToken.isNullOrEmpty()) { + headers["Authorization"] = "Bearer $accessToken" + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Added Authorization Bearer header with JWT (prod/staging mode)", + ) + } else { + // Fallback to API key if no JWT available + val apiKey = CppBridgeTelemetry.getApiKey() + if (!apiKey.isNullOrEmpty()) { + headers["Authorization"] = "Bearer $apiKey" + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "⚠️ No JWT - using API key directly (may fail if backend requires JWT)", + ) + } else { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "⚠️ No access token or API key available for Bearer auth!", + ) + } + } + } + + if (baseUrl.isNullOrEmpty()) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "❌ No base URL configured for device registration (env=$env)", + ) + return -1 + } + + // For Supabase (dev mode), add ?on_conflict=device_id for UPSERT + // For Railway (prod mode), the backend handles conflict internally + val finalEndpoint = + if (env == 0) { + if (endpoint.contains("?")) "$endpoint&on_conflict=device_id" else "$endpoint?on_conflict=device_id" + } else { + endpoint + } + + // Build full URL: baseUrl + endpoint path + val fullUrl = baseUrl.trimEnd('/') + finalEndpoint + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "📤 Device registration HTTP POST to: $fullUrl (env=$env)", + ) + + // Log request body for debugging + val bodyPreview = if (body.length > 200) body.substring(0, 200) + "..." else body + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Device registration body: $bodyPreview", + ) + + val (statusCode, response) = + CppBridgeTelemetry.sendTelemetry( + fullUrl, + CppBridgeTelemetry.HttpMethod.POST, + headers, + body, + ) + + if (statusCode in 200..299 || statusCode == 409) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "✅ Device registration successful (status=$statusCode)", + ) + } else { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "❌ Device registration failed: status=$statusCode, response=$response", + ) + } + + return statusCode + } + } + + // Register with native + val result = + com.runanywhere.sdk.native.bridge.RunAnywhereBridge + .racDeviceManagerSetCallbacks(callbacks) + + if (result == 0) { + isRegistered = true + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Device callbacks registered. Device ID: ${deviceId ?: "unknown"}", + ) + } else { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "Failed to register device callbacks: $result", + ) + } + } + } + + /** + * Check if the device callbacks are registered. + */ + fun isRegistered(): Boolean = isRegistered + + /** + * Get the current registration status. + */ + fun getRegistrationStatus(): Int = registrationStatus + + // ======================================================================== + // DEVICE CALLBACKS + // ======================================================================== + + /** + * Get device information as a JSON string. + * + * Returns device info matching the C++ rac_device_registration_info_t struct + * and Swift SDK's DeviceInfo struct for proper backend registration. + * + * @return JSON-encoded device information + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun getDeviceInfoCallback(): String { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "📱 getDeviceInfoCallback() called - gathering device info...", + ) + + val provider = deviceInfoProvider + + val platform = "android" // String platform for backend + // Note: detectPlatform() available for future use if needed + val deviceModel = provider?.getDeviceModel() ?: getDefaultDeviceModel() + val deviceName = provider?.getDeviceName() ?: deviceModel + val manufacturer = provider?.getDeviceManufacturer() ?: getDefaultManufacturer() + val osVersion = provider?.getOSVersion() ?: getDefaultOsVersion() + val osBuildId = provider?.getOSBuildId() ?: "" + val androidApiLevel = provider?.getSDKVersion() ?: getDefaultSdkVersion() + // Use RunAnywhere SDK version string (e.g., "0.1.0"), not Android API level + val sdkVersionString = com.runanywhere.sdk.utils.SDKConstants.SDK_VERSION + val locale = provider?.getLocale() ?: Locale.getDefault().toLanguageTag() + val timezone = + provider?.getTimezone() ?: java.util.TimeZone + .getDefault() + .id + val isEmulator = provider?.isEmulator() ?: false + val formFactor = provider?.getFormFactor() ?: "phone" + val architecture = provider?.getArchitecture() ?: getDefaultArchitecture() + // Use actual chip name or fallback to a descriptive string, not just architecture + val chipName = provider?.getChipName() ?: getDefaultChipName(architecture) + val totalMemory = provider?.getTotalMemory() ?: getDefaultTotalMemory() + val availableMemory = provider?.getAvailableMemory() ?: (totalMemory / 2) + val hasNeuralEngine = provider?.hasNeuralEngine() ?: false + val neuralEngineCores = provider?.getNeuralEngineCores() ?: 0 + val gpuFamily = provider?.getGPUFamily() ?: getDefaultGPUFamily(chipName) + val batteryLevel = provider?.getBatteryLevel() ?: -1.0 + val batteryState = provider?.getBatteryState() + val isLowPowerMode = provider?.isLowPowerMode() ?: false + val coreCount = provider?.getCoreCount() ?: Runtime.getRuntime().availableProcessors() + val performanceCores = provider?.getPerformanceCores() ?: (coreCount / 2) + val efficiencyCores = provider?.getEfficiencyCores() ?: (coreCount - performanceCores) + val deviceIdValue = deviceId ?: "" + + // Log key device info for debugging + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "📱 Device info: model=$deviceModel, os=$osVersion, arch=$architecture, chip=$chipName", + ) + + // Build JSON matching rac_device_registration_info_t struct + return buildString { + append("{") + // Required fields (backend schema) + append("\"device_id\":\"${escapeJson(deviceIdValue)}\",") + append("\"device_model\":\"${escapeJson(deviceModel)}\",") + append("\"device_name\":\"${escapeJson(deviceName)}\",") + append("\"platform\":\"${escapeJson(platform)}\",") + append("\"os_version\":\"${escapeJson(osVersion)}\",") + append("\"form_factor\":\"${escapeJson(formFactor)}\",") + append("\"architecture\":\"${escapeJson(architecture)}\",") + append("\"chip_name\":\"${escapeJson(chipName)}\",") + append("\"total_memory\":$totalMemory,") + append("\"available_memory\":$availableMemory,") + append("\"has_neural_engine\":$hasNeuralEngine,") + append("\"neural_engine_cores\":$neuralEngineCores,") + append("\"gpu_family\":\"${escapeJson(gpuFamily)}\",") + append("\"battery_level\":$batteryLevel,") + if (batteryState != null) { + append("\"battery_state\":\"${escapeJson(batteryState)}\",") + } else { + append("\"battery_state\":null,") + } + append("\"is_low_power_mode\":$isLowPowerMode,") + append("\"core_count\":$coreCount,") + append("\"performance_cores\":$performanceCores,") + append("\"efficiency_cores\":$efficiencyCores,") + append("\"device_fingerprint\":\"${escapeJson(deviceIdValue)}\",") + // Legacy fields (backward compatibility) + append("\"device_type\":\"mobile\",") + append("\"os_name\":\"Android\",") + append("\"processor_count\":$coreCount,") + append("\"is_simulator\":$isEmulator,") + // Additional fields + append("\"manufacturer\":\"${escapeJson(manufacturer)}\",") + append("\"os_build_id\":\"${escapeJson(osBuildId)}\",") + // sdk_version is the RunAnywhere SDK version string (e.g., "0.1.0") + append("\"sdk_version\":\"${escapeJson(sdkVersionString)}\",") + // android_api_level is the Android SDK_INT for internal use + append("\"android_api_level\":$androidApiLevel,") + append("\"locale\":\"${escapeJson(locale)}\",") + append("\"timezone\":\"${escapeJson(timezone)}\"") + append("}") + } + } + + /** + * Get default architecture from system properties. + * On Android, uses Build.SUPPORTED_ABIS to get the actual ABI string. + * Returns actual Android ABI: "arm64-v8a", "armeabi-v7a", "x86_64", "x86", etc. + * Backend accepts: arm64, arm64-v8a, armeabi-v7a, x86_64, x86, unknown + */ + private fun getDefaultArchitecture(): String { + // Try to get Android SUPPORTED_ABIS first (returns "arm64-v8a", "armeabi-v7a", etc.) + try { + val buildClass = Class.forName("android.os.Build") + + @Suppress("UNCHECKED_CAST") + val supportedAbis = buildClass.getField("SUPPORTED_ABIS").get(null) as? Array + if (!supportedAbis.isNullOrEmpty()) { + return supportedAbis[0] // Return the primary ABI as-is + } + } catch (e: Exception) { + // Fall through to system property + } + + // Fallback: map JVM os.arch to Android-style ABI strings + val arch = System.getProperty("os.arch") ?: return "unknown" + return when { + arch.contains("aarch64", ignoreCase = true) -> "arm64-v8a" + arch.contains("arm64", ignoreCase = true) -> "arm64-v8a" + arch.contains("arm", ignoreCase = true) -> "armeabi-v7a" + arch.contains("x86_64", ignoreCase = true) -> "x86_64" + arch.contains("amd64", ignoreCase = true) -> "x86_64" + arch.contains("x86", ignoreCase = true) -> "x86" + else -> "unknown" + } + } + + /** + * Get default total memory from system. + * On Android, uses ActivityManager to get actual device RAM. + */ + private fun getDefaultTotalMemory(): Long { + // Try to get actual device memory via ActivityManager + try { + val contextClass = Class.forName("android.content.Context") + val activityServiceField = contextClass.getField("ACTIVITY_SERVICE") + val activityService = activityServiceField.get(null) as String + + // Get application context + val activityThreadClass = Class.forName("android.app.ActivityThread") + val currentAppMethod = activityThreadClass.getMethod("currentApplication") + val context = currentAppMethod.invoke(null) + + if (context != null) { + val getSystemServiceMethod = contextClass.getMethod("getSystemService", String::class.java) + val activityManager = getSystemServiceMethod.invoke(context, activityService) + + if (activityManager != null) { + val memInfoClass = Class.forName("android.app.ActivityManager\$MemoryInfo") + val memInfo = memInfoClass.getDeclaredConstructor().newInstance() + + val getMemInfoMethod = activityManager.javaClass.getMethod("getMemoryInfo", memInfoClass) + getMemInfoMethod.invoke(activityManager, memInfo) + + val totalMemField = memInfoClass.getField("totalMem") + return totalMemField.getLong(memInfo) + } + } + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Could not get device memory via ActivityManager: ${e.message}", + ) + } + + // Fallback to JVM max memory (less accurate) + return Runtime.getRuntime().maxMemory() + } + + /** + * Get default chip name based on architecture and device info. + * Tries to read from /proc/cpuinfo or Build.HARDWARE. + */ + private fun getDefaultChipName(architecture: String): String { + // Try to get from Build.HARDWARE + try { + val buildClass = Class.forName("android.os.Build") + val hardware = buildClass.getField("HARDWARE").get(null) as? String + if (!hardware.isNullOrEmpty() && hardware != "unknown") { + return hardware + } + } catch (e: Exception) { + // Fall through + } + + // Try to read from /proc/cpuinfo + try { + val cpuInfo = java.io.File("/proc/cpuinfo").readText() + // Look for "Hardware" line + val hardwareLine = cpuInfo.lines().find { it.startsWith("Hardware", ignoreCase = true) } + if (hardwareLine != null) { + val chipName = hardwareLine.substringAfter(":").trim() + if (chipName.isNotEmpty()) { + return chipName + } + } + } catch (e: Exception) { + // Fall through + } + + // Fallback to architecture as last resort + return architecture + } + + /** + * Get default GPU family based on chip name. + * Infers GPU vendor from known chip manufacturers: + * - Samsung Exynos → Mali + * - Qualcomm Snapdragon → Adreno + * - MediaTek → Mali (mostly) + * - HiSilicon Kirin → Mali + * - Google Tensor → Mali + * - Apple → Apple + */ + private fun getDefaultGPUFamily(chipName: String): String { + val chipLower = chipName.lowercase() + + return when { + // Samsung Exynos uses Mali GPUs + chipLower.contains("exynos") -> "mali" + chipLower.startsWith("s5e") -> "mali" // Samsung internal chip naming (e.g., s5e8535) + chipLower.contains("samsung") -> "mali" + + // Qualcomm Snapdragon uses Adreno GPUs + chipLower.contains("snapdragon") -> "adreno" + chipLower.contains("qualcomm") -> "adreno" + chipLower.contains("sdm") -> "adreno" // SDM845, SDM855, etc. + chipLower.contains("sm8") -> "adreno" // SM8150, SM8250, etc. + chipLower.contains("sm7") -> "adreno" // SM7150, etc. + chipLower.contains("sm6") -> "adreno" // SM6150, etc. + chipLower.contains("msm") -> "adreno" // Older MSM chips + + // MediaTek uses Mali GPUs (mostly) + chipLower.contains("mediatek") -> "mali" + chipLower.contains("mt6") -> "mali" // MT6xxx series + chipLower.contains("mt8") -> "mali" // MT8xxx series + chipLower.contains("dimensity") -> "mali" + chipLower.contains("helio") -> "mali" + + // HiSilicon Kirin uses Mali GPUs + chipLower.contains("kirin") -> "mali" + chipLower.contains("hisilicon") -> "mali" + + // Google Tensor uses Mali GPUs + chipLower.contains("tensor") -> "mali" + chipLower.contains("gs1") -> "mali" // GS101 (Tensor) + chipLower.contains("gs2") -> "mali" // GS201 (Tensor G2) + + // Intel/x86 GPUs + chipLower.contains("intel") -> "intel" + + // NVIDIA (rare on mobile) + chipLower.contains("nvidia") -> "nvidia" + chipLower.contains("tegra") -> "nvidia" + + else -> "unknown" + } + } + + /** + * Get the unique device identifier. + * + * Returns a persistent device ID that is: + * - Unique per device installation + * - Persisted across app restarts + * - Used for analytics and device registration + * + * @return The device ID string + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun getDeviceIdCallback(): String { + return deviceId ?: run { + initializeDeviceId() + deviceId ?: "" + } + } + + /** + * Check if the device is registered with the backend. + * + * **Production/Staging mode**: Returns persisted status. + * - First launch: returns false → device registers once with full info + * - Subsequent launches: returns true → skips registration (already done) + * + * **Development mode**: Always returns false to trigger UPSERT. + * - Supabase uses UPSERT (`?on_conflict=device_id`) which updates existing records + * - This ensures device info is always fresh during development + * + * @return true if already registered (prod/staging), false otherwise + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun isDeviceRegisteredCallback(): Boolean { + // Get current environment from telemetry (0=DEV, 1=STAGING, 2=PRODUCTION) + val env = CppBridgeTelemetry.currentEnvironment + + // For DEVELOPMENT mode (env=0): Always return false to trigger UPSERT + // Supabase handles duplicates gracefully with ?on_conflict=device_id + if (env == 0) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "isDeviceRegisteredCallback: dev mode → returning false (UPSERT always)", + ) + return false + } + + // For PRODUCTION/STAGING mode (env != 0): Return persisted status + // - First launch: NOT_REGISTERED → returns false → registers once + // - Subsequent launches: REGISTERED → returns true → skips registration + val isRegistered = registrationStatus == RegistrationStatus.REGISTERED + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "isDeviceRegisteredCallback: prod/staging mode → returning $isRegistered (register once)", + ) + return isRegistered + } + + /** + * Set the device registration status. + * + * Called by C++ core when device registration status changes. + * + * @param status The new registration status (see [RegistrationStatus]) + * @param errorMessage Optional error message if status is FAILED + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun setRegistrationStatusCallback(status: Int, errorMessage: String?) { + val previousStatus = registrationStatus + registrationStatus = status + + val deviceIdValue = deviceId ?: "" + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Registration status changed: $previousStatus -> $status", + ) + + // Persist registration status so we don't re-register on every app restart + if (status == RegistrationStatus.REGISTERED) { + persistRegistrationStatus(true) + } else if (status == RegistrationStatus.NOT_REGISTERED) { + persistRegistrationStatus(false) + } + + // Notify listener + try { + when (status) { + RegistrationStatus.REGISTERING -> { + deviceListener?.onRegistrationStarted(deviceIdValue) + } + RegistrationStatus.REGISTERED -> { + deviceListener?.onRegistrationCompleted(deviceIdValue) + } + RegistrationStatus.FAILED -> { + deviceListener?.onRegistrationFailed( + deviceIdValue, + errorMessage ?: "Unknown error", + ) + } + } + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in device listener: ${e.message}", + ) + } + } + + /** + * HTTP POST callback for device registration requests. + * + * Called by C++ core to send device registration data to the backend. + * This is used when the C++ telemetry HTTP callback is not yet available. + * + * @param url The registration endpoint URL + * @param body The request body (JSON) + * @param headers JSON-encoded headers map + * @param completionCallbackId ID for the C++ completion callback + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun httpPostCallback( + url: String, + body: String, + headers: String?, + completionCallbackId: Long, + ) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Device registration POST to: $url", + ) + + // Delegate to telemetry HTTP callback if available + CppBridgeTelemetry.httpCallback( + requestId = "device-registration-${System.currentTimeMillis()}", + url = url, + method = CppBridgeTelemetry.HttpMethod.POST, + headers = headers, + body = body, + completionCallbackId = completionCallbackId, + ) + } + + // ======================================================================== + // JNI NATIVE DECLARATIONS + // ======================================================================== + + /** + * Native method to set the device callbacks with C++ core. + * + * Registers [getDeviceInfoCallback], [getDeviceIdCallback], + * [isDeviceRegisteredCallback], [setRegistrationStatusCallback], + * and [httpPostCallback] with C++ core. + * Reserved for future native callback integration. + * + * C API: rac_device_set_callbacks(...) + */ + @Suppress("unused") + @JvmStatic + private external fun nativeSetDeviceCallbacks() + + /** + * Native method to unset the device callbacks. + * + * Called during shutdown to clean up native resources. + * Reserved for future native callback integration. + * + * C API: rac_device_set_callbacks(nullptr) + */ + @Suppress("unused") + @JvmStatic + private external fun nativeUnsetDeviceCallbacks() + + /** + * Native method to trigger device registration with backend. + * + * @return 0 on success, error code on failure + * + * C API: rac_device_register() + */ + @JvmStatic + external fun nativeRegisterDevice(): Int + + /** + * Native method to check if device needs re-registration. + * + * @return true if registration is needed + * + * C API: rac_device_needs_registration() + */ + @JvmStatic + external fun nativeNeedsRegistration(): Boolean + + // ======================================================================== + // LIFECYCLE MANAGEMENT + // ======================================================================== + + /** + * Unregister the device callbacks and clean up resources. + * + * Called during SDK shutdown. + */ + fun unregister() { + synchronized(lock) { + if (!isRegistered) { + return + } + + // Clear native callbacks by setting null + // This is handled by the JNI layer + + deviceListener = null + registrationStatus = RegistrationStatus.NOT_REGISTERED + isRegistered = false + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Device callbacks unregistered", + ) + } + } + + // ======================================================================== + // UTILITY FUNCTIONS + // ======================================================================== + + /** + * Initialize or retrieve the device ID. + * + * First checks secure storage for an existing ID. + * If not found, generates a new UUID and stores it. + */ + private fun initializeDeviceId() { + if (deviceId != null) { + return + } + + // Try to load from secure storage + val storedId = CppBridgePlatformAdapter.secureGetCallback(DEVICE_ID_KEY) + if (storedId != null) { + deviceId = String(storedId, Charsets.UTF_8) + return + } + + // Generate new ID + val newId = UUID.randomUUID().toString() + deviceId = newId + + // Store in secure storage + CppBridgePlatformAdapter.secureSetCallback( + DEVICE_ID_KEY, + newId.toByteArray(Charsets.UTF_8), + ) + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "Generated new device ID: $newId", + ) + } + + /** + * Load persisted registration status from secure storage. + * + * NOTE: For production/staging modes, we don't rely solely on local persistence + * to skip registration. The C++ layer and backend handle the logic: + * - C++ checks if device is registered via callback + * - Backend supports upsert/update of device info + * - Full device info should be sent at least once per installation + * + * For development mode (Supabase), UPSERT is used so we can always register. + */ + private fun loadRegistrationStatus() { + val storedStatus = CppBridgePlatformAdapter.secureGetCallback(REGISTRATION_STATUS_KEY) + if (storedStatus != null) { + val statusStr = String(storedStatus, Charsets.UTF_8) + if (statusStr == "true" || statusStr == "1") { + // For development mode, we trust the persisted status + // For production/staging, we'll let the C++ layer decide + // but we still load the status for informational purposes + registrationStatus = RegistrationStatus.REGISTERED + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "📋 Loaded persisted registration status: REGISTERED", + ) + } + } + } + + /** + * Persist registration status to secure storage. + * + * @param isRegistered Whether the device is registered + */ + private fun persistRegistrationStatus(isRegistered: Boolean) { + val value = if (isRegistered) "true" else "false" + CppBridgePlatformAdapter.secureSetCallback( + REGISTRATION_STATUS_KEY, + value.toByteArray(Charsets.UTF_8), + ) + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Persisted registration status: $value", + ) + } + + /** + * Manually set the device ID. + * + * Useful for testing or when migrating from another ID system. + * + * @param id The device ID to set + */ + fun setDeviceId(id: String) { + synchronized(lock) { + deviceId = id + CppBridgePlatformAdapter.secureSetCallback( + DEVICE_ID_KEY, + id.toByteArray(Charsets.UTF_8), + ) + } + } + + /** + * Get the current device ID without initializing. + * + * @return The device ID, or null if not initialized + */ + fun getDeviceId(): String? = deviceId + + /** + * Trigger device registration with the backend. + * + * This should be called after SDK initialization when the app is ready + * to register the device. + * + * @param environment SDK environment (0=DEVELOPMENT, 1=STAGING, 2=PRODUCTION) + * @param buildToken Optional build token for development mode + * @return true if registration was triggered, false if already registered or failed + */ + fun triggerRegistration(environment: Int = 0, buildToken: String? = null): Boolean { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "📱 triggerRegistration called: env=$environment, buildToken=${if (buildToken != null) "present (${buildToken.length} chars)" else "null"}", + ) + + if (!isRegistered) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "❌ Cannot trigger registration: device callbacks not registered", + ) + return false + } + + // NOTE: Unlike development mode, we don't skip registration for production/staging + // based on local registrationStatus. This matches Swift SDK behavior where the C++ + // layer handles all the logic via rac_device_manager_register_if_needed(). + // + // For production/staging (env != 0): + // - The C++ code will skip if already registered (performance optimization) + // - But we need to call it at least once to send full device info + // - Authentication only creates a basic device record + // + // For development (env == 0): + // - The C++ code uses UPSERT to always update (track active devices) + // - We still call every time to update last_seen_at + + if (registrationStatus == RegistrationStatus.REGISTERING) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "⏳ Device registration already in progress", + ) + return true + } + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "📤 Calling racDeviceManagerRegisterIfNeeded...", + ) + + // Call native registration + val result = + com.runanywhere.sdk.native.bridge.RunAnywhereBridge.racDeviceManagerRegisterIfNeeded( + environment, + buildToken, + ) + + val resultMessage = + when (result) { + 0 -> "✅ SUCCESS" + 1 -> "⚠️ Already registered" + else -> "❌ ERROR (code=$result)" + } + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "Device registration result: $resultMessage", + ) + + return result == 0 + } + + /** + * Check if device is registered with backend. + * Calls native method to get current status. + */ + fun checkIsRegistered(): Boolean { + return com.runanywhere.sdk.native.bridge.RunAnywhereBridge + .racDeviceManagerIsRegistered() + } + + /** + * Clear device registration status. + */ + fun clearRegistration() { + com.runanywhere.sdk.native.bridge.RunAnywhereBridge + .racDeviceManagerClearRegistration() + registrationStatus = RegistrationStatus.NOT_REGISTERED + } + + /** + * Get native device ID from C++ device manager. + */ + fun getNativeDeviceId(): String? { + return com.runanywhere.sdk.native.bridge.RunAnywhereBridge + .racDeviceManagerGetDeviceId() + } + + /** + * Detect the current platform type. + * Reserved for future platform-specific handling. + */ + @Suppress("unused") + private fun detectPlatform(): Int { + val osName = System.getProperty("os.name")?.lowercase() ?: "" + val javaVendor = System.getProperty("java.vendor")?.lowercase() ?: "" + val vmName = System.getProperty("java.vm.name")?.lowercase() ?: "" + + return when { + // Check for Android runtime + vmName.contains("dalvik") || vmName.contains("art") -> PlatformType.ANDROID + javaVendor.contains("android") -> PlatformType.ANDROID + + // Check OS + osName.contains("mac") || osName.contains("darwin") -> PlatformType.MACOS + osName.contains("linux") -> PlatformType.LINUX + osName.contains("win") -> PlatformType.WINDOWS + + // Default to JVM + else -> PlatformType.JVM + } + } + + /** + * Get default device model for JVM environment. + */ + private fun getDefaultDeviceModel(): String { + // Try to get from Android Build class via reflection + return try { + val buildClass = Class.forName("android.os.Build") + buildClass.getField("MODEL").get(null) as? String ?: "unknown" + } catch (e: Exception) { + System.getProperty("os.name") ?: "unknown" + } + } + + /** + * Get default manufacturer for JVM/Android environment. + */ + private fun getDefaultManufacturer(): String { + // Try to get from Android Build class via reflection + return try { + val buildClass = Class.forName("android.os.Build") + buildClass.getField("MANUFACTURER").get(null) as? String ?: "unknown" + } catch (e: Exception) { + System.getProperty("java.vendor") ?: "unknown" + } + } + + /** + * Get default OS version for Android. + */ + private fun getDefaultOsVersion(): String { + return try { + val versionClass = Class.forName("android.os.Build\$VERSION") + versionClass.getField("RELEASE").get(null) as? String ?: "unknown" + } catch (e: Exception) { + System.getProperty("os.version") ?: "unknown" + } + } + + /** + * Get default SDK/API version for Android. + */ + private fun getDefaultSdkVersion(): Int { + return try { + val versionClass = Class.forName("android.os.Build\$VERSION") + versionClass.getField("SDK_INT").get(null) as? Int ?: 0 + } catch (e: Exception) { + 0 + } + } + + /** + * Escape special characters for JSON string. + */ + private fun escapeJson(value: String): String { + return value + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + } +} diff --git a/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeDownload.kt b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeDownload.kt new file mode 100644 index 000000000..f4050c018 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeDownload.kt @@ -0,0 +1,1534 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * Download extension for CppBridge. + * Provides download manager bridge for C++ core model download operations. + * + * Follows iOS CppBridge+Download.swift architecture. + */ + +package com.runanywhere.sdk.foundation.bridge.extensions + +import java.io.File +import java.io.FileOutputStream +import java.io.InputStream +import java.net.HttpURLConnection +import java.net.URL +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors +import java.util.concurrent.Future +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicLong + +/** + * Download bridge that provides download manager callbacks for C++ core model download operations. + * + * The C++ core needs download manager functionality for: + * - Downloading model files from remote URLs + * - Tracking download progress and status + * - Managing concurrent downloads + * - Resuming interrupted downloads + * - Validating downloaded files (checksum verification) + * + * Usage: + * - Called during Phase 2 initialization in [CppBridge.initializeServices] + * - Must be registered after [CppBridgeModelPaths] and [CppBridgeModelRegistry] are registered + * + * Thread Safety: + * - Registration is thread-safe via synchronized block + * - All callbacks are thread-safe + * - Downloads are executed on a background thread pool + */ +object CppBridgeDownload { + /** + * Download status constants matching C++ RAC_DOWNLOAD_STATUS_* values. + */ + object DownloadStatus { + /** Download is queued but not started */ + const val QUEUED = 0 + + /** Download is in progress */ + const val DOWNLOADING = 1 + + /** Download is paused */ + const val PAUSED = 2 + + /** Download completed successfully */ + const val COMPLETED = 3 + + /** Download failed */ + const val FAILED = 4 + + /** Download was cancelled */ + const val CANCELLED = 5 + + /** Download is verifying checksum */ + const val VERIFYING = 6 + + /** + * Get a human-readable name for the download status. + */ + fun getName(status: Int): String = + when (status) { + QUEUED -> "QUEUED" + DOWNLOADING -> "DOWNLOADING" + PAUSED -> "PAUSED" + COMPLETED -> "COMPLETED" + FAILED -> "FAILED" + CANCELLED -> "CANCELLED" + VERIFYING -> "VERIFYING" + else -> "UNKNOWN($status)" + } + + /** + * Check if the download status indicates completion (success or failure). + */ + fun isTerminal(status: Int): Boolean = status in listOf(COMPLETED, FAILED, CANCELLED) + } + + /** + * Download error codes matching C++ RAC_DOWNLOAD_ERROR_* values. + */ + object DownloadError { + /** No error */ + const val NONE = 0 + + /** Network error (connection failed, etc.) */ + const val NETWORK_ERROR = 1 + + /** File write error */ + const val FILE_ERROR = 2 + + /** Not enough storage space */ + const val INSUFFICIENT_STORAGE = 3 + + /** Invalid URL */ + const val INVALID_URL = 4 + + /** Checksum verification failed */ + const val CHECKSUM_FAILED = 5 + + /** Download was cancelled */ + const val CANCELLED = 6 + + /** Server error (4xx or 5xx response) */ + const val SERVER_ERROR = 7 + + /** Download timeout */ + const val TIMEOUT = 8 + + /** Network is unavailable (no internet connection) */ + const val NETWORK_UNAVAILABLE = 9 + + /** DNS resolution failed */ + const val DNS_ERROR = 10 + + /** SSL/TLS error */ + const val SSL_ERROR = 11 + + /** Unknown error */ + const val UNKNOWN = 99 + + /** + * Get a human-readable name for the error code. + */ + fun getName(error: Int): String = + when (error) { + NONE -> "NONE" + NETWORK_ERROR -> "NETWORK_ERROR" + FILE_ERROR -> "FILE_ERROR" + INSUFFICIENT_STORAGE -> "INSUFFICIENT_STORAGE" + INVALID_URL -> "INVALID_URL" + CHECKSUM_FAILED -> "CHECKSUM_FAILED" + CANCELLED -> "CANCELLED" + SERVER_ERROR -> "SERVER_ERROR" + TIMEOUT -> "TIMEOUT" + NETWORK_UNAVAILABLE -> "NETWORK_UNAVAILABLE" + DNS_ERROR -> "DNS_ERROR" + SSL_ERROR -> "SSL_ERROR" + UNKNOWN -> "UNKNOWN" + else -> "UNKNOWN($error)" + } + + /** + * Get a user-friendly error message for the error code. + */ + fun getUserMessage(error: Int): String = + when (error) { + NONE -> "No error" + NETWORK_ERROR -> "Network error. Please check your internet connection and try again." + FILE_ERROR -> "Failed to save the file. Please check available storage." + INSUFFICIENT_STORAGE -> "Not enough storage space. Please free up some space and try again." + INVALID_URL -> "Invalid download URL." + CHECKSUM_FAILED -> "File verification failed. The download may be corrupted." + CANCELLED -> "Download was cancelled." + SERVER_ERROR -> "Server error. Please try again later." + TIMEOUT -> "Connection timed out. Please check your internet connection and try again." + NETWORK_UNAVAILABLE -> "No internet connection. Please check your network settings and try again." + DNS_ERROR -> "Unable to connect to server. Please check your internet connection." + SSL_ERROR -> "Secure connection failed. Please try again." + UNKNOWN -> "An unexpected error occurred. Please try again." + else -> "Download failed. Please try again." + } + } + + /** + * Download priority levels. + */ + object DownloadPriority { + /** Low priority (background downloads) */ + const val LOW = 0 + + /** Normal priority (default) */ + const val NORMAL = 1 + + /** High priority (user-requested) */ + const val HIGH = 2 + + /** Urgent priority (immediate start) */ + const val URGENT = 3 + } + + @Volatile + private var isRegistered: Boolean = false + + private val lock = Any() + + /** + * Tag for logging. + */ + private const val TAG = "CppBridgeDownload" + + /** + * Default buffer size for file downloads (8 KB). + */ + private const val DEFAULT_BUFFER_SIZE = 8192 + + /** + * Default connection timeout in milliseconds. + */ + private const val DEFAULT_CONNECT_TIMEOUT_MS = 30_000 + + /** + * Default read timeout in milliseconds. + */ + private const val DEFAULT_READ_TIMEOUT_MS = 60_000 + + /** + * Maximum concurrent downloads. + */ + private const val MAX_CONCURRENT_DOWNLOADS = 3 + + /** + * Background executor for download operations. + */ + private val downloadExecutor = + Executors.newFixedThreadPool(MAX_CONCURRENT_DOWNLOADS) { runnable -> + Thread(runnable, "runanywhere-download").apply { + isDaemon = true + } + } + + /** + * Active downloads map. + * Key: Download ID + * Value: [DownloadTask] instance + */ + private val activeDownloads = ConcurrentHashMap() + + /** + * Download futures for cancellation. + */ + private val downloadFutures = ConcurrentHashMap>() + + /** + * Optional listener for download events. + * Set this before calling [register] to receive events. + */ + @Volatile + var downloadListener: DownloadListener? = null + + /** + * Optional provider for custom download behavior. + * Set this to customize download logic (e.g., use OkHttp instead of HttpURLConnection). + */ + @Volatile + var downloadProvider: DownloadProvider? = null + + /** + * Download task data class tracking a single download. + * + * @param downloadId Unique identifier for this download + * @param url The URL to download from + * @param destinationPath The local file path to save to + * @param modelId The model ID (for associating with model registry) + * @param modelType The model type (see [CppBridgeModelRegistry.ModelType]) + * @param status Current download status + * @param error Error code if status is FAILED + * @param totalBytes Total file size in bytes (-1 if unknown) + * @param downloadedBytes Bytes downloaded so far + * @param startedAt Timestamp when download started + * @param completedAt Timestamp when download completed (or failed) + * @param priority Download priority + * @param expectedChecksum Expected checksum for verification (null to skip verification) + */ + data class DownloadTask( + val downloadId: String, + val url: String, + val destinationPath: String, + val modelId: String, + val modelType: Int, + var status: Int = DownloadStatus.QUEUED, + var error: Int = DownloadError.NONE, + var totalBytes: Long = -1L, + var downloadedBytes: Long = 0L, + val startedAt: Long = System.currentTimeMillis(), + var completedAt: Long = 0L, + val priority: Int = DownloadPriority.NORMAL, + val expectedChecksum: String? = null, + ) { + /** + * Get the download progress as a percentage (0-100). + */ + fun getProgress(): Int { + if (totalBytes <= 0) return 0 + return ((downloadedBytes * 100) / totalBytes).toInt().coerceIn(0, 100) + } + + /** + * Get the status name. + */ + fun getStatusName(): String = DownloadStatus.getName(status) + + /** + * Get the error name. + */ + fun getErrorName(): String = DownloadError.getName(error) + + /** + * Check if the download is still in progress. + */ + fun isActive(): Boolean = status == DownloadStatus.DOWNLOADING || status == DownloadStatus.VERIFYING + + /** + * Check if the download completed successfully. + */ + fun isCompleted(): Boolean = status == DownloadStatus.COMPLETED + + /** + * Check if the download failed or was cancelled. + */ + fun isFailed(): Boolean = status == DownloadStatus.FAILED || status == DownloadStatus.CANCELLED + } + + /** + * Listener interface for download events. + */ + interface DownloadListener { + /** + * Called when a download starts. + * + * @param downloadId The download ID + * @param modelId The model ID + * @param url The download URL + */ + fun onDownloadStarted(downloadId: String, modelId: String, url: String) + + /** + * Called when download progress is updated. + * + * @param downloadId The download ID + * @param downloadedBytes Bytes downloaded so far + * @param totalBytes Total file size (-1 if unknown) + * @param progress Progress percentage (0-100) + */ + fun onDownloadProgress(downloadId: String, downloadedBytes: Long, totalBytes: Long, progress: Int) + + /** + * Called when a download completes successfully. + * + * @param downloadId The download ID + * @param modelId The model ID + * @param filePath The local file path + * @param fileSize The file size in bytes + */ + fun onDownloadCompleted(downloadId: String, modelId: String, filePath: String, fileSize: Long) + + /** + * Called when a download fails. + * + * @param downloadId The download ID + * @param modelId The model ID + * @param error The error code (see [DownloadError]) + * @param errorMessage Human-readable error message + */ + fun onDownloadFailed(downloadId: String, modelId: String, error: Int, errorMessage: String) + + /** + * Called when a download is paused. + * + * @param downloadId The download ID + */ + fun onDownloadPaused(downloadId: String) + + /** + * Called when a download is resumed. + * + * @param downloadId The download ID + */ + fun onDownloadResumed(downloadId: String) + + /** + * Called when a download is cancelled. + * + * @param downloadId The download ID + */ + fun onDownloadCancelled(downloadId: String) + } + + /** + * Provider interface for custom download implementations. + */ + interface DownloadProvider { + /** + * Perform a download with custom logic. + * + * @param url The URL to download from + * @param destinationPath The local file path to save to + * @param progressCallback Callback for progress updates (downloadedBytes, totalBytes) + * @return true if download succeeded, false otherwise + */ + fun download( + url: String, + destinationPath: String, + progressCallback: (downloadedBytes: Long, totalBytes: Long) -> Unit, + ): Boolean + + /** + * Check if resume is supported for a URL. + * + * @param url The URL to check + * @return true if the server supports range requests + */ + fun supportsResume(url: String): Boolean + } + + /** + * Register the download callbacks with C++ core. + * + * This must be called during SDK initialization, after [CppBridgeModelPaths.register]. + * It is safe to call multiple times; subsequent calls are no-ops. + */ + fun register() { + synchronized(lock) { + if (isRegistered) { + return + } + + // Register the download callbacks with C++ via JNI + // TODO: Call native registration + // nativeSetDownloadCallbacks() + + isRegistered = true + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Download manager callbacks registered", + ) + } + } + + /** + * Check if the download callbacks are registered. + */ + fun isRegistered(): Boolean = isRegistered + + // ======================================================================== + // DOWNLOAD CALLBACKS + // ======================================================================== + + /** + * Start download callback. + * + * Starts a new download for a model. + * + * @param url The URL to download from + * @param modelId The model ID + * @param modelType The model type (see [CppBridgeModelRegistry.ModelType]) + * @param priority Download priority (see [DownloadPriority]) + * @param expectedChecksum Expected checksum for verification (null to skip) + * @return The download ID, or null if download could not be started + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun startDownloadCallback( + url: String, + modelId: String, + modelType: Int, + priority: Int, + expectedChecksum: String?, + ): String? { + return try { + // Check network connectivity first + if (!checkNetworkConnectivity()) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "No internet connection. Please check your network settings and try again.", + ) + // Notify listener of failure + val downloadId = UUID.randomUUID().toString() + try { + downloadListener?.onDownloadFailed( + downloadId, + modelId, + DownloadError.NETWORK_UNAVAILABLE, + "No internet connection. Please check your network settings and try again.", + ) + } catch (e: Exception) { + // Ignore listener errors + } + return null + } + + // Validate URL + try { + URL(url) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "Invalid download URL: $url", + ) + return null + } + + // Get destination path + val tempPath = CppBridgeModelPaths.getTempDownloadPath(modelId) + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "Download destination path: $tempPath", + ) + + // Check available storage + val availableStorage = CppBridgeModelPaths.getAvailableStorage() + if (availableStorage < 100 * 1024 * 1024) { // Require at least 100MB free + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Low storage space: ${availableStorage / (1024 * 1024)}MB available", + ) + } + + // Create download task + val downloadId = UUID.randomUUID().toString() + val task = + DownloadTask( + downloadId = downloadId, + url = url, + destinationPath = tempPath, + modelId = modelId, + modelType = modelType, + priority = priority, + expectedChecksum = expectedChecksum, + ) + + activeDownloads[downloadId] = task + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Starting download: $downloadId for model $modelId", + ) + + // Note: Download status is tracked by the download manager, not model registry + // The C++ registry just stores the local_path when download is complete + + // Start download on background thread + val future = + downloadExecutor.submit { + executeDownload(task) + } + downloadFutures[downloadId] = future + + // Notify listener + try { + downloadListener?.onDownloadStarted(downloadId, modelId, url) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in download listener onDownloadStarted: ${e.message}", + ) + } + + downloadId + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "Failed to start download: ${e.message}", + ) + null + } + } + + /** + * Cancel download callback. + * + * Cancels an active download. + * + * @param downloadId The download ID to cancel + * @return true if cancelled, false if download not found or already completed + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun cancelDownloadCallback(downloadId: String): Boolean { + val task = activeDownloads[downloadId] + if (task == null || DownloadStatus.isTerminal(task.status)) { + return false + } + + // Cancel the future + val future = downloadFutures.remove(downloadId) + future?.cancel(true) + + // Update task status + task.status = DownloadStatus.CANCELLED + task.error = DownloadError.CANCELLED + task.completedAt = System.currentTimeMillis() + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Download cancelled: $downloadId", + ) + + // Clear model download status on cancellation + CppBridgeModelRegistry.updateDownloadStatus(task.modelId, null) + + // Cleanup temp file + try { + File(task.destinationPath).delete() + } catch (e: Exception) { + // Ignore cleanup errors + } + + // Notify listener + try { + downloadListener?.onDownloadCancelled(downloadId) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in download listener onDownloadCancelled: ${e.message}", + ) + } + + return true + } + + /** + * Pause download callback. + * + * Pauses an active download. + * + * @param downloadId The download ID to pause + * @return true if paused, false if download not found or cannot be paused + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun pauseDownloadCallback(downloadId: String): Boolean { + val task = activeDownloads[downloadId] + if (task == null || task.status != DownloadStatus.DOWNLOADING) { + return false + } + + // Cancel the future (will be resumed later) + val future = downloadFutures.remove(downloadId) + future?.cancel(true) + + task.status = DownloadStatus.PAUSED + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Download paused: $downloadId at ${task.downloadedBytes} bytes", + ) + + // Notify listener + try { + downloadListener?.onDownloadPaused(downloadId) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in download listener onDownloadPaused: ${e.message}", + ) + } + + return true + } + + /** + * Resume download callback. + * + * Resumes a paused download. + * + * @param downloadId The download ID to resume + * @return true if resumed, false if download not found or cannot be resumed + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun resumeDownloadCallback(downloadId: String): Boolean { + val task = activeDownloads[downloadId] + if (task == null || task.status != DownloadStatus.PAUSED) { + return false + } + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Resuming download: $downloadId from ${task.downloadedBytes} bytes", + ) + + // Restart download on background thread + val future = + downloadExecutor.submit { + executeDownload(task, resumeFrom = task.downloadedBytes) + } + downloadFutures[downloadId] = future + + // Notify listener + try { + downloadListener?.onDownloadResumed(downloadId) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in download listener onDownloadResumed: ${e.message}", + ) + } + + return true + } + + /** + * Get download status callback. + * + * Returns the current status of a download. + * + * @param downloadId The download ID + * @return The download status (see [DownloadStatus]), or -1 if not found + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun getDownloadStatusCallback(downloadId: String): Int { + return activeDownloads[downloadId]?.status ?: -1 + } + + /** + * Get download progress callback. + * + * Returns the download progress as a JSON string. + * + * @param downloadId The download ID + * @return JSON-encoded progress information, or null if not found + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun getDownloadProgressCallback(downloadId: String): String? { + val task = activeDownloads[downloadId] ?: return null + + return buildString { + append("{") + append("\"download_id\":\"${escapeJson(task.downloadId)}\",") + append("\"model_id\":\"${escapeJson(task.modelId)}\",") + append("\"status\":${task.status},") + append("\"error\":${task.error},") + append("\"total_bytes\":${task.totalBytes},") + append("\"downloaded_bytes\":${task.downloadedBytes},") + append("\"progress\":${task.getProgress()},") + append("\"started_at\":${task.startedAt},") + append("\"completed_at\":${task.completedAt}") + append("}") + } + } + + /** + * Get all active downloads callback. + * + * Returns information about all active downloads. + * + * @return JSON-encoded array of download information + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun getAllDownloadsCallback(): String { + val downloads = activeDownloads.values.toList() + + return buildString { + append("[") + downloads.forEachIndexed { index, task -> + if (index > 0) append(",") + append("{") + append("\"download_id\":\"${escapeJson(task.downloadId)}\",") + append("\"model_id\":\"${escapeJson(task.modelId)}\",") + append("\"url\":\"${escapeJson(task.url)}\",") + append("\"status\":${task.status},") + append("\"error\":${task.error},") + append("\"total_bytes\":${task.totalBytes},") + append("\"downloaded_bytes\":${task.downloadedBytes},") + append("\"progress\":${task.getProgress()}") + append("}") + } + append("]") + } + } + + /** + * Get active download count callback. + * + * @return The number of active downloads + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun getActiveDownloadCountCallback(): Int { + return activeDownloads.values.count { it.isActive() } + } + + /** + * Clear completed downloads callback. + * + * Removes completed, failed, and cancelled downloads from tracking. + * + * @return The number of downloads cleared + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun clearCompletedDownloadsCallback(): Int { + val toRemove = activeDownloads.filter { DownloadStatus.isTerminal(it.value.status) } + toRemove.keys.forEach { activeDownloads.remove(it) } + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Cleared ${toRemove.size} completed downloads", + ) + + return toRemove.size + } + + // ======================================================================== + // DOWNLOAD EXECUTION + // ======================================================================== + + /** + * Execute a download task. + */ + private fun executeDownload(task: DownloadTask, resumeFrom: Long = 0L) { + var connection: HttpURLConnection? = null + var inputStream: InputStream? = null + var outputStream: FileOutputStream? = null + + try { + task.status = DownloadStatus.DOWNLOADING + + // Check for custom provider + val provider = downloadProvider + if (provider != null) { + val downloadedBytes = AtomicLong(resumeFrom) + val success = + provider.download( + task.url, + task.destinationPath, + ) { bytes, total -> + downloadedBytes.set(bytes) + task.downloadedBytes = bytes + task.totalBytes = total + notifyProgress(task) + } + + if (success) { + completeDownload(task) + } else { + failDownload(task, DownloadError.UNKNOWN, "Custom provider download failed") + } + return + } + + // Standard download using HttpURLConnection + val url = URL(task.url) + connection = url.openConnection() as HttpURLConnection + connection.connectTimeout = DEFAULT_CONNECT_TIMEOUT_MS + connection.readTimeout = DEFAULT_READ_TIMEOUT_MS + connection.setRequestProperty("User-Agent", "RunAnywhere-SDK/Kotlin") + + // Support resume if possible + if (resumeFrom > 0) { + connection.setRequestProperty("Range", "bytes=$resumeFrom-") + } + + connection.connect() + + val responseCode = connection.responseCode + when { + responseCode == HttpURLConnection.HTTP_OK -> { + task.totalBytes = connection.contentLengthLong + task.downloadedBytes = 0L + } + responseCode == HttpURLConnection.HTTP_PARTIAL -> { + // Resume successful + val contentRange = connection.getHeaderField("Content-Range") + if (contentRange != null && contentRange.contains("/")) { + val total = contentRange.substringAfter("/").toLongOrNull() + if (total != null) { + task.totalBytes = total + } + } + task.downloadedBytes = resumeFrom + } + responseCode in 400..599 -> { + failDownload(task, DownloadError.SERVER_ERROR, "Server returned $responseCode") + return + } + else -> { + failDownload(task, DownloadError.NETWORK_ERROR, "Unexpected response: $responseCode") + return + } + } + + // Ensure parent directory exists + val destFile = File(task.destinationPath) + destFile.parentFile?.mkdirs() + + inputStream = connection.inputStream + outputStream = FileOutputStream(destFile, resumeFrom > 0) + + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var bytesRead: Int + var lastProgressUpdate = System.currentTimeMillis() + + while (inputStream.read(buffer).also { bytesRead = it } != -1) { + // Check for cancellation + if (Thread.currentThread().isInterrupted || task.status == DownloadStatus.CANCELLED) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Download interrupted: ${task.downloadId}", + ) + return + } + + // Check for pause + if (task.status == DownloadStatus.PAUSED) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Download paused during execution: ${task.downloadId}", + ) + return + } + + outputStream.write(buffer, 0, bytesRead) + task.downloadedBytes += bytesRead + + // Throttle progress updates (max once per 100ms) + val now = System.currentTimeMillis() + if (now - lastProgressUpdate >= 100) { + notifyProgress(task) + lastProgressUpdate = now + } + } + + outputStream.flush() + + // Verify checksum if provided + if (task.expectedChecksum != null) { + task.status = DownloadStatus.VERIFYING + if (!verifyChecksum(task.destinationPath, task.expectedChecksum)) { + failDownload(task, DownloadError.CHECKSUM_FAILED, "Checksum verification failed") + return + } + } + + // Complete download + completeDownload(task) + } catch (e: java.net.SocketTimeoutException) { + failDownload(task, DownloadError.TIMEOUT, DownloadError.getUserMessage(DownloadError.TIMEOUT)) + } catch (e: java.net.UnknownHostException) { + // DNS resolution failed - likely no internet or DNS issue + val userMessage = + if (!checkNetworkConnectivity()) { + DownloadError.getUserMessage(DownloadError.NETWORK_UNAVAILABLE) + } else { + DownloadError.getUserMessage(DownloadError.DNS_ERROR) + } + failDownload(task, DownloadError.DNS_ERROR, userMessage) + } catch (e: java.net.ConnectException) { + // Connection refused or network unreachable + val userMessage = + if (!checkNetworkConnectivity()) { + DownloadError.getUserMessage(DownloadError.NETWORK_UNAVAILABLE) + } else { + DownloadError.getUserMessage(DownloadError.NETWORK_ERROR) + } + failDownload(task, DownloadError.NETWORK_ERROR, userMessage) + } catch (e: java.net.NoRouteToHostException) { + failDownload(task, DownloadError.NETWORK_UNAVAILABLE, DownloadError.getUserMessage(DownloadError.NETWORK_UNAVAILABLE)) + } catch (e: javax.net.ssl.SSLException) { + failDownload(task, DownloadError.SSL_ERROR, DownloadError.getUserMessage(DownloadError.SSL_ERROR)) + } catch (e: java.io.IOException) { + if (Thread.currentThread().isInterrupted) { + // Download was cancelled/paused + return + } + // Check if this is a network-related IO error + val errorMessage = e.message?.lowercase() ?: "" + val (errorCode, userMessage) = + when { + errorMessage.contains("network") || errorMessage.contains("connection") -> { + if (!checkNetworkConnectivity()) { + Pair(DownloadError.NETWORK_UNAVAILABLE, DownloadError.getUserMessage(DownloadError.NETWORK_UNAVAILABLE)) + } else { + Pair(DownloadError.NETWORK_ERROR, DownloadError.getUserMessage(DownloadError.NETWORK_ERROR)) + } + } + errorMessage.contains("space") || errorMessage.contains("storage") -> { + Pair(DownloadError.INSUFFICIENT_STORAGE, DownloadError.getUserMessage(DownloadError.INSUFFICIENT_STORAGE)) + } + else -> { + Pair(DownloadError.FILE_ERROR, DownloadError.getUserMessage(DownloadError.FILE_ERROR)) + } + } + failDownload(task, errorCode, userMessage) + } catch (e: Exception) { + if (Thread.currentThread().isInterrupted) { + return + } + failDownload(task, DownloadError.UNKNOWN, DownloadError.getUserMessage(DownloadError.UNKNOWN)) + } finally { + try { + inputStream?.close() + } catch (e: Exception) { + // Ignore + } + try { + outputStream?.close() + } catch (e: Exception) { + // Ignore + } + connection?.disconnect() + } + } + + /** + * Complete a download successfully. + */ + private fun completeDownload(task: DownloadTask) { + task.status = DownloadStatus.COMPLETED + task.completedAt = System.currentTimeMillis() + + // Get file size + val fileSize = File(task.destinationPath).length() + task.downloadedBytes = fileSize + if (task.totalBytes < 0) { + task.totalBytes = fileSize + } + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Download completed: ${task.downloadId} (${fileSize / 1024}KB)", + ) + + // Move to final location + val moved = + CppBridgeModelPaths.moveDownloadToFinal( + task.destinationPath, + task.modelId, + task.modelType, + ) + + if (moved) { + // Update model download status in C++ registry with local path + val finalPath = CppBridgeModelPaths.getModelPath(task.modelId, task.modelType) + CppBridgeModelRegistry.updateDownloadStatus(task.modelId, finalPath) + + // Notify listener + try { + downloadListener?.onDownloadCompleted(task.downloadId, task.modelId, finalPath, fileSize) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in download listener onDownloadCompleted: ${e.message}", + ) + } + } else { + failDownload(task, DownloadError.FILE_ERROR, "Failed to move download to final location") + } + } + + /** + * Fail a download with an error. + */ + private fun failDownload(task: DownloadTask, error: Int, message: String) { + task.status = DownloadStatus.FAILED + task.error = error + task.completedAt = System.currentTimeMillis() + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "Download failed: ${task.downloadId} - $message", + ) + + // Clear download status on failure (model is no longer downloaded) + CppBridgeModelRegistry.updateDownloadStatus(task.modelId, null) + + // Cleanup temp file + try { + File(task.destinationPath).delete() + } catch (e: Exception) { + // Ignore cleanup errors + } + + // Notify listener + try { + downloadListener?.onDownloadFailed(task.downloadId, task.modelId, error, message) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in download listener onDownloadFailed: ${e.message}", + ) + } + } + + /** + * Notify progress update. + */ + private fun notifyProgress(task: DownloadTask) { + try { + downloadListener?.onDownloadProgress( + task.downloadId, + task.downloadedBytes, + task.totalBytes, + task.getProgress(), + ) + } catch (e: Exception) { + // Ignore progress listener errors + } + + // Note: C++ progress callback not used - downloads are managed entirely in Kotlin + // The progress is reported through downloadListener to the SDK's Flow-based API + } + + /** + * Verify file checksum. + */ + private fun verifyChecksum(filePath: String, expectedChecksum: String): Boolean { + return try { + val file = File(filePath) + if (!file.exists()) return false + + val digest = java.security.MessageDigest.getInstance("SHA-256") + file.inputStream().use { input -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var bytesRead: Int + while (input.read(buffer).also { bytesRead = it } != -1) { + digest.update(buffer, 0, bytesRead) + } + } + + val actualChecksum = digest.digest().joinToString("") { "%02x".format(it) } + val matches = actualChecksum.equals(expectedChecksum, ignoreCase = true) + + if (!matches) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Checksum mismatch: expected=$expectedChecksum, actual=$actualChecksum", + ) + } + + matches + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "Checksum verification error: ${e.message}", + ) + false + } + } + + // ======================================================================== + // JNI NATIVE DECLARATIONS + // ======================================================================== + + /** + * Native method to set the download callbacks with C++ core. + * + * Registers [startDownloadCallback], [cancelDownloadCallback], + * [pauseDownloadCallback], [resumeDownloadCallback], etc. with C++ core. + * Reserved for future native callback integration. + * + * C API: rac_download_set_callbacks(...) + */ + @Suppress("unused") + @JvmStatic + private external fun nativeSetDownloadCallbacks() + + /** + * Native method to unset the download callbacks. + * + * Called during shutdown to clean up native resources. + * Reserved for future native callback integration. + * + * C API: rac_download_set_callbacks(nullptr) + */ + @Suppress("unused") + @JvmStatic + private external fun nativeUnsetDownloadCallbacks() + + // Note: nativeInvokeProgressCallback removed - downloads are managed entirely in Kotlin + // Progress is reported through downloadListener to the SDK's Flow-based API + + /** + * Native method to start a download from C++. + * + * @param url The URL to download + * @param modelId The model ID + * @param modelType The model type + * @param priority Download priority + * @param expectedChecksum Expected checksum (or null) + * @return The download ID, or null on error + * + * C API: rac_download_start(url, model_id, type, priority, checksum) + */ + @JvmStatic + external fun nativeStartDownload( + url: String, + modelId: String, + modelType: Int, + priority: Int, + expectedChecksum: String?, + ): String? + + /** + * Native method to cancel a download from C++. + * + * @param downloadId The download ID + * @return 0 on success, error code on failure + * + * C API: rac_download_cancel(download_id) + */ + @JvmStatic + external fun nativeCancel(downloadId: String): Int + + /** + * Native method to pause a download from C++. + * + * @param downloadId The download ID + * @return 0 on success, error code on failure + * + * C API: rac_download_pause(download_id) + */ + @JvmStatic + external fun nativePause(downloadId: String): Int + + /** + * Native method to resume a download from C++. + * + * @param downloadId The download ID + * @return 0 on success, error code on failure + * + * C API: rac_download_resume(download_id) + */ + @JvmStatic + external fun nativeResume(downloadId: String): Int + + /** + * Native method to get download status from C++. + * + * @param downloadId The download ID + * @return Download status, or -1 if not found + * + * C API: rac_download_get_status(download_id) + */ + @JvmStatic + external fun nativeGetStatus(downloadId: String): Int + + // ======================================================================== + // LIFECYCLE MANAGEMENT + // ======================================================================== + + /** + * Unregister the download callbacks and clean up resources. + * + * Called during SDK shutdown. + */ + fun unregister() { + synchronized(lock) { + if (!isRegistered) { + return + } + + // TODO: Call native unregistration + // nativeUnsetDownloadCallbacks() + + downloadListener = null + downloadProvider = null + isRegistered = false + } + } + + /** + * Shutdown the download manager. + * + * Cancels all active downloads and releases resources. + */ + fun shutdown() { + synchronized(lock) { + unregister() + + // Cancel all active downloads + activeDownloads.values + .filter { it.isActive() } + .forEach { task -> + cancelDownloadCallback(task.downloadId) + } + + // Shutdown executor + try { + downloadExecutor.shutdown() + if (!downloadExecutor.awaitTermination(5, TimeUnit.SECONDS)) { + downloadExecutor.shutdownNow() + } + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error shutting down download executor: ${e.message}", + ) + downloadExecutor.shutdownNow() + } + + activeDownloads.clear() + downloadFutures.clear() + } + } + + // ======================================================================== + // UTILITY FUNCTIONS + // ======================================================================== + + /** + * Start a download for a model. + * + * @param url The URL to download from + * @param modelId The model ID + * @param modelType The model type (see [CppBridgeModelRegistry.ModelType]) + * @param priority Download priority (see [DownloadPriority]) + * @param expectedChecksum Expected checksum for verification (null to skip) + * @return The download ID, or null if download could not be started + */ + fun startDownload( + url: String, + modelId: String, + modelType: Int, + priority: Int = DownloadPriority.NORMAL, + expectedChecksum: String? = null, + ): String? { + return startDownloadCallback(url, modelId, modelType, priority, expectedChecksum) + } + + /** + * Cancel a download. + * + * @param downloadId The download ID + * @return true if cancelled + */ + fun cancelDownload(downloadId: String): Boolean { + return cancelDownloadCallback(downloadId) + } + + /** + * Pause a download. + * + * @param downloadId The download ID + * @return true if paused + */ + fun pauseDownload(downloadId: String): Boolean { + return pauseDownloadCallback(downloadId) + } + + /** + * Resume a download. + * + * @param downloadId The download ID + * @return true if resumed + */ + fun resumeDownload(downloadId: String): Boolean { + return resumeDownloadCallback(downloadId) + } + + /** + * Get the status of a download. + * + * @param downloadId The download ID + * @return The download status, or -1 if not found + */ + fun getDownloadStatus(downloadId: String): Int { + return getDownloadStatusCallback(downloadId) + } + + /** + * Get a download task by ID. + * + * @param downloadId The download ID + * @return The [DownloadTask], or null if not found + */ + fun getDownload(downloadId: String): DownloadTask? { + return activeDownloads[downloadId] + } + + /** + * Get all active downloads. + * + * @return List of active [DownloadTask] instances + */ + fun getActiveDownloads(): List { + return activeDownloads.values.filter { it.isActive() } + } + + /** + * Get all downloads. + * + * @return List of all [DownloadTask] instances + */ + fun getAllDownloads(): List { + return activeDownloads.values.toList() + } + + /** + * Get the number of active downloads. + * + * @return Active download count + */ + fun getActiveDownloadCount(): Int { + return getActiveDownloadCountCallback() + } + + /** + * Clear completed downloads from tracking. + * + * @return Number of downloads cleared + */ + fun clearCompletedDownloads(): Int { + return clearCompletedDownloadsCallback() + } + + /** + * Cancel all active downloads. + * + * @return Number of downloads cancelled + */ + fun cancelAllDownloads(): Int { + val activeIds = + activeDownloads.values + .filter { it.isActive() } + .map { it.downloadId } + + var cancelled = 0 + for (downloadId in activeIds) { + if (cancelDownloadCallback(downloadId)) { + cancelled++ + } + } + + return cancelled + } + + /** + * Escape special characters for JSON string. + */ + private fun escapeJson(value: String): String { + return value + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + } + + // ======================================================================== + // NETWORK CONNECTIVITY + // ======================================================================== + + /** + * Check if network connectivity is available. + * + * On Android, uses ConnectivityManager to check network state. + * On JVM, attempts a simple connection check. + * + * @return true if network is available, false otherwise + */ + private fun checkNetworkConnectivity(): Boolean { + return try { + // Try to use Android's NetworkConnectivity if available + val networkClass = Class.forName("com.runanywhere.sdk.platform.NetworkConnectivity") + val isAvailableMethod = networkClass.getDeclaredMethod("isNetworkAvailable") + val instance = networkClass.getDeclaredField("INSTANCE").get(null) + isAvailableMethod.invoke(instance) as Boolean + } catch (e: ClassNotFoundException) { + // Not on Android, assume network is available (will fail with proper error if not) + true + } catch (e: Exception) { + // If we can't check, assume available and let it fail with proper error message + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Could not check network connectivity: ${e.message}", + ) + true + } + } + + /** + * Check network connectivity and return detailed status. + * + * @return Pair of (isAvailable, statusMessage) + */ + fun checkNetworkStatus(): Pair { + return try { + val networkClass = Class.forName("com.runanywhere.sdk.platform.NetworkConnectivity") + val isAvailableMethod = networkClass.getDeclaredMethod("isNetworkAvailable") + val getDescriptionMethod = networkClass.getDeclaredMethod("getNetworkDescription") + val instance = networkClass.getDeclaredField("INSTANCE").get(null) + + val isAvailable = isAvailableMethod.invoke(instance) as Boolean + val description = getDescriptionMethod.invoke(instance) as String + + Pair(isAvailable, description) + } catch (e: Exception) { + Pair(true, "Unknown") + } + } +} diff --git a/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeEvents.kt b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeEvents.kt new file mode 100644 index 000000000..dab3443a7 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeEvents.kt @@ -0,0 +1,1450 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * Events extension for CppBridge. + * Provides analytics event callback registration for C++ core. + * + * Follows iOS CppBridge+Telemetry.swift architecture. + */ + +package com.runanywhere.sdk.foundation.bridge.extensions + +import com.runanywhere.sdk.native.bridge.RunAnywhereBridge + +/** + * Events bridge that registers analytics event callbacks with C++ core. + * + * The C++ core generates analytics events during SDK operations (model loading, + * inference, errors, etc.). This extension registers a callback to receive + * those events and route them to the Kotlin analytics system. + * + * Usage: + * - Called during Phase 1 initialization in [CppBridge.initialize] + * - Must be registered after [CppBridgePlatformAdapter] is registered + * + * Thread Safety: + * - Registration is thread-safe via synchronized block + * - Event callback is called from C++ threads, must be thread-safe + */ +object CppBridgeEvents { + /** + * Event type constants matching C++ rac_analytics_events.h RAC_EVENT_* values. + * These values MUST match the C++ enum exactly for proper event routing. + */ + object EventType { + // LLM Events (100-199) + const val LLM_MODEL_LOAD_STARTED = 100 + const val LLM_MODEL_LOAD_COMPLETED = 101 + const val LLM_MODEL_LOAD_FAILED = 102 + const val LLM_MODEL_UNLOADED = 103 + const val LLM_GENERATION_STARTED = 110 + const val LLM_GENERATION_COMPLETED = 111 + const val LLM_GENERATION_FAILED = 112 + const val LLM_FIRST_TOKEN = 113 + const val LLM_STREAMING_UPDATE = 114 + + // STT Events (200-299) + const val STT_MODEL_LOAD_STARTED = 200 + const val STT_MODEL_LOAD_COMPLETED = 201 + const val STT_MODEL_LOAD_FAILED = 202 + const val STT_MODEL_UNLOADED = 203 + const val STT_TRANSCRIPTION_STARTED = 210 + const val STT_TRANSCRIPTION_COMPLETED = 211 + const val STT_TRANSCRIPTION_FAILED = 212 + const val STT_PARTIAL_TRANSCRIPT = 213 + + // TTS Events (300-399) + const val TTS_VOICE_LOAD_STARTED = 300 + const val TTS_VOICE_LOAD_COMPLETED = 301 + const val TTS_VOICE_LOAD_FAILED = 302 + const val TTS_VOICE_UNLOADED = 303 + const val TTS_SYNTHESIS_STARTED = 310 + const val TTS_SYNTHESIS_COMPLETED = 311 + const val TTS_SYNTHESIS_FAILED = 312 + const val TTS_SYNTHESIS_CHUNK = 313 + + // VAD Events (400-499) + const val VAD_STARTED = 400 + const val VAD_STOPPED = 401 + const val VAD_SPEECH_STARTED = 402 + const val VAD_SPEECH_ENDED = 403 + const val VAD_PAUSED = 404 + const val VAD_RESUMED = 405 + + // VoiceAgent Events (500-599) + const val VOICE_AGENT_TURN_STARTED = 500 + const val VOICE_AGENT_TURN_COMPLETED = 501 + const val VOICE_AGENT_TURN_FAILED = 502 + const val VOICE_AGENT_STT_STATE_CHANGED = 510 + const val VOICE_AGENT_LLM_STATE_CHANGED = 511 + const val VOICE_AGENT_TTS_STATE_CHANGED = 512 + const val VOICE_AGENT_ALL_READY = 513 + + // SDK Lifecycle Events (600-699) + const val SDK_INIT_STARTED = 600 + const val SDK_INIT_COMPLETED = 601 + const val SDK_INIT_FAILED = 602 + const val SDK_MODELS_LOADED = 603 + + // Model Download Events (700-719) + const val MODEL_DOWNLOAD_STARTED = 700 + const val MODEL_DOWNLOAD_PROGRESS = 701 + const val MODEL_DOWNLOAD_COMPLETED = 702 + const val MODEL_DOWNLOAD_FAILED = 703 + const val MODEL_DOWNLOAD_CANCELLED = 704 + + // Model Extraction Events (710-719) + const val MODEL_EXTRACTION_STARTED = 710 + const val MODEL_EXTRACTION_PROGRESS = 711 + const val MODEL_EXTRACTION_COMPLETED = 712 + const val MODEL_EXTRACTION_FAILED = 713 + + // Model Deletion Events (720-729) + const val MODEL_DELETED = 720 + + // Storage Events (800-899) + const val STORAGE_CACHE_CLEARED = 800 + const val STORAGE_CACHE_CLEAR_FAILED = 801 + const val STORAGE_TEMP_CLEANED = 802 + + // Device Events (900-999) + const val DEVICE_REGISTERED = 900 + const val DEVICE_REGISTRATION_FAILED = 901 + + // Network Events (1000-1099) + const val NETWORK_CONNECTIVITY_CHANGED = 1000 + + // Error Events (1100-1199) + const val SDK_ERROR = 1100 + + // Framework Events (1200-1299) + const val FRAMEWORK_MODELS_REQUESTED = 1200 + const val FRAMEWORK_MODELS_RETRIEVED = 1201 + + /** + * Get a human-readable name for the event type. + */ + fun getName(type: Int): String = + when (type) { + // LLM + LLM_MODEL_LOAD_STARTED -> "LLM_MODEL_LOAD_STARTED" + LLM_MODEL_LOAD_COMPLETED -> "LLM_MODEL_LOAD_COMPLETED" + LLM_MODEL_LOAD_FAILED -> "LLM_MODEL_LOAD_FAILED" + LLM_MODEL_UNLOADED -> "LLM_MODEL_UNLOADED" + LLM_GENERATION_STARTED -> "LLM_GENERATION_STARTED" + LLM_GENERATION_COMPLETED -> "LLM_GENERATION_COMPLETED" + LLM_GENERATION_FAILED -> "LLM_GENERATION_FAILED" + LLM_FIRST_TOKEN -> "LLM_FIRST_TOKEN" + LLM_STREAMING_UPDATE -> "LLM_STREAMING_UPDATE" + // STT + STT_MODEL_LOAD_STARTED -> "STT_MODEL_LOAD_STARTED" + STT_MODEL_LOAD_COMPLETED -> "STT_MODEL_LOAD_COMPLETED" + STT_MODEL_LOAD_FAILED -> "STT_MODEL_LOAD_FAILED" + STT_MODEL_UNLOADED -> "STT_MODEL_UNLOADED" + STT_TRANSCRIPTION_STARTED -> "STT_TRANSCRIPTION_STARTED" + STT_TRANSCRIPTION_COMPLETED -> "STT_TRANSCRIPTION_COMPLETED" + STT_TRANSCRIPTION_FAILED -> "STT_TRANSCRIPTION_FAILED" + STT_PARTIAL_TRANSCRIPT -> "STT_PARTIAL_TRANSCRIPT" + // TTS + TTS_VOICE_LOAD_STARTED -> "TTS_VOICE_LOAD_STARTED" + TTS_VOICE_LOAD_COMPLETED -> "TTS_VOICE_LOAD_COMPLETED" + TTS_VOICE_LOAD_FAILED -> "TTS_VOICE_LOAD_FAILED" + TTS_VOICE_UNLOADED -> "TTS_VOICE_UNLOADED" + TTS_SYNTHESIS_STARTED -> "TTS_SYNTHESIS_STARTED" + TTS_SYNTHESIS_COMPLETED -> "TTS_SYNTHESIS_COMPLETED" + TTS_SYNTHESIS_FAILED -> "TTS_SYNTHESIS_FAILED" + TTS_SYNTHESIS_CHUNK -> "TTS_SYNTHESIS_CHUNK" + // VAD + VAD_STARTED -> "VAD_STARTED" + VAD_STOPPED -> "VAD_STOPPED" + VAD_SPEECH_STARTED -> "VAD_SPEECH_STARTED" + VAD_SPEECH_ENDED -> "VAD_SPEECH_ENDED" + VAD_PAUSED -> "VAD_PAUSED" + VAD_RESUMED -> "VAD_RESUMED" + // Voice Agent + VOICE_AGENT_TURN_STARTED -> "VOICE_AGENT_TURN_STARTED" + VOICE_AGENT_TURN_COMPLETED -> "VOICE_AGENT_TURN_COMPLETED" + VOICE_AGENT_TURN_FAILED -> "VOICE_AGENT_TURN_FAILED" + VOICE_AGENT_STT_STATE_CHANGED -> "VOICE_AGENT_STT_STATE_CHANGED" + VOICE_AGENT_LLM_STATE_CHANGED -> "VOICE_AGENT_LLM_STATE_CHANGED" + VOICE_AGENT_TTS_STATE_CHANGED -> "VOICE_AGENT_TTS_STATE_CHANGED" + VOICE_AGENT_ALL_READY -> "VOICE_AGENT_ALL_READY" + // SDK Lifecycle + SDK_INIT_STARTED -> "SDK_INIT_STARTED" + SDK_INIT_COMPLETED -> "SDK_INIT_COMPLETED" + SDK_INIT_FAILED -> "SDK_INIT_FAILED" + SDK_MODELS_LOADED -> "SDK_MODELS_LOADED" + // Download + MODEL_DOWNLOAD_STARTED -> "MODEL_DOWNLOAD_STARTED" + MODEL_DOWNLOAD_PROGRESS -> "MODEL_DOWNLOAD_PROGRESS" + MODEL_DOWNLOAD_COMPLETED -> "MODEL_DOWNLOAD_COMPLETED" + MODEL_DOWNLOAD_FAILED -> "MODEL_DOWNLOAD_FAILED" + MODEL_DOWNLOAD_CANCELLED -> "MODEL_DOWNLOAD_CANCELLED" + // Extraction + MODEL_EXTRACTION_STARTED -> "MODEL_EXTRACTION_STARTED" + MODEL_EXTRACTION_PROGRESS -> "MODEL_EXTRACTION_PROGRESS" + MODEL_EXTRACTION_COMPLETED -> "MODEL_EXTRACTION_COMPLETED" + MODEL_EXTRACTION_FAILED -> "MODEL_EXTRACTION_FAILED" + // Deletion + MODEL_DELETED -> "MODEL_DELETED" + // Storage + STORAGE_CACHE_CLEARED -> "STORAGE_CACHE_CLEARED" + STORAGE_CACHE_CLEAR_FAILED -> "STORAGE_CACHE_CLEAR_FAILED" + STORAGE_TEMP_CLEANED -> "STORAGE_TEMP_CLEANED" + // Device + DEVICE_REGISTERED -> "DEVICE_REGISTERED" + DEVICE_REGISTRATION_FAILED -> "DEVICE_REGISTRATION_FAILED" + // Network + NETWORK_CONNECTIVITY_CHANGED -> "NETWORK_CONNECTIVITY_CHANGED" + // Error + SDK_ERROR -> "SDK_ERROR" + // Framework + FRAMEWORK_MODELS_REQUESTED -> "FRAMEWORK_MODELS_REQUESTED" + FRAMEWORK_MODELS_RETRIEVED -> "FRAMEWORK_MODELS_RETRIEVED" + else -> "UNKNOWN($type)" + } + } + + @Volatile + private var isRegistered: Boolean = false + + private val lock = Any() + + /** + * Tag for logging. + */ + private const val TAG = "CppBridgeEvents" + + /** + * Optional listener for receiving analytics events. + * Set this before calling [register] to receive events. + */ + @Volatile + var eventListener: AnalyticsEventListener? = null + + /** + * Listener interface for receiving analytics events from C++ core. + */ + interface AnalyticsEventListener { + /** + * Called when an analytics event is received from C++ core. + * + * @param eventType The type of event (see [EventType] constants) + * @param eventName The name/category of the event + * @param eventData JSON-encoded event data, or null if no data + * @param timestampMs The timestamp when the event occurred (milliseconds since epoch) + */ + fun onEvent(eventType: Int, eventName: String, eventData: String?, timestampMs: Long) + } + + /** + * Register the analytics event callback with C++ core. + * + * This connects C++ analytics events to the telemetry manager for batching and HTTP transport. + * Events from LLM/STT/TTS operations flow: C++ emit → callback → telemetry manager → HTTP + * + * @param telemetryHandle Handle to the telemetry manager (from racTelemetryManagerCreate) + * @return true if registration succeeded, false otherwise + */ + fun register(telemetryHandle: Long): Boolean { + synchronized(lock) { + if (isRegistered) { + return true + } + + if (telemetryHandle == 0L) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Cannot register analytics callback: telemetry handle is null", + ) + return false + } + + // Register C++ analytics callback that routes to telemetry manager + // This mirrors Swift's Events.register() -> rac_analytics_events_set_callback() + val result = RunAnywhereBridge.racAnalyticsEventsSetCallback(telemetryHandle) + if (result == 0) { // RAC_SUCCESS + isRegistered = true + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Analytics events callback registered with telemetry manager", + ) + return true + } else { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Failed to register analytics callback: error $result", + ) + return false + } + } + } + + /** + * Unregister the analytics event callback. + * Called during SDK shutdown. + */ + fun unregister() { + synchronized(lock) { + if (!isRegistered) { + return + } + // Unregister by passing 0 (null handle) + RunAnywhereBridge.racAnalyticsEventsSetCallback(0L) + isRegistered = false + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Analytics events callback unregistered", + ) + } + } + + /** + * Check if the events callback is registered. + */ + fun isRegistered(): Boolean = isRegistered + + // ======================================================================== + // EVENT CALLBACK + // ======================================================================== + + /** + * Event callback invoked by C++ core when an analytics event occurs. + * + * Routes events to the registered [AnalyticsEventListener] if one is set. + * + * @param eventType The type of event (see [EventType] constants) + * @param eventName The name/category of the event + * @param eventData JSON-encoded event data, or null if no data + * @param timestampMs The timestamp when the event occurred (milliseconds since epoch) + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun eventCallback(eventType: Int, eventName: String, eventData: String?, timestampMs: Long) { + // Log the event for debugging (at trace level to avoid noise) + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.TRACE, + TAG, + "Event: ${EventType.getName(eventType)} - $eventName", + ) + + // Route to the registered listener + try { + eventListener?.onEvent(eventType, eventName, eventData, timestampMs) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "Error in event listener: ${e.message}", + ) + } + } + + // ======================================================================== + // UTILITY FUNCTIONS + // ======================================================================== + + /** + * Track a custom event programmatically. + * + * This allows Kotlin code to emit events that will be processed + * by the same analytics pipeline as C++ events. + * + * @param eventType The type of event (see [EventType] constants) + * @param eventName The name/category of the event + * @param eventData Optional JSON-encoded event data + */ + fun trackEvent(eventType: Int, eventName: String, eventData: String? = null) { + val timestampMs = System.currentTimeMillis() + eventCallback(eventType, eventName, eventData, timestampMs) + } + + /** + * Track an error event. + * + * @param errorMessage The error message + * @param operation The operation that failed (e.g., "model_load") + * @param context Additional context (optional) + */ + fun trackError(errorMessage: String, operation: String = "unknown", context: String? = null) { + emitSDKError(errorMessage, operation, context) + } + + /** + * Track a warning event (logs only, not tracked via telemetry). + * + * @param warningMessage The warning message + * @param warningData Optional context data + */ + fun trackWarning(warningMessage: String, warningData: String? = null) { + val message = + if (warningData != null) { + "$warningMessage [context: $warningData]" + } else { + warningMessage + } + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + message, + ) + } + + // ======================================================================== + // DOWNLOAD EVENT HELPERS (mirrors Swift CppBridge+Telemetry.swift) + // ======================================================================== + + /** + * Emit download started event via C++. + * + * @param modelId The model being downloaded + * @param totalBytes Expected total bytes (0 if unknown) + */ + fun emitDownloadStarted(modelId: String, totalBytes: Long = 0) { + RunAnywhereBridge.racAnalyticsEventEmitDownload( + EventType.MODEL_DOWNLOAD_STARTED, + modelId, + 0.0, // progress + 0, // bytesDownloaded + totalBytes, + 0.0, // durationMs + 0, // sizeBytes + null, // archiveType + 0, // errorCode + null, // errorMessage + ) + } + + /** + * Emit download progress event via C++. + */ + fun emitDownloadProgress(modelId: String, progress: Double, bytesDownloaded: Long, totalBytes: Long) { + RunAnywhereBridge.racAnalyticsEventEmitDownload( + EventType.MODEL_DOWNLOAD_PROGRESS, + modelId, + progress, + bytesDownloaded, + totalBytes, + 0.0, // durationMs + 0, // sizeBytes + null, // archiveType + 0, // errorCode + null, // errorMessage + ) + } + + /** + * Emit download completed event via C++. + */ + fun emitDownloadCompleted(modelId: String, durationMs: Double, sizeBytes: Long) { + RunAnywhereBridge.racAnalyticsEventEmitDownload( + EventType.MODEL_DOWNLOAD_COMPLETED, + modelId, + 100.0, // progress + sizeBytes, // bytesDownloaded + sizeBytes, // totalBytes + durationMs, + sizeBytes, + null, // archiveType + 0, // errorCode (RAC_SUCCESS) + null, // errorMessage + ) + } + + /** + * Emit download failed event via C++. + */ + fun emitDownloadFailed(modelId: String, errorMessage: String) { + RunAnywhereBridge.racAnalyticsEventEmitDownload( + EventType.MODEL_DOWNLOAD_FAILED, + modelId, + 0.0, // progress + 0, // bytesDownloaded + 0, // totalBytes + 0.0, // durationMs + 0, // sizeBytes + null, // archiveType + -5, // errorCode (RAC_ERROR_OPERATION_FAILED) + errorMessage, + ) + } + + /** + * Emit download cancelled event via C++. + */ + fun emitDownloadCancelled(modelId: String) { + RunAnywhereBridge.racAnalyticsEventEmitDownload( + EventType.MODEL_DOWNLOAD_CANCELLED, + modelId, + 0.0, + 0, + 0, + 0.0, + 0, + null, + 0, // RAC_SUCCESS + null, + ) + } + + // ======================================================================== + // EXTRACTION EVENT HELPERS + // ======================================================================== + + /** + * Emit extraction started event via C++. + */ + fun emitExtractionStarted(modelId: String, archiveType: String) { + RunAnywhereBridge.racAnalyticsEventEmitDownload( + EventType.MODEL_EXTRACTION_STARTED, + modelId, + 0.0, + 0, + 0, + 0.0, + 0, + archiveType, + 0, + null, + ) + } + + /** + * Emit extraction progress event via C++. + */ + fun emitExtractionProgress(modelId: String, progress: Double) { + RunAnywhereBridge.racAnalyticsEventEmitDownload( + EventType.MODEL_EXTRACTION_PROGRESS, + modelId, + progress, + 0, + 0, + 0.0, + 0, + null, + 0, + null, + ) + } + + /** + * Emit extraction completed event via C++. + */ + fun emitExtractionCompleted(modelId: String, durationMs: Double) { + RunAnywhereBridge.racAnalyticsEventEmitDownload( + EventType.MODEL_EXTRACTION_COMPLETED, + modelId, + 100.0, + 0, + 0, + durationMs, + 0, + null, + 0, + null, + ) + } + + /** + * Emit extraction failed event via C++. + */ + fun emitExtractionFailed(modelId: String, errorMessage: String) { + RunAnywhereBridge.racAnalyticsEventEmitDownload( + EventType.MODEL_EXTRACTION_FAILED, + modelId, + 0.0, + 0, + 0, + 0.0, + 0, + null, + -5, + errorMessage, + ) + } + + // ======================================================================== + // MODEL DELETED EVENT + // ======================================================================== + + /** + * Emit model deleted event via C++. + */ + fun emitModelDeleted(modelId: String) { + RunAnywhereBridge.racAnalyticsEventEmitDownload( + EventType.MODEL_DELETED, + modelId, + 0.0, + 0, + 0, + 0.0, + 0, + null, + 0, + null, + ) + } + + // ======================================================================== + // SDK LIFECYCLE EVENTS + // ======================================================================== + + /** + * Emit SDK init started event via C++. + */ + fun emitSDKInitStarted() { + RunAnywhereBridge.racAnalyticsEventEmitSdkLifecycle( + EventType.SDK_INIT_STARTED, + 0.0, // durationMs + 0, // count + 0, // errorCode + null, // errorMessage + ) + } + + /** + * Emit SDK init completed event via C++. + */ + fun emitSDKInitCompleted(durationMs: Double) { + RunAnywhereBridge.racAnalyticsEventEmitSdkLifecycle( + EventType.SDK_INIT_COMPLETED, + durationMs, + 0, + 0, + null, + ) + } + + /** + * Emit SDK init failed event via C++. + */ + fun emitSDKInitFailed(errorMessage: String) { + RunAnywhereBridge.racAnalyticsEventEmitSdkLifecycle( + EventType.SDK_INIT_FAILED, + 0.0, + 0, + -5, // RAC_ERROR_OPERATION_FAILED + errorMessage, + ) + } + + /** + * Emit SDK models loaded event via C++. + */ + fun emitSDKModelsLoaded(count: Int) { + RunAnywhereBridge.racAnalyticsEventEmitSdkLifecycle( + EventType.SDK_MODELS_LOADED, + 0.0, + count, + 0, + null, + ) + } + + // ======================================================================== + // STORAGE EVENTS + // ======================================================================== + + /** + * Emit storage cache cleared event via C++. + */ + fun emitStorageCacheCleared(freedBytes: Long) { + RunAnywhereBridge.racAnalyticsEventEmitStorage( + EventType.STORAGE_CACHE_CLEARED, + freedBytes, + 0, + null, + ) + } + + /** + * Emit storage cache clear failed event via C++. + */ + fun emitStorageCacheClearFailed(errorMessage: String) { + RunAnywhereBridge.racAnalyticsEventEmitStorage( + EventType.STORAGE_CACHE_CLEAR_FAILED, + 0, + -5, + errorMessage, + ) + } + + /** + * Emit storage temp cleaned event via C++. + */ + fun emitStorageTempCleaned(freedBytes: Long) { + RunAnywhereBridge.racAnalyticsEventEmitStorage( + EventType.STORAGE_TEMP_CLEANED, + freedBytes, + 0, + null, + ) + } + + // ======================================================================== + // VOICE AGENT / PIPELINE EVENTS + // ======================================================================== + + /** + * Emit voice agent turn started event via C++. + */ + fun emitVoiceAgentTurnStarted() { + RunAnywhereBridge.racAnalyticsEventEmitSdkLifecycle( + EventType.VOICE_AGENT_TURN_STARTED, + 0.0, + 0, + 0, + null, + ) + } + + /** + * Emit voice agent turn completed event via C++. + */ + fun emitVoiceAgentTurnCompleted(durationMs: Double) { + RunAnywhereBridge.racAnalyticsEventEmitSdkLifecycle( + EventType.VOICE_AGENT_TURN_COMPLETED, + durationMs, + 0, + 0, + null, + ) + } + + /** + * Emit voice agent turn failed event via C++. + */ + fun emitVoiceAgentTurnFailed(errorMessage: String) { + RunAnywhereBridge.racAnalyticsEventEmitSdkLifecycle( + EventType.VOICE_AGENT_TURN_FAILED, + 0.0, + 0, + -5, + errorMessage, + ) + } + + // ======================================================================== + // DEVICE EVENTS + // ======================================================================== + + /** + * Emit device registered event via C++. + */ + fun emitDeviceRegistered(deviceId: String) { + RunAnywhereBridge.racAnalyticsEventEmitDevice( + EventType.DEVICE_REGISTERED, + deviceId, + 0, + null, + ) + } + + /** + * Emit device registration failed event via C++. + */ + fun emitDeviceRegistrationFailed(errorMessage: String) { + RunAnywhereBridge.racAnalyticsEventEmitDevice( + EventType.DEVICE_REGISTRATION_FAILED, + null, + -5, + errorMessage, + ) + } + + // ======================================================================== + // SDK ERROR EVENTS + // ======================================================================== + + /** + * Emit SDK error event via C++. + */ + fun emitSDKError(errorMessage: String, operation: String, context: String? = null) { + RunAnywhereBridge.racAnalyticsEventEmitSdkError( + EventType.SDK_ERROR, + -5, // RAC_ERROR_OPERATION_FAILED + errorMessage, + operation, + context, + ) + } + + // ======================================================================== + // NETWORK EVENTS + // ======================================================================== + + /** + * Emit network connectivity changed event via C++. + */ + fun emitNetworkConnectivityChanged(isOnline: Boolean) { + RunAnywhereBridge.racAnalyticsEventEmitNetwork( + EventType.NETWORK_CONNECTIVITY_CHANGED, + isOnline, + ) + } + + // ======================================================================== + // LLM MODEL EVENTS (mirrors Swift CppBridge+Telemetry.swift) + // ======================================================================== + + /** + * Inference framework constants matching C++ rac_inference_framework_t. + */ + object Framework { + const val UNKNOWN = 0 + const val LLAMACPP = 1 + const val ONNX = 2 + const val MLX = 3 + const val COREML = 4 + const val FOUNDATION = 5 + const val SYSTEM = 6 + } + + /** + * Emit LLM model load started event via C++. + */ + fun emitLlmModelLoadStarted(modelId: String, modelName: String?, framework: Int = Framework.UNKNOWN) { + RunAnywhereBridge.racAnalyticsEventEmitLlmModel( + EventType.LLM_MODEL_LOAD_STARTED, + modelId, + modelName, + 0, // modelSizeBytes + 0.0, // durationMs + framework, + 0, + null, + ) + } + + /** + * Emit LLM model load completed event via C++. + */ + fun emitLlmModelLoadCompleted( + modelId: String, + modelName: String?, + modelSizeBytes: Long, + durationMs: Double, + framework: Int = Framework.UNKNOWN, + ) { + RunAnywhereBridge.racAnalyticsEventEmitLlmModel( + EventType.LLM_MODEL_LOAD_COMPLETED, + modelId, + modelName, + modelSizeBytes, + durationMs, + framework, + 0, + null, + ) + } + + /** + * Emit LLM model load failed event via C++. + */ + fun emitLlmModelLoadFailed( + modelId: String, + modelName: String?, + errorMessage: String, + framework: Int = Framework.UNKNOWN, + ) { + RunAnywhereBridge.racAnalyticsEventEmitLlmModel( + EventType.LLM_MODEL_LOAD_FAILED, + modelId, + modelName, + 0, + 0.0, + framework, + -5, + errorMessage, + ) + } + + /** + * Emit LLM model unloaded event via C++. + */ + fun emitLlmModelUnloaded(modelId: String, modelName: String?, framework: Int = Framework.UNKNOWN) { + RunAnywhereBridge.racAnalyticsEventEmitLlmModel( + EventType.LLM_MODEL_UNLOADED, + modelId, + modelName, + 0, + 0.0, + framework, + 0, + null, + ) + } + + // ======================================================================== + // LLM GENERATION EVENTS + // ======================================================================== + + /** + * Emit LLM generation started event via C++. + */ + fun emitLlmGenerationStarted( + generationId: String?, + modelId: String?, + modelName: String?, + isStreaming: Boolean = false, + framework: Int = Framework.UNKNOWN, + ) { + RunAnywhereBridge.racAnalyticsEventEmitLlmGeneration( + EventType.LLM_GENERATION_STARTED, + generationId, + modelId, + modelName, + 0, + 0, + 0.0, + 0.0, // tokens, duration, tokensPerSec + isStreaming, + 0.0, // timeToFirstToken + framework, + 0f, + 0, + 0, // temperature, maxTokens, contextLength + 0, + null, + ) + } + + /** + * Emit LLM generation completed event via C++. + */ + fun emitLlmGenerationCompleted( + generationId: String?, + modelId: String?, + modelName: String?, + inputTokens: Int, + outputTokens: Int, + durationMs: Double, + tokensPerSecond: Double, + isStreaming: Boolean = false, + timeToFirstTokenMs: Double = 0.0, + framework: Int = Framework.UNKNOWN, + temperature: Float = 0f, + maxTokens: Int = 0, + contextLength: Int = 0, + ) { + RunAnywhereBridge.racAnalyticsEventEmitLlmGeneration( + EventType.LLM_GENERATION_COMPLETED, + generationId, + modelId, + modelName, + inputTokens, + outputTokens, + durationMs, + tokensPerSecond, + isStreaming, + timeToFirstTokenMs, + framework, + temperature, + maxTokens, + contextLength, + 0, + null, + ) + } + + /** + * Emit LLM generation failed event via C++. + */ + fun emitLlmGenerationFailed( + generationId: String?, + modelId: String?, + modelName: String?, + errorMessage: String, + isStreaming: Boolean = false, + framework: Int = Framework.UNKNOWN, + ) { + RunAnywhereBridge.racAnalyticsEventEmitLlmGeneration( + EventType.LLM_GENERATION_FAILED, + generationId, + modelId, + modelName, + 0, + 0, + 0.0, + 0.0, + isStreaming, + 0.0, + framework, + 0f, + 0, + 0, + -5, + errorMessage, + ) + } + + /** + * Emit LLM first token event via C++. + */ + fun emitLlmFirstToken( + generationId: String?, + modelId: String?, + timeToFirstTokenMs: Double, + framework: Int = Framework.UNKNOWN, + ) { + RunAnywhereBridge.racAnalyticsEventEmitLlmGeneration( + EventType.LLM_FIRST_TOKEN, + generationId, + modelId, + null, + 0, + 0, + 0.0, + 0.0, + true, // isStreaming + timeToFirstTokenMs, + framework, + 0f, + 0, + 0, + 0, + null, + ) + } + + // ======================================================================== + // STT MODEL EVENTS + // ======================================================================== + + /** + * Emit STT model load started event via C++. + */ + fun emitSttModelLoadStarted(modelId: String, modelName: String?, framework: Int = Framework.UNKNOWN) { + RunAnywhereBridge.racAnalyticsEventEmitSttTranscription( + EventType.STT_MODEL_LOAD_STARTED, + null, + modelId, + modelName, + null, + 0f, + 0.0, + 0.0, + 0, + 0, + 0.0, + null, + 0, + false, + framework, + 0, + null, + ) + } + + /** + * Emit STT model load completed event via C++. + */ + fun emitSttModelLoadCompleted( + modelId: String, + modelName: String?, + durationMs: Double, + framework: Int = Framework.UNKNOWN, + ) { + RunAnywhereBridge.racAnalyticsEventEmitSttTranscription( + EventType.STT_MODEL_LOAD_COMPLETED, + null, + modelId, + modelName, + null, + 0f, + durationMs, + 0.0, + 0, + 0, + 0.0, + null, + 0, + false, + framework, + 0, + null, + ) + } + + /** + * Emit STT model load failed event via C++. + */ + fun emitSttModelLoadFailed( + modelId: String, + modelName: String?, + errorMessage: String, + framework: Int = Framework.UNKNOWN, + ) { + RunAnywhereBridge.racAnalyticsEventEmitSttTranscription( + EventType.STT_MODEL_LOAD_FAILED, + null, + modelId, + modelName, + null, + 0f, + 0.0, + 0.0, + 0, + 0, + 0.0, + null, + 0, + false, + framework, + -5, + errorMessage, + ) + } + + // ======================================================================== + // STT TRANSCRIPTION EVENTS + // ======================================================================== + + /** + * Emit STT transcription started event via C++. + */ + fun emitSttTranscriptionStarted( + transcriptionId: String?, + modelId: String?, + modelName: String?, + audioLengthMs: Double, + audioSizeBytes: Int, + isStreaming: Boolean = false, + framework: Int = Framework.UNKNOWN, + ) { + RunAnywhereBridge.racAnalyticsEventEmitSttTranscription( + EventType.STT_TRANSCRIPTION_STARTED, + transcriptionId, + modelId, + modelName, + null, + 0f, + 0.0, + audioLengthMs, + audioSizeBytes, + 0, + 0.0, + null, + 0, + isStreaming, + framework, + 0, + null, + ) + } + + /** + * Emit STT transcription completed event via C++. + */ + fun emitSttTranscriptionCompleted( + transcriptionId: String?, + modelId: String?, + modelName: String?, + text: String?, + confidence: Float, + durationMs: Double, + audioLengthMs: Double, + audioSizeBytes: Int, + wordCount: Int, + realTimeFactor: Double, + language: String?, + sampleRate: Int, + isStreaming: Boolean = false, + framework: Int = Framework.UNKNOWN, + ) { + RunAnywhereBridge.racAnalyticsEventEmitSttTranscription( + EventType.STT_TRANSCRIPTION_COMPLETED, + transcriptionId, + modelId, + modelName, + text, + confidence, + durationMs, + audioLengthMs, + audioSizeBytes, + wordCount, + realTimeFactor, + language, + sampleRate, + isStreaming, + framework, + 0, + null, + ) + } + + /** + * Emit STT transcription failed event via C++. + */ + fun emitSttTranscriptionFailed( + transcriptionId: String?, + modelId: String?, + modelName: String?, + errorMessage: String, + isStreaming: Boolean = false, + framework: Int = Framework.UNKNOWN, + ) { + RunAnywhereBridge.racAnalyticsEventEmitSttTranscription( + EventType.STT_TRANSCRIPTION_FAILED, + transcriptionId, + modelId, + modelName, + null, + 0f, + 0.0, + 0.0, + 0, + 0, + 0.0, + null, + 0, + isStreaming, + framework, + -5, + errorMessage, + ) + } + + // ======================================================================== + // TTS MODEL EVENTS + // ======================================================================== + + /** + * Emit TTS voice load started event via C++. + */ + fun emitTtsVoiceLoadStarted(modelId: String, modelName: String?, framework: Int = Framework.UNKNOWN) { + RunAnywhereBridge.racAnalyticsEventEmitTtsSynthesis( + EventType.TTS_VOICE_LOAD_STARTED, + null, + modelId, + modelName, + 0, + 0.0, + 0, + 0.0, + 0.0, + 0, + framework, + 0, + null, + ) + } + + /** + * Emit TTS voice load completed event via C++. + */ + fun emitTtsVoiceLoadCompleted( + modelId: String, + modelName: String?, + durationMs: Double, + framework: Int = Framework.UNKNOWN, + ) { + RunAnywhereBridge.racAnalyticsEventEmitTtsSynthesis( + EventType.TTS_VOICE_LOAD_COMPLETED, + null, + modelId, + modelName, + 0, + 0.0, + 0, + durationMs, + 0.0, + 0, + framework, + 0, + null, + ) + } + + /** + * Emit TTS voice load failed event via C++. + */ + fun emitTtsVoiceLoadFailed( + modelId: String, + modelName: String?, + errorMessage: String, + framework: Int = Framework.UNKNOWN, + ) { + RunAnywhereBridge.racAnalyticsEventEmitTtsSynthesis( + EventType.TTS_VOICE_LOAD_FAILED, + null, + modelId, + modelName, + 0, + 0.0, + 0, + 0.0, + 0.0, + 0, + framework, + -5, + errorMessage, + ) + } + + // ======================================================================== + // TTS SYNTHESIS EVENTS + // ======================================================================== + + /** + * Emit TTS synthesis started event via C++. + */ + fun emitTtsSynthesisStarted( + synthesisId: String?, + modelId: String?, + modelName: String?, + characterCount: Int, + framework: Int = Framework.UNKNOWN, + ) { + RunAnywhereBridge.racAnalyticsEventEmitTtsSynthesis( + EventType.TTS_SYNTHESIS_STARTED, + synthesisId, + modelId, + modelName, + characterCount, + 0.0, + 0, + 0.0, + 0.0, + 0, + framework, + 0, + null, + ) + } + + /** + * Emit TTS synthesis completed event via C++. + */ + fun emitTtsSynthesisCompleted( + synthesisId: String?, + modelId: String?, + modelName: String?, + characterCount: Int, + audioDurationMs: Double, + audioSizeBytes: Int, + processingDurationMs: Double, + charactersPerSecond: Double, + sampleRate: Int, + framework: Int = Framework.UNKNOWN, + ) { + RunAnywhereBridge.racAnalyticsEventEmitTtsSynthesis( + EventType.TTS_SYNTHESIS_COMPLETED, + synthesisId, + modelId, + modelName, + characterCount, + audioDurationMs, + audioSizeBytes, + processingDurationMs, + charactersPerSecond, + sampleRate, + framework, + 0, + null, + ) + } + + /** + * Emit TTS synthesis failed event via C++. + */ + fun emitTtsSynthesisFailed( + synthesisId: String?, + modelId: String?, + modelName: String?, + errorMessage: String, + framework: Int = Framework.UNKNOWN, + ) { + RunAnywhereBridge.racAnalyticsEventEmitTtsSynthesis( + EventType.TTS_SYNTHESIS_FAILED, + synthesisId, + modelId, + modelName, + 0, + 0.0, + 0, + 0.0, + 0.0, + 0, + framework, + -5, + errorMessage, + ) + } + + // ======================================================================== + // VAD EVENTS + // ======================================================================== + + /** + * Emit VAD started event via C++. + */ + fun emitVadStarted() { + RunAnywhereBridge.racAnalyticsEventEmitVad( + EventType.VAD_STARTED, + 0.0, + 0f, + ) + } + + /** + * Emit VAD stopped event via C++. + */ + fun emitVadStopped() { + RunAnywhereBridge.racAnalyticsEventEmitVad( + EventType.VAD_STOPPED, + 0.0, + 0f, + ) + } + + /** + * Emit VAD speech started event via C++. + */ + fun emitVadSpeechStarted(energyLevel: Float = 0f) { + RunAnywhereBridge.racAnalyticsEventEmitVad( + EventType.VAD_SPEECH_STARTED, + 0.0, + energyLevel, + ) + } + + /** + * Emit VAD speech ended event via C++. + */ + fun emitVadSpeechEnded(speechDurationMs: Double, energyLevel: Float = 0f) { + RunAnywhereBridge.racAnalyticsEventEmitVad( + EventType.VAD_SPEECH_ENDED, + speechDurationMs, + energyLevel, + ) + } + + /** + * Emit VAD paused event via C++. + */ + fun emitVadPaused() { + RunAnywhereBridge.racAnalyticsEventEmitVad( + EventType.VAD_PAUSED, + 0.0, + 0f, + ) + } + + /** + * Emit VAD resumed event via C++. + */ + fun emitVadResumed() { + RunAnywhereBridge.racAnalyticsEventEmitVad( + EventType.VAD_RESUMED, + 0.0, + 0f, + ) + } +} diff --git a/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeHTTP.kt b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeHTTP.kt new file mode 100644 index 000000000..c9f29835a --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeHTTP.kt @@ -0,0 +1,828 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * HTTP extension for CppBridge. + * Provides HTTP transport bridge for C++ core network operations. + * + * Follows iOS CppBridge+HTTP.swift architecture. + */ + +package com.runanywhere.sdk.foundation.bridge.extensions + +import java.io.BufferedReader +import java.io.InputStreamReader +import java.io.OutputStreamWriter +import java.net.HttpURLConnection +import java.net.URL +import java.util.UUID +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +/** + * HTTP bridge that provides network transport callbacks for C++ core operations. + * + * The C++ core may need to perform HTTP requests for various operations such as: + * - Model downloads + * - Authentication flows + * - Service API calls + * - Configuration fetching + * + * This extension provides a unified HTTP transport layer via callbacks that C++ can invoke + * to perform network operations using the platform's native HTTP stack. + * + * Usage: + * - Called during Phase 1 initialization in [CppBridge.initialize] + * - Must be registered after [CppBridgePlatformAdapter] is registered + * + * Thread Safety: + * - Registration is thread-safe via synchronized block + * - HTTP requests are executed on a background thread pool + * - Callbacks from C++ are thread-safe + */ +object CppBridgeHTTP { + /** + * HTTP method constants matching C++ RAC_HTTP_METHOD_* values. + */ + object HttpMethod { + const val GET = 0 + const val POST = 1 + const val PUT = 2 + const val DELETE = 3 + const val PATCH = 4 + const val HEAD = 5 + const val OPTIONS = 6 + + /** + * Get the string representation of an HTTP method. + */ + fun getName(method: Int): String = + when (method) { + GET -> "GET" + POST -> "POST" + PUT -> "PUT" + DELETE -> "DELETE" + PATCH -> "PATCH" + HEAD -> "HEAD" + OPTIONS -> "OPTIONS" + else -> "GET" + } + } + + /** + * HTTP response status categories. + */ + object HttpStatus { + const val SUCCESS_MIN = 200 + const val SUCCESS_MAX = 299 + const val REDIRECT_MIN = 300 + const val REDIRECT_MAX = 399 + const val CLIENT_ERROR_MIN = 400 + const val CLIENT_ERROR_MAX = 499 + const val SERVER_ERROR_MIN = 500 + const val SERVER_ERROR_MAX = 599 + + fun isSuccess(statusCode: Int): Boolean = statusCode in SUCCESS_MIN..SUCCESS_MAX + + fun isRedirect(statusCode: Int): Boolean = statusCode in REDIRECT_MIN..REDIRECT_MAX + + fun isClientError(statusCode: Int): Boolean = statusCode in CLIENT_ERROR_MIN..CLIENT_ERROR_MAX + + fun isServerError(statusCode: Int): Boolean = statusCode in SERVER_ERROR_MIN..SERVER_ERROR_MAX + + fun isError(statusCode: Int): Boolean = isClientError(statusCode) || isServerError(statusCode) + } + + /** + * HTTP error codes for C++ callback responses. + */ + object HttpErrorCode { + const val NONE = 0 + const val NETWORK_ERROR = 1 + const val TIMEOUT = 2 + const val INVALID_URL = 3 + const val SSL_ERROR = 4 + const val UNKNOWN = 99 + } + + @Volatile + private var isRegistered: Boolean = false + + private val lock = Any() + + /** + * Tag for logging. + */ + private const val TAG = "CppBridgeHTTP" + + /** + * Default connection timeout in milliseconds. + */ + private const val DEFAULT_CONNECT_TIMEOUT_MS = 30_000 + + /** + * Default read timeout in milliseconds. + */ + private const val DEFAULT_READ_TIMEOUT_MS = 60_000 + + /** + * Maximum response size in bytes (10 MB). + */ + private const val MAX_RESPONSE_SIZE = 10 * 1024 * 1024 + + /** + * Background executor for HTTP requests. + * Using a cached thread pool to handle concurrent HTTP requests efficiently. + */ + private val httpExecutor = + Executors.newCachedThreadPool { runnable -> + Thread(runnable, "runanywhere-http").apply { + isDaemon = true + } + } + + /** + * Optional interceptor for customizing HTTP requests. + * Set this before calling [register] to customize requests (e.g., add auth headers). + */ + @Volatile + var requestInterceptor: HttpRequestInterceptor? = null + + /** + * Optional listener for HTTP request events. + * Set this to receive notifications about HTTP operations. + */ + @Volatile + var requestListener: HttpRequestListener? = null + + /** + * Interface for intercepting and modifying HTTP requests. + */ + interface HttpRequestInterceptor { + /** + * Called before an HTTP request is sent. + * Can be used to add headers, modify the URL, etc. + * + * @param url The request URL + * @param method The HTTP method (see [HttpMethod] constants) + * @param headers Mutable map of headers to be sent with the request + * @return Modified URL, or the original URL if no changes needed + */ + fun onBeforeRequest(url: String, method: Int, headers: MutableMap): String + } + + /** + * Listener interface for HTTP request events. + */ + interface HttpRequestListener { + /** + * Called when an HTTP request starts. + * + * @param requestId Unique identifier for this request + * @param url The request URL + * @param method The HTTP method + */ + fun onRequestStart(requestId: String, url: String, method: Int) + + /** + * Called when an HTTP request completes. + * + * @param requestId Unique identifier for this request + * @param statusCode The HTTP status code (-1 if request failed before getting a response) + * @param success Whether the request was successful + * @param durationMs Request duration in milliseconds + * @param errorMessage Error message if the request failed, null otherwise + */ + fun onRequestComplete( + requestId: String, + statusCode: Int, + success: Boolean, + durationMs: Long, + errorMessage: String?, + ) + } + + /** + * Register the HTTP callback with C++ core. + * + * This must be called during SDK initialization, after [CppBridgePlatformAdapter.register]. + * It is safe to call multiple times; subsequent calls are no-ops. + */ + fun register() { + synchronized(lock) { + if (isRegistered) { + return + } + + // Register the HTTP callback with C++ via JNI + // The callback will be invoked by C++ when HTTP requests need to be made + // TODO: Call native registration + // nativeSetHttpCallback() + + isRegistered = true + } + } + + /** + * Check if the HTTP callback is registered. + */ + fun isRegistered(): Boolean = isRegistered + + // ======================================================================== + // HTTP CALLBACK + // ======================================================================== + + /** + * HTTP callback invoked by C++ core to perform HTTP requests. + * + * Performs an HTTP request and returns the response via the completion callback. + * + * @param requestId Unique identifier for this request (generated by C++ or this method) + * @param url The request URL + * @param method The HTTP method (see [HttpMethod] constants) + * @param headers JSON-encoded headers map, or null for no headers + * @param body Request body as string, or null for no body + * @param timeoutMs Request timeout in milliseconds (0 for default) + * @param completionCallbackId ID for the C++ completion callback to invoke with the response + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun httpCallback( + requestId: String?, + url: String, + method: Int, + headers: String?, + body: String?, + timeoutMs: Int, + completionCallbackId: Long, + ) { + val actualRequestId = requestId ?: UUID.randomUUID().toString() + + // Log the request for debugging + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "HTTP ${HttpMethod.getName(method)} request to: $url", + ) + + // Notify listener of request start + try { + requestListener?.onRequestStart(actualRequestId, url, method) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in HTTP listener onRequestStart: ${e.message}", + ) + } + + // Execute HTTP request on background thread + httpExecutor.execute { + executeHttpRequest( + requestId = actualRequestId, + url = url, + method = method, + headersJson = headers, + body = body, + timeoutMs = timeoutMs, + completionCallbackId = completionCallbackId, + ) + } + } + + /** + * Execute an HTTP request synchronously. + */ + private fun executeHttpRequest( + requestId: String, + url: String, + method: Int, + headersJson: String?, + body: String?, + timeoutMs: Int, + completionCallbackId: Long, + ) { + var connection: HttpURLConnection? = null + var statusCode = -1 + var responseBody: String? = null + var responseHeaders: Map? = null + var errorMessage: String? = null + var errorCode = HttpErrorCode.NONE + val startTime = System.currentTimeMillis() + + try { + // Parse headers from JSON if provided + val headers = mutableMapOf() + if (headersJson != null) { + parseHeadersJson(headersJson, headers) + } + + // Allow interceptor to modify request + val finalUrl = requestInterceptor?.onBeforeRequest(url, method, headers) ?: url + + // Create connection + val urlObj = + try { + URL(finalUrl) + } catch (e: Exception) { + errorCode = HttpErrorCode.INVALID_URL + throw IllegalArgumentException("Invalid URL: $finalUrl", e) + } + + connection = urlObj.openConnection() as HttpURLConnection + connection.requestMethod = HttpMethod.getName(method) + + // Set timeouts + val connectTimeout = if (timeoutMs > 0) timeoutMs else DEFAULT_CONNECT_TIMEOUT_MS + val readTimeout = if (timeoutMs > 0) timeoutMs else DEFAULT_READ_TIMEOUT_MS + connection.connectTimeout = connectTimeout + connection.readTimeout = readTimeout + connection.doInput = true + + // Set headers + for ((key, value) in headers) { + connection.setRequestProperty(key, value) + } + + // Set default content type if not specified and body is present + if (body != null && !headers.keys.any { it.equals("Content-Type", ignoreCase = true) }) { + connection.setRequestProperty("Content-Type", "application/json") + } + + // Add default User-Agent if not set + if (!headers.keys.any { it.equals("User-Agent", ignoreCase = true) }) { + connection.setRequestProperty("User-Agent", "RunAnywhere-SDK/Kotlin") + } + + // Write body if present + if (body != null && method != HttpMethod.GET && method != HttpMethod.HEAD) { + connection.doOutput = true + OutputStreamWriter(connection.outputStream, Charsets.UTF_8).use { writer -> + writer.write(body) + writer.flush() + } + } + + // Get response + statusCode = connection.responseCode + + // Read response headers + responseHeaders = + connection.headerFields + .filterKeys { it != null } + .mapValues { it.value.firstOrNull() ?: "" } + .filterValues { it.isNotEmpty() } + + // Read response body + val inputStream = + if (HttpStatus.isSuccess(statusCode)) { + connection.inputStream + } else { + connection.errorStream + } + + if (inputStream != null) { + BufferedReader(InputStreamReader(inputStream, Charsets.UTF_8)).use { reader -> + val content = StringBuilder() + var bytesRead = 0 + val buffer = CharArray(8192) + var read: Int + + while (reader.read(buffer).also { read = it } != -1) { + bytesRead += read * 2 // Approximate byte count for chars + if (bytesRead > MAX_RESPONSE_SIZE) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Response truncated: exceeded max size of $MAX_RESPONSE_SIZE bytes", + ) + break + } + content.append(buffer, 0, read) + } + responseBody = content.toString() + } + } + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "HTTP response: $statusCode (${System.currentTimeMillis() - startTime}ms)", + ) + } catch (e: java.net.SocketTimeoutException) { + errorMessage = "Request timeout: ${e.message}" + errorCode = HttpErrorCode.TIMEOUT + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "HTTP request timeout: $errorMessage", + ) + } catch (e: javax.net.ssl.SSLException) { + errorMessage = "SSL error: ${e.message}" + errorCode = HttpErrorCode.SSL_ERROR + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "HTTP SSL error: $errorMessage", + ) + } catch (e: java.net.UnknownHostException) { + errorMessage = "Network error: Unknown host ${e.message}" + errorCode = HttpErrorCode.NETWORK_ERROR + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "HTTP network error: $errorMessage", + ) + } catch (e: java.io.IOException) { + errorMessage = "Network error: ${e.message}" + errorCode = HttpErrorCode.NETWORK_ERROR + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "HTTP I/O error: $errorMessage", + ) + } catch (e: Exception) { + errorMessage = e.message ?: "Unknown error" + if (errorCode == HttpErrorCode.NONE) { + errorCode = HttpErrorCode.UNKNOWN + } + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "HTTP request failed: $errorMessage", + ) + } finally { + connection?.disconnect() + } + + val durationMs = System.currentTimeMillis() - startTime + val success = HttpStatus.isSuccess(statusCode) + + // Notify listener of completion + try { + requestListener?.onRequestComplete(requestId, statusCode, success, durationMs, errorMessage) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in HTTP listener onRequestComplete: ${e.message}", + ) + } + + // Invoke C++ completion callback + try { + nativeInvokeCompletionCallback( + completionCallbackId, + statusCode, + responseBody, + serializeHeaders(responseHeaders), + errorCode, + errorMessage, + ) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "Error invoking completion callback: ${e.message}", + ) + } + } + + /** + * Parse a JSON string of headers into a mutable map. + * Simple JSON parsing without external dependencies. + */ + private fun parseHeadersJson(json: String, headers: MutableMap) { + // Simple JSON parsing for {"key": "value", ...} format + // Handles basic cases without external dependencies + try { + val trimmed = json.trim() + if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) { + return + } + + val content = trimmed.substring(1, trimmed.length - 1) + if (content.isBlank()) { + return + } + + // Split by comma, but not within quoted strings + var depth = 0 + var start = 0 + var inString = false + val pairs = mutableListOf() + + for (i in content.indices) { + val char = content[i] + when { + char == '"' && (i == 0 || content[i - 1] != '\\') -> inString = !inString + !inString && char == '{' -> depth++ + !inString && char == '}' -> depth-- + !inString && char == '[' -> depth++ + !inString && char == ']' -> depth-- + !inString && depth == 0 && char == ',' -> { + pairs.add(content.substring(start, i).trim()) + start = i + 1 + } + } + } + pairs.add(content.substring(start).trim()) + + // Parse each key-value pair + for (pair in pairs) { + val colonIndex = pair.indexOf(':') + if (colonIndex > 0) { + val key = pair.substring(0, colonIndex).trim().removeSurrounding("\"") + val value = pair.substring(colonIndex + 1).trim().removeSurrounding("\"") + if (key.isNotEmpty()) { + headers[key] = value + } + } + } + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Failed to parse headers JSON: ${e.message}", + ) + } + } + + /** + * Serialize headers map to JSON string. + */ + private fun serializeHeaders(headers: Map?): String? { + if (headers.isNullOrEmpty()) return null + + return try { + val sb = StringBuilder("{") + var first = true + for ((key, value) in headers) { + if (!first) sb.append(",") + first = false + sb.append("\"") + sb.append(key.replace("\"", "\\\"")) + sb.append("\":\"") + sb.append(value.replace("\"", "\\\"")) + sb.append("\"") + } + sb.append("}") + sb.toString() + } catch (e: Exception) { + null + } + } + + // ======================================================================== + // JNI NATIVE DECLARATIONS + // ======================================================================== + + /** + * Native method to set the HTTP callback with C++ core. + * + * This registers [httpCallback] with the C++ rac_http_set_callback function. + * Reserved for future native callback integration. + * + * C API: rac_http_set_callback(rac_http_callback_t callback) + */ + @Suppress("unused") + @JvmStatic + private external fun nativeSetHttpCallback() + + /** + * Native method to unset the HTTP callback. + * + * Called during shutdown to clean up native resources. + * Reserved for future native callback integration. + * + * C API: rac_http_set_callback(nullptr) + */ + @Suppress("unused") + @JvmStatic + private external fun nativeUnsetHttpCallback() + + /** + * Native method to invoke the C++ completion callback with HTTP response. + * + * @param callbackId The completion callback ID from the original request + * @param statusCode The HTTP status code (-1 if request failed) + * @param responseBody The response body, or null if no body + * @param responseHeaders JSON-encoded response headers, or null if none + * @param errorCode Error code (see [HttpErrorCode]) + * @param errorMessage Error message if the request failed, null otherwise + * + * C API: rac_http_invoke_completion(callback_id, status_code, response_body, response_headers, error_code, error_message) + */ + @JvmStatic + private external fun nativeInvokeCompletionCallback( + callbackId: Long, + statusCode: Int, + responseBody: String?, + responseHeaders: String?, + errorCode: Int, + errorMessage: String?, + ) + + // ======================================================================== + // LIFECYCLE MANAGEMENT + // ======================================================================== + + /** + * Unregister the HTTP callback and clean up resources. + * + * Called during SDK shutdown. + */ + fun unregister() { + synchronized(lock) { + if (!isRegistered) { + return + } + + // TODO: Call native unregistration + // nativeUnsetHttpCallback() + + requestInterceptor = null + requestListener = null + isRegistered = false + } + } + + /** + * Shutdown the HTTP executor. + * + * Called during SDK shutdown to release thread pool resources. + */ + fun shutdown() { + synchronized(lock) { + unregister() + try { + httpExecutor.shutdown() + if (!httpExecutor.awaitTermination(5, TimeUnit.SECONDS)) { + httpExecutor.shutdownNow() + } + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error shutting down HTTP executor: ${e.message}", + ) + httpExecutor.shutdownNow() + } + } + } + + // ======================================================================== + // UTILITY FUNCTIONS + // ======================================================================== + + /** + * Perform an HTTP request synchronously from Kotlin code. + * + * This is a utility method for performing HTTP requests from Kotlin directly, + * not intended for use by C++ callbacks. + * + * @param url The request URL + * @param method The HTTP method (see [HttpMethod] constants) + * @param headers Map of headers to send + * @param body Request body, or null for no body + * @param timeoutMs Request timeout in milliseconds (0 for default) + * @return [HttpResponse] containing status code, body, and headers + */ + fun request( + url: String, + method: Int = HttpMethod.GET, + headers: Map? = null, + body: String? = null, + timeoutMs: Int = 0, + ): HttpResponse { + var connection: HttpURLConnection? = null + + try { + val urlObj = URL(url) + connection = urlObj.openConnection() as HttpURLConnection + connection.requestMethod = HttpMethod.getName(method) + + val connectTimeout = if (timeoutMs > 0) timeoutMs else DEFAULT_CONNECT_TIMEOUT_MS + val readTimeout = if (timeoutMs > 0) timeoutMs else DEFAULT_READ_TIMEOUT_MS + connection.connectTimeout = connectTimeout + connection.readTimeout = readTimeout + connection.doInput = true + + // Set headers + headers?.forEach { (key, value) -> + connection.setRequestProperty(key, value) + } + + // Set default content type if not specified and body is present + if (body != null && headers?.keys?.any { it.equals("Content-Type", ignoreCase = true) } != true) { + connection.setRequestProperty("Content-Type", "application/json") + } + + // Write body if present + if (body != null && method != HttpMethod.GET && method != HttpMethod.HEAD) { + connection.doOutput = true + OutputStreamWriter(connection.outputStream, Charsets.UTF_8).use { writer -> + writer.write(body) + writer.flush() + } + } + + val statusCode = connection.responseCode + + val inputStream = + if (HttpStatus.isSuccess(statusCode)) { + connection.inputStream + } else { + connection.errorStream + } + + val responseBody = + if (inputStream != null) { + BufferedReader(InputStreamReader(inputStream, Charsets.UTF_8)).use { reader -> + reader.readText() + } + } else { + null + } + + val responseHeaders = + connection.headerFields + .filterKeys { it != null } + .mapValues { it.value.firstOrNull() ?: "" } + .filterValues { it.isNotEmpty() } + + return HttpResponse( + statusCode = statusCode, + body = responseBody, + headers = responseHeaders, + success = HttpStatus.isSuccess(statusCode), + errorMessage = null, + ) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "HTTP request failed: ${e.message}", + ) + return HttpResponse( + statusCode = -1, + body = null, + headers = emptyMap(), + success = false, + errorMessage = e.message ?: "Unknown error", + ) + } finally { + connection?.disconnect() + } + } + + /** + * Perform a GET request. + */ + fun get(url: String, headers: Map? = null, timeoutMs: Int = 0): HttpResponse { + return request(url, HttpMethod.GET, headers, null, timeoutMs) + } + + /** + * Perform a POST request with JSON body. + */ + fun post( + url: String, + body: String?, + headers: Map? = null, + timeoutMs: Int = 0, + ): HttpResponse { + return request(url, HttpMethod.POST, headers, body, timeoutMs) + } + + /** + * Perform a PUT request with JSON body. + */ + fun put( + url: String, + body: String?, + headers: Map? = null, + timeoutMs: Int = 0, + ): HttpResponse { + return request(url, HttpMethod.PUT, headers, body, timeoutMs) + } + + /** + * Perform a DELETE request. + */ + fun delete(url: String, headers: Map? = null, timeoutMs: Int = 0): HttpResponse { + return request(url, HttpMethod.DELETE, headers, null, timeoutMs) + } + + /** + * HTTP response data class. + */ + data class HttpResponse( + val statusCode: Int, + val body: String?, + val headers: Map, + val success: Boolean, + val errorMessage: String?, + ) +} diff --git a/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeLLM.kt b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeLLM.kt new file mode 100644 index 000000000..6150a020d --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeLLM.kt @@ -0,0 +1,1297 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * LLM extension for CppBridge. + * Provides LLM component lifecycle management for C++ core. + * + * Follows iOS CppBridge+LLM.swift architecture. + */ + +package com.runanywhere.sdk.foundation.bridge.extensions + +import com.runanywhere.sdk.data.transform.IncompleteBytesToStringBuffer +import com.runanywhere.sdk.foundation.bridge.CppBridge +import com.runanywhere.sdk.foundation.errors.SDKError +import com.runanywhere.sdk.native.bridge.RunAnywhereBridge + +/** + * LLM bridge that provides Large Language Model component lifecycle management for C++ core. + * + * The C++ core needs LLM component management for: + * - Creating and destroying LLM instances + * - Loading and unloading models + * - Text generation (standard and streaming) + * - Canceling ongoing operations + * - Component state tracking + * + * Usage: + * - Called during Phase 2 initialization in [CppBridge.initializeServices] + * - Must be registered after [CppBridgePlatformAdapter] and [CppBridgeModelRegistry] are registered + * + * Thread Safety: + * - This object is thread-safe via synchronized blocks + * - All callbacks are thread-safe + * - Matches iOS Actor-based pattern using Kotlin synchronized + */ +object CppBridgeLLM { + /** + * LLM component state constants matching C++ RAC_LLM_STATE_* values. + */ + object LLMState { + /** Component not created */ + const val NOT_CREATED = 0 + + /** Component created but no model loaded */ + const val CREATED = 1 + + /** Model is loading */ + const val LOADING = 2 + + /** Model loaded and ready for inference */ + const val READY = 3 + + /** Inference in progress */ + const val GENERATING = 4 + + /** Model is unloading */ + const val UNLOADING = 5 + + /** Component in error state */ + const val ERROR = 6 + + /** + * Get a human-readable name for the LLM state. + */ + fun getName(state: Int): String = + when (state) { + NOT_CREATED -> "NOT_CREATED" + CREATED -> "CREATED" + LOADING -> "LOADING" + READY -> "READY" + GENERATING -> "GENERATING" + UNLOADING -> "UNLOADING" + ERROR -> "ERROR" + else -> "UNKNOWN($state)" + } + + /** + * Check if the state indicates the component is usable. + */ + fun isReady(state: Int): Boolean = state == READY + } + + /** + * LLM generation mode constants. + */ + object GenerationMode { + /** Standard completion mode */ + const val COMPLETION = 0 + + /** Chat/instruction mode */ + const val CHAT = 1 + + /** Fill-in-the-middle mode */ + const val INFILL = 2 + } + + /** + * LLM stop reason constants. + */ + object StopReason { + /** Generation still in progress */ + const val NOT_STOPPED = 0 + + /** Reached end of sequence token */ + const val EOS = 1 + + /** Reached maximum token limit */ + const val MAX_TOKENS = 2 + + /** Hit a stop sequence */ + const val STOP_SEQUENCE = 3 + + /** Generation was cancelled */ + const val CANCELLED = 4 + + /** Generation failed */ + const val ERROR = 5 + + /** + * Get a human-readable name for the stop reason. + */ + fun getName(reason: Int): String = + when (reason) { + NOT_STOPPED -> "NOT_STOPPED" + EOS -> "EOS" + MAX_TOKENS -> "MAX_TOKENS" + STOP_SEQUENCE -> "STOP_SEQUENCE" + CANCELLED -> "CANCELLED" + ERROR -> "ERROR" + else -> "UNKNOWN($reason)" + } + } + + @Volatile + private var isRegistered: Boolean = false + + @Volatile + private var state: Int = LLMState.NOT_CREATED + + @Volatile + private var handle: Long = 0 + + @Volatile + private var loadedModelId: String? = null + + @Volatile + private var loadedModelPath: String? = null + + @Volatile + private var isCancelled: Boolean = false + + @Volatile + private var isNativeLibraryLoaded: Boolean = false + + private val lock = Any() + + /** + * Tag for logging. + */ + private const val TAG = "CppBridgeLLM" + + /** + * Check if native LLM library is available. + */ + val isNativeAvailable: Boolean + get() = isNativeLibraryLoaded + + /** + * Initialize native library availability check. + * Should be called during SDK initialization. + */ + fun checkNativeLibrary() { + try { + // Try to call a simple native method to verify library is loaded + // If it throws UnsatisfiedLinkError, the library isn't available + isNativeLibraryLoaded = true + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Native LLM library check passed", + ) + } catch (e: UnsatisfiedLinkError) { + isNativeLibraryLoaded = false + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Native LLM library not available: ${e.message}", + ) + } + } + + /** + * Singleton shared instance for accessing the LLM component. + * Matches iOS CppBridge.LLM.shared pattern. + */ + val shared: CppBridgeLLM = this + + /** + * Optional listener for LLM events. + * Set this before calling [register] to receive events. + */ + @Volatile + var llmListener: LLMListener? = null + + /** + * Optional streaming callback for token-by-token generation. + * This is invoked for each generated token during streaming. + */ + @Volatile + var streamCallback: StreamCallback? = null + + /** + * LLM generation configuration. + * + * @param maxTokens Maximum number of tokens to generate + * @param temperature Sampling temperature (0.0 to 2.0) + * @param topP Top-p (nucleus) sampling parameter + * @param topK Top-k sampling parameter + * @param repeatPenalty Penalty for repeating tokens + * @param stopSequences List of sequences that stop generation + * @param seed Random seed for reproducibility (-1 for random) + */ + data class GenerationConfig( + val maxTokens: Int = 512, + val temperature: Float = 0.7f, + val topP: Float = 0.9f, + val topK: Int = 40, + val repeatPenalty: Float = 1.1f, + val stopSequences: List = emptyList(), + val seed: Long = -1, + ) { + /** + * Convert to JSON string for C++ interop. + */ + fun toJson(): String { + return buildString { + append("{") + append("\"max_tokens\":$maxTokens,") + append("\"temperature\":$temperature,") + append("\"top_p\":$topP,") + append("\"top_k\":$topK,") + append("\"repeat_penalty\":$repeatPenalty,") + append("\"stop_sequences\":[") + stopSequences.forEachIndexed { index, seq -> + if (index > 0) append(",") + append("\"${escapeJson(seq)}\"") + } + append("],") + append("\"seed\":$seed") + append("}") + } + } + + companion object { + /** Default configuration */ + val DEFAULT = GenerationConfig() + } + } + + /** + * LLM model configuration. + * + * @param contextLength Context window size in tokens + * @param gpuLayers Number of layers to offload to GPU (-1 for auto) + * @param threads Number of threads for inference (-1 for auto) + * @param batchSize Batch size for prompt processing + * @param useMemoryMap Whether to use memory-mapped loading + * @param useLocking Whether to use file locking + */ + data class ModelConfig( + val contextLength: Int = 4096, + val gpuLayers: Int = -1, + val threads: Int = -1, + val batchSize: Int = 512, + val useMemoryMap: Boolean = true, + val useLocking: Boolean = false, + ) { + /** + * Convert to JSON string for C++ interop. + */ + fun toJson(): String { + return buildString { + append("{") + append("\"context_length\":$contextLength,") + append("\"gpu_layers\":$gpuLayers,") + append("\"threads\":$threads,") + append("\"batch_size\":$batchSize,") + append("\"use_memory_map\":$useMemoryMap,") + append("\"use_locking\":$useLocking") + append("}") + } + } + + companion object { + /** Default configuration */ + val DEFAULT = ModelConfig() + } + } + + /** + * LLM generation result. + * + * @param text Generated text + * @param tokensGenerated Number of tokens generated + * @param tokensEvaluated Number of tokens evaluated (prompt + generated) + * @param stopReason Reason for stopping generation + * @param generationTimeMs Time spent generating in milliseconds + * @param tokensPerSecond Generation speed + */ + data class GenerationResult( + val text: String, + val tokensGenerated: Int, + val tokensEvaluated: Int, + val stopReason: Int, + val generationTimeMs: Long, + val tokensPerSecond: Float, + ) { + /** + * Get the stop reason name. + */ + fun getStopReasonName(): String = StopReason.getName(stopReason) + + /** + * Check if generation completed normally. + */ + fun isComplete(): Boolean = stopReason == StopReason.EOS || stopReason == StopReason.MAX_TOKENS + + /** + * Check if generation was cancelled. + */ + fun wasCancelled(): Boolean = stopReason == StopReason.CANCELLED + } + + /** + * Listener interface for LLM events. + */ + interface LLMListener { + /** + * Called when the LLM component state changes. + * + * @param previousState The previous state + * @param newState The new state + */ + fun onStateChanged(previousState: Int, newState: Int) + + /** + * Called when a model is loaded. + * + * @param modelId The model ID + * @param modelPath The model path + */ + fun onModelLoaded(modelId: String, modelPath: String) + + /** + * Called when a model is unloaded. + * + * @param modelId The previously loaded model ID + */ + fun onModelUnloaded(modelId: String) + + /** + * Called when generation starts. + * + * @param prompt The input prompt + */ + fun onGenerationStarted(prompt: String) + + /** + * Called when generation completes. + * + * @param result The generation result + */ + fun onGenerationCompleted(result: GenerationResult) + + /** + * Called when an error occurs. + * + * @param errorCode The error code + * @param errorMessage The error message + */ + fun onError(errorCode: Int, errorMessage: String) + } + + /** + * Callback interface for streaming token generation. + */ + fun interface StreamCallback { + /** + * Called for each generated token. + * + * @param token The generated token text + * @return true to continue generation, false to stop + */ + fun onToken(token: String): Boolean + } + + /** + * Register the LLM callbacks with C++ core. + * + * This must be called during SDK initialization, after [CppBridgePlatformAdapter.register]. + * It is safe to call multiple times; subsequent calls are no-ops. + */ + fun register() { + synchronized(lock) { + if (isRegistered) { + return + } + + // TODO: Call native registration + // nativeSetLLMCallbacks() + + isRegistered = true + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "LLM callbacks registered", + ) + } + } + + /** + * Check if the LLM callbacks are registered. + */ + fun isRegistered(): Boolean = isRegistered + + /** + * Get the current component handle. + * + * @return The native handle, or throws if not created + * @throws SDKError if the component is not created + */ + @Throws(SDKError::class) + fun getHandle(): Long { + synchronized(lock) { + if (handle == 0L) { + throw SDKError.notInitialized("LLM component not created") + } + return handle + } + } + + /** + * Check if a model is loaded. + */ + val isLoaded: Boolean + get() = synchronized(lock) { state == LLMState.READY && loadedModelId != null } + + /** + * Check if the component is ready for inference. + */ + val isReady: Boolean + get() = LLMState.isReady(state) + + /** + * Get the currently loaded model ID. + */ + fun getLoadedModelId(): String? = loadedModelId + + /** + * Get the currently loaded model path. + */ + fun getLoadedModelPath(): String? = loadedModelPath + + /** + * Get the current component state. + */ + fun getState(): Int = state + + // ======================================================================== + // LIFECYCLE OPERATIONS + // ======================================================================== + + /** + * Create the LLM component. + * + * @return 0 on success, error code on failure + */ + fun create(): Int { + synchronized(lock) { + if (handle != 0L) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "LLM component already created", + ) + return 0 + } + + // Check if native commons library is loaded + if (!CppBridge.isNativeLibraryLoaded) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "Native library not loaded. LLM inference requires native libraries to be bundled.", + ) + throw SDKError.notInitialized("Native library not available. Please ensure the native libraries are bundled in your APK.") + } + + // Create LLM component via RunAnywhereBridge + val result = + try { + RunAnywhereBridge.racLlmComponentCreate() + } catch (e: UnsatisfiedLinkError) { + isNativeLibraryLoaded = false + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "LLM component creation failed. Native method not available: ${e.message}", + ) + throw SDKError.notInitialized("LLM native library not available. Please ensure the LLM backend is bundled in your APK.") + } + + if (result == 0L) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "Failed to create LLM component", + ) + return -1 + } + + handle = result + isNativeLibraryLoaded = true + setState(LLMState.CREATED) + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "LLM component created", + ) + + return 0 + } + } + + /** + * Load a model. + * + * @param modelPath Path to the model file + * @param modelId Unique identifier for the model (for telemetry) + * @param modelName Human-readable name for the model (for telemetry) + * @param config Model configuration (reserved for future use) + * @return 0 on success, error code on failure + */ + @Suppress("UNUSED_PARAMETER") + fun loadModel(modelPath: String, modelId: String, modelName: String? = null, config: ModelConfig = ModelConfig.DEFAULT): Int { + synchronized(lock) { + if (handle == 0L) { + // Auto-create component if needed + val createResult = create() + if (createResult != 0) { + return createResult + } + } + + if (loadedModelId != null) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Unloading current model before loading new one: $loadedModelId", + ) + unload() + } + + setState(LLMState.LOADING) + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "Loading model: $modelId from $modelPath", + ) + + // Pass modelPath, modelId, and modelName separately to C++ lifecycle + // This ensures correct telemetry - model_id should be the registered ID, not the file path + val result = RunAnywhereBridge.racLlmComponentLoadModel(handle, modelPath, modelId, modelName) + if (result != 0) { + setState(LLMState.ERROR) + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "Failed to load model: $modelId (error: $result)", + ) + + try { + llmListener?.onError(result, "Failed to load model: $modelId") + } catch (e: Exception) { + // Ignore listener errors + } + + return result + } + + loadedModelId = modelId + loadedModelPath = modelPath + setState(LLMState.READY) + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "Model loaded successfully: $modelId", + ) + + // Update model assignment status + CppBridgeModelAssignment.setAssignmentStatusCallback( + CppBridgeModelRegistry.ModelType.LLM, + CppBridgeModelAssignment.AssignmentStatus.READY, + CppBridgeModelAssignment.FailureReason.NONE, + ) + + // Update component state + CppBridgeState.setComponentStateCallback( + CppBridgeState.ComponentType.LLM, + CppBridgeState.ComponentState.READY, + ) + + try { + llmListener?.onModelLoaded(modelId, modelPath) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in LLM listener onModelLoaded: ${e.message}", + ) + } + + return 0 + } + } + + /** + * Generate text from a prompt. + * + * @param prompt The input prompt + * @param config Generation configuration (optional) + * @return The generation result + * @throws SDKError if generation fails + */ + @Throws(SDKError::class) + fun generate(prompt: String, config: GenerationConfig = GenerationConfig.DEFAULT): GenerationResult { + synchronized(lock) { + if (handle == 0L || state != LLMState.READY) { + throw SDKError.llm("LLM component not ready for generation") + } + + isCancelled = false + setState(LLMState.GENERATING) + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Starting generation (prompt length: ${prompt.length})", + ) + + try { + llmListener?.onGenerationStarted(prompt) + } catch (e: Exception) { + // Ignore listener errors + } + + val startTime = System.currentTimeMillis() + + try { + val resultJson = + RunAnywhereBridge.racLlmComponentGenerate(handle, prompt, config.toJson()) + ?: throw SDKError.llm("Generation failed: null result") + + val result = parseGenerationResult(resultJson, System.currentTimeMillis() - startTime) + + setState(LLMState.READY) + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Generation completed: ${result.tokensGenerated} tokens, ${result.tokensPerSecond} tok/s", + ) + + try { + llmListener?.onGenerationCompleted(result) + } catch (e: Exception) { + // Ignore listener errors + } + + return result + } catch (e: Exception) { + setState(LLMState.READY) // Reset to ready, not error + throw if (e is SDKError) e else SDKError.llm("Generation failed: ${e.message}") + } + } + } + + /** + * Generate text with streaming output. + * + * @param prompt The input prompt + * @param config Generation configuration (optional) + * @param callback Callback for each generated token + * @return The final generation result + * @throws SDKError if generation fails + */ + @Throws(SDKError::class) + fun generateStream( + prompt: String, + config: GenerationConfig = GenerationConfig.DEFAULT, + callback: StreamCallback, + ): GenerationResult { + synchronized(lock) { + if (handle == 0L || state != LLMState.READY) { + throw SDKError.llm("LLM component not ready for generation") + } + + isCancelled = false + streamCallback = callback + setState(LLMState.GENERATING) + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Starting streaming generation (prompt length: ${prompt.length})", + ) + + try { + llmListener?.onGenerationStarted(prompt) + } catch (e: Exception) { + // Ignore listener errors + } + + val startTime = System.currentTimeMillis() + + try { + val byteStreamDecoder = IncompleteBytesToStringBuffer() + // Use the new callback-based streaming JNI method + // This calls back to Kotlin for each token in real-time + val jniCallback = + RunAnywhereBridge.TokenCallback { tokenBytes -> + try { + val text = byteStreamDecoder.push(tokenBytes) + // Forward each token to the user's callback + if (text.isNotEmpty()) callback.onToken(text) + true + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in stream callback: ${e.message}", + ) + true // Continue even if callback fails + } + } + + val resultJson = + RunAnywhereBridge.racLlmComponentGenerateStreamWithCallback( + handle, + prompt, + config.toJson(), + jniCallback, + ) ?: throw SDKError.llm("Streaming generation failed: null result") + + try { + // when stream ends: + val tail = byteStreamDecoder.finish() + if (tail.isNotEmpty()) callback.onToken(tail) + } catch (e: Exception) {} + + val result = parseGenerationResult(resultJson, System.currentTimeMillis() - startTime) + + setState(LLMState.READY) + streamCallback = null + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Streaming generation completed: ${result.tokensGenerated} tokens", + ) + + try { + llmListener?.onGenerationCompleted(result) + } catch (e: Exception) { + // Ignore listener errors + } + + return result + } catch (e: Exception) { + setState(LLMState.READY) // Reset to ready, not error + streamCallback = null + throw if (e is SDKError) e else SDKError.llm("Streaming generation failed: ${e.message}") + } + } + } + + /** + * Cancel an ongoing generation. + */ + fun cancel() { + synchronized(lock) { + if (state != LLMState.GENERATING) { + return + } + + isCancelled = true + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Cancelling generation", + ) + + RunAnywhereBridge.racLlmComponentCancel(handle) + } + } + + /** + * Unload the current model. + */ + fun unload() { + synchronized(lock) { + if (loadedModelId == null) { + return + } + + val previousModelId = loadedModelId ?: return + + setState(LLMState.UNLOADING) + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "Unloading model: $previousModelId", + ) + + RunAnywhereBridge.racLlmComponentUnload(handle) + + loadedModelId = null + loadedModelPath = null + setState(LLMState.CREATED) + + // Update model assignment status + CppBridgeModelAssignment.setAssignmentStatusCallback( + CppBridgeModelRegistry.ModelType.LLM, + CppBridgeModelAssignment.AssignmentStatus.NOT_ASSIGNED, + CppBridgeModelAssignment.FailureReason.NONE, + ) + + // Update component state + CppBridgeState.setComponentStateCallback( + CppBridgeState.ComponentType.LLM, + CppBridgeState.ComponentState.CREATED, + ) + + try { + llmListener?.onModelUnloaded(previousModelId) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in LLM listener onModelUnloaded: ${e.message}", + ) + } + } + } + + /** + * Destroy the LLM component and release resources. + */ + fun destroy() { + synchronized(lock) { + if (handle == 0L) { + return + } + + // Unload model first if loaded + if (loadedModelId != null) { + unload() + } + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "Destroying LLM component", + ) + + RunAnywhereBridge.racLlmComponentDestroy(handle) + + handle = 0 + setState(LLMState.NOT_CREATED) + + // Update component state + CppBridgeState.setComponentStateCallback( + CppBridgeState.ComponentType.LLM, + CppBridgeState.ComponentState.NOT_CREATED, + ) + } + } + + // ======================================================================== + // JNI CALLBACKS + // ======================================================================== + + /** + * Streaming token callback. + * + * Called from C++ for each generated token during streaming. + * + * @param token The generated token text + * @return true to continue generation, false to stop + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun streamTokenCallback(token: String): Boolean { + if (isCancelled) { + return false + } + + val callback = streamCallback ?: return true + + return try { + callback.onToken(token) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in stream callback: ${e.message}", + ) + true // Continue on error + } + } + + /** + * Progress callback. + * + * Called from C++ to report model loading progress. + * + * @param progress Loading progress (0.0 to 1.0) + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun progressCallback(progress: Float) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Model loading progress: ${(progress * 100).toInt()}%", + ) + } + + /** + * Get state callback. + * + * @return The current LLM component state + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun getStateCallback(): Int { + return state + } + + /** + * Is loaded callback. + * + * @return true if a model is loaded + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun isLoadedCallback(): Boolean { + return loadedModelId != null && state == LLMState.READY + } + + /** + * Get loaded model ID callback. + * + * @return The loaded model ID, or null + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun getLoadedModelIdCallback(): String? { + return loadedModelId + } + + // ======================================================================== + // JNI NATIVE DECLARATIONS + // ======================================================================== + + /** + * Native method to set the LLM callbacks with C++ core. + * + * Registers [streamTokenCallback], [progressCallback], etc. with C++ core. + * Reserved for future native callback integration. + * + * C API: rac_llm_set_callbacks(...) + */ + @Suppress("unused") + @JvmStatic + private external fun nativeSetLLMCallbacks() + + /** + * Native method to unset the LLM callbacks. + * + * Called during shutdown to clean up native resources. + * Reserved for future native callback integration. + * + * C API: rac_llm_set_callbacks(nullptr) + */ + @Suppress("unused") + @JvmStatic + private external fun nativeUnsetLLMCallbacks() + + /** + * Native method to create the LLM component. + * + * @return Handle to the created component, or 0 on failure + * + * C API: rac_llm_component_create() + */ + @JvmStatic + external fun nativeCreate(): Long + + /** + * Native method to load a model. + * + * @param handle The component handle + * @param modelPath Path to the model file + * @param configJson JSON configuration string + * @return 0 on success, error code on failure + * + * C API: rac_llm_component_load_model(handle, model_path, config) + */ + @JvmStatic + external fun nativeLoadModel(handle: Long, modelPath: String, configJson: String): Int + + /** + * Native method to generate text. + * + * @param handle The component handle + * @param prompt The input prompt + * @param configJson JSON configuration string + * @return JSON-encoded result, or null on failure + * + * C API: rac_llm_component_generate(handle, prompt, config) + */ + @JvmStatic + external fun nativeGenerate(handle: Long, prompt: String, configJson: String): String? + + /** + * Native method to generate text with streaming. + * + * @param handle The component handle + * @param prompt The input prompt + * @param configJson JSON configuration string + * @return JSON-encoded result, or null on failure + * + * C API: rac_llm_component_generate_stream(handle, prompt, config) + */ + @JvmStatic + external fun nativeGenerateStream(handle: Long, prompt: String, configJson: String): String? + + /** + * Native method to cancel generation. + * + * @param handle The component handle + * + * C API: rac_llm_component_cancel(handle) + */ + @JvmStatic + external fun nativeCancel(handle: Long) + + /** + * Native method to unload the model. + * + * @param handle The component handle + * + * C API: rac_llm_component_unload(handle) + */ + @JvmStatic + external fun nativeUnload(handle: Long) + + /** + * Native method to destroy the component. + * + * @param handle The component handle + * + * C API: rac_llm_component_destroy(handle) + */ + @JvmStatic + external fun nativeDestroy(handle: Long) + + /** + * Native method to get context size. + * + * @param handle The component handle + * @return The context size in tokens + * + * C API: rac_llm_component_get_context_size(handle) + */ + @JvmStatic + external fun nativeGetContextSize(handle: Long): Int + + /** + * Native method to tokenize text. + * + * @param handle The component handle + * @param text The text to tokenize + * @return The number of tokens + * + * C API: rac_llm_component_tokenize(handle, text) + */ + @JvmStatic + external fun nativeTokenize(handle: Long, text: String): Int + + // ======================================================================== + // LIFECYCLE MANAGEMENT + // ======================================================================== + + /** + * Unregister the LLM callbacks and clean up resources. + * + * Called during SDK shutdown. + */ + fun unregister() { + synchronized(lock) { + if (!isRegistered) { + return + } + + // Destroy component if created + if (handle != 0L) { + destroy() + } + + // TODO: Call native unregistration + // nativeUnsetLLMCallbacks() + + llmListener = null + streamCallback = null + isRegistered = false + } + } + + // ======================================================================== + // UTILITY FUNCTIONS + // ======================================================================== + + /** + * Set the component state and notify listeners. + */ + private fun setState(newState: Int) { + val previousState = state + if (newState != previousState) { + state = newState + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "State changed: ${LLMState.getName(previousState)} -> ${LLMState.getName(newState)}", + ) + + try { + llmListener?.onStateChanged(previousState, newState) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in LLM listener onStateChanged: ${e.message}", + ) + } + } + } + + /** + * Parse generation result from JSON. + */ + private fun parseGenerationResult(json: String, elapsedMs: Long): GenerationResult { + fun extractString(key: String): String { + val pattern = "\"$key\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"" + val regex = Regex(pattern) + return regex + .find(json) + ?.groupValues + ?.get(1) + ?.let { unescapeJson(it) } ?: "" + } + + fun extractInt(key: String): Int { + val pattern = "\"$key\"\\s*:\\s*(-?\\d+)" + val regex = Regex(pattern) + return regex + .find(json) + ?.groupValues + ?.get(1) + ?.toIntOrNull() ?: 0 + } + + fun extractFloat(key: String): Float { + val pattern = "\"$key\"\\s*:\\s*(-?[\\d.]+)" + val regex = Regex(pattern) + return regex + .find(json) + ?.groupValues + ?.get(1) + ?.toFloatOrNull() ?: 0f + } + + val text = extractString("text") + val tokensGenerated = extractInt("tokens_generated") + val tokensEvaluated = extractInt("tokens_evaluated") + val stopReason = extractInt("stop_reason") + val tokensPerSecond = + if (elapsedMs > 0) { + tokensGenerated * 1000f / elapsedMs + } else { + extractFloat("tokens_per_second") + } + + return GenerationResult( + text = text, + tokensGenerated = tokensGenerated, + tokensEvaluated = tokensEvaluated, + stopReason = stopReason, + generationTimeMs = elapsedMs, + tokensPerSecond = tokensPerSecond, + ) + } + + /** + * Escape special characters for JSON string. + */ + private fun escapeJson(value: String): String { + return value + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + } + + /** + * Unescape JSON string. + */ + private fun unescapeJson(value: String): String { + return value + .replace("\\n", "\n") + .replace("\\r", "\r") + .replace("\\t", "\t") + .replace("\\\"", "\"") + .replace("\\\\", "\\") + } + + /** + * Get the context size of the loaded model. + * + * @return The context size in tokens, or 0 if no model is loaded + */ + fun getContextSize(): Int { + synchronized(lock) { + if (handle == 0L || state != LLMState.READY) { + return 0 + } + return RunAnywhereBridge.racLlmComponentGetContextSize(handle) + } + } + + /** + * Tokenize text and return the token count. + * + * @param text The text to tokenize + * @return The number of tokens, or 0 if no model is loaded + */ + fun tokenize(text: String): Int { + synchronized(lock) { + if (handle == 0L || state != LLMState.READY) { + return 0 + } + return RunAnywhereBridge.racLlmComponentTokenize(handle, text) + } + } + + /** + * Get a state summary for diagnostics. + * + * @return Human-readable state summary + */ + fun getStateSummary(): String { + return buildString { + append("LLM State: ${LLMState.getName(state)}") + if (loadedModelId != null) { + append(", Model: $loadedModelId") + } + if (handle != 0L) { + append(", Handle: $handle") + } + } + } +} diff --git a/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeModelAssignment.kt b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeModelAssignment.kt new file mode 100644 index 000000000..c27edd109 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeModelAssignment.kt @@ -0,0 +1,1238 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * ModelAssignment extension for CppBridge. + * Provides model assignment callbacks for C++ core. + * + * Follows iOS CppBridge+ModelAssignment.swift architecture. + */ + +package com.runanywhere.sdk.foundation.bridge.extensions + +/** + * Model assignment bridge that provides runtime model selection callbacks for C++ core. + * + * The C++ core needs model assignment functionality for: + * - Assigning models to specific component types (LLM, STT, TTS, VAD) + * - Querying which model is currently assigned to a component + * - Tracking assignment status and validity + * - Managing default model assignments per component + * + * Usage: + * - Called during Phase 2 initialization in [CppBridge.initializeServices] + * - Must be registered after [CppBridgeModelRegistry] and [CppBridgeModelPaths] are registered + * + * Thread Safety: + * - Registration is thread-safe via synchronized block + * - All callbacks are thread-safe + */ +object CppBridgeModelAssignment { + /** + * Assignment status constants matching C++ RAC_ASSIGNMENT_STATUS_* values. + */ + object AssignmentStatus { + /** No model assigned */ + const val NOT_ASSIGNED = 0 + + /** Model is assigned but not validated */ + const val PENDING = 1 + + /** Model is assigned and ready for use */ + const val READY = 2 + + /** Model assignment is loading */ + const val LOADING = 3 + + /** Model assignment failed (model not found, invalid, etc.) */ + const val FAILED = 4 + + /** Model assignment is unloading */ + const val UNLOADING = 5 + + /** + * Get a human-readable name for the assignment status. + */ + fun getName(status: Int): String = + when (status) { + NOT_ASSIGNED -> "NOT_ASSIGNED" + PENDING -> "PENDING" + READY -> "READY" + LOADING -> "LOADING" + FAILED -> "FAILED" + UNLOADING -> "UNLOADING" + else -> "UNKNOWN($status)" + } + + /** + * Check if the assignment status indicates the model is usable. + */ + fun isUsable(status: Int): Boolean = status == READY + } + + /** + * Assignment failure reason constants matching C++ RAC_ASSIGNMENT_FAILURE_* values. + */ + object FailureReason { + /** No failure */ + const val NONE = 0 + + /** Model not found in registry */ + const val MODEL_NOT_FOUND = 1 + + /** Model file not found on disk */ + const val FILE_NOT_FOUND = 2 + + /** Model file is corrupted or invalid */ + const val MODEL_CORRUPTED = 3 + + /** Model format not supported for component */ + const val FORMAT_NOT_SUPPORTED = 4 + + /** Model type does not match component type */ + const val TYPE_MISMATCH = 5 + + /** Not enough memory to load model */ + const val INSUFFICIENT_MEMORY = 6 + + /** Model loading failed */ + const val LOAD_FAILED = 7 + + /** Unknown failure */ + const val UNKNOWN = 99 + + /** + * Get a human-readable name for the failure reason. + */ + fun getName(reason: Int): String = + when (reason) { + NONE -> "NONE" + MODEL_NOT_FOUND -> "MODEL_NOT_FOUND" + FILE_NOT_FOUND -> "FILE_NOT_FOUND" + MODEL_CORRUPTED -> "MODEL_CORRUPTED" + FORMAT_NOT_SUPPORTED -> "FORMAT_NOT_SUPPORTED" + TYPE_MISMATCH -> "TYPE_MISMATCH" + INSUFFICIENT_MEMORY -> "INSUFFICIENT_MEMORY" + LOAD_FAILED -> "LOAD_FAILED" + UNKNOWN -> "UNKNOWN" + else -> "UNKNOWN($reason)" + } + } + + @Volatile + private var isRegistered: Boolean = false + + private val lock = Any() + + /** + * Model assignments by component type. + * Key: Component type from [CppBridgeModelRegistry.ModelType] + * Value: [ModelAssignment] data + */ + private val assignments = mutableMapOf() + + /** + * Default model assignments by component type. + * Used when no explicit assignment is set. + */ + private val defaultAssignments = mutableMapOf() + + /** + * Tag for logging. + */ + private const val TAG = "CppBridgeModelAssignment" + + /** + * Optional listener for model assignment events. + * Set this before calling [register] to receive events. + */ + @Volatile + var assignmentListener: ModelAssignmentListener? = null + + /** + * Optional provider for custom assignment validation logic. + * Set this to implement custom model compatibility checks. + */ + @Volatile + var assignmentProvider: ModelAssignmentProvider? = null + + /** + * Model assignment data class. + * + * @param componentType The component type this assignment is for + * @param modelId The assigned model ID + * @param status The current assignment status + * @param failureReason Failure reason if status is FAILED + * @param assignedAt Timestamp when the assignment was made + * @param loadedAt Timestamp when the model was loaded (if status is READY) + */ + data class ModelAssignment( + val componentType: Int, + val modelId: String, + val status: Int, + val failureReason: Int = FailureReason.NONE, + val assignedAt: Long = System.currentTimeMillis(), + val loadedAt: Long = 0, + ) { + /** + * Check if the assignment is ready for use. + */ + fun isReady(): Boolean = AssignmentStatus.isUsable(status) + + /** + * Get the component type name. + */ + fun getComponentTypeName(): String = CppBridgeModelRegistry.ModelType.getName(componentType) + + /** + * Get the status name. + */ + fun getStatusName(): String = AssignmentStatus.getName(status) + + /** + * Get the failure reason name. + */ + fun getFailureReasonName(): String = FailureReason.getName(failureReason) + } + + /** + * Listener interface for model assignment events. + */ + interface ModelAssignmentListener { + /** + * Called when a model is assigned to a component. + * + * @param componentType The component type (see [CppBridgeModelRegistry.ModelType]) + * @param modelId The assigned model ID + */ + fun onModelAssigned(componentType: Int, modelId: String) + + /** + * Called when a model assignment is removed. + * + * @param componentType The component type + * @param previousModelId The previously assigned model ID + */ + fun onModelUnassigned(componentType: Int, previousModelId: String) + + /** + * Called when an assignment status changes. + * + * @param componentType The component type + * @param modelId The model ID + * @param previousStatus The previous status + * @param newStatus The new status + */ + fun onAssignmentStatusChanged( + componentType: Int, + modelId: String, + previousStatus: Int, + newStatus: Int, + ) + + /** + * Called when a model assignment fails. + * + * @param componentType The component type + * @param modelId The model ID + * @param reason The failure reason (see [FailureReason]) + */ + fun onAssignmentFailed(componentType: Int, modelId: String, reason: Int) + + /** + * Called when a model becomes ready for use. + * + * @param componentType The component type + * @param modelId The model ID + */ + fun onModelReady(componentType: Int, modelId: String) + } + + /** + * Provider interface for custom assignment validation logic. + */ + interface ModelAssignmentProvider { + /** + * Validate if a model can be assigned to a component type. + * + * @param modelId The model ID to validate + * @param componentType The component type + * @return true if the model can be assigned, false otherwise + */ + fun validateAssignment(modelId: String, componentType: Int): Boolean + + /** + * Get the best model for a component type. + * + * This is used to auto-select a model when no explicit assignment exists. + * + * @param componentType The component type + * @return The best model ID, or null if no suitable model is found + */ + fun getBestModel(componentType: Int): String? + + /** + * Check model compatibility with component requirements. + * + * @param modelId The model ID + * @param componentType The component type + * @return A [FailureReason] constant, or [FailureReason.NONE] if compatible + */ + fun checkCompatibility(modelId: String, componentType: Int): Int + } + + /** + * Callback object for C++ model assignment API. + * Methods are called from JNI. + */ + private val nativeCallbackHandler = object { + /** + * HTTP GET callback for model assignments. + * @param endpoint API endpoint path (e.g., "/api/v1/model-assignments/for-sdk") + * @param requiresAuth Whether auth header is required + * @return JSON response or "ERROR:message" on failure + */ + @Suppress("unused") // Called from JNI + fun httpGet(endpoint: String, requiresAuth: Boolean): String { + return try { + // Get base URL from telemetry config or use default + val baseUrl = CppBridgeTelemetry.getBaseUrl() + ?: "https://api.runanywhere.ai" + val fullUrl = "$baseUrl$endpoint" + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + ">>> Model assignment HTTP GET to: $fullUrl (requiresAuth: $requiresAuth)", + ) + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + ">>> Base URL: $baseUrl, Endpoint: $endpoint", + ) + + // Build headers - matching Swift SDK's HTTPService.defaultHeaders + val headers = mutableMapOf() + headers["Accept"] = "application/json" + headers["Content-Type"] = "application/json" + headers["X-SDK-Client"] = "RunAnywhereSDK" + headers["X-SDK-Version"] = com.runanywhere.sdk.utils.SDKConstants.SDK_VERSION + headers["X-Platform"] = "android" + + if (requiresAuth) { + // Get access token from auth manager + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "Auth state - isAuthenticated: ${CppBridgeAuth.isAuthenticated}, tokenNeedsRefresh: ${CppBridgeAuth.tokenNeedsRefresh}", + ) + val accessToken = CppBridgeAuth.getValidToken() + if (!accessToken.isNullOrEmpty()) { + headers["Authorization"] = "Bearer $accessToken" + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "Added Authorization header (token length: ${accessToken.length})", + ) + } else { + // Fallback to API key if no OAuth token available + // This mirrors Swift SDK's HTTPService.resolveToken() behavior + val apiKey = CppBridgeTelemetry.getApiKey() + if (!apiKey.isNullOrEmpty()) { + headers["Authorization"] = "Bearer $apiKey" + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "No OAuth token available, falling back to API key authentication (key length: ${apiKey.length})", + ) + } else { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "⚠️ No access token or API key available for authenticated request! Model assignments will likely fail.", + ) + } + } + } + + // Make HTTP request + val response = CppBridgeHTTP.get(fullUrl, headers) + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "<<< Model assignment response: status=${response.statusCode}, success=${response.success}, bodyLen=${response.body?.length ?: 0}", + ) + + // Log full response body for debugging + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "<<< Full response body: ${response.body ?: "null"}", + ) + + if (response.success && response.body != null) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "Model assignments fetched successfully: ${response.body.take(500)}", + ) + response.body + } else { + val errorMsg = response.errorMessage ?: "HTTP ${response.statusCode}" + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "HTTP GET failed: $errorMsg", + ) + "ERROR:$errorMsg" + } + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "HTTP GET exception: ${e.message}", + ) + "ERROR:${e.message}" + } + } + } + + /** + * Register the model assignment callbacks with C++ core. + * + * This must be called during SDK initialization, after [CppBridgeModelRegistry.register]. + * It is safe to call multiple times; subsequent calls are no-ops. + * + * @param autoFetch Whether to auto-fetch models after registration. + * Should be false for development mode, true for staging/production. + * @return true if registration succeeded, false otherwise + */ + fun register(autoFetch: Boolean = false): Boolean { + synchronized(lock) { + if (isRegistered) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Model assignment callbacks already registered, skipping", + ) + return true + } + + // Register the model assignment callbacks with C++ via JNI + // auto_fetch controls whether models are fetched immediately after registration + try { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "Registering model assignment callbacks with C++ (autoFetch: $autoFetch)...", + ) + + val result = com.runanywhere.sdk.native.bridge.RunAnywhereBridge + .racModelAssignmentSetCallbacks(nativeCallbackHandler, autoFetch) + + if (result == 0) { // RAC_SUCCESS + isRegistered = true + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "✅ Model assignment callbacks registered successfully (autoFetch: $autoFetch)", + ) + return true + } else { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "❌ Failed to register model assignment callbacks: error code $result " + + "(RAC_ERROR_INVALID_ARGUMENT=-201, RAC_ERROR_INVALID_STATE=-231)", + ) + return false + } + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "❌ Exception registering model assignment callbacks: ${e.message}", + ) + return false + } + } + } + + /** + * Check if the model assignment callbacks are registered. + */ + fun isRegistered(): Boolean = isRegistered + + // ======================================================================== + // MODEL ASSIGNMENT CALLBACKS + // ======================================================================== + + /** + * Assign model callback. + * + * Assigns a model to a component type. + * + * @param componentType The component type (see [CppBridgeModelRegistry.ModelType]) + * @param modelId The model ID to assign + * @return true if assigned successfully, false otherwise + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun assignModelCallback(componentType: Int, modelId: String): Boolean { + return try { + // Validate assignment if provider is available + val provider = assignmentProvider + if (provider != null) { + val compatibilityReason = provider.checkCompatibility(modelId, componentType) + if (compatibilityReason != FailureReason.NONE) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Model assignment failed compatibility check: ${FailureReason.getName(compatibilityReason)}", + ) + + // Create failed assignment + synchronized(lock) { + assignments[componentType] = + ModelAssignment( + componentType = componentType, + modelId = modelId, + status = AssignmentStatus.FAILED, + failureReason = compatibilityReason, + ) + } + + try { + assignmentListener?.onAssignmentFailed(componentType, modelId, compatibilityReason) + } catch (e: Exception) { + // Ignore listener errors + } + + return false + } + } + + val previousAssignment: ModelAssignment? + + synchronized(lock) { + previousAssignment = assignments[componentType] + assignments[componentType] = + ModelAssignment( + componentType = componentType, + modelId = modelId, + status = AssignmentStatus.PENDING, + ) + } + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Model assigned: ${CppBridgeModelRegistry.ModelType.getName(componentType)} -> $modelId", + ) + + // Notify listener + try { + if (previousAssignment != null && previousAssignment.modelId != modelId) { + assignmentListener?.onModelUnassigned(componentType, previousAssignment.modelId) + } + assignmentListener?.onModelAssigned(componentType, modelId) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in assignment listener: ${e.message}", + ) + } + + true + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "Failed to assign model: ${e.message}", + ) + false + } + } + + /** + * Unassign model callback. + * + * Removes the model assignment for a component type. + * + * @param componentType The component type + * @return true if unassigned, false if no assignment existed + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun unassignModelCallback(componentType: Int): Boolean { + val removed = + synchronized(lock) { + assignments.remove(componentType) + } + + if (removed != null) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Model unassigned: ${CppBridgeModelRegistry.ModelType.getName(componentType)}", + ) + + // Notify listener + try { + assignmentListener?.onModelUnassigned(componentType, removed.modelId) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in assignment listener onModelUnassigned: ${e.message}", + ) + } + + return true + } + + return false + } + + /** + * Get assigned model callback. + * + * Returns the model ID assigned to a component type. + * + * @param componentType The component type + * @return The assigned model ID, or null if none assigned + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun getAssignedModelCallback(componentType: Int): String? { + val assignment = + synchronized(lock) { + assignments[componentType] + } + + if (assignment != null) { + return assignment.modelId + } + + // Check for default assignment + val defaultModelId = + synchronized(lock) { + defaultAssignments[componentType] + } + + if (defaultModelId != null) { + return defaultModelId + } + + // Try to get best model from provider + val provider = assignmentProvider + if (provider != null) { + try { + return provider.getBestModel(componentType) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error getting best model: ${e.message}", + ) + } + } + + return null + } + + /** + * Get assignment status callback. + * + * Returns the assignment status for a component type. + * + * @param componentType The component type + * @return The assignment status (see [AssignmentStatus]) + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun getAssignmentStatusCallback(componentType: Int): Int { + return synchronized(lock) { + assignments[componentType]?.status ?: AssignmentStatus.NOT_ASSIGNED + } + } + + /** + * Set assignment status callback. + * + * Updates the assignment status for a component type. + * + * @param componentType The component type + * @param status The new status (see [AssignmentStatus]) + * @param failureReason Failure reason if status is FAILED + * @return true if updated, false if no assignment exists + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun setAssignmentStatusCallback(componentType: Int, status: Int, failureReason: Int): Boolean { + val previousStatus: Int + val modelId: String + val updated: Boolean + + synchronized(lock) { + val assignment = assignments[componentType] + if (assignment == null) { + return false + } + + previousStatus = assignment.status + modelId = assignment.modelId + + if (previousStatus == status) { + return true // No change needed + } + + val loadedAt = if (status == AssignmentStatus.READY) System.currentTimeMillis() else assignment.loadedAt + + assignments[componentType] = + assignment.copy( + status = status, + failureReason = if (status == AssignmentStatus.FAILED) failureReason else FailureReason.NONE, + loadedAt = loadedAt, + ) + updated = true + } + + if (updated) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Assignment status updated: ${CppBridgeModelRegistry.ModelType.getName(componentType)} " + + "${AssignmentStatus.getName(previousStatus)} -> ${AssignmentStatus.getName(status)}", + ) + + // Notify listener + try { + assignmentListener?.onAssignmentStatusChanged(componentType, modelId, previousStatus, status) + + when (status) { + AssignmentStatus.READY -> { + assignmentListener?.onModelReady(componentType, modelId) + } + AssignmentStatus.FAILED -> { + assignmentListener?.onAssignmentFailed(componentType, modelId, failureReason) + } + } + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in assignment listener: ${e.message}", + ) + } + } + + return true + } + + /** + * Check if assignment is ready callback. + * + * @param componentType The component type + * @return true if a model is assigned and ready + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun isAssignmentReadyCallback(componentType: Int): Boolean { + return synchronized(lock) { + assignments[componentType]?.isReady() ?: false + } + } + + /** + * Get all assignments callback. + * + * Returns all current model assignments as JSON. + * + * @return JSON-encoded array of assignments + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun getAllAssignmentsCallback(): String { + val allAssignments = + synchronized(lock) { + assignments.values.toList() + } + + return buildString { + append("[") + allAssignments.forEachIndexed { index, assignment -> + if (index > 0) append(",") + append(assignmentToJson(assignment)) + } + append("]") + } + } + + /** + * Set default model callback. + * + * Sets the default model for a component type. + * + * @param componentType The component type + * @param modelId The default model ID + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun setDefaultModelCallback(componentType: Int, modelId: String) { + synchronized(lock) { + defaultAssignments[componentType] = modelId + } + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Default model set: ${CppBridgeModelRegistry.ModelType.getName(componentType)} -> $modelId", + ) + } + + /** + * Get default model callback. + * + * Gets the default model for a component type. + * + * @param componentType The component type + * @return The default model ID, or null if not set + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun getDefaultModelCallback(componentType: Int): String? { + return synchronized(lock) { + defaultAssignments[componentType] + } + } + + /** + * Clear all assignments callback. + * + * Removes all model assignments. + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun clearAllAssignmentsCallback() { + val clearedAssignments = + synchronized(lock) { + val all = assignments.toMap() + assignments.clear() + all + } + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "All assignments cleared (${clearedAssignments.size} assignments)", + ) + + // Notify listener for each cleared assignment + try { + clearedAssignments.forEach { (componentType, assignment) -> + assignmentListener?.onModelUnassigned(componentType, assignment.modelId) + } + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in assignment listener during clear: ${e.message}", + ) + } + } + + // ======================================================================== + // JNI NATIVE DECLARATIONS + // ======================================================================== + + /** + * Native method to set the model assignment callbacks with C++ core. + * + * Registers [assignModelCallback], [unassignModelCallback], + * [getAssignedModelCallback], [getAssignmentStatusCallback], etc. with C++ core. + * Reserved for future native callback integration. + * + * C API: rac_model_assignment_set_callbacks(...) + */ + @Suppress("unused") + @JvmStatic + private external fun nativeSetModelAssignmentCallbacks() + + /** + * Native method to unset the model assignment callbacks. + * + * Called during shutdown to clean up native resources. + * Reserved for future native callback integration. + * + * C API: rac_model_assignment_set_callbacks(nullptr) + */ + @Suppress("unused") + @JvmStatic + private external fun nativeUnsetModelAssignmentCallbacks() + + /** + * Native method to assign a model. + * + * @param componentType The component type + * @param modelId The model ID to assign + * @return 0 on success, error code on failure + * + * C API: rac_model_assignment_assign(component_type, model_id) + */ + @JvmStatic + external fun nativeAssign(componentType: Int, modelId: String): Int + + /** + * Native method to unassign a model. + * + * @param componentType The component type + * @return 0 on success, error code on failure + * + * C API: rac_model_assignment_unassign(component_type) + */ + @JvmStatic + external fun nativeUnassign(componentType: Int): Int + + /** + * Native method to get the assigned model. + * + * @param componentType The component type + * @return The assigned model ID, or null + * + * C API: rac_model_assignment_get(component_type) + */ + @JvmStatic + external fun nativeGetAssigned(componentType: Int): String? + + /** + * Native method to load the assigned model. + * + * @param componentType The component type + * @return 0 on success, error code on failure + * + * C API: rac_model_assignment_load(component_type) + */ + @JvmStatic + external fun nativeLoad(componentType: Int): Int + + /** + * Native method to unload the assigned model. + * + * @param componentType The component type + * @return 0 on success, error code on failure + * + * C API: rac_model_assignment_unload(component_type) + */ + @JvmStatic + external fun nativeUnload(componentType: Int): Int + + // ======================================================================== + // LIFECYCLE MANAGEMENT + // ======================================================================== + + /** + * Unregister the model assignment callbacks and clean up resources. + * + * Called during SDK shutdown. + */ + fun unregister() { + synchronized(lock) { + if (!isRegistered) { + return + } + + // Clear callbacks by calling with null + try { + com.runanywhere.sdk.native.bridge.RunAnywhereBridge + .racModelAssignmentSetCallbacks(Unit, false) + } catch (e: Exception) { + // Ignore errors during shutdown + } + + assignmentListener = null + assignmentProvider = null + assignments.clear() + isRegistered = false + } + } + + // ======================================================================== + // PUBLIC FETCH API + // ======================================================================== + + /** + * Fetch model assignments from the backend. + * + * This fetches models assigned to this device based on device type and platform. + * Results are cached and saved to the model registry. + * + * @param forceRefresh If true, bypass cache and fetch fresh data + * @return JSON string of model assignments, or empty array on error + */ + fun fetchModelAssignments(forceRefresh: Boolean = false): String { + // Check if callbacks are registered before attempting fetch + if (!isRegistered) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "❌ Cannot fetch model assignments: callbacks not registered. " + + "Call register() first before fetchModelAssignments().", + ) + return "[]" + } + + return try { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + ">>> Fetching model assignments from backend (forceRefresh: $forceRefresh)...", + ) + + val result = com.runanywhere.sdk.native.bridge.RunAnywhereBridge + .racModelAssignmentFetch(forceRefresh) + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "<<< Fetched model assignments: ${result.take(200)}${if (result.length > 200) "..." else ""}", + ) + result + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "❌ Failed to fetch model assignments: ${e.message}", + ) + "[]" + } + } + + // ======================================================================== + // UTILITY FUNCTIONS + // ======================================================================== + + /** + * Assign a model to a component type. + * + * @param componentType The component type (see [CppBridgeModelRegistry.ModelType]) + * @param modelId The model ID to assign + * @return true if assigned successfully + */ + fun assignModel(componentType: Int, modelId: String): Boolean { + return assignModelCallback(componentType, modelId) + } + + /** + * Unassign the model from a component type. + * + * @param componentType The component type + * @return true if unassigned + */ + fun unassignModel(componentType: Int): Boolean { + return unassignModelCallback(componentType) + } + + /** + * Get the model assigned to a component type. + * + * @param componentType The component type + * @return The assigned model ID, or null + */ + fun getAssignedModel(componentType: Int): String? { + return getAssignedModelCallback(componentType) + } + + /** + * Get the full assignment data for a component type. + * + * @param componentType The component type + * @return The [ModelAssignment] data, or null + */ + fun getAssignment(componentType: Int): ModelAssignment? { + return synchronized(lock) { + assignments[componentType] + } + } + + /** + * Get all current assignments. + * + * @return Map of component type to [ModelAssignment] + */ + fun getAllAssignments(): Map { + return synchronized(lock) { + assignments.toMap() + } + } + + /** + * Check if a component has a ready assignment. + * + * @param componentType The component type + * @return true if the component has a model assigned and ready + */ + fun isReady(componentType: Int): Boolean { + return isAssignmentReadyCallback(componentType) + } + + /** + * Set the default model for a component type. + * + * @param componentType The component type + * @param modelId The default model ID + */ + fun setDefaultModel(componentType: Int, modelId: String) { + setDefaultModelCallback(componentType, modelId) + } + + /** + * Get the default model for a component type. + * + * @param componentType The component type + * @return The default model ID, or null + */ + fun getDefaultModel(componentType: Int): String? { + return getDefaultModelCallback(componentType) + } + + /** + * Clear all default assignments. + */ + fun clearDefaultAssignments() { + synchronized(lock) { + defaultAssignments.clear() + } + } + + /** + * Clear all assignments. + */ + fun clearAllAssignments() { + clearAllAssignmentsCallback() + } + + /** + * Assign model to LLM component. + * + * Convenience method for assigning a model to the LLM component. + * + * @param modelId The model ID + * @return true if assigned successfully + */ + fun assignLLMModel(modelId: String): Boolean { + return assignModel(CppBridgeModelRegistry.ModelType.LLM, modelId) + } + + /** + * Assign model to STT component. + * + * Convenience method for assigning a model to the STT component. + * + * @param modelId The model ID + * @return true if assigned successfully + */ + fun assignSTTModel(modelId: String): Boolean { + return assignModel(CppBridgeModelRegistry.ModelType.STT, modelId) + } + + /** + * Assign model to TTS component. + * + * Convenience method for assigning a model to the TTS component. + * + * @param modelId The model ID + * @return true if assigned successfully + */ + fun assignTTSModel(modelId: String): Boolean { + return assignModel(CppBridgeModelRegistry.ModelType.TTS, modelId) + } + + /** + * Assign model to VAD component. + * + * Convenience method for assigning a model to the VAD component. + * + * @param modelId The model ID + * @return true if assigned successfully + */ + fun assignVADModel(modelId: String): Boolean { + return assignModel(CppBridgeModelRegistry.ModelType.VAD, modelId) + } + + /** + * Get the LLM model assignment. + * + * @return The assigned model ID, or null + */ + fun getLLMModel(): String? { + return getAssignedModel(CppBridgeModelRegistry.ModelType.LLM) + } + + /** + * Get the STT model assignment. + * + * @return The assigned model ID, or null + */ + fun getSTTModel(): String? { + return getAssignedModel(CppBridgeModelRegistry.ModelType.STT) + } + + /** + * Get the TTS model assignment. + * + * @return The assigned model ID, or null + */ + fun getTTSModel(): String? { + return getAssignedModel(CppBridgeModelRegistry.ModelType.TTS) + } + + /** + * Get the VAD model assignment. + * + * @return The assigned model ID, or null + */ + fun getVADModel(): String? { + return getAssignedModel(CppBridgeModelRegistry.ModelType.VAD) + } + + /** + * Convert ModelAssignment to JSON string. + */ + private fun assignmentToJson(assignment: ModelAssignment): String { + return buildString { + append("{") + append("\"component_type\":${assignment.componentType},") + append("\"model_id\":\"${escapeJson(assignment.modelId)}\",") + append("\"status\":${assignment.status},") + append("\"failure_reason\":${assignment.failureReason},") + append("\"assigned_at\":${assignment.assignedAt},") + append("\"loaded_at\":${assignment.loadedAt}") + append("}") + } + } + + /** + * Escape special characters for JSON string. + */ + private fun escapeJson(value: String): String { + return value + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + } +} diff --git a/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeModelPaths.kt b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeModelPaths.kt new file mode 100644 index 000000000..2819f768c --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeModelPaths.kt @@ -0,0 +1,993 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * ModelPaths extension for CppBridge. + * Provides model path utilities for C++ core. + * + * Follows iOS CppBridge+ModelPaths.swift architecture. + */ + +package com.runanywhere.sdk.foundation.bridge.extensions + +import java.io.File + +/** + * Model paths bridge that provides model path utilities for C++ core. + * + * The C++ core needs model path utilities for: + * - Getting and setting the base directory for model storage + * - Getting the models directory path + * - Getting specific model file paths + * - Managing model file locations across platforms + * + * Usage: + * - Called during Phase 2 initialization in [CppBridge.initializeServices] + * - Must be registered after [CppBridgePlatformAdapter] is registered + * + * Thread Safety: + * - Registration is thread-safe via synchronized block + * - All callbacks are thread-safe + */ +object CppBridgeModelPaths { + /** + * Model file extension constants. + */ + object ModelExtension { + /** GGUF model files (LlamaCPP) */ + const val GGUF = ".gguf" + + /** ONNX model files */ + const val ONNX = ".onnx" + + /** TensorFlow Lite model files */ + const val TFLITE = ".tflite" + + /** JSON metadata files */ + const val JSON = ".json" + + /** Binary model files */ + const val BIN = ".bin" + } + + /** + * Model subdirectory names. + */ + object ModelDirectory { + /** LLM models directory */ + const val LLM = "llm" + + /** STT models directory */ + const val STT = "stt" + + /** TTS models directory */ + const val TTS = "tts" + + /** VAD models directory */ + const val VAD = "vad" + + /** Embedding models directory */ + const val EMBEDDING = "embedding" + + /** Downloaded models directory */ + const val DOWNLOADS = "downloads" + + /** Cache directory */ + const val CACHE = "cache" + } + + @Volatile + private var isRegistered: Boolean = false + + @Volatile + private var baseDirectory: String? = null + + private val lock = Any() + + /** + * Tag for logging. + */ + private const val TAG = "CppBridgeModelPaths" + + /** + * Default models directory name. + */ + private const val DEFAULT_MODELS_DIR = "models" + + /** + * Optional listener for path change events. + * Set this before calling [register] to receive events. + */ + @Volatile + var pathListener: ModelPathListener? = null + + /** + * Optional provider for platform-specific paths. + * Set this on Android to provide proper app-specific directories. + * Setting this resets the base directory so it will be re-initialized + * with the new provider on next access. + */ + @Volatile + private var _pathProvider: ModelPathProvider? = null + + var pathProvider: ModelPathProvider? + get() = _pathProvider + set(value) { + synchronized(lock) { + _pathProvider = value + // Reset base directory so it gets re-initialized with the new provider + if (value != null && baseDirectory != null) { + val previousBase = baseDirectory + baseDirectory = null + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Path provider set, resetting base directory (was: $previousBase)", + ) + } + } + } + + /** + * Listener interface for model path change events. + */ + interface ModelPathListener { + /** + * Called when the base directory changes. + * + * @param previousPath The previous base directory + * @param newPath The new base directory + */ + fun onBaseDirectoryChanged(previousPath: String?, newPath: String?) + + /** + * Called when a model directory is created. + * + * @param path The directory path that was created + */ + fun onDirectoryCreated(path: String) + + /** + * Called when a model file is added. + * + * @param modelId The model ID + * @param path The file path + */ + fun onModelFileAdded(modelId: String, path: String) + } + + /** + * Provider interface for platform-specific model paths. + */ + interface ModelPathProvider { + /** + * Get the app's files directory. + * + * On Android, this returns Context.filesDir. + * On JVM, this returns the user's home directory or working directory. + * + * @return The files directory path + */ + fun getFilesDirectory(): String + + /** + * Get the app's cache directory. + * + * @return The cache directory path + */ + fun getCacheDirectory(): String + + /** + * Get the external storage directory (if available). + * + * On Android, this returns external files directory. + * On JVM, this may return null. + * + * @return The external storage path, or null if not available + */ + fun getExternalStorageDirectory(): String? + + /** + * Check if a path is writable. + * + * @param path The path to check + * @return true if the path is writable + */ + fun isPathWritable(path: String): Boolean + } + + /** + * Register the model paths callbacks with C++ core. + * + * This must be called during SDK initialization, after [CppBridgePlatformAdapter.register]. + * It is safe to call multiple times; subsequent calls are no-ops. + */ + fun register() { + synchronized(lock) { + if (isRegistered) { + return + } + + // Initialize base directory if not set + if (baseDirectory == null) { + initializeDefaultBaseDirectory() + } + + // Register the model paths callbacks with C++ via JNI + // TODO: Call native registration + // nativeSetModelPathsCallbacks() + + isRegistered = true + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Model paths callbacks registered. Base dir: $baseDirectory", + ) + } + } + + /** + * Check if the model paths callbacks are registered. + */ + fun isRegistered(): Boolean = isRegistered + + // ======================================================================== + // MODEL PATH CALLBACKS + // ======================================================================== + + /** + * Get the base directory callback. + * + * Returns the base directory for model storage. + * + * @return The base directory path + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun getBaseDirCallback(): String { + return synchronized(lock) { + baseDirectory ?: initializeDefaultBaseDirectory() + } + } + + /** + * Set the base directory callback. + * + * Sets the base directory for model storage. + * + * @param path The base directory path + * @return true if set successfully, false otherwise + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun setBaseDirCallback(path: String): Boolean { + return try { + val file = File(path) + + // Create directory if it doesn't exist + if (!file.exists()) { + if (!file.mkdirs()) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "Failed to create base directory: $path", + ) + return false + } + } + + // Verify it's a directory and writable + if (!file.isDirectory) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "Path is not a directory: $path", + ) + return false + } + + if (!file.canWrite()) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "Directory is not writable: $path", + ) + return false + } + + val previousPath = + synchronized(lock) { + val prev = baseDirectory + baseDirectory = path + prev + } + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Base directory set: $path", + ) + + // Notify listener + try { + pathListener?.onBaseDirectoryChanged(previousPath, path) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in path listener: ${e.message}", + ) + } + + true + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "Failed to set base directory: ${e.message}", + ) + false + } + } + + /** + * Get the models directory callback. + * + * Returns the directory for storing models. + * + * @return The models directory path + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun getModelsDirectoryCallback(): String { + val base = getBaseDirCallback() + return File(base, DEFAULT_MODELS_DIR).absolutePath + } + + /** + * Get a model path callback. + * + * Returns the path for a specific model by ID. + * + * @param modelId The model ID + * @return The model file path + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun getModelPathCallback(modelId: String): String { + val modelsDir = getModelsDirectoryCallback() + return File(modelsDir, modelId).absolutePath + } + + /** + * Get model path by type callback. + * + * Returns the path for a model of a specific type. + * + * @param modelId The model ID + * @param modelType The model type (see [CppBridgeModelRegistry.ModelType]) + * @return The model file path + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun getModelPathByTypeCallback(modelId: String, modelType: Int): String { + val typeDir = getModelTypeDirectory(modelType) + return File(typeDir, modelId).absolutePath + } + + /** + * Get downloads directory callback. + * + * Returns the directory for in-progress downloads. + * + * @return The downloads directory path + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun getDownloadsDirectoryCallback(): String { + val base = getBaseDirCallback() + return File(base, ModelDirectory.DOWNLOADS).absolutePath + } + + /** + * Get cache directory callback. + * + * Returns the directory for cached model data. + * + * @return The cache directory path + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun getCacheDirectoryCallback(): String { + val provider = pathProvider + if (provider != null) { + return provider.getCacheDirectory() + } + + val base = getBaseDirCallback() + return File(base, ModelDirectory.CACHE).absolutePath + } + + /** + * Check if a model exists callback. + * + * @param modelId The model ID + * @return true if the model file exists + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun modelExistsCallback(modelId: String): Boolean { + val modelPath = getModelPathCallback(modelId) + return File(modelPath).exists() + } + + /** + * Get model file size callback. + * + * @param modelId The model ID + * @return The file size in bytes, or -1 if not found + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun getModelFileSizeCallback(modelId: String): Long { + val modelPath = getModelPathCallback(modelId) + val file = File(modelPath) + return if (file.exists()) file.length() else -1L + } + + /** + * Create model directory callback. + * + * Creates the directory for a specific model type. + * + * @param modelType The model type (see [CppBridgeModelRegistry.ModelType]) + * @return true if directory exists or was created, false otherwise + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun createModelDirectoryCallback(modelType: Int): Boolean { + return try { + val dirPath = getModelTypeDirectory(modelType) + val dir = File(dirPath) + + if (dir.exists()) { + true + } else { + val created = dir.mkdirs() + if (created) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Created model directory: $dirPath", + ) + + try { + pathListener?.onDirectoryCreated(dirPath) + } catch (e: Exception) { + // Ignore listener errors + } + } + created + } + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "Failed to create model directory: ${e.message}", + ) + false + } + } + + /** + * Delete model file callback. + * + * @param modelId The model ID + * @return true if deleted or didn't exist, false on error + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun deleteModelFileCallback(modelId: String): Boolean { + return try { + val modelPath = getModelPathCallback(modelId) + val file = File(modelPath) + + if (!file.exists()) { + true + } else { + val deleted = file.delete() + if (deleted) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Deleted model file: $modelPath", + ) + } + deleted + } + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "Failed to delete model file: ${e.message}", + ) + false + } + } + + /** + * Get available storage space callback. + * + * @return Available space in bytes + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun getAvailableStorageCallback(): Long { + return try { + val base = getBaseDirCallback() + File(base).usableSpace + } catch (e: Exception) { + -1L + } + } + + /** + * Get total storage space callback. + * + * @return Total space in bytes + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun getTotalStorageCallback(): Long { + return try { + val base = getBaseDirCallback() + File(base).totalSpace + } catch (e: Exception) { + -1L + } + } + + // ======================================================================== + // JNI NATIVE DECLARATIONS + // ======================================================================== + + /** + * Native method to set the model paths callbacks with C++ core. + * + * Registers [getBaseDirCallback], [setBaseDirCallback], + * [getModelsDirectoryCallback], [getModelPathCallback], etc. with C++ core. + * Reserved for future native callback integration. + * + * C API: rac_model_paths_set_callbacks(...) + */ + @Suppress("unused") + @JvmStatic + private external fun nativeSetModelPathsCallbacks() + + /** + * Native method to unset the model paths callbacks. + * + * Called during shutdown to clean up native resources. + * Reserved for future native callback integration. + * + * C API: rac_model_paths_set_callbacks(nullptr) + */ + @Suppress("unused") + @JvmStatic + private external fun nativeUnsetModelPathsCallbacks() + + /** + * Native method to get the base directory from C++ core. + * + * @return The base directory path from C++ + * + * C API: rac_model_paths_get_base_dir() + */ + @JvmStatic + external fun nativeGetBaseDir(): String? + + /** + * Native method to set the base directory in C++ core. + * + * @param path The base directory path + * @return 0 on success, error code on failure + * + * C API: rac_model_paths_set_base_dir(path) + */ + @JvmStatic + external fun nativeSetBaseDir(path: String): Int + + /** + * Native method to get the models directory from C++ core. + * + * @return The models directory path + * + * C API: rac_model_paths_get_models_directory() + */ + @JvmStatic + external fun nativeGetModelsDirectory(): String? + + /** + * Native method to get a model path from C++ core. + * + * @param modelId The model ID + * @return The model file path + * + * C API: rac_model_paths_get_model_path(model_id) + */ + @JvmStatic + external fun nativeGetModelPath(modelId: String): String? + + /** + * Native method to resolve a model path from C++ core. + * + * Resolves relative paths and validates the model exists. + * + * @param modelId The model ID + * @param modelType The model type + * @return The resolved model path, or null if not found + * + * C API: rac_model_paths_resolve(model_id, type) + */ + @JvmStatic + external fun nativeResolvePath(modelId: String, modelType: Int): String? + + // ======================================================================== + // LIFECYCLE MANAGEMENT + // ======================================================================== + + /** + * Unregister the model paths callbacks and clean up resources. + * + * Called during SDK shutdown. + */ + fun unregister() { + synchronized(lock) { + if (!isRegistered) { + return + } + + // TODO: Call native unregistration + // nativeUnsetModelPathsCallbacks() + + pathListener = null + isRegistered = false + } + } + + // ======================================================================== + // UTILITY FUNCTIONS + // ======================================================================== + + /** + * Set the base directory for model storage. + * + * @param path The base directory path + * @return true if set successfully, false otherwise + */ + fun setBaseDirectory(path: String): Boolean { + return setBaseDirCallback(path) + } + + /** + * Get the base directory for model storage. + * + * @return The base directory path + */ + fun getBaseDirectory(): String { + return getBaseDirCallback() + } + + /** + * Get the models directory. + * + * @return The models directory path + */ + fun getModelsDirectory(): String { + return getModelsDirectoryCallback() + } + + /** + * Get the path for a specific model. + * + * @param modelId The model ID + * @return The model file path + */ + fun getModelPath(modelId: String): String { + return getModelPathCallback(modelId) + } + + /** + * Get the path for a model of a specific type. + * + * @param modelId The model ID + * @param modelType The model type (see [CppBridgeModelRegistry.ModelType]) + * @return The model file path + */ + fun getModelPath(modelId: String, modelType: Int): String { + return getModelPathByTypeCallback(modelId, modelType) + } + + /** + * Get the downloads directory. + * + * @return The downloads directory path + */ + fun getDownloadsDirectory(): String { + return getDownloadsDirectoryCallback() + } + + /** + * Get the cache directory. + * + * @return The cache directory path + */ + fun getCacheDirectory(): String { + return getCacheDirectoryCallback() + } + + /** + * Check if a model file exists. + * + * @param modelId The model ID + * @return true if the model file exists + */ + fun modelExists(modelId: String): Boolean { + return modelExistsCallback(modelId) + } + + /** + * Get the file size of a model. + * + * @param modelId The model ID + * @return The file size in bytes, or -1 if not found + */ + fun getModelFileSize(modelId: String): Long { + return getModelFileSizeCallback(modelId) + } + + /** + * Create the directory for a specific model type. + * + * @param modelType The model type (see [CppBridgeModelRegistry.ModelType]) + * @return true if directory exists or was created + */ + fun createModelDirectory(modelType: Int): Boolean { + return createModelDirectoryCallback(modelType) + } + + /** + * Delete a model file. + * + * @param modelId The model ID + * @return true if deleted or didn't exist + */ + fun deleteModelFile(modelId: String): Boolean { + return deleteModelFileCallback(modelId) + } + + /** + * Get available storage space. + * + * @return Available space in bytes + */ + fun getAvailableStorage(): Long { + return getAvailableStorageCallback() + } + + /** + * Get total storage space. + * + * @return Total space in bytes + */ + fun getTotalStorage(): Long { + return getTotalStorageCallback() + } + + /** + * Check if there is enough storage for a model. + * + * @param requiredBytes The required space in bytes + * @return true if there is enough space + */ + fun hasEnoughStorage(requiredBytes: Long): Boolean { + val available = getAvailableStorage() + return available >= requiredBytes + } + + /** + * Ensure all model directories exist. + * + * Creates the base directory, models directory, and all type-specific directories. + * + * @return true if all directories exist or were created + */ + fun ensureDirectoriesExist(): Boolean { + return try { + // Create base directory + val base = File(getBaseDirCallback()) + if (!base.exists() && !base.mkdirs()) { + return false + } + + // Create models directory + val modelsDir = File(getModelsDirectoryCallback()) + if (!modelsDir.exists() && !modelsDir.mkdirs()) { + return false + } + + // Create downloads directory + val downloadsDir = File(getDownloadsDirectoryCallback()) + if (!downloadsDir.exists() && !downloadsDir.mkdirs()) { + return false + } + + // Create type-specific directories + for (type in listOf( + CppBridgeModelRegistry.ModelType.LLM, + CppBridgeModelRegistry.ModelType.STT, + CppBridgeModelRegistry.ModelType.TTS, + CppBridgeModelRegistry.ModelType.VAD, + CppBridgeModelRegistry.ModelType.EMBEDDING, + )) { + createModelDirectoryCallback(type) + } + + true + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "Failed to ensure directories exist: ${e.message}", + ) + false + } + } + + /** + * Get the temporary file path for a download. + * + * @param modelId The model ID + * @return The temporary file path + */ + fun getTempDownloadPath(modelId: String): String { + val downloadsDir = getDownloadsDirectoryCallback() + return File(downloadsDir, "$modelId.tmp").absolutePath + } + + /** + * Move a downloaded file to its final location. + * + * @param tempPath The temporary file path + * @param modelId The model ID + * @param modelType The model type + * @return true if moved successfully + */ + fun moveDownloadToFinal(tempPath: String, modelId: String, modelType: Int): Boolean { + return try { + val tempFile = File(tempPath) + if (!tempFile.exists()) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "Temp file does not exist: $tempPath", + ) + return false + } + + // Ensure target directory exists + createModelDirectoryCallback(modelType) + + val finalPath = getModelPathByTypeCallback(modelId, modelType) + val finalFile = File(finalPath) + + // Delete existing file if present + if (finalFile.exists()) { + finalFile.delete() + } + + // Move file + val moved = tempFile.renameTo(finalFile) + if (!moved) { + // If rename fails, try copy and delete + tempFile.copyTo(finalFile, overwrite = true) + tempFile.delete() + } + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Moved model to final location: $finalPath", + ) + + try { + pathListener?.onModelFileAdded(modelId, finalPath) + } catch (e: Exception) { + // Ignore listener errors + } + + true + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "Failed to move download to final location: ${e.message}", + ) + false + } + } + + /** + * Get the directory path for a specific model type. + */ + private fun getModelTypeDirectory(modelType: Int): String { + val modelsDir = getModelsDirectoryCallback() + val typeName = + when (modelType) { + CppBridgeModelRegistry.ModelType.LLM -> ModelDirectory.LLM + CppBridgeModelRegistry.ModelType.STT -> ModelDirectory.STT + CppBridgeModelRegistry.ModelType.TTS -> ModelDirectory.TTS + CppBridgeModelRegistry.ModelType.VAD -> ModelDirectory.VAD + CppBridgeModelRegistry.ModelType.EMBEDDING -> ModelDirectory.EMBEDDING + else -> "other" + } + return File(modelsDir, typeName).absolutePath + } + + /** + * Initialize the default base directory. + */ + private fun initializeDefaultBaseDirectory(): String { + val provider = pathProvider + val basePath = + if (provider != null) { + // Use platform-specific directory + val filesDir = provider.getFilesDirectory() + File(filesDir, "runanywhere").absolutePath + } else { + // Use user home directory or temp directory as fallback + val userHome = System.getProperty("user.home") + if (userHome != null) { + File(userHome, ".runanywhere").absolutePath + } else { + File(System.getProperty("java.io.tmpdir", "/tmp"), "runanywhere").absolutePath + } + } + + synchronized(lock) { + if (baseDirectory == null) { + baseDirectory = basePath + + // Create the directory + try { + val dir = File(basePath) + if (!dir.exists()) { + dir.mkdirs() + } + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Failed to create default base directory: ${e.message}", + ) + } + } + } + + return basePath + } +} diff --git a/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeModelRegistry.kt b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeModelRegistry.kt new file mode 100644 index 000000000..bf77629f2 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeModelRegistry.kt @@ -0,0 +1,428 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * ModelRegistry extension for CppBridge. + * Provides direct access to the C++ model registry. + * + * Mirrors iOS CppBridge+ModelRegistry.swift architecture: + * - Uses the global C++ model registry directly via JNI + * - NO Kotlin-side caching - everything is in C++ + * - Service providers in C++ look up models from this registry + */ + +package com.runanywhere.sdk.foundation.bridge.extensions + +import com.runanywhere.sdk.native.bridge.RunAnywhereBridge + +/** + * Model registry bridge that provides direct access to the C++ model registry. + * + * IMPORTANT: This does NOT maintain a Kotlin-side cache. All models are stored + * in the C++ registry (rac_model_registry) so that C++ service providers can + * find models when loading. This mirrors the Swift SDK architecture. + * + * Usage: + * - Register models during SDK initialization via [registerModel] + * - C++ backends will use these models when loading + * - Download status is updated via [updateDownloadStatus] + */ +object CppBridgeModelRegistry { + private const val TAG = "CppBridge/CppBridgeModelRegistry" + + /** + * Model category constants matching C++ RAC_MODEL_CATEGORY_* values. + */ + object ModelCategory { + const val LANGUAGE = 0 // RAC_MODEL_CATEGORY_LANGUAGE + const val SPEECH_RECOGNITION = 1 // RAC_MODEL_CATEGORY_SPEECH_RECOGNITION + const val SPEECH_SYNTHESIS = 2 // RAC_MODEL_CATEGORY_SPEECH_SYNTHESIS + const val AUDIO = 3 // RAC_MODEL_CATEGORY_AUDIO + const val VISION = 4 // RAC_MODEL_CATEGORY_VISION + const val IMAGE_GENERATION = 5 // RAC_MODEL_CATEGORY_IMAGE_GENERATION + const val MULTIMODAL = 6 // RAC_MODEL_CATEGORY_MULTIMODAL + } + + /** + * Model type constants (alias for category for backwards compatibility). + */ + object ModelType { + const val LLM = ModelCategory.LANGUAGE + const val STT = ModelCategory.SPEECH_RECOGNITION + const val TTS = ModelCategory.SPEECH_SYNTHESIS + const val VAD = ModelCategory.AUDIO + const val EMBEDDING = 99 + const val UNKNOWN = 99 + + /** + * Get display name for a model type. + */ + fun getName(type: Int): String = + when (type) { + LLM -> "LLM" + STT -> "STT" + TTS -> "TTS" + VAD -> "VAD" + EMBEDDING -> "EMBEDDING" + else -> "UNKNOWN" + } + } + + /** + * Model format constants matching C++ RAC_MODEL_FORMAT_* values. + */ + object ModelFormat { + const val UNKNOWN = 0 // RAC_MODEL_FORMAT_UNKNOWN + const val GGUF = 1 // RAC_MODEL_FORMAT_GGUF + const val ONNX = 2 // RAC_MODEL_FORMAT_ONNX + const val ORT = 3 // RAC_MODEL_FORMAT_ORT + const val BIN = 4 // RAC_MODEL_FORMAT_BIN + const val COREML = 5 // RAC_MODEL_FORMAT_COREML + const val TFLITE = 6 // RAC_MODEL_FORMAT_TFLITE + } + + /** + * Inference framework constants matching C++ RAC_FRAMEWORK_* values. + * IMPORTANT: Must match rac_model_types.h exactly! + */ + object Framework { + const val ONNX = 0 // RAC_FRAMEWORK_ONNX + const val LLAMACPP = 1 // RAC_FRAMEWORK_LLAMACPP + const val FOUNDATION_MODELS = 2 // RAC_FRAMEWORK_FOUNDATION_MODELS + const val SYSTEM_TTS = 3 // RAC_FRAMEWORK_SYSTEM_TTS + const val FLUID_AUDIO = 4 // RAC_FRAMEWORK_FLUID_AUDIO + const val BUILTIN = 5 // RAC_FRAMEWORK_BUILTIN + const val NONE = 6 // RAC_FRAMEWORK_NONE + const val UNKNOWN = 99 // RAC_FRAMEWORK_UNKNOWN + } + + /** + * Model status constants. + */ + object ModelStatus { + const val NOT_AVAILABLE = 0 + const val AVAILABLE = 1 + const val DOWNLOADING = 2 + const val DOWNLOADED = 3 + const val DOWNLOAD_FAILED = 4 + const val LOADED = 5 + const val CORRUPTED = 6 + + fun isReady(status: Int): Boolean = status == DOWNLOADED || status == LOADED + } + + /** + * Model information data class. + */ + data class ModelInfo( + val modelId: String, + val name: String, + val category: Int, + val format: Int, + val framework: Int, + val downloadUrl: String?, + val localPath: String?, + val downloadSize: Long, + val contextLength: Int, + val supportsThinking: Boolean, + val description: String?, + val status: Int = ModelStatus.AVAILABLE, + ) + + // ======================================================================== + // PUBLIC API - Mirrors Swift CppBridge.ModelRegistry + // ======================================================================== + + /** + * Save model to C++ registry. + * + * This stores the model in the C++ registry so that C++ service providers + * (like LlamaCPP) can find it when loading models. + * + * @param model The model info to save + * @throws RuntimeException if save fails + */ + fun save(model: ModelInfo) { + log(LogLevel.DEBUG, "Saving model to C++ registry: ${model.modelId} (framework=${model.framework})") + + val result = + RunAnywhereBridge.racModelRegistrySave( + modelId = model.modelId, + name = model.name, + category = model.category, + format = model.format, + framework = model.framework, + downloadUrl = model.downloadUrl, + localPath = model.localPath, + downloadSize = model.downloadSize, + contextLength = model.contextLength, + supportsThinking = model.supportsThinking, + description = model.description, + ) + + if (result != RunAnywhereBridge.RAC_SUCCESS) { + log(LogLevel.ERROR, "Failed to save model: ${model.modelId}, error=$result") + throw RuntimeException("Failed to save model to C++ registry: $result") + } + + log(LogLevel.INFO, "Model saved to C++ registry: ${model.modelId}") + } + + /** + * Get model info from C++ registry. + * + * @param modelId The model ID + * @return ModelInfo or null if not found + */ + fun get(modelId: String): ModelInfo? { + val json = RunAnywhereBridge.racModelRegistryGet(modelId) ?: return null + return parseModelInfoJson(json) + } + + /** + * Get all models from C++ registry. + * + * @return List of all models + */ + fun getAll(): List { + val json = RunAnywhereBridge.racModelRegistryGetAll() + return parseModelInfoArrayJson(json) + } + + /** + * Get downloaded models from C++ registry. + * + * @return List of downloaded models + */ + fun getDownloaded(): List { + val json = RunAnywhereBridge.racModelRegistryGetDownloaded() + return parseModelInfoArrayJson(json) + } + + /** + * Remove model from C++ registry. + * + * @param modelId The model ID + * @return true if removed successfully + */ + fun remove(modelId: String): Boolean { + val result = RunAnywhereBridge.racModelRegistryRemove(modelId) + return result == RunAnywhereBridge.RAC_SUCCESS + } + + /** + * Update download status in C++ registry. + * + * @param modelId The model ID + * @param localPath The local path (or null to clear download) + * @return true if updated successfully + */ + fun updateDownloadStatus(modelId: String, localPath: String?): Boolean { + log(LogLevel.DEBUG, "Updating download status: $modelId -> ${localPath ?: "null"}") + val result = RunAnywhereBridge.racModelRegistryUpdateDownloadStatus(modelId, localPath) + return result == RunAnywhereBridge.RAC_SUCCESS + } + + // ======================================================================== + // CONVENIENCE METHODS - For backwards compatibility + // ======================================================================== + + /** + * Register a model (alias for save). + */ + fun registerModel(model: ModelInfo) = save(model) + + /** + * Check if a model exists. + */ + fun hasModel(modelId: String): Boolean = get(modelId) != null + + /** + * Get all registered models. + */ + fun getAllModels(): List = getAll() + + /** + * Get downloaded models. + */ + fun getDownloadedModels(): List = getDownloaded() + + /** + * Get models by type/category. + */ + fun getModelsByType(type: Int): List { + return getAll().filter { it.category == type } + } + + /** + * Scan filesystem and restore downloaded models. + * + * This is called during SDK initialization to detect previously + * downloaded models and update their status in the C++ registry. + */ + fun scanAndRestoreDownloadedModels() { + log(LogLevel.DEBUG, "Scanning for previously downloaded models...") + + val baseDir = CppBridgeModelPaths.getBaseDirectory() + val modelsDir = java.io.File(baseDir, "models") + + if (!modelsDir.exists()) { + log(LogLevel.DEBUG, "Models directory does not exist: ${modelsDir.absolutePath}") + return + } + + val typeDirectories = + mapOf( + "llm" to ModelCategory.LANGUAGE, + "stt" to ModelCategory.SPEECH_RECOGNITION, + "tts" to ModelCategory.SPEECH_SYNTHESIS, + "vad" to ModelCategory.AUDIO, + ) + + var restoredCount = 0 + + for ((dirName, _) in typeDirectories) { + val typeDir = java.io.File(modelsDir, dirName) + if (!typeDir.exists() || !typeDir.isDirectory) continue + + log(LogLevel.DEBUG, "Scanning type directory: ${typeDir.absolutePath}") + + // Scan each model file or folder in this type directory + typeDir.listFiles()?.forEach { modelPath -> + // Model can be stored as: + // 1. A directory containing the model (e.g., models/llm/model-name/) + // 2. A file directly (e.g., models/llm/model-name) + val modelId = modelPath.name + log(LogLevel.DEBUG, "Found: $modelId (isDir=${modelPath.isDirectory}, isFile=${modelPath.isFile})") + + // Check if this model exists in registry + val existingModel = get(modelId) + if (existingModel != null) { + // Update with local path + if (updateDownloadStatus(modelId, modelPath.absolutePath)) { + restoredCount++ + log(LogLevel.DEBUG, "Restored downloaded model: $modelId at ${modelPath.absolutePath}") + } + } else { + log(LogLevel.DEBUG, "Model $modelId not found in registry, skipping") + } + } + } + + log(LogLevel.INFO, "Scan complete: Restored $restoredCount previously downloaded models") + } + + // ======================================================================== + // JSON PARSING - Parse C++ JSON responses + // ======================================================================== + + private fun parseModelInfoJson(json: String): ModelInfo? { + if (json == "null" || json.isBlank()) return null + + return try { + ModelInfo( + modelId = extractString(json, "model_id") ?: return null, + name = extractString(json, "name") ?: "", + category = extractInt(json, "category"), + format = extractInt(json, "format"), + framework = extractInt(json, "framework"), + downloadUrl = extractString(json, "download_url"), + localPath = extractString(json, "local_path"), + downloadSize = extractLong(json, "download_size"), + contextLength = extractInt(json, "context_length"), + supportsThinking = extractBoolean(json, "supports_thinking"), + description = extractString(json, "description"), + status = if (extractString(json, "local_path") != null) ModelStatus.DOWNLOADED else ModelStatus.AVAILABLE, + ) + } catch (e: Exception) { + log(LogLevel.ERROR, "Failed to parse model JSON: ${e.message}") + null + } + } + + private fun parseModelInfoArrayJson(json: String): List { + if (json == "[]" || json.isBlank()) return emptyList() + + val models = mutableListOf() + + // Simple array parsing - find each object + var depth = 0 + var objectStart = -1 + + for (i in json.indices) { + when (json[i]) { + '{' -> { + if (depth == 0) objectStart = i + depth++ + } + '}' -> { + depth-- + if (depth == 0 && objectStart >= 0) { + val objectJson = json.substring(objectStart, i + 1) + parseModelInfoJson(objectJson)?.let { models.add(it) } + objectStart = -1 + } + } + } + } + + return models + } + + private fun extractString(json: String, key: String): String? { + val pattern = """"$key"\s*:\s*"([^"]*)"""" + val regex = Regex(pattern) + return regex + .find(json) + ?.groupValues + ?.get(1) + ?.takeIf { it.isNotEmpty() } + } + + private fun extractInt(json: String, key: String): Int { + val pattern = """"$key"\s*:\s*(-?\d+)""" + val regex = Regex(pattern) + return regex + .find(json) + ?.groupValues + ?.get(1) + ?.toIntOrNull() ?: 0 + } + + private fun extractLong(json: String, key: String): Long { + val pattern = """"$key"\s*:\s*(-?\d+)""" + val regex = Regex(pattern) + return regex + .find(json) + ?.groupValues + ?.get(1) + ?.toLongOrNull() ?: 0L + } + + private fun extractBoolean(json: String, key: String): Boolean { + val pattern = """"$key"\s*:\s*(true|false)""" + val regex = Regex(pattern, RegexOption.IGNORE_CASE) + return regex + .find(json) + ?.groupValues + ?.get(1) + ?.lowercase() == "true" + } + + // ======================================================================== + // LOGGING + // ======================================================================== + + private enum class LogLevel { DEBUG, INFO, WARN, ERROR } + + private fun log(level: LogLevel, message: String) { + val adapterLevel = + when (level) { + LogLevel.DEBUG -> CppBridgePlatformAdapter.LogLevel.DEBUG + LogLevel.INFO -> CppBridgePlatformAdapter.LogLevel.INFO + LogLevel.WARN -> CppBridgePlatformAdapter.LogLevel.WARN + LogLevel.ERROR -> CppBridgePlatformAdapter.LogLevel.ERROR + } + CppBridgePlatformAdapter.logCallback(adapterLevel, TAG, message) + } +} diff --git a/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgePlatform.kt b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgePlatform.kt new file mode 100644 index 000000000..909fa1877 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgePlatform.kt @@ -0,0 +1,1493 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * Platform extension for CppBridge. + * Provides platform services callbacks for C++ core. + * Android equivalent of iOS Foundation Models integration. + * + * Follows iOS CppBridge+Platform.swift architecture. + */ + +package com.runanywhere.sdk.foundation.bridge.extensions + +import com.runanywhere.sdk.foundation.errors.SDKError + +/** + * Platform bridge that provides platform services callbacks for C++ core. + * + * This is the Android equivalent of iOS Foundation Models integration. + * It provides callbacks for: + * - Platform AI model availability checking + * - Platform LLM inference (e.g., Google AI, on-device models) + * - Platform TTS services (system TTS) + * - Platform STT services (system speech recognition) + * - Platform capabilities detection + * + * The C++ core uses these callbacks to: + * - Query available platform AI capabilities + * - Delegate inference to platform services when appropriate + * - Check model availability before fallback to local models + * + * Usage: + * - Called during Phase 2 initialization in [CppBridge.initializeServices] + * - Must be registered after [CppBridgeModelAssignment] is registered + * + * Thread Safety: + * - This object is thread-safe via synchronized blocks + * - All callbacks are thread-safe + */ +object CppBridgePlatform { + /** + * Platform service type constants matching C++ RAC_PLATFORM_SERVICE_* values. + */ + object ServiceType { + /** No platform service */ + const val NONE = 0 + + /** Platform LLM service (e.g., Google AI, Samsung AI) */ + const val LLM = 1 + + /** Platform TTS service (system text-to-speech) */ + const val TTS = 2 + + /** Platform STT service (system speech recognition) */ + const val STT = 3 + + /** Platform embedding service */ + const val EMBEDDING = 4 + + /** Platform image generation service */ + const val IMAGE_GENERATION = 5 + + /** Platform vision/image understanding service */ + const val VISION = 6 + + /** + * Get a human-readable name for the service type. + */ + fun getName(type: Int): String = + when (type) { + NONE -> "NONE" + LLM -> "LLM" + TTS -> "TTS" + STT -> "STT" + EMBEDDING -> "EMBEDDING" + IMAGE_GENERATION -> "IMAGE_GENERATION" + VISION -> "VISION" + else -> "UNKNOWN($type)" + } + } + + /** + * Platform service availability status constants. + */ + object AvailabilityStatus { + /** Service availability unknown */ + const val UNKNOWN = 0 + + /** Service is available and ready */ + const val AVAILABLE = 1 + + /** Service is not available on this device */ + const val NOT_AVAILABLE = 2 + + /** Service requires download/installation */ + const val REQUIRES_DOWNLOAD = 3 + + /** Service is downloading */ + const val DOWNLOADING = 4 + + /** Service is available but requires authentication */ + const val REQUIRES_AUTH = 5 + + /** Service is temporarily unavailable */ + const val TEMPORARILY_UNAVAILABLE = 6 + + /** + * Get a human-readable name for the availability status. + */ + fun getName(status: Int): String = + when (status) { + UNKNOWN -> "UNKNOWN" + AVAILABLE -> "AVAILABLE" + NOT_AVAILABLE -> "NOT_AVAILABLE" + REQUIRES_DOWNLOAD -> "REQUIRES_DOWNLOAD" + DOWNLOADING -> "DOWNLOADING" + REQUIRES_AUTH -> "REQUIRES_AUTH" + TEMPORARILY_UNAVAILABLE -> "TEMPORARILY_UNAVAILABLE" + else -> "UNKNOWN($status)" + } + + /** + * Check if the status indicates the service is usable. + */ + fun isUsable(status: Int): Boolean = status == AVAILABLE + } + + /** + * Platform inference error codes. + */ + object ErrorCode { + /** Success */ + const val SUCCESS = 0 + + /** Service not available */ + const val SERVICE_NOT_AVAILABLE = 1 + + /** Service not initialized */ + const val NOT_INITIALIZED = 2 + + /** Invalid request */ + const val INVALID_REQUEST = 3 + + /** Request timeout */ + const val TIMEOUT = 4 + + /** Request cancelled */ + const val CANCELLED = 5 + + /** Rate limited */ + const val RATE_LIMITED = 6 + + /** Authentication required */ + const val AUTH_REQUIRED = 7 + + /** Model not available */ + const val MODEL_NOT_AVAILABLE = 8 + + /** Content filtered */ + const val CONTENT_FILTERED = 9 + + /** Internal error */ + const val INTERNAL_ERROR = 10 + + /** + * Get a human-readable name for the error code. + */ + fun getName(code: Int): String = + when (code) { + SUCCESS -> "SUCCESS" + SERVICE_NOT_AVAILABLE -> "SERVICE_NOT_AVAILABLE" + NOT_INITIALIZED -> "NOT_INITIALIZED" + INVALID_REQUEST -> "INVALID_REQUEST" + TIMEOUT -> "TIMEOUT" + CANCELLED -> "CANCELLED" + RATE_LIMITED -> "RATE_LIMITED" + AUTH_REQUIRED -> "AUTH_REQUIRED" + MODEL_NOT_AVAILABLE -> "MODEL_NOT_AVAILABLE" + CONTENT_FILTERED -> "CONTENT_FILTERED" + INTERNAL_ERROR -> "INTERNAL_ERROR" + else -> "UNKNOWN($code)" + } + } + + /** + * Platform model type constants. + */ + object ModelType { + /** Default/auto-select model */ + const val DEFAULT = 0 + + /** Small/fast model */ + const val SMALL = 1 + + /** Medium/balanced model */ + const val MEDIUM = 2 + + /** Large/high-quality model */ + const val LARGE = 3 + + /** + * Get a human-readable name for the model type. + */ + fun getName(type: Int): String = + when (type) { + DEFAULT -> "DEFAULT" + SMALL -> "SMALL" + MEDIUM -> "MEDIUM" + LARGE -> "LARGE" + else -> "UNKNOWN($type)" + } + } + + @Volatile + private var isRegistered: Boolean = false + + @Volatile + private var isInitialized: Boolean = false + + private val lock = Any() + + /** + * Cached service availability states. + */ + private val serviceAvailability = mutableMapOf() + + /** + * Tag for logging. + */ + private const val TAG = "CppBridgePlatform" + + /** + * Optional listener for platform service events. + * Set this before calling [register] to receive events. + */ + @Volatile + var platformListener: PlatformListener? = null + + /** + * Optional provider for platform service implementations. + * Set this to provide custom platform service implementations. + */ + @Volatile + var platformProvider: PlatformProvider? = null + + /** + * Platform LLM request configuration. + * + * @param prompt The input prompt + * @param systemPrompt Optional system prompt + * @param maxTokens Maximum tokens to generate + * @param temperature Sampling temperature + * @param modelType Preferred model type + * @param streaming Whether to use streaming output + */ + data class LLMRequest( + val prompt: String, + val systemPrompt: String? = null, + val maxTokens: Int = 512, + val temperature: Float = 0.7f, + val modelType: Int = ModelType.DEFAULT, + val streaming: Boolean = false, + ) { + /** + * Convert to JSON string for C++ interop. + */ + fun toJson(): String { + return buildString { + append("{") + append("\"prompt\":\"${escapeJsonString(prompt)}\",") + systemPrompt?.let { append("\"system_prompt\":\"${escapeJsonString(it)}\",") } + append("\"max_tokens\":$maxTokens,") + append("\"temperature\":$temperature,") + append("\"model_type\":$modelType,") + append("\"streaming\":$streaming") + append("}") + } + } + } + + /** + * Platform LLM response. + * + * @param text Generated text + * @param tokensGenerated Number of tokens generated + * @param finishReason Reason for finishing + * @param modelUsed Model identifier used + * @param latencyMs Latency in milliseconds + */ + data class LLMResponse( + val text: String, + val tokensGenerated: Int, + val finishReason: String, + val modelUsed: String?, + val latencyMs: Long, + ) + + /** + * Platform TTS request configuration. + * + * @param text Text to synthesize + * @param language Language code + * @param voiceId Optional voice identifier + * @param speakingRate Speaking rate multiplier + * @param pitch Voice pitch multiplier + */ + data class TTSRequest( + val text: String, + val language: String = "en-US", + val voiceId: String? = null, + val speakingRate: Float = 1.0f, + val pitch: Float = 1.0f, + ) { + /** + * Convert to JSON string for C++ interop. + */ + fun toJson(): String { + return buildString { + append("{") + append("\"text\":\"${escapeJsonString(text)}\",") + append("\"language\":\"$language\",") + voiceId?.let { append("\"voice_id\":\"${escapeJsonString(it)}\",") } + append("\"speaking_rate\":$speakingRate,") + append("\"pitch\":$pitch") + append("}") + } + } + } + + /** + * Platform TTS response. + * + * @param audioData Synthesized audio bytes + * @param durationMs Audio duration in milliseconds + * @param sampleRate Audio sample rate + * @param format Audio format (e.g., "PCM_16", "MP3") + */ + data class TTSResponse( + val audioData: ByteArray, + val durationMs: Long, + val sampleRate: Int, + val format: String, + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is TTSResponse) return false + + if (!audioData.contentEquals(other.audioData)) return false + if (durationMs != other.durationMs) return false + if (sampleRate != other.sampleRate) return false + if (format != other.format) return false + + return true + } + + override fun hashCode(): Int { + var result = audioData.contentHashCode() + result = 31 * result + durationMs.hashCode() + result = 31 * result + sampleRate + result = 31 * result + format.hashCode() + return result + } + } + + /** + * Platform STT request configuration. + * + * @param audioData Audio data bytes + * @param language Language code + * @param sampleRate Audio sample rate + * @param format Audio format + * @param enablePunctuation Enable automatic punctuation + */ + data class STTRequest( + val audioData: ByteArray, + val language: String = "en-US", + val sampleRate: Int = 16000, + val format: String = "PCM_16", + val enablePunctuation: Boolean = true, + ) { + /** + * Convert to JSON string for C++ interop (excluding audio data). + */ + fun toJson(): String { + return buildString { + append("{") + append("\"language\":\"$language\",") + append("\"sample_rate\":$sampleRate,") + append("\"format\":\"$format\",") + append("\"enable_punctuation\":$enablePunctuation") + append("}") + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is STTRequest) return false + + if (!audioData.contentEquals(other.audioData)) return false + if (language != other.language) return false + if (sampleRate != other.sampleRate) return false + if (format != other.format) return false + if (enablePunctuation != other.enablePunctuation) return false + + return true + } + + override fun hashCode(): Int { + var result = audioData.contentHashCode() + result = 31 * result + language.hashCode() + result = 31 * result + sampleRate + result = 31 * result + format.hashCode() + result = 31 * result + enablePunctuation.hashCode() + return result + } + } + + /** + * Platform STT response. + * + * @param text Transcribed text + * @param confidence Confidence score (0.0 to 1.0) + * @param language Detected language + * @param isFinal Whether this is a final result + */ + data class STTResponse( + val text: String, + val confidence: Float, + val language: String, + val isFinal: Boolean, + ) + + /** + * Platform service capabilities. + * + * @param serviceType The service type + * @param isAvailable Whether the service is available + * @param supportedLanguages List of supported language codes + * @param supportedModels List of supported model identifiers + * @param maxInputLength Maximum input length (characters or tokens) + * @param supportsStreaming Whether streaming is supported + * @param requiresNetwork Whether network is required + */ + data class ServiceCapabilities( + val serviceType: Int, + val isAvailable: Boolean, + val supportedLanguages: List, + val supportedModels: List, + val maxInputLength: Int, + val supportsStreaming: Boolean, + val requiresNetwork: Boolean, + ) + + /** + * Listener interface for platform service events. + */ + interface PlatformListener { + /** + * Called when a service availability changes. + * + * @param serviceType The service type + * @param status The new availability status + */ + fun onServiceAvailabilityChanged(serviceType: Int, status: Int) + + /** + * Called when a platform LLM request completes. + * + * @param response The LLM response + * @param error Error code (0 for success) + */ + fun onLLMComplete(response: LLMResponse?, error: Int) + + /** + * Called when a platform TTS request completes. + * + * @param response The TTS response + * @param error Error code (0 for success) + */ + fun onTTSComplete(response: TTSResponse?, error: Int) + + /** + * Called when a platform STT request completes. + * + * @param response The STT response + * @param error Error code (0 for success) + */ + fun onSTTComplete(response: STTResponse?, error: Int) + + /** + * Called when a streaming token is received. + * + * @param token The token text + * @param isFinal Whether this is the final token + */ + fun onStreamingToken(token: String, isFinal: Boolean) + + /** + * Called when an error occurs. + * + * @param serviceType The service type + * @param errorCode The error code + * @param errorMessage The error message + */ + fun onError(serviceType: Int, errorCode: Int, errorMessage: String) + } + + /** + * Provider interface for platform service implementations. + */ + interface PlatformProvider { + /** + * Check if a service is available. + * + * @param serviceType The service type to check + * @return The availability status + */ + fun checkServiceAvailability(serviceType: Int): Int + + /** + * Get capabilities for a service. + * + * @param serviceType The service type + * @return The service capabilities, or null if not available + */ + fun getServiceCapabilities(serviceType: Int): ServiceCapabilities? + + /** + * Execute a platform LLM request. + * + * @param request The LLM request + * @param callback Callback for streaming tokens (optional) + * @return The LLM response + */ + fun executeLLMRequest(request: LLMRequest, callback: StreamCallback?): LLMResponse? + + /** + * Execute a platform TTS request. + * + * @param request The TTS request + * @return The TTS response + */ + fun executeTTSRequest(request: TTSRequest): TTSResponse? + + /** + * Execute a platform STT request. + * + * @param request The STT request + * @return The STT response + */ + fun executeSTTRequest(request: STTRequest): STTResponse? + + /** + * Cancel an ongoing request. + * + * @param serviceType The service type + */ + fun cancelRequest(serviceType: Int) + } + + /** + * Callback interface for streaming output. + */ + fun interface StreamCallback { + /** + * Called for each token during streaming. + * + * @param token The token text + * @param isFinal Whether this is the final token + * @return true to continue streaming, false to stop + */ + fun onToken(token: String, isFinal: Boolean): Boolean + } + + /** + * Register the platform callbacks with C++ core. + * + * This must be called during SDK initialization, after [CppBridgeModelAssignment.register]. + * It is safe to call multiple times; subsequent calls are no-ops. + */ + fun register() { + synchronized(lock) { + if (isRegistered) { + return + } + + // Initialize service availability cache + initializeServiceAvailability() + + // Register the platform callbacks with C++ via JNI + // TODO: Call native registration + // nativeSetPlatformCallbacks() + + isRegistered = true + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Platform callbacks registered", + ) + } + } + + /** + * Check if the platform callbacks are registered. + */ + fun isRegistered(): Boolean = isRegistered + + /** + * Initialize the platform services. + * + * This should be called after registration to initialize platform service connections. + * + * @return 0 on success, error code on failure + */ + fun initialize(): Int { + synchronized(lock) { + if (!isRegistered) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Cannot initialize: not registered", + ) + return ErrorCode.NOT_INITIALIZED + } + + if (isInitialized) { + return ErrorCode.SUCCESS + } + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "Initializing platform services", + ) + + // Check availability of all services + refreshServiceAvailability() + + isInitialized = true + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "Platform services initialized", + ) + + return ErrorCode.SUCCESS + } + } + + // ======================================================================== + // AVAILABILITY CALLBACKS + // ======================================================================== + + /** + * Check service availability callback. + * + * Called from C++ to check if a platform service is available. + * + * @param serviceType The service type to check + * @return The availability status + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun isServiceAvailableCallback(serviceType: Int): Int { + return synchronized(lock) { + serviceAvailability[serviceType] ?: run { + // Query provider if available + val status = + platformProvider?.checkServiceAvailability(serviceType) + ?: AvailabilityStatus.NOT_AVAILABLE + serviceAvailability[serviceType] = status + status + } + } + } + + /** + * Get service capabilities callback. + * + * Called from C++ to get capabilities for a platform service. + * + * @param serviceType The service type + * @return JSON-encoded capabilities, or null if not available + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun getServiceCapabilitiesCallback(serviceType: Int): String? { + val provider = platformProvider ?: return null + val capabilities = provider.getServiceCapabilities(serviceType) ?: return null + + return buildString { + append("{") + append("\"service_type\":$serviceType,") + append("\"is_available\":${capabilities.isAvailable},") + append("\"supported_languages\":[") + capabilities.supportedLanguages.forEachIndexed { index, lang -> + if (index > 0) append(",") + append("\"$lang\"") + } + append("],") + append("\"supported_models\":[") + capabilities.supportedModels.forEachIndexed { index, model -> + if (index > 0) append(",") + append("\"$model\"") + } + append("],") + append("\"max_input_length\":${capabilities.maxInputLength},") + append("\"supports_streaming\":${capabilities.supportsStreaming},") + append("\"requires_network\":${capabilities.requiresNetwork}") + append("}") + } + } + + /** + * Set service availability callback. + * + * Called from C++ when a service availability changes. + * + * @param serviceType The service type + * @param status The new availability status + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun setServiceAvailabilityCallback(serviceType: Int, status: Int) { + val previousStatus: Int + synchronized(lock) { + previousStatus = serviceAvailability[serviceType] ?: AvailabilityStatus.UNKNOWN + serviceAvailability[serviceType] = status + } + + if (status != previousStatus) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "Service ${ServiceType.getName(serviceType)} availability changed: " + + "${AvailabilityStatus.getName(previousStatus)} -> ${AvailabilityStatus.getName(status)}", + ) + + try { + platformListener?.onServiceAvailabilityChanged(serviceType, status) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in platform listener onServiceAvailabilityChanged: ${e.message}", + ) + } + } + } + + // ======================================================================== + // LLM SERVICE CALLBACKS + // ======================================================================== + + /** + * Platform LLM generate callback. + * + * Called from C++ to generate text using platform LLM service. + * + * @param requestJson JSON-encoded LLM request + * @return JSON-encoded LLM response, or null on failure + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun platformLLMGenerateCallback(requestJson: String): String? { + val provider = platformProvider + if (provider == null) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Platform LLM generate: no provider set", + ) + return null + } + + val request = parseLLMRequest(requestJson) ?: return null + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Platform LLM generate: ${request.prompt.take(50)}...", + ) + + try { + val response = + provider.executeLLMRequest(request, null) + ?: return null + + try { + platformListener?.onLLMComplete(response, ErrorCode.SUCCESS) + } catch (e: Exception) { + // Ignore listener errors + } + + return buildString { + append("{") + append("\"text\":\"${escapeJsonString(response.text)}\",") + append("\"tokens_generated\":${response.tokensGenerated},") + append("\"finish_reason\":\"${response.finishReason}\",") + response.modelUsed?.let { append("\"model_used\":\"$it\",") } + append("\"latency_ms\":${response.latencyMs}") + append("}") + } + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "Platform LLM generate failed: ${e.message}", + ) + + try { + platformListener?.onError(ServiceType.LLM, ErrorCode.INTERNAL_ERROR, e.message ?: "Unknown error") + } catch (e2: Exception) { + // Ignore listener errors + } + + return null + } + } + + /** + * Platform LLM streaming generate callback. + * + * Called from C++ to generate text with streaming using platform LLM service. + * + * @param requestJson JSON-encoded LLM request + * @return JSON-encoded final LLM response, or null on failure + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun platformLLMGenerateStreamCallback(requestJson: String): String? { + val provider = platformProvider + if (provider == null) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Platform LLM stream generate: no provider set", + ) + return null + } + + val request = parseLLMRequest(requestJson) ?: return null + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Platform LLM stream generate: ${request.prompt.take(50)}...", + ) + + try { + val streamCallback = + StreamCallback { token, isFinal -> + try { + platformListener?.onStreamingToken(token, isFinal) + } catch (e: Exception) { + // Ignore listener errors + } + streamingTokenCallback(token, isFinal) + } + + val response = + provider.executeLLMRequest(request.copy(streaming = true), streamCallback) + ?: return null + + try { + platformListener?.onLLMComplete(response, ErrorCode.SUCCESS) + } catch (e: Exception) { + // Ignore listener errors + } + + return buildString { + append("{") + append("\"text\":\"${escapeJsonString(response.text)}\",") + append("\"tokens_generated\":${response.tokensGenerated},") + append("\"finish_reason\":\"${response.finishReason}\",") + response.modelUsed?.let { append("\"model_used\":\"$it\",") } + append("\"latency_ms\":${response.latencyMs}") + append("}") + } + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "Platform LLM stream generate failed: ${e.message}", + ) + + try { + platformListener?.onError(ServiceType.LLM, ErrorCode.INTERNAL_ERROR, e.message ?: "Unknown error") + } catch (e2: Exception) { + // Ignore listener errors + } + + return null + } + } + + // ======================================================================== + // TTS SERVICE CALLBACKS + // ======================================================================== + + /** + * Platform TTS synthesize callback. + * + * Called from C++ to synthesize speech using platform TTS service. + * + * @param requestJson JSON-encoded TTS request + * @return Synthesized audio bytes, or null on failure + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun platformTTSSynthesizeCallback(requestJson: String): ByteArray? { + val provider = platformProvider + if (provider == null) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Platform TTS synthesize: no provider set", + ) + return null + } + + val request = parseTTSRequest(requestJson) ?: return null + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Platform TTS synthesize: ${request.text.take(50)}...", + ) + + try { + val response = + provider.executeTTSRequest(request) + ?: return null + + try { + platformListener?.onTTSComplete(response, ErrorCode.SUCCESS) + } catch (e: Exception) { + // Ignore listener errors + } + + return response.audioData + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "Platform TTS synthesize failed: ${e.message}", + ) + + try { + platformListener?.onError(ServiceType.TTS, ErrorCode.INTERNAL_ERROR, e.message ?: "Unknown error") + } catch (e2: Exception) { + // Ignore listener errors + } + + return null + } + } + + // ======================================================================== + // STT SERVICE CALLBACKS + // ======================================================================== + + /** + * Platform STT transcribe callback. + * + * Called from C++ to transcribe audio using platform STT service. + * + * @param audioData Audio data bytes + * @param configJson JSON-encoded STT configuration + * @return JSON-encoded STT response, or null on failure + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun platformSTTTranscribeCallback(audioData: ByteArray, configJson: String): String? { + val provider = platformProvider + if (provider == null) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Platform STT transcribe: no provider set", + ) + return null + } + + val request = parseSTTRequest(audioData, configJson) ?: return null + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Platform STT transcribe: ${audioData.size} bytes", + ) + + try { + val response = + provider.executeSTTRequest(request) + ?: return null + + try { + platformListener?.onSTTComplete(response, ErrorCode.SUCCESS) + } catch (e: Exception) { + // Ignore listener errors + } + + return buildString { + append("{") + append("\"text\":\"${escapeJsonString(response.text)}\",") + append("\"confidence\":${response.confidence},") + append("\"language\":\"${response.language}\",") + append("\"is_final\":${response.isFinal}") + append("}") + } + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "Platform STT transcribe failed: ${e.message}", + ) + + try { + platformListener?.onError(ServiceType.STT, ErrorCode.INTERNAL_ERROR, e.message ?: "Unknown error") + } catch (e2: Exception) { + // Ignore listener errors + } + + return null + } + } + + // ======================================================================== + // CANCEL CALLBACK + // ======================================================================== + + /** + * Cancel platform request callback. + * + * Called from C++ to cancel an ongoing platform request. + * + * @param serviceType The service type to cancel + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun cancelPlatformRequestCallback(serviceType: Int) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Cancelling platform request: ${ServiceType.getName(serviceType)}", + ) + + try { + platformProvider?.cancelRequest(serviceType) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error cancelling platform request: ${e.message}", + ) + } + } + + // ======================================================================== + // STREAMING CALLBACK + // ======================================================================== + + /** + * Streaming token callback. + * + * Called to send a streaming token back to C++. + * + * @param token The token text + * @param isFinal Whether this is the final token + * @return true to continue streaming, false to stop + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun streamingTokenCallback(token: String, isFinal: Boolean): Boolean { + return try { + nativeOnStreamingToken(token, isFinal) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in streaming token callback: ${e.message}", + ) + true // Continue on error + } + } + + // ======================================================================== + // JNI NATIVE DECLARATIONS + // ======================================================================== + + /** + * Native method to set the platform callbacks with C++ core. + * Reserved for future native callback integration. + * + * C API: rac_platform_set_callbacks(...) + */ + @Suppress("unused") + @JvmStatic + private external fun nativeSetPlatformCallbacks() + + /** + * Native method to unset the platform callbacks. + * Reserved for future native callback integration. + * + * C API: rac_platform_set_callbacks(nullptr) + */ + @Suppress("unused") + @JvmStatic + private external fun nativeUnsetPlatformCallbacks() + + /** + * Native method to send a streaming token to C++. + * + * @param token The token text + * @param isFinal Whether this is the final token + * @return true to continue streaming, false to stop + * + * C API: rac_platform_on_streaming_token(token, is_final) + */ + @JvmStatic + external fun nativeOnStreamingToken(token: String, isFinal: Boolean): Boolean + + /** + * Native method to check if platform LLM is available. + * + * @return The availability status + * + * C API: rac_platform_is_llm_available() + */ + @JvmStatic + external fun nativeIsLLMAvailable(): Int + + /** + * Native method to check if platform TTS is available. + * + * @return The availability status + * + * C API: rac_platform_is_tts_available() + */ + @JvmStatic + external fun nativeIsTTSAvailable(): Int + + /** + * Native method to check if platform STT is available. + * + * @return The availability status + * + * C API: rac_platform_is_stt_available() + */ + @JvmStatic + external fun nativeIsSTTAvailable(): Int + + // ======================================================================== + // LIFECYCLE MANAGEMENT + // ======================================================================== + + /** + * Unregister the platform callbacks and clean up resources. + * + * Called during SDK shutdown. + */ + fun unregister() { + synchronized(lock) { + if (!isRegistered) { + return + } + + // TODO: Call native unregistration + // nativeUnsetPlatformCallbacks() + + platformListener = null + platformProvider = null + serviceAvailability.clear() + isInitialized = false + isRegistered = false + } + } + + // ======================================================================== + // PUBLIC UTILITY METHODS + // ======================================================================== + + /** + * Check if a platform service is available. + * + * @param serviceType The service type to check + * @return true if available, false otherwise + */ + fun isServiceAvailable(serviceType: Int): Boolean { + return AvailabilityStatus.isUsable(isServiceAvailableCallback(serviceType)) + } + + /** + * Get the availability status for a service. + * + * @param serviceType The service type + * @return The availability status + */ + fun getServiceAvailability(serviceType: Int): Int { + return synchronized(lock) { + serviceAvailability[serviceType] ?: AvailabilityStatus.UNKNOWN + } + } + + /** + * Refresh service availability for all services. + */ + fun refreshServiceAvailability() { + val provider = platformProvider ?: return + + for (serviceType in listOf(ServiceType.LLM, ServiceType.TTS, ServiceType.STT, ServiceType.EMBEDDING)) { + val status = provider.checkServiceAvailability(serviceType) + setServiceAvailabilityCallback(serviceType, status) + } + } + + /** + * Get capabilities for a service. + * + * @param serviceType The service type + * @return The service capabilities, or null if not available + */ + fun getCapabilities(serviceType: Int): ServiceCapabilities? { + return platformProvider?.getServiceCapabilities(serviceType) + } + + /** + * Execute a platform LLM request. + * + * @param request The LLM request + * @param callback Optional streaming callback + * @return The LLM response + * @throws SDKError if the request fails + */ + @Throws(SDKError::class) + fun executeLLM(request: LLMRequest, callback: StreamCallback? = null): LLMResponse { + val provider = + platformProvider + ?: throw SDKError.platform("Platform provider not set") + + if (!isServiceAvailable(ServiceType.LLM)) { + throw SDKError.platform("Platform LLM service not available") + } + + return provider.executeLLMRequest(request, callback) + ?: throw SDKError.platform("Platform LLM request failed") + } + + /** + * Execute a platform TTS request. + * + * @param request The TTS request + * @return The TTS response + * @throws SDKError if the request fails + */ + @Throws(SDKError::class) + fun executeTTS(request: TTSRequest): TTSResponse { + val provider = + platformProvider + ?: throw SDKError.platform("Platform provider not set") + + if (!isServiceAvailable(ServiceType.TTS)) { + throw SDKError.platform("Platform TTS service not available") + } + + return provider.executeTTSRequest(request) + ?: throw SDKError.platform("Platform TTS request failed") + } + + /** + * Execute a platform STT request. + * + * @param request The STT request + * @return The STT response + * @throws SDKError if the request fails + */ + @Throws(SDKError::class) + fun executeSTT(request: STTRequest): STTResponse { + val provider = + platformProvider + ?: throw SDKError.platform("Platform provider not set") + + if (!isServiceAvailable(ServiceType.STT)) { + throw SDKError.platform("Platform STT service not available") + } + + return provider.executeSTTRequest(request) + ?: throw SDKError.platform("Platform STT request failed") + } + + /** + * Get a state summary for diagnostics. + * + * @return Human-readable state summary + */ + fun getStateSummary(): String { + return buildString { + append("Platform Services: registered=$isRegistered, initialized=$isInitialized") + append(", LLM=${AvailabilityStatus.getName(getServiceAvailability(ServiceType.LLM))}") + append(", TTS=${AvailabilityStatus.getName(getServiceAvailability(ServiceType.TTS))}") + append(", STT=${AvailabilityStatus.getName(getServiceAvailability(ServiceType.STT))}") + } + } + + // ======================================================================== + // PRIVATE UTILITY FUNCTIONS + // ======================================================================== + + /** + * Initialize service availability cache. + */ + private fun initializeServiceAvailability() { + serviceAvailability[ServiceType.LLM] = AvailabilityStatus.UNKNOWN + serviceAvailability[ServiceType.TTS] = AvailabilityStatus.UNKNOWN + serviceAvailability[ServiceType.STT] = AvailabilityStatus.UNKNOWN + serviceAvailability[ServiceType.EMBEDDING] = AvailabilityStatus.UNKNOWN + serviceAvailability[ServiceType.IMAGE_GENERATION] = AvailabilityStatus.UNKNOWN + serviceAvailability[ServiceType.VISION] = AvailabilityStatus.UNKNOWN + } + + /** + * Escape a string for JSON encoding. + */ + private fun escapeJsonString(str: String): String { + return str + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + } + + /** + * Parse LLM request from JSON. + */ + private fun parseLLMRequest(json: String): LLMRequest? { + return try { + fun extractString(key: String): String? { + val pattern = "\"$key\"\\s*:\\s*\"([^\"]*)\"" + val regex = Regex(pattern) + return regex.find(json)?.groupValues?.get(1) + } + + fun extractInt(key: String): Int? { + val pattern = "\"$key\"\\s*:\\s*(-?\\d+)" + val regex = Regex(pattern) + return regex + .find(json) + ?.groupValues + ?.get(1) + ?.toIntOrNull() + } + + fun extractFloat(key: String): Float? { + val pattern = "\"$key\"\\s*:\\s*(-?[\\d.]+)" + val regex = Regex(pattern) + return regex + .find(json) + ?.groupValues + ?.get(1) + ?.toFloatOrNull() + } + + fun extractBoolean(key: String): Boolean? { + val pattern = "\"$key\"\\s*:\\s*(true|false)" + val regex = Regex(pattern) + return regex + .find(json) + ?.groupValues + ?.get(1) + ?.toBooleanStrictOrNull() + } + + val prompt = extractString("prompt") ?: return null + + LLMRequest( + prompt = prompt, + systemPrompt = extractString("system_prompt"), + maxTokens = extractInt("max_tokens") ?: 512, + temperature = extractFloat("temperature") ?: 0.7f, + modelType = extractInt("model_type") ?: ModelType.DEFAULT, + streaming = extractBoolean("streaming") ?: false, + ) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Failed to parse LLM request: ${e.message}", + ) + null + } + } + + /** + * Parse TTS request from JSON. + */ + private fun parseTTSRequest(json: String): TTSRequest? { + return try { + fun extractString(key: String): String? { + val pattern = "\"$key\"\\s*:\\s*\"([^\"]*)\"" + val regex = Regex(pattern) + return regex.find(json)?.groupValues?.get(1) + } + + fun extractFloat(key: String): Float? { + val pattern = "\"$key\"\\s*:\\s*(-?[\\d.]+)" + val regex = Regex(pattern) + return regex + .find(json) + ?.groupValues + ?.get(1) + ?.toFloatOrNull() + } + + val text = extractString("text") ?: return null + + TTSRequest( + text = text, + language = extractString("language") ?: "en-US", + voiceId = extractString("voice_id"), + speakingRate = extractFloat("speaking_rate") ?: 1.0f, + pitch = extractFloat("pitch") ?: 1.0f, + ) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Failed to parse TTS request: ${e.message}", + ) + null + } + } + + /** + * Parse STT request from audio data and config JSON. + */ + private fun parseSTTRequest(audioData: ByteArray, json: String): STTRequest? { + return try { + fun extractString(key: String): String? { + val pattern = "\"$key\"\\s*:\\s*\"([^\"]*)\"" + val regex = Regex(pattern) + return regex.find(json)?.groupValues?.get(1) + } + + fun extractInt(key: String): Int? { + val pattern = "\"$key\"\\s*:\\s*(-?\\d+)" + val regex = Regex(pattern) + return regex + .find(json) + ?.groupValues + ?.get(1) + ?.toIntOrNull() + } + + fun extractBoolean(key: String): Boolean? { + val pattern = "\"$key\"\\s*:\\s*(true|false)" + val regex = Regex(pattern) + return regex + .find(json) + ?.groupValues + ?.get(1) + ?.toBooleanStrictOrNull() + } + + STTRequest( + audioData = audioData, + language = extractString("language") ?: "en-US", + sampleRate = extractInt("sample_rate") ?: 16000, + format = extractString("format") ?: "PCM_16", + enablePunctuation = extractBoolean("enable_punctuation") ?: true, + ) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Failed to parse STT request: ${e.message}", + ) + null + } + } +} diff --git a/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgePlatformAdapter.kt b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgePlatformAdapter.kt new file mode 100644 index 000000000..1e96f1f7c --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgePlatformAdapter.kt @@ -0,0 +1,468 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * Platform adapter extension for CppBridge. + * Provides JNI callbacks for platform-specific operations required by C++ core. + * + * Follows iOS CppBridge+PlatformAdapter.swift architecture. + */ + +package com.runanywhere.sdk.foundation.bridge.extensions + +import com.runanywhere.sdk.foundation.SDKLogger +import java.io.File +import java.util.concurrent.ConcurrentHashMap + +/** + * Platform adapter that provides JNI callbacks for C++ core operations. + * + * CRITICAL: This MUST be registered FIRST before any C++ calls. + * On Android, call [setAndroidContext] before secure storage operations for persistence. + * + * Provides callbacks for: + * - Logging: Route C++ logs to Kotlin logging system + * - File Operations: fileExists, fileRead, fileWrite, fileDelete + * - Secure Storage: secureGet, secureSet, secureDelete (encrypted key-value store) + * - Clock: nowMs (current timestamp in milliseconds) + */ +object CppBridgePlatformAdapter { + /** + * Log level constants matching C++ RAC_LOG_LEVEL_* values. + */ + object LogLevel { + const val TRACE = 0 + const val DEBUG = 1 + const val INFO = 2 + const val WARN = 3 + const val ERROR = 4 + const val FATAL = 5 + } + + @Volatile + private var isRegistered: Boolean = false + + private val lock = Any() + + /** + * Platform-specific storage delegate for Android persistent storage. + * Set via [setAndroidContext] on Android platform. + */ + @Volatile + private var platformStorage: PlatformSecureStorage? = null + + /** + * In-memory fallback storage for JVM environments or when platform storage is not available. + */ + private val inMemoryStorage = ConcurrentHashMap() + + /** + * Tag for logging. + */ + private const val TAG = "CppBridge" + + /** + * Interface for platform-specific secure storage. + * Implemented differently on Android vs JVM. + */ + interface PlatformSecureStorage { + fun get(key: String): ByteArray? + fun set(key: String, value: ByteArray): Boolean + fun delete(key: String): Boolean + fun clear() + } + + /** + * Set the platform-specific storage implementation. + * On Android, this should be called with an AndroidSecureStorage instance. + * + * @param storage The platform storage implementation + */ + fun setPlatformStorage(storage: PlatformSecureStorage) { + synchronized(lock) { + platformStorage = storage + logCallback(LogLevel.DEBUG, TAG, "Platform storage initialized for persistent storage") + } + } + + /** + * Register the platform adapter with C++ core. + * + * This MUST be called before any other C++ operations. + * It is safe to call multiple times; subsequent calls are no-ops. + */ + fun register() { + synchronized(lock) { + if (isRegistered) { + return + } + + // Register all callbacks with C++ via JNI + // The actual JNI registration happens in native code using RegisterNatives() + // or via the native library initialization + + // TODO: Call native registration + // nativeRegisterPlatformAdapter( + // logCallback = ::logCallback, + // fileExistsCallback = ::fileExistsCallback, + // fileReadCallback = ::fileReadCallback, + // fileWriteCallback = ::fileWriteCallback, + // fileDeleteCallback = ::fileDeleteCallback, + // secureGetCallback = ::secureGetCallback, + // secureSetCallback = ::secureSetCallback, + // secureDeleteCallback = ::secureDeleteCallback, + // nowMsCallback = ::nowMsCallback + // ) + + isRegistered = true + } + } + + /** + * Check if the platform adapter is registered. + */ + fun isRegistered(): Boolean = isRegistered + + // ======================================================================== + // LOGGING CALLBACKS + // ======================================================================== + + /** + * Log callback for C++ core. + * + * Routes C++ log messages to Kotlin logging system. + * Parses structured metadata from C++ log messages. + * + * Format: "Message text | key1=value1, key2=value2" + * + * @param level The log level (see [LogLevel] constants) + * @param tag The log tag/category + * @param message The log message (may contain metadata) + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun logCallback(level: Int, tag: String, message: String) { + // Parse structured metadata from C++ log messages + val (cleanMessage, metadata) = parseLogMetadata(message) + val category = if (tag.isNotEmpty()) tag else "RAC" + + // Create logger with proper category for destination routing + val logger = SDKLogger(category) + + when (level) { + LogLevel.TRACE -> logger.trace("[Native] $cleanMessage", metadata) + LogLevel.DEBUG -> logger.debug("[Native] $cleanMessage", metadata) + LogLevel.INFO -> logger.info("[Native] $cleanMessage", metadata) + LogLevel.WARN -> logger.warning("[Native] $cleanMessage", metadata) + LogLevel.ERROR -> logger.error("[Native] $cleanMessage", metadata) + LogLevel.FATAL -> logger.fault("[Native] $cleanMessage", metadata) + else -> logger.debug("[Native] $cleanMessage", metadata) + } + } + + /** + * Parse structured metadata from C++ log messages. + * + * Format: "Message text | key1=value1, key2=value2" + * + * Matches iOS SDK's parseLogMetadata function in CppBridge+PlatformAdapter.swift + * + * @param message The raw log message from C++ + * @return Pair of (clean message, metadata map) + */ + private fun parseLogMetadata(message: String): Pair?> { + val parts = message.split(" | ", limit = 2) + if (parts.size < 2) { + return Pair(message, null) + } + + val cleanMessage = parts[0] + val metadataString = parts[1] + + val metadata = mutableMapOf() + val pairs = + metadataString + .split(Regex("[,\\s]+")) + .filter { it.isNotEmpty() && it.contains("=") } + + for (pair in pairs) { + val keyValue = pair.split("=", limit = 2) + if (keyValue.size != 2) continue + + val key = keyValue[0].trim() + val value = keyValue[1].trim() + + // Map known C++ keys to SDK metadata keys (matching iOS behavior) + when (key) { + "file" -> metadata["source_file"] = value + "func" -> metadata["source_function"] = value + "error_code" -> metadata["error_code"] = value.toIntOrNull() ?: value + "error" -> metadata["error_message"] = value + "model" -> metadata["model_id"] = value + "framework" -> metadata["framework"] = value + else -> metadata[key] = value + } + } + + return Pair(cleanMessage, metadata.ifEmpty { null }) + } + + // ======================================================================== + // FILE OPERATION CALLBACKS + // ======================================================================== + + /** + * Check if a file exists at the given path. + * + * @param path The file path to check + * @return true if the file exists, false otherwise + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun fileExistsCallback(path: String): Boolean { + return try { + File(path).exists() + } catch (e: Exception) { + logCallback(LogLevel.ERROR, "FileOps", "fileExists failed for '$path': ${e.message}") + false + } + } + + /** + * Read file contents as bytes. + * + * @param path The file path to read + * @return The file contents as ByteArray, or null if read fails + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun fileReadCallback(path: String): ByteArray? { + return try { + val file = File(path) + if (!file.exists()) { + logCallback(LogLevel.WARN, "FileOps", "fileRead: file not found '$path'") + return null + } + file.readBytes() + } catch (e: Exception) { + logCallback(LogLevel.ERROR, "FileOps", "fileRead failed for '$path': ${e.message}") + null + } + } + + /** + * Write bytes to a file. + * + * @param path The file path to write to + * @param data The data to write + * @return true if write succeeded, false otherwise + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun fileWriteCallback(path: String, data: ByteArray): Boolean { + return try { + val file = File(path) + // Create parent directories if they don't exist + file.parentFile?.mkdirs() + file.writeBytes(data) + true + } catch (e: Exception) { + logCallback(LogLevel.ERROR, "FileOps", "fileWrite failed for '$path': ${e.message}") + false + } + } + + /** + * Delete a file at the given path. + * + * @param path The file path to delete + * @return true if delete succeeded or file didn't exist, false otherwise + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun fileDeleteCallback(path: String): Boolean { + return try { + val file = File(path) + if (!file.exists()) { + return true // File doesn't exist, consider it deleted + } + file.delete() + } catch (e: Exception) { + logCallback(LogLevel.ERROR, "FileOps", "fileDelete failed for '$path': ${e.message}") + false + } + } + + // ======================================================================== + // SECURE STORAGE CALLBACKS + // ======================================================================== + + /** + * Get a value from secure storage. + * + * On Android with platform storage set: Uses persistent storage (SharedPreferences) + * On JVM or without platform storage: Uses in-memory storage (non-persistent) + * + * @param key The key to retrieve + * @return The stored value as ByteArray, or null if not found + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun secureGetCallback(key: String): ByteArray? { + return try { + // Try platform storage first (persistent storage) + val storage = platformStorage + if (storage != null) { + val result = storage.get(key) + if (result != null) { + return result + } + } + // Fall back to in-memory storage + inMemoryStorage[key] + } catch (e: Exception) { + logCallback(LogLevel.ERROR, "SecureStorage", "secureGet failed for key '$key': ${e.message}") + null + } + } + + /** + * Store a value in secure storage. + * + * On Android with platform storage set: Uses persistent storage (SharedPreferences) + * On JVM or without platform storage: Uses in-memory storage (non-persistent) + * + * @param key The key to store under + * @param value The value to store + * @return true if storage succeeded, false otherwise + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun secureSetCallback(key: String, value: ByteArray): Boolean { + return try { + // Try platform storage first (persistent storage) + val storage = platformStorage + if (storage != null) { + if (storage.set(key, value)) { + logCallback(LogLevel.DEBUG, "SecureStorage", "Persisted key '$key' to platform storage") + return true + } + } + // Fall back to in-memory storage + inMemoryStorage[key] = value.copyOf() + logCallback(LogLevel.WARN, "SecureStorage", "Using in-memory storage for key '$key' (platform storage not set)") + true + } catch (e: Exception) { + logCallback(LogLevel.ERROR, "SecureStorage", "secureSet failed for key '$key': ${e.message}") + false + } + } + + /** + * Delete a value from secure storage. + * + * @param key The key to delete + * @return true if delete succeeded or key didn't exist, false otherwise + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun secureDeleteCallback(key: String): Boolean { + return try { + // Remove from platform storage if available + platformStorage?.delete(key) + // Also remove from in-memory + inMemoryStorage.remove(key) + true + } catch (e: Exception) { + logCallback(LogLevel.ERROR, "SecureStorage", "secureDelete failed for key '$key': ${e.message}") + false + } + } + + // ======================================================================== + // CLOCK CALLBACKS + // ======================================================================== + + /** + * Get the current time in milliseconds since Unix epoch. + * + * @return Current timestamp in milliseconds + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun nowMsCallback(): Long { + return System.currentTimeMillis() + } + + // ======================================================================== + // JNI NATIVE DECLARATIONS + // ======================================================================== + + /** + * Native method to register the platform adapter with C++ core. + * + * This is called during [register] to pass callback references to native code. + * Reserved for future native callback integration. + */ + @Suppress("unused") + @JvmStatic + private external fun nativeRegisterPlatformAdapter() + + /** + * Native method to unregister the platform adapter. + * + * Called during shutdown to clean up native resources. + * Reserved for future native callback integration. + */ + @Suppress("unused") + @JvmStatic + private external fun nativeUnregisterPlatformAdapter() + + // ======================================================================== + // LIFECYCLE MANAGEMENT + // ======================================================================== + + /** + * Unregister the platform adapter and clean up resources. + * + * Called during SDK shutdown. + * Note: Does NOT clear persistent storage (device ID should survive SDK restarts) + */ + fun unregister() { + synchronized(lock) { + if (!isRegistered) { + return + } + + // TODO: Call native unregistration + // nativeUnregisterPlatformAdapter() + + // Only clear in-memory storage, preserve persistent storage + inMemoryStorage.clear() + isRegistered = false + } + } + + /** + * Clear all secure storage entries (both persistent and in-memory). + * + * WARNING: This clears the device ID! Device will be re-registered on next app start. + * Useful for testing or when user requests data deletion. + */ + fun clearSecureStorage() { + // Clear platform storage + platformStorage?.clear() + // Clear in-memory storage + inMemoryStorage.clear() + logCallback(LogLevel.INFO, "SecureStorage", "All secure storage cleared") + } +} diff --git a/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeSTT.kt b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeSTT.kt new file mode 100644 index 000000000..e50ce3862 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeSTT.kt @@ -0,0 +1,1381 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * STT extension for CppBridge. + * Provides Speech-to-Text component lifecycle management for C++ core. + * + * Follows iOS CppBridge+STT.swift architecture. + */ + +package com.runanywhere.sdk.foundation.bridge.extensions + +import com.runanywhere.sdk.foundation.bridge.CppBridge +import com.runanywhere.sdk.foundation.errors.SDKError +import com.runanywhere.sdk.native.bridge.RunAnywhereBridge + +/** + * STT bridge that provides Speech-to-Text component lifecycle management for C++ core. + * + * The C++ core needs STT component management for: + * - Creating and destroying STT instances + * - Loading and unloading models + * - Audio transcription (standard and streaming) + * - Canceling ongoing operations + * - Component state tracking + * + * Usage: + * - Called during Phase 2 initialization in [CppBridge.initializeServices] + * - Must be registered after [CppBridgePlatformAdapter] and [CppBridgeModelRegistry] are registered + * + * Thread Safety: + * - This object is thread-safe via synchronized blocks + * - All callbacks are thread-safe + * - Matches iOS Actor-based pattern using Kotlin synchronized + */ +object CppBridgeSTT { + /** + * STT component state constants matching C++ RAC_STT_STATE_* values. + */ + object STTState { + /** Component not created */ + const val NOT_CREATED = 0 + + /** Component created but no model loaded */ + const val CREATED = 1 + + /** Model is loading */ + const val LOADING = 2 + + /** Model loaded and ready for transcription */ + const val READY = 3 + + /** Transcription in progress */ + const val TRANSCRIBING = 4 + + /** Model is unloading */ + const val UNLOADING = 5 + + /** Component in error state */ + const val ERROR = 6 + + /** + * Get a human-readable name for the STT state. + */ + fun getName(state: Int): String = + when (state) { + NOT_CREATED -> "NOT_CREATED" + CREATED -> "CREATED" + LOADING -> "LOADING" + READY -> "READY" + TRANSCRIBING -> "TRANSCRIBING" + UNLOADING -> "UNLOADING" + ERROR -> "ERROR" + else -> "UNKNOWN($state)" + } + + /** + * Check if the state indicates the component is usable. + */ + fun isReady(state: Int): Boolean = state == READY + } + + /** + * Audio format constants for STT input. + */ + object AudioFormat { + /** 16-bit PCM audio */ + const val PCM_16 = 0 + + /** 32-bit float audio */ + const val PCM_FLOAT = 1 + + /** WAV file format */ + const val WAV = 2 + + /** MP3 file format */ + const val MP3 = 3 + + /** FLAC file format */ + const val FLAC = 4 + + /** Opus/OGG format */ + const val OPUS = 5 + } + + /** + * Language code constants. + */ + object Language { + const val AUTO = "auto" + const val ENGLISH = "en" + const val SPANISH = "es" + const val FRENCH = "fr" + const val GERMAN = "de" + const val ITALIAN = "it" + const val PORTUGUESE = "pt" + const val JAPANESE = "ja" + const val CHINESE = "zh" + const val KOREAN = "ko" + const val RUSSIAN = "ru" + const val ARABIC = "ar" + const val HINDI = "hi" + } + + /** + * Transcription completion reason constants. + */ + object CompletionReason { + /** Transcription still in progress */ + const val NOT_COMPLETED = 0 + + /** End of audio reached */ + const val END_OF_AUDIO = 1 + + /** Silence detected */ + const val SILENCE_DETECTED = 2 + + /** Transcription was cancelled */ + const val CANCELLED = 3 + + /** Maximum duration reached */ + const val MAX_DURATION = 4 + + /** Transcription failed */ + const val ERROR = 5 + + /** + * Get a human-readable name for the completion reason. + */ + fun getName(reason: Int): String = + when (reason) { + NOT_COMPLETED -> "NOT_COMPLETED" + END_OF_AUDIO -> "END_OF_AUDIO" + SILENCE_DETECTED -> "SILENCE_DETECTED" + CANCELLED -> "CANCELLED" + MAX_DURATION -> "MAX_DURATION" + ERROR -> "ERROR" + else -> "UNKNOWN($reason)" + } + } + + @Volatile + private var isRegistered: Boolean = false + + @Volatile + private var state: Int = STTState.NOT_CREATED + + @Volatile + private var handle: Long = 0 + + @Volatile + private var loadedModelId: String? = null + + @Volatile + private var loadedModelPath: String? = null + + @Volatile + private var isCancelled: Boolean = false + + private val lock = Any() + + /** + * Tag for logging. + */ + private const val TAG = "CppBridgeSTT" + + /** + * Singleton shared instance for accessing the STT component. + * Matches iOS CppBridge.STT.shared pattern. + */ + val shared: CppBridgeSTT = this + + /** + * Optional listener for STT events. + * Set this before calling [register] to receive events. + */ + @Volatile + var sttListener: STTListener? = null + + /** + * Optional streaming callback for partial transcription results. + * This is invoked for each partial transcription during streaming. + */ + @Volatile + var streamCallback: StreamCallback? = null + + /** + * STT transcription configuration. + * + * @param language Language code (e.g., "en", "es", "auto") + * @param sampleRate Audio sample rate in Hz (default: 16000) + * @param channels Number of audio channels (default: 1 = mono) + * @param audioFormat Audio format type + * @param enableTimestamps Whether to include word timestamps + * @param enablePunctuation Whether to add punctuation + * @param maxDurationMs Maximum transcription duration in milliseconds (0 = unlimited) + * @param vadEnabled Whether to use voice activity detection + * @param vadSilenceMs Milliseconds of silence to detect end of speech + */ + data class TranscriptionConfig( + val language: String = Language.AUTO, + val sampleRate: Int = 16000, + val channels: Int = 1, + val audioFormat: Int = AudioFormat.PCM_16, + val enableTimestamps: Boolean = false, + val enablePunctuation: Boolean = true, + val maxDurationMs: Long = 0, + val vadEnabled: Boolean = true, + val vadSilenceMs: Int = 1000, + ) { + /** + * Convert to JSON string for C++ interop. + */ + fun toJson(): String { + return buildString { + append("{") + append("\"language\":\"${escapeJson(language)}\",") + append("\"sample_rate\":$sampleRate,") + append("\"channels\":$channels,") + append("\"audio_format\":$audioFormat,") + append("\"enable_timestamps\":$enableTimestamps,") + append("\"enable_punctuation\":$enablePunctuation,") + append("\"max_duration_ms\":$maxDurationMs,") + append("\"vad_enabled\":$vadEnabled,") + append("\"vad_silence_ms\":$vadSilenceMs") + append("}") + } + } + + companion object { + /** Default configuration */ + val DEFAULT = TranscriptionConfig() + } + } + + /** + * STT model configuration. + * + * @param threads Number of threads for inference (-1 for auto) + * @param gpuEnabled Whether to use GPU acceleration + * @param beamSize Beam search size (larger = more accurate but slower) + * @param useFlashAttention Whether to use flash attention optimization + */ + data class ModelConfig( + val threads: Int = -1, + val gpuEnabled: Boolean = false, + val beamSize: Int = 5, + val useFlashAttention: Boolean = true, + ) { + /** + * Convert to JSON string for C++ interop. + */ + fun toJson(): String { + return buildString { + append("{") + append("\"threads\":$threads,") + append("\"gpu_enabled\":$gpuEnabled,") + append("\"beam_size\":$beamSize,") + append("\"use_flash_attention\":$useFlashAttention") + append("}") + } + } + + companion object { + /** Default configuration */ + val DEFAULT = ModelConfig() + } + } + + /** + * Word-level timestamp information. + * + * @param word The transcribed word + * @param startMs Start time in milliseconds + * @param endMs End time in milliseconds + * @param confidence Confidence score (0.0 to 1.0) + */ + data class WordTimestamp( + val word: String, + val startMs: Long, + val endMs: Long, + val confidence: Float, + ) + + /** + * STT transcription result. + * + * @param text Transcribed text + * @param language Detected or specified language + * @param durationMs Audio duration in milliseconds + * @param completionReason Reason for transcription completion + * @param confidence Overall confidence score (0.0 to 1.0) + * @param processingTimeMs Time spent processing in milliseconds + * @param wordTimestamps Word-level timestamps (if enabled) + */ + data class TranscriptionResult( + val text: String, + val language: String, + val durationMs: Long, + val completionReason: Int, + val confidence: Float, + val processingTimeMs: Long, + val wordTimestamps: List = emptyList(), + ) { + /** + * Get the completion reason name. + */ + fun getCompletionReasonName(): String = CompletionReason.getName(completionReason) + + /** + * Check if transcription completed successfully. + */ + fun isComplete(): Boolean = + completionReason == CompletionReason.END_OF_AUDIO || + completionReason == CompletionReason.SILENCE_DETECTED + + /** + * Check if transcription was cancelled. + */ + fun wasCancelled(): Boolean = completionReason == CompletionReason.CANCELLED + } + + /** + * Partial transcription result for streaming. + * + * @param text Current partial transcription text + * @param isFinal Whether this is a finalized segment + * @param confidence Confidence score for this segment + */ + data class PartialResult( + val text: String, + val isFinal: Boolean, + val confidence: Float, + ) + + /** + * Listener interface for STT events. + */ + interface STTListener { + /** + * Called when the STT component state changes. + * + * @param previousState The previous state + * @param newState The new state + */ + fun onStateChanged(previousState: Int, newState: Int) + + /** + * Called when a model is loaded. + * + * @param modelId The model ID + * @param modelPath The model path + */ + fun onModelLoaded(modelId: String, modelPath: String) + + /** + * Called when a model is unloaded. + * + * @param modelId The previously loaded model ID + */ + fun onModelUnloaded(modelId: String) + + /** + * Called when transcription starts. + */ + fun onTranscriptionStarted() + + /** + * Called when transcription completes. + * + * @param result The transcription result + */ + fun onTranscriptionCompleted(result: TranscriptionResult) + + /** + * Called when partial transcription is available during streaming. + * + * @param partial The partial result + */ + fun onPartialResult(partial: PartialResult) + + /** + * Called when an error occurs. + * + * @param errorCode The error code + * @param errorMessage The error message + */ + fun onError(errorCode: Int, errorMessage: String) + } + + /** + * Callback interface for streaming transcription. + */ + fun interface StreamCallback { + /** + * Called for each partial transcription result. + * + * @param text The partial transcription text + * @param isFinal Whether this is a finalized segment + * @return true to continue transcription, false to stop + */ + fun onPartialResult(text: String, isFinal: Boolean): Boolean + } + + /** + * Register the STT callbacks with C++ core. + * + * This must be called during SDK initialization, after [CppBridgePlatformAdapter.register]. + * It is safe to call multiple times; subsequent calls are no-ops. + */ + fun register() { + synchronized(lock) { + if (isRegistered) { + return + } + + // TODO: Call native registration + // nativeSetSTTCallbacks() + + isRegistered = true + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "STT callbacks registered", + ) + } + } + + /** + * Check if the STT callbacks are registered. + */ + fun isRegistered(): Boolean = isRegistered + + /** + * Get the current component handle. + * + * @return The native handle, or throws if not created + * @throws SDKError if the component is not created + */ + @Throws(SDKError::class) + fun getHandle(): Long { + synchronized(lock) { + if (handle == 0L) { + throw SDKError.notInitialized("STT component not created") + } + return handle + } + } + + /** + * Check if a model is loaded. + */ + val isLoaded: Boolean + get() = synchronized(lock) { state == STTState.READY && loadedModelId != null } + + /** + * Check if the component is ready for transcription. + */ + val isReady: Boolean + get() = STTState.isReady(state) + + /** + * Get the currently loaded model ID. + */ + fun getLoadedModelId(): String? = loadedModelId + + /** + * Get the currently loaded model path. + */ + fun getLoadedModelPath(): String? = loadedModelPath + + /** + * Get the current component state. + */ + fun getState(): Int = state + + // ======================================================================== + // LIFECYCLE OPERATIONS + // ======================================================================== + + /** + * Create the STT component. + * + * @return 0 on success, error code on failure + */ + fun create(): Int { + synchronized(lock) { + if (handle != 0L) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "STT component already created", + ) + return 0 + } + + // Check if native commons library is loaded + if (!CppBridge.isNativeLibraryLoaded) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "Native library not loaded. STT inference requires native libraries to be bundled.", + ) + throw SDKError.notInitialized("Native library not available. Please ensure the native libraries are bundled in your APK.") + } + + // Create STT component via RunAnywhereBridge + val result = + try { + RunAnywhereBridge.racSttComponentCreate() + } catch (e: UnsatisfiedLinkError) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "STT component creation failed. Native method not available: ${e.message}", + ) + throw SDKError.notInitialized("STT native library not available. Please ensure the STT backend is bundled in your APK.") + } + + if (result == 0L) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "Failed to create STT component", + ) + return -1 + } + + handle = result + setState(STTState.CREATED) + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "STT component created", + ) + + return 0 + } + } + + /** + * Load a model. + * + * @param modelPath Path to the model file + * @param modelId Unique identifier for the model (for telemetry) + * @param modelName Human-readable name for the model (for telemetry) + * @param config Model configuration (reserved for future use) + * @return 0 on success, error code on failure + */ + @Suppress("UNUSED_PARAMETER") + fun loadModel(modelPath: String, modelId: String, modelName: String? = null, config: ModelConfig = ModelConfig.DEFAULT): Int { + synchronized(lock) { + if (handle == 0L) { + // Auto-create component if needed + val createResult = create() + if (createResult != 0) { + return createResult + } + } + + if (loadedModelId != null) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Unloading current model before loading new one: $loadedModelId", + ) + unload() + } + + setState(STTState.LOADING) + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "Loading model: $modelId from $modelPath", + ) + + val result = RunAnywhereBridge.racSttComponentLoadModel(handle, modelPath, modelId, modelName) + if (result != 0) { + setState(STTState.ERROR) + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "Failed to load model: $modelId (error: $result)", + ) + + try { + sttListener?.onError(result, "Failed to load model: $modelId") + } catch (e: Exception) { + // Ignore listener errors + } + + return result + } + + loadedModelId = modelId + loadedModelPath = modelPath + setState(STTState.READY) + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "Model loaded successfully: $modelId", + ) + + // Update model assignment status + CppBridgeModelAssignment.setAssignmentStatusCallback( + CppBridgeModelRegistry.ModelType.STT, + CppBridgeModelAssignment.AssignmentStatus.READY, + CppBridgeModelAssignment.FailureReason.NONE, + ) + + // Update component state + CppBridgeState.setComponentStateCallback( + CppBridgeState.ComponentType.STT, + CppBridgeState.ComponentState.READY, + ) + + try { + sttListener?.onModelLoaded(modelId, modelPath) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in STT listener onModelLoaded: ${e.message}", + ) + } + + return 0 + } + } + + /** + * Transcribe audio data. + * + * @param audioData Raw audio data bytes + * @param config Transcription configuration (optional) + * @return The transcription result + * @throws SDKError if transcription fails + */ + @Throws(SDKError::class) + fun transcribe(audioData: ByteArray, config: TranscriptionConfig = TranscriptionConfig.DEFAULT): TranscriptionResult { + synchronized(lock) { + if (handle == 0L || state != STTState.READY) { + throw SDKError.stt("STT component not ready for transcription") + } + + isCancelled = false + setState(STTState.TRANSCRIBING) + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Starting transcription (audio size: ${audioData.size} bytes)", + ) + + try { + sttListener?.onTranscriptionStarted() + } catch (e: Exception) { + // Ignore listener errors + } + + val startTime = System.currentTimeMillis() + + try { + val resultJson = + RunAnywhereBridge.racSttComponentTranscribe(handle, audioData, config.toJson()) + ?: throw SDKError.stt("Transcription failed: null result") + + val result = parseTranscriptionResult(resultJson, System.currentTimeMillis() - startTime) + + setState(STTState.READY) + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Transcription completed: ${result.text.length} chars, ${result.processingTimeMs}ms", + ) + + try { + sttListener?.onTranscriptionCompleted(result) + } catch (e: Exception) { + // Ignore listener errors + } + + return result + } catch (e: Exception) { + setState(STTState.READY) // Reset to ready, not error + throw if (e is SDKError) e else SDKError.stt("Transcription failed: ${e.message}") + } + } + } + + /** + * Transcribe audio file. + * + * @param audioPath Path to the audio file + * @param config Transcription configuration (optional) + * @return The transcription result + * @throws SDKError if transcription fails + */ + @Throws(SDKError::class) + fun transcribeFile(audioPath: String, config: TranscriptionConfig = TranscriptionConfig.DEFAULT): TranscriptionResult { + synchronized(lock) { + if (handle == 0L || state != STTState.READY) { + throw SDKError.stt("STT component not ready for transcription") + } + + isCancelled = false + setState(STTState.TRANSCRIBING) + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Starting file transcription: $audioPath", + ) + + try { + sttListener?.onTranscriptionStarted() + } catch (e: Exception) { + // Ignore listener errors + } + + val startTime = System.currentTimeMillis() + + try { + val resultJson = + RunAnywhereBridge.racSttComponentTranscribeFile(handle, audioPath, config.toJson()) + ?: throw SDKError.stt("Transcription failed: null result") + + val result = parseTranscriptionResult(resultJson, System.currentTimeMillis() - startTime) + + setState(STTState.READY) + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "File transcription completed: ${result.text.length} chars", + ) + + try { + sttListener?.onTranscriptionCompleted(result) + } catch (e: Exception) { + // Ignore listener errors + } + + return result + } catch (e: Exception) { + setState(STTState.READY) // Reset to ready, not error + throw if (e is SDKError) e else SDKError.stt("File transcription failed: ${e.message}") + } + } + } + + /** + * Transcribe audio with streaming output. + * + * @param audioData Raw audio data bytes + * @param config Transcription configuration (optional) + * @param callback Callback for partial results + * @return The final transcription result + * @throws SDKError if transcription fails + */ + @Throws(SDKError::class) + fun transcribeStream( + audioData: ByteArray, + config: TranscriptionConfig = TranscriptionConfig.DEFAULT, + callback: StreamCallback, + ): TranscriptionResult { + synchronized(lock) { + if (handle == 0L || state != STTState.READY) { + throw SDKError.stt("STT component not ready for transcription") + } + + isCancelled = false + streamCallback = callback + setState(STTState.TRANSCRIBING) + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Starting streaming transcription (audio size: ${audioData.size} bytes)", + ) + + try { + sttListener?.onTranscriptionStarted() + } catch (e: Exception) { + // Ignore listener errors + } + + val startTime = System.currentTimeMillis() + + try { + val resultJson = + RunAnywhereBridge.racSttComponentTranscribeStream(handle, audioData, config.toJson()) + ?: throw SDKError.stt("Streaming transcription failed: null result") + + val result = parseTranscriptionResult(resultJson, System.currentTimeMillis() - startTime) + + setState(STTState.READY) + streamCallback = null + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Streaming transcription completed: ${result.text.length} chars", + ) + + try { + sttListener?.onTranscriptionCompleted(result) + } catch (e: Exception) { + // Ignore listener errors + } + + return result + } catch (e: Exception) { + setState(STTState.READY) // Reset to ready, not error + streamCallback = null + throw if (e is SDKError) e else SDKError.stt("Streaming transcription failed: ${e.message}") + } + } + } + + /** + * Cancel an ongoing transcription. + */ + fun cancel() { + synchronized(lock) { + if (state != STTState.TRANSCRIBING) { + return + } + + isCancelled = true + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Cancelling transcription", + ) + + RunAnywhereBridge.racSttComponentCancel(handle) + } + } + + /** + * Unload the current model. + */ + fun unload() { + synchronized(lock) { + if (loadedModelId == null) { + return + } + + val previousModelId = loadedModelId ?: return + + setState(STTState.UNLOADING) + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "Unloading model: $previousModelId", + ) + + RunAnywhereBridge.racSttComponentUnload(handle) + + loadedModelId = null + loadedModelPath = null + setState(STTState.CREATED) + + // Update model assignment status + CppBridgeModelAssignment.setAssignmentStatusCallback( + CppBridgeModelRegistry.ModelType.STT, + CppBridgeModelAssignment.AssignmentStatus.NOT_ASSIGNED, + CppBridgeModelAssignment.FailureReason.NONE, + ) + + // Update component state + CppBridgeState.setComponentStateCallback( + CppBridgeState.ComponentType.STT, + CppBridgeState.ComponentState.CREATED, + ) + + try { + sttListener?.onModelUnloaded(previousModelId) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in STT listener onModelUnloaded: ${e.message}", + ) + } + } + } + + /** + * Destroy the STT component and release resources. + */ + fun destroy() { + synchronized(lock) { + if (handle == 0L) { + return + } + + // Unload model first if loaded + if (loadedModelId != null) { + unload() + } + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "Destroying STT component", + ) + + RunAnywhereBridge.racSttComponentDestroy(handle) + + handle = 0 + setState(STTState.NOT_CREATED) + + // Update component state + CppBridgeState.setComponentStateCallback( + CppBridgeState.ComponentType.STT, + CppBridgeState.ComponentState.NOT_CREATED, + ) + } + } + + // ======================================================================== + // JNI CALLBACKS + // ======================================================================== + + /** + * Streaming partial result callback. + * + * Called from C++ for each partial transcription result during streaming. + * + * @param text The partial transcription text + * @param isFinal Whether this is a finalized segment + * @return true to continue transcription, false to stop + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun streamPartialCallback(text: String, isFinal: Boolean): Boolean { + if (isCancelled) { + return false + } + + val callback = streamCallback ?: return true + + // Notify listener + try { + sttListener?.onPartialResult(PartialResult(text, isFinal, 1.0f)) + } catch (e: Exception) { + // Ignore listener errors + } + + return try { + callback.onPartialResult(text, isFinal) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in stream callback: ${e.message}", + ) + true // Continue on error + } + } + + /** + * Progress callback. + * + * Called from C++ to report model loading or transcription progress. + * + * @param progress Progress (0.0 to 1.0) + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun progressCallback(progress: Float) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Progress: ${(progress * 100).toInt()}%", + ) + } + + /** + * Get state callback. + * + * @return The current STT component state + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun getStateCallback(): Int { + return state + } + + /** + * Is loaded callback. + * + * @return true if a model is loaded + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun isLoadedCallback(): Boolean { + return loadedModelId != null && state == STTState.READY + } + + /** + * Get loaded model ID callback. + * + * @return The loaded model ID, or null + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun getLoadedModelIdCallback(): String? { + return loadedModelId + } + + // ======================================================================== + // JNI NATIVE DECLARATIONS + // ======================================================================== + + /** + * Native method to set the STT callbacks with C++ core. + * + * Registers [streamPartialCallback], [progressCallback], etc. with C++ core. + * Reserved for future native callback integration. + * + * C API: rac_stt_set_callbacks(...) + */ + @Suppress("unused") + @JvmStatic + private external fun nativeSetSTTCallbacks() + + /** + * Native method to unset the STT callbacks. + * + * Called during shutdown to clean up native resources. + * Reserved for future native callback integration. + * + * C API: rac_stt_set_callbacks(nullptr) + */ + @Suppress("unused") + @JvmStatic + private external fun nativeUnsetSTTCallbacks() + + /** + * Native method to create the STT component. + * + * @return Handle to the created component, or 0 on failure + * + * C API: rac_stt_component_create() + */ + @JvmStatic + external fun nativeCreate(): Long + + /** + * Native method to load a model. + * + * @param handle The component handle + * @param modelPath Path to the model file + * @param configJson JSON configuration string + * @return 0 on success, error code on failure + * + * C API: rac_stt_component_load_model(handle, model_path, config) + */ + @JvmStatic + external fun nativeLoadModel(handle: Long, modelPath: String, configJson: String): Int + + /** + * Native method to transcribe audio data. + * + * @param handle The component handle + * @param audioData Raw audio bytes + * @param configJson JSON configuration string + * @return JSON-encoded result, or null on failure + * + * C API: rac_stt_component_transcribe(handle, audio_data, audio_size, config) + */ + @JvmStatic + external fun nativeTranscribe(handle: Long, audioData: ByteArray, configJson: String): String? + + /** + * Native method to transcribe audio file. + * + * @param handle The component handle + * @param audioPath Path to the audio file + * @param configJson JSON configuration string + * @return JSON-encoded result, or null on failure + * + * C API: rac_stt_component_transcribe_file(handle, audio_path, config) + */ + @JvmStatic + external fun nativeTranscribeFile(handle: Long, audioPath: String, configJson: String): String? + + /** + * Native method to transcribe audio with streaming. + * + * @param handle The component handle + * @param audioData Raw audio bytes + * @param configJson JSON configuration string + * @return JSON-encoded result, or null on failure + * + * C API: rac_stt_component_transcribe_stream(handle, audio_data, audio_size, config) + */ + @JvmStatic + external fun nativeTranscribeStream(handle: Long, audioData: ByteArray, configJson: String): String? + + /** + * Native method to cancel transcription. + * + * @param handle The component handle + * + * C API: rac_stt_component_cancel(handle) + */ + @JvmStatic + external fun nativeCancel(handle: Long) + + /** + * Native method to unload the model. + * + * @param handle The component handle + * + * C API: rac_stt_component_unload(handle) + */ + @JvmStatic + external fun nativeUnload(handle: Long) + + /** + * Native method to destroy the component. + * + * @param handle The component handle + * + * C API: rac_stt_component_destroy(handle) + */ + @JvmStatic + external fun nativeDestroy(handle: Long) + + /** + * Native method to get supported languages. + * + * @param handle The component handle + * @return JSON array of supported language codes + * + * C API: rac_stt_component_get_languages(handle) + */ + @JvmStatic + external fun nativeGetLanguages(handle: Long): String? + + /** + * Native method to detect language from audio. + * + * @param handle The component handle + * @param audioData Raw audio bytes + * @return Detected language code + * + * C API: rac_stt_component_detect_language(handle, audio_data, audio_size) + */ + @JvmStatic + external fun nativeDetectLanguage(handle: Long, audioData: ByteArray): String? + + // ======================================================================== + // LIFECYCLE MANAGEMENT + // ======================================================================== + + /** + * Unregister the STT callbacks and clean up resources. + * + * Called during SDK shutdown. + */ + fun unregister() { + synchronized(lock) { + if (!isRegistered) { + return + } + + // Destroy component if created + if (handle != 0L) { + destroy() + } + + // TODO: Call native unregistration + // nativeUnsetSTTCallbacks() + + sttListener = null + streamCallback = null + isRegistered = false + } + } + + // ======================================================================== + // UTILITY FUNCTIONS + // ======================================================================== + + /** + * Set the component state and notify listeners. + */ + private fun setState(newState: Int) { + val previousState = state + if (newState != previousState) { + state = newState + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "State changed: ${STTState.getName(previousState)} -> ${STTState.getName(newState)}", + ) + + try { + sttListener?.onStateChanged(previousState, newState) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in STT listener onStateChanged: ${e.message}", + ) + } + } + } + + /** + * Parse transcription result from JSON. + */ + private fun parseTranscriptionResult(json: String, elapsedMs: Long): TranscriptionResult { + fun extractString(key: String): String { + val pattern = "\"$key\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"" + val regex = Regex(pattern) + return regex + .find(json) + ?.groupValues + ?.get(1) + ?.let { unescapeJson(it) } ?: "" + } + + fun extractInt(key: String): Int { + val pattern = "\"$key\"\\s*:\\s*(-?\\d+)" + val regex = Regex(pattern) + return regex + .find(json) + ?.groupValues + ?.get(1) + ?.toIntOrNull() ?: 0 + } + + fun extractLong(key: String): Long { + val pattern = "\"$key\"\\s*:\\s*(-?\\d+)" + val regex = Regex(pattern) + return regex + .find(json) + ?.groupValues + ?.get(1) + ?.toLongOrNull() ?: 0L + } + + fun extractFloat(key: String): Float { + val pattern = "\"$key\"\\s*:\\s*(-?[\\d.]+)" + val regex = Regex(pattern) + return regex + .find(json) + ?.groupValues + ?.get(1) + ?.toFloatOrNull() ?: 0f + } + + val text = extractString("text") + val language = extractString("language").ifEmpty { Language.AUTO } + val durationMs = extractLong("duration_ms") + val completionReason = extractInt("completion_reason") + val confidence = extractFloat("confidence") + + return TranscriptionResult( + text = text, + language = language, + durationMs = durationMs, + completionReason = completionReason, + confidence = confidence, + processingTimeMs = elapsedMs, + wordTimestamps = emptyList(), // TODO: Parse word timestamps if present + ) + } + + /** + * Escape special characters for JSON string. + */ + private fun escapeJson(value: String): String { + return value + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + } + + /** + * Unescape JSON string. + */ + private fun unescapeJson(value: String): String { + return value + .replace("\\n", "\n") + .replace("\\r", "\r") + .replace("\\t", "\t") + .replace("\\\"", "\"") + .replace("\\\\", "\\") + } + + /** + * Get supported languages. + * + * @return List of supported language codes, or empty list if model not loaded + */ + fun getSupportedLanguages(): List { + synchronized(lock) { + if (handle == 0L || state != STTState.READY) { + return emptyList() + } + val json = RunAnywhereBridge.racSttComponentGetLanguages(handle) ?: return emptyList() + // Parse JSON array + val pattern = "\"([^\"]+)\"" + return Regex(pattern).findAll(json).map { it.groupValues[1] }.toList() + } + } + + /** + * Detect language from audio sample. + * + * @param audioData Raw audio data bytes + * @return Detected language code, or Language.AUTO if detection fails + */ + fun detectLanguage(audioData: ByteArray): String { + synchronized(lock) { + if (handle == 0L || state != STTState.READY) { + return Language.AUTO + } + return RunAnywhereBridge.racSttComponentDetectLanguage(handle, audioData) ?: Language.AUTO + } + } + + /** + * Get a state summary for diagnostics. + * + * @return Human-readable state summary + */ + fun getStateSummary(): String { + return buildString { + append("STT State: ${STTState.getName(state)}") + if (loadedModelId != null) { + append(", Model: $loadedModelId") + } + if (handle != 0L) { + append(", Handle: $handle") + } + } + } +} diff --git a/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeServices.kt b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeServices.kt new file mode 100644 index 000000000..67077ce28 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeServices.kt @@ -0,0 +1,1285 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * Services extension for CppBridge. + * Provides service registry integration for C++ core. + * + * Follows iOS CppBridge+Services.swift architecture. + */ + +package com.runanywhere.sdk.foundation.bridge.extensions + +/** + * Services bridge that provides service registry integration for C++ core. + * + * The service registry manages the lifecycle and state of all SDK services: + * - LLM (Large Language Model) + * - STT (Speech-to-Text) + * - TTS (Text-to-Speech) + * - VAD (Voice Activity Detection) + * - VoiceAgent (Conversational AI pipeline) + * + * The C++ core uses the service registry for: + * - Querying available services and their capabilities + * - Managing service lifecycle (create, initialize, destroy) + * - Tracking service state and readiness + * - Coordinating service dependencies + * + * Usage: + * - Called during Phase 2 initialization in [CppBridge.initializeServices] + * - Must be registered after [CppBridgePlatformAdapter] is registered + * + * Thread Safety: + * - This object is thread-safe via synchronized blocks + * - All callbacks are thread-safe + */ +object CppBridgeServices { + /** + * Service type constants matching C++ RAC_SERVICE_TYPE_* values. + */ + object ServiceType { + /** Unknown service type */ + const val UNKNOWN = 0 + + /** LLM service */ + const val LLM = 1 + + /** STT service */ + const val STT = 2 + + /** TTS service */ + const val TTS = 3 + + /** VAD service */ + const val VAD = 4 + + /** Voice Agent service */ + const val VOICE_AGENT = 5 + + /** Model registry service */ + const val MODEL_REGISTRY = 6 + + /** Download manager service */ + const val DOWNLOAD_MANAGER = 7 + + /** Platform service (system AI capabilities) */ + const val PLATFORM = 8 + + /** Telemetry service */ + const val TELEMETRY = 9 + + /** Authentication service */ + const val AUTH = 10 + + /** + * Get a human-readable name for the service type. + */ + fun getName(type: Int): String = + when (type) { + UNKNOWN -> "UNKNOWN" + LLM -> "LLM" + STT -> "STT" + TTS -> "TTS" + VAD -> "VAD" + VOICE_AGENT -> "VOICE_AGENT" + MODEL_REGISTRY -> "MODEL_REGISTRY" + DOWNLOAD_MANAGER -> "DOWNLOAD_MANAGER" + PLATFORM -> "PLATFORM" + TELEMETRY -> "TELEMETRY" + AUTH -> "AUTH" + else -> "UNKNOWN($type)" + } + + /** + * Get all AI service types (components that process models). + */ + fun getAIServiceTypes(): List = listOf(LLM, STT, TTS, VAD, VOICE_AGENT) + + /** + * Get all infrastructure service types. + */ + fun getInfrastructureServiceTypes(): List = + listOf( + MODEL_REGISTRY, + DOWNLOAD_MANAGER, + PLATFORM, + TELEMETRY, + AUTH, + ) + + /** + * Get all service types. + */ + fun getAllServiceTypes(): List = + listOf( + LLM, + STT, + TTS, + VAD, + VOICE_AGENT, + MODEL_REGISTRY, + DOWNLOAD_MANAGER, + PLATFORM, + TELEMETRY, + AUTH, + ) + } + + /** + * Service state constants matching C++ RAC_SERVICE_STATE_* values. + */ + object ServiceState { + /** Service not registered */ + const val NOT_REGISTERED = 0 + + /** Service registered but not initialized */ + const val REGISTERED = 1 + + /** Service is initializing */ + const val INITIALIZING = 2 + + /** Service is ready for use */ + const val READY = 3 + + /** Service is busy processing */ + const val BUSY = 4 + + /** Service is paused */ + const val PAUSED = 5 + + /** Service is in error state */ + const val ERROR = 6 + + /** Service is shutting down */ + const val SHUTTING_DOWN = 7 + + /** Service is destroyed */ + const val DESTROYED = 8 + + /** + * Get a human-readable name for the service state. + */ + fun getName(state: Int): String = + when (state) { + NOT_REGISTERED -> "NOT_REGISTERED" + REGISTERED -> "REGISTERED" + INITIALIZING -> "INITIALIZING" + READY -> "READY" + BUSY -> "BUSY" + PAUSED -> "PAUSED" + ERROR -> "ERROR" + SHUTTING_DOWN -> "SHUTTING_DOWN" + DESTROYED -> "DESTROYED" + else -> "UNKNOWN($state)" + } + + /** + * Check if the state indicates the service is usable. + */ + fun isUsable(state: Int): Boolean = state == READY + + /** + * Check if the state indicates the service is operational (usable or busy). + */ + fun isOperational(state: Int): Boolean = state == READY || state == BUSY + } + + /** + * Service capability flags. + */ + object ServiceCapability { + /** Service supports streaming output */ + const val STREAMING = 1 + + /** Service supports cancellation */ + const val CANCELLATION = 2 + + /** Service supports progress reporting */ + const val PROGRESS_REPORTING = 4 + + /** Service supports batch processing */ + const val BATCH_PROCESSING = 8 + + /** Service supports offline mode */ + const val OFFLINE = 16 + + /** Service supports on-device processing */ + const val ON_DEVICE = 32 + + /** Service supports cloud processing */ + const val CLOUD = 64 + + /** Service supports real-time processing */ + const val REAL_TIME = 128 + + /** + * Check if capabilities include a specific flag. + */ + fun hasCapability(capabilities: Int, flag: Int): Boolean = (capabilities and flag) != 0 + + /** + * Get a list of capability names from a capability flags value. + */ + fun getCapabilityNames(capabilities: Int): List { + val names = mutableListOf() + if (hasCapability(capabilities, STREAMING)) names.add("STREAMING") + if (hasCapability(capabilities, CANCELLATION)) names.add("CANCELLATION") + if (hasCapability(capabilities, PROGRESS_REPORTING)) names.add("PROGRESS_REPORTING") + if (hasCapability(capabilities, BATCH_PROCESSING)) names.add("BATCH_PROCESSING") + if (hasCapability(capabilities, OFFLINE)) names.add("OFFLINE") + if (hasCapability(capabilities, ON_DEVICE)) names.add("ON_DEVICE") + if (hasCapability(capabilities, CLOUD)) names.add("CLOUD") + if (hasCapability(capabilities, REAL_TIME)) names.add("REAL_TIME") + return names + } + } + + @Volatile + private var isRegistered: Boolean = false + + @Volatile + private var isInitialized: Boolean = false + + private val lock = Any() + + /** + * Registry of service information. + */ + private val serviceRegistry = mutableMapOf() + + /** + * Tag for logging. + */ + private const val TAG = "CppBridgeServices" + + /** + * Optional listener for service registry events. + * Set this before calling [register] to receive events. + */ + @Volatile + var servicesListener: ServicesListener? = null + + /** + * Service information data class. + * + * @param serviceType The service type (see [ServiceType]) + * @param state The current service state (see [ServiceState]) + * @param capabilities Bitfield of service capabilities (see [ServiceCapability]) + * @param version Service version string + * @param lastError Last error message, or null if no error + * @param lastErrorCode Last error code, or 0 if no error + * @param metadata Additional service metadata + */ + data class ServiceInfo( + val serviceType: Int, + val state: Int, + val capabilities: Int, + val version: String, + val lastError: String?, + val lastErrorCode: Int, + val metadata: Map, + ) { + /** + * Check if the service is ready for use. + */ + fun isReady(): Boolean = ServiceState.isUsable(state) + + /** + * Check if the service is operational. + */ + fun isOperational(): Boolean = ServiceState.isOperational(state) + + /** + * Get the service type name. + */ + fun getTypeName(): String = ServiceType.getName(serviceType) + + /** + * Get the state name. + */ + fun getStateName(): String = ServiceState.getName(state) + + /** + * Check if the service has a specific capability. + */ + fun hasCapability(capability: Int): Boolean = + ServiceCapability.hasCapability(capabilities, capability) + + /** + * Get list of capability names. + */ + fun getCapabilityNames(): List = + ServiceCapability.getCapabilityNames(capabilities) + + /** + * Convert to JSON string for C++ interop. + */ + fun toJson(): String { + return buildString { + append("{") + append("\"service_type\":$serviceType,") + append("\"state\":$state,") + append("\"capabilities\":$capabilities,") + append("\"version\":\"${escapeJsonString(version)}\",") + lastError?.let { append("\"last_error\":\"${escapeJsonString(it)}\",") } + append("\"last_error_code\":$lastErrorCode,") + append("\"metadata\":{") + metadata.entries.forEachIndexed { index, entry -> + if (index > 0) append(",") + append("\"${escapeJsonString(entry.key)}\":\"${escapeJsonString(entry.value)}\"") + } + append("}") + append("}") + } + } + } + + /** + * Service dependency information. + * + * @param serviceType The service type + * @param dependsOn List of service types this service depends on + * @param optional Whether the dependency is optional + */ + data class ServiceDependency( + val serviceType: Int, + val dependsOn: List, + val optional: Boolean = false, + ) + + /** + * Listener interface for service registry events. + */ + interface ServicesListener { + /** + * Called when a service is registered. + * + * @param serviceType The service type + * @param serviceInfo The service information + */ + fun onServiceRegistered(serviceType: Int, serviceInfo: ServiceInfo) + + /** + * Called when a service is unregistered. + * + * @param serviceType The service type + */ + fun onServiceUnregistered(serviceType: Int) + + /** + * Called when a service state changes. + * + * @param serviceType The service type + * @param previousState The previous state + * @param newState The new state + */ + fun onServiceStateChanged(serviceType: Int, previousState: Int, newState: Int) + + /** + * Called when a service encounters an error. + * + * @param serviceType The service type + * @param errorCode The error code + * @param errorMessage The error message + */ + fun onServiceError(serviceType: Int, errorCode: Int, errorMessage: String) + + /** + * Called when all services are ready. + */ + fun onAllServicesReady() + } + + /** + * Register the services callbacks with C++ core. + * + * This must be called during SDK initialization, after [CppBridgePlatformAdapter.register]. + * It is safe to call multiple times; subsequent calls are no-ops. + */ + fun register() { + synchronized(lock) { + if (isRegistered) { + return + } + + // Initialize the service registry with known services + initializeServiceRegistry() + + // Register the services callbacks with C++ via JNI + // TODO: Call native registration + // nativeSetServicesCallbacks() + + isRegistered = true + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Services callbacks registered", + ) + } + } + + /** + * Check if the services callbacks are registered. + */ + fun isRegistered(): Boolean = isRegistered + + /** + * Initialize the service registry. + * + * This should be called after registration to initialize all services. + * + * @return 0 on success, error code on failure + */ + fun initialize(): Int { + synchronized(lock) { + if (!isRegistered) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Cannot initialize: not registered", + ) + return -1 + } + + if (isInitialized) { + return 0 + } + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "Initializing service registry", + ) + + isInitialized = true + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "Service registry initialized with ${serviceRegistry.size} services", + ) + + return 0 + } + } + + // ======================================================================== + // SERVICE REGISTRY CALLBACKS + // ======================================================================== + + /** + * Get service info callback. + * + * Returns service information as JSON string for a given service type. + * + * @param serviceType The service type to look up + * @return JSON-encoded service information, or null if not found + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun getServiceInfoCallback(serviceType: Int): String? { + val service = + synchronized(lock) { + serviceRegistry[serviceType] + } ?: return null + + return service.toJson() + } + + /** + * Register service callback. + * + * Registers or updates a service in the registry. + * + * @param serviceType The service type + * @param serviceInfoJson JSON-encoded service information + * @return true if registered successfully, false otherwise + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun registerServiceCallback(serviceType: Int, serviceInfoJson: String): Boolean { + return try { + val serviceInfo = parseServiceInfoJson(serviceType, serviceInfoJson) + val previousService: ServiceInfo? + + synchronized(lock) { + previousService = serviceRegistry[serviceType] + serviceRegistry[serviceType] = serviceInfo + } + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Service registered: ${serviceInfo.getTypeName()} (${serviceInfo.getStateName()})", + ) + + // Notify listener + try { + if (previousService == null) { + servicesListener?.onServiceRegistered(serviceType, serviceInfo) + } else if (previousService.state != serviceInfo.state) { + servicesListener?.onServiceStateChanged( + serviceType, + previousService.state, + serviceInfo.state, + ) + } + + // Check if all services are ready + checkAllServicesReady() + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in services listener: ${e.message}", + ) + } + + true + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "Failed to register service: ${e.message}", + ) + false + } + } + + /** + * Unregister service callback. + * + * Removes a service from the registry. + * + * @param serviceType The service type to remove + * @return true if removed, false if not found + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun unregisterServiceCallback(serviceType: Int): Boolean { + val removed = + synchronized(lock) { + serviceRegistry.remove(serviceType) + } + + if (removed != null) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Service unregistered: ${ServiceType.getName(serviceType)}", + ) + + // Notify listener + try { + servicesListener?.onServiceUnregistered(serviceType) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in services listener onServiceUnregistered: ${e.message}", + ) + } + + return true + } + + return false + } + + /** + * Get service state callback. + * + * Returns the current state of a service. + * + * @param serviceType The service type + * @return The service state, or [ServiceState.NOT_REGISTERED] if not found + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun getServiceStateCallback(serviceType: Int): Int { + return synchronized(lock) { + serviceRegistry[serviceType]?.state ?: ServiceState.NOT_REGISTERED + } + } + + /** + * Set service state callback. + * + * Updates the state of a service. + * + * @param serviceType The service type + * @param state The new state + * @return true if updated, false if service not found + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun setServiceStateCallback(serviceType: Int, state: Int): Boolean { + val previousState: Int + val updated: Boolean + + synchronized(lock) { + val service = serviceRegistry[serviceType] + if (service == null) { + return false + } + + previousState = service.state + if (previousState == state) { + return true // No change needed + } + + serviceRegistry[serviceType] = service.copy(state = state) + updated = true + } + + if (updated) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Service state updated: ${ServiceType.getName(serviceType)} " + + "${ServiceState.getName(previousState)} -> ${ServiceState.getName(state)}", + ) + + // Notify listener + try { + servicesListener?.onServiceStateChanged(serviceType, previousState, state) + + // Check if all services are ready + if (state == ServiceState.READY) { + checkAllServicesReady() + } + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in services listener onServiceStateChanged: ${e.message}", + ) + } + } + + return true + } + + /** + * Set service error callback. + * + * Updates the error state of a service. + * + * @param serviceType The service type + * @param errorCode The error code + * @param errorMessage The error message + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun setServiceErrorCallback(serviceType: Int, errorCode: Int, errorMessage: String) { + synchronized(lock) { + val service = serviceRegistry[serviceType] ?: return + serviceRegistry[serviceType] = + service.copy( + state = ServiceState.ERROR, + lastError = errorMessage, + lastErrorCode = errorCode, + ) + } + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "Service error: ${ServiceType.getName(serviceType)} (code: $errorCode): $errorMessage", + ) + + // Notify listener + try { + servicesListener?.onServiceError(serviceType, errorCode, errorMessage) + servicesListener?.onServiceStateChanged( + serviceType, + ServiceState.READY, + ServiceState.ERROR, + ) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in services listener: ${e.message}", + ) + } + } + + /** + * Get all services callback. + * + * Returns all registered services as JSON array. + * + * @return JSON-encoded array of service information + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun getAllServicesCallback(): String { + val services = + synchronized(lock) { + serviceRegistry.values.toList() + } + + return buildString { + append("[") + services.forEachIndexed { index, service -> + if (index > 0) append(",") + append(service.toJson()) + } + append("]") + } + } + + /** + * Get ready services callback. + * + * Returns all ready services as JSON array. + * + * @return JSON-encoded array of ready service information + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun getReadyServicesCallback(): String { + val services = + synchronized(lock) { + serviceRegistry.values.filter { it.isReady() } + } + + return buildString { + append("[") + services.forEachIndexed { index, service -> + if (index > 0) append(",") + append(service.toJson()) + } + append("]") + } + } + + /** + * Is service ready callback. + * + * @param serviceType The service type to check + * @return true if the service is ready, false otherwise + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun isServiceReadyCallback(serviceType: Int): Boolean { + return synchronized(lock) { + serviceRegistry[serviceType]?.isReady() ?: false + } + } + + /** + * Get service capabilities callback. + * + * @param serviceType The service type + * @return The service capabilities bitfield, or 0 if not found + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun getServiceCapabilitiesCallback(serviceType: Int): Int { + return synchronized(lock) { + serviceRegistry[serviceType]?.capabilities ?: 0 + } + } + + /** + * Has service callback. + * + * @param serviceType The service type to check + * @return true if the service exists in the registry + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun hasServiceCallback(serviceType: Int): Boolean { + return synchronized(lock) { + serviceRegistry.containsKey(serviceType) + } + } + + /** + * Get service count callback. + * + * @return The number of registered services + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun getServiceCountCallback(): Int { + return synchronized(lock) { + serviceRegistry.size + } + } + + // ======================================================================== + // JNI NATIVE DECLARATIONS + // ======================================================================== + + /** + * Native method to set the services callbacks with C++ core. + * + * Registers all service registry callbacks with C++ core. + * Reserved for future native callback integration. + * + * C API: rac_services_set_callbacks(...) + */ + @Suppress("unused") + @JvmStatic + private external fun nativeSetServicesCallbacks() + + /** + * Native method to unset the services callbacks. + * + * Called during shutdown to clean up native resources. + * Reserved for future native callback integration. + * + * C API: rac_services_set_callbacks(nullptr) + */ + @Suppress("unused") + @JvmStatic + private external fun nativeUnsetServicesCallbacks() + + /** + * Native method to initialize the service registry. + * + * @return 0 on success, error code on failure + * + * C API: rac_services_initialize() + */ + @JvmStatic + external fun nativeInitialize(): Int + + /** + * Native method to shutdown the service registry. + * + * @return 0 on success, error code on failure + * + * C API: rac_services_shutdown() + */ + @JvmStatic + external fun nativeShutdown(): Int + + /** + * Native method to get a service from the C++ registry. + * + * @param serviceType The service type + * @return JSON-encoded service info, or null if not found + * + * C API: rac_services_get(service_type) + */ + @JvmStatic + external fun nativeGet(serviceType: Int): String? + + /** + * Native method to register a service with the C++ registry. + * + * @param serviceType The service type + * @param serviceInfoJson JSON-encoded service information + * @return 0 on success, error code on failure + * + * C API: rac_services_register(service_type, service_info) + */ + @JvmStatic + external fun nativeRegister(serviceType: Int, serviceInfoJson: String): Int + + /** + * Native method to start a service. + * + * @param serviceType The service type + * @return 0 on success, error code on failure + * + * C API: rac_services_start(service_type) + */ + @JvmStatic + external fun nativeStart(serviceType: Int): Int + + /** + * Native method to stop a service. + * + * @param serviceType The service type + * @return 0 on success, error code on failure + * + * C API: rac_services_stop(service_type) + */ + @JvmStatic + external fun nativeStop(serviceType: Int): Int + + // ======================================================================== + // LIFECYCLE MANAGEMENT + // ======================================================================== + + /** + * Unregister the services callbacks and clean up resources. + * + * Called during SDK shutdown. + */ + fun unregister() { + synchronized(lock) { + if (!isRegistered) { + return + } + + // TODO: Call native unregistration + // nativeUnsetServicesCallbacks() + + servicesListener = null + serviceRegistry.clear() + isInitialized = false + isRegistered = false + } + } + + // ======================================================================== + // PUBLIC UTILITY METHODS + // ======================================================================== + + /** + * Get a service by type. + * + * @param serviceType The service type + * @return The service information, or null if not found + */ + fun getService(serviceType: Int): ServiceInfo? { + return synchronized(lock) { + serviceRegistry[serviceType] + } + } + + /** + * Get all registered services. + * + * @return List of all service information + */ + fun getAllServices(): List { + return synchronized(lock) { + serviceRegistry.values.toList() + } + } + + /** + * Get all ready services. + * + * @return List of ready service information + */ + fun getReadyServices(): List { + return synchronized(lock) { + serviceRegistry.values.filter { it.isReady() } + } + } + + /** + * Get all AI services. + * + * @return List of AI service information (LLM, STT, TTS, VAD, VoiceAgent) + */ + fun getAIServices(): List { + return synchronized(lock) { + serviceRegistry.values.filter { it.serviceType in ServiceType.getAIServiceTypes() } + } + } + + /** + * Check if a service is registered. + * + * @param serviceType The service type + * @return true if the service is registered + */ + fun hasService(serviceType: Int): Boolean { + return hasServiceCallback(serviceType) + } + + /** + * Check if a service is ready. + * + * @param serviceType The service type + * @return true if the service is ready + */ + fun isServiceReady(serviceType: Int): Boolean { + return isServiceReadyCallback(serviceType) + } + + /** + * Check if all AI services are ready. + * + * @return true if all AI services are ready + */ + fun areAIServicesReady(): Boolean { + return synchronized(lock) { + ServiceType.getAIServiceTypes().all { type -> + serviceRegistry[type]?.isReady() ?: false + } + } + } + + /** + * Get the number of registered services. + * + * @return The service count + */ + fun getServiceCount(): Int { + return getServiceCountCallback() + } + + /** + * Register a service. + * + * @param serviceInfo The service information to register + */ + fun registerService(serviceInfo: ServiceInfo) { + registerServiceCallback(serviceInfo.serviceType, serviceInfo.toJson()) + } + + /** + * Unregister a service. + * + * @param serviceType The service type to unregister + * @return true if the service was removed, false if not found + */ + fun unregisterService(serviceType: Int): Boolean { + return unregisterServiceCallback(serviceType) + } + + /** + * Update a service's state. + * + * @param serviceType The service type + * @param state The new state (see [ServiceState]) + * @return true if updated, false if service not found + */ + fun updateServiceState(serviceType: Int, state: Int): Boolean { + return setServiceStateCallback(serviceType, state) + } + + /** + * Set a service's error. + * + * @param serviceType The service type + * @param errorCode The error code + * @param errorMessage The error message + */ + fun setServiceError(serviceType: Int, errorCode: Int, errorMessage: String) { + setServiceErrorCallback(serviceType, errorCode, errorMessage) + } + + /** + * Get the service dependencies. + * + * @return Map of service type to its dependencies + */ + fun getServiceDependencies(): Map { + return mapOf( + ServiceType.VOICE_AGENT to + ServiceDependency( + serviceType = ServiceType.VOICE_AGENT, + dependsOn = listOf(ServiceType.LLM, ServiceType.STT, ServiceType.TTS, ServiceType.VAD), + ), + ServiceType.LLM to + ServiceDependency( + serviceType = ServiceType.LLM, + dependsOn = listOf(ServiceType.MODEL_REGISTRY), + ), + ServiceType.STT to + ServiceDependency( + serviceType = ServiceType.STT, + dependsOn = listOf(ServiceType.MODEL_REGISTRY), + ), + ServiceType.TTS to + ServiceDependency( + serviceType = ServiceType.TTS, + dependsOn = listOf(ServiceType.MODEL_REGISTRY), + ), + ServiceType.VAD to + ServiceDependency( + serviceType = ServiceType.VAD, + dependsOn = listOf(ServiceType.MODEL_REGISTRY), + ), + ) + } + + /** + * Check if a service's dependencies are satisfied. + * + * @param serviceType The service type + * @return true if all dependencies are ready + */ + fun areDependenciesSatisfied(serviceType: Int): Boolean { + val dependency = getServiceDependencies()[serviceType] ?: return true + + return synchronized(lock) { + dependency.dependsOn.all { depType -> + val depService = serviceRegistry[depType] + if (dependency.optional) { + depService == null || depService.isReady() + } else { + depService?.isReady() ?: false + } + } + } + } + + /** + * Get a state summary for diagnostics. + * + * @return Human-readable state summary + */ + fun getStateSummary(): String { + val services = synchronized(lock) { serviceRegistry.values.toList() } + + return buildString { + append("Services Registry: registered=$isRegistered, initialized=$isInitialized\n") + append("Services (${services.size}):\n") + services.forEach { service -> + append(" - ${service.getTypeName()}: ${service.getStateName()}") + if (service.lastError != null) { + append(" [ERROR: ${service.lastError}]") + } + append("\n") + } + } + } + + // ======================================================================== + // PRIVATE UTILITY FUNCTIONS + // ======================================================================== + + /** + * Initialize the service registry with known services. + */ + private fun initializeServiceRegistry() { + // Register all known service types with NOT_REGISTERED state + ServiceType.getAllServiceTypes().forEach { serviceType -> + serviceRegistry[serviceType] = + ServiceInfo( + serviceType = serviceType, + state = ServiceState.NOT_REGISTERED, + capabilities = getDefaultCapabilities(serviceType), + version = "1.0.0", + lastError = null, + lastErrorCode = 0, + metadata = emptyMap(), + ) + } + } + + /** + * Get default capabilities for a service type. + */ + private fun getDefaultCapabilities(serviceType: Int): Int { + return when (serviceType) { + ServiceType.LLM -> { + ServiceCapability.STREAMING or + ServiceCapability.CANCELLATION or + ServiceCapability.ON_DEVICE + } + ServiceType.STT -> { + ServiceCapability.STREAMING or + ServiceCapability.CANCELLATION or + ServiceCapability.ON_DEVICE or + ServiceCapability.REAL_TIME + } + ServiceType.TTS -> { + ServiceCapability.STREAMING or + ServiceCapability.CANCELLATION or + ServiceCapability.ON_DEVICE + } + ServiceType.VAD -> { + ServiceCapability.REAL_TIME or + ServiceCapability.ON_DEVICE + } + ServiceType.VOICE_AGENT -> { + ServiceCapability.STREAMING or + ServiceCapability.CANCELLATION or + ServiceCapability.REAL_TIME or + ServiceCapability.ON_DEVICE + } + ServiceType.DOWNLOAD_MANAGER -> { + ServiceCapability.PROGRESS_REPORTING or + ServiceCapability.CANCELLATION + } + else -> 0 + } + } + + /** + * Check if all required services are ready and notify listener. + */ + private fun checkAllServicesReady() { + val allReady = + synchronized(lock) { + ServiceType.getAIServiceTypes().all { type -> + val service = serviceRegistry[type] + service == null || service.isReady() + } + } + + if (allReady) { + try { + servicesListener?.onAllServicesReady() + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in services listener onAllServicesReady: ${e.message}", + ) + } + } + } + + /** + * Parse JSON string to ServiceInfo. + */ + private fun parseServiceInfoJson(serviceType: Int, json: String): ServiceInfo { + val cleanJson = json.trim() + + fun extractString(key: String): String? { + val pattern = "\"$key\"\\s*:\\s*\"([^\"]*)\"" + val regex = Regex(pattern) + return regex.find(cleanJson)?.groupValues?.get(1) + } + + fun extractInt(key: String): Int { + val pattern = "\"$key\"\\s*:\\s*(-?\\d+)" + val regex = Regex(pattern) + return regex + .find(cleanJson) + ?.groupValues + ?.get(1) + ?.toIntOrNull() ?: 0 + } + + return ServiceInfo( + serviceType = serviceType, + state = extractInt("state"), + capabilities = extractInt("capabilities"), + version = extractString("version") ?: "1.0.0", + lastError = extractString("last_error"), + lastErrorCode = extractInt("last_error_code"), + metadata = emptyMap(), // Simplified - full implementation would parse nested object + ) + } + + /** + * Escape a string for JSON encoding. + */ + private fun escapeJsonString(str: String): String { + return str + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + } +} diff --git a/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeState.kt b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeState.kt new file mode 100644 index 000000000..9d72e93ee --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeState.kt @@ -0,0 +1,778 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * State extension for CppBridge. + * Provides SDK state management callbacks for C++ core. + * + * Follows iOS CppBridge+State.swift architecture. + */ + +package com.runanywhere.sdk.foundation.bridge.extensions + +/** + * State bridge that provides SDK state management callbacks for C++ core. + * + * The C++ core needs state information for: + * - Tracking SDK lifecycle (initializing, ready, error) + * - Component state tracking (LLM, STT, TTS, VAD) + * - Health monitoring and diagnostics + * - Error state reporting + * + * Usage: + * - Called during Phase 1 initialization in [CppBridge.initialize] + * - Must be registered after [CppBridgePlatformAdapter] is registered + * + * Thread Safety: + * - Registration is thread-safe via synchronized block + * - All callbacks are thread-safe using atomic operations + */ +object CppBridgeState { + /** + * SDK state constants matching C++ RAC_STATE_* values. + */ + object SDKState { + /** SDK not initialized */ + const val UNINITIALIZED = 0 + + /** SDK is initializing */ + const val INITIALIZING = 1 + + /** SDK core is initialized (Phase 1 complete) */ + const val CORE_READY = 2 + + /** SDK services are initialized (Phase 2 complete) */ + const val SERVICES_READY = 3 + + /** SDK is fully ready for use */ + const val READY = 4 + + /** SDK is shutting down */ + const val SHUTTING_DOWN = 5 + + /** SDK encountered an error */ + const val ERROR = 6 + + /** + * Get a human-readable name for the SDK state. + */ + fun getName(state: Int): String = + when (state) { + UNINITIALIZED -> "UNINITIALIZED" + INITIALIZING -> "INITIALIZING" + CORE_READY -> "CORE_READY" + SERVICES_READY -> "SERVICES_READY" + READY -> "READY" + SHUTTING_DOWN -> "SHUTTING_DOWN" + ERROR -> "ERROR" + else -> "UNKNOWN($state)" + } + + /** + * Check if the state indicates the SDK is usable. + */ + fun isUsable(state: Int): Boolean = state in CORE_READY..READY + } + + /** + * Component state constants matching C++ RAC_COMPONENT_STATE_* values. + */ + object ComponentState { + /** Component not created */ + const val NOT_CREATED = 0 + + /** Component created but not loaded */ + const val CREATED = 1 + + /** Component is loading model */ + const val LOADING = 2 + + /** Component is ready for use */ + const val READY = 3 + + /** Component is processing */ + const val PROCESSING = 4 + + /** Component is unloading */ + const val UNLOADING = 5 + + /** Component encountered an error */ + const val ERROR = 6 + + /** + * Get a human-readable name for the component state. + */ + fun getName(state: Int): String = + when (state) { + NOT_CREATED -> "NOT_CREATED" + CREATED -> "CREATED" + LOADING -> "LOADING" + READY -> "READY" + PROCESSING -> "PROCESSING" + UNLOADING -> "UNLOADING" + ERROR -> "ERROR" + else -> "UNKNOWN($state)" + } + } + + /** + * Component type constants matching C++ RAC_COMPONENT_TYPE_* values. + */ + object ComponentType { + const val LLM = 0 + const val STT = 1 + const val TTS = 2 + const val VAD = 3 + const val VOICE_AGENT = 4 + + /** + * Get a human-readable name for the component type. + */ + fun getName(type: Int): String = + when (type) { + LLM -> "LLM" + STT -> "STT" + TTS -> "TTS" + VAD -> "VAD" + VOICE_AGENT -> "VOICE_AGENT" + else -> "UNKNOWN($type)" + } + } + + /** + * Health status constants. + */ + object HealthStatus { + /** All systems operational */ + const val HEALTHY = 0 + + /** Some issues detected but functional */ + const val DEGRADED = 1 + + /** Critical issues, functionality impaired */ + const val UNHEALTHY = 2 + + /** + * Get a human-readable name for the health status. + */ + fun getName(status: Int): String = + when (status) { + HEALTHY -> "HEALTHY" + DEGRADED -> "DEGRADED" + UNHEALTHY -> "UNHEALTHY" + else -> "UNKNOWN($status)" + } + } + + @Volatile + private var isRegistered: Boolean = false + + @Volatile + private var sdkState: Int = SDKState.UNINITIALIZED + + @Volatile + private var healthStatus: Int = HealthStatus.HEALTHY + + @Volatile + private var lastError: String? = null + + @Volatile + private var lastErrorCode: Int = 0 + + private val lock = Any() + + /** + * Component states storage. + */ + private val componentStates = mutableMapOf() + + /** + * Component error messages. + */ + private val componentErrors = mutableMapOf() + + /** + * Tag for logging. + */ + private const val TAG = "CppBridgeState" + + /** + * Optional listener for state change events. + * Set this before calling [register] to receive events. + */ + @Volatile + var stateListener: StateListener? = null + + /** + * Listener interface for state change events. + */ + interface StateListener { + /** + * Called when the SDK state changes. + * + * @param previousState The previous SDK state + * @param newState The new SDK state + */ + fun onSDKStateChanged(previousState: Int, newState: Int) + + /** + * Called when a component state changes. + * + * @param componentType The component type (see [ComponentType]) + * @param previousState The previous component state + * @param newState The new component state + */ + fun onComponentStateChanged(componentType: Int, previousState: Int, newState: Int) + + /** + * Called when the health status changes. + * + * @param previousStatus The previous health status + * @param newStatus The new health status + */ + fun onHealthStatusChanged(previousStatus: Int, newStatus: Int) + + /** + * Called when an error occurs. + * + * @param errorCode The error code + * @param errorMessage The error message + */ + fun onError(errorCode: Int, errorMessage: String) + } + + /** + * Register the state callbacks with C++ core. + * + * This must be called during SDK initialization, after [CppBridgePlatformAdapter.register]. + * It is safe to call multiple times; subsequent calls are no-ops. + */ + fun register() { + synchronized(lock) { + if (isRegistered) { + return + } + + // Initialize component states + initializeComponentStates() + + // Register the state callbacks with C++ via JNI + // TODO: Call native registration + // nativeSetStateCallbacks() + + isRegistered = true + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "State callbacks registered. SDK State: ${SDKState.getName(sdkState)}", + ) + } + } + + /** + * Check if the state callbacks are registered. + */ + fun isRegistered(): Boolean = isRegistered + + /** + * Get the current SDK state. + */ + fun getSDKState(): Int = sdkState + + /** + * Check if the SDK is ready for use. + */ + fun isReady(): Boolean = SDKState.isUsable(sdkState) + + // ======================================================================== + // STATE CALLBACKS + // ======================================================================== + + /** + * Get the SDK state callback. + * + * @return The current SDK state (see [SDKState]) + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun getSDKStateCallback(): Int { + return sdkState + } + + /** + * Set the SDK state callback. + * + * Called by C++ core when SDK state changes. + * + * @param state The new SDK state (see [SDKState]) + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun setSDKStateCallback(state: Int) { + val previousState = sdkState + if (state != previousState) { + sdkState = state + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "SDK state changed: ${SDKState.getName(previousState)} -> ${SDKState.getName(state)}", + ) + + // Notify listener + try { + stateListener?.onSDKStateChanged(previousState, state) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in state listener: ${e.message}", + ) + } + } + } + + /** + * Get a component state callback. + * + * @param componentType The component type (see [ComponentType]) + * @return The component state (see [ComponentState]) + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun getComponentStateCallback(componentType: Int): Int { + return synchronized(lock) { + componentStates[componentType] ?: ComponentState.NOT_CREATED + } + } + + /** + * Set a component state callback. + * + * Called by C++ core when a component state changes. + * + * @param componentType The component type (see [ComponentType]) + * @param state The new component state (see [ComponentState]) + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun setComponentStateCallback(componentType: Int, state: Int) { + val previousState: Int + synchronized(lock) { + previousState = componentStates[componentType] ?: ComponentState.NOT_CREATED + componentStates[componentType] = state + + // Clear error if state is not ERROR + if (state != ComponentState.ERROR) { + componentErrors.remove(componentType) + } + } + + if (state != previousState) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Component ${ComponentType.getName(componentType)} state changed: " + + "${ComponentState.getName(previousState)} -> ${ComponentState.getName(state)}", + ) + + // Notify listener + try { + stateListener?.onComponentStateChanged(componentType, previousState, state) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in state listener onComponentStateChanged: ${e.message}", + ) + } + + // Update health status based on component states + updateHealthStatus() + } + } + + /** + * Set component error callback. + * + * Called by C++ core when a component encounters an error. + * + * @param componentType The component type (see [ComponentType]) + * @param errorCode The error code + * @param errorMessage The error message + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun setComponentErrorCallback(componentType: Int, errorCode: Int, errorMessage: String) { + synchronized(lock) { + componentStates[componentType] = ComponentState.ERROR + componentErrors[componentType] = errorMessage + } + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "Component ${ComponentType.getName(componentType)} error: [$errorCode] $errorMessage", + ) + + // Notify listener + try { + stateListener?.onError(errorCode, "Component ${ComponentType.getName(componentType)}: $errorMessage") + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in state listener onError: ${e.message}", + ) + } + + updateHealthStatus() + } + + /** + * Get the health status callback. + * + * @return The current health status (see [HealthStatus]) + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun getHealthStatusCallback(): Int { + return healthStatus + } + + /** + * Set the health status callback. + * + * Called by C++ core when health status changes. + * + * @param status The new health status (see [HealthStatus]) + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun setHealthStatusCallback(status: Int) { + val previousStatus = healthStatus + if (status != previousStatus) { + healthStatus = status + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "Health status changed: ${HealthStatus.getName(previousStatus)} -> ${HealthStatus.getName(status)}", + ) + + // Notify listener + try { + stateListener?.onHealthStatusChanged(previousStatus, status) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in state listener onHealthStatusChanged: ${e.message}", + ) + } + } + } + + /** + * Set SDK error callback. + * + * Called by C++ core when an error occurs. + * + * @param errorCode The error code + * @param errorMessage The error message + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun setErrorCallback(errorCode: Int, errorMessage: String) { + lastErrorCode = errorCode + lastError = errorMessage + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "SDK error: [$errorCode] $errorMessage", + ) + + // Set SDK state to ERROR if it's a critical error + if (errorCode != 0) { + setSDKStateCallback(SDKState.ERROR) + } + + // Notify listener + try { + stateListener?.onError(errorCode, errorMessage) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in state listener onError: ${e.message}", + ) + } + } + + /** + * Clear error callback. + * + * Clears the last error state. + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun clearErrorCallback() { + lastErrorCode = 0 + lastError = null + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Error state cleared", + ) + } + + /** + * Check if SDK is ready callback. + * + * @return true if SDK is ready for use, false otherwise + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun isReadyCallback(): Boolean { + return SDKState.isUsable(sdkState) + } + + // ======================================================================== + // JNI NATIVE DECLARATIONS + // ======================================================================== + + /** + * Native method to set the state callbacks with C++ core. + * + * Registers [getSDKStateCallback], [setSDKStateCallback], + * [getComponentStateCallback], [setComponentStateCallback], etc. with C++ core. + * Reserved for future native callback integration. + * + * C API: rac_state_set_callbacks(...) + */ + @Suppress("unused") + @JvmStatic + private external fun nativeSetStateCallbacks() + + /** + * Native method to unset the state callbacks. + * + * Called during shutdown to clean up native resources. + * Reserved for future native callback integration. + * + * C API: rac_state_set_callbacks(nullptr) + */ + @Suppress("unused") + @JvmStatic + private external fun nativeUnsetStateCallbacks() + + /** + * Native method to get the C++ SDK state. + * + * @return The C++ SDK state + * + * C API: rac_get_state() + */ + @JvmStatic + external fun nativeGetState(): Int + + /** + * Native method to check if C++ SDK is initialized. + * + * @return true if initialized, false otherwise + * + * C API: rac_is_initialized() + */ + @JvmStatic + external fun nativeIsInitialized(): Boolean + + // ======================================================================== + // LIFECYCLE MANAGEMENT + // ======================================================================== + + /** + * Unregister the state callbacks and clean up resources. + * + * Called during SDK shutdown. + */ + fun unregister() { + synchronized(lock) { + if (!isRegistered) { + return + } + + // TODO: Call native unregistration + // nativeUnsetStateCallbacks() + + stateListener = null + componentStates.clear() + componentErrors.clear() + lastError = null + lastErrorCode = 0 + sdkState = SDKState.UNINITIALIZED + healthStatus = HealthStatus.HEALTHY + isRegistered = false + } + } + + // ======================================================================== + // UTILITY FUNCTIONS + // ======================================================================== + + /** + * Initialize component states to NOT_CREATED. + */ + private fun initializeComponentStates() { + componentStates[ComponentType.LLM] = ComponentState.NOT_CREATED + componentStates[ComponentType.STT] = ComponentState.NOT_CREATED + componentStates[ComponentType.TTS] = ComponentState.NOT_CREATED + componentStates[ComponentType.VAD] = ComponentState.NOT_CREATED + componentStates[ComponentType.VOICE_AGENT] = ComponentState.NOT_CREATED + } + + /** + * Update health status based on component states. + */ + private fun updateHealthStatus() { + val hasErrors = + synchronized(lock) { + componentStates.values.any { it == ComponentState.ERROR } + } + + val newStatus = + when { + sdkState == SDKState.ERROR -> HealthStatus.UNHEALTHY + hasErrors -> HealthStatus.DEGRADED + else -> HealthStatus.HEALTHY + } + + if (newStatus != healthStatus) { + setHealthStatusCallback(newStatus) + } + } + + /** + * Get the last error message. + * + * @return The last error message, or null if no error + */ + fun getLastError(): String? = lastError + + /** + * Get the last error code. + * + * @return The last error code, or 0 if no error + */ + fun getLastErrorCode(): Int = lastErrorCode + + /** + * Get the state of a specific component. + * + * @param componentType The component type (see [ComponentType]) + * @return The component state (see [ComponentState]) + */ + fun getComponentState(componentType: Int): Int { + return getComponentStateCallback(componentType) + } + + /** + * Get the error message for a component. + * + * @param componentType The component type + * @return The error message, or null if no error + */ + fun getComponentError(componentType: Int): String? { + return synchronized(lock) { + componentErrors[componentType] + } + } + + /** + * Get the health status. + * + * @return The current health status (see [HealthStatus]) + */ + fun getHealthStatus(): Int = healthStatus + + /** + * Get all component states as a map. + * + * @return Map of component type to state + */ + fun getAllComponentStates(): Map { + return synchronized(lock) { + componentStates.toMap() + } + } + + /** + * Set the SDK state. + * + * Used internally during initialization phases. + * + * @param state The new SDK state + */ + fun setState(state: Int) { + setSDKStateCallback(state) + } + + /** + * Clear all error states. + */ + fun clearErrors() { + synchronized(lock) { + lastError = null + lastErrorCode = 0 + componentErrors.clear() + + // Reset any components in ERROR state to NOT_CREATED + for (key in componentStates.keys) { + if (componentStates[key] == ComponentState.ERROR) { + componentStates[key] = ComponentState.NOT_CREATED + } + } + } + + updateHealthStatus() + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "All error states cleared", + ) + } + + /** + * Get a summary of the current state. + * + * @return Human-readable state summary + */ + fun getStateSummary(): String { + return buildString { + append("SDK State: ${SDKState.getName(sdkState)}") + append(", Health: ${HealthStatus.getName(healthStatus)}") + + val states = getAllComponentStates() + val ready = states.count { it.value == ComponentState.READY } + val errors = states.count { it.value == ComponentState.ERROR } + append(", Components: $ready ready, $errors errors") + + if (lastError != null) { + append(", Last Error: [$lastErrorCode] $lastError") + } + } + } +} diff --git a/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeStorage.kt b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeStorage.kt new file mode 100644 index 000000000..f178afe0f --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeStorage.kt @@ -0,0 +1,1048 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * Storage extension for CppBridge. + * Provides storage utilities and data persistence callbacks for C++ core. + * + * Follows iOS CppBridge+Storage.swift architecture. + */ + +package com.runanywhere.sdk.foundation.bridge.extensions + +import java.io.File +import java.util.concurrent.ConcurrentHashMap + +/** + * Storage bridge that provides storage utilities for C++ core operations. + * + * The C++ core needs storage functionality for: + * - Persisting SDK configuration and state + * - Managing cached data (model metadata, inference results) + * - Storing user preferences and settings + * - Temporary file management + * - Storage quota management and cleanup + * + * Usage: + * - Called during Phase 2 initialization in [CppBridge.initializeServices] + * - Must be registered after [CppBridgePlatformAdapter] and [CppBridgeModelPaths] are registered + * + * Thread Safety: + * - Registration is thread-safe via synchronized block + * - All callbacks are thread-safe + */ +object CppBridgeStorage { + /** + * Storage type constants matching C++ RAC_STORAGE_TYPE_* values. + */ + object StorageType { + /** In-memory storage (non-persistent) */ + const val MEMORY = 0 + + /** Disk-based storage (persistent) */ + const val DISK = 1 + + /** Cache storage (may be cleared by system) */ + const val CACHE = 2 + + /** Secure storage (encrypted, persistent) */ + const val SECURE = 3 + + /** Temporary storage (cleared on app restart) */ + const val TEMPORARY = 4 + + /** + * Get a human-readable name for the storage type. + */ + fun getName(type: Int): String = + when (type) { + MEMORY -> "MEMORY" + DISK -> "DISK" + CACHE -> "CACHE" + SECURE -> "SECURE" + TEMPORARY -> "TEMPORARY" + else -> "UNKNOWN($type)" + } + } + + /** + * Storage namespace constants for organizing stored data. + */ + object StorageNamespace { + /** SDK configuration data */ + const val CONFIG = "config" + + /** Model metadata and registry */ + const val MODELS = "models" + + /** Inference result cache */ + const val INFERENCE_CACHE = "inference_cache" + + /** User preferences */ + const val PREFERENCES = "preferences" + + /** Session data (temporary) */ + const val SESSION = "session" + + /** Analytics and telemetry data */ + const val ANALYTICS = "analytics" + + /** Download progress and state */ + const val DOWNLOADS = "downloads" + } + + /** + * Storage error codes. + */ + object StorageError { + /** No error */ + const val NONE = 0 + + /** Storage not initialized */ + const val NOT_INITIALIZED = 1 + + /** Key not found */ + const val KEY_NOT_FOUND = 2 + + /** Write failed */ + const val WRITE_FAILED = 3 + + /** Read failed */ + const val READ_FAILED = 4 + + /** Delete failed */ + const val DELETE_FAILED = 5 + + /** Storage full */ + const val STORAGE_FULL = 6 + + /** Invalid namespace */ + const val INVALID_NAMESPACE = 7 + + /** Permission denied */ + const val PERMISSION_DENIED = 8 + + /** Serialization error */ + const val SERIALIZATION_ERROR = 9 + + /** Unknown error */ + const val UNKNOWN = 99 + + /** + * Get a human-readable name for the error code. + */ + fun getName(error: Int): String = + when (error) { + NONE -> "NONE" + NOT_INITIALIZED -> "NOT_INITIALIZED" + KEY_NOT_FOUND -> "KEY_NOT_FOUND" + WRITE_FAILED -> "WRITE_FAILED" + READ_FAILED -> "READ_FAILED" + DELETE_FAILED -> "DELETE_FAILED" + STORAGE_FULL -> "STORAGE_FULL" + INVALID_NAMESPACE -> "INVALID_NAMESPACE" + PERMISSION_DENIED -> "PERMISSION_DENIED" + SERIALIZATION_ERROR -> "SERIALIZATION_ERROR" + UNKNOWN -> "UNKNOWN" + else -> "UNKNOWN($error)" + } + } + + @Volatile + private var isRegistered: Boolean = false + + private val lock = Any() + + /** + * Tag for logging. + */ + private const val TAG = "CppBridgeStorage" + + /** + * Default storage quota in bytes (100 MB). + */ + private const val DEFAULT_QUOTA_BYTES = 100L * 1024 * 1024 + + /** + * Default cache expiry in milliseconds (7 days). + */ + private const val DEFAULT_CACHE_EXPIRY_MS = 7L * 24 * 60 * 60 * 1000 + + /** + * In-memory storage for MEMORY type. + */ + private val memoryStorage = ConcurrentHashMap() + + /** + * Storage quota per namespace (in bytes). + */ + private val namespaceQuotas = ConcurrentHashMap() + + /** + * Storage usage per namespace (in bytes). + */ + private val namespaceUsage = ConcurrentHashMap() + + /** + * Optional listener for storage events. + * Set this before calling [register] to receive events. + */ + @Volatile + var storageListener: StorageListener? = null + + /** + * Optional provider for platform-specific storage. + * Set this on Android to provide proper app-specific storage. + */ + @Volatile + var storageProvider: StorageProvider? = null + + /** + * Listener interface for storage events. + */ + interface StorageListener { + /** + * Called when data is stored. + * + * @param namespace The storage namespace + * @param key The key + * @param size Size in bytes + */ + fun onDataStored(namespace: String, key: String, size: Long) + + /** + * Called when data is deleted. + * + * @param namespace The storage namespace + * @param key The key + */ + fun onDataDeleted(namespace: String, key: String) + + /** + * Called when storage is cleared. + * + * @param namespace The storage namespace + */ + fun onStorageCleared(namespace: String) + + /** + * Called when storage quota is exceeded. + * + * @param namespace The storage namespace + * @param usedBytes Current usage in bytes + * @param quotaBytes Quota limit in bytes + */ + fun onQuotaExceeded(namespace: String, usedBytes: Long, quotaBytes: Long) + } + + /** + * Provider interface for platform-specific storage operations. + */ + interface StorageProvider { + /** + * Get the storage directory for a namespace. + * + * @param namespace The storage namespace + * @param storageType The storage type + * @return The directory path + */ + fun getStorageDirectory(namespace: String, storageType: Int): String + + /** + * Check if storage is available. + * + * @return true if storage is available + */ + fun isStorageAvailable(): Boolean + + /** + * Get available storage space in bytes. + * + * @return Available bytes + */ + fun getAvailableSpace(): Long + } + + /** + * Register the storage callbacks with C++ core. + * + * This must be called during SDK initialization, after [CppBridgePlatformAdapter.register]. + * It is safe to call multiple times; subsequent calls are no-ops. + */ + fun register() { + synchronized(lock) { + if (isRegistered) { + return + } + + // Initialize default quotas + initializeDefaultQuotas() + + // Register the storage callbacks with C++ via JNI + // TODO: Call native registration + // nativeSetStorageCallbacks() + + isRegistered = true + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Storage callbacks registered", + ) + } + } + + /** + * Check if the storage callbacks are registered. + */ + fun isRegistered(): Boolean = isRegistered + + // ======================================================================== + // STORAGE CALLBACKS + // ======================================================================== + + /** + * Store data callback. + * + * Stores data in the specified namespace. + * + * @param namespace The storage namespace + * @param key The key to store under + * @param data The data to store + * @param storageType The storage type (see [StorageType]) + * @return 0 on success, error code on failure + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun storeDataCallback(namespace: String, key: String, data: ByteArray, storageType: Int): Int { + return try { + val fullKey = "$namespace:$key" + + // Check quota + val currentUsage = namespaceUsage.getOrDefault(namespace, 0L) + val quota = namespaceQuotas.getOrDefault(namespace, DEFAULT_QUOTA_BYTES) + + if (currentUsage + data.size > quota) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Storage quota exceeded for namespace '$namespace'", + ) + + try { + storageListener?.onQuotaExceeded(namespace, currentUsage, quota) + } catch (e: Exception) { + // Ignore listener errors + } + + return StorageError.STORAGE_FULL + } + + when (storageType) { + StorageType.MEMORY -> { + memoryStorage[fullKey] = data.copyOf() + } + StorageType.DISK, StorageType.CACHE, StorageType.TEMPORARY -> { + val file = getStorageFile(namespace, key, storageType) + file.parentFile?.mkdirs() + file.writeBytes(data) + } + StorageType.SECURE -> { + // Use platform adapter's secure storage + CppBridgePlatformAdapter.secureSetCallback(fullKey, data) + } + else -> { + return StorageError.INVALID_NAMESPACE + } + } + + // Update usage + namespaceUsage[namespace] = currentUsage + data.size + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Data stored: $namespace/$key (${data.size} bytes)", + ) + + // Notify listener + try { + storageListener?.onDataStored(namespace, key, data.size.toLong()) + } catch (e: Exception) { + // Ignore listener errors + } + + StorageError.NONE + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "Failed to store data: ${e.message}", + ) + StorageError.WRITE_FAILED + } + } + + /** + * Retrieve data callback. + * + * Retrieves data from the specified namespace. + * + * @param namespace The storage namespace + * @param key The key to retrieve + * @param storageType The storage type (see [StorageType]) + * @return The stored data, or null if not found + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun retrieveDataCallback(namespace: String, key: String, storageType: Int): ByteArray? { + return try { + val fullKey = "$namespace:$key" + + when (storageType) { + StorageType.MEMORY -> { + memoryStorage[fullKey] + } + StorageType.DISK, StorageType.CACHE, StorageType.TEMPORARY -> { + val file = getStorageFile(namespace, key, storageType) + if (file.exists()) file.readBytes() else null + } + StorageType.SECURE -> { + CppBridgePlatformAdapter.secureGetCallback(fullKey) + } + else -> null + } + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "Failed to retrieve data: ${e.message}", + ) + null + } + } + + /** + * Delete data callback. + * + * Deletes data from the specified namespace. + * + * @param namespace The storage namespace + * @param key The key to delete + * @param storageType The storage type (see [StorageType]) + * @return 0 on success, error code on failure + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun deleteDataCallback(namespace: String, key: String, storageType: Int): Int { + return try { + val fullKey = "$namespace:$key" + + when (storageType) { + StorageType.MEMORY -> { + memoryStorage.remove(fullKey) + } + StorageType.DISK, StorageType.CACHE, StorageType.TEMPORARY -> { + val file = getStorageFile(namespace, key, storageType) + if (file.exists()) { + val size = file.length() + if (file.delete()) { + // Update usage + val currentUsage = namespaceUsage.getOrDefault(namespace, 0L) + namespaceUsage[namespace] = maxOf(0L, currentUsage - size) + } + } + } + StorageType.SECURE -> { + CppBridgePlatformAdapter.secureDeleteCallback(fullKey) + } + else -> { + return StorageError.INVALID_NAMESPACE + } + } + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Data deleted: $namespace/$key", + ) + + // Notify listener + try { + storageListener?.onDataDeleted(namespace, key) + } catch (e: Exception) { + // Ignore listener errors + } + + StorageError.NONE + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "Failed to delete data: ${e.message}", + ) + StorageError.DELETE_FAILED + } + } + + /** + * Check if data exists callback. + * + * @param namespace The storage namespace + * @param key The key to check + * @param storageType The storage type (see [StorageType]) + * @return true if data exists + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun hasDataCallback(namespace: String, key: String, storageType: Int): Boolean { + return try { + val fullKey = "$namespace:$key" + + when (storageType) { + StorageType.MEMORY -> { + memoryStorage.containsKey(fullKey) + } + StorageType.DISK, StorageType.CACHE, StorageType.TEMPORARY -> { + getStorageFile(namespace, key, storageType).exists() + } + StorageType.SECURE -> { + CppBridgePlatformAdapter.secureGetCallback(fullKey) != null + } + else -> false + } + } catch (e: Exception) { + false + } + } + + /** + * List keys callback. + * + * Lists all keys in a namespace. + * + * @param namespace The storage namespace + * @param storageType The storage type (see [StorageType]) + * @return JSON-encoded array of keys + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun listKeysCallback(namespace: String, storageType: Int): String { + return try { + val keys = + when (storageType) { + StorageType.MEMORY -> { + val prefix = "$namespace:" + memoryStorage.keys + .filter { it.startsWith(prefix) } + .map { it.removePrefix(prefix) } + } + StorageType.DISK, StorageType.CACHE, StorageType.TEMPORARY -> { + val dir = getStorageDirectory(namespace, storageType) + dir.listFiles()?.map { it.name } ?: emptyList() + } + else -> emptyList() + } + + buildString { + append("[") + keys.forEachIndexed { index, key -> + if (index > 0) append(",") + append("\"${escapeJson(key)}\"") + } + append("]") + } + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "Failed to list keys: ${e.message}", + ) + "[]" + } + } + + /** + * Clear namespace callback. + * + * Clears all data in a namespace. + * + * @param namespace The storage namespace + * @param storageType The storage type (see [StorageType]) + * @return 0 on success, error code on failure + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun clearNamespaceCallback(namespace: String, storageType: Int): Int { + return try { + when (storageType) { + StorageType.MEMORY -> { + val prefix = "$namespace:" + memoryStorage.keys + .filter { it.startsWith(prefix) } + .forEach { memoryStorage.remove(it) } + } + StorageType.DISK, StorageType.CACHE, StorageType.TEMPORARY -> { + val dir = getStorageDirectory(namespace, storageType) + dir.deleteRecursively() + dir.mkdirs() + } + else -> { + return StorageError.INVALID_NAMESPACE + } + } + + // Reset usage + namespaceUsage[namespace] = 0L + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Namespace cleared: $namespace", + ) + + // Notify listener + try { + storageListener?.onStorageCleared(namespace) + } catch (e: Exception) { + // Ignore listener errors + } + + StorageError.NONE + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "Failed to clear namespace: ${e.message}", + ) + StorageError.DELETE_FAILED + } + } + + /** + * Get storage usage callback. + * + * Returns storage usage for a namespace. + * + * @param namespace The storage namespace + * @return Usage in bytes + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun getStorageUsageCallback(namespace: String): Long { + return namespaceUsage.getOrDefault(namespace, 0L) + } + + /** + * Get storage quota callback. + * + * Returns storage quota for a namespace. + * + * @param namespace The storage namespace + * @return Quota in bytes + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun getStorageQuotaCallback(namespace: String): Long { + return namespaceQuotas.getOrDefault(namespace, DEFAULT_QUOTA_BYTES) + } + + /** + * Set storage quota callback. + * + * Sets storage quota for a namespace. + * + * @param namespace The storage namespace + * @param quotaBytes Quota in bytes + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun setStorageQuotaCallback(namespace: String, quotaBytes: Long) { + namespaceQuotas[namespace] = quotaBytes + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Storage quota set: $namespace = ${quotaBytes / (1024 * 1024)}MB", + ) + } + + /** + * Cleanup expired cache callback. + * + * Removes expired entries from cache storage. + * + * @param maxAgeMs Maximum age in milliseconds + * @return Number of entries cleaned up + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun cleanupExpiredCacheCallback(maxAgeMs: Long): Int { + return try { + var cleanedCount = 0 + val cutoffTime = System.currentTimeMillis() - maxAgeMs + + // Clean up cache storage + val cacheDir = getStorageDirectory(StorageNamespace.INFERENCE_CACHE, StorageType.CACHE) + cacheDir.listFiles()?.forEach { file -> + if (file.lastModified() < cutoffTime) { + val size = file.length() + if (file.delete()) { + cleanedCount++ + // Update usage + val currentUsage = namespaceUsage.getOrDefault(StorageNamespace.INFERENCE_CACHE, 0L) + namespaceUsage[StorageNamespace.INFERENCE_CACHE] = maxOf(0L, currentUsage - size) + } + } + } + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Cleaned up $cleanedCount expired cache entries", + ) + + cleanedCount + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "Failed to cleanup expired cache: ${e.message}", + ) + 0 + } + } + + // ======================================================================== + // JNI NATIVE DECLARATIONS + // ======================================================================== + + /** + * Native method to set the storage callbacks with C++ core. + * Reserved for future native callback integration. + * + * C API: rac_storage_set_callbacks(...) + */ + @Suppress("unused") + @JvmStatic + private external fun nativeSetStorageCallbacks() + + /** + * Native method to unset the storage callbacks. + * Reserved for future native callback integration. + * + * C API: rac_storage_set_callbacks(nullptr) + */ + @Suppress("unused") + @JvmStatic + private external fun nativeUnsetStorageCallbacks() + + /** + * Native method to store data in C++ storage. + * + * C API: rac_storage_store(namespace, key, data, size, type) + */ + @JvmStatic + external fun nativeStore(namespace: String, key: String, data: ByteArray, storageType: Int): Int + + /** + * Native method to retrieve data from C++ storage. + * + * C API: rac_storage_retrieve(namespace, key, type) + */ + @JvmStatic + external fun nativeRetrieve(namespace: String, key: String, storageType: Int): ByteArray? + + /** + * Native method to delete data from C++ storage. + * + * C API: rac_storage_delete(namespace, key, type) + */ + @JvmStatic + external fun nativeDelete(namespace: String, key: String, storageType: Int): Int + + /** + * Native method to check if data exists in C++ storage. + * + * C API: rac_storage_has(namespace, key, type) + */ + @JvmStatic + external fun nativeHas(namespace: String, key: String, storageType: Int): Boolean + + // ======================================================================== + // LIFECYCLE MANAGEMENT + // ======================================================================== + + /** + * Unregister the storage callbacks and clean up resources. + * + * Called during SDK shutdown. + */ + fun unregister() { + synchronized(lock) { + if (!isRegistered) { + return + } + + // TODO: Call native unregistration + // nativeUnsetStorageCallbacks() + + storageListener = null + storageProvider = null + memoryStorage.clear() + namespaceQuotas.clear() + namespaceUsage.clear() + isRegistered = false + } + } + + // ======================================================================== + // UTILITY FUNCTIONS + // ======================================================================== + + /** + * Store data in the specified namespace. + * + * @param namespace The storage namespace + * @param key The key to store under + * @param data The data to store + * @param storageType The storage type (default: DISK) + * @return true if stored successfully + */ + fun store( + namespace: String, + key: String, + data: ByteArray, + storageType: Int = StorageType.DISK, + ): Boolean { + return storeDataCallback(namespace, key, data, storageType) == StorageError.NONE + } + + /** + * Store a string in the specified namespace. + * + * @param namespace The storage namespace + * @param key The key to store under + * @param value The string to store + * @param storageType The storage type (default: DISK) + * @return true if stored successfully + */ + fun storeString( + namespace: String, + key: String, + value: String, + storageType: Int = StorageType.DISK, + ): Boolean { + return store(namespace, key, value.toByteArray(Charsets.UTF_8), storageType) + } + + /** + * Retrieve data from the specified namespace. + * + * @param namespace The storage namespace + * @param key The key to retrieve + * @param storageType The storage type (default: DISK) + * @return The stored data, or null if not found + */ + fun retrieve( + namespace: String, + key: String, + storageType: Int = StorageType.DISK, + ): ByteArray? { + return retrieveDataCallback(namespace, key, storageType) + } + + /** + * Retrieve a string from the specified namespace. + * + * @param namespace The storage namespace + * @param key The key to retrieve + * @param storageType The storage type (default: DISK) + * @return The stored string, or null if not found + */ + fun retrieveString( + namespace: String, + key: String, + storageType: Int = StorageType.DISK, + ): String? { + return retrieve(namespace, key, storageType)?.toString(Charsets.UTF_8) + } + + /** + * Delete data from the specified namespace. + * + * @param namespace The storage namespace + * @param key The key to delete + * @param storageType The storage type (default: DISK) + * @return true if deleted successfully + */ + fun delete( + namespace: String, + key: String, + storageType: Int = StorageType.DISK, + ): Boolean { + return deleteDataCallback(namespace, key, storageType) == StorageError.NONE + } + + /** + * Check if data exists in the specified namespace. + * + * @param namespace The storage namespace + * @param key The key to check + * @param storageType The storage type (default: DISK) + * @return true if data exists + */ + fun has( + namespace: String, + key: String, + storageType: Int = StorageType.DISK, + ): Boolean { + return hasDataCallback(namespace, key, storageType) + } + + /** + * List all keys in a namespace. + * + * @param namespace The storage namespace + * @param storageType The storage type (default: DISK) + * @return List of keys + */ + fun listKeys( + namespace: String, + storageType: Int = StorageType.DISK, + ): List { + val json = listKeysCallback(namespace, storageType) + // Simple JSON array parsing + return json + .trim() + .removePrefix("[") + .removeSuffix("]") + .split(",") + .filter { it.isNotBlank() } + .map { it.trim().removeSurrounding("\"") } + } + + /** + * Clear all data in a namespace. + * + * @param namespace The storage namespace + * @param storageType The storage type (default: DISK) + * @return true if cleared successfully + */ + fun clear( + namespace: String, + storageType: Int = StorageType.DISK, + ): Boolean { + return clearNamespaceCallback(namespace, storageType) == StorageError.NONE + } + + /** + * Get storage usage for a namespace. + * + * @param namespace The storage namespace + * @return Usage in bytes + */ + fun getUsage(namespace: String): Long { + return getStorageUsageCallback(namespace) + } + + /** + * Get storage quota for a namespace. + * + * @param namespace The storage namespace + * @return Quota in bytes + */ + fun getQuota(namespace: String): Long { + return getStorageQuotaCallback(namespace) + } + + /** + * Set storage quota for a namespace. + * + * @param namespace The storage namespace + * @param quotaBytes Quota in bytes + */ + fun setQuota(namespace: String, quotaBytes: Long) { + setStorageQuotaCallback(namespace, quotaBytes) + } + + /** + * Cleanup expired cache entries. + * + * @param maxAgeMs Maximum age in milliseconds (default: 7 days) + * @return Number of entries cleaned up + */ + fun cleanupExpiredCache(maxAgeMs: Long = DEFAULT_CACHE_EXPIRY_MS): Int { + return cleanupExpiredCacheCallback(maxAgeMs) + } + + /** + * Clear all in-memory storage. + */ + fun clearMemoryStorage() { + memoryStorage.clear() + } + + /** + * Get the storage file for a key. + */ + private fun getStorageFile(namespace: String, key: String, storageType: Int): File { + val dir = getStorageDirectory(namespace, storageType) + return File(dir, key) + } + + /** + * Get the storage directory for a namespace. + */ + private fun getStorageDirectory(namespace: String, storageType: Int): File { + val provider = storageProvider + if (provider != null) { + return File(provider.getStorageDirectory(namespace, storageType)) + } + + val baseDir = CppBridgeModelPaths.getBaseDirectory() + val typeDir = + when (storageType) { + StorageType.CACHE -> "cache" + StorageType.TEMPORARY -> "temp" + else -> "data" + } + + return File(File(baseDir, typeDir), namespace) + } + + /** + * Initialize default storage quotas. + */ + private fun initializeDefaultQuotas() { + namespaceQuotas[StorageNamespace.CONFIG] = 10L * 1024 * 1024 // 10 MB + namespaceQuotas[StorageNamespace.MODELS] = 50L * 1024 * 1024 // 50 MB + namespaceQuotas[StorageNamespace.INFERENCE_CACHE] = 100L * 1024 * 1024 // 100 MB + namespaceQuotas[StorageNamespace.PREFERENCES] = 1L * 1024 * 1024 // 1 MB + namespaceQuotas[StorageNamespace.SESSION] = 10L * 1024 * 1024 // 10 MB + namespaceQuotas[StorageNamespace.ANALYTICS] = 20L * 1024 * 1024 // 20 MB + namespaceQuotas[StorageNamespace.DOWNLOADS] = 10L * 1024 * 1024 // 10 MB + } + + /** + * Escape special characters for JSON string. + */ + private fun escapeJson(value: String): String { + return value + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + } +} diff --git a/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeStrategy.kt b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeStrategy.kt new file mode 100644 index 000000000..f6a0f0bc3 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeStrategy.kt @@ -0,0 +1,1204 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * Strategy extension for CppBridge. + * Provides execution strategy management callbacks for C++ core. + * + * Follows iOS CppBridge+Strategy.swift architecture. + */ + +package com.runanywhere.sdk.foundation.bridge.extensions + +import java.util.concurrent.ConcurrentHashMap + +/** + * Strategy bridge that provides execution strategy management for C++ core. + * + * The C++ core needs strategy functionality for: + * - Selecting execution strategy (on-device, cloud, hybrid) + * - Managing model execution preferences per component type + * - Handling fallback strategies when primary fails + * - Adapting to device capabilities and network conditions + * - Optimizing for latency, quality, or cost + * + * Usage: + * - Called during Phase 2 initialization in [CppBridge.initializeServices] + * - Must be registered after [CppBridgePlatformAdapter] and [CppBridgeModelRegistry] are registered + * + * Thread Safety: + * - Registration is thread-safe via synchronized block + * - All callbacks are thread-safe + */ +object CppBridgeStrategy { + /** + * Execution strategy type constants matching C++ RAC_STRATEGY_TYPE_* values. + */ + object StrategyType { + /** Execute on-device using local models */ + const val ON_DEVICE = 0 + + /** Execute in the cloud using remote APIs */ + const val CLOUD = 1 + + /** Hybrid: try on-device first, fallback to cloud */ + const val HYBRID_LOCAL_FIRST = 2 + + /** Hybrid: try cloud first, fallback to on-device */ + const val HYBRID_CLOUD_FIRST = 3 + + /** Automatic: SDK decides based on conditions */ + const val AUTO = 4 + + /** + * Get a human-readable name for the strategy type. + */ + fun getName(type: Int): String = + when (type) { + ON_DEVICE -> "ON_DEVICE" + CLOUD -> "CLOUD" + HYBRID_LOCAL_FIRST -> "HYBRID_LOCAL_FIRST" + HYBRID_CLOUD_FIRST -> "HYBRID_CLOUD_FIRST" + AUTO -> "AUTO" + else -> "UNKNOWN($type)" + } + + /** + * Check if the strategy type uses on-device execution. + */ + fun usesOnDevice(type: Int): Boolean = type in listOf(ON_DEVICE, HYBRID_LOCAL_FIRST, HYBRID_CLOUD_FIRST, AUTO) + + /** + * Check if the strategy type uses cloud execution. + */ + fun usesCloud(type: Int): Boolean = type in listOf(CLOUD, HYBRID_LOCAL_FIRST, HYBRID_CLOUD_FIRST, AUTO) + } + + /** + * Strategy optimization target constants. + */ + object OptimizationTarget { + /** Optimize for lowest latency */ + const val LATENCY = 0 + + /** Optimize for best quality */ + const val QUALITY = 1 + + /** Optimize for lowest cost */ + const val COST = 2 + + /** Optimize for power efficiency */ + const val POWER = 3 + + /** Balanced optimization across all factors */ + const val BALANCED = 4 + + /** + * Get a human-readable name for the optimization target. + */ + fun getName(target: Int): String = + when (target) { + LATENCY -> "LATENCY" + QUALITY -> "QUALITY" + COST -> "COST" + POWER -> "POWER" + BALANCED -> "BALANCED" + else -> "UNKNOWN($target)" + } + } + + /** + * Strategy decision reason constants. + */ + object StrategyReason { + /** User preference */ + const val USER_PREFERENCE = 0 + + /** Model not available locally */ + const val MODEL_NOT_AVAILABLE = 1 + + /** Model not downloaded */ + const val MODEL_NOT_DOWNLOADED = 2 + + /** Insufficient device resources (memory, storage) */ + const val INSUFFICIENT_RESOURCES = 3 + + /** Network not available */ + const val NETWORK_UNAVAILABLE = 4 + + /** Cloud API quota exceeded */ + const val CLOUD_QUOTA_EXCEEDED = 5 + + /** Primary strategy failed, using fallback */ + const val FALLBACK = 6 + + /** Automatic decision by SDK */ + const val AUTO_DECISION = 7 + + /** Device battery low */ + const val LOW_BATTERY = 8 + + /** + * Get a human-readable name for the decision reason. + */ + fun getName(reason: Int): String = + when (reason) { + USER_PREFERENCE -> "USER_PREFERENCE" + MODEL_NOT_AVAILABLE -> "MODEL_NOT_AVAILABLE" + MODEL_NOT_DOWNLOADED -> "MODEL_NOT_DOWNLOADED" + INSUFFICIENT_RESOURCES -> "INSUFFICIENT_RESOURCES" + NETWORK_UNAVAILABLE -> "NETWORK_UNAVAILABLE" + CLOUD_QUOTA_EXCEEDED -> "CLOUD_QUOTA_EXCEEDED" + FALLBACK -> "FALLBACK" + AUTO_DECISION -> "AUTO_DECISION" + LOW_BATTERY -> "LOW_BATTERY" + else -> "UNKNOWN($reason)" + } + } + + /** + * Component type constants for strategy configuration. + */ + object ComponentType { + /** LLM component */ + const val LLM = 0 + + /** STT component */ + const val STT = 1 + + /** TTS component */ + const val TTS = 2 + + /** VAD component */ + const val VAD = 3 + + /** Voice Agent (combined pipeline) */ + const val VOICE_AGENT = 4 + + /** Embedding component */ + const val EMBEDDING = 5 + + /** + * Get a human-readable name for the component type. + */ + fun getName(type: Int): String = + when (type) { + LLM -> "LLM" + STT -> "STT" + TTS -> "TTS" + VAD -> "VAD" + VOICE_AGENT -> "VOICE_AGENT" + EMBEDDING -> "EMBEDDING" + else -> "UNKNOWN($type)" + } + } + + @Volatile + private var isRegistered: Boolean = false + + private val lock = Any() + + /** + * Tag for logging. + */ + private const val TAG = "CppBridgeStrategy" + + /** + * Global default strategy. + */ + @Volatile + private var defaultStrategy: Int = StrategyType.AUTO + + /** + * Global optimization target. + */ + @Volatile + private var optimizationTarget: Int = OptimizationTarget.BALANCED + + /** + * Per-component strategy configuration. + */ + private val componentStrategies = ConcurrentHashMap() + + /** + * Per-component optimization targets. + */ + private val componentOptimizations = ConcurrentHashMap() + + /** + * Strategy capability flags. + */ + private val strategyCapabilities = ConcurrentHashMap() + + /** + * Optional listener for strategy events. + * Set this before calling [register] to receive events. + */ + @Volatile + var strategyListener: StrategyListener? = null + + /** + * Optional provider for device capabilities. + * Set this to customize capability detection. + */ + @Volatile + var capabilityProvider: CapabilityProvider? = null + + /** + * Strategy capabilities data class. + */ + data class StrategyCapabilities( + val supportsOnDevice: Boolean = true, + val supportsCloud: Boolean = true, + val hasLocalModel: Boolean = false, + val hasNetworkAccess: Boolean = true, + val availableMemoryMB: Long = 0, + val availableStorageMB: Long = 0, + val batteryLevel: Int = 100, + val isCharging: Boolean = false, + ) { + /** + * Check if on-device execution is viable. + */ + fun canExecuteOnDevice(): Boolean { + return supportsOnDevice && hasLocalModel && availableMemoryMB > 100 + } + + /** + * Check if cloud execution is viable. + */ + fun canExecuteOnCloud(): Boolean { + return supportsCloud && hasNetworkAccess + } + } + + /** + * Strategy decision result data class. + */ + data class StrategyDecision( + val strategy: Int, + val reason: Int, + val componentType: Int, + val canFallback: Boolean, + val fallbackStrategy: Int?, + ) { + /** + * Get the strategy name. + */ + fun getStrategyName(): String = StrategyType.getName(strategy) + + /** + * Get the reason name. + */ + fun getReasonName(): String = StrategyReason.getName(reason) + + /** + * Get the component name. + */ + fun getComponentName(): String = ComponentType.getName(componentType) + } + + /** + * Listener interface for strategy events. + */ + interface StrategyListener { + /** + * Called when the default strategy changes. + * + * @param previousStrategy The previous strategy + * @param newStrategy The new strategy + */ + fun onDefaultStrategyChanged(previousStrategy: Int, newStrategy: Int) + + /** + * Called when a component strategy changes. + * + * @param componentType The component type + * @param previousStrategy The previous strategy + * @param newStrategy The new strategy + */ + fun onComponentStrategyChanged(componentType: Int, previousStrategy: Int, newStrategy: Int) + + /** + * Called when a strategy decision is made. + * + * @param decision The strategy decision + */ + fun onStrategyDecision(decision: StrategyDecision) + + /** + * Called when a fallback is triggered. + * + * @param componentType The component type + * @param failedStrategy The strategy that failed + * @param fallbackStrategy The fallback strategy + * @param reason The failure reason + */ + fun onFallbackTriggered(componentType: Int, failedStrategy: Int, fallbackStrategy: Int, reason: String) + } + + /** + * Provider interface for device capability detection. + */ + interface CapabilityProvider { + /** + * Get current device capabilities. + * + * @return Current capabilities + */ + fun getCapabilities(): StrategyCapabilities + + /** + * Check if network is available. + * + * @return true if network is available + */ + fun isNetworkAvailable(): Boolean + + /** + * Get available memory in MB. + * + * @return Available memory in MB + */ + fun getAvailableMemoryMB(): Long + + /** + * Get battery level (0-100). + * + * @return Battery level percentage + */ + fun getBatteryLevel(): Int + + /** + * Check if device is charging. + * + * @return true if charging + */ + fun isCharging(): Boolean + } + + /** + * Register the strategy callbacks with C++ core. + * + * This must be called during SDK initialization, after [CppBridgePlatformAdapter.register]. + * It is safe to call multiple times; subsequent calls are no-ops. + */ + fun register() { + synchronized(lock) { + if (isRegistered) { + return + } + + // Initialize default component strategies + initializeDefaultStrategies() + + // Register the strategy callbacks with C++ via JNI + // TODO: Call native registration + // nativeSetStrategyCallbacks() + + isRegistered = true + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Strategy callbacks registered. Default: ${StrategyType.getName(defaultStrategy)}", + ) + } + } + + /** + * Check if the strategy callbacks are registered. + */ + fun isRegistered(): Boolean = isRegistered + + // ======================================================================== + // STRATEGY CALLBACKS + // ======================================================================== + + /** + * Get strategy callback. + * + * Returns the current strategy for a component. + * + * @param componentType The component type (see [ComponentType]) + * @return The strategy type (see [StrategyType]) + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun getStrategyCallback(componentType: Int): Int { + return componentStrategies.getOrDefault(componentType, defaultStrategy) + } + + /** + * Set strategy callback. + * + * Sets the strategy for a component. + * + * @param componentType The component type (see [ComponentType]) + * @param strategy The strategy type (see [StrategyType]) + * @return 0 on success, error code on failure + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun setStrategyCallback(componentType: Int, strategy: Int): Int { + val previousStrategy = componentStrategies.put(componentType, strategy) ?: defaultStrategy + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Strategy set: ${ComponentType.getName(componentType)} = ${StrategyType.getName(strategy)}", + ) + + // Notify listener + try { + if (previousStrategy != strategy) { + strategyListener?.onComponentStrategyChanged(componentType, previousStrategy, strategy) + } + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in strategy listener: ${e.message}", + ) + } + + return 0 + } + + /** + * Get default strategy callback. + * + * @return The default strategy type + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun getDefaultStrategyCallback(): Int { + return defaultStrategy + } + + /** + * Set default strategy callback. + * + * @param strategy The default strategy type + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun setDefaultStrategyCallback(strategy: Int) { + val previousStrategy = defaultStrategy + defaultStrategy = strategy + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Default strategy set: ${StrategyType.getName(strategy)}", + ) + + // Notify listener + try { + if (previousStrategy != strategy) { + strategyListener?.onDefaultStrategyChanged(previousStrategy, strategy) + } + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in strategy listener: ${e.message}", + ) + } + } + + /** + * Get optimization target callback. + * + * @param componentType The component type (see [ComponentType]) + * @return The optimization target (see [OptimizationTarget]) + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun getOptimizationTargetCallback(componentType: Int): Int { + return componentOptimizations.getOrDefault(componentType, optimizationTarget) + } + + /** + * Set optimization target callback. + * + * @param componentType The component type (see [ComponentType]) + * @param target The optimization target (see [OptimizationTarget]) + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun setOptimizationTargetCallback(componentType: Int, target: Int) { + componentOptimizations[componentType] = target + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Optimization target set: ${ComponentType.getName(componentType)} = ${OptimizationTarget.getName(target)}", + ) + } + + /** + * Decide strategy callback. + * + * Makes a strategy decision based on current conditions. + * + * @param componentType The component type (see [ComponentType]) + * @param modelId The model ID (optional) + * @return JSON-encoded strategy decision + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun decideStrategyCallback(componentType: Int, modelId: String?): String { + val decision = makeStrategyDecision(componentType, modelId) + + // Notify listener + try { + strategyListener?.onStrategyDecision(decision) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in strategy listener: ${e.message}", + ) + } + + return buildString { + append("{") + append("\"strategy\":${decision.strategy},") + append("\"reason\":${decision.reason},") + append("\"component_type\":${decision.componentType},") + append("\"can_fallback\":${decision.canFallback},") + append("\"fallback_strategy\":${decision.fallbackStrategy ?: "null"}") + append("}") + } + } + + /** + * Report strategy failure callback. + * + * Reports a strategy execution failure and triggers fallback if available. + * + * @param componentType The component type + * @param failedStrategy The strategy that failed + * @param errorMessage The error message + * @return The fallback strategy, or -1 if no fallback available + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun reportStrategyFailureCallback(componentType: Int, failedStrategy: Int, errorMessage: String): Int { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Strategy failed: ${ComponentType.getName(componentType)} ${StrategyType.getName(failedStrategy)} - $errorMessage", + ) + + // Determine fallback + val fallback = determineFallbackStrategy(componentType, failedStrategy) + + if (fallback >= 0) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "Falling back to: ${StrategyType.getName(fallback)}", + ) + + // Notify listener + try { + strategyListener?.onFallbackTriggered(componentType, failedStrategy, fallback, errorMessage) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in strategy listener: ${e.message}", + ) + } + } + + return fallback + } + + /** + * Get capabilities callback. + * + * Returns current device capabilities as JSON. + * + * @return JSON-encoded capabilities + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun getCapabilitiesCallback(): String { + val caps = getCurrentCapabilities() + + return buildString { + append("{") + append("\"supports_on_device\":${caps.supportsOnDevice},") + append("\"supports_cloud\":${caps.supportsCloud},") + append("\"has_local_model\":${caps.hasLocalModel},") + append("\"has_network_access\":${caps.hasNetworkAccess},") + append("\"available_memory_mb\":${caps.availableMemoryMB},") + append("\"available_storage_mb\":${caps.availableStorageMB},") + append("\"battery_level\":${caps.batteryLevel},") + append("\"is_charging\":${caps.isCharging}") + append("}") + } + } + + /** + * Update capabilities callback. + * + * Updates cached capabilities for a component. + * + * @param componentType The component type + * @param capabilitiesJson JSON-encoded capabilities + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun updateCapabilitiesCallback(componentType: Int, capabilitiesJson: String) { + try { + val caps = parseCapabilitiesJson(capabilitiesJson) + strategyCapabilities[componentType] = caps + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Capabilities updated for ${ComponentType.getName(componentType)}", + ) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Failed to parse capabilities: ${e.message}", + ) + } + } + + /** + * Check if strategy is available callback. + * + * @param componentType The component type + * @param strategy The strategy to check + * @return true if the strategy is available + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun isStrategyAvailableCallback(componentType: Int, strategy: Int): Boolean { + val caps = strategyCapabilities[componentType] ?: getCurrentCapabilities() + + return when (strategy) { + StrategyType.ON_DEVICE -> caps.canExecuteOnDevice() + StrategyType.CLOUD -> caps.canExecuteOnCloud() + StrategyType.HYBRID_LOCAL_FIRST, StrategyType.HYBRID_CLOUD_FIRST -> { + caps.canExecuteOnDevice() || caps.canExecuteOnCloud() + } + StrategyType.AUTO -> true + else -> false + } + } + + // ======================================================================== + // JNI NATIVE DECLARATIONS + // ======================================================================== + + /** + * Native method to set the strategy callbacks with C++ core. + * Reserved for future native callback integration. + * + * C API: rac_strategy_set_callbacks(...) + */ + @Suppress("unused") + @JvmStatic + private external fun nativeSetStrategyCallbacks() + + /** + * Native method to unset the strategy callbacks. + * Reserved for future native callback integration. + * + * C API: rac_strategy_set_callbacks(nullptr) + */ + @Suppress("unused") + @JvmStatic + private external fun nativeUnsetStrategyCallbacks() + + /** + * Native method to get the current strategy from C++ core. + * + * C API: rac_strategy_get(component_type) + */ + @JvmStatic + external fun nativeGet(componentType: Int): Int + + /** + * Native method to set the strategy in C++ core. + * + * C API: rac_strategy_set(component_type, strategy) + */ + @JvmStatic + external fun nativeSet(componentType: Int, strategy: Int): Int + + /** + * Native method to decide strategy in C++ core. + * + * C API: rac_strategy_decide(component_type, model_id) + */ + @JvmStatic + external fun nativeDecide(componentType: Int, modelId: String?): String + + // ======================================================================== + // LIFECYCLE MANAGEMENT + // ======================================================================== + + /** + * Unregister the strategy callbacks and clean up resources. + * + * Called during SDK shutdown. + */ + fun unregister() { + synchronized(lock) { + if (!isRegistered) { + return + } + + // TODO: Call native unregistration + // nativeUnsetStrategyCallbacks() + + strategyListener = null + capabilityProvider = null + componentStrategies.clear() + componentOptimizations.clear() + strategyCapabilities.clear() + isRegistered = false + } + } + + // ======================================================================== + // UTILITY FUNCTIONS + // ======================================================================== + + /** + * Get the current strategy for a component. + * + * @param componentType The component type + * @return The strategy type + */ + fun getStrategy(componentType: Int): Int { + return getStrategyCallback(componentType) + } + + /** + * Set the strategy for a component. + * + * @param componentType The component type + * @param strategy The strategy type + */ + fun setStrategy(componentType: Int, strategy: Int) { + setStrategyCallback(componentType, strategy) + } + + /** + * Get the default strategy. + * + * @return The default strategy type + */ + fun getDefaultStrategy(): Int { + return getDefaultStrategyCallback() + } + + /** + * Set the default strategy. + * + * @param strategy The strategy type + */ + fun setDefaultStrategy(strategy: Int) { + setDefaultStrategyCallback(strategy) + } + + /** + * Get the optimization target for a component. + * + * @param componentType The component type + * @return The optimization target + */ + fun getOptimizationTarget(componentType: Int): Int { + return getOptimizationTargetCallback(componentType) + } + + /** + * Set the optimization target for a component. + * + * @param componentType The component type + * @param target The optimization target + */ + fun setOptimizationTarget(componentType: Int, target: Int) { + setOptimizationTargetCallback(componentType, target) + } + + /** + * Set the global optimization target. + * + * @param target The optimization target + */ + fun setGlobalOptimizationTarget(target: Int) { + optimizationTarget = target + } + + /** + * Make a strategy decision for a component. + * + * @param componentType The component type + * @param modelId Optional model ID + * @return The strategy decision + */ + fun decideStrategy(componentType: Int, modelId: String? = null): StrategyDecision { + return makeStrategyDecision(componentType, modelId) + } + + /** + * Report a strategy failure. + * + * @param componentType The component type + * @param failedStrategy The strategy that failed + * @param errorMessage The error message + * @return The fallback strategy, or null if none available + */ + fun reportFailure(componentType: Int, failedStrategy: Int, errorMessage: String): Int? { + val fallback = reportStrategyFailureCallback(componentType, failedStrategy, errorMessage) + return if (fallback >= 0) fallback else null + } + + /** + * Check if a strategy is available for a component. + * + * @param componentType The component type + * @param strategy The strategy to check + * @return true if available + */ + fun isStrategyAvailable(componentType: Int, strategy: Int): Boolean { + return isStrategyAvailableCallback(componentType, strategy) + } + + /** + * Get current device capabilities. + * + * @return Current capabilities + */ + fun getCapabilities(): StrategyCapabilities { + return getCurrentCapabilities() + } + + /** + * Set capabilities for a component. + * + * @param componentType The component type + * @param capabilities The capabilities + */ + fun setCapabilities(componentType: Int, capabilities: StrategyCapabilities) { + strategyCapabilities[componentType] = capabilities + } + + /** + * Set on-device-only strategy for all components. + * + * Useful for offline mode. + */ + fun setOnDeviceOnly() { + setDefaultStrategy(StrategyType.ON_DEVICE) + for (type in listOf( + ComponentType.LLM, + ComponentType.STT, + ComponentType.TTS, + ComponentType.VAD, + ComponentType.VOICE_AGENT, + ComponentType.EMBEDDING, + )) { + setStrategy(type, StrategyType.ON_DEVICE) + } + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "Switched to on-device only mode", + ) + } + + /** + * Set cloud-only strategy for all components. + */ + fun setCloudOnly() { + setDefaultStrategy(StrategyType.CLOUD) + for (type in listOf( + ComponentType.LLM, + ComponentType.STT, + ComponentType.TTS, + ComponentType.VOICE_AGENT, + )) { + setStrategy(type, StrategyType.CLOUD) + } + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "Switched to cloud only mode", + ) + } + + /** + * Set hybrid strategy (local first) for all components. + */ + fun setHybridLocalFirst() { + setDefaultStrategy(StrategyType.HYBRID_LOCAL_FIRST) + for (type in listOf( + ComponentType.LLM, + ComponentType.STT, + ComponentType.TTS, + ComponentType.VOICE_AGENT, + )) { + setStrategy(type, StrategyType.HYBRID_LOCAL_FIRST) + } + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "Switched to hybrid (local first) mode", + ) + } + + /** + * Set auto strategy for all components. + */ + fun setAuto() { + setDefaultStrategy(StrategyType.AUTO) + componentStrategies.clear() + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "Switched to auto strategy mode", + ) + } + + /** + * Make a strategy decision based on current conditions. + */ + private fun makeStrategyDecision(componentType: Int, modelId: String?): StrategyDecision { + val configuredStrategy = componentStrategies.getOrDefault(componentType, defaultStrategy) + + // If strategy is not AUTO, use it directly if available + if (configuredStrategy != StrategyType.AUTO) { + if (isStrategyAvailableCallback(componentType, configuredStrategy)) { + return StrategyDecision( + strategy = configuredStrategy, + reason = StrategyReason.USER_PREFERENCE, + componentType = componentType, + canFallback = determineFallbackStrategy(componentType, configuredStrategy) >= 0, + fallbackStrategy = determineFallbackStrategy(componentType, configuredStrategy).takeIf { it >= 0 }, + ) + } + } + + // Auto decision logic + val caps = strategyCapabilities[componentType] ?: getCurrentCapabilities() + + // Check if we have a local model + val hasLocalModel = + if (modelId != null) { + val model = CppBridgeModelRegistry.get(modelId) + model != null && model.localPath != null + } else { + caps.hasLocalModel + } + + val decision = + when { + // Network unavailable - must use on-device + !caps.hasNetworkAccess -> { + if (hasLocalModel && caps.canExecuteOnDevice()) { + StrategyDecision( + strategy = StrategyType.ON_DEVICE, + reason = StrategyReason.NETWORK_UNAVAILABLE, + componentType = componentType, + canFallback = false, + fallbackStrategy = null, + ) + } else { + // No fallback available + StrategyDecision( + strategy = StrategyType.ON_DEVICE, + reason = StrategyReason.MODEL_NOT_DOWNLOADED, + componentType = componentType, + canFallback = false, + fallbackStrategy = null, + ) + } + } + + // Low battery and not charging - prefer cloud to save power + caps.batteryLevel < 20 && !caps.isCharging -> { + StrategyDecision( + strategy = StrategyType.CLOUD, + reason = StrategyReason.LOW_BATTERY, + componentType = componentType, + canFallback = hasLocalModel, + fallbackStrategy = if (hasLocalModel) StrategyType.ON_DEVICE else null, + ) + } + + // Has local model - prefer on-device + hasLocalModel && caps.canExecuteOnDevice() -> { + StrategyDecision( + strategy = StrategyType.ON_DEVICE, + reason = StrategyReason.AUTO_DECISION, + componentType = componentType, + canFallback = caps.hasNetworkAccess, + fallbackStrategy = if (caps.hasNetworkAccess) StrategyType.CLOUD else null, + ) + } + + // No local model - use cloud + caps.canExecuteOnCloud() -> { + StrategyDecision( + strategy = StrategyType.CLOUD, + reason = StrategyReason.MODEL_NOT_DOWNLOADED, + componentType = componentType, + canFallback = false, + fallbackStrategy = null, + ) + } + + // No options available + else -> { + StrategyDecision( + strategy = StrategyType.ON_DEVICE, + reason = StrategyReason.INSUFFICIENT_RESOURCES, + componentType = componentType, + canFallback = false, + fallbackStrategy = null, + ) + } + } + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Strategy decision: ${decision.getComponentName()} = ${decision.getStrategyName()} (${decision.getReasonName()})", + ) + + return decision + } + + /** + * Determine fallback strategy when primary fails. + */ + private fun determineFallbackStrategy(componentType: Int, failedStrategy: Int): Int { + val caps = strategyCapabilities[componentType] ?: getCurrentCapabilities() + + return when (failedStrategy) { + StrategyType.ON_DEVICE -> { + if (caps.canExecuteOnCloud()) StrategyType.CLOUD else -1 + } + StrategyType.CLOUD -> { + if (caps.canExecuteOnDevice()) StrategyType.ON_DEVICE else -1 + } + StrategyType.HYBRID_LOCAL_FIRST -> { + if (caps.canExecuteOnCloud()) StrategyType.CLOUD else -1 + } + StrategyType.HYBRID_CLOUD_FIRST -> { + if (caps.canExecuteOnDevice()) StrategyType.ON_DEVICE else -1 + } + else -> -1 + } + } + + /** + * Get current capabilities from provider or defaults. + */ + private fun getCurrentCapabilities(): StrategyCapabilities { + val provider = capabilityProvider + if (provider != null) { + return provider.getCapabilities() + } + + // Return default capabilities + val runtime = Runtime.getRuntime() + val availableMemoryMB = (runtime.freeMemory() + (runtime.maxMemory() - runtime.totalMemory())) / (1024 * 1024) + val availableStorageMB = CppBridgeModelPaths.getAvailableStorage() / (1024 * 1024) + + return StrategyCapabilities( + supportsOnDevice = true, + supportsCloud = true, + hasLocalModel = false, // Would need to check model registry + hasNetworkAccess = true, // Assume true for JVM + availableMemoryMB = availableMemoryMB, + availableStorageMB = availableStorageMB, + batteryLevel = 100, // JVM doesn't have battery + isCharging = true, + ) + } + + /** + * Initialize default strategies for all components. + */ + private fun initializeDefaultStrategies() { + // VAD should always be on-device for latency + componentStrategies[ComponentType.VAD] = StrategyType.ON_DEVICE + + // Other components use auto by default + componentStrategies[ComponentType.LLM] = StrategyType.AUTO + componentStrategies[ComponentType.STT] = StrategyType.AUTO + componentStrategies[ComponentType.TTS] = StrategyType.AUTO + componentStrategies[ComponentType.VOICE_AGENT] = StrategyType.AUTO + componentStrategies[ComponentType.EMBEDDING] = StrategyType.ON_DEVICE + } + + /** + * Parse capabilities JSON. + */ + private fun parseCapabilitiesJson(json: String): StrategyCapabilities { + fun extractBoolean(key: String): Boolean { + val pattern = "\"$key\"\\s*:\\s*(true|false)" + val regex = Regex(pattern) + return regex.find(json)?.groupValues?.get(1) == "true" + } + + fun extractLong(key: String): Long { + val pattern = "\"$key\"\\s*:\\s*(-?\\d+)" + val regex = Regex(pattern) + return regex + .find(json) + ?.groupValues + ?.get(1) + ?.toLongOrNull() ?: 0L + } + + fun extractInt(key: String): Int { + val pattern = "\"$key\"\\s*:\\s*(-?\\d+)" + val regex = Regex(pattern) + return regex + .find(json) + ?.groupValues + ?.get(1) + ?.toIntOrNull() ?: 0 + } + + return StrategyCapabilities( + supportsOnDevice = extractBoolean("supports_on_device"), + supportsCloud = extractBoolean("supports_cloud"), + hasLocalModel = extractBoolean("has_local_model"), + hasNetworkAccess = extractBoolean("has_network_access"), + availableMemoryMB = extractLong("available_memory_mb"), + availableStorageMB = extractLong("available_storage_mb"), + batteryLevel = extractInt("battery_level"), + isCharging = extractBoolean("is_charging"), + ) + } +} diff --git a/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeTTS.kt b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeTTS.kt new file mode 100644 index 000000000..d119886ba --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeTTS.kt @@ -0,0 +1,1511 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * TTS extension for CppBridge. + * Provides Text-to-Speech component lifecycle management for C++ core. + * + * Follows iOS CppBridge+TTS.swift architecture. + */ + +package com.runanywhere.sdk.foundation.bridge.extensions + +import com.runanywhere.sdk.foundation.bridge.CppBridge +import com.runanywhere.sdk.foundation.errors.SDKError +import com.runanywhere.sdk.native.bridge.RunAnywhereBridge + +/** + * TTS bridge that provides Text-to-Speech component lifecycle management for C++ core. + * + * The C++ core needs TTS component management for: + * - Creating and destroying TTS instances + * - Loading and unloading models + * - Text synthesis (standard and streaming) + * - Canceling ongoing operations + * - Component state tracking + * + * Usage: + * - Called during Phase 2 initialization in [CppBridge.initializeServices] + * - Must be registered after [CppBridgePlatformAdapter] and [CppBridgeModelRegistry] are registered + * + * Thread Safety: + * - This object is thread-safe via synchronized blocks + * - All callbacks are thread-safe + * - Matches iOS Actor-based pattern using Kotlin synchronized + */ +object CppBridgeTTS { + /** + * TTS component state constants matching C++ RAC_TTS_STATE_* values. + */ + object TTSState { + /** Component not created */ + const val NOT_CREATED = 0 + + /** Component created but no model loaded */ + const val CREATED = 1 + + /** Model is loading */ + const val LOADING = 2 + + /** Model loaded and ready for synthesis */ + const val READY = 3 + + /** Synthesis in progress */ + const val SYNTHESIZING = 4 + + /** Model is unloading */ + const val UNLOADING = 5 + + /** Component in error state */ + const val ERROR = 6 + + /** + * Get a human-readable name for the TTS state. + */ + fun getName(state: Int): String = + when (state) { + NOT_CREATED -> "NOT_CREATED" + CREATED -> "CREATED" + LOADING -> "LOADING" + READY -> "READY" + SYNTHESIZING -> "SYNTHESIZING" + UNLOADING -> "UNLOADING" + ERROR -> "ERROR" + else -> "UNKNOWN($state)" + } + + /** + * Check if the state indicates the component is usable. + */ + fun isReady(state: Int): Boolean = state == READY + } + + /** + * Audio output format constants for TTS. + */ + object AudioFormat { + /** 16-bit PCM audio */ + const val PCM_16 = 0 + + /** 32-bit float audio */ + const val PCM_FLOAT = 1 + + /** WAV file format */ + const val WAV = 2 + + /** MP3 file format */ + const val MP3 = 3 + + /** Opus/OGG format */ + const val OPUS = 4 + + /** AAC format */ + const val AAC = 5 + + /** + * Get a human-readable name for the audio format. + */ + fun getName(format: Int): String = + when (format) { + PCM_16 -> "PCM_16" + PCM_FLOAT -> "PCM_FLOAT" + WAV -> "WAV" + MP3 -> "MP3" + OPUS -> "OPUS" + AAC -> "AAC" + else -> "UNKNOWN($format)" + } + } + + /** + * Language code constants. + */ + object Language { + const val ENGLISH = "en" + const val SPANISH = "es" + const val FRENCH = "fr" + const val GERMAN = "de" + const val ITALIAN = "it" + const val PORTUGUESE = "pt" + const val JAPANESE = "ja" + const val CHINESE = "zh" + const val KOREAN = "ko" + const val RUSSIAN = "ru" + const val ARABIC = "ar" + const val HINDI = "hi" + } + + /** + * Synthesis completion reason constants. + */ + object CompletionReason { + /** Synthesis still in progress */ + const val NOT_COMPLETED = 0 + + /** End of text reached */ + const val END_OF_TEXT = 1 + + /** Synthesis was cancelled */ + const val CANCELLED = 2 + + /** Maximum duration reached */ + const val MAX_DURATION = 3 + + /** Synthesis failed */ + const val ERROR = 4 + + /** + * Get a human-readable name for the completion reason. + */ + fun getName(reason: Int): String = + when (reason) { + NOT_COMPLETED -> "NOT_COMPLETED" + END_OF_TEXT -> "END_OF_TEXT" + CANCELLED -> "CANCELLED" + MAX_DURATION -> "MAX_DURATION" + ERROR -> "ERROR" + else -> "UNKNOWN($reason)" + } + } + + @Volatile + private var isRegistered: Boolean = false + + @Volatile + private var state: Int = TTSState.NOT_CREATED + + @Volatile + private var handle: Long = 0 + + @Volatile + private var loadedModelId: String? = null + + @Volatile + private var loadedModelPath: String? = null + + @Volatile + private var isCancelled: Boolean = false + + private val lock = Any() + + /** + * Tag for logging. + */ + private const val TAG = "CppBridgeTTS" + + /** + * Singleton shared instance for accessing the TTS component. + * Matches iOS CppBridge.TTS.shared pattern. + */ + val shared: CppBridgeTTS = this + + /** + * Optional listener for TTS events. + * Set this before calling [register] to receive events. + */ + @Volatile + var ttsListener: TTSListener? = null + + /** + * Optional streaming callback for audio chunk output. + * This is invoked for each audio chunk during streaming synthesis. + */ + @Volatile + var streamCallback: StreamCallback? = null + + /** + * TTS synthesis configuration. + * + * @param language Language code (e.g., "en", "es") + * @param voiceId Voice identifier (model-specific) + * @param speed Speaking rate multiplier (0.5 to 2.0, 1.0 = normal) + * @param pitch Pitch adjustment (-1.0 to 1.0, 0.0 = normal) + * @param volume Volume level (0.0 to 1.0) + * @param sampleRate Output audio sample rate in Hz (default: 22050) + * @param audioFormat Output audio format + * @param maxDurationMs Maximum synthesis duration in milliseconds (0 = unlimited) + */ + data class SynthesisConfig( + val language: String = Language.ENGLISH, + val voiceId: String = "", + val speed: Float = 1.0f, + val pitch: Float = 0.0f, + val volume: Float = 1.0f, + val sampleRate: Int = 22050, + val audioFormat: Int = AudioFormat.PCM_16, + val maxDurationMs: Long = 0, + ) { + /** + * Convert to JSON string for C++ interop. + */ + fun toJson(): String { + return buildString { + append("{") + append("\"language\":\"${escapeJson(language)}\",") + append("\"voice_id\":\"${escapeJson(voiceId)}\",") + append("\"speed\":$speed,") + append("\"pitch\":$pitch,") + append("\"volume\":$volume,") + append("\"sample_rate\":$sampleRate,") + append("\"audio_format\":$audioFormat,") + append("\"max_duration_ms\":$maxDurationMs") + append("}") + } + } + + companion object { + /** Default configuration */ + val DEFAULT = SynthesisConfig() + } + } + + /** + * TTS model configuration. + * + * @param threads Number of threads for inference (-1 for auto) + * @param gpuEnabled Whether to use GPU acceleration + * @param useFlashAttention Whether to use flash attention optimization + */ + data class ModelConfig( + val threads: Int = -1, + val gpuEnabled: Boolean = false, + val useFlashAttention: Boolean = true, + ) { + /** + * Convert to JSON string for C++ interop. + */ + fun toJson(): String { + return buildString { + append("{") + append("\"threads\":$threads,") + append("\"gpu_enabled\":$gpuEnabled,") + append("\"use_flash_attention\":$useFlashAttention") + append("}") + } + } + + companion object { + /** Default configuration */ + val DEFAULT = ModelConfig() + } + } + + /** + * Voice information data class. + * + * @param voiceId Unique voice identifier + * @param name Human-readable voice name + * @param language Language code this voice supports + * @param gender Voice gender ("male", "female", "neutral") + * @param quality Voice quality tier ("standard", "premium", "neural") + */ + data class VoiceInfo( + val voiceId: String, + val name: String, + val language: String, + val gender: String, + val quality: String, + ) + + /** + * TTS synthesis result. + * + * @param audioData Synthesized audio data bytes + * @param text Original input text + * @param durationMs Audio duration in milliseconds + * @param completionReason Reason for synthesis completion + * @param sampleRate Audio sample rate in Hz + * @param audioFormat Audio format used + * @param processingTimeMs Time spent processing in milliseconds + */ + data class SynthesisResult( + val audioData: ByteArray, + val text: String, + val durationMs: Long, + val completionReason: Int, + val sampleRate: Int, + val audioFormat: Int, + val processingTimeMs: Long, + ) { + /** + * Get the completion reason name. + */ + fun getCompletionReasonName(): String = CompletionReason.getName(completionReason) + + /** + * Check if synthesis completed successfully. + */ + fun isComplete(): Boolean = completionReason == CompletionReason.END_OF_TEXT + + /** + * Check if synthesis was cancelled. + */ + fun wasCancelled(): Boolean = completionReason == CompletionReason.CANCELLED + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is SynthesisResult) return false + if (!audioData.contentEquals(other.audioData)) return false + if (text != other.text) return false + if (durationMs != other.durationMs) return false + if (completionReason != other.completionReason) return false + if (sampleRate != other.sampleRate) return false + if (audioFormat != other.audioFormat) return false + if (processingTimeMs != other.processingTimeMs) return false + return true + } + + override fun hashCode(): Int { + var result = audioData.contentHashCode() + result = 31 * result + text.hashCode() + result = 31 * result + durationMs.hashCode() + result = 31 * result + completionReason + result = 31 * result + sampleRate + result = 31 * result + audioFormat + result = 31 * result + processingTimeMs.hashCode() + return result + } + } + + /** + * Audio chunk for streaming synthesis. + * + * @param audioData Audio data bytes for this chunk + * @param isFinal Whether this is the final chunk + * @param chunkIndex Index of this chunk in the sequence + */ + data class AudioChunk( + val audioData: ByteArray, + val isFinal: Boolean, + val chunkIndex: Int, + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is AudioChunk) return false + if (!audioData.contentEquals(other.audioData)) return false + if (isFinal != other.isFinal) return false + if (chunkIndex != other.chunkIndex) return false + return true + } + + override fun hashCode(): Int { + var result = audioData.contentHashCode() + result = 31 * result + isFinal.hashCode() + result = 31 * result + chunkIndex + return result + } + } + + /** + * Listener interface for TTS events. + */ + interface TTSListener { + /** + * Called when the TTS component state changes. + * + * @param previousState The previous state + * @param newState The new state + */ + fun onStateChanged(previousState: Int, newState: Int) + + /** + * Called when a model is loaded. + * + * @param modelId The model ID + * @param modelPath The model path + */ + fun onModelLoaded(modelId: String, modelPath: String) + + /** + * Called when a model is unloaded. + * + * @param modelId The previously loaded model ID + */ + fun onModelUnloaded(modelId: String) + + /** + * Called when synthesis starts. + * + * @param text The input text + */ + fun onSynthesisStarted(text: String) + + /** + * Called when synthesis completes. + * + * @param result The synthesis result + */ + fun onSynthesisCompleted(result: SynthesisResult) + + /** + * Called when an audio chunk is available during streaming. + * + * @param chunk The audio chunk + */ + fun onAudioChunk(chunk: AudioChunk) + + /** + * Called when an error occurs. + * + * @param errorCode The error code + * @param errorMessage The error message + */ + fun onError(errorCode: Int, errorMessage: String) + } + + /** + * Callback interface for streaming audio output. + */ + fun interface StreamCallback { + /** + * Called for each audio chunk. + * + * @param audioData The audio data bytes + * @param isFinal Whether this is the final chunk + * @return true to continue synthesis, false to stop + */ + fun onAudioChunk(audioData: ByteArray, isFinal: Boolean): Boolean + } + + /** + * Register the TTS callbacks with C++ core. + * + * This must be called during SDK initialization, after [CppBridgePlatformAdapter.register]. + * It is safe to call multiple times; subsequent calls are no-ops. + */ + fun register() { + synchronized(lock) { + if (isRegistered) { + return + } + + // TODO: Call native registration + // nativeSetTTSCallbacks() + + isRegistered = true + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "TTS callbacks registered", + ) + } + } + + /** + * Check if the TTS callbacks are registered. + */ + fun isRegistered(): Boolean = isRegistered + + /** + * Get the current component handle. + * + * @return The native handle, or throws if not created + * @throws SDKError if the component is not created + */ + @Throws(SDKError::class) + fun getHandle(): Long { + synchronized(lock) { + if (handle == 0L) { + throw SDKError.notInitialized("TTS component not created") + } + return handle + } + } + + /** + * Check if a model is loaded. + */ + val isLoaded: Boolean + get() = synchronized(lock) { state == TTSState.READY && loadedModelId != null } + + /** + * Check if the component is ready for synthesis. + */ + val isReady: Boolean + get() = TTSState.isReady(state) + + /** + * Get the currently loaded model ID. + */ + fun getLoadedModelId(): String? = loadedModelId + + /** + * Get the currently loaded model path. + */ + fun getLoadedModelPath(): String? = loadedModelPath + + /** + * Get the current component state. + */ + fun getState(): Int = state + + // ======================================================================== + // LIFECYCLE OPERATIONS + // ======================================================================== + + /** + * Create the TTS component. + * + * @return 0 on success, error code on failure + */ + fun create(): Int { + synchronized(lock) { + if (handle != 0L) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "TTS component already created", + ) + return 0 + } + + // Check if native commons library is loaded + if (!CppBridge.isNativeLibraryLoaded) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "Native library not loaded. TTS inference requires native libraries to be bundled.", + ) + throw SDKError.notInitialized("Native library not available. Please ensure the native libraries are bundled in your APK.") + } + + // Create TTS component via RunAnywhereBridge + val result = + try { + RunAnywhereBridge.racTtsComponentCreate() + } catch (e: UnsatisfiedLinkError) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "TTS component creation failed. Native method not available: ${e.message}", + ) + throw SDKError.notInitialized("TTS native library not available. Please ensure the TTS backend is bundled in your APK.") + } + + if (result == 0L) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "Failed to create TTS component", + ) + return -1 + } + + handle = result + setState(TTSState.CREATED) + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "TTS component created", + ) + + return 0 + } + } + + /** + * Load a model. + * + * @param modelPath Path to the model file + * @param modelId Unique identifier for the model (for telemetry) + * @param modelName Human-readable name for the model (for telemetry) + * @param config Model configuration (reserved for future use) + * @return 0 on success, error code on failure + */ + @Suppress("UNUSED_PARAMETER") + fun loadModel(modelPath: String, modelId: String, modelName: String? = null, config: ModelConfig = ModelConfig.DEFAULT): Int { + synchronized(lock) { + if (handle == 0L) { + // Auto-create component if needed + val createResult = create() + if (createResult != 0) { + return createResult + } + } + + if (loadedModelId != null) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Unloading current model before loading new one: $loadedModelId", + ) + unload() + } + + setState(TTSState.LOADING) + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "Loading model: $modelId from $modelPath", + ) + + val result = RunAnywhereBridge.racTtsComponentLoadModel(handle, modelPath, modelId, modelName) + if (result != 0) { + setState(TTSState.ERROR) + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "Failed to load model: $modelId (error: $result)", + ) + + try { + ttsListener?.onError(result, "Failed to load model: $modelId") + } catch (e: Exception) { + // Ignore listener errors + } + + return result + } + + loadedModelId = modelId + loadedModelPath = modelPath + setState(TTSState.READY) + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "Model loaded successfully: $modelId", + ) + + // Update model assignment status + CppBridgeModelAssignment.setAssignmentStatusCallback( + CppBridgeModelRegistry.ModelType.TTS, + CppBridgeModelAssignment.AssignmentStatus.READY, + CppBridgeModelAssignment.FailureReason.NONE, + ) + + // Update component state + CppBridgeState.setComponentStateCallback( + CppBridgeState.ComponentType.TTS, + CppBridgeState.ComponentState.READY, + ) + + try { + ttsListener?.onModelLoaded(modelId, modelPath) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in TTS listener onModelLoaded: ${e.message}", + ) + } + + return 0 + } + } + + /** + * Synthesize audio from text. + * + * @param text The input text to synthesize + * @param config Synthesis configuration (optional) + * @return The synthesis result + * @throws SDKError if synthesis fails + */ + @Throws(SDKError::class) + fun synthesize(text: String, config: SynthesisConfig = SynthesisConfig.DEFAULT): SynthesisResult { + synchronized(lock) { + if (handle == 0L || state != TTSState.READY) { + throw SDKError.tts("TTS component not ready for synthesis") + } + + isCancelled = false + setState(TTSState.SYNTHESIZING) + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Starting synthesis (text length: ${text.length})", + ) + + try { + ttsListener?.onSynthesisStarted(text) + } catch (e: Exception) { + // Ignore listener errors + } + + val startTime = System.currentTimeMillis() + + try { + val rawAudioData = + RunAnywhereBridge.racTtsComponentSynthesize(handle, text, config.toJson()) + ?: throw SDKError.tts("Synthesis failed: null result") + + // TTS backends output Float32 PCM - convert to WAV for playback compatibility + val audioData = + RunAnywhereBridge.racAudioFloat32ToWav(rawAudioData, config.sampleRate) + ?: throw SDKError.tts("Failed to convert audio to WAV format") + + val processingTimeMs = System.currentTimeMillis() - startTime + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Converted ${rawAudioData.size} bytes Float32 PCM to ${audioData.size} bytes WAV", + ) + + // Calculate approximate duration based on WAV data size (minus 44-byte header) and sample rate + // WAV is Int16 (2 bytes per sample) mono, so samples = (size - 44) / 2 + val durationMs = calculateWavDuration(audioData.size, config.sampleRate) + + val result = + SynthesisResult( + audioData = audioData, + text = text, + durationMs = durationMs, + completionReason = CompletionReason.END_OF_TEXT, + sampleRate = config.sampleRate, + audioFormat = AudioFormat.WAV, // Output is now WAV format + processingTimeMs = processingTimeMs, + ) + + setState(TTSState.READY) + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Synthesis completed: ${audioData.size} bytes WAV, ${result.durationMs}ms audio", + ) + + try { + ttsListener?.onSynthesisCompleted(result) + } catch (e: Exception) { + // Ignore listener errors + } + + return result + } catch (e: Exception) { + setState(TTSState.READY) // Reset to ready, not error + throw if (e is SDKError) e else SDKError.tts("Synthesis failed: ${e.message}") + } + } + } + + /** + * Synthesize audio with streaming output. + * + * @param text The input text to synthesize + * @param config Synthesis configuration (optional) + * @param callback Callback for audio chunks + * @return The final synthesis result + * @throws SDKError if synthesis fails + */ + @Throws(SDKError::class) + fun synthesizeStream( + text: String, + config: SynthesisConfig = SynthesisConfig.DEFAULT, + callback: StreamCallback, + ): SynthesisResult { + synchronized(lock) { + if (handle == 0L || state != TTSState.READY) { + throw SDKError.tts("TTS component not ready for synthesis") + } + + isCancelled = false + streamCallback = callback + setState(TTSState.SYNTHESIZING) + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Starting streaming synthesis (text length: ${text.length})", + ) + + try { + ttsListener?.onSynthesisStarted(text) + } catch (e: Exception) { + // Ignore listener errors + } + + val startTime = System.currentTimeMillis() + + try { + val rawAudioData = + RunAnywhereBridge.racTtsComponentSynthesizeStream(handle, text, config.toJson()) + ?: throw SDKError.tts("Streaming synthesis failed: null result") + + // TTS backends output Float32 PCM - convert to WAV for playback compatibility + val audioData = + RunAnywhereBridge.racAudioFloat32ToWav(rawAudioData, config.sampleRate) + ?: throw SDKError.tts("Failed to convert streaming audio to WAV format") + + val processingTimeMs = System.currentTimeMillis() - startTime + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Converted ${rawAudioData.size} bytes Float32 PCM to ${audioData.size} bytes WAV (streaming)", + ) + + val durationMs = calculateWavDuration(audioData.size, config.sampleRate) + + val result = + SynthesisResult( + audioData = audioData, + text = text, + durationMs = durationMs, + completionReason = if (isCancelled) CompletionReason.CANCELLED else CompletionReason.END_OF_TEXT, + sampleRate = config.sampleRate, + audioFormat = AudioFormat.WAV, // Output is now WAV format + processingTimeMs = processingTimeMs, + ) + + setState(TTSState.READY) + streamCallback = null + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Streaming synthesis completed: ${audioData.size} bytes WAV", + ) + + try { + ttsListener?.onSynthesisCompleted(result) + } catch (e: Exception) { + // Ignore listener errors + } + + return result + } catch (e: Exception) { + setState(TTSState.READY) // Reset to ready, not error + streamCallback = null + throw if (e is SDKError) e else SDKError.tts("Streaming synthesis failed: ${e.message}") + } + } + } + + /** + * Synthesize audio and save to file. + * + * @param text The input text to synthesize + * @param outputPath Path to save the audio file + * @param config Synthesis configuration (optional) + * @return The synthesis result (with empty audioData, as it's saved to file) + * @throws SDKError if synthesis fails + */ + @Throws(SDKError::class) + fun synthesizeToFile( + text: String, + outputPath: String, + config: SynthesisConfig = SynthesisConfig.DEFAULT, + ): SynthesisResult { + synchronized(lock) { + if (handle == 0L || state != TTSState.READY) { + throw SDKError.tts("TTS component not ready for synthesis") + } + + isCancelled = false + setState(TTSState.SYNTHESIZING) + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Starting synthesis to file: $outputPath (text length: ${text.length})", + ) + + try { + ttsListener?.onSynthesisStarted(text) + } catch (e: Exception) { + // Ignore listener errors + } + + val startTime = System.currentTimeMillis() + + try { + val durationMs = RunAnywhereBridge.racTtsComponentSynthesizeToFile(handle, text, outputPath, config.toJson()) + if (durationMs < 0) { + throw SDKError.tts("Synthesis to file failed: error code $durationMs") + } + + val processingTimeMs = System.currentTimeMillis() - startTime + + val result = + SynthesisResult( + audioData = ByteArray(0), // Empty since saved to file + text = text, + durationMs = durationMs, + completionReason = CompletionReason.END_OF_TEXT, + sampleRate = config.sampleRate, + audioFormat = config.audioFormat, + processingTimeMs = processingTimeMs, + ) + + setState(TTSState.READY) + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Synthesis to file completed: $outputPath, ${durationMs}ms audio", + ) + + try { + ttsListener?.onSynthesisCompleted(result) + } catch (e: Exception) { + // Ignore listener errors + } + + return result + } catch (e: Exception) { + setState(TTSState.READY) // Reset to ready, not error + throw if (e is SDKError) e else SDKError.tts("Synthesis to file failed: ${e.message}") + } + } + } + + /** + * Cancel an ongoing synthesis. + */ + fun cancel() { + synchronized(lock) { + if (state != TTSState.SYNTHESIZING) { + return + } + + isCancelled = true + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Cancelling synthesis", + ) + + RunAnywhereBridge.racTtsComponentCancel(handle) + } + } + + /** + * Unload the current model. + */ + fun unload() { + synchronized(lock) { + if (loadedModelId == null) { + return + } + + val previousModelId = loadedModelId ?: return + + setState(TTSState.UNLOADING) + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "Unloading model: $previousModelId", + ) + + RunAnywhereBridge.racTtsComponentUnload(handle) + + loadedModelId = null + loadedModelPath = null + setState(TTSState.CREATED) + + // Update model assignment status + CppBridgeModelAssignment.setAssignmentStatusCallback( + CppBridgeModelRegistry.ModelType.TTS, + CppBridgeModelAssignment.AssignmentStatus.NOT_ASSIGNED, + CppBridgeModelAssignment.FailureReason.NONE, + ) + + // Update component state + CppBridgeState.setComponentStateCallback( + CppBridgeState.ComponentType.TTS, + CppBridgeState.ComponentState.CREATED, + ) + + try { + ttsListener?.onModelUnloaded(previousModelId) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in TTS listener onModelUnloaded: ${e.message}", + ) + } + } + } + + /** + * Destroy the TTS component and release resources. + */ + fun destroy() { + synchronized(lock) { + if (handle == 0L) { + return + } + + // Unload model first if loaded + if (loadedModelId != null) { + unload() + } + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "Destroying TTS component", + ) + + RunAnywhereBridge.racTtsComponentDestroy(handle) + + handle = 0 + setState(TTSState.NOT_CREATED) + + // Update component state + CppBridgeState.setComponentStateCallback( + CppBridgeState.ComponentType.TTS, + CppBridgeState.ComponentState.NOT_CREATED, + ) + } + } + + // ======================================================================== + // JNI CALLBACKS + // ======================================================================== + + /** + * Streaming audio chunk callback. + * + * Called from C++ for each audio chunk during streaming synthesis. + * + * @param audioData The audio data bytes for this chunk + * @param isFinal Whether this is the final chunk + * @return true to continue synthesis, false to stop + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun streamAudioCallback(audioData: ByteArray, isFinal: Boolean): Boolean { + if (isCancelled) { + return false + } + + val callback = streamCallback ?: return true + + // Notify listener + try { + ttsListener?.onAudioChunk(AudioChunk(audioData, isFinal, -1)) + } catch (e: Exception) { + // Ignore listener errors + } + + return try { + callback.onAudioChunk(audioData, isFinal) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in stream callback: ${e.message}", + ) + true // Continue on error + } + } + + /** + * Progress callback. + * + * Called from C++ to report model loading or synthesis progress. + * + * @param progress Progress (0.0 to 1.0) + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun progressCallback(progress: Float) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Progress: ${(progress * 100).toInt()}%", + ) + } + + /** + * Get state callback. + * + * @return The current TTS component state + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun getStateCallback(): Int { + return state + } + + /** + * Is loaded callback. + * + * @return true if a model is loaded + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun isLoadedCallback(): Boolean { + return loadedModelId != null && state == TTSState.READY + } + + /** + * Get loaded model ID callback. + * + * @return The loaded model ID, or null + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun getLoadedModelIdCallback(): String? { + return loadedModelId + } + + // ======================================================================== + // JNI NATIVE DECLARATIONS + // ======================================================================== + + /** + * Native method to set the TTS callbacks with C++ core. + * + * Registers [streamAudioCallback], [progressCallback], etc. with C++ core. + * Reserved for future native callback integration. + * + * C API: rac_tts_set_callbacks(...) + */ + @Suppress("unused") + @JvmStatic + private external fun nativeSetTTSCallbacks() + + /** + * Native method to unset the TTS callbacks. + * + * Called during shutdown to clean up native resources. + * Reserved for future native callback integration. + * + * C API: rac_tts_set_callbacks(nullptr) + */ + @Suppress("unused") + @JvmStatic + private external fun nativeUnsetTTSCallbacks() + + /** + * Native method to create the TTS component. + * + * @return Handle to the created component, or 0 on failure + * + * C API: rac_tts_component_create() + */ + @JvmStatic + external fun nativeCreate(): Long + + /** + * Native method to load a model. + * + * @param handle The component handle + * @param modelPath Path to the model file + * @param configJson JSON configuration string + * @return 0 on success, error code on failure + * + * C API: rac_tts_component_load_model(handle, model_path, config) + */ + @JvmStatic + external fun nativeLoadModel(handle: Long, modelPath: String, configJson: String): Int + + /** + * Native method to synthesize audio from text. + * + * @param handle The component handle + * @param text The input text + * @param configJson JSON configuration string + * @return Audio data bytes, or null on failure + * + * C API: rac_tts_component_synthesize(handle, text, config) + */ + @JvmStatic + external fun nativeSynthesize(handle: Long, text: String, configJson: String): ByteArray? + + /** + * Native method to synthesize audio with streaming. + * + * @param handle The component handle + * @param text The input text + * @param configJson JSON configuration string + * @return Final audio data bytes, or null on failure + * + * C API: rac_tts_component_synthesize_stream(handle, text, config) + */ + @JvmStatic + external fun nativeSynthesizeStream(handle: Long, text: String, configJson: String): ByteArray? + + /** + * Native method to synthesize audio to file. + * + * @param handle The component handle + * @param text The input text + * @param outputPath Path to save the audio file + * @param configJson JSON configuration string + * @return Audio duration in milliseconds, or negative error code on failure + * + * C API: rac_tts_component_synthesize_to_file(handle, text, output_path, config) + */ + @JvmStatic + external fun nativeSynthesizeToFile(handle: Long, text: String, outputPath: String, configJson: String): Long + + /** + * Native method to cancel synthesis. + * + * @param handle The component handle + * + * C API: rac_tts_component_cancel(handle) + */ + @JvmStatic + external fun nativeCancel(handle: Long) + + /** + * Native method to unload the model. + * + * @param handle The component handle + * + * C API: rac_tts_component_unload(handle) + */ + @JvmStatic + external fun nativeUnload(handle: Long) + + /** + * Native method to destroy the component. + * + * @param handle The component handle + * + * C API: rac_tts_component_destroy(handle) + */ + @JvmStatic + external fun nativeDestroy(handle: Long) + + /** + * Native method to get available voices. + * + * @param handle The component handle + * @return JSON array of voice information + * + * C API: rac_tts_component_get_voices(handle) + */ + @JvmStatic + external fun nativeGetVoices(handle: Long): String? + + /** + * Native method to set the active voice. + * + * @param handle The component handle + * @param voiceId The voice ID to use + * @return 0 on success, error code on failure + * + * C API: rac_tts_component_set_voice(handle, voice_id) + */ + @JvmStatic + external fun nativeSetVoice(handle: Long, voiceId: String): Int + + /** + * Native method to get supported languages. + * + * @param handle The component handle + * @return JSON array of supported language codes + * + * C API: rac_tts_component_get_languages(handle) + */ + @JvmStatic + external fun nativeGetLanguages(handle: Long): String? + + // ======================================================================== + // LIFECYCLE MANAGEMENT + // ======================================================================== + + /** + * Unregister the TTS callbacks and clean up resources. + * + * Called during SDK shutdown. + */ + fun unregister() { + synchronized(lock) { + if (!isRegistered) { + return + } + + // Destroy component if created + if (handle != 0L) { + destroy() + } + + // TODO: Call native unregistration + // nativeUnsetTTSCallbacks() + + ttsListener = null + streamCallback = null + isRegistered = false + } + } + + // ======================================================================== + // UTILITY FUNCTIONS + // ======================================================================== + + /** + * Set the component state and notify listeners. + */ + private fun setState(newState: Int) { + val previousState = state + if (newState != previousState) { + state = newState + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "State changed: ${TTSState.getName(previousState)} -> ${TTSState.getName(newState)}", + ) + + try { + ttsListener?.onStateChanged(previousState, newState) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in TTS listener onStateChanged: ${e.message}", + ) + } + } + } + + /** + * Calculate approximate audio duration from data size. + * Reserved for future audio duration estimation. + */ + @Suppress("unused") + private fun calculateAudioDuration(dataSize: Int, sampleRate: Int, audioFormat: Int): Long { + // Calculate based on audio format + val bytesPerSample = + when (audioFormat) { + AudioFormat.PCM_16 -> 2 + AudioFormat.PCM_FLOAT -> 4 + else -> 2 // Default to 16-bit PCM + } + + // Assuming mono audio + val samples = dataSize / bytesPerSample + return (samples * 1000L) / sampleRate + } + + /** + * Calculate audio duration from WAV file data. + * WAV format: 44-byte header + Int16 PCM samples (2 bytes per sample, mono) + */ + private fun calculateWavDuration(wavSize: Int, sampleRate: Int): Long { + // WAV header is 44 bytes, data is Int16 (2 bytes per sample), mono + val headerSize = 44 + val bytesPerSample = 2 + val pcmSize = (wavSize - headerSize).coerceAtLeast(0) + val samples = pcmSize / bytesPerSample + return (samples * 1000L) / sampleRate + } + + /** + * Escape special characters for JSON string. + */ + private fun escapeJson(value: String): String { + return value + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + } + + /** + * Get available voices. + * + * @return List of available voice information, or empty list if model not loaded + */ + fun getAvailableVoices(): List { + synchronized(lock) { + if (handle == 0L || state != TTSState.READY) { + return emptyList() + } + val json = RunAnywhereBridge.racTtsComponentGetVoices(handle) ?: return emptyList() + return parseVoicesJson(json) + } + } + + /** + * Set the active voice. + * + * @param voiceId The voice ID to use + * @return true if successful + */ + fun setVoice(voiceId: String): Boolean { + synchronized(lock) { + if (handle == 0L || state != TTSState.READY) { + return false + } + return RunAnywhereBridge.racTtsComponentSetVoice(handle, voiceId) == 0 + } + } + + /** + * Get supported languages. + * + * @return List of supported language codes, or empty list if model not loaded + */ + fun getSupportedLanguages(): List { + synchronized(lock) { + if (handle == 0L || state != TTSState.READY) { + return emptyList() + } + val json = RunAnywhereBridge.racTtsComponentGetLanguages(handle) ?: return emptyList() + // Parse JSON array + val pattern = "\"([^\"]+)\"" + return Regex(pattern).findAll(json).map { it.groupValues[1] }.toList() + } + } + + /** + * Parse voices JSON to list of VoiceInfo. + */ + private fun parseVoicesJson(json: String): List { + val voices = mutableListOf() + + // Simple JSON array parsing + val voicePattern = "\\{[^}]+\\}" + val voiceMatches = Regex(voicePattern).findAll(json) + + for (match in voiceMatches) { + val voiceJson = match.value + + fun extractString(key: String): String { + val pattern = "\"$key\"\\s*:\\s*\"([^\"]*)\"" + return Regex(pattern).find(voiceJson)?.groupValues?.get(1) ?: "" + } + + voices.add( + VoiceInfo( + voiceId = extractString("voice_id").ifEmpty { extractString("id") }, + name = extractString("name"), + language = extractString("language"), + gender = extractString("gender"), + quality = extractString("quality"), + ), + ) + } + + return voices + } + + /** + * Get a state summary for diagnostics. + * + * @return Human-readable state summary + */ + fun getStateSummary(): String { + return buildString { + append("TTS State: ${TTSState.getName(state)}") + if (loadedModelId != null) { + append(", Model: $loadedModelId") + } + if (handle != 0L) { + append(", Handle: $handle") + } + } + } +} diff --git a/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeTelemetry.kt b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeTelemetry.kt new file mode 100644 index 000000000..1435037f2 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeTelemetry.kt @@ -0,0 +1,989 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * Telemetry extension for CppBridge. + * Provides HTTP callback for C++ core to send telemetry data to backend services. + * + * Follows iOS CppBridge+Telemetry.swift architecture. + */ + +package com.runanywhere.sdk.foundation.bridge.extensions + +import java.io.BufferedReader +import java.io.InputStreamReader +import java.io.OutputStreamWriter +import java.net.HttpURLConnection +import java.net.URL +import java.util.concurrent.Executors + +/** + * Telemetry bridge that provides HTTP callback for C++ core telemetry operations. + * + * The C++ core generates telemetry data that needs to be sent to backend services. + * This extension provides the HTTP transport layer via callbacks that C++ can invoke + * to send telemetry data. + * + * Usage: + * - Called during Phase 1 initialization in [CppBridge.initialize] + * - Must be registered after [CppBridgePlatformAdapter] is registered + * + * Thread Safety: + * - Registration is thread-safe via synchronized block + * - HTTP callbacks are executed on a background thread pool + * - Callbacks from C++ are thread-safe + */ +object CppBridgeTelemetry { + /** + * HTTP method constants matching C++ RAC_HTTP_METHOD_* values. + */ + object HttpMethod { + const val GET = 0 + const val POST = 1 + const val PUT = 2 + const val DELETE = 3 + const val PATCH = 4 + + /** + * Get the string representation of an HTTP method. + */ + fun getName(method: Int): String = + when (method) { + GET -> "GET" + POST -> "POST" + PUT -> "PUT" + DELETE -> "DELETE" + PATCH -> "PATCH" + else -> "GET" + } + } + + /** + * HTTP response status categories. + */ + object HttpStatus { + const val SUCCESS_MIN = 200 + const val SUCCESS_MAX = 299 + const val CLIENT_ERROR_MIN = 400 + const val CLIENT_ERROR_MAX = 499 + const val SERVER_ERROR_MIN = 500 + const val SERVER_ERROR_MAX = 599 + + fun isSuccess(statusCode: Int): Boolean = statusCode in SUCCESS_MIN..SUCCESS_MAX + + fun isClientError(statusCode: Int): Boolean = statusCode in CLIENT_ERROR_MIN..CLIENT_ERROR_MAX + + fun isServerError(statusCode: Int): Boolean = statusCode in SERVER_ERROR_MIN..SERVER_ERROR_MAX + } + + @Volatile + private var isRegistered: Boolean = false + + private val lock = Any() + + /** + * Tag for logging. + */ + private const val TAG = "CppBridgeTelemetry" + + /** + * Default connection timeout in milliseconds. + */ + private const val DEFAULT_CONNECT_TIMEOUT_MS = 10_000 + + /** + * Default read timeout in milliseconds. + */ + private const val DEFAULT_READ_TIMEOUT_MS = 30_000 + + /** + * Background executor for HTTP requests. + * Using a cached thread pool to handle concurrent telemetry requests efficiently. + */ + private val httpExecutor = + Executors.newCachedThreadPool { runnable -> + Thread(runnable, "runanywhere-telemetry").apply { + isDaemon = true + } + } + + /** + * Optional interceptor for customizing HTTP requests. + * Set this before calling [register] to customize requests (e.g., add auth headers). + */ + @Volatile + var requestInterceptor: HttpRequestInterceptor? = null + + /** + * Optional listener for telemetry events. + * Set this to receive notifications about telemetry operations. + */ + @Volatile + var telemetryListener: TelemetryListener? = null + + /** + * Interface for intercepting and modifying HTTP requests. + */ + interface HttpRequestInterceptor { + /** + * Called before an HTTP request is sent. + * Can be used to add headers, modify the URL, etc. + * + * @param url The request URL + * @param method The HTTP method (see [HttpMethod] constants) + * @param headers Mutable map of headers to be sent with the request + * @return Modified URL, or the original URL if no changes needed + */ + fun onBeforeRequest(url: String, method: Int, headers: MutableMap): String + } + + /** + * Listener interface for telemetry events. + */ + interface TelemetryListener { + /** + * Called when a telemetry request starts. + * + * @param requestId Unique identifier for this request + * @param url The request URL + * @param method The HTTP method + */ + fun onRequestStart(requestId: String, url: String, method: Int) + + /** + * Called when a telemetry request completes. + * + * @param requestId Unique identifier for this request + * @param statusCode The HTTP status code (-1 if request failed before getting a response) + * @param success Whether the request was successful + * @param errorMessage Error message if the request failed, null otherwise + */ + fun onRequestComplete(requestId: String, statusCode: Int, success: Boolean, errorMessage: String?) + } + + /** + * Telemetry manager handle (from C++). + */ + @Volatile + private var telemetryManagerHandle: Long = 0 + + /** + * Register the telemetry HTTP callback with C++ core. + * + * This must be called during SDK initialization, after [CppBridgePlatformAdapter.register]. + * It is safe to call multiple times; subsequent calls are no-ops. + */ + fun register() { + synchronized(lock) { + if (isRegistered) { + return + } + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Registering telemetry callbacks...", + ) + + isRegistered = true + } + } + + /** + * Initialize the telemetry manager with device and SDK info. + * Called during SDK initialization after register(). + * + * @param environment SDK environment (0=DEVELOPMENT, 1=STAGING, 2=PRODUCTION) + * @param deviceId Persistent device UUID + * @param deviceModel Device model (e.g., "Pixel 8 Pro") + * @param osVersion OS version (e.g., "14") + * @param sdkVersion SDK version string + */ + fun initialize( + environment: Int, + deviceId: String, + deviceModel: String, + osVersion: String, + sdkVersion: String, + ) { + synchronized(lock) { + // Store environment for HTTP base URL resolution + currentEnvironment = environment + + // Create telemetry manager + telemetryManagerHandle = + com.runanywhere.sdk.native.bridge.RunAnywhereBridge.racTelemetryManagerCreate( + environment, + deviceId, + "android", + sdkVersion, + ) + + if (telemetryManagerHandle != 0L) { + // Set device info + com.runanywhere.sdk.native.bridge.RunAnywhereBridge.racTelemetryManagerSetDeviceInfo( + telemetryManagerHandle, + deviceModel, + osVersion, + ) + + // Set HTTP callback + val httpCallback = + object { + @Suppress("unused") + fun onHttpRequest(endpoint: String, body: String, bodyLength: Int, requiresAuth: Boolean) { + // Execute HTTP request on background thread + httpExecutor.execute { + performTelemetryHttp(endpoint, body, requiresAuth) + } + } + } + com.runanywhere.sdk.native.bridge.RunAnywhereBridge.racTelemetryManagerSetHttpCallback( + telemetryManagerHandle, + httpCallback, + ) + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "Telemetry manager initialized (handle=$telemetryManagerHandle, env=$environment)", + ) + } else { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Failed to create telemetry manager", + ) + } + } + } + + /** + * Base URL for telemetry HTTP requests. + * Set this via [configureBaseUrl] before SDK initialization, or it will use environment defaults. + */ + @Volatile + private var _baseUrl: String? = null + + /** + * API key for authentication (used in production/staging mode). + * Set this via [setApiKey] during SDK initialization. + */ + @Volatile + private var _apiKey: String? = null + + /** + * Set the base URL for telemetry HTTP requests. + * Should be called before SDK initialization if using a custom URL. + */ + fun setBaseUrl(url: String) { + _baseUrl = url + } + + /** + * Set the API key for authentication. + * In production/staging mode, this will be used as Bearer token. + */ + fun setApiKey(key: String) { + _apiKey = key + } + + /** + * Get the base URL for device registration. + * Exposed for CppBridgeDevice to use in production mode. + */ + fun getBaseUrl(): String? = _baseUrl + + /** + * Get the API key for authentication. + * Exposed for CppBridgeDevice to use in production mode. + */ + fun getApiKey(): String? = _apiKey + + /** + * Get the effective base URL for the current environment. + * + * Priority by environment: + * - DEVELOPMENT (env=0): Always use Supabase URL from C++ dev config (ignores _baseUrl) + * - STAGING/PRODUCTION: Use _baseUrl if available, otherwise environment defaults + */ + private fun getEffectiveBaseUrl(environment: Int): String { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "🔍 getEffectiveBaseUrl: env=$environment, _baseUrl=$_baseUrl", + ) + + // DEVELOPMENT mode: Always use Supabase from C++ dev config, ignore any passed baseUrl + // This ensures telemetry always goes to Supabase in dev mode regardless of what app passes + if (environment == 0) { // DEVELOPMENT + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "🔍 Attempting to get Supabase URL from C++ dev config...", + ) + try { + val supabaseUrl = + com.runanywhere.sdk.native.bridge.RunAnywhereBridge + .racDevConfigGetSupabaseUrl() + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "C++ dev config returned supabaseUrl: '$supabaseUrl'", + ) + if (!supabaseUrl.isNullOrEmpty()) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "✅ Using Supabase URL from C++ dev config: $supabaseUrl", + ) + return supabaseUrl + } else { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "⚠️ C++ dev config returned null/empty Supabase URL", + ) + } + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "❌ Failed to get Supabase URL from dev config: ${e.message}", + ) + } + } else { + // STAGING/PRODUCTION: Use explicitly configured _baseUrl if available + _baseUrl?.let { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Using explicitly configured _baseUrl for env=$environment: $it", + ) + return it + } + } + + // Environment-specific defaults (fallback) + // Note: Production URL should be provided via configuration, not hardcoded + return when (environment) { + 0 -> { + // DEVELOPMENT - no dev config available, warn user + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "⚠️ Development mode but Supabase URL not configured in C++ dev_config. " + + "Please fill in development_config.cpp with your Supabase credentials.", + ) + "" // Return empty to indicate not configured + } + 1 -> { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Using staging API URL", + ) + "https://staging-api.runanywhere.ai" // STAGING + } + 2 -> { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Using production API URL", + ) + "https://api.runanywhere.ai" // PRODUCTION + } + else -> "https://api.runanywhere.ai" + } + } + + /** + * Current SDK environment (0=DEV, 1=STAGING, 2=PRODUCTION). + * Exposed for CppBridgeDevice to determine which URL and auth to use. + * + * IMPORTANT: This MUST be set early in initialization (before device registration) + * so that CppBridgeDevice.isDeviceRegisteredCallback() can determine the correct + * behavior for production/staging modes. + */ + @Volatile + var currentEnvironment: Int = 0 + private set + + /** + * Set the current environment early in initialization. + * This must be called before CppBridgeDevice.register() so that device registration + * callbacks can determine the correct behavior for production/staging modes. + */ + fun setEnvironment(environment: Int) { + currentEnvironment = environment + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Environment set to: $environment (${when (environment) { + 0 -> "DEVELOPMENT" 1 -> "STAGING" else -> "PRODUCTION" + }})", + ) + } + + /** + * Whether HTTP is configured (base URL available). + */ + val isHttpConfigured: Boolean + get() = _baseUrl != null || currentEnvironment > 0 // STAGING or PRODUCTION have defaults + + /** + * Cached API key for Supabase authentication. + */ + @Volatile + private var cachedApiKey: String? = null + + /** + * Get the Supabase API key (anon key) for authentication. + * Required for all Supabase API calls. + */ + private fun getSupabaseApiKey(): String? { + cachedApiKey?.let { return it } + + return try { + val apiKey = + com.runanywhere.sdk.native.bridge.RunAnywhereBridge + .racDevConfigGetSupabaseKey() + if (!apiKey.isNullOrEmpty()) { + cachedApiKey = apiKey + apiKey + } else { + null + } + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Failed to get Supabase API key from dev config: ${e.message}", + ) + null + } + } + + /** + * Perform HTTP request for telemetry. + */ + private fun performTelemetryHttp(endpoint: String, body: String, requiresAuth: Boolean) { + try { + // Build full URL - endpoint is relative path like "/api/v1/sdk/telemetry" + val effectiveBaseUrl = getEffectiveBaseUrl(currentEnvironment) + + // Check if base URL is configured + if (effectiveBaseUrl.isEmpty()) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Telemetry base URL not configured, skipping HTTP request to $endpoint. Events will be queued.", + ) + return + } + + val fullUrl = "$effectiveBaseUrl$endpoint" + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "📤 Telemetry HTTP POST to: $fullUrl", + ) + + // Build headers + val headers = + mutableMapOf( + "Content-Type" to "application/json", + "Accept" to "application/json", + "X-SDK-Client" to "RunAnywhereSDK", + "X-SDK-Version" to "1.0.0", + "X-Platform" to "Android", + ) + + // Environment 0=DEV, 1=STAGING, 2=PRODUCTION + // In production/staging: Use Authorization: Bearer {apiKey} + // In development: Use apikey header for Supabase + if (currentEnvironment == 0) { + // DEVELOPMENT mode - use Supabase apikey header + headers["Prefer"] = "return=representation" + val supabaseKey = getSupabaseApiKey() + if (supabaseKey != null) { + headers["apikey"] = supabaseKey + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Added Supabase apikey header (dev mode)", + ) + } else { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "⚠️ No Supabase API key available - request may fail!", + ) + } + } else { + // PRODUCTION/STAGING mode - use Authorization: Bearer {accessToken} + // The accessToken is a JWT obtained from CppBridgeAuth.authenticate() + // Use getValidToken() which automatically refreshes if needed + val accessToken = CppBridgeAuth.getValidToken() + if (accessToken != null) { + headers["Authorization"] = "Bearer $accessToken" + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Added Authorization Bearer header with JWT (prod/staging mode)", + ) + } else { + // Fallback to API key if no JWT available + // This can happen if authenticate() hasn't been called yet + val apiKey = _apiKey + if (apiKey != null) { + headers["Authorization"] = "Bearer $apiKey" + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "⚠️ No JWT token - using API key directly (may fail if backend requires JWT)", + ) + } else { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "⚠️ No access token or API key available - request may fail!", + ) + } + } + } + + // Allow interceptor to add auth headers if required + if (requiresAuth) { + requestInterceptor?.onBeforeRequest(fullUrl, HttpMethod.POST, headers) + } + + // Log request body for debugging (truncated) + val bodyPreview = if (body.length > 200) body.substring(0, 200) + "..." else body + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Request body: $bodyPreview", + ) + + val (statusCode, response) = sendTelemetry(fullUrl, HttpMethod.POST, headers, body) + + if (HttpStatus.isSuccess(statusCode)) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "✅ Telemetry sent successfully (status=$statusCode)", + ) + if (response != null) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Response: $response", + ) + } + } else { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "❌ Telemetry HTTP failed: status=$statusCode, response=$response", + ) + } + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "❌ Telemetry HTTP error: ${e.message}, cause: ${e.cause?.message}", + ) + } + } + + /** + * Flush pending telemetry events. + */ + fun flush() { + synchronized(lock) { + if (telemetryManagerHandle != 0L) { + com.runanywhere.sdk.native.bridge.RunAnywhereBridge + .racTelemetryManagerFlush(telemetryManagerHandle) + } + } + } + + /** + * Check if the telemetry callback is registered. + */ + fun isRegistered(): Boolean = isRegistered + + /** + * Get the telemetry manager handle for analytics events callback registration. + * Returns 0 if telemetry manager is not initialized. + */ + fun getTelemetryHandle(): Long = telemetryManagerHandle + + // ======================================================================== + // HTTP CALLBACK + // ======================================================================== + + /** + * HTTP callback invoked by C++ core to send telemetry data. + * + * Performs an HTTP request and returns the response via the completion callback. + * + * @param requestId Unique identifier for this request + * @param url The request URL + * @param method The HTTP method (see [HttpMethod] constants) + * @param headers JSON-encoded headers map, or null for no headers + * @param body Request body as string, or null for no body + * @param completionCallbackId ID for the C++ completion callback to invoke with the response + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun httpCallback( + requestId: String, + url: String, + method: Int, + headers: String?, + body: String?, + completionCallbackId: Long, + ) { + // Log the request for debugging + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "HTTP ${HttpMethod.getName(method)} request to: $url", + ) + + // Notify listener of request start + try { + telemetryListener?.onRequestStart(requestId, url, method) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in telemetry listener onRequestStart: ${e.message}", + ) + } + + // Execute HTTP request on background thread + httpExecutor.execute { + executeHttpRequest( + requestId = requestId, + url = url, + method = method, + headersJson = headers, + body = body, + completionCallbackId = completionCallbackId, + ) + } + } + + /** + * Execute an HTTP request synchronously. + */ + @Suppress("UNUSED_PARAMETER") + private fun executeHttpRequest( + requestId: String, + url: String, + method: Int, + headersJson: String?, + body: String?, + completionCallbackId: Long, // Reserved for future async callback support + ) { + var connection: HttpURLConnection? = null + var statusCode = -1 + var responseBody: String? = null + var errorMessage: String? = null + + try { + // Parse headers from JSON if provided + val headers = mutableMapOf() + if (headersJson != null) { + parseHeadersJson(headersJson, headers) + } + + // Allow interceptor to modify request + val finalUrl = requestInterceptor?.onBeforeRequest(url, method, headers) ?: url + + // Create connection + val urlObj = URL(finalUrl) + connection = urlObj.openConnection() as HttpURLConnection + connection.requestMethod = HttpMethod.getName(method) + connection.connectTimeout = DEFAULT_CONNECT_TIMEOUT_MS + connection.readTimeout = DEFAULT_READ_TIMEOUT_MS + connection.doInput = true + + // Set headers + for ((key, value) in headers) { + connection.setRequestProperty(key, value) + } + + // Set default content type if not specified and body is present + if (body != null && !headers.containsKey("Content-Type")) { + connection.setRequestProperty("Content-Type", "application/json") + } + + // Write body if present + if (body != null && method != HttpMethod.GET) { + connection.doOutput = true + OutputStreamWriter(connection.outputStream, Charsets.UTF_8).use { writer -> + writer.write(body) + writer.flush() + } + } + + // Get response + statusCode = connection.responseCode + + // Read response body + val inputStream = + if (HttpStatus.isSuccess(statusCode)) { + connection.inputStream + } else { + connection.errorStream + } + + if (inputStream != null) { + BufferedReader(InputStreamReader(inputStream, Charsets.UTF_8)).use { reader -> + responseBody = reader.readText() + } + } + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "HTTP response: $statusCode", + ) + } catch (e: Exception) { + errorMessage = e.message ?: "Unknown error" + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "HTTP request failed: $errorMessage", + ) + } finally { + connection?.disconnect() + } + + // Notify listener of completion + val success = HttpStatus.isSuccess(statusCode) + try { + telemetryListener?.onRequestComplete(requestId, statusCode, success, errorMessage) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in telemetry listener onRequestComplete: ${e.message}", + ) + } + + // Note: The new telemetry manager handles completion internally + // via the HTTP callback mechanism. No explicit completion callback needed. + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "HTTP request completed with status: $statusCode", + ) + } + + /** + * Parse a JSON string of headers into a mutable map. + * Simple JSON parsing without external dependencies. + */ + private fun parseHeadersJson(json: String, headers: MutableMap) { + // Simple JSON parsing for {"key": "value", ...} format + // Handles basic cases without external dependencies + try { + val trimmed = json.trim() + if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) { + return + } + + val content = trimmed.substring(1, trimmed.length - 1) + if (content.isBlank()) { + return + } + + // Split by comma, but not within quoted strings + var depth = 0 + var start = 0 + val pairs = mutableListOf() + + for (i in content.indices) { + when (content[i]) { + '"' -> { + // Skip to closing quote + var j = i + 1 + while (j < content.length && content[j] != '"') { + if (content[j] == '\\') j++ // Skip escaped char + j++ + } + } + '{', '[' -> depth++ + '}', ']' -> depth-- + ',' -> + if (depth == 0) { + pairs.add(content.substring(start, i).trim()) + start = i + 1 + } + } + } + pairs.add(content.substring(start).trim()) + + // Parse each key-value pair + for (pair in pairs) { + val colonIndex = pair.indexOf(':') + if (colonIndex > 0) { + val key = pair.substring(0, colonIndex).trim().removeSurrounding("\"") + val value = pair.substring(colonIndex + 1).trim().removeSurrounding("\"") + if (key.isNotEmpty()) { + headers[key] = value + } + } + } + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Failed to parse headers JSON: ${e.message}", + ) + } + } + + // ======================================================================== + // LIFECYCLE MANAGEMENT + // ======================================================================== + + /** + * Unregister the telemetry HTTP callback and clean up resources. + * + * Called during SDK shutdown. + */ + fun unregister() { + synchronized(lock) { + if (!isRegistered) { + return + } + + // Destroy telemetry manager + if (telemetryManagerHandle != 0L) { + com.runanywhere.sdk.native.bridge.RunAnywhereBridge + .racTelemetryManagerDestroy(telemetryManagerHandle) + telemetryManagerHandle = 0 + } + + requestInterceptor = null + telemetryListener = null + isRegistered = false + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Telemetry unregistered", + ) + } + } + + // ======================================================================== + // UTILITY FUNCTIONS + // ======================================================================== + + /** + * Send telemetry data synchronously from Kotlin code. + * + * This is a utility method for sending telemetry from Kotlin directly, + * not intended for use by C++ callbacks. + * + * @param url The request URL + * @param method The HTTP method (see [HttpMethod] constants) + * @param headers Map of headers to send + * @param body Request body, or null for no body + * @return Pair of (statusCode, responseBody), or (-1, null) on error + */ + fun sendTelemetry( + url: String, + method: Int = HttpMethod.POST, + headers: Map? = null, + body: String? = null, + ): Pair { + var connection: HttpURLConnection? = null + + try { + val urlObj = URL(url) + connection = urlObj.openConnection() as HttpURLConnection + connection.requestMethod = HttpMethod.getName(method) + connection.connectTimeout = DEFAULT_CONNECT_TIMEOUT_MS + connection.readTimeout = DEFAULT_READ_TIMEOUT_MS + connection.doInput = true + + // Set headers + headers?.forEach { (key, value) -> + connection.setRequestProperty(key, value) + } + + // Set default content type if not specified and body is present + if (body != null && headers?.containsKey("Content-Type") != true) { + connection.setRequestProperty("Content-Type", "application/json") + } + + // Write body if present + if (body != null && method != HttpMethod.GET) { + connection.doOutput = true + OutputStreamWriter(connection.outputStream, Charsets.UTF_8).use { writer -> + writer.write(body) + writer.flush() + } + } + + val statusCode = connection.responseCode + + val inputStream = + if (HttpStatus.isSuccess(statusCode)) { + connection.inputStream + } else { + connection.errorStream + } + + val responseBody = + if (inputStream != null) { + BufferedReader(InputStreamReader(inputStream, Charsets.UTF_8)).use { reader -> + reader.readText() + } + } else { + null + } + + return Pair(statusCode, responseBody) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "sendTelemetry failed: ${e.message}", + ) + return Pair(-1, null) + } finally { + connection?.disconnect() + } + } + + /** + * Send a POST request with JSON body. + * + * Convenience method for common telemetry use case. + * + * @param url The request URL + * @param jsonBody The JSON body to send + * @param additionalHeaders Additional headers to include + * @return Pair of (statusCode, responseBody), or (-1, null) on error + */ + fun sendJsonPost( + url: String, + jsonBody: String, + additionalHeaders: Map? = null, + ): Pair { + val headers = mutableMapOf("Content-Type" to "application/json") + additionalHeaders?.let { headers.putAll(it) } + return sendTelemetry(url, HttpMethod.POST, headers, jsonBody) + } +} diff --git a/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeVAD.kt b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeVAD.kt new file mode 100644 index 000000000..752c6003b --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeVAD.kt @@ -0,0 +1,1474 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * VAD extension for CppBridge. + * Provides Voice Activity Detection component lifecycle management for C++ core. + * + * Follows iOS CppBridge+VAD.swift architecture. + */ + +package com.runanywhere.sdk.foundation.bridge.extensions + +import com.runanywhere.sdk.foundation.bridge.CppBridge +import com.runanywhere.sdk.foundation.errors.SDKError +import com.runanywhere.sdk.native.bridge.RunAnywhereBridge + +/** + * VAD bridge that provides Voice Activity Detection component lifecycle management for C++ core. + * + * The C++ core needs VAD component management for: + * - Creating and destroying VAD instances + * - Loading and unloading models + * - Audio processing for speech detection + * - Canceling ongoing operations + * - Component state tracking + * + * Usage: + * - Called during Phase 2 initialization in [CppBridge.initializeServices] + * - Must be registered after [CppBridgePlatformAdapter] and [CppBridgeModelRegistry] are registered + * + * Thread Safety: + * - This object is thread-safe via synchronized blocks + * - All callbacks are thread-safe + * - Matches iOS Actor-based pattern using Kotlin synchronized + */ +object CppBridgeVAD { + /** + * VAD component state constants matching C++ RAC_VAD_STATE_* values. + */ + object VADState { + /** Component not created */ + const val NOT_CREATED = 0 + + /** Component created but no model loaded */ + const val CREATED = 1 + + /** Model is loading */ + const val LOADING = 2 + + /** Model loaded and ready for detection */ + const val READY = 3 + + /** Detection in progress */ + const val DETECTING = 4 + + /** Model is unloading */ + const val UNLOADING = 5 + + /** Component in error state */ + const val ERROR = 6 + + /** + * Get a human-readable name for the VAD state. + */ + fun getName(state: Int): String = + when (state) { + NOT_CREATED -> "NOT_CREATED" + CREATED -> "CREATED" + LOADING -> "LOADING" + READY -> "READY" + DETECTING -> "DETECTING" + UNLOADING -> "UNLOADING" + ERROR -> "ERROR" + else -> "UNKNOWN($state)" + } + + /** + * Check if the state indicates the component is usable. + */ + fun isReady(state: Int): Boolean = state == READY + } + + /** + * Audio format constants for VAD input. + */ + object AudioFormat { + /** 16-bit PCM audio */ + const val PCM_16 = 0 + + /** 32-bit float audio */ + const val PCM_FLOAT = 1 + + /** + * Get a human-readable name for the audio format. + */ + fun getName(format: Int): String = + when (format) { + PCM_16 -> "PCM_16" + PCM_FLOAT -> "PCM_FLOAT" + else -> "UNKNOWN($format)" + } + } + + /** + * Detection mode constants. + */ + object DetectionMode { + /** Frame-by-frame detection */ + const val FRAME = 0 + + /** Continuous streaming detection */ + const val STREAM = 1 + + /** Segment-based detection (returns speech segments) */ + const val SEGMENT = 2 + + /** + * Get a human-readable name for the detection mode. + */ + fun getName(mode: Int): String = + when (mode) { + FRAME -> "FRAME" + STREAM -> "STREAM" + SEGMENT -> "SEGMENT" + else -> "UNKNOWN($mode)" + } + } + + /** + * Detection event type constants. + */ + object EventType { + /** No speech detected */ + const val SILENCE = 0 + + /** Speech started */ + const val SPEECH_START = 1 + + /** Speech ongoing */ + const val SPEECH_ONGOING = 2 + + /** Speech ended */ + const val SPEECH_END = 3 + + /** + * Get a human-readable name for the event type. + */ + fun getName(type: Int): String = + when (type) { + SILENCE -> "SILENCE" + SPEECH_START -> "SPEECH_START" + SPEECH_ONGOING -> "SPEECH_ONGOING" + SPEECH_END -> "SPEECH_END" + else -> "UNKNOWN($type)" + } + } + + @Volatile + private var isRegistered: Boolean = false + + @Volatile + private var state: Int = VADState.NOT_CREATED + + @Volatile + private var handle: Long = 0 + + @Volatile + private var loadedModelId: String? = null + + @Volatile + private var loadedModelPath: String? = null + + @Volatile + private var isCancelled: Boolean = false + + private val lock = Any() + + /** + * Tag for logging. + */ + private const val TAG = "CppBridgeVAD" + + /** + * Singleton shared instance for accessing the VAD component. + * Matches iOS CppBridge.VAD.shared pattern. + */ + val shared: CppBridgeVAD = this + + /** + * Optional listener for VAD events. + * Set this before calling [register] to receive events. + */ + @Volatile + var vadListener: VADListener? = null + + /** + * Optional streaming callback for real-time detection results. + * This is invoked for each detection event during streaming. + */ + @Volatile + var streamCallback: StreamCallback? = null + + /** + * VAD detection configuration. + * + * @param sampleRate Audio sample rate in Hz (default: 16000) + * @param channels Number of audio channels (default: 1 = mono) + * @param audioFormat Audio format type + * @param frameLength Frame length in milliseconds (default: 30) + * @param threshold Detection threshold (0.0 to 1.0, default: 0.5) + * @param minSpeechDurationMs Minimum speech duration in milliseconds + * @param minSilenceDurationMs Minimum silence duration to consider speech ended + * @param padding Padding in milliseconds to add around speech segments + * @param mode Detection mode (frame, stream, or segment) + */ + data class DetectionConfig( + val sampleRate: Int = 16000, + val channels: Int = 1, + val audioFormat: Int = AudioFormat.PCM_16, + val frameLength: Int = 30, + val threshold: Float = 0.5f, + val minSpeechDurationMs: Int = 250, + val minSilenceDurationMs: Int = 300, + val padding: Int = 100, + val mode: Int = DetectionMode.STREAM, + ) { + /** + * Convert to JSON string for C++ interop. + */ + fun toJson(): String { + return buildString { + append("{") + append("\"sample_rate\":$sampleRate,") + append("\"channels\":$channels,") + append("\"audio_format\":$audioFormat,") + append("\"frame_length\":$frameLength,") + append("\"threshold\":$threshold,") + append("\"min_speech_duration_ms\":$minSpeechDurationMs,") + append("\"min_silence_duration_ms\":$minSilenceDurationMs,") + append("\"padding\":$padding,") + append("\"mode\":$mode") + append("}") + } + } + + companion object { + /** Default configuration */ + val DEFAULT = DetectionConfig() + } + } + + /** + * VAD model configuration. + * + * @param threads Number of threads for inference (-1 for auto) + * @param gpuEnabled Whether to use GPU acceleration + */ + data class ModelConfig( + val threads: Int = -1, + val gpuEnabled: Boolean = false, + ) { + /** + * Convert to JSON string for C++ interop. + */ + fun toJson(): String { + return buildString { + append("{") + append("\"threads\":$threads,") + append("\"gpu_enabled\":$gpuEnabled") + append("}") + } + } + + companion object { + /** Default configuration */ + val DEFAULT = ModelConfig() + } + } + + /** + * Speech segment information. + * + * @param startMs Start time in milliseconds + * @param endMs End time in milliseconds + * @param confidence Confidence score (0.0 to 1.0) + */ + data class SpeechSegment( + val startMs: Long, + val endMs: Long, + val confidence: Float, + ) { + /** + * Get the duration of the segment in milliseconds. + */ + fun getDurationMs(): Long = endMs - startMs + } + + /** + * VAD detection result for a single frame. + * + * @param isSpeech Whether speech is detected + * @param probability Speech probability (0.0 to 1.0) + * @param eventType Event type (see [EventType]) + * @param timestampMs Timestamp in milliseconds + */ + data class FrameResult( + val isSpeech: Boolean, + val probability: Float, + val eventType: Int, + val timestampMs: Long, + ) { + /** + * Get the event type name. + */ + fun getEventTypeName(): String = EventType.getName(eventType) + } + + /** + * VAD detection result containing speech segments. + * + * @param segments List of detected speech segments + * @param audioDurationMs Total audio duration in milliseconds + * @param processingTimeMs Time spent processing in milliseconds + * @param hasSpeech Whether any speech was detected + */ + data class DetectionResult( + val segments: List, + val audioDurationMs: Long, + val processingTimeMs: Long, + val hasSpeech: Boolean, + ) { + /** + * Get the total speech duration in milliseconds. + */ + fun getTotalSpeechDurationMs(): Long = segments.sumOf { it.getDurationMs() } + + /** + * Get the speech ratio (speech time / total time). + */ + fun getSpeechRatio(): Float { + if (audioDurationMs == 0L) return 0f + return getTotalSpeechDurationMs().toFloat() / audioDurationMs + } + } + + /** + * Listener interface for VAD events. + */ + interface VADListener { + /** + * Called when the VAD component state changes. + * + * @param previousState The previous state + * @param newState The new state + */ + fun onStateChanged(previousState: Int, newState: Int) + + /** + * Called when a model is loaded. + * + * @param modelId The model ID + * @param modelPath The model path + */ + fun onModelLoaded(modelId: String, modelPath: String) + + /** + * Called when a model is unloaded. + * + * @param modelId The previously loaded model ID + */ + fun onModelUnloaded(modelId: String) + + /** + * Called when detection starts. + */ + fun onDetectionStarted() + + /** + * Called when detection completes. + * + * @param result The detection result + */ + fun onDetectionCompleted(result: DetectionResult) + + /** + * Called when a frame is processed during streaming. + * + * @param frameResult The frame result + */ + fun onFrameResult(frameResult: FrameResult) + + /** + * Called when speech starts during streaming. + * + * @param timestampMs Timestamp in milliseconds + */ + fun onSpeechStart(timestampMs: Long) + + /** + * Called when speech ends during streaming. + * + * @param timestampMs Timestamp in milliseconds + * @param segment The detected speech segment + */ + fun onSpeechEnd(timestampMs: Long, segment: SpeechSegment) + + /** + * Called when an error occurs. + * + * @param errorCode The error code + * @param errorMessage The error message + */ + fun onError(errorCode: Int, errorMessage: String) + } + + /** + * Callback interface for streaming detection. + */ + fun interface StreamCallback { + /** + * Called for each detection frame. + * + * @param isSpeech Whether speech is detected + * @param probability Speech probability + * @param eventType Event type (see [EventType]) + * @return true to continue detection, false to stop + */ + fun onFrame(isSpeech: Boolean, probability: Float, eventType: Int): Boolean + } + + /** + * Register the VAD callbacks with C++ core. + * + * This must be called during SDK initialization, after [CppBridgePlatformAdapter.register]. + * It is safe to call multiple times; subsequent calls are no-ops. + */ + fun register() { + synchronized(lock) { + if (isRegistered) { + return + } + + // TODO: Call native registration + // nativeSetVADCallbacks() + + isRegistered = true + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "VAD callbacks registered", + ) + } + } + + /** + * Check if the VAD callbacks are registered. + */ + fun isRegistered(): Boolean = isRegistered + + /** + * Get the current component handle. + * + * @return The native handle, or throws if not created + * @throws SDKError if the component is not created + */ + @Throws(SDKError::class) + fun getHandle(): Long { + synchronized(lock) { + if (handle == 0L) { + throw SDKError.notInitialized("VAD component not created") + } + return handle + } + } + + /** + * Check if a model is loaded. + */ + val isLoaded: Boolean + get() = synchronized(lock) { state == VADState.READY && loadedModelId != null } + + /** + * Check if the component is ready for detection. + */ + val isReady: Boolean + get() = VADState.isReady(state) + + /** + * Get the currently loaded model ID. + */ + fun getLoadedModelId(): String? = loadedModelId + + /** + * Get the currently loaded model path. + */ + fun getLoadedModelPath(): String? = loadedModelPath + + /** + * Get the current component state. + */ + fun getState(): Int = state + + // ======================================================================== + // LIFECYCLE OPERATIONS + // ======================================================================== + + /** + * Create the VAD component. + * + * @return 0 on success, error code on failure + */ + fun create(): Int { + synchronized(lock) { + if (handle != 0L) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "VAD component already created", + ) + return 0 + } + + // Check if native commons library is loaded + if (!CppBridge.isNativeLibraryLoaded) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "Native library not loaded. VAD inference requires native libraries to be bundled.", + ) + throw SDKError.notInitialized("Native library not available. Please ensure the native libraries are bundled in your APK.") + } + + // Create VAD component via RunAnywhereBridge + val result = + try { + RunAnywhereBridge.racVadComponentCreate() + } catch (e: UnsatisfiedLinkError) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "VAD component creation failed. Native method not available: ${e.message}", + ) + throw SDKError.notInitialized("VAD native library not available. Please ensure the VAD backend is bundled in your APK.") + } + + if (result == 0L) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "Failed to create VAD component", + ) + return -1 + } + + handle = result + setState(VADState.CREATED) + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "VAD component created", + ) + + return 0 + } + } + + /** + * Load a model. + * + * @param modelPath Path to the model file + * @param modelId Unique identifier for the model + * @param config Model configuration (optional) + * @return 0 on success, error code on failure + */ + fun loadModel(modelPath: String, modelId: String, config: ModelConfig = ModelConfig.DEFAULT): Int { + synchronized(lock) { + if (handle == 0L) { + // Auto-create component if needed + val createResult = create() + if (createResult != 0) { + return createResult + } + } + + if (loadedModelId != null) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Unloading current model before loading new one: $loadedModelId", + ) + unload() + } + + setState(VADState.LOADING) + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "Loading model: $modelId from $modelPath", + ) + + val result = RunAnywhereBridge.racVadComponentLoadModel(handle, modelPath, config.toJson()) + if (result != 0) { + setState(VADState.ERROR) + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "Failed to load model: $modelId (error: $result)", + ) + + try { + vadListener?.onError(result, "Failed to load model: $modelId") + } catch (e: Exception) { + // Ignore listener errors + } + + return result + } + + loadedModelId = modelId + loadedModelPath = modelPath + setState(VADState.READY) + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "Model loaded successfully: $modelId", + ) + + // Update model assignment status + CppBridgeModelAssignment.setAssignmentStatusCallback( + CppBridgeModelRegistry.ModelType.VAD, + CppBridgeModelAssignment.AssignmentStatus.READY, + CppBridgeModelAssignment.FailureReason.NONE, + ) + + // Update component state + CppBridgeState.setComponentStateCallback( + CppBridgeState.ComponentType.VAD, + CppBridgeState.ComponentState.READY, + ) + + try { + vadListener?.onModelLoaded(modelId, modelPath) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in VAD listener onModelLoaded: ${e.message}", + ) + } + + return 0 + } + } + + /** + * Process audio data for voice activity detection. + * + * @param audioData Raw audio data bytes + * @param config Detection configuration (optional) + * @return The detection result + * @throws SDKError if detection fails + */ + @Throws(SDKError::class) + fun process(audioData: ByteArray, config: DetectionConfig = DetectionConfig.DEFAULT): DetectionResult { + synchronized(lock) { + if (handle == 0L || state != VADState.READY) { + throw SDKError.vad("VAD component not ready for detection") + } + + isCancelled = false + setState(VADState.DETECTING) + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Starting detection (audio size: ${audioData.size} bytes)", + ) + + try { + vadListener?.onDetectionStarted() + } catch (e: Exception) { + // Ignore listener errors + } + + val startTime = System.currentTimeMillis() + + try { + val resultJson = + RunAnywhereBridge.racVadComponentProcess(handle, audioData, config.toJson()) + ?: throw SDKError.vad("Detection failed: null result") + + val result = parseDetectionResult(resultJson, System.currentTimeMillis() - startTime) + + setState(VADState.READY) + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Detection completed: ${result.segments.size} segments, ${result.processingTimeMs}ms", + ) + + try { + vadListener?.onDetectionCompleted(result) + } catch (e: Exception) { + // Ignore listener errors + } + + return result + } catch (e: Exception) { + setState(VADState.READY) // Reset to ready, not error + throw if (e is SDKError) e else SDKError.vad("Detection failed: ${e.message}") + } + } + } + + /** + * Process audio with streaming output. + * + * @param audioData Raw audio data bytes + * @param config Detection configuration (optional) + * @param callback Callback for frame results + * @return The final detection result + * @throws SDKError if detection fails + */ + @Throws(SDKError::class) + fun processStream( + audioData: ByteArray, + config: DetectionConfig = DetectionConfig.DEFAULT, + callback: StreamCallback, + ): DetectionResult { + synchronized(lock) { + if (handle == 0L || state != VADState.READY) { + throw SDKError.vad("VAD component not ready for detection") + } + + isCancelled = false + streamCallback = callback + setState(VADState.DETECTING) + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Starting streaming detection (audio size: ${audioData.size} bytes)", + ) + + try { + vadListener?.onDetectionStarted() + } catch (e: Exception) { + // Ignore listener errors + } + + val startTime = System.currentTimeMillis() + + try { + val resultJson = + RunAnywhereBridge.racVadComponentProcessStream(handle, audioData, config.toJson()) + ?: throw SDKError.vad("Streaming detection failed: null result") + + val result = parseDetectionResult(resultJson, System.currentTimeMillis() - startTime) + + setState(VADState.READY) + streamCallback = null + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Streaming detection completed: ${result.segments.size} segments", + ) + + try { + vadListener?.onDetectionCompleted(result) + } catch (e: Exception) { + // Ignore listener errors + } + + return result + } catch (e: Exception) { + setState(VADState.READY) // Reset to ready, not error + streamCallback = null + throw if (e is SDKError) e else SDKError.vad("Streaming detection failed: ${e.message}") + } + } + } + + /** + * Process a single audio frame for real-time detection. + * + * @param audioData Raw audio data bytes for the frame + * @param config Detection configuration (optional) + * @return The frame result + * @throws SDKError if processing fails + */ + @Throws(SDKError::class) + fun processFrame(audioData: ByteArray, config: DetectionConfig = DetectionConfig.DEFAULT): FrameResult { + synchronized(lock) { + if (handle == 0L || state != VADState.READY) { + throw SDKError.vad("VAD component not ready for detection") + } + + try { + val resultJson = + RunAnywhereBridge.racVadComponentProcessFrame(handle, audioData, config.toJson()) + ?: throw SDKError.vad("Frame processing failed: null result") + + return parseFrameResult(resultJson) + } catch (e: Exception) { + throw if (e is SDKError) e else SDKError.vad("Frame processing failed: ${e.message}") + } + } + } + + /** + * Cancel an ongoing detection. + */ + fun cancel() { + synchronized(lock) { + if (state != VADState.DETECTING) { + return + } + + isCancelled = true + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Cancelling detection", + ) + + RunAnywhereBridge.racVadComponentCancel(handle) + } + } + + /** + * Reset the VAD state for a new stream. + * + * Call this when starting a new audio stream to clear internal buffers. + */ + fun reset() { + synchronized(lock) { + if (handle == 0L) { + return + } + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Resetting VAD state", + ) + + RunAnywhereBridge.racVadComponentReset(handle) + } + } + + /** + * Unload the current model. + */ + fun unload() { + synchronized(lock) { + if (loadedModelId == null) { + return + } + + val previousModelId = loadedModelId ?: return + + setState(VADState.UNLOADING) + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "Unloading model: $previousModelId", + ) + + RunAnywhereBridge.racVadComponentUnload(handle) + + loadedModelId = null + loadedModelPath = null + setState(VADState.CREATED) + + // Update model assignment status + CppBridgeModelAssignment.setAssignmentStatusCallback( + CppBridgeModelRegistry.ModelType.VAD, + CppBridgeModelAssignment.AssignmentStatus.NOT_ASSIGNED, + CppBridgeModelAssignment.FailureReason.NONE, + ) + + // Update component state + CppBridgeState.setComponentStateCallback( + CppBridgeState.ComponentType.VAD, + CppBridgeState.ComponentState.CREATED, + ) + + try { + vadListener?.onModelUnloaded(previousModelId) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in VAD listener onModelUnloaded: ${e.message}", + ) + } + } + } + + /** + * Destroy the VAD component and release resources. + */ + fun destroy() { + synchronized(lock) { + if (handle == 0L) { + return + } + + // Unload model first if loaded + if (loadedModelId != null) { + unload() + } + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "Destroying VAD component", + ) + + RunAnywhereBridge.racVadComponentDestroy(handle) + + handle = 0 + setState(VADState.NOT_CREATED) + + // Update component state + CppBridgeState.setComponentStateCallback( + CppBridgeState.ComponentType.VAD, + CppBridgeState.ComponentState.NOT_CREATED, + ) + } + } + + // ======================================================================== + // JNI CALLBACKS + // ======================================================================== + + /** + * Streaming frame callback. + * + * Called from C++ for each processed frame during streaming. + * + * @param isSpeech Whether speech is detected + * @param probability Speech probability + * @param eventType Event type (see [EventType]) + * @return true to continue detection, false to stop + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun streamFrameCallback(isSpeech: Boolean, probability: Float, eventType: Int): Boolean { + if (isCancelled) { + return false + } + + val callback = streamCallback ?: return true + + // Create frame result for listener + val frameResult = + FrameResult( + isSpeech = isSpeech, + probability = probability, + eventType = eventType, + timestampMs = System.currentTimeMillis(), + ) + + // Notify listener + try { + vadListener?.onFrameResult(frameResult) + } catch (e: Exception) { + // Ignore listener errors + } + + return try { + callback.onFrame(isSpeech, probability, eventType) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in stream callback: ${e.message}", + ) + true // Continue on error + } + } + + /** + * Speech start callback. + * + * Called from C++ when speech is detected starting. + * + * @param timestampMs Timestamp in milliseconds + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun speechStartCallback(timestampMs: Long) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Speech started at ${timestampMs}ms", + ) + + try { + vadListener?.onSpeechStart(timestampMs) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in VAD listener onSpeechStart: ${e.message}", + ) + } + } + + /** + * Speech end callback. + * + * Called from C++ when speech is detected ending. + * + * @param startMs Segment start timestamp in milliseconds + * @param endMs Segment end timestamp in milliseconds + * @param confidence Confidence score + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun speechEndCallback(startMs: Long, endMs: Long, confidence: Float) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Speech ended: ${startMs}ms - ${endMs}ms (confidence: $confidence)", + ) + + val segment = SpeechSegment(startMs, endMs, confidence) + + try { + vadListener?.onSpeechEnd(endMs, segment) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in VAD listener onSpeechEnd: ${e.message}", + ) + } + } + + /** + * Progress callback. + * + * Called from C++ to report model loading progress. + * + * @param progress Progress (0.0 to 1.0) + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun progressCallback(progress: Float) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Progress: ${(progress * 100).toInt()}%", + ) + } + + /** + * Get state callback. + * + * @return The current VAD component state + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun getStateCallback(): Int { + return state + } + + /** + * Is loaded callback. + * + * @return true if a model is loaded + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun isLoadedCallback(): Boolean { + return loadedModelId != null && state == VADState.READY + } + + /** + * Get loaded model ID callback. + * + * @return The loaded model ID, or null + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun getLoadedModelIdCallback(): String? { + return loadedModelId + } + + // ======================================================================== + // JNI NATIVE DECLARATIONS + // ======================================================================== + + /** + * Native method to set the VAD callbacks with C++ core. + * + * Registers [streamFrameCallback], [speechStartCallback], + * [speechEndCallback], [progressCallback], etc. with C++ core. + * Reserved for future native callback integration. + * + * C API: rac_vad_set_callbacks(...) + */ + @Suppress("unused") + @JvmStatic + private external fun nativeSetVADCallbacks() + + /** + * Native method to unset the VAD callbacks. + * + * Called during shutdown to clean up native resources. + * Reserved for future native callback integration. + * + * C API: rac_vad_set_callbacks(nullptr) + */ + @Suppress("unused") + @JvmStatic + private external fun nativeUnsetVADCallbacks() + + /** + * Native method to create the VAD component. + * + * @return Handle to the created component, or 0 on failure + * + * C API: rac_vad_component_create() + */ + @JvmStatic + external fun nativeCreate(): Long + + /** + * Native method to load a model. + * + * @param handle The component handle + * @param modelPath Path to the model file + * @param configJson JSON configuration string + * @return 0 on success, error code on failure + * + * C API: rac_vad_component_load_model(handle, model_path, config) + */ + @JvmStatic + external fun nativeLoadModel(handle: Long, modelPath: String, configJson: String): Int + + /** + * Native method to process audio data. + * + * @param handle The component handle + * @param audioData Raw audio bytes + * @param configJson JSON configuration string + * @return JSON-encoded result, or null on failure + * + * C API: rac_vad_component_process(handle, audio_data, audio_size, config) + */ + @JvmStatic + external fun nativeProcess(handle: Long, audioData: ByteArray, configJson: String): String? + + /** + * Native method to process audio with streaming. + * + * @param handle The component handle + * @param audioData Raw audio bytes + * @param configJson JSON configuration string + * @return JSON-encoded result, or null on failure + * + * C API: rac_vad_component_process_stream(handle, audio_data, audio_size, config) + */ + @JvmStatic + external fun nativeProcessStream(handle: Long, audioData: ByteArray, configJson: String): String? + + /** + * Native method to process a single audio frame. + * + * @param handle The component handle + * @param audioData Raw audio bytes for the frame + * @param configJson JSON configuration string + * @return JSON-encoded result, or null on failure + * + * C API: rac_vad_component_process_frame(handle, audio_data, audio_size, config) + */ + @JvmStatic + external fun nativeProcessFrame(handle: Long, audioData: ByteArray, configJson: String): String? + + /** + * Native method to cancel detection. + * + * @param handle The component handle + * + * C API: rac_vad_component_cancel(handle) + */ + @JvmStatic + external fun nativeCancel(handle: Long) + + /** + * Native method to reset the VAD state. + * + * @param handle The component handle + * + * C API: rac_vad_component_reset(handle) + */ + @JvmStatic + external fun nativeReset(handle: Long) + + /** + * Native method to unload the model. + * + * @param handle The component handle + * + * C API: rac_vad_component_unload(handle) + */ + @JvmStatic + external fun nativeUnload(handle: Long) + + /** + * Native method to destroy the component. + * + * @param handle The component handle + * + * C API: rac_vad_component_destroy(handle) + */ + @JvmStatic + external fun nativeDestroy(handle: Long) + + /** + * Native method to get the minimum frame size. + * + * @param handle The component handle + * @return The minimum frame size in samples + * + * C API: rac_vad_component_get_min_frame_size(handle) + */ + @JvmStatic + external fun nativeGetMinFrameSize(handle: Long): Int + + /** + * Native method to get the supported sample rates. + * + * @param handle The component handle + * @return JSON array of supported sample rates + * + * C API: rac_vad_component_get_sample_rates(handle) + */ + @JvmStatic + external fun nativeGetSampleRates(handle: Long): String? + + // ======================================================================== + // LIFECYCLE MANAGEMENT + // ======================================================================== + + /** + * Unregister the VAD callbacks and clean up resources. + * + * Called during SDK shutdown. + */ + fun unregister() { + synchronized(lock) { + if (!isRegistered) { + return + } + + // Destroy component if created + if (handle != 0L) { + destroy() + } + + // TODO: Call native unregistration + // nativeUnsetVADCallbacks() + + vadListener = null + streamCallback = null + isRegistered = false + } + } + + // ======================================================================== + // UTILITY FUNCTIONS + // ======================================================================== + + /** + * Set the component state and notify listeners. + */ + private fun setState(newState: Int) { + val previousState = state + if (newState != previousState) { + state = newState + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "State changed: ${VADState.getName(previousState)} -> ${VADState.getName(newState)}", + ) + + try { + vadListener?.onStateChanged(previousState, newState) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in VAD listener onStateChanged: ${e.message}", + ) + } + } + } + + /** + * Parse detection result from JSON. + */ + private fun parseDetectionResult(json: String, elapsedMs: Long): DetectionResult { + fun extractLong(key: String): Long { + val pattern = "\"$key\"\\s*:\\s*(-?\\d+)" + val regex = Regex(pattern) + return regex + .find(json) + ?.groupValues + ?.get(1) + ?.toLongOrNull() ?: 0L + } + + fun extractBoolean(key: String): Boolean { + val pattern = "\"$key\"\\s*:\\s*(true|false)" + val regex = Regex(pattern) + return regex + .find(json) + ?.groupValues + ?.get(1) + ?.toBooleanStrictOrNull() ?: false + } + + // Parse segments array + val segments = mutableListOf() + val segmentsPattern = "\"segments\"\\s*:\\s*\\[([^\\]]*)]" + val segmentsMatch = Regex(segmentsPattern).find(json) + if (segmentsMatch != null) { + val segmentsContent = segmentsMatch.groupValues[1] + val segmentPattern = "\\{[^}]+\\}" + Regex(segmentPattern).findAll(segmentsContent).forEach { match -> + val segmentJson = match.value + + fun extractFromSegment(key: String): String? { + val p = "\"$key\"\\s*:\\s*(-?[\\d.]+)" + return Regex(p).find(segmentJson)?.groupValues?.get(1) + } + val startMs = extractFromSegment("start_ms")?.toLongOrNull() ?: 0L + val endMs = extractFromSegment("end_ms")?.toLongOrNull() ?: 0L + val confidence = extractFromSegment("confidence")?.toFloatOrNull() ?: 0f + segments.add(SpeechSegment(startMs, endMs, confidence)) + } + } + + val audioDurationMs = extractLong("audio_duration_ms") + val hasSpeech = extractBoolean("has_speech") + + return DetectionResult( + segments = segments, + audioDurationMs = audioDurationMs, + processingTimeMs = elapsedMs, + hasSpeech = hasSpeech, + ) + } + + /** + * Parse frame result from JSON. + */ + private fun parseFrameResult(json: String): FrameResult { + fun extractBoolean(key: String): Boolean { + val pattern = "\"$key\"\\s*:\\s*(true|false)" + val regex = Regex(pattern) + return regex + .find(json) + ?.groupValues + ?.get(1) + ?.toBooleanStrictOrNull() ?: false + } + + fun extractFloat(key: String): Float { + val pattern = "\"$key\"\\s*:\\s*(-?[\\d.]+)" + val regex = Regex(pattern) + return regex + .find(json) + ?.groupValues + ?.get(1) + ?.toFloatOrNull() ?: 0f + } + + fun extractInt(key: String): Int { + val pattern = "\"$key\"\\s*:\\s*(-?\\d+)" + val regex = Regex(pattern) + return regex + .find(json) + ?.groupValues + ?.get(1) + ?.toIntOrNull() ?: 0 + } + + fun extractLong(key: String): Long { + val pattern = "\"$key\"\\s*:\\s*(-?\\d+)" + val regex = Regex(pattern) + return regex + .find(json) + ?.groupValues + ?.get(1) + ?.toLongOrNull() ?: 0L + } + + return FrameResult( + isSpeech = extractBoolean("is_speech"), + probability = extractFloat("probability"), + eventType = extractInt("event_type"), + timestampMs = extractLong("timestamp_ms"), + ) + } + + /** + * Get the minimum frame size for processing. + * + * @return The minimum frame size in samples, or 0 if model not loaded + */ + fun getMinFrameSize(): Int { + synchronized(lock) { + if (handle == 0L || state != VADState.READY) { + return 0 + } + return RunAnywhereBridge.racVadComponentGetMinFrameSize(handle) + } + } + + /** + * Get supported sample rates. + * + * @return List of supported sample rates, or empty list if model not loaded + */ + fun getSupportedSampleRates(): List { + synchronized(lock) { + if (handle == 0L || state != VADState.READY) { + return emptyList() + } + val json = RunAnywhereBridge.racVadComponentGetSampleRates(handle) ?: return emptyList() + // Parse JSON array of integers + val pattern = "\\d+" + return Regex(pattern).findAll(json).map { it.value.toInt() }.toList() + } + } + + /** + * Get a state summary for diagnostics. + * + * @return Human-readable state summary + */ + fun getStateSummary(): String { + return buildString { + append("VAD State: ${VADState.getName(state)}") + if (loadedModelId != null) { + append(", Model: $loadedModelId") + } + if (handle != 0L) { + append(", Handle: $handle") + } + } + } +} diff --git a/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeVoiceAgent.kt b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeVoiceAgent.kt new file mode 100644 index 000000000..d975f5569 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/bridge/extensions/CppBridgeVoiceAgent.kt @@ -0,0 +1,1821 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * Voice Agent extension for CppBridge. + * Provides Voice Agent pipeline management for C++ core. + * + * Follows iOS CppBridge+VoiceAgent.swift architecture. + */ + +package com.runanywhere.sdk.foundation.bridge.extensions + +import com.runanywhere.sdk.foundation.errors.SDKError + +/** + * Voice Agent bridge that provides conversational AI pipeline management for C++ core. + * + * The Voice Agent orchestrates: + * - Voice Activity Detection (VAD) for speech detection + * - Speech-to-Text (STT) for transcription + * - Large Language Model (LLM) for response generation + * - Text-to-Speech (TTS) for audio synthesis + * + * The C++ core needs Voice Agent management for: + * - Creating and destroying Voice Agent instances + * - Initializing the voice pipeline with component models + * - Processing voice turns (full conversation loop) + * - Individual pipeline operations (detect, transcribe, generate, synthesize) + * - Canceling ongoing operations + * - Component state tracking + * + * Usage: + * - Called during Phase 2 initialization in [CppBridge.initializeServices] + * - Must be registered after AI component bridges (LLM, STT, TTS, VAD) are registered + * + * Thread Safety: + * - This object is thread-safe via synchronized blocks + * - All callbacks are thread-safe + * - Matches iOS Actor-based pattern using Kotlin synchronized + */ +object CppBridgeVoiceAgent { + /** + * Voice Agent state constants matching C++ RAC_VOICE_AGENT_STATE_* values. + */ + object VoiceAgentState { + /** Agent not created */ + const val NOT_CREATED = 0 + + /** Agent created but not initialized */ + const val CREATED = 1 + + /** Agent is initializing (loading models) */ + const val INITIALIZING = 2 + + /** Agent initialized and ready */ + const val READY = 3 + + /** Agent is listening for speech */ + const val LISTENING = 4 + + /** Agent is processing speech (STT) */ + const val TRANSCRIBING = 5 + + /** Agent is generating response (LLM) */ + const val GENERATING = 6 + + /** Agent is speaking (TTS) */ + const val SPEAKING = 7 + + /** Agent is processing a complete turn */ + const val PROCESSING_TURN = 8 + + /** Agent in error state */ + const val ERROR = 9 + + /** + * Get a human-readable name for the Voice Agent state. + */ + fun getName(state: Int): String = + when (state) { + NOT_CREATED -> "NOT_CREATED" + CREATED -> "CREATED" + INITIALIZING -> "INITIALIZING" + READY -> "READY" + LISTENING -> "LISTENING" + TRANSCRIBING -> "TRANSCRIBING" + GENERATING -> "GENERATING" + SPEAKING -> "SPEAKING" + PROCESSING_TURN -> "PROCESSING_TURN" + ERROR -> "ERROR" + else -> "UNKNOWN($state)" + } + + /** + * Check if the state indicates the agent is ready. + */ + fun isReady(state: Int): Boolean = state == READY + + /** + * Check if the state indicates the agent is processing. + */ + fun isProcessing(state: Int): Boolean = state in LISTENING..PROCESSING_TURN + } + + /** + * Turn phase constants for tracking conversation flow. + */ + object TurnPhase { + /** No active turn */ + const val IDLE = 0 + + /** Detecting speech activity */ + const val SPEECH_DETECTION = 1 + + /** Transcribing speech to text */ + const val TRANSCRIPTION = 2 + + /** Generating LLM response */ + const val RESPONSE_GENERATION = 3 + + /** Synthesizing speech from response */ + const val SPEECH_SYNTHESIS = 4 + + /** Turn completed */ + const val COMPLETED = 5 + + /** Turn cancelled */ + const val CANCELLED = 6 + + /** Turn failed */ + const val FAILED = 7 + + /** + * Get a human-readable name for the turn phase. + */ + fun getName(phase: Int): String = + when (phase) { + IDLE -> "IDLE" + SPEECH_DETECTION -> "SPEECH_DETECTION" + TRANSCRIPTION -> "TRANSCRIPTION" + RESPONSE_GENERATION -> "RESPONSE_GENERATION" + SPEECH_SYNTHESIS -> "SPEECH_SYNTHESIS" + COMPLETED -> "COMPLETED" + CANCELLED -> "CANCELLED" + FAILED -> "FAILED" + else -> "UNKNOWN($phase)" + } + } + + /** + * Turn completion reason constants. + */ + object CompletionReason { + /** Turn completed successfully */ + const val SUCCESS = 0 + + /** Turn was cancelled by user */ + const val CANCELLED = 1 + + /** No speech detected */ + const val NO_SPEECH = 2 + + /** Transcription failed */ + const val TRANSCRIPTION_FAILED = 3 + + /** Response generation failed */ + const val GENERATION_FAILED = 4 + + /** Speech synthesis failed */ + const val SYNTHESIS_FAILED = 5 + + /** Turn timed out */ + const val TIMEOUT = 6 + + /** Generic error */ + const val ERROR = 7 + + /** + * Get a human-readable name for the completion reason. + */ + fun getName(reason: Int): String = + when (reason) { + SUCCESS -> "SUCCESS" + CANCELLED -> "CANCELLED" + NO_SPEECH -> "NO_SPEECH" + TRANSCRIPTION_FAILED -> "TRANSCRIPTION_FAILED" + GENERATION_FAILED -> "GENERATION_FAILED" + SYNTHESIS_FAILED -> "SYNTHESIS_FAILED" + TIMEOUT -> "TIMEOUT" + ERROR -> "ERROR" + else -> "UNKNOWN($reason)" + } + + /** + * Check if the reason indicates success. + */ + fun isSuccess(reason: Int): Boolean = reason == SUCCESS + } + + /** + * Interrupt mode constants for handling interruptions. + */ + object InterruptMode { + /** No interruption allowed */ + const val NONE = 0 + + /** Interrupt immediately when speech detected */ + const val IMMEDIATE = 1 + + /** Wait for end of phrase before interrupting */ + const val END_OF_PHRASE = 2 + + /** + * Get a human-readable name for the interrupt mode. + */ + fun getName(mode: Int): String = + when (mode) { + NONE -> "NONE" + IMMEDIATE -> "IMMEDIATE" + END_OF_PHRASE -> "END_OF_PHRASE" + else -> "UNKNOWN($mode)" + } + } + + @Volatile + private var isRegistered: Boolean = false + + @Volatile + private var state: Int = VoiceAgentState.NOT_CREATED + + @Volatile + private var currentPhase: Int = TurnPhase.IDLE + + @Volatile + private var handle: Long = 0 + + @Volatile + private var isInitialized: Boolean = false + + @Volatile + private var isCancelled: Boolean = false + + private val lock = Any() + + /** + * Tag for logging. + */ + private const val TAG = "CppBridgeVoiceAgent" + + /** + * Singleton shared instance for accessing the Voice Agent. + * Matches iOS CppBridge.VoiceAgent.shared pattern. + */ + val shared: CppBridgeVoiceAgent = this + + /** + * Optional listener for Voice Agent events. + * Set this before calling [register] to receive events. + */ + @Volatile + var voiceAgentListener: VoiceAgentListener? = null + + /** + * Optional callback for streaming audio output. + * This is invoked for each audio chunk during synthesis. + */ + @Volatile + var audioStreamCallback: AudioStreamCallback? = null + + /** + * Optional callback for streaming LLM response. + * This is invoked for each token during response generation. + */ + @Volatile + var responseStreamCallback: ResponseStreamCallback? = null + + /** + * Voice Agent configuration. + * + * @param vadModelPath Path to VAD model + * @param sttModelPath Path to STT model + * @param llmModelPath Path to LLM model + * @param ttsModelPath Path to TTS model + * @param vadModelId Optional VAD model ID for registry + * @param sttModelId Optional STT model ID for registry + * @param llmModelId Optional LLM model ID for registry + * @param ttsModelId Optional TTS model ID for registry + * @param systemPrompt System prompt for LLM + * @param voiceId Voice ID for TTS + * @param language Language code for STT/TTS + * @param sampleRate Audio sample rate in Hz + * @param interruptMode Interrupt mode for handling user interruptions + * @param maxTurnDurationMs Maximum turn duration in milliseconds (0 = no limit) + * @param silenceTimeoutMs Silence timeout for end of speech detection + * @param enableVad Whether to enable VAD for speech detection + * @param enableStreaming Whether to enable streaming for LLM and TTS + */ + data class VoiceAgentConfig( + val vadModelPath: String? = null, + val sttModelPath: String? = null, + val llmModelPath: String? = null, + val ttsModelPath: String? = null, + val vadModelId: String? = null, + val sttModelId: String? = null, + val llmModelId: String? = null, + val ttsModelId: String? = null, + val systemPrompt: String = "You are a helpful voice assistant.", + val voiceId: String? = null, + val language: String = "en", + val sampleRate: Int = 16000, + val interruptMode: Int = InterruptMode.IMMEDIATE, + val maxTurnDurationMs: Long = 60000, + val silenceTimeoutMs: Long = 1500, + val enableVad: Boolean = true, + val enableStreaming: Boolean = true, + ) { + /** + * Convert to JSON string for C++ interop. + */ + fun toJson(): String { + return buildString { + append("{") + vadModelPath?.let { append("\"vad_model_path\":\"${escapeJsonString(it)}\",") } + sttModelPath?.let { append("\"stt_model_path\":\"${escapeJsonString(it)}\",") } + llmModelPath?.let { append("\"llm_model_path\":\"${escapeJsonString(it)}\",") } + ttsModelPath?.let { append("\"tts_model_path\":\"${escapeJsonString(it)}\",") } + vadModelId?.let { append("\"vad_model_id\":\"${escapeJsonString(it)}\",") } + sttModelId?.let { append("\"stt_model_id\":\"${escapeJsonString(it)}\",") } + llmModelId?.let { append("\"llm_model_id\":\"${escapeJsonString(it)}\",") } + ttsModelId?.let { append("\"tts_model_id\":\"${escapeJsonString(it)}\",") } + append("\"system_prompt\":\"${escapeJsonString(systemPrompt)}\",") + voiceId?.let { append("\"voice_id\":\"${escapeJsonString(it)}\",") } + append("\"language\":\"$language\",") + append("\"sample_rate\":$sampleRate,") + append("\"interrupt_mode\":$interruptMode,") + append("\"max_turn_duration_ms\":$maxTurnDurationMs,") + append("\"silence_timeout_ms\":$silenceTimeoutMs,") + append("\"enable_vad\":$enableVad,") + append("\"enable_streaming\":$enableStreaming") + append("}") + } + } + + companion object { + /** Default configuration */ + val DEFAULT = VoiceAgentConfig() + } + } + + /** + * Turn configuration for individual turns. + * + * @param context Conversation context/history + * @param maxResponseTokens Maximum tokens for LLM response + * @param temperature LLM temperature (0.0 to 2.0) + * @param skipVad Skip VAD and assume speech is present + * @param skipTts Skip TTS and only return text response + * @param audioFormat Output audio format + */ + data class TurnConfig( + val context: String? = null, + val maxResponseTokens: Int = 512, + val temperature: Float = 0.7f, + val skipVad: Boolean = false, + val skipTts: Boolean = false, + val audioFormat: Int = 0, // PCM_16 + ) { + /** + * Convert to JSON string for C++ interop. + */ + fun toJson(): String { + return buildString { + append("{") + context?.let { append("\"context\":\"${escapeJsonString(it)}\",") } + append("\"max_response_tokens\":$maxResponseTokens,") + append("\"temperature\":$temperature,") + append("\"skip_vad\":$skipVad,") + append("\"skip_tts\":$skipTts,") + append("\"audio_format\":$audioFormat") + append("}") + } + } + + companion object { + /** Default configuration */ + val DEFAULT = TurnConfig() + } + } + + /** + * Turn result containing conversation turn output. + * + * @param userText Transcribed user speech + * @param assistantText Generated assistant response + * @param audioData Synthesized audio bytes (null if skipTts) + * @param audioDurationMs Duration of synthesized audio + * @param completionReason Reason for turn completion + * @param processingTimeMs Total processing time + * @param transcriptionTimeMs Time spent on transcription + * @param generationTimeMs Time spent on LLM generation + * @param synthesisTimeMs Time spent on TTS synthesis + */ + data class TurnResult( + val userText: String?, + val assistantText: String?, + val audioData: ByteArray?, + val audioDurationMs: Long, + val completionReason: Int, + val processingTimeMs: Long, + val transcriptionTimeMs: Long, + val generationTimeMs: Long, + val synthesisTimeMs: Long, + ) { + /** + * Check if the turn was successful. + */ + fun isSuccess(): Boolean = CompletionReason.isSuccess(completionReason) + + /** + * Get completion reason name. + */ + fun getCompletionReasonName(): String = CompletionReason.getName(completionReason) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is TurnResult) return false + + if (userText != other.userText) return false + if (assistantText != other.assistantText) return false + if (audioData != null) { + if (other.audioData == null) return false + if (!audioData.contentEquals(other.audioData)) return false + } else if (other.audioData != null) { + return false + } + if (audioDurationMs != other.audioDurationMs) return false + if (completionReason != other.completionReason) return false + if (processingTimeMs != other.processingTimeMs) return false + + return true + } + + override fun hashCode(): Int { + var result = userText?.hashCode() ?: 0 + result = 31 * result + (assistantText?.hashCode() ?: 0) + result = 31 * result + (audioData?.contentHashCode() ?: 0) + result = 31 * result + audioDurationMs.hashCode() + result = 31 * result + completionReason + result = 31 * result + processingTimeMs.hashCode() + return result + } + } + + /** + * Speech detection result. + * + * @param hasSpeech Whether speech was detected + * @param speechStartMs Start time of speech in milliseconds + * @param speechEndMs End time of speech in milliseconds + * @param confidence Detection confidence (0.0 to 1.0) + */ + data class SpeechDetectionResult( + val hasSpeech: Boolean, + val speechStartMs: Long, + val speechEndMs: Long, + val confidence: Float, + ) + + /** + * Transcription result. + * + * @param text Transcribed text + * @param language Detected language code + * @param confidence Transcription confidence + * @param durationMs Duration of the audio transcribed + */ + data class TranscriptionResult( + val text: String, + val language: String, + val confidence: Float, + val durationMs: Long, + ) + + /** + * Response generation result. + * + * @param text Generated response text + * @param tokenCount Number of tokens generated + * @param stopReason Reason for stopping generation + */ + data class ResponseResult( + val text: String, + val tokenCount: Int, + val stopReason: Int, + ) + + /** + * Audio synthesis result. + * + * @param audioData Synthesized audio bytes + * @param durationMs Audio duration in milliseconds + * @param sampleRate Sample rate of the audio + */ + data class SynthesisResult( + val audioData: ByteArray, + val durationMs: Long, + val sampleRate: Int, + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is SynthesisResult) return false + + if (!audioData.contentEquals(other.audioData)) return false + if (durationMs != other.durationMs) return false + if (sampleRate != other.sampleRate) return false + + return true + } + + override fun hashCode(): Int { + var result = audioData.contentHashCode() + result = 31 * result + durationMs.hashCode() + result = 31 * result + sampleRate + return result + } + } + + /** + * Listener interface for Voice Agent events. + */ + interface VoiceAgentListener { + /** + * Called when the Voice Agent state changes. + * + * @param previousState The previous state + * @param newState The new state + */ + fun onStateChanged(previousState: Int, newState: Int) + + /** + * Called when the Voice Agent is initialized. + */ + fun onInitialized() + + /** + * Called when a turn phase changes. + * + * @param phase The new turn phase (see [TurnPhase]) + */ + fun onTurnPhaseChanged(phase: Int) + + /** + * Called when speech is detected. + * + * @param result The speech detection result + */ + fun onSpeechDetected(result: SpeechDetectionResult) + + /** + * Called when transcription is complete. + * + * @param result The transcription result + */ + fun onTranscriptionComplete(result: TranscriptionResult) + + /** + * Called when partial transcription is available during streaming. + * + * @param partialText The partial transcription + */ + fun onPartialTranscription(partialText: String) + + /** + * Called when response generation is complete. + * + * @param result The response result + */ + fun onResponseComplete(result: ResponseResult) + + /** + * Called when a response token is generated during streaming. + * + * @param token The generated token + */ + fun onResponseToken(token: String) + + /** + * Called when audio synthesis is complete. + * + * @param result The synthesis result + */ + fun onSynthesisComplete(result: SynthesisResult) + + /** + * Called when an audio chunk is ready during streaming synthesis. + * + * @param audioChunk The audio chunk bytes + */ + fun onAudioChunk(audioChunk: ByteArray) + + /** + * Called when a turn is complete. + * + * @param result The turn result + */ + fun onTurnComplete(result: TurnResult) + + /** + * Called when the user interrupts the agent. + */ + fun onUserInterrupt() + + /** + * Called when an error occurs. + * + * @param errorCode The error code + * @param errorMessage The error message + */ + fun onError(errorCode: Int, errorMessage: String) + } + + /** + * Callback interface for streaming audio output. + */ + fun interface AudioStreamCallback { + /** + * Called for each audio chunk during synthesis. + * + * @param audioChunk The audio chunk bytes + * @param isFinal Whether this is the final chunk + * @return true to continue streaming, false to stop + */ + fun onAudioChunk(audioChunk: ByteArray, isFinal: Boolean): Boolean + } + + /** + * Callback interface for streaming response tokens. + */ + fun interface ResponseStreamCallback { + /** + * Called for each token during response generation. + * + * @param token The generated token + * @param isFinal Whether this is the final token + * @return true to continue streaming, false to stop + */ + fun onToken(token: String, isFinal: Boolean): Boolean + } + + /** + * Register the Voice Agent callbacks with C++ core. + * + * This must be called during SDK initialization, after AI component bridges are registered. + * It is safe to call multiple times; subsequent calls are no-ops. + */ + fun register() { + synchronized(lock) { + if (isRegistered) { + return + } + + // TODO: Call native registration + // nativeSetVoiceAgentCallbacks() + + isRegistered = true + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Voice Agent callbacks registered", + ) + } + } + + /** + * Check if the Voice Agent callbacks are registered. + */ + fun isRegistered(): Boolean = isRegistered + + /** + * Get the current component handle. + * + * @return The native handle, or throws if not created + * @throws SDKError if the component is not created + */ + @Throws(SDKError::class) + fun getHandle(): Long { + synchronized(lock) { + if (handle == 0L) { + throw SDKError.notInitialized("Voice Agent not created") + } + return handle + } + } + + /** + * Check if the Voice Agent is initialized. + */ + val isAgentInitialized: Boolean + get() = synchronized(lock) { isInitialized && state == VoiceAgentState.READY } + + /** + * Check if the Voice Agent is ready for use. + */ + val isReady: Boolean + get() = VoiceAgentState.isReady(state) + + /** + * Check if the Voice Agent is currently processing. + */ + val isProcessing: Boolean + get() = VoiceAgentState.isProcessing(state) + + /** + * Get the current Voice Agent state. + */ + fun getState(): Int = state + + /** + * Get the current turn phase. + */ + fun getCurrentPhase(): Int = currentPhase + + // ======================================================================== + // LIFECYCLE OPERATIONS + // ======================================================================== + + /** + * Create the Voice Agent component. + * + * @return 0 on success, error code on failure + */ + fun create(): Int { + synchronized(lock) { + if (handle != 0L) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Voice Agent already created", + ) + return 0 + } + + val result = nativeCreate() + if (result == 0L) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "Failed to create Voice Agent", + ) + return -1 + } + + handle = result + setState(VoiceAgentState.CREATED) + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "Voice Agent created", + ) + + return 0 + } + } + + /** + * Initialize the Voice Agent with component models. + * + * @param config Voice Agent configuration + * @return 0 on success, error code on failure + */ + fun initialize(config: VoiceAgentConfig = VoiceAgentConfig.DEFAULT): Int { + synchronized(lock) { + if (handle == 0L) { + // Auto-create if needed + val createResult = create() + if (createResult != 0) { + return createResult + } + } + + if (isInitialized) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Voice Agent already initialized", + ) + return 0 + } + + setState(VoiceAgentState.INITIALIZING) + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "Initializing Voice Agent", + ) + + val result = nativeInitialize(handle, config.toJson()) + if (result != 0) { + setState(VoiceAgentState.ERROR) + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.ERROR, + TAG, + "Failed to initialize Voice Agent (error: $result)", + ) + + try { + voiceAgentListener?.onError(result, "Failed to initialize Voice Agent") + } catch (e: Exception) { + // Ignore listener errors + } + + return result + } + + isInitialized = true + setState(VoiceAgentState.READY) + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "Voice Agent initialized successfully", + ) + + // Update component state + CppBridgeState.setComponentStateCallback( + CppBridgeState.ComponentType.VOICE_AGENT, + CppBridgeState.ComponentState.READY, + ) + + try { + voiceAgentListener?.onInitialized() + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in Voice Agent listener onInitialized: ${e.message}", + ) + } + + return 0 + } + } + + /** + * Process a complete voice turn. + * + * This orchestrates the full pipeline: VAD -> STT -> LLM -> TTS + * + * @param audioData Raw audio data bytes + * @param config Turn configuration + * @return The turn result + * @throws SDKError if processing fails + */ + @Throws(SDKError::class) + fun processVoiceTurn(audioData: ByteArray, config: TurnConfig = TurnConfig.DEFAULT): TurnResult { + synchronized(lock) { + if (handle == 0L || !isInitialized) { + throw SDKError.voiceAgent("Voice Agent not initialized") + } + + if (state != VoiceAgentState.READY) { + throw SDKError.voiceAgent("Voice Agent not ready (state: ${VoiceAgentState.getName(state)})") + } + + isCancelled = false + setState(VoiceAgentState.PROCESSING_TURN) + setPhase(TurnPhase.SPEECH_DETECTION) + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "Starting voice turn (audio size: ${audioData.size} bytes)", + ) + + val startTime = System.currentTimeMillis() + + try { + val resultJson = + nativeProcessVoiceTurn(handle, audioData, config.toJson()) + ?: throw SDKError.voiceAgent("Voice turn processing failed: null result") + + val result = parseTurnResult(resultJson, System.currentTimeMillis() - startTime) + + setState(VoiceAgentState.READY) + setPhase(TurnPhase.COMPLETED) + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "Voice turn completed: ${result.getCompletionReasonName()}, ${result.processingTimeMs}ms", + ) + + try { + voiceAgentListener?.onTurnComplete(result) + } catch (e: Exception) { + // Ignore listener errors + } + + return result + } catch (e: Exception) { + setState(VoiceAgentState.READY) + setPhase(TurnPhase.FAILED) + throw if (e is SDKError) e else SDKError.voiceAgent("Voice turn failed: ${e.message}") + } + } + } + + /** + * Detect speech in audio data. + * + * @param audioData Raw audio data bytes + * @return The speech detection result + * @throws SDKError if detection fails + */ + @Throws(SDKError::class) + fun detectSpeech(audioData: ByteArray): SpeechDetectionResult { + synchronized(lock) { + if (handle == 0L || !isInitialized) { + throw SDKError.voiceAgent("Voice Agent not initialized") + } + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Detecting speech (audio size: ${audioData.size} bytes)", + ) + + try { + val resultJson = + nativeDetectSpeech(handle, audioData) + ?: throw SDKError.voiceAgent("Speech detection failed: null result") + + val result = parseSpeechDetectionResult(resultJson) + + try { + voiceAgentListener?.onSpeechDetected(result) + } catch (e: Exception) { + // Ignore listener errors + } + + return result + } catch (e: Exception) { + throw if (e is SDKError) e else SDKError.voiceAgent("Speech detection failed: ${e.message}") + } + } + } + + /** + * Transcribe audio to text. + * + * @param audioData Raw audio data bytes + * @return The transcription result + * @throws SDKError if transcription fails + */ + @Throws(SDKError::class) + fun transcribe(audioData: ByteArray): TranscriptionResult { + synchronized(lock) { + if (handle == 0L || !isInitialized) { + throw SDKError.voiceAgent("Voice Agent not initialized") + } + + setState(VoiceAgentState.TRANSCRIBING) + setPhase(TurnPhase.TRANSCRIPTION) + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Transcribing audio (size: ${audioData.size} bytes)", + ) + + try { + val resultJson = + nativeTranscribe(handle, audioData) + ?: throw SDKError.voiceAgent("Transcription failed: null result") + + val result = parseTranscriptionResult(resultJson) + + setState(VoiceAgentState.READY) + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Transcription complete: \"${result.text.take(50)}...\"", + ) + + try { + voiceAgentListener?.onTranscriptionComplete(result) + } catch (e: Exception) { + // Ignore listener errors + } + + return result + } catch (e: Exception) { + setState(VoiceAgentState.READY) + throw if (e is SDKError) e else SDKError.voiceAgent("Transcription failed: ${e.message}") + } + } + } + + /** + * Generate a response from the LLM. + * + * @param prompt The user prompt/input + * @param context Optional conversation context + * @return The response result + * @throws SDKError if generation fails + */ + @Throws(SDKError::class) + fun generateResponse(prompt: String, context: String? = null): ResponseResult { + synchronized(lock) { + if (handle == 0L || !isInitialized) { + throw SDKError.voiceAgent("Voice Agent not initialized") + } + + setState(VoiceAgentState.GENERATING) + setPhase(TurnPhase.RESPONSE_GENERATION) + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Generating response for: \"${prompt.take(50)}...\"", + ) + + try { + val resultJson = + nativeGenerateResponse(handle, prompt, context) + ?: throw SDKError.voiceAgent("Response generation failed: null result") + + val result = parseResponseResult(resultJson) + + setState(VoiceAgentState.READY) + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Response generated: ${result.tokenCount} tokens", + ) + + try { + voiceAgentListener?.onResponseComplete(result) + } catch (e: Exception) { + // Ignore listener errors + } + + return result + } catch (e: Exception) { + setState(VoiceAgentState.READY) + throw if (e is SDKError) e else SDKError.voiceAgent("Response generation failed: ${e.message}") + } + } + } + + /** + * Synthesize speech from text. + * + * @param text The text to synthesize + * @return The synthesis result + * @throws SDKError if synthesis fails + */ + @Throws(SDKError::class) + fun synthesizeSpeech(text: String): SynthesisResult { + synchronized(lock) { + if (handle == 0L || !isInitialized) { + throw SDKError.voiceAgent("Voice Agent not initialized") + } + + setState(VoiceAgentState.SPEAKING) + setPhase(TurnPhase.SPEECH_SYNTHESIS) + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Synthesizing speech: \"${text.take(50)}...\"", + ) + + try { + val audioData = + nativeSynthesizeSpeech(handle, text) + ?: throw SDKError.voiceAgent("Speech synthesis failed: null result") + + // Parse duration from native result or estimate + val durationMs = estimateAudioDuration(audioData.size) + val result = SynthesisResult(audioData, durationMs, 16000) + + setState(VoiceAgentState.READY) + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Speech synthesized: ${audioData.size} bytes, ${durationMs}ms", + ) + + try { + voiceAgentListener?.onSynthesisComplete(result) + } catch (e: Exception) { + // Ignore listener errors + } + + return result + } catch (e: Exception) { + setState(VoiceAgentState.READY) + throw if (e is SDKError) e else SDKError.voiceAgent("Speech synthesis failed: ${e.message}") + } + } + } + + /** + * Cancel an ongoing operation. + */ + fun cancel() { + synchronized(lock) { + if (!VoiceAgentState.isProcessing(state)) { + return + } + + isCancelled = true + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "Cancelling Voice Agent operation", + ) + + nativeCancel(handle) + + setPhase(TurnPhase.CANCELLED) + } + } + + /** + * Reset the Voice Agent for a new conversation. + */ + fun reset() { + synchronized(lock) { + if (handle == 0L) { + return + } + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Resetting Voice Agent", + ) + + nativeReset(handle) + setPhase(TurnPhase.IDLE) + } + } + + /** + * Destroy the Voice Agent and release resources. + */ + fun destroy() { + synchronized(lock) { + if (handle == 0L) { + return + } + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.INFO, + TAG, + "Destroying Voice Agent", + ) + + nativeDestroy(handle) + + handle = 0 + isInitialized = false + setState(VoiceAgentState.NOT_CREATED) + setPhase(TurnPhase.IDLE) + + // Update component state + CppBridgeState.setComponentStateCallback( + CppBridgeState.ComponentType.VOICE_AGENT, + CppBridgeState.ComponentState.NOT_CREATED, + ) + } + } + + // ======================================================================== + // JNI CALLBACKS + // ======================================================================== + + /** + * State change callback. + * + * Called from C++ when the Voice Agent state changes. + * + * @param newState The new state + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun stateChangeCallback(newState: Int) { + setState(newState) + } + + /** + * Turn phase callback. + * + * Called from C++ when the turn phase changes. + * + * @param phase The new turn phase + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun turnPhaseCallback(phase: Int) { + setPhase(phase) + } + + /** + * Partial transcription callback. + * + * Called from C++ for streaming partial transcription results. + * + * @param partialText The partial transcription + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun partialTranscriptionCallback(partialText: String) { + try { + voiceAgentListener?.onPartialTranscription(partialText) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in Voice Agent listener onPartialTranscription: ${e.message}", + ) + } + } + + /** + * Response token callback. + * + * Called from C++ for each token during streaming response generation. + * + * @param token The generated token + * @param isFinal Whether this is the final token + * @return true to continue streaming, false to stop + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun responseTokenCallback(token: String, isFinal: Boolean): Boolean { + if (isCancelled) { + return false + } + + try { + voiceAgentListener?.onResponseToken(token) + } catch (e: Exception) { + // Ignore listener errors + } + + return try { + responseStreamCallback?.onToken(token, isFinal) ?: true + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in response stream callback: ${e.message}", + ) + true // Continue on error + } + } + + /** + * Audio chunk callback. + * + * Called from C++ for each audio chunk during streaming synthesis. + * + * @param audioChunk The audio chunk bytes + * @param isFinal Whether this is the final chunk + * @return true to continue streaming, false to stop + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun audioChunkCallback(audioChunk: ByteArray, isFinal: Boolean): Boolean { + if (isCancelled) { + return false + } + + try { + voiceAgentListener?.onAudioChunk(audioChunk) + } catch (e: Exception) { + // Ignore listener errors + } + + return try { + audioStreamCallback?.onAudioChunk(audioChunk, isFinal) ?: true + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in audio stream callback: ${e.message}", + ) + true // Continue on error + } + } + + /** + * User interrupt callback. + * + * Called from C++ when the user interrupts the agent. + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun userInterruptCallback() { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "User interrupt detected", + ) + + try { + voiceAgentListener?.onUserInterrupt() + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in Voice Agent listener onUserInterrupt: ${e.message}", + ) + } + } + + /** + * Progress callback. + * + * Called from C++ to report progress. + * + * @param progress Progress (0.0 to 1.0) + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun progressCallback(progress: Float) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Progress: ${(progress * 100).toInt()}%", + ) + } + + /** + * Get state callback. + * + * @return The current Voice Agent state + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun getStateCallback(): Int { + return state + } + + /** + * Is initialized callback. + * + * @return true if the Voice Agent is initialized + * + * NOTE: This function is called from JNI. Do not capture any state. + */ + @JvmStatic + fun isInitializedCallback(): Boolean { + return isInitialized && state == VoiceAgentState.READY + } + + // ======================================================================== + // JNI NATIVE DECLARATIONS + // ======================================================================== + + /** + * Native method to set the Voice Agent callbacks with C++ core. + * Reserved for future native callback integration. + * + * C API: rac_voice_agent_set_callbacks(...) + */ + @Suppress("unused") + @JvmStatic + private external fun nativeSetVoiceAgentCallbacks() + + /** + * Native method to unset the Voice Agent callbacks. + * Reserved for future native callback integration. + * + * C API: rac_voice_agent_set_callbacks(nullptr) + */ + @Suppress("unused") + @JvmStatic + private external fun nativeUnsetVoiceAgentCallbacks() + + /** + * Native method to create the Voice Agent. + * + * @return Handle to the created component, or 0 on failure + * + * C API: rac_voice_agent_create() + */ + @JvmStatic + external fun nativeCreate(): Long + + /** + * Native method to initialize the Voice Agent. + * + * @param handle The component handle + * @param configJson JSON configuration string + * @return 0 on success, error code on failure + * + * C API: rac_voice_agent_initialize(handle, config) + */ + @JvmStatic + external fun nativeInitialize(handle: Long, configJson: String): Int + + /** + * Native method to process a complete voice turn. + * + * @param handle The component handle + * @param audioData Raw audio bytes + * @param configJson JSON configuration string + * @return JSON-encoded result, or null on failure + * + * C API: rac_voice_agent_process_voice_turn(handle, audio_data, audio_size, config) + */ + @JvmStatic + external fun nativeProcessVoiceTurn(handle: Long, audioData: ByteArray, configJson: String): String? + + /** + * Native method to detect speech in audio. + * + * @param handle The component handle + * @param audioData Raw audio bytes + * @return JSON-encoded result, or null on failure + * + * C API: rac_voice_agent_detect_speech(handle, audio_data, audio_size) + */ + @JvmStatic + external fun nativeDetectSpeech(handle: Long, audioData: ByteArray): String? + + /** + * Native method to transcribe audio. + * + * @param handle The component handle + * @param audioData Raw audio bytes + * @return JSON-encoded result, or null on failure + * + * C API: rac_voice_agent_transcribe(handle, audio_data, audio_size) + */ + @JvmStatic + external fun nativeTranscribe(handle: Long, audioData: ByteArray): String? + + /** + * Native method to generate a response. + * + * @param handle The component handle + * @param prompt The user prompt + * @param context Optional conversation context + * @return JSON-encoded result, or null on failure + * + * C API: rac_voice_agent_generate_response(handle, prompt, context) + */ + @JvmStatic + external fun nativeGenerateResponse(handle: Long, prompt: String, context: String?): String? + + /** + * Native method to synthesize speech. + * + * @param handle The component handle + * @param text The text to synthesize + * @return Audio bytes, or null on failure + * + * C API: rac_voice_agent_synthesize_speech(handle, text) + */ + @JvmStatic + external fun nativeSynthesizeSpeech(handle: Long, text: String): ByteArray? + + /** + * Native method to cancel an operation. + * + * @param handle The component handle + * + * C API: rac_voice_agent_cancel(handle) + */ + @JvmStatic + external fun nativeCancel(handle: Long) + + /** + * Native method to reset the Voice Agent. + * + * @param handle The component handle + * + * C API: rac_voice_agent_reset(handle) + */ + @JvmStatic + external fun nativeReset(handle: Long) + + /** + * Native method to destroy the Voice Agent. + * + * @param handle The component handle + * + * C API: rac_voice_agent_destroy(handle) + */ + @JvmStatic + external fun nativeDestroy(handle: Long) + + /** + * Native method to get component states. + * + * @param handle The component handle + * @return JSON with component states + * + * C API: rac_voice_agent_get_component_states(handle) + */ + @JvmStatic + external fun nativeGetComponentStates(handle: Long): String? + + // ======================================================================== + // LIFECYCLE MANAGEMENT + // ======================================================================== + + /** + * Unregister the Voice Agent callbacks and clean up resources. + * + * Called during SDK shutdown. + */ + fun unregister() { + synchronized(lock) { + if (!isRegistered) { + return + } + + // Destroy component if created + if (handle != 0L) { + destroy() + } + + // TODO: Call native unregistration + // nativeUnsetVoiceAgentCallbacks() + + voiceAgentListener = null + audioStreamCallback = null + responseStreamCallback = null + isRegistered = false + } + } + + // ======================================================================== + // UTILITY FUNCTIONS + // ======================================================================== + + /** + * Set the Voice Agent state and notify listeners. + */ + private fun setState(newState: Int) { + val previousState = state + if (newState != previousState) { + state = newState + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "State changed: ${VoiceAgentState.getName(previousState)} -> ${VoiceAgentState.getName(newState)}", + ) + + try { + voiceAgentListener?.onStateChanged(previousState, newState) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in Voice Agent listener onStateChanged: ${e.message}", + ) + } + } + } + + /** + * Set the current turn phase and notify listeners. + */ + private fun setPhase(newPhase: Int) { + val previousPhase = currentPhase + if (newPhase != previousPhase) { + currentPhase = newPhase + + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.DEBUG, + TAG, + "Phase changed: ${TurnPhase.getName(previousPhase)} -> ${TurnPhase.getName(newPhase)}", + ) + + try { + voiceAgentListener?.onTurnPhaseChanged(newPhase) + } catch (e: Exception) { + CppBridgePlatformAdapter.logCallback( + CppBridgePlatformAdapter.LogLevel.WARN, + TAG, + "Error in Voice Agent listener onTurnPhaseChanged: ${e.message}", + ) + } + } + } + + /** + * Escape a string for JSON encoding. + */ + private fun escapeJsonString(str: String): String { + return str + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + } + + /** + * Estimate audio duration from byte size. + * Assumes 16-bit PCM at 16kHz mono. + */ + private fun estimateAudioDuration(byteSize: Int): Long { + // 16-bit = 2 bytes per sample, 16kHz = 16000 samples per second + // Duration (ms) = (bytes / 2) / 16000 * 1000 = bytes / 32 + return (byteSize / 32).toLong() + } + + /** + * Parse turn result from JSON. + */ + private fun parseTurnResult(json: String, elapsedMs: Long): TurnResult { + fun extractString(key: String): String? { + val pattern = "\"$key\"\\s*:\\s*\"([^\"]*)\"" + val regex = Regex(pattern) + return regex.find(json)?.groupValues?.get(1) + } + + fun extractLong(key: String): Long { + val pattern = "\"$key\"\\s*:\\s*(-?\\d+)" + val regex = Regex(pattern) + return regex + .find(json) + ?.groupValues + ?.get(1) + ?.toLongOrNull() ?: 0L + } + + fun extractInt(key: String): Int { + val pattern = "\"$key\"\\s*:\\s*(-?\\d+)" + val regex = Regex(pattern) + return regex + .find(json) + ?.groupValues + ?.get(1) + ?.toIntOrNull() ?: 0 + } + + return TurnResult( + userText = extractString("user_text"), + assistantText = extractString("assistant_text"), + audioData = null, // Would need base64 decoding from JSON + audioDurationMs = extractLong("audio_duration_ms"), + completionReason = extractInt("completion_reason"), + processingTimeMs = elapsedMs, + transcriptionTimeMs = extractLong("transcription_time_ms"), + generationTimeMs = extractLong("generation_time_ms"), + synthesisTimeMs = extractLong("synthesis_time_ms"), + ) + } + + /** + * Parse speech detection result from JSON. + */ + private fun parseSpeechDetectionResult(json: String): SpeechDetectionResult { + fun extractBoolean(key: String): Boolean { + val pattern = "\"$key\"\\s*:\\s*(true|false)" + val regex = Regex(pattern) + return regex + .find(json) + ?.groupValues + ?.get(1) + ?.toBooleanStrictOrNull() ?: false + } + + fun extractLong(key: String): Long { + val pattern = "\"$key\"\\s*:\\s*(-?\\d+)" + val regex = Regex(pattern) + return regex + .find(json) + ?.groupValues + ?.get(1) + ?.toLongOrNull() ?: 0L + } + + fun extractFloat(key: String): Float { + val pattern = "\"$key\"\\s*:\\s*(-?[\\d.]+)" + val regex = Regex(pattern) + return regex + .find(json) + ?.groupValues + ?.get(1) + ?.toFloatOrNull() ?: 0f + } + + return SpeechDetectionResult( + hasSpeech = extractBoolean("has_speech"), + speechStartMs = extractLong("speech_start_ms"), + speechEndMs = extractLong("speech_end_ms"), + confidence = extractFloat("confidence"), + ) + } + + /** + * Parse transcription result from JSON. + */ + private fun parseTranscriptionResult(json: String): TranscriptionResult { + fun extractString(key: String): String { + val pattern = "\"$key\"\\s*:\\s*\"([^\"]*)\"" + val regex = Regex(pattern) + return regex.find(json)?.groupValues?.get(1) ?: "" + } + + fun extractFloat(key: String): Float { + val pattern = "\"$key\"\\s*:\\s*(-?[\\d.]+)" + val regex = Regex(pattern) + return regex + .find(json) + ?.groupValues + ?.get(1) + ?.toFloatOrNull() ?: 0f + } + + fun extractLong(key: String): Long { + val pattern = "\"$key\"\\s*:\\s*(-?\\d+)" + val regex = Regex(pattern) + return regex + .find(json) + ?.groupValues + ?.get(1) + ?.toLongOrNull() ?: 0L + } + + return TranscriptionResult( + text = extractString("text"), + language = extractString("language"), + confidence = extractFloat("confidence"), + durationMs = extractLong("duration_ms"), + ) + } + + /** + * Parse response result from JSON. + */ + private fun parseResponseResult(json: String): ResponseResult { + fun extractString(key: String): String { + val pattern = "\"$key\"\\s*:\\s*\"([^\"]*)\"" + val regex = Regex(pattern) + return regex.find(json)?.groupValues?.get(1) ?: "" + } + + fun extractInt(key: String): Int { + val pattern = "\"$key\"\\s*:\\s*(-?\\d+)" + val regex = Regex(pattern) + return regex + .find(json) + ?.groupValues + ?.get(1) + ?.toIntOrNull() ?: 0 + } + + return ResponseResult( + text = extractString("text"), + tokenCount = extractInt("token_count"), + stopReason = extractInt("stop_reason"), + ) + } + + /** + * Get a state summary for diagnostics. + * + * @return Human-readable state summary + */ + fun getStateSummary(): String { + return buildString { + append("Voice Agent State: ${VoiceAgentState.getName(state)}") + append(", Phase: ${TurnPhase.getName(currentPhase)}") + append(", Initialized: $isInitialized") + if (handle != 0L) { + append(", Handle: $handle") + } + } + } + + /** + * Get component states for diagnostics. + * + * @return Map of component type names to their states + */ + fun getComponentStates(): Map { + synchronized(lock) { + if (handle == 0L) { + return emptyMap() + } + + val json = nativeGetComponentStates(handle) ?: return emptyMap() + + // Simple parsing for diagnostic purposes + val states = mutableMapOf() + val pattern = "\"(\\w+)\"\\s*:\\s*\"?(\\w+)\"?" + Regex(pattern).findAll(json).forEach { match -> + val (key, value) = match.destructured + states[key] = value + } + return states + } + } +} diff --git a/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/logging/SentryDestination.kt b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/logging/SentryDestination.kt new file mode 100644 index 000000000..17fbb99b3 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/logging/SentryDestination.kt @@ -0,0 +1,150 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * Log destination that sends logs to Sentry for error tracking. + * Matches iOS SDK's SentryDestination.swift. + */ + +package com.runanywhere.sdk.foundation.logging + +import com.runanywhere.sdk.foundation.LogDestination +import com.runanywhere.sdk.foundation.LogEntry +import com.runanywhere.sdk.foundation.LogLevel +import io.sentry.Breadcrumb +import io.sentry.Sentry +import io.sentry.SentryEvent +import io.sentry.SentryLevel +import io.sentry.protocol.Message +import java.util.Date + +/** + * Log destination that sends warning+ logs to Sentry. + * + * - Warning level: Added as breadcrumbs for context trail + * - Error/Fault level: Captured as Sentry events + */ +class SentryDestination : LogDestination { + companion object { + const val DESTINATION_ID = "com.runanywhere.logging.sentry" + } + + /** + * Unique identifier for this destination. + */ + override val identifier: String = DESTINATION_ID + + /** + * Whether this destination is available for writing. + */ + override val isAvailable: Boolean + get() = SentryManager.isInitialized + + /** + * Only send warning level and above to Sentry. + */ + private val minSentryLevel: LogLevel = LogLevel.WARNING + + // ============================================================================= + // LOG DESTINATION OPERATIONS + // ============================================================================= + + /** + * Write a log entry to Sentry. + * + * @param entry The log entry to write + */ + override fun write(entry: LogEntry) { + if (!isAvailable || entry.level < minSentryLevel) { + return + } + + // Add as breadcrumb for context trail + addBreadcrumb(entry) + + // For error and fault levels, capture as Sentry event + if (entry.level >= LogLevel.ERROR) { + captureEvent(entry) + } + } + + /** + * Flush any buffered entries. + */ + override fun flush() { + if (!isAvailable) return + SentryManager.flush() + } + + // ============================================================================= + // PRIVATE HELPERS + // ============================================================================= + + /** + * Add a breadcrumb for context trail. + */ + private fun addBreadcrumb(entry: LogEntry) { + val timestamp = Date(entry.timestamp.toEpochMilliseconds()) + val breadcrumb = + Breadcrumb(timestamp).apply { + category = entry.category + message = entry.message + level = convertToSentryLevel(entry.level) + entry.metadata?.forEach { (key, value) -> + setData(key, value) + } + } + + Sentry.addBreadcrumb(breadcrumb) + } + + /** + * Capture an error event in Sentry. + */ + private fun captureEvent(entry: LogEntry) { + val timestamp = Date(entry.timestamp.toEpochMilliseconds()) + val event = + SentryEvent(timestamp).apply { + level = convertToSentryLevel(entry.level) + message = + Message().apply { + formatted = entry.message + } + + // Add tags + setTag("category", entry.category) + setTag("log_level", entry.level.toString()) + + // Add metadata as extras + entry.metadata?.forEach { (key, value) -> + setExtra(key, value) + } + + // Add model info if present + entry.modelId?.let { setExtra("model_id", it) } + entry.framework?.let { setExtra("framework", it) } + entry.errorCode?.let { setExtra("error_code", it) } + + // Add source location if present + entry.file?.let { setExtra("source_file", it) } + entry.line?.let { setExtra("source_line", it) } + entry.function?.let { setExtra("source_function", it) } + } + + Sentry.captureEvent(event) + } + + /** + * Convert SDK LogLevel to Sentry level. + */ + private fun convertToSentryLevel(level: LogLevel): SentryLevel { + return when (level) { + LogLevel.TRACE -> SentryLevel.DEBUG + LogLevel.DEBUG -> SentryLevel.DEBUG + LogLevel.INFO -> SentryLevel.INFO + LogLevel.WARNING -> SentryLevel.WARNING + LogLevel.ERROR -> SentryLevel.ERROR + LogLevel.FAULT -> SentryLevel.FATAL + } + } +} diff --git a/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/logging/SentryManager.kt b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/logging/SentryManager.kt new file mode 100644 index 000000000..3197605eb --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/foundation/logging/SentryManager.kt @@ -0,0 +1,224 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * Manages Sentry SDK initialization for crash reporting and error tracking. + * Matches iOS SDK's SentryManager.swift. + */ + +package com.runanywhere.sdk.foundation.logging + +import com.runanywhere.sdk.foundation.SDKEnvironment +import com.runanywhere.sdk.foundation.SDKLogger +import com.runanywhere.sdk.native.bridge.RunAnywhereBridge +import com.runanywhere.sdk.utils.SDKConstants +import io.sentry.Breadcrumb +import io.sentry.Sentry +import io.sentry.SentryEvent +import io.sentry.SentryLevel +import io.sentry.SentryOptions +import io.sentry.protocol.Message +import io.sentry.protocol.User + +/** + * Manages Sentry SDK initialization and configuration. + * Provides centralized error tracking for the RunAnywhere SDK. + */ +object SentryManager { + private const val TAG = "SentryManager" + + @Volatile + private var _isInitialized: Boolean = false + + /** + * Whether Sentry has been successfully initialized. + */ + val isInitialized: Boolean + get() = _isInitialized + + // ============================================================================= + // INITIALIZATION + // ============================================================================= + + /** + * Initialize Sentry with the configured DSN. + * + * @param dsn Sentry DSN (if null, uses C++ config sentryDSN) + * @param environment SDK environment for tagging events + */ + fun initialize( + dsn: String? = null, + environment: SDKEnvironment = SDKEnvironment.DEVELOPMENT, + ) { + if (_isInitialized) { + return + } + + // Use provided DSN or fallback to C++ config + val sentryDSN = dsn ?: getSentryDsnFromConfig() + + if (sentryDSN.isNullOrEmpty() || sentryDSN == "YOUR_SENTRY_DSN_HERE") { + SDKLogger(TAG).debug("Sentry DSN not configured. Crash reporting disabled.") + return + } + + try { + Sentry.init { options: SentryOptions -> + options.dsn = sentryDSN + options.environment = environment.name.lowercase() + options.isEnableAutoSessionTracking = true + options.isAttachStacktrace = true + options.tracesSampleRate = 0.0 // Disable performance tracing + + // Add SDK info to all events + options.beforeSend = + SentryOptions.BeforeSendCallback { event, _ -> + event.setTag("sdk_name", "RunAnywhere") + event.setTag("sdk_version", SDKConstants.VERSION) + event + } + } + + _isInitialized = true + SDKLogger(TAG).info("Sentry initialized successfully") + } catch (e: Exception) { + SDKLogger(TAG).error("Failed to initialize Sentry: ${e.message}") + } + } + + /** + * Get Sentry DSN from C++ dev config. + */ + private fun getSentryDsnFromConfig(): String? { + return try { + RunAnywhereBridge.racDevConfigGetSentryDsn() + } catch (e: Exception) { + null + } + } + + // ============================================================================= + // DIRECT API (for advanced use cases) + // ============================================================================= + + /** + * Capture an error directly with Sentry. + * + * @param error The error to capture + * @param context Additional context as key-value pairs + */ + fun captureError(error: Throwable, context: Map? = null) { + if (!_isInitialized) return + + Sentry.captureException(error) { scope -> + context?.forEach { (key, value) -> + scope.setExtra(key, value?.toString() ?: "null") + } + } + } + + /** + * Capture an error message directly with Sentry. + * + * @param message The error message + * @param level Sentry level (defaults to ERROR) + * @param context Additional context as key-value pairs + */ + fun captureMessage( + message: String, + level: SentryLevel = SentryLevel.ERROR, + context: Map? = null, + ) { + if (!_isInitialized) return + + val event = + SentryEvent().apply { + this.level = level + this.message = + Message().apply { + this.formatted = message + } + } + + context?.forEach { (key, value) -> + event.setExtra(key, value?.toString() ?: "null") + } + + Sentry.captureEvent(event) + } + + /** + * Add a breadcrumb for context trail. + * + * @param category Category of the breadcrumb + * @param message Message for the breadcrumb + * @param level Log level + * @param data Additional data + */ + fun addBreadcrumb( + category: String, + message: String, + level: SentryLevel = SentryLevel.INFO, + data: Map? = null, + ) { + if (!_isInitialized) return + + val breadcrumb = + Breadcrumb().apply { + this.category = category + this.message = message + this.level = level + data?.forEach { (key, value) -> + this.setData(key, value) + } + } + + Sentry.addBreadcrumb(breadcrumb) + } + + /** + * Set user information for Sentry events. + * + * @param userId Unique user identifier + * @param email User email (optional) + * @param username Username (optional) + */ + fun setUser(userId: String, email: String? = null, username: String? = null) { + if (!_isInitialized) return + + val user = + User().apply { + this.id = userId + this.email = email + this.username = username + } + Sentry.setUser(user) + } + + /** + * Clear user information. + */ + fun clearUser() { + if (!_isInitialized) return + Sentry.setUser(null) + } + + /** + * Flush pending events. + * + * @param timeoutMs Timeout in milliseconds + */ + fun flush(timeoutMs: Long = 2000L) { + if (!_isInitialized) return + Sentry.flush(timeoutMs) + } + + /** + * Close Sentry SDK. + */ + fun close() { + if (!_isInitialized) return + Sentry.close() + _isInitialized = false + } +} diff --git a/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/jni/NativeLoader.kt b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/jni/NativeLoader.kt new file mode 100644 index 000000000..bfa77f7f3 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/jni/NativeLoader.kt @@ -0,0 +1,66 @@ +package com.runanywhere.sdk.jni + +import java.io.File + +/** + * Native library loader for platform-specific libraries + * Shared between JVM and Android + */ +object NativeLoader { + private val loadedLibraries = mutableSetOf() + + /** + * Load a native library from resources + */ + fun loadLibrary(libName: String) { + if (libName in loadedLibraries) return + + val os = System.getProperty("os.name").lowercase() + + val libFileName = + when { + os.contains("win") -> "$libName.dll" + os.contains("mac") -> "lib$libName.dylib" + else -> "lib$libName.so" + } + + val platformDir = + when { + os.contains("win") -> "win" + os.contains("mac") -> "mac" + else -> "linux" + } + + val resourcePath = "/native/$platformDir/$libFileName" + + try { + // Try to load from resources + val resource = NativeLoader::class.java.getResourceAsStream(resourcePath) + + if (resource != null) { + val tempFile = File.createTempFile(libName, libFileName) + tempFile.deleteOnExit() + + resource.use { input -> + tempFile.outputStream().use { output -> + input.copyTo(output) + } + } + + System.load(tempFile.absolutePath) + loadedLibraries.add(libName) + } else { + // Fallback to system library + System.loadLibrary(libName) + loadedLibraries.add(libName) + } + } catch (e: Exception) { + throw UnsatisfiedLinkError("Failed to load native library $libName: ${e.message}") + } + } + + /** + * Check if a library is already loaded + */ + fun isLibraryLoaded(libName: String): Boolean = libName in loadedLibraries +} diff --git a/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/models/DeviceInfo.kt b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/models/DeviceInfo.kt new file mode 100644 index 000000000..3b4396bc9 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/models/DeviceInfo.kt @@ -0,0 +1,176 @@ +package com.runanywhere.sdk.models + +import java.net.NetworkInterface +import java.security.MessageDigest +import java.util.UUID + +/** + * JVM and Android actual implementation for collecting device info. + * Uses reflection to detect Android and call Build.* APIs when available. + */ +actual fun collectDeviceInfo(): DeviceInfo { + return if (isAndroid()) { + collectAndroidDeviceInfo() + } else { + collectJvmDeviceInfo() + } +} + +/** + * Check if running on Android by looking at JVM properties. + */ +private fun isAndroid(): Boolean { + val javaVmName = System.getProperty("java.vm.name") ?: "" + val javaVendor = System.getProperty("java.vendor") ?: "" + return javaVmName.contains("Dalvik", ignoreCase = true) || + javaVmName.contains("Art", ignoreCase = true) || + javaVendor.contains("Android", ignoreCase = true) || + System.getProperty("java.specification.vendor")?.contains("Android", ignoreCase = true) == true +} + +/** + * Collect device info on Android using reflection to access Build class. + */ +private fun collectAndroidDeviceInfo(): DeviceInfo { + return try { + val buildClass = Class.forName("android.os.Build") + val versionClass = Class.forName("android.os.Build\$VERSION") + + val manufacturer = buildClass.getField("MANUFACTURER").get(null) as? String ?: "Unknown" + val model = buildClass.getField("MODEL").get(null) as? String ?: "Unknown" + val device = buildClass.getField("DEVICE").get(null) as? String ?: "Unknown" + val brand = buildClass.getField("BRAND").get(null) as? String ?: "Unknown" + + val sdkInt = versionClass.getField("SDK_INT").get(null) as? Int ?: 0 + val release = versionClass.getField("RELEASE").get(null) as? String ?: "Unknown" + + // Get supported ABIs + val supportedAbis = + try { + @Suppress("UNCHECKED_CAST") + val abis = buildClass.getField("SUPPORTED_ABIS").get(null) as? Array + abis?.firstOrNull() ?: "unknown" + } catch (e: Exception) { + "unknown" + } + + val runtime = Runtime.getRuntime() + val totalMemory = runtime.maxMemory() + val processorCount = runtime.availableProcessors() + + DeviceInfo( + deviceId = generateDeviceId(), + modelIdentifier = "$manufacturer $device", + modelName = "$brand $model", + architecture = mapArchitecture(supportedAbis), + osVersion = "Android $release (API $sdkInt)", + platform = "Android", + deviceType = "mobile", + formFactor = "phone", + totalMemory = totalMemory, + processorCount = processorCount, + ) + } catch (e: Exception) { + // Fallback to basic JVM detection if reflection fails + collectJvmDeviceInfo() + } +} + +/** + * Collect device info on standard JVM. + */ +private fun collectJvmDeviceInfo(): DeviceInfo { + val osName = System.getProperty("os.name") ?: "Unknown" + val osVersion = System.getProperty("os.version") ?: "Unknown" + val osArch = System.getProperty("os.arch") ?: "Unknown" + + val platform = + when { + osName.contains("Mac", ignoreCase = true) -> "macOS" + osName.contains("Windows", ignoreCase = true) -> "Windows" + osName.contains("Linux", ignoreCase = true) -> "Linux" + else -> "JVM" + } + + val runtime = Runtime.getRuntime() + val totalMemory = runtime.maxMemory() + val processorCount = runtime.availableProcessors() + + return DeviceInfo( + deviceId = generateDeviceId(), + modelIdentifier = osName, + modelName = "$osName $osArch", + architecture = mapArchitecture(osArch), + osVersion = osVersion, + platform = platform, + deviceType = "desktop", + formFactor = "desktop", + totalMemory = totalMemory, + processorCount = processorCount, + ) +} + +/** + * Generate a stable device ID based on hardware characteristics. + */ +private fun generateDeviceId(): String { + return try { + // Try to use MAC address for stable device ID (works on JVM, may fail on Android) + val networkInterfaces = NetworkInterface.getNetworkInterfaces() + val macAddresses = + networkInterfaces + ?.asSequence() + ?.mapNotNull { it.hardwareAddress } + ?.filter { it.isNotEmpty() } + ?.map { it.joinToString(":") { byte -> "%02X".format(byte) } } + ?.toList() ?: emptyList() + + if (macAddresses.isNotEmpty()) { + val combinedMac = macAddresses.sorted().joinToString("-") + hashString(combinedMac) + } else { + // Fallback to system properties + val systemInfo = + listOf( + System.getProperty("os.name"), + System.getProperty("os.arch"), + System.getProperty("user.name"), + System.getProperty("user.home"), + ).filterNotNull().joinToString("-") + if (systemInfo.isNotEmpty()) { + hashString(systemInfo) + } else { + UUID.randomUUID().toString() + } + } + } catch (e: Exception) { + // Ultimate fallback - random UUID (not stable across restarts) + UUID.randomUUID().toString() + } +} + +/** + * Hash a string to create a device ID. + */ +private fun hashString(input: String): String { + val digest = MessageDigest.getInstance("SHA-256") + val hash = digest.digest(input.toByteArray()) + return hash.take(16).joinToString("") { "%02x".format(it) } +} + +/** + * Map architecture names to standard names. + */ +private fun mapArchitecture(osArch: String): String { + return when { + osArch.contains("arm64", ignoreCase = true) -> "arm64" + osArch.contains("aarch64", ignoreCase = true) -> "arm64" + osArch.contains("arm", ignoreCase = true) -> "arm" + osArch.contains("x86_64", ignoreCase = true) -> "x86_64" + osArch.contains("amd64", ignoreCase = true) -> "x86_64" + osArch.contains("64", ignoreCase = true) -> "x86_64" + osArch.contains("x86", ignoreCase = true) -> "x86" + osArch.contains("86", ignoreCase = true) -> "x86" + else -> osArch + } +} diff --git a/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/native/bridge/RunAnywhereBridge.kt b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/native/bridge/RunAnywhereBridge.kt new file mode 100644 index 000000000..5c93b2a44 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/native/bridge/RunAnywhereBridge.kt @@ -0,0 +1,894 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * JNI Bridge for runanywhere-commons C API (rac_* functions). + * + * This matches the Swift SDK's CppBridge pattern where: + * - Swift uses CRACommons (C headers) → RACommons.xcframework + * - Kotlin uses RunAnywhereBridge (JNI) → librunanywhere_jni.so + * + * The JNI library is built from runanywhere-commons/src/jni/runanywhere_commons_jni.cpp + * and provides the rac_* API surface that wraps the C++ commons layer. + */ + +package com.runanywhere.sdk.native.bridge + +import com.runanywhere.sdk.foundation.SDKLogger + +/** + * RunAnywhereBridge provides low-level JNI bindings for the runanywhere-commons C API. + * + * This object maps directly to the JNI functions in runanywhere_commons_jni.cpp. + * For higher-level usage, use CppBridge and its extensions. + * + * @see com.runanywhere.sdk.foundation.bridge.CppBridge + */ +object RunAnywhereBridge { + private const val TAG = "RunAnywhereBridge" + + // ======================================================================== + // NATIVE LIBRARY LOADING + // ======================================================================== + + @Volatile + private var nativeLibraryLoaded = false + private val loadLock = Any() + + private val logger = SDKLogger(TAG) + + /** + * Load the native commons library if not already loaded. + * @return true if the library is loaded, false otherwise + */ + fun ensureNativeLibraryLoaded(): Boolean { + if (nativeLibraryLoaded) return true + + synchronized(loadLock) { + if (nativeLibraryLoaded) return true + + logger.info("Loading native library 'runanywhere_jni'...") + + try { + System.loadLibrary("runanywhere_jni") + nativeLibraryLoaded = true + logger.info("✅ Native library loaded successfully") + return true + } catch (e: UnsatisfiedLinkError) { + logger.error("❌ Failed to load native library: ${e.message}", throwable = e) + return false + } catch (e: Exception) { + logger.error("❌ Unexpected error: ${e.message}", throwable = e) + return false + } + } + } + + fun isNativeLibraryLoaded(): Boolean = nativeLibraryLoaded + + // ======================================================================== + // CORE INITIALIZATION (rac_core.h) + // ======================================================================== + + @JvmStatic + external fun racInit(): Int + + @JvmStatic + external fun racShutdown(): Int + + @JvmStatic + external fun racIsInitialized(): Boolean + + // ======================================================================== + // PLATFORM ADAPTER (rac_platform_adapter.h) + // ======================================================================== + + @JvmStatic + external fun racSetPlatformAdapter(adapter: Any): Int + + @JvmStatic + external fun racGetPlatformAdapter(): Any? + + // ======================================================================== + // LOGGING (rac_logger.h) + // ======================================================================== + + @JvmStatic + external fun racConfigureLogging(level: Int, logFilePath: String?): Int + + @JvmStatic + external fun racLog(level: Int, tag: String, message: String) + + // ======================================================================== + // LLM COMPONENT (rac_llm_component.h) + // ======================================================================== + + @JvmStatic + external fun racLlmComponentCreate(): Long + + @JvmStatic + external fun racLlmComponentDestroy(handle: Long) + + @JvmStatic + external fun racLlmComponentConfigure(handle: Long, configJson: String): Int + + @JvmStatic + external fun racLlmComponentIsLoaded(handle: Long): Boolean + + @JvmStatic + external fun racLlmComponentGetModelId(handle: Long): String? + + /** + * Load a model. Takes model path (or ID) and optional config JSON. + */ + @JvmStatic + external fun racLlmComponentLoadModel(handle: Long, modelPath: String, modelId: String, modelName: String?): Int + + @JvmStatic + external fun racLlmComponentUnload(handle: Long): Int + + @JvmStatic + external fun racLlmComponentCleanup(handle: Long): Int + + @JvmStatic + external fun racLlmComponentCancel(handle: Long): Int + + /** + * Generate text (non-streaming). + * @return JSON result string or null on error + */ + @JvmStatic + external fun racLlmComponentGenerate(handle: Long, prompt: String, optionsJson: String?): String? + + /** + * Generate text with streaming - simplified version that returns result JSON. + * Streaming is handled internally, result returned on completion. + */ + @JvmStatic + external fun racLlmComponentGenerateStream(handle: Long, prompt: String, optionsJson: String?): String? + + /** + * Token callback interface for streaming generation. + */ + fun interface TokenCallback { + fun onToken(token: ByteArray): Boolean + } + + /** + * Generate text with true streaming - calls tokenCallback for each token. + * This provides real-time token-by-token streaming. + * + * @param handle LLM component handle + * @param prompt The prompt to generate from + * @param optionsJson Options as JSON string + * @param tokenCallback Callback invoked for each generated token + * @return JSON result string with final metrics, or null on error + */ + @JvmStatic + external fun racLlmComponentGenerateStreamWithCallback( + handle: Long, + prompt: String, + optionsJson: String?, + tokenCallback: TokenCallback, + ): String? + + @JvmStatic + external fun racLlmComponentSupportsStreaming(handle: Long): Boolean + + @JvmStatic + external fun racLlmComponentGetState(handle: Long): Int + + @JvmStatic + external fun racLlmComponentGetMetrics(handle: Long): String? + + @JvmStatic + external fun racLlmComponentGetContextSize(handle: Long): Int + + @JvmStatic + external fun racLlmComponentTokenize(handle: Long, text: String): Int + + @JvmStatic + external fun racLlmSetCallbacks(streamCallback: Any?, progressCallback: Any?) + + // ======================================================================== + // STT COMPONENT (rac_stt_component.h) + // ======================================================================== + + @JvmStatic + external fun racSttComponentCreate(): Long + + @JvmStatic + external fun racSttComponentDestroy(handle: Long) + + @JvmStatic + external fun racSttComponentIsLoaded(handle: Long): Boolean + + @JvmStatic + external fun racSttComponentLoadModel(handle: Long, modelPath: String, modelId: String, modelName: String?): Int + + @JvmStatic + external fun racSttComponentUnload(handle: Long): Int + + @JvmStatic + external fun racSttComponentCancel(handle: Long): Int + + @JvmStatic + external fun racSttComponentTranscribe(handle: Long, audioData: ByteArray, optionsJson: String?): String? + + @JvmStatic + external fun racSttComponentTranscribeFile(handle: Long, audioPath: String, optionsJson: String?): String? + + @JvmStatic + external fun racSttComponentTranscribeStream(handle: Long, audioData: ByteArray, optionsJson: String?): String? + + @JvmStatic + external fun racSttComponentSupportsStreaming(handle: Long): Boolean + + @JvmStatic + external fun racSttComponentGetState(handle: Long): Int + + @JvmStatic + external fun racSttComponentGetLanguages(handle: Long): String? + + @JvmStatic + external fun racSttComponentDetectLanguage(handle: Long, audioData: ByteArray): String? + + @JvmStatic + external fun racSttSetCallbacks(frameCallback: Any?, progressCallback: Any?) + + // ======================================================================== + // TTS COMPONENT (rac_tts_component.h) + // ======================================================================== + + @JvmStatic + external fun racTtsComponentCreate(): Long + + @JvmStatic + external fun racTtsComponentDestroy(handle: Long) + + @JvmStatic + external fun racTtsComponentIsLoaded(handle: Long): Boolean + + @JvmStatic + external fun racTtsComponentLoadModel(handle: Long, modelPath: String, modelId: String, modelName: String?): Int + + @JvmStatic + external fun racTtsComponentUnload(handle: Long): Int + + @JvmStatic + external fun racTtsComponentCancel(handle: Long): Int + + @JvmStatic + external fun racTtsComponentSynthesize(handle: Long, text: String, optionsJson: String?): ByteArray? + + @JvmStatic + external fun racTtsComponentSynthesizeToFile(handle: Long, text: String, outputPath: String, optionsJson: String?): Long + + @JvmStatic + external fun racTtsComponentSynthesizeStream(handle: Long, text: String, optionsJson: String?): ByteArray? + + @JvmStatic + external fun racTtsComponentGetVoices(handle: Long): String? + + @JvmStatic + external fun racTtsComponentSetVoice(handle: Long, voiceId: String): Int + + @JvmStatic + external fun racTtsComponentGetState(handle: Long): Int + + @JvmStatic + external fun racTtsComponentGetLanguages(handle: Long): String? + + @JvmStatic + external fun racTtsSetCallbacks(audioCallback: Any?, progressCallback: Any?) + + // ======================================================================== + // VAD COMPONENT (rac_vad_component.h) + // ======================================================================== + + @JvmStatic + external fun racVadComponentCreate(): Long + + @JvmStatic + external fun racVadComponentDestroy(handle: Long) + + @JvmStatic + external fun racVadComponentIsLoaded(handle: Long): Boolean + + @JvmStatic + external fun racVadComponentLoadModel(handle: Long, modelId: String?, configJson: String?): Int + + @JvmStatic + external fun racVadComponentUnload(handle: Long): Int + + @JvmStatic + external fun racVadComponentProcess(handle: Long, samples: ByteArray, optionsJson: String?): String? + + @JvmStatic + external fun racVadComponentProcessStream(handle: Long, samples: ByteArray, optionsJson: String?): String? + + @JvmStatic + external fun racVadComponentProcessFrame(handle: Long, samples: ByteArray, optionsJson: String?): String? + + @JvmStatic + external fun racVadComponentReset(handle: Long): Int + + @JvmStatic + external fun racVadComponentCancel(handle: Long): Int + + @JvmStatic + external fun racVadComponentSetThreshold(handle: Long, threshold: Float): Int + + @JvmStatic + external fun racVadComponentGetState(handle: Long): Int + + @JvmStatic + external fun racVadComponentGetMinFrameSize(handle: Long): Int + + @JvmStatic + external fun racVadComponentGetSampleRates(handle: Long): String? + + @JvmStatic + external fun racVadSetCallbacks( + frameCallback: Any?, + speechStartCallback: Any?, + speechEndCallback: Any?, + progressCallback: Any?, + ) + + // ======================================================================== + // BACKEND REGISTRATION + // ======================================================================== + // NOTE: Backend registration has been MOVED to their respective module JNI bridges: + // + // LlamaCPP: com.runanywhere.sdk.llm.llamacpp.LlamaCPPBridge.nativeRegister() + // (in module: runanywhere-core-llamacpp) + // + // ONNX: com.runanywhere.sdk.core.onnx.ONNXBridge.nativeRegister() + // (in module: runanywhere-core-onnx) + // + // This mirrors the Swift SDK architecture where each backend has its own + // XCFramework (RABackendLlamaCPP, RABackendONNX) with separate registration. + // ======================================================================== + + // ======================================================================== + // DOWNLOAD MANAGER (rac_download.h) + // ======================================================================== + + @JvmStatic + external fun racDownloadStart(url: String, destPath: String, progressCallback: Any?): Long + + @JvmStatic + external fun racDownloadCancel(downloadId: Long): Int + + @JvmStatic + external fun racDownloadGetProgress(downloadId: Long): String? + + // ======================================================================== + // MODEL REGISTRY - Direct C++ registry access (mirrors Swift CppBridge+ModelRegistry) + // ======================================================================== + + /** + * Save model to C++ registry. + * This stores the model directly in the C++ model registry for service provider lookup. + * + * @param modelId Unique model identifier + * @param name Display name + * @param category Model category (0=LLM, 1=STT, 2=TTS, 3=VAD) + * @param format Model format (0=UNKNOWN, 1=GGUF, 2=ONNX, etc.) + * @param framework Inference framework (0=LLAMACPP, 1=ONNX, etc.) + * @param downloadUrl Download URL (nullable) + * @param localPath Local file path (nullable) + * @param downloadSize Size in bytes + * @param contextLength Context length for LLM + * @param supportsThinking Whether model supports thinking mode + * @param description Model description (nullable) + * @return RAC_SUCCESS on success, error code on failure + */ + @JvmStatic + external fun racModelRegistrySave( + modelId: String, + name: String, + category: Int, + format: Int, + framework: Int, + downloadUrl: String?, + localPath: String?, + downloadSize: Long, + contextLength: Int, + supportsThinking: Boolean, + description: String?, + ): Int + + /** + * Get model info from C++ registry as JSON. + * + * @param modelId Model identifier + * @return JSON string with model info, or null if not found + */ + @JvmStatic + external fun racModelRegistryGet(modelId: String): String? + + /** + * Get all models from C++ registry as JSON array. + * + * @return JSON array string with all models + */ + @JvmStatic + external fun racModelRegistryGetAll(): String + + /** + * Get downloaded models from C++ registry as JSON array. + * + * @return JSON array string with downloaded models + */ + @JvmStatic + external fun racModelRegistryGetDownloaded(): String + + /** + * Remove model from C++ registry. + * + * @param modelId Model identifier + * @return RAC_SUCCESS on success, error code on failure + */ + @JvmStatic + external fun racModelRegistryRemove(modelId: String): Int + + /** + * Update download status in C++ registry. + * + * @param modelId Model identifier + * @param localPath Local path after download (or null to clear) + * @return RAC_SUCCESS on success, error code on failure + */ + @JvmStatic + external fun racModelRegistryUpdateDownloadStatus(modelId: String, localPath: String?): Int + + // ======================================================================== + // MODEL ASSIGNMENT (rac_model_assignment.h) + // Mirrors Swift SDK's CppBridge+ModelAssignment.swift + // ======================================================================== + + /** + * Set model assignment callbacks. + * The callback object must implement: + * - httpGet(endpoint: String, requiresAuth: Boolean): String (returns JSON response or "ERROR:message") + * - getDeviceInfo(): String (returns "deviceType|platform") + * + * @param callback Callback object implementing the required methods + * @param autoFetch If true, automatically fetch models after registration + * @return RAC_SUCCESS on success, error code on failure + */ + @JvmStatic + external fun racModelAssignmentSetCallbacks(callback: Any, autoFetch: Boolean): Int + + /** + * Fetch model assignments from backend. + * Results are cached and saved to the model registry. + * + * @param forceRefresh If true, bypass cache and fetch fresh data + * @return JSON array of model assignments + */ + @JvmStatic + external fun racModelAssignmentFetch(forceRefresh: Boolean): String + + // ======================================================================== + // AUDIO UTILS (rac_audio_utils.h) + // ======================================================================== + + /** + * Convert Float32 PCM audio data to WAV format. + * + * TTS backends typically output raw Float32 PCM samples in range [-1.0, 1.0]. + * This function converts them to a complete WAV file that can be played by + * standard audio players (MediaPlayer on Android, etc.). + * + * @param pcmData Float32 PCM audio data (raw bytes) + * @param sampleRate Sample rate in Hz (e.g., 22050 for Piper TTS) + * @return WAV file data as ByteArray, or null on error + */ + @JvmStatic + external fun racAudioFloat32ToWav(pcmData: ByteArray, sampleRate: Int): ByteArray? + + /** + * Convert Int16 PCM audio data to WAV format. + * + * @param pcmData Int16 PCM audio data (raw bytes) + * @param sampleRate Sample rate in Hz + * @return WAV file data as ByteArray, or null on error + */ + @JvmStatic + external fun racAudioInt16ToWav(pcmData: ByteArray, sampleRate: Int): ByteArray? + + /** + * Get the WAV header size in bytes. + * + * @return WAV header size (always 44 bytes for standard PCM WAV) + */ + @JvmStatic + external fun racAudioWavHeaderSize(): Int + + // ======================================================================== + // DEVICE MANAGER (rac_device_manager.h) + // Mirrors Swift SDK's CppBridge+Device.swift + // ======================================================================== + + /** + * Set device manager callbacks. + * The callback object must implement: + * - getDeviceInfo(): String (returns JSON) + * - getDeviceId(): String + * - isRegistered(): Boolean + * - setRegistered(registered: Boolean) + * - httpPost(endpoint: String, body: String, requiresAuth: Boolean): Int (status code) + */ + @JvmStatic + external fun racDeviceManagerSetCallbacks(callbacks: Any): Int + + /** + * Register device with backend if not already registered. + * @param environment SDK environment (0=DEVELOPMENT, 1=STAGING, 2=PRODUCTION) + * @param buildToken Optional build token for development mode + */ + @JvmStatic + external fun racDeviceManagerRegisterIfNeeded(environment: Int, buildToken: String?): Int + + /** + * Check if device is registered. + */ + @JvmStatic + external fun racDeviceManagerIsRegistered(): Boolean + + /** + * Clear device registration status. + */ + @JvmStatic + external fun racDeviceManagerClearRegistration() + + /** + * Get the current device ID. + */ + @JvmStatic + external fun racDeviceManagerGetDeviceId(): String? + + // ======================================================================== + // TELEMETRY MANAGER (rac_telemetry_manager.h) + // Mirrors Swift SDK's CppBridge+Telemetry.swift + // ======================================================================== + + /** + * Create telemetry manager. + * @param environment SDK environment + * @param deviceId Persistent device UUID + * @param platform Platform string ("android") + * @param sdkVersion SDK version string + * @return Handle to telemetry manager, or 0 on failure + */ + @JvmStatic + external fun racTelemetryManagerCreate( + environment: Int, + deviceId: String, + platform: String, + sdkVersion: String, + ): Long + + /** + * Destroy telemetry manager. + */ + @JvmStatic + external fun racTelemetryManagerDestroy(handle: Long) + + /** + * Set device info for telemetry payloads. + */ + @JvmStatic + external fun racTelemetryManagerSetDeviceInfo(handle: Long, deviceModel: String, osVersion: String) + + /** + * Set HTTP callback for telemetry. + * The callback object must implement: + * - onHttpRequest(endpoint: String, body: String, bodyLength: Int, requiresAuth: Boolean) + */ + @JvmStatic + external fun racTelemetryManagerSetHttpCallback(handle: Long, callback: Any) + + /** + * Flush pending telemetry events. + */ + @JvmStatic + external fun racTelemetryManagerFlush(handle: Long): Int + + // ======================================================================== + // ANALYTICS EVENTS (rac_analytics_events.h) + // ======================================================================== + + /** + * Register analytics events callback with telemetry manager. + * Events from C++ will be routed to the telemetry manager for batching and HTTP transport. + * + * @param telemetryHandle Handle to the telemetry manager (from racTelemetryManagerCreate) + * Pass 0 to unregister the callback + * @return RAC_SUCCESS or error code + */ + @JvmStatic + external fun racAnalyticsEventsSetCallback(telemetryHandle: Long): Int + + /** + * Emit a download/extraction event. + * Maps to rac_analytics_model_download_t struct in C++. + */ + @JvmStatic + external fun racAnalyticsEventEmitDownload( + eventType: Int, + modelId: String?, + progress: Double, + bytesDownloaded: Long, + totalBytes: Long, + durationMs: Double, + sizeBytes: Long, + archiveType: String?, + errorCode: Int, + errorMessage: String?, + ): Int + + /** + * Emit an SDK lifecycle event. + * Maps to rac_analytics_sdk_lifecycle_t struct in C++. + */ + @JvmStatic + external fun racAnalyticsEventEmitSdkLifecycle( + eventType: Int, + durationMs: Double, + count: Int, + errorCode: Int, + errorMessage: String?, + ): Int + + /** + * Emit a storage event. + * Maps to rac_analytics_storage_t struct in C++. + */ + @JvmStatic + external fun racAnalyticsEventEmitStorage( + eventType: Int, + freedBytes: Long, + errorCode: Int, + errorMessage: String?, + ): Int + + /** + * Emit a device event. + * Maps to rac_analytics_device_t struct in C++. + */ + @JvmStatic + external fun racAnalyticsEventEmitDevice( + eventType: Int, + deviceId: String?, + errorCode: Int, + errorMessage: String?, + ): Int + + /** + * Emit an SDK error event. + * Maps to rac_analytics_sdk_error_t struct in C++. + */ + @JvmStatic + external fun racAnalyticsEventEmitSdkError( + eventType: Int, + errorCode: Int, + errorMessage: String?, + operation: String?, + context: String?, + ): Int + + /** + * Emit a network event. + * Maps to rac_analytics_network_t struct in C++. + */ + @JvmStatic + external fun racAnalyticsEventEmitNetwork( + eventType: Int, + isOnline: Boolean, + ): Int + + /** + * Emit an LLM generation event. + * Maps to rac_analytics_llm_generation_t struct in C++. + */ + @JvmStatic + external fun racAnalyticsEventEmitLlmGeneration( + eventType: Int, + generationId: String?, + modelId: String?, + modelName: String?, + inputTokens: Int, + outputTokens: Int, + durationMs: Double, + tokensPerSecond: Double, + isStreaming: Boolean, + timeToFirstTokenMs: Double, + framework: Int, + temperature: Float, + maxTokens: Int, + contextLength: Int, + errorCode: Int, + errorMessage: String?, + ): Int + + /** + * Emit an LLM model event. + * Maps to rac_analytics_llm_model_t struct in C++. + */ + @JvmStatic + external fun racAnalyticsEventEmitLlmModel( + eventType: Int, + modelId: String?, + modelName: String?, + modelSizeBytes: Long, + durationMs: Double, + framework: Int, + errorCode: Int, + errorMessage: String?, + ): Int + + /** + * Emit an STT transcription event. + * Maps to rac_analytics_stt_transcription_t struct in C++. + */ + @JvmStatic + external fun racAnalyticsEventEmitSttTranscription( + eventType: Int, + transcriptionId: String?, + modelId: String?, + modelName: String?, + text: String?, + confidence: Float, + durationMs: Double, + audioLengthMs: Double, + audioSizeBytes: Int, + wordCount: Int, + realTimeFactor: Double, + language: String?, + sampleRate: Int, + isStreaming: Boolean, + framework: Int, + errorCode: Int, + errorMessage: String?, + ): Int + + /** + * Emit a TTS synthesis event. + * Maps to rac_analytics_tts_synthesis_t struct in C++. + */ + @JvmStatic + external fun racAnalyticsEventEmitTtsSynthesis( + eventType: Int, + synthesisId: String?, + modelId: String?, + modelName: String?, + characterCount: Int, + audioDurationMs: Double, + audioSizeBytes: Int, + processingDurationMs: Double, + charactersPerSecond: Double, + sampleRate: Int, + framework: Int, + errorCode: Int, + errorMessage: String?, + ): Int + + /** + * Emit a VAD event. + * Maps to rac_analytics_vad_t struct in C++. + */ + @JvmStatic + external fun racAnalyticsEventEmitVad( + eventType: Int, + speechDurationMs: Double, + energyLevel: Float, + ): Int + + // ======================================================================== + // DEVELOPMENT CONFIG (rac_dev_config.h) + // Mirrors Swift SDK's CppBridge+Environment.swift DevConfig + // ======================================================================== + + /** + * Check if development config is available (has Supabase credentials configured). + * @return true if dev config is available + */ + @JvmStatic + external fun racDevConfigIsAvailable(): Boolean + + /** + * Get Supabase URL for development mode. + * @return Supabase URL or null if not configured + */ + @JvmStatic + external fun racDevConfigGetSupabaseUrl(): String? + + /** + * Get Supabase anon key for development mode. + * @return Supabase anon key or null if not configured + */ + @JvmStatic + external fun racDevConfigGetSupabaseKey(): String? + + /** + * Get build token for development mode. + * @return Build token or null if not configured + */ + @JvmStatic + external fun racDevConfigGetBuildToken(): String? + + /** + * Get Sentry DSN for crash reporting. + * @return Sentry DSN or null if not configured + */ + @JvmStatic + external fun racDevConfigGetSentryDsn(): String? + + // ======================================================================== + // SDK CONFIGURATION INITIALIZATION + // ======================================================================== + + /** + * Initialize SDK configuration with version and platform info. + * This must be called during SDK initialization for device registration + * to include the correct sdk_version (instead of "unknown"). + * + * @param environment Environment (0=development, 1=staging, 2=production) + * @param deviceId Device ID string + * @param platform Platform string (e.g., "android") + * @param sdkVersion SDK version string (e.g., "0.1.0") + * @param apiKey API key (can be empty for development) + * @param baseUrl Base URL (can be empty for development) + * @return 0 on success, error code on failure + */ + @JvmStatic + external fun racSdkInit( + environment: Int, + deviceId: String?, + platform: String, + sdkVersion: String, + apiKey: String?, + baseUrl: String?, + ): Int + + // ======================================================================== + // CONSTANTS + // ======================================================================== + + // Result codes + const val RAC_SUCCESS = 0 + const val RAC_ERROR_INVALID_PARAMS = -1 + const val RAC_ERROR_INVALID_HANDLE = -2 + const val RAC_ERROR_NOT_INITIALIZED = -3 + const val RAC_ERROR_ALREADY_INITIALIZED = -4 + const val RAC_ERROR_OPERATION_FAILED = -5 + const val RAC_ERROR_NOT_SUPPORTED = -6 + const val RAC_ERROR_MODEL_NOT_LOADED = -7 + const val RAC_ERROR_OUT_OF_MEMORY = -8 + const val RAC_ERROR_IO = -9 + const val RAC_ERROR_CANCELLED = -10 + const val RAC_ERROR_MODULE_ALREADY_REGISTERED = -20 + const val RAC_ERROR_MODULE_NOT_FOUND = -21 + const val RAC_ERROR_SERVICE_NOT_FOUND = -22 + + // Lifecycle states + const val RAC_LIFECYCLE_IDLE = 0 + const val RAC_LIFECYCLE_INITIALIZING = 1 + const val RAC_LIFECYCLE_LOADING = 2 + const val RAC_LIFECYCLE_READY = 3 + const val RAC_LIFECYCLE_ACTIVE = 4 + const val RAC_LIFECYCLE_UNLOADING = 5 + const val RAC_LIFECYCLE_ERROR = 6 + + // Log levels + const val RAC_LOG_TRACE = 0 + const val RAC_LOG_DEBUG = 1 + const val RAC_LOG_INFO = 2 + const val RAC_LOG_WARN = 3 + const val RAC_LOG_ERROR = 4 + const val RAC_LOG_FATAL = 5 +} diff --git a/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/public/PlatformBridge.kt b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/public/PlatformBridge.kt new file mode 100644 index 000000000..610e4459d --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/public/PlatformBridge.kt @@ -0,0 +1,79 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * Platform-specific bridge for JVM/Android that connects RunAnywhere to CppBridge. + * Implements the expect/actual pattern for cross-platform compatibility. + */ + +package com.runanywhere.sdk.public + +import com.runanywhere.sdk.foundation.SDKLogger +import com.runanywhere.sdk.foundation.bridge.CppBridge +import com.runanywhere.sdk.foundation.bridge.extensions.CppBridgeTelemetry +import kotlinx.coroutines.runBlocking + +private const val TAG = "PlatformBridge" +private val logger = SDKLogger(TAG) + +/** + * Initialize the CppBridge with the given environment. + * This loads the native libraries and registers platform adapters. + * + * @param environment SDK environment + * @param apiKey API key for authentication (required for production/staging) + * @param baseURL Backend API base URL (required for production/staging) + */ +internal actual fun initializePlatformBridge(environment: SDKEnvironment, apiKey: String?, baseURL: String?) { + logger.info("Initializing CppBridge for environment: $environment") + + val cppEnvironment = + when (environment) { + SDKEnvironment.DEVELOPMENT -> CppBridge.Environment.DEVELOPMENT + SDKEnvironment.STAGING -> CppBridge.Environment.STAGING + SDKEnvironment.PRODUCTION -> CppBridge.Environment.PRODUCTION + } + + // Configure telemetry base URL if provided + if (!baseURL.isNullOrEmpty()) { + CppBridgeTelemetry.setBaseUrl(baseURL) + logger.info("Telemetry base URL configured: $baseURL") + } + + CppBridge.initialize(cppEnvironment, apiKey, baseURL) + + logger.info("CppBridge initialization complete. Native library loaded: ${CppBridge.isNativeLibraryLoaded}") +} + +/** + * Initialize CppBridge services (Phase 2). + * This includes model assignment, platform services, and device registration. + */ +internal actual fun initializePlatformBridgeServices() { + logger.info("Initializing CppBridge services...") + + // Use runBlocking to call the suspend function + // This is safe because services initialization is typically called once + runBlocking { + CppBridge.initializeServices() + } + + logger.info("CppBridge services initialization complete") +} + +/** + * Shutdown CppBridge and release resources. + */ +internal actual fun shutdownPlatformBridge() { + logger.info("Shutting down CppBridge...") + CppBridge.shutdown() + logger.info("CppBridge shutdown complete") +} + +/** + * Configure telemetry base URL. + * This should be called before SDK initialization if using a custom backend URL. + */ +fun configureTelemetryBaseUrl(baseUrl: String) { + CppBridgeTelemetry.setBaseUrl(baseUrl) +} diff --git a/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/public/extensions/RunAnywhere+Logging.jvmAndroid.kt b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/public/extensions/RunAnywhere+Logging.jvmAndroid.kt new file mode 100644 index 000000000..ac42c9c92 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/public/extensions/RunAnywhere+Logging.jvmAndroid.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * JVM/Android actual implementations for logging configuration. + */ + +package com.runanywhere.sdk.public.extensions + +import com.runanywhere.sdk.foundation.SDKLogger +import com.runanywhere.sdk.public.RunAnywhere + +// Internal log level state +@Volatile +private var currentLogLevel: LogLevel = LogLevel.INFO + +// File logging state +@Volatile +private var fileLoggingEnabled: Boolean = false + +@Volatile +private var fileLoggingPath: String? = null + +private val logger = SDKLogger.shared + +internal actual fun RunAnywhere.setLogLevelInternal(level: LogLevel) { + currentLogLevel = level + logger.debug("Log level set to ${level.name}") +} + +actual fun RunAnywhere.setFileLogging(enabled: Boolean, path: String?) { + fileLoggingEnabled = enabled + fileLoggingPath = path + logger.debug("File logging ${if (enabled) "enabled" else "disabled"}${path?.let { " at $it" } ?: ""}") +} + +actual fun RunAnywhere.getLogLevel(): LogLevel { + return currentLogLevel +} + +actual fun RunAnywhere.flushLogs() { + // Logs are written immediately via platform adapter, no buffering + logger.debug("Logs flushed") +} diff --git a/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/public/extensions/RunAnywhere+ModelManagement.jvmAndroid.kt b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/public/extensions/RunAnywhere+ModelManagement.jvmAndroid.kt new file mode 100644 index 000000000..3f916b0c3 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/public/extensions/RunAnywhere+ModelManagement.jvmAndroid.kt @@ -0,0 +1,1025 @@ +/* + * Copyright 2024 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * JVM/Android actual implementations for model management operations. + */ + +package com.runanywhere.sdk.public.extensions + +import com.runanywhere.sdk.core.types.InferenceFramework +import com.runanywhere.sdk.foundation.SDKLogger +import com.runanywhere.sdk.foundation.bridge.extensions.CppBridgeDownload +import com.runanywhere.sdk.foundation.bridge.extensions.CppBridgeEvents +import com.runanywhere.sdk.foundation.bridge.extensions.CppBridgeLLM +import com.runanywhere.sdk.foundation.bridge.extensions.CppBridgeModelPaths +import com.runanywhere.sdk.foundation.bridge.extensions.CppBridgeModelRegistry +import com.runanywhere.sdk.foundation.bridge.extensions.CppBridgeSTT +import com.runanywhere.sdk.foundation.errors.SDKError +import com.runanywhere.sdk.public.RunAnywhere +import com.runanywhere.sdk.public.extensions.Models.DownloadProgress +import com.runanywhere.sdk.public.extensions.Models.DownloadState +import com.runanywhere.sdk.public.extensions.Models.ModelCategory +import com.runanywhere.sdk.public.extensions.Models.ModelFormat +import com.runanywhere.sdk.public.extensions.Models.ModelInfo +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.withContext +import org.apache.commons.compress.archivers.tar.TarArchiveInputStream +import org.apache.commons.compress.compressors.bzip2.BZip2CompressorInputStream +import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream +import java.io.BufferedInputStream +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.util.zip.ZipInputStream + +// MARK: - Model Registration Implementation + +private val modelsLogger = SDKLogger.models + +/** + * Internal implementation for registering a model to the C++ registry. + * This is called by the public registerModel() function in commonMain. + * + * IMPORTANT: This saves directly to the C++ registry so that C++ service providers + * (like LlamaCPP) can find the model when loading. The framework field is critical + * for correct backend selection. + */ +internal actual fun registerModelInternal(modelInfo: ModelInfo) { + try { + // Convert public ModelInfo to bridge ModelInfo + // CRITICAL: The framework field must be set correctly for C++ can_handle() to work + val bridgeModelInfo = + CppBridgeModelRegistry.ModelInfo( + modelId = modelInfo.id, + name = modelInfo.name, + category = + when (modelInfo.category) { + ModelCategory.LANGUAGE -> CppBridgeModelRegistry.ModelCategory.LANGUAGE + ModelCategory.SPEECH_RECOGNITION -> CppBridgeModelRegistry.ModelCategory.SPEECH_RECOGNITION + ModelCategory.SPEECH_SYNTHESIS -> CppBridgeModelRegistry.ModelCategory.SPEECH_SYNTHESIS + ModelCategory.AUDIO -> CppBridgeModelRegistry.ModelCategory.AUDIO + else -> CppBridgeModelRegistry.ModelCategory.LANGUAGE + }, + format = + when (modelInfo.format) { + ModelFormat.GGUF -> CppBridgeModelRegistry.ModelFormat.GGUF + ModelFormat.ONNX -> CppBridgeModelRegistry.ModelFormat.ONNX + ModelFormat.ORT -> CppBridgeModelRegistry.ModelFormat.ORT + else -> CppBridgeModelRegistry.ModelFormat.UNKNOWN + }, + // CRITICAL: Map InferenceFramework to C++ framework constant + framework = + when (modelInfo.framework) { + InferenceFramework.LLAMA_CPP -> CppBridgeModelRegistry.Framework.LLAMACPP + InferenceFramework.ONNX -> CppBridgeModelRegistry.Framework.ONNX + InferenceFramework.FOUNDATION_MODELS -> CppBridgeModelRegistry.Framework.FOUNDATION_MODELS + InferenceFramework.SYSTEM_TTS -> CppBridgeModelRegistry.Framework.SYSTEM_TTS + InferenceFramework.FLUID_AUDIO -> CppBridgeModelRegistry.Framework.FLUID_AUDIO + InferenceFramework.BUILT_IN -> CppBridgeModelRegistry.Framework.BUILTIN + InferenceFramework.NONE -> CppBridgeModelRegistry.Framework.NONE + InferenceFramework.UNKNOWN -> CppBridgeModelRegistry.Framework.UNKNOWN + }, + downloadUrl = modelInfo.downloadURL, + localPath = modelInfo.localPath, + downloadSize = modelInfo.downloadSize ?: 0, + contextLength = modelInfo.contextLength ?: 0, + supportsThinking = modelInfo.supportsThinking, + description = modelInfo.description, + status = CppBridgeModelRegistry.ModelStatus.AVAILABLE, + ) + + // Save directly to C++ registry - this is where C++ backends look for models + CppBridgeModelRegistry.save(bridgeModelInfo) + + // Also add to the in-memory cache for immediate availability from Kotlin + addToModelCache(modelInfo) + + modelsLogger.info("Registered model: ${modelInfo.name} (${modelInfo.id})") + } catch (e: Exception) { + modelsLogger.error("Failed to register model: ${e.message}") + } +} + +// In-memory model cache for registered models +private val registeredModels = mutableListOf() +private val modelCacheLock = Any() + +private fun addToModelCache(modelInfo: ModelInfo) { + synchronized(modelCacheLock) { + // Remove existing if present (update) + registeredModels.removeAll { it.id == modelInfo.id } + registeredModels.add(modelInfo) + } +} + +private fun getRegisteredModels(): List { + synchronized(modelCacheLock) { + return registeredModels.toList() + } +} + +// Convert CppBridgeModelRegistry.ModelInfo to public ModelInfo +private fun CppBridgeModelRegistry.ModelInfo.toPublicModelInfo(): ModelInfo { + return bridgeModelToPublic(this) +} + +private fun getAllBridgeModels(): List { + // Get all models directly from C++ registry + return CppBridgeModelRegistry.getAll() +} + +// Track if we've scanned for downloaded models +@Volatile +private var hasScannedForDownloads = false +private val scanLock = Any() + +actual suspend fun RunAnywhere.availableModels(): List { + if (!isInitialized) { + throw SDKError.notInitialized("SDK not initialized") + } + + // Scan for downloaded models once on first call + synchronized(scanLock) { + if (!hasScannedForDownloads) { + CppBridgeModelRegistry.scanAndRestoreDownloadedModels() + syncRegisteredModelsWithBridge() + hasScannedForDownloads = true + } + } + + // Get models from in-memory cache (registered via registerModel()) + val registeredModelList = getRegisteredModels() + + // Get models from C++ bridge + val bridgeModels = getAllBridgeModels().map { it.toPublicModelInfo() } + + // Merge both lists, with registered models taking precedence + val allModels = mutableMapOf() + for (model in bridgeModels) { + allModels[model.id] = model + } + for (model in registeredModelList) { + allModels[model.id] = model + } + + return allModels.values.toList() +} + +/** + * Sync the registered models cache with the bridge registry. + * This updates localPath for models that were found on disk. + */ +private fun syncRegisteredModelsWithBridge() { + synchronized(modelCacheLock) { + val updatedModels = mutableListOf() + for (model in registeredModels) { + // Check bridge registry for updated info (especially localPath) + val bridgeModel = CppBridgeModelRegistry.get(model.id) + if (bridgeModel != null && bridgeModel.localPath != null) { + // Model was found on disk, update local path (isDownloaded is computed from localPath) + updatedModels.add(model.copy(localPath = bridgeModel.localPath)) + } else { + updatedModels.add(model) + } + } + registeredModels.clear() + registeredModels.addAll(updatedModels) + } +} + +actual suspend fun RunAnywhere.models(category: ModelCategory): List { + if (!isInitialized) { + throw SDKError.notInitialized("SDK not initialized") + } + val type = + when (category) { + ModelCategory.LANGUAGE -> CppBridgeModelRegistry.ModelType.LLM + ModelCategory.SPEECH_RECOGNITION -> CppBridgeModelRegistry.ModelType.STT + ModelCategory.SPEECH_SYNTHESIS -> CppBridgeModelRegistry.ModelType.TTS + ModelCategory.AUDIO -> CppBridgeModelRegistry.ModelType.VAD + else -> return emptyList() + } + return CppBridgeModelRegistry.getModelsByType(type).map { bridgeModelToPublic(it) } +} + +actual suspend fun RunAnywhere.downloadedModels(): List { + if (!isInitialized) { + throw SDKError.notInitialized("SDK not initialized") + } + return CppBridgeModelRegistry.getDownloaded().map { bridgeModelToPublic(it) } +} + +actual suspend fun RunAnywhere.model(modelId: String): ModelInfo? { + if (!isInitialized) { + throw SDKError.notInitialized("SDK not initialized") + } + // Get model from C++ registry + val bridgeModel = CppBridgeModelRegistry.get(modelId) ?: return null + return bridgeModelToPublic(bridgeModel) +} + +// Convert CppBridgeModelRegistry.ModelInfo to public ModelInfo +private fun bridgeModelToPublic(bridge: CppBridgeModelRegistry.ModelInfo): ModelInfo { + return ModelInfo( + id = bridge.modelId, + name = bridge.name, + category = + when (bridge.category) { + CppBridgeModelRegistry.ModelCategory.LANGUAGE -> ModelCategory.LANGUAGE + CppBridgeModelRegistry.ModelCategory.SPEECH_RECOGNITION -> ModelCategory.SPEECH_RECOGNITION + CppBridgeModelRegistry.ModelCategory.SPEECH_SYNTHESIS -> ModelCategory.SPEECH_SYNTHESIS + CppBridgeModelRegistry.ModelCategory.AUDIO -> ModelCategory.AUDIO + else -> ModelCategory.LANGUAGE + }, + format = + when (bridge.format) { + CppBridgeModelRegistry.ModelFormat.GGUF -> ModelFormat.GGUF + CppBridgeModelRegistry.ModelFormat.ONNX -> ModelFormat.ONNX + CppBridgeModelRegistry.ModelFormat.ORT -> ModelFormat.ORT + else -> ModelFormat.UNKNOWN + }, + framework = + when (bridge.framework) { + CppBridgeModelRegistry.Framework.LLAMACPP -> InferenceFramework.LLAMA_CPP + CppBridgeModelRegistry.Framework.ONNX -> InferenceFramework.ONNX + CppBridgeModelRegistry.Framework.FOUNDATION_MODELS -> InferenceFramework.FOUNDATION_MODELS + CppBridgeModelRegistry.Framework.SYSTEM_TTS -> InferenceFramework.SYSTEM_TTS + else -> InferenceFramework.UNKNOWN + }, + downloadURL = bridge.downloadUrl, + localPath = bridge.localPath, + downloadSize = if (bridge.downloadSize > 0) bridge.downloadSize else null, + contextLength = if (bridge.contextLength > 0) bridge.contextLength else null, + supportsThinking = bridge.supportsThinking, + description = bridge.description, + ) +} + +/** + * Download a model by ID. + * + * Mirrors Swift `RunAnywhere.downloadModel()` exactly: + * 1. Gets model info from registry + * 2. Starts download via CppBridgeDownload + * 3. Handles archive extraction for .tar.gz and .zip + * 4. Updates model registry with local path + * + * @param modelId The model ID to download + * @return Flow of DownloadProgress updates + */ +actual fun RunAnywhere.downloadModel(modelId: String): Flow = + callbackFlow { + val downloadLogger = SDKLogger.download + + // 0. Check network connectivity first (for better user experience) + val (isNetworkAvailable, networkDescription) = CppBridgeDownload.checkNetworkStatus() + if (!isNetworkAvailable) { + downloadLogger.error("No internet connection: $networkDescription") + throw SDKError.networkUnavailable( + IllegalStateException("No internet connection. Please check your network settings and try again."), + ) + } + downloadLogger.debug("Network status: $networkDescription") + + // 1. Get model info from registered models or bridge models + // First check registered models, then fall back to bridge models (from remote API) + val modelInfo = + getRegisteredModels().find { it.id == modelId } + ?: getAllBridgeModels().find { it.modelId == modelId }?.toPublicModelInfo() + ?: throw SDKError.model("Model '$modelId' not found in registry") + + val downloadUrl = + modelInfo.downloadURL + ?: throw SDKError.model("Model '$modelId' has no download URL") + + downloadLogger.info("Starting download for model: $modelId") + downloadLogger.info(" URL: $downloadUrl") + downloadLogger.info(" Category: ${modelInfo.category}") + downloadLogger.info(" Framework: ${modelInfo.framework}") + + // 2. Emit initial progress + trySend( + DownloadProgress( + modelId = modelId, + progress = 0f, + bytesDownloaded = 0, + totalBytes = modelInfo.downloadSize, + state = DownloadState.PENDING, + ), + ) + + // 3. Determine model type for path resolution + val modelType = + when (modelInfo.category) { + ModelCategory.LANGUAGE -> CppBridgeModelRegistry.ModelType.LLM + ModelCategory.SPEECH_RECOGNITION -> CppBridgeModelRegistry.ModelType.STT + ModelCategory.SPEECH_SYNTHESIS -> CppBridgeModelRegistry.ModelType.TTS + ModelCategory.AUDIO -> CppBridgeModelRegistry.ModelType.VAD + else -> CppBridgeModelRegistry.ModelType.LLM + } + + // 4. Emit download started event and record start time + val downloadStartTime = System.currentTimeMillis() + CppBridgeEvents.emitDownloadStarted(modelId, modelInfo.downloadSize ?: 0) + + // 5. Create a CompletableDeferred to wait for download completion + // This is used to properly suspend until the async download finishes + data class DownloadResult( + val success: Boolean, + val filePath: String?, + val fileSize: Long, + val error: String?, + ) + val downloadCompletion = CompletableDeferred() + + // 6. Set up download listener to convert callbacks to Flow + val downloadListener = + object : CppBridgeDownload.DownloadListener { + override fun onDownloadStarted(downloadId: String, modelId: String, url: String) { + downloadLogger.debug("Download actually started: $downloadId") + trySend( + DownloadProgress( + modelId = modelId, + progress = 0f, + bytesDownloaded = 0, + totalBytes = modelInfo.downloadSize, + state = DownloadState.DOWNLOADING, + ), + ) + } + + override fun onDownloadProgress(downloadId: String, downloadedBytes: Long, totalBytes: Long, progress: Int) { + val progressFraction = progress.toFloat() / 100f + downloadLogger.debug("Download progress: $progress% ($downloadedBytes / $totalBytes)") + + // Emit progress event every 5% + if (progress % 5 == 0) { + CppBridgeEvents.emitDownloadProgress(modelId, progressFraction.toDouble(), downloadedBytes, totalBytes) + } + + trySend( + DownloadProgress( + modelId = modelId, + progress = progressFraction, + bytesDownloaded = downloadedBytes, + totalBytes = if (totalBytes > 0) totalBytes else modelInfo.downloadSize, + state = DownloadState.DOWNLOADING, + ), + ) + } + + override fun onDownloadCompleted(downloadId: String, modelId: String, filePath: String, fileSize: Long) { + downloadLogger.info("Download completed callback: $filePath ($fileSize bytes)") + // Signal completion to the waiting coroutine + downloadCompletion.complete( + DownloadResult( + success = true, + filePath = filePath, + fileSize = fileSize, + error = null, + ), + ) + } + + override fun onDownloadFailed(downloadId: String, modelId: String, error: Int, errorMessage: String) { + downloadLogger.error("Download failed callback: $errorMessage (error code: $error)") + // Signal failure to the waiting coroutine + downloadCompletion.complete( + DownloadResult( + success = false, + filePath = null, + fileSize = 0, + error = errorMessage, + ), + ) + } + + override fun onDownloadPaused(downloadId: String) { + downloadLogger.info("Download paused: $downloadId") + trySend( + DownloadProgress( + modelId = modelId, + progress = 0f, + bytesDownloaded = 0, + totalBytes = modelInfo.downloadSize, + state = DownloadState.PENDING, + ), + ) + } + + override fun onDownloadResumed(downloadId: String) { + downloadLogger.info("Download resumed: $downloadId") + } + + override fun onDownloadCancelled(downloadId: String) { + downloadLogger.info("Download cancelled: $downloadId") + downloadCompletion.complete( + DownloadResult( + success = false, + filePath = null, + fileSize = 0, + error = "Download cancelled", + ), + ) + } + } + + // Register listener BEFORE starting download + CppBridgeDownload.downloadListener = downloadListener + + try { + // 7. Start the actual download (runs asynchronously on thread pool) + val downloadId = + CppBridgeDownload.startDownload( + url = downloadUrl, + modelId = modelId, + modelType = modelType, + priority = CppBridgeDownload.DownloadPriority.NORMAL, + expectedChecksum = null, + ) ?: throw SDKError.download("Failed to start download for model: $modelId") + + downloadLogger.info("Download queued with ID: $downloadId, waiting for completion...") + + // 8. Wait for download to complete (suspends until callback fires) + val result = downloadCompletion.await() + + // 9. Handle result + if (!result.success) { + val errorMsg = result.error ?: "Unknown download error" + CppBridgeEvents.emitDownloadFailed(modelId, errorMsg) + trySend( + DownloadProgress( + modelId = modelId, + progress = 0f, + bytesDownloaded = 0, + totalBytes = modelInfo.downloadSize, + state = DownloadState.ERROR, + error = errorMsg, + ), + ) + throw SDKError.download("Download failed for model: $modelId - $errorMsg") + } + + // 10. Get the downloaded file path + val downloadedPath = result.filePath ?: CppBridgeModelPaths.getModelPath(modelId, modelType) + val downloadedFile = File(downloadedPath) + + downloadLogger.info("Downloaded file: $downloadedPath (exists: ${downloadedFile.exists()}, size: ${result.fileSize})") + + // 11. Handle extraction if needed (for .tar.gz, .tar.bz2, or .zip archives) + val finalModelPath = + if (requiresExtraction(downloadUrl)) { + downloadLogger.info("Archive detected in URL, extracting...") + trySend( + DownloadProgress( + modelId = modelId, + progress = 0.95f, + bytesDownloaded = result.fileSize, + totalBytes = result.fileSize, + state = DownloadState.EXTRACTING, + ), + ) + + // Pass the URL to determine archive type (file may be saved without extension) + val extractedPath = extractArchive(downloadedFile, modelId, modelType, downloadUrl, downloadLogger) + downloadLogger.info("Extraction complete: $extractedPath") + extractedPath + } else { + downloadedPath + } + + // 12. Update model in C++ registry with local path + val updatedModelInfo = modelInfo.copy(localPath = finalModelPath) + addToModelCache(updatedModelInfo) + CppBridgeModelRegistry.updateDownloadStatus(modelId, finalModelPath) + + downloadLogger.info("Model ready at: $finalModelPath") + + // 13. Emit completion events + val downloadDurationMs = System.currentTimeMillis() - downloadStartTime + CppBridgeEvents.emitDownloadCompleted(modelId, downloadDurationMs.toDouble(), result.fileSize) + + trySend( + DownloadProgress( + modelId = modelId, + progress = 1f, + bytesDownloaded = result.fileSize, + totalBytes = result.fileSize, + state = DownloadState.COMPLETED, + ), + ) + + // Close the channel to signal completion to collectors + close() + } catch (e: Exception) { + downloadLogger.error("Download error: ${e.message}") + CppBridgeEvents.emitDownloadFailed(modelId, e.message ?: "Unknown error") + // Close with exception so collectors receive the error + close(e) + } finally { + // Clean up listener + CppBridgeDownload.downloadListener = null + } + + awaitClose { + downloadLogger.debug("Download flow closed for: $modelId") + } + } + +/** + * Check if URL requires extraction (is an archive). + * Supports: .tar.gz, .tgz, .tar.bz2, .tbz2, .zip + */ +private fun requiresExtraction(url: String): Boolean { + val lowercaseUrl = url.lowercase() + return lowercaseUrl.endsWith(".tar.gz") || + lowercaseUrl.endsWith(".tgz") || + lowercaseUrl.endsWith(".tar.bz2") || + lowercaseUrl.endsWith(".tbz2") || + lowercaseUrl.endsWith(".zip") +} + +/** + * Extract an archive to the model directory. + * + * Supports: + * - .tar.gz / .tgz → Uses Apache Commons Compress + * - .tar.bz2 / .tbz2 → Uses Apache Commons Compress + * - .zip → Uses java.util.zip + * + * Archives typically contain a root folder (e.g., sherpa-onnx-whisper-tiny.en/) + * so we extract to the parent directory and the archive structure creates the model folder. + * + * @param archiveFile The downloaded archive file (may not have extension in filename) + * @param modelId The model ID + * @param modelType The model type + * @param originalUrl The original download URL (used to determine archive type) + * @param logger Logger for debug output + */ +@Suppress("UNUSED_PARAMETER") +private suspend fun extractArchive( + archiveFile: File, + modelId: String, + modelType: Int, // Reserved for future type-specific extraction logic + originalUrl: String, + logger: SDKLogger, +): String = + withContext(Dispatchers.IO) { + // Extract to parent directory - the archive typically contains a root folder + // e.g., archive contains: sherpa-onnx-whisper-tiny.en/tiny.en-decoder.onnx + // So we extract to /models/stt/ and get /models/stt/sherpa-onnx-whisper-tiny.en/ + val parentDir = archiveFile.parentFile + if (parentDir == null || !parentDir.exists()) { + throw SDKError.download("Cannot determine extraction directory for: ${archiveFile.absolutePath}") + } + + logger.info("Extracting to parent: ${parentDir.absolutePath}") + logger.debug("Archive file: ${archiveFile.absolutePath}") + logger.debug("Original URL: $originalUrl") + + // Use the URL to determine archive type (file may be saved without extension) + val lowercaseUrl = originalUrl.lowercase() + + // IMPORTANT: The archive file name might conflict with the folder inside the archive + // (e.g., file "sherpa-onnx-whisper-tiny.en" and archive contains folder "sherpa-onnx-whisper-tiny.en/") + // We need to rename/move the archive before extracting to avoid ENOTDIR errors + val tempArchiveFile = File(parentDir, "${archiveFile.name}.tmp_archive") + try { + if (!archiveFile.renameTo(tempArchiveFile)) { + // If rename fails, copy and delete + archiveFile.copyTo(tempArchiveFile, overwrite = true) + archiveFile.delete() + } + logger.debug("Moved archive to temp: ${tempArchiveFile.absolutePath}") + } catch (e: Exception) { + logger.error("Failed to move archive to temp location: ${e.message}") + throw SDKError.download("Failed to prepare archive for extraction: ${e.message}") + } + + try { + when { + lowercaseUrl.endsWith(".tar.gz") || lowercaseUrl.endsWith(".tgz") -> { + logger.info("Extracting tar.gz archive...") + extractTarGz(tempArchiveFile, parentDir, logger) + } + lowercaseUrl.endsWith(".tar.bz2") || lowercaseUrl.endsWith(".tbz2") -> { + logger.info("Extracting tar.bz2 archive...") + extractTarBz2(tempArchiveFile, parentDir, logger) + } + lowercaseUrl.endsWith(".zip") -> { + logger.info("Extracting zip archive...") + extractZip(tempArchiveFile, parentDir, logger) + } + else -> { + logger.warn("Unknown archive type for URL: $originalUrl") + // Restore the original file + tempArchiveFile.renameTo(archiveFile) + return@withContext archiveFile.absolutePath + } + } + } finally { + // Always clean up the temp archive file + try { + if (tempArchiveFile.exists()) { + tempArchiveFile.delete() + logger.debug("Cleaned up temp archive: ${tempArchiveFile.absolutePath}") + } + } catch (e: Exception) { + logger.warn("Failed to clean up temp archive: ${e.message}") + } + } + + // Find the extracted model directory + // The archive should have created a folder with the model ID name + val expectedModelDir = File(parentDir, modelId) + val finalPath = + if (expectedModelDir.exists() && expectedModelDir.isDirectory) { + expectedModelDir.absolutePath + } else { + // Fallback: look for any new directory created + parentDir + .listFiles() + ?.firstOrNull { + it.isDirectory && it.name.contains(modelId.substringBefore("-")) + }?.absolutePath ?: parentDir.absolutePath + } + + logger.info("Model extracted to: $finalPath") + finalPath + } + +/** + * Extract a .tar.gz archive. + */ +private fun extractTarGz(archiveFile: File, destDir: File, logger: SDKLogger) { + logger.debug("Extracting tar.gz: ${archiveFile.absolutePath}") + + FileInputStream(archiveFile).use { fis -> + BufferedInputStream(fis).use { bis -> + GzipCompressorInputStream(bis).use { gzis -> + TarArchiveInputStream(gzis).use { tais -> + var entry = tais.nextEntry + var fileCount = 0 + + while (entry != null) { + val destFile = File(destDir, entry.name) + + // Security check - prevent path traversal + if (!destFile.canonicalPath.startsWith(destDir.canonicalPath)) { + throw SecurityException("Tar entry outside destination: ${entry.name}") + } + + if (entry.isDirectory) { + destFile.mkdirs() + } else { + destFile.parentFile?.mkdirs() + FileOutputStream(destFile).use { fos -> + tais.copyTo(fos) + } + fileCount++ + } + + entry = tais.nextEntry + } + + logger.info("Extracted $fileCount files from tar.gz") + } + } + } + } +} + +/** + * Extract a .tar.bz2 archive. + */ +private fun extractTarBz2(archiveFile: File, destDir: File, logger: SDKLogger) { + logger.debug("Extracting tar.bz2: ${archiveFile.absolutePath}") + + FileInputStream(archiveFile).use { fis -> + BufferedInputStream(fis).use { bis -> + BZip2CompressorInputStream(bis).use { bzis -> + TarArchiveInputStream(bzis).use { tais -> + var entry = tais.nextEntry + var fileCount = 0 + + while (entry != null) { + val destFile = File(destDir, entry.name) + + // Security check - prevent path traversal + if (!destFile.canonicalPath.startsWith(destDir.canonicalPath)) { + throw SecurityException("Tar entry outside destination: ${entry.name}") + } + + if (entry.isDirectory) { + destFile.mkdirs() + } else { + destFile.parentFile?.mkdirs() + FileOutputStream(destFile).use { fos -> + tais.copyTo(fos) + } + fileCount++ + } + + entry = tais.nextEntry + } + + logger.info("Extracted $fileCount files from tar.bz2") + } + } + } + } +} + +/** + * Extract a .zip archive. + */ +private fun extractZip(archiveFile: File, destDir: File, logger: SDKLogger) { + logger.debug("Extracting zip: ${archiveFile.absolutePath}") + + ZipInputStream(FileInputStream(archiveFile)).use { zis -> + var entry = zis.nextEntry + var fileCount = 0 + + while (entry != null) { + val destFile = File(destDir, entry.name) + + // Security check - prevent path traversal + if (!destFile.canonicalPath.startsWith(destDir.canonicalPath)) { + throw SecurityException("Zip entry outside destination: ${entry.name}") + } + + if (entry.isDirectory) { + destFile.mkdirs() + } else { + destFile.parentFile?.mkdirs() + FileOutputStream(destFile).use { fos -> + zis.copyTo(fos) + } + fileCount++ + } + + zis.closeEntry() + entry = zis.nextEntry + } + + logger.info("Extracted $fileCount files from zip") + } +} + +actual suspend fun RunAnywhere.cancelDownload(modelId: String) { + if (!isInitialized) { + throw SDKError.notInitialized("SDK not initialized") + } + // Update C++ registry to mark download cancelled + CppBridgeModelRegistry.updateDownloadStatus(modelId, null) +} + +actual suspend fun RunAnywhere.isModelDownloaded(modelId: String): Boolean { + if (!isInitialized) { + throw SDKError.notInitialized("SDK not initialized") + } + val model = CppBridgeModelRegistry.get(modelId) ?: return false + return model.localPath != null && model.localPath.isNotEmpty() +} + +actual suspend fun RunAnywhere.deleteModel(modelId: String) { + if (!isInitialized) { + throw SDKError.notInitialized("SDK not initialized") + } + CppBridgeModelRegistry.remove(modelId) +} + +actual suspend fun RunAnywhere.deleteAllModels() { + if (!isInitialized) { + throw SDKError.notInitialized("SDK not initialized") + } + // Would need to parse and delete each - simplified +} + +actual suspend fun RunAnywhere.refreshModelRegistry() { + if (!isInitialized) { + throw SDKError.notInitialized("SDK not initialized") + } + // Trigger registry refresh via native call + // TODO: Implement via CppBridge +} + +actual suspend fun RunAnywhere.loadLLMModel(modelId: String) { + if (!isInitialized) { + throw SDKError.notInitialized("SDK not initialized") + } + + val model = + CppBridgeModelRegistry.get(modelId) + ?: throw SDKError.model("Model '$modelId' not found in registry") + + val localPath = + model.localPath + ?: throw SDKError.model("Model '$modelId' is not downloaded") + + // Pass modelPath, modelId, and modelName separately for correct telemetry + val result = CppBridgeLLM.loadModel(localPath, modelId, model.name) + if (result != 0) { + throw SDKError.llm("Failed to load LLM model '$modelId' (error code: $result)") + } +} + +actual suspend fun RunAnywhere.unloadLLMModel() { + if (!isInitialized) { + throw SDKError.notInitialized("SDK not initialized") + } + CppBridgeLLM.unload() +} + +actual suspend fun RunAnywhere.isLLMModelLoaded(): Boolean { + return CppBridgeLLM.isLoaded +} + +actual val RunAnywhere.currentLLMModelId: String? + get() = CppBridgeLLM.getLoadedModelId() + +actual suspend fun RunAnywhere.currentLLMModel(): ModelInfo? { + val modelId = CppBridgeLLM.getLoadedModelId() ?: return null + // Look up in registered models first + val registeredModel = getRegisteredModels().find { it.id == modelId } + if (registeredModel != null) return registeredModel + // Fall back to bridge models + return getAllBridgeModels().find { it.modelId == modelId }?.toPublicModelInfo() +} + +actual suspend fun RunAnywhere.currentSTTModel(): ModelInfo? { + val modelId = CppBridgeSTT.getLoadedModelId() ?: return null + // Look up in registered models first + val registeredModel = getRegisteredModels().find { it.id == modelId } + if (registeredModel != null) return registeredModel + // Fall back to bridge models + return getAllBridgeModels().find { it.modelId == modelId }?.toPublicModelInfo() +} + +actual suspend fun RunAnywhere.loadSTTModel(modelId: String) { + if (!isInitialized) { + throw SDKError.notInitialized("SDK not initialized") + } + + val model = + CppBridgeModelRegistry.get(modelId) + ?: throw SDKError.model("Model '$modelId' not found in registry") + + val localPath = + model.localPath + ?: throw SDKError.model("Model '$modelId' is not downloaded") + + // Pass modelPath, modelId, and modelName separately for correct telemetry + val result = CppBridgeSTT.loadModel(localPath, modelId, model.name) + if (result != 0) { + throw SDKError.stt("Failed to load STT model '$modelId' (error code: $result)") + } +} + +// ============================================================================ +// MODEL ASSIGNMENTS API +// ============================================================================ + +/** + * Fetch model assignments for the current device from the backend. + * + * This method fetches models assigned to this device based on device type and platform. + * Results are cached and saved to the model registry automatically. + * + * Note: Model assignments are automatically fetched during SDK initialization + * when services are initialized (Phase 2). This method allows manual refresh. + * + * @param forceRefresh If true, bypass cache and fetch fresh data from backend + * @return List of ModelInfo objects assigned to this device + */ +actual suspend fun RunAnywhere.fetchModelAssignments(forceRefresh: Boolean): List = + withContext(Dispatchers.IO) { + if (!isInitialized) { + throw SDKError.notInitialized("SDK not initialized") + } + + ensureServicesReady() + + modelsLogger.info("Fetching model assignments (forceRefresh=$forceRefresh)...") + + try { + val jsonResult = com.runanywhere.sdk.foundation.bridge.extensions + .CppBridgeModelAssignment.fetchModelAssignments(forceRefresh) + + // Parse JSON result to ModelInfo list + val models = parseModelAssignmentsJson(jsonResult) + modelsLogger.info("Fetched ${models.size} model assignments") + models + } catch (e: Exception) { + modelsLogger.error("Failed to fetch model assignments: ${e.message}") + emptyList() + } + } + +/** + * Parse model assignments JSON to list of ModelInfo. + */ +private fun parseModelAssignmentsJson(json: String): List { + if (json.isEmpty() || json == "[]") { + return emptyList() + } + + val models = mutableListOf() + + // Simple JSON parsing (without external library dependency) + // Expected format: [{"id":"...", "name":"...", ...}, ...] + try { + // Remove array brackets and split by },{ + val trimmed = json.trim().removePrefix("[").removeSuffix("]") + if (trimmed.isEmpty()) return models + + val objects = trimmed.split("},\\s*\\{".toRegex()) + + for ((index, obj) in objects.withIndex()) { + try { + // Add back braces + var jsonObj = obj + if (!jsonObj.startsWith("{")) jsonObj = "{$jsonObj" + if (!jsonObj.endsWith("}")) jsonObj = "$jsonObj}" + + // Extract fields (simple approach) + val id = extractJsonString(jsonObj, "id") ?: continue + val name = extractJsonString(jsonObj, "name") ?: id + val categoryInt = extractJsonInt(jsonObj, "category") ?: 0 + val formatInt = extractJsonInt(jsonObj, "format") ?: 0 + val frameworkInt = extractJsonInt(jsonObj, "framework") ?: 0 + val downloadUrl = extractJsonString(jsonObj, "downloadUrl") + val downloadSize = extractJsonLong(jsonObj, "downloadSize") ?: 0L + val contextLength = extractJsonInt(jsonObj, "contextLength") ?: 0 + val supportsThinking = extractJsonBool(jsonObj, "supportsThinking") ?: false + + val modelInfo = ModelInfo( + id = id, + name = name, + category = when (categoryInt) { + 0 -> ModelCategory.LANGUAGE + 1 -> ModelCategory.SPEECH_RECOGNITION + 2 -> ModelCategory.SPEECH_SYNTHESIS + 3 -> ModelCategory.AUDIO + else -> ModelCategory.LANGUAGE + }, + format = when (formatInt) { + 1 -> ModelFormat.GGUF + 2 -> ModelFormat.ONNX + 3 -> ModelFormat.ORT + else -> ModelFormat.UNKNOWN + }, + framework = when (frameworkInt) { + 1 -> InferenceFramework.LLAMA_CPP + 2 -> InferenceFramework.ONNX + 3 -> InferenceFramework.FOUNDATION_MODELS + 4 -> InferenceFramework.SYSTEM_TTS + else -> InferenceFramework.UNKNOWN + }, + downloadURL = downloadUrl, + localPath = null, + downloadSize = if (downloadSize > 0) downloadSize else null, + contextLength = if (contextLength > 0) contextLength else null, + supportsThinking = supportsThinking, + description = null, + ) + models.add(modelInfo) + } catch (e: Exception) { + modelsLogger.warn("Failed to parse model at index $index: ${e.message}") + } + } + } catch (e: Exception) { + modelsLogger.error("Failed to parse model assignments JSON: ${e.message}") + } + + return models +} + +private fun extractJsonString(json: String, key: String): String? { + val pattern = "\"$key\"\\s*:\\s*\"([^\"]*)\"" + val regex = pattern.toRegex() + return regex.find(json)?.groupValues?.get(1) +} + +private fun extractJsonInt(json: String, key: String): Int? { + val pattern = "\"$key\"\\s*:\\s*(\\d+)" + val regex = pattern.toRegex() + return regex.find(json)?.groupValues?.get(1)?.toIntOrNull() +} + +private fun extractJsonLong(json: String, key: String): Long? { + val pattern = "\"$key\"\\s*:\\s*(\\d+)" + val regex = pattern.toRegex() + return regex.find(json)?.groupValues?.get(1)?.toLongOrNull() +} + +private fun extractJsonBool(json: String, key: String): Boolean? { + val pattern = "\"$key\"\\s*:\\s*(true|false)" + val regex = pattern.toRegex() + return regex.find(json)?.groupValues?.get(1)?.let { it == "true" } +} diff --git a/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/public/extensions/RunAnywhere+STT.jvmAndroid.kt b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/public/extensions/RunAnywhere+STT.jvmAndroid.kt new file mode 100644 index 000000000..28c689426 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/public/extensions/RunAnywhere+STT.jvmAndroid.kt @@ -0,0 +1,148 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * JVM/Android actual implementations for Speech-to-Text operations. + */ + +package com.runanywhere.sdk.public.extensions + +import com.runanywhere.sdk.foundation.SDKLogger +import com.runanywhere.sdk.foundation.bridge.extensions.CppBridgeSTT +import com.runanywhere.sdk.foundation.errors.SDKError +import com.runanywhere.sdk.public.RunAnywhere +import com.runanywhere.sdk.public.extensions.STT.STTOptions +import com.runanywhere.sdk.public.extensions.STT.STTOutput +import com.runanywhere.sdk.public.extensions.STT.STTTranscriptionResult +import com.runanywhere.sdk.public.extensions.STT.TranscriptionMetadata + +private val sttLogger = SDKLogger.stt + +actual suspend fun RunAnywhere.transcribe(audioData: ByteArray): String { + val result = transcribeWithOptions(audioData, STTOptions()) + return result.text +} + +actual suspend fun RunAnywhere.unloadSTTModel() { + if (!isInitialized) { + throw SDKError.notInitialized("SDK not initialized") + } + CppBridgeSTT.unload() +} + +actual suspend fun RunAnywhere.isSTTModelLoaded(): Boolean { + return CppBridgeSTT.isLoaded +} + +actual val RunAnywhere.currentSTTModelId: String? + get() = CppBridgeSTT.getLoadedModelId() + +actual val RunAnywhere.isSTTModelLoadedSync: Boolean + get() = CppBridgeSTT.isLoaded + +actual suspend fun RunAnywhere.transcribeWithOptions( + audioData: ByteArray, + options: STTOptions, +): STTOutput { + if (!isInitialized) { + throw SDKError.notInitialized("SDK not initialized") + } + + val audioLengthSec = estimateAudioLength(audioData.size) + sttLogger.debug("Transcribing audio: ${audioData.size} bytes (${String.format("%.2f", audioLengthSec)}s)") + + // Convert to CppBridgeSTT config + val config = + CppBridgeSTT.TranscriptionConfig( + language = options.language ?: CppBridgeSTT.Language.AUTO, + sampleRate = options.sampleRate, + ) + + val result = CppBridgeSTT.transcribe(audioData, config) + sttLogger.info("Transcription complete: ${result.text.take(50)}${if (result.text.length > 50) "..." else ""}") + + val metadata = + TranscriptionMetadata( + modelId = CppBridgeSTT.getLoadedModelId() ?: "unknown", + processingTime = result.processingTimeMs / 1000.0, + audioLength = audioLengthSec, + ) + + return STTOutput( + text = result.text, + confidence = result.confidence, + wordTimestamps = null, + detectedLanguage = result.language, + alternatives = null, + metadata = metadata, + ) +} + +actual suspend fun RunAnywhere.transcribeStream( + audioData: ByteArray, + options: STTOptions, + onPartialResult: (STTTranscriptionResult) -> Unit, +): STTOutput { + if (!isInitialized) { + throw SDKError.notInitialized("SDK not initialized") + } + + val audioLengthSec = estimateAudioLength(audioData.size) + + val config = + CppBridgeSTT.TranscriptionConfig( + language = options.language ?: CppBridgeSTT.Language.AUTO, + sampleRate = options.sampleRate, + ) + + val result = + CppBridgeSTT.transcribeStream(audioData, config) { partialText, isFinal -> + onPartialResult(STTTranscriptionResult(transcript = partialText)) + true // Continue processing + } + + val metadata = + TranscriptionMetadata( + modelId = CppBridgeSTT.getLoadedModelId() ?: "unknown", + processingTime = result.processingTimeMs / 1000.0, + audioLength = audioLengthSec, + ) + + return STTOutput( + text = result.text, + confidence = result.confidence, + wordTimestamps = null, + detectedLanguage = result.language, + alternatives = null, + metadata = metadata, + ) +} + +actual suspend fun RunAnywhere.processStreamingAudio(samples: FloatArray) { + if (!isInitialized) { + throw SDKError.notInitialized("SDK not initialized") + } + + val config = CppBridgeSTT.TranscriptionConfig() + val audioData = samples.toByteArray() + CppBridgeSTT.transcribe(audioData, config) +} + +actual suspend fun RunAnywhere.stopStreamingTranscription() { + CppBridgeSTT.cancel() +} + +// Private helper +private fun estimateAudioLength(dataSize: Int): Double { + val bytesPerSample = 2 // 16-bit + val sampleRate = 16000.0 + val samples = dataSize.toDouble() / bytesPerSample.toDouble() + return samples / sampleRate +} + +private fun FloatArray.toByteArray(): ByteArray { + val buffer = java.nio.ByteBuffer.allocate(size * 4) + buffer.order(java.nio.ByteOrder.LITTLE_ENDIAN) + buffer.asFloatBuffer().put(this) + return buffer.array() +} diff --git a/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/public/extensions/RunAnywhere+Storage.jvmAndroid.kt b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/public/extensions/RunAnywhere+Storage.jvmAndroid.kt new file mode 100644 index 000000000..17e2bdb58 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/public/extensions/RunAnywhere+Storage.jvmAndroid.kt @@ -0,0 +1,256 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * JVM/Android actual implementations for storage operations. + */ + +package com.runanywhere.sdk.public.extensions + +import com.runanywhere.sdk.core.types.InferenceFramework +import com.runanywhere.sdk.foundation.SDKLogger +import com.runanywhere.sdk.foundation.bridge.extensions.CppBridgeModelPaths +import com.runanywhere.sdk.foundation.bridge.extensions.CppBridgeModelRegistry +import com.runanywhere.sdk.foundation.bridge.extensions.CppBridgeStorage +import com.runanywhere.sdk.foundation.errors.SDKError +import com.runanywhere.sdk.public.RunAnywhere +import com.runanywhere.sdk.public.extensions.Models.ModelArtifactType +import com.runanywhere.sdk.public.extensions.Models.ModelCategory +import com.runanywhere.sdk.public.extensions.Models.ModelFormat +import com.runanywhere.sdk.public.extensions.Models.ModelInfo +import com.runanywhere.sdk.public.extensions.Storage.AppStorageInfo +import com.runanywhere.sdk.public.extensions.Storage.DeviceStorageInfo +import com.runanywhere.sdk.public.extensions.Storage.ModelStorageMetrics +import com.runanywhere.sdk.public.extensions.Storage.StorageAvailability +import com.runanywhere.sdk.public.extensions.Storage.StorageInfo +import java.io.File + +private val storageLogger = SDKLogger.shared + +// Model storage quota in bytes (default 10GB) +@Volatile +private var maxModelStorageBytes: Long = 10L * 1024 * 1024 * 1024 + +actual suspend fun RunAnywhere.storageInfo(): StorageInfo { + if (!isInitialized) { + throw SDKError.notInitialized("SDK not initialized") + } + + val baseDir = File(CppBridgeModelPaths.getBaseDirectory()) + val cacheDir = File(baseDir, "cache") + val modelsDir = File(baseDir, "models") + val appSupportDir = File(baseDir, "data") + + // Calculate directory sizes + val cacheSize = calculateDirectorySize(cacheDir) + val modelsSize = calculateDirectorySize(modelsDir) + val appSupportSize = calculateDirectorySize(appSupportDir) + + val appStorage = + AppStorageInfo( + documentsSize = modelsSize, + cacheSize = cacheSize, + appSupportSize = appSupportSize, + totalSize = cacheSize + modelsSize + appSupportSize, + ) + + // Get device storage info + val totalSpace = baseDir.totalSpace + val freeSpace = baseDir.freeSpace + val usedSpace = totalSpace - freeSpace + + val deviceStorage = + DeviceStorageInfo( + totalSpace = totalSpace, + freeSpace = freeSpace, + usedSpace = usedSpace, + ) + + // Get downloaded models from C++ registry and convert to storage metrics + val downloadedModels = CppBridgeModelRegistry.getDownloaded() + val modelMetrics = + downloadedModels.mapNotNull { registryModel -> + convertToModelStorageMetrics(registryModel) + } + + return StorageInfo( + appStorage = appStorage, + deviceStorage = deviceStorage, + models = modelMetrics, + ) +} + +actual suspend fun RunAnywhere.checkStorageAvailability(requiredBytes: Long): StorageAvailability { + if (!isInitialized) { + throw SDKError.notInitialized("SDK not initialized") + } + + val baseDir = File(CppBridgeModelPaths.getBaseDirectory()) + val availableSpace = baseDir.freeSpace + val isAvailable = availableSpace >= requiredBytes + + // Check if we're getting low on space (less than 1GB after this operation) + val hasWarning = isAvailable && (availableSpace - requiredBytes) < 1024L * 1024 * 1024 + + val recommendation = + when { + !isAvailable -> "Not enough storage space. Required: ${formatBytes(requiredBytes)}, Available: ${formatBytes(availableSpace)}" + hasWarning -> "Storage is running low. Consider clearing cache or removing unused models." + else -> null + } + + return StorageAvailability( + isAvailable = isAvailable, + requiredSpace = requiredBytes, + availableSpace = availableSpace, + hasWarning = hasWarning, + recommendation = recommendation, + ) +} + +actual suspend fun RunAnywhere.cacheSize(): Long { + if (!isInitialized) { + throw SDKError.notInitialized("SDK not initialized") + } + + val cacheDir = File(CppBridgeModelPaths.getBaseDirectory(), "cache") + return calculateDirectorySize(cacheDir) +} + +actual suspend fun RunAnywhere.clearCache() { + if (!isInitialized) { + throw SDKError.notInitialized("SDK not initialized") + } + + storageLogger.info("Clearing cache...") + + // Clear the storage cache namespace + CppBridgeStorage.clear(CppBridgeStorage.StorageNamespace.INFERENCE_CACHE, CppBridgeStorage.StorageType.CACHE) + + // Also clear the file cache directory + val cacheDir = File(CppBridgeModelPaths.getBaseDirectory(), "cache") + if (cacheDir.exists()) { + cacheDir.deleteRecursively() + cacheDir.mkdirs() + } + + storageLogger.info("Cache cleared") +} + +actual suspend fun RunAnywhere.setMaxModelStorage(maxBytes: Long) { + if (!isInitialized) { + throw SDKError.notInitialized("SDK not initialized") + } + + maxModelStorageBytes = maxBytes + CppBridgeStorage.setQuota(CppBridgeStorage.StorageNamespace.MODELS, maxBytes) +} + +actual suspend fun RunAnywhere.modelStorageUsed(): Long { + if (!isInitialized) { + throw SDKError.notInitialized("SDK not initialized") + } + + val modelsDir = File(CppBridgeModelPaths.getBaseDirectory(), "models") + return calculateDirectorySize(modelsDir) +} + +// Helper function to calculate directory size recursively +private fun calculateDirectorySize(directory: File): Long { + if (!directory.exists()) return 0L + if (directory.isFile) return directory.length() + + var size = 0L + directory.listFiles()?.forEach { file -> + size += + if (file.isDirectory) { + calculateDirectorySize(file) + } else { + file.length() + } + } + return size +} + +// Helper function to format bytes as human-readable string +private fun formatBytes(bytes: Long): String { + if (bytes < 1024) return "$bytes B" + val kb = bytes / 1024.0 + if (kb < 1024) return "%.1f KB".format(kb) + val mb = kb / 1024.0 + if (mb < 1024) return "%.1f MB".format(mb) + val gb = mb / 1024.0 + return "%.2f GB".format(gb) +} + +/** + * Convert a CppBridgeModelRegistry.ModelInfo to ModelStorageMetrics. + * Calculates actual size on disk from the model's local path. + */ +private fun convertToModelStorageMetrics( + registryModel: CppBridgeModelRegistry.ModelInfo, +): ModelStorageMetrics? { + val localPath = registryModel.localPath ?: return null + + // Calculate size on disk + val modelFile = File(localPath) + val sizeOnDisk = calculateDirectorySize(modelFile) + + // Convert framework int to InferenceFramework enum + val framework = + when (registryModel.framework) { + CppBridgeModelRegistry.Framework.ONNX -> InferenceFramework.ONNX + CppBridgeModelRegistry.Framework.LLAMACPP -> InferenceFramework.LLAMA_CPP + CppBridgeModelRegistry.Framework.FOUNDATION_MODELS -> InferenceFramework.FOUNDATION_MODELS + CppBridgeModelRegistry.Framework.SYSTEM_TTS -> InferenceFramework.SYSTEM_TTS + CppBridgeModelRegistry.Framework.FLUID_AUDIO -> InferenceFramework.FLUID_AUDIO + CppBridgeModelRegistry.Framework.BUILTIN -> InferenceFramework.BUILT_IN + CppBridgeModelRegistry.Framework.NONE -> InferenceFramework.NONE + else -> InferenceFramework.UNKNOWN + } + + // Convert category int to ModelCategory enum + val category = + when (registryModel.category) { + CppBridgeModelRegistry.ModelCategory.LANGUAGE -> ModelCategory.LANGUAGE + CppBridgeModelRegistry.ModelCategory.SPEECH_RECOGNITION -> ModelCategory.SPEECH_RECOGNITION + CppBridgeModelRegistry.ModelCategory.SPEECH_SYNTHESIS -> ModelCategory.SPEECH_SYNTHESIS + CppBridgeModelRegistry.ModelCategory.AUDIO -> ModelCategory.AUDIO + CppBridgeModelRegistry.ModelCategory.VISION -> ModelCategory.VISION + CppBridgeModelRegistry.ModelCategory.IMAGE_GENERATION -> ModelCategory.IMAGE_GENERATION + CppBridgeModelRegistry.ModelCategory.MULTIMODAL -> ModelCategory.MULTIMODAL + else -> ModelCategory.LANGUAGE + } + + // Convert format int to ModelFormat enum + val format = + when (registryModel.format) { + CppBridgeModelRegistry.ModelFormat.GGUF -> ModelFormat.GGUF + CppBridgeModelRegistry.ModelFormat.ONNX -> ModelFormat.ONNX + CppBridgeModelRegistry.ModelFormat.ORT -> ModelFormat.ORT + CppBridgeModelRegistry.ModelFormat.BIN -> ModelFormat.BIN + else -> ModelFormat.UNKNOWN + } + + // Create public ModelInfo from registry model + val modelInfo = + ModelInfo( + id = registryModel.modelId, + name = registryModel.name, + category = category, + format = format, + downloadURL = registryModel.downloadUrl, + localPath = localPath, + artifactType = ModelArtifactType.SingleFile(), + downloadSize = registryModel.downloadSize.takeIf { it > 0 }, + framework = framework, + contextLength = registryModel.contextLength.takeIf { it > 0 }, + supportsThinking = registryModel.supportsThinking, + description = registryModel.description, + ) + + return ModelStorageMetrics( + model = modelInfo, + sizeOnDisk = sizeOnDisk, + ) +} diff --git a/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/public/extensions/RunAnywhere+TTS.jvmAndroid.kt b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/public/extensions/RunAnywhere+TTS.jvmAndroid.kt new file mode 100644 index 000000000..129259db2 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/public/extensions/RunAnywhere+TTS.jvmAndroid.kt @@ -0,0 +1,187 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * JVM/Android actual implementations for Text-to-Speech operations. + */ + +package com.runanywhere.sdk.public.extensions + +import com.runanywhere.sdk.features.tts.TtsAudioPlayback +import com.runanywhere.sdk.foundation.SDKLogger +import com.runanywhere.sdk.foundation.bridge.extensions.CppBridgeModelRegistry +import com.runanywhere.sdk.foundation.bridge.extensions.CppBridgeTTS +import com.runanywhere.sdk.foundation.errors.SDKError +import com.runanywhere.sdk.public.RunAnywhere +import com.runanywhere.sdk.public.extensions.TTS.TTSOptions +import com.runanywhere.sdk.public.extensions.TTS.TTSOutput +import com.runanywhere.sdk.public.extensions.TTS.TTSSpeakResult +import com.runanywhere.sdk.public.extensions.TTS.TTSSynthesisMetadata + +private val ttsLogger = SDKLogger.tts +private val ttsAudioPlayback = TtsAudioPlayback + +actual suspend fun RunAnywhere.loadTTSVoice(voiceId: String) { + if (!isInitialized) { + throw SDKError.notInitialized("SDK not initialized") + } + + ttsLogger.debug("Loading TTS voice: $voiceId") + + val modelInfo = + CppBridgeModelRegistry.get(voiceId) + ?: throw SDKError.tts("Voice '$voiceId' not found in registry") + + val localPath = + modelInfo.localPath + ?: throw SDKError.tts("Voice '$voiceId' is not downloaded") + + // Pass modelPath, modelId, and modelName separately for correct telemetry + val result = CppBridgeTTS.loadModel(localPath, voiceId, modelInfo.name) + if (result != 0) { + ttsLogger.error("Failed to load TTS voice '$voiceId' (error code: $result)") + throw SDKError.tts("Failed to load TTS voice '$voiceId' (error code: $result)") + } + ttsLogger.info("TTS voice loaded: $voiceId") +} + +actual suspend fun RunAnywhere.unloadTTSVoice() { + if (!isInitialized) { + throw SDKError.notInitialized("SDK not initialized") + } + CppBridgeTTS.unload() +} + +actual suspend fun RunAnywhere.isTTSVoiceLoaded(): Boolean { + return CppBridgeTTS.isLoaded +} + +actual val RunAnywhere.currentTTSVoiceId: String? + get() = CppBridgeTTS.getLoadedModelId() + +actual val RunAnywhere.isTTSVoiceLoadedSync: Boolean + get() = CppBridgeTTS.isLoaded + +actual suspend fun RunAnywhere.availableTTSVoices(): List { + // Get available voices from TTS component + return CppBridgeTTS.getAvailableVoices().map { it.voiceId } +} + +actual suspend fun RunAnywhere.synthesize( + text: String, + options: TTSOptions, +): TTSOutput { + if (!isInitialized) { + throw SDKError.notInitialized("SDK not initialized") + } + + val voiceId = CppBridgeTTS.getLoadedModelId() ?: "unknown" + ttsLogger.debug("Synthesizing text: ${text.take(50)}${if (text.length > 50) "..." else ""} (voice: $voiceId)") + + val config = + CppBridgeTTS.SynthesisConfig( + speed = options.rate, + pitch = options.pitch, + volume = options.volume, + sampleRate = options.sampleRate, + language = options.language ?: CppBridgeTTS.Language.ENGLISH, + ) + + val result = CppBridgeTTS.synthesize(text, config) + ttsLogger.info("Synthesis complete: ${result.durationMs}ms audio") + + val metadata = + TTSSynthesisMetadata( + voice = voiceId, + language = config.language, + processingTime = result.processingTimeMs / 1000.0, + characterCount = text.length, + ) + + return TTSOutput( + audioData = result.audioData, + format = options.audioFormat, + duration = result.durationMs / 1000.0, + phonemeTimestamps = null, + metadata = metadata, + ) +} + +actual suspend fun RunAnywhere.synthesizeStream( + text: String, + options: TTSOptions, + onAudioChunk: (ByteArray) -> Unit, +): TTSOutput { + if (!isInitialized) { + throw SDKError.notInitialized("SDK not initialized") + } + + val voiceId = CppBridgeTTS.getLoadedModelId() ?: "unknown" + + val config = + CppBridgeTTS.SynthesisConfig( + speed = options.rate, + pitch = options.pitch, + volume = options.volume, + sampleRate = options.sampleRate, + language = options.language ?: CppBridgeTTS.Language.ENGLISH, + ) + + val result = + CppBridgeTTS.synthesizeStream(text, config) { audioData, isFinal -> + onAudioChunk(audioData) + true // Continue processing + } + + val metadata = + TTSSynthesisMetadata( + voice = voiceId, + language = config.language, + processingTime = result.processingTimeMs / 1000.0, + characterCount = text.length, + ) + + return TTSOutput( + audioData = result.audioData, + format = options.audioFormat, + duration = result.durationMs / 1000.0, + phonemeTimestamps = null, + metadata = metadata, + ) +} + +actual suspend fun RunAnywhere.stopSynthesis() { + CppBridgeTTS.cancel() +} + +actual suspend fun RunAnywhere.speak( + text: String, + options: TTSOptions, +): TTSSpeakResult { + if (!isInitialized) { + throw SDKError.notInitialized("SDK not initialized") + } + + val output = synthesize(text, options) + + if (output.audioData.isNotEmpty()) { + try { + ttsAudioPlayback.play(output.audioData) + ttsLogger.debug("Audio playback completed") + } catch (e: Exception) { + ttsLogger.error("Audio playback failed: ${e.message}", throwable = e) + throw if (e is SDKError) e else SDKError.tts("Failed to play audio: ${e.message}") + } + } + + return TTSSpeakResult.from(output) +} + +actual suspend fun RunAnywhere.isSpeaking(): Boolean { + return ttsAudioPlayback.isPlaying +} + +actual suspend fun RunAnywhere.stopSpeaking() { + ttsAudioPlayback.stop() + stopSynthesis() +} diff --git a/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/public/extensions/RunAnywhere+TextGeneration.jvmAndroid.kt b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/public/extensions/RunAnywhere+TextGeneration.jvmAndroid.kt new file mode 100644 index 000000000..1eb6d6545 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/public/extensions/RunAnywhere+TextGeneration.jvmAndroid.kt @@ -0,0 +1,200 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * JVM/Android actual implementations for text generation (LLM) operations. + */ + +package com.runanywhere.sdk.public.extensions + +import com.runanywhere.sdk.foundation.SDKLogger +import com.runanywhere.sdk.foundation.bridge.extensions.CppBridgeLLM +import com.runanywhere.sdk.foundation.errors.SDKError +import com.runanywhere.sdk.public.RunAnywhere +import com.runanywhere.sdk.public.extensions.LLM.LLMGenerationOptions +import com.runanywhere.sdk.public.extensions.LLM.LLMGenerationResult +import com.runanywhere.sdk.public.extensions.LLM.LLMStreamingResult +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch + +private val llmLogger = SDKLogger.llm + +actual suspend fun RunAnywhere.chat(prompt: String): String { + val result = generate(prompt, null) + return result.text +} + +actual suspend fun RunAnywhere.generate( + prompt: String, + options: LLMGenerationOptions?, +): LLMGenerationResult { + if (!isInitialized) { + throw SDKError.notInitialized("SDK not initialized") + } + + ensureServicesReady() + + val opts = options ?: LLMGenerationOptions.DEFAULT + val startTime = System.currentTimeMillis() + llmLogger.debug("Generating response for prompt: ${prompt.take(50)}${if (prompt.length > 50) "..." else ""}") + + // Convert to CppBridgeLLM config + val config = + CppBridgeLLM.GenerationConfig( + maxTokens = opts.maxTokens, + temperature = opts.temperature, + topP = opts.topP, + ) + + // Call CppBridgeLLM to generate + val cppResult = CppBridgeLLM.generate(prompt, config) + + val endTime = System.currentTimeMillis() + val latencyMs = (endTime - startTime).toDouble() + llmLogger.info("Generation complete: ${cppResult.tokensGenerated} tokens in ${latencyMs.toLong()}ms (${String.format("%.1f", cppResult.tokensPerSecond)} tok/s)") + + return LLMGenerationResult( + text = cppResult.text, + thinkingContent = null, + inputTokens = cppResult.tokensEvaluated - cppResult.tokensGenerated, + tokensUsed = cppResult.tokensGenerated, + modelUsed = CppBridgeLLM.getLoadedModelId() ?: "unknown", + latencyMs = latencyMs, + framework = "llamacpp", + tokensPerSecond = cppResult.tokensPerSecond.toDouble(), + timeToFirstTokenMs = null, + thinkingTokens = null, + responseTokens = cppResult.tokensGenerated, + ) +} + +actual fun RunAnywhere.generateStream( + prompt: String, + options: LLMGenerationOptions?, +): Flow = + flow { + if (!isInitialized) { + throw SDKError.notInitialized("SDK not initialized") + } + + val opts = options ?: LLMGenerationOptions.DEFAULT + + val config = + CppBridgeLLM.GenerationConfig( + maxTokens = opts.maxTokens, + temperature = opts.temperature, + topP = opts.topP, + ) + + // Use a channel to bridge callback to flow + val channel = Channel(Channel.UNLIMITED) + + // Start generation in a separate coroutine + val scope = CoroutineScope(Dispatchers.IO) + scope.launch { + try { + CppBridgeLLM.generateStream(prompt, config) { token -> + channel.trySend(token) + true // Continue generation + } + } finally { + channel.close() + } + } + + // Emit tokens from the channel + for (token in channel) { + emit(token) + } + } + +actual suspend fun RunAnywhere.generateStreamWithMetrics( + prompt: String, + options: LLMGenerationOptions?, +): LLMStreamingResult { + if (!isInitialized) { + throw SDKError.notInitialized("SDK not initialized") + } + + ensureServicesReady() + + val opts = options ?: LLMGenerationOptions.DEFAULT + val resultDeferred = CompletableDeferred() + val startTime = System.currentTimeMillis() + + var fullText = "" + var tokenCount = 0 + var firstTokenTime: Long? = null + + val config = + CppBridgeLLM.GenerationConfig( + maxTokens = opts.maxTokens, + temperature = opts.temperature, + topP = opts.topP, + ) + + // Use a channel to bridge callback to flow + val channel = Channel(Channel.UNLIMITED) + + // Start generation in a separate coroutine + val scope = CoroutineScope(Dispatchers.IO) + scope.launch { + try { + val cppResult = + CppBridgeLLM.generateStream(prompt, config) { token -> + if (firstTokenTime == null) { + firstTokenTime = System.currentTimeMillis() + } + fullText += token + tokenCount++ + channel.trySend(token) + true // Continue generation + } + + // Build final result after generation completes + val endTime = System.currentTimeMillis() + val latencyMs = (endTime - startTime).toDouble() + val timeToFirstTokenMs = firstTokenTime?.let { (it - startTime).toDouble() } + + val result = + LLMGenerationResult( + text = fullText, + tokensUsed = tokenCount, + modelUsed = CppBridgeLLM.getLoadedModelId() ?: "unknown", + latencyMs = latencyMs, + framework = "llamacpp", + tokensPerSecond = cppResult.tokensPerSecond.toDouble(), + timeToFirstTokenMs = timeToFirstTokenMs, + responseTokens = tokenCount, + ) + resultDeferred.complete(result) + } catch (e: Exception) { + resultDeferred.completeExceptionally(e) + } finally { + channel.close() + } + } + + val tokenStream = + flow { + for (token in channel) { + emit(token) + } + } + + return LLMStreamingResult( + stream = tokenStream, + result = scope.async { resultDeferred.await() }, + ) +} + +actual fun RunAnywhere.cancelGeneration() { + // Cancel any ongoing generation via CppBridge + CppBridgeLLM.cancel() +} diff --git a/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/public/extensions/RunAnywhere+VAD.jvmAndroid.kt b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/public/extensions/RunAnywhere+VAD.jvmAndroid.kt new file mode 100644 index 000000000..b6cf7875e --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/public/extensions/RunAnywhere+VAD.jvmAndroid.kt @@ -0,0 +1,104 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * JVM/Android actual implementations for Voice Activity Detection operations. + */ + +package com.runanywhere.sdk.public.extensions + +import com.runanywhere.sdk.foundation.SDKLogger +import com.runanywhere.sdk.foundation.bridge.extensions.CppBridgeVAD +import com.runanywhere.sdk.foundation.errors.SDKError +import com.runanywhere.sdk.public.RunAnywhere +import com.runanywhere.sdk.public.extensions.VAD.VADConfiguration +import com.runanywhere.sdk.public.extensions.VAD.VADResult +import com.runanywhere.sdk.public.extensions.VAD.VADStatistics +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val vadLogger = SDKLogger.vad + +actual suspend fun RunAnywhere.detectVoiceActivity(audioData: ByteArray): VADResult { + if (!isInitialized) { + throw SDKError.notInitialized("SDK not initialized") + } + + vadLogger.debug("Processing VAD frame: ${audioData.size} bytes") + + val config = CppBridgeVAD.DetectionConfig() + val frameResult = CppBridgeVAD.processFrame(audioData, config) + + if (frameResult.isSpeech) { + vadLogger.debug("Speech detected (confidence: ${String.format("%.2f", frameResult.probability)})") + } + + return VADResult( + isSpeech = frameResult.isSpeech, + confidence = frameResult.probability, + energyLevel = 0f, // Not directly available from frame result + statistics = null, + ) +} + +actual suspend fun RunAnywhere.configureVAD(configuration: VADConfiguration) { + if (!isInitialized) { + throw SDKError.notInitialized("SDK not initialized") + } + + // VAD configuration is passed per-call in the current architecture + // This is a no-op as configuration is applied during processing +} + +actual suspend fun RunAnywhere.getVADStatistics(): VADStatistics { + if (!isInitialized) { + throw SDKError.notInitialized("SDK not initialized") + } + + // Return default statistics as the current API doesn't have a dedicated statistics method + return VADStatistics( + current = 0f, + threshold = 0.5f, + ambient = 0f, + recentAvg = 0f, + recentMax = 0f, + ) +} + +actual fun RunAnywhere.streamVAD(audioSamples: Flow): Flow { + return audioSamples.map { samples -> + val audioData = samples.toByteArray() + val config = CppBridgeVAD.DetectionConfig(audioFormat = CppBridgeVAD.AudioFormat.PCM_FLOAT) + val frameResult = CppBridgeVAD.processFrame(audioData, config) + VADResult( + isSpeech = frameResult.isSpeech, + confidence = frameResult.probability, + energyLevel = 0f, + statistics = null, + ) + } +} + +actual suspend fun RunAnywhere.calibrateVAD(ambientAudioData: ByteArray) { + if (!isInitialized) { + throw SDKError.notInitialized("SDK not initialized") + } + // Process a frame with the ambient audio to calibrate + val config = CppBridgeVAD.DetectionConfig() + CppBridgeVAD.processFrame(ambientAudioData, config) +} + +actual suspend fun RunAnywhere.resetVAD() { + if (!isInitialized) { + throw SDKError.notInitialized("SDK not initialized") + } + CppBridgeVAD.reset() +} + +// Helper function to convert FloatArray to ByteArray +private fun FloatArray.toByteArray(): ByteArray { + val buffer = java.nio.ByteBuffer.allocate(size * 4) + buffer.order(java.nio.ByteOrder.LITTLE_ENDIAN) + buffer.asFloatBuffer().put(this) + return buffer.array() +} diff --git a/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/public/extensions/RunAnywhere+VoiceAgent.jvmAndroid.kt b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/public/extensions/RunAnywhere+VoiceAgent.jvmAndroid.kt new file mode 100644 index 000000000..9a6f1e2ed --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/public/extensions/RunAnywhere+VoiceAgent.jvmAndroid.kt @@ -0,0 +1,463 @@ +/* + * Copyright 2026 RunAnywhere SDK + * SPDX-License-Identifier: Apache-2.0 + * + * JVM/Android actual implementations for VoiceAgent operations. + * + * Note: This implementation orchestrates STT, LLM, and TTS at the Kotlin level + * since the native VoiceAgent C++ component is not yet implemented. + */ + +package com.runanywhere.sdk.public.extensions + +import com.runanywhere.sdk.foundation.SDKLogger +import com.runanywhere.sdk.foundation.bridge.extensions.CppBridgeLLM +import com.runanywhere.sdk.foundation.bridge.extensions.CppBridgeSTT +import com.runanywhere.sdk.foundation.bridge.extensions.CppBridgeTTS +import com.runanywhere.sdk.foundation.errors.SDKError +import com.runanywhere.sdk.public.RunAnywhere +import com.runanywhere.sdk.public.extensions.VoiceAgent.ComponentLoadState +import com.runanywhere.sdk.public.extensions.VoiceAgent.VoiceAgentComponentStates +import com.runanywhere.sdk.public.extensions.VoiceAgent.VoiceAgentConfiguration +import com.runanywhere.sdk.public.extensions.VoiceAgent.VoiceAgentResult +import com.runanywhere.sdk.public.extensions.VoiceAgent.VoiceSessionConfig +import com.runanywhere.sdk.public.extensions.VoiceAgent.VoiceSessionEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +import java.nio.ByteOrder +import kotlin.math.sqrt + +private val voiceAgentLogger = SDKLogger.voiceAgent + +// Session state managed at Kotlin level +@Volatile +private var voiceSessionActive: Boolean = false + +@Volatile +private var currentSystemPrompt: String? = null + +@Volatile +private var voiceAgentInitialized: Boolean = false + +/** + * Check if all required components (STT, LLM, TTS) are loaded. + * This is the Kotlin-level "readiness" check since native VoiceAgent isn't available. + */ +private fun areAllComponentsLoaded(): Boolean { + return CppBridgeSTT.isLoaded && CppBridgeLLM.isLoaded && CppBridgeTTS.isLoaded +} + +/** + * Get list of missing components for error messages. + */ +private fun getMissingComponents(): List { + val missing = mutableListOf() + if (!CppBridgeSTT.isLoaded) missing.add("STT") + if (!CppBridgeLLM.isLoaded) missing.add("LLM") + if (!CppBridgeTTS.isLoaded) missing.add("TTS") + return missing +} + +actual suspend fun RunAnywhere.configureVoiceAgent(configuration: VoiceAgentConfiguration) { + if (!isInitialized) { + throw SDKError.notInitialized("SDK not initialized") + } + + // Configuration stored - model IDs can be used to load models if needed + // The systemPrompt is set separately via setVoiceSystemPrompt() + // Actual initialization happens when all models are loaded + voiceAgentInitialized = false +} + +actual suspend fun RunAnywhere.voiceAgentComponentStates(): VoiceAgentComponentStates { + // Query individual component bridges directly for accurate state and model IDs + // This mirrors iOS's approach of querying CppBridge.STT.shared.isLoaded and currentModelId separately + + val sttLoaded = CppBridgeSTT.isLoaded + val sttModelId = CppBridgeSTT.getLoadedModelId() + + val llmLoaded = CppBridgeLLM.isLoaded + val llmModelId = CppBridgeLLM.getLoadedModelId() + + val ttsLoaded = CppBridgeTTS.isLoaded + val ttsVoiceId = CppBridgeTTS.getLoadedModelId() + + return VoiceAgentComponentStates( + stt = if (sttLoaded && sttModelId != null) ComponentLoadState.Loaded(sttModelId) else ComponentLoadState.NotLoaded, + llm = if (llmLoaded && llmModelId != null) ComponentLoadState.Loaded(llmModelId) else ComponentLoadState.NotLoaded, + tts = if (ttsLoaded && ttsVoiceId != null) ComponentLoadState.Loaded(ttsVoiceId) else ComponentLoadState.NotLoaded, + ) +} + +actual suspend fun RunAnywhere.isVoiceAgentReady(): Boolean { + // VoiceAgent is "ready" when all three components are loaded + // Since native VoiceAgent doesn't exist, we track readiness based on component states + return areAllComponentsLoaded() +} + +actual suspend fun RunAnywhere.initializeVoiceAgentWithLoadedModels() { + if (!isInitialized) { + throw SDKError.notInitialized("SDK not initialized") + } + + // Already initialized and all components loaded + if (voiceAgentInitialized && areAllComponentsLoaded()) { + voiceAgentLogger.debug("VoiceAgent already initialized") + return + } + + voiceAgentLogger.info("Initializing VoiceAgent with loaded models...") + + // Check if all component models are loaded + if (!areAllComponentsLoaded()) { + val missing = getMissingComponents() + voiceAgentLogger.error("Cannot initialize: Models not loaded: ${missing.joinToString(", ")}") + throw SDKError.voiceAgent("Cannot initialize: Models not loaded: ${missing.joinToString(", ")}") + } + + // All components are loaded - mark voice agent as initialized at Kotlin level + voiceAgentInitialized = true + voiceAgentLogger.info("VoiceAgent initialized successfully") +} + +actual suspend fun RunAnywhere.processVoice(audioData: ByteArray): VoiceAgentResult { + if (!isInitialized) { + throw SDKError.notInitialized("SDK not initialized") + } + + voiceAgentLogger.debug("Processing voice input: ${audioData.size} bytes") + + // Check if all components are loaded + if (!areAllComponentsLoaded()) { + val missing = getMissingComponents() + voiceAgentLogger.warning("Models not loaded: ${missing.joinToString(", ")}") + return VoiceAgentResult( + speechDetected = false, + transcription = null, + response = "Models not loaded: ${missing.joinToString(", ")}", + synthesizedAudio = null, + ) + } + + return try { + // Step 1: Transcribe audio using STT + voiceAgentLogger.debug("Step 1: Transcribing audio...") + val transcriptionResult = CppBridgeSTT.transcribe(audioData) + val transcriptionText = transcriptionResult.text + if (transcriptionText.isBlank()) { + voiceAgentLogger.debug("No speech detected in audio") + return VoiceAgentResult( + speechDetected = false, + transcription = null, + response = null, + synthesizedAudio = null, + ) + } + voiceAgentLogger.info("Transcription: ${transcriptionText.take(100)}${if (transcriptionText.length > 100) "..." else ""}") + + // Step 2: Generate response using LLM + voiceAgentLogger.debug("Step 2: Generating LLM response...") + val systemPrompt = currentSystemPrompt ?: "You are a helpful voice assistant." + val chatPrompt = "$systemPrompt\n\nUser: $transcriptionText\n\nAssistant:" + val generationResult = CppBridgeLLM.generate(chatPrompt) + val responseText = generationResult.text + voiceAgentLogger.info("Response: ${responseText.take(100)}${if (responseText.length > 100) "..." else ""}") + + // Step 3: Synthesize speech using TTS + voiceAgentLogger.debug("Step 3: Synthesizing TTS audio...") + val audioOutput = + if (responseText.isNotBlank()) { + val synthesisResult = CppBridgeTTS.synthesize(responseText) + voiceAgentLogger.debug("TTS synthesis complete: ${synthesisResult.audioData.size} bytes") + synthesisResult.audioData + } else { + null + } + + voiceAgentLogger.info("Voice processing complete") + VoiceAgentResult( + speechDetected = true, + transcription = transcriptionText, + response = responseText, + synthesizedAudio = audioOutput, + ) + } catch (e: Exception) { + voiceAgentLogger.error("Voice processing error: ${e.message}", throwable = e) + VoiceAgentResult( + speechDetected = false, + transcription = null, + response = "Processing error: ${e.message}", + synthesizedAudio = null, + ) + } +} + +actual fun RunAnywhere.startVoiceSession(config: VoiceSessionConfig): Flow = + flow { + if (!isInitialized) { + voiceAgentLogger.error("Cannot start voice session: SDK not initialized") + emit(VoiceSessionEvent.Error("SDK not initialized")) + return@flow + } + + // Check if all component models are loaded + if (!areAllComponentsLoaded()) { + val missing = getMissingComponents() + voiceAgentLogger.error("Cannot start voice session: Models not loaded: ${missing.joinToString(", ")}") + emit(VoiceSessionEvent.Error("Models not loaded: ${missing.joinToString(", ")}")) + return@flow + } + + // Mark voice agent as initialized and session as active + voiceAgentInitialized = true + voiceSessionActive = true + voiceAgentLogger.info("Voice session started") + emit(VoiceSessionEvent.Started) + + // The actual voice session loop would be driven by audio input from the app layer + // This flow represents session events that the app can collect + // Audio recording and processing should be handled by the app using processVoice() + } + +actual suspend fun RunAnywhere.stopVoiceSession() { + if (!isInitialized) { + throw SDKError.notInitialized("SDK not initialized") + } + + voiceAgentLogger.info("Stopping voice session...") + voiceSessionActive = false + // Cancel any ongoing operations + CppBridgeSTT.cancel() + CppBridgeLLM.cancel() + CppBridgeTTS.cancel() + voiceAgentLogger.info("Voice session stopped") +} + +actual suspend fun RunAnywhere.isVoiceSessionActive(): Boolean { + return voiceSessionActive +} + +actual suspend fun RunAnywhere.clearVoiceConversation() { + if (!isInitialized) { + throw SDKError.notInitialized("SDK not initialized") + } + + // Clear conversation context - for LLM, this would clear any stored conversation history + // Currently no persistent conversation state, so this is a no-op +} + +actual suspend fun RunAnywhere.setVoiceSystemPrompt(prompt: String) { + if (!isInitialized) { + throw SDKError.notInitialized("SDK not initialized") + } + + currentSystemPrompt = prompt +} + +/** + * Stream a voice session with automatic silence detection. + * + * This implementation handles: + * - Audio level calculation (RMS) for visualization + * - Speech detection when audio level exceeds threshold + * - Automatic silence detection - triggers processing after configured silence duration + * - Full STT → LLM → TTS pipeline orchestration + * - Continuous conversation mode - auto-resumes after TTS completion + */ +actual fun RunAnywhere.streamVoiceSession( + audioChunks: Flow, + config: VoiceSessionConfig, +): Flow = channelFlow { + if (!isInitialized) { + send(VoiceSessionEvent.Error("SDK not initialized")) + return@channelFlow + } + + // Check if all components are loaded + if (!areAllComponentsLoaded()) { + val missing = getMissingComponents() + send(VoiceSessionEvent.Error("Models not loaded: ${missing.joinToString(", ")}")) + return@channelFlow + } + + voiceAgentLogger.info("Starting streaming voice session with auto-silence detection") + send(VoiceSessionEvent.Started) + + // Session state + val audioBuffer = ByteArrayOutputStream() + var isSpeechActive = false + var lastSpeechTime = 0L + var isProcessingTurn = false + val minAudioBytes = 16000 // ~0.5s at 16kHz, 16-bit + val silenceDurationMs = (config.silenceDuration * 1000).toLong() + + /** + * Calculate RMS (Root Mean Square) for audio level visualization + */ + fun calculateRMS(audioData: ByteArray): Float { + if (audioData.isEmpty()) return 0f + val shorts = ByteBuffer.wrap(audioData).order(ByteOrder.LITTLE_ENDIAN).asShortBuffer() + var sum = 0.0 + while (shorts.hasRemaining()) { + val sample = shorts.get().toFloat() / Short.MAX_VALUE + sum += sample * sample + } + return sqrt(sum / (audioData.size / 2)).toFloat() + } + + /** + * Normalize audio level for visualization (0.0 to 1.0) + */ + fun normalizeAudioLevel(rms: Float): Float = (rms * 3.0f).coerceIn(0f, 1f) + + /** + * Process accumulated audio through the voice pipeline + */ + suspend fun processAudio(): Boolean { + if (isProcessingTurn) return false + isProcessingTurn = true + + val audioData = synchronized(audioBuffer) { + val data = audioBuffer.toByteArray() + audioBuffer.reset() + data + } + + if (audioData.size < minAudioBytes) { + voiceAgentLogger.debug("Audio too short to process (${audioData.size} bytes)") + isProcessingTurn = false + return false + } + + voiceAgentLogger.info("Processing ${audioData.size} bytes through voice pipeline") + send(VoiceSessionEvent.Processing) + + try { + // Step 1: Transcribe audio using STT + val transcriptionResult = withContext(Dispatchers.Default) { + CppBridgeSTT.transcribe(audioData) + } + val transcriptionText = transcriptionResult.text + + if (transcriptionText.isBlank()) { + voiceAgentLogger.debug("No speech detected in audio") + isProcessingTurn = false + return false + } + + voiceAgentLogger.info("Transcription: ${transcriptionText.take(100)}") + send(VoiceSessionEvent.Transcribed(transcriptionText)) + + // Step 2: Generate response using LLM + val systemPrompt = currentSystemPrompt ?: "You are a helpful voice assistant." + val chatPrompt = "$systemPrompt\n\nUser: $transcriptionText\n\nAssistant:" + val generationResult = withContext(Dispatchers.Default) { + CppBridgeLLM.generate(chatPrompt) + } + val responseText = generationResult.text + + voiceAgentLogger.info("Response: ${responseText.take(100)}") + send(VoiceSessionEvent.Responded(responseText)) + + // Step 3: Synthesize speech using TTS + var audioOutput: ByteArray? = null + if (responseText.isNotBlank()) { + send(VoiceSessionEvent.Speaking) + val synthesisResult = withContext(Dispatchers.Default) { + CppBridgeTTS.synthesize(responseText) + } + audioOutput = synthesisResult.audioData + voiceAgentLogger.debug("TTS synthesis complete: ${audioOutput.size} bytes") + } + + // Emit turn completed with audio for app to play + send(VoiceSessionEvent.TurnCompleted(transcriptionText, responseText, audioOutput)) + + isProcessingTurn = false + return true + } catch (e: Exception) { + voiceAgentLogger.error("Voice processing error: ${e.message}", throwable = e) + send(VoiceSessionEvent.Error("Processing error: ${e.message}")) + isProcessingTurn = false + return false + } + } + + // Main audio processing loop + var lastCheckTime = System.currentTimeMillis() + + try { + audioChunks.collect { chunk -> + if (!isActive || isProcessingTurn) return@collect + + // Accumulate audio + synchronized(audioBuffer) { + audioBuffer.write(chunk) + } + + // Calculate and emit audio level + val rms = calculateRMS(chunk) + val normalizedLevel = normalizeAudioLevel(rms) + send(VoiceSessionEvent.Listening(normalizedLevel)) + + // Speech detection + if (normalizedLevel > config.speechThreshold) { + if (!isSpeechActive) { + voiceAgentLogger.debug("Speech started (level: $normalizedLevel)") + isSpeechActive = true + send(VoiceSessionEvent.SpeechStarted) + } + lastSpeechTime = System.currentTimeMillis() + } + + // Silence detection - check periodically + val now = System.currentTimeMillis() + if (now - lastCheckTime >= 50) { // Check every 50ms + lastCheckTime = now + + if (isSpeechActive && lastSpeechTime > 0) { + if (normalizedLevel <= config.speechThreshold) { + val silenceTime = now - lastSpeechTime + if (silenceTime > silenceDurationMs) { + voiceAgentLogger.debug("Speech ended after ${silenceTime}ms of silence") + isSpeechActive = false + + // Process accumulated audio + val processed = processAudio() + + // If continuous mode, reset for next turn + if (config.continuousMode && processed) { + lastSpeechTime = 0L + // Continue collecting audio for next turn + } + } + } + } + } + } + } catch (e: kotlinx.coroutines.CancellationException) { + voiceAgentLogger.debug("Voice session cancelled") + } catch (e: Exception) { + voiceAgentLogger.error("Voice session error: ${e.message}", throwable = e) + send(VoiceSessionEvent.Error("Session error: ${e.message}")) + } + + // Process any remaining audio when stream ends + if (!isProcessingTurn) { + val remainingSize = synchronized(audioBuffer) { audioBuffer.size() } + if (remainingSize >= minAudioBytes) { + voiceAgentLogger.info("Processing remaining audio on stream end") + processAudio() + } + } + + send(VoiceSessionEvent.Stopped) + voiceAgentLogger.info("Voice session ended") +} diff --git a/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/storage/SharedFileSystem.kt b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/storage/SharedFileSystem.kt new file mode 100644 index 000000000..56af389d5 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/storage/SharedFileSystem.kt @@ -0,0 +1,104 @@ +package com.runanywhere.sdk.storage + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream +import java.io.OutputStream + +/** + * Shared FileSystem implementation for JVM and Android platforms + * Uses standard Java File API operations + */ +abstract class SharedFileSystem : FileSystem { + override suspend fun writeBytes( + path: String, + data: ByteArray, + ) = withContext(Dispatchers.IO) { + File(path).writeBytes(data) + } + + override suspend fun appendBytes( + path: String, + data: ByteArray, + ) = withContext(Dispatchers.IO) { + FileOutputStream(path, true).use { outputStream -> + outputStream.write(data) + } + } + + override suspend fun writeStream( + path: String, + block: suspend (OutputStream) -> T, + ): T = + withContext(Dispatchers.IO) { + FileOutputStream(path).use { outputStream -> + block(outputStream) + } + } + + override suspend fun readBytes(path: String): ByteArray = + withContext(Dispatchers.IO) { + File(path).readBytes() + } + + override suspend fun exists(path: String): Boolean = + withContext(Dispatchers.IO) { + File(path).exists() + } + + override suspend fun delete(path: String): Boolean = + withContext(Dispatchers.IO) { + File(path).delete() + } + + override suspend fun deleteRecursively(path: String): Boolean = + withContext(Dispatchers.IO) { + File(path).deleteRecursively() + } + + override suspend fun createDirectory(path: String): Boolean = + withContext(Dispatchers.IO) { + File(path).mkdirs() + } + + override suspend fun fileSize(path: String): Long = + withContext(Dispatchers.IO) { + File(path).length() + } + + override suspend fun listFiles(path: String): List = + withContext(Dispatchers.IO) { + File(path).listFiles()?.map { it.absolutePath } ?: emptyList() + } + + override suspend fun move( + from: String, + to: String, + ): Boolean = + withContext(Dispatchers.IO) { + try { + File(from).renameTo(File(to)) + } catch (e: Exception) { + false + } + } + + override suspend fun copy( + from: String, + to: String, + ): Boolean = + withContext(Dispatchers.IO) { + try { + File(from).copyTo(File(to), overwrite = true) + true + } catch (e: Exception) { + false + } + } + + override suspend fun isDirectory(path: String): Boolean = + withContext(Dispatchers.IO) { + File(path).isDirectory + } +} diff --git a/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/utils/CryptoUtils.kt b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/utils/CryptoUtils.kt new file mode 100644 index 000000000..b13f8837c --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/utils/CryptoUtils.kt @@ -0,0 +1,12 @@ +package com.runanywhere.sdk.utils + +import java.security.MessageDigest + +/** + * Shared cryptographic utilities for JVM and Android platforms + */ +fun calculateSHA256(data: ByteArray): String { + val digest = MessageDigest.getInstance("SHA-256") + val hash = digest.digest(data) + return hash.joinToString("") { "%02x".format(it) } +} diff --git a/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/utils/SharedBuildConfig.kt b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/utils/SharedBuildConfig.kt new file mode 100644 index 000000000..4082259f9 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/utils/SharedBuildConfig.kt @@ -0,0 +1,9 @@ +package com.runanywhere.sdk.utils + +/** + * Shared build configuration constants for JVM and Android platforms + */ +object SharedBuildConfig { + const val VERSION_NAME: String = "1.0.0" + const val APPLICATION_ID: String = "com.runanywhere.sdk" +} diff --git a/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/utils/TimeUtils.kt b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/utils/TimeUtils.kt new file mode 100644 index 000000000..08ce5e5c5 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmAndroidMain/kotlin/com/runanywhere/sdk/utils/TimeUtils.kt @@ -0,0 +1,6 @@ +package com.runanywhere.sdk.utils + +/** + * Shared JVM/Android implementation of time utilities + */ +actual fun getCurrentTimeMillis(): Long = System.currentTimeMillis() diff --git a/sdk/runanywhere-kotlin/src/jvmMain/kotlin/com/runanywhere/sdk/data/models/DeviceInfoModels.kt b/sdk/runanywhere-kotlin/src/jvmMain/kotlin/com/runanywhere/sdk/data/models/DeviceInfoModels.kt new file mode 100644 index 000000000..b1bdc34e8 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmMain/kotlin/com/runanywhere/sdk/data/models/DeviceInfoModels.kt @@ -0,0 +1,9 @@ +package com.runanywhere.sdk.data.models + +actual fun getPlatformAPILevel(): Int = 35 // Default high API level for JVM + +actual fun getPlatformOSVersion(): String { + val osName = System.getProperty("os.name") ?: "Unknown" + val osVersion = System.getProperty("os.version") ?: "" + return "$osName $osVersion".trim() +} diff --git a/sdk/runanywhere-kotlin/src/jvmMain/kotlin/com/runanywhere/sdk/features/stt/JvmAudioCaptureManager.kt b/sdk/runanywhere-kotlin/src/jvmMain/kotlin/com/runanywhere/sdk/features/stt/JvmAudioCaptureManager.kt new file mode 100644 index 000000000..7bbb63e36 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmMain/kotlin/com/runanywhere/sdk/features/stt/JvmAudioCaptureManager.kt @@ -0,0 +1,176 @@ +package com.runanywhere.sdk.features.stt + +import com.runanywhere.sdk.foundation.SDKLogger +import com.runanywhere.sdk.foundation.currentTimeMillis +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.isActive +import javax.sound.sampled.AudioFormat +import javax.sound.sampled.AudioSystem +import javax.sound.sampled.DataLine +import javax.sound.sampled.LineUnavailableException +import javax.sound.sampled.TargetDataLine +import kotlin.coroutines.coroutineContext +import kotlin.math.log10 +import kotlin.math.sqrt + +/** + * Platform-specific factory for JVM + */ +actual fun createAudioCaptureManager(): AudioCaptureManager = JvmAudioCaptureManager() + +/** + * JVM implementation of AudioCaptureManager using Java Sound API. + * Captures audio at 16kHz mono 16-bit PCM format. + * + * Matches iOS AudioCaptureManager behavior exactly. + */ +class JvmAudioCaptureManager : AudioCaptureManager { + private val logger = SDKLogger("AudioCapture") + + private var targetLine: TargetDataLine? = null + + private val _isRecording = MutableStateFlow(false) + override val isRecording: StateFlow = _isRecording.asStateFlow() + + private val _audioLevel = MutableStateFlow(0.0f) + override val audioLevel: StateFlow = _audioLevel.asStateFlow() + + override val targetSampleRate: Int = 16000 + + // Audio format: 16kHz, 16-bit, mono, signed, little-endian + private val audioFormat = + AudioFormat( + targetSampleRate.toFloat(), + 16, // sample size in bits + 1, // mono + true, // signed + false, // little-endian + ) + + // Buffer size for ~100ms of audio at 16kHz (1600 samples * 2 bytes) + private val bufferSize = 3200 + + init { + logger.info("JvmAudioCaptureManager initialized") + } + + override suspend fun requestPermission(): Boolean { + // On JVM/Desktop, there's typically no permission system for microphone + // We just check if audio input is available + return hasPermission() + } + + override suspend fun hasPermission(): Boolean { + return try { + val info = DataLine.Info(TargetDataLine::class.java, audioFormat) + AudioSystem.isLineSupported(info) + } catch (e: Exception) { + logger.error("Error checking audio availability: ${e.message}") + false + } + } + + override suspend fun startRecording(): Flow = + flow { + if (_isRecording.value) { + logger.warning("Already recording") + return@flow + } + + try { + val info = DataLine.Info(TargetDataLine::class.java, audioFormat) + + if (!AudioSystem.isLineSupported(info)) { + throw AudioCaptureError.DeviceNotAvailable + } + + val line = AudioSystem.getLine(info) as TargetDataLine + line.open(audioFormat, bufferSize) + line.start() + + targetLine = line + _isRecording.value = true + + logger.info("Recording started - sampleRate: $targetSampleRate, bufferSize: $bufferSize") + + // Audio capture loop + val buffer = ByteArray(bufferSize) + + while (coroutineContext.isActive && _isRecording.value) { + val bytesRead = line.read(buffer, 0, buffer.size) + + if (bytesRead > 0) { + // Copy the data to a new array + val audioData = buffer.copyOf(bytesRead) + + // Update audio level for visualization + updateAudioLevel(audioData, bytesRead) + + // Emit audio chunk + emit(AudioChunk(audioData, currentTimeMillis())) + } + } + } catch (e: LineUnavailableException) { + logger.error("Audio line unavailable: ${e.message}") + throw AudioCaptureError.DeviceNotAvailable + } catch (e: AudioCaptureError) { + throw e + } catch (e: Exception) { + logger.error("Recording error: ${e.message}", throwable = e) + throw AudioCaptureError.RecordingFailed(e.message ?: "Unknown error") + } finally { + stopRecordingInternal() + } + }.flowOn(Dispatchers.IO) + + override fun stopRecording() { + if (!_isRecording.value) return + stopRecordingInternal() + } + + private fun stopRecordingInternal() { + try { + targetLine?.stop() + targetLine?.close() + targetLine = null + } catch (e: Exception) { + logger.error("Error stopping recording: ${e.message}") + } + + _isRecording.value = false + _audioLevel.value = 0.0f + logger.info("Recording stopped") + } + + override suspend fun cleanup() { + stopRecording() + } + + private fun updateAudioLevel(buffer: ByteArray, count: Int) { + if (count < 2) return + + // Convert bytes to samples and calculate RMS + var sum = 0.0 + val sampleCount = count / 2 + + for (i in 0 until sampleCount) { + val low = buffer[i * 2].toInt() and 0xFF + val high = buffer[i * 2 + 1].toInt() + val sample = ((high shl 8) or low).toShort().toDouble() / 32768.0 // Normalize + sum += sample * sample + } + + val rms = sqrt(sum / sampleCount).toFloat() + val dbLevel = 20 * log10((rms + 0.0001).toDouble()) // Add small value to avoid log(0) + + // Normalize to 0-1 range (-60dB to 0dB) + val normalizedLevel = ((dbLevel + 60) / 60).coerceIn(0.0, 1.0).toFloat() + _audioLevel.value = normalizedLevel + } +} diff --git a/sdk/runanywhere-kotlin/src/jvmMain/kotlin/com/runanywhere/sdk/features/tts/TtsAudioPlayback.jvm.kt b/sdk/runanywhere-kotlin/src/jvmMain/kotlin/com/runanywhere/sdk/features/tts/TtsAudioPlayback.jvm.kt new file mode 100644 index 000000000..d794db6bc --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmMain/kotlin/com/runanywhere/sdk/features/tts/TtsAudioPlayback.jvm.kt @@ -0,0 +1,20 @@ +package com.runanywhere.sdk.features.tts + +import com.runanywhere.sdk.foundation.SDKLogger + +internal actual object TtsAudioPlayback { + private val logger = SDKLogger.tts + + actual val isPlaying: Boolean + get() = false + + actual suspend fun play(audioData: ByteArray) { + if (audioData.isNotEmpty()) { + logger.warning("Audio playback is not supported on JVM targets") + } + } + + actual fun stop() { + // No-op on JVM. + } +} diff --git a/sdk/runanywhere-kotlin/src/jvmMain/kotlin/com/runanywhere/sdk/foundation/HostAppInfo.kt b/sdk/runanywhere-kotlin/src/jvmMain/kotlin/com/runanywhere/sdk/foundation/HostAppInfo.kt new file mode 100644 index 000000000..827f42cee --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmMain/kotlin/com/runanywhere/sdk/foundation/HostAppInfo.kt @@ -0,0 +1,7 @@ +package com.runanywhere.sdk.foundation + +/** + * JVM implementation of getHostAppInfo + * Returns null for all fields as JVM doesn't have a standard way to get app info + */ +actual fun getHostAppInfo(): HostAppInfo = HostAppInfo(null, null, null) diff --git a/sdk/runanywhere-kotlin/src/jvmMain/kotlin/com/runanywhere/sdk/foundation/PlatformLogger.kt b/sdk/runanywhere-kotlin/src/jvmMain/kotlin/com/runanywhere/sdk/foundation/PlatformLogger.kt new file mode 100644 index 000000000..6b4dcc41f --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmMain/kotlin/com/runanywhere/sdk/foundation/PlatformLogger.kt @@ -0,0 +1,60 @@ +package com.runanywhere.sdk.foundation + +/** + * JVM implementation of PlatformLogger using println. + * Supports all log levels including TRACE and FAULT. + */ +actual class PlatformLogger actual constructor( + private val tag: String, +) { + /** + * Log a trace-level message. + */ + actual fun trace(message: String) { + println("TRACE[$tag]: $message") + } + + /** + * Log a debug-level message. + */ + actual fun debug(message: String) { + println("DEBUG[$tag]: $message") + } + + /** + * Log an info-level message. + */ + actual fun info(message: String) { + println("INFO[$tag]: $message") + } + + /** + * Log a warning-level message. + */ + actual fun warning(message: String) { + println("WARN[$tag]: $message") + } + + /** + * Log an error-level message. + */ + actual fun error( + message: String, + throwable: Throwable?, + ) { + println("ERROR[$tag]: $message") + throwable?.printStackTrace() + } + + /** + * Log a fault-level message (critical system errors). + * Outputs to stderr for maximum visibility. + */ + actual fun fault( + message: String, + throwable: Throwable?, + ) { + System.err.println("FAULT[$tag]: $message") + throwable?.printStackTrace(System.err) + } +} diff --git a/sdk/runanywhere-kotlin/src/jvmMain/kotlin/com/runanywhere/sdk/foundation/PlatformTime.kt b/sdk/runanywhere-kotlin/src/jvmMain/kotlin/com/runanywhere/sdk/foundation/PlatformTime.kt new file mode 100644 index 000000000..a72301529 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmMain/kotlin/com/runanywhere/sdk/foundation/PlatformTime.kt @@ -0,0 +1,21 @@ +package com.runanywhere.sdk.foundation + +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +/** + * JVM implementation of time utilities + */ +actual fun currentTimeMillis(): Long = System.currentTimeMillis() + +/** + * Get current time as ISO8601 string + * Matches iOS format exactly: "2025-10-25 23:24:53+00" + */ +actual fun currentTimeISO8601(): String { + val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss'+00'", Locale.US) + sdf.timeZone = TimeZone.getTimeZone("UTC") + return sdf.format(Date()) +} diff --git a/sdk/runanywhere-kotlin/src/jvmMain/kotlin/com/runanywhere/sdk/foundation/device/DeviceInfoService.kt b/sdk/runanywhere-kotlin/src/jvmMain/kotlin/com/runanywhere/sdk/foundation/device/DeviceInfoService.kt new file mode 100644 index 000000000..360f444e1 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmMain/kotlin/com/runanywhere/sdk/foundation/device/DeviceInfoService.kt @@ -0,0 +1,64 @@ +package com.runanywhere.sdk.foundation.device + +import java.lang.management.ManagementFactory + +/** + * JVM implementation of DeviceInfoService + * + * Collects device information using Java System APIs + */ +actual class DeviceInfoService { + actual fun getOSName(): String = System.getProperty("os.name") ?: "Unknown" + + actual fun getOSVersion(): String = System.getProperty("os.version") ?: "Unknown" + + actual fun getDeviceModel(): String { + // JVM doesn't have a concept of device model, return generic label + return try { + "Desktop" + } catch (e: Exception) { + "Desktop" + } + } + + actual fun getChipName(): String? = + try { + System.getProperty("os.arch") + } catch (e: Exception) { + null + } + + actual fun getTotalMemoryGB(): Double? = + try { + val osBean = ManagementFactory.getOperatingSystemMXBean() + if (osBean is com.sun.management.OperatingSystemMXBean) { + // Convert bytes to GB + osBean.totalMemorySize / (1024.0 * 1024.0 * 1024.0) + } else { + // Fallback to Runtime max memory + Runtime.getRuntime().maxMemory() / (1024.0 * 1024.0 * 1024.0) + } + } catch (e: Exception) { + null + } + + actual fun getTotalMemoryBytes(): Long? = + try { + val osBean = ManagementFactory.getOperatingSystemMXBean() + if (osBean is com.sun.management.OperatingSystemMXBean) { + osBean.totalMemorySize + } else { + // Fallback to Runtime max memory + Runtime.getRuntime().maxMemory() + } + } catch (e: Exception) { + null + } + + actual fun getArchitecture(): String? = + try { + System.getProperty("os.arch") + } catch (e: Exception) { + null + } +} diff --git a/sdk/runanywhere-kotlin/src/jvmMain/kotlin/com/runanywhere/sdk/platform/Checksum.kt b/sdk/runanywhere-kotlin/src/jvmMain/kotlin/com/runanywhere/sdk/platform/Checksum.kt new file mode 100644 index 000000000..716faff34 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmMain/kotlin/com/runanywhere/sdk/platform/Checksum.kt @@ -0,0 +1,68 @@ +package com.runanywhere.sdk.platform + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.security.MessageDigest + +/** + * JVM implementation of checksum calculation. + * ONLY file I/O is here - business logic stays in commonMain. + */ + +actual suspend fun calculateSHA256(filePath: String): String = + withContext(Dispatchers.IO) { + calculateChecksumFromFile(filePath, "SHA-256") + } + +actual suspend fun calculateMD5(filePath: String): String = + withContext(Dispatchers.IO) { + calculateChecksumFromFile(filePath, "MD5") + } + +actual fun calculateSHA256Bytes(data: ByteArray): String = calculateChecksumFromBytes(data, "SHA-256") + +actual fun calculateMD5Bytes(data: ByteArray): String = calculateChecksumFromBytes(data, "MD5") + +/** + * Shared implementation for file-based checksum calculation. + * Platform-specific: Uses java.io.File for file I/O. + */ +private fun calculateChecksumFromFile( + filePath: String, + algorithm: String, +): String { + val file = File(filePath) + if (!file.exists()) { + throw IllegalArgumentException("File does not exist: $filePath") + } + + val digest = MessageDigest.getInstance(algorithm) + + file.inputStream().use { input -> + val buffer = ByteArray(8192) // 8KB buffer + var bytesRead: Int + + while (input.read(buffer).also { bytesRead = it } != -1) { + digest.update(buffer, 0, bytesRead) + } + } + + // Convert to hex string (lowercase to match Swift) + return digest.digest().joinToString("") { "%02x".format(it) } +} + +/** + * Shared implementation for byte array checksum calculation. + * Platform-specific: Uses java.security.MessageDigest. + */ +private fun calculateChecksumFromBytes( + data: ByteArray, + algorithm: String, +): String { + val digest = MessageDigest.getInstance(algorithm) + digest.update(data) + + // Convert to hex string (lowercase to match Swift) + return digest.digest().joinToString("") { "%02x".format(it) } +} diff --git a/sdk/runanywhere-kotlin/src/jvmMain/kotlin/com/runanywhere/sdk/platform/StoragePlatform.jvm.kt b/sdk/runanywhere-kotlin/src/jvmMain/kotlin/com/runanywhere/sdk/platform/StoragePlatform.jvm.kt new file mode 100644 index 000000000..2939703b2 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmMain/kotlin/com/runanywhere/sdk/platform/StoragePlatform.jvm.kt @@ -0,0 +1,48 @@ +package com.runanywhere.sdk.platform + +import java.io.File + +/** + * JVM implementation of platform-specific storage operations + * Uses Java File API for storage calculations + * + * Reference: Matches iOS FileManager storage calculations but uses JVM APIs + */ + +actual suspend fun getPlatformStorageInfo(path: String): PlatformStorageInfo { + val file = File(path) + + val totalSpace = file.totalSpace + val availableSpace = file.usableSpace + val usedSpace = totalSpace - availableSpace + + return PlatformStorageInfo( + totalSpace = totalSpace, + availableSpace = availableSpace, + usedSpace = usedSpace, + ) +} + +actual fun getPlatformBaseDirectory(): String { + // Match iOS pattern: app-specific directory for SDK files + // iOS: .applicationSupportDirectory + // JVM: ~/.runanywhere + val userHome = System.getProperty("user.home") + val baseDir = File(userHome, ".runanywhere") + if (!baseDir.exists()) { + baseDir.mkdirs() + } + return baseDir.absolutePath +} + +actual fun getPlatformTempDirectory(): String { + // Match iOS pattern: temporary directory + // iOS: .temporaryDirectory + // JVM: temp directory/runanywhere-temp + val systemTemp = System.getProperty("java.io.tmpdir") + val tempDir = File(systemTemp, "runanywhere-temp") + if (!tempDir.exists()) { + tempDir.mkdirs() + } + return tempDir.absolutePath +} diff --git a/sdk/runanywhere-kotlin/src/jvmMain/kotlin/com/runanywhere/sdk/security/SecureStorage.kt b/sdk/runanywhere-kotlin/src/jvmMain/kotlin/com/runanywhere/sdk/security/SecureStorage.kt new file mode 100644 index 000000000..c2de835b8 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmMain/kotlin/com/runanywhere/sdk/security/SecureStorage.kt @@ -0,0 +1,314 @@ +package com.runanywhere.sdk.security + +import com.runanywhere.sdk.foundation.SDKLogger +import com.runanywhere.sdk.foundation.errors.SDKError +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.nio.file.Files +import java.security.SecureRandom +import java.util.* +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec +import kotlin.io.encoding.ExperimentalEncodingApi + +/** + * JVM implementation of SecureStorage using encrypted file storage + * Uses AES-GCM encryption with a locally generated key + * For production JVM applications, consider integrating with system keystore + */ +@OptIn(ExperimentalEncodingApi::class) +class JvmSecureStorage private constructor( + private val storageDir: File, + private val identifier: String, + private val secretKey: SecretKey, +) : SecureStorage { + private val logger = SDKLogger("JvmSecureStorage") + private val cipher = Cipher.getInstance("AES/GCM/NoPadding") + private val gcmTagLength = 16 + private val ivLength = 12 + + companion object { + private val storageInstances = mutableMapOf() + + /** + * Create secure storage instance for JVM + */ + fun create(identifier: String): JvmSecureStorage { + // Return cached instance if available + storageInstances[identifier]?.let { return it } + + try { + // Create storage directory + val userHome = System.getProperty("user.home") + val storageDir = File(userHome, ".runanywhere-sdk/$identifier") + if (!storageDir.exists()) { + storageDir.mkdirs() + } + + // Load or create encryption key + val secretKey = loadOrCreateKey(storageDir) + + val storage = JvmSecureStorage(storageDir, identifier, secretKey) + storageInstances[identifier] = storage + return storage + } catch (e: Exception) { + throw SDKError.storage("Failed to create JVM secure storage: ${e.message}", cause = e) + } + } + + /** + * Load existing encryption key or create a new one + */ + private fun loadOrCreateKey(storageDir: File): SecretKey { + val keyFile = File(storageDir, ".encryption_key") + + return if (keyFile.exists()) { + try { + val keyBytes = keyFile.readBytes() + SecretKeySpec(keyBytes, "AES") + } catch (e: Exception) { + // If key loading fails, create a new one + createAndSaveKey(keyFile) + } + } else { + createAndSaveKey(keyFile) + } + } + + /** + * Create and save a new encryption key + */ + private fun createAndSaveKey(keyFile: File): SecretKey { + val keyGenerator = KeyGenerator.getInstance("AES") + keyGenerator.init(256) // 256-bit AES key + val secretKey = keyGenerator.generateKey() + + // Save key to file with restricted permissions + keyFile.writeBytes(secretKey.encoded) + + // Set file permissions to be readable only by owner (Unix-like systems) + try { + val path = keyFile.toPath() + Files.setPosixFilePermissions( + path, + setOf( + java.nio.file.attribute.PosixFilePermission.OWNER_READ, + java.nio.file.attribute.PosixFilePermission.OWNER_WRITE, + ), + ) + } catch (e: Exception) { + // Ignore on Windows or if POSIX permissions are not supported + } + + return secretKey + } + + /** + * Check if JVM secure storage is supported + */ + fun isSupported(): Boolean = + try { + // Check if we can create directories and files + val testDir = File(System.getProperty("user.home"), ".runanywhere-sdk-test") + testDir.mkdirs() + val canWrite = testDir.canWrite() + testDir.deleteRecursively() + canWrite + } catch (e: Exception) { + false + } + } + + override suspend fun setSecureString( + key: String, + value: String, + ) = withContext(Dispatchers.IO) { + try { + val encryptedData = encrypt(value.toByteArray()) + val file = File(storageDir, "$key.enc") + file.writeBytes(encryptedData) + logger.debug("Stored secure string for key: $key") + } catch (e: Exception) { + logger.error("Failed to store secure string for key: $key", throwable = e) + throw SDKError.storage("Failed to store secure data: ${e.message}") + } + } + + override suspend fun getSecureString(key: String): String? = + withContext(Dispatchers.IO) { + try { + val file = File(storageDir, "$key.enc") + if (!file.exists()) return@withContext null + + val encryptedData = file.readBytes() + val decryptedData = decrypt(encryptedData) + val value = String(decryptedData) + logger.debug("Retrieved secure string for key: $key") + value + } catch (e: Exception) { + logger.error("Failed to retrieve secure string for key: $key", throwable = e) + throw SDKError.storage("Failed to retrieve secure data: ${e.message}") + } + } + + override suspend fun setSecureData( + key: String, + data: ByteArray, + ) = withContext(Dispatchers.IO) { + try { + val encryptedData = encrypt(data) + val file = File(storageDir, "$key.bin.enc") + file.writeBytes(encryptedData) + logger.debug("Stored secure data for key: $key (${data.size} bytes)") + } catch (e: Exception) { + logger.error("Failed to store secure data for key: $key", throwable = e) + throw SDKError.storage("Failed to store secure data: ${e.message}") + } + } + + override suspend fun getSecureData(key: String): ByteArray? = + withContext(Dispatchers.IO) { + try { + val file = File(storageDir, "$key.bin.enc") + if (!file.exists()) return@withContext null + + val encryptedData = file.readBytes() + val decryptedData = decrypt(encryptedData) + logger.debug("Retrieved secure data for key: $key (${decryptedData.size} bytes)") + decryptedData + } catch (e: Exception) { + logger.error("Failed to retrieve secure data for key: $key", throwable = e) + throw SDKError.storage("Failed to retrieve secure data: ${e.message}") + } + } + + override suspend fun removeSecure(key: String) = + withContext(Dispatchers.IO) { + try { + val stringFile = File(storageDir, "$key.enc") + val dataFile = File(storageDir, "$key.bin.enc") + + var removed = false + if (stringFile.exists()) { + stringFile.delete() + removed = true + } + if (dataFile.exists()) { + dataFile.delete() + removed = true + } + + if (removed) { + logger.debug("Removed secure data for key: $key") + } + } catch (e: Exception) { + logger.error("Failed to remove secure data for key: $key", throwable = e) + throw SDKError.storage("Failed to remove secure data: ${e.message}") + } + } + + override suspend fun containsKey(key: String): Boolean = + withContext(Dispatchers.IO) { + try { + val stringFile = File(storageDir, "$key.enc") + val dataFile = File(storageDir, "$key.bin.enc") + stringFile.exists() || dataFile.exists() + } catch (e: Exception) { + logger.error("Failed to check key existence: $key", throwable = e) + false + } + } + + override suspend fun clearAll() = + withContext(Dispatchers.IO) { + try { + storageDir.listFiles()?.forEach { file -> + if (file.name.endsWith(".enc")) { + file.delete() + } + } + logger.info("Cleared all secure data") + } catch (e: Exception) { + logger.error("Failed to clear all secure data", throwable = e) + throw SDKError.storage("Failed to clear secure data: ${e.message}") + } + } + + override suspend fun getAllKeys(): Set = + withContext(Dispatchers.IO) { + try { + storageDir + .listFiles() + ?.filter { it.name.endsWith(".enc") } + ?.map { + it.name.removeSuffix(".enc").removeSuffix(".bin") + }?.toSet() ?: emptySet() + } catch (e: Exception) { + logger.error("Failed to get all keys", throwable = e) + emptySet() + } + } + + override suspend fun isAvailable(): Boolean = + withContext(Dispatchers.IO) { + try { + // Test by trying to encrypt/decrypt data + val testData = "availability_test".toByteArray() + val encrypted = encrypt(testData) + val decrypted = decrypt(encrypted) + testData.contentEquals(decrypted) + } catch (e: Exception) { + logger.error("Secure storage availability test failed", throwable = e) + false + } + } + + /** + * Encrypt data using AES-GCM + */ + private fun encrypt(data: ByteArray): ByteArray { + // Generate random IV + val iv = ByteArray(ivLength) + SecureRandom().nextBytes(iv) + + // Initialize cipher for encryption + cipher.init(Cipher.ENCRYPT_MODE, secretKey, GCMParameterSpec(gcmTagLength * 8, iv)) + + // Encrypt data + val encryptedData = cipher.doFinal(data) + + // Combine IV + encrypted data + return iv + encryptedData + } + + /** + * Decrypt data using AES-GCM + */ + private fun decrypt(encryptedDataWithIv: ByteArray): ByteArray { + // Extract IV and encrypted data + val iv = encryptedDataWithIv.sliceArray(0 until ivLength) + val encryptedData = encryptedDataWithIv.sliceArray(ivLength until encryptedDataWithIv.size) + + // Initialize cipher for decryption + cipher.init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(gcmTagLength * 8, iv)) + + // Decrypt data + return cipher.doFinal(encryptedData) + } +} + +/** + * JVM implementation of SecureStorageFactory + */ +@Suppress("UtilityClassWithPublicConstructor") // KMP expect/actual pattern requires class +actual class SecureStorageFactory { + actual companion object { + actual fun create(identifier: String): SecureStorage = JvmSecureStorage.create(identifier) + + actual fun isSupported(): Boolean = JvmSecureStorage.isSupported() + } +} diff --git a/sdk/runanywhere-kotlin/src/jvmMain/kotlin/com/runanywhere/sdk/storage/JvmFileSystem.kt b/sdk/runanywhere-kotlin/src/jvmMain/kotlin/com/runanywhere/sdk/storage/JvmFileSystem.kt new file mode 100644 index 000000000..74a63e4d3 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmMain/kotlin/com/runanywhere/sdk/storage/JvmFileSystem.kt @@ -0,0 +1,18 @@ +package com.runanywhere.sdk.storage + +/** + * JVM implementation of FileSystem using java.io.File + * Extends shared implementation and provides JVM-specific directory paths + */ +internal class JvmFileSystem : SharedFileSystem() { + override fun getCacheDirectory(): String = System.getProperty("java.io.tmpdir") ?: "/tmp" + + override fun getDataDirectory(): String = System.getProperty("user.home") + "/.runanywhere" + + override fun getTempDirectory(): String = System.getProperty("java.io.tmpdir") ?: "/tmp" +} + +/** + * Factory function to create FileSystem for JVM + */ +actual fun createFileSystem(): FileSystem = JvmFileSystem() diff --git a/sdk/runanywhere-kotlin/src/jvmMain/kotlin/com/runanywhere/sdk/storage/JvmPlatformStorage.kt b/sdk/runanywhere-kotlin/src/jvmMain/kotlin/com/runanywhere/sdk/storage/JvmPlatformStorage.kt new file mode 100644 index 000000000..0d4b08177 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmMain/kotlin/com/runanywhere/sdk/storage/JvmPlatformStorage.kt @@ -0,0 +1,73 @@ +package com.runanywhere.sdk.storage + +import java.util.concurrent.ConcurrentHashMap + +/** + * JVM implementation of PlatformStorage using in-memory storage + * For production, this could be replaced with file-based storage using Properties + */ +internal class JvmPlatformStorage : PlatformStorage { + private val storage = ConcurrentHashMap() + + override suspend fun putString( + key: String, + value: String, + ) { + storage[key] = value + } + + override suspend fun getString(key: String): String? = storage[key] + + override suspend fun putBoolean( + key: String, + value: Boolean, + ) { + storage[key] = value.toString() + } + + override suspend fun getBoolean( + key: String, + defaultValue: Boolean, + ): Boolean = storage[key]?.toBoolean() ?: defaultValue + + override suspend fun putLong( + key: String, + value: Long, + ) { + storage[key] = value.toString() + } + + override suspend fun getLong( + key: String, + defaultValue: Long, + ): Long = storage[key]?.toLongOrNull() ?: defaultValue + + override suspend fun putInt( + key: String, + value: Int, + ) { + storage[key] = value.toString() + } + + override suspend fun getInt( + key: String, + defaultValue: Int, + ): Int = storage[key]?.toIntOrNull() ?: defaultValue + + override suspend fun remove(key: String) { + storage.remove(key) + } + + override suspend fun clear() { + storage.clear() + } + + override suspend fun contains(key: String): Boolean = storage.containsKey(key) + + override suspend fun getAllKeys(): Set = storage.keys.toSet() +} + +/** + * Factory function to create platform storage for JVM + */ +actual fun createPlatformStorage(): PlatformStorage = JvmPlatformStorage() diff --git a/sdk/runanywhere-kotlin/src/jvmMain/kotlin/com/runanywhere/sdk/storage/KeychainManager.kt b/sdk/runanywhere-kotlin/src/jvmMain/kotlin/com/runanywhere/sdk/storage/KeychainManager.kt new file mode 100644 index 000000000..bf29dacdd --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmMain/kotlin/com/runanywhere/sdk/storage/KeychainManager.kt @@ -0,0 +1,111 @@ +package com.runanywhere.sdk.storage + +import com.runanywhere.sdk.foundation.SDKLogger +import java.util.Base64 +import java.util.prefs.Preferences +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.SecretKeySpec + +/** + * JVM implementation of secure credential storage + * Uses Java Preferences API with optional encryption + */ +object KeychainManager { + private val logger = SDKLogger("KeychainManager") + private val prefs: Preferences = Preferences.userNodeForPackage(KeychainManager::class.java) + private const val API_KEY_PREF = "runanywhere_api_key" + private const val ENCRYPTION_KEY_PREF = "runanywhere_encryption_key" + + /** + * Store API key securely + */ + fun storeAPIKey(apiKey: String) { + try { + // For production, encrypt the API key + val encryptedKey = encrypt(apiKey) + prefs.put(API_KEY_PREF, encryptedKey) + prefs.flush() + logger.debug("API key stored securely") + } catch (e: Exception) { + logger.error("Failed to store API key securely", throwable = e) + // Fallback to plain storage + prefs.put(API_KEY_PREF, apiKey) + prefs.flush() + } + } + + /** + * Retrieve API key + */ + fun getAPIKey(): String? = + try { + val stored = prefs.get(API_KEY_PREF, null) + if (stored != null) { + decrypt(stored) + } else { + null + } + } catch (e: Exception) { + logger.error("Failed to retrieve API key", throwable = e) + // Try as plain text + prefs.get(API_KEY_PREF, null) + } + + /** + * Clear stored credentials + */ + fun clear() { + prefs.remove(API_KEY_PREF) + prefs.remove(ENCRYPTION_KEY_PREF) + prefs.flush() + logger.debug("Cleared stored credentials") + } + + /** + * Simple encryption for API key + */ + private fun encrypt(plainText: String): String { + val key = getOrCreateEncryptionKey() + val cipher = Cipher.getInstance("AES") + cipher.init(Cipher.ENCRYPT_MODE, key) + val encrypted = cipher.doFinal(plainText.toByteArray()) + return Base64.getEncoder().encodeToString(encrypted) + } + + /** + * Decrypt API key + */ + private fun decrypt(encryptedText: String): String { + val key = getOrCreateEncryptionKey() + val cipher = Cipher.getInstance("AES") + cipher.init(Cipher.DECRYPT_MODE, key) + val decrypted = cipher.doFinal(Base64.getDecoder().decode(encryptedText)) + return String(decrypted) + } + + /** + * Get or create encryption key + */ + private fun getOrCreateEncryptionKey(): SecretKey { + val storedKey = prefs.get(ENCRYPTION_KEY_PREF, null) + return if (storedKey != null) { + // Restore existing key + val decodedKey = Base64.getDecoder().decode(storedKey) + SecretKeySpec(decodedKey, 0, decodedKey.size, "AES") + } else { + // Generate new key + val keyGen = KeyGenerator.getInstance("AES") + keyGen.init(128) // 128-bit AES + val secretKey = keyGen.generateKey() + + // Store for future use + val encodedKey = Base64.getEncoder().encodeToString(secretKey.encoded) + prefs.put(ENCRYPTION_KEY_PREF, encodedKey) + prefs.flush() + + secretKey + } + } +} diff --git a/sdk/runanywhere-kotlin/src/jvmMain/kotlin/com/runanywhere/sdk/utils/BuildConfig.kt b/sdk/runanywhere-kotlin/src/jvmMain/kotlin/com/runanywhere/sdk/utils/BuildConfig.kt new file mode 100644 index 000000000..9f3dd207d --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmMain/kotlin/com/runanywhere/sdk/utils/BuildConfig.kt @@ -0,0 +1,10 @@ +package com.runanywhere.sdk.utils + +/** + * JVM implementation of BuildConfig + */ +actual object BuildConfig { + actual val DEBUG: Boolean = System.getProperty("debug", "false").toBoolean() + actual val VERSION_NAME: String = SharedBuildConfig.VERSION_NAME + actual val APPLICATION_ID: String = SharedBuildConfig.APPLICATION_ID +} diff --git a/sdk/runanywhere-kotlin/src/jvmMain/kotlin/com/runanywhere/sdk/utils/PlatformUtils.kt b/sdk/runanywhere-kotlin/src/jvmMain/kotlin/com/runanywhere/sdk/utils/PlatformUtils.kt new file mode 100644 index 000000000..bdcc76662 --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmMain/kotlin/com/runanywhere/sdk/utils/PlatformUtils.kt @@ -0,0 +1,77 @@ +package com.runanywhere.sdk.utils + +import java.net.InetAddress +import java.util.* +import java.util.prefs.Preferences + +/** + * JVM implementation of platform utilities + */ +actual object PlatformUtils { + private val prefs = Preferences.userNodeForPackage(PlatformUtils::class.java) + private const val DEVICE_ID_KEY = "com.runanywhere.sdk.deviceId" + + actual fun getDeviceId(): String { + // Check if we already have a stored device ID + var deviceId = prefs.get(DEVICE_ID_KEY, null) + + if (deviceId == null) { + // Generate a new UUID and store it + deviceId = UUID.randomUUID().toString() + prefs.put(DEVICE_ID_KEY, deviceId) + prefs.flush() + } + + return deviceId + } + + actual fun getPlatformName(): String { + // Return the actual OS platform that the backend expects + val osName = System.getProperty("os.name", "").lowercase() + return when { + osName.contains("mac") || osName.contains("darwin") -> "macos" + osName.contains("win") -> "windows" + osName.contains("nix") || osName.contains("nux") || osName.contains("aix") -> "linux" + else -> "linux" // Default to linux for other Unix-like systems + } + } + + actual fun getDeviceInfo(): Map = + mapOf( + "platform" to getPlatformName(), + "os_name" to System.getProperty("os.name", "Unknown"), + "os_version" to getOSVersion(), + "os_arch" to System.getProperty("os.arch", "Unknown"), + "java_version" to System.getProperty("java.version", "Unknown"), + "java_vendor" to System.getProperty("java.vendor", "Unknown"), + "user_country" to System.getProperty("user.country", "Unknown"), + "user_language" to System.getProperty("user.language", "Unknown"), + "hostname" to getHostName(), + "device_model" to getDeviceModel(), + ) + + actual fun getOSVersion(): String = System.getProperty("os.version", "Unknown") + + actual fun getDeviceModel(): String { + // For JVM, return the OS name and architecture + val osName = System.getProperty("os.name", "Unknown") + val osArch = System.getProperty("os.arch", "Unknown") + return "$osName $osArch" + } + + actual fun getAppVersion(): String? { + // Try to get version from manifest or return null + return try { + PlatformUtils::class.java.`package`?.implementationVersion + } catch (e: Exception) { + null + } + } + + private fun getHostName(): String = + try { + InetAddress.getLocalHost().hostName + } catch (e: Exception) { + "Unknown" + } +} diff --git a/sdk/runanywhere-kotlin/src/jvmTest/kotlin/com/runanywhere/sdk/SDKTest.kt b/sdk/runanywhere-kotlin/src/jvmTest/kotlin/com/runanywhere/sdk/SDKTest.kt new file mode 100644 index 000000000..393e08f3b --- /dev/null +++ b/sdk/runanywhere-kotlin/src/jvmTest/kotlin/com/runanywhere/sdk/SDKTest.kt @@ -0,0 +1,56 @@ +package com.runanywhere.sdk + +import com.runanywhere.sdk.data.models.SDKEnvironment +import com.runanywhere.sdk.public.RunAnywhere +import kotlinx.coroutines.runBlocking +import kotlin.test.Test + +class SDKTest { + @Test + fun testSDKInitialization() = + runBlocking { + // Initialize SDK in development mode (no API key needed) + RunAnywhere.initialize( + apiKey = "test-api-key", + environment = SDKEnvironment.DEVELOPMENT, + ) + + // Check if SDK is initialized + val isInitialized = RunAnywhere.isInitialized + println("SDK initialized: $isInitialized") + + // Get available models + val models = RunAnywhere.availableModels() + println("Available models: ${models.size}") + models.forEach { model -> + println("- ${model.name} (${model.id}): ${model.category}") + } + + // Clean up + RunAnywhere.cleanup() + } + + @Test + fun testSimpleTranscription() = + runBlocking { + // Initialize SDK + RunAnywhere.initialize( + apiKey = "test-api-key", + environment = SDKEnvironment.DEVELOPMENT, + ) + + // Create dummy audio data (16-bit PCM at 16kHz, 1 second of silence) + val audioData = ByteArray(16000 * 2) // 1 second at 16kHz, 16-bit + + try { + // Try to transcribe + val result = RunAnywhere.transcribe(audioData) + println("Transcription result: $result") + } catch (e: Exception) { + println("Transcription failed (expected in test environment): ${e.message}") + } + + // Clean up + RunAnywhere.cleanup() + } +} diff --git a/sdk/runanywhere-react-native/.gitignore b/sdk/runanywhere-react-native/.gitignore new file mode 100644 index 000000000..54aa8aa29 --- /dev/null +++ b/sdk/runanywhere-react-native/.gitignore @@ -0,0 +1,82 @@ +# Dependencies +node_modules/ + +# Build outputs +lib/ +dist/ + +# Nitrogen/Nitro generated files (auto-generated bridge code) +nitrogen/generated/ + +# iOS +ios/build/ +**/ios/Binaries/ +*.xcframework.zip + +# NOTE: iOS xcframeworks are now bundled in npm package - DO NOT gitignore them +# The following are bundled for npm publish: +# - packages/core/ios/Frameworks/RACommons.xcframework +# - packages/llamacpp/ios/Frameworks/RABackendLLAMACPP.xcframework +# - packages/onnx/ios/Frameworks/RABackendONNX.xcframework +# - packages/onnx/ios/Frameworks/onnxruntime.xcframework + +# Xcode environment (machine-specific Node.js paths) +.xcode.env +.xcode.env.local + +# Android +android/build/ +android/.gradle/ +android/.cxx/ +android/libs/ +# NOTE: jniLibs are now bundled in npm package - DO NOT gitignore them +# **/android/src/main/jniLibs/ # REMOVED - libs are bundled for npm publish +**/android/src/main/include/ +**/android/include/ +*.aar + +# TypeScript +*.tsbuildinfo + +# Metro +.metro-health-check* + +# Testing +coverage/ + +# macOS +.DS_Store + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Environment +.env +.env.local +.env.* + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Temporary files +*.tmp +tmp/ + +# Lock files (use yarn.lock or package-lock.json, not both) +# Uncomment the one you don't use: +# yarn.lock +# package-lock.json + +# Yarn PnP +.pnp.cjs +.pnp.loader.mjs +.yarn/ + +# Package artifacts +package.tgz diff --git a/sdk/runanywhere-react-native/.npmignore b/sdk/runanywhere-react-native/.npmignore new file mode 100644 index 000000000..10b6db6ce --- /dev/null +++ b/sdk/runanywhere-react-native/.npmignore @@ -0,0 +1,81 @@ +# .npmignore - Override .gitignore for npm publishing +# IMPORTANT: nitrogen/generated/ must be INCLUDED in npm package + +# Dependencies (always excluded) +node_modules/ + +# Development files +.git/ +.github/ +.vscode/ +.idea/ + +# Build outputs (lib is published, but built fresh) +dist/ + +# iOS development files (XCFramework is downloaded via podspec) +ios/build/ +*.xcframework +*.xcframework.zip + +# Android development files (native libs downloaded via gradle) +android/build/ +android/.gradle/ +android/.cxx/ +android/src/main/jniLibs/ +*.aar + +# Example apps +example/ +examples/ + +# Tests +__tests__/ +__mocks__/ +__fixtures__/ +coverage/ +*.test.js +*.test.ts +*.spec.js +*.spec.ts + +# Documentation (README is kept) +docs/ +*.md +!README.md + +# TypeScript build info +*.tsbuildinfo + +# Metro +.metro-health-check* + +# macOS +.DS_Store + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Environment +.env +.env.local +.env.* + +# Temporary files +*.tmp +tmp/ + +# Lock files +yarn.lock +package-lock.json + +# Yarn PnP +.pnp.cjs +.pnp.loader.mjs +.yarn/ + +# Package artifacts +package.tgz diff --git a/sdk/runanywhere-react-native/.swiftlint.yml b/sdk/runanywhere-react-native/.swiftlint.yml new file mode 100644 index 000000000..6144057f9 --- /dev/null +++ b/sdk/runanywhere-react-native/.swiftlint.yml @@ -0,0 +1,81 @@ +# SwiftLint configuration for RunAnywhere React Native SDK +# Applies to all Swift code in the monorepo packages + +# Directories to include +included: + - packages/core/ios + - packages/llamacpp/ios + - packages/onnx/ios + +# Directories to exclude +excluded: + - node_modules + - .build + - DerivedData + - Pods + +# Basic rules +line_length: + warning: 150 + error: 200 + ignores_urls: true + ignores_function_declarations: true + ignores_comments: true + +file_length: + warning: 800 + error: 1500 + +function_body_length: + warning: 80 + error: 300 + +identifier_name: + min_length: + warning: 2 + error: 1 + max_length: + warning: 40 + error: 50 + excluded: + - id + - i + - j + - k + - x + - y + - z + +# Custom rules for logging enforcement +custom_rules: + # Logging enforcement rules - require SDKLogger usage + # Use "// swiftlint:disable:next no_print_statements" above intentional print() usage. + no_print_statements: + name: "Use SDKLogger Instead of print()" + regex: '^\s*print\(' + message: "Use SDKLogger instead of print(). Example: SDKLogger.shared.debug(\"message\")" + severity: error + + no_nslog_statements: + name: "Use SDKLogger Instead of NSLog()" + regex: 'NSLog\(' + message: "Use SDKLogger instead of NSLog(). Example: SDKLogger.shared.info(\"message\")" + severity: error + + no_os_log_statements: + name: "Use SDKLogger Instead of os_log()" + regex: 'os_log\(' + message: "Use SDKLogger instead of os_log(). The SDK logging system handles os_log internally." + severity: error + + no_debug_print_statements: + name: "Use SDKLogger Instead of debugPrint()" + regex: 'debugPrint\(' + message: "Use SDKLogger.debug() instead of debugPrint(). Example: SDKLogger.shared.debug(\"message\")" + severity: error + + no_apple_logger: + name: "Use SDKLogger Instead of Apple Logger" + regex: '= Logger\(' + message: "Use SDKLogger instead of Apple's os.Logger. Example: SDKLogger(category: \"MyCategory\")" + severity: error diff --git a/sdk/runanywhere-react-native/.yarnrc.yml b/sdk/runanywhere-react-native/.yarnrc.yml new file mode 100644 index 000000000..59245e0a8 --- /dev/null +++ b/sdk/runanywhere-react-native/.yarnrc.yml @@ -0,0 +1,8 @@ +compressionLevel: mixed + +enableGlobalCache: false + +nodeLinker: node-modules + +# Allow lockfile modifications in CI +enableImmutableInstalls: false diff --git a/sdk/runanywhere-react-native/Docs/ARCHITECTURE.md b/sdk/runanywhere-react-native/Docs/ARCHITECTURE.md new file mode 100644 index 000000000..bceba3483 --- /dev/null +++ b/sdk/runanywhere-react-native/Docs/ARCHITECTURE.md @@ -0,0 +1,698 @@ +# RunAnywhere React Native SDK Architecture + +Overview of the RunAnywhere React Native SDK architecture, including system design, data flow, threading model, and native integration. + +--- + +## Table of Contents + +- [System Overview](#system-overview) +- [Multi-Package Structure](#multi-package-structure) +- [Layer Architecture](#layer-architecture) +- [Data Flow](#data-flow) +- [Threading & Performance](#threading--performance) +- [Native Integration](#native-integration) +- [Memory Management](#memory-management) +- [Offline & Resilience](#offline--resilience) +- [Security & Privacy](#security--privacy) +- [Event System](#event-system) +- [Error Handling](#error-handling) + +--- + +## System Overview + +The RunAnywhere React Native SDK is a modular, multi-package SDK for on-device AI in React Native applications. It provides: + +- **LLM Text Generation** via LlamaCPP (GGUF models) +- **Speech-to-Text** via ONNX Runtime (Whisper models) +- **Text-to-Speech** via ONNX Runtime (Piper TTS) +- **Voice Activity Detection** via Silero VAD + +### Design Principles + +1. **Modularity** — Only install what you need (core, llamacpp, onnx) +2. **Privacy-First** — All inference runs on-device by default +3. **Performance** — JSI/Nitro for synchronous native calls, C++ for inference +4. **Cross-Platform** — Single TypeScript API for iOS and Android +5. **Consistency** — API matches Swift and Kotlin SDKs + +--- + +## Multi-Package Structure + +The SDK is organized as a Yarn workspaces monorepo with three packages: + +``` +sdk/runanywhere-react-native/ +├── packages/ +│ ├── core/ # @runanywhere/core +│ │ ├── src/ # TypeScript source +│ │ │ ├── Public/ # RunAnywhere main API +│ │ │ ├── Foundation/ # Error types, logging, DI +│ │ │ ├── Infrastructure/ # Events, native bridge +│ │ │ ├── Features/ # Voice session, audio +│ │ │ ├── services/ # Model registry, download, network +│ │ │ └── types/ # TypeScript interfaces +│ │ ├── cpp/ # C++ HybridObject bridges +│ │ ├── ios/ # Swift native module +│ │ ├── android/ # Kotlin native module +│ │ └── nitrogen/ # Generated Nitro specs +│ │ +│ ├── llamacpp/ # @runanywhere/llamacpp +│ │ ├── src/ # LlamaCPP module wrapper +│ │ ├── cpp/ # LlamaCPP native bridge +│ │ ├── ios/ # iOS podspec, frameworks +│ │ └── android/ # Android gradle, jniLibs +│ │ +│ └── onnx/ # @runanywhere/onnx +│ ├── src/ # ONNX module wrapper +│ ├── cpp/ # ONNX native bridge +│ ├── ios/ # iOS podspec, frameworks +│ └── android/ # Android gradle, jniLibs +│ +├── scripts/ +│ └── build-react-native.sh # Build script for native binaries +│ +└── package.json # Root monorepo config +``` + +### Package Dependencies + +``` +@runanywhere/core (required) + ├── react-native-nitro-modules (JSI bridge) + ├── react-native-fs (file system) + ├── react-native-blob-util (downloads) + └── react-native-device-info (device metrics) + +@runanywhere/llamacpp + └── @runanywhere/core (peer dependency) + +@runanywhere/onnx + └── @runanywhere/core (peer dependency) +``` + +--- + +## Layer Architecture + +The SDK follows a layered architecture with clear separation of concerns: + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ LAYER 1: TypeScript API │ +│ ┌────────────────────────────────────────────────────────────────────┐ │ +│ │ RunAnywhere (Public API Singleton) │ │ +│ │ • initialize(), generate(), chat(), loadModel() │ │ +│ │ • transcribe(), transcribeFile() │ │ +│ │ • synthesize(), speak() │ │ +│ │ • Event subscriptions via EventBus │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌──────────────────────┐ │ +│ │ LlamaCPP │ │ ONNX │ │ EventBus │ │ ModelRegistry │ │ +│ │ (LLM) │ │ (STT/TTS) │ │ (Events) │ │ (Model Metadata) │ │ +│ └────────────┘ └────────────┘ └────────────┘ └──────────────────────┘ │ +├─────────────────────────────────────────────────────────────────────────┤ +│ LAYER 2: Service Layer │ +│ ┌────────────┐ ┌──────────────┐ ┌────────────────┐ ┌────────────────┐ │ +│ │ Download │ │ FileSystem │ │ HTTPService │ │ Telemetry │ │ +│ │ Service │ │ (react-native│ │ (axios-based) │ │ Service │ │ +│ │ │ │ -fs) │ │ │ │ │ │ +│ └────────────┘ └──────────────┘ └────────────────┘ └────────────────┘ │ +├─────────────────────────────────────────────────────────────────────────┤ +│ LAYER 3: Native Bridge (Nitro/JSI) │ +│ ┌────────────────────────────────────────────────────────────────────┐ │ +│ │ HybridRunAnywhereCore │ │ +│ │ • Nitrogen-generated C++ ↔ TypeScript bindings │ │ +│ │ • Synchronous JSI calls (no async bridge overhead) │ │ +│ │ • Direct memory sharing between JS and native │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌────────────────────┐ ┌─────────────────────────────┐ │ +│ │ HybridRunAnywhere │ │ HybridRunAnywhereONNX │ │ +│ │ Llama │ │ • STT inference │ │ +│ │ • LLM inference │ │ • TTS synthesis │ │ +│ │ • Token stream │ │ • VAD processing │ │ +│ └────────────────────┘ └─────────────────────────────┘ │ +├─────────────────────────────────────────────────────────────────────────┤ +│ LAYER 4: Native Code │ +│ │ +│ ┌─────────────────────────────┐ ┌─────────────────────────────────┐│ +│ │ iOS (Swift + Obj-C) │ │ Android (Kotlin + JNI) ││ +│ │ • PlatformAdapter │ │ • PlatformAdapter ││ +│ │ • KeychainManager │ │ • EncryptedSharedPreferences ││ +│ │ • SDKLogger │ │ • SDKLogger ││ +│ │ • AudioDecoder │ │ • AudioDecoder ││ +│ └─────────────────────────────┘ └─────────────────────────────────┘│ +├─────────────────────────────────────────────────────────────────────────┤ +│ LAYER 5: C++ Core (runanywhere-commons) │ +│ ┌────────────────────────────────────────────────────────────────────┐ │ +│ │ RACommons │ │ +│ │ • Model loading & management │ │ +│ │ • Device registration │ │ +│ │ • Telemetry collection │ │ +│ │ • Secure storage │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌────────────────────┐ ┌─────────────────────────────┐ │ +│ │ RABackendLLAMACPP │ │ RABackendONNX │ │ +│ │ • llama.cpp │ │ • sherpa-onnx │ │ +│ │ • GGUF loader │ │ • Whisper STT │ │ +│ │ • Token sampler │ │ • Piper TTS │ │ +│ └────────────────────┘ └─────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### Component Responsibilities + +| Component | Layer | Responsibility | +|-----------|-------|----------------| +| `RunAnywhere` | TypeScript | Public API singleton, state management | +| `EventBus` | TypeScript | Event subscription and dispatch | +| `ModelRegistry` | TypeScript | Model metadata and discovery | +| `LlamaCPP` | TypeScript | LLM module registration and model declaration | +| `ONNX` | TypeScript | STT/TTS module registration and model declaration | +| `DownloadService` | TypeScript | Model downloads with progress and resume | +| `FileSystem` | TypeScript | Cross-platform file operations | +| `HybridRunAnywhereCore` | Native Bridge | Core native bindings (Nitrogen/JSI) | +| `HybridRunAnywhereLlama` | Native Bridge | LLM inference bindings | +| `HybridRunAnywhereONNX` | Native Bridge | STT/TTS inference bindings | +| `RACommons` | C++ | Core infrastructure, registries | +| `RABackendLLAMACPP` | C++ | LLM inference engine | +| `RABackendONNX` | C++ | STT/TTS/VAD inference engine | + +--- + +## Data Flow + +### Text Generation Flow + +``` +User calls RunAnywhere.generate(prompt, options) + │ + ├─► [TypeScript] RunAnywhere+TextGeneration.ts + │ • Validates input, checks model loaded + │ • Builds generation config JSON + │ + ├─► [JSI Bridge] HybridRunAnywhereLlama + │ • Synchronous call to native + │ • Direct memory access (no serialization) + │ + ├─► [C++] RABackendLLAMACPP + │ • llama_model_load (if not loaded) + │ • llama_tokenize (prompt → tokens) + │ • llama_decode (inference) + │ • llama_sample (sampling loop) + │ + ├─► [Callback] Token streaming (optional) + │ • Each token sent via JSI callback + │ • UI updates in real-time + │ + └─► [Return] GenerationResult + • text: generated response + • tokensUsed: total tokens + • latencyMs: wall time + • performanceMetrics: tok/s, TTFT +``` + +### Speech-to-Text Flow + +``` +User calls RunAnywhere.transcribeFile(audioPath, options) + │ + ├─► [TypeScript] RunAnywhere+STT.ts + │ • Validates audio file exists + │ • Checks STT model loaded + │ + ├─► [JSI Bridge] HybridRunAnywhereONNX + │ • Reads audio file + │ • Decodes to float32 samples + │ + ├─► [C++] RABackendONNX (Sherpa-ONNX) + │ • Create recognizer + │ • Feed audio samples + │ • Get transcription result + │ + └─► [Return] STTResult + • text: transcription + • segments: word timestamps + • confidence: overall score +``` + +### Model Download Flow + +``` +User calls RunAnywhere.downloadModel(modelId, onProgress) + │ + ├─► [TypeScript] DownloadService.ts + │ • Creates download task + │ • Validates URL, checks storage + │ + ├─► [Native] react-native-blob-util + │ • HTTP GET with progress + │ • Background download support + │ • Resume capability + │ + ├─► [Callback] Progress updates + │ • bytesDownloaded, bytesTotal + │ • onProgress callback invoked + │ + ├─► [TypeScript] Archive extraction + │ • .tar.gz, .tar.bz2, .zip support + │ • Uses react-native-zip-archive + │ + └─► [TypeScript] ModelRegistry update + • localPath set + • isDownloaded = true +``` + +--- + +## Threading & Performance + +### JavaScript Thread + +- All TypeScript API calls originate here +- Event subscriptions and callbacks execute here +- UI updates must be dispatched to main thread (React handles this) + +### JSI Thread (Native) + +- Nitrogen/Nitro HybridObjects execute synchronously +- Direct memory access between JS and native +- No JSON serialization overhead +- Blocking calls should be avoided for long operations + +### Inference Thread (C++) + +- LLM inference runs on dedicated background thread +- Prevents blocking the JS thread +- Token streaming yields back to JS thread per token +- STT/TTS inference also runs on background thread + +### Thread Safety + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ JS Thread │ │ JSI Thread │ │ Inference Thread│ +│ (React) │────►│ (Nitro) │────►│ (C++) │ +│ │ │ │ │ │ +│ • UI updates │ │ • Native calls │ │ • Model loading │ +│ • Event handlers│ │ • Memory access │ │ • Token sampling│ +│ • State mgmt │ │ • Callbacks │ │ • Audio decode │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ +``` + +### Performance Optimizations + +1. **JSI/Nitro** — No async bridge overhead for native calls +2. **Streaming** — Tokens streamed in real-time, not batched +3. **Background threads** — Long operations don't block UI +4. **Model caching** — Models kept in memory between calls +5. **Lazy loading** — Download to disk, load on first use + +--- + +## Native Integration + +### iOS (Swift) + +``` +packages/core/ios/ +├── PlatformAdapter.swift # Platform abstraction +├── PlatformAdapterBridge.m # Obj-C bridge +├── KeychainManager.swift # Secure storage +├── SDKLogger.swift # Logging +├── AudioDecoder.m # Audio file decoding +├── ArchiveUtility.swift # Tar/zip extraction +└── Binaries/ + └── RACommons.xcframework # Core C++ framework + +packages/llamacpp/ios/ +└── Frameworks/ + └── RABackendLLAMACPP.xcframework + +packages/onnx/ios/ +└── Frameworks/ + ├── RABackendONNX.xcframework + └── onnxruntime.xcframework +``` + +### Android (Kotlin) + +``` +packages/core/android/ +├── src/main/java/.../ +│ ├── PlatformAdapter.kt # Platform abstraction +│ ├── KeychainManager.kt # EncryptedSharedPreferences +│ ├── SDKLogger.kt # Logging +│ └── AudioDecoder.kt # Audio file decoding +├── src/main/jniLibs/ +│ ├── arm64-v8a/ +│ │ ├── librac_commons.so +│ │ ├── librunanywhere_jni.so +│ │ └── libc++_shared.so +│ └── armeabi-v7a/ +│ └── ... + +packages/llamacpp/android/ +└── src/main/jniLibs/ + └── arm64-v8a/ + └── librunanywhere_llamacpp.so + +packages/onnx/android/ +└── src/main/jniLibs/ + └── arm64-v8a/ + ├── librunanywhere_onnx.so + └── libonnxruntime.so +``` + +### Nitrogen/Nitro Code Generation + +The native bridge uses Nitrogen (Nitro's code generator) to create C++ HybridObjects: + +``` +nitro.json → nitrogen generate → Generated bindings + +packages/core/nitrogen/generated/ +├── HybridRunAnywhereCoreSpec.hpp # C++ interface +├── HybridRunAnywhereCoreSpec.swift # Swift implementation +└── ... + +packages/llamacpp/nitrogen/generated/ +├── HybridRunAnywhereLlamaSpec.hpp +└── ... + +packages/onnx/nitrogen/generated/ +├── HybridRunAnywhereONNXSpec.hpp +└── ... +``` + +--- + +## Memory Management + +### Model Memory + +- **LLM models**: 500MB–8GB depending on size/quantization +- **STT models**: 50–200MB for Whisper +- **TTS voices**: 20–100MB for Piper + +### Memory Lifecycle + +``` +1. Download → Model saved to disk (app Documents directory) +2. loadModel() → Model loaded into RAM (C++ heap) +3. generate() → Context window allocated +4. unloadModel() → Model freed from RAM +5. deleteModel() → Model removed from disk +``` + +### Automatic Memory Management + +- Models are reference-counted in C++ +- Unload on app backgrounding (iOS) +- Low-memory warnings trigger cleanup +- React Native lifecycle callbacks integrated + +### Best Practices + +```typescript +// Unload when not needed +await RunAnywhere.unloadModel(); + +// Check available memory before loading large models +const storage = await RunAnywhere.getStorageInfo(); +const modelInfo = await RunAnywhere.getModelInfo(modelId); + +if (storage.freeSpace > (modelInfo?.memoryRequired ?? 0)) { + await RunAnywhere.loadModel(modelInfo.localPath); +} +``` + +--- + +## Offline & Resilience + +### Offline Capabilities + +| Feature | Works Offline | Notes | +|---------|---------------|-------| +| LLM Generation | Yes | Requires downloaded model | +| Speech-to-Text | Yes | Requires downloaded model | +| Text-to-Speech | Yes | Requires downloaded model | +| Model Download | No | Requires network | +| Device Registration | No | Queued for when online | +| Telemetry | No | Buffered, sent when online | + +### Download Resilience + +- **Resume support**: Downloads can be resumed after interruption +- **Retry logic**: Automatic retry on transient failures +- **Storage check**: Validates free space before download +- **Extraction**: Archives extracted in temp dir, moved on success + +### Network Recovery + +``` +Network unavailable → Download paused +Network restored → Download resumes automatically +App backgrounded → Download continues (platform-dependent) +App terminated → Download state persisted, resume on launch +``` + +--- + +## Security & Privacy + +### Data Privacy + +1. **On-Device Processing**: All inference runs locally +2. **No Data Upload**: Prompts and responses never leave device +3. **Optional Telemetry**: Only anonymous metrics (latency, errors) +4. **User Control**: Telemetry can be disabled + +### Secure Storage + +| Platform | Implementation | Used For | +|----------|----------------|----------| +| iOS | Keychain Services | Device ID, API tokens | +| Android | EncryptedSharedPreferences | Device ID, API tokens | + +### API Authentication (Production) + +```typescript +// Production mode authentication flow +await RunAnywhere.initialize({ + apiKey: '', + environment: SDKEnvironment.Production, +}); + +// SDK authenticates with backend +// JWT tokens stored securely +// Tokens refreshed automatically +``` + +### Model Integrity + +- Models downloaded over HTTPS +- SHA256 checksum verification (optional) +- Models stored in app sandbox (not accessible by other apps) + +--- + +## Event System + +### EventBus Architecture + +The SDK uses a publish-subscribe pattern for events: + +```typescript +// Event categories +enum EventCategory { + Initialization = 'initialization', + Generation = 'generation', + Model = 'model', + Voice = 'voice', + Storage = 'storage', + Network = 'network', + Error = 'error', +} + +// Subscribe to events +const unsubscribe = EventBus.on('Generation', (event) => { + // Handle event +}); + +// Publish events (internal) +EventBus.publish('Generation', { type: 'started', ... }); +``` + +### Event Types + +| Category | Events | +|----------|--------| +| Initialization | `started`, `completed`, `failed` | +| Generation | `started`, `tokenGenerated`, `completed`, `failed`, `cancelled` | +| Model | `downloadStarted`, `downloadProgress`, `downloadCompleted`, `loadStarted`, `loadCompleted` | +| Voice | `sttStarted`, `sttCompleted`, `ttsStarted`, `ttsCompleted` | +| Storage | `cleared`, `modelDeleted` | + +### Native Event Bridge + +Native events are forwarded to TypeScript via NativeEventEmitter: + +``` +C++ Event → Native Bridge → NativeEventEmitter → EventBus → Subscribers +``` + +--- + +## Error Handling + +### SDKError Structure + +```typescript +interface SDKError extends Error { + code: SDKErrorCode; // Unique error code + category: ErrorCategory; // Error category + underlyingError?: Error; // Original error (if wrapped) + context?: ErrorContext; // Additional context + recoverySuggestion?: string; // How to fix +} +``` + +### Error Codes + +| Code | Category | Description | +|------|----------|-------------| +| `notInitialized` | General | SDK not initialized | +| `alreadyInitialized` | General | SDK already initialized | +| `invalidInput` | General | Invalid input parameters | +| `modelNotFound` | Model | Model not found in registry | +| `modelLoadFailed` | Model | Failed to load model | +| `modelNotLoaded` | Model | Model not loaded into memory | +| `downloadFailed` | Download | Model download failed | +| `insufficientStorage` | Storage | Not enough disk space | +| `insufficientMemory` | Memory | Not enough RAM | +| `generationFailed` | LLM | Text generation failed | +| `sttFailed` | STT | Speech transcription failed | +| `ttsFailed` | TTS | Speech synthesis failed | +| `networkUnavailable` | Network | No network connection | +| `authenticationFailed` | Auth | Invalid API key | + +### Error Recovery + +```typescript +try { + await RunAnywhere.generate(prompt); +} catch (error) { + if (isSDKError(error)) { + switch (error.code) { + case SDKErrorCode.modelNotLoaded: + // Load model and retry + await RunAnywhere.loadModel(modelPath); + return RunAnywhere.generate(prompt); + + case SDKErrorCode.insufficientMemory: + // Unload unused models and retry + await RunAnywhere.unloadModel(); + return RunAnywhere.generate(prompt); + + default: + throw error; + } + } +} +``` + +--- + +## Appendix: File Structure Reference + +### Core Package (`@runanywhere/core`) + +``` +packages/core/src/ +├── Public/ +│ ├── RunAnywhere.ts # Main API singleton +│ ├── Events/ +│ │ └── EventBus.ts # Event pub/sub +│ └── Extensions/ +│ ├── RunAnywhere+TextGeneration.ts +│ ├── RunAnywhere+STT.ts +│ ├── RunAnywhere+TTS.ts +│ ├── RunAnywhere+VAD.ts +│ ├── RunAnywhere+Models.ts +│ ├── RunAnywhere+Storage.ts +│ ├── RunAnywhere+VoiceAgent.ts +│ ├── RunAnywhere+VoiceSession.ts +│ ├── RunAnywhere+StructuredOutput.ts +│ └── RunAnywhere+Logging.ts +├── Foundation/ +│ ├── ErrorTypes/ # SDKError, error codes +│ ├── Initialization/ # Init state machine +│ ├── Security/ # Secure storage, device ID +│ ├── Logging/ # SDKLogger, log levels +│ ├── DependencyInjection/ # ServiceRegistry, ServiceContainer +│ └── Constants/ # SDK constants +├── Infrastructure/ +│ └── Events/ # Event publishing internals +├── Features/ +│ └── VoiceSession/ # Voice session management +├── services/ +│ ├── ModelRegistry.ts # Model metadata store +│ ├── DownloadService.ts # Model downloads +│ ├── FileSystem.ts # File operations +│ ├── SystemTTSService.ts # Platform TTS +│ └── Network/ +│ ├── HTTPService.ts # HTTP client +│ ├── TelemetryService.ts # Analytics +│ └── APIEndpoints.ts # API routes +├── types/ +│ ├── enums.ts # SDK enumerations +│ ├── models.ts # Data interfaces +│ ├── events.ts # Event types +│ ├── LLMTypes.ts # LLM-specific types +│ ├── STTTypes.ts # STT-specific types +│ ├── TTSTypes.ts # TTS-specific types +│ └── VADTypes.ts # VAD-specific types +└── native/ + └── NativeRunAnywhereCore.ts # Native module accessor +``` + +### LlamaCPP Package (`@runanywhere/llamacpp`) + +``` +packages/llamacpp/src/ +├── index.ts # Package exports +├── LlamaCPP.ts # Module API (register, addModel) +├── LlamaCppProvider.ts # Backend provider registration +├── native/ +│ └── NativeRunAnywhereLlama.ts # Native module accessor +└── specs/ + └── RunAnywhereLlama.nitro.ts # Nitrogen spec +``` + +### ONNX Package (`@runanywhere/onnx`) + +``` +packages/onnx/src/ +├── index.ts # Package exports +├── ONNX.ts # Module API (register, addModel) +├── ONNXProvider.ts # Backend provider registration +├── native/ +│ └── NativeRunAnywhereONNX.ts # Native module accessor +└── specs/ + └── RunAnywhereONNX.nitro.ts # Nitrogen spec +``` + +--- + +## Revision History + +| Version | Date | Changes | +|---------|------|---------| +| 1.0 | 2026-01-14 | Initial architecture document | diff --git a/sdk/runanywhere-react-native/Docs/Documentation.md b/sdk/runanywhere-react-native/Docs/Documentation.md new file mode 100644 index 000000000..5c66dae62 --- /dev/null +++ b/sdk/runanywhere-react-native/Docs/Documentation.md @@ -0,0 +1,1557 @@ +# RunAnywhere React Native SDK - API Reference + +API documentation for the RunAnywhere React Native SDK. + +--- + +## Table of Contents + +- [Core API](#core-api) + - [RunAnywhere](#runanywhere) + - [Initialization](#initialization) + - [Text Generation](#text-generation) + - [Speech-to-Text](#speech-to-text) + - [Text-to-Speech](#text-to-speech) + - [Voice Activity Detection](#voice-activity-detection) + - [Voice Agent](#voice-agent) + - [Model Management](#model-management) + - [Storage Management](#storage-management) + - [Events](#events) +- [LlamaCPP Module](#llamacpp-module) +- [ONNX Module](#onnx-module) +- [Types Reference](#types-reference) +- [Error Reference](#error-reference) + +--- + +## Core API + +### RunAnywhere + +The main SDK singleton providing all public methods. + +```typescript +import { RunAnywhere } from '@runanywhere/core'; +``` + +#### Properties + +| Property | Type | Description | +|----------|------|-------------| +| `isSDKInitialized` | `boolean` | Whether SDK core is initialized | +| `areServicesReady` | `boolean` | Whether all services are ready | +| `currentEnvironment` | `SDKEnvironment \| null` | Current SDK environment | +| `version` | `string` | SDK version string | +| `deviceId` | `string` | Cached device ID (sync) | +| `events` | `EventBus` | Event subscription system | + +--- + +### Initialization + +#### `RunAnywhere.initialize(options)` + +Initialize the SDK. Must be called before any other API. + +```typescript +await RunAnywhere.initialize(options: SDKInitOptions): Promise +``` + +**Parameters:** + +```typescript +interface SDKInitOptions { + /** API key for authentication (production/staging) */ + apiKey?: string; + + /** Base URL for API requests */ + baseURL?: string; + + /** SDK environment */ + environment?: SDKEnvironment; + + /** Supabase project URL (development mode only) */ + supabaseURL?: string; + + /** Supabase anon key (development mode only) */ + supabaseKey?: string; + + /** Enable debug logging */ + debug?: boolean; +} + +enum SDKEnvironment { + Development = 'development', + Staging = 'staging', + Production = 'production', +} +``` + +**Returns:** `Promise` - Resolves when initialization is complete + +**Throws:** `SDKError` with codes: +- `alreadyInitialized` - SDK already initialized +- `nativeModuleNotAvailable` - Native module not linked +- `authenticationFailed` - Invalid API key (production) + +**Example:** + +```typescript +// Development mode (no API key needed) +await RunAnywhere.initialize({ + environment: SDKEnvironment.Development, +}); + +// Production mode +await RunAnywhere.initialize({ + apiKey: 'your-api-key', + baseURL: 'https://api.runanywhere.ai', + environment: SDKEnvironment.Production, +}); +``` + +--- + +#### `RunAnywhere.isInitialized()` + +Check if the SDK is initialized. + +```typescript +await RunAnywhere.isInitialized(): Promise +``` + +**Returns:** `Promise` - `true` if SDK is initialized + +**Example:** + +```typescript +const isReady = await RunAnywhere.isInitialized(); +if (!isReady) { + await RunAnywhere.initialize({ ... }); +} +``` + +--- + +#### `RunAnywhere.reset()` + +Reset the SDK to uninitialized state. Unloads all models and clears state. + +```typescript +await RunAnywhere.reset(): Promise +``` + +**Example:** + +```typescript +await RunAnywhere.reset(); +// SDK can now be reinitialized with different options +``` + +--- + +#### `RunAnywhere.destroy()` + +Destroy the SDK instance. Same as `reset()`. + +```typescript +await RunAnywhere.destroy(): Promise +``` + +--- + +### Text Generation + +#### `RunAnywhere.loadModel(modelPath)` + +Load an LLM model into memory. + +```typescript +await RunAnywhere.loadModel(modelPath: string): Promise +``` + +**Parameters:** +- `modelPath` - Absolute path to the model file (.gguf) + +**Returns:** `Promise` - `true` if model loaded successfully + +**Throws:** `SDKError` with codes: +- `notInitialized` - SDK not initialized +- `modelNotFound` - Model file not found at path +- `modelLoadFailed` - Failed to load model (memory, format, etc.) + +**Example:** + +```typescript +const modelInfo = await RunAnywhere.getModelInfo('smollm2-360m-q8_0'); +if (modelInfo?.localPath) { + await RunAnywhere.loadModel(modelInfo.localPath); +} +``` + +--- + +#### `RunAnywhere.isModelLoaded()` + +Check if an LLM model is currently loaded. + +```typescript +await RunAnywhere.isModelLoaded(): Promise +``` + +**Returns:** `Promise` - `true` if a model is loaded + +--- + +#### `RunAnywhere.unloadModel()` + +Unload the currently loaded LLM model from memory. + +```typescript +await RunAnywhere.unloadModel(): Promise +``` + +**Example:** + +```typescript +// Free memory when done +await RunAnywhere.unloadModel(); +``` + +--- + +#### `RunAnywhere.chat(prompt)` + +Simple chat interface. Returns just the response text. + +```typescript +await RunAnywhere.chat(prompt: string): Promise +``` + +**Parameters:** +- `prompt` - The user's message + +**Returns:** `Promise` - The generated response text + +**Throws:** `SDKError` with codes: +- `notInitialized` - SDK not initialized +- `modelNotLoaded` - No model loaded +- `generationFailed` - Generation failed + +**Example:** + +```typescript +const response = await RunAnywhere.chat('What is the capital of France?'); +console.log(response); +``` + +--- + +#### `RunAnywhere.generate(prompt, options?)` + +Generate text with full options and metrics. + +```typescript +await RunAnywhere.generate( + prompt: string, + options?: GenerationOptions +): Promise +``` + +**Parameters:** + +```typescript +interface GenerationOptions { + /** Maximum number of tokens to generate (default: 256) */ + maxTokens?: number; + + /** Temperature for sampling (0.0 - 2.0, default: 0.7) */ + temperature?: number; + + /** Top-p sampling parameter (default: 0.95) */ + topP?: number; + + /** Stop sequences - stop generation when encountered */ + stopSequences?: string[]; + + /** System prompt to define AI behavior */ + systemPrompt?: string; + + /** Preferred execution target */ + preferredExecutionTarget?: ExecutionTarget; + + /** Preferred framework for generation */ + preferredFramework?: LLMFramework; +} +``` + +**Returns:** + +```typescript +interface GenerationResult { + /** Generated text (with thinking content removed if extracted) */ + text: string; + + /** Thinking/reasoning content extracted from the response */ + thinkingContent?: string; + + /** Number of tokens used */ + tokensUsed: number; + + /** Model used for generation */ + modelUsed: string; + + /** Latency in milliseconds */ + latencyMs: number; + + /** Execution target (device/cloud/hybrid) */ + executionTarget: ExecutionTarget; + + /** Framework used for generation */ + framework?: LLMFramework; + + /** Hardware acceleration used */ + hardwareUsed: HardwareAcceleration; + + /** Memory used during generation (bytes) */ + memoryUsed: number; + + /** Detailed performance metrics */ + performanceMetrics: PerformanceMetrics; + + /** Number of tokens in the response content */ + responseTokens: number; +} + +interface PerformanceMetrics { + /** Time to first token in milliseconds */ + timeToFirstTokenMs?: number; + + /** Tokens generated per second */ + tokensPerSecond?: number; + + /** Total inference time in milliseconds */ + inferenceTimeMs: number; +} +``` + +**Example:** + +```typescript +const result = await RunAnywhere.generate( + 'Explain quantum computing in simple terms', + { + maxTokens: 200, + temperature: 0.7, + systemPrompt: 'You are a helpful science teacher.', + } +); + +console.log('Response:', result.text); +console.log('Tokens:', result.tokensUsed); +console.log('Speed:', result.performanceMetrics.tokensPerSecond, 'tok/s'); +console.log('TTFT:', result.performanceMetrics.timeToFirstTokenMs, 'ms'); +``` + +--- + +#### `RunAnywhere.generateStream(prompt, options?)` + +Generate text with token-by-token streaming. + +```typescript +await RunAnywhere.generateStream( + prompt: string, + options?: GenerationOptions +): Promise +``` + +**Returns:** + +```typescript +interface LLMStreamingResult { + /** Async iterator for tokens */ + stream: AsyncIterable; + + /** Promise resolving to final result with metrics */ + result: Promise; +} +``` + +**Example:** + +```typescript +const streamResult = await RunAnywhere.generateStream( + 'Write a poem about AI', + { maxTokens: 150 } +); + +// Display tokens in real-time +let fullText = ''; +for await (const token of streamResult.stream) { + fullText += token; + console.log(token); // Each token as it's generated +} + +// Get final metrics +const finalResult = await streamResult.result; +console.log('Speed:', finalResult.performanceMetrics.tokensPerSecond, 'tok/s'); +``` + +--- + +#### `RunAnywhere.cancelGeneration()` + +Cancel an ongoing generation. + +```typescript +await RunAnywhere.cancelGeneration(): Promise +``` + +--- + +### Speech-to-Text + +#### `RunAnywhere.loadSTTModel(modelPath, modelType?)` + +Load a Speech-to-Text model. + +```typescript +await RunAnywhere.loadSTTModel( + modelPath: string, + modelType?: string +): Promise +``` + +**Parameters:** +- `modelPath` - Path to the STT model directory +- `modelType` - Model type identifier (default: 'whisper') + +**Returns:** `Promise` - `true` if loaded successfully + +**Example:** + +```typescript +const sttModel = await RunAnywhere.getModelInfo('sherpa-onnx-whisper-tiny.en'); +await RunAnywhere.loadSTTModel(sttModel.localPath, 'whisper'); +``` + +--- + +#### `RunAnywhere.isSTTModelLoaded()` + +Check if an STT model is loaded. + +```typescript +await RunAnywhere.isSTTModelLoaded(): Promise +``` + +--- + +#### `RunAnywhere.unloadSTTModel()` + +Unload the currently loaded STT model. + +```typescript +await RunAnywhere.unloadSTTModel(): Promise +``` + +--- + +#### `RunAnywhere.transcribeFile(audioPath, options?)` + +Transcribe an audio file. + +```typescript +await RunAnywhere.transcribeFile( + audioPath: string, + options?: STTOptions +): Promise +``` + +**Parameters:** + +```typescript +interface STTOptions { + /** Language code (e.g., 'en', 'es') */ + language?: string; + + /** Enable punctuation */ + punctuation?: boolean; + + /** Enable speaker diarization */ + diarization?: boolean; + + /** Enable word timestamps */ + wordTimestamps?: boolean; + + /** Sample rate */ + sampleRate?: number; +} +``` + +**Returns:** + +```typescript +interface STTResult { + /** Main transcription text */ + text: string; + + /** Segments with timing */ + segments: STTSegment[]; + + /** Detected language */ + language?: string; + + /** Overall confidence (0.0 - 1.0) */ + confidence: number; + + /** Duration in seconds */ + duration: number; + + /** Alternative transcriptions */ + alternatives: STTAlternative[]; +} + +interface STTSegment { + text: string; + startTime: number; + endTime: number; + speakerId?: string; + confidence: number; +} +``` + +**Example:** + +```typescript +const result = await RunAnywhere.transcribeFile(audioFilePath, { + language: 'en', + wordTimestamps: true, +}); + +console.log('Text:', result.text); +console.log('Confidence:', result.confidence); +console.log('Duration:', result.duration, 'seconds'); +``` + +--- + +#### `RunAnywhere.transcribe(audioData, options?)` + +Transcribe raw audio data (base64-encoded). + +```typescript +await RunAnywhere.transcribe( + audioData: string, + options?: STTOptions +): Promise +``` + +**Parameters:** +- `audioData` - Base64-encoded audio data (float32 PCM) + +--- + +#### `RunAnywhere.transcribeBuffer(samples, sampleRate, options?)` + +Transcribe float32 audio samples. + +```typescript +await RunAnywhere.transcribeBuffer( + samples: number[], + sampleRate: number, + options?: STTOptions +): Promise +``` + +--- + +### Text-to-Speech + +#### `RunAnywhere.loadTTSModel(modelPath, modelType?)` + +Load a Text-to-Speech model. + +```typescript +await RunAnywhere.loadTTSModel( + modelPath: string, + modelType?: string +): Promise +``` + +**Parameters:** +- `modelPath` - Path to the TTS model directory +- `modelType` - Model type identifier (default: 'piper') + +**Example:** + +```typescript +const ttsModel = await RunAnywhere.getModelInfo('vits-piper-en_US-lessac-medium'); +await RunAnywhere.loadTTSModel(ttsModel.localPath, 'piper'); +``` + +--- + +#### `RunAnywhere.loadTTSVoice(voiceId)` + +Load a specific TTS voice. + +```typescript +await RunAnywhere.loadTTSVoice(voiceId: string): Promise +``` + +--- + +#### `RunAnywhere.isTTSModelLoaded()` + +Check if a TTS model is loaded. + +```typescript +await RunAnywhere.isTTSModelLoaded(): Promise +``` + +--- + +#### `RunAnywhere.unloadTTSModel()` + +Unload the currently loaded TTS model. + +```typescript +await RunAnywhere.unloadTTSModel(): Promise +``` + +--- + +#### `RunAnywhere.synthesize(text, options?)` + +Synthesize speech from text. + +```typescript +await RunAnywhere.synthesize( + text: string, + options?: TTSConfiguration +): Promise +``` + +**Parameters:** + +```typescript +interface TTSConfiguration { + /** Voice identifier */ + voice?: string; + + /** Speech rate (0.5 - 2.0, default: 1.0) */ + rate?: number; + + /** Pitch (0.5 - 2.0, default: 1.0) */ + pitch?: number; + + /** Volume (0.0 - 1.0, default: 1.0) */ + volume?: number; +} +``` + +**Returns:** + +```typescript +interface TTSResult { + /** Base64-encoded audio data (float32 PCM) */ + audio: string; + + /** Sample rate of the audio */ + sampleRate: number; + + /** Number of samples */ + numSamples: number; + + /** Duration in seconds */ + duration: number; +} +``` + +**Example:** + +```typescript +const result = await RunAnywhere.synthesize( + 'Hello from the SDK.', + { rate: 1.0, pitch: 1.0, volume: 0.8 } +); + +console.log('Duration:', result.duration, 'seconds'); +console.log('Sample rate:', result.sampleRate); +// result.audio contains base64-encoded float32 PCM +``` + +--- + +#### `RunAnywhere.synthesizeStream(text, options?, callback?)` + +Synthesize speech with streaming chunks. + +```typescript +await RunAnywhere.synthesizeStream( + text: string, + options?: TTSConfiguration, + callback?: (chunk: TTSOutput) => void +): Promise +``` + +--- + +#### `RunAnywhere.speak(text, options?)` + +Speak text using system TTS (AVSpeechSynthesizer / Android TTS). + +```typescript +await RunAnywhere.speak( + text: string, + options?: TTSConfiguration +): Promise +``` + +--- + +#### `RunAnywhere.stopSpeaking()` + +Stop current speech synthesis. + +```typescript +await RunAnywhere.stopSpeaking(): Promise +``` + +--- + +#### `RunAnywhere.isSpeaking()` + +Check if currently speaking. + +```typescript +await RunAnywhere.isSpeaking(): Promise +``` + +--- + +#### `RunAnywhere.availableTTSVoices()` + +Get list of available TTS voices. + +```typescript +await RunAnywhere.availableTTSVoices(): Promise +``` + +--- + +### Voice Activity Detection + +#### `RunAnywhere.initializeVAD(config?)` + +Initialize Voice Activity Detection. + +```typescript +await RunAnywhere.initializeVAD( + config?: VADConfiguration +): Promise +``` + +**Parameters:** + +```typescript +interface VADConfiguration { + /** Energy threshold for speech detection */ + energyThreshold?: number; + + /** Sample rate */ + sampleRate?: number; + + /** Frame length in milliseconds */ + frameLength?: number; + + /** Enable auto calibration */ + autoCalibration?: boolean; +} +``` + +--- + +#### `RunAnywhere.loadVADModel(modelPath)` + +Load a VAD model. + +```typescript +await RunAnywhere.loadVADModel(modelPath: string): Promise +``` + +--- + +#### `RunAnywhere.isVADModelLoaded()` + +Check if VAD model is loaded. + +```typescript +await RunAnywhere.isVADModelLoaded(): Promise +``` + +--- + +#### `RunAnywhere.processVAD(audioSamples)` + +Process audio samples for voice activity. + +```typescript +await RunAnywhere.processVAD( + audioSamples: number[] +): Promise +``` + +**Returns:** + +```typescript +interface VADResult { + /** Whether speech is detected */ + isSpeech: boolean; + + /** Confidence score (0.0 - 1.0) */ + confidence: number; + + /** Start time of speech segment */ + startTime?: number; + + /** End time of speech segment */ + endTime?: number; +} +``` + +--- + +#### `RunAnywhere.startVAD()` + +Start continuous VAD processing. + +```typescript +await RunAnywhere.startVAD(): Promise +``` + +--- + +#### `RunAnywhere.stopVAD()` + +Stop continuous VAD processing. + +```typescript +await RunAnywhere.stopVAD(): Promise +``` + +--- + +#### `RunAnywhere.setVADSpeechActivityCallback(callback)` + +Set callback for speech activity events. + +```typescript +RunAnywhere.setVADSpeechActivityCallback( + callback: (event: SpeechActivityEvent) => void +): void +``` + +--- + +### Voice Agent + +#### `RunAnywhere.initializeVoiceAgent(config)` + +Initialize the voice agent pipeline (VAD → STT → LLM → TTS). + +```typescript +await RunAnywhere.initializeVoiceAgent( + config: VoiceAgentConfig +): Promise +``` + +**Parameters:** + +```typescript +interface VoiceAgentConfig { + /** LLM model ID */ + llmModelId: string; + + /** STT model ID */ + sttModelId: string; + + /** TTS model ID */ + ttsModelId: string; + + /** System prompt for LLM */ + systemPrompt?: string; + + /** Generation options */ + generationOptions?: GenerationOptions; +} +``` + +--- + +#### `RunAnywhere.processVoiceTurn(audioData)` + +Process a complete voice turn (STT → LLM → TTS). + +```typescript +await RunAnywhere.processVoiceTurn( + audioData: string +): Promise +``` + +**Returns:** + +```typescript +interface VoiceTurnResult { + /** Transcribed user speech */ + userTranscript: string; + + /** LLM response text */ + assistantResponse: string; + + /** Synthesized audio (base64) */ + audio: string; + + /** Performance metrics */ + metrics: VoiceAgentMetrics; +} +``` + +--- + +#### `RunAnywhere.startVoiceSession(config, callback)` + +Start an interactive voice session. + +```typescript +await RunAnywhere.startVoiceSession( + config: VoiceSessionConfig, + callback: (event: VoiceSessionEvent) => void +): Promise +``` + +--- + +### Model Management + +#### `RunAnywhere.getAvailableModels()` + +Get list of all available models (registered + downloaded). + +```typescript +await RunAnywhere.getAvailableModels(): Promise +``` + +**Returns:** Array of `ModelInfo` objects + +**Example:** + +```typescript +const models = await RunAnywhere.getAvailableModels(); +const llmModels = models.filter(m => m.category === ModelCategory.Language); +const downloadedModels = models.filter(m => m.isDownloaded); +``` + +--- + +#### `RunAnywhere.getModelInfo(modelId)` + +Get information about a specific model. + +```typescript +await RunAnywhere.getModelInfo(modelId: string): Promise +``` + +**Returns:** `ModelInfo` or `null` if not found + +--- + +#### `RunAnywhere.getDownloadedModels()` + +Get list of downloaded model IDs. + +```typescript +await RunAnywhere.getDownloadedModels(): Promise +``` + +--- + +#### `RunAnywhere.isModelDownloaded(modelId)` + +Check if a model is downloaded. + +```typescript +await RunAnywhere.isModelDownloaded(modelId: string): Promise +``` + +--- + +#### `RunAnywhere.downloadModel(modelId, onProgress?)` + +Download a model with progress tracking. + +```typescript +await RunAnywhere.downloadModel( + modelId: string, + onProgress?: (progress: DownloadProgress) => void +): Promise +``` + +**Parameters:** + +```typescript +interface DownloadProgress { + /** Model ID */ + modelId: string; + + /** Progress (0.0 - 1.0) */ + progress: number; + + /** Bytes downloaded */ + bytesDownloaded: number; + + /** Total bytes */ + bytesTotal: number; + + /** Download state */ + state: DownloadState; +} + +enum DownloadState { + Queued = 'queued', + Downloading = 'downloading', + Extracting = 'extracting', + Completed = 'completed', + Failed = 'failed', + Cancelled = 'cancelled', +} +``` + +**Example:** + +```typescript +await RunAnywhere.downloadModel('smollm2-360m-q8_0', (progress) => { + const percent = (progress.progress * 100).toFixed(1); + console.log(`Downloading: ${percent}%`); + + if (progress.state === DownloadState.Extracting) { + console.log('Extracting archive...'); + } +}); +``` + +--- + +#### `RunAnywhere.cancelDownload(modelId)` + +Cancel an ongoing download. + +```typescript +await RunAnywhere.cancelDownload(modelId: string): Promise +``` + +--- + +#### `RunAnywhere.deleteModel(modelId)` + +Delete a downloaded model from disk. + +```typescript +await RunAnywhere.deleteModel(modelId: string): Promise +``` + +--- + +### Storage Management + +#### `RunAnywhere.getStorageInfo()` + +Get storage usage information. + +```typescript +await RunAnywhere.getStorageInfo(): Promise +``` + +**Returns:** + +```typescript +interface StorageInfo { + /** Total storage available (bytes) */ + totalSpace: number; + + /** Storage used by SDK (bytes) */ + usedSpace: number; + + /** Free space available (bytes) */ + freeSpace: number; + + /** Models storage path */ + modelsPath: string; +} +``` + +--- + +#### `RunAnywhere.clearCache()` + +Clear SDK cache files. + +```typescript +await RunAnywhere.clearCache(): Promise +``` + +--- + +#### `RunAnywhere.cleanTempFiles()` + +Clean temporary files. + +```typescript +await RunAnywhere.cleanTempFiles(): Promise +``` + +--- + +### Events + +#### `EventBus` + +The SDK event system for subscribing to SDK events. + +```typescript +import { EventBus } from '@runanywhere/core'; + +// Or via RunAnywhere +RunAnywhere.events +``` + +#### Event Subscription Methods + +```typescript +// Subscribe to all events +const unsubscribe = EventBus.on( + category: EventCategory, + callback: (event: SDKEvent) => void +): () => void + +// Shorthand methods +EventBus.onInitialization(callback) +EventBus.onGeneration(callback) +EventBus.onModel(callback) +EventBus.onVoice(callback) +EventBus.onStorage(callback) +EventBus.onError(callback) +``` + +#### Event Types + +```typescript +// Initialization Events +interface SDKInitializationEvent { + type: 'started' | 'completed' | 'failed'; + error?: string; +} + +// Generation Events +interface SDKGenerationEvent { + type: 'started' | 'tokenGenerated' | 'completed' | 'failed' | 'cancelled'; + token?: string; + response?: GenerationResult; + error?: string; +} + +// Model Events +interface SDKModelEvent { + type: 'downloadStarted' | 'downloadProgress' | 'downloadCompleted' | + 'downloadFailed' | 'loadStarted' | 'loadCompleted' | 'unloaded'; + modelId: string; + progress?: number; + error?: string; +} + +// Voice Events +interface SDKVoiceEvent { + type: 'sttStarted' | 'sttCompleted' | 'ttsStarted' | 'ttsCompleted' | + 'vadSpeechStarted' | 'vadSpeechEnded'; + result?: STTResult | TTSResult; +} +``` + +**Example:** + +```typescript +// Subscribe to generation events +const unsubscribe = RunAnywhere.events.onGeneration((event) => { + switch (event.type) { + case 'started': + console.log('Generation started'); + break; + case 'tokenGenerated': + process.stdout.write(event.token); + break; + case 'completed': + console.log('\nDone!', event.response.tokensUsed, 'tokens'); + break; + case 'failed': + console.error('Error:', event.error); + break; + } +}); + +// Later: unsubscribe +unsubscribe(); +``` + +--- + +## LlamaCPP Module + +```typescript +import { LlamaCPP, LlamaCppProvider } from '@runanywhere/llamacpp'; +``` + +### `LlamaCPP.register()` + +Register the LlamaCPP backend with the SDK. + +```typescript +LlamaCPP.register(): void +``` + +**Example:** + +```typescript +// Register after SDK initialization +await RunAnywhere.initialize({ ... }); +LlamaCPP.register(); +``` + +--- + +### `LlamaCPP.addModel(options)` + +Add a GGUF model to the registry. + +```typescript +await LlamaCPP.addModel(options: LlamaCPPModelOptions): Promise +``` + +**Parameters:** + +```typescript +interface LlamaCPPModelOptions { + /** Unique model ID. If not provided, generated from URL filename */ + id?: string; + + /** Display name for the model */ + name: string; + + /** Download URL for the model */ + url: string; + + /** Model category (defaults to Language) */ + modality?: ModelCategory; + + /** Memory requirement in bytes */ + memoryRequirement?: number; + + /** Whether model supports reasoning/thinking tokens */ + supportsThinking?: boolean; +} +``` + +**Example:** + +```typescript +await LlamaCPP.addModel({ + id: 'smollm2-360m-q8_0', + name: 'SmolLM2 360M Q8_0', + url: 'https://huggingface.co/prithivMLmods/SmolLM2-360M-GGUF/resolve/main/SmolLM2-360M.Q8_0.gguf', + memoryRequirement: 500_000_000, +}); + +// Model with thinking support +await LlamaCPP.addModel({ + id: 'qwen2.5-0.5b-instruct', + name: 'Qwen 2.5 0.5B Instruct', + url: 'https://huggingface.co/.../qwen2.5-0.5b-instruct-q6_k.gguf', + memoryRequirement: 600_000_000, + supportsThinking: true, +}); +``` + +--- + +### `LlamaCppProvider.register()` + +Lower-level registration with ServiceRegistry. + +```typescript +LlamaCppProvider.register(): void +``` + +--- + +## ONNX Module + +```typescript +import { ONNX, ONNXProvider, ModelArtifactType } from '@runanywhere/onnx'; +``` + +### `ONNX.register()` + +Register the ONNX backend with the SDK. + +```typescript +ONNX.register(): void +``` + +--- + +### `ONNX.addModel(options)` + +Add an ONNX model (STT or TTS) to the registry. + +```typescript +await ONNX.addModel(options: ONNXModelOptions): Promise +``` + +**Parameters:** + +```typescript +interface ONNXModelOptions { + /** Unique model ID. If not provided, generated from URL filename */ + id?: string; + + /** Display name for the model */ + name: string; + + /** Download URL for the model */ + url: string; + + /** Model category (SpeechRecognition or SpeechSynthesis) */ + modality: ModelCategory; + + /** How the model is packaged */ + artifactType?: ModelArtifactType; + + /** Memory requirement in bytes */ + memoryRequirement?: number; +} + +enum ModelArtifactType { + SingleFile = 'singleFile', + TarGzArchive = 'tarGzArchive', + TarBz2Archive = 'tarBz2Archive', + ZipArchive = 'zipArchive', +} +``` + +**Example:** + +```typescript +// STT Model +await ONNX.addModel({ + id: 'sherpa-onnx-whisper-tiny.en', + name: 'Sherpa Whisper Tiny (English)', + url: 'https://github.com/RunanywhereAI/sherpa-onnx/releases/.../sherpa-onnx-whisper-tiny.en.tar.gz', + modality: ModelCategory.SpeechRecognition, + artifactType: ModelArtifactType.TarGzArchive, + memoryRequirement: 75_000_000, +}); + +// TTS Model +await ONNX.addModel({ + id: 'vits-piper-en_US-lessac-medium', + name: 'Piper TTS (US English)', + url: 'https://github.com/RunanywhereAI/sherpa-onnx/releases/.../vits-piper-en_US-lessac-medium.tar.gz', + modality: ModelCategory.SpeechSynthesis, + artifactType: ModelArtifactType.TarGzArchive, + memoryRequirement: 65_000_000, +}); +``` + +--- + +## Types Reference + +### Enums + +```typescript +enum SDKEnvironment { + Development = 'development', + Staging = 'staging', + Production = 'production', +} + +enum ExecutionTarget { + OnDevice = 'onDevice', + Cloud = 'cloud', + Hybrid = 'hybrid', +} + +enum LLMFramework { + CoreML = 'CoreML', + TensorFlowLite = 'TFLite', + MLX = 'MLX', + ONNX = 'ONNX', + LlamaCpp = 'LlamaCpp', + FoundationModels = 'FoundationModels', + WhisperKit = 'WhisperKit', + SystemTTS = 'SystemTTS', + PiperTTS = 'PiperTTS', +} + +enum ModelCategory { + Language = 'language', + SpeechRecognition = 'speech-recognition', + SpeechSynthesis = 'speech-synthesis', + Vision = 'vision', + ImageGeneration = 'image-generation', + Multimodal = 'multimodal', + Audio = 'audio', +} + +enum ModelFormat { + GGUF = 'gguf', + GGML = 'ggml', + ONNX = 'onnx', + MLModel = 'mlmodel', + SafeTensors = 'safetensors', +} + +enum HardwareAcceleration { + CPU = 'cpu', + GPU = 'gpu', + NeuralEngine = 'neuralEngine', + NPU = 'npu', +} + +enum ComponentState { + NotInitialized = 'notInitialized', + Initializing = 'initializing', + Ready = 'ready', + Error = 'error', + CleaningUp = 'cleaningUp', +} +``` + +### Core Interfaces + +```typescript +interface ModelInfo { + id: string; + name: string; + category: ModelCategory; + format: ModelFormat; + downloadURL?: string; + localPath?: string; + downloadSize?: number; + memoryRequired?: number; + compatibleFrameworks: LLMFramework[]; + preferredFramework?: LLMFramework; + supportsThinking: boolean; + isDownloaded: boolean; + isAvailable: boolean; +} + +interface GenerationResult { + text: string; + thinkingContent?: string; + tokensUsed: number; + modelUsed: string; + latencyMs: number; + executionTarget: ExecutionTarget; + framework?: LLMFramework; + hardwareUsed: HardwareAcceleration; + memoryUsed: number; + performanceMetrics: PerformanceMetrics; + responseTokens: number; +} + +interface STTResult { + text: string; + segments: STTSegment[]; + language?: string; + confidence: number; + duration: number; + alternatives: STTAlternative[]; +} + +interface TTSResult { + audio: string; + sampleRate: number; + numSamples: number; + duration: number; +} +``` + +--- + +## Error Reference + +### SDKError + +```typescript +import { SDKError, SDKErrorCode, isSDKError, ErrorCategory } from '@runanywhere/core'; + +interface SDKError extends Error { + code: SDKErrorCode; + category: ErrorCategory; + underlyingError?: Error; + recoverySuggestion?: string; +} + +function isSDKError(error: unknown): error is SDKError +``` + +### Error Codes + +| Code | Category | Description | +|------|----------|-------------| +| `notInitialized` | General | SDK not initialized | +| `alreadyInitialized` | General | SDK already initialized | +| `invalidInput` | General | Invalid input parameters | +| `modelNotFound` | Model | Model not in registry | +| `modelLoadFailed` | Model | Failed to load model | +| `modelNotLoaded` | Model | No model currently loaded | +| `downloadFailed` | Download | Model download failed | +| `downloadCancelled` | Download | Download was cancelled | +| `insufficientStorage` | Storage | Not enough disk space | +| `insufficientMemory` | Memory | Not enough RAM | +| `generationFailed` | LLM | Text generation failed | +| `generationCancelled` | LLM | Generation was cancelled | +| `sttFailed` | STT | Transcription failed | +| `ttsFailed` | TTS | Synthesis failed | +| `vadFailed` | VAD | Voice detection failed | +| `networkUnavailable` | Network | No network connection | +| `authenticationFailed` | Auth | Invalid API key | +| `permissionDenied` | Permission | Missing required permission | + +### Error Helper Functions + +```typescript +// Create common errors +import { + notInitializedError, + modelNotFoundError, + modelLoadError, + generationError, + networkError, +} from '@runanywhere/core'; + +// Example +throw notInitializedError(); +throw modelNotFoundError('model-id'); +throw generationError('Generation timed out'); +``` + +--- + +## See Also + +- [README.md](../README.md) — Quick start guide +- [ARCHITECTURE.md](../ARCHITECTURE.md) — System architecture +- [React Native Sample App](../../../examples/react-native/RunAnywhereAI/) — Working demo diff --git a/sdk/runanywhere-react-native/README.md b/sdk/runanywhere-react-native/README.md new file mode 100644 index 000000000..bd8818459 --- /dev/null +++ b/sdk/runanywhere-react-native/README.md @@ -0,0 +1,743 @@ +# RunAnywhere React Native SDK + +On-device AI for React Native. Run LLMs, Speech-to-Text, Text-to-Speech, and Voice AI locally with privacy-first, offline-capable inference. + +

+ React Native 0.74+ + iOS 15.1+ + Android 7.0+ + TypeScript 5.2+ + License +

+ +--- + +## Quick Links + +- [Architecture Overview](#architecture-overview) +- [Quick Start](#quick-start) +- [API Reference](Docs/Documentation.md) +- [Sample App](../../examples/react-native/RunAnywhereAI/) +- [FAQ](#faq) +- [Troubleshooting](#troubleshooting) +- [Contributing](#contributing) + +--- + +## Features + +### Large Language Models (LLM) +- On-device text generation with streaming support +- **LlamaCPP** backend for GGUF models (Llama 2, Mistral, SmolLM, Qwen, etc.) +- Metal GPU acceleration on iOS, CPU + NNAPI on Android +- System prompts and customizable generation parameters +- Support for thinking/reasoning models +- Token streaming with real-time callbacks + +### Speech-to-Text (STT) +- Real-time and batch audio transcription +- Multi-language support with Whisper models via ONNX Runtime +- Word-level timestamps and confidence scores +- Voice Activity Detection (VAD) integration + +### Text-to-Speech (TTS) +- Neural voice synthesis with Piper TTS +- System voices via platform TTS (AVSpeechSynthesizer / Android TTS) +- Streaming audio generation for long text +- Customizable voice, pitch, rate, and volume + +### Voice Activity Detection (VAD) +- Energy-based speech detection with Silero VAD +- Configurable sensitivity thresholds +- Real-time audio stream processing + +### Voice Agent Pipeline +- Full VAD → STT → LLM → TTS orchestration +- Complete voice conversation flow +- Push-to-talk and hands-free modes + +### Infrastructure +- Automatic model discovery and download with progress tracking +- Comprehensive event system via `EventBus` +- Built-in analytics and telemetry +- Structured logging with multiple log levels +- Keychain-persisted device identity (iOS) / EncryptedSharedPreferences (Android) + +--- + +## System Requirements + +| Component | Minimum | Recommended | +|-----------|---------|-------------| +| **React Native** | 0.71+ | 0.74+ | +| **iOS** | 15.1+ | 17.0+ | +| **Android** | API 24 (7.0+) | API 28+ | +| **Node.js** | 18+ | 20+ | +| **Xcode** | 15+ | 16+ | +| **Android Studio** | Hedgehog+ | Latest | +| **RAM** | 3GB | 6GB+ for 7B models | +| **Storage** | Variable | Models: 200MB–8GB | + +Apple Silicon devices (M1/M2/M3, A14+) and Android devices with 6GB+ RAM are recommended. Metal GPU acceleration provides 3-5x speedup on iOS. + +--- + +## Multi-Package Architecture + +This SDK uses a modular multi-package architecture. Install only the packages you need: + +| Package | Description | Required | +|---------|-------------|----------| +| `@runanywhere/core` | Core SDK infrastructure, public API, events, model registry | Yes | +| `@runanywhere/llamacpp` | LlamaCPP backend for LLM text generation (GGUF models) | For LLM | +| `@runanywhere/onnx` | ONNX Runtime backend for STT/TTS (Whisper, Piper) | For Voice | + +--- + +## Installation + +### Full Installation (All Features) + +```bash +npm install @runanywhere/core @runanywhere/llamacpp @runanywhere/onnx +# or +yarn add @runanywhere/core @runanywhere/llamacpp @runanywhere/onnx +``` + +### Minimal Installation (LLM Only) + +```bash +npm install @runanywhere/core @runanywhere/llamacpp +``` + +### Minimal Installation (STT/TTS Only) + +```bash +npm install @runanywhere/core @runanywhere/onnx +``` + +### iOS Setup + +```bash +cd ios && pod install && cd .. +``` + +### Android Setup + +No additional setup required. Native libraries are automatically downloaded during the Gradle build. + +--- + +## Quick Start + +### 1. Initialize the SDK + +```typescript +import { RunAnywhere, SDKEnvironment, ModelCategory } from '@runanywhere/core'; +import { LlamaCPP } from '@runanywhere/llamacpp'; +import { ONNX, ModelArtifactType } from '@runanywhere/onnx'; + +// Initialize SDK (development mode - no API key needed) +await RunAnywhere.initialize({ + environment: SDKEnvironment.Development, +}); + +// Register LlamaCpp module and add LLM models +LlamaCPP.register(); +await LlamaCPP.addModel({ + id: 'smollm2-360m-q8_0', + name: 'SmolLM2 360M Q8_0', + url: 'https://huggingface.co/prithivMLmods/SmolLM2-360M-GGUF/resolve/main/SmolLM2-360M.Q8_0.gguf', + memoryRequirement: 500_000_000, +}); + +// Register ONNX module and add STT/TTS models +ONNX.register(); +await ONNX.addModel({ + id: 'sherpa-onnx-whisper-tiny.en', + name: 'Sherpa Whisper Tiny (ONNX)', + url: 'https://github.com/RunanywhereAI/sherpa-onnx/releases/download/runanywhere-models-v1/sherpa-onnx-whisper-tiny.en.tar.gz', + modality: ModelCategory.SpeechRecognition, + artifactType: ModelArtifactType.TarGzArchive, + memoryRequirement: 75_000_000, +}); + +console.log('SDK initialized'); +``` + +### 2. Download & Load a Model + +```typescript +// Download model with progress tracking +await RunAnywhere.downloadModel('smollm2-360m-q8_0', (progress) => { + console.log(`Download: ${(progress.progress * 100).toFixed(1)}%`); +}); + +// Load model into memory +const modelInfo = await RunAnywhere.getModelInfo('smollm2-360m-q8_0'); +if (modelInfo?.localPath) { + await RunAnywhere.loadModel(modelInfo.localPath); +} + +// Check if model is loaded +const isLoaded = await RunAnywhere.isModelLoaded(); +console.log('Model loaded:', isLoaded); +``` + +### 3. Generate Text + +```typescript +// Simple chat +const response = await RunAnywhere.chat('What is the capital of France?'); +console.log(response); // "Paris is the capital of France." + +// With options +const result = await RunAnywhere.generate( + 'Explain quantum computing in simple terms', + { + maxTokens: 200, + temperature: 0.7, + systemPrompt: 'You are a helpful assistant.', + } +); + +console.log('Response:', result.text); +console.log('Speed:', result.performanceMetrics.tokensPerSecond, 'tok/s'); +console.log('Latency:', result.latencyMs, 'ms'); +``` + +### 4. Streaming Generation + +```typescript +const streamResult = await RunAnywhere.generateStream( + 'Write a short poem about AI', + { maxTokens: 150 } +); + +// Display tokens in real-time +for await (const token of streamResult.stream) { + process.stdout.write(token); +} + +// Get final metrics +const metrics = await streamResult.result; +console.log('\nSpeed:', metrics.performanceMetrics.tokensPerSecond, 'tok/s'); +``` + +### 5. Speech-to-Text + +```typescript +// Download and load STT model +await RunAnywhere.downloadModel('sherpa-onnx-whisper-tiny.en'); +const sttModel = await RunAnywhere.getModelInfo('sherpa-onnx-whisper-tiny.en'); +await RunAnywhere.loadSTTModel(sttModel.localPath, 'whisper'); + +// Transcribe audio file +const result = await RunAnywhere.transcribeFile(audioFilePath, { + language: 'en', +}); + +console.log('Transcription:', result.text); +console.log('Confidence:', result.confidence); +``` + +### 6. Text-to-Speech + +```typescript +// Download and load TTS model +await RunAnywhere.downloadModel('vits-piper-en_US-lessac-medium'); +const ttsModel = await RunAnywhere.getModelInfo('vits-piper-en_US-lessac-medium'); +await RunAnywhere.loadTTSModel(ttsModel.localPath, 'piper'); + +// Synthesize speech +const output = await RunAnywhere.synthesize( + 'Hello from the RunAnywhere SDK.', + { rate: 1.0, pitch: 1.0, volume: 1.0 } +); + +// output.audio contains base64-encoded float32 PCM +// output.sampleRate, output.numSamples, output.duration +``` + +--- + +## Architecture Overview + +The RunAnywhere SDK follows a modular, provider-based architecture with a shared C++ core: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Your React Native App │ +├─────────────────────────────────────────────────────────────────┤ +│ @runanywhere/core (TypeScript API) │ +│ ┌──────────────┐ ┌───────────────┐ ┌──────────────────────┐ │ +│ │ RunAnywhere │ │ EventBus │ │ ModelRegistry │ │ +│ │ (public API) │ │ (events, │ │ (model discovery, │ │ +│ │ │ │ callbacks) │ │ download, storage) │ │ +│ └──────────────┘ └───────────────┘ └──────────────────────┘ │ +├────────────┬─────────────────────────────────────┬──────────────┤ +│ │ │ │ +│ ┌─────────▼─────────┐ ┌────────────▼────────────┐ │ +│ │ @runanywhere/ │ │ @runanywhere/onnx │ │ +│ │ llamacpp │ │ (STT/TTS/VAD) │ │ +│ │ (LLM/GGUF) │ │ │ │ +│ └─────────┬─────────┘ └────────────┬────────────┘ │ +├────────────┼─────────────────────────────────────┼──────────────┤ +│ │ Nitrogen/Nitro JSI │ │ +│ │ (Native Bridge Layer) │ │ +├────────────┼─────────────────────────────────────┼──────────────┤ +│ ┌─────────▼──────────────────────────────────────▼───────────┐ │ +│ │ runanywhere-commons (C++) │ │ +│ │ ┌────────────────┐ ┌────────────────┐ ┌───────────────┐ │ │ +│ │ │ RACommons │ │ RABackend │ │ RABackendONNX │ │ │ +│ │ │ (Core Engine) │ │ LLAMACPP │ │ (Sherpa-ONNX) │ │ │ +│ │ └────────────────┘ └────────────────┘ └───────────────┘ │ │ +│ └────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Key Components + +| Component | Description | +|-----------|-------------| +| **RunAnywhere** | Main SDK singleton providing all public methods | +| **EventBus** | Event subscription system for SDK events (initialization, generation, model, voice) | +| **ModelRegistry** | Manages model metadata, discovery, and download tracking | +| **ServiceContainer** | Dependency injection for internal services | +| **FileSystem** | Cross-platform file operations for model storage | +| **DownloadService** | Model download with progress, resume, and extraction | + +### Native Binaries + +| Framework | Size | Provides | +|-----------|------|----------| +| `RACommons.xcframework` / `librac_commons.so` | ~2MB | Core C++ commons, registries, events | +| `RABackendLLAMACPP.xcframework` / `librunanywhere_llamacpp.so` | ~15-25MB | LLM capability (GGUF models) | +| `RABackendONNX.xcframework` / `librunanywhere_onnx.so` | ~50-70MB | STT, TTS, VAD (ONNX models) | + +For detailed architecture documentation, see [ARCHITECTURE.md](ARCHITECTURE.md). + +--- + +## Configuration + +### SDK Initialization Parameters + +```typescript +// Development mode (default) - no API key needed +await RunAnywhere.initialize({ + environment: SDKEnvironment.Development, +}); + +// Production mode - requires API key +await RunAnywhere.initialize({ + apiKey: '', + baseURL: 'https://api.runanywhere.ai', + environment: SDKEnvironment.Production, +}); +``` + +### Environment Modes + +| Environment | Description | +|-------------|-------------| +| `.Development` | Verbose logging, local backend, no auth required | +| `.Staging` | Testing with real services | +| `.Production` | Minimal logging, full authentication, telemetry | + +### Generation Options + +```typescript +const options: GenerationOptions = { + maxTokens: 256, // Maximum tokens to generate + temperature: 0.7, // Sampling temperature (0.0–2.0) + topP: 0.95, // Top-p sampling parameter + stopSequences: ['END'], // Stop generation at these sequences + systemPrompt: 'You are a helpful assistant.', +}; +``` + +--- + +## Error Handling + +The SDK provides structured error handling through `SDKError`: + +```typescript +import { SDKError, SDKErrorCode, isSDKError } from '@runanywhere/core'; + +try { + const response = await RunAnywhere.generate('Hello!'); +} catch (error) { + if (isSDKError(error)) { + switch (error.code) { + case SDKErrorCode.notInitialized: + console.log('SDK not initialized. Call RunAnywhere.initialize() first.'); + break; + case SDKErrorCode.modelNotFound: + console.log('Model not found. Download it first.'); + break; + case SDKErrorCode.insufficientMemory: + console.log('Not enough memory. Try a smaller model.'); + break; + default: + console.log('Error:', error.message); + } + } +} +``` + +### Error Categories + +| Category | Description | +|----------|-------------| +| `general` | General SDK errors | +| `llm` | LLM generation errors | +| `stt` | Speech-to-text errors | +| `tts` | Text-to-speech errors | +| `vad` | Voice activity detection errors | +| `voiceAgent` | Voice pipeline errors | +| `download` | Model download errors | +| `network` | Network-related errors | +| `authentication` | Auth and API key errors | + +--- + +## Logging & Observability + +### Configure Logging + +```typescript +import { LogLevel, SDKLogger } from '@runanywhere/core'; + +// Set minimum log level +RunAnywhere.setLogLevel(LogLevel.Debug); // debug, info, warning, error, fault + +// Create a custom logger +const logger = new SDKLogger('MyApp'); +logger.info('App started'); +logger.debug('Debug info', { modelId: 'llama-2' }); +``` + +### Subscribe to Events + +```typescript +// Subscribe to generation events +const unsubscribe = RunAnywhere.events.onGeneration((event) => { + switch (event.type) { + case 'started': + console.log('Generation started'); + break; + case 'tokenGenerated': + console.log('Token:', event.token); + break; + case 'completed': + console.log('Done:', event.response.text); + break; + case 'failed': + console.error('Error:', event.error); + break; + } +}); + +// Subscribe to model events +RunAnywhere.events.onModel((event) => { + if (event.type === 'downloadProgress') { + console.log(`Progress: ${(event.progress * 100).toFixed(1)}%`); + } +}); + +// Unsubscribe when done +unsubscribe(); +``` + +--- + +## Performance & Best Practices + +### Model Selection + +| Model Size | RAM Required | Use Case | +|------------|--------------|----------| +| 360M–500M (Q8) | ~500MB | Fast, lightweight chat | +| 1B–3B (Q4/Q6) | 1–2GB | Balanced quality/speed | +| 7B (Q4) | 4–5GB | High quality, slower | + +### Memory Management + +```typescript +// Unload models when not in use +await RunAnywhere.unloadModel(); + +// Check storage before downloading +const storageInfo = await RunAnywhere.getStorageInfo(); +if (storageInfo.freeSpace > modelSize) { + // Safe to download +} + +// Clean up temporary files +await RunAnywhere.clearCache(); +await RunAnywhere.cleanTempFiles(); +``` + +### Best Practices + +1. **Prefer streaming** for better perceived latency in chat UIs +2. **Unload unused models** to free device memory +3. **Handle errors gracefully** with user-friendly messages +4. **Test on target devices** — performance varies by hardware +5. **Use smaller models** for faster iteration during development +6. **Pre-download models** during onboarding for better UX + +--- + +## Troubleshooting + +### Model Download Fails + +**Symptoms:** Download stuck or fails with network error + +**Solutions:** +1. Check internet connection +2. Verify sufficient storage (need 2x model size for extraction) +3. Try on WiFi instead of cellular +4. Check if model URL is accessible + +### Out of Memory + +**Symptoms:** App crashes during model loading or inference + +**Solutions:** +1. Use a smaller model (360M instead of 7B) +2. Unload unused models first with `RunAnywhere.unloadModel()` +3. Close other memory-intensive apps +4. Test on device with more RAM + +### Inference Too Slow + +**Symptoms:** Generation takes 10+ seconds per token + +**Solutions:** +1. Use Apple Silicon device for Metal acceleration (iOS) +2. Reduce `maxTokens` for shorter responses +3. Use quantized models (Q4 instead of Q8) +4. Check device thermal state + +### Model Not Found After Download + +**Symptoms:** `modelNotFound` error even though download completed + +**Solutions:** +1. Refresh model registry: `await RunAnywhere.getAvailableModels()` +2. Check model path in storage +3. Delete and re-download the model + +### Native Module Not Available + +**Symptoms:** `Native module not available` error + +**Solutions:** +1. Ensure `pod install` was run for iOS +2. Rebuild the app: `npx react-native run-ios` / `run-android` +3. Check that all packages are installed correctly +4. Reset Metro cache: `npx react-native start --reset-cache` + +--- + +## FAQ + +### Q: Do I need an internet connection? +**A:** Only for initial model download. Once downloaded, all inference runs 100% on-device with no network required. + +### Q: How much storage do models need? +**A:** Varies by model: +- Small LLMs (360M–1B): 200MB–1GB +- Medium LLMs (3B–7B Q4): 2–5GB +- STT models: 50–200MB +- TTS voices: 20–100MB + +### Q: Is user data sent to the cloud? +**A:** No. All inference happens on-device. Only anonymous analytics (latency, error rates) are collected in production mode, and this can be disabled. + +### Q: Which devices are supported? +**A:** iOS 15.1+ (iPhone/iPad) and Android 7.0+ (API 24+). Modern devices with 6GB+ RAM are recommended for larger models. + +### Q: Can I use custom models? +**A:** Yes, any GGUF model works with the LlamaCPP backend. ONNX models work for STT/TTS. + +### Q: What's the difference between `chat()` and `generate()`? +**A:** `chat()` is a convenience method that returns just the text. `generate()` returns full metrics (tokens, latency, etc.). + +--- + +## Local Development & Contributing + +Contributions are welcome. This section explains how to set up your development environment to build the SDK from source and test your changes with the sample app. + +### Prerequisites + +- **Node.js** 18+ +- **Xcode** 15+ (for iOS builds) +- **Android Studio** with NDK (for Android builds) +- **CMake** 3.21+ + +### First-Time Setup (Build from Source) + +The SDK depends on native C++ libraries from `runanywhere-commons`. The setup script builds these locally so you can develop and test the SDK end-to-end. + +```bash +# 1. Clone the repository +git clone https://github.com/RunanywhereAI/runanywhere-sdks.git +cd runanywhere-sdks/sdk/runanywhere-react-native + +# 2. Run first-time setup (~15-20 minutes) +./scripts/build-react-native.sh --setup + +# 3. Install JavaScript dependencies +yarn install +``` + +**What the setup script does:** +1. Downloads dependencies (ONNX Runtime, Sherpa-ONNX) +2. Builds `RACommons.xcframework` and JNI libraries +3. Builds `RABackendLLAMACPP` (LLM backend) +4. Builds `RABackendONNX` (STT/TTS/VAD backend) +5. Copies frameworks to `ios/Binaries/` and JNI libs to `android/src/main/jniLibs/` +6. Creates `.testlocal` marker files (enables local library consumption) + +### Understanding testLocal + +The SDK has two modes: + +| Mode | Description | +|------|-------------| +| **Local** | Uses frameworks/JNI libs from package directories (for development) | +| **Remote** | Downloads from GitHub releases during `pod install`/Gradle sync (for end users) | + +When you run `--setup`, the script automatically enables local mode via: +- **iOS**: `.testlocal` marker files in `ios/` directories +- **Android**: `RA_TEST_LOCAL=1` environment variable or `runanywhere.testLocal=true` in `gradle.properties` + +### Testing with the React Native Sample App + +The recommended way to test SDK changes is with the sample app: + +```bash +# 1. Ensure SDK is set up (from previous step) + +# 2. Navigate to the sample app +cd ../../examples/react-native/RunAnywhereAI + +# 3. Install sample app dependencies +npm install + +# 4. iOS: Install pods and run +cd ios && pod install && cd .. +npx react-native run-ios + +# 5. Android: Run directly +npx react-native run-android +``` + +You can open the sample app in **VS Code** or **Cursor** for development. + +The sample app's `package.json` uses workspace dependencies to reference the local SDK packages: + +``` +Sample App → Local RN SDK Packages → Local Frameworks/JNI libs + ↑ + Built by build-react-native.sh --setup +``` + +### Development Workflow + +**After modifying TypeScript SDK code:** + +```bash +# Type check all packages +yarn typecheck + +# Run ESLint +yarn lint + +# Build all packages +yarn build +``` + +**After modifying runanywhere-commons (C++ code):** + +```bash +cd sdk/runanywhere-react-native +./scripts/build-react-native.sh --local --rebuild-commons +``` + +### Build Script Reference + +| Command | Description | +|---------|-------------| +| `--setup` | First-time setup: downloads deps, builds all frameworks, enables local mode | +| `--local` | Use local frameworks from package directories | +| `--remote` | Use remote frameworks from GitHub releases | +| `--rebuild-commons` | Rebuild runanywhere-commons from source | +| `--ios` | Build for iOS only | +| `--android` | Build for Android only | +| `--clean` | Clean build artifacts before building | +| `--abis=ABIS` | Android ABIs to build (default: `arm64-v8a`) | + +### Code Style + +We use ESLint and Prettier for code formatting: + +```bash +# Run linter +yarn lint + +# Auto-fix linting issues +yarn lint:fix +``` + +### Pull Request Process + +1. Fork the repository +2. Create a feature branch: `git checkout -b feature/my-feature` +3. Make your changes with tests +4. Ensure type checking passes: `yarn typecheck` +5. Run linter: `yarn lint` +6. Commit with a descriptive message +7. Push and open a Pull Request + +### Reporting Issues + +Open an issue on GitHub with: +- SDK version: `RunAnywhere.version` +- Platform (iOS/Android) and OS version +- Device model +- React Native version +- Steps to reproduce +- Expected vs actual behavior +- Relevant logs (with sensitive info redacted) + +--- + +## Support + +- **GitHub Issues**: [Report bugs](https://github.com/RunanywhereAI/runanywhere-sdks/issues) +- **Discord**: [Community](https://discord.gg/pxRkYmWh) +- **Email**: san@runanywhere.ai + +--- + +## License + +MIT License. See [LICENSE](../../LICENSE) for details. + +--- + +## Related Documentation + +- [Architecture](ARCHITECTURE.md) +- [API Reference](Docs/Documentation.md) +- [Sample App](../../examples/react-native/RunAnywhereAI/) +- [Swift SDK](../runanywhere-swift/) +- [Kotlin SDK](../runanywhere-kotlin/) +- [Flutter SDK](../runanywhere-flutter/) diff --git a/sdk/runanywhere-react-native/lerna.json b/sdk/runanywhere-react-native/lerna.json new file mode 100644 index 000000000..411a38c25 --- /dev/null +++ b/sdk/runanywhere-react-native/lerna.json @@ -0,0 +1,19 @@ +{ + "$schema": "node_modules/lerna/schemas/lerna-schema.json", + "version": "0.2.0", + "packages": [ + "packages/*" + ], + "npmClient": "yarn", + "useWorkspaces": true, + "command": { + "version": { + "allowBranch": "main", + "conventionalCommits": true, + "message": "chore(release): publish %s" + }, + "publish": { + "registry": "https://registry.npmjs.org/" + } + } +} diff --git a/sdk/runanywhere-react-native/package-lock.json b/sdk/runanywhere-react-native/package-lock.json new file mode 100644 index 000000000..1aca334b7 --- /dev/null +++ b/sdk/runanywhere-react-native/package-lock.json @@ -0,0 +1,13932 @@ +{ + "name": "runanywhere-react-native-monorepo", + "version": "0.2.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "runanywhere-react-native-monorepo", + "version": "0.2.0", + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "devDependencies": { + "@commitlint/config-conventional": "^17.0.2", + "@evilmartians/lefthook": "^1.5.0", + "@types/node": "^20.10.0", + "@types/react": "^18.2.44", + "@typescript-eslint/eslint-plugin": "^8.50.0", + "@typescript-eslint/parser": "^8.50.0", + "commitlint": "^17.0.2", + "del-cli": "^5.1.0", + "eslint": "^8.51.0", + "eslint-config-prettier": "^9.0.0", + "eslint-plugin-prettier": "^5.0.1", + "lerna": "^8.0.0", + "prettier": "^3.0.3", + "react": "18.2.0", + "react-native": "0.74.0", + "typescript": "^5.2.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", + "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz", + "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz", + "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { + "version": "5.1.1", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/yallist": { + "version": "3.1.1", + "license": "ISC" + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz", + "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/helper-replace-supers": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/traverse": "^7.28.6", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.28.5", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "regexpu-core": "^6.3.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.6.5", + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-plugin-utils": "^7.27.1", + "debug": "^4.4.1", + "lodash.debounce": "^4.0.8", + "resolve": "^1.22.10" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/helper-environment-visitor": { + "version": "7.24.7", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.28.5", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-wrap-function": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", + "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.28.3", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.3", + "@babel/types": "^7.28.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", + "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.6" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz", + "integrity": "sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz", + "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz", + "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz", + "integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-transform-optional-chaining": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.13.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.6.tgz", + "integrity": "sha512-a0aBScVTlNaiUe35UtfxAN7A/tehvvG4/ByO6+46VPKTRSlfnAFsgKy0FUh+qAkQrDTmhDkT+IBOKlOoMUxQ0g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-proposal-async-generator-functions": { + "version": "7.20.7", + "license": "MIT", + "dependencies": { + "@babel/helper-environment-visitor": "^7.18.9", + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/helper-remap-async-to-generator": "^7.18.9", + "@babel/plugin-syntax-async-generators": "^7.8.4" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-class-properties": { + "version": "7.18.6", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-export-default-from": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-logical-assignment-operators": { + "version": "7.20.7", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-nullish-coalescing-operator": { + "version": "7.18.6", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-numeric-separator": { + "version": "7.18.6", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/plugin-syntax-numeric-separator": "^7.10.4" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-object-rest-spread": { + "version": "7.20.7", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.20.5", + "@babel/helper-compilation-targets": "^7.20.7", + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-transform-parameters": "^7.20.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-optional-catch-binding": { + "version": "7.18.6", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-optional-chaining": { + "version": "7.21.0", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/helper-skip-transparent-expression-wrappers": "^7.20.0", + "@babel/plugin-syntax-optional-chaining": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.0-placeholder-for-preset-env.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", + "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-dynamic-import": { + "version": "7.8.3", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-export-default-from": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-flow": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-assertions": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.28.6.tgz", + "integrity": "sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", + "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-unicode-sets-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", + "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.28.6.tgz", + "integrity": "sha512-9knsChgsMzBV5Yh3kkhrZNxH3oCYAfMBkNNaVN4cP2RVlFPe8wYdwwcnOsAbkdDoV9UjFtOXWrWB52M8W4jNeA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-remap-async-to-generator": "^7.27.1", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.28.6.tgz", + "integrity": "sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-remap-async-to-generator": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoped-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz", + "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.6.tgz", + "integrity": "sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz", + "integrity": "sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz", + "integrity": "sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz", + "integrity": "sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-globals": "^7.28.0", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-replace-supers": "^7.28.6", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-computed-properties": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.28.6.tgz", + "integrity": "sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/template": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.28.5", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-dotall-regex": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.28.6.tgz", + "integrity": "sha512-SljjowuNKB7q5Oayv4FoPzeB74g3QgLt8IVJw9ADvWy3QnUb/01aw8I4AVv8wYnPvQz2GDDZ/g3GhcNyDBI4Bg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-keys": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz", + "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.28.6.tgz", + "integrity": "sha512-5suVoXjC14lUN6ZL9OLKIHCNVWCrqGqlmEp/ixdXjvgnEl/kauLvvMO/Xw9NyMc95Joj1AeLVPVMvibBgSoFlA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-dynamic-import": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz", + "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-explicit-resource-management": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.6.tgz", + "integrity": "sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-transform-destructuring": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-exponentiation-operator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.6.tgz", + "integrity": "sha512-WitabqiGjV/vJ0aPOLSFfNY1u9U3R7W36B03r5I2KoNix+a3sOhJ3pKFB3R5It9/UiK78NiO0KE9P21cMhlPkw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz", + "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-flow-strip-types": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-syntax-flow": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", + "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-function-name": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-json-strings": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.28.6.tgz", + "integrity": "sha512-Nr+hEN+0geQkzhbdgQVPoqr47lZbm+5fCUmO70722xJZd0Mvb59+33QLImGj6F+DkK3xgDi1YVysP8whD6FQAw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-literals": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.6.tgz", + "integrity": "sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-member-expression-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz", + "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-amd": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz", + "integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz", + "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-systemjs": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.28.5.tgz", + "integrity": "sha512-vn5Jma98LCOeBy/KpeQhXcV2WZgaRUtjwQmjoBuLNlOmkg0fB5pdvYVeWRYI69wWKwK2cD1QbMiUQnoujWvrew==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-umd": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz", + "integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-new-target": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz", + "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz", + "integrity": "sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-numeric-separator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.28.6.tgz", + "integrity": "sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.6.tgz", + "integrity": "sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-transform-destructuring": "^7.28.5", + "@babel/plugin-transform-parameters": "^7.27.7", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-super": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz", + "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.28.6.tgz", + "integrity": "sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.6.tgz", + "integrity": "sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.27.7", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.28.6.tgz", + "integrity": "sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.28.6.tgz", + "integrity": "sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-property-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz", + "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-display-name": { + "version": "7.28.0", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regenerator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.28.6.tgz", + "integrity": "sha512-eZhoEZHYQLL5uc1gS5e9/oTknS0sSSAtd5TkKMUp3J+S/CaUjagc0kOUPsEbDmMeva0nC3WWl4SxVY6+OBuxfw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regexp-modifiers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.28.6.tgz", + "integrity": "sha512-QGWAepm9qxpaIs7UM9FvUSnCGlb8Ua1RhyM4/veAxLwt3gMat/LSGrZixyuj4I6+Kn9iwvqCyPTtbdxanYoWYg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-reserved-words": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz", + "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-runtime": { + "version": "7.28.5", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "babel-plugin-polyfill-corejs2": "^0.4.14", + "babel-plugin-polyfill-corejs3": "^0.13.0", + "babel-plugin-polyfill-regenerator": "^0.6.5", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-runtime/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-spread": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.28.6.tgz", + "integrity": "sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-sticky-regex": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", + "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typeof-symbol": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz", + "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.28.5", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-create-class-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-escapes": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz", + "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-property-regex": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.28.6.tgz", + "integrity": "sha512-4Wlbdl/sIZjzi/8St0evF0gEZrgOswVO6aOzqxh1kDZOl9WmLrHq2HtGhnOJZmHZYKP8WZ1MDLCt5DAWwRo57A==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-sets-regex": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.28.6.tgz", + "integrity": "sha512-/wHc/paTUmsDYN7SZkpWxogTOBNnlx7nBQYfy6JJlCT7G3mVhltk3e++N7zV0XfgGsrqBxd4rJQt9H16I21Y1Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/preset-env": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.28.6.tgz", + "integrity": "sha512-GaTI4nXDrs7l0qaJ6Rg06dtOXTBCG6TMDB44zbqofCIC4PqC7SEvmFFtpxzCDw9W5aJ7RKVshgXTLvLdBFV/qw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.28.5", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.27.1", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.27.1", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.28.6", + "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", + "@babel/plugin-syntax-import-assertions": "^7.28.6", + "@babel/plugin-syntax-import-attributes": "^7.28.6", + "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", + "@babel/plugin-transform-arrow-functions": "^7.27.1", + "@babel/plugin-transform-async-generator-functions": "^7.28.6", + "@babel/plugin-transform-async-to-generator": "^7.28.6", + "@babel/plugin-transform-block-scoped-functions": "^7.27.1", + "@babel/plugin-transform-block-scoping": "^7.28.6", + "@babel/plugin-transform-class-properties": "^7.28.6", + "@babel/plugin-transform-class-static-block": "^7.28.6", + "@babel/plugin-transform-classes": "^7.28.6", + "@babel/plugin-transform-computed-properties": "^7.28.6", + "@babel/plugin-transform-destructuring": "^7.28.5", + "@babel/plugin-transform-dotall-regex": "^7.28.6", + "@babel/plugin-transform-duplicate-keys": "^7.27.1", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.28.6", + "@babel/plugin-transform-dynamic-import": "^7.27.1", + "@babel/plugin-transform-explicit-resource-management": "^7.28.6", + "@babel/plugin-transform-exponentiation-operator": "^7.28.6", + "@babel/plugin-transform-export-namespace-from": "^7.27.1", + "@babel/plugin-transform-for-of": "^7.27.1", + "@babel/plugin-transform-function-name": "^7.27.1", + "@babel/plugin-transform-json-strings": "^7.28.6", + "@babel/plugin-transform-literals": "^7.27.1", + "@babel/plugin-transform-logical-assignment-operators": "^7.28.6", + "@babel/plugin-transform-member-expression-literals": "^7.27.1", + "@babel/plugin-transform-modules-amd": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.28.6", + "@babel/plugin-transform-modules-systemjs": "^7.28.5", + "@babel/plugin-transform-modules-umd": "^7.27.1", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.27.1", + "@babel/plugin-transform-new-target": "^7.27.1", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6", + "@babel/plugin-transform-numeric-separator": "^7.28.6", + "@babel/plugin-transform-object-rest-spread": "^7.28.6", + "@babel/plugin-transform-object-super": "^7.27.1", + "@babel/plugin-transform-optional-catch-binding": "^7.28.6", + "@babel/plugin-transform-optional-chaining": "^7.28.6", + "@babel/plugin-transform-parameters": "^7.27.7", + "@babel/plugin-transform-private-methods": "^7.28.6", + "@babel/plugin-transform-private-property-in-object": "^7.28.6", + "@babel/plugin-transform-property-literals": "^7.27.1", + "@babel/plugin-transform-regenerator": "^7.28.6", + "@babel/plugin-transform-regexp-modifiers": "^7.28.6", + "@babel/plugin-transform-reserved-words": "^7.27.1", + "@babel/plugin-transform-shorthand-properties": "^7.27.1", + "@babel/plugin-transform-spread": "^7.28.6", + "@babel/plugin-transform-sticky-regex": "^7.27.1", + "@babel/plugin-transform-template-literals": "^7.27.1", + "@babel/plugin-transform-typeof-symbol": "^7.27.1", + "@babel/plugin-transform-unicode-escapes": "^7.27.1", + "@babel/plugin-transform-unicode-property-regex": "^7.28.6", + "@babel/plugin-transform-unicode-regex": "^7.27.1", + "@babel/plugin-transform-unicode-sets-regex": "^7.28.6", + "@babel/preset-modules": "0.1.6-no-external-plugins", + "babel-plugin-polyfill-corejs2": "^0.4.14", + "babel-plugin-polyfill-corejs3": "^0.13.0", + "babel-plugin-polyfill-regenerator": "^0.6.5", + "core-js-compat": "^3.43.0", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-env/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "peer": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/preset-flow": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-transform-flow-strip-types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-modules": { + "version": "0.1.6-no-external-plugins", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", + "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "7.28.5", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.27.1", + "@babel/plugin-transform-typescript": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/register": { + "version": "7.28.3", + "license": "MIT", + "dependencies": { + "clone-deep": "^4.0.1", + "find-cache-dir": "^2.0.0", + "make-dir": "^2.1.0", + "pirates": "^4.0.6", + "source-map-support": "^0.5.16" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz", + "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", + "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@commitlint/cli": { + "version": "17.8.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/format": "^17.8.1", + "@commitlint/lint": "^17.8.1", + "@commitlint/load": "^17.8.1", + "@commitlint/read": "^17.8.1", + "@commitlint/types": "^17.8.1", + "execa": "^5.0.0", + "lodash.isfunction": "^3.0.9", + "resolve-from": "5.0.0", + "resolve-global": "1.0.0", + "yargs": "^17.0.0" + }, + "bin": { + "commitlint": "cli.js" + }, + "engines": { + "node": ">=v14" + } + }, + "node_modules/@commitlint/config-conventional": { + "version": "17.8.1", + "dev": true, + "license": "MIT", + "dependencies": { + "conventional-changelog-conventionalcommits": "^6.1.0" + }, + "engines": { + "node": ">=v14" + } + }, + "node_modules/@commitlint/config-validator": { + "version": "17.8.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^17.8.1", + "ajv": "^8.11.0" + }, + "engines": { + "node": ">=v14" + } + }, + "node_modules/@commitlint/config-validator/node_modules/ajv": { + "version": "8.17.1", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@commitlint/ensure": { + "version": "17.8.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^17.8.1", + "lodash.camelcase": "^4.3.0", + "lodash.kebabcase": "^4.1.1", + "lodash.snakecase": "^4.1.1", + "lodash.startcase": "^4.4.0", + "lodash.upperfirst": "^4.3.1" + }, + "engines": { + "node": ">=v14" + } + }, + "node_modules/@commitlint/execute-rule": { + "version": "17.8.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=v14" + } + }, + "node_modules/@commitlint/format": { + "version": "17.8.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^17.8.1", + "chalk": "^4.1.0" + }, + "engines": { + "node": ">=v14" + } + }, + "node_modules/@commitlint/is-ignored": { + "version": "17.8.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^17.8.1", + "semver": "7.5.4" + }, + "engines": { + "node": ">=v14" + } + }, + "node_modules/@commitlint/is-ignored/node_modules/lru-cache": { + "version": "6.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@commitlint/is-ignored/node_modules/semver": { + "version": "7.5.4", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@commitlint/lint": { + "version": "17.8.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/is-ignored": "^17.8.1", + "@commitlint/parse": "^17.8.1", + "@commitlint/rules": "^17.8.1", + "@commitlint/types": "^17.8.1" + }, + "engines": { + "node": ">=v14" + } + }, + "node_modules/@commitlint/load": { + "version": "17.8.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/config-validator": "^17.8.1", + "@commitlint/execute-rule": "^17.8.1", + "@commitlint/resolve-extends": "^17.8.1", + "@commitlint/types": "^17.8.1", + "@types/node": "20.5.1", + "chalk": "^4.1.0", + "cosmiconfig": "^8.0.0", + "cosmiconfig-typescript-loader": "^4.0.0", + "lodash.isplainobject": "^4.0.6", + "lodash.merge": "^4.6.2", + "lodash.uniq": "^4.5.0", + "resolve-from": "^5.0.0", + "ts-node": "^10.8.1", + "typescript": "^4.6.4 || ^5.2.2" + }, + "engines": { + "node": ">=v14" + } + }, + "node_modules/@commitlint/load/node_modules/@types/node": { + "version": "20.5.1", + "dev": true, + "license": "MIT" + }, + "node_modules/@commitlint/load/node_modules/cosmiconfig-typescript-loader": { + "version": "4.4.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=v14.21.3" + }, + "peerDependencies": { + "@types/node": "*", + "cosmiconfig": ">=7", + "ts-node": ">=10", + "typescript": ">=4" + } + }, + "node_modules/@commitlint/load/node_modules/ts-node": { + "version": "10.9.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/@commitlint/message": { + "version": "17.8.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=v14" + } + }, + "node_modules/@commitlint/parse": { + "version": "17.8.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^17.8.1", + "conventional-changelog-angular": "^6.0.0", + "conventional-commits-parser": "^4.0.0" + }, + "engines": { + "node": ">=v14" + } + }, + "node_modules/@commitlint/read": { + "version": "17.8.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/top-level": "^17.8.1", + "@commitlint/types": "^17.8.1", + "fs-extra": "^11.0.0", + "git-raw-commits": "^2.0.11", + "minimist": "^1.2.6" + }, + "engines": { + "node": ">=v14" + } + }, + "node_modules/@commitlint/read/node_modules/fs-extra": { + "version": "11.3.2", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@commitlint/read/node_modules/git-raw-commits": { + "version": "2.0.11", + "dev": true, + "license": "MIT", + "dependencies": { + "dargs": "^7.0.0", + "lodash": "^4.17.15", + "meow": "^8.0.0", + "split2": "^3.0.0", + "through2": "^4.0.0" + }, + "bin": { + "git-raw-commits": "cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@commitlint/read/node_modules/through2": { + "version": "4.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "3" + } + }, + "node_modules/@commitlint/resolve-extends": { + "version": "17.8.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/config-validator": "^17.8.1", + "@commitlint/types": "^17.8.1", + "import-fresh": "^3.0.0", + "lodash.mergewith": "^4.6.2", + "resolve-from": "^5.0.0", + "resolve-global": "^1.0.0" + }, + "engines": { + "node": ">=v14" + } + }, + "node_modules/@commitlint/rules": { + "version": "17.8.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/ensure": "^17.8.1", + "@commitlint/message": "^17.8.1", + "@commitlint/to-lines": "^17.8.1", + "@commitlint/types": "^17.8.1", + "execa": "^5.0.0" + }, + "engines": { + "node": ">=v14" + } + }, + "node_modules/@commitlint/to-lines": { + "version": "17.8.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=v14" + } + }, + "node_modules/@commitlint/top-level": { + "version": "17.8.1", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^5.0.0" + }, + "engines": { + "node": ">=v14" + } + }, + "node_modules/@commitlint/top-level/node_modules/find-up": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@commitlint/top-level/node_modules/locate-path": { + "version": "6.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@commitlint/top-level/node_modules/p-limit": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@commitlint/top-level/node_modules/p-locate": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@commitlint/types": { + "version": "17.8.1", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0" + }, + "engines": { + "node": ">=v14" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@emnapi/core": { + "version": "1.7.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.7.1", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@evilmartians/lefthook": { + "version": "1.13.6", + "cpu": [ + "x64", + "arm64", + "ia32" + ], + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "os": [ + "darwin", + "linux", + "win32" + ], + "bin": { + "lefthook": "bin/index.js" + } + }, + "node_modules/@hapi/hoek": { + "version": "9.3.0", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/topo": { + "version": "5.1.0", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.2", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hutson/parse-repository-url": { + "version": "3.0.2", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@inquirer/external-editor": { + "version": "1.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor/node_modules/iconv-lite": { + "version": "0.7.1", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@isaacs/balanced-match": { + "version": "4.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/brace-expansion": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@isaacs/balanced-match": "^4.0.1" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/string-locale-compare": { + "version": "1.1.0", + "dev": true, + "license": "ISC" + }, + "node_modules/@isaacs/ttlcache": { + "version": "1.4.1", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/@jest/create-cache-key-function": { + "version": "29.7.0", + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/environment": { + "version": "29.7.0", + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/environment/node_modules/@types/node": { + "version": "24.10.2", + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@jest/environment/node_modules/undici-types": { + "version": "7.16.0", + "license": "MIT" + }, + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers/node_modules/@types/node": { + "version": "24.10.2", + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@jest/fake-timers/node_modules/undici-types": { + "version": "7.16.0", + "license": "MIT" + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types/node_modules/@types/node": { + "version": "24.10.2", + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@jest/types/node_modules/undici-types": { + "version": "7.16.0", + "license": "MIT" + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@lerna/create": { + "version": "8.2.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@npmcli/arborist": "7.5.4", + "@npmcli/package-json": "5.2.0", + "@npmcli/run-script": "8.1.0", + "@nx/devkit": ">=17.1.2 < 21", + "@octokit/plugin-enterprise-rest": "6.0.1", + "@octokit/rest": "20.1.2", + "aproba": "2.0.0", + "byte-size": "8.1.1", + "chalk": "4.1.0", + "clone-deep": "4.0.1", + "cmd-shim": "6.0.3", + "color-support": "1.1.3", + "columnify": "1.6.0", + "console-control-strings": "^1.1.0", + "conventional-changelog-core": "5.0.1", + "conventional-recommended-bump": "7.0.1", + "cosmiconfig": "9.0.0", + "dedent": "1.5.3", + "execa": "5.0.0", + "fs-extra": "^11.2.0", + "get-stream": "6.0.0", + "git-url-parse": "14.0.0", + "glob-parent": "6.0.2", + "graceful-fs": "4.2.11", + "has-unicode": "2.0.1", + "ini": "^1.3.8", + "init-package-json": "6.0.3", + "inquirer": "^8.2.4", + "is-ci": "3.0.1", + "is-stream": "2.0.0", + "js-yaml": "4.1.0", + "libnpmpublish": "9.0.9", + "load-json-file": "6.2.0", + "make-dir": "4.0.0", + "minimatch": "3.0.5", + "multimatch": "5.0.0", + "node-fetch": "2.6.7", + "npm-package-arg": "11.0.2", + "npm-packlist": "8.0.2", + "npm-registry-fetch": "^17.1.0", + "nx": ">=17.1.2 < 21", + "p-map": "4.0.0", + "p-map-series": "2.1.0", + "p-queue": "6.6.2", + "p-reduce": "^2.1.0", + "pacote": "^18.0.6", + "pify": "5.0.0", + "read-cmd-shim": "4.0.0", + "resolve-from": "5.0.0", + "rimraf": "^4.4.1", + "semver": "^7.3.4", + "set-blocking": "^2.0.0", + "signal-exit": "3.0.7", + "slash": "^3.0.0", + "ssri": "^10.0.6", + "string-width": "^4.2.3", + "tar": "6.2.1", + "temp-dir": "1.0.0", + "through": "2.3.8", + "tinyglobby": "0.2.12", + "upath": "2.0.1", + "uuid": "^10.0.0", + "validate-npm-package-license": "^3.0.4", + "validate-npm-package-name": "5.0.1", + "wide-align": "1.1.5", + "write-file-atomic": "5.0.1", + "write-pkg": "4.0.0", + "yargs": "17.7.2", + "yargs-parser": "21.1.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@lerna/create/node_modules/@npmcli/package-json": { + "version": "5.2.0", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^5.0.0", + "glob": "^10.2.2", + "hosted-git-info": "^7.0.0", + "json-parse-even-better-errors": "^3.0.0", + "normalize-package-data": "^6.0.0", + "proc-log": "^4.0.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@lerna/create/node_modules/chalk": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@lerna/create/node_modules/cosmiconfig": { + "version": "9.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@lerna/create/node_modules/cosmiconfig/node_modules/js-yaml": { + "version": "4.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@lerna/create/node_modules/execa": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/@lerna/create/node_modules/execa/node_modules/get-stream": { + "version": "6.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@lerna/create/node_modules/execa/node_modules/is-stream": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@lerna/create/node_modules/get-stream": { + "version": "6.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@lerna/create/node_modules/is-stream": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@lerna/create/node_modules/js-yaml": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@lerna/create/node_modules/make-dir": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@lerna/create/node_modules/minimatch": { + "version": "3.0.5", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@lerna/create/node_modules/minimatch/node_modules/brace-expansion": { + "version": "1.1.12", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@lerna/create/node_modules/minipass": { + "version": "4.2.8", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/@lerna/create/node_modules/node-fetch": { + "version": "2.6.7", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/@lerna/create/node_modules/normalize-package-data": { + "version": "6.0.2", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^7.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@lerna/create/node_modules/npm-package-arg": { + "version": "11.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "hosted-git-info": "^7.0.0", + "proc-log": "^4.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^5.0.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@lerna/create/node_modules/pify": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@lerna/create/node_modules/rimraf": { + "version": "4.4.1", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^9.2.0" + }, + "bin": { + "rimraf": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@lerna/create/node_modules/rimraf/node_modules/glob": { + "version": "9.3.5", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "minimatch": "^8.0.2", + "minipass": "^4.2.4", + "path-scurry": "^1.6.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@lerna/create/node_modules/rimraf/node_modules/minimatch": { + "version": "8.0.4", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@lerna/create/node_modules/tinyglobby": { + "version": "0.2.12", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.4.3", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@emnapi/core": "^1.1.0", + "@emnapi/runtime": "^1.1.0", + "@tybys/wasm-util": "^0.9.0" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@npmcli/agent": { + "version": "2.2.2", + "dev": true, + "license": "ISC", + "dependencies": { + "agent-base": "^7.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "lru-cache": "^10.0.1", + "socks-proxy-agent": "^8.0.3" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@npmcli/arborist": { + "version": "7.5.4", + "dev": true, + "license": "ISC", + "dependencies": { + "@isaacs/string-locale-compare": "^1.1.0", + "@npmcli/fs": "^3.1.1", + "@npmcli/installed-package-contents": "^2.1.0", + "@npmcli/map-workspaces": "^3.0.2", + "@npmcli/metavuln-calculator": "^7.1.1", + "@npmcli/name-from-folder": "^2.0.0", + "@npmcli/node-gyp": "^3.0.0", + "@npmcli/package-json": "^5.1.0", + "@npmcli/query": "^3.1.0", + "@npmcli/redact": "^2.0.0", + "@npmcli/run-script": "^8.1.0", + "bin-links": "^4.0.4", + "cacache": "^18.0.3", + "common-ancestor-path": "^1.0.1", + "hosted-git-info": "^7.0.2", + "json-parse-even-better-errors": "^3.0.2", + "json-stringify-nice": "^1.1.4", + "lru-cache": "^10.2.2", + "minimatch": "^9.0.4", + "nopt": "^7.2.1", + "npm-install-checks": "^6.2.0", + "npm-package-arg": "^11.0.2", + "npm-pick-manifest": "^9.0.1", + "npm-registry-fetch": "^17.0.1", + "pacote": "^18.0.6", + "parse-conflict-json": "^3.0.0", + "proc-log": "^4.2.0", + "proggy": "^2.0.0", + "promise-all-reject-late": "^1.0.0", + "promise-call-limit": "^3.0.1", + "read-package-json-fast": "^3.0.2", + "semver": "^7.3.7", + "ssri": "^10.0.6", + "treeverse": "^3.0.0", + "walk-up-path": "^3.0.1" + }, + "bin": { + "arborist": "bin/index.js" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@npmcli/fs": { + "version": "3.1.1", + "dev": true, + "license": "ISC", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/@npmcli/git": { + "version": "5.0.8", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/promise-spawn": "^7.0.0", + "ini": "^4.1.3", + "lru-cache": "^10.0.1", + "npm-pick-manifest": "^9.0.0", + "proc-log": "^4.0.0", + "promise-inflight": "^1.0.1", + "promise-retry": "^2.0.1", + "semver": "^7.3.5", + "which": "^4.0.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@npmcli/git/node_modules/ini": { + "version": "4.1.3", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/@npmcli/installed-package-contents": { + "version": "2.1.0", + "dev": true, + "license": "ISC", + "dependencies": { + "npm-bundled": "^3.0.0", + "npm-normalize-package-bin": "^3.0.0" + }, + "bin": { + "installed-package-contents": "bin/index.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/@npmcli/map-workspaces": { + "version": "3.0.6", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/name-from-folder": "^2.0.0", + "glob": "^10.2.2", + "minimatch": "^9.0.0", + "read-package-json-fast": "^3.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/@npmcli/metavuln-calculator": { + "version": "7.1.1", + "dev": true, + "license": "ISC", + "dependencies": { + "cacache": "^18.0.0", + "json-parse-even-better-errors": "^3.0.0", + "pacote": "^18.0.0", + "proc-log": "^4.1.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@npmcli/name-from-folder": { + "version": "2.0.0", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/@npmcli/node-gyp": { + "version": "3.0.0", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/@npmcli/package-json": { + "version": "5.2.1", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^5.0.0", + "glob": "^10.2.2", + "hosted-git-info": "^7.0.0", + "json-parse-even-better-errors": "^3.0.0", + "normalize-package-data": "^6.0.0", + "proc-log": "^4.0.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@npmcli/package-json/node_modules/normalize-package-data": { + "version": "6.0.2", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^7.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@npmcli/promise-spawn": { + "version": "7.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "which": "^4.0.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@npmcli/query": { + "version": "3.1.0", + "dev": true, + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^6.0.10" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/@npmcli/redact": { + "version": "2.0.1", + "dev": true, + "license": "ISC", + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@npmcli/run-script": { + "version": "8.1.0", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/node-gyp": "^3.0.0", + "@npmcli/package-json": "^5.0.0", + "@npmcli/promise-spawn": "^7.0.0", + "node-gyp": "^10.0.0", + "proc-log": "^4.0.0", + "which": "^4.0.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@nx/devkit": { + "version": "20.8.3", + "dev": true, + "license": "MIT", + "dependencies": { + "ejs": "^3.1.7", + "enquirer": "~2.3.6", + "ignore": "^5.0.4", + "minimatch": "9.0.3", + "semver": "^7.5.3", + "tmp": "~0.2.1", + "tslib": "^2.3.0", + "yargs-parser": "21.1.1" + }, + "peerDependencies": { + "nx": ">= 19 <= 21" + } + }, + "node_modules/@nx/devkit/node_modules/minimatch": { + "version": "9.0.3", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@nx/nx-darwin-arm64": { + "version": "20.8.3", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@octokit/auth-token": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/core": { + "version": "5.2.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^4.0.0", + "@octokit/graphql": "^7.1.0", + "@octokit/request": "^8.4.1", + "@octokit/request-error": "^5.1.1", + "@octokit/types": "^13.0.0", + "before-after-hook": "^2.2.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/endpoint": { + "version": "9.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^13.1.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/graphql": { + "version": "7.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/request": "^8.4.1", + "@octokit/types": "^13.0.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "24.2.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/plugin-enterprise-rest": { + "version": "6.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "11.4.4-cjs.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^13.7.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": "5" + } + }, + "node_modules/@octokit/plugin-request-log": { + "version": "4.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": "5" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "13.3.2-cjs.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^13.8.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": "^5" + } + }, + "node_modules/@octokit/request": { + "version": "8.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^9.0.6", + "@octokit/request-error": "^5.1.1", + "@octokit/types": "^13.1.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/request-error": { + "version": "5.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^13.1.0", + "deprecation": "^2.0.0", + "once": "^1.4.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/rest": { + "version": "20.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/core": "^5.0.2", + "@octokit/plugin-paginate-rest": "11.4.4-cjs.2", + "@octokit/plugin-request-log": "^4.0.0", + "@octokit/plugin-rest-endpoint-methods": "13.3.2-cjs.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/types": { + "version": "13.10.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^24.2.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@pkgr/core": { + "version": "0.2.9", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "node_modules/@react-native-community/cli": { + "version": "13.6.4", + "license": "MIT", + "dependencies": { + "@react-native-community/cli-clean": "13.6.4", + "@react-native-community/cli-config": "13.6.4", + "@react-native-community/cli-debugger-ui": "13.6.4", + "@react-native-community/cli-doctor": "13.6.4", + "@react-native-community/cli-hermes": "13.6.4", + "@react-native-community/cli-server-api": "13.6.4", + "@react-native-community/cli-tools": "13.6.4", + "@react-native-community/cli-types": "13.6.4", + "chalk": "^4.1.2", + "commander": "^9.4.1", + "deepmerge": "^4.3.0", + "execa": "^5.0.0", + "find-up": "^4.1.0", + "fs-extra": "^8.1.0", + "graceful-fs": "^4.1.3", + "prompts": "^2.4.2", + "semver": "^7.5.2" + }, + "bin": { + "react-native": "build/bin.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@react-native-community/cli-clean": { + "version": "13.6.4", + "license": "MIT", + "dependencies": { + "@react-native-community/cli-tools": "13.6.4", + "chalk": "^4.1.2", + "execa": "^5.0.0", + "fast-glob": "^3.3.2" + } + }, + "node_modules/@react-native-community/cli-config": { + "version": "13.6.4", + "license": "MIT", + "dependencies": { + "@react-native-community/cli-tools": "13.6.4", + "chalk": "^4.1.2", + "cosmiconfig": "^5.1.0", + "deepmerge": "^4.3.0", + "fast-glob": "^3.3.2", + "joi": "^17.2.1" + } + }, + "node_modules/@react-native-community/cli-config/node_modules/argparse": { + "version": "1.0.10", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@react-native-community/cli-config/node_modules/cosmiconfig": { + "version": "5.2.1", + "license": "MIT", + "dependencies": { + "import-fresh": "^2.0.0", + "is-directory": "^0.3.1", + "js-yaml": "^3.13.1", + "parse-json": "^4.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@react-native-community/cli-config/node_modules/import-fresh": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "caller-path": "^2.0.0", + "resolve-from": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@react-native-community/cli-config/node_modules/js-yaml": { + "version": "3.14.2", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@react-native-community/cli-config/node_modules/parse-json": { + "version": "4.0.0", + "license": "MIT", + "dependencies": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@react-native-community/cli-config/node_modules/resolve-from": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@react-native-community/cli-debugger-ui": { + "version": "13.6.4", + "license": "MIT", + "dependencies": { + "serve-static": "^1.13.1" + } + }, + "node_modules/@react-native-community/cli-doctor": { + "version": "13.6.4", + "license": "MIT", + "dependencies": { + "@react-native-community/cli-config": "13.6.4", + "@react-native-community/cli-platform-android": "13.6.4", + "@react-native-community/cli-platform-apple": "13.6.4", + "@react-native-community/cli-platform-ios": "13.6.4", + "@react-native-community/cli-tools": "13.6.4", + "chalk": "^4.1.2", + "command-exists": "^1.2.8", + "deepmerge": "^4.3.0", + "envinfo": "^7.10.0", + "execa": "^5.0.0", + "hermes-profile-transformer": "^0.0.6", + "node-stream-zip": "^1.9.1", + "ora": "^5.4.1", + "semver": "^7.5.2", + "strip-ansi": "^5.2.0", + "wcwidth": "^1.0.1", + "yaml": "^2.2.1" + } + }, + "node_modules/@react-native-community/cli-doctor/node_modules/ansi-regex": { + "version": "4.1.1", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@react-native-community/cli-doctor/node_modules/envinfo": { + "version": "7.21.0", + "license": "MIT", + "bin": { + "envinfo": "dist/cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@react-native-community/cli-doctor/node_modules/strip-ansi": { + "version": "5.2.0", + "license": "MIT", + "dependencies": { + "ansi-regex": "^4.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@react-native-community/cli-hermes": { + "version": "13.6.4", + "license": "MIT", + "dependencies": { + "@react-native-community/cli-platform-android": "13.6.4", + "@react-native-community/cli-tools": "13.6.4", + "chalk": "^4.1.2", + "hermes-profile-transformer": "^0.0.6" + } + }, + "node_modules/@react-native-community/cli-platform-android": { + "version": "13.6.4", + "license": "MIT", + "dependencies": { + "@react-native-community/cli-tools": "13.6.4", + "chalk": "^4.1.2", + "execa": "^5.0.0", + "fast-glob": "^3.3.2", + "fast-xml-parser": "^4.2.4", + "logkitty": "^0.7.1" + } + }, + "node_modules/@react-native-community/cli-platform-apple": { + "version": "13.6.4", + "license": "MIT", + "dependencies": { + "@react-native-community/cli-tools": "13.6.4", + "chalk": "^4.1.2", + "execa": "^5.0.0", + "fast-glob": "^3.3.2", + "fast-xml-parser": "^4.0.12", + "ora": "^5.4.1" + } + }, + "node_modules/@react-native-community/cli-platform-ios": { + "version": "13.6.4", + "license": "MIT", + "dependencies": { + "@react-native-community/cli-platform-apple": "13.6.4" + } + }, + "node_modules/@react-native-community/cli-server-api": { + "version": "13.6.4", + "license": "MIT", + "dependencies": { + "@react-native-community/cli-debugger-ui": "13.6.4", + "@react-native-community/cli-tools": "13.6.4", + "compression": "^1.7.1", + "connect": "^3.6.5", + "errorhandler": "^1.5.1", + "nocache": "^3.0.1", + "pretty-format": "^26.6.2", + "serve-static": "^1.13.1", + "ws": "^7.5.1" + } + }, + "node_modules/@react-native-community/cli-server-api/node_modules/@jest/types": { + "version": "26.6.2", + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^15.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": ">= 10.14.2" + } + }, + "node_modules/@react-native-community/cli-server-api/node_modules/@types/node": { + "version": "24.10.2", + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@react-native-community/cli-server-api/node_modules/@types/yargs": { + "version": "15.0.20", + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@react-native-community/cli-server-api/node_modules/pretty-format": { + "version": "26.6.2", + "license": "MIT", + "dependencies": { + "@jest/types": "^26.6.2", + "ansi-regex": "^5.0.0", + "ansi-styles": "^4.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@react-native-community/cli-server-api/node_modules/react-is": { + "version": "17.0.2", + "license": "MIT" + }, + "node_modules/@react-native-community/cli-server-api/node_modules/undici-types": { + "version": "7.16.0", + "license": "MIT" + }, + "node_modules/@react-native-community/cli-tools": { + "version": "13.6.4", + "license": "MIT", + "dependencies": { + "appdirsjs": "^1.2.4", + "chalk": "^4.1.2", + "execa": "^5.0.0", + "find-up": "^5.0.0", + "mime": "^2.4.1", + "node-fetch": "^2.6.0", + "open": "^6.2.0", + "ora": "^5.4.1", + "semver": "^7.5.2", + "shell-quote": "^1.7.3", + "sudo-prompt": "^9.0.0" + } + }, + "node_modules/@react-native-community/cli-tools/node_modules/find-up": { + "version": "5.0.0", + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@react-native-community/cli-tools/node_modules/is-wsl": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@react-native-community/cli-tools/node_modules/locate-path": { + "version": "6.0.0", + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@react-native-community/cli-tools/node_modules/open": { + "version": "6.4.0", + "license": "MIT", + "dependencies": { + "is-wsl": "^1.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@react-native-community/cli-tools/node_modules/p-limit": { + "version": "3.1.0", + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@react-native-community/cli-tools/node_modules/p-locate": { + "version": "5.0.0", + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@react-native-community/cli-types": { + "version": "13.6.4", + "license": "MIT", + "dependencies": { + "joi": "^17.2.1" + } + }, + "node_modules/@react-native-community/cli/node_modules/fs-extra": { + "version": "8.1.0", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/@react-native-community/cli/node_modules/jsonfile": { + "version": "4.0.0", + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@react-native-community/cli/node_modules/universalify": { + "version": "0.1.2", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/@react-native/assets-registry": { + "version": "0.74.81", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@react-native/babel-plugin-codegen": { + "version": "0.74.81", + "license": "MIT", + "dependencies": { + "@react-native/codegen": "0.74.81" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@react-native/babel-preset": { + "version": "0.74.81", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.20.0", + "@babel/plugin-proposal-async-generator-functions": "^7.0.0", + "@babel/plugin-proposal-class-properties": "^7.18.0", + "@babel/plugin-proposal-export-default-from": "^7.0.0", + "@babel/plugin-proposal-logical-assignment-operators": "^7.18.0", + "@babel/plugin-proposal-nullish-coalescing-operator": "^7.18.0", + "@babel/plugin-proposal-numeric-separator": "^7.0.0", + "@babel/plugin-proposal-object-rest-spread": "^7.20.0", + "@babel/plugin-proposal-optional-catch-binding": "^7.0.0", + "@babel/plugin-proposal-optional-chaining": "^7.20.0", + "@babel/plugin-syntax-dynamic-import": "^7.8.0", + "@babel/plugin-syntax-export-default-from": "^7.0.0", + "@babel/plugin-syntax-flow": "^7.18.0", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.0.0", + "@babel/plugin-syntax-optional-chaining": "^7.0.0", + "@babel/plugin-transform-arrow-functions": "^7.0.0", + "@babel/plugin-transform-async-to-generator": "^7.20.0", + "@babel/plugin-transform-block-scoping": "^7.0.0", + "@babel/plugin-transform-classes": "^7.0.0", + "@babel/plugin-transform-computed-properties": "^7.0.0", + "@babel/plugin-transform-destructuring": "^7.20.0", + "@babel/plugin-transform-flow-strip-types": "^7.20.0", + "@babel/plugin-transform-function-name": "^7.0.0", + "@babel/plugin-transform-literals": "^7.0.0", + "@babel/plugin-transform-modules-commonjs": "^7.0.0", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.0.0", + "@babel/plugin-transform-parameters": "^7.0.0", + "@babel/plugin-transform-private-methods": "^7.22.5", + "@babel/plugin-transform-private-property-in-object": "^7.22.11", + "@babel/plugin-transform-react-display-name": "^7.0.0", + "@babel/plugin-transform-react-jsx": "^7.0.0", + "@babel/plugin-transform-react-jsx-self": "^7.0.0", + "@babel/plugin-transform-react-jsx-source": "^7.0.0", + "@babel/plugin-transform-runtime": "^7.0.0", + "@babel/plugin-transform-shorthand-properties": "^7.0.0", + "@babel/plugin-transform-spread": "^7.0.0", + "@babel/plugin-transform-sticky-regex": "^7.0.0", + "@babel/plugin-transform-typescript": "^7.5.0", + "@babel/plugin-transform-unicode-regex": "^7.0.0", + "@babel/template": "^7.0.0", + "@react-native/babel-plugin-codegen": "0.74.81", + "babel-plugin-transform-flow-enums": "^0.0.2", + "react-refresh": "^0.14.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@babel/core": "*" + } + }, + "node_modules/@react-native/codegen": { + "version": "0.74.81", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.0", + "glob": "^7.1.1", + "hermes-parser": "0.19.1", + "invariant": "^2.2.4", + "jscodeshift": "^0.14.0", + "mkdirp": "^0.5.1", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@babel/preset-env": "^7.1.6" + } + }, + "node_modules/@react-native/codegen/node_modules/brace-expansion": { + "version": "1.1.12", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@react-native/codegen/node_modules/glob": { + "version": "7.2.3", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@react-native/codegen/node_modules/minimatch": { + "version": "3.1.2", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@react-native/codegen/node_modules/mkdirp": { + "version": "0.5.6", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/@react-native/community-cli-plugin": { + "version": "0.74.81", + "license": "MIT", + "dependencies": { + "@react-native-community/cli-server-api": "13.6.4", + "@react-native-community/cli-tools": "13.6.4", + "@react-native/dev-middleware": "0.74.81", + "@react-native/metro-babel-transformer": "0.74.81", + "chalk": "^4.0.0", + "execa": "^5.1.1", + "metro": "^0.80.3", + "metro-config": "^0.80.3", + "metro-core": "^0.80.3", + "node-fetch": "^2.2.0", + "querystring": "^0.2.1", + "readline": "^1.3.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@react-native/debugger-frontend": { + "version": "0.74.81", + "license": "BSD-3-Clause", + "engines": { + "node": ">=18" + } + }, + "node_modules/@react-native/dev-middleware": { + "version": "0.74.81", + "license": "MIT", + "dependencies": { + "@isaacs/ttlcache": "^1.4.1", + "@react-native/debugger-frontend": "0.74.81", + "@rnx-kit/chromium-edge-launcher": "^1.0.0", + "chrome-launcher": "^0.15.2", + "connect": "^3.6.5", + "debug": "^2.2.0", + "node-fetch": "^2.2.0", + "nullthrows": "^1.1.1", + "open": "^7.0.3", + "selfsigned": "^2.4.1", + "serve-static": "^1.13.1", + "temp-dir": "^2.0.0", + "ws": "^6.2.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@react-native/dev-middleware/node_modules/debug": { + "version": "2.6.9", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/@react-native/dev-middleware/node_modules/ms": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/@react-native/dev-middleware/node_modules/open": { + "version": "7.4.2", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@react-native/dev-middleware/node_modules/temp-dir": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@react-native/dev-middleware/node_modules/ws": { + "version": "6.2.3", + "license": "MIT", + "dependencies": { + "async-limiter": "~1.0.0" + } + }, + "node_modules/@react-native/gradle-plugin": { + "version": "0.74.81", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@react-native/js-polyfills": { + "version": "0.74.81", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@react-native/metro-babel-transformer": { + "version": "0.74.81", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.20.0", + "@react-native/babel-preset": "0.74.81", + "hermes-parser": "0.19.1", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@babel/core": "*" + } + }, + "node_modules/@react-native/normalize-colors": { + "version": "0.74.81", + "license": "MIT" + }, + "node_modules/@react-native/virtualized-lists": { + "version": "0.74.81", + "license": "MIT", + "dependencies": { + "invariant": "^2.2.4", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/react": "^18.2.6", + "react": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@rnx-kit/chromium-edge-launcher": { + "version": "1.0.0", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "^18.0.0", + "escape-string-regexp": "^4.0.0", + "is-wsl": "^2.2.0", + "lighthouse-logger": "^1.0.0", + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + }, + "engines": { + "node": ">=14.15" + } + }, + "node_modules/@rnx-kit/chromium-edge-launcher/node_modules/@types/node": { + "version": "18.19.130", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@rnx-kit/chromium-edge-launcher/node_modules/undici-types": { + "version": "5.26.5", + "license": "MIT" + }, + "node_modules/@runanywhere/core": { + "resolved": "packages/core", + "link": true + }, + "node_modules/@runanywhere/llamacpp": { + "resolved": "packages/llamacpp", + "link": true + }, + "node_modules/@runanywhere/onnx": { + "resolved": "packages/onnx", + "link": true + }, + "node_modules/@sideway/address": { + "version": "4.1.5", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@sideway/formula": { + "version": "3.0.1", + "license": "BSD-3-Clause" + }, + "node_modules/@sideway/pinpoint": { + "version": "2.0.0", + "license": "BSD-3-Clause" + }, + "node_modules/@sigstore/bundle": { + "version": "2.3.2", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.3.2" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@sigstore/core": { + "version": "1.1.0", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@sigstore/protobuf-specs": { + "version": "0.3.3", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@sigstore/sign": { + "version": "2.3.2", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^2.3.2", + "@sigstore/core": "^1.0.0", + "@sigstore/protobuf-specs": "^0.3.2", + "make-fetch-happen": "^13.0.1", + "proc-log": "^4.2.0", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@sigstore/tuf": { + "version": "2.3.4", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.3.2", + "tuf-js": "^2.2.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@sigstore/verify": { + "version": "1.2.1", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^2.3.2", + "@sigstore/core": "^1.1.0", + "@sigstore/protobuf-specs": "^0.3.2" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@ts-morph/common": { + "version": "0.28.1", + "dev": true, + "license": "MIT", + "dependencies": { + "minimatch": "^10.0.1", + "path-browserify": "^1.0.1", + "tinyglobby": "^0.2.14" + } + }, + "node_modules/@ts-morph/common/node_modules/minimatch": { + "version": "10.1.1", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/brace-expansion": "^5.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "dev": true, + "license": "MIT" + }, + "node_modules/@tufjs/canonical-json": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@tufjs/models": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@tufjs/canonical-json": "2.0.0", + "minimatch": "^9.0.4" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.9.0", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/minimatch": { + "version": "3.0.5", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/minimist": { + "version": "1.2.5", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.29", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/node-forge": { + "version": "1.3.14", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node-forge/node_modules/@types/node": { + "version": "24.10.2", + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/node-forge/node_modules/undici-types": { + "version": "7.16.0", + "license": "MIT" + }, + "node_modules/@types/normalize-package-data": { + "version": "2.4.4", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.27", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.50.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.50.0", + "@typescript-eslint/type-utils": "8.50.0", + "@typescript-eslint/utils": "8.50.0", + "@typescript-eslint/visitor-keys": "8.50.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.50.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.50.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.50.0", + "@typescript-eslint/types": "8.50.0", + "@typescript-eslint/typescript-estree": "8.50.0", + "@typescript-eslint/visitor-keys": "8.50.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.50.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.50.0", + "@typescript-eslint/types": "^8.50.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.50.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.50.0", + "@typescript-eslint/visitor-keys": "8.50.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.50.0", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.50.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.50.0", + "@typescript-eslint/typescript-estree": "8.50.0", + "@typescript-eslint/utils": "8.50.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.50.0", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.50.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.50.0", + "@typescript-eslint/tsconfig-utils": "8.50.0", + "@typescript-eslint/types": "8.50.0", + "@typescript-eslint/visitor-keys": "8.50.0", + "debug": "^4.3.4", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.50.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.50.0", + "@typescript-eslint/types": "8.50.0", + "@typescript-eslint/typescript-estree": "8.50.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.50.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.50.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "dev": true, + "license": "ISC" + }, + "node_modules/@yarnpkg/lockfile": { + "version": "1.1.0", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/@yarnpkg/parsers": { + "version": "3.0.2", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "js-yaml": "^3.10.0", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/@yarnpkg/parsers/node_modules/argparse": { + "version": "1.0.10", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@yarnpkg/parsers/node_modules/js-yaml": { + "version": "3.14.2", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@zkochan/js-yaml": { + "version": "0.0.7", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/abbrev": { + "version": "2.0.0", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/negotiator": { + "version": "0.6.3", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/add-stream": { + "version": "1.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "7.1.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/aggregate-error": { + "version": "4.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "clean-stack": "^4.0.0", + "indent-string": "^5.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/aggregate-error/node_modules/indent-string": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv/node_modules/json-schema-traverse": { + "version": "0.4.1", + "dev": true, + "license": "MIT" + }, + "node_modules/anser": { + "version": "1.4.10", + "license": "MIT" + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-escapes/node_modules/type-fest": { + "version": "0.21.3", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-fragments": { + "version": "0.2.1", + "license": "MIT", + "dependencies": { + "colorette": "^1.0.7", + "slice-ansi": "^2.0.0", + "strip-ansi": "^5.0.0" + } + }, + "node_modules/ansi-fragments/node_modules/ansi-regex": { + "version": "4.1.1", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-fragments/node_modules/strip-ansi": { + "version": "5.2.0", + "license": "MIT", + "dependencies": { + "ansi-regex": "^4.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.1", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/appdirsjs": { + "version": "1.2.7", + "license": "MIT" + }, + "node_modules/aproba": { + "version": "2.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/arg": { + "version": "4.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-differ": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/array-ify": { + "version": "1.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/array-union": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/arrify": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/asap": { + "version": "2.0.6", + "license": "MIT" + }, + "node_modules/ast-types": { + "version": "0.15.2", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/astral-regex": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/async": { + "version": "3.2.6", + "dev": true, + "license": "MIT" + }, + "node_modules/async-limiter": { + "version": "1.0.1", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "dev": true, + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.2", + "dev": true, + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/babel-core": { + "version": "7.0.0-bridge.0", + "license": "MIT", + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.14", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.7", + "@babel/helper-define-polyfill-provider": "^0.6.5", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs2/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.13.0", + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.5", + "core-js-compat": "^3.43.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.5", + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.5" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-transform-flow-enums": { + "version": "0.0.2", + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-flow": "^7.12.1" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.32", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/before-after-hook": { + "version": "2.2.3", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/bin-links": { + "version": "4.0.4", + "dev": true, + "license": "ISC", + "dependencies": { + "cmd-shim": "^6.0.0", + "npm-normalize-package-bin": "^3.0.0", + "read-cmd-shim": "^4.0.0", + "write-file-atomic": "^5.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.0", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.8.25", + "caniuse-lite": "^1.0.30001754", + "electron-to-chromium": "^1.5.249", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.1.4" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "license": "MIT" + }, + "node_modules/byte-size": { + "version": "8.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.17" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cacache": { + "version": "18.0.4", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^3.1.0", + "fs-minipass": "^3.0.0", + "glob": "^10.2.2", + "lru-cache": "^10.0.1", + "minipass": "^7.0.3", + "minipass-collect": "^2.0.1", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^4.0.0", + "ssri": "^10.0.0", + "tar": "^6.1.11", + "unique-filename": "^3.0.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/caller-callsite": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "callsites": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/caller-callsite/node_modules/callsites": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/caller-path": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "caller-callsite": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-keys": { + "version": "6.2.2", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase": "^5.3.1", + "map-obj": "^4.0.0", + "quick-lru": "^4.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001757", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chardet": { + "version": "2.1.1", + "dev": true, + "license": "MIT" + }, + "node_modules/chownr": { + "version": "2.0.0", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/chrome-launcher": { + "version": "0.15.2", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "*", + "escape-string-regexp": "^4.0.0", + "is-wsl": "^2.2.0", + "lighthouse-logger": "^1.0.0" + }, + "bin": { + "print-chrome-path": "bin/print-chrome-path.js" + }, + "engines": { + "node": ">=12.13.0" + } + }, + "node_modules/chrome-launcher/node_modules/@types/node": { + "version": "24.10.2", + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/chrome-launcher/node_modules/undici-types": { + "version": "7.16.0", + "license": "MIT" + }, + "node_modules/ci-info": { + "version": "3.9.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/clean-stack": { + "version": "4.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "5.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clean-stack/node_modules/escape-string-regexp": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-width": { + "version": "3.0.0", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/clone-deep": { + "version": "4.0.1", + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/cmd-shim": { + "version": "6.0.3", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/code-block-writer": { + "version": "13.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "license": "MIT" + }, + "node_modules/color-support": { + "version": "1.1.3", + "dev": true, + "license": "ISC", + "bin": { + "color-support": "bin.js" + } + }, + "node_modules/colorette": { + "version": "1.4.0", + "license": "MIT" + }, + "node_modules/columnify": { + "version": "1.6.0", + "dev": true, + "license": "MIT", + "dependencies": { + "strip-ansi": "^6.0.1", + "wcwidth": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/command-exists": { + "version": "1.2.9", + "license": "MIT" + }, + "node_modules/commander": { + "version": "9.5.0", + "license": "MIT", + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/commitlint": { + "version": "17.8.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/cli": "^17.8.1", + "@commitlint/types": "^17.8.1" + }, + "bin": { + "commitlint": "cli.js" + }, + "engines": { + "node": ">=v14" + } + }, + "node_modules/common-ancestor-path": { + "version": "1.0.1", + "dev": true, + "license": "ISC" + }, + "node_modules/commondir": { + "version": "1.0.1", + "license": "MIT" + }, + "node_modules/compare-func": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "array-ify": "^1.0.0", + "dot-prop": "^5.1.0" + } + }, + "node_modules/compressible": { + "version": "2.0.18", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compressible/node_modules/mime-db": { + "version": "1.54.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "dev": true, + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/connect": { + "version": "3.7.0", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "finalhandler": "1.1.2", + "parseurl": "~1.3.3", + "utils-merge": "1.0.1" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/connect/node_modules/debug": { + "version": "2.6.9", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/connect/node_modules/ms": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "dev": true, + "license": "ISC" + }, + "node_modules/conventional-changelog-angular": { + "version": "6.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "compare-func": "^2.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/conventional-changelog-conventionalcommits": { + "version": "6.1.0", + "dev": true, + "license": "ISC", + "dependencies": { + "compare-func": "^2.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/conventional-changelog-core": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "add-stream": "^1.0.0", + "conventional-changelog-writer": "^6.0.0", + "conventional-commits-parser": "^4.0.0", + "dateformat": "^3.0.3", + "get-pkg-repo": "^4.2.1", + "git-raw-commits": "^3.0.0", + "git-remote-origin-url": "^2.0.0", + "git-semver-tags": "^5.0.0", + "normalize-package-data": "^3.0.3", + "read-pkg": "^3.0.0", + "read-pkg-up": "^3.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/conventional-changelog-core/node_modules/find-up": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/conventional-changelog-core/node_modules/locate-path": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^2.0.0", + "path-exists": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/conventional-changelog-core/node_modules/p-limit": { + "version": "1.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/conventional-changelog-core/node_modules/p-locate": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/conventional-changelog-core/node_modules/p-try": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/conventional-changelog-core/node_modules/path-exists": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/conventional-changelog-core/node_modules/read-pkg-up": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^2.0.0", + "read-pkg": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/conventional-changelog-preset-loader": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/conventional-changelog-writer": { + "version": "6.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "conventional-commits-filter": "^3.0.0", + "dateformat": "^3.0.3", + "handlebars": "^4.7.7", + "json-stringify-safe": "^5.0.1", + "meow": "^8.1.2", + "semver": "^7.0.0", + "split": "^1.0.1" + }, + "bin": { + "conventional-changelog-writer": "cli.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/conventional-commits-filter": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash.ismatch": "^4.4.0", + "modify-values": "^1.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/conventional-commits-parser": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "is-text-path": "^1.0.1", + "JSONStream": "^1.3.5", + "meow": "^8.1.2", + "split2": "^3.2.2" + }, + "bin": { + "conventional-commits-parser": "cli.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/conventional-recommended-bump": { + "version": "7.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "concat-stream": "^2.0.0", + "conventional-changelog-preset-loader": "^3.0.0", + "conventional-commits-filter": "^3.0.0", + "conventional-commits-parser": "^4.0.0", + "git-raw-commits": "^3.0.0", + "git-semver-tags": "^5.0.0", + "meow": "^8.1.2" + }, + "bin": { + "conventional-recommended-bump": "cli.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/core-js-compat": { + "version": "3.47.0", + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "license": "MIT" + }, + "node_modules/cosmiconfig": { + "version": "8.3.6", + "dev": true, + "license": "MIT", + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cross-spawn/node_modules/isexe": { + "version": "2.0.0", + "license": "ISC" + }, + "node_modules/cross-spawn/node_modules/which": { + "version": "2.0.2", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "devOptional": true, + "license": "MIT" + }, + "node_modules/dargs": { + "version": "7.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/dateformat": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/dayjs": { + "version": "1.11.19", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decamelize-keys": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "decamelize": "^1.1.0", + "map-obj": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decamelize-keys/node_modules/map-obj": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dedent": { + "version": "1.5.3", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/del": { + "version": "7.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "globby": "^13.1.2", + "graceful-fs": "^4.2.10", + "is-glob": "^4.0.3", + "is-path-cwd": "^3.0.0", + "is-path-inside": "^4.0.0", + "p-map": "^5.5.0", + "rimraf": "^3.0.2", + "slash": "^4.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/del-cli": { + "version": "5.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "del": "^7.1.0", + "meow": "^10.1.3" + }, + "bin": { + "del": "cli.js", + "del-cli": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/del-cli/node_modules/camelcase": { + "version": "6.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/del-cli/node_modules/camelcase-keys": { + "version": "7.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase": "^6.3.0", + "map-obj": "^4.1.0", + "quick-lru": "^5.1.1", + "type-fest": "^1.2.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/del-cli/node_modules/decamelize": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/del-cli/node_modules/find-up": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/del-cli/node_modules/indent-string": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/del-cli/node_modules/locate-path": { + "version": "6.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/del-cli/node_modules/meow": { + "version": "10.1.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/minimist": "^1.2.2", + "camelcase-keys": "^7.0.0", + "decamelize": "^5.0.0", + "decamelize-keys": "^1.1.0", + "hard-rejection": "^2.1.0", + "minimist-options": "4.1.0", + "normalize-package-data": "^3.0.2", + "read-pkg-up": "^8.0.0", + "redent": "^4.0.0", + "trim-newlines": "^4.0.2", + "type-fest": "^1.2.2", + "yargs-parser": "^20.2.9" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/del-cli/node_modules/p-limit": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/del-cli/node_modules/p-locate": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/del-cli/node_modules/quick-lru": { + "version": "5.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/del-cli/node_modules/read-pkg": { + "version": "6.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/normalize-package-data": "^2.4.0", + "normalize-package-data": "^3.0.2", + "parse-json": "^5.2.0", + "type-fest": "^1.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/del-cli/node_modules/read-pkg-up": { + "version": "8.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^5.0.0", + "read-pkg": "^6.0.0", + "type-fest": "^1.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/del-cli/node_modules/redent": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^5.0.0", + "strip-indent": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/del-cli/node_modules/strip-indent": { + "version": "4.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/del-cli/node_modules/trim-newlines": { + "version": "4.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/del-cli/node_modules/yargs-parser": { + "version": "20.2.9", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/del/node_modules/p-map": { + "version": "5.5.0", + "dev": true, + "license": "MIT", + "dependencies": { + "aggregate-error": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/del/node_modules/slash": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/denodeify": { + "version": "1.2.1", + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/deprecation": { + "version": "2.3.1", + "dev": true, + "license": "ISC" + }, + "node_modules/destroy": { + "version": "1.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-indent": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/diff": { + "version": "4.0.2", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dot-prop": { + "version": "5.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "is-obj": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "16.4.7", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "11.0.7", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand/node_modules/dotenv": { + "version": "16.6.1", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "dev": true, + "license": "MIT" + }, + "node_modules/ee-first": { + "version": "1.1.1", + "license": "MIT" + }, + "node_modules/ejs": { + "version": "3.1.10", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.262", + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "1.0.2", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/encoding": { + "version": "0.1.13", + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/enquirer": { + "version": "2.3.6", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-colors": "^4.1.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/envinfo": { + "version": "7.13.0", + "dev": true, + "license": "MIT", + "bin": { + "envinfo": "dist/cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/error-ex": { + "version": "1.3.4", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/error-stack-parser": { + "version": "2.1.4", + "license": "MIT", + "dependencies": { + "stackframe": "^1.3.4" + } + }, + "node_modules/errorhandler": { + "version": "1.5.1", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.7", + "escape-html": "~1.0.3" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-config-prettier": { + "version": "9.1.2", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-prettier": { + "version": "5.5.4", + "dev": true, + "license": "MIT", + "dependencies": { + "prettier-linter-helpers": "^1.0.0", + "synckit": "^0.11.7" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.12", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/find-up": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/is-path-inside": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint/node_modules/locate-path": { + "version": "6.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint/node_modules/p-limit": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/p-locate": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "dev": true, + "license": "MIT" + }, + "node_modules/execa": { + "version": "5.1.1", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "license": "Apache-2.0" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-diff": { + "version": "1.3.0", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fast-xml-parser": { + "version": "4.5.3", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^1.1.1" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fastq": { + "version": "1.19.1", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/figures": { + "version": "3.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^1.0.5" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/figures/node_modules/escape-string-regexp": { + "version": "1.0.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/filelist": { + "version": "1.0.4", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.6", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.1.2", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "statuses": "~1.5.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/finalhandler/node_modules/on-finished": { + "version": "2.3.0", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/statuses": { + "version": "1.5.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/find-cache-dir": { + "version": "2.1.0", + "license": "MIT", + "dependencies": { + "commondir": "^1.0.1", + "make-dir": "^2.0.0", + "pkg-dir": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/find-cache-dir/node_modules/find-up": { + "version": "3.0.0", + "license": "MIT", + "dependencies": { + "locate-path": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/find-cache-dir/node_modules/locate-path": { + "version": "3.0.0", + "license": "MIT", + "dependencies": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/find-cache-dir/node_modules/p-locate": { + "version": "3.0.0", + "license": "MIT", + "dependencies": { + "p-limit": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/find-cache-dir/node_modules/path-exists": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/find-cache-dir/node_modules/pkg-dir": { + "version": "3.0.0", + "license": "MIT", + "dependencies": { + "find-up": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "dev": true, + "license": "BSD-3-Clause", + "bin": { + "flat": "cli.js" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "dev": true, + "license": "ISC" + }, + "node_modules/flow-enums-runtime": { + "version": "0.0.6", + "license": "MIT" + }, + "node_modules/flow-parser": { + "version": "0.293.0", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/front-matter": { + "version": "4.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "js-yaml": "^3.13.1" + } + }, + "node_modules/front-matter/node_modules/argparse": { + "version": "1.0.10", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/front-matter/node_modules/js-yaml": { + "version": "3.14.2", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/fs-extra": { + "version": "11.3.3", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fs-minipass": { + "version": "3.0.3", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.4.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-pkg-repo": { + "version": "4.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@hutson/parse-repository-url": "^3.0.0", + "hosted-git-info": "^4.0.0", + "through2": "^2.0.0", + "yargs": "^16.2.0" + }, + "bin": { + "get-pkg-repo": "src/cli.js" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-pkg-repo/node_modules/cliui": { + "version": "7.0.4", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/get-pkg-repo/node_modules/hosted-git-info": { + "version": "4.1.0", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/get-pkg-repo/node_modules/lru-cache": { + "version": "6.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/get-pkg-repo/node_modules/yargs": { + "version": "16.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/get-pkg-repo/node_modules/yargs-parser": { + "version": "20.2.9", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/get-port": { + "version": "5.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/git-raw-commits": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "dargs": "^7.0.0", + "meow": "^8.1.2", + "split2": "^3.2.2" + }, + "bin": { + "git-raw-commits": "cli.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/git-remote-origin-url": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "gitconfiglocal": "^1.0.0", + "pify": "^2.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/git-remote-origin-url/node_modules/pify": { + "version": "2.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/git-semver-tags": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "meow": "^8.1.2", + "semver": "^7.0.0" + }, + "bin": { + "git-semver-tags": "cli.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/git-up": { + "version": "7.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "is-ssh": "^1.4.0", + "parse-url": "^8.1.0" + } + }, + "node_modules/git-url-parse": { + "version": "14.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "git-up": "^7.0.0" + } + }, + "node_modules/gitconfiglocal": { + "version": "1.0.0", + "dev": true, + "license": "BSD", + "dependencies": { + "ini": "^1.3.2" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/global-dirs": { + "version": "0.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.4" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globals/node_modules/type-fest": { + "version": "0.20.2", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby": { + "version": "13.2.2", + "dev": true, + "license": "MIT", + "dependencies": { + "dir-glob": "^3.0.1", + "fast-glob": "^3.3.0", + "ignore": "^5.2.4", + "merge2": "^1.4.1", + "slash": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby/node_modules/slash": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "dev": true, + "license": "MIT" + }, + "node_modules/handlebars": { + "version": "4.7.8", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/hard-rejection": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-unicode": { + "version": "2.0.1", + "dev": true, + "license": "ISC" + }, + "node_modules/hasown": { + "version": "2.0.2", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.19.1", + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.19.1", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.19.1" + } + }, + "node_modules/hermes-profile-transformer": { + "version": "0.0.6", + "license": "MIT", + "dependencies": { + "source-map": "^0.7.3" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/hermes-profile-transformer/node_modules/source-map": { + "version": "0.7.6", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/hosted-git-info": { + "version": "7.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http-errors": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/ignore-walk": { + "version": "6.0.5", + "dev": true, + "license": "ISC", + "dependencies": { + "minimatch": "^9.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/image-size": { + "version": "1.2.1", + "license": "MIT", + "dependencies": { + "queue": "6.0.2" + }, + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=16.x" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/import-local": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "dev": true, + "license": "ISC" + }, + "node_modules/init-package-json": { + "version": "6.0.3", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/package-json": "^5.0.0", + "npm-package-arg": "^11.0.0", + "promzard": "^1.0.0", + "read": "^3.0.1", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4", + "validate-npm-package-name": "^5.0.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/inquirer": { + "version": "8.2.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/external-editor": "^1.0.0", + "ansi-escapes": "^4.2.1", + "chalk": "^4.1.1", + "cli-cursor": "^3.1.0", + "cli-width": "^3.0.0", + "figures": "^3.0.0", + "lodash": "^4.17.21", + "mute-stream": "0.0.8", + "ora": "^5.4.1", + "run-async": "^2.4.0", + "rxjs": "^7.5.5", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "through": "^2.3.6", + "wrap-ansi": "^6.0.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/inquirer/node_modules/mute-stream": { + "version": "0.0.8", + "dev": true, + "license": "ISC" + }, + "node_modules/inquirer/node_modules/wrap-ansi": { + "version": "6.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/invariant": { + "version": "2.2.4", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/ip-address": { + "version": "10.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "license": "MIT" + }, + "node_modules/is-ci": { + "version": "3.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ci-info": "^3.2.0" + }, + "bin": { + "is-ci": "bin.js" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-directory": { + "version": "0.3.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-interactive": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-lambda": { + "version": "1.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/is-number": { + "version": "7.0.0", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-obj": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-path-cwd": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-path-inside": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-plain-obj": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-ssh": { + "version": "1.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "protocols": "^2.0.1" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-text-path": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "text-extensions": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "3.1.1", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16" + } + }, + "node_modules/isobject": { + "version": "3.0.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jake": { + "version": "10.9.4", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-diff": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "29.7.0", + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-environment-node/node_modules/@types/node": { + "version": "24.10.2", + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/jest-environment-node/node_modules/undici-types": { + "version": "7.16.0", + "license": "MIT" + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-mock": { + "version": "29.7.0", + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-mock/node_modules/@types/node": { + "version": "24.10.2", + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/jest-mock/node_modules/undici-types": { + "version": "7.16.0", + "license": "MIT" + }, + "node_modules/jest-util": { + "version": "29.7.0", + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-util/node_modules/@types/node": { + "version": "24.10.2", + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/jest-util/node_modules/picomatch": { + "version": "2.3.1", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/jest-util/node_modules/undici-types": { + "version": "7.16.0", + "license": "MIT" + }, + "node_modules/jest-validate": { + "version": "29.7.0", + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/@types/node": { + "version": "24.10.2", + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jest-worker/node_modules/undici-types": { + "version": "7.16.0", + "license": "MIT" + }, + "node_modules/joi": { + "version": "17.13.3", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.3.0", + "@hapi/topo": "^5.1.0", + "@sideway/address": "^4.1.5", + "@sideway/formula": "^3.0.1", + "@sideway/pinpoint": "^2.0.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsc-android": { + "version": "250231.0.0", + "license": "BSD-2-Clause" + }, + "node_modules/jsc-safe-url": { + "version": "0.2.4", + "license": "0BSD" + }, + "node_modules/jscodeshift": { + "version": "0.14.0", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.13.16", + "@babel/parser": "^7.13.16", + "@babel/plugin-proposal-class-properties": "^7.13.0", + "@babel/plugin-proposal-nullish-coalescing-operator": "^7.13.8", + "@babel/plugin-proposal-optional-chaining": "^7.13.12", + "@babel/plugin-transform-modules-commonjs": "^7.13.8", + "@babel/preset-flow": "^7.13.13", + "@babel/preset-typescript": "^7.13.0", + "@babel/register": "^7.13.16", + "babel-core": "^7.0.0-bridge.0", + "chalk": "^4.1.2", + "flow-parser": "0.*", + "graceful-fs": "^4.2.4", + "micromatch": "^4.0.4", + "neo-async": "^2.5.0", + "node-dir": "^0.1.17", + "recast": "^0.21.0", + "temp": "^0.8.4", + "write-file-atomic": "^2.3.0" + }, + "bin": { + "jscodeshift": "bin/jscodeshift.js" + }, + "peerDependencies": { + "@babel/preset-env": "^7.1.6" + } + }, + "node_modules/jscodeshift/node_modules/write-file-atomic": { + "version": "2.4.3", + "license": "ISC", + "dependencies": { + "graceful-fs": "^4.1.11", + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.2" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-better-errors": { + "version": "1.0.2", + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "3.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-nice": { + "version": "1.1.4", + "dev": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "dev": true, + "license": "ISC" + }, + "node_modules/json5": { + "version": "2.2.3", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonc-parser": { + "version": "3.2.0", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "6.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonparse": { + "version": "1.3.1", + "dev": true, + "engines": [ + "node >= 0.2.0" + ], + "license": "MIT" + }, + "node_modules/JSONStream": { + "version": "1.3.5", + "dev": true, + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "jsonparse": "^1.2.0", + "through": ">=2.2.7 <3" + }, + "bin": { + "JSONStream": "bin.js" + }, + "engines": { + "node": "*" + } + }, + "node_modules/just-diff": { + "version": "6.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/just-diff-apply": { + "version": "5.5.0", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lerna": { + "version": "8.2.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@lerna/create": "8.2.4", + "@npmcli/arborist": "7.5.4", + "@npmcli/package-json": "5.2.0", + "@npmcli/run-script": "8.1.0", + "@nx/devkit": ">=17.1.2 < 21", + "@octokit/plugin-enterprise-rest": "6.0.1", + "@octokit/rest": "20.1.2", + "aproba": "2.0.0", + "byte-size": "8.1.1", + "chalk": "4.1.0", + "clone-deep": "4.0.1", + "cmd-shim": "6.0.3", + "color-support": "1.1.3", + "columnify": "1.6.0", + "console-control-strings": "^1.1.0", + "conventional-changelog-angular": "7.0.0", + "conventional-changelog-core": "5.0.1", + "conventional-recommended-bump": "7.0.1", + "cosmiconfig": "9.0.0", + "dedent": "1.5.3", + "envinfo": "7.13.0", + "execa": "5.0.0", + "fs-extra": "^11.2.0", + "get-port": "5.1.1", + "get-stream": "6.0.0", + "git-url-parse": "14.0.0", + "glob-parent": "6.0.2", + "graceful-fs": "4.2.11", + "has-unicode": "2.0.1", + "import-local": "3.1.0", + "ini": "^1.3.8", + "init-package-json": "6.0.3", + "inquirer": "^8.2.4", + "is-ci": "3.0.1", + "is-stream": "2.0.0", + "jest-diff": ">=29.4.3 < 30", + "js-yaml": "4.1.0", + "libnpmaccess": "8.0.6", + "libnpmpublish": "9.0.9", + "load-json-file": "6.2.0", + "make-dir": "4.0.0", + "minimatch": "3.0.5", + "multimatch": "5.0.0", + "node-fetch": "2.6.7", + "npm-package-arg": "11.0.2", + "npm-packlist": "8.0.2", + "npm-registry-fetch": "^17.1.0", + "nx": ">=17.1.2 < 21", + "p-map": "4.0.0", + "p-map-series": "2.1.0", + "p-pipe": "3.1.0", + "p-queue": "6.6.2", + "p-reduce": "2.1.0", + "p-waterfall": "2.1.1", + "pacote": "^18.0.6", + "pify": "5.0.0", + "read-cmd-shim": "4.0.0", + "resolve-from": "5.0.0", + "rimraf": "^4.4.1", + "semver": "^7.3.8", + "set-blocking": "^2.0.0", + "signal-exit": "3.0.7", + "slash": "3.0.0", + "ssri": "^10.0.6", + "string-width": "^4.2.3", + "tar": "6.2.1", + "temp-dir": "1.0.0", + "through": "2.3.8", + "tinyglobby": "0.2.12", + "typescript": ">=3 < 6", + "upath": "2.0.1", + "uuid": "^10.0.0", + "validate-npm-package-license": "3.0.4", + "validate-npm-package-name": "5.0.1", + "wide-align": "1.1.5", + "write-file-atomic": "5.0.1", + "write-pkg": "4.0.0", + "yargs": "17.7.2", + "yargs-parser": "21.1.1" + }, + "bin": { + "lerna": "dist/cli.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/lerna/node_modules/@npmcli/package-json": { + "version": "5.2.0", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^5.0.0", + "glob": "^10.2.2", + "hosted-git-info": "^7.0.0", + "json-parse-even-better-errors": "^3.0.0", + "normalize-package-data": "^6.0.0", + "proc-log": "^4.0.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/lerna/node_modules/chalk": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/lerna/node_modules/conventional-changelog-angular": { + "version": "7.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "compare-func": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/lerna/node_modules/cosmiconfig": { + "version": "9.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/lerna/node_modules/cosmiconfig/node_modules/js-yaml": { + "version": "4.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/lerna/node_modules/execa": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/lerna/node_modules/execa/node_modules/get-stream": { + "version": "6.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lerna/node_modules/execa/node_modules/is-stream": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lerna/node_modules/get-stream": { + "version": "6.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lerna/node_modules/is-stream": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/lerna/node_modules/js-yaml": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/lerna/node_modules/make-dir": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lerna/node_modules/minimatch": { + "version": "3.0.5", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/lerna/node_modules/minimatch/node_modules/brace-expansion": { + "version": "1.1.12", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/lerna/node_modules/minipass": { + "version": "4.2.8", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/lerna/node_modules/node-fetch": { + "version": "2.6.7", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/lerna/node_modules/normalize-package-data": { + "version": "6.0.2", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^7.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/lerna/node_modules/npm-package-arg": { + "version": "11.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "hosted-git-info": "^7.0.0", + "proc-log": "^4.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^5.0.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/lerna/node_modules/pify": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lerna/node_modules/rimraf": { + "version": "4.4.1", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^9.2.0" + }, + "bin": { + "rimraf": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/lerna/node_modules/rimraf/node_modules/glob": { + "version": "9.3.5", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "minimatch": "^8.0.2", + "minipass": "^4.2.4", + "path-scurry": "^1.6.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/lerna/node_modules/rimraf/node_modules/minimatch": { + "version": "8.0.4", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/lerna/node_modules/tinyglobby": { + "version": "0.2.12", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.4.3", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/libnpmaccess": { + "version": "8.0.6", + "dev": true, + "license": "ISC", + "dependencies": { + "npm-package-arg": "^11.0.2", + "npm-registry-fetch": "^17.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/libnpmpublish": { + "version": "9.0.9", + "dev": true, + "license": "ISC", + "dependencies": { + "ci-info": "^4.0.0", + "normalize-package-data": "^6.0.1", + "npm-package-arg": "^11.0.2", + "npm-registry-fetch": "^17.0.1", + "proc-log": "^4.2.0", + "semver": "^7.3.7", + "sigstore": "^2.2.0", + "ssri": "^10.0.6" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/libnpmpublish/node_modules/ci-info": { + "version": "4.3.1", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/libnpmpublish/node_modules/normalize-package-data": { + "version": "6.0.2", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^7.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/lighthouse-logger": { + "version": "1.4.2", + "license": "Apache-2.0", + "dependencies": { + "debug": "^2.6.9", + "marky": "^1.2.2" + } + }, + "node_modules/lighthouse-logger/node_modules/debug": { + "version": "2.6.9", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/lighthouse-logger/node_modules/ms": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "dev": true, + "license": "MIT" + }, + "node_modules/load-json-file": { + "version": "6.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.15", + "parse-json": "^5.0.0", + "strip-bom": "^4.0.0", + "type-fest": "^0.6.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/load-json-file/node_modules/strip-bom": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/load-json-file/node_modules/type-fest": { + "version": "0.6.0", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=8" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "license": "MIT" + }, + "node_modules/lodash.isfunction": { + "version": "3.0.9", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.ismatch": { + "version": "4.4.0", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.kebabcase": { + "version": "4.1.1", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.mergewith": { + "version": "4.6.2", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.snakecase": { + "version": "4.1.1", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.startcase": { + "version": "4.4.0", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.throttle": { + "version": "4.1.1", + "license": "MIT" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.upperfirst": { + "version": "4.3.1", + "dev": true, + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/logkitty": { + "version": "0.7.1", + "license": "MIT", + "dependencies": { + "ansi-fragments": "^0.2.1", + "dayjs": "^1.8.15", + "yargs": "^15.1.0" + }, + "bin": { + "logkitty": "bin/logkitty.js" + } + }, + "node_modules/logkitty/node_modules/cliui": { + "version": "6.0.0", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/logkitty/node_modules/wrap-ansi": { + "version": "6.2.0", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/logkitty/node_modules/y18n": { + "version": "4.0.3", + "license": "ISC" + }, + "node_modules/logkitty/node_modules/yargs": { + "version": "15.4.1", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/logkitty/node_modules/yargs-parser": { + "version": "18.1.3", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "dev": true, + "license": "ISC" + }, + "node_modules/make-dir": { + "version": "2.1.0", + "license": "MIT", + "dependencies": { + "pify": "^4.0.1", + "semver": "^5.6.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/make-dir/node_modules/pify": { + "version": "4.0.1", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "5.7.2", + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "dev": true, + "license": "ISC" + }, + "node_modules/make-fetch-happen": { + "version": "13.0.1", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/agent": "^2.0.0", + "cacache": "^18.0.0", + "http-cache-semantics": "^4.1.1", + "is-lambda": "^1.0.1", + "minipass": "^7.0.2", + "minipass-fetch": "^3.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.3", + "proc-log": "^4.2.0", + "promise-retry": "^2.0.1", + "ssri": "^10.0.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/makeerror": { + "version": "1.0.12", + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/map-obj": { + "version": "4.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/marky": { + "version": "1.3.0", + "license": "Apache-2.0" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "5.2.1", + "license": "MIT" + }, + "node_modules/meow": { + "version": "8.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/minimist": "^1.2.0", + "camelcase-keys": "^6.2.2", + "decamelize-keys": "^1.1.0", + "hard-rejection": "^2.1.0", + "minimist-options": "4.1.0", + "normalize-package-data": "^3.0.0", + "read-pkg-up": "^7.0.1", + "redent": "^3.0.0", + "trim-newlines": "^3.0.0", + "type-fest": "^0.18.0", + "yargs-parser": "^20.2.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/meow/node_modules/type-fest": { + "version": "0.18.1", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/meow/node_modules/yargs-parser": { + "version": "20.2.9", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/metro": { + "version": "0.80.12", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "@babel/core": "^7.20.0", + "@babel/generator": "^7.20.0", + "@babel/parser": "^7.20.0", + "@babel/template": "^7.0.0", + "@babel/traverse": "^7.20.0", + "@babel/types": "^7.20.0", + "accepts": "^1.3.7", + "chalk": "^4.0.0", + "ci-info": "^2.0.0", + "connect": "^3.6.5", + "debug": "^2.2.0", + "denodeify": "^1.2.1", + "error-stack-parser": "^2.0.6", + "flow-enums-runtime": "^0.0.6", + "graceful-fs": "^4.2.4", + "hermes-parser": "0.23.1", + "image-size": "^1.0.2", + "invariant": "^2.2.4", + "jest-worker": "^29.6.3", + "jsc-safe-url": "^0.2.2", + "lodash.throttle": "^4.1.1", + "metro-babel-transformer": "0.80.12", + "metro-cache": "0.80.12", + "metro-cache-key": "0.80.12", + "metro-config": "0.80.12", + "metro-core": "0.80.12", + "metro-file-map": "0.80.12", + "metro-resolver": "0.80.12", + "metro-runtime": "0.80.12", + "metro-source-map": "0.80.12", + "metro-symbolicate": "0.80.12", + "metro-transform-plugins": "0.80.12", + "metro-transform-worker": "0.80.12", + "mime-types": "^2.1.27", + "nullthrows": "^1.1.1", + "serialize-error": "^2.1.0", + "source-map": "^0.5.6", + "strip-ansi": "^6.0.0", + "throat": "^5.0.0", + "ws": "^7.5.10", + "yargs": "^17.6.2" + }, + "bin": { + "metro": "src/cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/metro-babel-transformer": { + "version": "0.80.12", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.20.0", + "flow-enums-runtime": "^0.0.6", + "hermes-parser": "0.23.1", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/metro-babel-transformer/node_modules/hermes-estree": { + "version": "0.23.1", + "license": "MIT" + }, + "node_modules/metro-babel-transformer/node_modules/hermes-parser": { + "version": "0.23.1", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.23.1" + } + }, + "node_modules/metro-cache": { + "version": "0.80.12", + "license": "MIT", + "dependencies": { + "exponential-backoff": "^3.1.1", + "flow-enums-runtime": "^0.0.6", + "metro-core": "0.80.12" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/metro-cache-key": { + "version": "0.80.12", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/metro-config": { + "version": "0.80.12", + "license": "MIT", + "dependencies": { + "connect": "^3.6.5", + "cosmiconfig": "^5.0.5", + "flow-enums-runtime": "^0.0.6", + "jest-validate": "^29.6.3", + "metro": "0.80.12", + "metro-cache": "0.80.12", + "metro-core": "0.80.12", + "metro-runtime": "0.80.12" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/metro-config/node_modules/argparse": { + "version": "1.0.10", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/metro-config/node_modules/cosmiconfig": { + "version": "5.2.1", + "license": "MIT", + "dependencies": { + "import-fresh": "^2.0.0", + "is-directory": "^0.3.1", + "js-yaml": "^3.13.1", + "parse-json": "^4.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/metro-config/node_modules/import-fresh": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "caller-path": "^2.0.0", + "resolve-from": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/metro-config/node_modules/js-yaml": { + "version": "3.14.2", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/metro-config/node_modules/parse-json": { + "version": "4.0.0", + "license": "MIT", + "dependencies": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/metro-config/node_modules/resolve-from": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/metro-core": { + "version": "0.80.12", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "lodash.throttle": "^4.1.1", + "metro-resolver": "0.80.12" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/metro-file-map": { + "version": "0.80.12", + "license": "MIT", + "dependencies": { + "anymatch": "^3.0.3", + "debug": "^2.2.0", + "fb-watchman": "^2.0.0", + "flow-enums-runtime": "^0.0.6", + "graceful-fs": "^4.2.4", + "invariant": "^2.2.4", + "jest-worker": "^29.6.3", + "micromatch": "^4.0.4", + "node-abort-controller": "^3.1.1", + "nullthrows": "^1.1.1", + "walker": "^1.0.7" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/metro-file-map/node_modules/debug": { + "version": "2.6.9", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/metro-file-map/node_modules/ms": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/metro-minify-terser": { + "version": "0.80.12", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "terser": "^5.15.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/metro-resolver": { + "version": "0.80.12", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/metro-runtime": { + "version": "0.80.12", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.0", + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/metro-source-map": { + "version": "0.80.12", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.20.0", + "@babel/types": "^7.20.0", + "flow-enums-runtime": "^0.0.6", + "invariant": "^2.2.4", + "metro-symbolicate": "0.80.12", + "nullthrows": "^1.1.1", + "ob1": "0.80.12", + "source-map": "^0.5.6", + "vlq": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/metro-source-map/node_modules/source-map": { + "version": "0.5.7", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/metro-symbolicate": { + "version": "0.80.12", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "invariant": "^2.2.4", + "metro-source-map": "0.80.12", + "nullthrows": "^1.1.1", + "source-map": "^0.5.6", + "through2": "^2.0.1", + "vlq": "^1.0.0" + }, + "bin": { + "metro-symbolicate": "src/index.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/metro-symbolicate/node_modules/source-map": { + "version": "0.5.7", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/metro-transform-plugins": { + "version": "0.80.12", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.20.0", + "@babel/generator": "^7.20.0", + "@babel/template": "^7.0.0", + "@babel/traverse": "^7.20.0", + "flow-enums-runtime": "^0.0.6", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/metro-transform-worker": { + "version": "0.80.12", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.20.0", + "@babel/generator": "^7.20.0", + "@babel/parser": "^7.20.0", + "@babel/types": "^7.20.0", + "flow-enums-runtime": "^0.0.6", + "metro": "0.80.12", + "metro-babel-transformer": "0.80.12", + "metro-cache": "0.80.12", + "metro-cache-key": "0.80.12", + "metro-minify-terser": "0.80.12", + "metro-source-map": "0.80.12", + "metro-transform-plugins": "0.80.12", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/metro/node_modules/ci-info": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/metro/node_modules/debug": { + "version": "2.6.9", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/metro/node_modules/hermes-estree": { + "version": "0.23.1", + "license": "MIT" + }, + "node_modules/metro/node_modules/hermes-parser": { + "version": "0.23.1", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.23.1" + } + }, + "node_modules/metro/node_modules/ms": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/metro/node_modules/source-map": { + "version": "0.5.7", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minimist-options": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "arrify": "^1.0.1", + "is-plain-obj": "^1.1.0", + "kind-of": "^6.0.3" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-collect": { + "version": "2.0.1", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-fetch": { + "version": "3.0.5", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.0.3", + "minipass-sized": "^1.0.3", + "minizlib": "^2.1.2" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + }, + "optionalDependencies": { + "encoding": "^0.1.13" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.5", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-flush/node_modules/minipass": { + "version": "3.3.6", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized": { + "version": "1.0.3", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized/node_modules/minipass": { + "version": "3.3.6", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/modify-values": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "license": "MIT" + }, + "node_modules/multimatch": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/minimatch": "^3.0.3", + "array-differ": "^3.0.0", + "array-union": "^2.1.0", + "arrify": "^2.0.1", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/multimatch/node_modules/arrify": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/multimatch/node_modules/brace-expansion": { + "version": "1.1.12", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/multimatch/node_modules/minimatch": { + "version": "3.1.2", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/mute-stream": { + "version": "1.0.0", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.4", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "license": "MIT" + }, + "node_modules/nitrogen": { + "version": "0.31.10", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "react-native-nitro-modules": "^0.31.10", + "ts-morph": "^27.0.0", + "yargs": "^18.0.0", + "zod": "^4.0.5" + }, + "bin": { + "nitrogen": "lib/index.js" + } + }, + "node_modules/nitrogen/node_modules/ansi-regex": { + "version": "6.2.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/nitrogen/node_modules/ansi-styles": { + "version": "6.2.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/nitrogen/node_modules/chalk": { + "version": "5.6.2", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/nitrogen/node_modules/cliui": { + "version": "9.0.1", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/nitrogen/node_modules/emoji-regex": { + "version": "10.6.0", + "dev": true, + "license": "MIT" + }, + "node_modules/nitrogen/node_modules/string-width": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/nitrogen/node_modules/strip-ansi": { + "version": "7.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/nitrogen/node_modules/wrap-ansi": { + "version": "9.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/nitrogen/node_modules/yargs": { + "version": "18.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/nitrogen/node_modules/yargs-parser": { + "version": "22.0.0", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/nocache": { + "version": "3.0.4", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "license": "MIT" + }, + "node_modules/node-dir": { + "version": "0.1.17", + "license": "MIT", + "dependencies": { + "minimatch": "^3.0.2" + }, + "engines": { + "node": ">= 0.10.5" + } + }, + "node_modules/node-dir/node_modules/brace-expansion": { + "version": "1.1.12", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/node-dir/node_modules/minimatch": { + "version": "3.1.2", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-forge": { + "version": "1.3.3", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/node-gyp": { + "version": "10.3.1", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "glob": "^10.3.10", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^13.0.0", + "nopt": "^7.0.0", + "proc-log": "^4.1.0", + "semver": "^7.3.5", + "tar": "^6.2.1", + "which": "^4.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "license": "MIT" + }, + "node_modules/node-machine-id": { + "version": "1.1.12", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.27", + "license": "MIT" + }, + "node_modules/node-stream-zip": { + "version": "1.15.0", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/antelle" + } + }, + "node_modules/nopt": { + "version": "7.2.1", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/normalize-package-data": { + "version": "3.0.3", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^4.0.1", + "is-core-module": "^2.5.0", + "semver": "^7.3.4", + "validate-npm-package-license": "^3.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/normalize-package-data/node_modules/hosted-git-info": { + "version": "4.1.0", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/normalize-package-data/node_modules/lru-cache": { + "version": "6.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-bundled": { + "version": "3.0.1", + "dev": true, + "license": "ISC", + "dependencies": { + "npm-normalize-package-bin": "^3.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm-install-checks": { + "version": "6.3.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "semver": "^7.1.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm-normalize-package-bin": { + "version": "3.0.1", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm-package-arg": { + "version": "11.0.3", + "dev": true, + "license": "ISC", + "dependencies": { + "hosted-git-info": "^7.0.0", + "proc-log": "^4.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^5.0.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm-packlist": { + "version": "8.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "ignore-walk": "^6.0.4" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm-pick-manifest": { + "version": "9.1.0", + "dev": true, + "license": "ISC", + "dependencies": { + "npm-install-checks": "^6.0.0", + "npm-normalize-package-bin": "^3.0.0", + "npm-package-arg": "^11.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm-registry-fetch": { + "version": "17.1.0", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/redact": "^2.0.0", + "jsonparse": "^1.3.1", + "make-fetch-happen": "^13.0.0", + "minipass": "^7.0.2", + "minipass-fetch": "^3.0.0", + "minizlib": "^2.1.2", + "npm-package-arg": "^11.0.0", + "proc-log": "^4.0.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nullthrows": { + "version": "1.1.1", + "license": "MIT" + }, + "node_modules/nx": { + "version": "20.8.3", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@napi-rs/wasm-runtime": "0.2.4", + "@yarnpkg/lockfile": "^1.1.0", + "@yarnpkg/parsers": "3.0.2", + "@zkochan/js-yaml": "0.0.7", + "axios": "^1.8.3", + "chalk": "^4.1.0", + "cli-cursor": "3.1.0", + "cli-spinners": "2.6.1", + "cliui": "^8.0.1", + "dotenv": "~16.4.5", + "dotenv-expand": "~11.0.6", + "enquirer": "~2.3.6", + "figures": "3.2.0", + "flat": "^5.0.2", + "front-matter": "^4.0.2", + "ignore": "^5.0.4", + "jest-diff": "^29.4.1", + "jsonc-parser": "3.2.0", + "lines-and-columns": "2.0.3", + "minimatch": "9.0.3", + "node-machine-id": "1.1.12", + "npm-run-path": "^4.0.1", + "open": "^8.4.0", + "ora": "5.3.0", + "resolve.exports": "2.0.3", + "semver": "^7.5.3", + "string-width": "^4.2.3", + "tar-stream": "~2.2.0", + "tmp": "~0.2.1", + "tsconfig-paths": "^4.1.2", + "tslib": "^2.3.0", + "yaml": "^2.6.0", + "yargs": "^17.6.2", + "yargs-parser": "21.1.1" + }, + "bin": { + "nx": "bin/nx.js", + "nx-cloud": "bin/nx-cloud.js" + }, + "optionalDependencies": { + "@nx/nx-darwin-arm64": "20.8.3", + "@nx/nx-darwin-x64": "20.8.3", + "@nx/nx-freebsd-x64": "20.8.3", + "@nx/nx-linux-arm-gnueabihf": "20.8.3", + "@nx/nx-linux-arm64-gnu": "20.8.3", + "@nx/nx-linux-arm64-musl": "20.8.3", + "@nx/nx-linux-x64-gnu": "20.8.3", + "@nx/nx-linux-x64-musl": "20.8.3", + "@nx/nx-win32-arm64-msvc": "20.8.3", + "@nx/nx-win32-x64-msvc": "20.8.3" + }, + "peerDependencies": { + "@swc-node/register": "^1.8.0", + "@swc/core": "^1.3.85" + }, + "peerDependenciesMeta": { + "@swc-node/register": { + "optional": true + }, + "@swc/core": { + "optional": true + } + } + }, + "node_modules/nx/node_modules/cli-spinners": { + "version": "2.6.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/nx/node_modules/lines-and-columns": { + "version": "2.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/nx/node_modules/minimatch": { + "version": "9.0.3", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/nx/node_modules/ora": { + "version": "5.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "log-symbols": "^4.0.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/nx/node_modules/ora/node_modules/cli-spinners": { + "version": "2.9.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ob1": { + "version": "0.80.12", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/open": { + "version": "8.4.2", + "dev": true, + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/ora": { + "version": "5.4.1", + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-finally": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-map": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-map-series": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/p-map/node_modules/aggregate-error": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-map/node_modules/clean-stack": { + "version": "2.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/p-pipe": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-queue": { + "version": "6.6.2", + "dev": true, + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.4", + "p-timeout": "^3.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-reduce": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/p-timeout": { + "version": "3.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-finally": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/p-waterfall": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "p-reduce": "^2.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/pacote": { + "version": "18.0.6", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^5.0.0", + "@npmcli/installed-package-contents": "^2.0.1", + "@npmcli/package-json": "^5.1.0", + "@npmcli/promise-spawn": "^7.0.0", + "@npmcli/run-script": "^8.0.0", + "cacache": "^18.0.0", + "fs-minipass": "^3.0.0", + "minipass": "^7.0.2", + "npm-package-arg": "^11.0.0", + "npm-packlist": "^8.0.0", + "npm-pick-manifest": "^9.0.0", + "npm-registry-fetch": "^17.0.0", + "proc-log": "^4.0.0", + "promise-retry": "^2.0.1", + "sigstore": "^2.2.0", + "ssri": "^10.0.0", + "tar": "^6.1.11" + }, + "bin": { + "pacote": "bin/index.js" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-conflict-json": { + "version": "3.0.1", + "dev": true, + "license": "ISC", + "dependencies": { + "json-parse-even-better-errors": "^3.0.0", + "just-diff": "^6.0.0", + "just-diff-apply": "^5.2.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-json/node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "dev": true, + "license": "MIT" + }, + "node_modules/parse-path": { + "version": "7.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "protocols": "^2.0.0" + } + }, + "node_modules/parse-url": { + "version": "8.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "parse-path": "^7.0.0" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.7.3", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/proc-log": { + "version": "4.2.0", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "license": "MIT" + }, + "node_modules/proggy": { + "version": "2.0.0", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/promise": { + "version": "8.3.0", + "license": "MIT", + "dependencies": { + "asap": "~2.0.6" + } + }, + "node_modules/promise-all-reject-late": { + "version": "1.0.1", + "dev": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/promise-call-limit": { + "version": "3.0.2", + "dev": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/promise-inflight": { + "version": "1.0.1", + "dev": true, + "license": "ISC" + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/promzard": { + "version": "1.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "read": "^3.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/protocols": { + "version": "2.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/querystring": { + "version": "0.2.1", + "license": "MIT", + "engines": { + "node": ">=0.4.x" + } + }, + "node_modules/queue": { + "version": "6.0.2", + "license": "MIT", + "dependencies": { + "inherits": "~2.0.3" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/quick-lru": { + "version": "4.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/react": { + "version": "18.2.0", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-devtools-core": { + "version": "5.3.2", + "license": "MIT", + "dependencies": { + "shell-quote": "^1.6.1", + "ws": "^7" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "license": "MIT" + }, + "node_modules/react-native": { + "version": "0.74.0", + "license": "MIT", + "dependencies": { + "@jest/create-cache-key-function": "^29.6.3", + "@react-native-community/cli": "13.6.4", + "@react-native-community/cli-platform-android": "13.6.4", + "@react-native-community/cli-platform-ios": "13.6.4", + "@react-native/assets-registry": "0.74.81", + "@react-native/codegen": "0.74.81", + "@react-native/community-cli-plugin": "0.74.81", + "@react-native/gradle-plugin": "0.74.81", + "@react-native/js-polyfills": "0.74.81", + "@react-native/normalize-colors": "0.74.81", + "@react-native/virtualized-lists": "0.74.81", + "abort-controller": "^3.0.0", + "anser": "^1.4.9", + "ansi-regex": "^5.0.0", + "base64-js": "^1.5.1", + "chalk": "^4.0.0", + "event-target-shim": "^5.0.1", + "flow-enums-runtime": "^0.0.6", + "invariant": "^2.2.4", + "jest-environment-node": "^29.6.3", + "jsc-android": "^250231.0.0", + "memoize-one": "^5.0.0", + "metro-runtime": "^0.80.3", + "metro-source-map": "^0.80.3", + "mkdirp": "^0.5.1", + "nullthrows": "^1.1.1", + "pretty-format": "^26.5.2", + "promise": "^8.3.0", + "react-devtools-core": "^5.0.0", + "react-refresh": "^0.14.0", + "react-shallow-renderer": "^16.15.0", + "regenerator-runtime": "^0.13.2", + "scheduler": "0.24.0-canary-efb381bbf-20230505", + "stacktrace-parser": "^0.1.10", + "whatwg-fetch": "^3.0.0", + "ws": "^6.2.2", + "yargs": "^17.6.2" + }, + "bin": { + "react-native": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/react": "^18.2.6", + "react": "18.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-native-nitro-modules": { + "version": "0.31.10", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native/node_modules/@jest/types": { + "version": "26.6.2", + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^15.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": ">= 10.14.2" + } + }, + "node_modules/react-native/node_modules/@types/node": { + "version": "24.10.2", + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/react-native/node_modules/@types/yargs": { + "version": "15.0.20", + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/react-native/node_modules/mkdirp": { + "version": "0.5.6", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/react-native/node_modules/pretty-format": { + "version": "26.6.2", + "license": "MIT", + "dependencies": { + "@jest/types": "^26.6.2", + "ansi-regex": "^5.0.0", + "ansi-styles": "^4.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/react-native/node_modules/react-is": { + "version": "17.0.2", + "license": "MIT" + }, + "node_modules/react-native/node_modules/undici-types": { + "version": "7.16.0", + "license": "MIT" + }, + "node_modules/react-native/node_modules/ws": { + "version": "6.2.3", + "license": "MIT", + "dependencies": { + "async-limiter": "~1.0.0" + } + }, + "node_modules/react-refresh": { + "version": "0.14.2", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-shallow-renderer": { + "version": "16.15.0", + "license": "MIT", + "dependencies": { + "object-assign": "^4.1.1", + "react-is": "^16.12.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/read": { + "version": "3.0.1", + "dev": true, + "license": "ISC", + "dependencies": { + "mute-stream": "^1.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/read-cmd-shim": { + "version": "4.0.0", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/read-package-json-fast": { + "version": "3.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "json-parse-even-better-errors": "^3.0.0", + "npm-normalize-package-bin": "^3.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/read-pkg": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "load-json-file": "^4.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/read-pkg-up": { + "version": "7.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.1.0", + "read-pkg": "^5.2.0", + "type-fest": "^0.8.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg-up/node_modules/hosted-git-info": { + "version": "2.8.9", + "dev": true, + "license": "ISC" + }, + "node_modules/read-pkg-up/node_modules/normalize-package-data": { + "version": "2.5.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "node_modules/read-pkg-up/node_modules/read-pkg": { + "version": "5.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/normalize-package-data": "^2.4.0", + "normalize-package-data": "^2.5.0", + "parse-json": "^5.0.0", + "type-fest": "^0.6.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg-up/node_modules/read-pkg/node_modules/type-fest": { + "version": "0.6.0", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg-up/node_modules/semver": { + "version": "5.7.2", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/read-pkg-up/node_modules/type-fest": { + "version": "0.8.1", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg/node_modules/hosted-git-info": { + "version": "2.8.9", + "dev": true, + "license": "ISC" + }, + "node_modules/read-pkg/node_modules/load-json-file": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2", + "parse-json": "^4.0.0", + "pify": "^3.0.0", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/read-pkg/node_modules/normalize-package-data": { + "version": "2.5.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "node_modules/read-pkg/node_modules/parse-json": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/read-pkg/node_modules/path-type": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/read-pkg/node_modules/semver": { + "version": "5.7.2", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readline": { + "version": "1.3.0", + "license": "BSD" + }, + "node_modules/recast": { + "version": "0.21.5", + "license": "MIT", + "dependencies": { + "ast-types": "0.15.2", + "esprima": "~4.0.0", + "source-map": "~0.6.1", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/regenerate": { + "version": "1.4.2", + "license": "MIT" + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.2.2", + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "license": "MIT" + }, + "node_modules/regexpu-core": { + "version": "6.4.0", + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.2.2", + "regjsgen": "^0.8.0", + "regjsparser": "^0.13.0", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.2.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regjsgen": { + "version": "0.8.0", + "license": "MIT" + }, + "node_modules/regjsparser": { + "version": "0.13.0", + "license": "BSD-2-Clause", + "dependencies": { + "jsesc": "~3.1.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "license": "ISC" + }, + "node_modules/resolve": { + "version": "1.22.11", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-global": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "global-dirs": "^0.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve.exports": { + "version": "2.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "1.1.12", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "3.1.2", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/run-async": { + "version": "2.4.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "devOptional": true, + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.24.0-canary-efb381bbf-20230505", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/selfsigned": { + "version": "2.4.1", + "license": "MIT", + "dependencies": { + "@types/node-forge": "^1.3.0", + "node-forge": "^1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver": { + "version": "7.7.3", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.0", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/send/node_modules/mime": { + "version": "1.6.0", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/serialize-error": { + "version": "2.1.0", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/serve-static": { + "version": "1.16.2", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/serve-static/node_modules/encodeurl": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "license": "ISC" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "license": "ISC" + }, + "node_modules/shallow-clone": { + "version": "3.0.1", + "license": "MIT", + "dependencies": { + "kind-of": "^6.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.8.3", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "license": "ISC" + }, + "node_modules/sigstore": { + "version": "2.3.1", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^2.3.2", + "@sigstore/core": "^1.0.0", + "@sigstore/protobuf-specs": "^0.3.2", + "@sigstore/sign": "^2.3.2", + "@sigstore/tuf": "^2.3.4", + "@sigstore/verify": "^1.2.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "license": "MIT" + }, + "node_modules/slash": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/slice-ansi": { + "version": "2.1.0", + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.0", + "astral-regex": "^1.0.0", + "is-fullwidth-code-point": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "3.2.1", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/slice-ansi/node_modules/color-convert": { + "version": "1.9.3", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/slice-ansi/node_modules/color-name": { + "version": "1.1.3", + "license": "MIT" + }, + "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/sort-keys": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "is-plain-obj": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.22", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/split": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "through": "2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/split2": { + "version": "3.2.2", + "dev": true, + "license": "ISC", + "dependencies": { + "readable-stream": "^3.0.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "license": "BSD-3-Clause" + }, + "node_modules/ssri": { + "version": "10.0.6", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/stackframe": { + "version": "1.3.4", + "license": "MIT" + }, + "node_modules/stacktrace-parser": { + "version": "0.1.11", + "license": "MIT", + "dependencies": { + "type-fest": "^0.7.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/stacktrace-parser/node_modules/type-fest": { + "version": "0.7.1", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=8" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strnum": { + "version": "1.1.2", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/sudo-prompt": { + "version": "9.2.1", + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/synckit": { + "version": "0.11.11", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.2.9" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "dev": true, + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar/node_modules/fs-minipass": { + "version": "2.1.0", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/tar/node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/temp": { + "version": "0.8.4", + "license": "MIT", + "dependencies": { + "rimraf": "~2.6.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/temp-dir": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/temp/node_modules/brace-expansion": { + "version": "1.1.12", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/temp/node_modules/glob": { + "version": "7.2.3", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/temp/node_modules/minimatch": { + "version": "3.1.2", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/temp/node_modules/rimraf": { + "version": "2.6.3", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/terser": { + "version": "5.44.1", + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "license": "MIT" + }, + "node_modules/text-extensions": { + "version": "1.9.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "dev": true, + "license": "MIT" + }, + "node_modules/throat": { + "version": "5.0.0", + "license": "MIT" + }, + "node_modules/through": { + "version": "2.3.8", + "dev": true, + "license": "MIT" + }, + "node_modules/through2": { + "version": "2.0.5", + "license": "MIT", + "dependencies": { + "readable-stream": "~2.3.6", + "xtend": "~4.0.1" + } + }, + "node_modules/through2/node_modules/readable-stream": { + "version": "2.3.8", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/through2/node_modules/safe-buffer": { + "version": "5.1.2", + "license": "MIT" + }, + "node_modules/through2/node_modules/string_decoder": { + "version": "1.1.1", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tmp": { + "version": "0.2.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "license": "BSD-3-Clause" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "license": "MIT" + }, + "node_modules/treeverse": { + "version": "3.0.0", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/trim-newlines": { + "version": "3.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-morph": { + "version": "27.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@ts-morph/common": "~0.28.1", + "code-block-writer": "^13.0.3" + } + }, + "node_modules/tsconfig-paths": { + "version": "4.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "license": "0BSD" + }, + "node_modules/tuf-js": { + "version": "2.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@tufjs/models": "2.0.1", + "debug": "^4.3.4", + "make-fetch-happen": "^13.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "1.4.0", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "dev": true, + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "dev": true, + "license": "MIT" + }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.1", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.2.1", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.2.0", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unique-filename": { + "version": "3.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "unique-slug": "^4.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/unique-slug": { + "version": "4.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/universal-user-agent": { + "version": "6.0.1", + "dev": true, + "license": "ISC" + }, + "node_modules/universalify": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/upath": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4", + "yarn": "*" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.4", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "10.0.0", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/validate-npm-package-name": { + "version": "5.0.1", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vlq": { + "version": "1.0.1", + "license": "MIT" + }, + "node_modules/walk-up-path": { + "version": "3.0.1", + "dev": true, + "license": "ISC" + }, + "node_modules/walker": { + "version": "1.0.8", + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "license": "MIT" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "4.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^16.13.0 || >=18.0.0" + } + }, + "node_modules/which-module": { + "version": "2.0.1", + "license": "ISC" + }, + "node_modules/wide-align": { + "version": "1.1.5", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "5.0.1", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/write-file-atomic/node_modules/signal-exit": { + "version": "4.1.0", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/write-json-file": { + "version": "3.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-indent": "^5.0.0", + "graceful-fs": "^4.1.15", + "make-dir": "^2.1.0", + "pify": "^4.0.1", + "sort-keys": "^2.0.0", + "write-file-atomic": "^2.4.2" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/write-json-file/node_modules/pify": { + "version": "4.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/write-json-file/node_modules/write-file-atomic": { + "version": "2.4.3", + "dev": true, + "license": "ISC", + "dependencies": { + "graceful-fs": "^4.1.11", + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.2" + } + }, + "node_modules/write-pkg": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "sort-keys": "^2.0.0", + "type-fest": "^0.4.1", + "write-json-file": "^3.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/write-pkg/node_modules/type-fest": { + "version": "0.4.1", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=6" + } + }, + "node_modules/ws": { + "version": "7.5.10", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.8.2", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.1.13", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "packages/core": { + "name": "@runanywhere/core", + "version": "0.16.11", + "license": "MIT", + "devDependencies": { + "@types/react": "^18.2.44", + "nitrogen": "^0.31.10", + "react-native-nitro-modules": "^0.31.10", + "typescript": "^5.2.2" + }, + "peerDependencies": { + "react": "*", + "react-native": "*", + "react-native-blob-util": "^0.19.0", + "react-native-device-info": "^11.0.0", + "react-native-fs": "^2.20.0", + "react-native-nitro-modules": "^0.31.3", + "react-native-zip-archive": "^6.1.0" + }, + "peerDependenciesMeta": { + "react-native-blob-util": { + "optional": true + }, + "react-native-device-info": { + "optional": true + }, + "react-native-fs": { + "optional": true + }, + "react-native-zip-archive": { + "optional": true + } + } + }, + "packages/llamacpp": { + "name": "@runanywhere/llamacpp", + "version": "0.16.11", + "license": "MIT", + "devDependencies": { + "nitrogen": "^0.31.10", + "react-native-nitro-modules": "^0.31.10", + "typescript": "^5.2.2" + }, + "peerDependencies": { + "@runanywhere/core": "^0.16.0", + "react": "*", + "react-native": "*", + "react-native-nitro-modules": "^0.31.3" + } + }, + "packages/onnx": { + "name": "@runanywhere/onnx", + "version": "0.16.11", + "license": "MIT", + "devDependencies": { + "nitrogen": "^0.31.10", + "react-native-nitro-modules": "^0.31.10", + "typescript": "^5.2.2" + }, + "peerDependencies": { + "@runanywhere/core": "^0.16.0", + "react": "*", + "react-native": "*", + "react-native-nitro-modules": "^0.31.3" + } + } + } +} diff --git a/sdk/runanywhere-react-native/package.json b/sdk/runanywhere-react-native/package.json new file mode 100644 index 000000000..1ca2a43c6 --- /dev/null +++ b/sdk/runanywhere-react-native/package.json @@ -0,0 +1,93 @@ +{ + "name": "runanywhere-react-native-monorepo", + "version": "0.17.0", + "private": true, + "description": "RunAnywhere React Native SDK - Multi-Package Monorepo", + "workspaces": [ + "packages/*" + ], + "scripts": { + "build": "yarn workspaces foreach -A run build", + "typecheck": "yarn workspaces foreach -A run typecheck", + "lint": "yarn workspaces foreach -A run lint", + "lint:fix": "yarn workspaces foreach -A run lint:fix", + "clean": "yarn workspaces foreach -A run clean", + "example": "yarn workspace runanywhere-example", + "core:nitrogen": "yarn workspace @runanywhere/core nitrogen", + "core:prepare": "yarn workspace @runanywhere/core prepare", + "llamacpp:nitrogen": "yarn workspace @runanywhere/llamacpp nitrogen", + "llamacpp:prepare": "yarn workspace @runanywhere/llamacpp prepare", + "onnx:nitrogen": "yarn workspace @runanywhere/onnx nitrogen", + "onnx:prepare": "yarn workspace @runanywhere/onnx prepare", + "nitrogen:all": "yarn core:nitrogen && yarn llamacpp:nitrogen && yarn onnx:nitrogen", + "native:local": "export RA_TEST_LOCAL=1", + "native:remote": "unset RA_TEST_LOCAL", + "core:download-ios": "cd packages/core && pod install --project-directory=ios", + "core:download-android": "cd packages/core/android && ./gradlew downloadNativeLibs", + "llamacpp:download-ios": "cd packages/llamacpp && pod install --project-directory=ios", + "llamacpp:download-android": "cd packages/llamacpp/android && ./gradlew downloadNativeLibs", + "onnx:download-ios": "cd packages/onnx && pod install --project-directory=ios", + "onnx:download-android": "cd packages/onnx/android && ./gradlew downloadNativeLibs", + "release": "lerna publish" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/RunanywhereAI/sdks.git" + }, + "author": "RunAnywhere AI (https://runanywhere.com)", + "license": "MIT", + "bugs": { + "url": "https://github.com/RunanywhereAI/sdks/issues" + }, + "homepage": "https://github.com/RunanywhereAI/sdks#readme", + "devDependencies": { + "@commitlint/config-conventional": "^17.0.2", + "@evilmartians/lefthook": "^1.5.0", + "@types/node": "^24.10.0", + "@types/react": "~19.1.0", + "@typescript-eslint/eslint-plugin": "^8.50.0", + "@typescript-eslint/parser": "^8.50.0", + "commitlint": "^17.0.2", + "del-cli": "^5.1.0", + "eslint": "^8.51.0", + "eslint-config-prettier": "^9.0.0", + "eslint-plugin-prettier": "^5.0.1", + "lerna": "^8.0.0", + "prettier": "^3.0.3", + "react": "19.2.0", + "react-native": "0.83.1", + "typescript": "~5.9.2" + }, + "packageManager": "yarn@3.6.1", + "engines": { + "node": ">=18" + }, + "prettier": { + "quoteProps": "consistent", + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "useTabs": false + }, + "eslintConfig": { + "root": true, + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": 2020, + "sourceType": "module" + }, + "plugins": [ + "@typescript-eslint" + ], + "extends": [ + "plugin:@typescript-eslint/recommended", + "prettier" + ], + "rules": { + "@typescript-eslint/no-unused-vars": "off", + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-require-imports": "off", + "no-console": "error" + } + } +} diff --git a/sdk/runanywhere-react-native/packages/core/.npmignore b/sdk/runanywhere-react-native/packages/core/.npmignore new file mode 100644 index 000000000..f7bb38dd6 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/.npmignore @@ -0,0 +1,16 @@ +# Test local flags - should not be published +**/.testlocal +*.testlocal +.testlocal + +# Build artifacts +*.log +.cxx/ +build/ + +# IDE +.idea/ +*.iml + +# macOS +.DS_Store diff --git a/sdk/runanywhere-react-native/packages/core/.testlocal b/sdk/runanywhere-react-native/packages/core/.testlocal new file mode 100644 index 000000000..e69de29bb diff --git a/sdk/runanywhere-react-native/packages/core/README.md b/sdk/runanywhere-react-native/packages/core/README.md new file mode 100644 index 000000000..617013c8f --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/README.md @@ -0,0 +1,427 @@ +# @runanywhere/core + +Core SDK for RunAnywhere React Native. Foundation package providing the public API, events, model management, and native bridge infrastructure. + +--- + +## Overview + +`@runanywhere/core` is the foundation package of the RunAnywhere React Native SDK. It provides: + +- **RunAnywhere API** — Main SDK singleton with all public methods +- **EventBus** — Event subscription system for SDK events +- **ModelRegistry** — Model metadata management and discovery +- **DownloadService** — Model downloads with progress and resume +- **FileSystem** — Cross-platform file operations +- **Native Bridge** — Nitrogen/Nitro JSI bindings to C++ core +- **Error Handling** — Structured SDK errors with recovery suggestions +- **Logging** — Configurable logging with multiple levels + +This package is **required** for all RunAnywhere functionality. Additional capabilities are provided by: +- `@runanywhere/llamacpp` — LLM text generation (GGUF models) +- `@runanywhere/onnx` — Speech-to-Text and Text-to-Speech + +--- + +## Installation + +```bash +npm install @runanywhere/core +# or +yarn add @runanywhere/core +``` + +### Peer Dependencies + +The following peer dependencies are optional but recommended: + +```bash +npm install react-native-nitro-modules react-native-fs react-native-blob-util react-native-device-info react-native-zip-archive +``` + +### iOS Setup + +```bash +cd ios && pod install && cd .. +``` + +### Android Setup + +No additional setup required. + +--- + +## Quick Start + +```typescript +import { RunAnywhere, SDKEnvironment } from '@runanywhere/core'; + +// Initialize SDK +await RunAnywhere.initialize({ + environment: SDKEnvironment.Development, +}); + +// Check initialization +const isReady = await RunAnywhere.isInitialized(); +console.log('SDK ready:', isReady); + +// Get SDK version +console.log('Version:', RunAnywhere.version); +``` + +--- + +## API Reference + +### RunAnywhere (Main API) + +The `RunAnywhere` object is the main entry point for all SDK functionality. + +#### Initialization + +```typescript +// Initialize SDK +await RunAnywhere.initialize({ + apiKey?: string, // API key (production/staging) + baseURL?: string, // API base URL + environment?: SDKEnvironment, + debug?: boolean, +}); + +// Check status +const isInit = await RunAnywhere.isInitialized(); +const isActive = RunAnywhere.isSDKInitialized; + +// Reset SDK +await RunAnywhere.reset(); +``` + +#### Properties + +| Property | Type | Description | +|----------|------|-------------| +| `isSDKInitialized` | `boolean` | Whether SDK is initialized | +| `areServicesReady` | `boolean` | Whether services are ready | +| `currentEnvironment` | `SDKEnvironment` | Current environment | +| `version` | `string` | SDK version | +| `deviceId` | `string` | Persistent device ID | +| `events` | `EventBus` | Event subscription system | + +#### Model Management + +```typescript +// Get available models +const models = await RunAnywhere.getAvailableModels(); + +// Get specific model info +const model = await RunAnywhere.getModelInfo('model-id'); + +// Check if downloaded +const isDownloaded = await RunAnywhere.isModelDownloaded('model-id'); + +// Download with progress +await RunAnywhere.downloadModel('model-id', (progress) => { + console.log(`${(progress.progress * 100).toFixed(1)}%`); +}); + +// Delete model +await RunAnywhere.deleteModel('model-id'); +``` + +#### Storage Management + +```typescript +// Get storage info +const storage = await RunAnywhere.getStorageInfo(); +console.log('Free:', storage.freeSpace); +console.log('Used:', storage.usedSpace); + +// Clear cache +await RunAnywhere.clearCache(); +await RunAnywhere.cleanTempFiles(); +``` + +--- + +### EventBus + +Subscribe to SDK events for reactive updates. + +```typescript +import { EventBus, EventCategory } from '@runanywhere/core'; + +// Subscribe to events +const unsubscribe = EventBus.on('Generation', (event) => { + console.log('Event:', event.type); +}); + +// Shorthand methods +RunAnywhere.events.onInitialization((event) => { ... }); +RunAnywhere.events.onGeneration((event) => { ... }); +RunAnywhere.events.onModel((event) => { ... }); +RunAnywhere.events.onVoice((event) => { ... }); + +// Unsubscribe +unsubscribe(); +``` + +#### Event Categories + +| Category | Events | +|----------|--------| +| `Initialization` | `started`, `completed`, `failed` | +| `Generation` | `started`, `tokenGenerated`, `completed`, `failed` | +| `Model` | `downloadStarted`, `downloadProgress`, `downloadCompleted`, `loadCompleted` | +| `Voice` | `sttStarted`, `sttCompleted`, `ttsStarted`, `ttsCompleted` | + +--- + +### ModelRegistry + +Manage model metadata and discovery. + +```typescript +import { ModelRegistry } from '@runanywhere/core'; + +// Initialize (called automatically) +await ModelRegistry.initialize(); + +// Register a model +await ModelRegistry.registerModel({ + id: 'my-model', + name: 'My Model', + category: ModelCategory.Language, + format: ModelFormat.GGUF, + downloadURL: 'https://...', + // ... +}); + +// Get model +const model = await ModelRegistry.getModel('my-model'); + +// List models by category +const llmModels = await ModelRegistry.getModelsByCategory(ModelCategory.Language); + +// Update model +await ModelRegistry.updateModel('my-model', { isDownloaded: true }); +``` + +--- + +### DownloadService + +Download models with progress tracking. + +```typescript +import { DownloadService, DownloadState } from '@runanywhere/core'; + +// Create download task +const task = await DownloadService.downloadModel( + 'model-id', + 'https://download-url.com/model.gguf', + (progress) => { + console.log(`Progress: ${progress.progress * 100}%`); + console.log(`State: ${progress.state}`); + } +); + +// Cancel download +await DownloadService.cancelDownload('model-id'); + +// Get active downloads +const activeDownloads = DownloadService.getActiveDownloads(); +``` + +--- + +### FileSystem + +Cross-platform file operations. + +```typescript +import { FileSystem } from '@runanywhere/core'; + +// Check availability +if (FileSystem.isAvailable()) { + // Get directories + const docs = FileSystem.getDocumentsDirectory(); + const cache = FileSystem.getCacheDirectory(); + + // Model operations + const exists = await FileSystem.modelExists('model-id', 'LlamaCpp'); + const path = await FileSystem.getModelPath('model-id', 'LlamaCpp'); + + // File operations + const fileExists = await FileSystem.exists('/path/to/file'); + const size = await FileSystem.getFileSize('/path/to/file'); + await FileSystem.deleteFile('/path/to/file'); +} +``` + +--- + +### Error Handling + +```typescript +import { + SDKError, + SDKErrorCode, + isSDKError, + notInitializedError, + modelNotFoundError, +} from '@runanywhere/core'; + +try { + await RunAnywhere.generate('Hello'); +} catch (error) { + if (isSDKError(error)) { + console.log('Code:', error.code); + console.log('Category:', error.category); + console.log('Suggestion:', error.recoverySuggestion); + } +} + +// Create errors +throw notInitializedError(); +throw modelNotFoundError('model-id'); +``` + +--- + +### Logging + +```typescript +import { SDKLogger, LogLevel } from '@runanywhere/core'; + +// Set global log level +RunAnywhere.setLogLevel(LogLevel.Debug); + +// Create custom logger +const logger = new SDKLogger('MyModule'); +logger.debug('Debug message', { data: 'value' }); +logger.info('Info message'); +logger.warning('Warning message'); +logger.error('Error message', new Error('...')); +``` + +--- + +## Types + +### Enums + +```typescript +import { + SDKEnvironment, + ExecutionTarget, + LLMFramework, + ModelCategory, + ModelFormat, + HardwareAcceleration, + ComponentState, +} from '@runanywhere/core'; +``` + +### Interfaces + +```typescript +import type { + // Models + ModelInfo, + StorageInfo, + + // Generation + GenerationOptions, + GenerationResult, + PerformanceMetrics, + + // Voice + STTOptions, + STTResult, + TTSConfiguration, + TTSResult, + VADConfiguration, + + // Events + SDKEvent, + SDKGenerationEvent, + SDKModelEvent, + SDKVoiceEvent, + + // Download + DownloadProgress, + DownloadConfiguration, +} from '@runanywhere/core'; +``` + +--- + +## Package Structure + +``` +packages/core/ +├── src/ +│ ├── index.ts # Package exports +│ ├── Public/ +│ │ ├── RunAnywhere.ts # Main API singleton +│ │ ├── Events/ +│ │ │ └── EventBus.ts # Event pub/sub +│ │ └── Extensions/ # API method implementations +│ ├── Foundation/ +│ │ ├── ErrorTypes/ # SDK errors +│ │ ├── Initialization/ # Init state machine +│ │ ├── Security/ # Secure storage +│ │ ├── Logging/ # Logger +│ │ └── DependencyInjection/ # Service registry +│ ├── Infrastructure/ +│ │ └── Events/ # Event internals +│ ├── Features/ +│ │ └── VoiceSession/ # Voice session +│ ├── services/ +│ │ ├── ModelRegistry.ts # Model metadata +│ │ ├── DownloadService.ts # Downloads +│ │ ├── FileSystem.ts # File ops +│ │ └── Network/ # HTTP, telemetry +│ ├── types/ # TypeScript types +│ └── native/ # Native module access +├── cpp/ # C++ HybridObject bridges +├── ios/ # Swift native module +├── android/ # Kotlin native module +└── nitrogen/ # Generated Nitro specs +``` + +--- + +## Native Integration + +This package includes native bindings via Nitrogen/Nitro for: + +- **RACommons** — Core C++ infrastructure +- **PlatformAdapter** — Platform-specific implementations +- **SecureStorage** — Keychain (iOS) / EncryptedSharedPreferences (Android) +- **SDKLogger** — Native logging +- **AudioDecoder** — Audio file decoding + +### iOS + +The package uses `RACommons.xcframework` which is automatically downloaded during `pod install`. + +### Android + +Native libraries (`librac_commons.so`, `librunanywhere_jni.so`) are automatically downloaded during Gradle build. + +--- + +## See Also + +- [Main SDK README](../../README.md) — Full SDK documentation +- [ARCHITECTURE.md](../../ARCHITECTURE.md) — System architecture +- [API Reference](../../Docs/Documentation.md) — Complete API docs +- [@runanywhere/llamacpp](../llamacpp/README.md) — LLM backend +- [@runanywhere/onnx](../onnx/README.md) — STT/TTS backend + +--- + +## License + +MIT License diff --git a/sdk/runanywhere-react-native/packages/core/RunAnywhereCore.podspec b/sdk/runanywhere-react-native/packages/core/RunAnywhereCore.podspec new file mode 100644 index 000000000..e560c0afd --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/RunAnywhereCore.podspec @@ -0,0 +1,57 @@ +require "json" + +package = JSON.parse(File.read(File.join(__dir__, "package.json"))) + +Pod::Spec.new do |s| + s.name = "RunAnywhereCore" + s.module_name = "RunAnywhereCore" + s.version = package["version"] + s.summary = package["description"] + s.homepage = "https://runanywhere.com" + s.license = package["license"] + s.authors = "RunAnywhere AI" + + s.platforms = { :ios => "15.1" } + s.source = { :git => "https://github.com/RunanywhereAI/sdks.git", :tag => "#{s.version}" } + + # ============================================================================= + # Core SDK - RACommons xcframework is bundled in npm package + # No downloads needed - framework is included in ios/Binaries/ + # ============================================================================= + puts "[RunAnywhereCore] Using bundled RACommons.xcframework from npm package" + s.vendored_frameworks = "ios/Binaries/RACommons.xcframework" + + # Source files + s.source_files = [ + "ios/**/*.{swift}", + "ios/**/*.{h,m,mm}", + "cpp/HybridRunAnywhereCore.cpp", + "cpp/HybridRunAnywhereCore.hpp", + "cpp/bridges/**/*.{cpp,hpp}", + ] + + s.pod_target_xcconfig = { + "CLANG_CXX_LANGUAGE_STANDARD" => "c++17", + "HEADER_SEARCH_PATHS" => [ + "$(PODS_TARGET_SRCROOT)/cpp", + "$(PODS_TARGET_SRCROOT)/cpp/bridges", + "$(PODS_TARGET_SRCROOT)/ios/Binaries/RACommons.xcframework/ios-arm64/RACommons.framework/Headers", + "$(PODS_TARGET_SRCROOT)/ios/Binaries/RACommons.xcframework/ios-arm64_x86_64-simulator/RACommons.framework/Headers", + "$(PODS_ROOT)/Headers/Public", + ].join(" "), + "GCC_PREPROCESSOR_DEFINITIONS" => "$(inherited) HAS_RACOMMONS=1", + "DEFINES_MODULE" => "YES", + "SWIFT_OBJC_INTEROP_MODE" => "objcxx", + } + + s.libraries = "c++", "archive", "bz2" + s.frameworks = "Accelerate", "Foundation", "CoreML", "AudioToolbox" + + s.dependency 'React-jsi' + s.dependency 'React-callinvoker' + + load 'nitrogen/generated/ios/RunAnywhereCore+autolinking.rb' + add_nitrogen_files(s) + + install_modules_dependencies(s) +end diff --git a/sdk/runanywhere-react-native/packages/core/android/CMakeLists.txt b/sdk/runanywhere-react-native/packages/core/android/CMakeLists.txt new file mode 100644 index 000000000..6a08dc20c --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/android/CMakeLists.txt @@ -0,0 +1,118 @@ +project(runanywherecore) +cmake_minimum_required(VERSION 3.9.0) + +set(PACKAGE_NAME runanywherecore) +set(CMAKE_VERBOSE_MAKEFILE ON) +set(CMAKE_CXX_STANDARD 20) + +# ============================================================================= +# 16KB Page Alignment for Android 15+ (API 35) Compliance +# Required starting November 1, 2025 for Google Play submissions +# ============================================================================= +set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -Wl,-z,max-page-size=16384") + +# Path to pre-built native libraries (downloaded from runanywhere-sdks) +set(JNILIB_DIR ${CMAKE_SOURCE_DIR}/src/main/jniLibs/${ANDROID_ABI}) + +# Path to RAC headers (downloaded with native libraries) +set(RAC_INCLUDE_DIR ${CMAKE_SOURCE_DIR}/src/main/include) + +# ============================================================================= +# RACommons - Core SDK functionality (rac_* API) +# This is the ONLY native library in the core package +# RACommons is REQUIRED - it's downloaded via Gradle downloadNativeLibs task +# ============================================================================= +if(NOT EXISTS "${JNILIB_DIR}/librac_commons.so") + message(FATAL_ERROR "[RunAnywhereCore] RACommons not found at ${JNILIB_DIR}/librac_commons.so\n" + "Run: ./gradlew :runanywhere_core:downloadNativeLibs") +endif() + +add_library(rac_commons SHARED IMPORTED) +set_target_properties(rac_commons PROPERTIES + IMPORTED_LOCATION "${JNILIB_DIR}/librac_commons.so" + IMPORTED_NO_SONAME TRUE +) +message(STATUS "[RunAnywhereCore] Found RACommons at ${JNILIB_DIR}/librac_commons.so") + +# ============================================================================= +# Source files - Core bridges only (no LLM/STT/TTS/VAD) +# ============================================================================= +# Collect core bridge source files +file(GLOB BRIDGE_SOURCES "../cpp/bridges/*.cpp") + +add_library(${PACKAGE_NAME} SHARED + src/main/cpp/cpp-adapter.cpp + ../cpp/HybridRunAnywhereCore.cpp + ${BRIDGE_SOURCES} +) + +# ============================================================================= +# Fix NitroModules prefab path for library modules +# The prefab config generated by AGP has incorrect paths when building library modules +# We need to create the NitroModules target BEFORE the autolinking.cmake runs +# ============================================================================= +if(DEFINED REACT_NATIVE_NITRO_BUILD_DIR) + # Find NitroModules.so in the app's build directory + set(NITRO_LIBS_DIR "${REACT_NATIVE_NITRO_BUILD_DIR}/intermediates/merged_native_libs/debug/mergeDebugNativeLibs/out/lib/${ANDROID_ABI}") + if(EXISTS "${NITRO_LIBS_DIR}/libNitroModules.so") + message(STATUS "[RunAnywhereCore] Using NitroModules from app build: ${NITRO_LIBS_DIR}") + add_library(react-native-nitro-modules::NitroModules SHARED IMPORTED) + set_target_properties(react-native-nitro-modules::NitroModules PROPERTIES + IMPORTED_LOCATION "${NITRO_LIBS_DIR}/libNitroModules.so" + ) + endif() +endif() + +# Add Nitrogen specs (this handles all React Native linking) +include(${CMAKE_SOURCE_DIR}/../nitrogen/generated/android/runanywherecore+autolinking.cmake) + +# ============================================================================= +# Include directories +# ============================================================================= +include_directories( + "src/main/cpp" + "../cpp" + "../cpp/bridges" + "${CMAKE_SOURCE_DIR}/include" + # RAC API headers from runanywhere-commons (flat access) + "${RAC_INCLUDE_DIR}" + "${RAC_INCLUDE_DIR}/rac" + "${RAC_INCLUDE_DIR}/rac/core" + "${RAC_INCLUDE_DIR}/rac/core/capabilities" + "${RAC_INCLUDE_DIR}/rac/features" + "${RAC_INCLUDE_DIR}/rac/features/llm" + "${RAC_INCLUDE_DIR}/rac/features/stt" + "${RAC_INCLUDE_DIR}/rac/features/tts" + "${RAC_INCLUDE_DIR}/rac/features/vad" + "${RAC_INCLUDE_DIR}/rac/features/voice_agent" + "${RAC_INCLUDE_DIR}/rac/features/platform" + "${RAC_INCLUDE_DIR}/rac/infrastructure" + "${RAC_INCLUDE_DIR}/rac/infrastructure/device" + "${RAC_INCLUDE_DIR}/rac/infrastructure/download" + "${RAC_INCLUDE_DIR}/rac/infrastructure/events" + "${RAC_INCLUDE_DIR}/rac/infrastructure/model_management" + "${RAC_INCLUDE_DIR}/rac/infrastructure/network" + "${RAC_INCLUDE_DIR}/rac/infrastructure/storage" + "${RAC_INCLUDE_DIR}/rac/infrastructure/telemetry" +) + +# ============================================================================= +# Linking +# ============================================================================= +find_library(LOG_LIB log) + +target_link_libraries( + ${PACKAGE_NAME} + ${LOG_LIB} + android +) + +# Link RACommons - REQUIRED for core functionality +target_link_libraries(${PACKAGE_NAME} rac_commons) +target_compile_definitions(${PACKAGE_NAME} PRIVATE HAS_RACOMMONS=1) + +# 16KB page alignment - MUST be on target for Android 15+ compliance +target_link_options(${PACKAGE_NAME} PRIVATE -Wl,-z,max-page-size=16384) + +# NOTE: No LlamaCPP or ONNX linking here +# Those are in @runanywhere/llamacpp and @runanywhere/onnx packages respectively diff --git a/sdk/runanywhere-react-native/packages/core/android/build.gradle b/sdk/runanywhere-react-native/packages/core/android/build.gradle new file mode 100644 index 000000000..97e072216 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/android/build.gradle @@ -0,0 +1,385 @@ +// ============================================================================= +// Node Binary Detection for Android Studio Compatibility +// Android Studio doesn't inherit terminal PATH, so we need to find node explicitly +// ============================================================================= +def findNodeBinary() { + // Check local.properties first (user can override) + def localProperties = new Properties() + def localPropertiesFile = rootProject.file("local.properties") + if (localPropertiesFile.exists()) { + localPropertiesFile.withInputStream { localProperties.load(it) } + def nodePath = localProperties.getProperty("node.path") + if (nodePath && new File(nodePath).exists()) { + return nodePath + } + } + + // Check common node installation paths + def homeDir = System.getProperty("user.home") + def nodePaths = [ + "/opt/homebrew/bin/node", // macOS ARM (Apple Silicon) + "/usr/local/bin/node", // macOS Intel / Linux + "/usr/bin/node", // Linux system + "${homeDir}/.nvm/current/bin/node", // nvm + "${homeDir}/.volta/bin/node", // volta + "${homeDir}/.asdf/shims/node" // asdf + ] + for (path in nodePaths) { + if (new File(path).exists()) { + return path + } + } + + // Fallback to 'node' (works if PATH is set correctly in terminal builds) + return "node" +} + +def getExtOrDefault(name) { + return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties['RunAnywhereCore_' + name] +} + +// Only arm64-v8a is supported - native libraries are only built for 64-bit ARM +def reactNativeArchitectures() { + return ["arm64-v8a"] +} + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' +apply from: '../nitrogen/generated/android/runanywherecore+autolinking.gradle' +apply plugin: 'com.facebook.react' + +// Configure node path for Android Studio builds +// Set the react extension's nodeExecutableAndArgs after plugin is applied +def nodeBinary = findNodeBinary() +logger.lifecycle("[RunAnywhereCore] Using node binary: ${nodeBinary}") + +// Configure all codegen tasks to use the detected node binary +afterEvaluate { + tasks.withType(com.facebook.react.tasks.GenerateCodegenSchemaTask).configureEach { + nodeExecutableAndArgs.set([nodeBinary]) + } + tasks.withType(com.facebook.react.tasks.GenerateCodegenArtifactsTask).configureEach { + nodeExecutableAndArgs.set([nodeBinary]) + } +} + +def getExtOrIntegerDefault(name) { + if (rootProject.ext.has(name)) { + return rootProject.ext.get(name) + } else if (project.properties.containsKey('RunAnywhereCore_' + name)) { + return (project.properties['RunAnywhereCore_' + name]).toInteger() + } + def defaults = [ + 'compileSdkVersion': 36, + 'minSdkVersion': 24, + 'targetSdkVersion': 36 + ] + return defaults[name] ?: 36 +} + +// ============================================================================= +// Version Constants (MUST match Swift Package.swift and iOS Podspec) +// RACommons ONLY - no backend binaries +// ============================================================================= +def commonsVersion = "0.1.4" + +// ============================================================================= +// Binary Source - RACommons from runanywhere-sdks +// ============================================================================= +def githubOrg = "RunanywhereAI" +def commonsRepo = "runanywhere-sdks" + +// ============================================================================= +// testLocal Toggle +// ============================================================================= +def useLocalBuild = project.findProperty("runanywhere.testLocal")?.toBoolean() ?: + System.getenv("RA_TEST_LOCAL") == "1" ?: false + +// Native libraries directory +def jniLibsDir = file("src/main/jniLibs") +def downloadedLibsDir = file("build/downloaded-libs") +def includeDir = file("src/main/include") + +// Local runanywhere-commons path (for local builds) +def runAnywhereCommonsDir = file("../../../../runanywhere-commons") + +android { + namespace "com.margelo.nitro.runanywhere.core" + + compileSdkVersion getExtOrIntegerDefault('compileSdkVersion') + + defaultConfig { + minSdkVersion getExtOrIntegerDefault('minSdkVersion') + targetSdkVersion getExtOrIntegerDefault('targetSdkVersion') + + ndk { + abiFilters 'arm64-v8a', 'x86_64' + } + + externalNativeBuild { + cmake { + cppFlags "-frtti -fexceptions -Wall -fstack-protector-all" + arguments "-DANDROID_STL=c++_shared", + // Fix NitroModules prefab path - use app's build directory + "-DREACT_NATIVE_NITRO_BUILD_DIR=${rootProject.buildDir}" + abiFilters 'arm64-v8a', 'x86_64' + } + } + } + + externalNativeBuild { + cmake { + path "CMakeLists.txt" + } + } + + packagingOptions { + excludes = [ + "META-INF", + "META-INF/**" + ] + pickFirsts = [ + "**/libc++_shared.so", + "**/libjsi.so", + "**/libfbjni.so", + "**/libfolly_runtime.so", + "**/librac_commons.so", + "**/librunanywhere_jni.so" + ] + jniLibs { + useLegacyPackaging = true + } + } + + buildFeatures { + buildConfig true + prefab true + } + + buildTypes { + release { + minifyEnabled false + } + } + + lint { + disable 'GradleCompatible' + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + sourceSets { + main { + java.srcDirs += [ + "generated/java", + "generated/jni" + ] + + if (useLocalBuild) { + def commonsDistDir = new File(runAnywhereCommonsDir, "dist/android") + if (commonsDistDir.exists()) { + jniLibs.srcDirs = [commonsDistDir] + logger.lifecycle("[RunAnywhereCore] Using LOCAL native libraries from: $commonsDistDir") + } else { + logger.warn("[RunAnywhereCore] Local commons dist not found at: $commonsDistDir") + } + } else { + jniLibs.srcDirs = [jniLibsDir] + } + } + } +} + +// ============================================================================= +// Download Native Libraries (RACommons ONLY) +// Backend modules (LlamaCPP, ONNX) are downloaded by their respective packages +// ============================================================================= + +task downloadNativeLibs { + description = "Downloads RACommons from GitHub releases" + group = "build setup" + + def versionFile = file("${jniLibsDir}/.version") + def expectedVersion = commonsVersion + + outputs.dir(jniLibsDir) + outputs.upToDateWhen { + versionFile.exists() && versionFile.text.trim() == expectedVersion + } + + doLast { + if (useLocalBuild) { + logger.lifecycle("[RunAnywhereCore] Skipping download - using local build mode") + return + } + + // Check if libs are already bundled (npm install case) + def bundledLibsDir = file("${jniLibsDir}/arm64-v8a") + def bundledLibs = bundledLibsDir.exists() ? bundledLibsDir.listFiles()?.findAll { it.name.endsWith(".so") } : [] + if (bundledLibs?.size() > 0) { + logger.lifecycle("[RunAnywhereCore] ✅ Using bundled native libraries from npm package (${bundledLibs.size()} .so files)") + return + } + + def currentVersion = versionFile.exists() ? versionFile.text.trim() : "" + if (currentVersion == expectedVersion) { + logger.lifecycle("[RunAnywhereCore] RACommons version $expectedVersion already downloaded") + return + } + + logger.lifecycle("[RunAnywhereCore] Downloading RACommons...") + logger.lifecycle(" Commons Version: $commonsVersion") + + downloadedLibsDir.mkdirs() + jniLibsDir.deleteDir() + jniLibsDir.mkdirs() + includeDir.mkdirs() + + // ============================================================================= + // Download RACommons from runanywhere-sdks + // ============================================================================= + def commonsUrl = "https://github.com/${githubOrg}/${commonsRepo}/releases/download/commons-v${commonsVersion}/RACommons-android-v${commonsVersion}.zip" + def commonsZip = file("${downloadedLibsDir}/RACommons.zip") + + logger.lifecycle("\n📦 Downloading RACommons...") + logger.lifecycle(" URL: $commonsUrl") + + try { + new URL(commonsUrl).withInputStream { input -> + commonsZip.withOutputStream { output -> + output << input + } + } + logger.lifecycle(" Downloaded: ${commonsZip.length() / 1024}KB") + + // Extract and flatten the archive structure + // Archive structure: jniLibs/arm64-v8a/*.so + // Target structure: arm64-v8a/*.so (directly in jniLibsDir) + copy { + from zipTree(commonsZip) + into jniLibsDir + // IMPORTANT: Exclude libc++_shared.so - React Native provides its own + // Using a different version causes ABI compatibility issues + exclude "**/libc++_shared.so" + eachFile { fileCopyDetails -> + def pathString = fileCopyDetails.relativePath.pathString + // Handle jniLibs/ABI/*.so structure -> flatten to ABI/*.so + if (pathString.startsWith("jniLibs/") && pathString.endsWith(".so")) { + def newPath = pathString.replaceFirst("^jniLibs/", "") + fileCopyDetails.relativePath = new RelativePath(true, newPath.split("/")) + } else if (pathString.endsWith(".so")) { + // Keep .so files as-is if already in correct structure + } else { + // Exclude non-so files from jniLibs + if (!pathString.contains("include/")) { + fileCopyDetails.exclude() + } + } + } + includeEmptyDirs = false + } + + // Copy headers if present in the archive + def tempExtract = file("${downloadedLibsDir}/temp-commons") + tempExtract.deleteDir() + copy { + from zipTree(commonsZip) + into tempExtract + } + def headersDir = new File(tempExtract, "include") + if (headersDir.exists()) { + copy { + from headersDir + into includeDir + } + logger.lifecycle(" ✅ RACommons headers installed") + } + // Also check for nested jniLibs/include structure + def nestedHeadersDir = new File(tempExtract, "jniLibs/include") + if (nestedHeadersDir.exists()) { + copy { + from nestedHeadersDir + into includeDir + } + logger.lifecycle(" ✅ RACommons headers installed (from nested path)") + } + tempExtract.deleteDir() + + logger.lifecycle(" ✅ RACommons installed") + } catch (Exception e) { + logger.error("❌ Failed to download RACommons: ${e.message}") + throw new GradleException("Failed to download RACommons", e) + } + + // ============================================================================= + // List installed files + // ============================================================================= + logger.lifecycle("\n📋 Installed native libraries:") + jniLibsDir.listFiles()?.findAll { it.isDirectory() }?.each { abiDir -> + logger.lifecycle(" ${abiDir.name}/") + abiDir.listFiles()?.findAll { it.name.endsWith(".so") }?.sort()?.each { soFile -> + logger.lifecycle(" ${soFile.name} (${soFile.length() / 1024}KB)") + } + } + + if (includeDir.exists() && includeDir.listFiles()?.size() > 0) { + logger.lifecycle("\n📋 Installed headers:") + includeDir.eachFileRecurse { file -> + if (file.isFile() && file.name.endsWith(".h")) { + logger.lifecycle(" ${file.relativePath(includeDir)}") + } + } + } + + versionFile.text = expectedVersion + logger.lifecycle("\n✅ RACommons version $expectedVersion installed") + } +} + +if (!useLocalBuild) { + preBuild.dependsOn downloadNativeLibs + + afterEvaluate { + tasks.matching { + it.name.contains("generateCodegen") || it.name.contains("Codegen") + }.configureEach { + mustRunAfter downloadNativeLibs + } + } +} + +// NOTE: cleanNativeLibs is NOT attached to clean task because npm-bundled libs should persist +// Only use this task manually when needed during development +task cleanNativeLibs(type: Delete) { + description = "Removes downloaded native libraries (use manually, not during normal clean)" + group = "build" + delete downloadedLibsDir + delete includeDir + // DO NOT delete jniLibsDir - it contains npm-bundled libraries +} + +// DO NOT add: clean.dependsOn cleanNativeLibs +// This would delete bundled .so files from the npm package + +repositories { + mavenCentral() + google() +} + +dependencies { + implementation "com.facebook.react:react-android" + implementation project(":react-native-nitro-modules") + + // Apache Commons Compress for tar.gz archive extraction + implementation "org.apache.commons:commons-compress:1.26.0" + + // AndroidX Security for EncryptedSharedPreferences (device identity persistence) + implementation "androidx.security:security-crypto:1.1.0-alpha06" +} diff --git a/sdk/runanywhere-react-native/packages/core/android/consumer-rules.pro b/sdk/runanywhere-react-native/packages/core/android/consumer-rules.pro new file mode 100644 index 000000000..753e07c62 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/android/consumer-rules.pro @@ -0,0 +1,5 @@ +# Keep ArchiveUtility for JNI access +-keep class com.margelo.nitro.runanywhere.ArchiveUtility { *; } +-keepclassmembers class com.margelo.nitro.runanywhere.ArchiveUtility { + public static *** extract(java.lang.String, java.lang.String); +} diff --git a/sdk/runanywhere-react-native/packages/core/android/src/main/AndroidManifest.xml b/sdk/runanywhere-react-native/packages/core/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000..81e2465f1 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + diff --git a/sdk/runanywhere-react-native/packages/core/android/src/main/cpp/cpp-adapter.cpp b/sdk/runanywhere-react-native/packages/core/android/src/main/cpp/cpp-adapter.cpp new file mode 100644 index 000000000..04fcb76a5 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/android/src/main/cpp/cpp-adapter.cpp @@ -0,0 +1,271 @@ +#include +#include +#include +#include "runanywherecoreOnLoad.hpp" + +#define LOG_TAG "ArchiveJNI" +#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) +#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) + +// Store JavaVM globally for JNI calls from background threads +// NOT static - needs to be accessible from InitBridge.cpp for secure storage +JavaVM* g_javaVM = nullptr; + +// Cache class and method references at JNI_OnLoad time +// This is necessary because FindClass from native threads uses the system class loader +static jclass g_archiveUtilityClass = nullptr; +static jmethodID g_extractMethod = nullptr; + +// PlatformAdapterBridge class and methods for secure storage (used by InitBridge.cpp) +// NOT static - needs to be accessible from InitBridge.cpp +jclass g_platformAdapterBridgeClass = nullptr; +jclass g_httpResponseClass = nullptr; // Inner class for httpPostSync response +jmethodID g_secureSetMethod = nullptr; +jmethodID g_secureGetMethod = nullptr; +jmethodID g_secureDeleteMethod = nullptr; +jmethodID g_secureExistsMethod = nullptr; +jmethodID g_getPersistentDeviceUUIDMethod = nullptr; +jmethodID g_httpPostSyncMethod = nullptr; +jmethodID g_getDeviceModelMethod = nullptr; +jmethodID g_getOSVersionMethod = nullptr; +jmethodID g_getChipNameMethod = nullptr; +jmethodID g_getTotalMemoryMethod = nullptr; +jmethodID g_getAvailableMemoryMethod = nullptr; +jmethodID g_getCoreCountMethod = nullptr; +jmethodID g_getArchitectureMethod = nullptr; +jmethodID g_getGPUFamilyMethod = nullptr; +jmethodID g_isTabletMethod = nullptr; +// HttpResponse field IDs +jfieldID g_httpResponse_successField = nullptr; +jfieldID g_httpResponse_statusCodeField = nullptr; +jfieldID g_httpResponse_responseBodyField = nullptr; +jfieldID g_httpResponse_errorMessageField = nullptr; + +// Forward declaration +extern "C" bool ArchiveUtility_extractAndroid(const char* archivePath, const char* destinationPath); + +JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void*) { + g_javaVM = vm; + + // Get JNIEnv to cache class references + JNIEnv* env = nullptr; + if (vm->GetEnv(reinterpret_cast(&env), JNI_VERSION_1_6) == JNI_OK && env != nullptr) { + // Find and cache the ArchiveUtility class + jclass localClass = env->FindClass("com/margelo/nitro/runanywhere/ArchiveUtility"); + if (localClass != nullptr) { + // Create a global reference so it persists across JNI calls + g_archiveUtilityClass = (jclass)env->NewGlobalRef(localClass); + env->DeleteLocalRef(localClass); + + // Cache the extract method + g_extractMethod = env->GetStaticMethodID( + g_archiveUtilityClass, + "extract", + "(Ljava/lang/String;Ljava/lang/String;)Z" + ); + + if (g_extractMethod != nullptr) { + LOGI("ArchiveUtility class and method cached successfully"); + } else { + LOGE("Failed to find extract method in ArchiveUtility"); + if (env->ExceptionCheck()) { + env->ExceptionClear(); + } + } + } else { + LOGE("Failed to find ArchiveUtility class at JNI_OnLoad"); + if (env->ExceptionCheck()) { + env->ExceptionClear(); + } + } + + // Find and cache the PlatformAdapterBridge class (for secure storage) + jclass platformClass = env->FindClass("com/margelo/nitro/runanywhere/PlatformAdapterBridge"); + if (platformClass != nullptr) { + g_platformAdapterBridgeClass = (jclass)env->NewGlobalRef(platformClass); + env->DeleteLocalRef(platformClass); + + // Cache all methods we need + g_secureSetMethod = env->GetStaticMethodID(g_platformAdapterBridgeClass, "secureSet", "(Ljava/lang/String;Ljava/lang/String;)Z"); + g_secureGetMethod = env->GetStaticMethodID(g_platformAdapterBridgeClass, "secureGet", "(Ljava/lang/String;)Ljava/lang/String;"); + g_secureDeleteMethod = env->GetStaticMethodID(g_platformAdapterBridgeClass, "secureDelete", "(Ljava/lang/String;)Z"); + g_secureExistsMethod = env->GetStaticMethodID(g_platformAdapterBridgeClass, "secureExists", "(Ljava/lang/String;)Z"); + g_getPersistentDeviceUUIDMethod = env->GetStaticMethodID(g_platformAdapterBridgeClass, "getPersistentDeviceUUID", "()Ljava/lang/String;"); + g_httpPostSyncMethod = env->GetStaticMethodID(g_platformAdapterBridgeClass, "httpPostSync", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lcom/margelo/nitro/runanywhere/PlatformAdapterBridge$HttpResponse;"); + g_getDeviceModelMethod = env->GetStaticMethodID(g_platformAdapterBridgeClass, "getDeviceModel", "()Ljava/lang/String;"); + g_getOSVersionMethod = env->GetStaticMethodID(g_platformAdapterBridgeClass, "getOSVersion", "()Ljava/lang/String;"); + g_getChipNameMethod = env->GetStaticMethodID(g_platformAdapterBridgeClass, "getChipName", "()Ljava/lang/String;"); + g_getTotalMemoryMethod = env->GetStaticMethodID(g_platformAdapterBridgeClass, "getTotalMemory", "()J"); + g_getAvailableMemoryMethod = env->GetStaticMethodID(g_platformAdapterBridgeClass, "getAvailableMemory", "()J"); + g_getCoreCountMethod = env->GetStaticMethodID(g_platformAdapterBridgeClass, "getCoreCount", "()I"); + g_getArchitectureMethod = env->GetStaticMethodID(g_platformAdapterBridgeClass, "getArchitecture", "()Ljava/lang/String;"); + g_getGPUFamilyMethod = env->GetStaticMethodID(g_platformAdapterBridgeClass, "getGPUFamily", "()Ljava/lang/String;"); + g_isTabletMethod = env->GetStaticMethodID(g_platformAdapterBridgeClass, "isTablet", "()Z"); + + if (g_secureSetMethod && g_secureGetMethod && g_getPersistentDeviceUUIDMethod && + g_getDeviceModelMethod && g_getOSVersionMethod && g_getChipNameMethod && + g_getTotalMemoryMethod && g_getAvailableMemoryMethod && g_getCoreCountMethod && + g_getArchitectureMethod && g_getGPUFamilyMethod && g_isTabletMethod) { + LOGI("PlatformAdapterBridge class and methods cached successfully"); + } else { + LOGE("Failed to cache some PlatformAdapterBridge methods"); + if (env->ExceptionCheck()) { + env->ExceptionClear(); + } + } + + // Cache HttpResponse inner class and its fields + jclass responseClass = env->FindClass("com/margelo/nitro/runanywhere/PlatformAdapterBridge$HttpResponse"); + if (responseClass != nullptr) { + g_httpResponseClass = (jclass)env->NewGlobalRef(responseClass); + env->DeleteLocalRef(responseClass); + + g_httpResponse_successField = env->GetFieldID(g_httpResponseClass, "success", "Z"); + g_httpResponse_statusCodeField = env->GetFieldID(g_httpResponseClass, "statusCode", "I"); + g_httpResponse_responseBodyField = env->GetFieldID(g_httpResponseClass, "responseBody", "Ljava/lang/String;"); + g_httpResponse_errorMessageField = env->GetFieldID(g_httpResponseClass, "errorMessage", "Ljava/lang/String;"); + + if (g_httpResponse_successField && g_httpResponse_statusCodeField) { + LOGI("HttpResponse class and fields cached successfully"); + } else { + LOGE("Failed to cache HttpResponse fields"); + } + } else { + LOGE("Failed to find HttpResponse inner class at JNI_OnLoad"); + if (env->ExceptionCheck()) { + env->ExceptionClear(); + } + } + } else { + LOGE("Failed to find PlatformAdapterBridge class at JNI_OnLoad"); + if (env->ExceptionCheck()) { + env->ExceptionClear(); + } + } + } + + return margelo::nitro::runanywhere::initialize(vm); +} + +/** + * Get JNIEnv for the current thread + * Attaches thread if not already attached + */ +static JNIEnv* getJNIEnv() { + JNIEnv* env = nullptr; + if (g_javaVM == nullptr) { + LOGE("JavaVM is null"); + return nullptr; + } + + int status = g_javaVM->GetEnv(reinterpret_cast(&env), JNI_VERSION_1_6); + if (status == JNI_EDETACHED) { + if (g_javaVM->AttachCurrentThread(&env, nullptr) != JNI_OK) { + LOGE("Failed to attach thread"); + return nullptr; + } + LOGI("Attached thread to JVM"); + } else if (status != JNI_OK) { + LOGE("Failed to get JNIEnv, status=%d", status); + return nullptr; + } + return env; +} + +/** + * Log Java exception details before clearing + */ +static void logAndClearException(JNIEnv* env, const char* context) { + if (env->ExceptionCheck()) { + jthrowable exception = env->ExceptionOccurred(); + env->ExceptionClear(); + + // Get exception message + jclass throwableClass = env->FindClass("java/lang/Throwable"); + if (throwableClass) { + jmethodID getMessageMethod = env->GetMethodID(throwableClass, "getMessage", "()Ljava/lang/String;"); + if (getMessageMethod) { + jstring messageStr = (jstring)env->CallObjectMethod(exception, getMessageMethod); + if (messageStr) { + const char* message = env->GetStringUTFChars(messageStr, nullptr); + LOGE("[%s] Java exception: %s", context, message); + env->ReleaseStringUTFChars(messageStr, message); + env->DeleteLocalRef(messageStr); + } else { + LOGE("[%s] Java exception (no message)", context); + } + } + env->DeleteLocalRef(throwableClass); + } + + // Also print stack trace to logcat + jclass exceptionClass = env->GetObjectClass(exception); + if (exceptionClass) { + jmethodID printStackTraceMethod = env->GetMethodID(exceptionClass, "printStackTrace", "()V"); + if (printStackTraceMethod) { + env->CallVoidMethod(exception, printStackTraceMethod); + env->ExceptionClear(); // Clear any exception from printStackTrace + } + env->DeleteLocalRef(exceptionClass); + } + + env->DeleteLocalRef(exception); + } +} + +/** + * Call Kotlin ArchiveUtility.extract() via JNI + * Uses cached class and method references from JNI_OnLoad + */ +extern "C" bool ArchiveUtility_extractAndroid(const char* archivePath, const char* destinationPath) { + LOGI("Starting extraction: %s -> %s", archivePath, destinationPath); + + // Check if class and method were cached + if (g_archiveUtilityClass == nullptr || g_extractMethod == nullptr) { + LOGE("ArchiveUtility class or method not cached. JNI_OnLoad may have failed."); + return false; + } + + JNIEnv* env = getJNIEnv(); + if (env == nullptr) { + LOGE("Failed to get JNIEnv"); + return false; + } + + LOGI("Using cached ArchiveUtility class and method"); + + // Create Java strings + jstring jArchivePath = env->NewStringUTF(archivePath); + jstring jDestinationPath = env->NewStringUTF(destinationPath); + + if (jArchivePath == nullptr || jDestinationPath == nullptr) { + LOGE("Failed to create Java strings"); + if (jArchivePath) env->DeleteLocalRef(jArchivePath); + if (jDestinationPath) env->DeleteLocalRef(jDestinationPath); + return false; + } + + // Call the method using cached references + LOGI("Calling ArchiveUtility.extract()..."); + jboolean result = env->CallStaticBooleanMethod( + g_archiveUtilityClass, + g_extractMethod, + jArchivePath, + jDestinationPath + ); + + // Check for exceptions + if (env->ExceptionCheck()) { + LOGE("Exception during extraction"); + logAndClearException(env, "extract"); + result = JNI_FALSE; + } else { + LOGI("Extraction returned: %s", result ? "true" : "false"); + } + + // Cleanup local references + env->DeleteLocalRef(jArchivePath); + env->DeleteLocalRef(jDestinationPath); + + return result == JNI_TRUE; +} diff --git a/sdk/runanywhere-react-native/packages/core/android/src/main/java/com/margelo/nitro/runanywhere/ArchiveUtility.kt b/sdk/runanywhere-react-native/packages/core/android/src/main/java/com/margelo/nitro/runanywhere/ArchiveUtility.kt new file mode 100644 index 000000000..6dd64acad --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/android/src/main/java/com/margelo/nitro/runanywhere/ArchiveUtility.kt @@ -0,0 +1,308 @@ +/** + * ArchiveUtility.kt + * + * Native archive extraction utility for Android. + * Uses Apache Commons Compress for robust tar.gz extraction (streaming, memory-efficient). + * Uses Java's native ZipInputStream for zip extraction. + * + * Mirrors the implementation from: + * sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Download/Utilities/ArchiveUtility.swift + * + * Supports: tar.gz, zip + * Note: All models should use tar.gz from RunanywhereAI/sherpa-onnx fork for best performance + */ + +package com.margelo.nitro.runanywhere + +import org.apache.commons.compress.archivers.tar.TarArchiveEntry +import org.apache.commons.compress.archivers.tar.TarArchiveInputStream +import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream +import java.io.BufferedInputStream +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.util.zip.ZipInputStream + +/** + * Utility for handling archive extraction on Android + */ +object ArchiveUtility { + private val logger = SDKLogger.archive + + /** + * Extract an archive to a destination directory + * @param archivePath Path to the archive file + * @param destinationPath Destination directory path + * @return true if extraction succeeded + */ + @JvmStatic + fun extract(archivePath: String, destinationPath: String): Boolean { + logger.info("extract() called: $archivePath -> $destinationPath") + return try { + extractArchive(archivePath, destinationPath) + logger.info("extract() succeeded") + true + } catch (e: Exception) { + logger.logError(e, "Extraction failed") + false + } + } + + /** + * Extract an archive to a destination directory (throwing version) + */ + @Throws(Exception::class) + fun extractArchive( + archivePath: String, + destinationPath: String, + progressHandler: ((Double) -> Unit)? = null + ) { + val archiveFile = File(archivePath) + val destinationDir = File(destinationPath) + + if (!archiveFile.exists()) { + throw Exception("Archive not found: $archivePath") + } + + // Detect archive type by magic bytes (more reliable than file extension) + val archiveType = detectArchiveTypeByMagicBytes(archiveFile) + logger.info("Detected archive type: $archiveType for: $archivePath") + + when (archiveType) { + ArchiveType.GZIP -> { + extractTarGz(archiveFile, destinationDir, progressHandler) + } + ArchiveType.ZIP -> { + extractZip(archiveFile, destinationDir, progressHandler) + } + ArchiveType.BZIP2 -> { + throw Exception("tar.bz2 not supported. Use tar.gz from RunanywhereAI/sherpa-onnx fork.") + } + ArchiveType.XZ -> { + throw Exception("tar.xz not supported. Use tar.gz from RunanywhereAI/sherpa-onnx fork.") + } + ArchiveType.UNKNOWN -> { + // Fallback to file extension check + val lowercased = archivePath.lowercase() + when { + lowercased.endsWith(".tar.gz") || lowercased.endsWith(".tgz") -> { + extractTarGz(archiveFile, destinationDir, progressHandler) + } + lowercased.endsWith(".zip") -> { + extractZip(archiveFile, destinationDir, progressHandler) + } + else -> { + throw Exception("Unknown archive format: $archivePath") + } + } + } + } + } + + /** + * Archive type detected by magic bytes + */ + private enum class ArchiveType { + GZIP, ZIP, BZIP2, XZ, UNKNOWN + } + + /** + * Detect archive type by reading magic bytes from file header + */ + private fun detectArchiveTypeByMagicBytes(file: File): ArchiveType { + return try { + FileInputStream(file).use { fis -> + val header = ByteArray(6) + val bytesRead = fis.read(header) + if (bytesRead < 2) return ArchiveType.UNKNOWN + + // Check for gzip: 0x1f 0x8b + if (header[0] == 0x1f.toByte() && header[1] == 0x8b.toByte()) { + return ArchiveType.GZIP + } + + // Check for zip: 0x50 0x4b 0x03 0x04 ("PK\x03\x04") + if (bytesRead >= 4 && + header[0] == 0x50.toByte() && header[1] == 0x4b.toByte() && + header[2] == 0x03.toByte() && header[3] == 0x04.toByte()) { + return ArchiveType.ZIP + } + + // Check for bzip2: 0x42 0x5a ("BZ") + if (header[0] == 0x42.toByte() && header[1] == 0x5a.toByte()) { + return ArchiveType.BZIP2 + } + + // Check for xz: 0xfd 0x37 0x7a 0x58 0x5a 0x00 + if (bytesRead >= 6 && + header[0] == 0xfd.toByte() && header[1] == 0x37.toByte() && + header[2] == 0x7a.toByte() && header[3] == 0x58.toByte() && + header[4] == 0x5a.toByte() && header[5] == 0x00.toByte()) { + return ArchiveType.XZ + } + + ArchiveType.UNKNOWN + } + } catch (e: Exception) { + logger.error("Failed to detect archive type: ${e.message}") + ArchiveType.UNKNOWN + } + } + + // MARK: - tar.gz Extraction + + /** + * Extract a tar.gz archive using Apache Commons Compress (streaming, memory-efficient) + * This approach doesn't load the entire file into memory. + */ + private fun extractTarGz( + sourceFile: File, + destinationDir: File, + progressHandler: ((Double) -> Unit)? + ) { + val startTime = System.currentTimeMillis() + logger.info("Extracting tar.gz: ${sourceFile.name} (size: ${formatBytes(sourceFile.length())})") + progressHandler?.invoke(0.0) + + destinationDir.mkdirs() + var fileCount = 0 + val totalSize = sourceFile.length() + var bytesRead = 0L + + try { + // Use Apache Commons Compress for streaming tar.gz extraction + FileInputStream(sourceFile).use { fis -> + BufferedInputStream(fis).use { bis -> + GzipCompressorInputStream(bis).use { gzis -> + TarArchiveInputStream(gzis).use { tarIn -> + var entry: TarArchiveEntry? = tarIn.nextTarEntry + while (entry != null) { + val name = entry.name + + // Skip macOS resource forks and empty names + if (name.isEmpty() || name.startsWith("._") || name.startsWith("./._")) { + entry = tarIn.nextTarEntry + continue + } + + val outputFile = File(destinationDir, name) + + // Security check - prevent zip slip attack + val destDirPath = destinationDir.canonicalPath + val outputFilePath = outputFile.canonicalPath + if (!outputFilePath.startsWith(destDirPath + File.separator) && + outputFilePath != destDirPath) { + logger.warning("Skipping entry outside destination: $name") + entry = tarIn.nextTarEntry + continue + } + + if (entry.isDirectory) { + outputFile.mkdirs() + } else { + // Create parent directories + outputFile.parentFile?.mkdirs() + + // Extract file + FileOutputStream(outputFile).use { fos -> + val buffer = ByteArray(8192) + var len: Int + while (tarIn.read(buffer).also { len = it } != -1) { + fos.write(buffer, 0, len) + bytesRead += len + } + } + fileCount++ + + // Log progress for large files + if (fileCount % 10 == 0) { + logger.debug("Extracted $fileCount files...") + } + } + + // Report progress (estimate based on compressed bytes) + val progress = (bytesRead.toDouble() / (totalSize * 3)).coerceAtMost(0.95) + progressHandler?.invoke(progress) + + entry = tarIn.nextTarEntry + } + } + } + } + } + + val totalTime = System.currentTimeMillis() - startTime + logger.info("Extracted $fileCount files in ${totalTime}ms") + progressHandler?.invoke(1.0) + } catch (e: Exception) { + logger.logError(e, "tar.gz extraction failed") + throw e + } + } + + // MARK: - ZIP Extraction + + /** + * Extract a zip archive using Java's native ZipInputStream + */ + private fun extractZip( + sourceFile: File, + destinationDir: File, + progressHandler: ((Double) -> Unit)? + ) { + logger.info("Extracting zip: ${sourceFile.name}") + progressHandler?.invoke(0.0) + + destinationDir.mkdirs() + + var fileCount = 0 + ZipInputStream(BufferedInputStream(FileInputStream(sourceFile))).use { zis -> + var entry = zis.nextEntry + while (entry != null) { + val fileName = entry.name + val newFile = File(destinationDir, fileName) + + // Security check - prevent zip slip attack + val destDirPath = destinationDir.canonicalPath + val newFilePath = newFile.canonicalPath + if (!newFilePath.startsWith(destDirPath + File.separator)) { + throw Exception("Entry is outside of the target dir: $fileName") + } + + if (entry.isDirectory) { + newFile.mkdirs() + } else { + // Create parent directories + newFile.parentFile?.mkdirs() + + // Write file + FileOutputStream(newFile).use { fos -> + val buffer = ByteArray(8192) + var len: Int + while (zis.read(buffer).also { len = it } != -1) { + fos.write(buffer, 0, len) + } + } + fileCount++ + } + + zis.closeEntry() + entry = zis.nextEntry + } + } + + logger.info("Extracted $fileCount files from zip") + progressHandler?.invoke(1.0) + } + + // MARK: - Helpers + + private fun formatBytes(bytes: Long): String { + return when { + bytes < 1024 -> "$bytes B" + bytes < 1024 * 1024 -> String.format("%.1f KB", bytes / 1024.0) + bytes < 1024 * 1024 * 1024 -> String.format("%.1f MB", bytes / (1024.0 * 1024)) + else -> String.format("%.2f GB", bytes / (1024.0 * 1024 * 1024)) + } + } +} diff --git a/sdk/runanywhere-react-native/packages/core/android/src/main/java/com/margelo/nitro/runanywhere/HybridRunAnywhereDeviceInfo.kt b/sdk/runanywhere-react-native/packages/core/android/src/main/java/com/margelo/nitro/runanywhere/HybridRunAnywhereDeviceInfo.kt new file mode 100644 index 000000000..7d1205b18 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/android/src/main/java/com/margelo/nitro/runanywhere/HybridRunAnywhereDeviceInfo.kt @@ -0,0 +1,229 @@ +/** + * HybridRunAnywhereDeviceInfo.kt + * + * Android implementation of device information for RunAnywhere SDK. + * Provides device capabilities, memory info, and battery status. + */ + +package com.margelo.nitro.runanywhere + +import android.app.ActivityManager +import android.content.Context +import android.os.BatteryManager +import android.os.Build +import android.os.PowerManager +import com.margelo.nitro.NitroModules +import com.margelo.nitro.core.Promise +import java.io.BufferedReader +import java.io.File +import java.io.FileReader + +/** + * Kotlin implementation of RunAnywhereDeviceInfo HybridObject. + * Provides device information and capabilities for the RunAnywhere SDK on Android. + */ +class HybridRunAnywhereDeviceInfo : HybridRunAnywhereDeviceInfoSpec() { + + companion object { + private val logger = SDKLogger.core + } + + private val context = NitroModules.applicationContext ?: error("Android context not found") + + /** + * Get device model name + */ + override fun getDeviceModel(): Promise = Promise.async { + val manufacturer = Build.MANUFACTURER + val model = Build.MODEL + if (model.startsWith(manufacturer, ignoreCase = true)) { + model.capitalize() + } else { + "${manufacturer.capitalize()} $model" + } + } + + /** + * Get OS version + */ + override fun getOSVersion(): Promise = Promise.async { + Build.VERSION.RELEASE + } + + /** + * Get platform name + */ + override fun getPlatform(): Promise = Promise.async { + "android" + } + + /** + * Get total RAM in bytes + */ + override fun getTotalRAM(): Promise = Promise.async { + val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + val memInfo = ActivityManager.MemoryInfo() + activityManager.getMemoryInfo(memInfo) + memInfo.totalMem.toDouble() + } + + /** + * Get available RAM in bytes + */ + override fun getAvailableRAM(): Promise = Promise.async { + val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + val memInfo = ActivityManager.MemoryInfo() + activityManager.getMemoryInfo(memInfo) + memInfo.availMem.toDouble() + } + + /** + * Get number of CPU cores + */ + override fun getCPUCores(): Promise = Promise.async { + Runtime.getRuntime().availableProcessors().toDouble() + } + + /** + * Check if device has GPU acceleration + */ + override fun hasGPU(): Promise = Promise.async { + // Check for Vulkan support as indicator of modern GPU + val hasVulkan = try { + val pm = context.packageManager + pm.hasSystemFeature("android.hardware.vulkan.level") + } catch (e: Exception) { + false + } + + // Most Android devices have some GPU + hasVulkan || Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP + } + + /** + * Check if device has Neural Engine / NPU + */ + override fun hasNPU(): Promise = Promise.async { + // Android Neural Networks API (NNAPI) is available on API 27+ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + val hardware = Build.HARDWARE.lowercase() + val soc = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + Build.SOC_MODEL.lowercase() + } else { + "" + } + + listOf( + "qcom", // Qualcomm (Hexagon DSP) + "exynos", // Samsung (NPU) + "tensor", // Google (TPU) + "kirin", // Huawei (NPU) + "dimensity", // MediaTek (APU) + "mtk" // MediaTek + ).any { hardware.contains(it) || soc.contains(it) } + } else { + false + } + } + + /** + * Get chip name if available + */ + override fun getChipName(): Promise = Promise.async { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val socModel = Build.SOC_MODEL + val socManufacturer = Build.SOC_MANUFACTURER + if (socModel.isNotEmpty() && socModel != "unknown") { + if (socManufacturer.isNotEmpty() && socManufacturer != "unknown") { + "$socManufacturer $socModel" + } else { + socModel + } + } else { + getCPUInfo() + } + } else { + getCPUInfo() + } + } + + /** + * Get device thermal state (0 = nominal, 1 = fair, 2 = serious, 3 = critical) + */ + override fun getThermalState(): Promise = Promise.async { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager + when (powerManager.currentThermalStatus) { + PowerManager.THERMAL_STATUS_NONE -> 0.0 + PowerManager.THERMAL_STATUS_LIGHT -> 0.0 + PowerManager.THERMAL_STATUS_MODERATE -> 1.0 + PowerManager.THERMAL_STATUS_SEVERE -> 2.0 + PowerManager.THERMAL_STATUS_CRITICAL -> 3.0 + PowerManager.THERMAL_STATUS_EMERGENCY -> 3.0 + PowerManager.THERMAL_STATUS_SHUTDOWN -> 3.0 + else -> 0.0 + } + } else { + 0.0 + } + } + + /** + * Get battery level (0.0 to 1.0) + */ + override fun getBatteryLevel(): Promise = Promise.async { + val batteryManager = context.getSystemService(Context.BATTERY_SERVICE) as BatteryManager + val level = batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY) + level.toDouble() / 100.0 + } + + /** + * Check if device is charging + */ + override fun isCharging(): Promise = Promise.async { + val batteryManager = context.getSystemService(Context.BATTERY_SERVICE) as BatteryManager + batteryManager.isCharging + } + + /** + * Check if low power mode is enabled + */ + override fun isLowPowerMode(): Promise = Promise.async { + val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager + powerManager.isPowerSaveMode + } + + /** + * Read CPU info from /proc/cpuinfo + */ + private fun getCPUInfo(): String { + return try { + val cpuInfo = File("/proc/cpuinfo") + if (cpuInfo.exists()) { + BufferedReader(FileReader(cpuInfo)).use { reader -> + var line: String? + while (reader.readLine().also { line = it } != null) { + if (line?.startsWith("Hardware") == true) { + return line?.substringAfter(":")?.trim() ?: "Unknown" + } + if (line?.startsWith("model name") == true) { + return line?.substringAfter(":")?.trim() ?: "Unknown" + } + } + } + } + Build.HARDWARE + } catch (e: Exception) { + logger.warning("Failed to read CPU info: ${e.message}") + Build.HARDWARE + } + } + + private fun String.capitalize(): String { + return if (isNotEmpty()) { + this[0].uppercaseChar() + substring(1) + } else { + this + } + } +} diff --git a/sdk/runanywhere-react-native/packages/core/android/src/main/java/com/margelo/nitro/runanywhere/PlatformAdapterBridge.kt b/sdk/runanywhere-react-native/packages/core/android/src/main/java/com/margelo/nitro/runanywhere/PlatformAdapterBridge.kt new file mode 100644 index 000000000..19326b3f0 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/android/src/main/java/com/margelo/nitro/runanywhere/PlatformAdapterBridge.kt @@ -0,0 +1,392 @@ +/** + * PlatformAdapterBridge.kt + * + * JNI bridge for platform-specific operations (secure storage). + * Called from C++ via JNI. + * + * Reference: sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/security/SecureStorage.kt + */ + +package com.margelo.nitro.runanywhere + +import android.util.Log + +/** + * JNI bridge that C++ code calls for platform operations. + * All methods are static and called via JNI from InitBridge.cpp + */ +object PlatformAdapterBridge { + private const val TAG = "PlatformAdapterBridge" + + /** + * Called from C++ to set a secure value + */ + @JvmStatic + fun secureSet(key: String, value: String): Boolean { + Log.d(TAG, "secureSet key=$key") + return SecureStorageManager.set(key, value) + } + + /** + * Called from C++ to get a secure value + */ + @JvmStatic + fun secureGet(key: String): String? { + Log.d(TAG, "secureGet key=$key") + return SecureStorageManager.get(key) + } + + /** + * Called from C++ to delete a secure value + */ + @JvmStatic + fun secureDelete(key: String): Boolean { + Log.d(TAG, "secureDelete key=$key") + return SecureStorageManager.delete(key) + } + + /** + * Called from C++ to check if key exists + */ + @JvmStatic + fun secureExists(key: String): Boolean { + return SecureStorageManager.exists(key) + } + + /** + * Called from C++ to get persistent device UUID + */ + @JvmStatic + fun getPersistentDeviceUUID(): String { + Log.d(TAG, "getPersistentDeviceUUID") + return SecureStorageManager.getPersistentDeviceUUID() + } + + // ======================================================================== + // HTTP POST for Device Registration (Synchronous) + // Matches Kotlin SDK's CppBridgeDevice.httpPost + // ======================================================================== + + /** + * HTTP response data class + */ + data class HttpResponse( + val success: Boolean, + val statusCode: Int, + val responseBody: String?, + val errorMessage: String? + ) + + /** + * Synchronous HTTP POST for device registration + * Called from C++ device manager callbacks via JNI + * + * @param url Full URL to POST to + * @param jsonBody JSON body string + * @param supabaseKey Supabase API key (for dev mode, can be null) + * @return HttpResponse with result + */ + @JvmStatic + fun httpPostSync(url: String, jsonBody: String, supabaseKey: String?): HttpResponse { + Log.d(TAG, "httpPostSync to: $url") + // Log first 300 chars of JSON body for debugging + Log.d(TAG, "httpPostSync body (first 300 chars): ${jsonBody.take(300)}") + + // For Supabase device registration, add ?on_conflict=device_id for UPSERT + // This matches Swift's HTTPService.swift logic + var finalUrl = url + if (url.contains("/rest/v1/sdk_devices") && !url.contains("on_conflict=")) { + val separator = if (url.contains("?")) "&" else "?" + finalUrl = "$url${separator}on_conflict=device_id" + Log.d(TAG, "Added on_conflict for UPSERT: $finalUrl") + } + + return try { + val urlConnection = java.net.URL(finalUrl).openConnection() as java.net.HttpURLConnection + urlConnection.requestMethod = "POST" + urlConnection.connectTimeout = 30000 + urlConnection.readTimeout = 30000 + urlConnection.doOutput = true + + // Headers + urlConnection.setRequestProperty("Content-Type", "application/json") + urlConnection.setRequestProperty("Accept", "application/json") + + // Supabase headers (for device registration UPSERT) + if (!supabaseKey.isNullOrEmpty()) { + urlConnection.setRequestProperty("apikey", supabaseKey) + urlConnection.setRequestProperty("Authorization", "Bearer $supabaseKey") + urlConnection.setRequestProperty("Prefer", "resolution=merge-duplicates") + } + + // Write body + urlConnection.outputStream.use { os -> + os.write(jsonBody.toByteArray(Charsets.UTF_8)) + } + + val statusCode = urlConnection.responseCode + val responseBody = try { + urlConnection.inputStream.bufferedReader().use { it.readText() } + } catch (e: Exception) { + urlConnection.errorStream?.bufferedReader()?.use { it.readText() } + } + + // 2xx or 409 (conflict/already exists) = success for device registration + val isSuccess = statusCode in 200..299 || statusCode == 409 + + Log.d(TAG, "httpPostSync completed: status=$statusCode success=$isSuccess") + if (!isSuccess) { + Log.e(TAG, "httpPostSync error response: $responseBody") + } + + HttpResponse( + success = isSuccess, + statusCode = statusCode, + responseBody = responseBody, + errorMessage = if (!isSuccess) "HTTP $statusCode" else null + ) + } catch (e: Exception) { + Log.e(TAG, "httpPostSync error", e) + HttpResponse( + success = false, + statusCode = 0, + responseBody = null, + errorMessage = e.message ?: "Unknown error" + ) + } + } + + // ======================================================================== + // Device Info (Synchronous) + // For device registration callback which must be synchronous + // ======================================================================== + + /** + * Get device model name (e.g., "Pixel 8 Pro") + */ + @JvmStatic + fun getDeviceModel(): String { + return android.os.Build.MODEL + } + + /** + * Get OS version (e.g., "14") + */ + @JvmStatic + fun getOSVersion(): String { + return android.os.Build.VERSION.RELEASE + } + + /** + * Get chip name (e.g., "Tensor G3") + */ + @JvmStatic + fun getChipName(): String { + return android.os.Build.HARDWARE + } + + /** + * Get total memory in bytes + */ + @JvmStatic + fun getTotalMemory(): Long { + // Try ActivityManager first (needs Context) + val context = SecureStorageManager.getContext() + if (context != null) { + try { + val activityManager = context.getSystemService(android.content.Context.ACTIVITY_SERVICE) as? android.app.ActivityManager + val memInfo = android.app.ActivityManager.MemoryInfo() + activityManager?.getMemoryInfo(memInfo) + if (memInfo.totalMem > 0) { + return memInfo.totalMem + } + } catch (e: Exception) { + Log.w(TAG, "getTotalMemory via ActivityManager failed: ${e.message}") + } + } + + // Fallback: Read from /proc/meminfo (works without Context) + try { + val memInfoFile = java.io.File("/proc/meminfo") + if (memInfoFile.exists()) { + memInfoFile.bufferedReader().use { reader -> + val line = reader.readLine() // First line: MemTotal: ... + if (line != null && line.startsWith("MemTotal:")) { + val parts = line.split("\\s+".toRegex()) + if (parts.size >= 2) { + val kB = parts[1].toLongOrNull() ?: 0L + return kB * 1024 // Convert KB to bytes + } + } + } + } + } catch (e: Exception) { + Log.w(TAG, "getTotalMemory via /proc/meminfo failed: ${e.message}") + } + + // Last resort: Return a reasonable default for modern phones (4GB) + return 4L * 1024 * 1024 * 1024 + } + + /** + * Get available memory in bytes + */ + @JvmStatic + fun getAvailableMemory(): Long { + // Try ActivityManager first (needs Context) + val context = SecureStorageManager.getContext() + if (context != null) { + try { + val activityManager = context.getSystemService(android.content.Context.ACTIVITY_SERVICE) as? android.app.ActivityManager + val memInfo = android.app.ActivityManager.MemoryInfo() + activityManager?.getMemoryInfo(memInfo) + if (memInfo.availMem > 0) { + return memInfo.availMem + } + } catch (e: Exception) { + Log.w(TAG, "getAvailableMemory via ActivityManager failed: ${e.message}") + } + } + + // Fallback: Read from /proc/meminfo (works without Context) + try { + val memInfoFile = java.io.File("/proc/meminfo") + if (memInfoFile.exists()) { + memInfoFile.bufferedReader().use { reader -> + var line = reader.readLine() + while (line != null) { + if (line.startsWith("MemAvailable:")) { + val parts = line.split("\\s+".toRegex()) + if (parts.size >= 2) { + val kB = parts[1].toLongOrNull() ?: 0L + return kB * 1024 // Convert KB to bytes + } + } + line = reader.readLine() + } + } + } + } catch (e: Exception) { + Log.w(TAG, "getAvailableMemory via /proc/meminfo failed: ${e.message}") + } + + // Last resort: Return half of total as estimate + return getTotalMemory() / 2 + } + + /** + * Get CPU core count + */ + @JvmStatic + fun getCoreCount(): Int { + return Runtime.getRuntime().availableProcessors() + } + + /** + * Get architecture (e.g., "arm64-v8a") + */ + @JvmStatic + fun getArchitecture(): String { + return android.os.Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown" + } + + /** + * Get GPU family based on chip name + * Infers GPU vendor from known chip manufacturers: + * - Google Tensor/Pixel → Mali + * - Samsung Exynos → Mali + * - Qualcomm Snapdragon → Adreno + * - MediaTek → Mali (mostly) + * - HiSilicon Kirin → Mali + * + * Aligned with Kotlin SDK's CppBridgeDevice.getDefaultGPUFamily() + */ + @JvmStatic + fun getGPUFamily(): String { + val chipName = getChipName().lowercase() + val manufacturer = android.os.Build.MANUFACTURER.lowercase() + + return when { + // Google Pixel codenames (all use Mali GPUs from Samsung/Google Tensor) + chipName == "bluejay" -> "mali" // Pixel 6a (Tensor) + chipName == "oriole" -> "mali" // Pixel 6 (Tensor) + chipName == "raven" -> "mali" // Pixel 6 Pro (Tensor) + chipName == "cheetah" -> "mali" // Pixel 7 (Tensor G2) + chipName == "panther" -> "mali" // Pixel 7 Pro (Tensor G2) + chipName == "lynx" -> "mali" // Pixel 7a (Tensor G2) + chipName == "tangorpro" -> "mali" // Pixel Tablet (Tensor G2) + chipName == "shiba" -> "mali" // Pixel 8 (Tensor G3) + chipName == "husky" -> "mali" // Pixel 8 Pro (Tensor G3) + chipName == "akita" -> "mali" // Pixel 8a (Tensor G3) + chipName == "caiman" -> "mali" // Pixel 9 (Tensor G4) + chipName == "komodo" -> "mali" // Pixel 9 Pro (Tensor G4) + chipName == "comet" -> "mali" // Pixel 9 Pro XL (Tensor G4) + chipName == "tokay" -> "mali" // Pixel 9 Pro Fold (Tensor G4) + + // Google Tensor generic patterns + chipName.contains("tensor") -> "mali" + chipName.contains("gs1") -> "mali" // GS101 (Tensor) + chipName.contains("gs2") -> "mali" // GS201 (Tensor G2) + chipName.contains("zuma") -> "mali" // Zuma (Tensor G3) + manufacturer == "google" -> "mali" // Default for Google devices + + // Samsung Exynos uses Mali GPUs + chipName.contains("exynos") -> "mali" + chipName.startsWith("s5e") -> "mali" // Samsung internal naming (e.g., s5e8535) + chipName.contains("samsung") -> "mali" + + // Qualcomm Snapdragon uses Adreno GPUs + chipName.contains("snapdragon") -> "adreno" + chipName.contains("qualcomm") -> "adreno" + chipName.contains("sdm") -> "adreno" // SDM845, SDM855, etc. + chipName.contains("sm8") -> "adreno" // SM8150, SM8250, etc. + chipName.contains("sm7") -> "adreno" // SM7150, etc. + chipName.contains("sm6") -> "adreno" // SM6150, etc. + chipName.contains("msm") -> "adreno" // Older MSM chips + chipName.contains("kona") -> "adreno" // Snapdragon 865 + chipName.contains("lahaina") -> "adreno" // Snapdragon 888 + chipName.contains("taro") -> "adreno" // Snapdragon 8 Gen 1 + chipName.contains("kalama") -> "adreno" // Snapdragon 8 Gen 2 + chipName.contains("pineapple") -> "adreno" // Snapdragon 8 Gen 3 + manufacturer == "qualcomm" -> "adreno" + + // MediaTek uses Mali GPUs (mostly) + chipName.contains("mediatek") -> "mali" + chipName.contains("mt6") -> "mali" // MT6xxx series + chipName.contains("mt8") -> "mali" // MT8xxx series + chipName.contains("dimensity") -> "mali" + chipName.contains("helio") -> "mali" + + // HiSilicon Kirin uses Mali GPUs + chipName.contains("kirin") -> "mali" + chipName.contains("hisilicon") -> "mali" + + // Intel/x86 GPUs + chipName.contains("intel") -> "intel" + + // NVIDIA (rare on mobile) + chipName.contains("nvidia") -> "nvidia" + chipName.contains("tegra") -> "nvidia" + + else -> "unknown" + } + } + + /** + * Check if device is a tablet + * Uses screen size configuration to determine form factor + * Matches Swift SDK: device.userInterfaceIdiom == .pad + */ + @JvmStatic + fun isTablet(): Boolean { + val context = SecureStorageManager.getContext() + if (context != null) { + val screenLayout = context.resources.configuration.screenLayout and + android.content.res.Configuration.SCREENLAYOUT_SIZE_MASK + return screenLayout >= android.content.res.Configuration.SCREENLAYOUT_SIZE_LARGE + } + // Fallback: Check display metrics if context unavailable + return false + } +} + diff --git a/sdk/runanywhere-react-native/packages/core/android/src/main/java/com/margelo/nitro/runanywhere/RunAnywhereCorePackage.kt b/sdk/runanywhere-react-native/packages/core/android/src/main/java/com/margelo/nitro/runanywhere/RunAnywhereCorePackage.kt new file mode 100644 index 000000000..462d7ded1 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/android/src/main/java/com/margelo/nitro/runanywhere/RunAnywhereCorePackage.kt @@ -0,0 +1,28 @@ +package com.margelo.nitro.runanywhere + +import android.util.Log +import com.facebook.react.BaseReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.module.model.ReactModuleInfoProvider + +class RunAnywhereCorePackage : BaseReactPackage() { + override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? { + // Initialize secure storage with application context + SecureStorageManager.initialize(reactContext.applicationContext) + return null + } + + override fun getReactModuleInfoProvider(): ReactModuleInfoProvider { + return ReactModuleInfoProvider { HashMap() } + } + + companion object { + private const val TAG = "RunAnywhereCorePackage" + + init { + System.loadLibrary("runanywherecore") + Log.i(TAG, "Loaded native library: runanywherecore") + } + } +} diff --git a/sdk/runanywhere-react-native/packages/core/android/src/main/java/com/margelo/nitro/runanywhere/SDKLogger.kt b/sdk/runanywhere-react-native/packages/core/android/src/main/java/com/margelo/nitro/runanywhere/SDKLogger.kt new file mode 100644 index 000000000..cac946fd3 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/android/src/main/java/com/margelo/nitro/runanywhere/SDKLogger.kt @@ -0,0 +1,357 @@ +/** + * SDKLogger.kt + * + * Android native logging implementation for React Native SDK. + * Provides structured logging with category-based filtering. + * Supports forwarding logs to TypeScript for centralized logging. + * + * Matches: + * - iOS SDK: sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Logging/SDKLogger.swift + * - TypeScript: packages/core/src/Foundation/Logging/Logger/SDKLogger.ts + * - iOS RN: packages/core/ios/SDKLogger.swift + * + * Usage: + * SDKLogger.shared.info("SDK initialized") + * SDKLogger.download.debug("Starting download: $url") + * SDKLogger.llm.error("Generation failed", mapOf("modelId" to "llama-3.2")) + */ + +package com.margelo.nitro.runanywhere + +import android.util.Log +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +/** + * Log severity levels matching TypeScript LogLevel enum + */ +enum class LogLevel(val value: Int) { + Debug(0), + Info(1), + Warning(2), + Error(3), + Fault(4); + + val description: String + get() = when (this) { + Debug -> "DEBUG" + Info -> "INFO" + Warning -> "WARN" + Error -> "ERROR" + Fault -> "FAULT" + } + + companion object { + fun fromValue(value: Int): LogLevel = entries.find { it.value == value } ?: Info + } +} + +/** + * Log entry for forwarding to TypeScript + * Matches TypeScript: LogEntry interface + */ +data class NativeLogEntry( + val level: Int, + val category: String, + val message: String, + val metadata: Map?, + val timestamp: Date +) { + /** + * Convert to Map for JSON serialization + */ + fun toMap(): Map { + val isoFormatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + + return mapOf( + "level" to level, + "category" to category, + "message" to message, + "timestamp" to isoFormatter.format(timestamp), + "metadata" to metadata?.mapValues { (_, value) -> + when (value) { + is String, is Number, is Boolean -> value + else -> value?.toString() + } + } + ) + } +} + +/** + * Interface for forwarding logs to TypeScript + */ +interface NativeLogForwarder { + fun forwardLog(entry: NativeLogEntry) +} + +/** + * Simple logger for SDK components with category-based filtering. + * Thread-safe and easy to use. + * + * Matches iOS: SDKLogger class + */ +class SDKLogger( + /** Logger category (e.g., "LLM", "Download", "Models") */ + val category: String = "SDK" +) { + companion object { + /** Minimum log level (logs below this level are ignored) */ + @Volatile + private var minLogLevel: LogLevel = LogLevel.Debug + + /** Whether local logcat logging is enabled */ + @Volatile + private var localLoggingEnabled = true + + /** Whether to forward logs to TypeScript */ + @Volatile + private var forwardingEnabled = true + + /** Log forwarder for TypeScript bridge */ + @Volatile + private var logForwarder: NativeLogForwarder? = null + + // ================================================================== + // Configuration + // ================================================================== + + /** + * Set the minimum log level. + * @param level Minimum level to log + */ + @JvmStatic + fun setMinLogLevel(level: LogLevel) { + minLogLevel = level + } + + /** + * Get the current minimum log level. + */ + @JvmStatic + fun getMinLogLevel(): LogLevel = minLogLevel + + /** + * Enable or disable local logcat logging. + * @param enabled Whether to log to logcat + */ + @JvmStatic + fun setLocalLoggingEnabled(enabled: Boolean) { + localLoggingEnabled = enabled + } + + /** + * Enable or disable log forwarding to TypeScript. + * @param enabled Whether to forward logs + */ + @JvmStatic + fun setForwardingEnabled(enabled: Boolean) { + forwardingEnabled = enabled + } + + /** + * Set the log forwarder for TypeScript bridge. + * @param forwarder Log forwarder implementation + */ + @JvmStatic + fun setLogForwarder(forwarder: NativeLogForwarder?) { + logForwarder = forwarder + } + + /** + * Check if log forwarding is configured + */ + @JvmStatic + fun isForwardingConfigured(): Boolean = logForwarder != null && forwardingEnabled + + // ================================================================== + // Convenience Loggers (Static) + // ================================================================== + + /** Shared logger for general SDK operations. Category: "RunAnywhere" */ + @JvmField + val shared = SDKLogger("RunAnywhere") + + /** Logger for LLM operations. Category: "LLM" */ + @JvmField + val llm = SDKLogger("LLM") + + /** Logger for STT (Speech-to-Text) operations. Category: "STT" */ + @JvmField + val stt = SDKLogger("STT") + + /** Logger for TTS (Text-to-Speech) operations. Category: "TTS" */ + @JvmField + val tts = SDKLogger("TTS") + + /** Logger for download operations. Category: "Download" */ + @JvmField + val download = SDKLogger("Download") + + /** Logger for model operations. Category: "Models" */ + @JvmField + val models = SDKLogger("Models") + + /** Logger for core SDK operations. Category: "Core" */ + @JvmField + val core = SDKLogger("Core") + + /** Logger for VAD operations. Category: "VAD" */ + @JvmField + val vad = SDKLogger("VAD") + + /** Logger for network operations. Category: "Network" */ + @JvmField + val network = SDKLogger("Network") + + /** Logger for events. Category: "Events" */ + @JvmField + val events = SDKLogger("Events") + + /** Logger for archive/extraction operations. Category: "Archive" */ + @JvmField + val archive = SDKLogger("Archive") + + /** Logger for audio decoding operations. Category: "AudioDecoder" */ + @JvmField + val audioDecoder = SDKLogger("AudioDecoder") + } + + // ================================================================== + // Logging Methods + // ================================================================== + + /** + * Log a debug message. + * @param message Log message + * @param metadata Optional metadata map + */ + @JvmOverloads + fun debug(message: String, metadata: Map? = null) { + log(LogLevel.Debug, message, metadata) + } + + /** + * Log an info message. + * @param message Log message + * @param metadata Optional metadata map + */ + @JvmOverloads + fun info(message: String, metadata: Map? = null) { + log(LogLevel.Info, message, metadata) + } + + /** + * Log a warning message. + * @param message Log message + * @param metadata Optional metadata map + */ + @JvmOverloads + fun warning(message: String, metadata: Map? = null) { + log(LogLevel.Warning, message, metadata) + } + + /** + * Log an error message. + * @param message Log message + * @param metadata Optional metadata map + */ + @JvmOverloads + fun error(message: String, metadata: Map? = null) { + log(LogLevel.Error, message, metadata) + } + + /** + * Log a fault/critical message. + * @param message Log message + * @param metadata Optional metadata map + */ + @JvmOverloads + fun fault(message: String, metadata: Map? = null) { + log(LogLevel.Fault, message, metadata) + } + + // ================================================================== + // Error Logging + // ================================================================== + + /** + * Log a Throwable with full context. + * @param throwable Throwable to log + * @param additionalInfo Optional additional context + */ + @JvmOverloads + fun logError(throwable: Throwable, additionalInfo: String? = null) { + var message = throwable.message ?: throwable.toString() + if (additionalInfo != null) { + message += " | Context: $additionalInfo" + } + + val metadata = mutableMapOf( + "error_class" to throwable.javaClass.simpleName, + "error_message" to throwable.message + ) + + throwable.cause?.let { cause -> + metadata["error_cause"] = cause.message + } + + log(LogLevel.Error, message, metadata) + + // Also log stack trace at debug level + if (minLogLevel <= LogLevel.Debug) { + Log.d(category, "Stack trace:", throwable) + } + } + + // ================================================================== + // Core Logging + // ================================================================== + + /** + * Log a message with the specified level. + * @param level Log level + * @param message Log message + * @param metadata Optional metadata map + */ + fun log(level: LogLevel, message: String, metadata: Map? = null) { + if (level.value < minLogLevel.value) return + + val timestamp = Date() + + // Build formatted message + var output = "[$category] $message" + if (!metadata.isNullOrEmpty()) { + val metaStr = metadata.entries.joinToString(", ") { "${it.key}=${it.value}" } + output += " | $metaStr" + } + + // Log to Android Log (logcat) if enabled + if (localLoggingEnabled) { + when (level) { + LogLevel.Debug -> Log.d(category, output) + LogLevel.Info -> Log.i(category, output) + LogLevel.Warning -> Log.w(category, output) + LogLevel.Error -> Log.e(category, output) + LogLevel.Fault -> Log.wtf(category, output) // "What a Terrible Failure" + } + } + + // Forward to TypeScript if enabled + if (forwardingEnabled) { + logForwarder?.forwardLog( + NativeLogEntry( + level = level.value, + category = category, + message = message, + metadata = metadata, + timestamp = timestamp + ) + ) + } + } +} diff --git a/sdk/runanywhere-react-native/packages/core/android/src/main/java/com/margelo/nitro/runanywhere/SecureStorageManager.kt b/sdk/runanywhere-react-native/packages/core/android/src/main/java/com/margelo/nitro/runanywhere/SecureStorageManager.kt new file mode 100644 index 000000000..26559b41e --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/android/src/main/java/com/margelo/nitro/runanywhere/SecureStorageManager.kt @@ -0,0 +1,147 @@ +/** + * SecureStorageManager.kt + * + * Android secure storage using EncryptedSharedPreferences. + * Provides hardware-backed encryption when available (Android Keystore). + * + * Reference: sdk/runanywhere-kotlin/src/androidMain/kotlin/com/runanywhere/sdk/security/SecureStorage.kt + */ + +package com.margelo.nitro.runanywhere + +import android.annotation.SuppressLint +import android.content.Context +import android.content.SharedPreferences +import android.util.Log +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import java.util.UUID + +/** + * Secure storage manager for persistent device identity and sensitive data. + * Uses EncryptedSharedPreferences backed by Android Keystore. + */ +object SecureStorageManager { + private const val TAG = "SecureStorageManager" + private const val PREFS_NAME = "runanywhere_secure_prefs" + private const val DEVICE_UUID_KEY = "com.runanywhere.sdk.device.uuid" + + @SuppressLint("StaticFieldLeak") + private var context: Context? = null + private var encryptedPrefs: SharedPreferences? = null + + /** + * Get the stored context (for platform bridge operations) + */ + @JvmStatic + fun getContext(): Context? = context + + /** + * Initialize with application context + * Must be called before any other operations + */ + @JvmStatic + fun initialize(applicationContext: Context) { + if (context != null) return + + context = applicationContext.applicationContext + try { + val masterKey = MasterKey.Builder(context!!) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + encryptedPrefs = EncryptedSharedPreferences.create( + context!!, + PREFS_NAME, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + Log.i(TAG, "SecureStorageManager initialized") + } catch (e: Exception) { + Log.e(TAG, "Failed to initialize EncryptedSharedPreferences", e) + } + } + + /** + * Set a secure string value + */ + @JvmStatic + fun set(key: String, value: String): Boolean { + return try { + encryptedPrefs?.edit()?.putString(key, value)?.apply() + Log.d(TAG, "secureSet key=$key") + true + } catch (e: Exception) { + Log.e(TAG, "secureSet failed for key=$key", e) + false + } + } + + /** + * Get a secure string value + */ + @JvmStatic + fun get(key: String): String? { + return try { + val value = encryptedPrefs?.getString(key, null) + Log.d(TAG, "secureGet key=$key found=${value != null}") + value + } catch (e: Exception) { + Log.e(TAG, "secureGet failed for key=$key", e) + null + } + } + + /** + * Delete a secure value + */ + @JvmStatic + fun delete(key: String): Boolean { + return try { + encryptedPrefs?.edit()?.remove(key)?.apply() + Log.d(TAG, "secureDelete key=$key") + true + } catch (e: Exception) { + Log.e(TAG, "secureDelete failed for key=$key", e) + false + } + } + + /** + * Check if key exists + */ + @JvmStatic + fun exists(key: String): Boolean { + return try { + encryptedPrefs?.contains(key) ?: false + } catch (e: Exception) { + Log.e(TAG, "secureExists failed for key=$key", e) + false + } + } + + /** + * Get or create persistent device UUID + * Survives app reinstalls (stored in EncryptedSharedPreferences) + */ + @JvmStatic + fun getPersistentDeviceUUID(): String { + // Try to get existing UUID + val existingUUID = get(DEVICE_UUID_KEY) + if (!existingUUID.isNullOrEmpty()) { + Log.i(TAG, "Loaded persistent device UUID from secure storage") + return existingUUID + } + + // Generate new UUID + val newUUID = UUID.randomUUID().toString() + if (set(DEVICE_UUID_KEY, newUUID)) { + Log.i(TAG, "Generated and stored new persistent device UUID") + } else { + Log.w(TAG, "Generated device UUID but failed to persist") + } + return newUUID + } +} + diff --git a/sdk/runanywhere-react-native/packages/core/cpp/HybridRunAnywhereCore.cpp b/sdk/runanywhere-react-native/packages/core/cpp/HybridRunAnywhereCore.cpp new file mode 100644 index 000000000..dbb75ae09 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/cpp/HybridRunAnywhereCore.cpp @@ -0,0 +1,2572 @@ +/** + * HybridRunAnywhereCore.cpp + * + * Nitrogen HybridObject implementation for RunAnywhere Core SDK. + * + * Core SDK implementation - includes: + * - SDK Lifecycle, Authentication, Device Registration + * - Model Registry, Download Service, Storage + * - Events, HTTP Client, Utilities + * - LLM/STT/TTS/VAD/VoiceAgent capabilities (backend-agnostic) + * + * The capability methods (LLM, STT, TTS, VAD, VoiceAgent) are BACKEND-AGNOSTIC. + * They call the C++ rac_*_component_* APIs which work with any registered backend. + * Apps must install a backend package to register the actual implementation: + * - @runanywhere/llamacpp registers the LLM backend via rac_backend_llamacpp_register() + * - @runanywhere/onnx registers the STT/TTS/VAD backends via rac_backend_onnx_register() + * + * Mirrors Swift's CppBridge architecture from: + * sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/ + */ + + +#include "HybridRunAnywhereCore.hpp" + +// RACommons headers +#include "rac_dev_config.h" // For rac_dev_config_get_build_token + +// Core bridges - aligned with actual RACommons API +#include "bridges/InitBridge.hpp" +#include "bridges/DeviceBridge.hpp" +#include "bridges/AuthBridge.hpp" +#include "bridges/StorageBridge.hpp" +#include "bridges/ModelRegistryBridge.hpp" +#include "bridges/EventBridge.hpp" +#include "bridges/HTTPBridge.hpp" +#include "bridges/DownloadBridge.hpp" +#include "bridges/TelemetryBridge.hpp" + +// RACommons C API headers for capability methods +// These are backend-agnostic - they work with any registered backend +#include "rac_core.h" +#include "rac_llm_component.h" +#include "rac_llm_types.h" +#include "rac_llm_structured_output.h" +#include "rac_stt_component.h" +#include "rac_stt_types.h" +#include "rac_tts_component.h" +#include "rac_tts_types.h" +#include "rac_vad_component.h" +#include "rac_vad_types.h" +#include "rac_voice_agent.h" +#include "rac_types.h" +#include "rac_model_assignment.h" + +#include +#include +#include +#include +#include +#include + +// Platform-specific headers for memory usage +#if defined(__APPLE__) +#include +#include +#endif + +// Platform-specific logging +#if defined(ANDROID) || defined(__ANDROID__) +#include +#define LOG_TAG "HybridRunAnywhereCore" +#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) +#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, LOG_TAG, __VA_ARGS__) +#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) +#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__) +#else +#define LOGI(...) printf("[HybridRunAnywhereCore] "); printf(__VA_ARGS__); printf("\n") +#define LOGW(...) printf("[HybridRunAnywhereCore WARN] "); printf(__VA_ARGS__); printf("\n") +#define LOGE(...) printf("[HybridRunAnywhereCore ERROR] "); printf(__VA_ARGS__); printf("\n") +#define LOGD(...) printf("[HybridRunAnywhereCore DEBUG] "); printf(__VA_ARGS__); printf("\n") +#endif + +namespace margelo::nitro::runanywhere { + +using namespace ::runanywhere::bridges; + +// ============================================================================ +// Base64 Utilities +// ============================================================================ + +namespace { + +static const std::string base64_chars = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +std::vector base64Decode(const std::string& encoded) { + std::vector decoded; + if (encoded.empty()) return decoded; + + int val = 0, valb = -8; + for (char c : encoded) { + if (c == '=' || c == '\n' || c == '\r') continue; + size_t pos = base64_chars.find(c); + if (pos == std::string::npos) continue; + val = (val << 6) + static_cast(pos); + valb += 6; + if (valb >= 0) { + decoded.push_back(static_cast((val >> valb) & 0xFF)); + valb -= 8; + } + } + return decoded; +} + +std::string base64Encode(const uint8_t* data, size_t len) { + std::string encoded; + if (!data || len == 0) return encoded; + + int val = 0, valb = -6; + for (size_t i = 0; i < len; i++) { + val = (val << 8) + data[i]; + valb += 8; + while (valb >= 0) { + encoded.push_back(base64_chars[(val >> valb) & 0x3F]); + valb -= 6; + } + } + if (valb > -6) { + encoded.push_back(base64_chars[((val << 8) >> (valb + 8)) & 0x3F]); + } + while (encoded.size() % 4) { + encoded.push_back('='); + } + return encoded; +} + +// ============================================================================ +// JSON Utilities +// ============================================================================ + +int extractIntValue(const std::string& json, const std::string& key, int defaultValue) { + std::string searchKey = "\"" + key + "\":"; + size_t pos = json.find(searchKey); + if (pos == std::string::npos) return defaultValue; + pos += searchKey.length(); + while (pos < json.size() && (json[pos] == ' ' || json[pos] == '\t')) pos++; + if (pos >= json.size()) return defaultValue; + // Skip if this is a string value (starts with quote) + if (json[pos] == '"') return defaultValue; + // Try to parse as integer, return default on failure + try { + return std::stoi(json.substr(pos)); + } catch (...) { + return defaultValue; + } +} + +double extractDoubleValue(const std::string& json, const std::string& key, double defaultValue) { + std::string searchKey = "\"" + key + "\":"; + size_t pos = json.find(searchKey); + if (pos == std::string::npos) return defaultValue; + pos += searchKey.length(); + while (pos < json.size() && (json[pos] == ' ' || json[pos] == '\t')) pos++; + if (pos >= json.size()) return defaultValue; + // Skip if this is a string value (starts with quote) + if (json[pos] == '"') return defaultValue; + // Try to parse as double, return default on failure + try { + return std::stod(json.substr(pos)); + } catch (...) { + return defaultValue; + } +} + +std::string extractStringValue(const std::string& json, const std::string& key, const std::string& defaultValue = "") { + std::string searchKey = "\"" + key + "\":\""; + size_t pos = json.find(searchKey); + if (pos == std::string::npos) return defaultValue; + pos += searchKey.length(); + size_t endPos = json.find("\"", pos); + if (endPos == std::string::npos) return defaultValue; + return json.substr(pos, endPos - pos); +} + +bool extractBoolValue(const std::string& json, const std::string& key, bool defaultValue = false) { + std::string searchKey = "\"" + key + "\":"; + size_t pos = json.find(searchKey); + if (pos == std::string::npos) return defaultValue; + pos += searchKey.length(); + while (pos < json.size() && (json[pos] == ' ' || json[pos] == '\t')) pos++; + if (pos >= json.size()) return defaultValue; + if (json.substr(pos, 4) == "true") return true; + if (json.substr(pos, 5) == "false") return false; + return defaultValue; +} + +// Convert TypeScript framework string to C++ enum +rac_inference_framework_t frameworkFromString(const std::string& framework) { + if (framework == "LlamaCpp" || framework == "llamacpp") return RAC_FRAMEWORK_LLAMACPP; + if (framework == "ONNX" || framework == "onnx") return RAC_FRAMEWORK_ONNX; + if (framework == "FoundationModels") return RAC_FRAMEWORK_FOUNDATION_MODELS; + if (framework == "SystemTTS") return RAC_FRAMEWORK_SYSTEM_TTS; + return RAC_FRAMEWORK_UNKNOWN; +} + +// Convert TypeScript category string to C++ enum +rac_model_category_t categoryFromString(const std::string& category) { + if (category == "Language" || category == "language") return RAC_MODEL_CATEGORY_LANGUAGE; + // Handle both hyphen and underscore variants + if (category == "SpeechRecognition" || category == "speech-recognition" || category == "speech_recognition") return RAC_MODEL_CATEGORY_SPEECH_RECOGNITION; + if (category == "SpeechSynthesis" || category == "speech-synthesis" || category == "speech_synthesis") return RAC_MODEL_CATEGORY_SPEECH_SYNTHESIS; + if (category == "VoiceActivity" || category == "voice-activity" || category == "voice_activity") return RAC_MODEL_CATEGORY_AUDIO; + if (category == "Vision" || category == "vision") return RAC_MODEL_CATEGORY_VISION; + if (category == "ImageGeneration" || category == "image-generation" || category == "image_generation") return RAC_MODEL_CATEGORY_IMAGE_GENERATION; + if (category == "Multimodal" || category == "multimodal") return RAC_MODEL_CATEGORY_MULTIMODAL; + if (category == "Audio" || category == "audio") return RAC_MODEL_CATEGORY_AUDIO; + return RAC_MODEL_CATEGORY_UNKNOWN; +} + +// Convert TypeScript format string to C++ enum +rac_model_format_t formatFromString(const std::string& format) { + if (format == "GGUF" || format == "gguf") return RAC_MODEL_FORMAT_GGUF; + if (format == "GGML" || format == "ggml") return RAC_MODEL_FORMAT_BIN; // GGML -> BIN as fallback + if (format == "ONNX" || format == "onnx") return RAC_MODEL_FORMAT_ONNX; + if (format == "ORT" || format == "ort") return RAC_MODEL_FORMAT_ORT; + if (format == "BIN" || format == "bin") return RAC_MODEL_FORMAT_BIN; + return RAC_MODEL_FORMAT_UNKNOWN; +} + +std::string jsonString(const std::string& value) { + std::string escaped = "\""; + for (char c : value) { + if (c == '"') escaped += "\\\""; + else if (c == '\\') escaped += "\\\\"; + else if (c == '\n') escaped += "\\n"; + else if (c == '\r') escaped += "\\r"; + else if (c == '\t') escaped += "\\t"; + else escaped += c; + } + escaped += "\""; + return escaped; +} + +std::string buildJsonObject(const std::vector>& keyValues) { + std::string result = "{"; + for (size_t i = 0; i < keyValues.size(); i++) { + if (i > 0) result += ","; + result += "\"" + keyValues[i].first + "\":" + keyValues[i].second; + } + result += "}"; + return result; +} + +} // anonymous namespace + +// ============================================================================ +// Constructor / Destructor +// ============================================================================ + +HybridRunAnywhereCore::HybridRunAnywhereCore() : HybridObject(TAG) { + LOGI("HybridRunAnywhereCore constructor - core module"); +} + +HybridRunAnywhereCore::~HybridRunAnywhereCore() { + LOGI("HybridRunAnywhereCore destructor"); + + // Cleanup bridges (note: telemetry is NOT shutdown here because it's shared + // across instances and should persist for the SDK lifetime) + EventBridge::shared().unregisterFromEvents(); + DownloadBridge::shared().shutdown(); + StorageBridge::shared().shutdown(); + ModelRegistryBridge::shared().shutdown(); + // Note: InitBridge and TelemetryBridge are not shutdown in destructor + // to allow events to be tracked even after HybridObject instances are destroyed +} + +// ============================================================================ +// SDK Lifecycle +// ============================================================================ + +std::shared_ptr> HybridRunAnywhereCore::initialize( + const std::string& configJson) { + return Promise::async([this, configJson]() { + std::lock_guard lock(initMutex_); + + LOGI("Initializing Core SDK..."); + + // Parse config + std::string apiKey = extractStringValue(configJson, "apiKey"); + std::string baseURL = extractStringValue(configJson, "baseURL", "https://api.runanywhere.ai"); + std::string deviceId = extractStringValue(configJson, "deviceId"); + std::string envStr = extractStringValue(configJson, "environment", "production"); + std::string sdkVersionFromConfig = extractStringValue(configJson, "sdkVersion", "0.2.0"); + + // Determine environment + SDKEnvironment env = SDKEnvironment::Production; + if (envStr == "development") env = SDKEnvironment::Development; + else if (envStr == "staging") env = SDKEnvironment::Staging; + + // 1. Initialize core (platform adapter + state) + rac_result_t result = InitBridge::shared().initialize(env, apiKey, baseURL, deviceId); + if (result != RAC_SUCCESS) { + setLastError("Failed to initialize SDK core: " + std::to_string(result)); + return false; + } + + // Set SDK version from TypeScript SDKConstants (centralized version) + InitBridge::shared().setSdkVersion(sdkVersionFromConfig); + + // 2. Set base directory for model paths (mirrors Swift's CppBridge.ModelPaths.setBaseDirectory) + // This must be called before using model path utilities + std::string documentsPath = extractStringValue(configJson, "documentsPath"); + if (!documentsPath.empty()) { + result = InitBridge::shared().setBaseDirectory(documentsPath); + if (result != RAC_SUCCESS) { + LOGE("Failed to set base directory: %d", result); + // Continue - not fatal, but model paths may not work correctly + } + } else { + LOGE("documentsPath not provided in config - model paths may not work correctly!"); + } + + // 3. Initialize model registry + result = ModelRegistryBridge::shared().initialize(); + if (result != RAC_SUCCESS) { + LOGE("Failed to initialize model registry: %d", result); + // Continue - not fatal + } + + // 4. Initialize storage analyzer + result = StorageBridge::shared().initialize(); + if (result != RAC_SUCCESS) { + LOGE("Failed to initialize storage analyzer: %d", result); + // Continue - not fatal + } + + // 5. Initialize download manager + result = DownloadBridge::shared().initialize(); + if (result != RAC_SUCCESS) { + LOGE("Failed to initialize download manager: %d", result); + // Continue - not fatal + } + + // 6. Register for events + EventBridge::shared().registerForEvents(); + + // 7. Configure HTTP + HTTPBridge::shared().configure(baseURL, apiKey); + + // 8. Initialize telemetry (matches Swift's CppBridge.Telemetry.initialize) + // This creates the C++ telemetry manager and registers HTTP callback + { + std::string persistentDeviceId = InitBridge::shared().getPersistentDeviceUUID(); + std::string deviceModel = InitBridge::shared().getDeviceModel(); + std::string osVersion = InitBridge::shared().getOSVersion(); + + if (!persistentDeviceId.empty()) { + TelemetryBridge::shared().initialize( + env == SDKEnvironment::Development ? RAC_ENV_DEVELOPMENT : + env == SDKEnvironment::Staging ? RAC_ENV_STAGING : RAC_ENV_PRODUCTION, + persistentDeviceId, + deviceModel, + osVersion, + sdkVersionFromConfig // Use version from config + ); + + // Register analytics events callback to route events to telemetry + TelemetryBridge::shared().registerEventsCallback(); + + LOGI("Telemetry initialized with device: %s", persistentDeviceId.c_str()); + } else { + LOGE("Cannot initialize telemetry: device ID unavailable"); + } + } + + // 9. Initialize model assignments with auto-fetch + // Set up HTTP GET callback for fetching models from backend + { + rac_assignment_callbacks_t callbacks = {}; + + // HTTP GET callback - uses HTTPBridge for network requests + callbacks.http_get = [](const char* endpoint, rac_bool_t requires_auth, + rac_assignment_http_response_t* out_response, void* user_data) -> rac_result_t { + if (!out_response) return RAC_ERROR_NULL_POINTER; + + try { + std::string endpointStr = endpoint ? endpoint : ""; + LOGD("Model assignment HTTP GET: %s", endpointStr.c_str()); + + // Use HTTPBridge::execute which calls the registered JS executor + auto responseOpt = HTTPBridge::shared().execute("GET", endpointStr, "", requires_auth == RAC_TRUE); + + if (!responseOpt.has_value()) { + LOGE("HTTP executor not registered"); + out_response->result = RAC_ERROR_HTTP_REQUEST_FAILED; + out_response->error_message = strdup("HTTP executor not registered"); + return RAC_ERROR_HTTP_REQUEST_FAILED; + } + + const auto& response = responseOpt.value(); + if (response.success && !response.body.empty()) { + out_response->result = RAC_SUCCESS; + out_response->status_code = response.statusCode; + out_response->response_body = strdup(response.body.c_str()); + out_response->response_length = response.body.length(); + return RAC_SUCCESS; + } else { + out_response->result = RAC_ERROR_HTTP_REQUEST_FAILED; + out_response->status_code = response.statusCode; + if (!response.error.empty()) { + out_response->error_message = strdup(response.error.c_str()); + } + return RAC_ERROR_HTTP_REQUEST_FAILED; + } + } catch (const std::exception& e) { + LOGE("Model assignment HTTP GET failed: %s", e.what()); + out_response->result = RAC_ERROR_HTTP_REQUEST_FAILED; + out_response->error_message = strdup(e.what()); + return RAC_ERROR_HTTP_REQUEST_FAILED; + } + }; + + callbacks.user_data = nullptr; + // Only auto-fetch in staging/production, not development + bool shouldAutoFetch = (env != SDKEnvironment::Development); + callbacks.auto_fetch = shouldAutoFetch ? RAC_TRUE : RAC_FALSE; + + result = rac_model_assignment_set_callbacks(&callbacks); + if (result == RAC_SUCCESS) { + LOGI("Model assignment callbacks registered (autoFetch: %s)", shouldAutoFetch ? "true" : "false"); + } else { + LOGE("Failed to register model assignment callbacks: %d", result); + // Continue - not fatal, models can be fetched later + } + } + + LOGI("Core SDK initialized successfully"); + return true; + }); +} + +std::shared_ptr> HybridRunAnywhereCore::destroy() { + return Promise::async([this]() { + std::lock_guard lock(initMutex_); + + LOGI("Destroying Core SDK..."); + + // Cleanup in reverse order + TelemetryBridge::shared().shutdown(); // Flush and destroy telemetry first + EventBridge::shared().unregisterFromEvents(); + DownloadBridge::shared().shutdown(); + StorageBridge::shared().shutdown(); + ModelRegistryBridge::shared().shutdown(); + InitBridge::shared().shutdown(); + + LOGI("Core SDK destroyed"); + }); +} + +std::shared_ptr> HybridRunAnywhereCore::isInitialized() { + return Promise::async([]() { + return InitBridge::shared().isInitialized(); + }); +} + +std::shared_ptr> HybridRunAnywhereCore::getBackendInfo() { + return Promise::async([]() { + // Check if SDK is initialized using the actual InitBridge state + bool isInitialized = InitBridge::shared().isInitialized(); + + std::string status = isInitialized ? "initialized" : "not_initialized"; + std::string name = isInitialized ? "RunAnywhere Core" : "Not initialized"; + + return buildJsonObject({ + {"name", jsonString(name)}, + {"status", jsonString(status)}, + {"version", jsonString("0.2.0")}, + {"api", jsonString("rac_*")}, + {"source", jsonString("runanywhere-commons")}, + {"module", jsonString("core")}, + {"initialized", isInitialized ? "true" : "false"} + }); + }); +} + +// ============================================================================ +// Authentication +// ============================================================================ + +std::shared_ptr> HybridRunAnywhereCore::authenticate( + const std::string& apiKey) { + return Promise::async([this, apiKey]() -> bool { + LOGI("Authenticating..."); + + // Build auth request JSON + std::string deviceId = DeviceBridge::shared().getDeviceId(); + // Use actual platform (ios/android) as backend only accepts these values +#if defined(__APPLE__) + std::string platform = "ios"; +#elif defined(ANDROID) || defined(__ANDROID__) + std::string platform = "android"; +#else + std::string platform = "ios"; // Default to ios for unknown platforms +#endif + // Use centralized SDK version from InitBridge (set from TypeScript SDKConstants) + std::string sdkVersion = InitBridge::shared().getSdkVersion(); + + std::string requestJson = AuthBridge::shared().buildAuthenticateRequestJSON( + apiKey, deviceId, platform, sdkVersion + ); + + if (requestJson.empty()) { + setLastError("Failed to build auth request"); + return false; + } + + // NOTE: HTTP request must be made by JS layer + // This C++ method just prepares the request JSON + // The JS layer should: + // 1. Call this method to prepare + // 2. Make HTTP POST to /api/v1/auth/sdk/authenticate + // 3. Call handleAuthResponse() with the response + + // For now, we indicate that auth JSON is prepared + LOGI("Auth request JSON prepared. HTTP must be done by JS layer."); + return true; + }); +} + +std::shared_ptr> HybridRunAnywhereCore::isAuthenticated() { + return Promise::async([]() -> bool { + return AuthBridge::shared().isAuthenticated(); + }); +} + +std::shared_ptr> HybridRunAnywhereCore::getUserId() { + return Promise::async([]() -> std::string { + return AuthBridge::shared().getUserId(); + }); +} + +std::shared_ptr> HybridRunAnywhereCore::getOrganizationId() { + return Promise::async([]() -> std::string { + return AuthBridge::shared().getOrganizationId(); + }); +} + +std::shared_ptr> HybridRunAnywhereCore::setAuthTokens( + const std::string& authResponseJson) { + return Promise::async([this, authResponseJson]() -> bool { + LOGI("Setting auth tokens from JS authentication response..."); + + // Parse the auth response + AuthResponse response = AuthBridge::shared().handleAuthResponse(authResponseJson); + + if (response.success) { + // IMPORTANT: Actually store the tokens in AuthBridge! + // handleAuthResponse only parses, setAuth stores them + AuthBridge::shared().setAuth(response); + + LOGI("Auth tokens set successfully. Token expires in %lld seconds", + static_cast(response.expiresIn)); + LOGD("Access token stored (length=%zu)", response.accessToken.length()); + return true; + } else { + LOGE("Failed to set auth tokens: %s", response.error.c_str()); + setLastError("Failed to set auth tokens: " + response.error); + return false; + } + }); +} + +// ============================================================================ +// Device Registration +// ============================================================================ + +std::shared_ptr> HybridRunAnywhereCore::registerDevice( + const std::string& environmentJson) { + return Promise::async([this, environmentJson]() -> bool { + LOGI("Registering device..."); + + // Parse environment + std::string envStr = extractStringValue(environmentJson, "environment", "production"); + rac_environment_t env = RAC_ENV_PRODUCTION; + if (envStr == "development") env = RAC_ENV_DEVELOPMENT; + else if (envStr == "staging") env = RAC_ENV_STAGING; + + std::string buildToken = extractStringValue(environmentJson, "buildToken", ""); + std::string supabaseKey = extractStringValue(environmentJson, "supabaseKey", ""); + + // For development mode, get build token from C++ dev config if not provided + // This matches Swift's CppBridge.DevConfig.buildToken behavior + if (buildToken.empty() && env == RAC_ENV_DEVELOPMENT) { + const char* devBuildToken = rac_dev_config_get_build_token(); + if (devBuildToken && strlen(devBuildToken) > 0) { + buildToken = devBuildToken; + LOGD("Using build token from dev config"); + } + } + + // Set up platform callbacks (matches Swift's CppBridge.Device.registerCallbacks) + DevicePlatformCallbacks callbacks; + + // Device info callback - populates all fields needed by backend + // Matches Swift's CppBridge+Device.swift get_device_info callback + callbacks.getDeviceInfo = []() -> DeviceInfo { + DeviceInfo info; + + // Core identification + info.deviceId = InitBridge::shared().getPersistentDeviceUUID(); + // Use actual platform (ios/android) as backend only accepts these values +#if defined(__APPLE__) + info.platform = "ios"; +#elif defined(ANDROID) || defined(__ANDROID__) + info.platform = "android"; +#else + info.platform = "ios"; // Default to ios for unknown platforms +#endif + // Use centralized SDK version from InitBridge (set from TypeScript SDKConstants) + info.sdkVersion = InitBridge::shared().getSdkVersion(); + + // Device hardware info from platform-specific code + info.deviceModel = InitBridge::shared().getDeviceModel(); + info.deviceName = info.deviceModel; // Use model as name (React Native doesn't expose device name) + info.osVersion = InitBridge::shared().getOSVersion(); + info.chipName = InitBridge::shared().getChipName(); + info.architecture = InitBridge::shared().getArchitecture(); + info.totalMemory = InitBridge::shared().getTotalMemory(); + info.availableMemory = InitBridge::shared().getAvailableMemory(); + info.coreCount = InitBridge::shared().getCoreCount(); + + // Form factor detection (matches Swift SDK: device.userInterfaceIdiom == .pad) + // Uses platform-specific detection via InitBridge::isTablet() + bool isTabletDevice = InitBridge::shared().isTablet(); + info.formFactor = isTabletDevice ? "tablet" : "phone"; + + // Platform-specific values + #if defined(__APPLE__) + info.osName = "iOS"; + info.gpuFamily = InitBridge::shared().getGPUFamily(); // "apple" + info.hasNeuralEngine = true; + info.neuralEngineCores = 16; // Modern iPhones have 16 ANE cores + #elif defined(ANDROID) || defined(__ANDROID__) + info.osName = "Android"; + info.gpuFamily = InitBridge::shared().getGPUFamily(); // "mali", "adreno", etc. + info.hasNeuralEngine = false; + info.neuralEngineCores = 0; + #else + info.osName = "Unknown"; + info.gpuFamily = "unknown"; + info.hasNeuralEngine = false; + info.neuralEngineCores = 0; + #endif + + // Battery info (not available in React Native easily, use defaults) + info.batteryLevel = -1.0; // Unknown + info.batteryState = ""; // Unknown + info.isLowPowerMode = false; + + // Core distribution (approximate for mobile devices) + info.performanceCores = info.coreCount > 4 ? 2 : 1; + info.efficiencyCores = info.coreCount - info.performanceCores; + + return info; + }; + + // Device ID callback + callbacks.getDeviceId = []() -> std::string { + return InitBridge::shared().getPersistentDeviceUUID(); + }; + + // Check registration status callback + callbacks.isRegistered = []() -> bool { + // Check UserDefaults/SharedPrefs for registration status + std::string value; + if (InitBridge::shared().secureGet("com.runanywhere.sdk.deviceRegistered", value)) { + return value == "true"; + } + return false; + }; + + // Set registration status callback + callbacks.setRegistered = [](bool registered) { + InitBridge::shared().secureSet("com.runanywhere.sdk.deviceRegistered", + registered ? "true" : "false"); + }; + + // HTTP POST callback - key for device registration! + // Uses native URLSession (iOS) or HttpURLConnection (Android) + // All credentials come from C++ dev config (matches Swift's CppBridge.DevConfig) + callbacks.httpPost = [env]( + const std::string& endpoint, + const std::string& jsonBody, + bool requiresAuth + ) -> std::tuple { + // Build full URL based on environment (matches Swift HTTPService) + std::string baseURL; + std::string apiKey; + + if (env == RAC_ENV_DEVELOPMENT) { + // Development: Use Supabase from C++ dev config (development_config.cpp) + // NO FALLBACK - credentials must come from C++ config only + const char* devUrl = rac_dev_config_get_supabase_url(); + const char* devKey = rac_dev_config_get_supabase_key(); + + baseURL = devUrl ? devUrl : ""; + apiKey = devKey ? devKey : ""; + + if (baseURL.empty()) { + LOGW("Development mode but Supabase URL not configured in C++ dev_config"); + } else { + LOGD("Using Supabase from dev config: %s", baseURL.c_str()); + } + } else { + // Production/Staging: Use configured Railway URL + // These come from SDK initialization (App.tsx -> RunAnywhere.initialize) + baseURL = InitBridge::shared().getBaseURL(); + + // For production mode, prefer JWT access token (from authentication) + // over raw API key. This matches Swift/Kotlin behavior. + std::string accessToken = AuthBridge::shared().getAccessToken(); + if (!accessToken.empty()) { + apiKey = accessToken; // Use JWT for Authorization header + LOGD("Using JWT access token for device registration"); + } else { + // Fallback to API key if not authenticated yet + apiKey = InitBridge::shared().getApiKey(); + LOGD("Using API key for device registration (not authenticated)"); + } + + // Fallback to default if not configured + if (baseURL.empty()) { + baseURL = "https://api.runanywhere.ai"; + } + + LOGD("Using production config: %s", baseURL.c_str()); + } + + std::string fullURL = baseURL + endpoint; + LOGI("Device HTTP POST to: %s (env=%d)", fullURL.c_str(), env); + + return InitBridge::shared().httpPostSync(fullURL, jsonBody, apiKey); + }; + + // Set callbacks on DeviceBridge + DeviceBridge::shared().setPlatformCallbacks(callbacks); + + // Register callbacks with C++ + rac_result_t result = DeviceBridge::shared().registerCallbacks(); + if (result != RAC_SUCCESS) { + setLastError("Failed to register device callbacks: " + std::to_string(result)); + return false; + } + + // Now register device + result = DeviceBridge::shared().registerIfNeeded(env, buildToken); + if (result != RAC_SUCCESS) { + setLastError("Device registration failed: " + std::to_string(result)); + return false; + } + + LOGI("Device registered successfully"); + return true; + }); +} + +std::shared_ptr> HybridRunAnywhereCore::isDeviceRegistered() { + return Promise::async([]() -> bool { + return DeviceBridge::shared().isRegistered(); + }); +} + +std::shared_ptr> HybridRunAnywhereCore::clearDeviceRegistration() { + return Promise::async([]() -> bool { + LOGI("Clearing device registration flag for testing..."); + bool success = InitBridge::shared().secureDelete("com.runanywhere.sdk.deviceRegistered"); + if (success) { + LOGI("Device registration flag cleared successfully"); + } else { + LOGI("Device registration flag not found (may not exist)"); + } + return true; // Return true even if key didn't exist + }); +} + +std::shared_ptr> HybridRunAnywhereCore::getDeviceId() { + return Promise::async([]() -> std::string { + return DeviceBridge::shared().getDeviceId(); + }); +} + +// ============================================================================ +// Model Registry +// ============================================================================ + +std::shared_ptr> HybridRunAnywhereCore::getAvailableModels() { + return Promise::async([]() -> std::string { + auto models = ModelRegistryBridge::shared().getAllModels(); + + LOGI("getAvailableModels: Building JSON for %zu models", models.size()); + + std::string result = "["; + for (size_t i = 0; i < models.size(); i++) { + if (i > 0) result += ","; + const auto& m = models[i]; + // Convert C++ enum values to TypeScript string values for compatibility + std::string categoryStr = "unknown"; + switch (m.category) { + case RAC_MODEL_CATEGORY_LANGUAGE: categoryStr = "language"; break; + case RAC_MODEL_CATEGORY_SPEECH_RECOGNITION: categoryStr = "speech-recognition"; break; + case RAC_MODEL_CATEGORY_SPEECH_SYNTHESIS: categoryStr = "speech-synthesis"; break; + case RAC_MODEL_CATEGORY_VISION: categoryStr = "vision"; break; + case RAC_MODEL_CATEGORY_AUDIO: categoryStr = "audio"; break; + case RAC_MODEL_CATEGORY_MULTIMODAL: categoryStr = "multimodal"; break; + default: categoryStr = "unknown"; break; + } + std::string formatStr = "unknown"; + switch (m.format) { + case RAC_MODEL_FORMAT_GGUF: formatStr = "gguf"; break; + case RAC_MODEL_FORMAT_ONNX: formatStr = "onnx"; break; + case RAC_MODEL_FORMAT_ORT: formatStr = "ort"; break; + case RAC_MODEL_FORMAT_BIN: formatStr = "bin"; break; + default: formatStr = "unknown"; break; + } + std::string frameworkStr = "unknown"; + switch (m.framework) { + case RAC_FRAMEWORK_LLAMACPP: frameworkStr = "LlamaCpp"; break; + case RAC_FRAMEWORK_ONNX: frameworkStr = "ONNX"; break; + case RAC_FRAMEWORK_FOUNDATION_MODELS: frameworkStr = "FoundationModels"; break; + case RAC_FRAMEWORK_SYSTEM_TTS: frameworkStr = "SystemTTS"; break; + default: frameworkStr = "unknown"; break; + } + + result += buildJsonObject({ + {"id", jsonString(m.id)}, + {"name", jsonString(m.name)}, + {"localPath", jsonString(m.localPath)}, + {"downloadURL", jsonString(m.downloadUrl)}, // TypeScript uses capital U + {"category", jsonString(categoryStr)}, // String for TypeScript + {"format", jsonString(formatStr)}, // String for TypeScript + {"preferredFramework", jsonString(frameworkStr)}, // String for TypeScript + {"downloadSize", std::to_string(m.downloadSize)}, + {"memoryRequired", std::to_string(m.memoryRequired)}, + {"supportsThinking", m.supportsThinking ? "true" : "false"}, + {"isDownloaded", m.isDownloaded ? "true" : "false"}, + {"isAvailable", "true"} // Models in registry are available + }); + } + result += "]"; + + LOGD("getAvailableModels: JSON length=%zu", result.length()); + + return result; + }); +} + +std::shared_ptr> HybridRunAnywhereCore::getModelInfo( + const std::string& modelId) { + return Promise::async([modelId]() -> std::string { + auto model = ModelRegistryBridge::shared().getModel(modelId); + if (!model.has_value()) { + return "{}"; + } + + const auto& m = model.value(); + + // Convert enums to strings (same as getAvailableModels) + std::string categoryStr = "unknown"; + switch (m.category) { + case RAC_MODEL_CATEGORY_LANGUAGE: categoryStr = "language"; break; + case RAC_MODEL_CATEGORY_SPEECH_RECOGNITION: categoryStr = "speech-recognition"; break; + case RAC_MODEL_CATEGORY_SPEECH_SYNTHESIS: categoryStr = "speech-synthesis"; break; + case RAC_MODEL_CATEGORY_AUDIO: categoryStr = "audio"; break; + case RAC_MODEL_CATEGORY_VISION: categoryStr = "vision"; break; + case RAC_MODEL_CATEGORY_IMAGE_GENERATION: categoryStr = "image-generation"; break; + case RAC_MODEL_CATEGORY_MULTIMODAL: categoryStr = "multimodal"; break; + default: categoryStr = "unknown"; break; + } + std::string formatStr = "unknown"; + switch (m.format) { + case RAC_MODEL_FORMAT_GGUF: formatStr = "gguf"; break; + case RAC_MODEL_FORMAT_ONNX: formatStr = "onnx"; break; + case RAC_MODEL_FORMAT_ORT: formatStr = "ort"; break; + case RAC_MODEL_FORMAT_BIN: formatStr = "bin"; break; + default: formatStr = "unknown"; break; + } + std::string frameworkStr = "unknown"; + switch (m.framework) { + case RAC_FRAMEWORK_LLAMACPP: frameworkStr = "LlamaCpp"; break; + case RAC_FRAMEWORK_ONNX: frameworkStr = "ONNX"; break; + case RAC_FRAMEWORK_FOUNDATION_MODELS: frameworkStr = "FoundationModels"; break; + case RAC_FRAMEWORK_SYSTEM_TTS: frameworkStr = "SystemTTS"; break; + default: frameworkStr = "unknown"; break; + } + + return buildJsonObject({ + {"id", jsonString(m.id)}, + {"name", jsonString(m.name)}, + {"description", jsonString(m.description)}, + {"localPath", jsonString(m.localPath)}, + {"downloadURL", jsonString(m.downloadUrl)}, // Fixed: downloadURL (capital URL) to match TypeScript + {"category", jsonString(categoryStr)}, // String for TypeScript + {"format", jsonString(formatStr)}, // String for TypeScript + {"preferredFramework", jsonString(frameworkStr)}, // String for TypeScript (preferredFramework key) + {"downloadSize", std::to_string(m.downloadSize)}, + {"memoryRequired", std::to_string(m.memoryRequired)}, + {"contextLength", std::to_string(m.contextLength)}, + {"supportsThinking", m.supportsThinking ? "true" : "false"}, + {"isDownloaded", m.isDownloaded ? "true" : "false"}, + {"isAvailable", "true"} // Added isAvailable field + }); + }); +} + +std::shared_ptr> HybridRunAnywhereCore::isModelDownloaded( + const std::string& modelId) { + return Promise::async([modelId]() -> bool { + return ModelRegistryBridge::shared().isModelDownloaded(modelId); + }); +} + +std::shared_ptr> HybridRunAnywhereCore::getModelPath( + const std::string& modelId) { + return Promise::async([modelId]() -> std::string { + auto path = ModelRegistryBridge::shared().getModelPath(modelId); + return path.value_or(""); + }); +} + +std::shared_ptr> HybridRunAnywhereCore::registerModel( + const std::string& modelJson) { + return Promise::async([modelJson]() -> bool { + LOGI("Registering model from JSON: %.200s", modelJson.c_str()); + + ModelInfo model; + model.id = extractStringValue(modelJson, "id"); + model.name = extractStringValue(modelJson, "name"); + model.description = extractStringValue(modelJson, "description"); + model.localPath = extractStringValue(modelJson, "localPath"); + + // Support both TypeScript naming (downloadURL) and C++ naming (downloadUrl) + model.downloadUrl = extractStringValue(modelJson, "downloadURL"); + if (model.downloadUrl.empty()) { + model.downloadUrl = extractStringValue(modelJson, "downloadUrl"); + } + + model.downloadSize = extractIntValue(modelJson, "downloadSize", 0); + model.memoryRequired = extractIntValue(modelJson, "memoryRequired", 0); + model.contextLength = extractIntValue(modelJson, "contextLength", 0); + model.supportsThinking = extractBoolValue(modelJson, "supportsThinking", false); + + // Handle category - could be string (TypeScript) or int + std::string categoryStr = extractStringValue(modelJson, "category"); + if (!categoryStr.empty()) { + model.category = categoryFromString(categoryStr); + } else { + model.category = static_cast(extractIntValue(modelJson, "category", RAC_MODEL_CATEGORY_UNKNOWN)); + } + + // Handle format - could be string (TypeScript) or int + std::string formatStr = extractStringValue(modelJson, "format"); + if (!formatStr.empty()) { + model.format = formatFromString(formatStr); + } else { + model.format = static_cast(extractIntValue(modelJson, "format", RAC_MODEL_FORMAT_UNKNOWN)); + } + + // Handle framework - prefer string extraction for TypeScript compatibility + std::string frameworkStr = extractStringValue(modelJson, "preferredFramework"); + if (!frameworkStr.empty()) { + model.framework = frameworkFromString(frameworkStr); + } else { + frameworkStr = extractStringValue(modelJson, "framework"); + if (!frameworkStr.empty()) { + model.framework = frameworkFromString(frameworkStr); + } else { + model.framework = static_cast(extractIntValue(modelJson, "preferredFramework", RAC_FRAMEWORK_UNKNOWN)); + } + } + + LOGI("Registering model: id=%s, name=%s, framework=%d, category=%d", + model.id.c_str(), model.name.c_str(), model.framework, model.category); + + rac_result_t result = ModelRegistryBridge::shared().addModel(model); + + if (result == RAC_SUCCESS) { + LOGI("✅ Model registered successfully: %s", model.id.c_str()); + } else { + LOGE("❌ Model registration failed: %s, result=%d", model.id.c_str(), result); + } + + return result == RAC_SUCCESS; + }); +} + +// ============================================================================ +// Download Service +// ============================================================================ + +std::shared_ptr> HybridRunAnywhereCore::downloadModel( + const std::string& modelId, + const std::string& url, + const std::string& destPath) { + return Promise::async([this, modelId, url, destPath]() -> bool { + LOGI("Starting download: %s", modelId.c_str()); + + std::string taskId = DownloadBridge::shared().startDownload( + modelId, url, destPath, false, // requiresExtraction + [](const DownloadProgress& progress) { + LOGD("Download progress: %.1f%%", progress.overallProgress * 100); + } + ); + + if (taskId.empty()) { + setLastError("Failed to start download"); + return false; + } + + return true; + }); +} + +std::shared_ptr> HybridRunAnywhereCore::cancelDownload( + const std::string& taskId) { + return Promise::async([taskId]() -> bool { + rac_result_t result = DownloadBridge::shared().cancelDownload(taskId); + return result == RAC_SUCCESS; + }); +} + +std::shared_ptr> HybridRunAnywhereCore::getDownloadProgress( + const std::string& taskId) { + return Promise::async([taskId]() -> std::string { + auto progress = DownloadBridge::shared().getProgress(taskId); + if (!progress.has_value()) { + return "{}"; + } + + const auto& p = progress.value(); + std::string stateStr; + switch (p.state) { + case DownloadState::Pending: stateStr = "pending"; break; + case DownloadState::Downloading: stateStr = "downloading"; break; + case DownloadState::Extracting: stateStr = "extracting"; break; + case DownloadState::Retrying: stateStr = "retrying"; break; + case DownloadState::Completed: stateStr = "completed"; break; + case DownloadState::Failed: stateStr = "failed"; break; + case DownloadState::Cancelled: stateStr = "cancelled"; break; + } + + return buildJsonObject({ + {"bytesDownloaded", std::to_string(p.bytesDownloaded)}, + {"totalBytes", std::to_string(p.totalBytes)}, + {"overallProgress", std::to_string(p.overallProgress)}, + {"stageProgress", std::to_string(p.stageProgress)}, + {"state", jsonString(stateStr)}, + {"speed", std::to_string(p.speed)}, + {"estimatedTimeRemaining", std::to_string(p.estimatedTimeRemaining)}, + {"retryAttempt", std::to_string(p.retryAttempt)}, + {"errorCode", std::to_string(p.errorCode)}, + {"errorMessage", jsonString(p.errorMessage)} + }); + }); +} + +// ============================================================================ +// Storage +// ============================================================================ + +std::shared_ptr> HybridRunAnywhereCore::getStorageInfo() { + return Promise::async([]() { + auto registryHandle = ModelRegistryBridge::shared().getHandle(); + auto info = StorageBridge::shared().analyzeStorage(registryHandle); + + return buildJsonObject({ + {"totalDeviceSpace", std::to_string(info.deviceStorage.totalSpace)}, + {"freeDeviceSpace", std::to_string(info.deviceStorage.freeSpace)}, + {"usedDeviceSpace", std::to_string(info.deviceStorage.usedSpace)}, + {"documentsSize", std::to_string(info.appStorage.documentsSize)}, + {"cacheSize", std::to_string(info.appStorage.cacheSize)}, + {"appSupportSize", std::to_string(info.appStorage.appSupportSize)}, + {"totalAppSize", std::to_string(info.appStorage.totalSize)}, + {"totalModelsSize", std::to_string(info.totalModelsSize)}, + {"modelCount", std::to_string(info.models.size())} + }); + }); +} + +std::shared_ptr> HybridRunAnywhereCore::clearCache() { + return Promise::async([]() { + LOGI("Clearing cache..."); + + // Clear the model assignment cache (in-memory cache for model assignments) + rac_model_assignment_clear_cache(); + + LOGI("Cache cleared successfully"); + return true; + }); +} + +std::shared_ptr> HybridRunAnywhereCore::deleteModel( + const std::string& modelId) { + return Promise::async([modelId]() { + LOGI("Deleting model: %s", modelId.c_str()); + rac_result_t result = ModelRegistryBridge::shared().removeModel(modelId); + return result == RAC_SUCCESS; + }); +} + +// ============================================================================ +// Events +// ============================================================================ + +std::shared_ptr> HybridRunAnywhereCore::emitEvent( + const std::string& eventJson) { + return Promise::async([eventJson]() -> void { + std::string type = extractStringValue(eventJson, "type"); + std::string categoryStr = extractStringValue(eventJson, "category", "sdk"); + + EventCategory category = EventCategory::SDK; + if (categoryStr == "model") category = EventCategory::Model; + else if (categoryStr == "llm") category = EventCategory::LLM; + else if (categoryStr == "stt") category = EventCategory::STT; + else if (categoryStr == "tts") category = EventCategory::TTS; + + EventBridge::shared().trackEvent(type, category, EventDestination::All, eventJson); + }); +} + +std::shared_ptr> HybridRunAnywhereCore::pollEvents() { + // Events are push-based via callback, not polling + return Promise::async([]() -> std::string { + return "[]"; + }); +} + +// ============================================================================ +// HTTP Client +// ============================================================================ + +std::shared_ptr> HybridRunAnywhereCore::configureHttp( + const std::string& baseUrl, + const std::string& apiKey) { + return Promise::async([baseUrl, apiKey]() -> bool { + HTTPBridge::shared().configure(baseUrl, apiKey); + return HTTPBridge::shared().isConfigured(); + }); +} + +std::shared_ptr> HybridRunAnywhereCore::httpPost( + const std::string& path, + const std::string& bodyJson) { + return Promise::async([this, path, bodyJson]() -> std::string { + // HTTP is handled by JS layer + // This returns URL for JS to use + std::string url = HTTPBridge::shared().buildURL(path); + + // Try to use registered executor if available + auto response = HTTPBridge::shared().execute("POST", path, bodyJson, true); + if (response.has_value()) { + if (response->success) { + return response->body; + } else { + throw std::runtime_error(response->error); + } + } + + // No executor - return error indicating HTTP must be done by JS + throw std::runtime_error("HTTP executor not registered. Use JS layer for HTTP requests."); + }); +} + +std::shared_ptr> HybridRunAnywhereCore::httpGet( + const std::string& path) { + return Promise::async([this, path]() -> std::string { + auto response = HTTPBridge::shared().execute("GET", path, "", true); + if (response.has_value()) { + if (response->success) { + return response->body; + } else { + throw std::runtime_error(response->error); + } + } + + throw std::runtime_error("HTTP executor not registered. Use JS layer for HTTP requests."); + }); +} + +// ============================================================================ +// Utility Functions +// ============================================================================ + +std::shared_ptr> HybridRunAnywhereCore::getLastError() { + return Promise::async([this]() { return lastError_; }); +} + +// Forward declaration for platform-specific archive extraction +#if defined(__APPLE__) +extern "C" bool ArchiveUtility_extract(const char* archivePath, const char* destinationPath); +#elif defined(__ANDROID__) +// On Android, we'll call the Kotlin ArchiveUtility via JNI in a separate helper +extern "C" bool ArchiveUtility_extractAndroid(const char* archivePath, const char* destinationPath); +#endif + +std::shared_ptr> HybridRunAnywhereCore::extractArchive( + const std::string& archivePath, + const std::string& destPath) { + return Promise::async([this, archivePath, destPath]() { + LOGI("extractArchive: %s -> %s", archivePath.c_str(), destPath.c_str()); + +#if defined(__APPLE__) + // iOS: Call Swift ArchiveUtility + bool success = ArchiveUtility_extract(archivePath.c_str(), destPath.c_str()); + if (success) { + LOGI("iOS archive extraction succeeded"); + return true; + } else { + LOGE("iOS archive extraction failed"); + setLastError("Archive extraction failed"); + return false; + } +#elif defined(__ANDROID__) + // Android: Call Kotlin ArchiveUtility via JNI + bool success = ArchiveUtility_extractAndroid(archivePath.c_str(), destPath.c_str()); + if (success) { + LOGI("Android archive extraction succeeded"); + return true; + } else { + LOGE("Android archive extraction failed"); + setLastError("Archive extraction failed"); + return false; + } +#else + LOGW("Archive extraction not supported on this platform"); + setLastError("Archive extraction not supported"); + return false; +#endif + }); +} + +std::shared_ptr> HybridRunAnywhereCore::getDeviceCapabilities() { + return Promise::async([]() { + std::string platform = +#if defined(__APPLE__) + "ios"; +#else + "android"; +#endif + bool supportsMetal = +#if defined(__APPLE__) + true; +#else + false; +#endif + bool supportsVulkan = +#if defined(__APPLE__) + false; +#else + true; +#endif + return buildJsonObject({ + {"platform", jsonString(platform)}, + {"supports_metal", supportsMetal ? "true" : "false"}, + {"supports_vulkan", supportsVulkan ? "true" : "false"}, + {"api", jsonString("rac_*")}, + {"module", jsonString("core")} + }); + }); +} + +std::shared_ptr> HybridRunAnywhereCore::getMemoryUsage() { + return Promise::async([]() { + double memoryUsageMB = 0.0; + +#if defined(__APPLE__) + // iOS/macOS: Use mach_task_basic_info + mach_task_basic_info_data_t taskInfo; + mach_msg_type_number_t infoCount = MACH_TASK_BASIC_INFO_COUNT; + + kern_return_t result = task_info( + mach_task_self(), + MACH_TASK_BASIC_INFO, + reinterpret_cast(&taskInfo), + &infoCount + ); + + if (result == KERN_SUCCESS) { + // resident_size is in bytes, convert to MB + memoryUsageMB = static_cast(taskInfo.resident_size) / (1024.0 * 1024.0); + } +#elif defined(__ANDROID__) || defined(ANDROID) + // Android: Read from /proc/self/status + FILE* file = fopen("/proc/self/status", "r"); + if (file) { + char line[128]; + while (fgets(line, sizeof(line), file)) { + // Look for VmRSS (Resident Set Size) + if (strncmp(line, "VmRSS:", 6) == 0) { + long vmRssKB = 0; + sscanf(line + 6, "%ld", &vmRssKB); + memoryUsageMB = static_cast(vmRssKB) / 1024.0; + break; + } + } + fclose(file); + } +#endif + + LOGI("Memory usage: %.2f MB", memoryUsageMB); + return memoryUsageMB; + }); +} + +// ============================================================================ +// Helper Methods +// ============================================================================ + +void HybridRunAnywhereCore::setLastError(const std::string& error) { + lastError_ = error; + LOGE("%s", error.c_str()); +} + +// ============================================================================ +// LLM Capability (Backend-Agnostic) +// Calls rac_llm_component_* APIs - works with any registered backend +// Uses a global LLM component handle shared across HybridRunAnywhereCore instances +// ============================================================================ + +// Global LLM component handle - shared across all instances +static rac_handle_t g_llm_component_handle = nullptr; +static std::mutex g_llm_mutex; + +static rac_handle_t getGlobalLLMHandle() { + std::lock_guard lock(g_llm_mutex); + if (g_llm_component_handle == nullptr) { + rac_result_t result = rac_llm_component_create(&g_llm_component_handle); + if (result != RAC_SUCCESS) { + g_llm_component_handle = nullptr; + } + } + return g_llm_component_handle; +} + +std::shared_ptr> HybridRunAnywhereCore::loadTextModel( + const std::string& modelPath, + const std::optional& configJson) { + return Promise::async([this, modelPath, configJson]() -> bool { + LOGI("Loading text model: %s", modelPath.c_str()); + + rac_handle_t handle = getGlobalLLMHandle(); + if (!handle) { + setLastError("Failed to create LLM component. Is an LLM backend registered?"); + throw std::runtime_error("LLM backend not registered. Install @runanywhere/llamacpp."); + } + + // Load the model + rac_result_t result = rac_llm_component_load_model(handle, modelPath.c_str(), modelPath.c_str(), modelPath.c_str()); + if (result != RAC_SUCCESS) { + setLastError("Failed to load model: " + std::to_string(result)); + throw std::runtime_error("Failed to load text model: " + std::to_string(result)); + } + + LOGI("Text model loaded successfully"); + return true; + }); +} + +std::shared_ptr> HybridRunAnywhereCore::isTextModelLoaded() { + return Promise::async([]() -> bool { + rac_handle_t handle = getGlobalLLMHandle(); + if (!handle) { + return false; + } + bool isLoaded = rac_llm_component_is_loaded(handle) == RAC_TRUE; + LOGD("isTextModelLoaded: handle=%p, isLoaded=%s", handle, isLoaded ? "true" : "false"); + return isLoaded; + }); +} + +std::shared_ptr> HybridRunAnywhereCore::unloadTextModel() { + return Promise::async([]() -> bool { + rac_handle_t handle = getGlobalLLMHandle(); + if (!handle) { + return false; + } + rac_llm_component_cleanup(handle); + // Reset global handle since model is unloaded + { + std::lock_guard lock(g_llm_mutex); + g_llm_component_handle = nullptr; + } + return true; + }); +} + +std::shared_ptr> HybridRunAnywhereCore::generate( + const std::string& prompt, + const std::optional& optionsJson) { + return Promise::async([this, prompt, optionsJson]() -> std::string { + LOGI("Generating text..."); + + rac_handle_t handle = getGlobalLLMHandle(); + if (!handle) { + throw std::runtime_error("LLM component not available. Is an LLM backend registered?"); + } + + if (rac_llm_component_is_loaded(handle) != RAC_TRUE) { + throw std::runtime_error("No LLM model loaded. Call loadTextModel first."); + } + + // Parse options + int maxTokens = 256; + float temperature = 0.7f; + if (optionsJson.has_value()) { + maxTokens = extractIntValue(optionsJson.value(), "max_tokens", 256); + temperature = static_cast(extractDoubleValue(optionsJson.value(), "temperature", 0.7)); + } + + rac_llm_options_t options = {}; + options.max_tokens = maxTokens; + options.temperature = temperature; + options.top_p = 0.9f; + + rac_llm_result_t llmResult = {}; + rac_result_t result = rac_llm_component_generate(handle, prompt.c_str(), &options, &llmResult); + + if (result != RAC_SUCCESS) { + throw std::runtime_error("Text generation failed: " + std::to_string(result)); + } + + std::string text = llmResult.text ? llmResult.text : ""; + int tokensUsed = llmResult.completion_tokens; + + return buildJsonObject({ + {"text", jsonString(text)}, + {"tokensUsed", std::to_string(tokensUsed)}, + {"modelUsed", jsonString("llm")}, + {"latencyMs", std::to_string(llmResult.total_time_ms)} + }); + }); +} + +// Streaming context for LLM callbacks +struct LLMStreamContext { + std::function callback; + std::string accumulatedText; + int tokenCount = 0; + bool hasError = false; + std::string errorMessage; + rac_llm_result_t finalResult = {}; +}; + +// Token callback for streaming +static rac_bool_t llmStreamTokenCallback(const char* token, void* userData) { + auto* ctx = static_cast(userData); + if (!ctx || !token) return RAC_FALSE; + + std::string tokenStr(token); + ctx->accumulatedText += tokenStr; + ctx->tokenCount++; + + // Call the JS callback with partial text (not final) + if (ctx->callback) { + ctx->callback(tokenStr, false); + } + + return RAC_TRUE; // Continue streaming +} + +// Complete callback for streaming +static void llmStreamCompleteCallback(const rac_llm_result_t* result, void* userData) { + auto* ctx = static_cast(userData); + if (!ctx) return; + + if (result) { + ctx->finalResult = *result; + } + + // Call callback with final signal + if (ctx->callback) { + ctx->callback("", true); + } +} + +// Error callback for streaming +static void llmStreamErrorCallback(rac_result_t errorCode, const char* errorMessage, void* userData) { + auto* ctx = static_cast(userData); + if (!ctx) return; + + ctx->hasError = true; + ctx->errorMessage = errorMessage ? std::string(errorMessage) : "Unknown streaming error"; + LOGE("LLM streaming error: %d - %s", errorCode, ctx->errorMessage.c_str()); +} + +std::shared_ptr> HybridRunAnywhereCore::generateStream( + const std::string& prompt, + const std::string& optionsJson, + const std::function& callback) { + return Promise::async([this, prompt, optionsJson, callback]() -> std::string { + LOGI("Streaming text generation..."); + + rac_handle_t handle = getGlobalLLMHandle(); + if (!handle) { + throw std::runtime_error("LLM component not available. Is an LLM backend registered?"); + } + + if (rac_llm_component_is_loaded(handle) != RAC_TRUE) { + throw std::runtime_error("No LLM model loaded. Call loadTextModel first."); + } + + // Parse options + rac_llm_options_t options = {}; + options.max_tokens = extractIntValue(optionsJson, "max_tokens", 256); + options.temperature = static_cast(extractDoubleValue(optionsJson, "temperature", 0.7)); + options.top_p = 0.9f; + + // Create streaming context + LLMStreamContext ctx; + ctx.callback = callback; + + // Use proper streaming API + rac_result_t result = rac_llm_component_generate_stream( + handle, + prompt.c_str(), + &options, + llmStreamTokenCallback, + llmStreamCompleteCallback, + llmStreamErrorCallback, + &ctx + ); + + if (result != RAC_SUCCESS) { + throw std::runtime_error("Streaming generation failed: " + std::to_string(result)); + } + + if (ctx.hasError) { + throw std::runtime_error("Streaming error: " + ctx.errorMessage); + } + + LOGI("Streaming complete: %zu chars, %d tokens", ctx.accumulatedText.size(), ctx.tokenCount); + + return buildJsonObject({ + {"text", jsonString(ctx.accumulatedText)}, + {"tokensUsed", std::to_string(ctx.tokenCount)} + }); + }); +} + +std::shared_ptr> HybridRunAnywhereCore::cancelGeneration() { + return Promise::async([]() -> bool { + rac_handle_t handle = getGlobalLLMHandle(); + if (!handle) { + return false; + } + rac_llm_component_cancel(handle); + return true; + }); +} + +std::shared_ptr> HybridRunAnywhereCore::generateStructured( + const std::string& prompt, + const std::string& schema, + const std::optional& optionsJson) { + return Promise::async([this, prompt, schema, optionsJson]() -> std::string { + LOGI("Generating structured output..."); + + rac_handle_t handle = getGlobalLLMHandle(); + if (!handle) { + throw std::runtime_error("LLM component not available. Is an LLM backend registered?"); + } + + if (rac_llm_component_is_loaded(handle) != RAC_TRUE) { + throw std::runtime_error("No LLM model loaded. Call loadTextModel first."); + } + + // Prepare the prompt with the schema embedded + rac_structured_output_config_t config = RAC_STRUCTURED_OUTPUT_DEFAULT; + config.json_schema = schema.c_str(); + config.include_schema_in_prompt = RAC_TRUE; + + char* preparedPrompt = nullptr; + rac_result_t prepResult = rac_structured_output_prepare_prompt(prompt.c_str(), &config, &preparedPrompt); + if (prepResult != RAC_SUCCESS || !preparedPrompt) { + throw std::runtime_error("Failed to prepare structured output prompt"); + } + + // Generate with the prepared prompt + rac_llm_options_t options = {}; + if (optionsJson.has_value()) { + options.max_tokens = extractIntValue(optionsJson.value(), "max_tokens", 512); + options.temperature = static_cast(extractDoubleValue(optionsJson.value(), "temperature", 0.7)); + } else { + options.max_tokens = 512; + options.temperature = 0.7f; + } + + rac_llm_result_t llmResult = {}; + rac_result_t result = rac_llm_component_generate(handle, preparedPrompt, &options, &llmResult); + + free(preparedPrompt); + + if (result != RAC_SUCCESS) { + throw std::runtime_error("Text generation failed: " + std::to_string(result)); + } + + std::string generatedText; + if (llmResult.text) { + generatedText = std::string(llmResult.text); + } + rac_llm_result_free(&llmResult); + + // Extract JSON from the generated text + char* extractedJson = nullptr; + rac_result_t extractResult = rac_structured_output_extract_json(generatedText.c_str(), &extractedJson, nullptr); + + if (extractResult == RAC_SUCCESS && extractedJson) { + std::string jsonOutput = std::string(extractedJson); + free(extractedJson); + LOGI("Extracted structured JSON: %s", jsonOutput.substr(0, 100).c_str()); + return jsonOutput; + } + + // If extraction failed, return the raw text (let the caller handle it) + LOGI("Could not extract JSON, returning raw: %s", generatedText.substr(0, 100).c_str()); + return generatedText; + }); +} + +// ============================================================================ +// STT Capability (Backend-Agnostic) +// Calls rac_stt_component_* APIs - works with any registered backend +// Uses a global STT component handle shared across HybridRunAnywhereCore instances +// ============================================================================ + +// Global STT component handle - shared across all instances +// This ensures model loading state persists even when HybridRunAnywhereCore instances are recreated +static rac_handle_t g_stt_component_handle = nullptr; +static std::mutex g_stt_mutex; + +static rac_handle_t getGlobalSTTHandle() { + std::lock_guard lock(g_stt_mutex); + if (g_stt_component_handle == nullptr) { + rac_result_t result = rac_stt_component_create(&g_stt_component_handle); + if (result != RAC_SUCCESS) { + g_stt_component_handle = nullptr; + } + } + return g_stt_component_handle; +} + +std::shared_ptr> HybridRunAnywhereCore::loadSTTModel( + const std::string& modelPath, + const std::string& modelType, + const std::optional& configJson) { + return Promise::async([this, modelPath, modelType]() -> bool { + LOGI("Loading STT model: %s", modelPath.c_str()); + + rac_handle_t handle = getGlobalSTTHandle(); + if (!handle) { + setLastError("Failed to create STT component. Is an STT backend registered?"); + throw std::runtime_error("STT backend not registered. Install @runanywhere/onnx."); + } + + rac_result_t result = rac_stt_component_load_model(handle, modelPath.c_str(), modelPath.c_str(), modelType.c_str()); + if (result != RAC_SUCCESS) { + throw std::runtime_error("Failed to load STT model: " + std::to_string(result)); + } + + LOGI("STT model loaded successfully"); + return true; + }); +} + +std::shared_ptr> HybridRunAnywhereCore::isSTTModelLoaded() { + return Promise::async([]() -> bool { + rac_handle_t handle = getGlobalSTTHandle(); + if (!handle) { + return false; + } + bool isLoaded = rac_stt_component_is_loaded(handle) == RAC_TRUE; + LOGD("isSTTModelLoaded: handle=%p, isLoaded=%s", handle, isLoaded ? "true" : "false"); + return isLoaded; + }); +} + +std::shared_ptr> HybridRunAnywhereCore::unloadSTTModel() { + return Promise::async([]() -> bool { + rac_handle_t handle = getGlobalSTTHandle(); + if (!handle) { + return false; + } + rac_stt_component_cleanup(handle); + // Reset global handle since model is unloaded + { + std::lock_guard lock(g_stt_mutex); + g_stt_component_handle = nullptr; + } + return true; + }); +} + +std::shared_ptr> HybridRunAnywhereCore::transcribe( + const std::string& audioBase64, + double sampleRate, + const std::optional& language) { + return Promise::async([this, audioBase64, sampleRate, language]() -> std::string { + LOGI("Transcribing audio (base64)..."); + + rac_handle_t handle = getGlobalSTTHandle(); + if (!handle) { + throw std::runtime_error("STT component not available. Is an STT backend registered?"); + } + + if (rac_stt_component_is_loaded(handle) != RAC_TRUE) { + throw std::runtime_error("No STT model loaded. Call loadSTTModel first."); + } + + // Decode base64 audio data + std::vector audioData = base64Decode(audioBase64); + if (audioData.empty()) { + throw std::runtime_error("Failed to decode base64 audio data"); + } + + LOGI("Decoded %zu bytes of audio data", audioData.size()); + + // Set up transcription options + rac_stt_options_t options = RAC_STT_OPTIONS_DEFAULT; + options.sample_rate = static_cast(sampleRate > 0 ? sampleRate : 16000); + options.audio_format = RAC_AUDIO_FORMAT_PCM; + if (language.has_value() && !language->empty()) { + options.language = language->c_str(); + } + + // Transcribe + rac_stt_result_t result = {}; + rac_result_t status = rac_stt_component_transcribe( + handle, + audioData.data(), + audioData.size(), + &options, + &result + ); + + if (status != RAC_SUCCESS) { + throw std::runtime_error("Transcription failed with error code: " + std::to_string(status)); + } + + std::string transcribedText; + if (result.text) { + transcribedText = std::string(result.text); + } + + // Free the result + rac_stt_result_free(&result); + + LOGI("Transcription result: %s", transcribedText.c_str()); + return transcribedText; + }); +} + +std::shared_ptr> HybridRunAnywhereCore::transcribeFile( + const std::string& filePath, + const std::optional& language) { + return Promise::async([this, filePath, language]() -> std::string { + LOGI("Transcribing file: %s", filePath.c_str()); + + rac_handle_t handle = getGlobalSTTHandle(); + if (!handle) { + throw std::runtime_error("STT component not available. Is an STT backend registered?"); + } + + if (rac_stt_component_is_loaded(handle) != RAC_TRUE) { + throw std::runtime_error("No STT model loaded. Call loadSTTModel first."); + } + + // Open the file + FILE* file = fopen(filePath.c_str(), "rb"); + if (!file) { + throw std::runtime_error("Failed to open audio file: " + filePath); + } + + // Get file size + fseek(file, 0, SEEK_END); + long fileSize = ftell(file); + fseek(file, 0, SEEK_SET); + + if (fileSize <= 0) { + fclose(file); + throw std::runtime_error("Audio file is empty: " + filePath); + } + + LOGI("File size: %ld bytes", fileSize); + + // Read the entire file into memory + std::vector fileData(fileSize); + size_t bytesRead = fread(fileData.data(), 1, fileSize, file); + fclose(file); + + if (bytesRead != static_cast(fileSize)) { + throw std::runtime_error("Failed to read audio file completely"); + } + + // Parse WAV header to extract audio data + // WAV header: RIFF chunk (12 bytes) + fmt chunk + data chunk + // We need to find the "data" chunk and extract PCM audio + + const uint8_t* data = fileData.data(); + size_t dataSize = fileData.size(); + int32_t sampleRate = 16000; + + // Check RIFF header + if (dataSize < 44) { + throw std::runtime_error("File too small to be a valid WAV file"); + } + + // Check "RIFF" signature + if (data[0] != 'R' || data[1] != 'I' || data[2] != 'F' || data[3] != 'F') { + throw std::runtime_error("Invalid WAV file: missing RIFF header"); + } + + // Check "WAVE" format + if (data[8] != 'W' || data[9] != 'A' || data[10] != 'V' || data[11] != 'E') { + throw std::runtime_error("Invalid WAV file: missing WAVE format"); + } + + // Find "fmt " and "data" chunks + size_t pos = 12; + size_t audioDataOffset = 0; + size_t audioDataSize = 0; + + while (pos + 8 < dataSize) { + char chunkId[5] = {0}; + memcpy(chunkId, &data[pos], 4); + uint32_t chunkSize = *reinterpret_cast(&data[pos + 4]); + + if (strcmp(chunkId, "fmt ") == 0) { + // Parse fmt chunk + if (pos + 8 + chunkSize <= dataSize && chunkSize >= 16) { + // Bytes 12-13: Audio format (1 = PCM) + // Bytes 14-15: Number of channels + // Bytes 16-19: Sample rate + sampleRate = *reinterpret_cast(&data[pos + 12]); + LOGI("WAV sample rate: %d Hz", sampleRate); + } + } else if (strcmp(chunkId, "data") == 0) { + // Found data chunk + audioDataOffset = pos + 8; + audioDataSize = chunkSize; + LOGI("Found audio data: offset=%zu, size=%zu", audioDataOffset, audioDataSize); + break; + } + + pos += 8 + chunkSize; + // Align to 2-byte boundary + if (chunkSize % 2 != 0) pos++; + } + + if (audioDataSize == 0 || audioDataOffset + audioDataSize > dataSize) { + throw std::runtime_error("Could not find valid audio data in WAV file"); + } + + // Set up transcription options + rac_stt_options_t options = RAC_STT_OPTIONS_DEFAULT; + options.sample_rate = sampleRate; + options.audio_format = RAC_AUDIO_FORMAT_WAV; // Tell the backend it's WAV format + if (language.has_value() && !language->empty()) { + options.language = language->c_str(); + } + + LOGI("Transcribing %zu bytes of audio at %d Hz", audioDataSize, sampleRate); + + // Transcribe - pass the raw PCM data (after WAV header) + rac_stt_result_t result = {}; + rac_result_t status = rac_stt_component_transcribe( + handle, + &data[audioDataOffset], + audioDataSize, + &options, + &result + ); + + if (status != RAC_SUCCESS) { + throw std::runtime_error("Transcription failed with error code: " + std::to_string(status)); + } + + std::string transcribedText; + if (result.text) { + transcribedText = std::string(result.text); + } + + // Free the result + rac_stt_result_free(&result); + + LOGI("Transcription result: %s", transcribedText.c_str()); + return transcribedText; + }); +} + +// ============================================================================ +// TTS Capability (Backend-Agnostic) +// Calls rac_tts_component_* APIs - works with any registered backend +// Uses a global TTS component handle shared across HybridRunAnywhereCore instances +// ============================================================================ + +// Global TTS component handle - shared across all instances +static rac_handle_t g_tts_component_handle = nullptr; +static std::mutex g_tts_mutex; + +static rac_handle_t getGlobalTTSHandle() { + std::lock_guard lock(g_tts_mutex); + if (g_tts_component_handle == nullptr) { + rac_result_t result = rac_tts_component_create(&g_tts_component_handle); + if (result != RAC_SUCCESS) { + g_tts_component_handle = nullptr; + } + } + return g_tts_component_handle; +} + +std::shared_ptr> HybridRunAnywhereCore::loadTTSModel( + const std::string& modelPath, + const std::string& modelType, + const std::optional& configJson) { + return Promise::async([this, modelPath, modelType]() -> bool { + LOGI("Loading TTS model: %s", modelPath.c_str()); + + rac_handle_t handle = getGlobalTTSHandle(); + if (!handle) { + setLastError("Failed to create TTS component. Is a TTS backend registered?"); + throw std::runtime_error("TTS backend not registered. Install @runanywhere/onnx."); + } + + // Configure the TTS component first + rac_tts_config_t config = RAC_TTS_CONFIG_DEFAULT; + config.model_id = modelPath.c_str(); + rac_result_t result = rac_tts_component_configure(handle, &config); + if (result != RAC_SUCCESS) { + LOGE("TTS configure failed: %d", result); + throw std::runtime_error("Failed to configure TTS: " + std::to_string(result)); + } + + // Extract model ID from path for telemetry + std::string voiceId = modelPath; + size_t lastSlash = voiceId.find_last_of('/'); + if (lastSlash != std::string::npos) { + voiceId = voiceId.substr(lastSlash + 1); + } + + // Load the voice - this is what actually loads the model files + result = rac_tts_component_load_voice(handle, modelPath.c_str(), voiceId.c_str(), modelType.c_str()); + if (result != RAC_SUCCESS) { + LOGE("TTS load_voice failed: %d", result); + throw std::runtime_error("Failed to load TTS voice: " + std::to_string(result)); + } + + // Verify loading + bool isLoaded = rac_tts_component_is_loaded(handle) == RAC_TRUE; + LOGI("TTS model loaded successfully, isLoaded=%s", isLoaded ? "true" : "false"); + + return isLoaded; + }); +} + +std::shared_ptr> HybridRunAnywhereCore::isTTSModelLoaded() { + return Promise::async([]() -> bool { + rac_handle_t handle = getGlobalTTSHandle(); + if (!handle) { + return false; + } + bool isLoaded = rac_tts_component_is_loaded(handle) == RAC_TRUE; + LOGD("isTTSModelLoaded: handle=%p, isLoaded=%s", handle, isLoaded ? "true" : "false"); + return isLoaded; + }); +} + +std::shared_ptr> HybridRunAnywhereCore::unloadTTSModel() { + return Promise::async([]() -> bool { + rac_handle_t handle = getGlobalTTSHandle(); + if (!handle) { + return false; + } + rac_tts_component_cleanup(handle); + // Reset global handle since model is unloaded + { + std::lock_guard lock(g_tts_mutex); + g_tts_component_handle = nullptr; + } + return true; + }); +} + +std::shared_ptr> HybridRunAnywhereCore::synthesize( + const std::string& text, + const std::string& voiceId, + double speedRate, + double pitchShift) { + return Promise::async([this, text, voiceId, speedRate, pitchShift]() -> std::string { + LOGI("Synthesizing speech: %s", text.substr(0, 50).c_str()); + + rac_handle_t handle = getGlobalTTSHandle(); + if (!handle) { + throw std::runtime_error("TTS component not available. Is a TTS backend registered?"); + } + + if (rac_tts_component_is_loaded(handle) != RAC_TRUE) { + throw std::runtime_error("No TTS model loaded. Call loadTTSModel first."); + } + + // Set up synthesis options + rac_tts_options_t options = RAC_TTS_OPTIONS_DEFAULT; + if (!voiceId.empty()) { + options.voice = voiceId.c_str(); + } + options.rate = static_cast(speedRate > 0 ? speedRate : 1.0); + options.pitch = static_cast(pitchShift > 0 ? pitchShift : 1.0); + + // Synthesize + rac_tts_result_t result = {}; + rac_result_t status = rac_tts_component_synthesize(handle, text.c_str(), &options, &result); + + if (status != RAC_SUCCESS) { + throw std::runtime_error("TTS synthesis failed with error code: " + std::to_string(status)); + } + + if (!result.audio_data || result.audio_size == 0) { + rac_tts_result_free(&result); + throw std::runtime_error("TTS synthesis returned no audio data"); + } + + LOGI("TTS synthesis complete: %zu bytes, %d Hz, %lld ms", + result.audio_size, result.sample_rate, result.duration_ms); + + // Convert audio data to base64 + std::string audioBase64 = base64Encode( + static_cast(result.audio_data), + result.audio_size + ); + + // Build JSON result with metadata + std::ostringstream json; + json << "{"; + json << "\"audioBase64\":\"" << audioBase64 << "\","; + json << "\"sampleRate\":" << result.sample_rate << ","; + json << "\"durationMs\":" << result.duration_ms << ","; + json << "\"audioSize\":" << result.audio_size; + json << "}"; + + rac_tts_result_free(&result); + + return json.str(); + }); +} + +std::shared_ptr> HybridRunAnywhereCore::getTTSVoices() { + return Promise::async([]() -> std::string { + return "[]"; // Return empty array for now + }); +} + +std::shared_ptr> HybridRunAnywhereCore::cancelTTS() { + return Promise::async([]() -> bool { + return true; + }); +} + +// ============================================================================ +// VAD Capability (Backend-Agnostic) +// Calls rac_vad_component_* APIs - works with any registered backend +// Uses a global VAD component handle shared across HybridRunAnywhereCore instances +// ============================================================================ + +// Global VAD component handle - shared across all instances +static rac_handle_t g_vad_component_handle = nullptr; +static std::mutex g_vad_mutex; + +static rac_handle_t getGlobalVADHandle() { + std::lock_guard lock(g_vad_mutex); + if (g_vad_component_handle == nullptr) { + rac_result_t result = rac_vad_component_create(&g_vad_component_handle); + if (result != RAC_SUCCESS) { + g_vad_component_handle = nullptr; + } + } + return g_vad_component_handle; +} + +std::shared_ptr> HybridRunAnywhereCore::loadVADModel( + const std::string& modelPath, + const std::optional& configJson) { + return Promise::async([this, modelPath]() -> bool { + LOGI("Loading VAD model: %s", modelPath.c_str()); + + rac_handle_t handle = getGlobalVADHandle(); + if (!handle) { + setLastError("Failed to create VAD component. Is a VAD backend registered?"); + throw std::runtime_error("VAD backend not registered. Install @runanywhere/onnx."); + } + + rac_vad_config_t config = RAC_VAD_CONFIG_DEFAULT; + config.model_id = modelPath.c_str(); + rac_result_t result = rac_vad_component_configure(handle, &config); + if (result != RAC_SUCCESS) { + throw std::runtime_error("Failed to configure VAD: " + std::to_string(result)); + } + + result = rac_vad_component_initialize(handle); + if (result != RAC_SUCCESS) { + throw std::runtime_error("Failed to initialize VAD: " + std::to_string(result)); + } + + LOGI("VAD model loaded successfully"); + return true; + }); +} + +std::shared_ptr> HybridRunAnywhereCore::isVADModelLoaded() { + return Promise::async([]() -> bool { + rac_handle_t handle = getGlobalVADHandle(); + if (!handle) { + return false; + } + bool isLoaded = rac_vad_component_is_initialized(handle) == RAC_TRUE; + LOGD("isVADModelLoaded: handle=%p, isLoaded=%s", handle, isLoaded ? "true" : "false"); + return isLoaded; + }); +} + +std::shared_ptr> HybridRunAnywhereCore::unloadVADModel() { + return Promise::async([]() -> bool { + rac_handle_t handle = getGlobalVADHandle(); + if (!handle) { + return false; + } + rac_vad_component_cleanup(handle); + // Reset global handle since model is unloaded + { + std::lock_guard lock(g_vad_mutex); + g_vad_component_handle = nullptr; + } + return true; + }); +} + +std::shared_ptr> HybridRunAnywhereCore::processVAD( + const std::string& audioBase64, + const std::optional& optionsJson) { + return Promise::async([this, audioBase64, optionsJson]() -> std::string { + LOGI("Processing VAD..."); + + rac_handle_t handle = getGlobalVADHandle(); + if (!handle) { + throw std::runtime_error("VAD component not available. Is a VAD backend registered?"); + } + + // Decode base64 audio data + std::vector audioData = base64Decode(audioBase64); + if (audioData.empty()) { + throw std::runtime_error("Failed to decode base64 audio data for VAD"); + } + + // Convert byte data to float samples + // Assuming 16-bit PCM audio: 2 bytes per sample + size_t numSamples = audioData.size() / sizeof(int16_t); + std::vector floatSamples(numSamples); + + const int16_t* pcmData = reinterpret_cast(audioData.data()); + for (size_t i = 0; i < numSamples; i++) { + floatSamples[i] = static_cast(pcmData[i]) / 32768.0f; + } + + LOGI("VAD processing %zu samples", numSamples); + + // Process with VAD + rac_bool_t isSpeech = RAC_FALSE; + rac_result_t status = rac_vad_component_process( + handle, + floatSamples.data(), + numSamples, + &isSpeech + ); + + if (status != RAC_SUCCESS) { + throw std::runtime_error("VAD processing failed with error code: " + std::to_string(status)); + } + + // Return JSON result + std::ostringstream json; + json << "{"; + json << "\"isSpeech\":" << (isSpeech == RAC_TRUE ? "true" : "false") << ","; + json << "\"samplesProcessed\":" << numSamples; + json << "}"; + + return json.str(); + }); +} + +std::shared_ptr> HybridRunAnywhereCore::resetVAD() { + return Promise::async([]() -> void { + rac_handle_t handle = getGlobalVADHandle(); + if (handle) { + rac_vad_component_reset(handle); + } + }); +} + +// ============================================================================ +// Voice Agent Capability (Backend-Agnostic) +// Calls rac_voice_agent_* APIs - requires STT, LLM, TTS, and VAD backends +// Uses a global voice agent handle that composes the global component handles +// Mirrors Swift SDK's CppBridge.VoiceAgent.shared architecture +// ============================================================================ + +// Global Voice Agent handle - composes the global STT, LLM, TTS, VAD handles +static rac_voice_agent_handle_t g_voice_agent_handle = nullptr; +static std::mutex g_voice_agent_mutex; + +static rac_voice_agent_handle_t getGlobalVoiceAgentHandle() { + std::lock_guard lock(g_voice_agent_mutex); + if (g_voice_agent_handle == nullptr) { + // Get component handles - required for voice agent + rac_handle_t llmHandle = getGlobalLLMHandle(); + rac_handle_t sttHandle = getGlobalSTTHandle(); + rac_handle_t ttsHandle = getGlobalTTSHandle(); + rac_handle_t vadHandle = getGlobalVADHandle(); + + if (!llmHandle || !sttHandle || !ttsHandle || !vadHandle) { + // Cannot create voice agent without all components + return nullptr; + } + + rac_result_t result = rac_voice_agent_create( + llmHandle, sttHandle, ttsHandle, vadHandle, &g_voice_agent_handle); + if (result != RAC_SUCCESS) { + g_voice_agent_handle = nullptr; + } + } + return g_voice_agent_handle; +} + +std::shared_ptr> HybridRunAnywhereCore::initializeVoiceAgent( + const std::string& configJson) { + return Promise::async([this, configJson]() -> bool { + LOGI("Initializing voice agent..."); + + rac_voice_agent_handle_t handle = getGlobalVoiceAgentHandle(); + if (!handle) { + throw std::runtime_error("Voice agent requires STT, LLM, TTS, and VAD backends. " + "Install @runanywhere/llamacpp and @runanywhere/onnx."); + } + + // Initialize with default config (or parse configJson if needed) + rac_result_t result = rac_voice_agent_initialize(handle, nullptr); + if (result != RAC_SUCCESS) { + throw std::runtime_error("Failed to initialize voice agent: " + std::to_string(result)); + } + + LOGI("Voice agent initialized"); + return true; + }); +} + +std::shared_ptr> HybridRunAnywhereCore::initializeVoiceAgentWithLoadedModels() { + return Promise::async([this]() -> bool { + LOGI("Initializing voice agent with loaded models..."); + + rac_voice_agent_handle_t handle = getGlobalVoiceAgentHandle(); + if (!handle) { + throw std::runtime_error("Voice agent requires STT, LLM, TTS, and VAD backends. " + "Install @runanywhere/llamacpp and @runanywhere/onnx."); + } + + // Initialize using already-loaded models + rac_result_t result = rac_voice_agent_initialize_with_loaded_models(handle); + if (result != RAC_SUCCESS) { + throw std::runtime_error("Voice agent requires all models to be loaded. Error: " + std::to_string(result)); + } + + LOGI("Voice agent initialized with loaded models"); + return true; + }); +} + +std::shared_ptr> HybridRunAnywhereCore::isVoiceAgentReady() { + return Promise::async([]() -> bool { + rac_voice_agent_handle_t handle = getGlobalVoiceAgentHandle(); + if (!handle) { + return false; + } + + rac_bool_t isReady = RAC_FALSE; + rac_result_t result = rac_voice_agent_is_ready(handle, &isReady); + if (result != RAC_SUCCESS) { + return false; + } + + LOGD("isVoiceAgentReady: %s", isReady == RAC_TRUE ? "true" : "false"); + return isReady == RAC_TRUE; + }); +} + +std::shared_ptr> HybridRunAnywhereCore::getVoiceAgentComponentStates() { + return Promise::async([]() -> std::string { + rac_voice_agent_handle_t handle = getGlobalVoiceAgentHandle(); + + // Get component loaded states + rac_bool_t sttLoaded = RAC_FALSE; + rac_bool_t llmLoaded = RAC_FALSE; + rac_bool_t ttsLoaded = RAC_FALSE; + + if (handle) { + rac_voice_agent_is_stt_loaded(handle, &sttLoaded); + rac_voice_agent_is_llm_loaded(handle, &llmLoaded); + rac_voice_agent_is_tts_loaded(handle, &ttsLoaded); + } + + // Get model IDs if loaded + const char* sttModelId = handle ? rac_voice_agent_get_stt_model_id(handle) : nullptr; + const char* llmModelId = handle ? rac_voice_agent_get_llm_model_id(handle) : nullptr; + const char* ttsVoiceId = handle ? rac_voice_agent_get_tts_voice_id(handle) : nullptr; + + return buildJsonObject({ + {"stt", buildJsonObject({ + {"available", handle ? "true" : "false"}, + {"loaded", sttLoaded == RAC_TRUE ? "true" : "false"}, + {"modelId", sttModelId ? jsonString(sttModelId) : "null"} + })}, + {"llm", buildJsonObject({ + {"available", handle ? "true" : "false"}, + {"loaded", llmLoaded == RAC_TRUE ? "true" : "false"}, + {"modelId", llmModelId ? jsonString(llmModelId) : "null"} + })}, + {"tts", buildJsonObject({ + {"available", handle ? "true" : "false"}, + {"loaded", ttsLoaded == RAC_TRUE ? "true" : "false"}, + {"voiceId", ttsVoiceId ? jsonString(ttsVoiceId) : "null"} + })} + }); + }); +} + +std::shared_ptr> HybridRunAnywhereCore::processVoiceTurn( + const std::string& audioBase64) { + return Promise::async([this, audioBase64]() -> std::string { + LOGI("Processing voice turn..."); + + rac_voice_agent_handle_t handle = getGlobalVoiceAgentHandle(); + if (!handle) { + throw std::runtime_error("Voice agent not available"); + } + + // Decode base64 audio + std::vector audioData = base64Decode(audioBase64); + if (audioData.empty()) { + throw std::runtime_error("Failed to decode audio data"); + } + + rac_voice_agent_result_t result = {}; + rac_result_t status = rac_voice_agent_process_voice_turn( + handle, audioData.data(), audioData.size(), &result); + + if (status != RAC_SUCCESS) { + throw std::runtime_error("Voice turn processing failed: " + std::to_string(status)); + } + + // Build result JSON + std::string responseJson = buildJsonObject({ + {"speechDetected", result.speech_detected == RAC_TRUE ? "true" : "false"}, + {"transcription", result.transcription ? jsonString(result.transcription) : "\"\""}, + {"response", result.response ? jsonString(result.response) : "\"\""}, + {"audioSize", std::to_string(result.synthesized_audio_size)} + }); + + // Free result resources + rac_voice_agent_result_free(&result); + + LOGI("Voice turn completed"); + return responseJson; + }); +} + +std::shared_ptr> HybridRunAnywhereCore::voiceAgentTranscribe( + const std::string& audioBase64) { + return Promise::async([this, audioBase64]() -> std::string { + LOGI("Voice agent transcribing..."); + + rac_voice_agent_handle_t handle = getGlobalVoiceAgentHandle(); + if (!handle) { + throw std::runtime_error("Voice agent not available"); + } + + // Decode base64 audio + std::vector audioData = base64Decode(audioBase64); + if (audioData.empty()) { + throw std::runtime_error("Failed to decode audio data"); + } + + char* transcription = nullptr; + rac_result_t status = rac_voice_agent_transcribe( + handle, audioData.data(), audioData.size(), &transcription); + + if (status != RAC_SUCCESS) { + throw std::runtime_error("Transcription failed: " + std::to_string(status)); + } + + std::string result = transcription ? transcription : ""; + if (transcription) { + free(transcription); + } + + return result; + }); +} + +std::shared_ptr> HybridRunAnywhereCore::voiceAgentGenerateResponse( + const std::string& prompt) { + return Promise::async([this, prompt]() -> std::string { + LOGI("Voice agent generating response..."); + + rac_voice_agent_handle_t handle = getGlobalVoiceAgentHandle(); + if (!handle) { + throw std::runtime_error("Voice agent not available"); + } + + char* response = nullptr; + rac_result_t status = rac_voice_agent_generate_response(handle, prompt.c_str(), &response); + + if (status != RAC_SUCCESS) { + throw std::runtime_error("Response generation failed: " + std::to_string(status)); + } + + std::string result = response ? response : ""; + if (response) { + free(response); + } + + return result; + }); +} + +std::shared_ptr> HybridRunAnywhereCore::voiceAgentSynthesizeSpeech( + const std::string& text) { + return Promise::async([this, text]() -> std::string { + LOGI("Voice agent synthesizing speech..."); + + rac_voice_agent_handle_t handle = getGlobalVoiceAgentHandle(); + if (!handle) { + throw std::runtime_error("Voice agent not available"); + } + + void* audioData = nullptr; + size_t audioSize = 0; + rac_result_t status = rac_voice_agent_synthesize_speech( + handle, text.c_str(), &audioData, &audioSize); + + if (status != RAC_SUCCESS) { + throw std::runtime_error("Speech synthesis failed: " + std::to_string(status)); + } + + // Encode audio to base64 + std::string audioBase64 = base64Encode(static_cast(audioData), audioSize); + + if (audioData) { + free(audioData); + } + + return audioBase64; + }); +} + +std::shared_ptr> HybridRunAnywhereCore::cleanupVoiceAgent() { + return Promise::async([]() -> void { + LOGI("Cleaning up voice agent..."); + + rac_voice_agent_handle_t handle = getGlobalVoiceAgentHandle(); + if (handle) { + rac_voice_agent_cleanup(handle); + } + + // Note: We don't destroy the voice agent handle here - it's reusable + // The models can be unloaded separately via unloadSTTModel, etc. + }); +} + +// ============================================================================ +// Secure Storage Methods +// Matches Swift: KeychainManager.swift +// ============================================================================ + +std::shared_ptr> HybridRunAnywhereCore::secureStorageSet( + const std::string& key, + const std::string& value) { + return Promise::async([key, value]() -> bool { + LOGI("Secure storage set: key=%s", key.c_str()); + + bool success = InitBridge::shared().secureSet(key, value); + if (!success) { + LOGE("Failed to store value for key: %s", key.c_str()); + } + return success; + }); +} + +std::shared_ptr>> HybridRunAnywhereCore::secureStorageGet( + const std::string& key) { + return Promise>::async([key]() -> std::variant { + LOGI("Secure storage get: key=%s", key.c_str()); + + std::string value; + if (InitBridge::shared().secureGet(key, value)) { + return value; + } + return nitro::NullType(); + }); +} + +std::shared_ptr> HybridRunAnywhereCore::secureStorageDelete( + const std::string& key) { + return Promise::async([key]() -> bool { + LOGI("Secure storage delete: key=%s", key.c_str()); + + bool success = InitBridge::shared().secureDelete(key); + if (!success) { + LOGE("Failed to delete key: %s", key.c_str()); + } + return success; + }); +} + +std::shared_ptr> HybridRunAnywhereCore::secureStorageExists( + const std::string& key) { + return Promise::async([key]() -> bool { + LOGD("Secure storage exists: key=%s", key.c_str()); + return InitBridge::shared().secureExists(key); + }); +} + +std::shared_ptr> HybridRunAnywhereCore::getPersistentDeviceUUID() { + return Promise::async([]() -> std::string { + LOGI("Getting persistent device UUID..."); + + std::string uuid = InitBridge::shared().getPersistentDeviceUUID(); + + if (uuid.empty()) { + throw std::runtime_error("Failed to get or generate device UUID"); + } + + LOGI("Persistent device UUID: %s", uuid.c_str()); + return uuid; + }); +} + +// ============================================================================ +// Telemetry +// Matches Swift: CppBridge+Telemetry.swift +// C++ handles all telemetry logic - batching, JSON building, routing +// ============================================================================ + +std::shared_ptr> HybridRunAnywhereCore::flushTelemetry() { + return Promise::async([]() -> void { + LOGI("Flushing telemetry events..."); + TelemetryBridge::shared().flush(); + LOGI("Telemetry flushed"); + }); +} + +std::shared_ptr> HybridRunAnywhereCore::isTelemetryInitialized() { + return Promise::async([]() -> bool { + return TelemetryBridge::shared().isInitialized(); + }); +} + +} // namespace margelo::nitro::runanywhere diff --git a/sdk/runanywhere-react-native/packages/core/cpp/HybridRunAnywhereCore.hpp b/sdk/runanywhere-react-native/packages/core/cpp/HybridRunAnywhereCore.hpp new file mode 100644 index 000000000..5291493e1 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/cpp/HybridRunAnywhereCore.hpp @@ -0,0 +1,271 @@ +/** + * HybridRunAnywhereCore.hpp + * + * Nitrogen HybridObject implementation for RunAnywhere Core SDK. + * This single C++ file works on both iOS and Android. + * + * Core SDK implementation - includes: + * - SDK Lifecycle (init, destroy) + * - Authentication + * - Device Registration + * - Model Registry + * - Download Service + * - Storage + * - Events + * - HTTP Client + * - Utilities + * - LLM/STT/TTS/VAD/VoiceAgent (backend-agnostic via rac_*_component_* APIs) + * + * The capability methods (LLM, STT, TTS, VAD) are BACKEND-AGNOSTIC. + * They call the C++ rac_*_component_* APIs which work with any registered backend. + * Apps must install a backend package to register the actual implementation: + * - @runanywhere/llamacpp registers the LLM backend + * - @runanywhere/onnx registers the STT/TTS/VAD backends + * + * The HybridRunAnywhereCoreSpec base class is auto-generated by Nitrogen + * from src/specs/RunAnywhereCore.nitro.ts + */ + +#pragma once + +// Include the generated spec header (created by nitrogen) +#if __has_include() +#include "HybridRunAnywhereCoreSpec.hpp" +#else +// Fallback include path during development +#include "../nitrogen/generated/shared/c++/HybridRunAnywhereCoreSpec.hpp" +#endif + +#include +#include +#include +#include +#include + +namespace margelo::nitro::runanywhere { + +/** + * HybridRunAnywhereCore - Core SDK native implementation + * + * Implements the RunAnywhereCore interface defined in RunAnywhereCore.nitro.ts + * Delegates to modular bridges that call runanywhere-commons rac_* API. + */ +class HybridRunAnywhereCore : public HybridRunAnywhereCoreSpec { +public: + HybridRunAnywhereCore(); + ~HybridRunAnywhereCore(); + + // ============================================================================ + // SDK Lifecycle + // ============================================================================ + + std::shared_ptr> initialize(const std::string& configJson) override; + std::shared_ptr> destroy() override; + std::shared_ptr> isInitialized() override; + std::shared_ptr> getBackendInfo() override; + + // ============================================================================ + // Authentication - Delegates to AuthBridge + // ============================================================================ + + std::shared_ptr> authenticate(const std::string& apiKey) override; + std::shared_ptr> isAuthenticated() override; + std::shared_ptr> getUserId() override; + std::shared_ptr> getOrganizationId() override; + std::shared_ptr> setAuthTokens(const std::string& authResponseJson) override; + + // ============================================================================ + // Device Registration - Delegates to DeviceBridge + // ============================================================================ + + std::shared_ptr> registerDevice(const std::string& environmentJson) override; + std::shared_ptr> isDeviceRegistered() override; + std::shared_ptr> clearDeviceRegistration() override; + std::shared_ptr> getDeviceId() override; + + // ============================================================================ + // Model Registry - Delegates to ModelRegistryBridge + // ============================================================================ + + std::shared_ptr> getAvailableModels() override; + std::shared_ptr> getModelInfo(const std::string& modelId) override; + std::shared_ptr> isModelDownloaded(const std::string& modelId) override; + std::shared_ptr> getModelPath(const std::string& modelId) override; + std::shared_ptr> registerModel(const std::string& modelJson) override; + + // ============================================================================ + // Download Service - Delegates to DownloadBridge + // ============================================================================ + + std::shared_ptr> downloadModel( + const std::string& modelId, + const std::string& url, + const std::string& destPath) override; + std::shared_ptr> cancelDownload(const std::string& modelId) override; + std::shared_ptr> getDownloadProgress(const std::string& modelId) override; + + // ============================================================================ + // Storage - Delegates to StorageBridge + // ============================================================================ + + std::shared_ptr> getStorageInfo() override; + std::shared_ptr> clearCache() override; + std::shared_ptr> deleteModel(const std::string& modelId) override; + + // ============================================================================ + // Events - Delegates to EventBridge + // ============================================================================ + + std::shared_ptr> emitEvent(const std::string& eventJson) override; + std::shared_ptr> pollEvents() override; + + // ============================================================================ + // HTTP Client - Delegates to HTTPBridge + // ============================================================================ + + std::shared_ptr> configureHttp( + const std::string& baseUrl, + const std::string& apiKey) override; + std::shared_ptr> httpPost( + const std::string& path, + const std::string& bodyJson) override; + std::shared_ptr> httpGet(const std::string& path) override; + + // ============================================================================ + // Utility Functions + // ============================================================================ + + std::shared_ptr> getLastError() override; + std::shared_ptr> extractArchive( + const std::string& archivePath, + const std::string& destPath) override; + std::shared_ptr> getDeviceCapabilities() override; + std::shared_ptr> getMemoryUsage() override; + + // ============================================================================ + // LLM Capability (Backend-Agnostic) + // Delegates to rac_llm_component_* APIs via LLMBridge + // ============================================================================ + + std::shared_ptr> loadTextModel( + const std::string& modelPath, + const std::optional& configJson) override; + std::shared_ptr> isTextModelLoaded() override; + std::shared_ptr> unloadTextModel() override; + std::shared_ptr> generate( + const std::string& prompt, + const std::optional& optionsJson) override; + std::shared_ptr> generateStream( + const std::string& prompt, + const std::string& optionsJson, + const std::function& callback) override; + std::shared_ptr> cancelGeneration() override; + std::shared_ptr> generateStructured( + const std::string& prompt, + const std::string& schema, + const std::optional& optionsJson) override; + + // ============================================================================ + // STT Capability (Backend-Agnostic) + // Delegates to rac_stt_component_* APIs via STTBridge + // ============================================================================ + + std::shared_ptr> loadSTTModel( + const std::string& modelPath, + const std::string& modelType, + const std::optional& configJson) override; + std::shared_ptr> isSTTModelLoaded() override; + std::shared_ptr> unloadSTTModel() override; + std::shared_ptr> transcribe( + const std::string& audioBase64, + double sampleRate, + const std::optional& language) override; + std::shared_ptr> transcribeFile( + const std::string& filePath, + const std::optional& language) override; + + // ============================================================================ + // TTS Capability (Backend-Agnostic) + // Delegates to rac_tts_component_* APIs via TTSBridge + // ============================================================================ + + std::shared_ptr> loadTTSModel( + const std::string& modelPath, + const std::string& modelType, + const std::optional& configJson) override; + std::shared_ptr> isTTSModelLoaded() override; + std::shared_ptr> unloadTTSModel() override; + std::shared_ptr> synthesize( + const std::string& text, + const std::string& voiceId, + double speedRate, + double pitchShift) override; + std::shared_ptr> getTTSVoices() override; + std::shared_ptr> cancelTTS() override; + + // ============================================================================ + // VAD Capability (Backend-Agnostic) + // Delegates to rac_vad_component_* APIs via VADBridge + // ============================================================================ + + std::shared_ptr> loadVADModel( + const std::string& modelPath, + const std::optional& configJson) override; + std::shared_ptr> isVADModelLoaded() override; + std::shared_ptr> unloadVADModel() override; + std::shared_ptr> processVAD( + const std::string& audioBase64, + const std::optional& optionsJson) override; + std::shared_ptr> resetVAD() override; + + // ============================================================================ + // Secure Storage + // Matches Swift: KeychainManager.swift + // Uses platform adapter callbacks for Keychain/Keystore + // ============================================================================ + + std::shared_ptr> secureStorageSet( + const std::string& key, + const std::string& value) override; + std::shared_ptr>> secureStorageGet( + const std::string& key) override; + std::shared_ptr> secureStorageDelete(const std::string& key) override; + std::shared_ptr> secureStorageExists(const std::string& key) override; + std::shared_ptr> getPersistentDeviceUUID() override; + + // ============================================================================ + // Telemetry + // Matches Swift: CppBridge+Telemetry.swift + // C++ handles all telemetry logic - batching, JSON building, routing + // ============================================================================ + + std::shared_ptr> flushTelemetry() override; + std::shared_ptr> isTelemetryInitialized() override; + + // ============================================================================ + // Voice Agent Capability (Backend-Agnostic) + // Delegates to rac_voice_agent_* APIs via VoiceAgentBridge + // ============================================================================ + + std::shared_ptr> initializeVoiceAgent(const std::string& configJson) override; + std::shared_ptr> initializeVoiceAgentWithLoadedModels() override; + std::shared_ptr> isVoiceAgentReady() override; + std::shared_ptr> getVoiceAgentComponentStates() override; + std::shared_ptr> processVoiceTurn(const std::string& audioBase64) override; + std::shared_ptr> voiceAgentTranscribe(const std::string& audioBase64) override; + std::shared_ptr> voiceAgentGenerateResponse(const std::string& prompt) override; + std::shared_ptr> voiceAgentSynthesizeSpeech(const std::string& text) override; + std::shared_ptr> cleanupVoiceAgent() override; + +private: + // Thread safety + std::mutex initMutex_; + + // State tracking + std::string lastError_; + + // Helper methods + void setLastError(const std::string& error); +}; + +} // namespace margelo::nitro::runanywhere diff --git a/sdk/runanywhere-react-native/packages/core/cpp/bridges/AuthBridge.cpp b/sdk/runanywhere-react-native/packages/core/cpp/bridges/AuthBridge.cpp new file mode 100644 index 000000000..631e17b92 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/cpp/bridges/AuthBridge.cpp @@ -0,0 +1,209 @@ +/** + * @file AuthBridge.cpp + * @brief C++ bridge for authentication operations. + * + * NOTE: The RACommons library (librac_commons.so) does NOT export auth state + * management functions. Authentication must be handled at the platform level + * (TypeScript/Kotlin/Swift) with tokens managed outside of C++. + * + * This bridge provides a passthrough interface that delegates to platform. + */ + +#include "AuthBridge.hpp" +#include "rac_error.h" +#include + +// Platform-specific logging +#if defined(ANDROID) || defined(__ANDROID__) +#include +#define LOG_TAG "AuthBridge" +#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) +#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__) +#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) +#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, LOG_TAG, __VA_ARGS__) +#else +#include +#define LOGI(...) printf("[AuthBridge] "); printf(__VA_ARGS__); printf("\n") +#define LOGD(...) printf("[AuthBridge DEBUG] "); printf(__VA_ARGS__); printf("\n") +#define LOGE(...) printf("[AuthBridge ERROR] "); printf(__VA_ARGS__); printf("\n") +#define LOGW(...) printf("[AuthBridge WARN] "); printf(__VA_ARGS__); printf("\n") +#endif + +namespace runanywhere { +namespace bridges { + +// ============================================================================= +// Singleton Implementation +// ============================================================================= + +AuthBridge& AuthBridge::shared() { + static AuthBridge instance; + return instance; +} + +// ============================================================================= +// Platform Callbacks +// ============================================================================= + +void AuthBridge::setPlatformCallbacks(const AuthPlatformCallbacks& callbacks) { + platformCallbacks_ = callbacks; + LOGI("Platform callbacks set for AuthBridge"); +} + +// ============================================================================= +// JSON Building (Platform calls HTTP, we just format) +// ============================================================================= + +std::string AuthBridge::buildAuthenticateRequestJSON( + const std::string& apiKey, + const std::string& deviceId, + const std::string& platform, + const std::string& sdkVersion +) { + // Simple JSON building without external dependencies + std::string json = "{"; + json += "\"api_key\":\"" + apiKey + "\","; + json += "\"device_id\":\"" + deviceId + "\","; + json += "\"platform\":\"" + platform + "\","; + json += "\"sdk_version\":\"" + sdkVersion + "\""; + json += "}"; + return json; +} + +std::string AuthBridge::buildRefreshRequestJSON( + const std::string& refreshToken, + const std::string& deviceId +) { + std::string json = "{"; + json += "\"refresh_token\":\"" + refreshToken + "\","; + json += "\"device_id\":\"" + deviceId + "\""; + json += "}"; + return json; +} + +// ============================================================================= +// Response Handling (Parse JSON and extract fields) +// ============================================================================= + +AuthResponse AuthBridge::handleAuthResponse(const std::string& jsonResponse) { + AuthResponse response; + + // Simple JSON parsing (extract key fields) + // In production, use a proper JSON library + auto extractString = [&](const std::string& key) -> std::string { + std::string searchKey = "\"" + key + "\":\""; + size_t pos = jsonResponse.find(searchKey); + if (pos == std::string::npos) return ""; + pos += searchKey.length(); + size_t endPos = jsonResponse.find("\"", pos); + if (endPos == std::string::npos) return ""; + return jsonResponse.substr(pos, endPos - pos); + }; + + auto extractInt = [&](const std::string& key) -> int64_t { + std::string searchKey = "\"" + key + "\":"; + size_t pos = jsonResponse.find(searchKey); + if (pos == std::string::npos) return 0; + pos += searchKey.length(); + try { + return std::stoll(jsonResponse.substr(pos)); + } catch (...) { + return 0; + } + }; + + response.accessToken = extractString("access_token"); + response.refreshToken = extractString("refresh_token"); + response.deviceId = extractString("device_id"); + response.userId = extractString("user_id"); + response.organizationId = extractString("organization_id"); + response.expiresIn = extractInt("expires_in"); + response.success = !response.accessToken.empty(); + + if (!response.success) { + response.error = extractString("error"); + if (response.error.empty()) { + response.error = extractString("message"); + } + } + + return response; +} + +// ============================================================================= +// State Management (Delegated to platform via callbacks) +// ============================================================================= + +void AuthBridge::setAuth(const AuthResponse& auth) { + // Store locally for C++ access + currentAuth_ = auth; + isAuthenticated_ = auth.success && !auth.accessToken.empty(); + + // Notify platform + if (platformCallbacks_.onAuthStateChanged) { + platformCallbacks_.onAuthStateChanged(isAuthenticated_); + } + + LOGI("Auth state updated: authenticated=%d", isAuthenticated_ ? 1 : 0); +} + +std::string AuthBridge::getAccessToken() const { + if (platformCallbacks_.getAccessToken) { + return platformCallbacks_.getAccessToken(); + } + return currentAuth_.accessToken; +} + +std::string AuthBridge::getRefreshToken() const { + if (platformCallbacks_.getRefreshToken) { + return platformCallbacks_.getRefreshToken(); + } + return currentAuth_.refreshToken; +} + +bool AuthBridge::isAuthenticated() const { + if (platformCallbacks_.isAuthenticated) { + return platformCallbacks_.isAuthenticated(); + } + return isAuthenticated_; +} + +bool AuthBridge::tokenNeedsRefresh() const { + if (platformCallbacks_.tokenNeedsRefresh) { + return platformCallbacks_.tokenNeedsRefresh(); + } + // Default: check if we have refresh token but no valid access token + return !currentAuth_.refreshToken.empty() && currentAuth_.accessToken.empty(); +} + +std::string AuthBridge::getUserId() const { + if (platformCallbacks_.getUserId) { + return platformCallbacks_.getUserId(); + } + return currentAuth_.userId; +} + +std::string AuthBridge::getOrganizationId() const { + if (platformCallbacks_.getOrganizationId) { + return platformCallbacks_.getOrganizationId(); + } + return currentAuth_.organizationId; +} + +void AuthBridge::clearAuth() { + currentAuth_ = AuthResponse(); + isAuthenticated_ = false; + + if (platformCallbacks_.clearAuth) { + platformCallbacks_.clearAuth(); + } + + if (platformCallbacks_.onAuthStateChanged) { + platformCallbacks_.onAuthStateChanged(false); + } + + LOGI("Auth state cleared"); +} + +} // namespace bridges +} // namespace runanywhere diff --git a/sdk/runanywhere-react-native/packages/core/cpp/bridges/AuthBridge.hpp b/sdk/runanywhere-react-native/packages/core/cpp/bridges/AuthBridge.hpp new file mode 100644 index 000000000..9c1fc57e9 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/cpp/bridges/AuthBridge.hpp @@ -0,0 +1,157 @@ +/** + * @file AuthBridge.hpp + * @brief C++ bridge for authentication operations. + * + * NOTE: The RACommons library (librac_commons.so) does NOT export auth state + * management functions. Authentication must be handled at the platform level + * (TypeScript/Kotlin/Swift) with tokens managed outside of C++. + * + * This bridge provides a passthrough interface that delegates to platform. + */ + +#pragma once + +#include +#include + +namespace runanywhere { +namespace bridges { + +/** + * Auth response structure + */ +struct AuthResponse { + bool success = false; + std::string accessToken; + std::string refreshToken; + std::string deviceId; + std::string userId; + std::string organizationId; + int64_t expiresIn = 0; + std::string error; +}; + +/** + * Platform callbacks for auth operations + * + * Platform (TypeScript/Kotlin/Swift) implements secure storage + * and HTTP operations, this C++ layer just provides the interface. + */ +struct AuthPlatformCallbacks { + // Get tokens from platform secure storage + std::function getAccessToken; + std::function getRefreshToken; + + // Query auth state + std::function isAuthenticated; + std::function tokenNeedsRefresh; + + // Get user info + std::function getUserId; + std::function getOrganizationId; + + // Clear auth (logout) + std::function clearAuth; + + // Notify platform of auth state changes + std::function onAuthStateChanged; +}; + +/** + * AuthBridge - Authentication state management + * + * Provides JSON building/parsing utilities and state access. + * Actual HTTP calls and secure storage are done by platform. + */ +class AuthBridge { +public: + /** + * Get shared instance + */ + static AuthBridge& shared(); + + /** + * Set platform callbacks + * Must be called during SDK initialization + */ + void setPlatformCallbacks(const AuthPlatformCallbacks& callbacks); + + /** + * Build authenticate request JSON + * Platform uses this to make HTTP POST to /api/v1/auth/sdk/authenticate + */ + std::string buildAuthenticateRequestJSON( + const std::string& apiKey, + const std::string& deviceId, + const std::string& platform, + const std::string& sdkVersion + ); + + /** + * Build refresh request JSON + * Platform uses this to make HTTP POST to /api/v1/auth/sdk/refresh + */ + std::string buildRefreshRequestJSON( + const std::string& refreshToken, + const std::string& deviceId + ); + + /** + * Handle authentication response JSON + * Returns parsed AuthResponse + */ + AuthResponse handleAuthResponse(const std::string& jsonResponse); + + /** + * Set auth state (called by platform after successful auth) + */ + void setAuth(const AuthResponse& auth); + + /** + * Get current access token + */ + std::string getAccessToken() const; + + /** + * Get current refresh token + */ + std::string getRefreshToken() const; + + /** + * Check if currently authenticated + */ + bool isAuthenticated() const; + + /** + * Check if token needs refresh + */ + bool tokenNeedsRefresh() const; + + /** + * Get user ID + */ + std::string getUserId() const; + + /** + * Get organization ID + */ + std::string getOrganizationId() const; + + /** + * Clear authentication state + */ + void clearAuth(); + +private: + AuthBridge() = default; + ~AuthBridge() = default; + AuthBridge(const AuthBridge&) = delete; + AuthBridge& operator=(const AuthBridge&) = delete; + + AuthPlatformCallbacks platformCallbacks_{}; + AuthResponse currentAuth_{}; + bool isAuthenticated_ = false; +}; + +} // namespace bridges +} // namespace runanywhere diff --git a/sdk/runanywhere-react-native/packages/core/cpp/bridges/DeviceBridge.cpp b/sdk/runanywhere-react-native/packages/core/cpp/bridges/DeviceBridge.cpp new file mode 100644 index 000000000..a4687d9fd --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/cpp/bridges/DeviceBridge.cpp @@ -0,0 +1,257 @@ +/** + * @file DeviceBridge.cpp + * @brief C++ bridge for device operations. + * + * Mirrors Swift's CppBridge+Device.swift pattern. + * Registers callbacks with rac_device_manager and delegates to platform. + */ + +#include "DeviceBridge.hpp" +#include "rac_error.h" +#include + +// Platform-specific logging +#if defined(ANDROID) || defined(__ANDROID__) +#include +#define LOG_TAG "DeviceBridge" +#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) +#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__) +#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) +#else +#include +#define LOGI(...) printf("[DeviceBridge] "); printf(__VA_ARGS__); printf("\n") +#define LOGD(...) printf("[DeviceBridge DEBUG] "); printf(__VA_ARGS__); printf("\n") +#define LOGE(...) printf("[DeviceBridge ERROR] "); printf(__VA_ARGS__); printf("\n") +#endif + +namespace runanywhere { +namespace bridges { + +// ============================================================================= +// Static storage for callbacks (needed for C function pointers) +// ============================================================================= + +static DevicePlatformCallbacks* g_deviceCallbacks = nullptr; + +// ============================================================================= +// C Callback Implementations (called by RACommons) +// ============================================================================= + +static void deviceGetInfoCallback(rac_device_registration_info_t* outInfo, void* userData) { + if (!outInfo || !g_deviceCallbacks || !g_deviceCallbacks->getDeviceInfo) { + LOGE("getDeviceInfo callback not available"); + return; + } + + DeviceInfo info = g_deviceCallbacks->getDeviceInfo(); + + // Note: We need to use static storage for strings since RACommons + // only keeps pointers. In a real implementation, these would need + // to be managed carefully for lifetime. + static std::string s_deviceId, s_deviceModel, s_deviceName, s_platform; + static std::string s_osVersion, s_formFactor, s_architecture, s_chipName; + static std::string s_gpuFamily, s_batteryState, s_deviceType, s_osName; + static std::string s_deviceFingerprint; + + s_deviceId = info.deviceId; + s_deviceModel = info.deviceModel; + s_deviceName = info.deviceName; + s_platform = info.platform; + s_osVersion = info.osVersion; + s_formFactor = info.formFactor; + s_architecture = info.architecture; + s_chipName = info.chipName; + s_gpuFamily = info.gpuFamily; + s_batteryState = info.batteryState; + s_deviceType = info.formFactor; // Use formFactor as device_type + s_osName = info.osName.empty() ? info.platform : info.osName; + s_deviceFingerprint = info.deviceId; + + // Fill out the struct - matches Swift's implementation + outInfo->device_id = s_deviceId.c_str(); + outInfo->device_model = s_deviceModel.c_str(); + outInfo->device_name = s_deviceName.c_str(); + outInfo->platform = s_platform.c_str(); + outInfo->os_version = s_osVersion.c_str(); + outInfo->form_factor = s_formFactor.c_str(); + outInfo->architecture = s_architecture.c_str(); + outInfo->chip_name = s_chipName.c_str(); + outInfo->total_memory = info.totalMemory; + outInfo->available_memory = info.availableMemory; + outInfo->has_neural_engine = info.hasNeuralEngine ? RAC_TRUE : RAC_FALSE; + outInfo->neural_engine_cores = info.neuralEngineCores; + outInfo->gpu_family = s_gpuFamily.c_str(); + outInfo->battery_level = info.batteryLevel; + outInfo->battery_state = s_batteryState.empty() ? nullptr : s_batteryState.c_str(); + outInfo->is_low_power_mode = info.isLowPowerMode ? RAC_TRUE : RAC_FALSE; + outInfo->core_count = info.coreCount; + outInfo->performance_cores = info.performanceCores; + outInfo->efficiency_cores = info.efficiencyCores; + outInfo->device_fingerprint = s_deviceFingerprint.c_str(); + + // Legacy fields + outInfo->device_type = s_deviceType.c_str(); + outInfo->os_name = s_osName.c_str(); + outInfo->processor_count = info.coreCount; + outInfo->is_simulator = info.isSimulator ? RAC_TRUE : RAC_FALSE; + + LOGD("Device info populated: model=%s, platform=%s", s_deviceModel.c_str(), s_platform.c_str()); +} + +static const char* deviceGetIdCallback(void* userData) { + if (!g_deviceCallbacks || !g_deviceCallbacks->getDeviceId) { + LOGE("getDeviceId callback not available"); + return nullptr; + } + + static std::string s_deviceId; + s_deviceId = g_deviceCallbacks->getDeviceId(); + return s_deviceId.c_str(); +} + +static rac_bool_t deviceIsRegisteredCallback(void* userData) { + if (!g_deviceCallbacks || !g_deviceCallbacks->isRegistered) { + return RAC_FALSE; + } + return g_deviceCallbacks->isRegistered() ? RAC_TRUE : RAC_FALSE; +} + +static void deviceSetRegisteredCallback(rac_bool_t registered, void* userData) { + if (!g_deviceCallbacks || !g_deviceCallbacks->setRegistered) { + LOGE("setRegistered callback not available"); + return; + } + g_deviceCallbacks->setRegistered(registered == RAC_TRUE); + LOGI("Device registration status set: %s", registered == RAC_TRUE ? "true" : "false"); +} + +static rac_result_t deviceHttpPostCallback( + const char* endpoint, + const char* jsonBody, + rac_bool_t requiresAuth, + rac_device_http_response_t* outResponse, + void* userData +) { + if (!endpoint || !jsonBody || !outResponse) { + return RAC_ERROR_INVALID_ARGUMENT; + } + + if (!g_deviceCallbacks || !g_deviceCallbacks->httpPost) { + LOGE("httpPost callback not available"); + outResponse->result = RAC_ERROR_NOT_SUPPORTED; + return RAC_ERROR_NOT_SUPPORTED; + } + + LOGI("Making HTTP POST to: %s", endpoint); + + auto [success, statusCode, responseBody, errorMessage] = + g_deviceCallbacks->httpPost(endpoint, jsonBody, requiresAuth == RAC_TRUE); + + // Store response strings statically for lifetime + static std::string s_responseBody, s_errorMessage; + s_responseBody = responseBody; + s_errorMessage = errorMessage; + + if (success) { + outResponse->result = RAC_SUCCESS; + outResponse->status_code = statusCode; + outResponse->response_body = s_responseBody.empty() ? nullptr : s_responseBody.c_str(); + outResponse->error_message = nullptr; + LOGI("HTTP POST succeeded with status %d", statusCode); + return RAC_SUCCESS; + } else { + outResponse->result = RAC_ERROR_NETWORK_ERROR; + outResponse->status_code = statusCode; + outResponse->response_body = nullptr; + outResponse->error_message = s_errorMessage.empty() ? nullptr : s_errorMessage.c_str(); + LOGE("HTTP POST failed: %s", s_errorMessage.c_str()); + return RAC_ERROR_NETWORK_ERROR; + } +} + +// ============================================================================= +// DeviceBridge Implementation +// ============================================================================= + +DeviceBridge& DeviceBridge::shared() { + static DeviceBridge instance; + return instance; +} + +void DeviceBridge::setPlatformCallbacks(const DevicePlatformCallbacks& callbacks) { + platformCallbacks_ = callbacks; + + // Store in global for C callbacks + static DevicePlatformCallbacks storedCallbacks; + storedCallbacks = callbacks; + g_deviceCallbacks = &storedCallbacks; + + LOGI("Device platform callbacks set"); +} + +rac_result_t DeviceBridge::registerCallbacks() { + if (callbacksRegistered_) { + LOGD("Device callbacks already registered"); + return RAC_SUCCESS; + } + + // Reset callbacks struct + memset(&racCallbacks_, 0, sizeof(racCallbacks_)); + + // Set callback function pointers + racCallbacks_.get_device_info = deviceGetInfoCallback; + racCallbacks_.get_device_id = deviceGetIdCallback; + racCallbacks_.is_registered = deviceIsRegisteredCallback; + racCallbacks_.set_registered = deviceSetRegisteredCallback; + racCallbacks_.http_post = deviceHttpPostCallback; + racCallbacks_.user_data = nullptr; + + // Register with RACommons + rac_result_t result = rac_device_manager_set_callbacks(&racCallbacks_); + + if (result == RAC_SUCCESS) { + callbacksRegistered_ = true; + LOGI("Device manager callbacks registered with RACommons"); + } else { + LOGE("Failed to register device manager callbacks: %d", result); + } + + return result; +} + +rac_result_t DeviceBridge::registerIfNeeded(rac_environment_t environment, const std::string& buildToken) { + if (!callbacksRegistered_) { + LOGE("Device callbacks not registered - call registerCallbacks() first"); + return RAC_ERROR_NOT_INITIALIZED; + } + + LOGI("Registering device if needed (env=%d)...", static_cast(environment)); + + const char* tokenPtr = buildToken.empty() ? nullptr : buildToken.c_str(); + rac_result_t result = rac_device_manager_register_if_needed(environment, tokenPtr); + + if (result == RAC_SUCCESS) { + LOGI("Device registration completed successfully"); + } else { + LOGE("Device registration failed: %d", result); + } + + return result; +} + +bool DeviceBridge::isRegistered() const { + return rac_device_manager_is_registered() == RAC_TRUE; +} + +void DeviceBridge::clearRegistration() { + rac_device_manager_clear_registration(); + LOGI("Device registration cleared"); +} + +std::string DeviceBridge::getDeviceId() const { + const char* id = rac_device_manager_get_device_id(); + return id ? std::string(id) : ""; +} + +} // namespace bridges +} // namespace runanywhere diff --git a/sdk/runanywhere-react-native/packages/core/cpp/bridges/DeviceBridge.hpp b/sdk/runanywhere-react-native/packages/core/cpp/bridges/DeviceBridge.hpp new file mode 100644 index 000000000..eefffd13a --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/cpp/bridges/DeviceBridge.hpp @@ -0,0 +1,155 @@ +/** + * @file DeviceBridge.hpp + * @brief C++ bridge for device operations. + * + * Mirrors Swift's CppBridge+Device.swift pattern. + * Registers callbacks with rac_device_manager and delegates to platform. + * + * Reference: sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+Device.swift + */ + +#pragma once + +#include +#include + +#include "rac_types.h" +#include "rac_device_manager.h" +#include "rac_environment.h" + +namespace runanywhere { +namespace bridges { + +/** + * Device info structure + */ +struct DeviceInfo { + std::string deviceId; + std::string deviceModel; + std::string deviceName; + std::string platform; + std::string osName; + std::string osVersion; + std::string formFactor; + std::string architecture; + std::string chipName; + int64_t totalMemory = 0; + int64_t availableMemory = 0; + bool hasNeuralEngine = false; + int32_t neuralEngineCores = 0; + std::string gpuFamily; + float batteryLevel = -1.0f; + std::string batteryState; + bool isLowPowerMode = false; + int32_t coreCount = 0; + int32_t performanceCores = 0; + int32_t efficiencyCores = 0; + bool isSimulator = false; + std::string sdkVersion; +}; + +/** + * Device registration result + */ +struct DeviceRegistrationResult { + bool success = false; + std::string deviceId; + std::string error; +}; + +/** + * Platform callbacks for device operations + */ +struct DevicePlatformCallbacks { + // Get device hardware/OS info + std::function getDeviceInfo; + + // Get persistent device ID (from keychain/keystore) + std::function getDeviceId; + + // Check if device is registered (from UserDefaults/SharedPrefs) + std::function isRegistered; + + // Set registration status + std::function setRegistered; + + // Make HTTP POST for device registration + // Returns: (success, statusCode, responseBody, errorMessage) + std::function( + const std::string& endpoint, + const std::string& jsonBody, + bool requiresAuth + )> httpPost; +}; + +/** + * DeviceBridge - Device registration and info via rac_device_manager_* API + * + * Mirrors Swift's CppBridge.Device pattern: + * - Platform provides callbacks + * - C++ handles business logic via RACommons + */ +class DeviceBridge { +public: + /** + * Get shared instance + */ + static DeviceBridge& shared(); + + /** + * Set platform callbacks + * Must be called during SDK initialization BEFORE registerCallbacks() + */ + void setPlatformCallbacks(const DevicePlatformCallbacks& callbacks); + + /** + * Register callbacks with RACommons device manager + * Must be called during SDK initialization after setPlatformCallbacks() + */ + rac_result_t registerCallbacks(); + + /** + * Register device with backend if not already registered + * Delegates to rac_device_manager_register_if_needed() + * + * @param environment SDK environment + * @param buildToken Optional build token for development mode + * @return RAC_SUCCESS if registered or already registered + */ + rac_result_t registerIfNeeded(rac_environment_t environment, const std::string& buildToken = ""); + + /** + * Check if device is registered + */ + bool isRegistered() const; + + /** + * Clear device registration status + */ + void clearRegistration(); + + /** + * Get the device ID + */ + std::string getDeviceId() const; + + /** + * Check if callbacks are registered + */ + bool isCallbacksRegistered() const { return callbacksRegistered_; } + +private: + DeviceBridge() = default; + ~DeviceBridge() = default; + DeviceBridge(const DeviceBridge&) = delete; + DeviceBridge& operator=(const DeviceBridge&) = delete; + + bool callbacksRegistered_ = false; + DevicePlatformCallbacks platformCallbacks_{}; + + // Callbacks struct for RACommons (must persist) + rac_device_callbacks_t racCallbacks_{}; +}; + +} // namespace bridges +} // namespace runanywhere diff --git a/sdk/runanywhere-react-native/packages/core/cpp/bridges/DownloadBridge.cpp b/sdk/runanywhere-react-native/packages/core/cpp/bridges/DownloadBridge.cpp new file mode 100644 index 000000000..f0768cee0 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/cpp/bridges/DownloadBridge.cpp @@ -0,0 +1,299 @@ +/** + * @file DownloadBridge.cpp + * @brief C++ bridge for download operations. + * + * Mirrors Swift's CppBridge+Download.swift pattern. + */ + +#include "DownloadBridge.hpp" +#include +#include + +// Platform-specific logging +#if defined(ANDROID) || defined(__ANDROID__) +#include +#define LOG_TAG "DownloadBridge" +#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) +#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__) +#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) +#else +#include +#define LOGI(...) printf("[DownloadBridge] "); printf(__VA_ARGS__); printf("\n") +#define LOGD(...) printf("[DownloadBridge DEBUG] "); printf(__VA_ARGS__); printf("\n") +#define LOGE(...) printf("[DownloadBridge ERROR] "); printf(__VA_ARGS__); printf("\n") +#endif + +namespace runanywhere { +namespace bridges { + +DownloadBridge& DownloadBridge::shared() { + static DownloadBridge instance; + return instance; +} + +DownloadBridge::~DownloadBridge() { + shutdown(); +} + +rac_result_t DownloadBridge::initialize(const DownloadConfig* config) { + if (handle_) { + LOGD("Download manager already initialized"); + return RAC_SUCCESS; + } + + // Setup config if provided + const rac_download_config_t* racConfig = nullptr; + rac_download_config_t configStruct = RAC_DOWNLOAD_CONFIG_DEFAULT; + + if (config) { + configStruct.max_concurrent_downloads = config->maxConcurrentDownloads; + configStruct.request_timeout_seconds = config->requestTimeoutSeconds; + configStruct.max_retry_attempts = config->maxRetryAttempts; + configStruct.retry_delay_seconds = config->retryDelaySeconds; + configStruct.allow_cellular = config->allowCellular ? RAC_TRUE : RAC_FALSE; + configStruct.allow_constrained_network = config->allowConstrainedNetwork ? RAC_TRUE : RAC_FALSE; + racConfig = &configStruct; + } + + // Create manager + rac_result_t result = rac_download_manager_create(racConfig, &handle_); + + if (result == RAC_SUCCESS) { + LOGI("Download manager created successfully"); + } else { + LOGE("Failed to create download manager: %d", result); + handle_ = nullptr; + } + + return result; +} + +void DownloadBridge::shutdown() { + if (handle_) { + rac_download_manager_destroy(handle_); + handle_ = nullptr; + progressCallbacks_.clear(); + LOGI("Download manager destroyed"); + } +} + +std::string DownloadBridge::startDownload( + const std::string& modelId, + const std::string& url, + const std::string& destinationPath, + bool requiresExtraction, + std::function progressHandler +) { + if (!handle_) { + LOGE("Download manager not initialized"); + return ""; + } + + char* taskIdPtr = nullptr; + + rac_result_t result = rac_download_manager_start( + handle_, + modelId.c_str(), + url.c_str(), + destinationPath.c_str(), + requiresExtraction ? RAC_TRUE : RAC_FALSE, + nullptr, // Progress callback - we poll instead + nullptr, // Complete callback - we poll instead + nullptr, // User data + &taskIdPtr + ); + + if (result != RAC_SUCCESS || !taskIdPtr) { + LOGE("Failed to start download: %d", result); + return ""; + } + + std::string taskId(taskIdPtr); + free(taskIdPtr); + + // Store progress callback + if (progressHandler) { + progressCallbacks_[taskId] = progressHandler; + } + + LOGI("Started download task: %s for model: %s", taskId.c_str(), modelId.c_str()); + return taskId; +} + +rac_result_t DownloadBridge::cancelDownload(const std::string& taskId) { + if (!handle_) { + return RAC_ERROR_NOT_INITIALIZED; + } + + rac_result_t result = rac_download_manager_cancel(handle_, taskId.c_str()); + + if (result == RAC_SUCCESS) { + progressCallbacks_.erase(taskId); + LOGI("Cancelled download task: %s", taskId.c_str()); + } else { + LOGE("Failed to cancel download %s: %d", taskId.c_str(), result); + } + + return result; +} + +rac_result_t DownloadBridge::pauseAll() { + if (!handle_) { + return RAC_ERROR_NOT_INITIALIZED; + } + + rac_result_t result = rac_download_manager_pause_all(handle_); + + if (result == RAC_SUCCESS) { + LOGI("Paused all downloads"); + } else { + LOGE("Failed to pause downloads: %d", result); + } + + return result; +} + +rac_result_t DownloadBridge::resumeAll() { + if (!handle_) { + return RAC_ERROR_NOT_INITIALIZED; + } + + rac_result_t result = rac_download_manager_resume_all(handle_); + + if (result == RAC_SUCCESS) { + LOGI("Resumed all downloads"); + } else { + LOGE("Failed to resume downloads: %d", result); + } + + return result; +} + +DownloadProgress DownloadBridge::fromRac(const rac_download_progress_t& cProgress) { + DownloadProgress progress; + progress.stage = static_cast(cProgress.stage); + progress.bytesDownloaded = cProgress.bytes_downloaded; + progress.totalBytes = cProgress.total_bytes; + progress.stageProgress = cProgress.stage_progress; + progress.overallProgress = cProgress.overall_progress; + progress.state = static_cast(cProgress.state); + progress.speed = cProgress.speed; + progress.estimatedTimeRemaining = cProgress.estimated_time_remaining; + progress.retryAttempt = cProgress.retry_attempt; + progress.errorCode = cProgress.error_code; + progress.errorMessage = cProgress.error_message ? cProgress.error_message : ""; + return progress; +} + +std::optional DownloadBridge::getProgress(const std::string& taskId) { + if (!handle_) { + return std::nullopt; + } + + rac_download_progress_t cProgress = RAC_DOWNLOAD_PROGRESS_DEFAULT; + rac_result_t result = rac_download_manager_get_progress(handle_, taskId.c_str(), &cProgress); + + if (result != RAC_SUCCESS) { + return std::nullopt; + } + + return fromRac(cProgress); +} + +std::vector DownloadBridge::getActiveTasks() { + std::vector tasks; + + if (!handle_) { + return tasks; + } + + char** taskIdsPtr = nullptr; + size_t count = 0; + + rac_result_t result = rac_download_manager_get_active_tasks(handle_, &taskIdsPtr, &count); + + if (result != RAC_SUCCESS || !taskIdsPtr) { + return tasks; + } + + for (size_t i = 0; i < count; i++) { + if (taskIdsPtr[i]) { + tasks.push_back(taskIdsPtr[i]); + } + } + + rac_download_task_ids_free(taskIdsPtr, count); + + return tasks; +} + +bool DownloadBridge::isHealthy() { + if (!handle_) { + return false; + } + + rac_bool_t healthy = RAC_FALSE; + rac_result_t result = rac_download_manager_is_healthy(handle_, &healthy); + + return result == RAC_SUCCESS && healthy == RAC_TRUE; +} + +void DownloadBridge::updateProgress(const std::string& taskId, int64_t bytesDownloaded, int64_t totalBytes) { + if (!handle_) { + return; + } + + rac_download_manager_update_progress(handle_, taskId.c_str(), bytesDownloaded, totalBytes); + + // Notify callback + auto it = progressCallbacks_.find(taskId); + if (it != progressCallbacks_.end()) { + auto progress = getProgress(taskId); + if (progress) { + it->second(*progress); + } + } +} + +void DownloadBridge::markComplete(const std::string& taskId, const std::string& downloadedPath) { + if (!handle_) { + return; + } + + rac_download_manager_mark_complete(handle_, taskId.c_str(), downloadedPath.c_str()); + + // Notify final progress + auto it = progressCallbacks_.find(taskId); + if (it != progressCallbacks_.end()) { + auto progress = getProgress(taskId); + if (progress) { + it->second(*progress); + } + progressCallbacks_.erase(it); + } + + LOGI("Download completed: %s", taskId.c_str()); +} + +void DownloadBridge::markFailed(const std::string& taskId, rac_result_t errorCode, const std::string& errorMessage) { + if (!handle_) { + return; + } + + rac_download_manager_mark_failed(handle_, taskId.c_str(), errorCode, errorMessage.c_str()); + + // Notify final progress + auto it = progressCallbacks_.find(taskId); + if (it != progressCallbacks_.end()) { + auto progress = getProgress(taskId); + if (progress) { + it->second(*progress); + } + progressCallbacks_.erase(it); + } + + LOGE("Download failed: %s - %s", taskId.c_str(), errorMessage.c_str()); +} + +} // namespace bridges +} // namespace runanywhere diff --git a/sdk/runanywhere-react-native/packages/core/cpp/bridges/DownloadBridge.hpp b/sdk/runanywhere-react-native/packages/core/cpp/bridges/DownloadBridge.hpp new file mode 100644 index 000000000..b6607ccb7 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/cpp/bridges/DownloadBridge.hpp @@ -0,0 +1,197 @@ +/** + * @file DownloadBridge.hpp + * @brief C++ bridge for download operations. + * + * Mirrors Swift's CppBridge+Download.swift pattern: + * - Handle-based API via rac_download_manager_* + * - Platform provides HTTP download implementation + * + * Reference: sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+Download.swift + */ + +#pragma once + +#include +#include +#include +#include +#include + +#include "rac_types.h" +#include "rac_download.h" + +namespace runanywhere { +namespace bridges { + +/** + * Download stage enum matching RAC + */ +enum class DownloadStage { + Downloading = 0, + Extracting = 1, + Validating = 2, + Completed = 3 +}; + +/** + * Download state enum matching RAC + */ +enum class DownloadState { + Pending = 0, + Downloading = 1, + Extracting = 2, + Retrying = 3, + Completed = 4, + Failed = 5, + Cancelled = 6 +}; + +/** + * Download progress info + */ +struct DownloadProgress { + DownloadStage stage = DownloadStage::Downloading; + int64_t bytesDownloaded = 0; + int64_t totalBytes = 0; + double stageProgress = 0.0; + double overallProgress = 0.0; + DownloadState state = DownloadState::Pending; + double speed = 0.0; // bytes per second + double estimatedTimeRemaining = -1.0; // seconds + int32_t retryAttempt = 0; + rac_result_t errorCode = RAC_SUCCESS; + std::string errorMessage; +}; + +/** + * Download configuration + */ +struct DownloadConfig { + int32_t maxConcurrentDownloads = 1; + int32_t requestTimeoutSeconds = 60; + int32_t maxRetryAttempts = 3; + int32_t retryDelaySeconds = 5; + bool allowCellular = true; + bool allowConstrainedNetwork = false; +}; + +/** + * DownloadBridge - Download orchestration via rac_download_manager_* API + * + * Mirrors Swift's CppBridge.Download pattern: + * - Handle-based API + * - Progress callbacks stored and invoked + * - Platform provides actual HTTP downloads + */ +class DownloadBridge { +public: + /** + * Get shared instance + */ + static DownloadBridge& shared(); + + /** + * Initialize the download manager + * @param config Optional configuration + */ + rac_result_t initialize(const DownloadConfig* config = nullptr); + + /** + * Shutdown and cleanup + */ + void shutdown(); + + /** + * Check if initialized + */ + bool isInitialized() const { return handle_ != nullptr; } + + // ========================================================================= + // Download Operations + // ========================================================================= + + /** + * Start a download task + * + * @param modelId Model identifier + * @param url Download URL + * @param destinationPath Where to save the file + * @param requiresExtraction Whether to extract archive after download + * @param progressHandler Callback for progress updates + * @return Task ID for tracking, empty on error + */ + std::string startDownload( + const std::string& modelId, + const std::string& url, + const std::string& destinationPath, + bool requiresExtraction, + std::function progressHandler + ); + + /** + * Cancel a download task + */ + rac_result_t cancelDownload(const std::string& taskId); + + /** + * Pause all active downloads + */ + rac_result_t pauseAll(); + + /** + * Resume all paused downloads + */ + rac_result_t resumeAll(); + + // ========================================================================= + // Progress Tracking + // ========================================================================= + + /** + * Get progress for a task + */ + std::optional getProgress(const std::string& taskId); + + /** + * Get list of active task IDs + */ + std::vector getActiveTasks(); + + /** + * Check if download service is healthy + */ + bool isHealthy(); + + // ========================================================================= + // Progress Updates (called by platform HTTP layer) + // ========================================================================= + + /** + * Update download progress (called by platform) + */ + void updateProgress(const std::string& taskId, int64_t bytesDownloaded, int64_t totalBytes); + + /** + * Mark download as complete (called by platform) + */ + void markComplete(const std::string& taskId, const std::string& downloadedPath); + + /** + * Mark download as failed (called by platform) + */ + void markFailed(const std::string& taskId, rac_result_t errorCode, const std::string& errorMessage); + +private: + DownloadBridge() = default; + ~DownloadBridge(); + DownloadBridge(const DownloadBridge&) = delete; + DownloadBridge& operator=(const DownloadBridge&) = delete; + + static DownloadProgress fromRac(const rac_download_progress_t& cProgress); + + rac_download_manager_handle_t handle_ = nullptr; + std::unordered_map> progressCallbacks_; +}; + +} // namespace bridges +} // namespace runanywhere diff --git a/sdk/runanywhere-react-native/packages/core/cpp/bridges/EventBridge.cpp b/sdk/runanywhere-react-native/packages/core/cpp/bridges/EventBridge.cpp new file mode 100644 index 000000000..f696fd8d1 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/cpp/bridges/EventBridge.cpp @@ -0,0 +1,125 @@ +/** + * @file EventBridge.cpp + * @brief C++ bridge for event operations. + * + * Simplified event bridge that manages event callbacks locally. + * Does not depend on RACommons event functions (which may not be exported). + */ + +#include "EventBridge.hpp" +#include + +// Platform-specific logging +#if defined(ANDROID) || defined(__ANDROID__) +#include +#define LOG_TAG "EventBridge" +#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) +#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__) +#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) +#else +#include +#define LOGI(...) printf("[EventBridge] "); printf(__VA_ARGS__); printf("\n") +#define LOGD(...) printf("[EventBridge DEBUG] "); printf(__VA_ARGS__); printf("\n") +#define LOGE(...) printf("[EventBridge ERROR] "); printf(__VA_ARGS__); printf("\n") +#endif + +namespace runanywhere { +namespace bridges { + +// ============================================================================= +// EventBridge Implementation +// ============================================================================= + +EventBridge& EventBridge::shared() { + static EventBridge instance; + return instance; +} + +EventBridge::~EventBridge() { + unregisterFromEvents(); +} + +void EventBridge::setEventCallback(EventCallback callback) { + eventCallback_ = callback; + LOGI("Event callback registered"); +} + +void EventBridge::registerForEvents() { + if (isRegistered_) { + LOGD("Already registered for events"); + return; + } + + isRegistered_ = true; + LOGI("Event registration enabled"); +} + +void EventBridge::unregisterFromEvents() { + if (!isRegistered_) { + return; + } + + isRegistered_ = false; + LOGI("Event registration disabled"); +} + +rac_result_t EventBridge::trackEvent( + const std::string& type, + EventCategory category, + EventDestination destination, + const std::string& propertiesJson +) { + LOGD("trackEvent: type=%s category=%d", type.c_str(), static_cast(category)); + + // If we have a callback registered, forward the event + if (eventCallback_) { + SDKEvent event; + auto now = std::chrono::system_clock::now(); + auto millis = std::chrono::duration_cast( + now.time_since_epoch()).count(); + + event.id = std::to_string(millis); + event.type = type; + event.category = category; + event.timestampMs = millis; + event.destination = destination; + event.propertiesJson = propertiesJson; + + eventCallback_(event); + } + + return RAC_SUCCESS; +} + +rac_result_t EventBridge::publishEvent(const SDKEvent& event) { + LOGD("publishEvent: type=%s", event.type.c_str()); + + // If we have a callback registered, forward the event + if (eventCallback_) { + eventCallback_(event); + } + + return RAC_SUCCESS; +} + +std::string EventBridge::getCategoryName(EventCategory category) { + switch (category) { + case EventCategory::SDK: return "sdk"; + case EventCategory::Model: return "model"; + case EventCategory::LLM: return "llm"; + case EventCategory::STT: return "stt"; + case EventCategory::TTS: return "tts"; + case EventCategory::Voice: return "voice"; + case EventCategory::Storage: return "storage"; + case EventCategory::Device: return "device"; + case EventCategory::Network: return "network"; + case EventCategory::Error: return "error"; + case EventCategory::Analytics: return "analytics"; + case EventCategory::Performance: return "performance"; + case EventCategory::User: return "user"; + default: return "unknown"; + } +} + +} // namespace bridges +} // namespace runanywhere diff --git a/sdk/runanywhere-react-native/packages/core/cpp/bridges/EventBridge.hpp b/sdk/runanywhere-react-native/packages/core/cpp/bridges/EventBridge.hpp new file mode 100644 index 000000000..fa9203133 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/cpp/bridges/EventBridge.hpp @@ -0,0 +1,139 @@ +/** + * @file EventBridge.hpp + * @brief C++ bridge for event operations. + * + * Mirrors Swift's event handling pattern: + * - Subscribe to events via rac_event_subscribe() + * - Forward events to JS layer + * + * Reference: sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Events/ + */ + +#pragma once + +#include +#include +#include +#include + +#include "rac_types.h" + +namespace runanywhere { +namespace bridges { + +/** + * Event category enum matching RAC + */ +enum class EventCategory { + SDK = 0, + Model = 1, + LLM = 2, + STT = 3, + TTS = 4, + Voice = 5, + Storage = 6, + Device = 7, + Network = 8, + Error = 9, + Analytics = 10, + Performance = 11, + User = 12 +}; + +/** + * Event destination enum matching RAC + */ +enum class EventDestination { + PublicOnly = 0, + AnalyticsOnly = 1, + All = 2 +}; + +/** + * Event data structure + */ +struct SDKEvent { + std::string id; + std::string type; + EventCategory category = EventCategory::SDK; + int64_t timestampMs = 0; + std::string sessionId; + EventDestination destination = EventDestination::All; + std::string propertiesJson; +}; + +/** + * Event callback type + */ +using EventCallback = std::function; + +/** + * EventBridge - Event subscription and publishing + * + * Mirrors Swift's EventBridge pattern: + * - Subscribe to C++ events + * - Forward to JS layer + * - Track events via rac_event_track() + */ +class EventBridge { +public: + /** + * Get shared instance + */ + static EventBridge& shared(); + + /** + * Register event callback for JS layer + * Events will be forwarded to this callback + */ + void setEventCallback(EventCallback callback); + + /** + * Register with RACommons to receive events + * Must be called during SDK initialization + */ + void registerForEvents(); + + /** + * Unregister from RACommons events + */ + void unregisterFromEvents(); + + /** + * Track an event + * + * @param type Event type string + * @param category Event category + * @param destination Where to route this event + * @param propertiesJson Event properties as JSON + */ + rac_result_t trackEvent( + const std::string& type, + EventCategory category, + EventDestination destination, + const std::string& propertiesJson + ); + + /** + * Publish an event + */ + rac_result_t publishEvent(const SDKEvent& event); + + /** + * Get category name + */ + static std::string getCategoryName(EventCategory category); + +private: + EventBridge() = default; + ~EventBridge(); + EventBridge(const EventBridge&) = delete; + EventBridge& operator=(const EventBridge&) = delete; + + EventCallback eventCallback_; + uint64_t subscriptionId_ = 0; + bool isRegistered_ = false; +}; + +} // namespace bridges +} // namespace runanywhere diff --git a/sdk/runanywhere-react-native/packages/core/cpp/bridges/HTTPBridge.cpp b/sdk/runanywhere-react-native/packages/core/cpp/bridges/HTTPBridge.cpp new file mode 100644 index 000000000..50867a35b --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/cpp/bridges/HTTPBridge.cpp @@ -0,0 +1,96 @@ +/** + * @file HTTPBridge.cpp + * @brief HTTP bridge implementation + * + * NOTE: HTTP is handled by the JS layer. This bridge manages configuration. + */ + +#include "HTTPBridge.hpp" + +// Platform-specific logging +#if defined(ANDROID) || defined(__ANDROID__) +#include +#define LOG_TAG "HTTPBridge" +#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) +#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__) +#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) +#else +#include +#define LOGI(...) printf("[HTTPBridge] "); printf(__VA_ARGS__); printf("\n") +#define LOGD(...) printf("[HTTPBridge DEBUG] "); printf(__VA_ARGS__); printf("\n") +#define LOGE(...) printf("[HTTPBridge ERROR] "); printf(__VA_ARGS__); printf("\n") +#endif + +namespace runanywhere { +namespace bridges { + +HTTPBridge& HTTPBridge::shared() { + static HTTPBridge instance; + return instance; +} + +void HTTPBridge::configure(const std::string& baseURL, const std::string& apiKey) { + baseURL_ = baseURL; + apiKey_ = apiKey; + configured_ = true; + + LOGI("HTTP configured: baseURL=%s", baseURL.c_str()); +} + +void HTTPBridge::setAuthorizationToken(const std::string& token) { + authToken_ = token; + LOGD("Authorization token set"); +} + +std::optional HTTPBridge::getAuthorizationToken() const { + return authToken_; +} + +void HTTPBridge::clearAuthorizationToken() { + authToken_.reset(); + LOGD("Authorization token cleared"); +} + +void HTTPBridge::setHTTPExecutor(HTTPExecutor executor) { + executor_ = executor; + LOGI("HTTP executor registered"); +} + +std::optional HTTPBridge::execute( + const std::string& method, + const std::string& endpoint, + const std::string& body, + bool requiresAuth +) { + if (!executor_) { + LOGE("No HTTP executor registered - HTTP requests must go through JS layer"); + return std::nullopt; + } + + std::string url = buildURL(endpoint); + LOGD("Executing %s %s", method.c_str(), url.c_str()); + + return executor_(method, url, body, requiresAuth); +} + +std::string HTTPBridge::buildURL(const std::string& endpoint) const { + if (baseURL_.empty()) { + return endpoint; + } + + // Ensure proper URL joining + std::string url = baseURL_; + if (!url.empty() && url.back() == '/') { + url.pop_back(); + } + + if (!endpoint.empty() && endpoint.front() != '/') { + url += '/'; + } + + url += endpoint; + return url; +} + +} // namespace bridges +} // namespace runanywhere diff --git a/sdk/runanywhere-react-native/packages/core/cpp/bridges/HTTPBridge.hpp b/sdk/runanywhere-react-native/packages/core/cpp/bridges/HTTPBridge.hpp new file mode 100644 index 000000000..8389256d0 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/cpp/bridges/HTTPBridge.hpp @@ -0,0 +1,144 @@ +/** + * @file HTTPBridge.hpp + * @brief HTTP bridge documentation + * + * NOTE: HTTP is handled entirely by the JavaScript/platform layer. + * + * In Swift, HTTPService.swift handles all HTTP requests. + * In React Native, the JS layer (HTTPService.ts) handles HTTP. + * + * C++ does NOT make HTTP requests directly. Instead: + * 1. C++ provides JSON building functions (rac_auth_request_to_json, etc.) + * 2. JS layer makes the HTTP request + * 3. C++ parses the response (rac_auth_response_from_json, etc.) + * 4. C++ stores state (rac_state_set_auth, etc.) + * + * This bridge provides: + * - Configuration storage (base URL, API key) + * - Authorization header management + * - HTTP executor registration (for C++ components that need to make requests) + * + * Reference: sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+HTTP.swift + */ + +#pragma once + +#include +#include +#include + +#include "rac_types.h" +#include "rac_http_client.h" + +namespace runanywhere { +namespace bridges { + +/** + * HTTP response + */ +struct HTTPResponse { + int32_t statusCode = 0; + std::string body; + std::string error; + bool success = false; +}; + +/** + * HTTP executor callback type + * Platform provides this to handle HTTP requests + */ +using HTTPExecutor = std::function; + +/** + * HTTPBridge - HTTP configuration and executor registration + * + * NOTE: Actual HTTP requests are made by the JS layer, not C++. + * This bridge handles configuration and provides an executor for + * C++ components that need HTTP access. + */ +class HTTPBridge { +public: + /** + * Get shared instance + */ + static HTTPBridge& shared(); + + /** + * Configure HTTP with base URL and API key + */ + void configure(const std::string& baseURL, const std::string& apiKey); + + /** + * Check if configured + */ + bool isConfigured() const { return configured_; } + + /** + * Get base URL + */ + const std::string& getBaseURL() const { return baseURL_; } + + /** + * Get API key + */ + const std::string& getAPIKey() const { return apiKey_; } + + /** + * Set authorization token + */ + void setAuthorizationToken(const std::string& token); + + /** + * Get authorization token + */ + std::optional getAuthorizationToken() const; + + /** + * Clear authorization token + */ + void clearAuthorizationToken(); + + /** + * Register HTTP executor (called by platform) + * + * This allows C++ components to make HTTP requests through the platform. + * The platform handles the actual network operations. + */ + void setHTTPExecutor(HTTPExecutor executor); + + /** + * Execute HTTP request via registered executor + * Returns nullopt if no executor registered + */ + std::optional execute( + const std::string& method, + const std::string& endpoint, + const std::string& body, + bool requiresAuth + ); + + /** + * Build full URL from endpoint + */ + std::string buildURL(const std::string& endpoint) const; + +private: + HTTPBridge() = default; + ~HTTPBridge() = default; + HTTPBridge(const HTTPBridge&) = delete; + HTTPBridge& operator=(const HTTPBridge&) = delete; + + bool configured_ = false; + std::string baseURL_; + std::string apiKey_; + std::optional authToken_; + HTTPExecutor executor_; +}; + +} // namespace bridges +} // namespace runanywhere diff --git a/sdk/runanywhere-react-native/packages/core/cpp/bridges/InitBridge.cpp b/sdk/runanywhere-react-native/packages/core/cpp/bridges/InitBridge.cpp new file mode 100644 index 000000000..8b9c46222 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/cpp/bridges/InitBridge.cpp @@ -0,0 +1,1273 @@ +/** + * @file InitBridge.cpp + * @brief SDK initialization bridge implementation + * + * Implements platform adapter registration and SDK initialization. + * Mirrors Swift's CppBridge.initialize() pattern. + */ + +#include "InitBridge.hpp" +#include "rac_model_paths.h" +#include "rac_environment.h" // For rac_sdk_init, rac_sdk_config_t +#include +#include +#include +#include +#include + +// Platform-specific logging and bridges +#if defined(ANDROID) || defined(__ANDROID__) +#include +#include +#define LOG_TAG "InitBridge" +#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) +#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__) +#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, LOG_TAG, __VA_ARGS__) +#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) + +// Use the JavaVM from cpp-adapter.cpp (set in JNI_OnLoad there) +// NOTE: JNI_OnLoad is defined in cpp-adapter.cpp - do NOT define it here! +extern JavaVM* g_javaVM; + +// Use cached class and method references from cpp-adapter.cpp +// These are set in JNI_OnLoad to avoid FindClass from background threads +extern jclass g_platformAdapterBridgeClass; +extern jclass g_httpResponseClass; +extern jmethodID g_secureSetMethod; +extern jmethodID g_secureGetMethod; +extern jmethodID g_secureDeleteMethod; +extern jmethodID g_secureExistsMethod; +extern jmethodID g_getPersistentDeviceUUIDMethod; +extern jmethodID g_httpPostSyncMethod; +extern jmethodID g_getDeviceModelMethod; +extern jmethodID g_getOSVersionMethod; +extern jmethodID g_getChipNameMethod; +extern jmethodID g_getTotalMemoryMethod; +extern jmethodID g_getAvailableMemoryMethod; +extern jmethodID g_getCoreCountMethod; +extern jmethodID g_getArchitectureMethod; +extern jmethodID g_getGPUFamilyMethod; +extern jmethodID g_isTabletMethod; +// HttpResponse field IDs +extern jfieldID g_httpResponse_successField; +extern jfieldID g_httpResponse_statusCodeField; +extern jfieldID g_httpResponse_responseBodyField; +extern jfieldID g_httpResponse_errorMessageField; + +// Helper to get JNIEnv for current thread +static JNIEnv* getJNIEnv() { + if (!g_javaVM) { + LOGE("JavaVM not initialized - cpp-adapter JNI_OnLoad may not have been called"); + return nullptr; + } + + JNIEnv* env = nullptr; + int status = g_javaVM->GetEnv(reinterpret_cast(&env), JNI_VERSION_1_6); + + if (status == JNI_EDETACHED) { + // Attach current thread + if (g_javaVM->AttachCurrentThread(&env, nullptr) != JNI_OK) { + LOGE("Failed to attach current thread to JVM"); + return nullptr; + } + } else if (status != JNI_OK) { + LOGE("Failed to get JNI environment: %d", status); + return nullptr; + } + + return env; +} + +// Android JNI bridge for secure storage +// Uses cached class/method references from cpp-adapter.cpp to avoid FindClass from bg threads +namespace AndroidBridge { + bool secureSet(const char* key, const char* value) { + JNIEnv* env = getJNIEnv(); + if (!env) return false; + + // Use cached references from JNI_OnLoad + if (!g_platformAdapterBridgeClass || !g_secureSetMethod) { + LOGE("PlatformAdapterBridge class or secureSet method not cached"); + return false; + } + + jstring jKey = env->NewStringUTF(key); + jstring jValue = env->NewStringUTF(value); + jboolean result = env->CallStaticBooleanMethod(g_platformAdapterBridgeClass, g_secureSetMethod, jKey, jValue); + + LOGD("secureSet (Android): key=%s, success=%d", key, result); + + env->DeleteLocalRef(jKey); + env->DeleteLocalRef(jValue); + + return result; + } + + bool secureGet(const char* key, std::string& outValue) { + JNIEnv* env = getJNIEnv(); + if (!env) return false; + + // Use cached references from JNI_OnLoad + if (!g_platformAdapterBridgeClass || !g_secureGetMethod) { + LOGE("PlatformAdapterBridge class or secureGet method not cached"); + return false; + } + + jstring jKey = env->NewStringUTF(key); + jstring jResult = (jstring)env->CallStaticObjectMethod(g_platformAdapterBridgeClass, g_secureGetMethod, jKey); + + env->DeleteLocalRef(jKey); + + if (jResult == nullptr) { + LOGD("secureGet (Android): key=%s not found", key); + return false; + } + + const char* resultStr = env->GetStringUTFChars(jResult, nullptr); + if (resultStr) { + outValue = resultStr; + env->ReleaseStringUTFChars(jResult, resultStr); + } + env->DeleteLocalRef(jResult); + + LOGD("secureGet (Android): key=%s found", key); + return !outValue.empty(); + } + + bool secureDelete(const char* key) { + JNIEnv* env = getJNIEnv(); + if (!env) return false; + + // Use cached references from JNI_OnLoad + if (!g_platformAdapterBridgeClass || !g_secureDeleteMethod) { + LOGE("PlatformAdapterBridge class or secureDelete method not cached"); + return false; + } + + jstring jKey = env->NewStringUTF(key); + jboolean result = env->CallStaticBooleanMethod(g_platformAdapterBridgeClass, g_secureDeleteMethod, jKey); + + LOGD("secureDelete (Android): key=%s, success=%d", key, result); + + env->DeleteLocalRef(jKey); + + return result; + } + + bool secureExists(const char* key) { + // For secureExists, we'll try secureGet and check if value is non-empty + // since we don't have a cached method for it + std::string value; + return secureGet(key, value); + } + + std::string getPersistentDeviceUUID() { + JNIEnv* env = getJNIEnv(); + if (!env) return ""; + + // Use cached references from JNI_OnLoad + if (!g_platformAdapterBridgeClass || !g_getPersistentDeviceUUIDMethod) { + LOGE("PlatformAdapterBridge class or getPersistentDeviceUUID method not cached"); + return ""; + } + + jstring jResult = (jstring)env->CallStaticObjectMethod(g_platformAdapterBridgeClass, g_getPersistentDeviceUUIDMethod); + if (!jResult) return ""; + + const char* resultStr = env->GetStringUTFChars(jResult, nullptr); + std::string uuid = resultStr ? resultStr : ""; + + if (resultStr) env->ReleaseStringUTFChars(jResult, resultStr); + env->DeleteLocalRef(jResult); + + LOGD("getPersistentDeviceUUID (Android): %s", uuid.c_str()); + return uuid; + } + + // HTTP POST for device registration (synchronous) + // Returns: (success, statusCode, responseBody, errorMessage) + std::tuple httpPostSync( + const std::string& url, + const std::string& jsonBody, + const std::string& supabaseKey + ) { + JNIEnv* env = getJNIEnv(); + if (!env) { + return {false, 0, "", "JNI not available"}; + } + + // Use cached references from JNI_OnLoad + if (!g_platformAdapterBridgeClass || !g_httpPostSyncMethod) { + LOGE("PlatformAdapterBridge class or httpPostSync method not cached"); + return {false, 0, "", "Bridge class/method not cached"}; + } + + if (!g_httpResponseClass || !g_httpResponse_successField) { + LOGE("HttpResponse class or fields not cached"); + return {false, 0, "", "HttpResponse class/fields not cached"}; + } + + LOGI("httpPostSync to: %s", url.c_str()); + + jstring jUrl = env->NewStringUTF(url.c_str()); + jstring jBody = env->NewStringUTF(jsonBody.c_str()); + jstring jKey = supabaseKey.empty() ? nullptr : env->NewStringUTF(supabaseKey.c_str()); + + jobject response = env->CallStaticObjectMethod(g_platformAdapterBridgeClass, g_httpPostSyncMethod, jUrl, jBody, jKey); + + env->DeleteLocalRef(jUrl); + env->DeleteLocalRef(jBody); + if (jKey) env->DeleteLocalRef(jKey); + + if (!response) { + LOGE("httpPostSync returned null response"); + return {false, 0, "", "httpPostSync returned null"}; + } + + // Extract fields from HttpResponse using cached field IDs + bool success = env->GetBooleanField(response, g_httpResponse_successField); + int statusCode = env->GetIntField(response, g_httpResponse_statusCodeField); + + std::string responseBody; + jstring jResponseBody = (jstring)env->GetObjectField(response, g_httpResponse_responseBodyField); + if (jResponseBody) { + const char* str = env->GetStringUTFChars(jResponseBody, nullptr); + if (str) { + responseBody = str; + env->ReleaseStringUTFChars(jResponseBody, str); + } + env->DeleteLocalRef(jResponseBody); + } + + std::string errorMessage; + jstring jErrorMessage = (jstring)env->GetObjectField(response, g_httpResponse_errorMessageField); + if (jErrorMessage) { + const char* str = env->GetStringUTFChars(jErrorMessage, nullptr); + if (str) { + errorMessage = str; + env->ReleaseStringUTFChars(jErrorMessage, str); + } + env->DeleteLocalRef(jErrorMessage); + } + + env->DeleteLocalRef(response); + + LOGI("httpPostSync result: success=%d statusCode=%d", success, statusCode); + + return {success, statusCode, responseBody, errorMessage}; + } + + // Device info methods - use cached references from JNI_OnLoad + std::string getDeviceModel() { + JNIEnv* env = getJNIEnv(); + if (!env) return "Unknown"; + + // Use cached references + if (!g_platformAdapterBridgeClass || !g_getDeviceModelMethod) { + LOGE("PlatformAdapterBridge class or getDeviceModel method not cached"); + return "Unknown"; + } + + jstring result = (jstring)env->CallStaticObjectMethod(g_platformAdapterBridgeClass, g_getDeviceModelMethod); + + if (!result) return "Unknown"; + + const char* str = env->GetStringUTFChars(result, nullptr); + std::string modelName = str ? str : "Unknown"; + env->ReleaseStringUTFChars(result, str); + env->DeleteLocalRef(result); + + LOGD("getDeviceModel (Android): %s", modelName.c_str()); + return modelName; + } + + std::string getOSVersion() { + JNIEnv* env = getJNIEnv(); + if (!env) return "Unknown"; + + // Use cached references + if (!g_platformAdapterBridgeClass || !g_getOSVersionMethod) { + LOGE("PlatformAdapterBridge class or getOSVersion method not cached"); + return "Unknown"; + } + + jstring result = (jstring)env->CallStaticObjectMethod(g_platformAdapterBridgeClass, g_getOSVersionMethod); + + if (!result) return "Unknown"; + + const char* str = env->GetStringUTFChars(result, nullptr); + std::string version = str ? str : "Unknown"; + env->ReleaseStringUTFChars(result, str); + env->DeleteLocalRef(result); + + return version; + } + + std::string getChipName() { + JNIEnv* env = getJNIEnv(); + if (!env) return "Unknown"; + + // Use cached references + if (!g_platformAdapterBridgeClass || !g_getChipNameMethod) { + LOGE("PlatformAdapterBridge class or getChipName method not cached"); + return "Unknown"; + } + + jstring result = (jstring)env->CallStaticObjectMethod(g_platformAdapterBridgeClass, g_getChipNameMethod); + + if (!result) return "Unknown"; + + const char* str = env->GetStringUTFChars(result, nullptr); + std::string chipName = str ? str : "Unknown"; + env->ReleaseStringUTFChars(result, str); + env->DeleteLocalRef(result); + + return chipName; + } + + uint64_t getTotalMemory() { + JNIEnv* env = getJNIEnv(); + if (!env) return 0; + + // Use cached references + if (!g_platformAdapterBridgeClass || !g_getTotalMemoryMethod) { + LOGE("PlatformAdapterBridge class or getTotalMemory method not cached"); + return 0; + } + + jlong result = env->CallStaticLongMethod(g_platformAdapterBridgeClass, g_getTotalMemoryMethod); + + return static_cast(result); + } + + uint64_t getAvailableMemory() { + JNIEnv* env = getJNIEnv(); + if (!env) return 0; + + // Use cached references + if (!g_platformAdapterBridgeClass || !g_getAvailableMemoryMethod) { + LOGE("PlatformAdapterBridge class or getAvailableMemory method not cached"); + return 0; + } + + jlong result = env->CallStaticLongMethod(g_platformAdapterBridgeClass, g_getAvailableMemoryMethod); + + return static_cast(result); + } + + int getCoreCount() { + JNIEnv* env = getJNIEnv(); + if (!env) return 1; + + // Use cached references + if (!g_platformAdapterBridgeClass || !g_getCoreCountMethod) { + LOGE("PlatformAdapterBridge class or getCoreCount method not cached"); + return 1; + } + + jint result = env->CallStaticIntMethod(g_platformAdapterBridgeClass, g_getCoreCountMethod); + + return static_cast(result); + } + + std::string getArchitecture() { + JNIEnv* env = getJNIEnv(); + if (!env) return "unknown"; + + // Use cached references + if (!g_platformAdapterBridgeClass || !g_getArchitectureMethod) { + LOGE("PlatformAdapterBridge class or getArchitecture method not cached"); + return "unknown"; + } + + jstring result = (jstring)env->CallStaticObjectMethod(g_platformAdapterBridgeClass, g_getArchitectureMethod); + + if (!result) return "unknown"; + + const char* str = env->GetStringUTFChars(result, nullptr); + std::string arch = str ? str : "unknown"; + env->ReleaseStringUTFChars(result, str); + env->DeleteLocalRef(result); + + return arch; + } + + std::string getGPUFamily() { + JNIEnv* env = getJNIEnv(); + if (!env) return "unknown"; + + // Use cached references + if (!g_platformAdapterBridgeClass || !g_getGPUFamilyMethod) { + LOGE("PlatformAdapterBridge class or getGPUFamily method not cached"); + return "unknown"; + } + + jstring result = (jstring)env->CallStaticObjectMethod(g_platformAdapterBridgeClass, g_getGPUFamilyMethod); + + if (!result) return "unknown"; + + const char* str = env->GetStringUTFChars(result, nullptr); + std::string gpuFamily = str ? str : "unknown"; + env->ReleaseStringUTFChars(result, str); + env->DeleteLocalRef(result); + + return gpuFamily; + } + + bool isTablet() { + JNIEnv* env = getJNIEnv(); + if (!env) return false; + + // Use cached references + if (!g_platformAdapterBridgeClass || !g_isTabletMethod) { + LOGE("PlatformAdapterBridge class or isTablet method not cached"); + return false; + } + + jboolean result = env->CallStaticBooleanMethod(g_platformAdapterBridgeClass, g_isTabletMethod); + return result == JNI_TRUE; + } +} // namespace AndroidBridge +#elif defined(__APPLE__) +#include +// iOS platform bridge for Keychain, HTTP, and Device Info +extern "C" { + // Secure storage + bool PlatformAdapter_secureSet(const char* key, const char* value); + bool PlatformAdapter_secureGet(const char* key, char** outValue); + bool PlatformAdapter_secureDelete(const char* key); + bool PlatformAdapter_secureExists(const char* key); + + // Device type detection + bool PlatformAdapter_isTablet(void); + bool PlatformAdapter_getPersistentDeviceUUID(char** outValue); + + // Device info (synchronous) + bool PlatformAdapter_getDeviceModel(char** outValue); + bool PlatformAdapter_getOSVersion(char** outValue); + bool PlatformAdapter_getChipName(char** outValue); + uint64_t PlatformAdapter_getTotalMemory(void); + uint64_t PlatformAdapter_getAvailableMemory(void); + int PlatformAdapter_getCoreCount(void); + bool PlatformAdapter_getArchitecture(char** outValue); + bool PlatformAdapter_getGPUFamily(char** outValue); + + // HTTP + bool PlatformAdapter_httpPostSync( + const char* url, + const char* jsonBody, + const char* supabaseKey, + int* outStatusCode, + char** outResponseBody, + char** outErrorMessage + ); +} +#define LOGI(...) printf("[InitBridge] "); printf(__VA_ARGS__); printf("\n") +#define LOGD(...) printf("[InitBridge DEBUG] "); printf(__VA_ARGS__); printf("\n") +#define LOGW(...) printf("[InitBridge WARN] "); printf(__VA_ARGS__); printf("\n") +#define LOGE(...) printf("[InitBridge ERROR] "); printf(__VA_ARGS__); printf("\n") +#else +#include +#define LOGI(...) printf("[InitBridge] "); printf(__VA_ARGS__); printf("\n") +#define LOGD(...) printf("[InitBridge DEBUG] "); printf(__VA_ARGS__); printf("\n") +#define LOGW(...) printf("[InitBridge WARN] "); printf(__VA_ARGS__); printf("\n") +#define LOGE(...) printf("[InitBridge ERROR] "); printf(__VA_ARGS__); printf("\n") +#endif + +namespace runanywhere { +namespace bridges { + +// ============================================================================= +// Static storage for callbacks (needed for C function pointers) +// ============================================================================= + +static PlatformCallbacks* g_platformCallbacks = nullptr; + +// ============================================================================= +// C Callback Implementations (called by RACommons) +// ============================================================================= + +static rac_bool_t platformFileExistsCallback(const char* path, void* userData) { + if (!path || !g_platformCallbacks || !g_platformCallbacks->fileExists) { + return RAC_FALSE; + } + return g_platformCallbacks->fileExists(path) ? RAC_TRUE : RAC_FALSE; +} + +static rac_result_t platformFileReadCallback( + const char* path, + void** outData, + size_t* outSize, + void* userData +) { + if (!path || !outData || !outSize) { + return RAC_ERROR_NULL_POINTER; + } + + if (!g_platformCallbacks || !g_platformCallbacks->fileRead) { + return RAC_ERROR_NOT_SUPPORTED; + } + + try { + std::string content = g_platformCallbacks->fileRead(path); + if (content.empty()) { + return RAC_ERROR_FILE_NOT_FOUND; + } + + // Allocate buffer and copy data + char* buffer = static_cast(malloc(content.size())); + if (!buffer) { + return RAC_ERROR_OUT_OF_MEMORY; + } + + memcpy(buffer, content.data(), content.size()); + *outData = buffer; + *outSize = content.size(); + + return RAC_SUCCESS; + } catch (...) { + return RAC_ERROR_FILE_NOT_FOUND; + } +} + +static rac_result_t platformFileWriteCallback( + const char* path, + const void* data, + size_t size, + void* userData +) { + if (!path || !data) { + return RAC_ERROR_NULL_POINTER; + } + + if (!g_platformCallbacks || !g_platformCallbacks->fileWrite) { + return RAC_ERROR_NOT_SUPPORTED; + } + + try { + std::string content(static_cast(data), size); + bool success = g_platformCallbacks->fileWrite(path, content); + return success ? RAC_SUCCESS : RAC_ERROR_FILE_WRITE_FAILED; + } catch (...) { + return RAC_ERROR_FILE_WRITE_FAILED; + } +} + +static rac_result_t platformFileDeleteCallback(const char* path, void* userData) { + if (!path) { + return RAC_ERROR_NULL_POINTER; + } + + if (!g_platformCallbacks || !g_platformCallbacks->fileDelete) { + return RAC_ERROR_NOT_SUPPORTED; + } + + try { + bool success = g_platformCallbacks->fileDelete(path); + return success ? RAC_SUCCESS : RAC_ERROR_FILE_NOT_FOUND; + } catch (...) { + return RAC_ERROR_FILE_NOT_FOUND; + } +} + +static rac_result_t platformSecureGetCallback( + const char* key, + char** outValue, + void* userData +) { + if (!key || !outValue) { + return RAC_ERROR_NULL_POINTER; + } + + if (!g_platformCallbacks || !g_platformCallbacks->secureGet) { + return RAC_ERROR_NOT_SUPPORTED; + } + + try { + std::string value = g_platformCallbacks->secureGet(key); + if (value.empty()) { + return RAC_ERROR_SECURE_STORAGE_FAILED; + } + + *outValue = strdup(value.c_str()); + return *outValue ? RAC_SUCCESS : RAC_ERROR_OUT_OF_MEMORY; + } catch (...) { + return RAC_ERROR_SECURE_STORAGE_FAILED; + } +} + +static rac_result_t platformSecureSetCallback( + const char* key, + const char* value, + void* userData +) { + if (!key || !value) { + return RAC_ERROR_NULL_POINTER; + } + + if (!g_platformCallbacks || !g_platformCallbacks->secureSet) { + return RAC_ERROR_NOT_SUPPORTED; + } + + try { + bool success = g_platformCallbacks->secureSet(key, value); + return success ? RAC_SUCCESS : RAC_ERROR_SECURE_STORAGE_FAILED; + } catch (...) { + return RAC_ERROR_SECURE_STORAGE_FAILED; + } +} + +static rac_result_t platformSecureDeleteCallback(const char* key, void* userData) { + if (!key) { + return RAC_ERROR_NULL_POINTER; + } + + if (!g_platformCallbacks || !g_platformCallbacks->secureDelete) { + return RAC_ERROR_NOT_SUPPORTED; + } + + try { + bool success = g_platformCallbacks->secureDelete(key); + return success ? RAC_SUCCESS : RAC_ERROR_SECURE_STORAGE_FAILED; + } catch (...) { + return RAC_ERROR_SECURE_STORAGE_FAILED; + } +} + +static void platformLogCallback( + rac_log_level_t level, + const char* category, + const char* message, + void* userData +) { + if (!message) return; + + // Always log to Android/iOS native logging + const char* levelStr = "INFO"; + switch (level) { + case RAC_LOG_TRACE: levelStr = "TRACE"; break; + case RAC_LOG_DEBUG: levelStr = "DEBUG"; break; + case RAC_LOG_INFO: levelStr = "INFO"; break; + case RAC_LOG_WARNING: levelStr = "WARN"; break; + case RAC_LOG_ERROR: levelStr = "ERROR"; break; + case RAC_LOG_FATAL: levelStr = "FATAL"; break; + } + + const char* cat = category ? category : "RAC"; + +#if defined(ANDROID) || defined(__ANDROID__) + int androidLevel = ANDROID_LOG_INFO; + switch (level) { + case RAC_LOG_TRACE: + case RAC_LOG_DEBUG: androidLevel = ANDROID_LOG_DEBUG; break; + case RAC_LOG_INFO: androidLevel = ANDROID_LOG_INFO; break; + case RAC_LOG_WARNING: androidLevel = ANDROID_LOG_WARN; break; + case RAC_LOG_ERROR: + case RAC_LOG_FATAL: androidLevel = ANDROID_LOG_ERROR; break; + } + __android_log_print(androidLevel, cat, "%s", message); +#else + printf("[%s] [%s] %s\n", levelStr, cat, message); +#endif + + // Also forward to JS callback if available + if (g_platformCallbacks && g_platformCallbacks->log) { + g_platformCallbacks->log(static_cast(level), cat, message); + } +} + +static int64_t platformNowMsCallback(void* userData) { + if (g_platformCallbacks && g_platformCallbacks->nowMs) { + return g_platformCallbacks->nowMs(); + } + + // Fallback to system time + auto now = std::chrono::system_clock::now(); + auto ms = std::chrono::duration_cast( + now.time_since_epoch() + ).count(); + return static_cast(ms); +} + +static rac_result_t platformGetMemoryInfoCallback(rac_memory_info_t* outInfo, void* userData) { + // Memory info not easily available in React Native + // Return not supported - platform can query via JS if needed + return RAC_ERROR_NOT_SUPPORTED; +} + +static void platformTrackErrorCallback(const char* errorJson, void* userData) { + // Forward error tracking to logging for now + if (errorJson) { + LOGE("Track error: %s", errorJson); + } +} + +// ============================================================================= +// InitBridge Implementation +// ============================================================================= + +InitBridge& InitBridge::shared() { + static InitBridge instance; + return instance; +} + +InitBridge::~InitBridge() { + shutdown(); +} + +void InitBridge::setPlatformCallbacks(const PlatformCallbacks& callbacks) { + callbacks_ = callbacks; + + // Store in global for C callbacks + static PlatformCallbacks storedCallbacks; + storedCallbacks = callbacks_; + g_platformCallbacks = &storedCallbacks; + + LOGI("Platform callbacks registered"); +} + +void InitBridge::registerPlatformAdapter() { + if (adapterRegistered_) { + return; + } + + // Reset adapter + memset(&adapter_, 0, sizeof(adapter_)); + + // File operations + adapter_.file_exists = platformFileExistsCallback; + adapter_.file_read = platformFileReadCallback; + adapter_.file_write = platformFileWriteCallback; + adapter_.file_delete = platformFileDeleteCallback; + + // Secure storage + adapter_.secure_get = platformSecureGetCallback; + adapter_.secure_set = platformSecureSetCallback; + adapter_.secure_delete = platformSecureDeleteCallback; + + // Logging + adapter_.log = platformLogCallback; + + // Clock + adapter_.now_ms = platformNowMsCallback; + + // Memory info (not implemented) + adapter_.get_memory_info = platformGetMemoryInfoCallback; + + // Error tracking + adapter_.track_error = platformTrackErrorCallback; + + // HTTP download (handled by JS layer) + adapter_.http_download = nullptr; + adapter_.http_download_cancel = nullptr; + + // Archive extraction (handled by JS layer) + adapter_.extract_archive = nullptr; + + adapter_.user_data = nullptr; + + // Register with RACommons + rac_result_t result = rac_set_platform_adapter(&adapter_); + if (result == RAC_SUCCESS) { + adapterRegistered_ = true; + LOGI("Platform adapter registered with RACommons"); + } else { + LOGE("Failed to register platform adapter: %d", result); + } +} + +rac_environment_t InitBridge::toRacEnvironment(SDKEnvironment env) { + switch (env) { + case SDKEnvironment::Development: + return RAC_ENV_DEVELOPMENT; + case SDKEnvironment::Staging: + return RAC_ENV_STAGING; + case SDKEnvironment::Production: + return RAC_ENV_PRODUCTION; + default: + return RAC_ENV_DEVELOPMENT; + } +} + +rac_result_t InitBridge::initialize( + SDKEnvironment environment, + const std::string& apiKey, + const std::string& baseURL, + const std::string& deviceId +) { + if (initialized_) { + LOGI("SDK already initialized"); + return RAC_SUCCESS; + } + + environment_ = environment; + apiKey_ = apiKey; + baseURL_ = baseURL; + deviceId_ = deviceId; + + // Step 1: Register platform adapter FIRST + registerPlatformAdapter(); + + // Step 2: Configure logging based on environment + rac_environment_t racEnv = toRacEnvironment(environment); + rac_result_t logResult = rac_configure_logging(racEnv); + if (logResult != RAC_SUCCESS) { + LOGE("Failed to configure logging: %d", logResult); + // Continue anyway - logging is not critical + } + + // Step 3: Initialize RACommons using rac_init + // NOTE: rac_init takes a config struct, not individual parameters + // The actual auth/state management is done at the platform level + rac_config_t config = {}; + config.platform_adapter = &adapter_; + config.log_level = RAC_LOG_INFO; + config.log_tag = "RunAnywhere"; + config.reserved = nullptr; + + rac_result_t initResult = rac_init(&config); + + if (initResult != RAC_SUCCESS) { + LOGE("Failed to initialize RACommons: %d", initResult); + return initResult; + } + + // Step 4: Initialize SDK config with version (required for device registration) + // This populates rac_sdk_get_config() which device registration uses + // Matches Swift: CppBridge+State.swift initialize() + rac_sdk_config_t sdkConfig = {}; + // Use actual platform (ios/android) as backend only accepts these values +#if defined(__APPLE__) + sdkConfig.platform = "ios"; +#elif defined(ANDROID) || defined(__ANDROID__) + sdkConfig.platform = "android"; +#else + sdkConfig.platform = "ios"; // Default to ios for unknown platforms +#endif + // Use centralized SDK version (set from TypeScript SDKConstants via setSdkVersion) + static std::string s_sdkVersion; + s_sdkVersion = getSdkVersion(); + sdkConfig.sdk_version = s_sdkVersion.c_str(); + sdkConfig.device_id = getPersistentDeviceUUID().c_str(); + + rac_validation_result_t validResult = rac_sdk_init(&sdkConfig); + if (validResult != RAC_VALIDATION_OK) { + LOGW("SDK config validation warning: %d (non-fatal)", validResult); + // Non-fatal - device registration can still work without this + } else { + LOGI("SDK config initialized with version: %s", sdkConfig.sdk_version); + } + + initialized_ = true; + LOGI("SDK initialized successfully for environment %d", static_cast(environment)); + + return RAC_SUCCESS; +} + +rac_result_t InitBridge::setBaseDirectory(const std::string& documentsPath) { + if (documentsPath.empty()) { + LOGE("Base directory path is empty"); + return RAC_ERROR_NULL_POINTER; + } + + rac_result_t result = rac_model_paths_set_base_dir(documentsPath.c_str()); + if (result == RAC_SUCCESS) { + LOGI("Model paths base directory set to: %s", documentsPath.c_str()); + } else { + LOGE("Failed to set model paths base directory: %d", result); + } + + return result; +} + +void InitBridge::shutdown() { + if (!initialized_) { + return; + } + + LOGI("Shutting down SDK..."); + + // Shutdown RACommons + rac_shutdown(); + + // Note: Platform adapter callbacks remain valid (static) + + initialized_ = false; + LOGI("SDK shutdown complete"); +} + +// ============================================================================= +// Secure Storage Methods +// Matches Swift: KeychainManager +// ============================================================================= + +bool InitBridge::secureSet(const std::string& key, const std::string& value) { +#if defined(__APPLE__) + // Use iOS Keychain bridge directly + bool success = PlatformAdapter_secureSet(key.c_str(), value.c_str()); + LOGD("secureSet (iOS): key=%s, success=%d", key.c_str(), success); + return success; +#elif defined(ANDROID) || defined(__ANDROID__) + // Use Android JNI bridge + bool success = AndroidBridge::secureSet(key.c_str(), value.c_str()); + LOGD("secureSet (Android): key=%s, success=%d", key.c_str(), success); + return success; +#else + if (!g_platformCallbacks || !g_platformCallbacks->secureSet) { + LOGE("secureSet: Platform callback not available"); + return false; + } + + try { + bool success = g_platformCallbacks->secureSet(key, value); + LOGD("secureSet: key=%s, success=%d", key.c_str(), success); + return success; + } catch (...) { + LOGE("secureSet: Exception for key=%s", key.c_str()); + return false; + } +#endif +} + +bool InitBridge::secureGet(const std::string& key, std::string& outValue) { +#if defined(__APPLE__) + // Use iOS Keychain bridge directly + char* value = nullptr; + bool success = PlatformAdapter_secureGet(key.c_str(), &value); + if (success && value != nullptr) { + outValue = value; + free(value); + LOGD("secureGet (iOS): key=%s found", key.c_str()); + return true; + } + LOGD("secureGet (iOS): key=%s not found", key.c_str()); + return false; +#elif defined(ANDROID) || defined(__ANDROID__) + // Use Android JNI bridge + bool success = AndroidBridge::secureGet(key.c_str(), outValue); + LOGD("secureGet (Android): key=%s, found=%d", key.c_str(), success); + return success; +#else + if (!g_platformCallbacks || !g_platformCallbacks->secureGet) { + LOGE("secureGet: Platform callback not available"); + return false; + } + + try { + std::string value = g_platformCallbacks->secureGet(key); + if (value.empty()) { + LOGD("secureGet: key=%s not found", key.c_str()); + return false; + } + outValue = value; + LOGD("secureGet: key=%s found", key.c_str()); + return true; + } catch (...) { + LOGE("secureGet: Exception for key=%s", key.c_str()); + return false; + } +#endif +} + +bool InitBridge::secureDelete(const std::string& key) { +#if defined(__APPLE__) + // Use iOS Keychain bridge directly + bool success = PlatformAdapter_secureDelete(key.c_str()); + LOGD("secureDelete (iOS): key=%s, success=%d", key.c_str(), success); + return success; +#elif defined(ANDROID) || defined(__ANDROID__) + // Use Android JNI bridge + bool success = AndroidBridge::secureDelete(key.c_str()); + LOGD("secureDelete (Android): key=%s, success=%d", key.c_str(), success); + return success; +#else + if (!g_platformCallbacks || !g_platformCallbacks->secureDelete) { + LOGE("secureDelete: Platform callback not available"); + return false; + } + + try { + bool success = g_platformCallbacks->secureDelete(key); + LOGD("secureDelete: key=%s, success=%d", key.c_str(), success); + return success; + } catch (...) { + LOGE("secureDelete: Exception for key=%s", key.c_str()); + return false; + } +#endif +} + +bool InitBridge::secureExists(const std::string& key) { +#if defined(__APPLE__) + // Use iOS Keychain bridge directly + bool exists = PlatformAdapter_secureExists(key.c_str()); + LOGD("secureExists (iOS): key=%s, exists=%d", key.c_str(), exists); + return exists; +#elif defined(ANDROID) || defined(__ANDROID__) + // Use Android JNI bridge + bool exists = AndroidBridge::secureExists(key.c_str()); + LOGD("secureExists (Android): key=%s, exists=%d", key.c_str(), exists); + return exists; +#else + if (!g_platformCallbacks || !g_platformCallbacks->secureGet) { + LOGE("secureExists: Platform callback not available"); + return false; + } + + try { + std::string value = g_platformCallbacks->secureGet(key); + bool exists = !value.empty(); + LOGD("secureExists: key=%s, exists=%d", key.c_str(), exists); + return exists; + } catch (...) { + LOGE("secureExists: Exception for key=%s", key.c_str()); + return false; + } +#endif +} + +std::string InitBridge::getPersistentDeviceUUID() { + // Key matches Swift: KeychainManager.KeychainKey.deviceUUID + static const char* DEVICE_UUID_KEY = "com.runanywhere.sdk.device.uuid"; + + // Thread-safe: cached result (matches Swift pattern) + static std::string cachedUUID; + static std::mutex uuidMutex; + + { + std::lock_guard lock(uuidMutex); + if (!cachedUUID.empty()) { + return cachedUUID; + } + } + + // Strategy 1: Try to load from secure storage (survives reinstalls) + std::string storedUUID; + if (secureGet(DEVICE_UUID_KEY, storedUUID) && !storedUUID.empty()) { + std::lock_guard lock(uuidMutex); + cachedUUID = storedUUID; + LOGI("Loaded persistent device UUID from keychain"); + return cachedUUID; + } + + // Strategy 2: Generate new UUID + // Generate a UUID4-like string: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx + auto generateUUID = []() -> std::string { + static const char hexChars[] = "0123456789abcdef"; + + // Use high-resolution clock and random for seeding + auto now = std::chrono::high_resolution_clock::now(); + auto seed = static_cast( + now.time_since_epoch().count() ^ + reinterpret_cast(&now) + ); + srand(seed); + + char uuid[37]; + for (int i = 0; i < 36; i++) { + if (i == 8 || i == 13 || i == 18 || i == 23) { + uuid[i] = '-'; + } else if (i == 14) { + uuid[i] = '4'; // UUID version 4 + } else if (i == 19) { + uuid[i] = hexChars[(rand() & 0x03) | 0x08]; // variant bits + } else { + uuid[i] = hexChars[rand() & 0x0F]; + } + } + uuid[36] = '\0'; + return std::string(uuid); + }; + + std::string newUUID = generateUUID(); + + // Store in secure storage + if (secureSet(DEVICE_UUID_KEY, newUUID)) { + LOGI("Generated and stored new persistent device UUID"); + } else { + LOGW("Generated device UUID but failed to persist (will regenerate on restart)"); + } + + { + std::lock_guard lock(uuidMutex); + cachedUUID = newUUID; + } + + return newUUID; +} + +// ============================================================================= +// Device Info (Synchronous) +// For device registration callback which must be synchronous +// ============================================================================= + +std::string InitBridge::getDeviceModel() { +#if defined(__APPLE__) + char* value = nullptr; + if (PlatformAdapter_getDeviceModel(&value) && value) { + std::string result(value); + free(value); + return result; + } + return "Unknown"; +#elif defined(ANDROID) || defined(__ANDROID__) + return AndroidBridge::getDeviceModel(); +#else + return "Unknown"; +#endif +} + +std::string InitBridge::getOSVersion() { +#if defined(__APPLE__) + char* value = nullptr; + if (PlatformAdapter_getOSVersion(&value) && value) { + std::string result(value); + free(value); + return result; + } + return "Unknown"; +#elif defined(ANDROID) || defined(__ANDROID__) + return AndroidBridge::getOSVersion(); +#else + return "Unknown"; +#endif +} + +std::string InitBridge::getChipName() { +#if defined(__APPLE__) + char* value = nullptr; + if (PlatformAdapter_getChipName(&value) && value) { + std::string result(value); + free(value); + return result; + } + return "Apple Silicon"; +#elif defined(ANDROID) || defined(__ANDROID__) + return AndroidBridge::getChipName(); +#else + return "Unknown"; +#endif +} + +uint64_t InitBridge::getTotalMemory() { +#if defined(__APPLE__) + return PlatformAdapter_getTotalMemory(); +#elif defined(ANDROID) || defined(__ANDROID__) + return AndroidBridge::getTotalMemory(); +#else + return 0; +#endif +} + +uint64_t InitBridge::getAvailableMemory() { +#if defined(__APPLE__) + return PlatformAdapter_getAvailableMemory(); +#elif defined(ANDROID) || defined(__ANDROID__) + return AndroidBridge::getAvailableMemory(); +#else + return 0; +#endif +} + +int InitBridge::getCoreCount() { +#if defined(__APPLE__) + return PlatformAdapter_getCoreCount(); +#elif defined(ANDROID) || defined(__ANDROID__) + return AndroidBridge::getCoreCount(); +#else + return 1; +#endif +} + +std::string InitBridge::getArchitecture() { +#if defined(__APPLE__) + char* value = nullptr; + if (PlatformAdapter_getArchitecture(&value) && value) { + std::string result(value); + free(value); + return result; + } + return "arm64"; +#elif defined(ANDROID) || defined(__ANDROID__) + return AndroidBridge::getArchitecture(); +#else + return "unknown"; +#endif +} + +std::string InitBridge::getGPUFamily() { +#if defined(__APPLE__) + char* value = nullptr; + if (PlatformAdapter_getGPUFamily(&value) && value) { + std::string result(value); + free(value); + return result; + } + return "apple"; // Default GPU family for iOS/macOS +#elif defined(ANDROID) || defined(__ANDROID__) + return AndroidBridge::getGPUFamily(); +#else + return "unknown"; +#endif +} + +bool InitBridge::isTablet() { +#if defined(__APPLE__) + return PlatformAdapter_isTablet(); +#elif defined(ANDROID) || defined(__ANDROID__) + return AndroidBridge::isTablet(); +#else + return false; +#endif +} + +// ============================================================================= +// HTTP POST for Device Registration (Synchronous) +// Matches Swift: CppBridge+Device.swift http_post callback +// ============================================================================= + +std::tuple InitBridge::httpPostSync( + const std::string& url, + const std::string& jsonBody, + const std::string& supabaseKey +) { + LOGI("httpPostSync to: %s", url.c_str()); + +#if defined(ANDROID) || defined(__ANDROID__) + // Android: Call JNI to PlatformAdapterBridge.httpPostSync + return AndroidBridge::httpPostSync(url, jsonBody, supabaseKey); + +#elif defined(__APPLE__) + // iOS: Call PlatformAdapter_httpPostSync via extern C + int statusCode = 0; + char* responseBody = nullptr; + char* errorMessage = nullptr; + + bool success = PlatformAdapter_httpPostSync( + url.c_str(), + jsonBody.c_str(), + supabaseKey.empty() ? nullptr : supabaseKey.c_str(), + &statusCode, + &responseBody, + &errorMessage + ); + + std::string responseBodyStr = responseBody ? responseBody : ""; + std::string errorMessageStr = errorMessage ? errorMessage : ""; + + // Free allocated strings + if (responseBody) free(responseBody); + if (errorMessage) free(errorMessage); + + LOGI("httpPostSync result: success=%d statusCode=%d", success, statusCode); + return {success, statusCode, responseBodyStr, errorMessageStr}; + +#else + // Unsupported platform + LOGE("httpPostSync: Unsupported platform"); + return {false, 0, "", "Unsupported platform"}; +#endif +} + +} // namespace bridges +} // namespace runanywhere diff --git a/sdk/runanywhere-react-native/packages/core/cpp/bridges/InitBridge.hpp b/sdk/runanywhere-react-native/packages/core/cpp/bridges/InitBridge.hpp new file mode 100644 index 000000000..ebc8919cc --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/cpp/bridges/InitBridge.hpp @@ -0,0 +1,306 @@ +/** + * @file InitBridge.hpp + * @brief SDK initialization bridge for React Native + * + * Handles rac_init() and rac_shutdown() lifecycle management. + * Registers platform adapter with callbacks for file I/O, logging, secure storage. + * + * Matches Swift's CppBridge initialization pattern. + */ + +#pragma once + +#include +#include +#include +#include + +// RACommons headers +#include "rac_core.h" +#include "rac_types.h" +#include "rac_platform_adapter.h" +#include "rac_sdk_state.h" +#include "rac_environment.h" +#include "rac_model_paths.h" + +namespace runanywhere { +namespace bridges { + +/** + * @brief Platform callbacks provided by React Native/JavaScript layer + * + * These callbacks are invoked by C++ when platform-specific operations are needed. + */ +struct PlatformCallbacks { + // File operations + std::function fileExists; + std::function fileRead; + std::function fileWrite; + std::function fileDelete; + + // Secure storage (keychain/keystore) + std::function secureGet; + std::function secureSet; + std::function secureDelete; + + // Logging + std::function log; + + // Clock + std::function nowMs; +}; + +/** + * @brief SDK Environment enum matching Swift's SDKEnvironment + */ +enum class SDKEnvironment { + Development = 0, + Staging = 1, + Production = 2 +}; + +/** + * @brief SDK initialization bridge singleton + * + * Manages the lifecycle of the runanywhere-commons SDK. + * Registers platform adapter and initializes state. + */ +class InitBridge { +public: + static InitBridge& shared(); + + /** + * @brief Register platform callbacks + * + * Must be called BEFORE initialize() to set up platform operations. + * + * @param callbacks Platform-specific callbacks + */ + void setPlatformCallbacks(const PlatformCallbacks& callbacks); + + /** + * @brief Initialize the SDK + * + * 1. Registers platform adapter with RACommons + * 2. Configures logging for environment + * 3. Initializes SDK state + * + * @param environment SDK environment (development, staging, production) + * @param apiKey API key for authentication + * @param baseURL Base URL for API requests + * @param deviceId Persistent device identifier + * @return RAC_SUCCESS or error code + */ + rac_result_t initialize(SDKEnvironment environment, + const std::string& apiKey, + const std::string& baseURL, + const std::string& deviceId); + + /** + * @brief Set base directory for model paths + * + * Must be called after initialize() and before using model path utilities. + * Mirrors Swift's CppBridge.ModelPaths.setBaseDirectory(). + * + * @param documentsPath Path to Documents directory + * @return RAC_SUCCESS or error code + */ + rac_result_t setBaseDirectory(const std::string& documentsPath); + + /** + * @brief Shutdown the SDK + */ + void shutdown(); + + /** + * @brief Check if SDK is initialized + */ + bool isInitialized() const { return initialized_; } + + /** + * @brief Get current environment + */ + SDKEnvironment getEnvironment() const { return environment_; } + + /** + * @brief Convert SDK environment to RAC environment + */ + static rac_environment_t toRacEnvironment(SDKEnvironment env); + + // ========================================================================= + // Secure Storage Methods + // Matches Swift: KeychainManager + // ========================================================================= + + /** + * @brief Store a value in secure storage (Keychain/Keystore) + * @param key Storage key + * @param value Value to store + * @return true if successful + */ + bool secureSet(const std::string& key, const std::string& value); + + /** + * @brief Get a value from secure storage + * @param key Storage key + * @param outValue Output value (empty if not found) + * @return true if value found and retrieved + */ + bool secureGet(const std::string& key, std::string& outValue); + + /** + * @brief Delete a value from secure storage + * @param key Storage key + * @return true if deleted or didn't exist + */ + bool secureDelete(const std::string& key); + + /** + * @brief Check if a key exists in secure storage + * @param key Storage key + * @return true if key exists + */ + bool secureExists(const std::string& key); + + /** + * @brief Get or create persistent device UUID + * + * Strategy (matches Swift DeviceIdentity): + * 1. Try to load from secure storage (survives reinstalls) + * 2. If not found, generate new UUID and store + * + * @return Persistent device UUID + */ + std::string getPersistentDeviceUUID(); + + // ========================================================================= + // Device Info (Synchronous) + // For device registration callback which must be synchronous + // ========================================================================= + + /** + * @brief Get device model name (e.g., "iPhone 16 Pro Max") + */ + std::string getDeviceModel(); + + /** + * @brief Get OS version (e.g., "18.2") + */ + std::string getOSVersion(); + + /** + * @brief Get chip name (e.g., "A18 Pro") + */ + std::string getChipName(); + + /** + * @brief Get total memory in bytes + */ + uint64_t getTotalMemory(); + + /** + * @brief Get available memory in bytes + */ + uint64_t getAvailableMemory(); + + /** + * @brief Get CPU core count + */ + int getCoreCount(); + + /** + * @brief Get architecture (e.g., "arm64") + */ + std::string getArchitecture(); + + /** + * @brief Get GPU family (e.g., "mali", "adreno") + */ + std::string getGPUFamily(); + + /** + * @brief Check if device is a tablet + * Uses platform-specific detection (UIDevice on iOS, Configuration on Android) + * Matches Swift SDK: device.userInterfaceIdiom == .pad + */ + bool isTablet(); + + // ========================================================================= + // Configuration Getters (for HTTP requests in production mode) + // ========================================================================= + + /** + * @brief Get configured API key + */ + std::string getApiKey() const { return apiKey_; } + + /** + * @brief Get configured base URL + */ + std::string getBaseURL() const { return baseURL_; } + + /** + * @brief Set SDK version (passed from TypeScript layer) + * Must be called during initialization to ensure consistency + */ + void setSdkVersion(const std::string& version) { sdkVersion_ = version; } + + /** + * @brief Get SDK version + * Returns centralized version passed from TypeScript SDKConstants + */ + std::string getSdkVersion() const { return sdkVersion_.empty() ? "0.2.0" : sdkVersion_; } + + // Note: getEnvironment() already defined above in "SDK Environment" section + + // ========================================================================= + // HTTP Methods for Device Registration + // Matches Swift: CppBridge+Device.swift http_post callback + // ========================================================================= + + /** + * @brief Synchronous HTTP POST for device registration + * + * Uses native URLSession (iOS) or HttpURLConnection (Android). + * Required by C++ rac_device_manager which expects synchronous HTTP. + * + * @param url Full URL to POST to + * @param jsonBody JSON body string + * @param supabaseKey Supabase API key (for dev mode, empty for prod) + * @return tuple + */ + std::tuple httpPostSync( + const std::string& url, + const std::string& jsonBody, + const std::string& supabaseKey + ); + +private: + InitBridge() = default; + ~InitBridge(); + + // Disable copy/move + InitBridge(const InitBridge&) = delete; + InitBridge& operator=(const InitBridge&) = delete; + + void registerPlatformAdapter(); + + bool initialized_ = false; + bool adapterRegistered_ = false; + SDKEnvironment environment_ = SDKEnvironment::Development; + + // Configuration stored at initialization + std::string apiKey_; + std::string baseURL_; + std::string deviceId_; + std::string sdkVersion_; // SDK version from TypeScript SDKConstants + + // Platform adapter - must persist for C++ to call + rac_platform_adapter_t adapter_{}; + + // Platform callbacks from JS layer + PlatformCallbacks callbacks_{}; +}; + +} // namespace bridges +} // namespace runanywhere diff --git a/sdk/runanywhere-react-native/packages/core/cpp/bridges/ModelRegistryBridge.cpp b/sdk/runanywhere-react-native/packages/core/cpp/bridges/ModelRegistryBridge.cpp new file mode 100644 index 000000000..0051ece22 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/cpp/bridges/ModelRegistryBridge.cpp @@ -0,0 +1,394 @@ +/** + * @file ModelRegistryBridge.cpp + * @brief C++ bridge for model registry operations. + * + * Mirrors Swift's CppBridge+ModelRegistry.swift pattern. + */ + +#include "ModelRegistryBridge.hpp" +#include "rac_core.h" // For rac_get_model_registry() +#include + +// Platform-specific logging +#if defined(ANDROID) || defined(__ANDROID__) +#include +#define LOG_TAG "ModelRegistryBridge" +#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) +#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__) +#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) +#else +#include +#define LOGI(...) printf("[ModelRegistryBridge] "); printf(__VA_ARGS__); printf("\n") +#define LOGD(...) printf("[ModelRegistryBridge DEBUG] "); printf(__VA_ARGS__); printf("\n") +#define LOGE(...) printf("[ModelRegistryBridge ERROR] "); printf(__VA_ARGS__); printf("\n") +#endif + +namespace runanywhere { +namespace bridges { + +ModelRegistryBridge& ModelRegistryBridge::shared() { + static ModelRegistryBridge instance; + return instance; +} + +ModelRegistryBridge::~ModelRegistryBridge() { + shutdown(); +} + +rac_result_t ModelRegistryBridge::initialize() { + if (handle_) { + LOGD("Model registry already initialized"); + return RAC_SUCCESS; + } + + // Use the GLOBAL model registry (same as Swift SDK) + // This ensures models registered by backends are visible to the SDK + handle_ = rac_get_model_registry(); + + if (handle_) { + LOGI("Using global C++ model registry"); + return RAC_SUCCESS; + } else { + LOGE("Failed to get global model registry"); + return RAC_ERROR_NOT_INITIALIZED; + } +} + +void ModelRegistryBridge::shutdown() { + // NOTE: We're using the GLOBAL registry - DO NOT clear the handle + // The global registry persists for the lifetime of the app + // Just log that shutdown was called, but don't actually release the handle + LOGI("Model registry shutdown called (global registry handle retained)"); + // DO NOT: handle_ = nullptr; +} + +ModelInfo ModelRegistryBridge::fromRac(const rac_model_info_t& cModel) { + ModelInfo model; + + model.id = cModel.id ? cModel.id : ""; + model.name = cModel.name ? cModel.name : ""; + model.description = cModel.description ? cModel.description : ""; + model.category = cModel.category; + model.format = cModel.format; + model.framework = cModel.framework; + model.downloadUrl = cModel.download_url ? cModel.download_url : ""; + model.localPath = cModel.local_path ? cModel.local_path : ""; + model.downloadSize = cModel.download_size; + model.memoryRequired = cModel.memory_required; + model.contextLength = cModel.context_length; + model.supportsThinking = cModel.supports_thinking == RAC_TRUE; + model.source = cModel.source; + + // Copy tags + if (cModel.tags && cModel.tag_count > 0) { + for (size_t i = 0; i < cModel.tag_count; i++) { + if (cModel.tags[i]) { + model.tags.push_back(cModel.tags[i]); + } + } + } + + // Check if downloaded + model.isDownloaded = !model.localPath.empty() && model.localPath[0] != '\0'; + + return model; +} + +void ModelRegistryBridge::toRac(const ModelInfo& model, rac_model_info_t& cModel) { + // Note: This allocates strings that must be freed + // For now we use static storage for simplicity + static std::string s_id, s_name, s_desc, s_url, s_path; + static std::vector s_tags; + static std::vector s_tagPtrs; + + s_id = model.id; + s_name = model.name; + s_desc = model.description; + s_url = model.downloadUrl; + s_path = model.localPath; + + memset(&cModel, 0, sizeof(cModel)); + + cModel.id = const_cast(s_id.c_str()); + cModel.name = const_cast(s_name.c_str()); + cModel.description = s_desc.empty() ? nullptr : const_cast(s_desc.c_str()); + cModel.category = model.category; + cModel.format = model.format; + cModel.framework = model.framework; + cModel.download_url = s_url.empty() ? nullptr : const_cast(s_url.c_str()); + cModel.local_path = s_path.empty() ? nullptr : const_cast(s_path.c_str()); + cModel.download_size = model.downloadSize; + cModel.memory_required = model.memoryRequired; + cModel.context_length = model.contextLength; + cModel.supports_thinking = model.supportsThinking ? RAC_TRUE : RAC_FALSE; + cModel.source = model.source; + + // Setup tags + s_tags = model.tags; + s_tagPtrs.clear(); + for (const auto& tag : s_tags) { + s_tagPtrs.push_back(tag.c_str()); + } + if (!s_tagPtrs.empty()) { + cModel.tags = const_cast(s_tagPtrs.data()); + cModel.tag_count = s_tagPtrs.size(); + } +} + +rac_result_t ModelRegistryBridge::addModel(const ModelInfo& model) { + if (!handle_) { + return RAC_ERROR_NOT_INITIALIZED; + } + + rac_model_info_t cModel; + toRac(model, cModel); + + // Use rac_model_registry_save to add/update a model + rac_result_t result = rac_model_registry_save(handle_, &cModel); + + if (result == RAC_SUCCESS) { + LOGI("Added model: %s", model.id.c_str()); + } else { + LOGE("Failed to add model %s: %d", model.id.c_str(), result); + } + + return result; +} + +rac_result_t ModelRegistryBridge::removeModel(const std::string& modelId) { + if (!handle_) { + return RAC_ERROR_NOT_INITIALIZED; + } + + rac_result_t result = rac_model_registry_remove(handle_, modelId.c_str()); + + if (result == RAC_SUCCESS) { + LOGI("Removed model: %s", modelId.c_str()); + } else { + LOGE("Failed to remove model %s: %d", modelId.c_str(), result); + } + + return result; +} + +rac_result_t ModelRegistryBridge::updateModelPath(const std::string& modelId, const std::string& localPath) { + if (!handle_) { + return RAC_ERROR_NOT_INITIALIZED; + } + + // Use rac_model_registry_update_download_status to update the model's local path + rac_result_t result = rac_model_registry_update_download_status(handle_, modelId.c_str(), localPath.c_str()); + + if (result == RAC_SUCCESS) { + LOGI("Updated model path: %s -> %s", modelId.c_str(), localPath.c_str()); + } else { + LOGE("Failed to update model path %s: %d", modelId.c_str(), result); + } + + return result; +} + +std::optional ModelRegistryBridge::getModel(const std::string& modelId) { + if (!handle_) { + return std::nullopt; + } + + rac_model_info_t* cModel = nullptr; + rac_result_t result = rac_model_registry_get(handle_, modelId.c_str(), &cModel); + + if (result != RAC_SUCCESS || !cModel) { + return std::nullopt; + } + + ModelInfo model = fromRac(*cModel); + rac_model_info_free(cModel); + + return model; +} + +std::vector ModelRegistryBridge::getAllModels() { + std::vector models; + + if (!handle_) { + LOGE("getAllModels: Registry not initialized!"); + return models; + } + + rac_model_info_t** cModels = nullptr; + size_t count = 0; + + LOGD("getAllModels: Calling rac_model_registry_get_all with handle=%p", handle_); + + rac_result_t result = rac_model_registry_get_all(handle_, &cModels, &count); + + LOGI("getAllModels: result=%d, count=%zu", result, count); + + if (result != RAC_SUCCESS || !cModels) { + LOGE("getAllModels: Failed with result=%d, cModels=%p", result, (void*)cModels); + return models; + } + + for (size_t i = 0; i < count; i++) { + if (cModels[i]) { + models.push_back(fromRac(*cModels[i])); + LOGD("getAllModels: Added model %s", cModels[i]->id); + } + } + + rac_model_info_array_free(cModels, count); + + LOGI("getAllModels: Returning %zu models", models.size()); + + return models; +} + +std::vector ModelRegistryBridge::getModels(const ModelFilter& filter) { + std::vector models; + + if (!handle_) { + return models; + } + + // Get all models first + rac_model_info_t** cModels = nullptr; + size_t count = 0; + + rac_result_t result = rac_model_registry_get_all(handle_, &cModels, &count); + + if (result != RAC_SUCCESS || !cModels) { + return models; + } + + // Setup filter + rac_model_filter_t cFilter = {}; + cFilter.framework = filter.framework; + cFilter.format = filter.format; + cFilter.max_size = filter.maxSize; + cFilter.search_query = filter.searchQuery.empty() ? nullptr : filter.searchQuery.c_str(); + + // Apply filter using rac_model_matches_filter helper + for (size_t i = 0; i < count; i++) { + if (cModels[i]) { + if (rac_model_matches_filter(cModels[i], &cFilter) == RAC_TRUE) { + models.push_back(fromRac(*cModels[i])); + } + } + } + + rac_model_info_array_free(cModels, count); + + return models; +} + +std::vector ModelRegistryBridge::getModelsByFramework(rac_inference_framework_t framework) { + ModelFilter filter; + filter.framework = framework; + return getModels(filter); +} + +std::vector ModelRegistryBridge::getDownloadedModels() { + std::vector models; + + if (!handle_) { + return models; + } + + rac_model_info_t** cModels = nullptr; + size_t count = 0; + + rac_result_t result = rac_model_registry_get_downloaded(handle_, &cModels, &count); + + if (result != RAC_SUCCESS || !cModels) { + return models; + } + + for (size_t i = 0; i < count; i++) { + if (cModels[i]) { + models.push_back(fromRac(*cModels[i])); + } + } + + rac_model_info_array_free(cModels, count); + + return models; +} + +bool ModelRegistryBridge::modelExists(const std::string& modelId) { + if (!handle_) { + return false; + } + + // Check existence by trying to get the model + rac_model_info_t* cModel = nullptr; + rac_result_t result = rac_model_registry_get(handle_, modelId.c_str(), &cModel); + + if (result == RAC_SUCCESS && cModel) { + rac_model_info_free(cModel); + return true; + } + + return false; +} + +bool ModelRegistryBridge::isModelDownloaded(const std::string& modelId) { + if (!handle_) { + return false; + } + + // Get the model and check its download status + rac_model_info_t* cModel = nullptr; + rac_result_t result = rac_model_registry_get(handle_, modelId.c_str(), &cModel); + + if (result != RAC_SUCCESS || !cModel) { + return false; + } + + rac_bool_t downloaded = rac_model_info_is_downloaded(cModel); + rac_model_info_free(cModel); + + return downloaded == RAC_TRUE; +} + +std::optional ModelRegistryBridge::getModelPath(const std::string& modelId) { + if (!handle_) { + return std::nullopt; + } + + // Get the model and extract its local_path + rac_model_info_t* cModel = nullptr; + rac_result_t result = rac_model_registry_get(handle_, modelId.c_str(), &cModel); + + if (result != RAC_SUCCESS || !cModel) { + return std::nullopt; + } + + std::string pathStr; + if (cModel->local_path && cModel->local_path[0] != '\0') { + pathStr = cModel->local_path; + } + + rac_model_info_free(cModel); + + return pathStr.empty() ? std::nullopt : std::make_optional(pathStr); +} + +size_t ModelRegistryBridge::getModelCount() { + if (!handle_) { + return 0; + } + + // Get count by getting all models + rac_model_info_t** cModels = nullptr; + size_t count = 0; + + rac_result_t result = rac_model_registry_get_all(handle_, &cModels, &count); + + if (result == RAC_SUCCESS && cModels) { + rac_model_info_array_free(cModels, count); + } + + return count; +} + +} // namespace bridges +} // namespace runanywhere diff --git a/sdk/runanywhere-react-native/packages/core/cpp/bridges/ModelRegistryBridge.hpp b/sdk/runanywhere-react-native/packages/core/cpp/bridges/ModelRegistryBridge.hpp new file mode 100644 index 000000000..e1c37ee49 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/cpp/bridges/ModelRegistryBridge.hpp @@ -0,0 +1,177 @@ +/** + * @file ModelRegistryBridge.hpp + * @brief C++ bridge for model registry operations. + * + * Mirrors Swift's CppBridge+ModelRegistry.swift pattern: + * - Handle-based API via rac_model_registry_* + * - Model management and queries + * + * Reference: sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+ModelRegistry.swift + */ + +#pragma once + +#include +#include +#include +#include + +#include "rac_types.h" +#include "rac_model_registry.h" +#include "rac_model_types.h" + +namespace runanywhere { +namespace bridges { + +/** + * Model info wrapper for C++ use + */ +struct ModelInfo { + std::string id; + std::string name; + std::string description; + rac_model_category_t category = RAC_MODEL_CATEGORY_UNKNOWN; + rac_model_format_t format = RAC_MODEL_FORMAT_UNKNOWN; + rac_inference_framework_t framework = RAC_FRAMEWORK_UNKNOWN; + std::string downloadUrl; + std::string localPath; + int64_t downloadSize = 0; + int64_t memoryRequired = 0; + int32_t contextLength = 0; + bool supportsThinking = false; + std::vector tags; + rac_model_source_t source = RAC_MODEL_SOURCE_REMOTE; + bool isDownloaded = false; +}; + +/** + * Model filter criteria + */ +struct ModelFilter { + rac_inference_framework_t framework = RAC_FRAMEWORK_UNKNOWN; + rac_model_format_t format = RAC_MODEL_FORMAT_UNKNOWN; + rac_model_category_t category = RAC_MODEL_CATEGORY_UNKNOWN; + int64_t maxSize = 0; + std::string searchQuery; +}; + +/** + * ModelRegistryBridge - Model registry via rac_model_registry_* API + * + * Mirrors Swift's CppBridge.ModelRegistry pattern: + * - Handle-based API + * - Model CRUD operations + * - Query and filtering + */ +class ModelRegistryBridge { +public: + /** + * Get shared instance + */ + static ModelRegistryBridge& shared(); + + /** + * Initialize the model registry + */ + rac_result_t initialize(); + + /** + * Shutdown and cleanup + */ + void shutdown(); + + /** + * Check if initialized + */ + bool isInitialized() const { return handle_ != nullptr; } + + /** + * Get the underlying handle (for use by other bridges) + */ + rac_model_registry_handle_t getHandle() const { return handle_; } + + // ========================================================================= + // Model CRUD Operations + // ========================================================================= + + /** + * Add a model to the registry + */ + rac_result_t addModel(const ModelInfo& model); + + /** + * Remove a model from the registry + */ + rac_result_t removeModel(const std::string& modelId); + + /** + * Update model local path after download + */ + rac_result_t updateModelPath(const std::string& modelId, const std::string& localPath); + + // ========================================================================= + // Model Queries + // ========================================================================= + + /** + * Get a model by ID + */ + std::optional getModel(const std::string& modelId); + + /** + * Get all models + */ + std::vector getAllModels(); + + /** + * Get models filtered by criteria + */ + std::vector getModels(const ModelFilter& filter); + + /** + * Get models by framework + */ + std::vector getModelsByFramework(rac_inference_framework_t framework); + + /** + * Get downloaded models + */ + std::vector getDownloadedModels(); + + /** + * Check if a model exists + */ + bool modelExists(const std::string& modelId); + + /** + * Check if a model is downloaded + */ + bool isModelDownloaded(const std::string& modelId); + + /** + * Get model path if downloaded + */ + std::optional getModelPath(const std::string& modelId); + + /** + * Get model count + */ + size_t getModelCount(); + +private: + ModelRegistryBridge() = default; + ~ModelRegistryBridge(); + ModelRegistryBridge(const ModelRegistryBridge&) = delete; + ModelRegistryBridge& operator=(const ModelRegistryBridge&) = delete; + + // Convert C model info to C++ wrapper + static ModelInfo fromRac(const rac_model_info_t& cModel); + + // Convert C++ wrapper to C model info + static void toRac(const ModelInfo& model, rac_model_info_t& cModel); + + rac_model_registry_handle_t handle_ = nullptr; +}; + +} // namespace bridges +} // namespace runanywhere diff --git a/sdk/runanywhere-react-native/packages/core/cpp/bridges/StorageBridge.cpp b/sdk/runanywhere-react-native/packages/core/cpp/bridges/StorageBridge.cpp new file mode 100644 index 000000000..61893c375 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/cpp/bridges/StorageBridge.cpp @@ -0,0 +1,269 @@ +/** + * @file StorageBridge.cpp + * @brief C++ bridge for storage operations. + * + * Mirrors Swift's CppBridge+Storage.swift pattern. + */ + +#include "StorageBridge.hpp" +#include + +// Platform-specific logging +#if defined(ANDROID) || defined(__ANDROID__) +#include +#define LOG_TAG "StorageBridge" +#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) +#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__) +#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) +#else +#include +#define LOGI(...) printf("[StorageBridge] "); printf(__VA_ARGS__); printf("\n") +#define LOGD(...) printf("[StorageBridge DEBUG] "); printf(__VA_ARGS__); printf("\n") +#define LOGE(...) printf("[StorageBridge ERROR] "); printf(__VA_ARGS__); printf("\n") +#endif + +namespace runanywhere { +namespace bridges { + +// ============================================================================= +// Static storage for callbacks (needed for C function pointers) +// ============================================================================= + +static StoragePlatformCallbacks* g_storageCallbacks = nullptr; + +// ============================================================================= +// C Callback Implementations (called by RACommons) +// ============================================================================= + +static int64_t storageCalculateDirSizeCallback(const char* path, void* userData) { + if (!path || !g_storageCallbacks || !g_storageCallbacks->calculateDirSize) { + return 0; + } + return g_storageCallbacks->calculateDirSize(path); +} + +static int64_t storageGetFileSizeCallback(const char* path, void* userData) { + if (!path || !g_storageCallbacks || !g_storageCallbacks->getFileSize) { + return -1; + } + return g_storageCallbacks->getFileSize(path); +} + +static rac_bool_t storagePathExistsCallback( + const char* path, + rac_bool_t* isDirectory, + void* userData +) { + if (!path || !g_storageCallbacks || !g_storageCallbacks->pathExists) { + return RAC_FALSE; + } + + auto [exists, isDir] = g_storageCallbacks->pathExists(path); + if (isDirectory) { + *isDirectory = isDir ? RAC_TRUE : RAC_FALSE; + } + return exists ? RAC_TRUE : RAC_FALSE; +} + +static int64_t storageGetAvailableSpaceCallback(void* userData) { + if (!g_storageCallbacks || !g_storageCallbacks->getAvailableSpace) { + return 0; + } + return g_storageCallbacks->getAvailableSpace(); +} + +static int64_t storageGetTotalSpaceCallback(void* userData) { + if (!g_storageCallbacks || !g_storageCallbacks->getTotalSpace) { + return 0; + } + return g_storageCallbacks->getTotalSpace(); +} + +// ============================================================================= +// StorageBridge Implementation +// ============================================================================= + +StorageBridge& StorageBridge::shared() { + static StorageBridge instance; + return instance; +} + +StorageBridge::~StorageBridge() { + shutdown(); +} + +void StorageBridge::setPlatformCallbacks(const StoragePlatformCallbacks& callbacks) { + platformCallbacks_ = callbacks; + + // Store in global for C callbacks + static StoragePlatformCallbacks storedCallbacks; + storedCallbacks = callbacks; + g_storageCallbacks = &storedCallbacks; + + LOGI("Storage platform callbacks set"); +} + +rac_result_t StorageBridge::initialize() { + if (handle_) { + LOGD("Storage analyzer already initialized"); + return RAC_SUCCESS; + } + + // Setup callback struct + memset(&racCallbacks_, 0, sizeof(racCallbacks_)); + racCallbacks_.calculate_dir_size = storageCalculateDirSizeCallback; + racCallbacks_.get_file_size = storageGetFileSizeCallback; + racCallbacks_.path_exists = storagePathExistsCallback; + racCallbacks_.get_available_space = storageGetAvailableSpaceCallback; + racCallbacks_.get_total_space = storageGetTotalSpaceCallback; + racCallbacks_.user_data = nullptr; + + // Create analyzer + rac_result_t result = rac_storage_analyzer_create(&racCallbacks_, &handle_); + + if (result == RAC_SUCCESS) { + LOGI("Storage analyzer created successfully"); + } else { + LOGE("Failed to create storage analyzer: %d", result); + handle_ = nullptr; + } + + return result; +} + +void StorageBridge::shutdown() { + if (handle_) { + rac_storage_analyzer_destroy(handle_); + handle_ = nullptr; + LOGI("Storage analyzer destroyed"); + } +} + +StorageInfo StorageBridge::analyzeStorage(rac_model_registry_handle_t registryHandle) { + StorageInfo result; + + if (!handle_) { + LOGE("Storage analyzer not initialized"); + return result; + } + + if (!registryHandle) { + LOGE("Model registry handle is null"); + return result; + } + + rac_storage_info_t cInfo = {}; + rac_result_t status = rac_storage_analyzer_analyze(handle_, registryHandle, &cInfo); + + if (status != RAC_SUCCESS) { + LOGE("Storage analysis failed: %d", status); + return result; + } + + // Convert app storage + result.appStorage.documentsSize = cInfo.app_storage.documents_size; + result.appStorage.cacheSize = cInfo.app_storage.cache_size; + result.appStorage.appSupportSize = cInfo.app_storage.app_support_size; + result.appStorage.totalSize = cInfo.app_storage.total_size; + + // Convert device storage + result.deviceStorage.totalSpace = cInfo.device_storage.total_space; + result.deviceStorage.freeSpace = cInfo.device_storage.free_space; + result.deviceStorage.usedSpace = cInfo.device_storage.used_space; + + // Convert model metrics + if (cInfo.models && cInfo.model_count > 0) { + for (size_t i = 0; i < cInfo.model_count; i++) { + const auto& cModel = cInfo.models[i]; + ModelStorageMetrics metrics; + metrics.modelId = cModel.model_id ? cModel.model_id : ""; + metrics.modelName = cModel.model_name ? cModel.model_name : ""; + metrics.localPath = cModel.local_path ? cModel.local_path : ""; + metrics.sizeOnDisk = cModel.size_on_disk; + result.models.push_back(metrics); + } + } + + result.totalModelsSize = cInfo.total_models_size; + + // Free C++ result + rac_storage_info_free(&cInfo); + + LOGI("Storage analysis complete: %zu models, total size: %lld bytes", + result.models.size(), static_cast(result.totalModelsSize)); + + return result; +} + +std::optional StorageBridge::getModelStorageMetrics( + rac_model_registry_handle_t registryHandle, + const std::string& modelId, + rac_inference_framework_t framework +) { + if (!handle_ || !registryHandle) { + return std::nullopt; + } + + rac_model_storage_metrics_t cMetrics = {}; + rac_result_t result = rac_storage_analyzer_get_model_metrics( + handle_, registryHandle, modelId.c_str(), framework, &cMetrics + ); + + if (result != RAC_SUCCESS) { + return std::nullopt; + } + + ModelStorageMetrics metrics; + metrics.modelId = cMetrics.model_id ? cMetrics.model_id : ""; + metrics.modelName = cMetrics.model_name ? cMetrics.model_name : ""; + metrics.localPath = cMetrics.local_path ? cMetrics.local_path : ""; + metrics.sizeOnDisk = cMetrics.size_on_disk; + + return metrics; +} + +StorageAvailability StorageBridge::checkStorageAvailable(int64_t modelSize, double safetyMargin) { + StorageAvailability result; + + // Use callbacks directly for synchronous check + int64_t available = g_storageCallbacks && g_storageCallbacks->getAvailableSpace + ? g_storageCallbacks->getAvailableSpace() : 0; + + int64_t required = static_cast(static_cast(modelSize) * (1.0 + safetyMargin)); + + result.isAvailable = available > required; + result.requiredSpace = required; + result.availableSpace = available; + result.hasWarning = available < required * 2; + + if (!result.isAvailable) { + int64_t shortfall = required - available; + // Format shortfall in MB + double shortfallMB = static_cast(shortfall) / (1024.0 * 1024.0); + result.recommendation = "Need " + std::to_string(static_cast(shortfallMB)) + " MB more space."; + } else if (result.hasWarning) { + result.recommendation = "Storage space is getting low."; + } + + return result; +} + +int64_t StorageBridge::calculateSize(const std::string& path) { + if (!handle_) { + LOGE("Storage analyzer not initialized"); + return -1; + } + + int64_t size = 0; + rac_result_t result = rac_storage_analyzer_calculate_size(handle_, path.c_str(), &size); + + if (result != RAC_SUCCESS) { + LOGE("Failed to calculate size for %s: %d", path.c_str(), result); + return -1; + } + + return size; +} + +} // namespace bridges +} // namespace runanywhere diff --git a/sdk/runanywhere-react-native/packages/core/cpp/bridges/StorageBridge.hpp b/sdk/runanywhere-react-native/packages/core/cpp/bridges/StorageBridge.hpp new file mode 100644 index 000000000..43d0a718d --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/cpp/bridges/StorageBridge.hpp @@ -0,0 +1,172 @@ +/** + * @file StorageBridge.hpp + * @brief C++ bridge for storage operations. + * + * Mirrors Swift's CppBridge+Storage.swift pattern: + * - C++ handles business logic (which models, path calculations, aggregation) + * - Platform provides file operation callbacks + * + * Reference: sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+Storage.swift + */ + +#pragma once + +#include +#include +#include +#include + +#include "rac_types.h" +#include "rac_storage_analyzer.h" +#include "rac_model_registry.h" + +namespace runanywhere { +namespace bridges { + +/** + * App storage info + */ +struct AppStorageInfo { + int64_t documentsSize = 0; + int64_t cacheSize = 0; + int64_t appSupportSize = 0; + int64_t totalSize = 0; +}; + +/** + * Device storage info + */ +struct DeviceStorageInfo { + int64_t totalSpace = 0; + int64_t freeSpace = 0; + int64_t usedSpace = 0; +}; + +/** + * Model storage metrics + */ +struct ModelStorageMetrics { + std::string modelId; + std::string modelName; + std::string localPath; + int64_t sizeOnDisk = 0; +}; + +/** + * Overall storage info + */ +struct StorageInfo { + AppStorageInfo appStorage; + DeviceStorageInfo deviceStorage; + std::vector models; + int64_t totalModelsSize = 0; +}; + +/** + * Storage availability result + */ +struct StorageAvailability { + bool isAvailable = false; + int64_t requiredSpace = 0; + int64_t availableSpace = 0; + bool hasWarning = false; + std::string recommendation; +}; + +/** + * Platform callbacks for storage file operations + */ +struct StoragePlatformCallbacks { + // Calculate directory size + std::function calculateDirSize; + + // Get file size + std::function getFileSize; + + // Check if path exists (returns: exists, isDirectory) + std::function(const std::string& path)> pathExists; + + // Get available disk space + std::function getAvailableSpace; + + // Get total disk space + std::function getTotalSpace; +}; + +/** + * StorageBridge - Storage analysis via rac_storage_analyzer_* API + * + * Mirrors Swift's CppBridge.Storage pattern: + * - Handle-based API + * - Platform provides file callbacks + * - C++ handles business logic + */ +class StorageBridge { +public: + /** + * Get shared instance + */ + static StorageBridge& shared(); + + /** + * Set platform callbacks for file operations + * Must be called during SDK initialization + */ + void setPlatformCallbacks(const StoragePlatformCallbacks& callbacks); + + /** + * Initialize the storage analyzer + * Creates handle with registered callbacks + */ + rac_result_t initialize(); + + /** + * Shutdown and cleanup + */ + void shutdown(); + + /** + * Check if initialized + */ + bool isInitialized() const { return handle_ != nullptr; } + + /** + * Analyze overall storage + * + * @param registryHandle Model registry handle for model enumeration + * @return Storage info + */ + StorageInfo analyzeStorage(rac_model_registry_handle_t registryHandle); + + /** + * Get storage metrics for a specific model + */ + std::optional getModelStorageMetrics( + rac_model_registry_handle_t registryHandle, + const std::string& modelId, + rac_inference_framework_t framework + ); + + /** + * Check if storage is available for a download + */ + StorageAvailability checkStorageAvailable(int64_t modelSize, double safetyMargin = 0.1); + + /** + * Calculate size at a path + */ + int64_t calculateSize(const std::string& path); + +private: + StorageBridge() = default; + ~StorageBridge(); + StorageBridge(const StorageBridge&) = delete; + StorageBridge& operator=(const StorageBridge&) = delete; + + rac_storage_analyzer_handle_t handle_ = nullptr; + StoragePlatformCallbacks platformCallbacks_{}; + rac_storage_callbacks_t racCallbacks_{}; +}; + +} // namespace bridges +} // namespace runanywhere diff --git a/sdk/runanywhere-react-native/packages/core/cpp/bridges/TelemetryBridge.cpp b/sdk/runanywhere-react-native/packages/core/cpp/bridges/TelemetryBridge.cpp new file mode 100644 index 000000000..4641f2bec --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/cpp/bridges/TelemetryBridge.cpp @@ -0,0 +1,359 @@ +/** + * TelemetryBridge.cpp + * + * C++ telemetry bridge implementation for React Native. + * + * Reference: sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+Telemetry.swift + * + * Key insight from Swift/Kotlin: + * - C++ telemetry manager builds JSON and batches events + * - Platform SDK provides HTTP callback for sending + * - Analytics events are routed through C++ callback to telemetry manager + */ + +#include "TelemetryBridge.hpp" +#include "InitBridge.hpp" +#include "AuthBridge.hpp" +#include "rac_dev_config.h" + +// Platform-specific logging +#if defined(ANDROID) || defined(__ANDROID__) +#include +#define LOG_TAG "TelemetryBridge" +#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) +#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, LOG_TAG, __VA_ARGS__) +#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) +#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__) +#else +#define LOGI(...) printf("[TelemetryBridge] "); printf(__VA_ARGS__); printf("\n") +#define LOGW(...) printf("[TelemetryBridge WARN] "); printf(__VA_ARGS__); printf("\n") +#define LOGE(...) printf("[TelemetryBridge ERROR] "); printf(__VA_ARGS__); printf("\n") +#define LOGD(...) printf("[TelemetryBridge DEBUG] "); printf(__VA_ARGS__); printf("\n") +#endif + +namespace runanywhere { +namespace bridges { + +// Forward declarations for callbacks +static void telemetryHttpCallback( + void* userData, + const char* endpoint, + const char* jsonBody, + size_t jsonLength, + rac_bool_t requiresAuth +); + +static void analyticsEventCallback( + rac_event_type_t type, + const rac_analytics_event_data_t* data, + void* userData +); + +// ============================================================================ +// Singleton +// ============================================================================ + +TelemetryBridge& TelemetryBridge::shared() { + static TelemetryBridge instance; + return instance; +} + +TelemetryBridge::~TelemetryBridge() { + shutdown(); +} + +// ============================================================================ +// Lifecycle +// ============================================================================ + +void TelemetryBridge::initialize( + rac_environment_t environment, + const std::string& deviceId, + const std::string& deviceModel, + const std::string& osVersion, + const std::string& sdkVersion +) { + std::lock_guard lock(mutex_); + + // Destroy existing manager if any + if (manager_) { + rac_telemetry_manager_flush(manager_); + rac_telemetry_manager_destroy(manager_); + manager_ = nullptr; + } + + environment_ = environment; + + LOGI("Creating telemetry manager: device=%s, model=%s, os=%s, sdk=%s, env=%d", + deviceId.c_str(), deviceModel.c_str(), osVersion.c_str(), sdkVersion.c_str(), environment); + + // Create telemetry manager + // Matches Swift: rac_telemetry_manager_create(Environment.toC(environment), did, plat, ver) + manager_ = rac_telemetry_manager_create( + environment, + deviceId.c_str(), + "react-native", // platform + sdkVersion.c_str() + ); + + if (!manager_) { + LOGE("Failed to create telemetry manager"); + return; + } + + // Set device info + // Matches Swift: rac_telemetry_manager_set_device_info(manager, model, os) + rac_telemetry_manager_set_device_info(manager_, deviceModel.c_str(), osVersion.c_str()); + + // Register HTTP callback - this is where platform provides HTTP transport + // Matches Swift: rac_telemetry_manager_set_http_callback(manager, telemetryHttpCallback, userData) + rac_telemetry_manager_set_http_callback(manager_, telemetryHttpCallback, this); + + LOGI("Telemetry manager initialized successfully"); +} + +void TelemetryBridge::shutdown() { + std::lock_guard lock(mutex_); + + // Unregister events callback first + if (eventsCallbackRegistered_) { + rac_analytics_events_set_callback(nullptr, nullptr); + eventsCallbackRegistered_ = false; + } + + if (manager_) { + LOGI("Shutting down telemetry manager..."); + + // Flush pending events + rac_telemetry_manager_flush(manager_); + + // Destroy manager + rac_telemetry_manager_destroy(manager_); + manager_ = nullptr; + + LOGI("Telemetry manager destroyed"); + } +} + +bool TelemetryBridge::isInitialized() const { + std::lock_guard lock(mutex_); + return manager_ != nullptr; +} + +// ============================================================================ +// Event Tracking +// ============================================================================ + +void TelemetryBridge::trackAnalyticsEvent( + rac_event_type_t eventType, + const rac_analytics_event_data_t* data +) { + std::lock_guard lock(mutex_); + + if (!manager_) { + LOGD("Telemetry not initialized, skipping event"); + return; + } + + // Route to C++ telemetry manager + // Matches Swift: rac_telemetry_manager_track_analytics(mgr, type, data) + rac_result_t result = rac_telemetry_manager_track_analytics(manager_, eventType, data); + if (result != RAC_SUCCESS) { + LOGE("Failed to track analytics event: %d", result); + } +} + +void TelemetryBridge::flush() { + std::lock_guard lock(mutex_); + + if (!manager_) { + return; + } + + LOGI("Flushing telemetry events..."); + rac_telemetry_manager_flush(manager_); +} + +// ============================================================================ +// Events Callback Registration +// ============================================================================ + +void TelemetryBridge::registerEventsCallback() { + std::lock_guard lock(mutex_); + + if (eventsCallbackRegistered_) { + return; + } + + // Register analytics callback - routes events to telemetry manager + // Matches Swift: rac_analytics_events_set_callback(analyticsEventCallback, nil) + rac_result_t result = rac_analytics_events_set_callback(analyticsEventCallback, this); + if (result != RAC_SUCCESS) { + LOGE("Failed to register analytics events callback: %d", result); + return; + } + + eventsCallbackRegistered_ = true; + LOGI("Analytics events callback registered"); +} + +void TelemetryBridge::unregisterEventsCallback() { + std::lock_guard lock(mutex_); + + if (!eventsCallbackRegistered_) { + return; + } + + rac_analytics_events_set_callback(nullptr, nullptr); + eventsCallbackRegistered_ = false; + LOGI("Analytics events callback unregistered"); +} + +// ============================================================================ +// HTTP Callback (Platform provides HTTP transport) +// ============================================================================ + +/** + * HTTP callback invoked by C++ telemetry manager when it's time to send events. + * + * C++ has already: + * - Built the JSON payload + * - Determined the endpoint + * - Batched the events + * + * We just need to make the HTTP POST request using platform-native HTTP. + * + * Matches Swift's telemetryHttpCallback in CppBridge+Telemetry.swift + */ +static void telemetryHttpCallback( + void* userData, + const char* endpoint, + const char* jsonBody, + size_t jsonLength, + rac_bool_t requiresAuth +) { + if (!endpoint || !jsonBody) { + LOGE("Invalid telemetry HTTP callback parameters"); + return; + } + + auto* bridge = static_cast(userData); + if (!bridge) { + LOGE("TelemetryBridge not available for HTTP callback"); + return; + } + + std::string path(endpoint); + std::string json(jsonBody, jsonLength); + rac_environment_t env = bridge->getEnvironment(); + + LOGI("Telemetry HTTP callback: endpoint=%s, bodyLen=%zu, env=%d", path.c_str(), jsonLength, env); + + // Build full URL based on environment + // Matches Swift HTTPService logic + std::string baseURL; + std::string apiKey; + + if (env == RAC_ENV_DEVELOPMENT) { + // Development: Use Supabase from C++ dev config (development_config.cpp) + // NO FALLBACK - credentials must come from C++ config only + const char* devUrl = rac_dev_config_get_supabase_url(); + const char* devKey = rac_dev_config_get_supabase_key(); + + baseURL = devUrl ? devUrl : ""; + apiKey = devKey ? devKey : ""; + + if (baseURL.empty()) { + LOGW("Development mode but Supabase URL not configured in C++ dev_config"); + } else { + LOGD("Telemetry using Supabase: %s", baseURL.c_str()); + } + } else { + // Production/Staging: Use configured Railway URL + // These come from SDK initialization (App.tsx -> RunAnywhere.initialize) + baseURL = InitBridge::shared().getBaseURL(); + + // For production mode, prefer JWT access token (from authentication) + // over raw API key. This matches Swift/Kotlin behavior. + std::string accessToken = AuthBridge::shared().getAccessToken(); + if (!accessToken.empty()) { + apiKey = accessToken; // Use JWT for Authorization header + LOGD("Telemetry using JWT access token"); + } else { + // Fallback to API key if not authenticated yet + apiKey = InitBridge::shared().getApiKey(); + LOGD("Telemetry using API key (not authenticated)"); + } + + // Fallback to default if not configured + if (baseURL.empty()) { + baseURL = "https://api.runanywhere.ai"; + } + + LOGD("Telemetry using production: %s", baseURL.c_str()); + } + + std::string fullURL = baseURL + path; + + LOGI("Telemetry POST to: %s", fullURL.c_str()); + + // Use platform-native HTTP (same as device registration) + auto [success, statusCode, responseBody, errorMessage] = + InitBridge::shared().httpPostSync(fullURL, json, apiKey); + + if (success) { + LOGI("✅ Telemetry sent successfully (status=%d)", statusCode); + + // Notify C++ that HTTP completed + rac_telemetry_manager_http_complete( + bridge->getHandle(), + RAC_TRUE, + responseBody.c_str(), + nullptr + ); + } else { + LOGE("❌ Telemetry HTTP failed: status=%d, error=%s", statusCode, errorMessage.c_str()); + + // Notify C++ of failure + rac_telemetry_manager_http_complete( + bridge->getHandle(), + RAC_FALSE, + nullptr, + errorMessage.c_str() + ); + } +} + +// ============================================================================ +// Analytics Events Callback +// ============================================================================ + +/** + * Analytics callback - receives events from C++ analytics system. + * + * Routes events to telemetry manager for batching and sending. + * + * Matches Swift's analyticsEventCallback in CppBridge+Telemetry.swift + */ +static void analyticsEventCallback( + rac_event_type_t type, + const rac_analytics_event_data_t* data, + void* userData +) { + if (!data) { + return; + } + + auto* bridge = static_cast(userData); + if (!bridge) { + return; + } + + // Forward to telemetry manager + // C++ handles JSON building, batching, etc. + bridge->trackAnalyticsEvent(type, data); +} + +} // namespace bridges +} // namespace runanywhere + diff --git a/sdk/runanywhere-react-native/packages/core/cpp/bridges/TelemetryBridge.hpp b/sdk/runanywhere-react-native/packages/core/cpp/bridges/TelemetryBridge.hpp new file mode 100644 index 000000000..d2c1d868a --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/cpp/bridges/TelemetryBridge.hpp @@ -0,0 +1,126 @@ +/** + * TelemetryBridge.hpp + * + * C++ telemetry bridge for React Native - aligned with Swift/Kotlin SDKs. + * + * Reference: sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+Telemetry.swift + * + * Architecture: + * - C++ telemetry manager handles all event logic (batching, JSON building) + * - Platform SDK (React Native) only provides HTTP transport + * - Events from analytics callback are routed to telemetry manager + */ + +#pragma once + +#include +#include +#include "rac_telemetry_manager.h" +#include "rac_analytics_events.h" +#include "rac_environment.h" + +namespace runanywhere { +namespace bridges { + +/** + * TelemetryBridge - Manages C++ telemetry manager lifecycle + * + * This matches Swift's CppBridge.Telemetry implementation: + * - Creates/destroys telemetry manager + * - Registers HTTP callback for sending events + * - Routes analytics events to telemetry manager + */ +class TelemetryBridge { +public: + /** + * Singleton accessor + */ + static TelemetryBridge& shared(); + + /** + * Initialize telemetry manager + * + * @param environment SDK environment (affects endpoints and encoding) + * @param deviceId Persistent device UUID + * @param deviceModel Device model string (e.g., "iPhone 16 Pro") + * @param osVersion OS version string (e.g., "18.0") + * @param sdkVersion SDK version string + */ + void initialize( + rac_environment_t environment, + const std::string& deviceId, + const std::string& deviceModel, + const std::string& osVersion, + const std::string& sdkVersion + ); + + /** + * Shutdown telemetry manager + * Flushes pending events and destroys manager + */ + void shutdown(); + + /** + * Check if telemetry is initialized + */ + bool isInitialized() const; + + /** + * Track analytics event from C++ callback + * Routes to rac_telemetry_manager_track_analytics + */ + void trackAnalyticsEvent( + rac_event_type_t eventType, + const rac_analytics_event_data_t* data + ); + + /** + * Flush pending telemetry events immediately + */ + void flush(); + + /** + * Register analytics events callback + * This routes analytics events to the telemetry manager + */ + void registerEventsCallback(); + + /** + * Unregister analytics events callback + */ + void unregisterEventsCallback(); + + /** + * Get telemetry manager handle (for advanced use) + */ + rac_telemetry_manager_t* getHandle() const { return manager_; } + + /** + * Get current environment + */ + rac_environment_t getEnvironment() const { return environment_; } + +private: + TelemetryBridge() = default; + ~TelemetryBridge(); + + // Non-copyable + TelemetryBridge(const TelemetryBridge&) = delete; + TelemetryBridge& operator=(const TelemetryBridge&) = delete; + + // Telemetry manager handle + rac_telemetry_manager_t* manager_ = nullptr; + + // Current environment + rac_environment_t environment_ = RAC_ENV_PRODUCTION; + + // Thread safety + mutable std::mutex mutex_; + + // Events callback registered flag + bool eventsCallbackRegistered_ = false; +}; + +} // namespace bridges +} // namespace runanywhere + diff --git a/sdk/runanywhere-react-native/packages/core/ios/.testlocal b/sdk/runanywhere-react-native/packages/core/ios/.testlocal new file mode 100644 index 000000000..e69de29bb diff --git a/sdk/runanywhere-react-native/packages/core/ios/ArchiveUtility.swift b/sdk/runanywhere-react-native/packages/core/ios/ArchiveUtility.swift new file mode 100644 index 000000000..f39ed9dfc --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/ios/ArchiveUtility.swift @@ -0,0 +1,526 @@ +/** + * ArchiveUtility.swift + * + * Native archive extraction utility for React Native. + * Uses Apple's native Compression framework for gzip decompression (fast) + * and pure Swift tar extraction. + * + * Mirrors the implementation from: + * sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Download/Utilities/ArchiveUtility.swift + * + * Supports: tar.gz, zip + * Note: All models should use tar.gz from RunanywhereAI/sherpa-onnx fork for best performance + */ + +import Compression +import Foundation + +/// Archive extraction errors +public enum ArchiveError: Error, LocalizedError { + case invalidArchive(String) + case decompressionFailed(String) + case extractionFailed(String) + case unsupportedFormat(String) + case fileNotFound(String) + + public var errorDescription: String? { + switch self { + case .invalidArchive(let msg): return "Invalid archive: \(msg)" + case .decompressionFailed(let msg): return "Decompression failed: \(msg)" + case .extractionFailed(let msg): return "Extraction failed: \(msg)" + case .unsupportedFormat(let msg): return "Unsupported format: \(msg)" + case .fileNotFound(let msg): return "File not found: \(msg)" + } + } +} + +/// Utility for handling archive extraction +@objc public final class ArchiveUtility: NSObject { + + // MARK: - Public API + + /// Extract an archive to a destination directory + /// - Parameters: + /// - archivePath: Path to the archive file + /// - destinationPath: Destination directory path + /// - Returns: true if extraction succeeded + @objc public static func extract( + archivePath: String, + to destinationPath: String + ) -> Bool { + do { + try extractArchive(archivePath: archivePath, to: destinationPath) + return true + } catch { + SDKLogger.archive.logError(error, additionalInfo: "Extraction failed") + return false + } + } + + /// Extract an archive to a destination directory (throwing version) + public static func extractArchive( + archivePath: String, + to destinationPath: String, + progressHandler: ((Double) -> Void)? = nil + ) throws { + let archiveURL = URL(fileURLWithPath: archivePath) + let destinationURL = URL(fileURLWithPath: destinationPath) + + // Ensure archive exists + guard FileManager.default.fileExists(atPath: archivePath) else { + throw ArchiveError.fileNotFound("Archive not found: \(archivePath)") + } + + // Detect archive type by magic bytes (more reliable than file extension) + let archiveType = try detectArchiveTypeByMagicBytes(archivePath) + SDKLogger.archive.info("Detected archive type: \(archiveType) for: \(archivePath)") + + switch archiveType { + case .gzip: + try extractTarGz(from: archiveURL, to: destinationURL, progressHandler: progressHandler) + case .zip: + try extractZip(from: archiveURL, to: destinationURL, progressHandler: progressHandler) + case .bzip2: + throw ArchiveError.unsupportedFormat("tar.bz2 not supported. Use tar.gz from RunanywhereAI/sherpa-onnx fork.") + case .xz: + throw ArchiveError.unsupportedFormat("tar.xz not supported. Use tar.gz from RunanywhereAI/sherpa-onnx fork.") + case .unknown: + // Fallback to file extension check + let lowercased = archivePath.lowercased() + if lowercased.hasSuffix(".tar.gz") || lowercased.hasSuffix(".tgz") { + try extractTarGz(from: archiveURL, to: destinationURL, progressHandler: progressHandler) + } else if lowercased.hasSuffix(".zip") { + try extractZip(from: archiveURL, to: destinationURL, progressHandler: progressHandler) + } else { + throw ArchiveError.unsupportedFormat("Unknown archive format: \(archivePath)") + } + } + } + + /// Archive type detected by magic bytes + private enum DetectedArchiveType { + case gzip + case zip + case bzip2 + case xz + case unknown + } + + /// Detect archive type by reading magic bytes from file header + private static func detectArchiveTypeByMagicBytes(_ path: String) throws -> DetectedArchiveType { + guard let fileHandle = FileHandle(forReadingAtPath: path) else { + throw ArchiveError.fileNotFound("Cannot open file: \(path)") + } + defer { try? fileHandle.close() } + + // Read first 6 bytes for magic number detection + guard let headerData = try? fileHandle.read(upToCount: 6), headerData.count >= 2 else { + return .unknown + } + + // Check for gzip: 0x1f 0x8b + if headerData[0] == 0x1f && headerData[1] == 0x8b { + return .gzip + } + + // Check for zip: 0x50 0x4b 0x03 0x04 ("PK\x03\x04") + if headerData.count >= 4 && + headerData[0] == 0x50 && headerData[1] == 0x4b && + headerData[2] == 0x03 && headerData[3] == 0x04 { + return .zip + } + + // Check for bzip2: 0x42 0x5a ("BZ") + if headerData[0] == 0x42 && headerData[1] == 0x5a { + return .bzip2 + } + + // Check for xz: 0xfd 0x37 0x7a 0x58 0x5a 0x00 + if headerData.count >= 6 && + headerData[0] == 0xfd && headerData[1] == 0x37 && + headerData[2] == 0x7a && headerData[3] == 0x58 && + headerData[4] == 0x5a && headerData[5] == 0x00 { + return .xz + } + + return .unknown + } + + // MARK: - tar.gz Extraction (Native Compression Framework) + + /// Extract a tar.gz archive using Apple's native Compression framework + private static func extractTarGz( + from sourceURL: URL, + to destinationURL: URL, + progressHandler: ((Double) -> Void)? + ) throws { + let overallStart = Date() + SDKLogger.archive.info("Extracting tar.gz: \(sourceURL.lastPathComponent)") + progressHandler?(0.0) + + // Step 1: Read compressed data + let readStart = Date() + let compressedData = try Data(contentsOf: sourceURL) + let readTime = Date().timeIntervalSince(readStart) + SDKLogger.archive.info("Read \(formatBytes(compressedData.count)) in \(String(format: "%.2f", readTime))s") + progressHandler?(0.05) + + // Step 2: Decompress gzip using NATIVE Compression framework + let decompressStart = Date() + SDKLogger.archive.info("Starting native gzip decompression...") + let tarData = try decompressGzipNative(compressedData) + let decompressTime = Date().timeIntervalSince(decompressStart) + SDKLogger.archive.info("Decompressed to \(formatBytes(tarData.count)) in \(String(format: "%.2f", decompressTime))s") + progressHandler?(0.3) + + // Step 3: Extract tar archive + let extractStart = Date() + SDKLogger.archive.info("Extracting tar data...") + try extractTarData(tarData, to: destinationURL, progressHandler: { progress in + progressHandler?(0.3 + progress * 0.7) + }) + let extractTime = Date().timeIntervalSince(extractStart) + SDKLogger.archive.info("Tar extract completed in \(String(format: "%.2f", extractTime))s") + + let totalTime = Date().timeIntervalSince(overallStart) + SDKLogger.archive.info("Total extraction time: \(String(format: "%.2f", totalTime))s") + progressHandler?(1.0) + } + + /// Decompress gzip data using Apple's native Compression framework + private static func decompressGzipNative(_ compressedData: Data) throws -> Data { + // Gzip header validation + guard compressedData.count >= 10 else { + throw ArchiveError.invalidArchive("Gzip data too short") + } + + guard compressedData[0] == 0x1f && compressedData[1] == 0x8b else { + throw ArchiveError.invalidArchive("Invalid gzip magic number") + } + + guard compressedData[2] == 8 else { + throw ArchiveError.invalidArchive("Unsupported gzip compression method") + } + + let flags = compressedData[3] + var headerSize = 10 + + // Handle optional fields + if (flags & 0x04) != 0 { // FEXTRA + guard compressedData.count > headerSize + 2 else { + throw ArchiveError.invalidArchive("Truncated gzip header (FEXTRA)") + } + let extraLen = Int(compressedData[headerSize]) | (Int(compressedData[headerSize + 1]) << 8) + headerSize += 2 + extraLen + } + + if (flags & 0x08) != 0 { // FNAME + while headerSize < compressedData.count && compressedData[headerSize] != 0 { + headerSize += 1 + } + headerSize += 1 + } + + if (flags & 0x10) != 0 { // FCOMMENT + while headerSize < compressedData.count && compressedData[headerSize] != 0 { + headerSize += 1 + } + headerSize += 1 + } + + if (flags & 0x02) != 0 { // FHCRC + headerSize += 2 + } + + // Extract raw deflate stream (skip header and 8-byte trailer) + guard compressedData.count > headerSize + 8 else { + throw ArchiveError.invalidArchive("Invalid gzip structure") + } + let deflateData = compressedData.subdata(in: headerSize..<(compressedData.count - 8)) + + // Use native Compression framework to decompress + // Start with a reasonable estimate (model files typically compress 3-5x) + var destinationBufferSize = deflateData.count * 10 + var decompressedData = Data(count: destinationBufferSize) + + let decompressedSize = deflateData.withUnsafeBytes { (srcPtr: UnsafeRawBufferPointer) -> Int in + guard let sourceAddress = srcPtr.baseAddress else { return 0 } + + return decompressedData.withUnsafeMutableBytes { (destPtr: UnsafeMutableRawBufferPointer) -> Int in + guard let destAddress = destPtr.baseAddress else { return 0 } + + return compression_decode_buffer( + destAddress.assumingMemoryBound(to: UInt8.self), + destinationBufferSize, + sourceAddress.assumingMemoryBound(to: UInt8.self), + deflateData.count, + nil, + COMPRESSION_ZLIB + ) + } + } + + // If buffer was too small, try again with a larger buffer + if decompressedSize == 0 || decompressedSize == destinationBufferSize { + destinationBufferSize = deflateData.count * 30 + decompressedData = Data(count: destinationBufferSize) + + let retrySize = deflateData.withUnsafeBytes { (srcPtr: UnsafeRawBufferPointer) -> Int in + guard let sourceAddress = srcPtr.baseAddress else { return 0 } + + return decompressedData.withUnsafeMutableBytes { (destPtr: UnsafeMutableRawBufferPointer) -> Int in + guard let destAddress = destPtr.baseAddress else { return 0 } + + return compression_decode_buffer( + destAddress.assumingMemoryBound(to: UInt8.self), + destinationBufferSize, + sourceAddress.assumingMemoryBound(to: UInt8.self), + deflateData.count, + nil, + COMPRESSION_ZLIB + ) + } + } + + guard retrySize > 0 && retrySize < destinationBufferSize else { + throw ArchiveError.decompressionFailed("Native decompression failed - buffer too small or corrupted data") + } + + decompressedData.count = retrySize + return decompressedData + } + + decompressedData.count = decompressedSize + return decompressedData + } + + // MARK: - ZIP Extraction (Pure Swift using Foundation) + + private static func extractZip( + from sourceURL: URL, + to destinationURL: URL, + progressHandler: ((Double) -> Void)? + ) throws { + SDKLogger.archive.info("Extracting zip: \(sourceURL.lastPathComponent)") + progressHandler?(0.0) + + // Create destination directory + try FileManager.default.createDirectory(at: destinationURL, withIntermediateDirectories: true) + + // Read zip file + guard let archive = try? Data(contentsOf: sourceURL) else { + throw ArchiveError.fileNotFound("Cannot read zip file: \(sourceURL.path)") + } + + // Parse and extract ZIP using pure Swift + var offset = 0 + var fileCount = 0 + let totalSize = archive.count + + while offset < archive.count - 4 { + // Check for local file header signature (0x04034b50 = PK\x03\x04) + let sig0 = archive[offset] + let sig1 = archive[offset + 1] + let sig2 = archive[offset + 2] + let sig3 = archive[offset + 3] + + if sig0 == 0x50 && sig1 == 0x4b && sig2 == 0x03 && sig3 == 0x04 { + // Local file header + let compressionMethod = UInt16(archive[offset + 8]) | (UInt16(archive[offset + 9]) << 8) + let compressedSize = UInt32(archive[offset + 18]) | + (UInt32(archive[offset + 19]) << 8) | + (UInt32(archive[offset + 20]) << 16) | + (UInt32(archive[offset + 21]) << 24) + let uncompressedSize = UInt32(archive[offset + 22]) | + (UInt32(archive[offset + 23]) << 8) | + (UInt32(archive[offset + 24]) << 16) | + (UInt32(archive[offset + 25]) << 24) + let fileNameLength = UInt16(archive[offset + 26]) | (UInt16(archive[offset + 27]) << 8) + let extraFieldLength = UInt16(archive[offset + 28]) | (UInt16(archive[offset + 29]) << 8) + + let headerEnd = offset + 30 + let fileNameData = archive.subdata(in: headerEnd..<(headerEnd + Int(fileNameLength))) + let fileName = String(data: fileNameData, encoding: .utf8) ?? "" + + let dataStart = headerEnd + Int(fileNameLength) + Int(extraFieldLength) + let dataEnd = dataStart + Int(compressedSize) + + let filePath = destinationURL.appendingPathComponent(fileName) + + if fileName.hasSuffix("/") { + // Directory + try FileManager.default.createDirectory(at: filePath, withIntermediateDirectories: true) + } else if !fileName.isEmpty && !fileName.hasPrefix("__MACOSX") { + // File + try FileManager.default.createDirectory(at: filePath.deletingLastPathComponent(), withIntermediateDirectories: true) + + if compressionMethod == 0 { + // Stored (no compression) + let fileData = archive.subdata(in: dataStart.. Data? { + var destinationBufferSize = max(uncompressedSize, data.count * 4) + var decompressedData = Data(count: destinationBufferSize) + + let decompressedSize = data.withUnsafeBytes { (srcPtr: UnsafeRawBufferPointer) -> Int in + guard let sourceAddress = srcPtr.baseAddress else { return 0 } + + return decompressedData.withUnsafeMutableBytes { (destPtr: UnsafeMutableRawBufferPointer) -> Int in + guard let destAddress = destPtr.baseAddress else { return 0 } + + // Use COMPRESSION_ZLIB for raw deflate + return compression_decode_buffer( + destAddress.assumingMemoryBound(to: UInt8.self), + destinationBufferSize, + sourceAddress.assumingMemoryBound(to: UInt8.self), + data.count, + nil, + COMPRESSION_ZLIB + ) + } + } + + guard decompressedSize > 0 else { return nil } + decompressedData.count = decompressedSize + return decompressedData + } + + // MARK: - TAR Extraction (Pure Swift) + + /// Extract tar data to destination directory + private static func extractTarData( + _ tarData: Data, + to destinationURL: URL, + progressHandler: ((Double) -> Void)? + ) throws { + // Create destination directory + try FileManager.default.createDirectory(at: destinationURL, withIntermediateDirectories: true) + + var offset = 0 + let totalSize = tarData.count + var fileCount = 0 + + while offset + 512 <= tarData.count { + // Read tar header (512 bytes) + let headerData = tarData.subdata(in: offset..<(offset + 512)) + + // Check for end of archive (two consecutive zero blocks) + if headerData.allSatisfy({ $0 == 0 }) { + break + } + + // Parse header + let nameData = headerData.subdata(in: 0..<100) + let sizeData = headerData.subdata(in: 124..<136) + let typeFlag = headerData[156] + let prefixData = headerData.subdata(in: 345..<500) + + // Get file name + let name = extractNullTerminatedString(from: nameData) + let prefix = extractNullTerminatedString(from: prefixData) + let fullName = prefix.isEmpty ? name : "\(prefix)/\(name)" + + // Skip if name is empty or is macOS resource fork + guard !fullName.isEmpty, !fullName.hasPrefix("._") else { + offset += 512 + continue + } + + // Parse file size (octal) + let sizeString = extractNullTerminatedString(from: sizeData).trimmingCharacters(in: .whitespaces) + let fileSize = Int(sizeString, radix: 8) ?? 0 + + offset += 512 // Move past header + + let filePath = destinationURL.appendingPathComponent(fullName) + + // Handle different entry types + if typeFlag == 0x35 || (typeFlag == 0x30 && fullName.hasSuffix("/")) { // Directory + try FileManager.default.createDirectory(at: filePath, withIntermediateDirectories: true) + } else if typeFlag == 0x30 || typeFlag == 0 { // Regular file + // Ensure parent directory exists + try FileManager.default.createDirectory(at: filePath.deletingLastPathComponent(), withIntermediateDirectories: true) + + // Extract file data + if fileSize > 0 && offset + fileSize <= tarData.count { + let fileData = tarData.subdata(in: offset..<(offset + fileSize)) + try fileData.write(to: filePath) + } else { + // Create empty file + FileManager.default.createFile(atPath: filePath.path, contents: nil) + } + fileCount += 1 + } else if typeFlag == 0x32 { // Symbolic link + let linkName = extractNullTerminatedString(from: headerData.subdata(in: 157..<257)) + if !linkName.isEmpty { + try FileManager.default.createDirectory(at: filePath.deletingLastPathComponent(), withIntermediateDirectories: true) + try? FileManager.default.createSymbolicLink(atPath: filePath.path, withDestinationPath: linkName) + } + } + + // Move to next entry (file data + padding to 512-byte boundary) + offset += fileSize + let padding = (512 - (fileSize % 512)) % 512 + offset += padding + + // Report progress + progressHandler?(Double(offset) / Double(totalSize)) + } + + SDKLogger.archive.info("Extracted \(fileCount) files") + } + + // MARK: - Helpers + + private static func extractNullTerminatedString(from data: Data) -> String { + if let nullIndex = data.firstIndex(of: 0) { + return String(data: data.subdata(in: 0.. String { + if bytes < 1024 { + return "\(bytes) B" + } else if bytes < 1024 * 1024 { + return String(format: "%.1f KB", Double(bytes) / 1024) + } else if bytes < 1024 * 1024 * 1024 { + return String(format: "%.1f MB", Double(bytes) / (1024 * 1024)) + } else { + return String(format: "%.2f GB", Double(bytes) / (1024 * 1024 * 1024)) + } + } +} diff --git a/sdk/runanywhere-react-native/packages/core/ios/ArchiveUtilityBridge.m b/sdk/runanywhere-react-native/packages/core/ios/ArchiveUtilityBridge.m new file mode 100644 index 000000000..7955617db --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/ios/ArchiveUtilityBridge.m @@ -0,0 +1,52 @@ +/** + * ArchiveUtilityBridge.m + * + * C bridge to call Swift ArchiveUtility from C++. + * This bridge is necessary because C++ cannot directly call Swift code. + */ + +#import +#import "RNSDKLoggerBridge.h" + +static NSString * const kLogCategory = @"ArchiveBridge"; + +// Import the generated Swift header from the pod +#if __has_include() +#import +#elif __has_include("RunAnywhereCore-Swift.h") +#import "RunAnywhereCore-Swift.h" +#else +// Forward declare the Swift class if header not found +@interface ArchiveUtility : NSObject ++ (BOOL)extractWithArchivePath:(NSString * _Nonnull)archivePath to:(NSString * _Nonnull)destinationPath; +@end +#endif + +/** + * Extract an archive to a destination directory + * Called from C++ HybridRunAnywhereCore::extractArchive + */ +bool ArchiveUtility_extract(const char* archivePath, const char* destinationPath) { + @autoreleasepool { + if (archivePath == NULL || destinationPath == NULL) { + RN_LOG_ERROR(kLogCategory, @"Invalid null path"); + return false; + } + + NSString* archivePathStr = [NSString stringWithUTF8String:archivePath]; + NSString* destinationPathStr = [NSString stringWithUTF8String:destinationPath]; + + if (archivePathStr == nil || destinationPathStr == nil) { + RN_LOG_ERROR(kLogCategory, @"Failed to create NSString from path"); + return false; + } + + @try { + BOOL result = [ArchiveUtility extractWithArchivePath:archivePathStr to:destinationPathStr]; + return result; + } @catch (NSException *exception) { + RN_LOG_ERROR(kLogCategory, @"Exception: %@", exception); + return false; + } + } +} diff --git a/sdk/runanywhere-react-native/packages/core/ios/AudioDecoder.h b/sdk/runanywhere-react-native/packages/core/ios/AudioDecoder.h new file mode 100644 index 000000000..f994e5b49 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/ios/AudioDecoder.h @@ -0,0 +1,38 @@ +/** + * AudioDecoder.h + * + * iOS audio file decoder using built-in AudioToolbox. + * Converts any audio format (M4A, CAF, WAV, etc.) to PCM float32 samples. + */ + +#ifndef AudioDecoder_h +#define AudioDecoder_h + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Decode an audio file to PCM float32 samples at 16kHz mono + * Works with any iOS-supported audio format (M4A, CAF, WAV, MP3, etc.) + * + * @param filePath Path to the audio file (null-terminated C string) + * @param samples Output: pointer to float array (caller must free with ra_free_audio_samples) + * @param numSamples Output: number of samples + * @param sampleRate Output: sample rate (will be 16000 Hz) + * @return 1 on success, 0 on failure + */ +int ra_decode_audio_file(const char* filePath, float** samples, size_t* numSamples, int* sampleRate); + +/** + * Free samples allocated by ra_decode_audio_file + */ +void ra_free_audio_samples(float* samples); + +#ifdef __cplusplus +} +#endif + +#endif /* AudioDecoder_h */ diff --git a/sdk/runanywhere-react-native/packages/core/ios/AudioDecoder.m b/sdk/runanywhere-react-native/packages/core/ios/AudioDecoder.m new file mode 100644 index 000000000..429c62ee3 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/ios/AudioDecoder.m @@ -0,0 +1,162 @@ +/** + * AudioDecoder.m + * + * iOS audio file decoder using built-in AudioToolbox. + * Converts any audio format (M4A, CAF, WAV, etc.) to PCM float32 samples. + */ + +#import +#import +#import "AudioDecoder.h" +#import "RNSDKLoggerBridge.h" + +static NSString * const kLogCategory = @"AudioDecoder"; + +int ra_decode_audio_file(const char* filePath, float** samples, size_t* numSamples, int* sampleRate) { + if (!filePath || !samples || !numSamples || !sampleRate) { + RN_LOG_ERROR(kLogCategory, @"Invalid parameters"); + return 0; + } + + NSString *path = [NSString stringWithUTF8String:filePath]; + + // Create URL from file path + NSURL *fileURL = [NSURL fileURLWithPath:path]; + if (![[NSFileManager defaultManager] fileExistsAtPath:path]) { + RN_LOG_ERROR(kLogCategory, @"File not found: %@", path); + return 0; + } + + RN_LOG_INFO(kLogCategory, @"Decoding file: %@", path); + + // Open the audio file + ExtAudioFileRef audioFile = NULL; + OSStatus status = ExtAudioFileOpenURL((__bridge CFURLRef)fileURL, &audioFile); + if (status != noErr || !audioFile) { + RN_LOG_ERROR(kLogCategory, @"Failed to open audio file: %d", (int)status); + return 0; + } + + // Get the source format + AudioStreamBasicDescription srcFormat; + UInt32 propSize = sizeof(srcFormat); + status = ExtAudioFileGetProperty(audioFile, kExtAudioFileProperty_FileDataFormat, &propSize, &srcFormat); + if (status != noErr) { + RN_LOG_ERROR(kLogCategory, @"Failed to get source format: %d", (int)status); + ExtAudioFileDispose(audioFile); + return 0; + } + + RN_LOG_INFO(kLogCategory, @"Source format: %.0f Hz, %d channels, %d bits", + srcFormat.mSampleRate, srcFormat.mChannelsPerFrame, srcFormat.mBitsPerChannel); + + // Set the output format to 16kHz mono float32 (optimal for Whisper) + AudioStreamBasicDescription dstFormat; + memset(&dstFormat, 0, sizeof(dstFormat)); + dstFormat.mSampleRate = 16000.0; // 16kHz for Whisper + dstFormat.mFormatID = kAudioFormatLinearPCM; + dstFormat.mFormatFlags = kAudioFormatFlagIsFloat | kAudioFormatFlagIsPacked; + dstFormat.mBitsPerChannel = 32; + dstFormat.mChannelsPerFrame = 1; // Mono + dstFormat.mFramesPerPacket = 1; + dstFormat.mBytesPerFrame = sizeof(float); + dstFormat.mBytesPerPacket = sizeof(float); + + status = ExtAudioFileSetProperty(audioFile, kExtAudioFileProperty_ClientDataFormat, sizeof(dstFormat), &dstFormat); + if (status != noErr) { + RN_LOG_ERROR(kLogCategory, @"Failed to set output format: %d", (int)status); + ExtAudioFileDispose(audioFile); + return 0; + } + + // Get the total number of frames + SInt64 totalFrames = 0; + propSize = sizeof(totalFrames); + status = ExtAudioFileGetProperty(audioFile, kExtAudioFileProperty_FileLengthFrames, &propSize, &totalFrames); + if (status != noErr) { + RN_LOG_ERROR(kLogCategory, @"Failed to get frame count: %d", (int)status); + ExtAudioFileDispose(audioFile); + return 0; + } + + // Calculate output frames after sample rate conversion + double ratio = 16000.0 / srcFormat.mSampleRate; + SInt64 outputFrames = (SInt64)(totalFrames * ratio) + 4096; // Add buffer + + RN_LOG_DEBUG(kLogCategory, @"Total frames: %lld, estimated output: %lld", totalFrames, outputFrames); + + // Allocate buffer for all samples + float *buffer = (float *)malloc(outputFrames * sizeof(float)); + if (!buffer) { + RN_LOG_ERROR(kLogCategory, @"Failed to allocate buffer"); + ExtAudioFileDispose(audioFile); + return 0; + } + + // Read audio data in chunks + const UInt32 chunkSize = 8192; + float *tempBuffer = (float *)malloc(chunkSize * sizeof(float)); + size_t totalSamples = 0; + + while (1) { + AudioBufferList bufferList; + bufferList.mNumberBuffers = 1; + bufferList.mBuffers[0].mNumberChannels = 1; + bufferList.mBuffers[0].mDataByteSize = chunkSize * sizeof(float); + bufferList.mBuffers[0].mData = tempBuffer; + + UInt32 framesToRead = chunkSize; + status = ExtAudioFileRead(audioFile, &framesToRead, &bufferList); + + if (status != noErr) { + RN_LOG_ERROR(kLogCategory, @"Error reading audio: %d", (int)status); + break; + } + + if (framesToRead == 0) { + // End of file + break; + } + + // Check if we need to grow the buffer + if (totalSamples + framesToRead > (size_t)outputFrames) { + outputFrames *= 2; + float *newBuffer = (float *)realloc(buffer, outputFrames * sizeof(float)); + if (!newBuffer) { + RN_LOG_ERROR(kLogCategory, @"Failed to reallocate buffer"); + free(buffer); + free(tempBuffer); + ExtAudioFileDispose(audioFile); + return 0; + } + buffer = newBuffer; + } + + // Copy samples + memcpy(buffer + totalSamples, tempBuffer, framesToRead * sizeof(float)); + totalSamples += framesToRead; + } + + free(tempBuffer); + ExtAudioFileDispose(audioFile); + + if (totalSamples == 0) { + RN_LOG_WARNING(kLogCategory, @"No samples decoded"); + free(buffer); + return 0; + } + + RN_LOG_INFO(kLogCategory, @"Decoded %zu samples at 16000 Hz", totalSamples); + + *samples = buffer; + *numSamples = totalSamples; + *sampleRate = 16000; + + return 1; +} + +void ra_free_audio_samples(float* samples) { + if (samples) { + free(samples); + } +} diff --git a/sdk/runanywhere-react-native/packages/core/ios/HybridRunAnywhereDeviceInfo.swift b/sdk/runanywhere-react-native/packages/core/ios/HybridRunAnywhereDeviceInfo.swift new file mode 100644 index 000000000..21349245d --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/ios/HybridRunAnywhereDeviceInfo.swift @@ -0,0 +1,214 @@ +import Foundation +import NitroModules +import UIKit + +/// Swift implementation of RunAnywhereDeviceInfo HybridObject +/// Mirrors: sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Device/Models/Domain/DeviceInfo.swift +class HybridRunAnywhereDeviceInfo: HybridRunAnywhereDeviceInfoSpec { + + // MARK: - Model Lookup Tables (from Swift SDK) + + private static let deviceModels: [String: String] = [ + // iPhone 17 (2025) + "iPhone18,1": "iPhone 17 Pro", "iPhone18,2": "iPhone 17 Pro Max", + "iPhone18,3": "iPhone 17", "iPhone18,4": "iPhone 17 Plus", + // iPhone 16 (2024) + "iPhone17,1": "iPhone 16 Pro", "iPhone17,2": "iPhone 16 Pro Max", + "iPhone17,3": "iPhone 16", "iPhone17,4": "iPhone 16 Plus", + // iPhone 15 (2023) + "iPhone16,1": "iPhone 15 Pro", "iPhone16,2": "iPhone 15 Pro Max", + "iPhone15,4": "iPhone 15", "iPhone15,5": "iPhone 15 Plus", + // iPhone 14 (2022) + "iPhone15,2": "iPhone 14 Pro", "iPhone15,3": "iPhone 14 Pro Max", + "iPhone14,7": "iPhone 14", "iPhone14,8": "iPhone 14 Plus", + // iPhone 13 (2021) + "iPhone14,2": "iPhone 13 Pro", "iPhone14,3": "iPhone 13 Pro Max", + "iPhone14,4": "iPhone 13 mini", "iPhone14,5": "iPhone 13", + // iPhone 12 (2020) + "iPhone13,1": "iPhone 12 mini", "iPhone13,2": "iPhone 12", + "iPhone13,3": "iPhone 12 Pro", "iPhone13,4": "iPhone 12 Pro Max", + // iPhone SE + "iPhone14,6": "iPhone SE (3rd gen)", "iPhone12,8": "iPhone SE (2nd gen)", + // iPad Pro M4 (2024) + "iPad16,3": "iPad Pro 11-inch (M4)", "iPad16,4": "iPad Pro 11-inch (M4)", + "iPad16,5": "iPad Pro 13-inch (M4)", "iPad16,6": "iPad Pro 13-inch (M4)", + // iPad Pro M2 (2022) + "iPad14,3": "iPad Pro 11-inch (M2)", "iPad14,4": "iPad Pro 11-inch (M2)", + "iPad14,5": "iPad Pro 12.9-inch (M2)", "iPad14,6": "iPad Pro 12.9-inch (M2)", + // iPad Air + "iPad14,8": "iPad Air (M2)", "iPad14,9": "iPad Air (M2)", + "iPad13,16": "iPad Air (5th gen)", "iPad13,17": "iPad Air (5th gen)" + ] + + // MARK: - Get Machine Identifier + + private static func getMachineIdentifier() -> String { + var sysinfo = utsname() + uname(&sysinfo) + return withUnsafePointer(to: &sysinfo.machine) { + $0.withMemoryRebound(to: CChar.self, capacity: 1) { + String(validatingUTF8: $0) ?? "Unknown" + } + } + } + + // MARK: - Chip Name Lookup (from Swift SDK) + + private static func getChipNameForModel(_ identifier: String) -> String { + if identifier.hasPrefix("iPhone18,") { return "A19 Pro" } + if identifier.hasPrefix("iPhone17,1") || identifier.hasPrefix("iPhone17,2") { return "A18 Pro" } + if identifier.hasPrefix("iPhone17,") { return "A18" } + if identifier.hasPrefix("iPhone16,") { return "A17 Pro" } + if identifier.hasPrefix("iPhone15,2") || identifier.hasPrefix("iPhone15,3") { return "A16 Bionic" } + if identifier.hasPrefix("iPhone15,") { return "A16 Bionic" } + if identifier.hasPrefix("iPhone14,") { return "A15 Bionic" } + if identifier.hasPrefix("iPhone13,") { return "A14 Bionic" } + if identifier.hasPrefix("iPhone12,") { return "A13 Bionic" } + if identifier.hasPrefix("iPad16,") { return "M4" } + if identifier.hasPrefix("iPad14,3") || identifier.hasPrefix("iPad14,4") || + identifier.hasPrefix("iPad14,5") || identifier.hasPrefix("iPad14,6") { return "M2" } + if identifier.hasPrefix("iPad14,8") || identifier.hasPrefix("iPad14,9") { return "M2" } + if identifier.hasPrefix("iPad13,") { return "M1" } + + #if arch(arm64) + return "Apple Silicon" + #else + return "Intel" + #endif + } + + // MARK: - HybridObject Implementation + + func getDeviceModel() throws -> Promise { + return Promise.async { + let machineId = Self.getMachineIdentifier() + + // Check simulator + #if targetEnvironment(simulator) + if let simModelId = ProcessInfo.processInfo.environment["SIMULATOR_MODEL_IDENTIFIER"] { + return Self.deviceModels[simModelId] ?? "iOS Simulator" + } + return "iOS Simulator" + #else + // Look up friendly model name + return Self.deviceModels[machineId] ?? UIDevice.current.model + #endif + } + } + + func getOSVersion() throws -> Promise { + return Promise.async { + return UIDevice.current.systemVersion + } + } + + func getPlatform() throws -> Promise { + return Promise.async { + return "ios" + } + } + + func getTotalRAM() throws -> Promise { + return Promise.async { + return Double(ProcessInfo.processInfo.physicalMemory) + } + } + + func getAvailableRAM() throws -> Promise { + return Promise.async { + var info = mach_task_basic_info() + var count = mach_msg_type_number_t(MemoryLayout.size)/4 + let kerr: kern_return_t = withUnsafeMutablePointer(to: &info) { + $0.withMemoryRebound(to: integer_t.self, capacity: 1) { + task_info(mach_task_self_, task_flavor_t(MACH_TASK_BASIC_INFO), $0, &count) + } + } + if kerr == KERN_SUCCESS { + let usedMemory = Double(info.resident_size) + let totalMemory = Double(ProcessInfo.processInfo.physicalMemory) + return totalMemory - usedMemory + } + return Double(ProcessInfo.processInfo.physicalMemory) / 2 + } + } + + func getCPUCores() throws -> Promise { + return Promise.async { + return Double(ProcessInfo.processInfo.processorCount) + } + } + + func hasGPU() throws -> Promise { + return Promise.async { + // iOS devices always have GPU + return true + } + } + + func hasNPU() throws -> Promise { + return Promise.async { + // Check for Neural Engine (A11 Bionic and later = iPhone X and later) + // All arm64 iOS devices since 2017 have Neural Engine + #if arch(arm64) + return true + #else + return false + #endif + } + } + + func getChipName() throws -> Promise { + return Promise.async { + let machineId = Self.getMachineIdentifier() + + #if targetEnvironment(simulator) + if let simModelId = ProcessInfo.processInfo.environment["SIMULATOR_MODEL_IDENTIFIER"] { + return Self.getChipNameForModel(simModelId) + } + return "Simulated" + #else + return Self.getChipNameForModel(machineId) + #endif + } + } + + func getThermalState() throws -> Promise { + return Promise.async { + let state = ProcessInfo.processInfo.thermalState + switch state { + case .nominal: return 0.0 + case .fair: return 1.0 + case .serious: return 2.0 + case .critical: return 3.0 + @unknown default: return 0.0 + } + } + } + + func getBatteryLevel() throws -> Promise { + return Promise.async { + await MainActor.run { + UIDevice.current.isBatteryMonitoringEnabled = true + } + let level = UIDevice.current.batteryLevel + // batteryLevel is -1.0 if monitoring not enabled or on simulator + return level >= 0 ? Double(level) : -1.0 + } + } + + func isCharging() throws -> Promise { + return Promise.async { + await MainActor.run { + UIDevice.current.isBatteryMonitoringEnabled = true + } + let state = UIDevice.current.batteryState + return state == .charging || state == .full + } + } + + func isLowPowerMode() throws -> Promise { + return Promise.async { + return ProcessInfo.processInfo.isLowPowerModeEnabled + } + } +} diff --git a/sdk/runanywhere-react-native/packages/core/ios/KeychainManager.swift b/sdk/runanywhere-react-native/packages/core/ios/KeychainManager.swift new file mode 100644 index 000000000..ccc2f4825 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/ios/KeychainManager.swift @@ -0,0 +1,116 @@ +/** + * KeychainManager.swift + * + * iOS Keychain manager for secure storage of sensitive data. + * Matches Swift SDK's KeychainManager pattern. + * + * Reference: sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Security/KeychainManager.swift + */ + +import Foundation +import Security + +/// Keychain manager for secure storage (singleton) +@objc public class KeychainManager: NSObject { + + // MARK: - Singleton + + @objc public static let shared = KeychainManager() + + // MARK: - Properties + + private let serviceName = "com.runanywhere.sdk" + + // MARK: - Initialization + + private override init() { + super.init() + } + + // MARK: - Public API + + /// Store a value in the keychain + /// - Parameters: + /// - value: Value to store + /// - key: Key to store under + /// - Returns: true if successful + @objc public func set(_ value: String, forKey key: String) -> Bool { + guard let data = value.data(using: .utf8) else { + return false + } + + // Delete existing item first (update by delete + add) + _ = delete(forKey: key) + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrAccount as String: key, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + ] + + let status = SecItemAdd(query as CFDictionary, nil) + return status == errSecSuccess + } + + /// Retrieve a value from the keychain + /// - Parameter key: Key to retrieve + /// - Returns: Stored value or nil + @objc public func get(forKey key: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var dataTypeRef: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &dataTypeRef) + + guard status == errSecSuccess, + let data = dataTypeRef as? Data, + let value = String(data: data, encoding: .utf8) else { + return nil + } + + return value + } + + /// Delete a value from the keychain + /// - Parameter key: Key to delete + /// - Returns: true if successful or item didn't exist + @objc public func delete(forKey key: String) -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrAccount as String: key + ] + + let status = SecItemDelete(query as CFDictionary) + return status == errSecSuccess || status == errSecItemNotFound + } + + /// Check if a key exists in the keychain + /// - Parameter key: Key to check + /// - Returns: true if key exists + @objc public func exists(forKey key: String) -> Bool { + return get(forKey: key) != nil + } + + // MARK: - Device UUID Convenience + + private let deviceUUIDKey = "com.runanywhere.sdk.device.uuid" + + /// Store device UUID + @objc public func storeDeviceUUID(_ uuid: String) -> Bool { + return set(uuid, forKey: deviceUUIDKey) + } + + /// Retrieve device UUID + @objc public func retrieveDeviceUUID() -> String? { + return get(forKey: deviceUUIDKey) + } +} + diff --git a/sdk/runanywhere-react-native/packages/core/ios/PlatformAdapter.swift b/sdk/runanywhere-react-native/packages/core/ios/PlatformAdapter.swift new file mode 100644 index 000000000..dd04bdc51 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/ios/PlatformAdapter.swift @@ -0,0 +1,100 @@ +/** + * PlatformAdapter.swift + * + * iOS platform adapter for C++ callbacks. + * Bridges iOS-specific implementations (Keychain, FileManager) to C++ layer. + * + * Reference: sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+State.swift + */ + +import Foundation + +/// Platform adapter that provides iOS implementations for C++ callbacks +@objc public class PlatformAdapter: NSObject { + + // MARK: - Singleton + + @objc public static let shared = PlatformAdapter() + + private override init() { + super.init() + } + + // MARK: - Secure Storage (Keychain) + + /// Get value from Keychain + @objc public func secureGet(_ key: String) -> String? { + return KeychainManager.shared.get(forKey: key) + } + + /// Set value in Keychain + @objc public func secureSet(_ key: String, value: String) -> Bool { + return KeychainManager.shared.set(value, forKey: key) + } + + /// Delete value from Keychain + @objc public func secureDelete(_ key: String) -> Bool { + return KeychainManager.shared.delete(forKey: key) + } + + /// Check if key exists in Keychain + @objc public func secureExists(_ key: String) -> Bool { + return KeychainManager.shared.exists(forKey: key) + } + + // MARK: - File Operations + + /// Check if file exists + @objc public func fileExists(_ path: String) -> Bool { + return FileManager.default.fileExists(atPath: path) + } + + /// Read file contents + @objc public func fileRead(_ path: String) -> String? { + return try? String(contentsOfFile: path, encoding: .utf8) + } + + /// Write file contents + @objc public func fileWrite(_ path: String, data: String) -> Bool { + do { + try data.write(toFile: path, atomically: true, encoding: .utf8) + return true + } catch { + return false + } + } + + /// Delete file + @objc public func fileDelete(_ path: String) -> Bool { + do { + try FileManager.default.removeItem(atPath: path) + return true + } catch { + return false + } + } + + // MARK: - Device UUID + + /// Get persistent device UUID (from Keychain or generate new) + @objc public func getPersistentDeviceUUID() -> String { + // Try to get from Keychain first + if let existingUUID = KeychainManager.shared.retrieveDeviceUUID() { + return existingUUID + } + + // Try vendor ID + #if os(iOS) || os(tvOS) + if let vendorUUID = UIDevice.current.identifierForVendor?.uuidString { + _ = KeychainManager.shared.storeDeviceUUID(vendorUUID) + return vendorUUID + } + #endif + + // Generate new UUID + let newUUID = UUID().uuidString + _ = KeychainManager.shared.storeDeviceUUID(newUUID) + return newUUID + } +} + diff --git a/sdk/runanywhere-react-native/packages/core/ios/PlatformAdapterBridge.h b/sdk/runanywhere-react-native/packages/core/ios/PlatformAdapterBridge.h new file mode 100644 index 000000000..fdd791565 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/ios/PlatformAdapterBridge.h @@ -0,0 +1,152 @@ +/** + * PlatformAdapterBridge.h + * + * C interface for platform-specific operations (Keychain, File I/O). + * Called from C++ via extern "C" functions. + */ + +#ifndef PlatformAdapterBridge_h +#define PlatformAdapterBridge_h + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================ +// Secure Storage (Keychain) +// ============================================================================ + +/** + * Set a value in the Keychain + * @param key The key to store under + * @param value The value to store + * @return true if successful + */ +bool PlatformAdapter_secureSet(const char* key, const char* value); + +/** + * Get a value from the Keychain + * @param key The key to retrieve + * @param outValue Pointer to store the result (must be freed by caller with free()) + * @return true if found + */ +bool PlatformAdapter_secureGet(const char* key, char** outValue); + +/** + * Delete a value from the Keychain + * @param key The key to delete + * @return true if successful + */ +bool PlatformAdapter_secureDelete(const char* key); + +/** + * Check if a key exists in the Keychain + * @param key The key to check + * @return true if exists + */ +bool PlatformAdapter_secureExists(const char* key); + +/** + * Get persistent device UUID (from Keychain or generate new) + * @param outValue Pointer to store the UUID (must be freed by caller with free()) + * @return true if successful + */ +bool PlatformAdapter_getPersistentDeviceUUID(char** outValue); + +// ============================================================================ +// Device Info (Synchronous) +// ============================================================================ + +/** + * Get device model name (e.g., "iPhone 16 Pro Max") + * @param outValue Pointer to store the result (must be freed by caller) + * @return true if successful + */ +bool PlatformAdapter_getDeviceModel(char** outValue); + +/** + * Get OS version (e.g., "18.2") + * @param outValue Pointer to store the result (must be freed by caller) + * @return true if successful + */ +bool PlatformAdapter_getOSVersion(char** outValue); + +/** + * Get chip name (e.g., "A18 Pro") + * @param outValue Pointer to store the result (must be freed by caller) + * @return true if successful + */ +bool PlatformAdapter_getChipName(char** outValue); + +/** + * Get total memory in bytes + * @return Total memory in bytes + */ +uint64_t PlatformAdapter_getTotalMemory(void); + +/** + * Get available memory in bytes + * @return Available memory in bytes + */ +uint64_t PlatformAdapter_getAvailableMemory(void); + +/** + * Get CPU core count + * @return Number of CPU cores + */ +int PlatformAdapter_getCoreCount(void); + +/** + * Get architecture (e.g., "arm64") + * @param outValue Pointer to store the result (must be freed by caller) + * @return true if successful + */ +bool PlatformAdapter_getArchitecture(char** outValue); + +/** + * Get GPU family (e.g., "apple" for iOS, "mali", "adreno" for Android) + * @param outValue Pointer to store the result (must be freed by caller) + * @return true if successful + */ +bool PlatformAdapter_getGPUFamily(char** outValue); + +/** + * Check if device is a tablet + * Uses UIDevice.userInterfaceIdiom on iOS, Configuration on Android + * @return true if device is a tablet + */ +bool PlatformAdapter_isTablet(void); + +// ============================================================================ +// HTTP POST for Device Registration (Synchronous) +// ============================================================================ + +/** + * Synchronous HTTP POST for device registration + * Called from C++ device manager callbacks + * + * @param url Full URL to POST to + * @param jsonBody JSON body string + * @param supabaseKey Supabase API key (for dev mode, can be NULL) + * @param outStatusCode Pointer to store HTTP status code + * @param outResponseBody Pointer to store response body (must be freed by caller) + * @param outErrorMessage Pointer to store error message (must be freed by caller) + * @return true if request succeeded (2xx or 409) + */ +bool PlatformAdapter_httpPostSync( + const char* url, + const char* jsonBody, + const char* supabaseKey, + int* outStatusCode, + char** outResponseBody, + char** outErrorMessage +); + +#ifdef __cplusplus +} +#endif + +#endif /* PlatformAdapterBridge_h */ + diff --git a/sdk/runanywhere-react-native/packages/core/ios/PlatformAdapterBridge.m b/sdk/runanywhere-react-native/packages/core/ios/PlatformAdapterBridge.m new file mode 100644 index 000000000..50de55eee --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/ios/PlatformAdapterBridge.m @@ -0,0 +1,570 @@ +/** + * PlatformAdapterBridge.m + * + * C bridge to call Swift PlatformAdapter/KeychainManager from C++. + * This bridge is necessary because C++ cannot directly call Swift code. + */ + +#import +#import + +// Import the generated Swift header from the pod +#if __has_include() +#import +#elif __has_include("RunAnywhereCore-Swift.h") +#import "RunAnywhereCore-Swift.h" +#else +// Forward declare the Swift classes if header not found +@interface KeychainManager : NSObject ++ (KeychainManager * _Nonnull)shared; +- (BOOL)set:(NSString * _Nonnull)value forKey:(NSString * _Nonnull)key; +- (NSString * _Nullable)getForKey:(NSString * _Nonnull)key; +- (BOOL)deleteForKey:(NSString * _Nonnull)key; +- (BOOL)existsForKey:(NSString * _Nonnull)key; +@end + +@interface PlatformAdapter : NSObject ++ (PlatformAdapter * _Nonnull)shared; +- (NSString * _Nonnull)getPersistentDeviceUUID; +@end +#endif + +// ============================================================================ +// Secure Storage (Keychain) +// ============================================================================ + +/** + * Set a value in the Keychain + * @param key The key to store under + * @param value The value to store + * @return true if successful + */ +bool PlatformAdapter_secureSet(const char* key, const char* value) { + @autoreleasepool { + if (key == NULL || value == NULL) { + NSLog(@"[PlatformAdapterBridge] secureSet: Invalid null key or value"); + return false; + } + + NSString* keyStr = [NSString stringWithUTF8String:key]; + NSString* valueStr = [NSString stringWithUTF8String:value]; + + if (keyStr == nil || valueStr == nil) { + NSLog(@"[PlatformAdapterBridge] secureSet: Failed to create NSString"); + return false; + } + + @try { + BOOL result = [[KeychainManager shared] set:valueStr forKey:keyStr]; + NSLog(@"[PlatformAdapterBridge] secureSet key=%@ result=%d", keyStr, result); + return result; + } @catch (NSException *exception) { + NSLog(@"[PlatformAdapterBridge] secureSet exception: %@", exception); + return false; + } + } +} + +/** + * Get a value from the Keychain + * @param key The key to retrieve + * @param outValue Pointer to store the result (must be freed by caller) + * @return true if found + */ +bool PlatformAdapter_secureGet(const char* key, char** outValue) { + @autoreleasepool { + if (key == NULL || outValue == NULL) { + NSLog(@"[PlatformAdapterBridge] secureGet: Invalid null key or outValue"); + return false; + } + + *outValue = NULL; + + NSString* keyStr = [NSString stringWithUTF8String:key]; + if (keyStr == nil) { + NSLog(@"[PlatformAdapterBridge] secureGet: Failed to create NSString for key"); + return false; + } + + @try { + NSString* value = [[KeychainManager shared] getForKey:keyStr]; + if (value == nil) { + NSLog(@"[PlatformAdapterBridge] secureGet key=%@ not found", keyStr); + return false; + } + + const char* utf8Value = [value UTF8String]; + if (utf8Value == NULL) { + return false; + } + + *outValue = strdup(utf8Value); + NSLog(@"[PlatformAdapterBridge] secureGet key=%@ found", keyStr); + return *outValue != NULL; + } @catch (NSException *exception) { + NSLog(@"[PlatformAdapterBridge] secureGet exception: %@", exception); + return false; + } + } +} + +/** + * Delete a value from the Keychain + * @param key The key to delete + * @return true if successful + */ +bool PlatformAdapter_secureDelete(const char* key) { + @autoreleasepool { + if (key == NULL) { + NSLog(@"[PlatformAdapterBridge] secureDelete: Invalid null key"); + return false; + } + + NSString* keyStr = [NSString stringWithUTF8String:key]; + if (keyStr == nil) { + return false; + } + + @try { + BOOL result = [[KeychainManager shared] deleteForKey:keyStr]; + return result; + } @catch (NSException *exception) { + NSLog(@"[PlatformAdapterBridge] secureDelete exception: %@", exception); + return false; + } + } +} + +/** + * Check if a key exists in the Keychain + * @param key The key to check + * @return true if exists + */ +bool PlatformAdapter_secureExists(const char* key) { + @autoreleasepool { + if (key == NULL) { + return false; + } + + NSString* keyStr = [NSString stringWithUTF8String:key]; + if (keyStr == nil) { + return false; + } + + @try { + return [[KeychainManager shared] existsForKey:keyStr]; + } @catch (NSException *exception) { + return false; + } + } +} + +/** + * Get persistent device UUID (from Keychain or generate new) + * @param outValue Pointer to store the UUID (must be freed by caller) + * @return true if successful + */ +bool PlatformAdapter_getPersistentDeviceUUID(char** outValue) { + @autoreleasepool { + if (outValue == NULL) { + return false; + } + + *outValue = NULL; + + @try { + NSString* uuid = [[PlatformAdapter shared] getPersistentDeviceUUID]; + if (uuid == nil || uuid.length == 0) { + NSLog(@"[PlatformAdapterBridge] getPersistentDeviceUUID: Failed to get UUID"); + return false; + } + + const char* utf8Value = [uuid UTF8String]; + if (utf8Value == NULL) { + return false; + } + + *outValue = strdup(utf8Value); + NSLog(@"[PlatformAdapterBridge] getPersistentDeviceUUID: %@", uuid); + return *outValue != NULL; + } @catch (NSException *exception) { + NSLog(@"[PlatformAdapterBridge] getPersistentDeviceUUID exception: %@", exception); + return false; + } + } +} + +// ============================================================================ +// Device Info (Synchronous) +// ============================================================================ + +#import +#import + +/** + * Get the raw machine identifier (e.g., "iPhone17,1") + */ +static NSString* getMachineIdentifier(void) { + struct utsname systemInfo; + uname(&systemInfo); + return [NSString stringWithCString:systemInfo.machine encoding:NSUTF8StringEncoding]; +} + +/** + * Get human-readable device model name + */ +static NSString* getDeviceModelName(NSString* identifier) { + // iPhone models + NSDictionary* models = @{ + // iPhone 16 series + @"iPhone17,1": @"iPhone 16 Pro", + @"iPhone17,2": @"iPhone 16 Pro Max", + @"iPhone17,3": @"iPhone 16", + @"iPhone17,4": @"iPhone 16 Plus", + // iPhone 15 series + @"iPhone16,1": @"iPhone 15 Pro", + @"iPhone16,2": @"iPhone 15 Pro Max", + @"iPhone15,4": @"iPhone 15", + @"iPhone15,5": @"iPhone 15 Plus", + // iPhone 14 series + @"iPhone15,2": @"iPhone 14 Pro", + @"iPhone15,3": @"iPhone 14 Pro Max", + @"iPhone14,7": @"iPhone 14", + @"iPhone14,8": @"iPhone 14 Plus", + // iPad models + @"iPad14,1": @"iPad Pro 11-inch (4th generation)", + @"iPad14,2": @"iPad Pro 12.9-inch (6th generation)", + // Simulator + @"x86_64": @"Simulator", + @"arm64": @"Simulator", + }; + + NSString* name = models[identifier]; + return name ?: identifier; +} + +/** + * Get chip name for device model + */ +static NSString* getChipNameForModel(NSString* identifier) { + NSDictionary* chips = @{ + // A18 Pro + @"iPhone17,1": @"A18 Pro", + @"iPhone17,2": @"A18 Pro", + // A18 + @"iPhone17,3": @"A18", + @"iPhone17,4": @"A18", + // A17 Pro + @"iPhone16,1": @"A17 Pro", + @"iPhone16,2": @"A17 Pro", + // A16 Bionic + @"iPhone15,2": @"A16 Bionic", + @"iPhone15,3": @"A16 Bionic", + @"iPhone15,4": @"A16 Bionic", + @"iPhone15,5": @"A16 Bionic", + // A15 Bionic + @"iPhone14,7": @"A15 Bionic", + @"iPhone14,8": @"A15 Bionic", + // M2 + @"iPad14,1": @"M2", + @"iPad14,2": @"M2", + }; + + NSString* chip = chips[identifier]; + return chip ?: @"Apple Silicon"; +} + +bool PlatformAdapter_getDeviceModel(char** outValue) { + @autoreleasepool { + if (!outValue) return false; + *outValue = NULL; + + @try { + NSString* identifier = getMachineIdentifier(); + + // Check for simulator + #if TARGET_OS_SIMULATOR + NSDictionary* env = [[NSProcessInfo processInfo] environment]; + NSString* simModelId = env[@"SIMULATOR_MODEL_IDENTIFIER"]; + if (simModelId) { + identifier = simModelId; + } + #endif + + NSString* modelName = getDeviceModelName(identifier); + *outValue = strdup([modelName UTF8String]); + return *outValue != NULL; + } @catch (NSException* exception) { + return false; + } + } +} + +bool PlatformAdapter_getOSVersion(char** outValue) { + @autoreleasepool { + if (!outValue) return false; + *outValue = NULL; + + @try { + NSString* version = [[UIDevice currentDevice] systemVersion]; + *outValue = strdup([version UTF8String]); + return *outValue != NULL; + } @catch (NSException* exception) { + return false; + } + } +} + +bool PlatformAdapter_getChipName(char** outValue) { + @autoreleasepool { + if (!outValue) return false; + *outValue = NULL; + + @try { + NSString* identifier = getMachineIdentifier(); + + // Check for simulator + #if TARGET_OS_SIMULATOR + NSDictionary* env = [[NSProcessInfo processInfo] environment]; + NSString* simModelId = env[@"SIMULATOR_MODEL_IDENTIFIER"]; + if (simModelId) { + identifier = simModelId; + } + #endif + + NSString* chipName = getChipNameForModel(identifier); + *outValue = strdup([chipName UTF8String]); + return *outValue != NULL; + } @catch (NSException* exception) { + return false; + } + } +} + +uint64_t PlatformAdapter_getTotalMemory(void) { + return [NSProcessInfo processInfo].physicalMemory; +} + +uint64_t PlatformAdapter_getAvailableMemory(void) { + vm_statistics64_data_t vmStats; + mach_msg_type_number_t infoCount = HOST_VM_INFO64_COUNT; + kern_return_t result = host_statistics64(mach_host_self(), HOST_VM_INFO64, + (host_info64_t)&vmStats, &infoCount); + if (result != KERN_SUCCESS) { + return 0; + } + + uint64_t pageSize = vm_page_size; + uint64_t freeMemory = vmStats.free_count * pageSize; + uint64_t inactiveMemory = vmStats.inactive_count * pageSize; + + return freeMemory + inactiveMemory; +} + +int PlatformAdapter_getCoreCount(void) { + return (int)[[NSProcessInfo processInfo] processorCount]; +} + +bool PlatformAdapter_getArchitecture(char** outValue) { + @autoreleasepool { + if (!outValue) return false; + *outValue = NULL; + + @try { + #if __arm64__ + *outValue = strdup("arm64"); + #elif __x86_64__ + *outValue = strdup("x86_64"); + #else + *outValue = strdup("unknown"); + #endif + return *outValue != NULL; + } @catch (NSException* exception) { + return false; + } + } +} + +bool PlatformAdapter_getGPUFamily(char** outValue) { + @autoreleasepool { + if (!outValue) return false; + *outValue = NULL; + + @try { + // All iOS/macOS devices use Apple's custom GPUs + *outValue = strdup("apple"); + return *outValue != NULL; + } @catch (NSException* exception) { + return false; + } + } +} + +/** + * Check if device is a tablet + * Uses UIDevice.userInterfaceIdiom to determine form factor + * Matches Swift SDK: device.userInterfaceIdiom == .pad + */ +bool PlatformAdapter_isTablet(void) { + @autoreleasepool { + @try { + UIUserInterfaceIdiom idiom = [[UIDevice currentDevice] userInterfaceIdiom]; + return idiom == UIUserInterfaceIdiomPad; + } @catch (NSException* exception) { + NSLog(@"[PlatformAdapterBridge] isTablet exception: %@", exception); + return false; + } + } +} + +// ============================================================================ +// HTTP POST for Device Registration (Synchronous) +// Matches Swift's CppBridge+Device.swift http_post callback +// ============================================================================ + +/** + * Synchronous HTTP POST for device registration + * Called from C++ device manager callbacks + * + * @param url Full URL to POST to + * @param jsonBody JSON body string + * @param supabaseKey Supabase API key (for dev mode, can be NULL) + * @param outStatusCode Pointer to store HTTP status code + * @param outResponseBody Pointer to store response body (must be freed by caller) + * @param outErrorMessage Pointer to store error message (must be freed by caller) + * @return true if request succeeded (2xx or 409) + */ +bool PlatformAdapter_httpPostSync( + const char* url, + const char* jsonBody, + const char* supabaseKey, + int* outStatusCode, + char** outResponseBody, + char** outErrorMessage +) { + @autoreleasepool { + if (!url || !jsonBody || !outStatusCode) { + if (outErrorMessage) *outErrorMessage = strdup("Invalid arguments"); + return false; + } + + *outStatusCode = 0; + if (outResponseBody) *outResponseBody = NULL; + if (outErrorMessage) *outErrorMessage = NULL; + + NSString* urlStr = [NSString stringWithUTF8String:url]; + NSString* bodyStr = [NSString stringWithUTF8String:jsonBody]; + NSString* apiKey = supabaseKey ? [NSString stringWithUTF8String:supabaseKey] : nil; + + if (!urlStr || !bodyStr) { + if (outErrorMessage) *outErrorMessage = strdup("Invalid URL or body"); + return false; + } + + NSURL* nsUrl = [NSURL URLWithString:urlStr]; + if (!nsUrl) { + if (outErrorMessage) *outErrorMessage = strdup("Invalid URL format"); + return false; + } + + NSLog(@"[PlatformAdapterBridge] HTTP POST to: %@", urlStr); + + // For Supabase device registration, add ?on_conflict=device_id for UPSERT + // This matches Swift's HTTPService.swift logic + if ([urlStr containsString:@"/rest/v1/sdk_devices"]) { + if (![urlStr containsString:@"on_conflict="]) { + NSString* separator = [urlStr containsString:@"?"] ? @"&" : @"?"; + urlStr = [NSString stringWithFormat:@"%@%@on_conflict=device_id", urlStr, separator]; + nsUrl = [NSURL URLWithString:urlStr]; + NSLog(@"[PlatformAdapterBridge] Added on_conflict for UPSERT: %@", urlStr); + } + } + + // Create request + NSMutableURLRequest* request = [NSMutableURLRequest requestWithURL:nsUrl]; + request.HTTPMethod = @"POST"; + request.HTTPBody = [bodyStr dataUsingEncoding:NSUTF8StringEncoding]; + request.timeoutInterval = 30.0; + + // Headers + [request setValue:@"application/json" forHTTPHeaderField:@"Content-Type"]; + [request setValue:@"application/json" forHTTPHeaderField:@"Accept"]; + + // Supabase headers (for device registration UPSERT) + if (apiKey) { + [request setValue:apiKey forHTTPHeaderField:@"apikey"]; + [request setValue:[NSString stringWithFormat:@"Bearer %@", apiKey] forHTTPHeaderField:@"Authorization"]; + [request setValue:@"resolution=merge-duplicates" forHTTPHeaderField:@"Prefer"]; + } + + // Synchronous request using semaphore (like Swift SDK) + __block NSData* responseData = nil; + __block NSHTTPURLResponse* httpResponse = nil; + __block NSError* error = nil; + + dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); + + NSURLSessionDataTask* task = [[NSURLSession sharedSession] + dataTaskWithRequest:request + completionHandler:^(NSData* data, NSURLResponse* response, NSError* err) { + responseData = data; + httpResponse = (NSHTTPURLResponse*)response; + error = err; + dispatch_semaphore_signal(semaphore); + }]; + + [task resume]; + + // Wait with 30 second timeout + dispatch_time_t timeout = dispatch_time(DISPATCH_TIME_NOW, 30 * NSEC_PER_SEC); + long result = dispatch_semaphore_wait(semaphore, timeout); + + if (result != 0) { + if (outErrorMessage) *outErrorMessage = strdup("Request timed out"); + NSLog(@"[PlatformAdapterBridge] HTTP POST timed out"); + return false; + } + + if (error) { + if (outErrorMessage) *outErrorMessage = strdup([[error localizedDescription] UTF8String]); + NSLog(@"[PlatformAdapterBridge] HTTP POST error: %@", error); + return false; + } + + *outStatusCode = (int)httpResponse.statusCode; + + // Store response body + if (responseData && outResponseBody) { + NSString* bodyString = [[NSString alloc] initWithData:responseData encoding:NSUTF8StringEncoding]; + if (bodyString) { + *outResponseBody = strdup([bodyString UTF8String]); + } + } + + // 2xx or 409 (conflict/already exists) = success for device registration + BOOL isSuccess = (httpResponse.statusCode >= 200 && httpResponse.statusCode < 300) || + httpResponse.statusCode == 409; + + // Log response body for debugging (especially on errors) + NSString* responseBodyStr = nil; + if (responseData) { + responseBodyStr = [[NSString alloc] initWithData:responseData encoding:NSUTF8StringEncoding]; + } + + if (!isSuccess) { + NSLog(@"[PlatformAdapterBridge] HTTP POST failed with status=%ld, response: %@", + (long)httpResponse.statusCode, responseBodyStr ?: @"(empty)"); + if (outErrorMessage) { + NSString* errorMsg = [NSString stringWithFormat:@"HTTP %ld: %@", + (long)httpResponse.statusCode, responseBodyStr ?: @"Unknown error"]; + *outErrorMessage = strdup([errorMsg UTF8String]); + } + } + + NSLog(@"[PlatformAdapterBridge] HTTP POST completed: status=%d success=%d", + *outStatusCode, isSuccess); + + return isSuccess; + } +} + diff --git a/sdk/runanywhere-react-native/packages/core/ios/RNSDKLoggerBridge.h b/sdk/runanywhere-react-native/packages/core/ios/RNSDKLoggerBridge.h new file mode 100644 index 000000000..28e40751d --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/ios/RNSDKLoggerBridge.h @@ -0,0 +1,41 @@ +/** + * RNSDKLoggerBridge.h + * + * Objective-C bridge header for SDKLogger. + * Allows C and Objective-C code to use the Swift SDKLogger. + */ + +#ifndef RNSDKLoggerBridge_h +#define RNSDKLoggerBridge_h + +#import + +/** + * Log level enum matching Swift RNLogLevel + */ +typedef NS_ENUM(NSInteger, RNLogLevelObjC) { + RNLogLevelObjCDebug = 0, + RNLogLevelObjCInfo = 1, + RNLogLevelObjCWarning = 2, + RNLogLevelObjCError = 3, + RNLogLevelObjCFault = 4 +}; + +/** + * Log a message with the specified category and level. + * @param category Logger category (e.g., "Archive", "AudioDecoder") + * @param level Log level + * @param message Log message + */ +void RNSDKLoggerLog(NSString * _Nonnull category, RNLogLevelObjC level, NSString * _Nonnull message); + +/** + * Convenience macros for logging from Objective-C + */ +#define RN_LOG_DEBUG(category, ...) RNSDKLoggerLog(category, RNLogLevelObjCDebug, [NSString stringWithFormat:__VA_ARGS__]) +#define RN_LOG_INFO(category, ...) RNSDKLoggerLog(category, RNLogLevelObjCInfo, [NSString stringWithFormat:__VA_ARGS__]) +#define RN_LOG_WARNING(category, ...) RNSDKLoggerLog(category, RNLogLevelObjCWarning, [NSString stringWithFormat:__VA_ARGS__]) +#define RN_LOG_ERROR(category, ...) RNSDKLoggerLog(category, RNLogLevelObjCError, [NSString stringWithFormat:__VA_ARGS__]) +#define RN_LOG_FAULT(category, ...) RNSDKLoggerLog(category, RNLogLevelObjCFault, [NSString stringWithFormat:__VA_ARGS__]) + +#endif /* RNSDKLoggerBridge_h */ diff --git a/sdk/runanywhere-react-native/packages/core/ios/RNSDKLoggerBridge.m b/sdk/runanywhere-react-native/packages/core/ios/RNSDKLoggerBridge.m new file mode 100644 index 000000000..d1d208c71 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/ios/RNSDKLoggerBridge.m @@ -0,0 +1,66 @@ +/** + * RNSDKLoggerBridge.m + * + * Implementation of the Objective-C bridge for SDKLogger. + * Routes log messages to the Swift SDKLogger. + */ + +#import "RNSDKLoggerBridge.h" + +// Import the generated Swift header from the pod +#if __has_include() +#import +#elif __has_include("RunAnywhereCore-Swift.h") +#import "RunAnywhereCore-Swift.h" +#else +// Fallback: Forward declare the Swift class if header not found +@interface SDKLogger : NSObject +- (instancetype _Nonnull)initWithCategory:(NSString * _Nonnull)category; +- (void)debug:(NSString * _Nonnull)message metadata:(NSDictionary * _Nullable)metadata; +- (void)info:(NSString * _Nonnull)message metadata:(NSDictionary * _Nullable)metadata; +- (void)warning:(NSString * _Nonnull)message metadata:(NSDictionary * _Nullable)metadata; +- (void)error:(NSString * _Nonnull)message metadata:(NSDictionary * _Nullable)metadata; +- (void)fault:(NSString * _Nonnull)message metadata:(NSDictionary * _Nullable)metadata; +@end +#endif + +// Cache loggers for common categories +static NSMutableDictionary *loggerCache = nil; + +static SDKLogger *getLogger(NSString *category) { + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + loggerCache = [NSMutableDictionary new]; + }); + + @synchronized (loggerCache) { + SDKLogger *logger = loggerCache[category]; + if (!logger) { + logger = [[SDKLogger alloc] initWithCategory:category]; + loggerCache[category] = logger; + } + return logger; + } +} + +void RNSDKLoggerLog(NSString * _Nonnull category, RNLogLevelObjC level, NSString * _Nonnull message) { + SDKLogger *logger = getLogger(category); + + switch (level) { + case RNLogLevelObjCDebug: + [logger debug:message metadata:nil]; + break; + case RNLogLevelObjCInfo: + [logger info:message metadata:nil]; + break; + case RNLogLevelObjCWarning: + [logger warning:message metadata:nil]; + break; + case RNLogLevelObjCError: + [logger error:message metadata:nil]; + break; + case RNLogLevelObjCFault: + [logger fault:message metadata:nil]; + break; + } +} diff --git a/sdk/runanywhere-react-native/packages/core/ios/SDKLogger.swift b/sdk/runanywhere-react-native/packages/core/ios/SDKLogger.swift new file mode 100644 index 000000000..eaefef760 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/ios/SDKLogger.swift @@ -0,0 +1,329 @@ +/** + * SDKLogger.swift + * + * iOS native logging implementation for React Native SDK. + * Provides structured logging with category-based filtering. + * + * Matches: + * - iOS SDK: sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Logging/SDKLogger.swift + * - TypeScript: packages/core/src/Foundation/Logging/Logger/SDKLogger.ts + * + * Usage: + * SDKLogger.shared.info("SDK initialized") + * SDKLogger.download.debug("Starting download: \(url)") + * SDKLogger.llm.error("Generation failed", metadata: ["modelId": "llama-3.2"]) + */ + +import Foundation +import os + +// MARK: - LogLevel + +/// Log severity levels matching TypeScript LogLevel enum +@objc public enum RNLogLevel: Int, Comparable { + case debug = 0 + case info = 1 + case warning = 2 + case error = 3 + case fault = 4 + + public static func < (lhs: RNLogLevel, rhs: RNLogLevel) -> Bool { + lhs.rawValue < rhs.rawValue + } + + public var description: String { + switch self { + case .debug: return "DEBUG" + case .info: return "INFO" + case .warning: return "WARN" + case .error: return "ERROR" + case .fault: return "FAULT" + } + } +} + +// MARK: - SDKLogger + +// MARK: - Log Entry (for forwarding to TypeScript) + +/// Log entry structure for forwarding to TypeScript +/// Matches TypeScript: LogEntry interface +@objc public class NativeLogEntry: NSObject { + @objc public let level: Int + @objc public let category: String + @objc public let message: String + @objc public let metadata: [String: Any]? + @objc public let timestamp: Date + + @objc public init(level: RNLogLevel, category: String, message: String, metadata: [String: Any]?, timestamp: Date) { + self.level = level.rawValue + self.category = category + self.message = message + self.metadata = metadata + self.timestamp = timestamp + super.init() + } + + /// Convert to dictionary for JSON serialization + @objc public func toDictionary() -> [String: Any] { + var dict: [String: Any] = [ + "level": level, + "category": category, + "message": message, + "timestamp": ISO8601DateFormatter().string(from: timestamp) + ] + if let metadata = metadata { + // Convert metadata to JSON-safe types + dict["metadata"] = metadata.mapValues { value -> Any in + if let stringValue = value as? String { return stringValue } + if let numberValue = value as? NSNumber { return numberValue } + return String(describing: value) + } + } + return dict + } +} + +// MARK: - Log Forwarder Protocol + +/// Protocol for forwarding logs to TypeScript +@objc public protocol NativeLogForwarder { + func forwardLog(_ entry: NativeLogEntry) +} + +/// Simple logger for SDK components with category-based filtering. +/// Thread-safe and easy to use. Supports forwarding to TypeScript. +@objc public final class SDKLogger: NSObject { + + // MARK: - Properties + + /// Logger category (e.g., "LLM", "Download", "Models") + public let category: String + + /// Minimum log level (logs below this level are ignored) + private static var minLogLevel: RNLogLevel = .debug + + /// Whether local console logging is enabled + private static var localLoggingEnabled = true + + /// Whether to forward logs to TypeScript + private static var forwardingEnabled = true + + /// Log forwarder for TypeScript bridge + private static var logForwarder: NativeLogForwarder? + + /// OSLog instance for this category + private lazy var osLog: OSLog = { + OSLog(subsystem: "com.runanywhere.reactnative", category: category) + }() + + // MARK: - Initialization + + /// Create a new logger with the specified category. + /// - Parameter category: Category name for log filtering + @objc public init(category: String = "SDK") { + self.category = category + super.init() + } + + // MARK: - Configuration + + /// Set the minimum log level. + /// - Parameter level: Minimum level to log + @objc public static func setMinLogLevel(_ level: RNLogLevel) { + minLogLevel = level + } + + /// Get the current minimum log level. + @objc public static func getMinLogLevel() -> RNLogLevel { + return minLogLevel + } + + /// Enable or disable local console logging. + /// - Parameter enabled: Whether to log to console + @objc public static func setLocalLoggingEnabled(_ enabled: Bool) { + localLoggingEnabled = enabled + } + + /// Enable or disable log forwarding to TypeScript. + /// - Parameter enabled: Whether to forward logs + @objc public static func setForwardingEnabled(_ enabled: Bool) { + forwardingEnabled = enabled + } + + /// Set the log forwarder for TypeScript bridge. + /// - Parameter forwarder: Log forwarder implementation + @objc public static func setLogForwarder(_ forwarder: NativeLogForwarder?) { + logForwarder = forwarder + } + + /// Check if log forwarding is configured + @objc public static func isForwardingConfigured() -> Bool { + return logForwarder != nil && forwardingEnabled + } + + // MARK: - Logging Methods + + /// Log a debug message. + /// - Parameters: + /// - message: Log message + /// - metadata: Optional metadata dictionary + @objc public func debug(_ message: String, metadata: [String: Any]? = nil) { + log(level: .debug, message: message, metadata: metadata) + } + + /// Log an info message. + /// - Parameters: + /// - message: Log message + /// - metadata: Optional metadata dictionary + @objc public func info(_ message: String, metadata: [String: Any]? = nil) { + log(level: .info, message: message, metadata: metadata) + } + + /// Log a warning message. + /// - Parameters: + /// - message: Log message + /// - metadata: Optional metadata dictionary + @objc public func warning(_ message: String, metadata: [String: Any]? = nil) { + log(level: .warning, message: message, metadata: metadata) + } + + /// Log an error message. + /// - Parameters: + /// - message: Log message + /// - metadata: Optional metadata dictionary + @objc public func error(_ message: String, metadata: [String: Any]? = nil) { + log(level: .error, message: message, metadata: metadata) + } + + /// Log a fault/critical message. + /// - Parameters: + /// - message: Log message + /// - metadata: Optional metadata dictionary + @objc public func fault(_ message: String, metadata: [String: Any]? = nil) { + log(level: .fault, message: message, metadata: metadata) + } + + // MARK: - Error Logging + + /// Log an Error with full context. + /// - Parameters: + /// - error: Error to log + /// - additionalInfo: Optional additional context + @objc public func logError(_ error: Error, additionalInfo: String? = nil) { + let nsError = error as NSError + var message = error.localizedDescription + if let info = additionalInfo { + message += " | Context: \(info)" + } + + var metadata: [String: Any] = [ + "error_domain": nsError.domain, + "error_code": nsError.code + ] + + if !nsError.userInfo.isEmpty { + metadata["error_userInfo"] = nsError.userInfo.description + } + + log(level: .error, message: message, metadata: metadata) + } + + // MARK: - Core Logging + + /// Log a message with the specified level. + /// - Parameters: + /// - level: Log level + /// - message: Log message + /// - metadata: Optional metadata dictionary + public func log(level: RNLogLevel, message: String, metadata: [String: Any]? = nil) { + guard level >= Self.minLogLevel else { return } + + let timestamp = Date() + + // Build formatted message + var output = "[\(category)] \(message)" + if let metadata = metadata, !metadata.isEmpty { + let metaStr = metadata.map { "\($0.key)=\($0.value)" }.joined(separator: ", ") + output += " | \(metaStr)" + } + + // Log to OSLog (always, for system log capture) + switch level { + case .debug: + os_log(.debug, log: osLog, "%{public}@", output) + case .info: + os_log(.info, log: osLog, "%{public}@", output) + case .warning: + os_log(.default, log: osLog, "[WARN] %{public}@", output) + case .error: + os_log(.error, log: osLog, "%{public}@", output) + case .fault: + os_log(.fault, log: osLog, "%{public}@", output) + } + + // Also log to console if enabled + if Self.localLoggingEnabled { + let emoji: String + switch level { + case .debug: emoji = "[DEBUG]" + case .info: emoji = "[INFO]" + case .warning: emoji = "[WARN]" + case .error: emoji = "[ERROR]" + case .fault: emoji = "[FAULT]" + } + // swiftlint:disable:next no_print_statements + NSLog("%@ %@", emoji, output) + } + + // Forward to TypeScript if enabled + if Self.forwardingEnabled, let forwarder = Self.logForwarder { + let entry = NativeLogEntry( + level: level, + category: category, + message: message, + metadata: metadata, + timestamp: timestamp + ) + forwarder.forwardLog(entry) + } + } + + // MARK: - Convenience Loggers (Static) + + /// Shared logger for general SDK operations. Category: "RunAnywhere" + @objc public static let shared = SDKLogger(category: "RunAnywhere") + + /// Logger for LLM operations. Category: "LLM" + @objc public static let llm = SDKLogger(category: "LLM") + + /// Logger for STT (Speech-to-Text) operations. Category: "STT" + @objc public static let stt = SDKLogger(category: "STT") + + /// Logger for TTS (Text-to-Speech) operations. Category: "TTS" + @objc public static let tts = SDKLogger(category: "TTS") + + /// Logger for download operations. Category: "Download" + @objc public static let download = SDKLogger(category: "Download") + + /// Logger for model operations. Category: "Models" + @objc public static let models = SDKLogger(category: "Models") + + /// Logger for core SDK operations. Category: "Core" + @objc public static let core = SDKLogger(category: "Core") + + /// Logger for VAD operations. Category: "VAD" + @objc public static let vad = SDKLogger(category: "VAD") + + /// Logger for network operations. Category: "Network" + @objc public static let network = SDKLogger(category: "Network") + + /// Logger for events. Category: "Events" + @objc public static let events = SDKLogger(category: "Events") + + /// Logger for archive/extraction operations. Category: "Archive" + @objc public static let archive = SDKLogger(category: "Archive") + + /// Logger for audio decoding operations. Category: "AudioDecoder" + @objc public static let audioDecoder = SDKLogger(category: "AudioDecoder") +} diff --git a/sdk/runanywhere-react-native/packages/core/nitro.json b/sdk/runanywhere-react-native/packages/core/nitro.json new file mode 100644 index 000000000..b2f562ba5 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/nitro.json @@ -0,0 +1,20 @@ +{ + "cxxNamespace": ["runanywhere"], + "ios": { + "iosModuleName": "RunAnywhereCore" + }, + "android": { + "androidNamespace": ["runanywhere"], + "androidCxxLibName": "runanywherecore" + }, + "autolinking": { + "RunAnywhereCore": { + "cpp": "HybridRunAnywhereCore" + }, + "RunAnywhereDeviceInfo": { + "kotlin": "HybridRunAnywhereDeviceInfo", + "swift": "HybridRunAnywhereDeviceInfo" + } + }, + "ignorePaths": ["node_modules", "lib", "example"] +} diff --git a/sdk/runanywhere-react-native/packages/core/nitrogen/generated/.gitattributes b/sdk/runanywhere-react-native/packages/core/nitrogen/generated/.gitattributes new file mode 100644 index 000000000..fb7a0d5a3 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/nitrogen/generated/.gitattributes @@ -0,0 +1 @@ +** linguist-generated=true diff --git a/sdk/runanywhere-react-native/packages/core/nitrogen/generated/android/c++/JHybridRunAnywhereDeviceInfoSpec.cpp b/sdk/runanywhere-react-native/packages/core/nitrogen/generated/android/c++/JHybridRunAnywhereDeviceInfoSpec.cpp new file mode 100644 index 000000000..00259133c --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/nitrogen/generated/android/c++/JHybridRunAnywhereDeviceInfoSpec.cpp @@ -0,0 +1,257 @@ +/// +/// JHybridRunAnywhereDeviceInfoSpec.cpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2026 Marc Rousavy @ Margelo +/// + +#include "JHybridRunAnywhereDeviceInfoSpec.hpp" + + + +#include +#include +#include + +namespace margelo::nitro::runanywhere { + + jni::local_ref JHybridRunAnywhereDeviceInfoSpec::initHybrid(jni::alias_ref jThis) { + return makeCxxInstance(jThis); + } + + void JHybridRunAnywhereDeviceInfoSpec::registerNatives() { + registerHybrid({ + makeNativeMethod("initHybrid", JHybridRunAnywhereDeviceInfoSpec::initHybrid), + }); + } + + size_t JHybridRunAnywhereDeviceInfoSpec::getExternalMemorySize() noexcept { + static const auto method = javaClassStatic()->getMethod("getMemorySize"); + return method(_javaPart); + } + + void JHybridRunAnywhereDeviceInfoSpec::dispose() noexcept { + static const auto method = javaClassStatic()->getMethod("dispose"); + method(_javaPart); + } + + std::string JHybridRunAnywhereDeviceInfoSpec::toString() { + static const auto method = javaClassStatic()->getMethod("toString"); + auto javaString = method(_javaPart); + return javaString->toStdString(); + } + + // Properties + + + // Methods + std::shared_ptr> JHybridRunAnywhereDeviceInfoSpec::getDeviceModel() { + static const auto method = javaClassStatic()->getMethod()>("getDeviceModel"); + auto __result = method(_javaPart); + return [&]() { + auto __promise = Promise::create(); + __result->cthis()->addOnResolvedListener([=](const jni::alias_ref& __boxedResult) { + auto __result = jni::static_ref_cast(__boxedResult); + __promise->resolve(__result->toStdString()); + }); + __result->cthis()->addOnRejectedListener([=](const jni::alias_ref& __throwable) { + jni::JniException __jniError(__throwable); + __promise->reject(std::make_exception_ptr(__jniError)); + }); + return __promise; + }(); + } + std::shared_ptr> JHybridRunAnywhereDeviceInfoSpec::getOSVersion() { + static const auto method = javaClassStatic()->getMethod()>("getOSVersion"); + auto __result = method(_javaPart); + return [&]() { + auto __promise = Promise::create(); + __result->cthis()->addOnResolvedListener([=](const jni::alias_ref& __boxedResult) { + auto __result = jni::static_ref_cast(__boxedResult); + __promise->resolve(__result->toStdString()); + }); + __result->cthis()->addOnRejectedListener([=](const jni::alias_ref& __throwable) { + jni::JniException __jniError(__throwable); + __promise->reject(std::make_exception_ptr(__jniError)); + }); + return __promise; + }(); + } + std::shared_ptr> JHybridRunAnywhereDeviceInfoSpec::getPlatform() { + static const auto method = javaClassStatic()->getMethod()>("getPlatform"); + auto __result = method(_javaPart); + return [&]() { + auto __promise = Promise::create(); + __result->cthis()->addOnResolvedListener([=](const jni::alias_ref& __boxedResult) { + auto __result = jni::static_ref_cast(__boxedResult); + __promise->resolve(__result->toStdString()); + }); + __result->cthis()->addOnRejectedListener([=](const jni::alias_ref& __throwable) { + jni::JniException __jniError(__throwable); + __promise->reject(std::make_exception_ptr(__jniError)); + }); + return __promise; + }(); + } + std::shared_ptr> JHybridRunAnywhereDeviceInfoSpec::getTotalRAM() { + static const auto method = javaClassStatic()->getMethod()>("getTotalRAM"); + auto __result = method(_javaPart); + return [&]() { + auto __promise = Promise::create(); + __result->cthis()->addOnResolvedListener([=](const jni::alias_ref& __boxedResult) { + auto __result = jni::static_ref_cast(__boxedResult); + __promise->resolve(__result->value()); + }); + __result->cthis()->addOnRejectedListener([=](const jni::alias_ref& __throwable) { + jni::JniException __jniError(__throwable); + __promise->reject(std::make_exception_ptr(__jniError)); + }); + return __promise; + }(); + } + std::shared_ptr> JHybridRunAnywhereDeviceInfoSpec::getAvailableRAM() { + static const auto method = javaClassStatic()->getMethod()>("getAvailableRAM"); + auto __result = method(_javaPart); + return [&]() { + auto __promise = Promise::create(); + __result->cthis()->addOnResolvedListener([=](const jni::alias_ref& __boxedResult) { + auto __result = jni::static_ref_cast(__boxedResult); + __promise->resolve(__result->value()); + }); + __result->cthis()->addOnRejectedListener([=](const jni::alias_ref& __throwable) { + jni::JniException __jniError(__throwable); + __promise->reject(std::make_exception_ptr(__jniError)); + }); + return __promise; + }(); + } + std::shared_ptr> JHybridRunAnywhereDeviceInfoSpec::getCPUCores() { + static const auto method = javaClassStatic()->getMethod()>("getCPUCores"); + auto __result = method(_javaPart); + return [&]() { + auto __promise = Promise::create(); + __result->cthis()->addOnResolvedListener([=](const jni::alias_ref& __boxedResult) { + auto __result = jni::static_ref_cast(__boxedResult); + __promise->resolve(__result->value()); + }); + __result->cthis()->addOnRejectedListener([=](const jni::alias_ref& __throwable) { + jni::JniException __jniError(__throwable); + __promise->reject(std::make_exception_ptr(__jniError)); + }); + return __promise; + }(); + } + std::shared_ptr> JHybridRunAnywhereDeviceInfoSpec::hasGPU() { + static const auto method = javaClassStatic()->getMethod()>("hasGPU"); + auto __result = method(_javaPart); + return [&]() { + auto __promise = Promise::create(); + __result->cthis()->addOnResolvedListener([=](const jni::alias_ref& __boxedResult) { + auto __result = jni::static_ref_cast(__boxedResult); + __promise->resolve(static_cast(__result->value())); + }); + __result->cthis()->addOnRejectedListener([=](const jni::alias_ref& __throwable) { + jni::JniException __jniError(__throwable); + __promise->reject(std::make_exception_ptr(__jniError)); + }); + return __promise; + }(); + } + std::shared_ptr> JHybridRunAnywhereDeviceInfoSpec::hasNPU() { + static const auto method = javaClassStatic()->getMethod()>("hasNPU"); + auto __result = method(_javaPart); + return [&]() { + auto __promise = Promise::create(); + __result->cthis()->addOnResolvedListener([=](const jni::alias_ref& __boxedResult) { + auto __result = jni::static_ref_cast(__boxedResult); + __promise->resolve(static_cast(__result->value())); + }); + __result->cthis()->addOnRejectedListener([=](const jni::alias_ref& __throwable) { + jni::JniException __jniError(__throwable); + __promise->reject(std::make_exception_ptr(__jniError)); + }); + return __promise; + }(); + } + std::shared_ptr> JHybridRunAnywhereDeviceInfoSpec::getChipName() { + static const auto method = javaClassStatic()->getMethod()>("getChipName"); + auto __result = method(_javaPart); + return [&]() { + auto __promise = Promise::create(); + __result->cthis()->addOnResolvedListener([=](const jni::alias_ref& __boxedResult) { + auto __result = jni::static_ref_cast(__boxedResult); + __promise->resolve(__result->toStdString()); + }); + __result->cthis()->addOnRejectedListener([=](const jni::alias_ref& __throwable) { + jni::JniException __jniError(__throwable); + __promise->reject(std::make_exception_ptr(__jniError)); + }); + return __promise; + }(); + } + std::shared_ptr> JHybridRunAnywhereDeviceInfoSpec::getThermalState() { + static const auto method = javaClassStatic()->getMethod()>("getThermalState"); + auto __result = method(_javaPart); + return [&]() { + auto __promise = Promise::create(); + __result->cthis()->addOnResolvedListener([=](const jni::alias_ref& __boxedResult) { + auto __result = jni::static_ref_cast(__boxedResult); + __promise->resolve(__result->value()); + }); + __result->cthis()->addOnRejectedListener([=](const jni::alias_ref& __throwable) { + jni::JniException __jniError(__throwable); + __promise->reject(std::make_exception_ptr(__jniError)); + }); + return __promise; + }(); + } + std::shared_ptr> JHybridRunAnywhereDeviceInfoSpec::getBatteryLevel() { + static const auto method = javaClassStatic()->getMethod()>("getBatteryLevel"); + auto __result = method(_javaPart); + return [&]() { + auto __promise = Promise::create(); + __result->cthis()->addOnResolvedListener([=](const jni::alias_ref& __boxedResult) { + auto __result = jni::static_ref_cast(__boxedResult); + __promise->resolve(__result->value()); + }); + __result->cthis()->addOnRejectedListener([=](const jni::alias_ref& __throwable) { + jni::JniException __jniError(__throwable); + __promise->reject(std::make_exception_ptr(__jniError)); + }); + return __promise; + }(); + } + std::shared_ptr> JHybridRunAnywhereDeviceInfoSpec::isCharging() { + static const auto method = javaClassStatic()->getMethod()>("isCharging"); + auto __result = method(_javaPart); + return [&]() { + auto __promise = Promise::create(); + __result->cthis()->addOnResolvedListener([=](const jni::alias_ref& __boxedResult) { + auto __result = jni::static_ref_cast(__boxedResult); + __promise->resolve(static_cast(__result->value())); + }); + __result->cthis()->addOnRejectedListener([=](const jni::alias_ref& __throwable) { + jni::JniException __jniError(__throwable); + __promise->reject(std::make_exception_ptr(__jniError)); + }); + return __promise; + }(); + } + std::shared_ptr> JHybridRunAnywhereDeviceInfoSpec::isLowPowerMode() { + static const auto method = javaClassStatic()->getMethod()>("isLowPowerMode"); + auto __result = method(_javaPart); + return [&]() { + auto __promise = Promise::create(); + __result->cthis()->addOnResolvedListener([=](const jni::alias_ref& __boxedResult) { + auto __result = jni::static_ref_cast(__boxedResult); + __promise->resolve(static_cast(__result->value())); + }); + __result->cthis()->addOnRejectedListener([=](const jni::alias_ref& __throwable) { + jni::JniException __jniError(__throwable); + __promise->reject(std::make_exception_ptr(__jniError)); + }); + return __promise; + }(); + } + +} // namespace margelo::nitro::runanywhere diff --git a/sdk/runanywhere-react-native/packages/core/nitrogen/generated/android/c++/JHybridRunAnywhereDeviceInfoSpec.hpp b/sdk/runanywhere-react-native/packages/core/nitrogen/generated/android/c++/JHybridRunAnywhereDeviceInfoSpec.hpp new file mode 100644 index 000000000..9014b8419 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/nitrogen/generated/android/c++/JHybridRunAnywhereDeviceInfoSpec.hpp @@ -0,0 +1,77 @@ +/// +/// HybridRunAnywhereDeviceInfoSpec.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2026 Marc Rousavy @ Margelo +/// + +#pragma once + +#include +#include +#include "HybridRunAnywhereDeviceInfoSpec.hpp" + + + + +namespace margelo::nitro::runanywhere { + + using namespace facebook; + + class JHybridRunAnywhereDeviceInfoSpec: public jni::HybridClass, + public virtual HybridRunAnywhereDeviceInfoSpec { + public: + static auto constexpr kJavaDescriptor = "Lcom/margelo/nitro/runanywhere/HybridRunAnywhereDeviceInfoSpec;"; + static jni::local_ref initHybrid(jni::alias_ref jThis); + static void registerNatives(); + + protected: + // C++ constructor (called from Java via `initHybrid()`) + explicit JHybridRunAnywhereDeviceInfoSpec(jni::alias_ref jThis) : + HybridObject(HybridRunAnywhereDeviceInfoSpec::TAG), + HybridBase(jThis), + _javaPart(jni::make_global(jThis)) {} + + public: + ~JHybridRunAnywhereDeviceInfoSpec() override { + // Hermes GC can destroy JS objects on a non-JNI Thread. + jni::ThreadScope::WithClassLoader([&] { _javaPart.reset(); }); + } + + public: + size_t getExternalMemorySize() noexcept override; + void dispose() noexcept override; + std::string toString() override; + + public: + inline const jni::global_ref& getJavaPart() const noexcept { + return _javaPart; + } + + public: + // Properties + + + public: + // Methods + std::shared_ptr> getDeviceModel() override; + std::shared_ptr> getOSVersion() override; + std::shared_ptr> getPlatform() override; + std::shared_ptr> getTotalRAM() override; + std::shared_ptr> getAvailableRAM() override; + std::shared_ptr> getCPUCores() override; + std::shared_ptr> hasGPU() override; + std::shared_ptr> hasNPU() override; + std::shared_ptr> getChipName() override; + std::shared_ptr> getThermalState() override; + std::shared_ptr> getBatteryLevel() override; + std::shared_ptr> isCharging() override; + std::shared_ptr> isLowPowerMode() override; + + private: + friend HybridBase; + using HybridBase::HybridBase; + jni::global_ref _javaPart; + }; + +} // namespace margelo::nitro::runanywhere diff --git a/sdk/runanywhere-react-native/packages/core/nitrogen/generated/android/kotlin/com/margelo/nitro/runanywhere/HybridRunAnywhereDeviceInfoSpec.kt b/sdk/runanywhere-react-native/packages/core/nitrogen/generated/android/kotlin/com/margelo/nitro/runanywhere/HybridRunAnywhereDeviceInfoSpec.kt new file mode 100644 index 000000000..aced8a206 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/nitrogen/generated/android/kotlin/com/margelo/nitro/runanywhere/HybridRunAnywhereDeviceInfoSpec.kt @@ -0,0 +1,106 @@ +/// +/// HybridRunAnywhereDeviceInfoSpec.kt +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2026 Marc Rousavy @ Margelo +/// + +package com.margelo.nitro.runanywhere + +import androidx.annotation.Keep +import com.facebook.jni.HybridData +import com.facebook.proguard.annotations.DoNotStrip +import com.margelo.nitro.core.Promise +import com.margelo.nitro.core.HybridObject + +/** + * A Kotlin class representing the RunAnywhereDeviceInfo HybridObject. + * Implement this abstract class to create Kotlin-based instances of RunAnywhereDeviceInfo. + */ +@DoNotStrip +@Keep +@Suppress( + "KotlinJniMissingFunction", "unused", + "RedundantSuppression", "RedundantUnitReturnType", "SimpleRedundantLet", + "LocalVariableName", "PropertyName", "PrivatePropertyName", "FunctionName" +) +abstract class HybridRunAnywhereDeviceInfoSpec: HybridObject() { + @DoNotStrip + private var mHybridData: HybridData = initHybrid() + + init { + super.updateNative(mHybridData) + } + + override fun updateNative(hybridData: HybridData) { + mHybridData = hybridData + super.updateNative(hybridData) + } + + // Default implementation of `HybridObject.toString()` + override fun toString(): String { + return "[HybridObject RunAnywhereDeviceInfo]" + } + + // Properties + + + // Methods + @DoNotStrip + @Keep + abstract fun getDeviceModel(): Promise + + @DoNotStrip + @Keep + abstract fun getOSVersion(): Promise + + @DoNotStrip + @Keep + abstract fun getPlatform(): Promise + + @DoNotStrip + @Keep + abstract fun getTotalRAM(): Promise + + @DoNotStrip + @Keep + abstract fun getAvailableRAM(): Promise + + @DoNotStrip + @Keep + abstract fun getCPUCores(): Promise + + @DoNotStrip + @Keep + abstract fun hasGPU(): Promise + + @DoNotStrip + @Keep + abstract fun hasNPU(): Promise + + @DoNotStrip + @Keep + abstract fun getChipName(): Promise + + @DoNotStrip + @Keep + abstract fun getThermalState(): Promise + + @DoNotStrip + @Keep + abstract fun getBatteryLevel(): Promise + + @DoNotStrip + @Keep + abstract fun isCharging(): Promise + + @DoNotStrip + @Keep + abstract fun isLowPowerMode(): Promise + + private external fun initHybrid(): HybridData + + companion object { + protected const val TAG = "HybridRunAnywhereDeviceInfoSpec" + } +} diff --git a/sdk/runanywhere-react-native/packages/core/nitrogen/generated/android/kotlin/com/margelo/nitro/runanywhere/runanywherecoreOnLoad.kt b/sdk/runanywhere-react-native/packages/core/nitrogen/generated/android/kotlin/com/margelo/nitro/runanywhere/runanywherecoreOnLoad.kt new file mode 100644 index 000000000..a6db5a7cb --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/nitrogen/generated/android/kotlin/com/margelo/nitro/runanywhere/runanywherecoreOnLoad.kt @@ -0,0 +1,35 @@ +/// +/// runanywherecoreOnLoad.kt +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2026 Marc Rousavy @ Margelo +/// + +package com.margelo.nitro.runanywhere + +import android.util.Log + +internal class runanywherecoreOnLoad { + companion object { + private const val TAG = "runanywherecoreOnLoad" + private var didLoad = false + /** + * Initializes the native part of "runanywherecore". + * This method is idempotent and can be called more than once. + */ + @JvmStatic + fun initializeNative() { + if (didLoad) return + try { + Log.i(TAG, "Loading runanywherecore C++ library...") + System.loadLibrary("runanywherecore") + Log.i(TAG, "Successfully loaded runanywherecore C++ library!") + didLoad = true + } catch (e: Error) { + Log.e(TAG, "Failed to load runanywherecore C++ library! Is it properly installed and linked? " + + "Is the name correct? (see `CMakeLists.txt`, at `add_library(...)`)", e) + throw e + } + } + } +} diff --git a/sdk/runanywhere-react-native/packages/core/nitrogen/generated/android/runanywherecore+autolinking.cmake b/sdk/runanywhere-react-native/packages/core/nitrogen/generated/android/runanywherecore+autolinking.cmake new file mode 100644 index 000000000..9b8f566ef --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/nitrogen/generated/android/runanywherecore+autolinking.cmake @@ -0,0 +1,82 @@ +# +# runanywherecore+autolinking.cmake +# This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +# https://github.com/mrousavy/nitro +# Copyright © 2026 Marc Rousavy @ Margelo +# + +# This is a CMake file that adds all files generated by Nitrogen +# to the current CMake project. +# +# To use it, add this to your CMakeLists.txt: +# ```cmake +# include(${CMAKE_SOURCE_DIR}/../nitrogen/generated/android/runanywherecore+autolinking.cmake) +# ``` + +# Define a flag to check if we are building properly +add_definitions(-DBUILDING_RUNANYWHERECORE_WITH_GENERATED_CMAKE_PROJECT) + +# Enable Raw Props parsing in react-native (for Nitro Views) +add_definitions(-DRN_SERIALIZABLE_STATE) + +# Add all headers that were generated by Nitrogen +include_directories( + "../nitrogen/generated/shared/c++" + "../nitrogen/generated/android/c++" + "../nitrogen/generated/android/" +) + +# Add all .cpp sources that were generated by Nitrogen +target_sources( + # CMake project name (Android C++ library name) + runanywherecore PRIVATE + # Autolinking Setup + ../nitrogen/generated/android/runanywherecoreOnLoad.cpp + # Shared Nitrogen C++ sources + ../nitrogen/generated/shared/c++/HybridRunAnywhereCoreSpec.cpp + ../nitrogen/generated/shared/c++/HybridRunAnywhereDeviceInfoSpec.cpp + # Android-specific Nitrogen C++ sources + ../nitrogen/generated/android/c++/JHybridRunAnywhereDeviceInfoSpec.cpp +) + +# From node_modules/react-native/ReactAndroid/cmake-utils/folly-flags.cmake +# Used in node_modules/react-native/ReactAndroid/cmake-utils/ReactNative-application.cmake +target_compile_definitions( + runanywherecore PRIVATE + -DFOLLY_NO_CONFIG=1 + -DFOLLY_HAVE_CLOCK_GETTIME=1 + -DFOLLY_USE_LIBCPP=1 + -DFOLLY_CFG_NO_COROUTINES=1 + -DFOLLY_MOBILE=1 + -DFOLLY_HAVE_RECVMMSG=1 + -DFOLLY_HAVE_PTHREAD=1 + # Once we target android-23 above, we can comment + # the following line. NDK uses GNU style stderror_r() after API 23. + -DFOLLY_HAVE_XSI_STRERROR_R=1 +) + +# Add all libraries required by the generated specs +find_package(fbjni REQUIRED) # <-- Used for communication between Java <-> C++ +find_package(ReactAndroid REQUIRED) # <-- Used to set up React Native bindings (e.g. CallInvoker/TurboModule) +find_package(react-native-nitro-modules REQUIRED) # <-- Used to create all HybridObjects and use the Nitro core library + +# Link all libraries together +target_link_libraries( + runanywherecore + fbjni::fbjni # <-- Facebook C++ JNI helpers + ReactAndroid::jsi # <-- RN: JSI + react-native-nitro-modules::NitroModules # <-- NitroModules Core :) +) + +# Link react-native (different prefab between RN 0.75 and RN 0.76) +if(ReactAndroid_VERSION_MINOR GREATER_EQUAL 76) + target_link_libraries( + runanywherecore + ReactAndroid::reactnative # <-- RN: Native Modules umbrella prefab + ) +else() + target_link_libraries( + runanywherecore + ReactAndroid::react_nativemodule_core # <-- RN: TurboModules Core + ) +endif() diff --git a/sdk/runanywhere-react-native/packages/core/nitrogen/generated/android/runanywherecore+autolinking.gradle b/sdk/runanywhere-react-native/packages/core/nitrogen/generated/android/runanywherecore+autolinking.gradle new file mode 100644 index 000000000..436fd41b9 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/nitrogen/generated/android/runanywherecore+autolinking.gradle @@ -0,0 +1,27 @@ +/// +/// runanywherecore+autolinking.gradle +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2026 Marc Rousavy @ Margelo +/// + +/// This is a Gradle file that adds all files generated by Nitrogen +/// to the current Gradle project. +/// +/// To use it, add this to your build.gradle: +/// ```gradle +/// apply from: '../nitrogen/generated/android/runanywherecore+autolinking.gradle' +/// ``` + +logger.warn("[NitroModules] 🔥 runanywherecore is boosted by nitro!") + +android { + sourceSets { + main { + java.srcDirs += [ + // Nitrogen files + "${project.projectDir}/../nitrogen/generated/android/kotlin" + ] + } + } +} diff --git a/sdk/runanywhere-react-native/packages/core/nitrogen/generated/android/runanywherecoreOnLoad.cpp b/sdk/runanywhere-react-native/packages/core/nitrogen/generated/android/runanywherecoreOnLoad.cpp new file mode 100644 index 000000000..54a1aa7d0 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/nitrogen/generated/android/runanywherecoreOnLoad.cpp @@ -0,0 +1,54 @@ +/// +/// runanywherecoreOnLoad.cpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2026 Marc Rousavy @ Margelo +/// + +#ifndef BUILDING_RUNANYWHERECORE_WITH_GENERATED_CMAKE_PROJECT +#error runanywherecoreOnLoad.cpp is not being built with the autogenerated CMakeLists.txt project. Is a different CMakeLists.txt building this? +#endif + +#include "runanywherecoreOnLoad.hpp" + +#include +#include +#include + +#include "JHybridRunAnywhereDeviceInfoSpec.hpp" +#include "HybridRunAnywhereCore.hpp" +#include + +namespace margelo::nitro::runanywhere { + +int initialize(JavaVM* vm) { + using namespace margelo::nitro; + using namespace margelo::nitro::runanywhere; + using namespace facebook; + + return facebook::jni::initialize(vm, [] { + // Register native JNI methods + margelo::nitro::runanywhere::JHybridRunAnywhereDeviceInfoSpec::registerNatives(); + + // Register Nitro Hybrid Objects + HybridObjectRegistry::registerHybridObjectConstructor( + "RunAnywhereCore", + []() -> std::shared_ptr { + static_assert(std::is_default_constructible_v, + "The HybridObject \"HybridRunAnywhereCore\" is not default-constructible! " + "Create a public constructor that takes zero arguments to be able to autolink this HybridObject."); + return std::make_shared(); + } + ); + HybridObjectRegistry::registerHybridObjectConstructor( + "RunAnywhereDeviceInfo", + []() -> std::shared_ptr { + static DefaultConstructableObject object("com/margelo/nitro/runanywhere/HybridRunAnywhereDeviceInfo"); + auto instance = object.create(); + return instance->cthis()->shared(); + } + ); + }); +} + +} // namespace margelo::nitro::runanywhere diff --git a/sdk/runanywhere-react-native/packages/core/nitrogen/generated/android/runanywherecoreOnLoad.hpp b/sdk/runanywhere-react-native/packages/core/nitrogen/generated/android/runanywherecoreOnLoad.hpp new file mode 100644 index 000000000..12225720f --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/nitrogen/generated/android/runanywherecoreOnLoad.hpp @@ -0,0 +1,25 @@ +/// +/// runanywherecoreOnLoad.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2026 Marc Rousavy @ Margelo +/// + +#include +#include + +namespace margelo::nitro::runanywhere { + + /** + * Initializes the native (C++) part of runanywherecore, and autolinks all Hybrid Objects. + * Call this in your `JNI_OnLoad` function (probably inside `cpp-adapter.cpp`). + * Example: + * ```cpp (cpp-adapter.cpp) + * JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void*) { + * return margelo::nitro::runanywhere::initialize(vm); + * } + * ``` + */ + int initialize(JavaVM* vm); + +} // namespace margelo::nitro::runanywhere diff --git a/sdk/runanywhere-react-native/packages/core/nitrogen/generated/ios/RunAnywhereCore+autolinking.rb b/sdk/runanywhere-react-native/packages/core/nitrogen/generated/ios/RunAnywhereCore+autolinking.rb new file mode 100644 index 000000000..ba1c59f5f --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/nitrogen/generated/ios/RunAnywhereCore+autolinking.rb @@ -0,0 +1,60 @@ +# +# RunAnywhereCore+autolinking.rb +# This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +# https://github.com/mrousavy/nitro +# Copyright © 2026 Marc Rousavy @ Margelo +# + +# This is a Ruby script that adds all files generated by Nitrogen +# to the given podspec. +# +# To use it, add this to your .podspec: +# ```ruby +# Pod::Spec.new do |spec| +# # ... +# +# # Add all files generated by Nitrogen +# load 'nitrogen/generated/ios/RunAnywhereCore+autolinking.rb' +# add_nitrogen_files(spec) +# end +# ``` + +def add_nitrogen_files(spec) + Pod::UI.puts "[NitroModules] 🔥 RunAnywhereCore is boosted by nitro!" + + spec.dependency "NitroModules" + + current_source_files = Array(spec.attributes_hash['source_files']) + spec.source_files = current_source_files + [ + # Generated cross-platform specs + "nitrogen/generated/shared/**/*.{h,hpp,c,cpp,swift}", + # Generated bridges for the cross-platform specs + "nitrogen/generated/ios/**/*.{h,hpp,c,cpp,mm,swift}", + ] + + current_public_header_files = Array(spec.attributes_hash['public_header_files']) + spec.public_header_files = current_public_header_files + [ + # Generated specs + "nitrogen/generated/shared/**/*.{h,hpp}", + # Swift to C++ bridging helpers + "nitrogen/generated/ios/RunAnywhereCore-Swift-Cxx-Bridge.hpp" + ] + + current_private_header_files = Array(spec.attributes_hash['private_header_files']) + spec.private_header_files = current_private_header_files + [ + # iOS specific specs + "nitrogen/generated/ios/c++/**/*.{h,hpp}", + # Views are framework-specific and should be private + "nitrogen/generated/shared/**/views/**/*" + ] + + current_pod_target_xcconfig = spec.attributes_hash['pod_target_xcconfig'] || {} + spec.pod_target_xcconfig = current_pod_target_xcconfig.merge({ + # Use C++ 20 + "CLANG_CXX_LANGUAGE_STANDARD" => "c++20", + # Enables C++ <-> Swift interop (by default it's only C) + "SWIFT_OBJC_INTEROP_MODE" => "objcxx", + # Enables stricter modular headers + "DEFINES_MODULE" => "YES", + }) +end diff --git a/sdk/runanywhere-react-native/packages/core/nitrogen/generated/ios/RunAnywhereCore-Swift-Cxx-Bridge.cpp b/sdk/runanywhere-react-native/packages/core/nitrogen/generated/ios/RunAnywhereCore-Swift-Cxx-Bridge.cpp new file mode 100644 index 000000000..2c0b031dc --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/nitrogen/generated/ios/RunAnywhereCore-Swift-Cxx-Bridge.cpp @@ -0,0 +1,65 @@ +/// +/// RunAnywhereCore-Swift-Cxx-Bridge.cpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2026 Marc Rousavy @ Margelo +/// + +#include "RunAnywhereCore-Swift-Cxx-Bridge.hpp" + +// Include C++ implementation defined types +#include "HybridRunAnywhereDeviceInfoSpecSwift.hpp" +#include "RunAnywhereCore-Swift-Cxx-Umbrella.hpp" +#include + +namespace margelo::nitro::runanywhere::bridge::swift { + + // pragma MARK: std::function + Func_void_std__string create_Func_void_std__string(void* NON_NULL swiftClosureWrapper) noexcept { + auto swiftClosure = RunAnywhereCore::Func_void_std__string::fromUnsafe(swiftClosureWrapper); + return [swiftClosure = std::move(swiftClosure)](const std::string& result) mutable -> void { + swiftClosure.call(result); + }; + } + + // pragma MARK: std::function + Func_void_std__exception_ptr create_Func_void_std__exception_ptr(void* NON_NULL swiftClosureWrapper) noexcept { + auto swiftClosure = RunAnywhereCore::Func_void_std__exception_ptr::fromUnsafe(swiftClosureWrapper); + return [swiftClosure = std::move(swiftClosure)](const std::exception_ptr& error) mutable -> void { + swiftClosure.call(error); + }; + } + + // pragma MARK: std::function + Func_void_double create_Func_void_double(void* NON_NULL swiftClosureWrapper) noexcept { + auto swiftClosure = RunAnywhereCore::Func_void_double::fromUnsafe(swiftClosureWrapper); + return [swiftClosure = std::move(swiftClosure)](double result) mutable -> void { + swiftClosure.call(result); + }; + } + + // pragma MARK: std::function + Func_void_bool create_Func_void_bool(void* NON_NULL swiftClosureWrapper) noexcept { + auto swiftClosure = RunAnywhereCore::Func_void_bool::fromUnsafe(swiftClosureWrapper); + return [swiftClosure = std::move(swiftClosure)](bool result) mutable -> void { + swiftClosure.call(result); + }; + } + + // pragma MARK: std::shared_ptr + std::shared_ptr create_std__shared_ptr_HybridRunAnywhereDeviceInfoSpec_(void* NON_NULL swiftUnsafePointer) noexcept { + RunAnywhereCore::HybridRunAnywhereDeviceInfoSpec_cxx swiftPart = RunAnywhereCore::HybridRunAnywhereDeviceInfoSpec_cxx::fromUnsafe(swiftUnsafePointer); + return std::make_shared(swiftPart); + } + void* NON_NULL get_std__shared_ptr_HybridRunAnywhereDeviceInfoSpec_(std__shared_ptr_HybridRunAnywhereDeviceInfoSpec_ cppType) { + std::shared_ptr swiftWrapper = std::dynamic_pointer_cast(cppType); + #ifdef NITRO_DEBUG + if (swiftWrapper == nullptr) [[unlikely]] { + throw std::runtime_error("Class \"HybridRunAnywhereDeviceInfoSpec\" is not implemented in Swift!"); + } + #endif + RunAnywhereCore::HybridRunAnywhereDeviceInfoSpec_cxx& swiftPart = swiftWrapper->getSwiftPart(); + return swiftPart.toUnsafe(); + } + +} // namespace margelo::nitro::runanywhere::bridge::swift diff --git a/sdk/runanywhere-react-native/packages/core/nitrogen/generated/ios/RunAnywhereCore-Swift-Cxx-Bridge.hpp b/sdk/runanywhere-react-native/packages/core/nitrogen/generated/ios/RunAnywhereCore-Swift-Cxx-Bridge.hpp new file mode 100644 index 000000000..e296c4894 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/nitrogen/generated/ios/RunAnywhereCore-Swift-Cxx-Bridge.hpp @@ -0,0 +1,197 @@ +/// +/// RunAnywhereCore-Swift-Cxx-Bridge.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2026 Marc Rousavy @ Margelo +/// + +#pragma once + +// Forward declarations of C++ defined types +// Forward declaration of `HybridRunAnywhereDeviceInfoSpec` to properly resolve imports. +namespace margelo::nitro::runanywhere { class HybridRunAnywhereDeviceInfoSpec; } + +// Forward declarations of Swift defined types +// Forward declaration of `HybridRunAnywhereDeviceInfoSpec_cxx` to properly resolve imports. +namespace RunAnywhereCore { class HybridRunAnywhereDeviceInfoSpec_cxx; } + +// Include C++ defined types +#include "HybridRunAnywhereDeviceInfoSpec.hpp" +#include +#include +#include +#include +#include +#include +#include + +/** + * Contains specialized versions of C++ templated types so they can be accessed from Swift, + * as well as helper functions to interact with those C++ types from Swift. + */ +namespace margelo::nitro::runanywhere::bridge::swift { + + // pragma MARK: std::shared_ptr> + /** + * Specialized version of `std::shared_ptr>`. + */ + using std__shared_ptr_Promise_std__string__ = std::shared_ptr>; + inline std::shared_ptr> create_std__shared_ptr_Promise_std__string__() noexcept { + return Promise::create(); + } + inline PromiseHolder wrap_std__shared_ptr_Promise_std__string__(std::shared_ptr> promise) noexcept { + return PromiseHolder(std::move(promise)); + } + + // pragma MARK: std::function + /** + * Specialized version of `std::function`. + */ + using Func_void_std__string = std::function; + /** + * Wrapper class for a `std::function`, this can be used from Swift. + */ + class Func_void_std__string_Wrapper final { + public: + explicit Func_void_std__string_Wrapper(std::function&& func): _function(std::make_unique>(std::move(func))) {} + inline void call(std::string result) const noexcept { + _function->operator()(result); + } + private: + std::unique_ptr> _function; + } SWIFT_NONCOPYABLE; + Func_void_std__string create_Func_void_std__string(void* NON_NULL swiftClosureWrapper) noexcept; + inline Func_void_std__string_Wrapper wrap_Func_void_std__string(Func_void_std__string value) noexcept { + return Func_void_std__string_Wrapper(std::move(value)); + } + + // pragma MARK: std::function + /** + * Specialized version of `std::function`. + */ + using Func_void_std__exception_ptr = std::function; + /** + * Wrapper class for a `std::function`, this can be used from Swift. + */ + class Func_void_std__exception_ptr_Wrapper final { + public: + explicit Func_void_std__exception_ptr_Wrapper(std::function&& func): _function(std::make_unique>(std::move(func))) {} + inline void call(std::exception_ptr error) const noexcept { + _function->operator()(error); + } + private: + std::unique_ptr> _function; + } SWIFT_NONCOPYABLE; + Func_void_std__exception_ptr create_Func_void_std__exception_ptr(void* NON_NULL swiftClosureWrapper) noexcept; + inline Func_void_std__exception_ptr_Wrapper wrap_Func_void_std__exception_ptr(Func_void_std__exception_ptr value) noexcept { + return Func_void_std__exception_ptr_Wrapper(std::move(value)); + } + + // pragma MARK: std::shared_ptr> + /** + * Specialized version of `std::shared_ptr>`. + */ + using std__shared_ptr_Promise_double__ = std::shared_ptr>; + inline std::shared_ptr> create_std__shared_ptr_Promise_double__() noexcept { + return Promise::create(); + } + inline PromiseHolder wrap_std__shared_ptr_Promise_double__(std::shared_ptr> promise) noexcept { + return PromiseHolder(std::move(promise)); + } + + // pragma MARK: std::function + /** + * Specialized version of `std::function`. + */ + using Func_void_double = std::function; + /** + * Wrapper class for a `std::function`, this can be used from Swift. + */ + class Func_void_double_Wrapper final { + public: + explicit Func_void_double_Wrapper(std::function&& func): _function(std::make_unique>(std::move(func))) {} + inline void call(double result) const noexcept { + _function->operator()(result); + } + private: + std::unique_ptr> _function; + } SWIFT_NONCOPYABLE; + Func_void_double create_Func_void_double(void* NON_NULL swiftClosureWrapper) noexcept; + inline Func_void_double_Wrapper wrap_Func_void_double(Func_void_double value) noexcept { + return Func_void_double_Wrapper(std::move(value)); + } + + // pragma MARK: std::shared_ptr> + /** + * Specialized version of `std::shared_ptr>`. + */ + using std__shared_ptr_Promise_bool__ = std::shared_ptr>; + inline std::shared_ptr> create_std__shared_ptr_Promise_bool__() noexcept { + return Promise::create(); + } + inline PromiseHolder wrap_std__shared_ptr_Promise_bool__(std::shared_ptr> promise) noexcept { + return PromiseHolder(std::move(promise)); + } + + // pragma MARK: std::function + /** + * Specialized version of `std::function`. + */ + using Func_void_bool = std::function; + /** + * Wrapper class for a `std::function`, this can be used from Swift. + */ + class Func_void_bool_Wrapper final { + public: + explicit Func_void_bool_Wrapper(std::function&& func): _function(std::make_unique>(std::move(func))) {} + inline void call(bool result) const noexcept { + _function->operator()(result); + } + private: + std::unique_ptr> _function; + } SWIFT_NONCOPYABLE; + Func_void_bool create_Func_void_bool(void* NON_NULL swiftClosureWrapper) noexcept; + inline Func_void_bool_Wrapper wrap_Func_void_bool(Func_void_bool value) noexcept { + return Func_void_bool_Wrapper(std::move(value)); + } + + // pragma MARK: std::shared_ptr + /** + * Specialized version of `std::shared_ptr`. + */ + using std__shared_ptr_HybridRunAnywhereDeviceInfoSpec_ = std::shared_ptr; + std::shared_ptr create_std__shared_ptr_HybridRunAnywhereDeviceInfoSpec_(void* NON_NULL swiftUnsafePointer) noexcept; + void* NON_NULL get_std__shared_ptr_HybridRunAnywhereDeviceInfoSpec_(std__shared_ptr_HybridRunAnywhereDeviceInfoSpec_ cppType); + + // pragma MARK: std::weak_ptr + using std__weak_ptr_HybridRunAnywhereDeviceInfoSpec_ = std::weak_ptr; + inline std__weak_ptr_HybridRunAnywhereDeviceInfoSpec_ weakify_std__shared_ptr_HybridRunAnywhereDeviceInfoSpec_(const std::shared_ptr& strong) noexcept { return strong; } + + // pragma MARK: Result>> + using Result_std__shared_ptr_Promise_std__string___ = Result>>; + inline Result_std__shared_ptr_Promise_std__string___ create_Result_std__shared_ptr_Promise_std__string___(const std::shared_ptr>& value) noexcept { + return Result>>::withValue(value); + } + inline Result_std__shared_ptr_Promise_std__string___ create_Result_std__shared_ptr_Promise_std__string___(const std::exception_ptr& error) noexcept { + return Result>>::withError(error); + } + + // pragma MARK: Result>> + using Result_std__shared_ptr_Promise_double___ = Result>>; + inline Result_std__shared_ptr_Promise_double___ create_Result_std__shared_ptr_Promise_double___(const std::shared_ptr>& value) noexcept { + return Result>>::withValue(value); + } + inline Result_std__shared_ptr_Promise_double___ create_Result_std__shared_ptr_Promise_double___(const std::exception_ptr& error) noexcept { + return Result>>::withError(error); + } + + // pragma MARK: Result>> + using Result_std__shared_ptr_Promise_bool___ = Result>>; + inline Result_std__shared_ptr_Promise_bool___ create_Result_std__shared_ptr_Promise_bool___(const std::shared_ptr>& value) noexcept { + return Result>>::withValue(value); + } + inline Result_std__shared_ptr_Promise_bool___ create_Result_std__shared_ptr_Promise_bool___(const std::exception_ptr& error) noexcept { + return Result>>::withError(error); + } + +} // namespace margelo::nitro::runanywhere::bridge::swift diff --git a/sdk/runanywhere-react-native/packages/core/nitrogen/generated/ios/RunAnywhereCore-Swift-Cxx-Umbrella.hpp b/sdk/runanywhere-react-native/packages/core/nitrogen/generated/ios/RunAnywhereCore-Swift-Cxx-Umbrella.hpp new file mode 100644 index 000000000..e33eed06b --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/nitrogen/generated/ios/RunAnywhereCore-Swift-Cxx-Umbrella.hpp @@ -0,0 +1,45 @@ +/// +/// RunAnywhereCore-Swift-Cxx-Umbrella.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2026 Marc Rousavy @ Margelo +/// + +#pragma once + +// Forward declarations of C++ defined types +// Forward declaration of `HybridRunAnywhereDeviceInfoSpec` to properly resolve imports. +namespace margelo::nitro::runanywhere { class HybridRunAnywhereDeviceInfoSpec; } + +// Include C++ defined types +#include "HybridRunAnywhereDeviceInfoSpec.hpp" +#include +#include +#include +#include +#include + +// C++ helpers for Swift +#include "RunAnywhereCore-Swift-Cxx-Bridge.hpp" + +// Common C++ types used in Swift +#include +#include +#include +#include + +// Forward declarations of Swift defined types +// Forward declaration of `HybridRunAnywhereDeviceInfoSpec_cxx` to properly resolve imports. +namespace RunAnywhereCore { class HybridRunAnywhereDeviceInfoSpec_cxx; } + +// Include Swift defined types +#if __has_include("RunAnywhereCore-Swift.h") +// This header is generated by Xcode/Swift on every app build. +// If it cannot be found, make sure the Swift module's name (= podspec name) is actually "RunAnywhereCore". +#include "RunAnywhereCore-Swift.h" +// Same as above, but used when building with frameworks (`use_frameworks`) +#elif __has_include() +#include +#else +#error RunAnywhereCore's autogenerated Swift header cannot be found! Make sure the Swift module's name (= podspec name) is actually "RunAnywhereCore", and try building the app first. +#endif diff --git a/sdk/runanywhere-react-native/packages/core/nitrogen/generated/ios/RunAnywhereCoreAutolinking.mm b/sdk/runanywhere-react-native/packages/core/nitrogen/generated/ios/RunAnywhereCoreAutolinking.mm new file mode 100644 index 000000000..aca0b9696 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/nitrogen/generated/ios/RunAnywhereCoreAutolinking.mm @@ -0,0 +1,43 @@ +/// +/// RunAnywhereCoreAutolinking.mm +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2026 Marc Rousavy @ Margelo +/// + +#import +#import +#import "RunAnywhereCore-Swift-Cxx-Umbrella.hpp" +#import + +#include "HybridRunAnywhereCore.hpp" +#include "HybridRunAnywhereDeviceInfoSpecSwift.hpp" + +@interface RunAnywhereCoreAutolinking : NSObject +@end + +@implementation RunAnywhereCoreAutolinking + ++ (void) load { + using namespace margelo::nitro; + using namespace margelo::nitro::runanywhere; + + HybridObjectRegistry::registerHybridObjectConstructor( + "RunAnywhereCore", + []() -> std::shared_ptr { + static_assert(std::is_default_constructible_v, + "The HybridObject \"HybridRunAnywhereCore\" is not default-constructible! " + "Create a public constructor that takes zero arguments to be able to autolink this HybridObject."); + return std::make_shared(); + } + ); + HybridObjectRegistry::registerHybridObjectConstructor( + "RunAnywhereDeviceInfo", + []() -> std::shared_ptr { + std::shared_ptr hybridObject = RunAnywhereCore::RunAnywhereCoreAutolinking::createRunAnywhereDeviceInfo(); + return hybridObject; + } + ); +} + +@end diff --git a/sdk/runanywhere-react-native/packages/core/nitrogen/generated/ios/RunAnywhereCoreAutolinking.swift b/sdk/runanywhere-react-native/packages/core/nitrogen/generated/ios/RunAnywhereCoreAutolinking.swift new file mode 100644 index 000000000..2e6a9ad97 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/nitrogen/generated/ios/RunAnywhereCoreAutolinking.swift @@ -0,0 +1,25 @@ +/// +/// RunAnywhereCoreAutolinking.swift +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2026 Marc Rousavy @ Margelo +/// + +public final class RunAnywhereCoreAutolinking { + public typealias bridge = margelo.nitro.runanywhere.bridge.swift + + /** + * Creates an instance of a Swift class that implements `HybridRunAnywhereDeviceInfoSpec`, + * and wraps it in a Swift class that can directly interop with C++ (`HybridRunAnywhereDeviceInfoSpec_cxx`) + * + * This is generated by Nitrogen and will initialize the class specified + * in the `"autolinking"` property of `nitro.json` (in this case, `HybridRunAnywhereDeviceInfo`). + */ + public static func createRunAnywhereDeviceInfo() -> bridge.std__shared_ptr_HybridRunAnywhereDeviceInfoSpec_ { + let hybridObject = HybridRunAnywhereDeviceInfo() + return { () -> bridge.std__shared_ptr_HybridRunAnywhereDeviceInfoSpec_ in + let __cxxWrapped = hybridObject.getCxxWrapper() + return __cxxWrapped.getCxxPart() + }() + } +} diff --git a/sdk/runanywhere-react-native/packages/core/nitrogen/generated/ios/c++/HybridRunAnywhereDeviceInfoSpecSwift.cpp b/sdk/runanywhere-react-native/packages/core/nitrogen/generated/ios/c++/HybridRunAnywhereDeviceInfoSpecSwift.cpp new file mode 100644 index 000000000..8c2e4a8e7 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/nitrogen/generated/ios/c++/HybridRunAnywhereDeviceInfoSpecSwift.cpp @@ -0,0 +1,11 @@ +/// +/// HybridRunAnywhereDeviceInfoSpecSwift.cpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2026 Marc Rousavy @ Margelo +/// + +#include "HybridRunAnywhereDeviceInfoSpecSwift.hpp" + +namespace margelo::nitro::runanywhere { +} // namespace margelo::nitro::runanywhere diff --git a/sdk/runanywhere-react-native/packages/core/nitrogen/generated/ios/c++/HybridRunAnywhereDeviceInfoSpecSwift.hpp b/sdk/runanywhere-react-native/packages/core/nitrogen/generated/ios/c++/HybridRunAnywhereDeviceInfoSpecSwift.hpp new file mode 100644 index 000000000..c931b1958 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/nitrogen/generated/ios/c++/HybridRunAnywhereDeviceInfoSpecSwift.hpp @@ -0,0 +1,173 @@ +/// +/// HybridRunAnywhereDeviceInfoSpecSwift.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2026 Marc Rousavy @ Margelo +/// + +#pragma once + +#include "HybridRunAnywhereDeviceInfoSpec.hpp" + +// Forward declaration of `HybridRunAnywhereDeviceInfoSpec_cxx` to properly resolve imports. +namespace RunAnywhereCore { class HybridRunAnywhereDeviceInfoSpec_cxx; } + + + +#include +#include + +#include "RunAnywhereCore-Swift-Cxx-Umbrella.hpp" + +namespace margelo::nitro::runanywhere { + + /** + * The C++ part of HybridRunAnywhereDeviceInfoSpec_cxx.swift. + * + * HybridRunAnywhereDeviceInfoSpecSwift (C++) accesses HybridRunAnywhereDeviceInfoSpec_cxx (Swift), and might + * contain some additional bridging code for C++ <> Swift interop. + * + * Since this obviously introduces an overhead, I hope at some point in + * the future, HybridRunAnywhereDeviceInfoSpec_cxx can directly inherit from the C++ class HybridRunAnywhereDeviceInfoSpec + * to simplify the whole structure and memory management. + */ + class HybridRunAnywhereDeviceInfoSpecSwift: public virtual HybridRunAnywhereDeviceInfoSpec { + public: + // Constructor from a Swift instance + explicit HybridRunAnywhereDeviceInfoSpecSwift(const RunAnywhereCore::HybridRunAnywhereDeviceInfoSpec_cxx& swiftPart): + HybridObject(HybridRunAnywhereDeviceInfoSpec::TAG), + _swiftPart(swiftPart) { } + + public: + // Get the Swift part + inline RunAnywhereCore::HybridRunAnywhereDeviceInfoSpec_cxx& getSwiftPart() noexcept { + return _swiftPart; + } + + public: + inline size_t getExternalMemorySize() noexcept override { + return _swiftPart.getMemorySize(); + } + void dispose() noexcept override { + _swiftPart.dispose(); + } + std::string toString() override { + return _swiftPart.toString(); + } + + public: + // Properties + + + public: + // Methods + inline std::shared_ptr> getDeviceModel() override { + auto __result = _swiftPart.getDeviceModel(); + if (__result.hasError()) [[unlikely]] { + std::rethrow_exception(__result.error()); + } + auto __value = std::move(__result.value()); + return __value; + } + inline std::shared_ptr> getOSVersion() override { + auto __result = _swiftPart.getOSVersion(); + if (__result.hasError()) [[unlikely]] { + std::rethrow_exception(__result.error()); + } + auto __value = std::move(__result.value()); + return __value; + } + inline std::shared_ptr> getPlatform() override { + auto __result = _swiftPart.getPlatform(); + if (__result.hasError()) [[unlikely]] { + std::rethrow_exception(__result.error()); + } + auto __value = std::move(__result.value()); + return __value; + } + inline std::shared_ptr> getTotalRAM() override { + auto __result = _swiftPart.getTotalRAM(); + if (__result.hasError()) [[unlikely]] { + std::rethrow_exception(__result.error()); + } + auto __value = std::move(__result.value()); + return __value; + } + inline std::shared_ptr> getAvailableRAM() override { + auto __result = _swiftPart.getAvailableRAM(); + if (__result.hasError()) [[unlikely]] { + std::rethrow_exception(__result.error()); + } + auto __value = std::move(__result.value()); + return __value; + } + inline std::shared_ptr> getCPUCores() override { + auto __result = _swiftPart.getCPUCores(); + if (__result.hasError()) [[unlikely]] { + std::rethrow_exception(__result.error()); + } + auto __value = std::move(__result.value()); + return __value; + } + inline std::shared_ptr> hasGPU() override { + auto __result = _swiftPart.hasGPU(); + if (__result.hasError()) [[unlikely]] { + std::rethrow_exception(__result.error()); + } + auto __value = std::move(__result.value()); + return __value; + } + inline std::shared_ptr> hasNPU() override { + auto __result = _swiftPart.hasNPU(); + if (__result.hasError()) [[unlikely]] { + std::rethrow_exception(__result.error()); + } + auto __value = std::move(__result.value()); + return __value; + } + inline std::shared_ptr> getChipName() override { + auto __result = _swiftPart.getChipName(); + if (__result.hasError()) [[unlikely]] { + std::rethrow_exception(__result.error()); + } + auto __value = std::move(__result.value()); + return __value; + } + inline std::shared_ptr> getThermalState() override { + auto __result = _swiftPart.getThermalState(); + if (__result.hasError()) [[unlikely]] { + std::rethrow_exception(__result.error()); + } + auto __value = std::move(__result.value()); + return __value; + } + inline std::shared_ptr> getBatteryLevel() override { + auto __result = _swiftPart.getBatteryLevel(); + if (__result.hasError()) [[unlikely]] { + std::rethrow_exception(__result.error()); + } + auto __value = std::move(__result.value()); + return __value; + } + inline std::shared_ptr> isCharging() override { + auto __result = _swiftPart.isCharging(); + if (__result.hasError()) [[unlikely]] { + std::rethrow_exception(__result.error()); + } + auto __value = std::move(__result.value()); + return __value; + } + inline std::shared_ptr> isLowPowerMode() override { + auto __result = _swiftPart.isLowPowerMode(); + if (__result.hasError()) [[unlikely]] { + std::rethrow_exception(__result.error()); + } + auto __value = std::move(__result.value()); + return __value; + } + + private: + RunAnywhereCore::HybridRunAnywhereDeviceInfoSpec_cxx _swiftPart; + }; + +} // namespace margelo::nitro::runanywhere diff --git a/sdk/runanywhere-react-native/packages/core/nitrogen/generated/ios/swift/Func_void_bool.swift b/sdk/runanywhere-react-native/packages/core/nitrogen/generated/ios/swift/Func_void_bool.swift new file mode 100644 index 000000000..745812715 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/nitrogen/generated/ios/swift/Func_void_bool.swift @@ -0,0 +1,47 @@ +/// +/// Func_void_bool.swift +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2026 Marc Rousavy @ Margelo +/// + +import Foundation +import NitroModules + +/** + * Wraps a Swift `(_ value: Bool) -> Void` as a class. + * This class can be used from C++, e.g. to wrap the Swift closure as a `std::function`. + */ +public final class Func_void_bool { + public typealias bridge = margelo.nitro.runanywhere.bridge.swift + + private let closure: (_ value: Bool) -> Void + + public init(_ closure: @escaping (_ value: Bool) -> Void) { + self.closure = closure + } + + @inline(__always) + public func call(value: Bool) -> Void { + self.closure(value) + } + + /** + * Casts this instance to a retained unsafe raw pointer. + * This acquires one additional strong reference on the object! + */ + @inline(__always) + public func toUnsafe() -> UnsafeMutableRawPointer { + return Unmanaged.passRetained(self).toOpaque() + } + + /** + * Casts an unsafe pointer to a `Func_void_bool`. + * The pointer has to be a retained opaque `Unmanaged`. + * This removes one strong reference from the object! + */ + @inline(__always) + public static func fromUnsafe(_ pointer: UnsafeMutableRawPointer) -> Func_void_bool { + return Unmanaged.fromOpaque(pointer).takeRetainedValue() + } +} diff --git a/sdk/runanywhere-react-native/packages/core/nitrogen/generated/ios/swift/Func_void_double.swift b/sdk/runanywhere-react-native/packages/core/nitrogen/generated/ios/swift/Func_void_double.swift new file mode 100644 index 000000000..892e801e7 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/nitrogen/generated/ios/swift/Func_void_double.swift @@ -0,0 +1,47 @@ +/// +/// Func_void_double.swift +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2026 Marc Rousavy @ Margelo +/// + +import Foundation +import NitroModules + +/** + * Wraps a Swift `(_ value: Double) -> Void` as a class. + * This class can be used from C++, e.g. to wrap the Swift closure as a `std::function`. + */ +public final class Func_void_double { + public typealias bridge = margelo.nitro.runanywhere.bridge.swift + + private let closure: (_ value: Double) -> Void + + public init(_ closure: @escaping (_ value: Double) -> Void) { + self.closure = closure + } + + @inline(__always) + public func call(value: Double) -> Void { + self.closure(value) + } + + /** + * Casts this instance to a retained unsafe raw pointer. + * This acquires one additional strong reference on the object! + */ + @inline(__always) + public func toUnsafe() -> UnsafeMutableRawPointer { + return Unmanaged.passRetained(self).toOpaque() + } + + /** + * Casts an unsafe pointer to a `Func_void_double`. + * The pointer has to be a retained opaque `Unmanaged`. + * This removes one strong reference from the object! + */ + @inline(__always) + public static func fromUnsafe(_ pointer: UnsafeMutableRawPointer) -> Func_void_double { + return Unmanaged.fromOpaque(pointer).takeRetainedValue() + } +} diff --git a/sdk/runanywhere-react-native/packages/core/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift b/sdk/runanywhere-react-native/packages/core/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift new file mode 100644 index 000000000..48d9867a8 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift @@ -0,0 +1,47 @@ +/// +/// Func_void_std__exception_ptr.swift +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2026 Marc Rousavy @ Margelo +/// + +import Foundation +import NitroModules + +/** + * Wraps a Swift `(_ error: Error) -> Void` as a class. + * This class can be used from C++, e.g. to wrap the Swift closure as a `std::function`. + */ +public final class Func_void_std__exception_ptr { + public typealias bridge = margelo.nitro.runanywhere.bridge.swift + + private let closure: (_ error: Error) -> Void + + public init(_ closure: @escaping (_ error: Error) -> Void) { + self.closure = closure + } + + @inline(__always) + public func call(error: std.exception_ptr) -> Void { + self.closure(RuntimeError.from(cppError: error)) + } + + /** + * Casts this instance to a retained unsafe raw pointer. + * This acquires one additional strong reference on the object! + */ + @inline(__always) + public func toUnsafe() -> UnsafeMutableRawPointer { + return Unmanaged.passRetained(self).toOpaque() + } + + /** + * Casts an unsafe pointer to a `Func_void_std__exception_ptr`. + * The pointer has to be a retained opaque `Unmanaged`. + * This removes one strong reference from the object! + */ + @inline(__always) + public static func fromUnsafe(_ pointer: UnsafeMutableRawPointer) -> Func_void_std__exception_ptr { + return Unmanaged.fromOpaque(pointer).takeRetainedValue() + } +} diff --git a/sdk/runanywhere-react-native/packages/core/nitrogen/generated/ios/swift/Func_void_std__string.swift b/sdk/runanywhere-react-native/packages/core/nitrogen/generated/ios/swift/Func_void_std__string.swift new file mode 100644 index 000000000..99eb54b7e --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/nitrogen/generated/ios/swift/Func_void_std__string.swift @@ -0,0 +1,47 @@ +/// +/// Func_void_std__string.swift +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2026 Marc Rousavy @ Margelo +/// + +import Foundation +import NitroModules + +/** + * Wraps a Swift `(_ value: String) -> Void` as a class. + * This class can be used from C++, e.g. to wrap the Swift closure as a `std::function`. + */ +public final class Func_void_std__string { + public typealias bridge = margelo.nitro.runanywhere.bridge.swift + + private let closure: (_ value: String) -> Void + + public init(_ closure: @escaping (_ value: String) -> Void) { + self.closure = closure + } + + @inline(__always) + public func call(value: std.string) -> Void { + self.closure(String(value)) + } + + /** + * Casts this instance to a retained unsafe raw pointer. + * This acquires one additional strong reference on the object! + */ + @inline(__always) + public func toUnsafe() -> UnsafeMutableRawPointer { + return Unmanaged.passRetained(self).toOpaque() + } + + /** + * Casts an unsafe pointer to a `Func_void_std__string`. + * The pointer has to be a retained opaque `Unmanaged`. + * This removes one strong reference from the object! + */ + @inline(__always) + public static func fromUnsafe(_ pointer: UnsafeMutableRawPointer) -> Func_void_std__string { + return Unmanaged.fromOpaque(pointer).takeRetainedValue() + } +} diff --git a/sdk/runanywhere-react-native/packages/core/nitrogen/generated/ios/swift/HybridRunAnywhereDeviceInfoSpec.swift b/sdk/runanywhere-react-native/packages/core/nitrogen/generated/ios/swift/HybridRunAnywhereDeviceInfoSpec.swift new file mode 100644 index 000000000..792f254cf --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/nitrogen/generated/ios/swift/HybridRunAnywhereDeviceInfoSpec.swift @@ -0,0 +1,68 @@ +/// +/// HybridRunAnywhereDeviceInfoSpec.swift +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2026 Marc Rousavy @ Margelo +/// + +import Foundation +import NitroModules + +/// See ``HybridRunAnywhereDeviceInfoSpec`` +public protocol HybridRunAnywhereDeviceInfoSpec_protocol: HybridObject { + // Properties + + + // Methods + func getDeviceModel() throws -> Promise + func getOSVersion() throws -> Promise + func getPlatform() throws -> Promise + func getTotalRAM() throws -> Promise + func getAvailableRAM() throws -> Promise + func getCPUCores() throws -> Promise + func hasGPU() throws -> Promise + func hasNPU() throws -> Promise + func getChipName() throws -> Promise + func getThermalState() throws -> Promise + func getBatteryLevel() throws -> Promise + func isCharging() throws -> Promise + func isLowPowerMode() throws -> Promise +} + +public extension HybridRunAnywhereDeviceInfoSpec_protocol { + /// Default implementation of ``HybridObject.toString`` + func toString() -> String { + return "[HybridObject RunAnywhereDeviceInfo]" + } +} + +/// See ``HybridRunAnywhereDeviceInfoSpec`` +open class HybridRunAnywhereDeviceInfoSpec_base { + private weak var cxxWrapper: HybridRunAnywhereDeviceInfoSpec_cxx? = nil + public init() { } + public func getCxxWrapper() -> HybridRunAnywhereDeviceInfoSpec_cxx { + #if DEBUG + guard self is HybridRunAnywhereDeviceInfoSpec else { + fatalError("`self` is not a `HybridRunAnywhereDeviceInfoSpec`! Did you accidentally inherit from `HybridRunAnywhereDeviceInfoSpec_base` instead of `HybridRunAnywhereDeviceInfoSpec`?") + } + #endif + if let cxxWrapper = self.cxxWrapper { + return cxxWrapper + } else { + let cxxWrapper = HybridRunAnywhereDeviceInfoSpec_cxx(self as! HybridRunAnywhereDeviceInfoSpec) + self.cxxWrapper = cxxWrapper + return cxxWrapper + } + } +} + +/** + * A Swift base-protocol representing the RunAnywhereDeviceInfo HybridObject. + * Implement this protocol to create Swift-based instances of RunAnywhereDeviceInfo. + * ```swift + * class HybridRunAnywhereDeviceInfo : HybridRunAnywhereDeviceInfoSpec { + * // ... + * } + * ``` + */ +public typealias HybridRunAnywhereDeviceInfoSpec = HybridRunAnywhereDeviceInfoSpec_protocol & HybridRunAnywhereDeviceInfoSpec_base diff --git a/sdk/runanywhere-react-native/packages/core/nitrogen/generated/ios/swift/HybridRunAnywhereDeviceInfoSpec_cxx.swift b/sdk/runanywhere-react-native/packages/core/nitrogen/generated/ios/swift/HybridRunAnywhereDeviceInfoSpec_cxx.swift new file mode 100644 index 000000000..6a0319e7a --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/nitrogen/generated/ios/swift/HybridRunAnywhereDeviceInfoSpec_cxx.swift @@ -0,0 +1,366 @@ +/// +/// HybridRunAnywhereDeviceInfoSpec_cxx.swift +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2026 Marc Rousavy @ Margelo +/// + +import Foundation +import NitroModules + +/** + * A class implementation that bridges HybridRunAnywhereDeviceInfoSpec over to C++. + * In C++, we cannot use Swift protocols - so we need to wrap it in a class to make it strongly defined. + * + * Also, some Swift types need to be bridged with special handling: + * - Enums need to be wrapped in Structs, otherwise they cannot be accessed bi-directionally (Swift bug: https://github.com/swiftlang/swift/issues/75330) + * - Other HybridObjects need to be wrapped/unwrapped from the Swift TCxx wrapper + * - Throwing methods need to be wrapped with a Result type, as exceptions cannot be propagated to C++ + */ +open class HybridRunAnywhereDeviceInfoSpec_cxx { + /** + * The Swift <> C++ bridge's namespace (`margelo::nitro::runanywhere::bridge::swift`) + * from `RunAnywhereCore-Swift-Cxx-Bridge.hpp`. + * This contains specialized C++ templates, and C++ helper functions that can be accessed from Swift. + */ + public typealias bridge = margelo.nitro.runanywhere.bridge.swift + + /** + * Holds an instance of the `HybridRunAnywhereDeviceInfoSpec` Swift protocol. + */ + private var __implementation: any HybridRunAnywhereDeviceInfoSpec + + /** + * Holds a weak pointer to the C++ class that wraps the Swift class. + */ + private var __cxxPart: bridge.std__weak_ptr_HybridRunAnywhereDeviceInfoSpec_ + + /** + * Create a new `HybridRunAnywhereDeviceInfoSpec_cxx` that wraps the given `HybridRunAnywhereDeviceInfoSpec`. + * All properties and methods bridge to C++ types. + */ + public init(_ implementation: any HybridRunAnywhereDeviceInfoSpec) { + self.__implementation = implementation + self.__cxxPart = .init() + /* no base class */ + } + + /** + * Get the actual `HybridRunAnywhereDeviceInfoSpec` instance this class wraps. + */ + @inline(__always) + public func getHybridRunAnywhereDeviceInfoSpec() -> any HybridRunAnywhereDeviceInfoSpec { + return __implementation + } + + /** + * Casts this instance to a retained unsafe raw pointer. + * This acquires one additional strong reference on the object! + */ + public func toUnsafe() -> UnsafeMutableRawPointer { + return Unmanaged.passRetained(self).toOpaque() + } + + /** + * Casts an unsafe pointer to a `HybridRunAnywhereDeviceInfoSpec_cxx`. + * The pointer has to be a retained opaque `Unmanaged`. + * This removes one strong reference from the object! + */ + public class func fromUnsafe(_ pointer: UnsafeMutableRawPointer) -> HybridRunAnywhereDeviceInfoSpec_cxx { + return Unmanaged.fromOpaque(pointer).takeRetainedValue() + } + + /** + * Gets (or creates) the C++ part of this Hybrid Object. + * The C++ part is a `std::shared_ptr`. + */ + public func getCxxPart() -> bridge.std__shared_ptr_HybridRunAnywhereDeviceInfoSpec_ { + let cachedCxxPart = self.__cxxPart.lock() + if Bool(fromCxx: cachedCxxPart) { + return cachedCxxPart + } else { + let newCxxPart = bridge.create_std__shared_ptr_HybridRunAnywhereDeviceInfoSpec_(self.toUnsafe()) + __cxxPart = bridge.weakify_std__shared_ptr_HybridRunAnywhereDeviceInfoSpec_(newCxxPart) + return newCxxPart + } + } + + + + /** + * Get the memory size of the Swift class (plus size of any other allocations) + * so the JS VM can properly track it and garbage-collect the JS object if needed. + */ + @inline(__always) + public var memorySize: Int { + return MemoryHelper.getSizeOf(self.__implementation) + self.__implementation.memorySize + } + + /** + * Call dispose() on the Swift class. + * This _may_ be called manually from JS. + */ + @inline(__always) + public func dispose() { + self.__implementation.dispose() + } + + /** + * Call toString() on the Swift class. + */ + @inline(__always) + public func toString() -> String { + return self.__implementation.toString() + } + + // Properties + + + // Methods + @inline(__always) + public final func getDeviceModel() -> bridge.Result_std__shared_ptr_Promise_std__string___ { + do { + let __result = try self.__implementation.getDeviceModel() + let __resultCpp = { () -> bridge.std__shared_ptr_Promise_std__string__ in + let __promise = bridge.create_std__shared_ptr_Promise_std__string__() + let __promiseHolder = bridge.wrap_std__shared_ptr_Promise_std__string__(__promise) + __result + .then({ __result in __promiseHolder.resolve(std.string(__result)) }) + .catch({ __error in __promiseHolder.reject(__error.toCpp()) }) + return __promise + }() + return bridge.create_Result_std__shared_ptr_Promise_std__string___(__resultCpp) + } catch (let __error) { + let __exceptionPtr = __error.toCpp() + return bridge.create_Result_std__shared_ptr_Promise_std__string___(__exceptionPtr) + } + } + + @inline(__always) + public final func getOSVersion() -> bridge.Result_std__shared_ptr_Promise_std__string___ { + do { + let __result = try self.__implementation.getOSVersion() + let __resultCpp = { () -> bridge.std__shared_ptr_Promise_std__string__ in + let __promise = bridge.create_std__shared_ptr_Promise_std__string__() + let __promiseHolder = bridge.wrap_std__shared_ptr_Promise_std__string__(__promise) + __result + .then({ __result in __promiseHolder.resolve(std.string(__result)) }) + .catch({ __error in __promiseHolder.reject(__error.toCpp()) }) + return __promise + }() + return bridge.create_Result_std__shared_ptr_Promise_std__string___(__resultCpp) + } catch (let __error) { + let __exceptionPtr = __error.toCpp() + return bridge.create_Result_std__shared_ptr_Promise_std__string___(__exceptionPtr) + } + } + + @inline(__always) + public final func getPlatform() -> bridge.Result_std__shared_ptr_Promise_std__string___ { + do { + let __result = try self.__implementation.getPlatform() + let __resultCpp = { () -> bridge.std__shared_ptr_Promise_std__string__ in + let __promise = bridge.create_std__shared_ptr_Promise_std__string__() + let __promiseHolder = bridge.wrap_std__shared_ptr_Promise_std__string__(__promise) + __result + .then({ __result in __promiseHolder.resolve(std.string(__result)) }) + .catch({ __error in __promiseHolder.reject(__error.toCpp()) }) + return __promise + }() + return bridge.create_Result_std__shared_ptr_Promise_std__string___(__resultCpp) + } catch (let __error) { + let __exceptionPtr = __error.toCpp() + return bridge.create_Result_std__shared_ptr_Promise_std__string___(__exceptionPtr) + } + } + + @inline(__always) + public final func getTotalRAM() -> bridge.Result_std__shared_ptr_Promise_double___ { + do { + let __result = try self.__implementation.getTotalRAM() + let __resultCpp = { () -> bridge.std__shared_ptr_Promise_double__ in + let __promise = bridge.create_std__shared_ptr_Promise_double__() + let __promiseHolder = bridge.wrap_std__shared_ptr_Promise_double__(__promise) + __result + .then({ __result in __promiseHolder.resolve(__result) }) + .catch({ __error in __promiseHolder.reject(__error.toCpp()) }) + return __promise + }() + return bridge.create_Result_std__shared_ptr_Promise_double___(__resultCpp) + } catch (let __error) { + let __exceptionPtr = __error.toCpp() + return bridge.create_Result_std__shared_ptr_Promise_double___(__exceptionPtr) + } + } + + @inline(__always) + public final func getAvailableRAM() -> bridge.Result_std__shared_ptr_Promise_double___ { + do { + let __result = try self.__implementation.getAvailableRAM() + let __resultCpp = { () -> bridge.std__shared_ptr_Promise_double__ in + let __promise = bridge.create_std__shared_ptr_Promise_double__() + let __promiseHolder = bridge.wrap_std__shared_ptr_Promise_double__(__promise) + __result + .then({ __result in __promiseHolder.resolve(__result) }) + .catch({ __error in __promiseHolder.reject(__error.toCpp()) }) + return __promise + }() + return bridge.create_Result_std__shared_ptr_Promise_double___(__resultCpp) + } catch (let __error) { + let __exceptionPtr = __error.toCpp() + return bridge.create_Result_std__shared_ptr_Promise_double___(__exceptionPtr) + } + } + + @inline(__always) + public final func getCPUCores() -> bridge.Result_std__shared_ptr_Promise_double___ { + do { + let __result = try self.__implementation.getCPUCores() + let __resultCpp = { () -> bridge.std__shared_ptr_Promise_double__ in + let __promise = bridge.create_std__shared_ptr_Promise_double__() + let __promiseHolder = bridge.wrap_std__shared_ptr_Promise_double__(__promise) + __result + .then({ __result in __promiseHolder.resolve(__result) }) + .catch({ __error in __promiseHolder.reject(__error.toCpp()) }) + return __promise + }() + return bridge.create_Result_std__shared_ptr_Promise_double___(__resultCpp) + } catch (let __error) { + let __exceptionPtr = __error.toCpp() + return bridge.create_Result_std__shared_ptr_Promise_double___(__exceptionPtr) + } + } + + @inline(__always) + public final func hasGPU() -> bridge.Result_std__shared_ptr_Promise_bool___ { + do { + let __result = try self.__implementation.hasGPU() + let __resultCpp = { () -> bridge.std__shared_ptr_Promise_bool__ in + let __promise = bridge.create_std__shared_ptr_Promise_bool__() + let __promiseHolder = bridge.wrap_std__shared_ptr_Promise_bool__(__promise) + __result + .then({ __result in __promiseHolder.resolve(__result) }) + .catch({ __error in __promiseHolder.reject(__error.toCpp()) }) + return __promise + }() + return bridge.create_Result_std__shared_ptr_Promise_bool___(__resultCpp) + } catch (let __error) { + let __exceptionPtr = __error.toCpp() + return bridge.create_Result_std__shared_ptr_Promise_bool___(__exceptionPtr) + } + } + + @inline(__always) + public final func hasNPU() -> bridge.Result_std__shared_ptr_Promise_bool___ { + do { + let __result = try self.__implementation.hasNPU() + let __resultCpp = { () -> bridge.std__shared_ptr_Promise_bool__ in + let __promise = bridge.create_std__shared_ptr_Promise_bool__() + let __promiseHolder = bridge.wrap_std__shared_ptr_Promise_bool__(__promise) + __result + .then({ __result in __promiseHolder.resolve(__result) }) + .catch({ __error in __promiseHolder.reject(__error.toCpp()) }) + return __promise + }() + return bridge.create_Result_std__shared_ptr_Promise_bool___(__resultCpp) + } catch (let __error) { + let __exceptionPtr = __error.toCpp() + return bridge.create_Result_std__shared_ptr_Promise_bool___(__exceptionPtr) + } + } + + @inline(__always) + public final func getChipName() -> bridge.Result_std__shared_ptr_Promise_std__string___ { + do { + let __result = try self.__implementation.getChipName() + let __resultCpp = { () -> bridge.std__shared_ptr_Promise_std__string__ in + let __promise = bridge.create_std__shared_ptr_Promise_std__string__() + let __promiseHolder = bridge.wrap_std__shared_ptr_Promise_std__string__(__promise) + __result + .then({ __result in __promiseHolder.resolve(std.string(__result)) }) + .catch({ __error in __promiseHolder.reject(__error.toCpp()) }) + return __promise + }() + return bridge.create_Result_std__shared_ptr_Promise_std__string___(__resultCpp) + } catch (let __error) { + let __exceptionPtr = __error.toCpp() + return bridge.create_Result_std__shared_ptr_Promise_std__string___(__exceptionPtr) + } + } + + @inline(__always) + public final func getThermalState() -> bridge.Result_std__shared_ptr_Promise_double___ { + do { + let __result = try self.__implementation.getThermalState() + let __resultCpp = { () -> bridge.std__shared_ptr_Promise_double__ in + let __promise = bridge.create_std__shared_ptr_Promise_double__() + let __promiseHolder = bridge.wrap_std__shared_ptr_Promise_double__(__promise) + __result + .then({ __result in __promiseHolder.resolve(__result) }) + .catch({ __error in __promiseHolder.reject(__error.toCpp()) }) + return __promise + }() + return bridge.create_Result_std__shared_ptr_Promise_double___(__resultCpp) + } catch (let __error) { + let __exceptionPtr = __error.toCpp() + return bridge.create_Result_std__shared_ptr_Promise_double___(__exceptionPtr) + } + } + + @inline(__always) + public final func getBatteryLevel() -> bridge.Result_std__shared_ptr_Promise_double___ { + do { + let __result = try self.__implementation.getBatteryLevel() + let __resultCpp = { () -> bridge.std__shared_ptr_Promise_double__ in + let __promise = bridge.create_std__shared_ptr_Promise_double__() + let __promiseHolder = bridge.wrap_std__shared_ptr_Promise_double__(__promise) + __result + .then({ __result in __promiseHolder.resolve(__result) }) + .catch({ __error in __promiseHolder.reject(__error.toCpp()) }) + return __promise + }() + return bridge.create_Result_std__shared_ptr_Promise_double___(__resultCpp) + } catch (let __error) { + let __exceptionPtr = __error.toCpp() + return bridge.create_Result_std__shared_ptr_Promise_double___(__exceptionPtr) + } + } + + @inline(__always) + public final func isCharging() -> bridge.Result_std__shared_ptr_Promise_bool___ { + do { + let __result = try self.__implementation.isCharging() + let __resultCpp = { () -> bridge.std__shared_ptr_Promise_bool__ in + let __promise = bridge.create_std__shared_ptr_Promise_bool__() + let __promiseHolder = bridge.wrap_std__shared_ptr_Promise_bool__(__promise) + __result + .then({ __result in __promiseHolder.resolve(__result) }) + .catch({ __error in __promiseHolder.reject(__error.toCpp()) }) + return __promise + }() + return bridge.create_Result_std__shared_ptr_Promise_bool___(__resultCpp) + } catch (let __error) { + let __exceptionPtr = __error.toCpp() + return bridge.create_Result_std__shared_ptr_Promise_bool___(__exceptionPtr) + } + } + + @inline(__always) + public final func isLowPowerMode() -> bridge.Result_std__shared_ptr_Promise_bool___ { + do { + let __result = try self.__implementation.isLowPowerMode() + let __resultCpp = { () -> bridge.std__shared_ptr_Promise_bool__ in + let __promise = bridge.create_std__shared_ptr_Promise_bool__() + let __promiseHolder = bridge.wrap_std__shared_ptr_Promise_bool__(__promise) + __result + .then({ __result in __promiseHolder.resolve(__result) }) + .catch({ __error in __promiseHolder.reject(__error.toCpp()) }) + return __promise + }() + return bridge.create_Result_std__shared_ptr_Promise_bool___(__resultCpp) + } catch (let __error) { + let __exceptionPtr = __error.toCpp() + return bridge.create_Result_std__shared_ptr_Promise_bool___(__exceptionPtr) + } + } +} diff --git a/sdk/runanywhere-react-native/packages/core/nitrogen/generated/shared/c++/HybridRunAnywhereCoreSpec.cpp b/sdk/runanywhere-react-native/packages/core/nitrogen/generated/shared/c++/HybridRunAnywhereCoreSpec.cpp new file mode 100644 index 000000000..8459d4043 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/nitrogen/generated/shared/c++/HybridRunAnywhereCoreSpec.cpp @@ -0,0 +1,92 @@ +/// +/// HybridRunAnywhereCoreSpec.cpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2026 Marc Rousavy @ Margelo +/// + +#include "HybridRunAnywhereCoreSpec.hpp" + +namespace margelo::nitro::runanywhere { + + void HybridRunAnywhereCoreSpec::loadHybridMethods() { + // load base methods/properties + HybridObject::loadHybridMethods(); + // load custom methods/properties + registerHybrids(this, [](Prototype& prototype) { + prototype.registerHybridMethod("initialize", &HybridRunAnywhereCoreSpec::initialize); + prototype.registerHybridMethod("destroy", &HybridRunAnywhereCoreSpec::destroy); + prototype.registerHybridMethod("isInitialized", &HybridRunAnywhereCoreSpec::isInitialized); + prototype.registerHybridMethod("getBackendInfo", &HybridRunAnywhereCoreSpec::getBackendInfo); + prototype.registerHybridMethod("authenticate", &HybridRunAnywhereCoreSpec::authenticate); + prototype.registerHybridMethod("isAuthenticated", &HybridRunAnywhereCoreSpec::isAuthenticated); + prototype.registerHybridMethod("getUserId", &HybridRunAnywhereCoreSpec::getUserId); + prototype.registerHybridMethod("getOrganizationId", &HybridRunAnywhereCoreSpec::getOrganizationId); + prototype.registerHybridMethod("setAuthTokens", &HybridRunAnywhereCoreSpec::setAuthTokens); + prototype.registerHybridMethod("registerDevice", &HybridRunAnywhereCoreSpec::registerDevice); + prototype.registerHybridMethod("isDeviceRegistered", &HybridRunAnywhereCoreSpec::isDeviceRegistered); + prototype.registerHybridMethod("clearDeviceRegistration", &HybridRunAnywhereCoreSpec::clearDeviceRegistration); + prototype.registerHybridMethod("getDeviceId", &HybridRunAnywhereCoreSpec::getDeviceId); + prototype.registerHybridMethod("getAvailableModels", &HybridRunAnywhereCoreSpec::getAvailableModels); + prototype.registerHybridMethod("getModelInfo", &HybridRunAnywhereCoreSpec::getModelInfo); + prototype.registerHybridMethod("isModelDownloaded", &HybridRunAnywhereCoreSpec::isModelDownloaded); + prototype.registerHybridMethod("getModelPath", &HybridRunAnywhereCoreSpec::getModelPath); + prototype.registerHybridMethod("registerModel", &HybridRunAnywhereCoreSpec::registerModel); + prototype.registerHybridMethod("downloadModel", &HybridRunAnywhereCoreSpec::downloadModel); + prototype.registerHybridMethod("cancelDownload", &HybridRunAnywhereCoreSpec::cancelDownload); + prototype.registerHybridMethod("getDownloadProgress", &HybridRunAnywhereCoreSpec::getDownloadProgress); + prototype.registerHybridMethod("getStorageInfo", &HybridRunAnywhereCoreSpec::getStorageInfo); + prototype.registerHybridMethod("clearCache", &HybridRunAnywhereCoreSpec::clearCache); + prototype.registerHybridMethod("deleteModel", &HybridRunAnywhereCoreSpec::deleteModel); + prototype.registerHybridMethod("emitEvent", &HybridRunAnywhereCoreSpec::emitEvent); + prototype.registerHybridMethod("pollEvents", &HybridRunAnywhereCoreSpec::pollEvents); + prototype.registerHybridMethod("configureHttp", &HybridRunAnywhereCoreSpec::configureHttp); + prototype.registerHybridMethod("httpPost", &HybridRunAnywhereCoreSpec::httpPost); + prototype.registerHybridMethod("httpGet", &HybridRunAnywhereCoreSpec::httpGet); + prototype.registerHybridMethod("getLastError", &HybridRunAnywhereCoreSpec::getLastError); + prototype.registerHybridMethod("extractArchive", &HybridRunAnywhereCoreSpec::extractArchive); + prototype.registerHybridMethod("getDeviceCapabilities", &HybridRunAnywhereCoreSpec::getDeviceCapabilities); + prototype.registerHybridMethod("getMemoryUsage", &HybridRunAnywhereCoreSpec::getMemoryUsage); + prototype.registerHybridMethod("loadTextModel", &HybridRunAnywhereCoreSpec::loadTextModel); + prototype.registerHybridMethod("isTextModelLoaded", &HybridRunAnywhereCoreSpec::isTextModelLoaded); + prototype.registerHybridMethod("unloadTextModel", &HybridRunAnywhereCoreSpec::unloadTextModel); + prototype.registerHybridMethod("generate", &HybridRunAnywhereCoreSpec::generate); + prototype.registerHybridMethod("generateStream", &HybridRunAnywhereCoreSpec::generateStream); + prototype.registerHybridMethod("cancelGeneration", &HybridRunAnywhereCoreSpec::cancelGeneration); + prototype.registerHybridMethod("generateStructured", &HybridRunAnywhereCoreSpec::generateStructured); + prototype.registerHybridMethod("loadSTTModel", &HybridRunAnywhereCoreSpec::loadSTTModel); + prototype.registerHybridMethod("isSTTModelLoaded", &HybridRunAnywhereCoreSpec::isSTTModelLoaded); + prototype.registerHybridMethod("unloadSTTModel", &HybridRunAnywhereCoreSpec::unloadSTTModel); + prototype.registerHybridMethod("transcribe", &HybridRunAnywhereCoreSpec::transcribe); + prototype.registerHybridMethod("transcribeFile", &HybridRunAnywhereCoreSpec::transcribeFile); + prototype.registerHybridMethod("loadTTSModel", &HybridRunAnywhereCoreSpec::loadTTSModel); + prototype.registerHybridMethod("isTTSModelLoaded", &HybridRunAnywhereCoreSpec::isTTSModelLoaded); + prototype.registerHybridMethod("unloadTTSModel", &HybridRunAnywhereCoreSpec::unloadTTSModel); + prototype.registerHybridMethod("synthesize", &HybridRunAnywhereCoreSpec::synthesize); + prototype.registerHybridMethod("getTTSVoices", &HybridRunAnywhereCoreSpec::getTTSVoices); + prototype.registerHybridMethod("cancelTTS", &HybridRunAnywhereCoreSpec::cancelTTS); + prototype.registerHybridMethod("loadVADModel", &HybridRunAnywhereCoreSpec::loadVADModel); + prototype.registerHybridMethod("isVADModelLoaded", &HybridRunAnywhereCoreSpec::isVADModelLoaded); + prototype.registerHybridMethod("unloadVADModel", &HybridRunAnywhereCoreSpec::unloadVADModel); + prototype.registerHybridMethod("processVAD", &HybridRunAnywhereCoreSpec::processVAD); + prototype.registerHybridMethod("resetVAD", &HybridRunAnywhereCoreSpec::resetVAD); + prototype.registerHybridMethod("secureStorageSet", &HybridRunAnywhereCoreSpec::secureStorageSet); + prototype.registerHybridMethod("secureStorageGet", &HybridRunAnywhereCoreSpec::secureStorageGet); + prototype.registerHybridMethod("secureStorageDelete", &HybridRunAnywhereCoreSpec::secureStorageDelete); + prototype.registerHybridMethod("secureStorageExists", &HybridRunAnywhereCoreSpec::secureStorageExists); + prototype.registerHybridMethod("getPersistentDeviceUUID", &HybridRunAnywhereCoreSpec::getPersistentDeviceUUID); + prototype.registerHybridMethod("flushTelemetry", &HybridRunAnywhereCoreSpec::flushTelemetry); + prototype.registerHybridMethod("isTelemetryInitialized", &HybridRunAnywhereCoreSpec::isTelemetryInitialized); + prototype.registerHybridMethod("initializeVoiceAgent", &HybridRunAnywhereCoreSpec::initializeVoiceAgent); + prototype.registerHybridMethod("initializeVoiceAgentWithLoadedModels", &HybridRunAnywhereCoreSpec::initializeVoiceAgentWithLoadedModels); + prototype.registerHybridMethod("isVoiceAgentReady", &HybridRunAnywhereCoreSpec::isVoiceAgentReady); + prototype.registerHybridMethod("getVoiceAgentComponentStates", &HybridRunAnywhereCoreSpec::getVoiceAgentComponentStates); + prototype.registerHybridMethod("processVoiceTurn", &HybridRunAnywhereCoreSpec::processVoiceTurn); + prototype.registerHybridMethod("voiceAgentTranscribe", &HybridRunAnywhereCoreSpec::voiceAgentTranscribe); + prototype.registerHybridMethod("voiceAgentGenerateResponse", &HybridRunAnywhereCoreSpec::voiceAgentGenerateResponse); + prototype.registerHybridMethod("voiceAgentSynthesizeSpeech", &HybridRunAnywhereCoreSpec::voiceAgentSynthesizeSpeech); + prototype.registerHybridMethod("cleanupVoiceAgent", &HybridRunAnywhereCoreSpec::cleanupVoiceAgent); + }); + } + +} // namespace margelo::nitro::runanywhere diff --git a/sdk/runanywhere-react-native/packages/core/nitrogen/generated/shared/c++/HybridRunAnywhereCoreSpec.hpp b/sdk/runanywhere-react-native/packages/core/nitrogen/generated/shared/c++/HybridRunAnywhereCoreSpec.hpp new file mode 100644 index 000000000..6a51fc9d6 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/nitrogen/generated/shared/c++/HybridRunAnywhereCoreSpec.hpp @@ -0,0 +1,138 @@ +/// +/// HybridRunAnywhereCoreSpec.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2026 Marc Rousavy @ Margelo +/// + +#pragma once + +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif + + + +#include +#include +#include +#include +// #include // Removed - file does not exist in nitro-modules 0.31.3 +#include + +namespace margelo::nitro::runanywhere { + + using namespace margelo::nitro; + + /** + * An abstract base class for `RunAnywhereCore` + * Inherit this class to create instances of `HybridRunAnywhereCoreSpec` in C++. + * You must explicitly call `HybridObject`'s constructor yourself, because it is virtual. + * @example + * ```cpp + * class HybridRunAnywhereCore: public HybridRunAnywhereCoreSpec { + * public: + * HybridRunAnywhereCore(...): HybridObject(TAG) { ... } + * // ... + * }; + * ``` + */ + class HybridRunAnywhereCoreSpec: public virtual HybridObject { + public: + // Constructor + explicit HybridRunAnywhereCoreSpec(): HybridObject(TAG) { } + + // Destructor + ~HybridRunAnywhereCoreSpec() override = default; + + public: + // Properties + + + public: + // Methods + virtual std::shared_ptr> initialize(const std::string& configJson) = 0; + virtual std::shared_ptr> destroy() = 0; + virtual std::shared_ptr> isInitialized() = 0; + virtual std::shared_ptr> getBackendInfo() = 0; + virtual std::shared_ptr> authenticate(const std::string& apiKey) = 0; + virtual std::shared_ptr> isAuthenticated() = 0; + virtual std::shared_ptr> getUserId() = 0; + virtual std::shared_ptr> getOrganizationId() = 0; + virtual std::shared_ptr> setAuthTokens(const std::string& authResponseJson) = 0; + virtual std::shared_ptr> registerDevice(const std::string& environmentJson) = 0; + virtual std::shared_ptr> isDeviceRegistered() = 0; + virtual std::shared_ptr> clearDeviceRegistration() = 0; + virtual std::shared_ptr> getDeviceId() = 0; + virtual std::shared_ptr> getAvailableModels() = 0; + virtual std::shared_ptr> getModelInfo(const std::string& modelId) = 0; + virtual std::shared_ptr> isModelDownloaded(const std::string& modelId) = 0; + virtual std::shared_ptr> getModelPath(const std::string& modelId) = 0; + virtual std::shared_ptr> registerModel(const std::string& modelJson) = 0; + virtual std::shared_ptr> downloadModel(const std::string& modelId, const std::string& url, const std::string& destPath) = 0; + virtual std::shared_ptr> cancelDownload(const std::string& modelId) = 0; + virtual std::shared_ptr> getDownloadProgress(const std::string& modelId) = 0; + virtual std::shared_ptr> getStorageInfo() = 0; + virtual std::shared_ptr> clearCache() = 0; + virtual std::shared_ptr> deleteModel(const std::string& modelId) = 0; + virtual std::shared_ptr> emitEvent(const std::string& eventJson) = 0; + virtual std::shared_ptr> pollEvents() = 0; + virtual std::shared_ptr> configureHttp(const std::string& baseUrl, const std::string& apiKey) = 0; + virtual std::shared_ptr> httpPost(const std::string& path, const std::string& bodyJson) = 0; + virtual std::shared_ptr> httpGet(const std::string& path) = 0; + virtual std::shared_ptr> getLastError() = 0; + virtual std::shared_ptr> extractArchive(const std::string& archivePath, const std::string& destPath) = 0; + virtual std::shared_ptr> getDeviceCapabilities() = 0; + virtual std::shared_ptr> getMemoryUsage() = 0; + virtual std::shared_ptr> loadTextModel(const std::string& modelPath, const std::optional& configJson) = 0; + virtual std::shared_ptr> isTextModelLoaded() = 0; + virtual std::shared_ptr> unloadTextModel() = 0; + virtual std::shared_ptr> generate(const std::string& prompt, const std::optional& optionsJson) = 0; + virtual std::shared_ptr> generateStream(const std::string& prompt, const std::string& optionsJson, const std::function& callback) = 0; + virtual std::shared_ptr> cancelGeneration() = 0; + virtual std::shared_ptr> generateStructured(const std::string& prompt, const std::string& schema, const std::optional& optionsJson) = 0; + virtual std::shared_ptr> loadSTTModel(const std::string& modelPath, const std::string& modelType, const std::optional& configJson) = 0; + virtual std::shared_ptr> isSTTModelLoaded() = 0; + virtual std::shared_ptr> unloadSTTModel() = 0; + virtual std::shared_ptr> transcribe(const std::string& audioBase64, double sampleRate, const std::optional& language) = 0; + virtual std::shared_ptr> transcribeFile(const std::string& filePath, const std::optional& language) = 0; + virtual std::shared_ptr> loadTTSModel(const std::string& modelPath, const std::string& modelType, const std::optional& configJson) = 0; + virtual std::shared_ptr> isTTSModelLoaded() = 0; + virtual std::shared_ptr> unloadTTSModel() = 0; + virtual std::shared_ptr> synthesize(const std::string& text, const std::string& voiceId, double speedRate, double pitchShift) = 0; + virtual std::shared_ptr> getTTSVoices() = 0; + virtual std::shared_ptr> cancelTTS() = 0; + virtual std::shared_ptr> loadVADModel(const std::string& modelPath, const std::optional& configJson) = 0; + virtual std::shared_ptr> isVADModelLoaded() = 0; + virtual std::shared_ptr> unloadVADModel() = 0; + virtual std::shared_ptr> processVAD(const std::string& audioBase64, const std::optional& optionsJson) = 0; + virtual std::shared_ptr> resetVAD() = 0; + virtual std::shared_ptr> secureStorageSet(const std::string& key, const std::string& value) = 0; + virtual std::shared_ptr>> secureStorageGet(const std::string& key) = 0; + virtual std::shared_ptr> secureStorageDelete(const std::string& key) = 0; + virtual std::shared_ptr> secureStorageExists(const std::string& key) = 0; + virtual std::shared_ptr> getPersistentDeviceUUID() = 0; + virtual std::shared_ptr> flushTelemetry() = 0; + virtual std::shared_ptr> isTelemetryInitialized() = 0; + virtual std::shared_ptr> initializeVoiceAgent(const std::string& configJson) = 0; + virtual std::shared_ptr> initializeVoiceAgentWithLoadedModels() = 0; + virtual std::shared_ptr> isVoiceAgentReady() = 0; + virtual std::shared_ptr> getVoiceAgentComponentStates() = 0; + virtual std::shared_ptr> processVoiceTurn(const std::string& audioBase64) = 0; + virtual std::shared_ptr> voiceAgentTranscribe(const std::string& audioBase64) = 0; + virtual std::shared_ptr> voiceAgentGenerateResponse(const std::string& prompt) = 0; + virtual std::shared_ptr> voiceAgentSynthesizeSpeech(const std::string& text) = 0; + virtual std::shared_ptr> cleanupVoiceAgent() = 0; + + protected: + // Hybrid Setup + void loadHybridMethods() override; + + protected: + // Tag for logging + static constexpr auto TAG = "RunAnywhereCore"; + }; + +} // namespace margelo::nitro::runanywhere diff --git a/sdk/runanywhere-react-native/packages/core/nitrogen/generated/shared/c++/HybridRunAnywhereDeviceInfoSpec.cpp b/sdk/runanywhere-react-native/packages/core/nitrogen/generated/shared/c++/HybridRunAnywhereDeviceInfoSpec.cpp new file mode 100644 index 000000000..d0a0b5da8 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/nitrogen/generated/shared/c++/HybridRunAnywhereDeviceInfoSpec.cpp @@ -0,0 +1,33 @@ +/// +/// HybridRunAnywhereDeviceInfoSpec.cpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2026 Marc Rousavy @ Margelo +/// + +#include "HybridRunAnywhereDeviceInfoSpec.hpp" + +namespace margelo::nitro::runanywhere { + + void HybridRunAnywhereDeviceInfoSpec::loadHybridMethods() { + // load base methods/properties + HybridObject::loadHybridMethods(); + // load custom methods/properties + registerHybrids(this, [](Prototype& prototype) { + prototype.registerHybridMethod("getDeviceModel", &HybridRunAnywhereDeviceInfoSpec::getDeviceModel); + prototype.registerHybridMethod("getOSVersion", &HybridRunAnywhereDeviceInfoSpec::getOSVersion); + prototype.registerHybridMethod("getPlatform", &HybridRunAnywhereDeviceInfoSpec::getPlatform); + prototype.registerHybridMethod("getTotalRAM", &HybridRunAnywhereDeviceInfoSpec::getTotalRAM); + prototype.registerHybridMethod("getAvailableRAM", &HybridRunAnywhereDeviceInfoSpec::getAvailableRAM); + prototype.registerHybridMethod("getCPUCores", &HybridRunAnywhereDeviceInfoSpec::getCPUCores); + prototype.registerHybridMethod("hasGPU", &HybridRunAnywhereDeviceInfoSpec::hasGPU); + prototype.registerHybridMethod("hasNPU", &HybridRunAnywhereDeviceInfoSpec::hasNPU); + prototype.registerHybridMethod("getChipName", &HybridRunAnywhereDeviceInfoSpec::getChipName); + prototype.registerHybridMethod("getThermalState", &HybridRunAnywhereDeviceInfoSpec::getThermalState); + prototype.registerHybridMethod("getBatteryLevel", &HybridRunAnywhereDeviceInfoSpec::getBatteryLevel); + prototype.registerHybridMethod("isCharging", &HybridRunAnywhereDeviceInfoSpec::isCharging); + prototype.registerHybridMethod("isLowPowerMode", &HybridRunAnywhereDeviceInfoSpec::isLowPowerMode); + }); + } + +} // namespace margelo::nitro::runanywhere diff --git a/sdk/runanywhere-react-native/packages/core/nitrogen/generated/shared/c++/HybridRunAnywhereDeviceInfoSpec.hpp b/sdk/runanywhere-react-native/packages/core/nitrogen/generated/shared/c++/HybridRunAnywhereDeviceInfoSpec.hpp new file mode 100644 index 000000000..85bb252ae --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/nitrogen/generated/shared/c++/HybridRunAnywhereDeviceInfoSpec.hpp @@ -0,0 +1,75 @@ +/// +/// HybridRunAnywhereDeviceInfoSpec.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2026 Marc Rousavy @ Margelo +/// + +#pragma once + +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif + + + +#include +#include + +namespace margelo::nitro::runanywhere { + + using namespace margelo::nitro; + + /** + * An abstract base class for `RunAnywhereDeviceInfo` + * Inherit this class to create instances of `HybridRunAnywhereDeviceInfoSpec` in C++. + * You must explicitly call `HybridObject`'s constructor yourself, because it is virtual. + * @example + * ```cpp + * class HybridRunAnywhereDeviceInfo: public HybridRunAnywhereDeviceInfoSpec { + * public: + * HybridRunAnywhereDeviceInfo(...): HybridObject(TAG) { ... } + * // ... + * }; + * ``` + */ + class HybridRunAnywhereDeviceInfoSpec: public virtual HybridObject { + public: + // Constructor + explicit HybridRunAnywhereDeviceInfoSpec(): HybridObject(TAG) { } + + // Destructor + ~HybridRunAnywhereDeviceInfoSpec() override = default; + + public: + // Properties + + + public: + // Methods + virtual std::shared_ptr> getDeviceModel() = 0; + virtual std::shared_ptr> getOSVersion() = 0; + virtual std::shared_ptr> getPlatform() = 0; + virtual std::shared_ptr> getTotalRAM() = 0; + virtual std::shared_ptr> getAvailableRAM() = 0; + virtual std::shared_ptr> getCPUCores() = 0; + virtual std::shared_ptr> hasGPU() = 0; + virtual std::shared_ptr> hasNPU() = 0; + virtual std::shared_ptr> getChipName() = 0; + virtual std::shared_ptr> getThermalState() = 0; + virtual std::shared_ptr> getBatteryLevel() = 0; + virtual std::shared_ptr> isCharging() = 0; + virtual std::shared_ptr> isLowPowerMode() = 0; + + protected: + // Hybrid Setup + void loadHybridMethods() override; + + protected: + // Tag for logging + static constexpr auto TAG = "RunAnywhereDeviceInfo"; + }; + +} // namespace margelo::nitro::runanywhere diff --git a/sdk/runanywhere-react-native/packages/core/package.json b/sdk/runanywhere-react-native/packages/core/package.json new file mode 100644 index 000000000..72695a795 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/package.json @@ -0,0 +1,78 @@ +{ + "name": "@runanywhere/core", + "version": "0.17.6", + "description": "Core SDK for RunAnywhere React Native - includes RACommons bindings, native bridges, and public API", + "main": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": { + "source": "./src/index.ts", + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./package.json": "./package.json" + }, + "react-native": "src/index", + "source": "src/index", + "files": [ + "src", + "cpp", + "ios", + "android", + "nitrogen", + "nitro.json", + "react-native.config.js", + "*.podspec" + ], + "scripts": { + "typecheck": "tsc --noEmit", + "lint": "eslint \"src/**/*.ts\"", + "lint:fix": "eslint \"src/**/*.ts\" --fix", + "nitrogen": "nitrogen && node scripts/fix-nitrogen-output.js", + "prepare": "npm run nitrogen", + "prepublishOnly": "node scripts/fix-nitrogen-output.js" + }, + "keywords": [ + "react-native", + "runanywhere", + "ai", + "on-device", + "machine-learning", + "nitro", + "expo" + ], + "license": "MIT", + "peerDependencies": { + "react": ">=18.0.0", + "react-native": ">=0.74.0", + "react-native-blob-util": ">=0.19.0", + "react-native-device-info": ">=11.0.0", + "react-native-fs": ">=2.20.0", + "react-native-nitro-modules": ">=0.31.3", + "react-native-zip-archive": ">=6.1.0" + }, + "peerDependenciesMeta": { + "react-native-blob-util": { + "optional": true + }, + "react-native-device-info": { + "optional": true + }, + "react-native-fs": { + "optional": true + }, + "react-native-zip-archive": { + "optional": true + } + }, + "devDependencies": { + "@types/react": "~19.1.0", + "nitrogen": "^0.31.10", + "react-native-nitro-modules": "^0.31.10", + "typescript": "~5.9.2" + }, + "create-react-native-library": { + "languages": "kotlin-swift", + "type": "nitro-module" + } +} diff --git a/sdk/runanywhere-react-native/packages/core/react-native.config.js b/sdk/runanywhere-react-native/packages/core/react-native.config.js new file mode 100644 index 000000000..af868489d --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/react-native.config.js @@ -0,0 +1,14 @@ +module.exports = { + dependency: { + platforms: { + android: { + sourceDir: './android', + packageImportPath: 'import com.margelo.nitro.runanywhere.RunAnywhereCorePackage;', + packageInstance: 'new RunAnywhereCorePackage()', + }, + ios: { + podspecPath: './RunAnywhereCore.podspec', + }, + }, + }, +}; diff --git a/sdk/runanywhere-react-native/packages/core/scripts/fix-nitrogen-output.js b/sdk/runanywhere-react-native/packages/core/scripts/fix-nitrogen-output.js new file mode 100755 index 000000000..e75aea3a9 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/scripts/fix-nitrogen-output.js @@ -0,0 +1,25 @@ +#!/usr/bin/env node +/** + * Post-nitrogen script to fix generated code + * Removes the non-existent Null.hpp include from generated files + */ + +const fs = require('fs'); +const path = require('path'); + +const filePath = path.join(__dirname, '../nitrogen/generated/shared/c++/HybridRunAnywhereCoreSpec.hpp'); + +if (fs.existsSync(filePath)) { + let content = fs.readFileSync(filePath, 'utf8'); + + // Replace the Null.hpp include with a comment + content = content.replace( + /#include /g, + '// #include // Removed - file does not exist in nitro-modules 0.31.3' + ); + + fs.writeFileSync(filePath, content, 'utf8'); + console.log('✅ Fixed Null.hpp include in HybridRunAnywhereCoreSpec.hpp'); +} else { + console.log('⚠️ File not found:', filePath); +} diff --git a/sdk/runanywhere-react-native/packages/core/src/Features/VoiceSession/AudioCaptureManager.ts b/sdk/runanywhere-react-native/packages/core/src/Features/VoiceSession/AudioCaptureManager.ts new file mode 100644 index 000000000..6df2d2333 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/Features/VoiceSession/AudioCaptureManager.ts @@ -0,0 +1,481 @@ +/** + * AudioCaptureManager.ts + * + * Manages audio recording from the device microphone. + * Provides a cross-platform abstraction for audio capture in React Native. + * + * Reference: sdk/runanywhere-swift/Sources/RunAnywhere/Features/STT/Services/AudioCaptureManager.swift + */ + +import { Platform, PermissionsAndroid, NativeModules } from 'react-native'; +import { SDKLogger } from '../../Foundation/Logging/Logger/SDKLogger'; + +const logger = new SDKLogger('AudioCaptureManager'); + +// Lazy-load EventBus to avoid circular dependency issues during module initialization +// The circular dependency: AudioCaptureManager -> EventBus -> SDKLogger -> ... -> AudioCaptureManager +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let _eventBus: any = null; +function getEventBus() { + if (!_eventBus) { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + _eventBus = require('../../Public/Events').EventBus; + } catch { + logger.warning('EventBus not available'); + } + } + return _eventBus; +} + +/** + * Safely publish an event to the EventBus + * Handles cases where EventBus may not be fully initialized + */ +function safePublish(eventType: string, event: Record): void { + try { + const eventBus = getEventBus(); + if (eventBus?.publish) { + eventBus.publish(eventType, event); + } + } catch { + // Ignore EventBus errors - events are non-critical for audio functionality + } +} + +// Native iOS Audio Module (provided by the app) +const NativeAudioModule = Platform.OS === 'ios' ? NativeModules.NativeAudioModule : null; + +// Lazy load LiveAudioStream for Android +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let LiveAudioStream: any = null; + +function getLiveAudioStream() { + if (Platform.OS !== 'android') return null; + if (!LiveAudioStream) { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + LiveAudioStream = require('react-native-live-audio-stream').default; + } catch { + logger.warning('react-native-live-audio-stream not available'); + return null; + } + } + return LiveAudioStream; +} + +/** + * Audio data callback type + */ +export type AudioDataCallback = (audioData: ArrayBuffer) => void; + +/** + * Audio level callback type (level is 0.0 - 1.0) + */ +export type AudioLevelCallback = (level: number) => void; + +/** + * Audio capture configuration + */ +export interface AudioCaptureConfig { + /** Sample rate in Hz (default: 16000) */ + sampleRate?: number; + /** Number of channels (default: 1) */ + channels?: number; + /** Bits per sample (default: 16) */ + bitsPerSample?: number; +} + +/** + * Audio capture state + */ +export type AudioCaptureState = 'idle' | 'requesting_permission' | 'recording' | 'paused' | 'error'; + +/** + * AudioCaptureManager + * + * Handles microphone recording with permission management and audio level monitoring. + * Uses platform-native audio APIs: + * - iOS: NativeAudioModule (AVFoundation) + * - Android: react-native-live-audio-stream + */ +export class AudioCaptureManager { + private state: AudioCaptureState = 'idle'; + private config: Required; + private audioDataCallback: AudioDataCallback | null = null; + private audioLevelCallback: AudioLevelCallback | null = null; + private currentAudioLevel = 0; + private recordingStartTime: number | null = null; + private audioBuffer: ArrayBuffer[] = []; + private levelUpdateInterval: ReturnType | null = null; + private recordingPath: string | null = null; + private androidAudioChunks: string[] = []; + + constructor(config: AudioCaptureConfig = {}) { + this.config = { + sampleRate: config.sampleRate ?? 16000, + channels: config.channels ?? 1, + bitsPerSample: config.bitsPerSample ?? 16, + }; + } + + /** + * Current audio level (0.0 - 1.0) + */ + get audioLevel(): number { + return this.currentAudioLevel; + } + + /** + * Current capture state + */ + get captureState(): AudioCaptureState { + return this.state; + } + + /** + * Whether recording is active + */ + get isRecording(): boolean { + return this.state === 'recording'; + } + + /** + * Request microphone permission + * @returns true if permission granted + */ + async requestPermission(): Promise { + this.state = 'requesting_permission'; + logger.info('Requesting microphone permission...'); + + try { + if (Platform.OS === 'android') { + const grants = await PermissionsAndroid.requestMultiple([ + PermissionsAndroid.PERMISSIONS.RECORD_AUDIO, + ]); + const granted = grants[PermissionsAndroid.PERMISSIONS.RECORD_AUDIO] === PermissionsAndroid.RESULTS.GRANTED; + logger.info(`Android microphone permission: ${granted ? 'granted' : 'denied'}`); + this.state = granted ? 'idle' : 'error'; + return granted; + } + + // iOS: Permission is requested when starting recording + logger.info('Microphone permission granted (iOS)'); + this.state = 'idle'; + return true; + } catch (error) { + logger.error(`Permission request failed: ${error}`); + this.state = 'error'; + return false; + } + } + + /** + * Start recording audio + * @param onAudioData Callback for audio data chunks + */ + async startRecording(onAudioData?: AudioDataCallback): Promise { + if (this.state === 'recording') { + logger.warning('Already recording'); + return; + } + + this.audioDataCallback = onAudioData ?? null; + this.audioBuffer = []; + this.recordingStartTime = Date.now(); + this.state = 'recording'; + + logger.info('Starting audio recording...'); + safePublish('Voice', { type: 'recordingStarted' }); + + if (Platform.OS === 'ios') { + await this.startIOSRecording(); + } else { + await this.startAndroidRecording(); + } + } + + /** + * Stop recording and return recorded audio file path + */ + async stopRecording(): Promise<{ path: string; durationMs: number }> { + if (this.state !== 'recording') { + throw new Error('Not recording'); + } + + logger.info('Stopping audio recording...'); + this.state = 'idle'; + this.stopAudioLevelMonitoring(); + + const durationMs = this.recordingStartTime ? Date.now() - this.recordingStartTime : 0; + let path = ''; + + if (Platform.OS === 'ios') { + path = await this.stopIOSRecording(); + } else { + path = await this.stopAndroidRecording(); + } + + safePublish('Voice', { type: 'recordingStopped', duration: durationMs / 1000 }); + + this.audioDataCallback = null; + this.recordingStartTime = null; + + return { path, durationMs }; + } + + /** + * Set audio level callback + */ + setAudioLevelCallback(callback: AudioLevelCallback | null): void { + this.audioLevelCallback = callback; + } + + /** + * Cleanup resources + */ + cleanup(): void { + if (this.state === 'recording') { + this.stopRecording().catch(() => {}); + } + this.audioBuffer = []; + this.audioDataCallback = null; + this.audioLevelCallback = null; + this.stopAudioLevelMonitoring(); + logger.info('AudioCaptureManager cleaned up'); + } + + // iOS Implementation + + private async startIOSRecording(): Promise { + if (!NativeAudioModule) { + throw new Error('NativeAudioModule not available on iOS'); + } + + try { + const result = await NativeAudioModule.startRecording(); + this.recordingPath = result.path; + logger.info(`iOS recording started: ${result.path}`); + + // Start audio level polling + this.startAudioLevelMonitoring(); + } catch (error) { + this.state = 'error'; + throw error; + } + } + + private async stopIOSRecording(): Promise { + if (!NativeAudioModule) { + throw new Error('NativeAudioModule not available'); + } + + const result = await NativeAudioModule.stopRecording(); + return result.path; + } + + // Android Implementation + + private async startAndroidRecording(): Promise { + const audioStream = getLiveAudioStream(); + if (!audioStream) { + throw new Error('LiveAudioStream not available on Android'); + } + + this.androidAudioChunks = []; + + audioStream.init({ + sampleRate: this.config.sampleRate, + channels: this.config.channels, + bitsPerSample: this.config.bitsPerSample, + audioSource: 6, // VOICE_RECOGNITION + bufferSize: 4096, + }); + + audioStream.on('data', (data: string) => { + this.androidAudioChunks.push(data); + + // Calculate audio level from chunk + const level = this.calculateAudioLevelFromBase64(data); + this.currentAudioLevel = level; + + if (this.audioLevelCallback) { + this.audioLevelCallback(level); + } + + // Convert to ArrayBuffer and forward to callback + if (this.audioDataCallback) { + const buffer = this.base64ToArrayBuffer(data); + this.audioDataCallback(buffer); + } + }); + + audioStream.start(); + logger.info('Android recording started'); + } + + private async stopAndroidRecording(): Promise { + const audioStream = getLiveAudioStream(); + if (audioStream) { + audioStream.stop(); + } + + // Create WAV file from chunks + const path = await this.createWavFileFromChunks(); + this.androidAudioChunks = []; + return path; + } + + private async createWavFileFromChunks(): Promise { + // Combine all audio chunks into PCM data + let totalLength = 0; + const decodedChunks: Uint8Array[] = []; + + for (const chunk of this.androidAudioChunks) { + const decoded = Uint8Array.from(atob(chunk), c => c.charCodeAt(0)); + decodedChunks.push(decoded); + totalLength += decoded.length; + } + + // Create combined PCM buffer + const pcmData = new Uint8Array(totalLength); + let offset = 0; + for (const chunk of decodedChunks) { + pcmData.set(chunk, offset); + offset += chunk.length; + } + + // Create WAV header + const wavHeader = this.createWavHeader(totalLength); + const headerBytes = new Uint8Array(wavHeader); + + // Combine header and PCM data + const wavData = new Uint8Array(headerBytes.length + pcmData.length); + wavData.set(headerBytes, 0); + wavData.set(pcmData, headerBytes.length); + + // Write to file using RNFS + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const RNFS = require('react-native-fs'); + const fileName = `recording_${Date.now()}.wav`; + const filePath = `${RNFS.CachesDirectoryPath}/${fileName}`; + + const wavBase64 = this.arrayBufferToBase64(wavData.buffer); + await RNFS.writeFile(filePath, wavBase64, 'base64'); + + logger.info(`Android WAV file created: ${filePath}`); + return filePath; + } catch (error) { + logger.error(`Failed to create WAV file: ${error}`); + throw error; + } + } + + // Audio Level Monitoring + + private startAudioLevelMonitoring(): void { + if (Platform.OS === 'ios' && NativeAudioModule) { + // Poll audio level from native module + this.levelUpdateInterval = setInterval(async () => { + if (this.state !== 'recording') return; + + try { + const result = await NativeAudioModule.getAudioLevel(); + // Convert linear level (0-1) to normalized (0-1) + this.currentAudioLevel = result.level; + + if (this.audioLevelCallback) { + this.audioLevelCallback(this.currentAudioLevel); + } + } catch { + // Ignore errors + } + }, 50); + } + // Android audio level is calculated inline in the data callback + } + + private stopAudioLevelMonitoring(): void { + if (this.levelUpdateInterval) { + clearInterval(this.levelUpdateInterval); + this.levelUpdateInterval = null; + } + this.currentAudioLevel = 0; + } + + // Utilities + + private calculateAudioLevelFromBase64(base64Data: string): number { + try { + const bytes = Uint8Array.from(atob(base64Data), c => c.charCodeAt(0)); + const samples = new Int16Array(bytes.buffer); + + if (samples.length === 0) return 0; + + let sumSquares = 0; + for (let i = 0; i < samples.length; i++) { + const normalized = samples[i]! / 32768.0; + sumSquares += normalized * normalized; + } + + const rms = Math.sqrt(sumSquares / samples.length); + return Math.min(1, rms * 3); // Amplify slightly for visibility + } catch { + return 0; + } + } + + private base64ToArrayBuffer(base64: string): ArrayBuffer { + const binaryString = atob(base64); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes.buffer; + } + + private arrayBufferToBase64(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]!); + } + return btoa(binary); + } + + private createWavHeader(dataLength: number): ArrayBuffer { + const buffer = new ArrayBuffer(44); + const view = new DataView(buffer); + const { sampleRate, channels, bitsPerSample } = this.config; + const byteRate = sampleRate * channels * (bitsPerSample / 8); + const blockAlign = channels * (bitsPerSample / 8); + + // RIFF header + this.writeString(view, 0, 'RIFF'); + view.setUint32(4, 36 + dataLength, true); + this.writeString(view, 8, 'WAVE'); + + // fmt chunk + this.writeString(view, 12, 'fmt '); + view.setUint32(16, 16, true); + view.setUint16(20, 1, true); + view.setUint16(22, channels, true); + view.setUint32(24, sampleRate, true); + view.setUint32(28, byteRate, true); + view.setUint16(32, blockAlign, true); + view.setUint16(34, bitsPerSample, true); + + // data chunk + this.writeString(view, 36, 'data'); + view.setUint32(40, dataLength, true); + + return buffer; + } + + private writeString(view: DataView, offset: number, str: string): void { + for (let i = 0; i < str.length; i++) { + view.setUint8(offset + i, str.charCodeAt(i)); + } + } +} diff --git a/sdk/runanywhere-react-native/packages/core/src/Features/VoiceSession/AudioPlaybackManager.ts b/sdk/runanywhere-react-native/packages/core/src/Features/VoiceSession/AudioPlaybackManager.ts new file mode 100644 index 000000000..603313e36 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/Features/VoiceSession/AudioPlaybackManager.ts @@ -0,0 +1,503 @@ +/** + * AudioPlaybackManager.ts + * + * Manages audio playback for TTS output. + * Provides a cross-platform abstraction for audio playback in React Native. + * + * Reference: sdk/runanywhere-swift/Sources/RunAnywhere/Features/TTS/Services/AudioPlaybackManager.swift + */ + +import { Platform, NativeModules } from 'react-native'; +import { SDKLogger } from '../../Foundation/Logging/Logger/SDKLogger'; + +const logger = new SDKLogger('AudioPlaybackManager'); + +/** + * Safely publish an event to the EventBus + * Uses lazy loading to avoid circular dependency issues during module initialization + */ +function safePublish(eventType: string, event: Record): void { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { EventBus } = require('../../Public/Events'); + if (EventBus?.publish) { + EventBus.publish(eventType, event); + } + } catch { + // Ignore EventBus errors - events are non-critical for playback functionality + } +} + +// Native iOS Audio Module +const NativeAudioModule = Platform.OS === 'ios' ? NativeModules.NativeAudioModule : null; + +// Lazy load react-native-sound for Android +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let Sound: any = null; + +function getSound() { + if (Platform.OS === 'ios') return null; + if (!Sound) { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + Sound = require('react-native-sound').default; + Sound.setCategory('Playback'); + } catch { + logger.warning('react-native-sound not available'); + return null; + } + } + return Sound; +} + +/** + * Playback state + */ +export type PlaybackState = 'idle' | 'loading' | 'playing' | 'paused' | 'stopped' | 'error'; + +/** + * Playback completion callback + */ +export type PlaybackCompletionCallback = () => void; + +/** + * Playback error callback + */ +export type PlaybackErrorCallback = (error: Error) => void; + +/** + * Audio playback configuration + */ +export interface PlaybackConfig { + /** Volume (0.0 - 1.0) */ + volume?: number; + /** Playback rate multiplier */ + rate?: number; +} + +/** + * AudioPlaybackManager + * + * Handles audio playback for TTS and other audio output needs. + * Uses platform-native audio APIs: + * - iOS: NativeAudioModule (AVAudioPlayer) + * - Android: react-native-sound + */ +export class AudioPlaybackManager { + private state: PlaybackState = 'idle'; + private volume = 1.0; + private rate = 1.0; + private completionCallback: PlaybackCompletionCallback | null = null; + private errorCallback: PlaybackErrorCallback | null = null; + private playbackStartTime: number | null = null; + private playbackDuration: number | null = null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private currentSound: any = null; + + constructor(config: PlaybackConfig = {}) { + this.volume = config.volume ?? 1.0; + this.rate = config.rate ?? 1.0; + } + + /** + * Current playback state + */ + get playbackState(): PlaybackState { + return this.state; + } + + /** + * Whether audio is currently playing + */ + get isPlaying(): boolean { + return this.state === 'playing'; + } + + /** + * Whether audio is paused + */ + get isPaused(): boolean { + return this.state === 'paused'; + } + + /** + * Current volume level + */ + get currentVolume(): number { + return this.volume; + } + + /** + * Current playback rate + */ + get currentRate(): number { + return this.rate; + } + + /** + * Play audio data (base64 PCM float32 from TTS) + * @param audioData Base64 encoded audio data from TTS synthesis + * @param sampleRate Sample rate of the audio (default: 22050) + * @returns Promise that resolves when playback completes + */ + async play(audioData: ArrayBuffer | string, sampleRate = 22050): Promise { + if (this.state === 'playing') { + this.stop(); + } + + this.state = 'loading'; + logger.info('Loading audio for playback...'); + + try { + // Convert base64 PCM to WAV file + let wavPath: string; + if (typeof audioData === 'string') { + wavPath = await this.createWavFromPCMFloat32(audioData, sampleRate); + } else { + // ArrayBuffer - convert to base64 first + const base64 = this.arrayBufferToBase64(audioData); + wavPath = await this.createWavFromPCMFloat32(base64, sampleRate); + } + + // Play the WAV file + await this.playFile(wavPath); + + } catch (error) { + this.state = 'error'; + const err = error instanceof Error ? error : new Error(String(error)); + logger.error(`Playback failed: ${err.message}`); + safePublish('Voice', { type: 'playbackFailed', error: err.message }); + + if (this.errorCallback) { + this.errorCallback(err); + } + throw error; + } + } + + /** + * Play audio from file path + */ + async playFile(filePath: string): Promise { + this.playbackStartTime = Date.now(); + this.state = 'playing'; + + logger.info(`Playing audio file: ${filePath}`); + safePublish('Voice', { type: 'playbackStarted' }); + + if (Platform.OS === 'ios') { + await this.playFileIOS(filePath); + } else { + await this.playFileAndroid(filePath); + } + } + + /** + * Stop playback + */ + stop(): void { + if (this.state === 'idle' || this.state === 'stopped') { + return; + } + + logger.info('Stopping playback'); + this.state = 'stopped'; + + if (Platform.OS === 'ios' && NativeAudioModule) { + NativeAudioModule.stopPlayback().catch(() => {}); + } else if (this.currentSound) { + this.currentSound.stop(); + this.currentSound.release(); + this.currentSound = null; + } + + safePublish('Voice', { type: 'playbackStopped' }); + + if (this.completionCallback) { + this.completionCallback(); + } + } + + /** + * Pause playback + */ + pause(): void { + if (this.state === 'playing') { + this.state = 'paused'; + + if (Platform.OS === 'ios' && NativeAudioModule) { + NativeAudioModule.pausePlayback().catch(() => {}); + } else if (this.currentSound) { + this.currentSound.pause(); + } + + logger.info('Playback paused'); + safePublish('Voice', { type: 'playbackPaused' }); + } + } + + /** + * Resume playback + */ + resume(): void { + if (this.state === 'paused') { + this.state = 'playing'; + + if (Platform.OS === 'ios' && NativeAudioModule) { + NativeAudioModule.resumePlayback().catch(() => {}); + } else if (this.currentSound) { + this.currentSound.play(); + } + + logger.info('Playback resumed'); + safePublish('Voice', { type: 'playbackResumed' }); + } + } + + /** + * Set volume + * @param volume Volume level (0.0 - 1.0) + */ + setVolume(volume: number): void { + this.volume = Math.max(0, Math.min(1, volume)); + if (this.currentSound) { + this.currentSound.setVolume(this.volume); + } + logger.debug(`Volume set to ${this.volume}`); + } + + /** + * Set playback rate + * @param rate Playback rate multiplier (0.5 - 2.0) + */ + setRate(rate: number): void { + this.rate = Math.max(0.5, Math.min(2, rate)); + logger.debug(`Rate set to ${this.rate}`); + } + + /** + * Set completion callback + */ + setCompletionCallback(callback: PlaybackCompletionCallback | null): void { + this.completionCallback = callback; + } + + /** + * Set error callback + */ + setErrorCallback(callback: PlaybackErrorCallback | null): void { + this.errorCallback = callback; + } + + /** + * Get current playback position in seconds + */ + getCurrentPosition(): number { + if (!this.playbackStartTime || this.state !== 'playing') { + return 0; + } + return (Date.now() - this.playbackStartTime) / 1000; + } + + /** + * Get total duration in seconds + */ + getDuration(): number { + return this.playbackDuration ?? 0; + } + + /** + * Cleanup resources + */ + cleanup(): void { + this.stop(); + this.completionCallback = null; + this.errorCallback = null; + this.state = 'idle'; + logger.info('AudioPlaybackManager cleaned up'); + } + + // Private methods + + private async playFileIOS(filePath: string): Promise { + if (!NativeAudioModule) { + throw new Error('NativeAudioModule not available'); + } + + return new Promise((resolve, reject) => { + NativeAudioModule.playAudio(filePath) + .then((result: { duration: number }) => { + this.playbackDuration = result.duration; + + // Wait for playback to complete + const checkInterval = setInterval(async () => { + if (this.state !== 'playing') { + clearInterval(checkInterval); + resolve(); + return; + } + + try { + const status = await NativeAudioModule.getPlaybackStatus(); + if (!status.isPlaying) { + clearInterval(checkInterval); + this.handlePlaybackComplete(); + resolve(); + } + } catch { + clearInterval(checkInterval); + this.handlePlaybackComplete(); + resolve(); + } + }, 100); + }) + .catch((error: Error) => { + this.state = 'error'; + reject(error); + }); + }); + } + + private async playFileAndroid(filePath: string): Promise { + const SoundClass = getSound(); + if (!SoundClass) { + throw new Error('react-native-sound not available'); + } + + return new Promise((resolve, reject) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + this.currentSound = new SoundClass(filePath, '', (error: any) => { + if (error) { + this.state = 'error'; + reject(error); + return; + } + + this.playbackDuration = this.currentSound.getDuration(); + this.currentSound.setVolume(this.volume); + + this.currentSound.play((success: boolean) => { + if (this.currentSound) { + this.currentSound.release(); + this.currentSound = null; + } + + if (success) { + this.handlePlaybackComplete(); + resolve(); + } else { + this.state = 'error'; + reject(new Error('Playback failed')); + } + }); + }); + }); + } + + private handlePlaybackComplete(): void { + const duration = this.playbackStartTime + ? (Date.now() - this.playbackStartTime) / 1000 + : 0; + + this.state = 'idle'; + this.playbackStartTime = null; + + logger.info(`Playback completed (${duration.toFixed(2)}s)`); + + safePublish('Voice', { + type: 'playbackCompleted', + duration, + }); + + if (this.completionCallback) { + this.completionCallback(); + } + } + + /** + * Convert base64 PCM float32 audio to WAV file + * TTS output is base64-encoded float32 PCM samples + */ + private async createWavFromPCMFloat32(audioBase64: string, sampleRate: number): Promise { + // Decode base64 to get raw bytes + const binaryString = atob(audioBase64); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + + // Convert float32 samples to int16 (WAV compatible) + const floatView = new Float32Array(bytes.buffer); + const numSamples = floatView.length; + const int16Samples = new Int16Array(numSamples); + + for (let i = 0; i < numSamples; i++) { + const floatSample = floatView[i] ?? 0; + const sample = Math.max(-1, Math.min(1, floatSample)); + int16Samples[i] = sample < 0 ? sample * 0x8000 : sample * 0x7fff; + } + + // Create WAV header (44 bytes) + const wavDataSize = int16Samples.length * 2; + const wavBuffer = new ArrayBuffer(44 + wavDataSize); + const wavView = new DataView(wavBuffer); + + // RIFF header + this.writeString(wavView, 0, 'RIFF'); + wavView.setUint32(4, 36 + wavDataSize, true); + this.writeString(wavView, 8, 'WAVE'); + + // fmt chunk + this.writeString(wavView, 12, 'fmt '); + wavView.setUint32(16, 16, true); + wavView.setUint16(20, 1, true); // PCM + wavView.setUint16(22, 1, true); // mono + wavView.setUint32(24, sampleRate, true); + wavView.setUint32(28, sampleRate * 2, true); + wavView.setUint16(32, 2, true); + wavView.setUint16(34, 16, true); + + // data chunk + this.writeString(wavView, 36, 'data'); + wavView.setUint32(40, wavDataSize, true); + + // Copy audio data + const wavBytes = new Uint8Array(wavBuffer); + const int16Bytes = new Uint8Array(int16Samples.buffer); + for (let i = 0; i < int16Bytes.length; i++) { + wavBytes[44 + i] = int16Bytes[i]!; + } + + // Write to file + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const RNFS = require('react-native-fs'); + const fileName = `tts_${Date.now()}.wav`; + const filePath = `${RNFS.CachesDirectoryPath}/${fileName}`; + + const wavBase64 = this.arrayBufferToBase64(wavBuffer); + await RNFS.writeFile(filePath, wavBase64, 'base64'); + + logger.info(`WAV file created: ${filePath}`); + return filePath; + } catch (error) { + logger.error(`Failed to create WAV file: ${error}`); + throw error; + } + } + + private writeString(view: DataView, offset: number, str: string): void { + for (let i = 0; i < str.length; i++) { + view.setUint8(offset + i, str.charCodeAt(i)); + } + } + + private arrayBufferToBase64(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]!); + } + return btoa(binary); + } +} diff --git a/sdk/runanywhere-react-native/packages/core/src/Features/VoiceSession/VoiceSessionHandle.ts b/sdk/runanywhere-react-native/packages/core/src/Features/VoiceSession/VoiceSessionHandle.ts new file mode 100644 index 000000000..8920c9ad1 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/Features/VoiceSession/VoiceSessionHandle.ts @@ -0,0 +1,626 @@ +/** + * VoiceSessionHandle.ts + * + * High-level voice session API for simplified voice assistant integration. + * Handles audio capture, VAD, and processing internally. + * + * Matches Swift SDK: sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/VoiceAgent/RunAnywhere+VoiceSession.swift + * + * Usage: + * ```typescript + * // Start a voice session + * const session = await RunAnywhere.startVoiceSession(); + * + * // Consume events + * for await (const event of session.events()) { + * switch (event.type) { + * case 'listening': updateAudioMeter(event.audioLevel); break; + * case 'transcribed': showUserText(event.transcription); break; + * case 'responded': showAssistantText(event.response); break; + * case 'speaking': showSpeakingIndicator(); break; + * } + * } + * + * // Or use callback + * const session = await RunAnywhere.startVoiceSession({ + * onEvent: (event) => { ... } + * }); + * ``` + */ + +import { SDKLogger } from '../../Foundation/Logging/Logger/SDKLogger'; +import { AudioCaptureManager } from './AudioCaptureManager'; + +// Lazy-load EventBus to avoid circular dependency issues during module initialization +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let _eventBus: any = null; +function getEventBus() { + if (!_eventBus) { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + _eventBus = require('../../Public/Events').EventBus; + } catch { + // EventBus not available + } + } + return _eventBus; +} + +/** + * Safely publish an event to the EventBus + * Handles cases where EventBus may not be fully initialized due to circular dependencies + */ +function safePublish(eventType: string, event: Record): void { + try { + const eventBus = getEventBus(); + if (eventBus?.publish) { + eventBus.publish(eventType, event); + } + } catch { + // Ignore EventBus errors - events are non-critical for voice session functionality + } +} +import { AudioPlaybackManager } from './AudioPlaybackManager'; +import * as STT from '../../Public/Extensions/RunAnywhere+STT'; +import * as TextGeneration from '../../Public/Extensions/RunAnywhere+TextGeneration'; +import * as TTS from '../../Public/Extensions/RunAnywhere+TTS'; + +const logger = new SDKLogger('VoiceSession'); + +// ============================================================================ +// Types +// ============================================================================ + +/** + * Voice session configuration + * Matches Swift: VoiceSessionConfig + */ +export interface VoiceSessionConfig { + /** Silence duration (seconds) before processing speech (default: 1.5) */ + silenceDuration?: number; + + /** Minimum audio level to detect speech (0.0 - 1.0, default: 0.1) */ + speechThreshold?: number; + + /** Whether to auto-play TTS response (default: true) */ + autoPlayTTS?: boolean; + + /** Whether to auto-resume listening after TTS playback (default: true) */ + continuousMode?: boolean; + + /** Language code for STT (default: 'en') */ + language?: string; + + /** System prompt for LLM */ + systemPrompt?: string; + + /** Event callback (alternative to using events() iterator) */ + onEvent?: VoiceSessionEventCallback; +} + +/** + * Default configuration + */ +export const DEFAULT_VOICE_SESSION_CONFIG: Required> = { + silenceDuration: 1.5, + speechThreshold: 0.1, + autoPlayTTS: true, + continuousMode: true, + language: 'en', + systemPrompt: '', +}; + +/** + * Voice session event types + * Matches Swift: VoiceSessionEvent + */ +export type VoiceSessionEventType = + | 'started' + | 'listening' + | 'speechStarted' + | 'speechEnded' + | 'processing' + | 'transcribed' + | 'responded' + | 'speaking' + | 'turnCompleted' + | 'stopped' + | 'error'; + +/** + * Voice session event + */ +export interface VoiceSessionEvent { + type: VoiceSessionEventType; + timestamp: number; + /** Audio level (for 'listening' events, 0.0 - 1.0) */ + audioLevel?: number; + /** User's transcribed text (for 'transcribed' and 'turnCompleted' events) */ + transcription?: string; + /** Assistant's response (for 'responded' and 'turnCompleted' events) */ + response?: string; + /** TTS audio data (for 'turnCompleted' events) */ + audio?: string; + /** Error message (for 'error' events) */ + error?: string; +} + +/** + * Voice session event callback + */ +export type VoiceSessionEventCallback = (event: VoiceSessionEvent) => void; + +/** + * Voice session state + */ +export type VoiceSessionState = + | 'idle' + | 'starting' + | 'listening' + | 'processing' + | 'speaking' + | 'stopped' + | 'error'; + +// ============================================================================ +// VoiceSessionHandle +// ============================================================================ + +/** + * VoiceSessionHandle + * + * Handle to control an active voice session. + * Manages the full voice interaction loop: listen -> transcribe -> respond -> speak. + * + * Matches Swift SDK: VoiceSessionHandle actor + */ +export class VoiceSessionHandle { + private config: Required>; + private audioCapture: AudioCaptureManager; + private audioPlayback: AudioPlaybackManager; + private eventCallback: VoiceSessionEventCallback | null = null; + private eventListeners: VoiceSessionEventCallback[] = []; + + private state: VoiceSessionState = 'idle'; + private isSpeechActive = false; + private lastSpeechTime: number | null = null; + private vadInterval: ReturnType | null = null; + private currentAudioLevel = 0; + + constructor(config: VoiceSessionConfig = {}) { + const { onEvent, ...rest } = config; + this.config = { ...DEFAULT_VOICE_SESSION_CONFIG, ...rest }; + this.audioCapture = new AudioCaptureManager({ sampleRate: 16000 }); + this.audioPlayback = new AudioPlaybackManager(); + + if (onEvent) { + this.eventCallback = onEvent; + } + } + + // ============================================================================ + // Public Properties + // ============================================================================ + + /** + * Current session state + */ + get sessionState(): VoiceSessionState { + return this.state; + } + + /** + * Whether the session is running (listening or processing) + */ + get isRunning(): boolean { + return this.state !== 'idle' && this.state !== 'stopped' && this.state !== 'error'; + } + + /** + * Whether audio is currently playing + */ + get isSpeaking(): boolean { + return this.audioPlayback.isPlaying; + } + + /** + * Current audio level (0.0 - 1.0) + */ + get audioLevel(): number { + return this.currentAudioLevel; + } + + // ============================================================================ + // Public Methods + // ============================================================================ + + /** + * Start the voice session + */ + async start(): Promise { + if (this.isRunning) { + logger.warning('Session already running'); + return; + } + + this.state = 'starting'; + logger.info('Starting voice session...'); + + try { + // Check if models are loaded + const sttLoaded = await STT.isSTTModelLoaded(); + const llmLoaded = await TextGeneration.isModelLoaded(); + const ttsLoaded = await TTS.isTTSModelLoaded(); + + if (!sttLoaded || !llmLoaded || !ttsLoaded) { + throw new Error( + `Voice agent not ready. Models loaded: STT=${sttLoaded}, LLM=${llmLoaded}, TTS=${ttsLoaded}` + ); + } + + // Request microphone permission + const hasPermission = await this.audioCapture.requestPermission(); + if (!hasPermission) { + throw new Error('Microphone permission denied'); + } + + this.emit({ type: 'started', timestamp: Date.now() }); + await this.startListening(); + + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + this.state = 'error'; + logger.error(`Failed to start session: ${errorMsg}`); + this.emit({ type: 'error', timestamp: Date.now(), error: errorMsg }); + throw error; + } + } + + /** + * Stop the voice session + */ + stop(): void { + if (this.state === 'idle' || this.state === 'stopped') { + return; + } + + logger.info('Stopping voice session'); + this.state = 'stopped'; + + // Stop audio + this.audioCapture.cleanup(); + this.audioPlayback.stop(); + + // Clear VAD + if (this.vadInterval) { + clearInterval(this.vadInterval); + this.vadInterval = null; + } + + this.isSpeechActive = false; + this.lastSpeechTime = null; + + this.emit({ type: 'stopped', timestamp: Date.now() }); + logger.info('Voice session stopped'); + } + + /** + * Force process current audio (push-to-talk mode) + */ + async sendNow(): Promise { + if (!this.isRunning) { + logger.warning('Session not running'); + return; + } + + this.isSpeechActive = false; + await this.processCurrentAudio(); + } + + /** + * Add event listener + */ + addEventListener(callback: VoiceSessionEventCallback): () => void { + this.eventListeners.push(callback); + return () => { + const index = this.eventListeners.indexOf(callback); + if (index > -1) { + this.eventListeners.splice(index, 1); + } + }; + } + + /** + * Set single event callback (alternative to addEventListener) + */ + setEventCallback(callback: VoiceSessionEventCallback | null): void { + this.eventCallback = callback; + } + + /** + * Create async iterator for events + * Matches Swift's AsyncStream pattern + */ + async *events(): AsyncGenerator { + const queue: VoiceSessionEvent[] = []; + let resolver: ((value: VoiceSessionEvent | null) => void) | null = null; + let done = false; + + const unsubscribe = this.addEventListener((event) => { + if (event.type === 'stopped' || event.type === 'error') { + done = true; + } + + if (resolver) { + const currentResolver = resolver; + resolver = null; + currentResolver(event); + } else { + queue.push(event); + } + }); + + try { + while (!done) { + if (queue.length > 0) { + const event = queue.shift()!; + yield event; + if (event.type === 'stopped' || event.type === 'error') { + break; + } + } else { + const event = await new Promise((resolve) => { + resolver = resolve; + }); + if (event === null) break; + yield event; + } + } + } finally { + unsubscribe(); + } + } + + /** + * Cleanup resources + */ + cleanup(): void { + this.stop(); + this.audioCapture.cleanup(); + this.audioPlayback.cleanup(); + this.eventListeners = []; + this.eventCallback = null; + logger.info('VoiceSessionHandle cleaned up'); + } + + // ============================================================================ + // Private Methods + // ============================================================================ + + private emit(event: VoiceSessionEvent): void { + // Call single callback + if (this.eventCallback) { + this.eventCallback(event); + } + + // Call all listeners + for (const listener of this.eventListeners) { + try { + listener(event); + } catch (error) { + logger.error(`Event listener error: ${error}`); + } + } + + // Publish to EventBus for app-wide observation + // Map event type to EventBus type (note: we avoid spreading 'type' twice) + const { type, ...eventData } = event; + const eventBusType = `voiceSession_${type}` as const; + + switch (eventBusType) { + case 'voiceSession_started': + safePublish('Voice', { type: 'voiceSession_started' }); + break; + case 'voiceSession_listening': + safePublish('Voice', { type: 'voiceSession_listening', audioLevel: eventData.audioLevel }); + break; + case 'voiceSession_speechStarted': + safePublish('Voice', { type: 'voiceSession_speechStarted' }); + break; + case 'voiceSession_speechEnded': + safePublish('Voice', { type: 'voiceSession_speechEnded' }); + break; + case 'voiceSession_processing': + safePublish('Voice', { type: 'voiceSession_processing' }); + break; + case 'voiceSession_transcribed': + safePublish('Voice', { type: 'voiceSession_transcribed', transcription: eventData.transcription }); + break; + case 'voiceSession_responded': + safePublish('Voice', { type: 'voiceSession_responded', response: eventData.response }); + break; + case 'voiceSession_speaking': + safePublish('Voice', { type: 'voiceSession_speaking' }); + break; + case 'voiceSession_turnCompleted': + safePublish('Voice', { + type: 'voiceSession_turnCompleted', + transcription: eventData.transcription, + response: eventData.response, + audio: eventData.audio, + }); + break; + case 'voiceSession_stopped': + safePublish('Voice', { type: 'voiceSession_stopped' }); + break; + case 'voiceSession_error': + safePublish('Voice', { type: 'voiceSession_error', error: eventData.error }); + break; + } + } + + private async startListening(): Promise { + this.state = 'listening'; + this.isSpeechActive = false; + this.lastSpeechTime = null; + + // Set up audio level callback + this.audioCapture.setAudioLevelCallback((level) => { + this.currentAudioLevel = level; + this.emit({ type: 'listening', timestamp: Date.now(), audioLevel: level }); + }); + + // Start recording + await this.audioCapture.startRecording(); + + // Start VAD monitoring loop (matches Swift's startAudioLevelMonitoring) + this.startVADMonitoring(); + } + + /** + * VAD monitoring loop - runs every 50ms + * Matches Swift: startAudioLevelMonitoring() + */ + private startVADMonitoring(): void { + this.vadInterval = setInterval(() => { + this.checkSpeechState(this.currentAudioLevel); + }, 50); + } + + /** + * Check speech state based on audio level + * Matches Swift: checkSpeechState(level:) + */ + private checkSpeechState(level: number): void { + if (!this.isRunning || this.state === 'processing' || this.state === 'speaking') { + return; + } + + if (level > this.config.speechThreshold) { + // Speech detected + if (!this.isSpeechActive) { + logger.debug('Speech started'); + this.isSpeechActive = true; + this.emit({ type: 'speechStarted', timestamp: Date.now() }); + } + this.lastSpeechTime = Date.now(); + + } else if (this.isSpeechActive) { + // Was speaking, now silent - check if silence is long enough + if (this.lastSpeechTime) { + const silenceDuration = (Date.now() - this.lastSpeechTime) / 1000; + + if (silenceDuration > this.config.silenceDuration) { + logger.debug(`Speech ended (silence: ${silenceDuration.toFixed(2)}s)`); + this.isSpeechActive = false; + this.emit({ type: 'speechEnded', timestamp: Date.now() }); + + // Process the audio + this.processCurrentAudio(); + } + } + } + } + + /** + * Process current audio through the pipeline: STT -> LLM -> TTS + */ + private async processCurrentAudio(): Promise { + // Stop VAD and recording + if (this.vadInterval) { + clearInterval(this.vadInterval); + this.vadInterval = null; + } + + this.state = 'processing'; + this.emit({ type: 'processing', timestamp: Date.now() }); + + try { + // Stop recording and get audio file + const { path: audioPath } = await this.audioCapture.stopRecording(); + logger.info(`Audio recorded: ${audioPath}`); + + // Transcribe using STT + const sttResult = await STT.transcribeFile(audioPath, { + language: this.config.language, + }); + const transcription = sttResult.text?.trim() || ''; + + if (!transcription) { + logger.info('No speech detected in audio'); + if (this.config.continuousMode && this.isRunning) { + await this.startListening(); + } + return; + } + + // Emit transcription + this.emit({ + type: 'transcribed', + timestamp: Date.now(), + transcription, + }); + logger.info(`Transcribed: "${transcription}"`); + + // Generate response using LLM + const prompt = this.config.systemPrompt + ? `${this.config.systemPrompt}\n\nUser: ${transcription}\nAssistant:` + : transcription; + + const llmResult = await TextGeneration.generate(prompt, { + maxTokens: 500, + temperature: 0.7, + }); + const response = llmResult.text || ''; + + // Emit response + this.emit({ + type: 'responded', + timestamp: Date.now(), + response, + }); + logger.info(`Response: "${response.substring(0, 100)}..."`); + + // Synthesize and play TTS if enabled + let synthesizedAudio: string | undefined; + + if (this.config.autoPlayTTS && response) { + this.state = 'speaking'; + this.emit({ type: 'speaking', timestamp: Date.now() }); + + try { + const ttsResult = await TTS.synthesize(response); + synthesizedAudio = ttsResult.audioData; + + if (synthesizedAudio) { + await this.audioPlayback.play(synthesizedAudio, ttsResult.sampleRate); + } + } catch (ttsError) { + logger.warning(`TTS failed: ${ttsError}`); + } + } + + // Emit complete result + this.emit({ + type: 'turnCompleted', + timestamp: Date.now(), + transcription, + response, + audio: synthesizedAudio, + }); + + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + this.state = 'error'; + logger.error(`Processing failed: ${errorMsg}`); + this.emit({ type: 'error', timestamp: Date.now(), error: errorMsg }); + return; // Don't resume listening on error + } + + // Resume listening if continuous mode + if (this.config.continuousMode && this.isRunning) { + this.state = 'listening'; + await this.startListening(); + } + } +} diff --git a/sdk/runanywhere-react-native/packages/core/src/Features/VoiceSession/index.ts b/sdk/runanywhere-react-native/packages/core/src/Features/VoiceSession/index.ts new file mode 100644 index 000000000..7507e4a7b --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/Features/VoiceSession/index.ts @@ -0,0 +1,20 @@ +/** + * VoiceSession Feature Module + * + * Provides high-level voice session management for voice assistant integration. + */ + +export { AudioCaptureManager } from './AudioCaptureManager'; +export type { AudioDataCallback, AudioLevelCallback, AudioCaptureConfig, AudioCaptureState } from './AudioCaptureManager'; + +export { AudioPlaybackManager } from './AudioPlaybackManager'; +export type { PlaybackState, PlaybackCompletionCallback, PlaybackErrorCallback, PlaybackConfig } from './AudioPlaybackManager'; + +export { VoiceSessionHandle, DEFAULT_VOICE_SESSION_CONFIG } from './VoiceSessionHandle'; +export type { + VoiceSessionConfig, + VoiceSessionEvent, + VoiceSessionEventType, + VoiceSessionEventCallback, + VoiceSessionState, +} from './VoiceSessionHandle'; diff --git a/sdk/runanywhere-react-native/packages/core/src/Features/index.ts b/sdk/runanywhere-react-native/packages/core/src/Features/index.ts new file mode 100644 index 000000000..fc471f31c --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/Features/index.ts @@ -0,0 +1,7 @@ +/** + * Features Module + * + * Re-exports all feature modules. + */ + +export * from './VoiceSession'; diff --git a/sdk/runanywhere-react-native/packages/core/src/Foundation/Constants/SDKConstants.ts b/sdk/runanywhere-react-native/packages/core/src/Foundation/Constants/SDKConstants.ts new file mode 100644 index 000000000..e01437da6 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/Foundation/Constants/SDKConstants.ts @@ -0,0 +1,47 @@ +/** + * SDK-wide constants (metadata only) + * + * Centralized constants to ensure consistency across the SDK. + * Matches pattern: sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Constants/SDKConstants.swift + */ + +import { Platform } from 'react-native'; + +/** + * SDK Constants + * + * All SDK-wide constants should be defined here to avoid hardcoded values + * scattered throughout the codebase. + */ +export const SDKConstants = { + /** + * SDK version - must match the VERSION file in the repository root + * Update this when bumping the SDK version + */ + version: '0.2.0', + + /** + * SDK name + */ + name: 'RunAnywhere SDK', + + /** + * User agent string + */ + get userAgent(): string { + return `${this.name}/${this.version} (React Native)`; + }, + + /** + * Platform identifier (ios/android) + */ + get platform(): string { + return Platform.OS === 'ios' ? 'ios' : 'android'; + }, + + /** + * Minimum log level in production + */ + productionLogLevel: 'error', +} as const; + diff --git a/sdk/runanywhere-react-native/packages/core/src/Foundation/Constants/index.ts b/sdk/runanywhere-react-native/packages/core/src/Foundation/Constants/index.ts new file mode 100644 index 000000000..c1383535b --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/Foundation/Constants/index.ts @@ -0,0 +1,8 @@ +/** + * Constants Module + * + * SDK-wide constants and configuration values. + */ + +export { SDKConstants } from './SDKConstants'; + diff --git a/sdk/runanywhere-react-native/packages/core/src/Foundation/DependencyInjection/ServiceContainer.ts b/sdk/runanywhere-react-native/packages/core/src/Foundation/DependencyInjection/ServiceContainer.ts new file mode 100644 index 000000000..ae398a60e --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/Foundation/DependencyInjection/ServiceContainer.ts @@ -0,0 +1,154 @@ +/** + * ServiceContainer.ts + * + * Service container for managing SDK services. + * Simplified to work with native HTTP transport. + * + * Reference: sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/DI/ + */ + +import { SDKLogger } from '../Logging/Logger/SDKLogger'; +import { + HTTPService, + SDKEnvironment, +} from '../../services/Network'; + +const logger = new SDKLogger('ServiceContainer'); + +/** + * Service container for SDK dependency management + * Manages network configuration and service lifecycle + */ +export class ServiceContainer { + public static shared: ServiceContainer = new ServiceContainer(); + + private _apiKey?: string; + private _baseURL?: string; + private _environment: SDKEnvironment = SDKEnvironment.Development; + private _isInitialized: boolean = false; + + public constructor() {} + + // ========================================================================== + // API Configuration + // ========================================================================== + + /** + * Store API configuration + * + * @param apiKey API key for authentication + * @param environment SDK environment + * @param baseURL Optional base URL for production/staging + */ + public setAPIConfig( + apiKey: string, + environment: SDKEnvironment | string, + baseURL?: string + ): void { + this._apiKey = apiKey; + this._baseURL = baseURL; + + // Convert string to enum if needed + if (typeof environment === 'string') { + this._environment = this.parseEnvironment(environment); + } else { + this._environment = environment; + } + + logger.debug(`API config stored: env=${this.environmentString}`); + } + + // ========================================================================== + // Service Access + // ========================================================================== + + /** + * Get the HTTP service instance + * Note: HTTP is primarily handled by native layer + */ + public get httpService(): HTTPService { + return HTTPService.shared; + } + + // ========================================================================== + // Getters + // ========================================================================== + + public get apiKey(): string | undefined { + return this._apiKey; + } + + public get baseURL(): string | undefined { + return this._baseURL; + } + + public get environment(): SDKEnvironment { + return this._environment; + } + + public get environmentString(): string { + switch (this._environment) { + case SDKEnvironment.Development: + return 'development'; + case SDKEnvironment.Staging: + return 'staging'; + case SDKEnvironment.Production: + return 'production'; + default: + return 'unknown'; + } + } + + public get isInitialized(): boolean { + return this._isInitialized; + } + + // ========================================================================== + // Initialization + // ========================================================================== + + /** + * Mark services as initialized + */ + public markInitialized(): void { + this._isInitialized = true; + logger.debug('ServiceContainer marked as initialized'); + } + + /** + * Reset all services (for testing or SDK destruction) + */ + public reset(): void { + this._apiKey = undefined; + this._baseURL = undefined; + this._environment = SDKEnvironment.Development; + this._isInitialized = false; + + logger.debug('ServiceContainer reset'); + } + + // ========================================================================== + // Private Helpers + // ========================================================================== + + private parseEnvironment(env: string): SDKEnvironment { + const normalized = env.toLowerCase(); + switch (normalized) { + case 'development': + case 'dev': + case '0': + return SDKEnvironment.Development; + case 'staging': + case 'stage': + case '1': + return SDKEnvironment.Staging; + case 'production': + case 'prod': + case '2': + return SDKEnvironment.Production; + default: + logger.warning(`Unknown environment '${env}', defaulting to Development`); + return SDKEnvironment.Development; + } + } +} diff --git a/sdk/runanywhere-react-native/packages/core/src/Foundation/DependencyInjection/ServiceRegistry.ts b/sdk/runanywhere-react-native/packages/core/src/Foundation/DependencyInjection/ServiceRegistry.ts new file mode 100644 index 000000000..011fc3061 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/Foundation/DependencyInjection/ServiceRegistry.ts @@ -0,0 +1,51 @@ +/** + * ServiceRegistry.ts + * + * Simplified service registry. + * Service registration is now handled by native commons. + */ + +import { SDKLogger } from '../Logging/Logger/SDKLogger'; + +const logger = new SDKLogger('ServiceRegistry'); + +/** + * Minimal service registry + * Service registration is handled by native commons + */ +export class ServiceRegistry { + private static _instance: ServiceRegistry | null = null; + private initialized = false; + + static get shared(): ServiceRegistry { + if (!ServiceRegistry._instance) { + ServiceRegistry._instance = new ServiceRegistry(); + } + return ServiceRegistry._instance; + } + + /** + * Initialize (signals native to register services) + */ + async initialize(): Promise { + if (this.initialized) return; + + logger.debug('Service registry initialized - services in native'); + this.initialized = true; + } + + /** + * Check if initialized + */ + isInitialized(): boolean { + return this.initialized; + } + + /** + * Reset (for testing) + */ + reset(): void { + this.initialized = false; + ServiceRegistry._instance = null; + } +} diff --git a/sdk/runanywhere-react-native/packages/core/src/Foundation/DependencyInjection/index.ts b/sdk/runanywhere-react-native/packages/core/src/Foundation/DependencyInjection/index.ts new file mode 100644 index 000000000..a75d0ebfd --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/Foundation/DependencyInjection/index.ts @@ -0,0 +1,6 @@ +/** + * Dependency Injection exports + */ + +export { ServiceContainer } from './ServiceContainer'; +export { ServiceRegistry } from './ServiceRegistry'; diff --git a/sdk/runanywhere-react-native/packages/core/src/Foundation/ErrorTypes/ErrorCategory.ts b/sdk/runanywhere-react-native/packages/core/src/Foundation/ErrorTypes/ErrorCategory.ts new file mode 100644 index 000000000..0433c4626 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/Foundation/ErrorTypes/ErrorCategory.ts @@ -0,0 +1,184 @@ +/** + * ErrorCategory.ts + * + * Logical grouping for error filtering and analytics. + * Matches iOS SDK: Foundation/ErrorTypes/ErrorCategory.swift + */ + +import { ErrorCode } from './ErrorCodes'; + +export enum ErrorCategory { + /** SDK initialization errors */ + Initialization = 'initialization', + /** Model loading and validation errors */ + Model = 'model', + /** LLM/text generation errors */ + Generation = 'generation', + /** Network and API errors */ + Network = 'network', + /** File system and disk space errors */ + Storage = 'storage', + /** Out-of-memory conditions */ + Memory = 'memory', + /** Device compatibility issues */ + Hardware = 'hardware', + /** Input validation failures */ + Validation = 'validation', + /** Auth/API key errors */ + Authentication = 'authentication', + /** Individual component failures */ + Component = 'component', + /** Core framework errors */ + Framework = 'framework', + /** Unclassified errors */ + Unknown = 'unknown', +} + +/** + * All error categories for iteration. + */ +export const allErrorCategories: ErrorCategory[] = Object.values(ErrorCategory); + +/** + * Infer error category from an error code. + */ +export function getCategoryFromCode(code: ErrorCode): ErrorCategory { + // General errors (1000-1099) + if (code >= 1000 && code < 1100) { + if ( + code === ErrorCode.NotInitialized || + code === ErrorCode.AlreadyInitialized + ) { + return ErrorCategory.Initialization; + } + if (code === ErrorCode.InvalidInput) { + return ErrorCategory.Validation; + } + return ErrorCategory.Framework; + } + + // Model errors (1100-1199) + if (code >= 1100 && code < 1200) { + return ErrorCategory.Model; + } + + // Network errors (1200-1299) + if (code >= 1200 && code < 1300) { + return ErrorCategory.Network; + } + + // Storage errors (1300-1399) + if (code >= 1300 && code < 1400) { + return ErrorCategory.Storage; + } + + // Hardware errors (1500-1599) + if (code >= 1500 && code < 1600) { + return ErrorCategory.Hardware; + } + + // Authentication errors (1600-1699) + if (code >= 1600 && code < 1700) { + return ErrorCategory.Authentication; + } + + // Generation errors (1700-1799) + if (code >= 1700 && code < 1800) { + return ErrorCategory.Generation; + } + + return ErrorCategory.Unknown; +} + +/** + * Infer error category from an error object by inspecting its properties. + * Used for automatic categorization of unknown error types. + */ +export function inferCategoryFromError(error: Error): ErrorCategory { + const message = error.message.toLowerCase(); + const name = error.name.toLowerCase(); + + // Check for network-related errors + if ( + name.includes('network') || + name.includes('fetch') || + message.includes('network') || + message.includes('connection') || + message.includes('timeout') || + message.includes('offline') + ) { + return ErrorCategory.Network; + } + + // Check for authentication errors + if ( + message.includes('unauthorized') || + message.includes('authentication') || + message.includes('api key') || + message.includes('token') || + message.includes('401') || + message.includes('403') + ) { + return ErrorCategory.Authentication; + } + + // Check for storage/file errors + if ( + message.includes('storage') || + message.includes('disk') || + message.includes('file') || + message.includes('enoent') || + message.includes('eacces') || + message.includes('permission denied') + ) { + return ErrorCategory.Storage; + } + + // Check for memory errors + if ( + message.includes('memory') || + message.includes('out of memory') || + message.includes('heap') + ) { + return ErrorCategory.Memory; + } + + // Check for model errors + if ( + message.includes('model') || + message.includes('inference') || + message.includes('onnx') || + message.includes('llama') + ) { + return ErrorCategory.Model; + } + + // Check for generation errors + if ( + message.includes('generation') || + message.includes('token') || + message.includes('context length') + ) { + return ErrorCategory.Generation; + } + + // Check for validation errors + if ( + message.includes('invalid') || + message.includes('validation') || + message.includes('required') + ) { + return ErrorCategory.Validation; + } + + // Check for initialization errors + if ( + message.includes('not initialized') || + message.includes('already initialized') || + message.includes('initialization') + ) { + return ErrorCategory.Initialization; + } + + return ErrorCategory.Unknown; +} diff --git a/sdk/runanywhere-react-native/packages/core/src/Foundation/ErrorTypes/ErrorCodes.ts b/sdk/runanywhere-react-native/packages/core/src/Foundation/ErrorTypes/ErrorCodes.ts new file mode 100644 index 000000000..c68ecb790 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/Foundation/ErrorTypes/ErrorCodes.ts @@ -0,0 +1,151 @@ +/** + * ErrorCodes.ts + * + * Machine-readable error codes for SDK errors. + * Matches iOS SDK: Foundation/ErrorTypes/ErrorCodes.swift + * + * Error code ranges: + * - 1000-1099: General SDK state errors + * - 1100-1199: Model loading and validation + * - 1200-1299: Network and API communication + * - 1300-1399: File system and storage + * - 1500-1599: Hardware compatibility + * - 1600-1699: Authentication and authorization + * - 1700-1799: Text generation and token limits + */ + +export enum ErrorCode { + // General errors (1000-1099) + Unknown = 1000, + InvalidInput = 1001, + NotInitialized = 1002, + AlreadyInitialized = 1003, + OperationCancelled = 1004, + + // Model errors (1100-1199) + ModelNotFound = 1100, + ModelLoadFailed = 1101, + ModelValidationFailed = 1102, + ModelFormatUnsupported = 1103, + ModelCorrupted = 1104, + ModelIncompatible = 1105, + + // Network errors (1200-1299) + NetworkUnavailable = 1200, + NetworkTimeout = 1201, + DownloadFailed = 1202, + UploadFailed = 1203, + ApiError = 1204, + + // Storage errors (1300-1399) + InsufficientStorage = 1300, + StorageFull = 1301, + FileNotFound = 1302, + FileAccessDenied = 1303, + FileCorrupted = 1304, + + // Hardware errors (1500-1599) + HardwareUnsupported = 1500, + HardwareUnavailable = 1501, + + // Authentication errors (1600-1699) + AuthenticationFailed = 1600, + AuthenticationExpired = 1601, + AuthorizationDenied = 1602, + ApiKeyInvalid = 1603, + + // Generation errors (1700-1799) + GenerationFailed = 1700, + GenerationTimeout = 1701, + TokenLimitExceeded = 1702, + CostLimitExceeded = 1703, + ContextTooLong = 1704, +} + +/** + * Get a human-readable message for an error code. + */ +export function getErrorCodeMessage(code: ErrorCode): string { + switch (code) { + // General errors + case ErrorCode.Unknown: + return 'An unknown error occurred'; + case ErrorCode.InvalidInput: + return 'Invalid input provided'; + case ErrorCode.NotInitialized: + return 'SDK not initialized'; + case ErrorCode.AlreadyInitialized: + return 'SDK already initialized'; + case ErrorCode.OperationCancelled: + return 'Operation was cancelled'; + + // Model errors + case ErrorCode.ModelNotFound: + return 'Model not found'; + case ErrorCode.ModelLoadFailed: + return 'Failed to load model'; + case ErrorCode.ModelValidationFailed: + return 'Model validation failed'; + case ErrorCode.ModelFormatUnsupported: + return 'Model format not supported'; + case ErrorCode.ModelCorrupted: + return 'Model file is corrupted'; + case ErrorCode.ModelIncompatible: + return 'Model is incompatible with current runtime'; + + // Network errors + case ErrorCode.NetworkUnavailable: + return 'Network is unavailable'; + case ErrorCode.NetworkTimeout: + return 'Network request timed out'; + case ErrorCode.DownloadFailed: + return 'Download failed'; + case ErrorCode.UploadFailed: + return 'Upload failed'; + case ErrorCode.ApiError: + return 'API request failed'; + + // Storage errors + case ErrorCode.InsufficientStorage: + return 'Insufficient storage space'; + case ErrorCode.StorageFull: + return 'Storage is full'; + case ErrorCode.FileNotFound: + return 'File not found'; + case ErrorCode.FileAccessDenied: + return 'File access denied'; + case ErrorCode.FileCorrupted: + return 'File is corrupted'; + + // Hardware errors + case ErrorCode.HardwareUnsupported: + return 'Hardware is not supported'; + case ErrorCode.HardwareUnavailable: + return 'Hardware is unavailable'; + + // Authentication errors + case ErrorCode.AuthenticationFailed: + return 'Authentication failed'; + case ErrorCode.AuthenticationExpired: + return 'Authentication has expired'; + case ErrorCode.AuthorizationDenied: + return 'Authorization denied'; + case ErrorCode.ApiKeyInvalid: + return 'API key is invalid'; + + // Generation errors + case ErrorCode.GenerationFailed: + return 'Text generation failed'; + case ErrorCode.GenerationTimeout: + return 'Text generation timed out'; + case ErrorCode.TokenLimitExceeded: + return 'Token limit exceeded'; + case ErrorCode.CostLimitExceeded: + return 'Cost limit exceeded'; + case ErrorCode.ContextTooLong: + return 'Context is too long'; + + default: + return 'An error occurred'; + } +} diff --git a/sdk/runanywhere-react-native/packages/core/src/Foundation/ErrorTypes/ErrorContext.ts b/sdk/runanywhere-react-native/packages/core/src/Foundation/ErrorTypes/ErrorContext.ts new file mode 100644 index 000000000..1a93e128c --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/Foundation/ErrorTypes/ErrorContext.ts @@ -0,0 +1,201 @@ +/** + * ErrorContext.ts + * + * Captures detailed error context for debugging and logging. + * Matches iOS SDK: Foundation/ErrorTypes/ErrorContext.swift + */ + +/** + * Contextual information captured when an error occurs. + */ +export interface ErrorContext { + /** Stack trace at error point */ + readonly stackTrace: string[]; + /** Source file where error occurred */ + readonly file: string; + /** Line number */ + readonly line: number; + /** Function name */ + readonly function: string; + /** Error capture timestamp (ISO8601) */ + readonly timestamp: string; + /** Thread info ("main" or "background") */ + readonly threadInfo: string; +} + +/** + * Create an error context from the current call site. + * Note: In JavaScript, we can only capture stack traces, not file/line/function directly. + */ +export function createErrorContext(error?: Error): ErrorContext { + const now = new Date().toISOString(); + const stackTrace = parseStackTrace(error?.stack ?? new Error().stack ?? ''); + + // Extract location from first relevant stack frame + const location = extractLocationFromStack(stackTrace); + + return { + stackTrace, + file: location.file, + line: location.line, + function: location.function, + timestamp: now, + threadInfo: 'main', // JS is single-threaded (main thread) + }; +} + +/** + * Parse a stack trace string into an array of frames. + */ +function parseStackTrace(stack: string): string[] { + const lines = stack.split('\n'); + + return lines + .slice(1) // Skip "Error: message" line + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .filter((line) => !isSystemFrame(line)) + .slice(0, 15); // Limit to 15 frames like iOS +} + +/** + * Check if a stack frame is a system/internal frame that should be filtered. + */ +function isSystemFrame(frame: string): boolean { + const systemPatterns = [ + 'node_modules', + 'internal/', + '__webpack', + 'regenerator', + 'asyncToGenerator', + 'createErrorContext', // Filter ourselves out + 'parseStackTrace', + ]; + + return systemPatterns.some((pattern) => frame.includes(pattern)); +} + +/** + * Extract file, line, and function from the first relevant stack frame. + */ +function extractLocationFromStack(stackTrace: string[]): { + file: string; + line: number; + function: string; +} { + if (stackTrace.length === 0) { + return { file: 'unknown', line: 0, function: 'unknown' }; + } + + const firstFrame = stackTrace[0]; + + // Try to parse "at functionName (file:line:column)" format + const atMatch = firstFrame.match(/at\s+(.+?)\s+\((.+?):(\d+):(\d+)\)/); + if (atMatch) { + return { + function: atMatch[1], + file: atMatch[2], + line: parseInt(atMatch[3], 10), + }; + } + + // Try to parse "at file:line:column" format (anonymous function) + const atFileMatch = firstFrame.match(/at\s+(.+?):(\d+):(\d+)/); + if (atFileMatch) { + return { + function: 'anonymous', + file: atFileMatch[1], + line: parseInt(atFileMatch[2], 10), + }; + } + + // Try to parse "functionName@file:line:column" format (Safari/Firefox) + const atSignMatch = firstFrame.match(/(.+?)@(.+?):(\d+):(\d+)/); + if (atSignMatch) { + return { + function: atSignMatch[1] || 'anonymous', + file: atSignMatch[2], + line: parseInt(atSignMatch[3], 10), + }; + } + + return { file: 'unknown', line: 0, function: 'unknown' }; +} + +/** + * Format the stack trace as a readable string. + */ +export function formatStackTrace(context: ErrorContext): string { + if (context.stackTrace.length === 0) { + return 'No stack trace available'; + } + return context.stackTrace.join('\n'); +} + +/** + * Get a formatted location string (file:line in function). + */ +export function formatLocation(context: ErrorContext): string { + return `${context.file}:${context.line} in ${context.function}`; +} + +/** + * Get a complete formatted context string for logging. + */ +export function formatContext(context: ErrorContext): string { + return [ + `Time: ${context.timestamp}`, + `Thread: ${context.threadInfo}`, + `Location: ${formatLocation(context)}`, + `Stack Trace:`, + formatStackTrace(context), + ].join('\n'); +} + +/** + * An error wrapper that includes context information. + */ +export class ContextualError extends Error { + readonly context: ErrorContext; + readonly originalError: Error; + + constructor(error: Error, context?: ErrorContext) { + super(error.message); + this.name = 'ContextualError'; + this.originalError = error; + this.context = context ?? createErrorContext(error); + + // Maintain proper prototype chain + Object.setPrototypeOf(this, ContextualError.prototype); + } +} + +/** + * Wrap an error with context information. + */ +export function withContext(error: Error): ContextualError { + if (error instanceof ContextualError) { + return error; // Already has context + } + return new ContextualError(error); +} + +/** + * Extract error context from an error if available. + */ +export function getErrorContext(error: Error): ErrorContext | undefined { + if (error instanceof ContextualError) { + return error.context; + } + return undefined; +} + +/** + * Get the underlying error value, unwrapping ContextualError if needed. + */ +export function getUnderlyingError(error: Error): Error { + if (error instanceof ContextualError) { + return error.originalError; + } + return error; +} diff --git a/sdk/runanywhere-react-native/packages/core/src/Foundation/ErrorTypes/SDKError.ts b/sdk/runanywhere-react-native/packages/core/src/Foundation/ErrorTypes/SDKError.ts new file mode 100644 index 000000000..f30a8a4dd --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/Foundation/ErrorTypes/SDKError.ts @@ -0,0 +1,507 @@ +/** + * SDKError.ts + * + * Base SDK error class matching iOS SDKErrorProtocol. + * Matches iOS SDK: Foundation/ErrorTypes/SDKErrorProtocol.swift + */ + +import { ErrorCode, getErrorCodeMessage } from './ErrorCodes'; +import { + ErrorCategory, + getCategoryFromCode, + inferCategoryFromError, +} from './ErrorCategory'; +import type { ErrorContext } from './ErrorContext'; +import { + createErrorContext, + formatContext, + formatLocation, +} from './ErrorContext'; +import { SDKLogger, LogLevel } from '../Logging'; + +/** + * Legacy SDK error code enum (string-based). + * @deprecated Prefer using ErrorCode (numeric) for new code. + */ +export enum SDKErrorCode { + NotInitialized = 'notInitialized', + NotImplemented = 'notImplemented', + InvalidAPIKey = 'invalidAPIKey', + ModelNotFound = 'modelNotFound', + LoadingFailed = 'loadingFailed', + ModelLoadFailed = 'modelLoadFailed', + GenerationFailed = 'generationFailed', + GenerationTimeout = 'generationTimeout', + FrameworkNotAvailable = 'frameworkNotAvailable', + FeatureNotAvailable = 'featureNotAvailable', + DownloadFailed = 'downloadFailed', + ValidationFailed = 'validationFailed', + RoutingFailed = 'routingFailed', + DatabaseInitializationFailed = 'databaseInitializationFailed', + UnsupportedModality = 'unsupportedModality', + InvalidResponse = 'invalidResponse', + AuthenticationFailed = 'authenticationFailed', + NetworkError = 'networkError', + InvalidState = 'invalidState', + ComponentNotInitialized = 'componentNotInitialized', + ComponentNotReady = 'componentNotReady', + CleanupFailed = 'cleanupFailed', + ProcessingFailed = 'processingFailed', + Timeout = 'timeout', + ServerError = 'serverError', + StorageError = 'storageError', + InvalidConfiguration = 'invalidConfiguration', +} + +/** + * Map legacy string-based SDKErrorCode to numeric ErrorCode + */ +function mapLegacyCodeToErrorCode(code: SDKErrorCode): ErrorCode { + switch (code) { + case SDKErrorCode.NotInitialized: + return ErrorCode.NotInitialized; + case SDKErrorCode.NotImplemented: + return ErrorCode.Unknown; + case SDKErrorCode.InvalidAPIKey: + return ErrorCode.ApiKeyInvalid; + case SDKErrorCode.ModelNotFound: + return ErrorCode.ModelNotFound; + case SDKErrorCode.LoadingFailed: + case SDKErrorCode.ModelLoadFailed: + return ErrorCode.ModelLoadFailed; + case SDKErrorCode.GenerationFailed: + return ErrorCode.GenerationFailed; + case SDKErrorCode.GenerationTimeout: + return ErrorCode.GenerationTimeout; + case SDKErrorCode.FrameworkNotAvailable: + case SDKErrorCode.FeatureNotAvailable: + return ErrorCode.HardwareUnavailable; + case SDKErrorCode.DownloadFailed: + return ErrorCode.DownloadFailed; + case SDKErrorCode.ValidationFailed: + case SDKErrorCode.InvalidConfiguration: + return ErrorCode.InvalidInput; + case SDKErrorCode.RoutingFailed: + return ErrorCode.Unknown; + case SDKErrorCode.DatabaseInitializationFailed: + return ErrorCode.Unknown; + case SDKErrorCode.UnsupportedModality: + return ErrorCode.InvalidInput; + case SDKErrorCode.InvalidResponse: + return ErrorCode.ApiError; + case SDKErrorCode.AuthenticationFailed: + return ErrorCode.AuthenticationFailed; + case SDKErrorCode.NetworkError: + return ErrorCode.NetworkUnavailable; + case SDKErrorCode.InvalidState: + case SDKErrorCode.ComponentNotInitialized: + case SDKErrorCode.ComponentNotReady: + return ErrorCode.NotInitialized; + case SDKErrorCode.CleanupFailed: + return ErrorCode.Unknown; + case SDKErrorCode.ProcessingFailed: + return ErrorCode.GenerationFailed; + case SDKErrorCode.Timeout: + return ErrorCode.NetworkTimeout; + case SDKErrorCode.ServerError: + return ErrorCode.ApiError; + case SDKErrorCode.StorageError: + return ErrorCode.FileAccessDenied; + default: + return ErrorCode.Unknown; + } +} + +/** + * Base SDK error interface matching iOS SDKErrorProtocol. + */ +export interface SDKErrorProtocol { + /** Machine-readable error code */ + readonly code: ErrorCode; + /** Error category for filtering/analytics */ + readonly category: ErrorCategory; + /** Original error that caused this error */ + readonly underlyingError?: Error; + /** Error context with stack trace and location */ + readonly context: ErrorContext; +} + +/** + * Unified SDK error class. + * + * Supports both legacy string-based codes (SDKErrorCode) and + * new numeric codes (ErrorCode) for backwards compatibility. + * + * @example + * // Legacy usage (still works): + * throw new SDKError(SDKErrorCode.NotInitialized, 'SDK not ready'); + * + * // New recommended usage: + * throw new SDKError(ErrorCode.NotInitialized, 'SDK not ready'); + */ +export class SDKError extends Error implements SDKErrorProtocol { + readonly code: ErrorCode; + readonly legacyCode?: SDKErrorCode; + readonly category: ErrorCategory; + readonly underlyingError?: Error; + readonly context: ErrorContext; + readonly details?: Record; + + constructor( + code: ErrorCode | SDKErrorCode, + message?: string, + options?: { + underlyingError?: Error; + category?: ErrorCategory; + details?: Record; + } + ) { + // Determine if we're using legacy string code or new numeric code + const isLegacyCode = typeof code === 'string'; + const numericCode = isLegacyCode + ? mapLegacyCodeToErrorCode(code as SDKErrorCode) + : (code as ErrorCode); + const errorMessage = message ?? getErrorCodeMessage(numericCode); + + super(errorMessage); + + this.name = 'SDKError'; + this.code = numericCode; + this.legacyCode = isLegacyCode ? (code as SDKErrorCode) : undefined; + this.category = options?.category ?? getCategoryFromCode(numericCode); + this.underlyingError = options?.underlyingError; + this.context = createErrorContext(options?.underlyingError ?? this); + this.details = options?.details; + + // Maintain proper prototype chain + Object.setPrototypeOf(this, SDKError.prototype); + } + + /** + * Convert error to analytics data for event tracking. + */ + toAnalyticsData(): Record { + return { + error_code: this.code, + error_code_name: ErrorCode[this.code], + legacy_code: this.legacyCode, + error_category: this.category, + error_message: this.message, + error_location: formatLocation(this.context), + error_timestamp: this.context.timestamp, + has_underlying_error: this.underlyingError !== undefined, + underlying_error_name: this.underlyingError?.name, + underlying_error_message: this.underlyingError?.message, + ...this.details, + }; + } + + /** + * Log error with full context using SDKLogger. + */ + logError(): void { + const logger = new SDKLogger('SDKError'); + const metadata: Record = { + error_code: this.code, + error_code_name: ErrorCode[this.code], + category: this.category, + context: formatContext(this.context), + }; + + if (this.underlyingError) { + metadata.underlying_error = this.underlyingError.message; + metadata.underlying_stack = this.underlyingError.stack; + } + + logger.log(LogLevel.Error, `${ErrorCode[this.code]}: ${this.message}`, metadata); + } +} + +/** + * Convert any error to an SDKError. + * If already an SDKError, returns as-is. + * Otherwise, wraps with appropriate categorization. + */ +export function asSDKError(error: Error): SDKError { + if (error instanceof SDKError) { + return error; + } + + const category = inferCategoryFromError(error); + const code = mapCategoryToCode(category); + + return new SDKError(code, error.message, { + underlyingError: error, + category, + }); +} + +/** + * Map an error category to a default error code. + */ +function mapCategoryToCode(category: ErrorCategory): ErrorCode { + switch (category) { + case ErrorCategory.Initialization: + return ErrorCode.NotInitialized; + case ErrorCategory.Model: + return ErrorCode.ModelLoadFailed; + case ErrorCategory.Generation: + return ErrorCode.GenerationFailed; + case ErrorCategory.Network: + return ErrorCode.NetworkUnavailable; + case ErrorCategory.Storage: + return ErrorCode.FileNotFound; + case ErrorCategory.Memory: + return ErrorCode.HardwareUnavailable; + case ErrorCategory.Hardware: + return ErrorCode.HardwareUnsupported; + case ErrorCategory.Validation: + return ErrorCode.InvalidInput; + case ErrorCategory.Authentication: + return ErrorCode.AuthenticationFailed; + case ErrorCategory.Component: + return ErrorCode.Unknown; + case ErrorCategory.Framework: + return ErrorCode.Unknown; + case ErrorCategory.Unknown: + default: + return ErrorCode.Unknown; + } +} + +/** + * Type guard to check if an error is an SDKError. + */ +export function isSDKError(error: unknown): error is SDKError { + return error instanceof SDKError; +} + +/** + * Create and throw an SDKError, capturing context at the call site. + * Useful for wrapping errors with automatic context capture. + */ +export function captureAndThrow( + code: ErrorCode, + message?: string, + underlyingError?: Error +): never { + throw new SDKError(code, message, { underlyingError }); +} + +// Convenience factory functions for common error types + +export function notInitializedError(component?: string): SDKError { + const message = component + ? `${component} not initialized` + : 'SDK not initialized'; + return new SDKError(ErrorCode.NotInitialized, message); +} + +export function alreadyInitializedError(component?: string): SDKError { + const message = component + ? `${component} already initialized` + : 'SDK already initialized'; + return new SDKError(ErrorCode.AlreadyInitialized, message); +} + +export function invalidInputError(details?: string): SDKError { + const message = details ? `Invalid input: ${details}` : 'Invalid input'; + return new SDKError(ErrorCode.InvalidInput, message); +} + +export function modelNotFoundError(modelId?: string): SDKError { + const message = modelId ? `Model not found: ${modelId}` : 'Model not found'; + return new SDKError(ErrorCode.ModelNotFound, message); +} + +export function modelLoadError(modelId?: string, cause?: Error): SDKError { + const message = modelId + ? `Failed to load model: ${modelId}` + : 'Failed to load model'; + return new SDKError(ErrorCode.ModelLoadFailed, message, { + underlyingError: cause, + }); +} + +export function networkError(details?: string, cause?: Error): SDKError { + const message = details ?? 'Network error'; + return new SDKError(ErrorCode.NetworkUnavailable, message, { + underlyingError: cause, + }); +} + +export function authenticationError(details?: string): SDKError { + const message = details ?? 'Authentication failed'; + return new SDKError(ErrorCode.AuthenticationFailed, message); +} + +export function generationError(details?: string, cause?: Error): SDKError { + const message = details ?? 'Generation failed'; + return new SDKError(ErrorCode.GenerationFailed, message, { + underlyingError: cause, + }); +} + +export function storageError(details?: string, cause?: Error): SDKError { + const message = details ?? 'Storage error'; + return new SDKError(ErrorCode.FileNotFound, message, { + underlyingError: cause, + }); +} + +// ============================================================================ +// Native Error Wrapping +// ============================================================================ + +/** + * Native error structure from Nitro/React Native bridge. + * Native modules typically return errors as JSON with these fields. + */ +export interface NativeErrorData { + /** Error code (may be string or number) */ + code?: string | number; + /** Error message */ + message?: string; + /** Domain (iOS) or exception type (Android) */ + domain?: string; + /** User info dictionary (iOS) */ + userInfo?: Record; + /** Native stack trace */ + nativeStackTrace?: string; + /** Additional details */ + details?: Record; +} + +/** + * Parse and wrap a native error from JSON-based error data. + * + * Native modules (via Nitro) often return errors as JSON strings or objects. + * This function converts them to proper SDKError instances with full context. + * + * Matches iOS pattern where native errors are wrapped with proper categorization. + * + * @param nativeError - Native error data (string, object, or Error) + * @returns SDKError with proper wrapping + * + * @example + * ```typescript + * try { + * const result = await nativeModule.someMethod(); + * } catch (error) { + * throw fromNativeError(error); + * } + * ``` + */ +export function fromNativeError(nativeError: unknown): SDKError { + // Already an SDKError - return as-is + if (nativeError instanceof SDKError) { + return nativeError; + } + + // Standard Error - wrap it + if (nativeError instanceof Error) { + return asSDKError(nativeError); + } + + // Try to parse as JSON string + if (typeof nativeError === 'string') { + try { + const parsed = JSON.parse(nativeError); + return parseNativeErrorData(parsed); + } catch { + // Not JSON, treat as error message + return new SDKError(ErrorCode.Unknown, nativeError); + } + } + + // Object with error data + if (typeof nativeError === 'object' && nativeError !== null) { + return parseNativeErrorData(nativeError as NativeErrorData); + } + + // Unknown type - create generic error + return new SDKError(ErrorCode.Unknown, String(nativeError)); +} + +/** + * Parse native error data object into SDKError. + */ +function parseNativeErrorData(data: NativeErrorData): SDKError { + // Extract error code + let code = ErrorCode.Unknown; + if (typeof data.code === 'number') { + code = data.code in ErrorCode ? data.code : ErrorCode.Unknown; + } else if (typeof data.code === 'string') { + // Try to map string code to ErrorCode + code = mapNativeCodeString(data.code); + } + + // Build message + const message = data.message ?? 'Native error'; + + // Create underlying error with native stack trace + let underlyingError: Error | undefined; + if (data.nativeStackTrace) { + const nativeErr = new Error(message); + nativeErr.stack = data.nativeStackTrace; + nativeErr.name = data.domain ?? 'NativeError'; + underlyingError = nativeErr; + } + + return new SDKError(code, message, { + underlyingError, + details: { + nativeDomain: data.domain, + nativeUserInfo: data.userInfo, + ...data.details, + }, + }); +} + +/** + * Map native code strings to ErrorCode. + * Native modules may use various string identifiers. + */ +function mapNativeCodeString(codeString: string): ErrorCode { + const normalized = codeString.toLowerCase().replace(/[_-]/g, ''); + + // Common native error patterns + if (normalized.includes('notinitialized') || normalized.includes('notinit')) { + return ErrorCode.NotInitialized; + } + if (normalized.includes('modelload') || normalized.includes('loadfail')) { + return ErrorCode.ModelLoadFailed; + } + if (normalized.includes('modelnotfound')) { + return ErrorCode.ModelNotFound; + } + if (normalized.includes('generation') || normalized.includes('inference')) { + return ErrorCode.GenerationFailed; + } + if (normalized.includes('network') || normalized.includes('connection')) { + return ErrorCode.NetworkUnavailable; + } + if (normalized.includes('auth') || normalized.includes('unauthorized')) { + return ErrorCode.AuthenticationFailed; + } + if (normalized.includes('timeout')) { + return ErrorCode.NetworkTimeout; + } + if (normalized.includes('memory') || normalized.includes('oom')) { + return ErrorCode.HardwareUnavailable; + } + if (normalized.includes('file') || normalized.includes('storage')) { + return ErrorCode.FileNotFound; + } + if (normalized.includes('invalid') || normalized.includes('validation')) { + return ErrorCode.InvalidInput; + } + if (normalized.includes('download')) { + return ErrorCode.DownloadFailed; + } + if (normalized.includes('cancelled') || normalized.includes('canceled')) { + return ErrorCode.OperationCancelled; + } + + return ErrorCode.Unknown; +} diff --git a/sdk/runanywhere-react-native/packages/core/src/Foundation/ErrorTypes/index.ts b/sdk/runanywhere-react-native/packages/core/src/Foundation/ErrorTypes/index.ts new file mode 100644 index 000000000..1f4ad35b5 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/Foundation/ErrorTypes/index.ts @@ -0,0 +1,57 @@ +/** + * Foundation/ErrorTypes + * + * Unified error handling system for the SDK. + * Matches iOS SDK: Foundation/ErrorTypes/ + */ + +// Error codes +export { ErrorCode, getErrorCodeMessage } from './ErrorCodes'; + +// Error categories +export { + ErrorCategory, + allErrorCategories, + getCategoryFromCode, + inferCategoryFromError, +} from './ErrorCategory'; + +// Error context - Type exports +export type { ErrorContext } from './ErrorContext'; + +// Error context - Value exports +export { + createErrorContext, + formatStackTrace, + formatLocation, + formatContext, + ContextualError, + withContext, + getErrorContext, + getUnderlyingError, +} from './ErrorContext'; + +// SDK Error - Type exports +export type { SDKErrorProtocol } from './SDKError'; + +// SDK Error - Value exports +export { + // Legacy enum (backwards compatibility) + SDKErrorCode, + // Class + SDKError, + // Utility functions + asSDKError, + isSDKError, + captureAndThrow, + // Convenience factory functions + notInitializedError, + alreadyInitializedError, + invalidInputError, + modelNotFoundError, + modelLoadError, + networkError, + authenticationError, + generationError, + storageError, +} from './SDKError'; diff --git a/sdk/runanywhere-react-native/packages/core/src/Foundation/Initialization/InitializationPhase.ts b/sdk/runanywhere-react-native/packages/core/src/Foundation/Initialization/InitializationPhase.ts new file mode 100644 index 000000000..3acbcfb0c --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/Foundation/Initialization/InitializationPhase.ts @@ -0,0 +1,85 @@ +/** + * Initialization Phase + * + * Represents the two-phase initialization pattern matching iOS SDK. + * + * Reference: sdk/runanywhere-swift/Sources/RunAnywhere/Public/RunAnywhere.swift + * + * Phase 1 (Core): Synchronous, fast (~1-5ms) + * - Validate configuration + * - Setup logging + * - Store parameters + * - No network calls + * + * Phase 2 (Services): Asynchronous (~100-500ms) + * - Initialize network services + * - Setup authentication + * - Load models + * - Register device + */ + +/** + * The current initialization phase of the SDK + */ +export enum InitializationPhase { + /** + * SDK has not been initialized + */ + NotInitialized = 'notInitialized', + + /** + * Phase 1 complete: Core initialized (sync) + * - Configuration validated + * - Logging setup + * - Parameters stored + * - SDK is usable for basic operations + */ + CoreInitialized = 'coreInitialized', + + /** + * Phase 2 in progress: Services initializing (async) + * - Network services starting + * - Authentication in progress + * - Models loading + */ + ServicesInitializing = 'servicesInitializing', + + /** + * Phase 2 complete: All services ready + * - Network ready + * - Authenticated (if required) + * - Models loaded + * - Device registered + */ + FullyInitialized = 'fullyInitialized', + + /** + * Initialization failed + */ + Failed = 'failed', +} + +/** + * Check if a phase indicates the SDK is usable + */ +export function isSDKUsable(phase: InitializationPhase): boolean { + return ( + phase === InitializationPhase.CoreInitialized || + phase === InitializationPhase.ServicesInitializing || + phase === InitializationPhase.FullyInitialized + ); +} + +/** + * Check if a phase indicates services are ready + */ +export function areServicesReady(phase: InitializationPhase): boolean { + return phase === InitializationPhase.FullyInitialized; +} + +/** + * Check if initialization is in progress + */ +export function isInitializing(phase: InitializationPhase): boolean { + return phase === InitializationPhase.ServicesInitializing; +} diff --git a/sdk/runanywhere-react-native/packages/core/src/Foundation/Initialization/InitializationState.ts b/sdk/runanywhere-react-native/packages/core/src/Foundation/Initialization/InitializationState.ts new file mode 100644 index 000000000..cc32fb514 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/Foundation/Initialization/InitializationState.ts @@ -0,0 +1,168 @@ +/** + * Initialization State + * + * Tracks the complete initialization state of the SDK. + * Matches iOS SDK state tracking pattern. + * + * Reference: sdk/runanywhere-swift/Sources/RunAnywhere/Public/RunAnywhere.swift + */ + +import { InitializationPhase } from './InitializationPhase'; +import type { SDKEnvironment } from '../../types'; + +/** + * Parameters passed to SDK initialization + * Matches iOS SDKInitParams + */ +export interface SDKInitParams { + /** + * API key for backend authentication + */ + apiKey?: string; + + /** + * Base URL for API calls + */ + baseURL?: string; + + /** + * SDK environment (development, staging, production) + */ + environment: SDKEnvironment; +} + +/** + * Complete initialization state of the SDK + */ +export interface InitializationState { + /** + * Current initialization phase + */ + phase: InitializationPhase; + + /** + * Whether Phase 1 (core) initialization is complete + * Equivalent to iOS: isInitialized + */ + isCoreInitialized: boolean; + + /** + * Whether Phase 2 (services) initialization is complete + * Equivalent to iOS: hasCompletedServicesInit + */ + hasCompletedServicesInit: boolean; + + /** + * Current SDK environment + */ + environment: SDKEnvironment | null; + + /** + * Stored initialization parameters + */ + initParams: SDKInitParams | null; + + /** + * Backend type in use (e.g., 'llamacpp', 'onnx') + */ + backendType: string | null; + + /** + * Error if initialization failed + */ + error: Error | null; + + /** + * Timestamp when Phase 1 completed + */ + coreInitTimestamp: number | null; + + /** + * Timestamp when Phase 2 completed + */ + servicesInitTimestamp: number | null; +} + +/** + * Create initial (not initialized) state + */ +export function createInitialState(): InitializationState { + return { + phase: InitializationPhase.NotInitialized, + isCoreInitialized: false, + hasCompletedServicesInit: false, + environment: null, + initParams: null, + backendType: null, + error: null, + coreInitTimestamp: null, + servicesInitTimestamp: null, + }; +} + +/** + * Update state to Phase 1 complete + */ +export function markCoreInitialized( + state: InitializationState, + params: SDKInitParams, + backendType: string | null +): InitializationState { + return { + ...state, + phase: InitializationPhase.CoreInitialized, + isCoreInitialized: true, + environment: params.environment, + initParams: params, + backendType, + coreInitTimestamp: Date.now(), + error: null, + }; +} + +/** + * Update state to Phase 2 in progress + */ +export function markServicesInitializing( + state: InitializationState +): InitializationState { + return { + ...state, + phase: InitializationPhase.ServicesInitializing, + }; +} + +/** + * Update state to Phase 2 complete + */ +export function markServicesInitialized( + state: InitializationState +): InitializationState { + return { + ...state, + phase: InitializationPhase.FullyInitialized, + hasCompletedServicesInit: true, + servicesInitTimestamp: Date.now(), + }; +} + +/** + * Update state to failed + */ +export function markInitializationFailed( + state: InitializationState, + error: Error +): InitializationState { + return { + ...state, + phase: InitializationPhase.Failed, + error, + }; +} + +/** + * Reset state to initial + */ +export function resetState(): InitializationState { + return createInitialState(); +} diff --git a/sdk/runanywhere-react-native/packages/core/src/Foundation/Initialization/index.ts b/sdk/runanywhere-react-native/packages/core/src/Foundation/Initialization/index.ts new file mode 100644 index 000000000..e39c82ef7 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/Foundation/Initialization/index.ts @@ -0,0 +1,26 @@ +/** + * Initialization Module + * + * Types and utilities for SDK two-phase initialization. + * Matches iOS SDK pattern. + */ + +export { + InitializationPhase, + isSDKUsable, + areServicesReady, + isInitializing, +} from './InitializationPhase'; + +// Type exports +export type { SDKInitParams, InitializationState } from './InitializationState'; + +// Value exports +export { + createInitialState, + markCoreInitialized, + markServicesInitializing, + markServicesInitialized, + markInitializationFailed, + resetState, +} from './InitializationState'; diff --git a/sdk/runanywhere-react-native/packages/core/src/Foundation/Logging/Destinations/NativeLogBridge.ts b/sdk/runanywhere-react-native/packages/core/src/Foundation/Logging/Destinations/NativeLogBridge.ts new file mode 100644 index 000000000..b3cbd1bfc --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/Foundation/Logging/Destinations/NativeLogBridge.ts @@ -0,0 +1,147 @@ +/** + * NativeLogBridge.ts + * + * Bridge for receiving native logs (iOS/Android) in TypeScript. + * Native SDKLoggers forward logs through this bridge to the TypeScript LoggingManager. + * + * This enables: + * - Unified log handling across native and TypeScript + * - All native logs flowing through TypeScript destinations (Sentry, etc.) + * - Centralized log configuration + * + * Usage: + * import { NativeLogBridge } from '@runanywhere/core'; + * + * // Initialize bridge (call once at app startup) + * NativeLogBridge.initialize(); + * + * // Native logs will now flow to TypeScript LoggingManager + * // and all registered destinations (Console, Sentry, etc.) + */ + +import { LoggingManager, type LogEntry } from '../Services/LoggingManager'; +import { LogLevel } from '../Models/LogLevel'; + +// ============================================================================ +// Native Log Entry Interface +// ============================================================================ + +/** + * Native log entry structure (from iOS/Android) + * Matches NativeLogEntry in iOS/Android SDKLogger + */ +export interface NativeLogEntryData { + level: number; + category: string; + message: string; + metadata?: Record; + timestamp: string; // ISO8601 string +} + +// ============================================================================ +// Native Log Bridge +// ============================================================================ + +/** + * Bridge for receiving native logs in TypeScript. + * Registers as a forwarder on native SDKLoggers. + */ +export class NativeLogBridge { + private static initialized = false; + private static nativeLogsEnabled = true; + + /** + * Initialize the native log bridge. + * Call this once at app startup to start receiving native logs. + */ + static initialize(): void { + if (this.initialized) { + return; + } + + // The actual native bridge registration happens automatically + // when native code calls the forwarder. This is a marker that + // TypeScript is ready to receive logs. + this.initialized = true; + } + + /** + * Enable or disable native log forwarding to TypeScript + */ + static setEnabled(enabled: boolean): void { + this.nativeLogsEnabled = enabled; + } + + /** + * Check if native log forwarding is enabled + */ + static isEnabled(): boolean { + return this.nativeLogsEnabled; + } + + /** + * Handle a log entry from native code. + * Called by the native bridge when a log is received. + * + * @param entryData - Native log entry data + */ + static handleNativeLog(entryData: NativeLogEntryData): void { + if (!this.nativeLogsEnabled) { + return; + } + + // Convert to TypeScript LogEntry + const entry: LogEntry = { + level: entryData.level as LogLevel, + category: `Native.${entryData.category}`, + message: entryData.message, + metadata: entryData.metadata, + timestamp: new Date(entryData.timestamp), + }; + + // Forward to LoggingManager destinations (except console, since native already logged) + // We use a special method that skips console but sends to other destinations + this.forwardToDestinations(entry); + } + + /** + * Forward a log entry to non-console destinations. + * This avoids duplicate console logging (native already logged to console). + */ + private static forwardToDestinations(entry: LogEntry): void { + const manager = LoggingManager.shared; + const destinations = manager.getDestinations(); + + for (const destination of destinations) { + // Skip console destination (native already logged) + if (destination.identifier === 'console') { + continue; + } + + if (destination.isAvailable) { + try { + destination.write(entry); + } catch { + // Silently ignore destination errors + } + } + } + } +} + +// ============================================================================ +// Global handler for native bridge +// ============================================================================ + +/** + * Global function that native code can call to forward logs. + * This is registered as a callback that native code invokes. + * + * @param entryJson - JSON string of NativeLogEntryData + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(global as any).__runanywhereHandleNativeLog = ( + entryData: NativeLogEntryData +): void => { + NativeLogBridge.handleNativeLog(entryData); +}; diff --git a/sdk/runanywhere-react-native/packages/core/src/Foundation/Logging/Destinations/SentryDestination.ts b/sdk/runanywhere-react-native/packages/core/src/Foundation/Logging/Destinations/SentryDestination.ts new file mode 100644 index 000000000..36d645ec2 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/Foundation/Logging/Destinations/SentryDestination.ts @@ -0,0 +1,209 @@ +/** + * SentryDestination.ts + * + * Log destination that sends logs to Sentry for error tracking. + * + * Matches iOS: sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Logging/SentryDestination.swift + * + * Usage: + * import * as Sentry from '@sentry/react-native'; + * import { SentryDestination, LoggingManager } from '@runanywhere/core'; + * + * // Initialize Sentry first + * Sentry.init({ dsn: 'your-dsn-here' }); + * + * // Add Sentry destination + * const sentryDest = new SentryDestination(Sentry); + * LoggingManager.shared.addDestination(sentryDest); + */ + +import { LogLevel } from '../Models/LogLevel'; +import type { LogDestination, LogEntry } from '../Services/LoggingManager'; + +// ============================================================================ +// Sentry Types (minimal interface to avoid hard dependency) +// ============================================================================ + +/** + * Minimal Sentry interface for logging + * This allows apps to pass their own Sentry instance + */ +export interface SentryInterface { + addBreadcrumb(breadcrumb: { + message?: string; + category?: string; + level?: 'fatal' | 'error' | 'warning' | 'info' | 'debug'; + data?: Record; + timestamp?: number; + }): void; + + captureMessage( + message: string, + level?: 'fatal' | 'error' | 'warning' | 'info' | 'debug' + ): string; + + captureException(exception: Error, hint?: { extra?: Record }): string; + + setExtra(key: string, extra: unknown): void; + setTag(key: string, value: string): void; + + flush(timeout?: number): Promise; +} + +// ============================================================================ +// Sentry Destination +// ============================================================================ + +/** + * Log destination that sends warning+ logs to Sentry. + * Matches iOS: SentryDestination + */ +export class SentryDestination implements LogDestination { + static readonly DESTINATION_ID = 'com.runanywhere.logging.sentry'; + + readonly identifier = SentryDestination.DESTINATION_ID; + readonly name = 'Sentry'; + + private sentry: SentryInterface | null = null; + private initialized = false; + + /** Minimum level to send to Sentry (warning and above) */ + private readonly minSentryLevel: LogLevel = LogLevel.Warning; + + constructor(sentry?: SentryInterface) { + if (sentry) { + this.initialize(sentry); + } + } + + // ========================================================================== + // Initialization + // ========================================================================== + + /** + * Initialize with a Sentry instance + * @param sentry - Sentry SDK instance + */ + initialize(sentry: SentryInterface): void { + this.sentry = sentry; + this.initialized = true; + } + + /** + * Check if Sentry is available + */ + get isAvailable(): boolean { + return this.initialized && this.sentry !== null; + } + + // ========================================================================== + // LogDestination Implementation + // ========================================================================== + + /** + * Write a log entry to Sentry + * Matches iOS: write(_ entry: LogEntry) + */ + write(entry: LogEntry): void { + if (!this.isAvailable || !this.sentry) return; + if (entry.level < this.minSentryLevel) return; + + // Add breadcrumb for context trail + this.addBreadcrumb(entry); + + // For error and fault levels, capture as Sentry event + if (entry.level >= LogLevel.Error) { + this.captureEvent(entry); + } + } + + /** + * Flush pending Sentry events + */ + flush(): void { + if (!this.isAvailable || !this.sentry) return; + // Fire and forget - Sentry.flush returns a promise but we don't wait + void this.sentry.flush(2000); + } + + // ========================================================================== + // Private Helpers + // ========================================================================== + + /** + * Add a breadcrumb for the log entry + */ + private addBreadcrumb(entry: LogEntry): void { + if (!this.sentry) return; + + this.sentry.addBreadcrumb({ + message: entry.message, + category: entry.category, + level: this.convertToSentryLevel(entry.level), + data: entry.metadata as Record, + timestamp: entry.timestamp.getTime() / 1000, // Sentry uses seconds + }); + } + + /** + * Capture an event for error/fault level logs + */ + private captureEvent(entry: LogEntry): void { + if (!this.sentry) return; + + // Set tags + this.sentry.setTag('category', entry.category); + this.sentry.setTag('log_level', this.getLogLevelDescription(entry.level)); + + // Set extras + if (entry.metadata) { + for (const [key, value] of Object.entries(entry.metadata)) { + this.sentry.setExtra(key, value); + } + } + + // Capture the message + this.sentry.captureMessage( + `[${entry.category}] ${entry.message}`, + this.convertToSentryLevel(entry.level) + ); + } + + /** + * Convert LogLevel to Sentry severity level + */ + private convertToSentryLevel( + level: LogLevel + ): 'fatal' | 'error' | 'warning' | 'info' | 'debug' { + switch (level) { + case LogLevel.Debug: + return 'debug'; + case LogLevel.Info: + return 'info'; + case LogLevel.Warning: + return 'warning'; + case LogLevel.Error: + return 'error'; + case LogLevel.Fault: + return 'fatal'; + } + } + + /** + * Get log level description + */ + private getLogLevelDescription(level: LogLevel): string { + switch (level) { + case LogLevel.Debug: + return 'debug'; + case LogLevel.Info: + return 'info'; + case LogLevel.Warning: + return 'warning'; + case LogLevel.Error: + return 'error'; + case LogLevel.Fault: + return 'fault'; + } + } +} diff --git a/sdk/runanywhere-react-native/packages/core/src/Foundation/Logging/Logger/SDKLogger.ts b/sdk/runanywhere-react-native/packages/core/src/Foundation/Logging/Logger/SDKLogger.ts new file mode 100644 index 000000000..1cbc9d3c9 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/Foundation/Logging/Logger/SDKLogger.ts @@ -0,0 +1,232 @@ +/** + * SDKLogger.ts + * + * Centralized logging utility for SDK components. + * Provides structured logging with category-based filtering and metadata support. + * + * Matches iOS: sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Logging/SDKLogger.swift + * + * Usage: + * // Use convenience loggers + * SDKLogger.shared.info('SDK initialized'); + * SDKLogger.download.debug('Starting download', { url: 'https://...' }); + * SDKLogger.llm.error('Generation failed', { modelId: 'llama-3.2' }); + * + * // Create custom logger + * const logger = new SDKLogger('MyComponent'); + * logger.info('Component ready'); + */ + +import { LoggingManager } from '../Services/LoggingManager'; +import { LogLevel } from '../Models/LogLevel'; + +// ============================================================================ +// SDK Logger +// ============================================================================ + +/** + * Simple logger for SDK components with category-based filtering. + * Thread-safe (JS is single-threaded) and easy to use. + * + * Matches iOS: SDKLogger struct + */ +export class SDKLogger { + /** Logger category (e.g., "LLM", "Download", "Models") */ + public readonly category: string; + + /** + * Create a new logger with the specified category. + * @param category - Category name for log filtering + */ + constructor(category: string = 'SDK') { + this.category = category; + } + + // ========================================================================== + // Logging Methods + // ========================================================================== + + /** + * Log a debug message. + * Only logged when minLogLevel is Debug or lower. + * + * @param message - Log message + * @param metadata - Optional metadata key-value pairs + */ + public debug(message: string, metadata?: Record): void { + LoggingManager.shared.log(LogLevel.Debug, this.category, message, metadata); + } + + /** + * Log an info message. + * + * @param message - Log message + * @param metadata - Optional metadata key-value pairs + */ + public info(message: string, metadata?: Record): void { + LoggingManager.shared.log(LogLevel.Info, this.category, message, metadata); + } + + /** + * Log a warning message. + * + * @param message - Log message + * @param metadata - Optional metadata key-value pairs + */ + public warning(message: string, metadata?: Record): void { + LoggingManager.shared.log( + LogLevel.Warning, + this.category, + message, + metadata + ); + } + + /** + * Log an error message. + * + * @param message - Log message + * @param metadata - Optional metadata key-value pairs + */ + public error(message: string, metadata?: Record): void { + LoggingManager.shared.log(LogLevel.Error, this.category, message, metadata); + } + + /** + * Log a fault message (critical/fatal error). + * + * @param message - Log message + * @param metadata - Optional metadata key-value pairs + */ + public fault(message: string, metadata?: Record): void { + LoggingManager.shared.log(LogLevel.Fault, this.category, message, metadata); + } + + /** + * Log a message with a specific level. + * + * @param level - Log level + * @param message - Log message + * @param metadata - Optional metadata key-value pairs + */ + public log( + level: LogLevel, + message: string, + metadata?: Record + ): void { + LoggingManager.shared.log(level, this.category, message, metadata); + } + + // ========================================================================== + // Error Logging with Context + // ========================================================================== + + /** + * Log an Error object with full context. + * Extracts error information and logs with appropriate metadata. + * + * Matches iOS: logError(_ error:, additionalInfo:, file:, line:, function:) + * + * @param error - Error to log + * @param additionalInfo - Optional additional context + */ + public logError(error: Error, additionalInfo?: string): void { + const errorDesc = error.message || 'Unknown error'; + + let message = errorDesc; + if (additionalInfo) { + message += ` | Context: ${additionalInfo}`; + } + + const metadata: Record = { + error_name: error.name, + error_message: error.message, + error_stack: error.stack, + }; + + // If SDKError, include additional fields + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const sdkError = error as any; + if (sdkError.code !== undefined) { + metadata.error_code = sdkError.code; + } + if (sdkError.category !== undefined) { + metadata.error_category = sdkError.category; + } + if (sdkError.underlyingError !== undefined) { + metadata.underlying_error = sdkError.underlyingError.message; + } + + LoggingManager.shared.log(LogLevel.Error, this.category, message, metadata); + } + + // ========================================================================== + // Convenience Loggers (Static) + // ========================================================================== + + /** + * Shared logger for general SDK operations. + * Category: "RunAnywhere" + */ + public static readonly shared = new SDKLogger('RunAnywhere'); + + /** + * Logger for LLM operations. + * Category: "LLM" + */ + public static readonly llm = new SDKLogger('LLM'); + + /** + * Logger for STT (Speech-to-Text) operations. + * Category: "STT" + */ + public static readonly stt = new SDKLogger('STT'); + + /** + * Logger for TTS (Text-to-Speech) operations. + * Category: "TTS" + */ + public static readonly tts = new SDKLogger('TTS'); + + /** + * Logger for download operations. + * Category: "Download" + */ + public static readonly download = new SDKLogger('Download'); + + /** + * Logger for model operations. + * Category: "Models" + */ + public static readonly models = new SDKLogger('Models'); + + /** + * Logger for core SDK operations. + * Category: "Core" + */ + public static readonly core = new SDKLogger('Core'); + + /** + * Logger for VAD (Voice Activity Detection) operations. + * Category: "VAD" + */ + public static readonly vad = new SDKLogger('VAD'); + + /** + * Logger for network operations. + * Category: "Network" + */ + public static readonly network = new SDKLogger('Network'); + + /** + * Logger for events. + * Category: "Events" + */ + public static readonly events = new SDKLogger('Events'); + + /** + * Logger for archive/extraction operations. + * Category: "Archive" + */ + public static readonly archive = new SDKLogger('Archive'); +} diff --git a/sdk/runanywhere-react-native/packages/core/src/Foundation/Logging/Models/LogLevel.ts b/sdk/runanywhere-react-native/packages/core/src/Foundation/Logging/Models/LogLevel.ts new file mode 100644 index 000000000..b276e3e1e --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/Foundation/Logging/Models/LogLevel.ts @@ -0,0 +1,36 @@ +/** + * LogLevel.ts + * + * Log severity levels for the SDK + * + * Reference: sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Logging/Models/LogLevel.swift + */ + +/** + * Log severity levels + */ +export enum LogLevel { + Debug = 0, + Info = 1, + Warning = 2, + Error = 3, + Fault = 4, +} + +/** + * Get log level description + */ +export function getLogLevelDescription(level: LogLevel): string { + switch (level) { + case LogLevel.Debug: + return 'debug'; + case LogLevel.Info: + return 'info'; + case LogLevel.Warning: + return 'warning'; + case LogLevel.Error: + return 'error'; + case LogLevel.Fault: + return 'fault'; + } +} diff --git a/sdk/runanywhere-react-native/packages/core/src/Foundation/Logging/Models/LoggingConfiguration.ts b/sdk/runanywhere-react-native/packages/core/src/Foundation/Logging/Models/LoggingConfiguration.ts new file mode 100644 index 000000000..7dd5a0f0a --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/Foundation/Logging/Models/LoggingConfiguration.ts @@ -0,0 +1,117 @@ +/** + * LoggingConfiguration.ts + * + * Configuration for the logging system with environment presets. + * + * Matches iOS: sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Logging/SDKLogger.swift + * (LoggingConfiguration struct) + */ + +import { LogLevel } from './LogLevel'; + +// ============================================================================ +// SDK Environment +// ============================================================================ + +/** + * SDK environment for configuration + * Matches iOS: SDKEnvironment + */ +export enum SDKEnvironment { + Development = 'development', + Staging = 'staging', + Production = 'production', +} + +// ============================================================================ +// Logging Configuration +// ============================================================================ + +/** + * Configuration for the logging system. + * Matches iOS: LoggingConfiguration struct + */ +export interface LoggingConfiguration { + /** Enable local console logging */ + enableLocalLogging: boolean; + + /** Minimum log level to output */ + minLogLevel: LogLevel; + + /** Include device metadata in logs */ + includeDeviceMetadata: boolean; + + /** Enable Sentry logging */ + enableSentryLogging: boolean; + + /** Sentry DSN (required if enableSentryLogging is true) */ + sentryDSN?: string; + + /** Current environment */ + environment: SDKEnvironment; +} + +// ============================================================================ +// Default Configurations +// ============================================================================ + +/** + * Default configuration for development environment + */ +export const developmentConfig: LoggingConfiguration = { + enableLocalLogging: true, + minLogLevel: LogLevel.Debug, + includeDeviceMetadata: false, + enableSentryLogging: true, + environment: SDKEnvironment.Development, +}; + +/** + * Default configuration for staging environment + */ +export const stagingConfig: LoggingConfiguration = { + enableLocalLogging: true, + minLogLevel: LogLevel.Info, + includeDeviceMetadata: true, + enableSentryLogging: true, + environment: SDKEnvironment.Staging, +}; + +/** + * Default configuration for production environment + */ +export const productionConfig: LoggingConfiguration = { + enableLocalLogging: false, + minLogLevel: LogLevel.Warning, + includeDeviceMetadata: true, + enableSentryLogging: true, + environment: SDKEnvironment.Production, +}; + +/** + * Get default configuration for an environment + */ +export function getConfigurationForEnvironment( + environment: SDKEnvironment +): LoggingConfiguration { + switch (environment) { + case SDKEnvironment.Development: + return { ...developmentConfig }; + case SDKEnvironment.Staging: + return { ...stagingConfig }; + case SDKEnvironment.Production: + return { ...productionConfig }; + } +} + +/** + * Create a custom configuration + */ +export function createLoggingConfiguration( + partial: Partial +): LoggingConfiguration { + const defaultConfig = getConfigurationForEnvironment( + partial.environment ?? SDKEnvironment.Development + ); + return { ...defaultConfig, ...partial }; +} diff --git a/sdk/runanywhere-react-native/packages/core/src/Foundation/Logging/Services/LoggingManager.ts b/sdk/runanywhere-react-native/packages/core/src/Foundation/Logging/Services/LoggingManager.ts new file mode 100644 index 000000000..e5566b8e6 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/Foundation/Logging/Services/LoggingManager.ts @@ -0,0 +1,407 @@ +/** + * LoggingManager.ts + * + * Centralized logging manager with multiple destination support. + * Routes logs to multiple destinations (Console, Sentry, etc.) based on configuration. + * + * Matches iOS: sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Logging/SDKLogger.swift + * (Logging class - central service) + * + * Usage: + * // Configure for environment + * LoggingManager.shared.configure({ environment: SDKEnvironment.Production }); + * + * // Add Sentry destination + * LoggingManager.shared.addDestination(new SentryDestination(Sentry)); + * + * // Subscribe to log events (for custom handling) + * const unsubscribe = LoggingManager.shared.onLog((entry) => { + * // Forward to your analytics, etc. + * }); + */ + +import { LogLevel } from '../Models/LogLevel'; +import { + type LoggingConfiguration, + SDKEnvironment, + getConfigurationForEnvironment, +} from '../Models/LoggingConfiguration'; + +// ============================================================================ +// Log Entry +// ============================================================================ + +/** + * Log entry structure + * Matches iOS: LogEntry + */ +export interface LogEntry { + /** Log level */ + level: LogLevel; + /** Category/subsystem */ + category: string; + /** Log message */ + message: string; + /** Optional metadata */ + metadata?: Record; + /** Timestamp */ + timestamp: Date; +} + +// ============================================================================ +// Log Destination Protocol +// ============================================================================ + +/** + * Log destination interface + * Matches iOS: LogDestination protocol + */ +export interface LogDestination { + /** Unique identifier for this destination */ + identifier: string; + /** Human-readable name */ + name: string; + /** Whether destination is available */ + isAvailable: boolean; + /** Write a log entry */ + write(entry: LogEntry): void; + /** Flush pending writes */ + flush(): void; +} + +// ============================================================================ +// Console Destination +// ============================================================================ + +/** + * Console log destination (default) + */ +export class ConsoleLogDestination implements LogDestination { + readonly identifier = 'console'; + readonly name = 'Console'; + readonly isAvailable = true; + + write(entry: LogEntry): void { + const timestamp = entry.timestamp.toISOString(); + const levelStr = getLogLevelDescription(entry.level); + const logMessage = `[${timestamp}] [${levelStr}] [${entry.category}] ${entry.message}`; + + switch (entry.level) { + case LogLevel.Debug: + // eslint-disable-next-line no-console + console.debug(logMessage, entry.metadata ?? ''); + break; + case LogLevel.Info: + // eslint-disable-next-line no-console + console.info(logMessage, entry.metadata ?? ''); + break; + case LogLevel.Warning: + // eslint-disable-next-line no-console + console.warn(logMessage, entry.metadata ?? ''); + break; + case LogLevel.Error: + case LogLevel.Fault: + // eslint-disable-next-line no-console + console.error(logMessage, entry.metadata ?? ''); + break; + } + } + + flush(): void { + // Console doesn't need flushing + } +} + +// ============================================================================ +// Event Destination (for public exposure) +// ============================================================================ + +/** + * Log event callback type + */ +export type LogEventCallback = (entry: LogEntry) => void; + +/** + * Event-based log destination for public log exposure + * Allows external consumers to subscribe to log events + */ +export class EventLogDestination implements LogDestination { + readonly identifier = 'event'; + readonly name = 'Event Emitter'; + readonly isAvailable = true; + + private callbacks: Set = new Set(); + + /** + * Subscribe to log events + * @returns Unsubscribe function + */ + subscribe(callback: LogEventCallback): () => void { + this.callbacks.add(callback); + return () => { + this.callbacks.delete(callback); + }; + } + + write(entry: LogEntry): void { + for (const callback of this.callbacks) { + try { + callback(entry); + } catch { + // Ignore callback errors + } + } + } + + flush(): void { + // No buffering + } +} + +// ============================================================================ +// Logging Manager +// ============================================================================ + +/** + * Centralized logging manager with multiple destination support. + * Matches iOS: Logging class (central service) + */ +export class LoggingManager { + private static sharedInstance: LoggingManager | null = null; + private destinations: Map = new Map(); + + // Configuration + private config: LoggingConfiguration; + + // Default destinations + private readonly consoleDestination = new ConsoleLogDestination(); + private readonly eventDestination = new EventLogDestination(); + + private constructor() { + // Initialize with default development config + this.config = getConfigurationForEnvironment(SDKEnvironment.Development); + + // Add default console destination + this.addDestination(this.consoleDestination); + // Add event destination for public log exposure + this.addDestination(this.eventDestination); + } + + // ============================================================================ + // Configuration (matches iOS Logging.configure) + // ============================================================================ + + /** + * Get current configuration + */ + public get configuration(): LoggingConfiguration { + return { ...this.config }; + } + + /** + * Configure the logging system. + * Matches iOS: Logging.configure(_ config: LoggingConfiguration) + * + * @param config - Partial configuration to apply + */ + public configure(config: Partial): void { + // If environment is specified, get defaults for that environment + if (config.environment && !config.minLogLevel) { + const envConfig = getConfigurationForEnvironment(config.environment); + this.config = { ...envConfig, ...config }; + } else { + this.config = { ...this.config, ...config }; + } + + // Update console destination based on enableLocalLogging + if (!this.config.enableLocalLogging) { + this.removeDestination(this.consoleDestination.identifier); + } else if (!this.hasDestination(this.consoleDestination.identifier)) { + this.addDestination(this.consoleDestination); + } + } + + /** + * Apply configuration for a specific environment. + * Matches iOS: Logging.applyEnvironmentConfiguration(_ environment:) + * + * @param environment - SDK environment + */ + public applyEnvironmentConfiguration(environment: SDKEnvironment): void { + const envConfig = getConfigurationForEnvironment(environment); + this.configure(envConfig); + } + + /** + * Set local logging enabled. + * Matches iOS: Logging.setLocalLoggingEnabled(_ enabled:) + */ + public setLocalLoggingEnabled(enabled: boolean): void { + this.config.enableLocalLogging = enabled; + if (!enabled) { + this.removeDestination(this.consoleDestination.identifier); + } else if (!this.hasDestination(this.consoleDestination.identifier)) { + this.addDestination(this.consoleDestination); + } + } + + /** + * Set minimum log level. + * Matches iOS: Logging.setMinLogLevel(_ level:) + */ + public setMinLogLevel(level: LogLevel): void { + this.config.minLogLevel = level; + } + + /** + * Set include device metadata. + * Matches iOS: Logging.setIncludeDeviceMetadata(_ include:) + */ + public setIncludeDeviceMetadata(include: boolean): void { + this.config.includeDeviceMetadata = include; + } + + /** + * Get shared instance + */ + public static get shared(): LoggingManager { + if (!LoggingManager.sharedInstance) { + LoggingManager.sharedInstance = new LoggingManager(); + } + return LoggingManager.sharedInstance; + } + + /** + * Get current log level + * @deprecated Use configuration.minLogLevel instead + */ + public getLogLevel(): LogLevel { + return this.config.minLogLevel; + } + + // ============================================================================ + // Destination Management (matches iOS) + // ============================================================================ + + /** + * Add a log destination + * Matches iOS: addDestination(_ destination: LogDestination) + */ + public addDestination(destination: LogDestination): void { + this.destinations.set(destination.identifier, destination); + } + + /** + * Remove a log destination + * Matches iOS: removeDestination(_ identifier: String) + */ + public removeDestination(identifier: string): void { + this.destinations.delete(identifier); + } + + /** + * Get all registered destinations + */ + public getDestinations(): LogDestination[] { + return Array.from(this.destinations.values()); + } + + /** + * Check if a destination is registered + */ + public hasDestination(identifier: string): boolean { + return this.destinations.has(identifier); + } + + // ============================================================================ + // Public Log Event Subscription + // ============================================================================ + + /** + * Subscribe to all log events (for public exposure) + * This allows consumers to receive log events for their own logging infrastructure. + * Matches iOS pattern of exposing log events. + * + * @param callback - Function called for each log entry + * @returns Unsubscribe function + */ + public onLog(callback: LogEventCallback): () => void { + return this.eventDestination.subscribe(callback); + } + + // ============================================================================ + // Logging Operations + // ============================================================================ + + /** + * Log a message. + * Matches iOS: Logging.log(level:category:message:metadata:) + */ + public log( + level: LogLevel, + category: string, + message: string, + metadata?: Record + ): void { + // Filter by minimum log level + if (level < this.config.minLogLevel) { + return; + } + + // Check if logging is enabled at all + if (!this.config.enableLocalLogging && this.destinations.size <= 1) { + // Only event destination, check if there are subscribers + return; + } + + const entry: LogEntry = { + level, + category, + message, + metadata, + timestamp: new Date(), + }; + + // Write to all available destinations + for (const destination of this.destinations.values()) { + if (destination.isAvailable) { + try { + destination.write(entry); + } catch { + // Silently ignore destination errors + } + } + } + } + + /** + * Flush all destinations + */ + public flush(): void { + for (const destination of this.destinations.values()) { + try { + destination.flush(); + } catch { + // Silently ignore flush errors + } + } + } +} + +/** + * Get log level description + */ +function getLogLevelDescription(level: LogLevel): string { + switch (level) { + case LogLevel.Debug: + return 'DEBUG'; + case LogLevel.Info: + return 'INFO'; + case LogLevel.Warning: + return 'WARN'; + case LogLevel.Error: + return 'ERROR'; + case LogLevel.Fault: + return 'FAULT'; + } +} diff --git a/sdk/runanywhere-react-native/packages/core/src/Foundation/Logging/index.ts b/sdk/runanywhere-react-native/packages/core/src/Foundation/Logging/index.ts new file mode 100644 index 000000000..c66785798 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/Foundation/Logging/index.ts @@ -0,0 +1,59 @@ +/** + * Logging Module + * + * Centralized logging infrastructure with multiple destination support. + * Supports environment-based configuration and multiple log destinations (Console, Sentry, etc.) + * + * Matches iOS: sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Logging/ + * + * Usage: + * import { SDKLogger, LoggingManager, SDKEnvironment, SentryDestination } from '@runanywhere/core'; + * + * // Configure for production + * LoggingManager.shared.applyEnvironmentConfiguration(SDKEnvironment.Production); + * + * // Add Sentry destination (optional) + * LoggingManager.shared.addDestination(new SentryDestination(Sentry)); + * + * // Use loggers + * SDKLogger.llm.info('Model loaded', { modelId: 'llama-3.2' }); + */ + +// Logger +export { SDKLogger } from './Logger/SDKLogger'; + +// Log levels +export { LogLevel, getLogLevelDescription } from './Models/LogLevel'; + +// Configuration +export { + type LoggingConfiguration, + SDKEnvironment, + developmentConfig, + stagingConfig, + productionConfig, + getConfigurationForEnvironment, + createLoggingConfiguration, +} from './Models/LoggingConfiguration'; + +// Logging manager with destinations +export { + LoggingManager, + ConsoleLogDestination, + EventLogDestination, + type LogDestination, + type LogEntry, + type LogEventCallback, +} from './Services/LoggingManager'; + +// Sentry destination +export { + SentryDestination, + type SentryInterface, +} from './Destinations/SentryDestination'; + +// Native log bridge (for receiving logs from iOS/Android) +export { + NativeLogBridge, + type NativeLogEntryData, +} from './Destinations/NativeLogBridge'; diff --git a/sdk/runanywhere-react-native/packages/core/src/Foundation/Security/DeviceIdentity.ts b/sdk/runanywhere-react-native/packages/core/src/Foundation/Security/DeviceIdentity.ts new file mode 100644 index 000000000..631b6a624 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/Foundation/Security/DeviceIdentity.ts @@ -0,0 +1,92 @@ +/** + * DeviceIdentity.ts + * + * Simple utility for device identity management (UUID persistence) + * + * Provides persistent UUID that survives app reinstalls by storing in: + * - iOS: Keychain + * - Android: Keystore/EncryptedSharedPreferences + * + * Reference: sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Device/Services/DeviceIdentity.swift + */ + +import { requireNativeModule } from '../../native'; +import { SDKLogger } from '../Logging/Logger/SDKLogger'; + +const logger = new SDKLogger('DeviceIdentity'); + +/** + * Cached UUID (matches Swift pattern - avoid repeated native calls) + */ +let cachedUUID: string | null = null; + +/** + * DeviceIdentity - Persistent device UUID management + * + * Matches Swift's DeviceIdentity enum pattern: + * - Uses keychain/keystore for persistence (survives reinstalls) + * - Caches result after first access + * - Falls back to UUID generation if needed + */ +export const DeviceIdentity = { + /** + * Get a persistent device UUID that survives app reinstalls + * + * Strategy (matches Swift): + * 1. Return cached value if available (fast path) + * 2. Try to get from keychain (native call) + * 3. If not found, native will generate and store + * + * @returns Promise Persistent device UUID + */ + async getPersistentUUID(): Promise { + // Fast path: return cached value + if (cachedUUID) { + return cachedUUID; + } + + try { + const native = requireNativeModule(); + const uuid = await native.getPersistentDeviceUUID(); + + if (uuid && uuid.length > 0) { + cachedUUID = uuid; + logger.debug('Got persistent device UUID from native'); + return uuid; + } + + throw new Error('Native returned empty UUID'); + } catch (error) { + logger.error('Failed to get persistent device UUID', { error }); + throw error; + } + }, + + /** + * Get the cached UUID if available (synchronous) + * + * @returns Cached UUID or null if not yet loaded + */ + getCachedUUID(): string | null { + return cachedUUID; + }, + + /** + * Clear the cached UUID (for testing) + */ + clearCache(): void { + cachedUUID = null; + }, + + /** + * Validate if a device UUID is properly formatted + * + * @param uuid UUID string to validate + * @returns true if valid UUID format + */ + validateUUID(uuid: string): boolean { + // UUID format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx (36 chars) + return uuid.length === 36 && uuid.includes('-'); + }, +}; + diff --git a/sdk/runanywhere-react-native/packages/core/src/Foundation/Security/SecureStorageError.ts b/sdk/runanywhere-react-native/packages/core/src/Foundation/Security/SecureStorageError.ts new file mode 100644 index 000000000..e58938c06 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/Foundation/Security/SecureStorageError.ts @@ -0,0 +1,132 @@ +/** + * SecureStorageError.ts + * + * Errors for secure storage operations + * + * Reference: sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Security/KeychainManager.swift + */ + +/** + * Error codes for secure storage operations + */ +export enum SecureStorageErrorCode { + EncodingError = 'SECURE_STORAGE_ENCODING_ERROR', + DecodingError = 'SECURE_STORAGE_DECODING_ERROR', + ItemNotFound = 'SECURE_STORAGE_ITEM_NOT_FOUND', + StorageError = 'SECURE_STORAGE_STORAGE_ERROR', + RetrievalError = 'SECURE_STORAGE_RETRIEVAL_ERROR', + DeletionError = 'SECURE_STORAGE_DELETION_ERROR', + UnavailableError = 'SECURE_STORAGE_UNAVAILABLE', +} + +/** + * Secure storage error + * + * Matches iOS KeychainError enum. + */ +export class SecureStorageError extends Error { + readonly code: SecureStorageErrorCode; + readonly underlyingError?: Error; + + constructor( + code: SecureStorageErrorCode, + message?: string, + underlyingError?: Error + ) { + const msg = message ?? SecureStorageError.defaultMessage(code); + super(msg); + this.name = 'SecureStorageError'; + this.code = code; + this.underlyingError = underlyingError; + } + + private static defaultMessage(code: SecureStorageErrorCode): string { + switch (code) { + case SecureStorageErrorCode.EncodingError: + return 'Failed to encode data for secure storage'; + case SecureStorageErrorCode.DecodingError: + return 'Failed to decode data from secure storage'; + case SecureStorageErrorCode.ItemNotFound: + return 'Item not found in secure storage'; + case SecureStorageErrorCode.StorageError: + return 'Failed to store item in secure storage'; + case SecureStorageErrorCode.RetrievalError: + return 'Failed to retrieve item from secure storage'; + case SecureStorageErrorCode.DeletionError: + return 'Failed to delete item from secure storage'; + case SecureStorageErrorCode.UnavailableError: + return 'Secure storage is not available'; + } + } + + // Factory methods + static encodingError(underlyingError?: Error): SecureStorageError { + return new SecureStorageError( + SecureStorageErrorCode.EncodingError, + undefined, + underlyingError + ); + } + + static decodingError(underlyingError?: Error): SecureStorageError { + return new SecureStorageError( + SecureStorageErrorCode.DecodingError, + undefined, + underlyingError + ); + } + + static itemNotFound(key: string): SecureStorageError { + return new SecureStorageError( + SecureStorageErrorCode.ItemNotFound, + `Item not found in secure storage: ${key}` + ); + } + + static storageError(underlyingError?: Error): SecureStorageError { + return new SecureStorageError( + SecureStorageErrorCode.StorageError, + undefined, + underlyingError + ); + } + + static retrievalError(underlyingError?: Error): SecureStorageError { + return new SecureStorageError( + SecureStorageErrorCode.RetrievalError, + undefined, + underlyingError + ); + } + + static deletionError(underlyingError?: Error): SecureStorageError { + return new SecureStorageError( + SecureStorageErrorCode.DeletionError, + undefined, + underlyingError + ); + } + + static unavailable(): SecureStorageError { + return new SecureStorageError(SecureStorageErrorCode.UnavailableError); + } +} + +/** + * Type guard to check if an error is a SecureStorageError + */ +export function isSecureStorageError( + error: unknown +): error is SecureStorageError { + return error instanceof SecureStorageError; +} + +/** + * Type guard to check if error is "item not found" specifically + */ +export function isItemNotFoundError(error: unknown): boolean { + return ( + isSecureStorageError(error) && + error.code === SecureStorageErrorCode.ItemNotFound + ); +} diff --git a/sdk/runanywhere-react-native/packages/core/src/Foundation/Security/SecureStorageKeys.ts b/sdk/runanywhere-react-native/packages/core/src/Foundation/Security/SecureStorageKeys.ts new file mode 100644 index 000000000..9ce6c7424 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/Foundation/Security/SecureStorageKeys.ts @@ -0,0 +1,35 @@ +/** + * SecureStorageKeys.ts + * + * Keychain/secure storage key constants + * + * Reference: sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Security/KeychainManager.swift + */ + +/** + * Keys for secure storage (keychain on iOS, keystore on Android) + * + * These match the iOS KeychainKey enum values exactly. + */ +export const SecureStorageKeys = { + // SDK Core + apiKey: 'com.runanywhere.sdk.apiKey', + baseURL: 'com.runanywhere.sdk.baseURL', + environment: 'com.runanywhere.sdk.environment', + + // Device Identity + deviceUUID: 'com.runanywhere.sdk.device.uuid', + + // Authentication Tokens + accessToken: 'com.runanywhere.sdk.accessToken', + refreshToken: 'com.runanywhere.sdk.refreshToken', + tokenExpiresAt: 'com.runanywhere.sdk.tokenExpiresAt', + + // User/Org Identity + deviceId: 'com.runanywhere.sdk.deviceId', + userId: 'com.runanywhere.sdk.userId', + organizationId: 'com.runanywhere.sdk.organizationId', +} as const; + +export type SecureStorageKey = + (typeof SecureStorageKeys)[keyof typeof SecureStorageKeys]; diff --git a/sdk/runanywhere-react-native/packages/core/src/Foundation/Security/SecureStorageService.ts b/sdk/runanywhere-react-native/packages/core/src/Foundation/Security/SecureStorageService.ts new file mode 100644 index 000000000..2664569c8 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/Foundation/Security/SecureStorageService.ts @@ -0,0 +1,449 @@ +/** + * SecureStorageService.ts + * + * Secure storage abstraction for React Native + * + * This service provides secure key-value storage that uses: + * - iOS: Keychain (via native module) + * - Android: Keystore (via native module) + * + * In React Native, actual secure storage is delegated to the native layer. + * This TS layer provides type-safe APIs and caching. + * + * Reference: sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Security/KeychainManager.swift + */ + +import { requireNativeModule } from '../../native'; +import { SDKLogger } from '../Logging/Logger/SDKLogger'; +import { SecureStorageError, isItemNotFoundError } from './SecureStorageError'; +import { SecureStorageKeys, type SecureStorageKey } from './SecureStorageKeys'; +import type { SDKInitParams } from '../Initialization'; +import type { SDKEnvironment } from '../../types'; + +/** + * Extended native module type for secure storage methods + * These methods are optional and may not be available on all platforms + */ +interface SecureStorageNativeModule { + secureStorageIsAvailable?: () => Promise; + secureStorageStore?: (key: string, value: string) => Promise; + secureStorageRetrieve?: (key: string) => Promise; + secureStorageDelete?: (key: string) => Promise; + secureStorageExists?: (key: string) => Promise; +} + +/** + * Secure storage service + * + * Provides secure key-value storage matching iOS KeychainManager. + * All actual storage is delegated to the native layer. + */ +class SecureStorageServiceImpl { + private readonly logger = new SDKLogger('SecureStorageService'); + + // In-memory cache for frequently accessed values + private cache: Map = new Map(); + + // Flag to track if native storage is available + private _isAvailable: boolean | null = null; + + /** + * Check if secure storage is available + * + * Secure storage uses platform-native storage: + * - iOS: Keychain (always available) + * - Android: Keystore/EncryptedSharedPreferences (always available) + */ + async isAvailable(): Promise { + if (this._isAvailable !== null) { + return this._isAvailable; + } + + try { + const native = requireNativeModule(); + // Verify native module is available by checking for secure storage methods + // The methods are implemented in C++ and use platform callbacks + this._isAvailable = + typeof native.secureStorageSet === 'function' && + typeof native.secureStorageGet === 'function'; + return this._isAvailable; + } catch { + this._isAvailable = false; + return false; + } + } + + // ============================================================ + // Generic Storage Methods + // ============================================================ + + /** + * Store a string value securely + * + * Uses native secure storage: + * - iOS: Keychain + * - Android: Keystore/EncryptedSharedPreferences + * + * @param value - String value to store + * @param key - Storage key + */ + async store(value: string, key: SecureStorageKey | string): Promise { + try { + const native = requireNativeModule() as unknown as SecureStorageNativeModule; + + // Use the new native method + const success = await native.secureStorageSet(key, value); + + if (!success) { + throw new Error(`Native secureStorageSet returned false for key: ${key}`); + } + + // Update cache + this.cache.set(key, value); + this.logger.debug(`Stored value for key: ${key}`); + } catch (error) { + this.logger.error(`Failed to store value for key: ${key}`, { error }); + throw SecureStorageError.storageError( + error instanceof Error ? error : undefined + ); + } + } + + /** + * Retrieve a string value from secure storage + * + * Uses native secure storage: + * - iOS: Keychain + * - Android: Keystore/EncryptedSharedPreferences + * + * @param key - Storage key + * @returns Stored value or null if not found + */ + async retrieve(key: SecureStorageKey | string): Promise { + // Check cache first + const cached = this.cache.get(key); + if (cached !== undefined) { + return cached; + } + + try { + const native = requireNativeModule() as unknown as SecureStorageNativeModule; + + // Use the new native method + const value = await native.secureStorageGet(key); + + if (value !== null && value !== undefined) { + this.cache.set(key, value); + } + return value ?? null; + } catch (error) { + // Item not found is not an error - just return null + if (isItemNotFoundError(error)) { + return null; + } + + this.logger.error(`Failed to retrieve value for key: ${key}`, { error }); + throw SecureStorageError.retrievalError( + error instanceof Error ? error : undefined + ); + } + } + + /** + * Delete a value from secure storage + * + * Uses native secure storage: + * - iOS: Keychain + * - Android: Keystore/EncryptedSharedPreferences + * + * @param key - Storage key + */ + async delete(key: SecureStorageKey | string): Promise { + try { + const native = requireNativeModule() as unknown as SecureStorageNativeModule; + + // Use the new native method + await native.secureStorageDelete(key); + + // Remove from cache + this.cache.delete(key); + this.logger.debug(`Deleted value for key: ${key}`); + } catch (error) { + // Ignore "not found" errors on delete + if (!isItemNotFoundError(error)) { + this.logger.error(`Failed to delete value for key: ${key}`, { error }); + throw SecureStorageError.deletionError( + error instanceof Error ? error : undefined + ); + } + } + } + + /** + * Check if a key exists in secure storage + * + * Uses native secure storage: + * - iOS: Keychain + * - Android: Keystore/EncryptedSharedPreferences + * + * @param key - Storage key + * @returns True if key exists + */ + async exists(key: SecureStorageKey | string): Promise { + // Check cache first + if (this.cache.has(key)) { + return true; + } + + try { + const native = requireNativeModule() as unknown as SecureStorageNativeModule; + + // Use the new native method + return await native.secureStorageExists(key); + } catch { + return false; + } + } + + // ============================================================ + // SDK Parameters Storage (matching iOS KeychainManager) + // ============================================================ + + /** + * Store SDK initialization parameters + * + * @param params - SDK init params + */ + async storeSDKParams(params: SDKInitParams): Promise { + const promises: Promise[] = []; + + if (params.apiKey) { + promises.push(this.store(params.apiKey, SecureStorageKeys.apiKey)); + } + if (params.baseURL) { + promises.push(this.store(params.baseURL, SecureStorageKeys.baseURL)); + } + promises.push(this.store(params.environment, SecureStorageKeys.environment)); + + await Promise.all(promises); + this.logger.info('SDK parameters stored securely'); + } + + /** + * Retrieve stored SDK parameters + * + * @returns Stored SDK params or null if not found + */ + async retrieveSDKParams(): Promise { + const [apiKey, baseURL, environment] = await Promise.all([ + this.retrieve(SecureStorageKeys.apiKey), + this.retrieve(SecureStorageKeys.baseURL), + this.retrieve(SecureStorageKeys.environment), + ]); + + if (!apiKey || !baseURL || !environment) { + this.logger.debug('No stored SDK parameters found'); + return null; + } + + this.logger.debug('Retrieved SDK parameters from secure storage'); + return { + apiKey, + baseURL, + environment: environment as SDKEnvironment, + }; + } + + /** + * Clear stored SDK parameters + */ + async clearSDKParams(): Promise { + await Promise.all([ + this.delete(SecureStorageKeys.apiKey), + this.delete(SecureStorageKeys.baseURL), + this.delete(SecureStorageKeys.environment), + ]); + this.logger.info('SDK parameters cleared from secure storage'); + } + + // ============================================================ + // Device Identity Storage + // ============================================================ + + /** + * Store device UUID + * + * @param uuid - Device UUID + */ + async storeDeviceUUID(uuid: string): Promise { + await this.store(uuid, SecureStorageKeys.deviceUUID); + this.logger.debug('Device UUID stored'); + } + + /** + * Retrieve device UUID + * + * @returns Stored device UUID or null + */ + async retrieveDeviceUUID(): Promise { + return this.retrieve(SecureStorageKeys.deviceUUID); + } + + // ============================================================ + // Authentication Token Storage + // ============================================================ + + /** + * Store authentication tokens + * + * @param accessToken - Access token + * @param refreshToken - Refresh token + * @param expiresAt - Token expiration timestamp (Unix ms) + */ + async storeAuthTokens( + accessToken: string, + refreshToken: string, + expiresAt: number + ): Promise { + await Promise.all([ + this.store(accessToken, SecureStorageKeys.accessToken), + this.store(refreshToken, SecureStorageKeys.refreshToken), + this.store(expiresAt.toString(), SecureStorageKeys.tokenExpiresAt), + ]); + this.logger.debug('Auth tokens stored'); + } + + /** + * Retrieve stored auth tokens + * + * @returns Stored tokens or null if not found + */ + async retrieveAuthTokens(): Promise<{ + accessToken: string; + refreshToken: string; + expiresAt: number; + } | null> { + const [accessToken, refreshToken, expiresAtStr] = await Promise.all([ + this.retrieve(SecureStorageKeys.accessToken), + this.retrieve(SecureStorageKeys.refreshToken), + this.retrieve(SecureStorageKeys.tokenExpiresAt), + ]); + + if (!accessToken || !refreshToken) { + return null; + } + + const expiresAt = expiresAtStr ? parseInt(expiresAtStr, 10) : 0; + return { accessToken, refreshToken, expiresAt }; + } + + /** + * Clear stored auth tokens + */ + async clearAuthTokens(): Promise { + await Promise.all([ + this.delete(SecureStorageKeys.accessToken), + this.delete(SecureStorageKeys.refreshToken), + this.delete(SecureStorageKeys.tokenExpiresAt), + ]); + this.logger.debug('Auth tokens cleared'); + } + + // ============================================================ + // Identity Storage + // ============================================================ + + /** + * Store identity information + * + * @param deviceId - Device ID from backend + * @param userId - User ID (optional) + * @param organizationId - Organization ID + */ + async storeIdentity( + deviceId: string, + organizationId: string, + userId?: string + ): Promise { + const promises = [ + this.store(deviceId, SecureStorageKeys.deviceId), + this.store(organizationId, SecureStorageKeys.organizationId), + ]; + + if (userId) { + promises.push(this.store(userId, SecureStorageKeys.userId)); + } + + await Promise.all(promises); + this.logger.debug('Identity info stored'); + } + + /** + * Retrieve stored identity + * + * @returns Stored identity or null + */ + async retrieveIdentity(): Promise<{ + deviceId: string; + userId?: string; + organizationId: string; + } | null> { + const [deviceId, userId, organizationId] = await Promise.all([ + this.retrieve(SecureStorageKeys.deviceId), + this.retrieve(SecureStorageKeys.userId), + this.retrieve(SecureStorageKeys.organizationId), + ]); + + if (!deviceId || !organizationId) { + return null; + } + + return { + deviceId, + userId: userId ?? undefined, + organizationId, + }; + } + + /** + * Clear all stored identity info + */ + async clearIdentity(): Promise { + await Promise.all([ + this.delete(SecureStorageKeys.deviceId), + this.delete(SecureStorageKeys.userId), + this.delete(SecureStorageKeys.organizationId), + ]); + this.logger.debug('Identity info cleared'); + } + + // ============================================================ + // Utility + // ============================================================ + + /** + * Clear all cached values + */ + clearCache(): void { + this.cache.clear(); + } + + /** + * Clear all stored data (for logout/reset) + */ + async clearAll(): Promise { + await Promise.all([ + this.clearSDKParams(), + this.clearAuthTokens(), + this.clearIdentity(), + this.delete(SecureStorageKeys.deviceUUID), + ]); + this.clearCache(); + this.logger.info('All secure storage cleared'); + } +} + +/** + * Singleton instance + */ +export const SecureStorageService = new SecureStorageServiceImpl(); diff --git a/sdk/runanywhere-react-native/packages/core/src/Foundation/Security/index.ts b/sdk/runanywhere-react-native/packages/core/src/Foundation/Security/index.ts new file mode 100644 index 000000000..f20b64474 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/Foundation/Security/index.ts @@ -0,0 +1,17 @@ +/** + * Security Module + * + * Secure storage and credential management + * + * Reference: sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Security/ + */ + +export { SecureStorageService } from './SecureStorageService'; +export { SecureStorageKeys, type SecureStorageKey } from './SecureStorageKeys'; +export { + SecureStorageError, + SecureStorageErrorCode, + isSecureStorageError, + isItemNotFoundError, +} from './SecureStorageError'; +export { DeviceIdentity } from './DeviceIdentity'; diff --git a/sdk/runanywhere-react-native/packages/core/src/Foundation/index.ts b/sdk/runanywhere-react-native/packages/core/src/Foundation/index.ts new file mode 100644 index 000000000..0d9d78817 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/Foundation/index.ts @@ -0,0 +1,26 @@ +/** + * Foundation Module + * + * Core infrastructure for the SDK. + * Matches iOS SDK: Foundation/ + */ + +// Constants +export { SDKConstants } from './Constants'; + +// Error Types +export * from './ErrorTypes'; + +// Initialization +export * from './Initialization'; + +// Security +export * from './Security'; + +// Dependency Injection +export * from './DependencyInjection'; + +// Logging +export { SDKLogger } from './Logging/Logger/SDKLogger'; +export { LogLevel } from './Logging/Models/LogLevel'; +export { LoggingManager } from './Logging/Services/LoggingManager'; diff --git a/sdk/runanywhere-react-native/packages/core/src/Infrastructure/Events/EventPublisher.ts b/sdk/runanywhere-react-native/packages/core/src/Infrastructure/Events/EventPublisher.ts new file mode 100644 index 000000000..6c1bb8762 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/Infrastructure/Events/EventPublisher.ts @@ -0,0 +1,165 @@ +/** + * EventPublisher + * + * Single entry point for all SDK event tracking. + * Routes events to EventBus for public consumption. + * Analytics/telemetry is now handled by native commons. + * + * Reference: sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Events/EventPublisher.swift + */ + +import { EventDestination, type SDKEvent } from './SDKEvent'; +import { EventBus } from '../../Public/Events/EventBus'; +import type { AnySDKEvent } from '../../types/events'; +import { SDKLogger } from '../../Foundation/Logging/Logger/SDKLogger'; + +const logger = new SDKLogger('EventPublisher'); + +// ============================================================================ +// EventPublisher Class +// ============================================================================ + +/** + * Central event publisher that routes SDK events to appropriate destinations. + * + * Design: + * - Single entry point for all event tracking in the SDK + * - Routes to EventBus for public events + * - Analytics/telemetry is handled by native commons + * + * Usage: + * ```typescript + * // Track an event + * EventPublisher.shared.track(myEvent); + * ``` + */ +class EventPublisherImpl { + private isInitialized = false; + + /** + * Initialize the publisher. + * Should be called during SDK startup. + */ + initialize(): void { + this.isInitialized = true; + logger.debug('EventPublisher initialized'); + } + + /** + * Check if the publisher is initialized. + */ + get initialized(): boolean { + return this.isInitialized; + } + + /** + * Track an event synchronously. + * Routes to EventBus based on event.destination. + * + * @param event - The SDK event to track + */ + track(event: SDKEvent): void { + const destination = event.destination; + + // Route to EventBus (public) - unless analyticsOnly + if (destination !== EventDestination.AnalyticsOnly) { + this.publishToEventBus(event); + } + + // Analytics events are now handled by native commons via + // the rac_* API - no JS-side analytics queue needed + } + + /** + * Track an event asynchronously. + * Use this in async contexts. + * + * @param event - The SDK event to track + */ + async trackAsync(event: SDKEvent): Promise { + this.track(event); + } + + /** + * Track multiple events at once. + * + * @param events - Array of SDK events to track + */ + trackBatch(events: SDKEvent[]): void { + for (const event of events) { + this.track(event); + } + } + + /** + * Publish an event to the EventBus for public consumption. + */ + private publishToEventBus(event: SDKEvent): void { + // Map category to native event type for EventBus + const eventTypeMap: Record = { + sdk: 'Initialization', + model: 'Model', + llm: 'Generation', + stt: 'Voice', + tts: 'Voice', + voice: 'Voice', + storage: 'Storage', + device: 'Device', + network: 'Network', + error: 'Initialization', // Errors go through initialization channel + }; + + const eventType = eventTypeMap[event.category] ?? 'Model'; + + // Create a simplified event object for EventBus + // EventBus expects events with { type: string, ...properties } + const busEvent = { + type: event.type, + timestamp: event.timestamp.toISOString(), + ...event.properties, + } as AnySDKEvent; + + EventBus.publish(eventType, busEvent); + } + + /** + * Flush all pending analytics events. + * No-op since analytics is now in native commons. + */ + async flush(): Promise { + // Analytics flushing is handled by native commons + } + + /** + * Reset the publisher state. + * Primarily used for testing. + */ + reset(): void { + this.isInitialized = false; + } +} + +// ============================================================================ +// Singleton Instance +// ============================================================================ + +/** + * Shared EventPublisher singleton. + * + * Usage: + * ```typescript + * import { EventPublisher } from './Infrastructure/Events'; + * + * // Initialize once during SDK startup + * EventPublisher.shared.initialize(); + * + * // Track events anywhere in the SDK + * EventPublisher.shared.track(myEvent); + * ``` + */ +export const EventPublisher = { + /** Singleton instance */ + shared: new EventPublisherImpl(), +}; + +export type { EventPublisherImpl }; diff --git a/sdk/runanywhere-react-native/packages/core/src/Infrastructure/Events/SDKEvent.ts b/sdk/runanywhere-react-native/packages/core/src/Infrastructure/Events/SDKEvent.ts new file mode 100644 index 000000000..192b93d80 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/Infrastructure/Events/SDKEvent.ts @@ -0,0 +1,214 @@ +/** + * SDKEvent Protocol + * + * Unified event interface for the RunAnywhere SDK. + * All events conform to this protocol for consistent routing and handling. + * + * Reference: sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Events/SDKEvent.swift + */ + +// ============================================================================ +// Event Destination +// ============================================================================ + +/** + * Determines where an event should be routed. + * + * - `publicOnly`: Only to EventBus (for app developers to consume) + * - `analyticsOnly`: Only to analytics/telemetry (internal metrics to backend) + * - `all`: Both EventBus and Analytics (default) + */ +export enum EventDestination { + /** Only route to EventBus (public API for app developers) */ + PublicOnly = 'publicOnly', + + /** Only route to Analytics backend (internal telemetry) */ + AnalyticsOnly = 'analyticsOnly', + + /** Route to both EventBus and Analytics (default) */ + All = 'all', +} + +// ============================================================================ +// Event Category +// ============================================================================ + +/** + * Categories for SDK events. + * Used for filtering and routing events to appropriate handlers. + * + * Reference: sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Events/SDKEvent.swift + */ +export enum EventCategory { + /** SDK lifecycle events (init, shutdown) */ + SDK = 'sdk', + + /** Model download, load, unload events */ + Model = 'model', + + /** LLM generation events */ + LLM = 'llm', + + /** Speech-to-text events */ + STT = 'stt', + + /** Text-to-speech events */ + TTS = 'tts', + + /** Voice pipeline events (VAD, voice agent) */ + Voice = 'voice', + + /** Storage and cache events */ + Storage = 'storage', + + /** Device registration and info events */ + Device = 'device', + + /** Network connectivity and request events */ + Network = 'network', + + /** Error events */ + Error = 'error', +} + +// ============================================================================ +// SDKEvent Interface +// ============================================================================ + +/** + * Core SDKEvent interface. + * + * All SDK events must conform to this interface for unified handling. + * The interface provides: + * - Unique identification (id) + * - Type categorization (type, category) + * - Temporal tracking (timestamp) + * - Session grouping (sessionId) + * - Routing control (destination) + * - Serializable properties for analytics + * + * Reference: sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Events/SDKEvent.swift + */ +export interface SDKEvent { + /** + * Unique identifier for the event. + * Default: Auto-generated UUID + */ + readonly id: string; + + /** + * Event type string (e.g., "llm_generation_started", "model_load_completed"). + * Used for event identification and analytics. + */ + readonly type: string; + + /** + * Event category for filtering and routing. + */ + readonly category: EventCategory; + + /** + * When the event occurred. + * Default: Current timestamp + */ + readonly timestamp: Date; + + /** + * Optional session ID for grouping related events. + * Useful for tracking events across a user session or operation. + */ + readonly sessionId?: string; + + /** + * Where this event should be routed. + * Default: EventDestination.All + */ + readonly destination: EventDestination; + + /** + * Key-value properties for analytics serialization. + * All values are strings for universal backend compatibility. + */ + readonly properties: Record; +} + +// ============================================================================ +// Factory Helpers +// ============================================================================ + +/** + * Generate a unique event ID. + */ +function generateEventId(): string { + // Use crypto.randomUUID if available, otherwise fallback + if (typeof crypto !== 'undefined' && crypto.randomUUID) { + return crypto.randomUUID(); + } + /* eslint-disable no-bitwise -- Bitwise ops required for UUID generation per RFC 4122 */ + // Fallback for environments without crypto.randomUUID + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + const v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); + /* eslint-enable no-bitwise */ +} + +/** + * Create an SDKEvent with default values filled in. + * + * @param type - Event type string + * @param category - Event category + * @param properties - Event properties + * @param options - Optional overrides for id, timestamp, sessionId, destination + */ +export function createSDKEvent( + type: string, + category: EventCategory, + properties: Record = {}, + options: { + id?: string; + timestamp?: Date; + sessionId?: string; + destination?: EventDestination; + } = {} +): SDKEvent { + return { + id: options.id ?? generateEventId(), + type, + category, + timestamp: options.timestamp ?? new Date(), + sessionId: options.sessionId, + destination: options.destination ?? EventDestination.All, + properties, + }; +} + +// ============================================================================ +// Type Guards +// ============================================================================ + +/** + * Check if an object conforms to the SDKEvent interface. + */ +export function isSDKEvent(obj: unknown): obj is SDKEvent { + if (typeof obj !== 'object' || obj === null) { + return false; + } + + const event = obj as Record; + + return ( + typeof event.id === 'string' && + typeof event.type === 'string' && + typeof event.category === 'string' && + Object.values(EventCategory).includes(event.category as EventCategory) && + event.timestamp instanceof Date && + typeof event.destination === 'string' && + Object.values(EventDestination).includes( + event.destination as EventDestination + ) && + typeof event.properties === 'object' && + event.properties !== null + ); +} diff --git a/sdk/runanywhere-react-native/packages/core/src/Infrastructure/Events/index.ts b/sdk/runanywhere-react-native/packages/core/src/Infrastructure/Events/index.ts new file mode 100644 index 000000000..9c062baf9 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/Infrastructure/Events/index.ts @@ -0,0 +1,15 @@ +/** + * Events Infrastructure + * + * Event types and publisher for the SDK. + */ + +export { + type SDKEvent, + EventDestination, + EventCategory, + createSDKEvent, + isSDKEvent, +} from './SDKEvent'; + +export { EventPublisher } from './EventPublisher'; diff --git a/sdk/runanywhere-react-native/packages/core/src/Infrastructure/index.ts b/sdk/runanywhere-react-native/packages/core/src/Infrastructure/index.ts new file mode 100644 index 000000000..95afe5f02 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/Infrastructure/index.ts @@ -0,0 +1,9 @@ +/** + * Infrastructure Module + * + * Core infrastructure for the SDK. + * Most logic is in native commons. + */ + +// Events +export * from './Events'; diff --git a/sdk/runanywhere-react-native/packages/core/src/Public/Events/EventBus.ts b/sdk/runanywhere-react-native/packages/core/src/Public/Events/EventBus.ts new file mode 100644 index 000000000..8125fe2f4 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/Public/Events/EventBus.ts @@ -0,0 +1,488 @@ +/** + * RunAnywhere React Native SDK - Event Bus + * + * Central event bus for SDK-wide event distribution. + * Wraps NativeEventEmitter for cross-platform event handling. + * + * Reference: sdk/runanywhere-swift/Sources/RunAnywhere/Public/Events/EventBus.swift + */ + +import { NativeEventEmitter, NativeModules } from 'react-native'; +import { SDKLogger } from '../../Foundation/Logging'; +import type { + AnySDKEvent, + ComponentInitializationEvent, + SDKComponent, + SDKConfigurationEvent, + SDKDeviceEvent, + SDKEventListener, + SDKFrameworkEvent, + SDKGenerationEvent, + SDKInitializationEvent, + SDKModelEvent, + SDKNetworkEvent, + SDKPerformanceEvent, + SDKStorageEvent, + SDKVoiceEvent, + UnsubscribeFunction, +} from '../../types'; + +// Native module reference - accessed lazily in setup() to avoid +// accessing NativeModules before React Native is fully initialized (bridgeless mode) +function getRunAnywhereModule() { + return NativeModules.RunAnywhereModule; +} + +// Event name constants matching native modules +export const NativeEventNames = { + // Initialization events + SDK_INITIALIZATION: 'RunAnywhere_SDKInitialization', + // Configuration events + SDK_CONFIGURATION: 'RunAnywhere_SDKConfiguration', + // Generation events + SDK_GENERATION: 'RunAnywhere_SDKGeneration', + // Model events + SDK_MODEL: 'RunAnywhere_SDKModel', + // Voice events + SDK_VOICE: 'RunAnywhere_SDKVoice', + // Performance events + SDK_PERFORMANCE: 'RunAnywhere_SDKPerformance', + // Network events + SDK_NETWORK: 'RunAnywhere_SDKNetwork', + // Storage events + SDK_STORAGE: 'RunAnywhere_SDKStorage', + // Framework events + SDK_FRAMEWORK: 'RunAnywhere_SDKFramework', + // Device events + SDK_DEVICE: 'RunAnywhere_SDKDevice', + // Component events + SDK_COMPONENT: 'RunAnywhere_SDKComponent', + // All events (catch-all) + SDK_ALL_EVENTS: 'RunAnywhere_AllEvents', +} as const; + +type NativeEventName = (typeof NativeEventNames)[keyof typeof NativeEventNames]; + +/** + * Central event bus for SDK-wide event distribution + * Thread-safe event bus using React Native's NativeEventEmitter + */ +class EventBusImpl { + private emitter: NativeEventEmitter | null = null; + private subscriptions: Map>> = + new Map(); + private nativeSubscriptions: Map void }> = + new Map(); + private isSetup = false; + + /** + * Setup the event bus with the native module + * Called automatically when first subscription is made + */ + private setup(): void { + if (this.isSetup) return; + + // Only create NativeEventEmitter if native module exists + // Access NativeModules lazily to avoid issues with bridgeless mode + const RunAnywhereModule = getRunAnywhereModule(); + if (RunAnywhereModule) { + this.emitter = new NativeEventEmitter(RunAnywhereModule); + + // Subscribe to all native event types + this.setupNativeListener(NativeEventNames.SDK_INITIALIZATION); + this.setupNativeListener(NativeEventNames.SDK_CONFIGURATION); + this.setupNativeListener(NativeEventNames.SDK_GENERATION); + this.setupNativeListener(NativeEventNames.SDK_MODEL); + this.setupNativeListener(NativeEventNames.SDK_VOICE); + this.setupNativeListener(NativeEventNames.SDK_PERFORMANCE); + this.setupNativeListener(NativeEventNames.SDK_NETWORK); + this.setupNativeListener(NativeEventNames.SDK_STORAGE); + this.setupNativeListener(NativeEventNames.SDK_FRAMEWORK); + this.setupNativeListener(NativeEventNames.SDK_DEVICE); + this.setupNativeListener(NativeEventNames.SDK_COMPONENT); + } else { + SDKLogger.events.warning( + 'Native module not available. Events will only work in development mode.' + ); + } + + this.isSetup = true; + } + + /** + * Setup a listener for a specific native event type + */ + private setupNativeListener(eventName: NativeEventName): void { + if (!this.emitter) return; + + const subscription = this.emitter.addListener( + eventName, + (event: AnySDKEvent) => { + this.handleNativeEvent(eventName, event); + } + ); + + this.nativeSubscriptions.set(eventName, subscription); + } + + /** + * Handle an event from native + */ + private handleNativeEvent( + eventName: NativeEventName, + event: AnySDKEvent + ): void { + // Get subscribers for this event type + const typeSubscribers = this.subscriptions.get(eventName); + if (typeSubscribers) { + typeSubscribers.forEach((listener) => { + try { + listener(event); + } catch (error) { + SDKLogger.events.logError(error as Error, 'Error in event listener'); + } + }); + } + + // Also notify "all events" subscribers + const allSubscribers = this.subscriptions.get( + NativeEventNames.SDK_ALL_EVENTS + ); + if (allSubscribers) { + allSubscribers.forEach((listener) => { + try { + listener(event); + } catch (error) { + SDKLogger.events.logError(error as Error, 'Error in event listener'); + } + }); + } + } + + /** + * Subscribe to events of a specific type + */ + private subscribe( + eventName: NativeEventName, + listener: SDKEventListener + ): UnsubscribeFunction { + this.setup(); + + if (!this.subscriptions.has(eventName)) { + this.subscriptions.set(eventName, new Set()); + } + + const subscribers = this.subscriptions.get(eventName); + if (!subscribers) { + // Should never happen since we just set it above + return () => {}; + } + subscribers.add(listener as SDKEventListener); + + // Return unsubscribe function + return () => { + subscribers.delete(listener as SDKEventListener); + }; + } + + // ============================================================================ + // Public Subscription Methods + // ============================================================================ + + /** + * Subscribe to all SDK events + */ + onAllEvents(handler: SDKEventListener): UnsubscribeFunction { + return this.subscribe(NativeEventNames.SDK_ALL_EVENTS, handler); + } + + /** + * Subscribe to initialization events + */ + onInitialization( + handler: SDKEventListener + ): UnsubscribeFunction { + return this.subscribe(NativeEventNames.SDK_INITIALIZATION, handler); + } + + /** + * Subscribe to configuration events + */ + onConfiguration( + handler: SDKEventListener + ): UnsubscribeFunction { + return this.subscribe(NativeEventNames.SDK_CONFIGURATION, handler); + } + + /** + * Subscribe to generation events + */ + onGeneration( + handler: SDKEventListener + ): UnsubscribeFunction { + return this.subscribe(NativeEventNames.SDK_GENERATION, handler); + } + + /** + * Subscribe to model events + */ + onModel(handler: SDKEventListener): UnsubscribeFunction { + return this.subscribe(NativeEventNames.SDK_MODEL, handler); + } + + /** + * Subscribe to voice events + */ + onVoice(handler: SDKEventListener): UnsubscribeFunction { + return this.subscribe(NativeEventNames.SDK_VOICE, handler); + } + + /** + * Subscribe to performance events + */ + onPerformance( + handler: SDKEventListener + ): UnsubscribeFunction { + return this.subscribe(NativeEventNames.SDK_PERFORMANCE, handler); + } + + /** + * Subscribe to network events + */ + onNetwork(handler: SDKEventListener): UnsubscribeFunction { + return this.subscribe(NativeEventNames.SDK_NETWORK, handler); + } + + /** + * Subscribe to storage events + */ + onStorage(handler: SDKEventListener): UnsubscribeFunction { + return this.subscribe(NativeEventNames.SDK_STORAGE, handler); + } + + /** + * Subscribe to framework events + */ + onFramework( + handler: SDKEventListener + ): UnsubscribeFunction { + return this.subscribe(NativeEventNames.SDK_FRAMEWORK, handler); + } + + /** + * Subscribe to device events + */ + onDevice(handler: SDKEventListener): UnsubscribeFunction { + return this.subscribe(NativeEventNames.SDK_DEVICE, handler); + } + + /** + * Subscribe to component initialization events + */ + onComponentInitialization( + handler: SDKEventListener + ): UnsubscribeFunction { + return this.subscribe(NativeEventNames.SDK_COMPONENT, handler); + } + + /** + * Subscribe to specific component events + */ + onComponent( + component: SDKComponent, + handler: SDKEventListener + ): UnsubscribeFunction { + return this.onComponentInitialization((event) => { + // Filter by component if event has component property + if ('component' in event && event.component === component) { + handler(event); + } + }); + } + + // ============================================================================ + // Generic Event Subscription + // ============================================================================ + + /** + * Generic typed event subscription + * Example: events.on('generation', handler) + */ + on( + eventType: + | 'initialization' + | 'configuration' + | 'generation' + | 'model' + | 'voice' + | 'performance' + | 'network' + | 'storage' + | 'framework' + | 'device' + | 'component' + | 'all', + handler: SDKEventListener + ): UnsubscribeFunction { + const eventNameMap: Record = { + initialization: NativeEventNames.SDK_INITIALIZATION, + configuration: NativeEventNames.SDK_CONFIGURATION, + generation: NativeEventNames.SDK_GENERATION, + model: NativeEventNames.SDK_MODEL, + voice: NativeEventNames.SDK_VOICE, + performance: NativeEventNames.SDK_PERFORMANCE, + network: NativeEventNames.SDK_NETWORK, + storage: NativeEventNames.SDK_STORAGE, + framework: NativeEventNames.SDK_FRAMEWORK, + device: NativeEventNames.SDK_DEVICE, + component: NativeEventNames.SDK_COMPONENT, + all: NativeEventNames.SDK_ALL_EVENTS, + }; + + const eventName = eventNameMap[eventType]; + if (!eventName) { + SDKLogger.events.warning(`Unknown event type: ${eventType}`); + return () => {}; + } + + return this.subscribe(eventName, handler); + } + + // ============================================================================ + // Publishing (for internal/testing use) + // ============================================================================ + + /** + * Publish an event locally (for testing or JS-only events) + * Note: In production, events come from native modules + */ + publish(eventType: string, event: AnySDKEvent): void { + const eventName = `RunAnywhere_SDK${eventType}` as NativeEventName; + this.handleNativeEvent(eventName, event); + } + + /** + * Emit a model event + * Helper method for components to emit model-related events + */ + emitModel(event: SDKModelEvent): void { + this.handleNativeEvent(NativeEventNames.SDK_MODEL, event); + } + + /** + * Emit a voice event + * Helper method for components to emit voice-related events + */ + emitVoice(event: SDKVoiceEvent): void { + this.handleNativeEvent(NativeEventNames.SDK_VOICE, event); + } + + /** + * Emit a component initialization event + * Helper method for components to emit initialization-related events + */ + emitComponentInitialization(event: ComponentInitializationEvent): void { + this.handleNativeEvent(NativeEventNames.SDK_COMPONENT, event); + } + + // ============================================================================ + // Cleanup + // ============================================================================ + + /** + * Remove all subscriptions + */ + removeAllListeners(): void { + this.subscriptions.clear(); + + // Remove native subscriptions + this.nativeSubscriptions.forEach((subscription) => { + subscription.remove(); + }); + this.nativeSubscriptions.clear(); + + this.isSetup = false; + } +} + +// Singleton instance +let instance: EventBusImpl | null = null; + +/** + * Get the singleton instance of EventBus + */ +function getInstance(): EventBusImpl { + if (!instance) { + instance = new EventBusImpl(); + } + return instance; +} + +// Create singleton wrapper with all methods exposed at top level +const singletonWrapper = { + getInstance, + // Proxy all methods from getInstance() for backward compatibility + get onAllEvents() { + return getInstance().onAllEvents.bind(getInstance()); + }, + get onInitialization() { + return getInstance().onInitialization.bind(getInstance()); + }, + get onConfiguration() { + return getInstance().onConfiguration.bind(getInstance()); + }, + get onGeneration() { + return getInstance().onGeneration.bind(getInstance()); + }, + get onModel() { + return getInstance().onModel.bind(getInstance()); + }, + get onVoice() { + return getInstance().onVoice.bind(getInstance()); + }, + get onPerformance() { + return getInstance().onPerformance.bind(getInstance()); + }, + get onNetwork() { + return getInstance().onNetwork.bind(getInstance()); + }, + get onStorage() { + return getInstance().onStorage.bind(getInstance()); + }, + get onFramework() { + return getInstance().onFramework.bind(getInstance()); + }, + get onDevice() { + return getInstance().onDevice.bind(getInstance()); + }, + get onComponentInitialization() { + return getInstance().onComponentInitialization.bind(getInstance()); + }, + get onComponent() { + return getInstance().onComponent.bind(getInstance()); + }, + get on() { + return getInstance().on.bind(getInstance()); + }, + get publish() { + return getInstance().publish.bind(getInstance()); + }, + get emitModel() { + return getInstance().emitModel.bind(getInstance()); + }, + get emitVoice() { + return getInstance().emitVoice.bind(getInstance()); + }, + get emitComponentInitialization() { + return getInstance().emitComponentInitialization.bind(getInstance()); + }, + get removeAllListeners() { + return getInstance().removeAllListeners.bind(getInstance()); + }, +}; + +// Export singleton wrapper +export const EventBus = singletonWrapper; + +// Export type for the EventBus +export type { EventBusImpl }; diff --git a/sdk/runanywhere-react-native/packages/core/src/Public/Events/index.ts b/sdk/runanywhere-react-native/packages/core/src/Public/Events/index.ts new file mode 100644 index 000000000..7c6160829 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/Public/Events/index.ts @@ -0,0 +1,8 @@ +/** + * RunAnywhere React Native SDK - Events + * + * Reference: sdk/runanywhere-swift/Sources/RunAnywhere/Public/Events/ + */ + +export { EventBus, NativeEventNames } from './EventBus'; +export type { EventBusImpl } from './EventBus'; diff --git a/sdk/runanywhere-react-native/packages/core/src/Public/Extensions/RunAnywhere+Audio.ts b/sdk/runanywhere-react-native/packages/core/src/Public/Extensions/RunAnywhere+Audio.ts new file mode 100644 index 000000000..944d82a45 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/Public/Extensions/RunAnywhere+Audio.ts @@ -0,0 +1,688 @@ +/** + * RunAnywhere+Audio.ts + * + * Audio recording and playback utilities for the SDK. + * Provides a simple static API for common audio operations. + * + * Platform support: + * - iOS: NativeAudioModule (AVFoundation) + * - Android: react-native-live-audio-stream + react-native-sound + */ + +import { Platform, PermissionsAndroid, NativeModules } from 'react-native'; +import { SDKLogger } from '../../Foundation/Logging/Logger/SDKLogger'; + +const logger = new SDKLogger('Audio'); + +// Native iOS Audio Module +const NativeAudioModule = Platform.OS === 'ios' ? NativeModules.NativeAudioModule : null; + +// Lazy load Android dependencies +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let LiveAudioStream: any = null; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let Sound: any = null; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let RNFS: any = null; + +function getLiveAudioStream() { + if (Platform.OS !== 'android') return null; + if (!LiveAudioStream) { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + LiveAudioStream = require('react-native-live-audio-stream').default; + } catch { + logger.warning('react-native-live-audio-stream not available'); + return null; + } + } + return LiveAudioStream; +} + +function getSound() { + if (Platform.OS === 'ios') return null; + if (!Sound) { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + Sound = require('react-native-sound').default; + Sound.setCategory('Playback'); + } catch { + logger.warning('react-native-sound not available'); + return null; + } + } + return Sound; +} + +function getRNFS() { + if (!RNFS) { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + RNFS = require('react-native-fs'); + } catch { + logger.warning('react-native-fs not available'); + return null; + } + } + return RNFS; +} + +// ============================================================================ +// Constants +// ============================================================================ + +/** Default sample rate for speech recognition (Whisper models) */ +export const AUDIO_SAMPLE_RATE = 16000; + +/** TTS default sample rate */ +export const TTS_SAMPLE_RATE = 22050; + +// ============================================================================ +// Internal State +// ============================================================================ + +let isRecording = false; +let recordingStartTime = 0; +let currentRecordPath: string | null = null; +let audioChunks: string[] = []; +let progressCallback: ((currentPositionMs: number, metering?: number) => void) | null = null; +let audioLevelInterval: ReturnType | null = null; + +let isPlaying = false; +let playbackProgressInterval: ReturnType | null = null; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let currentSound: any = null; + +// ============================================================================ +// Types +// ============================================================================ + +export interface RecordingCallbacks { + /** Progress callback with current position (ms) and audio level (dB: -60 to 0) */ + onProgress?: (currentPositionMs: number, metering?: number) => void; +} + +export interface PlaybackCallbacks { + /** Progress callback with current position and total duration */ + onProgress?: (currentPositionMs: number, durationMs: number) => void; + /** Called when playback completes */ + onComplete?: () => void; +} + +export interface RecordingResult { + /** Path to the recorded audio file */ + uri: string; + /** Duration of the recording in milliseconds */ + durationMs: number; +} + +// ============================================================================ +// Permission +// ============================================================================ + +/** + * Request microphone permission + * @returns true if permission granted + */ +export async function requestAudioPermission(): Promise { + if (Platform.OS === 'android') { + try { + const grants = await PermissionsAndroid.requestMultiple([ + PermissionsAndroid.PERMISSIONS.RECORD_AUDIO, + ]); + const granted = grants[PermissionsAndroid.PERMISSIONS.RECORD_AUDIO] === PermissionsAndroid.RESULTS.GRANTED; + logger.info(`Android microphone permission: ${granted ? 'granted' : 'denied'}`); + return granted; + } catch (err) { + logger.error(`Permission request error: ${err}`); + return false; + } + } + + // iOS: Permissions are requested automatically when starting recording + return true; +} + +// ============================================================================ +// Recording +// ============================================================================ + +/** + * Start recording audio + * @param callbacks Optional callbacks for progress updates + * @returns Promise with the path where audio will be saved + */ +export async function startRecording(callbacks?: RecordingCallbacks): Promise { + if (isRecording) { + throw new Error('Recording already in progress'); + } + + const fs = getRNFS(); + if (!fs) { + throw new Error('react-native-fs not available'); + } + + // iOS: Use native audio module + if (Platform.OS === 'ios') { + if (!NativeAudioModule) { + throw new Error('NativeAudioModule not available on iOS'); + } + + const result = await NativeAudioModule.startRecording(); + logger.info(`iOS recording started: ${result.path}`); + + isRecording = true; + recordingStartTime = Date.now(); + currentRecordPath = result.path; + progressCallback = callbacks?.onProgress ?? null; + + // Poll for audio levels on iOS + if (progressCallback) { + audioLevelInterval = setInterval(async () => { + if (isRecording && NativeAudioModule) { + try { + const levelResult = await NativeAudioModule.getAudioLevel(); + const elapsed = Date.now() - recordingStartTime; + // Convert linear level (0-1) to dB (-60 to 0) + const db = levelResult.level > 0 ? 20 * Math.log10(levelResult.level) : -60; + progressCallback?.(elapsed, db); + } catch { + // Ignore errors + } + } + }, 100); + } + + return result.path; + } + + // Android: Use LiveAudioStream for raw PCM + const audioStream = getLiveAudioStream(); + if (!audioStream) { + throw new Error('LiveAudioStream not available on Android'); + } + + const fileName = `recording_${Date.now()}.wav`; + const filePath = `${fs.CachesDirectoryPath}/${fileName}`; + currentRecordPath = filePath; + audioChunks = []; + progressCallback = callbacks?.onProgress ?? null; + + audioStream.init({ + sampleRate: AUDIO_SAMPLE_RATE, + channels: 1, + bitsPerSample: 16, + audioSource: 6, // VOICE_RECOGNITION + bufferSize: 4096, + }); + + audioStream.on('data', (data: string) => { + audioChunks.push(data); + + if (progressCallback) { + const elapsed = Date.now() - recordingStartTime; + const audioLevel = calculateAudioLevelFromBase64(data); + progressCallback(elapsed, audioLevel); + } + }); + + audioStream.start(); + isRecording = true; + recordingStartTime = Date.now(); + + logger.info(`Android recording started: ${filePath}`); + return filePath; +} + +/** + * Stop recording and return the audio file path + * @returns Recording result with path and duration + */ +export async function stopRecording(): Promise { + if (!isRecording) { + throw new Error('No recording in progress'); + } + + // Clear audio level polling + if (audioLevelInterval) { + clearInterval(audioLevelInterval); + audioLevelInterval = null; + } + + const durationMs = Date.now() - recordingStartTime; + + // iOS: Use native audio module + if (Platform.OS === 'ios' && NativeAudioModule) { + const result = await NativeAudioModule.stopRecording(); + isRecording = false; + progressCallback = null; + logger.info(`iOS recording stopped: ${result.path}`); + return { uri: result.path, durationMs }; + } + + // Android: Stop LiveAudioStream and create WAV file + const audioStream = getLiveAudioStream(); + if (audioStream) { + audioStream.stop(); + } + isRecording = false; + + const uri = currentRecordPath || ''; + logger.info(`Android recording stopped, processing ${audioChunks.length} chunks`); + + // Create WAV file from chunks + await createWavFileFromChunks(uri, audioChunks); + + // Clean up + audioChunks = []; + currentRecordPath = null; + progressCallback = null; + + return { uri, durationMs }; +} + +/** + * Cancel recording without saving + */ +export async function cancelRecording(): Promise { + if (audioLevelInterval) { + clearInterval(audioLevelInterval); + audioLevelInterval = null; + } + + if (!isRecording) return; + + if (Platform.OS === 'ios' && NativeAudioModule) { + try { + await NativeAudioModule.cancelRecording(); + } catch { + // Ignore + } + } else { + const audioStream = getLiveAudioStream(); + if (audioStream) { + audioStream.stop(); + } + + // Delete partial file + if (currentRecordPath) { + const fs = getRNFS(); + try { + await fs?.unlink(currentRecordPath); + } catch { + // File may not exist + } + } + } + + isRecording = false; + audioChunks = []; + currentRecordPath = null; + progressCallback = null; +} + +// ============================================================================ +// Playback +// ============================================================================ + +/** + * Play audio from a file path + * @param uri Path to the audio file + * @param callbacks Optional callbacks for progress and completion + */ +export async function playAudio(uri: string, callbacks?: PlaybackCallbacks): Promise { + logger.info(`Playing audio: ${uri}`); + + // iOS: Use native audio module + if (Platform.OS === 'ios' && NativeAudioModule) { + const result = await NativeAudioModule.playAudio(uri); + isPlaying = true; + const durationMs = (result.duration || 0) * 1000; + + if (callbacks?.onProgress || callbacks?.onComplete) { + playbackProgressInterval = setInterval(async () => { + if (!isPlaying) { + if (playbackProgressInterval) { + clearInterval(playbackProgressInterval); + playbackProgressInterval = null; + } + return; + } + + try { + const status = await NativeAudioModule.getPlaybackStatus(); + const currentTimeMs = (status.currentTime || 0) * 1000; + const totalDurationMs = (status.duration || 0) * 1000; + + callbacks?.onProgress?.(currentTimeMs, totalDurationMs); + + if (!status.isPlaying && currentTimeMs >= totalDurationMs - 100) { + isPlaying = false; + if (playbackProgressInterval) { + clearInterval(playbackProgressInterval); + playbackProgressInterval = null; + } + callbacks?.onComplete?.(); + } + } catch { + // Ignore + } + }, 100); + } + + logger.info(`iOS playback started, duration: ${durationMs}ms`); + return; + } + + // Android: Use react-native-sound + const SoundClass = getSound(); + if (!SoundClass) { + throw new Error('react-native-sound not available'); + } + + return new Promise((resolve, reject) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + currentSound = new SoundClass(uri, '', (error: any) => { + if (error) { + logger.error(`Failed to load sound: ${error}`); + reject(error); + return; + } + + const durationMs = currentSound.getDuration() * 1000; + isPlaying = true; + + if (callbacks?.onProgress) { + playbackProgressInterval = setInterval(() => { + currentSound?.getCurrentTime((seconds: number) => { + callbacks?.onProgress?.(seconds * 1000, durationMs); + }); + }, 100); + } + + currentSound.play((success: boolean) => { + isPlaying = false; + if (playbackProgressInterval) { + clearInterval(playbackProgressInterval); + playbackProgressInterval = null; + } + + if (currentSound) { + currentSound.release(); + currentSound = null; + } + + if (success) { + callbacks?.onComplete?.(); + resolve(); + } else { + reject(new Error('Playback failed')); + } + }); + }); + }); +} + +/** + * Stop audio playback + */ +export async function stopPlayback(): Promise { + if (playbackProgressInterval) { + clearInterval(playbackProgressInterval); + playbackProgressInterval = null; + } + + isPlaying = false; + + if (Platform.OS === 'ios' && NativeAudioModule) { + try { + await NativeAudioModule.stopPlayback(); + } catch { + // Ignore + } + } else if (currentSound) { + currentSound.stop(); + currentSound.release(); + currentSound = null; + } +} + +/** + * Pause audio playback + */ +export async function pausePlayback(): Promise { + if (Platform.OS === 'ios' && NativeAudioModule) { + try { + await NativeAudioModule.pausePlayback(); + } catch { + // Ignore + } + } else if (currentSound) { + currentSound.pause(); + } +} + +/** + * Resume audio playback + */ +export async function resumePlayback(): Promise { + if (Platform.OS === 'ios' && NativeAudioModule) { + try { + await NativeAudioModule.resumePlayback(); + } catch { + // Ignore + } + } else if (currentSound) { + currentSound.play(); + } +} + +// ============================================================================ +// TTS Audio Utilities +// ============================================================================ + +/** + * Convert base64 PCM float32 audio to WAV file + * Used for TTS output which returns base64-encoded float32 PCM samples + * + * @param audioBase64 Base64 encoded float32 PCM audio data + * @param sampleRate Sample rate of the audio (default: 22050) + * @returns Path to the created WAV file + */ +export async function createWavFromPCMFloat32( + audioBase64: string, + sampleRate: number = TTS_SAMPLE_RATE +): Promise { + const fs = getRNFS(); + if (!fs) { + throw new Error('react-native-fs not available'); + } + + // Decode base64 to get raw bytes + const binaryString = atob(audioBase64); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + + // Convert float32 samples to int16 (WAV compatible) + const floatView = new Float32Array(bytes.buffer); + const numSamples = floatView.length; + const int16Samples = new Int16Array(numSamples); + + for (let i = 0; i < numSamples; i++) { + const floatSample = floatView[i] ?? 0; + const sample = Math.max(-1, Math.min(1, floatSample)); + int16Samples[i] = sample < 0 ? sample * 0x8000 : sample * 0x7fff; + } + + // Create WAV file + const wavDataSize = int16Samples.length * 2; + const wavBuffer = new ArrayBuffer(44 + wavDataSize); + const wavView = new DataView(wavBuffer); + + // RIFF header + writeString(wavView, 0, 'RIFF'); + wavView.setUint32(4, 36 + wavDataSize, true); + writeString(wavView, 8, 'WAVE'); + + // fmt chunk + writeString(wavView, 12, 'fmt '); + wavView.setUint32(16, 16, true); + wavView.setUint16(20, 1, true); // PCM + wavView.setUint16(22, 1, true); // mono + wavView.setUint32(24, sampleRate, true); + wavView.setUint32(28, sampleRate * 2, true); + wavView.setUint16(32, 2, true); + wavView.setUint16(34, 16, true); + + // data chunk + writeString(wavView, 36, 'data'); + wavView.setUint32(40, wavDataSize, true); + + // Copy audio data + const wavBytes = new Uint8Array(wavBuffer); + const int16Bytes = new Uint8Array(int16Samples.buffer); + for (let i = 0; i < int16Bytes.length; i++) { + wavBytes[44 + i] = int16Bytes[i]!; + } + + // Write to file + const fileName = `tts_${Date.now()}.wav`; + const filePath = `${fs.CachesDirectoryPath}/${fileName}`; + const wavBase64 = arrayBufferToBase64(wavBuffer); + await fs.writeFile(filePath, wavBase64, 'base64'); + + logger.info(`WAV file created: ${filePath}`); + return filePath; +} + +// ============================================================================ +// Cleanup +// ============================================================================ + +/** + * Cleanup all audio resources + */ +export async function cleanup(): Promise { + if (isRecording) { + await cancelRecording(); + } + await stopPlayback(); +} + +// ============================================================================ +// Utilities +// ============================================================================ + +/** + * Format milliseconds to MM:SS string + */ +export function formatDuration(ms: number): string { + const totalSeconds = Math.floor(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}:${seconds.toString().padStart(2, '0')}`; +} + +// ============================================================================ +// Private Helpers +// ============================================================================ + +function calculateAudioLevelFromBase64(base64Data: string): number { + try { + const bytes = Uint8Array.from(atob(base64Data), c => c.charCodeAt(0)); + const samples = new Int16Array(bytes.buffer); + + if (samples.length === 0) return -60; + + let sumSquares = 0; + for (let i = 0; i < samples.length; i++) { + const normalized = samples[i]! / 32768.0; + sumSquares += normalized * normalized; + } + const rms = Math.sqrt(sumSquares / samples.length); + const db = rms > 0 ? 20 * Math.log10(rms) : -60; + return Math.max(-60, Math.min(0, db)); + } catch { + return -60; + } +} + +async function createWavFileFromChunks(filePath: string, chunks: string[]): Promise { + const fs = getRNFS(); + if (!fs) { + throw new Error('react-native-fs not available'); + } + + // Combine all audio chunks into PCM data + let totalLength = 0; + const decodedChunks: Uint8Array[] = []; + + for (const chunk of chunks) { + const decoded = Uint8Array.from(atob(chunk), c => c.charCodeAt(0)); + decodedChunks.push(decoded); + totalLength += decoded.length; + } + + // Create combined PCM buffer + const pcmData = new Uint8Array(totalLength); + let offset = 0; + for (const chunk of decodedChunks) { + pcmData.set(chunk, offset); + offset += chunk.length; + } + + // Create WAV header + const wavHeader = createWavHeader(totalLength); + const headerBytes = new Uint8Array(wavHeader); + + // Combine header and PCM data + const wavData = new Uint8Array(headerBytes.length + pcmData.length); + wavData.set(headerBytes, 0); + wavData.set(pcmData, headerBytes.length); + + // Write to file + const wavBase64 = arrayBufferToBase64(wavData.buffer); + await fs.writeFile(filePath, wavBase64, 'base64'); + + logger.info(`WAV file written: ${filePath}, size: ${wavData.length} bytes`); +} + +function createWavHeader(dataLength: number): ArrayBuffer { + const buffer = new ArrayBuffer(44); + const view = new DataView(buffer); + const sampleRate = AUDIO_SAMPLE_RATE; + const byteRate = sampleRate * 2; + + writeString(view, 0, 'RIFF'); + view.setUint32(4, 36 + dataLength, true); + writeString(view, 8, 'WAVE'); + writeString(view, 12, 'fmt '); + view.setUint32(16, 16, true); + view.setUint16(20, 1, true); + view.setUint16(22, 1, true); + view.setUint32(24, sampleRate, true); + view.setUint32(28, byteRate, true); + view.setUint16(32, 2, true); + view.setUint16(34, 16, true); + writeString(view, 36, 'data'); + view.setUint32(40, dataLength, true); + + return buffer; +} + +function writeString(view: DataView, offset: number, str: string): void { + for (let i = 0; i < str.length; i++) { + view.setUint8(offset + i, str.charCodeAt(i)); + } +} + +function arrayBufferToBase64(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]!); + } + return btoa(binary); +} diff --git a/sdk/runanywhere-react-native/packages/core/src/Public/Extensions/RunAnywhere+Logging.ts b/sdk/runanywhere-react-native/packages/core/src/Public/Extensions/RunAnywhere+Logging.ts new file mode 100644 index 000000000..2a0957f15 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/Public/Extensions/RunAnywhere+Logging.ts @@ -0,0 +1,51 @@ +/** + * RunAnywhere+Logging.ts + * + * Logging extension for RunAnywhere SDK. + * Matches iOS: RunAnywhere+Logging.swift + */ + +import type { LogLevel } from '../../Foundation/Logging/Models/LogLevel'; +import type { + LogEventCallback, + LogDestination, +} from '../../Foundation/Logging'; + +// ============================================================================ +// Logging Extension +// ============================================================================ + +/** + * Set SDK log level + * Matches iOS: static func setLogLevel(_ level: LogLevel) + */ +export function setLogLevel(level: LogLevel): void { + const { LoggingManager } = require('../../Foundation/Logging'); + LoggingManager.shared.setLogLevel(level); +} + +/** + * Subscribe to all SDK log events + * Matches iOS pattern of exposing log events publicly. + */ +export function onLog(callback: LogEventCallback): () => void { + const { LoggingManager } = require('../../Foundation/Logging'); + return LoggingManager.shared.onLog(callback); +} + +/** + * Add a custom log destination + * Matches iOS: static func addLogDestination(_ destination: LogDestination) + */ +export function addLogDestination(destination: LogDestination): void { + const { LoggingManager } = require('../../Foundation/Logging'); + LoggingManager.shared.addDestination(destination); +} + +/** + * Remove a log destination by identifier + */ +export function removeLogDestination(identifier: string): void { + const { LoggingManager } = require('../../Foundation/Logging'); + LoggingManager.shared.removeDestination(identifier); +} diff --git a/sdk/runanywhere-react-native/packages/core/src/Public/Extensions/RunAnywhere+Models.ts b/sdk/runanywhere-react-native/packages/core/src/Public/Extensions/RunAnywhere+Models.ts new file mode 100644 index 000000000..62c48ac96 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/Public/Extensions/RunAnywhere+Models.ts @@ -0,0 +1,395 @@ +/** + * RunAnywhere+Models.ts + * + * Model registry and download extension for RunAnywhere SDK. + * Matches iOS: RunAnywhere+ModelManagement.swift and RunAnywhere+ModelAssignments.swift + */ + +import { + requireNativeModule, + isNativeModuleAvailable, +} from '../../native'; +import { ModelRegistry } from '../../services/ModelRegistry'; +import { FileSystem } from '../../services/FileSystem'; +import { SDKLogger } from '../../Foundation/Logging/Logger/SDKLogger'; +import type { ModelInfo, LLMFramework } from '../../types'; +import { ModelCategory } from '../../types'; + +const logger = new SDKLogger('RunAnywhere.Models'); + +// Track active downloads for cancellation +const activeDownloads = new Map(); + +// ============================================================================ +// Model Registry Extension +// ============================================================================ + +/** + * Get available models from the catalog + */ +export async function getAvailableModels(): Promise { + return ModelRegistry.getAvailableModels(); +} + +/** + * Get available frameworks + */ +export function getAvailableFrameworks(): LLMFramework[] { + const { + ServiceRegistry, + } = require('../../Foundation/DependencyInjection/ServiceRegistry'); + + const llmProviders = ServiceRegistry.shared.allLLMProviders(); + const frameworksSet = new Set(); + + for (const provider of llmProviders) { + if (provider.getProvidedModels) { + const models = provider.getProvidedModels(); + for (const model of models) { + for (const framework of model.compatibleFrameworks) { + frameworksSet.add(framework); + } + if (model.preferredFramework) { + frameworksSet.add(model.preferredFramework); + } + } + } + } + + return Array.from(frameworksSet); +} + +/** + * Get models for a specific framework + */ +export async function getModelsForFramework( + framework: LLMFramework +): Promise { + const allModels = await ModelRegistry.getAvailableModels(); + return allModels.filter( + (model) => + model.compatibleFrameworks.includes(framework) || + model.preferredFramework === framework + ); +} + +/** + * Get info for a specific model + */ +export async function getModelInfo(modelId: string): Promise { + if (!isNativeModuleAvailable()) { + return null; + } + const native = requireNativeModule(); + const infoJson = await native.getModelInfo(modelId); + try { + const result = JSON.parse(infoJson); + return result === 'null' ? null : result; + } catch { + return null; + } +} + +/** + * Check if a model is downloaded + */ +export async function isModelDownloaded(modelId: string): Promise { + if (!isNativeModuleAvailable()) { + return false; + } + const native = requireNativeModule(); + return native.isModelDownloaded(modelId); +} + +/** + * Get local path for a downloaded model + */ +export async function getModelPath(modelId: string): Promise { + if (!isNativeModuleAvailable()) { + return null; + } + const native = requireNativeModule(); + return native.getModelPath(modelId); +} + +/** + * Get list of downloaded models + */ +export async function getDownloadedModels(): Promise { + if (!isNativeModuleAvailable()) { + return []; + } + // Get all models and filter for downloaded ones + const native = requireNativeModule(); + const modelsJson = await native.getAvailableModels(); + try { + const allModels: ModelInfo[] = JSON.parse(modelsJson); + return allModels.filter(m => m.isDownloaded); + } catch { + return []; + } +} + +// ============================================================================ +// Model Assignments Extension +// ============================================================================ + +/** + * Fetch model assignments for the current device from the backend. + * + * Note: Model assignments are automatically fetched during SDK initialization + * (auto-fetch is enabled in the C++ layer). This method retrieves the cached + * models from the registry. + */ +export async function fetchModelAssignments( + forceRefresh = false, + initState: { isCoreInitialized: boolean }, + ensureServicesReady: () => Promise +): Promise { + if (!initState.isCoreInitialized) { + throw new Error('SDK not initialized. Call initialize() first.'); + } + + await ensureServicesReady(); + + logger.info('Fetching model assignments...'); + + // Models are auto-fetched at SDK initialization and saved to the registry + try { + const models = await ModelRegistry.getAllModels(); + logger.info(`Successfully fetched ${models.length} model assignments`); + return models; + } catch (error) { + logger.warning('Failed to fetch model assignments:', { error }); + return []; + } +} + +/** + * Get available models for a specific category + */ +export async function getModelsForCategory( + category: ModelCategory, + initState: { isCoreInitialized: boolean }, + ensureServicesReady: () => Promise +): Promise { + if (!initState.isCoreInitialized) { + throw new Error('SDK not initialized. Call initialize() first.'); + } + + await ensureServicesReady(); + + // Get models by category via ModelRegistry (delegates to native) + const allModels = await ModelRegistry.getModelsByCategory(category); + return allModels; +} + +/** + * Clear cached model assignments. + * Resets local state; next fetch will get fresh data from the registry. + */ +export async function clearModelAssignmentsCache( + initState: { isCoreInitialized: boolean } +): Promise { + if (!initState.isCoreInitialized) { + return; + } + + ModelRegistry.reset(); +} + +/** + * Register a model from a download URL + */ +export async function registerModel(options: { + id?: string; + name: string; + url: string; + framework: LLMFramework; + category?: ModelCategory; + memoryRequirement?: number; + supportsThinking?: boolean; +}): Promise { + const { ModelFormat, ConfigurationSource } = await import('../../types/enums'); + const now = new Date().toISOString(); + + const modelInfo: ModelInfo = { + id: options.id ?? generateModelId(options.url), + name: options.name, + category: options.category ?? ModelCategory.Language, + format: options.url.includes('.gguf') + ? ModelFormat.GGUF + : ModelFormat.GGUF, + downloadURL: options.url, + localPath: undefined, + downloadSize: undefined, + memoryRequired: options.memoryRequirement, + compatibleFrameworks: [options.framework], + preferredFramework: options.framework, + supportsThinking: options.supportsThinking ?? false, + metadata: { tags: [] }, + source: ConfigurationSource.Local, + createdAt: now, + updatedAt: now, + syncPending: false, + usageCount: 0, + isDownloaded: false, + isAvailable: true, + }; + + await ModelRegistry.registerModel(modelInfo); + return modelInfo; +} + +function generateModelId(url: string): string { + const urlObj = new URL(url); + const pathname = urlObj.pathname; + const filename = pathname.split('/').pop() ?? 'model'; + return filename.replace(/\.(gguf|bin|safetensors|tar\.gz|zip)$/i, ''); +} + +// ============================================================================ +// Model Download Extension +// ============================================================================ + +/** + * Download progress information + */ +export interface DownloadProgress { + modelId: string; + bytesDownloaded: number; + totalBytes: number; + progress: number; +} + +/** + * Download a model + * Uses react-native-fs for cross-platform downloads with progress tracking + */ +export async function downloadModel( + modelId: string, + onProgress?: (progress: DownloadProgress) => void +): Promise { + const modelInfo = await ModelRegistry.getModel(modelId); + if (!modelInfo) { + throw new Error(`Model not found: ${modelId}`); + } + + if (!modelInfo.downloadURL) { + throw new Error(`Model has no download URL: ${modelId}`); + } + + if (!FileSystem.isAvailable()) { + throw new Error('react-native-fs not installed - cannot download models'); + } + + // Determine file name with extension + let extension = ''; + if (modelInfo.downloadURL.includes('.gguf')) { + extension = '.gguf'; + } else if (modelInfo.downloadURL.includes('.tar.bz2')) { + extension = '.tar.bz2'; + } else if (modelInfo.downloadURL.includes('.tar.gz')) { + extension = '.tar.gz'; + } else if (modelInfo.downloadURL.includes('.zip')) { + extension = '.zip'; + } + const fileName = `${modelId}${extension}`; + + logger.info('Starting download (react-native-fs):', { + modelId, + url: modelInfo.downloadURL, + }); + + activeDownloads.set(modelId, 1); + let lastLoggedProgress = -1; + + try { + const destPath = await FileSystem.downloadModel( + fileName, + modelInfo.downloadURL, + (progress) => { + const progressPct = Math.round(progress.progress * 100); + if (progressPct - lastLoggedProgress >= 10) { + logger.debug(`Download progress: ${progressPct}%`); + lastLoggedProgress = progressPct; + } + + if (onProgress) { + onProgress({ + modelId, + bytesDownloaded: progress.bytesWritten, + totalBytes: progress.contentLength || modelInfo.downloadSize || 0, + progress: progress.progress, + }); + } + } + ); + + logger.info('Download completed:', { + modelId, + destPath, + }); + + // Update model in registry with local path + const updatedModel: ModelInfo = { + ...modelInfo, + localPath: destPath, + isDownloaded: true, + }; + await ModelRegistry.registerModel(updatedModel); + + return destPath; + } finally { + activeDownloads.delete(modelId); + } +} + +/** + * Cancel an ongoing download + */ +export async function cancelDownload(modelId: string): Promise { + if (activeDownloads.has(modelId)) { + activeDownloads.delete(modelId); + logger.info(`Marked download as cancelled: ${modelId}`); + return true; + } + return false; +} + +/** + * Delete a downloaded model + */ +export async function deleteModel(modelId: string): Promise { + try { + const modelInfo = await ModelRegistry.getModel(modelId); + const extension = modelInfo?.downloadURL?.includes('.gguf') + ? '.gguf' + : ''; + const fileName = `${modelId}${extension}`; + + // Delete using FileSystem service + const deleted = await FileSystem.deleteModel(fileName); + if (deleted) { + logger.info(`Deleted model: ${modelId}`); + } + + // Also try plain model ID in case of different naming + await FileSystem.deleteModel(modelId); + + // Update model in registry + if (modelInfo) { + const updatedModel: ModelInfo = { + ...modelInfo, + localPath: undefined, + isDownloaded: false, + }; + await ModelRegistry.registerModel(updatedModel); + } + + return true; + } catch (error) { + logger.error('Delete model error:', { error }); + return false; + } +} diff --git a/sdk/runanywhere-react-native/packages/core/src/Public/Extensions/RunAnywhere+STT.ts b/sdk/runanywhere-react-native/packages/core/src/Public/Extensions/RunAnywhere+STT.ts new file mode 100644 index 000000000..ca792af94 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/Public/Extensions/RunAnywhere+STT.ts @@ -0,0 +1,424 @@ +/** + * RunAnywhere+STT.ts + * + * Speech-to-Text extension for RunAnywhere SDK. + * Matches iOS: RunAnywhere+STT.swift + */ + +import { EventBus } from '../Events'; +import { requireNativeModule, isNativeModuleAvailable } from '../../native'; +import type { STTOptions, STTResult } from '../../types'; +import { SDKLogger } from '../../Foundation/Logging/Logger/SDKLogger'; +import type { + STTOutput, + STTPartialResult, + STTStreamCallback, + STTStreamOptions, + TranscriptionMetadata, +} from '../../types/STTTypes'; + +const logger = new SDKLogger('RunAnywhere.STT'); + +/** + * Extended native module type for streaming STT methods + * These methods are optional and may not be implemented in all backends + */ +interface StreamingSTTNativeModule { + startStreamingSTT?: (language: string) => Promise; + stopStreamingSTT?: () => Promise; + isStreamingSTT?: () => Promise; +} + +// ============================================================================ +// Speech-to-Text (STT) Extension +// ============================================================================ + +/** + * Load an STT model + */ +export async function loadSTTModel( + modelPath: string, + modelType: string = 'whisper', + config?: Record +): Promise { + if (!isNativeModuleAvailable()) { + logger.warning('Native module not available for loadSTTModel'); + return false; + } + const native = requireNativeModule(); + return native.loadSTTModel( + modelPath, + modelType, + config ? JSON.stringify(config) : undefined + ); +} + +/** + * Check if an STT model is loaded + */ +export async function isSTTModelLoaded(): Promise { + if (!isNativeModuleAvailable()) { + return false; + } + const native = requireNativeModule(); + return native.isSTTModelLoaded(); +} + +/** + * Unload the current STT model + */ +export async function unloadSTTModel(): Promise { + if (!isNativeModuleAvailable()) { + return false; + } + const native = requireNativeModule(); + return native.unloadSTTModel(); +} + +/** + * Simple voice transcription + * Matches Swift SDK: RunAnywhere.transcribe(_:) + * + * @param audioData Audio data (base64 string or ArrayBuffer) + * @returns Transcribed text + */ +export async function transcribeSimple( + audioData: string | ArrayBuffer +): Promise { + const result = await transcribe(audioData); + return result.text; +} + +/** + * Transcribe audio data with full options + * Matches Swift SDK: RunAnywhere.transcribeWithOptions(_:options:) + */ +export async function transcribe( + audioData: string | ArrayBuffer, + options?: STTOptions +): Promise { + if (!isNativeModuleAvailable()) { + throw new Error('Native module not available'); + } + const native = requireNativeModule(); + + let audioBase64: string; + if (typeof audioData === 'string') { + audioBase64 = audioData; + } else { + const bytes = new Uint8Array(audioData); + let binary = ''; + for (let i = 0; i < bytes.byteLength; i++) { + const byte = bytes[i]; + if (byte !== undefined) { + binary += String.fromCharCode(byte); + } + } + audioBase64 = btoa(binary); + } + + const sampleRate = options?.sampleRate ?? 16000; + const language = options?.language; + + const resultJson = await native.transcribe(audioBase64, sampleRate, language); + + try { + const result = JSON.parse(resultJson); + return { + text: result.text ?? '', + segments: result.segments ?? [], + language: result.language, + confidence: result.confidence ?? 1.0, + duration: result.duration ?? 0, + alternatives: result.alternatives ?? [], + }; + } catch { + if (resultJson.includes('error')) { + throw new Error(resultJson); + } + return { + text: resultJson, + segments: [], + confidence: 1.0, + duration: 0, + alternatives: [], + }; + } +} + +/** + * Transcribe audio buffer (Float32Array) + * Matches Swift SDK: RunAnywhere.transcribeBuffer(_:language:) + */ +export async function transcribeBuffer( + samples: Float32Array, + options?: STTOptions +): Promise { + if (!isNativeModuleAvailable()) { + throw new Error('Native module not available'); + } + + const startTime = Date.now(); + const native = requireNativeModule(); + + // Convert Float32Array to base64 + const bytes = new Uint8Array(samples.buffer); + let binary = ''; + for (let i = 0; i < bytes.byteLength; i++) { + const byte = bytes[i]; + if (byte !== undefined) { + binary += String.fromCharCode(byte); + } + } + const audioBase64 = btoa(binary); + + const sampleRate = options?.sampleRate ?? 16000; + const language = options?.language ?? 'en'; + + const resultJson = await native.transcribe(audioBase64, sampleRate, language); + const endTime = Date.now(); + const processingTime = (endTime - startTime) / 1000; + + try { + const result = JSON.parse(resultJson); + + // Estimate audio length from samples + const audioLength = samples.length / sampleRate; + + const metadata: TranscriptionMetadata = { + modelId: 'unknown', + processingTime, + audioLength, + realTimeFactor: processingTime / audioLength, + }; + + return { + text: result.text ?? '', + confidence: result.confidence ?? 1.0, + wordTimestamps: result.timestamps, + detectedLanguage: result.language, + alternatives: result.alternatives, + metadata, + }; + } catch { + throw new Error(`Transcription failed: ${resultJson}`); + } +} + +/** + * Transcribe audio with streaming callbacks + * Matches Swift SDK: RunAnywhere.transcribeStream(audioData:options:onPartialResult:) + * + * @param audioData Audio data to transcribe + * @param options Stream options with callback + * @returns Final transcription output + */ +export async function transcribeStream( + audioData: string | ArrayBuffer, + options: STTStreamOptions +): Promise { + if (!isNativeModuleAvailable()) { + throw new Error('Native module not available'); + } + + const startTime = Date.now(); + const native = requireNativeModule(); + + let audioBase64: string; + let audioSize: number; + + if (typeof audioData === 'string') { + audioBase64 = audioData; + audioSize = atob(audioData).length; + } else { + const bytes = new Uint8Array(audioData); + audioSize = bytes.byteLength; + let binary = ''; + for (let i = 0; i < bytes.byteLength; i++) { + const byte = bytes[i]; + if (byte !== undefined) { + binary += String.fromCharCode(byte); + } + } + audioBase64 = btoa(binary); + } + + const sampleRate = options?.sampleRate ?? 16000; + const language = options?.language ?? 'en'; + + // Set up event listener for partial results + let finalText = ''; + let finalConfidence = 1.0; + + if (options.onPartialResult) { + const unsubscribe = EventBus.onVoice((event) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const evt = event as any; + if (evt.type === 'sttPartialResult') { + const partialResult: STTPartialResult = { + transcript: evt.text ?? '', + confidence: evt.confidence, + isFinal: false, + }; + options.onPartialResult?.(partialResult); + } else if (evt.type === 'sttCompleted') { + finalText = evt.text ?? ''; + finalConfidence = evt.confidence ?? 1.0; + unsubscribe(); + } + }); + } + + // Transcribe + const resultJson = await native.transcribe(audioBase64, sampleRate, language); + const endTime = Date.now(); + const processingTime = (endTime - startTime) / 1000; + + try { + const result = JSON.parse(resultJson); + + // Estimate audio length + const bytesPerSample = 2; // 16-bit + const audioLength = audioSize / (sampleRate * bytesPerSample); + + const metadata: TranscriptionMetadata = { + modelId: 'unknown', + processingTime, + audioLength, + realTimeFactor: processingTime / audioLength, + }; + + // Emit final partial result + if (options.onPartialResult) { + options.onPartialResult({ + transcript: result.text ?? '', + confidence: result.confidence ?? 1.0, + isFinal: true, + }); + } + + return { + text: result.text ?? finalText, + confidence: result.confidence ?? finalConfidence, + wordTimestamps: result.timestamps, + detectedLanguage: result.language, + alternatives: result.alternatives, + metadata, + }; + } catch { + throw new Error(`Streaming transcription failed: ${resultJson}`); + } +} + +/** + * Transcribe audio from a file path + */ +export async function transcribeFile( + filePath: string, + options?: STTOptions +): Promise { + if (!isNativeModuleAvailable()) { + throw new Error('Native module not available'); + } + const native = requireNativeModule(); + + const language = options?.language ?? 'en'; + const resultJson = await native.transcribeFile(filePath, language); + + try { + const result = JSON.parse(resultJson); + if (result.error) { + throw new Error(result.error); + } + return { + text: result.text ?? '', + segments: result.segments ?? [], + language: result.language, + confidence: result.confidence ?? 1.0, + duration: result.duration ?? 0, + alternatives: result.alternatives ?? [], + }; + } catch { + if (resultJson.includes('error')) { + const errorMatch = resultJson.match(/"error":\s*"([^"]+)"/); + throw new Error(errorMatch ? errorMatch[1] : resultJson); + } + return { + text: resultJson, + segments: [], + confidence: 1.0, + duration: 0, + alternatives: [], + }; + } +} + +// ============================================================================ +// Streaming STT (Real-time) +// ============================================================================ + +/** + * Start streaming speech-to-text transcription + * @deprecated Use transcribeStream() for better API parity with Swift SDK + */ +export async function startStreamingSTT( + language: string = 'en', + onPartial?: (text: string, confidence: number) => void, + onFinal?: (text: string, confidence: number) => void, + onError?: (error: string) => void +): Promise { + if (!isNativeModuleAvailable()) { + logger.warning('Native module not available for startStreamingSTT'); + return false; + } + const native = requireNativeModule(); + + if (onPartial || onFinal || onError) { + EventBus.onVoice((event) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const evt = event as any; + if (evt.type === 'sttPartialResult' && onPartial) { + onPartial(evt.text || '', evt.confidence || 0); + } else if (evt.type === 'sttCompleted' && onFinal) { + onFinal(evt.text || '', evt.confidence || 0); + } else if (evt.type === 'sttFailed' && onError) { + onError(evt.error || 'Unknown error'); + } + }); + } + + const streamingNative = native as unknown as StreamingSTTNativeModule; + if (!streamingNative.startStreamingSTT) { + logger.warning('startStreamingSTT not available'); + return false; + } + return streamingNative.startStreamingSTT(language); +} + +/** + * Stop streaming speech-to-text transcription + */ +export async function stopStreamingSTT(): Promise { + if (!isNativeModuleAvailable()) { + return false; + } + const native = requireNativeModule() as unknown as StreamingSTTNativeModule; + if (!native.stopStreamingSTT) { + return false; + } + return native.stopStreamingSTT(); +} + +/** + * Check if streaming STT is currently active + */ +export async function isStreamingSTT(): Promise { + if (!isNativeModuleAvailable()) { + return false; + } + const native = requireNativeModule() as unknown as StreamingSTTNativeModule; + if (!native.isStreamingSTT) { + return false; + } + return native.isStreamingSTT(); +} diff --git a/sdk/runanywhere-react-native/packages/core/src/Public/Extensions/RunAnywhere+Storage.ts b/sdk/runanywhere-react-native/packages/core/src/Public/Extensions/RunAnywhere+Storage.ts new file mode 100644 index 000000000..b1b897f3f --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/Public/Extensions/RunAnywhere+Storage.ts @@ -0,0 +1,151 @@ +/** + * RunAnywhere+Storage.ts + * + * Storage management extension. + * Uses react-native-fs via FileSystem service. + * + * Reference: sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/Storage/RunAnywhere+Storage.swift + */ + +import { ModelRegistry } from '../../services/ModelRegistry'; +import { FileSystem } from '../../services/FileSystem'; +import { SDKLogger } from '../../Foundation/Logging/Logger/SDKLogger'; + +const logger = new SDKLogger('RunAnywhere.Storage'); + +/** + * Device storage information + * Matches Swift's DeviceStorageInfo + */ +export interface DeviceStorageInfo { + totalSpace: number; + freeSpace: number; + usedSpace: number; +} + +/** + * App storage information + * Matches Swift's AppStorageInfo + */ +export interface AppStorageInfo { + documentsSize: number; + cacheSize: number; + appSupportSize: number; + totalSize: number; +} + +/** + * Model storage information + */ +export interface ModelStorageInfo { + totalSize: number; + modelCount: number; +} + +/** + * Complete storage info structure + * Matches Swift's StorageInfo + */ +export interface StorageInfo { + deviceStorage: DeviceStorageInfo; + appStorage: AppStorageInfo; + modelStorage: ModelStorageInfo; + cacheSize: number; + totalModelsSize: number; +} + +/** + * Get storage information + * Returns structure matching Swift's StorageInfo + */ +export async function getStorageInfo(): Promise { + const emptyResult: StorageInfo = { + deviceStorage: { totalSpace: 0, freeSpace: 0, usedSpace: 0 }, + appStorage: { documentsSize: 0, cacheSize: 0, appSupportSize: 0, totalSize: 0 }, + modelStorage: { totalSize: 0, modelCount: 0 }, + cacheSize: 0, + totalModelsSize: 0, + }; + + if (!FileSystem.isAvailable()) { + return emptyResult; + } + + try { + const freeSpace = await FileSystem.getAvailableDiskSpace(); + const totalSpace = await FileSystem.getTotalDiskSpace(); + const usedSpace = totalSpace - freeSpace; + + // Get models directory size + let modelsSize = 0; + let modelCount = 0; + try { + const modelsDir = FileSystem.getModelsDirectory(); + const exists = await FileSystem.directoryExists(modelsDir); + if (exists) { + modelsSize = await FileSystem.getDirectorySize(modelsDir); + const files = await FileSystem.listDirectory(modelsDir); + modelCount = files.length; + } + } catch { + // Models directory may not exist yet + } + + // Get cache size + let cacheSize = 0; + try { + const cacheDir = FileSystem.getCacheDirectory(); + const exists = await FileSystem.directoryExists(cacheDir); + if (exists) { + cacheSize = await FileSystem.getDirectorySize(cacheDir); + } + } catch { + // Cache directory may not exist + } + + // Get app documents size (RunAnywhere directory) + let documentsSize = 0; + try { + const docsDir = FileSystem.getRunAnywhereDirectory(); + const exists = await FileSystem.directoryExists(docsDir); + if (exists) { + documentsSize = await FileSystem.getDirectorySize(docsDir); + } + } catch { + // Documents directory may not exist + } + + const totalAppSize = documentsSize + cacheSize; + + return { + deviceStorage: { + totalSpace, + freeSpace, + usedSpace, + }, + appStorage: { + documentsSize, + cacheSize, + appSupportSize: 0, + totalSize: totalAppSize, + }, + modelStorage: { + totalSize: modelsSize, + modelCount, + }, + cacheSize, + totalModelsSize: modelsSize, + }; + } catch (error) { + logger.warning('Failed to get storage info:', { error }); + return emptyResult; + } +} + +/** + * Clear cache + */ +export async function clearCache(): Promise { + ModelRegistry.reset(); + logger.info('Cache cleared'); +} diff --git a/sdk/runanywhere-react-native/packages/core/src/Public/Extensions/RunAnywhere+StructuredOutput.ts b/sdk/runanywhere-react-native/packages/core/src/Public/Extensions/RunAnywhere+StructuredOutput.ts new file mode 100644 index 000000000..e08273d3e --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/Public/Extensions/RunAnywhere+StructuredOutput.ts @@ -0,0 +1,316 @@ +/** + * RunAnywhere+StructuredOutput.ts + * + * Structured output extension for JSON schema-guided generation. + * Delegates to native StructuredOutputBridge. + * + * Reference: sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/LLM/RunAnywhere+StructuredOutput.swift + */ + +import { requireNativeModule, isNativeModuleAvailable } from '../../native'; +import { SDKLogger } from '../../Foundation/Logging/Logger/SDKLogger'; +import { generateStream } from './RunAnywhere+TextGeneration'; +import type { + StructuredOutputResult, + StructuredOutputOptions, + JSONSchema, +} from '../../types/StructuredOutputTypes'; + +const logger = new SDKLogger('RunAnywhere.StructuredOutput'); + +/** + * Stream token for structured output streaming + */ +export interface StreamToken { + text: string; + timestamp: Date; + tokenIndex: number; +} + +/** + * Structured output stream result + */ +export interface StructuredOutputStreamResult { + /** Async iterator for tokens */ + tokenStream: AsyncIterable; + + /** Promise that resolves to final parsed result */ + result: Promise; +} + +/** + * Generate structured output following a JSON schema + * Matches Swift SDK: RunAnywhere.generateStructured(_:prompt:options:) + * + * @param prompt The prompt text + * @param schema JSON schema defining the output structure + * @param options Optional generation options + * @returns Structured output result with parsed data + */ +export async function generateStructured( + prompt: string, + schema: JSONSchema, + options?: StructuredOutputOptions +): Promise> { + if (!isNativeModuleAvailable()) { + throw new Error('Native module not available'); + } + + const native = requireNativeModule(); + + try { + logger.debug('Generating structured output...'); + + const schemaJson = JSON.stringify(schema); + const optionsJson = options ? JSON.stringify(options) : undefined; + + const resultJson = await native.generateStructured(prompt, schemaJson, optionsJson); + + // Check for error + if (resultJson.includes('"error"')) { + const parsed = JSON.parse(resultJson); + if (parsed.error) { + throw new Error(parsed.error); + } + } + + // Parse the JSON result + const data = JSON.parse(resultJson) as T; + + return { + data, + raw: resultJson, + success: true, + }; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + logger.error(`Structured output failed: ${msg}`); + + return { + data: null as T, + raw: '', + success: false, + error: msg, + }; + } +} + +/** + * Generate structured output with streaming support + * Matches Swift SDK: RunAnywhere.generateStructuredStream(_:content:options:) + * + * Returns both a token stream for real-time display and a promise for the final result. + * + * Example: + * ```typescript + * interface Quiz { + * question: string; + * options: string[]; + * answer: number; + * } + * + * const schema: JSONSchema = { + * type: 'object', + * properties: { + * question: { type: 'string' }, + * options: { type: 'array', items: { type: 'string' } }, + * answer: { type: 'integer' } + * }, + * required: ['question', 'options', 'answer'] + * }; + * + * const streaming = generateStructuredStream(prompt, schema); + * + * // Display tokens in real-time + * for await (const token of streaming.tokenStream) { + * console.log(token.text); + * } + * + * // Get parsed result + * const quiz = await streaming.result; + * ``` + */ +export function generateStructuredStream( + prompt: string, + schema: JSONSchema, + options?: StructuredOutputOptions +): StructuredOutputStreamResult { + // Build system prompt for JSON generation + const systemPrompt = buildStructuredOutputSystemPrompt(schema); + const fullPrompt = `${systemPrompt}\n\n${prompt}`; + + let fullText = ''; + let resolveResult: ((value: T) => void) | null = null; + let rejectResult: ((error: Error) => void) | null = null; + + // Create result promise + const resultPromise = new Promise((resolve, reject) => { + resolveResult = resolve; + rejectResult = reject; + }); + + // Create token stream generator + async function* tokenGenerator(): AsyncGenerator { + try { + const streamingResult = await generateStream(fullPrompt, { + maxTokens: options?.maxTokens ?? 1500, + temperature: options?.temperature ?? 0.7, + }); + + let tokenIndex = 0; + for await (const token of streamingResult.stream) { + fullText += token; + + yield { + text: token, + timestamp: new Date(), + tokenIndex: tokenIndex++, + }; + } + + // Parse the final result + const parsed = parseStructuredOutput(fullText); + if (resolveResult) { + resolveResult(parsed); + } + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + if (rejectResult) { + rejectResult(err); + } + throw err; + } + } + + return { + tokenStream: tokenGenerator(), + result: resultPromise, + }; +} + +/** + * Generate structured output with automatic type inference + * @param prompt The prompt text + * @param schema JSON schema defining the output structure + * @returns The generated data matching the schema + */ +export async function generate( + prompt: string, + schema: JSONSchema +): Promise { + const result = await generateStructured(prompt, schema); + + if (!result.success) { + throw new Error(result.error || 'Structured generation failed'); + } + + return result.data; +} + +/** + * Extract entities from text using structured output + * @param text Source text to extract from + * @param entitySchema Schema describing the entities to extract + * @returns Extracted entities + */ +export async function extractEntities( + text: string, + entitySchema: JSONSchema +): Promise { + const prompt = `Extract the following information from this text: + +${text} + +Return the extracted data as JSON matching the provided schema.`; + + return generate(prompt, entitySchema); +} + +/** + * Classify text into categories using structured output + * @param text Text to classify + * @param categories List of possible categories + * @returns Classification result + */ +export async function classify( + text: string, + categories: string[] +): Promise<{ category: string; confidence: number }> { + const schema: JSONSchema = { + type: 'object', + properties: { + category: { + type: 'string', + enum: categories, + description: 'The category that best matches the text', + }, + confidence: { + type: 'number', + minimum: 0, + maximum: 1, + description: 'Confidence score between 0 and 1', + }, + }, + required: ['category', 'confidence'], + }; + + const prompt = `Classify the following text into one of these categories: ${categories.join(', ')} + +Text: ${text} + +Respond with the category and your confidence level.`; + + return generate<{ category: string; confidence: number }>(prompt, schema); +} + +// ============================================================================ +// Private Helpers +// ============================================================================ + +/** + * Build system prompt for structured JSON output + */ +function buildStructuredOutputSystemPrompt(schema: JSONSchema): string { + return `You are a JSON generator that outputs ONLY valid JSON without any additional text. +Start your response with { and end with }. Do not include any text before or after the JSON. +Do not include markdown code blocks or any formatting. + +Expected JSON schema: +${JSON.stringify(schema, null, 2)} + +Important: +- Output ONLY the JSON object, nothing else +- Ensure all required fields are present +- Use the exact field names from the schema +- Match the expected types (string, number, array, etc.)`; +} + +/** + * Parse structured output from generated text + */ +function parseStructuredOutput(text: string): T { + // Try to extract JSON from the response + let jsonStr = text.trim(); + + // Remove markdown code blocks if present + const codeBlockMatch = jsonStr.match(/```(?:json)?\s*([\s\S]*?)```/); + if (codeBlockMatch && codeBlockMatch[1]) { + jsonStr = codeBlockMatch[1].trim(); + } + + // Find JSON object boundaries + const startIdx = jsonStr.indexOf('{'); + const endIdx = jsonStr.lastIndexOf('}'); + + if (startIdx === -1 || endIdx === -1 || startIdx >= endIdx) { + throw new Error('No valid JSON object found in the response'); + } + + jsonStr = jsonStr.substring(startIdx, endIdx + 1); + + try { + return JSON.parse(jsonStr) as T; + } catch (error) { + throw new Error(`Failed to parse JSON: ${error}`); + } +} diff --git a/sdk/runanywhere-react-native/packages/core/src/Public/Extensions/RunAnywhere+TTS.ts b/sdk/runanywhere-react-native/packages/core/src/Public/Extensions/RunAnywhere+TTS.ts new file mode 100644 index 000000000..8317e8736 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/Public/Extensions/RunAnywhere+TTS.ts @@ -0,0 +1,430 @@ +/** + * RunAnywhere+TTS.ts + * + * Text-to-Speech extension for RunAnywhere SDK. + * Matches iOS: RunAnywhere+TTS.swift + */ + +import { requireNativeModule, isNativeModuleAvailable } from '../../native'; +import type { TTSConfiguration, TTSResult } from '../../types'; +import { SDKLogger } from '../../Foundation/Logging/Logger/SDKLogger'; +import { AudioPlaybackManager } from '../../Features/VoiceSession/AudioPlaybackManager'; +import type { + TTSOptions, + TTSOutput, + TTSSpeakResult, + TTSVoiceInfo, + TTSStreamChunkCallback, + TTSSynthesisMetadata, +} from '../../types/TTSTypes'; + +const logger = new SDKLogger('RunAnywhere.TTS'); + +// Internal audio playback manager for speak() functionality +let ttsAudioPlayback: AudioPlaybackManager | null = null; + +function getAudioPlayback(): AudioPlaybackManager { + if (!ttsAudioPlayback) { + ttsAudioPlayback = new AudioPlaybackManager(); + } + return ttsAudioPlayback; +} + +// ============================================================================ +// Voice Loading +// ============================================================================ + +/** + * Load a TTS model/voice + */ +export async function loadTTSModel( + modelPath: string, + modelType: string = 'piper', + config?: Record +): Promise { + if (!isNativeModuleAvailable()) { + logger.warning('Native module not available for loadTTSModel'); + return false; + } + const native = requireNativeModule(); + return native.loadTTSModel( + modelPath, + modelType, + config ? JSON.stringify(config) : undefined + ); +} + +/** + * Load a TTS voice by ID + * Matches Swift SDK: RunAnywhere.loadTTSVoice(_:) + */ +export async function loadTTSVoice(voiceId: string): Promise { + if (!isNativeModuleAvailable()) { + throw new Error('Native module not available'); + } + + logger.info(`Loading TTS voice: ${voiceId}`); + const native = requireNativeModule(); + + // Get model info to find the voice path + const modelInfoJson = await native.getModelInfo(voiceId); + const modelInfo = JSON.parse(modelInfoJson); + + if (!modelInfo.localPath) { + throw new Error(`Voice '${voiceId}' is not downloaded`); + } + + const loaded = await native.loadTTSModel(modelInfo.localPath, 'piper'); + if (!loaded) { + throw new Error(`Failed to load voice '${voiceId}'`); + } + + logger.info(`TTS voice loaded: ${voiceId}`); +} + +/** + * Unload the current TTS voice + * Matches Swift SDK: RunAnywhere.unloadTTSVoice() + */ +export async function unloadTTSVoice(): Promise { + if (!isNativeModuleAvailable()) { + return; + } + const native = requireNativeModule(); + await native.unloadTTSModel(); + logger.info('TTS voice unloaded'); +} + +/** + * Check if a TTS model is loaded + */ +export async function isTTSModelLoaded(): Promise { + if (!isNativeModuleAvailable()) { + return false; + } + const native = requireNativeModule(); + return native.isTTSModelLoaded(); +} + +/** + * Check if a TTS voice is loaded + * Matches Swift SDK: RunAnywhere.isTTSVoiceLoaded + */ +export async function isTTSVoiceLoaded(): Promise { + return isTTSModelLoaded(); +} + +/** + * Unload the current TTS model + */ +export async function unloadTTSModel(): Promise { + if (!isNativeModuleAvailable()) { + return false; + } + const native = requireNativeModule(); + return native.unloadTTSModel(); +} + +// ============================================================================ +// Voice Management +// ============================================================================ + +/** + * Get available TTS voices + * Matches Swift SDK: RunAnywhere.availableTTSVoices + */ +export async function availableTTSVoices(): Promise { + if (!isNativeModuleAvailable()) { + return []; + } + + const native = requireNativeModule(); + const voicesJson = await native.getTTSVoices(); + + try { + const voices = JSON.parse(voicesJson); + if (Array.isArray(voices)) { + return voices.map((v: TTSVoiceInfo | string) => + typeof v === 'string' ? v : v.id + ); + } + return []; + } catch { + return voicesJson ? [voicesJson] : []; + } +} + +/** + * Get detailed voice information + */ +export async function getTTSVoiceInfo(): Promise { + if (!isNativeModuleAvailable()) { + return []; + } + + const native = requireNativeModule(); + const voicesJson = await native.getTTSVoices(); + + try { + const voices = JSON.parse(voicesJson); + if (Array.isArray(voices)) { + return voices.map((v: TTSVoiceInfo | { id: string; name?: string; language?: string }) => ({ + id: v.id, + name: v.name ?? v.id, + language: v.language ?? 'en-US', + isDownloaded: true, + })); + } + return []; + } catch { + return []; + } +} + +// ============================================================================ +// Synthesis +// ============================================================================ + +/** + * Extended TTS output with additional fields for backward compatibility + */ +export interface TTSOutputExtended extends TTSOutput { + /** Base64 encoded audio (alias for audioData) */ + audio: string; + /** Sample rate in Hz */ + sampleRate: number; + /** Number of audio samples */ + numSamples: number; +} + +/** + * Synthesize text to speech + * Matches Swift SDK: RunAnywhere.synthesize(_:options:) + */ +export async function synthesize( + text: string, + options?: TTSOptions +): Promise { + if (!isNativeModuleAvailable()) { + throw new Error('Native module not available'); + } + + const startTime = Date.now(); + const native = requireNativeModule(); + + const voiceId = options?.voice ?? ''; + const speedRate = options?.rate ?? 1.0; + const pitchShift = options?.pitch ?? 1.0; + + const resultJson = await native.synthesize(text, voiceId, speedRate, pitchShift); + const endTime = Date.now(); + const processingTime = (endTime - startTime) / 1000; + + try { + const result = JSON.parse(resultJson); + + // C++ returns: audioBase64, sampleRate, durationMs, audioSize + const sampleRate = result.sampleRate ?? 22050; + const audioSize = result.audioSize ?? 0; + // audioSize is in bytes, Float32 PCM = 4 bytes per sample + const numSamples = Math.floor(audioSize / 4); + // Use durationMs from native if available, otherwise calculate from samples + const duration = result.durationMs + ? result.durationMs / 1000 + : (numSamples > 0 ? numSamples / sampleRate : 0); + + const audioData = result.audioBase64 ?? result.audio ?? ''; + + const metadata: TTSSynthesisMetadata = { + voice: voiceId || 'default', + language: options?.language, + processingTime, + characterCount: text.length, + }; + + return { + // TTSOutput fields + audioData, + format: 'pcm', + duration, + metadata, + // Extended fields for backward compatibility + audio: audioData, + sampleRate, + numSamples, + }; + } catch { + if (resultJson.includes('error')) { + throw new Error(resultJson); + } + return { + audioData: resultJson, + format: 'pcm', + duration: 0, + metadata: { + voice: voiceId || 'default', + processingTime, + characterCount: text.length, + }, + audio: resultJson, + sampleRate: 22050, + numSamples: 0, + }; + } +} + +/** + * Synthesize with streaming (chunked audio output) + * Matches Swift SDK: RunAnywhere.synthesizeStream(_:options:onAudioChunk:) + */ +export async function synthesizeStream( + text: string, + options: TTSOptions = {}, + onAudioChunk: TTSStreamChunkCallback +): Promise { + if (!isNativeModuleAvailable()) { + throw new Error('Native module not available'); + } + + const startTime = Date.now(); + + // For now, synthesize and emit as single chunk + // In a full implementation, this would stream chunks from native + const output = await synthesize(text, options); + + // Decode base64 and emit as chunk + if (output.audioData) { + try { + const binaryString = atob(output.audioData); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + onAudioChunk(bytes.buffer); + } catch (error) { + logger.error(`Failed to decode audio chunk: ${error}`); + } + } + + return output; +} + +/** + * Stop current TTS synthesis + * Matches Swift SDK: RunAnywhere.stopSynthesis() + */ +export async function stopSynthesis(): Promise { + // Native cancellation + cancelTTS(); + + // Also stop playback if speak() was used + const playback = getAudioPlayback(); + playback.stop(); +} + +// ============================================================================ +// Speak (Simple Playback API) +// ============================================================================ + +/** + * Speak text aloud - the simplest way to use TTS + * + * The SDK handles audio synthesis and playback internally. + * Just call this method and the text will be spoken through the device speakers. + * + * Matches Swift SDK: RunAnywhere.speak(_:options:) + * + * Example: + * ```typescript + * // Simple usage + * await speak("Hello world"); + * + * // With options + * const result = await speak("Hello", { rate: 1.2 }); + * console.log(`Duration: ${result.duration}s`); + * ``` + */ +export async function speak( + text: string, + options?: TTSOptions +): Promise { + if (!isNativeModuleAvailable()) { + throw new Error('Native module not available'); + } + + logger.info(`Speaking: "${text.substring(0, 50)}..."`); + + // Synthesize the audio + const output = await synthesize(text, options); + + // Play the audio + if (output.audioData) { + const playback = getAudioPlayback(); + await playback.play(output.audioData); + } + + return { + duration: output.duration, + voice: output.metadata.voice, + processingTime: output.metadata.processingTime, + characterCount: output.metadata.characterCount, + }; +} + +/** + * Whether speech is currently playing + * Matches Swift SDK: RunAnywhere.isSpeaking + */ +export function isSpeaking(): boolean { + const playback = getAudioPlayback(); + return playback.isPlaying; +} + +/** + * Stop current speech playback + * Matches Swift SDK: RunAnywhere.stopSpeaking() + */ +export async function stopSpeaking(): Promise { + const playback = getAudioPlayback(); + playback.stop(); + await stopSynthesis(); + logger.info('Speech stopped'); +} + +// ============================================================================ +// Legacy APIs +// ============================================================================ + +/** + * Get available TTS voices (legacy) + * @deprecated Use availableTTSVoices() instead + */ +export async function getTTSVoices(): Promise { + return availableTTSVoices(); +} + +/** + * Cancel ongoing TTS synthesis + */ +export function cancelTTS(): void { + if (!isNativeModuleAvailable()) { + return; + } + // Note: Native module would need a cancelTTS method + // For now, just log + logger.debug('TTS cancellation requested'); +} + +// ============================================================================ +// Cleanup +// ============================================================================ + +/** + * Cleanup TTS resources + */ +export function cleanupTTS(): void { + if (ttsAudioPlayback) { + ttsAudioPlayback.cleanup(); + ttsAudioPlayback = null; + } +} diff --git a/sdk/runanywhere-react-native/packages/core/src/Public/Extensions/RunAnywhere+TextGeneration.ts b/sdk/runanywhere-react-native/packages/core/src/Public/Extensions/RunAnywhere+TextGeneration.ts new file mode 100644 index 000000000..2cf8e86a6 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/Public/Extensions/RunAnywhere+TextGeneration.ts @@ -0,0 +1,320 @@ +/** + * RunAnywhere+TextGeneration.ts + * + * Text generation (LLM) extension for RunAnywhere SDK. + * Uses backend-agnostic rac_llm_component_* C++ APIs via the core native module. + * The actual backend (LlamaCPP, etc.) must be registered by installing + * and importing the appropriate backend package (e.g., @runanywhere/llamacpp). + * + * Matches iOS: RunAnywhere+TextGeneration.swift + */ + +import { EventBus } from '../Events'; +import { + requireNativeModule, + isNativeModuleAvailable, +} from '../../native'; +import type { GenerationOptions, GenerationResult } from '../../types'; +import { ExecutionTarget, HardwareAcceleration } from '../../types'; +import { SDKLogger } from '../../Foundation/Logging/Logger/SDKLogger'; +import type { + LLMStreamingResult, + LLMGenerationResult, +} from '../../types/LLMTypes'; + +const logger = new SDKLogger('RunAnywhere.TextGeneration'); + +// ============================================================================ +// Text Generation (LLM) Extension - Backend Agnostic +// ============================================================================ + +/** + * Load an LLM model by ID or path + * + * Matches iOS: `RunAnywhere.loadModel(_:)` + * @throws Error if no LLM backend is registered + */ +export async function loadModel( + modelPathOrId: string, + config?: Record +): Promise { + if (!isNativeModuleAvailable()) { + logger.warning('Native module not available for loadModel'); + return false; + } + const native = requireNativeModule(); + return native.loadTextModel( + modelPathOrId, + config ? JSON.stringify(config) : undefined + ); +} + +/** + * Check if an LLM model is loaded + * Matches iOS: `RunAnywhere.isModelLoaded` + */ +export async function isModelLoaded(): Promise { + if (!isNativeModuleAvailable()) { + return false; + } + const native = requireNativeModule(); + return native.isTextModelLoaded(); +} + +/** + * Unload the currently loaded LLM model + * Matches iOS: `RunAnywhere.unloadModel()` + */ +export async function unloadModel(): Promise { + if (!isNativeModuleAvailable()) { + return false; + } + const native = requireNativeModule(); + return native.unloadTextModel(); +} + +/** + * Simple chat - returns just the text response + * Matches Swift SDK: RunAnywhere.chat(_:) + */ +export async function chat(prompt: string): Promise { + const result = await generate(prompt); + return result.text; +} + +/** + * Text generation with options and full metrics + * Matches Swift SDK: RunAnywhere.generate(_:options:) + */ +export async function generate( + prompt: string, + options?: GenerationOptions +): Promise { + if (!isNativeModuleAvailable()) { + throw new Error('Native module not available'); + } + const native = requireNativeModule(); + + const optionsJson = JSON.stringify({ + max_tokens: options?.maxTokens ?? 1000, + temperature: options?.temperature ?? 0.7, + system_prompt: options?.systemPrompt ?? null, + }); + + const resultJson = await native.generate(prompt, optionsJson); + + try { + const result = JSON.parse(resultJson); + return { + text: result.text ?? '', + thinkingContent: result.thinkingContent, + tokensUsed: result.tokensUsed ?? 0, + modelUsed: result.modelUsed ?? 'unknown', + latencyMs: result.latencyMs ?? 0, + executionTarget: result.executionTarget ?? 0, + savedAmount: result.savedAmount ?? 0, + framework: result.framework, + hardwareUsed: result.hardwareUsed ?? 0, + memoryUsed: result.memoryUsed ?? 0, + performanceMetrics: { + timeToFirstTokenMs: result.performanceMetrics?.timeToFirstTokenMs, + tokensPerSecond: result.performanceMetrics?.tokensPerSecond, + inferenceTimeMs: + result.performanceMetrics?.inferenceTimeMs ?? result.latencyMs ?? 0, + }, + thinkingTokens: result.thinkingTokens, + responseTokens: result.responseTokens ?? result.tokensUsed ?? 0, + }; + } catch { + if (resultJson.includes('error')) { + throw new Error(resultJson); + } + return { + text: resultJson, + tokensUsed: 0, + modelUsed: 'unknown', + latencyMs: 0, + executionTarget: ExecutionTarget.OnDevice, + savedAmount: 0, + hardwareUsed: HardwareAcceleration.CPU, + memoryUsed: 0, + performanceMetrics: { + inferenceTimeMs: 0, + }, + responseTokens: 0, + }; + } +} + +/** + * Streaming text generation with async iterator + * + * Returns a LLMStreamingResult containing: + * - stream: AsyncIterable for consuming tokens + * - result: Promise for final metrics + * - cancel: Function to cancel generation + * + * Matches Swift SDK: RunAnywhere.generateStream(_:options:) + * + * Example usage: + * ```typescript + * const streaming = await generateStream(prompt); + * + * // Display tokens in real-time + * for await (const token of streaming.stream) { + * console.log(token); + * } + * + * // Get complete analytics after streaming finishes + * const metrics = await streaming.result; + * console.log(`Speed: ${metrics.tokensPerSecond} tok/s`); + * ``` + */ +export async function generateStream( + prompt: string, + options?: GenerationOptions +): Promise { + if (!isNativeModuleAvailable()) { + throw new Error('Native module not available'); + } + + const native = requireNativeModule(); + const startTime = Date.now(); + let firstTokenTime: number | null = null; + let cancelled = false; + let fullText = ''; + let tokenCount = 0; + let resolveResult: ((result: LLMGenerationResult) => void) | null = null; + let rejectResult: ((error: Error) => void) | null = null; + + const optionsJson = JSON.stringify({ + max_tokens: options?.maxTokens ?? 1000, + temperature: options?.temperature ?? 0.7, + system_prompt: options?.systemPrompt ?? null, + }); + + // Create the result promise + const resultPromise = new Promise((resolve, reject) => { + resolveResult = resolve; + rejectResult = reject; + }); + + // Create async generator for tokens + async function* tokenGenerator(): AsyncGenerator { + const tokenQueue: string[] = []; + let resolver: ((value: IteratorResult) => void) | null = null; + let done = false; + let error: Error | null = null; + + // Start streaming + native.generateStream( + prompt, + optionsJson, + (token: string, isComplete: boolean) => { + if (cancelled) return; + + if (!isComplete && token) { + // Track first token time + if (firstTokenTime === null) { + firstTokenTime = Date.now(); + } + + fullText += token; + tokenCount++; + + if (resolver) { + resolver({ value: token, done: false }); + resolver = null; + } else { + tokenQueue.push(token); + } + } + + if (isComplete) { + done = true; + + // Build final result + const endTime = Date.now(); + const latencyMs = endTime - startTime; + const timeToFirstTokenMs = firstTokenTime ? firstTokenTime - startTime : undefined; + const tokensPerSecond = latencyMs > 0 ? (tokenCount / latencyMs) * 1000 : 0; + + const finalResult: LLMGenerationResult = { + text: fullText, + thinkingContent: undefined, + inputTokens: Math.ceil(prompt.length / 4), + tokensUsed: tokenCount, + modelUsed: 'unknown', + latencyMs, + framework: 'unknown', // Backend-agnostic + tokensPerSecond, + timeToFirstTokenMs, + thinkingTokens: 0, + responseTokens: tokenCount, + }; + + if (resolveResult) { + resolveResult(finalResult); + } + + if (resolver) { + resolver({ value: undefined as unknown as string, done: true }); + resolver = null; + } + + EventBus.publish('Generation', { type: 'completed' }); + } + } + ).catch((err: Error) => { + error = err; + done = true; + if (rejectResult) { + rejectResult(err); + } + if (resolver) { + resolver({ value: undefined as unknown as string, done: true }); + } + EventBus.publish('Generation', { type: 'failed', error: err.message }); + }); + + // Yield tokens + while (!done || tokenQueue.length > 0) { + if (tokenQueue.length > 0) { + yield tokenQueue.shift()!; + } else if (!done) { + const result = await new Promise>((resolve) => { + resolver = resolve; + }); + if (result.done) break; + yield result.value; + } + } + + if (error) { + throw error; + } + } + + // Cancel function + const cancel = (): void => { + cancelled = true; + cancelGeneration(); + }; + + return { + stream: tokenGenerator(), + result: resultPromise, + cancel, + }; +} + +/** + * Cancel ongoing text generation + */ +export function cancelGeneration(): void { + if (!isNativeModuleAvailable()) { + return; + } + const native = requireNativeModule(); + native.cancelGeneration(); +} diff --git a/sdk/runanywhere-react-native/packages/core/src/Public/Extensions/RunAnywhere+VAD.ts b/sdk/runanywhere-react-native/packages/core/src/Public/Extensions/RunAnywhere+VAD.ts new file mode 100644 index 000000000..452a018f6 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/Public/Extensions/RunAnywhere+VAD.ts @@ -0,0 +1,359 @@ +/** + * RunAnywhere+VAD.ts + * + * Voice Activity Detection extension for RunAnywhere SDK. + * Matches iOS: RunAnywhere+VAD.swift + */ + +import { requireNativeModule, isNativeModuleAvailable } from '../../native'; +import { EventBus } from '../Events'; +import { SDKLogger } from '../../Foundation/Logging/Logger/SDKLogger'; +import type { + VADConfiguration, + VADResult, + SpeechActivityEvent, + VADSpeechActivityCallback, + VADAudioBufferCallback, + VADState, +} from '../../types/VADTypes'; + +const logger = new SDKLogger('RunAnywhere.VAD'); + +// ============================================================================ +// VAD State Management +// ============================================================================ + +// Internal VAD state +let vadState: VADState = { + isInitialized: false, + isRunning: false, + isSpeechActive: false, + currentProbability: 0, +}; + +// Callbacks +let speechActivityCallback: VADSpeechActivityCallback | null = null; +let audioBufferCallback: VADAudioBufferCallback | null = null; + +// ============================================================================ +// VAD Initialization +// ============================================================================ + +/** + * Initialize VAD with default configuration + * Matches Swift SDK: RunAnywhere.initializeVAD() + */ +export async function initializeVAD(config?: VADConfiguration): Promise { + if (!isNativeModuleAvailable()) { + throw new Error('Native module not available'); + } + + logger.info('Initializing VAD...'); + + const native = requireNativeModule(); + + // If a config is provided, configure VAD first + if (config) { + const configJson = JSON.stringify({ + sampleRate: config.sampleRate ?? 16000, + frameLength: config.frameLength ?? 0.1, + energyThreshold: config.energyThreshold ?? 0.005, + }); + + // Load VAD model if path provided, otherwise use default + const loaded = await native.loadVADModel('default', configJson); + if (!loaded) { + throw new Error('Failed to initialize VAD'); + } + } + + vadState.isInitialized = true; + logger.info('VAD initialized'); + + EventBus.publish('Voice', { type: 'vadInitialized' }); +} + +/** + * Check if VAD is ready + * Matches Swift SDK: RunAnywhere.isVADReady + */ +export async function isVADReady(): Promise { + if (!isNativeModuleAvailable()) { + return false; + } + + const native = requireNativeModule(); + return native.isVADModelLoaded(); +} + +/** + * Get current VAD state + */ +export function getVADState(): VADState { + return { ...vadState }; +} + +// ============================================================================ +// VAD Model Loading +// ============================================================================ + +/** + * Load a VAD model + */ +export async function loadVADModel( + modelPath: string, + config?: VADConfiguration +): Promise { + if (!isNativeModuleAvailable()) { + return false; + } + + logger.info(`Loading VAD model: ${modelPath}`); + const native = requireNativeModule(); + + const configJson = config ? JSON.stringify(config) : undefined; + const result = await native.loadVADModel(modelPath, configJson); + + if (result) { + vadState.isInitialized = true; + logger.info('VAD model loaded'); + } + + return result; +} + +/** + * Check if a VAD model is loaded + */ +export async function isVADModelLoaded(): Promise { + if (!isNativeModuleAvailable()) { + return false; + } + const native = requireNativeModule(); + return native.isVADModelLoaded(); +} + +/** + * Unload the current VAD model + */ +export async function unloadVADModel(): Promise { + if (!isNativeModuleAvailable()) { + return false; + } + + const native = requireNativeModule(); + const result = await native.unloadVADModel(); + + if (result) { + vadState.isInitialized = false; + vadState.isRunning = false; + vadState.isSpeechActive = false; + logger.info('VAD model unloaded'); + } + + return result; +} + +// ============================================================================ +// Speech Detection +// ============================================================================ + +/** + * Detect speech in audio samples + * Matches Swift SDK: RunAnywhere.detectSpeech(in:) + * + * @param samples Float32Array of audio samples + * @returns Whether speech was detected + */ +export async function detectSpeech(samples: Float32Array): Promise { + if (!isNativeModuleAvailable()) { + throw new Error('Native module not available'); + } + + // Convert Float32Array to base64 + const bytes = new Uint8Array(samples.buffer); + let binary = ''; + for (let i = 0; i < bytes.byteLength; i++) { + const byte = bytes[i]; + if (byte !== undefined) { + binary += String.fromCharCode(byte); + } + } + const audioBase64 = btoa(binary); + + const result = await processVAD(audioBase64); + + // Update state + const wasSpeechActive = vadState.isSpeechActive; + vadState.isSpeechActive = result.isSpeech; + vadState.currentProbability = result.probability; + + // Emit speech activity events + if (result.isSpeech && !wasSpeechActive) { + if (speechActivityCallback) { + speechActivityCallback('started'); + } + EventBus.publish('Voice', { type: 'speechStarted' }); + } else if (!result.isSpeech && wasSpeechActive) { + if (speechActivityCallback) { + speechActivityCallback('ended'); + } + EventBus.publish('Voice', { type: 'speechEnded' }); + } + + // Forward to audio buffer callback if set + if (audioBufferCallback) { + audioBufferCallback(samples); + } + + return result.isSpeech; +} + +/** + * Process audio for voice activity detection + * Returns detailed VAD result + */ +export async function processVAD( + audioData: string | ArrayBuffer, + sampleRate: number = 16000 +): Promise { + if (!isNativeModuleAvailable()) { + return { isSpeech: false, probability: 0 }; + } + const native = requireNativeModule(); + + let audioBase64: string; + if (typeof audioData === 'string') { + audioBase64 = audioData; + } else { + const bytes = new Uint8Array(audioData); + let binary = ''; + for (let i = 0; i < bytes.byteLength; i++) { + const byte = bytes[i]; + if (byte !== undefined) { + binary += String.fromCharCode(byte); + } + } + audioBase64 = btoa(binary); + } + + const optionsJson = JSON.stringify({ sampleRate }); + const resultJson = await native.processVAD(audioBase64, optionsJson); + + try { + const result = JSON.parse(resultJson); + return { + isSpeech: result.isSpeech ?? false, + probability: result.speechProbability ?? result.probability ?? 0, + startTime: result.startTime, + endTime: result.endTime, + }; + } catch { + return { isSpeech: false, probability: 0 }; + } +} + +// ============================================================================ +// VAD Control +// ============================================================================ + +/** + * Start VAD processing + * Matches Swift SDK: RunAnywhere.startVAD() + */ +export async function startVAD(): Promise { + if (!vadState.isInitialized) { + await initializeVAD(); + } + + vadState.isRunning = true; + logger.info('VAD started'); + + EventBus.publish('Voice', { type: 'vadStarted' }); +} + +/** + * Stop VAD processing + * Matches Swift SDK: RunAnywhere.stopVAD() + */ +export async function stopVAD(): Promise { + vadState.isRunning = false; + vadState.isSpeechActive = false; + vadState.currentProbability = 0; + + logger.info('VAD stopped'); + EventBus.publish('Voice', { type: 'vadStopped' }); +} + +/** + * Reset VAD state + */ +export async function resetVAD(): Promise { + if (!isNativeModuleAvailable()) { + return; + } + + const native = requireNativeModule(); + await native.resetVAD(); + + vadState.isSpeechActive = false; + vadState.currentProbability = 0; + + logger.debug('VAD state reset'); +} + +// ============================================================================ +// Callbacks +// ============================================================================ + +/** + * Set VAD speech activity callback + * Matches Swift SDK: RunAnywhere.setVADSpeechActivityCallback(_:) + * + * @param callback Callback invoked when speech state changes + */ +export function setVADSpeechActivityCallback( + callback: VADSpeechActivityCallback | null +): void { + speechActivityCallback = callback; + logger.debug('VAD speech activity callback set'); +} + +/** + * Set VAD audio buffer callback + * Matches Swift SDK: RunAnywhere.setVADAudioBufferCallback(_:) + * + * @param callback Callback invoked with audio samples + */ +export function setVADAudioBufferCallback( + callback: VADAudioBufferCallback | null +): void { + audioBufferCallback = callback; + logger.debug('VAD audio buffer callback set'); +} + +// ============================================================================ +// Cleanup +// ============================================================================ + +/** + * Cleanup VAD resources + * Matches Swift SDK: RunAnywhere.cleanupVAD() + */ +export async function cleanupVAD(): Promise { + await stopVAD(); + await unloadVADModel(); + + speechActivityCallback = null; + audioBufferCallback = null; + + vadState = { + isInitialized: false, + isRunning: false, + isSpeechActive: false, + currentProbability: 0, + }; + + logger.info('VAD cleaned up'); + EventBus.publish('Voice', { type: 'vadCleanedUp' }); +} diff --git a/sdk/runanywhere-react-native/packages/core/src/Public/Extensions/RunAnywhere+VoiceAgent.ts b/sdk/runanywhere-react-native/packages/core/src/Public/Extensions/RunAnywhere+VoiceAgent.ts new file mode 100644 index 000000000..921570ca4 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/Public/Extensions/RunAnywhere+VoiceAgent.ts @@ -0,0 +1,225 @@ +/** + * RunAnywhere+VoiceAgent.ts + * + * Voice Agent extension for the full voice pipeline. + * Delegates to native VoiceAgentBridge. + * + * Reference: sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/VoiceAgent/RunAnywhere+VoiceAgent.swift + */ + +import { requireNativeModule, isNativeModuleAvailable } from '../../native'; +import { SDKLogger } from '../../Foundation/Logging/Logger/SDKLogger'; +import type { + VoiceAgentConfig, + VoiceAgentComponentStates, + VoiceTurnResult, +} from '../../types/VoiceAgentTypes'; + +const logger = new SDKLogger('RunAnywhere.VoiceAgent'); + +/** + * Get voice agent component states + * @returns Component load states for STT, LLM, TTS + */ +export async function getVoiceAgentComponentStates(): Promise { + if (!isNativeModuleAvailable()) { + throw new Error('Native module not available'); + } + + const native = requireNativeModule(); + + try { + const resultJson = await native.getVoiceAgentComponentStates(); + return JSON.parse(resultJson) as VoiceAgentComponentStates; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + logger.error(`Failed to get component states: ${msg}`); + throw error; + } +} + +/** + * Check if all voice components are ready + */ +export async function areAllVoiceComponentsReady(): Promise { + const states = await getVoiceAgentComponentStates(); + return states.isFullyReady; +} + +/** + * Initialize voice agent with configuration + * @param config Voice agent configuration + * @returns true if initialized successfully + */ +export async function initializeVoiceAgent( + config: VoiceAgentConfig +): Promise { + if (!isNativeModuleAvailable()) { + throw new Error('Native module not available'); + } + + const native = requireNativeModule(); + + try { + logger.info('Initializing voice agent...'); + const result = await native.initializeVoiceAgent(JSON.stringify(config)); + if (result) { + logger.info('Voice agent initialized successfully'); + } + return result; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + logger.error(`Failed to initialize voice agent: ${msg}`); + throw error; + } +} + +/** + * Initialize voice agent using already-loaded models + * Uses the current STT, LLM, and TTS models + * @returns true if initialized successfully + */ +export async function initializeVoiceAgentWithLoadedModels(): Promise { + if (!isNativeModuleAvailable()) { + throw new Error('Native module not available'); + } + + const native = requireNativeModule(); + + try { + logger.info('Initializing voice agent with loaded models...'); + const result = await native.initializeVoiceAgentWithLoadedModels(); + if (result) { + logger.info('Voice agent initialized with loaded models'); + } + return result; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + logger.error(`Failed to initialize voice agent: ${msg}`); + throw error; + } +} + +/** + * Check if voice agent is ready + */ +export async function isVoiceAgentReady(): Promise { + if (!isNativeModuleAvailable()) { + return false; + } + + const native = requireNativeModule(); + return native.isVoiceAgentReady(); +} + +/** + * Process a complete voice turn: audio -> transcription -> response -> speech + * @param audioData Audio data as ArrayBuffer or base64 string + * @returns Voice turn result + */ +export async function processVoiceTurn( + audioData: ArrayBuffer | string +): Promise { + if (!isNativeModuleAvailable()) { + throw new Error('Native module not available'); + } + + const native = requireNativeModule(); + + try { + // Convert to base64 if ArrayBuffer + let base64Audio: string; + if (audioData instanceof ArrayBuffer) { + const bytes = new Uint8Array(audioData); + base64Audio = btoa(String.fromCharCode(...bytes)); + } else { + base64Audio = audioData; + } + + const resultJson = await native.processVoiceTurn(base64Audio); + const result = JSON.parse(resultJson); + + return { + speechDetected: result.speechDetected === true || result.speechDetected === 'true', + transcription: result.transcription || '', + response: result.response || '', + synthesizedAudio: result.synthesizedAudio || undefined, + sampleRate: result.sampleRate || 16000, + }; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + logger.error(`Voice turn failed: ${msg}`); + throw error; + } +} + +/** + * Transcribe audio using voice agent (voice agent must be initialized) + * @param audioData Audio data as ArrayBuffer or base64 string + * @returns Transcription text + */ +export async function voiceAgentTranscribe( + audioData: ArrayBuffer | string +): Promise { + if (!isNativeModuleAvailable()) { + throw new Error('Native module not available'); + } + + const native = requireNativeModule(); + + let base64Audio: string; + if (audioData instanceof ArrayBuffer) { + const bytes = new Uint8Array(audioData); + base64Audio = btoa(String.fromCharCode(...bytes)); + } else { + base64Audio = audioData; + } + + return native.voiceAgentTranscribe(base64Audio); +} + +/** + * Generate response using voice agent LLM + * @param prompt Input text + * @returns Generated response text + */ +export async function voiceAgentGenerateResponse( + prompt: string +): Promise { + if (!isNativeModuleAvailable()) { + throw new Error('Native module not available'); + } + + const native = requireNativeModule(); + return native.voiceAgentGenerateResponse(prompt); +} + +/** + * Synthesize speech using voice agent TTS + * @param text Text to synthesize + * @returns Base64-encoded audio data + */ +export async function voiceAgentSynthesizeSpeech( + text: string +): Promise { + if (!isNativeModuleAvailable()) { + throw new Error('Native module not available'); + } + + const native = requireNativeModule(); + return native.voiceAgentSynthesizeSpeech(text); +} + +/** + * Cleanup voice agent resources + */ +export async function cleanupVoiceAgent(): Promise { + if (!isNativeModuleAvailable()) { + return; + } + + const native = requireNativeModule(); + logger.info('Cleaning up voice agent...'); + await native.cleanupVoiceAgent(); + logger.info('Voice agent cleaned up'); +} diff --git a/sdk/runanywhere-react-native/packages/core/src/Public/Extensions/RunAnywhere+VoiceSession.ts b/sdk/runanywhere-react-native/packages/core/src/Public/Extensions/RunAnywhere+VoiceSession.ts new file mode 100644 index 000000000..56f59753b --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/Public/Extensions/RunAnywhere+VoiceSession.ts @@ -0,0 +1,159 @@ +/** + * RunAnywhere+VoiceSession.ts + * + * High-level voice session API for simplified voice assistant integration. + * Handles audio capture, VAD, and processing internally. + * + * Reference: sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/VoiceAgent/RunAnywhere+VoiceSession.swift + * + * Usage: + * ```typescript + * // Start a voice session with async iterator + * const session = await startVoiceSession(); + * + * for await (const event of session.events()) { + * switch (event.type) { + * case 'listening': + * updateAudioMeter(event.audioLevel); + * break; + * case 'processing': + * showProcessingIndicator(); + * break; + * case 'turnCompleted': + * updateUI(event.transcription, event.response); + * break; + * } + * } + * + * // Or use callbacks + * const session = await startVoiceSessionWithCallback({}, (event) => { + * // Handle event + * }); + * + * // Stop the session + * session.stop(); + * ``` + */ + +import { SDKLogger } from '../../Foundation/Logging/Logger/SDKLogger'; +import { + VoiceSessionHandle, + DEFAULT_VOICE_SESSION_CONFIG, + type VoiceSessionConfig, + type VoiceSessionEvent, + type VoiceSessionEventCallback, +} from '../../Features/VoiceSession'; + +const logger = new SDKLogger('RunAnywhere.VoiceSession'); + +// Re-export types for convenience +export type { + VoiceSessionConfig, + VoiceSessionEvent, + VoiceSessionEventCallback +}; +export { DEFAULT_VOICE_SESSION_CONFIG }; + +/** + * Start a voice session with async event iteration + * + * This is the simplest way to integrate voice assistant. + * The session handles audio capture, VAD, and processing internally. + * + * Example: + * ```typescript + * const session = await startVoiceSession(); + * + * // Consume events using async iteration + * for await (const event of session.events()) { + * switch (event.type) { + * case 'listening': + * audioMeter = event.audioLevel ?? 0; + * break; + * case 'processing': + * status = 'Processing...'; + * break; + * case 'turnCompleted': + * userText = event.transcription ?? ''; + * assistantText = event.response ?? ''; + * break; + * case 'stopped': + * // Session ended + * break; + * } + * } + * ``` + * + * @param config Session configuration (optional) + * @returns Session handle with events iterator + */ +export async function startVoiceSession( + config: VoiceSessionConfig = {} +): Promise { + logger.info('Starting voice session...'); + + const session = new VoiceSessionHandle(config); + await session.start(); + + logger.info('Voice session started'); + return session; +} + +/** + * Start a voice session with callback-based event handling + * + * Alternative API using callbacks instead of async iterator. + * You can also pass `onEvent` directly in the config. + * + * Example: + * ```typescript + * // Using onEvent in config (preferred) + * const session = await startVoiceSession({ + * onEvent: (event) => { + * switch (event.type) { + * case 'listening': setAudioLevel(event.audioLevel ?? 0); break; + * case 'transcribed': setUserText(event.transcription ?? ''); break; + * case 'responded': setAssistantText(event.response ?? ''); break; + * } + * } + * }); + * + * // Or using separate callback parameter + * const session = await startVoiceSessionWithCallback({}, (event) => { ... }); + * + * // Later... + * session.stop(); + * ``` + * + * @param config Session configuration + * @param onEvent Callback for each event + * @returns Session handle for control + */ +export async function startVoiceSessionWithCallback( + config: VoiceSessionConfig = {}, + onEvent: VoiceSessionEventCallback +): Promise { + logger.info('Starting voice session with callback...'); + + // Merge the callback into config + const configWithCallback = { ...config, onEvent }; + const session = new VoiceSessionHandle(configWithCallback); + await session.start(); + + logger.info('Voice session with callback started'); + return session; +} + +/** + * Create a voice session handle without starting it + * + * Useful when you want to configure the session before starting. + * + * @param config Session configuration + * @returns Session handle (not started) + */ +export function createVoiceSession( + config: VoiceSessionConfig = {} +): VoiceSessionHandle { + return new VoiceSessionHandle(config); +} diff --git a/sdk/runanywhere-react-native/packages/core/src/Public/Extensions/index.ts b/sdk/runanywhere-react-native/packages/core/src/Public/Extensions/index.ts new file mode 100644 index 000000000..40c4dfa5e --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/Public/Extensions/index.ts @@ -0,0 +1,151 @@ +/** + * RunAnywhere Extensions + * + * Re-exports all extension modules for convenient importing. + */ + +// Text Generation (LLM) +export { + loadModel, + isModelLoaded, + unloadModel, + chat, + generate, + generateStream, + cancelGeneration, +} from './RunAnywhere+TextGeneration'; + +// Speech-to-Text +export { + loadSTTModel, + isSTTModelLoaded, + unloadSTTModel, + transcribe, + transcribeSimple, + transcribeBuffer, + transcribeStream, + transcribeFile, + startStreamingSTT, + stopStreamingSTT, + isStreamingSTT, +} from './RunAnywhere+STT'; + +// Text-to-Speech +export { + loadTTSModel, + loadTTSVoice, + unloadTTSVoice, + isTTSModelLoaded, + isTTSVoiceLoaded, + unloadTTSModel, + synthesize, + synthesizeStream, + speak, + isSpeaking, + stopSpeaking, + availableTTSVoices, + getTTSVoices, + getTTSVoiceInfo, + stopSynthesis, + cancelTTS, + cleanupTTS, +} from './RunAnywhere+TTS'; + +// Voice Activity Detection +export { + initializeVAD, + isVADReady, + loadVADModel, + isVADModelLoaded, + unloadVADModel, + detectSpeech, + processVAD, + startVAD, + stopVAD, + resetVAD, + setVADSpeechActivityCallback, + setVADAudioBufferCallback, + cleanupVAD, + getVADState, +} from './RunAnywhere+VAD'; + +// Voice Agent +export { + initializeVoiceAgent, + initializeVoiceAgentWithLoadedModels, + isVoiceAgentReady, + getVoiceAgentComponentStates, + areAllVoiceComponentsReady, + processVoiceTurn, + voiceAgentTranscribe, + voiceAgentGenerateResponse, + voiceAgentSynthesizeSpeech, + cleanupVoiceAgent, +} from './RunAnywhere+VoiceAgent'; + +// Voice Session +export { + startVoiceSession, + startVoiceSessionWithCallback, + createVoiceSession, + DEFAULT_VOICE_SESSION_CONFIG, +} from './RunAnywhere+VoiceSession'; +export type { + VoiceSessionConfig, + VoiceSessionEvent, + VoiceSessionEventCallback +} from './RunAnywhere+VoiceSession'; + +// Structured Output +export { + generateStructured, + generateStructuredStream, + generate as generateStructuredType, + extractEntities, + classify, +} from './RunAnywhere+StructuredOutput'; +export type { + StreamToken, + StructuredOutputStreamResult +} from './RunAnywhere+StructuredOutput'; + +// Logging +export { setLogLevel } from './RunAnywhere+Logging'; + +// Storage +export { getStorageInfo, clearCache } from './RunAnywhere+Storage'; + +// Models +export { + getAvailableModels, + getModelInfo, + isModelDownloaded, + downloadModel, + cancelDownload, + deleteModel, +} from './RunAnywhere+Models'; + +// Audio Utilities +export { + requestAudioPermission, + startRecording, + stopRecording, + cancelRecording, + playAudio, + stopPlayback, + pausePlayback, + resumePlayback, + createWavFromPCMFloat32, + cleanup as cleanupAudio, + formatDuration, + AUDIO_SAMPLE_RATE, + TTS_SAMPLE_RATE, +} from './RunAnywhere+Audio'; +export type { + RecordingCallbacks, + PlaybackCallbacks, + RecordingResult, +} from './RunAnywhere+Audio'; + +// Re-export Audio as namespace for RunAnywhere.Audio access +export * as Audio from './RunAnywhere+Audio'; diff --git a/sdk/runanywhere-react-native/packages/core/src/Public/RunAnywhere.ts b/sdk/runanywhere-react-native/packages/core/src/Public/RunAnywhere.ts new file mode 100644 index 000000000..d5a803783 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/Public/RunAnywhere.ts @@ -0,0 +1,717 @@ +/** + * RunAnywhere React Native SDK - Main Entry Point + * + * Thin wrapper over native commons. + * All business logic is in native C++ (runanywhere-commons). + * + * Reference: sdk/runanywhere-swift/Sources/RunAnywhere/Public/RunAnywhere.swift + */ + +import { Platform } from 'react-native'; +import { EventBus } from './Events'; +import { requireNativeModule, isNativeModuleAvailable } from '../native'; +import { SDKEnvironment } from '../types'; +import { ModelRegistry } from '../services/ModelRegistry'; +import { ServiceContainer } from '../Foundation/DependencyInjection/ServiceContainer'; +import { SDKLogger } from '../Foundation/Logging/Logger/SDKLogger'; +import { SDKConstants } from '../Foundation/Constants'; +import { FileSystem } from '../services/FileSystem'; +import { + HTTPService, + SDKEnvironment as NetworkSDKEnvironment, + TelemetryService, +} from '../services/Network'; + +import type { + InitializationState, + SDKInitParams, +} from '../Foundation/Initialization'; +import { + createInitialState, + markCoreInitialized, + markServicesInitialized, + markInitializationFailed, + resetState, +} from '../Foundation/Initialization'; +import type { ModelInfo, SDKInitOptions } from '../types'; + +// Import extensions +import * as TextGeneration from './Extensions/RunAnywhere+TextGeneration'; +import * as STT from './Extensions/RunAnywhere+STT'; +import * as TTS from './Extensions/RunAnywhere+TTS'; +import * as VAD from './Extensions/RunAnywhere+VAD'; +import * as Storage from './Extensions/RunAnywhere+Storage'; +import * as Models from './Extensions/RunAnywhere+Models'; +import * as Logging from './Extensions/RunAnywhere+Logging'; +import * as VoiceAgent from './Extensions/RunAnywhere+VoiceAgent'; +import * as VoiceSession from './Extensions/RunAnywhere+VoiceSession'; +import * as StructuredOutput from './Extensions/RunAnywhere+StructuredOutput'; +import * as Audio from './Extensions/RunAnywhere+Audio'; + +const logger = new SDKLogger('RunAnywhere'); + +// ============================================================================ +// Internal State +// ============================================================================ + +let initState: InitializationState = createInitialState(); +let cachedDeviceId: string = ''; + +// ============================================================================ +// Conversation Helper +// ============================================================================ + +/** + * Simple conversation manager for multi-turn conversations + */ +export class Conversation { + private messages: string[] = []; + + async send(message: string): Promise { + this.messages.push(`User: ${message}`); + const contextPrompt = this.messages.join('\n') + '\nAssistant:'; + const result = await RunAnywhere.generate(contextPrompt); + this.messages.push(`Assistant: ${result.text}`); + return result.text; + } + + get history(): string[] { + return [...this.messages]; + } + + clear(): void { + this.messages = []; + } +} + +// ============================================================================ +// RunAnywhere SDK +// ============================================================================ + +/** + * The RunAnywhere SDK for React Native + */ +export const RunAnywhere = { + // ============================================================================ + // Event Access + // ============================================================================ + + events: EventBus, + + // ============================================================================ + // SDK State + // ============================================================================ + + get isSDKInitialized(): boolean { + return initState.isCoreInitialized; + }, + + get areServicesReady(): boolean { + return initState.hasCompletedServicesInit; + }, + + get currentEnvironment(): SDKEnvironment | null { + return initState.environment; + }, + + get version(): string { + return SDKConstants.version; + }, + + // ============================================================================ + // SDK Initialization + // ============================================================================ + + async initialize(options: SDKInitOptions): Promise { + const environment = options.environment ?? SDKEnvironment.Production; + + // Fail fast: API key is required for production/staging environments + // Development mode uses C++ dev config (Supabase credentials) instead + if (environment !== SDKEnvironment.Development && !options.apiKey) { + const envName = environment === SDKEnvironment.Staging ? 'staging' : 'production'; + throw new Error( + `API key is required for ${envName} environment. ` + + `Pass apiKey in initialize() options or use SDKEnvironment.Development for local testing.` + ); + } + + const initParams: SDKInitParams = { + apiKey: options.apiKey, + baseURL: options.baseURL, + environment, + }; + + EventBus.publish('Initialization', { type: 'started' }); + logger.info('SDK initialization starting...'); + + if (!isNativeModuleAvailable()) { + logger.warning('Native module not available'); + initState = markInitializationFailed( + initState, + new Error('Native module not available') + ); + throw new Error('Native module not available'); + } + + const native = requireNativeModule(); + + try { + // Get documents path for model storage (matches Swift SDK's base directory setup) + // Uses react-native-fs for the documents directory + const documentsPath = FileSystem.isAvailable() + ? FileSystem.getDocumentsDirectory() + : ''; + + // Configure network layer BEFORE native initialization + // This ensures HTTP is ready when C++ callbacks need it + const envString = environment === SDKEnvironment.Development ? 'development' + : environment === SDKEnvironment.Staging ? 'staging' + : 'production'; + + // Map environment string to SDKEnvironment enum for HTTPService + const networkEnv = environment === SDKEnvironment.Development + ? NetworkSDKEnvironment.Development + : environment === SDKEnvironment.Staging + ? NetworkSDKEnvironment.Staging + : NetworkSDKEnvironment.Production; + + // Configure HTTPService with network settings + HTTPService.shared.configure({ + baseURL: options.baseURL || 'https://api.runanywhere.ai', + apiKey: options.apiKey ?? '', + environment: networkEnv, + }); + + // Configure dev mode if Supabase credentials provided + if (options.supabaseURL && options.supabaseKey) { + HTTPService.shared.configureDev({ + supabaseURL: options.supabaseURL, + supabaseKey: options.supabaseKey, + }); + } + + // For development mode, Supabase credentials will be passed to native + if (environment === SDKEnvironment.Development && options.supabaseURL) { + logger.debug('Development mode - Supabase config provided'); + } + + // Initialize with config + // Note: Backend registration (llamacpp, onnx) is done by their respective packages + const configJson = JSON.stringify({ + apiKey: options.apiKey, + baseURL: options.baseURL, + environment: envString, + documentsPath: documentsPath, // Required for model paths (mirrors Swift SDK) + sdkVersion: SDKConstants.version, // Centralized version for C++ layer + supabaseURL: options.supabaseURL, // For development mode + supabaseKey: options.supabaseKey, // For development mode + }); + + await native.initialize(configJson); + + // Initialize model registry + await ModelRegistry.initialize(); + + // Cache device ID early (uses secure storage / Keychain) + try { + cachedDeviceId = await native.getPersistentDeviceUUID(); + logger.debug(`Device ID cached: ${cachedDeviceId.substring(0, 8)}...`); + } catch (e) { + logger.warning('Failed to get persistent device UUID'); + } + + // Initialize telemetry with device ID + TelemetryService.shared.configure(cachedDeviceId, networkEnv); + TelemetryService.shared.trackSDKInit(envString, true); + + // For production/staging mode, authenticate with backend to get JWT tokens + // This matches Swift SDK's CppBridge.Auth.authenticate(apiKey:) in setupHTTP() + if (environment !== SDKEnvironment.Development && options.apiKey) { + try { + logger.info('Authenticating with backend (production/staging mode)...'); + const authenticated = await this._authenticateWithBackend( + options.apiKey, + options.baseURL || 'https://api.runanywhere.ai', + cachedDeviceId + ); + if (authenticated) { + logger.info('Authentication successful - JWT tokens obtained'); + } else { + logger.warning('Authentication failed - API requests may fail'); + } + } catch (authErr) { + logger.warning(`Authentication failed (non-fatal): ${authErr instanceof Error ? authErr.message : String(authErr)}`); + } + } + + // Trigger device registration (non-blocking, best-effort) + // This matches Swift SDK's CppBridge.Device.registerIfNeeded(environment:) + // Uses native C++ → platform HTTP (exactly like Swift) + this._registerDeviceIfNeeded(environment, options.supabaseKey).catch(err => { + logger.warning(`Device registration failed (non-fatal): ${err.message}`); + }); + + ServiceContainer.shared.markInitialized(); + initState = markCoreInitialized(initState, initParams, 'core'); + initState = markServicesInitialized(initState); + + logger.info('SDK initialized successfully'); + EventBus.publish('Initialization', { type: 'completed' }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + logger.error(`SDK initialization failed: ${msg}`); + initState = markInitializationFailed(initState, error as Error); + EventBus.publish('Initialization', { type: 'failed', error: msg }); + throw error; + } + }, + + /** + * Register device with backend if not already registered + * Uses native C++ DeviceBridge + platform HTTP (URLSession/OkHttp) + * Exactly matches Swift SDK's CppBridge.Device.registerIfNeeded(environment:) + * @internal + */ + /** + * Authenticate with backend to get JWT access/refresh tokens + * This matches Swift SDK's CppBridge.Auth.authenticate(apiKey:) + * @internal + */ + async _authenticateWithBackend( + apiKey: string, + baseURL: string, + deviceId: string + ): Promise { + try { + const endpoint = '/api/v1/auth/sdk/authenticate'; + const fullUrl = baseURL.replace(/\/$/, '') + endpoint; + + // Use actual platform (ios/android) as backend only accepts these values + // This matches how Swift sends 'ios' and Kotlin sends 'android' + const platform = Platform.OS === 'ios' ? 'ios' : 'android'; + + const requestBody = JSON.stringify({ + api_key: apiKey, + device_id: deviceId, + platform: platform, + sdk_version: SDKConstants.version, + }); + + logger.debug(`Auth request to: ${fullUrl}`); + + const response = await fetch(fullUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: requestBody, + }); + + if (!response.ok) { + const errorText = await response.text(); + logger.error(`Authentication failed: HTTP ${response.status} - ${errorText}`); + return false; + } + + const authResponse = await response.json() as { + access_token: string; + refresh_token: string; + expires_in: number; + device_id: string; + organization_id: string; + user_id?: string; + token_type: string; + }; + + // Store tokens in HTTPService for subsequent requests + HTTPService.shared.setToken(authResponse.access_token); + + // Store tokens in C++ AuthBridge for native HTTP requests (telemetry, device registration) + try { + const native = requireNativeModule(); + if (native && typeof native.setAuthTokens === 'function') { + await native.setAuthTokens(JSON.stringify(authResponse)); + logger.debug('Auth tokens set in C++ AuthBridge'); + } else { + logger.warning('setAuthTokens not available on native module - tokens stored in JS only'); + } + } catch (nativeErr) { + logger.warning(`Failed to set auth tokens in native: ${nativeErr}`); + // Continue - tokens are still stored in HTTPService + } + + // Store tokens in secure storage for persistence + try { + const { SecureStorageService } = await import('../Foundation/Security/SecureStorageService'); + await SecureStorageService.storeAuthTokens( + authResponse.access_token, + authResponse.refresh_token, + authResponse.expires_in + ); + } catch (storageErr) { + logger.warning(`Failed to persist tokens: ${storageErr}`); + // Continue - tokens are still in memory + } + + logger.info(`Authentication successful! Token expires in ${authResponse.expires_in}s`); + return true; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + logger.error(`Authentication error: ${msg}`); + return false; + } + }, + + async _registerDeviceIfNeeded( + environment: SDKEnvironment, + supabaseKey?: string + ): Promise { + const envString = environment === SDKEnvironment.Development ? 'development' + : environment === SDKEnvironment.Staging ? 'staging' + : 'production'; + + try { + const native = requireNativeModule(); + + // Call native registerDevice which goes through: + // JS → C++ DeviceBridge → rac_device_manager_register_if_needed → http_post callback → native HTTP + // This exactly mirrors Swift's flow! + const success = await native.registerDevice(JSON.stringify({ + environment: envString, + supabaseKey: supabaseKey || '', + buildToken: '', // TODO: Add build token support if needed + })); + + if (success) { + logger.info('Device registered successfully via native'); + } else { + logger.warning('Device registration returned false'); + } + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + logger.warning(`Device registration error: ${msg}`); + } + }, + + async destroy(): Promise { + // Telemetry is handled by native layer - no JS-level shutdown needed + TelemetryService.shared.setEnabled(false); + + if (isNativeModuleAvailable()) { + const native = requireNativeModule(); + await native.destroy(); + } + ServiceContainer.shared.reset(); + initState = resetState(); + }, + + async reset(): Promise { + await this.destroy(); + }, + + async isInitialized(): Promise { + if (!isNativeModuleAvailable()) return false; + const native = requireNativeModule(); + return native.isInitialized(); + }, + + // ============================================================================ + // Authentication Info (Production/Staging only) + // Matches Swift SDK: RunAnywhere.getUserId(), getOrganizationId(), etc. + // ============================================================================ + + /** + * Get current user ID from authentication + * @returns User ID if authenticated, empty string otherwise + */ + async getUserId(): Promise { + if (!isNativeModuleAvailable()) return ''; + const native = requireNativeModule(); + const userId = await native.getUserId(); + return userId ?? ''; + }, + + /** + * Get current organization ID from authentication + * @returns Organization ID if authenticated, empty string otherwise + */ + async getOrganizationId(): Promise { + if (!isNativeModuleAvailable()) return ''; + const native = requireNativeModule(); + const orgId = await native.getOrganizationId(); + return orgId ?? ''; + }, + + /** + * Check if currently authenticated + * @returns true if authenticated with valid token + */ + async isAuthenticated(): Promise { + if (!isNativeModuleAvailable()) return false; + const native = requireNativeModule(); + return native.isAuthenticated(); + }, + + /** + * Check if device is registered with backend + */ + async isDeviceRegistered(): Promise { + if (!isNativeModuleAvailable()) return false; + const native = requireNativeModule(); + return native.isDeviceRegistered(); + }, + + /** + * Clear device registration flag (for testing) + * Forces re-registration on next SDK init + */ + async clearDeviceRegistration(): Promise { + if (!isNativeModuleAvailable()) return false; + const native = requireNativeModule(); + return native.clearDeviceRegistration(); + }, + + /** + * Get device ID (Keychain-persisted, survives reinstalls) + * Note: This is async because it uses secure storage + */ + get deviceId(): string { + // Return cached value if available (set during init) + return cachedDeviceId; + }, + + /** + * Get device ID asynchronously (Keychain-persisted, survives reinstalls) + */ + async getDeviceId(): Promise { + if (cachedDeviceId) { + return cachedDeviceId; + } + try { + const native = requireNativeModule(); + const uuid = await native.getPersistentDeviceUUID(); + cachedDeviceId = uuid; + return uuid; + } catch { + return ''; + } + }, + + // ============================================================================ + // Logging (Delegated to Extension) + // ============================================================================ + + setLogLevel: Logging.setLogLevel, + + // ============================================================================ + // Text Generation - LLM (Delegated to Extension) + // ============================================================================ + + loadModel: TextGeneration.loadModel, + isModelLoaded: TextGeneration.isModelLoaded, + unloadModel: TextGeneration.unloadModel, + chat: TextGeneration.chat, + generate: TextGeneration.generate, + generateStream: TextGeneration.generateStream, + cancelGeneration: TextGeneration.cancelGeneration, + + // ============================================================================ + // Speech-to-Text (Delegated to Extension) + // ============================================================================ + + loadSTTModel: STT.loadSTTModel, + isSTTModelLoaded: STT.isSTTModelLoaded, + unloadSTTModel: STT.unloadSTTModel, + transcribe: STT.transcribe, + transcribeSimple: STT.transcribeSimple, + transcribeBuffer: STT.transcribeBuffer, + transcribeStream: STT.transcribeStream, + transcribeFile: STT.transcribeFile, + + // ============================================================================ + // Text-to-Speech (Delegated to Extension) + // ============================================================================ + + loadTTSModel: TTS.loadTTSModel, + loadTTSVoice: TTS.loadTTSVoice, + unloadTTSVoice: TTS.unloadTTSVoice, + isTTSModelLoaded: TTS.isTTSModelLoaded, + isTTSVoiceLoaded: TTS.isTTSVoiceLoaded, + unloadTTSModel: TTS.unloadTTSModel, + synthesize: TTS.synthesize, + synthesizeStream: TTS.synthesizeStream, + speak: TTS.speak, + isSpeaking: TTS.isSpeaking, + stopSpeaking: TTS.stopSpeaking, + availableTTSVoices: TTS.availableTTSVoices, + stopSynthesis: TTS.stopSynthesis, + + // ============================================================================ + // Voice Activity Detection (Delegated to Extension) + // ============================================================================ + + initializeVAD: VAD.initializeVAD, + isVADReady: VAD.isVADReady, + loadVADModel: VAD.loadVADModel, + isVADModelLoaded: VAD.isVADModelLoaded, + unloadVADModel: VAD.unloadVADModel, + detectSpeech: VAD.detectSpeech, + processVAD: VAD.processVAD, + startVAD: VAD.startVAD, + stopVAD: VAD.stopVAD, + resetVAD: VAD.resetVAD, + setVADSpeechActivityCallback: VAD.setVADSpeechActivityCallback, + setVADAudioBufferCallback: VAD.setVADAudioBufferCallback, + cleanupVAD: VAD.cleanupVAD, + getVADState: VAD.getVADState, + + // ============================================================================ + // Voice Agent (Delegated to Extension) + // ============================================================================ + + initializeVoiceAgent: VoiceAgent.initializeVoiceAgent, + initializeVoiceAgentWithLoadedModels: VoiceAgent.initializeVoiceAgentWithLoadedModels, + isVoiceAgentReady: VoiceAgent.isVoiceAgentReady, + getVoiceAgentComponentStates: VoiceAgent.getVoiceAgentComponentStates, + areAllVoiceComponentsReady: VoiceAgent.areAllVoiceComponentsReady, + processVoiceTurn: VoiceAgent.processVoiceTurn, + voiceAgentTranscribe: VoiceAgent.voiceAgentTranscribe, + voiceAgentGenerateResponse: VoiceAgent.voiceAgentGenerateResponse, + voiceAgentSynthesizeSpeech: VoiceAgent.voiceAgentSynthesizeSpeech, + cleanupVoiceAgent: VoiceAgent.cleanupVoiceAgent, + + // ============================================================================ + // Voice Session (Delegated to Extension) + // ============================================================================ + + startVoiceSession: VoiceSession.startVoiceSession, + startVoiceSessionWithCallback: VoiceSession.startVoiceSessionWithCallback, + createVoiceSession: VoiceSession.createVoiceSession, + + // ============================================================================ + // Structured Output (Delegated to Extension) + // ============================================================================ + + generateStructured: StructuredOutput.generateStructured, + generateStructuredStream: StructuredOutput.generateStructuredStream, + extractEntities: StructuredOutput.extractEntities, + classify: StructuredOutput.classify, + + // ============================================================================ + // Storage Management (Delegated to Extension) + // ============================================================================ + + getStorageInfo: Storage.getStorageInfo, + clearCache: Storage.clearCache, + + // ============================================================================ + // Model Registry (Delegated to Extension) + // ============================================================================ + + getAvailableModels: Models.getAvailableModels, + getModelInfo: Models.getModelInfo, + isModelDownloaded: Models.isModelDownloaded, + downloadModel: Models.downloadModel, + cancelDownload: Models.cancelDownload, + deleteModel: Models.deleteModel, + + // ============================================================================ + // Utilities + // ============================================================================ + + async getLastError(): Promise { + if (!isNativeModuleAvailable()) return ''; + const native = requireNativeModule(); + return native.getLastError(); + }, + + async getBackendInfo(): Promise> { + if (!isNativeModuleAvailable()) return {}; + const native = requireNativeModule(); + const infoJson = await native.getBackendInfo(); + try { + return JSON.parse(infoJson); + } catch { + return {}; + } + }, + + /** + * Get SDK version + * @returns Version string + */ + async getVersion(): Promise { + // Return centralized SDK version constant + return SDKConstants.version; + }, + + /** + * Get available capabilities + * @returns Array of capability strings (llm, stt, tts, vad) + */ + async getCapabilities(): Promise { + const caps: string[] = ['core']; + // Check which backends are available + try { + if (await this.isModelLoaded()) caps.push('llm'); + if (await this.isSTTModelLoaded()) caps.push('stt'); + if (await this.isTTSModelLoaded()) caps.push('tts'); + if (await this.isVADModelLoaded()) caps.push('vad'); + } catch { + // Ignore errors - these methods may not be available + } + return caps; + }, + + /** + * Get downloaded models + * @returns Array of model IDs + */ + getDownloadedModels: Models.getDownloadedModels, + + /** + * Clean temporary files + */ + async cleanTempFiles(): Promise { + // Delegate to storage clearCache for now + await this.clearCache(); + return true; + }, + + // ============================================================================ + // Audio Utilities (Delegated to Extension) + // ============================================================================ + + /** Audio recording and playback utilities */ + Audio: { + requestPermission: Audio.requestAudioPermission, + startRecording: Audio.startRecording, + stopRecording: Audio.stopRecording, + cancelRecording: Audio.cancelRecording, + playAudio: Audio.playAudio, + stopPlayback: Audio.stopPlayback, + pausePlayback: Audio.pausePlayback, + resumePlayback: Audio.resumePlayback, + createWavFromPCMFloat32: Audio.createWavFromPCMFloat32, + cleanup: Audio.cleanup, + formatDuration: Audio.formatDuration, + SAMPLE_RATE: Audio.AUDIO_SAMPLE_RATE, + TTS_SAMPLE_RATE: Audio.TTS_SAMPLE_RATE, + }, + + // ============================================================================ + // Factory Methods + // ============================================================================ + + conversation(): Conversation { + return new Conversation(); + }, +}; + +// ============================================================================ +// Type Exports +// ============================================================================ + +export type { ModelInfo } from '../types/models'; +export type { DownloadProgress } from '../services/DownloadService'; diff --git a/sdk/runanywhere-react-native/packages/core/src/index.ts b/sdk/runanywhere-react-native/packages/core/src/index.ts new file mode 100644 index 000000000..70061f735 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/index.ts @@ -0,0 +1,238 @@ +/** + * @runanywhere/core - Core SDK for RunAnywhere React Native + * + * Core SDK that includes: + * - RACommons bindings via Nitrogen HybridObject + * - Authentication, Device Registration + * - Model Registry, Download Service + * - Storage, Events, HTTP Client + * + * NO LLM/STT/TTS/VAD functionality - use: + * - @runanywhere/llamacpp for text generation + * - @runanywhere/onnx for speech processing + * + * @packageDocumentation + */ + +// ============================================================================= +// Main SDK +// ============================================================================= + +export { RunAnywhere } from './Public/RunAnywhere'; + +// ============================================================================= +// Types +// ============================================================================= + +export * from './types'; + +// ============================================================================= +// Foundation - Error Types +// ============================================================================= + +export { + // Error Codes + ErrorCode, + getErrorCodeMessage, + // Error Category + ErrorCategory, + allErrorCategories, + getCategoryFromCode, + inferCategoryFromError, + // Error Context + type ErrorContext, + createErrorContext, + formatStackTrace, + formatLocation, + formatContext, + ContextualError, + withContext, + getErrorContext, + getUnderlyingError, + // SDKError + SDKErrorCode, + type SDKErrorProtocol, + SDKError, + asSDKError, + isSDKError, + captureAndThrow, + notInitializedError, + alreadyInitializedError, + invalidInputError, + modelNotFoundError, + modelLoadError, + networkError, + authenticationError, + generationError, + storageError, +} from './Foundation/ErrorTypes'; + +// ============================================================================= +// Foundation - Initialization +// ============================================================================= + +export { + InitializationPhase, + type SDKInitParams, + type InitializationState, + isSDKUsable, + areServicesReady, + isInitializing, + createInitialState, + markCoreInitialized, + markServicesInitializing, + markServicesInitialized, + markInitializationFailed, + resetState, +} from './Foundation/Initialization'; + +// ============================================================================= +// Foundation - Security +// ============================================================================= + +export { + SecureStorageKeys, + SecureStorageService, + type SecureStorageErrorCode, + SecureStorageError, + isSecureStorageError, + isItemNotFoundError, + DeviceIdentity, +} from './Foundation/Security'; + +// ============================================================================= +// Foundation - Constants +// ============================================================================= + +export { SDKConstants } from './Foundation/Constants'; + +// ============================================================================= +// Foundation - Logging +// ============================================================================= + +export { SDKLogger } from './Foundation/Logging/Logger/SDKLogger'; +export { LogLevel } from './Foundation/Logging/Models/LogLevel'; +export { LoggingManager } from './Foundation/Logging/Services/LoggingManager'; + +// ============================================================================= +// Foundation - DI +// ============================================================================= + +export { ServiceRegistry } from './Foundation/DependencyInjection/ServiceRegistry'; +export { ServiceContainer } from './Foundation/DependencyInjection/ServiceContainer'; + +// ============================================================================= +// Events +// ============================================================================= + +export { EventBus, NativeEventNames } from './Public/Events'; +export { + type SDKEvent, + EventDestination, + EventCategory, + createSDKEvent, + isSDKEvent, + EventPublisher, +} from './Infrastructure/Events'; + +// ============================================================================= +// Services (thin wrappers over native) +// ============================================================================= + +export { + ModelRegistry, + FileSystem, + DownloadService, + DownloadState, + SystemTTSService, + getVoicesByLanguage, + getDefaultVoice, + getPlatformDefaultVoice, + PlatformVoices, + type ModelCriteria, + type AddModelFromURLOptions, + type DownloadProgress, + type DownloadTask, + type DownloadConfiguration, + type ProgressCallback, +} from './services'; + +// ============================================================================= +// Network Layer - Using axios (industry standard HTTP library) +// ============================================================================= + +export { + // HTTP Service + HTTPService, + // Configuration + SDKEnvironment, + createNetworkConfig, + getEnvironmentName, + isDevelopment, + isProduction, + DEFAULT_BASE_URL, + DEFAULT_TIMEOUT_MS, + // Telemetry + TelemetryService, + TelemetryCategory, + // Endpoints + APIEndpoints, +} from './services'; + +export type { + HTTPServiceConfig, + DevModeConfig, + NetworkConfig, + TelemetryEvent, + APIEndpointKey, + APIEndpointValue, +} from './services'; + +// ============================================================================= +// Features +// ============================================================================= + +export { + AudioCaptureManager, + AudioPlaybackManager, + VoiceSessionHandle, + DEFAULT_VOICE_SESSION_CONFIG, +} from './Features'; +export type { + AudioDataCallback, + AudioLevelCallback, + AudioCaptureConfig, + AudioCaptureState, + PlaybackState, + PlaybackCompletionCallback, + PlaybackErrorCallback, + PlaybackConfig, + VoiceSessionConfig, + VoiceSessionEvent, + VoiceSessionEventType, + VoiceSessionEventCallback, + VoiceSessionState, +} from './Features'; + +// ============================================================================= +// Native Module (now part of core) +// ============================================================================= + +export { + NativeRunAnywhereCore, + getNativeCoreModule, + requireNativeCoreModule, + isNativeCoreModuleAvailable, + // Backwards compatibility exports (match old @runanywhere/native) + requireNativeModule, + isNativeModuleAvailable, + requireDeviceInfoModule, + requireFileSystemModule, +} from './native/NativeRunAnywhereCore'; +export type { NativeRunAnywhereCoreModule, FileSystemModule } from './native/NativeRunAnywhereCore'; + +// ============================================================================= +// Nitrogen Spec Types +// ============================================================================= + +export type { RunAnywhereCore } from './specs/RunAnywhereCore.nitro'; diff --git a/sdk/runanywhere-react-native/packages/core/src/native/NativeRunAnywhereCore.ts b/sdk/runanywhere-react-native/packages/core/src/native/NativeRunAnywhereCore.ts new file mode 100644 index 000000000..5e21d7230 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/native/NativeRunAnywhereCore.ts @@ -0,0 +1,291 @@ +/** + * NativeRunAnywhereCore.ts + * + * Exports the native RunAnywhereCore Hybrid Object from Nitro Modules. + * This module provides core SDK functionality without any inference backends. + * + * For LLM, STT, TTS, VAD capabilities, use the separate packages: + * - @runanywhere/llamacpp for text generation + * - @runanywhere/onnx for speech processing + */ + +import { NitroModules } from 'react-native-nitro-modules'; +import type { RunAnywhereCore } from '../specs/RunAnywhereCore.nitro'; +import type { RunAnywhereDeviceInfo } from '../specs/RunAnywhereDeviceInfo.nitro'; +import type { NativeRunAnywhereModule } from './NativeRunAnywhereModule'; +import { SDKLogger } from '../Foundation/Logging'; + +export type { NativeRunAnywhereModule } from './NativeRunAnywhereModule'; +export { hasNativeMethod } from './NativeRunAnywhereModule'; + +/** + * The native RunAnywhereCore module type + */ +export type NativeRunAnywhereCoreModule = RunAnywhereCore; + +/** + * Get the native RunAnywhereCore Hybrid Object + * + * This provides direct access to the native module. + * Most users should use the RunAnywhere facade class instead. + */ +export function requireNativeCoreModule(): NativeRunAnywhereCoreModule { + return NitroModules.createHybridObject('RunAnywhereCore'); +} + +/** + * Check if the native core module is available + */ +export function isNativeCoreModuleAvailable(): boolean { + try { + requireNativeCoreModule(); + return true; + } catch { + return false; + } +} + +/** + * Singleton instance of the native module (lazy initialized) + */ +let _nativeModule: NativeRunAnywhereModule | undefined; + +/** + * Get the singleton native module instance + * Returns the full module type for backwards compatibility + */ +export function getNativeCoreModule(): NativeRunAnywhereModule { + if (!_nativeModule) { + // Cast to full module type - optional methods may not be available + _nativeModule = requireNativeCoreModule() as unknown as NativeRunAnywhereModule; + } + return _nativeModule; +} + +// ============================================================================= +// Backwards compatibility exports +// These match the old @runanywhere/native exports +// ============================================================================= + +/** + * Get the native module with full API type + * Some methods may not be available unless backend packages are installed + */ +export function requireNativeModule(): NativeRunAnywhereModule { + return getNativeCoreModule(); +} + +/** + * Check if native module is available + */ +export function isNativeModuleAvailable(): boolean { + return isNativeCoreModuleAvailable(); +} + +/** + * Device info module interface + */ +export interface DeviceInfoModule { + deviceId: string; + getDeviceIdSync: () => string; + uniqueId: string; + getDeviceModel: () => Promise; + getChipName: () => Promise; + getTotalRAM: () => Promise; + getAvailableRAM: () => Promise; + hasNPU: () => Promise; + getOSVersion: () => Promise; + hasGPU: () => Promise; + getCPUCores: () => Promise; +} + +/** + * Singleton for device info hybrid object + */ +let _deviceInfoModule: RunAnywhereDeviceInfo | null = null; + +/** + * Get the RunAnywhereDeviceInfo hybrid object + * This provides real device info from native iOS/Android code + */ +function getDeviceInfoHybridObject(): RunAnywhereDeviceInfo | null { + if (_deviceInfoModule) { + return _deviceInfoModule; + } + try { + _deviceInfoModule = NitroModules.createHybridObject('RunAnywhereDeviceInfo'); + return _deviceInfoModule; + } catch (error) { + console.warn('[NativeRunAnywhereCore] Failed to create RunAnywhereDeviceInfo:', error); + return null; + } +} + +/** + * Device info module - provides device information + * + * Uses the RunAnywhereDeviceInfo NitroModule for real device info + * from native iOS (Swift) and Android (Kotlin) implementations. + */ +export function requireDeviceInfoModule(): DeviceInfoModule { + const deviceInfo = getDeviceInfoHybridObject(); + + return { + deviceId: '', + getDeviceIdSync: () => '', + uniqueId: '', + + getDeviceModel: async () => { + if (deviceInfo) { + try { + return await deviceInfo.getDeviceModel(); + } catch (error) { + console.warn('[DeviceInfo] getDeviceModel failed:', error); + } + } + return 'Unknown Device'; + }, + + getChipName: async () => { + if (deviceInfo) { + try { + return await deviceInfo.getChipName(); + } catch (error) { + console.warn('[DeviceInfo] getChipName failed:', error); + } + } + return 'Unknown'; + }, + + getTotalRAM: async () => { + if (deviceInfo) { + try { + return await deviceInfo.getTotalRAM(); + } catch (error) { + console.warn('[DeviceInfo] getTotalRAM failed:', error); + } + } + return 0; + }, + + getAvailableRAM: async () => { + if (deviceInfo) { + try { + return await deviceInfo.getAvailableRAM(); + } catch (error) { + console.warn('[DeviceInfo] getAvailableRAM failed:', error); + } + } + return 0; + }, + + hasNPU: async () => { + if (deviceInfo) { + try { + return await deviceInfo.hasNPU(); + } catch (error) { + console.warn('[DeviceInfo] hasNPU failed:', error); + } + } + return false; + }, + + getOSVersion: async () => { + if (deviceInfo) { + try { + return await deviceInfo.getOSVersion(); + } catch (error) { + console.warn('[DeviceInfo] getOSVersion failed:', error); + } + } + return 'Unknown'; + }, + + hasGPU: async () => { + if (deviceInfo) { + try { + return await deviceInfo.hasGPU(); + } catch (error) { + console.warn('[DeviceInfo] hasGPU failed:', error); + } + } + return false; + }, + + getCPUCores: async () => { + if (deviceInfo) { + try { + return await deviceInfo.getCPUCores(); + } catch (error) { + console.warn('[DeviceInfo] getCPUCores failed:', error); + } + } + return 0; + }, + }; +} + +/** + * File system module interface + */ +export interface FileSystemModule { + getAvailableDiskSpace(): Promise; + getTotalDiskSpace(): Promise; + downloadModel( + fileName: string, + url: string, + onProgress?: (progress: number) => void + ): Promise; + getModelPath(fileName: string): Promise; + modelExists(fileName: string): Promise; + deleteModel(fileName: string): Promise; + getDataDirectory(): Promise; + getModelsDirectory(): Promise; +} + +/** + * Get the file system module for model downloads and file operations + * Uses react-native-fs for cross-platform file operations + */ +export function requireFileSystemModule(): FileSystemModule { + // Import the FileSystem service + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { FileSystem } = require('../services/FileSystem'); + + return { + getAvailableDiskSpace: () => FileSystem.getAvailableDiskSpace(), + getTotalDiskSpace: () => FileSystem.getTotalDiskSpace(), + downloadModel: async ( + fileName: string, + url: string, + onProgress?: (progress: number) => void + ): Promise => { + try { + await FileSystem.downloadModel(fileName, url, (progress: { progress: number }) => { + if (onProgress) { + onProgress(progress.progress); + } + }); + return true; + } catch (error) { + SDKLogger.download.logError(error as Error, 'Download failed'); + return false; + } + }, + getModelPath: (fileName: string) => FileSystem.getModelPath(fileName), + modelExists: (fileName: string) => FileSystem.modelExists(fileName), + deleteModel: (fileName: string) => FileSystem.deleteModel(fileName), + getDataDirectory: () => Promise.resolve(FileSystem.getRunAnywhereDirectory()), + getModelsDirectory: () => Promise.resolve(FileSystem.getModelsDirectory()), + }; +} + +/** + * Default export - the native module getter + */ +export const NativeRunAnywhereCore = { + get: getNativeCoreModule, + isAvailable: isNativeCoreModuleAvailable, +}; + +export default NativeRunAnywhereCore; diff --git a/sdk/runanywhere-react-native/packages/core/src/native/NativeRunAnywhereModule.ts b/sdk/runanywhere-react-native/packages/core/src/native/NativeRunAnywhereModule.ts new file mode 100644 index 000000000..26a12611d --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/native/NativeRunAnywhereModule.ts @@ -0,0 +1,32 @@ +/** + * NativeRunAnywhereModule.ts + * + * Full native module type that includes all methods from core. + * All methods call backend-agnostic C++ APIs (rac_*_component_*). + * + * LLM, STT, TTS, VAD methods are backend-agnostic: + * - They call the C++ rac_*_component_* APIs + * - The actual backend is registered by importing backend packages: + * - @runanywhere/llamacpp registers the LLM backend + * - @runanywhere/onnx registers the STT/TTS/VAD backends + */ + +import type { RunAnywhereCore } from '../specs/RunAnywhereCore.nitro'; + +/** + * Native module type - directly matches RunAnywhereCore spec + * + * All methods are backend-agnostic. If no backend is registered for a + * capability, the methods will throw appropriate errors. + */ +export type NativeRunAnywhereModule = RunAnywhereCore; + +/** + * Type guard to check if a method is available on the native module + */ +export function hasNativeMethod( + native: NativeRunAnywhereModule, + method: K +): boolean { + return typeof native[method] === 'function'; +} diff --git a/sdk/runanywhere-react-native/packages/core/src/native/index.ts b/sdk/runanywhere-react-native/packages/core/src/native/index.ts new file mode 100644 index 000000000..f53ab7d93 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/native/index.ts @@ -0,0 +1,21 @@ +/** + * Native module exports for @runanywhere/core + */ + +export { + NativeRunAnywhereCore, + getNativeCoreModule, + requireNativeCoreModule, + isNativeCoreModuleAvailable, + // Backwards compatibility + requireNativeModule, + isNativeModuleAvailable, + requireDeviceInfoModule, + requireFileSystemModule, + hasNativeMethod, +} from './NativeRunAnywhereCore'; +export type { + NativeRunAnywhereCoreModule, + NativeRunAnywhereModule, + FileSystemModule, +} from './NativeRunAnywhereCore'; diff --git a/sdk/runanywhere-react-native/packages/core/src/services/DownloadService.ts b/sdk/runanywhere-react-native/packages/core/src/services/DownloadService.ts new file mode 100644 index 000000000..e9f376f91 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/services/DownloadService.ts @@ -0,0 +1,282 @@ +/** + * Download Service for RunAnywhere React Native SDK + * + * Thin wrapper over native download service. + * All download logic lives in native commons. + * + * Reference: sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+Download.swift + */ + +import { requireNativeModule, isNativeModuleAvailable } from '../native'; +import { EventBus } from '../Public/Events'; +import { SDKLogger } from '../Foundation/Logging/Logger/SDKLogger'; + +const logger = new SDKLogger('DownloadService'); + +/** + * Extended native module type for download service methods + * These methods are optional and may not be implemented in all backends + */ +interface DownloadNativeModule { + startModelDownload?: (modelId: string) => Promise; + pauseDownload?: (taskId: string) => Promise; + resumeDownload?: (taskId: string) => Promise; + pauseAllDownloads?: () => Promise; + resumeAllDownloads?: () => Promise; + cancelAllDownloads?: () => Promise; + configureDownloadService?: (configJson: string) => Promise; + isDownloadServiceHealthy?: () => Promise; + cancelDownload: (taskId: string) => Promise; + getDownloadProgress: (modelId: string) => Promise; +} + +/** + * Download state + */ +export enum DownloadState { + Idle = 'idle', + Queued = 'queued', + Downloading = 'downloading', + Paused = 'paused', + Completed = 'completed', + Failed = 'failed', + Cancelled = 'cancelled', +} + +/** + * Download progress information + */ +export interface DownloadProgress { + taskId: string; + modelId: string; + bytesDownloaded: number; + totalBytes: number; + progress: number; + state: DownloadState; + error?: string; +} + +/** + * Download task handle + */ +export interface DownloadTask { + id: string; + modelId: string; + state: DownloadState; + promise: Promise; + cancel: () => Promise; +} + +/** + * Download configuration + */ +export interface DownloadConfiguration { + timeout?: number; + maxConcurrentDownloads?: number; + retryCount?: number; + allowCellular?: boolean; +} + +/** + * Progress callback type + */ +export type ProgressCallback = (progress: DownloadProgress) => void; + +/** + * Download Service - Thin wrapper over native + */ +class DownloadServiceImpl { + private activeTasks = new Map(); + + /** + * Download a model by ID + */ + async downloadModelById( + modelId: string, + onProgress?: ProgressCallback + ): Promise { + if (!isNativeModuleAvailable()) { + throw new Error('Native module not available'); + } + + const native = requireNativeModule() as unknown as DownloadNativeModule; + if (!native.startModelDownload) { + throw new Error('startModelDownload not available'); + } + const taskId = await native.startModelDownload(modelId); + + logger.debug(`Started download: ${modelId} (task: ${taskId})`); + + // Subscribe to progress events if callback provided + let unsubscribe: (() => void) | null = null; + if (onProgress) { + unsubscribe = EventBus.onModel((event) => { + if (event.type === 'downloadProgress' && 'modelId' in event && event.modelId === modelId) { + onProgress({ + taskId: (event as { taskId?: string }).taskId ?? taskId, + modelId, + bytesDownloaded: (event as { bytesDownloaded?: number }).bytesDownloaded ?? 0, + totalBytes: (event as { totalBytes?: number }).totalBytes ?? 0, + progress: (event as { progress?: number }).progress ?? 0, + state: DownloadState.Downloading, + }); + } + }); + } + + // Wait for completion + return new Promise((resolve, reject) => { + const eventUnsubscribe = EventBus.onModel((event) => { + if (!('modelId' in event) || event.modelId !== modelId) return; + + if (event.type === 'downloadCompleted') { + eventUnsubscribe(); + unsubscribe?.(); + resolve((event as { localPath?: string }).localPath ?? ''); + } + if (event.type === 'downloadFailed') { + eventUnsubscribe(); + unsubscribe?.(); + reject(new Error((event as { error?: string }).error ?? 'Download failed')); + } + }); + }); + } + + /** + * Cancel a download + */ + async cancelDownload(taskId: string): Promise { + if (!isNativeModuleAvailable()) return; + + const native = requireNativeModule() as unknown as DownloadNativeModule; + await native.cancelDownload(taskId); + this.activeTasks.delete(taskId); + } + + /** + * Pause a download + */ + async pauseDownload(taskId: string): Promise { + if (!isNativeModuleAvailable()) return; + + const native = requireNativeModule() as unknown as DownloadNativeModule; + if (native.pauseDownload) { + await native.pauseDownload(taskId); + } + } + + /** + * Resume a download + */ + async resumeDownload(taskId: string): Promise { + if (!isNativeModuleAvailable()) return; + + const native = requireNativeModule() as unknown as DownloadNativeModule; + if (native.resumeDownload) { + await native.resumeDownload(taskId); + } + } + + /** + * Pause all downloads + */ + async pauseAll(): Promise { + if (!isNativeModuleAvailable()) return; + + const native = requireNativeModule() as unknown as DownloadNativeModule; + if (native.pauseAllDownloads) { + await native.pauseAllDownloads(); + } + } + + /** + * Resume all downloads + */ + async resumeAll(): Promise { + if (!isNativeModuleAvailable()) return; + + const native = requireNativeModule() as unknown as DownloadNativeModule; + if (native.resumeAllDownloads) { + await native.resumeAllDownloads(); + } + } + + /** + * Cancel all downloads + */ + async cancelAll(): Promise { + if (!isNativeModuleAvailable()) return; + + const native = requireNativeModule() as unknown as DownloadNativeModule; + if (native.cancelAllDownloads) { + await native.cancelAllDownloads(); + } + this.activeTasks.clear(); + } + + /** + * Get download progress + */ + async getDownloadProgress(modelId: string): Promise { + if (!isNativeModuleAvailable()) return null; + + const native = requireNativeModule() as unknown as DownloadNativeModule; + const json = await native.getDownloadProgress(modelId); + try { + const data = JSON.parse(json); + return typeof data === 'number' ? data : data?.progress ?? null; + } catch { + return null; + } + } + + /** + * Configure download service + */ + async configure(config: DownloadConfiguration): Promise { + if (!isNativeModuleAvailable()) return; + + const native = requireNativeModule() as unknown as DownloadNativeModule; + if (native.configureDownloadService) { + await native.configureDownloadService(JSON.stringify(config)); + } + } + + /** + * Check if service is healthy + */ + async isHealthy(): Promise { + if (!isNativeModuleAvailable()) return false; + + const native = requireNativeModule() as unknown as DownloadNativeModule; + if (!native.isDownloadServiceHealthy) { + return true; // Assume healthy if method not available + } + return native.isDownloadServiceHealthy(); + } + + /** + * Check if downloading + */ + isDownloading(modelId: string): boolean { + for (const task of this.activeTasks.values()) { + if (task.modelId === modelId && task.state === DownloadState.Downloading) { + return true; + } + } + return false; + } + + /** + * Reset (for testing) + */ + reset(): void { + this.activeTasks.clear(); + } +} + +/** + * Singleton instance + */ +export const DownloadService = new DownloadServiceImpl(); diff --git a/sdk/runanywhere-react-native/packages/core/src/services/FileSystem.ts b/sdk/runanywhere-react-native/packages/core/src/services/FileSystem.ts new file mode 100644 index 000000000..80506a100 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/services/FileSystem.ts @@ -0,0 +1,810 @@ +/** + * FileSystem.ts + * + * File system service using react-native-fs for model downloads and storage. + * Matches Swift SDK's path structure: Documents/RunAnywhere/Models/{framework}/{modelId}/ + */ + +import { Platform } from 'react-native'; +import { SDKLogger } from '../Foundation/Logging/Logger/SDKLogger'; + +const logger = new SDKLogger('FileSystem'); + +// Lazy-loaded native module getter to avoid initialization order issues +let _nativeModuleGetter: (() => { extractArchive: (archivePath: string, destPath: string) => Promise }) | null = null; + +function getNativeModule(): { extractArchive: (archivePath: string, destPath: string) => Promise } | null { + if (_nativeModuleGetter === null) { + try { + // Dynamic require to avoid circular dependency and initialization order issues + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { requireNativeModule, isNativeModuleAvailable } = require('../native/NativeRunAnywhereCore'); + if (isNativeModuleAvailable()) { + _nativeModuleGetter = () => requireNativeModule(); + } else { + logger.warning('Native module not available for archive extraction'); + return null; + } + } catch (e) { + logger.error('Failed to load native module:', { error: e }); + return null; + } + } + return _nativeModuleGetter ? _nativeModuleGetter() : null; +} + +// Types for react-native-fs (defined locally to avoid module resolution issues) +interface RNFSDownloadBeginCallbackResult { + jobId: number; + statusCode: number; + contentLength: number; + headers: Record; +} + +interface RNFSDownloadProgressCallbackResult { + jobId: number; + contentLength: number; + bytesWritten: number; +} + +interface RNFSStatResult { + name: string; + path: string; + size: number; + mode: number; + ctime: number; + mtime: number; + isFile: () => boolean; + isDirectory: () => boolean; +} + +interface RNFSDownloadResult { + jobId: number; + statusCode: number; + bytesWritten: number; +} + +interface RNFSDownloadFileOptions { + fromUrl: string; + toFile: string; + headers?: Record; + background?: boolean; + progressDivider?: number; + begin?: (res: RNFSDownloadBeginCallbackResult) => void; + progress?: (res: RNFSDownloadProgressCallbackResult) => void; + resumable?: () => void; + connectionTimeout?: number; + readTimeout?: number; +} + +interface RNFSModule { + DocumentDirectoryPath: string; + CachesDirectoryPath: string; + exists: (path: string) => Promise; + mkdir: (path: string, options?: { NSURLIsExcludedFromBackupKey?: boolean }) => Promise; + readDir: (path: string) => Promise; + readFile: (path: string, encoding?: string) => Promise; + writeFile: (path: string, contents: string, encoding?: string) => Promise; + moveFile: (source: string, dest: string) => Promise; + copyFile: (source: string, dest: string) => Promise; + unlink: (path: string) => Promise; + stat: (path: string) => Promise; + getFSInfo: () => Promise<{ totalSpace: number; freeSpace: number }>; + downloadFile: (options: RNFSDownloadFileOptions) => { jobId: number; promise: Promise }; + stopDownload: (jobId: number) => void; +} + +// Try to import react-native-fs +let RNFS: RNFSModule | null = null; +try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + RNFS = require('react-native-fs'); +} catch { + logger.warning('react-native-fs not installed, file operations will be limited'); +} + +// Try to import react-native-zip-archive +let ZipArchive: { + unzip: (source: string, target: string) => Promise; + unzipWithPassword: (source: string, target: string, password: string) => Promise; + unzipAssets: (assetPath: string, target: string) => Promise; + subscribe: (callback: (event: { progress: number; filePath: string }) => void) => { remove: () => void }; +} | null = null; +try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + ZipArchive = require('react-native-zip-archive'); +} catch { + logger.warning('react-native-zip-archive not installed, archive extraction will be limited'); +} + +// Constants matching Swift SDK path structure +const RUN_ANYWHERE_DIR = 'RunAnywhere'; +const MODELS_DIR = 'Models'; + +/** + * Download progress information + */ +export interface DownloadProgress { + bytesWritten: number; + contentLength: number; + progress: number; +} + +/** + * Archive types supported for extraction + * Matches Swift SDK's ArchiveType enum + */ +export enum ArchiveType { + Zip = 'zip', + TarBz2 = 'tar.bz2', + TarGz = 'tar.gz', + TarXz = 'tar.xz', +} + +/** + * Describes the internal structure of an archive after extraction + * Matches Swift SDK's ArchiveStructure enum + */ +export enum ArchiveStructure { + SingleFileNested = 'singleFileNested', + DirectoryBased = 'directoryBased', + NestedDirectory = 'nestedDirectory', + Unknown = 'unknown', +} + +/** + * Model artifact type - describes how a model is packaged + * Matches Swift SDK's ModelArtifactType enum + */ +export type ModelArtifactType = + | { type: 'singleFile' } + | { type: 'archive'; archiveType: ArchiveType; structure: ArchiveStructure } + | { type: 'multiFile'; files: string[] } + | { type: 'custom'; strategyId: string } + | { type: 'builtIn' }; + +/** + * Extraction result + */ +export interface ExtractionResult { + modelPath: string; + extractedSize: number; + fileCount: number; +} + +/** + * Infer archive type from URL + */ +function inferArchiveType(url: string): ArchiveType | null { + const lowercased = url.toLowerCase(); + if (lowercased.includes('.tar.bz2') || lowercased.includes('.tbz2')) { + return ArchiveType.TarBz2; + } + if (lowercased.includes('.tar.gz') || lowercased.includes('.tgz')) { + return ArchiveType.TarGz; + } + if (lowercased.includes('.tar.xz') || lowercased.includes('.txz')) { + return ArchiveType.TarXz; + } + if (lowercased.includes('.zip')) { + return ArchiveType.Zip; + } + return null; +} + +/** + * Infer framework from file name/extension + */ +function inferFramework(fileName: string): string { + const lower = fileName.toLowerCase(); + if (lower.includes('.gguf') || lower.includes('.bin')) { + return 'LlamaCpp'; + } + if (lower.includes('.onnx') || lower.includes('.tar') || lower.includes('.zip')) { + return 'ONNX'; + } + return 'LlamaCpp'; // Default +} + +/** + * Extract base model ID (remove extension) + */ +function getBaseModelId(modelId: string): string { + return modelId + .replace('.gguf', '') + .replace('.onnx', '') + .replace('.tar.bz2', '') + .replace('.tar.gz', '') + .replace('.zip', '') + .replace('.bin', ''); +} + +/** + * File system service for model management + */ +export const FileSystem = { + /** + * Check if react-native-fs is available + */ + isAvailable(): boolean { + return RNFS !== null; + }, + + /** + * Get the base documents directory + */ + getDocumentsDirectory(): string { + if (!RNFS) { + throw new Error('react-native-fs not installed'); + } + return Platform.OS === 'android' + ? RNFS.DocumentDirectoryPath + : RNFS.DocumentDirectoryPath; + }, + + /** + * Get the RunAnywhere base directory + * Returns: Documents/RunAnywhere/ + */ + getRunAnywhereDirectory(): string { + return `${this.getDocumentsDirectory()}/${RUN_ANYWHERE_DIR}`; + }, + + /** + * Get the models directory + * Returns: Documents/RunAnywhere/Models/ + */ + getModelsDirectory(): string { + return `${this.getRunAnywhereDirectory()}/${MODELS_DIR}`; + }, + + /** + * Get framework directory + * Returns: Documents/RunAnywhere/Models/{framework}/ + */ + getFrameworkDirectory(framework: string): string { + return `${this.getModelsDirectory()}/${framework}`; + }, + + /** + * Get model folder + * Returns: Documents/RunAnywhere/Models/{framework}/{modelId}/ + */ + getModelFolder(modelId: string, framework?: string): string { + const fw = framework || inferFramework(modelId); + const baseId = getBaseModelId(modelId); + return `${this.getFrameworkDirectory(fw)}/${baseId}`; + }, + + /** + * Get model file path + * For LlamaCpp: Documents/RunAnywhere/Models/LlamaCpp/{modelId}/{modelId}.gguf + * For ONNX: Documents/RunAnywhere/Models/ONNX/{modelId}/ (folder, checking for nested dirs) + */ + async getModelPath(modelId: string, framework?: string): Promise { + const fw = framework || inferFramework(modelId); + const folder = this.getModelFolder(modelId, fw); + const baseId = getBaseModelId(modelId); + + if (fw === 'LlamaCpp') { + // Single file model + const ext = modelId.includes('.gguf') + ? '.gguf' + : modelId.includes('.bin') + ? '.bin' + : '.gguf'; + return `${folder}/${baseId}${ext}`; + } + + // For ONNX, check if the model is in a nested directory structure + if (RNFS) { + try { + const exists = await RNFS.exists(folder); + if (exists) { + // Find the actual model path (handles nested directory structures) + const modelPath = await this.findModelPathAfterExtraction(folder); + return modelPath; + } + } catch { + // Fall through to return the default folder + } + } + + // Directory-based model (ONNX) + return folder; + }, + + /** + * Check if a model exists + * For LlamaCpp: checks if the .gguf file exists + * For ONNX: checks if the folder has .onnx files (extracted archive) + */ + async modelExists(modelId: string, framework?: string): Promise { + if (!RNFS) return false; + + const fw = framework || inferFramework(modelId); + const folder = this.getModelFolder(modelId, fw); + + try { + const exists = await RNFS.exists(folder); + if (!exists) return false; + + // Check if folder has contents + const files = await RNFS.readDir(folder); + if (files.length === 0) return false; + + if (fw === 'ONNX') { + // For ONNX, we need to check if there are actual model files (not just an archive) + // ONNX models should have .onnx files after extraction + const hasOnnxFiles = await this.hasModelFiles(folder); + return hasOnnxFiles; + } + + return true; + } catch { + return false; + } + }, + + /** + * Recursively check if a folder contains model files + */ + async hasModelFiles(folder: string): Promise { + if (!RNFS) return false; + + try { + const contents = await RNFS.readDir(folder); + + for (const item of contents) { + if (item.isFile()) { + const name = item.name.toLowerCase(); + // Check for actual model files, not archive files + if (name.endsWith('.onnx') || name.endsWith('.bin') || name.endsWith('.txt')) { + return true; + } + } else if (item.isDirectory()) { + // Check nested directories + const hasFiles = await this.hasModelFiles(item.path); + if (hasFiles) return true; + } + } + + return false; + } catch { + return false; + } + }, + + /** + * Create directory if it doesn't exist + */ + async ensureDirectory(path: string): Promise { + if (!RNFS) return; + + try { + const exists = await RNFS.exists(path); + if (!exists) { + await RNFS.mkdir(path); + } + } catch (error) { + logger.error(`Failed to create directory: ${path}`, { error }); + } + }, + + /** + * Download a model file + */ + async downloadModel( + modelId: string, + url: string, + onProgress?: (progress: DownloadProgress) => void, + framework?: string + ): Promise { + if (!RNFS) { + throw new Error('react-native-fs not installed'); + } + + const fw = framework || inferFramework(modelId); + const folder = this.getModelFolder(modelId, fw); + const baseId = getBaseModelId(modelId); + + // Ensure directory structure exists + await this.ensureDirectory(this.getRunAnywhereDirectory()); + await this.ensureDirectory(this.getModelsDirectory()); + await this.ensureDirectory(this.getFrameworkDirectory(fw)); + await this.ensureDirectory(folder); + + // Determine destination path + let destPath: string; + if (fw === 'LlamaCpp') { + const ext = + modelId.includes('.gguf') || url.includes('.gguf') + ? '.gguf' + : modelId.includes('.bin') || url.includes('.bin') + ? '.bin' + : '.gguf'; + destPath = `${folder}/${baseId}${ext}`; + } else { + // For archives, download to temp first + const tempName = `${baseId}_${Date.now()}.tmp`; + destPath = `${RNFS.CachesDirectoryPath}/${tempName}`; + } + + logger.info(`Downloading model: ${modelId}`); + logger.debug(`URL: ${url}`); + logger.debug(`Destination: ${destPath}`); + + // Check if already exists + const exists = await RNFS.exists(destPath); + if (exists && fw === 'LlamaCpp') { + logger.info(`Model already exists: ${destPath}`); + return destPath; + } + + // Download with progress + const downloadResult = RNFS.downloadFile({ + fromUrl: url, + toFile: destPath, + background: true, + progressDivider: 1, + begin: (res) => { + logger.info( + `Download started: ${res.contentLength} bytes, status: ${res.statusCode}` + ); + }, + progress: (res) => { + const progress = res.contentLength > 0 + ? res.bytesWritten / res.contentLength + : 0; + + if (onProgress) { + onProgress({ + bytesWritten: res.bytesWritten, + contentLength: res.contentLength, + progress, + }); + } + }, + }); + + const result = await downloadResult.promise; + + if (result.statusCode !== 200) { + throw new Error(`Download failed with status: ${result.statusCode}`); + } + + logger.info(`Download completed: ${result.bytesWritten} bytes`); + + // For ONNX archives, extract to final location + const archiveType = inferArchiveType(url); + if (fw === 'ONNX' && archiveType !== null) { + logger.info(`Extracting ${archiveType} archive...`); + + try { + const extractionResult = await this.extractArchive(destPath, folder, archiveType); + logger.info(`Extraction completed: ${extractionResult.fileCount} files, ${extractionResult.extractedSize} bytes`); + + // Clean up the temporary archive file + await RNFS.unlink(destPath); + + // Return the extracted folder path + destPath = extractionResult.modelPath; + } catch (extractError) { + logger.error(`Archive extraction failed: ${extractError}`); + // Clean up temp file on failure + try { + await RNFS.unlink(destPath); + } catch { + // Ignore cleanup errors + } + throw new Error(`Archive extraction failed: ${extractError}`); + } + } + + return destPath; + }, + + /** + * Extract an archive to a destination folder + * Uses native extraction via the core module (iOS: ArchiveUtility, Android: native extraction) + */ + async extractArchive( + archivePath: string, + destinationFolder: string, + archiveType: ArchiveType, + onProgress?: (progress: number) => void + ): Promise { + if (!RNFS) { + throw new Error('react-native-fs not installed'); + } + + logger.info(`Extracting archive: ${archivePath}`); + logger.info(`Archive type: ${archiveType}`); + logger.info(`Destination: ${destinationFolder}`); + + // Ensure destination exists + await this.ensureDirectory(destinationFolder); + + // Try native extraction first (supports tar.gz, tar.bz2, zip) + try { + const native = getNativeModule(); + if (!native) { + throw new Error('Native module not available'); + } + + logger.info('Using native archive extraction...'); + const success = await native.extractArchive(archivePath, destinationFolder); + + if (!success) { + throw new Error('Native extraction returned false'); + } + + logger.info('Native extraction completed successfully'); + } catch (nativeError) { + logger.warning(`Native extraction failed: ${nativeError}, trying fallback...`); + + // Fallback to react-native-zip-archive for ZIP files only + if (archiveType === ArchiveType.Zip && ZipArchive) { + logger.info('Falling back to react-native-zip-archive for ZIP...'); + + let subscription: { remove: () => void } | null = null; + if (onProgress) { + subscription = ZipArchive.subscribe(({ progress }) => { + onProgress(progress); + }); + } + + try { + await ZipArchive.unzip(archivePath, destinationFolder); + } finally { + if (subscription) { + subscription.remove(); + } + } + } else if (archiveType === ArchiveType.TarGz || archiveType === ArchiveType.TarBz2) { + // No fallback for tar archives - native is required + throw new Error( + `Archive extraction failed for ${archiveType}. Native extraction is required for tar archives. Error: ${nativeError}` + ); + } else { + throw new Error(`Archive extraction failed: ${nativeError}`); + } + } + + // After extraction, find the actual model path + // ONNX models are typically nested in a directory with the same name + const modelPath = await this.findModelPathAfterExtraction(destinationFolder); + + // Calculate extraction stats + const stats = await this.calculateExtractionStats(destinationFolder); + + return { + modelPath, + extractedSize: stats.totalSize, + fileCount: stats.fileCount, + }; + }, + + /** + * Find the actual model path after extraction + * Handles nested directory structures common in ONNX archives + */ + async findModelPathAfterExtraction(extractedFolder: string): Promise { + if (!RNFS) { + return extractedFolder; + } + + try { + const contents = await RNFS.readDir(extractedFolder); + + // If there's exactly one directory and no files, it might be a nested structure + const directories = contents.filter(item => item.isDirectory()); + const files = contents.filter(item => item.isFile()); + + if (directories.length === 1 && files.length === 0) { + // Nested directory - the actual model is inside + const nestedDir = directories[0]; + logger.info(`Found nested directory structure: ${nestedDir.name}`); + return nestedDir.path; + } + + // Otherwise, the extracted folder contains the model directly + return extractedFolder; + } catch (error) { + logger.error(`Error finding model path: ${error}`); + return extractedFolder; + } + }, + + /** + * Calculate extraction statistics + */ + async calculateExtractionStats(folder: string): Promise<{ totalSize: number; fileCount: number }> { + if (!RNFS) { + return { totalSize: 0, fileCount: 0 }; + } + + let totalSize = 0; + let fileCount = 0; + + const processDir = async (dir: string) => { + try { + const contents = await RNFS!.readDir(dir); + for (const item of contents) { + if (item.isFile()) { + totalSize += item.size; + fileCount++; + } else if (item.isDirectory()) { + await processDir(item.path); + } + } + } catch { + // Ignore errors + } + }; + + await processDir(folder); + return { totalSize, fileCount }; + }, + + /** + * Delete a model + */ + async deleteModel(modelId: string, framework?: string): Promise { + if (!RNFS) return false; + + const fw = framework || inferFramework(modelId); + const folder = this.getModelFolder(modelId, fw); + + try { + const exists = await RNFS.exists(folder); + if (exists) { + await RNFS.unlink(folder); + return true; + } + return false; + } catch (error) { + logger.error(`Failed to delete model: ${modelId}`, { error }); + return false; + } + }, + + /** + * Get available disk space in bytes + */ + async getAvailableDiskSpace(): Promise { + if (!RNFS) return 0; + + try { + const info = await RNFS.getFSInfo(); + return info.freeSpace; + } catch { + return 0; + } + }, + + /** + * Get total disk space in bytes + */ + async getTotalDiskSpace(): Promise { + if (!RNFS) return 0; + + try { + const info = await RNFS.getFSInfo(); + return info.totalSpace; + } catch { + return 0; + } + }, + + /** + * Read a file as string + */ + async readFile(path: string): Promise { + if (!RNFS) { + throw new Error('react-native-fs not installed'); + } + return RNFS.readFile(path, 'utf8'); + }, + + /** + * Write a string to a file + */ + async writeFile(path: string, content: string): Promise { + if (!RNFS) { + throw new Error('react-native-fs not installed'); + } + await RNFS.writeFile(path, content, 'utf8'); + }, + + /** + * Check if a file exists + */ + async fileExists(path: string): Promise { + if (!RNFS) return false; + return RNFS.exists(path); + }, + + /** + * Check if a directory exists + */ + async directoryExists(path: string): Promise { + if (!RNFS) return false; + try { + const exists = await RNFS.exists(path); + if (!exists) return false; + const stat = await RNFS.stat(path); + return stat.isDirectory(); + } catch { + return false; + } + }, + + /** + * Get the size of a directory in bytes (recursive) + */ + async getDirectorySize(dirPath: string): Promise { + if (!RNFS) return 0; + + try { + const exists = await RNFS.exists(dirPath); + if (!exists) return 0; + + let totalSize = 0; + const contents = await RNFS.readDir(dirPath); + + for (const item of contents) { + if (item.isDirectory()) { + totalSize += await this.getDirectorySize(item.path); + } else { + totalSize += item.size || 0; + } + } + + return totalSize; + } catch { + return 0; + } + }, + + /** + * Get the cache directory path + */ + getCacheDirectory(): string { + if (!RNFS) return ''; + return RNFS.CachesDirectoryPath; + }, + + /** + * List contents of a directory + */ + async listDirectory(dirPath: string): Promise { + if (!RNFS) return []; + + try { + const exists = await RNFS.exists(dirPath); + if (!exists) return []; + + const contents = await RNFS.readDir(dirPath); + return contents.map((item) => item.name); + } catch { + return []; + } + }, + + /** + * Delete a file + */ + async deleteFile(path: string): Promise { + if (!RNFS) return false; + + try { + await RNFS.unlink(path); + return true; + } catch { + return false; + } + }, +}; + +export default FileSystem; diff --git a/sdk/runanywhere-react-native/packages/core/src/services/ModelRegistry.ts b/sdk/runanywhere-react-native/packages/core/src/services/ModelRegistry.ts new file mode 100644 index 000000000..317a0e455 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/services/ModelRegistry.ts @@ -0,0 +1,246 @@ +/** + * Model Registry for RunAnywhere React Native SDK + * + * Thin wrapper over native model registry. + * All logic (caching, filtering, discovery) is in native commons. + * + * Reference: sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+ModelRegistry.swift + */ + +import { requireNativeModule, isNativeModuleAvailable } from '../native'; +import type { LLMFramework, ModelCategory, ModelInfo } from '../types'; +import { SDKLogger } from '../Foundation/Logging/Logger/SDKLogger'; + +const logger = new SDKLogger('ModelRegistry'); + +/** + * Criteria for filtering models (passed to native) + */ +export interface ModelCriteria { + framework?: LLMFramework; + category?: ModelCategory; + downloadedOnly?: boolean; + availableOnly?: boolean; +} + +/** + * Options for adding a model from URL + */ +export interface AddModelFromURLOptions { + name: string; + url: string; + framework: LLMFramework; + estimatedSize?: number; + supportsThinking?: boolean; +} + +/** + * Model Registry - Thin wrapper over native + * + * All model management logic lives in native commons. + */ +class ModelRegistryImpl { + private initialized = false; + + /** + * Initialize the registry (calls native) + */ + async initialize(): Promise { + if (this.initialized) return; + + if (!isNativeModuleAvailable()) { + logger.warning('Native module not available'); + this.initialized = true; + return; + } + + try { + // Just get available models to verify registry is working + await this.getAllModels(); + this.initialized = true; + logger.info('Model registry initialized via native'); + } catch (error) { + logger.warning('Failed to initialize registry:', { error }); + this.initialized = true; + } + } + + /** + * Get all models (native) + */ + async getAllModels(): Promise { + if (!isNativeModuleAvailable()) return []; + + try { + const native = requireNativeModule(); + const json = await native.getAvailableModels(); + return JSON.parse(json); + } catch (error) { + logger.error('Failed to get available models:', { error }); + return []; + } + } + + /** + * Get a model by ID (native) + */ + async getModel(id: string): Promise { + if (!isNativeModuleAvailable()) return null; + + try { + const native = requireNativeModule(); + const json = await native.getModelInfo(id); + if (!json || json === '{}') return null; + return JSON.parse(json); + } catch (error) { + logger.error('Failed to get model info:', { error }); + return null; + } + } + + /** + * Filter models by criteria + */ + async filterModels(criteria: ModelCriteria): Promise { + const allModels = await this.getAllModels(); + + // Simple filtering on JS side since native returns all + let models = allModels; + + if (criteria.framework) { + models = models.filter(m => m.compatibleFrameworks?.includes(criteria.framework!)); + } + if (criteria.category) { + models = models.filter(m => m.category === criteria.category); + } + if (criteria.downloadedOnly) { + models = models.filter(m => m.isDownloaded); + } + if (criteria.availableOnly) { + models = models.filter(m => m.isAvailable); + } + + return models; + } + + /** + * Register a model (native) + */ + async registerModel(model: ModelInfo): Promise { + if (!isNativeModuleAvailable()) return; + + const native = requireNativeModule(); + await native.registerModel(JSON.stringify(model)); + } + + /** + * Update model info (alias for registerModel) + */ + async updateModel(model: ModelInfo): Promise { + return this.registerModel(model); + } + + /** + * Remove a model (native) + */ + async removeModel(id: string): Promise { + if (!isNativeModuleAvailable()) return; + + const native = requireNativeModule(); + await native.deleteModel(id); + } + + /** + * Add model from URL - registers a model with a download URL + */ + async addModelFromURL(options: AddModelFromURLOptions): Promise { + if (!isNativeModuleAvailable()) { + throw new Error('Native module not available'); + } + + // Create a ModelInfo from the options and register it + const model: Partial = { + id: options.name.toLowerCase().replace(/\s+/g, '-'), + name: options.name, + downloadURL: options.url, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + compatibleFrameworks: [options.framework] as any, + downloadSize: options.estimatedSize ?? 0, + supportsThinking: options.supportsThinking ?? false, + isDownloaded: false, + isAvailable: true, + }; + + await this.registerModel(model as ModelInfo); + return model as ModelInfo; + } + + /** + * Get downloaded models + */ + async getDownloadedModels(): Promise { + return this.filterModels({ downloadedOnly: true }); + } + + /** + * Get available models + */ + async getAvailableModels(): Promise { + return this.filterModels({ availableOnly: true }); + } + + /** + * Get models by framework + */ + async getModelsByFramework(framework: LLMFramework): Promise { + return this.filterModels({ framework }); + } + + /** + * Get models by category + */ + async getModelsByCategory(category: ModelCategory): Promise { + return this.filterModels({ category }); + } + + /** + * Check if model is downloaded (native) + */ + async isModelDownloaded(modelId: string): Promise { + if (!isNativeModuleAvailable()) return false; + + try { + const native = requireNativeModule(); + return native.isModelDownloaded(modelId); + } catch { + return false; + } + } + + /** + * Check if model is available + */ + async isModelAvailable(modelId: string): Promise { + const model = await this.getModel(modelId); + return model?.isAvailable ?? false; + } + + /** + * Check if initialized + */ + isInitialized(): boolean { + return this.initialized; + } + + /** + * Reset (for testing) + */ + reset(): void { + this.initialized = false; + } +} + +/** + * Singleton instance + */ +export const ModelRegistry = new ModelRegistryImpl(); diff --git a/sdk/runanywhere-react-native/packages/core/src/services/Network/APIEndpoints.ts b/sdk/runanywhere-react-native/packages/core/src/services/Network/APIEndpoints.ts new file mode 100644 index 000000000..1114a505f --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/services/Network/APIEndpoints.ts @@ -0,0 +1,84 @@ +/** + * APIEndpoints.ts + * + * API endpoint constants. + * + * Production endpoints use /api/v1/* prefix (Railway) + * Development endpoints use Supabase REST API paths + * + * Reference: sdk/runanywhere-commons/include/rac/infrastructure/network/rac_endpoints.h + */ + +/** + * API endpoint paths + */ +export const APIEndpoints = { + // ============================================================================ + // Authentication + // ============================================================================ + + /** SDK authentication endpoint - POST with API key to get access token */ + AUTHENTICATE: '/api/v1/auth/sdk/authenticate', + + /** Token refresh endpoint - POST with refresh token */ + REFRESH_TOKEN: '/api/v1/auth/sdk/refresh', + + // ============================================================================ + // Device Registration + // ============================================================================ + + /** Device registration (production) */ + DEVICE_REGISTER: '/api/v1/devices/register', + + /** Device registration (development - Supabase) */ + DEV_DEVICE_REGISTER: '/rest/v1/sdk_devices', + + // ============================================================================ + // Models + // ============================================================================ + + /** Get available models */ + MODELS_LIST: '/api/v1/models', + + /** Get model details (append /{modelId}) */ + MODEL_INFO: '/api/v1/models', + + /** Model assignments */ + MODEL_ASSIGNMENTS: '/api/v1/model-assignments/for-sdk', + + /** Model assignments (development - Supabase) */ + DEV_MODEL_ASSIGNMENTS: '/rest/v1/sdk_model_assignments', + + // ============================================================================ + // Telemetry + // Matches C++: RAC_ENDPOINT_TELEMETRY, RAC_ENDPOINT_DEV_TELEMETRY + // ============================================================================ + + /** Send telemetry events (production) */ + TELEMETRY: '/api/v1/sdk/telemetry', + + /** Send telemetry events (development - Supabase) */ + DEV_TELEMETRY: '/rest/v1/telemetry_events', + + // ============================================================================ + // Usage + // ============================================================================ + + /** Report usage metrics */ + USAGE: '/api/v1/usage', + + /** Get usage summary */ + USAGE_SUMMARY: '/api/v1/usage/summary', +} as const; + +/** + * Type for endpoint keys + */ +export type APIEndpointKey = keyof typeof APIEndpoints; + +/** + * Type for endpoint values + */ +export type APIEndpointValue = (typeof APIEndpoints)[APIEndpointKey]; + +export default APIEndpoints; diff --git a/sdk/runanywhere-react-native/packages/core/src/services/Network/HTTPService.ts b/sdk/runanywhere-react-native/packages/core/src/services/Network/HTTPService.ts new file mode 100644 index 000000000..2169428de --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/services/Network/HTTPService.ts @@ -0,0 +1,479 @@ +/** + * HTTPService.ts + * + * Core HTTP service implementation using fetch (built-in to React Native). + * All network logic is centralized here. + * + * This is analogous to Swift's URLSession - using the platform's native HTTP client. + * + * Reference: sdk/runanywhere-swift/Sources/RunAnywhere/Data/Network/Services/HTTPService.swift + */ + +// React Native global types +declare const fetch: (url: string, init?: RequestInit) => Promise; +declare const setTimeout: (callback: () => void, ms: number) => number; +declare const clearTimeout: (id: number) => void; +declare const AbortController: { + new (): { + signal: AbortSignal; + abort(): void; + }; +}; + +interface RequestInit { + method?: string; + headers?: Record; + body?: string; + signal?: AbortSignal; +} + +interface AbortSignal { + aborted: boolean; +} + +interface Response { + ok: boolean; + status: number; + statusText: string; + text(): Promise; + json(): Promise; +} + +import { SDKLogger } from '../../Foundation/Logging/Logger/SDKLogger'; +import { SDKError } from '../../Foundation/ErrorTypes'; +import { ErrorCode } from '../../Foundation/ErrorTypes/ErrorCodes'; +import { SDKConstants } from '../../Foundation/Constants'; + +const logger = new SDKLogger('HTTPService'); + +// SDK Constants - use centralized constants where available +const SDK_CLIENT = 'RunAnywhereSDK'; +const SDK_PLATFORM = 'react-native'; +const DEFAULT_TIMEOUT_MS = 30000; + +/** + * SDK Environment enum matching Swift/C++ SDKEnvironment + * Uses string values to match types/enums.ts + */ +export enum SDKEnvironment { + Development = 'development', + Staging = 'staging', + Production = 'production', +} + +/** + * HTTP Service Configuration + */ +export interface HTTPServiceConfig { + /** Base URL for API requests */ + baseURL: string; + /** API key for authentication */ + apiKey: string; + /** SDK environment */ + environment: SDKEnvironment; + /** Request timeout in milliseconds */ + timeoutMs?: number; +} + +/** + * Development (Supabase) Configuration + */ +export interface DevModeConfig { + /** Supabase project URL */ + supabaseURL: string; + /** Supabase anon key */ + supabaseKey: string; +} + +/** + * HTTP Service - Core network implementation using fetch + * + * Centralized HTTP transport layer modeled after Swift's URLSession approach. + * Uses fetch - the built-in HTTP client in React Native. + * + * Features: + * - Environment-aware routing (Supabase for dev, Railway for prod) + * - Automatic header management + * - Proper timeout and error handling + * - Device registration with Supabase UPSERT support + * + * Usage: + * ```typescript + * // Configure (called during SDK init) + * HTTPService.shared.configure({ + * baseURL: 'https://api.runanywhere.ai', + * apiKey: 'your-api-key', + * environment: SDKEnvironment.Production, + * }); + * + * // Make requests + * const response = await HTTPService.shared.post('/api/v1/devices/register', deviceData); + * ``` + */ +export class HTTPService { + // ============================================================================ + // Singleton + // ============================================================================ + + private static _instance: HTTPService | null = null; + + /** + * Get shared HTTPService instance + */ + static get shared(): HTTPService { + if (!HTTPService._instance) { + HTTPService._instance = new HTTPService(); + } + return HTTPService._instance; + } + + // ============================================================================ + // Configuration + // ============================================================================ + + private baseURL: string = ''; + private apiKey: string = ''; + private environment: SDKEnvironment = SDKEnvironment.Production; + private accessToken: string | null = null; + private timeoutMs: number = DEFAULT_TIMEOUT_MS; + + // Development mode (Supabase) + private supabaseURL: string = ''; + private supabaseKey: string = ''; + + // ============================================================================ + // Initialization + // ============================================================================ + + private constructor() {} + + private get defaultHeaders(): Record { + return { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'X-SDK-Client': SDK_CLIENT, + 'X-SDK-Version': SDKConstants.version, + 'X-Platform': SDK_PLATFORM, + }; + } + + // ============================================================================ + // Configuration Methods + // ============================================================================ + + /** + * Configure HTTP service with base URL and API key + */ + configure(config: HTTPServiceConfig): void { + this.baseURL = config.baseURL; + this.apiKey = config.apiKey; + this.environment = config.environment; + this.timeoutMs = config.timeoutMs || DEFAULT_TIMEOUT_MS; + + logger.info( + `Configured for ${this.getEnvironmentName()} environment: ${this.getHostname(config.baseURL)}` + ); + } + + /** + * Configure development mode with Supabase credentials + * + * When in development mode, SDK makes calls directly to Supabase + * instead of going through the Railway backend. + */ + configureDev(config: DevModeConfig): void { + this.supabaseURL = config.supabaseURL; + this.supabaseKey = config.supabaseKey; + + logger.info('Development mode configured with Supabase'); + } + + /** + * Set authorization token + */ + setToken(token: string): void { + this.accessToken = token; + logger.debug('Access token set'); + } + + /** + * Clear authorization token + */ + clearToken(): void { + this.accessToken = null; + logger.debug('Access token cleared'); + } + + /** + * Check if HTTP service is configured + */ + get isConfigured(): boolean { + if (this.environment === SDKEnvironment.Development) { + return !!this.supabaseURL; + } + return !!this.baseURL && !!this.apiKey; + } + + /** + * Get current base URL + */ + get currentBaseURL(): string { + if (this.environment === SDKEnvironment.Development && this.supabaseURL) { + return this.supabaseURL; + } + return this.baseURL; + } + + // ============================================================================ + // HTTP Methods + // ============================================================================ + + /** + * POST request with JSON body + * + * @param path API endpoint path + * @param data Request body (will be JSON serialized) + * @returns Response data + */ + async post(path: string, data?: T): Promise { + let url = this.buildFullURL(path); + + // Handle device registration - add UPSERT for Supabase + const isDeviceReg = this.isDeviceRegistrationPath(path); + const headers = this.buildHeaders(isDeviceReg); + + if (isDeviceReg && this.environment === SDKEnvironment.Development) { + const separator = url.includes('?') ? '&' : '?'; + url = `${url}${separator}on_conflict=device_id`; + } + + const response = await this.executeRequest('POST', url, headers, data); + + // Handle 409 as success for device registration (device already exists) + if (isDeviceReg && response.status === 409) { + logger.info('Device already registered (409) - treating as success'); + return this.parseResponse(response); + } + + return this.handleResponse(response, path); + } + + /** + * GET request + * + * @param path API endpoint path + * @returns Response data + */ + async get(path: string): Promise { + const url = this.buildFullURL(path); + const headers = this.buildHeaders(false); + + const response = await this.executeRequest('GET', url, headers); + return this.handleResponse(response, path); + } + + /** + * PUT request + * + * @param path API endpoint path + * @param data Request body + * @returns Response data + */ + async put(path: string, data?: T): Promise { + const url = this.buildFullURL(path); + const headers = this.buildHeaders(false); + + const response = await this.executeRequest('PUT', url, headers, data); + return this.handleResponse(response, path); + } + + /** + * DELETE request + * + * @param path API endpoint path + * @returns Response data + */ + async delete(path: string): Promise { + const url = this.buildFullURL(path); + const headers = this.buildHeaders(false); + + const response = await this.executeRequest('DELETE', url, headers); + return this.handleResponse(response, path); + } + + /** + * POST request with raw response (returns raw data) + * + * @param path API endpoint path + * @param data Request body + * @returns Raw response data as string + */ + async postRaw(path: string, data?: unknown): Promise { + const response = await this.post(path, data); + return typeof response === 'string' ? response : JSON.stringify(response); + } + + /** + * GET request with raw response + * + * @param path API endpoint path + * @returns Raw response data as string + */ + async getRaw(path: string): Promise { + const response = await this.get(path); + return typeof response === 'string' ? response : JSON.stringify(response); + } + + // ============================================================================ + // Private Implementation + // ============================================================================ + + private async executeRequest( + method: string, + url: string, + headers: Record, + data?: T + ): Promise { + logger.debug(`${method} ${url}`); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs); + + try { + const options: RequestInit = { + method, + headers, + signal: controller.signal, + }; + + if (data !== undefined && method !== 'GET') { + options.body = JSON.stringify(data); + } + + const response = await fetch(url, options); + return response; + } finally { + clearTimeout(timeoutId); + } + } + + private buildHeaders(isDeviceRegistration: boolean): Record { + const headers: Record = { ...this.defaultHeaders }; + + if (this.environment === SDKEnvironment.Development) { + // Development mode - use Supabase headers + // Supabase requires BOTH apikey AND Authorization: Bearer headers + if (this.supabaseKey) { + headers['apikey'] = this.supabaseKey; + headers['Authorization'] = `Bearer ${this.supabaseKey}`; + headers['Prefer'] = isDeviceRegistration + ? 'resolution=merge-duplicates' + : 'return=representation'; + } + } else { + // Production/Staging - use Bearer token + const token = this.accessToken || this.apiKey; + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + } + + return headers; + } + + private buildFullURL(path: string): string { + // Handle full URLs + if (path.startsWith('http://') || path.startsWith('https://')) { + return path; + } + + const base = this.currentBaseURL.replace(/\/$/, ''); + const endpoint = path.startsWith('/') ? path : `/${path}`; + return `${base}${endpoint}`; + } + + private isDeviceRegistrationPath(path: string): boolean { + return ( + path.includes('sdk_devices') || + path.includes('devices/register') || + path.includes('rest/v1/sdk_devices') + ); + } + + private async parseResponse(response: Response): Promise { + const text = await response.text(); + if (!text) { + return {} as R; + } + try { + return JSON.parse(text) as R; + } catch { + return text as unknown as R; + } + } + + private async handleResponse(response: Response, path: string): Promise { + if (response.ok) { + return this.parseResponse(response); + } + + // Parse error response + let errorMessage = `HTTP ${response.status}`; + try { + const errorData = (await response.json()) as Record; + errorMessage = + (errorData.message as string) || + (errorData.error as string) || + (errorData.hint as string) || + errorMessage; + } catch { + // Ignore JSON parse errors + } + + logger.error(`HTTP ${response.status}: ${path}`); + throw this.createError(response.status, errorMessage, path); + } + + private createError(statusCode: number, message: string, path: string): SDKError { + switch (statusCode) { + case 400: + return new SDKError(ErrorCode.InvalidInput, `Bad request: ${message}`); + case 401: + return new SDKError(ErrorCode.AuthenticationFailed, message); + case 403: + return new SDKError(ErrorCode.AuthenticationFailed, `Forbidden: ${message}`); + case 404: + return new SDKError(ErrorCode.ApiError, `Not found: ${path}`); + case 429: + return new SDKError(ErrorCode.NetworkTimeout, `Rate limited: ${message}`); + case 500: + case 502: + case 503: + case 504: + return new SDKError(ErrorCode.ApiError, `Server error (${statusCode}): ${message}`); + default: + return new SDKError(ErrorCode.NetworkUnavailable, `HTTP ${statusCode}: ${message}`); + } + } + + private getEnvironmentName(): string { + switch (this.environment) { + case SDKEnvironment.Development: + return 'development'; + case SDKEnvironment.Staging: + return 'staging'; + case SDKEnvironment.Production: + return 'production'; + default: + return 'unknown'; + } + } + + private getHostname(url: string): string { + // Simple hostname extraction for React Native compatibility + const match = url.match(/^https?:\/\/([^/:]+)/); + return match ? match[1] : url.substring(0, 30); + } +} + +export default HTTPService; diff --git a/sdk/runanywhere-react-native/packages/core/src/services/Network/NetworkConfiguration.ts b/sdk/runanywhere-react-native/packages/core/src/services/Network/NetworkConfiguration.ts new file mode 100644 index 000000000..2a5ba8c98 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/services/Network/NetworkConfiguration.ts @@ -0,0 +1,130 @@ +/** + * NetworkConfiguration.ts + * + * Network configuration types and utilities. + * + * Reference: sdk/runanywhere-swift/Sources/RunAnywhere/Data/Network/Services/HTTPService.swift + */ + +import { SDKEnvironment } from './HTTPService'; + +export { SDKEnvironment }; + +/** + * Network configuration options for SDK initialization + */ +export interface NetworkConfig { + /** + * Base URL for API requests + * - Production: Railway endpoint (e.g., "https://api.runanywhere.ai") + * - Development: Can be left empty if supabase config is provided + */ + baseURL?: string; + + /** + * API key for authentication + * - Production: RunAnywhere API key + * - Development: Build token + */ + apiKey: string; + + /** + * SDK environment + * @default SDKEnvironment.Production + */ + environment?: SDKEnvironment; + + /** + * Supabase configuration for development mode + * When provided in development mode, SDK makes calls directly to Supabase + */ + supabase?: { + url: string; + anonKey: string; + }; + + /** + * Request timeout in milliseconds + * @default 30000 + */ + timeoutMs?: number; +} + +/** + * Default production base URL + */ +export const DEFAULT_BASE_URL = 'https://api.runanywhere.ai'; + +/** + * Default timeout in milliseconds + */ +export const DEFAULT_TIMEOUT_MS = 30000; + +/** + * Create network configuration from SDK init options + */ +export function createNetworkConfig(options: { + apiKey: string; + baseURL?: string; + environment?: 'development' | 'staging' | 'production'; + supabaseURL?: string; + supabaseKey?: string; + timeoutMs?: number; +}): NetworkConfig { + // Map string environment to enum + let environment = SDKEnvironment.Production; + if (options.environment === 'development') { + environment = SDKEnvironment.Development; + } else if (options.environment === 'staging') { + environment = SDKEnvironment.Staging; + } + + // Build supabase config if provided + const supabase = + options.supabaseURL && options.supabaseKey + ? { + url: options.supabaseURL, + anonKey: options.supabaseKey, + } + : undefined; + + return { + baseURL: options.baseURL || DEFAULT_BASE_URL, + apiKey: options.apiKey, + environment, + supabase, + timeoutMs: options.timeoutMs || DEFAULT_TIMEOUT_MS, + }; +} + +/** + * Get environment name string + */ +export function getEnvironmentName(env: SDKEnvironment): string { + switch (env) { + case SDKEnvironment.Development: + return 'development'; + case SDKEnvironment.Staging: + return 'staging'; + case SDKEnvironment.Production: + return 'production'; + default: + return 'unknown'; + } +} + +/** + * Check if environment is development + */ +export function isDevelopment(env: SDKEnvironment): boolean { + return env === SDKEnvironment.Development; +} + +/** + * Check if environment is production + */ +export function isProduction(env: SDKEnvironment): boolean { + return env === SDKEnvironment.Production; +} + +export default NetworkConfig; diff --git a/sdk/runanywhere-react-native/packages/core/src/services/Network/TelemetryService.ts b/sdk/runanywhere-react-native/packages/core/src/services/Network/TelemetryService.ts new file mode 100644 index 000000000..47123d190 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/services/Network/TelemetryService.ts @@ -0,0 +1,318 @@ +/** + * TelemetryService.ts + * + * Telemetry service for RunAnywhere SDK - aligned with Swift/Kotlin SDKs. + * + * ARCHITECTURE: + * - C++ telemetry manager handles all event logic (batching, JSON building, routing) + * - Platform SDK only provides HTTP transport (handled in C++ via platform callbacks) + * - Events are automatically tracked by C++ when using LLM/STT/TTS/VAD capabilities + * + * This TypeScript service provides: + * - A thin wrapper to flush telemetry via native C++ calls + * - Convenience methods that match the Swift/Kotlin API + * - SDK-level events that TypeScript code can emit + * + * Reference: sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+Telemetry.swift + */ + +import { NitroModules } from 'react-native-nitro-modules'; +import type { RunAnywhereCore } from '../../specs/RunAnywhereCore.nitro'; +import { SDKLogger } from '../../Foundation/Logging/Logger/SDKLogger'; +import { SDKEnvironment } from '../../types/enums'; + +const logger = new SDKLogger('TelemetryService'); + +// Lazy-loaded native module +let _nativeModule: RunAnywhereCore | null = null; + +function getNativeModule(): RunAnywhereCore { + if (!_nativeModule) { + _nativeModule = NitroModules.createHybridObject('RunAnywhereCore'); + } + return _nativeModule; +} + +/** + * Telemetry event categories (matches C++ categories) + */ +export enum TelemetryCategory { + SDK = 'sdk', + Model = 'model', + LLM = 'llm', + STT = 'stt', + TTS = 'tts', + VAD = 'vad', + VoiceAgent = 'voice_agent', + Error = 'error', +} + +/** + * TelemetryService - Event tracking for RunAnywhere SDK + * + * This service delegates to the C++ telemetry manager, which handles: + * - Batching events + * - Building JSON payloads + * - HTTP transport via platform-native callbacks + * + * Automatic telemetry: + * - LLM/STT/TTS/VAD events are tracked automatically by C++ when you use those capabilities + * - No manual tracking needed for model operations + * + * Manual telemetry: + * - Use track() for SDK-level events (e.g., app lifecycle) + * - Events are emitted to C++ analytics system which routes them to telemetry + * + * Usage: + * ```typescript + * // Flush pending events (e.g., on app background) + * await TelemetryService.shared.flush(); + * + * // Check if telemetry is ready + * const isReady = await TelemetryService.shared.isInitialized(); + * ``` + */ +export class TelemetryService { + // ============================================================================ + // Singleton + // ============================================================================ + + private static _instance: TelemetryService | null = null; + + /** + * Get shared TelemetryService instance + */ + static get shared(): TelemetryService { + if (!TelemetryService._instance) { + TelemetryService._instance = new TelemetryService(); + } + return TelemetryService._instance; + } + + // ============================================================================ + // State + // ============================================================================ + + private enabled: boolean = true; + private deviceId: string | null = null; + private environment: SDKEnvironment = SDKEnvironment.Production; + + // ============================================================================ + // Initialization + // ============================================================================ + + private constructor() {} + + /** + * Configure telemetry service + * + * Note: The actual C++ telemetry manager is initialized during SDK init. + * This method just stores the configuration for reference. + */ + configure(deviceId: string, environment: SDKEnvironment): void { + this.deviceId = deviceId; + this.environment = environment; + logger.debug(`Configured for ${environment} environment`); + } + + /** + * Enable or disable telemetry + */ + setEnabled(enabled: boolean): void { + this.enabled = enabled; + logger.debug(`Telemetry ${enabled ? 'enabled' : 'disabled'}`); + } + + /** + * Check if telemetry is enabled + */ + isEnabled(): boolean { + return this.enabled; + } + + // ============================================================================ + // Core Telemetry Operations (Delegate to C++) + // ============================================================================ + + /** + * Check if telemetry is initialized + * + * Returns true if the C++ telemetry manager is ready to accept events. + */ + async isInitialized(): Promise { + try { + return await getNativeModule().isTelemetryInitialized(); + } catch (error) { + logger.error(`Failed to check telemetry initialization: ${error}`); + return false; + } + } + + /** + * Flush pending telemetry events + * + * Sends all queued events to the backend immediately. + * Call this on app background/exit to ensure events are sent. + */ + async flush(): Promise { + if (!this.enabled) { + return; + } + + try { + await getNativeModule().flushTelemetry(); + logger.debug('Telemetry flushed'); + } catch (error) { + logger.error(`Failed to flush telemetry: ${error}`); + } + } + + /** + * Shutdown telemetry service + * + * Flushes any pending events before stopping. + */ + async shutdown(): Promise { + try { + await this.flush(); + logger.debug('Telemetry shutdown complete'); + } catch (error) { + logger.error(`Telemetry shutdown error: ${error}`); + } + } + + // ============================================================================ + // Convenience Methods (for backwards compatibility) + // + // Note: These methods exist for API compatibility, but most telemetry + // is automatically tracked by C++ when using LLM/STT/TTS/VAD capabilities. + // You typically don't need to call these manually. + // ============================================================================ + + /** + * Track an event (emits to C++ analytics system) + * + * Note: Most telemetry is automatic. Use this for custom SDK-level events. + */ + track( + _type: string, + _category: TelemetryCategory = TelemetryCategory.SDK, + _properties?: Record + ): void { + if (!this.enabled) { + return; + } + + // Note: In the full C++ implementation, this would call native.emitEvent() + // to route to the C++ analytics system. For now, we log a debug message. + // The C++ telemetry manager handles actual event tracking. + logger.debug(`Event tracked: ${_type} (handled by C++ telemetry)`); + } + + /** + * Track SDK initialization + */ + trackSDKInit(environment: string, success: boolean): void { + this.track('sdk_initialized', TelemetryCategory.SDK, { + environment, + success, + sdkVersion: '0.2.0', + platform: 'react-native', + }); + } + + /** + * Track model loading + * + * Note: Model loading events are automatically tracked by C++ when you + * call loadTextModel(), loadSTTModel(), etc. + */ + trackModelLoad( + modelId: string, + modelType: string, + success: boolean, + loadTimeMs?: number + ): void { + this.track('model_loaded', TelemetryCategory.Model, { + modelId, + modelType, + success, + loadTimeMs, + }); + } + + /** + * Track text generation + * + * Note: Generation events are automatically tracked by C++ when you + * call generate() or generateStream(). + */ + trackGeneration( + modelId: string, + promptTokens: number, + completionTokens: number, + latencyMs: number + ): void { + this.track('generation_completed', TelemetryCategory.LLM, { + modelId, + promptTokens, + completionTokens, + latencyMs, + }); + } + + /** + * Track transcription + * + * Note: Transcription events are automatically tracked by C++ when you + * call transcribe() or transcribeFile(). + */ + trackTranscription( + modelId: string, + audioDurationMs: number, + latencyMs: number + ): void { + this.track('transcription_completed', TelemetryCategory.STT, { + modelId, + audioDurationMs, + latencyMs, + }); + } + + /** + * Track speech synthesis + * + * Note: Synthesis events are automatically tracked by C++ when you + * call synthesize(). + */ + trackSynthesis( + voiceId: string, + textLength: number, + audioDurationMs: number, + latencyMs: number + ): void { + this.track('synthesis_completed', TelemetryCategory.TTS, { + voiceId, + textLength, + audioDurationMs, + latencyMs, + }); + } + + /** + * Track error + */ + trackError( + errorCode: string, + errorMessage: string, + context?: Record + ): void { + this.track('error', TelemetryCategory.Error, { + errorCode, + errorMessage, + ...context, + }); + } +} + +export default TelemetryService; diff --git a/sdk/runanywhere-react-native/packages/core/src/services/Network/index.ts b/sdk/runanywhere-react-native/packages/core/src/services/Network/index.ts new file mode 100644 index 000000000..14e2d51fa --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/services/Network/index.ts @@ -0,0 +1,29 @@ +/** + * Network Services + * + * Centralized network layer for RunAnywhere React Native SDK. + * Uses React Native's built-in fetch API for HTTP requests. + */ + +// Core HTTP service +export { HTTPService, SDKEnvironment } from './HTTPService'; +export type { HTTPServiceConfig, DevModeConfig } from './HTTPService'; + +// Configuration utilities +export { + createNetworkConfig, + getEnvironmentName, + isDevelopment, + isProduction, + DEFAULT_BASE_URL, + DEFAULT_TIMEOUT_MS, +} from './NetworkConfiguration'; +export type { NetworkConfig } from './NetworkConfiguration'; + +// API endpoints +export { APIEndpoints } from './APIEndpoints'; +export type { APIEndpointKey, APIEndpointValue } from './APIEndpoints'; + +// Telemetry +export { TelemetryService, TelemetryCategory } from './TelemetryService'; +export type { TelemetryEvent } from './TelemetryService'; diff --git a/sdk/runanywhere-react-native/packages/core/src/services/SystemTTSService.ts b/sdk/runanywhere-react-native/packages/core/src/services/SystemTTSService.ts new file mode 100644 index 000000000..10210d8df --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/services/SystemTTSService.ts @@ -0,0 +1,130 @@ +/** + * SystemTTSService.ts + * + * System TTS service wrapper. + * Delegates to native platform TTS. + * + * Reference: sdk/runanywhere-swift/Sources/RunAnywhere/Features/TTS/System/SystemTTSService.swift + */ + +import { requireNativeModule, isNativeModuleAvailable } from '../native'; +import { SDKLogger } from '../Foundation/Logging/Logger/SDKLogger'; + +const logger = new SDKLogger('SystemTTSService'); + +/** + * TTS Voice + */ +export interface TTSVoice { + id: string; + name: string; + language: string; + quality: string; +} + +/** + * Platform-specific voices + */ +export const PlatformVoices = { + ios: [] as TTSVoice[], + android: [] as TTSVoice[], +}; + +/** + * Get voices by language + */ +export async function getVoicesByLanguage(language: string): Promise { + if (!isNativeModuleAvailable()) return []; + + try { + const native = requireNativeModule(); + const json = await native.getTTSVoices(); + const voices: TTSVoice[] = JSON.parse(json); + return voices.filter(v => v.language.startsWith(language)); + } catch (error) { + logger.warning('Failed to get voices:', { error }); + return []; + } +} + +/** + * Get default voice + */ +export async function getDefaultVoice(): Promise { + if (!isNativeModuleAvailable()) return null; + + try { + const native = requireNativeModule(); + const json = await native.getTTSVoices(); + const voices: TTSVoice[] = JSON.parse(json); + return voices[0] ?? null; + } catch { + return null; + } +} + +/** + * Get platform default voice + */ +export function getPlatformDefaultVoice(): TTSVoice | null { + return null; +} + +/** + * System TTS Service + */ +export class SystemTTSService { + private static _instance: SystemTTSService | null = null; + + static get shared(): SystemTTSService { + if (!SystemTTSService._instance) { + SystemTTSService._instance = new SystemTTSService(); + } + return SystemTTSService._instance; + } + + /** + * Synthesize text to speech + */ + async synthesize( + text: string, + voiceId?: string, + speedRate = 1.0, + pitchShift = 1.0 + ): Promise { + if (!isNativeModuleAvailable()) { + throw new Error('Native module not available'); + } + + const native = requireNativeModule(); + return native.synthesize(text, voiceId ?? '', speedRate, pitchShift); + } + + /** + * Get available voices + */ + async getVoices(): Promise { + if (!isNativeModuleAvailable()) return []; + + const native = requireNativeModule(); + const json = await native.getTTSVoices(); + return JSON.parse(json); + } + + /** + * Cancel synthesis + */ + async cancel(): Promise { + if (!isNativeModuleAvailable()) return; + + const native = requireNativeModule(); + await native.cancelTTS(); + } + + /** + * Reset singleton (for testing) + */ + static reset(): void { + SystemTTSService._instance = null; + } +} diff --git a/sdk/runanywhere-react-native/packages/core/src/services/index.ts b/sdk/runanywhere-react-native/packages/core/src/services/index.ts new file mode 100644 index 000000000..b7d6cf4eb --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/services/index.ts @@ -0,0 +1,66 @@ +/** + * RunAnywhere React Native SDK - Services + * + * Core services for SDK functionality. + */ + +// Model Registry - Manages model discovery and registration (JS-based) +export { + ModelRegistry, + type ModelCriteria, + type AddModelFromURLOptions, +} from './ModelRegistry'; + +// File System - Cross-platform file operations using react-native-fs +export { + FileSystem, + ArchiveType, + ArchiveStructure, + type ModelArtifactType, + type DownloadProgress as FSDownloadProgress, + type ExtractionResult, +} from './FileSystem'; + +// Download Service - Native-based download (delegates to native commons) +export { + DownloadService, + DownloadState, + type DownloadProgress, + type DownloadTask, + type DownloadConfiguration, + type ProgressCallback, +} from './DownloadService'; + +// TTS Service - Native implementation available +export { + SystemTTSService, + getVoicesByLanguage, + getDefaultVoice, + getPlatformDefaultVoice, + PlatformVoices, +} from './SystemTTSService'; + +// Network Layer - HTTP service using axios (industry standard) +export { + // HTTP Service + HTTPService, + SDKEnvironment, + type HTTPServiceConfig, + type DevModeConfig, + // Configuration + createNetworkConfig, + getEnvironmentName, + isDevelopment, + isProduction, + DEFAULT_BASE_URL, + DEFAULT_TIMEOUT_MS, + type NetworkConfig, + // Telemetry + TelemetryService, + TelemetryCategory, + type TelemetryEvent, + // Endpoints + APIEndpoints, + type APIEndpointKey, + type APIEndpointValue, +} from './Network'; diff --git a/sdk/runanywhere-react-native/packages/core/src/specs/RunAnywhereCore.nitro.ts b/sdk/runanywhere-react-native/packages/core/src/specs/RunAnywhereCore.nitro.ts new file mode 100644 index 000000000..c232678d1 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/specs/RunAnywhereCore.nitro.ts @@ -0,0 +1,627 @@ +/** + * RunAnywhereCore Nitrogen Spec + * + * Core SDK interface - includes: + * - SDK Lifecycle (init, destroy) + * - Authentication + * - Device Registration + * - Model Registry + * - Download Service + * - Storage + * - Events + * - HTTP Client + * - Utilities + * - LLM/STT/TTS/VAD capabilities (backend-agnostic via rac_*_component_* APIs) + * + * The capability methods (LLM, STT, TTS, VAD) are BACKEND-AGNOSTIC. + * They call the C++ rac_*_component_* APIs which work with any registered backend. + * Apps must install a backend package to register the actual implementation: + * - @runanywhere/llamacpp registers the LLM backend + * - @runanywhere/onnx registers the STT/TTS/VAD backends + * + * Matches Swift SDK: RunAnywhere.swift + CppBridge extensions + */ +import type { HybridObject } from 'react-native-nitro-modules'; + +/** + * Core RunAnywhere native interface + * + * This interface provides all SDK functionality using backend-agnostic C++ APIs. + * Install backend packages to enable specific capabilities: + * - @runanywhere/llamacpp for text generation (LLM) + * - @runanywhere/onnx for speech processing (STT, TTS, VAD) + */ +export interface RunAnywhereCore + extends HybridObject<{ + ios: 'c++'; + android: 'c++'; + }> { + // ============================================================================ + // SDK Lifecycle + // Matches Swift: CppBridge+Init.swift + // ============================================================================ + + /** + * Initialize the SDK with configuration + * @param configJson JSON string with apiKey, baseURL, environment + * @returns true if initialized successfully + */ + initialize(configJson: string): Promise; + + /** + * Destroy the SDK and clean up resources + */ + destroy(): Promise; + + /** + * Check if SDK is initialized + */ + isInitialized(): Promise; + + /** + * Get backend info as JSON string + */ + getBackendInfo(): Promise; + + // ============================================================================ + // Authentication + // Matches Swift: CppBridge+Auth.swift + // ============================================================================ + + /** + * Authenticate with API key + * @param apiKey API key + * @returns true if authenticated successfully + */ + authenticate(apiKey: string): Promise; + + /** + * Check if currently authenticated + */ + isAuthenticated(): Promise; + + /** + * Get current user ID + * @returns User ID or empty if not authenticated + */ + getUserId(): Promise; + + /** + * Get current organization ID + * @returns Organization ID or empty if not authenticated + */ + getOrganizationId(): Promise; + + /** + * Set authentication tokens directly (after JS-side authentication) + * This stores the tokens in C++ AuthBridge for use by telemetry/device registration + * @param authResponseJson JSON string with access_token, refresh_token, expires_in, etc. + * @returns true if tokens were set successfully + */ + setAuthTokens(authResponseJson: string): Promise; + + // ============================================================================ + // Device Registration + // Matches Swift: CppBridge+Device.swift + // ============================================================================ + + /** + * Register device with backend + * @param environmentJson Environment configuration JSON + * @returns true if registered successfully + */ + registerDevice(environmentJson: string): Promise; + + /** + * Check if device is registered + */ + isDeviceRegistered(): Promise; + + /** + * Clear device registration flag (for testing) + * Forces re-registration on next SDK init + */ + clearDeviceRegistration(): Promise; + + /** + * Get the device ID + * @returns Device ID or empty if not registered + */ + getDeviceId(): Promise; + + // ============================================================================ + // Model Registry + // Matches Swift: CppBridge+ModelRegistry.swift + // ============================================================================ + + /** + * Get list of available models + * @returns JSON array of model info + */ + getAvailableModels(): Promise; + + /** + * Get info for a specific model + * @param modelId Model identifier + * @returns JSON with model info + */ + getModelInfo(modelId: string): Promise; + + /** + * Check if a model is downloaded + * @param modelId Model identifier + * @returns true if model exists locally + */ + isModelDownloaded(modelId: string): Promise; + + /** + * Get local path for a model + * @param modelId Model identifier + * @returns Local file path or empty if not downloaded + */ + getModelPath(modelId: string): Promise; + + /** + * Register a custom model with the registry + * @param modelJson JSON with model definition + * @returns true if registered successfully + */ + registerModel(modelJson: string): Promise; + + // ============================================================================ + // Download Service + // Matches Swift: CppBridge+Download.swift + // ============================================================================ + + /** + * Download a model + * @param modelId Model identifier + * @param url Download URL + * @param destPath Destination path + * @returns true if download started successfully + */ + downloadModel( + modelId: string, + url: string, + destPath: string + ): Promise; + + /** + * Cancel an ongoing download + * @param modelId Model identifier + * @returns true if cancelled + */ + cancelDownload(modelId: string): Promise; + + /** + * Get download progress + * @param modelId Model identifier + * @returns JSON with progress info (bytes, total, percentage) + */ + getDownloadProgress(modelId: string): Promise; + + // ============================================================================ + // Storage + // Matches Swift: RunAnywhere+Storage.swift + // ============================================================================ + + /** + * Get storage info (disk usage, available space) + * @returns JSON with storage info + */ + getStorageInfo(): Promise; + + /** + * Clear model cache + * @returns true if cleared successfully + */ + clearCache(): Promise; + + /** + * Delete a specific model + * @param modelId Model identifier + * @returns true if deleted successfully + */ + deleteModel(modelId: string): Promise; + + // ============================================================================ + // Events + // Matches Swift: CppBridge+Events.swift + // ============================================================================ + + /** + * Emit an event to the native event system + * @param eventJson Event JSON with type, category, data + */ + emitEvent(eventJson: string): Promise; + + /** + * Poll for pending events from native + * @returns JSON array of events + */ + pollEvents(): Promise; + + // ============================================================================ + // HTTP Client + // Matches Swift: CppBridge+HTTP.swift + // ============================================================================ + + /** + * Configure HTTP client + * @param baseUrl Base URL for API + * @param apiKey API key for authentication + * @returns true if configured successfully + */ + configureHttp(baseUrl: string, apiKey: string): Promise; + + /** + * Make HTTP POST request + * @param path API path + * @param bodyJson Request body JSON + * @returns Response JSON + */ + httpPost(path: string, bodyJson: string): Promise; + + /** + * Make HTTP GET request + * @param path API path + * @returns Response JSON + */ + httpGet(path: string): Promise; + + // ============================================================================ + // Utility Functions + // ============================================================================ + + /** + * Get the last error message + */ + getLastError(): Promise; + + /** + * Extract an archive (tar.bz2, tar.gz, zip) + * @param archivePath Path to the archive + * @param destPath Destination directory + */ + extractArchive(archivePath: string, destPath: string): Promise; + + /** + * Get device capabilities + * @returns JSON string with device info + */ + getDeviceCapabilities(): Promise; + + /** + * Get memory usage + * @returns Current memory usage in bytes + */ + getMemoryUsage(): Promise; + + // ============================================================================ + // LLM Capability (Backend-Agnostic) + // Matches Swift: CppBridge+LLM.swift - calls rac_llm_component_* APIs + // Requires a backend (e.g., @runanywhere/llamacpp) to be registered + // ============================================================================ + + /** + * Load a text generation model + * @param modelPath Path to the model file + * @param configJson Optional configuration JSON + * @returns true if model loaded successfully + */ + loadTextModel(modelPath: string, configJson?: string): Promise; + + /** + * Check if a text model is loaded + */ + isTextModelLoaded(): Promise; + + /** + * Unload the current text model + */ + unloadTextModel(): Promise; + + /** + * Generate text from a prompt + * @param prompt Input prompt + * @param optionsJson Generation options JSON + * @returns Generated text result as JSON + */ + generate(prompt: string, optionsJson?: string): Promise; + + /** + * Generate text with streaming (callback-based) + * @param prompt Input prompt + * @param optionsJson Generation options JSON + * @param callback Token callback (token: string, isComplete: boolean) => void + * @returns Final result as JSON + */ + generateStream( + prompt: string, + optionsJson: string, + callback: (token: string, isComplete: boolean) => void + ): Promise; + + /** + * Cancel ongoing text generation + */ + cancelGeneration(): Promise; + + /** + * Generate structured output (JSON) from a prompt + * @param prompt Input prompt + * @param schema JSON schema for output + * @param optionsJson Generation options JSON + * @returns Structured output as JSON + */ + generateStructured( + prompt: string, + schema: string, + optionsJson?: string + ): Promise; + + // ============================================================================ + // STT Capability (Backend-Agnostic) + // Matches Swift: CppBridge+STT.swift - calls rac_stt_component_* APIs + // Requires a backend (e.g., @runanywhere/onnx) to be registered + // ============================================================================ + + /** + * Load a speech-to-text model + * @param modelPath Path to the model file + * @param modelType Model type identifier + * @param configJson Optional configuration JSON + * @returns true if model loaded successfully + */ + loadSTTModel( + modelPath: string, + modelType: string, + configJson?: string + ): Promise; + + /** + * Check if an STT model is loaded + */ + isSTTModelLoaded(): Promise; + + /** + * Unload the current STT model + */ + unloadSTTModel(): Promise; + + /** + * Transcribe audio data + * @param audioBase64 Base64 encoded audio data + * @param sampleRate Audio sample rate + * @param language Language code (optional) + * @returns Transcription result as JSON + */ + transcribe( + audioBase64: string, + sampleRate: number, + language?: string + ): Promise; + + /** + * Transcribe an audio file + * @param filePath Path to the audio file + * @param language Language code (optional) + * @returns Transcription result as JSON + */ + transcribeFile(filePath: string, language?: string): Promise; + + // ============================================================================ + // TTS Capability (Backend-Agnostic) + // Matches Swift: CppBridge+TTS.swift - calls rac_tts_component_* APIs + // Requires a backend (e.g., @runanywhere/onnx) to be registered + // ============================================================================ + + /** + * Load a text-to-speech model/voice + * @param modelPath Path to the model file + * @param modelType Model type identifier + * @param configJson Optional configuration JSON + * @returns true if model loaded successfully + */ + loadTTSModel( + modelPath: string, + modelType: string, + configJson?: string + ): Promise; + + /** + * Check if a TTS model is loaded + */ + isTTSModelLoaded(): Promise; + + /** + * Unload the current TTS model + */ + unloadTTSModel(): Promise; + + /** + * Synthesize speech from text + * @param text Text to synthesize + * @param voiceId Voice ID to use + * @param speedRate Speech speed rate (1.0 = normal) + * @param pitchShift Pitch shift (-1.0 to 1.0) + * @returns Synthesized audio as base64 encoded JSON + */ + synthesize( + text: string, + voiceId: string, + speedRate: number, + pitchShift: number + ): Promise; + + /** + * Get available TTS voices + * @returns JSON array of voice info + */ + getTTSVoices(): Promise; + + /** + * Cancel ongoing TTS synthesis + */ + cancelTTS(): Promise; + + // ============================================================================ + // VAD Capability (Backend-Agnostic) + // Matches Swift: CppBridge+VAD.swift - calls rac_vad_component_* APIs + // Requires a backend (e.g., @runanywhere/onnx) to be registered + // ============================================================================ + + /** + * Load a voice activity detection model + * @param modelPath Path to the model file + * @param configJson Optional configuration JSON + * @returns true if model loaded successfully + */ + loadVADModel(modelPath: string, configJson?: string): Promise; + + /** + * Check if a VAD model is loaded + */ + isVADModelLoaded(): Promise; + + /** + * Unload the current VAD model + */ + unloadVADModel(): Promise; + + /** + * Process audio for voice activity detection + * @param audioBase64 Base64 encoded audio data + * @param optionsJson VAD options JSON + * @returns VAD result as JSON + */ + processVAD(audioBase64: string, optionsJson?: string): Promise; + + /** + * Reset VAD state + */ + resetVAD(): Promise; + + // ============================================================================ + // Secure Storage + // Matches Swift: KeychainManager.swift + // Uses platform secure storage (Keychain on iOS, Keystore on Android) + // ============================================================================ + + /** + * Store a string value securely + * @param key Storage key (e.g., "com.runanywhere.sdk.apiKey") + * @param value String value to store + * @returns true if stored successfully + */ + secureStorageSet(key: string, value: string): Promise; + + /** + * Retrieve a string value from secure storage + * @param key Storage key + * @returns Stored value or null if not found + */ + secureStorageGet(key: string): Promise; + + /** + * Delete a value from secure storage + * @param key Storage key + * @returns true if deleted successfully + */ + secureStorageDelete(key: string): Promise; + + /** + * Check if a key exists in secure storage + * @param key Storage key + * @returns true if key exists + */ + secureStorageExists(key: string): Promise; + + /** + * Get persistent device UUID + * This UUID survives app reinstalls (stored in Keychain/Keystore) + * Matches Swift: DeviceIdentity.persistentUUID + * @returns Persistent device UUID + */ + getPersistentDeviceUUID(): Promise; + + // ============================================================================ + // Telemetry + // Matches Swift: CppBridge+Telemetry.swift + // C++ handles all telemetry logic - batching, JSON building, routing + // ============================================================================ + + /** + * Flush pending telemetry events immediately + * Sends all queued events to the backend + */ + flushTelemetry(): Promise; + + /** + * Check if telemetry is initialized + */ + isTelemetryInitialized(): Promise; + + // ============================================================================ + // Voice Agent Capability (Backend-Agnostic) + // Matches Swift: CppBridge+VoiceAgent.swift - calls rac_voice_agent_* APIs + // Requires STT, LLM, and TTS backends to be registered + // ============================================================================ + + /** + * Initialize voice agent with configuration + * @param configJson Configuration JSON + * @returns true if initialized successfully + */ + initializeVoiceAgent(configJson: string): Promise; + + /** + * Initialize voice agent using already loaded models + * @returns true if initialized successfully + */ + initializeVoiceAgentWithLoadedModels(): Promise; + + /** + * Check if voice agent is ready + */ + isVoiceAgentReady(): Promise; + + /** + * Get voice agent component states + * @returns JSON with component states + */ + getVoiceAgentComponentStates(): Promise; + + /** + * Process a voice turn (STT -> LLM -> TTS) + * @param audioBase64 Base64 encoded audio input + * @returns Voice agent result as JSON + */ + processVoiceTurn(audioBase64: string): Promise; + + /** + * Transcribe audio using voice agent + * @param audioBase64 Base64 encoded audio data + * @returns Transcription text + */ + voiceAgentTranscribe(audioBase64: string): Promise; + + /** + * Generate response using voice agent + * @param prompt Text prompt + * @returns Generated response text + */ + voiceAgentGenerateResponse(prompt: string): Promise; + + /** + * Synthesize speech using voice agent + * @param text Text to synthesize + * @returns Synthesized audio as base64 + */ + voiceAgentSynthesizeSpeech(text: string): Promise; + + /** + * Cleanup voice agent resources + */ + cleanupVoiceAgent(): Promise; +} diff --git a/sdk/runanywhere-react-native/packages/core/src/specs/RunAnywhereDeviceInfo.nitro.ts b/sdk/runanywhere-react-native/packages/core/src/specs/RunAnywhereDeviceInfo.nitro.ts new file mode 100644 index 000000000..7fb67686a --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/specs/RunAnywhereDeviceInfo.nitro.ts @@ -0,0 +1,73 @@ +import { type HybridObject } from 'react-native-nitro-modules'; + +/** + * Device information interface for RunAnywhere SDK. + * Provides device capabilities, memory info, and battery status. + */ +export interface RunAnywhereDeviceInfo + extends HybridObject<{ ios: 'swift'; android: 'kotlin' }> { + /** + * Get device model name + */ + getDeviceModel(): Promise; + + /** + * Get OS version + */ + getOSVersion(): Promise; + + /** + * Get platform name (ios/android) + */ + getPlatform(): Promise; + + /** + * Get total RAM in bytes + */ + getTotalRAM(): Promise; + + /** + * Get available RAM in bytes + */ + getAvailableRAM(): Promise; + + /** + * Get number of CPU cores + */ + getCPUCores(): Promise; + + /** + * Check if device has GPU + */ + hasGPU(): Promise; + + /** + * Check if device has NPU (Neural Processing Unit) + */ + hasNPU(): Promise; + + /** + * Get chip/processor name + */ + getChipName(): Promise; + + /** + * Get thermal state (0=nominal, 1=fair, 2=serious, 3=critical) + */ + getThermalState(): Promise; + + /** + * Get battery level (0.0 to 1.0) + */ + getBatteryLevel(): Promise; + + /** + * Check if device is charging + */ + isCharging(): Promise; + + /** + * Check if low power mode is enabled + */ + isLowPowerMode(): Promise; +} diff --git a/sdk/runanywhere-react-native/packages/core/src/types/LLMTypes.ts b/sdk/runanywhere-react-native/packages/core/src/types/LLMTypes.ts new file mode 100644 index 000000000..55152a33c --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/types/LLMTypes.ts @@ -0,0 +1,127 @@ +/** + * LLMTypes.ts + * + * Type definitions for LLM streaming functionality. + * + * Reference: sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/LLM/LLMTypes.swift + */ + +/** + * LLM generation options + */ +export interface LLMGenerationOptions { + /** Maximum tokens to generate */ + maxTokens?: number; + + /** Temperature (0.0 - 2.0) */ + temperature?: number; + + /** Top-p sampling */ + topP?: number; + + /** Top-k sampling */ + topK?: number; + + /** Stop sequences */ + stopSequences?: string[]; + + /** System prompt */ + systemPrompt?: string; + + /** Enable streaming */ + streamingEnabled?: boolean; +} + +/** + * LLM generation result + */ +export interface LLMGenerationResult { + /** Generated text */ + text: string; + + /** Thinking content (for models with reasoning) */ + thinkingContent?: string; + + /** Input tokens count */ + inputTokens: number; + + /** Output tokens count */ + tokensUsed: number; + + /** Model ID used */ + modelUsed: string; + + /** Total latency in ms */ + latencyMs: number; + + /** Framework used */ + framework: string; + + /** Tokens per second */ + tokensPerSecond: number; + + /** Time to first token in ms */ + timeToFirstTokenMs?: number; + + /** Thinking tokens count */ + thinkingTokens: number; + + /** Response tokens count */ + responseTokens: number; +} + +/** + * LLM streaming result + * Contains both a stream for real-time tokens and a promise for final metrics + */ +export interface LLMStreamingResult { + /** Async iterator for tokens */ + stream: AsyncIterable; + + /** Promise that resolves to final result with metrics */ + result: Promise; + + /** Cancel the generation */ + cancel: () => void; +} + +/** + * LLM streaming metrics collector state + */ +export interface LLMStreamingMetrics { + /** Full generated text */ + fullText: string; + + /** Total token count */ + tokenCount: number; + + /** Time to first token in ms */ + timeToFirstTokenMs?: number; + + /** Total generation time in ms */ + totalTimeMs: number; + + /** Tokens per second */ + tokensPerSecond: number; + + /** Whether generation completed successfully */ + completed: boolean; + + /** Error if generation failed */ + error?: string; +} + +/** + * Token callback for streaming + */ +export type LLMTokenCallback = (token: string) => void; + +/** + * Stream completion callback + */ +export type LLMStreamCompleteCallback = (result: LLMGenerationResult) => void; + +/** + * Stream error callback + */ +export type LLMStreamErrorCallback = (error: Error) => void; diff --git a/sdk/runanywhere-react-native/packages/core/src/types/STTTypes.ts b/sdk/runanywhere-react-native/packages/core/src/types/STTTypes.ts new file mode 100644 index 000000000..36d3b5d8c --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/types/STTTypes.ts @@ -0,0 +1,124 @@ +/** + * STTTypes.ts + * + * Type definitions for Speech-to-Text functionality. + * + * Reference: sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/STT/STTTypes.swift + */ + +/** + * STT options + */ +export interface STTOptions { + /** Language code (e.g., 'en') */ + language?: string; + + /** Sample rate in Hz (default: 16000) */ + sampleRate?: number; + + /** Enable word timestamps */ + timestamps?: boolean; + + /** Enable multiple alternatives */ + alternatives?: boolean; + + /** Maximum alternatives to return */ + maxAlternatives?: number; +} + +/** + * STT transcription output + */ +export interface STTOutput { + /** Transcribed text */ + text: string; + + /** Confidence score (0.0 - 1.0) */ + confidence: number; + + /** Word timestamps (if requested) */ + wordTimestamps?: WordTimestamp[]; + + /** Detected language */ + detectedLanguage?: string; + + /** Alternative transcriptions */ + alternatives?: STTAlternative[]; + + /** Transcription metadata */ + metadata: TranscriptionMetadata; +} + +/** + * Word timestamp + */ +export interface WordTimestamp { + word: string; + startTime: number; + endTime: number; + confidence?: number; +} + +/** + * Alternative transcription + */ +export interface STTAlternative { + text: string; + confidence: number; +} + +/** + * Transcription metadata + */ +export interface TranscriptionMetadata { + /** Model ID used */ + modelId: string; + + /** Processing time in seconds */ + processingTime: number; + + /** Audio length in seconds */ + audioLength: number; + + /** Real-time factor (processing time / audio length) */ + realTimeFactor?: number; +} + +/** + * STT partial result (for streaming) + */ +export interface STTPartialResult { + /** Partial transcript */ + transcript: string; + + /** Confidence (if available) */ + confidence?: number; + + /** Word timestamps (if available) */ + timestamps?: WordTimestamp[]; + + /** Detected language */ + language?: string; + + /** Alternative transcriptions */ + alternatives?: STTAlternative[]; + + /** Whether this is the final result */ + isFinal: boolean; +} + +/** + * STT streaming callback + */ +export type STTStreamCallback = (result: STTPartialResult) => void; + +/** + * STT streaming options + */ +export interface STTStreamOptions extends STTOptions { + /** Callback for partial results */ + onPartialResult?: STTStreamCallback; + + /** Interval for partial results in ms */ + partialResultInterval?: number; +} diff --git a/sdk/runanywhere-react-native/packages/core/src/types/StructuredOutputTypes.ts b/sdk/runanywhere-react-native/packages/core/src/types/StructuredOutputTypes.ts new file mode 100644 index 000000000..1afa4f421 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/types/StructuredOutputTypes.ts @@ -0,0 +1,156 @@ +/** + * StructuredOutputTypes.ts + * + * Type definitions for Structured Output functionality. + * + * Reference: sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/LLM/StructuredOutput/ + */ + +/** + * JSON Schema type + */ +export type JSONSchemaType = + | 'string' + | 'number' + | 'integer' + | 'boolean' + | 'object' + | 'array' + | 'null'; + +/** + * JSON Schema property + */ +export interface JSONSchemaProperty { + type?: JSONSchemaType | JSONSchemaType[]; + description?: string; + enum?: (string | number | boolean)[]; + const?: string | number | boolean; + default?: unknown; + + // String validations + minLength?: number; + maxLength?: number; + pattern?: string; + format?: string; + + // Number validations + minimum?: number; + maximum?: number; + exclusiveMinimum?: number; + exclusiveMaximum?: number; + multipleOf?: number; + + // Array validations + items?: JSONSchema; + minItems?: number; + maxItems?: number; + uniqueItems?: boolean; + + // Object validations + properties?: Record; + required?: string[]; + additionalProperties?: boolean | JSONSchema; +} + +/** + * JSON Schema definition + */ +export interface JSONSchema extends JSONSchemaProperty { + $schema?: string; + $id?: string; + title?: string; + definitions?: Record; + $ref?: string; + + // Composition + allOf?: JSONSchema[]; + anyOf?: JSONSchema[]; + oneOf?: JSONSchema[]; + not?: JSONSchema; +} + +/** + * Structured output options + */ +export interface StructuredOutputOptions { + /** Maximum tokens to generate */ + maxTokens?: number; + + /** Temperature for generation (0.0 - 2.0) */ + temperature?: number; + + /** Strict schema adherence */ + strict?: boolean; + + /** Number of retries on parse failure */ + retries?: number; +} + +/** + * Structured output result + */ +export interface StructuredOutputResult { + /** Parsed data */ + data: T; + + /** Raw JSON string */ + raw: string; + + /** Whether generation was successful */ + success: boolean; + + /** Error message if failed */ + error?: string; +} + +/** + * Entity extraction result + */ +export interface EntityExtractionResult { + entities: T; + confidence: number; +} + +/** + * Classification result + */ +export interface ClassificationResult { + category: string; + confidence: number; + alternatives?: Array<{ + category: string; + confidence: number; + }>; +} + +/** + * Sentiment analysis result + */ +export interface SentimentResult { + sentiment: 'positive' | 'negative' | 'neutral'; + score: number; + aspects?: Array<{ + aspect: string; + sentiment: 'positive' | 'negative' | 'neutral'; + score: number; + }>; +} + +/** + * Named entity result + */ +export interface NamedEntity { + text: string; + type: string; + startOffset: number; + endOffset: number; + confidence: number; +} + +/** + * Named entity recognition result + */ +export interface NERResult { + entities: NamedEntity[]; +} diff --git a/sdk/runanywhere-react-native/packages/core/src/types/TTSTypes.ts b/sdk/runanywhere-react-native/packages/core/src/types/TTSTypes.ts new file mode 100644 index 000000000..c079ffd0d --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/types/TTSTypes.ts @@ -0,0 +1,126 @@ +/** + * TTSTypes.ts + * + * Type definitions for Text-to-Speech functionality. + * + * Reference: sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/TTS/TTSTypes.swift + */ + +/** + * TTS synthesis options + */ +export interface TTSOptions { + /** Voice ID to use */ + voice?: string; + + /** Speech rate multiplier (default: 1.0) */ + rate?: number; + + /** Pitch adjustment (default: 1.0) */ + pitch?: number; + + /** Volume (0.0 - 1.0, default: 1.0) */ + volume?: number; + + /** Sample rate in Hz (default: 22050) */ + sampleRate?: number; + + /** Language code (e.g., 'en-US') */ + language?: string; + + /** Audio format */ + audioFormat?: AudioFormat; +} + +/** + * Audio format enum + */ +export type AudioFormat = 'pcm' | 'wav' | 'mp3'; + +/** + * TTS synthesis output + */ +export interface TTSOutput { + /** Audio data (base64 encoded or raw PCM) */ + audioData: string; + + /** Audio format */ + format: AudioFormat; + + /** Duration in seconds */ + duration: number; + + /** Phoneme timestamps (if available) */ + phonemeTimestamps?: PhonemeTimestamp[]; + + /** Synthesis metadata */ + metadata: TTSSynthesisMetadata; +} + +/** + * Phoneme timestamp + */ +export interface PhonemeTimestamp { + phoneme: string; + startTime: number; + endTime: number; +} + +/** + * TTS synthesis metadata + */ +export interface TTSSynthesisMetadata { + /** Voice used */ + voice: string; + + /** Language */ + language?: string; + + /** Processing time in seconds */ + processingTime: number; + + /** Character count of input text */ + characterCount: number; +} + +/** + * TTS speak result (simple playback API) + */ +export interface TTSSpeakResult { + /** Duration of audio in seconds */ + duration: number; + + /** Voice used */ + voice: string; + + /** Processing time in seconds */ + processingTime: number; + + /** Character count */ + characterCount: number; +} + +/** + * TTS voice info + */ +export interface TTSVoiceInfo { + /** Voice ID */ + id: string; + + /** Display name */ + name: string; + + /** Language code */ + language: string; + + /** Gender */ + gender?: 'male' | 'female' | 'neutral'; + + /** Whether this voice is downloaded */ + isDownloaded: boolean; +} + +/** + * TTS stream chunk callback + */ +export type TTSStreamChunkCallback = (audioChunk: ArrayBuffer) => void; diff --git a/sdk/runanywhere-react-native/packages/core/src/types/VADTypes.ts b/sdk/runanywhere-react-native/packages/core/src/types/VADTypes.ts new file mode 100644 index 000000000..f075cca91 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/types/VADTypes.ts @@ -0,0 +1,70 @@ +/** + * VADTypes.ts + * + * Type definitions for Voice Activity Detection functionality. + * + * Reference: sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/VAD/VADTypes.swift + */ + +/** + * VAD configuration options + */ +export interface VADConfiguration { + /** Sample rate in Hz (default: 16000) */ + sampleRate?: number; + + /** Frame length in seconds (default: 0.1) */ + frameLength?: number; + + /** Energy threshold for speech detection (default: 0.005) */ + energyThreshold?: number; +} + +/** + * VAD processing result + */ +export interface VADResult { + /** Whether speech was detected */ + isSpeech: boolean; + + /** Speech probability (0.0 - 1.0) */ + probability: number; + + /** Start time of speech segment (seconds) */ + startTime?: number; + + /** End time of speech segment (seconds) */ + endTime?: number; +} + +/** + * Speech activity event types + */ +export type SpeechActivityEvent = 'started' | 'ended'; + +/** + * VAD speech activity callback + */ +export type VADSpeechActivityCallback = (event: SpeechActivityEvent) => void; + +/** + * VAD audio buffer callback + */ +export type VADAudioBufferCallback = (samples: Float32Array) => void; + +/** + * VAD state + */ +export interface VADState { + /** Whether VAD is initialized */ + isInitialized: boolean; + + /** Whether VAD is currently running */ + isRunning: boolean; + + /** Whether speech is currently active */ + isSpeechActive: boolean; + + /** Current speech probability */ + currentProbability: number; +} diff --git a/sdk/runanywhere-react-native/packages/core/src/types/VoiceAgentTypes.ts b/sdk/runanywhere-react-native/packages/core/src/types/VoiceAgentTypes.ts new file mode 100644 index 000000000..d6d71ca5f --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/types/VoiceAgentTypes.ts @@ -0,0 +1,182 @@ +/** + * VoiceAgentTypes.ts + * + * Type definitions for Voice Agent functionality. + * + * Reference: sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/VoiceAgent/ + */ + +/** + * Component load state + */ +export type ComponentLoadState = 'notLoaded' | 'loading' | 'loaded' | 'failed'; + +/** + * Individual component state + */ +export interface ComponentState { + state: ComponentLoadState; + modelId?: string; + voiceId?: string; +} + +/** + * Voice agent component states + */ +export interface VoiceAgentComponentStates { + stt: ComponentState; + llm: ComponentState; + tts: ComponentState; + isFullyReady: boolean; +} + +/** + * Voice agent configuration + */ +export interface VoiceAgentConfig { + /** STT model ID */ + sttModelId?: string; + + /** LLM model ID */ + llmModelId?: string; + + /** TTS voice ID */ + ttsVoiceId?: string; + + /** VAD sample rate (default: 16000) */ + vadSampleRate?: number; + + /** VAD frame length (default: 512) */ + vadFrameLength?: number; + + /** VAD energy threshold (default: 0.1) */ + vadEnergyThreshold?: number; + + /** Language code (e.g., 'en') */ + language?: string; + + /** System prompt for LLM */ + systemPrompt?: string; +} + +/** + * Voice turn result + */ +export interface VoiceTurnResult { + /** Whether speech was detected */ + speechDetected: boolean; + + /** Transcribed text from audio */ + transcription: string; + + /** Generated response text */ + response: string; + + /** Base64-encoded synthesized audio */ + synthesizedAudio?: string; + + /** Audio sample rate */ + sampleRate: number; +} + +/** + * Voice session event types + */ +export type VoiceSessionEventType = + | 'started' + | 'speechDetected' + | 'transcriptionComplete' + | 'responseGenerated' + | 'speechSynthesized' + | 'turnComplete' + | 'error' + | 'ended'; + +/** + * Voice session event + */ +export interface VoiceSessionEvent { + type: VoiceSessionEventType; + timestamp: number; + data?: { + transcription?: string; + response?: string; + audio?: string; + error?: string; + }; +} + +/** + * Voice session callback + */ +export type VoiceSessionCallback = (event: VoiceSessionEvent) => void; + +/** + * Voice agent metrics + */ +export interface VoiceAgentMetrics { + /** Time for STT processing (ms) */ + sttLatencyMs: number; + + /** Time for LLM generation (ms) */ + llmLatencyMs: number; + + /** Time for TTS synthesis (ms) */ + ttsLatencyMs: number; + + /** Total turn latency (ms) */ + totalLatencyMs: number; + + /** Number of tokens generated */ + tokensGenerated: number; + + /** Audio duration (seconds) */ + audioDurationSeconds: number; +} + +/** + * Voice session configuration (matches Swift VoiceSessionConfig) + */ +export interface VoiceSessionConfig { + /** Silence duration (seconds) before processing speech (default: 1.5) */ + silenceDuration?: number; + + /** Minimum audio level to detect speech (0.0 - 1.0, default: 0.1) */ + speechThreshold?: number; + + /** Whether to auto-play TTS response (default: true) */ + autoPlayTTS?: boolean; + + /** Whether to auto-resume listening after TTS playback (default: true) */ + continuousMode?: boolean; + + /** Language code (default: 'en') */ + language?: string; + + /** System prompt for LLM */ + systemPrompt?: string; +} + +/** + * Voice session events (matches Swift VoiceSessionEvent) + */ +export type VoiceSessionEventKind = + | { type: 'started' } + | { type: 'listening'; audioLevel: number } + | { type: 'speechStarted' } + | { type: 'processing' } + | { type: 'transcribed'; text: string } + | { type: 'responded'; text: string } + | { type: 'speaking' } + | { type: 'turnCompleted'; transcript: string; response: string; audio?: string } + | { type: 'stopped' } + | { type: 'error'; message: string }; + +/** + * Voice session error types + */ +export enum VoiceSessionErrorType { + MicrophonePermissionDenied = 'microphonePermissionDenied', + NotReady = 'notReady', + AlreadyRunning = 'alreadyRunning', +} diff --git a/sdk/runanywhere-react-native/packages/core/src/types/enums.ts b/sdk/runanywhere-react-native/packages/core/src/types/enums.ts new file mode 100644 index 000000000..8107d8368 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/types/enums.ts @@ -0,0 +1,258 @@ +/** + * RunAnywhere React Native SDK - Enums + * + * These enums match the iOS Swift SDK exactly for consistency. + * Reference: sdk/runanywhere-swift/Sources/RunAnywhere/Core/ + */ + +/** + * SDK environment for configuration and behavior + */ +export enum SDKEnvironment { + Development = 'development', + Staging = 'staging', + Production = 'production', +} + +/** + * Execution target for generation requests + */ +export enum ExecutionTarget { + OnDevice = 'onDevice', + Cloud = 'cloud', + Hybrid = 'hybrid', +} + +/** + * Supported LLM frameworks + * Reference: LLMFramework.swift + */ +export enum LLMFramework { + CoreML = 'CoreML', + TensorFlowLite = 'TFLite', + MLX = 'MLX', + SwiftTransformers = 'SwiftTransformers', + ONNX = 'ONNX', + ExecuTorch = 'ExecuTorch', + LlamaCpp = 'LlamaCpp', + FoundationModels = 'FoundationModels', + PicoLLM = 'PicoLLM', + MLC = 'MLC', + MediaPipe = 'MediaPipe', + WhisperKit = 'WhisperKit', + OpenAIWhisper = 'OpenAIWhisper', + SystemTTS = 'SystemTTS', + PiperTTS = 'PiperTTS', +} + +/** + * Human-readable display names for frameworks + */ +export const LLMFrameworkDisplayNames: Record = { + [LLMFramework.CoreML]: 'Core ML', + [LLMFramework.TensorFlowLite]: 'TensorFlow Lite', + [LLMFramework.MLX]: 'MLX', + [LLMFramework.SwiftTransformers]: 'Swift Transformers', + [LLMFramework.ONNX]: 'ONNX Runtime', + [LLMFramework.ExecuTorch]: 'ExecuTorch', + [LLMFramework.LlamaCpp]: 'llama.cpp', + [LLMFramework.FoundationModels]: 'Foundation Models', + [LLMFramework.PicoLLM]: 'Pico LLM', + [LLMFramework.MLC]: 'MLC', + [LLMFramework.MediaPipe]: 'MediaPipe', + [LLMFramework.WhisperKit]: 'WhisperKit', + [LLMFramework.OpenAIWhisper]: 'OpenAI Whisper', + [LLMFramework.SystemTTS]: 'System TTS', + [LLMFramework.PiperTTS]: 'Piper TTS', +}; + +/** + * Model categories based on input/output modality + * Reference: ModelCategory.swift + */ +export enum ModelCategory { + Language = 'language', + SpeechRecognition = 'speech-recognition', + SpeechSynthesis = 'speech-synthesis', + Vision = 'vision', + ImageGeneration = 'image-generation', + Multimodal = 'multimodal', + Audio = 'audio', +} + +/** + * Human-readable display names for model categories + */ +export const ModelCategoryDisplayNames: Record = { + [ModelCategory.Language]: 'Language Model', + [ModelCategory.SpeechRecognition]: 'Speech Recognition', + [ModelCategory.SpeechSynthesis]: 'Text-to-Speech', + [ModelCategory.Vision]: 'Vision Model', + [ModelCategory.ImageGeneration]: 'Image Generation', + [ModelCategory.Multimodal]: 'Multimodal', + [ModelCategory.Audio]: 'Audio Processing', +}; + +/** + * Model file formats + * Reference: ModelFormat.swift + */ +export enum ModelFormat { + GGUF = 'gguf', + GGML = 'ggml', + ONNX = 'onnx', + MLModel = 'mlmodel', + MLPackage = 'mlpackage', + TFLite = 'tflite', + SafeTensors = 'safetensors', + Bin = 'bin', + Zip = 'zip', + Folder = 'folder', + Proprietary = 'proprietary', // Built-in system models + Unknown = 'unknown', +} + +/** + * Framework modality (input/output types) + * Reference: FrameworkModality.swift + */ +export enum FrameworkModality { + TextToText = 'textToText', + VoiceToText = 'voiceToText', + TextToVoice = 'textToVoice', + ImageToText = 'imageToText', + TextToImage = 'textToImage', + Multimodal = 'multimodal', +} + +/** + * Component state for lifecycle management + */ +export enum ComponentState { + NotInitialized = 'notInitialized', + Initializing = 'initializing', + Ready = 'ready', + Error = 'error', + CleaningUp = 'cleaningUp', +} + +/** + * SDK component identifiers + * Note: Values match iOS SDK rawValue + * Reference: sdk/runanywhere-swift/Sources/RunAnywhere/Core/Types/ComponentTypes.swift + */ +export enum SDKComponent { + LLM = 'llm', + STT = 'stt', + TTS = 'tts', + VAD = 'vad', + Embedding = 'embedding', + SpeakerDiarization = 'speakerDiarization', + VoiceAgent = 'voice', +} + +/** + * Routing policy for execution decisions + */ +export enum RoutingPolicy { + OnDevicePreferred = 'onDevicePreferred', + CloudPreferred = 'cloudPreferred', + OnDeviceOnly = 'onDeviceOnly', + CloudOnly = 'cloudOnly', + Hybrid = 'hybrid', + CostOptimized = 'costOptimized', + LatencyOptimized = 'latencyOptimized', + PrivacyOptimized = 'privacyOptimized', +} + +/** + * Privacy mode for data handling + */ +export enum PrivacyMode { + Public = 'public', + Private = 'private', + Restricted = 'restricted', +} + +/** + * Hardware acceleration types + */ +export enum HardwareAcceleration { + CPU = 'cpu', + GPU = 'gpu', + NeuralEngine = 'neuralEngine', + NPU = 'npu', +} + +/** + * Audio format for STT/TTS + * Reference: sdk/runanywhere-swift/Sources/RunAnywhere/Core/Types/AudioTypes.swift + */ +export enum AudioFormat { + PCM = 'pcm', + WAV = 'wav', + MP3 = 'mp3', + M4A = 'm4a', + FLAC = 'flac', + OPUS = 'opus', + AAC = 'aac', +} + +/** + * Get MIME type for audio format + * @param format Audio format + * @returns MIME type string + */ +export function getAudioFormatMimeType(format: AudioFormat): string { + switch (format) { + case AudioFormat.PCM: + return 'audio/pcm'; + case AudioFormat.WAV: + return 'audio/wav'; + case AudioFormat.MP3: + return 'audio/mpeg'; + case AudioFormat.OPUS: + return 'audio/opus'; + case AudioFormat.AAC: + return 'audio/aac'; + case AudioFormat.FLAC: + return 'audio/flac'; + case AudioFormat.M4A: + return 'audio/mp4'; + } +} + +/** + * Get file extension for audio format + * @param format Audio format + * @returns File extension string (matches enum value) + */ +export function getAudioFormatFileExtension(format: AudioFormat): string { + return format; +} + +/** + * Configuration source + */ +export enum ConfigurationSource { + Remote = 'remote', + Local = 'local', + Builtin = 'builtin', +} + +/** + * Event types for categorization + */ +export enum SDKEventType { + Initialization = 'initialization', + Configuration = 'configuration', + Generation = 'generation', + Model = 'model', + Voice = 'voice', + Storage = 'storage', + Framework = 'framework', + Device = 'device', + Error = 'error', + Performance = 'performance', + Network = 'network', +} diff --git a/sdk/runanywhere-react-native/packages/core/src/types/events.ts b/sdk/runanywhere-react-native/packages/core/src/types/events.ts new file mode 100644 index 000000000..f5499b1ce --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/types/events.ts @@ -0,0 +1,337 @@ +/** + * RunAnywhere React Native SDK - Event Types + * + * These event types match the iOS Swift SDK event system. + * Reference: sdk/runanywhere-swift/Sources/RunAnywhere/Public/Events/SDKEvent.swift + */ + +import type { LLMFramework, SDKComponent, SDKEventType } from './enums'; +import type { + DefaultGenerationSettings, + DeviceInfoData, + FrameworkAvailability, + InitializationResult, + ModelInfo, + StorageInfo, + StoredModel, +} from './models'; + +// ============================================================================ +// Base Event Interface +// ============================================================================ + +/** + * Base interface for all SDK events + */ +export interface SDKEvent { + /** Event timestamp */ + timestamp: string; + + /** Event type category */ + eventType: SDKEventType; +} + +// ============================================================================ +// Initialization Events +// ============================================================================ + +export type SDKInitializationEvent = + | { type: 'started' } + | { type: 'configurationLoaded'; source: string } + | { type: 'servicesBootstrapped' } + | { type: 'completed' } + | { type: 'failed'; error: string }; + +// ============================================================================ +// Configuration Events +// ============================================================================ + +export type SDKConfigurationEvent = + | { type: 'fetchStarted' } + | { type: 'fetchCompleted'; source: string } + | { type: 'fetchFailed'; error: string } + | { type: 'loaded'; configuration: Record | null } + | { type: 'updated'; changes: string[] } + | { type: 'syncStarted' } + | { type: 'syncCompleted' } + | { type: 'syncFailed'; error: string } + | { type: 'settingsRequested' } + | { type: 'settingsRetrieved'; settings: DefaultGenerationSettings } + | { type: 'routingPolicyRequested' } + | { type: 'routingPolicyRetrieved'; policy: string } + | { type: 'privacyModeRequested' } + | { type: 'privacyModeRetrieved'; mode: string } + | { type: 'analyticsStatusRequested' } + | { type: 'analyticsStatusRetrieved'; enabled: boolean } + | { type: 'syncRequested' }; + +// ============================================================================ +// Generation Events +// ============================================================================ + +export type SDKGenerationEvent = + | { type: 'sessionStarted'; sessionId: string } + | { type: 'sessionEnded'; sessionId: string } + | { type: 'started'; prompt: string; sessionId?: string } + | { type: 'firstTokenGenerated'; token: string; latencyMs: number } + | { type: 'tokenGenerated'; token: string } + | { type: 'streamingUpdate'; text: string; tokensCount: number } + | { + type: 'completed'; + response: string; + tokensUsed: number; + latencyMs: number; + } + | { type: 'failed'; error: string } + | { type: 'modelLoaded'; modelId: string } + | { type: 'modelUnloaded'; modelId: string } + | { type: 'costCalculated'; amount: number; savedAmount: number } + | { type: 'routingDecision'; target: string; reason: string }; + +// ============================================================================ +// Model Events +// ============================================================================ + +export type SDKModelEvent = + | { type: 'loadStarted'; modelId: string } + | { type: 'loadProgress'; modelId: string; progress: number } + | { type: 'loadCompleted'; modelId: string } + | { type: 'loadFailed'; modelId: string; error: string } + | { type: 'unloadStarted' } + | { type: 'unloadCompleted' } + | { type: 'unloadFailed'; error: string } + | { type: 'downloadStarted'; modelId: string; taskId?: string } + | { + type: 'downloadProgress'; + modelId: string; + taskId?: string; + progress: number; + bytesDownloaded?: number; + totalBytes?: number; + downloadState?: string; + error?: string; + } + | { + type: 'downloadCompleted'; + modelId: string; + taskId?: string; + localPath?: string; + } + | { type: 'downloadFailed'; modelId: string; taskId?: string; error: string } + | { type: 'downloadCancelled'; modelId: string; taskId?: string } + | { type: 'listRequested' } + | { type: 'listCompleted'; models: ModelInfo[] } + | { type: 'listFailed'; error: string } + | { type: 'catalogLoaded'; models: ModelInfo[] } + | { type: 'deleteStarted'; modelId: string } + | { type: 'deleteCompleted'; modelId: string } + | { type: 'deleteFailed'; modelId: string; error: string } + | { type: 'customModelAdded'; name: string; url: string } + | { type: 'builtInModelRegistered'; modelId: string }; + +// ============================================================================ +// Voice Events +// ============================================================================ + +export type SDKVoiceEvent = + | { type: 'listeningStarted' } + | { type: 'listeningEnded' } + | { type: 'speechDetected' } + | { type: 'transcriptionStarted' } + | { type: 'transcriptionPartial'; text: string } + | { type: 'transcriptionFinal'; text: string } + | { type: 'responseGenerated'; text: string } + | { type: 'synthesisStarted' } + | { type: 'audioGenerated'; data: string } // base64 encoded + | { type: 'synthesisCompleted' } + | { type: 'pipelineError'; error: string } + | { type: 'pipelineStarted' } + | { type: 'pipelineCompleted' } + | { type: 'vadStarted' } + | { type: 'vadDetected' } + | { type: 'vadEnded' } + | { type: 'sttProcessing' } + | { type: 'llmProcessing' } + | { type: 'ttsProcessing' } + // Recording events + | { type: 'recordingStarted' } + | { type: 'recordingStopped'; duration?: number } + // Playback events + | { type: 'playbackStarted'; duration?: number } + | { type: 'playbackCompleted'; duration?: number } + | { type: 'playbackStopped' } + | { type: 'playbackPaused' } + | { type: 'playbackResumed' } + | { type: 'playbackFailed'; error: string } + // VAD events + | { type: 'vadInitialized' } + | { type: 'vadStopped' } + | { type: 'vadCleanedUp' } + | { type: 'speechStarted' } + | { type: 'speechEnded' } + // STT partial result events + | { type: 'sttPartialResult'; text?: string; confidence?: number } + | { type: 'sttCompleted'; text?: string; confidence?: number } + | { type: 'sttFailed'; error?: string } + // Voice session events + | { type: 'voiceSession_started' } + | { type: 'voiceSession_listening'; audioLevel?: number } + | { type: 'voiceSession_speechStarted' } + | { type: 'voiceSession_speechEnded' } + | { type: 'voiceSession_processing' } + | { type: 'voiceSession_transcribed'; transcription?: string } + | { type: 'voiceSession_responded'; response?: string } + | { type: 'voiceSession_speaking' } + | { type: 'voiceSession_turnCompleted'; transcription?: string; response?: string; audio?: string } + | { type: 'voiceSession_stopped' } + | { type: 'voiceSession_error'; error?: string }; + +// ============================================================================ +// Performance Events +// ============================================================================ + +export type SDKPerformanceEvent = + | { type: 'memoryWarning'; usage: number } + | { type: 'thermalStateChanged'; state: string } + | { type: 'latencyMeasured'; operation: string; milliseconds: number } + | { type: 'throughputMeasured'; tokensPerSecond: number }; + +// ============================================================================ +// Network Events +// ============================================================================ + +export type SDKNetworkEvent = + | { type: 'requestStarted'; url: string } + | { type: 'requestCompleted'; url: string; statusCode: number } + | { type: 'requestFailed'; url: string; error: string } + | { type: 'connectivityChanged'; isOnline: boolean }; + +// ============================================================================ +// Storage Events +// ============================================================================ + +export type SDKStorageEvent = + | { type: 'infoRequested' } + | { type: 'infoRetrieved'; info: StorageInfo } + | { type: 'modelsRequested' } + | { type: 'modelsRetrieved'; models: StoredModel[] } + | { type: 'clearCacheStarted' } + | { type: 'clearCacheCompleted' } + | { type: 'clearCacheFailed'; error: string } + | { type: 'cleanTempStarted' } + | { type: 'cleanTempCompleted' } + | { type: 'cleanTempFailed'; error: string } + | { type: 'deleteModelStarted'; modelId: string } + | { type: 'deleteModelCompleted'; modelId: string } + | { type: 'deleteModelFailed'; modelId: string; error: string }; + +// ============================================================================ +// Framework Events +// ============================================================================ + +export type SDKFrameworkEvent = + | { type: 'adapterRegistered'; framework: LLMFramework; name: string } + | { type: 'adaptersRequested' } + | { type: 'adaptersRetrieved'; count: number } + | { type: 'frameworksRequested' } + | { type: 'frameworksRetrieved'; frameworks: LLMFramework[] } + | { type: 'availabilityRequested' } + | { type: 'availabilityRetrieved'; availability: FrameworkAvailability[] } + | { type: 'modelsForFrameworkRequested'; framework: LLMFramework } + | { + type: 'modelsForFrameworkRetrieved'; + framework: LLMFramework; + models: ModelInfo[]; + } + | { type: 'frameworksForModalityRequested'; modality: string } + | { + type: 'frameworksForModalityRetrieved'; + modality: string; + frameworks: LLMFramework[]; + }; + +// ============================================================================ +// Device Events +// ============================================================================ + +export type SDKDeviceEvent = + | { type: 'deviceInfoCollected'; deviceInfo: DeviceInfoData } + | { type: 'deviceInfoCollectionFailed'; error: string } + | { type: 'deviceInfoRefreshed'; deviceInfo: DeviceInfoData } + | { type: 'deviceInfoSyncStarted' } + | { type: 'deviceInfoSyncCompleted' } + | { type: 'deviceInfoSyncFailed'; error: string } + | { type: 'deviceStateChanged'; property: string; newValue: string }; + +// ============================================================================ +// Component Initialization Events +// ============================================================================ + +export type ComponentInitializationEvent = + | { type: 'initializationStarted'; components: SDKComponent[] } + | { type: 'initializationCompleted'; result: InitializationResult } + | { + type: 'componentStateChanged'; + component: SDKComponent; + oldState: string; + newState: string; + } + | { type: 'componentChecking'; component: SDKComponent; modelId?: string } + | { + type: 'componentDownloadRequired'; + component: SDKComponent; + modelId: string; + sizeBytes: number; + } + | { + type: 'componentDownloadStarted'; + component: SDKComponent; + modelId: string; + } + | { + type: 'componentDownloadProgress'; + component: SDKComponent; + modelId: string; + progress: number; + } + | { + type: 'componentDownloadCompleted'; + component: SDKComponent; + modelId: string; + } + | { type: 'componentInitializing'; component: SDKComponent; modelId?: string } + | { type: 'componentReady'; component: SDKComponent; modelId?: string } + | { type: 'componentFailed'; component: SDKComponent; error: string } + | { type: 'parallelInitializationStarted'; components: SDKComponent[] } + | { type: 'sequentialInitializationStarted'; components: SDKComponent[] } + | { type: 'allComponentsReady' } + | { + type: 'someComponentsReady'; + ready: SDKComponent[]; + pending: SDKComponent[]; + }; + +// ============================================================================ +// Union Type for All Events +// ============================================================================ + +export type AnySDKEvent = + | SDKInitializationEvent + | SDKConfigurationEvent + | SDKGenerationEvent + | SDKModelEvent + | SDKVoiceEvent + | SDKPerformanceEvent + | SDKNetworkEvent + | SDKStorageEvent + | SDKFrameworkEvent + | SDKDeviceEvent + | ComponentInitializationEvent; + +// ============================================================================ +// Event Listener Types +// ============================================================================ + +export type SDKEventListener = (event: T) => void; + +export type UnsubscribeFunction = () => void; diff --git a/sdk/runanywhere-react-native/packages/core/src/types/external.d.ts b/sdk/runanywhere-react-native/packages/core/src/types/external.d.ts new file mode 100644 index 000000000..b424cccdf --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/types/external.d.ts @@ -0,0 +1,142 @@ +/** + * Type declarations for optional external modules + * These modules are dynamically imported and may not be installed + */ + +// react-native-fs is an optional dependency for file operations +declare module 'react-native-fs' { + const RNFS: { + DocumentDirectoryPath: string; + CachesDirectoryPath: string; + MainBundlePath: string; + LibraryDirectoryPath: string; + ExternalDirectoryPath: string; + ExternalStorageDirectoryPath: string; + TemporaryDirectoryPath: string; + DownloadDirectoryPath: string; + PicturesDirectoryPath: string; + + mkdir( + filepath: string, + options?: { NSURLIsExcludedFromBackupKey?: boolean } + ): Promise; + moveFile(filepath: string, destPath: string): Promise; + copyFile(filepath: string, destPath: string): Promise; + unlink(filepath: string): Promise; + exists(filepath: string): Promise; + readFile(filepath: string, encoding?: string): Promise; + writeFile( + filepath: string, + contents: string, + encoding?: string + ): Promise; + appendFile( + filepath: string, + contents: string, + encoding?: string + ): Promise; + stat(filepath: string): Promise<{ + name: string; + path: string; + size: number; + mode: number; + ctime: number; + mtime: number; + originalFilepath: string; + isFile: () => boolean; + isDirectory: () => boolean; + }>; + readDir(dirpath: string): Promise< + Array<{ + name: string; + path: string; + size: number; + ctime: Date; + mtime: Date; + isFile: () => boolean; + isDirectory: () => boolean; + }> + >; + hash(filepath: string, algorithm: string): Promise; + getFSInfo(): Promise<{ + totalSpace: number; + freeSpace: number; + }>; + downloadFile(options: { + fromUrl: string; + toFile: string; + headers?: Record; + background?: boolean; + begin?: (res: { + jobId: number; + contentLength: number; + statusCode: number; + }) => void; + progress?: (res: { + jobId: number; + bytesWritten: number; + contentLength: number; + }) => void; + progressDivider?: number; + }): { + jobId: number; + promise: Promise<{ + jobId: number; + statusCode: number; + bytesWritten: number; + }>; + }; + stopDownload(jobId: number): void; + }; + export = RNFS; +} + +// rn-fetch-blob is an optional dependency for large file downloads +declare module 'rn-fetch-blob' { + interface FetchBlobResponse { + path(): string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- External library JSON type + json(): any; + text(): string; + data: string; + info(): { status: number; headers: Record }; + flush(): void; + } + + interface StatefulPromise extends Promise { + cancel(): void; + progress( + callback: (received: number, total: number) => void + ): StatefulPromise; + } + + interface RNFetchBlob { + fs: { + dirs: { + DocumentDir: string; + CacheDir: string; + DownloadDir: string; + }; + exists(path: string): Promise; + unlink(path: string): Promise; + mkdir(path: string): Promise; + }; + config(options: { + fileCache?: boolean; + path?: string; + appendExt?: string; + timeout?: number; + }): { + fetch( + method: string, + url: string, + headers?: Record, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- External library body type + body?: any + ): StatefulPromise; + }; + } + + const RNFetchBlob: { default: RNFetchBlob }; + export = RNFetchBlob; +} diff --git a/sdk/runanywhere-react-native/packages/core/src/types/index.ts b/sdk/runanywhere-react-native/packages/core/src/types/index.ts new file mode 100644 index 000000000..36bf430c5 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/types/index.ts @@ -0,0 +1,146 @@ +/** + * RunAnywhere React Native SDK - Types + * + * Re-exports all types for convenient importing. + */ + +// Enums +export { + ComponentState, + ConfigurationSource, + ExecutionTarget, + FrameworkModality, + HardwareAcceleration, + LLMFramework, + LLMFrameworkDisplayNames, + ModelCategory, + ModelCategoryDisplayNames, + ModelFormat, + PrivacyMode, + RoutingPolicy, + SDKComponent, + SDKEnvironment, + SDKEventType, +} from './enums'; + +// Models +export type { + ComponentHealth, + ConfigurationData, + DefaultGenerationSettings, + DeviceInfoData, + FrameworkAvailability, + GeneratableType, + GenerationOptions, + GenerationResult, + InitializationResult, + LLMGenerationOptions, + ModelInfo, + ModelInfoMetadata, + PerformanceMetrics, + SDKInitOptions, + STTAlternative, + STTOptions, + STTResult, + STTSegment, + StorageInfo, + StoredModel, + StructuredOutputConfig, + StructuredOutputValidation, + ThinkingTagPattern, + TTSConfiguration, + TTSResult, + VADConfiguration, + VoiceAudioChunk, +} from './models'; + +// Events +export type { + AnySDKEvent, + ComponentInitializationEvent, + SDKConfigurationEvent, + SDKDeviceEvent, + SDKEvent, + SDKEventListener, + SDKFrameworkEvent, + SDKGenerationEvent, + SDKInitializationEvent, + SDKModelEvent, + SDKNetworkEvent, + SDKPerformanceEvent, + SDKStorageEvent, + SDKVoiceEvent, + UnsubscribeFunction, +} from './events'; + +// Voice Agent Types +export type { + ComponentLoadState, + ComponentState as VoiceAgentComponentState, + VoiceAgentComponentStates, + VoiceAgentConfig, + VoiceTurnResult, + VoiceSessionEventType, + VoiceSessionEvent, + VoiceSessionCallback, + VoiceAgentMetrics, +} from './VoiceAgentTypes'; + +// Structured Output Types +export type { + JSONSchemaType, + JSONSchemaProperty, + JSONSchema, + StructuredOutputOptions, + StructuredOutputResult, + EntityExtractionResult, + ClassificationResult, + SentimentResult, + NamedEntity, + NERResult, +} from './StructuredOutputTypes'; + +// VAD Types +export type { + VADConfiguration as VADConfig, + VADResult, + SpeechActivityEvent, + VADSpeechActivityCallback, + VADAudioBufferCallback, + VADState, +} from './VADTypes'; + +// TTS Types +export type { + TTSOptions as TTSOpts, + AudioFormat, + TTSOutput, + PhonemeTimestamp, + TTSSynthesisMetadata, + TTSSpeakResult, + TTSVoiceInfo, + TTSStreamChunkCallback, +} from './TTSTypes'; + +// STT Types +export type { + STTOptions as STTOpts, + STTOutput, + WordTimestamp, + STTAlternative as STTAlt, + TranscriptionMetadata, + STTPartialResult, + STTStreamCallback, + STTStreamOptions, +} from './STTTypes'; + +// LLM Types +export type { + LLMGenerationOptions as LLMGenOptions, + LLMGenerationResult as LLMGenResult, + LLMStreamingResult, + LLMStreamingMetrics, + LLMTokenCallback, + LLMStreamCompleteCallback, + LLMStreamErrorCallback, +} from './LLMTypes'; diff --git a/sdk/runanywhere-react-native/packages/core/src/types/models.ts b/sdk/runanywhere-react-native/packages/core/src/types/models.ts new file mode 100644 index 000000000..4ac17d6b4 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/src/types/models.ts @@ -0,0 +1,579 @@ +/** + * RunAnywhere React Native SDK - Data Models + * + * These interfaces match the iOS Swift SDK data structures. + * Reference: sdk/runanywhere-swift/Sources/RunAnywhere/ + */ + +import type { + ConfigurationSource, + ExecutionTarget, + HardwareAcceleration, + LLMFramework, + ModelCategory, + ModelFormat, + SDKEnvironment, +} from './enums'; + +// Structured output types (inline definitions since handler was deleted) +export type GeneratableType = 'string' | 'number' | 'boolean' | 'object' | 'array'; + +export interface StructuredOutputConfig { + schema?: Record; + jsonMode?: boolean; +} + +export interface StructuredOutputValidation { + isValid: boolean; + errors?: string[]; +} + +// ============================================================================ +// Model Information +// ============================================================================ + +/** + * Thinking tag pattern for reasoning models + */ +export interface ThinkingTagPattern { + openTag: string; + closeTag: string; +} + +/** + * Model metadata + */ +export interface ModelInfoMetadata { + description?: string; + author?: string; + license?: string; + tags?: string[]; + version?: string; +} + +/** + * Information about a model + * Reference: ModelInfo.swift + */ +export interface ModelInfo { + /** Unique identifier */ + id: string; + + /** Human-readable name */ + name: string; + + /** Model category (language, speech, vision, etc.) */ + category: ModelCategory; + + /** Model file format */ + format: ModelFormat; + + /** Download URL (if remote) */ + downloadURL?: string; + + /** Local file path (if downloaded) */ + localPath?: string; + + /** Download size in bytes */ + downloadSize?: number; + + /** Memory required to run the model in bytes */ + memoryRequired?: number; + + /** Compatible frameworks */ + compatibleFrameworks: LLMFramework[]; + + /** Preferred framework for this model */ + preferredFramework?: LLMFramework; + + /** Context length for language models */ + contextLength?: number; + + /** Whether the model supports thinking/reasoning */ + supportsThinking: boolean; + + /** Custom thinking pattern if supportsThinking */ + thinkingPattern?: ThinkingTagPattern; + + /** Optional metadata */ + metadata?: ModelInfoMetadata; + + /** Configuration source */ + source: ConfigurationSource; + + /** Creation timestamp */ + createdAt: string; + + /** Last update timestamp */ + updatedAt: string; + + /** Whether sync is pending */ + syncPending: boolean; + + /** Last used timestamp */ + lastUsed?: string; + + /** Usage count */ + usageCount: number; + + /** Whether the model is downloaded */ + isDownloaded: boolean; + + /** Whether the model is available for use */ + isAvailable: boolean; +} + +// ============================================================================ +// Generation Types +// ============================================================================ + +/** + * Performance metrics for generation + * Reference: GenerationResult.swift + */ +export interface PerformanceMetrics { + /** Time to first token in milliseconds */ + timeToFirstTokenMs?: number; + + /** Tokens generated per second */ + tokensPerSecond?: number; + + /** Total inference time in milliseconds */ + inferenceTimeMs: number; +} + +// Structured output types are defined above + +/** + * Result of a text generation request + * Reference: GenerationResult.swift + */ +export interface GenerationResult { + /** Generated text (with thinking content removed if extracted) */ + text: string; + + /** Thinking/reasoning content extracted from the response */ + thinkingContent?: string; + + /** Number of tokens used */ + tokensUsed: number; + + /** Model used for generation */ + modelUsed: string; + + /** Latency in milliseconds */ + latencyMs: number; + + /** Execution target (device/cloud/hybrid) */ + executionTarget: ExecutionTarget; + + /** Amount saved by using on-device execution */ + savedAmount: number; + + /** Framework used for generation (if on-device) */ + framework?: LLMFramework; + + /** Hardware acceleration used */ + hardwareUsed: HardwareAcceleration; + + /** Memory used during generation (in bytes) */ + memoryUsed: number; + + /** Detailed performance metrics */ + performanceMetrics: PerformanceMetrics; + + /** Structured output validation result */ + structuredOutputValidation?: StructuredOutputValidation; + + /** Number of tokens used for thinking/reasoning */ + thinkingTokens?: number; + + /** Number of tokens in the actual response content */ + responseTokens: number; +} + +/** + * Options for text generation + * Reference: GenerationOptions.swift + */ +export interface GenerationOptions { + /** Maximum number of tokens to generate */ + maxTokens?: number; + + /** Temperature for sampling (0.0 - 1.0) */ + temperature?: number; + + /** Top-p sampling parameter */ + topP?: number; + + /** Enable real-time tracking for cost dashboard */ + enableRealTimeTracking?: boolean; + + /** Stop sequences */ + stopSequences?: string[]; + + /** Enable streaming mode */ + streamingEnabled?: boolean; + + /** Preferred execution target */ + preferredExecutionTarget?: ExecutionTarget; + + /** Preferred framework for generation */ + preferredFramework?: LLMFramework; + + /** Structured output configuration */ + structuredOutput?: StructuredOutputConfig; + + /** System prompt to define AI behavior */ + systemPrompt?: string; +} + +/** + * Alias for GenerationOptions to match iOS SDK naming convention. + * @see GenerationOptions + */ +export type LLMGenerationOptions = GenerationOptions; + +// ============================================================================ +// Voice Types +// ============================================================================ + +/** + * Voice audio chunk for streaming + */ +export interface VoiceAudioChunk { + /** Float32 audio samples (base64 encoded) */ + samples: string; + + /** Timestamp */ + timestamp: number; + + /** Sample rate */ + sampleRate: number; + + /** Number of channels */ + channels: number; + + /** Sequence number */ + sequenceNumber: number; + + /** Whether this is the final chunk */ + isFinal: boolean; +} + +/** + * STT segment with timing information + */ +export interface STTSegment { + /** Transcribed text */ + text: string; + + /** Start time in seconds */ + startTime: number; + + /** End time in seconds */ + endTime: number; + + /** Speaker ID if diarization is enabled */ + speakerId?: string; + + /** Confidence score */ + confidence: number; +} + +/** + * STT alternative transcription + */ +export interface STTAlternative { + /** Alternative text */ + text: string; + + /** Confidence score */ + confidence: number; +} + +/** + * Speech-to-text result + */ +export interface STTResult { + /** Main transcription text */ + text: string; + + /** Segments with timing */ + segments: STTSegment[]; + + /** Detected language */ + language?: string; + + /** Overall confidence */ + confidence: number; + + /** Duration in seconds */ + duration: number; + + /** Alternative transcriptions */ + alternatives: STTAlternative[]; +} + +/** + * STT options for transcription + */ +export interface STTOptions { + /** Language code (e.g., 'en', 'es') */ + language?: string; + + /** Enable punctuation */ + punctuation?: boolean; + + /** Enable speaker diarization */ + diarization?: boolean; + + /** Enable word timestamps */ + wordTimestamps?: boolean; + + /** Sample rate */ + sampleRate?: number; +} + +/** + * TTS configuration + */ +export interface TTSConfiguration { + /** Voice identifier */ + voice?: string; + + /** Speech rate (0.5 - 2.0) */ + rate?: number; + + /** Pitch (0.5 - 2.0) */ + pitch?: number; + + /** Volume (0.0 - 1.0) */ + volume?: number; +} + +/** + * TTS synthesis result + */ +export interface TTSResult { + /** Base64 encoded audio data */ + audio: string; + + /** Sample rate of the audio */ + sampleRate: number; + + /** Number of samples */ + numSamples: number; + + /** Duration in seconds */ + duration: number; +} + +/** + * VAD configuration + */ +export interface VADConfiguration { + /** Energy threshold */ + energyThreshold?: number; + + /** Sample rate */ + sampleRate?: number; + + /** Frame length in milliseconds */ + frameLength?: number; + + /** Enable auto calibration */ + autoCalibration?: boolean; +} + +// ============================================================================ +// Configuration Types +// ============================================================================ + +/** + * Configuration data returned by the native SDK + */ +export interface ConfigurationData { + /** Current environment */ + environment: SDKEnvironment; + + /** API key (masked for security) */ + apiKey?: string; + + /** Base URL for API requests */ + baseURL?: string; + + /** Configuration source */ + source: ConfigurationSource; + + /** Default generation settings */ + defaultGenerationSettings?: DefaultGenerationSettings; + + /** Feature flags */ + featureFlags?: Record; + + /** Last updated timestamp */ + lastUpdated?: string; + + /** Additional configuration values */ + [key: string]: unknown; +} + +/** + * SDK initialization options + */ +export interface SDKInitOptions { + /** API key for authentication (production/staging) */ + apiKey?: string; + + /** Base URL for API requests (production: Railway endpoint) */ + baseURL?: string; + + /** SDK environment */ + environment?: SDKEnvironment; + + /** + * Supabase project URL (development mode) + * When set, SDK makes calls directly to Supabase + */ + supabaseURL?: string; + + /** + * Supabase anon key (development mode) + */ + supabaseKey?: string; + + /** Enable debug logging */ + debug?: boolean; +} + +/** + * Default generation settings + */ +export interface DefaultGenerationSettings { + maxTokens: number; + temperature: number; + topP: number; +} + +/** + * Storage information + */ +export interface StorageInfo { + /** Total storage available in bytes */ + totalSpace: number; + + /** Storage used by SDK in bytes */ + usedSpace: number; + + /** Free space available in bytes */ + freeSpace: number; + + /** Models storage path */ + modelsPath: string; +} + +/** + * Stored model information + */ +export interface StoredModel { + /** Model ID */ + id: string; + + /** Model name */ + name: string; + + /** Size on disk in bytes */ + sizeOnDisk: number; + + /** Download date */ + downloadedAt: string; + + /** Last used date */ + lastUsed?: string; +} + +// ============================================================================ +// Device Types +// ============================================================================ + +/** + * Device information + */ +export interface DeviceInfoData { + /** Device model */ + model: string; + + /** Device name */ + name: string; + + /** OS version */ + osVersion: string; + + /** Chip/processor name */ + chipName: string; + + /** Total memory in bytes */ + totalMemory: number; + + /** Whether device has Neural Engine */ + hasNeuralEngine: boolean; + + /** Processor architecture */ + architecture: string; +} + +/** + * Framework availability information + */ +export interface FrameworkAvailability { + /** Framework */ + framework: LLMFramework; + + /** Whether available */ + isAvailable: boolean; + + /** Reason if not available */ + reason?: string; +} + +// ============================================================================ +// Component Types +// ============================================================================ + +/** + * Initialization result for components + */ +export interface InitializationResult { + /** Whether initialization succeeded */ + success: boolean; + + /** Components that are ready */ + readyComponents: string[]; + + /** Components that failed */ + failedComponents: string[]; + + /** Error message if failed */ + error?: string; +} + +/** + * Component health information + */ +export interface ComponentHealth { + /** Component identifier */ + component: string; + + /** Whether healthy */ + isHealthy: boolean; + + /** Last check timestamp */ + lastCheck: string; + + /** Memory usage in bytes */ + memoryUsage?: number; + + /** Error message if unhealthy */ + error?: string; +} diff --git a/sdk/runanywhere-react-native/packages/core/tsconfig.json b/sdk/runanywhere-react-native/packages/core/tsconfig.json new file mode 100644 index 000000000..16ea4a1e4 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/core/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib", + "composite": true, + "declaration": true, + "declarationMap": true, + "baseUrl": ".", + "paths": { + "@runanywhere/core": ["./src"], + "@runanywhere/core/*": ["./src/*"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "lib"] +} diff --git a/sdk/runanywhere-react-native/packages/llamacpp/.npmignore b/sdk/runanywhere-react-native/packages/llamacpp/.npmignore new file mode 100644 index 000000000..f7bb38dd6 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/llamacpp/.npmignore @@ -0,0 +1,16 @@ +# Test local flags - should not be published +**/.testlocal +*.testlocal +.testlocal + +# Build artifacts +*.log +.cxx/ +build/ + +# IDE +.idea/ +*.iml + +# macOS +.DS_Store diff --git a/sdk/runanywhere-react-native/packages/llamacpp/README.md b/sdk/runanywhere-react-native/packages/llamacpp/README.md new file mode 100644 index 000000000..95ed240bb --- /dev/null +++ b/sdk/runanywhere-react-native/packages/llamacpp/README.md @@ -0,0 +1,421 @@ +# @runanywhere/llamacpp + +LlamaCPP backend for the RunAnywhere React Native SDK. Provides on-device LLM text generation with GGUF models powered by llama.cpp. + +--- + +## Overview + +`@runanywhere/llamacpp` provides the LlamaCPP backend for on-device Large Language Model (LLM) inference. It enables: + +- **Text Generation** — Generate text responses from prompts +- **Streaming** — Real-time token-by-token output +- **GGUF Support** — Run any GGUF-format model (Llama, Mistral, Qwen, SmolLM, etc.) +- **Metal GPU Acceleration** — 3-5x faster inference on Apple Silicon (iOS) +- **CPU Inference** — Works on all devices without GPU requirements +- **Memory Efficient** — Quantized models (Q4, Q6, Q8) for reduced memory usage + +--- + +## Requirements + +- `@runanywhere/core` (peer dependency) +- React Native 0.71+ +- iOS 15.1+ / Android API 24+ + +--- + +## Installation + +```bash +npm install @runanywhere/core @runanywhere/llamacpp +# or +yarn add @runanywhere/core @runanywhere/llamacpp +``` + +### iOS Setup + +```bash +cd ios && pod install && cd .. +``` + +### Android Setup + +No additional setup required. Native libraries are downloaded automatically. + +--- + +## Quick Start + +```typescript +import { RunAnywhere, SDKEnvironment, ModelCategory } from '@runanywhere/core'; +import { LlamaCPP } from '@runanywhere/llamacpp'; + +// 1. Initialize SDK +await RunAnywhere.initialize({ + environment: SDKEnvironment.Development, +}); + +// 2. Register LlamaCPP backend +LlamaCPP.register(); + +// 3. Add a model +await LlamaCPP.addModel({ + id: 'smollm2-360m-q8_0', + name: 'SmolLM2 360M Q8_0', + url: 'https://huggingface.co/prithivMLmods/SmolLM2-360M-GGUF/resolve/main/SmolLM2-360M.Q8_0.gguf', + memoryRequirement: 500_000_000, +}); + +// 4. Download model +await RunAnywhere.downloadModel('smollm2-360m-q8_0', (progress) => { + console.log(`Downloading: ${(progress.progress * 100).toFixed(1)}%`); +}); + +// 5. Load model +const modelInfo = await RunAnywhere.getModelInfo('smollm2-360m-q8_0'); +await RunAnywhere.loadModel(modelInfo.localPath); + +// 6. Generate text +const response = await RunAnywhere.chat('What is the capital of France?'); +console.log(response); +``` + +--- + +## API Reference + +### LlamaCPP Module + +```typescript +import { LlamaCPP } from '@runanywhere/llamacpp'; +``` + +#### `LlamaCPP.register()` + +Register the LlamaCPP backend with the SDK. Must be called before using LLM features. + +```typescript +LlamaCPP.register(): void +``` + +**Example:** + +```typescript +await RunAnywhere.initialize({ ... }); +LlamaCPP.register(); // Now LLM features are available +``` + +--- + +#### `LlamaCPP.addModel(options)` + +Add a GGUF model to the model registry. + +```typescript +await LlamaCPP.addModel(options: LlamaCPPModelOptions): Promise +``` + +**Parameters:** + +```typescript +interface LlamaCPPModelOptions { + /** + * Unique model ID. + * If not provided, generated from the URL filename. + */ + id?: string; + + /** Display name for the model */ + name: string; + + /** Download URL for the model (GGUF format) */ + url: string; + + /** + * Model category. + * Default: ModelCategory.Language + */ + modality?: ModelCategory; + + /** + * Memory requirement in bytes. + * Used for device capability checks. + */ + memoryRequirement?: number; + + /** + * Whether model supports reasoning/thinking tokens. + * If true, thinking content is extracted from responses. + */ + supportsThinking?: boolean; +} +``` + +**Returns:** `Promise` — The registered model info + +**Example:** + +```typescript +// Basic model +await LlamaCPP.addModel({ + id: 'smollm2-360m-q8_0', + name: 'SmolLM2 360M Q8_0', + url: 'https://huggingface.co/prithivMLmods/SmolLM2-360M-GGUF/resolve/main/SmolLM2-360M.Q8_0.gguf', + memoryRequirement: 500_000_000, +}); + +// Larger model +await LlamaCPP.addModel({ + id: 'llama-2-7b-chat-q4_k_m', + name: 'Llama 2 7B Chat Q4_K_M', + url: 'https://huggingface.co/TheBloke/Llama-2-7B-Chat-GGUF/resolve/main/llama-2-7b-chat.Q4_K_M.gguf', + memoryRequirement: 4_000_000_000, +}); + +// Model with thinking support (e.g., DeepSeek-R1) +await LlamaCPP.addModel({ + id: 'deepseek-r1-distill-qwen-1.5b', + name: 'DeepSeek R1 Distill Qwen 1.5B', + url: 'https://huggingface.co/.../deepseek-r1-distill-qwen-1.5b-q8_0.gguf', + memoryRequirement: 2_000_000_000, + supportsThinking: true, +}); +``` + +--- + +#### Module Properties + +```typescript +LlamaCPP.moduleId // 'llamacpp' +LlamaCPP.moduleName // 'LlamaCPP' +LlamaCPP.inferenceFramework // LLMFramework.LlamaCpp +LlamaCPP.capabilities // ['llm'] +LlamaCPP.defaultPriority // 100 +``` + +--- + +### Text Generation + +Once a model is registered and loaded, use the `RunAnywhere` API for generation: + +#### Simple Chat + +```typescript +const response = await RunAnywhere.chat('Hello!'); +console.log(response); +``` + +#### Generation with Options + +```typescript +const result = await RunAnywhere.generate( + 'Explain machine learning in simple terms', + { + maxTokens: 256, + temperature: 0.7, + topP: 0.95, + systemPrompt: 'You are a helpful teacher.', + stopSequences: ['\n\n'], + } +); + +console.log('Response:', result.text); +console.log('Tokens:', result.tokensUsed); +console.log('Speed:', result.performanceMetrics.tokensPerSecond, 'tok/s'); +console.log('TTFT:', result.performanceMetrics.timeToFirstTokenMs, 'ms'); +``` + +#### Streaming Generation + +```typescript +const streamResult = await RunAnywhere.generateStream( + 'Write a story about a robot', + { maxTokens: 500 } +); + +// Display tokens as they're generated +for await (const token of streamResult.stream) { + process.stdout.write(token); +} + +// Get final metrics +const result = await streamResult.result; +console.log('\nSpeed:', result.performanceMetrics.tokensPerSecond, 'tok/s'); +``` + +#### Model Management + +```typescript +// Load model +await RunAnywhere.loadModel('/path/to/model.gguf'); + +// Check if loaded +const isLoaded = await RunAnywhere.isModelLoaded(); + +// Unload to free memory +await RunAnywhere.unloadModel(); + +// Cancel ongoing generation +await RunAnywhere.cancelGeneration(); +``` + +--- + +## Supported Models + +Any GGUF-format model works with this backend. Recommended models: + +### Small Models (< 1GB RAM) + +| Model | Size | Memory | Description | +|-------|------|--------|-------------| +| SmolLM2 360M Q8_0 | ~400MB | 500MB | Fast, lightweight | +| Qwen 2.5 0.5B Q6_K | ~500MB | 600MB | Multilingual | +| LFM2 350M Q4_K_M | ~200MB | 250MB | Ultra-compact | + +### Medium Models (1-3GB RAM) + +| Model | Size | Memory | Description | +|-------|------|--------|-------------| +| Phi-3 Mini Q4_K_M | ~2GB | 2.5GB | Microsoft | +| Gemma 2B Q4_K_M | ~1.5GB | 2GB | Google | +| TinyLlama 1.1B Q4_K_M | ~700MB | 1GB | Fast chat | + +### Large Models (4GB+ RAM) + +| Model | Size | Memory | Description | +|-------|------|--------|-------------| +| Llama 2 7B Q4_K_M | ~4GB | 5GB | Meta | +| Mistral 7B Q4_K_M | ~4GB | 5GB | Mistral AI | +| Llama 3.2 3B Q4_K_M | ~2GB | 3GB | Meta latest | + +--- + +## Performance Tips + +### Device Recommendations + +- **Apple Silicon (M1/M2/M3, A14+)**: Metal GPU acceleration provides 3-5x speedup +- **Modern Android**: 6GB+ RAM recommended for 7B models +- **Older devices**: Use smaller models (360M-1B) + +### Optimization Strategies + +1. **Use quantized models** — Q4_K_M offers best quality/size ratio +2. **Limit maxTokens** — Shorter responses = faster generation +3. **Unload when idle** — Free memory for other apps +4. **Pre-download models** — Better UX during onboarding + +### Expected Performance + +| Device | Model | Speed | +|--------|-------|-------| +| iPhone 15 Pro | SmolLM2 360M Q8 | 50-80 tok/s | +| iPhone 15 Pro | Llama 3.2 3B Q4 | 15-25 tok/s | +| MacBook M2 | Llama 2 7B Q4 | 20-40 tok/s | +| Pixel 8 | SmolLM2 360M Q8 | 30-50 tok/s | + +--- + +## Native Integration + +### iOS + +This package uses `RABackendLLAMACPP.xcframework` which includes: +- llama.cpp compiled for iOS (arm64) +- Metal GPU acceleration +- Optimized NEON SIMD + +The framework is automatically downloaded during `pod install`. + +### Android + +Native library `librunanywhere_llamacpp.so` includes: +- llama.cpp compiled for Android (arm64-v8a, armeabi-v7a) +- OpenMP threading support +- Optimized for ARM NEON + +Libraries are automatically downloaded during Gradle build. + +--- + +## Package Structure + +``` +packages/llamacpp/ +├── src/ +│ ├── index.ts # Package exports +│ ├── LlamaCPP.ts # Module API (register, addModel) +│ ├── LlamaCppProvider.ts # Service provider +│ ├── native/ +│ │ └── NativeRunAnywhereLlama.ts +│ └── specs/ +│ └── RunAnywhereLlama.nitro.ts +├── cpp/ +│ ├── HybridRunAnywhereLlama.cpp +│ ├── HybridRunAnywhereLlama.hpp +│ └── bridges/ +├── ios/ +│ ├── RunAnywhereLlama.podspec +│ └── Frameworks/ +│ └── RABackendLLAMACPP.xcframework +├── android/ +│ ├── build.gradle +│ └── src/main/jniLibs/ +│ └── arm64-v8a/ +│ └── librunanywhere_llamacpp.so +└── nitrogen/ + └── generated/ +``` + +--- + +## Troubleshooting + +### Model fails to load + +**Symptoms:** `modelLoadFailed` error + +**Solutions:** +1. Check file exists at the path +2. Verify GGUF format (not GGML, SafeTensors, etc.) +3. Ensure sufficient memory (check `memoryRequirement`) +4. Try a smaller model + +### Slow generation + +**Symptoms:** < 5 tokens/second + +**Solutions:** +1. Use a smaller model (360M instead of 7B) +2. Check device isn't thermal throttling +3. Close other apps to free memory +4. On iOS, ensure Metal is enabled + +### Out of memory + +**Symptoms:** App crash during inference + +**Solutions:** +1. Unload model before loading a new one +2. Use a smaller/more quantized model +3. Reduce context length (fewer tokens) + +--- + +## See Also + +- [Main SDK README](../../README.md) — Full SDK documentation +- [API Reference](../../Docs/Documentation.md) — Complete API docs +- [@runanywhere/core](../core/README.md) — Core SDK +- [@runanywhere/onnx](../onnx/README.md) — STT/TTS backend +- [llama.cpp](https://github.com/ggerganov/llama.cpp) — Underlying engine + +--- + +## License + +MIT License diff --git a/sdk/runanywhere-react-native/packages/llamacpp/RunAnywhereLlama.podspec b/sdk/runanywhere-react-native/packages/llamacpp/RunAnywhereLlama.podspec new file mode 100644 index 000000000..07d090159 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/llamacpp/RunAnywhereLlama.podspec @@ -0,0 +1,57 @@ +require "json" + +package = JSON.parse(File.read(File.join(__dir__, "package.json"))) + +Pod::Spec.new do |s| + s.name = "RunAnywhereLlama" + s.version = package["version"] + s.summary = package["description"] + s.homepage = "https://runanywhere.com" + s.license = package["license"] + s.authors = "RunAnywhere AI" + + s.platforms = { :ios => "15.1" } + s.source = { :git => "https://github.com/RunanywhereAI/sdks.git", :tag => "#{s.version}" } + + # ============================================================================= + # LlamaCPP Backend - xcframework is bundled in npm package + # No downloads needed - framework is included in ios/Frameworks/ + # ============================================================================= + puts "[RunAnywhereLlama] Using bundled RABackendLLAMACPP.xcframework from npm package" + s.vendored_frameworks = "ios/Frameworks/RABackendLLAMACPP.xcframework" + + # Source files + s.source_files = [ + "cpp/HybridRunAnywhereLlama.cpp", + "cpp/HybridRunAnywhereLlama.hpp", + "cpp/bridges/**/*.{cpp,hpp}", + ] + + s.pod_target_xcconfig = { + "CLANG_CXX_LANGUAGE_STANDARD" => "c++17", + "HEADER_SEARCH_PATHS" => [ + "$(PODS_TARGET_SRCROOT)/cpp", + "$(PODS_TARGET_SRCROOT)/cpp/bridges", + "$(PODS_TARGET_SRCROOT)/ios/Frameworks/RABackendLLAMACPP.xcframework/ios-arm64/RABackendLLAMACPP.framework/Headers", + "$(PODS_TARGET_SRCROOT)/ios/Frameworks/RABackendLLAMACPP.xcframework/ios-arm64_x86_64-simulator/RABackendLLAMACPP.framework/Headers", + "$(PODS_TARGET_SRCROOT)/../core/ios/Frameworks/RACommons.xcframework/ios-arm64/RACommons.framework/Headers", + "$(PODS_TARGET_SRCROOT)/../core/ios/Frameworks/RACommons.xcframework/ios-arm64_x86_64-simulator/RACommons.framework/Headers", + "$(PODS_ROOT)/Headers/Public", + ].join(" "), + "GCC_PREPROCESSOR_DEFINITIONS" => "$(inherited) HAS_LLAMACPP=1", + "DEFINES_MODULE" => "YES", + "SWIFT_OBJC_INTEROP_MODE" => "objcxx", + } + + s.libraries = "c++" + s.frameworks = "Accelerate", "Foundation", "CoreML" + + s.dependency 'RunAnywhereCore' + s.dependency 'React-jsi' + s.dependency 'React-callinvoker' + + load 'nitrogen/generated/ios/RunAnywhereLlama+autolinking.rb' + add_nitrogen_files(s) + + install_modules_dependencies(s) +end diff --git a/sdk/runanywhere-react-native/packages/llamacpp/android/CMakeLists.txt b/sdk/runanywhere-react-native/packages/llamacpp/android/CMakeLists.txt new file mode 100644 index 000000000..9d2621ed5 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/llamacpp/android/CMakeLists.txt @@ -0,0 +1,131 @@ +project(runanywherellama) +cmake_minimum_required(VERSION 3.9.0) + +set(PACKAGE_NAME runanywherellama) +set(CMAKE_VERBOSE_MAKEFILE ON) +set(CMAKE_CXX_STANDARD 20) + +# ============================================================================= +# 16KB Page Alignment for Android 15+ (API 35) Compliance +# Required starting November 1, 2025 for Google Play submissions +# ============================================================================= +set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -Wl,-z,max-page-size=16384") + +# Path to pre-built native libraries (downloaded from runanywhere-binaries) +set(JNILIB_DIR ${CMAKE_SOURCE_DIR}/src/main/jniLibs/${ANDROID_ABI}) + +# ============================================================================= +# RABackendLlamaCPP - Llama LLM backend (REQUIRED) +# Downloaded via Gradle downloadNativeLibs task +# ============================================================================= +if(NOT EXISTS "${JNILIB_DIR}/librac_backend_llamacpp.so") + message(FATAL_ERROR "[RunAnywhereLlama] RABackendLlamaCPP not found at ${JNILIB_DIR}/librac_backend_llamacpp.so\n" + "Run: ./gradlew :runanywhere_llamacpp:downloadNativeLibs") +endif() + +add_library(rac_backend_llamacpp SHARED IMPORTED) +set_target_properties(rac_backend_llamacpp PROPERTIES + IMPORTED_LOCATION "${JNILIB_DIR}/librac_backend_llamacpp.so" + IMPORTED_NO_SONAME TRUE +) +message(STATUS "[RunAnywhereLlama] Found RABackendLlamaCPP at ${JNILIB_DIR}/librac_backend_llamacpp.so") + +# ============================================================================= +# Source files - Llama bridges +# ============================================================================= +file(GLOB BRIDGE_SOURCES "../cpp/bridges/*.cpp") + +add_library(${PACKAGE_NAME} SHARED + src/main/cpp/cpp-adapter.cpp + ../cpp/HybridRunAnywhereLlama.cpp + ${BRIDGE_SOURCES} +) + +# ============================================================================= +# Fix NitroModules prefab path for library modules +# The prefab config generated by AGP has incorrect paths when building library modules +# We need to create the NitroModules target BEFORE the autolinking.cmake runs +# ============================================================================= +if(DEFINED REACT_NATIVE_NITRO_BUILD_DIR) + # Find NitroModules.so in the app's build directory + set(NITRO_LIBS_DIR "${REACT_NATIVE_NITRO_BUILD_DIR}/intermediates/merged_native_libs/debug/mergeDebugNativeLibs/out/lib/${ANDROID_ABI}") + if(EXISTS "${NITRO_LIBS_DIR}/libNitroModules.so") + message(STATUS "[RunAnywhereLlama] Using NitroModules from app build: ${NITRO_LIBS_DIR}") + add_library(react-native-nitro-modules::NitroModules SHARED IMPORTED) + set_target_properties(react-native-nitro-modules::NitroModules PROPERTIES + IMPORTED_LOCATION "${NITRO_LIBS_DIR}/libNitroModules.so" + ) + endif() +endif() + +# Add Nitrogen specs (this handles all React Native linking) +include(${CMAKE_SOURCE_DIR}/../nitrogen/generated/android/runanywherellama+autolinking.cmake) + +# ============================================================================= +# Include directories +# ============================================================================= +# Get core package include dir (for rac/*.h headers) +get_filename_component(RN_NODE_MODULES "${CMAKE_SOURCE_DIR}/../../.." ABSOLUTE) +set(CORE_INCLUDE_DIR "${RN_NODE_MODULES}/@runanywhere/core/android/src/main/include") +set(CORE_JNILIB_DIR "${RN_NODE_MODULES}/@runanywhere/core/android/src/main/jniLibs/${ANDROID_ABI}") + +include_directories( + "src/main/cpp" + "../cpp" + "../cpp/bridges" + "${CMAKE_SOURCE_DIR}/include" + # RAC API headers from core package (flat access for all subdirectories) + "${CORE_INCLUDE_DIR}" + "${CORE_INCLUDE_DIR}/rac" + "${CORE_INCLUDE_DIR}/rac/core" + "${CORE_INCLUDE_DIR}/rac/core/capabilities" + "${CORE_INCLUDE_DIR}/rac/features" + "${CORE_INCLUDE_DIR}/rac/features/llm" + "${CORE_INCLUDE_DIR}/rac/features/stt" + "${CORE_INCLUDE_DIR}/rac/features/tts" + "${CORE_INCLUDE_DIR}/rac/features/vad" + "${CORE_INCLUDE_DIR}/rac/features/voice_agent" + "${CORE_INCLUDE_DIR}/rac/features/platform" + "${CORE_INCLUDE_DIR}/rac/infrastructure" + "${CORE_INCLUDE_DIR}/rac/infrastructure/device" + "${CORE_INCLUDE_DIR}/rac/infrastructure/download" + "${CORE_INCLUDE_DIR}/rac/infrastructure/events" + "${CORE_INCLUDE_DIR}/rac/infrastructure/model_management" + "${CORE_INCLUDE_DIR}/rac/infrastructure/network" + "${CORE_INCLUDE_DIR}/rac/infrastructure/storage" + "${CORE_INCLUDE_DIR}/rac/infrastructure/telemetry" +) + +# ============================================================================= +# RACommons - Core SDK functionality (from core package) +# ============================================================================= +if(NOT EXISTS "${CORE_JNILIB_DIR}/librac_commons.so") + message(FATAL_ERROR "[RunAnywhereLlama] RACommons not found at ${CORE_JNILIB_DIR}/librac_commons.so\n" + "Run: ./gradlew :runanywhere_core:downloadNativeLibs") +endif() + +add_library(rac_commons SHARED IMPORTED) +set_target_properties(rac_commons PROPERTIES + IMPORTED_LOCATION "${CORE_JNILIB_DIR}/librac_commons.so" + IMPORTED_NO_SONAME TRUE +) +message(STATUS "[RunAnywhereLlama] Found RACommons at ${CORE_JNILIB_DIR}/librac_commons.so") + +# ============================================================================= +# Linking - LlamaCPP backend and RACommons are REQUIRED +# ============================================================================= +find_library(LOG_LIB log) + +target_link_libraries( + ${PACKAGE_NAME} + ${LOG_LIB} + android + rac_commons + rac_backend_llamacpp +) + +# HAS_LLAMACPP and HAS_RACOMMONS are always defined since backends are required +target_compile_definitions(${PACKAGE_NAME} PRIVATE HAS_LLAMACPP=1 HAS_RACOMMONS=1) + +# 16KB page alignment - MUST be on target for Android 15+ compliance +target_link_options(${PACKAGE_NAME} PRIVATE -Wl,-z,max-page-size=16384) diff --git a/sdk/runanywhere-react-native/packages/llamacpp/android/build.gradle b/sdk/runanywhere-react-native/packages/llamacpp/android/build.gradle new file mode 100644 index 000000000..1ae557cad --- /dev/null +++ b/sdk/runanywhere-react-native/packages/llamacpp/android/build.gradle @@ -0,0 +1,352 @@ +// ============================================================================= +// Node Binary Detection for Android Studio Compatibility +// Android Studio doesn't inherit terminal PATH, so we need to find node explicitly +// ============================================================================= +def findNodeBinary() { + // Check local.properties first (user can override) + def localProperties = new Properties() + def localPropertiesFile = rootProject.file("local.properties") + if (localPropertiesFile.exists()) { + localPropertiesFile.withInputStream { localProperties.load(it) } + def nodePath = localProperties.getProperty("node.path") + if (nodePath && new File(nodePath).exists()) { + return nodePath + } + } + + // Check common node installation paths + def homeDir = System.getProperty("user.home") + def nodePaths = [ + "/opt/homebrew/bin/node", // macOS ARM (Apple Silicon) + "/usr/local/bin/node", // macOS Intel / Linux + "/usr/bin/node", // Linux system + "${homeDir}/.nvm/current/bin/node", // nvm + "${homeDir}/.volta/bin/node", // volta + "${homeDir}/.asdf/shims/node" // asdf + ] + for (path in nodePaths) { + if (new File(path).exists()) { + return path + } + } + + // Fallback to 'node' (works if PATH is set correctly in terminal builds) + return "node" +} + +def getExtOrDefault(name) { + return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties['RunAnywhereLlama_' + name] +} + +// Only arm64-v8a is supported +def reactNativeArchitectures() { + return ["arm64-v8a"] +} + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' +apply from: '../nitrogen/generated/android/runanywherellama+autolinking.gradle' +apply plugin: 'com.facebook.react' + +// Configure node path for Android Studio builds +// Set the react extension's nodeExecutableAndArgs after plugin is applied +def nodeBinary = findNodeBinary() +logger.lifecycle("[RunAnywhereLlama] Using node binary: ${nodeBinary}") + +// Configure all codegen tasks to use the detected node binary +afterEvaluate { + tasks.withType(com.facebook.react.tasks.GenerateCodegenSchemaTask).configureEach { + nodeExecutableAndArgs.set([nodeBinary]) + } + tasks.withType(com.facebook.react.tasks.GenerateCodegenArtifactsTask).configureEach { + nodeExecutableAndArgs.set([nodeBinary]) + } +} + +def getExtOrIntegerDefault(name) { + if (rootProject.ext.has(name)) { + return rootProject.ext.get(name) + } else if (project.properties.containsKey('RunAnywhereLlama_' + name)) { + return (project.properties['RunAnywhereLlama_' + name]).toInteger() + } + def defaults = [ + 'compileSdkVersion': 36, + 'minSdkVersion': 24, + 'targetSdkVersion': 36 + ] + return defaults[name] ?: 36 +} + +// ============================================================================= +// Version Constants (MUST match Swift Package.swift and iOS Podspec) +// RABackendLlamaCPP from runanywhere-sdks +// ============================================================================= +def coreVersion = "0.1.4" + +// ============================================================================= +// Binary Source - RABackendLlamaCPP from runanywhere-sdks +// ============================================================================= +def githubOrg = "RunanywhereAI" +def coreRepo = "runanywhere-sdks" + +// ============================================================================= +// testLocal Toggle +// ============================================================================= +def useLocalBuild = project.findProperty("runanywhere.testLocal")?.toBoolean() ?: + System.getenv("RA_TEST_LOCAL") == "1" ?: false + +// Native libraries directory +def jniLibsDir = file("src/main/jniLibs") +def downloadedLibsDir = file("build/downloaded-libs") + +android { + namespace "com.margelo.nitro.runanywhere.llama" + + compileSdkVersion getExtOrIntegerDefault('compileSdkVersion') + + defaultConfig { + minSdkVersion getExtOrIntegerDefault('minSdkVersion') + targetSdkVersion getExtOrIntegerDefault('targetSdkVersion') + + ndk { + abiFilters 'arm64-v8a', 'x86_64' + } + + externalNativeBuild { + cmake { + cppFlags "-frtti -fexceptions -Wall -fstack-protector-all" + arguments "-DANDROID_STL=c++_shared", + // Fix NitroModules prefab path - use app's build directory + "-DREACT_NATIVE_NITRO_BUILD_DIR=${rootProject.buildDir}" + abiFilters 'arm64-v8a', 'x86_64' + } + } + } + + externalNativeBuild { + cmake { + path "CMakeLists.txt" + } + } + + packagingOptions { + excludes = [ + "META-INF", + "META-INF/**" + ] + pickFirsts = [ + "**/libc++_shared.so", + "**/libjsi.so", + "**/libfbjni.so", + "**/libfolly_runtime.so" + ] + jniLibs { + useLegacyPackaging = true + } + } + + buildFeatures { + buildConfig true + prefab true + } + + buildTypes { + release { + minifyEnabled false + } + } + + lint { + disable 'GradleCompatible' + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + sourceSets { + main { + java.srcDirs += [ + "generated/java", + "generated/jni" + ] + jniLibs.srcDirs = [jniLibsDir] + } + } +} + +// ============================================================================= +// Download Native Libraries (RABackendLlamaCPP ONLY) +// ============================================================================= + +task downloadNativeLibs { + description = "Downloads RABackendLlamaCPP from GitHub releases" + group = "build setup" + + def versionFile = file("${jniLibsDir}/.version") + def expectedVersion = coreVersion + + outputs.dir(jniLibsDir) + outputs.upToDateWhen { + versionFile.exists() && versionFile.text.trim() == expectedVersion + } + + doLast { + if (useLocalBuild) { + logger.lifecycle("[RunAnywhereLlama] Skipping download - using local build mode") + return + } + + // Check if libs are already bundled (npm install case) + def bundledLibsDir = file("${jniLibsDir}/arm64-v8a") + def bundledLibs = bundledLibsDir.exists() ? bundledLibsDir.listFiles()?.findAll { it.name.endsWith(".so") } : [] + if (bundledLibs?.size() > 0) { + logger.lifecycle("[RunAnywhereLlama] ✅ Using bundled native libraries from npm package (${bundledLibs.size()} .so files)") + return + } + + def currentVersion = versionFile.exists() ? versionFile.text.trim() : "" + if (currentVersion == expectedVersion) { + logger.lifecycle("[RunAnywhereLlama] RABackendLlamaCPP version $expectedVersion already downloaded") + return + } + + logger.lifecycle("[RunAnywhereLlama] Downloading RABackendLlamaCPP...") + logger.lifecycle(" Core Version: $coreVersion") + + downloadedLibsDir.mkdirs() + jniLibsDir.deleteDir() + jniLibsDir.mkdirs() + + // ============================================================================= + // Download RABackendLlamaCPP from runanywhere-sdks + // ============================================================================= + def llamacppUrl = "https://github.com/${githubOrg}/${coreRepo}/releases/download/core-v${coreVersion}/RABackendLlamaCPP-android-v${coreVersion}.zip" + def llamacppZip = file("${downloadedLibsDir}/RABackendLlamaCPP.zip") + + logger.lifecycle("\n📦 Downloading RABackendLlamaCPP...") + logger.lifecycle(" URL: $llamacppUrl") + + try { + new URL(llamacppUrl).withInputStream { input -> + llamacppZip.withOutputStream { output -> + output << input + } + } + logger.lifecycle(" Downloaded: ${llamacppZip.length() / 1024}KB") + + // Extract and flatten the archive structure + // Archive structure: RABackendLlamaCPP-android-v0.1.4/llamacpp/arm64-v8a/*.so + // Target structure: arm64-v8a/*.so + copy { + from zipTree(llamacppZip) + into jniLibsDir + // IMPORTANT: Exclude libc++_shared.so - React Native provides its own + // Using a different version causes ABI compatibility issues + exclude "**/libc++_shared.so" + eachFile { fileCopyDetails -> + def pathString = fileCopyDetails.relativePath.pathString + // Handle RABackendLlamaCPP-android-vX.Y.Z/llamacpp/ABI/*.so structure + def match = pathString =~ /.*\/(arm64-v8a|armeabi-v7a|x86|x86_64)\/(.+\.so)$/ + if (match) { + def abi = match[0][1] + def filename = match[0][2] + fileCopyDetails.relativePath = new RelativePath(true, abi, filename) + } else if (pathString.endsWith(".so")) { + // Fallback: just use the last two segments (abi/file.so) + def segments = pathString.split("/") + if (segments.length >= 2) { + fileCopyDetails.relativePath = new RelativePath(true, segments[-2], segments[-1]) + } + } else { + // Exclude non-so files + fileCopyDetails.exclude() + } + } + includeEmptyDirs = false + } + + logger.lifecycle(" ✅ RABackendLlamaCPP native libraries installed") + + // Extract header files + def includeDir = file("include") + includeDir.deleteDir() + includeDir.mkdirs() + + copy { + from zipTree(llamacppZip) + into includeDir + eachFile { fileCopyDetails -> + def pathString = fileCopyDetails.relativePath.pathString + // Handle RABackendLlamaCPP-android-vX.Y.Z/include/*.h structure + if (pathString.contains("/include/") && pathString.endsWith(".h")) { + def filename = pathString.substring(pathString.lastIndexOf("/") + 1) + fileCopyDetails.relativePath = new RelativePath(true, filename) + } else { + fileCopyDetails.exclude() + } + } + includeEmptyDirs = false + } + logger.lifecycle(" ✅ RABackendLlamaCPP headers installed") + + } catch (Exception e) { + logger.error("❌ Failed to download RABackendLlamaCPP: ${e.message}") + throw new GradleException("Failed to download RABackendLlamaCPP", e) + } + + // ============================================================================= + // List installed files + // ============================================================================= + logger.lifecycle("\n📋 Installed native libraries:") + jniLibsDir.listFiles()?.findAll { it.isDirectory() }?.each { abiDir -> + logger.lifecycle(" ${abiDir.name}/") + abiDir.listFiles()?.findAll { it.name.endsWith(".so") }?.sort()?.each { soFile -> + logger.lifecycle(" ${soFile.name} (${soFile.length() / 1024}KB)") + } + } + + versionFile.text = expectedVersion + logger.lifecycle("\n✅ RABackendLlamaCPP version $expectedVersion installed") + } +} + +if (!useLocalBuild) { + preBuild.dependsOn downloadNativeLibs + + afterEvaluate { + tasks.matching { + it.name.contains("generateCodegen") || it.name.contains("Codegen") + }.configureEach { + mustRunAfter downloadNativeLibs + } + } +} + +// NOTE: cleanNativeLibs is NOT attached to clean task because npm-bundled libs should persist +// Only use this task manually when needed during development +task cleanNativeLibs(type: Delete) { + description = "Removes downloaded native libraries (use manually, not during normal clean)" + group = "build" + delete downloadedLibsDir + // DO NOT delete jniLibsDir - it contains npm-bundled libraries +} + +// DO NOT add: clean.dependsOn cleanNativeLibs +// This would delete bundled .so files from the npm package + +repositories { + mavenCentral() + google() +} + +dependencies { + implementation "com.facebook.react:react-android" + implementation project(":react-native-nitro-modules") + implementation project(":runanywhere_core") +} diff --git a/sdk/runanywhere-react-native/packages/llamacpp/android/src/main/AndroidManifest.xml b/sdk/runanywhere-react-native/packages/llamacpp/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000..236dae2f6 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/llamacpp/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + diff --git a/sdk/runanywhere-react-native/packages/llamacpp/android/src/main/cpp/cpp-adapter.cpp b/sdk/runanywhere-react-native/packages/llamacpp/android/src/main/cpp/cpp-adapter.cpp new file mode 100644 index 000000000..92bd3f8ea --- /dev/null +++ b/sdk/runanywhere-react-native/packages/llamacpp/android/src/main/cpp/cpp-adapter.cpp @@ -0,0 +1,14 @@ +/** + * cpp-adapter.cpp + * + * Android JNI entry point for RunAnywhereLlama native module. + * This file is required by React Native's CMake build system. + */ + +#include +#include "runanywherellamaOnLoad.hpp" + +extern "C" JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) { + // Initialize nitrogen module and register HybridObjects + return margelo::nitro::runanywhere::llama::initialize(vm); +} diff --git a/sdk/runanywhere-react-native/packages/llamacpp/android/src/main/java/com/margelo/nitro/runanywhere/llama/RunAnywhereLlamaPackage.kt b/sdk/runanywhere-react-native/packages/llamacpp/android/src/main/java/com/margelo/nitro/runanywhere/llama/RunAnywhereLlamaPackage.kt new file mode 100644 index 000000000..7d274069d --- /dev/null +++ b/sdk/runanywhere-react-native/packages/llamacpp/android/src/main/java/com/margelo/nitro/runanywhere/llama/RunAnywhereLlamaPackage.kt @@ -0,0 +1,35 @@ +package com.margelo.nitro.runanywhere.llama + +import com.facebook.react.BaseReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.module.model.ReactModuleInfoProvider +import com.margelo.nitro.runanywhere.SDKLogger + +/** + * React Native package for RunAnywhere LlamaCPP backend. + * This class is required for React Native autolinking. + */ +class RunAnywhereLlamaPackage : BaseReactPackage() { + override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? { + return null + } + + override fun getReactModuleInfoProvider(): ReactModuleInfoProvider { + return ReactModuleInfoProvider { HashMap() } + } + + companion object { + private val log = SDKLogger("LLM.LlamaCpp") + + init { + // Load the native library which registers the HybridObject factory + try { + System.loadLibrary("runanywherellama") + } catch (e: UnsatisfiedLinkError) { + // Native library may already be loaded or bundled differently + log.error("Failed to load runanywherellama: ${e.message}") + } + } + } +} diff --git a/sdk/runanywhere-react-native/packages/llamacpp/cpp/HybridRunAnywhereLlama.cpp b/sdk/runanywhere-react-native/packages/llamacpp/cpp/HybridRunAnywhereLlama.cpp new file mode 100644 index 000000000..5b2badca9 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/llamacpp/cpp/HybridRunAnywhereLlama.cpp @@ -0,0 +1,346 @@ +/** + * HybridRunAnywhereLlama.cpp + * + * Nitrogen HybridObject implementation for RunAnywhere Llama backend. + * + * Llama-specific implementation for text generation using LlamaCPP. + * + * NOTE: LlamaCPP backend is REQUIRED and always linked via the build system. + */ + +#include "HybridRunAnywhereLlama.hpp" + +// Llama bridges +#include "bridges/LLMBridge.hpp" +#include "bridges/StructuredOutputBridge.hpp" + +// Backend registration header - always available +extern "C" { +#include "rac_llm_llamacpp.h" +} + +// Unified logging via rac_logger.h +#include "rac_logger.h" + +#include +#include +#include +#include + +// Log category for this module +#define LOG_CATEGORY "LLM.LlamaCpp" + +namespace margelo::nitro::runanywhere::llama { + +using namespace ::runanywhere::bridges; + +// ============================================================================ +// JSON Utilities +// ============================================================================ + +namespace { + +int extractIntValue(const std::string& json, const std::string& key, int defaultValue) { + std::string searchKey = "\"" + key + "\":"; + size_t pos = json.find(searchKey); + if (pos == std::string::npos) return defaultValue; + pos += searchKey.length(); + while (pos < json.size() && (json[pos] == ' ' || json[pos] == '\t')) pos++; + if (pos >= json.size()) return defaultValue; + return std::stoi(json.substr(pos)); +} + +float extractFloatValue(const std::string& json, const std::string& key, float defaultValue) { + std::string searchKey = "\"" + key + "\":"; + size_t pos = json.find(searchKey); + if (pos == std::string::npos) return defaultValue; + pos += searchKey.length(); + while (pos < json.size() && (json[pos] == ' ' || json[pos] == '\t')) pos++; + if (pos >= json.size()) return defaultValue; + return std::stof(json.substr(pos)); +} + +std::string extractStringValue(const std::string& json, const std::string& key, const std::string& defaultValue = "") { + std::string searchKey = "\"" + key + "\":\""; + size_t pos = json.find(searchKey); + if (pos == std::string::npos) return defaultValue; + pos += searchKey.length(); + size_t endPos = json.find("\"", pos); + if (endPos == std::string::npos) return defaultValue; + return json.substr(pos, endPos - pos); +} + +std::string buildJsonObject(const std::vector>& keyValues) { + std::string result = "{"; + for (size_t i = 0; i < keyValues.size(); i++) { + if (i > 0) result += ","; + result += "\"" + keyValues[i].first + "\":" + keyValues[i].second; + } + result += "}"; + return result; +} + +std::string jsonString(const std::string& value) { + std::string escaped = "\""; + for (char c : value) { + if (c == '"') escaped += "\\\""; + else if (c == '\\') escaped += "\\\\"; + else if (c == '\n') escaped += "\\n"; + else if (c == '\r') escaped += "\\r"; + else if (c == '\t') escaped += "\\t"; + else escaped += c; + } + escaped += "\""; + return escaped; +} + +} // anonymous namespace + +// ============================================================================ +// Constructor / Destructor +// ============================================================================ + +HybridRunAnywhereLlama::HybridRunAnywhereLlama() : HybridObject(TAG) { + RAC_LOG_DEBUG(LOG_CATEGORY, "HybridRunAnywhereLlama constructor - Llama backend module"); +} + +HybridRunAnywhereLlama::~HybridRunAnywhereLlama() { + RAC_LOG_DEBUG(LOG_CATEGORY, "HybridRunAnywhereLlama destructor"); + LLMBridge::shared().destroy(); +} + +// ============================================================================ +// Backend Registration +// ============================================================================ + +std::shared_ptr> HybridRunAnywhereLlama::registerBackend() { + return Promise::async([this]() { + RAC_LOG_DEBUG(LOG_CATEGORY, "Registering LlamaCPP backend with C++ registry"); + + rac_result_t result = rac_backend_llamacpp_register(); + // RAC_SUCCESS (0) or RAC_ERROR_MODULE_ALREADY_REGISTERED (-4) are both OK + if (result == RAC_SUCCESS || result == -4) { + RAC_LOG_INFO(LOG_CATEGORY, "LlamaCPP backend registered successfully"); + isRegistered_ = true; + return true; + } else { + RAC_LOG_ERROR(LOG_CATEGORY, "LlamaCPP registration failed with code: %d", result); + setLastError("LlamaCPP registration failed with error: " + std::to_string(result)); + throw std::runtime_error("LlamaCPP registration failed with error: " + std::to_string(result)); + } + }); +} + +std::shared_ptr> HybridRunAnywhereLlama::unregisterBackend() { + return Promise::async([this]() { + RAC_LOG_DEBUG(LOG_CATEGORY, "Unregistering LlamaCPP backend"); + + rac_result_t result = rac_backend_llamacpp_unregister(); + isRegistered_ = false; + if (result != RAC_SUCCESS) { + RAC_LOG_ERROR(LOG_CATEGORY, "LlamaCPP unregistration failed with code: %d", result); + throw std::runtime_error("LlamaCPP unregistration failed with error: " + std::to_string(result)); + } + return true; + }); +} + +std::shared_ptr> HybridRunAnywhereLlama::isBackendRegistered() { + return Promise::async([this]() { + return isRegistered_; + }); +} + +// ============================================================================ +// Model Loading +// ============================================================================ + +std::shared_ptr> HybridRunAnywhereLlama::loadModel( + const std::string& path, + const std::optional& modelId, + const std::optional& modelName, + const std::optional& configJson) { + return Promise::async([this, path, modelId, modelName, configJson]() { + std::lock_guard lock(modelMutex_); + + RAC_LOG_INFO(LOG_CATEGORY, "Loading Llama model: %s", path.c_str()); + + std::string id = modelId.value_or(""); + std::string name = modelName.value_or(""); + + // Call with correct 4-arg signature (path, modelId, modelName) + // LLMBridge::loadModel will throw on error + auto result = LLMBridge::shared().loadModel(path, id, name); + if (result != 0) { + std::string error = "Failed to load Llama model: " + path + " (error: " + std::to_string(result) + ")"; + setLastError(error); + throw std::runtime_error(error); + } + return true; + }); +} + +std::shared_ptr> HybridRunAnywhereLlama::isModelLoaded() { + return Promise::async([]() { + return LLMBridge::shared().isLoaded(); + }); +} + +std::shared_ptr> HybridRunAnywhereLlama::unloadModel() { + return Promise::async([this]() { + std::lock_guard lock(modelMutex_); + auto result = LLMBridge::shared().unload(); + return result == 0; + }); +} + +std::shared_ptr> HybridRunAnywhereLlama::getModelInfo() { + return Promise::async([]() { + if (!LLMBridge::shared().isLoaded()) { + return std::string("{}"); + } + return buildJsonObject({ + {"loaded", "true"}, + {"backend", jsonString("llamacpp")} + }); + }); +} + +// ============================================================================ +// Text Generation +// ============================================================================ + +std::shared_ptr> HybridRunAnywhereLlama::generate( + const std::string& prompt, + const std::optional& optionsJson) { + return Promise::async([this, prompt, optionsJson]() { + if (!LLMBridge::shared().isLoaded()) { + setLastError("Model not loaded"); + throw std::runtime_error("LLMBridge: Model not loaded. Call loadModel() first."); + } + + LLMOptions options; + if (optionsJson.has_value()) { + options.maxTokens = extractIntValue(*optionsJson, "max_tokens", 512); + options.temperature = extractFloatValue(*optionsJson, "temperature", 0.7f); + options.topP = extractFloatValue(*optionsJson, "top_p", 0.9f); + options.topK = extractIntValue(*optionsJson, "top_k", 40); + } + + RAC_LOG_DEBUG(LOG_CATEGORY, "Generating with prompt: %.50s...", prompt.c_str()); + + auto startTime = std::chrono::high_resolution_clock::now(); + // LLMBridge::generate will throw on error + auto result = LLMBridge::shared().generate(prompt, options); + auto endTime = std::chrono::high_resolution_clock::now(); + auto durationMs = std::chrono::duration_cast( + endTime - startTime).count(); + + return buildJsonObject({ + {"text", jsonString(result.text)}, + {"tokensUsed", std::to_string(result.tokenCount)}, + {"latencyMs", std::to_string(durationMs)}, + {"cancelled", result.cancelled ? "true" : "false"} + }); + }); +} + +std::shared_ptr> HybridRunAnywhereLlama::generateStream( + const std::string& prompt, + const std::string& optionsJson, + const std::function& callback) { + return Promise::async([this, prompt, optionsJson, callback]() { + if (!LLMBridge::shared().isLoaded()) { + setLastError("Model not loaded"); + throw std::runtime_error("LLMBridge: Model not loaded. Call loadModel() first."); + } + + LLMOptions options; + options.maxTokens = extractIntValue(optionsJson, "max_tokens", 512); + options.temperature = extractFloatValue(optionsJson, "temperature", 0.7f); + + std::string fullResponse; + std::string streamError; + + LLMStreamCallbacks streamCallbacks; + streamCallbacks.onToken = [&callback, &fullResponse](const std::string& token) -> bool { + fullResponse += token; + if (callback) { + callback(token, false); + } + return true; + }; + streamCallbacks.onComplete = [&callback](const std::string&, int, double) { + if (callback) { + callback("", true); + } + }; + streamCallbacks.onError = [this, &streamError](int code, const std::string& message) { + setLastError(message); + streamError = message; + }; + + LLMBridge::shared().generateStream(prompt, options, streamCallbacks); + + if (!streamError.empty()) { + throw std::runtime_error("LLMBridge: Stream generation failed: " + streamError); + } + + return fullResponse; + }); +} + +std::shared_ptr> HybridRunAnywhereLlama::cancelGeneration() { + return Promise::async([]() { + LLMBridge::shared().cancel(); + return true; + }); +} + +// ============================================================================ +// Structured Output +// ============================================================================ + +std::shared_ptr> HybridRunAnywhereLlama::generateStructured( + const std::string& prompt, + const std::string& schema, + const std::optional& optionsJson) { + return Promise::async([this, prompt, schema, optionsJson]() { + auto result = StructuredOutputBridge::shared().generate( + prompt, schema, optionsJson.value_or("") + ); + + if (result.success) { + return result.json; + } else { + setLastError(result.error); + return buildJsonObject({{"error", jsonString(result.error)}}); + } + }); +} + +// ============================================================================ +// Utilities +// ============================================================================ + +std::shared_ptr> HybridRunAnywhereLlama::getLastError() { + return Promise::async([this]() { return lastError_; }); +} + +std::shared_ptr> HybridRunAnywhereLlama::getMemoryUsage() { + return Promise::async([]() { + // TODO: Get memory usage from LlamaCPP + return 0.0; + }); +} + +// ============================================================================ +// Helper Methods +// ============================================================================ + +void HybridRunAnywhereLlama::setLastError(const std::string& error) { + lastError_ = error; + RAC_LOG_ERROR(LOG_CATEGORY, "Error: %s", error.c_str()); +} + +} // namespace margelo::nitro::runanywhere::llama diff --git a/sdk/runanywhere-react-native/packages/llamacpp/cpp/HybridRunAnywhereLlama.hpp b/sdk/runanywhere-react-native/packages/llamacpp/cpp/HybridRunAnywhereLlama.hpp new file mode 100644 index 000000000..47c4d13e0 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/llamacpp/cpp/HybridRunAnywhereLlama.hpp @@ -0,0 +1,107 @@ +/** + * HybridRunAnywhereLlama.hpp + * + * Nitrogen HybridObject implementation for RunAnywhere Llama backend. + * This single C++ file works on both iOS and Android. + * + * Llama-specific implementation: + * - Backend Registration + * - Model Loading/Unloading + * - Text Generation (streaming and non-streaming) + * - Structured Output (JSON schema generation) + * + * Matches Swift SDK: LlamaCPPRuntime/LlamaCPP.swift + * + * The HybridRunAnywhereLlamaSpec base class is auto-generated by Nitrogen + * from src/specs/RunAnywhereLlama.nitro.ts + */ + +#pragma once + +// Include the generated spec header (created by nitrogen) +#if __has_include() +#include "HybridRunAnywhereLlamaSpec.hpp" +#else +// Fallback include path during development +#include "../nitrogen/generated/shared/c++/HybridRunAnywhereLlamaSpec.hpp" +#endif + +#include +#include + +namespace margelo::nitro::runanywhere::llama { + +/** + * HybridRunAnywhereLlama - Llama backend native implementation + * + * Implements the RunAnywhereLlama interface defined in RunAnywhereLlama.nitro.ts + * Delegates to LLMBridge and StructuredOutputBridge for actual inference. + */ +class HybridRunAnywhereLlama : public HybridRunAnywhereLlamaSpec { +public: + HybridRunAnywhereLlama(); + ~HybridRunAnywhereLlama(); + + // ============================================================================ + // Backend Registration + // ============================================================================ + + std::shared_ptr> registerBackend() override; + std::shared_ptr> unregisterBackend() override; + std::shared_ptr> isBackendRegistered() override; + + // ============================================================================ + // Model Loading + // ============================================================================ + + std::shared_ptr> loadModel( + const std::string& path, + const std::optional& modelId, + const std::optional& modelName, + const std::optional& configJson) override; + std::shared_ptr> isModelLoaded() override; + std::shared_ptr> unloadModel() override; + std::shared_ptr> getModelInfo() override; + + // ============================================================================ + // Text Generation + // ============================================================================ + + std::shared_ptr> generate( + const std::string& prompt, + const std::optional& optionsJson) override; + std::shared_ptr> generateStream( + const std::string& prompt, + const std::string& optionsJson, + const std::function& callback) override; + std::shared_ptr> cancelGeneration() override; + + // ============================================================================ + // Structured Output + // ============================================================================ + + std::shared_ptr> generateStructured( + const std::string& prompt, + const std::string& schema, + const std::optional& optionsJson) override; + + // ============================================================================ + // Utilities + // ============================================================================ + + std::shared_ptr> getLastError() override; + std::shared_ptr> getMemoryUsage() override; + +private: + // Thread safety + std::mutex modelMutex_; + + // State tracking + std::string lastError_; + bool isRegistered_ = false; + + // Helper methods + void setLastError(const std::string& error); +}; + +} // namespace margelo::nitro::runanywhere::llama diff --git a/sdk/runanywhere-react-native/packages/llamacpp/cpp/bridges/LLMBridge.cpp b/sdk/runanywhere-react-native/packages/llamacpp/cpp/bridges/LLMBridge.cpp new file mode 100644 index 000000000..79307f505 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/llamacpp/cpp/bridges/LLMBridge.cpp @@ -0,0 +1,209 @@ +/** + * @file LLMBridge.cpp + * @brief LLM capability bridge implementation + * + * NOTE: RACommons and LlamaCPP backend are REQUIRED and always linked via the build system. + */ + +#include "LLMBridge.hpp" +#include + +namespace runanywhere { +namespace bridges { + +LLMBridge& LLMBridge::shared() { + static LLMBridge instance; + return instance; +} + +LLMBridge::LLMBridge() = default; + +LLMBridge::~LLMBridge() { + destroy(); +} + +bool LLMBridge::isLoaded() const { + if (handle_) { + return rac_llm_component_is_loaded(handle_) == RAC_TRUE; + } + return false; +} + +std::string LLMBridge::currentModelId() const { + return loadedModelId_; +} + +rac_result_t LLMBridge::loadModel(const std::string& modelPath, + const std::string& modelId, + const std::string& modelName) { + // Create component if needed + if (!handle_) { + rac_result_t result = rac_llm_component_create(&handle_); + if (result != RAC_SUCCESS) { + throw std::runtime_error("LLMBridge: Failed to create LLM component. Error: " + std::to_string(result)); + } + } + + // Use modelPath as modelId if not provided + std::string effectiveModelId = modelId.empty() ? modelPath : modelId; + std::string effectiveModelName = modelName.empty() ? effectiveModelId : modelName; + + // Unload existing model if different + if (isLoaded() && loadedModelId_ != effectiveModelId) { + rac_llm_component_unload(handle_); + } + + // Load new model with correct 4-arg signature + // rac_llm_component_load_model(handle, model_path, model_id, model_name) + rac_result_t result = rac_llm_component_load_model( + handle_, + modelPath.c_str(), + effectiveModelId.c_str(), + effectiveModelName.c_str() + ); + if (result == RAC_SUCCESS) { + loadedModelId_ = effectiveModelId; + } else { + throw std::runtime_error("LLMBridge: Failed to load LLM model '" + effectiveModelId + "'. Error: " + std::to_string(result)); + } + return result; +} + +rac_result_t LLMBridge::unload() { + if (handle_) { + rac_result_t result = rac_llm_component_unload(handle_); + if (result == RAC_SUCCESS) { + loadedModelId_.clear(); + } else { + throw std::runtime_error("LLMBridge: Failed to unload LLM model. Error: " + std::to_string(result)); + } + return result; + } + loadedModelId_.clear(); + return RAC_SUCCESS; +} + +void LLMBridge::cleanup() { + if (handle_) { + rac_llm_component_cleanup(handle_); + } + loadedModelId_.clear(); +} + +void LLMBridge::cancel() { + cancellationRequested_ = true; + if (handle_) { + rac_llm_component_cancel(handle_); + } +} + +void LLMBridge::destroy() { + if (handle_) { + rac_llm_component_destroy(handle_); + handle_ = nullptr; + } + loadedModelId_.clear(); +} + +LLMResult LLMBridge::generate(const std::string& prompt, const LLMOptions& options) { + LLMResult result; + cancellationRequested_ = false; + + if (!handle_ || !isLoaded()) { + throw std::runtime_error("LLMBridge: LLM model not loaded. Call loadModel() first."); + } + + rac_llm_options_t racOptions = {}; + racOptions.max_tokens = options.maxTokens; + racOptions.temperature = static_cast(options.temperature); + racOptions.top_p = static_cast(options.topP); + // NOTE: top_k is not available in rac_llm_options_t, only top_p + + rac_llm_result_t racResult = {}; + rac_result_t status = rac_llm_component_generate(handle_, prompt.c_str(), + &racOptions, &racResult); + + if (status == RAC_SUCCESS) { + if (racResult.text) { + result.text = racResult.text; + } + result.tokenCount = racResult.completion_tokens; + result.durationMs = static_cast(racResult.total_time_ms); + } else { + throw std::runtime_error("LLMBridge: Text generation failed with error code: " + std::to_string(status)); + } + + result.cancelled = cancellationRequested_; + return result; +} + +void LLMBridge::generateStream(const std::string& prompt, const LLMOptions& options, + const LLMStreamCallbacks& callbacks) { + cancellationRequested_ = false; + + if (!handle_ || !isLoaded()) { + if (callbacks.onError) { + callbacks.onError(-4, "LLM model not loaded. Call loadModel() first."); + } + return; + } + + rac_llm_options_t racOptions = {}; + racOptions.max_tokens = options.maxTokens; + racOptions.temperature = static_cast(options.temperature); + racOptions.top_p = static_cast(options.topP); + // NOTE: top_k is not available in rac_llm_options_t, only top_p + + // Stream context for callbacks + struct StreamContext { + const LLMStreamCallbacks* callbacks; + bool* cancellationRequested; + std::string accumulatedText; + }; + + StreamContext ctx = { &callbacks, &cancellationRequested_, "" }; + + auto tokenCallback = [](const char* token, void* user_data) -> rac_bool_t { + auto* ctx = static_cast(user_data); + if (*ctx->cancellationRequested) { + return RAC_FALSE; + } + if (ctx->callbacks->onToken && token) { + ctx->accumulatedText += token; + return ctx->callbacks->onToken(token) ? RAC_TRUE : RAC_FALSE; + } + return RAC_TRUE; + }; + + auto completeCallback = [](const rac_llm_result_t* result, void* user_data) { + auto* ctx = static_cast(user_data); + if (ctx->callbacks->onComplete) { + ctx->callbacks->onComplete( + ctx->accumulatedText, + result ? result->completion_tokens : 0, + result ? static_cast(result->total_time_ms) : 0.0 + ); + } + }; + + auto errorCallback = [](rac_result_t error_code, const char* error_message, + void* user_data) { + auto* ctx = static_cast(user_data); + if (ctx->callbacks->onError) { + ctx->callbacks->onError(error_code, error_message ? error_message : "Unknown error"); + } + }; + + rac_llm_component_generate_stream(handle_, prompt.c_str(), &racOptions, + tokenCallback, completeCallback, errorCallback, &ctx); +} + +rac_lifecycle_state_t LLMBridge::getState() const { + if (handle_) { + return rac_llm_component_get_state(handle_); + } + return RAC_LIFECYCLE_STATE_IDLE; +} + +} // namespace bridges +} // namespace runanywhere diff --git a/sdk/runanywhere-react-native/packages/llamacpp/cpp/bridges/LLMBridge.hpp b/sdk/runanywhere-react-native/packages/llamacpp/cpp/bridges/LLMBridge.hpp new file mode 100644 index 000000000..b3a9c6e82 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/llamacpp/cpp/bridges/LLMBridge.hpp @@ -0,0 +1,109 @@ +/** + * @file LLMBridge.hpp + * @brief LLM capability bridge for React Native + * + * Matches Swift's CppBridge+LLM.swift pattern, providing: + * - Model lifecycle (load/unload) + * - Text generation (sync and streaming) + * - Cancellation support + * + * Aligned with rac_llm_component.h and rac_llm_types.h API. + * RACommons is REQUIRED - no stub implementations. + */ + +#pragma once + +#include +#include +#include + +// RACommons LLM headers - REQUIRED (flat include paths) +#include "rac_llm_component.h" +#include "rac_llm_types.h" + +namespace runanywhere { +namespace bridges { + +/** + * @brief LLM streaming callbacks + */ +struct LLMStreamCallbacks { + std::function onToken; + std::function onComplete; + std::function onError; +}; + +/** + * @brief LLM generation options + */ +struct LLMOptions { + int maxTokens = 512; + double temperature = 0.7; + double topP = 0.9; + int topK = 40; + std::string systemPrompt; + std::string stopSequence; +}; + +/** + * @brief LLM generation result + */ +struct LLMResult { + std::string text; + int tokenCount = 0; + double durationMs = 0.0; + bool cancelled = false; +}; + +/** + * @brief LLM capability bridge singleton + * + * Matches CppBridge+LLM.swift API. + * NOTE: RACommons is REQUIRED. All methods will throw std::runtime_error if + * the underlying C API calls fail. + */ +class LLMBridge { +public: + static LLMBridge& shared(); + + // Lifecycle + bool isLoaded() const; + std::string currentModelId() const; + /** + * Load an LLM model + * @param modelPath Path to the model file (.gguf) + * @param modelId Model identifier for telemetry (e.g., "smollm2-360m-q8_0") + * @param modelName Human-readable model name (e.g., "SmolLM2 360M Q8_0") + * @return RAC_SUCCESS or error code + */ + rac_result_t loadModel(const std::string& modelPath, + const std::string& modelId = "", + const std::string& modelName = ""); + rac_result_t unload(); + void cleanup(); + void cancel(); + void destroy(); + + // Generation + LLMResult generate(const std::string& prompt, const LLMOptions& options); + void generateStream(const std::string& prompt, const LLMOptions& options, + const LLMStreamCallbacks& callbacks); + + // State + rac_lifecycle_state_t getState() const; + +private: + LLMBridge(); + ~LLMBridge(); + + // Disable copy/move + LLMBridge(const LLMBridge&) = delete; + LLMBridge& operator=(const LLMBridge&) = delete; + + rac_handle_t handle_ = nullptr; + std::string loadedModelId_; + bool cancellationRequested_ = false; +}; + +} // namespace bridges +} // namespace runanywhere diff --git a/sdk/runanywhere-react-native/packages/llamacpp/cpp/bridges/StructuredOutputBridge.cpp b/sdk/runanywhere-react-native/packages/llamacpp/cpp/bridges/StructuredOutputBridge.cpp new file mode 100644 index 000000000..a42bb6a21 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/llamacpp/cpp/bridges/StructuredOutputBridge.cpp @@ -0,0 +1,151 @@ +/** + * @file StructuredOutputBridge.cpp + * @brief Structured Output bridge implementation + * + * Uses RACommons structured output API for prompt preparation and JSON extraction. + * Uses LLMBridge for actual text generation. + * RACommons is REQUIRED - no stub implementations. + */ + +#include "StructuredOutputBridge.hpp" +#include "LLMBridge.hpp" +#include +#include // For free() + +// Unified logging via rac_logger.h +#include "rac_logger.h" + +// Log category for this module +#define LOG_CATEGORY "LLM.StructuredOutput" + +namespace runanywhere { +namespace bridges { + +StructuredOutputBridge& StructuredOutputBridge::shared() { + static StructuredOutputBridge instance; + return instance; +} + +StructuredOutputResult StructuredOutputBridge::generate( + const std::string& prompt, + const std::string& schema, + const std::string& optionsJson +) { + StructuredOutputResult result; + + if (!LLMBridge::shared().isLoaded()) { + throw std::runtime_error("StructuredOutputBridge: LLM model not loaded. Call loadModel() first."); + } + + // Prepare the prompt using RACommons structured output API + rac_structured_output_config_t config = RAC_STRUCTURED_OUTPUT_DEFAULT; + config.json_schema = schema.c_str(); + config.include_schema_in_prompt = RAC_TRUE; + + char* preparedPrompt = nullptr; + rac_result_t prepResult = rac_structured_output_prepare_prompt( + prompt.c_str(), + &config, + &preparedPrompt + ); + + std::string structuredPrompt; + if (prepResult == RAC_SUCCESS && preparedPrompt) { + structuredPrompt = preparedPrompt; + free(preparedPrompt); + } else { + // Fallback: Build prompt manually + RAC_LOG_DEBUG(LOG_CATEGORY, "Fallback to manual prompt preparation"); + structuredPrompt = + "You must respond with valid JSON matching this schema:\n" + + schema + "\n\n" + + "User request: " + prompt + "\n\n" + + "Respond with valid JSON only, no other text:"; + } + + // Generate using LLMBridge + LLMOptions opts; + opts.maxTokens = 1024; + opts.temperature = 0.1; // Lower temperature for structured output + // TODO: Parse optionsJson if provided + + LLMResult llmResult; + try { + llmResult = LLMBridge::shared().generate(structuredPrompt, opts); + } catch (const std::runtime_error& e) { + throw std::runtime_error("StructuredOutputBridge: LLM generation failed: " + std::string(e.what())); + } + + if (llmResult.text.empty()) { + throw std::runtime_error("StructuredOutputBridge: LLM generation returned empty text."); + } + + // Extract JSON using RACommons API + char* extractedJson = nullptr; + size_t jsonLength = 0; + rac_result_t extractResult = rac_structured_output_extract_json( + llmResult.text.c_str(), + &extractedJson, + &jsonLength + ); + + if (extractResult == RAC_SUCCESS && extractedJson && jsonLength > 0) { + result.json = std::string(extractedJson, jsonLength); + result.success = true; + free(extractedJson); + RAC_LOG_INFO(LOG_CATEGORY, "Successfully extracted JSON (%zu bytes)", jsonLength); + } else { + // Fallback: Try manual extraction + RAC_LOG_DEBUG(LOG_CATEGORY, "Fallback to manual JSON extraction"); + + std::string text = llmResult.text; + size_t start = 0, end = 0; + + // Try using RACommons to find JSON boundaries + if (rac_structured_output_find_complete_json(text.c_str(), &start, &end) == RAC_TRUE) { + result.json = text.substr(start, end - start); + result.success = true; + } else { + // Manual fallback + start = text.find('{'); + end = text.rfind('}'); + + if (start != std::string::npos && end != std::string::npos && end > start) { + result.json = text.substr(start, end - start + 1); + result.success = true; + } else { + // Try array + start = text.find('['); + end = text.rfind(']'); + if (start != std::string::npos && end != std::string::npos && end > start) { + result.json = text.substr(start, end - start + 1); + result.success = true; + } else { + throw std::runtime_error("StructuredOutputBridge: Could not extract valid JSON from response: " + text); + } + } + } + } + + // Validate the extracted JSON (optional but good for debugging) + if (result.success) { + rac_structured_output_validation_t validation = {}; + rac_result_t valResult = rac_structured_output_validate( + result.json.c_str(), + &config, + &validation + ); + + if (valResult != RAC_SUCCESS || validation.is_valid != RAC_TRUE) { + RAC_LOG_WARNING(LOG_CATEGORY, "Extracted JSON failed validation"); + // Don't throw - the JSON was extracted, just log warning + } + + rac_structured_output_validation_free(&validation); + } + + return result; +} + +} // namespace bridges +} // namespace runanywhere diff --git a/sdk/runanywhere-react-native/packages/llamacpp/cpp/bridges/StructuredOutputBridge.hpp b/sdk/runanywhere-react-native/packages/llamacpp/cpp/bridges/StructuredOutputBridge.hpp new file mode 100644 index 000000000..62eb19aae --- /dev/null +++ b/sdk/runanywhere-react-native/packages/llamacpp/cpp/bridges/StructuredOutputBridge.hpp @@ -0,0 +1,66 @@ +/** + * @file StructuredOutputBridge.hpp + * @brief Structured Output bridge for React Native + * + * Matches Swift's RunAnywhere+StructuredOutput.swift pattern, providing: + * - JSON schema-guided generation + * - Structured output extraction + * + * Aligned with rac_llm_structured_output.h API. + * RACommons is REQUIRED - no stub implementations. + */ + +#pragma once + +#include + +// RACommons structured output header - REQUIRED (flat include paths) +#include "rac_llm_structured_output.h" +#include "rac_llm_types.h" + +namespace runanywhere { +namespace bridges { + +/** + * @brief Structured output result + */ +struct StructuredOutputResult { + std::string json; + bool success = false; + std::string error; +}; + +/** + * @brief Structured Output bridge singleton + * + * Generates LLM output following a JSON schema. + * NOTE: RACommons is REQUIRED. All methods will throw std::runtime_error if + * the underlying C API calls fail. + */ +class StructuredOutputBridge { +public: + static StructuredOutputBridge& shared(); + + /** + * Generate structured output following a JSON schema + * @param prompt User prompt + * @param schema JSON schema string + * @param optionsJson Generation options + * @return Structured output result + */ + StructuredOutputResult generate( + const std::string& prompt, + const std::string& schema, + const std::string& optionsJson = "" + ); + +private: + StructuredOutputBridge() = default; + ~StructuredOutputBridge() = default; + + StructuredOutputBridge(const StructuredOutputBridge&) = delete; + StructuredOutputBridge& operator=(const StructuredOutputBridge&) = delete; +}; + +} // namespace bridges +} // namespace runanywhere diff --git a/sdk/runanywhere-react-native/packages/llamacpp/cpp/rac_llm_llamacpp.h b/sdk/runanywhere-react-native/packages/llamacpp/cpp/rac_llm_llamacpp.h new file mode 100644 index 000000000..1303416ae --- /dev/null +++ b/sdk/runanywhere-react-native/packages/llamacpp/cpp/rac_llm_llamacpp.h @@ -0,0 +1,34 @@ +/** + * @file rac_llm_llamacpp.h + * @brief Backend registration API for LlamaCPP + * + * Forward declarations for LlamaCPP backend registration functions. + * These symbols are exported by RABackendLLAMACPP.xcframework. + */ + +#ifndef RAC_LLM_LLAMACPP_H +#define RAC_LLM_LLAMACPP_H + +#include "rac_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Register the LlamaCPP backend with the RACommons service registry. + * @return RAC_SUCCESS on success, RAC_ERROR_MODULE_ALREADY_REGISTERED if already registered + */ +rac_result_t rac_backend_llamacpp_register(void); + +/** + * Unregister the LlamaCPP backend from the RACommons service registry. + * @return RAC_SUCCESS on success + */ +rac_result_t rac_backend_llamacpp_unregister(void); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_LLM_LLAMACPP_H */ diff --git a/sdk/runanywhere-react-native/packages/llamacpp/ios/.testlocal b/sdk/runanywhere-react-native/packages/llamacpp/ios/.testlocal new file mode 100644 index 000000000..e69de29bb diff --git a/sdk/runanywhere-react-native/packages/llamacpp/ios/LlamaCPPBackend.podspec b/sdk/runanywhere-react-native/packages/llamacpp/ios/LlamaCPPBackend.podspec new file mode 100644 index 000000000..cf3e8941c --- /dev/null +++ b/sdk/runanywhere-react-native/packages/llamacpp/ios/LlamaCPPBackend.podspec @@ -0,0 +1,127 @@ +require "json" + +package = JSON.parse(File.read(File.join(__dir__, "..", "package.json"))) + +# ============================================================================= +# Version Constants (MUST match Swift Package.swift) +# ============================================================================= +CORE_VERSION = "0.1.4" + +# ============================================================================= +# Binary Source - RABackendLlamaCPP from runanywhere-sdks +# ============================================================================= +GITHUB_ORG = "RunanywhereAI" +CORE_REPO = "runanywhere-sdks" + +# ============================================================================= +# testLocal Toggle +# Set RA_TEST_LOCAL=1 or create .testlocal file to use local binaries +# ============================================================================= +TEST_LOCAL = ENV['RA_TEST_LOCAL'] == '1' || File.exist?(File.join(__dir__, '.testlocal')) + +Pod::Spec.new do |s| + s.name = "LlamaCPPBackend" + s.module_name = "RunAnywhereLlama" + s.version = package["version"] + s.summary = package["description"] + s.homepage = "https://runanywhere.com" + s.license = package["license"] + s.authors = "RunAnywhere AI" + + s.platforms = { :ios => "15.1" } + s.source = { :git => "https://github.com/RunanywhereAI/sdks.git", :tag => "#{s.version}" } + + # ============================================================================= + # Llama Backend - RABackendLlamaCPP + # Downloads from runanywhere-sdks (NOT runanywhere-sdks) + # ============================================================================= + if TEST_LOCAL + puts "[LlamaCPPBackend] Using LOCAL RABackendLlamaCPP from Frameworks/" + s.vendored_frameworks = "Frameworks/RABackendLLAMACPP.xcframework" + else + s.prepare_command = <<-CMD + set -e + + FRAMEWORK_DIR="Frameworks" + VERSION="#{CORE_VERSION}" + VERSION_FILE="$FRAMEWORK_DIR/.llamacpp_version" + + # Check if already downloaded with correct version + if [ -f "$VERSION_FILE" ] && [ -d "$FRAMEWORK_DIR/RABackendLLAMACPP.xcframework" ]; then + CURRENT_VERSION=$(cat "$VERSION_FILE") + if [ "$CURRENT_VERSION" = "$VERSION" ]; then + echo "✅ RABackendLLAMACPP.xcframework version $VERSION already downloaded" + exit 0 + fi + fi + + echo "📦 Downloading RABackendLlamaCPP.xcframework version $VERSION..." + + mkdir -p "$FRAMEWORK_DIR" + rm -rf "$FRAMEWORK_DIR/RABackendLLAMACPP.xcframework" + + # Download from runanywhere-sdks + DOWNLOAD_URL="https://github.com/#{GITHUB_ORG}/#{CORE_REPO}/releases/download/core-v$VERSION/RABackendLlamaCPP-ios-v$VERSION.zip" + ZIP_FILE="/tmp/RABackendLlamaCPP.zip" + + echo " URL: $DOWNLOAD_URL" + + curl -L -f -o "$ZIP_FILE" "$DOWNLOAD_URL" || { + echo "❌ Failed to download RABackendLlamaCPP from $DOWNLOAD_URL" + exit 1 + } + + echo "📂 Extracting RABackendLLAMACPP.xcframework..." + unzip -q -o "$ZIP_FILE" -d "$FRAMEWORK_DIR/" + rm -f "$ZIP_FILE" + + echo "$VERSION" > "$VERSION_FILE" + + if [ -d "$FRAMEWORK_DIR/RABackendLLAMACPP.xcframework" ]; then + echo "✅ RABackendLLAMACPP.xcframework installed successfully" + else + echo "❌ RABackendLLAMACPP.xcframework extraction failed" + exit 1 + fi + CMD + + s.vendored_frameworks = "Frameworks/RABackendLLAMACPP.xcframework" + end + + # Source files - Llama C++ implementation + s.source_files = [ + "../cpp/HybridRunAnywhereLlama.cpp", + "../cpp/HybridRunAnywhereLlama.hpp", + "../cpp/bridges/**/*.{cpp,hpp}", + ] + + # Build settings + s.pod_target_xcconfig = { + "CLANG_CXX_LANGUAGE_STANDARD" => "c++17", + "HEADER_SEARCH_PATHS" => [ + "$(PODS_TARGET_SRCROOT)/../cpp", + "$(PODS_TARGET_SRCROOT)/../cpp/bridges", + "$(PODS_TARGET_SRCROOT)/Frameworks/RABackendLLAMACPP.xcframework/ios-arm64/Headers", + "$(PODS_TARGET_SRCROOT)/Frameworks/RABackendLLAMACPP.xcframework/ios-arm64_x86_64-simulator/Headers", + "$(PODS_ROOT)/Headers/Public", + ].join(" "), + "GCC_PREPROCESSOR_DEFINITIONS" => "$(inherited) HAS_LLAMACPP=1", + "DEFINES_MODULE" => "YES", + "SWIFT_OBJC_INTEROP_MODE" => "objcxx", + } + + # Required system libraries + s.libraries = "c++" + s.frameworks = "Accelerate", "Foundation", "CoreML" + + # Dependencies + s.dependency 'RunAnywhereCore' + s.dependency 'React-jsi' + s.dependency 'React-callinvoker' + + # Load Nitrogen-generated autolinking + load '../nitrogen/generated/ios/RunAnywhereLlama+autolinking.rb' + add_nitrogen_files(s) + + install_modules_dependencies(s) +end diff --git a/sdk/runanywhere-react-native/packages/llamacpp/nitro.json b/sdk/runanywhere-react-native/packages/llamacpp/nitro.json new file mode 100644 index 000000000..7b4f21452 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/llamacpp/nitro.json @@ -0,0 +1,16 @@ +{ + "cxxNamespace": ["runanywhere", "llama"], + "ios": { + "iosModuleName": "RunAnywhereLlama" + }, + "android": { + "androidNamespace": ["runanywhere", "llama"], + "androidCxxLibName": "runanywherellama" + }, + "autolinking": { + "RunAnywhereLlama": { + "cpp": "HybridRunAnywhereLlama" + } + }, + "ignorePaths": ["node_modules", "lib", "example"] +} diff --git a/sdk/runanywhere-react-native/packages/llamacpp/nitrogen/generated/.gitattributes b/sdk/runanywhere-react-native/packages/llamacpp/nitrogen/generated/.gitattributes new file mode 100644 index 000000000..fb7a0d5a3 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/llamacpp/nitrogen/generated/.gitattributes @@ -0,0 +1 @@ +** linguist-generated=true diff --git a/sdk/runanywhere-react-native/packages/llamacpp/nitrogen/generated/android/kotlin/com/margelo/nitro/runanywhere/llama/runanywherellamaOnLoad.kt b/sdk/runanywhere-react-native/packages/llamacpp/nitrogen/generated/android/kotlin/com/margelo/nitro/runanywhere/llama/runanywherellamaOnLoad.kt new file mode 100644 index 000000000..51e1b1c93 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/llamacpp/nitrogen/generated/android/kotlin/com/margelo/nitro/runanywhere/llama/runanywherellamaOnLoad.kt @@ -0,0 +1,35 @@ +/// +/// runanywherellamaOnLoad.kt +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2026 Marc Rousavy @ Margelo +/// + +package com.margelo.nitro.runanywhere.llama + +import android.util.Log + +internal class runanywherellamaOnLoad { + companion object { + private const val TAG = "runanywherellamaOnLoad" + private var didLoad = false + /** + * Initializes the native part of "runanywherellama". + * This method is idempotent and can be called more than once. + */ + @JvmStatic + fun initializeNative() { + if (didLoad) return + try { + Log.i(TAG, "Loading runanywherellama C++ library...") + System.loadLibrary("runanywherellama") + Log.i(TAG, "Successfully loaded runanywherellama C++ library!") + didLoad = true + } catch (e: Error) { + Log.e(TAG, "Failed to load runanywherellama C++ library! Is it properly installed and linked? " + + "Is the name correct? (see `CMakeLists.txt`, at `add_library(...)`)", e) + throw e + } + } + } +} diff --git a/sdk/runanywhere-react-native/packages/llamacpp/nitrogen/generated/android/runanywherellama+autolinking.cmake b/sdk/runanywhere-react-native/packages/llamacpp/nitrogen/generated/android/runanywherellama+autolinking.cmake new file mode 100644 index 000000000..a60b40513 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/llamacpp/nitrogen/generated/android/runanywherellama+autolinking.cmake @@ -0,0 +1,81 @@ +# +# runanywherellama+autolinking.cmake +# This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +# https://github.com/mrousavy/nitro +# Copyright © 2026 Marc Rousavy @ Margelo +# + +# This is a CMake file that adds all files generated by Nitrogen +# to the current CMake project. +# +# To use it, add this to your CMakeLists.txt: +# ```cmake +# include(${CMAKE_SOURCE_DIR}/../nitrogen/generated/android/runanywherellama+autolinking.cmake) +# ``` + +# Define a flag to check if we are building properly +add_definitions(-DBUILDING_RUNANYWHERELLAMA_WITH_GENERATED_CMAKE_PROJECT) + +# Enable Raw Props parsing in react-native (for Nitro Views) +add_definitions(-DRN_SERIALIZABLE_STATE) + +# Add all headers that were generated by Nitrogen +include_directories( + "../nitrogen/generated/shared/c++" + "../nitrogen/generated/android/c++" + "../nitrogen/generated/android/" +) + +# Add all .cpp sources that were generated by Nitrogen +target_sources( + # CMake project name (Android C++ library name) + runanywherellama PRIVATE + # Autolinking Setup + ../nitrogen/generated/android/runanywherellamaOnLoad.cpp + # Shared Nitrogen C++ sources + ../nitrogen/generated/shared/c++/HybridRunAnywhereLlamaSpec.cpp + # Android-specific Nitrogen C++ sources + +) + +# From node_modules/react-native/ReactAndroid/cmake-utils/folly-flags.cmake +# Used in node_modules/react-native/ReactAndroid/cmake-utils/ReactNative-application.cmake +target_compile_definitions( + runanywherellama PRIVATE + -DFOLLY_NO_CONFIG=1 + -DFOLLY_HAVE_CLOCK_GETTIME=1 + -DFOLLY_USE_LIBCPP=1 + -DFOLLY_CFG_NO_COROUTINES=1 + -DFOLLY_MOBILE=1 + -DFOLLY_HAVE_RECVMMSG=1 + -DFOLLY_HAVE_PTHREAD=1 + # Once we target android-23 above, we can comment + # the following line. NDK uses GNU style stderror_r() after API 23. + -DFOLLY_HAVE_XSI_STRERROR_R=1 +) + +# Add all libraries required by the generated specs +find_package(fbjni REQUIRED) # <-- Used for communication between Java <-> C++ +find_package(ReactAndroid REQUIRED) # <-- Used to set up React Native bindings (e.g. CallInvoker/TurboModule) +find_package(react-native-nitro-modules REQUIRED) # <-- Used to create all HybridObjects and use the Nitro core library + +# Link all libraries together +target_link_libraries( + runanywherellama + fbjni::fbjni # <-- Facebook C++ JNI helpers + ReactAndroid::jsi # <-- RN: JSI + react-native-nitro-modules::NitroModules # <-- NitroModules Core :) +) + +# Link react-native (different prefab between RN 0.75 and RN 0.76) +if(ReactAndroid_VERSION_MINOR GREATER_EQUAL 76) + target_link_libraries( + runanywherellama + ReactAndroid::reactnative # <-- RN: Native Modules umbrella prefab + ) +else() + target_link_libraries( + runanywherellama + ReactAndroid::react_nativemodule_core # <-- RN: TurboModules Core + ) +endif() diff --git a/sdk/runanywhere-react-native/packages/llamacpp/nitrogen/generated/android/runanywherellama+autolinking.gradle b/sdk/runanywhere-react-native/packages/llamacpp/nitrogen/generated/android/runanywherellama+autolinking.gradle new file mode 100644 index 000000000..fd34562d8 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/llamacpp/nitrogen/generated/android/runanywherellama+autolinking.gradle @@ -0,0 +1,27 @@ +/// +/// runanywherellama+autolinking.gradle +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2026 Marc Rousavy @ Margelo +/// + +/// This is a Gradle file that adds all files generated by Nitrogen +/// to the current Gradle project. +/// +/// To use it, add this to your build.gradle: +/// ```gradle +/// apply from: '../nitrogen/generated/android/runanywherellama+autolinking.gradle' +/// ``` + +logger.warn("[NitroModules] 🔥 runanywherellama is boosted by nitro!") + +android { + sourceSets { + main { + java.srcDirs += [ + // Nitrogen files + "${project.projectDir}/../nitrogen/generated/android/kotlin" + ] + } + } +} diff --git a/sdk/runanywhere-react-native/packages/llamacpp/nitrogen/generated/android/runanywherellamaOnLoad.cpp b/sdk/runanywhere-react-native/packages/llamacpp/nitrogen/generated/android/runanywherellamaOnLoad.cpp new file mode 100644 index 000000000..b14b05f70 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/llamacpp/nitrogen/generated/android/runanywherellamaOnLoad.cpp @@ -0,0 +1,44 @@ +/// +/// runanywherellamaOnLoad.cpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2026 Marc Rousavy @ Margelo +/// + +#ifndef BUILDING_RUNANYWHERELLAMA_WITH_GENERATED_CMAKE_PROJECT +#error runanywherellamaOnLoad.cpp is not being built with the autogenerated CMakeLists.txt project. Is a different CMakeLists.txt building this? +#endif + +#include "runanywherellamaOnLoad.hpp" + +#include +#include +#include + +#include "HybridRunAnywhereLlama.hpp" + +namespace margelo::nitro::runanywhere::llama { + +int initialize(JavaVM* vm) { + using namespace margelo::nitro; + using namespace margelo::nitro::runanywhere::llama; + using namespace facebook; + + return facebook::jni::initialize(vm, [] { + // Register native JNI methods + + + // Register Nitro Hybrid Objects + HybridObjectRegistry::registerHybridObjectConstructor( + "RunAnywhereLlama", + []() -> std::shared_ptr { + static_assert(std::is_default_constructible_v, + "The HybridObject \"HybridRunAnywhereLlama\" is not default-constructible! " + "Create a public constructor that takes zero arguments to be able to autolink this HybridObject."); + return std::make_shared(); + } + ); + }); +} + +} // namespace margelo::nitro::runanywhere::llama diff --git a/sdk/runanywhere-react-native/packages/llamacpp/nitrogen/generated/android/runanywherellamaOnLoad.hpp b/sdk/runanywhere-react-native/packages/llamacpp/nitrogen/generated/android/runanywherellamaOnLoad.hpp new file mode 100644 index 000000000..76a46130c --- /dev/null +++ b/sdk/runanywhere-react-native/packages/llamacpp/nitrogen/generated/android/runanywherellamaOnLoad.hpp @@ -0,0 +1,25 @@ +/// +/// runanywherellamaOnLoad.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2026 Marc Rousavy @ Margelo +/// + +#include +#include + +namespace margelo::nitro::runanywhere::llama { + + /** + * Initializes the native (C++) part of runanywherellama, and autolinks all Hybrid Objects. + * Call this in your `JNI_OnLoad` function (probably inside `cpp-adapter.cpp`). + * Example: + * ```cpp (cpp-adapter.cpp) + * JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void*) { + * return margelo::nitro::runanywhere::llama::initialize(vm); + * } + * ``` + */ + int initialize(JavaVM* vm); + +} // namespace margelo::nitro::runanywhere::llama diff --git a/sdk/runanywhere-react-native/packages/llamacpp/nitrogen/generated/ios/RunAnywhereLlama+autolinking.rb b/sdk/runanywhere-react-native/packages/llamacpp/nitrogen/generated/ios/RunAnywhereLlama+autolinking.rb new file mode 100644 index 000000000..14cc888ee --- /dev/null +++ b/sdk/runanywhere-react-native/packages/llamacpp/nitrogen/generated/ios/RunAnywhereLlama+autolinking.rb @@ -0,0 +1,60 @@ +# +# RunAnywhereLlama+autolinking.rb +# This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +# https://github.com/mrousavy/nitro +# Copyright © 2026 Marc Rousavy @ Margelo +# + +# This is a Ruby script that adds all files generated by Nitrogen +# to the given podspec. +# +# To use it, add this to your .podspec: +# ```ruby +# Pod::Spec.new do |spec| +# # ... +# +# # Add all files generated by Nitrogen +# load 'nitrogen/generated/ios/RunAnywhereLlama+autolinking.rb' +# add_nitrogen_files(spec) +# end +# ``` + +def add_nitrogen_files(spec) + Pod::UI.puts "[NitroModules] 🔥 RunAnywhereLlama is boosted by nitro!" + + spec.dependency "NitroModules" + + current_source_files = Array(spec.attributes_hash['source_files']) + spec.source_files = current_source_files + [ + # Generated cross-platform specs + "nitrogen/generated/shared/**/*.{h,hpp,c,cpp,swift}", + # Generated bridges for the cross-platform specs + "nitrogen/generated/ios/**/*.{h,hpp,c,cpp,mm,swift}", + ] + + current_public_header_files = Array(spec.attributes_hash['public_header_files']) + spec.public_header_files = current_public_header_files + [ + # Generated specs + "nitrogen/generated/shared/**/*.{h,hpp}", + # Swift to C++ bridging helpers + "nitrogen/generated/ios/RunAnywhereLlama-Swift-Cxx-Bridge.hpp" + ] + + current_private_header_files = Array(spec.attributes_hash['private_header_files']) + spec.private_header_files = current_private_header_files + [ + # iOS specific specs + "nitrogen/generated/ios/c++/**/*.{h,hpp}", + # Views are framework-specific and should be private + "nitrogen/generated/shared/**/views/**/*" + ] + + current_pod_target_xcconfig = spec.attributes_hash['pod_target_xcconfig'] || {} + spec.pod_target_xcconfig = current_pod_target_xcconfig.merge({ + # Use C++ 20 + "CLANG_CXX_LANGUAGE_STANDARD" => "c++20", + # Enables C++ <-> Swift interop (by default it's only C) + "SWIFT_OBJC_INTEROP_MODE" => "objcxx", + # Enables stricter modular headers + "DEFINES_MODULE" => "YES", + }) +end diff --git a/sdk/runanywhere-react-native/packages/llamacpp/nitrogen/generated/ios/RunAnywhereLlama-Swift-Cxx-Bridge.cpp b/sdk/runanywhere-react-native/packages/llamacpp/nitrogen/generated/ios/RunAnywhereLlama-Swift-Cxx-Bridge.cpp new file mode 100644 index 000000000..c865136ed --- /dev/null +++ b/sdk/runanywhere-react-native/packages/llamacpp/nitrogen/generated/ios/RunAnywhereLlama-Swift-Cxx-Bridge.cpp @@ -0,0 +1,17 @@ +/// +/// RunAnywhereLlama-Swift-Cxx-Bridge.cpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2026 Marc Rousavy @ Margelo +/// + +#include "RunAnywhereLlama-Swift-Cxx-Bridge.hpp" + +// Include C++ implementation defined types + + +namespace margelo::nitro::runanywhere::llama::bridge::swift { + + + +} // namespace margelo::nitro::runanywhere::llama::bridge::swift diff --git a/sdk/runanywhere-react-native/packages/llamacpp/nitrogen/generated/ios/RunAnywhereLlama-Swift-Cxx-Bridge.hpp b/sdk/runanywhere-react-native/packages/llamacpp/nitrogen/generated/ios/RunAnywhereLlama-Swift-Cxx-Bridge.hpp new file mode 100644 index 000000000..6a930b0ed --- /dev/null +++ b/sdk/runanywhere-react-native/packages/llamacpp/nitrogen/generated/ios/RunAnywhereLlama-Swift-Cxx-Bridge.hpp @@ -0,0 +1,27 @@ +/// +/// RunAnywhereLlama-Swift-Cxx-Bridge.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2026 Marc Rousavy @ Margelo +/// + +#pragma once + +// Forward declarations of C++ defined types + + +// Forward declarations of Swift defined types + + +// Include C++ defined types + + +/** + * Contains specialized versions of C++ templated types so they can be accessed from Swift, + * as well as helper functions to interact with those C++ types from Swift. + */ +namespace margelo::nitro::runanywhere::llama::bridge::swift { + + + +} // namespace margelo::nitro::runanywhere::llama::bridge::swift diff --git a/sdk/runanywhere-react-native/packages/llamacpp/nitrogen/generated/ios/RunAnywhereLlama-Swift-Cxx-Umbrella.hpp b/sdk/runanywhere-react-native/packages/llamacpp/nitrogen/generated/ios/RunAnywhereLlama-Swift-Cxx-Umbrella.hpp new file mode 100644 index 000000000..36246178b --- /dev/null +++ b/sdk/runanywhere-react-native/packages/llamacpp/nitrogen/generated/ios/RunAnywhereLlama-Swift-Cxx-Umbrella.hpp @@ -0,0 +1,38 @@ +/// +/// RunAnywhereLlama-Swift-Cxx-Umbrella.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2026 Marc Rousavy @ Margelo +/// + +#pragma once + +// Forward declarations of C++ defined types + + +// Include C++ defined types + + +// C++ helpers for Swift +#include "RunAnywhereLlama-Swift-Cxx-Bridge.hpp" + +// Common C++ types used in Swift +#include +#include +#include +#include + +// Forward declarations of Swift defined types + + +// Include Swift defined types +#if __has_include("RunAnywhereLlama-Swift.h") +// This header is generated by Xcode/Swift on every app build. +// If it cannot be found, make sure the Swift module's name (= podspec name) is actually "RunAnywhereLlama". +#include "RunAnywhereLlama-Swift.h" +// Same as above, but used when building with frameworks (`use_frameworks`) +#elif __has_include() +#include +#else +#error RunAnywhereLlama's autogenerated Swift header cannot be found! Make sure the Swift module's name (= podspec name) is actually "RunAnywhereLlama", and try building the app first. +#endif diff --git a/sdk/runanywhere-react-native/packages/llamacpp/nitrogen/generated/ios/RunAnywhereLlamaAutolinking.mm b/sdk/runanywhere-react-native/packages/llamacpp/nitrogen/generated/ios/RunAnywhereLlamaAutolinking.mm new file mode 100644 index 000000000..c1113f63f --- /dev/null +++ b/sdk/runanywhere-react-native/packages/llamacpp/nitrogen/generated/ios/RunAnywhereLlamaAutolinking.mm @@ -0,0 +1,35 @@ +/// +/// RunAnywhereLlamaAutolinking.mm +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2026 Marc Rousavy @ Margelo +/// + +#import +#import + +#import + +#include "HybridRunAnywhereLlama.hpp" + +@interface RunAnywhereLlamaAutolinking : NSObject +@end + +@implementation RunAnywhereLlamaAutolinking + ++ (void) load { + using namespace margelo::nitro; + using namespace margelo::nitro::runanywhere::llama; + + HybridObjectRegistry::registerHybridObjectConstructor( + "RunAnywhereLlama", + []() -> std::shared_ptr { + static_assert(std::is_default_constructible_v, + "The HybridObject \"HybridRunAnywhereLlama\" is not default-constructible! " + "Create a public constructor that takes zero arguments to be able to autolink this HybridObject."); + return std::make_shared(); + } + ); +} + +@end diff --git a/sdk/runanywhere-react-native/packages/llamacpp/nitrogen/generated/ios/RunAnywhereLlamaAutolinking.swift b/sdk/runanywhere-react-native/packages/llamacpp/nitrogen/generated/ios/RunAnywhereLlamaAutolinking.swift new file mode 100644 index 000000000..d987ababb --- /dev/null +++ b/sdk/runanywhere-react-native/packages/llamacpp/nitrogen/generated/ios/RunAnywhereLlamaAutolinking.swift @@ -0,0 +1,12 @@ +/// +/// RunAnywhereLlamaAutolinking.swift +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2026 Marc Rousavy @ Margelo +/// + +public final class RunAnywhereLlamaAutolinking { + public typealias bridge = margelo.nitro.runanywhere.llama.bridge.swift + + +} diff --git a/sdk/runanywhere-react-native/packages/llamacpp/nitrogen/generated/shared/c++/HybridRunAnywhereLlamaSpec.cpp b/sdk/runanywhere-react-native/packages/llamacpp/nitrogen/generated/shared/c++/HybridRunAnywhereLlamaSpec.cpp new file mode 100644 index 000000000..a79346c28 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/llamacpp/nitrogen/generated/shared/c++/HybridRunAnywhereLlamaSpec.cpp @@ -0,0 +1,33 @@ +/// +/// HybridRunAnywhereLlamaSpec.cpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2026 Marc Rousavy @ Margelo +/// + +#include "HybridRunAnywhereLlamaSpec.hpp" + +namespace margelo::nitro::runanywhere::llama { + + void HybridRunAnywhereLlamaSpec::loadHybridMethods() { + // load base methods/properties + HybridObject::loadHybridMethods(); + // load custom methods/properties + registerHybrids(this, [](Prototype& prototype) { + prototype.registerHybridMethod("registerBackend", &HybridRunAnywhereLlamaSpec::registerBackend); + prototype.registerHybridMethod("unregisterBackend", &HybridRunAnywhereLlamaSpec::unregisterBackend); + prototype.registerHybridMethod("isBackendRegistered", &HybridRunAnywhereLlamaSpec::isBackendRegistered); + prototype.registerHybridMethod("loadModel", &HybridRunAnywhereLlamaSpec::loadModel); + prototype.registerHybridMethod("isModelLoaded", &HybridRunAnywhereLlamaSpec::isModelLoaded); + prototype.registerHybridMethod("unloadModel", &HybridRunAnywhereLlamaSpec::unloadModel); + prototype.registerHybridMethod("getModelInfo", &HybridRunAnywhereLlamaSpec::getModelInfo); + prototype.registerHybridMethod("generate", &HybridRunAnywhereLlamaSpec::generate); + prototype.registerHybridMethod("generateStream", &HybridRunAnywhereLlamaSpec::generateStream); + prototype.registerHybridMethod("cancelGeneration", &HybridRunAnywhereLlamaSpec::cancelGeneration); + prototype.registerHybridMethod("generateStructured", &HybridRunAnywhereLlamaSpec::generateStructured); + prototype.registerHybridMethod("getLastError", &HybridRunAnywhereLlamaSpec::getLastError); + prototype.registerHybridMethod("getMemoryUsage", &HybridRunAnywhereLlamaSpec::getMemoryUsage); + }); + } + +} // namespace margelo::nitro::runanywhere::llama diff --git a/sdk/runanywhere-react-native/packages/llamacpp/nitrogen/generated/shared/c++/HybridRunAnywhereLlamaSpec.hpp b/sdk/runanywhere-react-native/packages/llamacpp/nitrogen/generated/shared/c++/HybridRunAnywhereLlamaSpec.hpp new file mode 100644 index 000000000..8aab844b0 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/llamacpp/nitrogen/generated/shared/c++/HybridRunAnywhereLlamaSpec.hpp @@ -0,0 +1,77 @@ +/// +/// HybridRunAnywhereLlamaSpec.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2026 Marc Rousavy @ Margelo +/// + +#pragma once + +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif + + + +#include +#include +#include +#include + +namespace margelo::nitro::runanywhere::llama { + + using namespace margelo::nitro; + + /** + * An abstract base class for `RunAnywhereLlama` + * Inherit this class to create instances of `HybridRunAnywhereLlamaSpec` in C++. + * You must explicitly call `HybridObject`'s constructor yourself, because it is virtual. + * @example + * ```cpp + * class HybridRunAnywhereLlama: public HybridRunAnywhereLlamaSpec { + * public: + * HybridRunAnywhereLlama(...): HybridObject(TAG) { ... } + * // ... + * }; + * ``` + */ + class HybridRunAnywhereLlamaSpec: public virtual HybridObject { + public: + // Constructor + explicit HybridRunAnywhereLlamaSpec(): HybridObject(TAG) { } + + // Destructor + ~HybridRunAnywhereLlamaSpec() override = default; + + public: + // Properties + + + public: + // Methods + virtual std::shared_ptr> registerBackend() = 0; + virtual std::shared_ptr> unregisterBackend() = 0; + virtual std::shared_ptr> isBackendRegistered() = 0; + virtual std::shared_ptr> loadModel(const std::string& path, const std::optional& modelId, const std::optional& modelName, const std::optional& configJson) = 0; + virtual std::shared_ptr> isModelLoaded() = 0; + virtual std::shared_ptr> unloadModel() = 0; + virtual std::shared_ptr> getModelInfo() = 0; + virtual std::shared_ptr> generate(const std::string& prompt, const std::optional& optionsJson) = 0; + virtual std::shared_ptr> generateStream(const std::string& prompt, const std::string& optionsJson, const std::function& callback) = 0; + virtual std::shared_ptr> cancelGeneration() = 0; + virtual std::shared_ptr> generateStructured(const std::string& prompt, const std::string& schema, const std::optional& optionsJson) = 0; + virtual std::shared_ptr> getLastError() = 0; + virtual std::shared_ptr> getMemoryUsage() = 0; + + protected: + // Hybrid Setup + void loadHybridMethods() override; + + protected: + // Tag for logging + static constexpr auto TAG = "RunAnywhereLlama"; + }; + +} // namespace margelo::nitro::runanywhere::llama diff --git a/sdk/runanywhere-react-native/packages/llamacpp/package.json b/sdk/runanywhere-react-native/packages/llamacpp/package.json new file mode 100644 index 000000000..f4d673bd6 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/llamacpp/package.json @@ -0,0 +1,61 @@ +{ + "name": "@runanywhere/llamacpp", + "version": "0.17.6", + "description": "LlamaCpp backend for RunAnywhere React Native SDK - GGUF model support for on-device LLM", + "main": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": { + "source": "./src/index.ts", + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./package.json": "./package.json" + }, + "react-native": "src/index", + "source": "src/index", + "files": [ + "src", + "cpp", + "ios", + "android", + "nitrogen", + "nitro.json", + "react-native.config.js", + "*.podspec" + ], + "scripts": { + "typecheck": "tsc --noEmit", + "lint": "eslint \"src/**/*.ts\"", + "lint:fix": "eslint \"src/**/*.ts\" --fix", + "nitrogen": "nitrogen", + "prepare": "nitrogen" + }, + "keywords": [ + "react-native", + "runanywhere", + "llm", + "llamacpp", + "llama", + "gguf", + "on-device", + "nitro", + "expo" + ], + "license": "MIT", + "peerDependencies": { + "@runanywhere/core": ">=0.16.0", + "react": ">=18.0.0", + "react-native": ">=0.74.0", + "react-native-nitro-modules": ">=0.31.3" + }, + "devDependencies": { + "nitrogen": "^0.31.10", + "react-native-nitro-modules": "^0.31.10", + "typescript": "~5.9.2" + }, + "create-react-native-library": { + "languages": "kotlin-swift", + "type": "nitro-module" + } +} diff --git a/sdk/runanywhere-react-native/packages/llamacpp/react-native.config.js b/sdk/runanywhere-react-native/packages/llamacpp/react-native.config.js new file mode 100644 index 000000000..e3f8e278f --- /dev/null +++ b/sdk/runanywhere-react-native/packages/llamacpp/react-native.config.js @@ -0,0 +1,14 @@ +module.exports = { + dependency: { + platforms: { + android: { + sourceDir: './android', + packageImportPath: 'import com.margelo.nitro.runanywhere.llama.RunAnywhereLlamaPackage;', + packageInstance: 'new RunAnywhereLlamaPackage()', + }, + ios: { + podspecPath: './RunAnywhereLlama.podspec', + }, + }, + }, +}; diff --git a/sdk/runanywhere-react-native/packages/llamacpp/src/LlamaCPP.ts b/sdk/runanywhere-react-native/packages/llamacpp/src/LlamaCPP.ts new file mode 100644 index 000000000..c84818657 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/llamacpp/src/LlamaCPP.ts @@ -0,0 +1,206 @@ +/** + * @runanywhere/llamacpp - LlamaCPP Module + * + * LlamaCPP module wrapper for RunAnywhere React Native SDK. + * Provides public API for module registration and model declaration. + * + * This mirrors the Swift SDK's LlamaCPP module pattern: + * - LlamaCPP.register() - Register the module with ServiceRegistry + * - LlamaCPP.addModel() - Declare a model for this module + * + * Reference: sdk/runanywhere-swift/Sources/LlamaCPPRuntime/LlamaCPPServiceProvider.swift + */ + +import { LlamaCppProvider } from './LlamaCppProvider'; +import { + ModelRegistry, + FileSystem, + LLMFramework, + ModelCategory, + ModelFormat, + ConfigurationSource, + SDKLogger, + type ModelInfo, +} from '@runanywhere/core'; + +// SDKLogger instance for this module +const log = new SDKLogger('LLM.LlamaCpp'); + +/** + * Model registration options for LlamaCPP models + * + * Matches iOS: LlamaCPP.addModel() parameter structure + */ +export interface LlamaCPPModelOptions { + /** Unique model ID. If not provided, generated from URL filename */ + id?: string; + /** Display name for the model */ + name: string; + /** Download URL for the model */ + url: string; + /** Model category (defaults to Language for LLM models) */ + modality?: ModelCategory; + /** Memory requirement in bytes */ + memoryRequirement?: number; + /** Whether model supports reasoning/thinking tokens */ + supportsThinking?: boolean; +} + +/** + * LlamaCPP Module + * + * Public API for registering LlamaCPP module and declaring GGUF models. + * This provides the same developer experience as the iOS SDK. + * + * ## Usage + * + * ```typescript + * import { LlamaCPP } from '@runanywhere/llamacpp'; + * + * // Register module + * LlamaCPP.register(); + * + * // Add models + * LlamaCPP.addModel({ + * id: 'smollm2-360m-q8_0', + * name: 'SmolLM2 360M Q8_0', + * url: 'https://huggingface.co/prithivMLmods/SmolLM2-360M-GGUF/resolve/main/SmolLM2-360M.Q8_0.gguf', + * memoryRequirement: 500_000_000 + * }); + * ``` + * + * Matches iOS: public enum LlamaCPP: RunAnywhereModule + */ +export const LlamaCPP = { + /** + * Module metadata + * Matches iOS: static let moduleId, moduleName, inferenceFramework + */ + moduleId: 'llamacpp', + moduleName: 'LlamaCPP', + inferenceFramework: LLMFramework.LlamaCpp, + capabilities: ['llm'] as const, + defaultPriority: 100, + + /** + * Register LlamaCPP module with the SDK + * + * This registers the LlamaCPP provider with ServiceRegistry, + * enabling it to handle GGUF models. + * + * Matches iOS: static func register(priority: Int = defaultPriority) + * + * @example + * ```typescript + * LlamaCPP.register(); + * ``` + */ + register(): void { + log.debug('Registering LlamaCPP module'); + LlamaCppProvider.register(); + log.info('LlamaCPP module registered'); + }, + + /** + * Add a model to this module + * + * Registers a GGUF model with the ModelRegistry. + * The model will use LlamaCPP framework automatically. + * + * Matches iOS: static func addModel(id:name:url:modality:memoryRequirement:supportsThinking:) + * + * @param options - Model registration options + * @returns Promise resolving to the created ModelInfo + * + * @example + * ```typescript + * await LlamaCPP.addModel({ + * id: 'llama-2-7b-chat-q4_k_m', + * name: 'Llama 2 7B Chat Q4_K_M', + * url: 'https://huggingface.co/TheBloke/Llama-2-7B-Chat-GGUF/resolve/main/llama-2-7b-chat.Q4_K_M.gguf', + * memoryRequirement: 4_000_000_000 + * }); + * ``` + */ + async addModel(options: LlamaCPPModelOptions): Promise { + // Generate stable ID from URL if not provided + const modelId = options.id ?? this._generateModelId(options.url); + + // Determine modality (default to Language for LLM) + const category = options.modality ?? ModelCategory.Language; + + // Infer format from URL + const format = options.url.toLowerCase().includes('.gguf') + ? ModelFormat.GGUF + : ModelFormat.GGML; + + const now = new Date().toISOString(); + + // Check if model already exists on disk (persistence across sessions) + let isDownloaded = false; + let localPath: string | undefined; + + if (FileSystem.isAvailable()) { + try { + const exists = await FileSystem.modelExists(modelId, 'LlamaCpp'); + if (exists) { + localPath = await FileSystem.getModelPath(modelId, 'LlamaCpp'); + isDownloaded = true; + log.debug(`Model ${modelId} found on disk: ${localPath}`); + } + } catch (error) { + // Ignore errors checking for existing model + log.debug(`Could not check for existing model ${modelId}: ${error}`); + } + } + + const modelInfo: ModelInfo = { + id: modelId, + name: options.name, + category, + format, + downloadURL: options.url, + localPath, + downloadSize: undefined, + memoryRequired: options.memoryRequirement, + compatibleFrameworks: [LLMFramework.LlamaCpp], + preferredFramework: LLMFramework.LlamaCpp, + supportsThinking: options.supportsThinking ?? false, + metadata: { tags: [] }, + source: ConfigurationSource.Local, + createdAt: now, + updatedAt: now, + syncPending: false, + usageCount: 0, + isDownloaded, + isAvailable: true, + }; + + // Register with ModelRegistry and wait for completion + await ModelRegistry.registerModel(modelInfo); + + log.info(`Added model: ${modelId} (${options.name})`, { + modelId, + isDownloaded, + }); + + return modelInfo; + }, + + /** + * Generate a stable model ID from URL + * @internal + */ + _generateModelId(url: string): string { + try { + const urlObj = new URL(url); + const pathname = urlObj.pathname; + const filename = pathname.split('/').pop() ?? 'model'; + // Remove common extensions + return filename.replace(/\.(gguf|ggml|bin)$/i, ''); + } catch { + // Fallback for invalid URLs + return `model-${Date.now()}`; + } + }, +}; diff --git a/sdk/runanywhere-react-native/packages/llamacpp/src/LlamaCppProvider.ts b/sdk/runanywhere-react-native/packages/llamacpp/src/LlamaCppProvider.ts new file mode 100644 index 000000000..b7353e38d --- /dev/null +++ b/sdk/runanywhere-react-native/packages/llamacpp/src/LlamaCppProvider.ts @@ -0,0 +1,120 @@ +/** + * @runanywhere/llamacpp - LlamaCPP Provider + * + * LlamaCPP module registration for React Native SDK. + * Thin wrapper that triggers C++ backend registration. + * + * Reference: sdk/runanywhere-swift/Sources/LlamaCPPRuntime/LlamaCPP.swift + */ + +import { requireNativeLlamaModule, isNativeLlamaModuleAvailable } from './native/NativeRunAnywhereLlama'; +import { SDKLogger } from '@runanywhere/core'; + +// SDKLogger instance for this module +const log = new SDKLogger('LLM.LlamaCppProvider'); + +/** + * LlamaCPP Module + * + * Provides LLM capabilities using llama.cpp with GGUF models. + * The actual service is provided by the C++ backend. + * + * ## Registration + * + * ```typescript + * import { LlamaCppProvider } from '@runanywhere/llamacpp'; + * + * // Register the backend + * await LlamaCppProvider.register(); + * ``` + */ +export class LlamaCppProvider { + static readonly moduleId = 'llamacpp'; + static readonly moduleName = 'LlamaCPP'; + static readonly version = '2.0.0'; + + private static isRegistered = false; + + /** + * Register LlamaCPP backend with the C++ service registry. + * Calls rac_backend_llamacpp_register() to register the + * LlamaCPP service provider with the C++ commons layer. + * Safe to call multiple times - subsequent calls are no-ops. + * @returns Promise true if registered successfully + */ + static async register(): Promise { + if (this.isRegistered) { + log.debug('LlamaCPP already registered, returning'); + return true; + } + + if (!isNativeLlamaModuleAvailable()) { + log.warning('LlamaCPP native module not available'); + return false; + } + + log.debug('Registering LlamaCPP backend with C++ registry'); + + try { + const native = requireNativeLlamaModule(); + // Call the native registration method from the Llama module + const success = await native.registerBackend(); + if (success) { + this.isRegistered = true; + log.info('LlamaCPP backend registered successfully'); + } + return success; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + log.warning(`LlamaCPP registration failed: ${msg}`); + return false; + } + } + + /** + * Unregister the LlamaCPP backend from C++ registry. + * @returns Promise true if unregistered successfully + */ + static async unregister(): Promise { + if (!this.isRegistered) { + return true; + } + + if (!isNativeLlamaModuleAvailable()) { + return false; + } + + try { + const native = requireNativeLlamaModule(); + const success = await native.unregisterBackend(); + if (success) { + this.isRegistered = false; + log.debug('LlamaCPP backend unregistered'); + } + return success; + } catch (error) { + log.error(`LlamaCPP unregistration failed: ${error instanceof Error ? error.message : String(error)}`); + return false; + } + } + + /** + * Check if LlamaCPP can handle a given model + */ + static canHandle(modelId: string | null | undefined): boolean { + if (!modelId) { + return false; + } + const lowercased = modelId.toLowerCase(); + return lowercased.includes('gguf') || lowercased.endsWith('.gguf'); + } +} + +/** + * Auto-register when module is imported + */ +export function autoRegister(): void { + LlamaCppProvider.register().catch(() => { + // Silently handle registration failure during auto-registration + }); +} diff --git a/sdk/runanywhere-react-native/packages/llamacpp/src/index.ts b/sdk/runanywhere-react-native/packages/llamacpp/src/index.ts new file mode 100644 index 000000000..dc7467026 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/llamacpp/src/index.ts @@ -0,0 +1,59 @@ +/** + * @runanywhere/llamacpp - LlamaCPP Backend for RunAnywhere React Native SDK + * + * This package provides the LlamaCPP backend for on-device LLM inference. + * It supports GGUF models and provides the same API as the iOS SDK. + * + * ## Usage + * + * ```typescript + * import { RunAnywhere } from '@runanywhere/core'; + * import { LlamaCPP, LlamaCppProvider } from '@runanywhere/llamacpp'; + * + * // Initialize core SDK + * await RunAnywhere.initialize({ apiKey: 'your-key' }); + * + * // Register LlamaCPP backend (calls native rac_backend_llamacpp_register) + * await LlamaCppProvider.register(); + * + * // Add a model + * LlamaCPP.addModel({ + * id: 'smollm2-360m-q8_0', + * name: 'SmolLM2 360M Q8_0', + * url: 'https://huggingface.co/.../SmolLM2-360M.Q8_0.gguf', + * memoryRequirement: 500_000_000 + * }); + * + * // Download and use + * await RunAnywhere.downloadModel('smollm2-360m-q8_0'); + * await RunAnywhere.loadModel('smollm2-360m-q8_0'); + * const result = await RunAnywhere.generate('Hello, world!'); + * ``` + * + * @packageDocumentation + */ + +// ============================================================================= +// Main API +// ============================================================================= + +export { LlamaCPP, type LlamaCPPModelOptions } from './LlamaCPP'; +export { LlamaCppProvider, autoRegister } from './LlamaCppProvider'; + +// ============================================================================= +// Native Module +// ============================================================================= + +export { + NativeRunAnywhereLlama, + getNativeLlamaModule, + requireNativeLlamaModule, + isNativeLlamaModuleAvailable, +} from './native/NativeRunAnywhereLlama'; +export type { NativeRunAnywhereLlamaModule } from './native/NativeRunAnywhereLlama'; + +// ============================================================================= +// Nitrogen Spec Types +// ============================================================================= + +export type { RunAnywhereLlama } from './specs/RunAnywhereLlama.nitro'; diff --git a/sdk/runanywhere-react-native/packages/llamacpp/src/native/NativeRunAnywhereLlama.ts b/sdk/runanywhere-react-native/packages/llamacpp/src/native/NativeRunAnywhereLlama.ts new file mode 100644 index 000000000..ca927a33e --- /dev/null +++ b/sdk/runanywhere-react-native/packages/llamacpp/src/native/NativeRunAnywhereLlama.ts @@ -0,0 +1,58 @@ +/** + * NativeRunAnywhereLlama.ts + * + * Exports the native RunAnywhereLlama Hybrid Object from Nitro Modules. + * This module provides Llama-based text generation capabilities. + */ + +import { NitroModules } from 'react-native-nitro-modules'; +import type { RunAnywhereLlama } from '../specs/RunAnywhereLlama.nitro'; + +/** + * The native RunAnywhereLlama module type + */ +export type NativeRunAnywhereLlamaModule = RunAnywhereLlama; + +/** + * Get the native RunAnywhereLlama Hybrid Object + */ +export function requireNativeLlamaModule(): NativeRunAnywhereLlamaModule { + return NitroModules.createHybridObject('RunAnywhereLlama'); +} + +/** + * Check if the native Llama module is available + */ +export function isNativeLlamaModuleAvailable(): boolean { + try { + requireNativeLlamaModule(); + return true; + } catch { + return false; + } +} + +/** + * Singleton instance of the native module (lazy initialized) + */ +let _nativeModule: NativeRunAnywhereLlamaModule | undefined; + +/** + * Get the singleton native module instance + */ +export function getNativeLlamaModule(): NativeRunAnywhereLlamaModule { + if (!_nativeModule) { + _nativeModule = requireNativeLlamaModule(); + } + return _nativeModule; +} + +/** + * Default export - the native module getter + */ +export const NativeRunAnywhereLlama = { + get: getNativeLlamaModule, + isAvailable: isNativeLlamaModuleAvailable, +}; + +export default NativeRunAnywhereLlama; diff --git a/sdk/runanywhere-react-native/packages/llamacpp/src/native/index.ts b/sdk/runanywhere-react-native/packages/llamacpp/src/native/index.ts new file mode 100644 index 000000000..62d08f704 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/llamacpp/src/native/index.ts @@ -0,0 +1,11 @@ +/** + * Native module exports for @runanywhere/llamacpp + */ + +export { + NativeRunAnywhereLlama, + getNativeLlamaModule, + requireNativeLlamaModule, + isNativeLlamaModuleAvailable, +} from './NativeRunAnywhereLlama'; +export type { NativeRunAnywhereLlamaModule } from './NativeRunAnywhereLlama'; diff --git a/sdk/runanywhere-react-native/packages/llamacpp/src/specs/RunAnywhereLlama.nitro.ts b/sdk/runanywhere-react-native/packages/llamacpp/src/specs/RunAnywhereLlama.nitro.ts new file mode 100644 index 000000000..3ac612c61 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/llamacpp/src/specs/RunAnywhereLlama.nitro.ts @@ -0,0 +1,160 @@ +/** + * RunAnywhereLlama Nitrogen Spec + * + * LlamaCPP backend interface for Llama-based text generation: + * - Backend Registration + * - Model Loading/Unloading + * - Text Generation (non-streaming and streaming) + * - Structured Output (JSON schema generation) + * + * Matches Swift SDK: LlamaCPPRuntime/LlamaCPP.swift + CppBridge+LLM.swift + */ +import type { HybridObject } from 'react-native-nitro-modules'; + +/** + * Llama text generation native interface + * + * This interface provides Llama-based LLM capabilities. + * Requires @runanywhere/core to be initialized first. + */ +export interface RunAnywhereLlama + extends HybridObject<{ + ios: 'c++'; + android: 'c++'; + }> { + // ============================================================================ + // Backend Registration + // Matches Swift: LlamaCPP.register(), LlamaCPP.unregister() + // ============================================================================ + + /** + * Register the LlamaCPP backend with the C++ service registry. + * Calls rac_backend_llamacpp_register() from runanywhere-binaries. + * Safe to call multiple times - subsequent calls are no-ops. + * @returns true if registered successfully (or already registered) + */ + registerBackend(): Promise; + + /** + * Unregister the LlamaCPP backend from the C++ service registry. + * @returns true if unregistered successfully + */ + unregisterBackend(): Promise; + + /** + * Check if the LlamaCPP backend is registered + * @returns true if backend is registered + */ + isBackendRegistered(): Promise; + + // ============================================================================ + // Model Loading + // Matches Swift: CppBridge+LLM.swift loadTextModel/unloadTextModel + // ============================================================================ + + /** + * Load a Llama model for text generation + * @param path Path to the model file (.gguf) + * @param modelId Optional unique identifier for the model + * @param modelName Optional human-readable name for the model + * @param configJson Optional JSON configuration (context_length, gpu_layers, etc.) + * @returns true if loaded successfully + */ + loadModel( + path: string, + modelId?: string, + modelName?: string, + configJson?: string + ): Promise; + + /** + * Check if a Llama model is loaded + */ + isModelLoaded(): Promise; + + /** + * Unload the current Llama model + */ + unloadModel(): Promise; + + /** + * Get info about the currently loaded model + * @returns JSON with model info or empty if not loaded + */ + getModelInfo(): Promise; + + // ============================================================================ + // Text Generation + // Matches Swift: RunAnywhere+TextGeneration.swift + // ============================================================================ + + /** + * Generate text (non-streaming) + * @param prompt The prompt text + * @param optionsJson JSON string with generation options: + * - max_tokens: Maximum tokens to generate (default: 512) + * - temperature: Sampling temperature (default: 0.7) + * - top_p: Nucleus sampling parameter (default: 0.9) + * - top_k: Top-k sampling parameter (default: 40) + * - system_prompt: Optional system prompt + * @returns JSON string with generation result: + * - text: Generated text + * - tokensUsed: Number of tokens generated + * - latencyMs: Generation time in milliseconds + * - cancelled: Whether generation was cancelled + */ + generate(prompt: string, optionsJson?: string): Promise; + + /** + * Generate text with streaming callback + * @param prompt The prompt text + * @param optionsJson JSON string with generation options + * @param callback Called for each token with (token, isComplete) + * @returns Complete generated text + */ + generateStream( + prompt: string, + optionsJson: string, + callback: (token: string, isComplete: boolean) => void + ): Promise; + + /** + * Cancel ongoing text generation + * @returns true if cancellation was successful + */ + cancelGeneration(): Promise; + + // ============================================================================ + // Structured Output + // Matches Swift: RunAnywhere+StructuredOutput.swift + // ============================================================================ + + /** + * Generate structured output following a JSON schema + * Uses constrained generation to ensure output conforms to schema + * @param prompt The prompt text + * @param schema JSON schema string defining the output structure + * @param optionsJson Optional generation options + * @returns JSON string conforming to the provided schema + */ + generateStructured( + prompt: string, + schema: string, + optionsJson?: string + ): Promise; + + // ============================================================================ + // Utilities + // ============================================================================ + + /** + * Get the last error message from the Llama backend + */ + getLastError(): Promise; + + /** + * Get current memory usage of the Llama backend + * @returns Memory usage in bytes + */ + getMemoryUsage(): Promise; +} diff --git a/sdk/runanywhere-react-native/packages/llamacpp/tsconfig.json b/sdk/runanywhere-react-native/packages/llamacpp/tsconfig.json new file mode 100644 index 000000000..6e8d0e9d0 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/llamacpp/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib", + "composite": true, + "declaration": true, + "declarationMap": true, + "baseUrl": ".", + "paths": { + "@runanywhere/llamacpp": ["./src"], + "@runanywhere/llamacpp/*": ["./src/*"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "lib"], + "references": [ + { "path": "../core" } + ] +} diff --git a/sdk/runanywhere-react-native/packages/onnx/.npmignore b/sdk/runanywhere-react-native/packages/onnx/.npmignore new file mode 100644 index 000000000..f7bb38dd6 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/onnx/.npmignore @@ -0,0 +1,16 @@ +# Test local flags - should not be published +**/.testlocal +*.testlocal +.testlocal + +# Build artifacts +*.log +.cxx/ +build/ + +# IDE +.idea/ +*.iml + +# macOS +.DS_Store diff --git a/sdk/runanywhere-react-native/packages/onnx/README.md b/sdk/runanywhere-react-native/packages/onnx/README.md new file mode 100644 index 000000000..ad6b9622a --- /dev/null +++ b/sdk/runanywhere-react-native/packages/onnx/README.md @@ -0,0 +1,731 @@ +# @runanywhere/onnx + +ONNX Runtime backend for the RunAnywhere React Native SDK. Provides on-device Speech-to-Text (STT), Text-to-Speech (TTS), and Voice Activity Detection (VAD) powered by Sherpa-ONNX. + +--- + +## Overview + +`@runanywhere/onnx` provides the ONNX Runtime backend for on-device voice AI capabilities: + +### Speech-to-Text (STT) +- **Whisper Models** — Multi-language speech recognition +- **Batch Transcription** — Transcribe audio files +- **Real-time Streaming** — Live transcription support +- **Word Timestamps** — Precise timing information +- **Confidence Scores** — Per-segment reliability metrics + +### Text-to-Speech (TTS) +- **Piper TTS** — Natural neural voice synthesis +- **Multiple Voices** — Various languages and accents +- **Customizable** — Speed, pitch, and volume control +- **Streaming Output** — Chunked audio generation + +### Voice Activity Detection (VAD) +- **Silero VAD** — High-accuracy speech detection +- **Real-time Processing** — Low-latency audio analysis +- **Configurable Sensitivity** — Adjustable thresholds + +--- + +## Requirements + +- `@runanywhere/core` (peer dependency) +- React Native 0.71+ +- iOS 15.1+ / Android API 24+ +- Microphone permission (for live recording) + +--- + +## Installation + +```bash +npm install @runanywhere/core @runanywhere/onnx +# or +yarn add @runanywhere/core @runanywhere/onnx +``` + +### iOS Setup + +```bash +cd ios && pod install && cd .. +``` + +Add microphone permission to `Info.plist`: + +```xml +NSMicrophoneUsageDescription +Required for speech recognition +``` + +### Android Setup + +Add microphone permission to `AndroidManifest.xml`: + +```xml + +``` + +--- + +## Quick Start + +```typescript +import { RunAnywhere, SDKEnvironment, ModelCategory } from '@runanywhere/core'; +import { ONNX, ModelArtifactType } from '@runanywhere/onnx'; + +// 1. Initialize SDK +await RunAnywhere.initialize({ + environment: SDKEnvironment.Development, +}); + +// 2. Register ONNX backend +ONNX.register(); + +// 3. Add STT model (Whisper) +await ONNX.addModel({ + id: 'sherpa-onnx-whisper-tiny.en', + name: 'Sherpa Whisper Tiny (English)', + url: 'https://github.com/RunanywhereAI/sherpa-onnx/releases/download/runanywhere-models-v1/sherpa-onnx-whisper-tiny.en.tar.gz', + modality: ModelCategory.SpeechRecognition, + artifactType: ModelArtifactType.TarGzArchive, + memoryRequirement: 75_000_000, +}); + +// 4. Add TTS model (Piper) +await ONNX.addModel({ + id: 'vits-piper-en_US-lessac-medium', + name: 'Piper TTS (US English)', + url: 'https://github.com/RunanywhereAI/sherpa-onnx/releases/download/runanywhere-models-v1/vits-piper-en_US-lessac-medium.tar.gz', + modality: ModelCategory.SpeechSynthesis, + artifactType: ModelArtifactType.TarGzArchive, + memoryRequirement: 65_000_000, +}); + +// 5. Download models +await RunAnywhere.downloadModel('sherpa-onnx-whisper-tiny.en'); +await RunAnywhere.downloadModel('vits-piper-en_US-lessac-medium'); + +// 6. Load STT model +const sttModel = await RunAnywhere.getModelInfo('sherpa-onnx-whisper-tiny.en'); +await RunAnywhere.loadSTTModel(sttModel.localPath, 'whisper'); + +// 7. Transcribe audio +const result = await RunAnywhere.transcribeFile(audioFilePath, { + language: 'en', +}); +console.log('Transcription:', result.text); + +// 8. Load TTS model +const ttsModel = await RunAnywhere.getModelInfo('vits-piper-en_US-lessac-medium'); +await RunAnywhere.loadTTSModel(ttsModel.localPath, 'piper'); + +// 9. Synthesize speech +const audio = await RunAnywhere.synthesize('Hello world.', { + rate: 1.0, + pitch: 1.0, +}); +console.log('Audio duration:', audio.duration, 'seconds'); +``` + +--- + +## API Reference + +### ONNX Module + +```typescript +import { ONNX, ModelArtifactType } from '@runanywhere/onnx'; +``` + +#### `ONNX.register()` + +Register the ONNX backend with the SDK. Must be called before using STT/TTS features. + +```typescript +ONNX.register(): void +``` + +**Example:** + +```typescript +await RunAnywhere.initialize({ ... }); +ONNX.register(); // Now STT/TTS features are available +``` + +--- + +#### `ONNX.addModel(options)` + +Add an ONNX model (STT or TTS) to the model registry. + +```typescript +await ONNX.addModel(options: ONNXModelOptions): Promise +``` + +**Parameters:** + +```typescript +interface ONNXModelOptions { + /** + * Unique model ID. + * If not provided, generated from the URL filename. + */ + id?: string; + + /** Display name for the model */ + name: string; + + /** Download URL for the model */ + url: string; + + /** + * Model category. + * Required: ModelCategory.SpeechRecognition or ModelCategory.SpeechSynthesis + */ + modality: ModelCategory; + + /** + * How the model is packaged. + * If not provided, inferred from URL extension. + */ + artifactType?: ModelArtifactType; + + /** Memory requirement in bytes */ + memoryRequirement?: number; +} + +enum ModelArtifactType { + SingleFile = 'singleFile', // Single .onnx file + TarGzArchive = 'tarGzArchive', // .tar.gz archive + TarBz2Archive = 'tarBz2Archive', // .tar.bz2 archive + ZipArchive = 'zipArchive', // .zip archive +} +``` + +**Returns:** `Promise` — The registered model info + +**Example:** + +```typescript +// STT Model (Whisper) +await ONNX.addModel({ + id: 'sherpa-onnx-whisper-tiny.en', + name: 'Sherpa Whisper Tiny (English)', + url: 'https://github.com/RunanywhereAI/sherpa-onnx/releases/.../sherpa-onnx-whisper-tiny.en.tar.gz', + modality: ModelCategory.SpeechRecognition, + artifactType: ModelArtifactType.TarGzArchive, + memoryRequirement: 75_000_000, +}); + +// Larger STT Model +await ONNX.addModel({ + id: 'sherpa-onnx-whisper-small.en', + name: 'Sherpa Whisper Small (English)', + url: 'https://github.com/k2-fsa/sherpa-onnx/releases/.../sherpa-onnx-whisper-small.en.tar.bz2', + modality: ModelCategory.SpeechRecognition, + artifactType: ModelArtifactType.TarBz2Archive, + memoryRequirement: 250_000_000, +}); + +// TTS Model (Piper) +await ONNX.addModel({ + id: 'vits-piper-en_US-lessac-medium', + name: 'Piper TTS (US English - Lessac)', + url: 'https://github.com/RunanywhereAI/sherpa-onnx/releases/.../vits-piper-en_US-lessac-medium.tar.gz', + modality: ModelCategory.SpeechSynthesis, + artifactType: ModelArtifactType.TarGzArchive, + memoryRequirement: 65_000_000, +}); +``` + +--- + +#### Module Properties + +```typescript +ONNX.moduleId // 'onnx' +ONNX.moduleName // 'ONNX Runtime' +ONNX.inferenceFramework // LLMFramework.ONNX +ONNX.capabilities // ['stt', 'tts'] +ONNX.defaultPriority // 100 +``` + +--- + +### Speech-to-Text API + +Use the `RunAnywhere` API for STT operations: + +#### Load STT Model + +```typescript +await RunAnywhere.loadSTTModel( + modelPath: string, + modelType?: string // 'whisper' (default) +): Promise +``` + +#### Check Model Status + +```typescript +const isLoaded = await RunAnywhere.isSTTModelLoaded(): Promise +``` + +#### Unload Model + +```typescript +await RunAnywhere.unloadSTTModel(): Promise +``` + +#### Transcribe Audio File + +```typescript +const result = await RunAnywhere.transcribeFile( + audioPath: string, + options?: STTOptions +): Promise +``` + +**STT Options:** + +```typescript +interface STTOptions { + language?: string; // e.g., 'en', 'es', 'fr' + punctuation?: boolean; // Enable punctuation + diarization?: boolean; // Enable speaker diarization + wordTimestamps?: boolean; // Enable word-level timestamps + sampleRate?: number; // Audio sample rate +} +``` + +**STT Result:** + +```typescript +interface STTResult { + text: string; // Full transcription + segments: STTSegment[]; // Segments with timing + language?: string; // Detected language + confidence: number; // Overall confidence (0-1) + duration: number; // Audio duration in seconds + alternatives: STTAlternative[]; +} + +interface STTSegment { + text: string; + startTime: number; // seconds + endTime: number; // seconds + confidence: number; +} +``` + +#### Transcribe Raw Audio + +```typescript +// From base64-encoded audio +const result = await RunAnywhere.transcribe( + audioData: string, // base64 float32 PCM + options?: STTOptions +): Promise + +// From float32 samples +const result = await RunAnywhere.transcribeBuffer( + samples: number[], + sampleRate: number, + options?: STTOptions +): Promise +``` + +--- + +### Text-to-Speech API + +Use the `RunAnywhere` API for TTS operations: + +#### Load TTS Model + +```typescript +await RunAnywhere.loadTTSModel( + modelPath: string, + modelType?: string // 'piper' (default) +): Promise +``` + +#### Check Model Status + +```typescript +const isLoaded = await RunAnywhere.isTTSModelLoaded(): Promise +``` + +#### Unload Model + +```typescript +await RunAnywhere.unloadTTSModel(): Promise +``` + +#### Synthesize Speech + +```typescript +const result = await RunAnywhere.synthesize( + text: string, + options?: TTSConfiguration +): Promise +``` + +**TTS Configuration:** + +```typescript +interface TTSConfiguration { + voice?: string; // Voice identifier + rate?: number; // Speed (0.5-2.0, default: 1.0) + pitch?: number; // Pitch (0.5-2.0, default: 1.0) + volume?: number; // Volume (0.0-1.0, default: 1.0) +} +``` + +**TTS Result:** + +```typescript +interface TTSResult { + audio: string; // Base64-encoded float32 PCM + sampleRate: number; // Audio sample rate (typically 22050) + numSamples: number; // Total sample count + duration: number; // Duration in seconds +} +``` + +#### Streaming Synthesis + +```typescript +await RunAnywhere.synthesizeStream( + text: string, + options?: TTSConfiguration, + onChunk?: (chunk: TTSOutput) => void +): Promise +``` + +#### System TTS (Platform Native) + +```typescript +// Speak using AVSpeechSynthesizer (iOS) or Android TTS +await RunAnywhere.speak(text: string, options?: TTSConfiguration): Promise + +// Control playback +const isSpeaking = await RunAnywhere.isSpeaking(): Promise +await RunAnywhere.stopSpeaking(): Promise + +// List available voices +const voices = await RunAnywhere.availableTTSVoices(): Promise +``` + +--- + +### Voice Activity Detection API + +#### Initialize VAD + +```typescript +await RunAnywhere.initializeVAD(config?: VADConfiguration): Promise +``` + +**VAD Configuration:** + +```typescript +interface VADConfiguration { + energyThreshold?: number; // Speech detection threshold + sampleRate?: number; // Audio sample rate + frameLength?: number; // Frame length in ms + autoCalibration?: boolean; // Enable auto-calibration +} +``` + +#### Load VAD Model + +```typescript +await RunAnywhere.loadVADModel(modelPath: string): Promise +``` + +#### Process Audio + +```typescript +const result = await RunAnywhere.processVAD( + audioSamples: number[] +): Promise +``` + +**VAD Result:** + +```typescript +interface VADResult { + isSpeech: boolean; // Whether speech is detected + confidence: number; // Confidence score (0-1) + startTime?: number; // Speech segment start + endTime?: number; // Speech segment end +} +``` + +#### Continuous VAD + +```typescript +// Start/stop continuous processing +await RunAnywhere.startVAD(): Promise +await RunAnywhere.stopVAD(): Promise + +// Set callbacks +RunAnywhere.setVADSpeechActivityCallback((event) => { + if (event.type === 'speechStarted') { + console.log('Speech started'); + } else if (event.type === 'speechEnded') { + console.log('Speech ended'); + } +}); +``` + +--- + +## Supported Models + +### Speech-to-Text (Whisper) + +| Model | Size | Memory | Languages | Description | +|-------|------|--------|-----------|-------------| +| whisper-tiny.en | ~75MB | 100MB | English | Fastest, English-only | +| whisper-base.en | ~150MB | 200MB | English | Better accuracy | +| whisper-small.en | ~250MB | 350MB | English | High quality | +| whisper-tiny | ~75MB | 100MB | 99+ | Multilingual | + +### Text-to-Speech (Piper) + +| Voice | Size | Language | Description | +|-------|------|----------|-------------| +| en_US-lessac-medium | ~65MB | English (US) | Natural, clear | +| en_US-amy-medium | ~65MB | English (US) | Female voice | +| en_GB-alba-medium | ~65MB | English (UK) | British accent | +| de_DE-thorsten-medium | ~65MB | German | German voice | +| es_ES-mls-medium | ~65MB | Spanish | Spanish voice | +| fr_FR-siwis-medium | ~65MB | French | French voice | + +### Voice Activity Detection + +| Model | Size | Description | +|-------|------|-------------| +| silero-vad | ~2MB | High accuracy, real-time | + +--- + +## Usage Examples + +### Complete STT Example + +```typescript +import { RunAnywhere, SDKEnvironment, ModelCategory } from '@runanywhere/core'; +import { ONNX, ModelArtifactType } from '@runanywhere/onnx'; + +async function transcribeAudio(audioPath: string): Promise { + // Initialize + await RunAnywhere.initialize({ environment: SDKEnvironment.Development }); + ONNX.register(); + + // Add model + await ONNX.addModel({ + id: 'whisper-tiny-en', + name: 'Whisper Tiny English', + url: 'https://github.com/RunanywhereAI/sherpa-onnx/releases/.../sherpa-onnx-whisper-tiny.en.tar.gz', + modality: ModelCategory.SpeechRecognition, + artifactType: ModelArtifactType.TarGzArchive, + }); + + // Download if needed + if (!(await RunAnywhere.isModelDownloaded('whisper-tiny-en'))) { + await RunAnywhere.downloadModel('whisper-tiny-en', (p) => { + console.log(`Download: ${(p.progress * 100).toFixed(1)}%`); + }); + } + + // Load and transcribe + const model = await RunAnywhere.getModelInfo('whisper-tiny-en'); + await RunAnywhere.loadSTTModel(model.localPath, 'whisper'); + + const result = await RunAnywhere.transcribeFile(audioPath, { + language: 'en', + wordTimestamps: true, + }); + + console.log('Transcription:', result.text); + console.log('Confidence:', result.confidence); + console.log('Duration:', result.duration, 'seconds'); + + return result.text; +} +``` + +### Complete TTS Example + +```typescript +async function synthesizeSpeech(text: string): Promise { + // Initialize + await RunAnywhere.initialize({ environment: SDKEnvironment.Development }); + ONNX.register(); + + // Add model + await ONNX.addModel({ + id: 'piper-lessac', + name: 'Piper Lessac Voice', + url: 'https://github.com/RunanywhereAI/sherpa-onnx/releases/.../vits-piper-en_US-lessac-medium.tar.gz', + modality: ModelCategory.SpeechSynthesis, + artifactType: ModelArtifactType.TarGzArchive, + }); + + // Download if needed + if (!(await RunAnywhere.isModelDownloaded('piper-lessac'))) { + await RunAnywhere.downloadModel('piper-lessac'); + } + + // Load and synthesize + const model = await RunAnywhere.getModelInfo('piper-lessac'); + await RunAnywhere.loadTTSModel(model.localPath, 'piper'); + + const result = await RunAnywhere.synthesize(text, { + rate: 1.0, + pitch: 1.0, + volume: 0.8, + }); + + console.log('Duration:', result.duration, 'seconds'); + console.log('Sample rate:', result.sampleRate); + + // result.audio is base64-encoded float32 PCM + return result.audio; +} +``` + +### Voice Pipeline (STT → TTS) + +```typescript +async function voiceEcho(audioPath: string): Promise { + // Transcribe input audio + const transcription = await RunAnywhere.transcribeFile(audioPath); + console.log('You said:', transcription.text); + + // Synthesize echo + const audio = await RunAnywhere.synthesize( + `You said: ${transcription.text}` + ); + + return audio.audio; +} +``` + +--- + +## Native Integration + +### iOS + +This package uses `RABackendONNX.xcframework` which includes: +- ONNX Runtime compiled for iOS +- Sherpa-ONNX (Whisper, Piper, Silero VAD) +- Optimized for Apple Silicon + +Dependencies: +- `onnxruntime.xcframework` — ONNX Runtime core + +### Android + +Native libraries include: +- `librunanywhere_onnx.so` — ONNX backend +- `libonnxruntime.so` — ONNX Runtime +- `libsherpa-onnx-*.so` — Sherpa-ONNX libraries + +--- + +## Package Structure + +``` +packages/onnx/ +├── src/ +│ ├── index.ts # Package exports +│ ├── ONNX.ts # Module API (register, addModel) +│ ├── ONNXProvider.ts # Service provider +│ ├── native/ +│ │ └── NativeRunAnywhereONNX.ts +│ └── specs/ +│ └── RunAnywhereONNX.nitro.ts +├── cpp/ +│ ├── HybridRunAnywhereONNX.cpp +│ ├── HybridRunAnywhereONNX.hpp +│ └── bridges/ +├── ios/ +│ ├── RunAnywhereONNX.podspec +│ └── Frameworks/ +│ ├── RABackendONNX.xcframework +│ └── onnxruntime.xcframework +├── android/ +│ ├── build.gradle +│ └── src/main/jniLibs/ +│ └── arm64-v8a/ +│ ├── librunanywhere_onnx.so +│ ├── libonnxruntime.so +│ └── libsherpa-onnx-*.so +└── nitrogen/ + └── generated/ +``` + +--- + +## Troubleshooting + +### STT model fails to load + +**Symptoms:** `modelLoadFailed` error when loading STT model + +**Solutions:** +1. Verify the model directory contains all required files +2. Check that archive extraction completed successfully +3. Ensure the correct model type is specified ('whisper') + +### Poor transcription quality + +**Symptoms:** Transcription has many errors + +**Solutions:** +1. Use a larger model (small instead of tiny) +2. Ensure audio is clear with minimal background noise +3. Check audio sample rate matches model expectations +4. Try specifying the language explicitly + +### TTS audio is silent or distorted + +**Symptoms:** No sound or garbled audio + +**Solutions:** +1. Verify audio data is being decoded correctly +2. Check sample rate matches playback device +3. Ensure volume is not zero +4. Try a different TTS voice + +### Permission denied (microphone) + +**Symptoms:** Audio recording fails + +**Solutions:** +1. Add microphone permission to Info.plist (iOS) +2. Add RECORD_AUDIO permission to AndroidManifest.xml +3. Request runtime permission before recording + +--- + +## See Also + +- [Main SDK README](../../README.md) — Full SDK documentation +- [API Reference](../../Docs/Documentation.md) — Complete API docs +- [@runanywhere/core](../core/README.md) — Core SDK +- [@runanywhere/llamacpp](../llamacpp/README.md) — LLM backend +- [Sherpa-ONNX](https://github.com/k2-fsa/sherpa-onnx) — Underlying engine +- [ONNX Runtime](https://onnxruntime.ai/) — ONNX inference engine + +--- + +## License + +MIT License diff --git a/sdk/runanywhere-react-native/packages/onnx/RunAnywhereONNX.podspec b/sdk/runanywhere-react-native/packages/onnx/RunAnywhereONNX.podspec new file mode 100644 index 000000000..2aa7e4a2f --- /dev/null +++ b/sdk/runanywhere-react-native/packages/onnx/RunAnywhereONNX.podspec @@ -0,0 +1,62 @@ +require "json" + +package = JSON.parse(File.read(File.join(__dir__, "package.json"))) + +Pod::Spec.new do |s| + s.name = "RunAnywhereONNX" + s.version = package["version"] + s.summary = package["description"] + s.homepage = "https://runanywhere.com" + s.license = package["license"] + s.authors = "RunAnywhere AI" + + s.platforms = { :ios => "15.1" } + s.source = { :git => "https://github.com/RunanywhereAI/sdks.git", :tag => "#{s.version}" } + + # ============================================================================= + # ONNX Backend - xcframeworks are bundled in npm package + # No downloads needed - frameworks are included in ios/Frameworks/ + # ============================================================================= + puts "[RunAnywhereONNX] Using bundled xcframeworks from npm package" + s.vendored_frameworks = [ + "ios/Frameworks/RABackendONNX.xcframework", + "ios/Frameworks/onnxruntime.xcframework" + ] + + # Source files + s.source_files = [ + "cpp/HybridRunAnywhereONNX.cpp", + "cpp/HybridRunAnywhereONNX.hpp", + "cpp/bridges/**/*.{cpp,hpp}", + ] + + s.pod_target_xcconfig = { + "CLANG_CXX_LANGUAGE_STANDARD" => "c++17", + "HEADER_SEARCH_PATHS" => [ + "$(PODS_TARGET_SRCROOT)/cpp", + "$(PODS_TARGET_SRCROOT)/cpp/bridges", + "$(PODS_TARGET_SRCROOT)/ios/Frameworks/RABackendONNX.xcframework/ios-arm64/RABackendONNX.framework/Headers", + "$(PODS_TARGET_SRCROOT)/ios/Frameworks/RABackendONNX.xcframework/ios-arm64_x86_64-simulator/RABackendONNX.framework/Headers", + "$(PODS_TARGET_SRCROOT)/ios/Frameworks/onnxruntime.xcframework/ios-arm64/onnxruntime.framework/Headers", + "$(PODS_TARGET_SRCROOT)/ios/Frameworks/onnxruntime.xcframework/ios-arm64_x86_64-simulator/onnxruntime.framework/Headers", + "$(PODS_TARGET_SRCROOT)/../core/ios/Binaries/RACommons.xcframework/ios-arm64/RACommons.framework/Headers", + "$(PODS_TARGET_SRCROOT)/../core/ios/Binaries/RACommons.xcframework/ios-arm64_x86_64-simulator/RACommons.framework/Headers", + "$(PODS_ROOT)/Headers/Public", + ].join(" "), + "GCC_PREPROCESSOR_DEFINITIONS" => "$(inherited) HAS_ONNX=1", + "DEFINES_MODULE" => "YES", + "SWIFT_OBJC_INTEROP_MODE" => "objcxx", + } + + s.libraries = "c++" + s.frameworks = "Accelerate", "Foundation", "CoreML", "AudioToolbox" + + s.dependency 'RunAnywhereCore' + s.dependency 'React-jsi' + s.dependency 'React-callinvoker' + + load 'nitrogen/generated/ios/RunAnywhereONNX+autolinking.rb' + add_nitrogen_files(s) + + install_modules_dependencies(s) +end diff --git a/sdk/runanywhere-react-native/packages/onnx/android/CMakeLists.txt b/sdk/runanywhere-react-native/packages/onnx/android/CMakeLists.txt new file mode 100644 index 000000000..e7ee255c4 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/onnx/android/CMakeLists.txt @@ -0,0 +1,183 @@ +project(runanywhereonnx) +cmake_minimum_required(VERSION 3.9.0) + +set(PACKAGE_NAME runanywhereonnx) +set(CMAKE_VERBOSE_MAKEFILE ON) +set(CMAKE_CXX_STANDARD 20) + +# ============================================================================= +# 16KB Page Alignment for Android 15+ (API 35) Compliance +# Required starting November 1, 2025 for Google Play submissions +# ============================================================================= +set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -Wl,-z,max-page-size=16384") + +# Path to pre-built native libraries (downloaded from runanywhere-binaries) +set(JNILIB_DIR ${CMAKE_SOURCE_DIR}/src/main/jniLibs/${ANDROID_ABI}) + +# ============================================================================= +# RABackendONNX - STT/TTS/VAD backend (REQUIRED) +# Downloaded via Gradle downloadNativeLibs task +# ============================================================================= +if(NOT EXISTS "${JNILIB_DIR}/librac_backend_onnx.so") + message(FATAL_ERROR "[RunAnywhereONNX] RABackendONNX not found at ${JNILIB_DIR}/librac_backend_onnx.so\n" + "Run: ./gradlew :runanywhere_onnx:downloadNativeLibs") +endif() + +add_library(rac_backend_onnx SHARED IMPORTED) +set_target_properties(rac_backend_onnx PROPERTIES + IMPORTED_LOCATION "${JNILIB_DIR}/librac_backend_onnx.so" + IMPORTED_NO_SONAME TRUE +) +message(STATUS "[RunAnywhereONNX] Found RABackendONNX at ${JNILIB_DIR}/librac_backend_onnx.so") + +# ============================================================================= +# ONNX Runtime (REQUIRED) +# ============================================================================= +if(NOT EXISTS "${JNILIB_DIR}/libonnxruntime.so") + message(FATAL_ERROR "[RunAnywhereONNX] ONNX Runtime not found at ${JNILIB_DIR}/libonnxruntime.so\n" + "Run: ./gradlew :runanywhere_onnx:downloadNativeLibs") +endif() + +add_library(onnxruntime SHARED IMPORTED) +set_target_properties(onnxruntime PROPERTIES + IMPORTED_LOCATION "${JNILIB_DIR}/libonnxruntime.so" + IMPORTED_NO_SONAME TRUE +) +message(STATUS "[RunAnywhereONNX] Found ONNX Runtime at ${JNILIB_DIR}/libonnxruntime.so") + +# ============================================================================= +# Sherpa ONNX libraries (optional but typically present) +# ============================================================================= +if(EXISTS "${JNILIB_DIR}/libsherpa-onnx-c-api.so") + add_library(sherpa_onnx_c_api SHARED IMPORTED) + set_target_properties(sherpa_onnx_c_api PROPERTIES + IMPORTED_LOCATION "${JNILIB_DIR}/libsherpa-onnx-c-api.so" + IMPORTED_NO_SONAME TRUE + ) +endif() +if(EXISTS "${JNILIB_DIR}/libsherpa-onnx-cxx-api.so") + add_library(sherpa_onnx_cxx_api SHARED IMPORTED) + set_target_properties(sherpa_onnx_cxx_api PROPERTIES + IMPORTED_LOCATION "${JNILIB_DIR}/libsherpa-onnx-cxx-api.so" + IMPORTED_NO_SONAME TRUE + ) +endif() +if(EXISTS "${JNILIB_DIR}/libsherpa-onnx-jni.so") + add_library(sherpa_onnx_jni SHARED IMPORTED) + set_target_properties(sherpa_onnx_jni PROPERTIES + IMPORTED_LOCATION "${JNILIB_DIR}/libsherpa-onnx-jni.so" + IMPORTED_NO_SONAME TRUE + ) +endif() + +# ============================================================================= +# Source files - ONNX bridges +# ============================================================================= +file(GLOB BRIDGE_SOURCES "../cpp/bridges/*.cpp") + +add_library(${PACKAGE_NAME} SHARED + src/main/cpp/cpp-adapter.cpp + ../cpp/HybridRunAnywhereONNX.cpp + ${BRIDGE_SOURCES} +) + +# ============================================================================= +# Fix NitroModules prefab path for library modules +# The prefab config generated by AGP has incorrect paths when building library modules +# We need to create the NitroModules target BEFORE the autolinking.cmake runs +# ============================================================================= +if(DEFINED REACT_NATIVE_NITRO_BUILD_DIR) + # Find NitroModules.so in the app's build directory + set(NITRO_LIBS_DIR "${REACT_NATIVE_NITRO_BUILD_DIR}/intermediates/merged_native_libs/debug/mergeDebugNativeLibs/out/lib/${ANDROID_ABI}") + if(EXISTS "${NITRO_LIBS_DIR}/libNitroModules.so") + message(STATUS "[RunAnywhereONNX] Using NitroModules from app build: ${NITRO_LIBS_DIR}") + add_library(react-native-nitro-modules::NitroModules SHARED IMPORTED) + set_target_properties(react-native-nitro-modules::NitroModules PROPERTIES + IMPORTED_LOCATION "${NITRO_LIBS_DIR}/libNitroModules.so" + ) + endif() +endif() + +# Add Nitrogen specs (this handles all React Native linking) +include(${CMAKE_SOURCE_DIR}/../nitrogen/generated/android/runanywhereonnx+autolinking.cmake) + +# ============================================================================= +# Include directories +# ============================================================================= +# Get core package include dir (for rac/*.h headers) +get_filename_component(RN_NODE_MODULES "${CMAKE_SOURCE_DIR}/../../.." ABSOLUTE) +set(CORE_INCLUDE_DIR "${RN_NODE_MODULES}/@runanywhere/core/android/src/main/include") +set(CORE_JNILIB_DIR "${RN_NODE_MODULES}/@runanywhere/core/android/src/main/jniLibs/${ANDROID_ABI}") + +include_directories( + "src/main/cpp" + "../cpp" + "../cpp/bridges" + "${CMAKE_SOURCE_DIR}/include" + # RAC API headers from core package (flat access for all subdirectories) + "${CORE_INCLUDE_DIR}" + "${CORE_INCLUDE_DIR}/rac" + "${CORE_INCLUDE_DIR}/rac/core" + "${CORE_INCLUDE_DIR}/rac/core/capabilities" + "${CORE_INCLUDE_DIR}/rac/features" + "${CORE_INCLUDE_DIR}/rac/features/llm" + "${CORE_INCLUDE_DIR}/rac/features/stt" + "${CORE_INCLUDE_DIR}/rac/features/tts" + "${CORE_INCLUDE_DIR}/rac/features/vad" + "${CORE_INCLUDE_DIR}/rac/features/voice_agent" + "${CORE_INCLUDE_DIR}/rac/features/platform" + "${CORE_INCLUDE_DIR}/rac/infrastructure" + "${CORE_INCLUDE_DIR}/rac/infrastructure/device" + "${CORE_INCLUDE_DIR}/rac/infrastructure/download" + "${CORE_INCLUDE_DIR}/rac/infrastructure/events" + "${CORE_INCLUDE_DIR}/rac/infrastructure/model_management" + "${CORE_INCLUDE_DIR}/rac/infrastructure/network" + "${CORE_INCLUDE_DIR}/rac/infrastructure/storage" + "${CORE_INCLUDE_DIR}/rac/infrastructure/telemetry" +) + +# ============================================================================= +# RACommons - Core SDK functionality (from core package) +# ============================================================================= +if(NOT EXISTS "${CORE_JNILIB_DIR}/librac_commons.so") + message(FATAL_ERROR "[RunAnywhereONNX] RACommons not found at ${CORE_JNILIB_DIR}/librac_commons.so\n" + "Run: ./gradlew :runanywhere_core:downloadNativeLibs") +endif() + +add_library(rac_commons SHARED IMPORTED) +set_target_properties(rac_commons PROPERTIES + IMPORTED_LOCATION "${CORE_JNILIB_DIR}/librac_commons.so" + IMPORTED_NO_SONAME TRUE +) +message(STATUS "[RunAnywhereONNX] Found RACommons at ${CORE_JNILIB_DIR}/librac_commons.so") + +# ============================================================================= +# Linking - ONNX backend and RACommons are REQUIRED +# ============================================================================= +find_library(LOG_LIB log) + +target_link_libraries( + ${PACKAGE_NAME} + ${LOG_LIB} + android + rac_commons + rac_backend_onnx + onnxruntime +) + +# Also link sherpa-onnx libraries if available +if(TARGET sherpa_onnx_c_api) + target_link_libraries(${PACKAGE_NAME} sherpa_onnx_c_api) +endif() +if(TARGET sherpa_onnx_cxx_api) + target_link_libraries(${PACKAGE_NAME} sherpa_onnx_cxx_api) +endif() +if(TARGET sherpa_onnx_jni) + target_link_libraries(${PACKAGE_NAME} sherpa_onnx_jni) +endif() + +# HAS_ONNX is always defined since ONNX backend is required +target_compile_definitions(${PACKAGE_NAME} PRIVATE HAS_ONNX=1 HAS_RACOMMONS=1) + +# 16KB page alignment - MUST be on target for Android 15+ compliance +target_link_options(${PACKAGE_NAME} PRIVATE -Wl,-z,max-page-size=16384) diff --git a/sdk/runanywhere-react-native/packages/onnx/android/build.gradle b/sdk/runanywhere-react-native/packages/onnx/android/build.gradle new file mode 100644 index 000000000..76fc3f622 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/onnx/android/build.gradle @@ -0,0 +1,352 @@ +// ============================================================================= +// Node Binary Detection for Android Studio Compatibility +// Android Studio doesn't inherit terminal PATH, so we need to find node explicitly +// ============================================================================= +def findNodeBinary() { + // Check local.properties first (user can override) + def localProperties = new Properties() + def localPropertiesFile = rootProject.file("local.properties") + if (localPropertiesFile.exists()) { + localPropertiesFile.withInputStream { localProperties.load(it) } + def nodePath = localProperties.getProperty("node.path") + if (nodePath && new File(nodePath).exists()) { + return nodePath + } + } + + // Check common node installation paths + def homeDir = System.getProperty("user.home") + def nodePaths = [ + "/opt/homebrew/bin/node", // macOS ARM (Apple Silicon) + "/usr/local/bin/node", // macOS Intel / Linux + "/usr/bin/node", // Linux system + "${homeDir}/.nvm/current/bin/node", // nvm + "${homeDir}/.volta/bin/node", // volta + "${homeDir}/.asdf/shims/node" // asdf + ] + for (path in nodePaths) { + if (new File(path).exists()) { + return path + } + } + + // Fallback to 'node' (works if PATH is set correctly in terminal builds) + return "node" +} + +def getExtOrDefault(name) { + return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties['RunAnywhereONNX_' + name] +} + +// Only arm64-v8a is supported +def reactNativeArchitectures() { + return ["arm64-v8a"] +} + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' +apply from: '../nitrogen/generated/android/runanywhereonnx+autolinking.gradle' +apply plugin: 'com.facebook.react' + +// Configure node path for Android Studio builds +// Set the react extension's nodeExecutableAndArgs after plugin is applied +def nodeBinary = findNodeBinary() +logger.lifecycle("[RunAnywhereONNX] Using node binary: ${nodeBinary}") + +// Configure all codegen tasks to use the detected node binary +afterEvaluate { + tasks.withType(com.facebook.react.tasks.GenerateCodegenSchemaTask).configureEach { + nodeExecutableAndArgs.set([nodeBinary]) + } + tasks.withType(com.facebook.react.tasks.GenerateCodegenArtifactsTask).configureEach { + nodeExecutableAndArgs.set([nodeBinary]) + } +} + +def getExtOrIntegerDefault(name) { + if (rootProject.ext.has(name)) { + return rootProject.ext.get(name) + } else if (project.properties.containsKey('RunAnywhereONNX_' + name)) { + return (project.properties['RunAnywhereONNX_' + name]).toInteger() + } + def defaults = [ + 'compileSdkVersion': 36, + 'minSdkVersion': 24, + 'targetSdkVersion': 36 + ] + return defaults[name] ?: 36 +} + +// ============================================================================= +// Version Constants (MUST match Swift Package.swift and iOS Podspec) +// RABackendONNX from runanywhere-sdks +// ============================================================================= +def coreVersion = "0.1.4" + +// ============================================================================= +// Binary Source - RABackendONNX from runanywhere-sdks +// ============================================================================= +def githubOrg = "RunanywhereAI" +def coreRepo = "runanywhere-sdks" + +// ============================================================================= +// testLocal Toggle +// ============================================================================= +def useLocalBuild = project.findProperty("runanywhere.testLocal")?.toBoolean() ?: + System.getenv("RA_TEST_LOCAL") == "1" ?: false + +// Native libraries directory +def jniLibsDir = file("src/main/jniLibs") +def downloadedLibsDir = file("build/downloaded-libs") + +android { + namespace "com.margelo.nitro.runanywhere.onnx" + + compileSdkVersion getExtOrIntegerDefault('compileSdkVersion') + + defaultConfig { + minSdkVersion getExtOrIntegerDefault('minSdkVersion') + targetSdkVersion getExtOrIntegerDefault('targetSdkVersion') + + ndk { + abiFilters 'arm64-v8a', 'x86_64' + } + + externalNativeBuild { + cmake { + cppFlags "-frtti -fexceptions -Wall -fstack-protector-all" + arguments "-DANDROID_STL=c++_shared", + // Fix NitroModules prefab path - use app's build directory + "-DREACT_NATIVE_NITRO_BUILD_DIR=${rootProject.buildDir}" + abiFilters 'arm64-v8a', 'x86_64' + } + } + } + + externalNativeBuild { + cmake { + path "CMakeLists.txt" + } + } + + packagingOptions { + excludes = [ + "META-INF", + "META-INF/**" + ] + pickFirsts = [ + "**/libc++_shared.so", + "**/libjsi.so", + "**/libfbjni.so", + "**/libfolly_runtime.so" + ] + jniLibs { + useLegacyPackaging = true + } + } + + buildFeatures { + buildConfig true + prefab true + } + + buildTypes { + release { + minifyEnabled false + } + } + + lint { + disable 'GradleCompatible' + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + sourceSets { + main { + java.srcDirs += [ + "generated/java", + "generated/jni" + ] + jniLibs.srcDirs = [jniLibsDir] + } + } +} + +// ============================================================================= +// Download Native Libraries (RABackendONNX + ONNX Runtime) +// ============================================================================= + +task downloadNativeLibs { + description = "Downloads RABackendONNX and ONNX Runtime from GitHub releases" + group = "build setup" + + def versionFile = file("${jniLibsDir}/.version") + def expectedVersion = coreVersion + + outputs.dir(jniLibsDir) + outputs.upToDateWhen { + versionFile.exists() && versionFile.text.trim() == expectedVersion + } + + doLast { + if (useLocalBuild) { + logger.lifecycle("[RunAnywhereONNX] Skipping download - using local build mode") + return + } + + // Check if libs are already bundled (npm install case) + def bundledLibsDir = file("${jniLibsDir}/arm64-v8a") + def bundledLibs = bundledLibsDir.exists() ? bundledLibsDir.listFiles()?.findAll { it.name.endsWith(".so") } : [] + if (bundledLibs?.size() > 0) { + logger.lifecycle("[RunAnywhereONNX] ✅ Using bundled native libraries from npm package (${bundledLibs.size()} .so files)") + return + } + + def currentVersion = versionFile.exists() ? versionFile.text.trim() : "" + if (currentVersion == expectedVersion) { + logger.lifecycle("[RunAnywhereONNX] RABackendONNX version $expectedVersion already downloaded") + return + } + + logger.lifecycle("[RunAnywhereONNX] Downloading RABackendONNX...") + logger.lifecycle(" Core Version: $coreVersion") + + downloadedLibsDir.mkdirs() + jniLibsDir.deleteDir() + jniLibsDir.mkdirs() + + // ============================================================================= + // Download RABackendONNX from runanywhere-sdks + // ============================================================================= + def onnxUrl = "https://github.com/${githubOrg}/${coreRepo}/releases/download/core-v${coreVersion}/RABackendONNX-android-v${coreVersion}.zip" + def onnxZip = file("${downloadedLibsDir}/RABackendONNX.zip") + + logger.lifecycle("\n📦 Downloading RABackendONNX...") + logger.lifecycle(" URL: $onnxUrl") + + try { + new URL(onnxUrl).withInputStream { input -> + onnxZip.withOutputStream { output -> + output << input + } + } + logger.lifecycle(" Downloaded: ${onnxZip.length() / 1024}KB") + + // Extract and flatten the archive structure + // Archive structure: RABackendONNX-android-vX.Y.Z/onnx/arm64-v8a/*.so + // Target structure: arm64-v8a/*.so + copy { + from zipTree(onnxZip) + into jniLibsDir + // IMPORTANT: Exclude libc++_shared.so - React Native provides its own + // Using a different version causes ABI compatibility issues + exclude "**/libc++_shared.so" + eachFile { fileCopyDetails -> + def pathString = fileCopyDetails.relativePath.pathString + // Handle RABackendONNX-android-vX.Y.Z/onnx/ABI/*.so structure + def match = pathString =~ /.*\/(arm64-v8a|armeabi-v7a|x86|x86_64)\/(.+\.so)$/ + if (match) { + def abi = match[0][1] + def filename = match[0][2] + fileCopyDetails.relativePath = new RelativePath(true, abi, filename) + } else if (pathString.endsWith(".so")) { + // Fallback: just use the last two segments (abi/file.so) + def segments = pathString.split("/") + if (segments.length >= 2) { + fileCopyDetails.relativePath = new RelativePath(true, segments[-2], segments[-1]) + } + } else { + // Exclude non-so files + fileCopyDetails.exclude() + } + } + includeEmptyDirs = false + } + + logger.lifecycle(" ✅ RABackendONNX native libraries installed") + + // Extract header files + def includeDir = file("include") + includeDir.deleteDir() + includeDir.mkdirs() + + copy { + from zipTree(onnxZip) + into includeDir + eachFile { fileCopyDetails -> + def pathString = fileCopyDetails.relativePath.pathString + // Handle RABackendONNX-android-vX.Y.Z/include/*.h structure + if (pathString.contains("/include/") && pathString.endsWith(".h")) { + def filename = pathString.substring(pathString.lastIndexOf("/") + 1) + fileCopyDetails.relativePath = new RelativePath(true, filename) + } else { + fileCopyDetails.exclude() + } + } + includeEmptyDirs = false + } + logger.lifecycle(" ✅ RABackendONNX headers installed") + + } catch (Exception e) { + logger.error("❌ Failed to download RABackendONNX: ${e.message}") + throw new GradleException("Failed to download RABackendONNX", e) + } + + // ============================================================================= + // List installed files + // ============================================================================= + logger.lifecycle("\n📋 Installed native libraries:") + jniLibsDir.listFiles()?.findAll { it.isDirectory() }?.each { abiDir -> + logger.lifecycle(" ${abiDir.name}/") + abiDir.listFiles()?.findAll { it.name.endsWith(".so") }?.sort()?.each { soFile -> + logger.lifecycle(" ${soFile.name} (${soFile.length() / 1024}KB)") + } + } + + versionFile.text = expectedVersion + logger.lifecycle("\n✅ RABackendONNX version $expectedVersion installed") + } +} + +if (!useLocalBuild) { + preBuild.dependsOn downloadNativeLibs + + afterEvaluate { + tasks.matching { + it.name.contains("generateCodegen") || it.name.contains("Codegen") + }.configureEach { + mustRunAfter downloadNativeLibs + } + } +} + +// NOTE: cleanNativeLibs is NOT attached to clean task because npm-bundled libs should persist +// Only use this task manually when needed during development +task cleanNativeLibs(type: Delete) { + description = "Removes downloaded native libraries (use manually, not during normal clean)" + group = "build" + delete downloadedLibsDir + // DO NOT delete jniLibsDir - it contains npm-bundled libraries +} + +// DO NOT add: clean.dependsOn cleanNativeLibs +// This would delete bundled .so files from the npm package + +repositories { + mavenCentral() + google() +} + +dependencies { + implementation "com.facebook.react:react-android" + implementation project(":react-native-nitro-modules") + implementation project(":runanywhere_core") +} diff --git a/sdk/runanywhere-react-native/packages/onnx/android/src/main/AndroidManifest.xml b/sdk/runanywhere-react-native/packages/onnx/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000..9b10081c9 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/onnx/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + diff --git a/sdk/runanywhere-react-native/packages/onnx/android/src/main/cpp/cpp-adapter.cpp b/sdk/runanywhere-react-native/packages/onnx/android/src/main/cpp/cpp-adapter.cpp new file mode 100644 index 000000000..0ecdd0efe --- /dev/null +++ b/sdk/runanywhere-react-native/packages/onnx/android/src/main/cpp/cpp-adapter.cpp @@ -0,0 +1,14 @@ +/** + * cpp-adapter.cpp + * + * Android JNI entry point for RunAnywhereONNX native module. + * This file is required by React Native's CMake build system. + */ + +#include +#include "runanywhereonnxOnLoad.hpp" + +extern "C" JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) { + // Initialize nitrogen module and register HybridObjects + return margelo::nitro::runanywhere::onnx::initialize(vm); +} diff --git a/sdk/runanywhere-react-native/packages/onnx/android/src/main/java/com/margelo/nitro/runanywhere/onnx/RunAnywhereONNXPackage.kt b/sdk/runanywhere-react-native/packages/onnx/android/src/main/java/com/margelo/nitro/runanywhere/onnx/RunAnywhereONNXPackage.kt new file mode 100644 index 000000000..cc4528c69 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/onnx/android/src/main/java/com/margelo/nitro/runanywhere/onnx/RunAnywhereONNXPackage.kt @@ -0,0 +1,36 @@ +package com.margelo.nitro.runanywhere.onnx + +import com.facebook.react.BaseReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.module.model.ReactModuleInfoProvider +import com.margelo.nitro.runanywhere.SDKLogger + +/** + * React Native package for RunAnywhere ONNX backend. + * This class is required for React Native autolinking. + */ +class RunAnywhereONNXPackage : BaseReactPackage() { + override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? { + return null + } + + override fun getReactModuleInfoProvider(): ReactModuleInfoProvider { + return ReactModuleInfoProvider { HashMap() } + } + + companion object { + private val logger = SDKLogger("ONNX") + + init { + // Load the native library which registers the HybridObject factory + // The library name must match CMakeLists.txt PACKAGE_NAME: "runanywhereonnx" + try { + System.loadLibrary("runanywhereonnx") + } catch (e: UnsatisfiedLinkError) { + // Native library may already be loaded or bundled differently + logger.error("Failed to load runanywhereonnx: ${e.message}") + } + } + } +} diff --git a/sdk/runanywhere-react-native/packages/onnx/cpp/HybridRunAnywhereONNX.cpp b/sdk/runanywhere-react-native/packages/onnx/cpp/HybridRunAnywhereONNX.cpp new file mode 100644 index 000000000..969fbe026 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/onnx/cpp/HybridRunAnywhereONNX.cpp @@ -0,0 +1,509 @@ +/** + * HybridRunAnywhereONNX.cpp + * + * Nitrogen HybridObject implementation for RunAnywhere ONNX backend. + * + * ONNX-specific implementation for speech processing: + * - STT, TTS, VAD, Voice Agent + */ + +#include "HybridRunAnywhereONNX.hpp" + +// ONNX bridges +#include "bridges/STTBridge.hpp" +#include "bridges/TTSBridge.hpp" +#include "bridges/VADBridge.hpp" +#include "bridges/VoiceAgentBridge.hpp" + +// Backend registration header - always available +extern "C" { +#include "rac_vad_onnx.h" +} + +// RACommons logger - unified logging across platforms +#include "rac_logger.h" + +#include +#include +#include +#include + +// Category for ONNX module logging +static const char* LOG_CATEGORY = "ONNX"; + +namespace margelo::nitro::runanywhere::onnx { + +using namespace ::runanywhere::bridges; + +// ============================================================================ +// Base64 and JSON Utilities +// ============================================================================ + +namespace { + +static const std::string BASE64_CHARS = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +std::string base64Encode(const unsigned char* data, size_t length) { + std::string result; + result.reserve(((length + 2) / 3) * 4); + + for (size_t i = 0; i < length; i += 3) { + unsigned int n = static_cast(data[i]) << 16; + if (i + 1 < length) n |= static_cast(data[i + 1]) << 8; + if (i + 2 < length) n |= static_cast(data[i + 2]); + + result.push_back(BASE64_CHARS[(n >> 18) & 0x3F]); + result.push_back(BASE64_CHARS[(n >> 12) & 0x3F]); + result.push_back((i + 1 < length) ? BASE64_CHARS[(n >> 6) & 0x3F] : '='); + result.push_back((i + 2 < length) ? BASE64_CHARS[n & 0x3F] : '='); + } + + return result; +} + +std::vector base64Decode(const std::string& encoded) { + std::vector result; + result.reserve((encoded.size() / 4) * 3); + + std::vector T(256, -1); + for (int i = 0; i < 64; i++) { + T[static_cast(BASE64_CHARS[i])] = i; + } + + int val = 0, valb = -8; + for (unsigned char c : encoded) { + if (T[c] == -1) break; + val = (val << 6) + T[c]; + valb += 6; + if (valb >= 0) { + result.push_back(static_cast((val >> valb) & 0xFF)); + valb -= 8; + } + } + + return result; +} + +std::string encodeBase64Audio(const float* samples, size_t count) { + return base64Encode(reinterpret_cast(samples), + count * sizeof(float)); +} + +std::string encodeBase64Bytes(const uint8_t* data, size_t size) { + return base64Encode(data, size); +} + +std::string extractStringValue(const std::string& json, const std::string& key, const std::string& defaultValue = "") { + std::string searchKey = "\"" + key + "\":\""; + size_t pos = json.find(searchKey); + if (pos == std::string::npos) return defaultValue; + pos += searchKey.length(); + size_t endPos = json.find("\"", pos); + if (endPos == std::string::npos) return defaultValue; + return json.substr(pos, endPos - pos); +} + +std::string buildJsonObject(const std::vector>& keyValues) { + std::string result = "{"; + for (size_t i = 0; i < keyValues.size(); i++) { + if (i > 0) result += ","; + result += "\"" + keyValues[i].first + "\":" + keyValues[i].second; + } + result += "}"; + return result; +} + +std::string jsonString(const std::string& value) { + std::string escaped = "\""; + for (char c : value) { + if (c == '"') escaped += "\\\""; + else if (c == '\\') escaped += "\\\\"; + else if (c == '\n') escaped += "\\n"; + else if (c == '\r') escaped += "\\r"; + else if (c == '\t') escaped += "\\t"; + else escaped += c; + } + escaped += "\""; + return escaped; +} + +} // anonymous namespace + +// ============================================================================ +// Constructor / Destructor +// ============================================================================ + +HybridRunAnywhereONNX::HybridRunAnywhereONNX() : HybridObject(TAG) { + RAC_LOG_INFO(LOG_CATEGORY, "HybridRunAnywhereONNX constructor - ONNX backend module"); +} + +HybridRunAnywhereONNX::~HybridRunAnywhereONNX() { + RAC_LOG_INFO(LOG_CATEGORY, "HybridRunAnywhereONNX destructor"); + VoiceAgentBridge::shared().cleanup(); + STTBridge::shared().cleanup(); + TTSBridge::shared().cleanup(); + VADBridge::shared().cleanup(); +} + +// ============================================================================ +// Backend Registration +// ============================================================================ + +std::shared_ptr> HybridRunAnywhereONNX::registerBackend() { + return Promise::async([this]() { + RAC_LOG_INFO(LOG_CATEGORY, "Registering ONNX backend with C++ registry..."); + + rac_result_t result = rac_backend_onnx_register(); + // RAC_SUCCESS (0) or RAC_ERROR_MODULE_ALREADY_REGISTERED (-4) are both OK + if (result == RAC_SUCCESS || result == -4) { + RAC_LOG_INFO(LOG_CATEGORY, "ONNX backend registered successfully (STT + TTS + VAD)"); + isRegistered_ = true; + return true; + } else { + RAC_LOG_ERROR(LOG_CATEGORY, "ONNX registration failed with code: %d", result); + setLastError("ONNX registration failed with error: " + std::to_string(result)); + throw std::runtime_error("ONNX registration failed with error: " + std::to_string(result)); + } + }); +} + +std::shared_ptr> HybridRunAnywhereONNX::unregisterBackend() { + return Promise::async([this]() { + RAC_LOG_INFO(LOG_CATEGORY, "Unregistering ONNX backend..."); + + rac_result_t result = rac_backend_onnx_unregister(); + isRegistered_ = false; + if (result != RAC_SUCCESS) { + RAC_LOG_ERROR(LOG_CATEGORY, "ONNX unregistration failed with code: %d", result); + throw std::runtime_error("ONNX unregistration failed with error: " + std::to_string(result)); + } + return true; + }); +} + +std::shared_ptr> HybridRunAnywhereONNX::isBackendRegistered() { + return Promise::async([this]() { + return isRegistered_; + }); +} + +// ============================================================================ +// Speech-to-Text (STT) +// ============================================================================ + +std::shared_ptr> HybridRunAnywhereONNX::loadSTTModel( + const std::string& path, + const std::string& modelType, + const std::optional& configJson) { + return Promise::async([this, path]() { + std::lock_guard lock(modelMutex_); + RAC_LOG_INFO("STT.ONNX", "Loading STT model: %s", path.c_str()); + auto result = STTBridge::shared().loadModel(path); + if (result != 0) { + setLastError("Failed to load STT model"); + return false; + } + return true; + }); +} + +std::shared_ptr> HybridRunAnywhereONNX::isSTTModelLoaded() { + return Promise::async([]() { + return STTBridge::shared().isLoaded(); + }); +} + +std::shared_ptr> HybridRunAnywhereONNX::unloadSTTModel() { + return Promise::async([this]() { + std::lock_guard lock(modelMutex_); + auto result = STTBridge::shared().unload(); + return result == 0; + }); +} + +std::shared_ptr> HybridRunAnywhereONNX::transcribe( + const std::string& audioBase64, + double sampleRate, + const std::optional& language) { + return Promise::async([this, audioBase64, sampleRate, language]() { + if (!STTBridge::shared().isLoaded()) { + return buildJsonObject({{"error", jsonString("STT model not loaded")}}); + } + + auto audioBytes = base64Decode(audioBase64); + const void* samples = audioBytes.data(); + size_t audioSize = audioBytes.size(); + + STTOptions options; + options.language = language.value_or("en"); + + auto result = STTBridge::shared().transcribe(samples, audioSize, options); + + return buildJsonObject({ + {"text", jsonString(result.text)}, + {"confidence", std::to_string(result.confidence)}, + {"isFinal", result.isFinal ? "true" : "false"} + }); + }); +} + +std::shared_ptr> HybridRunAnywhereONNX::transcribeFile( + const std::string& filePath, + const std::optional& language) { + return Promise::async([this, filePath, language]() { + if (!STTBridge::shared().isLoaded()) { + return buildJsonObject({{"error", jsonString("STT model not loaded")}}); + } + + // TODO: Read audio file and transcribe + return buildJsonObject({{"error", jsonString("transcribeFile not yet implemented with rac_* API")}}); + }); +} + +std::shared_ptr> HybridRunAnywhereONNX::supportsSTTStreaming() { + return Promise::async([]() { + return true; + }); +} + +// ============================================================================ +// Text-to-Speech (TTS) +// ============================================================================ + +std::shared_ptr> HybridRunAnywhereONNX::loadTTSModel( + const std::string& path, + const std::string& modelType, + const std::optional& configJson) { + return Promise::async([this, path]() { + std::lock_guard lock(modelMutex_); + RAC_LOG_INFO("TTS.ONNX", "Loading TTS model: %s", path.c_str()); + auto result = TTSBridge::shared().loadModel(path); + if (result != 0) { + setLastError("Failed to load TTS model"); + return false; + } + return true; + }); +} + +std::shared_ptr> HybridRunAnywhereONNX::isTTSModelLoaded() { + return Promise::async([]() { + return TTSBridge::shared().isLoaded(); + }); +} + +std::shared_ptr> HybridRunAnywhereONNX::unloadTTSModel() { + return Promise::async([this]() { + std::lock_guard lock(modelMutex_); + auto result = TTSBridge::shared().unload(); + return result == 0; + }); +} + +std::shared_ptr> HybridRunAnywhereONNX::synthesize( + const std::string& text, + const std::string& voiceId, + double speedRate, + double pitchShift) { + return Promise::async([this, text, voiceId, speedRate, pitchShift]() { + if (!TTSBridge::shared().isLoaded()) { + return buildJsonObject({{"error", jsonString("TTS model not loaded")}}); + } + + TTSOptions options; + options.voiceId = voiceId; + options.speed = static_cast(speedRate); + options.pitch = static_cast(pitchShift); + + auto result = TTSBridge::shared().synthesize(text, options); + + std::string audioBase64 = encodeBase64Audio(result.audioData.data(), result.audioData.size()); + + return buildJsonObject({ + {"audio", jsonString(audioBase64)}, + {"sampleRate", std::to_string(result.sampleRate)}, + {"numSamples", std::to_string(result.audioData.size())}, + {"duration", std::to_string(result.durationMs / 1000.0)} + }); + }); +} + +std::shared_ptr> HybridRunAnywhereONNX::getTTSVoices() { + return Promise::async([]() { + return std::string("[{\"id\":\"default\",\"name\":\"Default Voice\",\"language\":\"en-US\"}]"); + }); +} + +// ============================================================================ +// Voice Activity Detection (VAD) +// ============================================================================ + +std::shared_ptr> HybridRunAnywhereONNX::loadVADModel( + const std::string& path, + const std::optional& configJson) { + return Promise::async([this, path]() { + std::lock_guard lock(modelMutex_); + RAC_LOG_INFO("VAD.ONNX", "Loading VAD model: %s", path.c_str()); + auto result = VADBridge::shared().loadModel(path); + if (result != 0) { + setLastError("Failed to load VAD model"); + return false; + } + return true; + }); +} + +std::shared_ptr> HybridRunAnywhereONNX::isVADModelLoaded() { + return Promise::async([]() { + return VADBridge::shared().isLoaded(); + }); +} + +std::shared_ptr> HybridRunAnywhereONNX::unloadVADModel() { + return Promise::async([this]() { + std::lock_guard lock(modelMutex_); + auto result = VADBridge::shared().unload(); + return result == 0; + }); +} + +std::shared_ptr> HybridRunAnywhereONNX::processVAD( + const std::string& audioBase64, + const std::optional& optionsJson) { + return Promise::async([this, audioBase64, optionsJson]() { + if (!VADBridge::shared().isLoaded()) { + return buildJsonObject({{"error", jsonString("VAD model not loaded")}}); + } + + auto audioBytes = base64Decode(audioBase64); + VADOptions options; + auto result = VADBridge::shared().process(audioBytes.data(), audioBytes.size(), options); + + return buildJsonObject({ + {"isSpeech", result.isSpeech ? "true" : "false"}, + {"speechProbability", std::to_string(result.speechProbability)}, + {"startTime", std::to_string(result.startTime)}, + {"endTime", std::to_string(result.endTime)} + }); + }); +} + +std::shared_ptr> HybridRunAnywhereONNX::resetVAD() { + return Promise::async([]() { + VADBridge::shared().reset(); + }); +} + +std::shared_ptr> HybridRunAnywhereONNX::initializeVAD( + const std::optional& configJson) { + return Promise::async([]() { + // TODO: Initialize VAD with config + return true; + }); +} + +std::shared_ptr> HybridRunAnywhereONNX::cleanupVAD() { + return Promise::async([]() { + VADBridge::shared().cleanup(); + }); +} + +std::shared_ptr> HybridRunAnywhereONNX::startVAD() { + return Promise::async([]() { + // TODO: Start VAD processing + return true; + }); +} + +std::shared_ptr> HybridRunAnywhereONNX::stopVAD() { + return Promise::async([]() { + // TODO: Stop VAD processing + return true; + }); +} + +// ============================================================================ +// Voice Agent +// ============================================================================ + +std::shared_ptr> HybridRunAnywhereONNX::initializeVoiceAgent( + const std::string& configJson) { + return Promise::async([configJson]() { + VoiceAgentConfig config; + config.sttModelId = extractStringValue(configJson, "sttModelId"); + config.llmModelId = extractStringValue(configJson, "llmModelId"); + config.ttsVoiceId = extractStringValue(configJson, "ttsVoiceId"); + + auto result = VoiceAgentBridge::shared().initialize(config); + return result == 0; + }); +} + +std::shared_ptr> HybridRunAnywhereONNX::isVoiceAgentReady() { + return Promise::async([]() { + return VoiceAgentBridge::shared().isReady(); + }); +} + +std::shared_ptr> HybridRunAnywhereONNX::processVoiceTurn( + const std::string& audioBase64) { + return Promise::async([this, audioBase64]() { + if (!VoiceAgentBridge::shared().isReady()) { + return buildJsonObject({{"error", jsonString("Voice agent not ready")}}); + } + + auto audioBytes = base64Decode(audioBase64); + auto result = VoiceAgentBridge::shared().processVoiceTurn( + audioBytes.data(), audioBytes.size() + ); + + std::string synthesizedBase64; + if (!result.synthesizedAudio.empty()) { + synthesizedBase64 = encodeBase64Bytes( + result.synthesizedAudio.data(), + result.synthesizedAudio.size() + ); + } + + return buildJsonObject({ + {"speechDetected", result.speechDetected ? "true" : "false"}, + {"transcription", jsonString(result.transcription)}, + {"response", jsonString(result.response)}, + {"synthesizedAudio", jsonString(synthesizedBase64)}, + {"sampleRate", std::to_string(result.sampleRate)} + }); + }); +} + +std::shared_ptr> HybridRunAnywhereONNX::cleanupVoiceAgent() { + return Promise::async([]() { + VoiceAgentBridge::shared().cleanup(); + }); +} + +// ============================================================================ +// Utilities +// ============================================================================ + +std::shared_ptr> HybridRunAnywhereONNX::getLastError() { + return Promise::async([this]() { return lastError_; }); +} + +std::shared_ptr> HybridRunAnywhereONNX::getMemoryUsage() { + return Promise::async([]() { + // TODO: Get memory usage from ONNX Runtime + return 0.0; + }); +} + +// ============================================================================ +// Helper Methods +// ============================================================================ + +void HybridRunAnywhereONNX::setLastError(const std::string& error) { + lastError_ = error; + RAC_LOG_ERROR(LOG_CATEGORY, "Error: %s", error.c_str()); +} + +} // namespace margelo::nitro::runanywhere::onnx diff --git a/sdk/runanywhere-react-native/packages/onnx/cpp/HybridRunAnywhereONNX.hpp b/sdk/runanywhere-react-native/packages/onnx/cpp/HybridRunAnywhereONNX.hpp new file mode 100644 index 000000000..9f0f3f4ba --- /dev/null +++ b/sdk/runanywhere-react-native/packages/onnx/cpp/HybridRunAnywhereONNX.hpp @@ -0,0 +1,139 @@ +/** + * HybridRunAnywhereONNX.hpp + * + * Nitrogen HybridObject implementation for RunAnywhere ONNX backend. + * This single C++ file works on both iOS and Android. + * + * ONNX-specific implementation: + * - Backend Registration + * - Speech-to-Text (STT) + * - Text-to-Speech (TTS) + * - Voice Activity Detection (VAD) + * - Voice Agent (full pipeline orchestration) + * + * Matches Swift SDK: ONNXRuntime/ONNX.swift + * + * The HybridRunAnywhereONNXSpec base class is auto-generated by Nitrogen + * from src/specs/RunAnywhereONNX.nitro.ts + */ + +#pragma once + +// Include the generated spec header (created by nitrogen) +#if __has_include() +#include "HybridRunAnywhereONNXSpec.hpp" +#else +// Fallback include path during development +#include "../nitrogen/generated/shared/c++/HybridRunAnywhereONNXSpec.hpp" +#endif + +#include +#include + +namespace margelo::nitro::runanywhere::onnx { + +/** + * HybridRunAnywhereONNX - ONNX backend native implementation + * + * Implements the RunAnywhereONNX interface defined in RunAnywhereONNX.nitro.ts + * Delegates to STTBridge, TTSBridge, VADBridge, and VoiceAgentBridge. + */ +class HybridRunAnywhereONNX : public HybridRunAnywhereONNXSpec { +public: + HybridRunAnywhereONNX(); + ~HybridRunAnywhereONNX(); + + // ============================================================================ + // Backend Registration + // ============================================================================ + + std::shared_ptr> registerBackend() override; + std::shared_ptr> unregisterBackend() override; + std::shared_ptr> isBackendRegistered() override; + + // ============================================================================ + // Speech-to-Text (STT) + // ============================================================================ + + std::shared_ptr> loadSTTModel( + const std::string& path, + const std::string& modelType, + const std::optional& configJson) override; + std::shared_ptr> isSTTModelLoaded() override; + std::shared_ptr> unloadSTTModel() override; + std::shared_ptr> transcribe( + const std::string& audioBase64, + double sampleRate, + const std::optional& language) override; + std::shared_ptr> transcribeFile( + const std::string& filePath, + const std::optional& language) override; + std::shared_ptr> supportsSTTStreaming() override; + + // ============================================================================ + // Text-to-Speech (TTS) + // ============================================================================ + + std::shared_ptr> loadTTSModel( + const std::string& path, + const std::string& modelType, + const std::optional& configJson) override; + std::shared_ptr> isTTSModelLoaded() override; + std::shared_ptr> unloadTTSModel() override; + std::shared_ptr> synthesize( + const std::string& text, + const std::string& voiceId, + double speedRate, + double pitchShift) override; + std::shared_ptr> getTTSVoices() override; + + // ============================================================================ + // Voice Activity Detection (VAD) + // ============================================================================ + + std::shared_ptr> loadVADModel( + const std::string& path, + const std::optional& configJson) override; + std::shared_ptr> isVADModelLoaded() override; + std::shared_ptr> unloadVADModel() override; + std::shared_ptr> processVAD( + const std::string& audioBase64, + const std::optional& optionsJson) override; + std::shared_ptr> resetVAD() override; + std::shared_ptr> initializeVAD( + const std::optional& configJson) override; + std::shared_ptr> cleanupVAD() override; + std::shared_ptr> startVAD() override; + std::shared_ptr> stopVAD() override; + + // ============================================================================ + // Voice Agent + // ============================================================================ + + std::shared_ptr> initializeVoiceAgent( + const std::string& configJson) override; + std::shared_ptr> isVoiceAgentReady() override; + std::shared_ptr> processVoiceTurn( + const std::string& audioBase64) override; + std::shared_ptr> cleanupVoiceAgent() override; + + // ============================================================================ + // Utilities + // ============================================================================ + + std::shared_ptr> getLastError() override; + std::shared_ptr> getMemoryUsage() override; + +private: + // Thread safety + std::mutex modelMutex_; + + // State tracking + std::string lastError_; + bool isRegistered_ = false; + + // Helper methods + void setLastError(const std::string& error); +}; + +} // namespace margelo::nitro::runanywhere::onnx diff --git a/sdk/runanywhere-react-native/packages/onnx/cpp/bridges/STTBridge.cpp b/sdk/runanywhere-react-native/packages/onnx/cpp/bridges/STTBridge.cpp new file mode 100644 index 000000000..9c55eed42 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/onnx/cpp/bridges/STTBridge.cpp @@ -0,0 +1,184 @@ +/** + * @file STTBridge.cpp + * @brief STT capability bridge implementation + * + * Aligned with rac_stt_component.h and rac_stt_types.h API. + * RACommons and ONNX backend are REQUIRED - no stub implementations. + */ + +#include "STTBridge.hpp" +#include + +// RACommons logger - unified logging across platforms +#include "rac_logger.h" + +// Category for STT.ONNX logging +static const char* LOG_CATEGORY = "STT.ONNX"; + +namespace runanywhere { +namespace bridges { + +STTBridge& STTBridge::shared() { + static STTBridge instance; + return instance; +} + +STTBridge::STTBridge() = default; + +STTBridge::~STTBridge() { + cleanup(); + if (handle_) { + rac_stt_component_destroy(handle_); + handle_ = nullptr; + } +} + +bool STTBridge::isLoaded() const { + if (handle_) { + return rac_stt_component_is_loaded(handle_) == RAC_TRUE; + } + return false; +} + +std::string STTBridge::currentModelId() const { + return loadedModelId_; +} + +rac_result_t STTBridge::loadModel(const std::string& modelPath, + const std::string& modelId, + const std::string& modelName) { + // Create component if needed + if (!handle_) { + rac_result_t result = rac_stt_component_create(&handle_); + if (result != RAC_SUCCESS) { + throw std::runtime_error("STTBridge: Failed to create STT component. Error: " + std::to_string(result)); + } + } + + // Use modelPath as modelId if not provided + std::string effectiveModelId = modelId.empty() ? modelPath : modelId; + std::string effectiveModelName = modelName.empty() ? effectiveModelId : modelName; + + // Unload existing model if different + if (isLoaded() && loadedModelId_ != effectiveModelId) { + rac_stt_component_unload(handle_); + } + + // Load new model with 4-argument signature + rac_result_t result = rac_stt_component_load_model( + handle_, + modelPath.c_str(), + effectiveModelId.c_str(), + effectiveModelName.c_str() + ); + + if (result == RAC_SUCCESS) { + loadedModelId_ = effectiveModelId; + RAC_LOG_INFO(LOG_CATEGORY, "STT model loaded: %s", effectiveModelId.c_str()); + } else { + throw std::runtime_error("STTBridge: Failed to load STT model '" + effectiveModelId + "'. Error: " + std::to_string(result)); + } + return result; +} + +rac_result_t STTBridge::unload() { + if (handle_) { + rac_result_t result = rac_stt_component_unload(handle_); + if (result == RAC_SUCCESS) { + loadedModelId_.clear(); + } else { + throw std::runtime_error("STTBridge: Failed to unload STT model. Error: " + std::to_string(result)); + } + return result; + } + loadedModelId_.clear(); + return RAC_SUCCESS; +} + +void STTBridge::cleanup() { + if (handle_) { + rac_stt_component_cleanup(handle_); + } + loadedModelId_.clear(); +} + +STTResult STTBridge::transcribe(const void* audioData, size_t audioSize, + const STTOptions& options) { + STTResult result; + + if (!handle_ || !isLoaded()) { + throw std::runtime_error("STTBridge: STT model not loaded. Call loadModel() first."); + } + + rac_stt_options_t racOptions = RAC_STT_OPTIONS_DEFAULT; + if (!options.language.empty()) { + racOptions.language = options.language.c_str(); + } + racOptions.sample_rate = options.sampleRate > 0 ? options.sampleRate : RAC_STT_DEFAULT_SAMPLE_RATE; + + rac_stt_result_t racResult = {}; + rac_result_t status = rac_stt_component_transcribe(handle_, audioData, audioSize, + &racOptions, &racResult); + + if (status == RAC_SUCCESS) { + if (racResult.text) { + result.text = racResult.text; + } + result.durationMs = static_cast(racResult.processing_time_ms); + result.confidence = racResult.confidence; + result.isFinal = true; + + // Free the C result + rac_stt_result_free(&racResult); + } else { + throw std::runtime_error("STTBridge: Transcription failed with error code: " + std::to_string(status)); + } + + return result; +} + +void STTBridge::transcribeStream(const void* audioData, size_t audioSize, + const STTOptions& options, + const STTStreamCallbacks& callbacks) { + if (!handle_ || !isLoaded()) { + if (callbacks.onError) { + callbacks.onError(-4, "STT model not loaded. Call loadModel() first."); + } + return; + } + + rac_stt_options_t racOptions = RAC_STT_OPTIONS_DEFAULT; + if (!options.language.empty()) { + racOptions.language = options.language.c_str(); + } + racOptions.sample_rate = options.sampleRate > 0 ? options.sampleRate : RAC_STT_DEFAULT_SAMPLE_RATE; + + // Stream context for callbacks + struct StreamContext { + const STTStreamCallbacks* callbacks; + }; + + StreamContext ctx = { &callbacks }; + + auto streamCallback = [](const char* partial_text, rac_bool_t is_final, void* user_data) { + auto* ctx = static_cast(user_data); + if (!ctx || !partial_text) return; + + STTResult sttResult; + sttResult.text = partial_text; + sttResult.confidence = 1.0f; + sttResult.isFinal = is_final == RAC_TRUE; + + if (sttResult.isFinal && ctx->callbacks->onFinalResult) { + ctx->callbacks->onFinalResult(sttResult); + } else if (!sttResult.isFinal && ctx->callbacks->onPartialResult) { + ctx->callbacks->onPartialResult(sttResult); + } + }; + + rac_stt_component_transcribe_stream(handle_, audioData, audioSize, + &racOptions, streamCallback, &ctx); +} + +} // namespace bridges +} // namespace runanywhere diff --git a/sdk/runanywhere-react-native/packages/onnx/cpp/bridges/STTBridge.hpp b/sdk/runanywhere-react-native/packages/onnx/cpp/bridges/STTBridge.hpp new file mode 100644 index 000000000..9c72d648e --- /dev/null +++ b/sdk/runanywhere-react-native/packages/onnx/cpp/bridges/STTBridge.hpp @@ -0,0 +1,97 @@ +/** + * @file STTBridge.hpp + * @brief STT (Speech-to-Text) capability bridge for React Native + * + * Matches Swift's CppBridge+STT.swift pattern, providing: + * - Model lifecycle (load/unload) + * - Transcription (batch and streaming) + * + * Aligned with rac_stt_component.h and rac_stt_types.h API. + * RACommons is REQUIRED - no stub implementations. + */ + +#pragma once + +#include +#include +#include +#include + +// RACommons STT headers - REQUIRED (flat include paths) +#include "rac_stt_component.h" +#include "rac_stt_types.h" + +namespace runanywhere { +namespace bridges { + +/** + * @brief STT transcription result + */ +struct STTResult { + std::string text; + double durationMs = 0.0; + double confidence = 0.0; + bool isFinal = true; +}; + +/** + * @brief STT transcription options + */ +struct STTOptions { + std::string language = "en"; + bool enableTimestamps = false; + bool enablePunctuation = true; + int sampleRate = 16000; +}; + +/** + * @brief STT streaming callbacks + */ +struct STTStreamCallbacks { + std::function onPartialResult; + std::function onFinalResult; + std::function onError; +}; + +/** + * @brief STT capability bridge singleton + * + * Matches CppBridge+STT.swift API. + * NOTE: RACommons is REQUIRED. All methods will throw std::runtime_error if + * the underlying C API calls fail. + */ +class STTBridge { +public: + static STTBridge& shared(); + + // Lifecycle + bool isLoaded() const; + std::string currentModelId() const; + rac_result_t loadModel(const std::string& modelPath, + const std::string& modelId = "", + const std::string& modelName = ""); + rac_result_t unload(); + void cleanup(); + + // Transcription + STTResult transcribe(const void* audioData, size_t audioSize, + const STTOptions& options); + + void transcribeStream(const void* audioData, size_t audioSize, + const STTOptions& options, + const STTStreamCallbacks& callbacks); + +private: + STTBridge(); + ~STTBridge(); + + // Disable copy/move + STTBridge(const STTBridge&) = delete; + STTBridge& operator=(const STTBridge&) = delete; + + rac_handle_t handle_ = nullptr; + std::string loadedModelId_; +}; + +} // namespace bridges +} // namespace runanywhere diff --git a/sdk/runanywhere-react-native/packages/onnx/cpp/bridges/TTSBridge.cpp b/sdk/runanywhere-react-native/packages/onnx/cpp/bridges/TTSBridge.cpp new file mode 100644 index 000000000..6ced9ede0 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/onnx/cpp/bridges/TTSBridge.cpp @@ -0,0 +1,140 @@ +/** + * @file TTSBridge.cpp + * @brief TTS capability bridge implementation + * + * Aligned with rac_tts_component.h and rac_tts_types.h API. + * RACommons and ONNX backend are REQUIRED - no stub implementations. + */ + +#include "TTSBridge.hpp" +#include +#include + +// RACommons logger - unified logging across platforms +#include "rac_logger.h" + +// Category for TTS.ONNX logging +static const char* LOG_CATEGORY = "TTS.ONNX"; + +namespace runanywhere { +namespace bridges { + +TTSBridge& TTSBridge::shared() { + static TTSBridge instance; + return instance; +} + +TTSBridge::TTSBridge() = default; + +TTSBridge::~TTSBridge() { + cleanup(); + if (handle_) { + rac_tts_component_destroy(handle_); + handle_ = nullptr; + } +} + +bool TTSBridge::isLoaded() const { + if (handle_) { + return rac_tts_component_is_loaded(handle_) == RAC_TRUE; + } + return false; +} + +std::string TTSBridge::currentModelId() const { + return loadedModelId_; +} + +rac_result_t TTSBridge::loadModel(const std::string& modelId) { + // Create component if needed + if (!handle_) { + rac_result_t result = rac_tts_component_create(&handle_); + if (result != RAC_SUCCESS) { + throw std::runtime_error("TTSBridge: Failed to create TTS component. Error: " + std::to_string(result)); + } + } + + // Unload existing model if different + if (isLoaded() && loadedModelId_ != modelId) { + rac_tts_component_unload(handle_); + } + + // Load new voice using rac_tts_component_load_voice(handle, voice_path, voice_id, voice_name) + // For TTS, modelId is the voice path/id + rac_result_t result = rac_tts_component_load_voice( + handle_, + modelId.c_str(), // voice_path + modelId.c_str(), // voice_id + modelId.c_str() // voice_name + ); + + if (result == RAC_SUCCESS) { + loadedModelId_ = modelId; + RAC_LOG_INFO(LOG_CATEGORY, "TTS voice loaded: %s", modelId.c_str()); + } else { + throw std::runtime_error("TTSBridge: Failed to load TTS voice '" + modelId + "'. Error: " + std::to_string(result)); + } + return result; +} + +rac_result_t TTSBridge::unload() { + if (handle_) { + rac_result_t result = rac_tts_component_unload(handle_); + if (result == RAC_SUCCESS) { + loadedModelId_.clear(); + } else { + throw std::runtime_error("TTSBridge: Failed to unload TTS voice. Error: " + std::to_string(result)); + } + return result; + } + loadedModelId_.clear(); + return RAC_SUCCESS; +} + +void TTSBridge::cleanup() { + if (handle_) { + rac_tts_component_cleanup(handle_); + } + loadedModelId_.clear(); +} + +TTSResult TTSBridge::synthesize(const std::string& text, const TTSOptions& options) { + TTSResult result; + + if (!handle_ || !isLoaded()) { + throw std::runtime_error("TTSBridge: TTS voice not loaded. Call loadModel() first."); + } + + rac_tts_options_t racOptions = RAC_TTS_OPTIONS_DEFAULT; + racOptions.rate = options.speed > 0 ? options.speed : 1.0f; + racOptions.pitch = options.pitch > 0 ? options.pitch : 1.0f; + racOptions.sample_rate = options.sampleRate > 0 ? options.sampleRate : RAC_TTS_DEFAULT_SAMPLE_RATE; + if (!options.voiceId.empty()) { + racOptions.voice = options.voiceId.c_str(); + } + + rac_tts_result_t racResult = {}; + rac_result_t status = rac_tts_component_synthesize(handle_, text.c_str(), + &racOptions, &racResult); + + if (status == RAC_SUCCESS) { + // Copy audio data + if (racResult.audio_data && racResult.audio_size > 0) { + size_t numSamples = racResult.audio_size / sizeof(float); + result.audioData.resize(numSamples); + std::memcpy(result.audioData.data(), racResult.audio_data, racResult.audio_size); + } + result.sampleRate = racResult.sample_rate; + result.durationMs = static_cast(racResult.duration_ms); + + // Free the C result + rac_tts_result_free(&racResult); + } else { + throw std::runtime_error("TTSBridge: Speech synthesis failed with error code: " + std::to_string(status)); + } + + return result; +} + +} // namespace bridges +} // namespace runanywhere diff --git a/sdk/runanywhere-react-native/packages/onnx/cpp/bridges/TTSBridge.hpp b/sdk/runanywhere-react-native/packages/onnx/cpp/bridges/TTSBridge.hpp new file mode 100644 index 000000000..3d3a47511 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/onnx/cpp/bridges/TTSBridge.hpp @@ -0,0 +1,80 @@ +/** + * @file TTSBridge.hpp + * @brief TTS (Text-to-Speech) capability bridge for React Native + * + * Matches Swift's CppBridge+TTS.swift pattern, providing: + * - Model lifecycle (load/unload) + * - Speech synthesis + * + * Aligned with rac_tts_component.h and rac_tts_types.h API. + * RACommons is REQUIRED - no stub implementations. + */ + +#pragma once + +#include +#include +#include +#include + +// RACommons TTS headers - REQUIRED (flat include paths) +#include "rac_tts_component.h" +#include "rac_tts_types.h" + +namespace runanywhere { +namespace bridges { + +/** + * @brief TTS synthesis result + */ +struct TTSResult { + std::vector audioData; + int sampleRate = 22050; + double durationMs = 0.0; +}; + +/** + * @brief TTS synthesis options + */ +struct TTSOptions { + std::string voiceId; + float speed = 1.0f; + float pitch = 1.0f; + int sampleRate = 22050; +}; + +/** + * @brief TTS capability bridge singleton + * + * Matches CppBridge+TTS.swift API. + * NOTE: RACommons is REQUIRED. All methods will throw std::runtime_error if + * the underlying C API calls fail. + */ +class TTSBridge { +public: + static TTSBridge& shared(); + + // Lifecycle + bool isLoaded() const; + std::string currentModelId() const; + rac_result_t loadModel(const std::string& modelId); + rac_result_t unload(); + void cleanup(); + + // Synthesis + TTSResult synthesize(const std::string& text, const TTSOptions& options); + +private: + TTSBridge(); + ~TTSBridge(); + + // Disable copy/move + TTSBridge(const TTSBridge&) = delete; + TTSBridge& operator=(const TTSBridge&) = delete; + + rac_handle_t handle_ = nullptr; + std::string loadedModelId_; +}; + +} // namespace bridges +} // namespace runanywhere diff --git a/sdk/runanywhere-react-native/packages/onnx/cpp/bridges/VADBridge.cpp b/sdk/runanywhere-react-native/packages/onnx/cpp/bridges/VADBridge.cpp new file mode 100644 index 000000000..dbd4c5908 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/onnx/cpp/bridges/VADBridge.cpp @@ -0,0 +1,159 @@ +/** + * @file VADBridge.cpp + * @brief VAD capability bridge implementation + * + * Aligned with rac_vad_component.h and rac_vad_types.h API. + * RACommons is REQUIRED - no stub implementations. + * + * NOTE: VAD doesn't "load models" like LLM/STT/TTS. + * Instead, it uses configure() + initialize() pattern. + */ + +#include "VADBridge.hpp" +#include + +// RACommons logger - unified logging across platforms +#include "rac_logger.h" + +// Category for VAD.ONNX logging +static const char* LOG_CATEGORY = "VAD.ONNX"; + +namespace runanywhere { +namespace bridges { + +VADBridge& VADBridge::shared() { + static VADBridge instance; + return instance; +} + +VADBridge::VADBridge() = default; + +VADBridge::~VADBridge() { + cleanup(); + if (handle_) { + rac_vad_component_destroy(handle_); + handle_ = nullptr; + } +} + +bool VADBridge::isLoaded() const { + if (handle_) { + return rac_vad_component_is_initialized(handle_) == RAC_TRUE; + } + return false; +} + +std::string VADBridge::currentModelId() const { + return loadedModelId_; +} + +rac_result_t VADBridge::loadModel(const std::string& modelId) { + // Create component if needed + if (!handle_) { + rac_result_t result = rac_vad_component_create(&handle_); + if (result != RAC_SUCCESS) { + throw std::runtime_error("VADBridge: Failed to create VAD component. Error: " + std::to_string(result)); + } + } + + // If already initialized with same modelId, return success + if (isLoaded() && loadedModelId_ == modelId) { + return RAC_SUCCESS; + } + + // Stop current VAD processing if running + if (isLoaded()) { + rac_vad_component_stop(handle_); + } + + // Configure VAD with the model_id (used for telemetry) + rac_vad_config_t config = RAC_VAD_CONFIG_DEFAULT; + config.model_id = modelId.c_str(); + + rac_result_t result = rac_vad_component_configure(handle_, &config); + if (result != RAC_SUCCESS) { + throw std::runtime_error("VADBridge: Failed to configure VAD with model '" + modelId + "'. Error: " + std::to_string(result)); + } + + // Initialize the VAD + result = rac_vad_component_initialize(handle_); + if (result == RAC_SUCCESS) { + loadedModelId_ = modelId; + RAC_LOG_INFO(LOG_CATEGORY, "VAD initialized with model: %s", modelId.c_str()); + } else { + throw std::runtime_error("VADBridge: Failed to initialize VAD. Error: " + std::to_string(result)); + } + + return result; +} + +rac_result_t VADBridge::unload() { + if (handle_) { + // Stop VAD processing (there's no unload for VAD) + rac_result_t result = rac_vad_component_stop(handle_); + if (result == RAC_SUCCESS) { + loadedModelId_.clear(); + RAC_LOG_INFO(LOG_CATEGORY, "VAD stopped"); + } else { + throw std::runtime_error("VADBridge: Failed to stop VAD. Error: " + std::to_string(result)); + } + return result; + } + loadedModelId_.clear(); + return RAC_SUCCESS; +} + +void VADBridge::cleanup() { + if (handle_) { + rac_vad_component_cleanup(handle_); + } + loadedModelId_.clear(); +} + +void VADBridge::reset() { + if (handle_) { + rac_result_t result = rac_vad_component_reset(handle_); + if (result != RAC_SUCCESS) { + RAC_LOG_ERROR(LOG_CATEGORY, "Failed to reset VAD: %d", result); + } + } + // Note: reset() doesn't clear the model, just resets the VAD state +} + +VADResult VADBridge::process(const void* audioData, size_t audioSize, const VADOptions& options) { + VADResult result; + + if (!handle_ || !isLoaded()) { + throw std::runtime_error("VADBridge: VAD not initialized. Call loadModel() first."); + } + + // Convert audio data to float samples + // Assuming audioData is already float samples or we need to convert + const float* samples = static_cast(audioData); + size_t numSamples = audioSize / sizeof(float); + + // Update energy threshold if specified + if (options.threshold > 0) { + rac_vad_component_set_energy_threshold(handle_, options.threshold); + } + + // Process audio + rac_bool_t isSpeech = RAC_FALSE; + rac_result_t status = rac_vad_component_process(handle_, samples, numSamples, &isSpeech); + + if (status != RAC_SUCCESS) { + throw std::runtime_error("VADBridge: VAD processing failed with error code: " + std::to_string(status)); + } + + result.isSpeech = isSpeech == RAC_TRUE; + + // Get additional info if needed + result.probability = rac_vad_component_get_energy_threshold(handle_); + result.speechProbability = result.probability; // Alias for API compatibility + result.durationMs = 0; // Not directly available from simple VAD API + + return result; +} + +} // namespace bridges +} // namespace runanywhere diff --git a/sdk/runanywhere-react-native/packages/onnx/cpp/bridges/VADBridge.hpp b/sdk/runanywhere-react-native/packages/onnx/cpp/bridges/VADBridge.hpp new file mode 100644 index 000000000..b42196728 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/onnx/cpp/bridges/VADBridge.hpp @@ -0,0 +1,83 @@ +/** + * @file VADBridge.hpp + * @brief VAD (Voice Activity Detection) capability bridge for React Native + * + * Matches Swift's CppBridge+VAD.swift pattern, providing: + * - Model lifecycle (load/unload) + * - Voice activity detection + * + * Aligned with rac_vad_component.h and rac_vad_types.h API. + * RACommons is REQUIRED - no stub implementations. + */ + +#pragma once + +#include +#include +#include +#include + +// RACommons VAD headers - REQUIRED (flat include paths) +#include "rac_vad_component.h" +#include "rac_vad_types.h" + +namespace runanywhere { +namespace bridges { + +/** + * @brief VAD detection result + */ +struct VADResult { + bool isSpeech = false; + float probability = 0.0f; + float speechProbability = 0.0f; // Alias for probability (for API compatibility) + double durationMs = 0.0; + double startTime = 0.0; // Start time of speech segment (ms) + double endTime = 0.0; // End time of speech segment (ms) +}; + +/** + * @brief VAD processing options + */ +struct VADOptions { + float threshold = 0.5f; + int windowSizeMs = 30; + int sampleRate = 16000; +}; + +/** + * @brief VAD capability bridge singleton + * + * Matches CppBridge+VAD.swift API. + * NOTE: RACommons is REQUIRED. All methods will throw std::runtime_error if + * the underlying C API calls fail. + */ +class VADBridge { +public: + static VADBridge& shared(); + + // Lifecycle + bool isLoaded() const; + std::string currentModelId() const; + rac_result_t loadModel(const std::string& modelId); + rac_result_t unload(); + void cleanup(); + void reset(); // Reset VAD state without unloading model + + // Detection + VADResult process(const void* audioData, size_t audioSize, const VADOptions& options); + +private: + VADBridge(); + ~VADBridge(); + + // Disable copy/move + VADBridge(const VADBridge&) = delete; + VADBridge& operator=(const VADBridge&) = delete; + + rac_handle_t handle_ = nullptr; + std::string loadedModelId_; +}; + +} // namespace bridges +} // namespace runanywhere diff --git a/sdk/runanywhere-react-native/packages/onnx/cpp/bridges/VoiceAgentBridge.cpp b/sdk/runanywhere-react-native/packages/onnx/cpp/bridges/VoiceAgentBridge.cpp new file mode 100644 index 000000000..60f2ed11c --- /dev/null +++ b/sdk/runanywhere-react-native/packages/onnx/cpp/bridges/VoiceAgentBridge.cpp @@ -0,0 +1,388 @@ +/** + * @file VoiceAgentBridge.cpp + * @brief Voice Agent bridge implementation + * + * Aligned with rac_voice_agent.h API. + * RACommons is REQUIRED - no stub implementations. + */ + +#include "VoiceAgentBridge.hpp" +#include "STTBridge.hpp" +#include "TTSBridge.hpp" +#include +#include + +// RACommons logger - unified logging across platforms +#include "rac_logger.h" + +// Category for VoiceAgent.ONNX logging +static const char* LOG_CATEGORY = "VoiceAgent.ONNX"; + +namespace runanywhere { +namespace bridges { + +VoiceAgentBridge& VoiceAgentBridge::shared() { + static VoiceAgentBridge instance; + return instance; +} + +VoiceAgentBridge::VoiceAgentBridge() { + RAC_LOG_INFO(LOG_CATEGORY, "VoiceAgentBridge created"); +} + +VoiceAgentBridge::~VoiceAgentBridge() { + cleanup(); +} + +rac_result_t VoiceAgentBridge::initialize(const VoiceAgentConfig& config) { + RAC_LOG_INFO(LOG_CATEGORY, "Initializing voice agent with config"); + config_ = config; + + // Create voice agent handle using standalone API (owns its component handles) + if (!handle_) { + rac_result_t result = rac_voice_agent_create_standalone(&handle_); + if (result != RAC_SUCCESS) { + RAC_LOG_ERROR(LOG_CATEGORY, "Failed to create voice agent: %d", result); + throw std::runtime_error("VoiceAgentBridge: Failed to create voice agent. Error: " + std::to_string(result)); + } + } + + // Build configuration struct matching rac_voice_agent_config_t + rac_voice_agent_config_t cConfig = RAC_VOICE_AGENT_CONFIG_DEFAULT; + + // VAD config + cConfig.vad_config.sample_rate = config.vadSampleRate; + cConfig.vad_config.frame_length = static_cast(config.vadFrameLength) / 1000.0f; // Convert to seconds + cConfig.vad_config.energy_threshold = config.vadEnergyThreshold; + + // STT config - model_path, model_id, model_name + if (!config.sttModelId.empty()) { + cConfig.stt_config.model_id = config.sttModelId.c_str(); + // model_path and model_name can be set if available + } + + // LLM config - model_path, model_id, model_name + if (!config.llmModelId.empty()) { + cConfig.llm_config.model_id = config.llmModelId.c_str(); + } + + // TTS config - voice_path, voice_id, voice_name + if (!config.ttsVoiceId.empty()) { + cConfig.tts_config.voice_id = config.ttsVoiceId.c_str(); + } + + rac_result_t result = rac_voice_agent_initialize(handle_, &cConfig); + if (result != RAC_SUCCESS) { + RAC_LOG_ERROR(LOG_CATEGORY, "Failed to initialize voice agent: %d", result); + throw std::runtime_error("VoiceAgentBridge: Failed to initialize voice agent. Error: " + std::to_string(result)); + } + + initialized_ = true; + RAC_LOG_INFO(LOG_CATEGORY, "Voice agent initialized successfully"); + return RAC_SUCCESS; +} + +rac_result_t VoiceAgentBridge::initializeWithLoadedModels() { + RAC_LOG_INFO(LOG_CATEGORY, "Initializing voice agent with loaded models"); + + if (!handle_) { + rac_result_t result = rac_voice_agent_create_standalone(&handle_); + if (result != RAC_SUCCESS) { + RAC_LOG_ERROR(LOG_CATEGORY, "Failed to create voice agent: %d", result); + throw std::runtime_error("VoiceAgentBridge: Failed to create voice agent. Error: " + std::to_string(result)); + } + } + + rac_result_t result = rac_voice_agent_initialize_with_loaded_models(handle_); + if (result != RAC_SUCCESS) { + RAC_LOG_ERROR(LOG_CATEGORY, "Failed to initialize with loaded models: %d", result); + throw std::runtime_error("VoiceAgentBridge: Failed to initialize with loaded models. Error: " + std::to_string(result)); + } + + initialized_ = true; + return RAC_SUCCESS; +} + +bool VoiceAgentBridge::isReady() const { + if (!handle_) return false; + + rac_bool_t ready = RAC_FALSE; + rac_result_t result = rac_voice_agent_is_ready(handle_, &ready); + if (result != RAC_SUCCESS) { + RAC_LOG_ERROR(LOG_CATEGORY, "Failed to check if voice agent is ready: %d", result); + return false; + } + return ready == RAC_TRUE; +} + +VoiceAgentComponentStates VoiceAgentBridge::getComponentStates() const { + VoiceAgentComponentStates states; + + if (!handle_) { + return states; + } + + // Check STT + rac_bool_t sttLoaded = RAC_FALSE; + if (rac_voice_agent_is_stt_loaded(handle_, &sttLoaded) == RAC_SUCCESS && sttLoaded == RAC_TRUE) { + states.stt = ComponentState::Loaded; + const char* sttModelId = rac_voice_agent_get_stt_model_id(handle_); + if (sttModelId) { + states.sttModelId = sttModelId; + } + } + + // Check LLM + rac_bool_t llmLoaded = RAC_FALSE; + if (rac_voice_agent_is_llm_loaded(handle_, &llmLoaded) == RAC_SUCCESS && llmLoaded == RAC_TRUE) { + states.llm = ComponentState::Loaded; + const char* llmModelId = rac_voice_agent_get_llm_model_id(handle_); + if (llmModelId) { + states.llmModelId = llmModelId; + } + } + + // Check TTS + rac_bool_t ttsLoaded = RAC_FALSE; + if (rac_voice_agent_is_tts_loaded(handle_, &ttsLoaded) == RAC_SUCCESS && ttsLoaded == RAC_TRUE) { + states.tts = ComponentState::Loaded; + const char* ttsVoiceId = rac_voice_agent_get_tts_voice_id(handle_); + if (ttsVoiceId) { + states.ttsVoiceId = ttsVoiceId; + } + } + + return states; +} + +rac_result_t VoiceAgentBridge::loadSTTModel(const std::string& modelPath, + const std::string& modelId, + const std::string& modelName) { + if (!handle_) { + throw std::runtime_error("VoiceAgentBridge: Voice agent not created. Call initialize() first."); + } + + rac_result_t result = rac_voice_agent_load_stt_model( + handle_, + modelPath.c_str(), + modelId.empty() ? modelPath.c_str() : modelId.c_str(), + modelName.empty() ? modelId.c_str() : modelName.c_str() + ); + + if (result != RAC_SUCCESS) { + RAC_LOG_ERROR(LOG_CATEGORY, "Failed to load STT model: %d", result); + throw std::runtime_error("VoiceAgentBridge: Failed to load STT model. Error: " + std::to_string(result)); + } + + RAC_LOG_INFO(LOG_CATEGORY, "STT model loaded: %s", modelId.c_str()); + return result; +} + +rac_result_t VoiceAgentBridge::loadLLMModel(const std::string& modelPath, + const std::string& modelId, + const std::string& modelName) { + if (!handle_) { + throw std::runtime_error("VoiceAgentBridge: Voice agent not created. Call initialize() first."); + } + + rac_result_t result = rac_voice_agent_load_llm_model( + handle_, + modelPath.c_str(), + modelId.empty() ? modelPath.c_str() : modelId.c_str(), + modelName.empty() ? modelId.c_str() : modelName.c_str() + ); + + if (result != RAC_SUCCESS) { + RAC_LOG_ERROR(LOG_CATEGORY, "Failed to load LLM model: %d", result); + throw std::runtime_error("VoiceAgentBridge: Failed to load LLM model. Error: " + std::to_string(result)); + } + + RAC_LOG_INFO(LOG_CATEGORY, "LLM model loaded: %s", modelId.c_str()); + return result; +} + +rac_result_t VoiceAgentBridge::loadTTSVoice(const std::string& voicePath, + const std::string& voiceId, + const std::string& voiceName) { + if (!handle_) { + throw std::runtime_error("VoiceAgentBridge: Voice agent not created. Call initialize() first."); + } + + rac_result_t result = rac_voice_agent_load_tts_voice( + handle_, + voicePath.c_str(), + voiceId.empty() ? voicePath.c_str() : voiceId.c_str(), + voiceName.empty() ? voiceId.c_str() : voiceName.c_str() + ); + + if (result != RAC_SUCCESS) { + RAC_LOG_ERROR(LOG_CATEGORY, "Failed to load TTS voice: %d", result); + throw std::runtime_error("VoiceAgentBridge: Failed to load TTS voice. Error: " + std::to_string(result)); + } + + RAC_LOG_INFO(LOG_CATEGORY, "TTS voice loaded: %s", voiceId.c_str()); + return result; +} + +VoiceAgentResult VoiceAgentBridge::processVoiceTurn(const void* audioData, size_t audioSize) { + VoiceAgentResult result; + + if (!handle_) { + throw std::runtime_error("VoiceAgentBridge: Voice agent not created. Call initialize() first."); + } + + if (!isReady()) { + throw std::runtime_error("VoiceAgentBridge: Voice agent not ready. Ensure all models are loaded."); + } + + rac_voice_agent_result_t cResult = {}; + rac_result_t ret = rac_voice_agent_process_voice_turn( + handle_, + audioData, + audioSize, + &cResult + ); + + if (ret != RAC_SUCCESS) { + RAC_LOG_ERROR(LOG_CATEGORY, "Failed to process voice turn: %d", ret); + throw std::runtime_error("VoiceAgentBridge: Failed to process voice turn. Error: " + std::to_string(ret)); + } + + result.speechDetected = cResult.speech_detected == RAC_TRUE; + if (cResult.transcription) { + result.transcription = std::string(cResult.transcription); + } + if (cResult.response) { + result.response = std::string(cResult.response); + } + if (cResult.synthesized_audio && cResult.synthesized_audio_size > 0) { + result.synthesizedAudio.assign( + static_cast(cResult.synthesized_audio), + static_cast(cResult.synthesized_audio) + cResult.synthesized_audio_size + ); + } + + // Free the C result + rac_voice_agent_result_free(&cResult); + + return result; +} + +std::string VoiceAgentBridge::transcribe(const void* audioData, size_t audioSize) { + if (!handle_) { + throw std::runtime_error("VoiceAgentBridge: Voice agent not created. Call initialize() first."); + } + + char* transcription = nullptr; + rac_result_t result = rac_voice_agent_transcribe( + handle_, + audioData, + audioSize, + &transcription + ); + + if (result != RAC_SUCCESS) { + RAC_LOG_ERROR(LOG_CATEGORY, "Failed to transcribe: %d", result); + throw std::runtime_error("VoiceAgentBridge: Failed to transcribe audio. Error: " + std::to_string(result)); + } + + std::string text; + if (transcription) { + text = transcription; + free(transcription); + } + return text; +} + +std::string VoiceAgentBridge::generateResponse(const std::string& prompt) { + if (!handle_) { + throw std::runtime_error("VoiceAgentBridge: Voice agent not created. Call initialize() first."); + } + + char* response = nullptr; + rac_result_t result = rac_voice_agent_generate_response( + handle_, + prompt.c_str(), + &response + ); + + if (result != RAC_SUCCESS) { + RAC_LOG_ERROR(LOG_CATEGORY, "Failed to generate response: %d", result); + throw std::runtime_error("VoiceAgentBridge: Failed to generate response. Error: " + std::to_string(result)); + } + + std::string text; + if (response) { + text = response; + free(response); + } + return text; +} + +std::vector VoiceAgentBridge::synthesizeSpeech(const std::string& text) { + if (!handle_) { + throw std::runtime_error("VoiceAgentBridge: Voice agent not created. Call initialize() first."); + } + + void* audioData = nullptr; + size_t audioSize = 0; + rac_result_t result = rac_voice_agent_synthesize_speech( + handle_, + text.c_str(), + &audioData, + &audioSize + ); + + if (result != RAC_SUCCESS) { + RAC_LOG_ERROR(LOG_CATEGORY, "Failed to synthesize speech: %d", result); + throw std::runtime_error("VoiceAgentBridge: Failed to synthesize speech. Error: " + std::to_string(result)); + } + + std::vector audio; + if (audioData && audioSize > 0) { + audio.assign( + static_cast(audioData), + static_cast(audioData) + audioSize + ); + free(audioData); + } + return audio; +} + +bool VoiceAgentBridge::detectSpeech(const float* samples, size_t sampleCount) { + if (!handle_) { + throw std::runtime_error("VoiceAgentBridge: Voice agent not created. Call initialize() first."); + } + + rac_bool_t speechDetected = RAC_FALSE; + rac_result_t result = rac_voice_agent_detect_speech( + handle_, + samples, + sampleCount, + &speechDetected + ); + + if (result != RAC_SUCCESS) { + RAC_LOG_ERROR(LOG_CATEGORY, "Failed to detect speech: %d", result); + throw std::runtime_error("VoiceAgentBridge: Failed to detect speech. Error: " + std::to_string(result)); + } + + return speechDetected == RAC_TRUE; +} + +void VoiceAgentBridge::cleanup() { + if (handle_) { + rac_result_t result = rac_voice_agent_cleanup(handle_); + if (result != RAC_SUCCESS) { + RAC_LOG_ERROR(LOG_CATEGORY, "Failed to cleanup voice agent: %d", result); + } + + rac_voice_agent_destroy(handle_); + handle_ = nullptr; + } + initialized_ = false; + RAC_LOG_INFO(LOG_CATEGORY, "Voice agent cleaned up"); +} + +} // namespace bridges +} // namespace runanywhere diff --git a/sdk/runanywhere-react-native/packages/onnx/cpp/bridges/VoiceAgentBridge.hpp b/sdk/runanywhere-react-native/packages/onnx/cpp/bridges/VoiceAgentBridge.hpp new file mode 100644 index 000000000..40fa8e2a6 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/onnx/cpp/bridges/VoiceAgentBridge.hpp @@ -0,0 +1,130 @@ +/** + * @file VoiceAgentBridge.hpp + * @brief Voice Agent bridge for React Native + * + * Matches Swift's CppBridge+VoiceAgent.swift pattern, providing: + * - Full voice pipeline orchestration (STT -> LLM -> TTS) + * - Component state management + * - Audio processing for voice turns + * + * Aligned with rac_voice_agent.h API. + * RACommons is REQUIRED - no stub implementations. + */ + +#pragma once + +#include +#include +#include +#include + +// RACommons voice agent header - REQUIRED (flat include paths) +#include "rac_voice_agent.h" + +namespace runanywhere { +namespace bridges { + +/** + * @brief Voice agent result structure + */ +struct VoiceAgentResult { + bool speechDetected = false; + std::string transcription; + std::string response; + std::vector synthesizedAudio; + int sampleRate = 16000; +}; + +/** + * @brief Component load state + */ +enum class ComponentState { + NotLoaded, + Loading, + Loaded, + Failed +}; + +/** + * @brief Voice agent component states + */ +struct VoiceAgentComponentStates { + ComponentState stt = ComponentState::NotLoaded; + ComponentState llm = ComponentState::NotLoaded; + ComponentState tts = ComponentState::NotLoaded; + std::string sttModelId; + std::string llmModelId; + std::string ttsVoiceId; + + bool isFullyReady() const { + return stt == ComponentState::Loaded && + llm == ComponentState::Loaded && + tts == ComponentState::Loaded; + } +}; + +/** + * @brief Voice agent configuration + */ +struct VoiceAgentConfig { + std::string sttModelId; + std::string llmModelId; + std::string ttsVoiceId; + int vadSampleRate = 16000; + int vadFrameLength = 512; + float vadEnergyThreshold = 0.1f; +}; + +/** + * @brief Voice Agent bridge singleton + * + * Matches CppBridge+VoiceAgent.swift API. + * Orchestrates the full voice pipeline using shared STT, LLM, and TTS components. + * + * NOTE: RACommons is REQUIRED. All methods will throw std::runtime_error if + * the underlying C API calls fail. + */ +class VoiceAgentBridge { +public: + static VoiceAgentBridge& shared(); + + // Lifecycle + rac_result_t initialize(const VoiceAgentConfig& config); + rac_result_t initializeWithLoadedModels(); + bool isReady() const; + VoiceAgentComponentStates getComponentStates() const; + void cleanup(); + + // Model Loading (for standalone voice agent) + rac_result_t loadSTTModel(const std::string& modelPath, + const std::string& modelId = "", + const std::string& modelName = ""); + rac_result_t loadLLMModel(const std::string& modelPath, + const std::string& modelId = "", + const std::string& modelName = ""); + rac_result_t loadTTSVoice(const std::string& voicePath, + const std::string& voiceId = "", + const std::string& voiceName = ""); + + // Voice Processing + VoiceAgentResult processVoiceTurn(const void* audioData, size_t audioSize); + std::string transcribe(const void* audioData, size_t audioSize); + std::string generateResponse(const std::string& prompt); + std::vector synthesizeSpeech(const std::string& text); + bool detectSpeech(const float* samples, size_t sampleCount); + +private: + VoiceAgentBridge(); + ~VoiceAgentBridge(); + + // Disable copy/move + VoiceAgentBridge(const VoiceAgentBridge&) = delete; + VoiceAgentBridge& operator=(const VoiceAgentBridge&) = delete; + + rac_voice_agent_handle_t handle_ = nullptr; + bool initialized_ = false; + VoiceAgentConfig config_; +}; + +} // namespace bridges +} // namespace runanywhere diff --git a/sdk/runanywhere-react-native/packages/onnx/cpp/rac_vad_onnx.h b/sdk/runanywhere-react-native/packages/onnx/cpp/rac_vad_onnx.h new file mode 100644 index 000000000..70aaf65be --- /dev/null +++ b/sdk/runanywhere-react-native/packages/onnx/cpp/rac_vad_onnx.h @@ -0,0 +1,34 @@ +/** + * @file rac_vad_onnx.h + * @brief Backend registration API for ONNX + * + * Forward declarations for ONNX backend registration functions. + * These symbols are exported by RABackendONNX.xcframework. + */ + +#ifndef RAC_VAD_ONNX_H +#define RAC_VAD_ONNX_H + +#include "rac_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Register the ONNX backend with the RACommons service registry. + * @return RAC_SUCCESS on success, RAC_ERROR_MODULE_ALREADY_REGISTERED if already registered + */ +rac_result_t rac_backend_onnx_register(void); + +/** + * Unregister the ONNX backend from the RACommons service registry. + * @return RAC_SUCCESS on success + */ +rac_result_t rac_backend_onnx_unregister(void); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_VAD_ONNX_H */ diff --git a/sdk/runanywhere-react-native/packages/onnx/ios/.testlocal b/sdk/runanywhere-react-native/packages/onnx/ios/.testlocal new file mode 100644 index 000000000..e69de29bb diff --git a/sdk/runanywhere-react-native/packages/onnx/ios/ONNXBackend.podspec b/sdk/runanywhere-react-native/packages/onnx/ios/ONNXBackend.podspec new file mode 100644 index 000000000..d0ed10165 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/onnx/ios/ONNXBackend.podspec @@ -0,0 +1,156 @@ +require "json" + +package = JSON.parse(File.read(File.join(__dir__, "..", "package.json"))) + +# ============================================================================= +# Version Constants (MUST match Swift Package.swift) +# ============================================================================= +CORE_VERSION = "0.1.4" +ONNXRUNTIME_VERSION = "1.17.1" + +# ============================================================================= +# Binary Source - RABackendONNX from runanywhere-sdks +# ============================================================================= +GITHUB_ORG = "RunanywhereAI" +CORE_REPO = "runanywhere-sdks" + +# ============================================================================= +# testLocal Toggle +# Set RA_TEST_LOCAL=1 or create .testlocal file to use local binaries +# ============================================================================= +TEST_LOCAL = ENV['RA_TEST_LOCAL'] == '1' || File.exist?(File.join(__dir__, '.testlocal')) + +Pod::Spec.new do |s| + s.name = "ONNXBackend" + s.module_name = "RunAnywhereONNX" + s.version = package["version"] + s.summary = package["description"] + s.homepage = "https://runanywhere.com" + s.license = package["license"] + s.authors = "RunAnywhere AI" + + s.platforms = { :ios => "15.1" } + s.source = { :git => "https://github.com/RunanywhereAI/sdks.git", :tag => "#{s.version}" } + + # ============================================================================= + # ONNX Backend - RABackendONNX + ONNX Runtime + # Downloads from runanywhere-sdks (NOT runanywhere-sdks) + # ============================================================================= + if TEST_LOCAL + puts "[ONNXBackend] Using LOCAL RABackendONNX from Frameworks/" + s.vendored_frameworks = [ + "Frameworks/RABackendONNX.xcframework", + "Frameworks/onnxruntime.xcframework" + ] + else + s.prepare_command = <<-CMD + set -e + + FRAMEWORK_DIR="Frameworks" + VERSION="#{CORE_VERSION}" + ONNX_VERSION="#{ONNXRUNTIME_VERSION}" + VERSION_FILE="$FRAMEWORK_DIR/.onnx_version" + + # Check if already downloaded with correct version + if [ -f "$VERSION_FILE" ] && [ -d "$FRAMEWORK_DIR/RABackendONNX.xcframework" ]; then + CURRENT_VERSION=$(cat "$VERSION_FILE") + if [ "$CURRENT_VERSION" = "$VERSION" ]; then + echo "✅ RABackendONNX.xcframework version $VERSION already downloaded" + # Still need to check onnxruntime + if [ -d "$FRAMEWORK_DIR/onnxruntime.xcframework" ]; then + exit 0 + fi + fi + fi + + echo "📦 Downloading RABackendONNX.xcframework version $VERSION..." + + mkdir -p "$FRAMEWORK_DIR" + rm -rf "$FRAMEWORK_DIR/RABackendONNX.xcframework" + + # Download RABackendONNX from runanywhere-sdks + DOWNLOAD_URL="https://github.com/#{GITHUB_ORG}/#{CORE_REPO}/releases/download/core-v$VERSION/RABackendONNX-ios-v$VERSION.zip" + ZIP_FILE="/tmp/RABackendONNX.zip" + + echo " URL: $DOWNLOAD_URL" + + curl -L -f -o "$ZIP_FILE" "$DOWNLOAD_URL" || { + echo "❌ Failed to download RABackendONNX from $DOWNLOAD_URL" + exit 1 + } + + echo "📂 Extracting RABackendONNX.xcframework..." + unzip -q -o "$ZIP_FILE" -d "$FRAMEWORK_DIR/" + rm -f "$ZIP_FILE" + + # Download ONNX Runtime if not present + if [ ! -d "$FRAMEWORK_DIR/onnxruntime.xcframework" ]; then + echo "📦 Downloading ONNX Runtime version $ONNX_VERSION..." + ONNX_URL="https://download.onnxruntime.ai/pod-archive-onnxruntime-c-$ONNX_VERSION.zip" + ONNX_ZIP="/tmp/onnxruntime.zip" + + curl -L -f -o "$ONNX_ZIP" "$ONNX_URL" || { + echo "❌ Failed to download ONNX Runtime from $ONNX_URL" + exit 1 + } + + echo "📂 Extracting onnxruntime.xcframework..." + unzip -q -o "$ONNX_ZIP" -d "$FRAMEWORK_DIR/" + rm -f "$ONNX_ZIP" + fi + + echo "$VERSION" > "$VERSION_FILE" + + if [ -d "$FRAMEWORK_DIR/RABackendONNX.xcframework" ] && [ -d "$FRAMEWORK_DIR/onnxruntime.xcframework" ]; then + echo "✅ ONNX frameworks installed successfully" + else + echo "❌ ONNX framework extraction failed" + exit 1 + fi + CMD + + s.vendored_frameworks = [ + "Frameworks/RABackendONNX.xcframework", + "Frameworks/onnxruntime.xcframework" + ] + end + + # Source files - ONNX C++ implementation + s.source_files = [ + "../cpp/HybridRunAnywhereONNX.cpp", + "../cpp/HybridRunAnywhereONNX.hpp", + "../cpp/bridges/**/*.{cpp,hpp}", + ] + + # Build settings + s.pod_target_xcconfig = { + "CLANG_CXX_LANGUAGE_STANDARD" => "c++17", + "HEADER_SEARCH_PATHS" => [ + "$(PODS_TARGET_SRCROOT)/../cpp", + "$(PODS_TARGET_SRCROOT)/../cpp/bridges", + "$(PODS_TARGET_SRCROOT)/Frameworks/RABackendONNX.xcframework/ios-arm64/RABackendONNX.framework/Headers", + "$(PODS_TARGET_SRCROOT)/Frameworks/RABackendONNX.xcframework/ios-arm64_x86_64-simulator/RABackendONNX.framework/Headers", + "$(PODS_TARGET_SRCROOT)/Frameworks/onnxruntime.xcframework/ios-arm64/onnxruntime.framework/Headers", + "$(PODS_TARGET_SRCROOT)/Frameworks/onnxruntime.xcframework/ios-arm64_x86_64-simulator/onnxruntime.framework/Headers", + "$(PODS_ROOT)/Headers/Public", + ].join(" "), + "GCC_PREPROCESSOR_DEFINITIONS" => "$(inherited) HAS_ONNX=1", + "DEFINES_MODULE" => "YES", + "SWIFT_OBJC_INTEROP_MODE" => "objcxx", + } + + # Required system libraries + s.libraries = "c++" + s.frameworks = "Accelerate", "Foundation", "CoreML", "AudioToolbox" + + # Dependencies + s.dependency 'RunAnywhereCore' + s.dependency 'React-jsi' + s.dependency 'React-callinvoker' + + # Load Nitrogen-generated autolinking + load '../nitrogen/generated/ios/RunAnywhereONNX+autolinking.rb' + add_nitrogen_files(s) + + install_modules_dependencies(s) +end diff --git a/sdk/runanywhere-react-native/packages/onnx/nitro.json b/sdk/runanywhere-react-native/packages/onnx/nitro.json new file mode 100644 index 000000000..6f446658d --- /dev/null +++ b/sdk/runanywhere-react-native/packages/onnx/nitro.json @@ -0,0 +1,16 @@ +{ + "cxxNamespace": ["runanywhere", "onnx"], + "ios": { + "iosModuleName": "RunAnywhereONNX" + }, + "android": { + "androidNamespace": ["runanywhere", "onnx"], + "androidCxxLibName": "runanywhereonnx" + }, + "autolinking": { + "RunAnywhereONNX": { + "cpp": "HybridRunAnywhereONNX" + } + }, + "ignorePaths": ["node_modules", "lib", "example"] +} diff --git a/sdk/runanywhere-react-native/packages/onnx/nitrogen/generated/.gitattributes b/sdk/runanywhere-react-native/packages/onnx/nitrogen/generated/.gitattributes new file mode 100644 index 000000000..fb7a0d5a3 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/onnx/nitrogen/generated/.gitattributes @@ -0,0 +1 @@ +** linguist-generated=true diff --git a/sdk/runanywhere-react-native/packages/onnx/nitrogen/generated/android/kotlin/com/margelo/nitro/runanywhere/onnx/runanywhereonnxOnLoad.kt b/sdk/runanywhere-react-native/packages/onnx/nitrogen/generated/android/kotlin/com/margelo/nitro/runanywhere/onnx/runanywhereonnxOnLoad.kt new file mode 100644 index 000000000..e7ca108e3 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/onnx/nitrogen/generated/android/kotlin/com/margelo/nitro/runanywhere/onnx/runanywhereonnxOnLoad.kt @@ -0,0 +1,35 @@ +/// +/// runanywhereonnxOnLoad.kt +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2026 Marc Rousavy @ Margelo +/// + +package com.margelo.nitro.runanywhere.onnx + +import android.util.Log + +internal class runanywhereonnxOnLoad { + companion object { + private const val TAG = "runanywhereonnxOnLoad" + private var didLoad = false + /** + * Initializes the native part of "runanywhereonnx". + * This method is idempotent and can be called more than once. + */ + @JvmStatic + fun initializeNative() { + if (didLoad) return + try { + Log.i(TAG, "Loading runanywhereonnx C++ library...") + System.loadLibrary("runanywhereonnx") + Log.i(TAG, "Successfully loaded runanywhereonnx C++ library!") + didLoad = true + } catch (e: Error) { + Log.e(TAG, "Failed to load runanywhereonnx C++ library! Is it properly installed and linked? " + + "Is the name correct? (see `CMakeLists.txt`, at `add_library(...)`)", e) + throw e + } + } + } +} diff --git a/sdk/runanywhere-react-native/packages/onnx/nitrogen/generated/android/runanywhereonnx+autolinking.cmake b/sdk/runanywhere-react-native/packages/onnx/nitrogen/generated/android/runanywhereonnx+autolinking.cmake new file mode 100644 index 000000000..834f62d73 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/onnx/nitrogen/generated/android/runanywhereonnx+autolinking.cmake @@ -0,0 +1,81 @@ +# +# runanywhereonnx+autolinking.cmake +# This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +# https://github.com/mrousavy/nitro +# Copyright © 2026 Marc Rousavy @ Margelo +# + +# This is a CMake file that adds all files generated by Nitrogen +# to the current CMake project. +# +# To use it, add this to your CMakeLists.txt: +# ```cmake +# include(${CMAKE_SOURCE_DIR}/../nitrogen/generated/android/runanywhereonnx+autolinking.cmake) +# ``` + +# Define a flag to check if we are building properly +add_definitions(-DBUILDING_RUNANYWHEREONNX_WITH_GENERATED_CMAKE_PROJECT) + +# Enable Raw Props parsing in react-native (for Nitro Views) +add_definitions(-DRN_SERIALIZABLE_STATE) + +# Add all headers that were generated by Nitrogen +include_directories( + "../nitrogen/generated/shared/c++" + "../nitrogen/generated/android/c++" + "../nitrogen/generated/android/" +) + +# Add all .cpp sources that were generated by Nitrogen +target_sources( + # CMake project name (Android C++ library name) + runanywhereonnx PRIVATE + # Autolinking Setup + ../nitrogen/generated/android/runanywhereonnxOnLoad.cpp + # Shared Nitrogen C++ sources + ../nitrogen/generated/shared/c++/HybridRunAnywhereONNXSpec.cpp + # Android-specific Nitrogen C++ sources + +) + +# From node_modules/react-native/ReactAndroid/cmake-utils/folly-flags.cmake +# Used in node_modules/react-native/ReactAndroid/cmake-utils/ReactNative-application.cmake +target_compile_definitions( + runanywhereonnx PRIVATE + -DFOLLY_NO_CONFIG=1 + -DFOLLY_HAVE_CLOCK_GETTIME=1 + -DFOLLY_USE_LIBCPP=1 + -DFOLLY_CFG_NO_COROUTINES=1 + -DFOLLY_MOBILE=1 + -DFOLLY_HAVE_RECVMMSG=1 + -DFOLLY_HAVE_PTHREAD=1 + # Once we target android-23 above, we can comment + # the following line. NDK uses GNU style stderror_r() after API 23. + -DFOLLY_HAVE_XSI_STRERROR_R=1 +) + +# Add all libraries required by the generated specs +find_package(fbjni REQUIRED) # <-- Used for communication between Java <-> C++ +find_package(ReactAndroid REQUIRED) # <-- Used to set up React Native bindings (e.g. CallInvoker/TurboModule) +find_package(react-native-nitro-modules REQUIRED) # <-- Used to create all HybridObjects and use the Nitro core library + +# Link all libraries together +target_link_libraries( + runanywhereonnx + fbjni::fbjni # <-- Facebook C++ JNI helpers + ReactAndroid::jsi # <-- RN: JSI + react-native-nitro-modules::NitroModules # <-- NitroModules Core :) +) + +# Link react-native (different prefab between RN 0.75 and RN 0.76) +if(ReactAndroid_VERSION_MINOR GREATER_EQUAL 76) + target_link_libraries( + runanywhereonnx + ReactAndroid::reactnative # <-- RN: Native Modules umbrella prefab + ) +else() + target_link_libraries( + runanywhereonnx + ReactAndroid::react_nativemodule_core # <-- RN: TurboModules Core + ) +endif() diff --git a/sdk/runanywhere-react-native/packages/onnx/nitrogen/generated/android/runanywhereonnx+autolinking.gradle b/sdk/runanywhere-react-native/packages/onnx/nitrogen/generated/android/runanywhereonnx+autolinking.gradle new file mode 100644 index 000000000..1483d7b5d --- /dev/null +++ b/sdk/runanywhere-react-native/packages/onnx/nitrogen/generated/android/runanywhereonnx+autolinking.gradle @@ -0,0 +1,27 @@ +/// +/// runanywhereonnx+autolinking.gradle +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2026 Marc Rousavy @ Margelo +/// + +/// This is a Gradle file that adds all files generated by Nitrogen +/// to the current Gradle project. +/// +/// To use it, add this to your build.gradle: +/// ```gradle +/// apply from: '../nitrogen/generated/android/runanywhereonnx+autolinking.gradle' +/// ``` + +logger.warn("[NitroModules] 🔥 runanywhereonnx is boosted by nitro!") + +android { + sourceSets { + main { + java.srcDirs += [ + // Nitrogen files + "${project.projectDir}/../nitrogen/generated/android/kotlin" + ] + } + } +} diff --git a/sdk/runanywhere-react-native/packages/onnx/nitrogen/generated/android/runanywhereonnxOnLoad.cpp b/sdk/runanywhere-react-native/packages/onnx/nitrogen/generated/android/runanywhereonnxOnLoad.cpp new file mode 100644 index 000000000..c23e51f7d --- /dev/null +++ b/sdk/runanywhere-react-native/packages/onnx/nitrogen/generated/android/runanywhereonnxOnLoad.cpp @@ -0,0 +1,44 @@ +/// +/// runanywhereonnxOnLoad.cpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2026 Marc Rousavy @ Margelo +/// + +#ifndef BUILDING_RUNANYWHEREONNX_WITH_GENERATED_CMAKE_PROJECT +#error runanywhereonnxOnLoad.cpp is not being built with the autogenerated CMakeLists.txt project. Is a different CMakeLists.txt building this? +#endif + +#include "runanywhereonnxOnLoad.hpp" + +#include +#include +#include + +#include "HybridRunAnywhereONNX.hpp" + +namespace margelo::nitro::runanywhere::onnx { + +int initialize(JavaVM* vm) { + using namespace margelo::nitro; + using namespace margelo::nitro::runanywhere::onnx; + using namespace facebook; + + return facebook::jni::initialize(vm, [] { + // Register native JNI methods + + + // Register Nitro Hybrid Objects + HybridObjectRegistry::registerHybridObjectConstructor( + "RunAnywhereONNX", + []() -> std::shared_ptr { + static_assert(std::is_default_constructible_v, + "The HybridObject \"HybridRunAnywhereONNX\" is not default-constructible! " + "Create a public constructor that takes zero arguments to be able to autolink this HybridObject."); + return std::make_shared(); + } + ); + }); +} + +} // namespace margelo::nitro::runanywhere::onnx diff --git a/sdk/runanywhere-react-native/packages/onnx/nitrogen/generated/android/runanywhereonnxOnLoad.hpp b/sdk/runanywhere-react-native/packages/onnx/nitrogen/generated/android/runanywhereonnxOnLoad.hpp new file mode 100644 index 000000000..7f07c5207 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/onnx/nitrogen/generated/android/runanywhereonnxOnLoad.hpp @@ -0,0 +1,25 @@ +/// +/// runanywhereonnxOnLoad.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2026 Marc Rousavy @ Margelo +/// + +#include +#include + +namespace margelo::nitro::runanywhere::onnx { + + /** + * Initializes the native (C++) part of runanywhereonnx, and autolinks all Hybrid Objects. + * Call this in your `JNI_OnLoad` function (probably inside `cpp-adapter.cpp`). + * Example: + * ```cpp (cpp-adapter.cpp) + * JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void*) { + * return margelo::nitro::runanywhere::onnx::initialize(vm); + * } + * ``` + */ + int initialize(JavaVM* vm); + +} // namespace margelo::nitro::runanywhere::onnx diff --git a/sdk/runanywhere-react-native/packages/onnx/nitrogen/generated/ios/RunAnywhereONNX+autolinking.rb b/sdk/runanywhere-react-native/packages/onnx/nitrogen/generated/ios/RunAnywhereONNX+autolinking.rb new file mode 100644 index 000000000..230e0b06a --- /dev/null +++ b/sdk/runanywhere-react-native/packages/onnx/nitrogen/generated/ios/RunAnywhereONNX+autolinking.rb @@ -0,0 +1,60 @@ +# +# RunAnywhereONNX+autolinking.rb +# This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +# https://github.com/mrousavy/nitro +# Copyright © 2026 Marc Rousavy @ Margelo +# + +# This is a Ruby script that adds all files generated by Nitrogen +# to the given podspec. +# +# To use it, add this to your .podspec: +# ```ruby +# Pod::Spec.new do |spec| +# # ... +# +# # Add all files generated by Nitrogen +# load 'nitrogen/generated/ios/RunAnywhereONNX+autolinking.rb' +# add_nitrogen_files(spec) +# end +# ``` + +def add_nitrogen_files(spec) + Pod::UI.puts "[NitroModules] 🔥 RunAnywhereONNX is boosted by nitro!" + + spec.dependency "NitroModules" + + current_source_files = Array(spec.attributes_hash['source_files']) + spec.source_files = current_source_files + [ + # Generated cross-platform specs + "nitrogen/generated/shared/**/*.{h,hpp,c,cpp,swift}", + # Generated bridges for the cross-platform specs + "nitrogen/generated/ios/**/*.{h,hpp,c,cpp,mm,swift}", + ] + + current_public_header_files = Array(spec.attributes_hash['public_header_files']) + spec.public_header_files = current_public_header_files + [ + # Generated specs + "nitrogen/generated/shared/**/*.{h,hpp}", + # Swift to C++ bridging helpers + "nitrogen/generated/ios/RunAnywhereONNX-Swift-Cxx-Bridge.hpp" + ] + + current_private_header_files = Array(spec.attributes_hash['private_header_files']) + spec.private_header_files = current_private_header_files + [ + # iOS specific specs + "nitrogen/generated/ios/c++/**/*.{h,hpp}", + # Views are framework-specific and should be private + "nitrogen/generated/shared/**/views/**/*" + ] + + current_pod_target_xcconfig = spec.attributes_hash['pod_target_xcconfig'] || {} + spec.pod_target_xcconfig = current_pod_target_xcconfig.merge({ + # Use C++ 20 + "CLANG_CXX_LANGUAGE_STANDARD" => "c++20", + # Enables C++ <-> Swift interop (by default it's only C) + "SWIFT_OBJC_INTEROP_MODE" => "objcxx", + # Enables stricter modular headers + "DEFINES_MODULE" => "YES", + }) +end diff --git a/sdk/runanywhere-react-native/packages/onnx/nitrogen/generated/ios/RunAnywhereONNX-Swift-Cxx-Bridge.cpp b/sdk/runanywhere-react-native/packages/onnx/nitrogen/generated/ios/RunAnywhereONNX-Swift-Cxx-Bridge.cpp new file mode 100644 index 000000000..b91d898ba --- /dev/null +++ b/sdk/runanywhere-react-native/packages/onnx/nitrogen/generated/ios/RunAnywhereONNX-Swift-Cxx-Bridge.cpp @@ -0,0 +1,17 @@ +/// +/// RunAnywhereONNX-Swift-Cxx-Bridge.cpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2026 Marc Rousavy @ Margelo +/// + +#include "RunAnywhereONNX-Swift-Cxx-Bridge.hpp" + +// Include C++ implementation defined types + + +namespace margelo::nitro::runanywhere::onnx::bridge::swift { + + + +} // namespace margelo::nitro::runanywhere::onnx::bridge::swift diff --git a/sdk/runanywhere-react-native/packages/onnx/nitrogen/generated/ios/RunAnywhereONNX-Swift-Cxx-Bridge.hpp b/sdk/runanywhere-react-native/packages/onnx/nitrogen/generated/ios/RunAnywhereONNX-Swift-Cxx-Bridge.hpp new file mode 100644 index 000000000..f4bdb80dc --- /dev/null +++ b/sdk/runanywhere-react-native/packages/onnx/nitrogen/generated/ios/RunAnywhereONNX-Swift-Cxx-Bridge.hpp @@ -0,0 +1,27 @@ +/// +/// RunAnywhereONNX-Swift-Cxx-Bridge.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2026 Marc Rousavy @ Margelo +/// + +#pragma once + +// Forward declarations of C++ defined types + + +// Forward declarations of Swift defined types + + +// Include C++ defined types + + +/** + * Contains specialized versions of C++ templated types so they can be accessed from Swift, + * as well as helper functions to interact with those C++ types from Swift. + */ +namespace margelo::nitro::runanywhere::onnx::bridge::swift { + + + +} // namespace margelo::nitro::runanywhere::onnx::bridge::swift diff --git a/sdk/runanywhere-react-native/packages/onnx/nitrogen/generated/ios/RunAnywhereONNX-Swift-Cxx-Umbrella.hpp b/sdk/runanywhere-react-native/packages/onnx/nitrogen/generated/ios/RunAnywhereONNX-Swift-Cxx-Umbrella.hpp new file mode 100644 index 000000000..003e7e454 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/onnx/nitrogen/generated/ios/RunAnywhereONNX-Swift-Cxx-Umbrella.hpp @@ -0,0 +1,38 @@ +/// +/// RunAnywhereONNX-Swift-Cxx-Umbrella.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2026 Marc Rousavy @ Margelo +/// + +#pragma once + +// Forward declarations of C++ defined types + + +// Include C++ defined types + + +// C++ helpers for Swift +#include "RunAnywhereONNX-Swift-Cxx-Bridge.hpp" + +// Common C++ types used in Swift +#include +#include +#include +#include + +// Forward declarations of Swift defined types + + +// Include Swift defined types +#if __has_include("RunAnywhereONNX-Swift.h") +// This header is generated by Xcode/Swift on every app build. +// If it cannot be found, make sure the Swift module's name (= podspec name) is actually "RunAnywhereONNX". +#include "RunAnywhereONNX-Swift.h" +// Same as above, but used when building with frameworks (`use_frameworks`) +#elif __has_include() +#include +#else +#error RunAnywhereONNX's autogenerated Swift header cannot be found! Make sure the Swift module's name (= podspec name) is actually "RunAnywhereONNX", and try building the app first. +#endif diff --git a/sdk/runanywhere-react-native/packages/onnx/nitrogen/generated/ios/RunAnywhereONNXAutolinking.mm b/sdk/runanywhere-react-native/packages/onnx/nitrogen/generated/ios/RunAnywhereONNXAutolinking.mm new file mode 100644 index 000000000..887e90e1f --- /dev/null +++ b/sdk/runanywhere-react-native/packages/onnx/nitrogen/generated/ios/RunAnywhereONNXAutolinking.mm @@ -0,0 +1,35 @@ +/// +/// RunAnywhereONNXAutolinking.mm +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2026 Marc Rousavy @ Margelo +/// + +#import +#import + +#import + +#include "HybridRunAnywhereONNX.hpp" + +@interface RunAnywhereONNXAutolinking : NSObject +@end + +@implementation RunAnywhereONNXAutolinking + ++ (void) load { + using namespace margelo::nitro; + using namespace margelo::nitro::runanywhere::onnx; + + HybridObjectRegistry::registerHybridObjectConstructor( + "RunAnywhereONNX", + []() -> std::shared_ptr { + static_assert(std::is_default_constructible_v, + "The HybridObject \"HybridRunAnywhereONNX\" is not default-constructible! " + "Create a public constructor that takes zero arguments to be able to autolink this HybridObject."); + return std::make_shared(); + } + ); +} + +@end diff --git a/sdk/runanywhere-react-native/packages/onnx/nitrogen/generated/ios/RunAnywhereONNXAutolinking.swift b/sdk/runanywhere-react-native/packages/onnx/nitrogen/generated/ios/RunAnywhereONNXAutolinking.swift new file mode 100644 index 000000000..597a8455e --- /dev/null +++ b/sdk/runanywhere-react-native/packages/onnx/nitrogen/generated/ios/RunAnywhereONNXAutolinking.swift @@ -0,0 +1,12 @@ +/// +/// RunAnywhereONNXAutolinking.swift +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2026 Marc Rousavy @ Margelo +/// + +public final class RunAnywhereONNXAutolinking { + public typealias bridge = margelo.nitro.runanywhere.onnx.bridge.swift + + +} diff --git a/sdk/runanywhere-react-native/packages/onnx/nitrogen/generated/shared/c++/HybridRunAnywhereONNXSpec.cpp b/sdk/runanywhere-react-native/packages/onnx/nitrogen/generated/shared/c++/HybridRunAnywhereONNXSpec.cpp new file mode 100644 index 000000000..192b9f768 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/onnx/nitrogen/generated/shared/c++/HybridRunAnywhereONNXSpec.cpp @@ -0,0 +1,49 @@ +/// +/// HybridRunAnywhereONNXSpec.cpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2026 Marc Rousavy @ Margelo +/// + +#include "HybridRunAnywhereONNXSpec.hpp" + +namespace margelo::nitro::runanywhere::onnx { + + void HybridRunAnywhereONNXSpec::loadHybridMethods() { + // load base methods/properties + HybridObject::loadHybridMethods(); + // load custom methods/properties + registerHybrids(this, [](Prototype& prototype) { + prototype.registerHybridMethod("registerBackend", &HybridRunAnywhereONNXSpec::registerBackend); + prototype.registerHybridMethod("unregisterBackend", &HybridRunAnywhereONNXSpec::unregisterBackend); + prototype.registerHybridMethod("isBackendRegistered", &HybridRunAnywhereONNXSpec::isBackendRegistered); + prototype.registerHybridMethod("loadSTTModel", &HybridRunAnywhereONNXSpec::loadSTTModel); + prototype.registerHybridMethod("isSTTModelLoaded", &HybridRunAnywhereONNXSpec::isSTTModelLoaded); + prototype.registerHybridMethod("unloadSTTModel", &HybridRunAnywhereONNXSpec::unloadSTTModel); + prototype.registerHybridMethod("transcribe", &HybridRunAnywhereONNXSpec::transcribe); + prototype.registerHybridMethod("transcribeFile", &HybridRunAnywhereONNXSpec::transcribeFile); + prototype.registerHybridMethod("supportsSTTStreaming", &HybridRunAnywhereONNXSpec::supportsSTTStreaming); + prototype.registerHybridMethod("loadTTSModel", &HybridRunAnywhereONNXSpec::loadTTSModel); + prototype.registerHybridMethod("isTTSModelLoaded", &HybridRunAnywhereONNXSpec::isTTSModelLoaded); + prototype.registerHybridMethod("unloadTTSModel", &HybridRunAnywhereONNXSpec::unloadTTSModel); + prototype.registerHybridMethod("synthesize", &HybridRunAnywhereONNXSpec::synthesize); + prototype.registerHybridMethod("getTTSVoices", &HybridRunAnywhereONNXSpec::getTTSVoices); + prototype.registerHybridMethod("loadVADModel", &HybridRunAnywhereONNXSpec::loadVADModel); + prototype.registerHybridMethod("isVADModelLoaded", &HybridRunAnywhereONNXSpec::isVADModelLoaded); + prototype.registerHybridMethod("unloadVADModel", &HybridRunAnywhereONNXSpec::unloadVADModel); + prototype.registerHybridMethod("processVAD", &HybridRunAnywhereONNXSpec::processVAD); + prototype.registerHybridMethod("resetVAD", &HybridRunAnywhereONNXSpec::resetVAD); + prototype.registerHybridMethod("initializeVAD", &HybridRunAnywhereONNXSpec::initializeVAD); + prototype.registerHybridMethod("cleanupVAD", &HybridRunAnywhereONNXSpec::cleanupVAD); + prototype.registerHybridMethod("startVAD", &HybridRunAnywhereONNXSpec::startVAD); + prototype.registerHybridMethod("stopVAD", &HybridRunAnywhereONNXSpec::stopVAD); + prototype.registerHybridMethod("initializeVoiceAgent", &HybridRunAnywhereONNXSpec::initializeVoiceAgent); + prototype.registerHybridMethod("isVoiceAgentReady", &HybridRunAnywhereONNXSpec::isVoiceAgentReady); + prototype.registerHybridMethod("processVoiceTurn", &HybridRunAnywhereONNXSpec::processVoiceTurn); + prototype.registerHybridMethod("cleanupVoiceAgent", &HybridRunAnywhereONNXSpec::cleanupVoiceAgent); + prototype.registerHybridMethod("getLastError", &HybridRunAnywhereONNXSpec::getLastError); + prototype.registerHybridMethod("getMemoryUsage", &HybridRunAnywhereONNXSpec::getMemoryUsage); + }); + } + +} // namespace margelo::nitro::runanywhere::onnx diff --git a/sdk/runanywhere-react-native/packages/onnx/nitrogen/generated/shared/c++/HybridRunAnywhereONNXSpec.hpp b/sdk/runanywhere-react-native/packages/onnx/nitrogen/generated/shared/c++/HybridRunAnywhereONNXSpec.hpp new file mode 100644 index 000000000..fe8004fe5 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/onnx/nitrogen/generated/shared/c++/HybridRunAnywhereONNXSpec.hpp @@ -0,0 +1,92 @@ +/// +/// HybridRunAnywhereONNXSpec.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2026 Marc Rousavy @ Margelo +/// + +#pragma once + +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif + + + +#include +#include +#include + +namespace margelo::nitro::runanywhere::onnx { + + using namespace margelo::nitro; + + /** + * An abstract base class for `RunAnywhereONNX` + * Inherit this class to create instances of `HybridRunAnywhereONNXSpec` in C++. + * You must explicitly call `HybridObject`'s constructor yourself, because it is virtual. + * @example + * ```cpp + * class HybridRunAnywhereONNX: public HybridRunAnywhereONNXSpec { + * public: + * HybridRunAnywhereONNX(...): HybridObject(TAG) { ... } + * // ... + * }; + * ``` + */ + class HybridRunAnywhereONNXSpec: public virtual HybridObject { + public: + // Constructor + explicit HybridRunAnywhereONNXSpec(): HybridObject(TAG) { } + + // Destructor + ~HybridRunAnywhereONNXSpec() override = default; + + public: + // Properties + + + public: + // Methods + virtual std::shared_ptr> registerBackend() = 0; + virtual std::shared_ptr> unregisterBackend() = 0; + virtual std::shared_ptr> isBackendRegistered() = 0; + virtual std::shared_ptr> loadSTTModel(const std::string& path, const std::string& modelType, const std::optional& configJson) = 0; + virtual std::shared_ptr> isSTTModelLoaded() = 0; + virtual std::shared_ptr> unloadSTTModel() = 0; + virtual std::shared_ptr> transcribe(const std::string& audioBase64, double sampleRate, const std::optional& language) = 0; + virtual std::shared_ptr> transcribeFile(const std::string& filePath, const std::optional& language) = 0; + virtual std::shared_ptr> supportsSTTStreaming() = 0; + virtual std::shared_ptr> loadTTSModel(const std::string& path, const std::string& modelType, const std::optional& configJson) = 0; + virtual std::shared_ptr> isTTSModelLoaded() = 0; + virtual std::shared_ptr> unloadTTSModel() = 0; + virtual std::shared_ptr> synthesize(const std::string& text, const std::string& voiceId, double speedRate, double pitchShift) = 0; + virtual std::shared_ptr> getTTSVoices() = 0; + virtual std::shared_ptr> loadVADModel(const std::string& path, const std::optional& configJson) = 0; + virtual std::shared_ptr> isVADModelLoaded() = 0; + virtual std::shared_ptr> unloadVADModel() = 0; + virtual std::shared_ptr> processVAD(const std::string& audioBase64, const std::optional& optionsJson) = 0; + virtual std::shared_ptr> resetVAD() = 0; + virtual std::shared_ptr> initializeVAD(const std::optional& configJson) = 0; + virtual std::shared_ptr> cleanupVAD() = 0; + virtual std::shared_ptr> startVAD() = 0; + virtual std::shared_ptr> stopVAD() = 0; + virtual std::shared_ptr> initializeVoiceAgent(const std::string& configJson) = 0; + virtual std::shared_ptr> isVoiceAgentReady() = 0; + virtual std::shared_ptr> processVoiceTurn(const std::string& audioBase64) = 0; + virtual std::shared_ptr> cleanupVoiceAgent() = 0; + virtual std::shared_ptr> getLastError() = 0; + virtual std::shared_ptr> getMemoryUsage() = 0; + + protected: + // Hybrid Setup + void loadHybridMethods() override; + + protected: + // Tag for logging + static constexpr auto TAG = "RunAnywhereONNX"; + }; + +} // namespace margelo::nitro::runanywhere::onnx diff --git a/sdk/runanywhere-react-native/packages/onnx/package.json b/sdk/runanywhere-react-native/packages/onnx/package.json new file mode 100644 index 000000000..e65676444 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/onnx/package.json @@ -0,0 +1,64 @@ +{ + "name": "@runanywhere/onnx", + "version": "0.17.6", + "description": "ONNX Runtime backend for RunAnywhere React Native SDK - Speech-to-Text, Text-to-Speech, and VAD", + "main": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": { + "source": "./src/index.ts", + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./package.json": "./package.json" + }, + "react-native": "src/index", + "source": "src/index", + "files": [ + "src", + "cpp", + "ios", + "android", + "nitrogen", + "nitro.json", + "react-native.config.js", + "*.podspec" + ], + "scripts": { + "typecheck": "tsc --noEmit", + "lint": "eslint \"src/**/*.ts\"", + "lint:fix": "eslint \"src/**/*.ts\" --fix", + "nitrogen": "nitrogen", + "prepare": "nitrogen" + }, + "keywords": [ + "react-native", + "runanywhere", + "onnx", + "stt", + "tts", + "vad", + "speech-to-text", + "text-to-speech", + "voice-activity-detection", + "sherpa-onnx", + "nitro", + "expo" + ], + "license": "MIT", + "peerDependencies": { + "@runanywhere/core": ">=0.16.0", + "react": ">=18.0.0", + "react-native": ">=0.74.0", + "react-native-nitro-modules": ">=0.31.3" + }, + "devDependencies": { + "nitrogen": "^0.31.10", + "react-native-nitro-modules": "^0.31.10", + "typescript": "~5.9.2" + }, + "create-react-native-library": { + "languages": "kotlin-swift", + "type": "nitro-module" + } +} diff --git a/sdk/runanywhere-react-native/packages/onnx/react-native.config.js b/sdk/runanywhere-react-native/packages/onnx/react-native.config.js new file mode 100644 index 000000000..6d6493899 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/onnx/react-native.config.js @@ -0,0 +1,14 @@ +module.exports = { + dependency: { + platforms: { + android: { + sourceDir: './android', + packageImportPath: 'import com.margelo.nitro.runanywhere.onnx.RunAnywhereONNXPackage;', + packageInstance: 'new RunAnywhereONNXPackage()', + }, + ios: { + podspecPath: './RunAnywhereONNX.podspec', + }, + }, + }, +}; diff --git a/sdk/runanywhere-react-native/packages/onnx/src/ONNX.ts b/sdk/runanywhere-react-native/packages/onnx/src/ONNX.ts new file mode 100644 index 000000000..345ec60b3 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/onnx/src/ONNX.ts @@ -0,0 +1,250 @@ +/** + * @runanywhere/onnx - ONNX Runtime Module + * + * ONNX Runtime module wrapper for RunAnywhere React Native SDK. + * Provides public API for module registration and model declaration. + * + * This mirrors the Swift SDK's ONNX module pattern: + * - ONNX.register() - Register the module with ServiceRegistry + * - ONNX.addModel() - Declare a model for this module + * + * Reference: sdk/runanywhere-swift/Sources/ONNXRuntime/ONNXServiceProvider.swift + */ + +import { ONNXProvider } from './ONNXProvider'; +import { + ModelRegistry, + FileSystem, + LLMFramework, + ModelCategory, + ModelFormat, + ConfigurationSource, + SDKLogger, + type ModelInfo, +} from '@runanywhere/core'; + +// Use SDKLogger with ONNX category +const logger = new SDKLogger('ONNX'); + +/** + * Model artifact type for ONNX models + * + * Matches iOS: ModelArtifactType enum + */ +export enum ModelArtifactType { + /** Single file model */ + SingleFile = 'singleFile', + /** Tar.gz archive with nested directory structure */ + TarGzArchive = 'tarGzArchive', + /** Tar.bz2 archive with nested directory structure */ + TarBz2Archive = 'tarBz2Archive', + /** ZIP archive */ + ZipArchive = 'zipArchive', +} + +/** + * Model registration options for ONNX models + * + * Matches iOS: ONNX.addModel() parameter structure + */ +export interface ONNXModelOptions { + /** Unique model ID. If not provided, generated from URL filename */ + id?: string; + /** Display name for the model */ + name: string; + /** Download URL for the model */ + url: string; + /** Model category (STT or TTS) */ + modality: ModelCategory; + /** How the model is packaged (inferred from URL if not specified) */ + artifactType?: ModelArtifactType; + /** Memory requirement in bytes */ + memoryRequirement?: number; +} + +/** + * ONNX Runtime Module + * + * Public API for registering ONNX module and declaring STT/TTS models. + * This provides the same developer experience as the iOS SDK. + * + * ## Usage + * + * ```typescript + * import { ONNX, ModelArtifactType } from '@runanywhere/onnx'; + * import { ModelCategory } from '@runanywhere/core'; + * + * // Register module + * ONNX.register(); + * + * // Add STT model + * ONNX.addModel({ + * id: 'sherpa-onnx-whisper-tiny.en', + * name: 'Sherpa Whisper Tiny (ONNX)', + * url: 'https://github.com/RunanywhereAI/sherpa-onnx/releases/.../sherpa-onnx-whisper-tiny.en.tar.gz', + * modality: ModelCategory.SpeechRecognition, + * artifactType: ModelArtifactType.TarGzArchive, + * memoryRequirement: 75_000_000 + * }); + * + * // Add TTS model + * ONNX.addModel({ + * id: 'vits-piper-en_US-lessac-medium', + * name: 'Piper TTS (US English - Medium)', + * url: 'https://github.com/RunanywhereAI/sherpa-onnx/releases/.../vits-piper-en_US-lessac-medium.tar.gz', + * modality: ModelCategory.SpeechSynthesis, + * memoryRequirement: 65_000_000 + * }); + * ``` + * + * Matches iOS: public enum ONNX: RunAnywhereModule + */ +export const ONNX = { + /** + * Module metadata + * Matches iOS: static let moduleId, moduleName, inferenceFramework + */ + moduleId: 'onnx', + moduleName: 'ONNX Runtime', + inferenceFramework: LLMFramework.ONNX, + capabilities: ['stt', 'tts'] as const, + defaultPriority: 100, + + /** + * Register ONNX module with the SDK + * + * This registers both ONNX STT and TTS providers with ServiceRegistry, + * enabling them to handle Sherpa-ONNX and Piper models. + * + * Matches iOS: static func register(priority: Int = defaultPriority) + * + * @example + * ```typescript + * ONNX.register(); + * ``` + */ + register(): void { + logger.info('Registering ONNX module (STT + TTS)'); + ONNXProvider.register(); + logger.info('ONNX module registered (STT + TTS)'); + }, + + /** + * Add a model to this module + * + * Registers an ONNX model (STT or TTS) with the ModelRegistry. + * The model will use ONNX framework automatically. + * + * Matches iOS: static func addModel(id:name:url:modality:artifactType:memoryRequirement:) + * + * @param options - Model registration options + * @returns Promise resolving to the created ModelInfo + * + * @example + * ```typescript + * // STT Model + * await ONNX.addModel({ + * id: 'sherpa-onnx-whisper-small.en', + * name: 'Sherpa Whisper Small (ONNX)', + * url: 'https://github.com/k2-fsa/sherpa-onnx/releases/.../sherpa-onnx-whisper-small.en.tar.bz2', + * modality: ModelCategory.SpeechRecognition, + * artifactType: ModelArtifactType.TarBz2Archive, + * memoryRequirement: 250_000_000 + * }); + * ``` + */ + async addModel(options: ONNXModelOptions): Promise { + // Generate stable ID from URL if not provided + const modelId = options.id ?? this._generateModelId(options.url); + + // Format is always ONNX for this module + const format = ModelFormat.ONNX; + + const now = new Date().toISOString(); + + // Check if model already exists on disk (persistence across sessions) + let isDownloaded = false; + let localPath: string | undefined; + + if (FileSystem.isAvailable()) { + try { + const exists = await FileSystem.modelExists(modelId, 'ONNX'); + if (exists) { + localPath = await FileSystem.getModelPath(modelId, 'ONNX'); + isDownloaded = true; + logger.info(`Model ${modelId} found on disk: ${localPath}`); + } + } catch (error) { + // Ignore errors checking for existing model + logger.debug(`Could not check for existing model ${modelId}: ${error}`); + } + } + + const modelInfo: ModelInfo = { + id: modelId, + name: options.name, + category: options.modality, + format, + downloadURL: options.url, + localPath, + downloadSize: undefined, + memoryRequired: options.memoryRequirement, + compatibleFrameworks: [LLMFramework.ONNX], + preferredFramework: LLMFramework.ONNX, + supportsThinking: false, // ONNX STT/TTS models don't support thinking + metadata: { + tags: [], + }, + source: ConfigurationSource.Local, + createdAt: now, + updatedAt: now, + syncPending: false, + usageCount: 0, + isDownloaded, + isAvailable: true, + }; + + // Register with ModelRegistry and wait for completion + await ModelRegistry.registerModel(modelInfo); + + logger.info(`Added model: ${modelId} (${options.name})${isDownloaded ? ' [already downloaded]' : ''}`); + + return modelInfo; + }, + + /** + * Generate a stable model ID from URL + * @internal + */ + _generateModelId(url: string): string { + try { + const urlObj = new URL(url); + const pathname = urlObj.pathname; + const filename = pathname.split('/').pop() ?? 'model'; + // Remove common archive extensions + return filename.replace(/\.(tar\.gz|tar\.bz2|zip|onnx)$/i, ''); + } catch { + // Fallback for invalid URLs + return `model-${Date.now()}`; + } + }, + + /** + * Infer artifact type from URL + * @internal + */ + _inferArtifactType(url: string): ModelArtifactType { + const lowercased = url.toLowerCase(); + + if (lowercased.includes('.tar.gz')) { + return ModelArtifactType.TarGzArchive; + } else if (lowercased.includes('.tar.bz2')) { + return ModelArtifactType.TarBz2Archive; + } else if (lowercased.includes('.zip')) { + return ModelArtifactType.ZipArchive; + } + + // Default to single file + return ModelArtifactType.SingleFile; + }, +}; diff --git a/sdk/runanywhere-react-native/packages/onnx/src/ONNXProvider.ts b/sdk/runanywhere-react-native/packages/onnx/src/ONNXProvider.ts new file mode 100644 index 000000000..e35b8db07 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/onnx/src/ONNXProvider.ts @@ -0,0 +1,170 @@ +/** + * @runanywhere/onnx - ONNX Provider + * + * ONNX Runtime module registration for React Native SDK. + * Thin wrapper that triggers C++ backend registration for STT/TTS/VAD. + * + * Reference: sdk/runanywhere-swift/Sources/ONNXRuntime/ONNX.swift + */ + +import { requireNativeONNXModule, isNativeONNXModuleAvailable } from './native/NativeRunAnywhereONNX'; +import { SDKLogger } from '@runanywhere/core'; + +// Use SDKLogger with ONNX.Provider category +const logger = new SDKLogger('ONNX.Provider'); + +/** + * ONNX Module + * + * Provides STT (Speech-to-Text), TTS (Text-to-Speech), and VAD capabilities + * using ONNX Runtime / Sherpa-ONNX. + * The actual services are provided by the C++ backend. + * + * ## Registration + * + * ```typescript + * import { ONNXProvider } from '@runanywhere/onnx'; + * + * // Register the backend + * await ONNXProvider.register(); + * ``` + */ +export class ONNXProvider { + static readonly moduleId = 'onnx'; + static readonly moduleName = 'ONNX Runtime'; + static readonly version = '1.23.2'; + + private static isRegistered = false; + + /** + * Register ONNX backend with the C++ service registry. + * Calls rac_backend_onnx_register() to register all ONNX + * service providers (STT, TTS, VAD) with the C++ commons layer. + * Safe to call multiple times - subsequent calls are no-ops. + * @returns Promise true if registered successfully + */ + static async register(): Promise { + if (this.isRegistered) { + logger.debug('ONNX already registered, returning'); + return true; + } + + if (!isNativeONNXModuleAvailable()) { + logger.warning('ONNX native module not available'); + return false; + } + + logger.info('Registering ONNX backend with C++ registry...'); + + try { + const native = requireNativeONNXModule(); + // Call the native registration method from the ONNX module + const success = await native.registerBackend(); + if (success) { + this.isRegistered = true; + logger.info('ONNX backend registered successfully (STT + TTS + VAD)'); + } + return success; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + logger.warning(`ONNX registration failed: ${msg}`); + return false; + } + } + + /** + * Unregister the ONNX backend from C++ registry. + * @returns Promise true if unregistered successfully + */ + static async unregister(): Promise { + if (!this.isRegistered) { + return true; + } + + if (!isNativeONNXModuleAvailable()) { + return false; + } + + try { + const native = requireNativeONNXModule(); + const success = await native.unregisterBackend(); + if (success) { + this.isRegistered = false; + logger.info('ONNX backend unregistered'); + } + return success; + } catch (error) { + return false; + } + } + + /** + * Check if ONNX can handle STT models + */ + static canHandleSTT(modelId: string | null | undefined): boolean { + if (!modelId) return false; + const lowercased = modelId.toLowerCase(); + return ( + lowercased.includes('whisper') || + lowercased.includes('zipformer') || + lowercased.includes('paraformer') + ); + } + + /** + * Check if ONNX can handle TTS models + */ + static canHandleTTS(modelId: string | null | undefined): boolean { + if (!modelId) return false; + const lowercased = modelId.toLowerCase(); + return lowercased.includes('piper') || lowercased.includes('vits'); + } + + /** + * Check if ONNX can handle VAD (always true for Silero VAD) + */ + static canHandleVAD(_modelId: string | null | undefined): boolean { + return true; // ONNX Silero VAD is the default + } + + /** + * Check if ONNX can handle a given model (STT/TTS/VAD) + */ + static canHandle(modelId: string | null | undefined): boolean { + if (!modelId) { + return false; + } + const lowercased = modelId.toLowerCase(); + + // STT: Whisper models (ONNX format) + if (lowercased.includes('whisper') && !lowercased.includes('whisperkit')) { + return true; + } + + // STT/TTS/VAD: Sherpa-ONNX models + if (lowercased.includes('sherpa-onnx') || lowercased.includes('sherpa_onnx')) { + return true; + } + + // TTS: Piper models + if (lowercased.includes('piper')) { + return true; + } + + // VAD: Silero VAD + if (lowercased.includes('silero') && lowercased.includes('vad')) { + return true; + } + + return false; + } +} + +/** + * Auto-register when module is imported + */ +export function autoRegister(): void { + ONNXProvider.register().catch(() => { + // Silently handle registration failure during auto-registration + }); +} diff --git a/sdk/runanywhere-react-native/packages/onnx/src/index.ts b/sdk/runanywhere-react-native/packages/onnx/src/index.ts new file mode 100644 index 000000000..f23fa8812 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/onnx/src/index.ts @@ -0,0 +1,70 @@ +/** + * @runanywhere/onnx - ONNX Runtime Backend for RunAnywhere React Native SDK + * + * This package provides the ONNX Runtime backend for Speech-to-Text (STT), + * Text-to-Speech (TTS), and Voice Activity Detection (VAD) using ONNX Runtime. + * + * ## Usage + * + * ```typescript + * import { RunAnywhere, ModelCategory } from '@runanywhere/core'; + * import { ONNX, ONNXProvider, ModelArtifactType } from '@runanywhere/onnx'; + * + * // Initialize core SDK + * await RunAnywhere.initialize({ apiKey: 'your-key' }); + * + * // Register ONNX backend (calls native rac_backend_onnx_register) + * await ONNXProvider.register(); + * + * // Add STT model + * ONNX.addModel({ + * id: 'sherpa-onnx-whisper-tiny.en', + * name: 'Sherpa Whisper Tiny', + * url: 'https://github.com/.../sherpa-onnx-whisper-tiny.en.tar.gz', + * modality: ModelCategory.SpeechRecognition, + * artifactType: ModelArtifactType.TarGzArchive, + * memoryRequirement: 75_000_000 + * }); + * + * // Add TTS model + * ONNX.addModel({ + * id: 'vits-piper-en_US-lessac-medium', + * name: 'Piper TTS (US English)', + * url: 'https://github.com/.../vits-piper-en_US-lessac-medium.tar.gz', + * modality: ModelCategory.SpeechSynthesis, + * memoryRequirement: 65_000_000 + * }); + * + * // Download and use + * await RunAnywhere.downloadModel('sherpa-onnx-whisper-tiny.en'); + * await RunAnywhere.loadSTTModel('sherpa-onnx-whisper-tiny.en'); + * const result = await RunAnywhere.transcribeFile('/path/to/audio.wav'); + * ``` + * + * @packageDocumentation + */ + +// ============================================================================= +// Main API +// ============================================================================= + +export { ONNX, ModelArtifactType, type ONNXModelOptions } from './ONNX'; +export { ONNXProvider, autoRegister } from './ONNXProvider'; + +// ============================================================================= +// Native Module +// ============================================================================= + +export { + NativeRunAnywhereONNX, + getNativeONNXModule, + requireNativeONNXModule, + isNativeONNXModuleAvailable, +} from './native/NativeRunAnywhereONNX'; +export type { NativeRunAnywhereONNXModule } from './native/NativeRunAnywhereONNX'; + +// ============================================================================= +// Nitrogen Spec Types +// ============================================================================= + +export type { RunAnywhereONNX } from './specs/RunAnywhereONNX.nitro'; diff --git a/sdk/runanywhere-react-native/packages/onnx/src/native/NativeRunAnywhereONNX.ts b/sdk/runanywhere-react-native/packages/onnx/src/native/NativeRunAnywhereONNX.ts new file mode 100644 index 000000000..e2c5b1547 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/onnx/src/native/NativeRunAnywhereONNX.ts @@ -0,0 +1,58 @@ +/** + * NativeRunAnywhereONNX.ts + * + * Exports the native RunAnywhereONNX Hybrid Object from Nitro Modules. + * This module provides ONNX-based STT, TTS, and VAD capabilities. + */ + +import { NitroModules } from 'react-native-nitro-modules'; +import type { RunAnywhereONNX } from '../specs/RunAnywhereONNX.nitro'; + +/** + * The native RunAnywhereONNX module type + */ +export type NativeRunAnywhereONNXModule = RunAnywhereONNX; + +/** + * Get the native RunAnywhereONNX Hybrid Object + */ +export function requireNativeONNXModule(): NativeRunAnywhereONNXModule { + return NitroModules.createHybridObject('RunAnywhereONNX'); +} + +/** + * Check if the native ONNX module is available + */ +export function isNativeONNXModuleAvailable(): boolean { + try { + requireNativeONNXModule(); + return true; + } catch { + return false; + } +} + +/** + * Singleton instance of the native module (lazy initialized) + */ +let _nativeModule: NativeRunAnywhereONNXModule | undefined; + +/** + * Get the singleton native module instance + */ +export function getNativeONNXModule(): NativeRunAnywhereONNXModule { + if (!_nativeModule) { + _nativeModule = requireNativeONNXModule(); + } + return _nativeModule; +} + +/** + * Default export - the native module getter + */ +export const NativeRunAnywhereONNX = { + get: getNativeONNXModule, + isAvailable: isNativeONNXModuleAvailable, +}; + +export default NativeRunAnywhereONNX; diff --git a/sdk/runanywhere-react-native/packages/onnx/src/native/index.ts b/sdk/runanywhere-react-native/packages/onnx/src/native/index.ts new file mode 100644 index 000000000..d14f46397 --- /dev/null +++ b/sdk/runanywhere-react-native/packages/onnx/src/native/index.ts @@ -0,0 +1,11 @@ +/** + * Native module exports for @runanywhere/onnx + */ + +export { + NativeRunAnywhereONNX, + getNativeONNXModule, + requireNativeONNXModule, + isNativeONNXModuleAvailable, +} from './NativeRunAnywhereONNX'; +export type { NativeRunAnywhereONNXModule } from './NativeRunAnywhereONNX'; diff --git a/sdk/runanywhere-react-native/packages/onnx/src/specs/RunAnywhereONNX.nitro.ts b/sdk/runanywhere-react-native/packages/onnx/src/specs/RunAnywhereONNX.nitro.ts new file mode 100644 index 000000000..a4c3b039b --- /dev/null +++ b/sdk/runanywhere-react-native/packages/onnx/src/specs/RunAnywhereONNX.nitro.ts @@ -0,0 +1,267 @@ +/** + * RunAnywhereONNX Nitrogen Spec + * + * ONNX backend interface for speech processing: + * - Backend Registration + * - Speech-to-Text (STT) + * - Text-to-Speech (TTS) + * - Voice Activity Detection (VAD) + * - Voice Agent (full pipeline orchestration) + * + * Matches Swift SDK: ONNXRuntime/ONNX.swift + CppBridge STT/TTS/VAD extensions + */ +import type { HybridObject } from 'react-native-nitro-modules'; + +/** + * ONNX speech processing native interface + * + * This interface provides ONNX-based STT, TTS, and VAD capabilities. + * Requires @runanywhere/core to be initialized first. + */ +export interface RunAnywhereONNX + extends HybridObject<{ + ios: 'c++'; + android: 'c++'; + }> { + // ============================================================================ + // Backend Registration + // Matches Swift: ONNX.register(), ONNX.unregister() + // ============================================================================ + + /** + * Register the ONNX backend with the C++ service registry. + * Registers STT, TTS, and VAD providers. + * Safe to call multiple times - subsequent calls are no-ops. + * @returns true if registered successfully (or already registered) + */ + registerBackend(): Promise; + + /** + * Unregister the ONNX backend from the C++ service registry. + * @returns true if unregistered successfully + */ + unregisterBackend(): Promise; + + /** + * Check if the ONNX backend is registered + * @returns true if backend is registered + */ + isBackendRegistered(): Promise; + + // ============================================================================ + // Speech-to-Text (STT) + // Matches Swift: CppBridge+STT.swift, RunAnywhere+STT.swift + // ============================================================================ + + /** + * Load an STT model + * @param path Path to the model directory + * @param modelType Model type (e.g., 'whisper', 'whisper-tiny') + * @param configJson Optional JSON configuration + * @returns true if loaded successfully + */ + loadSTTModel( + path: string, + modelType: string, + configJson?: string + ): Promise; + + /** + * Check if an STT model is loaded + */ + isSTTModelLoaded(): Promise; + + /** + * Unload the current STT model + */ + unloadSTTModel(): Promise; + + /** + * Transcribe audio data + * @param audioBase64 Base64-encoded float32 PCM audio + * @param sampleRate Audio sample rate (e.g., 16000) + * @param language Language code (e.g., 'en') + * @returns JSON string with transcription result: + * - text: Transcribed text + * - confidence: Confidence score (0-1) + * - isFinal: Whether this is a final result + */ + transcribe( + audioBase64: string, + sampleRate: number, + language?: string + ): Promise; + + /** + * Transcribe audio from a file path + * Native code handles M4A/WAV/CAF to PCM conversion + * @param filePath Path to the audio file + * @param language Language code (e.g., 'en') + * @returns JSON string with transcription result + */ + transcribeFile(filePath: string, language?: string): Promise; + + /** + * Check if STT supports streaming + */ + supportsSTTStreaming(): Promise; + + // ============================================================================ + // Text-to-Speech (TTS) + // Matches Swift: CppBridge+TTS.swift, RunAnywhere+TTS.swift + // ============================================================================ + + /** + * Load a TTS model + * @param path Path to the model directory + * @param modelType Model type (e.g., 'piper', 'vits') + * @param configJson Optional JSON configuration + * @returns true if loaded successfully + */ + loadTTSModel( + path: string, + modelType: string, + configJson?: string + ): Promise; + + /** + * Check if a TTS model is loaded + */ + isTTSModelLoaded(): Promise; + + /** + * Unload the current TTS model + */ + unloadTTSModel(): Promise; + + /** + * Synthesize speech from text + * @param text Text to synthesize + * @param voiceId Voice ID to use + * @param speedRate Speed multiplier (1.0 = normal) + * @param pitchShift Pitch adjustment + * @returns JSON string with audio data: + * - audio: Base64-encoded audio data + * - sampleRate: Audio sample rate + * - numSamples: Number of samples + * - duration: Duration in seconds + */ + synthesize( + text: string, + voiceId: string, + speedRate: number, + pitchShift: number + ): Promise; + + /** + * Get available TTS voices + * @returns JSON array of voice info + */ + getTTSVoices(): Promise; + + // ============================================================================ + // Voice Activity Detection (VAD) + // Matches Swift: CppBridge+VAD.swift, RunAnywhere+VAD.swift + // ============================================================================ + + /** + * Load a VAD model + * @param path Path to the VAD model + * @param configJson Optional configuration JSON + * @returns true if loaded successfully + */ + loadVADModel(path: string, configJson?: string): Promise; + + /** + * Check if VAD model is loaded + */ + isVADModelLoaded(): Promise; + + /** + * Unload the current VAD model + */ + unloadVADModel(): Promise; + + /** + * Process audio for voice activity detection + * @param audioBase64 Base64-encoded audio data + * @param optionsJson Optional processing options + * @returns JSON string with VAD result: + * - isSpeech: Whether speech is detected + * - speechProbability: Probability of speech (0-1) + * - startTime: Speech start time (if detected) + * - endTime: Speech end time (if detected) + */ + processVAD(audioBase64: string, optionsJson?: string): Promise; + + /** + * Reset VAD state (for continuous processing) + */ + resetVAD(): Promise; + + /** + * Initialize VAD with configuration + * @param configJson Optional configuration JSON + */ + initializeVAD(configJson?: string): Promise; + + /** + * Cleanup VAD resources + */ + cleanupVAD(): Promise; + + /** + * Start VAD processing + */ + startVAD(): Promise; + + /** + * Stop VAD processing + */ + stopVAD(): Promise; + + // ============================================================================ + // Voice Agent (Full Voice Pipeline) + // Matches Swift: CppBridge+VoiceAgent.swift, RunAnywhere+VoiceAgent.swift + // ============================================================================ + + /** + * Initialize voice agent with configuration + * @param configJson Configuration JSON with STT/LLM/TTS model IDs + * @returns true if initialized successfully + */ + initializeVoiceAgent(configJson: string): Promise; + + /** + * Check if voice agent is ready (all components initialized) + */ + isVoiceAgentReady(): Promise; + + /** + * Process a complete voice turn: audio -> transcription -> response -> speech + * Note: LLM generation requires @runanywhere/llamacpp to be installed + * @param audioBase64 Base64-encoded audio input + * @returns JSON with transcription, response, and synthesized audio + */ + processVoiceTurn(audioBase64: string): Promise; + + /** + * Cleanup voice agent resources + */ + cleanupVoiceAgent(): Promise; + + // ============================================================================ + // Utilities + // ============================================================================ + + /** + * Get the last error message from the ONNX backend + */ + getLastError(): Promise; + + /** + * Get current memory usage of the ONNX backend + * @returns Memory usage in bytes + */ + getMemoryUsage(): Promise; +} diff --git a/sdk/runanywhere-react-native/packages/onnx/tsconfig.json b/sdk/runanywhere-react-native/packages/onnx/tsconfig.json new file mode 100644 index 000000000..72ff4259f --- /dev/null +++ b/sdk/runanywhere-react-native/packages/onnx/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib", + "composite": true, + "declaration": true, + "declarationMap": true, + "baseUrl": ".", + "paths": { + "@runanywhere/onnx": ["./src"], + "@runanywhere/onnx/*": ["./src/*"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "lib"], + "references": [ + { "path": "../core" } + ] +} diff --git a/sdk/runanywhere-react-native/scripts/build-react-native.sh b/sdk/runanywhere-react-native/scripts/build-react-native.sh new file mode 100755 index 000000000..573412286 --- /dev/null +++ b/sdk/runanywhere-react-native/scripts/build-react-native.sh @@ -0,0 +1,603 @@ +#!/bin/bash +# ============================================================================= +# RunAnywhere React Native SDK - Build Script +# ============================================================================= +# +# Single entry point for building the React Native SDK and its native dependencies. +# Builds native C++ libraries for both iOS (XCFrameworks) and Android (JNI .so libs). +# +# USAGE: +# ./scripts/build-react-native.sh [options] +# +# OPTIONS: +# --setup First-time setup: install deps, build commons, copy frameworks/libs +# --local Use locally built native libs (sets RA_TEST_LOCAL=1) +# --remote Use remote libs from GitHub releases (unsets RA_TEST_LOCAL) +# --rebuild-commons Force rebuild of runanywhere-commons +# --ios Build for iOS only +# --android Build for Android only (default: both) +# --clean Clean build directories before building +# --skip-build Skip native build (only setup frameworks/libs) +# --abis=ABIS Android ABIs to build (default: arm64-v8a) +# Multiple: Use comma-separated (e.g., arm64-v8a,armeabi-v7a) +# --help Show this help message +# +# ANDROID ABI GUIDE: +# arm64-v8a 64-bit ARM (modern devices, ~85% coverage) +# armeabi-v7a 32-bit ARM (older devices, ~12% coverage) +# x86_64 64-bit Intel (emulators on Intel Macs) +# +# WHAT GETS BUILT: +# iOS Output (in packages/*/ios/): +# • core/ios/Binaries/RACommons.xcframework +# • llamacpp/ios/Frameworks/RABackendLLAMACPP.xcframework +# • onnx/ios/Frameworks/RABackendONNX.xcframework + onnxruntime.xcframework +# +# Android Output (in packages/*/android/src/main/jniLibs/{ABI}/): +# • core: librunanywhere_jni.so, librac_commons.so, libc++_shared.so, libomp.so +# • llamacpp: librunanywhere_llamacpp.so, librac_backend_llamacpp_jni.so +# • onnx: librunanywhere_onnx.so, libonnxruntime.so, libsherpa-onnx-*.so +# +# FRESH CLONE TO RUNNING APP: +# # 1. Build SDK with native libraries (~15-20 min) +# cd sdk/runanywhere-react-native +# ./scripts/build-react-native.sh --setup +# +# # 2. Setup sample app +# cd ../../examples/react-native/RunAnywhereAI +# yarn install +# +# # 3. Run on iOS +# cd ios && pod install && cd .. +# npx react-native run-ios +# +# # 4. Run on Android +# cp android/gradle.properties.example android/gradle.properties # One-time +# npx react-native run-android +# +# EXAMPLES: +# # First-time setup (iOS + Android, all backends) +# ./scripts/build-react-native.sh --setup +# +# # iOS only setup (~10 min) +# ./scripts/build-react-native.sh --setup --ios +# +# # Android only (~7 min) +# ./scripts/build-react-native.sh --setup --android +# +# # Android with multiple ABIs for production (97% device coverage) +# ./scripts/build-react-native.sh --setup --android --abis=arm64-v8a,armeabi-v7a +# +# # Rebuild after C++ changes +# ./scripts/build-react-native.sh --local --rebuild-commons +# +# # Just switch to local mode (uses cached libs) +# ./scripts/build-react-native.sh --local --skip-build +# +# ============================================================================= + +set -e + +# ============================================================================= +# Configuration +# ============================================================================= + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +RN_SDK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +SDK_ROOT="$(cd "${RN_SDK_DIR}/.." && pwd)" +COMMONS_DIR="${SDK_ROOT}/runanywhere-commons" +COMMONS_IOS_SCRIPT="${COMMONS_DIR}/scripts/build-ios.sh" +COMMONS_ANDROID_SCRIPT="${COMMONS_DIR}/scripts/build-android.sh" + +# Output directories +CORE_IOS_BINARIES="${RN_SDK_DIR}/packages/core/ios/Binaries" +LLAMACPP_IOS_FRAMEWORKS="${RN_SDK_DIR}/packages/llamacpp/ios/Frameworks" +ONNX_IOS_FRAMEWORKS="${RN_SDK_DIR}/packages/onnx/ios/Frameworks" + +CORE_ANDROID_JNILIBS="${RN_SDK_DIR}/packages/core/android/src/main/jniLibs" +LLAMACPP_ANDROID_JNILIBS="${RN_SDK_DIR}/packages/llamacpp/android/src/main/jniLibs" +ONNX_ANDROID_JNILIBS="${RN_SDK_DIR}/packages/onnx/android/src/main/jniLibs" + +# Defaults +MODE="local" +SETUP_MODE=false +REBUILD_COMMONS=false +CLEAN_BUILD=false +SKIP_BUILD=false +BUILD_IOS=true +BUILD_ANDROID=true +ABIS="arm64-v8a" + +# ============================================================================= +# Colors & Logging +# ============================================================================= + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +NC='\033[0m' + +log_header() { + echo "" + echo -e "${GREEN}═══════════════════════════════════════════${NC}" + echo -e "${GREEN} $1${NC}" + echo -e "${GREEN}═══════════════════════════════════════════${NC}" +} + +log_step() { + echo -e "${BLUE}==>${NC} $1" +} + +log_info() { + echo -e "${CYAN}[✓]${NC} $1" +} + +log_warn() { + echo -e "${YELLOW}[!]${NC} $1" +} + +log_error() { + echo -e "${RED}[✗]${NC} $1" +} + +# ============================================================================= +# Argument Parsing +# ============================================================================= + +show_help() { + head -75 "$0" | tail -70 + exit 0 +} + +for arg in "$@"; do + case "$arg" in + --setup) + SETUP_MODE=true + REBUILD_COMMONS=true + ;; + --local) + MODE="local" + ;; + --remote) + MODE="remote" + ;; + --rebuild-commons) + REBUILD_COMMONS=true + ;; + --ios) + BUILD_IOS=true + BUILD_ANDROID=false + ;; + --android) + BUILD_IOS=false + BUILD_ANDROID=true + ;; + --clean) + CLEAN_BUILD=true + ;; + --skip-build) + SKIP_BUILD=true + ;; + --abis=*) + ABIS="${arg#*=}" + ;; + --help|-h) + show_help + ;; + *) + log_error "Unknown option: $arg" + show_help + ;; + esac +done + +# ============================================================================= +# Setup Environment +# ============================================================================= + +setup_environment() { + log_header "Setting Up Environment" + + cd "$RN_SDK_DIR" + + # Check for yarn + if ! command -v yarn &> /dev/null; then + log_error "yarn is not installed. Please install yarn first." + exit 1 + fi + + # Install dependencies + log_step "Installing yarn dependencies..." + yarn install + + log_info "Dependencies installed" +} + +# ============================================================================= +# Build Commons (Native Libraries) +# ============================================================================= + +build_commons_ios() { + log_header "Building runanywhere-commons for iOS" + + if [[ ! -x "$COMMONS_IOS_SCRIPT" ]]; then + log_error "iOS build script not found: $COMMONS_IOS_SCRIPT" + exit 1 + fi + + local FLAGS="" + [[ "$CLEAN_BUILD" == true ]] && FLAGS="$FLAGS --clean" + + log_step "Running: build-ios.sh $FLAGS" + "$COMMONS_IOS_SCRIPT" $FLAGS + + log_info "iOS commons build complete" +} + +build_commons_android() { + log_header "Building runanywhere-commons for Android" + + if [[ ! -x "$COMMONS_ANDROID_SCRIPT" ]]; then + log_error "Android build script not found: $COMMONS_ANDROID_SCRIPT" + exit 1 + fi + + # build-android.sh takes positional args: BACKENDS ABIS + # Use "all" for backends to get both LlamaCPP and ONNX + local BACKENDS="llamacpp,onnx" + + log_step "Running: build-android.sh $BACKENDS $ABIS" + "$COMMONS_ANDROID_SCRIPT" "$BACKENDS" "$ABIS" + + log_info "Android commons build complete" +} + +# ============================================================================= +# Copy iOS Frameworks +# ============================================================================= + +copy_ios_frameworks() { + log_header "Copying iOS XCFrameworks" + + local COMMONS_DIST="${COMMONS_DIR}/dist" + + # Create directories + mkdir -p "$CORE_IOS_BINARIES" + mkdir -p "$LLAMACPP_IOS_FRAMEWORKS" + mkdir -p "$ONNX_IOS_FRAMEWORKS" + + # Copy RACommons.xcframework to core package + if [[ -d "${COMMONS_DIST}/RACommons.xcframework" ]]; then + rm -rf "${CORE_IOS_BINARIES}/RACommons.xcframework" + cp -R "${COMMONS_DIST}/RACommons.xcframework" "${CORE_IOS_BINARIES}/" + log_info "Core: RACommons.xcframework" + else + log_warn "RACommons.xcframework not found at ${COMMONS_DIST}/" + fi + + # Copy RABackendLLAMACPP.xcframework to llamacpp package + if [[ -d "${COMMONS_DIST}/RABackendLLAMACPP.xcframework" ]]; then + rm -rf "${LLAMACPP_IOS_FRAMEWORKS}/RABackendLLAMACPP.xcframework" + cp -R "${COMMONS_DIST}/RABackendLLAMACPP.xcframework" "${LLAMACPP_IOS_FRAMEWORKS}/" + log_info "LlamaCPP: RABackendLLAMACPP.xcframework" + else + log_warn "RABackendLLAMACPP.xcframework not found at ${COMMONS_DIST}/" + fi + + # Copy RABackendONNX.xcframework to onnx package + if [[ -d "${COMMONS_DIST}/RABackendONNX.xcframework" ]]; then + rm -rf "${ONNX_IOS_FRAMEWORKS}/RABackendONNX.xcframework" + cp -R "${COMMONS_DIST}/RABackendONNX.xcframework" "${ONNX_IOS_FRAMEWORKS}/" + log_info "ONNX: RABackendONNX.xcframework" + else + log_warn "RABackendONNX.xcframework not found at ${COMMONS_DIST}/" + fi + + # Copy onnxruntime.xcframework to onnx package (required dependency) + local ONNX_RUNTIME_PATH="${COMMONS_DIR}/third_party/onnxruntime-ios/onnxruntime.xcframework" + if [[ -d "${ONNX_RUNTIME_PATH}" ]]; then + rm -rf "${ONNX_IOS_FRAMEWORKS}/onnxruntime.xcframework" + cp -R "${ONNX_RUNTIME_PATH}" "${ONNX_IOS_FRAMEWORKS}/" + log_info "ONNX: onnxruntime.xcframework" + else + log_warn "onnxruntime.xcframework not found at ${ONNX_RUNTIME_PATH}" + fi + + # Create .testlocal markers for local mode + touch "${RN_SDK_DIR}/packages/core/ios/.testlocal" + touch "${RN_SDK_DIR}/packages/llamacpp/ios/.testlocal" + touch "${RN_SDK_DIR}/packages/onnx/ios/.testlocal" + + log_info "iOS frameworks copied" +} + +# ============================================================================= +# Copy Android JNI Libraries +# ============================================================================= + +copy_android_jnilibs() { + log_header "Copying Android JNI Libraries" + + local COMMONS_DIST="${COMMONS_DIR}/dist/android" + local COMMONS_BUILD="${COMMONS_DIR}/build/android/unified" + + IFS=',' read -ra ABI_ARRAY <<< "$ABIS" + + for ABI in "${ABI_ARRAY[@]}"; do + log_step "Copying libraries for ${ABI}..." + + # Create directories + mkdir -p "${CORE_ANDROID_JNILIBS}/${ABI}" + mkdir -p "${LLAMACPP_ANDROID_JNILIBS}/${ABI}" + mkdir -p "${ONNX_ANDROID_JNILIBS}/${ABI}" + + # ======================================================================= + # Core Package: RACommons (librunanywhere_jni.so, librac_commons.so, libc++_shared.so) + # ======================================================================= + + # Copy librunanywhere_jni.so + if [[ -f "${COMMONS_DIST}/jni/${ABI}/librunanywhere_jni.so" ]]; then + cp "${COMMONS_DIST}/jni/${ABI}/librunanywhere_jni.so" "${CORE_ANDROID_JNILIBS}/${ABI}/" + log_info "Core: librunanywhere_jni.so" + elif [[ -f "${COMMONS_BUILD}/${ABI}/src/jni/librunanywhere_jni.so" ]]; then + cp "${COMMONS_BUILD}/${ABI}/src/jni/librunanywhere_jni.so" "${CORE_ANDROID_JNILIBS}/${ABI}/" + log_info "Core: librunanywhere_jni.so (from build)" + else + log_warn "Core: librunanywhere_jni.so NOT FOUND" + fi + + # Copy librac_commons.so + if [[ -f "${COMMONS_BUILD}/${ABI}/librac_commons.so" ]]; then + cp "${COMMONS_BUILD}/${ABI}/librac_commons.so" "${CORE_ANDROID_JNILIBS}/${ABI}/" + log_info "Core: librac_commons.so" + fi + + # Copy libc++_shared.so + if [[ -f "${COMMONS_DIST}/llamacpp/${ABI}/libc++_shared.so" ]]; then + cp "${COMMONS_DIST}/llamacpp/${ABI}/libc++_shared.so" "${CORE_ANDROID_JNILIBS}/${ABI}/" + log_info "Core: libc++_shared.so" + elif [[ -f "${COMMONS_DIST}/jni/${ABI}/libc++_shared.so" ]]; then + cp "${COMMONS_DIST}/jni/${ABI}/libc++_shared.so" "${CORE_ANDROID_JNILIBS}/${ABI}/" + log_info "Core: libc++_shared.so" + fi + + # Copy libomp.so + if [[ -f "${COMMONS_DIST}/llamacpp/${ABI}/libomp.so" ]]; then + cp "${COMMONS_DIST}/llamacpp/${ABI}/libomp.so" "${CORE_ANDROID_JNILIBS}/${ABI}/" + log_info "Core: libomp.so" + elif [[ -f "${COMMONS_DIST}/jni/${ABI}/libomp.so" ]]; then + cp "${COMMONS_DIST}/jni/${ABI}/libomp.so" "${CORE_ANDROID_JNILIBS}/${ABI}/" + log_info "Core: libomp.so" + fi + + # ======================================================================= + # LlamaCPP Package: RABackendLlamaCPP + # Keep original library name (bridge libs depend on it) + # ======================================================================= + + # Copy backend library with original name + if [[ -f "${COMMONS_DIST}/llamacpp/${ABI}/librac_backend_llamacpp.so" ]]; then + cp "${COMMONS_DIST}/llamacpp/${ABI}/librac_backend_llamacpp.so" "${LLAMACPP_ANDROID_JNILIBS}/${ABI}/librac_backend_llamacpp.so" + log_info "LlamaCPP: librac_backend_llamacpp.so" + elif [[ -f "${COMMONS_BUILD}/${ABI}/src/backends/llamacpp/librac_backend_llamacpp.so" ]]; then + cp "${COMMONS_BUILD}/${ABI}/src/backends/llamacpp/librac_backend_llamacpp.so" "${LLAMACPP_ANDROID_JNILIBS}/${ABI}/librac_backend_llamacpp.so" + log_info "LlamaCPP: librac_backend_llamacpp.so (from build)" + fi + + # Copy JNI bridge + if [[ -f "${COMMONS_DIST}/llamacpp/${ABI}/librac_backend_llamacpp_jni.so" ]]; then + cp "${COMMONS_DIST}/llamacpp/${ABI}/librac_backend_llamacpp_jni.so" "${LLAMACPP_ANDROID_JNILIBS}/${ABI}/" + log_info "LlamaCPP: librac_backend_llamacpp_jni.so" + elif [[ -f "${COMMONS_BUILD}/${ABI}/src/backends/llamacpp/librac_backend_llamacpp_jni.so" ]]; then + cp "${COMMONS_BUILD}/${ABI}/src/backends/llamacpp/librac_backend_llamacpp_jni.so" "${LLAMACPP_ANDROID_JNILIBS}/${ABI}/" + log_info "LlamaCPP: librac_backend_llamacpp_jni.so (from build)" + fi + + # ======================================================================= + # ONNX Package: RABackendONNX + # Keep original library name (bridge libs depend on it) + # ======================================================================= + + # Copy backend library with original name + if [[ -f "${COMMONS_DIST}/onnx/${ABI}/librac_backend_onnx.so" ]]; then + cp "${COMMONS_DIST}/onnx/${ABI}/librac_backend_onnx.so" "${ONNX_ANDROID_JNILIBS}/${ABI}/librac_backend_onnx.so" + log_info "ONNX: librac_backend_onnx.so" + elif [[ -f "${COMMONS_BUILD}/${ABI}/src/backends/onnx/librac_backend_onnx.so" ]]; then + cp "${COMMONS_BUILD}/${ABI}/src/backends/onnx/librac_backend_onnx.so" "${ONNX_ANDROID_JNILIBS}/${ABI}/librac_backend_onnx.so" + log_info "ONNX: librac_backend_onnx.so (from build)" + fi + + # Copy JNI bridge + if [[ -f "${COMMONS_DIST}/onnx/${ABI}/librac_backend_onnx_jni.so" ]]; then + cp "${COMMONS_DIST}/onnx/${ABI}/librac_backend_onnx_jni.so" "${ONNX_ANDROID_JNILIBS}/${ABI}/" + log_info "ONNX: librac_backend_onnx_jni.so" + elif [[ -f "${COMMONS_BUILD}/${ABI}/src/backends/onnx/librac_backend_onnx_jni.so" ]]; then + cp "${COMMONS_BUILD}/${ABI}/src/backends/onnx/librac_backend_onnx_jni.so" "${ONNX_ANDROID_JNILIBS}/${ABI}/" + log_info "ONNX: librac_backend_onnx_jni.so (from build)" + fi + + # Copy ONNX Runtime + if [[ -f "${COMMONS_DIST}/onnx/${ABI}/libonnxruntime.so" ]]; then + cp "${COMMONS_DIST}/onnx/${ABI}/libonnxruntime.so" "${ONNX_ANDROID_JNILIBS}/${ABI}/" + log_info "ONNX: libonnxruntime.so" + fi + + # Copy Sherpa-ONNX libraries + for lib in libsherpa-onnx-c-api.so libsherpa-onnx-cxx-api.so libsherpa-onnx-jni.so; do + if [[ -f "${COMMONS_DIST}/onnx/${ABI}/${lib}" ]]; then + cp "${COMMONS_DIST}/onnx/${ABI}/${lib}" "${ONNX_ANDROID_JNILIBS}/${ABI}/" + log_info "ONNX: ${lib}" + fi + done + done + + log_info "Android JNI libraries copied" +} + +# ============================================================================= +# Copy C++ Headers (required for Android build) +# ============================================================================= + +copy_cpp_headers() { + log_header "Copying C++ Headers for Android" + + local COMMONS_INCLUDE="${COMMONS_DIR}/include/rac" + local CORE_INCLUDE="${RN_SDK_DIR}/packages/core/android/src/main/include" + + # Check if headers exist + if [[ ! -d "${COMMONS_INCLUDE}" ]]; then + log_warn "Headers not found at ${COMMONS_INCLUDE}" + return 1 + fi + + # Clean and recreate include directory + rm -rf "${CORE_INCLUDE}" + mkdir -p "${CORE_INCLUDE}" + + # Copy entire rac directory structure + cp -R "${COMMONS_INCLUDE}" "${CORE_INCLUDE}/" + + # Count headers + local count=$(find "${CORE_INCLUDE}" -name "*.h" | wc -l | tr -d ' ') + log_info "Copied ${count} headers to packages/core/android/src/main/include/" +} + +# ============================================================================= +# Set Mode (Local/Remote) +# ============================================================================= + +set_mode() { + log_header "Setting Build Mode: $MODE" + + if [[ "$MODE" == "local" ]]; then + export RA_TEST_LOCAL=1 + + # Create .testlocal markers for iOS + touch "${RN_SDK_DIR}/packages/core/ios/.testlocal" + touch "${RN_SDK_DIR}/packages/llamacpp/ios/.testlocal" + touch "${RN_SDK_DIR}/packages/onnx/ios/.testlocal" + + log_info "Switched to LOCAL mode" + log_info " iOS: Using Binaries/ and Frameworks/ directories" + log_info " Android: Set RA_TEST_LOCAL=1 or runanywhere.testLocal=true" + else + unset RA_TEST_LOCAL + + # Remove .testlocal markers + rm -f "${RN_SDK_DIR}/packages/core/ios/.testlocal" + rm -f "${RN_SDK_DIR}/packages/llamacpp/ios/.testlocal" + rm -f "${RN_SDK_DIR}/packages/onnx/ios/.testlocal" + + log_info "Switched to REMOTE mode" + log_info " iOS: Will download from GitHub releases" + log_info " Android: Will download from GitHub releases" + fi +} + +# ============================================================================= +# Clean +# ============================================================================= + +clean_build() { + log_header "Cleaning Build Directories" + + if [[ "$BUILD_IOS" == true ]]; then + rm -rf "${CORE_IOS_BINARIES}" + rm -rf "${LLAMACPP_IOS_FRAMEWORKS}" + rm -rf "${ONNX_IOS_FRAMEWORKS}" + log_info "Cleaned iOS frameworks" + fi + + if [[ "$BUILD_ANDROID" == true ]]; then + rm -rf "${CORE_ANDROID_JNILIBS}" + rm -rf "${LLAMACPP_ANDROID_JNILIBS}" + rm -rf "${ONNX_ANDROID_JNILIBS}" + rm -rf "${RN_SDK_DIR}/packages/core/android/src/main/include" + log_info "Cleaned Android jniLibs and headers" + fi +} + +# ============================================================================= +# Print Summary +# ============================================================================= + +print_summary() { + log_header "Build Complete!" + + echo "" + echo "Mode: $MODE" + echo "" + + if [[ "$BUILD_IOS" == true ]]; then + echo "iOS Frameworks:" + ls -la "${CORE_IOS_BINARIES}" 2>/dev/null || echo " (none)" + ls -la "${LLAMACPP_IOS_FRAMEWORKS}" 2>/dev/null || echo " (none)" + ls -la "${ONNX_IOS_FRAMEWORKS}" 2>/dev/null || echo " (none)" + echo "" + fi + + if [[ "$BUILD_ANDROID" == true ]]; then + echo "Android JNI Libraries:" + for pkg in core llamacpp onnx; do + local dir="${RN_SDK_DIR}/packages/${pkg}/android/src/main/jniLibs" + if [[ -d "$dir" ]]; then + local count=$(find "$dir" -name "*.so" 2>/dev/null | wc -l) + local size=$(du -sh "$dir" 2>/dev/null | cut -f1) + echo " ${pkg}: ${count} libs (${size})" + fi + done + echo "" + fi + + echo "Next steps:" + echo " 1. Run example app: cd examples/react-native/RunAnywhereAI" + echo " 2. iOS: cd ios && pod install && cd .. && npx react-native run-ios" + echo " 3. Android: npx react-native run-android" + echo "" + echo "To rebuild after C++ changes:" + echo " ./scripts/build-react-native.sh --local --rebuild-commons" +} + +# ============================================================================= +# Main +# ============================================================================= + +main() { + log_header "RunAnywhere React Native SDK Build" + echo "Mode: $MODE" + echo "Setup: $SETUP_MODE" + echo "Rebuild Commons: $REBUILD_COMMONS" + echo "iOS: $BUILD_IOS" + echo "Android: $BUILD_ANDROID" + echo "ABIs: $ABIS" + echo "" + + # Clean if requested + [[ "$CLEAN_BUILD" == true ]] && clean_build + + # Setup environment (install deps) + [[ "$SETUP_MODE" == true ]] && setup_environment + + # Build native libraries if needed + if [[ "$REBUILD_COMMONS" == true ]] && [[ "$SKIP_BUILD" == false ]]; then + [[ "$BUILD_IOS" == true ]] && build_commons_ios + [[ "$BUILD_ANDROID" == true ]] && build_commons_android + fi + + # Copy frameworks/libs if in local mode + if [[ "$MODE" == "local" ]]; then + [[ "$BUILD_IOS" == true ]] && copy_ios_frameworks + [[ "$BUILD_ANDROID" == true ]] && copy_android_jnilibs + [[ "$BUILD_ANDROID" == true ]] && copy_cpp_headers + fi + + # Set mode + set_mode + + # Print summary + print_summary +} + +main "$@" diff --git a/sdk/runanywhere-react-native/tsconfig.base.json b/sdk/runanywhere-react-native/tsconfig.base.json new file mode 100644 index 000000000..20715f21b --- /dev/null +++ b/sdk/runanywhere-react-native/tsconfig.base.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "commonjs", + "lib": ["esnext"], + "declaration": true, + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noImplicitThis": true, + "alwaysStrict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "moduleResolution": "node", + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "jsx": "react-native" + } +} diff --git a/sdk/runanywhere-react-native/yarn.lock b/sdk/runanywhere-react-native/yarn.lock new file mode 100644 index 000000000..2118ca206 --- /dev/null +++ b/sdk/runanywhere-react-native/yarn.lock @@ -0,0 +1,9424 @@ +# This file is generated by running "yarn install" inside your project. +# Manual changes might be lost - proceed with caution! + +__metadata: + version: 6 + cacheKey: 8 + +"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.12.13, @babel/code-frame@npm:^7.24.7, @babel/code-frame@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/code-frame@npm:7.28.6" + dependencies: + "@babel/helper-validator-identifier": ^7.28.5 + js-tokens: ^4.0.0 + picocolors: ^1.1.1 + checksum: 6e98e47fd324b41c1919ff6d0fbf6fa5e991e5beff6b55803d9adaff9e11f4bc432803e52165f7b0d49af0f718209c3138a9b2fd51ff624b19d47704f11f8287 + languageName: node + linkType: hard + +"@babel/compat-data@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/compat-data@npm:7.28.6" + checksum: 599b316aa0e3981aa9165ac34609ef5f29ebf5cecc04784e8b4932dd355aaa3599eaa222ff46a2fcfff52f083b8fd212650a52d8af57c4c217c81a100fefba09 + languageName: node + linkType: hard + +"@babel/core@npm:^7.11.6, @babel/core@npm:^7.12.3, @babel/core@npm:^7.25.2": + version: 7.28.6 + resolution: "@babel/core@npm:7.28.6" + dependencies: + "@babel/code-frame": ^7.28.6 + "@babel/generator": ^7.28.6 + "@babel/helper-compilation-targets": ^7.28.6 + "@babel/helper-module-transforms": ^7.28.6 + "@babel/helpers": ^7.28.6 + "@babel/parser": ^7.28.6 + "@babel/template": ^7.28.6 + "@babel/traverse": ^7.28.6 + "@babel/types": ^7.28.6 + "@jridgewell/remapping": ^2.3.5 + convert-source-map: ^2.0.0 + debug: ^4.1.0 + gensync: ^1.0.0-beta.2 + json5: ^2.2.3 + semver: ^6.3.1 + checksum: 09d3712c52b2dba76dc0394127f6aacdbb575d79f8b6dc41230c1a13d8047d259ba06d88d56d62d95bb06c94c025c1e4bdd896929b5d4644ce0b96a84fd91553 + languageName: node + linkType: hard + +"@babel/generator@npm:^7.25.0, @babel/generator@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/generator@npm:7.28.6" + dependencies: + "@babel/parser": ^7.28.6 + "@babel/types": ^7.28.6 + "@jridgewell/gen-mapping": ^0.3.12 + "@jridgewell/trace-mapping": ^0.3.28 + jsesc: ^3.0.2 + checksum: 74f62f140e301c8c21652f7db3bc275008708272c0395f178ba6953297af50c4ea484874a44b3f292d242ce8a977fd3f31d9d3a3501c3aaca9cd46e3b1cded01 + languageName: node + linkType: hard + +"@babel/helper-compilation-targets@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/helper-compilation-targets@npm:7.28.6" + dependencies: + "@babel/compat-data": ^7.28.6 + "@babel/helper-validator-option": ^7.27.1 + browserslist: ^4.24.0 + lru-cache: ^5.1.1 + semver: ^6.3.1 + checksum: 8151e36b74eb1c5e414fe945c189436421f7bfa011884de5be3dd7fd77f12f1f733ff7c982581dfa0a49d8af724450243c2409427114b4a6cfeb8333259d001c + languageName: node + linkType: hard + +"@babel/helper-globals@npm:^7.28.0": + version: 7.28.0 + resolution: "@babel/helper-globals@npm:7.28.0" + checksum: d8d7b91c12dad1ee747968af0cb73baf91053b2bcf78634da2c2c4991fb45ede9bd0c8f9b5f3254881242bc0921218fcb7c28ae885477c25177147e978ce4397 + languageName: node + linkType: hard + +"@babel/helper-module-imports@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/helper-module-imports@npm:7.28.6" + dependencies: + "@babel/traverse": ^7.28.6 + "@babel/types": ^7.28.6 + checksum: 437513aa029898b588a38f7991d7656c539b22f595207d85d0c407240c9e3f2aff8b9d0d7115fdedc91e7fdce4465100549a052024e2fba6a810bcbb7584296b + languageName: node + linkType: hard + +"@babel/helper-module-transforms@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/helper-module-transforms@npm:7.28.6" + dependencies: + "@babel/helper-module-imports": ^7.28.6 + "@babel/helper-validator-identifier": ^7.28.5 + "@babel/traverse": ^7.28.6 + peerDependencies: + "@babel/core": ^7.0.0 + checksum: 522f7d1d08b5e2ccd4ec912aca879bd1506af78d1fb30f46e3e6b4bb69c6ae6ab4e379a879723844230d27dc6d04a55b03f5215cd3141b7a2b40bb4a02f71a9f + languageName: node + linkType: hard + +"@babel/helper-plugin-utils@npm:^7.0.0, @babel/helper-plugin-utils@npm:^7.10.4, @babel/helper-plugin-utils@npm:^7.12.13, @babel/helper-plugin-utils@npm:^7.14.5, @babel/helper-plugin-utils@npm:^7.28.6, @babel/helper-plugin-utils@npm:^7.8.0": + version: 7.28.6 + resolution: "@babel/helper-plugin-utils@npm:7.28.6" + checksum: a0b4caab5e2180b215faa4d141ceac9e82fad9d446b8023eaeb8d82a6e62024726675b07fe8e616dd12f34e2bb59747e8d57aa8adab3e0717d1b8d691b118379 + languageName: node + linkType: hard + +"@babel/helper-string-parser@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/helper-string-parser@npm:7.27.1" + checksum: 0a8464adc4b39b138aedcb443b09f4005d86207d7126e5e079177e05c3116107d856ec08282b365e9a79a9872f40f4092a6127f8d74c8a01c1ef789dacfc25d6 + languageName: node + linkType: hard + +"@babel/helper-validator-identifier@npm:^7.28.5": + version: 7.28.5 + resolution: "@babel/helper-validator-identifier@npm:7.28.5" + checksum: 5a251a6848e9712aea0338f659a1a3bd334d26219d5511164544ca8ec20774f098c3a6661e9da65a0d085c745c00bb62c8fada38a62f08fa1f8053bc0aeb57e4 + languageName: node + linkType: hard + +"@babel/helper-validator-option@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/helper-validator-option@npm:7.27.1" + checksum: db73e6a308092531c629ee5de7f0d04390835b21a263be2644276cb27da2384b64676cab9f22cd8d8dbd854c92b1d7d56fc8517cf0070c35d1c14a8c828b0903 + languageName: node + linkType: hard + +"@babel/helpers@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/helpers@npm:7.28.6" + dependencies: + "@babel/template": ^7.28.6 + "@babel/types": ^7.28.6 + checksum: 4f3d555ec20dde40a2fcb244c86bfd9ec007b57ec9b30a9d04334c1ea2c1670bb82c151024124e1ab27ccf0b1f5ad30167633457a7c9ffbf4064fad2643f12fc + languageName: node + linkType: hard + +"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.14.7, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.25.3, @babel/parser@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/parser@npm:7.28.6" + dependencies: + "@babel/types": ^7.28.6 + bin: + parser: ./bin/babel-parser.js + checksum: 2a35319792ceef9bc918f0ff854449bef0120707798fe147ef988b0606de226e2fbc3a562ba687148bfe5336c6c67358fb27e71a94e425b28482dcaf0b172fd6 + languageName: node + linkType: hard + +"@babel/plugin-syntax-async-generators@npm:^7.8.4": + version: 7.8.4 + resolution: "@babel/plugin-syntax-async-generators@npm:7.8.4" + dependencies: + "@babel/helper-plugin-utils": ^7.8.0 + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 7ed1c1d9b9e5b64ef028ea5e755c0be2d4e5e4e3d6cf7df757b9a8c4cfa4193d268176d0f1f7fbecdda6fe722885c7fda681f480f3741d8a2d26854736f05367 + languageName: node + linkType: hard + +"@babel/plugin-syntax-bigint@npm:^7.8.3": + version: 7.8.3 + resolution: "@babel/plugin-syntax-bigint@npm:7.8.3" + dependencies: + "@babel/helper-plugin-utils": ^7.8.0 + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 3a10849d83e47aec50f367a9e56a6b22d662ddce643334b087f9828f4c3dd73bdc5909aaeabe123fed78515767f9ca43498a0e621c438d1cd2802d7fae3c9648 + languageName: node + linkType: hard + +"@babel/plugin-syntax-class-properties@npm:^7.12.13": + version: 7.12.13 + resolution: "@babel/plugin-syntax-class-properties@npm:7.12.13" + dependencies: + "@babel/helper-plugin-utils": ^7.12.13 + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 24f34b196d6342f28d4bad303612d7ff566ab0a013ce89e775d98d6f832969462e7235f3e7eaf17678a533d4be0ba45d3ae34ab4e5a9dcbda5d98d49e5efa2fc + languageName: node + linkType: hard + +"@babel/plugin-syntax-class-static-block@npm:^7.14.5": + version: 7.14.5 + resolution: "@babel/plugin-syntax-class-static-block@npm:7.14.5" + dependencies: + "@babel/helper-plugin-utils": ^7.14.5 + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 3e80814b5b6d4fe17826093918680a351c2d34398a914ce6e55d8083d72a9bdde4fbaf6a2dcea0e23a03de26dc2917ae3efd603d27099e2b98380345703bf948 + languageName: node + linkType: hard + +"@babel/plugin-syntax-import-attributes@npm:^7.24.7": + version: 7.28.6 + resolution: "@babel/plugin-syntax-import-attributes@npm:7.28.6" + dependencies: + "@babel/helper-plugin-utils": ^7.28.6 + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 6c8c6a5988dbb9799d6027360d1a5ba64faabf551f2ef11ba4eade0c62253b5c85d44ddc8eb643c74b9acb2bcaa664a950bd5de9a5d4aef291c4f2a48223bb4b + languageName: node + linkType: hard + +"@babel/plugin-syntax-import-meta@npm:^7.10.4": + version: 7.10.4 + resolution: "@babel/plugin-syntax-import-meta@npm:7.10.4" + dependencies: + "@babel/helper-plugin-utils": ^7.10.4 + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 166ac1125d10b9c0c430e4156249a13858c0366d38844883d75d27389621ebe651115cb2ceb6dc011534d5055719fa1727b59f39e1ab3ca97820eef3dcab5b9b + languageName: node + linkType: hard + +"@babel/plugin-syntax-json-strings@npm:^7.8.3": + version: 7.8.3 + resolution: "@babel/plugin-syntax-json-strings@npm:7.8.3" + dependencies: + "@babel/helper-plugin-utils": ^7.8.0 + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: bf5aea1f3188c9a507e16efe030efb996853ca3cadd6512c51db7233cc58f3ac89ff8c6bdfb01d30843b161cfe7d321e1bf28da82f7ab8d7e6bc5464666f354a + languageName: node + linkType: hard + +"@babel/plugin-syntax-logical-assignment-operators@npm:^7.10.4": + version: 7.10.4 + resolution: "@babel/plugin-syntax-logical-assignment-operators@npm:7.10.4" + dependencies: + "@babel/helper-plugin-utils": ^7.10.4 + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: aff33577037e34e515911255cdbb1fd39efee33658aa00b8a5fd3a4b903585112d037cce1cc9e4632f0487dc554486106b79ccd5ea63a2e00df4363f6d4ff886 + languageName: node + linkType: hard + +"@babel/plugin-syntax-nullish-coalescing-operator@npm:^7.8.3": + version: 7.8.3 + resolution: "@babel/plugin-syntax-nullish-coalescing-operator@npm:7.8.3" + dependencies: + "@babel/helper-plugin-utils": ^7.8.0 + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 87aca4918916020d1fedba54c0e232de408df2644a425d153be368313fdde40d96088feed6c4e5ab72aac89be5d07fef2ddf329a15109c5eb65df006bf2580d1 + languageName: node + linkType: hard + +"@babel/plugin-syntax-numeric-separator@npm:^7.10.4": + version: 7.10.4 + resolution: "@babel/plugin-syntax-numeric-separator@npm:7.10.4" + dependencies: + "@babel/helper-plugin-utils": ^7.10.4 + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 01ec5547bd0497f76cc903ff4d6b02abc8c05f301c88d2622b6d834e33a5651aa7c7a3d80d8d57656a4588f7276eba357f6b7e006482f5b564b7a6488de493a1 + languageName: node + linkType: hard + +"@babel/plugin-syntax-object-rest-spread@npm:^7.8.3": + version: 7.8.3 + resolution: "@babel/plugin-syntax-object-rest-spread@npm:7.8.3" + dependencies: + "@babel/helper-plugin-utils": ^7.8.0 + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: fddcf581a57f77e80eb6b981b10658421bc321ba5f0a5b754118c6a92a5448f12a0c336f77b8abf734841e102e5126d69110a306eadb03ca3e1547cab31f5cbf + languageName: node + linkType: hard + +"@babel/plugin-syntax-optional-catch-binding@npm:^7.8.3": + version: 7.8.3 + resolution: "@babel/plugin-syntax-optional-catch-binding@npm:7.8.3" + dependencies: + "@babel/helper-plugin-utils": ^7.8.0 + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 910d90e72bc90ea1ce698e89c1027fed8845212d5ab588e35ef91f13b93143845f94e2539d831dc8d8ededc14ec02f04f7bd6a8179edd43a326c784e7ed7f0b9 + languageName: node + linkType: hard + +"@babel/plugin-syntax-optional-chaining@npm:^7.8.3": + version: 7.8.3 + resolution: "@babel/plugin-syntax-optional-chaining@npm:7.8.3" + dependencies: + "@babel/helper-plugin-utils": ^7.8.0 + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: eef94d53a1453361553c1f98b68d17782861a04a392840341bc91780838dd4e695209c783631cf0de14c635758beafb6a3a65399846ffa4386bff90639347f30 + languageName: node + linkType: hard + +"@babel/plugin-syntax-private-property-in-object@npm:^7.14.5": + version: 7.14.5 + resolution: "@babel/plugin-syntax-private-property-in-object@npm:7.14.5" + dependencies: + "@babel/helper-plugin-utils": ^7.14.5 + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: b317174783e6e96029b743ccff2a67d63d38756876e7e5d0ba53a322e38d9ca452c13354a57de1ad476b4c066dbae699e0ca157441da611117a47af88985ecda + languageName: node + linkType: hard + +"@babel/plugin-syntax-top-level-await@npm:^7.14.5": + version: 7.14.5 + resolution: "@babel/plugin-syntax-top-level-await@npm:7.14.5" + dependencies: + "@babel/helper-plugin-utils": ^7.14.5 + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: bbd1a56b095be7820029b209677b194db9b1d26691fe999856462e66b25b281f031f3dfd91b1619e9dcf95bebe336211833b854d0fb8780d618e35667c2d0d7e + languageName: node + linkType: hard + +"@babel/runtime@npm:^7.25.0": + version: 7.28.6 + resolution: "@babel/runtime@npm:7.28.6" + checksum: 42d8a868c2fc2e9a77927945a6daa7ec03c7ea49e611e0d15442933cdabb12f20e3a6849c729259076c10a4247adec229331d1f94c2d0073ea0979d7853e29fd + languageName: node + linkType: hard + +"@babel/template@npm:^7.25.0, @babel/template@npm:^7.28.6, @babel/template@npm:^7.3.3": + version: 7.28.6 + resolution: "@babel/template@npm:7.28.6" + dependencies: + "@babel/code-frame": ^7.28.6 + "@babel/parser": ^7.28.6 + "@babel/types": ^7.28.6 + checksum: 8ab6383053e226025d9491a6e795293f2140482d14f60c1244bece6bf53610ed1e251d5e164de66adab765629881c7d9416e1e540c716541d2fd0f8f36a013d7 + languageName: node + linkType: hard + +"@babel/traverse--for-generate-function-map@npm:@babel/traverse@^7.25.3, @babel/traverse@npm:^7.25.3, @babel/traverse@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/traverse@npm:7.28.6" + dependencies: + "@babel/code-frame": ^7.28.6 + "@babel/generator": ^7.28.6 + "@babel/helper-globals": ^7.28.0 + "@babel/parser": ^7.28.6 + "@babel/template": ^7.28.6 + "@babel/types": ^7.28.6 + debug: ^4.3.1 + checksum: 07bc23b720d111a20382fcdba776b800a7c1f94e35f8e4f417869f6769ba67c2b9573c8240924ca3b0ee5a88fa7ed048efb289e8b324f5cb4971e771174a0d32 + languageName: node + linkType: hard + +"@babel/types@npm:^7.0.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.25.2, @babel/types@npm:^7.28.2, @babel/types@npm:^7.28.6, @babel/types@npm:^7.3.3": + version: 7.28.6 + resolution: "@babel/types@npm:7.28.6" + dependencies: + "@babel/helper-string-parser": ^7.27.1 + "@babel/helper-validator-identifier": ^7.28.5 + checksum: f76556cda59be337cc10dc68b2a9a947c10de018998bab41076e7b7e4489b28dd53299f98f22eec0774264c989515e6fdc56de91c73e3aa396367bb953200a6a + languageName: node + linkType: hard + +"@commitlint/cli@npm:^17.8.1": + version: 17.8.1 + resolution: "@commitlint/cli@npm:17.8.1" + dependencies: + "@commitlint/format": ^17.8.1 + "@commitlint/lint": ^17.8.1 + "@commitlint/load": ^17.8.1 + "@commitlint/read": ^17.8.1 + "@commitlint/types": ^17.8.1 + execa: ^5.0.0 + lodash.isfunction: ^3.0.9 + resolve-from: 5.0.0 + resolve-global: 1.0.0 + yargs: ^17.0.0 + bin: + commitlint: cli.js + checksum: 293d5868e2f586a9ac5364c40eeb0fe2131ea689312c43d43ababe6f2415c998619c5070cf89e7298125a1d96b9e5912b85f51db75aedbfb189d67554f911dbf + languageName: node + linkType: hard + +"@commitlint/config-conventional@npm:^17.0.2": + version: 17.8.1 + resolution: "@commitlint/config-conventional@npm:17.8.1" + dependencies: + conventional-changelog-conventionalcommits: ^6.1.0 + checksum: ce8ace1a13f3a797ed699ffa13dc46273a27e1dc3ae8a9d01492c0637a8592e4ed24bb32d9a43f8745a8690a52d77ea4a950d039977b0dbcbf834f8cbacf5def + languageName: node + linkType: hard + +"@commitlint/config-validator@npm:^17.8.1": + version: 17.8.1 + resolution: "@commitlint/config-validator@npm:17.8.1" + dependencies: + "@commitlint/types": ^17.8.1 + ajv: ^8.11.0 + checksum: 487051cc36a82ba50f217dfd26721f4fa26d8c4206ee5cb0debd2793aa950280f3ca5bd1a8738e9c71ca8508b58548918b43169c21219ca4cb67f5dcd1e49d9f + languageName: node + linkType: hard + +"@commitlint/ensure@npm:^17.8.1": + version: 17.8.1 + resolution: "@commitlint/ensure@npm:17.8.1" + dependencies: + "@commitlint/types": ^17.8.1 + lodash.camelcase: ^4.3.0 + lodash.kebabcase: ^4.1.1 + lodash.snakecase: ^4.1.1 + lodash.startcase: ^4.4.0 + lodash.upperfirst: ^4.3.1 + checksum: a4a5d3071df0e52dad0293c649c236f070c4fcd3380f11747a6f9b06b036adea281e557d117156e31313fbe18a7d71bf06e05e92776adbde7867190e1735bc43 + languageName: node + linkType: hard + +"@commitlint/execute-rule@npm:^17.8.1": + version: 17.8.1 + resolution: "@commitlint/execute-rule@npm:17.8.1" + checksum: 73354b5605931a71f727ee0262a5509277e92f134e2d704d44eafe4da7acb1cd2c7d084dcf8096cc0ac7ce83b023cc0ae8f79b17487b132ccc2e0b3920105a11 + languageName: node + linkType: hard + +"@commitlint/format@npm:^17.8.1": + version: 17.8.1 + resolution: "@commitlint/format@npm:17.8.1" + dependencies: + "@commitlint/types": ^17.8.1 + chalk: ^4.1.0 + checksum: 0481e4d49196c942d7723a1abd352c3c884ceb9f434fb4e64bfab71bc264e9b7c643a81069f20d2a035fca70261a472508d73b1a60fe378c60534ca6301408b6 + languageName: node + linkType: hard + +"@commitlint/is-ignored@npm:^17.8.1": + version: 17.8.1 + resolution: "@commitlint/is-ignored@npm:17.8.1" + dependencies: + "@commitlint/types": ^17.8.1 + semver: 7.5.4 + checksum: 26eb2f1a84a774625f3f6fe4fa978c57d81028ee6a6925ab3fb02981ac395f9584ab4a71af59c3f2ac84a06c775e3f52683c033c565d86271a7aa99c2eb6025c + languageName: node + linkType: hard + +"@commitlint/lint@npm:^17.8.1": + version: 17.8.1 + resolution: "@commitlint/lint@npm:17.8.1" + dependencies: + "@commitlint/is-ignored": ^17.8.1 + "@commitlint/parse": ^17.8.1 + "@commitlint/rules": ^17.8.1 + "@commitlint/types": ^17.8.1 + checksum: 025712ad928098b3f94d8dc38566785f6c3eeba799725dbd935c5514141ea77b01e036fed1dbbf60cc954736f706ddbb85339751c43f16f5f3f94170d1decb2a + languageName: node + linkType: hard + +"@commitlint/load@npm:^17.8.1": + version: 17.8.1 + resolution: "@commitlint/load@npm:17.8.1" + dependencies: + "@commitlint/config-validator": ^17.8.1 + "@commitlint/execute-rule": ^17.8.1 + "@commitlint/resolve-extends": ^17.8.1 + "@commitlint/types": ^17.8.1 + "@types/node": 20.5.1 + chalk: ^4.1.0 + cosmiconfig: ^8.0.0 + cosmiconfig-typescript-loader: ^4.0.0 + lodash.isplainobject: ^4.0.6 + lodash.merge: ^4.6.2 + lodash.uniq: ^4.5.0 + resolve-from: ^5.0.0 + ts-node: ^10.8.1 + typescript: ^4.6.4 || ^5.2.2 + checksum: 5a9a9f0d4621a4cc61c965c3adc88d04ccac40640b022bb3bbad70ed4435bb0c103647a2e29e37fc3d68021dae041c937bee611fe2e5461bebe997640f4f626b + languageName: node + linkType: hard + +"@commitlint/message@npm:^17.8.1": + version: 17.8.1 + resolution: "@commitlint/message@npm:17.8.1" + checksum: ee3ca9bf02828ea322becba47c67f7585aa3fd22b197eab69679961e67e3c7bdf56f6ef41cb3b831b521af7dabd305eb5d7ee053c8294531cc8ca64dbbff82fc + languageName: node + linkType: hard + +"@commitlint/parse@npm:^17.8.1": + version: 17.8.1 + resolution: "@commitlint/parse@npm:17.8.1" + dependencies: + "@commitlint/types": ^17.8.1 + conventional-changelog-angular: ^6.0.0 + conventional-commits-parser: ^4.0.0 + checksum: 5322ae049b43a329761063b6e698714593d84d874147ced6290c8d88a9ebea2ba8c660a5815392a731377ac26fbf6b215bb9b87d84d8b49cb47fa1c62d228b24 + languageName: node + linkType: hard + +"@commitlint/read@npm:^17.8.1": + version: 17.8.1 + resolution: "@commitlint/read@npm:17.8.1" + dependencies: + "@commitlint/top-level": ^17.8.1 + "@commitlint/types": ^17.8.1 + fs-extra: ^11.0.0 + git-raw-commits: ^2.0.11 + minimist: ^1.2.6 + checksum: 122f1842cb8b87b2c447383095420d077dcae6fbb4f871f8b05fa088f99d95d18a8c6675be2eb3e67bf7ff47a9990764261e3eebc5e474404f14e3379f48df42 + languageName: node + linkType: hard + +"@commitlint/resolve-extends@npm:^17.8.1": + version: 17.8.1 + resolution: "@commitlint/resolve-extends@npm:17.8.1" + dependencies: + "@commitlint/config-validator": ^17.8.1 + "@commitlint/types": ^17.8.1 + import-fresh: ^3.0.0 + lodash.mergewith: ^4.6.2 + resolve-from: ^5.0.0 + resolve-global: ^1.0.0 + checksum: c6fb7d3f263b876ff805396abad27bc514b1a69dcc634903c28782f4f3932eddc37221daa3264a45a5b82d28aa17a57c7bab4830c6efae741cc875f137366608 + languageName: node + linkType: hard + +"@commitlint/rules@npm:^17.8.1": + version: 17.8.1 + resolution: "@commitlint/rules@npm:17.8.1" + dependencies: + "@commitlint/ensure": ^17.8.1 + "@commitlint/message": ^17.8.1 + "@commitlint/to-lines": ^17.8.1 + "@commitlint/types": ^17.8.1 + execa: ^5.0.0 + checksum: b284514a4b8dad6bcbbc91c7548d69d0bbe9fcbdb241c15f5f9da413e8577c19d11190f1d709b38487c49dc874359bd9d0b72ab39f91cce06191e4ddaf8ec84d + languageName: node + linkType: hard + +"@commitlint/to-lines@npm:^17.8.1": + version: 17.8.1 + resolution: "@commitlint/to-lines@npm:17.8.1" + checksum: ff175c202c89537301f32b6e13ebe6919ac782a6e109cb5f6136566d71555a54f6574caf4d674d3409d32fdea1b4a28518837632ca05c7557d4f18f339574e62 + languageName: node + linkType: hard + +"@commitlint/top-level@npm:^17.8.1": + version: 17.8.1 + resolution: "@commitlint/top-level@npm:17.8.1" + dependencies: + find-up: ^5.0.0 + checksum: 25c8a6f4026c705a5ad4d9358eae7558734f549623da1c5f44cba8d6bc495f20d3ad05418febb8dca4f6b63f40bf44763007a14ab7209c435566843be114e7fc + languageName: node + linkType: hard + +"@commitlint/types@npm:^17.8.1": + version: 17.8.1 + resolution: "@commitlint/types@npm:17.8.1" + dependencies: + chalk: ^4.1.0 + checksum: a4cfa8c417aa0209694b96da04330282e41150caae1e1d0cec596ea34e3ce15afb84b3263abe5b89758ec1f3f71a9de0ee2d593df66db17b283127dd5e7cd6ac + languageName: node + linkType: hard + +"@cspotcode/source-map-support@npm:^0.8.0": + version: 0.8.1 + resolution: "@cspotcode/source-map-support@npm:0.8.1" + dependencies: + "@jridgewell/trace-mapping": 0.3.9 + checksum: 5718f267085ed8edb3e7ef210137241775e607ee18b77d95aa5bd7514f47f5019aa2d82d96b3bf342ef7aa890a346fa1044532ff7cc3009e7d24fce3ce6200fa + languageName: node + linkType: hard + +"@emnapi/core@npm:^1.1.0": + version: 1.8.1 + resolution: "@emnapi/core@npm:1.8.1" + dependencies: + "@emnapi/wasi-threads": 1.1.0 + tslib: ^2.4.0 + checksum: 2a2fb36f4e2f90e25f419f8979435160313664bbb833d852d9de4487ff47f05fd36bf2cd77c3555f704ec2b67ce3a949ed5542598664c775cdd5ef35ae1c85a4 + languageName: node + linkType: hard + +"@emnapi/runtime@npm:^1.1.0": + version: 1.8.1 + resolution: "@emnapi/runtime@npm:1.8.1" + dependencies: + tslib: ^2.4.0 + checksum: 0000a91d2d0ec3aaa37cbab9c360de3ff8250592f3ce4706b8c9c6d93e54151e623a8983c85543f33cb6f66cf30bb24bf0ddde466de484d6a6bf1fb2650382de + languageName: node + linkType: hard + +"@emnapi/wasi-threads@npm:1.1.0": + version: 1.1.0 + resolution: "@emnapi/wasi-threads@npm:1.1.0" + dependencies: + tslib: ^2.4.0 + checksum: 6cffe35f3e407ae26236092991786db5968b4265e6e55f4664bf6f2ce0508e2a02a44ce6ebb16f2acd2f6589efb293f4f9d09cc9fbf80c00fc1a203accc94196 + languageName: node + linkType: hard + +"@eslint-community/eslint-utils@npm:^4.2.0, @eslint-community/eslint-utils@npm:^4.9.1": + version: 4.9.1 + resolution: "@eslint-community/eslint-utils@npm:4.9.1" + dependencies: + eslint-visitor-keys: ^3.4.3 + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + checksum: 0a27c2d676c4be6b329ebb5dd8f6c5ef5fae9a019ff575655306d72874bb26f3ab20e0b241a5f086464bb1f2511ca26a29ff6f80c1e2b0b02eca4686b4dfe1b5 + languageName: node + linkType: hard + +"@eslint-community/regexpp@npm:^4.12.2, @eslint-community/regexpp@npm:^4.6.1": + version: 4.12.2 + resolution: "@eslint-community/regexpp@npm:4.12.2" + checksum: 1770bc81f676a72f65c7200b5675ff7a349786521f30e66125faaf767fde1ba1c19c3790e16ba8508a62a3933afcfc806a893858b3b5906faf693d862b9e4120 + languageName: node + linkType: hard + +"@eslint/eslintrc@npm:^2.1.4": + version: 2.1.4 + resolution: "@eslint/eslintrc@npm:2.1.4" + dependencies: + ajv: ^6.12.4 + debug: ^4.3.2 + espree: ^9.6.0 + globals: ^13.19.0 + ignore: ^5.2.0 + import-fresh: ^3.2.1 + js-yaml: ^4.1.0 + minimatch: ^3.1.2 + strip-json-comments: ^3.1.1 + checksum: 10957c7592b20ca0089262d8c2a8accbad14b4f6507e35416c32ee6b4dbf9cad67dfb77096bbd405405e9ada2b107f3797fe94362e1c55e0b09d6e90dd149127 + languageName: node + linkType: hard + +"@eslint/js@npm:8.57.1": + version: 8.57.1 + resolution: "@eslint/js@npm:8.57.1" + checksum: 2afb77454c06e8316793d2e8e79a0154854d35e6782a1217da274ca60b5044d2c69d6091155234ed0551a1e408f86f09dd4ece02752c59568fa403e60611e880 + languageName: node + linkType: hard + +"@evilmartians/lefthook@npm:^1.5.0": + version: 1.13.6 + resolution: "@evilmartians/lefthook@npm:1.13.6" + bin: + lefthook: bin/index.js + checksum: 6cceca3e874015678f50818ae14a74d959816cfaba6638f8852d007332404d6819b15c71538985a3650a1ef057aa6975c17fadfe43ece7a0da1aeb9faaf02946 + conditions: (os=darwin | os=linux | os=win32) & (cpu=x64 | cpu=arm64 | cpu=ia32) + languageName: node + linkType: hard + +"@humanwhocodes/config-array@npm:^0.13.0": + version: 0.13.0 + resolution: "@humanwhocodes/config-array@npm:0.13.0" + dependencies: + "@humanwhocodes/object-schema": ^2.0.3 + debug: ^4.3.1 + minimatch: ^3.0.5 + checksum: eae69ff9134025dd2924f0b430eb324981494be26f0fddd267a33c28711c4db643242cf9fddf7dadb9d16c96b54b2d2c073e60a56477df86e0173149313bd5d6 + languageName: node + linkType: hard + +"@humanwhocodes/module-importer@npm:^1.0.1": + version: 1.0.1 + resolution: "@humanwhocodes/module-importer@npm:1.0.1" + checksum: 0fd22007db8034a2cdf2c764b140d37d9020bbfce8a49d3ec5c05290e77d4b0263b1b972b752df8c89e5eaa94073408f2b7d977aed131faf6cf396ebb5d7fb61 + languageName: node + linkType: hard + +"@humanwhocodes/object-schema@npm:^2.0.3": + version: 2.0.3 + resolution: "@humanwhocodes/object-schema@npm:2.0.3" + checksum: d3b78f6c5831888c6ecc899df0d03bcc25d46f3ad26a11d7ea52944dc36a35ef543fad965322174238d677a43d5c694434f6607532cff7077062513ad7022631 + languageName: node + linkType: hard + +"@hutson/parse-repository-url@npm:^3.0.0": + version: 3.0.2 + resolution: "@hutson/parse-repository-url@npm:3.0.2" + checksum: 39992c5f183c5ca3d761d6ed9dfabcb79b5f3750bf1b7f3532e1dc439ca370138bbd426ee250fdaba460bc948e6761fbefd484b8f4f36885d71ded96138340d1 + languageName: node + linkType: hard + +"@inquirer/external-editor@npm:^1.0.0": + version: 1.0.3 + resolution: "@inquirer/external-editor@npm:1.0.3" + dependencies: + chardet: ^2.1.1 + iconv-lite: ^0.7.0 + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 9bd7a05247a00408c194648c74046d8a212df1e6b9fe0879b945ebfc35c2524e995e43f7ecd83f14d0bd4e31f985d18819efc31c27810e2c2b838ded7261431f + languageName: node + linkType: hard + +"@isaacs/balanced-match@npm:^4.0.1": + version: 4.0.1 + resolution: "@isaacs/balanced-match@npm:4.0.1" + checksum: 102fbc6d2c0d5edf8f6dbf2b3feb21695a21bc850f11bc47c4f06aa83bd8884fde3fe9d6d797d619901d96865fdcb4569ac2a54c937992c48885c5e3d9967fe8 + languageName: node + linkType: hard + +"@isaacs/brace-expansion@npm:^5.0.0": + version: 5.0.0 + resolution: "@isaacs/brace-expansion@npm:5.0.0" + dependencies: + "@isaacs/balanced-match": ^4.0.1 + checksum: d7a3b8b0ddbf0ccd8eeb1300e29dd0a0c02147e823d8138f248375a365682360620895c66d113e05ee02389318c654379b0e538b996345b83c914941786705b1 + languageName: node + linkType: hard + +"@isaacs/cliui@npm:^8.0.2": + version: 8.0.2 + resolution: "@isaacs/cliui@npm:8.0.2" + dependencies: + string-width: ^5.1.2 + string-width-cjs: "npm:string-width@^4.2.0" + strip-ansi: ^7.0.1 + strip-ansi-cjs: "npm:strip-ansi@^6.0.1" + wrap-ansi: ^8.1.0 + wrap-ansi-cjs: "npm:wrap-ansi@^7.0.0" + checksum: 4a473b9b32a7d4d3cfb7a614226e555091ff0c5a29a1734c28c72a182c2f6699b26fc6b5c2131dfd841e86b185aea714c72201d7c98c2fba5f17709333a67aeb + languageName: node + linkType: hard + +"@isaacs/fs-minipass@npm:^4.0.0": + version: 4.0.1 + resolution: "@isaacs/fs-minipass@npm:4.0.1" + dependencies: + minipass: ^7.0.4 + checksum: 5d36d289960e886484362d9eb6a51d1ea28baed5f5d0140bbe62b99bac52eaf06cc01c2bc0d3575977962f84f6b2c4387b043ee632216643d4787b0999465bf2 + languageName: node + linkType: hard + +"@isaacs/string-locale-compare@npm:^1.1.0": + version: 1.1.0 + resolution: "@isaacs/string-locale-compare@npm:1.1.0" + checksum: 7287da5d11497b82c542d3c2abe534808015be4f4883e71c26853277b5456f6bbe4108535db847a29f385ad6dc9318ffb0f55ee79bb5f39993233d7dccf8751d + languageName: node + linkType: hard + +"@isaacs/ttlcache@npm:^1.4.1": + version: 1.4.1 + resolution: "@isaacs/ttlcache@npm:1.4.1" + checksum: b99f0918faf1eba405b6bc3421584282b2edc46cca23f8d8e112a643bf6e4506c6c53a4525901118e229d19c5719bbec3028ec438d758fd71081f6c32af871ec + languageName: node + linkType: hard + +"@istanbuljs/load-nyc-config@npm:^1.0.0": + version: 1.1.0 + resolution: "@istanbuljs/load-nyc-config@npm:1.1.0" + dependencies: + camelcase: ^5.3.1 + find-up: ^4.1.0 + get-package-type: ^0.1.0 + js-yaml: ^3.13.1 + resolve-from: ^5.0.0 + checksum: d578da5e2e804d5c93228450a1380e1a3c691de4953acc162f387b717258512a3e07b83510a936d9fab03eac90817473917e24f5d16297af3867f59328d58568 + languageName: node + linkType: hard + +"@istanbuljs/schema@npm:^0.1.2": + version: 0.1.3 + resolution: "@istanbuljs/schema@npm:0.1.3" + checksum: 5282759d961d61350f33d9118d16bcaed914ebf8061a52f4fa474b2cb08720c9c81d165e13b82f2e5a8a212cc5af482f0c6fc1ac27b9e067e5394c9a6ed186c9 + languageName: node + linkType: hard + +"@jest/create-cache-key-function@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/create-cache-key-function@npm:29.7.0" + dependencies: + "@jest/types": ^29.6.3 + checksum: 681bc761fa1d6fa3dd77578d444f97f28296ea80755e90e46d1c8fa68661b9e67f54dd38b988742db636d26cf160450dc6011892cec98b3a7ceb58cad8ff3aae + languageName: node + linkType: hard + +"@jest/environment@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/environment@npm:29.7.0" + dependencies: + "@jest/fake-timers": ^29.7.0 + "@jest/types": ^29.6.3 + "@types/node": "*" + jest-mock: ^29.7.0 + checksum: 6fb398143b2543d4b9b8d1c6dbce83fa5247f84f550330604be744e24c2bd2178bb893657d62d1b97cf2f24baf85c450223f8237cccb71192c36a38ea2272934 + languageName: node + linkType: hard + +"@jest/fake-timers@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/fake-timers@npm:29.7.0" + dependencies: + "@jest/types": ^29.6.3 + "@sinonjs/fake-timers": ^10.0.2 + "@types/node": "*" + jest-message-util: ^29.7.0 + jest-mock: ^29.7.0 + jest-util: ^29.7.0 + checksum: caf2bbd11f71c9241b458d1b5a66cbe95debc5a15d96442444b5d5c7ba774f523c76627c6931cca5e10e76f0d08761f6f1f01a608898f4751a0eee54fc3d8d00 + languageName: node + linkType: hard + +"@jest/schemas@npm:^29.6.3": + version: 29.6.3 + resolution: "@jest/schemas@npm:29.6.3" + dependencies: + "@sinclair/typebox": ^0.27.8 + checksum: 910040425f0fc93cd13e68c750b7885590b8839066dfa0cd78e7def07bbb708ad869381f725945d66f2284de5663bbecf63e8fdd856e2ae6e261ba30b1687e93 + languageName: node + linkType: hard + +"@jest/transform@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/transform@npm:29.7.0" + dependencies: + "@babel/core": ^7.11.6 + "@jest/types": ^29.6.3 + "@jridgewell/trace-mapping": ^0.3.18 + babel-plugin-istanbul: ^6.1.1 + chalk: ^4.0.0 + convert-source-map: ^2.0.0 + fast-json-stable-stringify: ^2.1.0 + graceful-fs: ^4.2.9 + jest-haste-map: ^29.7.0 + jest-regex-util: ^29.6.3 + jest-util: ^29.7.0 + micromatch: ^4.0.4 + pirates: ^4.0.4 + slash: ^3.0.0 + write-file-atomic: ^4.0.2 + checksum: 0f8ac9f413903b3cb6d240102db848f2a354f63971ab885833799a9964999dd51c388162106a807f810071f864302cdd8e3f0c241c29ce02d85a36f18f3f40ab + languageName: node + linkType: hard + +"@jest/types@npm:^29.6.3": + version: 29.6.3 + resolution: "@jest/types@npm:29.6.3" + dependencies: + "@jest/schemas": ^29.6.3 + "@types/istanbul-lib-coverage": ^2.0.0 + "@types/istanbul-reports": ^3.0.0 + "@types/node": "*" + "@types/yargs": ^17.0.8 + chalk: ^4.0.0 + checksum: a0bcf15dbb0eca6bdd8ce61a3fb055349d40268622a7670a3b2eb3c3dbafe9eb26af59938366d520b86907b9505b0f9b29b85cec11579a9e580694b87cd90fcc + languageName: node + linkType: hard + +"@jridgewell/gen-mapping@npm:^0.3.12, @jridgewell/gen-mapping@npm:^0.3.5": + version: 0.3.13 + resolution: "@jridgewell/gen-mapping@npm:0.3.13" + dependencies: + "@jridgewell/sourcemap-codec": ^1.5.0 + "@jridgewell/trace-mapping": ^0.3.24 + checksum: f2105acefc433337145caa3c84bba286de954f61c0bc46279bbd85a9e6a02871089717fa060413cfb6a9d44189fe8313b2d1cabf3a2eb3284d208fd5f75c54ff + languageName: node + linkType: hard + +"@jridgewell/remapping@npm:^2.3.5": + version: 2.3.5 + resolution: "@jridgewell/remapping@npm:2.3.5" + dependencies: + "@jridgewell/gen-mapping": ^0.3.5 + "@jridgewell/trace-mapping": ^0.3.24 + checksum: 4a66a7397c3dc9c6b5c14a0024b1f98c5e1d90a0dbc1e5955b5038f2db339904df2a0ee8a66559fafb4fc23ff33700a2639fd40bbdd2e9e82b58b3bdf83738e3 + languageName: node + linkType: hard + +"@jridgewell/resolve-uri@npm:^3.0.3, @jridgewell/resolve-uri@npm:^3.1.0": + version: 3.1.2 + resolution: "@jridgewell/resolve-uri@npm:3.1.2" + checksum: 83b85f72c59d1c080b4cbec0fef84528963a1b5db34e4370fa4bd1e3ff64a0d80e0cee7369d11d73c704e0286fb2865b530acac7a871088fbe92b5edf1000870 + languageName: node + linkType: hard + +"@jridgewell/source-map@npm:^0.3.3": + version: 0.3.11 + resolution: "@jridgewell/source-map@npm:0.3.11" + dependencies: + "@jridgewell/gen-mapping": ^0.3.5 + "@jridgewell/trace-mapping": ^0.3.25 + checksum: c8a0011cc67e701f270fa042e32b312f382c413bcc70ca9c03684687cbf5b64d5eed87d4afa36dddaabe60ab3da6db4935f878febd9cfc7f82724ea1a114d344 + languageName: node + linkType: hard + +"@jridgewell/sourcemap-codec@npm:^1.4.10, @jridgewell/sourcemap-codec@npm:^1.4.14, @jridgewell/sourcemap-codec@npm:^1.5.0": + version: 1.5.5 + resolution: "@jridgewell/sourcemap-codec@npm:1.5.5" + checksum: c2e36e67971f719a8a3a85ef5a5f580622437cc723c35d03ebd0c9c0b06418700ef006f58af742791f71f6a4fc68fcfaf1f6a74ec2f9a3332860e9373459dae7 + languageName: node + linkType: hard + +"@jridgewell/trace-mapping@npm:0.3.9": + version: 0.3.9 + resolution: "@jridgewell/trace-mapping@npm:0.3.9" + dependencies: + "@jridgewell/resolve-uri": ^3.0.3 + "@jridgewell/sourcemap-codec": ^1.4.10 + checksum: d89597752fd88d3f3480845691a05a44bd21faac18e2185b6f436c3b0fd0c5a859fbbd9aaa92050c4052caf325ad3e10e2e1d1b64327517471b7d51babc0ddef + languageName: node + linkType: hard + +"@jridgewell/trace-mapping@npm:^0.3.18, @jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.25, @jridgewell/trace-mapping@npm:^0.3.28": + version: 0.3.31 + resolution: "@jridgewell/trace-mapping@npm:0.3.31" + dependencies: + "@jridgewell/resolve-uri": ^3.1.0 + "@jridgewell/sourcemap-codec": ^1.4.14 + checksum: af8fda2431348ad507fbddf8e25f5d08c79ecc94594061ce402cf41bc5aba1a7b3e59bf0fd70a619b35f33983a3f488ceeba8faf56bff784f98bb5394a8b7d47 + languageName: node + linkType: hard + +"@lerna/create@npm:8.2.4": + version: 8.2.4 + resolution: "@lerna/create@npm:8.2.4" + dependencies: + "@npmcli/arborist": 7.5.4 + "@npmcli/package-json": 5.2.0 + "@npmcli/run-script": 8.1.0 + "@nx/devkit": ">=17.1.2 < 21" + "@octokit/plugin-enterprise-rest": 6.0.1 + "@octokit/rest": 20.1.2 + aproba: 2.0.0 + byte-size: 8.1.1 + chalk: 4.1.0 + clone-deep: 4.0.1 + cmd-shim: 6.0.3 + color-support: 1.1.3 + columnify: 1.6.0 + console-control-strings: ^1.1.0 + conventional-changelog-core: 5.0.1 + conventional-recommended-bump: 7.0.1 + cosmiconfig: 9.0.0 + dedent: 1.5.3 + execa: 5.0.0 + fs-extra: ^11.2.0 + get-stream: 6.0.0 + git-url-parse: 14.0.0 + glob-parent: 6.0.2 + graceful-fs: 4.2.11 + has-unicode: 2.0.1 + ini: ^1.3.8 + init-package-json: 6.0.3 + inquirer: ^8.2.4 + is-ci: 3.0.1 + is-stream: 2.0.0 + js-yaml: 4.1.0 + libnpmpublish: 9.0.9 + load-json-file: 6.2.0 + make-dir: 4.0.0 + minimatch: 3.0.5 + multimatch: 5.0.0 + node-fetch: 2.6.7 + npm-package-arg: 11.0.2 + npm-packlist: 8.0.2 + npm-registry-fetch: ^17.1.0 + nx: ">=17.1.2 < 21" + p-map: 4.0.0 + p-map-series: 2.1.0 + p-queue: 6.6.2 + p-reduce: ^2.1.0 + pacote: ^18.0.6 + pify: 5.0.0 + read-cmd-shim: 4.0.0 + resolve-from: 5.0.0 + rimraf: ^4.4.1 + semver: ^7.3.4 + set-blocking: ^2.0.0 + signal-exit: 3.0.7 + slash: ^3.0.0 + ssri: ^10.0.6 + string-width: ^4.2.3 + tar: 6.2.1 + temp-dir: 1.0.0 + through: 2.3.8 + tinyglobby: 0.2.12 + upath: 2.0.1 + uuid: ^10.0.0 + validate-npm-package-license: ^3.0.4 + validate-npm-package-name: 5.0.1 + wide-align: 1.1.5 + write-file-atomic: 5.0.1 + write-pkg: 4.0.0 + yargs: 17.7.2 + yargs-parser: 21.1.1 + checksum: 79ccd20fac694813728b6582f806f12b3549a3bc87a91d6df6260e0c623b669fa1f84815d985b1c5c9f6d717a57e7407b8e8c5e5e770d7ff39b3bdba9e91883f + languageName: node + linkType: hard + +"@napi-rs/wasm-runtime@npm:0.2.4": + version: 0.2.4 + resolution: "@napi-rs/wasm-runtime@npm:0.2.4" + dependencies: + "@emnapi/core": ^1.1.0 + "@emnapi/runtime": ^1.1.0 + "@tybys/wasm-util": ^0.9.0 + checksum: 976eeca9c411724bf004f92a94707f1c78b6a5932a354e8b456eaae16c476dd6b96244c4afec60a3f621c922fca3ef2c6c3f6a900bd6b79f509dd4c0c2b3376d + languageName: node + linkType: hard + +"@nodelib/fs.scandir@npm:2.1.5": + version: 2.1.5 + resolution: "@nodelib/fs.scandir@npm:2.1.5" + dependencies: + "@nodelib/fs.stat": 2.0.5 + run-parallel: ^1.1.9 + checksum: a970d595bd23c66c880e0ef1817791432dbb7acbb8d44b7e7d0e7a22f4521260d4a83f7f9fd61d44fda4610105577f8f58a60718105fb38352baed612fd79e59 + languageName: node + linkType: hard + +"@nodelib/fs.stat@npm:2.0.5, @nodelib/fs.stat@npm:^2.0.2": + version: 2.0.5 + resolution: "@nodelib/fs.stat@npm:2.0.5" + checksum: 012480b5ca9d97bff9261571dbbec7bbc6033f69cc92908bc1ecfad0792361a5a1994bc48674b9ef76419d056a03efadfce5a6cf6dbc0a36559571a7a483f6f0 + languageName: node + linkType: hard + +"@nodelib/fs.walk@npm:^1.2.3, @nodelib/fs.walk@npm:^1.2.8": + version: 1.2.8 + resolution: "@nodelib/fs.walk@npm:1.2.8" + dependencies: + "@nodelib/fs.scandir": 2.1.5 + fastq: ^1.6.0 + checksum: 190c643f156d8f8f277bf2a6078af1ffde1fd43f498f187c2db24d35b4b4b5785c02c7dc52e356497b9a1b65b13edc996de08de0b961c32844364da02986dc53 + languageName: node + linkType: hard + +"@npmcli/agent@npm:^2.0.0": + version: 2.2.2 + resolution: "@npmcli/agent@npm:2.2.2" + dependencies: + agent-base: ^7.1.0 + http-proxy-agent: ^7.0.0 + https-proxy-agent: ^7.0.1 + lru-cache: ^10.0.1 + socks-proxy-agent: ^8.0.3 + checksum: 67de7b88cc627a79743c88bab35e023e23daf13831a8aa4e15f998b92f5507b644d8ffc3788afc8e64423c612e0785a6a92b74782ce368f49a6746084b50d874 + languageName: node + linkType: hard + +"@npmcli/agent@npm:^4.0.0": + version: 4.0.0 + resolution: "@npmcli/agent@npm:4.0.0" + dependencies: + agent-base: ^7.1.0 + http-proxy-agent: ^7.0.0 + https-proxy-agent: ^7.0.1 + lru-cache: ^11.2.1 + socks-proxy-agent: ^8.0.3 + checksum: 89ae20b44859ff8d4de56ade319d8ceaa267a0742d6f7345fe98aa5cd8614ced7db85ea4dc5bfbd6614dbb200a10b134e087143582534c939e8a02219e8665c8 + languageName: node + linkType: hard + +"@npmcli/arborist@npm:7.5.4": + version: 7.5.4 + resolution: "@npmcli/arborist@npm:7.5.4" + dependencies: + "@isaacs/string-locale-compare": ^1.1.0 + "@npmcli/fs": ^3.1.1 + "@npmcli/installed-package-contents": ^2.1.0 + "@npmcli/map-workspaces": ^3.0.2 + "@npmcli/metavuln-calculator": ^7.1.1 + "@npmcli/name-from-folder": ^2.0.0 + "@npmcli/node-gyp": ^3.0.0 + "@npmcli/package-json": ^5.1.0 + "@npmcli/query": ^3.1.0 + "@npmcli/redact": ^2.0.0 + "@npmcli/run-script": ^8.1.0 + bin-links: ^4.0.4 + cacache: ^18.0.3 + common-ancestor-path: ^1.0.1 + hosted-git-info: ^7.0.2 + json-parse-even-better-errors: ^3.0.2 + json-stringify-nice: ^1.1.4 + lru-cache: ^10.2.2 + minimatch: ^9.0.4 + nopt: ^7.2.1 + npm-install-checks: ^6.2.0 + npm-package-arg: ^11.0.2 + npm-pick-manifest: ^9.0.1 + npm-registry-fetch: ^17.0.1 + pacote: ^18.0.6 + parse-conflict-json: ^3.0.0 + proc-log: ^4.2.0 + proggy: ^2.0.0 + promise-all-reject-late: ^1.0.0 + promise-call-limit: ^3.0.1 + read-package-json-fast: ^3.0.2 + semver: ^7.3.7 + ssri: ^10.0.6 + treeverse: ^3.0.0 + walk-up-path: ^3.0.1 + bin: + arborist: bin/index.js + checksum: 1b205a6f4744ecf342a96a804a3a22b07dcf96f32ca61a877cf388a8a23b1f3bc97fa4054927a7606376d802ff9374de321b49ee9a4c95cf606420bfd8a534d9 + languageName: node + linkType: hard + +"@npmcli/fs@npm:^3.1.0, @npmcli/fs@npm:^3.1.1": + version: 3.1.1 + resolution: "@npmcli/fs@npm:3.1.1" + dependencies: + semver: ^7.3.5 + checksum: d960cab4b93adcb31ce223bfb75c5714edbd55747342efb67dcc2f25e023d930a7af6ece3e75f2f459b6f38fc14d031c766f116cd124fdc937fd33112579e820 + languageName: node + linkType: hard + +"@npmcli/fs@npm:^5.0.0": + version: 5.0.0 + resolution: "@npmcli/fs@npm:5.0.0" + dependencies: + semver: ^7.3.5 + checksum: 897dac32eb37e011800112d406b9ea2ebd96f1dab01bb8fbeb59191b86f6825dffed6a89f3b6c824753d10f8735b76d630927bd7610e9e123b129ef2e5f02cb5 + languageName: node + linkType: hard + +"@npmcli/git@npm:^5.0.0": + version: 5.0.8 + resolution: "@npmcli/git@npm:5.0.8" + dependencies: + "@npmcli/promise-spawn": ^7.0.0 + ini: ^4.1.3 + lru-cache: ^10.0.1 + npm-pick-manifest: ^9.0.0 + proc-log: ^4.0.0 + promise-inflight: ^1.0.1 + promise-retry: ^2.0.1 + semver: ^7.3.5 + which: ^4.0.0 + checksum: 8c1733b591e428719c60fceaca74b3355967f6ddbce851c0d163a3c2e8123aaa717361b8226f8f8e606685f14721ea97d8f99c4b5831bc9251007bb1a20663cd + languageName: node + linkType: hard + +"@npmcli/installed-package-contents@npm:^2.0.1, @npmcli/installed-package-contents@npm:^2.1.0": + version: 2.1.0 + resolution: "@npmcli/installed-package-contents@npm:2.1.0" + dependencies: + npm-bundled: ^3.0.0 + npm-normalize-package-bin: ^3.0.0 + bin: + installed-package-contents: bin/index.js + checksum: d0f307e0c971a4ffaea44d4f38d53b57e19222413f338bab26d4321c4a7b9098318d74719dd1f8747a6de0575ac0ba29aeb388edf6599ac8299506947f53ffb6 + languageName: node + linkType: hard + +"@npmcli/map-workspaces@npm:^3.0.2": + version: 3.0.6 + resolution: "@npmcli/map-workspaces@npm:3.0.6" + dependencies: + "@npmcli/name-from-folder": ^2.0.0 + glob: ^10.2.2 + minimatch: ^9.0.0 + read-package-json-fast: ^3.0.0 + checksum: bdb09ee1d044bb9b2857d9e2d7ca82f40783a8549b5a7e150e25f874ee354cdbc8109ad7c3df42ec412f7057d95baa05920c4d361c868a93a42146b8e4390d3d + languageName: node + linkType: hard + +"@npmcli/metavuln-calculator@npm:^7.1.1": + version: 7.1.1 + resolution: "@npmcli/metavuln-calculator@npm:7.1.1" + dependencies: + cacache: ^18.0.0 + json-parse-even-better-errors: ^3.0.0 + pacote: ^18.0.0 + proc-log: ^4.1.0 + semver: ^7.3.5 + checksum: c6297e40f914100c4effb574c55ef95cbf15d0c28e73e39f29de317b12a3d3d82571f8aca3f7635cc4c8e97bff35942c71c59a79e1a8abc93475744e61abc399 + languageName: node + linkType: hard + +"@npmcli/name-from-folder@npm:^2.0.0": + version: 2.0.0 + resolution: "@npmcli/name-from-folder@npm:2.0.0" + checksum: fb3ef891aa57315fb6171866847f298577c8bda98a028e93e458048477133e142b4eb45ce9f3b80454f7c257612cb01754ee782d608507698dd712164436f5bd + languageName: node + linkType: hard + +"@npmcli/node-gyp@npm:^3.0.0": + version: 3.0.0 + resolution: "@npmcli/node-gyp@npm:3.0.0" + checksum: fe3802b813eecb4ade7ad77c9396cb56721664275faab027e3bd8a5e15adfbbe39e2ecc19f7885feb3cfa009b96632741cc81caf7850ba74440c6a2eee7b4ffc + languageName: node + linkType: hard + +"@npmcli/package-json@npm:5.2.0": + version: 5.2.0 + resolution: "@npmcli/package-json@npm:5.2.0" + dependencies: + "@npmcli/git": ^5.0.0 + glob: ^10.2.2 + hosted-git-info: ^7.0.0 + json-parse-even-better-errors: ^3.0.0 + normalize-package-data: ^6.0.0 + proc-log: ^4.0.0 + semver: ^7.5.3 + checksum: 8df289c45b52cca88826cc737195cabf21757008e11d90b1f62d5400ff65834c0e9bcb552f235ba560c3af436a1ca3fc553b23b5cb5da8330ae56929065a6988 + languageName: node + linkType: hard + +"@npmcli/package-json@npm:^5.0.0, @npmcli/package-json@npm:^5.1.0": + version: 5.2.1 + resolution: "@npmcli/package-json@npm:5.2.1" + dependencies: + "@npmcli/git": ^5.0.0 + glob: ^10.2.2 + hosted-git-info: ^7.0.0 + json-parse-even-better-errors: ^3.0.0 + normalize-package-data: ^6.0.0 + proc-log: ^4.0.0 + semver: ^7.5.3 + checksum: f9f76428fb3b3350fe840f1fa49854d18ff1ecb82b426c9cf53a62a37389c357a89d64a07497f50b7fbf1c742f5a0cd349d8efdddef0bb6982497f8356c1f98a + languageName: node + linkType: hard + +"@npmcli/promise-spawn@npm:^7.0.0": + version: 7.0.2 + resolution: "@npmcli/promise-spawn@npm:7.0.2" + dependencies: + which: ^4.0.0 + checksum: 728256506ecbafb53064036e28c2815b9a9e9190ba7a48eec77b011a9f8a899515a6d96760dbde960bc1d3e5b828fd0b0b7fe3b512efaf049d299bacbd732fda + languageName: node + linkType: hard + +"@npmcli/query@npm:^3.1.0": + version: 3.1.0 + resolution: "@npmcli/query@npm:3.1.0" + dependencies: + postcss-selector-parser: ^6.0.10 + checksum: 33c018bfcc6d64593e7969847d0442beab4e8a42b6c9f932237c9fd135c95ab55de5c4b5d5d66302dd9fc3c748bc4ead780d3595e5d586fedf9859ed6b5f2744 + languageName: node + linkType: hard + +"@npmcli/redact@npm:^2.0.0": + version: 2.0.1 + resolution: "@npmcli/redact@npm:2.0.1" + checksum: 78b0a71f0f578191dd2e19044894ded0328359138deb167f4ca75ec63a81ae59bae5289287793fdc36c125608be7631c5b3b32eaa083f62a551430c68b64d295 + languageName: node + linkType: hard + +"@npmcli/run-script@npm:8.1.0, @npmcli/run-script@npm:^8.0.0, @npmcli/run-script@npm:^8.1.0": + version: 8.1.0 + resolution: "@npmcli/run-script@npm:8.1.0" + dependencies: + "@npmcli/node-gyp": ^3.0.0 + "@npmcli/package-json": ^5.0.0 + "@npmcli/promise-spawn": ^7.0.0 + node-gyp: ^10.0.0 + proc-log: ^4.0.0 + which: ^4.0.0 + checksum: 21adfb308b9064041d6d2f7f0d53924be0e1466d558de1c9802fab9eb84850bd8e04fdd5695924f331e1a36565461500d912e187909f91c03188cc763a106986 + languageName: node + linkType: hard + +"@nx/devkit@npm:>=17.1.2 < 21": + version: 20.8.4 + resolution: "@nx/devkit@npm:20.8.4" + dependencies: + ejs: ^3.1.7 + enquirer: ~2.3.6 + ignore: ^5.0.4 + minimatch: 9.0.3 + semver: ^7.5.3 + tmp: ~0.2.1 + tslib: ^2.3.0 + yargs-parser: 21.1.1 + peerDependencies: + nx: ">= 19 <= 21" + checksum: 1f42feb5ea77cb47d53a00652e8b8041c75d276c01d712d6748c9f615406cc46ce3ad9e00d34afd6cc6f997ad9799bc6355126fd4698665b7cc77cd3eb2ce1f5 + languageName: node + linkType: hard + +"@nx/nx-darwin-arm64@npm:20.8.4": + version: 20.8.4 + resolution: "@nx/nx-darwin-arm64@npm:20.8.4" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@nx/nx-darwin-x64@npm:20.8.4": + version: 20.8.4 + resolution: "@nx/nx-darwin-x64@npm:20.8.4" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@nx/nx-freebsd-x64@npm:20.8.4": + version: 20.8.4 + resolution: "@nx/nx-freebsd-x64@npm:20.8.4" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@nx/nx-linux-arm-gnueabihf@npm:20.8.4": + version: 20.8.4 + resolution: "@nx/nx-linux-arm-gnueabihf@npm:20.8.4" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@nx/nx-linux-arm64-gnu@npm:20.8.4": + version: 20.8.4 + resolution: "@nx/nx-linux-arm64-gnu@npm:20.8.4" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@nx/nx-linux-arm64-musl@npm:20.8.4": + version: 20.8.4 + resolution: "@nx/nx-linux-arm64-musl@npm:20.8.4" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@nx/nx-linux-x64-gnu@npm:20.8.4": + version: 20.8.4 + resolution: "@nx/nx-linux-x64-gnu@npm:20.8.4" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@nx/nx-linux-x64-musl@npm:20.8.4": + version: 20.8.4 + resolution: "@nx/nx-linux-x64-musl@npm:20.8.4" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@nx/nx-win32-arm64-msvc@npm:20.8.4": + version: 20.8.4 + resolution: "@nx/nx-win32-arm64-msvc@npm:20.8.4" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@nx/nx-win32-x64-msvc@npm:20.8.4": + version: 20.8.4 + resolution: "@nx/nx-win32-x64-msvc@npm:20.8.4" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@octokit/auth-token@npm:^4.0.0": + version: 4.0.0 + resolution: "@octokit/auth-token@npm:4.0.0" + checksum: d78f4dc48b214d374aeb39caec4fdbf5c1e4fd8b9fcb18f630b1fe2cbd5a880fca05445f32b4561f41262cb551746aeb0b49e89c95c6dd99299706684d0cae2f + languageName: node + linkType: hard + +"@octokit/core@npm:^5.0.2": + version: 5.2.2 + resolution: "@octokit/core@npm:5.2.2" + dependencies: + "@octokit/auth-token": ^4.0.0 + "@octokit/graphql": ^7.1.0 + "@octokit/request": ^8.4.1 + "@octokit/request-error": ^5.1.1 + "@octokit/types": ^13.0.0 + before-after-hook: ^2.2.0 + universal-user-agent: ^6.0.0 + checksum: d4303d808c6b8eca32ce03381db5f6230440c1c6cfd9d73376ed583973094abd8ca56d9a64d490e6b0045f827a8f913b619bd90eae99c2cba682487720dc8002 + languageName: node + linkType: hard + +"@octokit/endpoint@npm:^9.0.6": + version: 9.0.6 + resolution: "@octokit/endpoint@npm:9.0.6" + dependencies: + "@octokit/types": ^13.1.0 + universal-user-agent: ^6.0.0 + checksum: f853c08f0777a8cc7c3d2509835d478e11a76d722f807d4f2ad7c0e64bf4dd159536409f466b367a907886aa3b78574d3d09ed95ac462c769e4fccaaad81e72a + languageName: node + linkType: hard + +"@octokit/graphql@npm:^7.1.0": + version: 7.1.1 + resolution: "@octokit/graphql@npm:7.1.1" + dependencies: + "@octokit/request": ^8.4.1 + "@octokit/types": ^13.0.0 + universal-user-agent: ^6.0.0 + checksum: afb60d5dda6d365334480540610d67b0c5f8e3977dd895fe504ce988f8b7183f29f3b16b88d895a701a739cf29d157d49f8f9fbc71b6c57eb4fc9bd97e099f55 + languageName: node + linkType: hard + +"@octokit/openapi-types@npm:^24.2.0": + version: 24.2.0 + resolution: "@octokit/openapi-types@npm:24.2.0" + checksum: 3c2d2f4cafd21c8a1e6a6fe6b56df6a3c09bc52ab6f829c151f9397694d028aa183ae856f08e006ee7ecaa7bd7eb413a903fbc0ffa6403e7b284ddcda20b1294 + languageName: node + linkType: hard + +"@octokit/plugin-enterprise-rest@npm:6.0.1": + version: 6.0.1 + resolution: "@octokit/plugin-enterprise-rest@npm:6.0.1" + checksum: 1c9720002f31daf62f4f48e73557dcdd7fcde6e0f6d43256e3f2ec827b5548417297186c361fb1af497fdcc93075a7b681e6ff06e2f20e4a8a3e74cc09d1f7e3 + languageName: node + linkType: hard + +"@octokit/plugin-paginate-rest@npm:11.4.4-cjs.2": + version: 11.4.4-cjs.2 + resolution: "@octokit/plugin-paginate-rest@npm:11.4.4-cjs.2" + dependencies: + "@octokit/types": ^13.7.0 + peerDependencies: + "@octokit/core": 5 + checksum: e6d1f4da255d08c24188b5df1436f22680e7fe2608d3af5d2f08a98f40d565bd3df0c58d306f05caae923247fffe861ec12d5f1273a882333fcdb34255e6c8b0 + languageName: node + linkType: hard + +"@octokit/plugin-request-log@npm:^4.0.0": + version: 4.0.1 + resolution: "@octokit/plugin-request-log@npm:4.0.1" + peerDependencies: + "@octokit/core": 5 + checksum: fd8c0a201490cba00084689a0d1d54fc7b5ab5b6bdb7e447056b947b1754f78526e9685400eab10d3522bfa7b5bc49c555f41ec412c788610b96500b168f3789 + languageName: node + linkType: hard + +"@octokit/plugin-rest-endpoint-methods@npm:13.3.2-cjs.1": + version: 13.3.2-cjs.1 + resolution: "@octokit/plugin-rest-endpoint-methods@npm:13.3.2-cjs.1" + dependencies: + "@octokit/types": ^13.8.0 + peerDependencies: + "@octokit/core": ^5 + checksum: de38a7fe33aa41ecfa62dd8546d9b603cf43b1a6cf3a31e8c1950684e1cf0f9dc7ccbcff8ef570e825729f3800f42e6ae33447c836dfa12259391ced421df64f + languageName: node + linkType: hard + +"@octokit/request-error@npm:^5.1.1": + version: 5.1.1 + resolution: "@octokit/request-error@npm:5.1.1" + dependencies: + "@octokit/types": ^13.1.0 + deprecation: ^2.0.0 + once: ^1.4.0 + checksum: 17d0b3f59c2a8a285715bfe6a85168d9c417aa7a0ff553b9be4198a3bc8bb00384a3530221a448eb19f8f07ea9fc48d264869624f5f84fa63a948a7af8cddc8c + languageName: node + linkType: hard + +"@octokit/request@npm:^8.4.1": + version: 8.4.1 + resolution: "@octokit/request@npm:8.4.1" + dependencies: + "@octokit/endpoint": ^9.0.6 + "@octokit/request-error": ^5.1.1 + "@octokit/types": ^13.1.0 + universal-user-agent: ^6.0.0 + checksum: 0ba76728583543baeef9fda98690bc86c57e0a3ccac8c189d2b7d144d248c89167eb37a071ed8fead8f4da0a1c55c4dd98a8fc598769c263b95179fb200959de + languageName: node + linkType: hard + +"@octokit/rest@npm:20.1.2": + version: 20.1.2 + resolution: "@octokit/rest@npm:20.1.2" + dependencies: + "@octokit/core": ^5.0.2 + "@octokit/plugin-paginate-rest": 11.4.4-cjs.2 + "@octokit/plugin-request-log": ^4.0.0 + "@octokit/plugin-rest-endpoint-methods": 13.3.2-cjs.1 + checksum: 72309dd393f3424f0c4213d045332c1c1a00893bea4db9b54d6add7316d9a9b461932de3afe3c866bff52cc084c79e98f644dabd386cda95068690cc9ae97456 + languageName: node + linkType: hard + +"@octokit/types@npm:^13.0.0, @octokit/types@npm:^13.1.0, @octokit/types@npm:^13.7.0, @octokit/types@npm:^13.8.0": + version: 13.10.0 + resolution: "@octokit/types@npm:13.10.0" + dependencies: + "@octokit/openapi-types": ^24.2.0 + checksum: fca3764548d5872535b9025c3b5fe6373fe588b287cb5b5259364796c1931bbe5e9ab8a86a5274ce43bb2b3e43b730067c3b86b6b1ade12a98cd59b2e8b3610d + languageName: node + linkType: hard + +"@pkgjs/parseargs@npm:^0.11.0": + version: 0.11.0 + resolution: "@pkgjs/parseargs@npm:0.11.0" + checksum: 6ad6a00fc4f2f2cfc6bff76fb1d88b8ee20bc0601e18ebb01b6d4be583733a860239a521a7fbca73b612e66705078809483549d2b18f370eb346c5155c8e4a0f + languageName: node + linkType: hard + +"@pkgr/core@npm:^0.2.9": + version: 0.2.9 + resolution: "@pkgr/core@npm:0.2.9" + checksum: bb2fb86977d63f836f8f5b09015d74e6af6488f7a411dcd2bfdca79d76b5a681a9112f41c45bdf88a9069f049718efc6f3900d7f1de66a2ec966068308ae517f + languageName: node + linkType: hard + +"@react-native/assets-registry@npm:0.83.1": + version: 0.83.1 + resolution: "@react-native/assets-registry@npm:0.83.1" + checksum: ec788b086fb1be0813d47660c34cdd758eb54dada0e9e1a2e8b55d888adab3bd9e6431742d645317f94033522805fc2c7902aa9de567d7c77d37b9619d927cd5 + languageName: node + linkType: hard + +"@react-native/codegen@npm:0.83.1": + version: 0.83.1 + resolution: "@react-native/codegen@npm:0.83.1" + dependencies: + "@babel/core": ^7.25.2 + "@babel/parser": ^7.25.3 + glob: ^7.1.1 + hermes-parser: 0.32.0 + invariant: ^2.2.4 + nullthrows: ^1.1.1 + yargs: ^17.6.2 + peerDependencies: + "@babel/core": "*" + checksum: 49c7e79b81d2595df33617b29aea981716ac36d92083301977c896a8299d1e1ce86054a804c85e1411a3732fd4e1b71e6e9edf53830b577ec5a9dd9120ca45a0 + languageName: node + linkType: hard + +"@react-native/community-cli-plugin@npm:0.83.1": + version: 0.83.1 + resolution: "@react-native/community-cli-plugin@npm:0.83.1" + dependencies: + "@react-native/dev-middleware": 0.83.1 + debug: ^4.4.0 + invariant: ^2.2.4 + metro: ^0.83.3 + metro-config: ^0.83.3 + metro-core: ^0.83.3 + semver: ^7.1.3 + peerDependencies: + "@react-native-community/cli": "*" + "@react-native/metro-config": "*" + peerDependenciesMeta: + "@react-native-community/cli": + optional: true + "@react-native/metro-config": + optional: true + checksum: 75d2a9e4de37bb4eb59d787e31c12e4e36db363b765d6ceaae68ab1f4c7cad021f9f8358eeef4c795949172d6af94f4d93081f98e4110a39d14868cecfde75bd + languageName: node + linkType: hard + +"@react-native/debugger-frontend@npm:0.83.1": + version: 0.83.1 + resolution: "@react-native/debugger-frontend@npm:0.83.1" + checksum: 6eb15797a5a136a99443e9d8ee1da14a22cc3fdf629272811018a046d2d5abc0c9f60ccc41d7f95c5e04fbd361b4cdae924f79b81f7a11bdb119e15a072c08f7 + languageName: node + linkType: hard + +"@react-native/debugger-shell@npm:0.83.1": + version: 0.83.1 + resolution: "@react-native/debugger-shell@npm:0.83.1" + dependencies: + cross-spawn: ^7.0.6 + fb-dotslash: 0.5.8 + checksum: 22f45aeb7f3f9f93c7e9615b66bf158e7f3764d5c31e4aea80b85ffef28369d82a2e6208c7dca80e0ceeadf3fa17616f4c90b8fdbab41826a8c72d4ff194309b + languageName: node + linkType: hard + +"@react-native/dev-middleware@npm:0.83.1": + version: 0.83.1 + resolution: "@react-native/dev-middleware@npm:0.83.1" + dependencies: + "@isaacs/ttlcache": ^1.4.1 + "@react-native/debugger-frontend": 0.83.1 + "@react-native/debugger-shell": 0.83.1 + chrome-launcher: ^0.15.2 + chromium-edge-launcher: ^0.2.0 + connect: ^3.6.5 + debug: ^4.4.0 + invariant: ^2.2.4 + nullthrows: ^1.1.1 + open: ^7.0.3 + serve-static: ^1.16.2 + ws: ^7.5.10 + checksum: d8439119cd99a8db0649b97a1f459222f49bb9425e1248d1466e4f7f4a104915d1e6ccc11403a5a0f3aa810eea3aa836f921ff11f44c4d3a06769d96083beb86 + languageName: node + linkType: hard + +"@react-native/gradle-plugin@npm:0.83.1": + version: 0.83.1 + resolution: "@react-native/gradle-plugin@npm:0.83.1" + checksum: dcf126b36fc46d06d2c8e5482a63566aca36273c3b2da79c67e158ea82f25445775456077afc1fbaf0c198d3307aa94bda814d177c31a149fc1ee06ab0614105 + languageName: node + linkType: hard + +"@react-native/js-polyfills@npm:0.83.1": + version: 0.83.1 + resolution: "@react-native/js-polyfills@npm:0.83.1" + checksum: 1c3fbceac6371252d6e54f9e76b852bfaec7a7472455f9856467dd73a87b8445eda03fb38fc65bc9abd76606e6e52041c754db41f2a23c74dbf5e052e9af129a + languageName: node + linkType: hard + +"@react-native/normalize-colors@npm:0.83.1": + version: 0.83.1 + resolution: "@react-native/normalize-colors@npm:0.83.1" + checksum: dd87c889218522affe58059d424404cee28f168bc3641f015ee2620c55b3e29930d279eed6916f866c166bb53d425cd160ccfaab546a6123b6c74e9931eac5d1 + languageName: node + linkType: hard + +"@react-native/virtualized-lists@npm:0.83.1": + version: 0.83.1 + resolution: "@react-native/virtualized-lists@npm:0.83.1" + dependencies: + invariant: ^2.2.4 + nullthrows: ^1.1.1 + peerDependencies: + "@types/react": ^19.2.0 + react: "*" + react-native: "*" + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 35205e505c53ff95c71434c82d02d11a454c28d603189b84c83207fa121874d3c6e5a0b0605495fbaa6eef797a71aa42df8d1780e2e2c64ee1e6b2548a815e27 + languageName: node + linkType: hard + +"@runanywhere/core@workspace:packages/core": + version: 0.0.0-use.local + resolution: "@runanywhere/core@workspace:packages/core" + dependencies: + "@types/react": ~19.1.0 + nitrogen: ^0.31.10 + react-native-nitro-modules: ^0.31.10 + typescript: ~5.9.2 + peerDependencies: + react: ">=18.0.0" + react-native: ">=0.74.0" + react-native-blob-util: ">=0.19.0" + react-native-device-info: ">=11.0.0" + react-native-fs: ">=2.20.0" + react-native-nitro-modules: ">=0.31.3" + react-native-zip-archive: ">=6.1.0" + peerDependenciesMeta: + react-native-blob-util: + optional: true + react-native-device-info: + optional: true + react-native-fs: + optional: true + react-native-zip-archive: + optional: true + languageName: unknown + linkType: soft + +"@runanywhere/llamacpp@workspace:packages/llamacpp": + version: 0.0.0-use.local + resolution: "@runanywhere/llamacpp@workspace:packages/llamacpp" + dependencies: + nitrogen: ^0.31.10 + react-native-nitro-modules: ^0.31.10 + typescript: ~5.9.2 + peerDependencies: + "@runanywhere/core": ">=0.16.0" + react: ">=18.0.0" + react-native: ">=0.74.0" + react-native-nitro-modules: ">=0.31.3" + languageName: unknown + linkType: soft + +"@runanywhere/onnx@workspace:packages/onnx": + version: 0.0.0-use.local + resolution: "@runanywhere/onnx@workspace:packages/onnx" + dependencies: + nitrogen: ^0.31.10 + react-native-nitro-modules: ^0.31.10 + typescript: ~5.9.2 + peerDependencies: + "@runanywhere/core": ">=0.16.0" + react: ">=18.0.0" + react-native: ">=0.74.0" + react-native-nitro-modules: ">=0.31.3" + languageName: unknown + linkType: soft + +"@sigstore/bundle@npm:^2.3.2": + version: 2.3.2 + resolution: "@sigstore/bundle@npm:2.3.2" + dependencies: + "@sigstore/protobuf-specs": ^0.3.2 + checksum: 851095ef71473b187df4d8b3374821d53c152646e591557973bd9648a9f08e3e8f686c7523194f513ded9534b4d057aa18697ee11f784ec4e36161907ce6d8ee + languageName: node + linkType: hard + +"@sigstore/core@npm:^1.0.0, @sigstore/core@npm:^1.1.0": + version: 1.1.0 + resolution: "@sigstore/core@npm:1.1.0" + checksum: bb870cf11cfb260d9e83f40cc29e6bbaf6ef5211d42eacbb48517ff87b1f647ff687eff557b0b30f9880fac2517d14704ec6036ae4a0d99ef3265b3d40cef29c + languageName: node + linkType: hard + +"@sigstore/protobuf-specs@npm:^0.3.2": + version: 0.3.3 + resolution: "@sigstore/protobuf-specs@npm:0.3.3" + checksum: 5457c64efd564ef1a7fcf06fe48fc2c96f2e5865b9a4cde818ebbee6e592492b3834bd8f1c1202e5790f21278ad45f2dc771c1f7328175c099147ce3a680614a + languageName: node + linkType: hard + +"@sigstore/sign@npm:^2.3.2": + version: 2.3.2 + resolution: "@sigstore/sign@npm:2.3.2" + dependencies: + "@sigstore/bundle": ^2.3.2 + "@sigstore/core": ^1.0.0 + "@sigstore/protobuf-specs": ^0.3.2 + make-fetch-happen: ^13.0.1 + proc-log: ^4.2.0 + promise-retry: ^2.0.1 + checksum: b8bfc38716956df0aadbba8a78ed4b3a758747e31e1ed775deab0632243ff94aee51f6c17cf344834cf6e5174449358988ce35e3437e80e49867a7821ad5aa45 + languageName: node + linkType: hard + +"@sigstore/tuf@npm:^2.3.4": + version: 2.3.4 + resolution: "@sigstore/tuf@npm:2.3.4" + dependencies: + "@sigstore/protobuf-specs": ^0.3.2 + tuf-js: ^2.2.1 + checksum: 62f0b17e116d42d224c7d9f40a4037c7c20f456e026059ce6ebfc155e6d6445396549acd01a6f799943857e900f1bb2b0523d00a9353b8f3f99862f1eba59f6d + languageName: node + linkType: hard + +"@sigstore/verify@npm:^1.2.1": + version: 1.2.1 + resolution: "@sigstore/verify@npm:1.2.1" + dependencies: + "@sigstore/bundle": ^2.3.2 + "@sigstore/core": ^1.1.0 + "@sigstore/protobuf-specs": ^0.3.2 + checksum: bcd08c152d6166e9c6a019c8cb50afe1b284c01753e219e126665d21b5923cbdba3700daa3cee5197a07af551ecca8b209a6c557fbc0e5f6a4ee6f9c531047fe + languageName: node + linkType: hard + +"@sinclair/typebox@npm:^0.27.8": + version: 0.27.8 + resolution: "@sinclair/typebox@npm:0.27.8" + checksum: 00bd7362a3439021aa1ea51b0e0d0a0e8ca1351a3d54c606b115fdcc49b51b16db6e5f43b4fe7a28c38688523e22a94d49dd31168868b655f0d4d50f032d07a1 + languageName: node + linkType: hard + +"@sinonjs/commons@npm:^3.0.0": + version: 3.0.1 + resolution: "@sinonjs/commons@npm:3.0.1" + dependencies: + type-detect: 4.0.8 + checksum: a7c3e7cc612352f4004873747d9d8b2d4d90b13a6d483f685598c945a70e734e255f1ca5dc49702515533c403b32725defff148177453b3f3915bcb60e9d4601 + languageName: node + linkType: hard + +"@sinonjs/fake-timers@npm:^10.0.2": + version: 10.3.0 + resolution: "@sinonjs/fake-timers@npm:10.3.0" + dependencies: + "@sinonjs/commons": ^3.0.0 + checksum: 614d30cb4d5201550c940945d44c9e0b6d64a888ff2cd5b357f95ad6721070d6b8839cd10e15b76bf5e14af0bcc1d8f9ec00d49a46318f1f669a4bec1d7f3148 + languageName: node + linkType: hard + +"@ts-morph/common@npm:~0.28.1": + version: 0.28.1 + resolution: "@ts-morph/common@npm:0.28.1" + dependencies: + minimatch: ^10.0.1 + path-browserify: ^1.0.1 + tinyglobby: ^0.2.14 + checksum: bc3e879ff55fe8fe460d49124d10f74aba4ec92c261b7f65d48153a107e1b733676bb89e1c55fa4e5c045fe055c6c5247f7d340aaf1db1a44ffaf32ca2a00ec5 + languageName: node + linkType: hard + +"@tsconfig/node10@npm:^1.0.7": + version: 1.0.12 + resolution: "@tsconfig/node10@npm:1.0.12" + checksum: 27e2f989dbb20f773aa121b609a5361a473b7047ff286fce7c851e61f5eec0c74f0bdb38d5bd69c8a06f17e60e9530188f2219b1cbeabeac91f0a5fd348eac2a + languageName: node + linkType: hard + +"@tsconfig/node12@npm:^1.0.7": + version: 1.0.11 + resolution: "@tsconfig/node12@npm:1.0.11" + checksum: 5ce29a41b13e7897a58b8e2df11269c5395999e588b9a467386f99d1d26f6c77d1af2719e407621412520ea30517d718d5192a32403b8dfcc163bf33e40a338a + languageName: node + linkType: hard + +"@tsconfig/node14@npm:^1.0.0": + version: 1.0.3 + resolution: "@tsconfig/node14@npm:1.0.3" + checksum: 19275fe80c4c8d0ad0abed6a96dbf00642e88b220b090418609c4376e1cef81bf16237bf170ad1b341452feddb8115d8dd2e5acdfdea1b27422071163dc9ba9d + languageName: node + linkType: hard + +"@tsconfig/node16@npm:^1.0.2": + version: 1.0.4 + resolution: "@tsconfig/node16@npm:1.0.4" + checksum: 202319785901f942a6e1e476b872d421baec20cf09f4b266a1854060efbf78cde16a4d256e8bc949d31e6cd9a90f1e8ef8fb06af96a65e98338a2b6b0de0a0ff + languageName: node + linkType: hard + +"@tufjs/canonical-json@npm:2.0.0": + version: 2.0.0 + resolution: "@tufjs/canonical-json@npm:2.0.0" + checksum: cc719a1d0d0ae1aa1ba551a82c87dcbefac088e433c03a3d8a1d547ea721350e47dab4ab5b0fca40d5c7ab1f4882e72edc39c9eae15bf47c45c43bcb6ee39f4f + languageName: node + linkType: hard + +"@tufjs/models@npm:2.0.1": + version: 2.0.1 + resolution: "@tufjs/models@npm:2.0.1" + dependencies: + "@tufjs/canonical-json": 2.0.0 + minimatch: ^9.0.4 + checksum: 7a7370ac8dc3c18b66dddca3269d9b9282d891f1c289beb2060649fd50ef74eaa6494bd6d6b3edfe11f0f1efa14ec19c5ec819c7cf1871476c9e002115ffb9a7 + languageName: node + linkType: hard + +"@tybys/wasm-util@npm:^0.9.0": + version: 0.9.0 + resolution: "@tybys/wasm-util@npm:0.9.0" + dependencies: + tslib: ^2.4.0 + checksum: 8d44c64e64e39c746e45b5dff7b534716f20e1f6e8fc206f8e4c8ac454ec0eb35b65646e446dd80745bc898db37a4eca549a936766d447c2158c9c43d44e7708 + languageName: node + linkType: hard + +"@types/babel__core@npm:^7.1.14": + version: 7.20.5 + resolution: "@types/babel__core@npm:7.20.5" + dependencies: + "@babel/parser": ^7.20.7 + "@babel/types": ^7.20.7 + "@types/babel__generator": "*" + "@types/babel__template": "*" + "@types/babel__traverse": "*" + checksum: a3226f7930b635ee7a5e72c8d51a357e799d19cbf9d445710fa39ab13804f79ab1a54b72ea7d8e504659c7dfc50675db974b526142c754398d7413aa4bc30845 + languageName: node + linkType: hard + +"@types/babel__generator@npm:*": + version: 7.27.0 + resolution: "@types/babel__generator@npm:7.27.0" + dependencies: + "@babel/types": ^7.0.0 + checksum: e6739cacfa276c1ad38e1d8a6b4b1f816c2c11564e27f558b68151728489aaf0f4366992107ee4ed7615dfa303f6976dedcdce93df2b247116d1bcd1607ee260 + languageName: node + linkType: hard + +"@types/babel__template@npm:*": + version: 7.4.4 + resolution: "@types/babel__template@npm:7.4.4" + dependencies: + "@babel/parser": ^7.1.0 + "@babel/types": ^7.0.0 + checksum: d7a02d2a9b67e822694d8e6a7ddb8f2b71a1d6962dfd266554d2513eefbb205b33ca71a0d163b1caea3981ccf849211f9964d8bd0727124d18ace45aa6c9ae29 + languageName: node + linkType: hard + +"@types/babel__traverse@npm:*, @types/babel__traverse@npm:^7.0.6": + version: 7.28.0 + resolution: "@types/babel__traverse@npm:7.28.0" + dependencies: + "@babel/types": ^7.28.2 + checksum: e3124e6575b2f70de338eab8a9c704d315a86c46a8e395b6ec78a0157ab7b5fd877289556a57dcf28e4ff3543714e359cc1182d4afc4bcb4f3575a0bbafa0dad + languageName: node + linkType: hard + +"@types/graceful-fs@npm:^4.1.3": + version: 4.1.9 + resolution: "@types/graceful-fs@npm:4.1.9" + dependencies: + "@types/node": "*" + checksum: 79d746a8f053954bba36bd3d94a90c78de995d126289d656fb3271dd9f1229d33f678da04d10bce6be440494a5a73438e2e363e92802d16b8315b051036c5256 + languageName: node + linkType: hard + +"@types/istanbul-lib-coverage@npm:*, @types/istanbul-lib-coverage@npm:^2.0.0": + version: 2.0.6 + resolution: "@types/istanbul-lib-coverage@npm:2.0.6" + checksum: 3feac423fd3e5449485afac999dcfcb3d44a37c830af898b689fadc65d26526460bedb889db278e0d4d815a670331796494d073a10ee6e3a6526301fe7415778 + languageName: node + linkType: hard + +"@types/istanbul-lib-report@npm:*": + version: 3.0.3 + resolution: "@types/istanbul-lib-report@npm:3.0.3" + dependencies: + "@types/istanbul-lib-coverage": "*" + checksum: b91e9b60f865ff08cb35667a427b70f6c2c63e88105eadd29a112582942af47ed99c60610180aa8dcc22382fa405033f141c119c69b95db78c4c709fbadfeeb4 + languageName: node + linkType: hard + +"@types/istanbul-reports@npm:^3.0.0": + version: 3.0.4 + resolution: "@types/istanbul-reports@npm:3.0.4" + dependencies: + "@types/istanbul-lib-report": "*" + checksum: 93eb18835770b3431f68ae9ac1ca91741ab85f7606f310a34b3586b5a34450ec038c3eed7ab19266635499594de52ff73723a54a72a75b9f7d6a956f01edee95 + languageName: node + linkType: hard + +"@types/minimatch@npm:^3.0.3": + version: 3.0.5 + resolution: "@types/minimatch@npm:3.0.5" + checksum: c41d136f67231c3131cf1d4ca0b06687f4a322918a3a5adddc87ce90ed9dbd175a3610adee36b106ae68c0b92c637c35e02b58c8a56c424f71d30993ea220b92 + languageName: node + linkType: hard + +"@types/minimist@npm:^1.2.0, @types/minimist@npm:^1.2.2": + version: 1.2.5 + resolution: "@types/minimist@npm:1.2.5" + checksum: 477047b606005058ab0263c4f58097136268007f320003c348794f74adedc3166ffc47c80ec3e94687787f2ab7f4e72c468223946e79892cf0fd9e25e9970a90 + languageName: node + linkType: hard + +"@types/node@npm:*": + version: 25.0.9 + resolution: "@types/node@npm:25.0.9" + dependencies: + undici-types: ~7.16.0 + checksum: 0dd245ed1823d32851007da980319af17f9794b0fe4b6b46093cee185e4c3ea033162238d2dc053b4474c1eeb534d47d2679a92907b5e7a27dac0938cb250c7a + languageName: node + linkType: hard + +"@types/node@npm:20.5.1": + version: 20.5.1 + resolution: "@types/node@npm:20.5.1" + checksum: 3dbe611cd67afa987102c8558ee70f848949c5dcfee5f60abc073e55c0d7b048e391bf06bb1e0dc052cb7210ca97136ac496cbaf6e89123c989de6bd125fde82 + languageName: node + linkType: hard + +"@types/node@npm:^24.10.0": + version: 24.10.9 + resolution: "@types/node@npm:24.10.9" + dependencies: + undici-types: ~7.16.0 + checksum: ee6e0a13b286c4cec32c29e2a7d862345660f6720f805315733f7802f6ece45d23fa0d4baee56276e48e62c4b7c3d335e5f40955179afc383a26b91bcb88293a + languageName: node + linkType: hard + +"@types/normalize-package-data@npm:^2.4.0": + version: 2.4.4 + resolution: "@types/normalize-package-data@npm:2.4.4" + checksum: 65dff72b543997b7be8b0265eca7ace0e34b75c3e5fee31de11179d08fa7124a7a5587265d53d0409532ecb7f7fba662c2012807963e1f9b059653ec2c83ee05 + languageName: node + linkType: hard + +"@types/react@npm:~19.1.0": + version: 19.1.17 + resolution: "@types/react@npm:19.1.17" + dependencies: + csstype: ^3.0.2 + checksum: 4d73b79a73b1dbe873a459de4faca4ba50963a8e244ba5f665208cf05d682766c7ddc2c10f1aba3bebd876cb89e81104bdb09fee2bed0fc8482fc087bffa11e3 + languageName: node + linkType: hard + +"@types/stack-utils@npm:^2.0.0": + version: 2.0.3 + resolution: "@types/stack-utils@npm:2.0.3" + checksum: 72576cc1522090fe497337c2b99d9838e320659ac57fa5560fcbdcbafcf5d0216c6b3a0a8a4ee4fdb3b1f5e3420aa4f6223ab57b82fef3578bec3206425c6cf5 + languageName: node + linkType: hard + +"@types/yargs-parser@npm:*": + version: 21.0.3 + resolution: "@types/yargs-parser@npm:21.0.3" + checksum: ef236c27f9432983e91432d974243e6c4cdae227cb673740320eff32d04d853eed59c92ca6f1142a335cfdc0e17cccafa62e95886a8154ca8891cc2dec4ee6fc + languageName: node + linkType: hard + +"@types/yargs@npm:^17.0.8": + version: 17.0.35 + resolution: "@types/yargs@npm:17.0.35" + dependencies: + "@types/yargs-parser": "*" + checksum: ebf1f5373388cfcbf9cfb5e56ce7a77c0ba2450420f26f3701010ca92df48cce7e14e4245ed1f17178a38ff8702467a6f4047742775b8e2fd06dec8f4f3501ce + languageName: node + linkType: hard + +"@typescript-eslint/eslint-plugin@npm:^8.50.0": + version: 8.53.0 + resolution: "@typescript-eslint/eslint-plugin@npm:8.53.0" + dependencies: + "@eslint-community/regexpp": ^4.12.2 + "@typescript-eslint/scope-manager": 8.53.0 + "@typescript-eslint/type-utils": 8.53.0 + "@typescript-eslint/utils": 8.53.0 + "@typescript-eslint/visitor-keys": 8.53.0 + ignore: ^7.0.5 + natural-compare: ^1.4.0 + ts-api-utils: ^2.4.0 + peerDependencies: + "@typescript-eslint/parser": ^8.53.0 + eslint: ^8.57.0 || ^9.0.0 + typescript: ">=4.8.4 <6.0.0" + checksum: d140e7ddf29861f33481d163fdcf808ee987a33318e09c7d2b0d1e3f7f7b282bc27c8dc723c2aa4ce133cc3ae5c4c91b3635ba99bb68fa3343afe1e48ed0971f + languageName: node + linkType: hard + +"@typescript-eslint/parser@npm:^8.50.0": + version: 8.53.0 + resolution: "@typescript-eslint/parser@npm:8.53.0" + dependencies: + "@typescript-eslint/scope-manager": 8.53.0 + "@typescript-eslint/types": 8.53.0 + "@typescript-eslint/typescript-estree": 8.53.0 + "@typescript-eslint/visitor-keys": 8.53.0 + debug: ^4.4.3 + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: ">=4.8.4 <6.0.0" + checksum: 8d122877354a395f9c0db5fea3021451f629838492c1cb90f51be2bd2cb9ecbec84cad91e2f1bb68d3df650cbd8e86614aa339bfc77f35804dd9e3158a249e65 + languageName: node + linkType: hard + +"@typescript-eslint/project-service@npm:8.53.0": + version: 8.53.0 + resolution: "@typescript-eslint/project-service@npm:8.53.0" + dependencies: + "@typescript-eslint/tsconfig-utils": ^8.53.0 + "@typescript-eslint/types": ^8.53.0 + debug: ^4.4.3 + peerDependencies: + typescript: ">=4.8.4 <6.0.0" + checksum: 80a0a80818201f804b43f33af76e4f8ddd78e40261e0a3a6076ebbee7bd9f4ddbeda59e128fdd910a18f137ea19f549a0f1a445f74a07233e3326a20e7b5fc73 + languageName: node + linkType: hard + +"@typescript-eslint/scope-manager@npm:8.53.0": + version: 8.53.0 + resolution: "@typescript-eslint/scope-manager@npm:8.53.0" + dependencies: + "@typescript-eslint/types": 8.53.0 + "@typescript-eslint/visitor-keys": 8.53.0 + checksum: 81cfad672ac6fa5bd9379ee235e6bee986d6f8214691d3bc2a2e431910e5206711a952d6382237c32a6f2075f51c394427a0a646bd66e846500ebb7434e9208c + languageName: node + linkType: hard + +"@typescript-eslint/tsconfig-utils@npm:8.53.0, @typescript-eslint/tsconfig-utils@npm:^8.53.0": + version: 8.53.0 + resolution: "@typescript-eslint/tsconfig-utils@npm:8.53.0" + peerDependencies: + typescript: ">=4.8.4 <6.0.0" + checksum: cdbca1184ed1098675c5b5ce3423bc346fb800a011710752f86dd4848ac0d0477b1694a8f3ff95085a32ac40ef0b5a1df9934388730d2585a417c60609bcf385 + languageName: node + linkType: hard + +"@typescript-eslint/type-utils@npm:8.53.0": + version: 8.53.0 + resolution: "@typescript-eslint/type-utils@npm:8.53.0" + dependencies: + "@typescript-eslint/types": 8.53.0 + "@typescript-eslint/typescript-estree": 8.53.0 + "@typescript-eslint/utils": 8.53.0 + debug: ^4.4.3 + ts-api-utils: ^2.4.0 + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: ">=4.8.4 <6.0.0" + checksum: 5ed0b7e5cca9bb0bed3a5f64b7e8a099635769d76f3823109916fe6e36a47aae4d92a0c4998aa1847f27828af349f3ca9b6b3d4439c32cce0b5192542473bd7d + languageName: node + linkType: hard + +"@typescript-eslint/types@npm:8.53.0, @typescript-eslint/types@npm:^8.53.0": + version: 8.53.0 + resolution: "@typescript-eslint/types@npm:8.53.0" + checksum: 8e446338c3606e9c602ca2cde4e2f6e46d36a9ad1441a14af1d0b14975183f8873a8bf21846ef34873f9efc1cfce1ad43e2acedf6d768d30236de9cb04744eb2 + languageName: node + linkType: hard + +"@typescript-eslint/typescript-estree@npm:8.53.0": + version: 8.53.0 + resolution: "@typescript-eslint/typescript-estree@npm:8.53.0" + dependencies: + "@typescript-eslint/project-service": 8.53.0 + "@typescript-eslint/tsconfig-utils": 8.53.0 + "@typescript-eslint/types": 8.53.0 + "@typescript-eslint/visitor-keys": 8.53.0 + debug: ^4.4.3 + minimatch: ^9.0.5 + semver: ^7.7.3 + tinyglobby: ^0.2.15 + ts-api-utils: ^2.4.0 + peerDependencies: + typescript: ">=4.8.4 <6.0.0" + checksum: 61d11aa805f4567d3a48453650c6930f4ef579d4230ecb41ab1305f56b4e4ede62c90f0a9f7af478bf593625cbf699a4b3e21ad61d19cc8aabf56fc1c2de5dcf + languageName: node + linkType: hard + +"@typescript-eslint/utils@npm:8.53.0": + version: 8.53.0 + resolution: "@typescript-eslint/utils@npm:8.53.0" + dependencies: + "@eslint-community/eslint-utils": ^4.9.1 + "@typescript-eslint/scope-manager": 8.53.0 + "@typescript-eslint/types": 8.53.0 + "@typescript-eslint/typescript-estree": 8.53.0 + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: ">=4.8.4 <6.0.0" + checksum: c978bf8c2cec3906587a78e19cf48291f5417c6d4858720c3b15e4ccb38d9fdc2552bfe66f96620980342a70da9c38e12cd745ecfd39e344db385225a157b4ed + languageName: node + linkType: hard + +"@typescript-eslint/visitor-keys@npm:8.53.0": + version: 8.53.0 + resolution: "@typescript-eslint/visitor-keys@npm:8.53.0" + dependencies: + "@typescript-eslint/types": 8.53.0 + eslint-visitor-keys: ^4.2.1 + checksum: 1c509d6c783bdd914869a74f8c2979a48dc9c3baad658850fa897f90bc3c5475c53df6ff15f5a2e46571af84171bd2e81c6311b8b7e80ee57d89df247ed2b4b2 + languageName: node + linkType: hard + +"@ungap/structured-clone@npm:^1.2.0": + version: 1.3.0 + resolution: "@ungap/structured-clone@npm:1.3.0" + checksum: 64ed518f49c2b31f5b50f8570a1e37bde3b62f2460042c50f132430b2d869c4a6586f13aa33a58a4722715b8158c68cae2827389d6752ac54da2893c83e480fc + languageName: node + linkType: hard + +"@yarnpkg/lockfile@npm:^1.1.0": + version: 1.1.0 + resolution: "@yarnpkg/lockfile@npm:1.1.0" + checksum: 05b881b4866a3546861fee756e6d3812776ea47fa6eb7098f983d6d0eefa02e12b66c3fff931574120f196286a7ad4879ce02743c8bb2be36c6a576c7852083a + languageName: node + linkType: hard + +"@yarnpkg/parsers@npm:3.0.2": + version: 3.0.2 + resolution: "@yarnpkg/parsers@npm:3.0.2" + dependencies: + js-yaml: ^3.10.0 + tslib: ^2.4.0 + checksum: fb40a87ae7c9f3fc0b2a6b7d84375d1c69ae8304daf598c089b52966bfb4ac94fbd2dcd87ed041970416e03d34359cb5ff16be5f5601f48d1f936213a8edaf4d + languageName: node + linkType: hard + +"@zkochan/js-yaml@npm:0.0.7": + version: 0.0.7 + resolution: "@zkochan/js-yaml@npm:0.0.7" + dependencies: + argparse: ^2.0.1 + bin: + js-yaml: bin/js-yaml.js + checksum: fc53174afc1373c834ba56108e625bf5c98f430fb0a52d3da8e868156e21c2f6a7cd5e649d126db84bba6280bbc82d4f314457846aaf2107022d043100256dd7 + languageName: node + linkType: hard + +"JSONStream@npm:^1.3.5": + version: 1.3.5 + resolution: "JSONStream@npm:1.3.5" + dependencies: + jsonparse: ^1.2.0 + through: ">=2.2.7 <3" + bin: + JSONStream: ./bin.js + checksum: 2605fa124260c61bad38bb65eba30d2f72216a78e94d0ab19b11b4e0327d572b8d530c0c9cc3b0764f727ad26d39e00bf7ebad57781ca6368394d73169c59e46 + languageName: node + linkType: hard + +"abbrev@npm:^2.0.0": + version: 2.0.0 + resolution: "abbrev@npm:2.0.0" + checksum: 0e994ad2aa6575f94670d8a2149afe94465de9cedaaaac364e7fb43a40c3691c980ff74899f682f4ca58fa96b4cbd7421a015d3a6defe43a442117d7821a2f36 + languageName: node + linkType: hard + +"abbrev@npm:^4.0.0": + version: 4.0.0 + resolution: "abbrev@npm:4.0.0" + checksum: d0344b63d28e763f259b4898c41bdc92c08e9d06d0da5617d0bbe4d78244e46daea88c510a2f9472af59b031d9060ec1a999653144e793fd029a59dae2f56dc8 + languageName: node + linkType: hard + +"abort-controller@npm:^3.0.0": + version: 3.0.0 + resolution: "abort-controller@npm:3.0.0" + dependencies: + event-target-shim: ^5.0.0 + checksum: 170bdba9b47b7e65906a28c8ce4f38a7a369d78e2271706f020849c1bfe0ee2067d4261df8bbb66eb84f79208fd5b710df759d64191db58cfba7ce8ef9c54b75 + languageName: node + linkType: hard + +"accepts@npm:^1.3.7": + version: 1.3.8 + resolution: "accepts@npm:1.3.8" + dependencies: + mime-types: ~2.1.34 + negotiator: 0.6.3 + checksum: 50c43d32e7b50285ebe84b613ee4a3aa426715a7d131b65b786e2ead0fd76b6b60091b9916d3478a75f11f162628a2139991b6c03ab3f1d9ab7c86075dc8eab4 + languageName: node + linkType: hard + +"acorn-jsx@npm:^5.3.2": + version: 5.3.2 + resolution: "acorn-jsx@npm:5.3.2" + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + checksum: c3d3b2a89c9a056b205b69530a37b972b404ee46ec8e5b341666f9513d3163e2a4f214a71f4dfc7370f5a9c07472d2fd1c11c91c3f03d093e37637d95da98950 + languageName: node + linkType: hard + +"acorn-walk@npm:^8.1.1": + version: 8.3.4 + resolution: "acorn-walk@npm:8.3.4" + dependencies: + acorn: ^8.11.0 + checksum: 4ff03f42323e7cf90f1683e08606b0f460e1e6ac263d2730e3df91c7665b6f64e696db6ea27ee4bed18c2599569be61f28a8399fa170c611161a348c402ca19c + languageName: node + linkType: hard + +"acorn@npm:^8.11.0, acorn@npm:^8.15.0, acorn@npm:^8.4.1, acorn@npm:^8.9.0": + version: 8.15.0 + resolution: "acorn@npm:8.15.0" + bin: + acorn: bin/acorn + checksum: 309c6b49aedf1a2e34aaf266de06de04aab6eb097c02375c66fdeb0f64556a6a823540409914fb364d9a11bc30d79d485a2eba29af47992d3490e9886c4391c3 + languageName: node + linkType: hard + +"add-stream@npm:^1.0.0": + version: 1.0.0 + resolution: "add-stream@npm:1.0.0" + checksum: 3e9e8b0b8f0170406d7c3a9a39bfbdf419ccccb0fd2a396338c0fda0a339af73bf738ad414fc520741de74517acf0dd92b4a36fd3298a47fd5371eee8f2c5a06 + languageName: node + linkType: hard + +"agent-base@npm:^7.1.0, agent-base@npm:^7.1.2": + version: 7.1.4 + resolution: "agent-base@npm:7.1.4" + checksum: 86a7f542af277cfbd77dd61e7df8422f90bac512953709003a1c530171a9d019d072e2400eab2b59f84b49ab9dd237be44315ca663ac73e82b3922d10ea5eafa + languageName: node + linkType: hard + +"aggregate-error@npm:^3.0.0": + version: 3.1.0 + resolution: "aggregate-error@npm:3.1.0" + dependencies: + clean-stack: ^2.0.0 + indent-string: ^4.0.0 + checksum: 1101a33f21baa27a2fa8e04b698271e64616b886795fd43c31068c07533c7b3facfcaf4e9e0cab3624bd88f729a592f1c901a1a229c9e490eafce411a8644b79 + languageName: node + linkType: hard + +"aggregate-error@npm:^4.0.0": + version: 4.0.1 + resolution: "aggregate-error@npm:4.0.1" + dependencies: + clean-stack: ^4.0.0 + indent-string: ^5.0.0 + checksum: bb3ffdfd13447800fff237c2cba752c59868ee669104bb995dfbbe0b8320e967d679e683dabb640feb32e4882d60258165cde0baafc4cd467cc7d275a13ad6b5 + languageName: node + linkType: hard + +"ajv@npm:^6.12.4": + version: 6.12.6 + resolution: "ajv@npm:6.12.6" + dependencies: + fast-deep-equal: ^3.1.1 + fast-json-stable-stringify: ^2.0.0 + json-schema-traverse: ^0.4.1 + uri-js: ^4.2.2 + checksum: 874972efe5c4202ab0a68379481fbd3d1b5d0a7bd6d3cc21d40d3536ebff3352a2a1fabb632d4fd2cc7fe4cbdcd5ed6782084c9bbf7f32a1536d18f9da5007d4 + languageName: node + linkType: hard + +"ajv@npm:^8.11.0": + version: 8.17.1 + resolution: "ajv@npm:8.17.1" + dependencies: + fast-deep-equal: ^3.1.3 + fast-uri: ^3.0.1 + json-schema-traverse: ^1.0.0 + require-from-string: ^2.0.2 + checksum: 1797bf242cfffbaf3b870d13565bd1716b73f214bb7ada9a497063aada210200da36e3ed40237285f3255acc4feeae91b1fb183625331bad27da95973f7253d9 + languageName: node + linkType: hard + +"anser@npm:^1.4.9": + version: 1.4.10 + resolution: "anser@npm:1.4.10" + checksum: 3823c64f8930d3d97f36e56cdf646fa6351f1227e25eee70c3a17697447cae4238fc3a309bb3bc2003cf930687fa72aed71426dbcf3c0a15565e120a7fee5507 + languageName: node + linkType: hard + +"ansi-colors@npm:^4.1.1": + version: 4.1.3 + resolution: "ansi-colors@npm:4.1.3" + checksum: a9c2ec842038a1fabc7db9ece7d3177e2fe1c5dc6f0c51ecfbf5f39911427b89c00b5dc6b8bd95f82a26e9b16aaae2e83d45f060e98070ce4d1333038edceb0e + languageName: node + linkType: hard + +"ansi-escapes@npm:^4.2.1": + version: 4.3.2 + resolution: "ansi-escapes@npm:4.3.2" + dependencies: + type-fest: ^0.21.3 + checksum: 93111c42189c0a6bed9cdb4d7f2829548e943827ee8479c74d6e0b22ee127b2a21d3f8b5ca57723b8ef78ce011fbfc2784350eb2bde3ccfccf2f575fa8489815 + languageName: node + linkType: hard + +"ansi-regex@npm:^5.0.0, ansi-regex@npm:^5.0.1": + version: 5.0.1 + resolution: "ansi-regex@npm:5.0.1" + checksum: 2aa4bb54caf2d622f1afdad09441695af2a83aa3fe8b8afa581d205e57ed4261c183c4d3877cee25794443fde5876417d859c108078ab788d6af7e4fe52eb66b + languageName: node + linkType: hard + +"ansi-regex@npm:^6.0.1": + version: 6.2.2 + resolution: "ansi-regex@npm:6.2.2" + checksum: 9b17ce2c6daecc75bcd5966b9ad672c23b184dc3ed9bf3c98a0702f0d2f736c15c10d461913568f2cf527a5e64291c7473358885dd493305c84a1cfed66ba94f + languageName: node + linkType: hard + +"ansi-styles@npm:^4.0.0, ansi-styles@npm:^4.1.0": + version: 4.3.0 + resolution: "ansi-styles@npm:4.3.0" + dependencies: + color-convert: ^2.0.1 + checksum: 513b44c3b2105dd14cc42a19271e80f386466c4be574bccf60b627432f9198571ebf4ab1e4c3ba17347658f4ee1711c163d574248c0c1cdc2d5917a0ad582ec4 + languageName: node + linkType: hard + +"ansi-styles@npm:^5.0.0": + version: 5.2.0 + resolution: "ansi-styles@npm:5.2.0" + checksum: d7f4e97ce0623aea6bc0d90dcd28881ee04cba06c570b97fd3391bd7a268eedfd9d5e2dd4fdcbdd82b8105df5faf6f24aaedc08eaf3da898e702db5948f63469 + languageName: node + linkType: hard + +"ansi-styles@npm:^6.1.0, ansi-styles@npm:^6.2.1": + version: 6.2.3 + resolution: "ansi-styles@npm:6.2.3" + checksum: f1b0829cf048cce870a305819f65ce2adcebc097b6d6479e12e955fd6225df9b9eb8b497083b764df796d94383ff20016cc4dbbae5b40f36138fb65a9d33c2e2 + languageName: node + linkType: hard + +"anymatch@npm:^3.0.3": + version: 3.1.3 + resolution: "anymatch@npm:3.1.3" + dependencies: + normalize-path: ^3.0.0 + picomatch: ^2.0.4 + checksum: 3e044fd6d1d26545f235a9fe4d7a534e2029d8e59fa7fd9f2a6eb21230f6b5380ea1eaf55136e60cbf8e613544b3b766e7a6fa2102e2a3a117505466e3025dc2 + languageName: node + linkType: hard + +"aproba@npm:2.0.0": + version: 2.0.0 + resolution: "aproba@npm:2.0.0" + checksum: 5615cadcfb45289eea63f8afd064ab656006361020e1735112e346593856f87435e02d8dcc7ff0d11928bc7d425f27bc7c2a84f6c0b35ab0ff659c814c138a24 + languageName: node + linkType: hard + +"arg@npm:^4.1.0": + version: 4.1.3 + resolution: "arg@npm:4.1.3" + checksum: 544af8dd3f60546d3e4aff084d451b96961d2267d668670199692f8d054f0415d86fc5497d0e641e91546f0aa920e7c29e5250e99fc89f5552a34b5d93b77f43 + languageName: node + linkType: hard + +"argparse@npm:^1.0.7": + version: 1.0.10 + resolution: "argparse@npm:1.0.10" + dependencies: + sprintf-js: ~1.0.2 + checksum: 7ca6e45583a28de7258e39e13d81e925cfa25d7d4aacbf806a382d3c02fcb13403a07fb8aeef949f10a7cfe4a62da0e2e807b348a5980554cc28ee573ef95945 + languageName: node + linkType: hard + +"argparse@npm:^2.0.1": + version: 2.0.1 + resolution: "argparse@npm:2.0.1" + checksum: 83644b56493e89a254bae05702abf3a1101b4fa4d0ca31df1c9985275a5a5bd47b3c27b7fa0b71098d41114d8ca000e6ed90cad764b306f8a503665e4d517ced + languageName: node + linkType: hard + +"array-differ@npm:^3.0.0": + version: 3.0.0 + resolution: "array-differ@npm:3.0.0" + checksum: 117edd9df5c1530bd116c6e8eea891d4bd02850fd89b1b36e532b6540e47ca620a373b81feca1c62d1395d9ae601516ba538abe5e8172d41091da2c546b05fb7 + languageName: node + linkType: hard + +"array-ify@npm:^1.0.0": + version: 1.0.0 + resolution: "array-ify@npm:1.0.0" + checksum: c0502015b319c93dd4484f18036bcc4b654eb76a4aa1f04afbcef11ac918859bb1f5d71ba1f0f1141770db9eef1a4f40f1761753650873068010bbf7bcdae4a4 + languageName: node + linkType: hard + +"array-union@npm:^2.1.0": + version: 2.1.0 + resolution: "array-union@npm:2.1.0" + checksum: 5bee12395cba82da674931df6d0fea23c4aa4660cb3b338ced9f828782a65caa232573e6bf3968f23e0c5eb301764a382cef2f128b170a9dc59de0e36c39f98d + languageName: node + linkType: hard + +"arrify@npm:^1.0.1": + version: 1.0.1 + resolution: "arrify@npm:1.0.1" + checksum: 745075dd4a4624ff0225c331dacb99be501a515d39bcb7c84d24660314a6ec28e68131b137e6f7e16318170842ce97538cd298fc4cd6b2cc798e0b957f2747e7 + languageName: node + linkType: hard + +"arrify@npm:^2.0.1": + version: 2.0.1 + resolution: "arrify@npm:2.0.1" + checksum: 067c4c1afd182806a82e4c1cb8acee16ab8b5284fbca1ce29408e6e91281c36bb5b612f6ddfbd40a0f7a7e0c75bf2696eb94c027f6e328d6e9c52465c98e4209 + languageName: node + linkType: hard + +"asap@npm:~2.0.6": + version: 2.0.6 + resolution: "asap@npm:2.0.6" + checksum: b296c92c4b969e973260e47523207cd5769abd27c245a68c26dc7a0fe8053c55bb04360237cb51cab1df52be939da77150ace99ad331fb7fb13b3423ed73ff3d + languageName: node + linkType: hard + +"async-function@npm:^1.0.0": + version: 1.0.0 + resolution: "async-function@npm:1.0.0" + checksum: 9102e246d1ed9b37ac36f57f0a6ca55226876553251a31fc80677e71471f463a54c872dc78d5d7f80740c8ba624395cccbe8b60f7b690c4418f487d8e9fd1106 + languageName: node + linkType: hard + +"async-generator-function@npm:^1.0.0": + version: 1.0.0 + resolution: "async-generator-function@npm:1.0.0" + checksum: 74a71a4a2dd7afd06ebb612f6d612c7f4766a351bedffde466023bf6dae629e46b0d2cd38786239e0fbf245de0c7df76035465e16d1213774a0efb22fec0d713 + languageName: node + linkType: hard + +"async@npm:^3.2.6": + version: 3.2.6 + resolution: "async@npm:3.2.6" + checksum: ee6eb8cd8a0ab1b58bd2a3ed6c415e93e773573a91d31df9d5ef559baafa9dab37d3b096fa7993e84585cac3697b2af6ddb9086f45d3ac8cae821bb2aab65682 + languageName: node + linkType: hard + +"asynckit@npm:^0.4.0": + version: 0.4.0 + resolution: "asynckit@npm:0.4.0" + checksum: 7b78c451df768adba04e2d02e63e2d0bf3b07adcd6e42b4cf665cb7ce899bedd344c69a1dcbce355b5f972d597b25aaa1c1742b52cffd9caccb22f348114f6be + languageName: node + linkType: hard + +"axios@npm:^1.8.3": + version: 1.13.2 + resolution: "axios@npm:1.13.2" + dependencies: + follow-redirects: ^1.15.6 + form-data: ^4.0.4 + proxy-from-env: ^1.1.0 + checksum: 057d0204d5930e2969f0bccb9f0752745b1524a36994667833195e7e1a82f245d660752ba8517b2dbea17e9e4ed0479f10b80c5fe45edd0b5a0df645c0060386 + languageName: node + linkType: hard + +"babel-jest@npm:^29.7.0": + version: 29.7.0 + resolution: "babel-jest@npm:29.7.0" + dependencies: + "@jest/transform": ^29.7.0 + "@types/babel__core": ^7.1.14 + babel-plugin-istanbul: ^6.1.1 + babel-preset-jest: ^29.6.3 + chalk: ^4.0.0 + graceful-fs: ^4.2.9 + slash: ^3.0.0 + peerDependencies: + "@babel/core": ^7.8.0 + checksum: ee6f8e0495afee07cac5e4ee167be705c711a8cc8a737e05a587a131fdae2b3c8f9aa55dfd4d9c03009ac2d27f2de63d8ba96d3e8460da4d00e8af19ef9a83f7 + languageName: node + linkType: hard + +"babel-plugin-istanbul@npm:^6.1.1": + version: 6.1.1 + resolution: "babel-plugin-istanbul@npm:6.1.1" + dependencies: + "@babel/helper-plugin-utils": ^7.0.0 + "@istanbuljs/load-nyc-config": ^1.0.0 + "@istanbuljs/schema": ^0.1.2 + istanbul-lib-instrument: ^5.0.4 + test-exclude: ^6.0.0 + checksum: cb4fd95738219f232f0aece1116628cccff16db891713c4ccb501cddbbf9272951a5df81f2f2658dfdf4b3e7b236a9d5cbcf04d5d8c07dd5077297339598061a + languageName: node + linkType: hard + +"babel-plugin-jest-hoist@npm:^29.6.3": + version: 29.6.3 + resolution: "babel-plugin-jest-hoist@npm:29.6.3" + dependencies: + "@babel/template": ^7.3.3 + "@babel/types": ^7.3.3 + "@types/babel__core": ^7.1.14 + "@types/babel__traverse": ^7.0.6 + checksum: 51250f22815a7318f17214a9d44650ba89551e6d4f47a2dc259128428324b52f5a73979d010cefd921fd5a720d8c1d55ad74ff601cd94c7bd44d5f6292fde2d1 + languageName: node + linkType: hard + +"babel-plugin-syntax-hermes-parser@npm:0.32.0": + version: 0.32.0 + resolution: "babel-plugin-syntax-hermes-parser@npm:0.32.0" + dependencies: + hermes-parser: 0.32.0 + checksum: ec76abeefabf940e2d571db3b47d022a9be7602286133291e8e047d4855af6a8afc079e4631bc9a56209d751fad54b5199932a55753b1e2b56a719d20e2d5065 + languageName: node + linkType: hard + +"babel-preset-current-node-syntax@npm:^1.0.0": + version: 1.2.0 + resolution: "babel-preset-current-node-syntax@npm:1.2.0" + dependencies: + "@babel/plugin-syntax-async-generators": ^7.8.4 + "@babel/plugin-syntax-bigint": ^7.8.3 + "@babel/plugin-syntax-class-properties": ^7.12.13 + "@babel/plugin-syntax-class-static-block": ^7.14.5 + "@babel/plugin-syntax-import-attributes": ^7.24.7 + "@babel/plugin-syntax-import-meta": ^7.10.4 + "@babel/plugin-syntax-json-strings": ^7.8.3 + "@babel/plugin-syntax-logical-assignment-operators": ^7.10.4 + "@babel/plugin-syntax-nullish-coalescing-operator": ^7.8.3 + "@babel/plugin-syntax-numeric-separator": ^7.10.4 + "@babel/plugin-syntax-object-rest-spread": ^7.8.3 + "@babel/plugin-syntax-optional-catch-binding": ^7.8.3 + "@babel/plugin-syntax-optional-chaining": ^7.8.3 + "@babel/plugin-syntax-private-property-in-object": ^7.14.5 + "@babel/plugin-syntax-top-level-await": ^7.14.5 + peerDependencies: + "@babel/core": ^7.0.0 || ^8.0.0-0 + checksum: 3608fa671cfa46364ea6ec704b8fcdd7514b7b70e6ec09b1199e13ae73ed346c51d5ce2cb6d4d5b295f6a3f2cad1fdeec2308aa9e037002dd7c929194cc838ea + languageName: node + linkType: hard + +"babel-preset-jest@npm:^29.6.3": + version: 29.6.3 + resolution: "babel-preset-jest@npm:29.6.3" + dependencies: + babel-plugin-jest-hoist: ^29.6.3 + babel-preset-current-node-syntax: ^1.0.0 + peerDependencies: + "@babel/core": ^7.0.0 + checksum: aa4ff2a8a728d9d698ed521e3461a109a1e66202b13d3494e41eea30729a5e7cc03b3a2d56c594423a135429c37bf63a9fa8b0b9ce275298be3095a88c69f6fb + languageName: node + linkType: hard + +"balanced-match@npm:^1.0.0": + version: 1.0.2 + resolution: "balanced-match@npm:1.0.2" + checksum: 9706c088a283058a8a99e0bf91b0a2f75497f185980d9ffa8b304de1d9e58ebda7c72c07ebf01dadedaac5b2907b2c6f566f660d62bd336c3468e960403b9d65 + languageName: node + linkType: hard + +"base64-js@npm:^1.3.1, base64-js@npm:^1.5.1": + version: 1.5.1 + resolution: "base64-js@npm:1.5.1" + checksum: 669632eb3745404c2f822a18fc3a0122d2f9a7a13f7fb8b5823ee19d1d2ff9ee5b52c53367176ea4ad093c332fd5ab4bd0ebae5a8e27917a4105a4cfc86b1005 + languageName: node + linkType: hard + +"baseline-browser-mapping@npm:^2.9.0": + version: 2.9.15 + resolution: "baseline-browser-mapping@npm:2.9.15" + bin: + baseline-browser-mapping: dist/cli.js + checksum: 8d5a39ce1f66ce4fc2a387fcb01ec954db9c6e8e5d27f40385a3d006c69b81def63e09afa9e7b7532cee3b343cb5eaaf57943ca1f4760283f7edb085094ebed0 + languageName: node + linkType: hard + +"before-after-hook@npm:^2.2.0": + version: 2.2.3 + resolution: "before-after-hook@npm:2.2.3" + checksum: a1a2430976d9bdab4cd89cb50d27fa86b19e2b41812bf1315923b0cba03371ebca99449809226425dd3bcef20e010db61abdaff549278e111d6480034bebae87 + languageName: node + linkType: hard + +"bin-links@npm:^4.0.4": + version: 4.0.4 + resolution: "bin-links@npm:4.0.4" + dependencies: + cmd-shim: ^6.0.0 + npm-normalize-package-bin: ^3.0.0 + read-cmd-shim: ^4.0.0 + write-file-atomic: ^5.0.0 + checksum: 9fca1fddaa3c1c9f7efd6fd7a6d991e3d8f6aaa9de5d0b9355469c2c594d8d06c9b2e0519bb0304202c14ddbe832d27b6d419d55cea4340e2c26116f9190e5c9 + languageName: node + linkType: hard + +"bl@npm:^4.0.3, bl@npm:^4.1.0": + version: 4.1.0 + resolution: "bl@npm:4.1.0" + dependencies: + buffer: ^5.5.0 + inherits: ^2.0.4 + readable-stream: ^3.4.0 + checksum: 9e8521fa7e83aa9427c6f8ccdcba6e8167ef30cc9a22df26effcc5ab682ef91d2cbc23a239f945d099289e4bbcfae7a192e9c28c84c6202e710a0dfec3722662 + languageName: node + linkType: hard + +"brace-expansion@npm:^1.1.7": + version: 1.1.12 + resolution: "brace-expansion@npm:1.1.12" + dependencies: + balanced-match: ^1.0.0 + concat-map: 0.0.1 + checksum: 12cb6d6310629e3048cadb003e1aca4d8c9bb5c67c3c321bafdd7e7a50155de081f78ea3e0ed92ecc75a9015e784f301efc8132383132f4f7904ad1ac529c562 + languageName: node + linkType: hard + +"brace-expansion@npm:^2.0.1": + version: 2.0.2 + resolution: "brace-expansion@npm:2.0.2" + dependencies: + balanced-match: ^1.0.0 + checksum: 01dff195e3646bc4b0d27b63d9bab84d2ebc06121ff5013ad6e5356daa5a9d6b60fa26cf73c74797f2dc3fbec112af13578d51f75228c1112b26c790a87b0488 + languageName: node + linkType: hard + +"braces@npm:^3.0.3": + version: 3.0.3 + resolution: "braces@npm:3.0.3" + dependencies: + fill-range: ^7.1.1 + checksum: b95aa0b3bd909f6cd1720ffcf031aeaf46154dd88b4da01f9a1d3f7ea866a79eba76a6d01cbc3c422b2ee5cdc39a4f02491058d5df0d7bf6e6a162a832df1f69 + languageName: node + linkType: hard + +"browserslist@npm:^4.24.0": + version: 4.28.1 + resolution: "browserslist@npm:4.28.1" + dependencies: + baseline-browser-mapping: ^2.9.0 + caniuse-lite: ^1.0.30001759 + electron-to-chromium: ^1.5.263 + node-releases: ^2.0.27 + update-browserslist-db: ^1.2.0 + bin: + browserslist: cli.js + checksum: 895357d912ae5a88a3fa454d2d280e9869e13432df30ca8918e206c0783b3b59375b178fdaf16d0041a1cf21ac45c8eb0a20f96f73dbd9662abf4cf613177a1e + languageName: node + linkType: hard + +"bser@npm:2.1.1": + version: 2.1.1 + resolution: "bser@npm:2.1.1" + dependencies: + node-int64: ^0.4.0 + checksum: 9ba4dc58ce86300c862bffc3ae91f00b2a03b01ee07f3564beeeaf82aa243b8b03ba53f123b0b842c190d4399b94697970c8e7cf7b1ea44b61aa28c3526a4449 + languageName: node + linkType: hard + +"buffer-from@npm:^1.0.0": + version: 1.1.2 + resolution: "buffer-from@npm:1.1.2" + checksum: 0448524a562b37d4d7ed9efd91685a5b77a50672c556ea254ac9a6d30e3403a517d8981f10e565db24e8339413b43c97ca2951f10e399c6125a0d8911f5679bb + languageName: node + linkType: hard + +"buffer@npm:^5.5.0": + version: 5.7.1 + resolution: "buffer@npm:5.7.1" + dependencies: + base64-js: ^1.3.1 + ieee754: ^1.1.13 + checksum: e2cf8429e1c4c7b8cbd30834ac09bd61da46ce35f5c22a78e6c2f04497d6d25541b16881e30a019c6fd3154150650ccee27a308eff3e26229d788bbdeb08ab84 + languageName: node + linkType: hard + +"byte-size@npm:8.1.1": + version: 8.1.1 + resolution: "byte-size@npm:8.1.1" + checksum: 65f00881ffd3c2b282fe848ed954fa4ff8363eaa3f652102510668b90b3fad04d81889486ee1b641ee0d8c8b75cf32201f3b309e6b5fbb6cc869b48a91b62d3e + languageName: node + linkType: hard + +"cacache@npm:^18.0.0, cacache@npm:^18.0.3": + version: 18.0.4 + resolution: "cacache@npm:18.0.4" + dependencies: + "@npmcli/fs": ^3.1.0 + fs-minipass: ^3.0.0 + glob: ^10.2.2 + lru-cache: ^10.0.1 + minipass: ^7.0.3 + minipass-collect: ^2.0.1 + minipass-flush: ^1.0.5 + minipass-pipeline: ^1.2.4 + p-map: ^4.0.0 + ssri: ^10.0.0 + tar: ^6.1.11 + unique-filename: ^3.0.0 + checksum: b7422c113b4ec750f33beeca0f426a0024c28e3172f332218f48f963e5b970647fa1ac05679fe5bb448832c51efea9fda4456b9a95c3a1af1105fe6c1833cde2 + languageName: node + linkType: hard + +"cacache@npm:^20.0.1": + version: 20.0.3 + resolution: "cacache@npm:20.0.3" + dependencies: + "@npmcli/fs": ^5.0.0 + fs-minipass: ^3.0.0 + glob: ^13.0.0 + lru-cache: ^11.1.0 + minipass: ^7.0.3 + minipass-collect: ^2.0.1 + minipass-flush: ^1.0.5 + minipass-pipeline: ^1.2.4 + p-map: ^7.0.2 + ssri: ^13.0.0 + unique-filename: ^5.0.0 + checksum: 595e6b91d72972d596e1e9ccab8ddbf08b773f27240220b1b5b1b7b3f52173cfbcf095212e5d7acd86c3bd453c28e69b116469889c511615ef3589523d542639 + languageName: node + linkType: hard + +"call-bind-apply-helpers@npm:^1.0.1, call-bind-apply-helpers@npm:^1.0.2": + version: 1.0.2 + resolution: "call-bind-apply-helpers@npm:1.0.2" + dependencies: + es-errors: ^1.3.0 + function-bind: ^1.1.2 + checksum: b2863d74fcf2a6948221f65d95b91b4b2d90cfe8927650b506141e669f7d5de65cea191bf788838bc40d13846b7886c5bc5c84ab96c3adbcf88ad69a72fcdc6b + languageName: node + linkType: hard + +"callsites@npm:^3.0.0": + version: 3.1.0 + resolution: "callsites@npm:3.1.0" + checksum: 072d17b6abb459c2ba96598918b55868af677154bec7e73d222ef95a8fdb9bbf7dae96a8421085cdad8cd190d86653b5b6dc55a4484f2e5b2e27d5e0c3fc15b3 + languageName: node + linkType: hard + +"camelcase-keys@npm:^6.2.2": + version: 6.2.2 + resolution: "camelcase-keys@npm:6.2.2" + dependencies: + camelcase: ^5.3.1 + map-obj: ^4.0.0 + quick-lru: ^4.0.1 + checksum: 43c9af1adf840471e54c68ab3e5fe8a62719a6b7dbf4e2e86886b7b0ff96112c945736342b837bd2529ec9d1c7d1934e5653318478d98e0cf22c475c04658e2a + languageName: node + linkType: hard + +"camelcase-keys@npm:^7.0.0": + version: 7.0.2 + resolution: "camelcase-keys@npm:7.0.2" + dependencies: + camelcase: ^6.3.0 + map-obj: ^4.1.0 + quick-lru: ^5.1.1 + type-fest: ^1.2.1 + checksum: b5821cc48dd00e8398a30c5d6547f06837ab44de123f1b3a603d0a03399722b2fc67a485a7e47106eb02ef543c3b50c5ebaabc1242cde4b63a267c3258d2365b + languageName: node + linkType: hard + +"camelcase@npm:^5.3.1": + version: 5.3.1 + resolution: "camelcase@npm:5.3.1" + checksum: e6effce26b9404e3c0f301498184f243811c30dfe6d0b9051863bd8e4034d09c8c2923794f280d6827e5aa055f6c434115ff97864a16a963366fb35fd673024b + languageName: node + linkType: hard + +"camelcase@npm:^6.2.0, camelcase@npm:^6.3.0": + version: 6.3.0 + resolution: "camelcase@npm:6.3.0" + checksum: 8c96818a9076434998511251dcb2761a94817ea17dbdc37f47ac080bd088fc62c7369429a19e2178b993497132c8cbcf5cc1f44ba963e76782ba469c0474938d + languageName: node + linkType: hard + +"caniuse-lite@npm:^1.0.30001759": + version: 1.0.30001765 + resolution: "caniuse-lite@npm:1.0.30001765" + checksum: 15936de439be1e5cc5da5fbae16899bc28a122af58f6c485743482f0ef26e5bf9a07b9a00f74024495df0495bfe073dbde6341886d14b92325dded24b332a2d5 + languageName: node + linkType: hard + +"chalk@npm:4.1.0": + version: 4.1.0 + resolution: "chalk@npm:4.1.0" + dependencies: + ansi-styles: ^4.1.0 + supports-color: ^7.1.0 + checksum: 5561c7b4c063badee3e16d04bce50bd033e1be1bf4c6948639275683ffa7a1993c44639b43c22b1c505f0f813a24b1889037eb182546b48946f9fe7cdd0e7d13 + languageName: node + linkType: hard + +"chalk@npm:^4.0.0, chalk@npm:^4.1.0, chalk@npm:^4.1.1": + version: 4.1.2 + resolution: "chalk@npm:4.1.2" + dependencies: + ansi-styles: ^4.1.0 + supports-color: ^7.1.0 + checksum: fe75c9d5c76a7a98d45495b91b2172fa3b7a09e0cc9370e5c8feb1c567b85c4288e2b3fded7cfdd7359ac28d6b3844feb8b82b8686842e93d23c827c417e83fc + languageName: node + linkType: hard + +"chalk@npm:^5.3.0": + version: 5.6.2 + resolution: "chalk@npm:5.6.2" + checksum: 4ee2d47a626d79ca27cb5299ecdcce840ef5755e287412536522344db0fc51ca0f6d6433202332c29e2288c6a90a2b31f3bd626bc8c14743b6b6ee28abd3b796 + languageName: node + linkType: hard + +"chardet@npm:^2.1.1": + version: 2.1.1 + resolution: "chardet@npm:2.1.1" + checksum: 4e3dba2699018b79bb90a9562b5e5be27fcaab55250c12fa72f026b859fb24846396c346968546c14efc69b9f23aca3ef2b9816775012d08a4686ce3c362415c + languageName: node + linkType: hard + +"chownr@npm:^2.0.0": + version: 2.0.0 + resolution: "chownr@npm:2.0.0" + checksum: c57cf9dd0791e2f18a5ee9c1a299ae6e801ff58fee96dc8bfd0dcb4738a6ce58dd252a3605b1c93c6418fe4f9d5093b28ffbf4d66648cb2a9c67eaef9679be2f + languageName: node + linkType: hard + +"chownr@npm:^3.0.0": + version: 3.0.0 + resolution: "chownr@npm:3.0.0" + checksum: fd73a4bab48b79e66903fe1cafbdc208956f41ea4f856df883d0c7277b7ab29fd33ee65f93b2ec9192fc0169238f2f8307b7735d27c155821d886b84aa97aa8d + languageName: node + linkType: hard + +"chrome-launcher@npm:^0.15.2": + version: 0.15.2 + resolution: "chrome-launcher@npm:0.15.2" + dependencies: + "@types/node": "*" + escape-string-regexp: ^4.0.0 + is-wsl: ^2.2.0 + lighthouse-logger: ^1.0.0 + bin: + print-chrome-path: bin/print-chrome-path.js + checksum: e1f8131b9f7bd931248ea85f413c6cdb93a0d41440ff5bf0987f36afb081d2b2c7b60ba6062ee7ae2dd9b052143f6b275b38c9eb115d11b49c3ea8829bad7db0 + languageName: node + linkType: hard + +"chromium-edge-launcher@npm:^0.2.0": + version: 0.2.0 + resolution: "chromium-edge-launcher@npm:0.2.0" + dependencies: + "@types/node": "*" + escape-string-regexp: ^4.0.0 + is-wsl: ^2.2.0 + lighthouse-logger: ^1.0.0 + mkdirp: ^1.0.4 + rimraf: ^3.0.2 + checksum: 9b56d1f8f18e84e34d6da89a4d97787ef323a1ade6551dcc83a6899af17c1bfc27a844c23422a29f51c6a315d1e04e2ad12595aaf07d3822335c2fce15914feb + languageName: node + linkType: hard + +"ci-info@npm:^2.0.0": + version: 2.0.0 + resolution: "ci-info@npm:2.0.0" + checksum: 3b374666a85ea3ca43fa49aa3a048d21c9b475c96eb13c133505d2324e7ae5efd6a454f41efe46a152269e9b6a00c9edbe63ec7fa1921957165aae16625acd67 + languageName: node + linkType: hard + +"ci-info@npm:^3.2.0": + version: 3.9.0 + resolution: "ci-info@npm:3.9.0" + checksum: 6b19dc9b2966d1f8c2041a838217299718f15d6c4b63ae36e4674edd2bee48f780e94761286a56aa59eb305a85fbea4ddffb7630ec063e7ec7e7e5ad42549a87 + languageName: node + linkType: hard + +"ci-info@npm:^4.0.0": + version: 4.3.1 + resolution: "ci-info@npm:4.3.1" + checksum: 66c159d92648e8a07acab0a3a0681bff6ccc39aa44916263208c4d97bbbeedbbc886d7611fd30c21df1aa624ce3c6fcdfde982e74689e3e014e064e1d0805f94 + languageName: node + linkType: hard + +"clean-stack@npm:^2.0.0": + version: 2.2.0 + resolution: "clean-stack@npm:2.2.0" + checksum: 2ac8cd2b2f5ec986a3c743935ec85b07bc174d5421a5efc8017e1f146a1cf5f781ae962618f416352103b32c9cd7e203276e8c28241bbe946160cab16149fb68 + languageName: node + linkType: hard + +"clean-stack@npm:^4.0.0": + version: 4.2.0 + resolution: "clean-stack@npm:4.2.0" + dependencies: + escape-string-regexp: 5.0.0 + checksum: 373f656a31face5c615c0839213b9b542a0a48057abfb1df66900eab4dc2a5c6097628e4a0b5aa559cdfc4e66f8a14ea47be9681773165a44470ef5fb8ccc172 + languageName: node + linkType: hard + +"cli-cursor@npm:3.1.0, cli-cursor@npm:^3.1.0": + version: 3.1.0 + resolution: "cli-cursor@npm:3.1.0" + dependencies: + restore-cursor: ^3.1.0 + checksum: 2692784c6cd2fd85cfdbd11f53aea73a463a6d64a77c3e098b2b4697a20443f430c220629e1ca3b195ea5ac4a97a74c2ee411f3807abf6df2b66211fec0c0a29 + languageName: node + linkType: hard + +"cli-spinners@npm:2.6.1": + version: 2.6.1 + resolution: "cli-spinners@npm:2.6.1" + checksum: 423409baaa7a58e5104b46ca1745fbfc5888bbd0b0c5a626e052ae1387060839c8efd512fb127e25769b3dc9562db1dc1b5add6e0b93b7ef64f477feb6416a45 + languageName: node + linkType: hard + +"cli-spinners@npm:^2.5.0": + version: 2.9.2 + resolution: "cli-spinners@npm:2.9.2" + checksum: 1bd588289b28432e4676cb5d40505cfe3e53f2e4e10fbe05c8a710a154d6fe0ce7836844b00d6858f740f2ffe67cdc36e0fce9c7b6a8430e80e6388d5aa4956c + languageName: node + linkType: hard + +"cli-width@npm:^3.0.0": + version: 3.0.0 + resolution: "cli-width@npm:3.0.0" + checksum: 4c94af3769367a70e11ed69aa6095f1c600c0ff510f3921ab4045af961820d57c0233acfa8b6396037391f31b4c397e1f614d234294f979ff61430a6c166c3f6 + languageName: node + linkType: hard + +"cliui@npm:^7.0.2": + version: 7.0.4 + resolution: "cliui@npm:7.0.4" + dependencies: + string-width: ^4.2.0 + strip-ansi: ^6.0.0 + wrap-ansi: ^7.0.0 + checksum: ce2e8f578a4813806788ac399b9e866297740eecd4ad1823c27fd344d78b22c5f8597d548adbcc46f0573e43e21e751f39446c5a5e804a12aace402b7a315d7f + languageName: node + linkType: hard + +"cliui@npm:^8.0.1": + version: 8.0.1 + resolution: "cliui@npm:8.0.1" + dependencies: + string-width: ^4.2.0 + strip-ansi: ^6.0.1 + wrap-ansi: ^7.0.0 + checksum: 79648b3b0045f2e285b76fb2e24e207c6db44323581e421c3acbd0e86454cba1b37aea976ab50195a49e7384b871e6dfb2247ad7dec53c02454ac6497394cb56 + languageName: node + linkType: hard + +"cliui@npm:^9.0.1": + version: 9.0.1 + resolution: "cliui@npm:9.0.1" + dependencies: + string-width: ^7.2.0 + strip-ansi: ^7.1.0 + wrap-ansi: ^9.0.0 + checksum: 143879ae462bf76822f341bf40979f0225fdba8dde6dfe429018b13396fd0532752cc2a809ac48cecc0ea189406184ad7568c0af44eea73d2ac3b432c4c6431f + languageName: node + linkType: hard + +"clone-deep@npm:4.0.1": + version: 4.0.1 + resolution: "clone-deep@npm:4.0.1" + dependencies: + is-plain-object: ^2.0.4 + kind-of: ^6.0.2 + shallow-clone: ^3.0.0 + checksum: 770f912fe4e6f21873c8e8fbb1e99134db3b93da32df271d00589ea4a29dbe83a9808a322c93f3bcaf8584b8b4fa6fc269fc8032efbaa6728e0c9886c74467d2 + languageName: node + linkType: hard + +"clone@npm:^1.0.2": + version: 1.0.4 + resolution: "clone@npm:1.0.4" + checksum: d06418b7335897209e77bdd430d04f882189582e67bd1f75a04565f3f07f5b3f119a9d670c943b6697d0afb100f03b866b3b8a1f91d4d02d72c4ecf2bb64b5dd + languageName: node + linkType: hard + +"cmd-shim@npm:6.0.3, cmd-shim@npm:^6.0.0": + version: 6.0.3 + resolution: "cmd-shim@npm:6.0.3" + checksum: bd79ac1505fea77cba0caf271c16210ebfbe50f348a1907f4700740876ab2157e00882b9baa685a9fcf9bc92e08a87e21bd757f45a6938f00290422f80f7d27a + languageName: node + linkType: hard + +"code-block-writer@npm:^13.0.3": + version: 13.0.3 + resolution: "code-block-writer@npm:13.0.3" + checksum: 8e234f0ec2db9625d5efb9f05bdae79da6559bb4d9df94a6aa79a89a7b5ae25093b70d309fc5122840c9c07995cb14b4dd3f98a30f8878e3a3372e177df79454 + languageName: node + linkType: hard + +"color-convert@npm:^2.0.1": + version: 2.0.1 + resolution: "color-convert@npm:2.0.1" + dependencies: + color-name: ~1.1.4 + checksum: 79e6bdb9fd479a205c71d89574fccfb22bd9053bd98c6c4d870d65c132e5e904e6034978e55b43d69fcaa7433af2016ee203ce76eeba9cfa554b373e7f7db336 + languageName: node + linkType: hard + +"color-name@npm:~1.1.4": + version: 1.1.4 + resolution: "color-name@npm:1.1.4" + checksum: b0445859521eb4021cd0fb0cc1a75cecf67fceecae89b63f62b201cca8d345baf8b952c966862a9d9a2632987d4f6581f0ec8d957dfacece86f0a7919316f610 + languageName: node + linkType: hard + +"color-support@npm:1.1.3": + version: 1.1.3 + resolution: "color-support@npm:1.1.3" + bin: + color-support: bin.js + checksum: 9b7356817670b9a13a26ca5af1c21615463b500783b739b7634a0c2047c16cef4b2865d7576875c31c3cddf9dd621fa19285e628f20198b233a5cfdda6d0793b + languageName: node + linkType: hard + +"columnify@npm:1.6.0": + version: 1.6.0 + resolution: "columnify@npm:1.6.0" + dependencies: + strip-ansi: ^6.0.1 + wcwidth: ^1.0.0 + checksum: 0d590023616a27bcd2135c0f6ddd6fac94543263f9995538bbe391068976e30545e5534d369737ec7c3e9db4e53e70a277462de46aeb5a36e6997b4c7559c335 + languageName: node + linkType: hard + +"combined-stream@npm:^1.0.8": + version: 1.0.8 + resolution: "combined-stream@npm:1.0.8" + dependencies: + delayed-stream: ~1.0.0 + checksum: 49fa4aeb4916567e33ea81d088f6584749fc90c7abec76fd516bf1c5aa5c79f3584b5ba3de6b86d26ddd64bae5329c4c7479343250cfe71c75bb366eae53bb7c + languageName: node + linkType: hard + +"commander@npm:^12.0.0": + version: 12.1.0 + resolution: "commander@npm:12.1.0" + checksum: 68e9818b00fc1ed9cdab9eb16905551c2b768a317ae69a5e3c43924c2b20ac9bb65b27e1cab36aeda7b6496376d4da908996ba2c0b5d79463e0fb1e77935d514 + languageName: node + linkType: hard + +"commander@npm:^2.20.0": + version: 2.20.3 + resolution: "commander@npm:2.20.3" + checksum: ab8c07884e42c3a8dbc5dd9592c606176c7eb5c1ca5ff274bcf907039b2c41de3626f684ea75ccf4d361ba004bbaff1f577d5384c155f3871e456bdf27becf9e + languageName: node + linkType: hard + +"commitlint@npm:^17.0.2": + version: 17.8.1 + resolution: "commitlint@npm:17.8.1" + dependencies: + "@commitlint/cli": ^17.8.1 + "@commitlint/types": ^17.8.1 + bin: + commitlint: cli.js + checksum: 9875f759a0b76b16c8b803bc5416263869e5f99b3fac1adbb6051eb387dc5ae80702b819512e31f7081b205853a3adb4d498be085df5cfbb76581490ef04deb7 + languageName: node + linkType: hard + +"common-ancestor-path@npm:^1.0.1": + version: 1.0.1 + resolution: "common-ancestor-path@npm:1.0.1" + checksum: 1d2e4186067083d8cc413f00fc2908225f04ae4e19417ded67faa6494fb313c4fcd5b28a52326d1a62b466e2b3a4325e92c31133c5fee628cdf8856b3a57c3d7 + languageName: node + linkType: hard + +"compare-func@npm:^2.0.0": + version: 2.0.0 + resolution: "compare-func@npm:2.0.0" + dependencies: + array-ify: ^1.0.0 + dot-prop: ^5.1.0 + checksum: fb71d70632baa1e93283cf9d80f30ac97f003aabee026e0b4426c9716678079ef5fea7519b84d012cbed938c476493866a38a79760564a9e21ae9433e40e6f0d + languageName: node + linkType: hard + +"concat-map@npm:0.0.1": + version: 0.0.1 + resolution: "concat-map@npm:0.0.1" + checksum: 902a9f5d8967a3e2faf138d5cb784b9979bad2e6db5357c5b21c568df4ebe62bcb15108af1b2253744844eb964fc023fbd9afbbbb6ddd0bcc204c6fb5b7bf3af + languageName: node + linkType: hard + +"concat-stream@npm:^2.0.0": + version: 2.0.0 + resolution: "concat-stream@npm:2.0.0" + dependencies: + buffer-from: ^1.0.0 + inherits: ^2.0.3 + readable-stream: ^3.0.2 + typedarray: ^0.0.6 + checksum: d7f75d48f0ecd356c1545d87e22f57b488172811b1181d96021c7c4b14ab8855f5313280263dca44bb06e5222f274d047da3e290a38841ef87b59719bde967c7 + languageName: node + linkType: hard + +"connect@npm:^3.6.5": + version: 3.7.0 + resolution: "connect@npm:3.7.0" + dependencies: + debug: 2.6.9 + finalhandler: 1.1.2 + parseurl: ~1.3.3 + utils-merge: 1.0.1 + checksum: 96e1c4effcf219b065c7823e57351c94366d2e2a6952fa95e8212bffb35c86f1d5a3f9f6c5796d4cd3a5fdda628368b1c3cc44bf19c66cfd68fe9f9cab9177e2 + languageName: node + linkType: hard + +"console-control-strings@npm:^1.1.0": + version: 1.1.0 + resolution: "console-control-strings@npm:1.1.0" + checksum: 8755d76787f94e6cf79ce4666f0c5519906d7f5b02d4b884cf41e11dcd759ed69c57da0670afd9236d229a46e0f9cf519db0cd829c6dca820bb5a5c3def584ed + languageName: node + linkType: hard + +"conventional-changelog-angular@npm:7.0.0": + version: 7.0.0 + resolution: "conventional-changelog-angular@npm:7.0.0" + dependencies: + compare-func: ^2.0.0 + checksum: 2478962ad7ce42878449ba3568347d704f22c5c9af1cd36916b5600734bd7f82c09712a338c649195c44e907f1b0372ce52d6cb51df643f495c89af05ad4bc48 + languageName: node + linkType: hard + +"conventional-changelog-angular@npm:^6.0.0": + version: 6.0.0 + resolution: "conventional-changelog-angular@npm:6.0.0" + dependencies: + compare-func: ^2.0.0 + checksum: ddc59ead53a45b817d83208200967f5340866782b8362d5e2e34105fdfa3d3a31585ebbdec7750bdb9de53da869f847e8ca96634a9801f51e27ecf4e7ffe2bad + languageName: node + linkType: hard + +"conventional-changelog-conventionalcommits@npm:^6.1.0": + version: 6.1.0 + resolution: "conventional-changelog-conventionalcommits@npm:6.1.0" + dependencies: + compare-func: ^2.0.0 + checksum: 4383a35cdf72f5964e194a1146e7f78276e301f73bd993b71627bb93586b6470d411b9613507ceb37e0fed0b023199c95e941541fa47172b4e6a7916fc3a53ff + languageName: node + linkType: hard + +"conventional-changelog-core@npm:5.0.1": + version: 5.0.1 + resolution: "conventional-changelog-core@npm:5.0.1" + dependencies: + add-stream: ^1.0.0 + conventional-changelog-writer: ^6.0.0 + conventional-commits-parser: ^4.0.0 + dateformat: ^3.0.3 + get-pkg-repo: ^4.2.1 + git-raw-commits: ^3.0.0 + git-remote-origin-url: ^2.0.0 + git-semver-tags: ^5.0.0 + normalize-package-data: ^3.0.3 + read-pkg: ^3.0.0 + read-pkg-up: ^3.0.0 + checksum: 5f37f14f8d5effb4c6bf861df11e918a277ecc2cf94534eaed44d1455b11ef450d0f6d122f0e7450a44a268d9473730cf918b7558964dcba2f0ac0896824e66f + languageName: node + linkType: hard + +"conventional-changelog-preset-loader@npm:^3.0.0": + version: 3.0.0 + resolution: "conventional-changelog-preset-loader@npm:3.0.0" + checksum: 199c4730c5151f243d35c24585114900c2a7091eab5832cfeb49067a18a2b77d5c9a86b779e6e18b49278a1ff83c011c1d9bb6da95bd1f78d9e36d4d379216d5 + languageName: node + linkType: hard + +"conventional-changelog-writer@npm:^6.0.0": + version: 6.0.1 + resolution: "conventional-changelog-writer@npm:6.0.1" + dependencies: + conventional-commits-filter: ^3.0.0 + dateformat: ^3.0.3 + handlebars: ^4.7.7 + json-stringify-safe: ^5.0.1 + meow: ^8.1.2 + semver: ^7.0.0 + split: ^1.0.1 + bin: + conventional-changelog-writer: cli.js + checksum: d8619ff7446efa71e0a019c07bdf20debff3f32438f783277b80314109429d7075b3d913e59c57cd6e014e9bef611c2a8fb052de2832144f38c0e54485257126 + languageName: node + linkType: hard + +"conventional-commits-filter@npm:^3.0.0": + version: 3.0.0 + resolution: "conventional-commits-filter@npm:3.0.0" + dependencies: + lodash.ismatch: ^4.4.0 + modify-values: ^1.0.1 + checksum: 73337f42acff7189e1dfca8d13c9448ce085ac1c09976cb33617cc909949621befb1640b1c6c30a1be4953a1be0deea9e93fa0dc86725b8be8e249a64fbb4632 + languageName: node + linkType: hard + +"conventional-commits-parser@npm:^4.0.0": + version: 4.0.0 + resolution: "conventional-commits-parser@npm:4.0.0" + dependencies: + JSONStream: ^1.3.5 + is-text-path: ^1.0.1 + meow: ^8.1.2 + split2: ^3.2.2 + bin: + conventional-commits-parser: cli.js + checksum: 12d95b5ba8e0710a6d3cd2e01f01dd7818fdf0bb2b33f4b75444e2c9aee49598776b0706a528ed49e83aec5f1896c32cbc7f8e6589f61a15187293707448f928 + languageName: node + linkType: hard + +"conventional-recommended-bump@npm:7.0.1": + version: 7.0.1 + resolution: "conventional-recommended-bump@npm:7.0.1" + dependencies: + concat-stream: ^2.0.0 + conventional-changelog-preset-loader: ^3.0.0 + conventional-commits-filter: ^3.0.0 + conventional-commits-parser: ^4.0.0 + git-raw-commits: ^3.0.0 + git-semver-tags: ^5.0.0 + meow: ^8.1.2 + bin: + conventional-recommended-bump: cli.js + checksum: e2d1f2f40f93612a6da035d0c1a12d70208e0da509a17a9c9296a05e73a6eca5d81fe8c6a7b45e973181fa7c876c6edb9a114a2d7da4f6df00c47c7684ab62d2 + languageName: node + linkType: hard + +"convert-source-map@npm:^2.0.0": + version: 2.0.0 + resolution: "convert-source-map@npm:2.0.0" + checksum: 63ae9933be5a2b8d4509daca5124e20c14d023c820258e484e32dc324d34c2754e71297c94a05784064ad27615037ef677e3f0c00469fb55f409d2bb21261035 + languageName: node + linkType: hard + +"core-util-is@npm:~1.0.0": + version: 1.0.3 + resolution: "core-util-is@npm:1.0.3" + checksum: 9de8597363a8e9b9952491ebe18167e3b36e7707569eed0ebf14f8bba773611376466ae34575bca8cfe3c767890c859c74056084738f09d4e4a6f902b2ad7d99 + languageName: node + linkType: hard + +"cosmiconfig-typescript-loader@npm:^4.0.0": + version: 4.4.0 + resolution: "cosmiconfig-typescript-loader@npm:4.4.0" + peerDependencies: + "@types/node": "*" + cosmiconfig: ">=7" + ts-node: ">=10" + typescript: ">=4" + checksum: d6ba546de333f9440226ab2384a7b5355d8d2e278a9ca9d838664181bc27719764af10c69eec6f07189e63121e6d654235c374bd7dc455ac8dfdef3aad6657fd + languageName: node + linkType: hard + +"cosmiconfig@npm:9.0.0": + version: 9.0.0 + resolution: "cosmiconfig@npm:9.0.0" + dependencies: + env-paths: ^2.2.1 + import-fresh: ^3.3.0 + js-yaml: ^4.1.0 + parse-json: ^5.2.0 + peerDependencies: + typescript: ">=4.9.5" + peerDependenciesMeta: + typescript: + optional: true + checksum: a30c424b53d442ea0bdd24cb1b3d0d8687c8dda4a17ab6afcdc439f8964438801619cdb66e8e79f63b9caa3e6586b60d8bab9ce203e72df6c5e80179b971fe8f + languageName: node + linkType: hard + +"cosmiconfig@npm:^8.0.0": + version: 8.3.6 + resolution: "cosmiconfig@npm:8.3.6" + dependencies: + import-fresh: ^3.3.0 + js-yaml: ^4.1.0 + parse-json: ^5.2.0 + path-type: ^4.0.0 + peerDependencies: + typescript: ">=4.9.5" + peerDependenciesMeta: + typescript: + optional: true + checksum: dc339ebea427898c9e03bf01b56ba7afbac07fc7d2a2d5a15d6e9c14de98275a9565da949375aee1809591c152c0a3877bb86dbeaf74d5bd5aaa79955ad9e7a0 + languageName: node + linkType: hard + +"create-require@npm:^1.1.0": + version: 1.1.1 + resolution: "create-require@npm:1.1.1" + checksum: a9a1503d4390d8b59ad86f4607de7870b39cad43d929813599a23714831e81c520bddf61bcdd1f8e30f05fd3a2b71ae8538e946eb2786dc65c2bbc520f692eff + languageName: node + linkType: hard + +"cross-spawn@npm:^7.0.2, cross-spawn@npm:^7.0.3, cross-spawn@npm:^7.0.6": + version: 7.0.6 + resolution: "cross-spawn@npm:7.0.6" + dependencies: + path-key: ^3.1.0 + shebang-command: ^2.0.0 + which: ^2.0.1 + checksum: 8d306efacaf6f3f60e0224c287664093fa9185680b2d195852ba9a863f85d02dcc737094c6e512175f8ee0161f9b87c73c6826034c2422e39de7d6569cf4503b + languageName: node + linkType: hard + +"cssesc@npm:^3.0.0": + version: 3.0.0 + resolution: "cssesc@npm:3.0.0" + bin: + cssesc: bin/cssesc + checksum: f8c4ababffbc5e2ddf2fa9957dda1ee4af6048e22aeda1869d0d00843223c1b13ad3f5d88b51caa46c994225eacb636b764eb807a8883e2fb6f99b4f4e8c48b2 + languageName: node + linkType: hard + +"csstype@npm:^3.0.2": + version: 3.2.3 + resolution: "csstype@npm:3.2.3" + checksum: cb882521b3398958a1ce6ca98c011aec0bde1c77ecaf8a1dd4db3b112a189939beae3b1308243b2fe50fc27eb3edeb0f73a5a4d91d928765dc6d5ecc7bda92ee + languageName: node + linkType: hard + +"dargs@npm:^7.0.0": + version: 7.0.0 + resolution: "dargs@npm:7.0.0" + checksum: b8f1e3cba59c42e1f13a114ad4848c3fc1cf7470f633ee9e9f1043762429bc97d91ae31b826fb135eefde203a3fdb20deb0c0a0222ac29d937b8046085d668d1 + languageName: node + linkType: hard + +"dateformat@npm:^3.0.3": + version: 3.0.3 + resolution: "dateformat@npm:3.0.3" + checksum: ca4911148abb09887bd9bdcd632c399b06f3ecad709a18eb594d289a1031982f441e08e281db77ffebcb2cbcbfa1ac578a7cbfbf8743f41009aa5adc1846ed34 + languageName: node + linkType: hard + +"debug@npm:2.6.9, debug@npm:^2.6.9": + version: 2.6.9 + resolution: "debug@npm:2.6.9" + dependencies: + ms: 2.0.0 + checksum: d2f51589ca66df60bf36e1fa6e4386b318c3f1e06772280eea5b1ae9fd3d05e9c2b7fd8a7d862457d00853c75b00451aa2d7459b924629ee385287a650f58fe6 + languageName: node + linkType: hard + +"debug@npm:4, debug@npm:^4.1.0, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.4, debug@npm:^4.4.0, debug@npm:^4.4.3": + version: 4.4.3 + resolution: "debug@npm:4.4.3" + dependencies: + ms: ^2.1.3 + peerDependenciesMeta: + supports-color: + optional: true + checksum: 4805abd570e601acdca85b6aa3757186084a45cff9b2fa6eee1f3b173caa776b45f478b2a71a572d616d2010cea9211d0ac4a02a610e4c18ac4324bde3760834 + languageName: node + linkType: hard + +"decamelize-keys@npm:^1.1.0": + version: 1.1.1 + resolution: "decamelize-keys@npm:1.1.1" + dependencies: + decamelize: ^1.1.0 + map-obj: ^1.0.0 + checksum: fc645fe20b7bda2680bbf9481a3477257a7f9304b1691036092b97ab04c0ab53e3bf9fcc2d2ae382536568e402ec41fb11e1d4c3836a9abe2d813dd9ef4311e0 + languageName: node + linkType: hard + +"decamelize@npm:^1.1.0": + version: 1.2.0 + resolution: "decamelize@npm:1.2.0" + checksum: ad8c51a7e7e0720c70ec2eeb1163b66da03e7616d7b98c9ef43cce2416395e84c1e9548dd94f5f6ffecfee9f8b94251fc57121a8b021f2ff2469b2bae247b8aa + languageName: node + linkType: hard + +"decamelize@npm:^5.0.0": + version: 5.0.1 + resolution: "decamelize@npm:5.0.1" + checksum: 7c3b1ed4b3e60e7fbc00a35fb248298527c1cdfe603e41dfcf05e6c4a8cb9efbee60630deb677ed428908fb4e74e322966c687a094d1478ddc9c3a74e9dc7140 + languageName: node + linkType: hard + +"dedent@npm:1.5.3": + version: 1.5.3 + resolution: "dedent@npm:1.5.3" + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + checksum: 045b595557b2a8ea2eb9b0b4623d764e9a87326486fe2b61191b4342ed93dc01245644d8a09f3108a50c0ee7965f1eedd92e4a3a503ed89ea8e810566ea27f9a + languageName: node + linkType: hard + +"deep-is@npm:^0.1.3": + version: 0.1.4 + resolution: "deep-is@npm:0.1.4" + checksum: edb65dd0d7d1b9c40b2f50219aef30e116cedd6fc79290e740972c132c09106d2e80aa0bc8826673dd5a00222d4179c84b36a790eef63a4c4bca75a37ef90804 + languageName: node + linkType: hard + +"defaults@npm:^1.0.3": + version: 1.0.4 + resolution: "defaults@npm:1.0.4" + dependencies: + clone: ^1.0.2 + checksum: 3a88b7a587fc076b84e60affad8b85245c01f60f38fc1d259e7ac1d89eb9ce6abb19e27215de46b98568dd5bc48471730b327637e6f20b0f1bc85cf00440c80a + languageName: node + linkType: hard + +"define-lazy-prop@npm:^2.0.0": + version: 2.0.0 + resolution: "define-lazy-prop@npm:2.0.0" + checksum: 0115fdb065e0490918ba271d7339c42453d209d4cb619dfe635870d906731eff3e1ade8028bb461ea27ce8264ec5e22c6980612d332895977e89c1bbc80fcee2 + languageName: node + linkType: hard + +"del-cli@npm:^5.1.0": + version: 5.1.0 + resolution: "del-cli@npm:5.1.0" + dependencies: + del: ^7.1.0 + meow: ^10.1.3 + bin: + del: cli.js + del-cli: cli.js + checksum: 7a8953d3d22716d08080d7344ce9b66fe1608ac4aa32b6106ba825eb986ed2a31ba7826c2f269a2060f013885274c8935628bb6009336adc29a36413dc660741 + languageName: node + linkType: hard + +"del@npm:^7.1.0": + version: 7.1.0 + resolution: "del@npm:7.1.0" + dependencies: + globby: ^13.1.2 + graceful-fs: ^4.2.10 + is-glob: ^4.0.3 + is-path-cwd: ^3.0.0 + is-path-inside: ^4.0.0 + p-map: ^5.5.0 + rimraf: ^3.0.2 + slash: ^4.0.0 + checksum: 93527e78e95125809ff20a112814b00648ed64af204be1a565862698060c9ec8f5c5fe1a4866725acfde9b0da6423f4b7a7642c1d38cd4b05cbeb643a7b089e3 + languageName: node + linkType: hard + +"delayed-stream@npm:~1.0.0": + version: 1.0.0 + resolution: "delayed-stream@npm:1.0.0" + checksum: 46fe6e83e2cb1d85ba50bd52803c68be9bd953282fa7096f51fc29edd5d67ff84ff753c51966061e5ba7cb5e47ef6d36a91924eddb7f3f3483b1c560f77a0020 + languageName: node + linkType: hard + +"depd@npm:2.0.0, depd@npm:~2.0.0": + version: 2.0.0 + resolution: "depd@npm:2.0.0" + checksum: abbe19c768c97ee2eed6282d8ce3031126662252c58d711f646921c9623f9052e3e1906443066beec1095832f534e57c523b7333f8e7e0d93051ab6baef5ab3a + languageName: node + linkType: hard + +"deprecation@npm:^2.0.0": + version: 2.3.1 + resolution: "deprecation@npm:2.3.1" + checksum: f56a05e182c2c195071385455956b0c4106fe14e36245b00c689ceef8e8ab639235176a96977ba7c74afb173317fac2e0ec6ec7a1c6d1e6eaa401c586c714132 + languageName: node + linkType: hard + +"destroy@npm:1.2.0": + version: 1.2.0 + resolution: "destroy@npm:1.2.0" + checksum: 0acb300b7478a08b92d810ab229d5afe0d2f4399272045ab22affa0d99dbaf12637659411530a6fcd597a9bdac718fc94373a61a95b4651bbc7b83684a565e38 + languageName: node + linkType: hard + +"detect-indent@npm:^5.0.0": + version: 5.0.0 + resolution: "detect-indent@npm:5.0.0" + checksum: 61763211daa498e00eec073aba95d544ae5baed19286a0a655697fa4fffc9f4539c8376e2c7df8fa11d6f8eaa16c1e6a689f403ac41ee78a060278cdadefe2ff + languageName: node + linkType: hard + +"diff-sequences@npm:^29.6.3": + version: 29.6.3 + resolution: "diff-sequences@npm:29.6.3" + checksum: f4914158e1f2276343d98ff5b31fc004e7304f5470bf0f1adb2ac6955d85a531a6458d33e87667f98f6ae52ebd3891bb47d420bb48a5bd8b7a27ee25b20e33aa + languageName: node + linkType: hard + +"diff@npm:^4.0.1": + version: 4.0.2 + resolution: "diff@npm:4.0.2" + checksum: f2c09b0ce4e6b301c221addd83bf3f454c0bc00caa3dd837cf6c127d6edf7223aa2bbe3b688feea110b7f262adbfc845b757c44c8a9f8c0c5b15d8fa9ce9d20d + languageName: node + linkType: hard + +"dir-glob@npm:^3.0.1": + version: 3.0.1 + resolution: "dir-glob@npm:3.0.1" + dependencies: + path-type: ^4.0.0 + checksum: fa05e18324510d7283f55862f3161c6759a3f2f8dbce491a2fc14c8324c498286c54282c1f0e933cb930da8419b30679389499b919122952a4f8592362ef4615 + languageName: node + linkType: hard + +"doctrine@npm:^3.0.0": + version: 3.0.0 + resolution: "doctrine@npm:3.0.0" + dependencies: + esutils: ^2.0.2 + checksum: fd7673ca77fe26cd5cba38d816bc72d641f500f1f9b25b83e8ce28827fe2da7ad583a8da26ab6af85f834138cf8dae9f69b0cd6ab925f52ddab1754db44d99ce + languageName: node + linkType: hard + +"dot-prop@npm:^5.1.0": + version: 5.3.0 + resolution: "dot-prop@npm:5.3.0" + dependencies: + is-obj: ^2.0.0 + checksum: d5775790093c234ef4bfd5fbe40884ff7e6c87573e5339432870616331189f7f5d86575c5b5af2dcf0f61172990f4f734d07844b1f23482fff09e3c4bead05ea + languageName: node + linkType: hard + +"dotenv-expand@npm:~11.0.6": + version: 11.0.7 + resolution: "dotenv-expand@npm:11.0.7" + dependencies: + dotenv: ^16.4.5 + checksum: 58455ad9ffedbf6180b49f8f35596da54f10b02efcaabcba5400363f432e1da057113eee39b42365535da41df1e794d54a4aa67b22b37c41686c3dce4e6a28c5 + languageName: node + linkType: hard + +"dotenv@npm:^16.4.5": + version: 16.6.1 + resolution: "dotenv@npm:16.6.1" + checksum: e8bd63c9a37f57934f7938a9cf35de698097fadf980cb6edb61d33b3e424ceccfe4d10f37130b904a973b9038627c2646a3365a904b4406514ea94d7f1816b69 + languageName: node + linkType: hard + +"dotenv@npm:~16.4.5": + version: 16.4.7 + resolution: "dotenv@npm:16.4.7" + checksum: c27419b5875a44addcc56cc69b7dc5b0e6587826ca85d5b355da9303c6fc317fc9989f1f18366a16378c9fdd9532d14117a1abe6029cc719cdbbef6eaef2cea4 + languageName: node + linkType: hard + +"dunder-proto@npm:^1.0.1": + version: 1.0.1 + resolution: "dunder-proto@npm:1.0.1" + dependencies: + call-bind-apply-helpers: ^1.0.1 + es-errors: ^1.3.0 + gopd: ^1.2.0 + checksum: 149207e36f07bd4941921b0ca929e3a28f1da7bd6b6ff8ff7f4e2f2e460675af4576eeba359c635723dc189b64cdd4787e0255897d5b135ccc5d15cb8685fc90 + languageName: node + linkType: hard + +"eastasianwidth@npm:^0.2.0": + version: 0.2.0 + resolution: "eastasianwidth@npm:0.2.0" + checksum: 7d00d7cd8e49b9afa762a813faac332dee781932d6f2c848dc348939c4253f1d4564341b7af1d041853bc3f32c2ef141b58e0a4d9862c17a7f08f68df1e0f1ed + languageName: node + linkType: hard + +"ee-first@npm:1.1.1": + version: 1.1.1 + resolution: "ee-first@npm:1.1.1" + checksum: 1b4cac778d64ce3b582a7e26b218afe07e207a0f9bfe13cc7395a6d307849cfe361e65033c3251e00c27dd060cab43014c2d6b2647676135e18b77d2d05b3f4f + languageName: node + linkType: hard + +"ejs@npm:^3.1.7": + version: 3.1.10 + resolution: "ejs@npm:3.1.10" + dependencies: + jake: ^10.8.5 + bin: + ejs: bin/cli.js + checksum: ce90637e9c7538663ae023b8a7a380b2ef7cc4096de70be85abf5a3b9641912dde65353211d05e24d56b1f242d71185c6d00e02cb8860701d571786d92c71f05 + languageName: node + linkType: hard + +"electron-to-chromium@npm:^1.5.263": + version: 1.5.267 + resolution: "electron-to-chromium@npm:1.5.267" + checksum: 923a21ea4c3f2536eb7ccf80e92d9368a2e5a13e6deccb1d94c31b5a5b4e10e722149b85db9892e9819150f1c43462692a92dc85ba0c205a4eb578e173b3ab36 + languageName: node + linkType: hard + +"emoji-regex@npm:^10.3.0": + version: 10.6.0 + resolution: "emoji-regex@npm:10.6.0" + checksum: 8785f6a7ec4559c931bd6640f748fe23791f5af4c743b131d458c5551b4aa7da2a9cd882518723cb3859e8b0b59b0cc08f2ce0f8e65c61a026eed71c2dc407d5 + languageName: node + linkType: hard + +"emoji-regex@npm:^8.0.0": + version: 8.0.0 + resolution: "emoji-regex@npm:8.0.0" + checksum: d4c5c39d5a9868b5fa152f00cada8a936868fd3367f33f71be515ecee4c803132d11b31a6222b2571b1e5f7e13890156a94880345594d0ce7e3c9895f560f192 + languageName: node + linkType: hard + +"emoji-regex@npm:^9.2.2": + version: 9.2.2 + resolution: "emoji-regex@npm:9.2.2" + checksum: 8487182da74aabd810ac6d6f1994111dfc0e331b01271ae01ec1eb0ad7b5ecc2bbbbd2f053c05cb55a1ac30449527d819bbfbf0e3de1023db308cbcb47f86601 + languageName: node + linkType: hard + +"encodeurl@npm:~1.0.2": + version: 1.0.2 + resolution: "encodeurl@npm:1.0.2" + checksum: e50e3d508cdd9c4565ba72d2012e65038e5d71bdc9198cb125beb6237b5b1ade6c0d343998da9e170fb2eae52c1bed37d4d6d98a46ea423a0cddbed5ac3f780c + languageName: node + linkType: hard + +"encodeurl@npm:~2.0.0": + version: 2.0.0 + resolution: "encodeurl@npm:2.0.0" + checksum: abf5cd51b78082cf8af7be6785813c33b6df2068ce5191a40ca8b1afe6a86f9230af9a9ce694a5ce4665955e5c1120871826df9c128a642e09c58d592e2807fe + languageName: node + linkType: hard + +"encoding@npm:^0.1.13": + version: 0.1.13 + resolution: "encoding@npm:0.1.13" + dependencies: + iconv-lite: ^0.6.2 + checksum: bb98632f8ffa823996e508ce6a58ffcf5856330fde839ae42c9e1f436cc3b5cc651d4aeae72222916545428e54fd0f6aa8862fd8d25bdbcc4589f1e3f3715e7f + languageName: node + linkType: hard + +"end-of-stream@npm:^1.4.1": + version: 1.4.5 + resolution: "end-of-stream@npm:1.4.5" + dependencies: + once: ^1.4.0 + checksum: 1e0cfa6e7f49887544e03314f9dfc56a8cb6dde910cbb445983ecc2ff426fc05946df9d75d8a21a3a64f2cecfe1bf88f773952029f46756b2ed64a24e95b1fb8 + languageName: node + linkType: hard + +"enquirer@npm:~2.3.6": + version: 2.3.6 + resolution: "enquirer@npm:2.3.6" + dependencies: + ansi-colors: ^4.1.1 + checksum: 1c0911e14a6f8d26721c91e01db06092a5f7675159f0261d69c403396a385afd13dd76825e7678f66daffa930cfaa8d45f506fb35f818a2788463d022af1b884 + languageName: node + linkType: hard + +"env-paths@npm:^2.2.0, env-paths@npm:^2.2.1": + version: 2.2.1 + resolution: "env-paths@npm:2.2.1" + checksum: 65b5df55a8bab92229ab2b40dad3b387fad24613263d103a97f91c9fe43ceb21965cd3392b1ccb5d77088021e525c4e0481adb309625d0cb94ade1d1fb8dc17e + languageName: node + linkType: hard + +"envinfo@npm:7.13.0": + version: 7.13.0 + resolution: "envinfo@npm:7.13.0" + bin: + envinfo: dist/cli.js + checksum: 822fc30f53bd0be67f0e25be96eb6a2562b8062f3058846bbd7ec471bd4b7835fca6436ee72c4029c8ae4a3d8f8cddbe2ee725b22291f015232d20a682bee732 + languageName: node + linkType: hard + +"err-code@npm:^2.0.2": + version: 2.0.3 + resolution: "err-code@npm:2.0.3" + checksum: 8b7b1be20d2de12d2255c0bc2ca638b7af5171142693299416e6a9339bd7d88fc8d7707d913d78e0993176005405a236b066b45666b27b797252c771156ace54 + languageName: node + linkType: hard + +"error-ex@npm:^1.3.1": + version: 1.3.4 + resolution: "error-ex@npm:1.3.4" + dependencies: + is-arrayish: ^0.2.1 + checksum: 25136c0984569c8d68417036a9a1624804314296f24675199a391e5d20b2e26fe6d9304d40901293fa86900603a229983c9a8921ea7f1d16f814c2db946ff4ef + languageName: node + linkType: hard + +"error-stack-parser@npm:^2.0.6": + version: 2.1.4 + resolution: "error-stack-parser@npm:2.1.4" + dependencies: + stackframe: ^1.3.4 + checksum: 3b916d2d14c6682f287c8bfa28e14672f47eafe832701080e420e7cdbaebb2c50293868256a95706ac2330fe078cf5664713158b49bc30d7a5f2ac229ded0e18 + languageName: node + linkType: hard + +"es-define-property@npm:^1.0.1": + version: 1.0.1 + resolution: "es-define-property@npm:1.0.1" + checksum: 0512f4e5d564021c9e3a644437b0155af2679d10d80f21adaf868e64d30efdfbd321631956f20f42d655fedb2e3a027da479fad3fa6048f768eb453a80a5f80a + languageName: node + linkType: hard + +"es-errors@npm:^1.3.0": + version: 1.3.0 + resolution: "es-errors@npm:1.3.0" + checksum: ec1414527a0ccacd7f15f4a3bc66e215f04f595ba23ca75cdae0927af099b5ec865f9f4d33e9d7e86f512f252876ac77d4281a7871531a50678132429b1271b5 + languageName: node + linkType: hard + +"es-object-atoms@npm:^1.0.0, es-object-atoms@npm:^1.1.1": + version: 1.1.1 + resolution: "es-object-atoms@npm:1.1.1" + dependencies: + es-errors: ^1.3.0 + checksum: 214d3767287b12f36d3d7267ef342bbbe1e89f899cfd67040309fc65032372a8e60201410a99a1645f2f90c1912c8c49c8668066f6bdd954bcd614dda2e3da97 + languageName: node + linkType: hard + +"es-set-tostringtag@npm:^2.1.0": + version: 2.1.0 + resolution: "es-set-tostringtag@npm:2.1.0" + dependencies: + es-errors: ^1.3.0 + get-intrinsic: ^1.2.6 + has-tostringtag: ^1.0.2 + hasown: ^2.0.2 + checksum: 789f35de4be3dc8d11fdcb91bc26af4ae3e6d602caa93299a8c45cf05d36cc5081454ae2a6d3afa09cceca214b76c046e4f8151e092e6fc7feeb5efb9e794fc6 + languageName: node + linkType: hard + +"escalade@npm:^3.1.1, escalade@npm:^3.2.0": + version: 3.2.0 + resolution: "escalade@npm:3.2.0" + checksum: 47b029c83de01b0d17ad99ed766347b974b0d628e848de404018f3abee728e987da0d2d370ad4574aa3d5b5bfc368754fd085d69a30f8e75903486ec4b5b709e + languageName: node + linkType: hard + +"escape-html@npm:~1.0.3": + version: 1.0.3 + resolution: "escape-html@npm:1.0.3" + checksum: 6213ca9ae00d0ab8bccb6d8d4e0a98e76237b2410302cf7df70aaa6591d509a2a37ce8998008cbecae8fc8ffaadf3fb0229535e6a145f3ce0b211d060decbb24 + languageName: node + linkType: hard + +"escape-string-regexp@npm:5.0.0": + version: 5.0.0 + resolution: "escape-string-regexp@npm:5.0.0" + checksum: 20daabe197f3cb198ec28546deebcf24b3dbb1a5a269184381b3116d12f0532e06007f4bc8da25669d6a7f8efb68db0758df4cd981f57bc5b57f521a3e12c59e + languageName: node + linkType: hard + +"escape-string-regexp@npm:^1.0.5": + version: 1.0.5 + resolution: "escape-string-regexp@npm:1.0.5" + checksum: 6092fda75c63b110c706b6a9bfde8a612ad595b628f0bd2147eea1d3406723020810e591effc7db1da91d80a71a737a313567c5abb3813e8d9c71f4aa595b410 + languageName: node + linkType: hard + +"escape-string-regexp@npm:^2.0.0": + version: 2.0.0 + resolution: "escape-string-regexp@npm:2.0.0" + checksum: 9f8a2d5743677c16e85c810e3024d54f0c8dea6424fad3c79ef6666e81dd0846f7437f5e729dfcdac8981bc9e5294c39b4580814d114076b8d36318f46ae4395 + languageName: node + linkType: hard + +"escape-string-regexp@npm:^4.0.0": + version: 4.0.0 + resolution: "escape-string-regexp@npm:4.0.0" + checksum: 98b48897d93060f2322108bf29db0feba7dd774be96cd069458d1453347b25ce8682ecc39859d4bca2203cc0ab19c237bcc71755eff49a0f8d90beadeeba5cc5 + languageName: node + linkType: hard + +"eslint-config-prettier@npm:^9.0.0": + version: 9.1.2 + resolution: "eslint-config-prettier@npm:9.1.2" + peerDependencies: + eslint: ">=7.0.0" + bin: + eslint-config-prettier: bin/cli.js + checksum: e786b767331094fd024cb1b0899964a9da0602eaf4ebd617d6d9794752ccd04dbe997e3c14c17f256c97af20bee1c83c9273f69b74cb2081b6f514580d62408f + languageName: node + linkType: hard + +"eslint-plugin-prettier@npm:^5.0.1": + version: 5.5.5 + resolution: "eslint-plugin-prettier@npm:5.5.5" + dependencies: + prettier-linter-helpers: ^1.0.1 + synckit: ^0.11.12 + peerDependencies: + "@types/eslint": ">=8.0.0" + eslint: ">=8.0.0" + eslint-config-prettier: ">= 7.0.0 <10.0.0 || >=10.1.0" + prettier: ">=3.0.0" + peerDependenciesMeta: + "@types/eslint": + optional: true + eslint-config-prettier: + optional: true + checksum: 49b1c25d75ded255a8707d5f06288ae86e8ab4f8e273d4aabdabf73cd0903848916d5a3598ba8be82f2c8dd06769c5e6c172503b3b9cfb2636b6fc23b9c024fb + languageName: node + linkType: hard + +"eslint-scope@npm:^7.2.2": + version: 7.2.2 + resolution: "eslint-scope@npm:7.2.2" + dependencies: + esrecurse: ^4.3.0 + estraverse: ^5.2.0 + checksum: ec97dbf5fb04b94e8f4c5a91a7f0a6dd3c55e46bfc7bbcd0e3138c3a76977570e02ed89a1810c778dcd72072ff0e9621ba1379b4babe53921d71e2e4486fda3e + languageName: node + linkType: hard + +"eslint-visitor-keys@npm:^3.4.1, eslint-visitor-keys@npm:^3.4.3": + version: 3.4.3 + resolution: "eslint-visitor-keys@npm:3.4.3" + checksum: 36e9ef87fca698b6fd7ca5ca35d7b2b6eeaaf106572e2f7fd31c12d3bfdaccdb587bba6d3621067e5aece31c8c3a348b93922ab8f7b2cbc6aaab5e1d89040c60 + languageName: node + linkType: hard + +"eslint-visitor-keys@npm:^4.2.1": + version: 4.2.1 + resolution: "eslint-visitor-keys@npm:4.2.1" + checksum: 3a77e3f99a49109f6fb2c5b7784bc78f9743b834d238cdba4d66c602c6b52f19ed7bcd0a5c5dbbeae3a8689fd785e76c001799f53d2228b278282cf9f699fff5 + languageName: node + linkType: hard + +"eslint@npm:^8.51.0": + version: 8.57.1 + resolution: "eslint@npm:8.57.1" + dependencies: + "@eslint-community/eslint-utils": ^4.2.0 + "@eslint-community/regexpp": ^4.6.1 + "@eslint/eslintrc": ^2.1.4 + "@eslint/js": 8.57.1 + "@humanwhocodes/config-array": ^0.13.0 + "@humanwhocodes/module-importer": ^1.0.1 + "@nodelib/fs.walk": ^1.2.8 + "@ungap/structured-clone": ^1.2.0 + ajv: ^6.12.4 + chalk: ^4.0.0 + cross-spawn: ^7.0.2 + debug: ^4.3.2 + doctrine: ^3.0.0 + escape-string-regexp: ^4.0.0 + eslint-scope: ^7.2.2 + eslint-visitor-keys: ^3.4.3 + espree: ^9.6.1 + esquery: ^1.4.2 + esutils: ^2.0.2 + fast-deep-equal: ^3.1.3 + file-entry-cache: ^6.0.1 + find-up: ^5.0.0 + glob-parent: ^6.0.2 + globals: ^13.19.0 + graphemer: ^1.4.0 + ignore: ^5.2.0 + imurmurhash: ^0.1.4 + is-glob: ^4.0.0 + is-path-inside: ^3.0.3 + js-yaml: ^4.1.0 + json-stable-stringify-without-jsonify: ^1.0.1 + levn: ^0.4.1 + lodash.merge: ^4.6.2 + minimatch: ^3.1.2 + natural-compare: ^1.4.0 + optionator: ^0.9.3 + strip-ansi: ^6.0.1 + text-table: ^0.2.0 + bin: + eslint: bin/eslint.js + checksum: e2489bb7f86dd2011967759a09164e65744ef7688c310bc990612fc26953f34cc391872807486b15c06833bdff737726a23e9b4cdba5de144c311377dc41d91b + languageName: node + linkType: hard + +"espree@npm:^9.6.0, espree@npm:^9.6.1": + version: 9.6.1 + resolution: "espree@npm:9.6.1" + dependencies: + acorn: ^8.9.0 + acorn-jsx: ^5.3.2 + eslint-visitor-keys: ^3.4.1 + checksum: eb8c149c7a2a77b3f33a5af80c10875c3abd65450f60b8af6db1bfcfa8f101e21c1e56a561c6dc13b848e18148d43469e7cd208506238554fb5395a9ea5a1ab9 + languageName: node + linkType: hard + +"esprima@npm:^4.0.0": + version: 4.0.1 + resolution: "esprima@npm:4.0.1" + bin: + esparse: ./bin/esparse.js + esvalidate: ./bin/esvalidate.js + checksum: b45bc805a613dbea2835278c306b91aff6173c8d034223fa81498c77dcbce3b2931bf6006db816f62eacd9fd4ea975dfd85a5b7f3c6402cfd050d4ca3c13a628 + languageName: node + linkType: hard + +"esquery@npm:^1.4.2": + version: 1.7.0 + resolution: "esquery@npm:1.7.0" + dependencies: + estraverse: ^5.1.0 + checksum: 3239792b68cf39fe18966d0ca01549bb15556734f0144308fd213739b0f153671ae916013fce0bca032044a4dbcda98b43c1c667f20c20a54dec3597ac0d7c27 + languageName: node + linkType: hard + +"esrecurse@npm:^4.3.0": + version: 4.3.0 + resolution: "esrecurse@npm:4.3.0" + dependencies: + estraverse: ^5.2.0 + checksum: ebc17b1a33c51cef46fdc28b958994b1dc43cd2e86237515cbc3b4e5d2be6a811b2315d0a1a4d9d340b6d2308b15322f5c8291059521cc5f4802f65e7ec32837 + languageName: node + linkType: hard + +"estraverse@npm:^5.1.0, estraverse@npm:^5.2.0": + version: 5.3.0 + resolution: "estraverse@npm:5.3.0" + checksum: 072780882dc8416ad144f8fe199628d2b3e7bbc9989d9ed43795d2c90309a2047e6bc5979d7e2322a341163d22cfad9e21f4110597fe487519697389497e4e2b + languageName: node + linkType: hard + +"esutils@npm:^2.0.2": + version: 2.0.3 + resolution: "esutils@npm:2.0.3" + checksum: 22b5b08f74737379a840b8ed2036a5fb35826c709ab000683b092d9054e5c2a82c27818f12604bfc2a9a76b90b6834ef081edbc1c7ae30d1627012e067c6ec87 + languageName: node + linkType: hard + +"etag@npm:~1.8.1": + version: 1.8.1 + resolution: "etag@npm:1.8.1" + checksum: 571aeb3dbe0f2bbd4e4fadbdb44f325fc75335cd5f6f6b6a091e6a06a9f25ed5392f0863c5442acb0646787446e816f13cbfc6edce5b07658541dff573cab1ff + languageName: node + linkType: hard + +"event-target-shim@npm:^5.0.0": + version: 5.0.1 + resolution: "event-target-shim@npm:5.0.1" + checksum: 1ffe3bb22a6d51bdeb6bf6f7cf97d2ff4a74b017ad12284cc9e6a279e727dc30a5de6bb613e5596ff4dc3e517841339ad09a7eec44266eccb1aa201a30448166 + languageName: node + linkType: hard + +"eventemitter3@npm:^4.0.4": + version: 4.0.7 + resolution: "eventemitter3@npm:4.0.7" + checksum: 1875311c42fcfe9c707b2712c32664a245629b42bb0a5a84439762dd0fd637fc54d078155ea83c2af9e0323c9ac13687e03cfba79b03af9f40c89b4960099374 + languageName: node + linkType: hard + +"execa@npm:5.0.0": + version: 5.0.0 + resolution: "execa@npm:5.0.0" + dependencies: + cross-spawn: ^7.0.3 + get-stream: ^6.0.0 + human-signals: ^2.1.0 + is-stream: ^2.0.0 + merge-stream: ^2.0.0 + npm-run-path: ^4.0.1 + onetime: ^5.1.2 + signal-exit: ^3.0.3 + strip-final-newline: ^2.0.0 + checksum: a044367ebdcc68ca019810cb134510fc77bbc55c799122258ee0e00e289c132941ab48c2a331a036699c42bc8d479d451ae67c105fce5ce5cc813e7dd92d642b + languageName: node + linkType: hard + +"execa@npm:^5.0.0": + version: 5.1.1 + resolution: "execa@npm:5.1.1" + dependencies: + cross-spawn: ^7.0.3 + get-stream: ^6.0.0 + human-signals: ^2.1.0 + is-stream: ^2.0.0 + merge-stream: ^2.0.0 + npm-run-path: ^4.0.1 + onetime: ^5.1.2 + signal-exit: ^3.0.3 + strip-final-newline: ^2.0.0 + checksum: fba9022c8c8c15ed862847e94c252b3d946036d7547af310e344a527e59021fd8b6bb0723883ea87044dc4f0201f949046993124a42ccb0855cae5bf8c786343 + languageName: node + linkType: hard + +"exponential-backoff@npm:^3.1.1": + version: 3.1.3 + resolution: "exponential-backoff@npm:3.1.3" + checksum: 471fdb70fd3d2c08a74a026973bdd4105b7832911f610ca67bbb74e39279411c1eed2f2a110c9d41c2edd89459ba58fdaba1c174beed73e7a42d773882dcff82 + languageName: node + linkType: hard + +"fast-deep-equal@npm:^3.1.1, fast-deep-equal@npm:^3.1.3": + version: 3.1.3 + resolution: "fast-deep-equal@npm:3.1.3" + checksum: e21a9d8d84f53493b6aa15efc9cfd53dd5b714a1f23f67fb5dc8f574af80df889b3bce25dc081887c6d25457cce704e636395333abad896ccdec03abaf1f3f9d + languageName: node + linkType: hard + +"fast-diff@npm:^1.1.2": + version: 1.3.0 + resolution: "fast-diff@npm:1.3.0" + checksum: d22d371b994fdc8cce9ff510d7b8dc4da70ac327bcba20df607dd5b9cae9f908f4d1028f5fe467650f058d1e7270235ae0b8230809a262b4df587a3b3aa216c3 + languageName: node + linkType: hard + +"fast-glob@npm:^3.3.0": + version: 3.3.3 + resolution: "fast-glob@npm:3.3.3" + dependencies: + "@nodelib/fs.stat": ^2.0.2 + "@nodelib/fs.walk": ^1.2.3 + glob-parent: ^5.1.2 + merge2: ^1.3.0 + micromatch: ^4.0.8 + checksum: 0704d7b85c0305fd2cef37777337dfa26230fdd072dce9fb5c82a4b03156f3ffb8ed3e636033e65d45d2a5805a4e475825369a27404c0307f2db0c8eb3366fbd + languageName: node + linkType: hard + +"fast-json-stable-stringify@npm:^2.0.0, fast-json-stable-stringify@npm:^2.1.0": + version: 2.1.0 + resolution: "fast-json-stable-stringify@npm:2.1.0" + checksum: b191531e36c607977e5b1c47811158733c34ccb3bfde92c44798929e9b4154884378536d26ad90dfecd32e1ffc09c545d23535ad91b3161a27ddbb8ebe0cbecb + languageName: node + linkType: hard + +"fast-levenshtein@npm:^2.0.6": + version: 2.0.6 + resolution: "fast-levenshtein@npm:2.0.6" + checksum: 92cfec0a8dfafd9c7a15fba8f2cc29cd0b62b85f056d99ce448bbcd9f708e18ab2764bda4dd5158364f4145a7c72788538994f0d1787b956ef0d1062b0f7c24c + languageName: node + linkType: hard + +"fast-uri@npm:^3.0.1": + version: 3.1.0 + resolution: "fast-uri@npm:3.1.0" + checksum: daab0efd3548cc53d0db38ecc764d125773f8bd70c34552ff21abdc6530f26fa4cb1771f944222ca5e61a0a1a85d01a104848ff88c61736de445d97bd616ea7e + languageName: node + linkType: hard + +"fastq@npm:^1.6.0": + version: 1.20.1 + resolution: "fastq@npm:1.20.1" + dependencies: + reusify: ^1.0.4 + checksum: 49128edbf05e682bee3c1db3d2dfc7da195469065ef014d8368c555d829932313ae2ddf584bb03146409b0d5d9fdb387c471075483a7319b52f777ad91128ed8 + languageName: node + linkType: hard + +"fb-dotslash@npm:0.5.8": + version: 0.5.8 + resolution: "fb-dotslash@npm:0.5.8" + bin: + dotslash: bin/dotslash + checksum: 5678efe96898294e41c983cb8ea28952539566df5f8bfd2913e8e146425d7d9999d2c458bb4f3e0b07b36b5bcd23cada0868d94509c8b2d4b17de8bf0641775a + languageName: node + linkType: hard + +"fb-watchman@npm:^2.0.0": + version: 2.0.2 + resolution: "fb-watchman@npm:2.0.2" + dependencies: + bser: 2.1.1 + checksum: b15a124cef28916fe07b400eb87cbc73ca082c142abf7ca8e8de6af43eca79ca7bd13eb4d4d48240b3bd3136eaac40d16e42d6edf87a8e5d1dd8070626860c78 + languageName: node + linkType: hard + +"fdir@npm:^6.4.3, fdir@npm:^6.5.0": + version: 6.5.0 + resolution: "fdir@npm:6.5.0" + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + checksum: bd537daa9d3cd53887eed35efa0eab2dbb1ca408790e10e024120e7a36c6e9ae2b33710cb8381e35def01bc9c1d7eaba746f886338413e68ff6ebaee07b9a6e8 + languageName: node + linkType: hard + +"figures@npm:3.2.0, figures@npm:^3.0.0": + version: 3.2.0 + resolution: "figures@npm:3.2.0" + dependencies: + escape-string-regexp: ^1.0.5 + checksum: 85a6ad29e9aca80b49b817e7c89ecc4716ff14e3779d9835af554db91bac41c0f289c418923519392a1e582b4d10482ad282021330cd045bb7b80c84152f2a2b + languageName: node + linkType: hard + +"file-entry-cache@npm:^6.0.1": + version: 6.0.1 + resolution: "file-entry-cache@npm:6.0.1" + dependencies: + flat-cache: ^3.0.4 + checksum: f49701feaa6314c8127c3c2f6173cfefff17612f5ed2daaafc6da13b5c91fd43e3b2a58fd0d63f9f94478a501b167615931e7200e31485e320f74a33885a9c74 + languageName: node + linkType: hard + +"filelist@npm:^1.0.4": + version: 1.0.4 + resolution: "filelist@npm:1.0.4" + dependencies: + minimatch: ^5.0.1 + checksum: a303573b0821e17f2d5e9783688ab6fbfce5d52aaac842790ae85e704a6f5e4e3538660a63183d6453834dedf1e0f19a9dadcebfa3e926c72397694ea11f5160 + languageName: node + linkType: hard + +"fill-range@npm:^7.1.1": + version: 7.1.1 + resolution: "fill-range@npm:7.1.1" + dependencies: + to-regex-range: ^5.0.1 + checksum: b4abfbca3839a3d55e4ae5ec62e131e2e356bf4859ce8480c64c4876100f4df292a63e5bb1618e1d7460282ca2b305653064f01654474aa35c68000980f17798 + languageName: node + linkType: hard + +"finalhandler@npm:1.1.2": + version: 1.1.2 + resolution: "finalhandler@npm:1.1.2" + dependencies: + debug: 2.6.9 + encodeurl: ~1.0.2 + escape-html: ~1.0.3 + on-finished: ~2.3.0 + parseurl: ~1.3.3 + statuses: ~1.5.0 + unpipe: ~1.0.0 + checksum: 617880460c5138dd7ccfd555cb5dde4d8f170f4b31b8bd51e4b646bb2946c30f7db716428a1f2882d730d2b72afb47d1f67cc487b874cb15426f95753a88965e + languageName: node + linkType: hard + +"find-up@npm:^2.0.0": + version: 2.1.0 + resolution: "find-up@npm:2.1.0" + dependencies: + locate-path: ^2.0.0 + checksum: 43284fe4da09f89011f08e3c32cd38401e786b19226ea440b75386c1b12a4cb738c94969808d53a84f564ede22f732c8409e3cfc3f7fb5b5c32378ad0bbf28bd + languageName: node + linkType: hard + +"find-up@npm:^4.0.0, find-up@npm:^4.1.0": + version: 4.1.0 + resolution: "find-up@npm:4.1.0" + dependencies: + locate-path: ^5.0.0 + path-exists: ^4.0.0 + checksum: 4c172680e8f8c1f78839486e14a43ef82e9decd0e74145f40707cc42e7420506d5ec92d9a11c22bd2c48fb0c384ea05dd30e10dd152fefeec6f2f75282a8b844 + languageName: node + linkType: hard + +"find-up@npm:^5.0.0": + version: 5.0.0 + resolution: "find-up@npm:5.0.0" + dependencies: + locate-path: ^6.0.0 + path-exists: ^4.0.0 + checksum: 07955e357348f34660bde7920783204ff5a26ac2cafcaa28bace494027158a97b9f56faaf2d89a6106211a8174db650dd9f503f9c0d526b1202d5554a00b9095 + languageName: node + linkType: hard + +"flat-cache@npm:^3.0.4": + version: 3.2.0 + resolution: "flat-cache@npm:3.2.0" + dependencies: + flatted: ^3.2.9 + keyv: ^4.5.3 + rimraf: ^3.0.2 + checksum: e7e0f59801e288b54bee5cb9681e9ee21ee28ef309f886b312c9d08415b79fc0f24ac842f84356ce80f47d6a53de62197ce0e6e148dc42d5db005992e2a756ec + languageName: node + linkType: hard + +"flat@npm:^5.0.2": + version: 5.0.2 + resolution: "flat@npm:5.0.2" + bin: + flat: cli.js + checksum: 12a1536ac746db74881316a181499a78ef953632ddd28050b7a3a43c62ef5462e3357c8c29d76072bb635f147f7a9a1f0c02efef6b4be28f8db62ceb3d5c7f5d + languageName: node + linkType: hard + +"flatted@npm:^3.2.9": + version: 3.3.3 + resolution: "flatted@npm:3.3.3" + checksum: 8c96c02fbeadcf4e8ffd0fa24983241e27698b0781295622591fc13585e2f226609d95e422bcf2ef044146ffacb6b68b1f20871454eddf75ab3caa6ee5f4a1fe + languageName: node + linkType: hard + +"flow-enums-runtime@npm:^0.0.6": + version: 0.0.6 + resolution: "flow-enums-runtime@npm:0.0.6" + checksum: c60412ed6d43b26bf5dfa66be8e588c3ccdb20191fd269e02ca7e8e1d350c73a327cc9a7edb626c80c31eb906981945d12a87ca37118985f33406303806dab79 + languageName: node + linkType: hard + +"follow-redirects@npm:^1.15.6": + version: 1.15.11 + resolution: "follow-redirects@npm:1.15.11" + peerDependenciesMeta: + debug: + optional: true + checksum: 20bf55e9504f59e6cc3743ba27edb2ebf41edea1baab34799408f2c050f73f0c612728db21c691276296d2795ea8a812dc532a98e8793619fcab91abe06d017f + languageName: node + linkType: hard + +"foreground-child@npm:^3.1.0": + version: 3.3.1 + resolution: "foreground-child@npm:3.3.1" + dependencies: + cross-spawn: ^7.0.6 + signal-exit: ^4.0.1 + checksum: b2c1a6fc0bf0233d645d9fefdfa999abf37db1b33e5dab172b3cbfb0662b88bfbd2c9e7ab853533d199050ec6b65c03fcf078fc212d26e4990220e98c6930eef + languageName: node + linkType: hard + +"form-data@npm:^4.0.4": + version: 4.0.5 + resolution: "form-data@npm:4.0.5" + dependencies: + asynckit: ^0.4.0 + combined-stream: ^1.0.8 + es-set-tostringtag: ^2.1.0 + hasown: ^2.0.2 + mime-types: ^2.1.12 + checksum: af8328413c16d0cded5fccc975a44d227c5120fd46a9e81de8acf619d43ed838414cc6d7792195b30b248f76a65246949a129a4dadd148721948f90cd6d4fb69 + languageName: node + linkType: hard + +"fresh@npm:~0.5.2": + version: 0.5.2 + resolution: "fresh@npm:0.5.2" + checksum: 13ea8b08f91e669a64e3ba3a20eb79d7ca5379a81f1ff7f4310d54e2320645503cc0c78daedc93dfb6191287295f6479544a649c64d8e41a1c0fb0c221552346 + languageName: node + linkType: hard + +"front-matter@npm:^4.0.2": + version: 4.0.2 + resolution: "front-matter@npm:4.0.2" + dependencies: + js-yaml: ^3.13.1 + checksum: a5b4c36d75a820301ebf31db0f677332d189c4561903ab6853eaa0504b43634f98557dbf87752e09043dbd2c9dcc14b4bcf9151cb319c8ad7e26edb203c0cd23 + languageName: node + linkType: hard + +"fs-constants@npm:^1.0.0": + version: 1.0.0 + resolution: "fs-constants@npm:1.0.0" + checksum: 18f5b718371816155849475ac36c7d0b24d39a11d91348cfcb308b4494824413e03572c403c86d3a260e049465518c4f0d5bd00f0371cdfcad6d4f30a85b350d + languageName: node + linkType: hard + +"fs-extra@npm:^11.0.0, fs-extra@npm:^11.2.0": + version: 11.3.3 + resolution: "fs-extra@npm:11.3.3" + dependencies: + graceful-fs: ^4.2.0 + jsonfile: ^6.0.1 + universalify: ^2.0.0 + checksum: fb2acabbd1e04bcaca90eadfe98e6ffba1523b8009afbb9f4c0aae5efbca0bd0bf6c9a6831df5af5aaacb98d3e499898be848fb0c03d31ae7b9d1b053e81c151 + languageName: node + linkType: hard + +"fs-minipass@npm:^2.0.0": + version: 2.1.0 + resolution: "fs-minipass@npm:2.1.0" + dependencies: + minipass: ^3.0.0 + checksum: 1b8d128dae2ac6cc94230cc5ead341ba3e0efaef82dab46a33d171c044caaa6ca001364178d42069b2809c35a1c3c35079a32107c770e9ffab3901b59af8c8b1 + languageName: node + linkType: hard + +"fs-minipass@npm:^3.0.0": + version: 3.0.3 + resolution: "fs-minipass@npm:3.0.3" + dependencies: + minipass: ^7.0.3 + checksum: 8722a41109130851d979222d3ec88aabaceeaaf8f57b2a8f744ef8bd2d1ce95453b04a61daa0078822bc5cd21e008814f06fe6586f56fef511e71b8d2394d802 + languageName: node + linkType: hard + +"fs.realpath@npm:^1.0.0": + version: 1.0.0 + resolution: "fs.realpath@npm:1.0.0" + checksum: 99ddea01a7e75aa276c250a04eedeffe5662bce66c65c07164ad6264f9de18fb21be9433ead460e54cff20e31721c811f4fb5d70591799df5f85dce6d6746fd0 + languageName: node + linkType: hard + +"fsevents@npm:^2.3.2": + version: 2.3.3 + resolution: "fsevents@npm:2.3.3" + dependencies: + node-gyp: latest + checksum: 11e6ea6fea15e42461fc55b4b0e4a0a3c654faa567f1877dbd353f39156f69def97a69936d1746619d656c4b93de2238bf731f6085a03a50cabf287c9d024317 + conditions: os=darwin + languageName: node + linkType: hard + +"fsevents@patch:fsevents@^2.3.2#~builtin": + version: 2.3.3 + resolution: "fsevents@patch:fsevents@npm%3A2.3.3#~builtin::version=2.3.3&hash=df0bf1" + dependencies: + node-gyp: latest + conditions: os=darwin + languageName: node + linkType: hard + +"function-bind@npm:^1.1.2": + version: 1.1.2 + resolution: "function-bind@npm:1.1.2" + checksum: 2b0ff4ce708d99715ad14a6d1f894e2a83242e4a52ccfcefaee5e40050562e5f6dafc1adbb4ce2d4ab47279a45dc736ab91ea5042d843c3c092820dfe032efb1 + languageName: node + linkType: hard + +"generator-function@npm:^2.0.0": + version: 2.0.1 + resolution: "generator-function@npm:2.0.1" + checksum: 3bf87f7b0230de5d74529677e6c3ceb3b7b5d9618b5a22d92b45ce3876defbaf5a77791b25a61b0fa7d13f95675b5ff67a7769f3b9af33f096e34653519e873d + languageName: node + linkType: hard + +"gensync@npm:^1.0.0-beta.2": + version: 1.0.0-beta.2 + resolution: "gensync@npm:1.0.0-beta.2" + checksum: a7437e58c6be12aa6c90f7730eac7fa9833dc78872b4ad2963d2031b00a3367a93f98aec75f9aaac7220848e4026d67a8655e870b24f20a543d103c0d65952ec + languageName: node + linkType: hard + +"get-caller-file@npm:^2.0.5": + version: 2.0.5 + resolution: "get-caller-file@npm:2.0.5" + checksum: b9769a836d2a98c3ee734a88ba712e62703f1df31b94b784762c433c27a386dd6029ff55c2a920c392e33657d80191edbf18c61487e198844844516f843496b9 + languageName: node + linkType: hard + +"get-east-asian-width@npm:^1.0.0": + version: 1.4.0 + resolution: "get-east-asian-width@npm:1.4.0" + checksum: 1d9a81a8004f4217ebef5d461875047d269e4b57e039558fd65130877cd4da8e3f61e1c4eada0c8b10e2816c7baf7d5fddb7006f561da13bc6f6dd19c1e964a4 + languageName: node + linkType: hard + +"get-intrinsic@npm:^1.2.6": + version: 1.3.1 + resolution: "get-intrinsic@npm:1.3.1" + dependencies: + async-function: ^1.0.0 + async-generator-function: ^1.0.0 + call-bind-apply-helpers: ^1.0.2 + es-define-property: ^1.0.1 + es-errors: ^1.3.0 + es-object-atoms: ^1.1.1 + function-bind: ^1.1.2 + generator-function: ^2.0.0 + get-proto: ^1.0.1 + gopd: ^1.2.0 + has-symbols: ^1.1.0 + hasown: ^2.0.2 + math-intrinsics: ^1.1.0 + checksum: c02b3b6a445f9cd53e14896303794ac60f9751f58a69099127248abdb0251957174c6524245fc68579dc8e6a35161d3d94c93e665f808274716f4248b269436a + languageName: node + linkType: hard + +"get-package-type@npm:^0.1.0": + version: 0.1.0 + resolution: "get-package-type@npm:0.1.0" + checksum: bba0811116d11e56d702682ddef7c73ba3481f114590e705fc549f4d868972263896af313c57a25c076e3c0d567e11d919a64ba1b30c879be985fc9d44f96148 + languageName: node + linkType: hard + +"get-pkg-repo@npm:^4.2.1": + version: 4.2.1 + resolution: "get-pkg-repo@npm:4.2.1" + dependencies: + "@hutson/parse-repository-url": ^3.0.0 + hosted-git-info: ^4.0.0 + through2: ^2.0.0 + yargs: ^16.2.0 + bin: + get-pkg-repo: src/cli.js + checksum: 5abf169137665e45b09a857b33ad2fdcf2f4a09f0ecbd0ebdd789a7ce78c39186a21f58621127eb724d2d4a3a7ee8e6bd4ac7715efda01ad5200665afc218e0d + languageName: node + linkType: hard + +"get-port@npm:5.1.1": + version: 5.1.1 + resolution: "get-port@npm:5.1.1" + checksum: 0162663ffe5c09e748cd79d97b74cd70e5a5c84b760a475ce5767b357fb2a57cb821cee412d646aa8a156ed39b78aab88974eddaa9e5ee926173c036c0713787 + languageName: node + linkType: hard + +"get-proto@npm:^1.0.1": + version: 1.0.1 + resolution: "get-proto@npm:1.0.1" + dependencies: + dunder-proto: ^1.0.1 + es-object-atoms: ^1.0.0 + checksum: 4fc96afdb58ced9a67558698b91433e6b037aaa6f1493af77498d7c85b141382cf223c0e5946f334fb328ee85dfe6edd06d218eaf09556f4bc4ec6005d7f5f7b + languageName: node + linkType: hard + +"get-stream@npm:6.0.0": + version: 6.0.0 + resolution: "get-stream@npm:6.0.0" + checksum: 587e6a93127f9991b494a566f4971cf7a2645dfa78034818143480a80587027bdd8826cdcf80d0eff4a4a19de0d231d157280f24789fc9cc31492e1dcc1290cf + languageName: node + linkType: hard + +"get-stream@npm:^6.0.0": + version: 6.0.1 + resolution: "get-stream@npm:6.0.1" + checksum: e04ecece32c92eebf5b8c940f51468cd53554dcbb0ea725b2748be583c9523d00128137966afce410b9b051eb2ef16d657cd2b120ca8edafcf5a65e81af63cad + languageName: node + linkType: hard + +"git-raw-commits@npm:^2.0.11": + version: 2.0.11 + resolution: "git-raw-commits@npm:2.0.11" + dependencies: + dargs: ^7.0.0 + lodash: ^4.17.15 + meow: ^8.0.0 + split2: ^3.0.0 + through2: ^4.0.0 + bin: + git-raw-commits: cli.js + checksum: c178af43633684106179793b6e3473e1d2bb50bb41d04e2e285ea4eef342ca4090fee6bc8a737552fde879d22346c90de5c49f18c719a0f38d4c934f258a0f79 + languageName: node + linkType: hard + +"git-raw-commits@npm:^3.0.0": + version: 3.0.0 + resolution: "git-raw-commits@npm:3.0.0" + dependencies: + dargs: ^7.0.0 + meow: ^8.1.2 + split2: ^3.2.2 + bin: + git-raw-commits: cli.js + checksum: 198892f307829d22fc8ec1c9b4a63876a1fde847763857bb74bd1b04c6f6bc0d7464340c25d0f34fd0fb395759363aa1f8ce324357027320d80523bf234676ab + languageName: node + linkType: hard + +"git-remote-origin-url@npm:^2.0.0": + version: 2.0.0 + resolution: "git-remote-origin-url@npm:2.0.0" + dependencies: + gitconfiglocal: ^1.0.0 + pify: ^2.3.0 + checksum: 85263a09c044b5f4fe2acc45cbb3c5331ab2bd4484bb53dfe7f3dd593a4bf90a9786a2e00b9884524331f50b3da18e8c924f01c2944087fc7f342282c4437b73 + languageName: node + linkType: hard + +"git-semver-tags@npm:^5.0.0": + version: 5.0.1 + resolution: "git-semver-tags@npm:5.0.1" + dependencies: + meow: ^8.1.2 + semver: ^7.0.0 + bin: + git-semver-tags: cli.js + checksum: c181e1d9e7649fd90e6c347f400f791db08b236265d79874dfa60f09ca893fa7a4fceebf3fd5f01443705e7eac5c73c5235eb96c6bc4a39eb37746a1d7c49ec4 + languageName: node + linkType: hard + +"git-up@npm:^7.0.0": + version: 7.0.0 + resolution: "git-up@npm:7.0.0" + dependencies: + is-ssh: ^1.4.0 + parse-url: ^8.1.0 + checksum: 2faadbab51e94d2ffb220e426e950087cc02c15d664e673bd5d1f734cfa8196fed8b19493f7bf28fe216d087d10e22a7fd9b63687e0ba7d24f0ddcfb0a266d6e + languageName: node + linkType: hard + +"git-url-parse@npm:14.0.0": + version: 14.0.0 + resolution: "git-url-parse@npm:14.0.0" + dependencies: + git-up: ^7.0.0 + checksum: b011c5de652e60e5f19de9815d1b78b2f725deb07e73d1b9ff8ca6657406d0a6c691fbe4460017822676a80635f93099345cadbd06361b76f53c4556265d3e48 + languageName: node + linkType: hard + +"gitconfiglocal@npm:^1.0.0": + version: 1.0.0 + resolution: "gitconfiglocal@npm:1.0.0" + dependencies: + ini: ^1.3.2 + checksum: e6d2764c15bbab6d1d1000d1181bb907f6b3796bb04f63614dba571b18369e0ecb1beaf27ce8da5b24307ef607e3a5f262a67cb9575510b9446aac697d421beb + languageName: node + linkType: hard + +"glob-parent@npm:6.0.2, glob-parent@npm:^6.0.2": + version: 6.0.2 + resolution: "glob-parent@npm:6.0.2" + dependencies: + is-glob: ^4.0.3 + checksum: c13ee97978bef4f55106b71e66428eb1512e71a7466ba49025fc2aec59a5bfb0954d5abd58fc5ee6c9b076eef4e1f6d3375c2e964b88466ca390da4419a786a8 + languageName: node + linkType: hard + +"glob-parent@npm:^5.1.2": + version: 5.1.2 + resolution: "glob-parent@npm:5.1.2" + dependencies: + is-glob: ^4.0.1 + checksum: f4f2bfe2425296e8a47e36864e4f42be38a996db40420fe434565e4480e3322f18eb37589617a98640c5dc8fdec1a387007ee18dbb1f3f5553409c34d17f425e + languageName: node + linkType: hard + +"glob@npm:^10.2.2, glob@npm:^10.3.10": + version: 10.5.0 + resolution: "glob@npm:10.5.0" + dependencies: + foreground-child: ^3.1.0 + jackspeak: ^3.1.2 + minimatch: ^9.0.4 + minipass: ^7.1.2 + package-json-from-dist: ^1.0.0 + path-scurry: ^1.11.1 + bin: + glob: dist/esm/bin.mjs + checksum: cda96c074878abca9657bd984d2396945cf0d64283f6feeb40d738fe2da642be0010ad5210a1646244a5fc3511b0cab5a374569b3de5a12b8a63d392f18c6043 + languageName: node + linkType: hard + +"glob@npm:^13.0.0": + version: 13.0.0 + resolution: "glob@npm:13.0.0" + dependencies: + minimatch: ^10.1.1 + minipass: ^7.1.2 + path-scurry: ^2.0.0 + checksum: 963730222b0acc85a0d2616c08ba3a5d5b5f33fbf69182791967b8a02245db505577a6fc19836d5d58e1cbbfb414ad4f62f605a0372ab05cd9e6998efe944369 + languageName: node + linkType: hard + +"glob@npm:^7.1.1, glob@npm:^7.1.3, glob@npm:^7.1.4": + version: 7.2.3 + resolution: "glob@npm:7.2.3" + dependencies: + fs.realpath: ^1.0.0 + inflight: ^1.0.4 + inherits: 2 + minimatch: ^3.1.1 + once: ^1.3.0 + path-is-absolute: ^1.0.0 + checksum: 29452e97b38fa704dabb1d1045350fb2467cf0277e155aa9ff7077e90ad81d1ea9d53d3ee63bd37c05b09a065e90f16aec4a65f5b8de401d1dac40bc5605d133 + languageName: node + linkType: hard + +"glob@npm:^9.2.0": + version: 9.3.5 + resolution: "glob@npm:9.3.5" + dependencies: + fs.realpath: ^1.0.0 + minimatch: ^8.0.2 + minipass: ^4.2.4 + path-scurry: ^1.6.1 + checksum: 94b093adbc591bc36b582f77927d1fb0dbf3ccc231828512b017601408be98d1fe798fc8c0b19c6f2d1a7660339c3502ce698de475e9d938ccbb69b47b647c84 + languageName: node + linkType: hard + +"global-dirs@npm:^0.1.1": + version: 0.1.1 + resolution: "global-dirs@npm:0.1.1" + dependencies: + ini: ^1.3.4 + checksum: 10624f5a8ddb8634c22804c6b24f93fb591c3639a6bc78e3584e01a238fc6f7b7965824184e57d63f6df36980b6c191484ad7bc6c35a1599b8f1d64be64c2a4a + languageName: node + linkType: hard + +"globals@npm:^13.19.0": + version: 13.24.0 + resolution: "globals@npm:13.24.0" + dependencies: + type-fest: ^0.20.2 + checksum: 56066ef058f6867c04ff203b8a44c15b038346a62efbc3060052a1016be9f56f4cf0b2cd45b74b22b81e521a889fc7786c73691b0549c2f3a6e825b3d394f43c + languageName: node + linkType: hard + +"globby@npm:^13.1.2": + version: 13.2.2 + resolution: "globby@npm:13.2.2" + dependencies: + dir-glob: ^3.0.1 + fast-glob: ^3.3.0 + ignore: ^5.2.4 + merge2: ^1.4.1 + slash: ^4.0.0 + checksum: f3d84ced58a901b4fcc29c846983108c426631fe47e94872868b65565495f7bee7b3defd68923bd480582771fd4bbe819217803a164a618ad76f1d22f666f41e + languageName: node + linkType: hard + +"gopd@npm:^1.2.0": + version: 1.2.0 + resolution: "gopd@npm:1.2.0" + checksum: cc6d8e655e360955bdccaca51a12a474268f95bb793fc3e1f2bdadb075f28bfd1fd988dab872daf77a61d78cbaf13744bc8727a17cfb1d150d76047d805375f3 + languageName: node + linkType: hard + +"graceful-fs@npm:4.2.11, graceful-fs@npm:^4.1.11, graceful-fs@npm:^4.1.15, graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.10, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6, graceful-fs@npm:^4.2.9": + version: 4.2.11 + resolution: "graceful-fs@npm:4.2.11" + checksum: ac85f94da92d8eb6b7f5a8b20ce65e43d66761c55ce85ac96df6865308390da45a8d3f0296dd3a663de65d30ba497bd46c696cc1e248c72b13d6d567138a4fc7 + languageName: node + linkType: hard + +"graphemer@npm:^1.4.0": + version: 1.4.0 + resolution: "graphemer@npm:1.4.0" + checksum: bab8f0be9b568857c7bec9fda95a89f87b783546d02951c40c33f84d05bb7da3fd10f863a9beb901463669b6583173a8c8cc6d6b306ea2b9b9d5d3d943c3a673 + languageName: node + linkType: hard + +"handlebars@npm:^4.7.7": + version: 4.7.8 + resolution: "handlebars@npm:4.7.8" + dependencies: + minimist: ^1.2.5 + neo-async: ^2.6.2 + source-map: ^0.6.1 + uglify-js: ^3.1.4 + wordwrap: ^1.0.0 + dependenciesMeta: + uglify-js: + optional: true + bin: + handlebars: bin/handlebars + checksum: 00e68bb5c183fd7b8b63322e6234b5ac8fbb960d712cb3f25587d559c2951d9642df83c04a1172c918c41bcfc81bfbd7a7718bbce93b893e0135fc99edea93ff + languageName: node + linkType: hard + +"hard-rejection@npm:^2.1.0": + version: 2.1.0 + resolution: "hard-rejection@npm:2.1.0" + checksum: 7baaf80a0c7fff4ca79687b4060113f1529589852152fa935e6787a2bc96211e784ad4588fb3048136ff8ffc9dfcf3ae385314a5b24db32de20bea0d1597f9dc + languageName: node + linkType: hard + +"has-flag@npm:^4.0.0": + version: 4.0.0 + resolution: "has-flag@npm:4.0.0" + checksum: 261a1357037ead75e338156b1f9452c016a37dcd3283a972a30d9e4a87441ba372c8b81f818cd0fbcd9c0354b4ae7e18b9e1afa1971164aef6d18c2b6095a8ad + languageName: node + linkType: hard + +"has-symbols@npm:^1.0.3, has-symbols@npm:^1.1.0": + version: 1.1.0 + resolution: "has-symbols@npm:1.1.0" + checksum: b2316c7302a0e8ba3aaba215f834e96c22c86f192e7310bdf689dd0e6999510c89b00fbc5742571507cebf25764d68c988b3a0da217369a73596191ac0ce694b + languageName: node + linkType: hard + +"has-tostringtag@npm:^1.0.2": + version: 1.0.2 + resolution: "has-tostringtag@npm:1.0.2" + dependencies: + has-symbols: ^1.0.3 + checksum: 999d60bb753ad714356b2c6c87b7fb74f32463b8426e159397da4bde5bca7e598ab1073f4d8d4deafac297f2eb311484cd177af242776bf05f0d11565680468d + languageName: node + linkType: hard + +"has-unicode@npm:2.0.1": + version: 2.0.1 + resolution: "has-unicode@npm:2.0.1" + checksum: 1eab07a7436512db0be40a710b29b5dc21fa04880b7f63c9980b706683127e3c1b57cb80ea96d47991bdae2dfe479604f6a1ba410106ee1046a41d1bd0814400 + languageName: node + linkType: hard + +"hasown@npm:^2.0.2": + version: 2.0.2 + resolution: "hasown@npm:2.0.2" + dependencies: + function-bind: ^1.1.2 + checksum: e8516f776a15149ca6c6ed2ae3110c417a00b62260e222590e54aa367cbcd6ed99122020b37b7fbdf05748df57b265e70095d7bf35a47660587619b15ffb93db + languageName: node + linkType: hard + +"hermes-compiler@npm:0.14.0": + version: 0.14.0 + resolution: "hermes-compiler@npm:0.14.0" + checksum: 5b614ebe621e92550efd77a6aefe85d9cbab865386dc36de9895d4684ba0af13623d045b99f5b834f91a42ba3f00982462908eaf7cb6c8423056e9d5c8280ab3 + languageName: node + linkType: hard + +"hermes-estree@npm:0.32.0": + version: 0.32.0 + resolution: "hermes-estree@npm:0.32.0" + checksum: 7b0606a8d2cf4593634d01b0eae0764c0e4703bc5cd73cbb0547fb8dda9445a27a83345117c08eef64f6bdab1287e3c5a4e3001deed465a715d26f4e918c8b22 + languageName: node + linkType: hard + +"hermes-parser@npm:0.32.0": + version: 0.32.0 + resolution: "hermes-parser@npm:0.32.0" + dependencies: + hermes-estree: 0.32.0 + checksum: 7ec172ec763ee5ba1d01f273084ab4c7ad7a543d1ed11e887ea3a9eba7c0b83854dde08e835e38f29b74146b5ce17e67d556774324a63f8afe16fb57021bfdcb + languageName: node + linkType: hard + +"hosted-git-info@npm:^2.1.4": + version: 2.8.9 + resolution: "hosted-git-info@npm:2.8.9" + checksum: c955394bdab888a1e9bb10eb33029e0f7ce5a2ac7b3f158099dc8c486c99e73809dca609f5694b223920ca2174db33d32b12f9a2a47141dc59607c29da5a62dd + languageName: node + linkType: hard + +"hosted-git-info@npm:^4.0.0, hosted-git-info@npm:^4.0.1": + version: 4.1.0 + resolution: "hosted-git-info@npm:4.1.0" + dependencies: + lru-cache: ^6.0.0 + checksum: c3f87b3c2f7eb8c2748c8f49c0c2517c9a95f35d26f4bf54b2a8cba05d2e668f3753548b6ea366b18ec8dadb4e12066e19fa382a01496b0ffa0497eb23cbe461 + languageName: node + linkType: hard + +"hosted-git-info@npm:^7.0.0, hosted-git-info@npm:^7.0.2": + version: 7.0.2 + resolution: "hosted-git-info@npm:7.0.2" + dependencies: + lru-cache: ^10.0.1 + checksum: 467cf908a56556417b18e86ae3b8dee03c2360ef1d51e61c4028fe87f6f309b6ff038589c94b5666af207da9d972d5107698906aabeb78aca134641962a5c6f8 + languageName: node + linkType: hard + +"http-cache-semantics@npm:^4.1.1": + version: 4.2.0 + resolution: "http-cache-semantics@npm:4.2.0" + checksum: 7a7246ddfce629f96832791176fd643589d954e6f3b49548dadb4290451961237fab8fcea41cd2008fe819d95b41c1e8b97f47d088afc0a1c81705287b4ddbcc + languageName: node + linkType: hard + +"http-errors@npm:~2.0.1": + version: 2.0.1 + resolution: "http-errors@npm:2.0.1" + dependencies: + depd: ~2.0.0 + inherits: ~2.0.4 + setprototypeof: ~1.2.0 + statuses: ~2.0.2 + toidentifier: ~1.0.1 + checksum: 155d1a100a06e4964597013109590b97540a177b69c3600bbc93efc746465a99a2b718f43cdf76b3791af994bbe3a5711002046bf668cdc007ea44cea6df7ccd + languageName: node + linkType: hard + +"http-proxy-agent@npm:^7.0.0": + version: 7.0.2 + resolution: "http-proxy-agent@npm:7.0.2" + dependencies: + agent-base: ^7.1.0 + debug: ^4.3.4 + checksum: 670858c8f8f3146db5889e1fa117630910101db601fff7d5a8aa637da0abedf68c899f03d3451cac2f83bcc4c3d2dabf339b3aa00ff8080571cceb02c3ce02f3 + languageName: node + linkType: hard + +"https-proxy-agent@npm:^7.0.1, https-proxy-agent@npm:^7.0.5": + version: 7.0.6 + resolution: "https-proxy-agent@npm:7.0.6" + dependencies: + agent-base: ^7.1.2 + debug: 4 + checksum: b882377a120aa0544846172e5db021fa8afbf83fea2a897d397bd2ddd8095ab268c24bc462f40a15f2a8c600bf4aa05ce52927f70038d4014e68aefecfa94e8d + languageName: node + linkType: hard + +"human-signals@npm:^2.1.0": + version: 2.1.0 + resolution: "human-signals@npm:2.1.0" + checksum: b87fd89fce72391625271454e70f67fe405277415b48bcc0117ca73d31fa23a4241787afdc8d67f5a116cf37258c052f59ea82daffa72364d61351423848e3b8 + languageName: node + linkType: hard + +"iconv-lite@npm:^0.6.2": + version: 0.6.3 + resolution: "iconv-lite@npm:0.6.3" + dependencies: + safer-buffer: ">= 2.1.2 < 3.0.0" + checksum: 3f60d47a5c8fc3313317edfd29a00a692cc87a19cac0159e2ce711d0ebc9019064108323b5e493625e25594f11c6236647d8e256fbe7a58f4a3b33b89e6d30bf + languageName: node + linkType: hard + +"iconv-lite@npm:^0.7.0": + version: 0.7.2 + resolution: "iconv-lite@npm:0.7.2" + dependencies: + safer-buffer: ">= 2.1.2 < 3.0.0" + checksum: faf884c1f631a5d676e3e64054bed891c7c5f616b790082d99ccfbfd017c661a39db8009160268fd65fae57c9154d4d491ebc9c301f3446a078460ef114dc4b8 + languageName: node + linkType: hard + +"ieee754@npm:^1.1.13": + version: 1.2.1 + resolution: "ieee754@npm:1.2.1" + checksum: 5144c0c9815e54ada181d80a0b810221a253562422e7c6c3a60b1901154184f49326ec239d618c416c1c5945a2e197107aee8d986a3dd836b53dffefd99b5e7e + languageName: node + linkType: hard + +"ignore-walk@npm:^6.0.4": + version: 6.0.5 + resolution: "ignore-walk@npm:6.0.5" + dependencies: + minimatch: ^9.0.0 + checksum: 06f88a53c412385ca7333276149a7e9461b7fad977c44272d854522b0d456c2aa75d832bd3980a530e2c3881126aa9cc4782b3551ca270fffc0ce7c2b4a2e199 + languageName: node + linkType: hard + +"ignore@npm:^5.0.4, ignore@npm:^5.2.0, ignore@npm:^5.2.4": + version: 5.3.2 + resolution: "ignore@npm:5.3.2" + checksum: 2acfd32a573260ea522ea0bfeff880af426d68f6831f973129e2ba7363f422923cf53aab62f8369cbf4667c7b25b6f8a3761b34ecdb284ea18e87a5262a865be + languageName: node + linkType: hard + +"ignore@npm:^7.0.5": + version: 7.0.5 + resolution: "ignore@npm:7.0.5" + checksum: d0862bf64d3d58bf34d5fb0a9f725bec9ca5ce8cd1aecc8f28034269e8f69b8009ffd79ca3eda96962a6a444687781cd5efdb8c7c8ddc0a6996e36d31c217f14 + languageName: node + linkType: hard + +"image-size@npm:^1.0.2": + version: 1.2.1 + resolution: "image-size@npm:1.2.1" + dependencies: + queue: 6.0.2 + bin: + image-size: bin/image-size.js + checksum: 8601ddd4edc1db16f097f5cf585c23214e29c3b8f4d8a8f8d59b8e3bae2338c8a5073236bfff421d8541091a98a38b802ed049203c745286a69d1aac4e5bc4c7 + languageName: node + linkType: hard + +"import-fresh@npm:^3.0.0, import-fresh@npm:^3.2.1, import-fresh@npm:^3.3.0": + version: 3.3.1 + resolution: "import-fresh@npm:3.3.1" + dependencies: + parent-module: ^1.0.0 + resolve-from: ^4.0.0 + checksum: a06b19461b4879cc654d46f8a6244eb55eb053437afd4cbb6613cad6be203811849ed3e4ea038783092879487299fda24af932b86bdfff67c9055ba3612b8c87 + languageName: node + linkType: hard + +"import-local@npm:3.1.0": + version: 3.1.0 + resolution: "import-local@npm:3.1.0" + dependencies: + pkg-dir: ^4.2.0 + resolve-cwd: ^3.0.0 + bin: + import-local-fixture: fixtures/cli.js + checksum: bfcdb63b5e3c0e245e347f3107564035b128a414c4da1172a20dc67db2504e05ede4ac2eee1252359f78b0bfd7b19ef180aec427c2fce6493ae782d73a04cddd + languageName: node + linkType: hard + +"imurmurhash@npm:^0.1.4": + version: 0.1.4 + resolution: "imurmurhash@npm:0.1.4" + checksum: 7cae75c8cd9a50f57dadd77482359f659eaebac0319dd9368bcd1714f55e65badd6929ca58569da2b6494ef13fdd5598cd700b1eba23f8b79c5f19d195a3ecf7 + languageName: node + linkType: hard + +"indent-string@npm:^4.0.0": + version: 4.0.0 + resolution: "indent-string@npm:4.0.0" + checksum: 824cfb9929d031dabf059bebfe08cf3137365e112019086ed3dcff6a0a7b698cb80cf67ccccde0e25b9e2d7527aa6cc1fed1ac490c752162496caba3e6699612 + languageName: node + linkType: hard + +"indent-string@npm:^5.0.0": + version: 5.0.0 + resolution: "indent-string@npm:5.0.0" + checksum: e466c27b6373440e6d84fbc19e750219ce25865cb82d578e41a6053d727e5520dc5725217d6eb1cc76005a1bb1696a0f106d84ce7ebda3033b963a38583fb3b3 + languageName: node + linkType: hard + +"inflight@npm:^1.0.4": + version: 1.0.6 + resolution: "inflight@npm:1.0.6" + dependencies: + once: ^1.3.0 + wrappy: 1 + checksum: f4f76aa072ce19fae87ce1ef7d221e709afb59d445e05d47fba710e85470923a75de35bfae47da6de1b18afc3ce83d70facf44cfb0aff89f0a3f45c0a0244dfd + languageName: node + linkType: hard + +"inherits@npm:2, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.3, inherits@npm:~2.0.4": + version: 2.0.4 + resolution: "inherits@npm:2.0.4" + checksum: 4a48a733847879d6cf6691860a6b1e3f0f4754176e4d71494c41f3475553768b10f84b5ce1d40fbd0e34e6bfbb864ee35858ad4dd2cf31e02fc4a154b724d7f1 + languageName: node + linkType: hard + +"ini@npm:^1.3.2, ini@npm:^1.3.4, ini@npm:^1.3.8": + version: 1.3.8 + resolution: "ini@npm:1.3.8" + checksum: dfd98b0ca3a4fc1e323e38a6c8eb8936e31a97a918d3b377649ea15bdb15d481207a0dda1021efbd86b464cae29a0d33c1d7dcaf6c5672bee17fa849bc50a1b3 + languageName: node + linkType: hard + +"ini@npm:^4.1.3": + version: 4.1.3 + resolution: "ini@npm:4.1.3" + checksum: 004b2be42388877c58add606149f1a0c7985c90a0ba5dbf45a4738fdc70b0798d922caecaa54617029626505898ac451ff0537a08b949836b49d3267f66542c9 + languageName: node + linkType: hard + +"init-package-json@npm:6.0.3": + version: 6.0.3 + resolution: "init-package-json@npm:6.0.3" + dependencies: + "@npmcli/package-json": ^5.0.0 + npm-package-arg: ^11.0.0 + promzard: ^1.0.0 + read: ^3.0.1 + semver: ^7.3.5 + validate-npm-package-license: ^3.0.4 + validate-npm-package-name: ^5.0.0 + checksum: 332de50c7433551b9fcd192e337ec9a345ad2e5ac21fc190a676f56a2e88221c8149994fc370cf5cdad6c41d3ed420b354fe4914643d0d63cfb46c87d319e795 + languageName: node + linkType: hard + +"inquirer@npm:^8.2.4": + version: 8.2.7 + resolution: "inquirer@npm:8.2.7" + dependencies: + "@inquirer/external-editor": ^1.0.0 + ansi-escapes: ^4.2.1 + chalk: ^4.1.1 + cli-cursor: ^3.1.0 + cli-width: ^3.0.0 + figures: ^3.0.0 + lodash: ^4.17.21 + mute-stream: 0.0.8 + ora: ^5.4.1 + run-async: ^2.4.0 + rxjs: ^7.5.5 + string-width: ^4.1.0 + strip-ansi: ^6.0.0 + through: ^2.3.6 + wrap-ansi: ^6.0.1 + checksum: b7e39a04da31207826f675e2ff491bd35bb28efbe336e8fb49641d8353d4a312943514452fb0a23702e64f70c7e44188586880c902d67541aae579cd6564c3fb + languageName: node + linkType: hard + +"invariant@npm:^2.2.4": + version: 2.2.4 + resolution: "invariant@npm:2.2.4" + dependencies: + loose-envify: ^1.0.0 + checksum: cc3182d793aad82a8d1f0af697b462939cb46066ec48bbf1707c150ad5fad6406137e91a262022c269702e01621f35ef60269f6c0d7fd178487959809acdfb14 + languageName: node + linkType: hard + +"ip-address@npm:^10.0.1": + version: 10.1.0 + resolution: "ip-address@npm:10.1.0" + checksum: 76b1abcdf52a32e2e05ca1f202f3a8ab8547e5651a9233781b330271bd7f1a741067748d71c4cbb9d9906d9f1fa69e7ddc8b4a11130db4534fdab0e908c84e0d + languageName: node + linkType: hard + +"is-arrayish@npm:^0.2.1": + version: 0.2.1 + resolution: "is-arrayish@npm:0.2.1" + checksum: eef4417e3c10e60e2c810b6084942b3ead455af16c4509959a27e490e7aee87cfb3f38e01bbde92220b528a0ee1a18d52b787e1458ee86174d8c7f0e58cd488f + languageName: node + linkType: hard + +"is-ci@npm:3.0.1": + version: 3.0.1 + resolution: "is-ci@npm:3.0.1" + dependencies: + ci-info: ^3.2.0 + bin: + is-ci: bin.js + checksum: 192c66dc7826d58f803ecae624860dccf1899fc1f3ac5505284c0a5cf5f889046ffeb958fa651e5725d5705c5bcb14f055b79150ea5fcad7456a9569de60260e + languageName: node + linkType: hard + +"is-core-module@npm:^2.16.1, is-core-module@npm:^2.5.0": + version: 2.16.1 + resolution: "is-core-module@npm:2.16.1" + dependencies: + hasown: ^2.0.2 + checksum: 6ec5b3c42d9cbf1ac23f164b16b8a140c3cec338bf8f884c076ca89950c7cc04c33e78f02b8cae7ff4751f3247e3174b2330f1fe4de194c7210deb8b1ea316a7 + languageName: node + linkType: hard + +"is-docker@npm:^2.0.0, is-docker@npm:^2.1.1": + version: 2.2.1 + resolution: "is-docker@npm:2.2.1" + bin: + is-docker: cli.js + checksum: 3fef7ddbf0be25958e8991ad941901bf5922ab2753c46980b60b05c1bf9c9c2402d35e6dc32e4380b980ef5e1970a5d9d5e5aa2e02d77727c3b6b5e918474c56 + languageName: node + linkType: hard + +"is-extglob@npm:^2.1.1": + version: 2.1.1 + resolution: "is-extglob@npm:2.1.1" + checksum: df033653d06d0eb567461e58a7a8c9f940bd8c22274b94bf7671ab36df5719791aae15eef6d83bbb5e23283967f2f984b8914559d4449efda578c775c4be6f85 + languageName: node + linkType: hard + +"is-fullwidth-code-point@npm:^3.0.0": + version: 3.0.0 + resolution: "is-fullwidth-code-point@npm:3.0.0" + checksum: 44a30c29457c7fb8f00297bce733f0a64cd22eca270f83e58c105e0d015e45c019491a4ab2faef91ab51d4738c670daff901c799f6a700e27f7314029e99e348 + languageName: node + linkType: hard + +"is-glob@npm:^4.0.0, is-glob@npm:^4.0.1, is-glob@npm:^4.0.3": + version: 4.0.3 + resolution: "is-glob@npm:4.0.3" + dependencies: + is-extglob: ^2.1.1 + checksum: d381c1319fcb69d341cc6e6c7cd588e17cd94722d9a32dbd60660b993c4fb7d0f19438674e68dfec686d09b7c73139c9166b47597f846af387450224a8101ab4 + languageName: node + linkType: hard + +"is-interactive@npm:^1.0.0": + version: 1.0.0 + resolution: "is-interactive@npm:1.0.0" + checksum: 824808776e2d468b2916cdd6c16acacebce060d844c35ca6d82267da692e92c3a16fdba624c50b54a63f38bdc4016055b6f443ce57d7147240de4f8cdabaf6f9 + languageName: node + linkType: hard + +"is-lambda@npm:^1.0.1": + version: 1.0.1 + resolution: "is-lambda@npm:1.0.1" + checksum: 93a32f01940220532e5948538699ad610d5924ac86093fcee83022252b363eb0cc99ba53ab084a04e4fb62bf7b5731f55496257a4c38adf87af9c4d352c71c35 + languageName: node + linkType: hard + +"is-number@npm:^7.0.0": + version: 7.0.0 + resolution: "is-number@npm:7.0.0" + checksum: 456ac6f8e0f3111ed34668a624e45315201dff921e5ac181f8ec24923b99e9f32ca1a194912dc79d539c97d33dba17dc635202ff0b2cf98326f608323276d27a + languageName: node + linkType: hard + +"is-obj@npm:^2.0.0": + version: 2.0.0 + resolution: "is-obj@npm:2.0.0" + checksum: c9916ac8f4621962a42f5e80e7ffdb1d79a3fab7456ceaeea394cd9e0858d04f985a9ace45be44433bf605673c8be8810540fe4cc7f4266fc7526ced95af5a08 + languageName: node + linkType: hard + +"is-path-cwd@npm:^3.0.0": + version: 3.0.0 + resolution: "is-path-cwd@npm:3.0.0" + checksum: bc34d13b6a03dfca4a3ab6a8a5ba78ae4b24f4f1db4b2b031d2760c60d0913bd16a4b980dcb4e590adfc906649d5f5132684079a3972bd219da49deebb9adea8 + languageName: node + linkType: hard + +"is-path-inside@npm:^3.0.3": + version: 3.0.3 + resolution: "is-path-inside@npm:3.0.3" + checksum: abd50f06186a052b349c15e55b182326f1936c89a78bf6c8f2b707412517c097ce04bc49a0ca221787bc44e1049f51f09a2ffb63d22899051988d3a618ba13e9 + languageName: node + linkType: hard + +"is-path-inside@npm:^4.0.0": + version: 4.0.0 + resolution: "is-path-inside@npm:4.0.0" + checksum: 8810fa11c58e6360b82c3e0d6cd7d9c7d0392d3ac9eb10f980b81f9839f40ac6d1d6d6f05d069db0d227759801228f0b072e1b6c343e4469b065ab5fe0b68fe5 + languageName: node + linkType: hard + +"is-plain-obj@npm:^1.0.0, is-plain-obj@npm:^1.1.0": + version: 1.1.0 + resolution: "is-plain-obj@npm:1.1.0" + checksum: 0ee04807797aad50859652a7467481816cbb57e5cc97d813a7dcd8915da8195dc68c436010bf39d195226cde6a2d352f4b815f16f26b7bf486a5754290629931 + languageName: node + linkType: hard + +"is-plain-object@npm:^2.0.4": + version: 2.0.4 + resolution: "is-plain-object@npm:2.0.4" + dependencies: + isobject: ^3.0.1 + checksum: 2a401140cfd86cabe25214956ae2cfee6fbd8186809555cd0e84574f88de7b17abacb2e477a6a658fa54c6083ecbda1e6ae404c7720244cd198903848fca70ca + languageName: node + linkType: hard + +"is-ssh@npm:^1.4.0": + version: 1.4.1 + resolution: "is-ssh@npm:1.4.1" + dependencies: + protocols: ^2.0.1 + checksum: 005b461ac444398eb8b7cd2f489288e49dd18c8b6cbf1eb20767f9b79f330ab6e3308b2dac8ec6ca2a950d2a368912e0e992e2474bc1b5204693abb6226c1431 + languageName: node + linkType: hard + +"is-stream@npm:2.0.0": + version: 2.0.0 + resolution: "is-stream@npm:2.0.0" + checksum: 4dc47738e26bc4f1b3be9070b6b9e39631144f204fc6f87db56961220add87c10a999ba26cf81699f9ef9610426f69cb08a4713feff8deb7d8cadac907826935 + languageName: node + linkType: hard + +"is-stream@npm:^2.0.0": + version: 2.0.1 + resolution: "is-stream@npm:2.0.1" + checksum: b8e05ccdf96ac330ea83c12450304d4a591f9958c11fd17bed240af8d5ffe08aedafa4c0f4cfccd4d28dc9d4d129daca1023633d5c11601a6cbc77521f6fae66 + languageName: node + linkType: hard + +"is-text-path@npm:^1.0.1": + version: 1.0.1 + resolution: "is-text-path@npm:1.0.1" + dependencies: + text-extensions: ^1.0.0 + checksum: fb5d78752c22b3f73a7c9540768f765ffcfa38c9e421e2b9af869565307fa1ae5e3d3a2ba016a43549742856846566d327da406e94a5846ec838a288b1704fd2 + languageName: node + linkType: hard + +"is-unicode-supported@npm:^0.1.0": + version: 0.1.0 + resolution: "is-unicode-supported@npm:0.1.0" + checksum: a2aab86ee7712f5c2f999180daaba5f361bdad1efadc9610ff5b8ab5495b86e4f627839d085c6530363c6d6d4ecbde340fb8e54bdb83da4ba8e0865ed5513c52 + languageName: node + linkType: hard + +"is-wsl@npm:^2.1.1, is-wsl@npm:^2.2.0": + version: 2.2.0 + resolution: "is-wsl@npm:2.2.0" + dependencies: + is-docker: ^2.0.0 + checksum: 20849846ae414997d290b75e16868e5261e86ff5047f104027026fd61d8b5a9b0b3ade16239f35e1a067b3c7cc02f70183cb661010ed16f4b6c7c93dad1b19d8 + languageName: node + linkType: hard + +"isarray@npm:~1.0.0": + version: 1.0.0 + resolution: "isarray@npm:1.0.0" + checksum: f032df8e02dce8ec565cf2eb605ea939bdccea528dbcf565cdf92bfa2da9110461159d86a537388ef1acef8815a330642d7885b29010e8f7eac967c9993b65ab + languageName: node + linkType: hard + +"isexe@npm:^2.0.0": + version: 2.0.0 + resolution: "isexe@npm:2.0.0" + checksum: 26bf6c5480dda5161c820c5b5c751ae1e766c587b1f951ea3fcfc973bafb7831ae5b54a31a69bd670220e42e99ec154475025a468eae58ea262f813fdc8d1c62 + languageName: node + linkType: hard + +"isexe@npm:^3.1.1": + version: 3.1.1 + resolution: "isexe@npm:3.1.1" + checksum: 7fe1931ee4e88eb5aa524cd3ceb8c882537bc3a81b02e438b240e47012eef49c86904d0f0e593ea7c3a9996d18d0f1f3be8d3eaa92333977b0c3a9d353d5563e + languageName: node + linkType: hard + +"isobject@npm:^3.0.1": + version: 3.0.1 + resolution: "isobject@npm:3.0.1" + checksum: db85c4c970ce30693676487cca0e61da2ca34e8d4967c2e1309143ff910c207133a969f9e4ddb2dc6aba670aabce4e0e307146c310350b298e74a31f7d464703 + languageName: node + linkType: hard + +"istanbul-lib-coverage@npm:^3.2.0": + version: 3.2.2 + resolution: "istanbul-lib-coverage@npm:3.2.2" + checksum: 2367407a8d13982d8f7a859a35e7f8dd5d8f75aae4bb5484ede3a9ea1b426dc245aff28b976a2af48ee759fdd9be374ce2bd2669b644f31e76c5f46a2e29a831 + languageName: node + linkType: hard + +"istanbul-lib-instrument@npm:^5.0.4": + version: 5.2.1 + resolution: "istanbul-lib-instrument@npm:5.2.1" + dependencies: + "@babel/core": ^7.12.3 + "@babel/parser": ^7.14.7 + "@istanbuljs/schema": ^0.1.2 + istanbul-lib-coverage: ^3.2.0 + semver: ^6.3.0 + checksum: bf16f1803ba5e51b28bbd49ed955a736488381e09375d830e42ddeb403855b2006f850711d95ad726f2ba3f1ae8e7366de7e51d2b9ac67dc4d80191ef7ddf272 + languageName: node + linkType: hard + +"jackspeak@npm:^3.1.2": + version: 3.4.3 + resolution: "jackspeak@npm:3.4.3" + dependencies: + "@isaacs/cliui": ^8.0.2 + "@pkgjs/parseargs": ^0.11.0 + dependenciesMeta: + "@pkgjs/parseargs": + optional: true + checksum: be31027fc72e7cc726206b9f560395604b82e0fddb46c4cbf9f97d049bcef607491a5afc0699612eaa4213ca5be8fd3e1e7cd187b3040988b65c9489838a7c00 + languageName: node + linkType: hard + +"jake@npm:^10.8.5": + version: 10.9.4 + resolution: "jake@npm:10.9.4" + dependencies: + async: ^3.2.6 + filelist: ^1.0.4 + picocolors: ^1.1.1 + bin: + jake: bin/cli.js + checksum: 1ca6f6a6fe1f2385ed32df82fcb71f9c7378f7fb591ed0b183e9d79a1801221cfe96f3dd9174db2d1a9705a13ae659f2af7004ad23645c910121fc7086a137ef + languageName: node + linkType: hard + +"jest-diff@npm:>=29.4.3 < 30, jest-diff@npm:^29.4.1": + version: 29.7.0 + resolution: "jest-diff@npm:29.7.0" + dependencies: + chalk: ^4.0.0 + diff-sequences: ^29.6.3 + jest-get-type: ^29.6.3 + pretty-format: ^29.7.0 + checksum: 08e24a9dd43bfba1ef07a6374e5af138f53137b79ec3d5cc71a2303515335898888fa5409959172e1e05de966c9e714368d15e8994b0af7441f0721ee8e1bb77 + languageName: node + linkType: hard + +"jest-environment-node@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-environment-node@npm:29.7.0" + dependencies: + "@jest/environment": ^29.7.0 + "@jest/fake-timers": ^29.7.0 + "@jest/types": ^29.6.3 + "@types/node": "*" + jest-mock: ^29.7.0 + jest-util: ^29.7.0 + checksum: 501a9966292cbe0ca3f40057a37587cb6def25e1e0c5e39ac6c650fe78d3c70a2428304341d084ac0cced5041483acef41c477abac47e9a290d5545fd2f15646 + languageName: node + linkType: hard + +"jest-get-type@npm:^29.6.3": + version: 29.6.3 + resolution: "jest-get-type@npm:29.6.3" + checksum: 88ac9102d4679d768accae29f1e75f592b760b44277df288ad76ce5bf038c3f5ce3719dea8aa0f035dac30e9eb034b848ce716b9183ad7cc222d029f03e92205 + languageName: node + linkType: hard + +"jest-haste-map@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-haste-map@npm:29.7.0" + dependencies: + "@jest/types": ^29.6.3 + "@types/graceful-fs": ^4.1.3 + "@types/node": "*" + anymatch: ^3.0.3 + fb-watchman: ^2.0.0 + fsevents: ^2.3.2 + graceful-fs: ^4.2.9 + jest-regex-util: ^29.6.3 + jest-util: ^29.7.0 + jest-worker: ^29.7.0 + micromatch: ^4.0.4 + walker: ^1.0.8 + dependenciesMeta: + fsevents: + optional: true + checksum: c2c8f2d3e792a963940fbdfa563ce14ef9e14d4d86da645b96d3cd346b8d35c5ce0b992ee08593939b5f718cf0a1f5a90011a056548a1dbf58397d4356786f01 + languageName: node + linkType: hard + +"jest-message-util@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-message-util@npm:29.7.0" + dependencies: + "@babel/code-frame": ^7.12.13 + "@jest/types": ^29.6.3 + "@types/stack-utils": ^2.0.0 + chalk: ^4.0.0 + graceful-fs: ^4.2.9 + micromatch: ^4.0.4 + pretty-format: ^29.7.0 + slash: ^3.0.0 + stack-utils: ^2.0.3 + checksum: a9d025b1c6726a2ff17d54cc694de088b0489456c69106be6b615db7a51b7beb66788bea7a59991a019d924fbf20f67d085a445aedb9a4d6760363f4d7d09930 + languageName: node + linkType: hard + +"jest-mock@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-mock@npm:29.7.0" + dependencies: + "@jest/types": ^29.6.3 + "@types/node": "*" + jest-util: ^29.7.0 + checksum: 81ba9b68689a60be1482212878973700347cb72833c5e5af09895882b9eb5c4e02843a1bbdf23f94c52d42708bab53a30c45a3482952c9eec173d1eaac5b86c5 + languageName: node + linkType: hard + +"jest-regex-util@npm:^29.6.3": + version: 29.6.3 + resolution: "jest-regex-util@npm:29.6.3" + checksum: 0518beeb9bf1228261695e54f0feaad3606df26a19764bc19541e0fc6e2a3737191904607fb72f3f2ce85d9c16b28df79b7b1ec9443aa08c3ef0e9efda6f8f2a + languageName: node + linkType: hard + +"jest-util@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-util@npm:29.7.0" + dependencies: + "@jest/types": ^29.6.3 + "@types/node": "*" + chalk: ^4.0.0 + ci-info: ^3.2.0 + graceful-fs: ^4.2.9 + picomatch: ^2.2.3 + checksum: 042ab4980f4ccd4d50226e01e5c7376a8556b472442ca6091a8f102488c0f22e6e8b89ea874111d2328a2080083bf3225c86f3788c52af0bd0345a00eb57a3ca + languageName: node + linkType: hard + +"jest-validate@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-validate@npm:29.7.0" + dependencies: + "@jest/types": ^29.6.3 + camelcase: ^6.2.0 + chalk: ^4.0.0 + jest-get-type: ^29.6.3 + leven: ^3.1.0 + pretty-format: ^29.7.0 + checksum: 191fcdc980f8a0de4dbdd879fa276435d00eb157a48683af7b3b1b98b0f7d9de7ffe12689b617779097ff1ed77601b9f7126b0871bba4f776e222c40f62e9dae + languageName: node + linkType: hard + +"jest-worker@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-worker@npm:29.7.0" + dependencies: + "@types/node": "*" + jest-util: ^29.7.0 + merge-stream: ^2.0.0 + supports-color: ^8.0.0 + checksum: 30fff60af49675273644d408b650fc2eb4b5dcafc5a0a455f238322a8f9d8a98d847baca9d51ff197b6747f54c7901daa2287799230b856a0f48287d131f8c13 + languageName: node + linkType: hard + +"js-tokens@npm:^3.0.0 || ^4.0.0, js-tokens@npm:^4.0.0": + version: 4.0.0 + resolution: "js-tokens@npm:4.0.0" + checksum: 8a95213a5a77deb6cbe94d86340e8d9ace2b93bc367790b260101d2f36a2eaf4e4e22d9fa9cf459b38af3a32fb4190e638024cf82ec95ef708680e405ea7cc78 + languageName: node + linkType: hard + +"js-yaml@npm:4.1.0": + version: 4.1.0 + resolution: "js-yaml@npm:4.1.0" + dependencies: + argparse: ^2.0.1 + bin: + js-yaml: bin/js-yaml.js + checksum: c7830dfd456c3ef2c6e355cc5a92e6700ceafa1d14bba54497b34a99f0376cecbb3e9ac14d3e5849b426d5a5140709a66237a8c991c675431271c4ce5504151a + languageName: node + linkType: hard + +"js-yaml@npm:^3.10.0, js-yaml@npm:^3.13.1": + version: 3.14.2 + resolution: "js-yaml@npm:3.14.2" + dependencies: + argparse: ^1.0.7 + esprima: ^4.0.0 + bin: + js-yaml: bin/js-yaml.js + checksum: 626fc207734a3452d6ba84e1c8c226240e6d431426ed94d0ab043c50926d97c509629c08b1d636f5d27815833b7cfd225865631da9fb33cb957374490bf3e90b + languageName: node + linkType: hard + +"js-yaml@npm:^4.1.0": + version: 4.1.1 + resolution: "js-yaml@npm:4.1.1" + dependencies: + argparse: ^2.0.1 + bin: + js-yaml: bin/js-yaml.js + checksum: ea2339c6930fe048ec31b007b3c90be2714ab3e7defcc2c27ebf30c74fd940358f29070b4345af0019ef151875bf3bc3f8644bea1bab0372652b5044813ac02d + languageName: node + linkType: hard + +"jsc-safe-url@npm:^0.2.2": + version: 0.2.4 + resolution: "jsc-safe-url@npm:0.2.4" + checksum: 53b5741ba2c0a54da1722929dc80becb2c6fcc9525124fb6c2aec1a00f48e79afffd26816c278111e7b938e37ace029e33cbb8cdaa4ac1f528a87e58022284af + languageName: node + linkType: hard + +"jsesc@npm:^3.0.2": + version: 3.1.0 + resolution: "jsesc@npm:3.1.0" + bin: + jsesc: bin/jsesc + checksum: 19c94095ea026725540c0d29da33ab03144f6bcf2d4159e4833d534976e99e0c09c38cefa9a575279a51fc36b31166f8d6d05c9fe2645d5f15851d690b41f17f + languageName: node + linkType: hard + +"json-buffer@npm:3.0.1": + version: 3.0.1 + resolution: "json-buffer@npm:3.0.1" + checksum: 9026b03edc2847eefa2e37646c579300a1f3a4586cfb62bf857832b60c852042d0d6ae55d1afb8926163fa54c2b01d83ae24705f34990348bdac6273a29d4581 + languageName: node + linkType: hard + +"json-parse-better-errors@npm:^1.0.1": + version: 1.0.2 + resolution: "json-parse-better-errors@npm:1.0.2" + checksum: ff2b5ba2a70e88fd97a3cb28c1840144c5ce8fae9cbeeddba15afa333a5c407cf0e42300cd0a2885dbb055227fe68d405070faad941beeffbfde9cf3b2c78c5d + languageName: node + linkType: hard + +"json-parse-even-better-errors@npm:^2.3.0": + version: 2.3.1 + resolution: "json-parse-even-better-errors@npm:2.3.1" + checksum: 798ed4cf3354a2d9ccd78e86d2169515a0097a5c133337807cdf7f1fc32e1391d207ccfc276518cc1d7d8d4db93288b8a50ba4293d212ad1336e52a8ec0a941f + languageName: node + linkType: hard + +"json-parse-even-better-errors@npm:^3.0.0, json-parse-even-better-errors@npm:^3.0.2": + version: 3.0.2 + resolution: "json-parse-even-better-errors@npm:3.0.2" + checksum: 6f04ea6c9ccb783630a59297959247e921cc90b917b8351197ca7fd058fccc7079268fd9362be21ba876fc26aa5039369dd0a2280aae49aae425784794a94927 + languageName: node + linkType: hard + +"json-schema-traverse@npm:^0.4.1": + version: 0.4.1 + resolution: "json-schema-traverse@npm:0.4.1" + checksum: 7486074d3ba247769fda17d5181b345c9fb7d12e0da98b22d1d71a5db9698d8b4bd900a3ec1a4ffdd60846fc2556274a5c894d0c48795f14cb03aeae7b55260b + languageName: node + linkType: hard + +"json-schema-traverse@npm:^1.0.0": + version: 1.0.0 + resolution: "json-schema-traverse@npm:1.0.0" + checksum: 02f2f466cdb0362558b2f1fd5e15cce82ef55d60cd7f8fa828cf35ba74330f8d767fcae5c5c2adb7851fa811766c694b9405810879bc4e1ddd78a7c0e03658ad + languageName: node + linkType: hard + +"json-stable-stringify-without-jsonify@npm:^1.0.1": + version: 1.0.1 + resolution: "json-stable-stringify-without-jsonify@npm:1.0.1" + checksum: cff44156ddce9c67c44386ad5cddf91925fe06b1d217f2da9c4910d01f358c6e3989c4d5a02683c7a5667f9727ff05831f7aa8ae66c8ff691c556f0884d49215 + languageName: node + linkType: hard + +"json-stringify-nice@npm:^1.1.4": + version: 1.1.4 + resolution: "json-stringify-nice@npm:1.1.4" + checksum: 6ddf781148b46857ab04e97f47be05f14c4304b86eb5478369edbeacd070c21c697269964b982fc977e8989d4c59091103b1d9dc291aba40096d6cbb9a392b72 + languageName: node + linkType: hard + +"json-stringify-safe@npm:^5.0.1": + version: 5.0.1 + resolution: "json-stringify-safe@npm:5.0.1" + checksum: 48ec0adad5280b8a96bb93f4563aa1667fd7a36334f79149abd42446d0989f2ddc58274b479f4819f1f00617957e6344c886c55d05a4e15ebb4ab931e4a6a8ee + languageName: node + linkType: hard + +"json5@npm:^2.2.2, json5@npm:^2.2.3": + version: 2.2.3 + resolution: "json5@npm:2.2.3" + bin: + json5: lib/cli.js + checksum: 2a7436a93393830bce797d4626275152e37e877b265e94ca69c99e3d20c2b9dab021279146a39cdb700e71b2dd32a4cebd1514cd57cee102b1af906ce5040349 + languageName: node + linkType: hard + +"jsonc-parser@npm:3.2.0": + version: 3.2.0 + resolution: "jsonc-parser@npm:3.2.0" + checksum: 946dd9a5f326b745aa326d48a7257e3f4a4b62c5e98ec8e49fa2bdd8d96cef7e6febf1399f5c7016114fd1f68a1c62c6138826d5d90bc650448e3cf0951c53c7 + languageName: node + linkType: hard + +"jsonfile@npm:^6.0.1": + version: 6.2.0 + resolution: "jsonfile@npm:6.2.0" + dependencies: + graceful-fs: ^4.1.6 + universalify: ^2.0.0 + dependenciesMeta: + graceful-fs: + optional: true + checksum: c3028ec5c770bb41290c9bb9ca04bdd0a1b698ddbdf6517c9453d3f90fc9e000c9675959fb46891d317690a93c62de03ff1735d8dbe02be83e51168ce85815d3 + languageName: node + linkType: hard + +"jsonparse@npm:^1.2.0, jsonparse@npm:^1.3.1": + version: 1.3.1 + resolution: "jsonparse@npm:1.3.1" + checksum: 6514a7be4674ebf407afca0eda3ba284b69b07f9958a8d3113ef1005f7ec610860c312be067e450c569aab8b89635e332cee3696789c750692bb60daba627f4d + languageName: node + linkType: hard + +"just-diff-apply@npm:^5.2.0": + version: 5.5.0 + resolution: "just-diff-apply@npm:5.5.0" + checksum: ed6bbd59781542ccb786bd843038e4591e8390aa788075beb69d358051f68fbeb122bda050b7f42515d51fb64b907d5c7bea694a0543b87b24ce406cfb5f5bfa + languageName: node + linkType: hard + +"just-diff@npm:^6.0.0": + version: 6.0.2 + resolution: "just-diff@npm:6.0.2" + checksum: 1a0c7524f640cb88ab013862733e710f840927834208fd3b85cbc5da2ced97acc75e7dcfe493268ac6a6514c51dd8624d2fd9d057050efba3c02b81a6dcb7ff9 + languageName: node + linkType: hard + +"keyv@npm:^4.5.3": + version: 4.5.4 + resolution: "keyv@npm:4.5.4" + dependencies: + json-buffer: 3.0.1 + checksum: 74a24395b1c34bd44ad5cb2b49140d087553e170625240b86755a6604cd65aa16efdbdeae5cdb17ba1284a0fbb25ad06263755dbc71b8d8b06f74232ce3cdd72 + languageName: node + linkType: hard + +"kind-of@npm:^6.0.2, kind-of@npm:^6.0.3": + version: 6.0.3 + resolution: "kind-of@npm:6.0.3" + checksum: 3ab01e7b1d440b22fe4c31f23d8d38b4d9b91d9f291df683476576493d5dfd2e03848a8b05813dd0c3f0e835bc63f433007ddeceb71f05cb25c45ae1b19c6d3b + languageName: node + linkType: hard + +"lerna@npm:^8.0.0": + version: 8.2.4 + resolution: "lerna@npm:8.2.4" + dependencies: + "@lerna/create": 8.2.4 + "@npmcli/arborist": 7.5.4 + "@npmcli/package-json": 5.2.0 + "@npmcli/run-script": 8.1.0 + "@nx/devkit": ">=17.1.2 < 21" + "@octokit/plugin-enterprise-rest": 6.0.1 + "@octokit/rest": 20.1.2 + aproba: 2.0.0 + byte-size: 8.1.1 + chalk: 4.1.0 + clone-deep: 4.0.1 + cmd-shim: 6.0.3 + color-support: 1.1.3 + columnify: 1.6.0 + console-control-strings: ^1.1.0 + conventional-changelog-angular: 7.0.0 + conventional-changelog-core: 5.0.1 + conventional-recommended-bump: 7.0.1 + cosmiconfig: 9.0.0 + dedent: 1.5.3 + envinfo: 7.13.0 + execa: 5.0.0 + fs-extra: ^11.2.0 + get-port: 5.1.1 + get-stream: 6.0.0 + git-url-parse: 14.0.0 + glob-parent: 6.0.2 + graceful-fs: 4.2.11 + has-unicode: 2.0.1 + import-local: 3.1.0 + ini: ^1.3.8 + init-package-json: 6.0.3 + inquirer: ^8.2.4 + is-ci: 3.0.1 + is-stream: 2.0.0 + jest-diff: ">=29.4.3 < 30" + js-yaml: 4.1.0 + libnpmaccess: 8.0.6 + libnpmpublish: 9.0.9 + load-json-file: 6.2.0 + make-dir: 4.0.0 + minimatch: 3.0.5 + multimatch: 5.0.0 + node-fetch: 2.6.7 + npm-package-arg: 11.0.2 + npm-packlist: 8.0.2 + npm-registry-fetch: ^17.1.0 + nx: ">=17.1.2 < 21" + p-map: 4.0.0 + p-map-series: 2.1.0 + p-pipe: 3.1.0 + p-queue: 6.6.2 + p-reduce: 2.1.0 + p-waterfall: 2.1.1 + pacote: ^18.0.6 + pify: 5.0.0 + read-cmd-shim: 4.0.0 + resolve-from: 5.0.0 + rimraf: ^4.4.1 + semver: ^7.3.8 + set-blocking: ^2.0.0 + signal-exit: 3.0.7 + slash: 3.0.0 + ssri: ^10.0.6 + string-width: ^4.2.3 + tar: 6.2.1 + temp-dir: 1.0.0 + through: 2.3.8 + tinyglobby: 0.2.12 + typescript: ">=3 < 6" + upath: 2.0.1 + uuid: ^10.0.0 + validate-npm-package-license: 3.0.4 + validate-npm-package-name: 5.0.1 + wide-align: 1.1.5 + write-file-atomic: 5.0.1 + write-pkg: 4.0.0 + yargs: 17.7.2 + yargs-parser: 21.1.1 + bin: + lerna: dist/cli.js + checksum: ab46ecdf65a35d5171caa1d34023219c444aba328287e8ba49d22310f43918c98feb50b074c3191fa2da4102dd71f1292a4226b68083057c55aa811486c58cd8 + languageName: node + linkType: hard + +"leven@npm:^3.1.0": + version: 3.1.0 + resolution: "leven@npm:3.1.0" + checksum: 638401d534585261b6003db9d99afd244dfe82d75ddb6db5c0df412842d5ab30b2ef18de471aaec70fe69a46f17b4ae3c7f01d8a4e6580ef7adb9f4273ad1e55 + languageName: node + linkType: hard + +"levn@npm:^0.4.1": + version: 0.4.1 + resolution: "levn@npm:0.4.1" + dependencies: + prelude-ls: ^1.2.1 + type-check: ~0.4.0 + checksum: 12c5021c859bd0f5248561bf139121f0358285ec545ebf48bb3d346820d5c61a4309535c7f387ed7d84361cf821e124ce346c6b7cef8ee09a67c1473b46d0fc4 + languageName: node + linkType: hard + +"libnpmaccess@npm:8.0.6": + version: 8.0.6 + resolution: "libnpmaccess@npm:8.0.6" + dependencies: + npm-package-arg: ^11.0.2 + npm-registry-fetch: ^17.0.1 + checksum: 62fa6a476321268ebd379f35782d9ead8993964bd9dfc8afbd201921d9037b7bc9d956f8b2717f1247e44ab33cb7de45b556ded66144f4b3038a828299cb260d + languageName: node + linkType: hard + +"libnpmpublish@npm:9.0.9": + version: 9.0.9 + resolution: "libnpmpublish@npm:9.0.9" + dependencies: + ci-info: ^4.0.0 + normalize-package-data: ^6.0.1 + npm-package-arg: ^11.0.2 + npm-registry-fetch: ^17.0.1 + proc-log: ^4.2.0 + semver: ^7.3.7 + sigstore: ^2.2.0 + ssri: ^10.0.6 + checksum: bce18edcc02df5e08981f64093ed1772953b8efb27ed98018522f8c11cb91c882d420d790d3e3091dccd4f83a229f87b98562cbbed7ac4dc28af7eec9e5da9c1 + languageName: node + linkType: hard + +"lighthouse-logger@npm:^1.0.0": + version: 1.4.2 + resolution: "lighthouse-logger@npm:1.4.2" + dependencies: + debug: ^2.6.9 + marky: ^1.2.2 + checksum: ba6b73d93424318fab58b4e07c9ed246e3e969a3313f26b69515ed4c06457dd9a0b11bc706948398fdaef26aa4ba5e65cb848c37ce59f470d3c6c450b9b79a33 + languageName: node + linkType: hard + +"lines-and-columns@npm:2.0.3": + version: 2.0.3 + resolution: "lines-and-columns@npm:2.0.3" + checksum: 5955363dfd7d3d7c476d002eb47944dbe0310d57959e2112dce004c0dc76cecfd479cf8c098fd479ff344acdf04ee0e82b455462a26492231ac152f6c48d17a1 + languageName: node + linkType: hard + +"lines-and-columns@npm:^1.1.6": + version: 1.2.4 + resolution: "lines-and-columns@npm:1.2.4" + checksum: 0c37f9f7fa212b38912b7145e1cd16a5f3cd34d782441c3e6ca653485d326f58b3caccda66efce1c5812bde4961bbde3374fae4b0d11bf1226152337f3894aa5 + languageName: node + linkType: hard + +"load-json-file@npm:6.2.0": + version: 6.2.0 + resolution: "load-json-file@npm:6.2.0" + dependencies: + graceful-fs: ^4.1.15 + parse-json: ^5.0.0 + strip-bom: ^4.0.0 + type-fest: ^0.6.0 + checksum: 4429e430ebb99375fc7cd936348e4f7ba729486080ced4272091c1e386a7f5f738ea3337d8ffd4b01c2f5bc3ddde92f2c780045b66838fe98bdb79f901884643 + languageName: node + linkType: hard + +"load-json-file@npm:^4.0.0": + version: 4.0.0 + resolution: "load-json-file@npm:4.0.0" + dependencies: + graceful-fs: ^4.1.2 + parse-json: ^4.0.0 + pify: ^3.0.0 + strip-bom: ^3.0.0 + checksum: 8f5d6d93ba64a9620445ee9bde4d98b1eac32cf6c8c2d20d44abfa41a6945e7969456ab5f1ca2fb06ee32e206c9769a20eec7002fe290de462e8c884b6b8b356 + languageName: node + linkType: hard + +"locate-path@npm:^2.0.0": + version: 2.0.0 + resolution: "locate-path@npm:2.0.0" + dependencies: + p-locate: ^2.0.0 + path-exists: ^3.0.0 + checksum: 02d581edbbbb0fa292e28d96b7de36b5b62c2fa8b5a7e82638ebb33afa74284acf022d3b1e9ae10e3ffb7658fbc49163fcd5e76e7d1baaa7801c3e05a81da755 + languageName: node + linkType: hard + +"locate-path@npm:^5.0.0": + version: 5.0.0 + resolution: "locate-path@npm:5.0.0" + dependencies: + p-locate: ^4.1.0 + checksum: 83e51725e67517287d73e1ded92b28602e3ae5580b301fe54bfb76c0c723e3f285b19252e375712316774cf52006cb236aed5704692c32db0d5d089b69696e30 + languageName: node + linkType: hard + +"locate-path@npm:^6.0.0": + version: 6.0.0 + resolution: "locate-path@npm:6.0.0" + dependencies: + p-locate: ^5.0.0 + checksum: 72eb661788a0368c099a184c59d2fee760b3831c9c1c33955e8a19ae4a21b4116e53fa736dc086cdeb9fce9f7cc508f2f92d2d3aae516f133e16a2bb59a39f5a + languageName: node + linkType: hard + +"lodash.camelcase@npm:^4.3.0": + version: 4.3.0 + resolution: "lodash.camelcase@npm:4.3.0" + checksum: cb9227612f71b83e42de93eccf1232feeb25e705bdb19ba26c04f91e885bfd3dd5c517c4a97137658190581d3493ea3973072ca010aab7e301046d90740393d1 + languageName: node + linkType: hard + +"lodash.isfunction@npm:^3.0.9": + version: 3.0.9 + resolution: "lodash.isfunction@npm:3.0.9" + checksum: 99e54c34b1e8a9ba75c034deb39cedbd2aca7af685815e67a2a8ec4f73ec9748cda6ebee5a07d7de4b938e90d421fd280e9c385cc190f903ac217ac8aff30314 + languageName: node + linkType: hard + +"lodash.ismatch@npm:^4.4.0": + version: 4.4.0 + resolution: "lodash.ismatch@npm:4.4.0" + checksum: a393917578842705c7fc1a30fb80613d1ac42d20b67eb26a2a6004d6d61ee90b419f9eb320508ddcd608e328d91eeaa2651411727eaa9a12534ed6ccb02fc705 + languageName: node + linkType: hard + +"lodash.isplainobject@npm:^4.0.6": + version: 4.0.6 + resolution: "lodash.isplainobject@npm:4.0.6" + checksum: 29c6351f281e0d9a1d58f1a4c8f4400924b4c79f18dfc4613624d7d54784df07efaff97c1ff2659f3e085ecf4fff493300adc4837553104cef2634110b0d5337 + languageName: node + linkType: hard + +"lodash.kebabcase@npm:^4.1.1": + version: 4.1.1 + resolution: "lodash.kebabcase@npm:4.1.1" + checksum: 5a6c59161914e1bae23438a298c7433e83d935e0f59853fa862e691164696bc07f6dfa4c313d499fbf41ba8d53314e9850416502376705a357d24ee6ca33af78 + languageName: node + linkType: hard + +"lodash.merge@npm:^4.6.2": + version: 4.6.2 + resolution: "lodash.merge@npm:4.6.2" + checksum: ad580b4bdbb7ca1f7abf7e1bce63a9a0b98e370cf40194b03380a46b4ed799c9573029599caebc1b14e3f24b111aef72b96674a56cfa105e0f5ac70546cdc005 + languageName: node + linkType: hard + +"lodash.mergewith@npm:^4.6.2": + version: 4.6.2 + resolution: "lodash.mergewith@npm:4.6.2" + checksum: a6db2a9339752411f21b956908c404ec1e088e783a65c8b29e30ae5b3b6384f82517662d6f425cc97c2070b546cc2c7daaa8d33f78db7b6e9be06cd834abdeb8 + languageName: node + linkType: hard + +"lodash.snakecase@npm:^4.1.1": + version: 4.1.1 + resolution: "lodash.snakecase@npm:4.1.1" + checksum: 1685ed3e83dda6eae5a4dcaee161a51cd210aabb3e1c09c57150e7dd8feda19e4ca0d27d0631eabe8d0f4eaa51e376da64e8c018ae5415417c5890d42feb72a8 + languageName: node + linkType: hard + +"lodash.startcase@npm:^4.4.0": + version: 4.4.0 + resolution: "lodash.startcase@npm:4.4.0" + checksum: c03a4a784aca653845fe09d0ef67c902b6e49288dc45f542a4ab345a9c406a6dc194c774423fa313ee7b06283950301c1221dd2a1d8ecb2dac8dfbb9ed5606b5 + languageName: node + linkType: hard + +"lodash.throttle@npm:^4.1.1": + version: 4.1.1 + resolution: "lodash.throttle@npm:4.1.1" + checksum: 129c0a28cee48b348aef146f638ef8a8b197944d4e9ec26c1890c19d9bf5a5690fe11b655c77a4551268819b32d27f4206343e30c78961f60b561b8608c8c805 + languageName: node + linkType: hard + +"lodash.uniq@npm:^4.5.0": + version: 4.5.0 + resolution: "lodash.uniq@npm:4.5.0" + checksum: a4779b57a8d0f3c441af13d9afe7ecff22dd1b8ce1129849f71d9bbc8e8ee4e46dfb4b7c28f7ad3d67481edd6e51126e4e2a6ee276e25906d10f7140187c392d + languageName: node + linkType: hard + +"lodash.upperfirst@npm:^4.3.1": + version: 4.3.1 + resolution: "lodash.upperfirst@npm:4.3.1" + checksum: cadec6955900afe1928cc60cdc4923a79c2ef991e42665419cc81630ed9b4f952a1093b222e0141ab31cbc4dba549f97ec28ff67929d71e01861c97188a5fa83 + languageName: node + linkType: hard + +"lodash@npm:^4.17.15, lodash@npm:^4.17.21": + version: 4.17.21 + resolution: "lodash@npm:4.17.21" + checksum: eb835a2e51d381e561e508ce932ea50a8e5a68f4ebdd771ea240d3048244a8d13658acbd502cd4829768c56f2e16bdd4340b9ea141297d472517b83868e677f7 + languageName: node + linkType: hard + +"log-symbols@npm:^4.0.0, log-symbols@npm:^4.1.0": + version: 4.1.0 + resolution: "log-symbols@npm:4.1.0" + dependencies: + chalk: ^4.1.0 + is-unicode-supported: ^0.1.0 + checksum: fce1497b3135a0198803f9f07464165e9eb83ed02ceb2273930a6f8a508951178d8cf4f0378e9d28300a2ed2bc49050995d2bd5f53ab716bb15ac84d58c6ef74 + languageName: node + linkType: hard + +"loose-envify@npm:^1.0.0": + version: 1.4.0 + resolution: "loose-envify@npm:1.4.0" + dependencies: + js-tokens: ^3.0.0 || ^4.0.0 + bin: + loose-envify: cli.js + checksum: 6517e24e0cad87ec9888f500c5b5947032cdfe6ef65e1c1936a0c48a524b81e65542c9c3edc91c97d5bddc806ee2a985dbc79be89215d613b1de5db6d1cfe6f4 + languageName: node + linkType: hard + +"lru-cache@npm:^10.0.1, lru-cache@npm:^10.2.0, lru-cache@npm:^10.2.2": + version: 10.4.3 + resolution: "lru-cache@npm:10.4.3" + checksum: 6476138d2125387a6d20f100608c2583d415a4f64a0fecf30c9e2dda976614f09cad4baa0842447bd37dd459a7bd27f57d9d8f8ce558805abd487c583f3d774a + languageName: node + linkType: hard + +"lru-cache@npm:^11.0.0, lru-cache@npm:^11.1.0, lru-cache@npm:^11.2.1": + version: 11.2.4 + resolution: "lru-cache@npm:11.2.4" + checksum: cb8cf72b80a506593f51880bd5a765380d6d8eb82e99b2fbb2f22fe39e5f2f641d47a2509e74cc294617f32a4e90ae8f6214740fe00bc79a6178854f00419b24 + languageName: node + linkType: hard + +"lru-cache@npm:^5.1.1": + version: 5.1.1 + resolution: "lru-cache@npm:5.1.1" + dependencies: + yallist: ^3.0.2 + checksum: c154ae1cbb0c2206d1501a0e94df349653c92c8cbb25236d7e85190bcaf4567a03ac6eb43166fabfa36fd35623694da7233e88d9601fbf411a9a481d85dbd2cb + languageName: node + linkType: hard + +"lru-cache@npm:^6.0.0": + version: 6.0.0 + resolution: "lru-cache@npm:6.0.0" + dependencies: + yallist: ^4.0.0 + checksum: f97f499f898f23e4585742138a22f22526254fdba6d75d41a1c2526b3b6cc5747ef59c5612ba7375f42aca4f8461950e925ba08c991ead0651b4918b7c978297 + languageName: node + linkType: hard + +"make-dir@npm:4.0.0": + version: 4.0.0 + resolution: "make-dir@npm:4.0.0" + dependencies: + semver: ^7.5.3 + checksum: bf0731a2dd3aab4db6f3de1585cea0b746bb73eb5a02e3d8d72757e376e64e6ada190b1eddcde5b2f24a81b688a9897efd5018737d05e02e2a671dda9cff8a8a + languageName: node + linkType: hard + +"make-dir@npm:^2.1.0": + version: 2.1.0 + resolution: "make-dir@npm:2.1.0" + dependencies: + pify: ^4.0.1 + semver: ^5.6.0 + checksum: 043548886bfaf1820323c6a2997e6d2fa51ccc2586ac14e6f14634f7458b4db2daf15f8c310e2a0abd3e0cddc64df1890d8fc7263033602c47bb12cbfcf86aab + languageName: node + linkType: hard + +"make-error@npm:^1.1.1": + version: 1.3.6 + resolution: "make-error@npm:1.3.6" + checksum: b86e5e0e25f7f777b77fabd8e2cbf15737972869d852a22b7e73c17623928fccb826d8e46b9951501d3f20e51ad74ba8c59ed584f610526a48f8ccf88aaec402 + languageName: node + linkType: hard + +"make-fetch-happen@npm:^13.0.0, make-fetch-happen@npm:^13.0.1": + version: 13.0.1 + resolution: "make-fetch-happen@npm:13.0.1" + dependencies: + "@npmcli/agent": ^2.0.0 + cacache: ^18.0.0 + http-cache-semantics: ^4.1.1 + is-lambda: ^1.0.1 + minipass: ^7.0.2 + minipass-fetch: ^3.0.0 + minipass-flush: ^1.0.5 + minipass-pipeline: ^1.2.4 + negotiator: ^0.6.3 + proc-log: ^4.2.0 + promise-retry: ^2.0.1 + ssri: ^10.0.0 + checksum: 5c9fad695579b79488fa100da05777213dd9365222f85e4757630f8dd2a21a79ddd3206c78cfd6f9b37346819681782b67900ac847a57cf04190f52dda5343fd + languageName: node + linkType: hard + +"make-fetch-happen@npm:^15.0.0": + version: 15.0.3 + resolution: "make-fetch-happen@npm:15.0.3" + dependencies: + "@npmcli/agent": ^4.0.0 + cacache: ^20.0.1 + http-cache-semantics: ^4.1.1 + minipass: ^7.0.2 + minipass-fetch: ^5.0.0 + minipass-flush: ^1.0.5 + minipass-pipeline: ^1.2.4 + negotiator: ^1.0.0 + proc-log: ^6.0.0 + promise-retry: ^2.0.1 + ssri: ^13.0.0 + checksum: 4fb9dbb739b33565c85dacdcff7eb9388d8f36f326a59dc13375f01af809c42c48aa5d1f4840ee36623b2461a15476e1e79e4548ca1af30b42e1e324705ac8b3 + languageName: node + linkType: hard + +"makeerror@npm:1.0.12": + version: 1.0.12 + resolution: "makeerror@npm:1.0.12" + dependencies: + tmpl: 1.0.5 + checksum: b38a025a12c8146d6eeea5a7f2bf27d51d8ad6064da8ca9405fcf7bf9b54acd43e3b30ddd7abb9b1bfa4ddb266019133313482570ddb207de568f71ecfcf6060 + languageName: node + linkType: hard + +"map-obj@npm:^1.0.0": + version: 1.0.1 + resolution: "map-obj@npm:1.0.1" + checksum: 9949e7baec2a336e63b8d4dc71018c117c3ce6e39d2451ccbfd3b8350c547c4f6af331a4cbe1c83193d7c6b786082b6256bde843db90cb7da2a21e8fcc28afed + languageName: node + linkType: hard + +"map-obj@npm:^4.0.0, map-obj@npm:^4.1.0": + version: 4.3.0 + resolution: "map-obj@npm:4.3.0" + checksum: fbc554934d1a27a1910e842bc87b177b1a556609dd803747c85ece420692380827c6ae94a95cce4407c054fa0964be3bf8226f7f2cb2e9eeee432c7c1985684e + languageName: node + linkType: hard + +"marky@npm:^1.2.2": + version: 1.3.0 + resolution: "marky@npm:1.3.0" + checksum: c25fe1d45525e317f89d116e87a50d385cc7e7d0d418548e75334273cb97990db37228c365718b5572077c80f22a599c732ccbd3da9728cd806465d63c786eda + languageName: node + linkType: hard + +"math-intrinsics@npm:^1.1.0": + version: 1.1.0 + resolution: "math-intrinsics@npm:1.1.0" + checksum: 0e513b29d120f478c85a70f49da0b8b19bc638975eca466f2eeae0071f3ad00454c621bf66e16dd435896c208e719fc91ad79bbfba4e400fe0b372e7c1c9c9a2 + languageName: node + linkType: hard + +"memoize-one@npm:^5.0.0": + version: 5.2.1 + resolution: "memoize-one@npm:5.2.1" + checksum: a3cba7b824ebcf24cdfcd234aa7f86f3ad6394b8d9be4c96ff756dafb8b51c7f71320785fbc2304f1af48a0467cbbd2a409efc9333025700ed523f254cb52e3d + languageName: node + linkType: hard + +"meow@npm:^10.1.3": + version: 10.1.5 + resolution: "meow@npm:10.1.5" + dependencies: + "@types/minimist": ^1.2.2 + camelcase-keys: ^7.0.0 + decamelize: ^5.0.0 + decamelize-keys: ^1.1.0 + hard-rejection: ^2.1.0 + minimist-options: 4.1.0 + normalize-package-data: ^3.0.2 + read-pkg-up: ^8.0.0 + redent: ^4.0.0 + trim-newlines: ^4.0.2 + type-fest: ^1.2.2 + yargs-parser: ^20.2.9 + checksum: dd5f0caa4af18517813547dc66741dcbf52c4c23def5062578d39b11189fd9457aee5c1f2263a5cd6592a465023df8357e8ac876b685b64dbcf545e3f66c23a7 + languageName: node + linkType: hard + +"meow@npm:^8.0.0, meow@npm:^8.1.2": + version: 8.1.2 + resolution: "meow@npm:8.1.2" + dependencies: + "@types/minimist": ^1.2.0 + camelcase-keys: ^6.2.2 + decamelize-keys: ^1.1.0 + hard-rejection: ^2.1.0 + minimist-options: 4.1.0 + normalize-package-data: ^3.0.0 + read-pkg-up: ^7.0.1 + redent: ^3.0.0 + trim-newlines: ^3.0.0 + type-fest: ^0.18.0 + yargs-parser: ^20.2.3 + checksum: bc23bf1b4423ef6a821dff9734406bce4b91ea257e7f10a8b7f896f45b59649f07adc0926e2917eacd8cf1df9e4cd89c77623cf63dfd0f8bf54de07a32ee5a85 + languageName: node + linkType: hard + +"merge-stream@npm:^2.0.0": + version: 2.0.0 + resolution: "merge-stream@npm:2.0.0" + checksum: 6fa4dcc8d86629705cea944a4b88ef4cb0e07656ebf223fa287443256414283dd25d91c1cd84c77987f2aec5927af1a9db6085757cb43d90eb170ebf4b47f4f4 + languageName: node + linkType: hard + +"merge2@npm:^1.3.0, merge2@npm:^1.4.1": + version: 1.4.1 + resolution: "merge2@npm:1.4.1" + checksum: 7268db63ed5169466540b6fb947aec313200bcf6d40c5ab722c22e242f651994619bcd85601602972d3c85bd2cc45a358a4c61937e9f11a061919a1da569b0c2 + languageName: node + linkType: hard + +"metro-babel-transformer@npm:0.83.3": + version: 0.83.3 + resolution: "metro-babel-transformer@npm:0.83.3" + dependencies: + "@babel/core": ^7.25.2 + flow-enums-runtime: ^0.0.6 + hermes-parser: 0.32.0 + nullthrows: ^1.1.1 + checksum: dd178409d1718dae12dfffb6572ebc5bb78f1e0d7e93dce829c945957f8a686cb1b4c466c69585d7b982b3937fbea28d5c53a80691f2fc66717a0bcc800bc5b8 + languageName: node + linkType: hard + +"metro-cache-key@npm:0.83.3": + version: 0.83.3 + resolution: "metro-cache-key@npm:0.83.3" + dependencies: + flow-enums-runtime: ^0.0.6 + checksum: a6f9d2bf8b810f57d330d6f8f1ebf029e1224f426c5895f73d9bc1007482684048bfc7513a855626ee7f3ae72ca46e1b08cf983aefbfa84321bb7c0cef4ba4ae + languageName: node + linkType: hard + +"metro-cache@npm:0.83.3": + version: 0.83.3 + resolution: "metro-cache@npm:0.83.3" + dependencies: + exponential-backoff: ^3.1.1 + flow-enums-runtime: ^0.0.6 + https-proxy-agent: ^7.0.5 + metro-core: 0.83.3 + checksum: 95606275411d85de071fd95171a9548406cd1154320850a554bf00207804f7844ed252f9750a802d6612ade839c579b23bd87927ae173f43c368e8f5d900149d + languageName: node + linkType: hard + +"metro-config@npm:0.83.3, metro-config@npm:^0.83.3": + version: 0.83.3 + resolution: "metro-config@npm:0.83.3" + dependencies: + connect: ^3.6.5 + flow-enums-runtime: ^0.0.6 + jest-validate: ^29.7.0 + metro: 0.83.3 + metro-cache: 0.83.3 + metro-core: 0.83.3 + metro-runtime: 0.83.3 + yaml: ^2.6.1 + checksum: a14b77668a9712abbcebe5bf6a0081f0fd46caf8d37405174f261765abcd44d7a99910533fcc05edde3de10f9b22820cc9910c7dee2b01e761692a0a322f2608 + languageName: node + linkType: hard + +"metro-core@npm:0.83.3, metro-core@npm:^0.83.3": + version: 0.83.3 + resolution: "metro-core@npm:0.83.3" + dependencies: + flow-enums-runtime: ^0.0.6 + lodash.throttle: ^4.1.1 + metro-resolver: 0.83.3 + checksum: d06871313310cd718094ecbae805bcacea3f325340f6dff3c5044b62457c4690dd729cdb938349bdd3c41efa6f28032ae07696467ef006d5509fec9045c1966f + languageName: node + linkType: hard + +"metro-file-map@npm:0.83.3": + version: 0.83.3 + resolution: "metro-file-map@npm:0.83.3" + dependencies: + debug: ^4.4.0 + fb-watchman: ^2.0.0 + flow-enums-runtime: ^0.0.6 + graceful-fs: ^4.2.4 + invariant: ^2.2.4 + jest-worker: ^29.7.0 + micromatch: ^4.0.4 + nullthrows: ^1.1.1 + walker: ^1.0.7 + checksum: 0dea599206e93b6e8628be2aa98452d4dae16e805b810759ec8b50cebcd83f2d053f7e5865196d464f3793f86b3b5003830c6713f91bf62fa406a4af7c93a776 + languageName: node + linkType: hard + +"metro-minify-terser@npm:0.83.3": + version: 0.83.3 + resolution: "metro-minify-terser@npm:0.83.3" + dependencies: + flow-enums-runtime: ^0.0.6 + terser: ^5.15.0 + checksum: 1de88b70b7c903147807baa46497491a87600594fd0868b6538bbb9d7785242cabfbe8bccf36cc2285d0e17be72445b512d00c496952a159572545f3e6bcb199 + languageName: node + linkType: hard + +"metro-resolver@npm:0.83.3": + version: 0.83.3 + resolution: "metro-resolver@npm:0.83.3" + dependencies: + flow-enums-runtime: ^0.0.6 + checksum: de2ae5ced6239b004a97712f98934c6e830870d11614e2dba48250930214581f0746df8a4f0f1cb71060fe21c2cf919d3359106ad4f375c2500ba08e10922896 + languageName: node + linkType: hard + +"metro-runtime@npm:0.83.3, metro-runtime@npm:^0.83.3": + version: 0.83.3 + resolution: "metro-runtime@npm:0.83.3" + dependencies: + "@babel/runtime": ^7.25.0 + flow-enums-runtime: ^0.0.6 + checksum: dcbdc5502020d1e20cee1a3a8019323ab2f3ca2aa2d6ddb2b7a2b8547835a20b84fe4afc23c397f788584e108c70411db93df2f61322b44a4f0f119275052d03 + languageName: node + linkType: hard + +"metro-source-map@npm:0.83.3, metro-source-map@npm:^0.83.3": + version: 0.83.3 + resolution: "metro-source-map@npm:0.83.3" + dependencies: + "@babel/traverse": ^7.25.3 + "@babel/traverse--for-generate-function-map": "npm:@babel/traverse@^7.25.3" + "@babel/types": ^7.25.2 + flow-enums-runtime: ^0.0.6 + invariant: ^2.2.4 + metro-symbolicate: 0.83.3 + nullthrows: ^1.1.1 + ob1: 0.83.3 + source-map: ^0.5.6 + vlq: ^1.0.0 + checksum: 5bf3b7a1561bc1f0ad6ab3b7b550d4b4581da31964a7f218727a3201576912076c909a2e50fba4dd3c649d79312324dec683a37228f4559811c37b69ecca8831 + languageName: node + linkType: hard + +"metro-symbolicate@npm:0.83.3": + version: 0.83.3 + resolution: "metro-symbolicate@npm:0.83.3" + dependencies: + flow-enums-runtime: ^0.0.6 + invariant: ^2.2.4 + metro-source-map: 0.83.3 + nullthrows: ^1.1.1 + source-map: ^0.5.6 + vlq: ^1.0.0 + bin: + metro-symbolicate: src/index.js + checksum: 943cc2456d56ae2ed8369495c18966d91feff636b37909b5225ffb8ce2a50eba8fbedf116f3bea3059d431ebc621c9c9af8a8bfd181b0cd1fece051507e10ffd + languageName: node + linkType: hard + +"metro-transform-plugins@npm:0.83.3": + version: 0.83.3 + resolution: "metro-transform-plugins@npm:0.83.3" + dependencies: + "@babel/core": ^7.25.2 + "@babel/generator": ^7.25.0 + "@babel/template": ^7.25.0 + "@babel/traverse": ^7.25.3 + flow-enums-runtime: ^0.0.6 + nullthrows: ^1.1.1 + checksum: 6f92b9dfa53bdb63e79038bbd4d68791379ab26cf874679e64563618c578eeed3a828795debf8076ffd518431dff53191990784fb619046bcc03fff114b0cb21 + languageName: node + linkType: hard + +"metro-transform-worker@npm:0.83.3": + version: 0.83.3 + resolution: "metro-transform-worker@npm:0.83.3" + dependencies: + "@babel/core": ^7.25.2 + "@babel/generator": ^7.25.0 + "@babel/parser": ^7.25.3 + "@babel/types": ^7.25.2 + flow-enums-runtime: ^0.0.6 + metro: 0.83.3 + metro-babel-transformer: 0.83.3 + metro-cache: 0.83.3 + metro-cache-key: 0.83.3 + metro-minify-terser: 0.83.3 + metro-source-map: 0.83.3 + metro-transform-plugins: 0.83.3 + nullthrows: ^1.1.1 + checksum: fcb25ebc1ce703d830ef60c9af87325f996af4c3946325ab957b65ca59d12d181fe6c527c9ba1f932cd954d23a400052293117fe56f9a2727dfbc0a118e7bb27 + languageName: node + linkType: hard + +"metro@npm:0.83.3, metro@npm:^0.83.3": + version: 0.83.3 + resolution: "metro@npm:0.83.3" + dependencies: + "@babel/code-frame": ^7.24.7 + "@babel/core": ^7.25.2 + "@babel/generator": ^7.25.0 + "@babel/parser": ^7.25.3 + "@babel/template": ^7.25.0 + "@babel/traverse": ^7.25.3 + "@babel/types": ^7.25.2 + accepts: ^1.3.7 + chalk: ^4.0.0 + ci-info: ^2.0.0 + connect: ^3.6.5 + debug: ^4.4.0 + error-stack-parser: ^2.0.6 + flow-enums-runtime: ^0.0.6 + graceful-fs: ^4.2.4 + hermes-parser: 0.32.0 + image-size: ^1.0.2 + invariant: ^2.2.4 + jest-worker: ^29.7.0 + jsc-safe-url: ^0.2.2 + lodash.throttle: ^4.1.1 + metro-babel-transformer: 0.83.3 + metro-cache: 0.83.3 + metro-cache-key: 0.83.3 + metro-config: 0.83.3 + metro-core: 0.83.3 + metro-file-map: 0.83.3 + metro-resolver: 0.83.3 + metro-runtime: 0.83.3 + metro-source-map: 0.83.3 + metro-symbolicate: 0.83.3 + metro-transform-plugins: 0.83.3 + metro-transform-worker: 0.83.3 + mime-types: ^2.1.27 + nullthrows: ^1.1.1 + serialize-error: ^2.1.0 + source-map: ^0.5.6 + throat: ^5.0.0 + ws: ^7.5.10 + yargs: ^17.6.2 + bin: + metro: src/cli.js + checksum: 306d8c06b5a1a45e18df6e41f494bbc8b439700985429284eea7b3c3c82108e3c3795d859a8ab3ed7a85793d64e3160519be9aa84c6418d6ed37bd5ae4500b57 + languageName: node + linkType: hard + +"micromatch@npm:^4.0.4, micromatch@npm:^4.0.8": + version: 4.0.8 + resolution: "micromatch@npm:4.0.8" + dependencies: + braces: ^3.0.3 + picomatch: ^2.3.1 + checksum: 79920eb634e6f400b464a954fcfa589c4e7c7143209488e44baf627f9affc8b1e306f41f4f0deedde97e69cb725920879462d3e750ab3bd3c1aed675bb3a8966 + languageName: node + linkType: hard + +"mime-db@npm:1.52.0": + version: 1.52.0 + resolution: "mime-db@npm:1.52.0" + checksum: 0d99a03585f8b39d68182803b12ac601d9c01abfa28ec56204fa330bc9f3d1c5e14beb049bafadb3dbdf646dfb94b87e24d4ec7b31b7279ef906a8ea9b6a513f + languageName: node + linkType: hard + +"mime-types@npm:^2.1.12, mime-types@npm:^2.1.27, mime-types@npm:~2.1.34": + version: 2.1.35 + resolution: "mime-types@npm:2.1.35" + dependencies: + mime-db: 1.52.0 + checksum: 89a5b7f1def9f3af5dad6496c5ed50191ae4331cc5389d7c521c8ad28d5fdad2d06fd81baf38fed813dc4e46bb55c8145bb0ff406330818c9cf712fb2e9b3836 + languageName: node + linkType: hard + +"mime@npm:1.6.0": + version: 1.6.0 + resolution: "mime@npm:1.6.0" + bin: + mime: cli.js + checksum: fef25e39263e6d207580bdc629f8872a3f9772c923c7f8c7e793175cee22777bbe8bba95e5d509a40aaa292d8974514ce634ae35769faa45f22d17edda5e8557 + languageName: node + linkType: hard + +"mimic-fn@npm:^2.1.0": + version: 2.1.0 + resolution: "mimic-fn@npm:2.1.0" + checksum: d2421a3444848ce7f84bd49115ddacff29c15745db73f54041edc906c14b131a38d05298dae3081667627a59b2eb1ca4b436ff2e1b80f69679522410418b478a + languageName: node + linkType: hard + +"min-indent@npm:^1.0.0": + version: 1.0.1 + resolution: "min-indent@npm:1.0.1" + checksum: bfc6dd03c5eaf623a4963ebd94d087f6f4bbbfd8c41329a7f09706b0cb66969c4ddd336abeb587bc44bc6f08e13bf90f0b374f9d71f9f01e04adc2cd6f083ef1 + languageName: node + linkType: hard + +"minimatch@npm:3.0.5": + version: 3.0.5 + resolution: "minimatch@npm:3.0.5" + dependencies: + brace-expansion: ^1.1.7 + checksum: a3b84b426eafca947741b864502cee02860c4e7b145de11ad98775cfcf3066fef422583bc0ffce0952ddf4750c1ccf4220b1556430d4ce10139f66247d87d69e + languageName: node + linkType: hard + +"minimatch@npm:9.0.3": + version: 9.0.3 + resolution: "minimatch@npm:9.0.3" + dependencies: + brace-expansion: ^2.0.1 + checksum: 253487976bf485b612f16bf57463520a14f512662e592e95c571afdab1442a6a6864b6c88f248ce6fc4ff0b6de04ac7aa6c8bb51e868e99d1d65eb0658a708b5 + languageName: node + linkType: hard + +"minimatch@npm:^10.0.1, minimatch@npm:^10.1.1": + version: 10.1.1 + resolution: "minimatch@npm:10.1.1" + dependencies: + "@isaacs/brace-expansion": ^5.0.0 + checksum: 8820c0be92994f57281f0a7a2cc4268dcc4b610f9a1ab666685716b4efe4b5898b43c835a8f22298875b31c7a278a5e3b7e253eee7c886546bb0b61fb94bca6b + languageName: node + linkType: hard + +"minimatch@npm:^3.0.4, minimatch@npm:^3.0.5, minimatch@npm:^3.1.1, minimatch@npm:^3.1.2": + version: 3.1.2 + resolution: "minimatch@npm:3.1.2" + dependencies: + brace-expansion: ^1.1.7 + checksum: c154e566406683e7bcb746e000b84d74465b3a832c45d59912b9b55cd50dee66e5c4b1e5566dba26154040e51672f9aa450a9aef0c97cfc7336b78b7afb9540a + languageName: node + linkType: hard + +"minimatch@npm:^5.0.1": + version: 5.1.6 + resolution: "minimatch@npm:5.1.6" + dependencies: + brace-expansion: ^2.0.1 + checksum: 7564208ef81d7065a370f788d337cd80a689e981042cb9a1d0e6580b6c6a8c9279eba80010516e258835a988363f99f54a6f711a315089b8b42694f5da9d0d77 + languageName: node + linkType: hard + +"minimatch@npm:^8.0.2": + version: 8.0.4 + resolution: "minimatch@npm:8.0.4" + dependencies: + brace-expansion: ^2.0.1 + checksum: 2e46cffb86bacbc524ad45a6426f338920c529dd13f3a732cc2cf7618988ee1aae88df4ca28983285aca9e0f45222019ac2d14ebd17c1edadd2ee12221ab801a + languageName: node + linkType: hard + +"minimatch@npm:^9.0.0, minimatch@npm:^9.0.4, minimatch@npm:^9.0.5": + version: 9.0.5 + resolution: "minimatch@npm:9.0.5" + dependencies: + brace-expansion: ^2.0.1 + checksum: 2c035575eda1e50623c731ec6c14f65a85296268f749b9337005210bb2b34e2705f8ef1a358b188f69892286ab99dc42c8fb98a57bde55c8d81b3023c19cea28 + languageName: node + linkType: hard + +"minimist-options@npm:4.1.0": + version: 4.1.0 + resolution: "minimist-options@npm:4.1.0" + dependencies: + arrify: ^1.0.1 + is-plain-obj: ^1.1.0 + kind-of: ^6.0.3 + checksum: 8c040b3068811e79de1140ca2b708d3e203c8003eb9a414c1ab3cd467fc5f17c9ca02a5aef23bedc51a7f8bfbe77f87e9a7e31ec81fba304cda675b019496f4e + languageName: node + linkType: hard + +"minimist@npm:^1.2.5, minimist@npm:^1.2.6": + version: 1.2.8 + resolution: "minimist@npm:1.2.8" + checksum: 75a6d645fb122dad29c06a7597bddea977258957ed88d7a6df59b5cd3fe4a527e253e9bbf2e783e4b73657f9098b96a5fe96ab8a113655d4109108577ecf85b0 + languageName: node + linkType: hard + +"minipass-collect@npm:^2.0.1": + version: 2.0.1 + resolution: "minipass-collect@npm:2.0.1" + dependencies: + minipass: ^7.0.3 + checksum: b251bceea62090f67a6cced7a446a36f4cd61ee2d5cea9aee7fff79ba8030e416327a1c5aa2908dc22629d06214b46d88fdab8c51ac76bacbf5703851b5ad342 + languageName: node + linkType: hard + +"minipass-fetch@npm:^3.0.0": + version: 3.0.5 + resolution: "minipass-fetch@npm:3.0.5" + dependencies: + encoding: ^0.1.13 + minipass: ^7.0.3 + minipass-sized: ^1.0.3 + minizlib: ^2.1.2 + dependenciesMeta: + encoding: + optional: true + checksum: 8047d273236157aab27ab7cd8eab7ea79e6ecd63e8f80c3366ec076cb9a0fed550a6935bab51764369027c414647fd8256c2a20c5445fb250c483de43350de83 + languageName: node + linkType: hard + +"minipass-fetch@npm:^5.0.0": + version: 5.0.0 + resolution: "minipass-fetch@npm:5.0.0" + dependencies: + encoding: ^0.1.13 + minipass: ^7.0.3 + minipass-sized: ^1.0.3 + minizlib: ^3.0.1 + dependenciesMeta: + encoding: + optional: true + checksum: 416645d1e54c09fdfe64ec1676541ac2f6f2af3abc7ad25f2f22c4518535997c1ecd2c0c586ea8a5c6499ad7d8f97671f50ff38488ada54bf61fde309f731379 + languageName: node + linkType: hard + +"minipass-flush@npm:^1.0.5": + version: 1.0.5 + resolution: "minipass-flush@npm:1.0.5" + dependencies: + minipass: ^3.0.0 + checksum: 56269a0b22bad756a08a94b1ffc36b7c9c5de0735a4dd1ab2b06c066d795cfd1f0ac44a0fcae13eece5589b908ecddc867f04c745c7009be0b566421ea0944cf + languageName: node + linkType: hard + +"minipass-pipeline@npm:^1.2.4": + version: 1.2.4 + resolution: "minipass-pipeline@npm:1.2.4" + dependencies: + minipass: ^3.0.0 + checksum: b14240dac0d29823c3d5911c286069e36d0b81173d7bdf07a7e4a91ecdef92cdff4baaf31ea3746f1c61e0957f652e641223970870e2353593f382112257971b + languageName: node + linkType: hard + +"minipass-sized@npm:^1.0.3": + version: 1.0.3 + resolution: "minipass-sized@npm:1.0.3" + dependencies: + minipass: ^3.0.0 + checksum: 79076749fcacf21b5d16dd596d32c3b6bf4d6e62abb43868fac21674078505c8b15eaca4e47ed844985a4514854f917d78f588fcd029693709417d8f98b2bd60 + languageName: node + linkType: hard + +"minipass@npm:^3.0.0": + version: 3.3.6 + resolution: "minipass@npm:3.3.6" + dependencies: + yallist: ^4.0.0 + checksum: a30d083c8054cee83cdcdc97f97e4641a3f58ae743970457b1489ce38ee1167b3aaf7d815cd39ec7a99b9c40397fd4f686e83750e73e652b21cb516f6d845e48 + languageName: node + linkType: hard + +"minipass@npm:^4.2.4": + version: 4.2.8 + resolution: "minipass@npm:4.2.8" + checksum: 7f4914d5295a9a30807cae5227a37a926e6d910c03f315930fde52332cf0575dfbc20295318f91f0baf0e6bb11a6f668e30cde8027dea7a11b9d159867a3c830 + languageName: node + linkType: hard + +"minipass@npm:^5.0.0": + version: 5.0.0 + resolution: "minipass@npm:5.0.0" + checksum: 425dab288738853fded43da3314a0b5c035844d6f3097a8e3b5b29b328da8f3c1af6fc70618b32c29ff906284cf6406b6841376f21caaadd0793c1d5a6a620ea + languageName: node + linkType: hard + +"minipass@npm:^5.0.0 || ^6.0.2 || ^7.0.0, minipass@npm:^7.0.2, minipass@npm:^7.0.3, minipass@npm:^7.0.4, minipass@npm:^7.1.2": + version: 7.1.2 + resolution: "minipass@npm:7.1.2" + checksum: 2bfd325b95c555f2b4d2814d49325691c7bee937d753814861b0b49d5edcda55cbbf22b6b6a60bb91eddac8668771f03c5ff647dcd9d0f798e9548b9cdc46ee3 + languageName: node + linkType: hard + +"minizlib@npm:^2.1.1, minizlib@npm:^2.1.2": + version: 2.1.2 + resolution: "minizlib@npm:2.1.2" + dependencies: + minipass: ^3.0.0 + yallist: ^4.0.0 + checksum: f1fdeac0b07cf8f30fcf12f4b586795b97be856edea22b5e9072707be51fc95d41487faec3f265b42973a304fe3a64acd91a44a3826a963e37b37bafde0212c3 + languageName: node + linkType: hard + +"minizlib@npm:^3.0.1, minizlib@npm:^3.1.0": + version: 3.1.0 + resolution: "minizlib@npm:3.1.0" + dependencies: + minipass: ^7.1.2 + checksum: a15e6f0128f514b7d41a1c68ce531155447f4669e32d279bba1c1c071ef6c2abd7e4d4579bb59ccc2ed1531346749665968fdd7be8d83eb6b6ae2fe1f3d370a7 + languageName: node + linkType: hard + +"mkdirp@npm:^1.0.3, mkdirp@npm:^1.0.4": + version: 1.0.4 + resolution: "mkdirp@npm:1.0.4" + bin: + mkdirp: bin/cmd.js + checksum: a96865108c6c3b1b8e1d5e9f11843de1e077e57737602de1b82030815f311be11f96f09cce59bd5b903d0b29834733e5313f9301e3ed6d6f6fba2eae0df4298f + languageName: node + linkType: hard + +"modify-values@npm:^1.0.1": + version: 1.0.1 + resolution: "modify-values@npm:1.0.1" + checksum: 8296610c608bc97b03c2cf889c6cdf4517e32fa2d836440096374c2209f6b7b3e256c209493a0b32584b9cb32d528e99d0dd19dcd9a14d2d915a312d391cc7e9 + languageName: node + linkType: hard + +"ms@npm:2.0.0": + version: 2.0.0 + resolution: "ms@npm:2.0.0" + checksum: 0e6a22b8b746d2e0b65a430519934fefd41b6db0682e3477c10f60c76e947c4c0ad06f63ffdf1d78d335f83edee8c0aa928aa66a36c7cd95b69b26f468d527f4 + languageName: node + linkType: hard + +"ms@npm:2.1.3, ms@npm:^2.1.3": + version: 2.1.3 + resolution: "ms@npm:2.1.3" + checksum: aa92de608021b242401676e35cfa5aa42dd70cbdc082b916da7fb925c542173e36bce97ea3e804923fe92c0ad991434e4a38327e15a1b5b5f945d66df615ae6d + languageName: node + linkType: hard + +"multimatch@npm:5.0.0": + version: 5.0.0 + resolution: "multimatch@npm:5.0.0" + dependencies: + "@types/minimatch": ^3.0.3 + array-differ: ^3.0.0 + array-union: ^2.1.0 + arrify: ^2.0.1 + minimatch: ^3.0.4 + checksum: 82c8030a53af965cab48da22f1b0f894ef99e16ee680dabdfbd38d2dfacc3c8208c475203d747afd9e26db44118ed0221d5a0d65268c864f06d6efc7ac6df812 + languageName: node + linkType: hard + +"mute-stream@npm:0.0.8": + version: 0.0.8 + resolution: "mute-stream@npm:0.0.8" + checksum: ff48d251fc3f827e5b1206cda0ffdaec885e56057ee86a3155e1951bc940fd5f33531774b1cc8414d7668c10a8907f863f6561875ee6e8768931a62121a531a1 + languageName: node + linkType: hard + +"mute-stream@npm:^1.0.0": + version: 1.0.0 + resolution: "mute-stream@npm:1.0.0" + checksum: 36fc968b0e9c9c63029d4f9dc63911950a3bdf55c9a87f58d3a266289b67180201cade911e7699f8b2fa596b34c9db43dad37649e3f7fdd13c3bb9edb0017ee7 + languageName: node + linkType: hard + +"natural-compare@npm:^1.4.0": + version: 1.4.0 + resolution: "natural-compare@npm:1.4.0" + checksum: 23ad088b08f898fc9b53011d7bb78ec48e79de7627e01ab5518e806033861bef68d5b0cd0e2205c2f36690ac9571ff6bcb05eb777ced2eeda8d4ac5b44592c3d + languageName: node + linkType: hard + +"negotiator@npm:0.6.3": + version: 0.6.3 + resolution: "negotiator@npm:0.6.3" + checksum: b8ffeb1e262eff7968fc90a2b6767b04cfd9842582a9d0ece0af7049537266e7b2506dfb1d107a32f06dd849ab2aea834d5830f7f4d0e5cb7d36e1ae55d021d9 + languageName: node + linkType: hard + +"negotiator@npm:^0.6.3": + version: 0.6.4 + resolution: "negotiator@npm:0.6.4" + checksum: 7ded10aa02a0707d1d12a9973fdb5954f98547ca7beb60e31cb3a403cc6e8f11138db7a3b0128425cf836fc85d145ec4ce983b2bdf83dca436af879c2d683510 + languageName: node + linkType: hard + +"negotiator@npm:^1.0.0": + version: 1.0.0 + resolution: "negotiator@npm:1.0.0" + checksum: 20ebfe79b2d2e7cf9cbc8239a72662b584f71164096e6e8896c8325055497c96f6b80cd22c258e8a2f2aa382a787795ec3ee8b37b422a302c7d4381b0d5ecfbb + languageName: node + linkType: hard + +"neo-async@npm:^2.6.2": + version: 2.6.2 + resolution: "neo-async@npm:2.6.2" + checksum: deac9f8d00eda7b2e5cd1b2549e26e10a0faa70adaa6fdadca701cc55f49ee9018e427f424bac0c790b7c7e2d3068db97f3093f1093975f2acb8f8818b936ed9 + languageName: node + linkType: hard + +"nitrogen@npm:^0.31.10": + version: 0.31.10 + resolution: "nitrogen@npm:0.31.10" + dependencies: + chalk: ^5.3.0 + react-native-nitro-modules: ^0.31.10 + ts-morph: ^27.0.0 + yargs: ^18.0.0 + zod: ^4.0.5 + bin: + nitrogen: lib/index.js + checksum: 6bba165b334172d6ea060affefc14aded38294cd9ef23f58a3aff9fc89f41d7b62e6ae9897bec5d8ce181bb9656d387c67f2649b91ad133041db0aa04826ff71 + languageName: node + linkType: hard + +"node-fetch@npm:2.6.7": + version: 2.6.7 + resolution: "node-fetch@npm:2.6.7" + dependencies: + whatwg-url: ^5.0.0 + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + checksum: 8d816ffd1ee22cab8301c7756ef04f3437f18dace86a1dae22cf81db8ef29c0bf6655f3215cb0cdb22b420b6fe141e64b26905e7f33f9377a7fa59135ea3e10b + languageName: node + linkType: hard + +"node-gyp@npm:^10.0.0": + version: 10.3.1 + resolution: "node-gyp@npm:10.3.1" + dependencies: + env-paths: ^2.2.0 + exponential-backoff: ^3.1.1 + glob: ^10.3.10 + graceful-fs: ^4.2.6 + make-fetch-happen: ^13.0.0 + nopt: ^7.0.0 + proc-log: ^4.1.0 + semver: ^7.3.5 + tar: ^6.2.1 + which: ^4.0.0 + bin: + node-gyp: bin/node-gyp.js + checksum: 91b0690ab504fe051ad66863226dc5ecac72b8471f85e8428e4d5ca3217d3a2adfffae48cd555e8d009a4164689fff558b88d2bc9bfd246452a3336ab308cf99 + languageName: node + linkType: hard + +"node-gyp@npm:latest": + version: 12.1.0 + resolution: "node-gyp@npm:12.1.0" + dependencies: + env-paths: ^2.2.0 + exponential-backoff: ^3.1.1 + graceful-fs: ^4.2.6 + make-fetch-happen: ^15.0.0 + nopt: ^9.0.0 + proc-log: ^6.0.0 + semver: ^7.3.5 + tar: ^7.5.2 + tinyglobby: ^0.2.12 + which: ^6.0.0 + bin: + node-gyp: bin/node-gyp.js + checksum: 198d91c535fe9940bcdc0db4e578f94cf9872e0d068e88ef2f4656924248bb67245b270b48eded6634c7513841c0cd42f3da3ac9d77c8e16437fcd90703b9ef3 + languageName: node + linkType: hard + +"node-int64@npm:^0.4.0": + version: 0.4.0 + resolution: "node-int64@npm:0.4.0" + checksum: d0b30b1ee6d961851c60d5eaa745d30b5c95d94bc0e74b81e5292f7c42a49e3af87f1eb9e89f59456f80645d679202537de751b7d72e9e40ceea40c5e449057e + languageName: node + linkType: hard + +"node-machine-id@npm:1.1.12": + version: 1.1.12 + resolution: "node-machine-id@npm:1.1.12" + checksum: e23088a0fb4a77a1d6484b7f09a22992fd3e0054d4f2e427692b4c7081e6cf30118ba07b6113b6c89f1ce46fd26ec5ab1d76dcaf6c10317717889124511283a5 + languageName: node + linkType: hard + +"node-releases@npm:^2.0.27": + version: 2.0.27 + resolution: "node-releases@npm:2.0.27" + checksum: a9a54079d894704c2ec728a690b41fbc779a710f5d47b46fa3e460acff08a3e7dfa7108e5599b2db390aa31dac062c47c5118317201f12784188dc5b415f692d + languageName: node + linkType: hard + +"nopt@npm:^7.0.0, nopt@npm:^7.2.1": + version: 7.2.1 + resolution: "nopt@npm:7.2.1" + dependencies: + abbrev: ^2.0.0 + bin: + nopt: bin/nopt.js + checksum: 6fa729cc77ce4162cfad8abbc9ba31d4a0ff6850c3af61d59b505653bef4781ec059f8890ecfe93ee8aa0c511093369cca88bfc998101616a2904e715bbbb7c9 + languageName: node + linkType: hard + +"nopt@npm:^9.0.0": + version: 9.0.0 + resolution: "nopt@npm:9.0.0" + dependencies: + abbrev: ^4.0.0 + bin: + nopt: bin/nopt.js + checksum: 7a5d9ab0629eaec1944a95438cc4efa6418ed2834aa8eb21a1bea579a7d8ac3e30120131855376a96ef59ab0e23ad8e0bc94d3349770a95e5cb7119339f7c7fb + languageName: node + linkType: hard + +"normalize-package-data@npm:^2.3.2, normalize-package-data@npm:^2.5.0": + version: 2.5.0 + resolution: "normalize-package-data@npm:2.5.0" + dependencies: + hosted-git-info: ^2.1.4 + resolve: ^1.10.0 + semver: 2 || 3 || 4 || 5 + validate-npm-package-license: ^3.0.1 + checksum: 7999112efc35a6259bc22db460540cae06564aa65d0271e3bdfa86876d08b0e578b7b5b0028ee61b23f1cae9fc0e7847e4edc0948d3068a39a2a82853efc8499 + languageName: node + linkType: hard + +"normalize-package-data@npm:^3.0.0, normalize-package-data@npm:^3.0.2, normalize-package-data@npm:^3.0.3": + version: 3.0.3 + resolution: "normalize-package-data@npm:3.0.3" + dependencies: + hosted-git-info: ^4.0.1 + is-core-module: ^2.5.0 + semver: ^7.3.4 + validate-npm-package-license: ^3.0.1 + checksum: bbcee00339e7c26fdbc760f9b66d429258e2ceca41a5df41f5df06cc7652de8d82e8679ff188ca095cad8eff2b6118d7d866af2b68400f74602fbcbce39c160a + languageName: node + linkType: hard + +"normalize-package-data@npm:^6.0.0, normalize-package-data@npm:^6.0.1": + version: 6.0.2 + resolution: "normalize-package-data@npm:6.0.2" + dependencies: + hosted-git-info: ^7.0.0 + semver: ^7.3.5 + validate-npm-package-license: ^3.0.4 + checksum: ea35f8de68e03fc845f545c8197857c0cd256207fdb809ca63c2b39fe76ae77765ee939eb21811fb6c3b533296abf49ebe3cd617064f98a775adaccb24ff2e03 + languageName: node + linkType: hard + +"normalize-path@npm:^3.0.0": + version: 3.0.0 + resolution: "normalize-path@npm:3.0.0" + checksum: 88eeb4da891e10b1318c4b2476b6e2ecbeb5ff97d946815ffea7794c31a89017c70d7f34b3c2ebf23ef4e9fc9fb99f7dffe36da22011b5b5c6ffa34f4873ec20 + languageName: node + linkType: hard + +"npm-bundled@npm:^3.0.0": + version: 3.0.1 + resolution: "npm-bundled@npm:3.0.1" + dependencies: + npm-normalize-package-bin: ^3.0.0 + checksum: 1f4f7307d0ff2fbd31638689490f1fd673a4540cd1d027c7c5d15e484c71d63c4b27979944b6f8738035260cf5a5477ebaae75b08818420508e7cf317d71416e + languageName: node + linkType: hard + +"npm-install-checks@npm:^6.0.0, npm-install-checks@npm:^6.2.0": + version: 6.3.0 + resolution: "npm-install-checks@npm:6.3.0" + dependencies: + semver: ^7.1.1 + checksum: 6c20dadb878a0d2f1f777405217b6b63af1299d0b43e556af9363ee6eefaa98a17dfb7b612a473a473e96faf7e789c58b221e0d8ffdc1d34903c4f71618df3b4 + languageName: node + linkType: hard + +"npm-normalize-package-bin@npm:^3.0.0": + version: 3.0.1 + resolution: "npm-normalize-package-bin@npm:3.0.1" + checksum: de416d720ab22137a36292ff8a333af499ea0933ef2320a8c6f56a73b0f0448227fec4db5c890d702e26d21d04f271415eab6580b5546456861cc0c19498a4bf + languageName: node + linkType: hard + +"npm-package-arg@npm:11.0.2": + version: 11.0.2 + resolution: "npm-package-arg@npm:11.0.2" + dependencies: + hosted-git-info: ^7.0.0 + proc-log: ^4.0.0 + semver: ^7.3.5 + validate-npm-package-name: ^5.0.0 + checksum: cb78da54d42373fc87fcecfc68e74b10be02fea940becddf9fdcc8941334a5d57b5e867da2647e8b74880e1dc2b212d0fcc963fafd41cbccca8da3a1afef5b12 + languageName: node + linkType: hard + +"npm-package-arg@npm:^11.0.0, npm-package-arg@npm:^11.0.2": + version: 11.0.3 + resolution: "npm-package-arg@npm:11.0.3" + dependencies: + hosted-git-info: ^7.0.0 + proc-log: ^4.0.0 + semver: ^7.3.5 + validate-npm-package-name: ^5.0.0 + checksum: cc6f22c39201aa14dcceeddb81bfbf7fa0484f94bcd2b3ad038e18afec5167c843cdde90c897f6034dc368faa0100c1eeee6e3f436a89e0af32ba932af4a8c28 + languageName: node + linkType: hard + +"npm-packlist@npm:8.0.2, npm-packlist@npm:^8.0.0": + version: 8.0.2 + resolution: "npm-packlist@npm:8.0.2" + dependencies: + ignore-walk: ^6.0.4 + checksum: c75ae66b285503409e07878274d0580c1915e8db3a52539e7588a00d8c7c27b5c3c8459906d26142ffd772f0e8f291e9aa4ea076bb44a4ab0ba7e0f25b46423b + languageName: node + linkType: hard + +"npm-pick-manifest@npm:^9.0.0, npm-pick-manifest@npm:^9.0.1": + version: 9.1.0 + resolution: "npm-pick-manifest@npm:9.1.0" + dependencies: + npm-install-checks: ^6.0.0 + npm-normalize-package-bin: ^3.0.0 + npm-package-arg: ^11.0.0 + semver: ^7.3.5 + checksum: cbaad1e1420869efa851e8ba5d725263f679779e15bfca3713ec3ee1e897efab254e75c5445f442ffc96453cdfb15d362d25b0c0fcb03b156fe1653f9220cc40 + languageName: node + linkType: hard + +"npm-registry-fetch@npm:^17.0.0, npm-registry-fetch@npm:^17.0.1, npm-registry-fetch@npm:^17.1.0": + version: 17.1.0 + resolution: "npm-registry-fetch@npm:17.1.0" + dependencies: + "@npmcli/redact": ^2.0.0 + jsonparse: ^1.3.1 + make-fetch-happen: ^13.0.0 + minipass: ^7.0.2 + minipass-fetch: ^3.0.0 + minizlib: ^2.1.2 + npm-package-arg: ^11.0.0 + proc-log: ^4.0.0 + checksum: 12452e690aa98a4504fe70a40e97877656799a66d31b8e6d5786b85d1d27aee168162cd5d78acc05a7eac5fa56f2b5ba0bdf80e83daaf5ef67e66c3d8c979c39 + languageName: node + linkType: hard + +"npm-run-path@npm:^4.0.1": + version: 4.0.1 + resolution: "npm-run-path@npm:4.0.1" + dependencies: + path-key: ^3.0.0 + checksum: 5374c0cea4b0bbfdfae62da7bbdf1e1558d338335f4cacf2515c282ff358ff27b2ecb91ffa5330a8b14390ac66a1e146e10700440c1ab868208430f56b5f4d23 + languageName: node + linkType: hard + +"nullthrows@npm:^1.1.1": + version: 1.1.1 + resolution: "nullthrows@npm:1.1.1" + checksum: 10806b92121253eb1b08ecf707d92480f5331ba8ae5b23fa3eb0548ad24196eb797ed47606153006568a5733ea9e528a3579f21421f7828e09e7756f4bdd386f + languageName: node + linkType: hard + +"nx@npm:>=17.1.2 < 21": + version: 20.8.4 + resolution: "nx@npm:20.8.4" + dependencies: + "@napi-rs/wasm-runtime": 0.2.4 + "@nx/nx-darwin-arm64": 20.8.4 + "@nx/nx-darwin-x64": 20.8.4 + "@nx/nx-freebsd-x64": 20.8.4 + "@nx/nx-linux-arm-gnueabihf": 20.8.4 + "@nx/nx-linux-arm64-gnu": 20.8.4 + "@nx/nx-linux-arm64-musl": 20.8.4 + "@nx/nx-linux-x64-gnu": 20.8.4 + "@nx/nx-linux-x64-musl": 20.8.4 + "@nx/nx-win32-arm64-msvc": 20.8.4 + "@nx/nx-win32-x64-msvc": 20.8.4 + "@yarnpkg/lockfile": ^1.1.0 + "@yarnpkg/parsers": 3.0.2 + "@zkochan/js-yaml": 0.0.7 + axios: ^1.8.3 + chalk: ^4.1.0 + cli-cursor: 3.1.0 + cli-spinners: 2.6.1 + cliui: ^8.0.1 + dotenv: ~16.4.5 + dotenv-expand: ~11.0.6 + enquirer: ~2.3.6 + figures: 3.2.0 + flat: ^5.0.2 + front-matter: ^4.0.2 + ignore: ^5.0.4 + jest-diff: ^29.4.1 + jsonc-parser: 3.2.0 + lines-and-columns: 2.0.3 + minimatch: 9.0.3 + node-machine-id: 1.1.12 + npm-run-path: ^4.0.1 + open: ^8.4.0 + ora: 5.3.0 + resolve.exports: 2.0.3 + semver: ^7.5.3 + string-width: ^4.2.3 + tar-stream: ~2.2.0 + tmp: ~0.2.1 + tsconfig-paths: ^4.1.2 + tslib: ^2.3.0 + yaml: ^2.6.0 + yargs: ^17.6.2 + yargs-parser: 21.1.1 + peerDependencies: + "@swc-node/register": ^1.8.0 + "@swc/core": ^1.3.85 + dependenciesMeta: + "@nx/nx-darwin-arm64": + optional: true + "@nx/nx-darwin-x64": + optional: true + "@nx/nx-freebsd-x64": + optional: true + "@nx/nx-linux-arm-gnueabihf": + optional: true + "@nx/nx-linux-arm64-gnu": + optional: true + "@nx/nx-linux-arm64-musl": + optional: true + "@nx/nx-linux-x64-gnu": + optional: true + "@nx/nx-linux-x64-musl": + optional: true + "@nx/nx-win32-arm64-msvc": + optional: true + "@nx/nx-win32-x64-msvc": + optional: true + peerDependenciesMeta: + "@swc-node/register": + optional: true + "@swc/core": + optional: true + bin: + nx: bin/nx.js + nx-cloud: bin/nx-cloud.js + checksum: 45dc5308824c92dbbd6f86a74de337a8ecaa05c024f2b167b1a9d3b25cfa0fe62cb703b202b64abb89f8536963ea123f9d871a93623ccd60474a2644aef2528e + languageName: node + linkType: hard + +"ob1@npm:0.83.3": + version: 0.83.3 + resolution: "ob1@npm:0.83.3" + dependencies: + flow-enums-runtime: ^0.0.6 + checksum: 20dfe91d48d0cadd97159cfd53f5abdca435b55d58b1f562e0687485e8f44f8a95e8ab3c835badd13d0d8c01e3d7b14d639a316aa4bf82841ac78b49611d4e5c + languageName: node + linkType: hard + +"on-finished@npm:~2.3.0": + version: 2.3.0 + resolution: "on-finished@npm:2.3.0" + dependencies: + ee-first: 1.1.1 + checksum: 1db595bd963b0124d6fa261d18320422407b8f01dc65863840f3ddaaf7bcad5b28ff6847286703ca53f4ec19595bd67a2f1253db79fc4094911ec6aa8df1671b + languageName: node + linkType: hard + +"on-finished@npm:~2.4.1": + version: 2.4.1 + resolution: "on-finished@npm:2.4.1" + dependencies: + ee-first: 1.1.1 + checksum: d20929a25e7f0bb62f937a425b5edeb4e4cde0540d77ba146ec9357f00b0d497cdb3b9b05b9c8e46222407d1548d08166bff69cc56dfa55ba0e4469228920ff0 + languageName: node + linkType: hard + +"once@npm:^1.3.0, once@npm:^1.4.0": + version: 1.4.0 + resolution: "once@npm:1.4.0" + dependencies: + wrappy: 1 + checksum: cd0a88501333edd640d95f0d2700fbde6bff20b3d4d9bdc521bdd31af0656b5706570d6c6afe532045a20bb8dc0849f8332d6f2a416e0ba6d3d3b98806c7db68 + languageName: node + linkType: hard + +"onetime@npm:^5.1.0, onetime@npm:^5.1.2": + version: 5.1.2 + resolution: "onetime@npm:5.1.2" + dependencies: + mimic-fn: ^2.1.0 + checksum: 2478859ef817fc5d4e9c2f9e5728512ddd1dbc9fb7829ad263765bb6d3b91ce699d6e2332eef6b7dff183c2f490bd3349f1666427eaba4469fba0ac38dfd0d34 + languageName: node + linkType: hard + +"open@npm:^7.0.3": + version: 7.4.2 + resolution: "open@npm:7.4.2" + dependencies: + is-docker: ^2.0.0 + is-wsl: ^2.1.1 + checksum: 3333900ec0e420d64c23b831bc3467e57031461d843c801f569b2204a1acc3cd7b3ec3c7897afc9dde86491dfa289708eb92bba164093d8bd88fb2c231843c91 + languageName: node + linkType: hard + +"open@npm:^8.4.0": + version: 8.4.2 + resolution: "open@npm:8.4.2" + dependencies: + define-lazy-prop: ^2.0.0 + is-docker: ^2.1.1 + is-wsl: ^2.2.0 + checksum: 6388bfff21b40cb9bd8f913f9130d107f2ed4724ea81a8fd29798ee322b361ca31fa2cdfb491a5c31e43a3996cfe9566741238c7a741ada8d7af1cb78d85cf26 + languageName: node + linkType: hard + +"optionator@npm:^0.9.3": + version: 0.9.4 + resolution: "optionator@npm:0.9.4" + dependencies: + deep-is: ^0.1.3 + fast-levenshtein: ^2.0.6 + levn: ^0.4.1 + prelude-ls: ^1.2.1 + type-check: ^0.4.0 + word-wrap: ^1.2.5 + checksum: ecbd010e3dc73e05d239976422d9ef54a82a13f37c11ca5911dff41c98a6c7f0f163b27f922c37e7f8340af9d36febd3b6e9cef508f3339d4c393d7276d716bb + languageName: node + linkType: hard + +"ora@npm:5.3.0": + version: 5.3.0 + resolution: "ora@npm:5.3.0" + dependencies: + bl: ^4.0.3 + chalk: ^4.1.0 + cli-cursor: ^3.1.0 + cli-spinners: ^2.5.0 + is-interactive: ^1.0.0 + log-symbols: ^4.0.0 + strip-ansi: ^6.0.0 + wcwidth: ^1.0.1 + checksum: 60ec956843def482e2a9a78e98b6bfb19129cbf683fa4e4daca41423f9a098332a8a33b4ca335151b1e6836ff746e3b96e09441f3aea72151e4060990966daad + languageName: node + linkType: hard + +"ora@npm:^5.4.1": + version: 5.4.1 + resolution: "ora@npm:5.4.1" + dependencies: + bl: ^4.1.0 + chalk: ^4.1.0 + cli-cursor: ^3.1.0 + cli-spinners: ^2.5.0 + is-interactive: ^1.0.0 + is-unicode-supported: ^0.1.0 + log-symbols: ^4.1.0 + strip-ansi: ^6.0.0 + wcwidth: ^1.0.1 + checksum: 28d476ee6c1049d68368c0dc922e7225e3b5600c3ede88fade8052837f9ed342625fdaa84a6209302587c8ddd9b664f71f0759833cbdb3a4cf81344057e63c63 + languageName: node + linkType: hard + +"p-finally@npm:^1.0.0": + version: 1.0.0 + resolution: "p-finally@npm:1.0.0" + checksum: 93a654c53dc805dd5b5891bab16eb0ea46db8f66c4bfd99336ae929323b1af2b70a8b0654f8f1eae924b2b73d037031366d645f1fd18b3d30cbd15950cc4b1d4 + languageName: node + linkType: hard + +"p-limit@npm:^1.1.0": + version: 1.3.0 + resolution: "p-limit@npm:1.3.0" + dependencies: + p-try: ^1.0.0 + checksum: 281c1c0b8c82e1ac9f81acd72a2e35d402bf572e09721ce5520164e9de07d8274451378a3470707179ad13240535558f4b277f02405ad752e08c7d5b0d54fbfd + languageName: node + linkType: hard + +"p-limit@npm:^2.2.0": + version: 2.3.0 + resolution: "p-limit@npm:2.3.0" + dependencies: + p-try: ^2.0.0 + checksum: 84ff17f1a38126c3314e91ecfe56aecbf36430940e2873dadaa773ffe072dc23b7af8e46d4b6485d302a11673fe94c6b67ca2cfbb60c989848b02100d0594ac1 + languageName: node + linkType: hard + +"p-limit@npm:^3.0.2": + version: 3.1.0 + resolution: "p-limit@npm:3.1.0" + dependencies: + yocto-queue: ^0.1.0 + checksum: 7c3690c4dbf62ef625671e20b7bdf1cbc9534e83352a2780f165b0d3ceba21907e77ad63401708145ca4e25bfc51636588d89a8c0aeb715e6c37d1c066430360 + languageName: node + linkType: hard + +"p-locate@npm:^2.0.0": + version: 2.0.0 + resolution: "p-locate@npm:2.0.0" + dependencies: + p-limit: ^1.1.0 + checksum: e2dceb9b49b96d5513d90f715780f6f4972f46987dc32a0e18bc6c3fc74a1a5d73ec5f81b1398af5e58b99ea1ad03fd41e9181c01fa81b4af2833958696e3081 + languageName: node + linkType: hard + +"p-locate@npm:^4.1.0": + version: 4.1.0 + resolution: "p-locate@npm:4.1.0" + dependencies: + p-limit: ^2.2.0 + checksum: 513bd14a455f5da4ebfcb819ef706c54adb09097703de6aeaa5d26fe5ea16df92b48d1ac45e01e3944ce1e6aa2a66f7f8894742b8c9d6e276e16cd2049a2b870 + languageName: node + linkType: hard + +"p-locate@npm:^5.0.0": + version: 5.0.0 + resolution: "p-locate@npm:5.0.0" + dependencies: + p-limit: ^3.0.2 + checksum: 1623088f36cf1cbca58e9b61c4e62bf0c60a07af5ae1ca99a720837356b5b6c5ba3eb1b2127e47a06865fee59dd0453cad7cc844cda9d5a62ac1a5a51b7c86d3 + languageName: node + linkType: hard + +"p-map-series@npm:2.1.0": + version: 2.1.0 + resolution: "p-map-series@npm:2.1.0" + checksum: 69d4efbb6951c0dd62591d5a18c3af0af78496eae8b55791e049da239d70011aa3af727dece3fc9943e0bb3fd4fa64d24177cfbecc46efaf193179f0feeac486 + languageName: node + linkType: hard + +"p-map@npm:4.0.0, p-map@npm:^4.0.0": + version: 4.0.0 + resolution: "p-map@npm:4.0.0" + dependencies: + aggregate-error: ^3.0.0 + checksum: cb0ab21ec0f32ddffd31dfc250e3afa61e103ef43d957cc45497afe37513634589316de4eb88abdfd969fe6410c22c0b93ab24328833b8eb1ccc087fc0442a1c + languageName: node + linkType: hard + +"p-map@npm:^5.5.0": + version: 5.5.0 + resolution: "p-map@npm:5.5.0" + dependencies: + aggregate-error: ^4.0.0 + checksum: 065cb6fca6b78afbd070dd9224ff160dc23eea96e57863c09a0c8ea7ce921043f76854be7ee0abc295cff1ac9adcf700e79a1fbe3b80b625081087be58e7effb + languageName: node + linkType: hard + +"p-map@npm:^7.0.2": + version: 7.0.4 + resolution: "p-map@npm:7.0.4" + checksum: 4be2097e942f2fd3a4f4b0c6585c721f23851de8ad6484d20c472b3ea4937d5cd9a59914c832b1bceac7bf9d149001938036b82a52de0bc381f61ff2d35d26a5 + languageName: node + linkType: hard + +"p-pipe@npm:3.1.0": + version: 3.1.0 + resolution: "p-pipe@npm:3.1.0" + checksum: ee9a2609685f742c6ceb3122281ec4453bbbcc80179b13e66fd139dcf19b1c327cf6c2fdfc815b548d6667e7eaefe5396323f6d49c4f7933e4cef47939e3d65c + languageName: node + linkType: hard + +"p-queue@npm:6.6.2": + version: 6.6.2 + resolution: "p-queue@npm:6.6.2" + dependencies: + eventemitter3: ^4.0.4 + p-timeout: ^3.2.0 + checksum: 832642fcc4ab6477b43e6d7c30209ab10952969ed211c6d6f2931be8a4f9935e3578c72e8cce053dc34f2eb6941a408a2c516a54904e989851a1a209cf19761c + languageName: node + linkType: hard + +"p-reduce@npm:2.1.0, p-reduce@npm:^2.0.0, p-reduce@npm:^2.1.0": + version: 2.1.0 + resolution: "p-reduce@npm:2.1.0" + checksum: 99b26d36066a921982f25c575e78355824da0787c486e3dd9fc867460e8bf17d5fb3ce98d006b41bdc81ffc0aa99edf5faee53d11fe282a20291fb721b0cb1c7 + languageName: node + linkType: hard + +"p-timeout@npm:^3.2.0": + version: 3.2.0 + resolution: "p-timeout@npm:3.2.0" + dependencies: + p-finally: ^1.0.0 + checksum: 3dd0eaa048780a6f23e5855df3dd45c7beacff1f820476c1d0d1bcd6648e3298752ba2c877aa1c92f6453c7dd23faaf13d9f5149fc14c0598a142e2c5e8d649c + languageName: node + linkType: hard + +"p-try@npm:^1.0.0": + version: 1.0.0 + resolution: "p-try@npm:1.0.0" + checksum: 3b5303f77eb7722144154288bfd96f799f8ff3e2b2b39330efe38db5dd359e4fb27012464cd85cb0a76e9b7edd1b443568cb3192c22e7cffc34989df0bafd605 + languageName: node + linkType: hard + +"p-try@npm:^2.0.0": + version: 2.2.0 + resolution: "p-try@npm:2.2.0" + checksum: f8a8e9a7693659383f06aec604ad5ead237c7a261c18048a6e1b5b85a5f8a067e469aa24f5bc009b991ea3b058a87f5065ef4176793a200d4917349881216cae + languageName: node + linkType: hard + +"p-waterfall@npm:2.1.1": + version: 2.1.1 + resolution: "p-waterfall@npm:2.1.1" + dependencies: + p-reduce: ^2.0.0 + checksum: 8588bb8b004ee37e559c7e940a480c1742c42725d477b0776ff30b894920a3e48bddf8f60aa0ae82773e500a8fc99d75e947c450e0c2ce187aff72cc1b248f6d + languageName: node + linkType: hard + +"package-json-from-dist@npm:^1.0.0": + version: 1.0.1 + resolution: "package-json-from-dist@npm:1.0.1" + checksum: 58ee9538f2f762988433da00e26acc788036914d57c71c246bf0be1b60cdbd77dd60b6a3e1a30465f0b248aeb80079e0b34cb6050b1dfa18c06953bb1cbc7602 + languageName: node + linkType: hard + +"pacote@npm:^18.0.0, pacote@npm:^18.0.6": + version: 18.0.6 + resolution: "pacote@npm:18.0.6" + dependencies: + "@npmcli/git": ^5.0.0 + "@npmcli/installed-package-contents": ^2.0.1 + "@npmcli/package-json": ^5.1.0 + "@npmcli/promise-spawn": ^7.0.0 + "@npmcli/run-script": ^8.0.0 + cacache: ^18.0.0 + fs-minipass: ^3.0.0 + minipass: ^7.0.2 + npm-package-arg: ^11.0.0 + npm-packlist: ^8.0.0 + npm-pick-manifest: ^9.0.0 + npm-registry-fetch: ^17.0.0 + proc-log: ^4.0.0 + promise-retry: ^2.0.1 + sigstore: ^2.2.0 + ssri: ^10.0.0 + tar: ^6.1.11 + bin: + pacote: bin/index.js + checksum: a28a7aa0f4e1375d3f11917e5982e576611aa9057999e7b3a7fd18706e43d6ae4ab34b1002dc0a9821df95c3136dec6d2b6b72cfc7b02afcc1273cec006dea39 + languageName: node + linkType: hard + +"parent-module@npm:^1.0.0": + version: 1.0.1 + resolution: "parent-module@npm:1.0.1" + dependencies: + callsites: ^3.0.0 + checksum: 6ba8b255145cae9470cf5551eb74be2d22281587af787a2626683a6c20fbb464978784661478dd2a3f1dad74d1e802d403e1b03c1a31fab310259eec8ac560ff + languageName: node + linkType: hard + +"parse-conflict-json@npm:^3.0.0": + version: 3.0.1 + resolution: "parse-conflict-json@npm:3.0.1" + dependencies: + json-parse-even-better-errors: ^3.0.0 + just-diff: ^6.0.0 + just-diff-apply: ^5.2.0 + checksum: d8d2656bc02d4df36846366baec36b419da2fe944e31298719a4d28d28f772aa7cad2a69d01f6f329918e7c298ac481d1e6a9138d62d5662d5620a74f794af8f + languageName: node + linkType: hard + +"parse-json@npm:^4.0.0": + version: 4.0.0 + resolution: "parse-json@npm:4.0.0" + dependencies: + error-ex: ^1.3.1 + json-parse-better-errors: ^1.0.1 + checksum: 0fe227d410a61090c247e34fa210552b834613c006c2c64d9a05cfe9e89cf8b4246d1246b1a99524b53b313e9ac024438d0680f67e33eaed7e6f38db64cfe7b5 + languageName: node + linkType: hard + +"parse-json@npm:^5.0.0, parse-json@npm:^5.2.0": + version: 5.2.0 + resolution: "parse-json@npm:5.2.0" + dependencies: + "@babel/code-frame": ^7.0.0 + error-ex: ^1.3.1 + json-parse-even-better-errors: ^2.3.0 + lines-and-columns: ^1.1.6 + checksum: 62085b17d64da57f40f6afc2ac1f4d95def18c4323577e1eced571db75d9ab59b297d1d10582920f84b15985cbfc6b6d450ccbf317644cfa176f3ed982ad87e2 + languageName: node + linkType: hard + +"parse-path@npm:^7.0.0": + version: 7.1.0 + resolution: "parse-path@npm:7.1.0" + dependencies: + protocols: ^2.0.0 + checksum: 1da6535a967b14911837bba98e5f8d16acb415b28753ff6225e3121dce71167a96c79278fbb631d695210dadae37462a9eff40d93b9c659cf1ce496fd5db9bb6 + languageName: node + linkType: hard + +"parse-url@npm:^8.1.0": + version: 8.1.0 + resolution: "parse-url@npm:8.1.0" + dependencies: + parse-path: ^7.0.0 + checksum: b93e21ab4c93c7d7317df23507b41be7697694d4c94f49ed5c8d6288b01cba328fcef5ba388e147948eac20453dee0df9a67ab2012415189fff85973bdffe8d9 + languageName: node + linkType: hard + +"parseurl@npm:~1.3.3": + version: 1.3.3 + resolution: "parseurl@npm:1.3.3" + checksum: 407cee8e0a3a4c5cd472559bca8b6a45b82c124e9a4703302326e9ab60fc1081442ada4e02628efef1eb16197ddc7f8822f5a91fd7d7c86b51f530aedb17dfa2 + languageName: node + linkType: hard + +"path-browserify@npm:^1.0.1": + version: 1.0.1 + resolution: "path-browserify@npm:1.0.1" + checksum: c6d7fa376423fe35b95b2d67990060c3ee304fc815ff0a2dc1c6c3cfaff2bd0d572ee67e18f19d0ea3bbe32e8add2a05021132ac40509416459fffee35200699 + languageName: node + linkType: hard + +"path-exists@npm:^3.0.0": + version: 3.0.0 + resolution: "path-exists@npm:3.0.0" + checksum: 96e92643aa34b4b28d0de1cd2eba52a1c5313a90c6542d03f62750d82480e20bfa62bc865d5cfc6165f5fcd5aeb0851043c40a39be5989646f223300021bae0a + languageName: node + linkType: hard + +"path-exists@npm:^4.0.0": + version: 4.0.0 + resolution: "path-exists@npm:4.0.0" + checksum: 505807199dfb7c50737b057dd8d351b82c033029ab94cb10a657609e00c1bc53b951cfdbccab8de04c5584d5eff31128ce6afd3db79281874a5ef2adbba55ed1 + languageName: node + linkType: hard + +"path-is-absolute@npm:^1.0.0": + version: 1.0.1 + resolution: "path-is-absolute@npm:1.0.1" + checksum: 060840f92cf8effa293bcc1bea81281bd7d363731d214cbe5c227df207c34cd727430f70c6037b5159c8a870b9157cba65e775446b0ab06fd5ecc7e54615a3b8 + languageName: node + linkType: hard + +"path-key@npm:^3.0.0, path-key@npm:^3.1.0": + version: 3.1.1 + resolution: "path-key@npm:3.1.1" + checksum: 55cd7a9dd4b343412a8386a743f9c746ef196e57c823d90ca3ab917f90ab9f13dd0ded27252ba49dbdfcab2b091d998bc446f6220cd3cea65db407502a740020 + languageName: node + linkType: hard + +"path-parse@npm:^1.0.7": + version: 1.0.7 + resolution: "path-parse@npm:1.0.7" + checksum: 49abf3d81115642938a8700ec580da6e830dde670be21893c62f4e10bd7dd4c3742ddc603fe24f898cba7eb0c6bc1777f8d9ac14185d34540c6d4d80cd9cae8a + languageName: node + linkType: hard + +"path-scurry@npm:^1.11.1, path-scurry@npm:^1.6.1": + version: 1.11.1 + resolution: "path-scurry@npm:1.11.1" + dependencies: + lru-cache: ^10.2.0 + minipass: ^5.0.0 || ^6.0.2 || ^7.0.0 + checksum: 890d5abcd593a7912dcce7cf7c6bf7a0b5648e3dee6caf0712c126ca0a65c7f3d7b9d769072a4d1baf370f61ce493ab5b038d59988688e0c5f3f646ee3c69023 + languageName: node + linkType: hard + +"path-scurry@npm:^2.0.0": + version: 2.0.1 + resolution: "path-scurry@npm:2.0.1" + dependencies: + lru-cache: ^11.0.0 + minipass: ^7.1.2 + checksum: a022c6c38fed836079d03f96540eafd4cd989acf287b99613c82300107f366e889513ad8b671a2039a9d251122621f9c6fa649f0bd4d50acf95a6943a6692dbf + languageName: node + linkType: hard + +"path-type@npm:^3.0.0": + version: 3.0.0 + resolution: "path-type@npm:3.0.0" + dependencies: + pify: ^3.0.0 + checksum: 735b35e256bad181f38fa021033b1c33cfbe62ead42bb2222b56c210e42938eecb272ae1949f3b6db4ac39597a61b44edd8384623ec4d79bfdc9a9c0f12537a6 + languageName: node + linkType: hard + +"path-type@npm:^4.0.0": + version: 4.0.0 + resolution: "path-type@npm:4.0.0" + checksum: 5b1e2daa247062061325b8fdbfd1fb56dde0a448fb1455453276ea18c60685bdad23a445dc148cf87bc216be1573357509b7d4060494a6fd768c7efad833ee45 + languageName: node + linkType: hard + +"picocolors@npm:^1.1.1": + version: 1.1.1 + resolution: "picocolors@npm:1.1.1" + checksum: e1cf46bf84886c79055fdfa9dcb3e4711ad259949e3565154b004b260cd356c5d54b31a1437ce9782624bf766272fe6b0154f5f0c744fb7af5d454d2b60db045 + languageName: node + linkType: hard + +"picomatch@npm:^2.0.4, picomatch@npm:^2.2.3, picomatch@npm:^2.3.1": + version: 2.3.1 + resolution: "picomatch@npm:2.3.1" + checksum: 050c865ce81119c4822c45d3c84f1ced46f93a0126febae20737bd05ca20589c564d6e9226977df859ed5e03dc73f02584a2b0faad36e896936238238b0446cf + languageName: node + linkType: hard + +"picomatch@npm:^4.0.2, picomatch@npm:^4.0.3": + version: 4.0.3 + resolution: "picomatch@npm:4.0.3" + checksum: 6817fb74eb745a71445debe1029768de55fd59a42b75606f478ee1d0dc1aa6e78b711d041a7c9d5550e042642029b7f373dc1a43b224c4b7f12d23436735dba0 + languageName: node + linkType: hard + +"pify@npm:5.0.0": + version: 5.0.0 + resolution: "pify@npm:5.0.0" + checksum: 443e3e198ad6bfa8c0c533764cf75c9d5bc976387a163792fb553ffe6ce923887cf14eebf5aea9b7caa8eab930da8c33612990ae85bd8c2bc18bedb9eae94ecb + languageName: node + linkType: hard + +"pify@npm:^2.3.0": + version: 2.3.0 + resolution: "pify@npm:2.3.0" + checksum: 9503aaeaf4577acc58642ad1d25c45c6d90288596238fb68f82811c08104c800e5a7870398e9f015d82b44ecbcbef3dc3d4251a1cbb582f6e5959fe09884b2ba + languageName: node + linkType: hard + +"pify@npm:^3.0.0": + version: 3.0.0 + resolution: "pify@npm:3.0.0" + checksum: 6cdcbc3567d5c412450c53261a3f10991665d660961e06605decf4544a61a97a54fefe70a68d5c37080ff9d6f4cf51444c90198d1ba9f9309a6c0d6e9f5c4fde + languageName: node + linkType: hard + +"pify@npm:^4.0.1": + version: 4.0.1 + resolution: "pify@npm:4.0.1" + checksum: 9c4e34278cb09987685fa5ef81499c82546c033713518f6441778fbec623fc708777fe8ac633097c72d88470d5963094076c7305cafc7ad340aae27cfacd856b + languageName: node + linkType: hard + +"pirates@npm:^4.0.4": + version: 4.0.7 + resolution: "pirates@npm:4.0.7" + checksum: 3dcbaff13c8b5bc158416feb6dc9e49e3c6be5fddc1ea078a05a73ef6b85d79324bbb1ef59b954cdeff000dbf000c1d39f32dc69310c7b78fbada5171b583e40 + languageName: node + linkType: hard + +"pkg-dir@npm:^4.2.0": + version: 4.2.0 + resolution: "pkg-dir@npm:4.2.0" + dependencies: + find-up: ^4.0.0 + checksum: 9863e3f35132bf99ae1636d31ff1e1e3501251d480336edb1c211133c8d58906bed80f154a1d723652df1fda91e01c7442c2eeaf9dc83157c7ae89087e43c8d6 + languageName: node + linkType: hard + +"postcss-selector-parser@npm:^6.0.10": + version: 6.1.2 + resolution: "postcss-selector-parser@npm:6.1.2" + dependencies: + cssesc: ^3.0.0 + util-deprecate: ^1.0.2 + checksum: ce9440fc42a5419d103f4c7c1847cb75488f3ac9cbe81093b408ee9701193a509f664b4d10a2b4d82c694ee7495e022f8f482d254f92b7ffd9ed9dea696c6f84 + languageName: node + linkType: hard + +"prelude-ls@npm:^1.2.1": + version: 1.2.1 + resolution: "prelude-ls@npm:1.2.1" + checksum: cd192ec0d0a8e4c6da3bb80e4f62afe336df3f76271ac6deb0e6a36187133b6073a19e9727a1ff108cd8b9982e4768850d413baa71214dd80c7979617dca827a + languageName: node + linkType: hard + +"prettier-linter-helpers@npm:^1.0.1": + version: 1.0.1 + resolution: "prettier-linter-helpers@npm:1.0.1" + dependencies: + fast-diff: ^1.1.2 + checksum: 2dc35f5036a35f4c4f5e645887edda1436acb63687a7f12b2383e0a6f3c1f76b8a0a4709fe4d82e19157210feb5984b159bb714d43290022911ab53d606474ec + languageName: node + linkType: hard + +"prettier@npm:^3.0.3": + version: 3.8.0 + resolution: "prettier@npm:3.8.0" + bin: + prettier: bin/prettier.cjs + checksum: 50770d842539d5fa208bd84ecfb28ae367258b14a5e7e4b9472ba087ed9f8888a5bfd387bfd5596473529f04347670f9b1aa6f0bd8631bec1644e6a8e47c7d35 + languageName: node + linkType: hard + +"pretty-format@npm:^29.7.0": + version: 29.7.0 + resolution: "pretty-format@npm:29.7.0" + dependencies: + "@jest/schemas": ^29.6.3 + ansi-styles: ^5.0.0 + react-is: ^18.0.0 + checksum: 032c1602383e71e9c0c02a01bbd25d6759d60e9c7cf21937dde8357aa753da348fcec5def5d1002c9678a8524d5fe099ad98861286550ef44de8808cc61e43b6 + languageName: node + linkType: hard + +"proc-log@npm:^4.0.0, proc-log@npm:^4.1.0, proc-log@npm:^4.2.0": + version: 4.2.0 + resolution: "proc-log@npm:4.2.0" + checksum: 98f6cd012d54b5334144c5255ecb941ee171744f45fca8b43b58ae5a0c1af07352475f481cadd9848e7f0250376ee584f6aa0951a856ff8f021bdfbff4eb33fc + languageName: node + linkType: hard + +"proc-log@npm:^6.0.0": + version: 6.1.0 + resolution: "proc-log@npm:6.1.0" + checksum: ac450ff8244e95b0c9935b52d629fef92ae69b7e39aea19972a8234259614d644402dd62ce9cb094f4a637d8a4514cba90c1456ad785a40ad5b64d502875a817 + languageName: node + linkType: hard + +"process-nextick-args@npm:~2.0.0": + version: 2.0.1 + resolution: "process-nextick-args@npm:2.0.1" + checksum: 1d38588e520dab7cea67cbbe2efdd86a10cc7a074c09657635e34f035277b59fbb57d09d8638346bf7090f8e8ebc070c96fa5fd183b777fff4f5edff5e9466cf + languageName: node + linkType: hard + +"proggy@npm:^2.0.0": + version: 2.0.0 + resolution: "proggy@npm:2.0.0" + checksum: 398f38c5e53d8f3dd8e1f67140dd1044dfde0a8e43edb2df55f7f38b958912841c78a970e61f2ee7222be4f3f1ee0da134e21d0eb537805cb1b10516555c7ac1 + languageName: node + linkType: hard + +"promise-all-reject-late@npm:^1.0.0": + version: 1.0.1 + resolution: "promise-all-reject-late@npm:1.0.1" + checksum: d7d61ac412352e2c8c3463caa5b1c3ca0f0cc3db15a09f180a3da1446e33d544c4261fc716f772b95e4c27d559cfd2388540f44104feb356584f9c73cfb9ffcb + languageName: node + linkType: hard + +"promise-call-limit@npm:^3.0.1": + version: 3.0.2 + resolution: "promise-call-limit@npm:3.0.2" + checksum: e1e2d57658bd57574959bd89733958f4e6940a6a5788d2f380a81f62f5660f88f93a7dd9f9eb3d09dc7c4927387e25c00ca941a3bdfce8fb050987d2d0ffe59a + languageName: node + linkType: hard + +"promise-inflight@npm:^1.0.1": + version: 1.0.1 + resolution: "promise-inflight@npm:1.0.1" + checksum: 22749483091d2c594261517f4f80e05226d4d5ecc1fc917e1886929da56e22b5718b7f2a75f3807e7a7d471bc3be2907fe92e6e8f373ddf5c64bae35b5af3981 + languageName: node + linkType: hard + +"promise-retry@npm:^2.0.1": + version: 2.0.1 + resolution: "promise-retry@npm:2.0.1" + dependencies: + err-code: ^2.0.2 + retry: ^0.12.0 + checksum: f96a3f6d90b92b568a26f71e966cbbc0f63ab85ea6ff6c81284dc869b41510e6cdef99b6b65f9030f0db422bf7c96652a3fff9f2e8fb4a0f069d8f4430359429 + languageName: node + linkType: hard + +"promise@npm:^8.3.0": + version: 8.3.0 + resolution: "promise@npm:8.3.0" + dependencies: + asap: ~2.0.6 + checksum: a69f0ddbddf78ffc529cffee7ad950d307347615970564b17988ce43fbe767af5c738a9439660b24a9a8cbea106c0dcbb6c2b20e23b7e96a8e89e5c2679e94d5 + languageName: node + linkType: hard + +"promzard@npm:^1.0.0": + version: 1.0.2 + resolution: "promzard@npm:1.0.2" + dependencies: + read: ^3.0.1 + checksum: 08dee9179e79d4a6446f707cce46fb3e8e8d93ec8b8d722ddc1ec4043c4c07e2e88dc90c64326a58f83d1a7e2b0d6b3bdf11b8b2687b9c74bfb410bafe630ad8 + languageName: node + linkType: hard + +"protocols@npm:^2.0.0, protocols@npm:^2.0.1": + version: 2.0.2 + resolution: "protocols@npm:2.0.2" + checksum: 031cc068eb800468a50eb7c1e1c528bf142fb8314f5df9b9ea3c3f9df1697a19f97b9915b1229cef694d156812393172d9c3051ef7878d26eaa8c6faa5cccec4 + languageName: node + linkType: hard + +"proxy-from-env@npm:^1.1.0": + version: 1.1.0 + resolution: "proxy-from-env@npm:1.1.0" + checksum: ed7fcc2ba0a33404958e34d95d18638249a68c430e30fcb6c478497d72739ba64ce9810a24f53a7d921d0c065e5b78e3822759800698167256b04659366ca4d4 + languageName: node + linkType: hard + +"punycode@npm:^2.1.0": + version: 2.3.1 + resolution: "punycode@npm:2.3.1" + checksum: bb0a0ceedca4c3c57a9b981b90601579058903c62be23c5e8e843d2c2d4148a3ecf029d5133486fb0e1822b098ba8bba09e89d6b21742d02fa26bda6441a6fb2 + languageName: node + linkType: hard + +"queue-microtask@npm:^1.2.2": + version: 1.2.3 + resolution: "queue-microtask@npm:1.2.3" + checksum: b676f8c040cdc5b12723ad2f91414d267605b26419d5c821ff03befa817ddd10e238d22b25d604920340fd73efd8ba795465a0377c4adf45a4a41e4234e42dc4 + languageName: node + linkType: hard + +"queue@npm:6.0.2": + version: 6.0.2 + resolution: "queue@npm:6.0.2" + dependencies: + inherits: ~2.0.3 + checksum: ebc23639248e4fe40a789f713c20548e513e053b3dc4924b6cb0ad741e3f264dcff948225c8737834dd4f9ec286dbc06a1a7c13858ea382d9379f4303bcc0916 + languageName: node + linkType: hard + +"quick-lru@npm:^4.0.1": + version: 4.0.1 + resolution: "quick-lru@npm:4.0.1" + checksum: bea46e1abfaa07023e047d3cf1716a06172c4947886c053ede5c50321893711577cb6119360f810cc3ffcd70c4d7db4069c3cee876b358ceff8596e062bd1154 + languageName: node + linkType: hard + +"quick-lru@npm:^5.1.1": + version: 5.1.1 + resolution: "quick-lru@npm:5.1.1" + checksum: a516faa25574be7947969883e6068dbe4aa19e8ef8e8e0fd96cddd6d36485e9106d85c0041a27153286b0770b381328f4072aa40d3b18a19f5f7d2b78b94b5ed + languageName: node + linkType: hard + +"range-parser@npm:~1.2.1": + version: 1.2.1 + resolution: "range-parser@npm:1.2.1" + checksum: 0a268d4fea508661cf5743dfe3d5f47ce214fd6b7dec1de0da4d669dd4ef3d2144468ebe4179049eff253d9d27e719c88dae55be64f954e80135a0cada804ec9 + languageName: node + linkType: hard + +"react-devtools-core@npm:^6.1.5": + version: 6.1.5 + resolution: "react-devtools-core@npm:6.1.5" + dependencies: + shell-quote: ^1.6.1 + ws: ^7 + checksum: b54f2d2416f5f5ca61b1741367865eab18b0040d7e4b3236693595803dfdf82ae02adbcb480acc5b9767748b615a2d5ce3af286cde3a7f8c193123c62c777428 + languageName: node + linkType: hard + +"react-is@npm:^18.0.0": + version: 18.3.1 + resolution: "react-is@npm:18.3.1" + checksum: e20fe84c86ff172fc8d898251b7cc2c43645d108bf96d0b8edf39b98f9a2cae97b40520ee7ed8ee0085ccc94736c4886294456033304151c3f94978cec03df21 + languageName: node + linkType: hard + +"react-native-nitro-modules@npm:^0.31.10": + version: 0.31.10 + resolution: "react-native-nitro-modules@npm:0.31.10" + peerDependencies: + react: "*" + react-native: "*" + checksum: 1c8d2f12ff29b6733c784c5fd0dedcbec4ceec9f82eb49f49e7734f31b9367346938010dcacf69bf695a352e36fec5aa23fd57b8091f6acd78c3d9a61e14ea7f + languageName: node + linkType: hard + +"react-native@npm:0.83.1": + version: 0.83.1 + resolution: "react-native@npm:0.83.1" + dependencies: + "@jest/create-cache-key-function": ^29.7.0 + "@react-native/assets-registry": 0.83.1 + "@react-native/codegen": 0.83.1 + "@react-native/community-cli-plugin": 0.83.1 + "@react-native/gradle-plugin": 0.83.1 + "@react-native/js-polyfills": 0.83.1 + "@react-native/normalize-colors": 0.83.1 + "@react-native/virtualized-lists": 0.83.1 + abort-controller: ^3.0.0 + anser: ^1.4.9 + ansi-regex: ^5.0.0 + babel-jest: ^29.7.0 + babel-plugin-syntax-hermes-parser: 0.32.0 + base64-js: ^1.5.1 + commander: ^12.0.0 + flow-enums-runtime: ^0.0.6 + glob: ^7.1.1 + hermes-compiler: 0.14.0 + invariant: ^2.2.4 + jest-environment-node: ^29.7.0 + memoize-one: ^5.0.0 + metro-runtime: ^0.83.3 + metro-source-map: ^0.83.3 + nullthrows: ^1.1.1 + pretty-format: ^29.7.0 + promise: ^8.3.0 + react-devtools-core: ^6.1.5 + react-refresh: ^0.14.0 + regenerator-runtime: ^0.13.2 + scheduler: 0.27.0 + semver: ^7.1.3 + stacktrace-parser: ^0.1.10 + whatwg-fetch: ^3.0.0 + ws: ^7.5.10 + yargs: ^17.6.2 + peerDependencies: + "@types/react": ^19.1.1 + react: ^19.2.0 + peerDependenciesMeta: + "@types/react": + optional: true + bin: + react-native: cli.js + checksum: 9de956e38287afb5d989a1dde5d5488d6c2499f6acb90d07a9526e92c0822b0c9884cd871cfe42daacc2f006bc95acc8d395ba794af415758b2a8a7e8ca1cce8 + languageName: node + linkType: hard + +"react-refresh@npm:^0.14.0": + version: 0.14.2 + resolution: "react-refresh@npm:0.14.2" + checksum: d80db4bd40a36dab79010dc8aa317a5b931f960c0d83c4f3b81f0552cbcf7f29e115b84bb7908ec6a1eb67720fff7023084eff73ece8a7ddc694882478464382 + languageName: node + linkType: hard + +"react@npm:19.2.0": + version: 19.2.0 + resolution: "react@npm:19.2.0" + checksum: 33dd01bf699e1c5040eb249e0f552519adf7ee90b98c49d702a50bf23af6852ea46023a5f7f93966ab10acd7a45428fa0f193c686ecdaa7a75a03886e53ec3fe + languageName: node + linkType: hard + +"read-cmd-shim@npm:4.0.0, read-cmd-shim@npm:^4.0.0": + version: 4.0.0 + resolution: "read-cmd-shim@npm:4.0.0" + checksum: 2fb5a8a38984088476f559b17c6a73324a5db4e77e210ae0aab6270480fd85c355fc990d1c79102e25e555a8201606ed12844d6e3cd9f35d6a1518791184e05b + languageName: node + linkType: hard + +"read-package-json-fast@npm:^3.0.0, read-package-json-fast@npm:^3.0.2": + version: 3.0.2 + resolution: "read-package-json-fast@npm:3.0.2" + dependencies: + json-parse-even-better-errors: ^3.0.0 + npm-normalize-package-bin: ^3.0.0 + checksum: 8d406869f045f1d76e2a99865a8fd1c1af9c1dc06200b94d2b07eef87ed734b22703a8d72e1cd36ea36cc48e22020bdd187f88243c7dd0563f72114d38c17072 + languageName: node + linkType: hard + +"read-pkg-up@npm:^3.0.0": + version: 3.0.0 + resolution: "read-pkg-up@npm:3.0.0" + dependencies: + find-up: ^2.0.0 + read-pkg: ^3.0.0 + checksum: 16175573f2914ab9788897bcbe2a62b5728d0075e62285b3680cebe97059e2911e0134a062cf6e51ebe3e3775312bc788ac2039ed6af38ec68d2c10c6f2b30fb + languageName: node + linkType: hard + +"read-pkg-up@npm:^7.0.1": + version: 7.0.1 + resolution: "read-pkg-up@npm:7.0.1" + dependencies: + find-up: ^4.1.0 + read-pkg: ^5.2.0 + type-fest: ^0.8.1 + checksum: e4e93ce70e5905b490ca8f883eb9e48b5d3cebc6cd4527c25a0d8f3ae2903bd4121c5ab9c5a3e217ada0141098eeb661313c86fa008524b089b8ed0b7f165e44 + languageName: node + linkType: hard + +"read-pkg-up@npm:^8.0.0": + version: 8.0.0 + resolution: "read-pkg-up@npm:8.0.0" + dependencies: + find-up: ^5.0.0 + read-pkg: ^6.0.0 + type-fest: ^1.0.1 + checksum: fe4c80401656b40b408884457fffb5a8015c03b1018cfd8e48f8d82a5e9023e24963603aeb2755608d964593e046c15b34d29b07d35af9c7aa478be81805209c + languageName: node + linkType: hard + +"read-pkg@npm:^3.0.0": + version: 3.0.0 + resolution: "read-pkg@npm:3.0.0" + dependencies: + load-json-file: ^4.0.0 + normalize-package-data: ^2.3.2 + path-type: ^3.0.0 + checksum: 398903ebae6c7e9965419a1062924436cc0b6f516c42c4679a90290d2f87448ed8f977e7aa2dbba4aa1ac09248628c43e493ac25b2bc76640e946035200e34c6 + languageName: node + linkType: hard + +"read-pkg@npm:^5.2.0": + version: 5.2.0 + resolution: "read-pkg@npm:5.2.0" + dependencies: + "@types/normalize-package-data": ^2.4.0 + normalize-package-data: ^2.5.0 + parse-json: ^5.0.0 + type-fest: ^0.6.0 + checksum: eb696e60528b29aebe10e499ba93f44991908c57d70f2d26f369e46b8b9afc208ef11b4ba64f67630f31df8b6872129e0a8933c8c53b7b4daf0eace536901222 + languageName: node + linkType: hard + +"read-pkg@npm:^6.0.0": + version: 6.0.0 + resolution: "read-pkg@npm:6.0.0" + dependencies: + "@types/normalize-package-data": ^2.4.0 + normalize-package-data: ^3.0.2 + parse-json: ^5.2.0 + type-fest: ^1.0.1 + checksum: 0cebdff381128e923815c643074a87011070e5fc352bee575d327d6485da3317fab6d802a7b03deeb0be7be8d3ad1640397b3d5d2f044452caf4e8d1736bf94f + languageName: node + linkType: hard + +"read@npm:^3.0.1": + version: 3.0.1 + resolution: "read@npm:3.0.1" + dependencies: + mute-stream: ^1.0.0 + checksum: 65fdc31c18f457b08a4f6eea3624cbbe82f82d5f297f256062278627ed897381d1637dd494ba7419dd3c5ed73fb21a4cef1342748c6e108b0f8fc7f627a0b281 + languageName: node + linkType: hard + +"readable-stream@npm:3, readable-stream@npm:^3.0.0, readable-stream@npm:^3.0.2, readable-stream@npm:^3.1.1, readable-stream@npm:^3.4.0": + version: 3.6.2 + resolution: "readable-stream@npm:3.6.2" + dependencies: + inherits: ^2.0.3 + string_decoder: ^1.1.1 + util-deprecate: ^1.0.1 + checksum: bdcbe6c22e846b6af075e32cf8f4751c2576238c5043169a1c221c92ee2878458a816a4ea33f4c67623c0b6827c8a400409bfb3cf0bf3381392d0b1dfb52ac8d + languageName: node + linkType: hard + +"readable-stream@npm:~2.3.6": + version: 2.3.8 + resolution: "readable-stream@npm:2.3.8" + dependencies: + core-util-is: ~1.0.0 + inherits: ~2.0.3 + isarray: ~1.0.0 + process-nextick-args: ~2.0.0 + safe-buffer: ~5.1.1 + string_decoder: ~1.1.1 + util-deprecate: ~1.0.1 + checksum: 65645467038704f0c8aaf026a72fbb588a9e2ef7a75cd57a01702ee9db1c4a1e4b03aaad36861a6a0926546a74d174149c8c207527963e0c2d3eee2f37678a42 + languageName: node + linkType: hard + +"redent@npm:^3.0.0": + version: 3.0.0 + resolution: "redent@npm:3.0.0" + dependencies: + indent-string: ^4.0.0 + strip-indent: ^3.0.0 + checksum: fa1ef20404a2d399235e83cc80bd55a956642e37dd197b4b612ba7327bf87fa32745aeb4a1634b2bab25467164ab4ed9c15be2c307923dd08b0fe7c52431ae6b + languageName: node + linkType: hard + +"redent@npm:^4.0.0": + version: 4.0.0 + resolution: "redent@npm:4.0.0" + dependencies: + indent-string: ^5.0.0 + strip-indent: ^4.0.0 + checksum: 6944e7b1d8f3fd28c2515f5c605b9f7f0ea0f4edddf41890bbbdd4d9ee35abb7540c3b278f03ff827bd278bb6ff4a5bd8692ca406b748c5c1c3ce7355e9fbf8f + languageName: node + linkType: hard + +"regenerator-runtime@npm:^0.13.2": + version: 0.13.11 + resolution: "regenerator-runtime@npm:0.13.11" + checksum: 27481628d22a1c4e3ff551096a683b424242a216fee44685467307f14d58020af1e19660bf2e26064de946bad7eff28950eae9f8209d55723e2d9351e632bbb4 + languageName: node + linkType: hard + +"require-directory@npm:^2.1.1": + version: 2.1.1 + resolution: "require-directory@npm:2.1.1" + checksum: fb47e70bf0001fdeabdc0429d431863e9475e7e43ea5f94ad86503d918423c1543361cc5166d713eaa7029dd7a3d34775af04764bebff99ef413111a5af18c80 + languageName: node + linkType: hard + +"require-from-string@npm:^2.0.2": + version: 2.0.2 + resolution: "require-from-string@npm:2.0.2" + checksum: a03ef6895445f33a4015300c426699bc66b2b044ba7b670aa238610381b56d3f07c686251740d575e22f4c87531ba662d06937508f0f3c0f1ddc04db3130560b + languageName: node + linkType: hard + +"resolve-cwd@npm:^3.0.0": + version: 3.0.0 + resolution: "resolve-cwd@npm:3.0.0" + dependencies: + resolve-from: ^5.0.0 + checksum: 546e0816012d65778e580ad62b29e975a642989108d9a3c5beabfb2304192fa3c9f9146fbdfe213563c6ff51975ae41bac1d3c6e047dd9572c94863a057b4d81 + languageName: node + linkType: hard + +"resolve-from@npm:5.0.0, resolve-from@npm:^5.0.0": + version: 5.0.0 + resolution: "resolve-from@npm:5.0.0" + checksum: 4ceeb9113e1b1372d0cd969f3468fa042daa1dd9527b1b6bb88acb6ab55d8b9cd65dbf18819f9f9ddf0db804990901dcdaade80a215e7b2c23daae38e64f5bdf + languageName: node + linkType: hard + +"resolve-from@npm:^4.0.0": + version: 4.0.0 + resolution: "resolve-from@npm:4.0.0" + checksum: f4ba0b8494846a5066328ad33ef8ac173801a51739eb4d63408c847da9a2e1c1de1e6cbbf72699211f3d13f8fc1325648b169bd15eb7da35688e30a5fb0e4a7f + languageName: node + linkType: hard + +"resolve-global@npm:1.0.0, resolve-global@npm:^1.0.0": + version: 1.0.0 + resolution: "resolve-global@npm:1.0.0" + dependencies: + global-dirs: ^0.1.1 + checksum: c4e11d33e84bde7516b824503ffbe4b6cce863d5ce485680fd3db997b7c64da1df98321b1fd0703b58be8bc9bc83bc96bd83043f96194386b45eb47229efb6b6 + languageName: node + linkType: hard + +"resolve.exports@npm:2.0.3": + version: 2.0.3 + resolution: "resolve.exports@npm:2.0.3" + checksum: abfb9f98278dcd0c19b8a49bb486abfafa23df4636d49128ea270dc982053c3ef230a530aecda1fae1322873fdfa6c97674fc539651ddfdb375ac58e0b8ef6df + languageName: node + linkType: hard + +"resolve@npm:^1.10.0": + version: 1.22.11 + resolution: "resolve@npm:1.22.11" + dependencies: + is-core-module: ^2.16.1 + path-parse: ^1.0.7 + supports-preserve-symlinks-flag: ^1.0.0 + bin: + resolve: bin/resolve + checksum: 6d5baa2156b95a65ac431e7642e21106584e9f4194da50871cae8bc1bbd2b53bb7cee573c92543d83bb999620b224a087f62379d800ed1ccb189da6df5d78d50 + languageName: node + linkType: hard + +"resolve@patch:resolve@^1.10.0#~builtin": + version: 1.22.11 + resolution: "resolve@patch:resolve@npm%3A1.22.11#~builtin::version=1.22.11&hash=c3c19d" + dependencies: + is-core-module: ^2.16.1 + path-parse: ^1.0.7 + supports-preserve-symlinks-flag: ^1.0.0 + bin: + resolve: bin/resolve + checksum: 1462da84ac3410d7c2e12e4f5f25c1423d8a174c3b4245c43eafea85e7bbe6af3eb7ec10a4850b5e518e8531608604742b8cbd761e1acd7ad1035108b7c98013 + languageName: node + linkType: hard + +"restore-cursor@npm:^3.1.0": + version: 3.1.0 + resolution: "restore-cursor@npm:3.1.0" + dependencies: + onetime: ^5.1.0 + signal-exit: ^3.0.2 + checksum: f877dd8741796b909f2a82454ec111afb84eb45890eb49ac947d87991379406b3b83ff9673a46012fca0d7844bb989f45cc5b788254cf1a39b6b5a9659de0630 + languageName: node + linkType: hard + +"retry@npm:^0.12.0": + version: 0.12.0 + resolution: "retry@npm:0.12.0" + checksum: 623bd7d2e5119467ba66202d733ec3c2e2e26568074923bc0585b6b99db14f357e79bdedb63cab56cec47491c4a0da7e6021a7465ca6dc4f481d3898fdd3158c + languageName: node + linkType: hard + +"reusify@npm:^1.0.4": + version: 1.1.0 + resolution: "reusify@npm:1.1.0" + checksum: 64cb3142ac5e9ad689aca289585cb41d22521f4571f73e9488af39f6b1bd62f0cbb3d65e2ecc768ec6494052523f473f1eb4b55c3e9014b3590c17fc6a03e22a + languageName: node + linkType: hard + +"rimraf@npm:^3.0.2": + version: 3.0.2 + resolution: "rimraf@npm:3.0.2" + dependencies: + glob: ^7.1.3 + bin: + rimraf: bin.js + checksum: 87f4164e396f0171b0a3386cc1877a817f572148ee13a7e113b238e48e8a9f2f31d009a92ec38a591ff1567d9662c6b67fd8818a2dbbaed74bc26a87a2a4a9a0 + languageName: node + linkType: hard + +"rimraf@npm:^4.4.1": + version: 4.4.1 + resolution: "rimraf@npm:4.4.1" + dependencies: + glob: ^9.2.0 + bin: + rimraf: dist/cjs/src/bin.js + checksum: b786adc02651e2e24bbedb04bbdea80652fc9612632931ff2d9f898c5e4708fe30956186597373c568bd5230a4dc2fadfc816ccacba8a1daded3a006a6b74f1a + languageName: node + linkType: hard + +"run-async@npm:^2.4.0": + version: 2.4.1 + resolution: "run-async@npm:2.4.1" + checksum: a2c88aa15df176f091a2878eb840e68d0bdee319d8d97bbb89112223259cebecb94bc0defd735662b83c2f7a30bed8cddb7d1674eb48ae7322dc602b22d03797 + languageName: node + linkType: hard + +"run-parallel@npm:^1.1.9": + version: 1.2.0 + resolution: "run-parallel@npm:1.2.0" + dependencies: + queue-microtask: ^1.2.2 + checksum: cb4f97ad25a75ebc11a8ef4e33bb962f8af8516bb2001082ceabd8902e15b98f4b84b4f8a9b222e5d57fc3bd1379c483886ed4619367a7680dad65316993021d + languageName: node + linkType: hard + +"runanywhere-react-native-monorepo@workspace:.": + version: 0.0.0-use.local + resolution: "runanywhere-react-native-monorepo@workspace:." + dependencies: + "@commitlint/config-conventional": ^17.0.2 + "@evilmartians/lefthook": ^1.5.0 + "@types/node": ^24.10.0 + "@types/react": ~19.1.0 + "@typescript-eslint/eslint-plugin": ^8.50.0 + "@typescript-eslint/parser": ^8.50.0 + commitlint: ^17.0.2 + del-cli: ^5.1.0 + eslint: ^8.51.0 + eslint-config-prettier: ^9.0.0 + eslint-plugin-prettier: ^5.0.1 + lerna: ^8.0.0 + prettier: ^3.0.3 + react: 19.2.0 + react-native: 0.83.1 + typescript: ~5.9.2 + languageName: unknown + linkType: soft + +"rxjs@npm:^7.5.5": + version: 7.8.2 + resolution: "rxjs@npm:7.8.2" + dependencies: + tslib: ^2.1.0 + checksum: 2f233d7c832a6c255dabe0759014d7d9b1c9f1cb2f2f0d59690fd11c883c9826ea35a51740c06ab45b6ade0d9087bde9192f165cba20b6730d344b831ef80744 + languageName: node + linkType: hard + +"safe-buffer@npm:~5.1.0, safe-buffer@npm:~5.1.1": + version: 5.1.2 + resolution: "safe-buffer@npm:5.1.2" + checksum: f2f1f7943ca44a594893a852894055cf619c1fbcb611237fc39e461ae751187e7baf4dc391a72125e0ac4fb2d8c5c0b3c71529622e6a58f46b960211e704903c + languageName: node + linkType: hard + +"safe-buffer@npm:~5.2.0": + version: 5.2.1 + resolution: "safe-buffer@npm:5.2.1" + checksum: b99c4b41fdd67a6aaf280fcd05e9ffb0813654894223afb78a31f14a19ad220bba8aba1cb14eddce1fcfb037155fe6de4e861784eb434f7d11ed58d1e70dd491 + languageName: node + linkType: hard + +"safer-buffer@npm:>= 2.1.2 < 3.0.0": + version: 2.1.2 + resolution: "safer-buffer@npm:2.1.2" + checksum: cab8f25ae6f1434abee8d80023d7e72b598cf1327164ddab31003c51215526801e40b66c5e65d658a0af1e9d6478cadcb4c745f4bd6751f97d8644786c0978b0 + languageName: node + linkType: hard + +"scheduler@npm:0.27.0": + version: 0.27.0 + resolution: "scheduler@npm:0.27.0" + checksum: 92644ead0a9443e20f9d24132fe93675b156209b9eeb35ea245f8a86768d0cc0fcca56f341eeef21d9b6dd8e72d6d5e260eb5a41d34b05cd605dd45a29f572ef + languageName: node + linkType: hard + +"semver@npm:2 || 3 || 4 || 5, semver@npm:^5.6.0": + version: 5.7.2 + resolution: "semver@npm:5.7.2" + bin: + semver: bin/semver + checksum: fb4ab5e0dd1c22ce0c937ea390b4a822147a9c53dbd2a9a0132f12fe382902beef4fbf12cf51bb955248d8d15874ce8cd89532569756384f994309825f10b686 + languageName: node + linkType: hard + +"semver@npm:7.5.4": + version: 7.5.4 + resolution: "semver@npm:7.5.4" + dependencies: + lru-cache: ^6.0.0 + bin: + semver: bin/semver.js + checksum: 12d8ad952fa353b0995bf180cdac205a4068b759a140e5d3c608317098b3575ac2f1e09182206bf2eb26120e1c0ed8fb92c48c592f6099680de56bb071423ca3 + languageName: node + linkType: hard + +"semver@npm:^6.3.0, semver@npm:^6.3.1": + version: 6.3.1 + resolution: "semver@npm:6.3.1" + bin: + semver: bin/semver.js + checksum: ae47d06de28836adb9d3e25f22a92943477371292d9b665fb023fae278d345d508ca1958232af086d85e0155aee22e313e100971898bbb8d5d89b8b1d4054ca2 + languageName: node + linkType: hard + +"semver@npm:^7.0.0, semver@npm:^7.1.1, semver@npm:^7.1.3, semver@npm:^7.3.4, semver@npm:^7.3.5, semver@npm:^7.3.7, semver@npm:^7.3.8, semver@npm:^7.5.3, semver@npm:^7.7.3": + version: 7.7.3 + resolution: "semver@npm:7.7.3" + bin: + semver: bin/semver.js + checksum: f013a3ee4607857bcd3503b6ac1d80165f7f8ea94f5d55e2d3e33df82fce487aa3313b987abf9b39e0793c83c9fc67b76c36c067625141a9f6f704ae0ea18db2 + languageName: node + linkType: hard + +"send@npm:~0.19.1": + version: 0.19.2 + resolution: "send@npm:0.19.2" + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: ~2.0.0 + escape-html: ~1.0.3 + etag: ~1.8.1 + fresh: ~0.5.2 + http-errors: ~2.0.1 + mime: 1.6.0 + ms: 2.1.3 + on-finished: ~2.4.1 + range-parser: ~1.2.1 + statuses: ~2.0.2 + checksum: f9e11b718b48dbea72daa6a80e36e5a00fb6d01b1a6cfda8b3135c9ca9db84257738283da23371f437148ccd8f400e6171cd2a3642fb43fda462da407d9d30c0 + languageName: node + linkType: hard + +"serialize-error@npm:^2.1.0": + version: 2.1.0 + resolution: "serialize-error@npm:2.1.0" + checksum: 28464a6f65e6becd6e49fb782aff06573fdbf3d19f161a20228179842fed05c75a34110e54c3ee020b00240f9e11d8bee9b9fee5d04e0bc0bef1fdbf2baa297e + languageName: node + linkType: hard + +"serve-static@npm:^1.16.2": + version: 1.16.3 + resolution: "serve-static@npm:1.16.3" + dependencies: + encodeurl: ~2.0.0 + escape-html: ~1.0.3 + parseurl: ~1.3.3 + send: ~0.19.1 + checksum: ec7599540215e6676b223ea768bf7c256819180bf14f89d0b5d249a61bbb8f10b05b2a53048a153cb2cc7f3b367f1227d2fb715fe4b09d07299a9233eda1a453 + languageName: node + linkType: hard + +"set-blocking@npm:^2.0.0": + version: 2.0.0 + resolution: "set-blocking@npm:2.0.0" + checksum: 6e65a05f7cf7ebdf8b7c75b101e18c0b7e3dff4940d480efed8aad3a36a4005140b660fa1d804cb8bce911cac290441dc728084a30504d3516ac2ff7ad607b02 + languageName: node + linkType: hard + +"setprototypeof@npm:~1.2.0": + version: 1.2.0 + resolution: "setprototypeof@npm:1.2.0" + checksum: be18cbbf70e7d8097c97f713a2e76edf84e87299b40d085c6bf8b65314e994cc15e2e317727342fa6996e38e1f52c59720b53fe621e2eb593a6847bf0356db89 + languageName: node + linkType: hard + +"shallow-clone@npm:^3.0.0": + version: 3.0.1 + resolution: "shallow-clone@npm:3.0.1" + dependencies: + kind-of: ^6.0.2 + checksum: 39b3dd9630a774aba288a680e7d2901f5c0eae7b8387fc5c8ea559918b29b3da144b7bdb990d7ccd9e11be05508ac9e459ce51d01fd65e583282f6ffafcba2e7 + languageName: node + linkType: hard + +"shebang-command@npm:^2.0.0": + version: 2.0.0 + resolution: "shebang-command@npm:2.0.0" + dependencies: + shebang-regex: ^3.0.0 + checksum: 6b52fe87271c12968f6a054e60f6bde5f0f3d2db483a1e5c3e12d657c488a15474121a1d55cd958f6df026a54374ec38a4a963988c213b7570e1d51575cea7fa + languageName: node + linkType: hard + +"shebang-regex@npm:^3.0.0": + version: 3.0.0 + resolution: "shebang-regex@npm:3.0.0" + checksum: 1a2bcae50de99034fcd92ad4212d8e01eedf52c7ec7830eedcf886622804fe36884278f2be8be0ea5fde3fd1c23911643a4e0f726c8685b61871c8908af01222 + languageName: node + linkType: hard + +"shell-quote@npm:^1.6.1": + version: 1.8.3 + resolution: "shell-quote@npm:1.8.3" + checksum: 550dd84e677f8915eb013d43689c80bb114860649ec5298eb978f40b8f3d4bc4ccb072b82c094eb3548dc587144bb3965a8676f0d685c1cf4c40b5dc27166242 + languageName: node + linkType: hard + +"signal-exit@npm:3.0.7, signal-exit@npm:^3.0.2, signal-exit@npm:^3.0.3, signal-exit@npm:^3.0.7": + version: 3.0.7 + resolution: "signal-exit@npm:3.0.7" + checksum: a2f098f247adc367dffc27845853e9959b9e88b01cb301658cfe4194352d8d2bb32e18467c786a7fe15f1d44b233ea35633d076d5e737870b7139949d1ab6318 + languageName: node + linkType: hard + +"signal-exit@npm:^4.0.1": + version: 4.1.0 + resolution: "signal-exit@npm:4.1.0" + checksum: 64c757b498cb8629ffa5f75485340594d2f8189e9b08700e69199069c8e3070fb3e255f7ab873c05dc0b3cec412aea7402e10a5990cb6a050bd33ba062a6c549 + languageName: node + linkType: hard + +"sigstore@npm:^2.2.0": + version: 2.3.1 + resolution: "sigstore@npm:2.3.1" + dependencies: + "@sigstore/bundle": ^2.3.2 + "@sigstore/core": ^1.0.0 + "@sigstore/protobuf-specs": ^0.3.2 + "@sigstore/sign": ^2.3.2 + "@sigstore/tuf": ^2.3.4 + "@sigstore/verify": ^1.2.1 + checksum: 9e8c5e60dbe56591770fb26a0d0e987f1859d47d519532578540380d6464499bcd1f1765291d6a360d3ffe9aba171fc8b0c3e559931b0ea262140aff7e892296 + languageName: node + linkType: hard + +"slash@npm:3.0.0, slash@npm:^3.0.0": + version: 3.0.0 + resolution: "slash@npm:3.0.0" + checksum: 94a93fff615f25a999ad4b83c9d5e257a7280c90a32a7cb8b4a87996e4babf322e469c42b7f649fd5796edd8687652f3fb452a86dc97a816f01113183393f11c + languageName: node + linkType: hard + +"slash@npm:^4.0.0": + version: 4.0.0 + resolution: "slash@npm:4.0.0" + checksum: da8e4af73712253acd21b7853b7e0dbba776b786e82b010a5bfc8b5051a1db38ed8aba8e1e8f400dd2c9f373be91eb1c42b66e91abb407ff42b10feece5e1d2d + languageName: node + linkType: hard + +"smart-buffer@npm:^4.2.0": + version: 4.2.0 + resolution: "smart-buffer@npm:4.2.0" + checksum: b5167a7142c1da704c0e3af85c402002b597081dd9575031a90b4f229ca5678e9a36e8a374f1814c8156a725d17008ae3bde63b92f9cfd132526379e580bec8b + languageName: node + linkType: hard + +"socks-proxy-agent@npm:^8.0.3": + version: 8.0.5 + resolution: "socks-proxy-agent@npm:8.0.5" + dependencies: + agent-base: ^7.1.2 + debug: ^4.3.4 + socks: ^2.8.3 + checksum: b4fbcdb7ad2d6eec445926e255a1fb95c975db0020543fbac8dfa6c47aecc6b3b619b7fb9c60a3f82c9b2969912a5e7e174a056ae4d98cb5322f3524d6036e1d + languageName: node + linkType: hard + +"socks@npm:^2.8.3": + version: 2.8.7 + resolution: "socks@npm:2.8.7" + dependencies: + ip-address: ^10.0.1 + smart-buffer: ^4.2.0 + checksum: 4bbe2c88cf0eeaf49f94b7f11564a99b2571bde6fd1e714ff95b38f89e1f97858c19e0ab0e6d39eb7f6a984fa67366825895383ed563fe59962a1d57a1d55318 + languageName: node + linkType: hard + +"sort-keys@npm:^2.0.0": + version: 2.0.0 + resolution: "sort-keys@npm:2.0.0" + dependencies: + is-plain-obj: ^1.0.0 + checksum: f0fd827fa9f8f866e98588d2a38c35209afbf1e9a05bb0e4ceeeb8bbf31d923c8902b0a7e0f561590ddb65e58eba6a74f74b991c85360bcc52e83a3f0d1cffd7 + languageName: node + linkType: hard + +"source-map-support@npm:~0.5.20": + version: 0.5.21 + resolution: "source-map-support@npm:0.5.21" + dependencies: + buffer-from: ^1.0.0 + source-map: ^0.6.0 + checksum: 43e98d700d79af1d36f859bdb7318e601dfc918c7ba2e98456118ebc4c4872b327773e5a1df09b0524e9e5063bb18f0934538eace60cca2710d1fa687645d137 + languageName: node + linkType: hard + +"source-map@npm:^0.5.6": + version: 0.5.7 + resolution: "source-map@npm:0.5.7" + checksum: 5dc2043b93d2f194142c7f38f74a24670cd7a0063acdaf4bf01d2964b402257ae843c2a8fa822ad5b71013b5fcafa55af7421383da919752f22ff488bc553f4d + languageName: node + linkType: hard + +"source-map@npm:^0.6.0, source-map@npm:^0.6.1": + version: 0.6.1 + resolution: "source-map@npm:0.6.1" + checksum: 59ce8640cf3f3124f64ac289012c2b8bd377c238e316fb323ea22fbfe83da07d81e000071d7242cad7a23cd91c7de98e4df8830ec3f133cb6133a5f6e9f67bc2 + languageName: node + linkType: hard + +"spdx-correct@npm:^3.0.0": + version: 3.2.0 + resolution: "spdx-correct@npm:3.2.0" + dependencies: + spdx-expression-parse: ^3.0.0 + spdx-license-ids: ^3.0.0 + checksum: e9ae98d22f69c88e7aff5b8778dc01c361ef635580e82d29e5c60a6533cc8f4d820803e67d7432581af0cc4fb49973125076ee3b90df191d153e223c004193b2 + languageName: node + linkType: hard + +"spdx-exceptions@npm:^2.1.0": + version: 2.5.0 + resolution: "spdx-exceptions@npm:2.5.0" + checksum: bb127d6e2532de65b912f7c99fc66097cdea7d64c10d3ec9b5e96524dbbd7d20e01cba818a6ddb2ae75e62bb0c63d5e277a7e555a85cbc8ab40044984fa4ae15 + languageName: node + linkType: hard + +"spdx-expression-parse@npm:^3.0.0": + version: 3.0.1 + resolution: "spdx-expression-parse@npm:3.0.1" + dependencies: + spdx-exceptions: ^2.1.0 + spdx-license-ids: ^3.0.0 + checksum: a1c6e104a2cbada7a593eaa9f430bd5e148ef5290d4c0409899855ce8b1c39652bcc88a725259491a82601159d6dc790bedefc9016c7472f7de8de7361f8ccde + languageName: node + linkType: hard + +"spdx-license-ids@npm:^3.0.0": + version: 3.0.22 + resolution: "spdx-license-ids@npm:3.0.22" + checksum: 3810ce1ddd8c67d7cfa76a0af05157090a2d93e5bb93bd85bf9735f1fd8062c5b510423a4669dc7d8c34b0892b27a924b1c6f8965f85d852aa25062cceff5e29 + languageName: node + linkType: hard + +"split2@npm:^3.0.0, split2@npm:^3.2.2": + version: 3.2.2 + resolution: "split2@npm:3.2.2" + dependencies: + readable-stream: ^3.0.0 + checksum: 8127ddbedd0faf31f232c0e9192fede469913aa8982aa380752e0463b2e31c2359ef6962eb2d24c125bac59eeec76873678d723b1c7ff696216a1cd071e3994a + languageName: node + linkType: hard + +"split@npm:^1.0.1": + version: 1.0.1 + resolution: "split@npm:1.0.1" + dependencies: + through: 2 + checksum: 12f4554a5792c7e98bb3e22b53c63bfa5ef89aa704353e1db608a55b51f5b12afaad6e4a8ecf7843c15f273f43cdadd67b3705cc43d48a75c2cf4641d51f7e7a + languageName: node + linkType: hard + +"sprintf-js@npm:~1.0.2": + version: 1.0.3 + resolution: "sprintf-js@npm:1.0.3" + checksum: 19d79aec211f09b99ec3099b5b2ae2f6e9cdefe50bc91ac4c69144b6d3928a640bb6ae5b3def70c2e85a2c3d9f5ec2719921e3a59d3ca3ef4b2fd1a4656a0df3 + languageName: node + linkType: hard + +"ssri@npm:^10.0.0, ssri@npm:^10.0.6": + version: 10.0.6 + resolution: "ssri@npm:10.0.6" + dependencies: + minipass: ^7.0.3 + checksum: 4603d53a05bcd44188747d38f1cc43833b9951b5a1ee43ba50535bdfc5fe4a0897472dbe69837570a5417c3c073377ef4f8c1a272683b401857f72738ee57299 + languageName: node + linkType: hard + +"ssri@npm:^13.0.0": + version: 13.0.0 + resolution: "ssri@npm:13.0.0" + dependencies: + minipass: ^7.0.3 + checksum: 9705dff9e686b11f3035fb4c3d44ce690359a15a54adcd6a18951f2763f670877321178dc72c37a2b804dba3287ecaa48726dbd0cff79b2715b1cc24521b3af3 + languageName: node + linkType: hard + +"stack-utils@npm:^2.0.3": + version: 2.0.6 + resolution: "stack-utils@npm:2.0.6" + dependencies: + escape-string-regexp: ^2.0.0 + checksum: 052bf4d25bbf5f78e06c1d5e67de2e088b06871fa04107ca8d3f0e9d9263326e2942c8bedee3545795fc77d787d443a538345eef74db2f8e35db3558c6f91ff7 + languageName: node + linkType: hard + +"stackframe@npm:^1.3.4": + version: 1.3.4 + resolution: "stackframe@npm:1.3.4" + checksum: bae1596873595c4610993fa84f86a3387d67586401c1816ea048c0196800c0646c4d2da98c2ee80557fd9eff05877efe33b91ba6cd052658ed96ddc85d19067d + languageName: node + linkType: hard + +"stacktrace-parser@npm:^0.1.10": + version: 0.1.11 + resolution: "stacktrace-parser@npm:0.1.11" + dependencies: + type-fest: ^0.7.1 + checksum: 1120cf716606ec6a8e25cc9b6ada79d7b91e6a599bba1a6664e6badc8b5f37987d7df7d9ad0344f717a042781fd8e1e999de08614a5afea451b68902421036b5 + languageName: node + linkType: hard + +"statuses@npm:~1.5.0": + version: 1.5.0 + resolution: "statuses@npm:1.5.0" + checksum: c469b9519de16a4bb19600205cffb39ee471a5f17b82589757ca7bd40a8d92ebb6ed9f98b5a540c5d302ccbc78f15dc03cc0280dd6e00df1335568a5d5758a5c + languageName: node + linkType: hard + +"statuses@npm:~2.0.2": + version: 2.0.2 + resolution: "statuses@npm:2.0.2" + checksum: 6927feb50c2a75b2a4caab2c565491f7a93ad3d8dbad7b1398d52359e9243a20e2ebe35e33726dee945125ef7a515e9097d8a1b910ba2bbd818265a2f6c39879 + languageName: node + linkType: hard + +"string-width-cjs@npm:string-width@^4.2.0, string-width@npm:^1.0.2 || 2 || 3 || 4, string-width@npm:^4.1.0, string-width@npm:^4.2.0, string-width@npm:^4.2.3": + version: 4.2.3 + resolution: "string-width@npm:4.2.3" + dependencies: + emoji-regex: ^8.0.0 + is-fullwidth-code-point: ^3.0.0 + strip-ansi: ^6.0.1 + checksum: e52c10dc3fbfcd6c3a15f159f54a90024241d0f149cf8aed2982a2d801d2e64df0bf1dc351cf8e95c3319323f9f220c16e740b06faecd53e2462df1d2b5443fb + languageName: node + linkType: hard + +"string-width@npm:^5.0.1, string-width@npm:^5.1.2": + version: 5.1.2 + resolution: "string-width@npm:5.1.2" + dependencies: + eastasianwidth: ^0.2.0 + emoji-regex: ^9.2.2 + strip-ansi: ^7.0.1 + checksum: 7369deaa29f21dda9a438686154b62c2c5f661f8dda60449088f9f980196f7908fc39fdd1803e3e01541970287cf5deae336798337e9319a7055af89dafa7193 + languageName: node + linkType: hard + +"string-width@npm:^7.0.0, string-width@npm:^7.2.0": + version: 7.2.0 + resolution: "string-width@npm:7.2.0" + dependencies: + emoji-regex: ^10.3.0 + get-east-asian-width: ^1.0.0 + strip-ansi: ^7.1.0 + checksum: 42f9e82f61314904a81393f6ef75b832c39f39761797250de68c041d8ba4df2ef80db49ab6cd3a292923a6f0f409b8c9980d120f7d32c820b4a8a84a2598a295 + languageName: node + linkType: hard + +"string_decoder@npm:^1.1.1": + version: 1.3.0 + resolution: "string_decoder@npm:1.3.0" + dependencies: + safe-buffer: ~5.2.0 + checksum: 8417646695a66e73aefc4420eb3b84cc9ffd89572861fe004e6aeb13c7bc00e2f616247505d2dbbef24247c372f70268f594af7126f43548565c68c117bdeb56 + languageName: node + linkType: hard + +"string_decoder@npm:~1.1.1": + version: 1.1.1 + resolution: "string_decoder@npm:1.1.1" + dependencies: + safe-buffer: ~5.1.0 + checksum: 9ab7e56f9d60a28f2be697419917c50cac19f3e8e6c28ef26ed5f4852289fe0de5d6997d29becf59028556f2c62983790c1d9ba1e2a3cc401768ca12d5183a5b + languageName: node + linkType: hard + +"strip-ansi-cjs@npm:strip-ansi@^6.0.1, strip-ansi@npm:^6.0.0, strip-ansi@npm:^6.0.1": + version: 6.0.1 + resolution: "strip-ansi@npm:6.0.1" + dependencies: + ansi-regex: ^5.0.1 + checksum: f3cd25890aef3ba6e1a74e20896c21a46f482e93df4a06567cebf2b57edabb15133f1f94e57434e0a958d61186087b1008e89c94875d019910a213181a14fc8c + languageName: node + linkType: hard + +"strip-ansi@npm:^7.0.1, strip-ansi@npm:^7.1.0": + version: 7.1.2 + resolution: "strip-ansi@npm:7.1.2" + dependencies: + ansi-regex: ^6.0.1 + checksum: db0e3f9654e519c8a33c50fc9304d07df5649388e7da06d3aabf66d29e5ad65d5e6315d8519d409c15b32fa82c1df7e11ed6f8cd50b0e4404463f0c9d77c8d0b + languageName: node + linkType: hard + +"strip-bom@npm:^3.0.0": + version: 3.0.0 + resolution: "strip-bom@npm:3.0.0" + checksum: 8d50ff27b7ebe5ecc78f1fe1e00fcdff7af014e73cf724b46fb81ef889eeb1015fc5184b64e81a2efe002180f3ba431bdd77e300da5c6685d702780fbf0c8d5b + languageName: node + linkType: hard + +"strip-bom@npm:^4.0.0": + version: 4.0.0 + resolution: "strip-bom@npm:4.0.0" + checksum: 9dbcfbaf503c57c06af15fe2c8176fb1bf3af5ff65003851a102749f875a6dbe0ab3b30115eccf6e805e9d756830d3e40ec508b62b3f1ddf3761a20ebe29d3f3 + languageName: node + linkType: hard + +"strip-final-newline@npm:^2.0.0": + version: 2.0.0 + resolution: "strip-final-newline@npm:2.0.0" + checksum: 69412b5e25731e1938184b5d489c32e340605bb611d6140344abc3421b7f3c6f9984b21dff296dfcf056681b82caa3bb4cc996a965ce37bcfad663e92eae9c64 + languageName: node + linkType: hard + +"strip-indent@npm:^3.0.0": + version: 3.0.0 + resolution: "strip-indent@npm:3.0.0" + dependencies: + min-indent: ^1.0.0 + checksum: 18f045d57d9d0d90cd16f72b2313d6364fd2cb4bf85b9f593523ad431c8720011a4d5f08b6591c9d580f446e78855c5334a30fb91aa1560f5d9f95ed1b4a0530 + languageName: node + linkType: hard + +"strip-indent@npm:^4.0.0": + version: 4.1.1 + resolution: "strip-indent@npm:4.1.1" + checksum: d322bfdc59855006791a4aebe2a66e0892eab7004a5c064d74b86a0c6ecff2818974c9a5eda54b16d8af6aadbc90a6c02635ffcbec11ab33dd8979b1a6346fc0 + languageName: node + linkType: hard + +"strip-json-comments@npm:^3.1.1": + version: 3.1.1 + resolution: "strip-json-comments@npm:3.1.1" + checksum: 492f73e27268f9b1c122733f28ecb0e7e8d8a531a6662efbd08e22cccb3f9475e90a1b82cab06a392f6afae6d2de636f977e231296400d0ec5304ba70f166443 + languageName: node + linkType: hard + +"supports-color@npm:^7.1.0": + version: 7.2.0 + resolution: "supports-color@npm:7.2.0" + dependencies: + has-flag: ^4.0.0 + checksum: 3dda818de06ebbe5b9653e07842d9479f3555ebc77e9a0280caf5a14fb877ffee9ed57007c3b78f5a6324b8dbeec648d9e97a24e2ed9fdb81ddc69ea07100f4a + languageName: node + linkType: hard + +"supports-color@npm:^8.0.0": + version: 8.1.1 + resolution: "supports-color@npm:8.1.1" + dependencies: + has-flag: ^4.0.0 + checksum: c052193a7e43c6cdc741eb7f378df605636e01ad434badf7324f17fb60c69a880d8d8fcdcb562cf94c2350e57b937d7425ab5b8326c67c2adc48f7c87c1db406 + languageName: node + linkType: hard + +"supports-preserve-symlinks-flag@npm:^1.0.0": + version: 1.0.0 + resolution: "supports-preserve-symlinks-flag@npm:1.0.0" + checksum: 53b1e247e68e05db7b3808b99b892bd36fb096e6fba213a06da7fab22045e97597db425c724f2bbd6c99a3c295e1e73f3e4de78592289f38431049e1277ca0ae + languageName: node + linkType: hard + +"synckit@npm:^0.11.12": + version: 0.11.12 + resolution: "synckit@npm:0.11.12" + dependencies: + "@pkgr/core": ^0.2.9 + checksum: a53fb563d01ba8912a111b883fc3c701e267896ff8273e7aba9001f5f74711e125888f4039e93060795cd416122cf492ae419eb10a6a3e3b00e830917669d2cf + languageName: node + linkType: hard + +"tar-stream@npm:~2.2.0": + version: 2.2.0 + resolution: "tar-stream@npm:2.2.0" + dependencies: + bl: ^4.0.3 + end-of-stream: ^1.4.1 + fs-constants: ^1.0.0 + inherits: ^2.0.3 + readable-stream: ^3.1.1 + checksum: 699831a8b97666ef50021c767f84924cfee21c142c2eb0e79c63254e140e6408d6d55a065a2992548e72b06de39237ef2b802b99e3ece93ca3904a37622a66f3 + languageName: node + linkType: hard + +"tar@npm:6.2.1, tar@npm:^6.1.11, tar@npm:^6.2.1": + version: 6.2.1 + resolution: "tar@npm:6.2.1" + dependencies: + chownr: ^2.0.0 + fs-minipass: ^2.0.0 + minipass: ^5.0.0 + minizlib: ^2.1.1 + mkdirp: ^1.0.3 + yallist: ^4.0.0 + checksum: f1322768c9741a25356c11373bce918483f40fa9a25c69c59410c8a1247632487edef5fe76c5f12ac51a6356d2f1829e96d2bc34098668a2fc34d76050ac2b6c + languageName: node + linkType: hard + +"tar@npm:^7.5.2": + version: 7.5.3 + resolution: "tar@npm:7.5.3" + dependencies: + "@isaacs/fs-minipass": ^4.0.0 + chownr: ^3.0.0 + minipass: ^7.1.2 + minizlib: ^3.1.0 + yallist: ^5.0.0 + checksum: 146cd30727cd886c6cbed9e8f67c280f98bcab57c69d7acdb44d8a743987aac9b27cb10a98c2ea3cd416ded44ee93cad84c4acd6abad900f6aaa4845ceaf1b46 + languageName: node + linkType: hard + +"temp-dir@npm:1.0.0": + version: 1.0.0 + resolution: "temp-dir@npm:1.0.0" + checksum: cb2b58ddfb12efa83e939091386ad73b425c9a8487ea0095fe4653192a40d49184a771a1beba99045fbd011e389fd563122d79f54f82be86a55620667e08a6b2 + languageName: node + linkType: hard + +"terser@npm:^5.15.0": + version: 5.46.0 + resolution: "terser@npm:5.46.0" + dependencies: + "@jridgewell/source-map": ^0.3.3 + acorn: ^8.15.0 + commander: ^2.20.0 + source-map-support: ~0.5.20 + bin: + terser: bin/terser + checksum: 39d28f3723e84e80ddb4576a441adb12a6d365258fb9262e25f8b6d1e4514954e81f711008ee2ad9927f00b860a5bcbd4c1db7a6873d0f712bdcc667fb7b7557 + languageName: node + linkType: hard + +"test-exclude@npm:^6.0.0": + version: 6.0.0 + resolution: "test-exclude@npm:6.0.0" + dependencies: + "@istanbuljs/schema": ^0.1.2 + glob: ^7.1.4 + minimatch: ^3.0.4 + checksum: 3b34a3d77165a2cb82b34014b3aba93b1c4637a5011807557dc2f3da826c59975a5ccad765721c4648b39817e3472789f9b0fa98fc854c5c1c7a1e632aacdc28 + languageName: node + linkType: hard + +"text-extensions@npm:^1.0.0": + version: 1.9.0 + resolution: "text-extensions@npm:1.9.0" + checksum: 56a9962c1b62d39b2bcb369b7558ca85c1b55e554b38dfd725edcc0a1babe5815782a60c17ff6b839093b163dfebb92b804208aaaea616ec7571c8059ae0cf44 + languageName: node + linkType: hard + +"text-table@npm:^0.2.0": + version: 0.2.0 + resolution: "text-table@npm:0.2.0" + checksum: b6937a38c80c7f84d9c11dd75e49d5c44f71d95e810a3250bd1f1797fc7117c57698204adf676b71497acc205d769d65c16ae8fa10afad832ae1322630aef10a + languageName: node + linkType: hard + +"throat@npm:^5.0.0": + version: 5.0.0 + resolution: "throat@npm:5.0.0" + checksum: 031ff7f4431618036c1dedd99c8aa82f5c33077320a8358ed829e84b320783781d1869fe58e8f76e948306803de966f5f7573766a437562c9f5c033297ad2fe2 + languageName: node + linkType: hard + +"through2@npm:^2.0.0": + version: 2.0.5 + resolution: "through2@npm:2.0.5" + dependencies: + readable-stream: ~2.3.6 + xtend: ~4.0.1 + checksum: beb0f338aa2931e5660ec7bf3ad949e6d2e068c31f4737b9525e5201b824ac40cac6a337224856b56bd1ddd866334bbfb92a9f57cd6f66bc3f18d3d86fc0fe50 + languageName: node + linkType: hard + +"through2@npm:^4.0.0": + version: 4.0.2 + resolution: "through2@npm:4.0.2" + dependencies: + readable-stream: 3 + checksum: ac7430bd54ccb7920fd094b1c7ff3e1ad6edd94202e5528331253e5fde0cc56ceaa690e8df9895de2e073148c52dfbe6c4db74cacae812477a35660090960cc0 + languageName: node + linkType: hard + +"through@npm:2, through@npm:2.3.8, through@npm:>=2.2.7 <3, through@npm:^2.3.6": + version: 2.3.8 + resolution: "through@npm:2.3.8" + checksum: a38c3e059853c494af95d50c072b83f8b676a9ba2818dcc5b108ef252230735c54e0185437618596c790bbba8fcdaef5b290405981ffa09dce67b1f1bf190cbd + languageName: node + linkType: hard + +"tinyglobby@npm:0.2.12": + version: 0.2.12 + resolution: "tinyglobby@npm:0.2.12" + dependencies: + fdir: ^6.4.3 + picomatch: ^4.0.2 + checksum: ef9357fa1b2b661afdccd315cb4995f5f36bce948faaace68aae85fe57bdd8f837883045c88efc50d3186bac6586e4ae2f31026b9a3aac061b884217e6092e23 + languageName: node + linkType: hard + +"tinyglobby@npm:^0.2.12, tinyglobby@npm:^0.2.14, tinyglobby@npm:^0.2.15": + version: 0.2.15 + resolution: "tinyglobby@npm:0.2.15" + dependencies: + fdir: ^6.5.0 + picomatch: ^4.0.3 + checksum: 0e33b8babff966c6ab86e9b825a350a6a98a63700fa0bb7ae6cf36a7770a508892383adc272f7f9d17aaf46a9d622b455e775b9949a3f951eaaf5dfb26331d44 + languageName: node + linkType: hard + +"tmp@npm:~0.2.1": + version: 0.2.5 + resolution: "tmp@npm:0.2.5" + checksum: 9d18e58060114154939930457b9e198b34f9495bcc05a343bc0a0a29aa546d2c1c2b343dae05b87b17c8fde0af93ab7d8fe8574a8f6dc2cd8fd3f2ca1ad0d8e1 + languageName: node + linkType: hard + +"tmpl@npm:1.0.5": + version: 1.0.5 + resolution: "tmpl@npm:1.0.5" + checksum: cd922d9b853c00fe414c5a774817be65b058d54a2d01ebb415840960406c669a0fc632f66df885e24cb022ec812739199ccbdb8d1164c3e513f85bfca5ab2873 + languageName: node + linkType: hard + +"to-regex-range@npm:^5.0.1": + version: 5.0.1 + resolution: "to-regex-range@npm:5.0.1" + dependencies: + is-number: ^7.0.0 + checksum: f76fa01b3d5be85db6a2a143e24df9f60dd047d151062d0ba3df62953f2f697b16fe5dad9b0ac6191c7efc7b1d9dcaa4b768174b7b29da89d4428e64bc0a20ed + languageName: node + linkType: hard + +"toidentifier@npm:~1.0.1": + version: 1.0.1 + resolution: "toidentifier@npm:1.0.1" + checksum: 952c29e2a85d7123239b5cfdd889a0dde47ab0497f0913d70588f19c53f7e0b5327c95f4651e413c74b785147f9637b17410ac8c846d5d4a20a5a33eb6dc3a45 + languageName: node + linkType: hard + +"tr46@npm:~0.0.3": + version: 0.0.3 + resolution: "tr46@npm:0.0.3" + checksum: 726321c5eaf41b5002e17ffbd1fb7245999a073e8979085dacd47c4b4e8068ff5777142fc6726d6ca1fd2ff16921b48788b87225cbc57c72636f6efa8efbffe3 + languageName: node + linkType: hard + +"treeverse@npm:^3.0.0": + version: 3.0.0 + resolution: "treeverse@npm:3.0.0" + checksum: 73168d9887fa57b0719218f176c5a3cfbaaf310922879acb4adf76665bc17dcdb6ed3e4163f0c27eee17e346886186a1515ea6f87e96cdc10df1dce13bf622a0 + languageName: node + linkType: hard + +"trim-newlines@npm:^3.0.0": + version: 3.0.1 + resolution: "trim-newlines@npm:3.0.1" + checksum: b530f3fadf78e570cf3c761fb74fef655beff6b0f84b29209bac6c9622db75ad1417f4a7b5d54c96605dcd72734ad44526fef9f396807b90839449eb543c6206 + languageName: node + linkType: hard + +"trim-newlines@npm:^4.0.2": + version: 4.1.1 + resolution: "trim-newlines@npm:4.1.1" + checksum: 5b09f8e329e8f33c1111ef26906332ba7ba7248cde3e26fc054bb3d69f2858bf5feedca9559c572ff91f33e52977c28e0d41c387df6a02a633cbb8c2d8238627 + languageName: node + linkType: hard + +"ts-api-utils@npm:^2.4.0": + version: 2.4.0 + resolution: "ts-api-utils@npm:2.4.0" + peerDependencies: + typescript: ">=4.8.4" + checksum: beae72a4fa22a7cc91a8a0f3dfb487d72e30f06ac50ff72f327d061dea2d4940c6451d36578d949caad3893d4d2c7d42d53b7663597ccda54ad32cdb842c3e34 + languageName: node + linkType: hard + +"ts-morph@npm:^27.0.0": + version: 27.0.2 + resolution: "ts-morph@npm:27.0.2" + dependencies: + "@ts-morph/common": ~0.28.1 + code-block-writer: ^13.0.3 + checksum: 1ed2e89257d6f48fdce49bf51e1767787579220197efaa31ac25971c656c9a8a5a6bdd123042d16f83674eec119e4462a06f716187aec0b5e4740888ab5b73b7 + languageName: node + linkType: hard + +"ts-node@npm:^10.8.1": + version: 10.9.2 + resolution: "ts-node@npm:10.9.2" + dependencies: + "@cspotcode/source-map-support": ^0.8.0 + "@tsconfig/node10": ^1.0.7 + "@tsconfig/node12": ^1.0.7 + "@tsconfig/node14": ^1.0.0 + "@tsconfig/node16": ^1.0.2 + acorn: ^8.4.1 + acorn-walk: ^8.1.1 + arg: ^4.1.0 + create-require: ^1.1.0 + diff: ^4.0.1 + make-error: ^1.1.1 + v8-compile-cache-lib: ^3.0.1 + yn: 3.1.1 + peerDependencies: + "@swc/core": ">=1.2.50" + "@swc/wasm": ">=1.2.50" + "@types/node": "*" + typescript: ">=2.7" + peerDependenciesMeta: + "@swc/core": + optional: true + "@swc/wasm": + optional: true + bin: + ts-node: dist/bin.js + ts-node-cwd: dist/bin-cwd.js + ts-node-esm: dist/bin-esm.js + ts-node-script: dist/bin-script.js + ts-node-transpile-only: dist/bin-transpile.js + ts-script: dist/bin-script-deprecated.js + checksum: fde256c9073969e234526e2cfead42591b9a2aec5222bac154b0de2fa9e4ceb30efcd717ee8bc785a56f3a119bdd5aa27b333d9dbec94ed254bd26f8944c67ac + languageName: node + linkType: hard + +"tsconfig-paths@npm:^4.1.2": + version: 4.2.0 + resolution: "tsconfig-paths@npm:4.2.0" + dependencies: + json5: ^2.2.2 + minimist: ^1.2.6 + strip-bom: ^3.0.0 + checksum: 28c5f7bbbcabc9dabd4117e8fdc61483f6872a1c6b02a4b1c4d68c5b79d06896c3cc9547610c4c3ba64658531caa2de13ead1ea1bf321c7b53e969c4752b98c7 + languageName: node + linkType: hard + +"tslib@npm:^2.1.0, tslib@npm:^2.3.0, tslib@npm:^2.4.0": + version: 2.8.1 + resolution: "tslib@npm:2.8.1" + checksum: e4aba30e632b8c8902b47587fd13345e2827fa639e7c3121074d5ee0880723282411a8838f830b55100cbe4517672f84a2472667d355b81e8af165a55dc6203a + languageName: node + linkType: hard + +"tuf-js@npm:^2.2.1": + version: 2.2.1 + resolution: "tuf-js@npm:2.2.1" + dependencies: + "@tufjs/models": 2.0.1 + debug: ^4.3.4 + make-fetch-happen: ^13.0.1 + checksum: 23a8f84a33f4569296c7d1d6919ea87273923a3d0c6cc837a84fb200041a54bb1b50623f79cc77307325d945dfe10e372ac1cad105956e34d3df2d4984027bd8 + languageName: node + linkType: hard + +"type-check@npm:^0.4.0, type-check@npm:~0.4.0": + version: 0.4.0 + resolution: "type-check@npm:0.4.0" + dependencies: + prelude-ls: ^1.2.1 + checksum: ec688ebfc9c45d0c30412e41ca9c0cdbd704580eb3a9ccf07b9b576094d7b86a012baebc95681999dd38f4f444afd28504cb3a89f2ef16b31d4ab61a0739025a + languageName: node + linkType: hard + +"type-detect@npm:4.0.8": + version: 4.0.8 + resolution: "type-detect@npm:4.0.8" + checksum: 62b5628bff67c0eb0b66afa371bd73e230399a8d2ad30d852716efcc4656a7516904570cd8631a49a3ce57c10225adf5d0cbdcb47f6b0255fe6557c453925a15 + languageName: node + linkType: hard + +"type-fest@npm:^0.18.0": + version: 0.18.1 + resolution: "type-fest@npm:0.18.1" + checksum: e96dcee18abe50ec82dab6cbc4751b3a82046da54c52e3b2d035b3c519732c0b3dd7a2fa9df24efd1a38d953d8d4813c50985f215f1957ee5e4f26b0fe0da395 + languageName: node + linkType: hard + +"type-fest@npm:^0.20.2": + version: 0.20.2 + resolution: "type-fest@npm:0.20.2" + checksum: 4fb3272df21ad1c552486f8a2f8e115c09a521ad7a8db3d56d53718d0c907b62c6e9141ba5f584af3f6830d0872c521357e512381f24f7c44acae583ad517d73 + languageName: node + linkType: hard + +"type-fest@npm:^0.21.3": + version: 0.21.3 + resolution: "type-fest@npm:0.21.3" + checksum: e6b32a3b3877f04339bae01c193b273c62ba7bfc9e325b8703c4ee1b32dc8fe4ef5dfa54bf78265e069f7667d058e360ae0f37be5af9f153b22382cd55a9afe0 + languageName: node + linkType: hard + +"type-fest@npm:^0.4.1": + version: 0.4.1 + resolution: "type-fest@npm:0.4.1" + checksum: 25f882d9cc2f24af7a0a529157f96dead157894c456bfbad16d48f990c43b470dfb79848e8d9c03fe1be72a7d169e44f6f3135b54628393c66a6189c5dc077f7 + languageName: node + linkType: hard + +"type-fest@npm:^0.6.0": + version: 0.6.0 + resolution: "type-fest@npm:0.6.0" + checksum: b2188e6e4b21557f6e92960ec496d28a51d68658018cba8b597bd3ef757721d1db309f120ae987abeeda874511d14b776157ff809f23c6d1ce8f83b9b2b7d60f + languageName: node + linkType: hard + +"type-fest@npm:^0.7.1": + version: 0.7.1 + resolution: "type-fest@npm:0.7.1" + checksum: 5b1b113529d59949d97b76977d545989ddc11b81bb0c766b6d2ccc65473cb4b4a5c7d24f5be2c2bb2de302a5d7a13c1732ea1d34c8c59b7e0ec1f890cf7fc424 + languageName: node + linkType: hard + +"type-fest@npm:^0.8.1": + version: 0.8.1 + resolution: "type-fest@npm:0.8.1" + checksum: d61c4b2eba24009033ae4500d7d818a94fd6d1b481a8111612ee141400d5f1db46f199c014766b9fa9b31a6a7374d96fc748c6d688a78a3ce5a33123839becb7 + languageName: node + linkType: hard + +"type-fest@npm:^1.0.1, type-fest@npm:^1.2.1, type-fest@npm:^1.2.2": + version: 1.4.0 + resolution: "type-fest@npm:1.4.0" + checksum: b011c3388665b097ae6a109a437a04d6f61d81b7357f74cbcb02246f2f5bd72b888ae33631b99871388122ba0a87f4ff1c94078e7119ff22c70e52c0ff828201 + languageName: node + linkType: hard + +"typedarray@npm:^0.0.6": + version: 0.0.6 + resolution: "typedarray@npm:0.0.6" + checksum: 33b39f3d0e8463985eeaeeacc3cb2e28bc3dfaf2a5ed219628c0b629d5d7b810b0eb2165f9f607c34871d5daa92ba1dc69f49051cf7d578b4cbd26c340b9d1b1 + languageName: node + linkType: hard + +"typescript@npm:>=3 < 6, typescript@npm:^4.6.4 || ^5.2.2, typescript@npm:~5.9.2": + version: 5.9.3 + resolution: "typescript@npm:5.9.3" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 0d0ffb84f2cd072c3e164c79a2e5a1a1f4f168e84cb2882ff8967b92afe1def6c2a91f6838fb58b168428f9458c57a2ba06a6737711fdd87a256bbe83e9a217f + languageName: node + linkType: hard + +"typescript@patch:typescript@>=3 < 6#~builtin, typescript@patch:typescript@^4.6.4 || ^5.2.2#~builtin, typescript@patch:typescript@~5.9.2#~builtin": + version: 5.9.3 + resolution: "typescript@patch:typescript@npm%3A5.9.3#~builtin::version=5.9.3&hash=14eedb" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 8bb8d86819ac86a498eada254cad7fb69c5f74778506c700c2a712daeaff21d3a6f51fd0d534fe16903cb010d1b74f89437a3d02d4d0ff5ca2ba9a4660de8497 + languageName: node + linkType: hard + +"uglify-js@npm:^3.1.4": + version: 3.19.3 + resolution: "uglify-js@npm:3.19.3" + bin: + uglifyjs: bin/uglifyjs + checksum: 7ed6272fba562eb6a3149cfd13cda662f115847865c03099e3995a0e7a910eba37b82d4fccf9e88271bb2bcbe505bb374967450f433c17fa27aa36d94a8d0553 + languageName: node + linkType: hard + +"undici-types@npm:~7.16.0": + version: 7.16.0 + resolution: "undici-types@npm:7.16.0" + checksum: 1ef68fc6c5bad200c8b6f17de8e5bc5cfdcadc164ba8d7208cd087cfa8583d922d8316a7fd76c9a658c22b4123d3ff847429185094484fbc65377d695c905857 + languageName: node + linkType: hard + +"unique-filename@npm:^3.0.0": + version: 3.0.0 + resolution: "unique-filename@npm:3.0.0" + dependencies: + unique-slug: ^4.0.0 + checksum: 8e2f59b356cb2e54aab14ff98a51ac6c45781d15ceaab6d4f1c2228b780193dc70fae4463ce9e1df4479cb9d3304d7c2043a3fb905bdeca71cc7e8ce27e063df + languageName: node + linkType: hard + +"unique-filename@npm:^5.0.0": + version: 5.0.0 + resolution: "unique-filename@npm:5.0.0" + dependencies: + unique-slug: ^6.0.0 + checksum: a5f67085caef74bdd2a6869a200ed5d68d171f5cc38435a836b5fd12cce4e4eb55e6a190298035c325053a5687ed7a3c96f0a91e82215fd14729769d9ac57d9b + languageName: node + linkType: hard + +"unique-slug@npm:^4.0.0": + version: 4.0.0 + resolution: "unique-slug@npm:4.0.0" + dependencies: + imurmurhash: ^0.1.4 + checksum: 0884b58365af59f89739e6f71e3feacb5b1b41f2df2d842d0757933620e6de08eff347d27e9d499b43c40476cbaf7988638d3acb2ffbcb9d35fd035591adfd15 + languageName: node + linkType: hard + +"unique-slug@npm:^6.0.0": + version: 6.0.0 + resolution: "unique-slug@npm:6.0.0" + dependencies: + imurmurhash: ^0.1.4 + checksum: ad6cf238b10292d944521714d31bc9f3ca79fa80cb7a154aad183056493f98e85de669412c6bbfe527ffa9bdeff36d3dd4d5bccaf562c794f2580ab11932b691 + languageName: node + linkType: hard + +"universal-user-agent@npm:^6.0.0": + version: 6.0.1 + resolution: "universal-user-agent@npm:6.0.1" + checksum: fdc8e1ae48a05decfc7ded09b62071f571c7fe0bd793d700704c80cea316101d4eac15cc27ed2bb64f4ce166d2684777c3198b9ab16034f547abea0d3aa1c93c + languageName: node + linkType: hard + +"universalify@npm:^2.0.0": + version: 2.0.1 + resolution: "universalify@npm:2.0.1" + checksum: ecd8469fe0db28e7de9e5289d32bd1b6ba8f7183db34f3bfc4ca53c49891c2d6aa05f3fb3936a81285a905cc509fb641a0c3fc131ec786167eff41236ae32e60 + languageName: node + linkType: hard + +"unpipe@npm:~1.0.0": + version: 1.0.0 + resolution: "unpipe@npm:1.0.0" + checksum: 4fa18d8d8d977c55cb09715385c203197105e10a6d220087ec819f50cb68870f02942244f1017565484237f1f8c5d3cd413631b1ae104d3096f24fdfde1b4aa2 + languageName: node + linkType: hard + +"upath@npm:2.0.1": + version: 2.0.1 + resolution: "upath@npm:2.0.1" + checksum: 2db04f24a03ef72204c7b969d6991abec9e2cb06fb4c13a1fd1c59bc33b46526b16c3325e55930a11ff86a77a8cbbcda8f6399bf914087028c5beae21ecdb33c + languageName: node + linkType: hard + +"update-browserslist-db@npm:^1.2.0": + version: 1.2.3 + resolution: "update-browserslist-db@npm:1.2.3" + dependencies: + escalade: ^3.2.0 + picocolors: ^1.1.1 + peerDependencies: + browserslist: ">= 4.21.0" + bin: + update-browserslist-db: cli.js + checksum: 6f209a97ae8eacdd3a1ef2eb365adf49d1e2a757e5b2dd4ac87dc8c99236cbe3e572d3e605a87dd7b538a11751b71d9f93edc47c7405262a293a493d155316cd + languageName: node + linkType: hard + +"uri-js@npm:^4.2.2": + version: 4.4.1 + resolution: "uri-js@npm:4.4.1" + dependencies: + punycode: ^2.1.0 + checksum: 7167432de6817fe8e9e0c9684f1d2de2bb688c94388f7569f7dbdb1587c9f4ca2a77962f134ec90be0cc4d004c939ff0d05acc9f34a0db39a3c797dada262633 + languageName: node + linkType: hard + +"util-deprecate@npm:^1.0.1, util-deprecate@npm:^1.0.2, util-deprecate@npm:~1.0.1": + version: 1.0.2 + resolution: "util-deprecate@npm:1.0.2" + checksum: 474acf1146cb2701fe3b074892217553dfcf9a031280919ba1b8d651a068c9b15d863b7303cb15bd00a862b498e6cf4ad7b4a08fb134edd5a6f7641681cb54a2 + languageName: node + linkType: hard + +"utils-merge@npm:1.0.1": + version: 1.0.1 + resolution: "utils-merge@npm:1.0.1" + checksum: c81095493225ecfc28add49c106ca4f09cdf56bc66731aa8dabc2edbbccb1e1bfe2de6a115e5c6a380d3ea166d1636410b62ef216bb07b3feb1cfde1d95d5080 + languageName: node + linkType: hard + +"uuid@npm:^10.0.0": + version: 10.0.0 + resolution: "uuid@npm:10.0.0" + bin: + uuid: dist/bin/uuid + checksum: 4b81611ade2885d2313ddd8dc865d93d8dccc13ddf901745edca8f86d99bc46d7a330d678e7532e7ebf93ce616679fb19b2e3568873ac0c14c999032acb25869 + languageName: node + linkType: hard + +"v8-compile-cache-lib@npm:^3.0.1": + version: 3.0.1 + resolution: "v8-compile-cache-lib@npm:3.0.1" + checksum: 78089ad549e21bcdbfca10c08850022b22024cdcc2da9b168bcf5a73a6ed7bf01a9cebb9eac28e03cd23a684d81e0502797e88f3ccd27a32aeab1cfc44c39da0 + languageName: node + linkType: hard + +"validate-npm-package-license@npm:3.0.4, validate-npm-package-license@npm:^3.0.1, validate-npm-package-license@npm:^3.0.4": + version: 3.0.4 + resolution: "validate-npm-package-license@npm:3.0.4" + dependencies: + spdx-correct: ^3.0.0 + spdx-expression-parse: ^3.0.0 + checksum: 35703ac889d419cf2aceef63daeadbe4e77227c39ab6287eeb6c1b36a746b364f50ba22e88591f5d017bc54685d8137bc2d328d0a896e4d3fd22093c0f32a9ad + languageName: node + linkType: hard + +"validate-npm-package-name@npm:5.0.1, validate-npm-package-name@npm:^5.0.0": + version: 5.0.1 + resolution: "validate-npm-package-name@npm:5.0.1" + checksum: 0d583a1af23aeffea7748742cf22b6802458736fb8b60323ba5949763824d46f796474b0e1b9206beb716f9d75269e19dbd7795d6b038b29d561be95dd827381 + languageName: node + linkType: hard + +"vlq@npm:^1.0.0": + version: 1.0.1 + resolution: "vlq@npm:1.0.1" + checksum: 67ab6dd35c787eaa02c0ff1a869dd07a230db08722fb6014adaaf432634808ddb070765f70958b47997e438c331790cfcf20902411b0d6453f1a2a5923522f55 + languageName: node + linkType: hard + +"walk-up-path@npm:^3.0.1": + version: 3.0.1 + resolution: "walk-up-path@npm:3.0.1" + checksum: 9ffca02fe30fb65f6db531260582988c5e766f4c739cf86a6109380a7f791236b5d0b92b1dce37a6f73e22dca6bc9d93bf3700413e16251b2bd6bbd1ca2be316 + languageName: node + linkType: hard + +"walker@npm:^1.0.7, walker@npm:^1.0.8": + version: 1.0.8 + resolution: "walker@npm:1.0.8" + dependencies: + makeerror: 1.0.12 + checksum: ad7a257ea1e662e57ef2e018f97b3c02a7240ad5093c392186ce0bcf1f1a60bbadd520d073b9beb921ed99f64f065efb63dfc8eec689a80e569f93c1c5d5e16c + languageName: node + linkType: hard + +"wcwidth@npm:^1.0.0, wcwidth@npm:^1.0.1": + version: 1.0.1 + resolution: "wcwidth@npm:1.0.1" + dependencies: + defaults: ^1.0.3 + checksum: 814e9d1ddcc9798f7377ffa448a5a3892232b9275ebb30a41b529607691c0491de47cba426e917a4d08ded3ee7e9ba2f3fe32e62ee3cd9c7d3bafb7754bd553c + languageName: node + linkType: hard + +"webidl-conversions@npm:^3.0.0": + version: 3.0.1 + resolution: "webidl-conversions@npm:3.0.1" + checksum: c92a0a6ab95314bde9c32e1d0a6dfac83b578f8fa5f21e675bc2706ed6981bc26b7eb7e6a1fab158e5ce4adf9caa4a0aee49a52505d4d13c7be545f15021b17c + languageName: node + linkType: hard + +"whatwg-fetch@npm:^3.0.0": + version: 3.6.20 + resolution: "whatwg-fetch@npm:3.6.20" + checksum: c58851ea2c4efe5c2235f13450f426824cf0253c1d45da28f45900290ae602a20aff2ab43346f16ec58917d5562e159cd691efa368354b2e82918c2146a519c5 + languageName: node + linkType: hard + +"whatwg-url@npm:^5.0.0": + version: 5.0.0 + resolution: "whatwg-url@npm:5.0.0" + dependencies: + tr46: ~0.0.3 + webidl-conversions: ^3.0.0 + checksum: b8daed4ad3356cc4899048a15b2c143a9aed0dfae1f611ebd55073310c7b910f522ad75d727346ad64203d7e6c79ef25eafd465f4d12775ca44b90fa82ed9e2c + languageName: node + linkType: hard + +"which@npm:^2.0.1": + version: 2.0.2 + resolution: "which@npm:2.0.2" + dependencies: + isexe: ^2.0.0 + bin: + node-which: ./bin/node-which + checksum: 1a5c563d3c1b52d5f893c8b61afe11abc3bab4afac492e8da5bde69d550de701cf9806235f20a47b5c8fa8a1d6a9135841de2596535e998027a54589000e66d1 + languageName: node + linkType: hard + +"which@npm:^4.0.0": + version: 4.0.0 + resolution: "which@npm:4.0.0" + dependencies: + isexe: ^3.1.1 + bin: + node-which: bin/which.js + checksum: f17e84c042592c21e23c8195108cff18c64050b9efb8459589116999ea9da6dd1509e6a1bac3aeebefd137be00fabbb61b5c2bc0aa0f8526f32b58ee2f545651 + languageName: node + linkType: hard + +"which@npm:^6.0.0": + version: 6.0.0 + resolution: "which@npm:6.0.0" + dependencies: + isexe: ^3.1.1 + bin: + node-which: bin/which.js + checksum: df19b2cd8aac94b333fa29b42e8e371a21e634a742a3b156716f7752a5afe1d73fb5d8bce9b89326f453d96879e8fe626eb421e0117eb1a3ce9fd8c97f6b7db9 + languageName: node + linkType: hard + +"wide-align@npm:1.1.5": + version: 1.1.5 + resolution: "wide-align@npm:1.1.5" + dependencies: + string-width: ^1.0.2 || 2 || 3 || 4 + checksum: d5fc37cd561f9daee3c80e03b92ed3e84d80dde3365a8767263d03dacfc8fa06b065ffe1df00d8c2a09f731482fcacae745abfbb478d4af36d0a891fad4834d3 + languageName: node + linkType: hard + +"word-wrap@npm:^1.2.5": + version: 1.2.5 + resolution: "word-wrap@npm:1.2.5" + checksum: f93ba3586fc181f94afdaff3a6fef27920b4b6d9eaefed0f428f8e07adea2a7f54a5f2830ce59406c8416f033f86902b91eb824072354645eea687dff3691ccb + languageName: node + linkType: hard + +"wordwrap@npm:^1.0.0": + version: 1.0.0 + resolution: "wordwrap@npm:1.0.0" + checksum: 2a44b2788165d0a3de71fd517d4880a8e20ea3a82c080ce46e294f0b68b69a2e49cff5f99c600e275c698a90d12c5ea32aff06c311f0db2eb3f1201f3e7b2a04 + languageName: node + linkType: hard + +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0, wrap-ansi@npm:^7.0.0": + version: 7.0.0 + resolution: "wrap-ansi@npm:7.0.0" + dependencies: + ansi-styles: ^4.0.0 + string-width: ^4.1.0 + strip-ansi: ^6.0.0 + checksum: a790b846fd4505de962ba728a21aaeda189b8ee1c7568ca5e817d85930e06ef8d1689d49dbf0e881e8ef84436af3a88bc49115c2e2788d841ff1b8b5b51a608b + languageName: node + linkType: hard + +"wrap-ansi@npm:^6.0.1": + version: 6.2.0 + resolution: "wrap-ansi@npm:6.2.0" + dependencies: + ansi-styles: ^4.0.0 + string-width: ^4.1.0 + strip-ansi: ^6.0.0 + checksum: 6cd96a410161ff617b63581a08376f0cb9162375adeb7956e10c8cd397821f7eb2a6de24eb22a0b28401300bf228c86e50617cd568209b5f6775b93c97d2fe3a + languageName: node + linkType: hard + +"wrap-ansi@npm:^8.1.0": + version: 8.1.0 + resolution: "wrap-ansi@npm:8.1.0" + dependencies: + ansi-styles: ^6.1.0 + string-width: ^5.0.1 + strip-ansi: ^7.0.1 + checksum: 371733296dc2d616900ce15a0049dca0ef67597d6394c57347ba334393599e800bab03c41d4d45221b6bc967b8c453ec3ae4749eff3894202d16800fdfe0e238 + languageName: node + linkType: hard + +"wrap-ansi@npm:^9.0.0": + version: 9.0.2 + resolution: "wrap-ansi@npm:9.0.2" + dependencies: + ansi-styles: ^6.2.1 + string-width: ^7.0.0 + strip-ansi: ^7.1.0 + checksum: 9827bf8bbb341d2d15f26d8507d98ca2695279359073422fe089d374b30e233d24ab95beca55cf9ab8dcb89face00e919be4158af50d4b6d8eab5ef4ee399e0c + languageName: node + linkType: hard + +"wrappy@npm:1": + version: 1.0.2 + resolution: "wrappy@npm:1.0.2" + checksum: 159da4805f7e84a3d003d8841557196034155008f817172d4e986bd591f74aa82aa7db55929a54222309e01079a65a92a9e6414da5a6aa4b01ee44a511ac3ee5 + languageName: node + linkType: hard + +"write-file-atomic@npm:5.0.1, write-file-atomic@npm:^5.0.0": + version: 5.0.1 + resolution: "write-file-atomic@npm:5.0.1" + dependencies: + imurmurhash: ^0.1.4 + signal-exit: ^4.0.1 + checksum: 8dbb0e2512c2f72ccc20ccedab9986c7d02d04039ed6e8780c987dc4940b793339c50172a1008eed7747001bfacc0ca47562668a069a7506c46c77d7ba3926a9 + languageName: node + linkType: hard + +"write-file-atomic@npm:^2.4.2": + version: 2.4.3 + resolution: "write-file-atomic@npm:2.4.3" + dependencies: + graceful-fs: ^4.1.11 + imurmurhash: ^0.1.4 + signal-exit: ^3.0.2 + checksum: 2db81f92ae974fd87ab4a5e7932feacaca626679a7c98fcc73ad8fcea5a1950eab32fa831f79e9391ac99b562ca091ad49be37a79045bd65f595efbb8f4596ae + languageName: node + linkType: hard + +"write-file-atomic@npm:^4.0.2": + version: 4.0.2 + resolution: "write-file-atomic@npm:4.0.2" + dependencies: + imurmurhash: ^0.1.4 + signal-exit: ^3.0.7 + checksum: 5da60bd4eeeb935eec97ead3df6e28e5917a6bd317478e4a85a5285e8480b8ed96032bbcc6ecd07b236142a24f3ca871c924ec4a6575e623ec1b11bf8c1c253c + languageName: node + linkType: hard + +"write-json-file@npm:^3.2.0": + version: 3.2.0 + resolution: "write-json-file@npm:3.2.0" + dependencies: + detect-indent: ^5.0.0 + graceful-fs: ^4.1.15 + make-dir: ^2.1.0 + pify: ^4.0.1 + sort-keys: ^2.0.0 + write-file-atomic: ^2.4.2 + checksum: 2b97ce2027d53c28a33e4a8e7b0d565faf785988b3776f9e0c68d36477c1fb12639fd0d70877d92a861820707966c62ea9c5f7a36a165d615fd47ca8e24c8371 + languageName: node + linkType: hard + +"write-pkg@npm:4.0.0": + version: 4.0.0 + resolution: "write-pkg@npm:4.0.0" + dependencies: + sort-keys: ^2.0.0 + type-fest: ^0.4.1 + write-json-file: ^3.2.0 + checksum: 7864d44370f42a6761f6898d07ee2818c7a2faad45116580cf779f3adaf94e4bea5557612533a6c421c32323253ecb63b50615094960a637aeaef5df0fd2d6cd + languageName: node + linkType: hard + +"ws@npm:^7, ws@npm:^7.5.10": + version: 7.5.10 + resolution: "ws@npm:7.5.10" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: f9bb062abf54cc8f02d94ca86dcd349c3945d63851f5d07a3a61c2fcb755b15a88e943a63cf580cbdb5b74436d67ef6b67f745b8f7c0814e411379138e1863cb + languageName: node + linkType: hard + +"xtend@npm:~4.0.1": + version: 4.0.2 + resolution: "xtend@npm:4.0.2" + checksum: ac5dfa738b21f6e7f0dd6e65e1b3155036d68104e67e5d5d1bde74892e327d7e5636a076f625599dc394330a731861e87343ff184b0047fef1360a7ec0a5a36a + languageName: node + linkType: hard + +"y18n@npm:^5.0.5": + version: 5.0.8 + resolution: "y18n@npm:5.0.8" + checksum: 54f0fb95621ee60898a38c572c515659e51cc9d9f787fb109cef6fde4befbe1c4602dc999d30110feee37456ad0f1660fa2edcfde6a9a740f86a290999550d30 + languageName: node + linkType: hard + +"yallist@npm:^3.0.2": + version: 3.1.1 + resolution: "yallist@npm:3.1.1" + checksum: 48f7bb00dc19fc635a13a39fe547f527b10c9290e7b3e836b9a8f1ca04d4d342e85714416b3c2ab74949c9c66f9cebb0473e6bc353b79035356103b47641285d + languageName: node + linkType: hard + +"yallist@npm:^4.0.0": + version: 4.0.0 + resolution: "yallist@npm:4.0.0" + checksum: 343617202af32df2a15a3be36a5a8c0c8545208f3d3dfbc6bb7c3e3b7e8c6f8e7485432e4f3b88da3031a6e20afa7c711eded32ddfb122896ac5d914e75848d5 + languageName: node + linkType: hard + +"yallist@npm:^5.0.0": + version: 5.0.0 + resolution: "yallist@npm:5.0.0" + checksum: eba51182400b9f35b017daa7f419f434424410691bbc5de4f4240cc830fdef906b504424992700dc047f16b4d99100a6f8b8b11175c193f38008e9c96322b6a5 + languageName: node + linkType: hard + +"yaml@npm:^2.6.0, yaml@npm:^2.6.1": + version: 2.8.2 + resolution: "yaml@npm:2.8.2" + bin: + yaml: bin.mjs + checksum: 5ffd9f23bc7a450129cbd49dcf91418988f154ede10c83fd28ab293661ac2783c05da19a28d76a22cbd77828eae25d4bd7453f9a9fe2d287d085d72db46fd105 + languageName: node + linkType: hard + +"yargs-parser@npm:21.1.1, yargs-parser@npm:^21.1.1": + version: 21.1.1 + resolution: "yargs-parser@npm:21.1.1" + checksum: ed2d96a616a9e3e1cc7d204c62ecc61f7aaab633dcbfab2c6df50f7f87b393993fe6640d017759fe112d0cb1e0119f2b4150a87305cc873fd90831c6a58ccf1c + languageName: node + linkType: hard + +"yargs-parser@npm:^20.2.2, yargs-parser@npm:^20.2.3, yargs-parser@npm:^20.2.9": + version: 20.2.9 + resolution: "yargs-parser@npm:20.2.9" + checksum: 8bb69015f2b0ff9e17b2c8e6bfe224ab463dd00ca211eece72a4cd8a906224d2703fb8a326d36fdd0e68701e201b2a60ed7cf81ce0fd9b3799f9fe7745977ae3 + languageName: node + linkType: hard + +"yargs-parser@npm:^22.0.0": + version: 22.0.0 + resolution: "yargs-parser@npm:22.0.0" + checksum: 55df0d94f3f9f933f1349f244ddf72a6978a9d5a972b69332965cdfd5ec849ff26386965512f4179065b0573cc6e8df33ca44334958a892c47fedae08a967c99 + languageName: node + linkType: hard + +"yargs@npm:17.7.2, yargs@npm:^17.0.0, yargs@npm:^17.6.2": + version: 17.7.2 + resolution: "yargs@npm:17.7.2" + dependencies: + cliui: ^8.0.1 + escalade: ^3.1.1 + get-caller-file: ^2.0.5 + require-directory: ^2.1.1 + string-width: ^4.2.3 + y18n: ^5.0.5 + yargs-parser: ^21.1.1 + checksum: 73b572e863aa4a8cbef323dd911d79d193b772defd5a51aab0aca2d446655216f5002c42c5306033968193bdbf892a7a4c110b0d77954a7fdf563e653967b56a + languageName: node + linkType: hard + +"yargs@npm:^16.2.0": + version: 16.2.0 + resolution: "yargs@npm:16.2.0" + dependencies: + cliui: ^7.0.2 + escalade: ^3.1.1 + get-caller-file: ^2.0.5 + require-directory: ^2.1.1 + string-width: ^4.2.0 + y18n: ^5.0.5 + yargs-parser: ^20.2.2 + checksum: b14afbb51e3251a204d81937c86a7e9d4bdbf9a2bcee38226c900d00f522969ab675703bee2a6f99f8e20103f608382936034e64d921b74df82b63c07c5e8f59 + languageName: node + linkType: hard + +"yargs@npm:^18.0.0": + version: 18.0.0 + resolution: "yargs@npm:18.0.0" + dependencies: + cliui: ^9.0.1 + escalade: ^3.1.1 + get-caller-file: ^2.0.5 + string-width: ^7.2.0 + y18n: ^5.0.5 + yargs-parser: ^22.0.0 + checksum: a7cf1b97cb4e81c059f78fd32a4160505d421ecdce5409f5e3840fdcc4c982885fc645b44af961eab94d673cb46f81207d831aa87862246907ffacf45884976a + languageName: node + linkType: hard + +"yn@npm:3.1.1": + version: 3.1.1 + resolution: "yn@npm:3.1.1" + checksum: 2c487b0e149e746ef48cda9f8bad10fc83693cd69d7f9dcd8be4214e985de33a29c9e24f3c0d6bcf2288427040a8947406ab27f7af67ee9456e6b84854f02dd6 + languageName: node + linkType: hard + +"yocto-queue@npm:^0.1.0": + version: 0.1.0 + resolution: "yocto-queue@npm:0.1.0" + checksum: f77b3d8d00310def622123df93d4ee654fc6a0096182af8bd60679ddcdfb3474c56c6c7190817c84a2785648cdee9d721c0154eb45698c62176c322fb46fc700 + languageName: node + linkType: hard + +"zod@npm:^4.0.5": + version: 4.3.5 + resolution: "zod@npm:4.3.5" + checksum: 68691183a91c67c4102db20139f3b5af288c59b4b11eb2239d712aae99dc6c1cecaeebcb0c012b44489771be05fecba21e79f65af4b3163b220239ef0af3ec49 + languageName: node + linkType: hard diff --git a/sdk/runanywhere-swift/.github/workflows/ci.yml b/sdk/runanywhere-swift/.github/workflows/ci.yml new file mode 100644 index 000000000..81fc68ac3 --- /dev/null +++ b/sdk/runanywhere-swift/.github/workflows/ci.yml @@ -0,0 +1,181 @@ +name: iOS SDK CI + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main ] + +jobs: + swiftlint: + name: SwiftLint + runs-on: macos-14 + continue-on-error: true # Don't block other jobs initially + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Cache SwiftLint + uses: actions/cache@v3 + with: + path: /opt/homebrew/bin/swiftlint + key: ${{ runner.os }}-swiftlint-${{ hashFiles('.swiftlint.yml') }} + restore-keys: | + ${{ runner.os }}-swiftlint- + + - name: Install SwiftLint + env: + SWIFTLINT_VERSION: "0.57.1" + run: | + # Pin SwiftLint version for consistency with .pre-commit-config.yaml + INSTALLED_VERSION=$(swiftlint version 2>/dev/null || echo "none") + if [ "$INSTALLED_VERSION" != "$SWIFTLINT_VERSION" ]; then + echo "Installing SwiftLint $SWIFTLINT_VERSION..." + brew install swiftlint@$SWIFTLINT_VERSION 2>/dev/null || brew install swiftlint + echo "Installed: $(swiftlint version)" + else + echo "SwiftLint $SWIFTLINT_VERSION already installed" + fi + + - name: Run SwiftLint + run: | + swiftlint --reporter github-actions-logging + + swiftlint-analyze: + name: SwiftLint Analyzer + runs-on: macos-14 + continue-on-error: true # Analyzer rules are advisory initially + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode_15.2.app + + - name: Install SwiftLint + env: + SWIFTLINT_VERSION: "0.57.1" + run: | + brew install swiftlint 2>/dev/null || true + + - name: Build for analyzer + run: | + # Build and capture compiler log for analyzer rules + xcodebuild build \ + -scheme RunAnywhere \ + -destination "platform=macOS" \ + -configuration Debug \ + CODE_SIGN_IDENTITY="" \ + CODE_SIGNING_REQUIRED=NO \ + 2>&1 | tee xcodebuild.log + + - name: Run SwiftLint Analyzer + run: | + # Run analyzer rules (unused_import, unused_declaration, typesafe_array_init) + swiftlint analyze \ + --compiler-log-path xcodebuild.log \ + --reporter github-actions-logging || true + + test: + name: Test on macOS + runs-on: macos-14 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode_15.2.app + + - name: Cache Swift Package Manager + uses: actions/cache@v3 + with: + path: .build + key: ${{ runner.os }}-spm-${{ hashFiles('Package.resolved') }} + restore-keys: | + ${{ runner.os }}-spm- + + - name: Build SDK + run: | + swift build --configuration debug + swift build --configuration release + + - name: Run tests + run: swift test --enable-code-coverage + + - name: Generate coverage report + run: | + xcrun llvm-cov export \ + .build/debug/RunAnywhereSDKPackageTests.xctest/Contents/MacOS/RunAnywhereSDKPackageTests \ + -instr-profile=.build/debug/codecov/default.profdata \ + -format=lcov > coverage.lcov + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + file: ./coverage.lcov + flags: unittests + name: codecov-umbrella + + build-platforms: + name: Build for ${{ matrix.platform }} + runs-on: macos-14 + strategy: + matrix: + platform: [iOS, tvOS, watchOS, macOS] + include: + - platform: iOS + sdk: iphoneos + destination: "platform=iOS Simulator,name=iPhone 15,OS=17.2" + - platform: tvOS + sdk: appletvos + destination: "platform=tvOS Simulator,name=Apple TV,OS=17.2" + - platform: watchOS + sdk: watchos + destination: "platform=watchOS Simulator,name=Apple Watch Series 9 (45mm),OS=10.2" + - platform: macOS + sdk: macosx + destination: "platform=macOS" + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode_15.2.app + + - name: Build for ${{ matrix.platform }} + run: | + xcodebuild build \ + -scheme RunAnywhereSDK \ + -sdk ${{ matrix.sdk }} \ + -destination "${{ matrix.destination }}" \ + -configuration Release \ + CODE_SIGN_IDENTITY="" \ + CODE_SIGNING_REQUIRED=NO + + documentation: + name: Build Documentation + runs-on: macos-14 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode_15.2.app + + - name: Build documentation + run: | + xcodebuild docbuild \ + -scheme RunAnywhereSDK \ + -destination "platform=iOS Simulator,name=iPhone 15" \ + -derivedDataPath ./build + + - name: Archive documentation + uses: actions/upload-artifact@v3 + with: + name: documentation + path: build/Build/Products/Debug-iphonesimulator/RunAnywhereSDK.doccarchive diff --git a/sdk/runanywhere-swift/.gitignore b/sdk/runanywhere-swift/.gitignore new file mode 100644 index 000000000..b7244c1fa --- /dev/null +++ b/sdk/runanywhere-swift/.gitignore @@ -0,0 +1,23 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc + +# Local XCFrameworks (excluded from git - use remote distribution by default) +# Default mode (testLocal = false): SDK downloads XCFrameworks from runanywhere-binaries releases +# Local mode (testLocal = true): Place XCFrameworks in Binaries/ for local development/testing +Binaries/*.xcframework/ +XCFrameworks/ + +# SPM build artifacts (recreated during swift build) +Modules/ + +EXTERNAL/ + +# Documentation (auto-generated, keep local only) +docs/ +Docs/ diff --git a/sdk/runanywhere-swift/.periphery.yml b/sdk/runanywhere-swift/.periphery.yml new file mode 100644 index 000000000..ebdfe6c71 --- /dev/null +++ b/sdk/runanywhere-swift/.periphery.yml @@ -0,0 +1,32 @@ +# Periphery configuration for RunAnywhere Swift SDK +# Detects unused code: types, functions, properties, protocols, etc. + +# SPM Project configuration (no Xcode project/schemes needed) +format: xcode +skip_build: false +clean_build: false + +# Targets to analyze (main SDK and backend modules) +targets: + - RunAnywhere + - ONNXRuntime + - LlamaCPPRuntime + +# Retain patterns - don't flag these as unused +retain_public: true +retain_objc_accessible: true +retain_codable_properties: true +retain_swift_ui_previews: true +retain_assign_only_property_types: + - Encoder + - Decoder + +# Exclude directories from indexing +index_exclude: + - ".build/**/*" + - "DerivedData/**/*" + - "Tests/**/*" + +# Exclude from results (but still index) +report_exclude: + - "Tests/**/*" diff --git a/sdk/runanywhere-swift/.pre-commit-config.yaml b/sdk/runanywhere-swift/.pre-commit-config.yaml new file mode 100644 index 000000000..a4dde4377 --- /dev/null +++ b/sdk/runanywhere-swift/.pre-commit-config.yaml @@ -0,0 +1,121 @@ +# Pre-commit hooks configuration for RunAnywhere Swift SDK +# See https://pre-commit.com for more information + +default_language_version: + python: python3 + +repos: + # SwiftLint with autofix then lint + - repo: local + hooks: + - id: swiftlint-fix + name: SwiftLint AutoFix + description: Auto-fix SwiftLint issues + entry: bash -c 'cd "$(git rev-parse --show-toplevel)/sdk/runanywhere-swift" && swiftlint --fix --quiet 2>/dev/null || true' + language: system + types: [swift] + files: ^sdk/runanywhere-swift/.*\.swift$ + pass_filenames: false + + - id: swiftlint + name: SwiftLint + description: Run SwiftLint on Swift files (fails on violations) + entry: bash -c 'cd "$(git rev-parse --show-toplevel)/sdk/runanywhere-swift" && swiftlint --strict' + language: system + types: [swift] + files: ^sdk/runanywhere-swift/.*\.swift$ + pass_filenames: false + + - id: periphery + name: Periphery (Unused Code) + description: Detect unused code with Periphery + entry: >- + bash -c ' + OUTPUT=$(periphery scan --targets RunAnywhere --targets ONNXRuntime --targets LlamaCPPRuntime --targets FoundationModelsAdapter --targets FluidAudioDiarization 2>&1); + WARNINGS=$(echo "$OUTPUT" | grep "warning:" | grep -v "Associatedtype .* is unused"); + if [ -n "$WARNINGS" ]; then echo "$WARNINGS"; echo "Periphery found unused code"; exit 1; fi; + exit 0' + language: system + types: [swift] + files: \.swift$ + pass_filenames: false + + # General file hygiene hooks + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + # Remove trailing whitespace + - id: trailing-whitespace + name: Trim Trailing Whitespace + description: Remove trailing whitespace from all files + exclude: ^(\.build|DerivedData) + args: [--markdown-linebreak-ext=md] + + # Ensure files end with a newline + - id: end-of-file-fixer + name: Fix End of Files + description: Ensure files end with a newline + exclude: ^(\.build|DerivedData) + + # Check for merge conflicts + - id: check-merge-conflict + name: Check for Merge Conflicts + description: Check for files that contain merge conflict strings + + # Check YAML syntax + - id: check-yaml + name: Check YAML Syntax + description: Validate YAML file syntax + exclude: ^(\.build|DerivedData) + + # Check for large files + - id: check-added-large-files + name: Check for Large Files + description: Prevent large files from being committed + args: ['--maxkb=1000'] + exclude: ^(Binaries|\.build) + + # Detect private keys + - id: detect-private-key + name: Detect Private Keys + description: Detect private keys that should not be committed + + # Check for case conflicts + - id: check-case-conflict + name: Check for Case Conflicts + description: Check for files that would conflict in case-insensitive filesystems + + # Mixed line endings + - id: mixed-line-ending + name: Fix Mixed Line Endings + description: Fix files with mixed line endings + args: ['--fix=lf'] + exclude: ^(\.build|DerivedData) + + # Swift-specific formatting (optional - requires swift-format to be installed) + # Uncomment if you want to use swift-format + # - repo: local + # hooks: + # - id: swift-format + # name: Swift Format + # description: Format Swift code with swift-format + # entry: swift-format + # language: system + # types: [swift] + # args: + # - --in-place + # - --configuration + # - .swift-format.json + +# Configuration +fail_fast: false +minimum_pre_commit_version: '2.18.0' + +# Exclude patterns that apply to all hooks +exclude: | + (?x)^( + \.build/.*| + DerivedData/.*| + \.swiftpm/.*| + Package\.resolved + )$ diff --git a/sdk/runanywhere-swift/.swiftlint.yml b/sdk/runanywhere-swift/.swiftlint.yml new file mode 100644 index 000000000..cc1e91c9d --- /dev/null +++ b/sdk/runanywhere-swift/.swiftlint.yml @@ -0,0 +1,221 @@ +# SwiftLint configuration for RunAnywhere SDK + +# Rule configuration +opt_in_rules: + - attributes + - closure_end_indentation + - closure_spacing + - collection_alignment + - contains_over_filter_count + - contains_over_filter_is_empty + - contains_over_first_not_nil + - contains_over_range_nil_comparison + - empty_collection_literal + - empty_count + - empty_string + - first_where + - force_unwrapping + - last_where + - legacy_multiple + - multiline_arguments + - multiline_function_chains + - multiline_parameters + - operator_usage_whitespace + - overridden_super_call + - prefer_self_type_over_type_of_self + - redundant_nil_coalescing + - redundant_type_annotation + - strict_fileprivate + - toggle_bool + - unneeded_parentheses_in_closure_argument + - yoda_condition + # Strongly typed rules + - discouraged_object_literal + - array_init + # Unused code detection rules (lint-time) + - unused_closure_parameter + - unused_control_flow_label + - unused_enumerated + - unused_optional_binding + - unused_setter_value + - redundant_optional_initialization + - redundant_set_access_control + - redundant_void_return + - redundant_objc_attribute + - redundant_string_enum_value + # Code quality rules + - sorted_imports + - trailing_semicolon + - opening_brace + - closure_parameter_position + +# Analyzer rules (require `swiftlint analyze` with compilation) +# Run: swiftlint analyze --compiler-log-path +analyzer_rules: + - unused_import + - unused_declaration + - typesafe_array_init + +# Disabled rules (too strict or cosmetic) +disabled_rules: + - explicit_type_interface # Swift's type inference is a feature + - implicit_return # Explicit returns are often clearer + - discouraged_optional_collection # Optional collections are valid Swift + - pattern_matching_keywords # Too opinionated + - vertical_whitespace_opening_braces # Too cosmetic + - vertical_whitespace_closing_braces # Too cosmetic + - trailing_closure # Explicit closures are often clearer + +# Directories to include +included: + - Sources + - Tests + +# Directories to exclude +excluded: + - .build + - DerivedData + - Package.swift + +# Configure individual rules +line_length: + warning: 150 + error: 200 + ignores_urls: true + ignores_function_declarations: true + ignores_comments: true + +file_length: + warning: 800 + error: 1500 + +function_body_length: + warning: 80 + error: 300 + +function_parameter_count: + warning: 8 + error: 15 + +type_body_length: + warning: 400 + error: 600 + +cyclomatic_complexity: + warning: 15 + error: 30 + +large_tuple: + warning: 3 + error: 5 + +identifier_name: + min_length: + warning: 2 + error: 1 + max_length: + warning: 40 + error: 50 + excluded: + - id + - i + - j + - k + - x + - y + - z + +type_name: + min_length: 3 + max_length: + warning: 40 + error: 50 + +# Custom configurations +force_cast: error +force_try: error +trailing_whitespace: + ignores_empty_lines: true +vertical_whitespace: + max_empty_lines: 2 + +# Additional rule configuration +redundant_string_enum_value: + severity: warning + +# Custom rules for strong typing +custom_rules: + avoid_any_type: + name: "Avoid Any Type" + regex: ':\s*Any\b' + message: "Avoid using 'Any' type. Use specific types or protocols instead." + severity: warning + + avoid_any_object: + name: "Avoid AnyObject Type" + regex: ':\s*AnyObject\b' + message: "Avoid using 'AnyObject' type. Use specific types or protocols instead." + severity: warning + + prefer_concrete_types: + name: "Prefer Concrete Types" + regex: ':\s*\[String:\s*Any\]' + message: "Avoid dictionaries with 'Any' values. Define a struct or use specific types." + severity: warning + + avoid_force_cast: + name: "Avoid Force Cast" + regex: ' as! ' + message: "Force casting can crash. Use conditional casting (as?) instead." + severity: error + + avoid_implicitly_unwrapped_optionals: + name: "Avoid Implicitly Unwrapped Optionals" + regex: ':\s*\w+!' + message: "Avoid implicitly unwrapped optionals. Use regular optionals or non-optional types." + severity: warning + + todo_with_issue: + name: "TODO Must Reference GitHub Issue" + regex: '//\s*(TODO|FIXME|HACK|XXX|BUG|REFACTOR|OPTIMIZE)(?!.*#\d+)' + message: "TODOs must reference a GitHub issue (e.g., // TODO: #123 - Description)" + severity: warning + + multiline_todo_with_issue: + name: "Multiline TODO Must Reference GitHub Issue" + regex: '/\*\s*(TODO|FIXME|HACK|XXX|BUG|REFACTOR|OPTIMIZE)(?!.*#\d+)' + message: "TODOs must reference a GitHub issue (e.g., /* TODO: #123 - Description */)" + severity: warning + + # Logging enforcement rules - require SDKLogger usage + # Note: SDKLogger.swift is the only file allowed to use print() as it's the console output mechanism. + # Use "// swiftlint:disable:next no_print_statements" above intentional print() usage. + no_print_statements: + name: "Use SDKLogger Instead of print()" + regex: '^\s*print\(' + message: "Use SDKLogger instead of print(). Example: SDKLogger.shared.debug(\"message\")" + severity: error + + no_nslog_statements: + name: "Use SDKLogger Instead of NSLog()" + regex: 'NSLog\(' + message: "Use SDKLogger instead of NSLog(). Example: SDKLogger.shared.info(\"message\")" + severity: error + + no_os_log_statements: + name: "Use SDKLogger Instead of os_log()" + regex: 'os_log\(' + message: "Use SDKLogger instead of os_log(). The SDK logging system handles os_log internally." + severity: error + + no_debug_print_statements: + name: "Use SDKLogger Instead of debugPrint()" + regex: 'debugPrint\(' + message: "Use SDKLogger.debug() instead of debugPrint(). Example: SDKLogger.shared.debug(\"message\")" + severity: error + + no_apple_logger: + name: "Use SDKLogger Instead of Apple Logger" + regex: '= Logger\(' + message: "Use SDKLogger instead of Apple's os.Logger. Example: SDKLogger(category: \"MyCategory\")" + severity: error diff --git a/sdk/runanywhere-swift/Package.resolved b/sdk/runanywhere-swift/Package.resolved new file mode 100644 index 000000000..f644bf071 --- /dev/null +++ b/sdk/runanywhere-swift/Package.resolved @@ -0,0 +1,86 @@ +{ + "pins" : [ + { + "identity" : "alamofire", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Alamofire/Alamofire.git", + "state" : { + "revision" : "7be73f6c2b5cd90e40798b06ebd5da8f9f79cf88", + "version" : "5.11.0" + } + }, + { + "identity" : "bitbytedata", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tsolomko/BitByteData", + "state" : { + "revision" : "cdcdc5177ad536cfb11b95c620f926a81014b7fe", + "version" : "2.0.4" + } + }, + { + "identity" : "devicekit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/devicekit/DeviceKit.git", + "state" : { + "revision" : "581df61650bc457ec00373a592a84be3e7468eb1", + "version" : "5.7.0" + } + }, + { + "identity" : "files", + "kind" : "remoteSourceControl", + "location" : "https://github.com/JohnSundell/Files.git", + "state" : { + "revision" : "e85f2b4a8dfa0f242889f45236f3867d16e40480", + "version" : "4.3.0" + } + }, + { + "identity" : "sentry-cocoa", + "kind" : "remoteSourceControl", + "location" : "https://github.com/getsentry/sentry-cocoa", + "state" : { + "revision" : "8627bbc0e663238d516b541d8fc7edd62a627fde", + "version" : "8.57.3" + } + }, + { + "identity" : "swcompression", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tsolomko/SWCompression.git", + "state" : { + "revision" : "390e0b0af8dd19a600005a242a89e570ff482e09", + "version" : "4.8.6" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "810496cf121e525d660cd0ea89a758740476b85f", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc", + "version" : "3.15.1" + } + }, + { + "identity" : "zipfoundation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/weichsel/ZIPFoundation.git", + "state" : { + "revision" : "22787ffb59de99e5dc1fbfe80b19c97a904ad48d", + "version" : "0.9.20" + } + } + ], + "version" : 2 +} diff --git a/sdk/runanywhere-swift/README.md b/sdk/runanywhere-swift/README.md new file mode 100644 index 000000000..3b03ab49a --- /dev/null +++ b/sdk/runanywhere-swift/README.md @@ -0,0 +1,781 @@ +# RunAnywhere Swift SDK + +A production-grade, on-device AI SDK for iOS, macOS, tvOS, and watchOS. The SDK enables low-latency, privacy-preserving inference for large language models, speech recognition, and voice synthesis with modular backend support. + +## Table of Contents + +- [Overview](#overview) +- [Features](#features) +- [Requirements](#requirements) +- [Installation](#installation) +- [Quick Start](#quick-start) +- [Configuration](#configuration) +- [Usage Examples](#usage-examples) +- [Architecture](#architecture) +- [Logging and Observability](#logging-and-observability) +- [Error Handling](#error-handling) +- [Performance Guidelines](#performance-guidelines) +- [FAQ](#faq) +- [Contributing](#contributing) +- [License](#license) + +--- + +## Overview + +The RunAnywhere Swift SDK enables developers to run AI models directly on Apple devices without requiring network connectivity for inference. By keeping data on-device, the SDK ensures minimal latency and maximum privacy for your users. + +The SDK provides a unified interface to multiple AI capabilities, including large language models (LLMs), speech-to-text (STT), text-to-speech (TTS), voice activity detection (VAD), and speaker diarization. These capabilities are delivered through pluggable backend modules that can be included as needed. + +### Key Capabilities + +- **Multi-backend architecture** - Choose from LlamaCPP (GGUF models), ONNX Runtime, or Apple Foundation Models +- **Metal acceleration** - GPU-accelerated inference on Apple Silicon +- **Event-driven design** - Subscribe to SDK events for reactive UI updates +- **Production-ready** - Built-in analytics, logging, device registration, and model lifecycle management + +--- + +## Features + +### Language Models (LLM) + +- On-device text generation with streaming support +- Structured output generation with `Generatable` protocol +- System prompts and customizable generation parameters +- Support for thinking/reasoning models with token extraction +- Multiple framework backends (LlamaCPP, Apple Foundation Models) + +### Speech-to-Text (STT) + +- Real-time streaming transcription +- Batch audio transcription +- Multi-language support +- Whisper-based models via ONNX Runtime + +### Text-to-Speech (TTS) + +- Neural voice synthesis with ONNX models +- System voices via AVSpeechSynthesizer +- Streaming audio generation for long text +- Customizable voice, pitch, rate, and volume + +### Voice Activity Detection (VAD) + +- Energy-based speech detection +- Configurable sensitivity thresholds +- Real-time audio stream processing + +### Speaker Diarization + +- Identify multiple speakers in audio +- Speaker segmentation and labeling +- Integration with FluidAudio + +### Voice Agent Pipeline + +- Full VAD to STT to LLM to TTS orchestration +- Complete voice conversation flow +- Streaming and batch processing modes + +### Model Management + +- Automatic model discovery and catalog sync +- Download with progress tracking (download, extract, validate stages) +- In-memory model storage with file system caching +- Framework-specific model assignment + +### Observability + +- Comprehensive event system via `EventBus` +- Analytics and telemetry integration +- Structured logging with Pulse support +- Performance metrics (tokens per second, latency, memory) + +--- + +## Requirements + +| Platform | Minimum Version | +|----------|-----------------| +| iOS | 17.0+ | +| macOS | 14.0+ | +| tvOS | 17.0+ | +| watchOS | 10.0+ | + +**Swift Version:** 5.9+ + +**Xcode:** 15.2+ + +Some optional modules have higher runtime requirements: +- Apple Foundation Models (`RunAnywhereAppleAI`): iOS 26+ / macOS 26+ at runtime + +--- + +## Installation + +### Swift Package Manager (Recommended) + +Add the RunAnywhere SDK to your project using Xcode: + +1. Open your project in Xcode +2. Go to **File > Add Package Dependencies...** +3. Enter the repository URL: + ``` + https://github.com/RunanywhereAI/runanywhere-sdks + ``` +4. Select the version (e.g., `from: "1.0.0"`) +5. Choose the products you need: + - **RunAnywhere** (required) - Core SDK + - **RunAnywhereONNX** - ONNX Runtime for STT/TTS/VAD + - **RunAnywhereLlamaCPP** - LLM text generation with GGUF models + +### Package.swift + +```swift +dependencies: [ + .package(url: "https://github.com/RunanywhereAI/runanywhere-sdks", from: "1.0.0") +], +targets: [ + .target( + name: "YourApp", + dependencies: [ + .product(name: "RunAnywhere", package: "runanywhere-sdks"), + .product(name: "RunAnywhereLlamaCPP", package: "runanywhere-sdks"), + .product(name: "RunAnywhereONNX", package: "runanywhere-sdks"), + ] + ) +] +``` + +### Package Structure + +This repository contains **two** `Package.swift` files for different use cases: + +| File | Location | Purpose | +|------|----------|---------| +| **Root Package.swift** | `runanywhere-sdks/Package.swift` | For external SPM consumers. Downloads pre-built XCFrameworks from GitHub releases. | +| **Local Package.swift** | `runanywhere-sdks/sdk/runanywhere-swift/Package.swift` | For SDK developers. Uses local XCFrameworks from `Binaries/` directory. | + +**For app developers:** Use the root-level package via the GitHub URL (as shown above). + +**For SDK contributors:** Use the local package with `testLocal = true` after running the setup script. + +--- + +## Quick Start + +### 1. Initialize the SDK + +```swift +import RunAnywhere +import LlamaCPPRuntime + +@main +struct MyApp: App { + init() { + Task { @MainActor in + // Register the LlamaCPP module for LLM support + LlamaCPP.register() + + // Initialize the SDK + do { + try RunAnywhere.initialize( + apiKey: "", + baseURL: "https://api.runanywhere.ai", + environment: .production + ) + } catch { + print("SDK initialization failed: \(error)") + } + } + } + + var body: some Scene { + WindowGroup { + ContentView() + } + } +} +``` + +### 2. Generate Text + +```swift +// Simple chat interface +let response = try await RunAnywhere.chat("What is the capital of France?") +print(response) // "The capital of France is Paris." + +// Full generation with metrics +let result = try await RunAnywhere.generate( + "Explain quantum computing in simple terms", + options: LLMGenerationOptions( + maxTokens: 200, + temperature: 0.7 + ) +) +print("Response: \(result.text)") +print("Tokens used: \(result.tokensUsed)") +print("Speed: \(result.tokensPerSecond) tok/s") +``` + +### 3. Load a Model + +```swift +// Load an LLM model by ID +try await RunAnywhere.loadModel("llama-3.2-1b-instruct-q4") + +// Check if model is loaded +let isLoaded = await RunAnywhere.isModelLoaded +``` + +--- + +## Configuration + +### SDK Initialization Parameters + +```swift +try RunAnywhere.initialize( + apiKey: "", + baseURL: "https://api.runanywhere.ai", + environment: .production +) +``` + +### Environment Modes + +| Environment | Description | +|-----------------|--------------------------------------------------| +| `.development` | Verbose logging, mock services, local analytics | +| `.staging` | Testing with real services | +| `.production` | Minimal logging, full authentication, telemetry | + +### Generation Options + +```swift +let options = LLMGenerationOptions( + maxTokens: 100, + temperature: 0.8, + topP: 1.0, + stopSequences: ["END"], + streamingEnabled: false, + preferredFramework: .llamaCpp, + systemPrompt: "You are a helpful assistant." +) +``` + +### Module Registration + +Register modules at app startup before using their capabilities: + +```swift +import RunAnywhere +import LlamaCPPRuntime +import ONNXRuntime + +@MainActor +func setupSDK() { + LlamaCPP.register() // LLM (priority: 100) + ONNX.register() // STT + TTS (priority: 100) +} +``` + +--- + +## Usage Examples + +### Streaming Text Generation + +```swift +let result = try await RunAnywhere.generateStream( + "Write a short poem about AI", + options: LLMGenerationOptions(maxTokens: 150) +) + +for try await token in result.stream { + print(token, terminator: "") +} + +let metrics = try await result.result.value +print("\nSpeed: \(metrics.tokensPerSecond) tok/s") +``` + +### Structured Output Generation + +```swift +struct QuizQuestion: Generatable { + let question: String + let options: [String] + let correctAnswer: Int + + static var jsonSchema: String { + """ + { + "type": "object", + "properties": { + "question": { "type": "string" }, + "options": { "type": "array", "items": { "type": "string" } }, + "correctAnswer": { "type": "integer" } + }, + "required": ["question", "options", "correctAnswer"] + } + """ + } +} + +let quiz: QuizQuestion = try await RunAnywhere.generateStructured( + QuizQuestion.self, + prompt: "Create a quiz question about Swift programming" +) +``` + +### Speech-to-Text Transcription + +```swift +import RunAnywhere +import ONNXRuntime + +await ONNX.register() +try await RunAnywhere.loadSTTModel("whisper-base-onnx") + +let audioData: Data = // your audio data (16kHz, mono, Float32) +let transcription = try await RunAnywhere.transcribe(audioData) +print("Transcribed: \(transcription)") +``` + +### Text-to-Speech Synthesis + +```swift +try await RunAnywhere.loadTTSVoice("piper-en-us-amy") + +let output = try await RunAnywhere.synthesize( + "Hello! Welcome to RunAnywhere.", + options: TTSOptions( + speakingRate: 1.0, + pitch: 1.0, + volume: 0.8 + ) +) +``` + +### Voice Agent Pipeline + +```swift +try await RunAnywhere.initializeVoiceAgent( + sttModelId: "whisper-base-onnx", + llmModelId: "llama-3.2-1b-instruct-q4", + ttsVoice: "com.apple.ttsbundle.siri_female_en-US_compact" +) + +let audioData: Data = // recorded audio +let result = try await RunAnywhere.processVoiceTurn(audioData) + +print("User said: \(result.transcription)") +print("AI response: \(result.response)") + +await RunAnywhere.cleanupVoiceAgent() +``` + +### Subscribing to Events + +```swift +import Combine + +class ViewModel: ObservableObject { + private var cancellables = Set() + + init() { + RunAnywhere.events.events + .receive(on: DispatchQueue.main) + .sink { event in + print("Event: \(event.type)") + } + .store(in: &cancellables) + + RunAnywhere.events.events(for: .llm) + .sink { event in + print("LLM Event: \(event.type)") + } + .store(in: &cancellables) + } +} +``` + +### Model Download with Progress + +```swift +let models = try await RunAnywhere.availableModels() +let model = models.first { $0.id == "llama-3.2-1b-instruct-q4" }! + +let task = try await Download.shared.downloadModel(model) + +for await progress in task.progress { + let percent = Int(progress.overallProgress * 100) + print("\(progress.stage.displayName): \(percent)%") +} +``` + +--- + +## Architecture + +The RunAnywhere SDK follows a modular, provider-based architecture that separates core functionality from specific backend implementations: + +``` ++------------------------------------------------------------------+ +| Public API | +| RunAnywhere.generate() / transcribe() / synthesize() | ++------------------------------------------------------------------+ + | ++------------------------------------------------------------------+ +| Capability Layer | +| LLMCapability | STTCapability | TTSCapability | ... | ++------------------------------------------------------------------+ + | ++------------------------------------------------------------------+ +| ServiceRegistry | +| Routes requests to registered service providers | ++------------------------------------------------------------------+ + | + +--------------------+--------------------+ + v v v ++------------------+ +------------------+ +------------------+ +| LlamaCPP Module | | ONNX Module | | AppleAI Module | +| (LLM: GGUF) | | (STT + TTS) | | (LLM: iOS 26+) | ++------------------+ +------------------+ +------------------+ + | | | + v v v ++------------------------------------------------------------------+ +| Native Runtime / XCFramework | +| RunAnywhereCore (C++ with Metal acceleration) | ++------------------------------------------------------------------+ +``` + +**Key Components:** + +- **ModuleRegistry** - Discovers and tracks registered modules +- **ServiceRegistry** - Routes capability requests to the appropriate provider +- **Capability Classes** - Handle business logic, events, and analytics +- **EventBus** - Pub/sub system for SDK-wide events +- **ServiceContainer** - Dependency injection container + +For detailed architecture documentation, see [ARCHITECTURE.md](ARCHITECTURE.md). + +--- + +## Logging and Observability + +### Configure Log Level + +```swift +RunAnywhere.setLogLevel(.debug) +RunAnywhere.configureLocalLogging(enabled: true) +RunAnywhere.setDebugMode(true) +await RunAnywhere.flushAll() +``` + +### Log Levels + +| Level | Description | +|------------|------------------------------------------------| +| `.debug` | Detailed information for debugging | +| `.info` | General operational information | +| `.warning` | Potential issues that don't prevent operation | +| `.error` | Errors that affect specific operations | +| `.fault` | Critical errors indicating serious problems | + +### Analytics + +The SDK automatically tracks key metrics: + +- Generation latency and tokens per second +- Model load times and memory usage +- Error rates by category +- User session analytics (opt-in) + +--- + +## Error Handling + +All SDK errors are represented by `SDKError`, which provides: + +- Typed error cases for each error category +- Detailed error descriptions +- Recovery suggestions +- Underlying error information when applicable + +### Error Categories + +```swift +case notInitialized +case invalidAPIKey(String?) +case invalidConfiguration(String) +case modelNotFound(String) +case modelLoadFailed(String, Error?) +case modelIncompatible(String, String) +case generationFailed(String) +case generationTimeout(String?) +case contextTooLong(Int, Int) +case networkUnavailable +case downloadFailed(String, Error?) +case insufficientStorage(Int64, Int64) +case storageFull +``` + +### Handling Errors + +```swift +do { + let result = try await RunAnywhere.generate("Hello") +} catch let error as SDKError { + switch error.code { + case .notInitialized: + print("Please call RunAnywhere.initialize() first") + case .modelNotFound: + print("Model not found. Download it first.") + case .generationFailed: + print("Generation failed: \(error.message)") + default: + print("Error: \(error.localizedDescription)") + if let suggestion = error.recoverySuggestion { + print("Suggestion: \(suggestion)") + } + } +} +``` + +--- + +## Performance Guidelines + +### Model Selection + +- Smaller models (1-3B parameters) work well for most on-device use cases +- Q4/Q5 quantization provides good balance of quality and speed +- Test on target devices; performance varies significantly by hardware + +### Memory Management + +```swift +// Unload models when not in use +try await RunAnywhere.unloadModel() + +// Check storage before downloading +let storageInfo = await RunAnywhere.getStorageInfo() +if storageInfo.availableBytes > model.downloadSize ?? 0 { + // Safe to download +} + +// Clean up temporary files periodically +try await RunAnywhere.cleanTempFiles() +``` + +### Threading + +- SDK methods are async and safe to call from any context +- Heavy operations (model loading, generation) run on background threads +- UI updates from event subscriptions should dispatch to main thread + +### Streaming for Responsiveness + +```swift +let result = try await RunAnywhere.generateStream(prompt) +for try await token in result.stream { + await MainActor.run { self.text += token } +} +``` + +--- + +## FAQ + +### Do I need an internet connection to use the SDK? + +No, once models are downloaded, all inference happens on-device. You only need internet for: +- Initial SDK authentication +- Downloading models +- Syncing analytics (optional) + +### Which models are supported? + +The SDK supports: +- **GGUF models** via LlamaCPP (Llama, Mistral, Phi, Qwen, etc.) +- **ONNX models** for STT (Whisper variants) and TTS (Piper voices) +- **Apple Foundation Models** on iOS 26+ (built-in, no download) + +### How much storage do models require? + +Model sizes vary significantly: +- Small LLMs (1-3B Q4): 500MB - 2GB +- Medium LLMs (7B Q4): 3-5GB +- STT models: 50-500MB +- TTS voices: 20-100MB + +### Can I use multiple models simultaneously? + +Currently, one LLM can be loaded at a time. STT and TTS models can be loaded alongside LLM models. Use `unloadModel()` before loading a different LLM. + +### How do I handle model updates? + +Call `fetchModelAssignments(forceRefresh: true)` to sync the latest model catalog. New versions can be downloaded alongside existing models. + +### Is user data sent to the cloud? + +By default, only anonymous analytics (latency, error rates) are collected. Actual prompts, responses, and audio data never leave the device. + +### How do I debug issues? + +1. Enable debug mode: `RunAnywhere.setDebugMode(true)` +2. Check logs with Pulse integration +3. Subscribe to error events: `RunAnywhere.events.on(.error) { ... }` + +### What's the difference between chat() and generate()? + +- `chat(_:)` returns just the text string +- `generate(_:options:)` returns `LLMGenerationResult` with full metrics + +--- + +## Local Development & Contributing + +We welcome contributions to the RunAnywhere Swift SDK. This section explains how to set up your development environment to build the SDK from source and test your changes with the sample app. + +### Prerequisites + +- macOS 14.0 or later +- Xcode 15.2 or later +- CMake 3.21+ (for building native frameworks) + +### First-Time Setup (Build from Source) + +The SDK depends on native C++ libraries from `runanywhere-commons`. The setup script builds these locally so you can develop and test the SDK end-to-end. + +```bash +# 1. Clone the repository +git clone https://github.com/RunanywhereAI/runanywhere-sdks.git +cd runanywhere-sdks/sdk/runanywhere-swift + +# 2. Run first-time setup (~5-15 minutes) +./scripts/build-swift.sh --setup +``` + +**What the setup script does:** +1. Downloads dependencies (ONNX Runtime, Sherpa-ONNX) +2. Builds `RACommons.xcframework` (core infrastructure) +3. Builds `RABackendLLAMACPP.xcframework` (LLM backend) +4. Builds `RABackendONNX.xcframework` (STT/TTS/VAD backend) +5. Copies frameworks to `Binaries/` +6. Sets `testLocal = true` in Package.swift (enables local framework consumption) + +### Understanding testLocal + +The SDK has two modes controlled by `testLocal` in `Package.swift`: + +| Mode | Setting | Description | +|------|---------|-------------| +| **Local** | `testLocal = true` | Uses XCFrameworks from `Binaries/` (for development) | +| **Remote** | `testLocal = false` | Downloads XCFrameworks from GitHub releases (for end users) | + +When you run `--setup`, the script automatically sets `testLocal = true`. + +### Testing with the iOS Sample App + +The recommended way to test SDK changes is with the sample app: + +```bash +# 1. Ensure SDK is set up (from previous step) + +# 2. Navigate to the sample app +cd ../../examples/ios/RunAnywhereAI + +# 3. Open in Xcode +open RunAnywhereAI.xcodeproj + +# 4. If Xcode shows package errors, reset caches: +# File > Packages > Reset Package Caches + +# 5. Build and Run (⌘+R) +``` + +The sample app's `Package.swift` references the local SDK, which in turn uses the local frameworks from `Binaries/`. This creates a complete local development loop: + +``` +Sample App → Local Swift SDK → Local XCFrameworks (Binaries/) + ↑ + Built by build-swift.sh --setup +``` + +### Development Workflow + +**After modifying Swift SDK code:** +- No rebuild needed—Xcode picks up changes automatically + +**After modifying runanywhere-commons (C++ code):** + +```bash +cd sdk/runanywhere-swift +./scripts/build-swift.sh --local --build-commons +``` + +### Build Script Reference + +| Command | Description | +|---------|-------------| +| `--setup` | First-time setup: downloads deps, builds all frameworks, sets `testLocal = true` | +| `--local` | Use local frameworks from `Binaries/` | +| `--remote` | Use remote frameworks from GitHub releases | +| `--build-commons` | Rebuild runanywhere-commons from source | +| `--clean` | Clean build artifacts before building | +| `--release` | Build in release mode (default: debug) | +| `--skip-build` | Only setup frameworks, skip swift build | +| `--set-local` | Set `testLocal = true` in Package.swift | +| `--set-remote` | Set `testLocal = false` in Package.swift | + +### Running Tests + +```bash +swift test +``` + +### Code Style + +The project uses SwiftLint for code style enforcement: + +```bash +brew install swiftlint +swiftlint +``` + +### Pull Request Process + +1. Fork the repository +2. Create a feature branch: `git checkout -b feature/my-feature` +3. Make your changes with tests +4. Ensure all tests pass: `swift test` +5. Run linter: `swiftlint` +6. Commit with a descriptive message +7. Push and open a Pull Request + +### Reporting Issues + +Open an issue on GitHub with: +- SDK version (check with `RunAnywhere.version`) +- Platform and OS version +- Steps to reproduce +- Expected vs actual behavior +- Relevant logs (with sensitive info redacted) + +### Contact + +- **Discord:** https://discord.gg/pxRkYmWh +- **Email:** san@runanywhere.ai +- **GitHub Issues:** https://github.com/RunanywhereAI/runanywhere-sdks/issues + +--- + +## License + +Copyright 2025 RunAnywhere AI. All rights reserved. + +See the repository for license terms. For commercial licensing inquiries, contact san@runanywhere.ai. diff --git a/sdk/runanywhere-swift/Sources/LlamaCPPRuntime/LlamaCPP.swift b/sdk/runanywhere-swift/Sources/LlamaCPPRuntime/LlamaCPP.swift new file mode 100644 index 000000000..820677ed9 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/LlamaCPPRuntime/LlamaCPP.swift @@ -0,0 +1,133 @@ +// +// LlamaCPP.swift +// LlamaCPPRuntime Module +// +// Unified LlamaCPP module - thin wrapper that calls C++ backend registration. +// This replaces both LlamaCPPRuntime.swift and LlamaCPPServiceProvider.swift. +// + +import CRACommons +import Foundation +import LlamaCPPBackend +import os.log +import RunAnywhere + +// MARK: - LlamaCPP Module + +/// LlamaCPP module for LLM text generation. +/// +/// Provides large language model capabilities using llama.cpp +/// with GGUF models and Metal acceleration. +/// +/// ## Registration +/// +/// ```swift +/// import LlamaCPPRuntime +/// +/// // Register the backend (done automatically if auto-registration is enabled) +/// try LlamaCPP.register() +/// ``` +/// +/// ## Usage +/// +/// LLM services are accessed through the main SDK APIs - the C++ backend handles +/// service creation and lifecycle internally: +/// +/// ```swift +/// // Generate text via public API +/// let response = try await RunAnywhere.chat("Hello!") +/// +/// // Stream text via public API +/// for try await token in try await RunAnywhere.streamChat("Tell me a story") { +/// print(token, terminator: "") +/// } +/// ``` +public enum LlamaCPP: RunAnywhereModule { + private static let logger = SDKLogger(category: "LlamaCPP") + + // MARK: - Module Info + + /// Current version of the LlamaCPP Runtime module + public static let version = "2.0.0" + + /// LlamaCPP library version (underlying C++ library) + public static let llamaCppVersion = "b7199" + + // MARK: - RunAnywhereModule Conformance + + public static let moduleId = "llamacpp" + public static let moduleName = "LlamaCPP" + public static let capabilities: Set = [.llm] + public static let defaultPriority: Int = 100 + + /// LlamaCPP uses the llama.cpp inference framework + public static let inferenceFramework: InferenceFramework = .llamaCpp + + // MARK: - Registration State + + private static var isRegistered = false + + // MARK: - Registration + + /// Register LlamaCPP backend with the C++ service registry. + /// + /// This calls `rac_backend_llamacpp_register()` to register the + /// LlamaCPP service provider with the C++ commons layer. + /// + /// Safe to call multiple times - subsequent calls are no-ops. + /// + /// - Parameter priority: Ignored (C++ uses its own priority system) + /// - Throws: SDKError if registration fails + @MainActor + public static func register(priority _: Int = 100) { + guard !isRegistered else { + logger.debug("LlamaCPP already registered, returning") + return + } + + logger.info("Registering LlamaCPP backend with C++ registry...") + + let result = rac_backend_llamacpp_register() + + // RAC_ERROR_MODULE_ALREADY_REGISTERED is OK + if result != RAC_SUCCESS && result != RAC_ERROR_MODULE_ALREADY_REGISTERED { + let errorMsg = String(cString: rac_error_message(result)) + logger.error("LlamaCPP registration failed: \(errorMsg)") + // Don't throw - registration failure shouldn't crash the app + return + } + + isRegistered = true + logger.info("LlamaCPP backend registered successfully") + } + + /// Unregister the LlamaCPP backend from C++ registry. + public static func unregister() { + guard isRegistered else { return } + + _ = rac_backend_llamacpp_unregister() + isRegistered = false + logger.info("LlamaCPP backend unregistered") + } + + // MARK: - Model Handling + + /// Check if LlamaCPP can handle a given model + /// Uses file extension pattern matching - actual framework info is in C++ registry + public static func canHandle(modelId: String?) -> Bool { + guard let modelId = modelId else { return false } + return modelId.lowercased().hasSuffix(".gguf") + } +} + +// MARK: - Auto-Registration + +extension LlamaCPP { + /// Enable auto-registration for this module. + /// Access this property to trigger C++ backend registration. + public static let autoRegister: Void = { + Task { @MainActor in + LlamaCPP.register() + } + }() +} diff --git a/sdk/runanywhere-swift/Sources/LlamaCPPRuntime/README.md b/sdk/runanywhere-swift/Sources/LlamaCPPRuntime/README.md new file mode 100644 index 000000000..b689e1a08 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/LlamaCPPRuntime/README.md @@ -0,0 +1,280 @@ +# LlamaCPPRuntime Module + +The LlamaCPPRuntime module provides large language model (LLM) text generation capabilities for the RunAnywhere Swift SDK using llama.cpp with GGUF models and Metal acceleration. + +## Overview + +This module enables on-device text generation with support for: + +- GGUF model format (Llama, Mistral, Phi, Qwen, and other llama.cpp-compatible models) +- Streaming and non-streaming generation +- Metal GPU acceleration on Apple Silicon +- Configurable generation parameters (temperature, top-p, max tokens) +- System prompts and structured output + +## Requirements + +| Platform | Minimum Version | +|----------|-----------------| +| iOS | 17.0+ | +| macOS | 14.0+ | + +The module requires the `RABackendLlamaCPP.xcframework` binary, which is automatically included when you add the SDK as a dependency. + +## Installation + +The LlamaCPPRuntime module is included in the RunAnywhere SDK. Add it to your target: + +### Swift Package Manager + +```swift +dependencies: [ + .package(url: "https://github.com/RunanywhereAI/runanywhere-sdks", from: "0.16.0") +], +targets: [ + .target( + name: "YourApp", + dependencies: [ + .product(name: "RunAnywhere", package: "runanywhere-sdks"), + .product(name: "RunAnywhereLlamaCPP", package: "runanywhere-sdks"), + ] + ) +] +``` + +### Xcode + +1. Go to **File > Add Package Dependencies...** +2. Enter: `https://github.com/RunanywhereAI/runanywhere-sdks` +3. Select version and add `RunAnywhereLlamaCPP` to your target + +## Usage + +### Registration + +Register the module at app startup before using LLM capabilities: + +```swift +import RunAnywhere +import LlamaCPPRuntime + +@main +struct MyApp: App { + init() { + Task { @MainActor in + LlamaCPP.register() + + try RunAnywhere.initialize( + apiKey: "", + baseURL: "https://api.runanywhere.ai", + environment: .production + ) + } + } + + var body: some Scene { + WindowGroup { ContentView() } + } +} +``` + +### Loading a Model + +```swift +// Load a GGUF model by ID +try await RunAnywhere.loadModel("llama-3.2-1b-instruct-q4") + +// Check if model is loaded +let isLoaded = await RunAnywhere.isModelLoaded +``` + +### Text Generation + +```swift +// Simple chat +let response = try await RunAnywhere.chat("What is the capital of France?") +print(response) + +// Generation with options and metrics +let result = try await RunAnywhere.generate( + "Explain quantum computing in simple terms", + options: LLMGenerationOptions( + maxTokens: 200, + temperature: 0.7, + systemPrompt: "You are a helpful assistant." + ) +) + +print("Response: \(result.text)") +print("Tokens used: \(result.tokensUsed)") +print("Speed: \(result.tokensPerSecond) tok/s") +``` + +### Streaming Generation + +```swift +let result = try await RunAnywhere.generateStream( + "Write a short poem about technology", + options: LLMGenerationOptions(maxTokens: 150) +) + +// Display tokens in real-time +for try await token in result.stream { + print(token, terminator: "") +} + +// Get complete metrics after streaming finishes +let metrics = try await result.result.value +print("\nSpeed: \(metrics.tokensPerSecond) tok/s") +print("Total tokens: \(metrics.tokensUsed)") +``` + +### Structured Output + +```swift +struct QuizQuestion: Generatable { + let question: String + let options: [String] + let correctAnswer: Int + + static var jsonSchema: String { + """ + { + "type": "object", + "properties": { + "question": { "type": "string" }, + "options": { "type": "array", "items": { "type": "string" } }, + "correctAnswer": { "type": "integer" } + }, + "required": ["question", "options", "correctAnswer"] + } + """ + } +} + +let quiz: QuizQuestion = try await RunAnywhere.generateStructured( + QuizQuestion.self, + prompt: "Create a quiz question about Swift programming" +) +``` + +### Unloading + +```swift +try await RunAnywhere.unloadModel() +``` + +## API Reference + +### LlamaCPP Module + +```swift +public enum LlamaCPP: RunAnywhereModule { + /// Module identifier + public static let moduleId = "llamacpp" + + /// Human-readable module name + public static let moduleName = "LlamaCPP" + + /// Capabilities provided by this module + public static let capabilities: Set = [.llm] + + /// Default registration priority + public static let defaultPriority: Int = 100 + + /// Inference framework used + public static let inferenceFramework: InferenceFramework = .llamaCpp + + /// Module version + public static let version = "2.0.0" + + /// Underlying llama.cpp library version + public static let llamaCppVersion = "b7199" + + /// Register the module with the service registry + @MainActor + public static func register(priority: Int = 100) + + /// Unregister the module + public static func unregister() + + /// Check if the module can handle a given model + public static func canHandle(modelId: String?) -> Bool +} +``` + +### Model Compatibility + +The LlamaCPP module handles models with the `.gguf` file extension. Compatible model families include: + +- Llama (1B, 3B, 7B, etc.) +- Mistral +- Phi +- Qwen +- DeepSeek +- Other llama.cpp-compatible architectures + +### Generation Options + +Key options for LLM generation: + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `maxTokens` | Int | 100 | Maximum tokens to generate | +| `temperature` | Float | 0.8 | Sampling temperature (0.0 - 2.0) | +| `topP` | Float | 1.0 | Top-p sampling parameter | +| `stopSequences` | [String] | [] | Stop generation at these sequences | +| `systemPrompt` | String? | nil | System prompt for generation | + +## Architecture + +The module follows a thin wrapper pattern: + +``` +LlamaCPP.swift (Swift wrapper) + | +LlamaCPPBackend (C headers) + | +RABackendLlamaCPP.xcframework (C++ implementation) + | +llama.cpp (Core inference engine) +``` + +The Swift code registers the backend with the C++ service registry, which handles all model loading and inference operations internally. + +## Performance + +Typical performance on Apple Silicon: + +| Device | Model | Tokens/sec | +|--------|-------|------------| +| iPhone 15 Pro | Llama 3.2 1B Q4 | 25-35 | +| iPhone 15 Pro | Llama 3.2 3B Q4 | 15-20 | +| M1 MacBook | Llama 3.2 1B Q4 | 40-50 | +| M1 MacBook | Llama 3.2 7B Q4 | 20-30 | + +Performance varies based on model size, quantization, context length, and device thermal state. + +## Troubleshooting + +### Model Load Fails + +1. Ensure the model is downloaded: check `ModelInfo.isDownloaded` +2. Verify the model format is GGUF +3. Check available memory (large models require significant RAM) + +### Slow Generation + +1. Use smaller quantization (Q4 vs Q8) +2. Reduce context length +3. Ensure device is not thermally throttled + +### Registration Not Working + +1. Ensure `register()` is called on the main actor +2. Call `register()` before `RunAnywhere.initialize()` +3. Check for registration errors in logs + +## License + +Copyright 2025 RunAnywhere AI. All rights reserved. diff --git a/sdk/runanywhere-swift/Sources/LlamaCPPRuntime/include/LlamaCPPBackend.h b/sdk/runanywhere-swift/Sources/LlamaCPPRuntime/include/LlamaCPPBackend.h new file mode 100644 index 000000000..4fded02f3 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/LlamaCPPRuntime/include/LlamaCPPBackend.h @@ -0,0 +1,15 @@ +/** + * @file LlamaCPPBackend.h + * @brief Umbrella header for LlamaCPP backend C APIs + * + * This header exposes the LlamaCPP backend C API to Swift. + * Part of the unified LlamaCPPRuntime module. + */ + +#ifndef LLAMACPP_BACKEND_H +#define LLAMACPP_BACKEND_H + +// Include the LlamaCPP backend header +#include "rac_llm_llamacpp.h" + +#endif /* LLAMACPP_BACKEND_H */ diff --git a/sdk/runanywhere-swift/Sources/LlamaCPPRuntime/include/module.modulemap b/sdk/runanywhere-swift/Sources/LlamaCPPRuntime/include/module.modulemap new file mode 100644 index 000000000..1c4f0fdef --- /dev/null +++ b/sdk/runanywhere-swift/Sources/LlamaCPPRuntime/include/module.modulemap @@ -0,0 +1,11 @@ +module LlamaCPPBackend { + umbrella header "LlamaCPPBackend.h" + + export * + module * { export * } + + link framework "Accelerate" + link framework "Metal" + link framework "MetalKit" + link framework "MetalPerformanceShaders" +} diff --git a/sdk/runanywhere-swift/Sources/LlamaCPPRuntime/include/rac_error.h b/sdk/runanywhere-swift/Sources/LlamaCPPRuntime/include/rac_error.h new file mode 100644 index 000000000..35d3b4ec4 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/LlamaCPPRuntime/include/rac_error.h @@ -0,0 +1,467 @@ +/** + * @file rac_error.h + * @brief RunAnywhere Commons - Error Codes and Error Handling + * + * C port of Swift's ErrorCode enum from Foundation/Errors/ErrorCode.swift. + * + * Error codes for runanywhere-commons use the range -100 to -999 to avoid + * collision with runanywhere-core error codes (0 to -99). + * + * IMPORTANT: This is a direct translation of the Swift implementation. + * Do NOT add error codes not present in the Swift code. + */ + +#ifndef RAC_ERROR_H +#define RAC_ERROR_H + +#include "rac_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// ERROR CODE RANGES +// ============================================================================= +// +// runanywhere-core (ra_*): 0 to -99 +// runanywhere-commons (rac_*): -100 to -999 +// - Initialization errors: -100 to -109 +// - Model errors: -110 to -129 +// - Generation errors: -130 to -149 +// - Network errors: -150 to -179 +// - Storage errors: -180 to -219 +// - Hardware errors: -220 to -229 +// - Component state errors: -230 to -249 +// - Validation errors: -250 to -279 +// - Audio errors: -280 to -299 +// - Language/Voice errors: -300 to -319 +// - Authentication errors: -320 to -329 +// - Security errors: -330 to -349 +// - Extraction errors: -350 to -369 +// - Calibration errors: -370 to -379 +// - Module/Service errors: -400 to -499 +// - Platform adapter errors: -500 to -599 +// - Backend errors: -600 to -699 +// - Event errors: -700 to -799 +// - Other errors: -800 to -899 +// - Reserved: -900 to -999 + +// ============================================================================= +// INITIALIZATION ERRORS (-100 to -109) +// Mirrors Swift's ErrorCode: Initialization Errors +// ============================================================================= + +/** Component or service has not been initialized */ +#define RAC_ERROR_NOT_INITIALIZED ((rac_result_t) - 100) +/** Component or service is already initialized */ +#define RAC_ERROR_ALREADY_INITIALIZED ((rac_result_t) - 101) +/** Initialization failed */ +#define RAC_ERROR_INITIALIZATION_FAILED ((rac_result_t) - 102) +/** Configuration is invalid */ +#define RAC_ERROR_INVALID_CONFIGURATION ((rac_result_t) - 103) +/** API key is invalid or missing */ +#define RAC_ERROR_INVALID_API_KEY ((rac_result_t) - 104) +/** Environment mismatch (e.g., dev vs prod) */ +#define RAC_ERROR_ENVIRONMENT_MISMATCH ((rac_result_t) - 105) + +// ============================================================================= +// MODEL ERRORS (-110 to -129) +// Mirrors Swift's ErrorCode: Model Errors +// ============================================================================= + +/** Requested model was not found */ +#define RAC_ERROR_MODEL_NOT_FOUND ((rac_result_t) - 110) +/** Failed to load the model */ +#define RAC_ERROR_MODEL_LOAD_FAILED ((rac_result_t) - 111) +/** Model validation failed */ +#define RAC_ERROR_MODEL_VALIDATION_FAILED ((rac_result_t) - 112) +/** Model is incompatible with current runtime */ +#define RAC_ERROR_MODEL_INCOMPATIBLE ((rac_result_t) - 113) +/** Model format is invalid */ +#define RAC_ERROR_INVALID_MODEL_FORMAT ((rac_result_t) - 114) +/** Model storage is corrupted */ +#define RAC_ERROR_MODEL_STORAGE_CORRUPTED ((rac_result_t) - 115) +/** Model not loaded (alias for backward compatibility) */ +#define RAC_ERROR_MODEL_NOT_LOADED ((rac_result_t) - 116) + +// ============================================================================= +// GENERATION ERRORS (-130 to -149) +// Mirrors Swift's ErrorCode: Generation Errors +// ============================================================================= + +/** Text/audio generation failed */ +#define RAC_ERROR_GENERATION_FAILED ((rac_result_t) - 130) +/** Generation timed out */ +#define RAC_ERROR_GENERATION_TIMEOUT ((rac_result_t) - 131) +/** Context length exceeded maximum */ +#define RAC_ERROR_CONTEXT_TOO_LONG ((rac_result_t) - 132) +/** Token limit exceeded */ +#define RAC_ERROR_TOKEN_LIMIT_EXCEEDED ((rac_result_t) - 133) +/** Cost limit exceeded */ +#define RAC_ERROR_COST_LIMIT_EXCEEDED ((rac_result_t) - 134) +/** Inference failed */ +#define RAC_ERROR_INFERENCE_FAILED ((rac_result_t) - 135) + +// ============================================================================= +// NETWORK ERRORS (-150 to -179) +// Mirrors Swift's ErrorCode: Network Errors +// ============================================================================= + +/** Network is unavailable */ +#define RAC_ERROR_NETWORK_UNAVAILABLE ((rac_result_t) - 150) +/** Generic network error */ +#define RAC_ERROR_NETWORK_ERROR ((rac_result_t) - 151) +/** Request failed */ +#define RAC_ERROR_REQUEST_FAILED ((rac_result_t) - 152) +/** Download failed */ +#define RAC_ERROR_DOWNLOAD_FAILED ((rac_result_t) - 153) +/** Server returned an error */ +#define RAC_ERROR_SERVER_ERROR ((rac_result_t) - 154) +/** Request timed out */ +#define RAC_ERROR_TIMEOUT ((rac_result_t) - 155) +/** Invalid response from server */ +#define RAC_ERROR_INVALID_RESPONSE ((rac_result_t) - 156) +/** HTTP error with status code */ +#define RAC_ERROR_HTTP_ERROR ((rac_result_t) - 157) +/** Connection was lost */ +#define RAC_ERROR_CONNECTION_LOST ((rac_result_t) - 158) +/** Partial download (incomplete) */ +#define RAC_ERROR_PARTIAL_DOWNLOAD ((rac_result_t) - 159) +/** HTTP request failed */ +#define RAC_ERROR_HTTP_REQUEST_FAILED ((rac_result_t) - 160) +/** HTTP not supported */ +#define RAC_ERROR_HTTP_NOT_SUPPORTED ((rac_result_t) - 161) + +// ============================================================================= +// STORAGE ERRORS (-180 to -219) +// Mirrors Swift's ErrorCode: Storage Errors +// ============================================================================= + +/** Insufficient storage space */ +#define RAC_ERROR_INSUFFICIENT_STORAGE ((rac_result_t) - 180) +/** Storage is full */ +#define RAC_ERROR_STORAGE_FULL ((rac_result_t) - 181) +/** Generic storage error */ +#define RAC_ERROR_STORAGE_ERROR ((rac_result_t) - 182) +/** File was not found */ +#define RAC_ERROR_FILE_NOT_FOUND ((rac_result_t) - 183) +/** Failed to read file */ +#define RAC_ERROR_FILE_READ_FAILED ((rac_result_t) - 184) +/** Failed to write file */ +#define RAC_ERROR_FILE_WRITE_FAILED ((rac_result_t) - 185) +/** Permission denied for file operation */ +#define RAC_ERROR_PERMISSION_DENIED ((rac_result_t) - 186) +/** Failed to delete file or directory */ +#define RAC_ERROR_DELETE_FAILED ((rac_result_t) - 187) +/** Failed to move file */ +#define RAC_ERROR_MOVE_FAILED ((rac_result_t) - 188) +/** Failed to create directory */ +#define RAC_ERROR_DIRECTORY_CREATION_FAILED ((rac_result_t) - 189) +/** Directory not found */ +#define RAC_ERROR_DIRECTORY_NOT_FOUND ((rac_result_t) - 190) +/** Invalid file path */ +#define RAC_ERROR_INVALID_PATH ((rac_result_t) - 191) +/** Invalid file name */ +#define RAC_ERROR_INVALID_FILE_NAME ((rac_result_t) - 192) +/** Failed to create temporary file */ +#define RAC_ERROR_TEMP_FILE_CREATION_FAILED ((rac_result_t) - 193) +/** File delete failed (alias) */ +#define RAC_ERROR_FILE_DELETE_FAILED ((rac_result_t) - 187) + +// ============================================================================= +// HARDWARE ERRORS (-220 to -229) +// Mirrors Swift's ErrorCode: Hardware Errors +// ============================================================================= + +/** Hardware is unsupported */ +#define RAC_ERROR_HARDWARE_UNSUPPORTED ((rac_result_t) - 220) +/** Insufficient memory */ +#define RAC_ERROR_INSUFFICIENT_MEMORY ((rac_result_t) - 221) +/** Out of memory (alias) */ +#define RAC_ERROR_OUT_OF_MEMORY ((rac_result_t) - 221) + +// ============================================================================= +// COMPONENT STATE ERRORS (-230 to -249) +// Mirrors Swift's ErrorCode: Component State Errors +// ============================================================================= + +/** Component is not ready */ +#define RAC_ERROR_COMPONENT_NOT_READY ((rac_result_t) - 230) +/** Component is in invalid state */ +#define RAC_ERROR_INVALID_STATE ((rac_result_t) - 231) +/** Service is not available */ +#define RAC_ERROR_SERVICE_NOT_AVAILABLE ((rac_result_t) - 232) +/** Service is busy */ +#define RAC_ERROR_SERVICE_BUSY ((rac_result_t) - 233) +/** Processing failed */ +#define RAC_ERROR_PROCESSING_FAILED ((rac_result_t) - 234) +/** Start operation failed */ +#define RAC_ERROR_START_FAILED ((rac_result_t) - 235) +/** Feature/operation is not supported */ +#define RAC_ERROR_NOT_SUPPORTED ((rac_result_t) - 236) + +// ============================================================================= +// VALIDATION ERRORS (-250 to -279) +// Mirrors Swift's ErrorCode: Validation Errors +// ============================================================================= + +/** Validation failed */ +#define RAC_ERROR_VALIDATION_FAILED ((rac_result_t) - 250) +/** Input is invalid */ +#define RAC_ERROR_INVALID_INPUT ((rac_result_t) - 251) +/** Format is invalid */ +#define RAC_ERROR_INVALID_FORMAT ((rac_result_t) - 252) +/** Input is empty */ +#define RAC_ERROR_EMPTY_INPUT ((rac_result_t) - 253) +/** Text is too long */ +#define RAC_ERROR_TEXT_TOO_LONG ((rac_result_t) - 254) +/** Invalid SSML markup */ +#define RAC_ERROR_INVALID_SSML ((rac_result_t) - 255) +/** Invalid speaking rate */ +#define RAC_ERROR_INVALID_SPEAKING_RATE ((rac_result_t) - 256) +/** Invalid pitch */ +#define RAC_ERROR_INVALID_PITCH ((rac_result_t) - 257) +/** Invalid volume */ +#define RAC_ERROR_INVALID_VOLUME ((rac_result_t) - 258) +/** Invalid argument */ +#define RAC_ERROR_INVALID_ARGUMENT ((rac_result_t) - 259) +/** Null pointer */ +#define RAC_ERROR_NULL_POINTER ((rac_result_t) - 260) +/** Buffer too small */ +#define RAC_ERROR_BUFFER_TOO_SMALL ((rac_result_t) - 261) + +// ============================================================================= +// AUDIO ERRORS (-280 to -299) +// Mirrors Swift's ErrorCode: Audio Errors +// ============================================================================= + +/** Audio format is not supported */ +#define RAC_ERROR_AUDIO_FORMAT_NOT_SUPPORTED ((rac_result_t) - 280) +/** Audio session configuration failed */ +#define RAC_ERROR_AUDIO_SESSION_FAILED ((rac_result_t) - 281) +/** Microphone permission denied */ +#define RAC_ERROR_MICROPHONE_PERMISSION_DENIED ((rac_result_t) - 282) +/** Insufficient audio data */ +#define RAC_ERROR_INSUFFICIENT_AUDIO_DATA ((rac_result_t) - 283) +/** Audio buffer is empty */ +#define RAC_ERROR_EMPTY_AUDIO_BUFFER ((rac_result_t) - 284) +/** Audio session activation failed */ +#define RAC_ERROR_AUDIO_SESSION_ACTIVATION_FAILED ((rac_result_t) - 285) + +// ============================================================================= +// LANGUAGE/VOICE ERRORS (-300 to -319) +// Mirrors Swift's ErrorCode: Language/Voice Errors +// ============================================================================= + +/** Language is not supported */ +#define RAC_ERROR_LANGUAGE_NOT_SUPPORTED ((rac_result_t) - 300) +/** Voice is not available */ +#define RAC_ERROR_VOICE_NOT_AVAILABLE ((rac_result_t) - 301) +/** Streaming is not supported */ +#define RAC_ERROR_STREAMING_NOT_SUPPORTED ((rac_result_t) - 302) +/** Stream was cancelled */ +#define RAC_ERROR_STREAM_CANCELLED ((rac_result_t) - 303) + +// ============================================================================= +// AUTHENTICATION ERRORS (-320 to -329) +// Mirrors Swift's ErrorCode: Authentication Errors +// ============================================================================= + +/** Authentication failed */ +#define RAC_ERROR_AUTHENTICATION_FAILED ((rac_result_t) - 320) +/** Unauthorized access */ +#define RAC_ERROR_UNAUTHORIZED ((rac_result_t) - 321) +/** Access forbidden */ +#define RAC_ERROR_FORBIDDEN ((rac_result_t) - 322) + +// ============================================================================= +// SECURITY ERRORS (-330 to -349) +// Mirrors Swift's ErrorCode: Security Errors +// ============================================================================= + +/** Keychain operation failed */ +#define RAC_ERROR_KEYCHAIN_ERROR ((rac_result_t) - 330) +/** Encoding error */ +#define RAC_ERROR_ENCODING_ERROR ((rac_result_t) - 331) +/** Decoding error */ +#define RAC_ERROR_DECODING_ERROR ((rac_result_t) - 332) +/** Secure storage failed */ +#define RAC_ERROR_SECURE_STORAGE_FAILED ((rac_result_t) - 333) + +// ============================================================================= +// EXTRACTION ERRORS (-350 to -369) +// Mirrors Swift's ErrorCode: Extraction Errors +// ============================================================================= + +/** Extraction failed (JSON, archive, etc.) */ +#define RAC_ERROR_EXTRACTION_FAILED ((rac_result_t) - 350) +/** Checksum mismatch */ +#define RAC_ERROR_CHECKSUM_MISMATCH ((rac_result_t) - 351) +/** Unsupported archive format */ +#define RAC_ERROR_UNSUPPORTED_ARCHIVE ((rac_result_t) - 352) + +// ============================================================================= +// CALIBRATION ERRORS (-370 to -379) +// Mirrors Swift's ErrorCode: Calibration Errors +// ============================================================================= + +/** Calibration failed */ +#define RAC_ERROR_CALIBRATION_FAILED ((rac_result_t) - 370) +/** Calibration timed out */ +#define RAC_ERROR_CALIBRATION_TIMEOUT ((rac_result_t) - 371) + +// ============================================================================= +// CANCELLATION (-380 to -389) +// Mirrors Swift's ErrorCode: Cancellation +// ============================================================================= + +/** Operation was cancelled */ +#define RAC_ERROR_CANCELLED ((rac_result_t) - 380) + +// ============================================================================= +// MODULE/SERVICE ERRORS (-400 to -499) +// ============================================================================= + +/** Module not found */ +#define RAC_ERROR_MODULE_NOT_FOUND ((rac_result_t) - 400) +/** Module already registered */ +#define RAC_ERROR_MODULE_ALREADY_REGISTERED ((rac_result_t) - 401) +/** Module load failed */ +#define RAC_ERROR_MODULE_LOAD_FAILED ((rac_result_t) - 402) +/** Service not found */ +#define RAC_ERROR_SERVICE_NOT_FOUND ((rac_result_t) - 410) +/** Service already registered */ +#define RAC_ERROR_SERVICE_ALREADY_REGISTERED ((rac_result_t) - 411) +/** Service create failed */ +#define RAC_ERROR_SERVICE_CREATE_FAILED ((rac_result_t) - 412) +/** Capability not found */ +#define RAC_ERROR_CAPABILITY_NOT_FOUND ((rac_result_t) - 420) +/** Provider not found */ +#define RAC_ERROR_PROVIDER_NOT_FOUND ((rac_result_t) - 421) +/** No capable provider */ +#define RAC_ERROR_NO_CAPABLE_PROVIDER ((rac_result_t) - 422) +/** Generic not found */ +#define RAC_ERROR_NOT_FOUND ((rac_result_t) - 423) + +// ============================================================================= +// PLATFORM ADAPTER ERRORS (-500 to -599) +// ============================================================================= + +/** Adapter not set */ +#define RAC_ERROR_ADAPTER_NOT_SET ((rac_result_t) - 500) + +// ============================================================================= +// BACKEND ERRORS (-600 to -699) +// ============================================================================= + +/** Backend not found */ +#define RAC_ERROR_BACKEND_NOT_FOUND ((rac_result_t) - 600) +/** Backend not ready */ +#define RAC_ERROR_BACKEND_NOT_READY ((rac_result_t) - 601) +/** Backend init failed */ +#define RAC_ERROR_BACKEND_INIT_FAILED ((rac_result_t) - 602) +/** Backend busy */ +#define RAC_ERROR_BACKEND_BUSY ((rac_result_t) - 603) +/** Invalid handle */ +#define RAC_ERROR_INVALID_HANDLE ((rac_result_t) - 610) + +// ============================================================================= +// EVENT ERRORS (-700 to -799) +// ============================================================================= + +/** Invalid event category */ +#define RAC_ERROR_EVENT_INVALID_CATEGORY ((rac_result_t) - 700) +/** Event subscription failed */ +#define RAC_ERROR_EVENT_SUBSCRIPTION_FAILED ((rac_result_t) - 701) +/** Event publish failed */ +#define RAC_ERROR_EVENT_PUBLISH_FAILED ((rac_result_t) - 702) + +// ============================================================================= +// OTHER ERRORS (-800 to -899) +// Mirrors Swift's ErrorCode: Other Errors +// ============================================================================= + +/** Feature is not implemented */ +#define RAC_ERROR_NOT_IMPLEMENTED ((rac_result_t) - 800) +/** Feature is not available */ +#define RAC_ERROR_FEATURE_NOT_AVAILABLE ((rac_result_t) - 801) +/** Framework is not available */ +#define RAC_ERROR_FRAMEWORK_NOT_AVAILABLE ((rac_result_t) - 802) +/** Unsupported modality */ +#define RAC_ERROR_UNSUPPORTED_MODALITY ((rac_result_t) - 803) +/** Unknown error */ +#define RAC_ERROR_UNKNOWN ((rac_result_t) - 804) +/** Internal error */ +#define RAC_ERROR_INTERNAL ((rac_result_t) - 805) + +// ============================================================================= +// ERROR MESSAGE API +// ============================================================================= + +/** + * Gets a human-readable error message for an error code. + * + * @param error_code The error code to get a message for + * @return A static string describing the error (never NULL) + */ +RAC_API const char* rac_error_message(rac_result_t error_code); + +/** + * Gets the last detailed error message. + * + * This returns additional context beyond the error code, such as file paths + * or specific failure reasons. Returns NULL if no detailed message is set. + * + * @return The last error detail string, or NULL + * + * @note The returned string is thread-local and valid until the next + * RAC function call on the same thread. + */ +RAC_API const char* rac_error_get_details(void); + +/** + * Sets the detailed error message for the current thread. + * + * This is typically called internally by RAC functions to provide + * additional context for errors. + * + * @param details The detail string (will be copied internally) + */ +RAC_API void rac_error_set_details(const char* details); + +/** + * Clears the detailed error message for the current thread. + */ +RAC_API void rac_error_clear_details(void); + +/** + * Checks if an error code is in the commons range (-100 to -999). + * + * @param error_code The error code to check + * @return RAC_TRUE if the error is from commons, RAC_FALSE otherwise + */ +RAC_API rac_bool_t rac_error_is_commons_error(rac_result_t error_code); + +/** + * Checks if an error code is in the core range (0 to -99). + * + * @param error_code The error code to check + * @return RAC_TRUE if the error is from core, RAC_FALSE otherwise + */ +RAC_API rac_bool_t rac_error_is_core_error(rac_result_t error_code); + +/** + * Checks if an error is expected/routine (like cancellation). + * Mirrors Swift's ErrorCode.isExpected property. + * + * @param error_code The error code to check + * @return RAC_TRUE if expected, RAC_FALSE otherwise + */ +RAC_API rac_bool_t rac_error_is_expected(rac_result_t error_code); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_ERROR_H */ diff --git a/sdk/runanywhere-swift/Sources/LlamaCPPRuntime/include/rac_llm.h b/sdk/runanywhere-swift/Sources/LlamaCPPRuntime/include/rac_llm.h new file mode 100644 index 000000000..2c5708887 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/LlamaCPPRuntime/include/rac_llm.h @@ -0,0 +1,17 @@ +/** + * @file rac_llm.h + * @brief RunAnywhere Commons - LLM API (Convenience Header) + * + * This header includes both types and service interface for convenience. + * For better separation of concerns, prefer including: + * - rac_llm_types.h for data structures only + * - rac_llm_service.h for the service interface + */ + +#ifndef RAC_LLM_H +#define RAC_LLM_H + +#include "rac_llm_service.h" +#include "rac_llm_types.h" + +#endif /* RAC_LLM_H */ diff --git a/sdk/runanywhere-swift/Sources/LlamaCPPRuntime/include/rac_llm_llamacpp.h b/sdk/runanywhere-swift/Sources/LlamaCPPRuntime/include/rac_llm_llamacpp.h new file mode 100644 index 000000000..4d150ca1d --- /dev/null +++ b/sdk/runanywhere-swift/Sources/LlamaCPPRuntime/include/rac_llm_llamacpp.h @@ -0,0 +1,218 @@ +/** + * @file rac_llm_llamacpp.h + * @brief RunAnywhere Commons - LlamaCPP Backend for LLM + * + * C wrapper around runanywhere-core's LlamaCPP backend. + * Mirrors Swift's LlamaCPPService implementation. + * + * See: Sources/LlamaCPPRuntime/LlamaCPPService.swift + */ + +#ifndef RAC_LLM_LLAMACPP_H +#define RAC_LLM_LLAMACPP_H + +#include "rac_error.h" +#include "rac_types.h" +#include "rac_llm.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// EXPORT MACRO +// ============================================================================= + +#if defined(RAC_LLAMACPP_BUILDING) +#if defined(_WIN32) +#define RAC_LLAMACPP_API __declspec(dllexport) +#elif defined(__GNUC__) || defined(__clang__) +#define RAC_LLAMACPP_API __attribute__((visibility("default"))) +#else +#define RAC_LLAMACPP_API +#endif +#else +#define RAC_LLAMACPP_API +#endif + +// ============================================================================= +// CONFIGURATION - Mirrors Swift's LlamaCPPGenerationConfig +// ============================================================================= + +/** + * LlamaCPP-specific configuration. + * + * Mirrors Swift's LlamaCPPGenerationConfig and ra_llamacpp_config_t from core. + */ +typedef struct rac_llm_llamacpp_config { + /** Context size (0 = auto-detect from model) */ + int32_t context_size; + + /** Number of threads (0 = auto-detect) */ + int32_t num_threads; + + /** Number of layers to offload to GPU (Metal on iOS/macOS) */ + int32_t gpu_layers; + + /** Batch size for prompt processing */ + int32_t batch_size; +} rac_llm_llamacpp_config_t; + +/** + * Default LlamaCPP configuration. + */ +static const rac_llm_llamacpp_config_t RAC_LLM_LLAMACPP_CONFIG_DEFAULT = { + .context_size = 0, // Auto-detect + .num_threads = 0, // Auto-detect + .gpu_layers = -1, // All layers on GPU + .batch_size = 512}; + +// ============================================================================= +// LLAMACPP-SPECIFIC API +// ============================================================================= + +/** + * Creates a LlamaCPP LLM service. + * + * Mirrors Swift's LlamaCPPService.initialize(modelPath:) + * + * @param model_path Path to the GGUF model file + * @param config LlamaCPP-specific configuration (can be NULL for defaults) + * @param out_handle Output: Handle to the created service + * @return RAC_SUCCESS or error code + */ +RAC_LLAMACPP_API rac_result_t rac_llm_llamacpp_create(const char* model_path, + const rac_llm_llamacpp_config_t* config, + rac_handle_t* out_handle); + +/** + * Loads a GGUF model into an existing service. + * + * Mirrors Swift's LlamaCPPService.loadModel(path:config:) + * + * @param handle Service handle + * @param model_path Path to the GGUF model file + * @param config LlamaCPP configuration (can be NULL) + * @return RAC_SUCCESS or error code + */ +RAC_LLAMACPP_API rac_result_t rac_llm_llamacpp_load_model(rac_handle_t handle, + const char* model_path, + const rac_llm_llamacpp_config_t* config); + +/** + * Unloads the current model. + * + * Mirrors Swift's LlamaCPPService.unloadModel() + * + * @param handle Service handle + * @return RAC_SUCCESS or error code + */ +RAC_LLAMACPP_API rac_result_t rac_llm_llamacpp_unload_model(rac_handle_t handle); + +/** + * Checks if a model is loaded. + * + * Mirrors Swift's LlamaCPPService.isModelLoaded + * + * @param handle Service handle + * @return RAC_TRUE if model is loaded, RAC_FALSE otherwise + */ +RAC_LLAMACPP_API rac_bool_t rac_llm_llamacpp_is_model_loaded(rac_handle_t handle); + +/** + * Generates text completion. + * + * Mirrors Swift's LlamaCPPService.generate(prompt:config:) + * + * @param handle Service handle + * @param prompt Input prompt text + * @param options Generation options (can be NULL for defaults) + * @param out_result Output: Generation result (caller must free text with rac_free) + * @return RAC_SUCCESS or error code + */ +RAC_LLAMACPP_API rac_result_t rac_llm_llamacpp_generate(rac_handle_t handle, const char* prompt, + const rac_llm_options_t* options, + rac_llm_result_t* out_result); + +/** + * Streaming text generation callback. + * + * Mirrors Swift's streaming callback pattern. + * + * @param token Generated token string + * @param is_final Whether this is the final token + * @param user_data User-provided context + * @return RAC_TRUE to continue, RAC_FALSE to stop + */ +typedef rac_bool_t (*rac_llm_llamacpp_stream_callback_fn)(const char* token, rac_bool_t is_final, + void* user_data); + +/** + * Generates text with streaming callback. + * + * Mirrors Swift's LlamaCPPService.generateStream(prompt:config:) + * + * @param handle Service handle + * @param prompt Input prompt text + * @param options Generation options + * @param callback Callback for each token + * @param user_data User context passed to callback + * @return RAC_SUCCESS or error code + */ +RAC_LLAMACPP_API rac_result_t rac_llm_llamacpp_generate_stream( + rac_handle_t handle, const char* prompt, const rac_llm_options_t* options, + rac_llm_llamacpp_stream_callback_fn callback, void* user_data); + +/** + * Cancels ongoing generation. + * + * Mirrors Swift's LlamaCPPService.cancel() + * + * @param handle Service handle + */ +RAC_LLAMACPP_API void rac_llm_llamacpp_cancel(rac_handle_t handle); + +/** + * Gets model information as JSON. + * + * @param handle Service handle + * @param out_json Output: JSON string (caller must free with rac_free) + * @return RAC_SUCCESS or error code + */ +RAC_LLAMACPP_API rac_result_t rac_llm_llamacpp_get_model_info(rac_handle_t handle, char** out_json); + +/** + * Destroys a LlamaCPP LLM service. + * + * @param handle Service handle to destroy + */ +RAC_LLAMACPP_API void rac_llm_llamacpp_destroy(rac_handle_t handle); + +// ============================================================================= +// BACKEND REGISTRATION +// ============================================================================= + +/** + * Registers the LlamaCPP backend with the commons module and service registries. + * + * Should be called once during SDK initialization. + * This registers: + * - Module: "llamacpp" with TEXT_GENERATION capability + * - Service provider: LlamaCPP LLM provider + * + * @return RAC_SUCCESS or error code + */ +RAC_LLAMACPP_API rac_result_t rac_backend_llamacpp_register(void); + +/** + * Unregisters the LlamaCPP backend. + * + * @return RAC_SUCCESS or error code + */ +RAC_LLAMACPP_API rac_result_t rac_backend_llamacpp_unregister(void); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_LLM_LLAMACPP_H */ diff --git a/sdk/runanywhere-swift/Sources/LlamaCPPRuntime/include/rac_llm_types.h b/sdk/runanywhere-swift/Sources/LlamaCPPRuntime/include/rac_llm_types.h new file mode 100644 index 000000000..fe3d83bb4 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/LlamaCPPRuntime/include/rac_llm_types.h @@ -0,0 +1,373 @@ +/** + * @file rac_llm_types.h + * @brief RunAnywhere Commons - LLM Types and Data Structures + * + * C port of Swift's LLM Models from: + * Sources/RunAnywhere/Features/LLM/Models/LLMGenerationOptions.swift + * Sources/RunAnywhere/Features/LLM/Models/LLMGenerationResult.swift + * + * This header defines data structures only. For the service interface, + * see rac_llm_service.h. + */ + +#ifndef RAC_LLM_TYPES_H +#define RAC_LLM_TYPES_H + +#include "rac_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// CONFIGURATION - Mirrors Swift's LLMConfiguration +// ============================================================================= + +/** + * @brief LLM component configuration + * + * Mirrors Swift's LLMConfiguration struct exactly. + * See: Sources/RunAnywhere/Features/LLM/Models/LLMConfiguration.swift + */ +typedef struct rac_llm_config { + /** Model ID (optional - uses default if NULL) */ + const char* model_id; + + /** Preferred framework for generation (use RAC_FRAMEWORK_UNKNOWN for auto) */ + int32_t preferred_framework; + + /** Context length - max tokens the model can handle (default: 2048) */ + int32_t context_length; + + /** Temperature for sampling (0.0 - 2.0, default: 0.7) */ + float temperature; + + /** Maximum tokens to generate (default: 100) */ + int32_t max_tokens; + + /** System prompt for generation (can be NULL) */ + const char* system_prompt; + + /** Enable streaming mode (default: true) */ + rac_bool_t streaming_enabled; +} rac_llm_config_t; + +/** + * @brief Default LLM configuration + */ +static const rac_llm_config_t RAC_LLM_CONFIG_DEFAULT = {.model_id = RAC_NULL, + .preferred_framework = + 99, // RAC_FRAMEWORK_UNKNOWN + .context_length = 2048, + .temperature = 0.7f, + .max_tokens = 100, + .system_prompt = RAC_NULL, + .streaming_enabled = RAC_TRUE}; + +// ============================================================================= +// OPTIONS - Mirrors Swift's LLMGenerationOptions +// ============================================================================= + +/** + * @brief LLM generation options + * + * Mirrors Swift's LLMGenerationOptions struct exactly. + * See: Sources/RunAnywhere/Features/LLM/Models/LLMGenerationOptions.swift + */ +typedef struct rac_llm_options { + /** Maximum number of tokens to generate (default: 100) */ + int32_t max_tokens; + + /** Temperature for sampling (0.0 - 2.0, default: 0.8) */ + float temperature; + + /** Top-p sampling parameter (default: 1.0) */ + float top_p; + + /** Stop sequences (null-terminated array, can be NULL) */ + const char* const* stop_sequences; + size_t num_stop_sequences; + + /** Enable streaming mode (default: false) */ + rac_bool_t streaming_enabled; + + /** System prompt (can be NULL) */ + const char* system_prompt; +} rac_llm_options_t; + +/** + * @brief Default LLM generation options + */ +static const rac_llm_options_t RAC_LLM_OPTIONS_DEFAULT = {.max_tokens = 100, + .temperature = 0.8f, + .top_p = 1.0f, + .stop_sequences = RAC_NULL, + .num_stop_sequences = 0, + .streaming_enabled = RAC_FALSE, + .system_prompt = RAC_NULL}; + +// ============================================================================= +// RESULT - Mirrors Swift's LLMGenerationResult +// ============================================================================= + +/** + * @brief LLM generation result + */ +typedef struct rac_llm_result { + /** Generated text (owned, must be freed with rac_free) */ + char* text; + + /** Number of tokens in prompt */ + int32_t prompt_tokens; + + /** Number of tokens generated */ + int32_t completion_tokens; + + /** Total tokens (prompt + completion) */ + int32_t total_tokens; + + /** Time to first token in milliseconds */ + int64_t time_to_first_token_ms; + + /** Total generation time in milliseconds */ + int64_t total_time_ms; + + /** Tokens per second */ + float tokens_per_second; +} rac_llm_result_t; + +// ============================================================================= +// INFO - Mirrors Swift's LLMService properties +// ============================================================================= + +/** + * @brief LLM service handle info + * + * Mirrors Swift's LLMService properties. + */ +typedef struct rac_llm_info { + /** Whether the service is ready for generation (isReady) */ + rac_bool_t is_ready; + + /** Current model identifier (currentModel, can be NULL) */ + const char* current_model; + + /** Context length (contextLength, 0 if unknown) */ + int32_t context_length; + + /** Whether streaming is supported (supportsStreaming) */ + rac_bool_t supports_streaming; +} rac_llm_info_t; + +// ============================================================================= +// CALLBACKS +// ============================================================================= + +/** + * @brief LLM streaming callback + * + * Called for each generated token during streaming. + * Mirrors Swift's onToken callback pattern. + * + * @param token The generated token string + * @param user_data User-provided context + * @return RAC_TRUE to continue, RAC_FALSE to stop generation + */ +typedef rac_bool_t (*rac_llm_stream_callback_fn)(const char* token, void* user_data); + +// ============================================================================= +// THINKING TAG PATTERN - Mirrors Swift's ThinkingTagPattern +// ============================================================================= + +/** + * @brief Pattern for extracting thinking/reasoning content from model output + * + * Mirrors Swift's ThinkingTagPattern struct exactly. + * See: Sources/RunAnywhere/Features/LLM/Models/ThinkingTagPattern.swift + */ +typedef struct rac_thinking_tag_pattern { + /** Opening tag for thinking content (e.g., "") */ + const char* opening_tag; + + /** Closing tag for thinking content (e.g., "") */ + const char* closing_tag; +} rac_thinking_tag_pattern_t; + +/** + * @brief Default thinking tag pattern (DeepSeek/Hermes style) + */ +static const rac_thinking_tag_pattern_t RAC_THINKING_TAG_DEFAULT = {.opening_tag = "", + .closing_tag = ""}; + +/** + * @brief Alternative thinking pattern with full word + */ +static const rac_thinking_tag_pattern_t RAC_THINKING_TAG_FULL = {.opening_tag = "", + .closing_tag = ""}; + +// ============================================================================= +// STRUCTURED OUTPUT - Mirrors Swift's StructuredOutputConfig +// ============================================================================= + +/** + * @brief Structured output configuration + * + * Mirrors Swift's StructuredOutputConfig struct. + * See: Sources/RunAnywhere/Features/LLM/StructuredOutput/Generatable.swift + * + * Note: In C, we pass the JSON schema directly instead of using reflection. + */ +typedef struct rac_structured_output_config { + /** JSON schema for the expected output structure */ + const char* json_schema; + + /** Whether to include the schema in the prompt */ + rac_bool_t include_schema_in_prompt; +} rac_structured_output_config_t; + +/** + * @brief Default structured output configuration + */ +static const rac_structured_output_config_t RAC_STRUCTURED_OUTPUT_DEFAULT = { + .json_schema = RAC_NULL, .include_schema_in_prompt = RAC_TRUE}; + +/** + * @brief Structured output validation result + * + * Mirrors Swift's StructuredOutputValidation struct. + */ +typedef struct rac_structured_output_validation { + /** Whether the output is valid according to the schema */ + rac_bool_t is_valid; + + /** Error message if validation failed (can be NULL) */ + const char* error_message; + + /** Extracted JSON string (can be NULL) */ + char* extracted_json; +} rac_structured_output_validation_t; + +// ============================================================================= +// STREAMING RESULT - Mirrors Swift's LLMStreamingResult +// ============================================================================= + +/** + * @brief Token event during streaming + * + * Provides detailed information about each token during streaming generation. + */ +typedef struct rac_llm_token_event { + /** The generated token text */ + const char* token; + + /** Token index in the sequence */ + int32_t token_index; + + /** Is this the final token? */ + rac_bool_t is_final; + + /** Tokens generated per second so far */ + float tokens_per_second; +} rac_llm_token_event_t; + +/** + * @brief Extended streaming callback with token event details + * + * @param event Token event details + * @param user_data User-provided context + * @return RAC_TRUE to continue, RAC_FALSE to stop generation + */ +typedef rac_bool_t (*rac_llm_token_event_callback_fn)(const rac_llm_token_event_t* event, + void* user_data); + +/** + * @brief Streaming result handle + * + * Opaque handle for managing streaming generation. + * In C++, this wraps the streaming state and provides synchronization. + * + * Note: LLMStreamingResult in Swift returns an AsyncThrowingStream and a Task. + * In C, we use callbacks instead of async streams. + */ +typedef void* rac_llm_stream_handle_t; + +/** + * @brief Streaming generation parameters + * + * Configuration for starting a streaming generation. + */ +typedef struct rac_llm_stream_params { + /** Prompt to generate from */ + const char* prompt; + + /** Generation options */ + rac_llm_options_t options; + + /** Callback for each token */ + rac_llm_stream_callback_fn on_token; + + /** Extended callback with token event details (optional, can be NULL) */ + rac_llm_token_event_callback_fn on_token_event; + + /** User data passed to callbacks */ + void* user_data; + + /** Optional thinking tag pattern to extract thinking content */ + const rac_thinking_tag_pattern_t* thinking_pattern; +} rac_llm_stream_params_t; + +/** + * @brief Streaming generation metrics + * + * Metrics collected during streaming generation. + */ +typedef struct rac_llm_stream_metrics { + /** Time to first token in milliseconds */ + int64_t time_to_first_token_ms; + + /** Total generation time in milliseconds */ + int64_t total_time_ms; + + /** Number of tokens generated */ + int32_t tokens_generated; + + /** Tokens per second */ + float tokens_per_second; + + /** Number of tokens in the prompt */ + int32_t prompt_tokens; + + /** Thinking tokens if thinking pattern was used */ + int32_t thinking_tokens; + + /** Response tokens (excluding thinking) */ + int32_t response_tokens; +} rac_llm_stream_metrics_t; + +/** + * @brief Complete streaming result + * + * Final result after streaming generation is complete. + */ +typedef struct rac_llm_stream_result { + /** Full generated text (owned, must be freed with rac_free) */ + char* text; + + /** Extracted thinking content if pattern was provided (can be NULL) */ + char* thinking_content; + + /** Generation metrics */ + rac_llm_stream_metrics_t metrics; + + /** Error code if generation failed (RAC_SUCCESS on success) */ + rac_result_t error_code; + + /** Error message if generation failed (can be NULL) */ + char* error_message; +} rac_llm_stream_result_t; + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_LLM_TYPES_H */ diff --git a/sdk/runanywhere-swift/Sources/LlamaCPPRuntime/include/rac_types.h b/sdk/runanywhere-swift/Sources/LlamaCPPRuntime/include/rac_types.h new file mode 100644 index 000000000..dc888e53b --- /dev/null +++ b/sdk/runanywhere-swift/Sources/LlamaCPPRuntime/include/rac_types.h @@ -0,0 +1,255 @@ +/** + * @file rac_types.h + * @brief RunAnywhere Commons - Common Types and Definitions + * + * This header defines common types, handle types, and macros used throughout + * the runanywhere-commons library. All types use the RAC_ prefix to distinguish + * from the underlying runanywhere-core (ra_*) types. + */ + +#ifndef RAC_TYPES_H +#define RAC_TYPES_H + +#include +#include + +/** + * Null pointer macro for use in static initializers. + * Uses nullptr in C++ (preferred by clang-tidy modernize-use-nullptr) + * and NULL in C for compatibility. + */ +#ifdef __cplusplus +#define RAC_NULL nullptr +#else +#define RAC_NULL NULL +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// API VISIBILITY MACROS +// ============================================================================= + +#if defined(RAC_BUILDING_SHARED) +#if defined(_WIN32) +#define RAC_API __declspec(dllexport) +#elif defined(__GNUC__) || defined(__clang__) +#define RAC_API __attribute__((visibility("default"))) +#else +#define RAC_API +#endif +#elif defined(RAC_USING_SHARED) +#if defined(_WIN32) +#define RAC_API __declspec(dllimport) +#else +#define RAC_API +#endif +#else +#define RAC_API +#endif + +// ============================================================================= +// RESULT TYPE +// ============================================================================= + +/** + * Result type for all RAC functions. + * - 0 indicates success + * - Negative values indicate errors (see rac_error.h) + * + * Error code ranges: + * - runanywhere-core (ra_*): 0 to -99 + * - runanywhere-commons (rac_*): -100 to -999 + */ +typedef int32_t rac_result_t; + +/** Success result */ +#define RAC_SUCCESS ((rac_result_t)0) + +// ============================================================================= +// BOOLEAN TYPE +// ============================================================================= + +/** Boolean type for C compatibility */ +typedef int32_t rac_bool_t; + +#define RAC_TRUE ((rac_bool_t)1) +#define RAC_FALSE ((rac_bool_t)0) + +// ============================================================================= +// HANDLE TYPES +// ============================================================================= + +/** + * Opaque handle for internal objects. + * Handles should be treated as opaque pointers. + */ +typedef void* rac_handle_t; + +/** Invalid handle value */ +#define RAC_INVALID_HANDLE ((rac_handle_t)NULL) + +// ============================================================================= +// STRING TYPES +// ============================================================================= + +/** + * String view (non-owning reference to a string). + * The string is NOT guaranteed to be null-terminated. + */ +typedef struct rac_string_view { + const char* data; /**< Pointer to string data */ + size_t length; /**< Length in bytes (not including any null terminator) */ +} rac_string_view_t; + +/** + * Creates a string view from a null-terminated C string. + */ +#define RAC_STRING_VIEW(s) ((rac_string_view_t){(s), (s) ? strlen(s) : 0}) + +// ============================================================================= +// AUDIO TYPES +// ============================================================================= + +/** + * Audio buffer for STT/VAD operations. + * Contains PCM float samples in the range [-1.0, 1.0]. + */ +typedef struct rac_audio_buffer { + const float* samples; /**< PCM float samples */ + size_t num_samples; /**< Number of samples */ + int32_t sample_rate; /**< Sample rate in Hz (e.g., 16000) */ + int32_t channels; /**< Number of channels (1 = mono, 2 = stereo) */ +} rac_audio_buffer_t; + +/** + * Audio format specification. + */ +typedef struct rac_audio_format { + int32_t sample_rate; /**< Sample rate in Hz */ + int32_t channels; /**< Number of channels */ + int32_t bits_per_sample; /**< Bits per sample (16 or 32) */ +} rac_audio_format_t; + +// ============================================================================= +// MEMORY INFO +// ============================================================================= + +/** + * Memory information structure. + * Used by the platform adapter to report available memory. + */ +typedef struct rac_memory_info { + uint64_t total_bytes; /**< Total physical memory in bytes */ + uint64_t available_bytes; /**< Available memory in bytes */ + uint64_t used_bytes; /**< Used memory in bytes */ +} rac_memory_info_t; + +// ============================================================================= +// CAPABILITY TYPES +// ============================================================================= + +/** + * Capability types supported by backends. + * These match the capabilities defined in runanywhere-core. + */ +typedef enum rac_capability { + RAC_CAPABILITY_UNKNOWN = 0, + RAC_CAPABILITY_TEXT_GENERATION = 1, /**< LLM text generation */ + RAC_CAPABILITY_EMBEDDINGS = 2, /**< Text embeddings */ + RAC_CAPABILITY_STT = 3, /**< Speech-to-text */ + RAC_CAPABILITY_TTS = 4, /**< Text-to-speech */ + RAC_CAPABILITY_VAD = 5, /**< Voice activity detection */ + RAC_CAPABILITY_DIARIZATION = 6, /**< Speaker diarization */ +} rac_capability_t; + +/** + * Device type for backend execution. + */ +typedef enum rac_device { + RAC_DEVICE_CPU = 0, + RAC_DEVICE_GPU = 1, + RAC_DEVICE_NPU = 2, + RAC_DEVICE_AUTO = 3, +} rac_device_t; + +// ============================================================================= +// LOG LEVELS +// ============================================================================= + +/** + * Log level for the logging callback. + */ +typedef enum rac_log_level { + RAC_LOG_TRACE = 0, + RAC_LOG_DEBUG = 1, + RAC_LOG_INFO = 2, + RAC_LOG_WARNING = 3, + RAC_LOG_ERROR = 4, + RAC_LOG_FATAL = 5, +} rac_log_level_t; + +// ============================================================================= +// VERSION INFO +// ============================================================================= + +/** + * Version information structure. + */ +typedef struct rac_version { + uint16_t major; + uint16_t minor; + uint16_t patch; + const char* string; /**< Version string (e.g., "1.0.0") */ +} rac_version_t; + +// ============================================================================= +// UTILITY MACROS +// ============================================================================= + +/** Check if a result is a success */ +#define RAC_SUCCEEDED(result) ((result) >= 0) + +/** Check if a result is an error */ +#define RAC_FAILED(result) ((result) < 0) + +/** Check if a handle is valid */ +#define RAC_IS_VALID_HANDLE(handle) ((handle) != RAC_INVALID_HANDLE) + +// ============================================================================= +// MEMORY MANAGEMENT +// ============================================================================= + +/** + * Frees memory allocated by RAC functions. + * + * Use this to free strings and buffers returned by RAC functions that + * are marked as "must be freed with rac_free". + * + * @param ptr Pointer to memory to free (can be NULL) + */ +RAC_API void rac_free(void* ptr); + +/** + * Allocates memory using the RAC allocator. + * + * @param size Number of bytes to allocate + * @return Pointer to allocated memory, or NULL on failure + */ +RAC_API void* rac_alloc(size_t size); + +/** + * Duplicates a null-terminated string. + * + * @param str String to duplicate (can be NULL) + * @return Duplicated string (must be freed with rac_free), or NULL if str is NULL + */ +RAC_API char* rac_strdup(const char* str); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_TYPES_H */ diff --git a/sdk/runanywhere-swift/Sources/LlamaCPPRuntime/include/shim.c b/sdk/runanywhere-swift/Sources/LlamaCPPRuntime/include/shim.c new file mode 100644 index 000000000..80be8a4b7 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/LlamaCPPRuntime/include/shim.c @@ -0,0 +1,2 @@ +// Shim file to satisfy SPM's requirement for at least one source file in C targets +// This file intentionally left empty - all implementation is in the binary target diff --git a/sdk/runanywhere-swift/Sources/ONNXRuntime/ONNX.swift b/sdk/runanywhere-swift/Sources/ONNXRuntime/ONNX.swift new file mode 100644 index 000000000..73a5ffe5f --- /dev/null +++ b/sdk/runanywhere-swift/Sources/ONNXRuntime/ONNX.swift @@ -0,0 +1,146 @@ +// +// ONNX.swift +// ONNXRuntime Module +// +// Unified ONNX module - thin wrapper that calls C++ backend registration. +// This replaces both ONNXRuntime.swift and ONNXServiceProvider.swift. +// + +import CRACommons +import Foundation +import ONNXBackend +import RunAnywhere + +// MARK: - ONNX Module + +/// ONNX Runtime module for STT, TTS, and VAD services. +/// +/// Provides speech-to-text, text-to-speech, and voice activity detection +/// capabilities using ONNX Runtime with models like Whisper, Piper, and Silero. +/// +/// ## Registration +/// +/// ```swift +/// import ONNXRuntime +/// +/// // Register the backend (done automatically if auto-registration is enabled) +/// try ONNX.register() +/// ``` +/// +/// ## Usage +/// +/// Services are accessed through the main SDK APIs - the C++ backend handles +/// service creation and lifecycle internally: +/// +/// ```swift +/// // STT via public API +/// let text = try await RunAnywhere.transcribe(audioData) +/// +/// // TTS via public API +/// try await RunAnywhere.speak("Hello") +/// ``` +public enum ONNX: RunAnywhereModule { + private static let logger = SDKLogger(category: "ONNX") + + // MARK: - Module Info + + /// Current version of the ONNX Runtime module + public static let version = "2.0.0" + + /// ONNX Runtime library version (underlying C library) + public static let onnxRuntimeVersion = "1.23.2" + + // MARK: - RunAnywhereModule Conformance + + public static let moduleId = "onnx" + public static let moduleName = "ONNX Runtime" + public static let capabilities: Set = [.stt, .tts, .vad] + public static let defaultPriority: Int = 100 + + /// ONNX uses the ONNX Runtime inference framework + public static let inferenceFramework: InferenceFramework = .onnx + + // MARK: - Registration State + + private static var isRegistered = false + + // MARK: - Registration + + /// Register ONNX backend with the C++ service registry. + /// + /// This calls `rac_backend_onnx_register()` to register all ONNX + /// service providers (STT, TTS, VAD) with the C++ commons layer. + /// + /// Safe to call multiple times - subsequent calls are no-ops. + /// + /// - Parameter priority: Ignored (C++ uses its own priority system) + /// - Throws: SDKError if registration fails + @MainActor + public static func register(priority _: Int = 100) { + guard !isRegistered else { + logger.debug("ONNX already registered, returning") + return + } + + logger.info("Registering ONNX backend with C++ registry...") + + let result = rac_backend_onnx_register() + + // RAC_ERROR_MODULE_ALREADY_REGISTERED is OK + if result != RAC_SUCCESS && result != RAC_ERROR_MODULE_ALREADY_REGISTERED { + let errorMsg = String(cString: rac_error_message(result)) + logger.error("ONNX registration failed: \(errorMsg)") + // Don't throw - registration failure shouldn't crash the app + return + } + + isRegistered = true + logger.info("ONNX backend registered successfully (STT + TTS + VAD)") + } + + /// Unregister the ONNX backend from C++ registry. + public static func unregister() { + guard isRegistered else { return } + + _ = rac_backend_onnx_unregister() + isRegistered = false + logger.info("ONNX backend unregistered") + } + + // MARK: - Model Handling + + /// Check if ONNX can handle a given model for STT + /// Uses model name pattern matching - actual framework info is in C++ registry + public static func canHandleSTT(modelId: String?) -> Bool { + guard let modelId = modelId else { return false } + let lowercased = modelId.lowercased() + return lowercased.contains("whisper") || + lowercased.contains("zipformer") || + lowercased.contains("paraformer") + } + + /// Check if ONNX can handle a given model for TTS + /// Uses model name pattern matching - actual framework info is in C++ registry + public static func canHandleTTS(modelId: String?) -> Bool { + guard let modelId = modelId else { return false } + let lowercased = modelId.lowercased() + return lowercased.contains("piper") || lowercased.contains("vits") + } + + /// Check if ONNX can handle VAD (always true for Silero VAD) + public static func canHandleVAD(modelId _: String?) -> Bool { + return true // ONNX Silero VAD is the default + } +} + +// MARK: - Auto-Registration + +extension ONNX { + /// Enable auto-registration for this module. + /// Access this property to trigger C++ backend registration. + public static let autoRegister: Void = { + Task { @MainActor in + ONNX.register() + } + }() +} diff --git a/sdk/runanywhere-swift/Sources/ONNXRuntime/README.md b/sdk/runanywhere-swift/Sources/ONNXRuntime/README.md new file mode 100644 index 000000000..a0fc01ce5 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/ONNXRuntime/README.md @@ -0,0 +1,428 @@ +# ONNXRuntime Module + +The ONNXRuntime module provides speech-to-text (STT), text-to-speech (TTS), and voice activity detection (VAD) capabilities for the RunAnywhere Swift SDK using ONNX Runtime with models like Whisper, Piper, and Silero. + +## Overview + +This module enables on-device voice processing with support for: + +- Speech-to-text transcription (Whisper, Zipformer, Paraformer models) +- Text-to-speech synthesis (Piper, VITS voices) +- Voice activity detection (Silero VAD) +- Streaming and batch processing +- CoreML acceleration on Apple devices + +## Requirements + +| Platform | Minimum Version | +|----------|-----------------| +| iOS | 17.0+ | +| macOS | 14.0+ | + +The module requires: +- `RABackendONNX.xcframework` (included in SDK) +- ONNX Runtime (automatically linked) + +## Installation + +The ONNXRuntime module is included in the RunAnywhere SDK. Add it to your target: + +### Swift Package Manager + +```swift +dependencies: [ + .package(url: "https://github.com/RunanywhereAI/runanywhere-sdks", from: "0.16.0") +], +targets: [ + .target( + name: "YourApp", + dependencies: [ + .product(name: "RunAnywhere", package: "runanywhere-sdks"), + .product(name: "RunAnywhereONNX", package: "runanywhere-sdks"), + ] + ) +] +``` + +### Xcode + +1. Go to **File > Add Package Dependencies...** +2. Enter: `https://github.com/RunanywhereAI/runanywhere-sdks` +3. Select version and add `RunAnywhereONNX` to your target + +## Usage + +### Registration + +Register the module at app startup before using STT, TTS, or VAD capabilities: + +```swift +import RunAnywhere +import ONNXRuntime + +@main +struct MyApp: App { + init() { + Task { @MainActor in + ONNX.register() + + try RunAnywhere.initialize( + apiKey: "", + baseURL: "https://api.runanywhere.ai", + environment: .production + ) + } + } + + var body: some Scene { + WindowGroup { ContentView() } + } +} +``` + +### Speech-to-Text (STT) + +#### Loading a Model + +```swift +try await RunAnywhere.loadSTTModel("whisper-base-onnx") + +let isLoaded = await RunAnywhere.isSTTModelLoaded +``` + +#### Simple Transcription + +```swift +let audioData: Data = // your audio data (16kHz, mono, Float32) +let text = try await RunAnywhere.transcribe(audioData) +print("Transcribed: \(text)") +``` + +#### Transcription with Options + +```swift +let options = STTOptions( + language: "en-US", + sampleRate: 16000, + enableWordTimestamps: true +) + +let result = try await RunAnywhere.transcribeWithOptions(audioData, options: options) +print("Text: \(result.text)") +print("Confidence: \(result.confidence ?? 0)") +if let language = result.detectedLanguage { + print("Detected language: \(language)") +} +``` + +#### Streaming Transcription + +```swift +let output = try await RunAnywhere.transcribeStream( + audioData: audioData, + options: STTOptions(language: "en") +) { partialResult in + print("Partial: \(partialResult.transcript)") +} + +print("Final: \(output.text)") +``` + +#### Unloading + +```swift +try await RunAnywhere.unloadSTTModel() +``` + +### Text-to-Speech (TTS) + +#### Loading a Voice + +```swift +try await RunAnywhere.loadTTSVoice("piper-en-us-amy") + +let isLoaded = await RunAnywhere.isTTSVoiceLoaded +``` + +#### Simple Synthesis + +```swift +let output = try await RunAnywhere.synthesize( + "Hello! Welcome to RunAnywhere.", + options: TTSOptions(rate: 1.0, pitch: 1.0, volume: 0.8) +) + +// output.audioData contains the synthesized audio +// output.duration contains the audio length in seconds +``` + +#### Speak with Automatic Playback + +```swift +// Synthesize and play through device speakers +try await RunAnywhere.speak("Hello world") + +// With options +let result = try await RunAnywhere.speak( + "Hello", + options: TTSOptions(rate: 1.2, pitch: 1.0) +) +print("Duration: \(result.duration)s") +``` + +#### Streaming Synthesis + +```swift +let output = try await RunAnywhere.synthesizeStream( + "Long text to synthesize...", + options: TTSOptions() +) { chunk in + // Process audio chunk as it's generated + playAudioChunk(chunk) +} +``` + +#### Available Voices + +```swift +let voices = await RunAnywhere.availableTTSVoices +for voice in voices { + print("Voice: \(voice)") +} +``` + +#### Stopping Synthesis + +```swift +await RunAnywhere.stopSynthesis() +await RunAnywhere.stopSpeaking() +``` + +### Voice Activity Detection (VAD) + +#### Initialization + +```swift +// Default configuration +try await RunAnywhere.initializeVAD() + +// Custom configuration +try await RunAnywhere.initializeVAD(VADConfiguration( + sampleRate: 16000, + frameLength: 0.032, + energyThreshold: 0.5 +)) +``` + +#### Detection + +```swift +// From audio samples +let samples: [Float] = // your audio samples +let speechDetected = try await RunAnywhere.detectSpeech(in: samples) + +// From AVAudioPCMBuffer +let buffer: AVAudioPCMBuffer = // your audio buffer +let speechDetected = try await RunAnywhere.detectSpeech(in: buffer) +``` + +#### Callbacks + +```swift +// Speech activity callback +await RunAnywhere.setVADSpeechActivityCallback { event in + switch event { + case .started: + print("Speech started") + case .ended: + print("Speech ended") + } +} + +// Audio buffer callback +await RunAnywhere.setVADAudioBufferCallback { samples in + // Process audio samples +} +``` + +#### Control + +```swift +try await RunAnywhere.startVAD() +try await RunAnywhere.stopVAD() +await RunAnywhere.cleanupVAD() +``` + +## API Reference + +### ONNX Module + +```swift +public enum ONNX: RunAnywhereModule { + /// Module identifier + public static let moduleId = "onnx" + + /// Human-readable module name + public static let moduleName = "ONNX Runtime" + + /// Capabilities provided by this module + public static let capabilities: Set = [.stt, .tts, .vad] + + /// Default registration priority + public static let defaultPriority: Int = 100 + + /// Inference framework used + public static let inferenceFramework: InferenceFramework = .onnx + + /// Module version + public static let version = "2.0.0" + + /// Underlying ONNX Runtime version + public static let onnxRuntimeVersion = "1.23.2" + + /// Register the module with the service registry + @MainActor + public static func register(priority: Int = 100) + + /// Unregister the module + public static func unregister() + + /// Check if the module can handle a given STT model + public static func canHandleSTT(modelId: String?) -> Bool + + /// Check if the module can handle a given TTS model + public static func canHandleTTS(modelId: String?) -> Bool + + /// Check if the module can handle VAD + public static func canHandleVAD(modelId: String?) -> Bool +} +``` + +### Model Compatibility + +#### STT Models + +The ONNX module handles STT models containing: +- `whisper` (Whisper variants) +- `zipformer` (Zipformer ASR) +- `paraformer` (Paraformer ASR) + +#### TTS Models + +The ONNX module handles TTS models containing: +- `piper` (Piper TTS voices) +- `vits` (VITS TTS models) + +#### VAD + +The module uses Silero VAD by default for voice activity detection. + +### STT Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `language` | String | "en" | Language code for transcription | +| `sampleRate` | Int | 16000 | Audio sample rate in Hz | +| `enableWordTimestamps` | Bool | false | Include word-level timestamps | +| `enableVAD` | Bool | true | Enable voice activity detection | + +### TTS Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `rate` | Float | 1.0 | Speaking rate multiplier | +| `pitch` | Float | 1.0 | Voice pitch multiplier | +| `volume` | Float | 1.0 | Output volume (0.0 - 1.0) | +| `language` | String | "en-US" | Voice language | +| `sampleRate` | Int | 22050 | Output sample rate | +| `audioFormat` | AudioFormat | .wav | Output audio format | + +### VAD Configuration + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `sampleRate` | Int | 16000 | Audio sample rate in Hz | +| `frameLength` | Double | 0.032 | Frame length in seconds | +| `energyThreshold` | Double | 0.5 | Energy threshold for detection | + +## Architecture + +The module follows a thin wrapper pattern: + +``` +ONNX.swift (Swift wrapper) + | +ONNXBackend (C headers) + | +RABackendONNX.xcframework (C++ implementation) + | ++---------------+----------------+ +| | | +ONNX Runtime Sherpa-ONNX Silero VAD +``` + +The Swift code registers the backend with the C++ service registry, which handles all model loading and inference operations internally. + +## Performance + +### STT Performance + +| Device | Model | Real-time Factor | +|--------|-------|------------------| +| iPhone 15 Pro | Whisper Base | 0.3x (3x faster than real-time) | +| iPhone 15 Pro | Whisper Small | 0.5x | +| M1 MacBook | Whisper Base | 0.2x | +| M1 MacBook | Whisper Small | 0.3x | + +### TTS Performance + +| Device | Voice | Characters/sec | +|--------|-------|----------------| +| iPhone 15 Pro | Piper Amy | 200-300 | +| M1 MacBook | Piper Amy | 400-500 | + +Performance varies based on model size and device thermal state. + +## Audio Format Requirements + +### STT Input + +- Sample rate: 16000 Hz (default, configurable) +- Channels: Mono +- Format: Float32 PCM + +### TTS Output + +- Sample rate: 22050 Hz (default, configurable) +- Channels: Mono +- Format: Float32 PCM or WAV + +## Troubleshooting + +### Model Load Fails + +1. Ensure the model is downloaded: check `ModelInfo.isDownloaded` +2. Verify the model format matches the capability (Whisper for STT, Piper for TTS) +3. Check available memory + +### Poor Transcription Quality + +1. Ensure audio is 16kHz mono +2. Check audio levels (too quiet or clipped) +3. Try a larger Whisper model + +### TTS Audio Issues + +1. Verify the voice model is fully downloaded +2. Check audio output route +3. Ensure sample rate matches expectations + +### Registration Not Working + +1. Ensure `register()` is called on the main actor +2. Call `register()` before `RunAnywhere.initialize()` +3. Check for registration errors in logs + +## License + +Copyright 2025 RunAnywhere AI. All rights reserved. diff --git a/sdk/runanywhere-swift/Sources/ONNXRuntime/include/ONNXBackend.h b/sdk/runanywhere-swift/Sources/ONNXRuntime/include/ONNXBackend.h new file mode 100644 index 000000000..6ed8419ab --- /dev/null +++ b/sdk/runanywhere-swift/Sources/ONNXRuntime/include/ONNXBackend.h @@ -0,0 +1,17 @@ +/** + * @file ONNXBackend.h + * @brief Umbrella header for ONNX backend C APIs + * + * This header exposes the ONNX backend C APIs to Swift. + * Part of the unified ONNXRuntime module. + */ + +#ifndef ONNX_BACKEND_H +#define ONNX_BACKEND_H + +// Include the ONNX backend headers for STT, TTS, and VAD +#include "rac_stt_onnx.h" +#include "rac_tts_onnx.h" +#include "rac_vad_onnx.h" + +#endif /* ONNX_BACKEND_H */ diff --git a/sdk/runanywhere-swift/Sources/ONNXRuntime/include/module.modulemap b/sdk/runanywhere-swift/Sources/ONNXRuntime/include/module.modulemap new file mode 100644 index 000000000..3ca04eb4e --- /dev/null +++ b/sdk/runanywhere-swift/Sources/ONNXRuntime/include/module.modulemap @@ -0,0 +1,9 @@ +module ONNXBackend { + umbrella header "ONNXBackend.h" + + export * + module * { export * } + + link framework "Accelerate" + link framework "CoreML" +} diff --git a/sdk/runanywhere-swift/Sources/ONNXRuntime/include/rac_error.h b/sdk/runanywhere-swift/Sources/ONNXRuntime/include/rac_error.h new file mode 100644 index 000000000..35d3b4ec4 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/ONNXRuntime/include/rac_error.h @@ -0,0 +1,467 @@ +/** + * @file rac_error.h + * @brief RunAnywhere Commons - Error Codes and Error Handling + * + * C port of Swift's ErrorCode enum from Foundation/Errors/ErrorCode.swift. + * + * Error codes for runanywhere-commons use the range -100 to -999 to avoid + * collision with runanywhere-core error codes (0 to -99). + * + * IMPORTANT: This is a direct translation of the Swift implementation. + * Do NOT add error codes not present in the Swift code. + */ + +#ifndef RAC_ERROR_H +#define RAC_ERROR_H + +#include "rac_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// ERROR CODE RANGES +// ============================================================================= +// +// runanywhere-core (ra_*): 0 to -99 +// runanywhere-commons (rac_*): -100 to -999 +// - Initialization errors: -100 to -109 +// - Model errors: -110 to -129 +// - Generation errors: -130 to -149 +// - Network errors: -150 to -179 +// - Storage errors: -180 to -219 +// - Hardware errors: -220 to -229 +// - Component state errors: -230 to -249 +// - Validation errors: -250 to -279 +// - Audio errors: -280 to -299 +// - Language/Voice errors: -300 to -319 +// - Authentication errors: -320 to -329 +// - Security errors: -330 to -349 +// - Extraction errors: -350 to -369 +// - Calibration errors: -370 to -379 +// - Module/Service errors: -400 to -499 +// - Platform adapter errors: -500 to -599 +// - Backend errors: -600 to -699 +// - Event errors: -700 to -799 +// - Other errors: -800 to -899 +// - Reserved: -900 to -999 + +// ============================================================================= +// INITIALIZATION ERRORS (-100 to -109) +// Mirrors Swift's ErrorCode: Initialization Errors +// ============================================================================= + +/** Component or service has not been initialized */ +#define RAC_ERROR_NOT_INITIALIZED ((rac_result_t) - 100) +/** Component or service is already initialized */ +#define RAC_ERROR_ALREADY_INITIALIZED ((rac_result_t) - 101) +/** Initialization failed */ +#define RAC_ERROR_INITIALIZATION_FAILED ((rac_result_t) - 102) +/** Configuration is invalid */ +#define RAC_ERROR_INVALID_CONFIGURATION ((rac_result_t) - 103) +/** API key is invalid or missing */ +#define RAC_ERROR_INVALID_API_KEY ((rac_result_t) - 104) +/** Environment mismatch (e.g., dev vs prod) */ +#define RAC_ERROR_ENVIRONMENT_MISMATCH ((rac_result_t) - 105) + +// ============================================================================= +// MODEL ERRORS (-110 to -129) +// Mirrors Swift's ErrorCode: Model Errors +// ============================================================================= + +/** Requested model was not found */ +#define RAC_ERROR_MODEL_NOT_FOUND ((rac_result_t) - 110) +/** Failed to load the model */ +#define RAC_ERROR_MODEL_LOAD_FAILED ((rac_result_t) - 111) +/** Model validation failed */ +#define RAC_ERROR_MODEL_VALIDATION_FAILED ((rac_result_t) - 112) +/** Model is incompatible with current runtime */ +#define RAC_ERROR_MODEL_INCOMPATIBLE ((rac_result_t) - 113) +/** Model format is invalid */ +#define RAC_ERROR_INVALID_MODEL_FORMAT ((rac_result_t) - 114) +/** Model storage is corrupted */ +#define RAC_ERROR_MODEL_STORAGE_CORRUPTED ((rac_result_t) - 115) +/** Model not loaded (alias for backward compatibility) */ +#define RAC_ERROR_MODEL_NOT_LOADED ((rac_result_t) - 116) + +// ============================================================================= +// GENERATION ERRORS (-130 to -149) +// Mirrors Swift's ErrorCode: Generation Errors +// ============================================================================= + +/** Text/audio generation failed */ +#define RAC_ERROR_GENERATION_FAILED ((rac_result_t) - 130) +/** Generation timed out */ +#define RAC_ERROR_GENERATION_TIMEOUT ((rac_result_t) - 131) +/** Context length exceeded maximum */ +#define RAC_ERROR_CONTEXT_TOO_LONG ((rac_result_t) - 132) +/** Token limit exceeded */ +#define RAC_ERROR_TOKEN_LIMIT_EXCEEDED ((rac_result_t) - 133) +/** Cost limit exceeded */ +#define RAC_ERROR_COST_LIMIT_EXCEEDED ((rac_result_t) - 134) +/** Inference failed */ +#define RAC_ERROR_INFERENCE_FAILED ((rac_result_t) - 135) + +// ============================================================================= +// NETWORK ERRORS (-150 to -179) +// Mirrors Swift's ErrorCode: Network Errors +// ============================================================================= + +/** Network is unavailable */ +#define RAC_ERROR_NETWORK_UNAVAILABLE ((rac_result_t) - 150) +/** Generic network error */ +#define RAC_ERROR_NETWORK_ERROR ((rac_result_t) - 151) +/** Request failed */ +#define RAC_ERROR_REQUEST_FAILED ((rac_result_t) - 152) +/** Download failed */ +#define RAC_ERROR_DOWNLOAD_FAILED ((rac_result_t) - 153) +/** Server returned an error */ +#define RAC_ERROR_SERVER_ERROR ((rac_result_t) - 154) +/** Request timed out */ +#define RAC_ERROR_TIMEOUT ((rac_result_t) - 155) +/** Invalid response from server */ +#define RAC_ERROR_INVALID_RESPONSE ((rac_result_t) - 156) +/** HTTP error with status code */ +#define RAC_ERROR_HTTP_ERROR ((rac_result_t) - 157) +/** Connection was lost */ +#define RAC_ERROR_CONNECTION_LOST ((rac_result_t) - 158) +/** Partial download (incomplete) */ +#define RAC_ERROR_PARTIAL_DOWNLOAD ((rac_result_t) - 159) +/** HTTP request failed */ +#define RAC_ERROR_HTTP_REQUEST_FAILED ((rac_result_t) - 160) +/** HTTP not supported */ +#define RAC_ERROR_HTTP_NOT_SUPPORTED ((rac_result_t) - 161) + +// ============================================================================= +// STORAGE ERRORS (-180 to -219) +// Mirrors Swift's ErrorCode: Storage Errors +// ============================================================================= + +/** Insufficient storage space */ +#define RAC_ERROR_INSUFFICIENT_STORAGE ((rac_result_t) - 180) +/** Storage is full */ +#define RAC_ERROR_STORAGE_FULL ((rac_result_t) - 181) +/** Generic storage error */ +#define RAC_ERROR_STORAGE_ERROR ((rac_result_t) - 182) +/** File was not found */ +#define RAC_ERROR_FILE_NOT_FOUND ((rac_result_t) - 183) +/** Failed to read file */ +#define RAC_ERROR_FILE_READ_FAILED ((rac_result_t) - 184) +/** Failed to write file */ +#define RAC_ERROR_FILE_WRITE_FAILED ((rac_result_t) - 185) +/** Permission denied for file operation */ +#define RAC_ERROR_PERMISSION_DENIED ((rac_result_t) - 186) +/** Failed to delete file or directory */ +#define RAC_ERROR_DELETE_FAILED ((rac_result_t) - 187) +/** Failed to move file */ +#define RAC_ERROR_MOVE_FAILED ((rac_result_t) - 188) +/** Failed to create directory */ +#define RAC_ERROR_DIRECTORY_CREATION_FAILED ((rac_result_t) - 189) +/** Directory not found */ +#define RAC_ERROR_DIRECTORY_NOT_FOUND ((rac_result_t) - 190) +/** Invalid file path */ +#define RAC_ERROR_INVALID_PATH ((rac_result_t) - 191) +/** Invalid file name */ +#define RAC_ERROR_INVALID_FILE_NAME ((rac_result_t) - 192) +/** Failed to create temporary file */ +#define RAC_ERROR_TEMP_FILE_CREATION_FAILED ((rac_result_t) - 193) +/** File delete failed (alias) */ +#define RAC_ERROR_FILE_DELETE_FAILED ((rac_result_t) - 187) + +// ============================================================================= +// HARDWARE ERRORS (-220 to -229) +// Mirrors Swift's ErrorCode: Hardware Errors +// ============================================================================= + +/** Hardware is unsupported */ +#define RAC_ERROR_HARDWARE_UNSUPPORTED ((rac_result_t) - 220) +/** Insufficient memory */ +#define RAC_ERROR_INSUFFICIENT_MEMORY ((rac_result_t) - 221) +/** Out of memory (alias) */ +#define RAC_ERROR_OUT_OF_MEMORY ((rac_result_t) - 221) + +// ============================================================================= +// COMPONENT STATE ERRORS (-230 to -249) +// Mirrors Swift's ErrorCode: Component State Errors +// ============================================================================= + +/** Component is not ready */ +#define RAC_ERROR_COMPONENT_NOT_READY ((rac_result_t) - 230) +/** Component is in invalid state */ +#define RAC_ERROR_INVALID_STATE ((rac_result_t) - 231) +/** Service is not available */ +#define RAC_ERROR_SERVICE_NOT_AVAILABLE ((rac_result_t) - 232) +/** Service is busy */ +#define RAC_ERROR_SERVICE_BUSY ((rac_result_t) - 233) +/** Processing failed */ +#define RAC_ERROR_PROCESSING_FAILED ((rac_result_t) - 234) +/** Start operation failed */ +#define RAC_ERROR_START_FAILED ((rac_result_t) - 235) +/** Feature/operation is not supported */ +#define RAC_ERROR_NOT_SUPPORTED ((rac_result_t) - 236) + +// ============================================================================= +// VALIDATION ERRORS (-250 to -279) +// Mirrors Swift's ErrorCode: Validation Errors +// ============================================================================= + +/** Validation failed */ +#define RAC_ERROR_VALIDATION_FAILED ((rac_result_t) - 250) +/** Input is invalid */ +#define RAC_ERROR_INVALID_INPUT ((rac_result_t) - 251) +/** Format is invalid */ +#define RAC_ERROR_INVALID_FORMAT ((rac_result_t) - 252) +/** Input is empty */ +#define RAC_ERROR_EMPTY_INPUT ((rac_result_t) - 253) +/** Text is too long */ +#define RAC_ERROR_TEXT_TOO_LONG ((rac_result_t) - 254) +/** Invalid SSML markup */ +#define RAC_ERROR_INVALID_SSML ((rac_result_t) - 255) +/** Invalid speaking rate */ +#define RAC_ERROR_INVALID_SPEAKING_RATE ((rac_result_t) - 256) +/** Invalid pitch */ +#define RAC_ERROR_INVALID_PITCH ((rac_result_t) - 257) +/** Invalid volume */ +#define RAC_ERROR_INVALID_VOLUME ((rac_result_t) - 258) +/** Invalid argument */ +#define RAC_ERROR_INVALID_ARGUMENT ((rac_result_t) - 259) +/** Null pointer */ +#define RAC_ERROR_NULL_POINTER ((rac_result_t) - 260) +/** Buffer too small */ +#define RAC_ERROR_BUFFER_TOO_SMALL ((rac_result_t) - 261) + +// ============================================================================= +// AUDIO ERRORS (-280 to -299) +// Mirrors Swift's ErrorCode: Audio Errors +// ============================================================================= + +/** Audio format is not supported */ +#define RAC_ERROR_AUDIO_FORMAT_NOT_SUPPORTED ((rac_result_t) - 280) +/** Audio session configuration failed */ +#define RAC_ERROR_AUDIO_SESSION_FAILED ((rac_result_t) - 281) +/** Microphone permission denied */ +#define RAC_ERROR_MICROPHONE_PERMISSION_DENIED ((rac_result_t) - 282) +/** Insufficient audio data */ +#define RAC_ERROR_INSUFFICIENT_AUDIO_DATA ((rac_result_t) - 283) +/** Audio buffer is empty */ +#define RAC_ERROR_EMPTY_AUDIO_BUFFER ((rac_result_t) - 284) +/** Audio session activation failed */ +#define RAC_ERROR_AUDIO_SESSION_ACTIVATION_FAILED ((rac_result_t) - 285) + +// ============================================================================= +// LANGUAGE/VOICE ERRORS (-300 to -319) +// Mirrors Swift's ErrorCode: Language/Voice Errors +// ============================================================================= + +/** Language is not supported */ +#define RAC_ERROR_LANGUAGE_NOT_SUPPORTED ((rac_result_t) - 300) +/** Voice is not available */ +#define RAC_ERROR_VOICE_NOT_AVAILABLE ((rac_result_t) - 301) +/** Streaming is not supported */ +#define RAC_ERROR_STREAMING_NOT_SUPPORTED ((rac_result_t) - 302) +/** Stream was cancelled */ +#define RAC_ERROR_STREAM_CANCELLED ((rac_result_t) - 303) + +// ============================================================================= +// AUTHENTICATION ERRORS (-320 to -329) +// Mirrors Swift's ErrorCode: Authentication Errors +// ============================================================================= + +/** Authentication failed */ +#define RAC_ERROR_AUTHENTICATION_FAILED ((rac_result_t) - 320) +/** Unauthorized access */ +#define RAC_ERROR_UNAUTHORIZED ((rac_result_t) - 321) +/** Access forbidden */ +#define RAC_ERROR_FORBIDDEN ((rac_result_t) - 322) + +// ============================================================================= +// SECURITY ERRORS (-330 to -349) +// Mirrors Swift's ErrorCode: Security Errors +// ============================================================================= + +/** Keychain operation failed */ +#define RAC_ERROR_KEYCHAIN_ERROR ((rac_result_t) - 330) +/** Encoding error */ +#define RAC_ERROR_ENCODING_ERROR ((rac_result_t) - 331) +/** Decoding error */ +#define RAC_ERROR_DECODING_ERROR ((rac_result_t) - 332) +/** Secure storage failed */ +#define RAC_ERROR_SECURE_STORAGE_FAILED ((rac_result_t) - 333) + +// ============================================================================= +// EXTRACTION ERRORS (-350 to -369) +// Mirrors Swift's ErrorCode: Extraction Errors +// ============================================================================= + +/** Extraction failed (JSON, archive, etc.) */ +#define RAC_ERROR_EXTRACTION_FAILED ((rac_result_t) - 350) +/** Checksum mismatch */ +#define RAC_ERROR_CHECKSUM_MISMATCH ((rac_result_t) - 351) +/** Unsupported archive format */ +#define RAC_ERROR_UNSUPPORTED_ARCHIVE ((rac_result_t) - 352) + +// ============================================================================= +// CALIBRATION ERRORS (-370 to -379) +// Mirrors Swift's ErrorCode: Calibration Errors +// ============================================================================= + +/** Calibration failed */ +#define RAC_ERROR_CALIBRATION_FAILED ((rac_result_t) - 370) +/** Calibration timed out */ +#define RAC_ERROR_CALIBRATION_TIMEOUT ((rac_result_t) - 371) + +// ============================================================================= +// CANCELLATION (-380 to -389) +// Mirrors Swift's ErrorCode: Cancellation +// ============================================================================= + +/** Operation was cancelled */ +#define RAC_ERROR_CANCELLED ((rac_result_t) - 380) + +// ============================================================================= +// MODULE/SERVICE ERRORS (-400 to -499) +// ============================================================================= + +/** Module not found */ +#define RAC_ERROR_MODULE_NOT_FOUND ((rac_result_t) - 400) +/** Module already registered */ +#define RAC_ERROR_MODULE_ALREADY_REGISTERED ((rac_result_t) - 401) +/** Module load failed */ +#define RAC_ERROR_MODULE_LOAD_FAILED ((rac_result_t) - 402) +/** Service not found */ +#define RAC_ERROR_SERVICE_NOT_FOUND ((rac_result_t) - 410) +/** Service already registered */ +#define RAC_ERROR_SERVICE_ALREADY_REGISTERED ((rac_result_t) - 411) +/** Service create failed */ +#define RAC_ERROR_SERVICE_CREATE_FAILED ((rac_result_t) - 412) +/** Capability not found */ +#define RAC_ERROR_CAPABILITY_NOT_FOUND ((rac_result_t) - 420) +/** Provider not found */ +#define RAC_ERROR_PROVIDER_NOT_FOUND ((rac_result_t) - 421) +/** No capable provider */ +#define RAC_ERROR_NO_CAPABLE_PROVIDER ((rac_result_t) - 422) +/** Generic not found */ +#define RAC_ERROR_NOT_FOUND ((rac_result_t) - 423) + +// ============================================================================= +// PLATFORM ADAPTER ERRORS (-500 to -599) +// ============================================================================= + +/** Adapter not set */ +#define RAC_ERROR_ADAPTER_NOT_SET ((rac_result_t) - 500) + +// ============================================================================= +// BACKEND ERRORS (-600 to -699) +// ============================================================================= + +/** Backend not found */ +#define RAC_ERROR_BACKEND_NOT_FOUND ((rac_result_t) - 600) +/** Backend not ready */ +#define RAC_ERROR_BACKEND_NOT_READY ((rac_result_t) - 601) +/** Backend init failed */ +#define RAC_ERROR_BACKEND_INIT_FAILED ((rac_result_t) - 602) +/** Backend busy */ +#define RAC_ERROR_BACKEND_BUSY ((rac_result_t) - 603) +/** Invalid handle */ +#define RAC_ERROR_INVALID_HANDLE ((rac_result_t) - 610) + +// ============================================================================= +// EVENT ERRORS (-700 to -799) +// ============================================================================= + +/** Invalid event category */ +#define RAC_ERROR_EVENT_INVALID_CATEGORY ((rac_result_t) - 700) +/** Event subscription failed */ +#define RAC_ERROR_EVENT_SUBSCRIPTION_FAILED ((rac_result_t) - 701) +/** Event publish failed */ +#define RAC_ERROR_EVENT_PUBLISH_FAILED ((rac_result_t) - 702) + +// ============================================================================= +// OTHER ERRORS (-800 to -899) +// Mirrors Swift's ErrorCode: Other Errors +// ============================================================================= + +/** Feature is not implemented */ +#define RAC_ERROR_NOT_IMPLEMENTED ((rac_result_t) - 800) +/** Feature is not available */ +#define RAC_ERROR_FEATURE_NOT_AVAILABLE ((rac_result_t) - 801) +/** Framework is not available */ +#define RAC_ERROR_FRAMEWORK_NOT_AVAILABLE ((rac_result_t) - 802) +/** Unsupported modality */ +#define RAC_ERROR_UNSUPPORTED_MODALITY ((rac_result_t) - 803) +/** Unknown error */ +#define RAC_ERROR_UNKNOWN ((rac_result_t) - 804) +/** Internal error */ +#define RAC_ERROR_INTERNAL ((rac_result_t) - 805) + +// ============================================================================= +// ERROR MESSAGE API +// ============================================================================= + +/** + * Gets a human-readable error message for an error code. + * + * @param error_code The error code to get a message for + * @return A static string describing the error (never NULL) + */ +RAC_API const char* rac_error_message(rac_result_t error_code); + +/** + * Gets the last detailed error message. + * + * This returns additional context beyond the error code, such as file paths + * or specific failure reasons. Returns NULL if no detailed message is set. + * + * @return The last error detail string, or NULL + * + * @note The returned string is thread-local and valid until the next + * RAC function call on the same thread. + */ +RAC_API const char* rac_error_get_details(void); + +/** + * Sets the detailed error message for the current thread. + * + * This is typically called internally by RAC functions to provide + * additional context for errors. + * + * @param details The detail string (will be copied internally) + */ +RAC_API void rac_error_set_details(const char* details); + +/** + * Clears the detailed error message for the current thread. + */ +RAC_API void rac_error_clear_details(void); + +/** + * Checks if an error code is in the commons range (-100 to -999). + * + * @param error_code The error code to check + * @return RAC_TRUE if the error is from commons, RAC_FALSE otherwise + */ +RAC_API rac_bool_t rac_error_is_commons_error(rac_result_t error_code); + +/** + * Checks if an error code is in the core range (0 to -99). + * + * @param error_code The error code to check + * @return RAC_TRUE if the error is from core, RAC_FALSE otherwise + */ +RAC_API rac_bool_t rac_error_is_core_error(rac_result_t error_code); + +/** + * Checks if an error is expected/routine (like cancellation). + * Mirrors Swift's ErrorCode.isExpected property. + * + * @param error_code The error code to check + * @return RAC_TRUE if expected, RAC_FALSE otherwise + */ +RAC_API rac_bool_t rac_error_is_expected(rac_result_t error_code); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_ERROR_H */ diff --git a/sdk/runanywhere-swift/Sources/ONNXRuntime/include/rac_stt.h b/sdk/runanywhere-swift/Sources/ONNXRuntime/include/rac_stt.h new file mode 100644 index 000000000..7ae93e69e --- /dev/null +++ b/sdk/runanywhere-swift/Sources/ONNXRuntime/include/rac_stt.h @@ -0,0 +1,17 @@ +/** + * @file rac_stt.h + * @brief RunAnywhere Commons - STT API (Convenience Header) + * + * This header includes both types and service interface for convenience. + * For better separation of concerns, prefer including: + * - rac_stt_types.h for data structures only + * - rac_stt_service.h for the service interface + */ + +#ifndef RAC_STT_H +#define RAC_STT_H + +#include "rac_stt_service.h" +#include "rac_stt_types.h" + +#endif /* RAC_STT_H */ diff --git a/sdk/runanywhere-swift/Sources/ONNXRuntime/include/rac_stt_onnx.h b/sdk/runanywhere-swift/Sources/ONNXRuntime/include/rac_stt_onnx.h new file mode 100644 index 000000000..458a8db46 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/ONNXRuntime/include/rac_stt_onnx.h @@ -0,0 +1,195 @@ +/** + * @file rac_stt_onnx.h + * @brief RunAnywhere Commons - ONNX Backend for STT + * + * C wrapper around runanywhere-core's ONNX STT backend. + * Mirrors Swift's ONNXSTTService implementation. + * + * See: Sources/ONNXRuntime/ONNXSTTService.swift + */ + +#ifndef RAC_STT_ONNX_H +#define RAC_STT_ONNX_H + +#include "rac_error.h" +#include "rac_types.h" +#include "rac_stt.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// EXPORT MACRO +// ============================================================================= + +#if defined(RAC_ONNX_BUILDING) +#if defined(_WIN32) +#define RAC_ONNX_API __declspec(dllexport) +#elif defined(__GNUC__) || defined(__clang__) +#define RAC_ONNX_API __attribute__((visibility("default"))) +#else +#define RAC_ONNX_API +#endif +#else +#define RAC_ONNX_API +#endif + +// ============================================================================= +// CONFIGURATION +// ============================================================================= + +/** + * ONNX STT model types. + * Mirrors detection logic in ONNXSTTService.detectModelType(). + */ +typedef enum rac_stt_onnx_model_type { + RAC_STT_ONNX_MODEL_WHISPER = 0, + RAC_STT_ONNX_MODEL_ZIPFORMER = 1, + RAC_STT_ONNX_MODEL_PARAFORMER = 2, + RAC_STT_ONNX_MODEL_AUTO = 99 // Auto-detect +} rac_stt_onnx_model_type_t; + +/** + * ONNX STT configuration. + */ +typedef struct rac_stt_onnx_config { + /** Model type (or AUTO for detection) */ + rac_stt_onnx_model_type_t model_type; + + /** Number of threads (0 = auto) */ + int32_t num_threads; + + /** Enable CoreML on Apple platforms */ + rac_bool_t use_coreml; +} rac_stt_onnx_config_t; + +/** + * Default ONNX STT configuration. + */ +static const rac_stt_onnx_config_t RAC_STT_ONNX_CONFIG_DEFAULT = { + .model_type = RAC_STT_ONNX_MODEL_AUTO, .num_threads = 0, .use_coreml = RAC_TRUE}; + +// ============================================================================= +// ONNX STT API +// ============================================================================= + +/** + * Creates an ONNX STT service. + * + * Mirrors Swift's ONNXSTTService.initialize(modelPath:) + * + * @param model_path Path to the model directory or file + * @param config ONNX-specific configuration (can be NULL for defaults) + * @param out_handle Output: Handle to the created service + * @return RAC_SUCCESS or error code + */ +RAC_ONNX_API rac_result_t rac_stt_onnx_create(const char* model_path, + const rac_stt_onnx_config_t* config, + rac_handle_t* out_handle); + +/** + * Transcribes audio data. + * + * Mirrors Swift's ONNXSTTService.transcribe(audioData:options:) + * + * @param handle Service handle + * @param audio_samples Float32 PCM samples (16kHz mono) + * @param num_samples Number of samples + * @param options STT options (can be NULL for defaults) + * @param out_result Output: Transcription result + * @return RAC_SUCCESS or error code + */ +RAC_ONNX_API rac_result_t rac_stt_onnx_transcribe(rac_handle_t handle, const float* audio_samples, + size_t num_samples, + const rac_stt_options_t* options, + rac_stt_result_t* out_result); + +/** + * Checks if streaming is supported. + * + * Mirrors Swift's ONNXSTTService.supportsStreaming + * + * @param handle Service handle + * @return RAC_TRUE if streaming is supported + */ +RAC_ONNX_API rac_bool_t rac_stt_onnx_supports_streaming(rac_handle_t handle); + +/** + * Creates a streaming session. + * + * @param handle Service handle + * @param out_stream Output: Stream handle + * @return RAC_SUCCESS or error code + */ +RAC_ONNX_API rac_result_t rac_stt_onnx_create_stream(rac_handle_t handle, rac_handle_t* out_stream); + +/** + * Feeds audio to a streaming session. + * + * @param handle Service handle + * @param stream Stream handle + * @param audio_samples Float32 PCM samples + * @param num_samples Number of samples + * @return RAC_SUCCESS or error code + */ +RAC_ONNX_API rac_result_t rac_stt_onnx_feed_audio(rac_handle_t handle, rac_handle_t stream, + const float* audio_samples, size_t num_samples); + +/** + * Checks if stream is ready for decoding. + * + * @param handle Service handle + * @param stream Stream handle + * @return RAC_TRUE if ready + */ +RAC_ONNX_API rac_bool_t rac_stt_onnx_stream_is_ready(rac_handle_t handle, rac_handle_t stream); + +/** + * Decodes current stream state. + * + * @param handle Service handle + * @param stream Stream handle + * @param out_text Output: Partial transcription (caller must free) + * @return RAC_SUCCESS or error code + */ +RAC_ONNX_API rac_result_t rac_stt_onnx_decode_stream(rac_handle_t handle, rac_handle_t stream, + char** out_text); + +/** + * Signals end of audio input. + * + * @param handle Service handle + * @param stream Stream handle + */ +RAC_ONNX_API void rac_stt_onnx_input_finished(rac_handle_t handle, rac_handle_t stream); + +/** + * Checks if endpoint (end of speech) detected. + * + * @param handle Service handle + * @param stream Stream handle + * @return RAC_TRUE if endpoint detected + */ +RAC_ONNX_API rac_bool_t rac_stt_onnx_is_endpoint(rac_handle_t handle, rac_handle_t stream); + +/** + * Destroys a streaming session. + * + * @param handle Service handle + * @param stream Stream handle to destroy + */ +RAC_ONNX_API void rac_stt_onnx_destroy_stream(rac_handle_t handle, rac_handle_t stream); + +/** + * Destroys an ONNX STT service. + * + * @param handle Service handle to destroy + */ +RAC_ONNX_API void rac_stt_onnx_destroy(rac_handle_t handle); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_STT_ONNX_H */ diff --git a/sdk/runanywhere-swift/Sources/ONNXRuntime/include/rac_stt_types.h b/sdk/runanywhere-swift/Sources/ONNXRuntime/include/rac_stt_types.h new file mode 100644 index 000000000..7b9468b47 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/ONNXRuntime/include/rac_stt_types.h @@ -0,0 +1,359 @@ +/** + * @file rac_stt_types.h + * @brief RunAnywhere Commons - STT Types and Data Structures + * + * C port of Swift's STT Models from: + * Sources/RunAnywhere/Features/STT/Models/STTConfiguration.swift + * Sources/RunAnywhere/Features/STT/Models/STTOptions.swift + * Sources/RunAnywhere/Features/STT/Models/STTInput.swift + * Sources/RunAnywhere/Features/STT/Models/STTOutput.swift + * Sources/RunAnywhere/Features/STT/Models/STTTranscriptionResult.swift + * + * This header defines data structures only. For the service interface, + * see rac_stt_service.h. + */ + +#ifndef RAC_STT_TYPES_H +#define RAC_STT_TYPES_H + +#include "rac_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// CONSTANTS - Mirrors Swift's STTConstants +// ============================================================================= + +/** Default sample rate for STT (16kHz) */ +#define RAC_STT_DEFAULT_SAMPLE_RATE 16000 + +// ============================================================================= +// AUDIO FORMAT - Mirrors Swift's AudioFormat +// ============================================================================= + +/** + * @brief Audio format enumeration + */ +typedef enum rac_audio_format_enum { + RAC_AUDIO_FORMAT_PCM = 0, + RAC_AUDIO_FORMAT_WAV = 1, + RAC_AUDIO_FORMAT_MP3 = 2, + RAC_AUDIO_FORMAT_OPUS = 3, + RAC_AUDIO_FORMAT_FLAC = 4 +} rac_audio_format_enum_t; + +// ============================================================================= +// CONFIGURATION - Mirrors Swift's STTConfiguration +// ============================================================================= + +/** + * @brief STT component configuration + * + * Mirrors Swift's STTConfiguration struct exactly. + * See: Sources/RunAnywhere/Features/STT/Models/STTConfiguration.swift + */ +typedef struct rac_stt_config { + /** Model ID (optional - uses default if NULL) */ + const char* model_id; + + /** Preferred framework for transcription (use -1 for auto) */ + int32_t preferred_framework; + + /** Language code for transcription (e.g., "en-US") */ + const char* language; + + /** Sample rate in Hz (default: 16000) */ + int32_t sample_rate; + + /** Enable automatic punctuation in transcription */ + rac_bool_t enable_punctuation; + + /** Enable speaker diarization */ + rac_bool_t enable_diarization; + + /** Vocabulary list for improved recognition (NULL-terminated array, can be NULL) */ + const char* const* vocabulary_list; + size_t num_vocabulary; + + /** Maximum number of alternative transcriptions (default: 1) */ + int32_t max_alternatives; + + /** Enable word-level timestamps */ + rac_bool_t enable_timestamps; +} rac_stt_config_t; + +/** + * @brief Default STT configuration + */ +static const rac_stt_config_t RAC_STT_CONFIG_DEFAULT = {.model_id = RAC_NULL, + .preferred_framework = -1, + .language = "en-US", + .sample_rate = RAC_STT_DEFAULT_SAMPLE_RATE, + .enable_punctuation = RAC_TRUE, + .enable_diarization = RAC_FALSE, + .vocabulary_list = RAC_NULL, + .num_vocabulary = 0, + .max_alternatives = 1, + .enable_timestamps = RAC_TRUE}; + +// ============================================================================= +// OPTIONS - Mirrors Swift's STTOptions +// ============================================================================= + +/** + * @brief STT transcription options + * + * Mirrors Swift's STTOptions struct. + * See: Sources/RunAnywhere/Features/STT/Models/STTOptions.swift + */ +typedef struct rac_stt_options { + /** Language code for transcription (e.g., "en", "es", "fr") */ + const char* language; + + /** Whether to auto-detect the spoken language */ + rac_bool_t detect_language; + + /** Enable automatic punctuation in transcription */ + rac_bool_t enable_punctuation; + + /** Enable speaker diarization */ + rac_bool_t enable_diarization; + + /** Maximum number of speakers (0 = auto) */ + int32_t max_speakers; + + /** Enable word-level timestamps */ + rac_bool_t enable_timestamps; + + /** Audio format of input data */ + rac_audio_format_enum_t audio_format; + + /** Sample rate of input audio (default: 16000 Hz) */ + int32_t sample_rate; +} rac_stt_options_t; + +/** + * @brief Default STT options + */ +static const rac_stt_options_t RAC_STT_OPTIONS_DEFAULT = {.language = "en", + .detect_language = RAC_FALSE, + .enable_punctuation = RAC_TRUE, + .enable_diarization = RAC_FALSE, + .max_speakers = 0, + .enable_timestamps = RAC_TRUE, + .audio_format = RAC_AUDIO_FORMAT_PCM, + .sample_rate = 16000}; + +// ============================================================================= +// RESULT - Mirrors Swift's STTTranscriptionResult +// ============================================================================= + +/** + * @brief Word timestamp information + */ +typedef struct rac_stt_word { + /** The word text */ + const char* text; + /** Start time in milliseconds */ + int64_t start_ms; + /** End time in milliseconds */ + int64_t end_ms; + /** Confidence score (0.0 to 1.0) */ + float confidence; +} rac_stt_word_t; + +/** + * @brief STT transcription result + * + * Mirrors Swift's STTTranscriptionResult struct. + */ +typedef struct rac_stt_result { + /** Full transcribed text (owned, must be freed with rac_free) */ + char* text; + + /** Detected language code (can be NULL) */ + char* detected_language; + + /** Word-level timestamps (can be NULL) */ + rac_stt_word_t* words; + size_t num_words; + + /** Overall confidence score (0.0 to 1.0) */ + float confidence; + + /** Processing time in milliseconds */ + int64_t processing_time_ms; +} rac_stt_result_t; + +// ============================================================================= +// INFO - Mirrors Swift's STTService properties +// ============================================================================= + +/** + * @brief STT service info + */ +typedef struct rac_stt_info { + /** Whether the service is ready */ + rac_bool_t is_ready; + + /** Current model identifier (can be NULL) */ + const char* current_model; + + /** Whether streaming is supported */ + rac_bool_t supports_streaming; +} rac_stt_info_t; + +// ============================================================================= +// CALLBACKS +// ============================================================================= + +/** + * @brief STT streaming callback + * + * Called for partial transcription results during streaming. + * + * @param partial_text Partial transcription text + * @param is_final Whether this is a final result + * @param user_data User-provided context + */ +typedef void (*rac_stt_stream_callback_t)(const char* partial_text, rac_bool_t is_final, + void* user_data); + +// ============================================================================= +// INPUT - Mirrors Swift's STTInput +// ============================================================================= + +/** + * @brief STT input data + * + * Mirrors Swift's STTInput struct. + * See: Sources/RunAnywhere/Features/STT/Models/STTInput.swift + */ +typedef struct rac_stt_input { + /** Audio data bytes (raw audio data) */ + const uint8_t* audio_data; + size_t audio_data_size; + + /** Alternative: audio buffer (PCM float samples) */ + const float* audio_samples; + size_t num_samples; + + /** Audio format of input data */ + rac_audio_format_enum_t format; + + /** Language code override (can be NULL to use config default) */ + const char* language; + + /** Sample rate of the audio (default: 16000) */ + int32_t sample_rate; + + /** Custom options override (can be NULL) */ + const rac_stt_options_t* options; +} rac_stt_input_t; + +/** + * @brief Default STT input + */ +static const rac_stt_input_t RAC_STT_INPUT_DEFAULT = {.audio_data = RAC_NULL, + .audio_data_size = 0, + .audio_samples = RAC_NULL, + .num_samples = 0, + .format = RAC_AUDIO_FORMAT_PCM, + .language = RAC_NULL, + .sample_rate = RAC_STT_DEFAULT_SAMPLE_RATE, + .options = RAC_NULL}; + +// ============================================================================= +// TRANSCRIPTION METADATA - Mirrors Swift's TranscriptionMetadata +// ============================================================================= + +/** + * @brief Transcription metadata + * + * Mirrors Swift's TranscriptionMetadata struct. + * See: Sources/RunAnywhere/Features/STT/Models/STTOutput.swift + */ +typedef struct rac_transcription_metadata { + /** Model ID used for transcription */ + const char* model_id; + + /** Processing time in milliseconds */ + int64_t processing_time_ms; + + /** Audio length in milliseconds */ + int64_t audio_length_ms; + + /** Real-time factor (processing_time / audio_length) */ + float real_time_factor; +} rac_transcription_metadata_t; + +// ============================================================================= +// TRANSCRIPTION ALTERNATIVE - Mirrors Swift's TranscriptionAlternative +// ============================================================================= + +/** + * @brief Alternative transcription + * + * Mirrors Swift's TranscriptionAlternative struct. + */ +typedef struct rac_transcription_alternative { + /** Alternative transcription text */ + const char* text; + + /** Confidence score (0.0 to 1.0) */ + float confidence; +} rac_transcription_alternative_t; + +// ============================================================================= +// OUTPUT - Mirrors Swift's STTOutput +// ============================================================================= + +/** + * @brief STT output data + * + * Mirrors Swift's STTOutput struct. + * See: Sources/RunAnywhere/Features/STT/Models/STTOutput.swift + */ +typedef struct rac_stt_output { + /** Transcribed text (owned, must be freed with rac_free) */ + char* text; + + /** Confidence score (0.0 to 1.0) */ + float confidence; + + /** Word-level timestamps (can be NULL) */ + rac_stt_word_t* word_timestamps; + size_t num_word_timestamps; + + /** Detected language if auto-detected (can be NULL) */ + char* detected_language; + + /** Alternative transcriptions (can be NULL) */ + rac_transcription_alternative_t* alternatives; + size_t num_alternatives; + + /** Processing metadata */ + rac_transcription_metadata_t metadata; + + /** Timestamp in milliseconds since epoch */ + int64_t timestamp_ms; +} rac_stt_output_t; + +// ============================================================================= +// TRANSCRIPTION RESULT - Alias for compatibility +// ============================================================================= + +/** + * @brief STT transcription result (alias for rac_stt_output_t) + * + * For compatibility with existing code that uses "result" terminology. + */ +typedef rac_stt_output_t rac_stt_transcription_result_t; + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_STT_TYPES_H */ diff --git a/sdk/runanywhere-swift/Sources/ONNXRuntime/include/rac_tts.h b/sdk/runanywhere-swift/Sources/ONNXRuntime/include/rac_tts.h new file mode 100644 index 000000000..75c0eb12d --- /dev/null +++ b/sdk/runanywhere-swift/Sources/ONNXRuntime/include/rac_tts.h @@ -0,0 +1,17 @@ +/** + * @file rac_tts.h + * @brief RunAnywhere Commons - TTS API (Convenience Header) + * + * This header includes both types and service interface for convenience. + * For better separation of concerns, prefer including: + * - rac_tts_types.h for data structures only + * - rac_tts_service.h for the service interface + */ + +#ifndef RAC_TTS_H +#define RAC_TTS_H + +#include "rac_tts_service.h" +#include "rac_tts_types.h" + +#endif /* RAC_TTS_H */ diff --git a/sdk/runanywhere-swift/Sources/ONNXRuntime/include/rac_tts_onnx.h b/sdk/runanywhere-swift/Sources/ONNXRuntime/include/rac_tts_onnx.h new file mode 100644 index 000000000..603f709e7 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/ONNXRuntime/include/rac_tts_onnx.h @@ -0,0 +1,120 @@ +/** + * @file rac_tts_onnx.h + * @brief RunAnywhere Commons - ONNX Backend for TTS + * + * C wrapper around runanywhere-core's ONNX TTS backend. + * Mirrors Swift's ONNXTTSService implementation. + * + * See: Sources/ONNXRuntime/ONNXTTSService.swift + */ + +#ifndef RAC_TTS_ONNX_H +#define RAC_TTS_ONNX_H + +#include "rac_error.h" +#include "rac_types.h" +#include "rac_tts.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// EXPORT MACRO +// ============================================================================= + +#if defined(RAC_ONNX_BUILDING) +#if defined(_WIN32) +#define RAC_ONNX_API __declspec(dllexport) +#elif defined(__GNUC__) || defined(__clang__) +#define RAC_ONNX_API __attribute__((visibility("default"))) +#else +#define RAC_ONNX_API +#endif +#else +#define RAC_ONNX_API +#endif + +// ============================================================================= +// CONFIGURATION +// ============================================================================= + +/** + * ONNX TTS configuration. + */ +typedef struct rac_tts_onnx_config { + /** Number of threads (0 = auto) */ + int32_t num_threads; + + /** Enable CoreML on Apple platforms */ + rac_bool_t use_coreml; + + /** Default sample rate */ + int32_t sample_rate; +} rac_tts_onnx_config_t; + +/** + * Default ONNX TTS configuration. + */ +static const rac_tts_onnx_config_t RAC_TTS_ONNX_CONFIG_DEFAULT = { + .num_threads = 0, .use_coreml = RAC_TRUE, .sample_rate = 22050}; + +// ============================================================================= +// ONNX TTS API +// ============================================================================= + +/** + * Creates an ONNX TTS service. + * + * @param model_path Path to the model directory or file + * @param config ONNX-specific configuration (can be NULL for defaults) + * @param out_handle Output: Handle to the created service + * @return RAC_SUCCESS or error code + */ +RAC_ONNX_API rac_result_t rac_tts_onnx_create(const char* model_path, + const rac_tts_onnx_config_t* config, + rac_handle_t* out_handle); + +/** + * Synthesizes text to audio. + * + * @param handle Service handle + * @param text Text to synthesize + * @param options TTS options (can be NULL for defaults) + * @param out_result Output: Synthesis result + * @return RAC_SUCCESS or error code + */ +RAC_ONNX_API rac_result_t rac_tts_onnx_synthesize(rac_handle_t handle, const char* text, + const rac_tts_options_t* options, + rac_tts_result_t* out_result); + +/** + * Gets available voices. + * + * @param handle Service handle + * @param out_voices Output: Array of voice names (caller must free) + * @param out_count Output: Number of voices + * @return RAC_SUCCESS or error code + */ +RAC_ONNX_API rac_result_t rac_tts_onnx_get_voices(rac_handle_t handle, char*** out_voices, + size_t* out_count); + +/** + * Stops ongoing synthesis. + * + * @param handle Service handle + */ +RAC_ONNX_API void rac_tts_onnx_stop(rac_handle_t handle); + +/** + * Destroys an ONNX TTS service. + * + * @param handle Service handle to destroy + */ +RAC_ONNX_API void rac_tts_onnx_destroy(rac_handle_t handle); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_TTS_ONNX_H */ diff --git a/sdk/runanywhere-swift/Sources/ONNXRuntime/include/rac_tts_types.h b/sdk/runanywhere-swift/Sources/ONNXRuntime/include/rac_tts_types.h new file mode 100644 index 000000000..f1de70db2 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/ONNXRuntime/include/rac_tts_types.h @@ -0,0 +1,352 @@ +/** + * @file rac_tts_types.h + * @brief RunAnywhere Commons - TTS Types and Data Structures + * + * C port of Swift's TTS Models from: + * Sources/RunAnywhere/Features/TTS/Models/TTSConfiguration.swift + * Sources/RunAnywhere/Features/TTS/Models/TTSOptions.swift + * Sources/RunAnywhere/Features/TTS/Models/TTSInput.swift + * Sources/RunAnywhere/Features/TTS/Models/TTSOutput.swift + * + * This header defines data structures only. For the service interface, + * see rac_tts_service.h. + */ + +#ifndef RAC_TTS_TYPES_H +#define RAC_TTS_TYPES_H + +#include "rac_types.h" +#include "rac_stt_types.h" // For rac_audio_format_enum_t + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// CONSTANTS - Mirrors Swift's TTSConstants +// ============================================================================= + +/** Default sample rate for TTS (22050 Hz) */ +#define RAC_TTS_DEFAULT_SAMPLE_RATE 22050 + +/** CD quality sample rate (44100 Hz) */ +#define RAC_TTS_CD_QUALITY_SAMPLE_RATE 44100 + +// ============================================================================= +// CONFIGURATION - Mirrors Swift's TTSConfiguration +// ============================================================================= + +/** + * @brief TTS component configuration + * + * Mirrors Swift's TTSConfiguration struct exactly. + * See: Sources/RunAnywhere/Features/TTS/Models/TTSConfiguration.swift + */ +typedef struct rac_tts_config { + /** Model ID (voice identifier for TTS, optional) */ + const char* model_id; + + /** Preferred framework (use -1 for auto) */ + int32_t preferred_framework; + + /** Voice identifier to use for synthesis */ + const char* voice; + + /** Language for synthesis (BCP-47 format, e.g., "en-US") */ + const char* language; + + /** Speaking rate (0.5 to 2.0, 1.0 is normal) */ + float speaking_rate; + + /** Speech pitch (0.5 to 2.0, 1.0 is normal) */ + float pitch; + + /** Speech volume (0.0 to 1.0) */ + float volume; + + /** Audio format for output */ + rac_audio_format_enum_t audio_format; + + /** Whether to use neural/premium voice if available */ + rac_bool_t use_neural_voice; + + /** Whether to enable SSML markup support */ + rac_bool_t enable_ssml; +} rac_tts_config_t; + +/** + * @brief Default TTS configuration + */ +static const rac_tts_config_t RAC_TTS_CONFIG_DEFAULT = {.model_id = RAC_NULL, + .preferred_framework = -1, + .voice = RAC_NULL, + .language = "en-US", + .speaking_rate = 1.0f, + .pitch = 1.0f, + .volume = 1.0f, + .audio_format = RAC_AUDIO_FORMAT_PCM, + .use_neural_voice = RAC_TRUE, + .enable_ssml = RAC_FALSE}; + +// ============================================================================= +// OPTIONS - Mirrors Swift's TTSOptions +// ============================================================================= + +/** + * @brief TTS synthesis options + * + * Mirrors Swift's TTSOptions struct exactly. + * See: Sources/RunAnywhere/Features/TTS/Models/TTSOptions.swift + */ +typedef struct rac_tts_options { + /** Voice to use for synthesis (can be NULL for default) */ + const char* voice; + + /** Language for synthesis (BCP-47 format, e.g., "en-US") */ + const char* language; + + /** Speech rate (0.0 to 2.0, 1.0 is normal) */ + float rate; + + /** Speech pitch (0.0 to 2.0, 1.0 is normal) */ + float pitch; + + /** Speech volume (0.0 to 1.0) */ + float volume; + + /** Audio format for output */ + rac_audio_format_enum_t audio_format; + + /** Sample rate for output audio in Hz */ + int32_t sample_rate; + + /** Whether to use SSML markup */ + rac_bool_t use_ssml; +} rac_tts_options_t; + +/** + * @brief Default TTS options + */ +static const rac_tts_options_t RAC_TTS_OPTIONS_DEFAULT = {.voice = RAC_NULL, + .language = "en-US", + .rate = 1.0f, + .pitch = 1.0f, + .volume = 1.0f, + .audio_format = RAC_AUDIO_FORMAT_PCM, + .sample_rate = + RAC_TTS_DEFAULT_SAMPLE_RATE, + .use_ssml = RAC_FALSE}; + +// ============================================================================= +// INPUT - Mirrors Swift's TTSInput +// ============================================================================= + +/** + * @brief TTS input data + * + * Mirrors Swift's TTSInput struct exactly. + * See: Sources/RunAnywhere/Features/TTS/Models/TTSInput.swift + */ +typedef struct rac_tts_input { + /** Text to synthesize */ + const char* text; + + /** Optional SSML markup (overrides text if provided, can be NULL) */ + const char* ssml; + + /** Voice ID override (can be NULL) */ + const char* voice_id; + + /** Language override (can be NULL) */ + const char* language; + + /** Custom options override (can be NULL) */ + const rac_tts_options_t* options; +} rac_tts_input_t; + +/** + * @brief Default TTS input + */ +static const rac_tts_input_t RAC_TTS_INPUT_DEFAULT = {.text = RAC_NULL, + .ssml = RAC_NULL, + .voice_id = RAC_NULL, + .language = RAC_NULL, + .options = RAC_NULL}; + +// ============================================================================= +// RESULT - Mirrors Swift's TTS result +// ============================================================================= + +/** + * @brief TTS synthesis result + */ +typedef struct rac_tts_result { + /** Audio data (owned, must be freed with rac_free) */ + void* audio_data; + + /** Size of audio data in bytes */ + size_t audio_size; + + /** Audio format */ + rac_audio_format_enum_t audio_format; + + /** Sample rate */ + int32_t sample_rate; + + /** Duration in milliseconds */ + int64_t duration_ms; + + /** Processing time in milliseconds */ + int64_t processing_time_ms; +} rac_tts_result_t; + +// ============================================================================= +// INFO - Mirrors Swift's TTSService properties +// ============================================================================= + +/** + * @brief TTS service info + */ +typedef struct rac_tts_info { + /** Whether the service is ready */ + rac_bool_t is_ready; + + /** Whether currently synthesizing */ + rac_bool_t is_synthesizing; + + /** Available voices (null-terminated array) */ + const char* const* available_voices; + size_t num_voices; +} rac_tts_info_t; + +// ============================================================================= +// CALLBACKS +// ============================================================================= + +/** + * @brief TTS streaming callback + * + * Called for each audio chunk during streaming synthesis. + * + * @param audio_data Audio chunk data + * @param audio_size Size of audio chunk + * @param user_data User-provided context + */ +typedef void (*rac_tts_stream_callback_t)(const void* audio_data, size_t audio_size, + void* user_data); + +// ============================================================================= +// PHONEME TIMESTAMP - Mirrors Swift's TTSPhonemeTimestamp +// ============================================================================= + +/** + * @brief Phoneme timestamp information + * + * Mirrors Swift's TTSPhonemeTimestamp struct. + * See: Sources/RunAnywhere/Features/TTS/Models/TTSOutput.swift + */ +typedef struct rac_tts_phoneme_timestamp { + /** The phoneme */ + const char* phoneme; + + /** Start time in milliseconds */ + int64_t start_time_ms; + + /** End time in milliseconds */ + int64_t end_time_ms; +} rac_tts_phoneme_timestamp_t; + +// ============================================================================= +// SYNTHESIS METADATA - Mirrors Swift's TTSSynthesisMetadata +// ============================================================================= + +/** + * @brief Synthesis metadata + * + * Mirrors Swift's TTSSynthesisMetadata struct. + * See: Sources/RunAnywhere/Features/TTS/Models/TTSOutput.swift + */ +typedef struct rac_tts_synthesis_metadata { + /** Voice used for synthesis */ + const char* voice; + + /** Language used for synthesis */ + const char* language; + + /** Processing time in milliseconds */ + int64_t processing_time_ms; + + /** Number of characters synthesized */ + int32_t character_count; + + /** Characters processed per second */ + float characters_per_second; +} rac_tts_synthesis_metadata_t; + +// ============================================================================= +// OUTPUT - Mirrors Swift's TTSOutput +// ============================================================================= + +/** + * @brief TTS output data + * + * Mirrors Swift's TTSOutput struct exactly. + * See: Sources/RunAnywhere/Features/TTS/Models/TTSOutput.swift + */ +typedef struct rac_tts_output { + /** Synthesized audio data (owned, must be freed with rac_free) */ + void* audio_data; + + /** Size of audio data in bytes */ + size_t audio_size; + + /** Audio format of the output */ + rac_audio_format_enum_t format; + + /** Duration of the audio in milliseconds */ + int64_t duration_ms; + + /** Phoneme timestamps if available (can be NULL) */ + rac_tts_phoneme_timestamp_t* phoneme_timestamps; + size_t num_phoneme_timestamps; + + /** Processing metadata */ + rac_tts_synthesis_metadata_t metadata; + + /** Timestamp in milliseconds since epoch */ + int64_t timestamp_ms; +} rac_tts_output_t; + +// ============================================================================= +// SPEAK RESULT - Mirrors Swift's TTSSpeakResult +// ============================================================================= + +/** + * @brief Speak result (metadata only, no audio data) + * + * Mirrors Swift's TTSSpeakResult struct. + * The SDK handles audio playback internally when using speak(). + * See: Sources/RunAnywhere/Features/TTS/Models/TTSOutput.swift + */ +typedef struct rac_tts_speak_result { + /** Duration of the spoken audio in milliseconds */ + int64_t duration_ms; + + /** Audio format used */ + rac_audio_format_enum_t format; + + /** Audio size in bytes (0 for system TTS which plays directly) */ + size_t audio_size_bytes; + + /** Synthesis metadata */ + rac_tts_synthesis_metadata_t metadata; + + /** Timestamp when speech completed (milliseconds since epoch) */ + int64_t timestamp_ms; +} rac_tts_speak_result_t; + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_TTS_TYPES_H */ diff --git a/sdk/runanywhere-swift/Sources/ONNXRuntime/include/rac_types.h b/sdk/runanywhere-swift/Sources/ONNXRuntime/include/rac_types.h new file mode 100644 index 000000000..dc888e53b --- /dev/null +++ b/sdk/runanywhere-swift/Sources/ONNXRuntime/include/rac_types.h @@ -0,0 +1,255 @@ +/** + * @file rac_types.h + * @brief RunAnywhere Commons - Common Types and Definitions + * + * This header defines common types, handle types, and macros used throughout + * the runanywhere-commons library. All types use the RAC_ prefix to distinguish + * from the underlying runanywhere-core (ra_*) types. + */ + +#ifndef RAC_TYPES_H +#define RAC_TYPES_H + +#include +#include + +/** + * Null pointer macro for use in static initializers. + * Uses nullptr in C++ (preferred by clang-tidy modernize-use-nullptr) + * and NULL in C for compatibility. + */ +#ifdef __cplusplus +#define RAC_NULL nullptr +#else +#define RAC_NULL NULL +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// API VISIBILITY MACROS +// ============================================================================= + +#if defined(RAC_BUILDING_SHARED) +#if defined(_WIN32) +#define RAC_API __declspec(dllexport) +#elif defined(__GNUC__) || defined(__clang__) +#define RAC_API __attribute__((visibility("default"))) +#else +#define RAC_API +#endif +#elif defined(RAC_USING_SHARED) +#if defined(_WIN32) +#define RAC_API __declspec(dllimport) +#else +#define RAC_API +#endif +#else +#define RAC_API +#endif + +// ============================================================================= +// RESULT TYPE +// ============================================================================= + +/** + * Result type for all RAC functions. + * - 0 indicates success + * - Negative values indicate errors (see rac_error.h) + * + * Error code ranges: + * - runanywhere-core (ra_*): 0 to -99 + * - runanywhere-commons (rac_*): -100 to -999 + */ +typedef int32_t rac_result_t; + +/** Success result */ +#define RAC_SUCCESS ((rac_result_t)0) + +// ============================================================================= +// BOOLEAN TYPE +// ============================================================================= + +/** Boolean type for C compatibility */ +typedef int32_t rac_bool_t; + +#define RAC_TRUE ((rac_bool_t)1) +#define RAC_FALSE ((rac_bool_t)0) + +// ============================================================================= +// HANDLE TYPES +// ============================================================================= + +/** + * Opaque handle for internal objects. + * Handles should be treated as opaque pointers. + */ +typedef void* rac_handle_t; + +/** Invalid handle value */ +#define RAC_INVALID_HANDLE ((rac_handle_t)NULL) + +// ============================================================================= +// STRING TYPES +// ============================================================================= + +/** + * String view (non-owning reference to a string). + * The string is NOT guaranteed to be null-terminated. + */ +typedef struct rac_string_view { + const char* data; /**< Pointer to string data */ + size_t length; /**< Length in bytes (not including any null terminator) */ +} rac_string_view_t; + +/** + * Creates a string view from a null-terminated C string. + */ +#define RAC_STRING_VIEW(s) ((rac_string_view_t){(s), (s) ? strlen(s) : 0}) + +// ============================================================================= +// AUDIO TYPES +// ============================================================================= + +/** + * Audio buffer for STT/VAD operations. + * Contains PCM float samples in the range [-1.0, 1.0]. + */ +typedef struct rac_audio_buffer { + const float* samples; /**< PCM float samples */ + size_t num_samples; /**< Number of samples */ + int32_t sample_rate; /**< Sample rate in Hz (e.g., 16000) */ + int32_t channels; /**< Number of channels (1 = mono, 2 = stereo) */ +} rac_audio_buffer_t; + +/** + * Audio format specification. + */ +typedef struct rac_audio_format { + int32_t sample_rate; /**< Sample rate in Hz */ + int32_t channels; /**< Number of channels */ + int32_t bits_per_sample; /**< Bits per sample (16 or 32) */ +} rac_audio_format_t; + +// ============================================================================= +// MEMORY INFO +// ============================================================================= + +/** + * Memory information structure. + * Used by the platform adapter to report available memory. + */ +typedef struct rac_memory_info { + uint64_t total_bytes; /**< Total physical memory in bytes */ + uint64_t available_bytes; /**< Available memory in bytes */ + uint64_t used_bytes; /**< Used memory in bytes */ +} rac_memory_info_t; + +// ============================================================================= +// CAPABILITY TYPES +// ============================================================================= + +/** + * Capability types supported by backends. + * These match the capabilities defined in runanywhere-core. + */ +typedef enum rac_capability { + RAC_CAPABILITY_UNKNOWN = 0, + RAC_CAPABILITY_TEXT_GENERATION = 1, /**< LLM text generation */ + RAC_CAPABILITY_EMBEDDINGS = 2, /**< Text embeddings */ + RAC_CAPABILITY_STT = 3, /**< Speech-to-text */ + RAC_CAPABILITY_TTS = 4, /**< Text-to-speech */ + RAC_CAPABILITY_VAD = 5, /**< Voice activity detection */ + RAC_CAPABILITY_DIARIZATION = 6, /**< Speaker diarization */ +} rac_capability_t; + +/** + * Device type for backend execution. + */ +typedef enum rac_device { + RAC_DEVICE_CPU = 0, + RAC_DEVICE_GPU = 1, + RAC_DEVICE_NPU = 2, + RAC_DEVICE_AUTO = 3, +} rac_device_t; + +// ============================================================================= +// LOG LEVELS +// ============================================================================= + +/** + * Log level for the logging callback. + */ +typedef enum rac_log_level { + RAC_LOG_TRACE = 0, + RAC_LOG_DEBUG = 1, + RAC_LOG_INFO = 2, + RAC_LOG_WARNING = 3, + RAC_LOG_ERROR = 4, + RAC_LOG_FATAL = 5, +} rac_log_level_t; + +// ============================================================================= +// VERSION INFO +// ============================================================================= + +/** + * Version information structure. + */ +typedef struct rac_version { + uint16_t major; + uint16_t minor; + uint16_t patch; + const char* string; /**< Version string (e.g., "1.0.0") */ +} rac_version_t; + +// ============================================================================= +// UTILITY MACROS +// ============================================================================= + +/** Check if a result is a success */ +#define RAC_SUCCEEDED(result) ((result) >= 0) + +/** Check if a result is an error */ +#define RAC_FAILED(result) ((result) < 0) + +/** Check if a handle is valid */ +#define RAC_IS_VALID_HANDLE(handle) ((handle) != RAC_INVALID_HANDLE) + +// ============================================================================= +// MEMORY MANAGEMENT +// ============================================================================= + +/** + * Frees memory allocated by RAC functions. + * + * Use this to free strings and buffers returned by RAC functions that + * are marked as "must be freed with rac_free". + * + * @param ptr Pointer to memory to free (can be NULL) + */ +RAC_API void rac_free(void* ptr); + +/** + * Allocates memory using the RAC allocator. + * + * @param size Number of bytes to allocate + * @return Pointer to allocated memory, or NULL on failure + */ +RAC_API void* rac_alloc(size_t size); + +/** + * Duplicates a null-terminated string. + * + * @param str String to duplicate (can be NULL) + * @return Duplicated string (must be freed with rac_free), or NULL if str is NULL + */ +RAC_API char* rac_strdup(const char* str); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_TYPES_H */ diff --git a/sdk/runanywhere-swift/Sources/ONNXRuntime/include/rac_vad.h b/sdk/runanywhere-swift/Sources/ONNXRuntime/include/rac_vad.h new file mode 100644 index 000000000..10e8be953 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/ONNXRuntime/include/rac_vad.h @@ -0,0 +1,17 @@ +/** + * @file rac_vad.h + * @brief RunAnywhere Commons - VAD API (Convenience Header) + * + * This header includes both types and service interface for convenience. + * For better separation of concerns, prefer including: + * - rac_vad_types.h for data structures only + * - rac_vad_service.h for the service interface + */ + +#ifndef RAC_VAD_H +#define RAC_VAD_H + +#include "rac_vad_service.h" +#include "rac_vad_types.h" + +#endif /* RAC_VAD_H */ diff --git a/sdk/runanywhere-swift/Sources/ONNXRuntime/include/rac_vad_onnx.h b/sdk/runanywhere-swift/Sources/ONNXRuntime/include/rac_vad_onnx.h new file mode 100644 index 000000000..cf336c10b --- /dev/null +++ b/sdk/runanywhere-swift/Sources/ONNXRuntime/include/rac_vad_onnx.h @@ -0,0 +1,166 @@ +/** + * @file rac_vad_onnx.h + * @brief RunAnywhere Commons - ONNX Backend for VAD + * + * C wrapper around runanywhere-core's ONNX VAD backend. + * Mirrors Swift's VADService implementation pattern. + */ + +#ifndef RAC_VAD_ONNX_H +#define RAC_VAD_ONNX_H + +#include "rac_error.h" +#include "rac_types.h" +#include "rac_vad.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// EXPORT MACRO +// ============================================================================= + +#if defined(RAC_ONNX_BUILDING) +#if defined(_WIN32) +#define RAC_ONNX_API __declspec(dllexport) +#elif defined(__GNUC__) || defined(__clang__) +#define RAC_ONNX_API __attribute__((visibility("default"))) +#else +#define RAC_ONNX_API +#endif +#else +#define RAC_ONNX_API +#endif + +// ============================================================================= +// CONFIGURATION +// ============================================================================= + +/** + * ONNX VAD configuration. + */ +typedef struct rac_vad_onnx_config { + /** Sample rate (default: 16000) */ + int32_t sample_rate; + + /** Energy threshold for detection (0.0 to 1.0) */ + float energy_threshold; + + /** Frame length in seconds (default: 0.032 = 32ms) */ + float frame_length; + + /** Number of threads (0 = auto) */ + int32_t num_threads; +} rac_vad_onnx_config_t; + +/** + * Default ONNX VAD configuration. + */ +static const rac_vad_onnx_config_t RAC_VAD_ONNX_CONFIG_DEFAULT = { + .sample_rate = 16000, .energy_threshold = 0.5f, .frame_length = 0.032f, .num_threads = 0}; + +// ============================================================================= +// ONNX VAD API +// ============================================================================= + +/** + * Creates an ONNX VAD service. + * + * @param model_path Path to the VAD model (can be NULL for built-in) + * @param config ONNX-specific configuration (can be NULL for defaults) + * @param out_handle Output: Handle to the created service + * @return RAC_SUCCESS or error code + */ +RAC_ONNX_API rac_result_t rac_vad_onnx_create(const char* model_path, + const rac_vad_onnx_config_t* config, + rac_handle_t* out_handle); + +/** + * Processes audio samples for voice activity. + * + * @param handle Service handle + * @param samples Float32 PCM samples + * @param num_samples Number of samples + * @param out_is_speech Output: Whether speech was detected + * @return RAC_SUCCESS or error code + */ +RAC_ONNX_API rac_result_t rac_vad_onnx_process(rac_handle_t handle, const float* samples, + size_t num_samples, rac_bool_t* out_is_speech); + +/** + * Starts continuous VAD processing. + * + * @param handle Service handle + * @return RAC_SUCCESS or error code + */ +RAC_ONNX_API rac_result_t rac_vad_onnx_start(rac_handle_t handle); + +/** + * Stops continuous VAD processing. + * + * @param handle Service handle + * @return RAC_SUCCESS or error code + */ +RAC_ONNX_API rac_result_t rac_vad_onnx_stop(rac_handle_t handle); + +/** + * Resets VAD state. + * + * @param handle Service handle + * @return RAC_SUCCESS or error code + */ +RAC_ONNX_API rac_result_t rac_vad_onnx_reset(rac_handle_t handle); + +/** + * Sets the energy threshold. + * + * @param handle Service handle + * @param threshold New threshold (0.0 to 1.0) + * @return RAC_SUCCESS or error code + */ +RAC_ONNX_API rac_result_t rac_vad_onnx_set_threshold(rac_handle_t handle, float threshold); + +/** + * Checks if speech is currently active. + * + * @param handle Service handle + * @return RAC_TRUE if speech is active + */ +RAC_ONNX_API rac_bool_t rac_vad_onnx_is_speech_active(rac_handle_t handle); + +/** + * Destroys an ONNX VAD service. + * + * @param handle Service handle to destroy + */ +RAC_ONNX_API void rac_vad_onnx_destroy(rac_handle_t handle); + +// ============================================================================= +// BACKEND REGISTRATION +// ============================================================================= + +/** + * Registers the ONNX backend with the commons module and service registries. + * + * Should be called once during SDK initialization. + * This registers: + * - Module: "onnx" with STT, TTS, VAD capabilities + * - Service providers: ONNX STT, TTS, VAD providers + * + * @return RAC_SUCCESS or error code + */ +RAC_ONNX_API rac_result_t rac_backend_onnx_register(void); + +/** + * Unregisters the ONNX backend. + * + * @return RAC_SUCCESS or error code + */ +RAC_ONNX_API rac_result_t rac_backend_onnx_unregister(void); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_VAD_ONNX_H */ diff --git a/sdk/runanywhere-swift/Sources/ONNXRuntime/include/rac_vad_types.h b/sdk/runanywhere-swift/Sources/ONNXRuntime/include/rac_vad_types.h new file mode 100644 index 000000000..15382108f --- /dev/null +++ b/sdk/runanywhere-swift/Sources/ONNXRuntime/include/rac_vad_types.h @@ -0,0 +1,231 @@ +/** + * @file rac_vad_types.h + * @brief RunAnywhere Commons - VAD Types and Data Structures + * + * C port of Swift's VAD Models from: + * Sources/RunAnywhere/Features/VAD/Models/VADConfiguration.swift + * Sources/RunAnywhere/Features/VAD/Models/VADInput.swift + * Sources/RunAnywhere/Features/VAD/Models/VADOutput.swift + * Sources/RunAnywhere/Features/VAD/VADConstants.swift + * + * This header defines data structures only. For the service interface, + * see rac_vad_service.h. + */ + +#ifndef RAC_VAD_TYPES_H +#define RAC_VAD_TYPES_H + +#include "rac_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// CONSTANTS - Mirrors Swift's VADConstants +// ============================================================================= + +/** Default sample rate for VAD processing (16kHz) */ +#define RAC_VAD_DEFAULT_SAMPLE_RATE 16000 + +/** Default energy threshold for voice detection */ +#define RAC_VAD_DEFAULT_ENERGY_THRESHOLD 0.015f + +/** Default frame length in seconds */ +#define RAC_VAD_DEFAULT_FRAME_LENGTH 0.1f + +/** Default calibration multiplier */ +#define RAC_VAD_DEFAULT_CALIBRATION_MULTIPLIER 2.0f + +// ============================================================================= +// CONFIGURATION - Mirrors Swift's VADConfiguration +// ============================================================================= + +/** + * @brief VAD component configuration + * + * Mirrors Swift's VADConfiguration struct exactly. + * See: Sources/RunAnywhere/Features/VAD/Models/VADConfiguration.swift + */ +typedef struct rac_vad_config { + /** Model ID (not used for VAD, can be NULL) */ + const char* model_id; + + /** Preferred framework (use -1 for auto) */ + int32_t preferred_framework; + + /** Energy threshold for voice detection (0.0 to 1.0) */ + float energy_threshold; + + /** Sample rate in Hz (default: 16000) */ + int32_t sample_rate; + + /** Frame length in seconds (default: 0.1 = 100ms) */ + float frame_length; + + /** Enable automatic calibration */ + rac_bool_t enable_auto_calibration; + + /** Calibration multiplier (threshold = ambient noise * multiplier) */ + float calibration_multiplier; +} rac_vad_config_t; + +/** + * @brief Default VAD configuration + */ +static const rac_vad_config_t RAC_VAD_CONFIG_DEFAULT = { + .model_id = RAC_NULL, + .preferred_framework = -1, + .energy_threshold = RAC_VAD_DEFAULT_ENERGY_THRESHOLD, + .sample_rate = RAC_VAD_DEFAULT_SAMPLE_RATE, + .frame_length = RAC_VAD_DEFAULT_FRAME_LENGTH, + .enable_auto_calibration = RAC_FALSE, + .calibration_multiplier = RAC_VAD_DEFAULT_CALIBRATION_MULTIPLIER}; + +// ============================================================================= +// SPEECH ACTIVITY - Mirrors Swift's SpeechActivityEvent +// ============================================================================= + +/** + * @brief Speech activity event type + * + * Mirrors Swift's SpeechActivityEvent. + */ +typedef enum rac_speech_activity { + RAC_SPEECH_STARTED = 0, + RAC_SPEECH_ENDED = 1, + RAC_SPEECH_ONGOING = 2 +} rac_speech_activity_t; + +// ============================================================================= +// INPUT - Mirrors Swift's VADInput +// ============================================================================= + +/** + * @brief VAD input data + * + * Mirrors Swift's VADInput struct exactly. + * See: Sources/RunAnywhere/Features/VAD/Models/VADInput.swift + */ +typedef struct rac_vad_input { + /** Audio samples as float array (PCM float samples in range [-1.0, 1.0]) */ + const float* audio_samples; + size_t num_samples; + + /** Optional override for energy threshold (use -1 for no override) */ + float energy_threshold_override; +} rac_vad_input_t; + +/** + * @brief Default VAD input + */ +static const rac_vad_input_t RAC_VAD_INPUT_DEFAULT = { + .audio_samples = RAC_NULL, + .num_samples = 0, + .energy_threshold_override = -1.0f /* No override */ +}; + +// ============================================================================= +// OUTPUT - Mirrors Swift's VADOutput +// ============================================================================= + +/** + * @brief VAD output data + * + * Mirrors Swift's VADOutput struct exactly. + * See: Sources/RunAnywhere/Features/VAD/Models/VADOutput.swift + */ +typedef struct rac_vad_output { + /** Whether speech is detected in the current frame */ + rac_bool_t is_speech_detected; + + /** Current audio energy level (RMS value) */ + float energy_level; + + /** Timestamp in milliseconds since epoch */ + int64_t timestamp_ms; +} rac_vad_output_t; + +// ============================================================================= +// INFO - Mirrors Swift's VADService properties +// ============================================================================= + +/** + * @brief VAD service info + * + * Mirrors Swift's VADService properties. + */ +typedef struct rac_vad_info { + /** Whether speech is currently active (isSpeechActive) */ + rac_bool_t is_speech_active; + + /** Energy threshold for voice detection (energyThreshold) */ + float energy_threshold; + + /** Sample rate of the audio in Hz (sampleRate) */ + int32_t sample_rate; + + /** Frame length in seconds (frameLength) */ + float frame_length; +} rac_vad_info_t; + +// ============================================================================= +// STATISTICS - Mirrors Swift's VADStatistics +// ============================================================================= + +/** + * @brief VAD statistics + * + * Mirrors Swift's VADStatistics struct from SimpleEnergyVADService. + */ +typedef struct rac_vad_statistics { + /** Current calibrated threshold */ + float current_threshold; + + /** Ambient noise level */ + float ambient_noise_level; + + /** Total speech segments detected */ + int32_t total_speech_segments; + + /** Total duration of speech in milliseconds */ + int64_t total_speech_duration_ms; + + /** Average energy level */ + float average_energy; + + /** Peak energy level */ + float peak_energy; +} rac_vad_statistics_t; + +// ============================================================================= +// CALLBACKS +// ============================================================================= + +/** + * @brief Speech activity callback + * + * Mirrors Swift's VADService.onSpeechActivity callback. + * + * @param activity The speech activity event + * @param user_data User-provided context + */ +typedef void (*rac_vad_activity_callback_fn)(rac_speech_activity_t activity, void* user_data); + +/** + * @brief Audio buffer callback + * + * Mirrors Swift's VADService.onAudioBuffer callback. + * + * @param audio_data Audio data buffer (PCM float samples) + * @param num_samples Number of samples + * @param user_data User-provided context + */ +typedef void (*rac_vad_audio_callback_fn)(const float* audio_data, size_t num_samples, + void* user_data); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_VAD_TYPES_H */ diff --git a/sdk/runanywhere-swift/Sources/ONNXRuntime/include/shim.c b/sdk/runanywhere-swift/Sources/ONNXRuntime/include/shim.c new file mode 100644 index 000000000..80be8a4b7 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/ONNXRuntime/include/shim.c @@ -0,0 +1,2 @@ +// Shim file to satisfy SPM's requirement for at least one source file in C targets +// This file intentionally left empty - all implementation is in the binary target diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/CRACommons.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/CRACommons.h new file mode 100644 index 000000000..32a57442c --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/CRACommons.h @@ -0,0 +1,128 @@ +/** + * @file CRACommons.h + * @brief Umbrella header for CRACommons Swift bridge module + * + * This header exposes the runanywhere-commons C API to Swift. + * Import this module in Swift files that need direct C interop. + * + * Note: Headers are included using local includes for SPM compatibility. + */ + +#ifndef CRACOMMONS_H +#define CRACOMMONS_H + +// ============================================================================= +// CORE - Types, Error, Logging, Platform, State +// ============================================================================= + +#include "rac_types.h" +#include "rac_error.h" +#include "rac_structured_error.h" +#include "rac_log.h" +#include "rac_logger.h" +#include "rac_core.h" +#include "rac_platform_adapter.h" +#include "rac_component_types.h" +#include "rac_audio_utils.h" + +// Lifecycle management +#include "rac_lifecycle.h" + +// SDK State (centralized state management) +#include "rac_sdk_state.h" + +// ============================================================================= +// FEATURES - LLM, STT, TTS, VAD, Voice Agent +// ============================================================================= + +// LLM (Large Language Model) +#include "rac_llm.h" +#include "rac_llm_types.h" +#include "rac_llm_service.h" +#include "rac_llm_component.h" +#include "rac_llm_metrics.h" +#include "rac_llm_analytics.h" +#include "rac_llm_events.h" +#include "rac_llm_structured_output.h" + +// STT (Speech-to-Text) +#include "rac_stt.h" +#include "rac_stt_types.h" +#include "rac_stt_service.h" +#include "rac_stt_component.h" +#include "rac_stt_analytics.h" +#include "rac_stt_events.h" + +// TTS (Text-to-Speech) +#include "rac_tts.h" +#include "rac_tts_types.h" +#include "rac_tts_service.h" +#include "rac_tts_component.h" +#include "rac_tts_analytics.h" +#include "rac_tts_events.h" + +// VAD (Voice Activity Detection) +#include "rac_vad.h" +#include "rac_vad_types.h" +#include "rac_vad_service.h" +#include "rac_vad_component.h" +#include "rac_vad_energy.h" +#include "rac_vad_analytics.h" +#include "rac_vad_events.h" + +// Voice Agent +#include "rac_voice_agent.h" + +// ============================================================================= +// INFRASTRUCTURE - Events, Download, Model Management +// ============================================================================= + +// Event system +#include "rac_events.h" +#include "rac_analytics_events.h" + +// Download management +#include "rac_download.h" + +// Model management +#include "rac_model_types.h" +#include "rac_model_paths.h" +#include "rac_model_registry.h" +#include "rac_model_strategy.h" +#include "rac_model_assignment.h" + +// Storage analysis +#include "rac_storage_analyzer.h" + +// ============================================================================= +// PLATFORM BACKEND - Apple Foundation Models, System TTS +// ============================================================================= + +#include "rac_llm_platform.h" +#include "rac_tts_platform.h" + +// ============================================================================= +// NETWORK - Environment, Auth, API Types, Dev Config +// ============================================================================= + +#include "rac_environment.h" +#include "rac_endpoints.h" +#include "rac_api_types.h" +#include "rac_http_client.h" +#include "rac_auth_manager.h" +#include "rac_dev_config.h" + +// ============================================================================= +// TELEMETRY - Event payloads, batching, manager +// ============================================================================= + +#include "rac_telemetry_types.h" +#include "rac_telemetry_manager.h" + +// ============================================================================= +// DEVICE - Device registration manager +// ============================================================================= + +#include "rac_device_manager.h" + +#endif /* CRACOMMONS_H */ diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/module.modulemap b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/module.modulemap new file mode 100644 index 000000000..0a8643116 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/module.modulemap @@ -0,0 +1,8 @@ +module CRACommons { + umbrella header "CRACommons.h" + + export * + module * { export * } + + link framework "Accelerate" +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_analytics_events.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_analytics_events.h new file mode 100644 index 000000000..6d96daa24 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_analytics_events.h @@ -0,0 +1,610 @@ +/** + * @file rac_events.h + * @brief RunAnywhere Commons - Cross-Platform Event System + * + * C++ is the canonical source of truth for all analytics events. + * Platform SDKs (Swift, Kotlin, Flutter) register callbacks to receive + * these events and forward them to their native event systems. + * + * Usage: + * 1. Platform SDK registers callback via rac_events_set_callback() + * 2. C++ components emit events via rac_event_emit() + * 3. Platform SDK receives events in callback and converts to native events + */ + +#ifndef RAC_ANALYTICS_EVENTS_H +#define RAC_ANALYTICS_EVENTS_H + +#include "rac_types.h" +#include "rac_model_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// EVENT DESTINATION +// ============================================================================= + +// Include the event publishing header for destination types +#include "rac_events.h" + +// Alias the existing enum values for convenience in analytics context +#define RAC_EVENT_DEST_PUBLIC_ONLY RAC_EVENT_DESTINATION_PUBLIC_ONLY +#define RAC_EVENT_DEST_TELEMETRY_ONLY RAC_EVENT_DESTINATION_ANALYTICS_ONLY +#define RAC_EVENT_DEST_ALL RAC_EVENT_DESTINATION_ALL + +// ============================================================================= +// EVENT TYPES +// ============================================================================= + +/** + * @brief Event type enumeration + */ +typedef enum rac_event_type { + // LLM Events (100-199) + RAC_EVENT_LLM_MODEL_LOAD_STARTED = 100, + RAC_EVENT_LLM_MODEL_LOAD_COMPLETED = 101, + RAC_EVENT_LLM_MODEL_LOAD_FAILED = 102, + RAC_EVENT_LLM_MODEL_UNLOADED = 103, + RAC_EVENT_LLM_GENERATION_STARTED = 110, + RAC_EVENT_LLM_GENERATION_COMPLETED = 111, + RAC_EVENT_LLM_GENERATION_FAILED = 112, + RAC_EVENT_LLM_FIRST_TOKEN = 113, + RAC_EVENT_LLM_STREAMING_UPDATE = 114, + + // STT Events (200-299) + RAC_EVENT_STT_MODEL_LOAD_STARTED = 200, + RAC_EVENT_STT_MODEL_LOAD_COMPLETED = 201, + RAC_EVENT_STT_MODEL_LOAD_FAILED = 202, + RAC_EVENT_STT_MODEL_UNLOADED = 203, + RAC_EVENT_STT_TRANSCRIPTION_STARTED = 210, + RAC_EVENT_STT_TRANSCRIPTION_COMPLETED = 211, + RAC_EVENT_STT_TRANSCRIPTION_FAILED = 212, + RAC_EVENT_STT_PARTIAL_TRANSCRIPT = 213, + + // TTS Events (300-399) + RAC_EVENT_TTS_VOICE_LOAD_STARTED = 300, + RAC_EVENT_TTS_VOICE_LOAD_COMPLETED = 301, + RAC_EVENT_TTS_VOICE_LOAD_FAILED = 302, + RAC_EVENT_TTS_VOICE_UNLOADED = 303, + RAC_EVENT_TTS_SYNTHESIS_STARTED = 310, + RAC_EVENT_TTS_SYNTHESIS_COMPLETED = 311, + RAC_EVENT_TTS_SYNTHESIS_FAILED = 312, + RAC_EVENT_TTS_SYNTHESIS_CHUNK = 313, + + // VAD Events (400-499) + RAC_EVENT_VAD_STARTED = 400, + RAC_EVENT_VAD_STOPPED = 401, + RAC_EVENT_VAD_SPEECH_STARTED = 402, + RAC_EVENT_VAD_SPEECH_ENDED = 403, + RAC_EVENT_VAD_PAUSED = 404, + RAC_EVENT_VAD_RESUMED = 405, + + // VoiceAgent Events (500-599) + RAC_EVENT_VOICE_AGENT_TURN_STARTED = 500, + RAC_EVENT_VOICE_AGENT_TURN_COMPLETED = 501, + RAC_EVENT_VOICE_AGENT_TURN_FAILED = 502, + // Voice Agent Component State Events + RAC_EVENT_VOICE_AGENT_STT_STATE_CHANGED = 510, + RAC_EVENT_VOICE_AGENT_LLM_STATE_CHANGED = 511, + RAC_EVENT_VOICE_AGENT_TTS_STATE_CHANGED = 512, + RAC_EVENT_VOICE_AGENT_ALL_READY = 513, + + // SDK Lifecycle Events (600-699) + RAC_EVENT_SDK_INIT_STARTED = 600, + RAC_EVENT_SDK_INIT_COMPLETED = 601, + RAC_EVENT_SDK_INIT_FAILED = 602, + RAC_EVENT_SDK_MODELS_LOADED = 603, + + // Model Download Events (700-719) + RAC_EVENT_MODEL_DOWNLOAD_STARTED = 700, + RAC_EVENT_MODEL_DOWNLOAD_PROGRESS = 701, + RAC_EVENT_MODEL_DOWNLOAD_COMPLETED = 702, + RAC_EVENT_MODEL_DOWNLOAD_FAILED = 703, + RAC_EVENT_MODEL_DOWNLOAD_CANCELLED = 704, + + // Model Extraction Events (710-719) + RAC_EVENT_MODEL_EXTRACTION_STARTED = 710, + RAC_EVENT_MODEL_EXTRACTION_PROGRESS = 711, + RAC_EVENT_MODEL_EXTRACTION_COMPLETED = 712, + RAC_EVENT_MODEL_EXTRACTION_FAILED = 713, + + // Model Deletion Events (720-729) + RAC_EVENT_MODEL_DELETED = 720, + + // Storage Events (800-899) + RAC_EVENT_STORAGE_CACHE_CLEARED = 800, + RAC_EVENT_STORAGE_CACHE_CLEAR_FAILED = 801, + RAC_EVENT_STORAGE_TEMP_CLEANED = 802, + + // Device Events (900-999) + RAC_EVENT_DEVICE_REGISTERED = 900, + RAC_EVENT_DEVICE_REGISTRATION_FAILED = 901, + + // Network Events (1000-1099) + RAC_EVENT_NETWORK_CONNECTIVITY_CHANGED = 1000, + + // Error Events (1100-1199) + RAC_EVENT_SDK_ERROR = 1100, + + // Framework Events (1200-1299) + RAC_EVENT_FRAMEWORK_MODELS_REQUESTED = 1200, + RAC_EVENT_FRAMEWORK_MODELS_RETRIEVED = 1201, +} rac_event_type_t; + +/** + * @brief Get the destination for an event type + * + * @param type Event type + * @return Event destination + */ +RAC_API rac_event_destination_t rac_event_get_destination(rac_event_type_t type); + +// ============================================================================= +// EVENT DATA STRUCTURES +// ============================================================================= + +/** + * @brief LLM generation analytics event data + * Used for: GENERATION_STARTED, GENERATION_COMPLETED, GENERATION_FAILED + */ +typedef struct rac_analytics_llm_generation { + /** Unique generation identifier */ + const char* generation_id; + /** Model ID used for generation */ + const char* model_id; + /** Human-readable model name */ + const char* model_name; + /** Number of input/prompt tokens */ + int32_t input_tokens; + /** Number of output/completion tokens */ + int32_t output_tokens; + /** Total duration in milliseconds */ + double duration_ms; + /** Tokens generated per second */ + double tokens_per_second; + /** Whether this was a streaming generation */ + rac_bool_t is_streaming; + /** Time to first token in ms (0 if not streaming or not yet received) */ + double time_to_first_token_ms; + /** Inference framework used */ + rac_inference_framework_t framework; + /** Generation temperature (0 if not set) */ + float temperature; + /** Max tokens setting (0 if not set) */ + int32_t max_tokens; + /** Context length (0 if not set) */ + int32_t context_length; + /** Error code (RAC_SUCCESS if no error) */ + rac_result_t error_code; + /** Error message (NULL if no error) */ + const char* error_message; +} rac_analytics_llm_generation_t; + +/** + * @brief LLM model load analytics event data + * Used for: MODEL_LOAD_STARTED, MODEL_LOAD_COMPLETED, MODEL_LOAD_FAILED + */ +typedef struct rac_analytics_llm_model { + /** Model ID */ + const char* model_id; + /** Human-readable model name */ + const char* model_name; + /** Model size in bytes (0 if unknown) */ + int64_t model_size_bytes; + /** Load duration in milliseconds (for completed event) */ + double duration_ms; + /** Inference framework */ + rac_inference_framework_t framework; + /** Error code (RAC_SUCCESS if no error) */ + rac_result_t error_code; + /** Error message (NULL if no error) */ + const char* error_message; +} rac_analytics_llm_model_t; + +/** + * @brief STT transcription event data + * Used for: TRANSCRIPTION_STARTED, TRANSCRIPTION_COMPLETED, TRANSCRIPTION_FAILED + */ +typedef struct rac_analytics_stt_transcription { + /** Unique transcription identifier */ + const char* transcription_id; + /** Model ID used */ + const char* model_id; + /** Human-readable model name */ + const char* model_name; + /** Transcribed text (for completed event) */ + const char* text; + /** Confidence score (0.0 - 1.0) */ + float confidence; + /** Processing duration in milliseconds */ + double duration_ms; + /** Audio length in milliseconds */ + double audio_length_ms; + /** Audio size in bytes */ + int32_t audio_size_bytes; + /** Word count in result */ + int32_t word_count; + /** Real-time factor (audio_length / processing_time) */ + double real_time_factor; + /** Language code */ + const char* language; + /** Sample rate */ + int32_t sample_rate; + /** Whether streaming transcription */ + rac_bool_t is_streaming; + /** Inference framework */ + rac_inference_framework_t framework; + /** Error code (RAC_SUCCESS if no error) */ + rac_result_t error_code; + /** Error message (NULL if no error) */ + const char* error_message; +} rac_analytics_stt_transcription_t; + +/** + * @brief TTS synthesis event data + * Used for: SYNTHESIS_STARTED, SYNTHESIS_COMPLETED, SYNTHESIS_FAILED + */ +typedef struct rac_analytics_tts_synthesis { + /** Unique synthesis identifier */ + const char* synthesis_id; + /** Voice/Model ID used */ + const char* model_id; + /** Human-readable voice/model name */ + const char* model_name; + /** Character count of input text */ + int32_t character_count; + /** Audio duration in milliseconds */ + double audio_duration_ms; + /** Audio size in bytes */ + int32_t audio_size_bytes; + /** Processing duration in milliseconds */ + double processing_duration_ms; + /** Characters processed per second */ + double characters_per_second; + /** Sample rate */ + int32_t sample_rate; + /** Inference framework */ + rac_inference_framework_t framework; + /** Error code (RAC_SUCCESS if no error) */ + rac_result_t error_code; + /** Error message (NULL if no error) */ + const char* error_message; +} rac_analytics_tts_synthesis_t; + +/** + * @brief VAD event data + * Used for: VAD_STARTED, VAD_STOPPED, VAD_SPEECH_STARTED, VAD_SPEECH_ENDED + */ +typedef struct rac_analytics_vad { + /** Speech duration in milliseconds (for SPEECH_ENDED) */ + double speech_duration_ms; + /** Energy level (for speech events) */ + float energy_level; +} rac_analytics_vad_t; + +/** + * @brief Model download event data + * Used for: MODEL_DOWNLOAD_*, MODEL_EXTRACTION_*, MODEL_DELETED + */ +typedef struct rac_analytics_model_download { + /** Model identifier */ + const char* model_id; + /** Download progress (0.0 - 100.0) */ + double progress; + /** Bytes downloaded so far */ + int64_t bytes_downloaded; + /** Total bytes to download */ + int64_t total_bytes; + /** Duration in milliseconds */ + double duration_ms; + /** Final size in bytes (for completed event) */ + int64_t size_bytes; + /** Archive type (e.g., "zip", "tar.gz", "none") */ + const char* archive_type; + /** Error code (RAC_SUCCESS if no error) */ + rac_result_t error_code; + /** Error message (NULL if no error) */ + const char* error_message; +} rac_analytics_model_download_t; + +/** + * @brief SDK lifecycle event data + * Used for: SDK_INIT_*, SDK_MODELS_LOADED + */ +typedef struct rac_analytics_sdk_lifecycle { + /** Duration in milliseconds */ + double duration_ms; + /** Count (e.g., number of models loaded) */ + int32_t count; + /** Error code (RAC_SUCCESS if no error) */ + rac_result_t error_code; + /** Error message (NULL if no error) */ + const char* error_message; +} rac_analytics_sdk_lifecycle_t; + +/** + * @brief Storage event data + * Used for: STORAGE_CACHE_CLEARED, STORAGE_TEMP_CLEANED + */ +typedef struct rac_analytics_storage { + /** Bytes freed */ + int64_t freed_bytes; + /** Error code (RAC_SUCCESS if no error) */ + rac_result_t error_code; + /** Error message (NULL if no error) */ + const char* error_message; +} rac_analytics_storage_t; + +/** + * @brief Device event data + * Used for: DEVICE_REGISTERED, DEVICE_REGISTRATION_FAILED + */ +typedef struct rac_analytics_device { + /** Device identifier */ + const char* device_id; + /** Error code (RAC_SUCCESS if no error) */ + rac_result_t error_code; + /** Error message (NULL if no error) */ + const char* error_message; +} rac_analytics_device_t; + +/** + * @brief Network event data + * Used for: NETWORK_CONNECTIVITY_CHANGED + */ +typedef struct rac_analytics_network { + /** Whether the device is online */ + rac_bool_t is_online; +} rac_analytics_network_t; + +/** + * @brief SDK error event data + * Used for: SDK_ERROR + */ +typedef struct rac_analytics_sdk_error { + /** Error code */ + rac_result_t error_code; + /** Error message */ + const char* error_message; + /** Operation that failed */ + const char* operation; + /** Additional context */ + const char* context; +} rac_analytics_sdk_error_t; + +/** + * @brief Voice agent component state + * Used for: VOICE_AGENT_*_STATE_CHANGED events + */ +typedef enum rac_voice_agent_component_state { + RAC_VOICE_AGENT_STATE_NOT_LOADED = 0, + RAC_VOICE_AGENT_STATE_LOADING = 1, + RAC_VOICE_AGENT_STATE_LOADED = 2, + RAC_VOICE_AGENT_STATE_ERROR = 3, +} rac_voice_agent_component_state_t; + +/** + * @brief Voice agent state change event data + * Used for: VOICE_AGENT_STT_STATE_CHANGED, VOICE_AGENT_LLM_STATE_CHANGED, + * VOICE_AGENT_TTS_STATE_CHANGED, VOICE_AGENT_ALL_READY + */ +typedef struct rac_analytics_voice_agent_state { + /** Component name: "stt", "llm", "tts", or "all" */ + const char* component; + /** New state */ + rac_voice_agent_component_state_t state; + /** Model ID (if loaded) */ + const char* model_id; + /** Error message (if state is ERROR) */ + const char* error_message; +} rac_analytics_voice_agent_state_t; + +/** + * @brief Union of all event data types + */ +typedef struct rac_analytics_event_data { + rac_event_type_t type; + union { + rac_analytics_llm_generation_t llm_generation; + rac_analytics_llm_model_t llm_model; + rac_analytics_stt_transcription_t stt_transcription; + rac_analytics_tts_synthesis_t tts_synthesis; + rac_analytics_vad_t vad; + rac_analytics_model_download_t model_download; + rac_analytics_sdk_lifecycle_t sdk_lifecycle; + rac_analytics_storage_t storage; + rac_analytics_device_t device; + rac_analytics_network_t network; + rac_analytics_sdk_error_t sdk_error; + rac_analytics_voice_agent_state_t voice_agent_state; + } data; +} rac_analytics_event_data_t; + +// ============================================================================= +// EVENT CALLBACK API +// ============================================================================= + +/** + * @brief Event callback function type + * + * Platform SDKs implement this callback to receive events from C++. + * + * @param type Event type + * @param data Event data (lifetime: only valid during callback) + * @param user_data User data provided during registration + */ +typedef void (*rac_analytics_callback_fn)(rac_event_type_t type, + const rac_analytics_event_data_t* data, void* user_data); + +/** + * @brief Register analytics event callback + * + * Called by platform SDKs at initialization to receive analytics events. + * Only one callback can be registered at a time. + * + * @param callback Callback function (NULL to unregister) + * @param user_data User data passed to callback + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_analytics_events_set_callback(rac_analytics_callback_fn callback, + void* user_data); + +/** + * @brief Emit an analytics event + * + * Called internally by C++ components to emit analytics events. + * If no callback is registered, event is silently discarded. + * + * @param type Event type + * @param data Event data + */ +RAC_API void rac_analytics_event_emit(rac_event_type_t type, + const rac_analytics_event_data_t* data); + +/** + * @brief Check if analytics event callback is registered + * + * @return RAC_TRUE if callback is registered, RAC_FALSE otherwise + */ +RAC_API rac_bool_t rac_analytics_events_has_callback(void); + +// ============================================================================= +// PUBLIC EVENT CALLBACK API +// ============================================================================= + +/** + * @brief Public event callback function type + * + * Platform SDKs implement this callback to receive public events from C++. + * Public events are intended for app developers (UI updates, user feedback). + * + * @param type Event type + * @param data Event data (lifetime: only valid during callback) + * @param user_data User data provided during registration + */ +typedef void (*rac_public_event_callback_fn)(rac_event_type_t type, + const rac_analytics_event_data_t* data, + void* user_data); + +/** + * @brief Register public event callback + * + * Called by platform SDKs to receive public events (for app developers). + * Events are routed based on their destination: + * - PUBLIC_ONLY: Only sent to this callback + * - ALL: Sent to both this callback and telemetry + * + * @param callback Callback function (NULL to unregister) + * @param user_data User data passed to callback + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_analytics_events_set_public_callback(rac_public_event_callback_fn callback, + void* user_data); + +/** + * @brief Check if public event callback is registered + * + * @return RAC_TRUE if callback is registered, RAC_FALSE otherwise + */ +RAC_API rac_bool_t rac_analytics_events_has_public_callback(void); + +// ============================================================================= +// DEFAULT EVENT DATA +// ============================================================================= + +/** Default LLM generation event */ +static const rac_analytics_llm_generation_t RAC_ANALYTICS_LLM_GENERATION_DEFAULT = { + .generation_id = RAC_NULL, + .model_id = RAC_NULL, + .model_name = RAC_NULL, + .input_tokens = 0, + .output_tokens = 0, + .duration_ms = 0.0, + .tokens_per_second = 0.0, + .is_streaming = RAC_FALSE, + .time_to_first_token_ms = 0.0, + .framework = RAC_FRAMEWORK_UNKNOWN, + .temperature = 0.0f, + .max_tokens = 0, + .context_length = 0, + .error_code = RAC_SUCCESS, + .error_message = RAC_NULL}; + +/** Default STT transcription event */ +static const rac_analytics_stt_transcription_t RAC_ANALYTICS_STT_TRANSCRIPTION_DEFAULT = { + .transcription_id = RAC_NULL, + .model_id = RAC_NULL, + .model_name = RAC_NULL, + .text = RAC_NULL, + .confidence = 0.0f, + .duration_ms = 0.0, + .audio_length_ms = 0.0, + .audio_size_bytes = 0, + .word_count = 0, + .real_time_factor = 0.0, + .language = RAC_NULL, + .sample_rate = 0, + .is_streaming = RAC_FALSE, + .framework = RAC_FRAMEWORK_UNKNOWN, + .error_code = RAC_SUCCESS, + .error_message = RAC_NULL}; + +/** Default TTS synthesis event */ +static const rac_analytics_tts_synthesis_t RAC_ANALYTICS_TTS_SYNTHESIS_DEFAULT = { + .synthesis_id = RAC_NULL, + .model_id = RAC_NULL, + .model_name = RAC_NULL, + .character_count = 0, + .audio_duration_ms = 0.0, + .audio_size_bytes = 0, + .processing_duration_ms = 0.0, + .characters_per_second = 0.0, + .sample_rate = 0, + .framework = RAC_FRAMEWORK_UNKNOWN, + .error_code = RAC_SUCCESS, + .error_message = RAC_NULL}; + +/** Default VAD event */ +static const rac_analytics_vad_t RAC_ANALYTICS_VAD_DEFAULT = {.speech_duration_ms = 0.0, + .energy_level = 0.0f}; + +/** Default model download event */ +static const rac_analytics_model_download_t RAC_ANALYTICS_MODEL_DOWNLOAD_DEFAULT = { + .model_id = RAC_NULL, + .progress = 0.0, + .bytes_downloaded = 0, + .total_bytes = 0, + .duration_ms = 0.0, + .size_bytes = 0, + .archive_type = RAC_NULL, + .error_code = RAC_SUCCESS, + .error_message = RAC_NULL}; + +/** Default SDK lifecycle event */ +static const rac_analytics_sdk_lifecycle_t RAC_ANALYTICS_SDK_LIFECYCLE_DEFAULT = { + .duration_ms = 0.0, .count = 0, .error_code = RAC_SUCCESS, .error_message = RAC_NULL}; + +/** Default storage event */ +static const rac_analytics_storage_t RAC_ANALYTICS_STORAGE_DEFAULT = { + .freed_bytes = 0, .error_code = RAC_SUCCESS, .error_message = RAC_NULL}; + +/** Default device event */ +static const rac_analytics_device_t RAC_ANALYTICS_DEVICE_DEFAULT = { + .device_id = RAC_NULL, .error_code = RAC_SUCCESS, .error_message = RAC_NULL}; + +/** Default network event */ +static const rac_analytics_network_t RAC_ANALYTICS_NETWORK_DEFAULT = {.is_online = RAC_FALSE}; + +/** Default SDK error event */ +static const rac_analytics_sdk_error_t RAC_ANALYTICS_SDK_ERROR_DEFAULT = {.error_code = RAC_SUCCESS, + .error_message = RAC_NULL, + .operation = RAC_NULL, + .context = RAC_NULL}; + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_ANALYTICS_EVENTS_H */ diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_api_types.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_api_types.h new file mode 100644 index 000000000..9f969b0af --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_api_types.h @@ -0,0 +1,335 @@ +/** + * @file rac_api_types.h + * @brief API request and response data types + * + * Defines all data structures for API communication. + * This is the canonical source of truth - platform SDKs create thin wrappers. + */ + +#ifndef RAC_API_TYPES_H +#define RAC_API_TYPES_H + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// Authentication Types +// ============================================================================= + +/** + * @brief Authentication request payload + * Sent to POST /api/v1/auth/sdk/authenticate + */ +typedef struct { + const char* api_key; + const char* device_id; + const char* platform; // "ios", "android", etc. + const char* sdk_version; +} rac_auth_request_t; + +/** + * @brief Authentication response payload + * Received from authentication and refresh endpoints + */ +typedef struct { + char* access_token; + char* refresh_token; + char* device_id; + char* user_id; // Can be NULL (org-level auth) + char* organization_id; + char* token_type; // Usually "bearer" + int32_t expires_in; // Seconds until expiry +} rac_auth_response_t; + +/** + * @brief Refresh token request payload + * Sent to POST /api/v1/auth/sdk/refresh + */ +typedef struct { + const char* device_id; + const char* refresh_token; +} rac_refresh_request_t; + +// ============================================================================= +// Health Check Types +// ============================================================================= + +/** + * @brief Health status enum + */ +typedef enum { + RAC_HEALTH_HEALTHY = 0, + RAC_HEALTH_DEGRADED = 1, + RAC_HEALTH_UNHEALTHY = 2 +} rac_health_status_t; + +/** + * @brief Health check response + * Received from GET /v1/health + */ +typedef struct { + rac_health_status_t status; + char* version; + int64_t timestamp; // Unix timestamp +} rac_health_response_t; + +// ============================================================================= +// Device Registration Types +// ============================================================================= + +/** + * @brief Device hardware information + */ +typedef struct { + const char* device_fingerprint; + const char* device_model; // e.g., "iPhone15,2" + const char* os_version; // e.g., "17.0" + const char* platform; // "ios", "android", etc. + const char* architecture; // "arm64", "x86_64", etc. + int64_t total_memory; // Bytes + int32_t cpu_cores; + bool has_neural_engine; + bool has_gpu; +} rac_device_info_t; + +/** + * @brief Device registration request + * Sent to POST /api/v1/devices/register + */ +typedef struct { + rac_device_info_t device_info; + const char* sdk_version; + const char* build_token; + int64_t last_seen_at; // Unix timestamp +} rac_device_reg_request_t; + +/** + * @brief Device registration response + */ +typedef struct { + char* device_id; + char* status; // "registered" or "updated" + char* sync_status; // "synced" or "pending" +} rac_device_reg_response_t; + +// ============================================================================= +// Telemetry Types +// ============================================================================= + +/** + * @brief Telemetry event payload + * Contains all possible fields for LLM, STT, TTS, VAD events + */ +typedef struct { + // Required fields + const char* id; + const char* event_type; + int64_t timestamp; // Unix timestamp ms + int64_t created_at; // Unix timestamp ms + + // Event classification + const char* modality; // "llm", "stt", "tts", "model", "system" + + // Device identification + const char* device_id; + const char* session_id; + + // Model info + const char* model_id; + const char* model_name; + const char* framework; + + // Device info + const char* device; + const char* os_version; + const char* platform; + const char* sdk_version; + + // Common metrics + double processing_time_ms; + bool success; + bool has_success; // Whether success field is set + const char* error_message; + const char* error_code; + + // LLM-specific + int32_t input_tokens; + int32_t output_tokens; + int32_t total_tokens; + double tokens_per_second; + double time_to_first_token_ms; + double prompt_eval_time_ms; + double generation_time_ms; + int32_t context_length; + double temperature; + int32_t max_tokens; + + // STT-specific + double audio_duration_ms; + double real_time_factor; + int32_t word_count; + double confidence; + const char* language; + bool is_streaming; + int32_t segment_index; + + // TTS-specific + int32_t character_count; + double characters_per_second; + int32_t audio_size_bytes; + int32_t sample_rate; + const char* voice; + double output_duration_ms; + + // Model lifecycle + int64_t model_size_bytes; + const char* archive_type; + + // VAD-specific + double speech_duration_ms; + + // SDK lifecycle + int32_t count; + + // Storage + int64_t freed_bytes; + + // Network + bool is_online; + bool has_is_online; +} rac_telemetry_event_t; + +/** + * @brief Telemetry batch request + * Sent to POST /api/v1/sdk/telemetry + */ +typedef struct { + rac_telemetry_event_t* events; + size_t event_count; + const char* device_id; + int64_t timestamp; + const char* modality; // Can be NULL for V1 path +} rac_telemetry_batch_t; + +/** + * @brief Telemetry batch response + */ +typedef struct { + bool success; + int32_t events_received; + int32_t events_stored; + int32_t events_skipped; + char** errors; + size_t error_count; + char* storage_version; // "V1" or "V2" +} rac_telemetry_response_t; + +// ============================================================================= +// API Error Types +// ============================================================================= + +/** + * @brief API error information + */ +typedef struct { + int32_t status_code; + char* message; + char* code; + char* raw_body; + char* request_url; +} rac_api_error_t; + +// ============================================================================= +// Memory Management +// ============================================================================= + +/** + * @brief Free authentication response + */ +void rac_auth_response_free(rac_auth_response_t* response); + +/** + * @brief Free health response + */ +void rac_health_response_free(rac_health_response_t* response); + +/** + * @brief Free device registration response + */ +void rac_device_reg_response_free(rac_device_reg_response_t* response); + +/** + * @brief Free telemetry response + */ +void rac_telemetry_response_free(rac_telemetry_response_t* response); + +/** + * @brief Free API error + */ +void rac_api_error_free(rac_api_error_t* error); + +// ============================================================================= +// JSON Serialization +// ============================================================================= + +/** + * @brief Serialize auth request to JSON + * @param request The request to serialize + * @return JSON string (caller must free), or NULL on error + */ +char* rac_auth_request_to_json(const rac_auth_request_t* request); + +/** + * @brief Parse auth response from JSON + * @param json The JSON string + * @param out_response Output response (caller must free with rac_auth_response_free) + * @return 0 on success, -1 on error + */ +int rac_auth_response_from_json(const char* json, rac_auth_response_t* out_response); + +/** + * @brief Serialize refresh request to JSON + */ +char* rac_refresh_request_to_json(const rac_refresh_request_t* request); + +/** + * @brief Serialize device registration request to JSON + */ +char* rac_device_reg_request_to_json(const rac_device_reg_request_t* request); + +/** + * @brief Parse device registration response from JSON + */ +int rac_device_reg_response_from_json(const char* json, rac_device_reg_response_t* out_response); + +/** + * @brief Serialize telemetry event to JSON + */ +char* rac_telemetry_event_to_json(const rac_telemetry_event_t* event); + +/** + * @brief Serialize telemetry batch to JSON + */ +char* rac_telemetry_batch_to_json(const rac_telemetry_batch_t* batch); + +/** + * @brief Parse telemetry response from JSON + */ +int rac_telemetry_response_from_json(const char* json, rac_telemetry_response_t* out_response); + +/** + * @brief Parse API error from HTTP response + */ +int rac_api_error_from_response(int status_code, const char* body, const char* url, + rac_api_error_t* out_error); + +#ifdef __cplusplus +} +#endif + +#endif // RAC_API_TYPES_H diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_audio_utils.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_audio_utils.h new file mode 100644 index 000000000..47d1949f1 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_audio_utils.h @@ -0,0 +1,88 @@ +/** + * @file rac_audio_utils.h + * @brief RunAnywhere Commons - Audio Utility Functions + * + * Provides audio format conversion utilities used across the SDK. + * This centralizes audio processing logic that was previously duplicated + * in Swift/Kotlin SDKs. + */ + +#ifndef RAC_AUDIO_UTILS_H +#define RAC_AUDIO_UTILS_H + +#include "rac_types.h" +#include "rac_tts_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// AUDIO CONVERSION API +// ============================================================================= + +/** + * @brief Convert Float32 PCM samples to WAV format (Int16 PCM with header) + * + * TTS backends typically output raw Float32 PCM samples in range [-1.0, 1.0]. + * This function converts them to a complete WAV file that can be played by + * standard audio players (AVAudioPlayer on iOS, MediaPlayer on Android, etc.). + * + * WAV format details: + * - RIFF header with WAVE format + * - fmt chunk: PCM format (1), mono (1 channel), Int16 samples + * - data chunk: Int16 samples (scaled from Float32) + * + * @param pcm_data Input Float32 PCM samples + * @param pcm_size Size of pcm_data in bytes (must be multiple of 4) + * @param sample_rate Sample rate in Hz (e.g., 22050 for Piper TTS) + * @param out_wav_data Output: WAV file data (owned, must be freed with rac_free) + * @param out_wav_size Output: Size of WAV data in bytes + * @return RAC_SUCCESS or error code + * + * @note The caller owns the returned wav_data and must free it with rac_free() + * + * Example usage: + * @code + * void* wav_data = NULL; + * size_t wav_size = 0; + * rac_result_t result = rac_audio_float32_to_wav( + * pcm_samples, pcm_size, RAC_TTS_DEFAULT_SAMPLE_RATE, &wav_data, &wav_size); + * if (result == RAC_SUCCESS) { + * // Use wav_data... + * rac_free(wav_data); + * } + * @endcode + */ +RAC_API rac_result_t rac_audio_float32_to_wav(const void* pcm_data, size_t pcm_size, + int32_t sample_rate, void** out_wav_data, + size_t* out_wav_size); + +/** + * @brief Convert Int16 PCM samples to WAV format + * + * Similar to rac_audio_float32_to_wav but for Int16 input samples. + * + * @param pcm_data Input Int16 PCM samples + * @param pcm_size Size of pcm_data in bytes (must be multiple of 2) + * @param sample_rate Sample rate in Hz + * @param out_wav_data Output: WAV file data (owned, must be freed with rac_free) + * @param out_wav_size Output: Size of WAV data in bytes + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_audio_int16_to_wav(const void* pcm_data, size_t pcm_size, + int32_t sample_rate, void** out_wav_data, + size_t* out_wav_size); + +/** + * @brief Get WAV header size in bytes + * + * @return WAV header size (always 44 bytes for standard PCM WAV) + */ +RAC_API size_t rac_audio_wav_header_size(void); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_AUDIO_UTILS_H */ diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_auth_manager.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_auth_manager.h new file mode 100644 index 000000000..7f2ba61e8 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_auth_manager.h @@ -0,0 +1,252 @@ +/** + * @file rac_auth_manager.h + * @brief Authentication state management + * + * Manages authentication state including tokens, expiry, and refresh logic. + * Platform SDKs provide HTTP transport and secure storage callbacks. + */ + +#ifndef RAC_AUTH_MANAGER_H +#define RAC_AUTH_MANAGER_H + +#include +#include + +#include "rac_api_types.h" +#include "rac_environment.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// Auth State +// ============================================================================= + +/** + * @brief Authentication state structure + * + * Managed internally - use accessor functions. + */ +typedef struct { + char* access_token; + char* refresh_token; + char* device_id; + char* user_id; // Can be NULL + char* organization_id; + int64_t token_expires_at; // Unix timestamp (seconds) + bool is_authenticated; +} rac_auth_state_t; + +// ============================================================================= +// Platform Callbacks +// ============================================================================= + +/** + * @brief Callback for secure storage operations + * + * Platform implements to store tokens in Keychain/KeyStore. + */ +typedef struct { + /** + * @brief Store string value securely + * @param key Storage key + * @param value Value to store + * @return 0 on success, -1 on error + */ + int (*store)(const char* key, const char* value, void* context); + + /** + * @brief Retrieve string value + * @param key Storage key + * @param out_value Output buffer (caller provides) + * @param buffer_size Size of output buffer + * @return Length of value, or -1 on error/not found + */ + int (*retrieve)(const char* key, char* out_value, size_t buffer_size, void* context); + + /** + * @brief Delete stored value + * @param key Storage key + * @return 0 on success, -1 on error + */ + int (*delete_key)(const char* key, void* context); + + /** + * @brief Context pointer passed to all callbacks + */ + void* context; +} rac_secure_storage_t; + +// ============================================================================= +// Keychain Keys (for platform implementations) +// ============================================================================= + +#define RAC_KEY_ACCESS_TOKEN "com.runanywhere.sdk.accessToken" +#define RAC_KEY_REFRESH_TOKEN "com.runanywhere.sdk.refreshToken" +#define RAC_KEY_DEVICE_ID "com.runanywhere.sdk.deviceId" +#define RAC_KEY_USER_ID "com.runanywhere.sdk.userId" +#define RAC_KEY_ORGANIZATION_ID "com.runanywhere.sdk.organizationId" + +// ============================================================================= +// Initialization +// ============================================================================= + +/** + * @brief Initialize auth manager + * @param storage Secure storage callbacks (can be NULL for in-memory only) + */ +void rac_auth_init(const rac_secure_storage_t* storage); + +/** + * @brief Reset auth manager state + */ +void rac_auth_reset(void); + +// ============================================================================= +// Token State +// ============================================================================= + +/** + * @brief Check if currently authenticated + * @return true if valid access token exists + */ +bool rac_auth_is_authenticated(void); + +/** + * @brief Check if token needs refresh + * + * Returns true if token expires within 60 seconds. + * + * @return true if token should be refreshed + */ +bool rac_auth_needs_refresh(void); + +/** + * @brief Get current access token + * @return Access token string, or NULL if not authenticated + */ +const char* rac_auth_get_access_token(void); + +/** + * @brief Get current device ID + * @return Device ID string, or NULL if not set + */ +const char* rac_auth_get_device_id(void); + +/** + * @brief Get current user ID + * @return User ID string, or NULL if not set + */ +const char* rac_auth_get_user_id(void); + +/** + * @brief Get current organization ID + * @return Organization ID string, or NULL if not set + */ +const char* rac_auth_get_organization_id(void); + +// ============================================================================= +// Request Building +// ============================================================================= + +/** + * @brief Build authentication request JSON + * + * Creates JSON payload for POST /api/v1/auth/sdk/authenticate + * + * @param config SDK configuration with credentials + * @return JSON string (caller must free), or NULL on error + */ +char* rac_auth_build_authenticate_request(const rac_sdk_config_t* config); + +/** + * @brief Build token refresh request JSON + * + * Creates JSON payload for POST /api/v1/auth/sdk/refresh + * + * @return JSON string (caller must free), or NULL if no refresh token + */ +char* rac_auth_build_refresh_request(void); + +// ============================================================================= +// Response Handling +// ============================================================================= + +/** + * @brief Parse and store authentication response + * + * Updates internal auth state and optionally persists to secure storage. + * + * @param json JSON response body + * @return 0 on success, -1 on parse error + */ +int rac_auth_handle_authenticate_response(const char* json); + +/** + * @brief Parse and store refresh response + * + * Updates internal auth state and optionally persists to secure storage. + * + * @param json JSON response body + * @return 0 on success, -1 on parse error + */ +int rac_auth_handle_refresh_response(const char* json); + +// ============================================================================= +// Token Management +// ============================================================================= + +/** + * @brief Get valid access token, triggering refresh if needed + * + * This is the main entry point for getting a token. If the current token + * is expired or about to expire, it will: + * 1. Build a refresh request + * 2. Return a pending state indicating refresh is needed + * + * Platform must then: + * 1. Execute the HTTP request + * 2. Call rac_auth_handle_refresh_response with result + * 3. Call this function again to get the new token + * + * @param out_token Output pointer for token string + * @param out_needs_refresh Set to true if refresh HTTP call is needed + * @return 0 on success (token valid), 1 if refresh needed, -1 on error + */ +int rac_auth_get_valid_token(const char** out_token, bool* out_needs_refresh); + +/** + * @brief Clear all authentication state + * + * Clears in-memory state and secure storage. + */ +void rac_auth_clear(void); + +// ============================================================================= +// Persistence +// ============================================================================= + +/** + * @brief Load tokens from secure storage + * + * Call during initialization to restore saved auth state. + * + * @return 0 on success (tokens loaded), -1 if not found or error + */ +int rac_auth_load_stored_tokens(void); + +/** + * @brief Save current tokens to secure storage + * + * Called automatically by response handlers, but can be called manually. + * + * @return 0 on success, -1 on error + */ +int rac_auth_save_tokens(void); + +#ifdef __cplusplus +} +#endif + +#endif // RAC_AUTH_MANAGER_H diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_component_types.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_component_types.h new file mode 100644 index 000000000..9539883fb --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_component_types.h @@ -0,0 +1,160 @@ +/** + * @file rac_component_types.h + * @brief RunAnywhere Commons - Core Component Types + * + * C port of Swift's component types from: + * Sources/RunAnywhere/Core/Types/ComponentTypes.swift + * Sources/RunAnywhere/Core/Capabilities/Analytics/ResourceTypes.swift + * + * These types define SDK components, their configurations, and resource types. + */ + +#ifndef RAC_COMPONENT_TYPES_H +#define RAC_COMPONENT_TYPES_H + +#include "rac_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// SDK COMPONENT - Mirrors Swift's SDKComponent enum +// ============================================================================= + +/** + * @brief SDK component types for identification + * + * Mirrors Swift's SDKComponent enum exactly. + * See: Sources/RunAnywhere/Core/Types/ComponentTypes.swift + */ +typedef enum rac_sdk_component { + RAC_COMPONENT_LLM = 0, /**< Large Language Model */ + RAC_COMPONENT_STT = 1, /**< Speech-to-Text */ + RAC_COMPONENT_TTS = 2, /**< Text-to-Speech */ + RAC_COMPONENT_VAD = 3, /**< Voice Activity Detection */ + RAC_COMPONENT_VOICE = 4, /**< Voice Agent */ + RAC_COMPONENT_EMBEDDING = 5, /**< Embedding generation */ +} rac_sdk_component_t; + +/** + * @brief Get human-readable display name for SDK component + * + * @param component The SDK component type + * @return Display name string (static, do not free) + */ +RAC_API const char* rac_sdk_component_display_name(rac_sdk_component_t component); + +/** + * @brief Get raw string value for SDK component + * + * Mirrors Swift's rawValue property. + * + * @param component The SDK component type + * @return Raw string value (static, do not free) + */ +RAC_API const char* rac_sdk_component_raw_value(rac_sdk_component_t component); + +// ============================================================================= +// CAPABILITY RESOURCE TYPE - Mirrors Swift's CapabilityResourceType enum +// ============================================================================= + +/** + * @brief Types of resources that can be loaded by capabilities + * + * Mirrors Swift's CapabilityResourceType enum exactly. + * See: Sources/RunAnywhere/Core/Capabilities/Analytics/ResourceTypes.swift + */ +typedef enum rac_capability_resource_type { + RAC_RESOURCE_LLM_MODEL = 0, /**< LLM model */ + RAC_RESOURCE_STT_MODEL = 1, /**< STT model */ + RAC_RESOURCE_TTS_VOICE = 2, /**< TTS voice */ + RAC_RESOURCE_VAD_MODEL = 3, /**< VAD model */ + RAC_RESOURCE_DIARIZATION_MODEL = 4, /**< Diarization model */ +} rac_capability_resource_type_t; + +/** + * @brief Get raw string value for capability resource type + * + * Mirrors Swift's rawValue property. + * + * @param type The capability resource type + * @return Raw string value (static, do not free) + */ +RAC_API const char* rac_capability_resource_type_raw_value(rac_capability_resource_type_t type); + +// ============================================================================= +// COMPONENT CONFIGURATION - Mirrors Swift's ComponentConfiguration protocol +// ============================================================================= + +/** + * @brief Base component configuration + * + * Mirrors Swift's ComponentConfiguration protocol. + * See: Sources/RunAnywhere/Core/Types/ComponentTypes.swift + * + * Note: In C, we use a struct with common fields instead of a protocol. + * Specific configurations (LLM, STT, TTS, VAD) extend this with their own fields. + */ +typedef struct rac_component_config_base { + /** Model identifier (optional - uses default if NULL) */ + const char* model_id; + + /** Preferred inference framework (use -1 for auto/none) */ + int32_t preferred_framework; +} rac_component_config_base_t; + +/** + * @brief Default base component configuration + */ +static const rac_component_config_base_t RAC_COMPONENT_CONFIG_BASE_DEFAULT = { + .model_id = RAC_NULL, .preferred_framework = -1 /* No preference */ +}; + +// ============================================================================= +// COMPONENT INPUT/OUTPUT - Mirrors Swift's ComponentInput/ComponentOutput protocols +// ============================================================================= + +/** + * @brief Base component output with timestamp + * + * Mirrors Swift's ComponentOutput protocol requirement. + * All outputs include a timestamp in milliseconds since epoch. + */ +typedef struct rac_component_output_base { + /** Timestamp in milliseconds since epoch (1970-01-01 00:00:00 UTC) */ + int64_t timestamp_ms; +} rac_component_output_base_t; + +// ============================================================================= +// INFERENCE FRAMEWORK - Mirrors Swift's InferenceFramework enum +// (Typically defined in model_types, but included here for completeness) +// ============================================================================= + +/** + * @brief Get SDK component type from capability resource type + * + * Maps resource types to their corresponding SDK components. + * + * @param resource_type The capability resource type + * @return Corresponding SDK component type + */ +RAC_API rac_sdk_component_t +rac_resource_type_to_component(rac_capability_resource_type_t resource_type); + +/** + * @brief Get capability resource type from SDK component type + * + * Maps SDK components to their corresponding resource types. + * + * @param component The SDK component type + * @return Corresponding capability resource type, or -1 if no mapping exists + */ +RAC_API rac_capability_resource_type_t +rac_component_to_resource_type(rac_sdk_component_t component); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_COMPONENT_TYPES_H */ diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_core.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_core.h new file mode 100644 index 000000000..cde86e630 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_core.h @@ -0,0 +1,331 @@ +/** + * @file rac_core.h + * @brief RunAnywhere Commons - Core Initialization and Module Management + * + * This header provides the core API for initializing and shutting down + * the commons library, as well as module registration and discovery. + */ + +#ifndef RAC_CORE_H +#define RAC_CORE_H + +#include "rac_error.h" +#include "rac_types.h" +#include "rac_model_types.h" +#include "rac_environment.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// FORWARD DECLARATIONS +// ============================================================================= + +/** Platform adapter (see rac_platform_adapter.h) */ +typedef struct rac_platform_adapter rac_platform_adapter_t; + +// ============================================================================= +// CONFIGURATION +// ============================================================================= + +/** + * Configuration for initializing the commons library. + */ +typedef struct rac_config { + /** Platform adapter providing file, logging, and other platform callbacks */ + const rac_platform_adapter_t* platform_adapter; + + /** Log level for internal logging */ + rac_log_level_t log_level; + + /** Application-specific tag for logging */ + const char* log_tag; + + /** Reserved for future use (set to NULL) */ + void* reserved; +} rac_config_t; + +// ============================================================================= +// INITIALIZATION API +// ============================================================================= + +/** + * Initializes the commons library. + * + * This must be called before any other RAC functions. The platform adapter + * is required and provides callbacks for platform-specific operations. + * + * @param config Configuration options (platform_adapter is required) + * @return RAC_SUCCESS on success, or an error code on failure + * + * @note HTTP requests return RAC_ERROR_NOT_SUPPORTED - networking should be + * handled by the SDK layer (Swift/Kotlin), not the C++ layer. + */ +RAC_API rac_result_t rac_init(const rac_config_t* config); + +/** + * Shuts down the commons library. + * + * This releases all resources and unregisters all modules. Any active + * handles become invalid after this call. + */ +RAC_API void rac_shutdown(void); + +/** + * Checks if the commons library is initialized. + * + * @return RAC_TRUE if initialized, RAC_FALSE otherwise + */ +RAC_API rac_bool_t rac_is_initialized(void); + +/** + * Gets the version of the commons library. + * + * @return Version information structure + */ +RAC_API rac_version_t rac_get_version(void); + +/** + * Configures logging based on the environment. + * + * This configures C++ local logging (stderr) based on the environment: + * - Development: stderr ON, min level DEBUG + * - Staging: stderr ON, min level INFO + * - Production: stderr OFF, min level WARNING (logs only go to Swift bridge) + * + * Call this during SDK initialization after setting the platform adapter. + * + * @param environment The current SDK environment + * @return RAC_SUCCESS on success + */ +RAC_API rac_result_t rac_configure_logging(rac_environment_t environment); + +// ============================================================================= +// MODULE INFORMATION +// ============================================================================= + +/** + * Information about a registered module (backend). + */ +typedef struct rac_module_info { + const char* id; /**< Unique module identifier */ + const char* name; /**< Human-readable name */ + const char* version; /**< Module version string */ + const char* description; /**< Module description */ + + /** Capabilities provided by this module */ + const rac_capability_t* capabilities; + size_t num_capabilities; +} rac_module_info_t; + +// ============================================================================= +// MODULE REGISTRATION API +// ============================================================================= + +/** + * Registers a module with the registry. + * + * Modules (backends) call this to register themselves with the commons layer. + * This allows the SDK to discover available backends at runtime. + * + * @param info Module information (copied internally) + * @return RAC_SUCCESS on success, or an error code on failure + */ +RAC_API rac_result_t rac_module_register(const rac_module_info_t* info); + +/** + * Unregisters a module from the registry. + * + * @param module_id The unique ID of the module to unregister + * @return RAC_SUCCESS on success, or an error code on failure + */ +RAC_API rac_result_t rac_module_unregister(const char* module_id); + +/** + * Gets the list of registered modules. + * + * @param out_modules Pointer to receive the module list (do not free) + * @param out_count Pointer to receive the number of modules + * @return RAC_SUCCESS on success, or an error code on failure + * + * @note The returned list is valid until the next module registration/unregistration. + */ +RAC_API rac_result_t rac_module_list(const rac_module_info_t** out_modules, size_t* out_count); + +/** + * Gets modules that provide a specific capability. + * + * @param capability The capability to search for + * @param out_modules Pointer to receive the module list (do not free) + * @param out_count Pointer to receive the number of modules + * @return RAC_SUCCESS on success, or an error code on failure + */ +RAC_API rac_result_t rac_modules_for_capability(rac_capability_t capability, + const rac_module_info_t** out_modules, + size_t* out_count); + +/** + * Gets information about a specific module. + * + * @param module_id The unique ID of the module + * @param out_info Pointer to receive the module info (do not free) + * @return RAC_SUCCESS on success, or RAC_ERROR_MODULE_NOT_FOUND if not found + */ +RAC_API rac_result_t rac_module_get_info(const char* module_id, const rac_module_info_t** out_info); + +// ============================================================================= +// SERVICE PROVIDER API - Mirrors Swift's ServiceRegistry +// ============================================================================= + +/** + * Service request for creating services. + * Passed to canHandle and create functions. + * + * Mirrors Swift's approach where canHandle receives a model/voice ID. + */ +typedef struct rac_service_request { + /** Model or voice ID to check/create for (can be NULL for default) */ + const char* identifier; + + /** Configuration JSON string (can be NULL) */ + const char* config_json; + + /** The capability being requested */ + rac_capability_t capability; + + /** Framework hint for routing (from model registry) */ + rac_inference_framework_t framework; + + /** Local path to model file (can be NULL if using identifier lookup) */ + const char* model_path; +} rac_service_request_t; + +/** + * canHandle function type. + * Mirrors Swift's `canHandle: @Sendable (String?) -> Bool` + * + * @param request The service request + * @param user_data Provider-specific context + * @return RAC_TRUE if this provider can handle the request + */ +typedef rac_bool_t (*rac_service_can_handle_fn)(const rac_service_request_t* request, + void* user_data); + +/** + * Service factory function type. + * Mirrors Swift's factory closure. + * + * @param request The service request + * @param user_data Provider-specific context + * @return Handle to created service, or NULL on failure + */ +typedef rac_handle_t (*rac_service_create_fn)(const rac_service_request_t* request, + void* user_data); + +/** + * Service provider registration. + * Mirrors Swift's ServiceRegistration struct. + */ +typedef struct rac_service_provider { + /** Provider name (e.g., "LlamaCPPService") */ + const char* name; + + /** Capability this provider offers */ + rac_capability_t capability; + + /** Priority (higher = preferred, default 100) */ + int32_t priority; + + /** Function to check if provider can handle request */ + rac_service_can_handle_fn can_handle; + + /** Function to create service instance */ + rac_service_create_fn create; + + /** User data passed to callbacks */ + void* user_data; +} rac_service_provider_t; + +/** + * Registers a service provider. + * + * Mirrors Swift's ServiceRegistry.registerSTT/LLM/TTS/VAD methods. + * Providers are sorted by priority (higher first). + * + * @param provider Provider information (copied internally) + * @return RAC_SUCCESS on success, or an error code on failure + */ +RAC_API rac_result_t rac_service_register_provider(const rac_service_provider_t* provider); + +/** + * Unregisters a service provider. + * + * @param name The name of the provider to unregister + * @param capability The capability the provider was registered for + * @return RAC_SUCCESS on success, or an error code on failure + */ +RAC_API rac_result_t rac_service_unregister_provider(const char* name, rac_capability_t capability); + +/** + * Creates a service for a specific capability. + * + * Mirrors Swift's createSTT/LLM/TTS/VAD methods. + * Finds first provider that canHandle the request (sorted by priority). + * + * @param capability The capability needed + * @param request The service request (can have identifier and config) + * @param out_handle Pointer to receive the service handle + * @return RAC_SUCCESS on success, or an error code on failure + */ +RAC_API rac_result_t rac_service_create(rac_capability_t capability, + const rac_service_request_t* request, + rac_handle_t* out_handle); + +/** + * Lists registered providers for a capability. + * + * @param capability The capability to list providers for + * @param out_names Pointer to receive array of provider names + * @param out_count Pointer to receive count + * @return RAC_SUCCESS on success + */ +RAC_API rac_result_t rac_service_list_providers(rac_capability_t capability, + const char*** out_names, size_t* out_count); + +// ============================================================================= +// GLOBAL MODEL REGISTRY API +// ============================================================================= + +/** + * Gets the global model registry instance. + * The registry is created automatically on first access. + * + * @return Handle to the global model registry + */ +RAC_API struct rac_model_registry* rac_get_model_registry(void); + +/** + * Registers a model with the global registry. + * Convenience function that calls rac_model_registry_save on the global registry. + * + * @param model Model info to register + * @return RAC_SUCCESS on success, or error code + */ +RAC_API rac_result_t rac_register_model(const struct rac_model_info* model); + +/** + * Gets model info from the global registry. + * Convenience function that calls rac_model_registry_get on the global registry. + * + * @param model_id Model identifier + * @param out_model Output: Model info (owned, must be freed with rac_model_info_free) + * @return RAC_SUCCESS on success, RAC_ERROR_NOT_FOUND if not registered + */ +RAC_API rac_result_t rac_get_model(const char* model_id, struct rac_model_info** out_model); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_CORE_H */ diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_dev_config.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_dev_config.h new file mode 100644 index 000000000..0c2791f67 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_dev_config.h @@ -0,0 +1,83 @@ +/** + * @file rac_dev_config.h + * @brief Development mode configuration API + * + * Provides access to development mode configuration values. + * The actual values are defined in development_config.cpp which is git-ignored. + * + * This allows: + * - Cross-platform sharing of dev config (iOS, Android, Flutter) + * - Git-ignored secrets with template for developers + * - Consistent development environment across SDKs + * + * Security Model: + * - development_config.cpp is in .gitignore (not committed to main branch) + * - Real values are ONLY in release tags (for SPM/Maven distribution) + * - Used ONLY when SDK is in .development mode + * - Backend validates build token via POST /api/v1/devices/register/dev + */ + +#ifndef RAC_DEV_CONFIG_H +#define RAC_DEV_CONFIG_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// Development Configuration API +// ============================================================================= + +/** + * @brief Check if development config is available + * @return true if development config is properly configured + */ +bool rac_dev_config_is_available(void); + +/** + * @brief Get Supabase project URL for development mode + * @return URL string (static, do not free) + */ +const char* rac_dev_config_get_supabase_url(void); + +/** + * @brief Get Supabase anon key for development mode + * @return API key string (static, do not free) + */ +const char* rac_dev_config_get_supabase_key(void); + +/** + * @brief Get build token for development mode + * @return Build token string (static, do not free) + */ +const char* rac_dev_config_get_build_token(void); + +/** + * @brief Get Sentry DSN for crash reporting (optional) + * @return Sentry DSN string, or NULL if not configured + */ +const char* rac_dev_config_get_sentry_dsn(void); + +// ============================================================================= +// Convenience Functions +// ============================================================================= + +/** + * @brief Check if Supabase config is valid + * @return true if URL and key are non-empty + */ +bool rac_dev_config_has_supabase(void); + +/** + * @brief Check if build token is valid + * @return true if build token is non-empty + */ +bool rac_dev_config_has_build_token(void); + +#ifdef __cplusplus +} +#endif + +#endif // RAC_DEV_CONFIG_H diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_device_manager.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_device_manager.h new file mode 100644 index 000000000..5c50c8a8c --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_device_manager.h @@ -0,0 +1,176 @@ +/** + * @file rac_device_manager.h + * @brief Device Registration Manager - C++ Business Logic Layer + * + * Handles device registration orchestration with all business logic in C++. + * Platform SDKs (Swift, Kotlin) provide callbacks for: + * - Device info gathering (platform-specific APIs) + * - Device ID retrieval (Keychain/Keystore) + * - Registration persistence (UserDefaults/SharedPreferences) + * - HTTP transport (URLSession/OkHttp) + * + * Events are emitted via rac_analytics_event_emit(). + */ + +#ifndef RAC_DEVICE_MANAGER_H +#define RAC_DEVICE_MANAGER_H + +#include "rac_types.h" +#include "rac_environment.h" +#include "rac_telemetry_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// CALLBACK TYPES +// ============================================================================= + +/** + * @brief HTTP response for device registration + */ +typedef struct rac_device_http_response { + rac_result_t result; // RAC_SUCCESS on success + int32_t status_code; // HTTP status code (200, 400, etc.) + const char* response_body; // Response JSON (can be NULL) + const char* error_message; // Error message (can be NULL) +} rac_device_http_response_t; + +/** + * @brief Callback function types for platform-specific operations + */ + +/** + * Get device information (Swift calls DeviceInfo.current) + * @param out_info Output parameter for device info + * @param user_data User-provided context + */ +typedef void (*rac_device_get_info_fn)(rac_device_registration_info_t* out_info, void* user_data); + +/** + * Get persistent device ID (Swift calls DeviceIdentity.persistentUUID) + * @param user_data User-provided context + * @return Device ID string (must remain valid during callback) + */ +typedef const char* (*rac_device_get_id_fn)(void* user_data); + +/** + * Check if device is already registered (Swift checks UserDefaults) + * @param user_data User-provided context + * @return RAC_TRUE if registered, RAC_FALSE otherwise + */ +typedef rac_bool_t (*rac_device_is_registered_fn)(void* user_data); + +/** + * Mark device as registered/unregistered (Swift sets UserDefaults) + * @param registered RAC_TRUE to mark as registered, RAC_FALSE to clear + * @param user_data User-provided context + */ +typedef void (*rac_device_set_registered_fn)(rac_bool_t registered, void* user_data); + +/** + * Make HTTP POST request for device registration + * @param endpoint Full endpoint URL + * @param json_body JSON body to POST + * @param requires_auth Whether authentication header is required + * @param out_response Output parameter for response + * @param user_data User-provided context + * @return RAC_SUCCESS on success, error code otherwise + */ +typedef rac_result_t (*rac_device_http_post_fn)(const char* endpoint, const char* json_body, + rac_bool_t requires_auth, + rac_device_http_response_t* out_response, + void* user_data); + +/** + * @brief Callback structure for platform-specific operations + * + * Platform SDKs set these callbacks at initialization. + * C++ device manager calls these to access platform services. + */ +typedef struct rac_device_callbacks { + /** Get device hardware/OS information */ + rac_device_get_info_fn get_device_info; + + /** Get persistent device UUID (Keychain/Keystore) */ + rac_device_get_id_fn get_device_id; + + /** Check if device is registered (UserDefaults/SharedPreferences) */ + rac_device_is_registered_fn is_registered; + + /** Set registration status */ + rac_device_set_registered_fn set_registered; + + /** Make HTTP POST request */ + rac_device_http_post_fn http_post; + + /** User data passed to all callbacks */ + void* user_data; +} rac_device_callbacks_t; + +// ============================================================================= +// DEVICE MANAGER API +// ============================================================================= + +/** + * @brief Set callbacks for device manager operations + * + * Must be called before any other device manager functions. + * Typically called during SDK initialization. + * + * @param callbacks Callback structure (copied internally) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_device_manager_set_callbacks(const rac_device_callbacks_t* callbacks); + +/** + * @brief Register device with backend if not already registered + * + * This is the main entry point for device registration. + * Business logic: + * 1. Check if already registered (via callback) + * 2. If not, gather device info (via callback) + * 3. Build JSON payload (C++ implementation) + * 4. POST to backend (via callback) + * 5. On success, mark as registered (via callback) + * 6. Emit appropriate analytics event + * + * @param env Current SDK environment + * @param build_token Optional build token for development mode (can be NULL) + * @return RAC_SUCCESS on success or if already registered, error code otherwise + */ +RAC_API rac_result_t rac_device_manager_register_if_needed(rac_environment_t env, + const char* build_token); + +/** + * @brief Check if device is registered + * + * Delegates to the is_registered callback. + * + * @return RAC_TRUE if registered, RAC_FALSE otherwise + */ +RAC_API rac_bool_t rac_device_manager_is_registered(void); + +/** + * @brief Clear device registration status + * + * Delegates to the set_registered callback with RAC_FALSE. + * Useful for testing or user-initiated reset. + */ +RAC_API void rac_device_manager_clear_registration(void); + +/** + * @brief Get the current device ID + * + * Delegates to the get_device_id callback. + * + * @return Device ID string or NULL if callbacks not set + */ +RAC_API const char* rac_device_manager_get_device_id(void); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_DEVICE_MANAGER_H */ diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_download.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_download.h new file mode 100644 index 000000000..ec37c45e3 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_download.h @@ -0,0 +1,418 @@ +/** + * @file rac_download.h + * @brief Download Manager - Model Download Orchestration + * + * C port of Swift's DownloadService protocol and related types. + * Swift Source: Sources/RunAnywhere/Infrastructure/Download/ + * + * IMPORTANT: This is a direct translation of the Swift implementation. + * Do NOT add features not present in the Swift code. + * + * NOTE: The actual HTTP download is delegated to the platform adapter + * (Swift/Kotlin/etc). This C layer handles orchestration logic: + * - Progress tracking + * - State management + * - Retry logic + * - Post-download extraction + */ + +#ifndef RAC_DOWNLOAD_H +#define RAC_DOWNLOAD_H + +#include "rac_error.h" +#include "rac_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// TYPES - Mirrors Swift's DownloadState, DownloadStage, DownloadProgress +// ============================================================================= + +/** + * @brief Download state enumeration. + * Mirrors Swift's DownloadState enum. + */ +typedef enum rac_download_state { + RAC_DOWNLOAD_STATE_PENDING = 0, /**< Download is pending */ + RAC_DOWNLOAD_STATE_DOWNLOADING = 1, /**< Currently downloading */ + RAC_DOWNLOAD_STATE_EXTRACTING = 2, /**< Extracting archive contents */ + RAC_DOWNLOAD_STATE_RETRYING = 3, /**< Retrying after failure */ + RAC_DOWNLOAD_STATE_COMPLETED = 4, /**< Download completed successfully */ + RAC_DOWNLOAD_STATE_FAILED = 5, /**< Download failed */ + RAC_DOWNLOAD_STATE_CANCELLED = 6 /**< Download was cancelled */ +} rac_download_state_t; + +/** + * @brief Download stage enumeration. + * Mirrors Swift's DownloadStage enum. + */ +typedef enum rac_download_stage { + RAC_DOWNLOAD_STAGE_DOWNLOADING = 0, /**< Downloading the file(s) */ + RAC_DOWNLOAD_STAGE_EXTRACTING = 1, /**< Extracting archive contents */ + RAC_DOWNLOAD_STAGE_VALIDATING = 2, /**< Validating downloaded files */ + RAC_DOWNLOAD_STAGE_COMPLETED = 3 /**< Download and processing complete */ +} rac_download_stage_t; + +/** + * @brief Get display name for download stage. + * + * @param stage The download stage + * @return Display name string (static, do not free) + */ +RAC_API const char* rac_download_stage_display_name(rac_download_stage_t stage); + +/** + * @brief Get progress range for download stage. + * Download: 0-80%, Extraction: 80-95%, Validation: 95-99%, Completed: 100% + * + * @param stage The download stage + * @param out_start Output: Start of progress range (0.0-1.0) + * @param out_end Output: End of progress range (0.0-1.0) + */ +RAC_API void rac_download_stage_progress_range(rac_download_stage_t stage, double* out_start, + double* out_end); + +/** + * @brief Download progress information. + * Mirrors Swift's DownloadProgress struct. + */ +typedef struct rac_download_progress { + /** Current stage of the download pipeline */ + rac_download_stage_t stage; + + /** Bytes downloaded (for download stage) */ + int64_t bytes_downloaded; + + /** Total bytes to download */ + int64_t total_bytes; + + /** Progress within current stage (0.0 to 1.0) */ + double stage_progress; + + /** Overall progress across all stages (0.0 to 1.0) */ + double overall_progress; + + /** Current state */ + rac_download_state_t state; + + /** Download speed in bytes per second (0 if unknown) */ + double speed; + + /** Estimated time remaining in seconds (-1 if unknown) */ + double estimated_time_remaining; + + /** Retry attempt number (for RETRYING state) */ + int32_t retry_attempt; + + /** Error code (for FAILED state) */ + rac_result_t error_code; + + /** Error message (for FAILED state, can be NULL) */ + const char* error_message; +} rac_download_progress_t; + +/** + * @brief Default download progress values. + */ +static const rac_download_progress_t RAC_DOWNLOAD_PROGRESS_DEFAULT = { + .stage = RAC_DOWNLOAD_STAGE_DOWNLOADING, + .bytes_downloaded = 0, + .total_bytes = 0, + .stage_progress = 0.0, + .overall_progress = 0.0, + .state = RAC_DOWNLOAD_STATE_PENDING, + .speed = 0.0, + .estimated_time_remaining = -1.0, + .retry_attempt = 0, + .error_code = RAC_SUCCESS, + .error_message = RAC_NULL}; + +/** + * @brief Download task information. + * Mirrors Swift's DownloadTask struct. + */ +typedef struct rac_download_task { + /** Unique task ID */ + char* task_id; + + /** Model ID being downloaded */ + char* model_id; + + /** Download URL */ + char* url; + + /** Destination path */ + char* destination_path; + + /** Whether extraction is required */ + rac_bool_t requires_extraction; + + /** Current progress */ + rac_download_progress_t progress; +} rac_download_task_t; + +/** + * @brief Download configuration. + * Mirrors Swift's DownloadConfiguration struct. + */ +typedef struct rac_download_config { + /** Maximum concurrent downloads (default: 1) */ + int32_t max_concurrent_downloads; + + /** Request timeout in seconds (default: 60) */ + int32_t request_timeout_seconds; + + /** Maximum retry attempts (default: 3) */ + int32_t max_retry_attempts; + + /** Retry delay in seconds (default: 5) */ + int32_t retry_delay_seconds; + + /** Whether to allow cellular downloads (default: true) */ + rac_bool_t allow_cellular; + + /** Whether to allow downloads on low data mode (default: false) */ + rac_bool_t allow_constrained_network; +} rac_download_config_t; + +/** + * @brief Default download configuration. + */ +static const rac_download_config_t RAC_DOWNLOAD_CONFIG_DEFAULT = {.max_concurrent_downloads = 1, + .request_timeout_seconds = 60, + .max_retry_attempts = 3, + .retry_delay_seconds = 5, + .allow_cellular = RAC_TRUE, + .allow_constrained_network = + RAC_FALSE}; + +// ============================================================================= +// CALLBACKS +// ============================================================================= + +/** + * @brief Callback for download progress updates. + * Mirrors Swift's AsyncStream pattern. + * + * @param progress Current progress information + * @param user_data User-provided context + */ +typedef void (*rac_download_progress_callback_fn)(const rac_download_progress_t* progress, + void* user_data); + +/** + * @brief Callback for download completion. + * + * @param task_id The task ID + * @param result RAC_SUCCESS or error code + * @param final_path Path to the downloaded/extracted file (NULL on failure) + * @param user_data User-provided context + */ +typedef void (*rac_download_complete_callback_fn)(const char* task_id, rac_result_t result, + const char* final_path, void* user_data); + +// ============================================================================= +// OPAQUE HANDLE +// ============================================================================= + +/** + * @brief Opaque handle for download manager instance. + */ +typedef struct rac_download_manager* rac_download_manager_handle_t; + +// ============================================================================= +// LIFECYCLE API +// ============================================================================= + +/** + * @brief Create a download manager instance. + * + * @param config Configuration (can be NULL for defaults) + * @param out_handle Output: Handle to the created manager + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_download_manager_create(const rac_download_config_t* config, + rac_download_manager_handle_t* out_handle); + +/** + * @brief Destroy a download manager instance. + * + * @param handle Manager handle + */ +RAC_API void rac_download_manager_destroy(rac_download_manager_handle_t handle); + +// ============================================================================= +// DOWNLOAD API +// ============================================================================= + +/** + * @brief Start downloading a model. + * + * Mirrors Swift's DownloadService.downloadModel(_:). + * The actual HTTP download is performed by the platform adapter. + * + * @param handle Manager handle + * @param model_id Model identifier + * @param url Download URL + * @param destination_path Path where the model should be saved + * @param requires_extraction Whether the download needs to be extracted + * @param progress_callback Callback for progress updates (can be NULL) + * @param complete_callback Callback for completion (can be NULL) + * @param user_data User context passed to callbacks + * @param out_task_id Output: Task ID for tracking (owned, must be freed with rac_free) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_download_manager_start(rac_download_manager_handle_t handle, + const char* model_id, const char* url, + const char* destination_path, + rac_bool_t requires_extraction, + rac_download_progress_callback_fn progress_callback, + rac_download_complete_callback_fn complete_callback, + void* user_data, char** out_task_id); + +/** + * @brief Cancel a download. + * + * Mirrors Swift's DownloadService.cancelDownload(taskId:). + * + * @param handle Manager handle + * @param task_id Task ID to cancel + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_download_manager_cancel(rac_download_manager_handle_t handle, + const char* task_id); + +/** + * @brief Pause all active downloads. + * + * Mirrors Swift's AlamofireDownloadService.pauseAll(). + * + * @param handle Manager handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_download_manager_pause_all(rac_download_manager_handle_t handle); + +/** + * @brief Resume all paused downloads. + * + * Mirrors Swift's AlamofireDownloadService.resumeAll(). + * + * @param handle Manager handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_download_manager_resume_all(rac_download_manager_handle_t handle); + +// ============================================================================= +// STATUS API +// ============================================================================= + +/** + * @brief Get current progress for a download task. + * + * @param handle Manager handle + * @param task_id Task ID + * @param out_progress Output: Current progress + * @return RAC_SUCCESS or error code (RAC_ERROR_NOT_FOUND if task doesn't exist) + */ +RAC_API rac_result_t rac_download_manager_get_progress(rac_download_manager_handle_t handle, + const char* task_id, + rac_download_progress_t* out_progress); + +/** + * @brief Get list of active download task IDs. + * + * @param handle Manager handle + * @param out_task_ids Output: Array of task IDs (owned, each must be freed with rac_free) + * @param out_count Output: Number of tasks + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_download_manager_get_active_tasks(rac_download_manager_handle_t handle, + char*** out_task_ids, size_t* out_count); + +/** + * @brief Check if the download service is healthy. + * + * Mirrors Swift's AlamofireDownloadService.isHealthy(). + * + * @param handle Manager handle + * @param out_is_healthy Output: RAC_TRUE if healthy + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_download_manager_is_healthy(rac_download_manager_handle_t handle, + rac_bool_t* out_is_healthy); + +// ============================================================================= +// PROGRESS HELPERS +// ============================================================================= + +/** + * @brief Update download progress from HTTP callback. + * + * Called by platform adapter when download progress updates. + * + * @param handle Manager handle + * @param task_id Task ID + * @param bytes_downloaded Bytes downloaded so far + * @param total_bytes Total bytes to download + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_download_manager_update_progress(rac_download_manager_handle_t handle, + const char* task_id, + int64_t bytes_downloaded, + int64_t total_bytes); + +/** + * @brief Mark download as completed. + * + * Called by platform adapter when HTTP download finishes. + * + * @param handle Manager handle + * @param task_id Task ID + * @param downloaded_path Path to the downloaded file + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_download_manager_mark_complete(rac_download_manager_handle_t handle, + const char* task_id, + const char* downloaded_path); + +/** + * @brief Mark download as failed. + * + * Called by platform adapter when HTTP download fails. + * + * @param handle Manager handle + * @param task_id Task ID + * @param error_code Error code + * @param error_message Error message (can be NULL) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_download_manager_mark_failed(rac_download_manager_handle_t handle, + const char* task_id, rac_result_t error_code, + const char* error_message); + +// ============================================================================= +// MEMORY MANAGEMENT +// ============================================================================= + +/** + * @brief Free a download task. + * + * @param task Task to free + */ +RAC_API void rac_download_task_free(rac_download_task_t* task); + +/** + * @brief Free an array of task IDs. + * + * @param task_ids Array of task IDs + * @param count Number of task IDs + */ +RAC_API void rac_download_task_ids_free(char** task_ids, size_t count); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_DOWNLOAD_H */ diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_endpoints.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_endpoints.h new file mode 100644 index 000000000..9e3fefc7b --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_endpoints.h @@ -0,0 +1,88 @@ +/** + * @file rac_endpoints.h + * @brief API endpoint definitions + * + * Defines all API endpoint paths as constants. + * This is the canonical source of truth - platform SDKs should not duplicate these. + */ + +#ifndef RAC_ENDPOINTS_H +#define RAC_ENDPOINTS_H + +#include "rac_environment.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// Authentication & Health Endpoints +// ============================================================================= + +#define RAC_ENDPOINT_AUTHENTICATE "/api/v1/auth/sdk/authenticate" +#define RAC_ENDPOINT_REFRESH "/api/v1/auth/sdk/refresh" +#define RAC_ENDPOINT_HEALTH "/v1/health" + +// ============================================================================= +// Device Management - Production/Staging +// ============================================================================= + +#define RAC_ENDPOINT_DEVICE_REGISTER "/api/v1/devices/register" +#define RAC_ENDPOINT_TELEMETRY "/api/v1/sdk/telemetry" + +// ============================================================================= +// Device Management - Development (Supabase REST API) +// ============================================================================= + +#define RAC_ENDPOINT_DEV_DEVICE_REGISTER "/rest/v1/sdk_devices" +#define RAC_ENDPOINT_DEV_TELEMETRY "/rest/v1/telemetry_events" + +// ============================================================================= +// Model Management +// ============================================================================= + +#define RAC_ENDPOINT_MODELS_AVAILABLE "/api/v1/models/available" + +// ============================================================================= +// Environment-Based Endpoint Selection +// ============================================================================= + +/** + * @brief Get device registration endpoint for environment + * @param env The environment + * @return Endpoint path string + */ +const char* rac_endpoint_device_registration(rac_environment_t env); + +/** + * @brief Get telemetry endpoint for environment + * @param env The environment + * @return Endpoint path string + */ +const char* rac_endpoint_telemetry(rac_environment_t env); + +/** + * @brief Get model assignments endpoint + * @return Endpoint path string + */ +const char* rac_endpoint_model_assignments(void); + +// ============================================================================= +// Full URL Building +// ============================================================================= + +/** + * @brief Build full URL from base URL and endpoint + * @param base_url The base URL (e.g., "https://api.runanywhere.ai") + * @param endpoint The endpoint path (e.g., "/api/v1/health") + * @param out_buffer Buffer to write full URL + * @param buffer_size Size of buffer + * @return Length of written string, or -1 on error + */ +int rac_build_url(const char* base_url, const char* endpoint, char* out_buffer, size_t buffer_size); + +#ifdef __cplusplus +} +#endif + +#endif // RAC_ENDPOINTS_H diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_environment.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_environment.h new file mode 100644 index 000000000..d7759cac1 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_environment.h @@ -0,0 +1,220 @@ +/** + * @file rac_environment.h + * @brief SDK environment configuration + * + * Defines environment types (development, staging, production) and their + * associated settings like authentication requirements, log levels, etc. + * This is the canonical source of truth - platform SDKs create thin wrappers. + */ + +#ifndef RAC_ENVIRONMENT_H +#define RAC_ENVIRONMENT_H + +#include +#include + +#include "rac_types.h" // For rac_log_level_t + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// Environment Types +// ============================================================================= + +/** + * @brief SDK environment mode + * + * - DEVELOPMENT: Local/testing mode, no auth required, uses Supabase + * - STAGING: Testing with real services, requires API key + URL + * - PRODUCTION: Live environment, requires API key + HTTPS URL + */ +typedef enum { + RAC_ENV_DEVELOPMENT = 0, + RAC_ENV_STAGING = 1, + RAC_ENV_PRODUCTION = 2 +} rac_environment_t; + +// Note: rac_log_level_t is defined in rac_types.h +// We use the existing definition for consistency + +// ============================================================================= +// SDK Configuration +// ============================================================================= + +/** + * @brief SDK initialization configuration + * + * Contains all parameters needed to initialize the SDK. + * Platform SDKs populate this from their native config types. + */ +typedef struct { + rac_environment_t environment; + const char* api_key; // Required for staging/production + const char* base_url; // Required for staging/production + const char* device_id; // Set by platform (Keychain UUID, etc.) + const char* platform; // "ios", "android", "flutter", etc. + const char* sdk_version; // SDK version string +} rac_sdk_config_t; + +/** + * @brief Development network configuration + * + * Contains Supabase credentials for development mode. + * These are built into the SDK binary. + */ +typedef struct { + const char* base_url; // Supabase project URL + const char* api_key; // Supabase anon key + const char* build_token; // SDK build token for validation +} rac_dev_config_t; + +// ============================================================================= +// Environment Query Functions +// ============================================================================= + +/** + * @brief Check if environment requires API authentication + * @param env The environment to check + * @return true for staging/production, false for development + */ +bool rac_env_requires_auth(rac_environment_t env); + +/** + * @brief Check if environment requires a backend URL + * @param env The environment to check + * @return true for staging/production, false for development + */ +bool rac_env_requires_backend_url(rac_environment_t env); + +/** + * @brief Check if environment is production + * @param env The environment to check + * @return true only for production + */ +bool rac_env_is_production(rac_environment_t env); + +/** + * @brief Check if environment is a testing environment + * @param env The environment to check + * @return true for development and staging + */ +bool rac_env_is_testing(rac_environment_t env); + +/** + * @brief Get the default log level for an environment + * @param env The environment + * @return DEBUG for development, INFO for staging, WARNING for production + */ +rac_log_level_t rac_env_default_log_level(rac_environment_t env); + +/** + * @brief Check if telemetry should be sent for this environment + * @param env The environment + * @return true only for production + */ +bool rac_env_should_send_telemetry(rac_environment_t env); + +/** + * @brief Check if environment should sync with backend + * @param env The environment + * @return true for staging/production, false for development + */ +bool rac_env_should_sync_with_backend(rac_environment_t env); + +/** + * @brief Get human-readable environment description + * @param env The environment + * @return String like "Development Environment" + */ +const char* rac_env_description(rac_environment_t env); + +// ============================================================================= +// Validation Functions +// ============================================================================= + +/** + * @brief Validation result codes + */ +typedef enum { + RAC_VALIDATION_OK = 0, + RAC_VALIDATION_API_KEY_REQUIRED, + RAC_VALIDATION_API_KEY_TOO_SHORT, + RAC_VALIDATION_URL_REQUIRED, + RAC_VALIDATION_URL_INVALID_SCHEME, + RAC_VALIDATION_URL_HTTPS_REQUIRED, + RAC_VALIDATION_URL_INVALID_HOST, + RAC_VALIDATION_URL_LOCALHOST_NOT_ALLOWED, + RAC_VALIDATION_PRODUCTION_DEBUG_BUILD +} rac_validation_result_t; + +/** + * @brief Validate API key for the given environment + * @param api_key The API key to validate (can be NULL) + * @param env The target environment + * @return RAC_VALIDATION_OK if valid, error code otherwise + */ +rac_validation_result_t rac_validate_api_key(const char* api_key, rac_environment_t env); + +/** + * @brief Validate base URL for the given environment + * @param url The URL to validate (can be NULL) + * @param env The target environment + * @return RAC_VALIDATION_OK if valid, error code otherwise + */ +rac_validation_result_t rac_validate_base_url(const char* url, rac_environment_t env); + +/** + * @brief Validate complete SDK configuration + * @param config The configuration to validate + * @return RAC_VALIDATION_OK if valid, first error code otherwise + */ +rac_validation_result_t rac_validate_config(const rac_sdk_config_t* config); + +/** + * @brief Get error message for validation result + * @param result The validation result code + * @return Human-readable error message + */ +const char* rac_validation_error_message(rac_validation_result_t result); + +// ============================================================================= +// Global SDK State +// ============================================================================= + +/** + * @brief Initialize SDK with configuration + * @param config The SDK configuration + * @return RAC_VALIDATION_OK on success, error code on validation failure + */ +rac_validation_result_t rac_sdk_init(const rac_sdk_config_t* config); + +/** + * @brief Get current SDK configuration + * @return Pointer to current config, or NULL if not initialized + */ +const rac_sdk_config_t* rac_sdk_get_config(void); + +/** + * @brief Get current environment + * @return Current environment, or RAC_ENV_DEVELOPMENT if not initialized + */ +rac_environment_t rac_sdk_get_environment(void); + +/** + * @brief Check if SDK is initialized + * @return true if rac_sdk_init has been called successfully + */ +bool rac_sdk_is_initialized(void); + +/** + * @brief Reset SDK state (for testing) + */ +void rac_sdk_reset(void); + +#ifdef __cplusplus +} +#endif + +#endif // RAC_ENVIRONMENT_H diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_error.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_error.h new file mode 100644 index 000000000..d46fdf1e9 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_error.h @@ -0,0 +1,469 @@ +/** + * @file rac_error.h + * @brief RunAnywhere Commons - Error Codes and Error Handling + * + * C port of Swift's ErrorCode enum from Foundation/Errors/ErrorCode.swift. + * + * Error codes for runanywhere-commons use the range -100 to -999 to avoid + * collision with runanywhere-core error codes (0 to -99). + * + * IMPORTANT: This is a direct translation of the Swift implementation. + * Do NOT add error codes not present in the Swift code. + */ + +#ifndef RAC_ERROR_H +#define RAC_ERROR_H + +#include "rac_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// ERROR CODE RANGES +// ============================================================================= +// +// runanywhere-core (ra_*): 0 to -99 +// runanywhere-commons (rac_*): -100 to -999 +// - Initialization errors: -100 to -109 +// - Model errors: -110 to -129 +// - Generation errors: -130 to -149 +// - Network errors: -150 to -179 +// - Storage errors: -180 to -219 +// - Hardware errors: -220 to -229 +// - Component state errors: -230 to -249 +// - Validation errors: -250 to -279 +// - Audio errors: -280 to -299 +// - Language/Voice errors: -300 to -319 +// - Authentication errors: -320 to -329 +// - Security errors: -330 to -349 +// - Extraction errors: -350 to -369 +// - Calibration errors: -370 to -379 +// - Module/Service errors: -400 to -499 +// - Platform adapter errors: -500 to -599 +// - Backend errors: -600 to -699 +// - Event errors: -700 to -799 +// - Other errors: -800 to -899 +// - Reserved: -900 to -999 + +// ============================================================================= +// INITIALIZATION ERRORS (-100 to -109) +// Mirrors Swift's ErrorCode: Initialization Errors +// ============================================================================= + +/** Component or service has not been initialized */ +#define RAC_ERROR_NOT_INITIALIZED ((rac_result_t) - 100) +/** Component or service is already initialized */ +#define RAC_ERROR_ALREADY_INITIALIZED ((rac_result_t) - 101) +/** Initialization failed */ +#define RAC_ERROR_INITIALIZATION_FAILED ((rac_result_t) - 102) +/** Configuration is invalid */ +#define RAC_ERROR_INVALID_CONFIGURATION ((rac_result_t) - 103) +/** API key is invalid or missing */ +#define RAC_ERROR_INVALID_API_KEY ((rac_result_t) - 104) +/** Environment mismatch (e.g., dev vs prod) */ +#define RAC_ERROR_ENVIRONMENT_MISMATCH ((rac_result_t) - 105) +/** Invalid parameter value passed to a function */ +#define RAC_ERROR_INVALID_PARAMETER ((rac_result_t) - 106) + +// ============================================================================= +// MODEL ERRORS (-110 to -129) +// Mirrors Swift's ErrorCode: Model Errors +// ============================================================================= + +/** Requested model was not found */ +#define RAC_ERROR_MODEL_NOT_FOUND ((rac_result_t) - 110) +/** Failed to load the model */ +#define RAC_ERROR_MODEL_LOAD_FAILED ((rac_result_t) - 111) +/** Model validation failed */ +#define RAC_ERROR_MODEL_VALIDATION_FAILED ((rac_result_t) - 112) +/** Model is incompatible with current runtime */ +#define RAC_ERROR_MODEL_INCOMPATIBLE ((rac_result_t) - 113) +/** Model format is invalid */ +#define RAC_ERROR_INVALID_MODEL_FORMAT ((rac_result_t) - 114) +/** Model storage is corrupted */ +#define RAC_ERROR_MODEL_STORAGE_CORRUPTED ((rac_result_t) - 115) +/** Model not loaded (alias for backward compatibility) */ +#define RAC_ERROR_MODEL_NOT_LOADED ((rac_result_t) - 116) + +// ============================================================================= +// GENERATION ERRORS (-130 to -149) +// Mirrors Swift's ErrorCode: Generation Errors +// ============================================================================= + +/** Text/audio generation failed */ +#define RAC_ERROR_GENERATION_FAILED ((rac_result_t) - 130) +/** Generation timed out */ +#define RAC_ERROR_GENERATION_TIMEOUT ((rac_result_t) - 131) +/** Context length exceeded maximum */ +#define RAC_ERROR_CONTEXT_TOO_LONG ((rac_result_t) - 132) +/** Token limit exceeded */ +#define RAC_ERROR_TOKEN_LIMIT_EXCEEDED ((rac_result_t) - 133) +/** Cost limit exceeded */ +#define RAC_ERROR_COST_LIMIT_EXCEEDED ((rac_result_t) - 134) +/** Inference failed */ +#define RAC_ERROR_INFERENCE_FAILED ((rac_result_t) - 135) + +// ============================================================================= +// NETWORK ERRORS (-150 to -179) +// Mirrors Swift's ErrorCode: Network Errors +// ============================================================================= + +/** Network is unavailable */ +#define RAC_ERROR_NETWORK_UNAVAILABLE ((rac_result_t) - 150) +/** Generic network error */ +#define RAC_ERROR_NETWORK_ERROR ((rac_result_t) - 151) +/** Request failed */ +#define RAC_ERROR_REQUEST_FAILED ((rac_result_t) - 152) +/** Download failed */ +#define RAC_ERROR_DOWNLOAD_FAILED ((rac_result_t) - 153) +/** Server returned an error */ +#define RAC_ERROR_SERVER_ERROR ((rac_result_t) - 154) +/** Request timed out */ +#define RAC_ERROR_TIMEOUT ((rac_result_t) - 155) +/** Invalid response from server */ +#define RAC_ERROR_INVALID_RESPONSE ((rac_result_t) - 156) +/** HTTP error with status code */ +#define RAC_ERROR_HTTP_ERROR ((rac_result_t) - 157) +/** Connection was lost */ +#define RAC_ERROR_CONNECTION_LOST ((rac_result_t) - 158) +/** Partial download (incomplete) */ +#define RAC_ERROR_PARTIAL_DOWNLOAD ((rac_result_t) - 159) +/** HTTP request failed */ +#define RAC_ERROR_HTTP_REQUEST_FAILED ((rac_result_t) - 160) +/** HTTP not supported */ +#define RAC_ERROR_HTTP_NOT_SUPPORTED ((rac_result_t) - 161) + +// ============================================================================= +// STORAGE ERRORS (-180 to -219) +// Mirrors Swift's ErrorCode: Storage Errors +// ============================================================================= + +/** Insufficient storage space */ +#define RAC_ERROR_INSUFFICIENT_STORAGE ((rac_result_t) - 180) +/** Storage is full */ +#define RAC_ERROR_STORAGE_FULL ((rac_result_t) - 181) +/** Generic storage error */ +#define RAC_ERROR_STORAGE_ERROR ((rac_result_t) - 182) +/** File was not found */ +#define RAC_ERROR_FILE_NOT_FOUND ((rac_result_t) - 183) +/** Failed to read file */ +#define RAC_ERROR_FILE_READ_FAILED ((rac_result_t) - 184) +/** Failed to write file */ +#define RAC_ERROR_FILE_WRITE_FAILED ((rac_result_t) - 185) +/** Permission denied for file operation */ +#define RAC_ERROR_PERMISSION_DENIED ((rac_result_t) - 186) +/** Failed to delete file or directory */ +#define RAC_ERROR_DELETE_FAILED ((rac_result_t) - 187) +/** Failed to move file */ +#define RAC_ERROR_MOVE_FAILED ((rac_result_t) - 188) +/** Failed to create directory */ +#define RAC_ERROR_DIRECTORY_CREATION_FAILED ((rac_result_t) - 189) +/** Directory not found */ +#define RAC_ERROR_DIRECTORY_NOT_FOUND ((rac_result_t) - 190) +/** Invalid file path */ +#define RAC_ERROR_INVALID_PATH ((rac_result_t) - 191) +/** Invalid file name */ +#define RAC_ERROR_INVALID_FILE_NAME ((rac_result_t) - 192) +/** Failed to create temporary file */ +#define RAC_ERROR_TEMP_FILE_CREATION_FAILED ((rac_result_t) - 193) +/** File delete failed (alias) */ +#define RAC_ERROR_FILE_DELETE_FAILED ((rac_result_t) - 187) + +// ============================================================================= +// HARDWARE ERRORS (-220 to -229) +// Mirrors Swift's ErrorCode: Hardware Errors +// ============================================================================= + +/** Hardware is unsupported */ +#define RAC_ERROR_HARDWARE_UNSUPPORTED ((rac_result_t) - 220) +/** Insufficient memory */ +#define RAC_ERROR_INSUFFICIENT_MEMORY ((rac_result_t) - 221) +/** Out of memory (alias) */ +#define RAC_ERROR_OUT_OF_MEMORY ((rac_result_t) - 221) + +// ============================================================================= +// COMPONENT STATE ERRORS (-230 to -249) +// Mirrors Swift's ErrorCode: Component State Errors +// ============================================================================= + +/** Component is not ready */ +#define RAC_ERROR_COMPONENT_NOT_READY ((rac_result_t) - 230) +/** Component is in invalid state */ +#define RAC_ERROR_INVALID_STATE ((rac_result_t) - 231) +/** Service is not available */ +#define RAC_ERROR_SERVICE_NOT_AVAILABLE ((rac_result_t) - 232) +/** Service is busy */ +#define RAC_ERROR_SERVICE_BUSY ((rac_result_t) - 233) +/** Processing failed */ +#define RAC_ERROR_PROCESSING_FAILED ((rac_result_t) - 234) +/** Start operation failed */ +#define RAC_ERROR_START_FAILED ((rac_result_t) - 235) +/** Feature/operation is not supported */ +#define RAC_ERROR_NOT_SUPPORTED ((rac_result_t) - 236) + +// ============================================================================= +// VALIDATION ERRORS (-250 to -279) +// Mirrors Swift's ErrorCode: Validation Errors +// ============================================================================= + +/** Validation failed */ +#define RAC_ERROR_VALIDATION_FAILED ((rac_result_t) - 250) +/** Input is invalid */ +#define RAC_ERROR_INVALID_INPUT ((rac_result_t) - 251) +/** Format is invalid */ +#define RAC_ERROR_INVALID_FORMAT ((rac_result_t) - 252) +/** Input is empty */ +#define RAC_ERROR_EMPTY_INPUT ((rac_result_t) - 253) +/** Text is too long */ +#define RAC_ERROR_TEXT_TOO_LONG ((rac_result_t) - 254) +/** Invalid SSML markup */ +#define RAC_ERROR_INVALID_SSML ((rac_result_t) - 255) +/** Invalid speaking rate */ +#define RAC_ERROR_INVALID_SPEAKING_RATE ((rac_result_t) - 256) +/** Invalid pitch */ +#define RAC_ERROR_INVALID_PITCH ((rac_result_t) - 257) +/** Invalid volume */ +#define RAC_ERROR_INVALID_VOLUME ((rac_result_t) - 258) +/** Invalid argument */ +#define RAC_ERROR_INVALID_ARGUMENT ((rac_result_t) - 259) +/** Null pointer */ +#define RAC_ERROR_NULL_POINTER ((rac_result_t) - 260) +/** Buffer too small */ +#define RAC_ERROR_BUFFER_TOO_SMALL ((rac_result_t) - 261) + +// ============================================================================= +// AUDIO ERRORS (-280 to -299) +// Mirrors Swift's ErrorCode: Audio Errors +// ============================================================================= + +/** Audio format is not supported */ +#define RAC_ERROR_AUDIO_FORMAT_NOT_SUPPORTED ((rac_result_t) - 280) +/** Audio session configuration failed */ +#define RAC_ERROR_AUDIO_SESSION_FAILED ((rac_result_t) - 281) +/** Microphone permission denied */ +#define RAC_ERROR_MICROPHONE_PERMISSION_DENIED ((rac_result_t) - 282) +/** Insufficient audio data */ +#define RAC_ERROR_INSUFFICIENT_AUDIO_DATA ((rac_result_t) - 283) +/** Audio buffer is empty */ +#define RAC_ERROR_EMPTY_AUDIO_BUFFER ((rac_result_t) - 284) +/** Audio session activation failed */ +#define RAC_ERROR_AUDIO_SESSION_ACTIVATION_FAILED ((rac_result_t) - 285) + +// ============================================================================= +// LANGUAGE/VOICE ERRORS (-300 to -319) +// Mirrors Swift's ErrorCode: Language/Voice Errors +// ============================================================================= + +/** Language is not supported */ +#define RAC_ERROR_LANGUAGE_NOT_SUPPORTED ((rac_result_t) - 300) +/** Voice is not available */ +#define RAC_ERROR_VOICE_NOT_AVAILABLE ((rac_result_t) - 301) +/** Streaming is not supported */ +#define RAC_ERROR_STREAMING_NOT_SUPPORTED ((rac_result_t) - 302) +/** Stream was cancelled */ +#define RAC_ERROR_STREAM_CANCELLED ((rac_result_t) - 303) + +// ============================================================================= +// AUTHENTICATION ERRORS (-320 to -329) +// Mirrors Swift's ErrorCode: Authentication Errors +// ============================================================================= + +/** Authentication failed */ +#define RAC_ERROR_AUTHENTICATION_FAILED ((rac_result_t) - 320) +/** Unauthorized access */ +#define RAC_ERROR_UNAUTHORIZED ((rac_result_t) - 321) +/** Access forbidden */ +#define RAC_ERROR_FORBIDDEN ((rac_result_t) - 322) + +// ============================================================================= +// SECURITY ERRORS (-330 to -349) +// Mirrors Swift's ErrorCode: Security Errors +// ============================================================================= + +/** Keychain operation failed */ +#define RAC_ERROR_KEYCHAIN_ERROR ((rac_result_t) - 330) +/** Encoding error */ +#define RAC_ERROR_ENCODING_ERROR ((rac_result_t) - 331) +/** Decoding error */ +#define RAC_ERROR_DECODING_ERROR ((rac_result_t) - 332) +/** Secure storage failed */ +#define RAC_ERROR_SECURE_STORAGE_FAILED ((rac_result_t) - 333) + +// ============================================================================= +// EXTRACTION ERRORS (-350 to -369) +// Mirrors Swift's ErrorCode: Extraction Errors +// ============================================================================= + +/** Extraction failed (JSON, archive, etc.) */ +#define RAC_ERROR_EXTRACTION_FAILED ((rac_result_t) - 350) +/** Checksum mismatch */ +#define RAC_ERROR_CHECKSUM_MISMATCH ((rac_result_t) - 351) +/** Unsupported archive format */ +#define RAC_ERROR_UNSUPPORTED_ARCHIVE ((rac_result_t) - 352) + +// ============================================================================= +// CALIBRATION ERRORS (-370 to -379) +// Mirrors Swift's ErrorCode: Calibration Errors +// ============================================================================= + +/** Calibration failed */ +#define RAC_ERROR_CALIBRATION_FAILED ((rac_result_t) - 370) +/** Calibration timed out */ +#define RAC_ERROR_CALIBRATION_TIMEOUT ((rac_result_t) - 371) + +// ============================================================================= +// CANCELLATION (-380 to -389) +// Mirrors Swift's ErrorCode: Cancellation +// ============================================================================= + +/** Operation was cancelled */ +#define RAC_ERROR_CANCELLED ((rac_result_t) - 380) + +// ============================================================================= +// MODULE/SERVICE ERRORS (-400 to -499) +// ============================================================================= + +/** Module not found */ +#define RAC_ERROR_MODULE_NOT_FOUND ((rac_result_t) - 400) +/** Module already registered */ +#define RAC_ERROR_MODULE_ALREADY_REGISTERED ((rac_result_t) - 401) +/** Module load failed */ +#define RAC_ERROR_MODULE_LOAD_FAILED ((rac_result_t) - 402) +/** Service not found */ +#define RAC_ERROR_SERVICE_NOT_FOUND ((rac_result_t) - 410) +/** Service already registered */ +#define RAC_ERROR_SERVICE_ALREADY_REGISTERED ((rac_result_t) - 411) +/** Service create failed */ +#define RAC_ERROR_SERVICE_CREATE_FAILED ((rac_result_t) - 412) +/** Capability not found */ +#define RAC_ERROR_CAPABILITY_NOT_FOUND ((rac_result_t) - 420) +/** Provider not found */ +#define RAC_ERROR_PROVIDER_NOT_FOUND ((rac_result_t) - 421) +/** No capable provider */ +#define RAC_ERROR_NO_CAPABLE_PROVIDER ((rac_result_t) - 422) +/** Generic not found */ +#define RAC_ERROR_NOT_FOUND ((rac_result_t) - 423) + +// ============================================================================= +// PLATFORM ADAPTER ERRORS (-500 to -599) +// ============================================================================= + +/** Adapter not set */ +#define RAC_ERROR_ADAPTER_NOT_SET ((rac_result_t) - 500) + +// ============================================================================= +// BACKEND ERRORS (-600 to -699) +// ============================================================================= + +/** Backend not found */ +#define RAC_ERROR_BACKEND_NOT_FOUND ((rac_result_t) - 600) +/** Backend not ready */ +#define RAC_ERROR_BACKEND_NOT_READY ((rac_result_t) - 601) +/** Backend init failed */ +#define RAC_ERROR_BACKEND_INIT_FAILED ((rac_result_t) - 602) +/** Backend busy */ +#define RAC_ERROR_BACKEND_BUSY ((rac_result_t) - 603) +/** Invalid handle */ +#define RAC_ERROR_INVALID_HANDLE ((rac_result_t) - 610) + +// ============================================================================= +// EVENT ERRORS (-700 to -799) +// ============================================================================= + +/** Invalid event category */ +#define RAC_ERROR_EVENT_INVALID_CATEGORY ((rac_result_t) - 700) +/** Event subscription failed */ +#define RAC_ERROR_EVENT_SUBSCRIPTION_FAILED ((rac_result_t) - 701) +/** Event publish failed */ +#define RAC_ERROR_EVENT_PUBLISH_FAILED ((rac_result_t) - 702) + +// ============================================================================= +// OTHER ERRORS (-800 to -899) +// Mirrors Swift's ErrorCode: Other Errors +// ============================================================================= + +/** Feature is not implemented */ +#define RAC_ERROR_NOT_IMPLEMENTED ((rac_result_t) - 800) +/** Feature is not available */ +#define RAC_ERROR_FEATURE_NOT_AVAILABLE ((rac_result_t) - 801) +/** Framework is not available */ +#define RAC_ERROR_FRAMEWORK_NOT_AVAILABLE ((rac_result_t) - 802) +/** Unsupported modality */ +#define RAC_ERROR_UNSUPPORTED_MODALITY ((rac_result_t) - 803) +/** Unknown error */ +#define RAC_ERROR_UNKNOWN ((rac_result_t) - 804) +/** Internal error */ +#define RAC_ERROR_INTERNAL ((rac_result_t) - 805) + +// ============================================================================= +// ERROR MESSAGE API +// ============================================================================= + +/** + * Gets a human-readable error message for an error code. + * + * @param error_code The error code to get a message for + * @return A static string describing the error (never NULL) + */ +RAC_API const char* rac_error_message(rac_result_t error_code); + +/** + * Gets the last detailed error message. + * + * This returns additional context beyond the error code, such as file paths + * or specific failure reasons. Returns NULL if no detailed message is set. + * + * @return The last error detail string, or NULL + * + * @note The returned string is thread-local and valid until the next + * RAC function call on the same thread. + */ +RAC_API const char* rac_error_get_details(void); + +/** + * Sets the detailed error message for the current thread. + * + * This is typically called internally by RAC functions to provide + * additional context for errors. + * + * @param details The detail string (will be copied internally) + */ +RAC_API void rac_error_set_details(const char* details); + +/** + * Clears the detailed error message for the current thread. + */ +RAC_API void rac_error_clear_details(void); + +/** + * Checks if an error code is in the commons range (-100 to -999). + * + * @param error_code The error code to check + * @return RAC_TRUE if the error is from commons, RAC_FALSE otherwise + */ +RAC_API rac_bool_t rac_error_is_commons_error(rac_result_t error_code); + +/** + * Checks if an error code is in the core range (0 to -99). + * + * @param error_code The error code to check + * @return RAC_TRUE if the error is from core, RAC_FALSE otherwise + */ +RAC_API rac_bool_t rac_error_is_core_error(rac_result_t error_code); + +/** + * Checks if an error is expected/routine (like cancellation). + * Mirrors Swift's ErrorCode.isExpected property. + * + * @param error_code The error code to check + * @return RAC_TRUE if expected, RAC_FALSE otherwise + */ +RAC_API rac_bool_t rac_error_is_expected(rac_result_t error_code); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_ERROR_H */ diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_events.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_events.h new file mode 100644 index 000000000..96fee47cd --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_events.h @@ -0,0 +1,177 @@ +/** + * @file rac_events.h + * @brief RunAnywhere Commons - Event Publishing and Subscription + * + * C port of Swift's SDKEvent protocol and EventPublisher from: + * Sources/RunAnywhere/Infrastructure/Events/SDKEvent.swift + * Sources/RunAnywhere/Infrastructure/Events/EventPublisher.swift + * + * Events are categorized and can be routed to different destinations + * (public EventBus or analytics). + */ + +#ifndef RAC_EVENTS_H +#define RAC_EVENTS_H + +#include "rac_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// EVENT DESTINATION - Mirrors Swift's EventDestination +// ============================================================================= + +/** + * Where an event should be routed. + * Mirrors Swift's EventDestination enum. + */ +typedef enum rac_event_destination { + /** Only to public EventBus (app developers) */ + RAC_EVENT_DESTINATION_PUBLIC_ONLY = 0, + /** Only to analytics/telemetry (backend) */ + RAC_EVENT_DESTINATION_ANALYTICS_ONLY = 1, + /** Both destinations (default) */ + RAC_EVENT_DESTINATION_ALL = 2, +} rac_event_destination_t; + +// ============================================================================= +// EVENT CATEGORY - Mirrors Swift's EventCategory +// ============================================================================= + +/** + * Event categories for filtering/grouping. + * Mirrors Swift's EventCategory enum. + */ +typedef enum rac_event_category { + RAC_EVENT_CATEGORY_SDK = 0, + RAC_EVENT_CATEGORY_MODEL = 1, + RAC_EVENT_CATEGORY_LLM = 2, + RAC_EVENT_CATEGORY_STT = 3, + RAC_EVENT_CATEGORY_TTS = 4, + RAC_EVENT_CATEGORY_VOICE = 5, + RAC_EVENT_CATEGORY_STORAGE = 6, + RAC_EVENT_CATEGORY_DEVICE = 7, + RAC_EVENT_CATEGORY_NETWORK = 8, + RAC_EVENT_CATEGORY_ERROR = 9, +} rac_event_category_t; + +// ============================================================================= +// EVENT STRUCTURE - Mirrors Swift's SDKEvent protocol +// ============================================================================= + +/** + * Event data structure. + * Mirrors Swift's SDKEvent protocol properties. + */ +typedef struct rac_event { + /** Unique identifier for this event instance */ + const char* id; + + /** Event type string (used for analytics categorization) */ + const char* type; + + /** Category for filtering/routing */ + rac_event_category_t category; + + /** Timestamp in milliseconds since epoch */ + int64_t timestamp_ms; + + /** Optional session ID for grouping related events (can be NULL) */ + const char* session_id; + + /** Where to route this event */ + rac_event_destination_t destination; + + /** Event properties as JSON string (can be NULL) */ + const char* properties_json; +} rac_event_t; + +// ============================================================================= +// EVENT CALLBACK +// ============================================================================= + +/** + * Event callback function type. + * + * @param event The event data (valid only during the callback) + * @param user_data User-provided context data + */ +typedef void (*rac_event_callback_fn)(const rac_event_t* event, void* user_data); + +// ============================================================================= +// EVENT API +// ============================================================================= + +/** + * Subscribes to events of a specific category. + * + * @param category The category to subscribe to + * @param callback The callback function to invoke + * @param user_data User data passed to the callback + * @return Subscription ID (0 on failure), use with rac_event_unsubscribe + * + * @note The callback is invoked on the thread that publishes the event. + * Keep callback execution fast to avoid blocking. + */ +RAC_API uint64_t rac_event_subscribe(rac_event_category_t category, rac_event_callback_fn callback, + void* user_data); + +/** + * Subscribes to all events regardless of category. + * + * @param callback The callback function to invoke + * @param user_data User data passed to the callback + * @return Subscription ID (0 on failure) + */ +RAC_API uint64_t rac_event_subscribe_all(rac_event_callback_fn callback, void* user_data); + +/** + * Unsubscribes from events. + * + * @param subscription_id The subscription ID returned from subscribe + */ +RAC_API void rac_event_unsubscribe(uint64_t subscription_id); + +/** + * Publishes an event to all subscribers. + * + * This is called by the commons library to publish events. + * Swift's EventBridge subscribes to receive and re-publish to Swift consumers. + * + * @param event The event to publish + * @return RAC_SUCCESS on success, or an error code on failure + */ +RAC_API rac_result_t rac_event_publish(const rac_event_t* event); + +/** + * Track an event (convenience function matching Swift's EventPublisher.track). + * + * @param type Event type string + * @param category Event category + * @param destination Where to route this event + * @param properties_json Event properties as JSON (can be NULL) + * @return RAC_SUCCESS on success, or an error code on failure + */ +RAC_API rac_result_t rac_event_track(const char* type, rac_event_category_t category, + rac_event_destination_t destination, + const char* properties_json); + +// ============================================================================= +// UTILITY FUNCTIONS +// ============================================================================= + +/** + * Gets a string name for an event category. + * + * @param category The event category + * @return A string name (never NULL) + */ +RAC_API const char* rac_event_category_name(rac_event_category_t category); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_EVENTS_H */ diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_http_client.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_http_client.h new file mode 100644 index 000000000..c93c303ce --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_http_client.h @@ -0,0 +1,233 @@ +/** + * @file rac_http_client.h + * @brief HTTP client abstraction + * + * Defines a platform-agnostic HTTP interface. Platform SDKs implement + * the actual HTTP transport (URLSession, OkHttp, etc.) and register + * it via callback. + */ + +#ifndef RAC_HTTP_CLIENT_H +#define RAC_HTTP_CLIENT_H + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// HTTP Types +// ============================================================================= + +/** + * @brief HTTP method enum + */ +typedef enum { + RAC_HTTP_GET = 0, + RAC_HTTP_POST = 1, + RAC_HTTP_PUT = 2, + RAC_HTTP_DELETE = 3, + RAC_HTTP_PATCH = 4 +} rac_http_method_t; + +/** + * @brief HTTP header key-value pair + */ +typedef struct { + const char* key; + const char* value; +} rac_http_header_t; + +/** + * @brief HTTP request structure + */ +typedef struct { + rac_http_method_t method; + const char* url; // Full URL + const char* body; // JSON body (can be NULL for GET) + size_t body_length; + rac_http_header_t* headers; + size_t header_count; + int32_t timeout_ms; // Request timeout in milliseconds +} rac_http_request_t; + +/** + * @brief HTTP response structure + */ +typedef struct { + int32_t status_code; // HTTP status code (200, 401, etc.) + char* body; // Response body (caller frees) + size_t body_length; + rac_http_header_t* headers; + size_t header_count; + char* error_message; // Non-HTTP error (network failure, etc.) +} rac_http_response_t; + +// ============================================================================= +// Response Memory Management +// ============================================================================= + +/** + * @brief Free HTTP response + */ +void rac_http_response_free(rac_http_response_t* response); + +// ============================================================================= +// Platform Callback Interface +// ============================================================================= + +/** + * @brief Callback type for receiving HTTP response + * + * @param response The HTTP response (platform must free after callback returns) + * @param user_data Opaque user data passed to request + */ +typedef void (*rac_http_callback_t)(const rac_http_response_t* response, void* user_data); + +/** + * @brief HTTP executor function type + * + * Platform implements this to perform actual HTTP requests. + * Must call callback when request completes (success or failure). + * + * @param request The HTTP request to execute + * @param callback Callback to invoke with response + * @param user_data Opaque user data to pass to callback + */ +typedef void (*rac_http_executor_t)(const rac_http_request_t* request, rac_http_callback_t callback, + void* user_data); + +/** + * @brief Register platform HTTP executor + * + * Platform SDKs must call this during initialization to provide + * their HTTP implementation. + * + * @param executor The executor function + */ +void rac_http_set_executor(rac_http_executor_t executor); + +/** + * @brief Check if HTTP executor is registered + * @return true if executor has been set + */ +bool rac_http_has_executor(void); + +// ============================================================================= +// Request Building Helpers +// ============================================================================= + +/** + * @brief Create a new HTTP request + * @param method HTTP method + * @param url Full URL + * @return New request (caller must free with rac_http_request_free) + */ +rac_http_request_t* rac_http_request_create(rac_http_method_t method, const char* url); + +/** + * @brief Set request body + * @param request The request + * @param body JSON body string + */ +void rac_http_request_set_body(rac_http_request_t* request, const char* body); + +/** + * @brief Add header to request + * @param request The request + * @param key Header key + * @param value Header value + */ +void rac_http_request_add_header(rac_http_request_t* request, const char* key, const char* value); + +/** + * @brief Set request timeout + * @param request The request + * @param timeout_ms Timeout in milliseconds + */ +void rac_http_request_set_timeout(rac_http_request_t* request, int32_t timeout_ms); + +/** + * @brief Free HTTP request + */ +void rac_http_request_free(rac_http_request_t* request); + +// ============================================================================= +// Standard Headers +// ============================================================================= + +/** + * @brief Add standard SDK headers to request + * + * Adds: Content-Type, X-SDK-Client, X-SDK-Version, X-Platform + * + * @param request The request + * @param sdk_version SDK version string + * @param platform Platform string + */ +void rac_http_add_sdk_headers(rac_http_request_t* request, const char* sdk_version, + const char* platform); + +/** + * @brief Add authorization header + * @param request The request + * @param token Bearer token + */ +void rac_http_add_auth_header(rac_http_request_t* request, const char* token); + +/** + * @brief Add API key header (for Supabase compatibility) + * @param request The request + * @param api_key API key + */ +void rac_http_add_api_key_header(rac_http_request_t* request, const char* api_key); + +// ============================================================================= +// High-Level Request Functions +// ============================================================================= + +/** + * @brief Context for async HTTP operations + */ +typedef struct { + void* user_data; + void (*on_success)(const char* response_body, void* user_data); + void (*on_error)(int status_code, const char* error_message, void* user_data); +} rac_http_context_t; + +/** + * @brief Execute HTTP request asynchronously + * + * Uses the registered platform executor. + * + * @param request The request to execute + * @param context Callback context + */ +void rac_http_execute(const rac_http_request_t* request, rac_http_context_t* context); + +/** + * @brief Helper: POST JSON to endpoint + * @param url Full URL + * @param json_body JSON body + * @param auth_token Bearer token (can be NULL) + * @param context Callback context + */ +void rac_http_post_json(const char* url, const char* json_body, const char* auth_token, + rac_http_context_t* context); + +/** + * @brief Helper: GET from endpoint + * @param url Full URL + * @param auth_token Bearer token (can be NULL) + * @param context Callback context + */ +void rac_http_get(const char* url, const char* auth_token, rac_http_context_t* context); + +#ifdef __cplusplus +} +#endif + +#endif // RAC_HTTP_CLIENT_H diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_lifecycle.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_lifecycle.h new file mode 100644 index 000000000..83be4977a --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_lifecycle.h @@ -0,0 +1,290 @@ +/** + * @file rac_lifecycle.h + * @brief RunAnywhere Commons - Lifecycle Management API + * + * C port of Swift's ManagedLifecycle.swift from: + * Sources/RunAnywhere/Core/Capabilities/ManagedLifecycle.swift + * + * Provides unified lifecycle management with integrated event tracking. + * Tracks lifecycle events (load, unload) via EventPublisher. + */ + +#ifndef RAC_LIFECYCLE_H +#define RAC_LIFECYCLE_H + +#include "rac_error.h" +#include "rac_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// TYPES - Mirrors Swift's CapabilityLoadingState +// ============================================================================= + +/** + * @brief Capability loading state + * + * Mirrors Swift's CapabilityLoadingState enum. + */ +typedef enum rac_lifecycle_state { + RAC_LIFECYCLE_STATE_IDLE = 0, /**< Not loaded */ + RAC_LIFECYCLE_STATE_LOADING = 1, /**< Currently loading */ + RAC_LIFECYCLE_STATE_LOADED = 2, /**< Successfully loaded */ + RAC_LIFECYCLE_STATE_FAILED = 3 /**< Load failed */ +} rac_lifecycle_state_t; + +/** + * @brief Resource type for lifecycle tracking + * + * Mirrors Swift's CapabilityResourceType enum. + */ +typedef enum rac_resource_type { + RAC_RESOURCE_TYPE_LLM_MODEL = 0, + RAC_RESOURCE_TYPE_STT_MODEL = 1, + RAC_RESOURCE_TYPE_TTS_VOICE = 2, + RAC_RESOURCE_TYPE_VAD_MODEL = 3, + RAC_RESOURCE_TYPE_DIARIZATION_MODEL = 4 +} rac_resource_type_t; + +/** + * @brief Lifecycle metrics + * + * Mirrors Swift's ModelLifecycleMetrics struct. + */ +typedef struct rac_lifecycle_metrics { + /** Total lifecycle events */ + int32_t total_events; + + /** Start time (ms since epoch) */ + int64_t start_time_ms; + + /** Last event time (ms since epoch, 0 if none) */ + int64_t last_event_time_ms; + + /** Total load attempts */ + int32_t total_loads; + + /** Successful loads */ + int32_t successful_loads; + + /** Failed loads */ + int32_t failed_loads; + + /** Average load time in milliseconds */ + double average_load_time_ms; + + /** Total unloads */ + int32_t total_unloads; +} rac_lifecycle_metrics_t; + +/** + * @brief Lifecycle configuration + */ +typedef struct rac_lifecycle_config { + /** Resource type for event tracking */ + rac_resource_type_t resource_type; + + /** Logger category (can be NULL for default) */ + const char* logger_category; + + /** User data for callbacks */ + void* user_data; +} rac_lifecycle_config_t; + +/** + * @brief Service creation callback + * + * Called by the lifecycle manager to create a service for a given model ID. + * + * @param model_id The model ID to load + * @param user_data User-provided context + * @param out_service Output: Handle to the created service + * @return RAC_SUCCESS or error code + */ +typedef rac_result_t (*rac_lifecycle_create_service_fn)(const char* model_id, void* user_data, + rac_handle_t* out_service); + +/** + * @brief Service destroy callback + * + * Called by the lifecycle manager to destroy a service. + * + * @param service Handle to the service to destroy + * @param user_data User-provided context + */ +typedef void (*rac_lifecycle_destroy_service_fn)(rac_handle_t service, void* user_data); + +// ============================================================================= +// LIFECYCLE API - Mirrors Swift's ManagedLifecycle +// ============================================================================= + +/** + * @brief Create a lifecycle manager + * + * @param config Lifecycle configuration + * @param create_fn Service creation callback + * @param destroy_fn Service destruction callback (can be NULL) + * @param out_handle Output: Handle to the lifecycle manager + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_lifecycle_create(const rac_lifecycle_config_t* config, + rac_lifecycle_create_service_fn create_fn, + rac_lifecycle_destroy_service_fn destroy_fn, + rac_handle_t* out_handle); + +/** + * @brief Load a model with automatic event tracking + * + * Mirrors Swift's ManagedLifecycle.load(_:) + * If already loaded with same ID, skips duplicate load. + * + * @param handle Lifecycle manager handle + * @param model_path File path to the model (used for loading) - REQUIRED + * @param model_id Model identifier for telemetry (e.g., "sherpa-onnx-whisper-tiny.en") + * Optional: if NULL, defaults to model_path + * @param model_name Human-readable model name (e.g., "Sherpa Whisper Tiny (ONNX)") + * Optional: if NULL, defaults to model_id + * @param out_service Output: Handle to the loaded service + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_lifecycle_load(rac_handle_t handle, const char* model_path, + const char* model_id, const char* model_name, + rac_handle_t* out_service); + +/** + * @brief Unload the currently loaded model + * + * Mirrors Swift's ManagedLifecycle.unload() + * + * @param handle Lifecycle manager handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_lifecycle_unload(rac_handle_t handle); + +/** + * @brief Reset all state + * + * Mirrors Swift's ManagedLifecycle.reset() + * + * @param handle Lifecycle manager handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_lifecycle_reset(rac_handle_t handle); + +/** + * @brief Get current lifecycle state + * + * Mirrors Swift's ManagedLifecycle.state + * + * @param handle Lifecycle manager handle + * @return Current state + */ +RAC_API rac_lifecycle_state_t rac_lifecycle_get_state(rac_handle_t handle); + +/** + * @brief Check if a model is loaded + * + * Mirrors Swift's ManagedLifecycle.isLoaded + * + * @param handle Lifecycle manager handle + * @return RAC_TRUE if loaded, RAC_FALSE otherwise + */ +RAC_API rac_bool_t rac_lifecycle_is_loaded(rac_handle_t handle); + +/** + * @brief Get current model ID + * + * Mirrors Swift's ManagedLifecycle.currentModelId + * + * @param handle Lifecycle manager handle + * @return Current model ID (may be NULL if not loaded) + */ +RAC_API const char* rac_lifecycle_get_model_id(rac_handle_t handle); + +/** + * @brief Get current model name (human-readable) + * + * @param handle Lifecycle manager handle + * @return Current model name (may be NULL if not loaded) + */ +RAC_API const char* rac_lifecycle_get_model_name(rac_handle_t handle); + +/** + * @brief Get current service handle + * + * Mirrors Swift's ManagedLifecycle.currentService + * + * @param handle Lifecycle manager handle + * @return Current service handle (may be NULL if not loaded) + */ +RAC_API rac_handle_t rac_lifecycle_get_service(rac_handle_t handle); + +/** + * @brief Require service or return error + * + * Mirrors Swift's ManagedLifecycle.requireService() + * + * @param handle Lifecycle manager handle + * @param out_service Output: Service handle + * @return RAC_SUCCESS or RAC_ERROR_NOT_INITIALIZED if not loaded + */ +RAC_API rac_result_t rac_lifecycle_require_service(rac_handle_t handle, rac_handle_t* out_service); + +/** + * @brief Track an operation error + * + * Mirrors Swift's ManagedLifecycle.trackOperationError(_:operation:) + * + * @param handle Lifecycle manager handle + * @param error_code Error code + * @param operation Operation name + */ +RAC_API void rac_lifecycle_track_error(rac_handle_t handle, rac_result_t error_code, + const char* operation); + +/** + * @brief Get lifecycle metrics + * + * Mirrors Swift's ManagedLifecycle.getLifecycleMetrics() + * + * @param handle Lifecycle manager handle + * @param out_metrics Output: Lifecycle metrics + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_lifecycle_get_metrics(rac_handle_t handle, + rac_lifecycle_metrics_t* out_metrics); + +/** + * @brief Destroy a lifecycle manager + * + * @param handle Lifecycle manager handle + */ +RAC_API void rac_lifecycle_destroy(rac_handle_t handle); + +// ============================================================================= +// CONVENIENCE STATE HELPERS +// ============================================================================= + +/** + * @brief Get state name string + * + * @param state Lifecycle state + * @return Human-readable state name + */ +RAC_API const char* rac_lifecycle_state_name(rac_lifecycle_state_t state); + +/** + * @brief Get resource type name string + * + * @param type Resource type + * @return Human-readable resource type name + */ +RAC_API const char* rac_resource_type_name(rac_resource_type_t type); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_LIFECYCLE_H */ diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_llm.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_llm.h new file mode 100644 index 000000000..2c5708887 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_llm.h @@ -0,0 +1,17 @@ +/** + * @file rac_llm.h + * @brief RunAnywhere Commons - LLM API (Convenience Header) + * + * This header includes both types and service interface for convenience. + * For better separation of concerns, prefer including: + * - rac_llm_types.h for data structures only + * - rac_llm_service.h for the service interface + */ + +#ifndef RAC_LLM_H +#define RAC_LLM_H + +#include "rac_llm_service.h" +#include "rac_llm_types.h" + +#endif /* RAC_LLM_H */ diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_llm_analytics.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_llm_analytics.h new file mode 100644 index 000000000..2513ecdbf --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_llm_analytics.h @@ -0,0 +1,188 @@ +/** + * @file rac_llm_analytics.h + * @brief LLM Generation analytics service - 1:1 port of GenerationAnalyticsService.swift + * + * Tracks generation operations and metrics. + * Lifecycle events are handled by the lifecycle manager. + * + * NOTE: Token estimation uses ~4 chars/token (approximation, not exact tokenizer count). + * Actual token counts may vary depending on the model's tokenizer and input content. + * + * Swift Source: Sources/RunAnywhere/Features/LLM/Analytics/GenerationAnalyticsService.swift + */ + +#ifndef RAC_LLM_ANALYTICS_H +#define RAC_LLM_ANALYTICS_H + +#include "rac_types.h" +#include "rac_llm_metrics.h" +#include "rac_llm_types.h" +#include "rac_model_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// TYPES +// ============================================================================= + +/** + * @brief Opaque handle for LLM analytics service + */ +typedef struct rac_llm_analytics_s* rac_llm_analytics_handle_t; + +// Note: rac_generation_metrics_t is defined in rac_llm_metrics.h + +// ============================================================================= +// LIFECYCLE +// ============================================================================= + +/** + * @brief Create an LLM analytics service instance + * + * @param out_handle Output: Handle to the created service + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_analytics_create(rac_llm_analytics_handle_t* out_handle); + +/** + * @brief Destroy an LLM analytics service instance + * + * @param handle Handle to destroy + */ +RAC_API void rac_llm_analytics_destroy(rac_llm_analytics_handle_t handle); + +// ============================================================================= +// GENERATION TRACKING +// ============================================================================= + +/** + * @brief Start tracking a non-streaming generation + * + * Mirrors Swift's startGeneration() + * + * @param handle Analytics service handle + * @param model_id The model ID being used + * @param framework The inference framework type (can be RAC_INFERENCE_FRAMEWORK_UNKNOWN) + * @param temperature Generation temperature (NULL for default) + * @param max_tokens Maximum tokens to generate (NULL for default) + * @param context_length Context window size (NULL for default) + * @param out_generation_id Output: Generated unique ID (owned, must be freed with rac_free) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_analytics_start_generation( + rac_llm_analytics_handle_t handle, const char* model_id, rac_inference_framework_t framework, + const float* temperature, const int32_t* max_tokens, const int32_t* context_length, + char** out_generation_id); + +/** + * @brief Start tracking a streaming generation + * + * Mirrors Swift's startStreamingGeneration() + * + * @param handle Analytics service handle + * @param model_id The model ID being used + * @param framework The inference framework type + * @param temperature Generation temperature (NULL for default) + * @param max_tokens Maximum tokens to generate (NULL for default) + * @param context_length Context window size (NULL for default) + * @param out_generation_id Output: Generated unique ID (owned, must be freed with rac_free) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_analytics_start_streaming_generation( + rac_llm_analytics_handle_t handle, const char* model_id, rac_inference_framework_t framework, + const float* temperature, const int32_t* max_tokens, const int32_t* context_length, + char** out_generation_id); + +/** + * @brief Track first token for streaming generation (TTFT metric) + * + * Only applicable for streaming generations. Call is ignored for non-streaming. + * + * @param handle Analytics service handle + * @param generation_id The generation ID from start_streaming_generation + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_analytics_track_first_token(rac_llm_analytics_handle_t handle, + const char* generation_id); + +/** + * @brief Track streaming update (analytics only) + * + * Only applicable for streaming generations. + * + * @param handle Analytics service handle + * @param generation_id The generation ID + * @param tokens_generated Number of tokens generated so far + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_analytics_track_streaming_update(rac_llm_analytics_handle_t handle, + const char* generation_id, + int32_t tokens_generated); + +/** + * @brief Complete a generation (works for both streaming and non-streaming) + * + * @param handle Analytics service handle + * @param generation_id The generation ID + * @param input_tokens Number of input tokens processed + * @param output_tokens Number of output tokens generated + * @param model_id The model ID used + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_analytics_complete_generation(rac_llm_analytics_handle_t handle, + const char* generation_id, + int32_t input_tokens, + int32_t output_tokens, + const char* model_id); + +/** + * @brief Track generation failure + * + * @param handle Analytics service handle + * @param generation_id The generation ID + * @param error_code Error code + * @param error_message Error message + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_analytics_track_generation_failed(rac_llm_analytics_handle_t handle, + const char* generation_id, + rac_result_t error_code, + const char* error_message); + +/** + * @brief Track an error during LLM operations + * + * @param handle Analytics service handle + * @param error_code Error code + * @param error_message Error message + * @param operation Operation that failed + * @param model_id Model ID (can be NULL) + * @param generation_id Generation ID (can be NULL) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_analytics_track_error(rac_llm_analytics_handle_t handle, + rac_result_t error_code, + const char* error_message, const char* operation, + const char* model_id, const char* generation_id); + +// ============================================================================= +// METRICS +// ============================================================================= + +/** + * @brief Get current analytics metrics + * + * @param handle Analytics service handle + * @param out_metrics Output: Metrics structure + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_analytics_get_metrics(rac_llm_analytics_handle_t handle, + rac_generation_metrics_t* out_metrics); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_LLM_ANALYTICS_H */ diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_llm_component.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_llm_component.h new file mode 100644 index 000000000..640fbd086 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_llm_component.h @@ -0,0 +1,228 @@ +/** + * @file rac_llm_component.h + * @brief RunAnywhere Commons - LLM Capability Component + * + * C port of Swift's LLMCapability.swift from: + * Sources/RunAnywhere/Features/LLM/LLMCapability.swift + * + * Actor-based LLM capability that owns model lifecycle and generation. + * Uses lifecycle manager for unified lifecycle + analytics handling. + */ + +#ifndef RAC_LLM_COMPONENT_H +#define RAC_LLM_COMPONENT_H + +#include "rac_lifecycle.h" +#include "rac_error.h" +#include "rac_llm_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// NOTE: rac_llm_config_t is defined in rac_llm_types.h (included above) + +// ============================================================================= +// STREAMING CALLBACKS - For component-level streaming +// ============================================================================= + +/** + * @brief Streaming callback for token-by-token generation + * + * @param token The generated token + * @param user_data User-provided context + * @return RAC_TRUE to continue, RAC_FALSE to stop + */ +typedef rac_bool_t (*rac_llm_component_token_callback_fn)(const char* token, void* user_data); + +/** + * @brief Streaming completion callback + * + * Called when streaming is complete with final metrics. + * + * @param result Final generation result with metrics + * @param user_data User-provided context + */ +typedef void (*rac_llm_component_complete_callback_fn)(const rac_llm_result_t* result, + void* user_data); + +/** + * @brief Streaming error callback + * + * Called if streaming fails. + * + * @param error_code Error code + * @param error_message Error message + * @param user_data User-provided context + */ +typedef void (*rac_llm_component_error_callback_fn)(rac_result_t error_code, + const char* error_message, void* user_data); + +// ============================================================================= +// LLM COMPONENT API - Mirrors Swift's LLMCapability +// ============================================================================= + +/** + * @brief Create an LLM capability component + * + * Mirrors Swift's LLMCapability.init() + * + * @param out_handle Output: Handle to the component + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_component_create(rac_handle_t* out_handle); + +/** + * @brief Configure the LLM component + * + * Mirrors Swift's LLMCapability.configure(_:) + * + * @param handle Component handle + * @param config Configuration + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_component_configure(rac_handle_t handle, + const rac_llm_config_t* config); + +/** + * @brief Check if model is loaded + * + * Mirrors Swift's LLMCapability.isModelLoaded + * + * @param handle Component handle + * @return RAC_TRUE if loaded, RAC_FALSE otherwise + */ +RAC_API rac_bool_t rac_llm_component_is_loaded(rac_handle_t handle); + +/** + * @brief Get current model ID + * + * Mirrors Swift's LLMCapability.currentModelId + * + * @param handle Component handle + * @return Current model ID (NULL if not loaded) + */ +RAC_API const char* rac_llm_component_get_model_id(rac_handle_t handle); + +/** + * @brief Load a model + * + * Mirrors Swift's LLMCapability.loadModel(_:) + * + * @param handle Component handle + * @param model_path File path to the model (used for loading) - REQUIRED + * @param model_id Model identifier for telemetry (e.g., "smollm2-360m-q8_0") + * Optional: if NULL, defaults to model_path + * @param model_name Human-readable model name (e.g., "SmolLM2 360M Q8_0") + * Optional: if NULL, defaults to model_id + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_component_load_model(rac_handle_t handle, const char* model_path, + const char* model_id, const char* model_name); + +/** + * @brief Unload the current model + * + * Mirrors Swift's LLMCapability.unload() + * + * @param handle Component handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_component_unload(rac_handle_t handle); + +/** + * @brief Cleanup and reset the component + * + * Mirrors Swift's LLMCapability.cleanup() + * + * @param handle Component handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_component_cleanup(rac_handle_t handle); + +/** + * @brief Cancel ongoing generation + * + * Mirrors Swift's LLMCapability.cancel() + * Best-effort cancellation. + * + * @param handle Component handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_component_cancel(rac_handle_t handle); + +/** + * @brief Generate text (non-streaming) + * + * Mirrors Swift's LLMCapability.generate(_:options:) + * + * @param handle Component handle + * @param prompt Input prompt + * @param options Generation options (can be NULL for defaults) + * @param out_result Output: Generation result + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_component_generate(rac_handle_t handle, const char* prompt, + const rac_llm_options_t* options, + rac_llm_result_t* out_result); + +/** + * @brief Check if streaming is supported + * + * Mirrors Swift's LLMCapability.supportsStreaming + * + * @param handle Component handle + * @return RAC_TRUE if streaming supported, RAC_FALSE otherwise + */ +RAC_API rac_bool_t rac_llm_component_supports_streaming(rac_handle_t handle); + +/** + * @brief Generate text with streaming + * + * Mirrors Swift's LLMCapability.generateStream(_:options:) + * + * @param handle Component handle + * @param prompt Input prompt + * @param options Generation options (can be NULL for defaults) + * @param token_callback Called for each generated token + * @param complete_callback Called when generation completes + * @param error_callback Called on error + * @param user_data User context passed to callbacks + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_component_generate_stream( + rac_handle_t handle, const char* prompt, const rac_llm_options_t* options, + rac_llm_component_token_callback_fn token_callback, + rac_llm_component_complete_callback_fn complete_callback, + rac_llm_component_error_callback_fn error_callback, void* user_data); + +/** + * @brief Get lifecycle state + * + * @param handle Component handle + * @return Current lifecycle state + */ +RAC_API rac_lifecycle_state_t rac_llm_component_get_state(rac_handle_t handle); + +/** + * @brief Get lifecycle metrics + * + * @param handle Component handle + * @param out_metrics Output: Lifecycle metrics + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_component_get_metrics(rac_handle_t handle, + rac_lifecycle_metrics_t* out_metrics); + +/** + * @brief Destroy the LLM component + * + * @param handle Component handle + */ +RAC_API void rac_llm_component_destroy(rac_handle_t handle); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_LLM_COMPONENT_H */ diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_llm_events.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_llm_events.h new file mode 100644 index 000000000..423e51c88 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_llm_events.h @@ -0,0 +1,215 @@ +/** + * @file rac_llm_events.h + * @brief LLM-specific event types - 1:1 port of LLMEvent.swift + * + * All LLM-related events in one place. + * Each event declares its destination (public, analytics, or both). + * + * Swift Source: Sources/RunAnywhere/Features/LLM/Analytics/LLMEvent.swift + */ + +#ifndef RAC_LLM_EVENTS_H +#define RAC_LLM_EVENTS_H + +#include "rac_types.h" +#include "rac_events.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// LLM EVENT TYPES +// ============================================================================= + +/** + * @brief LLM event types enumeration + * Mirrors Swift's LLMEvent cases + */ +typedef enum rac_llm_event_type { + RAC_LLM_EVENT_MODEL_LOAD_STARTED = 0, + RAC_LLM_EVENT_MODEL_LOAD_COMPLETED, + RAC_LLM_EVENT_MODEL_LOAD_FAILED, + RAC_LLM_EVENT_MODEL_UNLOADED, + RAC_LLM_EVENT_MODEL_UNLOAD_STARTED, + RAC_LLM_EVENT_GENERATION_STARTED, + RAC_LLM_EVENT_FIRST_TOKEN, + RAC_LLM_EVENT_STREAMING_UPDATE, + RAC_LLM_EVENT_GENERATION_COMPLETED, + RAC_LLM_EVENT_GENERATION_FAILED, +} rac_llm_event_type_t; + +// ============================================================================= +// LLM EVENT DATA STRUCTURES +// ============================================================================= + +/** + * @brief Model load event data + */ +typedef struct rac_llm_model_load_event { + const char* model_id; + int64_t model_size_bytes; + rac_inference_framework_t framework; + double duration_ms; /**< Only for completed events */ + rac_result_t error_code; /**< Only for failed events */ + const char* error_message; /**< Only for failed events */ +} rac_llm_model_load_event_t; + +/** + * @brief Generation event data + */ +typedef struct rac_llm_generation_event { + const char* generation_id; + const char* model_id; + rac_bool_t is_streaming; + rac_inference_framework_t framework; + + /** For completed events */ + int32_t input_tokens; + int32_t output_tokens; + double duration_ms; + double tokens_per_second; + double time_to_first_token_ms; /**< -1 if not applicable */ + float temperature; + int32_t max_tokens; + int32_t context_length; + + /** For streaming updates */ + int32_t tokens_generated; + + /** For failed events */ + rac_result_t error_code; + const char* error_message; +} rac_llm_generation_event_t; + +// ============================================================================= +// EVENT PUBLISHING FUNCTIONS +// ============================================================================= + +/** + * @brief Publish a model load started event + * + * @param model_id Model identifier + * @param model_size_bytes Size of model in bytes (0 if unknown) + * @param framework Inference framework + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_event_model_load_started(const char* model_id, + int64_t model_size_bytes, + rac_inference_framework_t framework); + +/** + * @brief Publish a model load completed event + * + * @param model_id Model identifier + * @param duration_ms Load duration in milliseconds + * @param model_size_bytes Size of model in bytes + * @param framework Inference framework + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_event_model_load_completed(const char* model_id, double duration_ms, + int64_t model_size_bytes, + rac_inference_framework_t framework); + +/** + * @brief Publish a model load failed event + * + * @param model_id Model identifier + * @param error_code Error code + * @param error_message Error message + * @param framework Inference framework + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_event_model_load_failed(const char* model_id, rac_result_t error_code, + const char* error_message, + rac_inference_framework_t framework); + +/** + * @brief Publish a model unloaded event + * + * @param model_id Model identifier + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_event_model_unloaded(const char* model_id); + +/** + * @brief Publish a generation started event + * + * @param generation_id Generation identifier + * @param model_id Model identifier + * @param is_streaming Whether this is streaming generation + * @param framework Inference framework + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_event_generation_started(const char* generation_id, + const char* model_id, rac_bool_t is_streaming, + rac_inference_framework_t framework); + +/** + * @brief Publish a first token event (streaming only) + * + * @param generation_id Generation identifier + * @param model_id Model identifier + * @param time_to_first_token_ms Time to first token in milliseconds + * @param framework Inference framework + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_event_first_token(const char* generation_id, const char* model_id, + double time_to_first_token_ms, + rac_inference_framework_t framework); + +/** + * @brief Publish a streaming update event + * + * @param generation_id Generation identifier + * @param tokens_generated Number of tokens generated so far + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_event_streaming_update(const char* generation_id, + int32_t tokens_generated); + +/** + * @brief Publish a generation completed event + * + * @param event Generation event data + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_event_generation_completed(const rac_llm_generation_event_t* event); + +/** + * @brief Publish a generation failed event + * + * @param generation_id Generation identifier + * @param error_code Error code + * @param error_message Error message + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_event_generation_failed(const char* generation_id, + rac_result_t error_code, + const char* error_message); + +// ============================================================================= +// UTILITY FUNCTIONS +// ============================================================================= + +/** + * @brief Get the event type string for an LLM event type + * + * @param event_type The LLM event type + * @return Event type string (never NULL) + */ +RAC_API const char* rac_llm_event_type_string(rac_llm_event_type_t event_type); + +/** + * @brief Get the event destination for an LLM event type + * + * @param event_type The LLM event type + * @return Event destination + */ +RAC_API rac_event_destination_t rac_llm_event_destination(rac_llm_event_type_t event_type); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_LLM_EVENTS_H */ diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_llm_llamacpp.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_llm_llamacpp.h new file mode 100644 index 000000000..65973dee0 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_llm_llamacpp.h @@ -0,0 +1,218 @@ +/** + * @file rac_llm_llamacpp.h + * @brief RunAnywhere Core - LlamaCPP Backend RAC API + * + * Direct RAC API export from runanywhere-core's LlamaCPP backend. + * This header defines the public C API for LLM inference using llama.cpp. + * + * Mirrors Swift's LlamaCPPService implementation pattern. + */ + +#ifndef RAC_LLM_LLAMACPP_H +#define RAC_LLM_LLAMACPP_H + +#include "rac_error.h" +#include "rac_types.h" +#include "rac_llm.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// EXPORT MACRO +// ============================================================================= + +#if defined(RAC_LLAMACPP_BUILDING) +#if defined(_WIN32) +#define RAC_LLAMACPP_API __declspec(dllexport) +#elif defined(__GNUC__) || defined(__clang__) +#define RAC_LLAMACPP_API __attribute__((visibility("default"))) +#else +#define RAC_LLAMACPP_API +#endif +#else +#define RAC_LLAMACPP_API +#endif + +// ============================================================================= +// CONFIGURATION - Mirrors Swift's LlamaCPPGenerationConfig +// ============================================================================= + +/** + * LlamaCPP-specific configuration. + * + * Mirrors Swift's LlamaCPPGenerationConfig. + */ +typedef struct rac_llm_llamacpp_config { + /** Context size (0 = auto-detect from model) */ + int32_t context_size; + + /** Number of threads (0 = auto-detect) */ + int32_t num_threads; + + /** Number of layers to offload to GPU (Metal on iOS/macOS) */ + int32_t gpu_layers; + + /** Batch size for prompt processing */ + int32_t batch_size; +} rac_llm_llamacpp_config_t; + +/** + * Default LlamaCPP configuration. + */ +static const rac_llm_llamacpp_config_t RAC_LLM_LLAMACPP_CONFIG_DEFAULT = { + .context_size = 0, // Auto-detect + .num_threads = 0, // Auto-detect + .gpu_layers = -1, // All layers on GPU + .batch_size = 512}; + +// ============================================================================= +// LLAMACPP-SPECIFIC API +// ============================================================================= + +/** + * Creates a LlamaCPP LLM service. + * + * Mirrors Swift's LlamaCPPService.initialize(modelPath:) + * + * @param model_path Path to the GGUF model file + * @param config LlamaCPP-specific configuration (can be NULL for defaults) + * @param out_handle Output: Handle to the created service + * @return RAC_SUCCESS or error code + */ +RAC_LLAMACPP_API rac_result_t rac_llm_llamacpp_create(const char* model_path, + const rac_llm_llamacpp_config_t* config, + rac_handle_t* out_handle); + +/** + * Loads a GGUF model into an existing service. + * + * Mirrors Swift's LlamaCPPService.loadModel(path:config:) + * + * @param handle Service handle + * @param model_path Path to the GGUF model file + * @param config LlamaCPP configuration (can be NULL) + * @return RAC_SUCCESS or error code + */ +RAC_LLAMACPP_API rac_result_t rac_llm_llamacpp_load_model(rac_handle_t handle, + const char* model_path, + const rac_llm_llamacpp_config_t* config); + +/** + * Unloads the current model. + * + * Mirrors Swift's LlamaCPPService.unloadModel() + * + * @param handle Service handle + * @return RAC_SUCCESS or error code + */ +RAC_LLAMACPP_API rac_result_t rac_llm_llamacpp_unload_model(rac_handle_t handle); + +/** + * Checks if a model is loaded. + * + * Mirrors Swift's LlamaCPPService.isModelLoaded + * + * @param handle Service handle + * @return RAC_TRUE if model is loaded, RAC_FALSE otherwise + */ +RAC_LLAMACPP_API rac_bool_t rac_llm_llamacpp_is_model_loaded(rac_handle_t handle); + +/** + * Generates text completion. + * + * Mirrors Swift's LlamaCPPService.generate(prompt:config:) + * + * @param handle Service handle + * @param prompt Input prompt text + * @param options Generation options (can be NULL for defaults) + * @param out_result Output: Generation result (caller must free text with rac_free) + * @return RAC_SUCCESS or error code + */ +RAC_LLAMACPP_API rac_result_t rac_llm_llamacpp_generate(rac_handle_t handle, const char* prompt, + const rac_llm_options_t* options, + rac_llm_result_t* out_result); + +/** + * Streaming text generation callback. + * + * Mirrors Swift's streaming callback pattern. + * + * @param token Generated token string + * @param is_final Whether this is the final token + * @param user_data User-provided context + * @return RAC_TRUE to continue, RAC_FALSE to stop + */ +typedef rac_bool_t (*rac_llm_llamacpp_stream_callback_fn)(const char* token, rac_bool_t is_final, + void* user_data); + +/** + * Generates text with streaming callback. + * + * Mirrors Swift's LlamaCPPService.generateStream(prompt:config:) + * + * @param handle Service handle + * @param prompt Input prompt text + * @param options Generation options + * @param callback Callback for each token + * @param user_data User context passed to callback + * @return RAC_SUCCESS or error code + */ +RAC_LLAMACPP_API rac_result_t rac_llm_llamacpp_generate_stream( + rac_handle_t handle, const char* prompt, const rac_llm_options_t* options, + rac_llm_llamacpp_stream_callback_fn callback, void* user_data); + +/** + * Cancels ongoing generation. + * + * Mirrors Swift's LlamaCPPService.cancel() + * + * @param handle Service handle + */ +RAC_LLAMACPP_API void rac_llm_llamacpp_cancel(rac_handle_t handle); + +/** + * Gets model information as JSON. + * + * @param handle Service handle + * @param out_json Output: JSON string (caller must free with rac_free) + * @return RAC_SUCCESS or error code + */ +RAC_LLAMACPP_API rac_result_t rac_llm_llamacpp_get_model_info(rac_handle_t handle, char** out_json); + +/** + * Destroys a LlamaCPP LLM service. + * + * @param handle Service handle to destroy + */ +RAC_LLAMACPP_API void rac_llm_llamacpp_destroy(rac_handle_t handle); + +// ============================================================================= +// BACKEND REGISTRATION +// ============================================================================= + +/** + * Registers the LlamaCPP backend with the commons module and service registries. + * + * Should be called once during SDK initialization. + * This registers: + * - Module: "llamacpp" with TEXT_GENERATION capability + * - Service provider: LlamaCPP LLM provider + * + * @return RAC_SUCCESS or error code + */ +RAC_LLAMACPP_API rac_result_t rac_backend_llamacpp_register(void); + +/** + * Unregisters the LlamaCPP backend. + * + * @return RAC_SUCCESS or error code + */ +RAC_LLAMACPP_API rac_result_t rac_backend_llamacpp_unregister(void); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_LLM_LLAMACPP_H */ diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_llm_metrics.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_llm_metrics.h new file mode 100644 index 000000000..09794da73 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_llm_metrics.h @@ -0,0 +1,402 @@ +/** + * @file rac_llm_metrics.h + * @brief LLM Streaming Metrics - TTFT and Token Rate Tracking + * + * C port of Swift's StreamingMetricsCollector and GenerationAnalyticsService. + * Swift Source: Sources/RunAnywhere/Features/LLM/LLMCapability.swift (StreamingMetricsCollector) + * Swift Source: Sources/RunAnywhere/Features/LLM/Analytics/GenerationAnalyticsService.swift + * + * IMPORTANT: This is a direct translation of the Swift implementation. + * Do NOT add features not present in the Swift code. + */ + +#ifndef RAC_LLM_METRICS_H +#define RAC_LLM_METRICS_H + +#include "rac_error.h" +#include "rac_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// TYPES - Mirrors Swift's GenerationMetrics and StreamingMetricsCollector +// ============================================================================= + +/** + * @brief Generation metrics snapshot. + * Mirrors Swift's GenerationMetrics struct. + */ +typedef struct rac_generation_metrics { + /** Total generation count */ + int32_t total_generations; + + /** Streaming generation count */ + int32_t streaming_generations; + + /** Non-streaming generation count */ + int32_t non_streaming_generations; + + /** Average time-to-first-token in ms (streaming only) */ + double average_ttft_ms; + + /** Average tokens per second */ + double average_tokens_per_second; + + /** Total input tokens processed */ + int64_t total_input_tokens; + + /** Total output tokens generated */ + int64_t total_output_tokens; + + /** Service start time (Unix timestamp ms) */ + int64_t start_time_ms; + + /** Last event time (Unix timestamp ms) */ + int64_t last_event_time_ms; +} rac_generation_metrics_t; + +/** + * @brief Default generation metrics. + */ +static const rac_generation_metrics_t RAC_GENERATION_METRICS_DEFAULT = { + .total_generations = 0, + .streaming_generations = 0, + .non_streaming_generations = 0, + .average_ttft_ms = 0.0, + .average_tokens_per_second = 0.0, + .total_input_tokens = 0, + .total_output_tokens = 0, + .start_time_ms = 0, + .last_event_time_ms = 0}; + +/** + * @brief Streaming generation result. + * Mirrors Swift's LLMGenerationResult for streaming. + */ +typedef struct rac_streaming_result { + /** Generated text (owned, must be freed) */ + char* text; + + /** Thinking/reasoning content if any (owned, must be freed, can be NULL) */ + char* thinking_content; + + /** Input tokens processed */ + int32_t input_tokens; + + /** Output tokens generated */ + int32_t output_tokens; + + /** Model ID used (owned, must be freed) */ + char* model_id; + + /** Total latency in milliseconds */ + double latency_ms; + + /** Tokens generated per second */ + double tokens_per_second; + + /** Time-to-first-token in milliseconds (0 if not streaming) */ + double ttft_ms; + + /** Thinking tokens (for reasoning models) */ + int32_t thinking_tokens; + + /** Response tokens (excluding thinking) */ + int32_t response_tokens; +} rac_streaming_result_t; + +/** + * @brief Default streaming result. + */ +static const rac_streaming_result_t RAC_STREAMING_RESULT_DEFAULT = {.text = RAC_NULL, + .thinking_content = RAC_NULL, + .input_tokens = 0, + .output_tokens = 0, + .model_id = RAC_NULL, + .latency_ms = 0.0, + .tokens_per_second = 0.0, + .ttft_ms = 0.0, + .thinking_tokens = 0, + .response_tokens = 0}; + +// ============================================================================= +// OPAQUE HANDLES +// ============================================================================= + +/** + * @brief Opaque handle for streaming metrics collector. + */ +typedef struct rac_streaming_metrics_collector* rac_streaming_metrics_handle_t; + +/** + * @brief Opaque handle for generation analytics service. + */ +typedef struct rac_generation_analytics* rac_generation_analytics_handle_t; + +// ============================================================================= +// STREAMING METRICS COLLECTOR API - Mirrors Swift's StreamingMetricsCollector +// ============================================================================= + +/** + * @brief Create a streaming metrics collector. + * + * @param model_id Model ID being used + * @param generation_id Unique generation identifier + * @param prompt_length Length of input prompt (for token estimation) + * @param out_handle Output: Handle to the created collector + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_streaming_metrics_create(const char* model_id, const char* generation_id, + int32_t prompt_length, + rac_streaming_metrics_handle_t* out_handle); + +/** + * @brief Destroy a streaming metrics collector. + * + * @param handle Collector handle + */ +RAC_API void rac_streaming_metrics_destroy(rac_streaming_metrics_handle_t handle); + +/** + * @brief Mark the start of generation. + * + * Mirrors Swift's StreamingMetricsCollector.markStart(). + * + * @param handle Collector handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_streaming_metrics_mark_start(rac_streaming_metrics_handle_t handle); + +/** + * @brief Record a token received during streaming. + * + * Mirrors Swift's StreamingMetricsCollector.recordToken(_:). + * First call records TTFT. + * + * @param handle Collector handle + * @param token Token string received + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_streaming_metrics_record_token(rac_streaming_metrics_handle_t handle, + const char* token); + +/** + * @brief Mark generation as complete. + * + * Mirrors Swift's StreamingMetricsCollector.markComplete(). + * + * @param handle Collector handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_streaming_metrics_mark_complete(rac_streaming_metrics_handle_t handle); + +/** + * @brief Mark generation as failed. + * + * Mirrors Swift's StreamingMetricsCollector.recordError(_:). + * + * @param handle Collector handle + * @param error_code Error code + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_streaming_metrics_mark_failed(rac_streaming_metrics_handle_t handle, + rac_result_t error_code); + +/** + * @brief Get the generation result. + * + * Mirrors Swift's StreamingMetricsCollector.buildResult(). + * Only valid after markComplete() is called. + * + * @param handle Collector handle + * @param out_result Output: Streaming result (must be freed with rac_streaming_result_free) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_streaming_metrics_get_result(rac_streaming_metrics_handle_t handle, + rac_streaming_result_t* out_result); + +/** + * @brief Get current TTFT in milliseconds. + * + * @param handle Collector handle + * @param out_ttft_ms Output: TTFT in ms (0 if first token not yet received) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_streaming_metrics_get_ttft(rac_streaming_metrics_handle_t handle, + double* out_ttft_ms); + +/** + * @brief Get current token count. + * + * @param handle Collector handle + * @param out_token_count Output: Number of tokens recorded + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_streaming_metrics_get_token_count(rac_streaming_metrics_handle_t handle, + int32_t* out_token_count); + +/** + * @brief Get accumulated text. + * + * @param handle Collector handle + * @param out_text Output: Accumulated text (owned, must be freed) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_streaming_metrics_get_text(rac_streaming_metrics_handle_t handle, + char** out_text); + +/** + * @brief Set actual token counts from backend. + * + * Call this with actual token counts from the LLM backend's tokenizer + * to get accurate telemetry instead of character-based estimation. + * + * @param handle Collector handle + * @param input_tokens Actual input/prompt token count (0 to use estimation) + * @param output_tokens Actual output/completion token count (0 to use estimation) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_streaming_metrics_set_token_counts(rac_streaming_metrics_handle_t handle, + int32_t input_tokens, + int32_t output_tokens); + +// ============================================================================= +// GENERATION ANALYTICS SERVICE API - Mirrors Swift's GenerationAnalyticsService +// ============================================================================= + +/** + * @brief Create a generation analytics service. + * + * @param out_handle Output: Handle to the created service + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_generation_analytics_create(rac_generation_analytics_handle_t* out_handle); + +/** + * @brief Destroy a generation analytics service. + * + * @param handle Service handle + */ +RAC_API void rac_generation_analytics_destroy(rac_generation_analytics_handle_t handle); + +/** + * @brief Start tracking a non-streaming generation. + * + * Mirrors Swift's GenerationAnalyticsService.startGeneration(). + * + * @param handle Service handle + * @param generation_id Unique generation identifier + * @param model_id Model ID + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_generation_analytics_start(rac_generation_analytics_handle_t handle, + const char* generation_id, + const char* model_id); + +/** + * @brief Start tracking a streaming generation. + * + * Mirrors Swift's GenerationAnalyticsService.startStreamingGeneration(). + * + * @param handle Service handle + * @param generation_id Unique generation identifier + * @param model_id Model ID + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_generation_analytics_start_streaming( + rac_generation_analytics_handle_t handle, const char* generation_id, const char* model_id); + +/** + * @brief Track first token received (streaming only). + * + * Mirrors Swift's GenerationAnalyticsService.trackFirstToken(). + * + * @param handle Service handle + * @param generation_id Generation identifier + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_generation_analytics_track_first_token( + rac_generation_analytics_handle_t handle, const char* generation_id); + +/** + * @brief Track streaming update. + * + * Mirrors Swift's GenerationAnalyticsService.trackStreamingUpdate(). + * + * @param handle Service handle + * @param generation_id Generation identifier + * @param tokens_generated Number of tokens generated so far + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_generation_analytics_track_streaming_update( + rac_generation_analytics_handle_t handle, const char* generation_id, int32_t tokens_generated); + +/** + * @brief Complete a generation. + * + * Mirrors Swift's GenerationAnalyticsService.completeGeneration(). + * + * @param handle Service handle + * @param generation_id Generation identifier + * @param input_tokens Number of input tokens + * @param output_tokens Number of output tokens + * @param model_id Model ID used + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_generation_analytics_complete(rac_generation_analytics_handle_t handle, + const char* generation_id, + int32_t input_tokens, int32_t output_tokens, + const char* model_id); + +/** + * @brief Track generation failure. + * + * Mirrors Swift's GenerationAnalyticsService.trackGenerationFailed(). + * + * @param handle Service handle + * @param generation_id Generation identifier + * @param error_code Error code + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_generation_analytics_track_failed(rac_generation_analytics_handle_t handle, + const char* generation_id, + rac_result_t error_code); + +/** + * @brief Get aggregated metrics. + * + * Mirrors Swift's GenerationAnalyticsService.getMetrics(). + * + * @param handle Service handle + * @param out_metrics Output: Generation metrics + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_generation_analytics_get_metrics(rac_generation_analytics_handle_t handle, + rac_generation_metrics_t* out_metrics); + +/** + * @brief Reset metrics. + * + * @param handle Service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_generation_analytics_reset(rac_generation_analytics_handle_t handle); + +// ============================================================================= +// MEMORY MANAGEMENT +// ============================================================================= + +/** + * @brief Free a streaming result. + * + * @param result Result to free + */ +RAC_API void rac_streaming_result_free(rac_streaming_result_t* result); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_LLM_METRICS_H */ diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_llm_platform.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_llm_platform.h new file mode 100644 index 000000000..8e2a7e403 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_llm_platform.h @@ -0,0 +1,204 @@ +/** + * @file rac_llm_platform.h + * @brief RunAnywhere Commons - Platform LLM Backend (Apple Foundation Models) + * + * C API for platform-native LLM services. On Apple platforms, this uses + * Foundation Models (Apple Intelligence). The actual implementation is in + * Swift, with C++ providing the registration and callback infrastructure. + * + * This backend follows the same pattern as LlamaCPP and ONNX backends, + * but delegates to Swift via function pointer callbacks since Foundation + * Models is a Swift-only framework. + */ + +#ifndef RAC_LLM_PLATFORM_H +#define RAC_LLM_PLATFORM_H + +#include "rac_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// TYPES +// ============================================================================= + +/** Opaque handle to platform LLM service */ +typedef struct rac_llm_platform* rac_llm_platform_handle_t; + +/** + * Platform LLM configuration. + * Passed during initialization. + */ +typedef struct rac_llm_platform_config { + /** Reserved for future use */ + void* reserved; +} rac_llm_platform_config_t; + +/** + * Generation options for platform LLM. + */ +typedef struct rac_llm_platform_options { + /** Temperature for sampling (0.0 = deterministic, 1.0 = creative) */ + float temperature; + /** Maximum tokens to generate */ + int32_t max_tokens; + /** Reserved for future options */ + void* reserved; +} rac_llm_platform_options_t; + +// ============================================================================= +// SWIFT CALLBACK TYPES +// ============================================================================= + +/** + * Callback to check if platform LLM can handle a model ID. + * Implemented in Swift. + * + * @param model_id Model identifier to check (can be NULL) + * @param user_data User-provided context + * @return RAC_TRUE if this backend can handle the model + */ +typedef rac_bool_t (*rac_platform_llm_can_handle_fn)(const char* model_id, void* user_data); + +/** + * Callback to create platform LLM service. + * Implemented in Swift. + * + * @param model_path Path to model (ignored for built-in) + * @param config Configuration options + * @param user_data User-provided context + * @return Handle to created service (Swift object pointer), or NULL on failure + */ +typedef rac_handle_t (*rac_platform_llm_create_fn)(const char* model_path, + const rac_llm_platform_config_t* config, + void* user_data); + +/** + * Callback to generate text. + * Implemented in Swift. + * + * @param handle Service handle from create + * @param prompt Input prompt + * @param options Generation options + * @param out_response Output: Generated text (caller must free) + * @param user_data User-provided context + * @return RAC_SUCCESS or error code + */ +typedef rac_result_t (*rac_platform_llm_generate_fn)(rac_handle_t handle, const char* prompt, + const rac_llm_platform_options_t* options, + char** out_response, void* user_data); + +/** + * Callback to destroy platform LLM service. + * Implemented in Swift. + * + * @param handle Service handle to destroy + * @param user_data User-provided context + */ +typedef void (*rac_platform_llm_destroy_fn)(rac_handle_t handle, void* user_data); + +/** + * Swift callbacks for platform LLM operations. + */ +typedef struct rac_platform_llm_callbacks { + rac_platform_llm_can_handle_fn can_handle; + rac_platform_llm_create_fn create; + rac_platform_llm_generate_fn generate; + rac_platform_llm_destroy_fn destroy; + void* user_data; +} rac_platform_llm_callbacks_t; + +// ============================================================================= +// CALLBACK REGISTRATION +// ============================================================================= + +/** + * Sets the Swift callbacks for platform LLM operations. + * Must be called before using platform LLM services. + * + * @param callbacks Callback functions (copied internally) + * @return RAC_SUCCESS on success + */ +RAC_API rac_result_t rac_platform_llm_set_callbacks(const rac_platform_llm_callbacks_t* callbacks); + +/** + * Gets the current Swift callbacks. + * + * @return Pointer to callbacks, or NULL if not set + */ +RAC_API const rac_platform_llm_callbacks_t* rac_platform_llm_get_callbacks(void); + +/** + * Checks if Swift callbacks are registered. + * + * @return RAC_TRUE if callbacks are available + */ +RAC_API rac_bool_t rac_platform_llm_is_available(void); + +// ============================================================================= +// SERVICE API +// ============================================================================= + +/** + * Creates a platform LLM service. + * + * @param model_path Path to model (ignored for built-in, can be NULL) + * @param config Configuration options (can be NULL for defaults) + * @param out_handle Output: Service handle + * @return RAC_SUCCESS on success, or error code + */ +RAC_API rac_result_t rac_llm_platform_create(const char* model_path, + const rac_llm_platform_config_t* config, + rac_llm_platform_handle_t* out_handle); + +/** + * Destroys a platform LLM service. + * + * @param handle Service handle to destroy + */ +RAC_API void rac_llm_platform_destroy(rac_llm_platform_handle_t handle); + +/** + * Generates text using platform LLM. + * + * @param handle Service handle + * @param prompt Input prompt + * @param options Generation options (can be NULL for defaults) + * @param out_response Output: Generated text (caller must free with free()) + * @return RAC_SUCCESS on success, or error code + */ +RAC_API rac_result_t rac_llm_platform_generate(rac_llm_platform_handle_t handle, const char* prompt, + const rac_llm_platform_options_t* options, + char** out_response); + +// ============================================================================= +// BACKEND REGISTRATION +// ============================================================================= + +/** + * Registers the Platform backend with the module and service registries. + * + * This registers: + * - Module: "platform" with TEXT_GENERATION and TTS capabilities + * - LLM Provider: "AppleFoundationModels" (priority 50) + * - TTS Provider: "SystemTTS" (priority 10) + * - Built-in model entries for Foundation Models and System TTS + * + * @return RAC_SUCCESS on success, or an error code + */ +RAC_API rac_result_t rac_backend_platform_register(void); + +/** + * Unregisters the Platform backend. + * + * @return RAC_SUCCESS on success, or an error code + */ +RAC_API rac_result_t rac_backend_platform_unregister(void); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_LLM_PLATFORM_H */ diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_llm_service.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_llm_service.h new file mode 100644 index 000000000..cbc7caa86 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_llm_service.h @@ -0,0 +1,163 @@ +/** + * @file rac_llm_service.h + * @brief RunAnywhere Commons - LLM Service Interface + * + * Defines the generic LLM service API and vtable for multi-backend dispatch. + * Backends (LlamaCpp, Platform, ONNX) implement the vtable and register + * with the service registry. + */ + +#ifndef RAC_LLM_SERVICE_H +#define RAC_LLM_SERVICE_H + +#include "rac_error.h" +#include "rac_llm_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// SERVICE VTABLE - Backend implementations provide this +// ============================================================================= + +/** + * LLM Service operations vtable. + * Each backend implements these functions and provides a static vtable. + */ +typedef struct rac_llm_service_ops { + /** Initialize the service with a model path */ + rac_result_t (*initialize)(void* impl, const char* model_path); + + /** Generate text (blocking) */ + rac_result_t (*generate)(void* impl, const char* prompt, const rac_llm_options_t* options, + rac_llm_result_t* out_result); + + /** Generate text with streaming callback */ + rac_result_t (*generate_stream)(void* impl, const char* prompt, + const rac_llm_options_t* options, + rac_llm_stream_callback_fn callback, void* user_data); + + /** Get service info */ + rac_result_t (*get_info)(void* impl, rac_llm_info_t* out_info); + + /** Cancel ongoing generation */ + rac_result_t (*cancel)(void* impl); + + /** Cleanup/unload model (keeps service alive) */ + rac_result_t (*cleanup)(void* impl); + + /** Destroy the service */ + void (*destroy)(void* impl); +} rac_llm_service_ops_t; + +/** + * LLM Service instance. + * Contains vtable pointer and backend-specific implementation. + */ +typedef struct rac_llm_service { + /** Vtable with backend operations */ + const rac_llm_service_ops_t* ops; + + /** Backend-specific implementation handle */ + void* impl; + + /** Model ID for reference */ + const char* model_id; +} rac_llm_service_t; + +// ============================================================================= +// PUBLIC API - Generic service functions +// ============================================================================= + +/** + * @brief Create an LLM service + * + * Routes through service registry to find appropriate backend. + * + * @param model_id Model identifier (registry ID or path to model file) + * @param out_handle Output: Handle to the created service + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_create(const char* model_id, rac_handle_t* out_handle); + +/** + * @brief Initialize an LLM service + * + * @param handle Service handle + * @param model_path Path to the model file (can be NULL) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_initialize(rac_handle_t handle, const char* model_path); + +/** + * @brief Generate text from prompt + * + * @param handle Service handle + * @param prompt Input prompt + * @param options Generation options (can be NULL for defaults) + * @param out_result Output: Generation result (caller must free with rac_llm_result_free) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_generate(rac_handle_t handle, const char* prompt, + const rac_llm_options_t* options, + rac_llm_result_t* out_result); + +/** + * @brief Stream generate text token by token + * + * @param handle Service handle + * @param prompt Input prompt + * @param options Generation options (can be NULL for defaults) + * @param callback Callback for each token + * @param user_data User context passed to callback + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_generate_stream(rac_handle_t handle, const char* prompt, + const rac_llm_options_t* options, + rac_llm_stream_callback_fn callback, void* user_data); + +/** + * @brief Get service information + * + * @param handle Service handle + * @param out_info Output: Service information + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_get_info(rac_handle_t handle, rac_llm_info_t* out_info); + +/** + * @brief Cancel ongoing generation + * + * @param handle Service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_cancel(rac_handle_t handle); + +/** + * @brief Cleanup and release model resources + * + * @param handle Service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_llm_cleanup(rac_handle_t handle); + +/** + * @brief Destroy an LLM service instance + * + * @param handle Service handle to destroy + */ +RAC_API void rac_llm_destroy(rac_handle_t handle); + +/** + * @brief Free an LLM result + * + * @param result Result to free + */ +RAC_API void rac_llm_result_free(rac_llm_result_t* result); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_LLM_SERVICE_H */ diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_llm_structured_output.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_llm_structured_output.h new file mode 100644 index 000000000..699876030 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_llm_structured_output.h @@ -0,0 +1,141 @@ +/** + * @file rac_llm_structured_output.h + * @brief RunAnywhere Commons - LLM Structured Output JSON Parsing + * + * C port of Swift's StructuredOutputHandler.swift from: + * Sources/RunAnywhere/Features/LLM/StructuredOutput/StructuredOutputHandler.swift + * + * IMPORTANT: This is a direct translation of the Swift implementation. + * Do NOT add features not present in the Swift code. + * + * Provides JSON extraction and parsing functions for structured output generation. + */ + +#ifndef RAC_LLM_STRUCTURED_OUTPUT_H +#define RAC_LLM_STRUCTURED_OUTPUT_H + +#include "rac_error.h" +#include "rac_types.h" +#include "rac_llm_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// STRUCTURED OUTPUT API +// ============================================================================= + +/** + * @brief Extract JSON from potentially mixed text + * + * Ported from Swift StructuredOutputHandler.extractJSON(from:) (lines 102-132) + * + * Searches for complete JSON objects or arrays in the given text, + * handling cases where the text contains additional content before/after JSON. + * + * @param text Input text that may contain JSON mixed with other content + * @param out_json Output: Allocated JSON string (caller must free with rac_free) + * @param out_length Output: Length of extracted JSON string (can be NULL) + * @return RAC_SUCCESS if JSON found and extracted, error code otherwise + */ +RAC_API rac_result_t rac_structured_output_extract_json(const char* text, char** out_json, + size_t* out_length); + +/** + * @brief Find complete JSON boundaries in text + * + * Ported from Swift StructuredOutputHandler.findCompleteJSON(in:) (lines 135-176) + * + * Uses a character-by-character state machine to find matching braces/brackets + * while properly handling string escapes and nesting. + * + * @param text Text to search for JSON + * @param out_start Output: Start position of JSON (0-indexed) + * @param out_end Output: End position of JSON (exclusive) + * @return RAC_TRUE if complete JSON found, RAC_FALSE otherwise + */ +RAC_API rac_bool_t rac_structured_output_find_complete_json(const char* text, size_t* out_start, + size_t* out_end); + +/** + * @brief Find matching closing brace for an opening brace + * + * Ported from Swift StructuredOutputHandler.findMatchingBrace(in:startingFrom:) (lines 179-212) + * + * @param text Text to search + * @param start_pos Position of opening brace '{' + * @param out_end_pos Output: Position of matching closing brace '}' + * @return RAC_TRUE if matching brace found, RAC_FALSE otherwise + */ +RAC_API rac_bool_t rac_structured_output_find_matching_brace(const char* text, size_t start_pos, + size_t* out_end_pos); + +/** + * @brief Find matching closing bracket for an opening bracket + * + * Ported from Swift StructuredOutputHandler.findMatchingBracket(in:startingFrom:) (lines 215-248) + * + * @param text Text to search + * @param start_pos Position of opening bracket '[' + * @param out_end_pos Output: Position of matching closing bracket ']' + * @return RAC_TRUE if matching bracket found, RAC_FALSE otherwise + */ +RAC_API rac_bool_t rac_structured_output_find_matching_bracket(const char* text, size_t start_pos, + size_t* out_end_pos); + +/** + * @brief Prepare prompt with structured output instructions + * + * Ported from Swift StructuredOutputHandler.preparePrompt(originalPrompt:config:) (lines 43-82) + * + * Adds JSON schema and generation instructions to the prompt. + * + * @param original_prompt Original user prompt + * @param config Structured output configuration with JSON schema + * @param out_prompt Output: Allocated prepared prompt (caller must free with rac_free) + * @return RAC_SUCCESS on success, error code otherwise + */ +RAC_API rac_result_t rac_structured_output_prepare_prompt( + const char* original_prompt, const rac_structured_output_config_t* config, char** out_prompt); + +/** + * @brief Get system prompt for structured output generation + * + * Ported from Swift StructuredOutputHandler.getSystemPrompt(for:) (lines 10-30) + * + * Generates a system prompt instructing the model to output only valid JSON. + * + * @param json_schema JSON schema describing expected output structure + * @param out_prompt Output: Allocated system prompt (caller must free with rac_free) + * @return RAC_SUCCESS on success, error code otherwise + */ +RAC_API rac_result_t rac_structured_output_get_system_prompt(const char* json_schema, + char** out_prompt); + +/** + * @brief Validate that text contains valid structured output + * + * Ported from Swift StructuredOutputHandler.validateStructuredOutput(text:config:) (lines 264-282) + * + * @param text Text to validate + * @param config Structured output configuration (can be NULL for basic validation) + * @param out_validation Output: Validation result (caller must free extracted_json with rac_free) + * @return RAC_SUCCESS on success, error code otherwise + */ +RAC_API rac_result_t +rac_structured_output_validate(const char* text, const rac_structured_output_config_t* config, + rac_structured_output_validation_t* out_validation); + +/** + * @brief Free structured output validation result + * + * @param validation Validation result to free + */ +RAC_API void rac_structured_output_validation_free(rac_structured_output_validation_t* validation); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_LLM_STRUCTURED_OUTPUT_H */ diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_llm_types.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_llm_types.h new file mode 100644 index 000000000..04a59ca42 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_llm_types.h @@ -0,0 +1,384 @@ +/** + * @file rac_llm_types.h + * @brief RunAnywhere Commons - LLM Types and Data Structures + * + * C port of Swift's LLM Models from: + * Sources/RunAnywhere/Features/LLM/Models/LLMGenerationOptions.swift + * Sources/RunAnywhere/Features/LLM/Models/LLMGenerationResult.swift + * + * This header defines data structures only. For the service interface, + * see rac_llm_service.h. + */ + +#ifndef RAC_LLM_TYPES_H +#define RAC_LLM_TYPES_H + +#include "rac_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// CONFIGURATION - Mirrors Swift's LLMConfiguration +// ============================================================================= + +/** + * @brief LLM component configuration + * + * Mirrors Swift's LLMConfiguration struct exactly. + * See: Sources/RunAnywhere/Features/LLM/Models/LLMConfiguration.swift + */ +typedef struct rac_llm_config { + /** Model ID (optional - uses default if NULL) */ + const char* model_id; + + /** Preferred framework for generation (use RAC_FRAMEWORK_UNKNOWN for auto) */ + int32_t preferred_framework; + + /** Context length - max tokens the model can handle (default: 2048) */ + int32_t context_length; + + /** Temperature for sampling (0.0 - 2.0, default: 0.7) */ + float temperature; + + /** Maximum tokens to generate (default: 100) */ + int32_t max_tokens; + + /** System prompt for generation (can be NULL) */ + const char* system_prompt; + + /** Enable streaming mode (default: true) */ + rac_bool_t streaming_enabled; +} rac_llm_config_t; + +/** + * @brief Default LLM configuration + */ +static const rac_llm_config_t RAC_LLM_CONFIG_DEFAULT = {.model_id = RAC_NULL, + .preferred_framework = + 99, // RAC_FRAMEWORK_UNKNOWN + .context_length = 2048, + .temperature = 0.7f, + .max_tokens = 100, + .system_prompt = RAC_NULL, + .streaming_enabled = RAC_TRUE}; + +// ============================================================================= +// OPTIONS - Mirrors Swift's LLMGenerationOptions +// ============================================================================= + +/** + * @brief LLM generation options + * + * Mirrors Swift's LLMGenerationOptions struct exactly. + * See: Sources/RunAnywhere/Features/LLM/Models/LLMGenerationOptions.swift + */ +typedef struct rac_llm_options { + /** Maximum number of tokens to generate (default: 100) */ + int32_t max_tokens; + + /** Temperature for sampling (0.0 - 2.0, default: 0.8) */ + float temperature; + + /** Top-p sampling parameter (default: 1.0) */ + float top_p; + + /** Stop sequences (null-terminated array, can be NULL) */ + const char* const* stop_sequences; + size_t num_stop_sequences; + + /** Enable streaming mode (default: false) */ + rac_bool_t streaming_enabled; + + /** System prompt (can be NULL) */ + const char* system_prompt; +} rac_llm_options_t; + +/** + * @brief Default LLM generation options + */ +static const rac_llm_options_t RAC_LLM_OPTIONS_DEFAULT = {.max_tokens = 100, + .temperature = 0.8f, + .top_p = 1.0f, + .stop_sequences = RAC_NULL, + .num_stop_sequences = 0, + .streaming_enabled = RAC_FALSE, + .system_prompt = RAC_NULL}; + +// ============================================================================= +// RESULT - Mirrors Swift's LLMGenerationResult +// ============================================================================= + +/** + * @brief LLM generation result + */ +typedef struct rac_llm_result { + /** Generated text (owned, must be freed with rac_free) */ + char* text; + + /** Number of tokens in prompt */ + int32_t prompt_tokens; + + /** Number of tokens generated */ + int32_t completion_tokens; + + /** Total tokens (prompt + completion) */ + int32_t total_tokens; + + /** Time to first token in milliseconds */ + int64_t time_to_first_token_ms; + + /** Total generation time in milliseconds */ + int64_t total_time_ms; + + /** Tokens per second */ + float tokens_per_second; +} rac_llm_result_t; + +// ============================================================================= +// INFO - Mirrors Swift's LLMService properties +// ============================================================================= + +/** + * @brief LLM service handle info + * + * Mirrors Swift's LLMService properties. + */ +typedef struct rac_llm_info { + /** Whether the service is ready for generation (isReady) */ + rac_bool_t is_ready; + + /** Current model identifier (currentModel, can be NULL) */ + const char* current_model; + + /** Context length (contextLength, 0 if unknown) */ + int32_t context_length; + + /** Whether streaming is supported (supportsStreaming) */ + rac_bool_t supports_streaming; +} rac_llm_info_t; + +// ============================================================================= +// CALLBACKS +// ============================================================================= + +/** + * @brief LLM streaming callback + * + * Called for each generated token during streaming. + * Mirrors Swift's onToken callback pattern. + * + * @param token The generated token string + * @param user_data User-provided context + * @return RAC_TRUE to continue, RAC_FALSE to stop generation + */ +typedef rac_bool_t (*rac_llm_stream_callback_fn)(const char* token, void* user_data); + +// ============================================================================= +// THINKING TAG PATTERN - Mirrors Swift's ThinkingTagPattern +// ============================================================================= + +/** + * @brief Pattern for extracting thinking/reasoning content from model output + * + * Mirrors Swift's ThinkingTagPattern struct exactly. + * See: Sources/RunAnywhere/Features/LLM/Models/ThinkingTagPattern.swift + */ +typedef struct rac_thinking_tag_pattern { + /** Opening tag for thinking content (e.g., "") */ + const char* opening_tag; + + /** Closing tag for thinking content (e.g., "") */ + const char* closing_tag; +} rac_thinking_tag_pattern_t; + +/** + * @brief Default thinking tag pattern (DeepSeek/Hermes style) + */ +static const rac_thinking_tag_pattern_t RAC_THINKING_TAG_DEFAULT = {.opening_tag = "", + .closing_tag = ""}; + +/** + * @brief Alternative thinking pattern with full word + */ +static const rac_thinking_tag_pattern_t RAC_THINKING_TAG_FULL = {.opening_tag = "", + .closing_tag = ""}; + +// ============================================================================= +// STRUCTURED OUTPUT - Mirrors Swift's StructuredOutputConfig +// ============================================================================= + +/** + * @brief Structured output configuration + * + * Mirrors Swift's StructuredOutputConfig struct. + * See: Sources/RunAnywhere/Features/LLM/StructuredOutput/Generatable.swift + * + * Note: In C, we pass the JSON schema directly instead of using reflection. + */ +typedef struct rac_structured_output_config { + /** JSON schema for the expected output structure */ + const char* json_schema; + + /** Whether to include the schema in the prompt */ + rac_bool_t include_schema_in_prompt; +} rac_structured_output_config_t; + +/** + * @brief Default structured output configuration + */ +static const rac_structured_output_config_t RAC_STRUCTURED_OUTPUT_DEFAULT = { + .json_schema = RAC_NULL, .include_schema_in_prompt = RAC_TRUE}; + +/** + * @brief Structured output validation result + * + * Mirrors Swift's StructuredOutputValidation struct. + */ +typedef struct rac_structured_output_validation { + /** Whether the output is valid according to the schema */ + rac_bool_t is_valid; + + /** Error message if validation failed (can be NULL) */ + const char* error_message; + + /** Extracted JSON string (can be NULL) */ + char* extracted_json; +} rac_structured_output_validation_t; + +// ============================================================================= +// STREAMING RESULT - Mirrors Swift's LLMStreamingResult +// ============================================================================= + +/** + * @brief Token event during streaming + * + * Provides detailed information about each token during streaming generation. + */ +typedef struct rac_llm_token_event { + /** The generated token text */ + const char* token; + + /** Token index in the sequence */ + int32_t token_index; + + /** Is this the final token? */ + rac_bool_t is_final; + + /** Tokens generated per second so far */ + float tokens_per_second; +} rac_llm_token_event_t; + +/** + * @brief Extended streaming callback with token event details + * + * @param event Token event details + * @param user_data User-provided context + * @return RAC_TRUE to continue, RAC_FALSE to stop generation + */ +typedef rac_bool_t (*rac_llm_token_event_callback_fn)(const rac_llm_token_event_t* event, + void* user_data); + +/** + * @brief Streaming result handle + * + * Opaque handle for managing streaming generation. + * In C++, this wraps the streaming state and provides synchronization. + * + * Note: LLMStreamingResult in Swift returns an AsyncThrowingStream and a Task. + * In C, we use callbacks instead of async streams. + */ +typedef void* rac_llm_stream_handle_t; + +/** + * @brief Streaming generation parameters + * + * Configuration for starting a streaming generation. + */ +typedef struct rac_llm_stream_params { + /** Prompt to generate from */ + const char* prompt; + + /** Generation options */ + rac_llm_options_t options; + + /** Callback for each token */ + rac_llm_stream_callback_fn on_token; + + /** Extended callback with token event details (optional, can be NULL) */ + rac_llm_token_event_callback_fn on_token_event; + + /** User data passed to callbacks */ + void* user_data; + + /** Optional thinking tag pattern to extract thinking content */ + const rac_thinking_tag_pattern_t* thinking_pattern; +} rac_llm_stream_params_t; + +/** + * @brief Streaming generation metrics + * + * Metrics collected during streaming generation. + */ +typedef struct rac_llm_stream_metrics { + /** Time to first token in milliseconds */ + int64_t time_to_first_token_ms; + + /** Total generation time in milliseconds */ + int64_t total_time_ms; + + /** Number of tokens generated */ + int32_t tokens_generated; + + /** Tokens per second */ + float tokens_per_second; + + /** Number of tokens in the prompt */ + int32_t prompt_tokens; + + /** Thinking tokens if thinking pattern was used */ + int32_t thinking_tokens; + + /** Response tokens (excluding thinking) */ + int32_t response_tokens; +} rac_llm_stream_metrics_t; + +/** + * @brief Complete streaming result + * + * Final result after streaming generation is complete. + */ +typedef struct rac_llm_stream_result { + /** Full generated text (owned, must be freed with rac_free) */ + char* text; + + /** Extracted thinking content if pattern was provided (can be NULL) */ + char* thinking_content; + + /** Generation metrics */ + rac_llm_stream_metrics_t metrics; + + /** Error code if generation failed (RAC_SUCCESS on success) */ + rac_result_t error_code; + + /** Error message if generation failed (can be NULL) */ + char* error_message; +} rac_llm_stream_result_t; + +// ============================================================================= +// MEMORY MANAGEMENT +// ============================================================================= + +/** + * @brief Free LLM result resources + * + * @param result Result to free (can be NULL) + */ +RAC_API void rac_llm_result_free(rac_llm_result_t* result); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_LLM_TYPES_H */ diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_log.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_log.h new file mode 100644 index 000000000..9befe34a8 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_log.h @@ -0,0 +1,106 @@ +/** + * @file rac_log.h + * @brief RunAnywhere Commons - Logging API + * + * Provides simple logging macros for the C++ commons layer. + * These are internal logging utilities that route to the platform adapter + * when available, or to stdout/stderr for debugging. + */ + +#ifndef RAC_LOG_H +#define RAC_LOG_H + +#include +#include + +#include "rac_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// LOGGING FUNCTIONS +// ============================================================================= + +/** + * @brief Internal logging function. + * @param level Log level (uses rac_log_level_t from rac_types.h) + * @param category Log category (e.g., "LLM.Analytics") + * @param format Printf-style format string + * @param ... Format arguments + */ +static inline void rac_log_impl(rac_log_level_t level, const char* category, const char* format, + ...) { + // TODO: Route to platform adapter's logging when available + // For now, output to stderr for debugging + + const char* level_str = "???"; + switch (level) { + case RAC_LOG_TRACE: + level_str = "TRACE"; + break; + case RAC_LOG_DEBUG: + level_str = "DEBUG"; + break; + case RAC_LOG_INFO: + level_str = "INFO"; + break; + case RAC_LOG_WARNING: + level_str = "WARN"; + break; + case RAC_LOG_ERROR: + level_str = "ERROR"; + break; + case RAC_LOG_FATAL: + level_str = "FATAL"; + break; + } + + va_list args; + va_start(args, format); + + fprintf(stderr, "[RAC][%s][%s] ", level_str, category); + vfprintf(stderr, format, args); + fprintf(stderr, "\n"); + + va_end(args); +} + +// ============================================================================= +// CONVENIENCE MACROS +// ============================================================================= + +/** + * @brief Log a debug message. + * @param category Log category + * @param ... Printf-style format string and arguments + */ +#define log_debug(category, ...) rac_log_impl(RAC_LOG_DEBUG, category, __VA_ARGS__) + +/** + * @brief Log an info message. + * @param category Log category + * @param ... Printf-style format string and arguments + */ +#define log_info(category, ...) rac_log_impl(RAC_LOG_INFO, category, __VA_ARGS__) + +/** + * @brief Log a warning message. + * @param category Log category + * @param ... Printf-style format string and arguments + */ +#define log_warning(category, ...) rac_log_impl(RAC_LOG_WARNING, category, __VA_ARGS__) + +/** + * @brief Log an error message. + * @param category Log category + * @param ... Printf-style format string and arguments + */ +#define log_error(category, ...) rac_log_impl(RAC_LOG_ERROR, category, __VA_ARGS__) + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_LOG_H */ diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_logger.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_logger.h new file mode 100644 index 000000000..31f76e4f5 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_logger.h @@ -0,0 +1,416 @@ +/** + * @file rac_logger.h + * @brief RunAnywhere Commons - Structured Logging System + * + * Provides a structured logging system that: + * - Routes logs through the platform adapter to Swift/Kotlin + * - Captures source location metadata (file, line, function) + * - Supports log levels, categories, and structured metadata + * - Enables remote telemetry for production error tracking + * + * Usage: + * RAC_LOG_INFO("LLM", "Model loaded successfully"); + * RAC_LOG_ERROR("STT", "Failed to load model: %s", error_msg); + * RAC_LOG_DEBUG("VAD", "Energy level: %.2f", energy); + * + * With metadata: + * rac_log_with_metadata(RAC_LOG_ERROR, "ONNX", "Load failed", + * (rac_log_metadata_t){ + * .model_id = "whisper-tiny", + * .error_code = -100, + * .file = __FILE__, + * .line = __LINE__, + * .function = __func__ + * }); + */ + +#ifndef RAC_LOGGER_H +#define RAC_LOGGER_H + +#include +#include +#include +#include + +#include "rac_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// LOG METADATA STRUCTURE +// ============================================================================= + +/** + * @brief Metadata attached to a log entry. + * + * All fields are optional - set to NULL/0 if not applicable. + * This metadata flows through to Swift/Kotlin for remote telemetry. + */ +typedef struct rac_log_metadata { + // Source location (auto-populated by macros) + const char* file; /**< Source file name (use __FILE__) */ + int32_t line; /**< Source line number (use __LINE__) */ + const char* function; /**< Function name (use __func__) */ + + // Error context + int32_t error_code; /**< Error code if applicable (0 = none) */ + const char* error_msg; /**< Additional error message */ + + // Model context + const char* model_id; /**< Model ID if applicable */ + const char* framework; /**< Framework name (e.g., "sherpa-onnx") */ + + // Custom key-value pairs (for extensibility) + const char* custom_key1; + const char* custom_value1; + const char* custom_key2; + const char* custom_value2; +} rac_log_metadata_t; + +/** Default empty metadata */ +#define RAC_LOG_METADATA_EMPTY \ + (rac_log_metadata_t) { \ + NULL, 0, NULL, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL \ + } + +// ============================================================================= +// CORE LOGGING API +// ============================================================================= + +/** + * @brief Initialize the logging system. + * + * Call this after rac_set_platform_adapter() to enable logging. + * If not called, logs will fall back to stderr. + * + * @param min_level Minimum log level to output + * @return RAC_SUCCESS on success + */ +RAC_API rac_result_t rac_logger_init(rac_log_level_t min_level); + +/** + * @brief Shutdown the logging system. + * + * Flushes any pending logs. + */ +RAC_API void rac_logger_shutdown(void); + +/** + * @brief Set the minimum log level. + * + * Messages below this level will be filtered out. + * + * @param level Minimum log level + */ +RAC_API void rac_logger_set_min_level(rac_log_level_t level); + +/** + * @brief Get the current minimum log level. + * + * @return Current minimum log level + */ +RAC_API rac_log_level_t rac_logger_get_min_level(void); + +/** + * @brief Enable or disable fallback to stderr when platform adapter unavailable. + * + * @param enabled Whether to fallback to stderr (default: true) + */ +RAC_API void rac_logger_set_stderr_fallback(rac_bool_t enabled); + +/** + * @brief Enable or disable ALWAYS logging to stderr (in addition to platform adapter). + * + * When enabled (default: true), logs are ALWAYS written to stderr first, + * then forwarded to the platform adapter if available. This is essential + * for debugging crashes during static initialization before Swift/Kotlin + * is ready to receive logs. + * + * Set to false in production to reduce duplicate logging overhead. + * + * @param enabled Whether to always log to stderr (default: true) + */ +RAC_API void rac_logger_set_stderr_always(rac_bool_t enabled); + +/** + * @brief Log a message with metadata. + * + * This is the main logging function. Use the RAC_LOG_* macros for convenience. + * + * @param level Log level + * @param category Log category (e.g., "LLM", "STT.ONNX") + * @param message Log message (can include printf-style format specifiers) + * @param metadata Optional metadata (can be NULL) + */ +RAC_API void rac_logger_log(rac_log_level_t level, const char* category, const char* message, + const rac_log_metadata_t* metadata); + +/** + * @brief Log a formatted message with metadata. + * + * @param level Log level + * @param category Log category + * @param metadata Optional metadata (can be NULL) + * @param format Printf-style format string + * @param ... Format arguments + */ +RAC_API void rac_logger_logf(rac_log_level_t level, const char* category, + const rac_log_metadata_t* metadata, const char* format, ...); + +/** + * @brief Log a formatted message (variadic version). + * + * @param level Log level + * @param category Log category + * @param metadata Optional metadata + * @param format Printf-style format string + * @param args Variadic arguments + */ +RAC_API void rac_logger_logv(rac_log_level_t level, const char* category, + const rac_log_metadata_t* metadata, const char* format, va_list args); + +// ============================================================================= +// CONVENIENCE MACROS +// ============================================================================= + +/** + * Helper to create metadata with source location. + */ +#define RAC_LOG_META_HERE() \ + (rac_log_metadata_t) { \ + __FILE__, __LINE__, __func__, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL \ + } + +/** + * Helper to create metadata with source location and error code. + */ +#define RAC_LOG_META_ERROR(code, msg) \ + (rac_log_metadata_t) { \ + __FILE__, __LINE__, __func__, (code), (msg), NULL, NULL, NULL, NULL, NULL, NULL \ + } + +/** + * Helper to create metadata with model context. + */ +#define RAC_LOG_META_MODEL(mid, fw) \ + (rac_log_metadata_t) { \ + __FILE__, __LINE__, __func__, 0, NULL, (mid), (fw), NULL, NULL, NULL, NULL \ + } + +// --- Level-specific logging macros with automatic source location --- + +#define RAC_LOG_TRACE(category, ...) \ + do { \ + rac_log_metadata_t _meta = RAC_LOG_META_HERE(); \ + rac_logger_logf(RAC_LOG_TRACE, category, &_meta, __VA_ARGS__); \ + } while (0) + +#define RAC_LOG_DEBUG(category, ...) \ + do { \ + rac_log_metadata_t _meta = RAC_LOG_META_HERE(); \ + rac_logger_logf(RAC_LOG_DEBUG, category, &_meta, __VA_ARGS__); \ + } while (0) + +#define RAC_LOG_INFO(category, ...) \ + do { \ + rac_log_metadata_t _meta = RAC_LOG_META_HERE(); \ + rac_logger_logf(RAC_LOG_INFO, category, &_meta, __VA_ARGS__); \ + } while (0) + +#define RAC_LOG_WARNING(category, ...) \ + do { \ + rac_log_metadata_t _meta = RAC_LOG_META_HERE(); \ + rac_logger_logf(RAC_LOG_WARNING, category, &_meta, __VA_ARGS__); \ + } while (0) + +#define RAC_LOG_ERROR(category, ...) \ + do { \ + rac_log_metadata_t _meta = RAC_LOG_META_HERE(); \ + rac_logger_logf(RAC_LOG_ERROR, category, &_meta, __VA_ARGS__); \ + } while (0) + +#define RAC_LOG_FATAL(category, ...) \ + do { \ + rac_log_metadata_t _meta = RAC_LOG_META_HERE(); \ + rac_logger_logf(RAC_LOG_FATAL, category, &_meta, __VA_ARGS__); \ + } while (0) + +// --- Error logging with code --- + +#define RAC_LOG_ERROR_CODE(category, code, ...) \ + do { \ + rac_log_metadata_t _meta = RAC_LOG_META_ERROR(code, NULL); \ + rac_logger_logf(RAC_LOG_ERROR, category, &_meta, __VA_ARGS__); \ + } while (0) + +// --- Model context logging --- + +#define RAC_LOG_MODEL_INFO(category, model_id, framework, ...) \ + do { \ + rac_log_metadata_t _meta = RAC_LOG_META_MODEL(model_id, framework); \ + rac_logger_logf(RAC_LOG_INFO, category, &_meta, __VA_ARGS__); \ + } while (0) + +#define RAC_LOG_MODEL_ERROR(category, model_id, framework, ...) \ + do { \ + rac_log_metadata_t _meta = RAC_LOG_META_MODEL(model_id, framework); \ + rac_logger_logf(RAC_LOG_ERROR, category, &_meta, __VA_ARGS__); \ + } while (0) + +// ============================================================================= +// LEGACY COMPATIBILITY (maps to new logging system) +// ============================================================================= + +/** + * Legacy log_info macro - maps to RAC_LOG_INFO. + * @deprecated Use RAC_LOG_INFO instead. + */ +#define log_info(category, ...) RAC_LOG_INFO(category, __VA_ARGS__) + +/** + * Legacy log_debug macro - maps to RAC_LOG_DEBUG. + * @deprecated Use RAC_LOG_DEBUG instead. + */ +#define log_debug(category, ...) RAC_LOG_DEBUG(category, __VA_ARGS__) + +/** + * Legacy log_warning macro - maps to RAC_LOG_WARNING. + * @deprecated Use RAC_LOG_WARNING instead. + */ +#define log_warning(category, ...) RAC_LOG_WARNING(category, __VA_ARGS__) + +/** + * Legacy log_error macro - maps to RAC_LOG_ERROR. + * @deprecated Use RAC_LOG_ERROR instead. + */ +#define log_error(category, ...) RAC_LOG_ERROR(category, __VA_ARGS__) + +#ifdef __cplusplus +} +#endif + +// ============================================================================= +// C++ CONVENIENCE CLASS +// ============================================================================= + +#ifdef __cplusplus + +#include +#include + +namespace rac { + +/** + * @brief C++ Logger class for convenient logging with RAII. + * + * Usage: + * rac::Logger log("STT.ONNX"); + * log.info("Model loaded: %s", model_id); + * log.error("Failed with code %d", error_code); + */ +class Logger { + public: + explicit Logger(const char* category) : category_(category) {} + explicit Logger(const std::string& category) : category_(category.c_str()) {} + + void trace(const char* format, ...) const { + va_list args; + va_start(args, format); + rac_logger_logv(RAC_LOG_TRACE, category_, nullptr, format, args); + va_end(args); + } + + void debug(const char* format, ...) const { + va_list args; + va_start(args, format); + rac_logger_logv(RAC_LOG_DEBUG, category_, nullptr, format, args); + va_end(args); + } + + void info(const char* format, ...) const { + va_list args; + va_start(args, format); + rac_logger_logv(RAC_LOG_INFO, category_, nullptr, format, args); + va_end(args); + } + + void warning(const char* format, ...) const { + va_list args; + va_start(args, format); + rac_logger_logv(RAC_LOG_WARNING, category_, nullptr, format, args); + va_end(args); + } + + void error(const char* format, ...) const { + va_list args; + va_start(args, format); + rac_logger_logv(RAC_LOG_ERROR, category_, nullptr, format, args); + va_end(args); + } + + void error(int32_t code, const char* format, ...) const { + rac_log_metadata_t meta = RAC_LOG_METADATA_EMPTY; + meta.error_code = code; + + va_list args; + va_start(args, format); + rac_logger_logv(RAC_LOG_ERROR, category_, &meta, format, args); + va_end(args); + } + + void fatal(const char* format, ...) const { + va_list args; + va_start(args, format); + rac_logger_logv(RAC_LOG_FATAL, category_, nullptr, format, args); + va_end(args); + } + + // Log with model context + void modelInfo(const char* model_id, const char* framework, const char* format, ...) const { + rac_log_metadata_t meta = RAC_LOG_METADATA_EMPTY; + meta.model_id = model_id; + meta.framework = framework; + + va_list args; + va_start(args, format); + rac_logger_logv(RAC_LOG_INFO, category_, &meta, format, args); + va_end(args); + } + + void modelError(const char* model_id, const char* framework, int32_t code, const char* format, + ...) const { + rac_log_metadata_t meta = RAC_LOG_METADATA_EMPTY; + meta.model_id = model_id; + meta.framework = framework; + meta.error_code = code; + + va_list args; + va_start(args, format); + rac_logger_logv(RAC_LOG_ERROR, category_, &meta, format, args); + va_end(args); + } + + private: + const char* category_; +}; + +// Predefined loggers for common categories +namespace log { +inline Logger llm("LLM"); +inline Logger stt("STT"); +inline Logger tts("TTS"); +inline Logger vad("VAD"); +inline Logger onnx("ONNX"); +inline Logger llamacpp("LlamaCpp"); +inline Logger download("Download"); +inline Logger models("Models"); +inline Logger core("Core"); +} // namespace log + +} // namespace rac + +#endif // __cplusplus + +#endif /* RAC_LOGGER_H */ diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_model_assignment.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_model_assignment.h new file mode 100644 index 000000000..7316d3ccd --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_model_assignment.h @@ -0,0 +1,153 @@ +/** + * @file rac_model_assignment.h + * @brief Model Assignment Manager - Fetches models assigned to device from backend + * + * Handles fetching model assignments from the backend API. + * Business logic (caching, JSON parsing, registry saving) is in C++. + * Platform SDKs provide HTTP GET callback for network transport. + * + * Events are emitted via rac_analytics_event_emit(). + */ + +#ifndef RAC_MODEL_ASSIGNMENT_H +#define RAC_MODEL_ASSIGNMENT_H + +#include "rac_types.h" +#include "rac_model_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// CALLBACK TYPES +// ============================================================================= + +/** + * @brief HTTP response for model assignment fetch + */ +typedef struct rac_assignment_http_response { + rac_result_t result; // RAC_SUCCESS on success + int32_t status_code; // HTTP status code (200, 400, etc.) + const char* response_body; // Response JSON (must remain valid during processing) + size_t response_length; // Length of response body + const char* error_message; // Error message (can be NULL) +} rac_assignment_http_response_t; + +/** + * Make HTTP GET request for model assignments + * @param endpoint Endpoint path (e.g., "/api/v1/model-assignments/for-sdk") + * @param requires_auth Whether authentication header is required + * @param out_response Output parameter for response + * @param user_data User-provided context + * @return RAC_SUCCESS on success, error code otherwise + */ +typedef rac_result_t (*rac_assignment_http_get_fn)(const char* endpoint, rac_bool_t requires_auth, + rac_assignment_http_response_t* out_response, + void* user_data); + +/** + * @brief Callback structure for model assignment operations + */ +typedef struct rac_assignment_callbacks { + /** Make HTTP GET request */ + rac_assignment_http_get_fn http_get; + + /** User data passed to all callbacks */ + void* user_data; + + /** If true, automatically fetch models after callbacks are registered */ + rac_bool_t auto_fetch; +} rac_assignment_callbacks_t; + +// ============================================================================= +// MODEL ASSIGNMENT API +// ============================================================================= + +/** + * @brief Set callbacks for model assignment operations + * + * Must be called before any other model assignment functions. + * Typically called during SDK initialization. + * + * @param callbacks Callback structure (copied internally) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t +rac_model_assignment_set_callbacks(const rac_assignment_callbacks_t* callbacks); + +/** + * @brief Fetch model assignments from backend + * + * Fetches models assigned to this device from the backend API. + * Results are cached for cache_timeout_seconds. + * + * Business logic: + * 1. Check cache if not force_refresh + * 2. Get device info (via callback) + * 3. Build endpoint URL + * 4. Make HTTP GET (via callback) + * 5. Parse JSON response + * 6. Save models to registry + * 7. Update cache + * 8. Emit analytics event + * + * @param force_refresh If true, bypass cache + * @param out_models Output array of model infos (caller must free with rac_model_info_array_free) + * @param out_count Number of models returned + * @return RAC_SUCCESS on success, error code otherwise + */ +RAC_API rac_result_t rac_model_assignment_fetch(rac_bool_t force_refresh, + rac_model_info_t*** out_models, size_t* out_count); + +/** + * @brief Get cached model assignments for a specific framework + * + * Filters cached models by framework. Does not make network request. + * Call rac_model_assignment_fetch first to populate cache. + * + * @param framework Framework to filter by + * @param out_models Output array of model infos (caller must free) + * @param out_count Number of models returned + * @return RAC_SUCCESS on success, error code otherwise + */ +RAC_API rac_result_t rac_model_assignment_get_by_framework(rac_inference_framework_t framework, + rac_model_info_t*** out_models, + size_t* out_count); + +/** + * @brief Get cached model assignments for a specific category + * + * Filters cached models by category. Does not make network request. + * Call rac_model_assignment_fetch first to populate cache. + * + * @param category Category to filter by + * @param out_models Output array of model infos (caller must free) + * @param out_count Number of models returned + * @return RAC_SUCCESS on success, error code otherwise + */ +RAC_API rac_result_t rac_model_assignment_get_by_category(rac_model_category_t category, + rac_model_info_t*** out_models, + size_t* out_count); + +/** + * @brief Clear model assignment cache + * + * Clears the in-memory cache. Next fetch will make network request. + */ +RAC_API void rac_model_assignment_clear_cache(void); + +/** + * @brief Set cache timeout in seconds + * + * Default is 3600 (1 hour). + * + * @param timeout_seconds Cache timeout in seconds + */ +RAC_API void rac_model_assignment_set_cache_timeout(uint32_t timeout_seconds); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_MODEL_ASSIGNMENT_H */ diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_model_paths.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_model_paths.h new file mode 100644 index 000000000..67c42b4d7 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_model_paths.h @@ -0,0 +1,258 @@ +/** + * @file rac_model_paths.h + * @brief Model Path Utilities - Centralized Path Calculation + * + * C port of Swift's ModelPathUtils from: + * Sources/RunAnywhere/Infrastructure/ModelManagement/Utilities/ModelPathUtils.swift + * + * Path structure: `{base_dir}/RunAnywhere/Models/{framework}/{modelId}/` + * + * IMPORTANT: This is a direct translation of the Swift implementation. + * Do NOT add features not present in the Swift code. + */ + +#ifndef RAC_MODEL_PATHS_H +#define RAC_MODEL_PATHS_H + +#include + +#include "rac_error.h" +#include "rac_types.h" +#include "rac_model_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// CONFIGURATION +// ============================================================================= + +/** + * @brief Set the base directory for model storage. + * + * This must be called before using any path utilities. + * On iOS, this would typically be the Documents directory. + * The Swift platform adapter should call this during initialization. + * + * @param base_dir Base directory path (e.g., + * "/var/mobile/Containers/Data/Application/.../Documents") + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_model_paths_set_base_dir(const char* base_dir); + +/** + * @brief Get the configured base directory. + * + * @return Base directory path, or NULL if not configured + */ +RAC_API const char* rac_model_paths_get_base_dir(void); + +// ============================================================================= +// BASE DIRECTORIES - Mirrors ModelPathUtils base directory methods +// ============================================================================= + +/** + * @brief Get the base RunAnywhere directory. + * Mirrors Swift's ModelPathUtils.getBaseDirectory(). + * + * @param out_path Output buffer for path + * @param path_size Size of output buffer + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_model_paths_get_base_directory(char* out_path, size_t path_size); + +/** + * @brief Get the models directory. + * Mirrors Swift's ModelPathUtils.getModelsDirectory(). + * + * Returns: `{base_dir}/RunAnywhere/Models/` + * + * @param out_path Output buffer for path + * @param path_size Size of output buffer + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_model_paths_get_models_directory(char* out_path, size_t path_size); + +// ============================================================================= +// FRAMEWORK-SPECIFIC PATHS - Mirrors ModelPathUtils framework methods +// ============================================================================= + +/** + * @brief Get the directory for a specific framework. + * Mirrors Swift's ModelPathUtils.getFrameworkDirectory(framework:). + * + * Returns: `{base_dir}/RunAnywhere/Models/{framework}/` + * + * @param framework Inference framework + * @param out_path Output buffer for path + * @param path_size Size of output buffer + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_model_paths_get_framework_directory(rac_inference_framework_t framework, + char* out_path, size_t path_size); + +/** + * @brief Get the folder for a specific model. + * Mirrors Swift's ModelPathUtils.getModelFolder(modelId:framework:). + * + * Returns: `{base_dir}/RunAnywhere/Models/{framework}/{modelId}/` + * + * @param model_id Model identifier + * @param framework Inference framework + * @param out_path Output buffer for path + * @param path_size Size of output buffer + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_model_paths_get_model_folder(const char* model_id, + rac_inference_framework_t framework, + char* out_path, size_t path_size); + +// ============================================================================= +// MODEL FILE PATHS - Mirrors ModelPathUtils file path methods +// ============================================================================= + +/** + * @brief Get the full path to a model file. + * Mirrors Swift's ModelPathUtils.getModelFilePath(modelId:framework:format:). + * + * Returns: `{base_dir}/RunAnywhere/Models/{framework}/{modelId}/{modelId}.{format}` + * + * @param model_id Model identifier + * @param framework Inference framework + * @param format Model format + * @param out_path Output buffer for path + * @param path_size Size of output buffer + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_model_paths_get_model_file_path(const char* model_id, + rac_inference_framework_t framework, + rac_model_format_t format, char* out_path, + size_t path_size); + +/** + * @brief Get the expected model path for a model. + * Mirrors Swift's ModelPathUtils.getExpectedModelPath(modelId:framework:format:). + * + * For directory-based frameworks (e.g., ONNX), returns the model folder. + * For single-file frameworks (e.g., LlamaCpp), returns the model file path. + * + * @param model_id Model identifier + * @param framework Inference framework + * @param format Model format + * @param out_path Output buffer for path + * @param path_size Size of output buffer + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_model_paths_get_expected_model_path(const char* model_id, + rac_inference_framework_t framework, + rac_model_format_t format, + char* out_path, size_t path_size); + +/** + * @brief Get the model path from model info. + * Mirrors Swift's ModelPathUtils.getModelPath(modelInfo:). + * + * @param model_info Model information + * @param out_path Output buffer for path + * @param path_size Size of output buffer + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_model_paths_get_model_path(const rac_model_info_t* model_info, + char* out_path, size_t path_size); + +// ============================================================================= +// OTHER DIRECTORIES - Mirrors ModelPathUtils other directory methods +// ============================================================================= + +/** + * @brief Get the cache directory. + * Mirrors Swift's ModelPathUtils.getCacheDirectory(). + * + * Returns: `{base_dir}/RunAnywhere/Cache/` + * + * @param out_path Output buffer for path + * @param path_size Size of output buffer + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_model_paths_get_cache_directory(char* out_path, size_t path_size); + +/** + * @brief Get the temporary files directory. + * Mirrors Swift's ModelPathUtils.getTempDirectory(). + * + * Returns: `{base_dir}/RunAnywhere/Temp/` + * + * @param out_path Output buffer for path + * @param path_size Size of output buffer + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_model_paths_get_temp_directory(char* out_path, size_t path_size); + +/** + * @brief Get the downloads directory. + * Mirrors Swift's ModelPathUtils.getDownloadsDirectory(). + * + * Returns: `{base_dir}/RunAnywhere/Downloads/` + * + * @param out_path Output buffer for path + * @param path_size Size of output buffer + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_model_paths_get_downloads_directory(char* out_path, size_t path_size); + +// ============================================================================= +// PATH ANALYSIS - Mirrors ModelPathUtils analysis methods +// ============================================================================= + +/** + * @brief Extract model ID from a file path. + * Mirrors Swift's ModelPathUtils.extractModelId(from:). + * + * @param path File path + * @param out_model_id Output buffer for model ID (can be NULL to just check if valid) + * @param model_id_size Size of output buffer + * @return RAC_SUCCESS if model ID found, RAC_ERROR_NOT_FOUND otherwise + */ +RAC_API rac_result_t rac_model_paths_extract_model_id(const char* path, char* out_model_id, + size_t model_id_size); + +/** + * @brief Extract framework from a file path. + * Mirrors Swift's ModelPathUtils.extractFramework(from:). + * + * @param path File path + * @param out_framework Output: The framework if found + * @return RAC_SUCCESS if framework found, RAC_ERROR_NOT_FOUND otherwise + */ +RAC_API rac_result_t rac_model_paths_extract_framework(const char* path, + rac_inference_framework_t* out_framework); + +/** + * @brief Check if a path is within the models directory. + * Mirrors Swift's ModelPathUtils.isModelPath(_:). + * + * @param path File path to check + * @return RAC_TRUE if path is within models directory, RAC_FALSE otherwise + */ +RAC_API rac_bool_t rac_model_paths_is_model_path(const char* path); + +// ============================================================================= +// PATH UTILITIES +// ============================================================================= + +// NOTE: rac_model_format_extension is declared in rac_model_types.h + +/** + * @brief Get raw value string for a framework. + * + * @param framework Inference framework + * @return Raw value string (e.g., "LlamaCpp", "ONNX") + */ +RAC_API const char* rac_framework_raw_value(rac_inference_framework_t framework); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_MODEL_PATHS_H */ diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_model_registry.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_model_registry.h new file mode 100644 index 000000000..fac0a8fb2 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_model_registry.h @@ -0,0 +1,357 @@ +/** + * @file rac_model_registry.h + * @brief Model Information Registry - In-Memory Model Metadata Management + * + * C port of Swift's ModelInfoService and ModelInfo structures. + * Swift Source: Sources/RunAnywhere/Infrastructure/ModelManagement/Services/ModelInfoService.swift + * Swift Source: Sources/RunAnywhere/Infrastructure/ModelManagement/Models/Domain/ModelInfo.swift + * + * IMPORTANT: This is a direct translation of the Swift implementation. + * Do NOT add features not present in the Swift code. + */ + +#ifndef RAC_MODEL_REGISTRY_H +#define RAC_MODEL_REGISTRY_H + +#include "rac_error.h" +#include "rac_types.h" +#include "rac_model_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// TYPES - Uses types from rac_model_types.h +// ============================================================================= + +// NOTE: All model types (rac_model_category_t, rac_model_format_t, +// rac_inference_framework_t, rac_model_source_t, rac_artifact_type_kind_t, +// rac_model_info_t) are defined in rac_model_types.h + +// ============================================================================= +// OPAQUE HANDLE +// ============================================================================= + +/** + * @brief Opaque handle for model registry instance. + */ +typedef struct rac_model_registry* rac_model_registry_handle_t; + +// ============================================================================= +// LIFECYCLE API +// ============================================================================= + +/** + * @brief Create a model registry instance. + * + * @param out_handle Output: Handle to the created registry + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_model_registry_create(rac_model_registry_handle_t* out_handle); + +/** + * @brief Destroy a model registry instance. + * + * @param handle Registry handle + */ +RAC_API void rac_model_registry_destroy(rac_model_registry_handle_t handle); + +// ============================================================================= +// MODEL INFO API - Mirrors Swift's ModelInfoService +// ============================================================================= + +/** + * @brief Save model metadata. + * + * Mirrors Swift's ModelInfoService.saveModel(_:). + * + * @param handle Registry handle + * @param model Model info to save + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_model_registry_save(rac_model_registry_handle_t handle, + const rac_model_info_t* model); + +/** + * @brief Get model metadata by ID. + * + * Mirrors Swift's ModelInfoService.getModel(by:). + * + * @param handle Registry handle + * @param model_id Model identifier + * @param out_model Output: Model info (owned, must be freed with rac_model_info_free) + * @return RAC_SUCCESS, RAC_ERROR_NOT_FOUND, or other error code + */ +RAC_API rac_result_t rac_model_registry_get(rac_model_registry_handle_t handle, + const char* model_id, rac_model_info_t** out_model); + +/** + * @brief Load all stored models. + * + * Mirrors Swift's ModelInfoService.loadStoredModels(). + * + * @param handle Registry handle + * @param out_models Output: Array of model info (owned, each must be freed) + * @param out_count Output: Number of models + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_model_registry_get_all(rac_model_registry_handle_t handle, + rac_model_info_t*** out_models, size_t* out_count); + +/** + * @brief Load models for specific frameworks. + * + * Mirrors Swift's ModelInfoService.loadModels(for:). + * + * @param handle Registry handle + * @param frameworks Array of frameworks to filter by + * @param framework_count Number of frameworks + * @param out_models Output: Array of model info (owned, each must be freed) + * @param out_count Output: Number of models + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_model_registry_get_by_frameworks( + rac_model_registry_handle_t handle, const rac_inference_framework_t* frameworks, + size_t framework_count, rac_model_info_t*** out_models, size_t* out_count); + +/** + * @brief Update model last used date. + * + * Mirrors Swift's ModelInfoService.updateLastUsed(for:). + * Also increments usage count. + * + * @param handle Registry handle + * @param model_id Model identifier + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_model_registry_update_last_used(rac_model_registry_handle_t handle, + const char* model_id); + +/** + * @brief Remove model metadata. + * + * Mirrors Swift's ModelInfoService.removeModel(_:). + * + * @param handle Registry handle + * @param model_id Model identifier + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_model_registry_remove(rac_model_registry_handle_t handle, + const char* model_id); + +/** + * @brief Get downloaded models. + * + * Mirrors Swift's ModelInfoService.getDownloadedModels(). + * + * @param handle Registry handle + * @param out_models Output: Array of model info (owned, each must be freed) + * @param out_count Output: Number of models + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_model_registry_get_downloaded(rac_model_registry_handle_t handle, + rac_model_info_t*** out_models, + size_t* out_count); + +/** + * @brief Update download status for a model. + * + * Mirrors Swift's ModelInfoService.updateDownloadStatus(for:localPath:). + * + * @param handle Registry handle + * @param model_id Model identifier + * @param local_path Path to downloaded model (can be NULL to clear) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_model_registry_update_download_status(rac_model_registry_handle_t handle, + const char* model_id, + const char* local_path); + +// ============================================================================= +// QUERY HELPERS +// ============================================================================= + +/** + * @brief Check if a model is downloaded and available. + * + * Mirrors Swift's ModelInfo.isDownloaded computed property. + * + * @param model Model info + * @return RAC_TRUE if downloaded, RAC_FALSE otherwise + */ +RAC_API rac_bool_t rac_model_info_is_downloaded(const rac_model_info_t* model); + +/** + * @brief Check if model category requires context length. + * + * @param category Model category + * @return RAC_TRUE if requires context length + */ +RAC_API rac_bool_t rac_model_category_requires_context_length(rac_model_category_t category); + +/** + * @brief Check if model category supports thinking. + * + * @param category Model category + * @return RAC_TRUE if supports thinking + */ +RAC_API rac_bool_t rac_model_category_supports_thinking(rac_model_category_t category); + +/** + * @brief Infer artifact type from URL and format. + * + * Mirrors Swift's ModelArtifactType.infer(from:format:). + * + * @param url Download URL (can be NULL) + * @param format Model format + * @return Inferred artifact type kind + */ +RAC_API rac_artifact_type_kind_t rac_model_infer_artifact_type(const char* url, + rac_model_format_t format); + +// ============================================================================= +// MEMORY MANAGEMENT +// ============================================================================= + +/** + * @brief Allocate a new model info struct. + * + * @return Allocated model info (must be freed with rac_model_info_free) + */ +RAC_API rac_model_info_t* rac_model_info_alloc(void); + +/** + * @brief Free a model info struct and its contents. + * + * @param model Model info to free + */ +RAC_API void rac_model_info_free(rac_model_info_t* model); + +/** + * @brief Free an array of model info structs. + * + * @param models Array of model info pointers + * @param count Number of models + */ +RAC_API void rac_model_info_array_free(rac_model_info_t** models, size_t count); + +/** + * @brief Copy a model info struct. + * + * @param model Model info to copy + * @return Deep copy (must be freed with rac_model_info_free) + */ +RAC_API rac_model_info_t* rac_model_info_copy(const rac_model_info_t* model); + +// ============================================================================= +// MODEL DISCOVERY - Scan file system for downloaded models +// ============================================================================= + +/** + * @brief Callback to list directory contents + * @param path Directory path + * @param out_entries Output: Array of entry names (allocated by callback) + * @param out_count Output: Number of entries + * @param user_data User context + * @return RAC_SUCCESS or error code + */ +typedef rac_result_t (*rac_list_directory_fn)(const char* path, char*** out_entries, + size_t* out_count, void* user_data); + +/** + * @brief Callback to free directory entries + * @param entries Array of entry names + * @param count Number of entries + * @param user_data User context + */ +typedef void (*rac_free_directory_entries_fn)(char** entries, size_t count, void* user_data); + +/** + * @brief Callback to check if path is a directory + * @param path Path to check + * @param user_data User context + * @return RAC_TRUE if directory, RAC_FALSE otherwise + */ +typedef rac_bool_t (*rac_is_directory_fn)(const char* path, void* user_data); + +/** + * @brief Callback to check if path exists + * @param path Path to check + * @param user_data User context + * @return RAC_TRUE if exists + */ +typedef rac_bool_t (*rac_path_exists_discovery_fn)(const char* path, void* user_data); + +/** + * @brief Callback to check if file has model extension + * @param path File path + * @param framework Expected framework + * @param user_data User context + * @return RAC_TRUE if valid model file + */ +typedef rac_bool_t (*rac_is_model_file_fn)(const char* path, rac_inference_framework_t framework, + void* user_data); + +/** + * @brief Callbacks for model discovery file operations + */ +typedef struct { + rac_list_directory_fn list_directory; + rac_free_directory_entries_fn free_entries; + rac_is_directory_fn is_directory; + rac_path_exists_discovery_fn path_exists; + rac_is_model_file_fn is_model_file; + void* user_data; +} rac_discovery_callbacks_t; + +/** + * @brief Discovery result for a single model + */ +typedef struct { + /** Model ID that was discovered */ + const char* model_id; + /** Path where model was found */ + const char* local_path; + /** Framework of the model */ + rac_inference_framework_t framework; +} rac_discovered_model_t; + +/** + * @brief Result of model discovery scan + */ +typedef struct { + /** Number of models discovered as downloaded */ + size_t discovered_count; + /** Array of discovered models */ + rac_discovered_model_t* discovered_models; + /** Number of unregistered model folders found */ + size_t unregistered_count; +} rac_discovery_result_t; + +/** + * @brief Discover downloaded models on the file system. + * + * Scans the models directory for each framework, checks if folders + * contain valid model files, and updates the registry for registered models. + * + * @param handle Registry handle + * @param callbacks Platform file operation callbacks + * @param out_result Output: Discovery result (caller must call rac_discovery_result_free) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_model_registry_discover_downloaded( + rac_model_registry_handle_t handle, const rac_discovery_callbacks_t* callbacks, + rac_discovery_result_t* out_result); + +/** + * @brief Free discovery result + * @param result Discovery result to free + */ +RAC_API void rac_discovery_result_free(rac_discovery_result_t* result); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_MODEL_REGISTRY_H */ diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_model_strategy.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_model_strategy.h new file mode 100644 index 000000000..73454a18d --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_model_strategy.h @@ -0,0 +1,374 @@ +/** + * @file rac_model_strategy.h + * @brief Model Storage and Download Strategy Protocols + * + * Defines callback-based protocols for backend-specific model handling: + * - Storage Strategy: How models are stored, detected, and validated + * - Download Strategy: How models are downloaded and post-processed + * + * Each backend (ONNX, LlamaCPP, etc.) registers its strategies during + * backend registration. The SDK uses these strategies for model management. + * + * Architecture: + * - Strategies are registered per-framework via rac_model_strategy_register() + * - Swift/platform code provides file system operations via callbacks + * - Business logic (path resolution, validation, extraction) lives in C++ + */ + +#ifndef RAC_MODEL_STRATEGY_H +#define RAC_MODEL_STRATEGY_H + +#include +#include + +#include "rac_error.h" +#include "rac_types.h" +#include "rac_model_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// STORAGE STRATEGY - How models are stored and detected on disk +// ============================================================================= + +/** + * @brief Model storage details returned by storage strategy + */ +typedef struct { + /** Model format detected */ + rac_model_format_t format; + + /** Total size on disk in bytes */ + int64_t total_size; + + /** Number of files in the model directory */ + int file_count; + + /** Primary model file name (e.g., "model.onnx") - owned, must free */ + char* primary_file; + + /** Whether this is a directory-based model (vs single file) */ + rac_bool_t is_directory_based; + + /** Whether the model storage is valid/complete */ + rac_bool_t is_valid; +} rac_model_storage_details_t; + +/** + * @brief Free storage details resources + */ +RAC_API void rac_model_storage_details_free(rac_model_storage_details_t* details); + +/** + * @brief Storage strategy callbacks - implemented by backend + * + * These callbacks define how a backend handles model storage detection. + * Each backend registers these during rac_backend_xxx_register(). + */ +typedef struct { + /** + * @brief Find the primary model path within a model folder + * + * For single-file models: returns path to the model file + * For directory-based models: returns path to primary model file or directory + * + * @param model_id Model identifier + * @param model_folder Path to the model's folder + * @param out_path Output buffer for the resolved path + * @param path_size Size of output buffer + * @param user_data Backend-specific context + * @return RAC_SUCCESS if found, RAC_ERROR_NOT_FOUND otherwise + */ + rac_result_t (*find_model_path)(const char* model_id, const char* model_folder, char* out_path, + size_t path_size, void* user_data); + + /** + * @brief Detect model format and size in a folder + * + * @param model_folder Path to check + * @param out_details Output storage details + * @param user_data Backend-specific context + * @return RAC_SUCCESS if model detected, RAC_ERROR_NOT_FOUND otherwise + */ + rac_result_t (*detect_model)(const char* model_folder, rac_model_storage_details_t* out_details, + void* user_data); + + /** + * @brief Validate that model storage is complete and usable + * + * @param model_folder Path to the model folder + * @param user_data Backend-specific context + * @return RAC_TRUE if valid, RAC_FALSE otherwise + */ + rac_bool_t (*is_valid_storage)(const char* model_folder, void* user_data); + + /** + * @brief Get list of expected file patterns for this backend + * + * @param out_patterns Output array of pattern strings (owned by backend) + * @param out_count Number of patterns + * @param user_data Backend-specific context + */ + void (*get_expected_patterns)(const char*** out_patterns, size_t* out_count, void* user_data); + + /** Backend-specific context passed to all callbacks */ + void* user_data; + + /** Human-readable name for logging */ + const char* name; +} rac_storage_strategy_t; + +// ============================================================================= +// DOWNLOAD STRATEGY - How models are downloaded and post-processed +// ============================================================================= + +/** + * @brief Model download task configuration (strategy-specific) + * + * Note: This is separate from rac_model_download_config_t in rac_download.h which + * is used for the download manager. This struct is strategy-specific. + */ +typedef struct rac_model_download_config { + /** Model ID being downloaded */ + const char* model_id; + + /** Source URL for download */ + const char* source_url; + + /** Destination folder path */ + const char* destination_folder; + + /** Expected archive type (or RAC_ARCHIVE_TYPE_NONE for direct files) */ + rac_archive_type_t archive_type; + + /** Expected total size in bytes (0 if unknown) */ + int64_t expected_size; + + /** Whether to resume partial downloads */ + rac_bool_t allow_resume; +} rac_model_download_config_t; + +/** + * @brief Download result information + */ +typedef struct { + /** Final path to the downloaded/extracted model */ + char* final_path; + + /** Actual size downloaded in bytes */ + int64_t downloaded_size; + + /** Whether extraction was performed */ + rac_bool_t was_extracted; + + /** Number of files after extraction (1 for single file) */ + int file_count; +} rac_download_result_t; + +/** + * @brief Free download result resources + */ +RAC_API void rac_download_result_free(rac_download_result_t* result); + +/** + * @brief Download strategy callbacks - implemented by backend + * + * These callbacks define how a backend handles model downloads. + * Actual HTTP transport is provided by platform (Swift/Kotlin). + */ +typedef struct { + /** + * @brief Prepare download - validate and configure + * + * Called before download starts to validate config and prepare destination. + * + * @param config Download configuration + * @param user_data Backend-specific context + * @return RAC_SUCCESS if ready to download + */ + rac_result_t (*prepare_download)(const rac_model_download_config_t* config, void* user_data); + + /** + * @brief Get the destination file path for download + * + * @param config Download configuration + * @param out_path Output buffer for destination path + * @param path_size Size of output buffer + * @param user_data Backend-specific context + * @return RAC_SUCCESS on success + */ + rac_result_t (*get_destination_path)(const rac_model_download_config_t* config, char* out_path, + size_t path_size, void* user_data); + + /** + * @brief Post-process after download (extraction, validation) + * + * Called after download completes. Handles extraction and validation. + * + * @param config Original download configuration + * @param downloaded_path Path to downloaded file + * @param out_result Output result information + * @param user_data Backend-specific context + * @return RAC_SUCCESS if post-processing succeeded + */ + rac_result_t (*post_process)(const rac_model_download_config_t* config, + const char* downloaded_path, rac_download_result_t* out_result, + void* user_data); + + /** + * @brief Cleanup failed or cancelled download + * + * @param config Download configuration + * @param user_data Backend-specific context + */ + void (*cleanup)(const rac_model_download_config_t* config, void* user_data); + + /** Backend-specific context passed to all callbacks */ + void* user_data; + + /** Human-readable name for logging */ + const char* name; +} rac_download_strategy_t; + +// ============================================================================= +// STRATEGY REGISTRATION API +// ============================================================================= + +/** + * @brief Register storage strategy for a framework + * + * Called by backends during rac_backend_xxx_register(). + * + * @param framework Framework this strategy applies to + * @param strategy Storage strategy callbacks + * @return RAC_SUCCESS on success + */ +RAC_API rac_result_t rac_storage_strategy_register(rac_inference_framework_t framework, + const rac_storage_strategy_t* strategy); + +/** + * @brief Register download strategy for a framework + * + * Called by backends during rac_backend_xxx_register(). + * + * @param framework Framework this strategy applies to + * @param strategy Download strategy callbacks + * @return RAC_SUCCESS on success + */ +RAC_API rac_result_t rac_download_strategy_register(rac_inference_framework_t framework, + const rac_download_strategy_t* strategy); + +/** + * @brief Unregister strategies for a framework + * + * Called by backends during unregistration. + * + * @param framework Framework to unregister + */ +RAC_API void rac_model_strategy_unregister(rac_inference_framework_t framework); + +// ============================================================================= +// STRATEGY LOOKUP API - Used by SDK core +// ============================================================================= + +/** + * @brief Get storage strategy for a framework + * + * @param framework Framework to query + * @return Strategy pointer or NULL if not registered + */ +RAC_API const rac_storage_strategy_t* rac_storage_strategy_get(rac_inference_framework_t framework); + +/** + * @brief Get download strategy for a framework + * + * @param framework Framework to query + * @return Strategy pointer or NULL if not registered + */ +RAC_API const rac_download_strategy_t* +rac_download_strategy_get(rac_inference_framework_t framework); + +// ============================================================================= +// CONVENIENCE API - High-level operations using registered strategies +// ============================================================================= + +/** + * @brief Find model path using framework's storage strategy + * + * @param framework Inference framework + * @param model_id Model identifier + * @param model_folder Model folder path + * @param out_path Output buffer for resolved path + * @param path_size Size of output buffer + * @return RAC_SUCCESS if found + */ +RAC_API rac_result_t rac_model_strategy_find_path(rac_inference_framework_t framework, + const char* model_id, const char* model_folder, + char* out_path, size_t path_size); + +/** + * @brief Detect model using framework's storage strategy + * + * @param framework Inference framework + * @param model_folder Model folder path + * @param out_details Output storage details + * @return RAC_SUCCESS if model detected + */ +RAC_API rac_result_t rac_model_strategy_detect(rac_inference_framework_t framework, + const char* model_folder, + rac_model_storage_details_t* out_details); + +/** + * @brief Validate model storage using framework's strategy + * + * @param framework Inference framework + * @param model_folder Model folder path + * @return RAC_TRUE if valid + */ +RAC_API rac_bool_t rac_model_strategy_is_valid(rac_inference_framework_t framework, + const char* model_folder); + +/** + * @brief Prepare download using framework's strategy + * + * @param framework Inference framework + * @param config Download configuration + * @return RAC_SUCCESS if ready + */ +RAC_API rac_result_t rac_model_strategy_prepare_download(rac_inference_framework_t framework, + const rac_model_download_config_t* config); + +/** + * @brief Get download destination using framework's strategy + * + * @param framework Inference framework + * @param config Download configuration + * @param out_path Output buffer for path + * @param path_size Size of output buffer + * @return RAC_SUCCESS on success + */ +RAC_API rac_result_t rac_model_strategy_get_download_dest(rac_inference_framework_t framework, + const rac_model_download_config_t* config, + char* out_path, size_t path_size); + +/** + * @brief Post-process download using framework's strategy + * + * @param framework Inference framework + * @param config Download configuration + * @param downloaded_path Path to downloaded file + * @param out_result Output result + * @return RAC_SUCCESS if successful + */ +RAC_API rac_result_t rac_model_strategy_post_process(rac_inference_framework_t framework, + const rac_model_download_config_t* config, + const char* downloaded_path, + rac_download_result_t* out_result); + +#ifdef __cplusplus +} +#endif + +#endif // RAC_MODEL_STRATEGY_H diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_model_types.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_model_types.h new file mode 100644 index 000000000..b620bdbf6 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_model_types.h @@ -0,0 +1,613 @@ +/** + * @file rac_model_types.h + * @brief Model Types - Comprehensive Type Definitions for Model Management + * + * C port of Swift's model type definitions from: + * - ModelCategory.swift + * - ModelFormat.swift + * - ModelArtifactType.swift + * - InferenceFramework.swift + * - ModelInfo.swift + * + * IMPORTANT: This is a direct translation of the Swift implementation. + * Do NOT add features not present in the Swift code. + */ + +#ifndef RAC_MODEL_TYPES_H +#define RAC_MODEL_TYPES_H + +#include +#include + +#include "rac_error.h" +#include "rac_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// ARCHIVE TYPES - From ModelArtifactType.swift +// ============================================================================= + +/** + * @brief Supported archive formats for model packaging. + * Mirrors Swift's ArchiveType enum. + */ +typedef enum rac_archive_type { + RAC_ARCHIVE_TYPE_NONE = -1, /**< No archive - direct file */ + RAC_ARCHIVE_TYPE_ZIP = 0, /**< ZIP archive */ + RAC_ARCHIVE_TYPE_TAR_BZ2 = 1, /**< tar.bz2 archive */ + RAC_ARCHIVE_TYPE_TAR_GZ = 2, /**< tar.gz archive */ + RAC_ARCHIVE_TYPE_TAR_XZ = 3 /**< tar.xz archive */ +} rac_archive_type_t; + +/** + * @brief Internal structure of an archive after extraction. + * Mirrors Swift's ArchiveStructure enum. + */ +typedef enum rac_archive_structure { + RAC_ARCHIVE_STRUCTURE_SINGLE_FILE_NESTED = + 0, /**< Single model file at root or nested in one directory */ + RAC_ARCHIVE_STRUCTURE_DIRECTORY_BASED = 1, /**< Multiple files in a directory */ + RAC_ARCHIVE_STRUCTURE_NESTED_DIRECTORY = 2, /**< Subdirectory structure */ + RAC_ARCHIVE_STRUCTURE_UNKNOWN = 99 /**< Unknown - detected after extraction */ +} rac_archive_structure_t; + +// ============================================================================= +// EXPECTED MODEL FILES - From ModelArtifactType.swift +// ============================================================================= + +/** + * @brief Expected model files after extraction/download. + * Mirrors Swift's ExpectedModelFiles struct. + */ +typedef struct rac_expected_model_files { + /** File patterns that must be present (e.g., "*.onnx", "encoder*.onnx") */ + const char** required_patterns; + size_t required_pattern_count; + + /** File patterns that may be present but are optional */ + const char** optional_patterns; + size_t optional_pattern_count; + + /** Description of the model files for documentation */ + const char* description; +} rac_expected_model_files_t; + +/** + * @brief Multi-file model descriptor. + * Mirrors Swift's ModelFileDescriptor struct. + */ +typedef struct rac_model_file_descriptor { + /** Relative path from base URL to this file */ + const char* relative_path; + + /** Destination path relative to model folder */ + const char* destination_path; + + /** Whether this file is required (vs optional) */ + rac_bool_t is_required; +} rac_model_file_descriptor_t; + +// ============================================================================= +// MODEL ARTIFACT TYPE - From ModelArtifactType.swift +// ============================================================================= + +/** + * @brief Model artifact type enumeration. + * Mirrors Swift's ModelArtifactType enum (simplified for C). + */ +typedef enum rac_artifact_type_kind { + RAC_ARTIFACT_KIND_SINGLE_FILE = 0, /**< Single file download */ + RAC_ARTIFACT_KIND_ARCHIVE = 1, /**< Archive requiring extraction */ + RAC_ARTIFACT_KIND_MULTI_FILE = 2, /**< Multiple files */ + RAC_ARTIFACT_KIND_CUSTOM = 3, /**< Custom download strategy */ + RAC_ARTIFACT_KIND_BUILT_IN = 4 /**< Built-in model (no download) */ +} rac_artifact_type_kind_t; + +/** + * @brief Full model artifact type with associated data. + * Mirrors Swift's ModelArtifactType enum with associated values. + */ +typedef struct rac_model_artifact_info { + /** The kind of artifact */ + rac_artifact_type_kind_t kind; + + /** For archive type: the archive format */ + rac_archive_type_t archive_type; + + /** For archive type: the internal structure */ + rac_archive_structure_t archive_structure; + + /** Expected files after extraction (can be NULL) */ + rac_expected_model_files_t* expected_files; + + /** For multi-file: descriptors array (can be NULL) */ + rac_model_file_descriptor_t* file_descriptors; + size_t file_descriptor_count; + + /** For custom: strategy identifier */ + const char* strategy_id; +} rac_model_artifact_info_t; + +// ============================================================================= +// MODEL CATEGORY - From ModelCategory.swift +// ============================================================================= + +/** + * @brief Model category based on input/output modality. + * Mirrors Swift's ModelCategory enum. + */ +typedef enum rac_model_category { + RAC_MODEL_CATEGORY_LANGUAGE = 0, /**< Text-to-text models (LLMs) */ + RAC_MODEL_CATEGORY_SPEECH_RECOGNITION = 1, /**< Voice-to-text models (ASR/STT) */ + RAC_MODEL_CATEGORY_SPEECH_SYNTHESIS = 2, /**< Text-to-voice models (TTS) */ + RAC_MODEL_CATEGORY_VISION = 3, /**< Image understanding models */ + RAC_MODEL_CATEGORY_IMAGE_GENERATION = 4, /**< Text-to-image models */ + RAC_MODEL_CATEGORY_MULTIMODAL = 5, /**< Multi-modality models */ + RAC_MODEL_CATEGORY_AUDIO = 6, /**< Audio processing (diarization, etc.) */ + RAC_MODEL_CATEGORY_UNKNOWN = 99 /**< Unknown category */ +} rac_model_category_t; + +// ============================================================================= +// MODEL FORMAT - From ModelFormat.swift +// ============================================================================= + +/** + * @brief Supported model file formats. + * Mirrors Swift's ModelFormat enum. + */ +typedef enum rac_model_format { + RAC_MODEL_FORMAT_ONNX = 0, /**< ONNX format */ + RAC_MODEL_FORMAT_ORT = 1, /**< ONNX Runtime format */ + RAC_MODEL_FORMAT_GGUF = 2, /**< GGUF format (llama.cpp) */ + RAC_MODEL_FORMAT_BIN = 3, /**< Binary format */ + RAC_MODEL_FORMAT_UNKNOWN = 99 /**< Unknown format */ +} rac_model_format_t; + +// ============================================================================= +// INFERENCE FRAMEWORK - From InferenceFramework.swift +// ============================================================================= + +/** + * @brief Supported inference frameworks/runtimes. + * Mirrors Swift's InferenceFramework enum. + */ +typedef enum rac_inference_framework { + RAC_FRAMEWORK_ONNX = 0, /**< ONNX Runtime */ + RAC_FRAMEWORK_LLAMACPP = 1, /**< llama.cpp */ + RAC_FRAMEWORK_FOUNDATION_MODELS = 2, /**< Apple Foundation Models */ + RAC_FRAMEWORK_SYSTEM_TTS = 3, /**< System TTS */ + RAC_FRAMEWORK_FLUID_AUDIO = 4, /**< FluidAudio */ + RAC_FRAMEWORK_BUILTIN = 5, /**< Built-in (e.g., energy VAD) */ + RAC_FRAMEWORK_NONE = 6, /**< No framework needed */ + RAC_FRAMEWORK_UNKNOWN = 99 /**< Unknown framework */ +} rac_inference_framework_t; + +// ============================================================================= +// MODEL SOURCE +// ============================================================================= + +/** + * @brief Model source enumeration. + * Mirrors Swift's ModelSource enum. + */ +typedef enum rac_model_source { + RAC_MODEL_SOURCE_REMOTE = 0, /**< Model from remote API/catalog */ + RAC_MODEL_SOURCE_LOCAL = 1 /**< Model provided locally */ +} rac_model_source_t; + +// ============================================================================= +// MODEL INFO - From ModelInfo.swift +// ============================================================================= + +/** + * @brief Complete model information structure. + * Mirrors Swift's ModelInfo struct. + */ +typedef struct rac_model_info { + /** Unique model identifier */ + char* id; + + /** Human-readable model name */ + char* name; + + /** Model category */ + rac_model_category_t category; + + /** Model format */ + rac_model_format_t format; + + /** Inference framework */ + rac_inference_framework_t framework; + + /** Download URL (can be NULL) */ + char* download_url; + + /** Local path (can be NULL) */ + char* local_path; + + /** Artifact information */ + rac_model_artifact_info_t artifact_info; + + /** Download size in bytes (0 if unknown) */ + int64_t download_size; + + /** Memory required in bytes (0 if unknown) */ + int64_t memory_required; + + /** Context length (for language models, 0 if not applicable) */ + int32_t context_length; + + /** Whether model supports thinking/reasoning */ + rac_bool_t supports_thinking; + + /** Tags (NULL-terminated array of strings, can be NULL) */ + char** tags; + size_t tag_count; + + /** Description (can be NULL) */ + char* description; + + /** Model source */ + rac_model_source_t source; + + /** Created timestamp (Unix timestamp) */ + int64_t created_at; + + /** Updated timestamp (Unix timestamp) */ + int64_t updated_at; + + /** Last used timestamp (0 if never used) */ + int64_t last_used; + + /** Usage count */ + int32_t usage_count; +} rac_model_info_t; + +// ============================================================================= +// HELPER FUNCTIONS +// ============================================================================= + +/** + * @brief Get file extension for archive type. + * Mirrors Swift's ArchiveType.fileExtension. + * + * @param type Archive type + * @return File extension string (e.g., "zip", "tar.bz2") + */ +RAC_API const char* rac_archive_type_extension(rac_archive_type_t type); + +/** + * @brief Detect archive type from URL path. + * Mirrors Swift's ArchiveType.from(url:). + * + * @param url_path URL path string + * @param out_type Output: Detected archive type + * @return RAC_TRUE if archive detected, RAC_FALSE otherwise + */ +RAC_API rac_bool_t rac_archive_type_from_path(const char* url_path, rac_archive_type_t* out_type); + +/** + * @brief Check if model category requires context length. + * Mirrors Swift's ModelCategory.requiresContextLength. + * + * @param category Model category + * @return RAC_TRUE if requires context length + */ +RAC_API rac_bool_t rac_model_category_requires_context_length(rac_model_category_t category); + +/** + * @brief Check if model category supports thinking/reasoning. + * Mirrors Swift's ModelCategory.supportsThinking. + * + * @param category Model category + * @return RAC_TRUE if supports thinking + */ +RAC_API rac_bool_t rac_model_category_supports_thinking(rac_model_category_t category); + +/** + * @brief Get model category from framework. + * Mirrors Swift's ModelCategory.from(framework:). + * + * @param framework Inference framework + * @return Model category + */ +RAC_API rac_model_category_t rac_model_category_from_framework(rac_inference_framework_t framework); + +/** + * @brief Get supported formats for a framework. + * Mirrors Swift's InferenceFramework.supportedFormats. + * + * @param framework Inference framework + * @param out_formats Output: Array of supported formats + * @param out_count Output: Number of formats + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_framework_get_supported_formats(rac_inference_framework_t framework, + rac_model_format_t** out_formats, + size_t* out_count); + +/** + * @brief Check if framework supports a format. + * Mirrors Swift's InferenceFramework.supports(format:). + * + * @param framework Inference framework + * @param format Model format + * @return RAC_TRUE if supported + */ +RAC_API rac_bool_t rac_framework_supports_format(rac_inference_framework_t framework, + rac_model_format_t format); + +/** + * @brief Check if framework uses directory-based models. + * Mirrors Swift's InferenceFramework.usesDirectoryBasedModels. + * + * @param framework Inference framework + * @return RAC_TRUE if uses directory-based models + */ +RAC_API rac_bool_t rac_framework_uses_directory_based_models(rac_inference_framework_t framework); + +/** + * @brief Check if framework supports LLM. + * Mirrors Swift's InferenceFramework.supportsLLM. + * + * @param framework Inference framework + * @return RAC_TRUE if supports LLM + */ +RAC_API rac_bool_t rac_framework_supports_llm(rac_inference_framework_t framework); + +/** + * @brief Check if framework supports STT. + * Mirrors Swift's InferenceFramework.supportsSTT. + * + * @param framework Inference framework + * @return RAC_TRUE if supports STT + */ +RAC_API rac_bool_t rac_framework_supports_stt(rac_inference_framework_t framework); + +/** + * @brief Check if framework supports TTS. + * Mirrors Swift's InferenceFramework.supportsTTS. + * + * @param framework Inference framework + * @return RAC_TRUE if supports TTS + */ +RAC_API rac_bool_t rac_framework_supports_tts(rac_inference_framework_t framework); + +/** + * @brief Get framework display name. + * Mirrors Swift's InferenceFramework.displayName. + * + * @param framework Inference framework + * @return Display name string + */ +RAC_API const char* rac_framework_display_name(rac_inference_framework_t framework); + +/** + * @brief Get framework analytics key. + * Mirrors Swift's InferenceFramework.analyticsKey. + * + * @param framework Inference framework + * @return Analytics key string (snake_case) + */ +RAC_API const char* rac_framework_analytics_key(rac_inference_framework_t framework); + +/** + * @brief Check if artifact requires extraction. + * Mirrors Swift's ModelArtifactType.requiresExtraction. + * + * @param artifact Artifact info + * @return RAC_TRUE if requires extraction + */ +RAC_API rac_bool_t rac_artifact_requires_extraction(const rac_model_artifact_info_t* artifact); + +/** + * @brief Check if artifact requires download. + * Mirrors Swift's ModelArtifactType.requiresDownload. + * + * @param artifact Artifact info + * @return RAC_TRUE if requires download + */ +RAC_API rac_bool_t rac_artifact_requires_download(const rac_model_artifact_info_t* artifact); + +/** + * @brief Infer artifact type from URL. + * Mirrors Swift's ModelArtifactType.infer(from:format:). + * + * @param url Download URL (can be NULL) + * @param format Model format + * @param out_artifact Output: Inferred artifact info + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_artifact_infer_from_url(const char* url, rac_model_format_t format, + rac_model_artifact_info_t* out_artifact); + +/** + * @brief Check if model is downloaded and available. + * Mirrors Swift's ModelInfo.isDownloaded. + * + * @param model Model info + * @return RAC_TRUE if downloaded + */ +RAC_API rac_bool_t rac_model_info_is_downloaded(const rac_model_info_t* model); + +// ============================================================================= +// FORMAT DETECTION - From RegistryService.swift +// ============================================================================= + +/** + * @brief Detect model format from file extension. + * Ported from Swift RegistryService.detectFormatFromExtension() (lines 330-338) + * + * @param extension File extension (without dot, e.g., "onnx", "gguf") + * @param out_format Output: Detected format + * @return RAC_TRUE if format detected, RAC_FALSE if unknown + */ +RAC_API rac_bool_t rac_model_detect_format_from_extension(const char* extension, + rac_model_format_t* out_format); + +/** + * @brief Detect framework from model format. + * Ported from Swift RegistryService.detectFramework(for:) (lines 340-343) + * + * @param format Model format + * @param out_framework Output: Detected framework + * @return RAC_TRUE if framework detected, RAC_FALSE if unknown + */ +RAC_API rac_bool_t rac_model_detect_framework_from_format(rac_model_format_t format, + rac_inference_framework_t* out_framework); + +/** + * @brief Get file extension string for a model format. + * Mirrors Swift's ModelFormat.fileExtension. + * + * @param format Model format + * @return Extension string (e.g., "onnx", "gguf") or NULL if unknown + */ +RAC_API const char* rac_model_format_extension(rac_model_format_t format); + +// ============================================================================= +// MODEL ID/NAME GENERATION - From RegistryService.swift +// ============================================================================= + +/** + * @brief Generate model ID from URL by stripping known extensions. + * Ported from Swift RegistryService.generateModelId(from:) (lines 311-318) + * + * @param url URL path string (e.g., "model.tar.gz", "llama-7b.gguf") + * @param out_id Output buffer for model ID + * @param max_len Maximum length of output buffer + */ +RAC_API void rac_model_generate_id(const char* url, char* out_id, size_t max_len); + +/** + * @brief Generate human-readable model name from URL. + * Ported from Swift RegistryService.generateModelName(from:) (lines 320-324) + * Replaces underscores and dashes with spaces. + * + * @param url URL path string + * @param out_name Output buffer for model name + * @param max_len Maximum length of output buffer + */ +RAC_API void rac_model_generate_name(const char* url, char* out_name, size_t max_len); + +// ============================================================================= +// MODEL FILTERING - From RegistryService.swift +// ============================================================================= + +/** + * @brief Model filtering criteria. + * Mirrors Swift's ModelCriteria struct. + */ +typedef struct rac_model_filter { + /** Filter by framework (RAC_FRAMEWORK_UNKNOWN = any) */ + rac_inference_framework_t framework; + + /** Filter by format (RAC_MODEL_FORMAT_UNKNOWN = any) */ + rac_model_format_t format; + + /** Maximum download size in bytes (0 = no limit) */ + int64_t max_size; + + /** Search query for name/id/description (NULL = no search filter) */ + const char* search_query; +} rac_model_filter_t; + +/** + * @brief Filter models by criteria. + * Ported from Swift RegistryService.filterModels(by:) (lines 104-126) + * + * @param models Array of models to filter + * @param models_count Number of models in input array + * @param filter Filter criteria (NULL = no filtering, return all) + * @param out_models Output array for filtered models (caller allocates) + * @param out_capacity Maximum capacity of output array + * @return Number of models that passed the filter (may exceed out_capacity) + */ +RAC_API size_t rac_model_filter_models(const rac_model_info_t* models, size_t models_count, + const rac_model_filter_t* filter, + rac_model_info_t* out_models, size_t out_capacity); + +/** + * @brief Check if a model matches filter criteria. + * Helper function for filtering. + * + * @param model Model to check + * @param filter Filter criteria + * @return RAC_TRUE if model matches, RAC_FALSE otherwise + */ +RAC_API rac_bool_t rac_model_matches_filter(const rac_model_info_t* model, + const rac_model_filter_t* filter); + +// ============================================================================= +// MEMORY MANAGEMENT +// ============================================================================= + +/** + * @brief Allocate expected model files structure. + * + * @return Allocated structure (must be freed with rac_expected_model_files_free) + */ +RAC_API rac_expected_model_files_t* rac_expected_model_files_alloc(void); + +/** + * @brief Free expected model files structure. + * + * @param files Structure to free + */ +RAC_API void rac_expected_model_files_free(rac_expected_model_files_t* files); + +/** + * @brief Allocate model file descriptor array. + * + * @param count Number of descriptors + * @return Allocated array (must be freed with rac_model_file_descriptors_free) + */ +RAC_API rac_model_file_descriptor_t* rac_model_file_descriptors_alloc(size_t count); + +/** + * @brief Free model file descriptor array. + * + * @param descriptors Array to free + * @param count Number of descriptors + */ +RAC_API void rac_model_file_descriptors_free(rac_model_file_descriptor_t* descriptors, + size_t count); + +/** + * @brief Allocate model info structure. + * + * @return Allocated structure (must be freed with rac_model_info_free) + */ +RAC_API rac_model_info_t* rac_model_info_alloc(void); + +/** + * @brief Free model info structure. + * + * @param model Model info to free + */ +RAC_API void rac_model_info_free(rac_model_info_t* model); + +/** + * @brief Free array of model info pointers. + * + * @param models Array of model info pointers + * @param count Number of models + */ +RAC_API void rac_model_info_array_free(rac_model_info_t** models, size_t count); + +/** + * @brief Deep copy model info structure. + * + * @param model Model info to copy + * @return Deep copy (must be freed with rac_model_info_free) + */ +RAC_API rac_model_info_t* rac_model_info_copy(const rac_model_info_t* model); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_MODEL_TYPES_H */ diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_platform_adapter.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_platform_adapter.h new file mode 100644 index 000000000..143615b0a --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_platform_adapter.h @@ -0,0 +1,340 @@ +/** + * @file rac_platform_adapter.h + * @brief RunAnywhere Commons - Platform Adapter Interface + * + * Platform adapter provides callbacks for platform-specific operations. + * Swift/Kotlin SDK implements these callbacks and passes them during init. + * + * NOTE: HTTP networking is delegated to the platform layer (Swift/Kotlin). + * The C++ layer only handles orchestration logic. + */ + +#ifndef RAC_PLATFORM_ADAPTER_H +#define RAC_PLATFORM_ADAPTER_H + +#include "rac_error.h" +#include "rac_types.h" +#include "rac_model_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// CALLBACK TYPES (defined outside struct for C compatibility) +// ============================================================================= + +/** + * HTTP download progress callback type. + * @param bytes_downloaded Bytes downloaded so far + * @param total_bytes Total bytes to download (0 if unknown) + * @param callback_user_data Context passed to http_download + */ +typedef void (*rac_http_progress_callback_fn)(int64_t bytes_downloaded, int64_t total_bytes, + void* callback_user_data); + +/** + * HTTP download completion callback type. + * @param result RAC_SUCCESS or error code + * @param downloaded_path Path to downloaded file (NULL on failure) + * @param callback_user_data Context passed to http_download + */ +typedef void (*rac_http_complete_callback_fn)(rac_result_t result, const char* downloaded_path, + void* callback_user_data); + +/** + * Archive extraction progress callback type. + * @param files_extracted Number of files extracted so far + * @param total_files Total files to extract + * @param callback_user_data Context passed to extract_archive + */ +typedef void (*rac_extract_progress_callback_fn)(int32_t files_extracted, int32_t total_files, + void* callback_user_data); + +// ============================================================================= +// PLATFORM ADAPTER STRUCTURE +// ============================================================================= + +/** + * Platform adapter structure. + * + * Implements platform-specific operations via callbacks. + * The SDK layer (Swift/Kotlin) provides these implementations. + */ +typedef struct rac_platform_adapter { + // ------------------------------------------------------------------------- + // File System Operations + // ------------------------------------------------------------------------- + + /** + * Check if a file exists. + * @param path File path + * @param user_data Platform context + * @return RAC_TRUE if file exists, RAC_FALSE otherwise + */ + rac_bool_t (*file_exists)(const char* path, void* user_data); + + /** + * Read file contents. + * @param path File path + * @param out_data Output buffer (caller must free with rac_free) + * @param out_size Output file size + * @param user_data Platform context + * @return RAC_SUCCESS on success, error code on failure + */ + rac_result_t (*file_read)(const char* path, void** out_data, size_t* out_size, void* user_data); + + /** + * Write file contents. + * @param path File path + * @param data Data to write + * @param size Data size + * @param user_data Platform context + * @return RAC_SUCCESS on success, error code on failure + */ + rac_result_t (*file_write)(const char* path, const void* data, size_t size, void* user_data); + + /** + * Delete a file. + * @param path File path + * @param user_data Platform context + * @return RAC_SUCCESS on success, error code on failure + */ + rac_result_t (*file_delete)(const char* path, void* user_data); + + // ------------------------------------------------------------------------- + // Secure Storage (Keychain/KeyStore) + // ------------------------------------------------------------------------- + + /** + * Get a value from secure storage. + * @param key Key name + * @param out_value Output value (caller must free with rac_free) + * @param user_data Platform context + * @return RAC_SUCCESS on success, RAC_ERROR_FILE_NOT_FOUND if not found + */ + rac_result_t (*secure_get)(const char* key, char** out_value, void* user_data); + + /** + * Set a value in secure storage. + * @param key Key name + * @param value Value to store + * @param user_data Platform context + * @return RAC_SUCCESS on success, error code on failure + */ + rac_result_t (*secure_set)(const char* key, const char* value, void* user_data); + + /** + * Delete a value from secure storage. + * @param key Key name + * @param user_data Platform context + * @return RAC_SUCCESS on success, error code on failure + */ + rac_result_t (*secure_delete)(const char* key, void* user_data); + + // ------------------------------------------------------------------------- + // Logging + // ------------------------------------------------------------------------- + + /** + * Log a message. + * @param level Log level + * @param category Log category (e.g., "ModuleRegistry") + * @param message Log message + * @param user_data Platform context + */ + void (*log)(rac_log_level_t level, const char* category, const char* message, void* user_data); + + // ------------------------------------------------------------------------- + // Error Tracking (Optional - for Sentry/crash reporting) + // ------------------------------------------------------------------------- + + /** + * Track a structured error for telemetry/crash reporting. + * Can be NULL - errors will still be logged but not sent to Sentry. + * + * Called for non-expected errors (i.e., not cancellations). + * The JSON string contains full error details including stack trace. + * + * @param error_json JSON representation of the structured error + * @param user_data Platform context + */ + void (*track_error)(const char* error_json, void* user_data); + + // ------------------------------------------------------------------------- + // Clock + // ------------------------------------------------------------------------- + + /** + * Get current time in milliseconds since Unix epoch. + * @param user_data Platform context + * @return Current time in milliseconds + */ + int64_t (*now_ms)(void* user_data); + + // ------------------------------------------------------------------------- + // Memory Info + // ------------------------------------------------------------------------- + + /** + * Get memory information. + * @param out_info Output memory info structure + * @param user_data Platform context + * @return RAC_SUCCESS on success, error code on failure + */ + rac_result_t (*get_memory_info)(rac_memory_info_t* out_info, void* user_data); + + // ------------------------------------------------------------------------- + // HTTP Download (Optional - can be NULL) + // ------------------------------------------------------------------------- + + /** + * Start an HTTP download. + * Can be NULL - download orchestration in C++ will call back to Swift/Kotlin. + * + * @param url URL to download from + * @param destination_path Where to save the downloaded file + * @param progress_callback Progress callback (can be NULL) + * @param complete_callback Completion callback + * @param callback_user_data User context for callbacks + * @param out_task_id Output: Task ID for cancellation (owned, must be freed) + * @param user_data Platform context + * @return RAC_SUCCESS if download started, error code otherwise + */ + rac_result_t (*http_download)(const char* url, const char* destination_path, + rac_http_progress_callback_fn progress_callback, + rac_http_complete_callback_fn complete_callback, + void* callback_user_data, char** out_task_id, void* user_data); + + /** + * Cancel an HTTP download. + * Can be NULL. + * + * @param task_id Task ID returned from http_download + * @param user_data Platform context + * @return RAC_SUCCESS if cancelled, error code otherwise + */ + rac_result_t (*http_download_cancel)(const char* task_id, void* user_data); + + // ------------------------------------------------------------------------- + // Archive Extraction (Optional - can be NULL) + // ------------------------------------------------------------------------- + + /** + * Extract an archive (ZIP or TAR). + * Can be NULL - extraction will be handled by Swift/Kotlin. + * + * @param archive_path Path to the archive + * @param destination_dir Where to extract files + * @param progress_callback Progress callback (can be NULL) + * @param callback_user_data User context for callback + * @param user_data Platform context + * @return RAC_SUCCESS if extracted, error code otherwise + */ + rac_result_t (*extract_archive)(const char* archive_path, const char* destination_dir, + rac_extract_progress_callback_fn progress_callback, + void* callback_user_data, void* user_data); + + // ------------------------------------------------------------------------- + // User Data + // ------------------------------------------------------------------------- + + /** Platform-specific context passed to all callbacks */ + void* user_data; + +} rac_platform_adapter_t; + +// ============================================================================= +// PLATFORM ADAPTER API +// ============================================================================= + +/** + * Sets the platform adapter. + * + * Called during rac_init() - the adapter pointer must remain valid + * until rac_shutdown() is called. + * + * @param adapter Platform adapter (must not be NULL) + * @return RAC_SUCCESS on success, error code on failure + */ +RAC_API rac_result_t rac_set_platform_adapter(const rac_platform_adapter_t* adapter); + +/** + * Gets the current platform adapter. + * + * @return The current adapter, or NULL if not set + */ +RAC_API const rac_platform_adapter_t* rac_get_platform_adapter(void); + +// ============================================================================= +// CONVENIENCE FUNCTIONS (use platform adapter internally) +// ============================================================================= + +/** + * Log a message using the platform adapter. + * @param level Log level + * @param category Category string + * @param message Message string + */ +RAC_API void rac_log(rac_log_level_t level, const char* category, const char* message); + +/** + * Get current time in milliseconds. + * @return Current time in milliseconds since epoch + */ +RAC_API int64_t rac_get_current_time_ms(void); + +/** + * Start an HTTP download using the platform adapter. + * Returns RAC_ERROR_NOT_SUPPORTED if http_download callback is NULL. + * + * @param url URL to download + * @param destination_path Where to save + * @param progress_callback Progress callback (can be NULL) + * @param complete_callback Completion callback + * @param callback_user_data User data for callbacks + * @param out_task_id Output: Task ID (owned, must be freed) + * @return RAC_SUCCESS if started, error code otherwise + */ +RAC_API rac_result_t rac_http_download(const char* url, const char* destination_path, + rac_http_progress_callback_fn progress_callback, + rac_http_complete_callback_fn complete_callback, + void* callback_user_data, char** out_task_id); + +/** + * Cancel an HTTP download. + * Returns RAC_ERROR_NOT_SUPPORTED if http_download_cancel callback is NULL. + * + * @param task_id Task ID to cancel + * @return RAC_SUCCESS if cancelled, error code otherwise + */ +RAC_API rac_result_t rac_http_download_cancel(const char* task_id); + +/** + * Extract an archive using the platform adapter. + * Returns RAC_ERROR_NOT_SUPPORTED if extract_archive callback is NULL. + * + * @param archive_path Path to archive + * @param destination_dir Where to extract + * @param progress_callback Progress callback (can be NULL) + * @param callback_user_data User data for callback + * @return RAC_SUCCESS if extracted, error code otherwise + */ +RAC_API rac_result_t rac_extract_archive(const char* archive_path, const char* destination_dir, + rac_extract_progress_callback_fn progress_callback, + void* callback_user_data); + +/** + * Check if a model framework is a platform service (Swift-native). + * Platform services are handled via service registry callbacks, not C++ backends. + * + * @param framework Framework to check + * @return RAC_TRUE if platform service, RAC_FALSE if C++ backend + */ +RAC_API rac_bool_t rac_framework_is_platform_service(rac_inference_framework_t framework); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_PLATFORM_ADAPTER_H */ diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_sdk_state.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_sdk_state.h new file mode 100644 index 000000000..b14d48c85 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_sdk_state.h @@ -0,0 +1,292 @@ +/** + * @file rac_sdk_state.h + * @brief Centralized SDK state management (C++ equivalent of ServiceContainer) + * + * This is the single source of truth for all SDK runtime state. + * Platform SDKs (Swift, Kotlin, Flutter) should query state from here + * rather than maintaining their own copies. + * + * Pattern mirrors Swift's ServiceContainer: + * - Singleton access via rac_state_get_instance() + * - Lazy initialization for sub-components + * - Thread-safe access via internal mutex + * - Reset capability for testing + * + * State Categories: + * 1. Auth State - Tokens, user/org IDs, authentication status + * 2. Device State - Device ID, registration status + * 3. Environment - SDK environment, API key, base URL + * 4. Services - Telemetry manager, model registry handles + */ + +#ifndef RAC_SDK_STATE_H +#define RAC_SDK_STATE_H + +#include +#include + +#include "rac_types.h" // For rac_result_t, RAC_SUCCESS +#include "rac_environment.h" // For rac_environment_t + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// State Structure (Opaque - internal structure hidden from C API) +// ============================================================================= + +/** + * @brief Opaque handle to SDK state + * + * The internal structure is hidden to allow C++ implementation + * while exposing a clean C API for platform interop. + */ +typedef struct rac_sdk_state* rac_sdk_state_handle_t; + +// ============================================================================= +// Auth Data Input Structure (Public - for platform to populate) +// ============================================================================= + +/** + * @brief Authentication data input + * + * Platforms use this to set auth state after successful HTTP authentication. + * C++ copies the data internally and manages lifetime. + * + * Note: This is distinct from rac_auth_state_t in rac_auth_manager.h which + * is the internal state structure. + */ +typedef struct { + const char* access_token; + const char* refresh_token; + int64_t expires_at_unix; // Unix timestamp (seconds) + const char* user_id; // Nullable + const char* organization_id; + const char* device_id; +} rac_auth_data_t; + +// ============================================================================= +// Singleton Access +// ============================================================================= + +/** + * @brief Get the singleton SDK state instance + * + * Creates the instance on first call (lazy initialization). + * Thread-safe. + * + * @return Handle to the SDK state (never NULL after first call) + */ +rac_sdk_state_handle_t rac_state_get_instance(void); + +// ============================================================================= +// Initialization & Lifecycle +// ============================================================================= + +/** + * @brief Initialize SDK state with configuration + * + * Called during SDK initialization. Sets up environment and base config. + * + * @param env The SDK environment (development, staging, production) + * @param api_key The API key (copied internally) + * @param base_url The base URL (copied internally) + * @param device_id The persistent device ID (copied internally) + * @return RAC_SUCCESS on success + */ +rac_result_t rac_state_initialize(rac_environment_t env, const char* api_key, const char* base_url, + const char* device_id); + +/** + * @brief Check if SDK state is initialized + * @return true if initialized + */ +bool rac_state_is_initialized(void); + +/** + * @brief Reset all state (for testing or re-initialization) + * + * Clears all state including auth tokens, handles, etc. + * Does NOT free the singleton - just resets to initial state. + */ +void rac_state_reset(void); + +/** + * @brief Shutdown and free all resources + * + * Called during SDK shutdown. Frees all memory and destroys handles. + */ +void rac_state_shutdown(void); + +// ============================================================================= +// Environment Queries +// ============================================================================= + +/** + * @brief Get current environment + * @return The SDK environment + */ +rac_environment_t rac_state_get_environment(void); + +/** + * @brief Get base URL + * @return The base URL string (do not free) + */ +const char* rac_state_get_base_url(void); + +/** + * @brief Get API key + * @return The API key string (do not free) + */ +const char* rac_state_get_api_key(void); + +/** + * @brief Get device ID + * @return The device ID string (do not free) + */ +const char* rac_state_get_device_id(void); + +// ============================================================================= +// Auth State Management +// ============================================================================= + +/** + * @brief Set authentication state after successful auth + * + * Called by platform after HTTP auth response is received. + * Copies all strings internally. + * + * @param auth The auth data to set + * @return RAC_SUCCESS on success + */ +rac_result_t rac_state_set_auth(const rac_auth_data_t* auth); + +/** + * @brief Get current access token + * @return Access token string or NULL if not authenticated (do not free) + */ +const char* rac_state_get_access_token(void); + +/** + * @brief Get current refresh token + * @return Refresh token string or NULL (do not free) + */ +const char* rac_state_get_refresh_token(void); + +/** + * @brief Check if currently authenticated + * @return true if authenticated with valid (non-expired) token + */ +bool rac_state_is_authenticated(void); + +/** + * @brief Check if token needs refresh + * + * Returns true if token expires within the next 60 seconds. + * + * @return true if refresh is needed + */ +bool rac_state_token_needs_refresh(void); + +/** + * @brief Get token expiry timestamp + * @return Unix timestamp (seconds) when token expires, or 0 if not set + */ +int64_t rac_state_get_token_expires_at(void); + +/** + * @brief Get user ID + * @return User ID string or NULL (do not free) + */ +const char* rac_state_get_user_id(void); + +/** + * @brief Get organization ID + * @return Organization ID string or NULL (do not free) + */ +const char* rac_state_get_organization_id(void); + +/** + * @brief Clear authentication state + * + * Called on logout or auth failure. Clears tokens but not device/env config. + */ +void rac_state_clear_auth(void); + +// ============================================================================= +// Device State Management +// ============================================================================= + +/** + * @brief Set device registration status + * @param registered Whether device is registered with backend + */ +void rac_state_set_device_registered(bool registered); + +/** + * @brief Check if device is registered + * @return true if device has been registered + */ +bool rac_state_is_device_registered(void); + +// ============================================================================= +// State Change Callbacks (for platform observers) +// ============================================================================= + +/** + * @brief Callback type for auth state changes + * @param is_authenticated Current auth status + * @param user_data User-provided context + */ +typedef void (*rac_auth_changed_callback_t)(bool is_authenticated, void* user_data); + +/** + * @brief Register callback for auth state changes + * + * Called whenever auth state changes (login, logout, token refresh). + * + * @param callback The callback function (NULL to unregister) + * @param user_data Context passed to callback + */ +void rac_state_on_auth_changed(rac_auth_changed_callback_t callback, void* user_data); + +// ============================================================================= +// Persistence Bridge (Platform implements secure storage) +// ============================================================================= + +/** + * @brief Callback type for persisting state to secure storage + * @param key The key to store under + * @param value The value to store (NULL to delete) + * @param user_data User-provided context + */ +typedef void (*rac_persist_callback_t)(const char* key, const char* value, void* user_data); + +/** + * @brief Callback type for loading state from secure storage + * @param key The key to load + * @param user_data User-provided context + * @return The stored value or NULL (caller must NOT free) + */ +typedef const char* (*rac_load_callback_t)(const char* key, void* user_data); + +/** + * @brief Register callbacks for secure storage + * + * Platform implements these to persist to Keychain/KeyStore. + * C++ calls persist_callback when state changes. + * C++ calls load_callback during initialization. + * + * @param persist Callback to persist a value + * @param load Callback to load a value + * @param user_data Context passed to callbacks + */ +void rac_state_set_persistence_callbacks(rac_persist_callback_t persist, rac_load_callback_t load, + void* user_data); + +#ifdef __cplusplus +} +#endif + +#endif // RAC_SDK_STATE_H diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_storage_analyzer.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_storage_analyzer.h new file mode 100644 index 000000000..b52e8ce24 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_storage_analyzer.h @@ -0,0 +1,286 @@ +/** + * @file rac_storage_analyzer.h + * @brief Storage Analyzer - Centralized Storage Analysis Logic + * + * Business logic for storage analysis lives here in C++. + * Platform-specific file operations are provided via callbacks. + * + * Storage structure: `{base_dir}/RunAnywhere/Models/{framework}/{modelId}/` + */ + +#ifndef RAC_STORAGE_ANALYZER_H +#define RAC_STORAGE_ANALYZER_H + +#include +#include + +#include "rac_error.h" +#include "rac_types.h" +#include "rac_model_registry.h" +#include "rac_model_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// DATA STRUCTURES +// ============================================================================= + +/** + * @brief Storage metrics for a single model + */ +typedef struct { + /** Model ID */ + const char* model_id; + + /** Model name */ + const char* model_name; + + /** Inference framework */ + rac_inference_framework_t framework; + + /** Local path to model */ + const char* local_path; + + /** Actual size on disk in bytes */ + int64_t size_on_disk; + + /** Model format */ + rac_model_format_t format; + + /** Artifact type info */ + rac_model_artifact_info_t artifact_info; +} rac_model_storage_metrics_t; + +/** + * @brief Device storage information + */ +typedef struct { + /** Total device storage in bytes */ + int64_t total_space; + + /** Free space in bytes */ + int64_t free_space; + + /** Used space in bytes */ + int64_t used_space; +} rac_device_storage_t; + +/** + * @brief App storage information + */ +typedef struct { + /** Documents directory size in bytes */ + int64_t documents_size; + + /** Cache directory size in bytes */ + int64_t cache_size; + + /** App support directory size in bytes */ + int64_t app_support_size; + + /** Total app storage */ + int64_t total_size; +} rac_app_storage_t; + +/** + * @brief Storage availability result + */ +typedef struct { + /** Whether storage is available */ + rac_bool_t is_available; + + /** Required space in bytes */ + int64_t required_space; + + /** Available space in bytes */ + int64_t available_space; + + /** Whether there's a warning (low space) */ + rac_bool_t has_warning; + + /** Recommendation message (may be NULL) */ + const char* recommendation; +} rac_storage_availability_t; + +/** + * @brief Overall storage info + */ +typedef struct { + /** App storage */ + rac_app_storage_t app_storage; + + /** Device storage */ + rac_device_storage_t device_storage; + + /** Array of model storage metrics */ + rac_model_storage_metrics_t* models; + + /** Number of models */ + size_t model_count; + + /** Total size of all models */ + int64_t total_models_size; +} rac_storage_info_t; + +// ============================================================================= +// PLATFORM CALLBACKS - Swift/Kotlin implements these +// ============================================================================= + +/** + * @brief Callback to calculate directory size + * @param path Directory path + * @param user_data User context + * @return Size in bytes + */ +typedef int64_t (*rac_calculate_dir_size_fn)(const char* path, void* user_data); + +/** + * @brief Callback to get file size + * @param path File path + * @param user_data User context + * @return Size in bytes, or -1 if not found + */ +typedef int64_t (*rac_get_file_size_fn)(const char* path, void* user_data); + +/** + * @brief Callback to check if path exists + * @param path Path to check + * @param is_directory Output: true if directory + * @param user_data User context + * @return true if exists + */ +typedef rac_bool_t (*rac_path_exists_fn)(const char* path, rac_bool_t* is_directory, + void* user_data); + +/** + * @brief Callback to get available disk space + * @param user_data User context + * @return Available space in bytes + */ +typedef int64_t (*rac_get_available_space_fn)(void* user_data); + +/** + * @brief Callback to get total disk space + * @param user_data User context + * @return Total space in bytes + */ +typedef int64_t (*rac_get_total_space_fn)(void* user_data); + +/** + * @brief Platform callbacks for file operations + */ +typedef struct { + rac_calculate_dir_size_fn calculate_dir_size; + rac_get_file_size_fn get_file_size; + rac_path_exists_fn path_exists; + rac_get_available_space_fn get_available_space; + rac_get_total_space_fn get_total_space; + void* user_data; +} rac_storage_callbacks_t; + +// ============================================================================= +// STORAGE ANALYZER API +// ============================================================================= + +/** Opaque handle to storage analyzer */ +typedef struct rac_storage_analyzer* rac_storage_analyzer_handle_t; + +/** + * @brief Create a storage analyzer with platform callbacks + * + * @param callbacks Platform-specific file operation callbacks + * @param out_handle Output: Created analyzer handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_storage_analyzer_create(const rac_storage_callbacks_t* callbacks, + rac_storage_analyzer_handle_t* out_handle); + +/** + * @brief Destroy a storage analyzer + * + * @param handle Analyzer handle to destroy + */ +RAC_API void rac_storage_analyzer_destroy(rac_storage_analyzer_handle_t handle); + +/** + * @brief Analyze overall storage + * + * Business logic in C++: + * - Gets models from rac_model_registry + * - Calculates paths via rac_model_paths + * - Calls platform callbacks for sizes + * - Aggregates results + * + * @param handle Analyzer handle + * @param registry_handle Model registry handle + * @param out_info Output: Storage info (caller must call rac_storage_info_free) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_storage_analyzer_analyze(rac_storage_analyzer_handle_t handle, + rac_model_registry_handle_t registry_handle, + rac_storage_info_t* out_info); + +/** + * @brief Get storage metrics for a specific model + * + * @param handle Analyzer handle + * @param registry_handle Model registry handle + * @param model_id Model identifier + * @param framework Inference framework + * @param out_metrics Output: Model metrics + * @return RAC_SUCCESS or RAC_ERROR_NOT_FOUND + */ +RAC_API rac_result_t rac_storage_analyzer_get_model_metrics( + rac_storage_analyzer_handle_t handle, rac_model_registry_handle_t registry_handle, + const char* model_id, rac_inference_framework_t framework, + rac_model_storage_metrics_t* out_metrics); + +/** + * @brief Check if storage is available for a download + * + * @param handle Analyzer handle + * @param model_size Size of model to download + * @param safety_margin Safety margin (e.g., 0.1 for 10% extra) + * @param out_availability Output: Availability result + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_storage_analyzer_check_available( + rac_storage_analyzer_handle_t handle, int64_t model_size, double safety_margin, + rac_storage_availability_t* out_availability); + +/** + * @brief Calculate size at a path (file or directory) + * + * @param handle Analyzer handle + * @param path Path to calculate size for + * @param out_size Output: Size in bytes + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_storage_analyzer_calculate_size(rac_storage_analyzer_handle_t handle, + const char* path, int64_t* out_size); + +// ============================================================================= +// CLEANUP +// ============================================================================= + +/** + * @brief Free storage info returned by rac_storage_analyzer_analyze + * + * @param info Storage info to free + */ +RAC_API void rac_storage_info_free(rac_storage_info_t* info); + +/** + * @brief Free storage availability result + * + * @param availability Availability result to free + */ +RAC_API void rac_storage_availability_free(rac_storage_availability_t* availability); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_STORAGE_ANALYZER_H */ diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_structured_error.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_structured_error.h new file mode 100644 index 000000000..5a960d3c1 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_structured_error.h @@ -0,0 +1,594 @@ +/** + * @file rac_structured_error.h + * @brief RunAnywhere Commons - Structured Error System + * + * Provides a comprehensive structured error type that mirrors Swift's SDKError. + * This is the source of truth for error structures across all platforms + * (Swift, Kotlin, React Native, Flutter). + * + * Features: + * - Error codes and categories matching Swift's ErrorCode and ErrorCategory + * - Stack trace capture (platform-dependent) + * - Structured metadata for telemetry + * - Serialization to JSON for remote logging + * + * Usage: + * rac_error_t* error = rac_error_create(RAC_ERROR_MODEL_NOT_FOUND, + * RAC_CATEGORY_STT, + * "Model not found: whisper-tiny.en"); + * rac_error_set_model_context(error, "whisper-tiny.en", "sherpa-onnx"); + * rac_error_capture_stack_trace(error); + * // ... use error ... + * rac_error_destroy(error); + */ + +#ifndef RAC_STRUCTURED_ERROR_H +#define RAC_STRUCTURED_ERROR_H + +#include + +#include "rac_error.h" +#include "rac_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// ERROR CATEGORIES +// ============================================================================= + +/** + * @brief Error categories matching Swift's ErrorCategory. + * + * These define which component/modality an error belongs to. + */ +typedef enum rac_error_category { + RAC_CATEGORY_GENERAL = 0, /**< General SDK errors */ + RAC_CATEGORY_STT = 1, /**< Speech-to-Text errors */ + RAC_CATEGORY_TTS = 2, /**< Text-to-Speech errors */ + RAC_CATEGORY_LLM = 3, /**< Large Language Model errors */ + RAC_CATEGORY_VAD = 4, /**< Voice Activity Detection errors */ + RAC_CATEGORY_VLM = 5, /**< Vision Language Model errors */ + RAC_CATEGORY_SPEAKER_DIARIZATION = 6, /**< Speaker Diarization errors */ + RAC_CATEGORY_WAKE_WORD = 7, /**< Wake Word Detection errors */ + RAC_CATEGORY_VOICE_AGENT = 8, /**< Voice Agent errors */ + RAC_CATEGORY_DOWNLOAD = 9, /**< Download errors */ + RAC_CATEGORY_FILE_MANAGEMENT = 10, /**< File management errors */ + RAC_CATEGORY_NETWORK = 11, /**< Network errors */ + RAC_CATEGORY_AUTHENTICATION = 12, /**< Authentication errors */ + RAC_CATEGORY_SECURITY = 13, /**< Security errors */ + RAC_CATEGORY_RUNTIME = 14, /**< Runtime/backend errors */ +} rac_error_category_t; + +// ============================================================================= +// STACK FRAME +// ============================================================================= + +/** + * @brief A single frame in a stack trace. + */ +typedef struct rac_stack_frame { + const char* function; /**< Function name */ + const char* file; /**< Source file name */ + int32_t line; /**< Line number */ + void* address; /**< Memory address (for symbolication) */ +} rac_stack_frame_t; + +// ============================================================================= +// STRUCTURED ERROR +// ============================================================================= + +/** + * @brief Maximum number of stack frames to capture. + */ +#define RAC_MAX_STACK_FRAMES 32 + +/** + * @brief Maximum length of error message. + */ +#define RAC_MAX_ERROR_MESSAGE 1024 + +/** + * @brief Maximum length of metadata strings. + */ +#define RAC_MAX_METADATA_STRING 256 + +/** + * @brief Structured error type matching Swift's SDKError. + * + * Contains all information needed for error reporting, logging, and telemetry. + */ +typedef struct rac_error { + // Core error info + rac_result_t code; /**< Error code (RAC_ERROR_*) */ + rac_error_category_t category; /**< Error category */ + char message[RAC_MAX_ERROR_MESSAGE]; /**< Human-readable message */ + + // Source location where error occurred + char source_file[RAC_MAX_METADATA_STRING]; /**< Source file name */ + int32_t source_line; /**< Source line number */ + char source_function[RAC_MAX_METADATA_STRING]; /**< Function name */ + + // Stack trace + rac_stack_frame_t stack_frames[RAC_MAX_STACK_FRAMES]; + int32_t stack_frame_count; + + // Underlying error (optional) + rac_result_t underlying_code; /**< Underlying error code (0 if none) */ + char underlying_message[RAC_MAX_ERROR_MESSAGE]; /**< Underlying error message */ + + // Context metadata + char model_id[RAC_MAX_METADATA_STRING]; /**< Model ID if applicable */ + char framework[RAC_MAX_METADATA_STRING]; /**< Framework (e.g., "sherpa-onnx") */ + char session_id[RAC_MAX_METADATA_STRING]; /**< Session ID for correlation */ + + // Timing + int64_t timestamp_ms; /**< When error occurred (unix ms) */ + + // Custom metadata (key-value pairs for extensibility) + char custom_key1[64]; + char custom_value1[RAC_MAX_METADATA_STRING]; + char custom_key2[64]; + char custom_value2[RAC_MAX_METADATA_STRING]; + char custom_key3[64]; + char custom_value3[RAC_MAX_METADATA_STRING]; +} rac_error_t; + +// ============================================================================= +// ERROR CREATION & DESTRUCTION +// ============================================================================= + +/** + * @brief Creates a new structured error. + * + * @param code Error code (RAC_ERROR_*) + * @param category Error category + * @param message Human-readable error message + * @return New error instance (caller must call rac_error_destroy) + */ +RAC_API rac_error_t* rac_error_create(rac_result_t code, rac_error_category_t category, + const char* message); + +/** + * @brief Creates an error with source location. + * + * Use the RAC_ERROR_HERE macro for convenient source location capture. + * + * @param code Error code + * @param category Error category + * @param message Error message + * @param file Source file (__FILE__) + * @param line Source line (__LINE__) + * @param function Function name (__func__) + * @return New error instance + */ +RAC_API rac_error_t* rac_error_create_at(rac_result_t code, rac_error_category_t category, + const char* message, const char* file, int32_t line, + const char* function); + +/** + * @brief Creates an error with formatted message. + * + * @param code Error code + * @param category Error category + * @param format Printf-style format string + * @param ... Format arguments + * @return New error instance + */ +RAC_API rac_error_t* rac_error_createf(rac_result_t code, rac_error_category_t category, + const char* format, ...); + +/** + * @brief Destroys a structured error and frees memory. + * + * @param error Error to destroy (can be NULL) + */ +RAC_API void rac_error_destroy(rac_error_t* error); + +/** + * @brief Creates a copy of an error. + * + * @param error Error to copy + * @return New copy of the error (caller must destroy) + */ +RAC_API rac_error_t* rac_error_copy(const rac_error_t* error); + +// ============================================================================= +// ERROR CONFIGURATION +// ============================================================================= + +/** + * @brief Sets the source location for an error. + * + * @param error Error to modify + * @param file Source file name + * @param line Source line number + * @param function Function name + */ +RAC_API void rac_error_set_source(rac_error_t* error, const char* file, int32_t line, + const char* function); + +/** + * @brief Sets the underlying error. + * + * @param error Error to modify + * @param underlying_code Underlying error code + * @param underlying_message Underlying error message + */ +RAC_API void rac_error_set_underlying(rac_error_t* error, rac_result_t underlying_code, + const char* underlying_message); + +/** + * @brief Sets model context for the error. + * + * @param error Error to modify + * @param model_id Model ID + * @param framework Framework name (e.g., "sherpa-onnx", "llama.cpp") + */ +RAC_API void rac_error_set_model_context(rac_error_t* error, const char* model_id, + const char* framework); + +/** + * @brief Sets session ID for correlation. + * + * @param error Error to modify + * @param session_id Session ID + */ +RAC_API void rac_error_set_session(rac_error_t* error, const char* session_id); + +/** + * @brief Sets custom metadata on the error. + * + * @param error Error to modify + * @param index Custom slot (0-2) + * @param key Metadata key + * @param value Metadata value + */ +RAC_API void rac_error_set_custom(rac_error_t* error, int32_t index, const char* key, + const char* value); + +// ============================================================================= +// STACK TRACE +// ============================================================================= + +/** + * @brief Captures the current stack trace into the error. + * + * Platform-dependent. On some platforms, only addresses may be captured + * and symbolication happens later. + * + * @param error Error to capture stack trace into + * @return Number of frames captured + */ +RAC_API int32_t rac_error_capture_stack_trace(rac_error_t* error); + +/** + * @brief Adds a manual stack frame to the error. + * + * Use this when automatic stack capture is not available. + * + * @param error Error to modify + * @param function Function name + * @param file File name + * @param line Line number + */ +RAC_API void rac_error_add_frame(rac_error_t* error, const char* function, const char* file, + int32_t line); + +// ============================================================================= +// ERROR INFORMATION +// ============================================================================= + +/** + * @brief Gets the error code name as a string. + * + * @param code Error code + * @return Static string with code name (e.g., "MODEL_NOT_FOUND") + */ +RAC_API const char* rac_error_code_name(rac_result_t code); + +/** + * @brief Gets the category name as a string. + * + * @param category Error category + * @return Static string with category name (e.g., "stt", "llm") + */ +RAC_API const char* rac_error_category_name(rac_error_category_t category); + +/** + * @brief Gets a recovery suggestion for the error. + * + * Mirrors Swift's SDKError.recoverySuggestion. + * + * @param code Error code + * @return Static string with suggestion, or NULL if none + */ +RAC_API const char* rac_error_recovery_suggestion(rac_result_t code); + +/** + * @brief Checks if an error is expected (like cancellation). + * + * Expected errors should typically not be logged as errors. + * + * @param error Error to check + * @return RAC_TRUE if expected, RAC_FALSE otherwise + */ +RAC_API rac_bool_t rac_error_is_expected_error(const rac_error_t* error); + +// ============================================================================= +// SERIALIZATION +// ============================================================================= + +/** + * @brief Serializes error to JSON string for telemetry. + * + * Returns a compact JSON representation suitable for sending to analytics. + * The returned string must be freed with rac_free(). + * + * @param error Error to serialize + * @return JSON string (caller must free), or NULL on failure + */ +RAC_API char* rac_error_to_json(const rac_error_t* error); + +/** + * @brief Gets telemetry properties as key-value pairs. + * + * Returns essential fields for analytics/telemetry events. + * Keys and values must be freed by caller. + * + * @param error Error to get properties from + * @param out_keys Output array of keys (caller allocates, at least 10 slots) + * @param out_values Output array of values (caller allocates, at least 10 slots) + * @return Number of properties written + */ +RAC_API int32_t rac_error_get_telemetry_properties(const rac_error_t* error, char** out_keys, + char** out_values); + +/** + * @brief Formats error as a human-readable string. + * + * Format: "SDKError[category.code]: message" + * The returned string must be freed with rac_free(). + * + * @param error Error to format + * @return Formatted string (caller must free) + */ +RAC_API char* rac_error_to_string(const rac_error_t* error); + +/** + * @brief Formats error with full debug info including stack trace. + * + * The returned string must be freed with rac_free(). + * + * @param error Error to format + * @return Debug string (caller must free) + */ +RAC_API char* rac_error_to_debug_string(const rac_error_t* error); + +// ============================================================================= +// CONVENIENCE MACROS +// ============================================================================= + +/** + * @brief Creates an error with automatic source location capture. + */ +#define RAC_ERROR(code, category, message) \ + rac_error_create_at(code, category, message, __FILE__, __LINE__, __func__) + +/** + * @brief Creates an error with formatted message and source location. + */ +#define RAC_ERRORF(code, category, ...) \ + rac_error_create_at_f(code, category, __FILE__, __LINE__, __func__, __VA_ARGS__) + +/** + * @brief Category-specific error macros. + */ +#define RAC_ERROR_STT(code, msg) RAC_ERROR(code, RAC_CATEGORY_STT, msg) +#define RAC_ERROR_TTS(code, msg) RAC_ERROR(code, RAC_CATEGORY_TTS, msg) +#define RAC_ERROR_LLM(code, msg) RAC_ERROR(code, RAC_CATEGORY_LLM, msg) +#define RAC_ERROR_VAD(code, msg) RAC_ERROR(code, RAC_CATEGORY_VAD, msg) +#define RAC_ERROR_GENERAL(code, msg) RAC_ERROR(code, RAC_CATEGORY_GENERAL, msg) +#define RAC_ERROR_NETWORK(code, msg) RAC_ERROR(code, RAC_CATEGORY_NETWORK, msg) +#define RAC_ERROR_DOWNLOAD(code, msg) RAC_ERROR(code, RAC_CATEGORY_DOWNLOAD, msg) + +// ============================================================================= +// GLOBAL ERROR (Thread-Local Last Error) +// ============================================================================= + +/** + * @brief Sets the last error for the current thread. + * + * This copies the error into thread-local storage. The original error + * can be destroyed after this call. + * + * @param error Error to set (can be NULL to clear) + */ +RAC_API void rac_set_last_error(const rac_error_t* error); + +/** + * @brief Gets the last error for the current thread. + * + * @return Pointer to thread-local error (do not free), or NULL if none + */ +RAC_API const rac_error_t* rac_get_last_error(void); + +/** + * @brief Clears the last error for the current thread. + */ +RAC_API void rac_clear_last_error(void); + +/** + * @brief Convenience: creates, logs, and sets last error in one call. + * + * @param code Error code + * @param category Error category + * @param message Error message + * @return The error code (for easy return statements) + */ +RAC_API rac_result_t rac_set_error(rac_result_t code, rac_error_category_t category, + const char* message); + +/** + * @brief Convenience macro to set error and return. + */ +#define RAC_RETURN_ERROR(code, category, msg) return rac_set_error(code, category, msg) + +// ============================================================================= +// UNIFIED ERROR HANDLING (Log + Track) +// ============================================================================= + +/** + * @brief Creates, logs, and tracks a structured error. + * + * This is the recommended way to handle errors in C++ code. It: + * 1. Creates a structured error with source location + * 2. Captures stack trace (if available) + * 3. Logs the error via the logging system + * 4. Sends to error tracking (Sentry) via platform adapter + * 5. Sets as last error for retrieval + * + * @param code Error code + * @param category Error category + * @param message Error message + * @param file Source file (__FILE__) + * @param line Source line (__LINE__) + * @param function Function name (__func__) + * @return The error code (for easy return statements) + */ +RAC_API rac_result_t rac_error_log_and_track(rac_result_t code, rac_error_category_t category, + const char* message, const char* file, int32_t line, + const char* function); + +/** + * @brief Creates, logs, and tracks a structured error with model context. + * + * Same as rac_error_log_and_track but includes model information. + * + * @param code Error code + * @param category Error category + * @param message Error message + * @param model_id Model ID + * @param framework Framework name + * @param file Source file + * @param line Source line + * @param function Function name + * @return The error code + */ +RAC_API rac_result_t rac_error_log_and_track_model(rac_result_t code, rac_error_category_t category, + const char* message, const char* model_id, + const char* framework, const char* file, + int32_t line, const char* function); + +/** + * @brief Convenience macro to create, log, track error and return. + * + * Usage: + * if (model == NULL) { + * RAC_RETURN_TRACKED_ERROR(RAC_ERROR_MODEL_NOT_FOUND, RAC_CATEGORY_LLM, "Model not found"); + * } + */ +#define RAC_RETURN_TRACKED_ERROR(code, category, msg) \ + return rac_error_log_and_track(code, category, msg, __FILE__, __LINE__, __func__) + +/** + * @brief Convenience macro with model context. + */ +#define RAC_RETURN_TRACKED_ERROR_MODEL(code, category, msg, model_id, framework) \ + return rac_error_log_and_track_model(code, category, msg, model_id, framework, __FILE__, \ + __LINE__, __func__) + +#ifdef __cplusplus +} +#endif + +// ============================================================================= +// C++ CONVENIENCE CLASS +// ============================================================================= + +#ifdef __cplusplus + +#include +#include + +namespace rac { + +/** + * @brief RAII wrapper for rac_error_t. + */ +class Error { + public: + Error(rac_result_t code, rac_error_category_t category, const char* message) + : error_(rac_error_create(code, category, message), rac_error_destroy) {} + + Error(rac_error_t* error) : error_(error, rac_error_destroy) {} + + // Factory methods + static Error stt(rac_result_t code, const char* msg) { return {code, RAC_CATEGORY_STT, msg}; } + static Error tts(rac_result_t code, const char* msg) { return {code, RAC_CATEGORY_TTS, msg}; } + static Error llm(rac_result_t code, const char* msg) { return {code, RAC_CATEGORY_LLM, msg}; } + static Error vad(rac_result_t code, const char* msg) { return {code, RAC_CATEGORY_VAD, msg}; } + static Error network(rac_result_t code, const char* msg) { + return {code, RAC_CATEGORY_NETWORK, msg}; + } + + // Accessors + rac_result_t code() const { return error_ ? error_->code : RAC_SUCCESS; } + rac_error_category_t category() const { + return error_ ? error_->category : RAC_CATEGORY_GENERAL; + } + const char* message() const { return error_ ? error_->message : ""; } + + // Configuration + Error& setModelContext(const char* model_id, const char* framework) { + if (error_) + rac_error_set_model_context(error_.get(), model_id, framework); + return *this; + } + + Error& setSession(const char* session_id) { + if (error_) + rac_error_set_session(error_.get(), session_id); + return *this; + } + + Error& captureStackTrace() { + if (error_) + rac_error_capture_stack_trace(error_.get()); + return *this; + } + + // Conversion + std::string toString() const { + if (!error_) + return ""; + char* str = rac_error_to_string(error_.get()); + std::string result(str ? str : ""); + rac_free(str); + return result; + } + + std::string toJson() const { + if (!error_) + return "{}"; + char* json = rac_error_to_json(error_.get()); + std::string result(json ? json : "{}"); + rac_free(json); + return result; + } + + // Raw access + rac_error_t* get() { return error_.get(); } + const rac_error_t* get() const { return error_.get(); } + operator bool() const { return error_ != nullptr; } + + private: + std::unique_ptr error_; +}; + +} // namespace rac + +#endif // __cplusplus + +#endif /* RAC_STRUCTURED_ERROR_H */ diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_stt.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_stt.h new file mode 100644 index 000000000..7ae93e69e --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_stt.h @@ -0,0 +1,17 @@ +/** + * @file rac_stt.h + * @brief RunAnywhere Commons - STT API (Convenience Header) + * + * This header includes both types and service interface for convenience. + * For better separation of concerns, prefer including: + * - rac_stt_types.h for data structures only + * - rac_stt_service.h for the service interface + */ + +#ifndef RAC_STT_H +#define RAC_STT_H + +#include "rac_stt_service.h" +#include "rac_stt_types.h" + +#endif /* RAC_STT_H */ diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_stt_analytics.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_stt_analytics.h new file mode 100644 index 000000000..389f6fd79 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_stt_analytics.h @@ -0,0 +1,204 @@ +/** + * @file rac_stt_analytics.h + * @brief STT analytics service - 1:1 port of STTAnalyticsService.swift + * + * Tracks transcription operations and metrics. + * Lifecycle events are handled by the lifecycle manager. + * + * NOTE: Audio length estimation assumes 16-bit PCM @ 16kHz (standard for STT). + * Formula: audioLengthMs = (bytes / 2) / 16000 * 1000 + * + * NOTE: Real-Time Factor (RTF) will be 0 or undefined for streaming transcription + * since audioLengthMs = 0 when audio is processed in chunks of unknown total length. + * + * Swift Source: Sources/RunAnywhere/Features/STT/Analytics/STTAnalyticsService.swift + */ + +#ifndef RAC_STT_ANALYTICS_H +#define RAC_STT_ANALYTICS_H + +#include "rac_types.h" +#include "rac_stt_types.h" +#include "rac_model_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// TYPES +// ============================================================================= + +/** + * @brief Opaque handle for STT analytics service + */ +typedef struct rac_stt_analytics_s* rac_stt_analytics_handle_t; + +/** + * @brief STT metrics structure + * Mirrors Swift's STTMetrics struct + */ +typedef struct rac_stt_metrics { + /** Total number of events tracked */ + int32_t total_events; + + /** Start time (milliseconds since epoch) */ + int64_t start_time_ms; + + /** Last event time (milliseconds since epoch, 0 if no events) */ + int64_t last_event_time_ms; + + /** Total number of transcriptions */ + int32_t total_transcriptions; + + /** Average confidence score across all transcriptions (0.0 to 1.0) */ + float average_confidence; + + /** Average processing latency in milliseconds */ + double average_latency_ms; + + /** Average real-time factor (processing time / audio length) */ + double average_real_time_factor; + + /** Total audio processed in milliseconds */ + double total_audio_processed_ms; +} rac_stt_metrics_t; + +// ============================================================================= +// LIFECYCLE +// ============================================================================= + +/** + * @brief Create an STT analytics service instance + * + * @param out_handle Output: Handle to the created service + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_stt_analytics_create(rac_stt_analytics_handle_t* out_handle); + +/** + * @brief Destroy an STT analytics service instance + * + * @param handle Handle to destroy + */ +RAC_API void rac_stt_analytics_destroy(rac_stt_analytics_handle_t handle); + +// ============================================================================= +// TRANSCRIPTION TRACKING +// ============================================================================= + +/** + * @brief Start tracking a transcription + * + * @param handle Analytics service handle + * @param model_id The STT model identifier + * @param audio_length_ms Duration of audio in milliseconds + * @param audio_size_bytes Size of audio data in bytes + * @param language Language code for transcription + * @param is_streaming Whether this is a streaming transcription + * @param sample_rate Audio sample rate in Hz (default: 16000) + * @param framework The inference framework being used + * @param out_transcription_id Output: Generated unique ID (owned, must be freed with rac_free) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_stt_analytics_start_transcription( + rac_stt_analytics_handle_t handle, const char* model_id, double audio_length_ms, + int32_t audio_size_bytes, const char* language, rac_bool_t is_streaming, int32_t sample_rate, + rac_inference_framework_t framework, char** out_transcription_id); + +/** + * @brief Track partial transcript (for streaming transcription) + * + * @param handle Analytics service handle + * @param text Partial transcript text + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_stt_analytics_track_partial_transcript(rac_stt_analytics_handle_t handle, + const char* text); + +/** + * @brief Track final transcript (for streaming transcription) + * + * @param handle Analytics service handle + * @param text Final transcript text + * @param confidence Confidence score (0.0 to 1.0) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_stt_analytics_track_final_transcript(rac_stt_analytics_handle_t handle, + const char* text, float confidence); + +/** + * @brief Complete a transcription + * + * @param handle Analytics service handle + * @param transcription_id The transcription ID + * @param text The transcribed text + * @param confidence Confidence score (0.0 to 1.0) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_stt_analytics_complete_transcription(rac_stt_analytics_handle_t handle, + const char* transcription_id, + const char* text, float confidence); + +/** + * @brief Track transcription failure + * + * @param handle Analytics service handle + * @param transcription_id The transcription ID + * @param error_code Error code + * @param error_message Error message + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_stt_analytics_track_transcription_failed(rac_stt_analytics_handle_t handle, + const char* transcription_id, + rac_result_t error_code, + const char* error_message); + +/** + * @brief Track language detection + * + * @param handle Analytics service handle + * @param language Detected language code + * @param confidence Detection confidence (0.0 to 1.0) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_stt_analytics_track_language_detection(rac_stt_analytics_handle_t handle, + const char* language, + float confidence); + +/** + * @brief Track an error during STT operations + * + * @param handle Analytics service handle + * @param error_code Error code + * @param error_message Error message + * @param operation Operation that failed + * @param model_id Model ID (can be NULL) + * @param transcription_id Transcription ID (can be NULL) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_stt_analytics_track_error(rac_stt_analytics_handle_t handle, + rac_result_t error_code, + const char* error_message, const char* operation, + const char* model_id, + const char* transcription_id); + +// ============================================================================= +// METRICS +// ============================================================================= + +/** + * @brief Get current analytics metrics + * + * @param handle Analytics service handle + * @param out_metrics Output: Metrics structure + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_stt_analytics_get_metrics(rac_stt_analytics_handle_t handle, + rac_stt_metrics_t* out_metrics); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_STT_ANALYTICS_H */ diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_stt_component.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_stt_component.h new file mode 100644 index 000000000..d8d438e9a --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_stt_component.h @@ -0,0 +1,162 @@ +/** + * @file rac_stt_component.h + * @brief RunAnywhere Commons - STT Capability Component + * + * C port of Swift's STTCapability.swift from: + * Sources/RunAnywhere/Features/STT/STTCapability.swift + * + * Actor-based STT capability that owns model lifecycle and transcription. + * Uses lifecycle manager for unified lifecycle + analytics handling. + */ + +#ifndef RAC_STT_COMPONENT_H +#define RAC_STT_COMPONENT_H + +#include "rac_lifecycle.h" +#include "rac_error.h" +#include "rac_stt_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// NOTE: rac_stt_config_t is defined in rac_stt_types.h (included above) + +// ============================================================================= +// STT COMPONENT API - Mirrors Swift's STTCapability +// ============================================================================= + +/** + * @brief Create an STT capability component + * + * @param out_handle Output: Handle to the component + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_stt_component_create(rac_handle_t* out_handle); + +/** + * @brief Configure the STT component + * + * @param handle Component handle + * @param config Configuration + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_stt_component_configure(rac_handle_t handle, + const rac_stt_config_t* config); + +/** + * @brief Check if model is loaded + * + * @param handle Component handle + * @return RAC_TRUE if loaded, RAC_FALSE otherwise + */ +RAC_API rac_bool_t rac_stt_component_is_loaded(rac_handle_t handle); + +/** + * @brief Get current model ID + * + * @param handle Component handle + * @return Current model ID (NULL if not loaded) + */ +RAC_API const char* rac_stt_component_get_model_id(rac_handle_t handle); + +/** + * @brief Load a model + * + * @param handle Component handle + * @param model_path File path to the model (used for loading) - REQUIRED + * @param model_id Model identifier for telemetry (e.g., "sherpa-onnx-whisper-tiny.en") + * Optional: if NULL, defaults to model_path + * @param model_name Human-readable model name (e.g., "Sherpa Whisper Tiny (ONNX)") + * Optional: if NULL, defaults to model_id + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_stt_component_load_model(rac_handle_t handle, const char* model_path, + const char* model_id, const char* model_name); + +/** + * @brief Unload the current model + * + * @param handle Component handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_stt_component_unload(rac_handle_t handle); + +/** + * @brief Cleanup and reset the component + * + * @param handle Component handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_stt_component_cleanup(rac_handle_t handle); + +/** + * @brief Transcribe audio data (batch mode) + * + * @param handle Component handle + * @param audio_data Audio data buffer + * @param audio_size Size of audio data in bytes + * @param options Transcription options (can be NULL for defaults) + * @param out_result Output: Transcription result + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_stt_component_transcribe(rac_handle_t handle, const void* audio_data, + size_t audio_size, + const rac_stt_options_t* options, + rac_stt_result_t* out_result); + +/** + * @brief Check if streaming is supported + * + * @param handle Component handle + * @return RAC_TRUE if streaming supported, RAC_FALSE otherwise + */ +RAC_API rac_bool_t rac_stt_component_supports_streaming(rac_handle_t handle); + +/** + * @brief Transcribe audio with streaming + * + * @param handle Component handle + * @param audio_data Audio chunk data + * @param audio_size Size of audio chunk + * @param options Transcription options + * @param callback Callback for partial results + * @param user_data User context passed to callback + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_stt_component_transcribe_stream(rac_handle_t handle, + const void* audio_data, size_t audio_size, + const rac_stt_options_t* options, + rac_stt_stream_callback_t callback, + void* user_data); + +/** + * @brief Get lifecycle state + * + * @param handle Component handle + * @return Current lifecycle state + */ +RAC_API rac_lifecycle_state_t rac_stt_component_get_state(rac_handle_t handle); + +/** + * @brief Get lifecycle metrics + * + * @param handle Component handle + * @param out_metrics Output: Lifecycle metrics + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_stt_component_get_metrics(rac_handle_t handle, + rac_lifecycle_metrics_t* out_metrics); + +/** + * @brief Destroy the STT component + * + * @param handle Component handle + */ +RAC_API void rac_stt_component_destroy(rac_handle_t handle); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_STT_COMPONENT_H */ diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_stt_events.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_stt_events.h new file mode 100644 index 000000000..96164dc89 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_stt_events.h @@ -0,0 +1,62 @@ +/** + * @file rac_stt_events.h + * @brief STT-specific event types - 1:1 port of STTEvent.swift + * + * Swift Source: Sources/RunAnywhere/Features/STT/Analytics/STTEvent.swift + */ + +#ifndef RAC_STT_EVENTS_H +#define RAC_STT_EVENTS_H + +#include "rac_types.h" +#include "rac_events.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// STT EVENT TYPES +// ============================================================================= + +typedef enum rac_stt_event_type { + RAC_STT_EVENT_TRANSCRIPTION_STARTED = 0, + RAC_STT_EVENT_PARTIAL_TRANSCRIPT, + RAC_STT_EVENT_FINAL_TRANSCRIPT, + RAC_STT_EVENT_TRANSCRIPTION_COMPLETED, + RAC_STT_EVENT_TRANSCRIPTION_FAILED, + RAC_STT_EVENT_LANGUAGE_DETECTED, +} rac_stt_event_type_t; + +// ============================================================================= +// EVENT PUBLISHING FUNCTIONS +// ============================================================================= + +RAC_API rac_result_t rac_stt_event_transcription_started( + const char* transcription_id, const char* model_id, double audio_length_ms, + int32_t audio_size_bytes, const char* language, rac_bool_t is_streaming, + rac_inference_framework_t framework); + +RAC_API rac_result_t rac_stt_event_partial_transcript(const char* text, int32_t word_count); + +RAC_API rac_result_t rac_stt_event_final_transcript(const char* text, float confidence); + +RAC_API rac_result_t rac_stt_event_transcription_completed( + const char* transcription_id, const char* model_id, const char* text, float confidence, + double duration_ms, double audio_length_ms, int32_t word_count, double real_time_factor, + const char* language, rac_bool_t is_streaming, rac_inference_framework_t framework); + +RAC_API rac_result_t rac_stt_event_transcription_failed(const char* transcription_id, + const char* model_id, + rac_result_t error_code, + const char* error_message); + +RAC_API rac_result_t rac_stt_event_language_detected(const char* language, float confidence); + +RAC_API const char* rac_stt_event_type_string(rac_stt_event_type_t event_type); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_STT_EVENTS_H */ diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_stt_onnx.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_stt_onnx.h new file mode 100644 index 000000000..f90506eee --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_stt_onnx.h @@ -0,0 +1,99 @@ +/** + * @file rac_stt_onnx.h + * @brief RunAnywhere Core - ONNX Backend RAC API for STT + * + * Direct RAC API export from runanywhere-core's ONNX STT backend. + * Mirrors Swift's ONNXSTTService implementation pattern. + */ + +#ifndef RAC_STT_ONNX_H +#define RAC_STT_ONNX_H + +#include "rac_error.h" +#include "rac_types.h" +#include "rac_stt.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// EXPORT MACRO +// ============================================================================= + +#if defined(RAC_ONNX_BUILDING) +#if defined(_WIN32) +#define RAC_ONNX_API __declspec(dllexport) +#elif defined(__GNUC__) || defined(__clang__) +#define RAC_ONNX_API __attribute__((visibility("default"))) +#else +#define RAC_ONNX_API +#endif +#else +#define RAC_ONNX_API +#endif + +// ============================================================================= +// CONFIGURATION +// ============================================================================= + +/** + * ONNX STT model types. + */ +typedef enum rac_stt_onnx_model_type { + RAC_STT_ONNX_MODEL_WHISPER = 0, + RAC_STT_ONNX_MODEL_ZIPFORMER = 1, + RAC_STT_ONNX_MODEL_PARAFORMER = 2, + RAC_STT_ONNX_MODEL_AUTO = 99 +} rac_stt_onnx_model_type_t; + +/** + * ONNX STT configuration. + */ +typedef struct rac_stt_onnx_config { + rac_stt_onnx_model_type_t model_type; + int32_t num_threads; + rac_bool_t use_coreml; +} rac_stt_onnx_config_t; + +static const rac_stt_onnx_config_t RAC_STT_ONNX_CONFIG_DEFAULT = { + .model_type = RAC_STT_ONNX_MODEL_AUTO, .num_threads = 0, .use_coreml = RAC_TRUE}; + +// ============================================================================= +// ONNX STT API +// ============================================================================= + +RAC_ONNX_API rac_result_t rac_stt_onnx_create(const char* model_path, + const rac_stt_onnx_config_t* config, + rac_handle_t* out_handle); + +RAC_ONNX_API rac_result_t rac_stt_onnx_transcribe(rac_handle_t handle, const float* audio_samples, + size_t num_samples, + const rac_stt_options_t* options, + rac_stt_result_t* out_result); + +RAC_ONNX_API rac_bool_t rac_stt_onnx_supports_streaming(rac_handle_t handle); + +RAC_ONNX_API rac_result_t rac_stt_onnx_create_stream(rac_handle_t handle, rac_handle_t* out_stream); + +RAC_ONNX_API rac_result_t rac_stt_onnx_feed_audio(rac_handle_t handle, rac_handle_t stream, + const float* audio_samples, size_t num_samples); + +RAC_ONNX_API rac_bool_t rac_stt_onnx_stream_is_ready(rac_handle_t handle, rac_handle_t stream); + +RAC_ONNX_API rac_result_t rac_stt_onnx_decode_stream(rac_handle_t handle, rac_handle_t stream, + char** out_text); + +RAC_ONNX_API void rac_stt_onnx_input_finished(rac_handle_t handle, rac_handle_t stream); + +RAC_ONNX_API rac_bool_t rac_stt_onnx_is_endpoint(rac_handle_t handle, rac_handle_t stream); + +RAC_ONNX_API void rac_stt_onnx_destroy_stream(rac_handle_t handle, rac_handle_t stream); + +RAC_ONNX_API void rac_stt_onnx_destroy(rac_handle_t handle); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_STT_ONNX_H */ diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_stt_service.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_stt_service.h new file mode 100644 index 000000000..c51cd4ab3 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_stt_service.h @@ -0,0 +1,154 @@ +/** + * @file rac_stt_service.h + * @brief RunAnywhere Commons - STT Service Interface + * + * Defines the generic STT service API and vtable for multi-backend dispatch. + * Backends (ONNX, Whisper, etc.) implement the vtable and register + * with the service registry. + */ + +#ifndef RAC_STT_SERVICE_H +#define RAC_STT_SERVICE_H + +#include "rac_error.h" +#include "rac_stt_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// SERVICE VTABLE - Backend implementations provide this +// ============================================================================= + +/** + * STT Service operations vtable. + * Each backend implements these functions and provides a static vtable. + */ +typedef struct rac_stt_service_ops { + /** Initialize the service with a model path */ + rac_result_t (*initialize)(void* impl, const char* model_path); + + /** Transcribe audio (batch mode) */ + rac_result_t (*transcribe)(void* impl, const void* audio_data, size_t audio_size, + const rac_stt_options_t* options, rac_stt_result_t* out_result); + + /** Stream transcription for real-time processing */ + rac_result_t (*transcribe_stream)(void* impl, const void* audio_data, size_t audio_size, + const rac_stt_options_t* options, + rac_stt_stream_callback_t callback, void* user_data); + + /** Get service info */ + rac_result_t (*get_info)(void* impl, rac_stt_info_t* out_info); + + /** Cleanup/unload model (keeps service alive) */ + rac_result_t (*cleanup)(void* impl); + + /** Destroy the service */ + void (*destroy)(void* impl); +} rac_stt_service_ops_t; + +/** + * STT Service instance. + * Contains vtable pointer and backend-specific implementation. + */ +typedef struct rac_stt_service { + /** Vtable with backend operations */ + const rac_stt_service_ops_t* ops; + + /** Backend-specific implementation handle */ + void* impl; + + /** Model ID for reference */ + const char* model_id; +} rac_stt_service_t; + +// ============================================================================= +// PUBLIC API - Generic service functions +// ============================================================================= + +/** + * @brief Create an STT service + * + * Routes through service registry to find appropriate backend. + * + * @param model_path Path to the model file (can be NULL for some providers) + * @param out_handle Output: Handle to the created service + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_stt_create(const char* model_path, rac_handle_t* out_handle); + +/** + * @brief Initialize an STT service + * + * @param handle Service handle + * @param model_path Path to the model file (can be NULL) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_stt_initialize(rac_handle_t handle, const char* model_path); + +/** + * @brief Transcribe audio data (batch mode) + * + * @param handle Service handle + * @param audio_data Audio data buffer + * @param audio_size Size of audio data in bytes + * @param options Transcription options (can be NULL for defaults) + * @param out_result Output: Transcription result (caller must free with rac_stt_result_free) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_stt_transcribe(rac_handle_t handle, const void* audio_data, + size_t audio_size, const rac_stt_options_t* options, + rac_stt_result_t* out_result); + +/** + * @brief Stream transcription for real-time processing + * + * @param handle Service handle + * @param audio_data Audio chunk data + * @param audio_size Size of audio chunk + * @param options Transcription options + * @param callback Callback for partial results + * @param user_data User context passed to callback + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_stt_transcribe_stream(rac_handle_t handle, const void* audio_data, + size_t audio_size, const rac_stt_options_t* options, + rac_stt_stream_callback_t callback, void* user_data); + +/** + * @brief Get service information + * + * @param handle Service handle + * @param out_info Output: Service information + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_stt_get_info(rac_handle_t handle, rac_stt_info_t* out_info); + +/** + * @brief Cleanup and release resources + * + * @param handle Service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_stt_cleanup(rac_handle_t handle); + +/** + * @brief Destroy an STT service instance + * + * @param handle Service handle to destroy + */ +RAC_API void rac_stt_destroy(rac_handle_t handle); + +/** + * @brief Free an STT result + * + * @param result Result to free + */ +RAC_API void rac_stt_result_free(rac_stt_result_t* result); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_STT_SERVICE_H */ diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_stt_types.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_stt_types.h new file mode 100644 index 000000000..8343b24bb --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_stt_types.h @@ -0,0 +1,389 @@ +/** + * @file rac_stt_types.h + * @brief RunAnywhere Commons - STT Types and Data Structures + * + * C port of Swift's STT Models from: + * Sources/RunAnywhere/Features/STT/Models/STTConfiguration.swift + * Sources/RunAnywhere/Features/STT/Models/STTOptions.swift + * Sources/RunAnywhere/Features/STT/Models/STTInput.swift + * Sources/RunAnywhere/Features/STT/Models/STTOutput.swift + * Sources/RunAnywhere/Features/STT/Models/STTTranscriptionResult.swift + * + * This header defines data structures only. For the service interface, + * see rac_stt_service.h. + */ + +#ifndef RAC_STT_TYPES_H +#define RAC_STT_TYPES_H + +#include "rac_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// CONSTANTS - Single Source of Truth for STT +// Swift references these via CRACommons import +// ============================================================================= + +// Audio Format Constants +#define RAC_STT_DEFAULT_SAMPLE_RATE 16000 +#define RAC_STT_MAX_SAMPLE_RATE 48000 +#define RAC_STT_MIN_SAMPLE_RATE 8000 +#define RAC_STT_BYTES_PER_SAMPLE 2 +#define RAC_STT_CHANNELS 1 + +// Confidence Scores +#define RAC_STT_DEFAULT_CONFIDENCE 0.9f +#define RAC_STT_MIN_ACCEPTABLE_CONFIDENCE 0.5f + +// Streaming Constants +#define RAC_STT_DEFAULT_STREAMING_CHUNK_MS 100 +#define RAC_STT_MIN_STREAMING_CHUNK_MS 50 +#define RAC_STT_MAX_STREAMING_CHUNK_MS 1000 + +// Language +#define RAC_STT_DEFAULT_LANGUAGE "en" + +// ============================================================================= +// AUDIO FORMAT - Mirrors Swift's AudioFormat +// ============================================================================= + +/** + * @brief Audio format enumeration + * Mirrors Swift's AudioFormat from AudioTypes.swift + */ +typedef enum rac_audio_format_enum { + RAC_AUDIO_FORMAT_PCM = 0, + RAC_AUDIO_FORMAT_WAV = 1, + RAC_AUDIO_FORMAT_MP3 = 2, + RAC_AUDIO_FORMAT_OPUS = 3, + RAC_AUDIO_FORMAT_AAC = 4, + RAC_AUDIO_FORMAT_FLAC = 5 +} rac_audio_format_enum_t; + +// ============================================================================= +// CONFIGURATION - Mirrors Swift's STTConfiguration +// ============================================================================= + +/** + * @brief STT component configuration + * + * Mirrors Swift's STTConfiguration struct exactly. + * See: Sources/RunAnywhere/Features/STT/Models/STTConfiguration.swift + */ +typedef struct rac_stt_config { + /** Model ID (optional - uses default if NULL) */ + const char* model_id; + + /** Preferred framework for transcription (use -1 for auto) */ + int32_t preferred_framework; + + /** Language code for transcription (e.g., "en-US") */ + const char* language; + + /** Sample rate in Hz (default: 16000) */ + int32_t sample_rate; + + /** Enable automatic punctuation in transcription */ + rac_bool_t enable_punctuation; + + /** Enable speaker diarization */ + rac_bool_t enable_diarization; + + /** Vocabulary list for improved recognition (NULL-terminated array, can be NULL) */ + const char* const* vocabulary_list; + size_t num_vocabulary; + + /** Maximum number of alternative transcriptions (default: 1) */ + int32_t max_alternatives; + + /** Enable word-level timestamps */ + rac_bool_t enable_timestamps; +} rac_stt_config_t; + +/** + * @brief Default STT configuration + */ +static const rac_stt_config_t RAC_STT_CONFIG_DEFAULT = {.model_id = RAC_NULL, + .preferred_framework = -1, + .language = "en-US", + .sample_rate = RAC_STT_DEFAULT_SAMPLE_RATE, + .enable_punctuation = RAC_TRUE, + .enable_diarization = RAC_FALSE, + .vocabulary_list = RAC_NULL, + .num_vocabulary = 0, + .max_alternatives = 1, + .enable_timestamps = RAC_TRUE}; + +// ============================================================================= +// OPTIONS - Mirrors Swift's STTOptions +// ============================================================================= + +/** + * @brief STT transcription options + * + * Mirrors Swift's STTOptions struct. + * See: Sources/RunAnywhere/Features/STT/Models/STTOptions.swift + */ +typedef struct rac_stt_options { + /** Language code for transcription (e.g., "en", "es", "fr") */ + const char* language; + + /** Whether to auto-detect the spoken language */ + rac_bool_t detect_language; + + /** Enable automatic punctuation in transcription */ + rac_bool_t enable_punctuation; + + /** Enable speaker diarization */ + rac_bool_t enable_diarization; + + /** Maximum number of speakers (0 = auto) */ + int32_t max_speakers; + + /** Enable word-level timestamps */ + rac_bool_t enable_timestamps; + + /** Audio format of input data */ + rac_audio_format_enum_t audio_format; + + /** Sample rate of input audio (default: 16000 Hz) */ + int32_t sample_rate; +} rac_stt_options_t; + +/** + * @brief Default STT options + */ +static const rac_stt_options_t RAC_STT_OPTIONS_DEFAULT = {.language = "en", + .detect_language = RAC_FALSE, + .enable_punctuation = RAC_TRUE, + .enable_diarization = RAC_FALSE, + .max_speakers = 0, + .enable_timestamps = RAC_TRUE, + .audio_format = RAC_AUDIO_FORMAT_PCM, + .sample_rate = 16000}; + +// ============================================================================= +// RESULT - Mirrors Swift's STTTranscriptionResult +// ============================================================================= + +/** + * @brief Word timestamp information + */ +typedef struct rac_stt_word { + /** The word text */ + const char* text; + /** Start time in milliseconds */ + int64_t start_ms; + /** End time in milliseconds */ + int64_t end_ms; + /** Confidence score (0.0 to 1.0) */ + float confidence; +} rac_stt_word_t; + +/** + * @brief STT transcription result + * + * Mirrors Swift's STTTranscriptionResult struct. + */ +typedef struct rac_stt_result { + /** Full transcribed text (owned, must be freed with rac_free) */ + char* text; + + /** Detected language code (can be NULL) */ + char* detected_language; + + /** Word-level timestamps (can be NULL) */ + rac_stt_word_t* words; + size_t num_words; + + /** Overall confidence score (0.0 to 1.0) */ + float confidence; + + /** Processing time in milliseconds */ + int64_t processing_time_ms; +} rac_stt_result_t; + +// ============================================================================= +// INFO - Mirrors Swift's STTService properties +// ============================================================================= + +/** + * @brief STT service info + */ +typedef struct rac_stt_info { + /** Whether the service is ready */ + rac_bool_t is_ready; + + /** Current model identifier (can be NULL) */ + const char* current_model; + + /** Whether streaming is supported */ + rac_bool_t supports_streaming; +} rac_stt_info_t; + +// ============================================================================= +// CALLBACKS +// ============================================================================= + +/** + * @brief STT streaming callback + * + * Called for partial transcription results during streaming. + * + * @param partial_text Partial transcription text + * @param is_final Whether this is a final result + * @param user_data User-provided context + */ +typedef void (*rac_stt_stream_callback_t)(const char* partial_text, rac_bool_t is_final, + void* user_data); + +// ============================================================================= +// INPUT - Mirrors Swift's STTInput +// ============================================================================= + +/** + * @brief STT input data + * + * Mirrors Swift's STTInput struct. + * See: Sources/RunAnywhere/Features/STT/Models/STTInput.swift + */ +typedef struct rac_stt_input { + /** Audio data bytes (raw audio data) */ + const uint8_t* audio_data; + size_t audio_data_size; + + /** Alternative: audio buffer (PCM float samples) */ + const float* audio_samples; + size_t num_samples; + + /** Audio format of input data */ + rac_audio_format_enum_t format; + + /** Language code override (can be NULL to use config default) */ + const char* language; + + /** Sample rate of the audio (default: 16000) */ + int32_t sample_rate; + + /** Custom options override (can be NULL) */ + const rac_stt_options_t* options; +} rac_stt_input_t; + +/** + * @brief Default STT input + */ +static const rac_stt_input_t RAC_STT_INPUT_DEFAULT = {.audio_data = RAC_NULL, + .audio_data_size = 0, + .audio_samples = RAC_NULL, + .num_samples = 0, + .format = RAC_AUDIO_FORMAT_PCM, + .language = RAC_NULL, + .sample_rate = RAC_STT_DEFAULT_SAMPLE_RATE, + .options = RAC_NULL}; + +// ============================================================================= +// TRANSCRIPTION METADATA - Mirrors Swift's TranscriptionMetadata +// ============================================================================= + +/** + * @brief Transcription metadata + * + * Mirrors Swift's TranscriptionMetadata struct. + * See: Sources/RunAnywhere/Features/STT/Models/STTOutput.swift + */ +typedef struct rac_transcription_metadata { + /** Model ID used for transcription */ + const char* model_id; + + /** Processing time in milliseconds */ + int64_t processing_time_ms; + + /** Audio length in milliseconds */ + int64_t audio_length_ms; + + /** Real-time factor (processing_time / audio_length) */ + float real_time_factor; +} rac_transcription_metadata_t; + +// ============================================================================= +// TRANSCRIPTION ALTERNATIVE - Mirrors Swift's TranscriptionAlternative +// ============================================================================= + +/** + * @brief Alternative transcription + * + * Mirrors Swift's TranscriptionAlternative struct. + */ +typedef struct rac_transcription_alternative { + /** Alternative transcription text */ + const char* text; + + /** Confidence score (0.0 to 1.0) */ + float confidence; +} rac_transcription_alternative_t; + +// ============================================================================= +// OUTPUT - Mirrors Swift's STTOutput +// ============================================================================= + +/** + * @brief STT output data + * + * Mirrors Swift's STTOutput struct. + * See: Sources/RunAnywhere/Features/STT/Models/STTOutput.swift + */ +typedef struct rac_stt_output { + /** Transcribed text (owned, must be freed with rac_free) */ + char* text; + + /** Confidence score (0.0 to 1.0) */ + float confidence; + + /** Word-level timestamps (can be NULL) */ + rac_stt_word_t* word_timestamps; + size_t num_word_timestamps; + + /** Detected language if auto-detected (can be NULL) */ + char* detected_language; + + /** Alternative transcriptions (can be NULL) */ + rac_transcription_alternative_t* alternatives; + size_t num_alternatives; + + /** Processing metadata */ + rac_transcription_metadata_t metadata; + + /** Timestamp in milliseconds since epoch */ + int64_t timestamp_ms; +} rac_stt_output_t; + +// ============================================================================= +// TRANSCRIPTION RESULT - Alias for compatibility +// ============================================================================= + +/** + * @brief STT transcription result (alias for rac_stt_output_t) + * + * For compatibility with existing code that uses "result" terminology. + */ +typedef rac_stt_output_t rac_stt_transcription_result_t; + +// ============================================================================= +// MEMORY MANAGEMENT +// ============================================================================= + +/** + * @brief Free STT result resources + * + * @param result Result to free (can be NULL) + */ +RAC_API void rac_stt_result_free(rac_stt_result_t* result); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_STT_TYPES_H */ diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_stt_whispercpp.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_stt_whispercpp.h new file mode 100644 index 000000000..8b34ed269 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_stt_whispercpp.h @@ -0,0 +1,153 @@ +/** + * @file rac_stt_whispercpp.h + * @brief RunAnywhere Core - WhisperCPP Backend for STT + * + * RAC API for WhisperCPP-based speech-to-text. + * Provides high-quality transcription using whisper.cpp. + * + * NOTE: WhisperCPP and LlamaCPP both use GGML, which can cause symbol + * conflicts if linked together. Use ONNX Whisper for STT when also + * using LlamaCPP for LLM, or build with symbol prefixing. + */ + +#ifndef RAC_STT_WHISPERCPP_H +#define RAC_STT_WHISPERCPP_H + +#include "rac_error.h" +#include "rac_types.h" +#include "rac_stt.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// EXPORT MACRO +// ============================================================================= + +#if defined(RAC_WHISPERCPP_BUILDING) +#if defined(_WIN32) +#define RAC_WHISPERCPP_API __declspec(dllexport) +#elif defined(__GNUC__) || defined(__clang__) +#define RAC_WHISPERCPP_API __attribute__((visibility("default"))) +#else +#define RAC_WHISPERCPP_API +#endif +#else +#define RAC_WHISPERCPP_API +#endif + +// ============================================================================= +// CONFIGURATION +// ============================================================================= + +/** + * WhisperCPP-specific configuration. + */ +typedef struct rac_stt_whispercpp_config { + /** Number of threads (0 = auto) */ + int32_t num_threads; + + /** Enable GPU acceleration (Metal on Apple) */ + rac_bool_t use_gpu; + + /** Enable CoreML acceleration (Apple only) */ + rac_bool_t use_coreml; + + /** Language code for transcription (NULL = auto-detect) */ + const char* language; + + /** Translate to English (when source is non-English) */ + rac_bool_t translate; +} rac_stt_whispercpp_config_t; + +/** + * Default WhisperCPP configuration. + */ +static const rac_stt_whispercpp_config_t RAC_STT_WHISPERCPP_CONFIG_DEFAULT = { + .num_threads = 0, + .use_gpu = RAC_TRUE, + .use_coreml = RAC_TRUE, + .language = NULL, + .translate = RAC_FALSE}; + +// ============================================================================= +// WHISPERCPP STT API +// ============================================================================= + +/** + * Creates a WhisperCPP STT service. + * + * @param model_path Path to the Whisper GGML model file (.bin) + * @param config WhisperCPP-specific configuration (can be NULL for defaults) + * @param out_handle Output: Handle to the created service + * @return RAC_SUCCESS or error code + */ +RAC_WHISPERCPP_API rac_result_t rac_stt_whispercpp_create(const char* model_path, + const rac_stt_whispercpp_config_t* config, + rac_handle_t* out_handle); + +/** + * Transcribes audio data. + * + * @param handle Service handle + * @param audio_samples Float32 PCM samples (16kHz mono) + * @param num_samples Number of samples + * @param options STT options (can be NULL for defaults) + * @param out_result Output: Transcription result + * @return RAC_SUCCESS or error code + */ +RAC_WHISPERCPP_API rac_result_t rac_stt_whispercpp_transcribe(rac_handle_t handle, + const float* audio_samples, + size_t num_samples, + const rac_stt_options_t* options, + rac_stt_result_t* out_result); + +/** + * Gets detected language after transcription. + * + * @param handle Service handle + * @param out_language Output: Language code (caller must free) + * @return RAC_SUCCESS or error code + */ +RAC_WHISPERCPP_API rac_result_t rac_stt_whispercpp_get_language(rac_handle_t handle, + char** out_language); + +/** + * Checks if model is loaded and ready. + * + * @param handle Service handle + * @return RAC_TRUE if ready + */ +RAC_WHISPERCPP_API rac_bool_t rac_stt_whispercpp_is_ready(rac_handle_t handle); + +/** + * Destroys a WhisperCPP STT service. + * + * @param handle Service handle to destroy + */ +RAC_WHISPERCPP_API void rac_stt_whispercpp_destroy(rac_handle_t handle); + +// ============================================================================= +// BACKEND REGISTRATION +// ============================================================================= + +/** + * Registers the WhisperCPP backend with the commons module and service registries. + * + * @return RAC_SUCCESS or error code + */ +RAC_WHISPERCPP_API rac_result_t rac_backend_whispercpp_register(void); + +/** + * Unregisters the WhisperCPP backend. + * + * @return RAC_SUCCESS or error code + */ +RAC_WHISPERCPP_API rac_result_t rac_backend_whispercpp_unregister(void); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_STT_WHISPERCPP_H */ diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_telemetry_manager.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_telemetry_manager.h new file mode 100644 index 000000000..d938d7f22 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_telemetry_manager.h @@ -0,0 +1,206 @@ +/** + * @file rac_telemetry_manager.h + * @brief Telemetry manager - handles event queuing, batching, and serialization + * + * C++ handles all telemetry logic: + * - Convert analytics events to telemetry payloads + * - Queue and batch events + * - Group by modality for production + * - Serialize to JSON (environment-aware) + * - Callback to platform SDK for HTTP calls + * + * Platform SDKs only need to: + * - Provide device info + * - Make HTTP calls when callback is invoked + */ + +#ifndef RAC_TELEMETRY_MANAGER_H +#define RAC_TELEMETRY_MANAGER_H + +#include "rac_analytics_events.h" +#include "rac_types.h" +#include "rac_environment.h" +#include "rac_telemetry_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// TELEMETRY MANAGER +// ============================================================================= + +/** + * @brief Opaque telemetry manager handle + */ +typedef struct rac_telemetry_manager rac_telemetry_manager_t; + +/** + * @brief HTTP request callback from C++ to platform SDK + * + * C++ builds the JSON and determines the endpoint. + * Platform SDK just makes the HTTP call. + * + * @param user_data User data provided at registration + * @param endpoint The API endpoint path (e.g., "/api/v1/sdk/telemetry") + * @param json_body The JSON request body (null-terminated string) + * @param json_length Length of JSON body + * @param requires_auth Whether request needs authentication + */ +typedef void (*rac_telemetry_http_callback_t)(void* user_data, const char* endpoint, + const char* json_body, size_t json_length, + rac_bool_t requires_auth); + +/** + * @brief HTTP response callback from platform SDK to C++ + * + * Platform SDK calls this after HTTP completes. + * + * @param manager The telemetry manager + * @param success Whether HTTP call succeeded + * @param response_json Response JSON (can be NULL on failure) + * @param error_message Error message if failed (can be NULL) + */ +RAC_API void rac_telemetry_manager_http_complete(rac_telemetry_manager_t* manager, + rac_bool_t success, const char* response_json, + const char* error_message); + +// ============================================================================= +// LIFECYCLE +// ============================================================================= + +/** + * @brief Create telemetry manager + * + * @param env SDK environment (determines endpoint and encoding) + * @param device_id Persistent device UUID (from Keychain) + * @param platform Platform string ("ios", "android", etc.) + * @param sdk_version SDK version string + * @return Manager handle or NULL on failure + */ +RAC_API rac_telemetry_manager_t* rac_telemetry_manager_create(rac_environment_t env, + const char* device_id, + const char* platform, + const char* sdk_version); + +/** + * @brief Destroy telemetry manager + */ +RAC_API void rac_telemetry_manager_destroy(rac_telemetry_manager_t* manager); + +/** + * @brief Set device info for telemetry payloads + * + * Call this after creating the manager to set device details. + */ +RAC_API void rac_telemetry_manager_set_device_info(rac_telemetry_manager_t* manager, + const char* device_model, + const char* os_version); + +/** + * @brief Register HTTP callback + * + * Platform SDK must register this to receive HTTP requests. + */ +RAC_API void rac_telemetry_manager_set_http_callback(rac_telemetry_manager_t* manager, + rac_telemetry_http_callback_t callback, + void* user_data); + +// ============================================================================= +// EVENT TRACKING +// ============================================================================= + +/** + * @brief Track a telemetry payload directly + * + * Queues the payload for batching and sending. + */ +RAC_API rac_result_t rac_telemetry_manager_track(rac_telemetry_manager_t* manager, + const rac_telemetry_payload_t* payload); + +/** + * @brief Track from analytics event data + * + * Converts analytics event to telemetry payload and queues it. + */ +RAC_API rac_result_t rac_telemetry_manager_track_analytics(rac_telemetry_manager_t* manager, + rac_event_type_t event_type, + const rac_analytics_event_data_t* data); + +/** + * @brief Flush queued events immediately + * + * Sends all queued events to the backend. + */ +RAC_API rac_result_t rac_telemetry_manager_flush(rac_telemetry_manager_t* manager); + +// ============================================================================= +// JSON SERIALIZATION +// ============================================================================= + +/** + * @brief Serialize telemetry payload to JSON + * + * @param payload The payload to serialize + * @param env Environment (affects field names and which fields to include) + * @param out_json Output: JSON string (caller must free with rac_free) + * @param out_length Output: Length of JSON string + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_telemetry_manager_payload_to_json(const rac_telemetry_payload_t* payload, + rac_environment_t env, char** out_json, + size_t* out_length); + +/** + * @brief Serialize batch request to JSON + * + * @param request The batch request + * @param env Environment + * @param out_json Output: JSON string (caller must free with rac_free) + * @param out_length Output: Length of JSON string + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t +rac_telemetry_manager_batch_to_json(const rac_telemetry_batch_request_t* request, + rac_environment_t env, char** out_json, size_t* out_length); + +/** + * @brief Parse batch response from JSON + * + * @param json JSON response string + * @param out_response Output: Parsed response (caller must free) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_telemetry_manager_parse_response( + const char* json, rac_telemetry_batch_response_t* out_response); + +// ============================================================================= +// DEVICE REGISTRATION +// ============================================================================= + +/** + * @brief Serialize device registration request to JSON + * + * @param request The registration request + * @param env Environment + * @param out_json Output: JSON string (caller must free with rac_free) + * @param out_length Output: Length of JSON string + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t +rac_device_registration_to_json(const rac_device_registration_request_t* request, + rac_environment_t env, char** out_json, size_t* out_length); + +/** + * @brief Get device registration endpoint for environment + * + * @param env Environment + * @return Endpoint path string (static, do not free) + */ +RAC_API const char* rac_device_registration_endpoint(rac_environment_t env); + +#ifdef __cplusplus +} +#endif + +#endif // RAC_TELEMETRY_MANAGER_H diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_telemetry_types.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_telemetry_types.h new file mode 100644 index 000000000..74d663ec9 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_telemetry_types.h @@ -0,0 +1,234 @@ +/** + * @file rac_telemetry_types.h + * @brief Telemetry data structures - canonical source of truth + * + * All telemetry payloads are defined here. Platform SDKs (Swift, Kotlin, Flutter) + * use these types directly or create thin wrappers. + * + * Mirrors Swift's TelemetryEventPayload.swift structure. + */ + +#ifndef RAC_TELEMETRY_TYPES_H +#define RAC_TELEMETRY_TYPES_H + +#include "rac_types.h" +#include "rac_environment.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// TELEMETRY EVENT PAYLOAD +// ============================================================================= + +/** + * @brief Complete telemetry event payload + * + * Maps to backend SDKTelemetryEvent schema with all fields for: + * - LLM events (tokens, generation times, etc.) + * - STT events (audio duration, word count, etc.) + * - TTS events (character count, audio size, etc.) + * - VAD events (speech duration) + * - Model lifecycle events (size, archive type) + * - SDK lifecycle events (count) + * - Storage events (freed bytes) + * - Network events (online status) + */ +typedef struct rac_telemetry_payload { + // Required fields + const char* id; // Unique event ID (UUID) + const char* event_type; // Event type string + int64_t timestamp_ms; // Unix timestamp in milliseconds + int64_t created_at_ms; // When payload was created + + // Event classification + const char* modality; // "llm", "stt", "tts", "model", "system" + + // Device identification + const char* device_id; // Persistent device UUID + const char* session_id; // Optional session ID + + // Model info + const char* model_id; + const char* model_name; + const char* framework; // "llamacpp", "onnx", "mlx", etc. + + // Device info + const char* device; // Device model (e.g., "iPhone15,2") + const char* os_version; // OS version (e.g., "17.0") + const char* platform; // "ios", "android", "flutter" + const char* sdk_version; // SDK version string + + // Common performance metrics + double processing_time_ms; + rac_bool_t success; + rac_bool_t has_success; // Whether success field is set + const char* error_message; + const char* error_code; + + // LLM-specific fields + int32_t input_tokens; + int32_t output_tokens; + int32_t total_tokens; + double tokens_per_second; + double time_to_first_token_ms; + double prompt_eval_time_ms; + double generation_time_ms; + int32_t context_length; + double temperature; + int32_t max_tokens; + + // STT-specific fields + double audio_duration_ms; + double real_time_factor; + int32_t word_count; + double confidence; + const char* language; + rac_bool_t is_streaming; + rac_bool_t has_is_streaming; + int32_t segment_index; + + // TTS-specific fields + int32_t character_count; + double characters_per_second; + int32_t audio_size_bytes; + int32_t sample_rate; + const char* voice; + double output_duration_ms; + + // Model lifecycle fields + int64_t model_size_bytes; + const char* archive_type; + + // VAD fields + double speech_duration_ms; + + // SDK lifecycle fields + int32_t count; + + // Storage fields + int64_t freed_bytes; + + // Network fields + rac_bool_t is_online; + rac_bool_t has_is_online; +} rac_telemetry_payload_t; + +/** + * @brief Default/empty telemetry payload + */ +RAC_API rac_telemetry_payload_t rac_telemetry_payload_default(void); + +/** + * @brief Free any allocated strings in a telemetry payload + */ +RAC_API void rac_telemetry_payload_free(rac_telemetry_payload_t* payload); + +// ============================================================================= +// TELEMETRY BATCH REQUEST +// ============================================================================= + +/** + * @brief Batch telemetry request for API + * + * Supports both V1 and V2 storage paths: + * - V1 (legacy): modality = NULL → stores in sdk_telemetry_events table + * - V2 (normalized): modality = "llm"/"stt"/"tts"/"model" → normalized tables + */ +typedef struct rac_telemetry_batch_request { + rac_telemetry_payload_t* events; + size_t events_count; + const char* device_id; + int64_t timestamp_ms; + const char* modality; // NULL for V1, "llm"/"stt"/"tts"/"model" for V2 +} rac_telemetry_batch_request_t; + +/** + * @brief Batch telemetry response from API + */ +typedef struct rac_telemetry_batch_response { + rac_bool_t success; + int32_t events_received; + int32_t events_stored; + int32_t events_skipped; // Duplicates skipped + const char** errors; // Array of error messages + size_t errors_count; + const char* storage_version; // "V1" or "V2" +} rac_telemetry_batch_response_t; + +/** + * @brief Free batch response + */ +RAC_API void rac_telemetry_batch_response_free(rac_telemetry_batch_response_t* response); + +// ============================================================================= +// DEVICE REGISTRATION TYPES +// ============================================================================= + +/** + * @brief Device information for registration (telemetry-specific) + * + * Platform-specific values are passed in from Swift/Kotlin. + * Matches backend schemas/device.py DeviceInfo schema. + * Note: Named differently from rac_device_info_t to avoid conflict. + */ +typedef struct rac_device_registration_info { + // Required fields (backend schema) + const char* device_id; // Persistent UUID from Keychain/secure storage + const char* device_model; // "iPhone 16 Pro Max", "Pixel 7", etc. + const char* device_name; // User-assigned device name + const char* platform; // "ios", "android" + const char* os_version; // "17.0", "14" + const char* form_factor; // "phone", "tablet", "laptop", etc. + const char* architecture; // "arm64", "x86_64", etc. + const char* chip_name; // "A18 Pro", "Snapdragon 888", etc. + int64_t total_memory; // Total RAM in bytes + int64_t available_memory; // Available RAM in bytes + rac_bool_t has_neural_engine; // true if device has Neural Engine / NPU + int32_t neural_engine_cores; // Number of Neural Engine cores (0 if none) + const char* gpu_family; // "apple", "adreno", etc. + double battery_level; // 0.0-1.0, negative if unavailable + const char* battery_state; // "charging", "full", "unplugged", NULL if unavailable + rac_bool_t is_low_power_mode; // Low power mode enabled + int32_t core_count; // Total CPU cores + int32_t performance_cores; // Performance (P) cores + int32_t efficiency_cores; // Efficiency (E) cores + const char* device_fingerprint; // Unique device fingerprint (may be same as device_id) + + // Legacy fields (for backward compatibility) + const char* device_type; // "smartphone", "tablet", etc. (deprecated - use form_factor) + const char* os_name; // "iOS", "Android" (deprecated - use platform) + int64_t total_disk_bytes; + int64_t available_disk_bytes; + const char* processor_info; + int32_t processor_count; // Deprecated - use core_count + rac_bool_t is_simulator; + const char* locale; + const char* timezone; +} rac_device_registration_info_t; + +/** + * @brief Device registration request + */ +typedef struct rac_device_registration_request { + rac_device_registration_info_t device_info; + const char* sdk_version; + const char* build_token; // For development mode + int64_t last_seen_at_ms; +} rac_device_registration_request_t; + +/** + * @brief Device registration response + */ +typedef struct rac_device_registration_response { + const char* device_id; + const char* status; // "registered" or "updated" + const char* sync_status; // "synced" or "pending" +} rac_device_registration_response_t; + +#ifdef __cplusplus +} +#endif + +#endif // RAC_TELEMETRY_TYPES_H diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_tts.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_tts.h new file mode 100644 index 000000000..75c0eb12d --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_tts.h @@ -0,0 +1,17 @@ +/** + * @file rac_tts.h + * @brief RunAnywhere Commons - TTS API (Convenience Header) + * + * This header includes both types and service interface for convenience. + * For better separation of concerns, prefer including: + * - rac_tts_types.h for data structures only + * - rac_tts_service.h for the service interface + */ + +#ifndef RAC_TTS_H +#define RAC_TTS_H + +#include "rac_tts_service.h" +#include "rac_tts_types.h" + +#endif /* RAC_TTS_H */ diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_tts_analytics.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_tts_analytics.h new file mode 100644 index 000000000..ab3196adb --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_tts_analytics.h @@ -0,0 +1,181 @@ +/** + * @file rac_tts_analytics.h + * @brief TTS analytics service - 1:1 port of TTSAnalyticsService.swift + * + * Tracks synthesis operations and metrics. + * Lifecycle events are handled by the lifecycle manager. + * + * NOTE: Audio duration estimation assumes 16-bit PCM @ 22050Hz (standard for TTS). + * Formula: audioDurationMs = (bytes / 2) / 22050 * 1000 + * + * Swift Source: Sources/RunAnywhere/Features/TTS/Analytics/TTSAnalyticsService.swift + */ + +#ifndef RAC_TTS_ANALYTICS_H +#define RAC_TTS_ANALYTICS_H + +#include "rac_types.h" +#include "rac_tts_types.h" +#include "rac_model_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// TYPES +// ============================================================================= + +/** + * @brief Opaque handle for TTS analytics service + */ +typedef struct rac_tts_analytics_s* rac_tts_analytics_handle_t; + +/** + * @brief TTS metrics structure + * Mirrors Swift's TTSMetrics struct + */ +typedef struct rac_tts_metrics { + /** Total number of events tracked */ + int32_t total_events; + + /** Start time (milliseconds since epoch) */ + int64_t start_time_ms; + + /** Last event time (milliseconds since epoch, 0 if no events) */ + int64_t last_event_time_ms; + + /** Total number of syntheses */ + int32_t total_syntheses; + + /** Average synthesis speed (characters processed per second) */ + double average_characters_per_second; + + /** Average processing time in milliseconds */ + double average_processing_time_ms; + + /** Average audio duration in milliseconds */ + double average_audio_duration_ms; + + /** Total characters processed across all syntheses */ + int32_t total_characters_processed; + + /** Total audio size generated in bytes */ + int64_t total_audio_size_bytes; +} rac_tts_metrics_t; + +// ============================================================================= +// LIFECYCLE +// ============================================================================= + +/** + * @brief Create a TTS analytics service instance + * + * @param out_handle Output: Handle to the created service + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_tts_analytics_create(rac_tts_analytics_handle_t* out_handle); + +/** + * @brief Destroy a TTS analytics service instance + * + * @param handle Handle to destroy + */ +RAC_API void rac_tts_analytics_destroy(rac_tts_analytics_handle_t handle); + +// ============================================================================= +// SYNTHESIS TRACKING +// ============================================================================= + +/** + * @brief Start tracking a synthesis + * + * @param handle Analytics service handle + * @param text The text to synthesize + * @param voice The voice ID being used + * @param sample_rate Audio sample rate in Hz (default: 22050) + * @param framework The inference framework being used + * @param out_synthesis_id Output: Generated unique ID (owned, must be freed with rac_free) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_tts_analytics_start_synthesis(rac_tts_analytics_handle_t handle, + const char* text, const char* voice, + int32_t sample_rate, + rac_inference_framework_t framework, + char** out_synthesis_id); + +/** + * @brief Track synthesis chunk (for streaming synthesis) + * + * @param handle Analytics service handle + * @param synthesis_id The synthesis ID + * @param chunk_size Size of the chunk in bytes + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_tts_analytics_track_synthesis_chunk(rac_tts_analytics_handle_t handle, + const char* synthesis_id, + int32_t chunk_size); + +/** + * @brief Complete a synthesis + * + * @param handle Analytics service handle + * @param synthesis_id The synthesis ID + * @param audio_duration_ms Duration of the generated audio in milliseconds + * @param audio_size_bytes Size of the generated audio in bytes + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_tts_analytics_complete_synthesis(rac_tts_analytics_handle_t handle, + const char* synthesis_id, + double audio_duration_ms, + int32_t audio_size_bytes); + +/** + * @brief Track synthesis failure + * + * @param handle Analytics service handle + * @param synthesis_id The synthesis ID + * @param error_code Error code + * @param error_message Error message + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_tts_analytics_track_synthesis_failed(rac_tts_analytics_handle_t handle, + const char* synthesis_id, + rac_result_t error_code, + const char* error_message); + +/** + * @brief Track an error during TTS operations + * + * @param handle Analytics service handle + * @param error_code Error code + * @param error_message Error message + * @param operation Operation that failed + * @param model_id Model ID (can be NULL) + * @param synthesis_id Synthesis ID (can be NULL) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_tts_analytics_track_error(rac_tts_analytics_handle_t handle, + rac_result_t error_code, + const char* error_message, const char* operation, + const char* model_id, const char* synthesis_id); + +// ============================================================================= +// METRICS +// ============================================================================= + +/** + * @brief Get current analytics metrics + * + * @param handle Analytics service handle + * @param out_metrics Output: Metrics structure + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_tts_analytics_get_metrics(rac_tts_analytics_handle_t handle, + rac_tts_metrics_t* out_metrics); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_TTS_ANALYTICS_H */ diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_tts_component.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_tts_component.h new file mode 100644 index 000000000..da3d374c7 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_tts_component.h @@ -0,0 +1,158 @@ +/** + * @file rac_tts_component.h + * @brief RunAnywhere Commons - TTS Capability Component + * + * C port of Swift's TTSCapability.swift from: + * Sources/RunAnywhere/Features/TTS/TTSCapability.swift + * + * Actor-based TTS capability that owns voice lifecycle and synthesis. + * Uses lifecycle manager for unified lifecycle + analytics handling. + */ + +#ifndef RAC_TTS_COMPONENT_H +#define RAC_TTS_COMPONENT_H + +#include "rac_lifecycle.h" +#include "rac_error.h" +#include "rac_tts_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// NOTE: rac_tts_config_t is defined in rac_tts_types.h (included above) + +// ============================================================================= +// TTS COMPONENT API - Mirrors Swift's TTSCapability +// ============================================================================= + +/** + * @brief Create a TTS capability component + * + * @param out_handle Output: Handle to the component + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_tts_component_create(rac_handle_t* out_handle); + +/** + * @brief Configure the TTS component + * + * @param handle Component handle + * @param config Configuration + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_tts_component_configure(rac_handle_t handle, + const rac_tts_config_t* config); + +/** + * @brief Check if voice is loaded + * + * @param handle Component handle + * @return RAC_TRUE if loaded, RAC_FALSE otherwise + */ +RAC_API rac_bool_t rac_tts_component_is_loaded(rac_handle_t handle); + +/** + * @brief Get current voice ID + * + * @param handle Component handle + * @return Current voice ID (NULL if not loaded) + */ +RAC_API const char* rac_tts_component_get_voice_id(rac_handle_t handle); + +/** + * @brief Load a voice + * + * @param handle Component handle + * @param voice_path File path to the voice (used for loading) - REQUIRED + * @param voice_id Voice identifier for telemetry (e.g., "vits-piper-en_GB-alba-medium") + * Optional: if NULL, defaults to voice_path + * @param voice_name Human-readable voice name (e.g., "Piper TTS (British English)") + * Optional: if NULL, defaults to voice_id + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_tts_component_load_voice(rac_handle_t handle, const char* voice_path, + const char* voice_id, const char* voice_name); + +/** + * @brief Unload the current voice + * + * @param handle Component handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_tts_component_unload(rac_handle_t handle); + +/** + * @brief Cleanup and reset the component + * + * @param handle Component handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_tts_component_cleanup(rac_handle_t handle); + +/** + * @brief Stop current synthesis + * + * @param handle Component handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_tts_component_stop(rac_handle_t handle); + +/** + * @brief Synthesize text to audio + * + * @param handle Component handle + * @param text Text to synthesize + * @param options Synthesis options (can be NULL for defaults) + * @param out_result Output: Synthesis result + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_tts_component_synthesize(rac_handle_t handle, const char* text, + const rac_tts_options_t* options, + rac_tts_result_t* out_result); + +/** + * @brief Synthesize text with streaming + * + * @param handle Component handle + * @param text Text to synthesize + * @param options Synthesis options + * @param callback Callback for audio chunks + * @param user_data User context passed to callback + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_tts_component_synthesize_stream(rac_handle_t handle, const char* text, + const rac_tts_options_t* options, + rac_tts_stream_callback_t callback, + void* user_data); + +/** + * @brief Get lifecycle state + * + * @param handle Component handle + * @return Current lifecycle state + */ +RAC_API rac_lifecycle_state_t rac_tts_component_get_state(rac_handle_t handle); + +/** + * @brief Get lifecycle metrics + * + * @param handle Component handle + * @param out_metrics Output: Lifecycle metrics + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_tts_component_get_metrics(rac_handle_t handle, + rac_lifecycle_metrics_t* out_metrics); + +/** + * @brief Destroy the TTS component + * + * @param handle Component handle + */ +RAC_API void rac_tts_component_destroy(rac_handle_t handle); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_TTS_COMPONENT_H */ diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_tts_events.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_tts_events.h new file mode 100644 index 000000000..0b11749a2 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_tts_events.h @@ -0,0 +1,54 @@ +/** + * @file rac_tts_events.h + * @brief TTS-specific event types - 1:1 port of TTSEvent.swift + * + * Swift Source: Sources/RunAnywhere/Features/TTS/Analytics/TTSEvent.swift + */ + +#ifndef RAC_TTS_EVENTS_H +#define RAC_TTS_EVENTS_H + +#include "rac_types.h" +#include "rac_events.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// TTS EVENT TYPES +// ============================================================================= + +typedef enum rac_tts_event_type { + RAC_TTS_EVENT_SYNTHESIS_STARTED = 0, + RAC_TTS_EVENT_SYNTHESIS_CHUNK, + RAC_TTS_EVENT_SYNTHESIS_COMPLETED, + RAC_TTS_EVENT_SYNTHESIS_FAILED, +} rac_tts_event_type_t; + +// ============================================================================= +// EVENT PUBLISHING FUNCTIONS +// ============================================================================= + +RAC_API rac_result_t rac_tts_event_synthesis_started(const char* synthesis_id, const char* model_id, + int32_t character_count, int32_t sample_rate, + rac_inference_framework_t framework); + +RAC_API rac_result_t rac_tts_event_synthesis_chunk(const char* synthesis_id, int32_t chunk_size); + +RAC_API rac_result_t rac_tts_event_synthesis_completed( + const char* synthesis_id, const char* model_id, int32_t character_count, + double audio_duration_ms, int32_t audio_size_bytes, double processing_duration_ms, + double characters_per_second, int32_t sample_rate, rac_inference_framework_t framework); + +RAC_API rac_result_t rac_tts_event_synthesis_failed(const char* synthesis_id, const char* model_id, + rac_result_t error_code, + const char* error_message); + +RAC_API const char* rac_tts_event_type_string(rac_tts_event_type_t event_type); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_TTS_EVENTS_H */ diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_tts_onnx.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_tts_onnx.h new file mode 100644 index 000000000..0fd90a1ee --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_tts_onnx.h @@ -0,0 +1,71 @@ +/** + * @file rac_tts_onnx.h + * @brief RunAnywhere Core - ONNX Backend RAC API for TTS + * + * Direct RAC API export from runanywhere-core's ONNX TTS backend. + */ + +#ifndef RAC_TTS_ONNX_H +#define RAC_TTS_ONNX_H + +#include "rac_error.h" +#include "rac_types.h" +#include "rac_tts.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// EXPORT MACRO +// ============================================================================= + +#if defined(RAC_ONNX_BUILDING) +#if defined(_WIN32) +#define RAC_ONNX_API __declspec(dllexport) +#elif defined(__GNUC__) || defined(__clang__) +#define RAC_ONNX_API __attribute__((visibility("default"))) +#else +#define RAC_ONNX_API +#endif +#else +#define RAC_ONNX_API +#endif + +// ============================================================================= +// CONFIGURATION +// ============================================================================= + +typedef struct rac_tts_onnx_config { + int32_t num_threads; + rac_bool_t use_coreml; + int32_t sample_rate; +} rac_tts_onnx_config_t; + +static const rac_tts_onnx_config_t RAC_TTS_ONNX_CONFIG_DEFAULT = { + .num_threads = 0, .use_coreml = RAC_TRUE, .sample_rate = 22050}; + +// ============================================================================= +// ONNX TTS API +// ============================================================================= + +RAC_ONNX_API rac_result_t rac_tts_onnx_create(const char* model_path, + const rac_tts_onnx_config_t* config, + rac_handle_t* out_handle); + +RAC_ONNX_API rac_result_t rac_tts_onnx_synthesize(rac_handle_t handle, const char* text, + const rac_tts_options_t* options, + rac_tts_result_t* out_result); + +RAC_ONNX_API rac_result_t rac_tts_onnx_get_voices(rac_handle_t handle, char*** out_voices, + size_t* out_count); + +RAC_ONNX_API void rac_tts_onnx_stop(rac_handle_t handle); + +RAC_ONNX_API void rac_tts_onnx_destroy(rac_handle_t handle); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_TTS_ONNX_H */ diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_tts_platform.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_tts_platform.h new file mode 100644 index 000000000..33cd91067 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_tts_platform.h @@ -0,0 +1,197 @@ +/** + * @file rac_tts_platform.h + * @brief RunAnywhere Commons - Platform TTS Backend (System TTS) + * + * C API for platform-native TTS services. On Apple platforms, this uses + * AVSpeechSynthesizer. The actual implementation is in Swift, with C++ + * providing the registration and callback infrastructure. + * + * This backend follows the same pattern as ONNX TTS backend, but delegates + * to Swift via function pointer callbacks since AVSpeechSynthesizer is + * an Apple-only framework. + */ + +#ifndef RAC_TTS_PLATFORM_H +#define RAC_TTS_PLATFORM_H + +#include "rac_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// TYPES +// ============================================================================= + +/** Opaque handle to platform TTS service */ +typedef struct rac_tts_platform* rac_tts_platform_handle_t; + +/** + * Platform TTS configuration. + */ +typedef struct rac_tts_platform_config { + /** Voice identifier (can be NULL for default) */ + const char* voice_id; + /** Language code (e.g., "en-US") */ + const char* language; + /** Reserved for future use */ + void* reserved; +} rac_tts_platform_config_t; + +/** + * Synthesis options for platform TTS. + */ +typedef struct rac_tts_platform_options { + /** Speech rate (0.5 = half speed, 1.0 = normal, 2.0 = double) */ + float rate; + /** Pitch multiplier (0.5 = low, 1.0 = normal, 2.0 = high) */ + float pitch; + /** Volume (0.0 = silent, 1.0 = full) */ + float volume; + /** Voice identifier override (can be NULL) */ + const char* voice_id; + /** Reserved for future options */ + void* reserved; +} rac_tts_platform_options_t; + +// ============================================================================= +// SWIFT CALLBACK TYPES +// ============================================================================= + +/** + * Callback to check if platform TTS can handle a voice ID. + * Implemented in Swift. + * + * @param voice_id Voice identifier to check (can be NULL) + * @param user_data User-provided context + * @return RAC_TRUE if this backend can handle the voice + */ +typedef rac_bool_t (*rac_platform_tts_can_handle_fn)(const char* voice_id, void* user_data); + +/** + * Callback to create platform TTS service. + * Implemented in Swift. + * + * @param config Configuration options + * @param user_data User-provided context + * @return Handle to created service (Swift object pointer), or NULL on failure + */ +typedef rac_handle_t (*rac_platform_tts_create_fn)(const rac_tts_platform_config_t* config, + void* user_data); + +/** + * Callback to synthesize speech. + * Implemented in Swift. + * + * @param handle Service handle from create + * @param text Text to synthesize + * @param options Synthesis options + * @param user_data User-provided context + * @return RAC_SUCCESS or error code + */ +typedef rac_result_t (*rac_platform_tts_synthesize_fn)(rac_handle_t handle, const char* text, + const rac_tts_platform_options_t* options, + void* user_data); + +/** + * Callback to stop speech. + * Implemented in Swift. + * + * @param handle Service handle + * @param user_data User-provided context + */ +typedef void (*rac_platform_tts_stop_fn)(rac_handle_t handle, void* user_data); + +/** + * Callback to destroy platform TTS service. + * Implemented in Swift. + * + * @param handle Service handle to destroy + * @param user_data User-provided context + */ +typedef void (*rac_platform_tts_destroy_fn)(rac_handle_t handle, void* user_data); + +/** + * Swift callbacks for platform TTS operations. + */ +typedef struct rac_platform_tts_callbacks { + rac_platform_tts_can_handle_fn can_handle; + rac_platform_tts_create_fn create; + rac_platform_tts_synthesize_fn synthesize; + rac_platform_tts_stop_fn stop; + rac_platform_tts_destroy_fn destroy; + void* user_data; +} rac_platform_tts_callbacks_t; + +// ============================================================================= +// CALLBACK REGISTRATION +// ============================================================================= + +/** + * Sets the Swift callbacks for platform TTS operations. + * Must be called before using platform TTS services. + * + * @param callbacks Callback functions (copied internally) + * @return RAC_SUCCESS on success + */ +RAC_API rac_result_t rac_platform_tts_set_callbacks(const rac_platform_tts_callbacks_t* callbacks); + +/** + * Gets the current Swift callbacks. + * + * @return Pointer to callbacks, or NULL if not set + */ +RAC_API const rac_platform_tts_callbacks_t* rac_platform_tts_get_callbacks(void); + +/** + * Checks if Swift callbacks are registered. + * + * @return RAC_TRUE if callbacks are available + */ +RAC_API rac_bool_t rac_platform_tts_is_available(void); + +// ============================================================================= +// SERVICE API +// ============================================================================= + +/** + * Creates a platform TTS service. + * + * @param config Configuration options (can be NULL for defaults) + * @param out_handle Output: Service handle + * @return RAC_SUCCESS on success, or error code + */ +RAC_API rac_result_t rac_tts_platform_create(const rac_tts_platform_config_t* config, + rac_tts_platform_handle_t* out_handle); + +/** + * Destroys a platform TTS service. + * + * @param handle Service handle to destroy + */ +RAC_API void rac_tts_platform_destroy(rac_tts_platform_handle_t handle); + +/** + * Synthesizes speech using platform TTS. + * + * @param handle Service handle + * @param text Text to synthesize + * @param options Synthesis options (can be NULL for defaults) + * @return RAC_SUCCESS on success, or error code + */ +RAC_API rac_result_t rac_tts_platform_synthesize(rac_tts_platform_handle_t handle, const char* text, + const rac_tts_platform_options_t* options); + +/** + * Stops current speech synthesis. + * + * @param handle Service handle + */ +RAC_API void rac_tts_platform_stop(rac_tts_platform_handle_t handle); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_TTS_PLATFORM_H */ diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_tts_service.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_tts_service.h new file mode 100644 index 000000000..3757b19a7 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_tts_service.h @@ -0,0 +1,162 @@ +/** + * @file rac_tts_service.h + * @brief RunAnywhere Commons - TTS Service Interface + * + * Defines the generic TTS service API and vtable for multi-backend dispatch. + * Backends (ONNX, Platform/System TTS, etc.) implement the vtable and register + * with the service registry. + */ + +#ifndef RAC_TTS_SERVICE_H +#define RAC_TTS_SERVICE_H + +#include "rac_error.h" +#include "rac_tts_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// SERVICE VTABLE - Backend implementations provide this +// ============================================================================= + +/** + * TTS Service operations vtable. + * Each backend implements these functions and provides a static vtable. + */ +typedef struct rac_tts_service_ops { + /** Initialize the service */ + rac_result_t (*initialize)(void* impl); + + /** Synthesize text to audio (blocking) */ + rac_result_t (*synthesize)(void* impl, const char* text, const rac_tts_options_t* options, + rac_tts_result_t* out_result); + + /** Stream synthesis for long text */ + rac_result_t (*synthesize_stream)(void* impl, const char* text, + const rac_tts_options_t* options, + rac_tts_stream_callback_t callback, void* user_data); + + /** Stop current synthesis */ + rac_result_t (*stop)(void* impl); + + /** Get service info */ + rac_result_t (*get_info)(void* impl, rac_tts_info_t* out_info); + + /** Cleanup/release resources (keeps service alive) */ + rac_result_t (*cleanup)(void* impl); + + /** Destroy the service */ + void (*destroy)(void* impl); +} rac_tts_service_ops_t; + +/** + * TTS Service instance. + * Contains vtable pointer and backend-specific implementation. + */ +typedef struct rac_tts_service { + /** Vtable with backend operations */ + const rac_tts_service_ops_t* ops; + + /** Backend-specific implementation handle */ + void* impl; + + /** Model/voice ID for reference */ + const char* model_id; +} rac_tts_service_t; + +// ============================================================================= +// PUBLIC API - Generic service functions +// ============================================================================= + +/** + * @brief Create a TTS service + * + * Routes through service registry to find appropriate backend. + * + * @param voice_id Voice/model identifier (registry ID or path) + * @param out_handle Output: Handle to the created service + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_tts_create(const char* voice_id, rac_handle_t* out_handle); + +/** + * @brief Initialize a TTS service + * + * @param handle Service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_tts_initialize(rac_handle_t handle); + +/** + * @brief Synthesize text to audio + * + * @param handle Service handle + * @param text Text to synthesize + * @param options Synthesis options (can be NULL for defaults) + * @param out_result Output: Synthesis result (caller must free with rac_tts_result_free) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_tts_synthesize(rac_handle_t handle, const char* text, + const rac_tts_options_t* options, + rac_tts_result_t* out_result); + +/** + * @brief Stream synthesis for long text + * + * @param handle Service handle + * @param text Text to synthesize + * @param options Synthesis options + * @param callback Callback for each audio chunk + * @param user_data User context passed to callback + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_tts_synthesize_stream(rac_handle_t handle, const char* text, + const rac_tts_options_t* options, + rac_tts_stream_callback_t callback, void* user_data); + +/** + * @brief Stop current synthesis + * + * @param handle Service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_tts_stop(rac_handle_t handle); + +/** + * @brief Get service information + * + * @param handle Service handle + * @param out_info Output: Service information + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_tts_get_info(rac_handle_t handle, rac_tts_info_t* out_info); + +/** + * @brief Cleanup and release resources + * + * @param handle Service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_tts_cleanup(rac_handle_t handle); + +/** + * @brief Destroy a TTS service instance + * + * @param handle Service handle to destroy + */ +RAC_API void rac_tts_destroy(rac_handle_t handle); + +/** + * @brief Free a TTS result + * + * @param result Result to free + */ +RAC_API void rac_tts_result_free(rac_tts_result_t* result); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_TTS_SERVICE_H */ diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_tts_types.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_tts_types.h new file mode 100644 index 000000000..3673b38a3 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_tts_types.h @@ -0,0 +1,374 @@ +/** + * @file rac_tts_types.h + * @brief RunAnywhere Commons - TTS Types and Data Structures + * + * C port of Swift's TTS Models from: + * Sources/RunAnywhere/Features/TTS/Models/TTSConfiguration.swift + * Sources/RunAnywhere/Features/TTS/Models/TTSOptions.swift + * Sources/RunAnywhere/Features/TTS/Models/TTSInput.swift + * Sources/RunAnywhere/Features/TTS/Models/TTSOutput.swift + * + * This header defines data structures only. For the service interface, + * see rac_tts_service.h. + */ + +#ifndef RAC_TTS_TYPES_H +#define RAC_TTS_TYPES_H + +#include "rac_types.h" +#include "rac_stt_types.h" // For rac_audio_format_enum_t + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// CONSTANTS - Single Source of Truth for TTS +// Swift references these via CRACommons import +// ============================================================================= + +// Audio Format Constants +#define RAC_TTS_DEFAULT_SAMPLE_RATE 22050 +#define RAC_TTS_HIGH_QUALITY_SAMPLE_RATE 24000 +#define RAC_TTS_CD_QUALITY_SAMPLE_RATE 44100 +#define RAC_TTS_MAX_SAMPLE_RATE 48000 +#define RAC_TTS_BYTES_PER_SAMPLE 2 +#define RAC_TTS_CHANNELS 1 + +// Speaking Rate Constants +#define RAC_TTS_DEFAULT_SPEAKING_RATE 1.0f +#define RAC_TTS_MIN_SPEAKING_RATE 0.5f +#define RAC_TTS_MAX_SPEAKING_RATE 2.0f + +// Streaming Constants +#define RAC_TTS_DEFAULT_STREAMING_CHUNK_BYTES 4096 + +// ============================================================================= +// CONFIGURATION - Mirrors Swift's TTSConfiguration +// ============================================================================= + +/** + * @brief TTS component configuration + * + * Mirrors Swift's TTSConfiguration struct exactly. + * See: Sources/RunAnywhere/Features/TTS/Models/TTSConfiguration.swift + */ +typedef struct rac_tts_config { + /** Model ID (voice identifier for TTS, optional) */ + const char* model_id; + + /** Preferred framework (use -1 for auto) */ + int32_t preferred_framework; + + /** Voice identifier to use for synthesis */ + const char* voice; + + /** Language for synthesis (BCP-47 format, e.g., "en-US") */ + const char* language; + + /** Speaking rate (0.5 to 2.0, 1.0 is normal) */ + float speaking_rate; + + /** Speech pitch (0.5 to 2.0, 1.0 is normal) */ + float pitch; + + /** Speech volume (0.0 to 1.0) */ + float volume; + + /** Audio format for output */ + rac_audio_format_enum_t audio_format; + + /** Whether to use neural/premium voice if available */ + rac_bool_t use_neural_voice; + + /** Whether to enable SSML markup support */ + rac_bool_t enable_ssml; +} rac_tts_config_t; + +/** + * @brief Default TTS configuration + */ +static const rac_tts_config_t RAC_TTS_CONFIG_DEFAULT = {.model_id = RAC_NULL, + .preferred_framework = -1, + .voice = RAC_NULL, + .language = "en-US", + .speaking_rate = 1.0f, + .pitch = 1.0f, + .volume = 1.0f, + .audio_format = RAC_AUDIO_FORMAT_PCM, + .use_neural_voice = RAC_TRUE, + .enable_ssml = RAC_FALSE}; + +// ============================================================================= +// OPTIONS - Mirrors Swift's TTSOptions +// ============================================================================= + +/** + * @brief TTS synthesis options + * + * Mirrors Swift's TTSOptions struct exactly. + * See: Sources/RunAnywhere/Features/TTS/Models/TTSOptions.swift + */ +typedef struct rac_tts_options { + /** Voice to use for synthesis (can be NULL for default) */ + const char* voice; + + /** Language for synthesis (BCP-47 format, e.g., "en-US") */ + const char* language; + + /** Speech rate (0.0 to 2.0, 1.0 is normal) */ + float rate; + + /** Speech pitch (0.0 to 2.0, 1.0 is normal) */ + float pitch; + + /** Speech volume (0.0 to 1.0) */ + float volume; + + /** Audio format for output */ + rac_audio_format_enum_t audio_format; + + /** Sample rate for output audio in Hz */ + int32_t sample_rate; + + /** Whether to use SSML markup */ + rac_bool_t use_ssml; +} rac_tts_options_t; + +/** + * @brief Default TTS options + */ +static const rac_tts_options_t RAC_TTS_OPTIONS_DEFAULT = {.voice = RAC_NULL, + .language = "en-US", + .rate = 1.0f, + .pitch = 1.0f, + .volume = 1.0f, + .audio_format = RAC_AUDIO_FORMAT_PCM, + .sample_rate = + RAC_TTS_DEFAULT_SAMPLE_RATE, + .use_ssml = RAC_FALSE}; + +// ============================================================================= +// INPUT - Mirrors Swift's TTSInput +// ============================================================================= + +/** + * @brief TTS input data + * + * Mirrors Swift's TTSInput struct exactly. + * See: Sources/RunAnywhere/Features/TTS/Models/TTSInput.swift + */ +typedef struct rac_tts_input { + /** Text to synthesize */ + const char* text; + + /** Optional SSML markup (overrides text if provided, can be NULL) */ + const char* ssml; + + /** Voice ID override (can be NULL) */ + const char* voice_id; + + /** Language override (can be NULL) */ + const char* language; + + /** Custom options override (can be NULL) */ + const rac_tts_options_t* options; +} rac_tts_input_t; + +/** + * @brief Default TTS input + */ +static const rac_tts_input_t RAC_TTS_INPUT_DEFAULT = {.text = RAC_NULL, + .ssml = RAC_NULL, + .voice_id = RAC_NULL, + .language = RAC_NULL, + .options = RAC_NULL}; + +// ============================================================================= +// RESULT - Mirrors Swift's TTS result +// ============================================================================= + +/** + * @brief TTS synthesis result + */ +typedef struct rac_tts_result { + /** Audio data (owned, must be freed with rac_free) */ + void* audio_data; + + /** Size of audio data in bytes */ + size_t audio_size; + + /** Audio format */ + rac_audio_format_enum_t audio_format; + + /** Sample rate */ + int32_t sample_rate; + + /** Duration in milliseconds */ + int64_t duration_ms; + + /** Processing time in milliseconds */ + int64_t processing_time_ms; +} rac_tts_result_t; + +// ============================================================================= +// INFO - Mirrors Swift's TTSService properties +// ============================================================================= + +/** + * @brief TTS service info + */ +typedef struct rac_tts_info { + /** Whether the service is ready */ + rac_bool_t is_ready; + + /** Whether currently synthesizing */ + rac_bool_t is_synthesizing; + + /** Available voices (null-terminated array) */ + const char* const* available_voices; + size_t num_voices; +} rac_tts_info_t; + +// ============================================================================= +// CALLBACKS +// ============================================================================= + +/** + * @brief TTS streaming callback + * + * Called for each audio chunk during streaming synthesis. + * + * @param audio_data Audio chunk data + * @param audio_size Size of audio chunk + * @param user_data User-provided context + */ +typedef void (*rac_tts_stream_callback_t)(const void* audio_data, size_t audio_size, + void* user_data); + +// ============================================================================= +// PHONEME TIMESTAMP - Mirrors Swift's TTSPhonemeTimestamp +// ============================================================================= + +/** + * @brief Phoneme timestamp information + * + * Mirrors Swift's TTSPhonemeTimestamp struct. + * See: Sources/RunAnywhere/Features/TTS/Models/TTSOutput.swift + */ +typedef struct rac_tts_phoneme_timestamp { + /** The phoneme */ + const char* phoneme; + + /** Start time in milliseconds */ + int64_t start_time_ms; + + /** End time in milliseconds */ + int64_t end_time_ms; +} rac_tts_phoneme_timestamp_t; + +// ============================================================================= +// SYNTHESIS METADATA - Mirrors Swift's TTSSynthesisMetadata +// ============================================================================= + +/** + * @brief Synthesis metadata + * + * Mirrors Swift's TTSSynthesisMetadata struct. + * See: Sources/RunAnywhere/Features/TTS/Models/TTSOutput.swift + */ +typedef struct rac_tts_synthesis_metadata { + /** Voice used for synthesis */ + const char* voice; + + /** Language used for synthesis */ + const char* language; + + /** Processing time in milliseconds */ + int64_t processing_time_ms; + + /** Number of characters synthesized */ + int32_t character_count; + + /** Characters processed per second */ + float characters_per_second; +} rac_tts_synthesis_metadata_t; + +// ============================================================================= +// OUTPUT - Mirrors Swift's TTSOutput +// ============================================================================= + +/** + * @brief TTS output data + * + * Mirrors Swift's TTSOutput struct exactly. + * See: Sources/RunAnywhere/Features/TTS/Models/TTSOutput.swift + */ +typedef struct rac_tts_output { + /** Synthesized audio data (owned, must be freed with rac_free) */ + void* audio_data; + + /** Size of audio data in bytes */ + size_t audio_size; + + /** Audio format of the output */ + rac_audio_format_enum_t format; + + /** Duration of the audio in milliseconds */ + int64_t duration_ms; + + /** Phoneme timestamps if available (can be NULL) */ + rac_tts_phoneme_timestamp_t* phoneme_timestamps; + size_t num_phoneme_timestamps; + + /** Processing metadata */ + rac_tts_synthesis_metadata_t metadata; + + /** Timestamp in milliseconds since epoch */ + int64_t timestamp_ms; +} rac_tts_output_t; + +// ============================================================================= +// SPEAK RESULT - Mirrors Swift's TTSSpeakResult +// ============================================================================= + +/** + * @brief Speak result (metadata only, no audio data) + * + * Mirrors Swift's TTSSpeakResult struct. + * The SDK handles audio playback internally when using speak(). + * See: Sources/RunAnywhere/Features/TTS/Models/TTSOutput.swift + */ +typedef struct rac_tts_speak_result { + /** Duration of the spoken audio in milliseconds */ + int64_t duration_ms; + + /** Audio format used */ + rac_audio_format_enum_t format; + + /** Audio size in bytes (0 for system TTS which plays directly) */ + size_t audio_size_bytes; + + /** Synthesis metadata */ + rac_tts_synthesis_metadata_t metadata; + + /** Timestamp when speech completed (milliseconds since epoch) */ + int64_t timestamp_ms; +} rac_tts_speak_result_t; + +// ============================================================================= +// MEMORY MANAGEMENT +// ============================================================================= + +/** + * @brief Free TTS result resources + * + * @param result Result to free (can be NULL) + */ +RAC_API void rac_tts_result_free(rac_tts_result_t* result); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_TTS_TYPES_H */ diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_types.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_types.h new file mode 100644 index 000000000..dc888e53b --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_types.h @@ -0,0 +1,255 @@ +/** + * @file rac_types.h + * @brief RunAnywhere Commons - Common Types and Definitions + * + * This header defines common types, handle types, and macros used throughout + * the runanywhere-commons library. All types use the RAC_ prefix to distinguish + * from the underlying runanywhere-core (ra_*) types. + */ + +#ifndef RAC_TYPES_H +#define RAC_TYPES_H + +#include +#include + +/** + * Null pointer macro for use in static initializers. + * Uses nullptr in C++ (preferred by clang-tidy modernize-use-nullptr) + * and NULL in C for compatibility. + */ +#ifdef __cplusplus +#define RAC_NULL nullptr +#else +#define RAC_NULL NULL +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// API VISIBILITY MACROS +// ============================================================================= + +#if defined(RAC_BUILDING_SHARED) +#if defined(_WIN32) +#define RAC_API __declspec(dllexport) +#elif defined(__GNUC__) || defined(__clang__) +#define RAC_API __attribute__((visibility("default"))) +#else +#define RAC_API +#endif +#elif defined(RAC_USING_SHARED) +#if defined(_WIN32) +#define RAC_API __declspec(dllimport) +#else +#define RAC_API +#endif +#else +#define RAC_API +#endif + +// ============================================================================= +// RESULT TYPE +// ============================================================================= + +/** + * Result type for all RAC functions. + * - 0 indicates success + * - Negative values indicate errors (see rac_error.h) + * + * Error code ranges: + * - runanywhere-core (ra_*): 0 to -99 + * - runanywhere-commons (rac_*): -100 to -999 + */ +typedef int32_t rac_result_t; + +/** Success result */ +#define RAC_SUCCESS ((rac_result_t)0) + +// ============================================================================= +// BOOLEAN TYPE +// ============================================================================= + +/** Boolean type for C compatibility */ +typedef int32_t rac_bool_t; + +#define RAC_TRUE ((rac_bool_t)1) +#define RAC_FALSE ((rac_bool_t)0) + +// ============================================================================= +// HANDLE TYPES +// ============================================================================= + +/** + * Opaque handle for internal objects. + * Handles should be treated as opaque pointers. + */ +typedef void* rac_handle_t; + +/** Invalid handle value */ +#define RAC_INVALID_HANDLE ((rac_handle_t)NULL) + +// ============================================================================= +// STRING TYPES +// ============================================================================= + +/** + * String view (non-owning reference to a string). + * The string is NOT guaranteed to be null-terminated. + */ +typedef struct rac_string_view { + const char* data; /**< Pointer to string data */ + size_t length; /**< Length in bytes (not including any null terminator) */ +} rac_string_view_t; + +/** + * Creates a string view from a null-terminated C string. + */ +#define RAC_STRING_VIEW(s) ((rac_string_view_t){(s), (s) ? strlen(s) : 0}) + +// ============================================================================= +// AUDIO TYPES +// ============================================================================= + +/** + * Audio buffer for STT/VAD operations. + * Contains PCM float samples in the range [-1.0, 1.0]. + */ +typedef struct rac_audio_buffer { + const float* samples; /**< PCM float samples */ + size_t num_samples; /**< Number of samples */ + int32_t sample_rate; /**< Sample rate in Hz (e.g., 16000) */ + int32_t channels; /**< Number of channels (1 = mono, 2 = stereo) */ +} rac_audio_buffer_t; + +/** + * Audio format specification. + */ +typedef struct rac_audio_format { + int32_t sample_rate; /**< Sample rate in Hz */ + int32_t channels; /**< Number of channels */ + int32_t bits_per_sample; /**< Bits per sample (16 or 32) */ +} rac_audio_format_t; + +// ============================================================================= +// MEMORY INFO +// ============================================================================= + +/** + * Memory information structure. + * Used by the platform adapter to report available memory. + */ +typedef struct rac_memory_info { + uint64_t total_bytes; /**< Total physical memory in bytes */ + uint64_t available_bytes; /**< Available memory in bytes */ + uint64_t used_bytes; /**< Used memory in bytes */ +} rac_memory_info_t; + +// ============================================================================= +// CAPABILITY TYPES +// ============================================================================= + +/** + * Capability types supported by backends. + * These match the capabilities defined in runanywhere-core. + */ +typedef enum rac_capability { + RAC_CAPABILITY_UNKNOWN = 0, + RAC_CAPABILITY_TEXT_GENERATION = 1, /**< LLM text generation */ + RAC_CAPABILITY_EMBEDDINGS = 2, /**< Text embeddings */ + RAC_CAPABILITY_STT = 3, /**< Speech-to-text */ + RAC_CAPABILITY_TTS = 4, /**< Text-to-speech */ + RAC_CAPABILITY_VAD = 5, /**< Voice activity detection */ + RAC_CAPABILITY_DIARIZATION = 6, /**< Speaker diarization */ +} rac_capability_t; + +/** + * Device type for backend execution. + */ +typedef enum rac_device { + RAC_DEVICE_CPU = 0, + RAC_DEVICE_GPU = 1, + RAC_DEVICE_NPU = 2, + RAC_DEVICE_AUTO = 3, +} rac_device_t; + +// ============================================================================= +// LOG LEVELS +// ============================================================================= + +/** + * Log level for the logging callback. + */ +typedef enum rac_log_level { + RAC_LOG_TRACE = 0, + RAC_LOG_DEBUG = 1, + RAC_LOG_INFO = 2, + RAC_LOG_WARNING = 3, + RAC_LOG_ERROR = 4, + RAC_LOG_FATAL = 5, +} rac_log_level_t; + +// ============================================================================= +// VERSION INFO +// ============================================================================= + +/** + * Version information structure. + */ +typedef struct rac_version { + uint16_t major; + uint16_t minor; + uint16_t patch; + const char* string; /**< Version string (e.g., "1.0.0") */ +} rac_version_t; + +// ============================================================================= +// UTILITY MACROS +// ============================================================================= + +/** Check if a result is a success */ +#define RAC_SUCCEEDED(result) ((result) >= 0) + +/** Check if a result is an error */ +#define RAC_FAILED(result) ((result) < 0) + +/** Check if a handle is valid */ +#define RAC_IS_VALID_HANDLE(handle) ((handle) != RAC_INVALID_HANDLE) + +// ============================================================================= +// MEMORY MANAGEMENT +// ============================================================================= + +/** + * Frees memory allocated by RAC functions. + * + * Use this to free strings and buffers returned by RAC functions that + * are marked as "must be freed with rac_free". + * + * @param ptr Pointer to memory to free (can be NULL) + */ +RAC_API void rac_free(void* ptr); + +/** + * Allocates memory using the RAC allocator. + * + * @param size Number of bytes to allocate + * @return Pointer to allocated memory, or NULL on failure + */ +RAC_API void* rac_alloc(size_t size); + +/** + * Duplicates a null-terminated string. + * + * @param str String to duplicate (can be NULL) + * @return Duplicated string (must be freed with rac_free), or NULL if str is NULL + */ +RAC_API char* rac_strdup(const char* str); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_TYPES_H */ diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_vad.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_vad.h new file mode 100644 index 000000000..10e8be953 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_vad.h @@ -0,0 +1,17 @@ +/** + * @file rac_vad.h + * @brief RunAnywhere Commons - VAD API (Convenience Header) + * + * This header includes both types and service interface for convenience. + * For better separation of concerns, prefer including: + * - rac_vad_types.h for data structures only + * - rac_vad_service.h for the service interface + */ + +#ifndef RAC_VAD_H +#define RAC_VAD_H + +#include "rac_vad_service.h" +#include "rac_vad_types.h" + +#endif /* RAC_VAD_H */ diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_vad_analytics.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_vad_analytics.h new file mode 100644 index 000000000..ec798562e --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_vad_analytics.h @@ -0,0 +1,236 @@ +/** + * @file rac_vad_analytics.h + * @brief VAD analytics service - 1:1 port of VADAnalyticsService.swift + * + * Tracks VAD operations and metrics. + * + * Swift Source: Sources/RunAnywhere/Features/VAD/Analytics/VADAnalyticsService.swift + */ + +#ifndef RAC_VAD_ANALYTICS_H +#define RAC_VAD_ANALYTICS_H + +#include "rac_types.h" +#include "rac_vad_types.h" +#include "rac_model_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// TYPES +// ============================================================================= + +/** + * @brief Opaque handle for VAD analytics service + */ +typedef struct rac_vad_analytics_s* rac_vad_analytics_handle_t; + +/** + * @brief VAD metrics structure + * Mirrors Swift's VADMetrics struct + */ +typedef struct rac_vad_metrics { + /** Total number of events tracked */ + int32_t total_events; + + /** Start time (milliseconds since epoch) */ + int64_t start_time_ms; + + /** Last event time (milliseconds since epoch, 0 if no events) */ + int64_t last_event_time_ms; + + /** Total number of speech segments detected */ + int32_t total_speech_segments; + + /** Total speech duration in milliseconds */ + double total_speech_duration_ms; + + /** Average speech duration in milliseconds (-1 if no segments) */ + double average_speech_duration_ms; + + /** Current framework being used */ + rac_inference_framework_t framework; +} rac_vad_metrics_t; + +// ============================================================================= +// LIFECYCLE +// ============================================================================= + +/** + * @brief Create a VAD analytics service instance + * + * @param out_handle Output: Handle to the created service + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_analytics_create(rac_vad_analytics_handle_t* out_handle); + +/** + * @brief Destroy a VAD analytics service instance + * + * @param handle Handle to destroy + */ +RAC_API void rac_vad_analytics_destroy(rac_vad_analytics_handle_t handle); + +// ============================================================================= +// LIFECYCLE TRACKING +// ============================================================================= + +/** + * @brief Track VAD initialization + * + * @param handle Analytics service handle + * @param framework The inference framework being used + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_analytics_track_initialized(rac_vad_analytics_handle_t handle, + rac_inference_framework_t framework); + +/** + * @brief Track VAD initialization failure + * + * @param handle Analytics service handle + * @param error_code Error code + * @param error_message Error message + * @param framework The inference framework + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_analytics_track_initialization_failed( + rac_vad_analytics_handle_t handle, rac_result_t error_code, const char* error_message, + rac_inference_framework_t framework); + +/** + * @brief Track VAD cleanup + * + * @param handle Analytics service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_analytics_track_cleaned_up(rac_vad_analytics_handle_t handle); + +// ============================================================================= +// DETECTION TRACKING +// ============================================================================= + +/** + * @brief Track VAD started + * + * @param handle Analytics service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_analytics_track_started(rac_vad_analytics_handle_t handle); + +/** + * @brief Track VAD stopped + * + * @param handle Analytics service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_analytics_track_stopped(rac_vad_analytics_handle_t handle); + +/** + * @brief Track speech detected (start of speech/voice activity) + * + * @param handle Analytics service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_analytics_track_speech_start(rac_vad_analytics_handle_t handle); + +/** + * @brief Track speech ended (silence detected after speech) + * + * @param handle Analytics service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_analytics_track_speech_end(rac_vad_analytics_handle_t handle); + +/** + * @brief Track VAD paused + * + * @param handle Analytics service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_analytics_track_paused(rac_vad_analytics_handle_t handle); + +/** + * @brief Track VAD resumed + * + * @param handle Analytics service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_analytics_track_resumed(rac_vad_analytics_handle_t handle); + +// ============================================================================= +// MODEL LIFECYCLE (for model-based VAD) +// ============================================================================= + +/** + * @brief Track model load started + * + * @param handle Analytics service handle + * @param model_id The model identifier + * @param model_size_bytes Size of the model in bytes + * @param framework The inference framework + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_analytics_track_model_load_started( + rac_vad_analytics_handle_t handle, const char* model_id, int64_t model_size_bytes, + rac_inference_framework_t framework); + +/** + * @brief Track model load completed + * + * @param handle Analytics service handle + * @param model_id The model identifier + * @param duration_ms Time taken to load in milliseconds + * @param model_size_bytes Size of the model in bytes + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_analytics_track_model_load_completed(rac_vad_analytics_handle_t handle, + const char* model_id, + double duration_ms, + int64_t model_size_bytes); + +/** + * @brief Track model load failed + * + * @param handle Analytics service handle + * @param model_id The model identifier + * @param error_code Error code + * @param error_message Error message + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_analytics_track_model_load_failed(rac_vad_analytics_handle_t handle, + const char* model_id, + rac_result_t error_code, + const char* error_message); + +/** + * @brief Track model unloaded + * + * @param handle Analytics service handle + * @param model_id The model identifier + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_analytics_track_model_unloaded(rac_vad_analytics_handle_t handle, + const char* model_id); + +// ============================================================================= +// METRICS +// ============================================================================= + +/** + * @brief Get current analytics metrics + * + * @param handle Analytics service handle + * @param out_metrics Output: Metrics structure + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_analytics_get_metrics(rac_vad_analytics_handle_t handle, + rac_vad_metrics_t* out_metrics); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_VAD_ANALYTICS_H */ diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_vad_component.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_vad_component.h new file mode 100644 index 000000000..95e66421f --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_vad_component.h @@ -0,0 +1,185 @@ +/** + * @file rac_vad_component.h + * @brief RunAnywhere Commons - VAD Capability Component + * + * C port of Swift's VADCapability.swift from: + * Sources/RunAnywhere/Features/VAD/VADCapability.swift + * + * Actor-based VAD capability that owns model lifecycle and voice detection. + * Uses lifecycle manager for unified lifecycle + analytics handling. + */ + +#ifndef RAC_VAD_COMPONENT_H +#define RAC_VAD_COMPONENT_H + +#include "rac_lifecycle.h" +#include "rac_error.h" +#include "rac_vad_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// NOTE: rac_vad_config_t is defined in rac_vad_types.h (included above) + +// ============================================================================= +// VAD COMPONENT API - Mirrors Swift's VADCapability +// ============================================================================= + +/** + * @brief Create a VAD capability component + * + * @param out_handle Output: Handle to the component + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_component_create(rac_handle_t* out_handle); + +/** + * @brief Configure the VAD component + * + * @param handle Component handle + * @param config Configuration + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_component_configure(rac_handle_t handle, + const rac_vad_config_t* config); + +/** + * @brief Check if VAD is initialized + * + * @param handle Component handle + * @return RAC_TRUE if initialized, RAC_FALSE otherwise + */ +RAC_API rac_bool_t rac_vad_component_is_initialized(rac_handle_t handle); + +/** + * @brief Initialize the VAD + * + * @param handle Component handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_component_initialize(rac_handle_t handle); + +/** + * @brief Cleanup and reset the component + * + * @param handle Component handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_component_cleanup(rac_handle_t handle); + +/** + * @brief Set speech activity callback + * + * @param handle Component handle + * @param callback Activity callback + * @param user_data User context passed to callback + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_component_set_activity_callback(rac_handle_t handle, + rac_vad_activity_callback_fn callback, + void* user_data); + +/** + * @brief Set audio buffer callback + * + * @param handle Component handle + * @param callback Audio buffer callback + * @param user_data User context passed to callback + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_component_set_audio_callback(rac_handle_t handle, + rac_vad_audio_callback_fn callback, + void* user_data); + +/** + * @brief Start VAD processing + * + * @param handle Component handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_component_start(rac_handle_t handle); + +/** + * @brief Stop VAD processing + * + * @param handle Component handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_component_stop(rac_handle_t handle); + +/** + * @brief Reset VAD state + * + * @param handle Component handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_component_reset(rac_handle_t handle); + +/** + * @brief Process audio samples + * + * @param handle Component handle + * @param samples Float audio samples (PCM) + * @param num_samples Number of samples + * @param out_is_speech Output: Whether speech is detected + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_component_process(rac_handle_t handle, const float* samples, + size_t num_samples, rac_bool_t* out_is_speech); + +/** + * @brief Get current speech activity state + * + * @param handle Component handle + * @return RAC_TRUE if speech is active, RAC_FALSE otherwise + */ +RAC_API rac_bool_t rac_vad_component_is_speech_active(rac_handle_t handle); + +/** + * @brief Get current energy threshold + * + * @param handle Component handle + * @return Current energy threshold + */ +RAC_API float rac_vad_component_get_energy_threshold(rac_handle_t handle); + +/** + * @brief Set energy threshold + * + * @param handle Component handle + * @param threshold New threshold (0.0 to 1.0) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_component_set_energy_threshold(rac_handle_t handle, float threshold); + +/** + * @brief Get lifecycle state + * + * @param handle Component handle + * @return Current lifecycle state + */ +RAC_API rac_lifecycle_state_t rac_vad_component_get_state(rac_handle_t handle); + +/** + * @brief Get lifecycle metrics + * + * @param handle Component handle + * @param out_metrics Output: Lifecycle metrics + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_component_get_metrics(rac_handle_t handle, + rac_lifecycle_metrics_t* out_metrics); + +/** + * @brief Destroy the VAD component + * + * @param handle Component handle + */ +RAC_API void rac_vad_component_destroy(rac_handle_t handle); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_VAD_COMPONENT_H */ diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_vad_energy.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_vad_energy.h new file mode 100644 index 000000000..46fce4a21 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_vad_energy.h @@ -0,0 +1,443 @@ +/** + * @file rac_vad_energy.h + * @brief Energy-based Voice Activity Detection + * + * C port of Swift's SimpleEnergyVADService.swift + * Swift Source: Sources/RunAnywhere/Features/VAD/Services/SimpleEnergyVADService.swift + * + * IMPORTANT: This is a direct translation of the Swift implementation. + * Do NOT add features not present in the Swift code. + */ + +#ifndef RAC_VAD_ENERGY_H +#define RAC_VAD_ENERGY_H + +#include "rac_error.h" +#include "rac_types.h" +#include "rac_vad_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// CONSTANTS - Mirrors Swift's VADConstants +// NOTE: Core constants (RAC_VAD_DEFAULT_SAMPLE_RATE, RAC_VAD_DEFAULT_FRAME_LENGTH, +// RAC_VAD_DEFAULT_ENERGY_THRESHOLD, RAC_VAD_DEFAULT_CALIBRATION_MULTIPLIER) +// are defined in rac_vad_types.h +// ============================================================================= + +/** Frames of voice needed to start speech (normal mode) */ +#define RAC_VAD_VOICE_START_THRESHOLD 1 + +/** Frames of silence needed to end speech (normal mode) */ +#define RAC_VAD_VOICE_END_THRESHOLD 12 + +/** Frames of voice needed during TTS (prevents feedback) */ +#define RAC_VAD_TTS_VOICE_START_THRESHOLD 10 + +/** Frames of silence needed during TTS */ +#define RAC_VAD_TTS_VOICE_END_THRESHOLD 5 + +/** Number of calibration frames needed (~2 seconds at 100ms) */ +#define RAC_VAD_CALIBRATION_FRAMES_NEEDED 20 + +/** Default calibration multiplier */ +#define RAC_VAD_DEFAULT_CALIBRATION_MULTIPLIER 2.0f + +/** Default TTS threshold multiplier */ +#define RAC_VAD_DEFAULT_TTS_THRESHOLD_MULTIPLIER 3.0f + +/** Maximum threshold cap */ +#define RAC_VAD_MAX_THRESHOLD 0.020f + +/** Minimum threshold */ +#define RAC_VAD_MIN_THRESHOLD 0.003f + +/** Maximum recent values for statistics */ +#define RAC_VAD_MAX_RECENT_VALUES 50 + +// ============================================================================= +// TYPES +// ============================================================================= + +/** + * @brief Opaque handle for energy VAD service. + */ +typedef struct rac_energy_vad* rac_energy_vad_handle_t; + +/** + * @brief Speech activity event types. + * Mirrors Swift's SpeechActivityEvent enum. + */ +typedef enum rac_speech_activity_event { + RAC_SPEECH_ACTIVITY_STARTED = 0, /**< Speech has started */ + RAC_SPEECH_ACTIVITY_ENDED = 1 /**< Speech has ended */ +} rac_speech_activity_event_t; + +/** + * @brief Configuration for energy VAD. + * Mirrors Swift's SimpleEnergyVADService init parameters. + */ +typedef struct rac_energy_vad_config { + /** Audio sample rate (default: 16000) */ + int32_t sample_rate; + + /** Frame length in seconds (default: 0.1 = 100ms) */ + float frame_length; + + /** Energy threshold for voice detection (default: 0.005) */ + float energy_threshold; +} rac_energy_vad_config_t; + +/** + * @brief Default energy VAD configuration. + */ +static const rac_energy_vad_config_t RAC_ENERGY_VAD_CONFIG_DEFAULT = { + .sample_rate = RAC_VAD_DEFAULT_SAMPLE_RATE, + .frame_length = RAC_VAD_DEFAULT_FRAME_LENGTH, + .energy_threshold = RAC_VAD_DEFAULT_ENERGY_THRESHOLD}; + +/** + * @brief Energy VAD statistics for debugging. + * Mirrors Swift's SimpleEnergyVADService.getStatistics(). + * Note: This is separate from rac_vad_statistics_t in rac_vad_types.h + */ +typedef struct rac_energy_vad_stats { + /** Current energy value */ + float current; + + /** Current threshold value */ + float threshold; + + /** Ambient noise level from calibration */ + float ambient; + + /** Recent average energy */ + float recent_avg; + + /** Recent maximum energy */ + float recent_max; +} rac_energy_vad_stats_t; + +/** + * @brief Callback for speech activity events. + * Mirrors Swift's onSpeechActivity callback. + * + * @param event The speech activity event type + * @param user_data User-provided context + */ +typedef void (*rac_speech_activity_callback_fn)(rac_speech_activity_event_t event, void* user_data); + +/** + * @brief Callback for processed audio buffers. + * Mirrors Swift's onAudioBuffer callback. + * + * @param audio_data Audio data buffer + * @param audio_size Size of audio data in bytes + * @param user_data User-provided context + */ +typedef void (*rac_audio_buffer_callback_fn)(const void* audio_data, size_t audio_size, + void* user_data); + +// ============================================================================= +// LIFECYCLE API - Mirrors Swift's VADService protocol +// ============================================================================= + +/** + * @brief Create an energy VAD service. + * + * Mirrors Swift's SimpleEnergyVADService init. + * + * @param config Configuration (can be NULL for defaults) + * @param out_handle Output: Handle to the created service + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_energy_vad_create(const rac_energy_vad_config_t* config, + rac_energy_vad_handle_t* out_handle); + +/** + * @brief Destroy an energy VAD service. + * + * @param handle Service handle to destroy + */ +RAC_API void rac_energy_vad_destroy(rac_energy_vad_handle_t handle); + +/** + * @brief Initialize the VAD service. + * + * Mirrors Swift's VADService.initialize(). + * This starts the service and begins calibration. + * + * @param handle Service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_energy_vad_initialize(rac_energy_vad_handle_t handle); + +/** + * @brief Start voice activity detection. + * + * Mirrors Swift's SimpleEnergyVADService.start(). + * + * @param handle Service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_energy_vad_start(rac_energy_vad_handle_t handle); + +/** + * @brief Stop voice activity detection. + * + * Mirrors Swift's SimpleEnergyVADService.stop(). + * + * @param handle Service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_energy_vad_stop(rac_energy_vad_handle_t handle); + +/** + * @brief Reset the VAD state. + * + * Mirrors Swift's VADService.reset(). + * + * @param handle Service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_energy_vad_reset(rac_energy_vad_handle_t handle); + +// ============================================================================= +// PROCESSING API +// ============================================================================= + +/** + * @brief Process raw audio data for voice activity detection. + * + * Mirrors Swift's SimpleEnergyVADService.processAudioData(_:). + * + * @param handle Service handle + * @param audio_data Array of audio samples (float32) + * @param sample_count Number of samples + * @param out_has_voice Output: Whether voice was detected + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_energy_vad_process_audio(rac_energy_vad_handle_t handle, + const float* audio_data, size_t sample_count, + rac_bool_t* out_has_voice); + +/** + * @brief Calculate RMS energy of an audio signal. + * + * Mirrors Swift's calculateAverageEnergy(of:) using vDSP_rmsqv. + * + * @param audio_data Array of audio samples (float32) + * @param sample_count Number of samples + * @return RMS energy value, or 0.0 if empty + */ +RAC_API float rac_energy_vad_calculate_rms(const float* audio_data, size_t sample_count); + +// ============================================================================= +// PAUSE/RESUME API +// ============================================================================= + +/** + * @brief Pause VAD processing. + * + * Mirrors Swift's SimpleEnergyVADService.pause(). + * + * @param handle Service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_energy_vad_pause(rac_energy_vad_handle_t handle); + +/** + * @brief Resume VAD processing. + * + * Mirrors Swift's SimpleEnergyVADService.resume(). + * + * @param handle Service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_energy_vad_resume(rac_energy_vad_handle_t handle); + +// ============================================================================= +// CALIBRATION API +// ============================================================================= + +/** + * @brief Start automatic calibration to determine ambient noise level. + * + * Mirrors Swift's SimpleEnergyVADService.startCalibration(). + * Non-blocking; call rac_energy_vad_is_calibrating() to check status. + * + * @param handle Service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_energy_vad_start_calibration(rac_energy_vad_handle_t handle); + +/** + * @brief Check if calibration is in progress. + * + * @param handle Service handle + * @param out_is_calibrating Output: RAC_TRUE if calibrating + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_energy_vad_is_calibrating(rac_energy_vad_handle_t handle, + rac_bool_t* out_is_calibrating); + +/** + * @brief Set calibration parameters. + * + * Mirrors Swift's setCalibrationParameters(multiplier:). + * + * @param handle Service handle + * @param multiplier Calibration multiplier (clamped to 1.5-4.0) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_energy_vad_set_calibration_multiplier(rac_energy_vad_handle_t handle, + float multiplier); + +// ============================================================================= +// TTS FEEDBACK PREVENTION API +// ============================================================================= + +/** + * @brief Notify VAD that TTS is about to start playing. + * + * Mirrors Swift's notifyTTSWillStart(). + * Increases threshold to prevent TTS audio from triggering VAD. + * + * @param handle Service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_energy_vad_notify_tts_start(rac_energy_vad_handle_t handle); + +/** + * @brief Notify VAD that TTS has finished playing. + * + * Mirrors Swift's notifyTTSDidFinish(). + * Restores threshold to base value. + * + * @param handle Service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_energy_vad_notify_tts_finish(rac_energy_vad_handle_t handle); + +/** + * @brief Set TTS threshold multiplier. + * + * Mirrors Swift's setTTSThresholdMultiplier(_:). + * + * @param handle Service handle + * @param multiplier TTS threshold multiplier (clamped to 2.0-5.0) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_energy_vad_set_tts_multiplier(rac_energy_vad_handle_t handle, + float multiplier); + +// ============================================================================= +// STATE QUERY API +// ============================================================================= + +/** + * @brief Check if speech is currently active. + * + * Mirrors Swift's VADService.isSpeechActive property. + * + * @param handle Service handle + * @param out_is_active Output: RAC_TRUE if speech is active + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_energy_vad_is_speech_active(rac_energy_vad_handle_t handle, + rac_bool_t* out_is_active); + +/** + * @brief Get current energy threshold. + * + * @param handle Service handle + * @param out_threshold Output: Current threshold value + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_energy_vad_get_threshold(rac_energy_vad_handle_t handle, + float* out_threshold); + +/** + * @brief Set energy threshold. + * + * @param handle Service handle + * @param threshold New threshold value + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_energy_vad_set_threshold(rac_energy_vad_handle_t handle, float threshold); + +/** + * @brief Get VAD statistics for debugging. + * + * Mirrors Swift's getStatistics(). + * + * @param handle Service handle + * @param out_stats Output: VAD statistics + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_energy_vad_get_statistics(rac_energy_vad_handle_t handle, + rac_energy_vad_stats_t* out_stats); + +/** + * @brief Get sample rate. + * + * Mirrors Swift's sampleRate property. + * + * @param handle Service handle + * @param out_sample_rate Output: Sample rate + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_energy_vad_get_sample_rate(rac_energy_vad_handle_t handle, + int32_t* out_sample_rate); + +/** + * @brief Get frame length in samples. + * + * Mirrors Swift's frameLengthSamples property. + * + * @param handle Service handle + * @param out_frame_length Output: Frame length in samples + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_energy_vad_get_frame_length_samples(rac_energy_vad_handle_t handle, + int32_t* out_frame_length); + +// ============================================================================= +// CALLBACK API +// ============================================================================= + +/** + * @brief Set speech activity callback. + * + * Mirrors Swift's onSpeechActivity property. + * + * @param handle Service handle + * @param callback Callback function (can be NULL to clear) + * @param user_data User-provided context + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_energy_vad_set_speech_callback(rac_energy_vad_handle_t handle, + rac_speech_activity_callback_fn callback, + void* user_data); + +/** + * @brief Set audio buffer callback. + * + * Mirrors Swift's onAudioBuffer property. + * + * @param handle Service handle + * @param callback Callback function (can be NULL to clear) + * @param user_data User-provided context + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_energy_vad_set_audio_callback(rac_energy_vad_handle_t handle, + rac_audio_buffer_callback_fn callback, + void* user_data); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_VAD_ENERGY_H */ diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_vad_events.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_vad_events.h new file mode 100644 index 000000000..518e4f9b0 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_vad_events.h @@ -0,0 +1,76 @@ +/** + * @file rac_vad_events.h + * @brief VAD-specific event types - 1:1 port of VADEvent.swift + * + * Swift Source: Sources/RunAnywhere/Features/VAD/Analytics/VADEvent.swift + */ + +#ifndef RAC_VAD_EVENTS_H +#define RAC_VAD_EVENTS_H + +#include "rac_types.h" +#include "rac_events.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// VAD EVENT TYPES +// ============================================================================= + +typedef enum rac_vad_event_type { + RAC_VAD_EVENT_INITIALIZED = 0, + RAC_VAD_EVENT_INITIALIZATION_FAILED, + RAC_VAD_EVENT_CLEANED_UP, + RAC_VAD_EVENT_STARTED, + RAC_VAD_EVENT_STOPPED, + RAC_VAD_EVENT_SPEECH_STARTED, + RAC_VAD_EVENT_SPEECH_ENDED, + RAC_VAD_EVENT_PAUSED, + RAC_VAD_EVENT_RESUMED, + RAC_VAD_EVENT_MODEL_LOAD_STARTED, + RAC_VAD_EVENT_MODEL_LOAD_COMPLETED, + RAC_VAD_EVENT_MODEL_LOAD_FAILED, + RAC_VAD_EVENT_MODEL_UNLOADED, +} rac_vad_event_type_t; + +// ============================================================================= +// EVENT PUBLISHING FUNCTIONS +// ============================================================================= + +RAC_API rac_result_t rac_vad_event_initialized(rac_inference_framework_t framework); + +RAC_API rac_result_t rac_vad_event_initialization_failed(rac_result_t error_code, + const char* error_message, + rac_inference_framework_t framework); + +RAC_API rac_result_t rac_vad_event_cleaned_up(void); +RAC_API rac_result_t rac_vad_event_started(void); +RAC_API rac_result_t rac_vad_event_stopped(void); +RAC_API rac_result_t rac_vad_event_speech_started(void); +RAC_API rac_result_t rac_vad_event_speech_ended(double duration_ms); +RAC_API rac_result_t rac_vad_event_paused(void); +RAC_API rac_result_t rac_vad_event_resumed(void); + +RAC_API rac_result_t rac_vad_event_model_load_started(const char* model_id, + int64_t model_size_bytes, + rac_inference_framework_t framework); + +RAC_API rac_result_t rac_vad_event_model_load_completed(const char* model_id, double duration_ms, + int64_t model_size_bytes, + rac_inference_framework_t framework); + +RAC_API rac_result_t rac_vad_event_model_load_failed(const char* model_id, rac_result_t error_code, + const char* error_message, + rac_inference_framework_t framework); + +RAC_API rac_result_t rac_vad_event_model_unloaded(const char* model_id); + +RAC_API const char* rac_vad_event_type_string(rac_vad_event_type_t event_type); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_VAD_EVENTS_H */ diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_vad_onnx.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_vad_onnx.h new file mode 100644 index 000000000..dd1754855 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_vad_onnx.h @@ -0,0 +1,84 @@ +/** + * @file rac_vad_onnx.h + * @brief RunAnywhere Core - ONNX Backend RAC API for VAD + * + * Direct RAC API export from runanywhere-core's ONNX VAD backend. + */ + +#ifndef RAC_VAD_ONNX_H +#define RAC_VAD_ONNX_H + +#include "rac_error.h" +#include "rac_types.h" +#include "rac_vad.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// EXPORT MACRO +// ============================================================================= + +#if defined(RAC_ONNX_BUILDING) +#if defined(_WIN32) +#define RAC_ONNX_API __declspec(dllexport) +#elif defined(__GNUC__) || defined(__clang__) +#define RAC_ONNX_API __attribute__((visibility("default"))) +#else +#define RAC_ONNX_API +#endif +#else +#define RAC_ONNX_API +#endif + +// ============================================================================= +// CONFIGURATION +// ============================================================================= + +typedef struct rac_vad_onnx_config { + int32_t sample_rate; + float energy_threshold; + float frame_length; + int32_t num_threads; +} rac_vad_onnx_config_t; + +static const rac_vad_onnx_config_t RAC_VAD_ONNX_CONFIG_DEFAULT = { + .sample_rate = 16000, .energy_threshold = 0.5f, .frame_length = 0.032f, .num_threads = 0}; + +// ============================================================================= +// ONNX VAD API +// ============================================================================= + +RAC_ONNX_API rac_result_t rac_vad_onnx_create(const char* model_path, + const rac_vad_onnx_config_t* config, + rac_handle_t* out_handle); + +RAC_ONNX_API rac_result_t rac_vad_onnx_process(rac_handle_t handle, const float* samples, + size_t num_samples, rac_bool_t* out_is_speech); + +RAC_ONNX_API rac_result_t rac_vad_onnx_start(rac_handle_t handle); + +RAC_ONNX_API rac_result_t rac_vad_onnx_stop(rac_handle_t handle); + +RAC_ONNX_API rac_result_t rac_vad_onnx_reset(rac_handle_t handle); + +RAC_ONNX_API rac_result_t rac_vad_onnx_set_threshold(rac_handle_t handle, float threshold); + +RAC_ONNX_API rac_bool_t rac_vad_onnx_is_speech_active(rac_handle_t handle); + +RAC_ONNX_API void rac_vad_onnx_destroy(rac_handle_t handle); + +// ============================================================================= +// BACKEND REGISTRATION +// ============================================================================= + +RAC_ONNX_API rac_result_t rac_backend_onnx_register(void); + +RAC_ONNX_API rac_result_t rac_backend_onnx_unregister(void); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_VAD_ONNX_H */ diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_vad_service.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_vad_service.h new file mode 100644 index 000000000..6678ceb9d --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_vad_service.h @@ -0,0 +1,167 @@ +/** + * @file rac_vad_service.h + * @brief RunAnywhere Commons - VAD Service Interface (Protocol) + * + * C port of Swift's VADService protocol from: + * Sources/RunAnywhere/Features/VAD/Protocol/VADService.swift + * + * This header defines the service interface. For data types, + * see rac_vad_types.h. + */ + +#ifndef RAC_VAD_SERVICE_H +#define RAC_VAD_SERVICE_H + +#include "rac_error.h" +#include "rac_vad_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// SERVICE INTERFACE - Mirrors Swift's VADService protocol +// ============================================================================= + +/** + * @brief Create a VAD service + * + * @param out_handle Output: Handle to the created service + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_create(rac_handle_t* out_handle); + +/** + * @brief Initialize the VAD service + * + * Mirrors Swift's VADService.initialize() + * + * @param handle Service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_initialize(rac_handle_t handle); + +/** + * @brief Set speech activity callback + * + * Mirrors Swift's VADService.onSpeechActivity property. + * + * @param handle Service handle + * @param callback Activity callback (can be NULL to unset) + * @param user_data User context passed to callback + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_set_activity_callback(rac_handle_t handle, + rac_vad_activity_callback_fn callback, + void* user_data); + +/** + * @brief Set audio buffer callback + * + * Mirrors Swift's VADService.onAudioBuffer property. + * + * @param handle Service handle + * @param callback Audio callback (can be NULL to unset) + * @param user_data User context passed to callback + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_set_audio_callback(rac_handle_t handle, + rac_vad_audio_callback_fn callback, + void* user_data); + +/** + * @brief Start VAD processing + * + * Mirrors Swift's VADService.start() + * + * @param handle Service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_start(rac_handle_t handle); + +/** + * @brief Stop VAD processing + * + * Mirrors Swift's VADService.stop() + * + * @param handle Service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_stop(rac_handle_t handle); + +/** + * @brief Reset VAD state + * + * Mirrors Swift's VADService.reset() + * + * @param handle Service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_reset(rac_handle_t handle); + +/** + * @brief Pause VAD processing + * + * Mirrors Swift's VADService.pause() (optional, default no-op) + * + * @param handle Service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_pause(rac_handle_t handle); + +/** + * @brief Resume VAD processing + * + * Mirrors Swift's VADService.resume() (optional, default no-op) + * + * @param handle Service handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_resume(rac_handle_t handle); + +/** + * @brief Process audio samples + * + * Mirrors Swift's VADService.processAudioData(_:) + * + * @param handle Service handle + * @param samples Float audio samples (PCM) + * @param num_samples Number of samples + * @param out_is_speech Output: Whether speech is detected + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_process_samples(rac_handle_t handle, const float* samples, + size_t num_samples, rac_bool_t* out_is_speech); + +/** + * @brief Set energy threshold + * + * Mirrors Swift's VADService.energyThreshold setter. + * + * @param handle Service handle + * @param threshold New threshold (0.0 to 1.0) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_set_energy_threshold(rac_handle_t handle, float threshold); + +/** + * @brief Get service information + * + * @param handle Service handle + * @param out_info Output: Service information + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_vad_get_info(rac_handle_t handle, rac_vad_info_t* out_info); + +/** + * @brief Destroy a VAD service instance + * + * @param handle Service handle to destroy + */ +RAC_API void rac_vad_destroy(rac_handle_t handle); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_VAD_SERVICE_H */ diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_vad_types.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_vad_types.h new file mode 100644 index 000000000..e94ec20f0 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_vad_types.h @@ -0,0 +1,244 @@ +/** + * @file rac_vad_types.h + * @brief RunAnywhere Commons - VAD Types and Data Structures + * + * C port of Swift's VAD Models from: + * Sources/RunAnywhere/Features/VAD/Models/VADConfiguration.swift + * Sources/RunAnywhere/Features/VAD/Models/VADInput.swift + * Sources/RunAnywhere/Features/VAD/Models/VADOutput.swift + * Sources/RunAnywhere/Features/VAD/VADConstants.swift + * + * This header defines data structures only. For the service interface, + * see rac_vad_service.h. + */ + +#ifndef RAC_VAD_TYPES_H +#define RAC_VAD_TYPES_H + +#include "rac_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// CONSTANTS - Single Source of Truth for VAD +// Swift references these via CRACommons import +// ============================================================================= + +// Audio Format Constants +#define RAC_VAD_DEFAULT_SAMPLE_RATE 16000 +#define RAC_VAD_MAX_SAMPLE_RATE 48000 +#define RAC_VAD_MIN_SAMPLE_RATE 8000 + +// Energy Thresholds +#define RAC_VAD_DEFAULT_ENERGY_THRESHOLD 0.015f +#define RAC_VAD_MIN_ENERGY_THRESHOLD 0.001f +#define RAC_VAD_MAX_ENERGY_THRESHOLD 0.5f + +// Frame Processing +#define RAC_VAD_DEFAULT_FRAME_LENGTH 0.1f +#define RAC_VAD_MIN_FRAME_LENGTH 0.02f +#define RAC_VAD_MAX_FRAME_LENGTH 0.5f + +// Calibration +#define RAC_VAD_DEFAULT_CALIBRATION_MULTIPLIER 2.0f +#define RAC_VAD_MIN_CALIBRATION_MULTIPLIER 1.2f +#define RAC_VAD_MAX_CALIBRATION_MULTIPLIER 5.0f + +// Speech Detection +#define RAC_VAD_MIN_SPEECH_DURATION_MS 100 +#define RAC_VAD_MIN_SILENCE_DURATION_MS 300 + +// ============================================================================= +// CONFIGURATION - Mirrors Swift's VADConfiguration +// ============================================================================= + +/** + * @brief VAD component configuration + * + * Mirrors Swift's VADConfiguration struct exactly. + * See: Sources/RunAnywhere/Features/VAD/Models/VADConfiguration.swift + */ +typedef struct rac_vad_config { + /** Model ID (not used for VAD, can be NULL) */ + const char* model_id; + + /** Preferred framework (use -1 for auto) */ + int32_t preferred_framework; + + /** Energy threshold for voice detection (0.0 to 1.0) */ + float energy_threshold; + + /** Sample rate in Hz (default: 16000) */ + int32_t sample_rate; + + /** Frame length in seconds (default: 0.1 = 100ms) */ + float frame_length; + + /** Enable automatic calibration */ + rac_bool_t enable_auto_calibration; + + /** Calibration multiplier (threshold = ambient noise * multiplier) */ + float calibration_multiplier; +} rac_vad_config_t; + +/** + * @brief Default VAD configuration + */ +static const rac_vad_config_t RAC_VAD_CONFIG_DEFAULT = { + .model_id = RAC_NULL, + .preferred_framework = -1, + .energy_threshold = RAC_VAD_DEFAULT_ENERGY_THRESHOLD, + .sample_rate = RAC_VAD_DEFAULT_SAMPLE_RATE, + .frame_length = RAC_VAD_DEFAULT_FRAME_LENGTH, + .enable_auto_calibration = RAC_FALSE, + .calibration_multiplier = RAC_VAD_DEFAULT_CALIBRATION_MULTIPLIER}; + +// ============================================================================= +// SPEECH ACTIVITY - Mirrors Swift's SpeechActivityEvent +// ============================================================================= + +/** + * @brief Speech activity event type + * + * Mirrors Swift's SpeechActivityEvent. + */ +typedef enum rac_speech_activity { + RAC_SPEECH_STARTED = 0, + RAC_SPEECH_ENDED = 1, + RAC_SPEECH_ONGOING = 2 +} rac_speech_activity_t; + +// ============================================================================= +// INPUT - Mirrors Swift's VADInput +// ============================================================================= + +/** + * @brief VAD input data + * + * Mirrors Swift's VADInput struct exactly. + * See: Sources/RunAnywhere/Features/VAD/Models/VADInput.swift + */ +typedef struct rac_vad_input { + /** Audio samples as float array (PCM float samples in range [-1.0, 1.0]) */ + const float* audio_samples; + size_t num_samples; + + /** Optional override for energy threshold (use -1 for no override) */ + float energy_threshold_override; +} rac_vad_input_t; + +/** + * @brief Default VAD input + */ +static const rac_vad_input_t RAC_VAD_INPUT_DEFAULT = { + .audio_samples = RAC_NULL, + .num_samples = 0, + .energy_threshold_override = -1.0f /* No override */ +}; + +// ============================================================================= +// OUTPUT - Mirrors Swift's VADOutput +// ============================================================================= + +/** + * @brief VAD output data + * + * Mirrors Swift's VADOutput struct exactly. + * See: Sources/RunAnywhere/Features/VAD/Models/VADOutput.swift + */ +typedef struct rac_vad_output { + /** Whether speech is detected in the current frame */ + rac_bool_t is_speech_detected; + + /** Current audio energy level (RMS value) */ + float energy_level; + + /** Timestamp in milliseconds since epoch */ + int64_t timestamp_ms; +} rac_vad_output_t; + +// ============================================================================= +// INFO - Mirrors Swift's VADService properties +// ============================================================================= + +/** + * @brief VAD service info + * + * Mirrors Swift's VADService properties. + */ +typedef struct rac_vad_info { + /** Whether speech is currently active (isSpeechActive) */ + rac_bool_t is_speech_active; + + /** Energy threshold for voice detection (energyThreshold) */ + float energy_threshold; + + /** Sample rate of the audio in Hz (sampleRate) */ + int32_t sample_rate; + + /** Frame length in seconds (frameLength) */ + float frame_length; +} rac_vad_info_t; + +// ============================================================================= +// STATISTICS - Mirrors Swift's VADStatistics +// ============================================================================= + +/** + * @brief VAD statistics + * + * Mirrors Swift's VADStatistics struct from SimpleEnergyVADService. + */ +typedef struct rac_vad_statistics { + /** Current calibrated threshold */ + float current_threshold; + + /** Ambient noise level */ + float ambient_noise_level; + + /** Total speech segments detected */ + int32_t total_speech_segments; + + /** Total duration of speech in milliseconds */ + int64_t total_speech_duration_ms; + + /** Average energy level */ + float average_energy; + + /** Peak energy level */ + float peak_energy; +} rac_vad_statistics_t; + +// ============================================================================= +// CALLBACKS +// ============================================================================= + +/** + * @brief Speech activity callback + * + * Mirrors Swift's VADService.onSpeechActivity callback. + * + * @param activity The speech activity event + * @param user_data User-provided context + */ +typedef void (*rac_vad_activity_callback_fn)(rac_speech_activity_t activity, void* user_data); + +/** + * @brief Audio buffer callback + * + * Mirrors Swift's VADService.onAudioBuffer callback. + * + * @param audio_data Audio data buffer (PCM float samples) + * @param num_samples Number of samples + * @param user_data User-provided context + */ +typedef void (*rac_vad_audio_callback_fn)(const float* audio_data, size_t num_samples, + void* user_data); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_VAD_TYPES_H */ diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_voice_agent.h b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_voice_agent.h new file mode 100644 index 000000000..df61305da --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/include/rac_voice_agent.h @@ -0,0 +1,615 @@ +/** + * @file rac_voice_agent.h + * @brief Voice Agent Capability - Full Voice Conversation Pipeline + * + * C port of Swift's VoiceAgentCapability.swift + * Swift Source: Sources/RunAnywhere/Features/VoiceAgent/VoiceAgentCapability.swift + * + * IMPORTANT: This is a direct translation of the Swift implementation. + * Do NOT add features not present in the Swift code. + * + * Composes STT, LLM, TTS, and VAD capabilities for end-to-end voice processing. + */ + +#ifndef RAC_VOICE_AGENT_H +#define RAC_VOICE_AGENT_H + +#include "rac_error.h" +#include "rac_types.h" +#include "rac_llm_types.h" +#include "rac_stt_types.h" +#include "rac_tts_types.h" +#include "rac_vad_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ============================================================================= +// CONSTANTS - Voice Agent Timing Defaults +// ============================================================================= + +/** Default timeout for waiting for speech input (seconds) */ +#define RAC_VOICE_AGENT_DEFAULT_SPEECH_TIMEOUT_SEC 10.0 + +/** Default maximum recording duration (seconds) */ +#define RAC_VOICE_AGENT_DEFAULT_MAX_RECORDING_DURATION_SEC 30.0 + +/** Default pause duration to end recording (seconds) */ +#define RAC_VOICE_AGENT_DEFAULT_END_OF_SPEECH_PAUSE_SEC 1.5 + +/** Maximum time to wait for LLM response (seconds) */ +#define RAC_VOICE_AGENT_LLM_RESPONSE_TIMEOUT_SEC 30.0 + +/** Maximum time to wait for TTS synthesis (seconds) */ +#define RAC_VOICE_AGENT_TTS_RESPONSE_TIMEOUT_SEC 15.0 + +// ============================================================================= +// TYPES - Mirrors Swift's VoiceAgentConfiguration and VoiceAgentResult +// ============================================================================= + +/** + * @brief Audio pipeline state - Mirrors Swift's AudioPipelineState enum + * + * Represents the current state of the audio pipeline to prevent feedback loops. + * See: Sources/RunAnywhere/Features/VoiceAgent/Models/AudioPipelineState.swift + */ +typedef enum rac_audio_pipeline_state { + RAC_AUDIO_PIPELINE_IDLE = 0, /**< System is idle, ready to start listening */ + RAC_AUDIO_PIPELINE_LISTENING = 1, /**< Actively listening for speech via VAD */ + RAC_AUDIO_PIPELINE_PROCESSING_SPEECH = 2, /**< Processing detected speech with STT */ + RAC_AUDIO_PIPELINE_GENERATING_RESPONSE = 3, /**< Generating response with LLM */ + RAC_AUDIO_PIPELINE_PLAYING_TTS = 4, /**< Playing TTS output */ + RAC_AUDIO_PIPELINE_COOLDOWN = 5, /**< Cooldown period after TTS to prevent feedback */ + RAC_AUDIO_PIPELINE_ERROR = 6 /**< Error state requiring reset */ +} rac_audio_pipeline_state_t; + +/** + * @brief Get string representation of audio pipeline state + * + * @param state The pipeline state + * @return State name string (static, do not free) + */ +RAC_API const char* rac_audio_pipeline_state_name(rac_audio_pipeline_state_t state); + +/** + * @brief Voice agent event types. + * Mirrors Swift's VoiceAgentEvent enum. + */ +typedef enum rac_voice_agent_event_type { + RAC_VOICE_AGENT_EVENT_PROCESSED = 0, /**< Complete processing result */ + RAC_VOICE_AGENT_EVENT_VAD_TRIGGERED = 1, /**< VAD triggered (speech detected/ended) */ + RAC_VOICE_AGENT_EVENT_TRANSCRIPTION = 2, /**< Transcription available from STT */ + RAC_VOICE_AGENT_EVENT_RESPONSE = 3, /**< Response generated from LLM */ + RAC_VOICE_AGENT_EVENT_AUDIO_SYNTHESIZED = 4, /**< Audio synthesized from TTS */ + RAC_VOICE_AGENT_EVENT_ERROR = 5 /**< Error occurred during processing */ +} rac_voice_agent_event_type_t; + +/** + * @brief VAD configuration for voice agent. + * Mirrors Swift's VADConfiguration. + */ +typedef struct rac_voice_agent_vad_config { + /** Sample rate (default: 16000) */ + int32_t sample_rate; + + /** Frame length in seconds (default: 0.1) */ + float frame_length; + + /** Energy threshold (default: 0.005) */ + float energy_threshold; +} rac_voice_agent_vad_config_t; + +/** + * @brief Default VAD configuration. + */ +static const rac_voice_agent_vad_config_t RAC_VOICE_AGENT_VAD_CONFIG_DEFAULT = { + .sample_rate = 16000, .frame_length = 0.1f, .energy_threshold = 0.005f}; + +/** + * @brief STT configuration for voice agent. + * Mirrors Swift's STTConfiguration. + */ +typedef struct rac_voice_agent_stt_config { + /** Model path - file path used for loading (can be NULL to use already-loaded model) */ + const char* model_path; + /** Model ID - identifier for telemetry (e.g., "whisper-base") */ + const char* model_id; + /** Model name - human-readable name (e.g., "Whisper Base") */ + const char* model_name; +} rac_voice_agent_stt_config_t; + +/** + * @brief LLM configuration for voice agent. + * Mirrors Swift's LLMConfiguration. + */ +typedef struct rac_voice_agent_llm_config { + /** Model path - file path used for loading (can be NULL to use already-loaded model) */ + const char* model_path; + /** Model ID - identifier for telemetry (e.g., "llama-3.2-1b") */ + const char* model_id; + /** Model name - human-readable name (e.g., "Llama 3.2 1B Instruct") */ + const char* model_name; +} rac_voice_agent_llm_config_t; + +/** + * @brief TTS configuration for voice agent. + * Mirrors Swift's TTSConfiguration. + */ +typedef struct rac_voice_agent_tts_config { + /** Voice path - file path used for loading (can be NULL/empty to use already-loaded voice) */ + const char* voice_path; + /** Voice ID - identifier for telemetry (e.g., "vits-piper-en_GB-alba-medium") */ + const char* voice_id; + /** Voice name - human-readable name (e.g., "Piper TTS (British English)") */ + const char* voice_name; +} rac_voice_agent_tts_config_t; + +/** + * @brief Voice agent configuration. + * Mirrors Swift's VoiceAgentConfiguration. + */ +typedef struct rac_voice_agent_config { + /** VAD configuration */ + rac_voice_agent_vad_config_t vad_config; + + /** STT configuration */ + rac_voice_agent_stt_config_t stt_config; + + /** LLM configuration */ + rac_voice_agent_llm_config_t llm_config; + + /** TTS configuration */ + rac_voice_agent_tts_config_t tts_config; +} rac_voice_agent_config_t; + +/** + * @brief Default voice agent configuration. + */ +static const rac_voice_agent_config_t RAC_VOICE_AGENT_CONFIG_DEFAULT = { + .vad_config = {.sample_rate = 16000, .frame_length = 0.1f, .energy_threshold = 0.005f}, + .stt_config = {.model_path = RAC_NULL, .model_id = RAC_NULL, .model_name = RAC_NULL}, + .llm_config = {.model_path = RAC_NULL, .model_id = RAC_NULL, .model_name = RAC_NULL}, + .tts_config = {.voice_path = RAC_NULL, .voice_id = RAC_NULL, .voice_name = RAC_NULL}}; + +// ============================================================================= +// AUDIO PIPELINE STATE MANAGER CONFIG - Mirrors Swift's AudioPipelineStateManager.Configuration +// ============================================================================= + +/** + * @brief Audio pipeline state manager configuration + * + * Mirrors Swift's AudioPipelineStateManager.Configuration struct. + * See: Sources/RunAnywhere/Features/VoiceAgent/Models/AudioPipelineState.swift + */ +typedef struct rac_audio_pipeline_config { + /** Duration to wait after TTS before allowing microphone (seconds) */ + float cooldown_duration; + + /** Whether to enforce strict state transitions */ + rac_bool_t strict_transitions; + + /** Maximum TTS duration before forced timeout (seconds) */ + float max_tts_duration; +} rac_audio_pipeline_config_t; + +/** + * @brief Default audio pipeline configuration + */ +static const rac_audio_pipeline_config_t RAC_AUDIO_PIPELINE_CONFIG_DEFAULT = { + .cooldown_duration = 0.8f, /* 800ms - better feedback prevention */ + .strict_transitions = RAC_TRUE, + .max_tts_duration = 30.0f}; + +// ============================================================================= +// AUDIO PIPELINE STATE MANAGER API +// ============================================================================= + +/** + * @brief Check if microphone can be activated in current state + * + * @param current_state Current pipeline state + * @param last_tts_end_time_ms Last TTS end time in milliseconds since epoch (0 if none) + * @param cooldown_duration_ms Cooldown duration in milliseconds + * @return RAC_TRUE if microphone can be activated + */ +RAC_API rac_bool_t rac_audio_pipeline_can_activate_microphone( + rac_audio_pipeline_state_t current_state, int64_t last_tts_end_time_ms, + int64_t cooldown_duration_ms); + +/** + * @brief Check if TTS can be played in current state + * + * @param current_state Current pipeline state + * @return RAC_TRUE if TTS can be played + */ +RAC_API rac_bool_t rac_audio_pipeline_can_play_tts(rac_audio_pipeline_state_t current_state); + +/** + * @brief Check if a state transition is valid + * + * @param from_state Current state + * @param to_state Target state + * @return RAC_TRUE if transition is valid + */ +RAC_API rac_bool_t rac_audio_pipeline_is_valid_transition(rac_audio_pipeline_state_t from_state, + rac_audio_pipeline_state_t to_state); + +/** + * @brief Voice agent processing result. + * Mirrors Swift's VoiceAgentResult. + */ +typedef struct rac_voice_agent_result { + /** Whether speech was detected in the input audio */ + rac_bool_t speech_detected; + + /** Transcribed text from STT (owned, must be freed with rac_free) */ + char* transcription; + + /** Generated response text from LLM (owned, must be freed with rac_free) */ + char* response; + + /** Synthesized audio data from TTS (owned, must be freed with rac_free) */ + void* synthesized_audio; + + /** Size of synthesized audio data in bytes */ + size_t synthesized_audio_size; +} rac_voice_agent_result_t; + +/** + * @brief Voice agent event data. + * Contains union for different event types. + */ +typedef struct rac_voice_agent_event { + /** Event type */ + rac_voice_agent_event_type_t type; + + union { + /** For PROCESSED event */ + rac_voice_agent_result_t result; + + /** For VAD_TRIGGERED event: true if speech started, false if ended */ + rac_bool_t vad_speech_active; + + /** For TRANSCRIPTION event */ + const char* transcription; + + /** For RESPONSE event */ + const char* response; + + /** For AUDIO_SYNTHESIZED event */ + struct { + const void* audio_data; + size_t audio_size; + } audio; + + /** For ERROR event */ + rac_result_t error_code; + } data; +} rac_voice_agent_event_t; + +/** + * @brief Callback for voice agent events during streaming. + * + * @param event The event that occurred + * @param user_data User-provided context + */ +typedef void (*rac_voice_agent_event_callback_fn)(const rac_voice_agent_event_t* event, + void* user_data); + +// ============================================================================= +// OPAQUE HANDLE +// ============================================================================= + +/** + * @brief Opaque handle for voice agent instance. + */ +typedef struct rac_voice_agent* rac_voice_agent_handle_t; + +// ============================================================================= +// LIFECYCLE API +// ============================================================================= + +/** + * @brief Create a standalone voice agent that owns its component handles. + * + * This is the recommended API. The voice agent creates and manages its own + * STT, LLM, TTS, and VAD component handles internally. Use the model loading + * APIs to load models after creation. + * + * @param out_handle Output: Handle to the created voice agent + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_voice_agent_create_standalone(rac_voice_agent_handle_t* out_handle); + +/** + * @brief Create a voice agent instance with external component handles. + * + * DEPRECATED: Prefer rac_voice_agent_create_standalone(). + * This API is for backward compatibility when you need to share handles. + * + * @param llm_component_handle Handle to LLM component (rac_llm_component) + * @param stt_component_handle Handle to STT component (rac_stt_component) + * @param tts_component_handle Handle to TTS component (rac_tts_component) + * @param vad_component_handle Handle to VAD component (rac_vad_component) + * @param out_handle Output: Handle to the created voice agent + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_voice_agent_create(rac_handle_t llm_component_handle, + rac_handle_t stt_component_handle, + rac_handle_t tts_component_handle, + rac_handle_t vad_component_handle, + rac_voice_agent_handle_t* out_handle); + +/** + * @brief Destroy a voice agent instance. + * + * If created with rac_voice_agent_create_standalone(), this also destroys + * the owned component handles. + * + * @param handle Voice agent handle + */ +RAC_API void rac_voice_agent_destroy(rac_voice_agent_handle_t handle); + +// ============================================================================= +// MODEL LOADING API (for standalone voice agent) +// ============================================================================= + +/** + * @brief Load an STT model into the voice agent. + * + * @param handle Voice agent handle + * @param model_path File path to the model (used for loading) + * @param model_id Model identifier (used for telemetry, e.g., "whisper-base") + * @param model_name Human-readable model name (e.g., "Whisper Base") + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_voice_agent_load_stt_model(rac_voice_agent_handle_t handle, + const char* model_path, + const char* model_id, + const char* model_name); + +/** + * @brief Load an LLM model into the voice agent. + * + * @param handle Voice agent handle + * @param model_path File path to the model (used for loading) + * @param model_id Model identifier (used for telemetry, e.g., "llama-3.2-1b") + * @param model_name Human-readable model name (e.g., "Llama 3.2 1B Instruct") + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_voice_agent_load_llm_model(rac_voice_agent_handle_t handle, + const char* model_path, + const char* model_id, + const char* model_name); + +/** + * @brief Load a TTS voice into the voice agent. + * + * @param handle Voice agent handle + * @param voice_path File path to the voice (used for loading) + * @param voice_id Voice identifier (used for telemetry, e.g., "vits-piper-en_GB-alba-medium") + * @param voice_name Human-readable voice name (e.g., "Piper TTS (British English)") + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_voice_agent_load_tts_voice(rac_voice_agent_handle_t handle, + const char* voice_path, + const char* voice_id, + const char* voice_name); + +/** + * @brief Check if STT model is loaded. + * + * @param handle Voice agent handle + * @param out_loaded Output: RAC_TRUE if loaded + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_voice_agent_is_stt_loaded(rac_voice_agent_handle_t handle, + rac_bool_t* out_loaded); + +/** + * @brief Check if LLM model is loaded. + * + * @param handle Voice agent handle + * @param out_loaded Output: RAC_TRUE if loaded + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_voice_agent_is_llm_loaded(rac_voice_agent_handle_t handle, + rac_bool_t* out_loaded); + +/** + * @brief Check if TTS voice is loaded. + * + * @param handle Voice agent handle + * @param out_loaded Output: RAC_TRUE if loaded + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_voice_agent_is_tts_loaded(rac_voice_agent_handle_t handle, + rac_bool_t* out_loaded); + +/** + * @brief Get the currently loaded STT model ID. + * + * @param handle Voice agent handle + * @return Model ID string (static, do not free) or NULL if not loaded + */ +RAC_API const char* rac_voice_agent_get_stt_model_id(rac_voice_agent_handle_t handle); + +/** + * @brief Get the currently loaded LLM model ID. + * + * @param handle Voice agent handle + * @return Model ID string (static, do not free) or NULL if not loaded + */ +RAC_API const char* rac_voice_agent_get_llm_model_id(rac_voice_agent_handle_t handle); + +/** + * @brief Get the currently loaded TTS voice ID. + * + * @param handle Voice agent handle + * @return Voice ID string (static, do not free) or NULL if not loaded + */ +RAC_API const char* rac_voice_agent_get_tts_voice_id(rac_voice_agent_handle_t handle); + +/** + * @brief Initialize the voice agent with configuration. + * + * Mirrors Swift's VoiceAgentCapability.initialize(_:). + * This method is smart about reusing already-loaded models. + * + * @param handle Voice agent handle + * @param config Configuration (can be NULL for defaults) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_voice_agent_initialize(rac_voice_agent_handle_t handle, + const rac_voice_agent_config_t* config); + +/** + * @brief Initialize using already-loaded models. + * + * Mirrors Swift's VoiceAgentCapability.initializeWithLoadedModels(). + * Verifies all required components are loaded and marks the voice agent as ready. + * + * @param handle Voice agent handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_voice_agent_initialize_with_loaded_models(rac_voice_agent_handle_t handle); + +/** + * @brief Cleanup voice agent resources. + * + * Mirrors Swift's VoiceAgentCapability.cleanup(). + * + * @param handle Voice agent handle + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_voice_agent_cleanup(rac_voice_agent_handle_t handle); + +/** + * @brief Check if voice agent is ready. + * + * Mirrors Swift's VoiceAgentCapability.isReady property. + * + * @param handle Voice agent handle + * @param out_is_ready Output: RAC_TRUE if ready + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_voice_agent_is_ready(rac_voice_agent_handle_t handle, + rac_bool_t* out_is_ready); + +// ============================================================================= +// VOICE PROCESSING API +// ============================================================================= + +/** + * @brief Process a complete voice turn: audio → transcription → LLM response → synthesized speech. + * + * Mirrors Swift's VoiceAgentCapability.processVoiceTurn(_:). + * + * @param handle Voice agent handle + * @param audio_data Audio data from user + * @param audio_size Size of audio data in bytes + * @param out_result Output: Voice agent result (caller owns memory, must free with + * rac_voice_agent_result_free) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_voice_agent_process_voice_turn(rac_voice_agent_handle_t handle, + const void* audio_data, size_t audio_size, + rac_voice_agent_result_t* out_result); + +/** + * @brief Process audio with streaming events. + * + * Mirrors Swift's VoiceAgentCapability.processStream(_:). + * Events are delivered via the callback as processing progresses. + * + * @param handle Voice agent handle + * @param audio_data Audio data from user + * @param audio_size Size of audio data in bytes + * @param callback Event callback function + * @param user_data User context passed to callback + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_voice_agent_process_stream(rac_voice_agent_handle_t handle, + const void* audio_data, size_t audio_size, + rac_voice_agent_event_callback_fn callback, + void* user_data); + +// ============================================================================= +// INDIVIDUAL COMPONENT ACCESS API +// ============================================================================= + +/** + * @brief Transcribe audio only (without LLM/TTS). + * + * Mirrors Swift's VoiceAgentCapability.transcribe(_:). + * + * @param handle Voice agent handle + * @param audio_data Audio data + * @param audio_size Size of audio data in bytes + * @param out_transcription Output: Transcribed text (owned, must be freed with rac_free) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_voice_agent_transcribe(rac_voice_agent_handle_t handle, + const void* audio_data, size_t audio_size, + char** out_transcription); + +/** + * @brief Generate LLM response only. + * + * Mirrors Swift's VoiceAgentCapability.generateResponse(_:). + * + * @param handle Voice agent handle + * @param prompt Input prompt + * @param out_response Output: Generated response (owned, must be freed with rac_free) + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_voice_agent_generate_response(rac_voice_agent_handle_t handle, + const char* prompt, char** out_response); + +/** + * @brief Synthesize speech only. + * + * Mirrors Swift's VoiceAgentCapability.synthesizeSpeech(_:). + * + * @param handle Voice agent handle + * @param text Text to synthesize + * @param out_audio Output: Synthesized audio data (owned, must be freed with rac_free) + * @param out_audio_size Output: Size of audio data in bytes + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_voice_agent_synthesize_speech(rac_voice_agent_handle_t handle, + const char* text, void** out_audio, + size_t* out_audio_size); + +/** + * @brief Check if VAD detects speech. + * + * Mirrors Swift's VoiceAgentCapability.detectSpeech(_:). + * + * @param handle Voice agent handle + * @param samples Audio samples (float32) + * @param sample_count Number of samples + * @param out_speech_detected Output: RAC_TRUE if speech detected + * @return RAC_SUCCESS or error code + */ +RAC_API rac_result_t rac_voice_agent_detect_speech(rac_voice_agent_handle_t handle, + const float* samples, size_t sample_count, + rac_bool_t* out_speech_detected); + +// ============================================================================= +// MEMORY MANAGEMENT +// ============================================================================= + +/** + * @brief Free a voice agent result. + * + * @param result Result to free + */ +RAC_API void rac_voice_agent_result_free(rac_voice_agent_result_t* result); + +#ifdef __cplusplus +} +#endif + +#endif /* RAC_VOICE_AGENT_H */ diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/shim.c b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/shim.c new file mode 100644 index 000000000..2f5d496ff --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/CRACommons/shim.c @@ -0,0 +1,3 @@ +// CRACommons shim file +// This file exists to ensure Xcode's Swift Package Manager integration +// can build this module. The actual implementation is in the binary target. diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Core/Module/RunAnywhereModule.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Core/Module/RunAnywhereModule.swift new file mode 100644 index 000000000..01166f500 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Core/Module/RunAnywhereModule.swift @@ -0,0 +1,55 @@ +// +// RunAnywhereModule.swift +// RunAnywhere SDK +// +// Protocol for SDK modules that provide AI capabilities. +// Modules are the primary extension point for adding new backends. +// +// Note: Registration is now handled by the C++ platform backend. +// Modules only need to provide metadata and service creation. +// + +import Foundation + +/// Protocol for SDK modules that provide AI capabilities. +/// +/// Modules encapsulate backend-specific functionality for the SDK. +/// Each module typically provides one or more capabilities (LLM, STT, TTS, VAD). +/// +/// Registration with the C++ service registry is handled automatically by the +/// platform backend during SDK initialization. Modules only need to provide +/// metadata and service creation methods. +/// +/// ## Implementing a Module +/// +/// ```swift +/// public enum MyModule: RunAnywhereModule { +/// public static let moduleId = "my-module" +/// public static let moduleName = "My Module" +/// public static let capabilities: Set = [.llm] +/// public static let defaultPriority: Int = 100 +/// public static let inferenceFramework: InferenceFramework = .onnx +/// +/// public static func createService() async throws -> MyService { +/// let service = MyService() +/// try await service.initialize() +/// return service +/// } +/// } +/// ``` +public protocol RunAnywhereModule { + /// Unique identifier for this module (e.g., "llamacpp", "onnx") + static var moduleId: String { get } + + /// Human-readable name for the module + static var moduleName: String { get } + + /// Set of capabilities this module provides + static var capabilities: Set { get } + + /// Default priority for service registration (higher = preferred) + static var defaultPriority: Int { get } + + /// The inference framework this module uses + static var inferenceFramework: InferenceFramework { get } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Core/Types/AudioTypes.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Core/Types/AudioTypes.swift new file mode 100644 index 000000000..73b457868 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Core/Types/AudioTypes.swift @@ -0,0 +1,68 @@ +// +// AudioTypes.swift +// RunAnywhere SDK +// +// Audio-related type definitions used across audio components (STT, TTS, VAD, etc.) +// +// 🟢 BRIDGE: Maps to C++ rac_audio_format_enum_t +// C++ Source: include/rac/features/stt/rac_stt_types.h +// + +import CRACommons +import Foundation + +// MARK: - Audio Format + +/// Audio format options for audio processing +public enum AudioFormat: String, Sendable, CaseIterable { + case pcm + case wav + case mp3 + case opus + case aac + case flac + + /// File extension for this format + public var fileExtension: String { + rawValue + } + + /// MIME type for this format + public var mimeType: String { + switch self { + case .pcm: return "audio/pcm" + case .wav: return "audio/wav" + case .mp3: return "audio/mpeg" + case .opus: return "audio/opus" + case .aac: return "audio/aac" + case .flac: return "audio/flac" + } + } + + // MARK: - C++ Bridge (rac_audio_format_enum_t) + + /// Convert Swift AudioFormat to C++ rac_audio_format_enum_t + public func toCFormat() -> rac_audio_format_enum_t { + switch self { + case .pcm: return RAC_AUDIO_FORMAT_PCM + case .wav: return RAC_AUDIO_FORMAT_WAV + case .mp3: return RAC_AUDIO_FORMAT_MP3 + case .opus: return RAC_AUDIO_FORMAT_OPUS + case .aac: return RAC_AUDIO_FORMAT_AAC + case .flac: return RAC_AUDIO_FORMAT_FLAC + } + } + + /// Initialize from C++ rac_audio_format_enum_t + public init(from cFormat: rac_audio_format_enum_t) { + switch cFormat { + case RAC_AUDIO_FORMAT_PCM: self = .pcm + case RAC_AUDIO_FORMAT_WAV: self = .wav + case RAC_AUDIO_FORMAT_MP3: self = .mp3 + case RAC_AUDIO_FORMAT_OPUS: self = .opus + case RAC_AUDIO_FORMAT_AAC: self = .aac + case RAC_AUDIO_FORMAT_FLAC: self = .flac + default: self = .pcm + } + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Core/Types/ComponentTypes.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Core/Types/ComponentTypes.swift new file mode 100644 index 000000000..eb4cd9744 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Core/Types/ComponentTypes.swift @@ -0,0 +1,77 @@ +// +// ComponentTypes.swift +// RunAnywhere SDK +// +// Core type definitions for component models +// + +import Foundation + +// MARK: - Component Protocols + +/// Protocol for component configuration and initialization +/// +/// All component configurations (LLM, STT, TTS, VAD, etc.) conform to this protocol. +/// Provides common properties needed for model selection and framework preference. +public protocol ComponentConfiguration: Sendable { + /// Model identifier (optional - uses default if not specified) + var modelId: String? { get } + + /// Preferred inference framework for this component (optional) + var preferredFramework: InferenceFramework? { get } + + /// Validates the configuration + func validate() throws +} + +// Default implementation for preferredFramework (most configs don't need it) +extension ComponentConfiguration { + public var preferredFramework: InferenceFramework? { nil } +} + +/// Protocol for component output data +public protocol ComponentOutput: Sendable { + var timestamp: Date { get } +} + +// MARK: - SDK Component Enum + +/// SDK component types for identification. +/// +/// This enum consolidates what was previously `CapabilityType` and provides +/// a unified type for all AI capabilities in the SDK. +/// +/// ## Usage +/// +/// ```swift +/// // Check what capabilities a module provides +/// let capabilities = MyModule.capabilities +/// if capabilities.contains(.llm) { +/// // Module provides LLM services +/// } +/// ``` +public enum SDKComponent: String, CaseIterable, Codable, Sendable, Hashable { + case llm = "LLM" + case stt = "STT" + case tts = "TTS" + case vad = "VAD" + case voice = "VOICE" + case embedding = "EMBEDDING" + + /// Human-readable display name + public var displayName: String { + switch self { + case .llm: return "Language Model" + case .stt: return "Speech to Text" + case .tts: return "Text to Speech" + case .vad: return "Voice Activity Detection" + case .voice: return "Voice Agent" + case .embedding: return "Embedding" + } + } + + /// Analytics key for the component (lowercase) + public var analyticsKey: String { + rawValue.lowercased() + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Data/Network/Models/Auth/AuthenticationResponse.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Data/Network/Models/Auth/AuthenticationResponse.swift new file mode 100644 index 000000000..d7a6835b4 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Data/Network/Models/Auth/AuthenticationResponse.swift @@ -0,0 +1,44 @@ +import Foundation + +/// Response model for SDK authentication +/// Matches backend SDKAuthenticationResponse schema +public struct AuthenticationResponse: Codable, Sendable { + public let accessToken: String + public let refreshToken: String + public let expiresIn: Int + public let tokenType: String + public let organizationId: String + public let userId: String? + public let deviceId: String? + + public init( + accessToken: String, + refreshToken: String, + expiresIn: Int = 18000, + tokenType: String = "Bearer", + organizationId: String, + userId: String? = nil, + deviceId: String? = nil + ) { + self.accessToken = accessToken + self.refreshToken = refreshToken + self.expiresIn = expiresIn + self.tokenType = tokenType + self.organizationId = organizationId + self.userId = userId + self.deviceId = deviceId + } + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case refreshToken = "refresh_token" + case expiresIn = "expires_in" + case tokenType = "token_type" + case organizationId = "organization_id" + case userId = "user_id" + case deviceId = "device_id" + } +} + +/// Response model for token refresh (same as AuthenticationResponse) +public typealias RefreshTokenResponse = AuthenticationResponse diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Data/Network/Protocols/NetworkService.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Data/Network/Protocols/NetworkService.swift new file mode 100644 index 000000000..4bc9e1cb0 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Data/Network/Protocols/NetworkService.swift @@ -0,0 +1,60 @@ +import Foundation + +/// Protocol defining the network service interface +/// Takes path strings directly - use C++ RAC_ENDPOINT_* constants +public protocol NetworkService: Sendable { + /// Perform a POST request + func post( + _ path: String, + _ payload: T, + requiresAuth: Bool + ) async throws -> R + + /// Perform a GET request + func get( + _ path: String, + requiresAuth: Bool + ) async throws -> R + + /// Perform a raw POST request (returns Data) + func postRaw( + _ path: String, + _ payload: Data, + requiresAuth: Bool + ) async throws -> Data + + /// Perform a raw GET request (returns Data) + func getRaw( + _ path: String, + requiresAuth: Bool + ) async throws -> Data +} + +/// Extension to provide default implementations +public extension NetworkService { + func post( + _ path: String, + _ payload: T, + requiresAuth: Bool = true + ) async throws -> R { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode(payload) + let responseData = try await postRaw(path, data, requiresAuth: requiresAuth) + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode(R.self, from: responseData) + } + + func get( + _ path: String, + requiresAuth: Bool = true + ) async throws -> R { + let responseData = try await getRaw(path, requiresAuth: requiresAuth) + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode(R.self, from: responseData) + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Data/Network/Services/HTTPService.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Data/Network/Services/HTTPService.swift new file mode 100644 index 000000000..d75454bb6 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Data/Network/Services/HTTPService.swift @@ -0,0 +1,321 @@ +// +// HTTPService.swift +// RunAnywhere SDK +// +// Core HTTP service implementation using URLSession. +// All network logic is centralized here. +// + +import CRACommons +import Foundation + +/// HTTP Service - Core network implementation +/// Centralized HTTP transport layer using URLSession +public actor HTTPService: NetworkService { + + // MARK: - Singleton + + /// Shared HTTP service instance + public static let shared = HTTPService() + + // MARK: - Configuration + + private var session: URLSession + private var baseURL: URL? + private var apiKey: String? + private let logger = SDKLogger(category: "HTTPService") + + // MARK: - Initialization + + private init() { + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 30.0 + config.httpAdditionalHeaders = Self.defaultHeaders + self.session = URLSession(configuration: config) + } + + private static var defaultHeaders: [String: String] { + [ + "Content-Type": "application/json", + "Accept": "application/json", + "X-SDK-Client": "RunAnywhereSDK", + "X-SDK-Version": SDKConstants.version, + "X-Platform": SDKConstants.platform + ] + } + + // MARK: - Configuration + + /// Configure HTTP service with base URL and API key + public func configure(baseURL: URL, apiKey: String) { + self.baseURL = baseURL + self.apiKey = apiKey + + // Update session with API key header + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 30.0 + config.httpAdditionalHeaders = Self.defaultHeaders.merging([ + "apikey": apiKey, + "Prefer": "return=representation" + ]) { _, new in new } + self.session = URLSession(configuration: config) + + logger.info("HTTP service configured with base URL: \(baseURL.host ?? "unknown")") + } + + /// Configure with URL string + public func configure(baseURL: String, apiKey: String) { + guard let url = URL(string: baseURL) else { + logger.error("Invalid base URL: \(baseURL)") + return + } + configure(baseURL: url, apiKey: apiKey) + } + + /// Check if HTTP is configured + public var isConfigured: Bool { + baseURL != nil + } + + /// Current base URL + public var currentBaseURL: URL? { + baseURL + } + + // MARK: - NetworkService Protocol + + /// POST request with raw Data body + public func postRaw( + _ path: String, + _ payload: Data, + requiresAuth: Bool + ) async throws -> Data { + // For Supabase device registration, use UPSERT (merge-duplicates) to handle existing devices + // Supabase PostgREST requires both: + // 1. The `Prefer: resolution=merge-duplicates` header + // 2. The `?on_conflict=device_id` query parameter to specify the conflict column + if path.contains(RAC_ENDPOINT_DEV_DEVICE_REGISTER) { + // Add on_conflict query parameter to the path + let upsertPath = path.contains("?") ? "\(path)&on_conflict=device_id" : "\(path)?on_conflict=device_id" + return try await postRawWithHeaders( + upsertPath, + payload, + requiresAuth: requiresAuth, + additionalHeaders: ["Prefer": "resolution=merge-duplicates"] + ) + } + return try await postRawWithHeaders(path, payload, requiresAuth: requiresAuth) + } + + /// POST request with raw Data body and optional additional headers + private func postRawWithHeaders( + _ path: String, + _ payload: Data, + requiresAuth: Bool, + additionalHeaders: [String: String] = [:] + ) async throws -> Data { + guard let baseURL = baseURL else { + throw SDKError.network(.serviceNotAvailable, "HTTP service not configured") + } + + let url = buildURL(base: baseURL, path: path) + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.httpBody = payload + + return try await executeRequest(request, requiresAuth: requiresAuth, additionalHeaders: additionalHeaders) + } + + /// GET request with raw response + public func getRaw( + _ path: String, + requiresAuth: Bool + ) async throws -> Data { + guard let baseURL = baseURL else { + throw SDKError.network(.serviceNotAvailable, "HTTP service not configured") + } + + let url = buildURL(base: baseURL, path: path) + var request = URLRequest(url: url) + request.httpMethod = "GET" + + return try await executeRequest(request, requiresAuth: requiresAuth) + } + + // MARK: - Convenience Methods + + /// POST with JSON string body + public func post(_ path: String, json: String, requiresAuth: Bool = false) async throws -> Data { + guard let data = json.data(using: .utf8) else { + throw SDKError.general(.validationFailed, "Invalid JSON string") + } + return try await postRaw(path, data, requiresAuth: requiresAuth) + } + + /// POST with Encodable payload + public func post(_ path: String, payload: T, requiresAuth: Bool = true) async throws -> Data { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode(payload) + return try await postRaw(path, data, requiresAuth: requiresAuth) + } + + /// DELETE request + public func delete(_ path: String, requiresAuth: Bool = true) async throws -> Data { + guard let baseURL = baseURL else { + throw SDKError.network(.serviceNotAvailable, "HTTP service not configured") + } + + let url = buildURL(base: baseURL, path: path) + var request = URLRequest(url: url) + request.httpMethod = "DELETE" + + return try await executeRequest(request, requiresAuth: requiresAuth) + } + + /// PUT request + public func put(_ path: String, _ payload: Data, requiresAuth: Bool = true) async throws -> Data { + guard let baseURL = baseURL else { + throw SDKError.network(.serviceNotAvailable, "HTTP service not configured") + } + + let url = buildURL(base: baseURL, path: path) + var request = URLRequest(url: url) + request.httpMethod = "PUT" + request.httpBody = payload + + return try await executeRequest(request, requiresAuth: requiresAuth) + } + + // MARK: - Private Implementation + + private func buildURL(base: URL, path: String) -> URL { + // Handle paths that start with "/" vs full URLs + if path.hasPrefix("http://") || path.hasPrefix("https://") { + return URL(string: path) ?? base.appendingPathComponent(path) + } + + // Check if path contains query parameters + if path.contains("?") { + // Split path and query parameters + let components = path.split(separator: "?", maxSplits: 1) + let pathPart = String(components[0]) + let queryPart = String(components[1]) + + // Build URL with query parameters using URLComponents + guard var urlComponents = URLComponents(url: base, resolvingAgainstBaseURL: true) else { + return base.appendingPathComponent(path) + } + let existingPath = urlComponents.path + urlComponents.path = existingPath + pathPart + urlComponents.query = queryPart + + return urlComponents.url ?? base.appendingPathComponent(path) + } + + return base.appendingPathComponent(path) + } + + private func executeRequest(_ request: URLRequest, requiresAuth: Bool, additionalHeaders: [String: String] = [:]) async throws -> Data { + var request = request + + // Add additional headers if provided + for (key, value) in additionalHeaders { + request.setValue(value, forHTTPHeaderField: key) + } + + // Add authorization header + let token = try await resolveToken(requiresAuth: requiresAuth) + if !token.isEmpty { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + + // Execute request + let (data, response) = try await session.data(for: request) + + // Validate response + guard let httpResponse = response as? HTTPURLResponse else { + throw SDKError.network(.invalidResponse, "Invalid HTTP response") + } + + // Check status code + // For device registration, 409 (Conflict) means device already exists, which is fine + let isDeviceRegistration = request.url?.absoluteString.contains(RAC_ENDPOINT_DEV_DEVICE_REGISTER) ?? false + let isSuccess = (200...299).contains(httpResponse.statusCode) || (isDeviceRegistration && httpResponse.statusCode == 409) + + guard isSuccess else { + let error = parseHTTPError( + statusCode: httpResponse.statusCode, + data: data, + url: request.url?.absoluteString ?? "unknown" + ) + logger.error("HTTP \(httpResponse.statusCode): \(request.url?.absoluteString ?? "unknown")") + throw error + } + + // Log 409 as info for device registration (device already exists) + if isDeviceRegistration && httpResponse.statusCode == 409 { + logger.info("Device already registered (409) - treating as success") + } + + return data + } + + private func resolveToken(requiresAuth: Bool) async throws -> String { + if requiresAuth { + // Get token from C++ state, refreshing if needed + if let token = CppBridge.State.accessToken, !CppBridge.State.tokenNeedsRefresh { + return token + } + // Try refresh if we have refresh token + if CppBridge.State.refreshToken != nil { + try await CppBridge.Auth.refreshToken() + if let token = CppBridge.State.accessToken { + return token + } + } + // Fallback to API key if no OAuth token available + // This supports API key-only authentication for production mode + if let key = apiKey, !key.isEmpty { + return key + } + throw SDKError.authentication(.authenticationFailed, "No valid authentication token") + } + // Use API key for non-auth requests + return apiKey ?? "" + } + + private func parseHTTPError(statusCode: Int, data: Data, url _: String) -> SDKError { + // Try to parse error message from response body + var errorMessage = "HTTP error \(statusCode)" + + // JSONSerialization returns heterogeneous dictionary for parsing unknown JSON error responses + // swiftlint:disable:next avoid_any_type + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + if let message = json["message"] as? String { + errorMessage = message + } else if let error = json["error"] as? String { + errorMessage = error + } else if let hint = json["hint"] as? String { + errorMessage = "\(errorMessage): \(hint)" + } + } + + switch statusCode { + case 400: + return SDKError.network(.httpError, "Bad request: \(errorMessage)") + case 401: + return SDKError.authentication(.authenticationFailed, errorMessage) + case 403: + return SDKError.authentication(.forbidden, errorMessage) + case 404: + return SDKError.network(.httpError, "Not found: \(errorMessage)") + case 429: + return SDKError.network(.httpError, "Rate limited: \(errorMessage)") + case 500...599: + return SDKError.network(.serverError, "Server error (\(statusCode)): \(errorMessage)") + default: + return SDKError.network(.httpError, "HTTP \(statusCode): \(errorMessage)") + } + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Features/LLM/System/SystemFoundationModelsModule.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Features/LLM/System/SystemFoundationModelsModule.swift new file mode 100644 index 000000000..7f6cce59b --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Features/LLM/System/SystemFoundationModelsModule.swift @@ -0,0 +1,92 @@ +// +// SystemFoundationModelsModule.swift +// RunAnywhere SDK +// +// Built-in Apple Foundation Models (Apple Intelligence) module. +// Platform-specific LLM provider available on iOS 26+ / macOS 26+. +// +// Registration is now handled by the C++ platform backend. This module +// provides the Swift service implementation that the C++ backend calls. +// + +import CRACommons +import Foundation + +// MARK: - System Foundation Models Module + +/// Built-in Apple Foundation Models (Apple Intelligence) module. +/// +/// This is a platform-specific (iOS 26+/macOS 26+) LLM provider that uses +/// Apple's built-in Foundation Models powered by Apple Intelligence. +/// +/// The C++ platform backend handles registration with the service registry. +/// This Swift module provides the actual implementation through callbacks. +/// +/// ## Availability +/// +/// Requires: +/// - iOS 26.0+ or macOS 26.0+ +/// - Apple Intelligence enabled on the device +/// - Apple Intelligence capable hardware +/// +/// ## Usage +/// +/// ```swift +/// import RunAnywhere +/// +/// // Platform backend is registered automatically during SDK init +/// // Load the built-in model +/// try await RunAnywhere.loadModel("foundation-models-default") +/// +/// // Generate text +/// let response = try await RunAnywhere.chat("Hello!") +/// ``` +public enum SystemFoundationModels: RunAnywhereModule { + // MARK: - RunAnywhereModule Conformance + + public static let moduleId = "system-foundation-models" + public static let moduleName = "System Foundation Models" + public static let capabilities: Set = [.llm] + public static let defaultPriority: Int = 50 // Lower than LlamaCPP (100) + + /// System Foundation Models uses Apple's built-in Foundation Models + public static let inferenceFramework: InferenceFramework = .foundationModels + + // MARK: - Public API + + /// Check if Foundation Models is available on this device + public static var isAvailable: Bool { + guard #available(iOS 26.0, macOS 26.0, *) else { + return false + } + return true + } + + /// Check if this module can handle the given model ID + public static func canHandle(modelId: String?) -> Bool { + guard #available(iOS 26.0, macOS 26.0, *) else { + return false + } + + guard let modelId = modelId, !modelId.isEmpty else { + return false + } + + let lowercasedId = modelId.lowercased() + return lowercasedId.contains("foundation-models") + || lowercasedId.contains("foundation") + || lowercasedId.contains("apple-intelligence") + || lowercasedId == "foundation-models-default" + || lowercasedId == "system-llm" + } + + /// Create a SystemFoundationModelsService instance directly + /// + /// Use this for direct access without going through the service registry. + @available(iOS 26.0, macOS 26.0, *) + public static func createService() async throws -> SystemFoundationModelsService { + let service = SystemFoundationModelsService() + try await service.initialize(modelPath: "built-in") + return service + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Features/LLM/System/SystemFoundationModelsService.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Features/LLM/System/SystemFoundationModelsService.swift new file mode 100644 index 000000000..4f9dfe51b --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Features/LLM/System/SystemFoundationModelsService.swift @@ -0,0 +1,274 @@ +// +// SystemFoundationModelsService.swift +// RunAnywhere SDK +// +// Service implementation for Apple's Foundation Models (Apple Intelligence). +// Requires iOS 26+ / macOS 26+. +// + +import Foundation + +// Import FoundationModels with conditional compilation +#if canImport(FoundationModels) +import FoundationModels +#endif + +/// Service implementation for Apple's Foundation Models (Apple Intelligence). +/// +/// This service provides LLM text generation using Apple's built-in Foundation Models. +/// It requires iOS 26+ / macOS 26+ and an Apple Intelligence capable device. +@available(iOS 26.0, macOS 26.0, *) +public class SystemFoundationModelsService { + private var _currentModel: String? + private var _isReady = false + private let logger = SDKLogger(category: "SystemFoundationModels") + + #if canImport(FoundationModels) + // Type-erased wrapper for FoundationModels session + private var session: LanguageSessionWrapper? + #endif + + // MARK: - Framework Identification + + /// Apple Foundation Models inference framework + public let inferenceFramework: InferenceFramework = .foundationModels + + public var isReady: Bool { _isReady } + public var currentModel: String? { _currentModel } + + /// Foundation Models has a 4096 token context window + public var contextLength: Int? { 4096 } + + /// Apple Foundation Models does not support true token-by-token streaming + public var supportsStreaming: Bool { false } + + #if canImport(FoundationModels) + /// Type-erased wrapper for LanguageModelSession + private struct LanguageSessionWrapper { + let session: LanguageModelSession + } + #endif + + public init() { + } + + public func initialize(modelPath _: String?) async throws { + logger.info("Initializing Apple Foundation Models (iOS 26+/macOS 26+)") + + #if canImport(FoundationModels) + guard #available(iOS 26.0, macOS 26.0, *) else { + logger.error("iOS 26.0+ or macOS 26.0+ not available") + throw SDKError.llm(.notInitialized, "iOS 26.0+ or macOS 26.0+ not available") + } + + logger.info("FoundationModels framework is available, proceeding with initialization") + + do { + try await initializeFoundationModel() + _currentModel = "foundation-models-native" + _isReady = true + logger.info("Foundation Models initialized successfully") + } catch { + logger.error("Failed to initialize Foundation Models: \(error)") + throw SDKError.llm(.initializationFailed, "Failed to initialize Foundation Models", underlying: error) + } + #else + // Foundation Models framework not available + logger.error("FoundationModels framework not available") + throw SDKError.llm(.frameworkNotAvailable, "FoundationModels framework not available") + #endif + } + + #if canImport(FoundationModels) + /// Initializes the Foundation Model and creates session + private func initializeFoundationModel() async throws { + logger.info("Getting SystemLanguageModel.default...") + let model = SystemLanguageModel.default + logger.info("SystemLanguageModel.default obtained successfully") + + try checkModelAvailability(model) + + logger.info("Creating LanguageModelSession with instructions...") + let instructions = """ + You are a helpful AI assistant integrated into the RunAnywhere app. \ + Provide concise, accurate responses that are appropriate for mobile users. \ + Keep responses brief but informative. + """ + session = LanguageSessionWrapper(session: LanguageModelSession(instructions: instructions)) + logger.info("LanguageModelSession created successfully") + } + + /// Checks if the model is available and ready to use + private func checkModelAvailability(_ model: SystemLanguageModel) throws { + switch model.availability { + case .available: + logger.info("Foundation Models is available") + case .unavailable(.deviceNotEligible): + logger.error("Device not eligible for Apple Intelligence") + throw SDKError.llm(.hardwareUnsupported, "Device not eligible for Apple Intelligence") + case .unavailable(.appleIntelligenceNotEnabled): + logger.error("Apple Intelligence not enabled. Please enable it in Settings.") + throw SDKError.llm(.notInitialized, "Apple Intelligence not enabled. Please enable it in Settings.") + case .unavailable(.modelNotReady): + logger.error("Model not ready. It may be downloading or initializing.") + throw SDKError.llm(.componentNotReady, "Model not ready. It may be downloading or initializing.") + case .unavailable(let other): + logger.error("Foundation Models unavailable: \(String(describing: other))") + throw SDKError.llm(.serviceNotAvailable, "Foundation Models unavailable: \(String(describing: other))") + @unknown default: + logger.error("Unknown availability status") + throw SDKError.llm(.unknown, "Unknown Foundation Models availability status") + } + } + #endif + + public func generate(prompt: String, options: LLMGenerationOptions) async throws -> String { + guard isReady else { + throw SDKError.llm(.notInitialized, "Foundation Models service not initialized") + } + + logger.debug("Generating response for prompt: \(prompt.prefix(100))...") + + #if canImport(FoundationModels) + guard let sessionWrapper = session else { + logger.error("Session not available - was initialization successful?") + throw SDKError.llm(.notInitialized, "Session not available - was initialization successful?") + } + + let sessionObj = sessionWrapper.session + + // Check if session is responding to another request + guard !sessionObj.isResponding else { + logger.warning("Session is already responding to another request") + throw SDKError.llm(.serviceBusy, "Session is busy with another request") + } + + do { + let response = try await performGeneration( + with: sessionObj, + prompt: prompt, + temperature: Double(options.temperature) + ) + logger.debug("Generated response successfully") + return response + } catch let error as LanguageModelSession.GenerationError { + try handleGenerationError(error) + throw SDKError.llm(.generationFailed, "Generation failed", underlying: error) + } catch { + logger.error("Generation failed: \(error)") + throw SDKError.llm(.generationFailed, "Generation failed", underlying: error) + } + #else + // Foundation Models framework not available + logger.error("FoundationModels framework not available") + throw SDKError.llm(.frameworkNotAvailable, "FoundationModels framework not available") + #endif + } + + public func streamGenerate( + prompt: String, + options: LLMGenerationOptions, + onToken: @escaping (String) -> Void + ) async throws { + guard isReady else { + throw SDKError.llm(.notInitialized, "Foundation Models service not initialized") + } + + logger.debug("Starting streaming generation for prompt: \(prompt.prefix(100))...") + + #if canImport(FoundationModels) + guard let sessionWrapper = session else { + logger.error("Session not available for streaming") + throw SDKError.llm(.notInitialized, "Session not available for streaming") + } + + let sessionObj = sessionWrapper.session + + // Check if session is responding to another request + guard !sessionObj.isResponding else { + logger.warning("Session is already responding to another request") + throw SDKError.llm(.serviceBusy, "Session is busy with another request") + } + + do { + try await performStreamGeneration( + with: sessionObj, + prompt: prompt, + temperature: Double(options.temperature), + onToken: onToken + ) + logger.debug("Streaming generation completed successfully") + } catch let error as LanguageModelSession.GenerationError { + try handleGenerationError(error) + throw SDKError.llm(.generationFailed, "Streaming generation failed", underlying: error) + } catch { + logger.error("Streaming generation failed: \(error)") + throw SDKError.llm(.generationFailed, "Streaming generation failed", underlying: error) + } + #else + // Foundation Models framework not available + logger.error("FoundationModels framework not available for streaming") + throw SDKError.llm(.frameworkNotAvailable, "FoundationModels framework not available for streaming") + #endif + } + + #if canImport(FoundationModels) + /// Performs text generation with the given session + private func performGeneration( + with session: LanguageModelSession, + prompt: String, + temperature: Double + ) async throws -> String { + let foundationOptions = GenerationOptions(temperature: temperature) + let response = try await session.respond(to: prompt, options: foundationOptions) + return response.content + } + + /// Performs streaming text generation + private func performStreamGeneration( + with session: LanguageModelSession, + prompt: String, + temperature: Double, + onToken: @escaping (String) -> Void + ) async throws { + let foundationOptions = GenerationOptions(temperature: temperature) + let responseStream = session.streamResponse(to: prompt, options: foundationOptions) + + var previousContent = "" + for try await partialResponse in responseStream { + let currentContent = partialResponse.content + if currentContent.count > previousContent.count { + let newTokens = String(currentContent.dropFirst(previousContent.count)) + onToken(newTokens) + previousContent = currentContent + } + } + } + + /// Handles generation errors from FoundationModels + private func handleGenerationError(_ error: LanguageModelSession.GenerationError) throws { + logger.error("Foundation Models generation error: \(error)") + switch error { + case .exceededContextWindowSize: + logger.error("Exceeded context window size - please reduce prompt length") + // Foundation Models has a 4096 token context window + throw SDKError.llm(.contextTooLong, "Exceeded context window size (max 4096 tokens) - please reduce prompt length") + default: + logger.error("Other generation error: \(error)") + throw SDKError.llm(.generationFailed, "Foundation Models generation error", underlying: error) + } + } + #endif + + public func cleanup() async { + logger.info("Cleaning up Foundation Models") + + #if canImport(FoundationModels) + // Clean up the session + session = nil + #endif + + _isReady = false + _currentModel = nil + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Features/STT/Services/AudioCaptureManager.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Features/STT/Services/AudioCaptureManager.swift new file mode 100644 index 000000000..8ec05a7ec --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Features/STT/Services/AudioCaptureManager.swift @@ -0,0 +1,262 @@ +// +// AudioCaptureManager.swift +// RunAnywhere SDK +// +// Shared audio capture utility for STT features. +// Can be used with any STT backend (ONNX, etc.) +// + +import AVFoundation +import CRACommons +import Foundation + +/// Manages audio capture from microphone for STT services. +/// +/// This is a shared utility that works with any STT backend (ONNX, etc.). +/// It captures audio at 16kHz mono Int16 format, which is the standard input format +/// for speech recognition models like Whisper. +/// +/// - Works on: iOS, tvOS, and macOS using AVAudioEngine +/// - NOT supported on: watchOS (AVAudioEngine inputNode tap doesn't work reliably) +/// +/// ## Usage +/// ```swift +/// let capture = AudioCaptureManager() +/// let granted = await capture.requestPermission() +/// if granted { +/// try capture.startRecording { audioData in +/// // Feed audioData to your STT service +/// } +/// } +/// ``` +public class AudioCaptureManager: ObservableObject { + private let logger = SDKLogger(category: "AudioCapture") + + private var audioEngine: AVAudioEngine? + private var inputNode: AVAudioInputNode? + + @Published public var isRecording = false + @Published public var audioLevel: Float = 0.0 + + private let targetSampleRate = Double(RAC_STT_DEFAULT_SAMPLE_RATE) + + public init() { + logger.info("AudioCaptureManager initialized") + } + + /// Request microphone permission + public func requestPermission() async -> Bool { + #if os(iOS) + // Use modern AVAudioApplication API for iOS 17+ + if #available(iOS 17.0, *) { + return await AVAudioApplication.requestRecordPermission() + } else { + // Fallback to deprecated API for older iOS versions + return await withCheckedContinuation { continuation in + AVAudioSession.sharedInstance().requestRecordPermission { granted in + continuation.resume(returning: granted) + } + } + } + #elseif os(tvOS) + // tvOS doesn't have AVAudioApplication, use legacy API + return await withCheckedContinuation { continuation in + AVAudioSession.sharedInstance().requestRecordPermission { granted in + continuation.resume(returning: granted) + } + } + #elseif os(macOS) + // On macOS, use AVCaptureDevice for permission request + return await withCheckedContinuation { continuation in + AVCaptureDevice.requestAccess(for: .audio) { granted in + continuation.resume(returning: granted) + } + } + #endif + } + + /// Start recording audio from microphone + /// - Note: Not supported on watchOS due to AVAudioEngine limitations + public func startRecording(onAudioData: @escaping (Data) -> Void) throws { + guard !isRecording else { + logger.warning("Already recording") + return + } + + #if os(iOS) || os(tvOS) + // Configure audio session (iOS/tvOS only) + // watchOS is NOT supported - AVAudioEngine inputNode tap does not work on watchOS + let audioSession = AVAudioSession.sharedInstance() + try audioSession.setCategory(.record, mode: .measurement) + try audioSession.setActive(true) + #endif + + // Create audio engine (works on all platforms) + let engine = AVAudioEngine() + let inputNode = engine.inputNode + + // Get input format + let inputFormat = inputNode.outputFormat(forBus: 0) + logger.info("Input format: \(inputFormat.sampleRate) Hz, \(inputFormat.channelCount) channels") + + // Create converter format (16kHz, mono, int16) + guard let outputFormat = AVAudioFormat( + commonFormat: .pcmFormatInt16, + sampleRate: targetSampleRate, + channels: 1, + interleaved: false + ) else { + throw AudioCaptureError.formatConversionFailed + } + + // Create audio converter + guard let converter = AVAudioConverter(from: inputFormat, to: outputFormat) else { + throw AudioCaptureError.formatConversionFailed + } + + // Install tap on input node + inputNode.installTap(onBus: 0, bufferSize: 4096, format: inputFormat) { [weak self] buffer, _ in + guard let self = self else { return } + + // Update audio level for visualization + self.updateAudioLevel(buffer: buffer) + + // Convert to target format + guard let convertedBuffer = self.convert(buffer: buffer, using: converter, to: outputFormat) else { + return + } + + // Convert to Data (int16 PCM) + if let audioData = self.bufferToData(buffer: convertedBuffer) { + DispatchQueue.main.async { + onAudioData(audioData) + } + } + } + + // Start engine + try engine.start() + + self.audioEngine = engine + self.inputNode = inputNode + + DispatchQueue.main.async { + self.isRecording = true + } + + logger.info("Recording started") + } + + /// Stop recording + public func stopRecording() { + guard isRecording else { return } + + inputNode?.removeTap(onBus: 0) + audioEngine?.stop() + + audioEngine = nil + inputNode = nil + + #if os(iOS) || os(tvOS) + // Deactivate audio session (iOS/tvOS only) + try? AVAudioSession.sharedInstance().setActive(false) + #endif + + DispatchQueue.main.async { + self.isRecording = false + self.audioLevel = 0.0 + } + + logger.info("Recording stopped") + } + + // MARK: - Private Helpers + + private func convert( + buffer: AVAudioPCMBuffer, + using converter: AVAudioConverter, + to format: AVAudioFormat + ) -> AVAudioPCMBuffer? { + let capacity = AVAudioFrameCount(Double(buffer.frameLength) * (format.sampleRate / buffer.format.sampleRate)) + + guard let convertedBuffer = AVAudioPCMBuffer( + pcmFormat: format, + frameCapacity: capacity + ) else { + return nil + } + + var error: NSError? + let inputBlock: AVAudioConverterInputBlock = { _, outStatus in + outStatus.pointee = .haveData + return buffer + } + + converter.convert(to: convertedBuffer, error: &error, withInputFrom: inputBlock) + + if let error = error { + logger.error("Conversion error: \(error.localizedDescription)") + return nil + } + + return convertedBuffer + } + + private func bufferToData(buffer: AVAudioPCMBuffer) -> Data? { + guard let channelData = buffer.int16ChannelData else { + return nil + } + + let channelDataPointer = channelData.pointee + let dataSize = Int(buffer.frameLength * buffer.format.streamDescription.pointee.mBytesPerFrame) + + return Data(bytes: channelDataPointer, count: dataSize) + } + + private func updateAudioLevel(buffer: AVAudioPCMBuffer) { + guard let channelData = buffer.floatChannelData else { return } + + let channelDataPointer = channelData.pointee + let frames = Int(buffer.frameLength) + + // Calculate RMS (root mean square) for audio level + var sum: Float = 0.0 + for i in 0.. Void)? + private var playbackContinuation: CheckedContinuation? + + @Published public var isPlaying = false + @Published public var currentTime: TimeInterval = 0.0 + @Published public var duration: TimeInterval = 0.0 + + public override init() { + super.init() + logger.info("AudioPlaybackManager initialized") + } + + // MARK: - Public API + + /// Play audio data asynchronously (async/await) + /// - Parameter audioData: WAV audio data to play + /// - Throws: AudioPlaybackError if playback fails + public func play(_ audioData: Data) async throws { + guard !audioData.isEmpty else { + throw AudioPlaybackError.emptyAudioData + } + + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + self.playbackContinuation = continuation + + do { + try startPlayback(audioData) + } catch { + self.playbackContinuation = nil + continuation.resume(throwing: error) + } + } + } + + /// Play audio data with completion callback + /// - Parameters: + /// - audioData: WAV audio data to play + /// - completion: Called when playback finishes (true = success, false = error/interrupted) + public func play(_ audioData: Data, completion: @escaping (Bool) -> Void) { + guard !audioData.isEmpty else { + logger.warning("Empty audio data, skipping playback") + completion(false) + return + } + + playbackCompletion = completion + + do { + try startPlayback(audioData) + } catch { + logger.error("Failed to start playback: \(error)") + playbackCompletion = nil + completion(false) + } + } + + /// Stop current playback + public func stop() { + guard isPlaying else { return } + + audioPlayer?.stop() + cleanupPlayback(success: false) + logger.info("Playback stopped by user") + } + + /// Pause current playback + public func pause() { + guard isPlaying else { return } + audioPlayer?.pause() + logger.info("Playback paused") + } + + /// Resume paused playback + public func resume() { + guard let player = audioPlayer, !player.isPlaying else { return } + player.play() + logger.info("Playback resumed") + } + + // MARK: - Private Implementation + + private func startPlayback(_ audioData: Data) throws { + // Stop any existing playback + if isPlaying { + stop() + } + + // Configure audio session for playback + try configureAudioSession() + + // Create and configure audio player + audioPlayer = try AVAudioPlayer(data: audioData) + audioPlayer?.delegate = self + audioPlayer?.prepareToPlay() + + duration = audioPlayer?.duration ?? 0.0 + currentTime = 0.0 + + guard audioPlayer?.play() == true else { + throw AudioPlaybackError.playbackFailed + } + + DispatchQueue.main.async { + self.isPlaying = true + } + + logger.info("Playback started: \(audioData.count) bytes, duration: \(self.duration)s") + + // Start progress timer + startProgressTimer() + } + + private func configureAudioSession() throws { + #if os(iOS) || os(tvOS) + let audioSession = AVAudioSession.sharedInstance() + try audioSession.setCategory(.playback, mode: .default, options: [.duckOthers]) + try audioSession.setActive(true) + #endif + // macOS doesn't require audio session configuration + } + + private func deactivateAudioSession() { + #if os(iOS) || os(tvOS) + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + #endif + } + + private var progressTimer: Timer? + + private func startProgressTimer() { + progressTimer?.invalidate() + progressTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in + guard let self = self, let player = self.audioPlayer else { return } + DispatchQueue.main.async { + self.currentTime = player.currentTime + } + } + } + + private func stopProgressTimer() { + progressTimer?.invalidate() + progressTimer = nil + } + + private func cleanupPlayback(success: Bool) { + stopProgressTimer() + deactivateAudioSession() + + DispatchQueue.main.async { + self.isPlaying = false + self.currentTime = 0.0 + } + + // Complete async continuation if present + if let continuation = playbackContinuation { + playbackContinuation = nil + if success { + continuation.resume() + } else { + continuation.resume(throwing: AudioPlaybackError.playbackInterrupted) + } + } + + // Call completion handler if present + if let completion = playbackCompletion { + playbackCompletion = nil + completion(success) + } + + audioPlayer = nil + } + + // MARK: - AVAudioPlayerDelegate + + public func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { + logger.info("Playback finished: \(flag ? "success" : "failed")") + cleanupPlayback(success: flag) + } + + public func audioPlayerDecodeErrorDidOccur(_ player: AVAudioPlayer, error: Error?) { + logger.error("Playback decode error: \(error?.localizedDescription ?? "unknown")") + cleanupPlayback(success: false) + } + + #if os(iOS) || os(tvOS) + public func audioPlayerBeginInterruption(_ player: AVAudioPlayer) { + logger.info("Playback interrupted") + DispatchQueue.main.async { + self.isPlaying = false + } + } + + public func audioPlayerEndInterruption(_ player: AVAudioPlayer, withOptions flags: Int) { + logger.info("Playback interruption ended") + if flags == AVAudioSession.InterruptionOptions.shouldResume.rawValue { + player.play() + DispatchQueue.main.async { + self.isPlaying = true + } + } + } + #endif + + deinit { + stop() + } +} + +// MARK: - Errors + +public enum AudioPlaybackError: LocalizedError { + case emptyAudioData + case playbackFailed + case playbackInterrupted + case invalidAudioFormat + + public var errorDescription: String? { + switch self { + case .emptyAudioData: + return "Audio data is empty" + case .playbackFailed: + return "Failed to start audio playback" + case .playbackInterrupted: + return "Audio playback was interrupted" + case .invalidAudioFormat: + return "Invalid audio format" + } + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Features/TTS/System/SystemTTSModule.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Features/TTS/System/SystemTTSModule.swift new file mode 100644 index 000000000..3756f9e87 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Features/TTS/System/SystemTTSModule.swift @@ -0,0 +1,69 @@ +// +// SystemTTSModule.swift +// RunAnywhere SDK +// +// Built-in System TTS module using AVSpeechSynthesizer. +// Platform-specific fallback when no other TTS providers are available. +// +// Registration is now handled by the C++ platform backend. This module +// provides the Swift service implementation that the C++ backend calls. +// + +import CRACommons +import Foundation + +// MARK: - System TTS Module + +/// Built-in System TTS module using Apple's AVSpeechSynthesizer. +/// +/// This is a platform-specific (iOS/macOS) TTS provider that serves as +/// a fallback when no other TTS providers (like ONNX Piper) are available +/// or when explicitly requested via the "system-tts" voice ID. +/// +/// The C++ platform backend handles registration with the service registry. +/// This Swift module provides the actual implementation through callbacks. +/// +/// ## Usage +/// +/// ```swift +/// // Use system TTS explicitly +/// try await RunAnywhere.speak("Hello", voiceId: "system-tts") +/// +/// // Or as automatic fallback when no other TTS is available +/// try await RunAnywhere.speak("Hello") +/// ``` +public enum SystemTTS: RunAnywhereModule { + // MARK: - RunAnywhereModule Conformance + + public static let moduleId = "system-tts" + public static let moduleName = "System TTS" + public static let capabilities: Set = [.tts] + public static let defaultPriority: Int = 10 // Low priority - fallback only + + /// System TTS uses Apple's built-in speech synthesis + public static let inferenceFramework: InferenceFramework = .systemTTS + + // MARK: - Public API + + /// Check if this provider can handle the given voice ID + public static func canHandle(voiceId: String?) -> Bool { + guard let voiceId = voiceId else { + // System TTS can handle nil (fallback for TTS) + return true + } + + let lowercasedId = voiceId.lowercased() + return lowercasedId.contains("system-tts") + || lowercasedId.contains("system_tts") + || lowercasedId == "system" + || lowercasedId == "system-tts-default" + } + + /// Create a SystemTTSService instance + @MainActor + public static func createService() async throws -> SystemTTSService { + let service = SystemTTSService() + try await service.initialize() + return service + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Features/TTS/System/SystemTTSService.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Features/TTS/System/SystemTTSService.swift new file mode 100644 index 000000000..b50046d97 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Features/TTS/System/SystemTTSService.swift @@ -0,0 +1,172 @@ +// +// SystemTTSService.swift +// RunAnywhere SDK +// +// System TTS Service implementation using AVSpeechSynthesizer +// Fully isolated from Swift async context to avoid AVFoundation conflicts +// + +import AVFoundation +import Foundation + +// MARK: - System TTS Service + +/// System TTS Service implementation using AVSpeechSynthesizer +/// +/// This is the default TTS service that uses Apple's built-in speech synthesis. +/// It supports all iOS/macOS system voices and provides real-time speech playback. +/// +/// **Note:** System TTS plays audio directly through speakers. The returned `Data` +/// is a placeholder - use ONNX Piper TTS if you need actual audio data for custom playback. +/// +/// **Concurrency:** This service uses `Task.detached` to completely isolate AVFoundation +/// operations from Swift's async runtime, avoiding "unsafeForcedSync" warnings. +@MainActor +public final class SystemTTSService: NSObject { + + // MARK: - Framework Identification + + public nonisolated let inferenceFramework: InferenceFramework = .systemTTS + + // MARK: - Properties + + private let synthesizer = AVSpeechSynthesizer() + private let logger = SDKLogger(category: "SystemTTS") + + /// Completion handler for current speech operation + private var speechCompletion: ((Result) -> Void)? + + // MARK: - Initialization + + public override init() { + super.init() + synthesizer.delegate = self + } + + // MARK: - TTS Operations + + public nonisolated func initialize() async throws { + await MainActor.run { + logger.info("System TTS initialized (direct playback mode)") + } + } + + public nonisolated func synthesize(text: String, options: TTSOptions) async throws -> Data { + // Use Task.detached to completely break out of any async context + // This prevents AVFoundation's internal sync operations from conflicting with Swift concurrency + return try await Task.detached { @MainActor [self] in + logger.info("Speaking: '\(text.prefix(50))...'") + + let utterance = createUtterance(text: text, options: options) + + return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + // We're already on MainActor, so this is safe + speechCompletion = { result in + switch result { + case .success(let data): + continuation.resume(returning: data) + case .failure(let error): + continuation.resume(throwing: error) + } + } + synthesizer.speak(utterance) + } + }.value + } + + public nonisolated func synthesizeStream( + text: String, + options: TTSOptions, + onChunk: @escaping (Data) -> Void + ) async throws { + // System TTS doesn't support streaming - synthesize and signal completion + _ = try await synthesize(text: text, options: options) + onChunk(Data()) + } + + public func stop() { + synthesizer.stopSpeaking(at: .immediate) + speechCompletion?(.success(Data())) + speechCompletion = nil + } + + public nonisolated var isSynthesizing: Bool { + // Access synthesizer state - this is thread-safe for reading + synthesizer.isSpeaking + } + + public nonisolated var availableVoices: [String] { + AVSpeechSynthesisVoice.speechVoices().map { $0.identifier } + } + + public nonisolated func cleanup() async { + await MainActor.run { + stop() + } + } + + // MARK: - Private Helpers + + private func createUtterance(text: String, options: TTSOptions) -> AVSpeechUtterance { + let utterance = AVSpeechUtterance(string: text) + + // Configure voice + utterance.voice = resolveVoice(options: options) + + // Configure speech parameters + utterance.rate = options.rate * AVSpeechUtteranceDefaultSpeechRate + utterance.pitchMultiplier = options.pitch + utterance.volume = options.volume + utterance.preUtteranceDelay = 0.0 + utterance.postUtteranceDelay = 0.0 + + return utterance + } + + private func resolveVoice(options: TTSOptions) -> AVSpeechSynthesisVoice? { + guard let voiceId = options.voice, + voiceId != "system" && voiceId != "system-tts" else { + return AVSpeechSynthesisVoice(language: options.language) + } + + return AVSpeechSynthesisVoice(identifier: voiceId) + ?? AVSpeechSynthesisVoice(language: voiceId) + ?? AVSpeechSynthesisVoice(language: options.language) + } +} + +// MARK: - AVSpeechSynthesizerDelegate + +extension SystemTTSService: AVSpeechSynthesizerDelegate { + + public nonisolated func speechSynthesizer( + _ synthesizer: AVSpeechSynthesizer, + didFinish utterance: AVSpeechUtterance + ) { + Task { @MainActor in + logger.info("Speech playback completed") + speechCompletion?(.success(Data())) + speechCompletion = nil + } + } + + public nonisolated func speechSynthesizer( + _ synthesizer: AVSpeechSynthesizer, + didCancel utterance: AVSpeechUtterance + ) { + Task { @MainActor in + logger.info("Speech playback cancelled") + speechCompletion?(.failure(CancellationError())) + speechCompletion = nil + } + } + + public nonisolated func speechSynthesizer( + _ synthesizer: AVSpeechSynthesizer, + didStart utterance: AVSpeechUtterance + ) { + Task { @MainActor in + logger.debug("Speech playback started") + } + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/CppBridge.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/CppBridge.swift new file mode 100644 index 000000000..0542583be --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/CppBridge.swift @@ -0,0 +1,197 @@ +/** + * CppBridge.swift + * + * Unified bridge architecture for C++ ↔ Swift interop. + * + * All C++ bridges are organized under a single namespace for: + * - Consistent initialization/shutdown lifecycle + * - Shared access to platform resources + * - Clear ownership and dependency management + * + * ## Initialization Order + * + * ```swift + * // Phase 1: Core init (sync) - must be called first + * CppBridge.initialize(environment: .production) + * ├─ PlatformAdapter.register() ← File ops, logging, keychain + * ├─ Events.register() ← Analytics event callback + * ├─ Telemetry.initialize() ← Telemetry HTTP callback + * └─ Device.register() ← Device registration callbacks + * + * // Phase 2: Services init (async) - after HTTP is configured + * await CppBridge.initializeServices() + * ├─ ModelAssignment.register() ← Model assignment callbacks + * └─ Platform.register() ← LLM/TTS service callbacks + * ``` + * + * ## Bridge Extensions (in Extensions/ folder) + * + * - CppBridge+PlatformAdapter.swift - File ops, logging, keychain, clock + * - CppBridge+Environment.swift - Environment, DevConfig, Endpoints + * - CppBridge+Telemetry.swift - Events, Telemetry + * - CppBridge+Device.swift - Device registration + * - CppBridge+State.swift - SDK state management + * - CppBridge+HTTP.swift - HTTP transport + * - CppBridge+Auth.swift - Authentication flow + * - CppBridge+Services.swift - Service registry + * - CppBridge+ModelPaths.swift - Model path utilities + * - CppBridge+ModelRegistry.swift - Model registry + * - CppBridge+ModelAssignment.swift - Model assignment + * - CppBridge+Download.swift - Download manager + * - CppBridge+Platform.swift - Platform services (Foundation Models, System TTS) + * - CppBridge+LLM/STT/TTS/VAD.swift - AI component bridges + * - CppBridge+VoiceAgent.swift - Voice agent bridge + * - CppBridge+Storage/Strategy.swift - Storage utilities + */ + +import CRACommons +import Foundation + +// MARK: - Main Bridge Coordinator + +/// Central coordinator for all C++ bridges +/// Manages lifecycle and shared resources +public enum CppBridge { + + // MARK: - Shared State + + private static var _environment: SDKEnvironment = .development + private static var _isInitialized = false + private static var _servicesInitialized = false + private static let lock = NSLock() + + /// Current SDK environment + static var environment: SDKEnvironment { + lock.lock() + defer { lock.unlock() } + return _environment + } + + /// Whether core bridges are initialized (Phase 1) + public static var isInitialized: Bool { + lock.lock() + defer { lock.unlock() } + return _isInitialized + } + + /// Whether service bridges are initialized (Phase 2) + public static var servicesInitialized: Bool { + lock.lock() + defer { lock.unlock() } + return _servicesInitialized + } + + // MARK: - Phase 1: Core Initialization (Synchronous) + + /// Initialize all core C++ bridges + /// + /// This must be called FIRST during SDK initialization, before any C++ operations. + /// It registers fundamental platform callbacks that C++ needs. + /// + /// - Parameter environment: SDK environment + public static func initialize(environment: SDKEnvironment) { + lock.lock() + guard !_isInitialized else { + lock.unlock() + return + } + _environment = environment + lock.unlock() + + // Step 1: Platform adapter FIRST (logging, file ops, keychain) + // This must be registered before any other C++ calls + PlatformAdapter.register() + + // Step 1.5: Configure C++ logging based on environment + // In production: disables C++ stderr, logs only go through Swift bridge + // In development: C++ stderr ON for debugging + rac_configure_logging(environment.cEnvironment) + + // Step 2: Events callback (for analytics routing) + Events.register() + + // Step 3: Telemetry manager (builds JSON, calls HTTP callback) + Telemetry.initialize(environment: environment) + + // Step 4: Device registration callbacks + Device.register() + + lock.lock() + _isInitialized = true + lock.unlock() + + SDKLogger(category: "CppBridge").debug("Core bridges initialized for \(environment)") + } + + // MARK: - Phase 2: Services Initialization (Async) + + /// Initialize service bridges that require HTTP + /// + /// Called after HTTP transport is configured. These bridges need + /// network access to function. + @MainActor + public static func initializeServices() { + lock.lock() + guard !_servicesInitialized else { + lock.unlock() + return + } + let currentEnv = _environment + lock.unlock() + + // Model assignment (needs HTTP for API calls) + // Only auto-fetch in staging/production, not development + // IMPORTANT: Register WITHOUT auto-fetch first to avoid MainActor deadlock + // The HTTP callback uses semaphore.wait() which would block MainActor + // while the Task{} inside needs MainActor access + let shouldAutoFetch = currentEnv != .development + ModelAssignment.register(autoFetch: false) + + // If auto-fetch is needed, trigger it asynchronously off MainActor + if shouldAutoFetch { + Task.detached { + do { + _ = try await ModelAssignment.fetch(forceRefresh: true) + SDKLogger(category: "CppBridge").info("Auto-fetched model assignments successfully") + } catch { + SDKLogger(category: "CppBridge").warning("Auto-fetch model assignments failed: \(error.localizedDescription)") + } + } + } + + // Platform services (Foundation Models, System TTS) + Platform.register() + + lock.lock() + _servicesInitialized = true + lock.unlock() + + SDKLogger(category: "CppBridge").debug("Service bridges initialized (env: \(currentEnv), autoFetch: \(shouldAutoFetch))") + } + + // MARK: - Shutdown + + /// Shutdown all C++ bridges + public static func shutdown() { + lock.lock() + let wasInitialized = _isInitialized + lock.unlock() + + guard wasInitialized else { return } + + // Shutdown in reverse order + // Note: ModelAssignment and Platform callbacks remain valid (static) + + Telemetry.shutdown() + Events.unregister() + // PlatformAdapter callbacks remain valid (static) + // Device callbacks remain valid (static) + + lock.lock() + _isInitialized = false + _servicesInitialized = false + lock.unlock() + + SDKLogger(category: "CppBridge").debug("All bridges shutdown") + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+Auth.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+Auth.swift new file mode 100644 index 000000000..58af5e643 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+Auth.swift @@ -0,0 +1,297 @@ +// +// CppBridge+Auth.swift +// RunAnywhere SDK +// +// Authentication bridge extension for C++ interop. +// + +import CRACommons +import Foundation + +// MARK: - Auth Bridge (Complete Auth Flow) + +extension CppBridge { + + /// Complete authentication bridge + /// Handles full auth flow: JSON building, HTTP, parsing, state storage + public enum Auth { + + private static let logger = SDKLogger(category: "CppBridge.Auth") + + // MARK: - Complete Auth Flow + + /// Authenticate with backend + /// - Parameter apiKey: API key for authentication + /// - Returns: Authentication response + /// - Throws: SDKError on failure + @discardableResult + public static func authenticate(apiKey: String) async throws -> AuthenticationResponse { + let deviceId = DeviceIdentity.persistentUUID + + // 1. Build request JSON via C++ + guard let json = buildAuthenticateRequestJSON( + apiKey: apiKey, + deviceId: deviceId, + platform: SDKConstants.platform, + sdkVersion: SDKConstants.version + ) else { + throw SDKError.general(.validationFailed, "Failed to build auth request") + } + + logger.info("Starting authentication...") + + // 2. Make HTTP request + let responseData = try await HTTP.shared.post( + RAC_ENDPOINT_AUTHENTICATE, + json: json, + requiresAuth: false + ) + + // 3. Parse response via Codable + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let response = try decoder.decode(AuthenticationResponse.self, from: responseData) + + // 4. Store in C++ state + // Use our device ID if API doesn't return one (API deviceId is optional) + let effectiveDeviceId = response.deviceId ?? deviceId + let expiresAt = Date().addingTimeInterval(TimeInterval(response.expiresIn)) + State.setAuth( + accessToken: response.accessToken, + refreshToken: response.refreshToken, + expiresAt: expiresAt, + userId: response.userId, + organizationId: response.organizationId, + deviceId: effectiveDeviceId + ) + + // 5. Store in Keychain + try storeTokensInKeychain(response, deviceId: effectiveDeviceId) + + logger.info("Authentication successful") + return response + } + + /// Refresh access token + /// - Returns: New access token + /// - Throws: SDKError on failure + @discardableResult + public static func refreshToken() async throws -> String { + guard let refreshToken = State.refreshToken else { + throw SDKError.authentication(.invalidAPIKey, "No refresh token") + } + + guard let deviceId = State.deviceId else { + throw SDKError.authentication(.authenticationFailed, "No device ID") + } + + // 1. Build refresh request JSON via C++ + guard let json = buildRefreshRequestJSON(deviceId: deviceId, refreshToken: refreshToken) else { + throw SDKError.general(.validationFailed, "Failed to build refresh request") + } + + logger.debug("Refreshing access token...") + + // 2. Make HTTP request + let responseData = try await HTTP.shared.post( + RAC_ENDPOINT_REFRESH, + json: json, + requiresAuth: false + ) + + // 3. Parse response + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let response = try decoder.decode(AuthenticationResponse.self, from: responseData) + + // 4. Store in C++ state + // Use our device ID if API doesn't return one (API deviceId is optional) + let effectiveDeviceId = response.deviceId ?? deviceId + let expiresAt = Date().addingTimeInterval(TimeInterval(response.expiresIn)) + State.setAuth( + accessToken: response.accessToken, + refreshToken: response.refreshToken, + expiresAt: expiresAt, + userId: response.userId, + organizationId: response.organizationId, + deviceId: effectiveDeviceId + ) + + // 5. Store in Keychain + try storeTokensInKeychain(response, deviceId: effectiveDeviceId) + + logger.info("Token refresh successful") + return response.accessToken + } + + /// Get valid access token (refresh if needed) + /// - Returns: Valid access token + /// - Throws: SDKError if no valid token available + public static func getAccessToken() async throws -> String { + // Check if current token is valid + if let token = State.accessToken, !State.tokenNeedsRefresh { + return token + } + + // Try to refresh + if State.refreshToken != nil { + return try await refreshToken() + } + + throw SDKError.authentication(.authenticationFailed, "No valid token") + } + + /// Clear authentication state + public static func clearAuth() throws { + // Clear C++ state + State.clearAuth() + + // Clear Keychain + try KeychainManager.shared.delete(for: "com.runanywhere.sdk.accessToken") + try KeychainManager.shared.delete(for: "com.runanywhere.sdk.refreshToken") + try KeychainManager.shared.delete(for: "com.runanywhere.sdk.deviceId") + try KeychainManager.shared.delete(for: "com.runanywhere.sdk.userId") + try KeychainManager.shared.delete(for: "com.runanywhere.sdk.organizationId") + + logger.info("Authentication cleared") + } + + /// Check if currently authenticated + public static var isAuthenticated: Bool { + State.isAuthenticated + } + + // MARK: - Keychain Storage + + private static func storeTokensInKeychain(_ response: AuthenticationResponse, deviceId: String) throws { + try KeychainManager.shared.store(response.accessToken, for: "com.runanywhere.sdk.accessToken") + try KeychainManager.shared.store(response.refreshToken, for: "com.runanywhere.sdk.refreshToken") + try KeychainManager.shared.store(deviceId, for: "com.runanywhere.sdk.deviceId") + if let userId = response.userId { + try KeychainManager.shared.store(userId, for: "com.runanywhere.sdk.userId") + } + try KeychainManager.shared.store(response.organizationId, for: "com.runanywhere.sdk.organizationId") + } + + // MARK: - JSON Building (existing methods) + + /// Build authentication request JSON via C++ + /// - Parameters: + /// - apiKey: API key + /// - deviceId: Device ID + /// - platform: Platform string (e.g., "ios") + /// - sdkVersion: SDK version string + /// - Returns: JSON string for POST body, or nil on error + public static func buildAuthenticateRequestJSON( + apiKey: String, + deviceId: String, + platform: String, + sdkVersion: String + ) -> String? { + return apiKey.withCString { key in + deviceId.withCString { did in + platform.withCString { plat in + sdkVersion.withCString { ver in + var request = rac_auth_request_t( + api_key: key, + device_id: did, + platform: plat, + sdk_version: ver + ) + + guard let jsonPtr = rac_auth_request_to_json(&request) else { + return nil + } + + let json = String(cString: jsonPtr) + free(jsonPtr) + return json + } + } + } + } + } + + /// Build refresh token request JSON via C++ + /// - Parameters: + /// - deviceId: Device ID + /// - refreshToken: Refresh token + /// - Returns: JSON string for POST body, or nil on error + public static func buildRefreshRequestJSON( + deviceId: String, + refreshToken: String + ) -> String? { + return deviceId.withCString { did in + refreshToken.withCString { token in + var request = rac_refresh_request_t( + device_id: did, + refresh_token: token + ) + + guard let jsonPtr = rac_refresh_request_to_json(&request) else { + return nil + } + + let json = String(cString: jsonPtr) + free(jsonPtr) + return json + } + } + } + + /// Parse API error from HTTP response via C++ + /// - Parameters: + /// - statusCode: HTTP status code + /// - body: Response body data + /// - url: Request URL + /// - Returns: SDKError with appropriate category and message + public static func parseAPIError( + statusCode: Int32, + body: Data?, + url: String? + ) -> SDKError { + let bodyString = body.flatMap { String(data: $0, encoding: .utf8) } ?? "" + let urlString = url ?? "" + + var error = rac_api_error_t() + + let result = bodyString.withCString { bodyPtr in + urlString.withCString { urlPtr in + rac_api_error_from_response(statusCode, bodyPtr, urlPtr, &error) + } + } + + defer { + rac_api_error_free(&error) + } + + // Use C++ parsed message, or fallback + let message: String + if result == 0, let msgPtr = error.message { + message = String(cString: msgPtr) + } else { + message = "HTTP \(statusCode)" + } + + // Map status code to SDKError category + switch statusCode { + case 401: + return SDKError.network(.unauthorized, message) + case 403: + return SDKError.network(.forbidden, message) + case 404: + return SDKError.network(.invalidResponse, message) + case 408, 504: + return SDKError.network(.timeout, message) + case 422: + return SDKError.network(.validationFailed, message) + case 400..<500: + return SDKError.network(.httpError, "Client error \(statusCode): \(message)") + case 500..<600: + return SDKError.network(.serverError, "Server error \(statusCode): \(message)") + default: + return SDKError.network(.unknown, "\(message) (status: \(statusCode))") + } + } + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+Device.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+Device.swift new file mode 100644 index 000000000..4fab23f63 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+Device.swift @@ -0,0 +1,275 @@ +// +// CppBridge+Device.swift +// RunAnywhere SDK +// +// Device registration bridge extension for C++ interop. +// Implements callbacks for C++ device manager to access Swift platform APIs. +// + +import CRACommons +import Foundation + +// MARK: - Device Bridge + +extension CppBridge { + + /// Device registration bridge + /// C++ handles all business logic; Swift provides platform callbacks + public enum Device { + + // MARK: - Callback Storage (must persist for C++ to call) + + private static var callbacksRegistered = false + + // MARK: - Public API + + // Register callbacks with C++ device manager. + // Must be called during SDK initialization. + // swiftlint:disable:next function_body_length + public static func register() { + guard !callbacksRegistered else { return } + + var callbacks = rac_device_callbacks_t() + + // Get device info callback - populates all fields needed by backend + callbacks.get_device_info = { outInfo, _ in + guard let outInfo = outInfo else { return } + + let deviceInfo = DeviceInfo.current + let deviceId = DeviceIdentity.persistentUUID + + #if targetEnvironment(simulator) + let isSimulator = true + #else + let isSimulator = false + #endif + + // Fill out the device info struct + // Note: C strings are managed by Swift and remain valid during callback + + // Required fields (backend schema) + outInfo.pointee.device_id = (deviceId as NSString).utf8String + outInfo.pointee.device_model = (deviceInfo.deviceModel as NSString).utf8String + outInfo.pointee.device_name = (deviceInfo.deviceName as NSString).utf8String + outInfo.pointee.platform = (deviceInfo.platform as NSString).utf8String + outInfo.pointee.os_version = (deviceInfo.osVersion as NSString).utf8String + outInfo.pointee.form_factor = (deviceInfo.formFactor as NSString).utf8String + outInfo.pointee.architecture = (deviceInfo.architecture as NSString).utf8String + outInfo.pointee.chip_name = (deviceInfo.chipName as NSString).utf8String + outInfo.pointee.total_memory = Int64(deviceInfo.totalMemory) + outInfo.pointee.available_memory = Int64(deviceInfo.availableMemory) + outInfo.pointee.has_neural_engine = deviceInfo.hasNeuralEngine ? RAC_TRUE : RAC_FALSE + outInfo.pointee.neural_engine_cores = Int32(deviceInfo.neuralEngineCores) + outInfo.pointee.gpu_family = (deviceInfo.gpuFamily as NSString).utf8String + outInfo.pointee.battery_level = deviceInfo.batteryLevel ?? -1.0 + if let batteryState = deviceInfo.batteryState { + outInfo.pointee.battery_state = (batteryState as NSString).utf8String + } + outInfo.pointee.is_low_power_mode = deviceInfo.isLowPowerMode ? RAC_TRUE : RAC_FALSE + outInfo.pointee.core_count = Int32(deviceInfo.coreCount) + outInfo.pointee.performance_cores = Int32(deviceInfo.performanceCores) + outInfo.pointee.efficiency_cores = Int32(deviceInfo.efficiencyCores) + outInfo.pointee.device_fingerprint = (deviceId as NSString).utf8String + + // Legacy fields (backward compatibility) + outInfo.pointee.device_type = (deviceInfo.deviceType as NSString).utf8String + outInfo.pointee.os_name = ("iOS" as NSString).utf8String + outInfo.pointee.processor_count = Int32(deviceInfo.coreCount) + outInfo.pointee.is_simulator = isSimulator ? RAC_TRUE : RAC_FALSE + } + + // Get device ID callback + callbacks.get_device_id = { _ in + let deviceId = DeviceIdentity.persistentUUID + return (deviceId as NSString).utf8String + } + + // Check if registered callback + // Note: Cannot capture context in C function pointer, so use literal key + callbacks.is_registered = { _ in + return UserDefaults.standard.bool(forKey: "com.runanywhere.sdk.deviceRegistered") ? RAC_TRUE : RAC_FALSE + } + + // Set registered callback + // Note: Cannot capture context in C function pointer, so use literal key + callbacks.set_registered = { registered, _ in + if registered == RAC_TRUE { + UserDefaults.standard.set(true, forKey: "com.runanywhere.sdk.deviceRegistered") + } else { + UserDefaults.standard.removeObject(forKey: "com.runanywhere.sdk.deviceRegistered") + } + } + + // HTTP POST callback + callbacks.http_post = { endpoint, jsonBody, requiresAuth, outResponse, _ -> rac_result_t in + guard let endpoint = endpoint, let jsonBody = jsonBody, let outResponse = outResponse else { + return RAC_ERROR_INVALID_ARGUMENT + } + + let endpointStr = String(cString: endpoint) + let jsonStr = String(cString: jsonBody) + let needsAuth = requiresAuth == RAC_TRUE + + // Make synchronous HTTP call (we're already on a background thread from C++) + let semaphore = DispatchSemaphore(value: 0) + var result: rac_result_t = RAC_SUCCESS + + Task { + do { + guard let jsonData = jsonStr.data(using: .utf8) else { + outResponse.pointee.result = RAC_ERROR_INVALID_ARGUMENT + outResponse.pointee.status_code = 0 + outResponse.pointee.error_message = ("Invalid JSON data" as NSString).utf8String + result = RAC_ERROR_INVALID_ARGUMENT + semaphore.signal() + return + } + + // Use the HTTP bridge to make the request + let responseData = try await CppBridge.HTTP.shared.postRaw( + endpointStr, + jsonData, + requiresAuth: needsAuth + ) + + outResponse.pointee.result = RAC_SUCCESS + outResponse.pointee.status_code = 200 + if let responseStr = String(data: responseData, encoding: .utf8) { + outResponse.pointee.response_body = (responseStr as NSString).utf8String + } + result = RAC_SUCCESS + } catch { + outResponse.pointee.result = RAC_ERROR_NETWORK_ERROR + outResponse.pointee.status_code = 0 + outResponse.pointee.error_message = (error.localizedDescription as NSString).utf8String + result = RAC_ERROR_NETWORK_ERROR + } + semaphore.signal() + } + + semaphore.wait() + return result + } + + callbacks.user_data = nil + + let setResult = rac_device_manager_set_callbacks(&callbacks) + if setResult == RAC_SUCCESS { + callbacksRegistered = true + SDKLogger(category: "CppBridge.Device").debug("Device manager callbacks registered") + } else { + SDKLogger(category: "CppBridge.Device").error("Failed to register device manager callbacks: \(setResult)") + } + } + + /// Register device with backend if not already registered + /// All business logic is in C++ - this is just a thin wrapper + public static func registerIfNeeded(environment: SDKEnvironment) async throws { + guard callbacksRegistered else { + throw SDKError.general(.notInitialized, "Device manager callbacks not registered") + } + + // Get build token for development mode + let buildTokenString = environment == .development ? CppBridge.DevConfig.buildToken : nil + + let result: rac_result_t + if let token = buildTokenString { + result = token.withCString { tokenPtr in + rac_device_manager_register_if_needed(Environment.toC(environment), tokenPtr) + } + } else { + result = rac_device_manager_register_if_needed(Environment.toC(environment), nil) + } + + // RAC_SUCCESS means registered successfully or already registered + if result != RAC_SUCCESS { + throw SDKError.network(.serviceNotAvailable, "Device registration failed: \(result)") + } + } + + /// Check if device is registered + public static var isRegistered: Bool { + return rac_device_manager_is_registered() == RAC_TRUE + } + + /// Clear registration status + public static func clearRegistration() { + rac_device_manager_clear_registration() + } + + /// Get the device ID + public static var deviceId: String? { + guard let ptr = rac_device_manager_get_device_id() else { return nil } + return String(cString: ptr) + } + + // MARK: - Legacy API (for backward compatibility) + + /// Build device registration JSON via C++ (legacy) + @available(*, deprecated, message: "Use registerIfNeeded() instead - all logic is now in C++") + public static func buildRegistrationJSON(buildToken: String? = nil) -> String? { + let deviceInfo = DeviceInfo.current + let deviceId = DeviceIdentity.persistentUUID + let env = CppBridge.environment + + #if targetEnvironment(simulator) + let isSimulator = true + #else + let isSimulator = false + #endif + + var request = rac_device_registration_request_t() + var cDeviceInfo = rac_device_registration_info_t() + + return deviceId.withCString { did in + deviceInfo.deviceType.withCString { dtype in + deviceInfo.deviceModel.withCString { dmodel in + "iOS".withCString { osName in + deviceInfo.osVersion.withCString { osVer in + deviceInfo.platform.withCString { plat in + SDKConstants.version.withCString { sdkVer in + (buildToken ?? "").withCString { token in + + cDeviceInfo.device_id = did + cDeviceInfo.device_type = dtype + cDeviceInfo.device_model = dmodel + cDeviceInfo.os_name = osName + cDeviceInfo.os_version = osVer + cDeviceInfo.platform = plat + cDeviceInfo.total_memory = Int64(deviceInfo.totalMemory) + cDeviceInfo.available_memory = Int64(deviceInfo.availableMemory) + cDeviceInfo.core_count = Int32(deviceInfo.coreCount) + cDeviceInfo.is_simulator = isSimulator ? RAC_TRUE : RAC_FALSE + + request.device_info = cDeviceInfo + request.sdk_version = sdkVer + request.build_token = buildToken != nil ? token : nil + request.last_seen_at_ms = Int64(Date().timeIntervalSince1970 * 1000) + + var jsonPtr: UnsafeMutablePointer? + var jsonLen: Int = 0 + + let result = rac_device_registration_to_json( + &request, + Environment.toC(env), + &jsonPtr, + &jsonLen + ) + + if result == RAC_SUCCESS, let json = jsonPtr { + let jsonString = String(cString: json) + free(json) + return jsonString + } + return nil + } + } + } + } + } + } + } + } + } + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+Download.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+Download.swift new file mode 100644 index 000000000..46b3a7b96 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+Download.swift @@ -0,0 +1,246 @@ +// +// CppBridge+Download.swift +// RunAnywhere SDK +// +// Download manager bridge extension for C++ interop. +// + +import CRACommons +import Foundation + +// MARK: - Download Bridge + +extension CppBridge { + + /// Download manager bridge + /// Wraps C++ rac_download.h functions for download orchestration + public actor Download { + + /// Shared download manager instance + public static let shared = Download() + + private var handle: rac_download_manager_handle_t? + private let logger = SDKLogger(category: "CppBridge.Download") + + /// Active progress callbacks (taskId -> callback) + private var progressCallbacks: [String: (DownloadProgress) -> Void] = [:] + + private init() { + var handlePtr: rac_download_manager_handle_t? + let result = rac_download_manager_create(nil, &handlePtr) + if result == RAC_SUCCESS { + self.handle = handlePtr + logger.debug("Download manager created") + } else { + logger.error("Failed to create download manager") + } + } + + deinit { + if let handle = handle { + rac_download_manager_destroy(handle) + } + } + + // MARK: - Download Operations + + /// Start a download task + /// Returns the task ID for tracking + public func startDownload( + modelId: String, + url: URL, + destinationPath: URL, + requiresExtraction: Bool, + progressHandler: @escaping (DownloadProgress) -> Void + ) throws -> String { + guard let handle = handle else { + throw SDKError.general(.initializationFailed, "Download manager not initialized") + } + + var taskIdPtr: UnsafeMutablePointer? + + let result = modelId.withCString { mid in + url.absoluteString.withCString { urlStr in + destinationPath.path.withCString { destPath in + rac_download_manager_start( + handle, + mid, + urlStr, + destPath, + requiresExtraction ? RAC_TRUE : RAC_FALSE, + nil, // Progress callback handled via polling + nil, // Complete callback handled via polling + nil, // User data + &taskIdPtr + ) + } + } + } + + guard result == RAC_SUCCESS, let taskId = taskIdPtr else { + throw SDKError.download(.downloadFailed, "Failed to start download") + } + + let taskIdString = String(cString: taskId) + free(taskId) + + // Store progress callback + progressCallbacks[taskIdString] = progressHandler + + logger.info("Started download task: \(taskIdString)") + return taskIdString + } + + /// Cancel a download task + public func cancelDownload(taskId: String) throws { + guard let handle = handle else { + throw SDKError.general(.initializationFailed, "Download manager not initialized") + } + + let result = taskId.withCString { tid in + rac_download_manager_cancel(handle, tid) + } + + guard result == RAC_SUCCESS else { + throw SDKError.download(.downloadFailed, "Failed to cancel download") + } + + progressCallbacks.removeValue(forKey: taskId) + logger.info("Cancelled download task: \(taskId)") + } + + /// Pause all downloads + public func pauseAll() throws { + guard let handle = handle else { + throw SDKError.general(.initializationFailed, "Download manager not initialized") + } + + let result = rac_download_manager_pause_all(handle) + guard result == RAC_SUCCESS else { + throw SDKError.download(.downloadFailed, "Failed to pause downloads") + } + + logger.info("Paused all downloads") + } + + /// Resume all downloads + public func resumeAll() throws { + guard let handle = handle else { + throw SDKError.general(.initializationFailed, "Download manager not initialized") + } + + let result = rac_download_manager_resume_all(handle) + guard result == RAC_SUCCESS else { + throw SDKError.download(.downloadFailed, "Failed to resume downloads") + } + + logger.info("Resumed all downloads") + } + + // MARK: - Progress Tracking + + /// Get progress for a task + public func getProgress(taskId: String) -> DownloadProgress? { + guard let handle = handle else { return nil } + + var cProgress = RAC_DOWNLOAD_PROGRESS_DEFAULT + let result = taskId.withCString { tid in + rac_download_manager_get_progress(handle, tid, &cProgress) + } + + guard result == RAC_SUCCESS else { return nil } + return DownloadProgress(from: cProgress) + } + + /// Get active task IDs + public func getActiveTasks() -> [String] { + guard let handle = handle else { return [] } + + var taskIdsPtr: UnsafeMutablePointer?>? + var count: Int = 0 + + let result = rac_download_manager_get_active_tasks(handle, &taskIdsPtr, &count) + guard result == RAC_SUCCESS, let taskIds = taskIdsPtr else { return [] } + defer { rac_download_task_ids_free(taskIds, count) } + + var ids: [String] = [] + for i in 0.. Bool { + guard let handle = handle else { return false } + + var healthy: rac_bool_t = RAC_FALSE + let result = rac_download_manager_is_healthy(handle, &healthy) + + return result == RAC_SUCCESS && healthy == RAC_TRUE + } + + // MARK: - Progress Updates (called by platform HTTP layer) + + /// Update download progress (called by Alamofire/HTTP layer) + public func updateProgress(taskId: String, bytesDownloaded: Int64, totalBytes: Int64) { + guard let handle = handle else { return } + + _ = taskId.withCString { tid in + rac_download_manager_update_progress(handle, tid, bytesDownloaded, totalBytes) + } + + // Notify callback + if let progress = getProgress(taskId: taskId), + let callback = progressCallbacks[taskId] { + callback(progress) + } + } + + /// Mark download as complete (called by Alamofire/HTTP layer) + public func markComplete(taskId: String, downloadedPath: URL) { + guard let handle = handle else { return } + + _ = taskId.withCString { tid in + downloadedPath.path.withCString { path in + rac_download_manager_mark_complete(handle, tid, path) + } + } + + // Notify final progress + if let progress = getProgress(taskId: taskId), + let callback = progressCallbacks[taskId] { + callback(progress) + } + + progressCallbacks.removeValue(forKey: taskId) + logger.info("Download completed: \(taskId)") + } + + /// Mark download as failed (called by Alamofire/HTTP layer) + public func markFailed(taskId: String, error: SDKError) { + guard let handle = handle else { return } + + let errorCode = RAC_ERROR_DOWNLOAD_FAILED // Map to appropriate error + let errorMessage = error.localizedDescription + + _ = taskId.withCString { tid in + errorMessage.withCString { msg in + rac_download_manager_mark_failed(handle, tid, errorCode, msg) + } + } + + // Notify final progress + if let progress = getProgress(taskId: taskId), + let callback = progressCallbacks[taskId] { + callback(progress) + } + + progressCallbacks.removeValue(forKey: taskId) + logger.error("Download failed: \(taskId) - \(errorMessage)") + } + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+Environment.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+Environment.swift new file mode 100644 index 000000000..dd3816272 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+Environment.swift @@ -0,0 +1,149 @@ +// +// CppBridge+Environment.swift +// RunAnywhere SDK +// +// Environment and configuration bridge extensions for C++ interop. +// + +import CRACommons +import Foundation + +// MARK: - Environment Bridge + +extension CppBridge { + + /// Environment configuration bridge + /// Wraps C++ rac_environment.h functions + public enum Environment { + + /// Convert Swift environment to C++ type + public static func toC(_ env: SDKEnvironment) -> rac_environment_t { + switch env { + case .development: return RAC_ENV_DEVELOPMENT + case .staging: return RAC_ENV_STAGING + case .production: return RAC_ENV_PRODUCTION + } + } + + /// Convert C++ environment to Swift type + public static func fromC(_ env: rac_environment_t) -> SDKEnvironment { + switch env { + case RAC_ENV_DEVELOPMENT: return .development + case RAC_ENV_STAGING: return .staging + case RAC_ENV_PRODUCTION: return .production + default: return .development + } + } + + /// Check if environment requires authentication + public static func requiresAuth(_ env: SDKEnvironment) -> Bool { + return rac_env_requires_auth(toC(env)) + } + + /// Check if environment requires backend URL + public static func requiresBackendURL(_ env: SDKEnvironment) -> Bool { + return rac_env_requires_backend_url(toC(env)) + } + + /// Validate API key for environment + public static func validateAPIKey(_ key: String, for env: SDKEnvironment) -> rac_validation_result_t { + return key.withCString { rac_validate_api_key($0, toC(env)) } + } + + /// Validate base URL for environment + public static func validateBaseURL(_ url: String, for env: SDKEnvironment) -> rac_validation_result_t { + return url.withCString { rac_validate_base_url($0, toC(env)) } + } + + /// Get validation error message + public static func validationErrorMessage(_ result: rac_validation_result_t) -> String { + return String(cString: rac_validation_error_message(result)) + } + } +} + +// MARK: - Development Config Bridge + +extension CppBridge { + + /// Development configuration bridge + /// Wraps C++ rac_dev_config.h functions + /// Used for development mode with Supabase backend + public enum DevConfig { + + /// Check if development config is available + public static var isAvailable: Bool { + rac_dev_config_is_available() + } + + /// Get Supabase URL for development mode + public static var supabaseURL: String? { + guard isAvailable else { return nil } + guard let ptr = rac_dev_config_get_supabase_url() else { return nil } + return String(cString: ptr) + } + + /// Get Supabase API key for development mode + public static var supabaseKey: String? { + guard isAvailable else { return nil } + guard let ptr = rac_dev_config_get_supabase_key() else { return nil } + return String(cString: ptr) + } + + /// Get build token for development mode + public static var buildToken: String? { + guard rac_dev_config_has_build_token() else { return nil } + guard let ptr = rac_dev_config_get_build_token() else { return nil } + return String(cString: ptr) + } + + /// Get Sentry DSN for crash reporting (optional) + public static var sentryDSN: String? { + guard let ptr = rac_dev_config_get_sentry_dsn() else { return nil } + return String(cString: ptr) + } + + /// Configure CppBridge.HTTP for development mode using C++ config + /// - Returns: true if configured successfully, false if config not available + @discardableResult + public static func configureHTTP() async -> Bool { + guard let urlString = supabaseURL, + let url = URL(string: urlString), + let apiKey = supabaseKey else { + return false + } + await CppBridge.HTTP.shared.configure(baseURL: url, apiKey: apiKey) + return true + } + } +} + +// MARK: - Endpoints Bridge + +extension CppBridge { + + /// API endpoint paths bridge + /// Wraps C++ rac_endpoints.h macros and functions + public enum Endpoints { + + // Static endpoint strings (from C macros) + public static let authenticate = RAC_ENDPOINT_AUTHENTICATE + public static let refresh = RAC_ENDPOINT_REFRESH + public static let health = RAC_ENDPOINT_HEALTH + + /// Get device registration endpoint for environment + public static func deviceRegistration(for env: SDKEnvironment) -> String { + return String(cString: rac_endpoint_device_registration(Environment.toC(env))) + } + + /// Get telemetry endpoint for environment + public static func telemetry(for env: SDKEnvironment) -> String { + return String(cString: rac_endpoint_telemetry(Environment.toC(env))) + } + + /// Get model assignments endpoint + public static func modelAssignments() -> String { + return String(cString: rac_endpoint_model_assignments()) + } + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+HTTP.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+HTTP.swift new file mode 100644 index 000000000..d132d662a --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+HTTP.swift @@ -0,0 +1,42 @@ +// +// CppBridge+HTTP.swift +// RunAnywhere SDK +// +// HTTP bridge extension - thin wrapper over HTTPService. +// All actual network logic is in Data/Network/Services/HTTPService.swift +// + +import CRACommons +import Foundation + +// MARK: - HTTP Bridge + +extension CppBridge { + + /// HTTP bridge - thin wrapper over HTTPService + /// This provides C++ bridge compatibility while delegating to HTTPService + public enum HTTP { + + /// Shared HTTP service instance + public static var shared: HTTPService { + HTTPService.shared + } + + /// Configure HTTP with base URL and API key + public static func configure(baseURL: URL, apiKey: String) async { + await HTTPService.shared.configure(baseURL: baseURL, apiKey: apiKey) + } + + /// Configure HTTP with base URL string and API key + public static func configure(baseURL: String, apiKey: String) async { + await HTTPService.shared.configure(baseURL: baseURL, apiKey: apiKey) + } + + /// Check if HTTP is configured + public static var isConfigured: Bool { + get async { + await HTTPService.shared.isConfigured + } + } + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+LLM.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+LLM.swift new file mode 100644 index 000000000..08ee8883c --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+LLM.swift @@ -0,0 +1,103 @@ +// +// CppBridge+LLM.swift +// RunAnywhere SDK +// +// LLM component bridge - manages C++ LLM component lifecycle +// + +import CRACommons +import Foundation + +// MARK: - LLM Component Bridge + +extension CppBridge { + + /// LLM component manager + /// Provides thread-safe access to the C++ LLM component + public actor LLM { + + /// Shared LLM component instance + public static let shared = LLM() + + private var handle: rac_handle_t? + private var loadedModelId: String? + private let logger = SDKLogger(category: "CppBridge.LLM") + + private init() {} + + // MARK: - Handle Management + + /// Get or create the LLM component handle + public func getHandle() throws -> rac_handle_t { + if let handle = handle { + return handle + } + + var newHandle: rac_handle_t? + let result = rac_llm_component_create(&newHandle) + guard result == RAC_SUCCESS, let handle = newHandle else { + throw SDKError.llm(.notInitialized, "Failed to create LLM component: \(result)") + } + + self.handle = handle + logger.debug("LLM component created") + return handle + } + + // MARK: - State + + /// Check if a model is loaded + public var isLoaded: Bool { + guard let handle = handle else { return false } + return rac_llm_component_is_loaded(handle) == RAC_TRUE + } + + /// Get the currently loaded model ID + public var currentModelId: String? { loadedModelId } + + // MARK: - Model Lifecycle + + /// Load an LLM model + public func loadModel(_ modelPath: String, modelId: String, modelName: String) throws { + let handle = try getHandle() + let result = modelPath.withCString { pathPtr in + modelId.withCString { idPtr in + modelName.withCString { namePtr in + rac_llm_component_load_model(handle, pathPtr, idPtr, namePtr) + } + } + } + guard result == RAC_SUCCESS else { + throw SDKError.llm(.modelLoadFailed, "Failed to load model: \(result)") + } + loadedModelId = modelId + logger.info("LLM model loaded: \(modelId)") + } + + /// Unload the current model + public func unload() { + guard let handle = handle else { return } + rac_llm_component_cleanup(handle) + loadedModelId = nil + logger.info("LLM model unloaded") + } + + /// Cancel ongoing generation + public func cancel() { + guard let handle = handle else { return } + rac_llm_component_cancel(handle) + } + + // MARK: - Cleanup + + /// Destroy the component + public func destroy() { + if let handle = handle { + rac_llm_component_destroy(handle) + self.handle = nil + loadedModelId = nil + logger.debug("LLM component destroyed") + } + } + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+ModelAssignment.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+ModelAssignment.swift new file mode 100644 index 000000000..f26cef93f --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+ModelAssignment.swift @@ -0,0 +1,223 @@ +// +// CppBridge+ModelAssignment.swift +// RunAnywhere SDK +// +// Bridge for C++ model assignment manager. +// All business logic (caching, JSON parsing, registry saving) is in C++. +// Swift provides HTTP GET callback and device info. +// + +import CRACommons +import Foundation + +// MARK: - CppBridge Model Assignment Extension + +public extension CppBridge { + + /// Model assignment bridge - fetches model assignments from backend + enum ModelAssignment { + + private static let logger = SDKLogger(category: "CppBridge.ModelAssignment") + private static var isRegistered = false + + // MARK: - Registration + + /// Register callbacks with C++ model assignment manager + /// Called during SDK initialization + /// - Parameter autoFetch: Whether to auto-fetch models after registration. + /// Should be false for development mode, true for staging/production. + static func register(autoFetch: Bool = false) { + guard !isRegistered else { return } + + var callbacks = rac_assignment_callbacks_t() + + // HTTP GET callback + callbacks.http_get = { endpoint, requiresAuth, outResponse, _ -> rac_result_t in + guard let endpoint = endpoint, + let outResponse = outResponse else { + return RAC_ERROR_NULL_POINTER + } + + let endpointStr = String(cString: endpoint) + + // Use semaphore to make async call synchronous for C callback + let semaphore = DispatchSemaphore(value: 0) + var result: rac_result_t = RAC_ERROR_HTTP_REQUEST_FAILED + + Task { + do { + let data: Data = try await CppBridge.HTTP.shared.getRaw( + endpointStr, + requiresAuth: requiresAuth == RAC_TRUE + ) + + // Store response body - C++ will copy it + let responseStr = String(data: data, encoding: .utf8) ?? "" + responseStr.withCString { cStr in + outResponse.pointee.response_body = UnsafePointer(strdup(cStr)) + outResponse.pointee.response_length = data.count + } + outResponse.pointee.status_code = 200 + outResponse.pointee.result = RAC_SUCCESS + outResponse.pointee.error_message = nil + result = RAC_SUCCESS + } catch { + let errorMsg = error.localizedDescription + errorMsg.withCString { cStr in + outResponse.pointee.error_message = UnsafePointer(strdup(cStr)) + } + outResponse.pointee.result = RAC_ERROR_HTTP_REQUEST_FAILED + outResponse.pointee.status_code = 0 + outResponse.pointee.response_body = nil + outResponse.pointee.response_length = 0 + result = RAC_ERROR_HTTP_REQUEST_FAILED + } + semaphore.signal() + } + + _ = semaphore.wait(timeout: .now() + 30) + return result + } + + callbacks.user_data = nil + // Only auto-fetch in staging/production, not development + callbacks.auto_fetch = autoFetch ? RAC_TRUE : RAC_FALSE + + let result = rac_model_assignment_set_callbacks(&callbacks) + if result == RAC_SUCCESS { + isRegistered = true + logger.debug("Model assignment callbacks registered (autoFetch: \(autoFetch))") + } else { + logger.error("Failed to register model assignment callbacks: \(result)") + } + } + + // MARK: - Public API + + /// Fetch model assignments from backend + /// - Parameter forceRefresh: Force refresh even if cached + /// - Returns: Array of ModelInfo + public static func fetch(forceRefresh: Bool = false) async throws -> [ModelInfo] { + var outModels: UnsafeMutablePointer?>? + var outCount: Int = 0 + + let result = rac_model_assignment_fetch( + forceRefresh ? RAC_TRUE : RAC_FALSE, + &outModels, + &outCount + ) + + guard result == RAC_SUCCESS else { + throw SDKError.network(.httpError, "Failed to fetch model assignments: \(result)") + } + + defer { + if let models = outModels { + rac_model_info_array_free(models, outCount) + } + } + + var modelInfos: [ModelInfo] = [] + if let models = outModels { + for i in 0.. [ModelInfo] { + var outModels: UnsafeMutablePointer?>? + var outCount: Int = 0 + + let result = rac_model_assignment_get_by_framework( + framework.toCFramework(), + &outModels, + &outCount + ) + + guard result == RAC_SUCCESS else { + logger.warning("Failed to get models by framework: \(result)") + return [] + } + + defer { + if let models = outModels { + rac_model_info_array_free(models, outCount) + } + } + + var modelInfos: [ModelInfo] = [] + if let models = outModels { + for i in 0.. [ModelInfo] { + var outModels: UnsafeMutablePointer?>? + var outCount: Int = 0 + + let cCategory = categoryToCType(category) + let result = rac_model_assignment_get_by_category( + cCategory, + &outModels, + &outCount + ) + + guard result == RAC_SUCCESS else { + logger.warning("Failed to get models by category: \(result)") + return [] + } + + defer { + if let models = outModels { + rac_model_info_array_free(models, outCount) + } + } + + var modelInfos: [ModelInfo] = [] + if let models = outModels { + for i in 0.. rac_model_category_t { + switch category { + case .language: + return RAC_MODEL_CATEGORY_LANGUAGE + case .speechRecognition: + return RAC_MODEL_CATEGORY_SPEECH_RECOGNITION + case .speechSynthesis: + return RAC_MODEL_CATEGORY_SPEECH_SYNTHESIS + case .vision: + return RAC_MODEL_CATEGORY_VISION + case .imageGeneration: + return RAC_MODEL_CATEGORY_IMAGE_GENERATION + case .multimodal: + return RAC_MODEL_CATEGORY_MULTIMODAL + case .audio: + return RAC_MODEL_CATEGORY_AUDIO + } + } + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+ModelPaths.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+ModelPaths.swift new file mode 100644 index 000000000..5fc82af09 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+ModelPaths.swift @@ -0,0 +1,181 @@ +// +// CppBridge+ModelPaths.swift +// RunAnywhere SDK +// +// Model path utilities bridge extension for C++ interop. +// + +import CRACommons +import Foundation + +// MARK: - ModelPaths Bridge + +extension CppBridge { + + /// Model path utilities bridge + /// Wraps C++ rac_model_paths.h functions + public enum ModelPaths { + + private static let logger = SDKLogger(category: "CppBridge.ModelPaths") + private static let pathBufferSize = 1024 + + // MARK: - Configuration + + /// Set the base directory for model storage + /// Must be called during SDK initialization + public static func setBaseDirectory(_ baseDir: URL) throws { + let result = baseDir.path.withCString { path in + rac_model_paths_set_base_dir(path) + } + + guard result == RAC_SUCCESS else { + throw SDKError.general(.initializationFailed, "Failed to set base directory") + } + + logger.debug("Base directory set to: \(baseDir.path)") + } + + /// Get the configured base directory + public static var baseDirectory: URL? { + guard let ptr = rac_model_paths_get_base_dir() else { return nil } + return URL(fileURLWithPath: String(cString: ptr)) + } + + // MARK: - Directory Paths + + /// Get the models directory + /// Returns: `{base_dir}/RunAnywhere/Models/` + public static func getModelsDirectory() throws -> URL { + var buffer = [CChar](repeating: 0, count: pathBufferSize) + let result = rac_model_paths_get_models_directory(&buffer, buffer.count) + + guard result == RAC_SUCCESS else { + throw SDKError.general(.initializationFailed, "Base directory not configured") + } + + return URL(fileURLWithPath: String(cString: buffer)) + } + + /// Get the framework directory + /// Returns: `{base_dir}/RunAnywhere/Models/{framework}/` + public static func getFrameworkDirectory(framework: InferenceFramework) throws -> URL { + var buffer = [CChar](repeating: 0, count: pathBufferSize) + let result = rac_model_paths_get_framework_directory(framework.toCFramework(), &buffer, buffer.count) + + guard result == RAC_SUCCESS else { + throw SDKError.general(.initializationFailed, "Base directory not configured") + } + + return URL(fileURLWithPath: String(cString: buffer)) + } + + /// Get the model folder + /// Returns: `{base_dir}/RunAnywhere/Models/{framework}/{modelId}/` + public static func getModelFolder(modelId: String, framework: InferenceFramework) throws -> URL { + var buffer = [CChar](repeating: 0, count: pathBufferSize) + let result = modelId.withCString { mid in + rac_model_paths_get_model_folder(mid, framework.toCFramework(), &buffer, buffer.count) + } + + guard result == RAC_SUCCESS else { + throw SDKError.general(.initializationFailed, "Base directory not configured") + } + + return URL(fileURLWithPath: String(cString: buffer)) + } + + /// Get the expected model path (folder for directory-based, file for single-file) + public static func getExpectedModelPath( + modelId: String, + framework: InferenceFramework, + format: ModelFormat + ) throws -> URL { + var buffer = [CChar](repeating: 0, count: pathBufferSize) + let result = modelId.withCString { mid in + rac_model_paths_get_expected_model_path( + mid, + framework.toCFramework(), + format.toC(), + &buffer, + buffer.count + ) + } + + guard result == RAC_SUCCESS else { + throw SDKError.general(.initializationFailed, "Base directory not configured") + } + + return URL(fileURLWithPath: String(cString: buffer)) + } + + /// Get the cache directory + public static func getCacheDirectory() throws -> URL { + var buffer = [CChar](repeating: 0, count: pathBufferSize) + let result = rac_model_paths_get_cache_directory(&buffer, buffer.count) + + guard result == RAC_SUCCESS else { + throw SDKError.general(.initializationFailed, "Base directory not configured") + } + + return URL(fileURLWithPath: String(cString: buffer)) + } + + /// Get the downloads directory + public static func getDownloadsDirectory() throws -> URL { + var buffer = [CChar](repeating: 0, count: pathBufferSize) + let result = rac_model_paths_get_downloads_directory(&buffer, buffer.count) + + guard result == RAC_SUCCESS else { + throw SDKError.general(.initializationFailed, "Base directory not configured") + } + + return URL(fileURLWithPath: String(cString: buffer)) + } + + /// Get the temp directory + public static func getTempDirectory() throws -> URL { + var buffer = [CChar](repeating: 0, count: pathBufferSize) + let result = rac_model_paths_get_temp_directory(&buffer, buffer.count) + + guard result == RAC_SUCCESS else { + throw SDKError.general(.initializationFailed, "Base directory not configured") + } + + return URL(fileURLWithPath: String(cString: buffer)) + } + + // MARK: - Path Analysis + + /// Extract model ID from a file path + public static func extractModelId(from path: URL) -> String? { + var buffer = [CChar](repeating: 0, count: 256) + let result = path.path.withCString { pathPtr in + rac_model_paths_extract_model_id(pathPtr, &buffer, buffer.count) + } + + guard result == RAC_SUCCESS else { return nil } + return String(cString: buffer) + } + + /// Extract framework from a file path + public static func extractFramework(from path: URL) -> InferenceFramework? { + var framework: rac_inference_framework_t = RAC_FRAMEWORK_UNKNOWN + let result = path.path.withCString { pathPtr in + rac_model_paths_extract_framework(pathPtr, &framework) + } + + guard result == RAC_SUCCESS else { return nil } + return InferenceFramework(from: framework) + } + + /// Check if a path is within the models directory + public static func isModelPath(_ path: URL) -> Bool { + return path.path.withCString { pathPtr in + rac_model_paths_is_model_path(pathPtr) == RAC_TRUE + } + } + } +} + +// Note: InferenceFramework.toCFramework() is defined in InferenceFramework.swift +// Note: ModelFormat.toC() is defined in ModelTypes+CppBridge.swift diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+ModelRegistry.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+ModelRegistry.swift new file mode 100644 index 000000000..7681d3dd4 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+ModelRegistry.swift @@ -0,0 +1,345 @@ +// +// CppBridge+ModelRegistry.swift +// RunAnywhere SDK +// +// Model registry bridge extension for C++ interop. +// + +import CRACommons +import Foundation + +// MARK: - Model Discovery Result + +/// Result from model discovery scan +public struct ModelDiscoveryResult { + public let discoveredCount: Int + public let unregisteredCount: Int +} + +// MARK: - ModelRegistry Bridge + +extension CppBridge { + + /// Model registry bridge + /// Wraps C++ rac_model_registry.h functions for in-memory model storage + public actor ModelRegistry { + + /// Shared registry instance + public static let shared = ModelRegistry() + + /// Internal handle - accessed by CppBridge.Storage for registry operations + internal var handle: rac_model_registry_handle_t? + private let logger = SDKLogger(category: "CppBridge.ModelRegistry") + + private init() { + // Use the global C++ model registry so that models registered + // by C++ backends (like Platform) are visible to Swift + let globalRegistry = rac_get_model_registry() + if globalRegistry != nil { + self.handle = globalRegistry + logger.debug("Using global C++ model registry") + } else { + logger.error("Failed to get global model registry") + } + } + + deinit { + // Don't destroy the global registry - it's managed by C++ + // The handle is just a reference to the singleton + } + + // MARK: - Save/Get Operations + + /// Save model metadata to registry + public func save(_ model: ModelInfo) throws { + guard let handle = handle else { + throw SDKError.general(.initializationFailed, "Registry not initialized") + } + + var cModel = model.toCModelInfo() + defer { freeCModelInfo(&cModel) } + + let result = rac_model_registry_save(handle, &cModel) + guard result == RAC_SUCCESS else { + throw SDKError.general(.processingFailed, "Failed to save model") + } + + logger.debug("Model saved: \(model.id)") + } + + /// Get model metadata by ID + public func get(modelId: String) -> ModelInfo? { + guard let handle = handle else { return nil } + + var modelPtr: UnsafeMutablePointer? + let result = modelId.withCString { mid in + rac_model_registry_get(handle, mid, &modelPtr) + } + + guard result == RAC_SUCCESS, let model = modelPtr else { return nil } + defer { rac_model_info_free(model) } + + return ModelInfo(from: model.pointee) + } + + /// Get all stored models + public func getAll() -> [ModelInfo] { + guard let handle = handle else { return [] } + + var modelsPtr: UnsafeMutablePointer?>? + var count: Int = 0 + + let result = rac_model_registry_get_all(handle, &modelsPtr, &count) + guard result == RAC_SUCCESS, let models = modelsPtr else { return [] } + defer { rac_model_info_array_free(models, count) } + + var modelInfos: [ModelInfo] = [] + for i in 0.. [ModelInfo] { + guard let handle = handle else { return [] } + + var modelsPtr: UnsafeMutablePointer?>? + var count: Int = 0 + + let result = rac_model_registry_get_downloaded(handle, &modelsPtr, &count) + guard result == RAC_SUCCESS, let models = modelsPtr else { return [] } + defer { rac_model_info_array_free(models, count) } + + var modelInfos: [ModelInfo] = [] + for i in 0.. [ModelInfo] { + guard let handle = handle, !frameworks.isEmpty else { return [] } + + var cFrameworks = frameworks.map { $0.toCFramework() } + var modelsPtr: UnsafeMutablePointer?>? + var count: Int = 0 + + let result = rac_model_registry_get_by_frameworks( + handle, + &cFrameworks, + frameworks.count, + &modelsPtr, + &count + ) + + guard result == RAC_SUCCESS, let models = modelsPtr else { return [] } + defer { rac_model_info_array_free(models, count) } + + var modelInfos: [ModelInfo] = [] + for i in 0.. ModelDiscoveryResult { + guard let handle = handle else { + logger.warning("Discovery: Registry not initialized") + return ModelDiscoveryResult(discoveredCount: 0, unregisteredCount: 0) + } + + logger.info("Starting model discovery scan...") + + // Create callbacks struct + var callbacks = rac_discovery_callbacks_t() + callbacks.user_data = nil + + // List directory callback + callbacks.list_directory = { path, outEntries, outCount, _ -> rac_result_t in + guard let path = path else { return RAC_ERROR_INVALID_ARGUMENT } + + let url = URL(fileURLWithPath: String(cString: path)) + let fm = FileManager.default + + guard let contents = try? fm.contentsOfDirectory(at: url, includingPropertiesForKeys: nil) else { + outEntries?.pointee = nil + outCount?.pointee = 0 + return RAC_SUCCESS + } + + let count = contents.count + outCount?.pointee = count + + if contents.isEmpty { + outEntries?.pointee = nil + return RAC_SUCCESS + } + + // Allocate array of strings + let entries = UnsafeMutablePointer?>.allocate(capacity: count) + for (i, item) in contents.enumerated() { + let name = item.lastPathComponent + entries[i] = strdup(name) + } + + outEntries?.pointee = entries + return RAC_SUCCESS + } + + // Free entries callback + callbacks.free_entries = { entries, count, _ in + guard let entries = entries else { return } + for i in 0.. rac_bool_t in + guard let path = path else { return RAC_FALSE } + let pathStr = String(cString: path) + var isDir: ObjCBool = false + if FileManager.default.fileExists(atPath: pathStr, isDirectory: &isDir) { + return isDir.boolValue ? RAC_TRUE : RAC_FALSE + } + return RAC_FALSE + } + + // Path exists callback + callbacks.path_exists = { path, _ -> rac_bool_t in + guard let path = path else { return RAC_FALSE } + let pathStr = String(cString: path) + return FileManager.default.fileExists(atPath: pathStr) ? RAC_TRUE : RAC_FALSE + } + + // Is model file callback - checks for known model extensions + callbacks.is_model_file = { path, framework, _ -> rac_bool_t in + guard let path = path else { return RAC_FALSE } + let pathStr = String(cString: path) + let ext = (pathStr as NSString).pathExtension.lowercased() + + // Check based on framework + switch framework { + case RAC_FRAMEWORK_LLAMACPP: + return (ext == "gguf" || ext == "bin") ? RAC_TRUE : RAC_FALSE + case RAC_FRAMEWORK_ONNX: + return (ext == "onnx" || ext == "ort") ? RAC_TRUE : RAC_FALSE + case RAC_FRAMEWORK_FOUNDATION_MODELS, RAC_FRAMEWORK_SYSTEM_TTS: + // Built-in models don't need file check + return RAC_TRUE + default: + return (ext == "gguf" || ext == "onnx" || ext == "bin" || ext == "ort") ? RAC_TRUE : RAC_FALSE + } + } + + // Call C++ discovery + var result = rac_discovery_result_t() + let status = rac_model_registry_discover_downloaded(handle, &callbacks, &result) + defer { rac_discovery_result_free(&result) } + + if status != RAC_SUCCESS { + logger.warning("Discovery failed with status: \(status)") + return ModelDiscoveryResult(discoveredCount: 0, unregisteredCount: 0) + } + + logger.info("Discovery complete: \(result.discovered_count) models found, \(result.unregistered_count) unregistered folders") + return ModelDiscoveryResult( + discoveredCount: Int(result.discovered_count), + unregisteredCount: Int(result.unregistered_count) + ) + } + + // MARK: - Helper: Convert ModelInfo to C struct + + private func freeCModelInfo(_ model: inout rac_model_info_t) { + // Free allocated strings + if let id = model.id { free(UnsafeMutablePointer(mutating: id)) } + if let name = model.name { free(UnsafeMutablePointer(mutating: name)) } + if let url = model.download_url { free(UnsafeMutablePointer(mutating: url)) } + if let path = model.local_path { free(UnsafeMutablePointer(mutating: path)) } + if let desc = model.description { free(UnsafeMutablePointer(mutating: desc)) } + } + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+Platform.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+Platform.swift new file mode 100644 index 000000000..118ed8d31 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+Platform.swift @@ -0,0 +1,318 @@ +// +// CppBridge+Platform.swift +// RunAnywhere SDK +// +// Bridge extension for Platform backend (Apple Foundation Models + System TTS). +// This file registers Swift callbacks with the C++ platform backend. +// + +import CRACommons +import Foundation + +// MARK: - Platform Bridge + +extension CppBridge { + + /// Bridge for platform-native services (Foundation Models, System TTS) + /// + /// This bridge connects the C++ platform backend to Swift implementations. + /// The C++ side handles registration with the service registry, while Swift + /// provides the actual implementation through callbacks. + public enum Platform { + + private static let logger = SDKLogger(category: "CppBridge.Platform") + private static var isInitialized = false + + // MARK: - Service Instances + + // Cached Foundation Models service (type-erased for iOS 26+ availability) + // swiftlint:disable:next avoid_any_type + private static var foundationModelsService: Any? + + /// Cached System TTS service instance + private static var systemTTSService: SystemTTSService? + + // MARK: - Initialization + + /// Register the platform backend with C++. + /// This must be called during SDK initialization. + @MainActor + public static func register() { + guard !isInitialized else { + logger.debug("Platform backend already registered") + return + } + + logger.info("Registering platform backend...") + + // Register Swift callbacks for LLM (Foundation Models) + registerLLMCallbacks() + + // Register Swift callbacks for TTS (System TTS) + registerTTSCallbacks() + + // Register the backend module and service providers + let result = rac_backend_platform_register() + if result == RAC_SUCCESS || result == RAC_ERROR_MODULE_ALREADY_REGISTERED { + isInitialized = true + logger.info("✅ Platform backend registered successfully") + } else { + logger.error("❌ Failed to register platform backend: \(result)") + } + } + + /// Unregister the platform backend. + public static func unregister() { + guard isInitialized else { return } + + _ = rac_backend_platform_unregister() + foundationModelsService = nil + systemTTSService = nil + isInitialized = false + logger.info("Platform backend unregistered") + } + + // MARK: - LLM Callbacks (Foundation Models) + + // swiftlint:disable:next function_body_length + private static func registerLLMCallbacks() { + var callbacks = rac_platform_llm_callbacks_t() + + callbacks.can_handle = { modelIdPtr, _ -> rac_bool_t in + let modelId = modelIdPtr.map { String(cString: $0) } + + // Check if Foundation Models can handle this model + guard #available(iOS 26.0, macOS 26.0, *) else { + return RAC_FALSE + } + + guard let modelId = modelId, !modelId.isEmpty else { + return RAC_FALSE + } + + let lowercased = modelId.lowercased() + if lowercased.contains("foundation-models") || + lowercased.contains("foundation") || + lowercased.contains("apple-intelligence") || + lowercased == "system-llm" { + return RAC_TRUE + } + + return RAC_FALSE + } + + callbacks.create = { _, _, _ -> rac_handle_t? in + // Create Foundation Models service + guard #available(iOS 26.0, macOS 26.0, *) else { + return nil + } + + // Use a dispatch group to synchronously wait for async creation + var serviceHandle: rac_handle_t? + let group = DispatchGroup() + group.enter() + + Task { + do { + let service = SystemFoundationModelsService() + try await service.initialize(modelPath: "built-in") + Platform.foundationModelsService = service + + // Return a marker handle - actual service is managed by Swift + serviceHandle = UnsafeMutableRawPointer(bitPattern: 0xF00DADE1) + Platform.logger.info("Foundation Models service created") + } catch { + Platform.logger.error("Failed to create Foundation Models service: \(error)") + serviceHandle = nil + } + group.leave() + } + + group.wait() + return serviceHandle + } + + callbacks.generate = { _, promptPtr, _, outResponsePtr, _ -> rac_result_t in + guard let promptPtr = promptPtr, + let outResponsePtr = outResponsePtr else { + return RAC_ERROR_INVALID_PARAMETER + } + + guard #available(iOS 26.0, macOS 26.0, *) else { + return RAC_ERROR_NOT_SUPPORTED + } + + guard let service = Platform.foundationModelsService as? SystemFoundationModelsService else { + return RAC_ERROR_NOT_INITIALIZED + } + + let prompt = String(cString: promptPtr) + + var result: rac_result_t = RAC_ERROR_INTERNAL + let group = DispatchGroup() + group.enter() + + Task { + do { + let response = try await service.generate( + prompt: prompt, + options: LLMGenerationOptions() + ) + outResponsePtr.pointee = strdup(response) + result = RAC_SUCCESS + } catch { + Platform.logger.error("Foundation Models generate failed: \(error)") + result = RAC_ERROR_INTERNAL + } + group.leave() + } + + group.wait() + return result + } + + callbacks.destroy = { _, _ in + Platform.foundationModelsService = nil + Platform.logger.debug("Foundation Models service destroyed") + } + + callbacks.user_data = nil + + let result = rac_platform_llm_set_callbacks(&callbacks) + if result == RAC_SUCCESS { + logger.debug("LLM callbacks registered") + } else { + logger.error("Failed to register LLM callbacks: \(result)") + } + } + + // MARK: - TTS Callbacks (System TTS) + + // swiftlint:disable:next function_body_length + private static func registerTTSCallbacks() { + var callbacks = rac_platform_tts_callbacks_t() + + callbacks.can_handle = { voiceIdPtr, _ -> rac_bool_t in + guard let voiceIdPtr = voiceIdPtr else { + // System TTS can be a fallback for nil + return RAC_TRUE + } + + let voiceId = String(cString: voiceIdPtr).lowercased() + + if voiceId.contains("system-tts") || + voiceId.contains("system_tts") || + voiceId == "system" { + return RAC_TRUE + } + + return RAC_FALSE + } + + callbacks.create = { _, _ -> rac_handle_t? in + var serviceHandle: rac_handle_t? + + // Use DispatchQueue.main.sync to create the MainActor-isolated service + // This ensures proper thread safety for AVSpeechSynthesizer + DispatchQueue.main.sync { + let service = SystemTTSService() + Platform.systemTTSService = service + + // Return a marker handle + serviceHandle = UnsafeMutableRawPointer(bitPattern: 0x5157E775) + Platform.logger.info("System TTS service created") + } + + return serviceHandle + } + + callbacks.synthesize = { _, textPtr, optionsPtr, _ -> rac_result_t in + guard let textPtr = textPtr else { + return RAC_ERROR_INVALID_PARAMETER + } + + guard let service = Platform.systemTTSService else { + return RAC_ERROR_NOT_INITIALIZED + } + + let text = String(cString: textPtr) + + // Build TTS options from C struct + var rate: Float = 1.0 + var pitch: Float = 1.0 + var volume: Float = 1.0 + var voice: String? + + if let optionsPtr = optionsPtr { + rate = optionsPtr.pointee.rate + pitch = optionsPtr.pointee.pitch + volume = optionsPtr.pointee.volume + if let voicePtr = optionsPtr.pointee.voice_id { + voice = String(cString: voicePtr) + } + } + + let options = TTSOptions( + voice: voice, + rate: rate, + pitch: pitch, + volume: volume + ) + + var result: rac_result_t = RAC_ERROR_INTERNAL + let group = DispatchGroup() + group.enter() + + Task { + do { + _ = try await service.synthesize(text: text, options: options) + result = RAC_SUCCESS + } catch { + Platform.logger.error("System TTS synthesize failed: \(error)") + result = RAC_ERROR_INTERNAL + } + group.leave() + } + + group.wait() + return result + } + + callbacks.stop = { _, _ in + DispatchQueue.main.async { + Platform.systemTTSService?.stop() + } + } + + callbacks.destroy = { _, _ in + DispatchQueue.main.async { + Platform.systemTTSService?.stop() + Platform.systemTTSService = nil + Platform.logger.debug("System TTS service destroyed") + } + } + + callbacks.user_data = nil + + let result = rac_platform_tts_set_callbacks(&callbacks) + if result == RAC_SUCCESS { + logger.debug("TTS callbacks registered") + } else { + logger.error("Failed to register TTS callbacks: \(result)") + } + } + + // MARK: - Service Access + + /// Get the cached Foundation Models service (if created) + @available(iOS 26.0, macOS 26.0, *) + public static func getFoundationModelsService() -> SystemFoundationModelsService? { + return foundationModelsService as? SystemFoundationModelsService + } + + /// Get the cached System TTS service (if created) + public static func getSystemTTSService() -> SystemTTSService? { + return systemTTSService + } + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+PlatformAdapter.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+PlatformAdapter.swift new file mode 100644 index 000000000..f62dc7eae --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+PlatformAdapter.swift @@ -0,0 +1,443 @@ +// +// CppBridge+PlatformAdapter.swift +// RunAnywhere SDK +// +// Platform adapter bridge for fundamental C++ → Swift operations. +// Provides: logging, file operations, secure storage, clock. +// + +import CRACommons +import Foundation +import Security + +// MARK: - Platform Adapter Bridge + +extension CppBridge { + + /// Platform adapter - provides fundamental OS operations for C++ + /// + /// C++ code cannot directly: + /// - Write to disk + /// - Access Keychain + /// - Get current time + /// - Route logs to native logging system + /// + /// This bridge provides those capabilities via C function callbacks. + public enum PlatformAdapter { + + /// Whether the adapter has been registered + private static var isRegistered = false + + /// The adapter struct - MUST persist for C++ to call + private static var adapter = rac_platform_adapter_t() + + // MARK: - Registration + + /// Register platform adapter with C++ + /// Must be called FIRST during SDK init (before any C++ operations) + static func register() { + guard !isRegistered else { return } + + // Reset adapter + adapter = rac_platform_adapter_t() + + // MARK: Logging Callback + adapter.log = platformLogCallback + + // MARK: File Operations + adapter.file_exists = platformFileExistsCallback + adapter.file_read = platformFileReadCallback + adapter.file_write = platformFileWriteCallback + adapter.file_delete = platformFileDeleteCallback + + // MARK: Secure Storage (Keychain) + adapter.secure_get = platformSecureGetCallback + adapter.secure_set = platformSecureSetCallback + adapter.secure_delete = platformSecureDeleteCallback + + // MARK: Clock + adapter.now_ms = platformNowMsCallback + + // MARK: Memory Info (not implemented) + adapter.get_memory_info = { _, _ -> rac_result_t in + RAC_ERROR_NOT_SUPPORTED + } + + // MARK: Error Tracking (Sentry) + adapter.track_error = platformTrackErrorCallback + + // MARK: Optional Callbacks (handled by Swift directly) + adapter.http_download = nil + adapter.http_download_cancel = nil + adapter.extract_archive = nil + adapter.user_data = nil + + // Register with C++ + rac_set_platform_adapter(&adapter) + isRegistered = true + + // Force link device manager symbols + _ = rac_device_manager_is_registered() + + SDKLogger(category: "CppBridge.PlatformAdapter").debug("Platform adapter registered") + } + } +} + +// MARK: - C Function Pointer Callbacks (must be at file scope, no captures) + +private let platformKeychainService = "com.runanywhere.sdk" + +private func platformLogCallback( + level: rac_log_level_t, + category: UnsafePointer?, + message: UnsafePointer?, + userData _: UnsafeMutableRawPointer? +) { + guard let message = message else { return } + let msgString = String(cString: message) + let categoryString = category.map { String(cString: $0) } ?? "RAC" + + // Parse structured metadata from C++ log messages + let (cleanMessage, metadata) = parseLogMetadata(msgString) + + let logger = SDKLogger(category: categoryString) + switch level { + case RAC_LOG_ERROR: + logger.error(cleanMessage, metadata: metadata) + case RAC_LOG_WARNING: + logger.warning(cleanMessage, metadata: metadata) + case RAC_LOG_INFO: + logger.info(cleanMessage, metadata: metadata) + case RAC_LOG_DEBUG: + logger.debug(cleanMessage, metadata: metadata) + case RAC_LOG_TRACE: + logger.debug("[TRACE] \(cleanMessage)", metadata: metadata) + default: + logger.info(cleanMessage, metadata: metadata) + } +} + +// Parse structured metadata from C++ log messages. +// Format: "Message text | key1=value1, key2=value2" +// swiftlint:disable:next avoid_any_type +private func parseLogMetadata(_ message: String) -> (String, [String: Any]?) { + let parts = message.components(separatedBy: " | ") + guard parts.count >= 2 else { + return (message, nil) + } + + let cleanMessage = parts[0] + let metadataString = parts.dropFirst().joined(separator: " | ") + + // swiftlint:disable:next avoid_any_type prefer_concrete_types + var metadata: [String: Any] = [:] + let pairs = metadataString.components(separatedBy: CharacterSet(charactersIn: ", ")) + .filter { !$0.isEmpty } + + for pair in pairs { + let keyValue = pair.components(separatedBy: "=") + guard keyValue.count == 2 else { continue } + + let key = keyValue[0].trimmingCharacters(in: .whitespaces) + let value = keyValue[1].trimmingCharacters(in: .whitespaces) + + switch key { + case "file": + metadata["source_file"] = value + case "func": + metadata["source_function"] = value + case "error_code": + metadata["error_code"] = Int(value) ?? value + case "error": + metadata["error_message"] = value + case "model": + metadata["model_id"] = value + case "framework": + metadata["framework"] = value + default: + metadata[key] = value + } + } + + return (cleanMessage, metadata.isEmpty ? nil : metadata) +} + +private func platformFileExistsCallback( + path: UnsafePointer?, + userData _: UnsafeMutableRawPointer? +) -> rac_bool_t { + guard let path = path else { + return RAC_FALSE + } + let pathString = String(cString: path) + return FileManager.default.fileExists(atPath: pathString) ? RAC_TRUE : RAC_FALSE +} + +private func platformFileReadCallback( + path: UnsafePointer?, + outData: UnsafeMutablePointer?, + outSize: UnsafeMutablePointer?, + userData _: UnsafeMutableRawPointer? +) -> rac_result_t { + guard let path = path, let outData = outData, let outSize = outSize else { + return RAC_ERROR_NULL_POINTER + } + + let pathString = String(cString: path) + + do { + let data = try Data(contentsOf: URL(fileURLWithPath: pathString)) + + // Allocate buffer and copy data + let buffer = UnsafeMutablePointer.allocate(capacity: data.count) + data.copyBytes(to: buffer, count: data.count) + + outData.pointee = UnsafeMutableRawPointer(buffer) + outSize.pointee = data.count + + return RAC_SUCCESS + } catch { + return RAC_ERROR_FILE_NOT_FOUND + } +} + +private func platformFileWriteCallback( + path: UnsafePointer?, + data: UnsafeRawPointer?, + size: Int, + userData _: UnsafeMutableRawPointer? +) -> rac_result_t { + guard let path = path, let data = data else { + return RAC_ERROR_NULL_POINTER + } + + let pathString = String(cString: path) + let fileData = Data(bytes: data, count: size) + + do { + try fileData.write(to: URL(fileURLWithPath: pathString)) + return RAC_SUCCESS + } catch { + return RAC_ERROR_FILE_WRITE_FAILED + } +} + +private func platformFileDeleteCallback( + path: UnsafePointer?, + userData _: UnsafeMutableRawPointer? +) -> rac_result_t { + guard let path = path else { + return RAC_ERROR_NULL_POINTER + } + + let pathString = String(cString: path) + + do { + try FileManager.default.removeItem(atPath: pathString) + return RAC_SUCCESS + } catch { + return RAC_ERROR_FILE_NOT_FOUND + } +} + +private func platformSecureGetCallback( + key: UnsafePointer?, + outValue: UnsafeMutablePointer?>?, + userData _: UnsafeMutableRawPointer? +) -> rac_result_t { + guard let key = key, let outValue = outValue else { + return RAC_ERROR_NULL_POINTER + } + + let keyString = String(cString: key) + + // Keychain API requires dictionary with heterogeneous values + // swiftlint:disable:next avoid_any_type prefer_concrete_types + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: platformKeychainService, + kSecAttrAccount as String: keyString, + kSecReturnData as String: true + ] + + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status == errSecSuccess, let data = result as? Data else { + return RAC_ERROR_SECURE_STORAGE_FAILED + } + + if let stringValue = String(data: data, encoding: .utf8) { + let cString = strdup(stringValue) + outValue.pointee = cString + return RAC_SUCCESS + } + + return RAC_ERROR_SECURE_STORAGE_FAILED +} + +private func platformSecureSetCallback( + key: UnsafePointer?, + value: UnsafePointer?, + userData _: UnsafeMutableRawPointer? +) -> rac_result_t { + guard let key = key, let value = value else { + return RAC_ERROR_NULL_POINTER + } + + let keyString = String(cString: key) + let valueString = String(cString: value) + guard let data = valueString.data(using: .utf8) else { + return RAC_ERROR_SECURE_STORAGE_FAILED + } + + // Delete existing item first + // swiftlint:disable:next avoid_any_type prefer_concrete_types + let deleteQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: platformKeychainService, + kSecAttrAccount as String: keyString + ] + SecItemDelete(deleteQuery as CFDictionary) + + // Add new item + // swiftlint:disable:next avoid_any_type prefer_concrete_types + let addQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: platformKeychainService, + kSecAttrAccount as String: keyString, + kSecValueData as String: data + ] + + let status = SecItemAdd(addQuery as CFDictionary, nil) + return status == errSecSuccess ? RAC_SUCCESS : RAC_ERROR_SECURE_STORAGE_FAILED +} + +private func platformSecureDeleteCallback( + key: UnsafePointer?, + userData _: UnsafeMutableRawPointer? +) -> rac_result_t { + guard let key = key else { + return RAC_ERROR_NULL_POINTER + } + + let keyString = String(cString: key) + + // Keychain API requires dictionary with heterogeneous values + // swiftlint:disable:next avoid_any_type prefer_concrete_types + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: platformKeychainService, + kSecAttrAccount as String: keyString + ] + + let status = SecItemDelete(query as CFDictionary) + return status == errSecSuccess || status == errSecItemNotFound + ? RAC_SUCCESS : RAC_ERROR_SECURE_STORAGE_FAILED +} + +private func platformNowMsCallback( + userData _: UnsafeMutableRawPointer? +) -> Int64 { + Int64(Date().timeIntervalSince1970 * 1000) +} + +// MARK: - Error Tracking Callback + +/// Receives structured error JSON from C++ and sends to Sentry +private func platformTrackErrorCallback( + errorJson: UnsafePointer?, + userData _: UnsafeMutableRawPointer? +) { + guard let errorJson = errorJson else { return } + + let jsonString = String(cString: errorJson) + + // Parse the JSON and create a Sentry event + guard let jsonData = jsonString.data(using: .utf8), + let errorDict = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any] else { // swiftlint:disable:this avoid_any_type + return + } + + // Convert C++ structured error to Swift SDKError for consistent handling + let sdkError = createSDKErrorFromCppError(errorDict) + + // Log through the standard logging system (which routes to Sentry) + let category = errorDict["category"] as? String ?? "general" + let message = errorDict["message"] as? String ?? "Unknown error" + + // Build metadata from C++ error + // swiftlint:disable:next prefer_concrete_types avoid_any_type + var metadata: [String: Any] = [:] + + if let code = errorDict["code"] as? Int { + metadata["error_code"] = code + } + if let codeName = errorDict["code_name"] as? String { + metadata["error_code_name"] = codeName + } + if let sourceFile = errorDict["source_file"] as? String { + metadata["source_file"] = sourceFile + } + if let sourceLine = errorDict["source_line"] as? Int { + metadata["source_line"] = sourceLine + } + if let sourceFunction = errorDict["source_function"] as? String { + metadata["source_function"] = sourceFunction + } + if let modelId = errorDict["model_id"] as? String { + metadata["model_id"] = modelId + } + if let framework = errorDict["framework"] as? String { + metadata["framework"] = framework + } + if let sessionId = errorDict["session_id"] as? String { + metadata["session_id"] = sessionId + } + if let underlyingCode = errorDict["underlying_code"] as? Int, underlyingCode != 0 { + metadata["underlying_code"] = underlyingCode + metadata["underlying_message"] = errorDict["underlying_message"] as? String ?? "" + } + if let stackFrameCount = errorDict["stack_frame_count"] as? Int { + metadata["stack_frame_count"] = stackFrameCount + } + + // Route through logging system which handles Sentry + Logging.shared.log(level: .error, category: category, message: message, metadata: metadata) + + // Also directly capture in Sentry if available for better error grouping + if SentryManager.shared.isInitialized { + SentryManager.shared.captureError(sdkError, context: metadata) + } +} + +// Creates an SDKError from C++ error dictionary for consistent error handling +// swiftlint:disable:next avoid_any_type prefer_concrete_types +private func createSDKErrorFromCppError(_ errorDict: [String: Any]) -> SDKError { + let code = errorDict["code"] as? Int32 ?? Int32(RAC_ERROR_UNKNOWN) + let message = errorDict["message"] as? String ?? "Unknown error" + let categoryName = errorDict["category"] as? String ?? "general" + + // Map category name to ErrorCategory + let category = ErrorCategory(rawValue: categoryName) ?? .general + + // Map C++ error code to Swift ErrorCode + let errorCode = CommonsErrorMapping.toSDKError(code)?.code ?? .unknown + + // Build stack trace from C++ if available + var stackTrace: [String] = [] + if let sourceFile = errorDict["source_file"] as? String, + let sourceLine = errorDict["source_line"] as? Int, + let sourceFunction = errorDict["source_function"] as? String { + stackTrace.append("\(sourceFunction) at \(sourceFile):\(sourceLine)") + } + + return SDKError( + code: errorCode, + message: message, + category: category, + stackTrace: stackTrace, + underlyingError: nil + ) +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+STT.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+STT.swift new file mode 100644 index 000000000..55244a428 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+STT.swift @@ -0,0 +1,103 @@ +// +// CppBridge+STT.swift +// RunAnywhere SDK +// +// STT component bridge - manages C++ STT component lifecycle +// + +import CRACommons +import Foundation + +// MARK: - STT Component Bridge + +extension CppBridge { + + /// STT component manager + /// Provides thread-safe access to the C++ STT component + public actor STT { + + /// Shared STT component instance + public static let shared = STT() + + private var handle: rac_handle_t? + private var loadedModelId: String? + private let logger = SDKLogger(category: "CppBridge.STT") + + private init() {} + + // MARK: - Handle Management + + /// Get or create the STT component handle + public func getHandle() throws -> rac_handle_t { + if let handle = handle { + return handle + } + + var newHandle: rac_handle_t? + let result = rac_stt_component_create(&newHandle) + guard result == RAC_SUCCESS, let handle = newHandle else { + throw SDKError.stt(.notInitialized, "Failed to create STT component: \(result)") + } + + self.handle = handle + logger.debug("STT component created") + return handle + } + + // MARK: - State + + /// Check if a model is loaded + public var isLoaded: Bool { + guard let handle = handle else { return false } + return rac_stt_component_is_loaded(handle) == RAC_TRUE + } + + /// Get the currently loaded model ID + public var currentModelId: String? { loadedModelId } + + /// Check if streaming is supported + public var supportsStreaming: Bool { + guard let handle = handle else { return false } + return rac_stt_component_supports_streaming(handle) == RAC_TRUE + } + + // MARK: - Model Lifecycle + + /// Load an STT model + public func loadModel(_ modelPath: String, modelId: String, modelName: String) throws { + let handle = try getHandle() + let result = modelPath.withCString { pathPtr in + modelId.withCString { idPtr in + modelName.withCString { namePtr in + rac_stt_component_load_model(handle, pathPtr, idPtr, namePtr) + } + } + } + guard result == RAC_SUCCESS else { + throw SDKError.stt(.modelLoadFailed, "Failed to load model: \(result)") + } + loadedModelId = modelId + logger.info("STT model loaded: \(modelId)") + } + + /// Unload the current model + public func unload() { + guard let handle = handle else { return } + rac_stt_component_cleanup(handle) + loadedModelId = nil + logger.info("STT model unloaded") + } + + // MARK: - Cleanup + + /// Destroy the component + public func destroy() { + if let handle = handle { + rac_stt_component_destroy(handle) + self.handle = nil + loadedModelId = nil + logger.debug("STT component destroyed") + } + } + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+Services.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+Services.swift new file mode 100644 index 000000000..40dd9719e --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+Services.swift @@ -0,0 +1,311 @@ +// +// CppBridge+Services.swift +// RunAnywhere SDK +// +// Service registry bridge extension for C++ interop. +// + +import CRACommons +import Foundation + +// MARK: - Services Bridge (Service Registry Queries) + +extension CppBridge { + + /// Bridge for querying the C++ service registry + /// Provides runtime discovery of registered modules and service providers + public enum Services { + + /// Registered provider info + public struct ProviderInfo { // swiftlint:disable:this nesting + public let name: String + public let capability: SDKComponent + public let priority: Int + } + + /// Registered module info + public struct ModuleInfo { // swiftlint:disable:this nesting + public let id: String + public let name: String + public let version: String + public let capabilities: Set + } + + // MARK: - Provider Queries + + /// List all providers for a capability + /// - Parameter capability: The capability to query (llm, stt, tts, vad) + /// - Returns: Array of provider names sorted by priority (highest first) + public static func listProviders(for capability: SDKComponent) -> [String] { + let cCapability = capability.toC() + + var namesPtr: UnsafeMutablePointer?>? + var count: Int = 0 + + let result = rac_service_list_providers(cCapability, &namesPtr, &count) + guard result == RAC_SUCCESS, let names = namesPtr else { + return [] + } + + var providers: [String] = [] + for i in 0.. Bool { + !listProviders(for: capability).isEmpty + } + + /// Check if a specific provider is registered + public static func isProviderRegistered(_ name: String, for capability: SDKComponent) -> Bool { + listProviders(for: capability).contains(name) + } + + // MARK: - Module Queries + + /// List all registered modules + /// - Returns: Array of module info + public static func listModules() -> [ModuleInfo] { + var modulesPtr: UnsafePointer? + var count: Int = 0 + + let result = rac_module_list(&modulesPtr, &count) + guard result == RAC_SUCCESS, let modules = modulesPtr else { + return [] + } + + var moduleInfos: [ModuleInfo] = [] + for i in 0.. = [] + if let caps = module.capabilities { + for j in 0.. ModuleInfo? { + var modulePtr: UnsafePointer? + + let result = moduleId.withCString { idPtr in + rac_module_get_info(idPtr, &modulePtr) + } + + guard result == RAC_SUCCESS, let module = modulePtr?.pointee else { + return nil + } + + var capabilities: Set = [] + if let caps = module.capabilities { + for j in 0.. Bool { + getModule(moduleId) != nil + } + + // MARK: - Platform Service Registration + + /// Context for platform service callbacks + /// Internal for callback access (C callbacks are outside the extension) + class PlatformServiceContext { // swiftlint:disable:this nesting + let canHandle: (String?) -> Bool + + init(canHandle: @escaping (String?) -> Bool) { + self.canHandle = canHandle + } + } + + // Internal for callback access (C callbacks are outside the extension) + static var platformContexts: [String: PlatformServiceContext] = [:] + static let platformLock = NSLock() + + /// Register a platform-only service with the C++ registry + /// + /// This allows Swift-only services (like SystemTTS, AppleAI) to be registered + /// with the C++ service registry, making them discoverable alongside C++ backends. + /// + /// - Parameters: + /// - name: Provider name (e.g., "SystemTTS") + /// - capability: Capability this provider offers + /// - priority: Priority (higher = preferred) + /// - canHandle: Closure to check if provider can handle a request + /// - create: Factory closure to create the service (reserved for future use) + /// - Returns: true if registration succeeded + @discardableResult + public static func registerPlatformService( + name: String, + capability: SDKComponent, + priority: Int, + canHandle: @escaping (String?) -> Bool, + create _: @escaping () async throws -> Any + ) -> Bool { + platformLock.lock() + defer { platformLock.unlock() } + + // Store context for callbacks + let context = PlatformServiceContext(canHandle: canHandle) + platformContexts[name] = context + + // Create C provider struct + var provider = rac_service_provider_t() + provider.capability = capability.toC() + provider.priority = Int32(priority) + + // Use global callbacks that look up the context by name + // Note: We store the name as user_data + let namePtr = strdup(name) + provider.user_data = UnsafeMutableRawPointer(namePtr) + provider.can_handle = platformCanHandleCallback + provider.create = platformCreateCallback + + let result = name.withCString { namePtr in + provider.name = namePtr + return rac_service_register_provider(&provider) + } + + if result != RAC_SUCCESS && result != RAC_ERROR_MODULE_ALREADY_REGISTERED { + // Cleanup on failure + platformContexts.removeValue(forKey: name) + if let namePtr = provider.user_data?.assumingMemoryBound(to: CChar.self) { + free(namePtr) + } + return false + } + + return true + } + + /// Unregister a platform service + public static func unregisterPlatformService(name: String, capability: SDKComponent) { + platformLock.lock() + defer { platformLock.unlock() } + + platformContexts.removeValue(forKey: name) + + _ = name.withCString { namePtr in + rac_service_unregister_provider(namePtr, capability.toC()) + } + } + } +} + +// MARK: - Platform Service Callbacks + +/// Callback for checking if platform service can handle request +private func platformCanHandleCallback( + request: UnsafePointer?, + userData: UnsafeMutableRawPointer? +) -> rac_bool_t { + guard let userData = userData else { return RAC_FALSE } + + let name = String(cString: userData.assumingMemoryBound(to: CChar.self)) + + CppBridge.Services.platformLock.lock() + guard let context = CppBridge.Services.platformContexts[name] else { + CppBridge.Services.platformLock.unlock() + return RAC_FALSE + } + CppBridge.Services.platformLock.unlock() + + let identifier = request?.pointee.identifier.map { String(cString: $0) } + return context.canHandle(identifier) ? RAC_TRUE : RAC_FALSE +} + +/// Callback for creating platform service +private func platformCreateCallback( + request _: UnsafePointer?, + userData: UnsafeMutableRawPointer? +) -> rac_handle_t? { + guard let userData = userData else { return nil } + + let name = String(cString: userData.assumingMemoryBound(to: CChar.self)) + + CppBridge.Services.platformLock.lock() + guard CppBridge.Services.platformContexts[name] != nil else { + CppBridge.Services.platformLock.unlock() + return nil + } + CppBridge.Services.platformLock.unlock() + + // Platform services are Swift objects - we return a dummy handle + // The actual service is stored in the context and managed by Swift + // This is a bridge pattern - C++ tracks that a service exists, + // but Swift manages the actual instance + return UnsafeMutableRawPointer(bitPattern: 0xDEADBEEF) // Marker handle +} + +// MARK: - SDKComponent C++ Conversion + +extension SDKComponent { + + /// Convert to C++ capability type + func toC() -> rac_capability_t { + switch self { + case .llm: + return RAC_CAPABILITY_TEXT_GENERATION + case .stt: + return RAC_CAPABILITY_STT + case .tts: + return RAC_CAPABILITY_TTS + case .vad: + return RAC_CAPABILITY_VAD + case .voice: + // Voice agent uses multiple capabilities, default to text generation + return RAC_CAPABILITY_TEXT_GENERATION + case .embedding: + // Embeddings use text generation capability + return RAC_CAPABILITY_TEXT_GENERATION + } + } + + /// Convert from C++ capability type + static func from(_ capability: rac_capability_t) -> SDKComponent? { + switch capability { + case RAC_CAPABILITY_TEXT_GENERATION: + return .llm + case RAC_CAPABILITY_STT: + return .stt + case RAC_CAPABILITY_TTS: + return .tts + case RAC_CAPABILITY_VAD: + return .vad + default: + return nil + } + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+State.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+State.swift new file mode 100644 index 000000000..b3de3b009 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+State.swift @@ -0,0 +1,369 @@ +// +// CppBridge+State.swift +// RunAnywhere SDK +// +// SDK state management bridge extension for C++ interop. +// + +import CRACommons +import Foundation + +// MARK: - State Bridge (Centralized SDK State) + +extension CppBridge { + + /// SDK State bridge - centralized state management in C++ + /// C++ owns runtime state; Swift handles persistence (Keychain) + public enum State { + + private static var persistenceRegistered = false + + // MARK: - Initialization + + /// Initialize C++ state manager + /// - Parameters: + /// - environment: SDK environment + /// - apiKey: API key + /// - baseURL: Base URL + /// - deviceId: Persistent device ID + public static func initialize( + environment: SDKEnvironment, + apiKey: String, + baseURL: URL, + deviceId: String + ) { + apiKey.withCString { key in + baseURL.absoluteString.withCString { url in + deviceId.withCString { did in + _ = rac_state_initialize( + Environment.toC(environment), + key, + url, + did + ) + } + } + } + + // Initialize SDK config with version (required for device registration) + // This populates rac_sdk_get_config() which device registration uses + let sdkVersion = SDKConstants.version + let platform = SDKConstants.platform + + // Use withCString to ensure strings remain valid during the call + sdkVersion.withCString { sdkVer in + platform.withCString { plat in + apiKey.withCString { key in + baseURL.absoluteString.withCString { url in + deviceId.withCString { did in + var sdkConfig = rac_sdk_config_t() + sdkConfig.environment = Environment.toC(environment) + sdkConfig.api_key = apiKey.isEmpty ? nil : key + sdkConfig.base_url = baseURL.absoluteString.isEmpty ? nil : url + sdkConfig.device_id = deviceId.isEmpty ? nil : did + sdkConfig.platform = plat + sdkConfig.sdk_version = sdkVer + _ = rac_sdk_init(&sdkConfig) + } + } + } + } + } + + // Register Keychain persistence callbacks + registerPersistenceCallbacks() + + // Load any stored tokens from Keychain into C++ state + loadStoredAuth() + + SDKLogger(category: "CppBridge.State").debug("C++ state initialized") + } + + /// Check if state is initialized + public static var isInitialized: Bool { + rac_state_is_initialized() + } + + /// Reset state (for testing) + public static func reset() { + rac_state_reset() + } + + /// Shutdown state manager + public static func shutdown() { + rac_state_shutdown() + persistenceRegistered = false + } + + // MARK: - Environment Queries + + /// Get current environment from C++ state + public static var environment: SDKEnvironment { + Environment.fromC(rac_state_get_environment()) + } + + /// Get base URL from C++ state + public static var baseURL: String? { + guard let ptr = rac_state_get_base_url() else { return nil } + let str = String(cString: ptr) + return str.isEmpty ? nil : str + } + + /// Get API key from C++ state + public static var apiKey: String? { + guard let ptr = rac_state_get_api_key() else { return nil } + let str = String(cString: ptr) + return str.isEmpty ? nil : str + } + + /// Get device ID from C++ state + public static var deviceId: String? { + guard let ptr = rac_state_get_device_id() else { return nil } + let str = String(cString: ptr) + return str.isEmpty ? nil : str + } + + // MARK: - Auth State + + /// Set authentication state after successful HTTP auth + /// - Parameters: + /// - accessToken: Access token + /// - refreshToken: Refresh token + /// - expiresAt: Token expiry date + /// - userId: User ID (nullable) + /// - organizationId: Organization ID + /// - deviceId: Device ID from response + public static func setAuth( + accessToken: String, + refreshToken: String, + expiresAt: Date, + userId: String?, + organizationId: String, + deviceId: String + ) { + let expiresAtUnix = Int64(expiresAt.timeIntervalSince1970) + + accessToken.withCString { access in + refreshToken.withCString { refresh in + organizationId.withCString { org in + deviceId.withCString { did in + var authData = rac_auth_data_t( + access_token: access, + refresh_token: refresh, + expires_at_unix: expiresAtUnix, + user_id: nil, + organization_id: org, + device_id: did + ) + + if let userId = userId { + userId.withCString { user in + authData.user_id = user + _ = rac_state_set_auth(&authData) + } + } else { + _ = rac_state_set_auth(&authData) + } + } + } + } + } + + SDKLogger(category: "CppBridge.State").debug("Auth state set in C++") + } + + /// Get access token from C++ state + public static var accessToken: String? { + guard let ptr = rac_state_get_access_token() else { return nil } + return String(cString: ptr) + } + + /// Get refresh token from C++ state + public static var refreshToken: String? { + guard let ptr = rac_state_get_refresh_token() else { return nil } + return String(cString: ptr) + } + + /// Check if authenticated (valid non-expired token) + public static var isAuthenticated: Bool { + rac_state_is_authenticated() + } + + /// Check if token needs refresh + public static var tokenNeedsRefresh: Bool { + rac_state_token_needs_refresh() + } + + /// Get token expiry timestamp + public static var tokenExpiresAt: Date? { + let unix = rac_state_get_token_expires_at() + return unix > 0 ? Date(timeIntervalSince1970: TimeInterval(unix)) : nil + } + + /// Get user ID from C++ state + public static var userId: String? { + guard let ptr = rac_state_get_user_id() else { return nil } + return String(cString: ptr) + } + + /// Get organization ID from C++ state + public static var organizationId: String? { + guard let ptr = rac_state_get_organization_id() else { return nil } + return String(cString: ptr) + } + + /// Clear authentication state + public static func clearAuth() { + rac_state_clear_auth() + SDKLogger(category: "CppBridge.State").debug("Auth state cleared") + } + + // MARK: - Device State + + /// Set device registration status + public static func setDeviceRegistered(_ registered: Bool) { + rac_state_set_device_registered(registered) + } + + /// Check if device is registered + public static var isDeviceRegistered: Bool { + rac_state_is_device_registered() + } + + // MARK: - Persistence (Keychain Integration) + + /// Register Keychain persistence callbacks with C++ + private static func registerPersistenceCallbacks() { + guard !persistenceRegistered else { return } + + rac_state_set_persistence_callbacks( + keychainPersistCallback, + keychainLoadCallback, + nil + ) + + persistenceRegistered = true + } + + /// Load stored auth from Keychain into C++ state + private static func loadStoredAuth() { + // Load tokens from Keychain (use retrieveIfExists to avoid logging errors for missing items) + // retrieveIfExists returns String? and can throw + let accessToken: String? + let refreshToken: String? + + do { + accessToken = try KeychainManager.shared.retrieveIfExists(for: "com.runanywhere.sdk.accessToken") + refreshToken = try KeychainManager.shared.retrieveIfExists(for: "com.runanywhere.sdk.refreshToken") + } catch { + // Keychain error (not just missing item) - log but don't fail + SDKLogger(category: "CppBridge.State").debug("Keychain error loading auth: \(error.localizedDescription)") + return + } + + guard let accessToken = accessToken, + let refreshToken = refreshToken else { + // No stored auth tokens found - this is normal on first launch + SDKLogger(category: "CppBridge.State").debug("No stored auth data found in Keychain (expected on first launch)") + return + } + + // Load additional fields (optional - these may not exist) + let userId = try? KeychainManager.shared.retrieveIfExists(for: "com.runanywhere.sdk.userId") + let orgId = try? KeychainManager.shared.retrieveIfExists(for: "com.runanywhere.sdk.organizationId") + let deviceIdStored = try? KeychainManager.shared.retrieveIfExists(for: "com.runanywhere.sdk.deviceId") + + // Set in C++ state (use a far-future expiry for loaded tokens - they'll be refreshed if needed) + accessToken.withCString { access in + refreshToken.withCString { refresh in + (orgId ?? "").withCString { org in + (deviceIdStored ?? DeviceIdentity.persistentUUID).withCString { did in + var authData = rac_auth_data_t( + access_token: access, + refresh_token: refresh, + expires_at_unix: 0, // Unknown - will check via API + user_id: nil, + organization_id: org, + device_id: did + ) + + if let userId = userId { + userId.withCString { user in + authData.user_id = user + _ = rac_state_set_auth(&authData) + } + } else { + _ = rac_state_set_auth(&authData) + } + } + } + } + } + + SDKLogger(category: "CppBridge.State").debug("Loaded stored auth from Keychain") + } + } +} + +// MARK: - Keychain Persistence Callbacks + +/// C callback for persisting state to Keychain +private func keychainPersistCallback( + key: UnsafePointer?, + value: UnsafePointer?, + userData _: UnsafeMutableRawPointer? +) { + guard let key = key else { return } + let keyString = String(cString: key) + + // Map C++ keys to Keychain keys + let keychainKey: String + switch keyString { + case "access_token": + keychainKey = "com.runanywhere.sdk.accessToken" + case "refresh_token": + keychainKey = "com.runanywhere.sdk.refreshToken" + default: + return // Ignore unknown keys + } + + if let value = value { + // Store value + let valueString = String(cString: value) + try? KeychainManager.shared.store(valueString, for: keychainKey) + } else { + // Delete value + try? KeychainManager.shared.delete(for: keychainKey) + } +} + +/// C callback for loading state from Keychain +private func keychainLoadCallback( + key: UnsafePointer?, + userData _: UnsafeMutableRawPointer? +) -> UnsafePointer? { + guard let key = key else { return nil } + let keyString = String(cString: key) + + // Map C++ keys to Keychain keys + let keychainKey: String + switch keyString { + case "access_token": + keychainKey = "com.runanywhere.sdk.accessToken" + case "refresh_token": + keychainKey = "com.runanywhere.sdk.refreshToken" + default: + return nil + } + + // Load from Keychain + // Note: This returns a pointer that C++ should NOT free + // The Swift string's memory is managed by Swift + guard (try? KeychainManager.shared.retrieve(for: keychainKey)) != nil else { + return nil + } + + // This is a workaround - we return a static buffer + // In practice, C++ should call this during init only + return nil // For now, we load manually via loadStoredAuth() +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+Storage.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+Storage.swift new file mode 100644 index 000000000..4fa263832 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+Storage.swift @@ -0,0 +1,304 @@ +// +// CppBridge+Storage.swift +// RunAnywhere SDK +// +// Storage analyzer bridge - C++ owns business logic, Swift provides file operations +// + +import CRACommons +import Foundation + +// MARK: - Storage Bridge + +extension CppBridge { + + /// Storage analyzer bridge + /// C++ handles business logic (which models, path calculations, aggregation) + /// Swift provides platform-specific file operations via callbacks + public actor Storage { + + /// Shared storage analyzer instance + public static let shared = Storage() + + private var handle: rac_storage_analyzer_handle_t? + private let logger = SDKLogger(category: "CppBridge.Storage") + + private init() { + // Register callbacks and create analyzer + var callbacks = rac_storage_callbacks_t() + callbacks.calculate_dir_size = storageCalculateDirSizeCallback + callbacks.get_file_size = storageGetFileSizeCallback + callbacks.path_exists = storagePathExistsCallback + callbacks.get_available_space = storageGetAvailableSpaceCallback + callbacks.get_total_space = storageGetTotalSpaceCallback + callbacks.user_data = nil // We use global FileManager + + var handlePtr: rac_storage_analyzer_handle_t? + let result = rac_storage_analyzer_create(&callbacks, &handlePtr) + if result == RAC_SUCCESS { + self.handle = handlePtr + logger.debug("Storage analyzer created") + } else { + logger.error("Failed to create storage analyzer: \(result)") + } + } + + deinit { + if let handle = handle { + rac_storage_analyzer_destroy(handle) + } + } + + // MARK: - Public API + + /// Analyze overall storage + /// C++ iterates models, calculates paths, calls Swift for sizes + public func analyzeStorage() async -> StorageInfo { + guard let handle = handle else { + return .empty + } + + // Get registry handle from CppBridge.ModelRegistry + // Note: We need access to the registry's handle + let registryHandle = await getRegistryHandle() + guard let regHandle = registryHandle else { + return .empty + } + + var cInfo = rac_storage_info_t() + let result = rac_storage_analyzer_analyze(handle, regHandle, &cInfo) + + guard result == RAC_SUCCESS else { + logger.error("Storage analysis failed: \(result)") + return .empty + } + + defer { rac_storage_info_free(&cInfo) } + + // Convert C++ result to Swift types + return StorageInfo(from: cInfo) + } + + /// Get storage metrics for a specific model + public func getModelStorageMetrics( + modelId: String, + framework: InferenceFramework + ) async -> ModelStorageMetrics? { + guard let handle = handle else { return nil } + + let registryHandle = await getRegistryHandle() + guard let regHandle = registryHandle else { return nil } + + var cMetrics = rac_model_storage_metrics_t() + let result = modelId.withCString { mid in + rac_storage_analyzer_get_model_metrics( + handle, regHandle, mid, framework.toCFramework(), &cMetrics + ) + } + + guard result == RAC_SUCCESS else { return nil } + + // Get full ModelInfo from registry for complete data + guard let modelInfo = await CppBridge.ModelRegistry.shared.get(modelId: modelId) else { + return nil + } + + return ModelStorageMetrics(model: modelInfo, sizeOnDisk: cMetrics.size_on_disk) + } + + /// Check if storage is available for a download + /// Note: nonisolated because it only calls C functions and doesn't need actor state + public nonisolated func checkStorageAvailable( + modelSize: Int64, + safetyMargin: Double = 0.1 + ) -> StorageAvailability { + // Use C callbacks directly for synchronous check + let available = storageGetAvailableSpaceCallback(userData: nil) + let required = Int64(Double(modelSize) * (1.0 + safetyMargin)) + + let isAvailable = available > required + let hasWarning = available < required * 2 + + let recommendation: String? + if !isAvailable { + let shortfall = required - available + let formatter = ByteCountFormatter() + formatter.countStyle = .memory + recommendation = "Need \(formatter.string(fromByteCount: shortfall)) more space." + } else if hasWarning { + recommendation = "Storage space is getting low." + } else { + recommendation = nil + } + + return StorageAvailability( + isAvailable: isAvailable, + requiredSpace: required, + availableSpace: available, + hasWarning: hasWarning, + recommendation: recommendation + ) + } + + /// Calculate size at a path + public func calculateSize(at path: URL) throws -> Int64 { + guard let handle = handle else { + throw SDKError.general(.initializationFailed, "Storage analyzer not initialized") + } + + var size: Int64 = 0 + let result = path.path.withCString { pathPtr in + rac_storage_analyzer_calculate_size(handle, pathPtr, &size) + } + + guard result == RAC_SUCCESS else { + if result == RAC_ERROR_NOT_FOUND { + throw SDKError.fileManagement(.fileNotFound, "Path not found: \(path.path)") + } + throw SDKError.general(.processingFailed, "Failed to calculate size") + } + + return size + } + + // MARK: - Private + + private func getRegistryHandle() async -> rac_model_registry_handle_t? { + // Access the registry's handle + // Note: We need to expose this from CppBridge.ModelRegistry + return await CppBridge.ModelRegistry.shared.getHandle() + } + } +} + +// MARK: - C Callbacks (Platform-Specific File Operations) + +/// Calculate directory size using FileManager +private func storageCalculateDirSizeCallback( + path: UnsafePointer?, + userData _: UnsafeMutableRawPointer? +) -> Int64 { + guard let path = path else { return 0 } + let url = URL(fileURLWithPath: String(cString: path)) + return calculateDirectorySize(at: url) +} + +/// Get file size +private func storageGetFileSizeCallback( + path: UnsafePointer?, + userData _: UnsafeMutableRawPointer? +) -> Int64 { + guard let path = path else { return -1 } + let url = URL(fileURLWithPath: String(cString: path)) + return FileOperationsUtilities.fileSize(at: url) ?? -1 +} + +/// Check if path exists +private func storagePathExistsCallback( + path: UnsafePointer?, + isDirectory: UnsafeMutablePointer?, + userData _: UnsafeMutableRawPointer? +) -> rac_bool_t { + guard let path = path else { return RAC_FALSE } + let url = URL(fileURLWithPath: String(cString: path)) + let (exists, isDir) = FileOperationsUtilities.existsWithType(at: url) + isDirectory?.pointee = isDir ? RAC_TRUE : RAC_FALSE + return exists ? RAC_TRUE : RAC_FALSE +} + +/// Get available disk space +private func storageGetAvailableSpaceCallback(userData _: UnsafeMutableRawPointer?) -> Int64 { + do { + let attrs = try FileManager.default.attributesOfFileSystem( + forPath: NSHomeDirectory() + ) + return (attrs[.systemFreeSize] as? Int64) ?? 0 + } catch { + return 0 + } +} + +/// Get total disk space +private func storageGetTotalSpaceCallback(userData _: UnsafeMutableRawPointer?) -> Int64 { + do { + let attrs = try FileManager.default.attributesOfFileSystem( + forPath: NSHomeDirectory() + ) + return (attrs[.systemSize] as? Int64) ?? 0 + } catch { + return 0 + } +} + +/// Calculate directory size (recursive) +private func calculateDirectorySize(at url: URL) -> Int64 { + let fm = FileManager.default + guard let enumerator = fm.enumerator( + at: url, + includingPropertiesForKeys: [.fileSizeKey, .isRegularFileKey], + options: [.skipsHiddenFiles] + ) else { + // Maybe it's a file, not a directory + return FileOperationsUtilities.fileSize(at: url) ?? 0 + } + + var totalSize: Int64 = 0 + for case let fileURL as URL in enumerator { + if let values = try? fileURL.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey]), + values.isRegularFile == true { + totalSize += Int64(values.fileSize ?? 0) + } + } + return totalSize +} + +// MARK: - Swift Type Conversions + +extension StorageInfo { + /// Initialize from C++ storage info + init(from cInfo: rac_storage_info_t) { + // Convert app storage + let appStorage = AppStorageInfo( + documentsSize: cInfo.app_storage.documents_size, + cacheSize: cInfo.app_storage.cache_size, + appSupportSize: cInfo.app_storage.app_support_size, + totalSize: cInfo.app_storage.total_size + ) + + // Convert device storage + let deviceStorage = DeviceStorageInfo( + totalSpace: cInfo.device_storage.total_space, + freeSpace: cInfo.device_storage.free_space, + usedSpace: cInfo.device_storage.used_space + ) + + // Convert model metrics - need to get full ModelInfo from registry + var models: [ModelStorageMetrics] = [] + if let cModels = cInfo.models { + for i in 0.. rac_model_registry_handle_t? { + return handle + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+Strategy.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+Strategy.swift new file mode 100644 index 000000000..020192ed2 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+Strategy.swift @@ -0,0 +1,76 @@ +// +// CppBridge+Strategy.swift +// RunAnywhere SDK +// +// Archive type C++ conversion extensions. +// These are used by ModelTypes+CppBridge.swift for model artifact type handling. +// + +import CRACommons +import Foundation + +// MARK: - ArchiveType C++ Conversion + +extension ArchiveType { + /// Convert to C++ archive type + func toC() -> rac_archive_type_t { + switch self { + case .zip: + return RAC_ARCHIVE_TYPE_ZIP + case .tarBz2: + return RAC_ARCHIVE_TYPE_TAR_BZ2 + case .tarGz: + return RAC_ARCHIVE_TYPE_TAR_GZ + case .tarXz: + return RAC_ARCHIVE_TYPE_TAR_XZ + } + } + + /// Initialize from C++ archive type + init?(from cType: rac_archive_type_t) { + switch cType { + case RAC_ARCHIVE_TYPE_ZIP: + self = .zip + case RAC_ARCHIVE_TYPE_TAR_BZ2: + self = .tarBz2 + case RAC_ARCHIVE_TYPE_TAR_GZ: + self = .tarGz + case RAC_ARCHIVE_TYPE_TAR_XZ: + self = .tarXz + default: + return nil + } + } +} + +// MARK: - ArchiveStructure C++ Conversion + +extension ArchiveStructure { + /// Convert to C++ archive structure + func toC() -> rac_archive_structure_t { + switch self { + case .singleFileNested: + return RAC_ARCHIVE_STRUCTURE_SINGLE_FILE_NESTED + case .directoryBased: + return RAC_ARCHIVE_STRUCTURE_DIRECTORY_BASED + case .nestedDirectory: + return RAC_ARCHIVE_STRUCTURE_NESTED_DIRECTORY + case .unknown: + return RAC_ARCHIVE_STRUCTURE_UNKNOWN + } + } + + /// Initialize from C++ archive structure + init(from cStructure: rac_archive_structure_t) { + switch cStructure { + case RAC_ARCHIVE_STRUCTURE_SINGLE_FILE_NESTED: + self = .singleFileNested + case RAC_ARCHIVE_STRUCTURE_DIRECTORY_BASED: + self = .directoryBased + case RAC_ARCHIVE_STRUCTURE_NESTED_DIRECTORY: + self = .nestedDirectory + default: + self = .unknown + } + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+TTS.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+TTS.swift new file mode 100644 index 000000000..fd166f5b9 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+TTS.swift @@ -0,0 +1,103 @@ +// +// CppBridge+TTS.swift +// RunAnywhere SDK +// +// TTS component bridge - manages C++ TTS component lifecycle +// + +import CRACommons +import Foundation + +// MARK: - TTS Component Bridge + +extension CppBridge { + + /// TTS component manager + /// Provides thread-safe access to the C++ TTS component + public actor TTS { + + /// Shared TTS component instance + public static let shared = TTS() + + private var handle: rac_handle_t? + private var loadedVoiceId: String? + private let logger = SDKLogger(category: "CppBridge.TTS") + + private init() {} + + // MARK: - Handle Management + + /// Get or create the TTS component handle + public func getHandle() throws -> rac_handle_t { + if let handle = handle { + return handle + } + + var newHandle: rac_handle_t? + let result = rac_tts_component_create(&newHandle) + guard result == RAC_SUCCESS, let handle = newHandle else { + throw SDKError.tts(.notInitialized, "Failed to create TTS component: \(result)") + } + + self.handle = handle + logger.debug("TTS component created") + return handle + } + + // MARK: - State + + /// Check if a voice is loaded + public var isLoaded: Bool { + guard let handle = handle else { return false } + return rac_tts_component_is_loaded(handle) == RAC_TRUE + } + + /// Get the currently loaded voice ID + public var currentVoiceId: String? { loadedVoiceId } + + // MARK: - Voice Lifecycle + + /// Load a TTS voice + public func loadVoice(_ voicePath: String, voiceId: String, voiceName: String) throws { + let handle = try getHandle() + let result = voicePath.withCString { pathPtr in + voiceId.withCString { idPtr in + voiceName.withCString { namePtr in + rac_tts_component_load_voice(handle, pathPtr, idPtr, namePtr) + } + } + } + guard result == RAC_SUCCESS else { + throw SDKError.tts(.modelLoadFailed, "Failed to load voice: \(result)") + } + loadedVoiceId = voiceId + logger.info("TTS voice loaded: \(voiceId)") + } + + /// Unload the current voice + public func unload() { + guard let handle = handle else { return } + rac_tts_component_cleanup(handle) + loadedVoiceId = nil + logger.info("TTS voice unloaded") + } + + /// Stop synthesis + public func stop() { + guard let handle = handle else { return } + rac_tts_component_stop(handle) + } + + // MARK: - Cleanup + + /// Destroy the component + public func destroy() { + if let handle = handle { + rac_tts_component_destroy(handle) + self.handle = nil + loadedVoiceId = nil + logger.debug("TTS component destroyed") + } + } + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+Telemetry.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+Telemetry.swift new file mode 100644 index 000000000..98b4b285f --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+Telemetry.swift @@ -0,0 +1,499 @@ +// +// CppBridge+Telemetry.swift +// RunAnywhere SDK +// +// Telemetry bridge for C++ interop. +// All events originate from C++ - Swift only provides HTTP transport. +// + +import CRACommons +import Foundation + +// MARK: - Events Bridge + +extension CppBridge { + + /// Analytics events bridge + /// C++ handles all event logic - Swift just handles HTTP transport + public enum Events { + + private static var isRegistered = false + + /// Register C++ event callbacks + /// Only analytics callback is needed - for telemetry HTTP transport + static func register() { + guard !isRegistered else { return } + + // Register analytics callback (receives TELEMETRY_ONLY and ALL events) + // This forwards to C++ telemetry manager which builds JSON and calls HTTP callback + let result = rac_analytics_events_set_callback(analyticsEventCallback, nil) + if result != RAC_SUCCESS { + SDKLogger(category: "CppBridge.Events").warning("Failed to register analytics callback") + } + + // Note: Public events are handled directly by app developers via C++ callbacks + // No Swift EventPublisher layer needed + + isRegistered = true + SDKLogger(category: "CppBridge.Events").debug("Registered C++ event callbacks") + } + + /// Unregister C++ event callbacks + static func unregister() { + guard isRegistered else { return } + _ = rac_analytics_events_set_callback(nil, nil) + isRegistered = false + } + } +} + +/// Analytics callback - handles telemetry (C++ routes TELEMETRY_ONLY and ALL here) +private func analyticsEventCallback( + type: rac_event_type_t, + data: UnsafePointer?, + userData _: UnsafeMutableRawPointer? +) { + guard let data = data else { + return + } + // Forward to telemetry manager (C++ builds JSON, calls HTTP callback) + CppBridge.Telemetry.trackAnalyticsEvent(type: type, data: data) +} + +// MARK: - Telemetry Bridge + +extension CppBridge { + + /// Telemetry bridge + /// C++ handles JSON building, batching; Swift handles HTTP transport only + public enum Telemetry { + + private static var manager: OpaquePointer? + private static let lock = NSLock() + + /// Initialize telemetry manager + static func initialize(environment: SDKEnvironment) { + lock.lock() + defer { lock.unlock() } + + // Destroy existing if any + if let existing = manager { + rac_telemetry_manager_destroy(existing) + } + + let deviceId = DeviceIdentity.persistentUUID + let deviceInfo = DeviceInfo.current + + manager = deviceId.withCString { did in + SDKConstants.platform.withCString { plat in + SDKConstants.version.withCString { ver in + rac_telemetry_manager_create(Environment.toC(environment), did, plat, ver) + } + } + } + + // Set device info + deviceInfo.deviceModel.withCString { model in + deviceInfo.osVersion.withCString { os in + rac_telemetry_manager_set_device_info(manager, model, os) + } + } + + // Register HTTP callback - Swift provides HTTP transport for C++ + let userData = Unmanaged.passUnretained(Telemetry.self as AnyObject).toOpaque() + rac_telemetry_manager_set_http_callback(manager, telemetryHttpCallback, userData) + } + + /// Shutdown telemetry manager + static func shutdown() { + lock.lock() + defer { lock.unlock() } + + if let mgr = manager { + rac_telemetry_manager_flush(mgr) + rac_telemetry_manager_destroy(mgr) + manager = nil + } + } + + /// Track analytics event from C++ + static func trackAnalyticsEvent( + type: rac_event_type_t, + data: UnsafePointer + ) { + lock.lock() + let mgr = manager + lock.unlock() + + guard let mgr = mgr else { return } + rac_telemetry_manager_track_analytics(mgr, type, data) + } + + /// Flush pending events + public static func flush() { + lock.lock() + let mgr = manager + lock.unlock() + + guard let mgr = mgr else { return } + rac_telemetry_manager_flush(mgr) + } + } +} + +/// HTTP callback for telemetry - Swift provides HTTP transport for C++ telemetry +private func telemetryHttpCallback( + userData _: UnsafeMutableRawPointer?, + endpoint: UnsafePointer?, + jsonBody: UnsafePointer?, + jsonLength _: Int, + requiresAuth: rac_bool_t +) { + guard let endpoint = endpoint, let jsonBody = jsonBody else { return } + + let path = String(cString: endpoint) + let json = String(cString: jsonBody) + let needsAuth = requiresAuth == RAC_TRUE + + Task { + await performTelemetryHTTP(path: path, json: json, requiresAuth: needsAuth) + } +} + +private func performTelemetryHTTP(path: String, json: String, requiresAuth: Bool) async { + let logger = SDKLogger(category: "CppBridge.Telemetry") + + // Check if HTTP is configured before attempting request + let isConfigured = await CppBridge.HTTP.shared.isConfigured + guard isConfigured else { + logger.debug("HTTP not configured, cannot send telemetry to \(path). Events will be queued.") + return + } + + do { + _ = try await CppBridge.HTTP.shared.post(path, json: json, requiresAuth: requiresAuth) + logger.debug("✅ Telemetry sent to \(path)") + } catch { + logger.error("❌ HTTP failed for telemetry to \(path): \(error)") + } +} + +// MARK: - Event Emission Helpers (for Swift code that needs to emit events to C++) + +extension CppBridge.Events { + + // MARK: - Download Events + + /// Emit download started event via C++ + public static func emitDownloadStarted(modelId: String, totalBytes: Int64 = 0) { + modelId.withCString { modelIdPtr in + var eventData = rac_analytics_event_data_t() + eventData.type = RAC_EVENT_MODEL_DOWNLOAD_STARTED + eventData.data.model_download.model_id = modelIdPtr + eventData.data.model_download.total_bytes = totalBytes + eventData.data.model_download.progress = 0 + eventData.data.model_download.bytes_downloaded = 0 + eventData.data.model_download.duration_ms = 0 + eventData.data.model_download.error_code = RAC_SUCCESS + eventData.data.model_download.error_message = nil + rac_analytics_event_emit(RAC_EVENT_MODEL_DOWNLOAD_STARTED, &eventData) + } + } + + /// Emit download progress event via C++ + public static func emitDownloadProgress(modelId: String, progress: Double, bytesDownloaded: Int64, totalBytes: Int64) { + modelId.withCString { modelIdPtr in + var eventData = rac_analytics_event_data_t() + eventData.type = RAC_EVENT_MODEL_DOWNLOAD_PROGRESS + eventData.data.model_download.model_id = modelIdPtr + eventData.data.model_download.progress = progress + eventData.data.model_download.bytes_downloaded = bytesDownloaded + eventData.data.model_download.total_bytes = totalBytes + eventData.data.model_download.duration_ms = 0 + eventData.data.model_download.error_code = RAC_SUCCESS + eventData.data.model_download.error_message = nil + rac_analytics_event_emit(RAC_EVENT_MODEL_DOWNLOAD_PROGRESS, &eventData) + } + } + + /// Emit download completed event via C++ + public static func emitDownloadCompleted(modelId: String, durationMs: Double, sizeBytes: Int64) { + modelId.withCString { modelIdPtr in + var eventData = rac_analytics_event_data_t() + eventData.type = RAC_EVENT_MODEL_DOWNLOAD_COMPLETED + eventData.data.model_download.model_id = modelIdPtr + eventData.data.model_download.duration_ms = durationMs + eventData.data.model_download.size_bytes = sizeBytes + eventData.data.model_download.progress = 100 + eventData.data.model_download.error_code = RAC_SUCCESS + eventData.data.model_download.error_message = nil + rac_analytics_event_emit(RAC_EVENT_MODEL_DOWNLOAD_COMPLETED, &eventData) + } + } + + /// Emit download failed event via C++ + public static func emitDownloadFailed(modelId: String, error: SDKError) { + modelId.withCString { modelIdPtr in + error.message.withCString { errorMsgPtr in + var eventData = rac_analytics_event_data_t() + eventData.type = RAC_EVENT_MODEL_DOWNLOAD_FAILED + eventData.data.model_download.model_id = modelIdPtr + eventData.data.model_download.error_code = RAC_ERROR_UNKNOWN + eventData.data.model_download.error_message = errorMsgPtr + eventData.data.model_download.progress = 0 + eventData.data.model_download.duration_ms = 0 + rac_analytics_event_emit(RAC_EVENT_MODEL_DOWNLOAD_FAILED, &eventData) + } + } + } + + /// Emit download cancelled event via C++ + public static func emitDownloadCancelled(modelId: String) { + modelId.withCString { modelIdPtr in + var eventData = rac_analytics_event_data_t() + eventData.type = RAC_EVENT_MODEL_DOWNLOAD_CANCELLED + eventData.data.model_download.model_id = modelIdPtr + eventData.data.model_download.error_code = RAC_SUCCESS + eventData.data.model_download.error_message = nil + rac_analytics_event_emit(RAC_EVENT_MODEL_DOWNLOAD_CANCELLED, &eventData) + } + } + + // MARK: - Extraction Events + + /// Emit extraction started event via C++ + public static func emitExtractionStarted(modelId: String, archiveType: String) { + modelId.withCString { modelIdPtr in + archiveType.withCString { archiveTypePtr in + var eventData = rac_analytics_event_data_t() + eventData.type = RAC_EVENT_MODEL_EXTRACTION_STARTED + eventData.data.model_download.model_id = modelIdPtr + eventData.data.model_download.archive_type = archiveTypePtr + eventData.data.model_download.progress = 0 + eventData.data.model_download.error_code = RAC_SUCCESS + eventData.data.model_download.error_message = nil + rac_analytics_event_emit(RAC_EVENT_MODEL_EXTRACTION_STARTED, &eventData) + } + } + } + + /// Emit extraction progress event via C++ + public static func emitExtractionProgress(modelId: String, progress: Double) { + modelId.withCString { modelIdPtr in + var eventData = rac_analytics_event_data_t() + eventData.type = RAC_EVENT_MODEL_EXTRACTION_PROGRESS + eventData.data.model_download.model_id = modelIdPtr + eventData.data.model_download.progress = progress + eventData.data.model_download.error_code = RAC_SUCCESS + eventData.data.model_download.error_message = nil + rac_analytics_event_emit(RAC_EVENT_MODEL_EXTRACTION_PROGRESS, &eventData) + } + } + + /// Emit extraction completed event via C++ + public static func emitExtractionCompleted(modelId: String, durationMs: Double) { + modelId.withCString { modelIdPtr in + var eventData = rac_analytics_event_data_t() + eventData.type = RAC_EVENT_MODEL_EXTRACTION_COMPLETED + eventData.data.model_download.model_id = modelIdPtr + eventData.data.model_download.duration_ms = durationMs + eventData.data.model_download.progress = 100 + eventData.data.model_download.error_code = RAC_SUCCESS + eventData.data.model_download.error_message = nil + rac_analytics_event_emit(RAC_EVENT_MODEL_EXTRACTION_COMPLETED, &eventData) + } + } + + /// Emit extraction failed event via C++ + public static func emitExtractionFailed(modelId: String, error: SDKError) { + modelId.withCString { modelIdPtr in + error.message.withCString { errorMsgPtr in + var eventData = rac_analytics_event_data_t() + eventData.type = RAC_EVENT_MODEL_EXTRACTION_FAILED + eventData.data.model_download.model_id = modelIdPtr + eventData.data.model_download.error_code = RAC_ERROR_UNKNOWN + eventData.data.model_download.error_message = errorMsgPtr + rac_analytics_event_emit(RAC_EVENT_MODEL_EXTRACTION_FAILED, &eventData) + } + } + } + + // MARK: - Model Deleted Event + + /// Emit model deleted event via C++ + public static func emitModelDeleted(modelId: String) { + modelId.withCString { modelIdPtr in + var eventData = rac_analytics_event_data_t() + eventData.type = RAC_EVENT_MODEL_DELETED + eventData.data.model_download.model_id = modelIdPtr + eventData.data.model_download.error_code = RAC_SUCCESS + eventData.data.model_download.error_message = nil + rac_analytics_event_emit(RAC_EVENT_MODEL_DELETED, &eventData) + } + } + + // MARK: - SDK Lifecycle Events + + /// Emit SDK init started event via C++ + public static func emitSDKInitStarted() { + var eventData = rac_analytics_event_data_t() + eventData.type = RAC_EVENT_SDK_INIT_STARTED + eventData.data.sdk_lifecycle.duration_ms = 0 + eventData.data.sdk_lifecycle.count = 0 + eventData.data.sdk_lifecycle.error_code = RAC_SUCCESS + eventData.data.sdk_lifecycle.error_message = nil + rac_analytics_event_emit(RAC_EVENT_SDK_INIT_STARTED, &eventData) + } + + /// Emit SDK init completed event via C++ + public static func emitSDKInitCompleted(durationMs: Double) { + var eventData = rac_analytics_event_data_t() + eventData.type = RAC_EVENT_SDK_INIT_COMPLETED + eventData.data.sdk_lifecycle.duration_ms = durationMs + eventData.data.sdk_lifecycle.count = 0 + eventData.data.sdk_lifecycle.error_code = RAC_SUCCESS + eventData.data.sdk_lifecycle.error_message = nil + rac_analytics_event_emit(RAC_EVENT_SDK_INIT_COMPLETED, &eventData) + } + + /// Emit SDK init failed event via C++ + public static func emitSDKInitFailed(error: SDKError) { + error.message.withCString { errorMsgPtr in + var eventData = rac_analytics_event_data_t() + eventData.type = RAC_EVENT_SDK_INIT_FAILED + eventData.data.sdk_lifecycle.duration_ms = 0 + eventData.data.sdk_lifecycle.count = 0 + eventData.data.sdk_lifecycle.error_code = RAC_ERROR_UNKNOWN + eventData.data.sdk_lifecycle.error_message = errorMsgPtr + rac_analytics_event_emit(RAC_EVENT_SDK_INIT_FAILED, &eventData) + } + } + + /// Emit SDK models loaded event via C++ + public static func emitSDKModelsLoaded(count: Int) { + var eventData = rac_analytics_event_data_t() + eventData.type = RAC_EVENT_SDK_MODELS_LOADED + eventData.data.sdk_lifecycle.duration_ms = 0 + eventData.data.sdk_lifecycle.count = Int32(count) + eventData.data.sdk_lifecycle.error_code = RAC_SUCCESS + eventData.data.sdk_lifecycle.error_message = nil + rac_analytics_event_emit(RAC_EVENT_SDK_MODELS_LOADED, &eventData) + } + + // MARK: - Storage Events + + /// Emit storage cache cleared event via C++ + public static func emitStorageCacheCleared(freedBytes: Int64) { + var eventData = rac_analytics_event_data_t() + eventData.type = RAC_EVENT_STORAGE_CACHE_CLEARED + eventData.data.storage.freed_bytes = freedBytes + eventData.data.storage.error_code = RAC_SUCCESS + eventData.data.storage.error_message = nil + rac_analytics_event_emit(RAC_EVENT_STORAGE_CACHE_CLEARED, &eventData) + } + + /// Emit storage cache clear failed event via C++ + public static func emitStorageCacheClearFailed(error: SDKError) { + error.message.withCString { errorMsgPtr in + var eventData = rac_analytics_event_data_t() + eventData.type = RAC_EVENT_STORAGE_CACHE_CLEAR_FAILED + eventData.data.storage.freed_bytes = 0 + eventData.data.storage.error_code = RAC_ERROR_UNKNOWN + eventData.data.storage.error_message = errorMsgPtr + rac_analytics_event_emit(RAC_EVENT_STORAGE_CACHE_CLEAR_FAILED, &eventData) + } + } + + /// Emit storage temp cleaned event via C++ + public static func emitStorageTempCleaned(freedBytes: Int64) { + var eventData = rac_analytics_event_data_t() + eventData.type = RAC_EVENT_STORAGE_TEMP_CLEANED + eventData.data.storage.freed_bytes = freedBytes + eventData.data.storage.error_code = RAC_SUCCESS + eventData.data.storage.error_message = nil + rac_analytics_event_emit(RAC_EVENT_STORAGE_TEMP_CLEANED, &eventData) + } + + // MARK: - Voice Agent / Pipeline Events + + /// Emit voice agent turn started event via C++ + public static func emitVoiceAgentTurnStarted() { + var eventData = rac_analytics_event_data_t() + eventData.type = RAC_EVENT_VOICE_AGENT_TURN_STARTED + rac_analytics_event_emit(RAC_EVENT_VOICE_AGENT_TURN_STARTED, &eventData) + } + + /// Emit voice agent turn completed event via C++ + public static func emitVoiceAgentTurnCompleted(durationMs: Double) { + var eventData = rac_analytics_event_data_t() + eventData.type = RAC_EVENT_VOICE_AGENT_TURN_COMPLETED + eventData.data.sdk_lifecycle.duration_ms = durationMs + eventData.data.sdk_lifecycle.error_code = RAC_SUCCESS + eventData.data.sdk_lifecycle.error_message = nil + rac_analytics_event_emit(RAC_EVENT_VOICE_AGENT_TURN_COMPLETED, &eventData) + } + + /// Emit voice agent turn failed event via C++ + public static func emitVoiceAgentTurnFailed(error: SDKError) { + error.message.withCString { errorMsgPtr in + var eventData = rac_analytics_event_data_t() + eventData.type = RAC_EVENT_VOICE_AGENT_TURN_FAILED + eventData.data.sdk_lifecycle.duration_ms = 0 + eventData.data.sdk_lifecycle.count = 0 + eventData.data.sdk_lifecycle.error_code = RAC_ERROR_UNKNOWN + eventData.data.sdk_lifecycle.error_message = errorMsgPtr + rac_analytics_event_emit(RAC_EVENT_VOICE_AGENT_TURN_FAILED, &eventData) + } + } + + // MARK: - Device Events + + /// Emit device registered event via C++ + public static func emitDeviceRegistered(deviceId: String) { + deviceId.withCString { deviceIdPtr in + var eventData = rac_analytics_event_data_t() + eventData.type = RAC_EVENT_DEVICE_REGISTERED + eventData.data.device.device_id = deviceIdPtr + eventData.data.device.error_code = RAC_SUCCESS + eventData.data.device.error_message = nil + rac_analytics_event_emit(RAC_EVENT_DEVICE_REGISTERED, &eventData) + } + } + + /// Emit device registration failed event via C++ + public static func emitDeviceRegistrationFailed(error: SDKError) { + error.message.withCString { errorMsgPtr in + var eventData = rac_analytics_event_data_t() + eventData.type = RAC_EVENT_DEVICE_REGISTRATION_FAILED + eventData.data.device.device_id = nil + eventData.data.device.error_code = RAC_ERROR_UNKNOWN + eventData.data.device.error_message = errorMsgPtr + rac_analytics_event_emit(RAC_EVENT_DEVICE_REGISTRATION_FAILED, &eventData) + } + } + + // MARK: - SDK Error Events + + /// Emit SDK error event via C++ + public static func emitSDKError(error: SDKError, operation: String, context: String? = nil) { + error.message.withCString { errorMsgPtr in + operation.withCString { operationPtr in + var eventData = rac_analytics_event_data_t() + eventData.type = RAC_EVENT_SDK_ERROR + eventData.data.sdk_error.error_code = RAC_ERROR_UNKNOWN + eventData.data.sdk_error.error_message = errorMsgPtr + eventData.data.sdk_error.operation = operationPtr + + if let context = context { + context.withCString { contextPtr in + eventData.data.sdk_error.context = contextPtr + rac_analytics_event_emit(RAC_EVENT_SDK_ERROR, &eventData) + } + } else { + eventData.data.sdk_error.context = nil + rac_analytics_event_emit(RAC_EVENT_SDK_ERROR, &eventData) + } + } + } + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+VAD.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+VAD.swift new file mode 100644 index 000000000..d712b9e3b --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+VAD.swift @@ -0,0 +1,102 @@ +// +// CppBridge+VAD.swift +// RunAnywhere SDK +// +// VAD component bridge - manages C++ VAD component lifecycle +// + +import CRACommons +import Foundation + +// MARK: - VAD Component Bridge + +extension CppBridge { + + /// VAD component manager + /// Provides thread-safe access to the C++ VAD component + public actor VAD { + + /// Shared VAD component instance + public static let shared = VAD() + + private var handle: rac_handle_t? + private let logger = SDKLogger(category: "CppBridge.VAD") + + private init() {} + + // MARK: - Handle Management + + /// Get or create the VAD component handle + public func getHandle() throws -> rac_handle_t { + if let handle = handle { + return handle + } + + var newHandle: rac_handle_t? + let result = rac_vad_component_create(&newHandle) + guard result == RAC_SUCCESS, let handle = newHandle else { + throw SDKError.vad(.notInitialized, "Failed to create VAD component: \(result)") + } + + self.handle = handle + logger.debug("VAD component created") + return handle + } + + // MARK: - State + + /// Check if VAD is initialized + public var isInitialized: Bool { + guard let handle = handle else { return false } + return rac_vad_component_is_initialized(handle) == RAC_TRUE + } + + // MARK: - Lifecycle + + /// Initialize VAD + public func initialize() throws { + let handle = try getHandle() + let result = rac_vad_component_initialize(handle) + guard result == RAC_SUCCESS else { + throw SDKError.vad(.initializationFailed, "Failed to initialize VAD: \(result)") + } + logger.info("VAD initialized") + } + + /// Start VAD processing + public func start() throws { + let handle = try getHandle() + let result = rac_vad_component_start(handle) + guard result == RAC_SUCCESS else { + throw SDKError.vad(.processingFailed, "Failed to start VAD: \(result)") + } + } + + /// Stop VAD processing + public func stop() throws { + let handle = try getHandle() + let result = rac_vad_component_stop(handle) + guard result == RAC_SUCCESS else { + throw SDKError.vad(.processingFailed, "Failed to stop VAD: \(result)") + } + } + + /// Cleanup VAD + public func cleanup() { + guard let handle = handle else { return } + rac_vad_component_cleanup(handle) + logger.info("VAD cleaned up") + } + + // MARK: - Cleanup + + /// Destroy the component + public func destroy() { + if let handle = handle { + rac_vad_component_destroy(handle) + self.handle = nil + logger.debug("VAD component destroyed") + } + } + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+VoiceAgent.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+VoiceAgent.swift new file mode 100644 index 000000000..e1dc0644b --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+VoiceAgent.swift @@ -0,0 +1,83 @@ +// +// CppBridge+VoiceAgent.swift +// RunAnywhere SDK +// +// VoiceAgent component bridge - manages C++ VoiceAgent component lifecycle +// + +import CRACommons +import Foundation + +// MARK: - VoiceAgent Component Bridge + +extension CppBridge { + + /// VoiceAgent component manager + /// Provides thread-safe access to the C++ VoiceAgent component + public actor VoiceAgent { + + /// Shared VoiceAgent component instance + public static let shared = VoiceAgent() + + private var handle: rac_voice_agent_handle_t? + private let logger = SDKLogger(category: "CppBridge.VoiceAgent") + + private init() {} + + // MARK: - Handle Management + + /// Get or create the VoiceAgent handle + /// Requires LLM, STT, TTS, and VAD components to be available + public func getHandle() async throws -> rac_voice_agent_handle_t { + if let handle = handle { + return handle + } + + // Get handles from all required components + let llm = try await CppBridge.LLM.shared.getHandle() + let stt = try await CppBridge.STT.shared.getHandle() + let tts = try await CppBridge.TTS.shared.getHandle() + let vad = try await CppBridge.VAD.shared.getHandle() + + var newHandle: rac_voice_agent_handle_t? + let result = rac_voice_agent_create(llm, stt, tts, vad, &newHandle) + + guard result == RAC_SUCCESS, let handle = newHandle else { + throw SDKError.voiceAgent(.initializationFailed, "Failed to create voice agent: \(result)") + } + + self.handle = handle + logger.info("Voice agent created") + return handle + } + + // MARK: - State + + /// Check if voice agent is ready + public var isReady: Bool { + guard let handle = handle else { return false } + var ready: rac_bool_t = RAC_FALSE + let result = rac_voice_agent_is_ready(handle, &ready) + return result == RAC_SUCCESS && ready == RAC_TRUE + } + + // MARK: - Cleanup + + /// Cleanup the voice agent + public func cleanup() { + guard let handle = handle else { return } + rac_voice_agent_cleanup(handle) + logger.info("Voice agent cleaned up") + } + + /// Destroy the component + public func destroy() { + if let handle = handle { + rac_voice_agent_cleanup(handle) + rac_voice_agent_destroy(handle) + self.handle = nil + logger.debug("Voice agent destroyed") + } + } + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/ModelTypes+CppBridge.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/ModelTypes+CppBridge.swift new file mode 100644 index 000000000..d7d47053f --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/ModelTypes+CppBridge.swift @@ -0,0 +1,354 @@ +// +// ModelTypes+CppBridge.swift +// RunAnywhere SDK +// +// Conversion extensions for Swift model types to C++ model types. +// Used by CppBridge.ModelRegistry to convert between Swift and C++ types. +// + +import CRACommons +import Foundation + +// MARK: - ModelCategory C++ Conversion + +extension ModelCategory { + /// Convert to C++ model category type + func toC() -> rac_model_category_t { + switch self { + case .language: + return RAC_MODEL_CATEGORY_LANGUAGE + case .speechRecognition: + return RAC_MODEL_CATEGORY_SPEECH_RECOGNITION + case .speechSynthesis: + return RAC_MODEL_CATEGORY_SPEECH_SYNTHESIS + case .vision: + return RAC_MODEL_CATEGORY_VISION + case .imageGeneration: + return RAC_MODEL_CATEGORY_IMAGE_GENERATION + case .multimodal: + return RAC_MODEL_CATEGORY_MULTIMODAL + case .audio: + return RAC_MODEL_CATEGORY_AUDIO + } + } + + /// Initialize from C++ model category type + init(from cCategory: rac_model_category_t) { + switch cCategory { + case RAC_MODEL_CATEGORY_LANGUAGE: + self = .language + case RAC_MODEL_CATEGORY_SPEECH_RECOGNITION: + self = .speechRecognition + case RAC_MODEL_CATEGORY_SPEECH_SYNTHESIS: + self = .speechSynthesis + case RAC_MODEL_CATEGORY_VISION: + self = .vision + case RAC_MODEL_CATEGORY_IMAGE_GENERATION: + self = .imageGeneration + case RAC_MODEL_CATEGORY_MULTIMODAL: + self = .multimodal + case RAC_MODEL_CATEGORY_AUDIO: + self = .audio + default: + self = .audio // Default fallback + } + } +} + +// MARK: - ModelFormat C++ Conversion + +extension ModelFormat { + /// Convert to C++ model format type + func toC() -> rac_model_format_t { + switch self { + case .onnx: + return RAC_MODEL_FORMAT_ONNX + case .ort: + return RAC_MODEL_FORMAT_ORT + case .gguf: + return RAC_MODEL_FORMAT_GGUF + case .bin: + return RAC_MODEL_FORMAT_BIN + case .unknown: + return RAC_MODEL_FORMAT_UNKNOWN + } + } + + /// Initialize from C++ model format type + init(from cFormat: rac_model_format_t) { + switch cFormat { + case RAC_MODEL_FORMAT_ONNX: + self = .onnx + case RAC_MODEL_FORMAT_ORT: + self = .ort + case RAC_MODEL_FORMAT_GGUF: + self = .gguf + case RAC_MODEL_FORMAT_BIN: + self = .bin + default: + self = .unknown + } + } +} + +// MARK: - InferenceFramework C++ Conversion + +extension InferenceFramework { + /// Convert to C++ inference framework type + func toC() -> rac_inference_framework_t { + switch self { + case .onnx: + return RAC_FRAMEWORK_ONNX + case .llamaCpp: + return RAC_FRAMEWORK_LLAMACPP + case .foundationModels: + return RAC_FRAMEWORK_FOUNDATION_MODELS + case .systemTTS: + return RAC_FRAMEWORK_SYSTEM_TTS + case .fluidAudio: + return RAC_FRAMEWORK_FLUID_AUDIO + case .builtIn: + return RAC_FRAMEWORK_BUILTIN + case .none: + return RAC_FRAMEWORK_NONE + case .unknown: + return RAC_FRAMEWORK_UNKNOWN + } + } + + /// Initialize from C++ inference framework type + init(from cFramework: rac_inference_framework_t) { + switch cFramework { + case RAC_FRAMEWORK_ONNX: + self = .onnx + case RAC_FRAMEWORK_LLAMACPP: + self = .llamaCpp + case RAC_FRAMEWORK_FOUNDATION_MODELS: + self = .foundationModels + case RAC_FRAMEWORK_SYSTEM_TTS: + self = .systemTTS + case RAC_FRAMEWORK_FLUID_AUDIO: + self = .fluidAudio + case RAC_FRAMEWORK_BUILTIN: + self = .builtIn + case RAC_FRAMEWORK_NONE: + self = .none + default: + self = .unknown + } + } +} + +// MARK: - ModelArtifactType C++ Conversion + +extension ModelArtifactType { + /// Convert to C++ artifact info struct + func toCInfo() -> rac_model_artifact_info_t { + var info = rac_model_artifact_info_t() + + switch self { + case .singleFile: + info.kind = RAC_ARTIFACT_KIND_SINGLE_FILE + info.archive_type = RAC_ARCHIVE_TYPE_NONE + info.archive_structure = RAC_ARCHIVE_STRUCTURE_UNKNOWN + + case .archive(let archiveType, let structure, _): + info.kind = RAC_ARTIFACT_KIND_ARCHIVE + info.archive_type = archiveType.toC() + info.archive_structure = structure.toC() + + case .multiFile: + info.kind = RAC_ARTIFACT_KIND_MULTI_FILE + info.archive_type = RAC_ARCHIVE_TYPE_NONE + info.archive_structure = RAC_ARCHIVE_STRUCTURE_UNKNOWN + + case .custom(let strategyId): + info.kind = RAC_ARTIFACT_KIND_CUSTOM + info.archive_type = RAC_ARCHIVE_TYPE_NONE + info.archive_structure = RAC_ARCHIVE_STRUCTURE_UNKNOWN + info.strategy_id = UnsafePointer(strdup(strategyId)) + + case .builtIn: + info.kind = RAC_ARTIFACT_KIND_BUILT_IN + info.archive_type = RAC_ARCHIVE_TYPE_NONE + info.archive_structure = RAC_ARCHIVE_STRUCTURE_UNKNOWN + } + + return info + } + + /// Initialize from C++ artifact info + init(from cArtifact: rac_model_artifact_info_t) { + switch cArtifact.kind { + case RAC_ARTIFACT_KIND_SINGLE_FILE: + self = .singleFile(expectedFiles: .none) + + case RAC_ARTIFACT_KIND_ARCHIVE: + // Map archive type - use ArchiveType initializer from CppBridge+Strategy.swift + let archiveType = ArchiveType(from: cArtifact.archive_type) ?? .zip + let structure = ArchiveStructure(from: cArtifact.archive_structure) + self = .archive(archiveType, structure: structure, expectedFiles: .none) + + case RAC_ARTIFACT_KIND_MULTI_FILE: + self = .multiFile([]) + + case RAC_ARTIFACT_KIND_CUSTOM: + self = .custom(strategyId: cArtifact.strategy_id.map { String(cString: $0) } ?? "") + + case RAC_ARTIFACT_KIND_BUILT_IN: + self = .builtIn + + default: + self = .singleFile(expectedFiles: .none) + } + } +} + +// MARK: - ModelSource C++ Conversion + +extension ModelSource { + /// Convert to C++ model source type + func toC() -> rac_model_source_t { + switch self { + case .remote: + return RAC_MODEL_SOURCE_REMOTE + case .local: + return RAC_MODEL_SOURCE_LOCAL + } + } + + /// Initialize from C++ model source type + init(from cSource: rac_model_source_t) { + switch cSource { + case RAC_MODEL_SOURCE_REMOTE: + self = .remote + case RAC_MODEL_SOURCE_LOCAL: + self = .local + default: + self = .local + } + } +} + +// MARK: - ModelInfo C++ Conversion + +extension ModelInfo { + /// Convert to C++ model info struct + /// Note: The returned struct contains allocated strings that must be freed + func toCModelInfo() -> rac_model_info_t { + var cModel = rac_model_info_t() + + cModel.id = strdup(id) + cModel.name = strdup(name) + cModel.category = category.toC() + cModel.format = format.toC() + cModel.framework = framework.toC() + cModel.download_url = downloadURL.map { strdup($0.absoluteString) } + cModel.local_path = localPath.map { strdup($0.path) } + cModel.artifact_info = artifactType.toCInfo() // Use full conversion including archive_type + cModel.download_size = downloadSize ?? 0 + cModel.context_length = Int32(contextLength ?? 0) + cModel.supports_thinking = supportsThinking ? RAC_TRUE : RAC_FALSE + cModel.description = description.map { strdup($0) } + cModel.source = source.toC() + cModel.created_at = Int64(createdAt.timeIntervalSince1970) + cModel.updated_at = Int64(updatedAt.timeIntervalSince1970) + + return cModel + } + + /// Initialize from C++ model info struct + init(from cModel: rac_model_info_t) { + self.id = cModel.id.map { String(cString: $0) } ?? "" + self.name = cModel.name.map { String(cString: $0) } ?? "" + self.category = ModelCategory(from: cModel.category) + self.format = ModelFormat(from: cModel.format) + self.framework = InferenceFramework(from: cModel.framework) + + if let urlStr = cModel.download_url.map({ String(cString: $0) }), !urlStr.isEmpty { + self.downloadURL = URL(string: urlStr) + } else { + self.downloadURL = nil + } + + if let pathStr = cModel.local_path.map({ String(cString: $0) }), !pathStr.isEmpty { + self.localPath = URL(fileURLWithPath: pathStr) + } else { + self.localPath = nil + } + + self.artifactType = ModelArtifactType(from: cModel.artifact_info) + self.downloadSize = cModel.download_size > 0 ? cModel.download_size : nil + self.contextLength = cModel.context_length > 0 ? Int(cModel.context_length) : nil + self.supportsThinking = cModel.supports_thinking == RAC_TRUE + self.thinkingPattern = supportsThinking ? .defaultPattern : nil + self.description = cModel.description.map { String(cString: $0) } + self.source = ModelSource(from: cModel.source) + self.createdAt = Date(timeIntervalSince1970: TimeInterval(cModel.created_at)) + self.updatedAt = Date(timeIntervalSince1970: TimeInterval(cModel.updated_at)) + } +} + +// MARK: - DownloadStage C++ Conversion + +extension DownloadStage { + /// Initialize from C++ download stage + init(from cStage: rac_download_stage_t) { + switch cStage { + case RAC_DOWNLOAD_STAGE_DOWNLOADING: + self = .downloading + case RAC_DOWNLOAD_STAGE_EXTRACTING: + self = .extracting + case RAC_DOWNLOAD_STAGE_VALIDATING: + self = .validating + case RAC_DOWNLOAD_STAGE_COMPLETED: + self = .completed + default: + self = .downloading + } + } +} + +// MARK: - DownloadState C++ Conversion + +extension DownloadState { + /// Initialize from C++ download state and progress + init(from cState: rac_download_state_t, cProgress: rac_download_progress_t) { + switch cState { + case RAC_DOWNLOAD_STATE_PENDING: + self = .pending + case RAC_DOWNLOAD_STATE_DOWNLOADING: + self = .downloading + case RAC_DOWNLOAD_STATE_EXTRACTING: + self = .extracting + case RAC_DOWNLOAD_STATE_RETRYING: + self = .retrying(attempt: Int(cProgress.retry_attempt)) + case RAC_DOWNLOAD_STATE_COMPLETED: + self = .completed + case RAC_DOWNLOAD_STATE_FAILED: + let errorMessage = cProgress.error_message.map { String(cString: $0) } ?? "Download failed" + self = .failed(SDKError.download(.downloadFailed, errorMessage)) + case RAC_DOWNLOAD_STATE_CANCELLED: + self = .cancelled + default: + self = .pending + } + } +} + +// MARK: - DownloadProgress C++ Conversion + +extension DownloadProgress { + /// Initialize from C++ download progress struct + init(from cProgress: rac_download_progress_t) { + self.init( + stage: DownloadStage(from: cProgress.stage), + bytesDownloaded: cProgress.bytes_downloaded, + totalBytes: cProgress.total_bytes, + stageProgress: cProgress.stage_progress, + speed: cProgress.speed > 0 ? cProgress.speed : nil, + estimatedTimeRemaining: cProgress.estimated_time_remaining >= 0 ? cProgress.estimated_time_remaining : nil, + state: DownloadState(from: cProgress.state, cProgress: cProgress) + ) + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Constants/SDKConstants.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Constants/SDKConstants.swift new file mode 100644 index 000000000..7fa6653b4 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Constants/SDKConstants.swift @@ -0,0 +1,36 @@ +import Foundation + +/// SDK-wide constants (metadata only) +/// Capability-specific constants are in their respective capabilities: +/// - LLMConstants (LLM capability) +/// - StorageConstants (FileManagement capability) +/// - DownloadConstants (Download capability) +/// - LifecycleConstants (Lifecycle capability) +/// - RegistryConstants (Registry capability) +public enum SDKConstants { + /// SDK version - must match the VERSION file in the repository root + /// Update this when bumping the SDK version + public static let version = "0.16.0" + + /// SDK name + public static let name = "RunAnywhere SDK" + + /// User agent string + public static let userAgent = "\(name)/\(version) (Swift)" + + /// Platform identifier + #if os(iOS) + public static let platform = "ios" + #elseif os(macOS) + public static let platform = "macos" + #elseif os(tvOS) + public static let platform = "tvos" + #elseif os(watchOS) + public static let platform = "watchos" + #else + public static let platform = "unknown" + #endif + + /// Minimum log level in production + public static let productionLogLevel = "error" +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Errors/CommonsErrorMapping.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Errors/CommonsErrorMapping.swift new file mode 100644 index 000000000..58a20e0a9 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Errors/CommonsErrorMapping.swift @@ -0,0 +1,497 @@ +import CRACommons +import Foundation + +// MARK: - Commons Error Mapping + +/// Maps RAC_ERROR_* codes from runanywhere-commons to Swift SDKError. +/// +/// This is the single source of truth for C++ ↔ Swift error code translation. +/// C++ error codes are defined in `rac_error.h` and mirror Swift's `ErrorCode` enum. +/// +/// ## Error Code Ranges (C++) +/// - Initialization: -100 to -109 +/// - Model: -110 to -129 +/// - Generation: -130 to -149 +/// - Network: -150 to -179 +/// - Storage: -180 to -219 +/// - Hardware: -220 to -229 +/// - Component State: -230 to -249 +/// - Validation: -250 to -279 +/// - Audio: -280 to -299 +/// - Language/Voice: -300 to -319 +/// - Authentication: -320 to -329 +/// - Security: -330 to -349 +/// - Extraction: -350 to -369 +/// - Calibration: -370 to -379 +/// - Cancellation: -380 to -389 +/// - Module/Service: -400 to -499 +/// - Platform Adapter: -500 to -599 +/// - Backend: -600 to -699 +/// - Event: -700 to -799 +/// - Other: -800 to -899 +public enum CommonsErrorMapping { + + // MARK: - C++ to Swift + + /// Converts a rac_result_t error code to SDKError. + /// + /// - Parameter result: The C error code from runanywhere-commons + /// - Returns: Corresponding SDKError, or nil if result is RAC_SUCCESS + public static func toSDKError(_ result: rac_result_t) -> SDKError? { + guard result != RAC_SUCCESS else { return nil } + + let (errorCode, errorMessage) = mapErrorCode(result) + return SDKError.general(errorCode, errorMessage) + } + + // swiftlint:disable:next cyclomatic_complexity + private static func mapErrorCode(_ result: rac_result_t) -> (ErrorCode, String) { + // Delegate to category-specific mappers to reduce cyclomatic complexity + if let mapped = mapInitializationError(result) { return mapped } + if let mapped = mapModelError(result) { return mapped } + if let mapped = mapGenerationError(result) { return mapped } + if let mapped = mapNetworkError(result) { return mapped } + if let mapped = mapStorageError(result) { return mapped } + if let mapped = mapHardwareError(result) { return mapped } + if let mapped = mapComponentStateError(result) { return mapped } + if let mapped = mapValidationError(result) { return mapped } + if let mapped = mapAudioError(result) { return mapped } + if let mapped = mapLanguageVoiceError(result) { return mapped } + if let mapped = mapAuthenticationError(result) { return mapped } + if let mapped = mapSecurityError(result) { return mapped } + if let mapped = mapExtractionError(result) { return mapped } + if let mapped = mapCalibrationError(result) { return mapped } + if let mapped = mapCancellationError(result) { return mapped } + if let mapped = mapModuleServiceError(result) { return mapped } + if let mapped = mapPlatformAdapterError(result) { return mapped } + if let mapped = mapBackendError(result) { return mapped } + if let mapped = mapEventError(result) { return mapped } + if let mapped = mapOtherError(result) { return mapped } + return (.unknown, "Unknown error code: \(result)") + } + + // MARK: - Initialization Errors (-100 to -109) + + private static func mapInitializationError(_ result: rac_result_t) -> (ErrorCode, String)? { + switch result { + case RAC_ERROR_NOT_INITIALIZED: return (.notInitialized, "Component not initialized") + case RAC_ERROR_ALREADY_INITIALIZED: return (.alreadyInitialized, "Already initialized") + case RAC_ERROR_INITIALIZATION_FAILED: return (.initializationFailed, "Initialization failed") + case RAC_ERROR_INVALID_CONFIGURATION: return (.invalidConfiguration, "Invalid configuration") + case RAC_ERROR_INVALID_API_KEY: return (.invalidAPIKey, "Invalid API key") + case RAC_ERROR_ENVIRONMENT_MISMATCH: return (.environmentMismatch, "Environment mismatch") + case RAC_ERROR_INVALID_PARAMETER: return (.invalidConfiguration, "Invalid parameter") + default: return nil + } + } + + // MARK: - Model Errors (-110 to -129) + + private static func mapModelError(_ result: rac_result_t) -> (ErrorCode, String)? { + switch result { + case RAC_ERROR_MODEL_NOT_FOUND: return (.modelNotFound, "Model not found") + case RAC_ERROR_MODEL_LOAD_FAILED: return (.modelLoadFailed, "Model load failed") + case RAC_ERROR_MODEL_VALIDATION_FAILED: return (.modelValidationFailed, "Model validation failed") + case RAC_ERROR_MODEL_INCOMPATIBLE: return (.modelIncompatible, "Model incompatible") + case RAC_ERROR_INVALID_MODEL_FORMAT: return (.invalidModelFormat, "Invalid model format") + case RAC_ERROR_MODEL_STORAGE_CORRUPTED: return (.modelStorageCorrupted, "Model storage corrupted") + case RAC_ERROR_MODEL_NOT_LOADED: return (.notInitialized, "Model not loaded") + default: return nil + } + } + + // MARK: - Generation Errors (-130 to -149) + + private static func mapGenerationError(_ result: rac_result_t) -> (ErrorCode, String)? { + switch result { + case RAC_ERROR_GENERATION_FAILED: return (.generationFailed, "Generation failed") + case RAC_ERROR_GENERATION_TIMEOUT: return (.generationTimeout, "Generation timed out") + case RAC_ERROR_CONTEXT_TOO_LONG: return (.contextTooLong, "Context too long") + case RAC_ERROR_TOKEN_LIMIT_EXCEEDED: return (.tokenLimitExceeded, "Token limit exceeded") + case RAC_ERROR_COST_LIMIT_EXCEEDED: return (.costLimitExceeded, "Cost limit exceeded") + case RAC_ERROR_INFERENCE_FAILED: return (.generationFailed, "Inference failed") + default: return nil + } + } + + // MARK: - Network Errors (-150 to -179) + + private static func mapNetworkError(_ result: rac_result_t) -> (ErrorCode, String)? { + switch result { + case RAC_ERROR_NETWORK_UNAVAILABLE: return (.networkUnavailable, "Network unavailable") + case RAC_ERROR_NETWORK_ERROR: return (.networkError, "Network error") + case RAC_ERROR_REQUEST_FAILED: return (.requestFailed, "Request failed") + case RAC_ERROR_DOWNLOAD_FAILED: return (.downloadFailed, "Download failed") + case RAC_ERROR_SERVER_ERROR: return (.serverError, "Server error") + case RAC_ERROR_TIMEOUT: return (.timeout, "Request timed out") + case RAC_ERROR_INVALID_RESPONSE: return (.invalidResponse, "Invalid response") + case RAC_ERROR_HTTP_ERROR: return (.httpError, "HTTP error") + case RAC_ERROR_CONNECTION_LOST: return (.connectionLost, "Connection lost") + case RAC_ERROR_PARTIAL_DOWNLOAD: return (.partialDownload, "Partial download") + case RAC_ERROR_HTTP_REQUEST_FAILED: return (.requestFailed, "HTTP request failed") + case RAC_ERROR_HTTP_NOT_SUPPORTED: return (.notSupported, "HTTP not supported") + default: return nil + } + } + + // MARK: - Storage Errors (-180 to -219) + + private static func mapStorageError(_ result: rac_result_t) -> (ErrorCode, String)? { + switch result { + case RAC_ERROR_INSUFFICIENT_STORAGE: return (.insufficientStorage, "Insufficient storage") + case RAC_ERROR_STORAGE_FULL: return (.storageFull, "Storage full") + case RAC_ERROR_STORAGE_ERROR: return (.storageError, "Storage error") + case RAC_ERROR_FILE_NOT_FOUND: return (.fileNotFound, "File not found") + case RAC_ERROR_FILE_READ_FAILED: return (.fileReadFailed, "File read failed") + case RAC_ERROR_FILE_WRITE_FAILED: return (.fileWriteFailed, "File write failed") + case RAC_ERROR_PERMISSION_DENIED: return (.permissionDenied, "Permission denied") + case RAC_ERROR_DELETE_FAILED: return (.deleteFailed, "Delete failed") + case RAC_ERROR_MOVE_FAILED: return (.moveFailed, "Move failed") + case RAC_ERROR_DIRECTORY_CREATION_FAILED: return (.directoryCreationFailed, "Directory creation failed") + case RAC_ERROR_DIRECTORY_NOT_FOUND: return (.directoryNotFound, "Directory not found") + case RAC_ERROR_INVALID_PATH: return (.invalidPath, "Invalid path") + case RAC_ERROR_INVALID_FILE_NAME: return (.invalidFileName, "Invalid file name") + case RAC_ERROR_TEMP_FILE_CREATION_FAILED: return (.tempFileCreationFailed, "Temp file creation failed") + default: return nil + } + } + + // MARK: - Hardware Errors (-220 to -229) + + private static func mapHardwareError(_ result: rac_result_t) -> (ErrorCode, String)? { + switch result { + case RAC_ERROR_HARDWARE_UNSUPPORTED: return (.hardwareUnsupported, "Hardware unsupported") + case RAC_ERROR_INSUFFICIENT_MEMORY: return (.insufficientMemory, "Insufficient memory") + default: return nil + } + } + + // MARK: - Component State Errors (-230 to -249) + + private static func mapComponentStateError(_ result: rac_result_t) -> (ErrorCode, String)? { + switch result { + case RAC_ERROR_COMPONENT_NOT_READY: return (.componentNotReady, "Component not ready") + case RAC_ERROR_INVALID_STATE: return (.invalidState, "Invalid state") + case RAC_ERROR_SERVICE_NOT_AVAILABLE: return (.serviceNotAvailable, "Service not available") + case RAC_ERROR_SERVICE_BUSY: return (.serviceBusy, "Service busy") + case RAC_ERROR_PROCESSING_FAILED: return (.processingFailed, "Processing failed") + case RAC_ERROR_START_FAILED: return (.startFailed, "Start failed") + case RAC_ERROR_NOT_SUPPORTED: return (.notSupported, "Not supported") + default: return nil + } + } + + // MARK: - Validation Errors (-250 to -279) + + private static func mapValidationError(_ result: rac_result_t) -> (ErrorCode, String)? { + switch result { + case RAC_ERROR_VALIDATION_FAILED: return (.validationFailed, "Validation failed") + case RAC_ERROR_INVALID_INPUT: return (.invalidInput, "Invalid input") + case RAC_ERROR_INVALID_FORMAT: return (.invalidFormat, "Invalid format") + case RAC_ERROR_EMPTY_INPUT: return (.emptyInput, "Empty input") + case RAC_ERROR_TEXT_TOO_LONG: return (.textTooLong, "Text too long") + case RAC_ERROR_INVALID_SSML: return (.invalidSSML, "Invalid SSML") + case RAC_ERROR_INVALID_SPEAKING_RATE: return (.invalidSpeakingRate, "Invalid speaking rate") + case RAC_ERROR_INVALID_PITCH: return (.invalidPitch, "Invalid pitch") + case RAC_ERROR_INVALID_VOLUME: return (.invalidVolume, "Invalid volume") + case RAC_ERROR_INVALID_ARGUMENT: return (.invalidInput, "Invalid argument") + case RAC_ERROR_NULL_POINTER: return (.invalidInput, "Null pointer") + case RAC_ERROR_BUFFER_TOO_SMALL: return (.invalidInput, "Buffer too small") + default: return nil + } + } + + // MARK: - Audio Errors (-280 to -299) + + private static func mapAudioError(_ result: rac_result_t) -> (ErrorCode, String)? { + switch result { + case RAC_ERROR_AUDIO_FORMAT_NOT_SUPPORTED: return (.audioFormatNotSupported, "Audio format not supported") + case RAC_ERROR_AUDIO_SESSION_FAILED: return (.audioSessionFailed, "Audio session failed") + case RAC_ERROR_MICROPHONE_PERMISSION_DENIED: return (.microphonePermissionDenied, "Microphone permission denied") + case RAC_ERROR_INSUFFICIENT_AUDIO_DATA: return (.insufficientAudioData, "Insufficient audio data") + case RAC_ERROR_EMPTY_AUDIO_BUFFER: return (.emptyAudioBuffer, "Empty audio buffer") + case RAC_ERROR_AUDIO_SESSION_ACTIVATION_FAILED: return (.audioSessionActivationFailed, "Audio session activation failed") + default: return nil + } + } + + // MARK: - Language/Voice Errors (-300 to -319) + + private static func mapLanguageVoiceError(_ result: rac_result_t) -> (ErrorCode, String)? { + switch result { + case RAC_ERROR_LANGUAGE_NOT_SUPPORTED: return (.languageNotSupported, "Language not supported") + case RAC_ERROR_VOICE_NOT_AVAILABLE: return (.voiceNotAvailable, "Voice not available") + case RAC_ERROR_STREAMING_NOT_SUPPORTED: return (.streamingNotSupported, "Streaming not supported") + case RAC_ERROR_STREAM_CANCELLED: return (.streamCancelled, "Stream cancelled") + default: return nil + } + } + + // MARK: - Authentication Errors (-320 to -329) + + private static func mapAuthenticationError(_ result: rac_result_t) -> (ErrorCode, String)? { + switch result { + case RAC_ERROR_AUTHENTICATION_FAILED: return (.authenticationFailed, "Authentication failed") + case RAC_ERROR_UNAUTHORIZED: return (.unauthorized, "Unauthorized") + case RAC_ERROR_FORBIDDEN: return (.forbidden, "Forbidden") + default: return nil + } + } + + // MARK: - Security Errors (-330 to -349) + + private static func mapSecurityError(_ result: rac_result_t) -> (ErrorCode, String)? { + switch result { + case RAC_ERROR_KEYCHAIN_ERROR: return (.keychainError, "Keychain error") + case RAC_ERROR_ENCODING_ERROR: return (.encodingError, "Encoding error") + case RAC_ERROR_DECODING_ERROR: return (.decodingError, "Decoding error") + case RAC_ERROR_SECURE_STORAGE_FAILED: return (.keychainError, "Secure storage failed") + default: return nil + } + } + + // MARK: - Extraction Errors (-350 to -369) + + private static func mapExtractionError(_ result: rac_result_t) -> (ErrorCode, String)? { + switch result { + case RAC_ERROR_EXTRACTION_FAILED: return (.extractionFailed, "Extraction failed") + case RAC_ERROR_CHECKSUM_MISMATCH: return (.checksumMismatch, "Checksum mismatch") + case RAC_ERROR_UNSUPPORTED_ARCHIVE: return (.unsupportedArchive, "Unsupported archive") + default: return nil + } + } + + // MARK: - Calibration Errors (-370 to -379) + + private static func mapCalibrationError(_ result: rac_result_t) -> (ErrorCode, String)? { + switch result { + case RAC_ERROR_CALIBRATION_FAILED: return (.calibrationFailed, "Calibration failed") + case RAC_ERROR_CALIBRATION_TIMEOUT: return (.calibrationTimeout, "Calibration timed out") + default: return nil + } + } + + // MARK: - Cancellation (-380 to -389) + + private static func mapCancellationError(_ result: rac_result_t) -> (ErrorCode, String)? { + switch result { + case RAC_ERROR_CANCELLED: return (.cancelled, "Operation cancelled") + default: return nil + } + } + + // MARK: - Module/Service Errors (-400 to -499) + + private static func mapModuleServiceError(_ result: rac_result_t) -> (ErrorCode, String)? { + switch result { + case RAC_ERROR_MODULE_NOT_FOUND: return (.frameworkNotAvailable, "Module not found") + case RAC_ERROR_MODULE_ALREADY_REGISTERED: return (.alreadyInitialized, "Module already registered") + case RAC_ERROR_MODULE_LOAD_FAILED: return (.initializationFailed, "Module load failed") + case RAC_ERROR_SERVICE_NOT_FOUND: return (.serviceNotAvailable, "Service not found") + case RAC_ERROR_SERVICE_ALREADY_REGISTERED: return (.alreadyInitialized, "Service already registered") + case RAC_ERROR_SERVICE_CREATE_FAILED: return (.initializationFailed, "Service creation failed") + case RAC_ERROR_CAPABILITY_NOT_FOUND: return (.featureNotAvailable, "Capability not found") + case RAC_ERROR_PROVIDER_NOT_FOUND: return (.serviceNotAvailable, "Provider not found") + case RAC_ERROR_NO_CAPABLE_PROVIDER: return (.serviceNotAvailable, "No capable provider") + case RAC_ERROR_NOT_FOUND: return (.modelNotFound, "Not found") + default: return nil + } + } + + // MARK: - Platform Adapter Errors (-500 to -599) + + private static func mapPlatformAdapterError(_ result: rac_result_t) -> (ErrorCode, String)? { + switch result { + case RAC_ERROR_ADAPTER_NOT_SET: return (.notInitialized, "Platform adapter not set") + default: return nil + } + } + + // MARK: - Backend Errors (-600 to -699) + + private static func mapBackendError(_ result: rac_result_t) -> (ErrorCode, String)? { + switch result { + case RAC_ERROR_BACKEND_NOT_FOUND: return (.frameworkNotAvailable, "Backend not found") + case RAC_ERROR_BACKEND_NOT_READY: return (.componentNotReady, "Backend not ready") + case RAC_ERROR_BACKEND_INIT_FAILED: return (.initializationFailed, "Backend initialization failed") + case RAC_ERROR_BACKEND_BUSY: return (.serviceBusy, "Backend busy") + case RAC_ERROR_INVALID_HANDLE: return (.invalidState, "Invalid handle") + default: return nil + } + } + + // MARK: - Event Errors (-700 to -799) + + private static func mapEventError(_ result: rac_result_t) -> (ErrorCode, String)? { + switch result { + case RAC_ERROR_EVENT_INVALID_CATEGORY: return (.invalidInput, "Invalid event category") + case RAC_ERROR_EVENT_SUBSCRIPTION_FAILED: return (.unknown, "Event subscription failed") + case RAC_ERROR_EVENT_PUBLISH_FAILED: return (.unknown, "Event publish failed") + default: return nil + } + } + + // MARK: - Other Errors (-800 to -899) + + private static func mapOtherError(_ result: rac_result_t) -> (ErrorCode, String)? { + switch result { + case RAC_ERROR_NOT_IMPLEMENTED: return (.notImplemented, "Not implemented") + case RAC_ERROR_FEATURE_NOT_AVAILABLE: return (.featureNotAvailable, "Feature not available") + case RAC_ERROR_FRAMEWORK_NOT_AVAILABLE: return (.frameworkNotAvailable, "Framework not available") + case RAC_ERROR_UNSUPPORTED_MODALITY: return (.unsupportedModality, "Unsupported modality") + case RAC_ERROR_UNKNOWN: return (.unknown, "Unknown error") + case RAC_ERROR_INTERNAL: return (.unknown, "Internal error") + default: return nil + } + } + + // MARK: - Swift to C++ + + // Converts an SDKError to rac_result_t for passing errors back to C++. + // Parameter error: The SDK error + // Returns: Corresponding rac_result_t code + // swiftlint:disable:next cyclomatic_complexity function_body_length + public static func fromSDKError(_ error: SDKError) -> rac_result_t { + switch error.code { + // Initialization + case .notInitialized: return RAC_ERROR_NOT_INITIALIZED + case .alreadyInitialized: return RAC_ERROR_ALREADY_INITIALIZED + case .initializationFailed: return RAC_ERROR_INITIALIZATION_FAILED + case .invalidConfiguration: return RAC_ERROR_INVALID_CONFIGURATION + case .invalidAPIKey: return RAC_ERROR_INVALID_API_KEY + case .environmentMismatch: return RAC_ERROR_ENVIRONMENT_MISMATCH + + // Model + case .modelNotFound: return RAC_ERROR_MODEL_NOT_FOUND + case .modelLoadFailed: return RAC_ERROR_MODEL_LOAD_FAILED + case .modelValidationFailed: return RAC_ERROR_MODEL_VALIDATION_FAILED + case .modelIncompatible: return RAC_ERROR_MODEL_INCOMPATIBLE + case .invalidModelFormat: return RAC_ERROR_INVALID_MODEL_FORMAT + case .modelStorageCorrupted: return RAC_ERROR_MODEL_STORAGE_CORRUPTED + + // Generation + case .generationFailed: return RAC_ERROR_GENERATION_FAILED + case .generationTimeout: return RAC_ERROR_GENERATION_TIMEOUT + case .contextTooLong: return RAC_ERROR_CONTEXT_TOO_LONG + case .tokenLimitExceeded: return RAC_ERROR_TOKEN_LIMIT_EXCEEDED + case .costLimitExceeded: return RAC_ERROR_COST_LIMIT_EXCEEDED + + // Network + case .networkUnavailable: return RAC_ERROR_NETWORK_UNAVAILABLE + case .networkError: return RAC_ERROR_NETWORK_ERROR + case .requestFailed: return RAC_ERROR_REQUEST_FAILED + case .downloadFailed: return RAC_ERROR_DOWNLOAD_FAILED + case .serverError: return RAC_ERROR_SERVER_ERROR + case .timeout: return RAC_ERROR_TIMEOUT + case .invalidResponse: return RAC_ERROR_INVALID_RESPONSE + case .httpError: return RAC_ERROR_HTTP_ERROR + case .connectionLost: return RAC_ERROR_CONNECTION_LOST + case .partialDownload: return RAC_ERROR_PARTIAL_DOWNLOAD + + // Storage + case .insufficientStorage: return RAC_ERROR_INSUFFICIENT_STORAGE + case .storageFull: return RAC_ERROR_STORAGE_FULL + case .storageError: return RAC_ERROR_STORAGE_ERROR + case .fileNotFound: return RAC_ERROR_FILE_NOT_FOUND + case .fileReadFailed: return RAC_ERROR_FILE_READ_FAILED + case .fileWriteFailed: return RAC_ERROR_FILE_WRITE_FAILED + case .permissionDenied: return RAC_ERROR_PERMISSION_DENIED + case .deleteFailed: return RAC_ERROR_DELETE_FAILED + case .moveFailed: return RAC_ERROR_MOVE_FAILED + case .directoryCreationFailed: return RAC_ERROR_DIRECTORY_CREATION_FAILED + case .directoryNotFound: return RAC_ERROR_DIRECTORY_NOT_FOUND + case .invalidPath: return RAC_ERROR_INVALID_PATH + case .invalidFileName: return RAC_ERROR_INVALID_FILE_NAME + case .tempFileCreationFailed: return RAC_ERROR_TEMP_FILE_CREATION_FAILED + + // Hardware + case .hardwareUnsupported: return RAC_ERROR_HARDWARE_UNSUPPORTED + case .insufficientMemory: return RAC_ERROR_INSUFFICIENT_MEMORY + + // Component State + case .componentNotReady: return RAC_ERROR_COMPONENT_NOT_READY + case .invalidState: return RAC_ERROR_INVALID_STATE + case .serviceNotAvailable: return RAC_ERROR_SERVICE_NOT_AVAILABLE + case .serviceBusy: return RAC_ERROR_SERVICE_BUSY + case .processingFailed: return RAC_ERROR_PROCESSING_FAILED + case .startFailed: return RAC_ERROR_START_FAILED + case .notSupported: return RAC_ERROR_NOT_SUPPORTED + + // Validation + case .validationFailed: return RAC_ERROR_VALIDATION_FAILED + case .invalidInput: return RAC_ERROR_INVALID_INPUT + case .invalidFormat: return RAC_ERROR_INVALID_FORMAT + case .emptyInput: return RAC_ERROR_EMPTY_INPUT + case .textTooLong: return RAC_ERROR_TEXT_TOO_LONG + case .invalidSSML: return RAC_ERROR_INVALID_SSML + case .invalidSpeakingRate: return RAC_ERROR_INVALID_SPEAKING_RATE + case .invalidPitch: return RAC_ERROR_INVALID_PITCH + case .invalidVolume: return RAC_ERROR_INVALID_VOLUME + + // Audio + case .audioFormatNotSupported: return RAC_ERROR_AUDIO_FORMAT_NOT_SUPPORTED + case .audioSessionFailed: return RAC_ERROR_AUDIO_SESSION_FAILED + case .microphonePermissionDenied: return RAC_ERROR_MICROPHONE_PERMISSION_DENIED + case .insufficientAudioData: return RAC_ERROR_INSUFFICIENT_AUDIO_DATA + case .emptyAudioBuffer: return RAC_ERROR_EMPTY_AUDIO_BUFFER + case .audioSessionActivationFailed: return RAC_ERROR_AUDIO_SESSION_ACTIVATION_FAILED + + // Language/Voice + case .languageNotSupported: return RAC_ERROR_LANGUAGE_NOT_SUPPORTED + case .voiceNotAvailable: return RAC_ERROR_VOICE_NOT_AVAILABLE + case .streamingNotSupported: return RAC_ERROR_STREAMING_NOT_SUPPORTED + case .streamCancelled: return RAC_ERROR_STREAM_CANCELLED + + // Authentication + case .authenticationFailed: return RAC_ERROR_AUTHENTICATION_FAILED + case .unauthorized: return RAC_ERROR_UNAUTHORIZED + case .forbidden: return RAC_ERROR_FORBIDDEN + + // Security + case .keychainError: return RAC_ERROR_KEYCHAIN_ERROR + case .encodingError: return RAC_ERROR_ENCODING_ERROR + case .decodingError: return RAC_ERROR_DECODING_ERROR + + // Extraction + case .extractionFailed: return RAC_ERROR_EXTRACTION_FAILED + case .checksumMismatch: return RAC_ERROR_CHECKSUM_MISMATCH + case .unsupportedArchive: return RAC_ERROR_UNSUPPORTED_ARCHIVE + + // Calibration + case .calibrationFailed: return RAC_ERROR_CALIBRATION_FAILED + case .calibrationTimeout: return RAC_ERROR_CALIBRATION_TIMEOUT + + // Cancellation + case .cancelled: return RAC_ERROR_CANCELLED + + // Other + case .notImplemented: return RAC_ERROR_NOT_IMPLEMENTED + case .featureNotAvailable: return RAC_ERROR_FEATURE_NOT_AVAILABLE + case .frameworkNotAvailable: return RAC_ERROR_FRAMEWORK_NOT_AVAILABLE + case .unsupportedModality: return RAC_ERROR_UNSUPPORTED_MODALITY + case .unknown: return RAC_ERROR_UNKNOWN + } + } + + // MARK: - Utility Methods + + /// Throws an SDKError if the result indicates failure. + /// + /// - Parameter result: The rac_result_t to check + /// - Throws: SDKError if result != RAC_SUCCESS + public static func throwIfError(_ result: rac_result_t) throws { + if let error = toSDKError(result) { + throw error + } + } + + /// Maps a C error code to an SDKError. + /// Always returns a non-nil error (even for RAC_SUCCESS, returns a generic success error). + /// + /// - Parameter result: The C error code + /// - Returns: The corresponding SDKError + public static func mapCommonsError(_ result: rac_result_t) -> SDKError { + let (errorCode, errorMessage) = mapErrorCode(result) + return SDKError.general(errorCode, errorMessage) + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Errors/ErrorCategory.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Errors/ErrorCategory.swift new file mode 100644 index 000000000..35baf045e --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Errors/ErrorCategory.swift @@ -0,0 +1,56 @@ +// +// ErrorCategory.swift +// RunAnywhere +// +// Created by RunAnywhere on 2024. +// + +import Foundation + +/// Category of the error indicating which component/modality it belongs to. +public enum ErrorCategory: String, Sendable, CaseIterable { + /// General SDK errors not specific to any component + case general + + /// Speech-to-Text component errors + case stt + + /// Text-to-Speech component errors + case tts + + /// Large Language Model component errors + case llm + + /// Voice Activity Detection component errors + case vad + + /// Vision Language Model component errors + case vlm + + /// Speaker Diarization component errors + case speakerDiarization + + /// Wake Word detection component errors + case wakeWord + + /// Voice Agent component errors + case voiceAgent + + /// Model download and management errors + case download + + /// File system and storage errors + case fileManagement + + /// Network and API communication errors + case network + + /// Authentication and authorization errors + case authentication + + /// Security and keychain errors + case security + + /// ONNX and other runtime errors + case runtime +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Errors/ErrorCode.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Errors/ErrorCode.swift new file mode 100644 index 000000000..08b1fc253 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Errors/ErrorCode.swift @@ -0,0 +1,318 @@ +// +// ErrorCode.swift +// RunAnywhere +// +// Created by RunAnywhere on 2024. +// + +import Foundation + +/// All possible error codes in the SDK. +/// The code serves as a unique identifier for each error type. +public enum ErrorCode: String, Sendable, CaseIterable { + // MARK: - Initialization Errors + + /// Component or service has not been initialized + case notInitialized + + /// Component or service is already initialized + case alreadyInitialized + + /// Initialization failed + case initializationFailed + + /// Configuration is invalid + case invalidConfiguration + + /// API key is invalid or missing + case invalidAPIKey + + /// Environment mismatch (e.g., dev vs prod) + case environmentMismatch + + // MARK: - Model Errors + + /// Requested model was not found + case modelNotFound + + /// Failed to load the model + case modelLoadFailed + + /// Model validation failed + case modelValidationFailed + + /// Model is incompatible with current runtime + case modelIncompatible + + /// Model format is invalid + case invalidModelFormat + + /// Model storage is corrupted + case modelStorageCorrupted + + // MARK: - Generation Errors + + /// Text/audio generation failed + case generationFailed + + /// Generation timed out + case generationTimeout + + /// Context length exceeded maximum + case contextTooLong + + /// Token limit exceeded + case tokenLimitExceeded + + /// Cost limit exceeded + case costLimitExceeded + + // MARK: - Network Errors + + /// Network is unavailable + case networkUnavailable + + /// Generic network error + case networkError + + /// Request failed + case requestFailed + + /// Download failed + case downloadFailed + + /// Server returned an error + case serverError + + /// Request timed out + case timeout + + /// Invalid response from server + case invalidResponse + + /// HTTP error with status code + case httpError + + /// Connection was lost + case connectionLost + + /// Partial download (incomplete) + case partialDownload + + // MARK: - Storage Errors + + /// Insufficient storage space + case insufficientStorage + + /// Storage is full + case storageFull + + /// Generic storage error + case storageError + + /// File was not found + case fileNotFound + + /// Failed to read file + case fileReadFailed + + /// Failed to write file + case fileWriteFailed + + /// Permission denied for file operation + case permissionDenied + + /// Failed to delete file or directory + case deleteFailed + + /// Failed to move file + case moveFailed + + /// Failed to create directory + case directoryCreationFailed + + /// Directory not found + case directoryNotFound + + /// Invalid file path + case invalidPath + + /// Invalid file name + case invalidFileName + + /// Failed to create temporary file + case tempFileCreationFailed + + // MARK: - Hardware Errors + + /// Hardware is unsupported + case hardwareUnsupported + + /// Insufficient memory + case insufficientMemory + + // MARK: - Component State Errors + + /// Component is not ready + case componentNotReady + + /// Component is in invalid state + case invalidState + + /// Service is not available + case serviceNotAvailable + + /// Service is busy + case serviceBusy + + /// Processing failed + case processingFailed + + /// Start operation failed + case startFailed + + /// Feature/operation is not supported + case notSupported + + // MARK: - Validation Errors + + /// Validation failed + case validationFailed + + /// Input is invalid + case invalidInput + + /// Format is invalid + case invalidFormat + + /// Input is empty + case emptyInput + + /// Text is too long + case textTooLong + + /// Invalid SSML markup + case invalidSSML + + /// Invalid speaking rate + case invalidSpeakingRate + + /// Invalid pitch + case invalidPitch + + /// Invalid volume + case invalidVolume + + // MARK: - Audio Errors + + /// Audio format is not supported + case audioFormatNotSupported + + /// Audio session configuration failed + case audioSessionFailed + + /// Microphone permission denied + case microphonePermissionDenied + + /// Insufficient audio data + case insufficientAudioData + + /// Audio buffer is empty + case emptyAudioBuffer + + /// Audio session activation failed + case audioSessionActivationFailed + + // MARK: - Language/Voice Errors + + /// Language is not supported + case languageNotSupported + + /// Voice is not available + case voiceNotAvailable + + /// Streaming is not supported + case streamingNotSupported + + /// Stream was cancelled + case streamCancelled + + // MARK: - Authentication Errors + + /// Authentication failed + case authenticationFailed + + /// Unauthorized access + case unauthorized + + /// Access forbidden + case forbidden + + // MARK: - Security Errors + + /// Keychain operation failed + case keychainError + + /// Encoding error + case encodingError + + /// Decoding error + case decodingError + + // MARK: - Extraction Errors + + /// Extraction failed (JSON, archive, etc.) + case extractionFailed + + /// Checksum mismatch + case checksumMismatch + + /// Unsupported archive format + case unsupportedArchive + + // MARK: - Calibration Errors + + /// Calibration failed + case calibrationFailed + + /// Calibration timed out + case calibrationTimeout + + // MARK: - Cancellation + + /// Operation was cancelled + case cancelled + + // MARK: - Other Errors + + /// Feature is not implemented + case notImplemented + + /// Feature is not available + case featureNotAvailable + + /// Framework is not available + case frameworkNotAvailable + + /// Unsupported modality + case unsupportedModality + + /// Unknown error + case unknown +} + +// MARK: - Error Classification + +extension ErrorCode { + + /// Whether this error is expected/routine and shouldn't be logged as an error. + /// Examples: user cancellation, stream cancellation + public var isExpected: Bool { + switch self { + case .cancelled, .streamCancelled: + return true + default: + return false + } + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Errors/SDKError.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Errors/SDKError.swift new file mode 100644 index 000000000..4457d3b54 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Errors/SDKError.swift @@ -0,0 +1,488 @@ +// +// SDKError.swift +// RunAnywhere +// +// Created by RunAnywhere on 2024. +// + +import Foundation + +/// The unified error type for the RunAnywhere SDK. +/// +/// All errors in the SDK are represented by this type, providing consistent +/// error handling across all components and features. +/// +/// Errors are automatically logged to configured destinations (Sentry, console) +/// when created via factory methods. +/// +/// Example usage: +/// ```swift +/// throw SDKError.stt(.modelNotFound, "Whisper model not found at path") +/// throw SDKError.network(.timeout, "Request timed out after 30 seconds") +/// ``` +public struct SDKError: Error, LocalizedError, Sendable, CustomStringConvertible { + + // MARK: - Properties + + /// The specific error code identifying what went wrong + public let code: ErrorCode + + /// Human-readable message with context about the error + public let message: String + + /// The component/modality category this error belongs to + public let category: ErrorCategory + + /// Stack trace captured at the time of error creation + public let stackTrace: [String] + + /// The underlying error that caused this error, if any + public let underlyingError: (any Error)? + + // MARK: - Initialization + + /// Creates a new SDKError with all properties. + /// + /// Prefer using the factory methods (e.g., `SDKError.stt()`, `SDKError.llm()`) + /// which automatically capture the stack trace and log the error. + public init( + code: ErrorCode, + message: String, + category: ErrorCategory, + stackTrace: [String], + underlyingError: (any Error)? = nil + ) { + self.code = code + self.message = message + self.category = category + self.stackTrace = stackTrace + self.underlyingError = underlyingError + } + + // MARK: - LocalizedError + + public var errorDescription: String? { + message + } + + public var failureReason: String? { + "[\(category.rawValue.uppercased())] \(code.rawValue)" + } + + public var recoverySuggestion: String? { + switch code { + case .notInitialized: + return "Initialize the component before using it." + case .modelNotFound: + return "Ensure the model is downloaded and the path is correct." + case .networkUnavailable: + return "Check your internet connection and try again." + case .insufficientStorage: + return "Free up storage space and try again." + case .insufficientMemory: + return "Close other applications to free up memory." + case .microphonePermissionDenied: + return "Grant microphone permission in Settings." + case .timeout: + return "Try again or check your connection." + case .invalidAPIKey: + return "Verify your API key is correct." + case .cancelled: + return nil + default: + return nil + } + } + + // MARK: - CustomStringConvertible + + public var description: String { + var result = "SDKError[\(category.rawValue).\(code.rawValue)]: \(message)" + if let underlying = underlyingError { + result += "\n Caused by: \(underlying)" + } + return result + } + + // MARK: - Debug Helpers + + /// Returns a detailed debug description including stack trace + public var debugDescription: String { + var result = description + if !stackTrace.isEmpty { + result += "\n Stack trace:\n" + for frame in stackTrace.prefix(10) { + result += " \(frame)\n" + } + if stackTrace.count > 10 { + result += " ... and \(stackTrace.count - 10) more frames\n" + } + } + return result + } + + /// Returns a condensed stack trace with only SDK frames + public var sdkStackTrace: [String] { + stackTrace.filter { $0.contains("RunAnywhere") } + } +} + +// MARK: - Factory Methods + +extension SDKError { + + /// Creates an SDKError with automatic stack trace capture and logging. + /// + /// - Parameters: + /// - code: The error code + /// - message: Human-readable error message + /// - category: The error category + /// - underlyingError: Optional underlying error + /// - shouldLog: Whether to automatically log this error (default: true) + /// - Returns: A new SDKError instance + public static func make( + code: ErrorCode, + message: String, + category: ErrorCategory, + underlyingError: (any Error)? = nil, + shouldLog: Bool = true + ) -> SDKError { + let error = SDKError( + code: code, + message: message, + category: category, + stackTrace: Thread.callStackSymbols, + underlyingError: underlyingError + ) + + // Automatically log the error unless it's expected (cancelled, etc.) + if shouldLog && !code.isExpected { + error.log() + } + + return error + } + + // MARK: - Category-Specific Factories + + /// Creates a general SDK error. + public static func general( + _ code: ErrorCode, + _ message: String, + underlying: (any Error)? = nil + ) -> SDKError { + make(code: code, message: message, category: .general, underlyingError: underlying) + } + + /// Creates a Speech-to-Text error. + public static func stt( + _ code: ErrorCode, + _ message: String, + underlying: (any Error)? = nil + ) -> SDKError { + make(code: code, message: message, category: .stt, underlyingError: underlying) + } + + /// Creates a Text-to-Speech error. + public static func tts( + _ code: ErrorCode, + _ message: String, + underlying: (any Error)? = nil + ) -> SDKError { + make(code: code, message: message, category: .tts, underlyingError: underlying) + } + + /// Creates an LLM error. + public static func llm( + _ code: ErrorCode, + _ message: String, + underlying: (any Error)? = nil + ) -> SDKError { + make(code: code, message: message, category: .llm, underlyingError: underlying) + } + + /// Creates a VAD error. + public static func vad( + _ code: ErrorCode, + _ message: String, + underlying: (any Error)? = nil + ) -> SDKError { + make(code: code, message: message, category: .vad, underlyingError: underlying) + } + + /// Creates a VLM error. + public static func vlm( + _ code: ErrorCode, + _ message: String, + underlying: (any Error)? = nil + ) -> SDKError { + make(code: code, message: message, category: .vlm, underlyingError: underlying) + } + + /// Creates a Speaker Diarization error. + public static func speakerDiarization( + _ code: ErrorCode, + _ message: String, + underlying: (any Error)? = nil + ) -> SDKError { + make(code: code, message: message, category: .speakerDiarization, underlyingError: underlying) + } + + /// Creates a Wake Word error. + public static func wakeWord( + _ code: ErrorCode, + _ message: String, + underlying: (any Error)? = nil + ) -> SDKError { + make(code: code, message: message, category: .wakeWord, underlyingError: underlying) + } + + /// Creates a Voice Agent error. + public static func voiceAgent( + _ code: ErrorCode, + _ message: String, + underlying: (any Error)? = nil + ) -> SDKError { + make(code: code, message: message, category: .voiceAgent, underlyingError: underlying) + } + + /// Creates a download error. + public static func download( + _ code: ErrorCode, + _ message: String, + underlying: (any Error)? = nil + ) -> SDKError { + make(code: code, message: message, category: .download, underlyingError: underlying) + } + + /// Creates a file management error. + public static func fileManagement( + _ code: ErrorCode, + _ message: String, + underlying: (any Error)? = nil + ) -> SDKError { + make(code: code, message: message, category: .fileManagement, underlyingError: underlying) + } + + /// Creates a network error. + public static func network( + _ code: ErrorCode, + _ message: String, + underlying: (any Error)? = nil + ) -> SDKError { + make(code: code, message: message, category: .network, underlyingError: underlying) + } + + /// Creates an authentication error. + public static func authentication( + _ code: ErrorCode, + _ message: String, + underlying: (any Error)? = nil + ) -> SDKError { + make(code: code, message: message, category: .authentication, underlyingError: underlying) + } + + /// Creates a security error. + public static func security( + _ code: ErrorCode, + _ message: String, + underlying: (any Error)? = nil + ) -> SDKError { + make(code: code, message: message, category: .security, underlyingError: underlying) + } + + /// Creates a runtime error. + public static func runtime( + _ code: ErrorCode, + _ message: String, + underlying: (any Error)? = nil + ) -> SDKError { + make(code: code, message: message, category: .runtime, underlyingError: underlying) + } +} + +// MARK: - Error Logging + +extension SDKError { + + /// Log this error to all configured destinations. + /// + /// Called automatically by factory methods for unexpected errors. + /// Can be called manually for errors created via init. + public func log(file: String = #file, line: Int = #line, function: String = #function) { + let level: LogLevel = (code == .cancelled) ? .info : .error + let fileName = (file as NSString).lastPathComponent + + var metadata: [String: Any] = [ // swiftlint:disable:this prefer_concrete_types avoid_any_type + "error_code": code.rawValue, + "error_category": category.rawValue, + "source_file": fileName, + "source_line": line, + "source_function": function + ] + + if let underlying = underlyingError { + metadata["underlying_error"] = String(describing: underlying) + } + + if let reason = failureReason { + metadata["failure_reason"] = reason + } + + // Include condensed SDK stack trace + let sdkFrames = sdkStackTrace.prefix(5) + if !sdkFrames.isEmpty { + metadata["stack_trace"] = sdkFrames.joined(separator: "\n") + } + + Logging.shared.log( + level: level, + category: category.rawValue, + message: message, + metadata: metadata + ) + } +} + +// MARK: - Error Conversion + +extension SDKError { + + /// Converts any Error to an SDKError. + /// + /// If the error is already an SDKError, returns it as-is. + /// Otherwise, wraps it as an unknown general error. + public static func from(_ error: any Error, category: ErrorCategory = .general) -> SDKError { + if let sdkError = error as? SDKError { + return sdkError + } + + let nsError = error as NSError + + // Handle common system errors + if nsError.domain == NSURLErrorDomain { + return fromURLError(nsError, category: category) + } + + return make( + code: .unknown, + message: error.localizedDescription, + category: category, + underlyingError: error + ) + } + + /// Converts an optional Error to an SDKError. + /// + /// If the error is nil, returns a generic "Unknown error" SDKError. + /// Otherwise, delegates to `from(_:category:)`. + public static func from(_ error: (any Error)?, category: ErrorCategory = .general) -> SDKError { + guard let error = error else { + return make( + code: .unknown, + message: "Unknown error", + category: category, + underlyingError: nil + ) + } + return from(error, category: category) + } + + private static func fromURLError(_ nsError: NSError, category: ErrorCategory) -> SDKError { + let code: ErrorCode + switch nsError.code { + case NSURLErrorNotConnectedToInternet, NSURLErrorNetworkConnectionLost: + code = .networkUnavailable + case NSURLErrorTimedOut: + code = .timeout + case NSURLErrorCancelled: + code = .cancelled + case NSURLErrorCannotFindHost, NSURLErrorCannotConnectToHost: + code = .networkError + default: + code = .networkError + } + + return make( + code: code, + message: nsError.localizedDescription, + category: category, + underlyingError: nsError + ) + } +} + +// MARK: - Equatable + +extension SDKError: Equatable { + public static func == (lhs: SDKError, rhs: SDKError) -> Bool { + lhs.code == rhs.code && + lhs.category == rhs.category && + lhs.message == rhs.message + } +} + +// MARK: - Hashable + +extension SDKError: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(code) + hasher.combine(category) + hasher.combine(message) + } +} + +// MARK: - Telemetry Properties + +extension SDKError { + + /// Lightweight properties for telemetry/analytics events. + /// + /// Use this for event serialization sent to the backend analytics service. + /// Only includes essential fields needed for metrics and dashboards. + /// + /// For full error details (stack traces, underlying errors), use `SDKLogger` + /// which routes to console and Sentry for debugging and error monitoring. + public var telemetryProperties: [String: String] { + [ + "error_code": code.rawValue, + "error_category": category.rawValue, + "error_message": message + ] + } +} + +// MARK: - ONNX Runtime Error Conversion + +extension SDKError { + + /// Convert ONNX Runtime C error code to SDKError + public static func fromONNXCode(_ code: Int32) -> SDKError { + switch code { + case 0: + return runtime(.unknown, "Unexpected success code passed to error handler") + case -1: + return runtime(.initializationFailed, "ONNX Runtime initialization failed") + case -2: + return runtime(.modelLoadFailed, "Failed to load ONNX model") + case -3: + return runtime(.generationFailed, "ONNX inference failed") + case -4: + return runtime(.invalidState, "Invalid ONNX handle") + case -5: + return runtime(.invalidInput, "Invalid ONNX parameters") + case -6: + return runtime(.insufficientMemory, "ONNX Runtime out of memory") + case -7: + return runtime(.notImplemented, "ONNX feature not implemented") + case -8: + return runtime(.cancelled, "ONNX operation cancelled") + case -9: + return runtime(.timeout, "ONNX operation timed out") + case -10: + return runtime(.storageError, "ONNX IO error") + default: + return runtime(.unknown, "ONNX error code: \(code)") + } + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Security/KeychainManager.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Security/KeychainManager.swift new file mode 100644 index 000000000..dbb4d30b7 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Security/KeychainManager.swift @@ -0,0 +1,251 @@ +import Foundation +import Security + +/// Keychain manager for secure storage of sensitive data +public final class KeychainManager { + + // MARK: - Singleton + + public static let shared = KeychainManager() + + // MARK: - Properties + + private let serviceName = "com.runanywhere.sdk" + private let accessGroup: String? = nil // Set if you need app group sharing + private let logger = SDKLogger(category: "KeychainManager") + + // MARK: - Keychain Keys + + private enum KeychainKey: String { + // SDK Core + case apiKey = "com.runanywhere.sdk.apiKey" + case baseURL = "com.runanywhere.sdk.baseURL" + case environment = "com.runanywhere.sdk.environment" + + // Device Identity + case deviceUUID = "com.runanywhere.sdk.device.uuid" + } + + // MARK: - Initialization + + private init() {} + + // MARK: - Public Methods - SDK Credentials + + /// Store SDK initialization parameters securely + /// - Parameter params: SDK initialization parameters + /// - Throws: KeychainError if storage fails + public func storeSDKParams(_ params: SDKInitParams) throws { + // Store API key + try store(params.apiKey, for: KeychainKey.apiKey.rawValue) + + // Store base URL + try store(params.baseURL.absoluteString, for: KeychainKey.baseURL.rawValue) + + // Store environment + try store(params.environment.rawValue, for: KeychainKey.environment.rawValue) + + logger.info("SDK parameters stored securely in keychain") + } + + /// Retrieve stored SDK parameters + /// - Returns: Stored SDK parameters if available + public func retrieveSDKParams() -> SDKInitParams? { + guard let apiKey = try? retrieve(for: KeychainKey.apiKey.rawValue), + let urlString = try? retrieve(for: KeychainKey.baseURL.rawValue), + let url = URL(string: urlString), + let envString = try? retrieve(for: KeychainKey.environment.rawValue), + let environment = SDKEnvironment(rawValue: envString) else { + logger.debug("No stored SDK parameters found in keychain") + return nil + } + + logger.debug("Retrieved SDK parameters from keychain") + return try? SDKInitParams(apiKey: apiKey, baseURL: url, environment: environment) + } + + /// Clear stored SDK parameters + public func clearSDKParams() throws { + try delete(for: KeychainKey.apiKey.rawValue) + try delete(for: KeychainKey.baseURL.rawValue) + try delete(for: KeychainKey.environment.rawValue) + logger.info("SDK parameters cleared from keychain") + } + + // MARK: - Device Identity Methods + + /// Store device UUID + /// - Parameter uuid: Device UUID to store + public func storeDeviceUUID(_ uuid: String) throws { + try store(uuid, for: KeychainKey.deviceUUID.rawValue) + logger.debug("Device UUID stored in keychain") + } + + /// Retrieve device UUID + /// - Returns: Stored device UUID if available + public func retrieveDeviceUUID() -> String? { + return try? retrieve(for: KeychainKey.deviceUUID.rawValue) + } + + // MARK: - Generic Storage Methods + + /// Store a string value in the keychain + /// - Parameters: + /// - value: String value to store + /// - key: Unique key for the value + /// - Throws: SDKError if storage fails + public func store(_ value: String, for key: String) throws { + guard let data = value.data(using: .utf8) else { + throw SDKError.security(.encodingError, "Failed to encode string data for keychain storage") + } + + try store(data, for: key) + } + + /// Store data in the keychain + /// - Parameters: + /// - data: Data to store + /// - key: Unique key for the data + /// - Throws: SDKError if storage fails + public func store(_ data: Data, for key: String) throws { + var query = baseQuery(for: key) + query[kSecValueData as String] = data + + // Try to update first + var status = SecItemUpdate(query as CFDictionary, [kSecValueData as String: data] as CFDictionary) + + // If not found, add new item + if status == errSecItemNotFound { + status = SecItemAdd(query as CFDictionary, nil) + } + + guard status == errSecSuccess else { + throw SDKError.security(.keychainError, "Failed to store item in keychain: OSStatus \(status)") + } + } + + /// Retrieve a string value from the keychain + /// - Parameter key: Key for the value + /// - Returns: Stored string value + /// - Throws: SDKError if retrieval fails + public func retrieve(for key: String) throws -> String { + let data = try retrieveData(for: key) + + guard let string = String(data: data, encoding: .utf8) else { + throw SDKError.security(.decodingError, "Failed to decode string data from keychain") + } + + return string + } + + /// Retrieve data from the keychain + /// - Parameter key: Key for the data + /// - Returns: Stored data + /// - Throws: SDKError if retrieval fails (but not for missing items - use retrieveDataIfExists for that) + public func retrieveData(for key: String) throws -> Data { + var query = baseQuery(for: key) + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + + // swiftlint:disable:next avoid_any_object + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status == errSecSuccess, + let data = result as? Data else { + if status == errSecItemNotFound { + throw SDKError.security(.keychainError, "Item not found in keychain") + } + throw SDKError.security(.keychainError, "Failed to retrieve item from keychain: OSStatus \(status)") + } + + return data + } + + /// Retrieve data from the keychain if it exists (returns nil for missing items, no error thrown) + /// - Parameter key: Key for the data + /// - Returns: Stored data if found, nil if not found + /// - Throws: SDKError only for actual keychain errors (not for missing items) + public func retrieveDataIfExists(for key: String) throws -> Data? { + var query = baseQuery(for: key) + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + + // swiftlint:disable:next avoid_any_object + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + // Item not found is a normal case, return nil + if status == errSecItemNotFound { + return nil + } + + // Other errors are actual problems + guard status == errSecSuccess, + let data = result as? Data else { + throw SDKError.security(.keychainError, "Failed to retrieve item from keychain: OSStatus \(status)") + } + + return data + } + + /// Retrieve a string value from the keychain if it exists (returns nil for missing items, no error thrown) + /// - Parameter key: Key for the value + /// - Returns: Stored string value if found, nil if not found + /// - Throws: SDKError only for actual keychain errors (not for missing items) + public func retrieveIfExists(for key: String) throws -> String? { + guard let data = try retrieveDataIfExists(for: key) else { + return nil + } + + guard let string = String(data: data, encoding: .utf8) else { + throw SDKError.security(.decodingError, "Failed to decode string data from keychain") + } + + return string + } + + /// Delete an item from the keychain + /// - Parameter key: Key for the item to delete + /// - Throws: SDKError if deletion fails + public func delete(for key: String) throws { + let query = baseQuery(for: key) + let status = SecItemDelete(query as CFDictionary) + + guard status == errSecSuccess || status == errSecItemNotFound else { + throw SDKError.security(.keychainError, "Failed to delete item from keychain: OSStatus \(status)") + } + } + + /// Check if an item exists in the keychain + /// - Parameter key: Key to check + /// - Returns: True if item exists + public func exists(for key: String) -> Bool { + var query = baseQuery(for: key) + query[kSecReturnData as String] = false + + let status = SecItemCopyMatching(query as CFDictionary, nil) + return status == errSecSuccess + } + + // MARK: - Private Methods + + private func baseQuery(for key: String) -> [String: Any] { // swiftlint:disable:this prefer_concrete_types avoid_any_type + var query: [String: Any] = [ // swiftlint:disable:this prefer_concrete_types avoid_any_type + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrAccount as String: key, + kSecAttrSynchronizable as String: false // Don't sync to iCloud Keychain + ] + + // Add access group if specified + if let accessGroup = accessGroup { + query[kSecAttrAccessGroup as String] = accessGroup + } + + // Set accessibility - available when unlocked + query[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly + + return query + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Device/Models/Domain/DeviceInfo.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Device/Models/Domain/DeviceInfo.swift new file mode 100644 index 000000000..84422d1a4 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Device/Models/Domain/DeviceInfo.swift @@ -0,0 +1,305 @@ +// +// DeviceInfo.swift +// RunAnywhere SDK +// +// Core device hardware information for telemetry and API requests +// Matches backend schemas/device.py DeviceInfo schema +// + +import Foundation + +#if os(iOS) || os(tvOS) +import UIKit +#elseif os(watchOS) +import WatchKit +#endif + +/// Core device hardware information +/// Matches backend schemas/device.py DeviceInfo schema +public struct DeviceInfo: Codable, Sendable, Equatable { + + // MARK: - Required Fields (backend schema) + + public let deviceModel: String + public let deviceName: String + public let platform: String + public let osVersion: String + public let formFactor: String + public let architecture: String + public let chipName: String + public let totalMemory: Int + public let availableMemory: Int + public let hasNeuralEngine: Bool + public let neuralEngineCores: Int + public let gpuFamily: String + public let batteryLevel: Double? + public let batteryState: String? + public let isLowPowerMode: Bool + public let coreCount: Int + public let performanceCores: Int + public let efficiencyCores: Int + public let deviceFingerprint: String? + + // MARK: - Coding Keys (snake_case for backend) + + enum CodingKeys: String, CodingKey { + case deviceModel = "device_model" + case deviceName = "device_name" + case platform + case osVersion = "os_version" + case formFactor = "form_factor" + case architecture + case chipName = "chip_name" + case totalMemory = "total_memory" + case availableMemory = "available_memory" + case hasNeuralEngine = "has_neural_engine" + case neuralEngineCores = "neural_engine_cores" + case gpuFamily = "gpu_family" + case batteryLevel = "battery_level" + case batteryState = "battery_state" + case isLowPowerMode = "is_low_power_mode" + case coreCount = "core_count" + case performanceCores = "performance_cores" + case efficiencyCores = "efficiency_cores" + case deviceFingerprint = "device_fingerprint" + } + + // MARK: - Computed Properties + + public var cleanOSVersion: String { + // Extract version number from "Version 17.2 (Build 21C52)" -> "17.2" + if let match = osVersion.range(of: #"\d+\.\d+(\.\d+)?"#, options: .regularExpression) { + return String(osVersion[match]) + } + return osVersion + } + + /// Device type derived from form factor (for API compatibility) + public var deviceType: String { + switch formFactor { + case "phone": return "mobile" + case "tablet": return "tablet" + case "laptop", "desktop": return "desktop" + case "tv": return "tv" + case "watch": return "watch" + case "headset": return "vr" + default: return "mobile" + } + } + + /// Alias for backwards compatibility + public var modelName: String { deviceModel } + + /// Alias for backwards compatibility + public var deviceId: String { deviceFingerprint ?? "" } + + // MARK: - Current Device Info + + public static var current: DeviceInfo { + let processInfo = ProcessInfo.processInfo + let coreCount = processInfo.processorCount + + // Get architecture + #if arch(arm64) + let architecture = "arm64" + #elseif arch(x86_64) + let architecture = "x86_64" + #else + let architecture = "unknown" + #endif + + // Get model identifier for chip/model lookup + let modelId = getModelIdentifier() + let chipName = getChipName(for: modelId) + let (perfCores, effCores) = getCoreDistribution(totalCores: coreCount, modelId: modelId) + + // Platform-specific values + #if os(iOS) + let device = UIDevice.current + let resolvedModel = getDeviceModelName(for: modelId) + let deviceModel = resolvedModel ?? device.model + let deviceName = device.name + let platform = "ios" + let formFactor = device.userInterfaceIdiom == .pad ? "tablet" : "phone" + + // Battery info + device.isBatteryMonitoringEnabled = true + let batteryLevel: Double? = device.batteryLevel >= 0 ? Double(device.batteryLevel) : nil + let batteryState: String? = { + switch device.batteryState { + case .charging: return "charging" + case .full: return "full" + case .unplugged: return "unplugged" + default: return nil + } + }() + #elseif os(macOS) + let deviceModel = getDeviceModelName(for: modelId) ?? Host.current().localizedName ?? "Mac" + let deviceName = Host.current().localizedName ?? "Mac" + let platform = "macos" + let formFactor = modelId.contains("MacBook") ? "laptop" : "desktop" + let batteryLevel: Double? = nil + let batteryState: String? = nil + #elseif os(tvOS) + let device = UIDevice.current + let deviceModel = getDeviceModelName(for: modelId) ?? device.model + let deviceName = device.name + let platform = "ios" + let formFactor = "tv" + let batteryLevel: Double? = nil + let batteryState: String? = nil + #elseif os(watchOS) + let device = WKInterfaceDevice.current() + let deviceModel = getDeviceModelName(for: modelId) ?? device.model + let deviceName = device.name + let platform = "ios" + let formFactor = "watch" + let batteryLevel: Double? = nil + let batteryState: String? = nil + #elseif os(visionOS) + let deviceModel = "Apple Vision Pro" + let deviceName = "Vision Pro" + let platform = "ios" + let formFactor = "headset" + let batteryLevel: Double? = nil + let batteryState: String? = nil + #else + let deviceModel = "Unknown" + let deviceName = "Unknown" + let platform = "web" + let formFactor = "unknown" + let batteryLevel: Double? = nil + let batteryState: String? = nil + #endif + + // Get available memory and clean OS version + let availableMemory = getAvailableMemory() + let osVersion = cleanVersion(processInfo.operatingSystemVersionString) + + return DeviceInfo( + deviceModel: deviceModel, + deviceName: deviceName, + platform: platform, + osVersion: osVersion, + formFactor: formFactor, + architecture: architecture, + chipName: chipName, + totalMemory: Int(processInfo.physicalMemory), + availableMemory: availableMemory, + hasNeuralEngine: architecture == "arm64", + neuralEngineCores: architecture == "arm64" ? 16 : 0, + gpuFamily: "apple", + batteryLevel: batteryLevel, + batteryState: batteryState, + isLowPowerMode: processInfo.isLowPowerModeEnabled, + coreCount: coreCount, + performanceCores: perfCores, + efficiencyCores: effCores, + deviceFingerprint: DeviceIdentity.persistentUUID + ) + } + + // MARK: - System Helpers + + private static func getModelIdentifier() -> String { + var size = 0 + #if os(iOS) || os(tvOS) || os(watchOS) || os(visionOS) + sysctlbyname("hw.machine", nil, &size, nil, 0) + var machine = [CChar](repeating: 0, count: size) + sysctlbyname("hw.machine", &machine, &size, nil, 0) + return String(cString: machine) + #elseif os(macOS) + sysctlbyname("hw.model", nil, &size, nil, 0) + var model = [CChar](repeating: 0, count: size) + sysctlbyname("hw.model", &model, &size, nil, 0) + return String(cString: model) + #else + return "Unknown" + #endif + } + + private static func cleanVersion(_ version: String) -> String { + // Extract "17.2" from "Version 17.2 (Build 21C52)" + if let match = version.range(of: #"\d+\.\d+(\.\d+)?"#, options: .regularExpression) { + return String(version[match]) + } + return version + } + + private static func getAvailableMemory() -> Int { + var taskInfo = mach_task_basic_info() + var count = mach_msg_type_number_t(MemoryLayout.size) / 4 + let result = withUnsafeMutablePointer(to: &taskInfo) { + $0.withMemoryRebound(to: integer_t.self, capacity: 1) { + task_info(mach_task_self_, task_flavor_t(MACH_TASK_BASIC_INFO), $0, &count) + } + } + + let totalMemory = Int(ProcessInfo.processInfo.physicalMemory) + if result == KERN_SUCCESS { + return max(0, totalMemory - Int(taskInfo.resident_size)) + } + return totalMemory / 2 + } + + // MARK: - Device Model Lookup (minimal, common devices only) + + private static func getDeviceModelName(for identifier: String) -> String? { + // iPhone 14-17 series (2022-2025) + let models: [String: String] = [ + // iPhone 17 (2025) + "iPhone18,1": "iPhone 17 Pro", "iPhone18,2": "iPhone 17 Pro Max", + "iPhone18,3": "iPhone 17", "iPhone18,4": "iPhone 17 Plus", + // iPhone 16 (2024) + "iPhone17,1": "iPhone 16 Pro", "iPhone17,2": "iPhone 16 Pro Max", + "iPhone17,3": "iPhone 16", "iPhone17,4": "iPhone 16 Plus", + // iPhone 15 (2023) + "iPhone16,1": "iPhone 15 Pro", "iPhone16,2": "iPhone 15 Pro Max", + "iPhone15,4": "iPhone 15", "iPhone15,5": "iPhone 15 Plus", + // iPhone 14 (2022) + "iPhone15,2": "iPhone 14 Pro", "iPhone15,3": "iPhone 14 Pro Max", + "iPhone14,7": "iPhone 14", "iPhone14,8": "iPhone 14 Plus", + // iPad Pro M4 (2024) + "iPad16,3": "iPad Pro 11-inch (M4)", "iPad16,4": "iPad Pro 11-inch (M4)", + "iPad16,5": "iPad Pro 13-inch (M4)", "iPad16,6": "iPad Pro 13-inch (M4)", + // Mac M4 (2024) + "Mac16,1": "MacBook Pro 14-inch (M4)", "Mac16,6": "MacBook Pro 16-inch (M4)", + "Mac16,10": "iMac (M4)", "Mac16,15": "Mac mini (M4)" + ] + return models[identifier] + } + + private static func getChipName(for identifier: String) -> String { + // Map model prefix to chip + if identifier.hasPrefix("iPhone18,") { return "A19 Pro" } + if identifier.hasPrefix("iPhone17,1") || identifier.hasPrefix("iPhone17,2") { return "A18 Pro" } + if identifier.hasPrefix("iPhone17,") { return "A18" } + if identifier.hasPrefix("iPhone16,") { return "A17 Pro" } + if identifier.hasPrefix("iPhone15,2") || identifier.hasPrefix("iPhone15,3") { return "A16 Bionic" } + if identifier.hasPrefix("iPhone15,") { return "A16 Bionic" } + if identifier.hasPrefix("iPhone14,") { return "A15 Bionic" } + if identifier.hasPrefix("iPad16,") || identifier.hasPrefix("Mac16,") { return "M4" } + if identifier.hasPrefix("iPad15,") || identifier.hasPrefix("Mac15,") { return "M3" } + if identifier.hasPrefix("Mac14,") { return "M2" } + + #if arch(arm64) + return "Apple Silicon" + #else + return "Intel" + #endif + } + + private static func getCoreDistribution(totalCores: Int, modelId: String) -> (perf: Int, eff: Int) { + // iPhone: typically 2P + 4E = 6 cores + if modelId.hasPrefix("iPhone") { + return (2, totalCores - 2) + } + // iPad/Mac M-series: typically ~40% performance cores + if modelId.hasPrefix("iPad") || modelId.hasPrefix("Mac") { + let perf = max(2, totalCores * 2 / 5) + return (perf, totalCores - perf) + } + // Default split + return (max(1, totalCores / 3), totalCores - max(1, totalCores / 3)) + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Device/Services/DeviceIdentity.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Device/Services/DeviceIdentity.swift new file mode 100644 index 000000000..27d261d3c --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Device/Services/DeviceIdentity.swift @@ -0,0 +1,90 @@ +// +// DeviceIdentity.swift +// RunAnywhere SDK +// +// Simple utility for device identity management (UUID persistence) +// Uses lock-based synchronization for thread-safe initialization +// + +import Foundation + +#if os(iOS) || os(tvOS) +import UIKit +#endif + +/// Simple utility for device identity management +/// Provides persistent UUID that survives app reinstalls +public enum DeviceIdentity { + + // MARK: - Properties + + private static let logger = SDKLogger(category: "DeviceIdentity") + + /// Lock for thread-safe UUID initialization (read-check-write atomicity) + /// + /// Note: Using NSLock instead of Swift Actor because `persistentUUID` must be + /// synchronously accessible from non-async contexts (telemetry payloads, batch requests). + /// Actors require `await` which would break the synchronous API contract. + /// NSLock provides equivalent thread-safety with synchronous access. + /// Consider migrating to `OSAllocatedUnfairLock` when dropping iOS 15 support. + private static let initLock = NSLock() + + /// Cached UUID to avoid repeated keychain lookups after first access + private static var cachedUUID: String? + + // MARK: - Public API + + /// Get a persistent device UUID that survives app reinstalls + /// Uses keychain for persistence, falls back to vendor ID or generates new UUID + /// Thread-safe: uses lock to ensure atomic read-check-write on first access + public static var persistentUUID: String { + // Fast path: return cached value without locking (safe after initialization) + if let cached = cachedUUID { + return cached + } + + // Slow path: lock and initialize atomically + initLock.lock() + defer { initLock.unlock() } + + // Double-check after acquiring lock (another thread may have initialized) + if let cached = cachedUUID { + return cached + } + + // Strategy 1: Try to get from keychain (survives app reinstalls) + if let persistentUUID = KeychainManager.shared.retrieveDeviceUUID() { + cachedUUID = persistentUUID + return persistentUUID + } + + // Strategy 2: Use Apple's identifierForVendor + if let vendorUUID = vendorUUID { + try? KeychainManager.shared.storeDeviceUUID(vendorUUID) + logger.debug("Stored vendor UUID in keychain") + cachedUUID = vendorUUID + return vendorUUID + } + + // Strategy 3: Generate new UUID + let newUUID = UUID().uuidString + try? KeychainManager.shared.storeDeviceUUID(newUUID) + logger.debug("Generated and stored new device UUID") + cachedUUID = newUUID + return newUUID + } + + /// Get vendor UUID if available (iOS/tvOS only) + private static var vendorUUID: String? { + #if os(iOS) || os(tvOS) + return UIDevice.current.identifierForVendor?.uuidString + #else + return nil + #endif + } + + /// Validate if a device UUID is properly formatted + public static func validateUUID(_ uuid: String) -> Bool { + uuid.count == 36 && uuid.contains("-") + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Download/Models/Configuration/DownloadConfiguration.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Download/Models/Configuration/DownloadConfiguration.swift new file mode 100644 index 000000000..8a031023c --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Download/Models/Configuration/DownloadConfiguration.swift @@ -0,0 +1,37 @@ +import Foundation + +// MARK: - Download Configuration + +/// Configuration for download behavior +public struct DownloadConfiguration: Codable, Sendable { + public var maxConcurrentDownloads: Int + public var retryCount: Int + public var retryDelay: TimeInterval + public var timeout: TimeInterval + public var chunkSize: Int + public var resumeOnFailure: Bool + public var verifyChecksum: Bool + + /// Enable background downloads + public var enableBackgroundDownloads: Bool + + public init( + maxConcurrentDownloads: Int = 3, + retryCount: Int = 3, + retryDelay: TimeInterval = 2.0, + timeout: TimeInterval = 300.0, + chunkSize: Int = 1024 * 1024, // 1MB chunks + resumeOnFailure: Bool = true, + verifyChecksum: Bool = true, + enableBackgroundDownloads: Bool = false + ) { + self.maxConcurrentDownloads = maxConcurrentDownloads + self.retryCount = retryCount + self.retryDelay = retryDelay + self.timeout = timeout + self.chunkSize = chunkSize + self.resumeOnFailure = resumeOnFailure + self.verifyChecksum = verifyChecksum + self.enableBackgroundDownloads = enableBackgroundDownloads + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Download/Models/Output/DownloadProgress.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Download/Models/Output/DownloadProgress.swift new file mode 100644 index 000000000..59ae68b59 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Download/Models/Output/DownloadProgress.swift @@ -0,0 +1,176 @@ +import Foundation + +// MARK: - Download Stage + +/// Current stage in the download pipeline +public enum DownloadStage: Sendable, Equatable { + /// Downloading the file(s) + case downloading + + /// Extracting archive contents + case extracting + + /// Validating downloaded files + case validating + + /// Download and all processing complete + case completed + + /// Display name for UI + public var displayName: String { + switch self { + case .downloading: return "Downloading" + case .extracting: return "Extracting" + case .validating: return "Validating" + case .completed: return "Completed" + } + } + + /// Weight of this stage for overall progress calculation + /// Download: 0-80%, Extraction: 80-95%, Validation: 95-100% + public var progressRange: (start: Double, end: Double) { + switch self { + case .downloading: return (0.0, 0.80) + case .extracting: return (0.80, 0.95) + case .validating: return (0.95, 0.99) + case .completed: return (1.0, 1.0) + } + } +} + +// MARK: - Download Progress + +/// Download progress information with stage awareness +public struct DownloadProgress: Sendable { + /// Current stage of the download pipeline + public let stage: DownloadStage + + /// Bytes downloaded (for download stage) + public let bytesDownloaded: Int64 + + /// Total bytes to download + public let totalBytes: Int64 + + /// Current state (downloading, extracting, failed, etc.) + public let state: DownloadState + + /// Estimated time remaining in seconds + public let estimatedTimeRemaining: TimeInterval? + + /// Download speed in bytes per second + public let speed: Double? + + /// Progress within current stage (0.0 to 1.0) + public let stageProgress: Double + + /// Overall progress across all stages (0.0 to 1.0) + public var overallProgress: Double { + let range = stage.progressRange + return range.start + (stageProgress * (range.end - range.start)) + } + + /// Legacy percentage property (maps to stageProgress for download stage, overallProgress otherwise) + public var percentage: Double { + switch stage { + case .downloading: + return stageProgress + default: + return overallProgress + } + } + + // MARK: - Initializers + + /// Full initializer with all fields + public init( + stage: DownloadStage, + bytesDownloaded: Int64, + totalBytes: Int64, + stageProgress: Double, + speed: Double? = nil, + estimatedTimeRemaining: TimeInterval? = nil, + state: DownloadState + ) { + self.stage = stage + self.bytesDownloaded = bytesDownloaded + self.totalBytes = totalBytes + self.stageProgress = stageProgress + self.speed = speed + self.estimatedTimeRemaining = estimatedTimeRemaining + self.state = state + } + + /// Convenience init for download stage (calculates progress from bytes) + public init( + bytesDownloaded: Int64, + totalBytes: Int64, + state: DownloadState, + speed: Double? = nil, + estimatedTimeRemaining: TimeInterval? = nil + ) { + self.stage = state == .extracting ? .extracting : .downloading + self.bytesDownloaded = bytesDownloaded + self.totalBytes = totalBytes + self.state = state + self.speed = speed + self.estimatedTimeRemaining = estimatedTimeRemaining + self.stageProgress = totalBytes > 0 ? Double(bytesDownloaded) / Double(totalBytes) : 0 + } + + /// Convenience init with explicit percentage + public init( + bytesDownloaded: Int64, + totalBytes: Int64, + percentage: Double, + speed: Double? = nil, + estimatedTimeRemaining: TimeInterval? = nil, + state: DownloadState + ) { + self.stage = state == .extracting ? .extracting : .downloading + self.bytesDownloaded = bytesDownloaded + self.totalBytes = totalBytes + self.stageProgress = percentage + self.speed = speed + self.estimatedTimeRemaining = estimatedTimeRemaining + self.state = state + } + + // MARK: - Factory Methods + + /// Create progress for extraction stage + public static func extraction( + modelId _: String, + progress: Double, + totalBytes: Int64 = 0 + ) -> DownloadProgress { + DownloadProgress( + stage: .extracting, + bytesDownloaded: Int64(progress * Double(totalBytes)), + totalBytes: totalBytes, + stageProgress: progress, + state: .extracting + ) + } + + /// Create completed progress + public static func completed(totalBytes: Int64) -> DownloadProgress { + DownloadProgress( + stage: .completed, + bytesDownloaded: totalBytes, + totalBytes: totalBytes, + stageProgress: 1.0, + state: .completed + ) + } + + /// Create failed progress + public static func failed(_ error: Error, bytesDownloaded: Int64 = 0, totalBytes: Int64 = 0) -> DownloadProgress { + DownloadProgress( + stage: .downloading, + bytesDownloaded: bytesDownloaded, + totalBytes: totalBytes, + stageProgress: 0, + state: .failed(error) + ) + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Download/Models/Output/DownloadState.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Download/Models/Output/DownloadState.swift new file mode 100644 index 000000000..ca401a2e2 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Download/Models/Output/DownloadState.swift @@ -0,0 +1,43 @@ +import Foundation + +/// Download state enumeration +/// Note: @unchecked Sendable because Error protocol is not inherently Sendable +public enum DownloadState: Equatable, @unchecked Sendable { + case pending + case downloading + case extracting + case retrying(attempt: Int) + case completed + case failed(Error) + case cancelled + + // Custom Equatable implementation since Error is not Equatable + public static func == (lhs: DownloadState, rhs: DownloadState) -> Bool { + switch (lhs, rhs) { + case (.pending, .pending), + (.downloading, .downloading), + (.extracting, .extracting), + (.completed, .completed), + (.cancelled, .cancelled): + return true + case (.retrying(let lhsAttempt), .retrying(let rhsAttempt)): + return lhsAttempt == rhsAttempt + case (.failed(let lhsError), .failed(let rhsError)): + // Compare error descriptions since Error is not Equatable + return lhsError.localizedDescription == rhsError.localizedDescription + default: + return false + } + } +} + +// Custom Sendable conformance for Error +extension DownloadState { + /// Thread-safe wrapper for error + public var errorDescription: String? { + if case .failed(let error) = self { + return error.localizedDescription + } + return nil + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Download/Models/Output/DownloadTask.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Download/Models/Output/DownloadTask.swift new file mode 100644 index 000000000..2ef336247 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Download/Models/Output/DownloadTask.swift @@ -0,0 +1,21 @@ +import Foundation + +/// Download task information +public struct DownloadTask { + public let id: String + public let modelId: String + public let progress: AsyncStream + public let result: Task + + public init( + id: String, + modelId: String, + progress: AsyncStream, + result: Task + ) { + self.id = id + self.modelId = modelId + self.progress = progress + self.result = result + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Download/Services/AlamofireDownloadService+Execution.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Download/Services/AlamofireDownloadService+Execution.swift new file mode 100644 index 000000000..f0f83be2e --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Download/Services/AlamofireDownloadService+Execution.swift @@ -0,0 +1,198 @@ +import Alamofire +import Foundation + +// MARK: - Download Execution + +extension AlamofireDownloadService { + + /// Progress logging interval (every 10%) + private static let logProgressIntervalPercent = 10 + /// Public event interval (every 5%) + private static let publicProgressIntervalFraction = 0.05 + + /// Perform the actual download using Alamofire + func performDownload( + url: URL, + destination: URL, + model: ModelInfo, + taskId: String, + progressContinuation: AsyncStream.Continuation + ) async throws -> URL { + let destinationURL = destination + let dest: DownloadRequest.Destination = { _, _ in + return (destinationURL, [.removePreviousFile, .createIntermediateDirectories]) + } + + var lastReportedProgress = -1.0 + let downloadRequest = session.download(url, to: dest) + .downloadProgress { progress in + let downloadProgress = DownloadProgress( + stage: .downloading, + bytesDownloaded: progress.completedUnitCount, + totalBytes: progress.totalUnitCount, + stageProgress: progress.fractionCompleted, + state: .downloading + ) + + // Update C++ bridge with progress + Task { + await CppBridge.Download.shared.updateProgress( + taskId: taskId, + bytesDownloaded: progress.completedUnitCount, + totalBytes: progress.totalUnitCount + ) + } + + // Log progress at defined intervals (local logging only) + let progressPercent = Int(progress.fractionCompleted * 100) + if progressPercent.isMultiple(of: Self.logProgressIntervalPercent) && progressPercent > 0 { + self.logger.debug("Download progress", metadata: [ + "modelId": model.id, + "progress": progressPercent, + "bytesDownloaded": progress.completedUnitCount, + "totalBytes": progress.totalUnitCount + ]) + } + + // Track progress at defined intervals (via C++ for routing to EventBus/telemetry) + let progressValue = progress.fractionCompleted + if progressValue - lastReportedProgress >= Self.publicProgressIntervalFraction { + lastReportedProgress = progressValue + CppBridge.Events.emitDownloadProgress( + modelId: model.id, + progress: progressValue * 100, + bytesDownloaded: progress.completedUnitCount, + totalBytes: progress.totalUnitCount + ) + } + + progressContinuation.yield(downloadProgress) + } + .validate() + + activeDownloadRequests[taskId] = downloadRequest + + return try await withCheckedThrowingContinuation { continuation in + downloadRequest.response { response in + switch response.result { + case .success(let downloadedURL): + if let downloadedURL = downloadedURL { + continuation.resume(returning: downloadedURL) + } else { + let downloadError = SDKError.download(.invalidResponse, "Invalid response - no URL returned") + CppBridge.Events.emitDownloadFailed(modelId: model.id, error: downloadError) + continuation.resume(throwing: downloadError) + } + + case .failure(let error): + let downloadError = self.mapAlamofireError(error) + CppBridge.Events.emitDownloadFailed(modelId: model.id, error: downloadError) + self.logger.error("Download failed", metadata: [ + "modelId": model.id, + "url": url.absoluteString, + "error": downloadError.message, + "statusCode": response.response?.statusCode ?? 0 + ]) + continuation.resume(throwing: downloadError) + } + } + } + } + + /// Perform extraction for archive models (platform-specific - uses SWCompression) + func performExtraction( + archiveURL: URL, + destinationFolder: URL, + model: ModelInfo, + progressContinuation: AsyncStream.Continuation + ) async throws -> URL { + // Determine archive type from model artifact type or infer from archive URL/download URL + let archiveType: ArchiveType + let artifactTypeForExtraction: ModelArtifactType + + if case .archive(let type, let structure, let expectedFiles) = model.artifactType { + archiveType = type + artifactTypeForExtraction = model.artifactType + } else if let inferredType = ArchiveType.from(url: archiveURL) { + // Infer from downloaded archive file path + archiveType = inferredType + artifactTypeForExtraction = .archive(inferredType, structure: .unknown, expectedFiles: .none) + logger.info("Inferred archive type from file path: \(inferredType.rawValue)") + } else if let originalDownloadURL = model.downloadURL, + let inferredType = ArchiveType.from(url: originalDownloadURL) { + // Infer from original download URL + archiveType = inferredType + artifactTypeForExtraction = .archive(inferredType, structure: .unknown, expectedFiles: .none) + logger.info("Inferred archive type from download URL: \(inferredType.rawValue)") + } else { + throw SDKError.download(.extractionFailed, "Could not determine archive type for model: \(model.id)") + } + + let extractionStartTime = Date() + + // Track extraction started via C++ event system + CppBridge.Events.emitExtractionStarted( + modelId: model.id, + archiveType: archiveType.rawValue + ) + + logger.info("Starting extraction", metadata: [ + "modelId": model.id, + "archiveType": archiveType.rawValue, + "archiveURL": archiveURL.path, + "destination": destinationFolder.path + ]) + + // Report extraction stage + progressContinuation.yield(.extraction(modelId: model.id, progress: 0.0)) + + do { + var lastReportedExtractionProgress: Double = -1.0 + let result = try await extractionService.extract( + archiveURL: archiveURL, + to: destinationFolder, + artifactType: artifactTypeForExtraction, + progressHandler: { progress in + // Track extraction progress (via C++ for routing to EventBus/telemetry) + if progress - lastReportedExtractionProgress >= 0.1 { + lastReportedExtractionProgress = progress + CppBridge.Events.emitExtractionProgress( + modelId: model.id, + progress: progress * 100 + ) + } + + progressContinuation.yield(.extraction( + modelId: model.id, + progress: progress, + totalBytes: model.downloadSize ?? 0 + )) + } + ) + + let extractionDurationMs = Date().timeIntervalSince(extractionStartTime) * 1000 + + // Track extraction completed via C++ event system + CppBridge.Events.emitExtractionCompleted( + modelId: model.id, + durationMs: extractionDurationMs + ) + + logger.info("Extraction completed", metadata: [ + "modelId": model.id, + "modelPath": result.modelPath.path, + "extractedSize": result.extractedSize, + "fileCount": result.fileCount, + "durationMs": extractionDurationMs + ]) + + return result.modelPath + } catch { + CppBridge.Events.emitExtractionFailed( + modelId: model.id, + error: SDKError.from(error, category: .download) + ) + throw error + } + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Download/Services/AlamofireDownloadService.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Download/Services/AlamofireDownloadService.swift new file mode 100644 index 000000000..81503674c --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Download/Services/AlamofireDownloadService.swift @@ -0,0 +1,361 @@ +import Alamofire +import Files +import Foundation + +/// Download service using Alamofire for HTTP and C++ bridge for orchestration +/// C++ handles: task tracking, progress calculation, retry logic +/// Swift handles: HTTP transport via Alamofire, extraction via SWCompression +public class AlamofireDownloadService: @unchecked Sendable { + + // MARK: - Shared Instance + + /// Shared singleton instance + public static let shared = AlamofireDownloadService() + + // MARK: - Properties + + let session: Session + var activeDownloadRequests: [String: DownloadRequest] = [:] + let logger = SDKLogger(category: "AlamofireDownloadService") + + // MARK: - Services + + /// Extraction service for handling archive extraction + let extractionService: ModelExtractionServiceProtocol + + // MARK: - Initialization + + public init( + configuration: DownloadConfiguration = DownloadConfiguration(), + extractionService: ModelExtractionServiceProtocol = DefaultModelExtractionService() + ) { + self.extractionService = extractionService + + // Configure session + let sessionConfiguration = URLSessionConfiguration.default + sessionConfiguration.timeoutIntervalForRequest = configuration.timeout + sessionConfiguration.timeoutIntervalForResource = configuration.timeout * 2 + sessionConfiguration.httpMaximumConnectionsPerHost = configuration.maxConcurrentDownloads + + // Create custom retry policy + let retryPolicy = RetryPolicy( + retryLimit: UInt(configuration.retryCount), + exponentialBackoffBase: 2, + exponentialBackoffScale: configuration.retryDelay, + retryableHTTPMethods: [.get, .post] + ) + + self.session = Session( + configuration: sessionConfiguration, + interceptor: Interceptor(adapters: [], retriers: [retryPolicy]) + ) + } + + // MARK: - Download API + + /// Download a model + /// - Parameter model: The model to download + /// - Returns: A download task tracking the download + /// - Throws: An error if download setup fails + public func downloadModel(_ model: ModelInfo) async throws -> DownloadTask { + logger.info("Starting artifact-based download for model \(model.id)", metadata: [ + "artifactType": model.artifactType.displayName, + "requiresExtraction": model.artifactType.requiresExtraction + ]) + + return try await downloadModelWithArtifactType(model) + } + + public func cancelDownload(taskId: String) { + if let downloadRequest = activeDownloadRequests[taskId] { + downloadRequest.cancel() + activeDownloadRequests.removeValue(forKey: taskId) + + // Notify C++ bridge + Task { + try? await CppBridge.Download.shared.cancelDownload(taskId: taskId) + } + + CppBridge.Events.emitDownloadCancelled(modelId: taskId) + logger.info("Cancelled download task: \(taskId)") + } + } + + // MARK: - Public Methods + + /// Pause all active downloads + public func pauseAll() { + activeDownloadRequests.values.forEach { $0.suspend() } + Task { + try? await CppBridge.Download.shared.pauseAll() + } + logger.info("Paused all downloads") + } + + /// Resume all paused downloads + public func resumeAll() { + activeDownloadRequests.values.forEach { $0.resume() } + Task { + try? await CppBridge.Download.shared.resumeAll() + } + logger.info("Resumed all downloads") + } + + /// Check if service is healthy + public func isHealthy() -> Bool { + return true + } + + // MARK: - Internal Download Methods + + /// Download model using artifact-type-based approach + func downloadModelWithArtifactType(_ model: ModelInfo) async throws -> DownloadTask { + guard let downloadURL = model.downloadURL else { + let downloadError = SDKError.download(.invalidInput, "Invalid download URL for model: \(model.id)") + CppBridge.Events.emitDownloadFailed(modelId: model.id, error: downloadError) + throw downloadError + } + + // Track download started via C++ event system + CppBridge.Events.emitDownloadStarted(modelId: model.id, totalBytes: model.downloadSize ?? 0) + + let downloadStartTime = Date() + let (progressStream, progressContinuation) = AsyncStream.makeStream() + + // Determine if we need extraction + // First check artifact type, then infer from URL if not explicitly set + var requiresExtraction = model.artifactType.requiresExtraction + + // If artifact type doesn't require extraction, check if URL indicates an archive + // This is a safeguard for models registered without explicit artifact type + if !requiresExtraction, let archiveType = ArchiveType.from(url: downloadURL) { + logger.info("URL indicates archive type (\(archiveType.rawValue)) but artifact type doesn't require extraction. Inferring extraction needed.") + requiresExtraction = true + } + + // Get destination path from C++ path utilities + let destinationFolder = try CppBridge.ModelPaths.getModelFolder(modelId: model.id, framework: model.framework) + + // Start tracking in C++ download manager + let taskId = try await CppBridge.Download.shared.startDownload( + modelId: model.id, + url: downloadURL, + destinationPath: destinationFolder, + requiresExtraction: requiresExtraction + ) { progress in + progressContinuation.yield(progress) + } + + // Create download task + let task = DownloadTask( + id: taskId, + modelId: model.id, + progress: progressStream, + result: Task { + defer { + progressContinuation.finish() + self.activeDownloadRequests.removeValue(forKey: taskId) + } + + do { + return try await self.executeArtifactDownload( + model: model, + downloadURL: downloadURL, + taskId: taskId, + requiresExtraction: requiresExtraction, + downloadStartTime: downloadStartTime, + destinationFolder: destinationFolder, + progressContinuation: progressContinuation + ) + } catch { + // Notify C++ bridge of failure + await CppBridge.Download.shared.markFailed( + taskId: taskId, + error: SDKError.from(error, category: .download) + ) + progressContinuation.yield(.failed(error, bytesDownloaded: 0, totalBytes: model.downloadSize ?? 0)) + throw error + } + } + ) + + return task + } + + /// Execute the complete download workflow for artifact-based downloads + func executeArtifactDownload( + model: ModelInfo, + downloadURL: URL, + taskId: String, + requiresExtraction: Bool, + downloadStartTime: Date, + destinationFolder: URL, + progressContinuation: AsyncStream.Continuation + ) async throws -> URL { + // Determine download destination + let downloadDestination = determineDownloadDestination( + for: model, + modelFolderURL: destinationFolder, + requiresExtraction: requiresExtraction + ) + + // Log download start + logDownloadStart(model: model, url: downloadURL, destination: downloadDestination, requiresExtraction: requiresExtraction) + + // Perform download (Alamofire HTTP) + let downloadedURL = try await performDownload( + url: downloadURL, + destination: downloadDestination, + model: model, + taskId: taskId, + progressContinuation: progressContinuation + ) + + // Notify C++ that download portion is complete + await CppBridge.Download.shared.markComplete(taskId: taskId, downloadedPath: downloadedURL) + + // Handle extraction if needed + let finalModelPath = try await handlePostDownloadProcessing( + downloadedURL: downloadedURL, + modelFolderURL: destinationFolder, + model: model, + requiresExtraction: requiresExtraction, + progressContinuation: progressContinuation + ) + + // Update model metadata via C++ registry + try await updateModelMetadata(model: model, localPath: finalModelPath) + + // Track completion + trackDownloadCompletion(model: model, finalPath: finalModelPath, startTime: downloadStartTime, progressContinuation: progressContinuation) + + return finalModelPath + } + + /// Determine the download destination based on extraction requirements + private func determineDownloadDestination( + for model: ModelInfo, + modelFolderURL: URL, + requiresExtraction: Bool + ) -> URL { + if requiresExtraction { + // Download to temp location for archives + // Get archive extension - use the one from artifact type or infer from URL + let archiveExt = getArchiveExtensionFromModelOrURL(model) + + // Note: URL.appendingPathExtension doesn't work well with multi-part extensions like "tar.gz" + // So we construct the filename with extension directly + let filename = "\(model.id)_\(UUID().uuidString).\(archiveExt)" + return FileManager.default.temporaryDirectory.appendingPathComponent(filename) + } else { + // Download directly to model folder + return modelFolderURL.appendingPathComponent("\(model.id).\(model.format.rawValue)") + } + } + + /// Get archive extension from model's artifact type or infer from download URL + private func getArchiveExtensionFromModelOrURL(_ model: ModelInfo) -> String { + // First try to get from artifact type + if case .archive(let archiveType, _, _) = model.artifactType { + return archiveType.fileExtension + } + + // If not an explicit archive type, try to infer from download URL + if let url = model.downloadURL, + let archiveType = ArchiveType.from(url: url) { + return archiveType.fileExtension + } + + // Default to archive (unknown type) + return "archive" + } + + /// Log download start information + private func logDownloadStart(model: ModelInfo, url: URL, destination: URL, requiresExtraction: Bool) { + logger.info("Starting download", metadata: [ + "modelId": model.id, + "url": url.absoluteString, + "expectedSize": model.downloadSize ?? 0, + "destination": destination.path, + "requiresExtraction": requiresExtraction + ]) + } + + /// Handle post-download processing (extraction if needed) + private func handlePostDownloadProcessing( + downloadedURL: URL, + modelFolderURL: URL, + model: ModelInfo, + requiresExtraction: Bool, + progressContinuation: AsyncStream.Continuation + ) async throws -> URL { + if requiresExtraction { + let finalPath = try await performExtraction( + archiveURL: downloadedURL, + destinationFolder: modelFolderURL, + model: model, + progressContinuation: progressContinuation + ) + // Clean up archive + try? FileManager.default.removeItem(at: downloadedURL) + return finalPath + } else { + return downloadedURL + } + } + + /// Update model metadata via C++ registry + private func updateModelMetadata(model: ModelInfo, localPath: URL) async throws { + var updatedModel = model + updatedModel.localPath = localPath + try await CppBridge.ModelRegistry.shared.save(updatedModel) + logger.info("Model metadata saved successfully for: \(model.id)") + } + + /// Track download completion with analytics + func trackDownloadCompletion( + model: ModelInfo, + finalPath: URL, + startTime: Date, + progressContinuation: AsyncStream.Continuation + ) { + let durationMs = Date().timeIntervalSince(startTime) * 1000 + let fileSize = FileOperationsUtilities.fileSize(at: finalPath) ?? model.downloadSize ?? 0 + + CppBridge.Events.emitDownloadCompleted( + modelId: model.id, + durationMs: durationMs, + sizeBytes: fileSize + ) + + // Report completion + progressContinuation.yield(.completed(totalBytes: model.downloadSize ?? fileSize)) + + logger.info("Download completed", metadata: [ + "modelId": model.id, + "localPath": finalPath.path, + "fileSize": fileSize + ]) + } + + // MARK: - Helper Methods + + func mapAlamofireError(_ error: AFError) -> SDKError { + switch error { + case .sessionTaskFailed(let underlyingError): + let message = "Network error during download: \(underlyingError.localizedDescription)" + return SDKError.download(.networkError, message, underlying: underlyingError) + case .responseValidationFailed(reason: let reason): + switch reason { + case .unacceptableStatusCode(let code): + return SDKError.download(.httpError, "HTTP error \(code)") + default: + return SDKError.download(.invalidResponse, "Invalid response from server") + } + case .createURLRequestFailed, .invalidURL: + return SDKError.download(.invalidInput, "Invalid URL") + default: + return SDKError.download(.unknown, "Unknown download error: \(error.localizedDescription)") + } + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Download/Services/ExtractionService.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Download/Services/ExtractionService.swift new file mode 100644 index 000000000..707c65088 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Download/Services/ExtractionService.swift @@ -0,0 +1,230 @@ +// +// ExtractionService.swift +// RunAnywhere SDK +// +// Centralized service for extracting model archives. +// Uses pure Swift extraction via SWCompression (no native C library dependency). +// Located in Download as it's part of the download post-processing pipeline. +// + +import Foundation + +// MARK: - Extraction Result + +/// Result of an extraction operation +public struct ExtractionResult: Sendable { + /// Path to the extracted model (could be file or directory) + public let modelPath: URL + + /// Total extracted size in bytes + public let extractedSize: Int64 + + /// Number of files extracted + public let fileCount: Int + + /// Duration of extraction in seconds + public let durationSeconds: TimeInterval + + public init(modelPath: URL, extractedSize: Int64, fileCount: Int, durationSeconds: TimeInterval) { + self.modelPath = modelPath + self.extractedSize = extractedSize + self.fileCount = fileCount + self.durationSeconds = durationSeconds + } +} + +// MARK: - Extraction Service Protocol + +/// Protocol for model extraction service +public protocol ExtractionServiceProtocol: Sendable { + /// Extract an archive based on the model's artifact type + /// - Parameters: + /// - archiveURL: URL to the downloaded archive + /// - destinationURL: Directory to extract to + /// - artifactType: The model's artifact type (determines extraction method) + /// - progressHandler: Optional callback for extraction progress (0.0 to 1.0) + /// - Returns: Result containing the path to the extracted model + func extract( + archiveURL: URL, + to destinationURL: URL, + artifactType: ModelArtifactType, + progressHandler: ((Double) -> Void)? + ) async throws -> ExtractionResult +} + +// MARK: - Default Extraction Service + +/// Default implementation of the model extraction service +/// Uses pure Swift extraction via SWCompression for all archive types +public final class DefaultExtractionService: ExtractionServiceProtocol, @unchecked Sendable { + private let logger = SDKLogger(category: "ExtractionService") + + public init() {} + + public func extract( + archiveURL: URL, + to destinationURL: URL, + artifactType: ModelArtifactType, + progressHandler: ((Double) -> Void)? + ) async throws -> ExtractionResult { + let startTime = Date() + + guard case .archive(let archiveType, let structure, _) = artifactType else { + throw SDKError.download(.extractionFailed, "Artifact type does not require extraction") + } + + logger.info("Starting extraction", metadata: [ + "archiveURL": archiveURL.path, + "destination": destinationURL.path, + "archiveType": archiveType.rawValue + ]) + + // Ensure destination exists + try FileManager.default.createDirectory(at: destinationURL, withIntermediateDirectories: true) + + // Report starting + progressHandler?(0.0) + + // Perform extraction based on archive type using pure Swift (SWCompression) + switch archiveType { + case .zip: + try ArchiveUtility.extractZipArchive(from: archiveURL, to: destinationURL, progressHandler: progressHandler) + case .tarBz2: + try ArchiveUtility.extractTarBz2Archive(from: archiveURL, to: destinationURL, progressHandler: progressHandler) + case .tarGz: + try ArchiveUtility.extractTarGzArchive(from: archiveURL, to: destinationURL, progressHandler: progressHandler) + case .tarXz: + try ArchiveUtility.extractTarXzArchive(from: archiveURL, to: destinationURL, progressHandler: progressHandler) + } + + // Find the actual model path based on structure + let modelPath = findModelPath(in: destinationURL, structure: structure) + + // Calculate extracted size and file count + let (extractedSize, fileCount) = calculateExtractionStats(at: destinationURL) + + let duration = Date().timeIntervalSince(startTime) + + logger.info("Extraction completed", metadata: [ + "modelPath": modelPath.path, + "extractedSize": extractedSize, + "fileCount": fileCount, + "durationSeconds": duration + ]) + + progressHandler?(1.0) + + return ExtractionResult( + modelPath: modelPath, + extractedSize: extractedSize, + fileCount: fileCount, + durationSeconds: duration + ) + } + + // MARK: - Helper Methods + + /// Find the actual model path based on archive structure + private func findModelPath(in extractedDir: URL, structure: ArchiveStructure) -> URL { + switch structure { + case .singleFileNested: + // Look for a single model file, possibly in a subdirectory + return findSingleModelFile(in: extractedDir) ?? extractedDir + + case .nestedDirectory: + // Common pattern: archive contains one subdirectory with all the files + // e.g., sherpa-onnx archives extract to: extractedDir/vits-xxx/ + return findNestedDirectory(in: extractedDir) + + case .directoryBased, .unknown: + // Return the extraction directory itself + return extractedDir + } + } + + /// Find nested directory (for archives that extract to a subdirectory) + private func findNestedDirectory(in extractedDir: URL) -> URL { + let fm = FileManager.default + + guard let contents = try? fm.contentsOfDirectory(at: extractedDir, includingPropertiesForKeys: [.isDirectoryKey]) else { + return extractedDir + } + + // Filter out hidden files and macOS resource forks + let visibleContents = contents.filter { + !$0.lastPathComponent.hasPrefix(".") && !$0.lastPathComponent.hasPrefix("._") + } + + // If there's a single visible subdirectory, return it + if visibleContents.count == 1, let first = visibleContents.first { + var isDir: ObjCBool = false + if fm.fileExists(atPath: first.path, isDirectory: &isDir), isDir.boolValue { + return first + } + } + + return extractedDir + } + + /// Find a single model file in a directory (recursive, up to 2 levels) + private func findSingleModelFile(in directory: URL, depth: Int = 0) -> URL? { + guard depth < 2 else { return nil } + + let fm = FileManager.default + guard let contents = try? fm.contentsOfDirectory(at: directory, includingPropertiesForKeys: [.isDirectoryKey]) else { + return nil + } + + // Known model file extensions + let modelExtensions = Set(["gguf", "onnx", "ort", "bin"]) + + // Look for model files at this level + for item in contents where modelExtensions.contains(item.pathExtension.lowercased()) { + return item + } + + // Recursively check subdirectories + for item in contents { + var isDir: ObjCBool = false + if fm.fileExists(atPath: item.path, isDirectory: &isDir), isDir.boolValue { + if let found = findSingleModelFile(in: item, depth: depth + 1) { + return found + } + } + } + + return nil + } + + /// Calculate size and file count for extracted content + private func calculateExtractionStats(at url: URL) -> (Int64, Int) { + let fm = FileManager.default + guard let enumerator = fm.enumerator( + at: url, + includingPropertiesForKeys: [.fileSizeKey, .isRegularFileKey], + options: [] + ) else { + return (0, 0) + } + + var totalSize: Int64 = 0 + var fileCount = 0 + + for case let fileURL as URL in enumerator { + if let values = try? fileURL.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey]) { + if values.isRegularFile == true { + fileCount += 1 + totalSize += Int64(values.fileSize ?? 0) + } + } + } + + return (totalSize, fileCount) + } +} + +// MARK: - Type Aliases for backward compatibility + +/// Type alias for backward compatibility +public typealias ModelExtractionServiceProtocol = ExtractionServiceProtocol +public typealias DefaultModelExtractionService = DefaultExtractionService diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Download/Utilities/ArchiveUtility.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Download/Utilities/ArchiveUtility.swift new file mode 100644 index 000000000..03f0cb9b5 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Download/Utilities/ArchiveUtility.swift @@ -0,0 +1,558 @@ +import Compression +import Foundation +import SWCompression +import ZIPFoundation + +/// Utility for handling archive operations +/// Uses Apple's native Compression framework for gzip (fast) and SWCompression for bzip2/xz (pure Swift) +/// Works on all Apple platforms (iOS, macOS, tvOS, watchOS) +public final class ArchiveUtility { + + private static let logger = SDKLogger(category: "ArchiveUtility") + + private init() {} + + // MARK: - Public Extraction Methods + + /// Extract a tar.bz2 archive to a destination directory + /// Uses SWCompression for pure Swift bzip2 decompression (slower - Apple doesn't support bzip2 natively) + /// - Parameters: + /// - sourceURL: The URL of the tar.bz2 file to extract + /// - destinationURL: The destination directory URL + /// - progressHandler: Optional callback for extraction progress (0.0 to 1.0) + /// - Throws: SDKError if extraction fails + public static func extractTarBz2Archive( + from sourceURL: URL, + to destinationURL: URL, + progressHandler: ((Double) -> Void)? = nil + ) throws { + let overallStart = Date() + logger.info("🗜️ [EXTRACTION START] tar.bz2 archive: \(sourceURL.lastPathComponent)") + logger.warning("⚠️ bzip2 uses pure Swift decompression (slower than native gzip)") + progressHandler?(0.0) + + // Step 1: Read compressed data + let readStart = Date() + let compressedData = try Data(contentsOf: sourceURL) + let readTime = Date().timeIntervalSince(readStart) + logger.info("📖 [READ] \(formatBytes(compressedData.count)) in \(String(format: "%.2f", readTime))s") + progressHandler?(0.05) + + // Step 2: Decompress bzip2 using pure Swift (no native support from Apple) + let decompressStart = Date() + logger.info("🐢 [DECOMPRESS] Starting pure Swift bzip2 decompression (this may take a while)...") + let tarData: Data + do { + tarData = try BZip2.decompress(data: compressedData) + } catch { + logger.error("BZip2 decompression failed: \(error)") + throw SDKError.download(.extractionFailed, "BZip2 decompression failed: \(error.localizedDescription)", underlying: error) + } + let decompressTime = Date().timeIntervalSince(decompressStart) + logger.info("✅ [DECOMPRESS] \(formatBytes(compressedData.count)) → \(formatBytes(tarData.count)) in \(String(format: "%.2f", decompressTime))s") + progressHandler?(0.4) + + // Step 3: Extract tar archive + let extractStart = Date() + logger.info("📦 [TAR EXTRACT] Extracting files...") + try extractTarData(tarData, to: destinationURL, progressHandler: { progress in + progressHandler?(0.4 + progress * 0.6) + }) + let extractTime = Date().timeIntervalSince(extractStart) + logger.info("✅ [TAR EXTRACT] Completed in \(String(format: "%.2f", extractTime))s") + + let totalTime = Date().timeIntervalSince(overallStart) + let timingInfo = """ + read: \(String(format: "%.2f", readTime))s, \ + decompress: \(String(format: "%.2f", decompressTime))s, \ + extract: \(String(format: "%.2f", extractTime))s + """ + logger.info("🎉 [EXTRACTION COMPLETE] Total: \(String(format: "%.2f", totalTime))s (\(timingInfo))") + progressHandler?(1.0) + } + + /// Extract a tar.gz archive to a destination directory + /// Uses Apple's native Compression framework for fast gzip decompression + /// - Parameters: + /// - sourceURL: The URL of the tar.gz file to extract + /// - destinationURL: The destination directory URL + /// - progressHandler: Optional callback for extraction progress (0.0 to 1.0) + /// - Throws: SDKError if extraction fails + public static func extractTarGzArchive( + from sourceURL: URL, + to destinationURL: URL, + progressHandler: ((Double) -> Void)? = nil + ) throws { + let overallStart = Date() + logger.info("🗜️ [EXTRACTION START] tar.gz archive: \(sourceURL.lastPathComponent)") + progressHandler?(0.0) + + // Step 1: Read compressed data + let readStart = Date() + let compressedData = try Data(contentsOf: sourceURL) + let readTime = Date().timeIntervalSince(readStart) + logger.info("📖 [READ] \(formatBytes(compressedData.count)) in \(String(format: "%.2f", readTime))s") + progressHandler?(0.05) + + // Step 2: Decompress gzip using NATIVE Compression framework (10-20x faster than pure Swift) + let decompressStart = Date() + logger.info("⚡ [DECOMPRESS] Starting native gzip decompression...") + let tarData: Data + do { + tarData = try decompressGzipNative(compressedData) + } catch { + logger.error("Native gzip decompression failed: \(error), falling back to pure Swift") + // Fallback to SWCompression if native fails + do { + tarData = try GzipArchive.unarchive(archive: compressedData) + } catch { + logger.error("Gzip decompression failed: \(error)") + throw SDKError.download(.extractionFailed, "Gzip decompression failed: \(error.localizedDescription)", underlying: error) + } + } + let decompressTime = Date().timeIntervalSince(decompressStart) + logger.info("✅ [DECOMPRESS] \(formatBytes(compressedData.count)) → \(formatBytes(tarData.count)) in \(String(format: "%.2f", decompressTime))s") + progressHandler?(0.3) + + // Step 3: Extract tar archive + let extractStart = Date() + logger.info("📦 [TAR EXTRACT] Extracting files...") + try extractTarData(tarData, to: destinationURL, progressHandler: { progress in + progressHandler?(0.3 + progress * 0.7) + }) + let extractTime = Date().timeIntervalSince(extractStart) + logger.info("✅ [TAR EXTRACT] Completed in \(String(format: "%.2f", extractTime))s") + + let totalTime = Date().timeIntervalSince(overallStart) + let gzTimingInfo = """ + read: \(String(format: "%.2f", readTime))s, \ + decompress: \(String(format: "%.2f", decompressTime))s, \ + extract: \(String(format: "%.2f", extractTime))s + """ + logger.info("🎉 [EXTRACTION COMPLETE] Total: \(String(format: "%.2f", totalTime))s (\(gzTimingInfo))") + progressHandler?(1.0) + } + + /// Decompress gzip data using Apple's native Compression framework + /// This is 10-20x faster than pure Swift SWCompression + private static func decompressGzipNative(_ compressedData: Data) throws -> Data { + // Gzip files have a header we need to skip to get to the raw deflate stream + // Gzip header: magic (2) + method (1) + flags (1) + mtime (4) + xfl (1) + os (1) = 10 bytes minimum + guard compressedData.count >= 10 else { + throw SDKError.download(.extractionFailed, "Invalid gzip data: too short") + } + + // Verify gzip magic number + guard compressedData[0] == 0x1f && compressedData[1] == 0x8b else { + throw SDKError.download(.extractionFailed, "Invalid gzip magic number") + } + + // Check compression method (must be 8 = deflate) + guard compressedData[2] == 8 else { + throw SDKError.download(.extractionFailed, "Unsupported gzip compression method") + } + + let flags = compressedData[3] + var offset = 10 + + // Skip optional extra field (FEXTRA) + if flags & 0x04 != 0 { + guard compressedData.count >= offset + 2 else { + throw SDKError.download(.extractionFailed, "Invalid gzip extra field") + } + let extraLen = Int(compressedData[offset]) | (Int(compressedData[offset + 1]) << 8) + offset += 2 + extraLen + } + + // Skip optional original filename (FNAME) + if flags & 0x08 != 0 { + while offset < compressedData.count && compressedData[offset] != 0 { + offset += 1 + } + offset += 1 // Skip null terminator + } + + // Skip optional comment (FCOMMENT) + if flags & 0x10 != 0 { + while offset < compressedData.count && compressedData[offset] != 0 { + offset += 1 + } + offset += 1 + } + + // Skip optional header CRC (FHCRC) + if flags & 0x02 != 0 { + offset += 2 + } + + // The rest is the deflate stream (minus 8 bytes at end for CRC32 + size) + guard compressedData.count > offset + 8 else { + throw SDKError.download(.extractionFailed, "Invalid gzip structure") + } + + let deflateData = compressedData.subdata(in: offset..<(compressedData.count - 8)) + + // Use native decompression with simple buffer approach + return try decompressDeflateData(deflateData) + } + + /// Decompress raw deflate data using Apple's Compression framework + /// Uses compression_decode_buffer which is simpler and avoids memory management issues + private static func decompressDeflateData(_ data: Data) throws -> Data { + // Start with a reasonable estimate (model files typically compress 3-5x) + var destinationBufferSize = data.count * 8 + var decompressedData = Data(count: destinationBufferSize) + + let decompressedSize = data.withUnsafeBytes { sourcePointer -> Int in + guard let sourceAddress = sourcePointer.baseAddress else { return 0 } + + return decompressedData.withUnsafeMutableBytes { destPointer -> Int in + guard let destAddress = destPointer.baseAddress else { return 0 } + + return compression_decode_buffer( + destAddress.assumingMemoryBound(to: UInt8.self), + destinationBufferSize, + sourceAddress.assumingMemoryBound(to: UInt8.self), + data.count, + nil, // scratch buffer (nil = allocate internally) + COMPRESSION_ZLIB + ) + } + } + + // If buffer was too small, try again with a larger buffer + if decompressedSize == 0 || decompressedSize == destinationBufferSize { + // Try with a much larger buffer + destinationBufferSize = data.count * 20 + decompressedData = Data(count: destinationBufferSize) + + let retrySize = data.withUnsafeBytes { sourcePointer -> Int in + guard let sourceAddress = sourcePointer.baseAddress else { return 0 } + + return decompressedData.withUnsafeMutableBytes { destPointer -> Int in + guard let destAddress = destPointer.baseAddress else { return 0 } + + return compression_decode_buffer( + destAddress.assumingMemoryBound(to: UInt8.self), + destinationBufferSize, + sourceAddress.assumingMemoryBound(to: UInt8.self), + data.count, + nil, + COMPRESSION_ZLIB + ) + } + } + + guard retrySize > 0 && retrySize < destinationBufferSize else { + throw SDKError.download(.extractionFailed, "Native decompression failed - buffer too small or corrupted data") + } + + decompressedData.count = retrySize + return decompressedData + } + + decompressedData.count = decompressedSize + return decompressedData + } + + /// Extract a tar.xz archive to a destination directory + /// Uses SWCompression for pure Swift LZMA/XZ decompression and tar extraction + /// - Parameters: + /// - sourceURL: The URL of the tar.xz file to extract + /// - destinationURL: The destination directory URL + /// - progressHandler: Optional callback for extraction progress (0.0 to 1.0) + /// - Throws: SDKError if extraction fails + public static func extractTarXzArchive( + from sourceURL: URL, + to destinationURL: URL, + progressHandler: ((Double) -> Void)? = nil + ) throws { + logger.info("Extracting tar.xz archive: \(sourceURL.lastPathComponent)") + progressHandler?(0.0) + + // Read compressed data + let compressedData = try Data(contentsOf: sourceURL) + logger.debug("Read \(formatBytes(compressedData.count)) from archive") + progressHandler?(0.1) + + // Step 1: Decompress XZ using SWCompression + logger.debug("Decompressing XZ...") + let tarData: Data + do { + tarData = try XZArchive.unarchive(archive: compressedData) + } catch { + logger.error("XZ decompression failed: \(error)") + throw SDKError.download(.extractionFailed, "XZ decompression failed: \(error.localizedDescription)", underlying: error) + } + logger.debug("Decompressed to \(formatBytes(tarData.count)) of tar data") + progressHandler?(0.4) + + // Step 2: Extract tar archive using SWCompression + try extractTarData(tarData, to: destinationURL, progressHandler: { progress in + // Map tar extraction progress (0.4 to 1.0) + progressHandler?(0.4 + progress * 0.6) + }) + + logger.info("tar.xz extraction completed to: \(destinationURL.path)") + progressHandler?(1.0) + } + + /// Extract a zip archive to a destination directory + /// Uses ZIPFoundation for zip extraction + /// - Parameters: + /// - sourceURL: The URL of the zip file to extract + /// - destinationURL: The destination directory URL + /// - progressHandler: Optional callback for extraction progress (0.0 to 1.0) + /// - Throws: SDKError if extraction fails + public static func extractZipArchive( + from sourceURL: URL, + to destinationURL: URL, + progressHandler: ((Double) -> Void)? = nil + ) throws { + logger.info("Extracting zip archive: \(sourceURL.lastPathComponent)") + progressHandler?(0.0) + + do { + // Ensure destination directory exists + try FileManager.default.createDirectory( + at: destinationURL, + withIntermediateDirectories: true, + attributes: nil + ) + + // Use ZIPFoundation to extract + try FileManager.default.unzipItem( + at: sourceURL, + to: destinationURL, + skipCRC32: true, + progress: nil, + pathEncoding: .utf8 + ) + + logger.info("zip extraction completed to: \(destinationURL.path)") + progressHandler?(1.0) + } catch { + logger.error("Zip extraction failed: \(error)") + throw SDKError.download(.extractionFailed, "Failed to extract zip archive: \(error.localizedDescription)", underlying: error) + } + } + + /// Extract any supported archive format based on file extension + /// - Parameters: + /// - sourceURL: The archive file URL + /// - destinationURL: The destination directory URL + /// - progressHandler: Optional callback for extraction progress (0.0 to 1.0) + /// - Throws: SDKError if extraction fails or format is unsupported + public static func extractArchive( + from sourceURL: URL, + to destinationURL: URL, + progressHandler: ((Double) -> Void)? = nil + ) throws { + let archiveType = detectArchiveType(from: sourceURL) + + switch archiveType { + case .tarBz2: + try extractTarBz2Archive(from: sourceURL, to: destinationURL, progressHandler: progressHandler) + case .tarGz: + try extractTarGzArchive(from: sourceURL, to: destinationURL, progressHandler: progressHandler) + case .tarXz: + try extractTarXzArchive(from: sourceURL, to: destinationURL, progressHandler: progressHandler) + case .zip: + try extractZipArchive(from: sourceURL, to: destinationURL, progressHandler: progressHandler) + case .unknown: + throw SDKError.download(.unsupportedArchive, "Unsupported archive format: \(sourceURL.pathExtension)") + } + } + + // MARK: - Archive Type Detection + + /// Supported archive types + public enum ArchiveFormat { + case tarBz2 + case tarGz + case tarXz + case zip + case unknown + } + + /// Detect archive type from URL + public static func detectArchiveType(from url: URL) -> ArchiveFormat { + let path = url.path.lowercased() + + if path.hasSuffix(".tar.bz2") || path.hasSuffix(".tbz2") || path.hasSuffix(".tbz") { + return .tarBz2 + } else if path.hasSuffix(".tar.gz") || path.hasSuffix(".tgz") { + return .tarGz + } else if path.hasSuffix(".tar.xz") || path.hasSuffix(".txz") { + return .tarXz + } else if path.hasSuffix(".zip") { + return .zip + } + + return .unknown + } + + /// Check if a URL points to a tar.bz2 archive + public static func isTarBz2Archive(_ url: URL) -> Bool { + detectArchiveType(from: url) == .tarBz2 + } + + /// Check if a URL points to a tar.gz archive + public static func isTarGzArchive(_ url: URL) -> Bool { + detectArchiveType(from: url) == .tarGz + } + + /// Check if a URL points to a zip archive + public static func isZipArchive(_ url: URL) -> Bool { + detectArchiveType(from: url) == .zip + } + + /// Check if a URL points to any supported archive format + public static func isSupportedArchive(_ url: URL) -> Bool { + detectArchiveType(from: url) != .unknown + } + + // MARK: - Zip Creation + + /// Create a zip archive from a source directory + /// - Parameters: + /// - sourceURL: The source directory URL + /// - destinationURL: The destination zip file URL + /// - Throws: SDKError if compression fails + public static func createZipArchive( + from sourceURL: URL, + to destinationURL: URL + ) throws { + do { + try FileManager.default.zipItem( + at: sourceURL, + to: destinationURL, + shouldKeepParent: false, + compressionMethod: .deflate, + progress: nil + ) + logger.info("Created zip archive at: \(destinationURL.path)") + } catch { + logger.error("Failed to create zip archive: \(error)") + throw SDKError.download(.extractionFailed, "Failed to create archive: \(error.localizedDescription)", underlying: error) + } + } + + // MARK: - Private Helpers + + /// Extract tar data to destination directory using SWCompression + private static func extractTarData( + _ tarData: Data, + to destinationURL: URL, + progressHandler: ((Double) -> Void)? = nil + ) throws { + // Step 1: Parse tar entries + let parseStart = Date() + logger.info(" 📋 [TAR PARSE] Parsing tar entries from \(formatBytes(tarData.count))...") + + // Ensure destination directory exists + try FileManager.default.createDirectory(at: destinationURL, withIntermediateDirectories: true) + + // Parse tar entries using SWCompression + let entries: [TarEntry] + do { + entries = try TarContainer.open(container: tarData) + } catch { + logger.error("Tar parsing failed: \(error)") + throw SDKError.download(.extractionFailed, "Tar parsing failed: \(error.localizedDescription)", underlying: error) + } + let parseTime = Date().timeIntervalSince(parseStart) + logger.info(" ✅ [TAR PARSE] Found \(entries.count) entries in \(String(format: "%.2f", parseTime))s") + + // Step 2: Write files to disk + let writeStart = Date() + logger.info(" 💾 [FILE WRITE] Writing files to disk...") + + var extractedCount = 0 + var extractedFiles = 0 + var extractedDirs = 0 + var totalBytesWritten: Int64 = 0 + + for entry in entries { + let entryPath = entry.info.name + + // Skip empty names or entries starting with ._ (macOS resource forks) + guard !entryPath.isEmpty, !entryPath.hasPrefix("._") else { + continue + } + + let fullPath = destinationURL.appendingPathComponent(entryPath) + + switch entry.info.type { + case .directory: + try FileManager.default.createDirectory(at: fullPath, withIntermediateDirectories: true) + extractedDirs += 1 + + case .regular: + // Create parent directory if needed + let parentDir = fullPath.deletingLastPathComponent() + try FileManager.default.createDirectory(at: parentDir, withIntermediateDirectories: true) + + // Write file data + if let data = entry.data { + try data.write(to: fullPath) + extractedFiles += 1 + totalBytesWritten += Int64(data.count) + } + + case .symbolicLink: + // Handle symbolic links if needed + let linkName = entry.info.linkName + if !linkName.isEmpty { + let parentDir = fullPath.deletingLastPathComponent() + try FileManager.default.createDirectory(at: parentDir, withIntermediateDirectories: true) + try? FileManager.default.createSymbolicLink(atPath: fullPath.path, withDestinationPath: linkName) + } + + default: + // Skip other types (block devices, character devices, etc.) + break + } + + extractedCount += 1 + progressHandler?(Double(extractedCount) / Double(entries.count)) + } + + let writeTime = Date().timeIntervalSince(writeStart) + let bytesStr = formatBytes(Int(totalBytesWritten)) + let timeStr = String(format: "%.2f", writeTime) + logger.info(" ✅ [FILE WRITE] Wrote \(extractedFiles) files (\(bytesStr)) and \(extractedDirs) dirs in \(timeStr)s") + } + + /// Format bytes for logging + private static func formatBytes(_ bytes: Int) -> String { + if bytes < 1024 { + return "\(bytes) B" + } else if bytes < 1024 * 1024 { + return String(format: "%.1f KB", Double(bytes) / 1024) + } else if bytes < 1024 * 1024 * 1024 { + return String(format: "%.1f MB", Double(bytes) / (1024 * 1024)) + } else { + return String(format: "%.2f GB", Double(bytes) / (1024 * 1024 * 1024)) + } + } +} + +// MARK: - FileManager Extension for Archive Operations + +public extension FileManager { + + /// Extract any supported archive format + /// - Parameters: + /// - sourceURL: The archive file URL + /// - destinationURL: The destination directory URL + /// - progressHandler: Optional callback for extraction progress (0.0 to 1.0) + /// - Throws: SDKError if extraction fails or format is unsupported + func extractArchive( + from sourceURL: URL, + to destinationURL: URL, + progressHandler: ((Double) -> Void)? = nil + ) throws { + try ArchiveUtility.extractArchive(from: sourceURL, to: destinationURL, progressHandler: progressHandler) + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Events/SDKEvent.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Events/SDKEvent.swift new file mode 100644 index 000000000..466dd9f74 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Events/SDKEvent.swift @@ -0,0 +1,76 @@ +// +// SDKEvent.swift +// RunAnywhere SDK +// +// Minimal event protocol for SDK events. +// All event logic and definitions are in C++ (rac_analytics_events.h). +// This Swift protocol only provides the interface for bridged events. +// + +import Foundation + +// MARK: - Event Destination + +/// Where an event should be routed (mirrors C++ rac_event_destination_t) +public enum EventDestination: Sendable { + /// Only to public EventBus (app developers) + case publicOnly + /// Only to analytics/telemetry (backend) + case analyticsOnly + /// Both destinations (default) + case all +} + +// MARK: - Event Category + +/// Event categories for filtering/grouping (mirrors C++ categories) +public enum EventCategory: String, Sendable { + case sdk + case model + case llm + case stt + case tts + case voice + case storage + case device + case network + case error +} + +// MARK: - SDK Event Protocol + +/// Minimal protocol for SDK events. +/// +/// Events originate from C++ and are bridged to Swift via EventBridge. +/// App developers can subscribe to events via EventPublisher or EventBus. +public protocol SDKEvent: Sendable { + /// Unique identifier for this event instance + var id: String { get } + + /// Event type string (from C++ event types) + var type: String { get } + + /// Category for filtering/routing + var category: EventCategory { get } + + /// When the event occurred + var timestamp: Date { get } + + /// Optional session ID for grouping related events + var sessionId: String? { get } + + /// Where to route this event + var destination: EventDestination { get } + + /// Event properties as key-value pairs + var properties: [String: String] { get } +} + +// MARK: - Default Implementations + +extension SDKEvent { + public var id: String { UUID().uuidString } + public var timestamp: Date { Date() } + public var sessionId: String? { nil } + public var destination: EventDestination { .all } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/FileManagement/Services/SimplifiedFileManager.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/FileManagement/Services/SimplifiedFileManager.swift new file mode 100644 index 000000000..9c3182253 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/FileManagement/Services/SimplifiedFileManager.swift @@ -0,0 +1,253 @@ +import Files +import Foundation + +/// File manager for RunAnywhere SDK +/// +/// Directory Structure: +/// ``` +/// Documents/RunAnywhere/ +/// Models/ +/// {framework}/ # e.g., "onnx", "llamacpp" +/// {modelId}/ # e.g., "sherpa-onnx-whisper-tiny.en" +/// [model files] # Single file or directory with multiple files +/// Cache/ +/// Temp/ +/// Downloads/ +/// ``` +public class SimplifiedFileManager { + + // MARK: - Shared Instance + + /// Shared file manager instance + public static let shared: SimplifiedFileManager = { + do { + return try SimplifiedFileManager() + } catch { + fatalError("Failed to initialize SimplifiedFileManager: \(error)") + } + }() + + // MARK: - Properties + + private let baseFolder: Folder + private let logger = SDKLogger(category: "FileManager") + + // MARK: - Initialization + + public init() throws { + guard let documentsFolder = Folder.documents else { + throw SDKError.fileManagement(.permissionDenied, "Unable to access documents directory") + } + self.baseFolder = try documentsFolder.createSubfolderIfNeeded(withName: "RunAnywhere") + try createDirectoryStructure() + } + + private func createDirectoryStructure() throws { + _ = try baseFolder.createSubfolderIfNeeded(withName: "Models") + _ = try baseFolder.createSubfolderIfNeeded(withName: "Cache") + _ = try baseFolder.createSubfolderIfNeeded(withName: "Temp") + _ = try baseFolder.createSubfolderIfNeeded(withName: "Downloads") + } + + // MARK: - Model Folder Access + + /// Get the model folder path: Models/{framework}/{modelId}/ + public func getModelFolder(for modelId: String, framework: InferenceFramework) throws -> Folder { + let modelFolderURL = try CppBridge.ModelPaths.getModelFolder(modelId: modelId, framework: framework) + return try createFolderIfNeeded(at: modelFolderURL) + } + + /// Check if a model folder exists and contains files + public func modelFolderExists(modelId: String, framework: InferenceFramework) -> Bool { + guard let folderURL = try? CppBridge.ModelPaths.getModelFolder(modelId: modelId, framework: framework) else { + return false + } + return folderExistsAndHasContents(at: folderURL) + } + + /// Get the model folder URL (without creating it) + public func getModelFolderURL(modelId: String, framework: InferenceFramework) throws -> URL { + return try CppBridge.ModelPaths.getModelFolder(modelId: modelId, framework: framework) + } + + /// Delete a model folder and all its contents + public func deleteModel(modelId: String, framework: InferenceFramework) throws { + let folderURL = try CppBridge.ModelPaths.getModelFolder(modelId: modelId, framework: framework) + if FileManager.default.fileExists(atPath: folderURL.path) { + try FileManager.default.removeItem(at: folderURL) + logger.info("Deleted model: \(modelId) from \(framework.rawValue)") + } + } + + // MARK: - Model Discovery + + /// Get all downloaded models organized by framework + /// Returns: Dictionary of [framework: [modelId]] + public func getDownloadedModels() -> [InferenceFramework: [String]] { + var result: [InferenceFramework: [String]] = [:] + + guard let modelsURL = try? CppBridge.ModelPaths.getModelsDirectory(), + let contents = try? FileManager.default.contentsOfDirectory(at: modelsURL, includingPropertiesForKeys: [.isDirectoryKey]) else { + return result + } + + for frameworkFolder in contents { + // Check if it's a known framework folder + guard let framework = InferenceFramework.allCases.first(where: { $0.rawValue == frameworkFolder.lastPathComponent }), + isDirectory(at: frameworkFolder) else { + continue + } + + // Get model folders within this framework + let dirContents = try? FileManager.default.contentsOfDirectory( + at: frameworkFolder, + includingPropertiesForKeys: [.isDirectoryKey] + ) + guard let modelFolders = dirContents else { + continue + } + + var modelIds: [String] = [] + for modelFolder in modelFolders { + if isDirectory(at: modelFolder) && folderExistsAndHasContents(at: modelFolder) { + modelIds.append(modelFolder.lastPathComponent) + } + } + + if !modelIds.isEmpty { + result[framework] = modelIds + } + } + + return result + } + + /// Check if a specific model is downloaded + @MainActor + public func isModelDownloaded(modelId: String, framework: InferenceFramework) -> Bool { + // Check if the folder exists and has contents + guard let folderURL = try? CppBridge.ModelPaths.getModelFolder(modelId: modelId, framework: framework), + folderExistsAndHasContents(at: folderURL) else { + return false + } + + // Folder exists with contents - model is downloaded + // Module-specific validation can be done by the service when loading + return true + } + + // MARK: - Download Management + + public func getDownloadFolder() throws -> Folder { + return try baseFolder.subfolder(named: "Downloads") + } + + public func createTempDownloadFile(for modelId: String) throws -> File { + let downloadFolder = try getDownloadFolder() + let tempFileName = "\(modelId)_\(UUID().uuidString).tmp" + return try downloadFolder.createFile(named: tempFileName) + } + + // MARK: - Cache Management + + public func storeCache(key: String, data: Data) throws { + let cacheFolder = try baseFolder.subfolder(named: "Cache") + _ = try cacheFolder.createFile(named: "\(key).cache", contents: data) + } + + public func loadCache(key: String) throws -> Data? { + let cacheFolder = try baseFolder.subfolder(named: "Cache") + guard cacheFolder.containsFile(named: "\(key).cache") else { return nil } + return try cacheFolder.file(named: "\(key).cache").read() + } + + public func clearCache() throws { + let cacheFolder = try baseFolder.subfolder(named: "Cache") + for file in cacheFolder.files { + try file.delete() + } + logger.info("Cleared cache") + } + + // MARK: - Temp Files + + public func cleanTempFiles() throws { + let tempFolder = try baseFolder.subfolder(named: "Temp") + for file in tempFolder.files { + try file.delete() + } + logger.info("Cleaned temp files") + } + + // MARK: - Storage Info + + public func getAvailableSpace() -> Int64 { + do { + let values = try URL(fileURLWithPath: baseFolder.path).resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey]) + return values.volumeAvailableCapacityForImportantUsage ?? 0 + } catch { + return 0 + } + } + + public func getDeviceStorageInfo() -> DeviceStorageInfo { + do { + let attributes = try FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory()) + let totalSpace = (attributes[.systemSize] as? Int64) ?? 0 + let freeSpace = (attributes[.systemFreeSize] as? Int64) ?? 0 + return DeviceStorageInfo(totalSpace: totalSpace, freeSpace: freeSpace, usedSpace: totalSpace - freeSpace) + } catch { + return DeviceStorageInfo(totalSpace: 0, freeSpace: 0, usedSpace: 0) + } + } + + public func calculateDirectorySize(at url: URL) -> Int64 { + var totalSize: Int64 = 0 + if let enumerator = FileManager.default.enumerator(at: url, includingPropertiesForKeys: [.fileSizeKey], options: []) { + for case let fileURL as URL in enumerator { + if let fileSize = try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize { + totalSize += Int64(fileSize) + } + } + } + return totalSize + } + + public func getBaseDirectoryURL() -> URL { + return URL(fileURLWithPath: baseFolder.path) + } + + // MARK: - Private Helpers + + private func createFolderIfNeeded(at url: URL) throws -> Folder { + if !FileManager.default.fileExists(atPath: url.path) { + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true, attributes: nil) + } + return try Folder(path: url.path) + } + + private func isDirectory(at url: URL) -> Bool { + var isDir: ObjCBool = false + return FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir) && isDir.boolValue + } + + private func folderExistsAndHasContents(at url: URL) -> Bool { + guard isDirectory(at: url), + let contents = try? FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil), + !contents.isEmpty else { + return false + } + return true + } +} + +// MARK: - Folder Extension + +extension Folder { + func createSubfolderIfNeeded(withName name: String) throws -> Folder { + if containsSubfolder(named: name) { + return try subfolder(named: name) + } + return try createSubfolder(named: name) + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/FileManagement/Utilities/FileOperationsUtilities.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/FileManagement/Utilities/FileOperationsUtilities.swift new file mode 100644 index 000000000..84125cc32 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/FileManagement/Utilities/FileOperationsUtilities.swift @@ -0,0 +1,211 @@ +import Foundation + +/// Centralized utilities for file operations across the SDK +/// Provides a single source of truth for all file system interactions +public struct FileOperationsUtilities { + + // MARK: - Directory Access + + /// Get the documents directory URL + /// - Returns: URL to the documents directory + /// - Throws: SDKError if documents directory is not accessible + public static func getDocumentsDirectory() throws -> URL { + guard let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { + throw SDKError.fileManagement(.permissionDenied, "Unable to access documents directory") + } + return documentsURL + } + + /// Get the caches directory URL + /// - Returns: URL to the caches directory + /// - Throws: SDKError if caches directory is not accessible + public static func getCachesDirectory() throws -> URL { + guard let cachesURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else { + throw SDKError.fileManagement(.permissionDenied, "Unable to access caches directory") + } + return cachesURL + } + + /// Get the temporary directory URL + /// - Returns: URL to the temporary directory + public static func getTemporaryDirectory() -> URL { + return FileManager.default.temporaryDirectory + } + + // MARK: - File Existence + + /// Check if a file or directory exists at the given path + /// - Parameter url: The URL to check + /// - Returns: true if the file or directory exists + public static func exists(at url: URL) -> Bool { + return FileManager.default.fileExists(atPath: url.path) + } + + /// Check if a file or directory exists and get whether it's a directory + /// - Parameter url: The URL to check + /// - Returns: Tuple of (exists, isDirectory) + public static func existsWithType(at url: URL) -> (exists: Bool, isDirectory: Bool) { + var isDirectory: ObjCBool = false + let exists = FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) + return (exists, isDirectory.boolValue) + } + + /// Check if a path is a non-empty directory + /// - Parameter url: The URL to check + /// - Returns: true if it's a directory with at least one item + public static func isNonEmptyDirectory(at url: URL) -> Bool { + let (exists, isDirectory) = existsWithType(at: url) + guard exists && isDirectory else { return false } + + if let contents = try? FileManager.default.contentsOfDirectory(atPath: url.path), + !contents.isEmpty { + return true + } + return false + } + + // MARK: - Directory Contents + + /// List contents of a directory + /// - Parameter url: The directory URL + /// - Returns: Array of URLs for items in the directory + /// - Throws: Error if directory cannot be read + public static func contentsOfDirectory(at url: URL) throws -> [URL] { + return try FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil) + } + + /// List contents of a directory with specific properties + /// - Parameters: + /// - url: The directory URL + /// - properties: Resource keys to include + /// - Returns: Array of URLs for items in the directory + /// - Throws: Error if directory cannot be read + public static func contentsOfDirectory(at url: URL, includingPropertiesForKeys properties: [URLResourceKey]) throws -> [URL] { + return try FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: properties) + } + + /// Enumerate directory contents recursively + /// - Parameters: + /// - url: The directory URL to enumerate + /// - properties: Resource keys to fetch for each file + /// - options: Enumeration options + /// - Returns: DirectoryEnumerator or nil if enumeration fails + public static func enumerateDirectory( + at url: URL, + includingPropertiesForKeys properties: [URLResourceKey]? = nil, + options: FileManager.DirectoryEnumerationOptions = [] + ) -> FileManager.DirectoryEnumerator? { + return FileManager.default.enumerator( + at: url, + includingPropertiesForKeys: properties, + options: options + ) + } + + // MARK: - File Attributes + + /// Get the size of a file in bytes + /// - Parameter url: The file URL + /// - Returns: File size in bytes, or nil if unavailable + public static func fileSize(at url: URL) -> Int64? { + do { + let attributes = try FileManager.default.attributesOfItem(atPath: url.path) + return attributes[.size] as? Int64 + } catch { + return nil + } + } + + /// Get file attributes + /// - Parameter url: The file URL + /// - Returns: Dictionary of file attributes + /// - Throws: Error if attributes cannot be read + public static func attributes(at url: URL) throws -> [FileAttributeKey: Any] { // swiftlint:disable:this avoid_any_type + return try FileManager.default.attributesOfItem(atPath: url.path) + } + + /// Get the creation date of a file + /// - Parameter url: The file URL + /// - Returns: Creation date or nil if unavailable + public static func creationDate(at url: URL) -> Date? { + return (try? attributes(at: url))?[.creationDate] as? Date + } + + /// Get the modification date of a file + /// - Parameter url: The file URL + /// - Returns: Modification date or nil if unavailable + public static func modificationDate(at url: URL) -> Date? { + return (try? attributes(at: url))?[.modificationDate] as? Date + } + + // MARK: - Directory Operations + + /// Create a directory at the specified URL + /// - Parameters: + /// - url: The URL where to create the directory + /// - withIntermediateDirectories: Whether to create intermediate directories + /// - Throws: Error if directory creation fails + public static func createDirectory(at url: URL, withIntermediateDirectories: Bool = true) throws { + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: withIntermediateDirectories, attributes: nil) + } + + /// Calculate the total size of a directory including all subdirectories + /// - Parameter url: The directory URL + /// - Returns: Total size in bytes + public static func calculateDirectorySize(at url: URL) -> Int64 { + var totalSize: Int64 = 0 + + if let enumerator = enumerateDirectory(at: url, includingPropertiesForKeys: [.fileSizeKey]) { + for case let fileURL as URL in enumerator { + if let fileSize = try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize { + totalSize += Int64(fileSize) + } + } + } + + return totalSize + } + + // MARK: - File/Directory Removal + + /// Remove a file or directory at the specified URL + /// - Parameter url: The URL of the item to remove + /// - Throws: Error if removal fails + public static func removeItem(at url: URL) throws { + try FileManager.default.removeItem(at: url) + } + + /// Remove a file or directory if it exists + /// - Parameter url: The URL of the item to remove + /// - Returns: true if item was removed, false if it didn't exist + @discardableResult + public static func removeItemIfExists(at url: URL) -> Bool { + guard exists(at: url) else { return false } + do { + try removeItem(at: url) + return true + } catch { + return false + } + } + + // MARK: - File Copy/Move + + /// Copy a file from source to destination + /// - Parameters: + /// - sourceURL: The source file URL + /// - destinationURL: The destination file URL + /// - Throws: Error if copy fails + public static func copyItem(from sourceURL: URL, to destinationURL: URL) throws { + try FileManager.default.copyItem(at: sourceURL, to: destinationURL) + } + + /// Move a file from source to destination + /// - Parameters: + /// - sourceURL: The source file URL + /// - destinationURL: The destination file URL + /// - Throws: Error if move fails + public static func moveItem(from sourceURL: URL, to destinationURL: URL) throws { + try FileManager.default.moveItem(at: sourceURL, to: destinationURL) + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Logging/SDKLogger.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Logging/SDKLogger.swift new file mode 100644 index 000000000..30d34073e --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Logging/SDKLogger.swift @@ -0,0 +1,408 @@ +// +// SDKLogger.swift +// RunAnywhere SDK +// +// Simplified logging system with multi-destination support. +// Thread-safe, Sendable-compliant, and easy to configure. +// + +import Foundation +import os + +// MARK: - LogLevel + +/// Log severity levels +public enum LogLevel: Int, Comparable, CustomStringConvertible, Codable, Sendable { + case debug = 0 + case info = 1 + case warning = 2 + case error = 3 + case fault = 4 + + public static func < (lhs: LogLevel, rhs: LogLevel) -> Bool { + lhs.rawValue < rhs.rawValue + } + + public var description: String { + switch self { + case .debug: return "debug" + case .info: return "info" + case .warning: return "warning" + case .error: return "error" + case .fault: return "fault" + } + } +} + +// MARK: - LogEntry + +/// Represents a single log message with metadata +public struct LogEntry: Sendable { + public let timestamp: Date + public let level: LogLevel + public let category: String + public let message: String + public let metadata: [String: String]? + public let deviceInfo: DeviceInfo? + + public init( + timestamp: Date = Date(), + level: LogLevel, + category: String, + message: String, + metadata: [String: Any]? = nil, // swiftlint:disable:this prefer_concrete_types avoid_any_type + deviceInfo: DeviceInfo? = nil + ) { + self.timestamp = timestamp + self.level = level + self.category = category + self.message = message + self.metadata = metadata?.mapValues { String(describing: $0) } + self.deviceInfo = deviceInfo + } +} + +// MARK: - LogDestination Protocol + +/// Protocol for log output destinations (Console, Sentry, etc.) +public protocol LogDestination: AnyObject, Sendable { // swiftlint:disable:this avoid_any_object + var identifier: String { get } + var isAvailable: Bool { get } + func write(_ entry: LogEntry) + func flush() +} + +// MARK: - LoggingConfiguration + +/// Simple configuration for the logging system +public struct LoggingConfiguration: Sendable { + public var enableLocalLogging: Bool + public var minLogLevel: LogLevel + public var includeDeviceMetadata: Bool + public var enableSentryLogging: Bool + + public init( + enableLocalLogging: Bool = true, + minLogLevel: LogLevel = .info, + includeDeviceMetadata: Bool = true, + enableSentryLogging: Bool = false + ) { + self.enableLocalLogging = enableLocalLogging + self.minLogLevel = minLogLevel + self.includeDeviceMetadata = includeDeviceMetadata + self.enableSentryLogging = enableSentryLogging + } + + // MARK: - Environment Presets + + public static var development: LoggingConfiguration { + LoggingConfiguration( + enableLocalLogging: true, + minLogLevel: .debug, + includeDeviceMetadata: false, + enableSentryLogging: true + ) + } + + public static var staging: LoggingConfiguration { + LoggingConfiguration( + enableLocalLogging: true, + minLogLevel: .info, + includeDeviceMetadata: true, + enableSentryLogging: false + ) + } + + public static var production: LoggingConfiguration { + LoggingConfiguration( + enableLocalLogging: false, + minLogLevel: .warning, + includeDeviceMetadata: true, + enableSentryLogging: false + ) + } +} + +// MARK: - Logging (Central Service) + +/// Central logging service that routes logs to multiple destinations +public final class Logging: @unchecked Sendable { + + public static let shared = Logging() + + // MARK: - Thread-safe State + + private let lock = OSAllocatedUnfairLock(initialState: State()) + + private struct State { + var configuration: LoggingConfiguration + var destinations: [LogDestination] = [] + + init() { + let environment = RunAnywhere.currentEnvironment ?? .production + self.configuration = LoggingConfiguration.forEnvironment(environment) + } + } + + public var configuration: LoggingConfiguration { + get { lock.withLock { $0.configuration } } + set { lock.withLock { $0.configuration = newValue } } + } + + public var destinations: [LogDestination] { + lock.withLock { $0.destinations } + } + + // MARK: - Initialization + + private init() { + let config = lock.withLock { $0.configuration } + if config.enableSentryLogging { + setupSentryLogging() + } + } + + // MARK: - Configuration + + public func configure(_ config: LoggingConfiguration) { + let oldConfig = lock.withLock { state -> LoggingConfiguration in + let old = state.configuration + state.configuration = config + return old + } + + // Handle Sentry state changes + if config.enableSentryLogging && !oldConfig.enableSentryLogging { + setupSentryLogging() + } else if !config.enableSentryLogging && oldConfig.enableSentryLogging { + removeSentryDestination() + } + } + + public func setLocalLoggingEnabled(_ enabled: Bool) { + lock.withLock { $0.configuration.enableLocalLogging = enabled } + } + + public func setMinLogLevel(_ level: LogLevel) { + lock.withLock { $0.configuration.minLogLevel = level } + } + + public func setIncludeDeviceMetadata(_ include: Bool) { + lock.withLock { $0.configuration.includeDeviceMetadata = include } + } + + public func setSentryLoggingEnabled(_ enabled: Bool) { + var config = configuration + config.enableSentryLogging = enabled + configure(config) + } + + // MARK: - Core Logging + + public func log( + level: LogLevel, + category: String, + message: String, + metadata: [String: Any]? = nil // swiftlint:disable:this prefer_concrete_types avoid_any_type + ) { + let (config, currentDestinations) = lock.withLock { ($0.configuration, $0.destinations) } + + guard level >= config.minLogLevel else { return } + guard config.enableLocalLogging || config.enableSentryLogging else { return } + + let entry = LogEntry( + level: level, + category: category, + message: message, + metadata: sanitizeMetadata(metadata), + deviceInfo: config.includeDeviceMetadata ? DeviceInfo.current : nil + ) + + // Write to console if local logging enabled + if config.enableLocalLogging { + printToConsole(entry) + } + + // Write to all registered destinations + for destination in currentDestinations where destination.isAvailable { + destination.write(entry) + } + } + + // MARK: - Destination Management + + public func addDestination(_ destination: LogDestination) { + lock.withLock { state in + guard !state.destinations.contains(where: { $0.identifier == destination.identifier }) else { return } + state.destinations.append(destination) + } + } + + public func removeDestination(_ destination: LogDestination) { + lock.withLock { state in + state.destinations.removeAll { $0.identifier == destination.identifier } + } + } + + public func flush() { + let currentDestinations = destinations + for destination in currentDestinations { + destination.flush() + } + } + + // MARK: - Private Helpers + + private func setupSentryLogging() { + let environment = RunAnywhere.currentEnvironment ?? .development + SentryManager.shared.initialize(environment: environment) + addDestination(SentryDestination()) + } + + private func removeSentryDestination() { + lock.withLock { state in + state.destinations.removeAll { $0.identifier == SentryDestination.destinationID } + } + } + + private func printToConsole(_ entry: LogEntry) { + let emoji: String + switch entry.level { + case .debug: emoji = "🔍" + case .info: emoji = "ℹ️" + case .warning: emoji = "⚠️" + case .error: emoji = "❌" + case .fault: emoji = "💥" + } + + var output = "\(emoji) [\(entry.category)] \(entry.message)" + if let metadata = entry.metadata, !metadata.isEmpty { + let metaStr = metadata.map { "\($0.key)=\($0.value)" }.joined(separator: ", ") + output += " | \(metaStr)" + } + + // Always print when local logging is enabled (controlled by configuration) + // The enableLocalLogging flag already controls whether this method is called + // swiftlint:disable:next no_print_statements + print(output) + } + + // MARK: - Metadata Sanitization + + private static let sensitivePatterns = ["key", "secret", "password", "token", "auth", "credential"] + + private func sanitizeMetadata(_ metadata: [String: Any]?) -> [String: Any]? { // swiftlint:disable:this prefer_concrete_types avoid_any_type + guard let metadata = metadata else { return nil } + + var sanitized: [String: Any] = [:] // swiftlint:disable:this prefer_concrete_types avoid_any_type + for (key, value) in metadata { + let lowercased = key.lowercased() + if Self.sensitivePatterns.contains(where: { lowercased.contains($0) }) { + sanitized[key] = "[REDACTED]" + } else if let nested = value as? [String: Any] { // swiftlint:disable:this avoid_any_type + sanitized[key] = sanitizeMetadata(nested) ?? [:] + } else { + sanitized[key] = value + } + } + return sanitized + } +} + +// MARK: - Environment Helper + +extension LoggingConfiguration { + static func forEnvironment(_ environment: SDKEnvironment) -> LoggingConfiguration { + switch environment { + case .development: return .development + case .staging: return .staging + case .production: return .production + } + } +} + +extension Logging { + /// Apply configuration based on SDK environment + public func applyEnvironmentConfiguration(_ environment: SDKEnvironment) { + let config = LoggingConfiguration.forEnvironment(environment) + configure(config) + } +} + +// MARK: - SDKLogger (Convenience Wrapper) + +/// Simple logger for SDK components with category-based filtering +public struct SDKLogger: Sendable { + public let category: String + + public init(category: String = "SDK") { + self.category = category + } + + // MARK: - Logging Methods + + @inlinable + public func debug(_ message: @autoclosure () -> String, metadata: [String: Any]? = nil) { // swiftlint:disable:this prefer_concrete_types avoid_any_type + #if DEBUG + Logging.shared.log(level: .debug, category: category, message: message(), metadata: metadata) + #endif + } + + public func info(_ message: String, metadata: [String: Any]? = nil) { // swiftlint:disable:this prefer_concrete_types avoid_any_type + Logging.shared.log(level: .info, category: category, message: message, metadata: metadata) + } + + public func warning(_ message: String, metadata: [String: Any]? = nil) { // swiftlint:disable:this prefer_concrete_types avoid_any_type + Logging.shared.log(level: .warning, category: category, message: message, metadata: metadata) + } + + public func error(_ message: String, metadata: [String: Any]? = nil) { // swiftlint:disable:this prefer_concrete_types avoid_any_type + Logging.shared.log(level: .error, category: category, message: message, metadata: metadata) + } + + public func fault(_ message: String, metadata: [String: Any]? = nil) { // swiftlint:disable:this prefer_concrete_types avoid_any_type + Logging.shared.log(level: .fault, category: category, message: message, metadata: metadata) + } + + // MARK: - Error Logging with Context + + public func logError( + _ error: Error, + additionalInfo: String? = nil, + file: String = #file, + line: Int = #line, + function: String = #function + ) { + let fileName = (file as NSString).lastPathComponent + let errorDesc = (error as? SDKError)?.errorDescription ?? error.localizedDescription + + var message = "\(errorDesc) at \(fileName):\(line) in \(function)" + if let info = additionalInfo { + message += " | Context: \(info)" + } + + var metadata: [String: Any] = [ // swiftlint:disable:this prefer_concrete_types avoid_any_type + "source_file": fileName, + "source_line": line, + "source_function": function + ] + + if let sdkError = error as? SDKError { + metadata["error_code"] = sdkError.code.rawValue + metadata["error_category"] = sdkError.category.rawValue + } + + Logging.shared.log(level: .error, category: category, message: message, metadata: metadata) + } +} + +// MARK: - Convenience Loggers + +extension SDKLogger { + public static let shared = SDKLogger(category: "RunAnywhere") + public static let llm = SDKLogger(category: "LLM") + public static let stt = SDKLogger(category: "STT") + public static let tts = SDKLogger(category: "TTS") + public static let download = SDKLogger(category: "Download") + public static let models = SDKLogger(category: "Models") +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Logging/SentryDestination.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Logging/SentryDestination.swift new file mode 100644 index 000000000..1664192d5 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Logging/SentryDestination.swift @@ -0,0 +1,95 @@ +// +// SentryDestination.swift +// RunAnywhere SDK +// +// Log destination that sends logs to Sentry for error tracking +// + +import Foundation +import Sentry + +/// Log destination that sends warning+ logs to Sentry +public final class SentryDestination: LogDestination, @unchecked Sendable { + + // MARK: - LogDestination + + public static let destinationID = "com.runanywhere.logging.sentry" + + public let identifier: String = SentryDestination.destinationID + + public var isAvailable: Bool { + SentryManager.shared.isInitialized + } + + /// Only send warning level and above to Sentry + private let minSentryLevel: LogLevel = .warning + + public init() {} + + // MARK: - LogDestination Operations + + public func write(_ entry: LogEntry) { + guard entry.level >= minSentryLevel, isAvailable else { return } + + // Add as breadcrumb for context trail + addBreadcrumb(for: entry) + + // For error and fault levels, capture as Sentry event + if entry.level >= .error { + captureEvent(for: entry) + } + } + + public func flush() { + guard isAvailable else { return } + SentrySDK.flush(timeout: 2.0) + } + + // MARK: - Private Helpers + + private func addBreadcrumb(for entry: LogEntry) { + let breadcrumb = Breadcrumb(level: convertToSentryLevel(entry.level), category: entry.category) + breadcrumb.message = entry.message + breadcrumb.timestamp = entry.timestamp + + if let metadata = entry.metadata { + breadcrumb.data = metadata + } + + SentrySDK.addBreadcrumb(breadcrumb) + } + + private func captureEvent(for entry: LogEntry) { + let event = Event(level: convertToSentryLevel(entry.level)) + event.message = SentryMessage(formatted: entry.message) + event.timestamp = entry.timestamp + event.tags = [ + "category": entry.category, + "log_level": entry.level.description + ] + + if let metadata = entry.metadata { + event.extra = metadata + } + + if let deviceInfo = entry.deviceInfo { + var extra = event.extra ?? [:] + extra["device_model"] = deviceInfo.deviceModel + extra["os_version"] = deviceInfo.osVersion + extra["platform"] = deviceInfo.platform + event.extra = extra + } + + SentrySDK.capture(event: event) + } + + private func convertToSentryLevel(_ level: LogLevel) -> SentryLevel { + switch level { + case .debug: return .debug + case .info: return .info + case .warning: return .warning + case .error: return .error + case .fault: return .fatal + } + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Logging/SentryManager.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Logging/SentryManager.swift new file mode 100644 index 000000000..287935022 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Logging/SentryManager.swift @@ -0,0 +1,113 @@ +// +// SentryManager.swift +// RunAnywhere SDK +// +// Manages Sentry SDK initialization for crash reporting and error tracking +// + +import Foundation +import Sentry + +/// Manages Sentry SDK initialization and configuration +public final class SentryManager: @unchecked Sendable { + + public static let shared = SentryManager() + + public private(set) var isInitialized: Bool = false + + private init() {} + + // MARK: - Initialization + + /// Initialize Sentry with the configured DSN + /// - Parameters: + /// - dsn: Sentry DSN (if nil, uses C++ config sentryDSN) + /// - environment: SDK environment for tagging events + public func initialize(dsn: String? = nil, environment: SDKEnvironment = .development) { + guard !isInitialized else { return } + + // Use provided DSN or fallback to C++ config + let sentryDSN = dsn ?? CppBridge.DevConfig.sentryDSN + + guard let configuredDSN = sentryDSN, + configuredDSN != "YOUR_SENTRY_DSN_HERE" && !configuredDSN.isEmpty else { + // NOTE: Do NOT use SDKLogger here - it would cause a deadlock during Logging.shared initialization + #if DEBUG + // swiftlint:disable:next no_print_statements + print("🔍 [Sentry] DSN not configured. Crash reporting disabled.") + #endif + return + } + + SentrySDK.start { options in + options.dsn = configuredDSN + options.environment = environment.rawValue + options.enableCrashHandler = true + options.enableAutoBreadcrumbTracking = true + options.enableAppHangTracking = true + options.appHangTimeoutInterval = 2.0 + options.enableAutoSessionTracking = true + options.attachStacktrace = true + options.tracesSampleRate = 0 + + #if DEBUG + options.debug = true + options.diagnosticLevel = .warning + #else + options.debug = false + #endif + + options.beforeSend = { event in + event.tags?["sdk_name"] = "RunAnywhere" + event.tags?["sdk_version"] = SDKConstants.version + return event + } + } + + isInitialized = true + } + + // MARK: - Direct API (for advanced use cases) + + /// Capture an error directly with Sentry + public func captureError(_ error: Error, context: [String: Any]? = nil) { // swiftlint:disable:this prefer_concrete_types avoid_any_type + guard isInitialized else { return } + + SentrySDK.capture(error: error) { scope in + if let context = context { + for (key, value) in context { + scope.setExtra(value: value, key: key) + } + } + } + } + + /// Set user information for Sentry events + public func setUser(userId: String, email: String? = nil, username: String? = nil) { + guard isInitialized else { return } + + let user = User(userId: userId) + user.email = email + user.username = username + SentrySDK.setUser(user) + } + + /// Clear user information + public func clearUser() { + guard isInitialized else { return } + SentrySDK.setUser(nil) + } + + /// Flush pending events + public func flush(timeout: TimeInterval = 2.0) { + guard isInitialized else { return } + SentrySDK.flush(timeout: timeout) + } + + /// Close Sentry SDK + public func close() { + guard isInitialized else { return } + SentrySDK.close() + isInitialized = false + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Configuration/SDKEnvironment.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Configuration/SDKEnvironment.swift new file mode 100644 index 000000000..439085d36 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Configuration/SDKEnvironment.swift @@ -0,0 +1,245 @@ +import CRACommons +import Foundation + +/// SDK Environment mode - determines how data is handled +public enum SDKEnvironment: String, CaseIterable, Sendable { + /// Development/testing mode - may use local data, verbose logging + case development + + /// Staging mode - testing with real services + case staging + + /// Production mode - live environment + case production + + // MARK: - C++ Bridge + + /// Convert to C++ environment type for cross-platform consistency + var cEnvironment: rac_environment_t { + switch self { + case .development: return RAC_ENV_DEVELOPMENT + case .staging: return RAC_ENV_STAGING + case .production: return RAC_ENV_PRODUCTION + } + } + + /// Human-readable description + public var description: String { + switch self { + case .development: + return "Development Environment" + case .staging: + return "Staging Environment" + case .production: + return "Production Environment" + } + } + + /// Check if this is a production environment (uses C++) + public var isProduction: Bool { + rac_env_is_production(cEnvironment) + } + + /// Check if this is a testing environment (uses C++) + public var isTesting: Bool { + rac_env_is_testing(cEnvironment) + } + + /// Check if this environment requires a valid backend URL (uses C++) + public var requiresBackendURL: Bool { + rac_env_requires_backend_url(cEnvironment) + } + + // MARK: - Build Configuration Validation + + /// Check if the current build configuration is compatible with this environment + /// Production environment is only allowed in Release builds + public var isCompatibleWithCurrentBuild: Bool { + switch self { + case .development, .staging: + return true + case .production: + #if DEBUG + return false + #else + return true + #endif + } + } + + /// Returns true if we're running in a DEBUG build + public static var isDebugBuild: Bool { + #if DEBUG + return true + #else + return false + #endif + } + + // MARK: - Environment-Specific Settings + + /// Determine logging verbosity based on environment + public var defaultLogLevel: LogLevel { + switch self { + case .development: return .debug + case .staging: return .info + case .production: return .warning + } + } + + /// Should send telemetry data (production only) - uses C++ + public var shouldSendTelemetry: Bool { + rac_env_should_send_telemetry(cEnvironment) + } + + /// Should use mock data sources (development only) + public var useMockData: Bool { + self == .development // Keep simple - no C++ equivalent + } + + /// Should sync with backend (non-development) - uses C++ + public var shouldSyncWithBackend: Bool { + rac_env_should_sync_with_backend(cEnvironment) + } + + /// Requires API authentication (non-development) - uses C++ + public var requiresAuthentication: Bool { + rac_env_requires_auth(cEnvironment) + } +} + +/// SDK initialization parameters +public struct SDKInitParams { + /// API key for authentication + public let apiKey: String + + /// Base URL for API requests + /// - Required for staging and production environments + /// - Optional for development (uses placeholder if not provided) + public let baseURL: URL + + /// Environment mode (development/staging/production) + public let environment: SDKEnvironment + + // MARK: - Default Development URL + + /// Placeholder URL used for development when no URL is provided. + /// Development mode uses local analytics, so this is just a placeholder. + private static let developmentPlaceholderURL: URL = { + guard let url = URL(string: "https://dev.runanywhere.local") else { + fatalError("Invalid hardcoded development URL") + } + return url + }() + + // MARK: - Initializers + + /// Create initialization parameters for staging or production + /// - Parameters: + /// - apiKey: Your RunAnywhere API key (required) + /// - baseURL: Base URL for API requests (required, must be valid HTTPS URL) + /// - environment: Environment mode (default: production) + /// - Throws: SDKError if validation fails + public init( + apiKey: String, + baseURL: URL, + environment: SDKEnvironment = .production + ) throws { + self.apiKey = apiKey + self.baseURL = baseURL + self.environment = environment + + // Validate based on environment + try Self.validate(apiKey: apiKey, baseURL: baseURL, environment: environment) + } + + /// Convenience initializer with string URL for staging or production + /// - Parameters: + /// - apiKey: Your RunAnywhere API key + /// - baseURL: Base URL string for API requests + /// - environment: Environment mode (default: production) + /// - Throws: SDKError if URL is invalid or validation fails + public init( + apiKey: String, + baseURL: String, + environment: SDKEnvironment = .production + ) throws { + guard let url = URL(string: baseURL) else { + throw SDKError.general(.validationFailed, "Invalid base URL format: \(baseURL)") + } + try self.init(apiKey: apiKey, baseURL: url, environment: environment) + } + + /// Convenience initializer for development mode (no URL required) + /// - Parameter apiKey: Optional API key (not required for development) + /// - Note: Development mode uses Supabase internally for dev analytics + public init(forDevelopmentWithAPIKey apiKey: String = "") { + self.apiKey = apiKey + self.baseURL = Self.developmentPlaceholderURL + self.environment = .development + } + + // MARK: - Validation (Uses C++ for cross-platform consistency) + + /// Validate initialization parameters based on environment + /// Uses C++ validation logic for cross-platform consistency. + /// - Parameters: + /// - apiKey: The API key to validate + /// - baseURL: The base URL to validate + /// - environment: The target environment + /// - Throws: SDKError if validation fails + private static func validate( + apiKey: String, + baseURL: URL, + environment: SDKEnvironment + ) throws { + let logger = SDKLogger(category: "SDKInitParams") + + // Note: We allow any environment in DEBUG builds to support developer testing + // with custom backends. The environment parameter is informational for + // logging and behavior configuration, not a security boundary. + + // Call C++ validation for API key and URL + let cEnv = environment.cEnvironment + + // Validate API key via C++ + let apiKeyResult = apiKey.withCString { ptr in + rac_validate_api_key(ptr, cEnv) + } + if apiKeyResult != RAC_VALIDATION_OK { + let message = String(cString: rac_validation_error_message(apiKeyResult)) + switch apiKeyResult { + case RAC_VALIDATION_API_KEY_REQUIRED: + throw SDKError.general(.invalidAPIKey, "\(message) for \(environment.description)") + case RAC_VALIDATION_API_KEY_TOO_SHORT: + throw SDKError.general(.invalidAPIKey, message) + default: + throw SDKError.general(.validationFailed, message) + } + } + + // Validate URL via C++ + let urlResult = baseURL.absoluteString.withCString { ptr in + rac_validate_base_url(ptr, cEnv) + } + if urlResult != RAC_VALIDATION_OK { + let message = String(cString: rac_validation_error_message(urlResult)) + throw SDKError.general(.validationFailed, message) + } + + // Log warnings for staging HTTP (C++ validates but doesn't warn) + if environment == .staging, baseURL.scheme?.lowercased() == "http" { + logger.warning("Using HTTP for staging environment. Consider using HTTPS for security.") + } + + // Log warnings for staging localhost (C++ validates but doesn't warn) + if environment == .staging, let host = baseURL.host?.lowercased() { + if host.contains("localhost") || host.contains("127.0.0.1") || + host.contains("example.com") || host.contains(".local") { + logger.warning("Staging environment using local/example URL: \(host)") + } + } + + logger.info("URL validated for \(environment.description): \(baseURL.absoluteString)") + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Events/EventBus.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Events/EventBus.swift new file mode 100644 index 000000000..968611bf2 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Events/EventBus.swift @@ -0,0 +1,76 @@ +// +// EventBus.swift +// RunAnywhere SDK +// +// Simple pub/sub for SDK events. +// + +import Combine +import Foundation + +// MARK: - Event Bus + +/// Central event bus for SDK-wide event distribution. +/// +/// Subscribe to events by category or to all events: +/// ```swift +/// // Subscribe to all events +/// EventBus.shared.events +/// .sink { event in print(event.type) } +/// +/// // Subscribe to specific category +/// EventBus.shared.events(for: .llm) +/// .sink { event in print(event.type) } +/// ``` +public final class EventBus: @unchecked Sendable { + + // MARK: - Singleton + + public static let shared = EventBus() + + // MARK: - Publishers + + private let subject = PassthroughSubject() + + /// All events publisher + public var events: AnyPublisher { + subject.eraseToAnyPublisher() + } + + // MARK: - Initialization + + private init() {} + + // MARK: - Publishing + + /// Publish an event to all subscribers + public func publish(_ event: any SDKEvent) { + subject.send(event) + } + + // MARK: - Filtered Subscriptions + + /// Get events for a specific category + public func events(for category: EventCategory) -> AnyPublisher { + subject + .filter { $0.category == category } + .eraseToAnyPublisher() + } + + /// Subscribe to events with a closure + public func on(_ handler: @escaping (any SDKEvent) -> Void) -> AnyCancellable { + subject.sink { event in + handler(event) + } + } + + /// Subscribe to events of a specific category + public func on( + _ category: EventCategory, + handler: @escaping (any SDKEvent) -> Void + ) -> AnyCancellable { + events(for: category).sink { event in + handler(event) + } + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/LLM/LLMTypes.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/LLM/LLMTypes.swift new file mode 100644 index 000000000..d6d522139 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/LLM/LLMTypes.swift @@ -0,0 +1,531 @@ +// +// LLMTypes.swift +// RunAnywhere SDK +// +// Public types for LLM text generation. +// These are thin wrappers over C++ types in rac_llm_types.h +// + +import CRACommons +import Foundation + +// MARK: - LLM Configuration + +/// Configuration for LLM component +public struct LLMConfiguration: ComponentConfiguration, Sendable { + + // MARK: - ComponentConfiguration + + /// Component type + public var componentType: SDKComponent { .llm } + + /// Model ID (optional - uses default if not specified) + public let modelId: String? + + /// Preferred framework for generation + public let preferredFramework: InferenceFramework? + + // MARK: - Model Parameters + + /// Context length (max tokens the model can handle) + public let contextLength: Int + + // MARK: - Default Generation Parameters + + /// Temperature for sampling (0.0 - 2.0) + public let temperature: Double + + /// Maximum tokens to generate + public let maxTokens: Int + + /// System prompt for generation + public let systemPrompt: String? + + /// Enable streaming mode + public let streamingEnabled: Bool + + // MARK: - Initialization + + public init( + modelId: String? = nil, + contextLength: Int = 2048, + temperature: Double = 0.7, + maxTokens: Int = 100, + systemPrompt: String? = nil, + streamingEnabled: Bool = true, + preferredFramework: InferenceFramework? = nil + ) { + self.modelId = modelId + self.contextLength = contextLength + self.temperature = temperature + self.maxTokens = maxTokens + self.systemPrompt = systemPrompt + self.streamingEnabled = streamingEnabled + self.preferredFramework = preferredFramework + } + + // MARK: - Validation + + public func validate() throws { + guard contextLength > 0 && contextLength <= 32768 else { + throw SDKError.general(.validationFailed, "Context length must be between 1 and 32768") + } + guard temperature >= 0 && temperature <= 2.0 else { + throw SDKError.general(.validationFailed, "Temperature must be between 0 and 2.0") + } + guard maxTokens > 0 && maxTokens <= contextLength else { + throw SDKError.general(.validationFailed, "Max tokens must be between 1 and context length") + } + } +} + +// MARK: - LLMConfiguration Builder + +extension LLMConfiguration { + + /// Create configuration with builder pattern + public static func builder(modelId: String? = nil) -> Builder { + Builder(modelId: modelId) + } + + public class Builder { + private var modelId: String? + private var contextLength: Int = 2048 + private var temperature: Double = 0.7 + private var maxTokens: Int = 100 + private var systemPrompt: String? + private var streamingEnabled: Bool = true + private var preferredFramework: InferenceFramework? + + init(modelId: String?) { + self.modelId = modelId + } + + public func contextLength(_ length: Int) -> Builder { + self.contextLength = length + return self + } + + public func temperature(_ temp: Double) -> Builder { + self.temperature = temp + return self + } + + public func maxTokens(_ tokens: Int) -> Builder { + self.maxTokens = tokens + return self + } + + public func systemPrompt(_ prompt: String?) -> Builder { + self.systemPrompt = prompt + return self + } + + public func streamingEnabled(_ enabled: Bool) -> Builder { + self.streamingEnabled = enabled + return self + } + + public func preferredFramework(_ framework: InferenceFramework?) -> Builder { + self.preferredFramework = framework + return self + } + + public func build() -> LLMConfiguration { + LLMConfiguration( + modelId: modelId, + contextLength: contextLength, + temperature: temperature, + maxTokens: maxTokens, + systemPrompt: systemPrompt, + streamingEnabled: streamingEnabled, + preferredFramework: preferredFramework + ) + } + } +} + +// MARK: - LLM Generation Options + +/// Options for text generation +public struct LLMGenerationOptions: Sendable { + + /// Maximum number of tokens to generate + public let maxTokens: Int + + /// Temperature for sampling (0.0 - 2.0) + public let temperature: Float + + /// Top-p sampling parameter + public let topP: Float + + /// Stop sequences + public let stopSequences: [String] + + /// Enable streaming mode + public let streamingEnabled: Bool + + /// Preferred framework for generation + public let preferredFramework: InferenceFramework? + + /// Structured output configuration (optional) + public let structuredOutput: StructuredOutputConfig? + + /// System prompt to define AI behavior and formatting rules + public let systemPrompt: String? + + public init( + maxTokens: Int = 100, + temperature: Float = 0.8, + topP: Float = 1.0, + stopSequences: [String] = [], + streamingEnabled: Bool = false, + preferredFramework: InferenceFramework? = nil, + structuredOutput: StructuredOutputConfig? = nil, + systemPrompt: String? = nil + ) { + self.maxTokens = maxTokens + self.temperature = temperature + self.topP = topP + self.stopSequences = stopSequences + self.streamingEnabled = streamingEnabled + self.preferredFramework = preferredFramework + self.structuredOutput = structuredOutput + self.systemPrompt = systemPrompt + } + + // MARK: - C++ Bridge (rac_llm_options_t) + + /// Execute a closure with the C++ equivalent options struct + public func withCOptions(_ body: (UnsafePointer) throws -> T) rethrows -> T { + var cOptions = rac_llm_options_t() + cOptions.max_tokens = Int32(maxTokens) + cOptions.temperature = temperature + cOptions.top_p = topP + cOptions.streaming_enabled = streamingEnabled ? RAC_TRUE : RAC_FALSE + cOptions.stop_sequences = nil + cOptions.num_stop_sequences = 0 + + if let prompt = systemPrompt { + return try prompt.withCString { promptPtr in + cOptions.system_prompt = promptPtr + return try body(&cOptions) + } + } else { + cOptions.system_prompt = nil + return try body(&cOptions) + } + } +} + +// MARK: - LLM Generation Result + +/// Result of a text generation request +public struct LLMGenerationResult: Sendable { + + /// Generated text (with thinking content removed if extracted) + public let text: String + + /// Thinking/reasoning content extracted from the response + public let thinkingContent: String? + + /// Number of input/prompt tokens (from tokenizer) + public let inputTokens: Int + + /// Number of tokens used (output tokens) + public let tokensUsed: Int + + /// Model used for generation + public let modelUsed: String + + /// Total latency in milliseconds + public let latencyMs: TimeInterval + + /// Framework used for generation + public let framework: String? + + /// Tokens generated per second + public let tokensPerSecond: Double + + /// Time to first token in milliseconds (only for streaming) + public let timeToFirstTokenMs: Double? + + /// Structured output validation result + public var structuredOutputValidation: StructuredOutputValidation? + + /// Number of tokens used for thinking/reasoning + public let thinkingTokens: Int? + + /// Number of tokens in the actual response content + public let responseTokens: Int + + public init( + text: String, + thinkingContent: String? = nil, + inputTokens: Int = 0, + tokensUsed: Int, + modelUsed: String, + latencyMs: TimeInterval, + framework: String? = nil, + tokensPerSecond: Double = 0, + timeToFirstTokenMs: Double? = nil, + structuredOutputValidation: StructuredOutputValidation? = nil, + thinkingTokens: Int? = nil, + responseTokens: Int? = nil + ) { + self.text = text + self.thinkingContent = thinkingContent + self.inputTokens = inputTokens + self.tokensUsed = tokensUsed + self.modelUsed = modelUsed + self.latencyMs = latencyMs + self.framework = framework + self.tokensPerSecond = tokensPerSecond + self.timeToFirstTokenMs = timeToFirstTokenMs + self.structuredOutputValidation = structuredOutputValidation + self.thinkingTokens = thinkingTokens + self.responseTokens = responseTokens ?? tokensUsed + } + + // MARK: - C++ Bridge (rac_llm_result_t) + + /// Initialize from C++ rac_llm_result_t + public init(from cResult: rac_llm_result_t, modelId: String) { + self.init( + text: cResult.text.map { String(cString: $0) } ?? "", + thinkingContent: nil, + inputTokens: Int(cResult.prompt_tokens), + tokensUsed: Int(cResult.completion_tokens), + modelUsed: modelId, + latencyMs: TimeInterval(cResult.total_time_ms), + framework: nil, + tokensPerSecond: Double(cResult.tokens_per_second), + timeToFirstTokenMs: cResult.time_to_first_token_ms > 0 + ? Double(cResult.time_to_first_token_ms) : nil, + structuredOutputValidation: nil, + thinkingTokens: nil, + responseTokens: Int(cResult.completion_tokens) + ) + } + + /// Initialize from C++ rac_llm_stream_result_t + public init(from cStreamResult: rac_llm_stream_result_t, modelId: String) { + let metrics = cStreamResult.metrics + self.init( + text: cStreamResult.text.map { String(cString: $0) } ?? "", + thinkingContent: cStreamResult.thinking_content.map { String(cString: $0) }, + inputTokens: Int(metrics.prompt_tokens), + tokensUsed: Int(metrics.tokens_generated), + modelUsed: modelId, + latencyMs: TimeInterval(metrics.total_time_ms), + framework: nil, + tokensPerSecond: Double(metrics.tokens_per_second), + timeToFirstTokenMs: metrics.time_to_first_token_ms > 0 + ? Double(metrics.time_to_first_token_ms) : nil, + structuredOutputValidation: nil, + thinkingTokens: metrics.thinking_tokens > 0 ? Int(metrics.thinking_tokens) : nil, + responseTokens: Int(metrics.response_tokens) + ) + } +} + +// MARK: - LLM Streaming Result + +/// Container for streaming generation with metrics +public struct LLMStreamingResult: Sendable { + + /// Stream of tokens as they are generated + public let stream: AsyncThrowingStream + + /// Task that completes with final generation result including metrics + public let result: Task + + public init( + stream: AsyncThrowingStream, + result: Task + ) { + self.stream = stream + self.result = result + } +} + +// MARK: - Thinking Tag Pattern + +/// Pattern for extracting thinking/reasoning content from model output +public struct ThinkingTagPattern: Codable, Sendable { + + public let openingTag: String + public let closingTag: String + + public init(openingTag: String, closingTag: String) { + self.openingTag = openingTag + self.closingTag = closingTag + } + + /// Default pattern used by models like DeepSeek and Hermes + public static let defaultPattern = ThinkingTagPattern( + openingTag: "", + closingTag: "" + ) + + /// Alternative pattern with full "thinking" word + public static let thinkingPattern = ThinkingTagPattern( + openingTag: "", + closingTag: "" + ) + + /// Custom pattern for models that use different tags + public static func custom(opening: String, closing: String) -> ThinkingTagPattern { + ThinkingTagPattern(openingTag: opening, closingTag: closing) + } + + // MARK: - C++ Bridge (rac_thinking_tag_pattern_t) + + /// Execute a closure with the C++ equivalent pattern struct + public func withCPattern(_ body: (UnsafePointer) throws -> T) rethrows -> T { + return try openingTag.withCString { openingPtr in + return try closingTag.withCString { closingPtr in + var cPattern = rac_thinking_tag_pattern_t() + cPattern.opening_tag = openingPtr + cPattern.closing_tag = closingPtr + return try body(&cPattern) + } + } + } + + /// Initialize from C++ rac_thinking_tag_pattern_t + public init(from cPattern: rac_thinking_tag_pattern_t) { + self.init( + openingTag: cPattern.opening_tag.map { String(cString: $0) } ?? "", + closingTag: cPattern.closing_tag.map { String(cString: $0) } ?? "" + ) + } +} + +// MARK: - Structured Output Types + +/// Protocol for types that can be generated as structured output from LLMs +public protocol Generatable: Codable { + /// The JSON schema for this type + static var jsonSchema: String { get } +} + +public extension Generatable { + /// Generate a basic JSON schema from the type + static var jsonSchema: String { + return """ + { + "type": "object", + "additionalProperties": false + } + """ + } + + /// Type-specific generation hints + static var generationHints: GenerationHints? { + return nil + } +} + +/// Structured output configuration +public struct StructuredOutputConfig: @unchecked Sendable { + /// The type to generate + public let type: Generatable.Type + + /// Whether to include schema in prompt + public let includeSchemaInPrompt: Bool + + public init( + type: Generatable.Type, + includeSchemaInPrompt: Bool = true + ) { + self.type = type + self.includeSchemaInPrompt = includeSchemaInPrompt + } +} + +/// Hints for customizing structured output generation +public struct GenerationHints: Sendable { + public let temperature: Float? + public let maxTokens: Int? + public let systemRole: String? + + public init(temperature: Float? = nil, maxTokens: Int? = nil, systemRole: String? = nil) { + self.temperature = temperature + self.maxTokens = maxTokens + self.systemRole = systemRole + } +} + +/// Token emitted during streaming +public struct StreamToken: Sendable { + public let text: String + public let timestamp: Date + public let tokenIndex: Int + + public init(text: String, timestamp: Date = Date(), tokenIndex: Int) { + self.text = text + self.timestamp = timestamp + self.tokenIndex = tokenIndex + } +} + +/// Result containing both the token stream and final parsed result +public struct StructuredOutputStreamResult: @unchecked Sendable { + /// Stream of tokens as they're generated + public let tokenStream: AsyncThrowingStream + + /// Final parsed result (available after stream completes) + public let result: Task +} + +/// Structured output validation result +public struct StructuredOutputValidation: Sendable { + public let isValid: Bool + public let containsJSON: Bool + public let error: String? + + public init(isValid: Bool, containsJSON: Bool, error: String?) { + self.isValid = isValid + self.containsJSON = containsJSON + self.error = error + } +} + +// MARK: - Stream Accumulator + +/// Accumulates tokens during streaming for later parsing +public actor StreamAccumulator { + private var text = "" + private var isComplete = false + private var completionContinuation: CheckedContinuation? + + public init() {} + + public func append(_ token: String) { + text += token + } + + public var fullText: String { + return text + } + + public func markComplete() { + guard !isComplete else { return } + isComplete = true + completionContinuation?.resume() + completionContinuation = nil + } + + public func waitForCompletion() async { + guard !isComplete else { return } + + await withCheckedContinuation { continuation in + if isComplete { + continuation.resume() + } else { + completionContinuation = continuation + } + } + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/LLM/RunAnywhere+StructuredOutput.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/LLM/RunAnywhere+StructuredOutput.swift new file mode 100644 index 000000000..2656ae702 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/LLM/RunAnywhere+StructuredOutput.swift @@ -0,0 +1,279 @@ +// +// RunAnywhere+StructuredOutput.swift +// RunAnywhere SDK +// +// Public API for structured output generation. +// Uses C++ rac_structured_output_* APIs for JSON extraction. +// + +import CRACommons +import Foundation + +// MARK: - Structured Output Extensions + +public extension RunAnywhere { + + /// Generate structured output that conforms to a Generatable type (non-streaming) + /// - Parameters: + /// - type: The type to generate (must conform to Generatable) + /// - prompt: The prompt to generate from + /// - options: Generation options (structured output config will be added automatically) + /// - Returns: The generated object of the specified type + static func generateStructured( + _ type: T.Type, + prompt: String, + options: LLMGenerationOptions? = nil + ) async throws -> T { + // Get system prompt from C++ + let systemPrompt = getStructuredOutputSystemPrompt(for: type) + + // Create effective options with system prompt + let effectiveOptions = LLMGenerationOptions( + maxTokens: options?.maxTokens ?? type.generationHints?.maxTokens ?? 1500, + temperature: options?.temperature ?? type.generationHints?.temperature ?? 0.7, + topP: options?.topP ?? 1.0, + stopSequences: options?.stopSequences ?? [], + streamingEnabled: false, + preferredFramework: options?.preferredFramework, + structuredOutput: StructuredOutputConfig( + type: type, + includeSchemaInPrompt: false + ), + systemPrompt: systemPrompt + ) + + // Generate text via CppBridge.LLM + let generationResult = try await generateForStructuredOutput(prompt, options: effectiveOptions) + + // Extract JSON using C++ and parse to Swift type + return try parseStructuredOutput(from: generationResult.text, type: type) + } + + /// Generate structured output with streaming support + /// - Parameters: + /// - type: The type to generate (must conform to Generatable) + /// - content: The content to generate from (e.g., educational content for quiz) + /// - options: Generation options (optional) + /// - Returns: A structured output stream containing tokens and final result + static func generateStructuredStream( + _ type: T.Type, + content: String, + options: LLMGenerationOptions? = nil + ) -> StructuredOutputStreamResult { + let accumulator = StreamAccumulator() + + // Get system prompt from C++ + let systemPrompt = getStructuredOutputSystemPrompt(for: type) + + // Create effective options with system prompt + let effectiveOptions = LLMGenerationOptions( + maxTokens: options?.maxTokens ?? type.generationHints?.maxTokens ?? 1500, + temperature: options?.temperature ?? type.generationHints?.temperature ?? 0.7, + topP: options?.topP ?? 1.0, + stopSequences: options?.stopSequences ?? [], + streamingEnabled: true, + preferredFramework: options?.preferredFramework, + structuredOutput: StructuredOutputConfig( + type: type, + includeSchemaInPrompt: false + ), + systemPrompt: systemPrompt + ) + + // Create token stream + let tokenStream = AsyncThrowingStream { continuation in + Task { + do { + var tokenIndex = 0 + + // Stream tokens via public API + let streamingResult = try await generateStream(content, options: effectiveOptions) + for try await token in streamingResult.stream { + let streamToken = StreamToken( + text: token, + timestamp: Date(), + tokenIndex: tokenIndex + ) + + // Accumulate for parsing + await accumulator.append(token) + + // Yield to UI + continuation.yield(streamToken) + tokenIndex += 1 + } + + await accumulator.markComplete() + continuation.finish() + } catch { + await accumulator.markComplete() + continuation.finish(throwing: error) + } + } + } + + // Create result task that waits for streaming to complete + let resultTask = Task { + // Wait for accumulation to complete + await accumulator.waitForCompletion() + + // Get full response + let fullResponse = await accumulator.fullText + + // Parse using C++ extraction + Swift decoding with retry logic + var lastError: Error? + + for attempt in 1...3 { + do { + return try parseStructuredOutput(from: fullResponse, type: type) + } catch { + lastError = error + if attempt < 3 { + try? await Task.sleep(nanoseconds: 100_000_000) + } + } + } + + throw lastError ?? SDKError.llm(.extractionFailed, "Failed to parse structured output after 3 attempts") + } + + return StructuredOutputStreamResult(tokenStream: tokenStream, result: resultTask) + } + + /// Generate with structured output configuration + /// - Parameters: + /// - prompt: The prompt to generate from + /// - structuredOutput: Structured output configuration + /// - options: Generation options + /// - Returns: Generation result with structured data + static func generateWithStructuredOutput( + prompt: String, + structuredOutput: StructuredOutputConfig, + options: LLMGenerationOptions? = nil + ) async throws -> LLMGenerationResult { + let baseOptions = options ?? LLMGenerationOptions() + let internalOptions = LLMGenerationOptions( + maxTokens: baseOptions.maxTokens, + temperature: baseOptions.temperature, + topP: baseOptions.topP, + stopSequences: baseOptions.stopSequences, + streamingEnabled: baseOptions.streamingEnabled, + preferredFramework: baseOptions.preferredFramework, + structuredOutput: structuredOutput, + systemPrompt: baseOptions.systemPrompt + ) + + return try await generateForStructuredOutput(prompt, options: internalOptions) + } + + // MARK: - Private Helpers + + /// Get system prompt for structured output using C++ API + private static func getStructuredOutputSystemPrompt(for type: T.Type) -> String { + var promptPtr: UnsafeMutablePointer? + + let result = type.jsonSchema.withCString { schemaPtr in + rac_structured_output_get_system_prompt(schemaPtr, &promptPtr) + } + + guard result == RAC_SUCCESS, let ptr = promptPtr else { + // Fallback to basic prompt if C++ fails + return """ + You are a JSON generator that outputs ONLY valid JSON without any additional text. + Start with { and end with }. No text before or after. + Expected schema: \(type.jsonSchema) + """ + } + + let prompt = String(cString: ptr) + rac_free(ptr) + return prompt + } + + /// Parse structured output using C++ JSON extraction + Swift decoding + private static func parseStructuredOutput( + from text: String, + type: T.Type + ) throws -> T { + // Use C++ to extract JSON from the response + var jsonPtr: UnsafeMutablePointer? + + let extractResult = text.withCString { textPtr in + rac_structured_output_extract_json(textPtr, &jsonPtr, nil) + } + + guard extractResult == RAC_SUCCESS, let ptr = jsonPtr else { + throw SDKError.llm(.extractionFailed, "No valid JSON found in the response") + } + + let jsonString = String(cString: ptr) + rac_free(ptr) + + // Convert to Data and decode using Swift's JSONDecoder + guard let jsonData = jsonString.data(using: .utf8) else { + throw SDKError.llm(.invalidFormat, "Failed to convert JSON string to data") + } + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + do { + return try decoder.decode(type, from: jsonData) + } catch { + throw SDKError.llm(.validationFailed, "JSON decoding failed: \(error.localizedDescription)") + } + } + + /// Internal generation for structured output (calls C++ directly) + private static func generateForStructuredOutput( + _ prompt: String, + options: LLMGenerationOptions + ) async throws -> LLMGenerationResult { + guard isInitialized else { + throw SDKError.general(.notInitialized, "SDK not initialized") + } + + let handle = try await CppBridge.LLM.shared.getHandle() + + guard await CppBridge.LLM.shared.isLoaded else { + throw SDKError.llm(.notInitialized, "LLM model not loaded") + } + + let modelId = await CppBridge.LLM.shared.currentModelId ?? "unknown" + let startTime = Date() + + // Build C options + var cOptions = rac_llm_options_t() + cOptions.max_tokens = Int32(options.maxTokens) + cOptions.temperature = options.temperature + cOptions.top_p = options.topP + cOptions.streaming_enabled = RAC_FALSE + + // Generate + var llmResult = rac_llm_result_t() + let generateResult = prompt.withCString { promptPtr in + rac_llm_component_generate(handle, promptPtr, &cOptions, &llmResult) + } + + guard generateResult == RAC_SUCCESS else { + throw SDKError.llm(.generationFailed, "Generation failed: \(generateResult)") + } + + let totalTimeMs = Date().timeIntervalSince(startTime) * 1000 + let generatedText = llmResult.text.map { String(cString: $0) } ?? "" + + return LLMGenerationResult( + text: generatedText, + thinkingContent: nil, + inputTokens: Int(llmResult.prompt_tokens), + tokensUsed: Int(llmResult.completion_tokens), + modelUsed: modelId, + latencyMs: totalTimeMs, + framework: "llamacpp", + tokensPerSecond: Double(llmResult.tokens_per_second), + timeToFirstTokenMs: nil, + thinkingTokens: 0, + responseTokens: Int(llmResult.completion_tokens) + ) + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/LLM/RunAnywhere+TextGeneration.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/LLM/RunAnywhere+TextGeneration.swift new file mode 100644 index 000000000..5353259ee --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/LLM/RunAnywhere+TextGeneration.swift @@ -0,0 +1,359 @@ +// +// RunAnywhere+TextGeneration.swift +// RunAnywhere SDK +// +// Public API for text generation (LLM) operations. +// Calls C++ directly via CppBridge.LLM for all operations. +// Events are emitted by C++ layer via CppEventBridge. +// + +import CRACommons +import Foundation + +// MARK: - Text Generation + +public extension RunAnywhere { + + /// Simple text generation with automatic event publishing + /// - Parameter prompt: The text prompt + /// - Returns: Generated response (text only) + static func chat(_ prompt: String) async throws -> String { + let result = try await generate(prompt, options: nil) + return result.text + } + + /// Generate text with full metrics and analytics + /// - Parameters: + /// - prompt: The text prompt + /// - options: Generation options (optional) + /// - Returns: GenerationResult with full metrics including thinking tokens, timing, performance, etc. + /// - Note: Events are automatically dispatched via C++ layer + static func generate( + _ prompt: String, + options: LLMGenerationOptions? = nil + ) async throws -> LLMGenerationResult { + guard isInitialized else { + throw SDKError.general(.notInitialized, "SDK not initialized") + } + + try await ensureServicesReady() + + // Get handle from CppBridge.LLM + let handle = try await CppBridge.LLM.shared.getHandle() + + // Verify model is loaded + guard await CppBridge.LLM.shared.isLoaded else { + throw SDKError.llm(.notInitialized, "LLM model not loaded") + } + + let modelId = await CppBridge.LLM.shared.currentModelId ?? "unknown" + let opts = options ?? LLMGenerationOptions() + + let startTime = Date() + + // Build C options + var cOptions = rac_llm_options_t() + cOptions.max_tokens = Int32(opts.maxTokens) + cOptions.temperature = opts.temperature + cOptions.top_p = opts.topP + cOptions.streaming_enabled = RAC_FALSE + + // Generate (C++ emits events) + var llmResult = rac_llm_result_t() + let generateResult = prompt.withCString { promptPtr in + rac_llm_component_generate(handle, promptPtr, &cOptions, &llmResult) + } + + guard generateResult == RAC_SUCCESS else { + throw SDKError.llm(.generationFailed, "Generation failed: \(generateResult)") + } + + let endTime = Date() + let totalTimeMs = endTime.timeIntervalSince(startTime) * 1000 + + // Extract result + let generatedText: String + if let textPtr = llmResult.text { + generatedText = String(cString: textPtr) + } else { + generatedText = "" + } + let inputTokens = Int(llmResult.prompt_tokens) + let outputTokens = Int(llmResult.completion_tokens) + let tokensPerSecond = llmResult.tokens_per_second > 0 ? Double(llmResult.tokens_per_second) : 0 + + return LLMGenerationResult( + text: generatedText, + thinkingContent: nil, + inputTokens: inputTokens, + tokensUsed: outputTokens, + modelUsed: modelId, + latencyMs: totalTimeMs, + framework: "llamacpp", + tokensPerSecond: tokensPerSecond, + timeToFirstTokenMs: nil, + thinkingTokens: 0, + responseTokens: outputTokens + ) + } + + /// Streaming text generation with complete analytics + /// + /// Returns both a token stream for real-time display and a task that resolves to complete metrics. + /// + /// Example usage: + /// ```swift + /// let result = try await RunAnywhere.generateStream(prompt) + /// + /// // Display tokens in real-time + /// for try await token in result.stream { + /// print(token, terminator: "") + /// } + /// + /// // Get complete analytics after streaming finishes + /// let metrics = try await result.result.value + /// print("Speed: \(metrics.performanceMetrics.tokensPerSecond) tok/s") + /// print("Tokens: \(metrics.tokensUsed)") + /// print("Time: \(metrics.latencyMs)ms") + /// ``` + /// + /// - Parameters: + /// - prompt: The text prompt + /// - options: Generation options (optional) + /// - Returns: StreamingResult containing both the token stream and final metrics task + static func generateStream( + _ prompt: String, + options: LLMGenerationOptions? = nil + ) async throws -> LLMStreamingResult { + guard isInitialized else { + throw SDKError.general(.notInitialized, "SDK not initialized") + } + + try await ensureServicesReady() + + let handle = try await CppBridge.LLM.shared.getHandle() + + guard await CppBridge.LLM.shared.isLoaded else { + throw SDKError.llm(.notInitialized, "LLM model not loaded") + } + + let modelId = await CppBridge.LLM.shared.currentModelId ?? "unknown" + let opts = options ?? LLMGenerationOptions() + + let collector = LLMStreamingMetricsCollector(modelId: modelId, promptLength: prompt.count) + + var cOptions = rac_llm_options_t() + cOptions.max_tokens = Int32(opts.maxTokens) + cOptions.temperature = opts.temperature + cOptions.top_p = opts.topP + cOptions.streaming_enabled = RAC_TRUE + + let stream = createTokenStream( + prompt: prompt, + handle: handle, + options: cOptions, + collector: collector + ) + + let resultTask = Task { + try await collector.waitForResult() + } + + return LLMStreamingResult(stream: stream, result: resultTask) + } + + // MARK: - Private Streaming Helpers + + private static func createTokenStream( + prompt: String, + handle: UnsafeMutableRawPointer, + options: rac_llm_options_t, + collector: LLMStreamingMetricsCollector + ) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + Task { + do { + await collector.markStart() + + let context = LLMStreamCallbackContext(continuation: continuation, collector: collector) + let contextPtr = Unmanaged.passRetained(context).toOpaque() + + let callbacks = LLMStreamCallbacks.create() + var cOptions = options + + let streamResult = prompt.withCString { promptPtr in + rac_llm_component_generate_stream( + handle, + promptPtr, + &cOptions, + callbacks.token, + callbacks.complete, + callbacks.error, + contextPtr + ) + } + + if streamResult != RAC_SUCCESS { + Unmanaged.fromOpaque(contextPtr).release() + let error = SDKError.llm(.generationFailed, "Stream generation failed: \(streamResult)") + continuation.finish(throwing: error) + await collector.markFailed(error) + } + } catch { + continuation.finish(throwing: error) + await collector.markFailed(error) + } + } + } + } + +} + +// MARK: - Streaming Callbacks + +private enum LLMStreamCallbacks { + typealias TokenFn = rac_llm_component_token_callback_fn + typealias CompleteFn = rac_llm_component_complete_callback_fn + typealias ErrorFn = rac_llm_component_error_callback_fn + + struct Callbacks { + let token: TokenFn + let complete: CompleteFn + let error: ErrorFn + } + + static func create() -> Callbacks { + let tokenCallback: TokenFn = { tokenPtr, userData -> rac_bool_t in + guard let tokenPtr = tokenPtr, let userData = userData else { return RAC_TRUE } + let ctx = Unmanaged.fromOpaque(userData).takeUnretainedValue() + let token = String(cString: tokenPtr) + Task { + await ctx.collector.recordToken(token) + ctx.continuation.yield(token) + } + return RAC_TRUE + } + + let completeCallback: CompleteFn = { _, userData in + guard let userData = userData else { return } + let ctx = Unmanaged.fromOpaque(userData).takeUnretainedValue() + ctx.continuation.finish() + Task { await ctx.collector.markComplete() } + } + + let errorCallback: ErrorFn = { _, errorMsg, userData in + guard let userData = userData else { return } + let ctx = Unmanaged.fromOpaque(userData).takeUnretainedValue() + let message = errorMsg.map { String(cString: $0) } ?? "Unknown error" + let error = SDKError.llm(.generationFailed, message) + ctx.continuation.finish(throwing: error) + Task { await ctx.collector.markFailed(error) } + } + + return Callbacks(token: tokenCallback, complete: completeCallback, error: errorCallback) + } +} + +// MARK: - Streaming Callback Context + +private final class LLMStreamCallbackContext: @unchecked Sendable { + let continuation: AsyncThrowingStream.Continuation + let collector: LLMStreamingMetricsCollector + + init(continuation: AsyncThrowingStream.Continuation, collector: LLMStreamingMetricsCollector) { + self.continuation = continuation + self.collector = collector + } +} + +// MARK: - Streaming Metrics Collector + +/// Internal actor for collecting streaming metrics +private actor LLMStreamingMetricsCollector { + private let modelId: String + private let promptLength: Int + + private var startTime: Date? + private var firstTokenTime: Date? + private var fullText = "" + private var tokenCount = 0 + private var firstTokenRecorded = false + private var isComplete = false + private var error: Error? + private var resultContinuation: CheckedContinuation? + + init(modelId: String, promptLength: Int) { + self.modelId = modelId + self.promptLength = promptLength + } + + func markStart() { + startTime = Date() + } + + func recordToken(_ token: String) { + fullText += token + tokenCount += 1 + + if !firstTokenRecorded { + firstTokenRecorded = true + firstTokenTime = Date() + } + } + + func markComplete() { + isComplete = true + if let continuation = resultContinuation { + continuation.resume(returning: buildResult()) + resultContinuation = nil + } + } + + func markFailed(_ error: Error) { + self.error = error + if let continuation = resultContinuation { + continuation.resume(throwing: error) + resultContinuation = nil + } + } + + func waitForResult() async throws -> LLMGenerationResult { + if isComplete { + return buildResult() + } + if let error = error { + throw error + } + return try await withCheckedThrowingContinuation { continuation in + resultContinuation = continuation + } + } + + private func buildResult() -> LLMGenerationResult { + let endTime = Date() + let latencyMs = (startTime.map { endTime.timeIntervalSince($0) } ?? 0) * 1000 + + var timeToFirstTokenMs: Double? + if let start = startTime, let firstToken = firstTokenTime { + timeToFirstTokenMs = firstToken.timeIntervalSince(start) * 1000 + } + + let inputTokens = max(1, promptLength / 4) + let outputTokens = max(1, fullText.count / 4) + let tokensPerSecond = latencyMs > 0 ? Double(outputTokens) / (latencyMs / 1000.0) : 0 + + return LLMGenerationResult( + text: fullText, + thinkingContent: nil, + inputTokens: inputTokens, + tokensUsed: outputTokens, + modelUsed: modelId, + latencyMs: latencyMs, + framework: "llamacpp", + tokensPerSecond: tokensPerSecond, + timeToFirstTokenMs: timeToFirstTokenMs, + thinkingTokens: 0, + responseTokens: outputTokens + ) + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/Models/ModelTypes.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/Models/ModelTypes.swift new file mode 100644 index 000000000..d1ae0d91d --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/Models/ModelTypes.swift @@ -0,0 +1,485 @@ +// +// ModelTypes.swift +// RunAnywhere SDK +// +// Public types for model management. +// These are thin wrappers over C++ types in rac_model_types.h +// Business logic (format support, capability checks) is in C++. +// + +import CRACommons +import Foundation + +// MARK: - Model Source + +/// Source of model data (where the model info came from) +public enum ModelSource: String, Codable, Sendable { + /// Model info came from remote API (backend model catalog) + case remote + + /// Model info was provided locally via SDK input (addModel calls) + case local +} + +// MARK: - Model Format + +/// Model formats supported +public enum ModelFormat: String, CaseIterable, Codable, Sendable { + case onnx + case ort + case gguf + case bin + case unknown +} + +// MARK: - Model Category + +/// Defines the category/type of a model based on its input/output modality +public enum ModelCategory: String, CaseIterable, Codable, Sendable { + case language = "language" // Text-to-text models (LLMs) + case speechRecognition = "speech-recognition" // Voice-to-text models (ASR) + case speechSynthesis = "speech-synthesis" // Text-to-voice models (TTS) + case vision = "vision" // Image understanding models + case imageGeneration = "image-generation" // Text-to-image models + case multimodal = "multimodal" // Models that handle multiple modalities + case audio = "audio" // Audio processing (diarization, etc.) + + /// Whether this category typically requires context length + /// Note: C++ equivalent is rac_model_category_requires_context_length() + public var requiresContextLength: Bool { + switch self { + case .language, .multimodal: + return true + case .speechRecognition, .speechSynthesis, .vision, .imageGeneration, .audio: + return false + } + } + + /// Whether this category typically supports thinking/reasoning + /// Note: C++ equivalent is rac_model_category_supports_thinking() + public var supportsThinking: Bool { + switch self { + case .language, .multimodal: + return true + case .speechRecognition, .speechSynthesis, .vision, .imageGeneration, .audio: + return false + } + } +} + +// MARK: - Inference Framework + +/// Supported inference frameworks/runtimes for executing models +public enum InferenceFramework: String, CaseIterable, Codable, Sendable { + // Model-based frameworks + case onnx = "ONNX" + case llamaCpp = "LlamaCpp" + case foundationModels = "FoundationModels" + case systemTTS = "SystemTTS" + case fluidAudio = "FluidAudio" + + // Special cases + case builtIn = "BuiltIn" // For simple services (e.g., energy-based VAD) + case none = "None" // For services that don't use a model + case unknown = "Unknown" // For unknown/unspecified frameworks + + /// Human-readable display name for the framework + public var displayName: String { + switch self { + case .onnx: return "ONNX Runtime" + case .llamaCpp: return "llama.cpp" + case .foundationModels: return "Foundation Models" + case .systemTTS: return "System TTS" + case .fluidAudio: return "FluidAudio" + case .builtIn: return "Built-in" + case .none: return "None" + case .unknown: return "Unknown" + } + } + + /// Snake_case key for analytics/telemetry + public var analyticsKey: String { + switch self { + case .onnx: return "onnx" + case .llamaCpp: return "llama_cpp" + case .foundationModels: return "foundation_models" + case .systemTTS: return "system_tts" + case .fluidAudio: return "fluid_audio" + case .builtIn: return "built_in" + case .none: return "none" + case .unknown: return "unknown" + } + } +} + +// MARK: - InferenceFramework C++ Bridge + +public extension InferenceFramework { + /// Convert Swift InferenceFramework to C rac_inference_framework_t + func toCFramework() -> rac_inference_framework_t { + switch self { + case .onnx: return RAC_FRAMEWORK_ONNX + case .llamaCpp: return RAC_FRAMEWORK_LLAMACPP + case .foundationModels: return RAC_FRAMEWORK_FOUNDATION_MODELS + case .systemTTS: return RAC_FRAMEWORK_SYSTEM_TTS + case .fluidAudio: return RAC_FRAMEWORK_FLUID_AUDIO + case .builtIn: return RAC_FRAMEWORK_BUILTIN + case .none: return RAC_FRAMEWORK_NONE + case .unknown: return RAC_FRAMEWORK_UNKNOWN + } + } + + /// Create Swift InferenceFramework from C rac_inference_framework_t + static func fromCFramework(_ cFramework: rac_inference_framework_t) -> InferenceFramework { + switch cFramework { + case RAC_FRAMEWORK_ONNX: return .onnx + case RAC_FRAMEWORK_LLAMACPP: return .llamaCpp + case RAC_FRAMEWORK_FOUNDATION_MODELS: return .foundationModels + case RAC_FRAMEWORK_SYSTEM_TTS: return .systemTTS + case RAC_FRAMEWORK_FLUID_AUDIO: return .fluidAudio + case RAC_FRAMEWORK_BUILTIN: return .builtIn + case RAC_FRAMEWORK_NONE: return .none + default: return .unknown + } + } + + /// Initialize from a string, matching case-insensitively. + init?(caseInsensitive string: String) { + let lowercased = string.lowercased() + + if let exact = InferenceFramework(rawValue: string) { + self = exact + return + } + + if let framework = InferenceFramework.allCases.first(where: { $0.rawValue.lowercased() == lowercased }) { + self = framework + return + } + + if let framework = InferenceFramework.allCases.first(where: { $0.analyticsKey == lowercased }) { + self = framework + return + } + + return nil + } +} + +// MARK: - Archive Types + +/// Supported archive formats for model packaging +public enum ArchiveType: String, CaseIterable, Codable, Sendable { + case zip = "zip" + case tarBz2 = "tar.bz2" + case tarGz = "tar.gz" + case tarXz = "tar.xz" + + /// File extension for this archive type + public var fileExtension: String { + rawValue + } + + /// Detect archive type from URL + /// Note: C++ equivalent is rac_archive_type_from_path() + public static func from(url: URL) -> ArchiveType? { + let path = url.path.lowercased() + if path.hasSuffix(".tar.bz2") || path.hasSuffix(".tbz2") { + return .tarBz2 + } else if path.hasSuffix(".tar.gz") || path.hasSuffix(".tgz") { + return .tarGz + } else if path.hasSuffix(".tar.xz") || path.hasSuffix(".txz") { + return .tarXz + } else if path.hasSuffix(".zip") { + return .zip + } + return nil + } +} + +/// Describes the internal structure of an archive after extraction +public enum ArchiveStructure: String, Codable, Sendable, Equatable { + case singleFileNested + case directoryBased + case nestedDirectory + case unknown +} + +// MARK: - Expected Model Files + +/// Describes what files are expected after model extraction/download +public struct ExpectedModelFiles: Codable, Sendable, Equatable { + public let requiredPatterns: [String] + public let optionalPatterns: [String] + public let description: String? + + public init( + requiredPatterns: [String] = [], + optionalPatterns: [String] = [], + description: String? = nil + ) { + self.requiredPatterns = requiredPatterns + self.optionalPatterns = optionalPatterns + self.description = description + } + + public static let none = ExpectedModelFiles() +} + +/// Describes a file that needs to be downloaded as part of a multi-file model +public struct ModelFileDescriptor: Codable, Sendable, Equatable { + public let relativePath: String + public let destinationPath: String + public let isRequired: Bool + + public init(relativePath: String, destinationPath: String, isRequired: Bool = true) { + self.relativePath = relativePath + self.destinationPath = destinationPath + self.isRequired = isRequired + } +} + +// MARK: - Model Artifact Type + +/// Describes how a model is packaged and what processing is needed after download. +public enum ModelArtifactType: Codable, Sendable, Equatable { + case singleFile(expectedFiles: ExpectedModelFiles = .none) + case archive(ArchiveType, structure: ArchiveStructure, expectedFiles: ExpectedModelFiles = .none) + case multiFile([ModelFileDescriptor]) + case custom(strategyId: String) + case builtIn + + public var requiresExtraction: Bool { + if case .archive = self { return true } + return false + } + + public var requiresDownload: Bool { + if case .builtIn = self { return false } + return true + } + + public var expectedFiles: ExpectedModelFiles { + switch self { + case .singleFile(let expected), .archive(_, _, let expected): + return expected + default: + return .none + } + } + + public var displayName: String { + switch self { + case .singleFile: + return "Single File" + case .archive(let type, _, _): + return "\(type.rawValue.uppercased()) Archive" + case .multiFile(let files): + return "Multi-File (\(files.count) files)" + case .custom(let strategyId): + return "Custom (\(strategyId))" + case .builtIn: + return "Built-in" + } + } + + /// Infer artifact type from download URL + /// Note: C++ equivalent is rac_artifact_infer_from_url() + public static func infer(from url: URL?, format _: ModelFormat) -> ModelArtifactType { + guard let url = url else { + return .singleFile(expectedFiles: .none) + } + if let archiveType = ArchiveType.from(url: url) { + return .archive(archiveType, structure: .unknown, expectedFiles: .none) + } + return .singleFile(expectedFiles: .none) + } +} + +// MARK: - ModelArtifactType Codable + +extension ModelArtifactType { + private enum CodingKeys: String, CodingKey { + case type, archiveType, structure, expectedFiles, files, strategyId + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + + switch type { + case "singleFile": + let expected = try container.decodeIfPresent(ExpectedModelFiles.self, forKey: .expectedFiles) ?? .none + self = .singleFile(expectedFiles: expected) + case "archive": + let archiveType = try container.decode(ArchiveType.self, forKey: .archiveType) + let structure = try container.decode(ArchiveStructure.self, forKey: .structure) + let expected = try container.decodeIfPresent(ExpectedModelFiles.self, forKey: .expectedFiles) ?? .none + self = .archive(archiveType, structure: structure, expectedFiles: expected) + case "multiFile": + let files = try container.decode([ModelFileDescriptor].self, forKey: .files) + self = .multiFile(files) + case "custom": + let strategyId = try container.decode(String.self, forKey: .strategyId) + self = .custom(strategyId: strategyId) + case "builtIn": + self = .builtIn + default: + self = .singleFile() + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case .singleFile(let expected): + try container.encode("singleFile", forKey: .type) + if expected != .none { + try container.encode(expected, forKey: .expectedFiles) + } + case .archive(let archiveType, let structure, let expected): + try container.encode("archive", forKey: .type) + try container.encode(archiveType, forKey: .archiveType) + try container.encode(structure, forKey: .structure) + if expected != .none { + try container.encode(expected, forKey: .expectedFiles) + } + case .multiFile(let files): + try container.encode("multiFile", forKey: .type) + try container.encode(files, forKey: .files) + case .custom(let strategyId): + try container.encode("custom", forKey: .type) + try container.encode(strategyId, forKey: .strategyId) + case .builtIn: + try container.encode("builtIn", forKey: .type) + } + } +} + +// MARK: - Model Info + +/// Information about a model - in-memory entity +public struct ModelInfo: Codable, Sendable, Identifiable { + // Essential identifiers + public let id: String + public let name: String + public let category: ModelCategory + + // Format and location + public let format: ModelFormat + public let downloadURL: URL? + public var localPath: URL? + + // Artifact type + public let artifactType: ModelArtifactType + + // Size information + public let downloadSize: Int64? + + // Framework + public let framework: InferenceFramework + + // Model-specific capabilities + public let contextLength: Int? + public let supportsThinking: Bool + public let thinkingPattern: ThinkingTagPattern? + + // Optional metadata + public let description: String? + + // Tracking fields + public let source: ModelSource + public let createdAt: Date + public var updatedAt: Date + + // MARK: - Computed Properties + + /// Whether this model is downloaded and available locally + public var isDownloaded: Bool { + guard let localPath = localPath else { return false } + + if localPath.scheme == "builtin" { + return true + } + + let (exists, isDirectory) = FileOperationsUtilities.existsWithType(at: localPath) + + if exists && isDirectory { + return FileOperationsUtilities.isNonEmptyDirectory(at: localPath) + } + + return exists + } + + /// Whether this model is available for use + public var isAvailable: Bool { + isDownloaded + } + + /// Whether this is a built-in platform model + public var isBuiltIn: Bool { + if artifactType == .builtIn { + return true + } + if let localPath = localPath, localPath.scheme == "builtin" { + return true + } + return framework == .foundationModels || framework == .systemTTS + } + + private enum CodingKeys: String, CodingKey { + case id, name, category, format, downloadURL, localPath + case artifactType + case downloadSize + case framework + case contextLength, supportsThinking, thinkingPattern + case description + case source, createdAt, updatedAt + } + + public init( + id: String, + name: String, + category: ModelCategory, + format: ModelFormat, + framework: InferenceFramework, + downloadURL: URL? = nil, + localPath: URL? = nil, + artifactType: ModelArtifactType? = nil, + downloadSize: Int64? = nil, + contextLength: Int? = nil, + supportsThinking: Bool = false, + thinkingPattern: ThinkingTagPattern? = nil, + description: String? = nil, + source: ModelSource = .remote, + createdAt: Date = Date(), + updatedAt: Date = Date() + ) { + self.id = id + self.name = name + self.category = category + self.format = format + self.framework = framework + self.downloadURL = downloadURL + self.localPath = localPath + + self.artifactType = artifactType ?? ModelArtifactType.infer(from: downloadURL, format: format) + + self.downloadSize = downloadSize + + if category.requiresContextLength { + self.contextLength = contextLength ?? 2048 + } else { + self.contextLength = contextLength + } + + self.supportsThinking = category.supportsThinking ? supportsThinking : false + self.thinkingPattern = supportsThinking ? (thinkingPattern ?? .defaultPattern) : nil + + self.description = description + self.source = source + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/Models/RunAnywhere+Frameworks.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/Models/RunAnywhere+Frameworks.swift new file mode 100644 index 000000000..f3263c56a --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/Models/RunAnywhere+Frameworks.swift @@ -0,0 +1,61 @@ +// +// RunAnywhere+Frameworks.swift +// RunAnywhere SDK +// +// Public API for framework discovery and querying. +// + +import Foundation + +// MARK: - Framework Discovery API + +public extension RunAnywhere { + + /// Get all registered frameworks derived from available models + /// - Returns: Array of available inference frameworks that have models registered + static func getRegisteredFrameworks() async -> [InferenceFramework] { + // Derive frameworks from registered models - this is the source of truth + let allModels = await CppBridge.ModelRegistry.shared.getAll() + var frameworks: Set = [] + + for model in allModels { + // Add the model's framework (1:1 mapping) + frameworks.insert(model.framework) + } + + return Array(frameworks).sorted { $0.displayName < $1.displayName } + } + + /// Get all registered frameworks for a specific capability + /// - Parameter capability: The capability/component type to filter by + /// - Returns: Array of frameworks that provide the specified capability + static func getFrameworks(for capability: SDKComponent) async -> [InferenceFramework] { + let allModels = await CppBridge.ModelRegistry.shared.getAll() + var frameworks: Set = [] + + // Map capability to model categories + let relevantCategories: Set + switch capability { + case .llm: + relevantCategories = [.language, .multimodal] + case .stt: + relevantCategories = [.speechRecognition] + case .tts: + relevantCategories = [.speechSynthesis] + case .vad: + relevantCategories = [.audio] + case .voice: + relevantCategories = [.language, .speechRecognition, .speechSynthesis] + case .embedding: + // Embedding models could be language or multimodal + relevantCategories = [.language, .multimodal] + } + + for model in allModels where relevantCategories.contains(model.category) { + // Add the model's framework (1:1 mapping) + frameworks.insert(model.framework) + } + + return Array(frameworks).sorted { $0.displayName < $1.displayName } + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/Models/RunAnywhere+ModelAssignments.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/Models/RunAnywhere+ModelAssignments.swift new file mode 100644 index 000000000..62f00afca --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/Models/RunAnywhere+ModelAssignments.swift @@ -0,0 +1,186 @@ +import Foundation + +// MARK: - Model Registration API + +public extension RunAnywhere { + + /// Register a model from a download URL + /// Use this to add models for development or offline use + /// - Parameters: + /// - id: Explicit model ID. If nil, a stable ID is generated from the URL filename. + /// - name: Display name for the model + /// - url: Download URL for the model (e.g., HuggingFace) + /// - framework: Target inference framework + /// - modality: Model category (default: .language for LLMs) + /// - artifactType: How the model is packaged (archive, single file, etc.). If nil, inferred from URL. + /// - memoryRequirement: Estimated memory usage in bytes + /// - supportsThinking: Whether the model supports reasoning/thinking + /// - Returns: The created ModelInfo + @discardableResult + static func registerModel( + id: String? = nil, + name: String, + url: URL, + framework: InferenceFramework, + modality: ModelCategory = .language, + artifactType: ModelArtifactType? = nil, + memoryRequirement: Int64? = nil, + supportsThinking: Bool = false + ) -> ModelInfo { + // Generate model ID from URL filename if not provided + let modelId = id ?? generateModelId(from: url) + + // Detect format from URL extension + let format = detectFormat(from: url) + + // Create ModelInfo + let modelInfo = ModelInfo( + id: modelId, + name: name, + category: modality, + format: format, + framework: framework, + downloadURL: url, + localPath: nil, + artifactType: artifactType, + downloadSize: memoryRequirement, + contextLength: modality.requiresContextLength ? 2048 : nil, + supportsThinking: supportsThinking, + description: "User-added model", + source: .local + ) + + // Save to C++ registry (fire-and-forget) + Task { + do { + try await CppBridge.ModelRegistry.shared.save(modelInfo) + } catch { + SDKLogger(category: "RunAnywhere.Models").error("Failed to register model: \(error)") + } + } + + return modelInfo + } + + /// Register a model from a URL string + /// - Parameters: + /// - id: Explicit model ID. If nil, a stable ID is generated from the URL filename. + /// - name: Display name for the model + /// - urlString: Download URL string for the model + /// - framework: Target inference framework + /// - modality: Model category (default: .language for LLMs) + /// - artifactType: How the model is packaged (archive, single file, etc.). If nil, inferred from URL. + /// - memoryRequirement: Estimated memory usage in bytes + /// - supportsThinking: Whether the model supports reasoning/thinking + /// - Returns: The created ModelInfo, or nil if URL is invalid + @discardableResult + static func registerModel( + id: String? = nil, + name: String, + urlString: String, + framework: InferenceFramework, + modality: ModelCategory = .language, + artifactType: ModelArtifactType? = nil, + memoryRequirement: Int64? = nil, + supportsThinking: Bool = false + ) -> ModelInfo? { + guard let url = URL(string: urlString) else { + SDKLogger(category: "RunAnywhere.Models").error("Invalid URL: \(urlString)") + return nil + } + return registerModel( + id: id, + name: name, + url: url, + framework: framework, + modality: modality, + artifactType: artifactType, + memoryRequirement: memoryRequirement, + supportsThinking: supportsThinking + ) + } + + // MARK: - Private Helpers + + private static func generateModelId(from url: URL) -> String { + var filename = url.lastPathComponent + let knownExtensions = ["gz", "bz2", "tar", "zip", "gguf", "onnx", "ort", "bin"] + while let ext = filename.split(separator: ".").last, knownExtensions.contains(String(ext).lowercased()) { + filename = String(filename.dropLast(ext.count + 1)) + } + return filename + } + + private static func detectFormat(from url: URL) -> ModelFormat { + let ext = url.pathExtension.lowercased() + switch ext { + case "onnx": return .onnx + case "ort": return .ort + case "gguf": return .gguf + case "bin": return .bin + default: return .unknown + } + } +} + +// MARK: - Model Assignments API + +extension RunAnywhere { + + /// Fetch model assignments for the current device from the backend + /// This method fetches models assigned to this device based on device type and platform + /// - Parameter forceRefresh: Force refresh even if cached models are available + /// - Returns: Array of ModelInfo objects assigned to this device + /// - Throws: SDKError if fetching fails + public static func fetchModelAssignments(forceRefresh: Bool = false) async throws -> [ModelInfo] { + let logger = SDKLogger(category: "RunAnywhere.ModelAssignments") + + // Ensure SDK is initialized + guard isSDKInitialized else { + throw SDKError.general(.notInitialized, "SDK not initialized") + } + + // Ensure network services are initialized + try await ensureServicesReady() + + logger.info("Fetching model assignments...") + + // Delegate to C++ model assignment manager via bridge + let models = try await CppBridge.ModelAssignment.fetch(forceRefresh: forceRefresh) + logger.info("Successfully fetched \(models.count) model assignments") + + return models + } + + /// Get available models for a specific framework + /// - Parameter framework: The LLM framework to filter models for + /// - Returns: Array of ModelInfo objects compatible with the specified framework + public static func getModelsForFramework(_ framework: InferenceFramework) async throws -> [ModelInfo] { + // Ensure SDK is initialized + guard isSDKInitialized else { + throw SDKError.general(.notInitialized, "SDK not initialized") + } + + // Ensure network services are initialized + try await ensureServicesReady() + + // Use C++ bridge for filtering (uses cached data) + return CppBridge.ModelAssignment.getByFramework(framework) + } + + /// Get available models for a specific category + /// - Parameter category: The model category to filter models for + /// - Returns: Array of ModelInfo objects in the specified category + public static func getModelsForCategory(_ category: ModelCategory) async throws -> [ModelInfo] { + // Ensure SDK is initialized + guard isSDKInitialized else { + throw SDKError.general(.notInitialized, "SDK not initialized") + } + + // Ensure network services are initialized + try await ensureServicesReady() + + // Use C++ bridge for filtering (uses cached data) + return CppBridge.ModelAssignment.getByCategory(category) + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/Models/RunAnywhere+ModelManagement.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/Models/RunAnywhere+ModelManagement.swift new file mode 100644 index 000000000..0112a6199 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/Models/RunAnywhere+ModelManagement.swift @@ -0,0 +1,304 @@ +import Foundation + +// MARK: - Model Management + +extension RunAnywhere { + + /// Load an LLM model by ID + /// - Parameter modelId: The model identifier + public static func loadModel(_ modelId: String) async throws { + guard isInitialized else { + throw SDKError.general(.notInitialized, "SDK not initialized") + } + + try await ensureServicesReady() + + // Resolve model ID to local file path + let allModels = try await availableModels() + guard let modelInfo = allModels.first(where: { $0.id == modelId }) else { + throw SDKError.llm(.modelNotFound, "Model '\(modelId)' not found in registry") + } + + // Handle built-in models (Foundation Models, System TTS) - no file path needed + // These are platform services that don't require downloaded model files + if modelInfo.isBuiltIn { + // For built-in models, just pass the model ID to C++ + // The service registry will route to the correct platform provider + try await CppBridge.LLM.shared.loadModel(modelId, modelId: modelId, modelName: modelInfo.name) + return + } + + // For downloaded models, verify they exist and resolve the file path + guard modelInfo.localPath != nil else { + throw SDKError.llm(.modelNotFound, "Model '\(modelId)' is not downloaded") + } + + // Resolve actual model file path + let modelPath = try resolveModelFilePath(for: modelInfo) + try await CppBridge.LLM.shared.loadModel(modelPath.path, modelId: modelId, modelName: modelInfo.name) + } + + // MARK: - Private: Model Path Resolution + + /// Resolve the actual model file path for loading. + /// For single-file models (LlamaCpp), finds the actual .gguf file in the folder. + /// For directory-based models (ONNX), returns the folder containing the model files. + private static func resolveModelFilePath(for model: ModelInfo) throws -> URL { + let modelFolder = try CppBridge.ModelPaths.getModelFolder(modelId: model.id, framework: model.framework) + + // For ONNX models (directory-based), we need to find the actual model directory + // Archives often create a nested folder with the model name inside + if model.framework == .onnx { + return resolveONNXModelPath(modelFolder: modelFolder, modelId: model.id) + } + + // For single-file models (LlamaCpp), find the actual model file + return try resolveSingleFileModelPath(modelFolder: modelFolder, model: model) + } + + /// Resolve ONNX model directory path (handles nested archive extraction) + private static func resolveONNXModelPath(modelFolder: URL, modelId: String) -> URL { + let logger = SDKLogger(category: "ModelPathResolver") + + // Check if there's a nested folder with the model name (from archive extraction) + let nestedFolder = modelFolder.appendingPathComponent(modelId) + logger.debug("Checking nested folder: \(nestedFolder.path)") + + if FileManager.default.fileExists(atPath: nestedFolder.path) { + var isDir: ObjCBool = false + if FileManager.default.fileExists(atPath: nestedFolder.path, isDirectory: &isDir), isDir.boolValue { + // Check if this nested folder contains model files + if hasONNXModelFiles(at: nestedFolder) { + logger.info("Found ONNX model at nested path: \(nestedFolder.path)") + return nestedFolder + } + } + } + + // Check if model files exist directly in the model folder + if hasONNXModelFiles(at: modelFolder) { + logger.info("Found ONNX model at folder: \(modelFolder.path)") + return modelFolder + } + + // Scan for any subdirectory that contains model files + if let contents = try? FileManager.default.contentsOfDirectory(at: modelFolder, includingPropertiesForKeys: [.isDirectoryKey]) { + logger.debug("Scanning \(contents.count) items in model folder") + for item in contents { + var isDir: ObjCBool = false + if FileManager.default.fileExists(atPath: item.path, isDirectory: &isDir), isDir.boolValue { + if hasONNXModelFiles(at: item) { + logger.info("Found ONNX model in subdirectory: \(item.path)") + return item + } + } + } + } + + // Fallback to model folder + logger.warning("No ONNX model files found, falling back to: \(modelFolder.path)") + return modelFolder + } + + /// Check if a directory contains ONNX model files + private static func hasONNXModelFiles(at directory: URL) -> Bool { + guard let contents = try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) else { + return false + } + + // Check for any .onnx files (handles various naming conventions) + let hasOnnxFiles = contents.contains { url in + url.pathExtension.lowercased() == "onnx" + } + + // Also check for tokens.txt which is common to both STT and TTS + let hasTokensFile = contents.contains { url in + url.lastPathComponent.lowercased().contains("tokens") + } + + return hasOnnxFiles || hasTokensFile + } + + /// Resolve single-file model path (LlamaCpp .gguf files) + private static func resolveSingleFileModelPath(modelFolder: URL, model: ModelInfo) throws -> URL { + // Get the expected path from C++ + let expectedPath = try CppBridge.ModelPaths.getExpectedModelPath( + modelId: model.id, + framework: model.framework, + format: model.format + ) + + // If expected path exists, use it + if FileManager.default.fileExists(atPath: expectedPath.path) { + return expectedPath + } + + // Find files with the expected extension + let expectedExtension = model.format.rawValue.lowercased() + if let contents = try? FileManager.default.contentsOfDirectory(at: modelFolder, includingPropertiesForKeys: nil) { + // Look for files with the model format extension + let modelFiles = contents.filter { url in + let ext = url.pathExtension.lowercased() + return ext == expectedExtension || ext == "gguf" || ext == "bin" + } + + // Return the first match + if let modelFile = modelFiles.first { + return modelFile + } + } + + // Fallback to expected path + return expectedPath + } + + /// Unload the currently loaded LLM model + public static func unloadModel() async throws { + guard isInitialized else { + throw SDKError.general(.notInitialized, "SDK not initialized") + } + + await CppBridge.LLM.shared.unload() + } + + /// Check if an LLM model is loaded + public static var isModelLoaded: Bool { + get async { + await CppBridge.LLM.shared.isLoaded + } + } + + /// Check if the currently loaded LLM model supports streaming generation + /// + /// Some models (like Apple Foundation Models) don't support streaming and require + /// non-streaming generation via `generate()` instead of `generateStream()`. + /// + /// - Returns: `true` if streaming is supported, `false` if you should use `generate()` instead + /// - Note: Returns `false` if no model is loaded + public static var supportsLLMStreaming: Bool { + get async { + true // C++ layer supports streaming + } + } + + /// Load an STT (Speech-to-Text) model by ID + /// This loads the model into the STT component + /// - Parameter modelId: The model identifier (e.g., "whisper-base") + public static func loadSTTModel(_ modelId: String) async throws { + guard isInitialized else { + throw SDKError.general(.notInitialized, "SDK not initialized") + } + + try await ensureServicesReady() + + // Resolve model ID to local file path + let allModels = try await availableModels() + guard let modelInfo = allModels.first(where: { $0.id == modelId }) else { + throw SDKError.stt(.modelNotFound, "Model '\(modelId)' not found in registry") + } + guard modelInfo.localPath != nil else { + throw SDKError.stt(.modelNotFound, "Model '\(modelId)' is not downloaded") + } + + // Resolve actual model path + let modelPath = try resolveModelFilePath(for: modelInfo) + let logger = SDKLogger(category: "RunAnywhere.STT") + logger.info("Loading STT model from resolved path: \(modelPath.path)") + try await CppBridge.STT.shared.loadModel(modelPath.path, modelId: modelId, modelName: modelInfo.name) + } + + /// Load a TTS (Text-to-Speech) voice by ID + /// This loads the voice into the TTS component + /// - Parameter voiceId: The voice identifier + public static func loadTTSModel(_ voiceId: String) async throws { + guard isInitialized else { + throw SDKError.general(.notInitialized, "SDK not initialized") + } + + try await ensureServicesReady() + + // Resolve voice ID to local file path + let allModels = try await availableModels() + guard let modelInfo = allModels.first(where: { $0.id == voiceId }) else { + throw SDKError.tts(.modelNotFound, "Voice '\(voiceId)' not found in registry") + } + + // Handle built-in voices (System TTS) - no file path needed + if modelInfo.isBuiltIn { + let logger = SDKLogger(category: "RunAnywhere.TTS") + logger.info("Loading built-in TTS voice: \(voiceId)") + try await CppBridge.TTS.shared.loadVoice(voiceId, voiceId: voiceId, voiceName: modelInfo.name) + return + } + + guard modelInfo.localPath != nil else { + throw SDKError.tts(.modelNotFound, "Voice '\(voiceId)' is not downloaded") + } + + // Resolve actual model path + let modelPath = try resolveModelFilePath(for: modelInfo) + let logger = SDKLogger(category: "RunAnywhere.TTS") + logger.info("Loading TTS voice from resolved path: \(modelPath.path)") + try await CppBridge.TTS.shared.loadVoice(modelPath.path, voiceId: voiceId, voiceName: modelInfo.name) + } + + /// Get available models + /// - Returns: Array of available models + public static func availableModels() async throws -> [ModelInfo] { + guard isInitialized else { throw SDKError.general(.notInitialized, "SDK not initialized") } + return await CppBridge.ModelRegistry.shared.getAll() + } + + /// Get currently loaded LLM model ID + /// - Returns: Currently loaded model ID if any + public static func getCurrentModelId() async -> String? { + guard isInitialized else { return nil } + return await CppBridge.LLM.shared.currentModelId + } + + /// Get the currently loaded LLM model as ModelInfo + /// + /// This is a convenience property that combines `getCurrentModelId()` with + /// a lookup in the available models registry. + /// + /// - Returns: The currently loaded ModelInfo, or nil if no model is loaded + public static var currentLLMModel: ModelInfo? { + get async { + guard let modelId = await getCurrentModelId() else { return nil } + let models = (try? await availableModels()) ?? [] + return models.first { $0.id == modelId } + } + } + + /// Get the currently loaded STT model as ModelInfo + /// + /// - Returns: The currently loaded STT ModelInfo, or nil if no STT model is loaded + public static var currentSTTModel: ModelInfo? { + get async { + guard isInitialized else { return nil } + guard let modelId = await CppBridge.STT.shared.currentModelId else { return nil } + let models = (try? await availableModels()) ?? [] + return models.first { $0.id == modelId } + } + } + + /// Get the currently loaded TTS voice ID + /// + /// Note: TTS uses voices (not models), so this returns the voice identifier string. + /// - Returns: The TTS voice ID if one is loaded, nil otherwise + public static var currentTTSVoiceId: String? { + get async { + guard isInitialized else { return nil } + return await CppBridge.TTS.shared.currentVoiceId + } + } + + /// Cancel the current text generation + /// + /// Use this to stop an ongoing generation when the user navigates away + /// or explicitly requests cancellation. + public static func cancelGeneration() async { + guard isInitialized else { return } + await CppBridge.LLM.shared.cancel() + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/RunAnywhere+Logging.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/RunAnywhere+Logging.swift new file mode 100644 index 000000000..16d5427c7 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/RunAnywhere+Logging.swift @@ -0,0 +1,57 @@ +// +// RunAnywhere+Logging.swift +// RunAnywhere SDK +// +// Extension for configuring logging +// + +import Foundation + +extension RunAnywhere { + + // MARK: - Logging Configuration + + /// Configure logging with a predefined configuration + /// - Parameter config: The logging configuration to apply + public static func configureLogging(_ config: LoggingConfiguration) { + Logging.shared.configure(config) + } + + /// Enable or disable local console logging + /// - Parameter enabled: Whether to enable local logging + public static func setLocalLoggingEnabled(_ enabled: Bool) { + Logging.shared.setLocalLoggingEnabled(enabled) + } + + /// Set minimum log level for SDK logging + /// - Parameter level: Minimum log level to capture + public static func setLogLevel(_ level: LogLevel) { + Logging.shared.setMinLogLevel(level) + } + + /// Enable or disable Sentry error tracking + /// - Parameter enabled: Whether to enable Sentry logging + public static func setSentryLoggingEnabled(_ enabled: Bool) { + Logging.shared.setSentryLoggingEnabled(enabled) + } + + /// Add a custom log destination + /// - Parameter destination: The destination to add + public static func addLogDestination(_ destination: LogDestination) { + Logging.shared.addDestination(destination) + } + + // MARK: - Debugging Helpers + + /// Enable verbose debugging mode + /// - Parameter enabled: Whether to enable verbose mode + public static func setDebugMode(_ enabled: Bool) { + setLogLevel(enabled ? .debug : .info) + setLocalLoggingEnabled(enabled) + } + + /// Force flush all pending logs to destinations + public static func flushLogs() { + Logging.shared.flush() + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/STT/RunAnywhere+STT.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/STT/RunAnywhere+STT.swift new file mode 100644 index 000000000..664f1f6eb --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/STT/RunAnywhere+STT.swift @@ -0,0 +1,336 @@ +// +// RunAnywhere+STT.swift +// RunAnywhere SDK +// +// Public API for Speech-to-Text operations. +// Calls C++ directly via CppBridge.STT for all operations. +// Events are emitted by C++ layer via CppEventBridge. +// + +@preconcurrency import AVFoundation +import CRACommons +import Foundation + +// MARK: - STT Operations + +public extension RunAnywhere { + + // MARK: - Simple Transcription + + /// Simple voice transcription using default model + /// - Parameter audioData: Audio data to transcribe + /// - Returns: Transcribed text + static func transcribe(_ audioData: Data) async throws -> String { + guard isInitialized else { + throw SDKError.general(.notInitialized, "SDK not initialized") + } + try await ensureServicesReady() + + let result = try await transcribeWithOptions(audioData, options: STTOptions()) + return result.text + } + + // MARK: - Model Loading + + /// Unload the currently loaded STT model + static func unloadSTTModel() async throws { + guard isSDKInitialized else { + throw SDKError.general(.notInitialized, "SDK not initialized") + } + + await CppBridge.STT.shared.unload() + } + + /// Check if an STT model is loaded + static var isSTTModelLoaded: Bool { + get async { + await CppBridge.STT.shared.isLoaded + } + } + + // MARK: - Transcription + + /// Transcribe audio data to text (with options) + /// - Parameters: + /// - audioData: Raw audio data + /// - options: Transcription options + /// - Returns: Transcription output with text and metadata + static func transcribeWithOptions( + _ audioData: Data, + options: STTOptions + ) async throws -> STTOutput { + guard isSDKInitialized else { + throw SDKError.general(.notInitialized, "SDK not initialized") + } + + // Get handle from CppBridge.STT + let handle = try await CppBridge.STT.shared.getHandle() + + guard await CppBridge.STT.shared.isLoaded else { + throw SDKError.stt(.notInitialized, "STT model not loaded") + } + + let modelId = await CppBridge.STT.shared.currentModelId ?? "unknown" + let startTime = Date() + + // Calculate audio metrics + let audioSizeBytes = audioData.count + let audioLengthSec = estimateAudioLength(dataSize: audioSizeBytes) + + // Build C options + var cOptions = rac_stt_options_t() + cOptions.language = (options.language as NSString).utf8String + cOptions.sample_rate = Int32(options.sampleRate) + + // Transcribe (C++ emits events) + var sttResult = rac_stt_result_t() + let transcribeResult = audioData.withUnsafeBytes { audioPtr in + rac_stt_component_transcribe( + handle, + audioPtr.baseAddress, + audioData.count, + &cOptions, + &sttResult + ) + } + + guard transcribeResult == RAC_SUCCESS else { + throw SDKError.stt(.processingFailed, "Transcription failed: \(transcribeResult)") + } + + let endTime = Date() + let processingTimeSec = endTime.timeIntervalSince(startTime) + + // Extract result + let transcribedText: String + if let textPtr = sttResult.text { + transcribedText = String(cString: textPtr) + } else { + transcribedText = "" + } + let detectedLanguage: String? + if let langPtr = sttResult.detected_language { + detectedLanguage = String(cString: langPtr) + } else { + detectedLanguage = nil + } + let confidence = sttResult.confidence + + // Create metadata + let metadata = TranscriptionMetadata( + modelId: modelId, + processingTime: processingTimeSec, + audioLength: audioLengthSec + ) + + return STTOutput( + text: transcribedText, + confidence: confidence, + wordTimestamps: nil, + detectedLanguage: detectedLanguage, + alternatives: nil, + metadata: metadata + ) + } + + /// Transcribe audio buffer to text + /// - Parameters: + /// - buffer: Audio buffer + /// - language: Optional language hint + /// - Returns: Transcription output + static func transcribeBuffer( + _ buffer: AVAudioPCMBuffer, + language: String? = nil + ) async throws -> STTOutput { + guard isSDKInitialized else { + throw SDKError.general(.notInitialized, "SDK not initialized") + } + + // Convert AVAudioPCMBuffer to Data + guard let channelData = buffer.floatChannelData else { + throw SDKError.stt(.emptyAudioBuffer, "Audio buffer has no channel data") + } + + let frameLength = Int(buffer.frameLength) + let audioData = Data(bytes: channelData[0], count: frameLength * MemoryLayout.size) + + // Build options with language if provided + let options: STTOptions + if let language = language { + options = STTOptions(language: language) + } else { + options = STTOptions() + } + + return try await transcribeWithOptions(audioData, options: options) + } + + /// Start streaming transcription + /// - Parameters: + /// - options: Transcription options + /// - onPartialResult: Callback for partial transcription results + /// - onFinalResult: Callback for final transcription result + /// - onError: Callback for errors + @available(*, deprecated, message: "Use transcribeStream(audioData:options:onPartialResult:) instead") + static func startStreamingTranscription( + options _: STTOptions = STTOptions(), + onPartialResult _: @escaping (STTTranscriptionResult) -> Void, + onFinalResult _: @escaping (STTOutput) -> Void, + onError _: @escaping (Error) -> Void + ) async throws { + throw SDKError.stt(.streamingNotSupported, "Use transcribeStream(audioData:options:onPartialResult:) instead") + } + + /// Transcribe audio with streaming callbacks + /// - Parameters: + /// - audioData: Audio data to transcribe + /// - options: Transcription options + /// - onPartialResult: Callback for partial results + /// - Returns: Final transcription output + static func transcribeStream( + audioData: Data, + options: STTOptions = STTOptions(), + onPartialResult: @escaping (STTTranscriptionResult) -> Void + ) async throws -> STTOutput { + guard isSDKInitialized else { + throw SDKError.general(.notInitialized, "SDK not initialized") + } + + let handle = try await CppBridge.STT.shared.getHandle() + + guard await CppBridge.STT.shared.isLoaded else { + throw SDKError.stt(.notInitialized, "STT model not loaded") + } + + guard await CppBridge.STT.shared.supportsStreaming else { + throw SDKError.stt(.streamingNotSupported, "Model does not support streaming") + } + + let modelId = await CppBridge.STT.shared.currentModelId ?? "unknown" + let startTime = Date() + + // Create context for callback bridging + let context = STTStreamingContext(onPartialResult: onPartialResult) + let contextPtr = Unmanaged.passRetained(context).toOpaque() + + // Build C options + var cOptions = rac_stt_options_t() + cOptions.language = (options.language as NSString).utf8String + cOptions.sample_rate = Int32(options.sampleRate) + + // Stream transcription with callback + let result = audioData.withUnsafeBytes { audioPtr in + rac_stt_component_transcribe_stream( + handle, + audioPtr.baseAddress, + audioData.count, + &cOptions, + { partialText, isFinal, userData in + guard let userData = userData else { return } + let ctx = Unmanaged.fromOpaque(userData).takeUnretainedValue() + + let text = partialText.map { String(cString: $0) } ?? "" + let partialResult = STTTranscriptionResult( + transcript: text, + confidence: nil, + timestamps: nil, + language: nil, + alternatives: nil + ) + + ctx.onPartialResult(partialResult) + + if isFinal == RAC_TRUE { + ctx.finalText = text + } + }, + contextPtr + ) + } + + // Release context + let finalContext = Unmanaged.fromOpaque(contextPtr).takeRetainedValue() + + guard result == RAC_SUCCESS else { + throw SDKError.stt(.processingFailed, "Streaming transcription failed: \(result)") + } + + let endTime = Date() + let processingTimeSec = endTime.timeIntervalSince(startTime) + let audioLengthSec = estimateAudioLength(dataSize: audioData.count) + + let metadata = TranscriptionMetadata( + modelId: modelId, + processingTime: processingTimeSec, + audioLength: audioLengthSec + ) + + return STTOutput( + text: finalContext.finalText, + confidence: 0.0, + wordTimestamps: nil, + detectedLanguage: nil, + alternatives: nil, + metadata: metadata + ) + } + + /// Process audio samples for streaming transcription + /// - Parameter samples: Audio samples + static func processStreamingAudio(_ samples: [Float]) async throws { + guard isSDKInitialized else { + throw SDKError.general(.notInitialized, "SDK not initialized") + } + + let handle = try await CppBridge.STT.shared.getHandle() + + var cOptions = rac_stt_options_t() + cOptions.sample_rate = Int32(RAC_STT_DEFAULT_SAMPLE_RATE) + + let data = samples.withUnsafeBufferPointer { buffer in + Data(buffer: buffer) + } + + var sttResult = rac_stt_result_t() + let transcribeResult = data.withUnsafeBytes { audioPtr in + rac_stt_component_transcribe( + handle, + audioPtr.baseAddress, + data.count, + &cOptions, + &sttResult + ) + } + + if transcribeResult != RAC_SUCCESS { + throw SDKError.stt(.processingFailed, "Streaming process failed: \(transcribeResult)") + } + } + + /// Stop streaming transcription + static func stopStreamingTranscription() async { + // No-op - streaming is handled per-call + } + + // MARK: - Private Helpers + + /// Estimate audio length from data size (assumes 16kHz mono 16-bit) + private static func estimateAudioLength(dataSize: Int) -> Double { + let bytesPerSample = 2 // 16-bit + let sampleRate = 16000.0 + let samples = Double(dataSize) / Double(bytesPerSample) + return samples / sampleRate + } +} + +// MARK: - Streaming Context Helper + +/// Context class for bridging C callbacks to Swift closures +private final class STTStreamingContext: @unchecked Sendable { + let onPartialResult: (STTTranscriptionResult) -> Void + var finalText: String = "" + + init(onPartialResult: @escaping (STTTranscriptionResult) -> Void) { + self.onPartialResult = onPartialResult + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/STT/STTTypes.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/STT/STTTypes.swift new file mode 100644 index 000000000..0210d0c6e --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/STT/STTTypes.swift @@ -0,0 +1,334 @@ +// +// STTTypes.swift +// RunAnywhere SDK +// +// Public types for Speech-to-Text transcription. +// These are thin wrappers over C++ types in rac_stt_types.h +// + +import CRACommons +import Foundation + +// MARK: - STT Configuration + +/// Configuration for STT component +public struct STTConfiguration: ComponentConfiguration, Sendable { + /// Component type + public var componentType: SDKComponent { .stt } + + /// Model ID + public let modelId: String? + + // Model parameters + public let language: String + public let sampleRate: Int + public let enablePunctuation: Bool + public let enableDiarization: Bool + public let vocabularyList: [String] + public let maxAlternatives: Int + public let enableTimestamps: Bool + + public init( + modelId: String? = nil, + language: String = "en-US", + sampleRate: Int = Int(RAC_STT_DEFAULT_SAMPLE_RATE), + enablePunctuation: Bool = true, + enableDiarization: Bool = false, + vocabularyList: [String] = [], + maxAlternatives: Int = 1, + enableTimestamps: Bool = true + ) { + self.modelId = modelId + self.language = language + self.sampleRate = sampleRate + self.enablePunctuation = enablePunctuation + self.enableDiarization = enableDiarization + self.vocabularyList = vocabularyList + self.maxAlternatives = maxAlternatives + self.enableTimestamps = enableTimestamps + } + + public func validate() throws { + guard sampleRate > 0 && sampleRate <= 48000 else { + throw SDKError.general(.validationFailed, "Sample rate must be between 1 and 48000 Hz") + } + guard maxAlternatives > 0 && maxAlternatives <= 10 else { + throw SDKError.general(.validationFailed, "Max alternatives must be between 1 and 10") + } + } +} + +// MARK: - STT Options + +/// Options for speech-to-text transcription +public struct STTOptions: Sendable { + /// Language code for transcription (e.g., "en", "es", "fr") + public let language: String + + /// Whether to auto-detect the spoken language + public let detectLanguage: Bool + + /// Enable automatic punctuation in transcription + public let enablePunctuation: Bool + + /// Enable speaker diarization (identify different speakers) + public let enableDiarization: Bool + + /// Maximum number of speakers to identify (requires enableDiarization) + public let maxSpeakers: Int? + + /// Enable word-level timestamps + public let enableTimestamps: Bool + + /// Custom vocabulary words to improve recognition + public let vocabularyFilter: [String] + + /// Audio format of input data + public let audioFormat: AudioFormat + + /// Sample rate of input audio (default: 16000 Hz for STT models) + public let sampleRate: Int + + /// Preferred framework for transcription (ONNX, etc.) + public let preferredFramework: InferenceFramework? + + public init( + language: String = "en", + detectLanguage: Bool = false, + enablePunctuation: Bool = true, + enableDiarization: Bool = false, + maxSpeakers: Int? = nil, + enableTimestamps: Bool = true, + vocabularyFilter: [String] = [], + audioFormat: AudioFormat = .pcm, + sampleRate: Int = Int(RAC_STT_DEFAULT_SAMPLE_RATE), + preferredFramework: InferenceFramework? = nil + ) { + self.language = language + self.detectLanguage = detectLanguage + self.enablePunctuation = enablePunctuation + self.enableDiarization = enableDiarization + self.maxSpeakers = maxSpeakers + self.enableTimestamps = enableTimestamps + self.vocabularyFilter = vocabularyFilter + self.audioFormat = audioFormat + self.sampleRate = sampleRate + self.preferredFramework = preferredFramework + } + + /// Create options with default settings for a specific language + public static func `default`(language: String = "en") -> STTOptions { + STTOptions(language: language) + } + + // MARK: - C++ Bridge (rac_stt_options_t) + + /// Execute a closure with the C++ equivalent options struct + public func withCOptions(_ body: (UnsafePointer) throws -> T) rethrows -> T { + var cOptions = rac_stt_options_t() + cOptions.detect_language = detectLanguage ? RAC_TRUE : RAC_FALSE + cOptions.enable_punctuation = enablePunctuation ? RAC_TRUE : RAC_FALSE + cOptions.enable_diarization = enableDiarization ? RAC_TRUE : RAC_FALSE + cOptions.max_speakers = Int32(maxSpeakers ?? 0) + cOptions.enable_timestamps = enableTimestamps ? RAC_TRUE : RAC_FALSE + cOptions.audio_format = audioFormat.toCFormat() + cOptions.sample_rate = Int32(sampleRate) + + return try language.withCString { langPtr in + cOptions.language = langPtr + return try body(&cOptions) + } + } +} + +// MARK: - STT Output + +/// Output from Speech-to-Text (conforms to ComponentOutput protocol) +public struct STTOutput: ComponentOutput { + /// Transcribed text + public let text: String + + /// Confidence score (0.0 to 1.0) + public let confidence: Float + + /// Word-level timestamps if available + public let wordTimestamps: [WordTimestamp]? + + /// Detected language if auto-detected + public let detectedLanguage: String? + + /// Alternative transcriptions if available + public let alternatives: [TranscriptionAlternative]? + + /// Processing metadata + public let metadata: TranscriptionMetadata + + /// Timestamp (required by ComponentOutput) + public let timestamp: Date + + public init( + text: String, + confidence: Float, + wordTimestamps: [WordTimestamp]? = nil, + detectedLanguage: String? = nil, + alternatives: [TranscriptionAlternative]? = nil, + metadata: TranscriptionMetadata, + timestamp: Date = Date() + ) { + self.text = text + self.confidence = confidence + self.wordTimestamps = wordTimestamps + self.detectedLanguage = detectedLanguage + self.alternatives = alternatives + self.metadata = metadata + self.timestamp = timestamp + } + + // MARK: - C++ Bridge (rac_stt_output_t) + + /// Initialize from C++ rac_stt_output_t + public init(from cOutput: rac_stt_output_t) { + // Convert word timestamps + var wordTimestamps: [WordTimestamp]? + if cOutput.num_word_timestamps > 0, let cWords = cOutput.word_timestamps { + wordTimestamps = (0.. 0, let cAlts = cOutput.alternatives { + alternatives = (0.. 0 ? processingTime / audioLength : 0 + } +} + +/// Word timestamp information +public struct WordTimestamp: Sendable { + public let word: String + public let startTime: TimeInterval + public let endTime: TimeInterval + public let confidence: Float + + public init(word: String, startTime: TimeInterval, endTime: TimeInterval, confidence: Float) { + self.word = word + self.startTime = startTime + self.endTime = endTime + self.confidence = confidence + } +} + +/// Alternative transcription +public struct TranscriptionAlternative: Sendable { + public let text: String + public let confidence: Float + + public init(text: String, confidence: Float) { + self.text = text + self.confidence = confidence + } +} + +// MARK: - STT Transcription Result + +/// Transcription result from service +public struct STTTranscriptionResult: Sendable { + public let transcript: String + public let confidence: Float? + public let timestamps: [TimestampInfo]? + public let language: String? + public let alternatives: [AlternativeTranscription]? + + public init( + transcript: String, + confidence: Float? = nil, + timestamps: [TimestampInfo]? = nil, + language: String? = nil, + alternatives: [AlternativeTranscription]? = nil + ) { + self.transcript = transcript + self.confidence = confidence + self.timestamps = timestamps + self.language = language + self.alternatives = alternatives + } + + // MARK: - Nested Types + + public struct TimestampInfo: Sendable { + public let word: String + public let startTime: TimeInterval + public let endTime: TimeInterval + public let confidence: Float? + + public init(word: String, startTime: TimeInterval, endTime: TimeInterval, confidence: Float? = nil) { + self.word = word + self.startTime = startTime + self.endTime = endTime + self.confidence = confidence + } + } + + public struct AlternativeTranscription: Sendable { + public let transcript: String + public let confidence: Float + + public init(transcript: String, confidence: Float) { + self.transcript = transcript + self.confidence = confidence + } + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/Storage/RunAnywhere+Storage.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/Storage/RunAnywhere+Storage.swift new file mode 100644 index 000000000..b22362d44 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/Storage/RunAnywhere+Storage.swift @@ -0,0 +1,106 @@ +// +// RunAnywhere+Storage.swift +// RunAnywhere SDK +// +// Public API for storage and download operations. +// + +import Foundation + +// MARK: - Model Download API + +public extension RunAnywhere { + + /// Download a model by ID with progress tracking + /// + /// ```swift + /// for await progress in try await RunAnywhere.downloadModel("my-model-id") { + /// print("Progress: \(Int(progress.overallProgress * 100))%") + /// } + /// ``` + static func downloadModel(_ modelId: String) async throws -> AsyncStream { + let models = try await availableModels() + guard let model = models.first(where: { $0.id == modelId }) else { + throw SDKError.general(.modelNotFound, "Model not found: \(modelId)") + } + + let task = try await AlamofireDownloadService.shared.downloadModel(model) + return task.progress + } + + /// Download a model with a completion handler + static func downloadModel( + _ modelId: String, + progressHandler: @escaping (Double) -> Void + ) async throws { + let progressStream = try await downloadModel(modelId) + + for await progress in progressStream { + progressHandler(progress.overallProgress) + if progress.stage == .completed { + break + } + } + } +} + +// MARK: - Storage Extensions + +public extension RunAnywhere { + + /// Get storage information + /// Business logic is in C++ via CppBridge.Storage + static func getStorageInfo() async -> StorageInfo { + return await CppBridge.Storage.shared.analyzeStorage() + } + + /// Check if storage is available for a model download + static func checkStorageAvailable(for modelSize: Int64, safetyMargin: Double = 0.1) -> StorageAvailability { + return CppBridge.Storage.shared.checkStorageAvailable(modelSize: modelSize, safetyMargin: safetyMargin) + } + + /// Get storage metrics for a specific model + static func getModelStorageMetrics(modelId: String, framework: InferenceFramework) async -> ModelStorageMetrics? { + return await CppBridge.Storage.shared.getModelStorageMetrics(modelId: modelId, framework: framework) + } + + /// Clear cache + static func clearCache() async throws { + try SimplifiedFileManager.shared.clearCache() + // Emit via C++ event system + CppBridge.Events.emitStorageCacheCleared(freedBytes: 0) + } + + /// Clean temporary files + static func cleanTempFiles() async throws { + try SimplifiedFileManager.shared.cleanTempFiles() + // Emit via C++ event system + CppBridge.Events.emitStorageTempCleaned(freedBytes: 0) + } + + /// Delete a stored model + /// - Parameters: + /// - modelId: The model identifier + /// - framework: The framework the model belongs to + static func deleteStoredModel(_ modelId: String, framework: InferenceFramework) async throws { + try SimplifiedFileManager.shared.deleteModel(modelId: modelId, framework: framework) + // Emit via C++ event system + CppBridge.Events.emitModelDeleted(modelId: modelId) + } + + /// Get base directory URL + static func getBaseDirectoryURL() -> URL { + SimplifiedFileManager.shared.getBaseDirectoryURL() + } + + /// Get all downloaded models + static func getDownloadedModels() -> [InferenceFramework: [String]] { + SimplifiedFileManager.shared.getDownloadedModels() + } + + /// Check if a model is downloaded + @MainActor + static func isModelDownloaded(_ modelId: String, framework: InferenceFramework) -> Bool { + SimplifiedFileManager.shared.isModelDownloaded(modelId: modelId, framework: framework) + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/Storage/StorageTypes.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/Storage/StorageTypes.swift new file mode 100644 index 000000000..c3ce20f8b --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/Storage/StorageTypes.swift @@ -0,0 +1,204 @@ +// +// StorageTypes.swift +// RunAnywhere SDK +// +// Consolidated storage-related types for public API. +// Includes: storage info, configuration, availability, and model storage metrics. +// + +import Foundation + +// MARK: - Device Storage + +/// Device storage information +public struct DeviceStorageInfo: Sendable { + /// Total device storage space in bytes + public let totalSpace: Int64 + + /// Free space available in bytes + public let freeSpace: Int64 + + /// Used space in bytes + public let usedSpace: Int64 + + /// Percentage of storage used (0-100) + public var usagePercentage: Double { + guard totalSpace > 0 else { return 0 } + return Double(usedSpace) / Double(totalSpace) * 100 + } + + public init(totalSpace: Int64, freeSpace: Int64, usedSpace: Int64) { + self.totalSpace = totalSpace + self.freeSpace = freeSpace + self.usedSpace = usedSpace + } +} + +// MARK: - App Storage + +/// App storage breakdown by directory type +public struct AppStorageInfo: Sendable { + /// Documents directory size in bytes + public let documentsSize: Int64 + + /// Cache directory size in bytes + public let cacheSize: Int64 + + /// Application Support directory size in bytes + public let appSupportSize: Int64 + + /// Total app storage in bytes + public let totalSize: Int64 + + public init(documentsSize: Int64, cacheSize: Int64, appSupportSize: Int64, totalSize: Int64) { + self.documentsSize = documentsSize + self.cacheSize = cacheSize + self.appSupportSize = appSupportSize + self.totalSize = totalSize + } +} + +// MARK: - Model Storage Metrics + +/// Storage metrics for a single model +/// All model metadata (id, name, framework, artifactType, etc.) is in ModelInfo +/// This struct adds the on-disk storage size +public struct ModelStorageMetrics: Sendable { + /// The model info (contains id, framework, localPath, artifactType, etc.) + public let model: ModelInfo + + /// Actual size on disk in bytes (may differ from downloadSize after extraction) + public let sizeOnDisk: Int64 + + public init(model: ModelInfo, sizeOnDisk: Int64) { + self.model = model + self.sizeOnDisk = sizeOnDisk + } +} + +// MARK: - Stored Model (Backward Compatible) + +/// Backward-compatible stored model view +/// Provides a simple view of a stored model with computed properties +public struct StoredModel: Sendable, Identifiable { + /// Underlying model info + public let modelInfo: ModelInfo + + /// Size on disk in bytes + public let size: Int64 + + /// Model ID + public var id: String { modelInfo.id } + + /// Model name + public var name: String { modelInfo.name } + + /// Model format + public var format: ModelFormat { modelInfo.format } + + /// Inference framework + public var framework: InferenceFramework? { modelInfo.framework } + + /// Model description + public var description: String? { modelInfo.description } + + /// Path to the model on disk + public var path: URL { modelInfo.localPath ?? URL(fileURLWithPath: "/unknown") } + + /// Checksum (from download info if available) + public var checksum: String? { nil } + + /// Created date (use current date as fallback) + public var createdDate: Date { Date() } + + public init(modelInfo: ModelInfo, size: Int64) { + self.modelInfo = modelInfo + self.size = size + } + + /// Create from ModelStorageMetrics + public init(from metrics: ModelStorageMetrics) { + self.modelInfo = metrics.model + self.size = metrics.sizeOnDisk + } +} + +// MARK: - Storage Info (Aggregate) + +/// Complete storage information including device, app, and model storage +public struct StorageInfo: Sendable { + /// App storage usage + public let appStorage: AppStorageInfo + + /// Device storage capacity + public let deviceStorage: DeviceStorageInfo + + /// Storage metrics for each downloaded model + public let models: [ModelStorageMetrics] + + /// Total size of all models + public var totalModelsSize: Int64 { + models.reduce(0) { $0 + $1.sizeOnDisk } + } + + /// Number of stored models + public var modelCount: Int { + models.count + } + + /// Stored models array (backward compatible) + public var storedModels: [StoredModel] { + models.map { StoredModel(from: $0) } + } + + /// Empty storage info + public static let empty = StorageInfo( + appStorage: AppStorageInfo(documentsSize: 0, cacheSize: 0, appSupportSize: 0, totalSize: 0), + deviceStorage: DeviceStorageInfo(totalSpace: 0, freeSpace: 0, usedSpace: 0), + models: [] + ) + + public init( + appStorage: AppStorageInfo, + deviceStorage: DeviceStorageInfo, + models: [ModelStorageMetrics] + ) { + self.appStorage = appStorage + self.deviceStorage = deviceStorage + self.models = models + } +} + +// MARK: - Storage Availability + +/// Storage availability check result +public struct StorageAvailability: Sendable { + /// Whether storage is available for the requested operation + public let isAvailable: Bool + + /// Required space in bytes + public let requiredSpace: Int64 + + /// Available space in bytes + public let availableSpace: Int64 + + /// Whether there's a warning (e.g., low space) + public let hasWarning: Bool + + /// Recommendation message if any + public let recommendation: String? + + public init( + isAvailable: Bool, + requiredSpace: Int64, + availableSpace: Int64, + hasWarning: Bool, + recommendation: String? + ) { + self.isAvailable = isAvailable + self.requiredSpace = requiredSpace + self.availableSpace = availableSpace + self.hasWarning = hasWarning + self.recommendation = recommendation + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/TTS/RunAnywhere+TTS.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/TTS/RunAnywhere+TTS.swift new file mode 100644 index 000000000..cda94d5c6 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/TTS/RunAnywhere+TTS.swift @@ -0,0 +1,318 @@ +// +// RunAnywhere+TTS.swift +// RunAnywhere SDK +// +// Public API for Text-to-Speech operations. +// Calls C++ directly via CppBridge.TTS for all operations. +// Events are emitted by C++ layer via CppEventBridge. +// + +@preconcurrency import AVFoundation +import CRACommons +import Foundation + +// MARK: - TTS Operations + +public extension RunAnywhere { + + // MARK: - Voice Loading + + /// Load a TTS voice + /// - Parameter voiceId: The voice identifier + /// - Throws: Error if loading fails + static func loadTTSVoice(_ voiceId: String) async throws { + guard isSDKInitialized else { + throw SDKError.general(.notInitialized, "SDK not initialized") + } + + // Resolve voice ID to local file path + let allModels = try await availableModels() + guard let modelInfo = allModels.first(where: { $0.id == voiceId }) else { + throw SDKError.tts(.modelNotFound, "Voice '\(voiceId)' not found in registry") + } + guard let localPath = modelInfo.localPath else { + throw SDKError.tts(.modelNotFound, "Voice '\(voiceId)' is not downloaded") + } + + try await CppBridge.TTS.shared.loadVoice(localPath.path, voiceId: voiceId, voiceName: modelInfo.name) + } + + /// Unload the currently loaded TTS voice + static func unloadTTSVoice() async throws { + guard isSDKInitialized else { + throw SDKError.general(.notInitialized, "SDK not initialized") + } + + await CppBridge.TTS.shared.unload() + } + + /// Check if a TTS voice is loaded + static var isTTSVoiceLoaded: Bool { + get async { + await CppBridge.TTS.shared.isLoaded + } + } + + /// Get available TTS voices + static var availableTTSVoices: [String] { + get async { + let allModels = await CppBridge.ModelRegistry.shared.getByFrameworks([.onnx]) + let ttsModels = allModels.filter { $0.category == .speechSynthesis } + return ttsModels.map { $0.id } + } + } + + // MARK: - Synthesis + + /// Synthesize text to speech + /// - Parameters: + /// - text: Text to synthesize + /// - options: Synthesis options + /// - Returns: TTS output with audio data + static func synthesize( + _ text: String, + options: TTSOptions = TTSOptions() + ) async throws -> TTSOutput { + guard isSDKInitialized else { + throw SDKError.general(.notInitialized, "SDK not initialized") + } + + let handle = try await CppBridge.TTS.shared.getHandle() + + guard await CppBridge.TTS.shared.isLoaded else { + throw SDKError.tts(.notInitialized, "TTS voice not loaded") + } + + let voiceId = await CppBridge.TTS.shared.currentVoiceId ?? "unknown" + let startTime = Date() + + // Build C options + var cOptions = rac_tts_options_t() + cOptions.rate = options.rate + cOptions.pitch = options.pitch + cOptions.volume = options.volume + cOptions.sample_rate = Int32(options.sampleRate) + + // Synthesize (C++ emits events) + var ttsResult = rac_tts_result_t() + let synthesizeResult = text.withCString { textPtr in + rac_tts_component_synthesize(handle, textPtr, &cOptions, &ttsResult) + } + + guard synthesizeResult == RAC_SUCCESS else { + throw SDKError.tts(.processingFailed, "Synthesis failed: \(synthesizeResult)") + } + + let endTime = Date() + let processingTime = endTime.timeIntervalSince(startTime) + + // Extract audio data + let audioData: Data + if let audioPtr = ttsResult.audio_data, ttsResult.audio_size > 0 { + audioData = Data(bytes: audioPtr, count: ttsResult.audio_size) + } else { + audioData = Data() + } + + let sampleRate = Int(ttsResult.sample_rate) + let numSamples = audioData.count / 4 // Float32 = 4 bytes + let durationSec = Double(numSamples) / Double(sampleRate) + + let metadata = TTSSynthesisMetadata( + voice: voiceId, + language: options.language, + processingTime: processingTime, + characterCount: text.count + ) + + return TTSOutput( + audioData: audioData, + format: options.audioFormat, + duration: durationSec, + phonemeTimestamps: nil, + metadata: metadata + ) + } + + /// Stream synthesis for long text + /// - Parameters: + /// - text: Text to synthesize + /// - options: Synthesis options + /// - onAudioChunk: Callback for each audio chunk + /// - Returns: TTS output with full audio data + static func synthesizeStream( + _ text: String, + options: TTSOptions = TTSOptions(), + onAudioChunk: @escaping (Data) -> Void + ) async throws -> TTSOutput { + guard isSDKInitialized else { + throw SDKError.general(.notInitialized, "SDK not initialized") + } + + let handle = try await CppBridge.TTS.shared.getHandle() + + guard await CppBridge.TTS.shared.isLoaded else { + throw SDKError.tts(.notInitialized, "TTS voice not loaded") + } + + let voiceId = await CppBridge.TTS.shared.currentVoiceId ?? "unknown" + let startTime = Date() + var totalAudioData = Data() + + // Build C options + var cOptions = rac_tts_options_t() + cOptions.rate = options.rate + cOptions.pitch = options.pitch + cOptions.volume = options.volume + cOptions.sample_rate = Int32(options.sampleRate) + + // Create callback context + let context = TTSStreamContext(onChunk: onAudioChunk, totalData: &totalAudioData) + let contextPtr = Unmanaged.passRetained(context).toOpaque() + + let streamResult = text.withCString { textPtr in + rac_tts_component_synthesize_stream( + handle, + textPtr, + &cOptions, + { audioPtr, audioSize, userData in + guard let audioPtr = audioPtr, let userData = userData else { return } + let ctx = Unmanaged.fromOpaque(userData).takeUnretainedValue() + let chunk = Data(bytes: audioPtr, count: audioSize) + ctx.onChunk(chunk) + ctx.totalData.pointee.append(chunk) + }, + contextPtr + ) + } + + Unmanaged.fromOpaque(contextPtr).release() + + guard streamResult == RAC_SUCCESS else { + throw SDKError.tts(.processingFailed, "Streaming synthesis failed: \(streamResult)") + } + + let endTime = Date() + let processingTime = endTime.timeIntervalSince(startTime) + let numSamples = totalAudioData.count / 4 + let durationSec = Double(numSamples) / Double(options.sampleRate) + + let metadata = TTSSynthesisMetadata( + voice: voiceId, + language: options.language, + processingTime: processingTime, + characterCount: text.count + ) + + return TTSOutput( + audioData: totalAudioData, + format: options.audioFormat, + duration: durationSec, + phonemeTimestamps: nil, + metadata: metadata + ) + } + + /// Stop current TTS synthesis + static func stopSynthesis() async { + await CppBridge.TTS.shared.stop() + } + + // MARK: - Speak (Simple API) + + /// Speak text aloud - the simplest way to use TTS. + /// + /// The SDK handles audio synthesis and playback internally. + /// Just call this method and the text will be spoken through the device speakers. + /// + /// ## Example + /// ```swift + /// // Simple usage + /// try await RunAnywhere.speak("Hello world") + /// + /// // With options + /// let result = try await RunAnywhere.speak("Hello", options: TTSOptions(rate: 1.2)) + /// print("Duration: \(result.duration)s") + /// ``` + /// + /// - Parameters: + /// - text: Text to speak + /// - options: Synthesis options (rate, pitch, voice, etc.) + /// - Returns: Result containing metadata about the spoken audio + /// - Throws: Error if synthesis or playback fails + static func speak( + _ text: String, + options: TTSOptions = TTSOptions() + ) async throws -> TTSSpeakResult { + guard isSDKInitialized else { + throw SDKError.general(.notInitialized, "SDK not initialized") + } + + let output = try await synthesize(text, options: options) + + // Convert Float32 PCM to WAV format using C++ utility + let wavData = try convertPCMToWAV(pcmData: output.audioData, sampleRate: Int32(options.sampleRate)) + + // Play the audio using platform audio manager + if !wavData.isEmpty { + try await ttsAudioPlayback.play(wavData) + } + + return TTSSpeakResult(from: output) + } + + /// Whether speech is currently playing + static var isSpeaking: Bool { + get async { false } + } + + /// Stop current speech playback + static func stopSpeaking() async { + ttsAudioPlayback.stop() + await stopSynthesis() + } + + // MARK: - Private Audio Playback + + /// Audio playback manager for TTS speak functionality + private static let ttsAudioPlayback = AudioPlaybackManager() + + /// Convert Float32 PCM to WAV using C++ audio utilities + private static func convertPCMToWAV(pcmData: Data, sampleRate: Int32) throws -> Data { + guard !pcmData.isEmpty else { return Data() } + + var wavDataPtr: UnsafeMutableRawPointer? + var wavSize: Int = 0 + + let result = pcmData.withUnsafeBytes { pcmPtr in + rac_audio_float32_to_wav( + pcmPtr.baseAddress, + pcmData.count, + sampleRate, + &wavDataPtr, + &wavSize + ) + } + + guard result == RAC_SUCCESS, let ptr = wavDataPtr, wavSize > 0 else { + throw SDKError.tts(.processingFailed, "Failed to convert PCM to WAV: \(result)") + } + + let wavData = Data(bytes: ptr, count: wavSize) + rac_free(ptr) + + return wavData + } +} + +// MARK: - Streaming Context + +private final class TTSStreamContext: @unchecked Sendable { + let onChunk: (Data) -> Void + var totalData: UnsafeMutablePointer + + init(onChunk: @escaping (Data) -> Void, totalData: UnsafeMutablePointer) { + self.onChunk = onChunk + self.totalData = totalData + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/TTS/TTSTypes.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/TTS/TTSTypes.swift new file mode 100644 index 000000000..c6d93260b --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/TTS/TTSTypes.swift @@ -0,0 +1,463 @@ +// +// TTSTypes.swift +// RunAnywhere SDK +// +// Public types for Text-to-Speech synthesis. +// These are thin wrappers over C++ types in rac_tts_types.h +// + +import CRACommons +import Foundation + +// MARK: - TTS Configuration + +/// Configuration for TTS component +public struct TTSConfiguration: ComponentConfiguration, Sendable { + + // MARK: - ComponentConfiguration + + /// Component type + public var componentType: SDKComponent { .tts } + + /// Model ID (voice identifier for TTS) + public let modelId: String? + + /// Preferred framework (uses default extension implementation) + public var preferredFramework: InferenceFramework? { nil } + + // MARK: - TTS-Specific Properties + + /// Voice identifier to use for synthesis + public let voice: String + + /// Language for synthesis (BCP-47 format, e.g., "en-US") + public let language: String + + /// Speaking rate (0.5 to 2.0, 1.0 is normal) + public let speakingRate: Float + + /// Speech pitch (0.5 to 2.0, 1.0 is normal) + public let pitch: Float + + /// Speech volume (0.0 to 1.0) + public let volume: Float + + /// Audio format for output + public let audioFormat: AudioFormat + + /// Whether to use neural/premium voice if available + public let useNeuralVoice: Bool + + /// Whether to enable SSML markup support + public let enableSSML: Bool + + // MARK: - Initialization + + public init( + voice: String = "com.apple.ttsbundle.siri_female_en-US_compact", + language: String = "en-US", + speakingRate: Float = 1.0, + pitch: Float = 1.0, + volume: Float = 1.0, + audioFormat: AudioFormat = .pcm, + useNeuralVoice: Bool = true, + enableSSML: Bool = false + ) { + self.voice = voice + self.language = language + self.speakingRate = speakingRate + self.pitch = pitch + self.volume = volume + self.audioFormat = audioFormat + self.useNeuralVoice = useNeuralVoice + self.enableSSML = enableSSML + self.modelId = nil + } + + // MARK: - Validation + + public func validate() throws { + guard speakingRate >= 0.5 && speakingRate <= 2.0 else { + throw SDKError.tts(.invalidSpeakingRate, "Invalid speaking rate: \(speakingRate). Must be between 0.5 and 2.0.") + } + guard pitch >= 0.5 && pitch <= 2.0 else { + throw SDKError.tts(.invalidPitch, "Invalid pitch: \(pitch). Must be between 0.5 and 2.0.") + } + guard volume >= 0.0 && volume <= 1.0 else { + throw SDKError.tts(.invalidVolume, "Invalid volume: \(volume). Must be between 0.0 and 1.0.") + } + } +} + +// MARK: - TTSConfiguration Builder + +extension TTSConfiguration { + + /// Create configuration with builder pattern + public static func builder(voice: String = "com.apple.ttsbundle.siri_female_en-US_compact") -> Builder { + Builder(voice: voice) + } + + public class Builder { + private var voice: String + private var language: String = "en-US" + private var speakingRate: Float = 1.0 + private var pitch: Float = 1.0 + private var volume: Float = 1.0 + private var audioFormat: AudioFormat = .pcm + private var useNeuralVoice: Bool = true + private var enableSSML: Bool = false + + init(voice: String) { + self.voice = voice + } + + public func voice(_ voice: String) -> Builder { + self.voice = voice + return self + } + + public func language(_ language: String) -> Builder { + self.language = language + return self + } + + public func speakingRate(_ rate: Float) -> Builder { + self.speakingRate = rate + return self + } + + public func pitch(_ pitch: Float) -> Builder { + self.pitch = pitch + return self + } + + public func volume(_ volume: Float) -> Builder { + self.volume = volume + return self + } + + public func audioFormat(_ format: AudioFormat) -> Builder { + self.audioFormat = format + return self + } + + public func useNeuralVoice(_ enabled: Bool) -> Builder { + self.useNeuralVoice = enabled + return self + } + + public func enableSSML(_ enabled: Bool) -> Builder { + self.enableSSML = enabled + return self + } + + public func build() -> TTSConfiguration { + TTSConfiguration( + voice: voice, + language: language, + speakingRate: speakingRate, + pitch: pitch, + volume: volume, + audioFormat: audioFormat, + useNeuralVoice: useNeuralVoice, + enableSSML: enableSSML + ) + } + } +} + +// MARK: - TTS Options + +/// Options for text-to-speech synthesis +public struct TTSOptions: Sendable { + + /// Voice to use for synthesis (nil uses default) + public let voice: String? + + /// Language for synthesis (BCP-47 format, e.g., "en-US") + public let language: String + + /// Speech rate (0.0 to 2.0, 1.0 is normal) + public let rate: Float + + /// Speech pitch (0.0 to 2.0, 1.0 is normal) + public let pitch: Float + + /// Speech volume (0.0 to 1.0) + public let volume: Float + + /// Audio format for output + public let audioFormat: AudioFormat + + /// Sample rate for output audio in Hz + public let sampleRate: Int + + /// Whether to use SSML markup + public let useSSML: Bool + + public init( + voice: String? = nil, + language: String = "en-US", + rate: Float = 1.0, + pitch: Float = 1.0, + volume: Float = 1.0, + audioFormat: AudioFormat = .pcm, + sampleRate: Int = Int(RAC_TTS_DEFAULT_SAMPLE_RATE), + useSSML: Bool = false + ) { + self.voice = voice + self.language = language + self.rate = rate + self.pitch = pitch + self.volume = volume + self.audioFormat = audioFormat + self.sampleRate = sampleRate + self.useSSML = useSSML + } + + /// Create options from TTSConfiguration + public static func from(configuration: TTSConfiguration) -> TTSOptions { + TTSOptions( + voice: configuration.voice, + language: configuration.language, + rate: configuration.speakingRate, + pitch: configuration.pitch, + volume: configuration.volume, + audioFormat: configuration.audioFormat, + sampleRate: configuration.audioFormat == .pcm ? Int(RAC_TTS_DEFAULT_SAMPLE_RATE) : Int(RAC_TTS_CD_QUALITY_SAMPLE_RATE), + useSSML: configuration.enableSSML + ) + } + + /// Default options + public static var `default`: TTSOptions { + TTSOptions() + } + + // MARK: - C++ Bridge (rac_tts_options_t) + + /// Execute a closure with the C++ equivalent options struct + public func withCOptions(_ body: (UnsafePointer) throws -> T) rethrows -> T { + var cOptions = rac_tts_options_t() + cOptions.rate = rate + cOptions.pitch = pitch + cOptions.volume = volume + cOptions.audio_format = audioFormat.toCFormat() + cOptions.sample_rate = Int32(sampleRate) + cOptions.use_ssml = useSSML ? RAC_TRUE : RAC_FALSE + + return try language.withCString { langPtr in + cOptions.language = langPtr + + if let voice = voice { + return try voice.withCString { voicePtr in + cOptions.voice = voicePtr + return try body(&cOptions) + } + } else { + cOptions.voice = nil + return try body(&cOptions) + } + } + } +} + +// MARK: - TTS Output + +/// Output from Text-to-Speech synthesis +public struct TTSOutput: ComponentOutput, Sendable { + + /// Synthesized audio data + public let audioData: Data + + /// Audio format of the output + public let format: AudioFormat + + /// Duration of the audio in seconds + public let duration: TimeInterval + + /// Phoneme timestamps if available + public let phonemeTimestamps: [TTSPhonemeTimestamp]? + + /// Processing metadata + public let metadata: TTSSynthesisMetadata + + /// Timestamp (required by ComponentOutput) + public let timestamp: Date + + public init( + audioData: Data, + format: AudioFormat, + duration: TimeInterval, + phonemeTimestamps: [TTSPhonemeTimestamp]? = nil, + metadata: TTSSynthesisMetadata, + timestamp: Date = Date() + ) { + self.audioData = audioData + self.format = format + self.duration = duration + self.phonemeTimestamps = phonemeTimestamps + self.metadata = metadata + self.timestamp = timestamp + } + + /// Audio size in bytes + public var audioSizeBytes: Int { + audioData.count + } + + /// Whether the output has phoneme timing information + public var hasPhonemeTimestamps: Bool { + guard let timestamps = phonemeTimestamps else { return false } + return !timestamps.isEmpty + } + + // MARK: - C++ Bridge (rac_tts_output_t) + + /// Initialize from C++ rac_tts_output_t + public init(from cOutput: rac_tts_output_t) { + // Convert audio data + let audioData: Data + if cOutput.audio_size > 0, let dataPtr = cOutput.audio_data { + audioData = Data(bytes: dataPtr, count: cOutput.audio_size) + } else { + audioData = Data() + } + + // Convert audio format + let format = AudioFormat(from: cOutput.format) + + // Convert phoneme timestamps + var phonemeTimestamps: [TTSPhonemeTimestamp]? + if cOutput.num_phoneme_timestamps > 0, let cPhonemes = cOutput.phoneme_timestamps { + phonemeTimestamps = (0.. 0 ? Double(characterCount) / processingTime : 0 + } + + public init( + voice: String, + language: String, + processingTime: TimeInterval, + characterCount: Int + ) { + self.voice = voice + self.language = language + self.processingTime = processingTime + self.characterCount = characterCount + } +} + +/// Phoneme timestamp information +public struct TTSPhonemeTimestamp: Sendable { + /// The phoneme + public let phoneme: String + + /// Start time in seconds + public let startTime: TimeInterval + + /// End time in seconds + public let endTime: TimeInterval + + /// Duration of the phoneme + public var duration: TimeInterval { + endTime - startTime + } + + public init(phoneme: String, startTime: TimeInterval, endTime: TimeInterval) { + self.phoneme = phoneme + self.startTime = startTime + self.endTime = endTime + } +} + +// MARK: - Speak Result + +/// Result from `speak()` - contains metadata only, no audio data. +public struct TTSSpeakResult: Sendable { + + /// Duration of the spoken audio in seconds + public let duration: TimeInterval + + /// Audio format used + public let format: AudioFormat + + /// Audio size in bytes (0 for system TTS which plays directly) + public let audioSizeBytes: Int + + /// Synthesis metadata (voice, language, processing time, etc.) + public let metadata: TTSSynthesisMetadata + + /// Timestamp when speech completed + public let timestamp: Date + + public init( + duration: TimeInterval, + format: AudioFormat, + audioSizeBytes: Int, + metadata: TTSSynthesisMetadata, + timestamp: Date = Date() + ) { + self.duration = duration + self.format = format + self.audioSizeBytes = audioSizeBytes + self.metadata = metadata + self.timestamp = timestamp + } + + /// Create from TTSOutput (internal use) + internal init(from output: TTSOutput) { + self.duration = output.duration + self.format = output.format + self.audioSizeBytes = output.audioSizeBytes + self.metadata = output.metadata + self.timestamp = output.timestamp + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/VAD/RunAnywhere+VAD.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/VAD/RunAnywhere+VAD.swift new file mode 100644 index 000000000..4d77e5cec --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/VAD/RunAnywhere+VAD.swift @@ -0,0 +1,201 @@ +// +// RunAnywhere+VAD.swift +// RunAnywhere SDK +// +// Public API for Voice Activity Detection operations. +// Calls C++ directly via CppBridge.VAD for all operations. +// Events are emitted by C++ layer via CppEventBridge. +// + +@preconcurrency import AVFoundation +import CRACommons +import Foundation + +// MARK: - VAD State Storage + +/// Internal actor for managing VAD-specific state for callbacks +private actor VADStateManager { + static let shared = VADStateManager() + + var onAudioBuffer: (([Float]) -> Void)? + // periphery:ignore - Retained to prevent deallocation while C callback is active + var callbackContext: VADCallbackContext? + + func setOnAudioBuffer(_ callback: (([Float]) -> Void)?) { + onAudioBuffer = callback + } + + func setCallbackContext(_ context: VADCallbackContext?) { + callbackContext = context + } + + func getAudioBufferCallback() -> (([Float]) -> Void)? { + onAudioBuffer + } +} + +// MARK: - VAD Operations + +public extension RunAnywhere { + + // MARK: - Initialization + + /// Initialize VAD with default configuration + static func initializeVAD() async throws { + guard isSDKInitialized else { + throw SDKError.general(.notInitialized, "SDK not initialized") + } + + try await CppBridge.VAD.shared.initialize() + } + + /// Initialize VAD with configuration + /// - Parameter config: VAD configuration + static func initializeVAD(_ config: VADConfiguration) async throws { + guard isSDKInitialized else { + throw SDKError.general(.notInitialized, "SDK not initialized") + } + + // Get handle and configure + let handle = try await CppBridge.VAD.shared.getHandle() + + var cConfig = rac_vad_config_t() + cConfig.sample_rate = Int32(config.sampleRate) + cConfig.frame_length = Float(config.frameLength) + cConfig.energy_threshold = Float(config.energyThreshold) + + let configResult = rac_vad_component_configure(handle, &cConfig) + if configResult != RAC_SUCCESS { + // Log warning but continue + } + + // Initialize + let result = rac_vad_component_initialize(handle) + guard result == RAC_SUCCESS else { + throw SDKError.vad(.initializationFailed, "VAD initialization failed: \(result)") + } + } + + /// Check if VAD is ready + static var isVADReady: Bool { + get async { + await CppBridge.VAD.shared.isInitialized + } + } + + // MARK: - Detection + + /// Detect speech in audio buffer + /// - Parameter buffer: Audio buffer to analyze + /// - Returns: Whether speech was detected + static func detectSpeech(in buffer: AVAudioPCMBuffer) async throws -> Bool { + guard isSDKInitialized else { + throw SDKError.general(.notInitialized, "SDK not initialized") + } + + // Convert AVAudioPCMBuffer to [Float] + guard let channelData = buffer.floatChannelData else { + throw SDKError.vad(.emptyAudioBuffer, "Audio buffer has no channel data") + } + + let frameLength = Int(buffer.frameLength) + let samples = Array(UnsafeBufferPointer(start: channelData[0], count: frameLength)) + + return try await detectSpeech(in: samples) + } + + /// Detect speech in audio samples + /// - Parameter samples: Float array of audio samples + /// - Returns: Whether speech was detected + static func detectSpeech(in samples: [Float]) async throws -> Bool { + guard isSDKInitialized else { + throw SDKError.general(.notInitialized, "SDK not initialized") + } + + let handle = try await CppBridge.VAD.shared.getHandle() + + var hasVoice: rac_bool_t = RAC_FALSE + let result = samples.withUnsafeBufferPointer { buffer in + rac_vad_component_process( + handle, + buffer.baseAddress, + buffer.count, + &hasVoice + ) + } + + guard result == RAC_SUCCESS else { + throw SDKError.vad(.processingFailed, "Failed to process samples: \(result)") + } + + let detected = hasVoice == RAC_TRUE + + // Forward to audio buffer callback if set + if let callback = await VADStateManager.shared.getAudioBufferCallback() { + callback(samples) + } + + return detected + } + + // MARK: - Control + + /// Start VAD processing + static func startVAD() async throws { + try await CppBridge.VAD.shared.start() + } + + /// Stop VAD processing + static func stopVAD() async throws { + try await CppBridge.VAD.shared.stop() + } + + // MARK: - Callbacks + + /// Set VAD speech activity callback + /// - Parameter callback: Callback invoked when speech state changes + static func setVADSpeechActivityCallback(_ callback: @escaping (SpeechActivityEvent) -> Void) async { + guard let handle = try? await CppBridge.VAD.shared.getHandle() else { return } + + // Create callback context + let context = VADCallbackContext(onActivity: callback) + await VADStateManager.shared.setCallbackContext(context) + let contextPtr = Unmanaged.passRetained(context).toOpaque() + + rac_vad_component_set_activity_callback( + handle, + { activity, userData in + guard let userData = userData else { return } + let ctx = Unmanaged.fromOpaque(userData).takeUnretainedValue() + let event: SpeechActivityEvent = activity == RAC_SPEECH_STARTED ? .started : .ended + ctx.onActivity(event) + }, + contextPtr + ) + } + + /// Set VAD audio buffer callback + /// - Parameter callback: Callback invoked with audio samples + static func setVADAudioBufferCallback(_ callback: @escaping ([Float]) -> Void) async { + await VADStateManager.shared.setOnAudioBuffer(callback) + } + + // MARK: - Cleanup + + /// Cleanup VAD resources + static func cleanupVAD() async { + await CppBridge.VAD.shared.cleanup() + await VADStateManager.shared.setOnAudioBuffer(nil) + await VADStateManager.shared.setCallbackContext(nil) + } +} + +// MARK: - Callback Context + +private final class VADCallbackContext: @unchecked Sendable { + let onActivity: (SpeechActivityEvent) -> Void + + init(onActivity: @escaping (SpeechActivityEvent) -> Void) { + self.onActivity = onActivity + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/VAD/VADTypes.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/VAD/VADTypes.swift new file mode 100644 index 000000000..47851b2ff --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/VAD/VADTypes.swift @@ -0,0 +1,241 @@ +// +// VADTypes.swift +// RunAnywhere SDK +// +// Public types for Voice Activity Detection. +// These are thin wrappers over C++ types in rac_vad_types.h +// + +import CRACommons +import Foundation + +// MARK: - VAD Configuration + +/// Configuration for Voice Activity Detection operations +public struct VADConfiguration: ComponentConfiguration, Sendable { + + // MARK: - ComponentConfiguration + + /// Component type + public var componentType: SDKComponent { .vad } + + /// Model ID (not used for VAD) + public var modelId: String? { nil } + + /// Preferred framework (uses default extension implementation) + public var preferredFramework: InferenceFramework? { nil } + + // MARK: - Configuration Properties + + /// Energy threshold for voice detection (0.0 to 1.0) + /// Recommended range: 0.01-0.05 + public let energyThreshold: Float + + /// Sample rate in Hz (default: 16000) + public let sampleRate: Int + + /// Frame length in seconds (default: 0.1 = 100ms) + public let frameLength: Float + + /// Enable automatic calibration + public let enableAutoCalibration: Bool + + /// Calibration multiplier (threshold = ambient noise * multiplier) + /// Range: 1.5 to 5.0 + public let calibrationMultiplier: Float + + // MARK: - Initialization + + public init( + energyThreshold: Float = 0.015, + sampleRate: Int = Int(RAC_VAD_DEFAULT_SAMPLE_RATE), + frameLength: Float = 0.1, + enableAutoCalibration: Bool = false, + calibrationMultiplier: Float = 2.0 + ) { + self.energyThreshold = energyThreshold + self.sampleRate = sampleRate + self.frameLength = frameLength + self.enableAutoCalibration = enableAutoCalibration + self.calibrationMultiplier = calibrationMultiplier + } + + // MARK: - Validation + + public func validate() throws { + // Validate threshold range + guard energyThreshold >= 0 && energyThreshold <= 1.0 else { + throw SDKError.vad( + .invalidConfiguration, + "Energy threshold must be between 0 and 1.0. Recommended range: 0.01-0.05" + ) + } + + // Warn if threshold is too low + if energyThreshold < 0.002 { + throw SDKError.vad( + .invalidConfiguration, + "Energy threshold \(energyThreshold) is very low and may cause false positives. Recommended minimum: 0.002" + ) + } + + // Warn if threshold is too high + if energyThreshold > 0.1 { + throw SDKError.vad( + .invalidConfiguration, + "Energy threshold \(energyThreshold) is very high and may miss speech. Recommended maximum: 0.1" + ) + } + + // Validate sample rate + guard sampleRate > 0 && sampleRate <= 48000 else { + throw SDKError.vad( + .invalidConfiguration, + "Sample rate must be between 1 and 48000 Hz" + ) + } + + // Validate frame length + guard frameLength > 0 && frameLength <= 1.0 else { + throw SDKError.vad( + .invalidConfiguration, + "Frame length must be between 0 and 1 second" + ) + } + + // Validate calibration multiplier + guard calibrationMultiplier >= 1.5 && calibrationMultiplier <= 5.0 else { + throw SDKError.vad( + .invalidConfiguration, + "Calibration multiplier must be between 1.5 and 5.0" + ) + } + } +} + +// MARK: - VADConfiguration Builder + +extension VADConfiguration { + + /// Create configuration with builder pattern + public static func builder() -> Builder { + Builder() + } + + public class Builder { + private var energyThreshold: Float = 0.015 + private var sampleRate = Int(RAC_VAD_DEFAULT_SAMPLE_RATE) + private var frameLength: Float = 0.1 + private var enableAutoCalibration: Bool = false + private var calibrationMultiplier: Float = 2.0 + + public init() {} + + public func energyThreshold(_ threshold: Float) -> Builder { + self.energyThreshold = threshold + return self + } + + public func sampleRate(_ rate: Int) -> Builder { + self.sampleRate = rate + return self + } + + public func frameLength(_ length: Float) -> Builder { + self.frameLength = length + return self + } + + public func enableAutoCalibration(_ enabled: Bool) -> Builder { + self.enableAutoCalibration = enabled + return self + } + + public func calibrationMultiplier(_ multiplier: Float) -> Builder { + self.calibrationMultiplier = multiplier + return self + } + + public func build() -> VADConfiguration { + VADConfiguration( + energyThreshold: energyThreshold, + sampleRate: sampleRate, + frameLength: frameLength, + enableAutoCalibration: enableAutoCalibration, + calibrationMultiplier: calibrationMultiplier + ) + } + } +} + +// MARK: - VAD Statistics + +/// Statistics for VAD debugging and monitoring +public struct VADStatistics: Sendable { + + /// Current energy level + public let current: Float + + /// Energy threshold being used + public let threshold: Float + + /// Ambient noise level (from calibration) + public let ambient: Float + + /// Recent average energy level + public let recentAvg: Float + + /// Recent maximum energy level + public let recentMax: Float + + public init( + current: Float, + threshold: Float, + ambient: Float, + recentAvg: Float, + recentMax: Float + ) { + self.current = current + self.threshold = threshold + self.ambient = ambient + self.recentAvg = recentAvg + self.recentMax = recentMax + } + + // MARK: - C++ Bridge (rac_energy_vad_stats_t) + + /// Initialize from C++ rac_energy_vad_stats_t + public init(from cStats: rac_energy_vad_stats_t) { + self.init( + current: cStats.current, + threshold: cStats.threshold, + ambient: cStats.ambient, + recentAvg: cStats.recent_avg, + recentMax: cStats.recent_max + ) + } +} + +extension VADStatistics: CustomStringConvertible { + public var description: String { + """ + VADStatistics: + Current: \(String(format: "%.6f", current)) + Threshold: \(String(format: "%.6f", threshold)) + Ambient: \(String(format: "%.6f", ambient)) + Recent Avg: \(String(format: "%.6f", recentAvg)) + Recent Max: \(String(format: "%.6f", recentMax)) + """ + } +} + +// MARK: - Speech Activity Event + +/// Events representing speech activity state changes +public enum SpeechActivityEvent: String, Sendable { + /// Speech has started + case started + + /// Speech has ended + case ended +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/VoiceAgent/RunAnywhere+VoiceAgent.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/VoiceAgent/RunAnywhere+VoiceAgent.swift new file mode 100644 index 000000000..f96978079 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/VoiceAgent/RunAnywhere+VoiceAgent.swift @@ -0,0 +1,276 @@ +// +// RunAnywhere+VoiceAgent.swift +// RunAnywhere SDK +// +// Public API for Voice Agent operations (full voice pipeline). +// Calls C++ directly via CppBridge for all operations. +// Events are emitted by C++ layer - no Swift event emissions needed. +// +// Architecture: +// - Voice agent uses SHARED handles from the individual components (STT, LLM, TTS, VAD) +// - Models are loaded via loadSTT(), loadLLM(), loadTTS() (the individual APIs) +// - Voice agent is purely an orchestrator for the full voice pipeline +// - All events (including state changes) are emitted from C++ +// +// Types are defined in VoiceAgentTypes.swift +// + +import CRACommons +import Foundation + +// MARK: - Voice Agent Operations + +public extension RunAnywhere { + + // MARK: - Component State Management + + /// Get the current state of all voice agent components (STT, LLM, TTS) + /// + /// Use this to check which models are loaded and ready for the voice pipeline. + /// Models are loaded via the individual APIs (loadSTT, loadLLM, loadTTS). + static func getVoiceAgentComponentStates() async -> VoiceAgentComponentStates { + guard isSDKInitialized else { + return VoiceAgentComponentStates() + } + + let sttLoaded = await CppBridge.STT.shared.isLoaded + let sttId = await CppBridge.STT.shared.currentModelId + let llmLoaded = await CppBridge.LLM.shared.isLoaded + let llmId = await CppBridge.LLM.shared.currentModelId + let ttsLoaded = await CppBridge.TTS.shared.isLoaded + let ttsId = await CppBridge.TTS.shared.currentVoiceId + + let sttState: ComponentLoadState + if sttLoaded, let modelId = sttId { + sttState = .loaded(modelId: modelId) + } else { + sttState = .notLoaded + } + + let llmState: ComponentLoadState + if llmLoaded, let modelId = llmId { + llmState = .loaded(modelId: modelId) + } else { + llmState = .notLoaded + } + + let ttsState: ComponentLoadState + if ttsLoaded, let modelId = ttsId { + ttsState = .loaded(modelId: modelId) + } else { + ttsState = .notLoaded + } + + return VoiceAgentComponentStates(stt: sttState, llm: llmState, tts: ttsState) + } + + /// Check if all voice agent components are loaded and ready + static var areAllVoiceComponentsReady: Bool { + get async { + let states = await getVoiceAgentComponentStates() + return states.isFullyReady + } + } + + // MARK: - Initialization + + /// Initialize the voice agent with configuration + /// Events are emitted from C++ - no Swift event emissions needed + static func initializeVoiceAgent(_ config: VoiceAgentConfiguration) async throws { + guard isSDKInitialized else { + throw SDKError.general(.notInitialized, "SDK not initialized") + } + + try await ensureServicesReady() + + let handle = try await CppBridge.VoiceAgent.shared.getHandle() + + // Build C config + var cConfig = rac_voice_agent_config_t() + + // VAD config + cConfig.vad_config.sample_rate = Int32(config.vadSampleRate) + cConfig.vad_config.frame_length = config.vadFrameLength + cConfig.vad_config.energy_threshold = config.vadEnergyThreshold + + // STT config + if let sttModelId = config.sttModelId { + cConfig.stt_config.model_id = (sttModelId as NSString).utf8String + } + + // LLM config + if let llmModelId = config.llmModelId { + cConfig.llm_config.model_id = (llmModelId as NSString).utf8String + } + + // TTS config + if let ttsVoice = config.ttsVoice { + cConfig.tts_config.voice_id = (ttsVoice as NSString).utf8String + } + + let result = rac_voice_agent_initialize(handle, &cConfig) + guard result == RAC_SUCCESS else { + throw SDKError.voiceAgent(.initializationFailed, "Voice agent initialization failed: \(result)") + } + } + + /// Initialize voice agent using already-loaded models from individual APIs + /// Events are emitted from C++ - no Swift event emissions needed + static func initializeVoiceAgentWithLoadedModels() async throws { + guard isSDKInitialized else { + throw SDKError.general(.notInitialized, "SDK not initialized") + } + + try await ensureServicesReady() + + let handle = try await CppBridge.VoiceAgent.shared.getHandle() + + let result = rac_voice_agent_initialize_with_loaded_models(handle) + guard result == RAC_SUCCESS else { + throw SDKError.voiceAgent(.initializationFailed, "Failed to initialize with loaded models: \(result)") + } + } + + /// Check if voice agent is ready (all components initialized) + static var isVoiceAgentReady: Bool { + get async { + await CppBridge.VoiceAgent.shared.isReady + } + } + + // MARK: - Voice Processing + + /// Process a complete voice turn: audio -> transcription -> LLM response -> synthesized speech + static func processVoiceTurn(_ audioData: Data) async throws -> VoiceAgentResult { + guard isSDKInitialized else { + throw SDKError.general(.notInitialized, "SDK not initialized") + } + + let handle = try await CppBridge.VoiceAgent.shared.getHandle() + + var isReady: rac_bool_t = RAC_FALSE + rac_voice_agent_is_ready(handle, &isReady) + guard isReady == RAC_TRUE else { + throw SDKError.voiceAgent(.notInitialized, "Voice agent not ready") + } + + var cResult = rac_voice_agent_result_t() + let result = audioData.withUnsafeBytes { audioPtr in + rac_voice_agent_process_voice_turn( + handle, + audioPtr.baseAddress, + audioData.count, + &cResult + ) + } + + guard result == RAC_SUCCESS else { + throw SDKError.voiceAgent(.processingFailed, "Voice turn processing failed: \(result)") + } + + // Extract results + let speechDetected = cResult.speech_detected == RAC_TRUE + let transcription: String? = cResult.transcription.map { String(cString: $0) } + let response: String? = cResult.response.map { String(cString: $0) } + + // C++ returns WAV format directly + var synthesizedAudio: Data? + if let audioPtr = cResult.synthesized_audio, cResult.synthesized_audio_size > 0 { + synthesizedAudio = Data(bytes: audioPtr, count: cResult.synthesized_audio_size) + } + + // Free C result + rac_voice_agent_result_free(&cResult) + + return VoiceAgentResult( + speechDetected: speechDetected, + transcription: transcription, + response: response, + synthesizedAudio: synthesizedAudio + ) + } + + // MARK: - Individual Operations + + /// Transcribe audio (voice agent must be initialized) + static func voiceAgentTranscribe(_ audioData: Data) async throws -> String { + guard isSDKInitialized else { + throw SDKError.general(.notInitialized, "SDK not initialized") + } + + let handle = try await CppBridge.VoiceAgent.shared.getHandle() + + var transcriptionPtr: UnsafeMutablePointer? + let result = audioData.withUnsafeBytes { audioPtr in + rac_voice_agent_transcribe( + handle, + audioPtr.baseAddress, + audioData.count, + &transcriptionPtr + ) + } + + guard result == RAC_SUCCESS, let ptr = transcriptionPtr else { + throw SDKError.voiceAgent(.processingFailed, "Transcription failed: \(result)") + } + + let transcription = String(cString: ptr) + free(ptr) + + return transcription + } + + /// Generate LLM response (voice agent must be initialized) + static func voiceAgentGenerateResponse(_ prompt: String) async throws -> String { + guard isSDKInitialized else { + throw SDKError.general(.notInitialized, "SDK not initialized") + } + + let handle = try await CppBridge.VoiceAgent.shared.getHandle() + + var responsePtr: UnsafeMutablePointer? + let result = prompt.withCString { promptPtr in + rac_voice_agent_generate_response(handle, promptPtr, &responsePtr) + } + + guard result == RAC_SUCCESS, let ptr = responsePtr else { + throw SDKError.voiceAgent(.processingFailed, "Response generation failed: \(result)") + } + + let response = String(cString: ptr) + free(ptr) + + return response + } + + /// Synthesize speech (voice agent must be initialized) + static func voiceAgentSynthesizeSpeech(_ text: String) async throws -> Data { + guard isSDKInitialized else { + throw SDKError.general(.notInitialized, "SDK not initialized") + } + + let handle = try await CppBridge.VoiceAgent.shared.getHandle() + + var audioPtr: UnsafeMutableRawPointer? + var audioSize: Int = 0 + let result = text.withCString { textPtr in + rac_voice_agent_synthesize_speech(handle, textPtr, &audioPtr, &audioSize) + } + + guard result == RAC_SUCCESS, let ptr = audioPtr, audioSize > 0 else { + throw SDKError.voiceAgent(.processingFailed, "Speech synthesis failed: \(result)") + } + + let audioData = Data(bytes: ptr, count: audioSize) + free(ptr) + + return audioData + } + + // MARK: - Cleanup + + /// Cleanup voice agent resources + static func cleanupVoiceAgent() async { + await CppBridge.VoiceAgent.shared.cleanup() + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/VoiceAgent/RunAnywhere+VoiceSession.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/VoiceAgent/RunAnywhere+VoiceSession.swift new file mode 100644 index 000000000..091842093 --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/VoiceAgent/RunAnywhere+VoiceSession.swift @@ -0,0 +1,329 @@ +// +// RunAnywhere+VoiceSession.swift +// RunAnywhere SDK +// +// High-level voice session API for simplified voice assistant integration. +// Handles audio capture, VAD, and processing internally. +// +// Types are defined in VoiceAgentTypes.swift +// +// Usage: +// ```swift +// // Start a voice session +// let session = try await RunAnywhere.startVoiceSession() +// +// // Consume events in your UI +// for await event in session.events { +// switch event { +// case .listening(let level): updateAudioMeter(level) +// case .processing: showProcessingIndicator() +// case .result(let transcript, let response): updateUI(transcript, response) +// case .speaking: showSpeakingIndicator() +// case .error(let msg): showError(msg) +// } +// } +// +// // Or use callbacks +// try await RunAnywhere.startVoiceSession { event in +// // Handle event +// } +// ``` +// + +import AVFoundation +import Foundation + +// MARK: - Voice Session Handle + +/// Handle to control an active voice session +public actor VoiceSessionHandle { + private let logger = SDKLogger(category: "VoiceSession") + private let config: VoiceSessionConfig + + private let audioCapture = AudioCaptureManager() + private let audioPlayback = AudioPlaybackManager() + + private var isRunning = false + private var audioBuffer = Data() + private var lastSpeechTime: Date? + private var isSpeechActive = false + + private var eventContinuation: AsyncStream.Continuation? + + /// Stream of session events (nonisolated for easy consumption) + public nonisolated let events: AsyncStream + + init(config: VoiceSessionConfig) { + self.config = config + + var continuation: AsyncStream.Continuation! + self.events = AsyncStream { cont in + continuation = cont + } + self.eventContinuation = continuation + } + + /// Start the voice session + func start() async throws { + guard !isRunning else { return } + + // Verify voice agent is ready, or try to initialize + let isReady = await RunAnywhere.isVoiceAgentReady + if !isReady { + do { + try await RunAnywhere.initializeVoiceAgentWithLoadedModels() + } catch { + emit(.error("Voice agent not ready: \(error.localizedDescription)")) + throw error + } + } + + // Request mic permission + let hasPermission = await audioCapture.requestPermission() + guard hasPermission else { + emit(.error("Microphone permission denied")) + throw VoiceSessionError.microphonePermissionDenied + } + + isRunning = true + emit(.started) + + // Start audio capture loop + try await startListening() + } + + /// Stop the voice session + public func stop() { + guard isRunning else { return } + + isRunning = false + audioCapture.stopRecording() + audioPlayback.stop() + + audioBuffer = Data() + isSpeechActive = false + lastSpeechTime = nil + + emit(.stopped) + eventContinuation?.finish() + } + + /// Force process current audio (push-to-talk) + public func sendNow() async { + guard isRunning else { return } + isSpeechActive = false + await processCurrentAudio() + } + + // MARK: - Private + + private func emit(_ event: VoiceSessionEvent) { + eventContinuation?.yield(event) + } + + private func startListening() async throws { + audioBuffer = Data() + lastSpeechTime = nil + isSpeechActive = false + + try audioCapture.startRecording { [weak self] data in + guard let self = self else { return } + Task { + await self.handleAudioData(data) + } + } + + // Start audio level monitoring task + startAudioLevelMonitoring() + } + + private func startAudioLevelMonitoring() { + Task { [weak self] in + guard let self = self else { return } + while await self.isRunning { + // Get audio level on main actor since AudioCaptureManager is ObservableObject + let level = await self.getAudioLevel() + await self.checkSpeechState(level: level) + try? await Task.sleep(nanoseconds: 50_000_000) // 50ms + } + } + } + + private func getAudioLevel() async -> Float { + await MainActor.run { audioCapture.audioLevel } + } + + private func handleAudioData(_ data: Data) { + guard isRunning else { return } + audioBuffer.append(data) + } + + private func checkSpeechState(level: Float) async { + guard isRunning else { return } + + emit(.listening(audioLevel: level)) + + if level > config.speechThreshold { + if !isSpeechActive { + logger.debug("Speech started") + isSpeechActive = true + emit(.speechStarted) + } + lastSpeechTime = Date() + } else if isSpeechActive { + if let last = lastSpeechTime, Date().timeIntervalSince(last) > config.silenceDuration { + logger.debug("Speech ended") + isSpeechActive = false + + // Only process if we have enough audio + if audioBuffer.count > 16000 { // ~0.5s at 16kHz + await processCurrentAudio() + } else { + audioBuffer = Data() + } + } + } + } + + private func processCurrentAudio() async { + let audio = audioBuffer + audioBuffer = Data() + + guard !audio.isEmpty, isRunning else { return } + + // Stop listening during processing + audioCapture.stopRecording() + + emit(.processing) + + do { + let result = try await RunAnywhere.processVoiceTurn(audio) + + guard result.speechDetected else { + logger.info("No speech detected") + if config.continuousMode && isRunning { + try? await startListening() + } + return + } + + // Emit intermediate results + if let transcript = result.transcription { + emit(.transcribed(text: transcript)) + } + + if let response = result.response { + emit(.responded(text: response)) + } + + // Play TTS if enabled + if config.autoPlayTTS, let ttsAudio = result.synthesizedAudio, !ttsAudio.isEmpty { + emit(.speaking) + try await audioPlayback.play(ttsAudio) + } + + // Emit complete result + emit(.turnCompleted( + transcript: result.transcription ?? "", + response: result.response ?? "", + audio: result.synthesizedAudio + )) + + } catch { + logger.error("Processing failed: \(error)") + emit(.error(error.localizedDescription)) + } + + // Resume listening if continuous mode + if config.continuousMode && isRunning { + try? await startListening() + } + } +} + +// MARK: - RunAnywhere Extension + +public extension RunAnywhere { + + /// Start a voice session with async stream of events + /// + /// This is the simplest way to integrate voice assistant. + /// The session handles audio capture, VAD, and processing internally. + /// + /// Example: + /// ```swift + /// let session = try await RunAnywhere.startVoiceSession() + /// + /// // Consume events + /// for await event in session.events { + /// switch event { + /// case .listening(let level): + /// audioMeter = level + /// case .processing: + /// status = "Processing..." + /// case .turnCompleted(let transcript, let response, _): + /// userText = transcript + /// assistantText = response + /// case .stopped: + /// break + /// default: + /// break + /// } + /// } + /// ``` + /// + /// - Parameter config: Session configuration (optional) + /// - Returns: Session handle with events stream + static func startVoiceSession( + config: VoiceSessionConfig = .default + ) async throws -> VoiceSessionHandle { + let session = VoiceSessionHandle(config: config) + try await session.start() + return session + } + + /// Start a voice session with callback-based event handling + /// + /// Alternative API using callbacks instead of async stream. + /// + /// Example: + /// ```swift + /// let session = try await RunAnywhere.startVoiceSession { event in + /// switch event { + /// case .listening(let level): + /// DispatchQueue.main.async { self.audioLevel = level } + /// case .turnCompleted(let transcript, let response, _): + /// DispatchQueue.main.async { + /// self.userText = transcript + /// self.assistantText = response + /// } + /// default: + /// break + /// } + /// } + /// + /// // Later... + /// await session.stop() + /// ``` + /// + /// - Parameters: + /// - config: Session configuration + /// - onEvent: Callback for each event + /// - Returns: Session handle for control + static func startVoiceSession( + config: VoiceSessionConfig = .default, + onEvent: @escaping @Sendable (VoiceSessionEvent) -> Void + ) async throws -> VoiceSessionHandle { + let session = VoiceSessionHandle(config: config) + + // Forward events to callback + Task { + for await event in session.events { + onEvent(event) + } + } + + try await session.start() + return session + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/VoiceAgent/VoiceAgentTypes.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/VoiceAgent/VoiceAgentTypes.swift new file mode 100644 index 000000000..da6c8ff1e --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/VoiceAgent/VoiceAgentTypes.swift @@ -0,0 +1,254 @@ +// +// VoiceAgentTypes.swift +// RunAnywhere SDK +// +// Consolidated voice agent and voice session types for public API. +// Includes: configurations, states, results, events, and errors. +// + +import CRACommons +import Foundation + +// MARK: - Voice Agent Result + +/// Result from voice agent processing +/// Contains all outputs from the voice pipeline: transcription, LLM response, and synthesized audio +public struct VoiceAgentResult: Sendable { + /// Whether speech was detected in the input audio + public var speechDetected: Bool + + /// Transcribed text from STT + public var transcription: String? + + /// Generated response text from LLM + public var response: String? + + /// Synthesized audio data from TTS + public var synthesizedAudio: Data? + + /// Initialize with default values + public init( + speechDetected: Bool = false, + transcription: String? = nil, + response: String? = nil, + synthesizedAudio: Data? = nil + ) { + self.speechDetected = speechDetected + self.transcription = transcription + self.response = response + self.synthesizedAudio = synthesizedAudio + } + + /// Initialize from C++ rac_voice_agent_result_t + public init(from cResult: rac_voice_agent_result_t) { + self.init( + speechDetected: cResult.speech_detected == RAC_TRUE, + transcription: cResult.transcription.map { String(cString: $0) }, + response: cResult.response.map { String(cString: $0) }, + synthesizedAudio: { + guard cResult.synthesized_audio_size > 0, let audioPtr = cResult.synthesized_audio else { + return nil + } + return Data(bytes: audioPtr, count: cResult.synthesized_audio_size) + }() + ) + } +} + +// MARK: - Component Load State + +/// Represents the loading state of a single model/voice component +public enum ComponentLoadState: Sendable, Equatable { + case notLoaded + case loading + case loaded(modelId: String) + case error(String) + + /// Whether the component is currently loaded and ready to use + public var isLoaded: Bool { + if case .loaded = self { return true } + return false + } + + /// Whether the component is currently loading + public var isLoading: Bool { + if case .loading = self { return true } + return false + } + + /// Get the model ID if loaded + public var modelId: String? { + if case .loaded(let id) = self { return id } + return nil + } +} + +// MARK: - Voice Agent Component States + +/// Unified state of all voice agent components +public struct VoiceAgentComponentStates: Sendable { + /// Speech-to-Text component state + public let stt: ComponentLoadState + + /// Large Language Model component state + public let llm: ComponentLoadState + + /// Text-to-Speech component state + public let tts: ComponentLoadState + + /// Whether all components are loaded and the voice agent is ready to use + public var isFullyReady: Bool { + stt.isLoaded && llm.isLoaded && tts.isLoaded + } + + /// Whether any component is currently loading + public var isAnyLoading: Bool { + stt.isLoading || llm.isLoading || tts.isLoading + } + + /// Get a summary of which components are missing + public var missingComponents: [String] { + var missing: [String] = [] + if !stt.isLoaded { missing.append("STT") } + if !llm.isLoaded { missing.append("LLM") } + if !tts.isLoaded { missing.append("TTS") } + return missing + } + + public init( + stt: ComponentLoadState = .notLoaded, + llm: ComponentLoadState = .notLoaded, + tts: ComponentLoadState = .notLoaded + ) { + self.stt = stt + self.llm = llm + self.tts = tts + } +} + +// MARK: - Voice Agent Configuration + +/// Configuration for the voice agent +/// Uses C++ defaults via rac_voice_agent_config_t +public struct VoiceAgentConfiguration: Sendable { + /// STT model ID (optional - uses currently loaded model if nil) + public let sttModelId: String? + + /// LLM model ID (optional - uses currently loaded model if nil) + public let llmModelId: String? + + /// TTS voice (optional - uses currently loaded voice if nil) + public let ttsVoice: String? + + /// VAD sample rate + public let vadSampleRate: Int + + /// VAD frame length in seconds + public let vadFrameLength: Float + + /// VAD energy threshold + public let vadEnergyThreshold: Float + + public init( + sttModelId: String? = nil, + llmModelId: String? = nil, + ttsVoice: String? = nil, + vadSampleRate: Int = 16000, + vadFrameLength: Float = 0.1, + vadEnergyThreshold: Float = 0.005 + ) { + self.sttModelId = sttModelId + self.llmModelId = llmModelId + self.ttsVoice = ttsVoice + self.vadSampleRate = vadSampleRate + self.vadFrameLength = vadFrameLength + self.vadEnergyThreshold = vadEnergyThreshold + } +} + +// MARK: - Voice Session Events + +/// Events emitted during a voice session +public enum VoiceSessionEvent: Sendable { + /// Session started and ready + case started + + /// Listening for speech with current audio level (0.0 - 1.0) + case listening(audioLevel: Float) + + /// Speech detected, started accumulating audio + case speechStarted + + /// Speech ended, processing audio + case processing + + /// Got transcription from STT + case transcribed(text: String) + + /// Got response from LLM + case responded(text: String) + + /// Playing TTS audio + case speaking + + /// Complete turn result + case turnCompleted(transcript: String, response: String, audio: Data?) + + /// Session stopped + case stopped + + /// Error occurred + case error(String) +} + +// MARK: - Voice Session Configuration + +/// Configuration for voice session behavior +public struct VoiceSessionConfig: Sendable { + /// Silence duration (seconds) before processing speech + public var silenceDuration: TimeInterval + + /// Minimum audio level to detect speech (0.0 - 1.0) + public var speechThreshold: Float + + /// Whether to auto-play TTS response + public var autoPlayTTS: Bool + + /// Whether to auto-resume listening after TTS playback + public var continuousMode: Bool + + public init( + silenceDuration: TimeInterval = 1.5, + speechThreshold: Float = 0.1, + autoPlayTTS: Bool = true, + continuousMode: Bool = true + ) { + self.silenceDuration = silenceDuration + self.speechThreshold = speechThreshold + self.autoPlayTTS = autoPlayTTS + self.continuousMode = continuousMode + } + + /// Default configuration + public static let `default` = VoiceSessionConfig() +} + +// MARK: - Voice Session Errors + +/// Errors that can occur during a voice session +public enum VoiceSessionError: LocalizedError { + case microphonePermissionDenied + case notReady + case alreadyRunning + + public var errorDescription: String? { + switch self { + case .microphonePermissionDenied: + return "Microphone permission denied" + case .notReady: + return "Voice agent not ready. Load STT, LLM, and TTS models first." + case .alreadyRunning: + return "Voice session already running" + } + } +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Public/RunAnywhere.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Public/RunAnywhere.swift new file mode 100644 index 000000000..21dc0a78d --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Public/RunAnywhere.swift @@ -0,0 +1,410 @@ +// +// RunAnywhere.swift +// RunAnywhere SDK +// +// The main entry point for the RunAnywhere SDK. +// Contains SDK initialization, state management, and event access. +// + +import Combine +import Foundation +#if os(iOS) || os(tvOS) || os(watchOS) +import UIKit +#endif + +// MARK: - SDK Initialization Flow +// +// ┌─────────────────────────────────────────────────────────────────────────────┐ +// │ SDK INITIALIZATION FLOW │ +// └─────────────────────────────────────────────────────────────────────────────┘ +// +// PHASE 1: Core Init (Synchronous, ~1-5ms, No Network) +// ───────────────────────────────────────────────────── +// initialize() or initializeForDevelopment() +// ├─ Validate params (API key, URL, environment) +// ├─ Set log level +// ├─ Store params locally +// ├─ Store in Keychain (production/staging only) +// └─ Mark: isInitialized = true +// +// PHASE 2: Services Init (Async, ~100-500ms, Network Required) +// ──────────────────────────────────────────────────────────── +// completeServicesInitialization() +// ├─ Setup API Client +// │ ├─ Development: Use Supabase +// │ └─ Production/Staging: Authenticate with backend +// ├─ Register C++ Bridge Callbacks +// │ ├─ Model Assignment (CppBridge.ModelAssignment) +// │ └─ Platform Services (CppBridge.Platform) +// ├─ Load Models (from remote API via C++) +// ├─ Initialize EventPublisher (telemetry → backend) +// └─ Register Device with Backend +// +// USAGE: +// ────── +// // Development mode (default) +// try RunAnywhere.initialize() +// +// // Production mode - requires API key and backend URL +// try RunAnywhere.initialize( +// apiKey: "your_api_key", +// baseURL: "https://api.runanywhere.ai", +// environment: .production +// ) +// + +/// The RunAnywhere SDK - Single entry point for on-device AI +public enum RunAnywhere { + + // MARK: - Internal State Management + + /// Internal init params storage + internal static var initParams: SDKInitParams? + internal static var currentEnvironment: SDKEnvironment? + internal static var isInitialized = false + + /// Track if services initialization is complete (makes API calls O(1) after first use) + internal static var hasCompletedServicesInit = false + + // MARK: - SDK State + + /// Check if SDK is initialized (Phase 1 complete) + public static var isSDKInitialized: Bool { + isInitialized + } + + /// Check if services are fully ready (Phase 2 complete) + public static var areServicesReady: Bool { + hasCompletedServicesInit + } + + /// Check if SDK is active and ready for use + public static var isActive: Bool { + isInitialized && initParams != nil + } + + /// Current SDK version + public static var version: String { + SDKConstants.version + } + + /// Current environment (nil if not initialized) + public static var environment: SDKEnvironment? { + currentEnvironment + } + + /// Device ID (Keychain-persisted, survives reinstalls) + public static var deviceId: String { + DeviceIdentity.persistentUUID + } + + // MARK: - Event Access + + /// Access to all SDK events for subscription-based patterns + public static var events: EventBus { + EventBus.shared + } + + // MARK: - Authentication Info (Production/Staging only) + + /// Get current user ID from authentication + /// - Returns: User ID if authenticated, nil otherwise + public static func getUserId() -> String? { + CppBridge.State.userId + } + + /// Get current organization ID from authentication + /// - Returns: Organization ID if authenticated, nil otherwise + public static func getOrganizationId() -> String? { + CppBridge.State.organizationId + } + + /// Check if currently authenticated + /// - Returns: true if authenticated with valid token + public static var isAuthenticated: Bool { + CppBridge.State.isAuthenticated + } + + /// Check if device is registered with backend + public static func isDeviceRegistered() -> Bool { + CppBridge.Device.isRegistered + } + + // MARK: - SDK Reset (Testing) + + /// Reset SDK state (for testing purposes) + /// Clears all initialization state and cached data + public static func reset() { + let logger = SDKLogger(category: "RunAnywhere.Reset") + logger.info("Resetting SDK state...") + + isInitialized = false + hasCompletedServicesInit = false + initParams = nil + currentEnvironment = nil + + // Shutdown all C++ bridges and state + CppBridge.shutdown() + CppBridge.State.shutdown() + + logger.info("SDK state reset completed") + } + + // ═══════════════════════════════════════════════════════════════════════════ + // MARK: - SDK Initialization + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Initialize the RunAnywhere SDK + * + * This performs fast synchronous initialization, then starts async services in background. + * The SDK is usable immediately - services will be ready when first API call is made. + * + * **Phase 1 (Sync, ~1-5ms):** Validates params, sets up logging, stores config + * **Phase 2 (Background):** Network auth, service creation, model loading, device registration + * + * ## Usage Examples + * + * ```swift + * // Development mode (default) + * try RunAnywhere.initialize() + * + * // Production mode - requires API key and backend URL + * try RunAnywhere.initialize( + * apiKey: "your_api_key", + * baseURL: "https://api.runanywhere.ai", + * environment: .production + * ) + * ``` + * + * - Parameters: + * - apiKey: API key (optional for development, required for production/staging) + * - baseURL: Backend API base URL (optional for development, required for production/staging) + * - environment: SDK environment (default: .development) + * + * - Throws: SDKError if validation fails + */ + public static func initialize( + apiKey: String? = nil, + baseURL: String? = nil, + environment: SDKEnvironment = .development + ) throws { + let params: SDKInitParams + + if environment == .development { + // Development mode - use Supabase, no auth needed + params = SDKInitParams(forDevelopmentWithAPIKey: apiKey ?? "") + } else { + // Production/Staging mode - require API key and URL + guard let apiKey = apiKey, !apiKey.isEmpty else { + throw SDKError.general(.invalidConfiguration, "API key is required for \(environment.description) mode") + } + guard let baseURL = baseURL, !baseURL.isEmpty else { + throw SDKError.general(.invalidConfiguration, "Base URL is required for \(environment.description) mode") + } + params = try SDKInitParams(apiKey: apiKey, baseURL: baseURL, environment: environment) + } + + try performCoreInit(with: params, startBackgroundServices: true) + } + + /// Initialize with URL type for base URL + public static func initialize( + apiKey: String, + baseURL: URL, + environment: SDKEnvironment = .production + ) throws { + let params = try SDKInitParams(apiKey: apiKey, baseURL: baseURL, environment: environment) + try performCoreInit(with: params, startBackgroundServices: true) + } + + // ═══════════════════════════════════════════════════════════════════════════ + // MARK: - Phase 1: Core Initialization (Synchronous) + // ═══════════════════════════════════════════════════════════════════════════ + + /// Perform core initialization (Phase 1) + /// - Parameters: + /// - params: SDK initialization parameters + /// - startBackgroundServices: If true, starts Phase 2 in background task + private static func performCoreInit(with params: SDKInitParams, startBackgroundServices: Bool) throws { + // Return early if already initialized + guard !isInitialized else { return } + + let initStartTime = CFAbsoluteTimeGetCurrent() + + // Step 1: Set environment FIRST so Logging.shared initializes with correct config + // This must happen before any SDKLogger usage to ensure logs appear correctly + currentEnvironment = params.environment + initParams = params + + // Step 2: Apply environment-specific logging configuration + Logging.shared.applyEnvironmentConfiguration(params.environment) + + // Step 3: Initialize all core C++ bridges (platform adapter, events, telemetry, device) + // This must happen early so all C++ logs route to SDKLogger and events can be emitted + CppBridge.initialize(environment: params.environment) + + // Now safe to create logger and track events + let logger = SDKLogger(category: "RunAnywhere.Init") + CppBridge.Events.emitSDKInitStarted() + + do { + + // Step 4: Persist to Keychain (production/staging only) + if params.environment != .development { + try KeychainManager.shared.storeSDKParams(params) + } + + // Mark Phase 1 complete + isInitialized = true + + let initDurationMs = (CFAbsoluteTimeGetCurrent() - initStartTime) * 1000 + logger.info("✅ Phase 1 complete in \(String(format: "%.1f", initDurationMs))ms (\(params.environment.description))") + + CppBridge.Events.emitSDKInitCompleted(durationMs: initDurationMs) + + // Optionally start Phase 2 in background + if startBackgroundServices { + logger.debug("Starting Phase 2 (services) in background...") + Task.detached(priority: .userInitiated) { + do { + try await completeServicesInitialization() + SDKLogger(category: "RunAnywhere.Init").info("✅ Phase 2 complete (background)") + } catch { + SDKLogger(category: "RunAnywhere.Init") + .warning("⚠️ Phase 2 failed (non-critical): \(error.localizedDescription)") + } + } + } + + } catch { + logger.error("❌ Initialization failed: \(error.localizedDescription)") + initParams = nil + isInitialized = false + CppBridge.Events.emitSDKInitFailed(error: SDKError.from(error)) + throw error + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // MARK: - Phase 2: Services Initialization (Async) + // ═══════════════════════════════════════════════════════════════════════════ + + /// Complete services initialization (Phase 2) + /// + /// Called automatically in background by `initialize()`, or can be awaited directly + /// via `initializeAsync()`. Safe to call multiple times - returns immediately if already done. + /// + /// This method: + /// 1. Sets up API client (with authentication for production/staging) + /// 2. Initializes C++ model registry and bridges + /// 3. Initializes EventPublisher for telemetry + /// 4. Registers device with backend + public static func completeServicesInitialization() async throws { + // Fast path: already completed + if hasCompletedServicesInit { + return + } + + guard let params = initParams, let environment = currentEnvironment else { + throw SDKError.general(.notInitialized, "SDK not initialized") + } + + let logger = SDKLogger(category: "RunAnywhere.Services") + + // Check if HTTP needs initialization + let httpNeedsInit = await !CppBridge.HTTP.shared.isConfigured + + if httpNeedsInit { + logger.info("Initializing services for \(environment.description) mode...") + + // Step 1: Configure HTTP transport + try await setupHTTP(params: params, environment: environment, logger: logger) + + // Step 1.5: Flush any queued telemetry events now that HTTP is configured + // This ensures events queued during initialization are sent + CppBridge.Telemetry.flush() + logger.debug("Flushed queued telemetry events after HTTP configuration") + } + + // Step 2: Initialize C++ state + CppBridge.State.initialize( + environment: environment, + apiKey: params.apiKey, + baseURL: params.baseURL, + deviceId: DeviceIdentity.persistentUUID + ) + logger.debug("C++ state initialized") + + // Step 3: Initialize service bridges (Platform, ModelAssignment) + // Must be on MainActor for Platform services + await MainActor.run { + CppBridge.initializeServices() + } + logger.debug("Service bridges initialized") + + // Step 4: Set base directory for C++ model paths + if let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first { + try CppBridge.ModelPaths.setBaseDirectory(documentsURL) + logger.debug("Model paths base directory set") + } + + // Step 5: Register device via CppBridge (C++ handles all business logic) + do { + try await CppBridge.Device.registerIfNeeded(environment: environment) + logger.debug("Device registration check completed") + } catch { + logger.warning("Device registration failed (non-critical): \(error.localizedDescription)") + } + + // Step 6: Discover already-downloaded models on file system + // This scans the models directory and updates the registry for models found on disk + let discoveryResult = await CppBridge.ModelRegistry.shared.discoverDownloadedModels() + if discoveryResult.discoveredCount > 0 { + logger.info("Discovered \(discoveryResult.discoveredCount) downloaded models on startup") + } + + // Mark Phase 2 complete + hasCompletedServicesInit = true + } + + /// Ensure services are ready before API calls (internal guard) + /// O(1) after first successful initialization + internal static func ensureServicesReady() async throws { + if hasCompletedServicesInit { + return // O(1) fast path + } + try await completeServicesInitialization() + } + + // ═══════════════════════════════════════════════════════════════════════════ + // MARK: - Private: Service Setup Helpers + // ═══════════════════════════════════════════════════════════════════════════ + + /// Setup HTTP transport via CppBridge.HTTP + private static func setupHTTP( + params: SDKInitParams, + environment: SDKEnvironment, + logger: SDKLogger + ) async throws { + switch environment { + case .development: + // Use C++ development config for Supabase (cross-platform) + if await CppBridge.DevConfig.configureHTTP() { + logger.debug("HTTP: Supabase from C++ config (development)") + } else { + await CppBridge.HTTP.shared.configure(baseURL: params.baseURL, apiKey: params.apiKey) + logger.debug("HTTP: Provided URL (development)") + } + + case .staging, .production: + // Configure HTTP first + await CppBridge.HTTP.shared.configure(baseURL: params.baseURL, apiKey: params.apiKey) + + // Authenticate via CppBridge.Auth + try await CppBridge.Auth.authenticate(apiKey: params.apiKey) + logger.info("Authenticated for \(environment.description)") + } + } + +} diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Sessions/LiveTranscriptionSession.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Sessions/LiveTranscriptionSession.swift new file mode 100644 index 000000000..3f00bfd5b --- /dev/null +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Public/Sessions/LiveTranscriptionSession.swift @@ -0,0 +1,282 @@ +// +// LiveTranscriptionSession.swift +// RunAnywhere SDK +// +// High-level API for live/streaming transcription. +// Combines audio capture and streaming transcription into a single abstraction. +// + +import Combine +import Foundation + +// MARK: - Live Transcription Session + +/// A session for live/streaming speech-to-text transcription. +/// +/// This provides a high-level API that combines audio capture and streaming +/// transcription, handling all the complexity internally. +/// +/// ## Usage +/// +/// ```swift +/// // Start live transcription +/// let session = try await RunAnywhere.startLiveTranscription() +/// +/// // Listen for transcription updates +/// for await text in session.transcriptions { +/// print("Partial: \(text)") +/// } +/// +/// // Or use callback style +/// let session = try await RunAnywhere.startLiveTranscription { text in +/// print("Partial: \(text)") +/// } +/// +/// // Stop when done +/// await session.stop() +/// ``` +@MainActor +public final class LiveTranscriptionSession: ObservableObject { + private let logger = SDKLogger(category: "LiveTranscription") + + // MARK: - Published State + + /// Current transcription text (updates in real-time) + @Published public private(set) var currentText: String = "" + + /// Whether the session is actively transcribing + @Published public private(set) var isActive: Bool = false + + /// Current audio level (0.0 - 1.0) for visualization + @Published public private(set) var audioLevel: Float = 0.0 + + /// Error if transcription failed + @Published public private(set) var error: Error? + + // MARK: - Private Properties + + private let audioCapture: AudioCaptureManager + private var audioContinuation: AsyncStream.Continuation? + private var transcriptionTask: Task? + private var audioLevelCancellable: AnyCancellable? + private let options: STTOptions + + // Callback for partial transcriptions + private var onPartialCallback: ((String) -> Void)? + + // MARK: - Transcription Stream + + /// Async stream of transcription text updates + public var transcriptions: AsyncStream { + AsyncStream { [weak self] continuation in + Task { @MainActor in + self?.onPartialCallback = { text in + continuation.yield(text) + } + } + continuation.onTermination = { [weak self] _ in + Task { @MainActor in + self?.onPartialCallback = nil + } + } + } + } + + // MARK: - Initialization + + /// Create a new live transcription session + /// - Parameter options: STT options (language, etc.) + public init(options: STTOptions = STTOptions()) { + self.audioCapture = AudioCaptureManager() + self.options = options + } + + // MARK: - Public Methods + + /// Start live transcription + /// - Parameter onPartial: Optional callback for each partial transcription update + /// - Throws: `LiveTranscriptionError.alreadyActive` if session is already running, + /// `LiveTranscriptionError.microphonePermissionDenied` if mic access denied + public func start(onPartial: ((String) -> Void)? = nil) async throws { + guard !isActive else { + throw LiveTranscriptionError.alreadyActive + } + + // Request microphone permission + let granted = await audioCapture.requestPermission() + guard granted else { + throw LiveTranscriptionError.microphonePermissionDenied + } + + // Store callback + if let callback = onPartial { + self.onPartialCallback = callback + } + + // Subscribe to audio level updates + audioLevelCancellable = audioCapture.$audioLevel + .receive(on: DispatchQueue.main) + .sink { [weak self] level in + self?.audioLevel = level + } + + isActive = true + error = nil + currentText = "" + + // Start streaming transcription with callbacks + do { + try await RunAnywhere.startStreamingTranscription( + options: options, + onPartialResult: { [weak self] result in + Task { @MainActor in + guard let self = self, !Task.isCancelled else { return } + self.currentText = result.transcript + self.onPartialCallback?(result.transcript) + } + }, + onFinalResult: { [weak self] output in + Task { @MainActor in + guard let self = self else { return } + self.currentText = output.text + self.onPartialCallback?(output.text) + self.logger.info("Final transcription: \(output.text)") + } + }, + onError: { [weak self] err in + Task { @MainActor in + guard let self = self else { return } + self.error = err + self.logger.error("Transcription error: \(err.localizedDescription)") + } + } + ) + } catch { + isActive = false + throw error + } + + // Start audio capture that feeds into the streaming transcription + try audioCapture.startRecording { [weak self] audioData in + Task { + guard let self = self else { return } + // Convert Data to [Float] for streaming + let samples = audioData.withUnsafeBytes { buffer in + Array(buffer.bindMemory(to: Float.self)) + } + do { + try await RunAnywhere.processStreamingAudio(samples) + } catch { + Task { @MainActor in + self.error = error + } + } + } + } + + logger.info("Live transcription started") + } + + /// Stop live transcription + public func stop() async { + guard isActive else { return } + + logger.info("Stopping live transcription") + + // Stop streaming transcription + await RunAnywhere.stopStreamingTranscription() + + // Cancel transcription task + transcriptionTask?.cancel() + transcriptionTask = nil + + // Stop audio capture + audioCapture.stopRecording() + + // Finish audio stream + audioContinuation?.finish() + audioContinuation = nil + + // Clean up subscriptions + audioLevelCancellable?.cancel() + audioLevelCancellable = nil + + isActive = false + audioLevel = 0.0 + + logger.info("Live transcription stopped") + } + + /// Get the final transcription text + public var finalText: String { + currentText + } +} + +// MARK: - Errors + +/// Errors specific to live transcription +public enum LiveTranscriptionError: LocalizedError { + case microphonePermissionDenied + case alreadyActive + case notActive + + public var errorDescription: String? { + switch self { + case .microphonePermissionDenied: + return "Microphone permission is required for live transcription" + case .alreadyActive: + return "Live transcription session is already active" + case .notActive: + return "Live transcription session is not active" + } + } +} + +// MARK: - RunAnywhere Extension + +public extension RunAnywhere { + + /// Start a new live transcription session + /// + /// This provides a high-level API for real-time speech-to-text that handles + /// audio capture internally. + /// + /// ## Example + /// + /// ```swift + /// let session = try await RunAnywhere.startLiveTranscription() + /// + /// // Listen for updates + /// for await text in session.transcriptions { + /// print(text) + /// } + /// + /// // Or use the session's published properties + /// session.$currentText.sink { text in + /// self.transcriptionLabel.text = text + /// } + /// + /// // Stop when done + /// await session.stop() + /// ``` + /// + /// - Parameters: + /// - options: STT options (language, etc.) + /// - onPartial: Optional callback for each partial transcription + /// - Returns: A live transcription session + /// - Throws: If SDK is not initialized or microphone access is denied + @MainActor + static func startLiveTranscription( + options: STTOptions = STTOptions(), + onPartial: ((String) -> Void)? = nil + ) async throws -> LiveTranscriptionSession { + guard isSDKInitialized else { + throw SDKError.general(.notInitialized, "SDK not initialized") + } + + let session = LiveTranscriptionSession(options: options) + try await session.start(onPartial: onPartial) + return session + } +} diff --git a/sdk/runanywhere-swift/VERSION b/sdk/runanywhere-swift/VERSION new file mode 100644 index 000000000..8b5334dc1 --- /dev/null +++ b/sdk/runanywhere-swift/VERSION @@ -0,0 +1 @@ +0.17.5 diff --git a/sdk/runanywhere-swift/scripts/build-swift.sh b/sdk/runanywhere-swift/scripts/build-swift.sh new file mode 100755 index 000000000..3dd929c3a --- /dev/null +++ b/sdk/runanywhere-swift/scripts/build-swift.sh @@ -0,0 +1,360 @@ +#!/bin/bash +# ============================================================================= +# RunAnywhere Swift SDK - Build Script +# ============================================================================= +# +# Single entry point for building the Swift SDK. +# +# FIRST TIME SETUP: +# cd sdk/runanywhere-swift +# ./scripts/build-swift.sh --setup +# +# USAGE: +# ./scripts/build-swift.sh [command] +# +# COMMANDS: +# --setup First-time setup: downloads deps, builds commons, installs frameworks +# --local Use local frameworks (install from commons/dist to Binaries/) +# --remote Use remote frameworks from GitHub releases +# --build-commons Build runanywhere-commons from source +# +# OPTIONS: +# --clean Clean build artifacts before building +# --release Build in release mode (default: debug) +# --skip-build Skip swift build (only setup frameworks) +# --set-local Set testLocal = true in Package.swift +# --set-remote Set testLocal = false in Package.swift +# --help Show this help message +# +# EXAMPLES: +# # First time setup (recommended) +# ./scripts/build-swift.sh --setup +# +# # Rebuild after commons changes +# ./scripts/build-swift.sh --local --build-commons +# +# # Quick local (use existing commons build) +# ./scripts/build-swift.sh --local +# +# # Switch to remote mode (GitHub releases) +# ./scripts/build-swift.sh --remote +# +# ============================================================================= + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SWIFT_SDK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +SDK_DIR="$(cd "${SWIFT_SDK_DIR}/.." && pwd)" +REPO_ROOT="$(cd "${SDK_DIR}/.." && pwd)" + +# Source paths +COMMONS_DIR="$SDK_DIR/runanywhere-commons" +COMMONS_BUILD_SCRIPT="$COMMONS_DIR/scripts/build-ios.sh" + +# Destination paths (XCFrameworks go here) +BINARIES_DIR="$SWIFT_SDK_DIR/Binaries" + +# Root Package.swift (single source of truth) +PACKAGE_FILE="$REPO_ROOT/Package.swift" + +# Build configuration +BUILD_MODE="debug" +MODE="" +BUILD_COMMONS=false +CLEAN_BUILD=false +SKIP_BUILD=false +SET_LOCAL_MODE="" +SETUP_MODE=false + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +NC='\033[0m' + +log_info() { echo -e "${GREEN}[✓]${NC} $1"; } +log_warn() { echo -e "${YELLOW}[!]${NC} $1"; } +log_error() { echo -e "${RED}[✗]${NC} $1"; } +log_step() { echo -e "${BLUE}==>${NC} $1"; } +log_header() { echo -e "\n${GREEN}═══════════════════════════════════════════${NC}"; echo -e "${GREEN} $1${NC}"; echo -e "${GREEN}═══════════════════════════════════════════${NC}"; } + +show_help() { + head -45 "$0" | tail -40 + exit 0 +} + +# ============================================================================= +# Parse Arguments +# ============================================================================= + +for arg in "$@"; do + case "$arg" in + --setup) + SETUP_MODE=true + MODE="local" + BUILD_COMMONS=true + ;; + --local) + MODE="local" + ;; + --remote) + MODE="remote" + ;; + --build-commons) + BUILD_COMMONS=true + MODE="local" + ;; + --clean) + CLEAN_BUILD=true + ;; + --release) + BUILD_MODE="release" + ;; + --skip-build) + SKIP_BUILD=true + ;; + --set-local) + SET_LOCAL_MODE="local" + ;; + --set-remote) + SET_LOCAL_MODE="remote" + ;; + --help|-h) + show_help + ;; + esac +done + +# Default to local mode if nothing specified +if [[ -z "$MODE" && -z "$SET_LOCAL_MODE" ]]; then + # Check if Binaries/ has frameworks - if not, suggest --setup + if [[ ! -d "$BINARIES_DIR/RACommons.xcframework" ]]; then + echo "" + echo "═══════════════════════════════════════════════════════════════" + echo " RunAnywhere Swift SDK - Setup Required" + echo "═══════════════════════════════════════════════════════════════" + echo "" + echo "No frameworks found in Binaries/. Run first-time setup:" + echo "" + echo " ./scripts/build-swift.sh --setup" + echo "" + echo "This will download dependencies and build all frameworks." + echo "(Takes 5-15 minutes on first run)" + echo "" + exit 1 + fi + MODE="local" +fi + +# ============================================================================= +# Set Package.swift Mode +# ============================================================================= + +set_package_mode() { + local mode=$1 + + if [[ ! -f "$PACKAGE_FILE" ]]; then + log_error "Package.swift not found: $PACKAGE_FILE" + exit 1 + fi + + if [[ "$mode" == "local" ]]; then + log_step "Setting useLocalBinaries = true in Package.swift" + if grep -q 'let useLocalBinaries = true' "$PACKAGE_FILE"; then + log_info "Already in local mode" + return 0 + fi + sed -i '' 's/let useLocalBinaries = false/let useLocalBinaries = true/' "$PACKAGE_FILE" + if grep -q 'let useLocalBinaries = true' "$PACKAGE_FILE"; then + log_info "✓ Local mode enabled" + else + log_error "Failed to enable local mode" + exit 1 + fi + elif [[ "$mode" == "remote" ]]; then + log_step "Setting useLocalBinaries = false in Package.swift" + if grep -q 'let useLocalBinaries = false' "$PACKAGE_FILE"; then + log_info "Already in remote mode" + return 0 + fi + sed -i '' 's/let useLocalBinaries = true/let useLocalBinaries = false/' "$PACKAGE_FILE" + if grep -q 'let useLocalBinaries = false' "$PACKAGE_FILE"; then + log_info "✓ Remote mode enabled" + else + log_error "Failed to enable remote mode" + exit 1 + fi + fi +} + +# ============================================================================= +# Build Commons +# ============================================================================= + +build_commons() { + log_header "Building runanywhere-commons" + + if [[ ! -d "$COMMONS_DIR" ]]; then + log_error "runanywhere-commons not found at: $COMMONS_DIR" + log_error "Expected: sdk/runanywhere-commons/" + exit 1 + fi + + if [[ ! -x "$COMMONS_BUILD_SCRIPT" ]]; then + log_error "Build script not found: $COMMONS_BUILD_SCRIPT" + exit 1 + fi + + local FLAGS="" + [[ "$CLEAN_BUILD" == true ]] && FLAGS="$FLAGS --clean" + + log_step "Running: build-ios.sh $FLAGS" + log_info "This downloads dependencies and builds all frameworks..." + echo "" + "$COMMONS_BUILD_SCRIPT" $FLAGS + + log_info "runanywhere-commons build complete" +} + +# ============================================================================= +# Install Frameworks +# ============================================================================= + +install_frameworks() { + log_header "Installing XCFrameworks to Binaries/" + + mkdir -p "$BINARIES_DIR" + + # All frameworks are now in dist/ (flat structure from build-ios.sh) + for framework in RACommons RABackendLLAMACPP RABackendONNX; do + local src="$COMMONS_DIR/dist/${framework}.xcframework" + if [[ -d "$src" ]]; then + log_step "Copying ${framework}.xcframework" + rm -rf "$BINARIES_DIR/${framework}.xcframework" + cp -r "$src" "$BINARIES_DIR/" + log_info " ${framework}.xcframework ($(du -sh "$src" | cut -f1))" + else + if [[ "$framework" == "RACommons" ]]; then + log_warn "RACommons.xcframework not found at $src" + else + log_warn "${framework}.xcframework not found (optional)" + fi + fi + done + + log_info "Frameworks installed to: $BINARIES_DIR" +} + +# ============================================================================= +# Build Swift SDK +# ============================================================================= + +build_sdk() { + log_header "Building Swift SDK" + + cd "$REPO_ROOT" + + if $CLEAN_BUILD; then + log_step "Cleaning build..." + rm -rf .build/ 2>/dev/null || true + fi + + log_step "Running swift build ($BUILD_MODE)..." + + local BUILD_FLAGS="-Xswiftc -suppress-warnings" + if [[ "$BUILD_MODE" == "release" ]]; then + BUILD_FLAGS="$BUILD_FLAGS -c release" + fi + + if swift build $BUILD_FLAGS; then + log_info "Swift SDK built successfully" + else + log_error "Swift SDK build failed" + exit 1 + fi +} + +# ============================================================================= +# Main +# ============================================================================= + +main() { + if $SETUP_MODE; then + log_header "RunAnywhere Swift SDK - First Time Setup" + echo "" + echo "This will:" + echo " 1. Download ONNX Runtime & Sherpa-ONNX" + echo " 2. Build RACommons.xcframework" + echo " 3. Build RABackendLLAMACPP.xcframework" + echo " 4. Build RABackendONNX.xcframework" + echo " 5. Copy frameworks to Binaries/" + echo " 6. Set useLocalBinaries = true in Package.swift" + echo "" + echo "This may take 5-15 minutes..." + echo "" + else + log_header "RunAnywhere Swift SDK - Build" + echo "Repo Root: $REPO_ROOT" + echo "Swift SDK: $SWIFT_SDK_DIR" + echo "Commons: $COMMONS_DIR" + echo "Package.swift: $PACKAGE_FILE" + echo "Mode: $MODE" + echo "Build Commons: $BUILD_COMMONS" + echo "" + fi + + # Handle --set-local / --set-remote only + if [[ -n "$SET_LOCAL_MODE" && "$MODE" == "" ]]; then + set_package_mode "$SET_LOCAL_MODE" + log_header "Done!" + return 0 + fi + + # Build commons if requested + if $BUILD_COMMONS; then + build_commons + fi + + # In local mode, install frameworks and set package mode + if [[ "$MODE" == "local" ]]; then + install_frameworks + set_package_mode "local" + elif [[ "$MODE" == "remote" ]]; then + set_package_mode "remote" + fi + + # Build the SDK + if ! $SKIP_BUILD; then + build_sdk + else + log_info "Skipping swift build (--skip-build)" + fi + + log_header "Build Complete!" + + # Show status + echo "" + echo "Binaries directory: $BINARIES_DIR" + if [[ -d "$BINARIES_DIR" ]]; then + for xcfw in "$BINARIES_DIR"/*.xcframework; do + [[ -d "$xcfw" ]] && echo " $(du -sh "$xcfw" | cut -f1) $(basename "$xcfw")" + done + fi + + echo "" + echo "Package.swift: $(grep 'let useLocalBinaries' "$PACKAGE_FILE" | head -1 | xargs)" + echo "" + + if $SETUP_MODE; then + echo "Next steps:" + echo " 1. Open the example app in Xcode" + echo " 2. File > Packages > Reset Package Caches (if needed)" + echo " 3. Build & Run!" + echo "" + fi +} + +main "$@" diff --git a/sdk/runanywhere-swift/scripts/release-swift-sdk.sh b/sdk/runanywhere-swift/scripts/release-swift-sdk.sh new file mode 100755 index 000000000..13b7cddb8 --- /dev/null +++ b/sdk/runanywhere-swift/scripts/release-swift-sdk.sh @@ -0,0 +1,368 @@ +#!/usr/bin/env bash +# ============================================================================= +# RunAnywhere Swift SDK Release Script +# ============================================================================= +# +# Creates a production release from an existing test release. +# +# WORKFLOW: +# 1. Download assets from source test release +# 2. Calculate SHA256 checksums +# 3. Update root Package.swift with version and checksums +# 4. Commit, tag, and push +# 5. Create GitHub release with assets +# +# USAGE: +# ./scripts/release-swift-sdk.sh --version VERSION --source-release TAG [OPTIONS] +# +# OPTIONS: +# --version VERSION Version to release (e.g., 0.17.0) +# --source-release TAG Source release to copy assets from (e.g., v0.16.0-test.53) +# --dry-run Validate only, don't modify anything +# --help Show this help +# +# EXAMPLES: +# # Release v0.17.0 using assets from v0.16.0-test.53 +# ./scripts/release-swift-sdk.sh --version 0.17.0 --source-release v0.16.0-test.53 +# +# # Dry run +# ./scripts/release-swift-sdk.sh --version 0.17.0 --source-release v0.16.0-test.53 --dry-run +# +# ============================================================================= + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SDK_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +REPO_ROOT="$(cd "${SDK_ROOT}/../.." && pwd)" + +GITHUB_REPO="RunanywhereAI/runanywhere-sdks" + +# Arguments +VERSION="" +SOURCE_RELEASE="" +DRY_RUN=false + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +log_info() { echo -e "${GREEN}[OK]${NC} $1"; } +log_warn() { echo -e "${YELLOW}[!]${NC} $1"; } +log_error() { echo -e "${RED}[X]${NC} $1"; } +log_step() { echo -e "${BLUE}==>${NC} $1"; } +log_header() { echo -e "\n${BLUE}=== $1 ===${NC}"; } + +show_help() { + head -35 "$0" | tail -30 + exit 0 +} + +# Parse arguments +while [[ $# -gt 0 ]]; do + case "$1" in + --version) + VERSION="$2" + shift 2 + ;; + --source-release) + SOURCE_RELEASE="$2" + shift 2 + ;; + --dry-run) + DRY_RUN=true + shift + ;; + --help|-h) + show_help + ;; + *) + log_error "Unknown argument: $1" + show_help + ;; + esac +done + +# Validate arguments +if [[ -z "$VERSION" ]]; then + log_error "Missing --version" + show_help +fi + +if [[ -z "$SOURCE_RELEASE" ]]; then + log_error "Missing --source-release" + show_help +fi + +# Validate prerequisites +validate() { + log_header "Validating" + + # Check tools + for tool in gh git curl shasum; do + if ! command -v "$tool" &>/dev/null; then + log_error "Missing tool: $tool" + exit 1 + fi + done + log_info "Required tools available" + + # Check GitHub auth + if ! gh auth status &>/dev/null; then + log_error "GitHub CLI not authenticated. Run: gh auth login" + exit 1 + fi + log_info "GitHub CLI authenticated" + + # Check source release exists + if ! gh release view "$SOURCE_RELEASE" --repo "$GITHUB_REPO" &>/dev/null; then + log_error "Source release not found: $SOURCE_RELEASE" + exit 1 + fi + log_info "Source release found: $SOURCE_RELEASE" + + # Check target release doesn't exist + if gh release view "v$VERSION" --repo "$GITHUB_REPO" &>/dev/null; then + log_error "Target release already exists: v$VERSION" + exit 1 + fi + log_info "Target release available: v$VERSION" +} + +# Download and calculate checksums +calculate_checksums() { + log_header "Calculating Checksums" + + local tmp_dir + tmp_dir=$(mktemp -d) + trap "rm -rf $tmp_dir" EXIT + + cd "$tmp_dir" + + # Download iOS assets from source release + local assets=("RACommons-ios" "RABackendLLAMACPP-ios" "RABackendONNX-ios") + local source_version="${SOURCE_RELEASE#v}" + + for asset_prefix in "${assets[@]}"; do + local asset_name="${asset_prefix}-v${source_version}.zip" + local url="https://github.com/$GITHUB_REPO/releases/download/$SOURCE_RELEASE/$asset_name" + + log_step "Downloading: $asset_name" + if ! curl -sL "$url" -o "$asset_name"; then + log_error "Failed to download: $url" + exit 1 + fi + done + + # Calculate checksums + log_step "Calculating SHA256 checksums..." + + CHECKSUM_RACOMMONS=$(shasum -a 256 "RACommons-ios-v${source_version}.zip" | awk '{print $1}') + CHECKSUM_LLAMACPP=$(shasum -a 256 "RABackendLLAMACPP-ios-v${source_version}.zip" | awk '{print $1}') + CHECKSUM_ONNX=$(shasum -a 256 "RABackendONNX-ios-v${source_version}.zip" | awk '{print $1}') + + log_info "RACommons: $CHECKSUM_RACOMMONS" + log_info "LlamaCPP: $CHECKSUM_LLAMACPP" + log_info "ONNX: $CHECKSUM_ONNX" + + # Store asset paths for later upload + ASSET_RACOMMONS="$tmp_dir/RACommons-ios-v${source_version}.zip" + ASSET_LLAMACPP="$tmp_dir/RABackendLLAMACPP-ios-v${source_version}.zip" + ASSET_ONNX="$tmp_dir/RABackendONNX-ios-v${source_version}.zip" + + # Rename assets for new version + mv "RACommons-ios-v${source_version}.zip" "RACommons-ios-v${VERSION}.zip" + mv "RABackendLLAMACPP-ios-v${source_version}.zip" "RABackendLLAMACPP-ios-v${VERSION}.zip" + mv "RABackendONNX-ios-v${source_version}.zip" "RABackendONNX-ios-v${VERSION}.zip" + + ASSET_RACOMMONS="$tmp_dir/RACommons-ios-v${VERSION}.zip" + ASSET_LLAMACPP="$tmp_dir/RABackendLLAMACPP-ios-v${VERSION}.zip" + ASSET_ONNX="$tmp_dir/RABackendONNX-ios-v${VERSION}.zip" + + cd - >/dev/null +} + +# Update Package.swift +update_package_swift() { + log_header "Updating Package.swift" + + local package_file="$REPO_ROOT/Package.swift" + + if [[ ! -f "$package_file" ]]; then + log_error "Package.swift not found at: $package_file" + exit 1 + fi + + if [[ "$DRY_RUN" == true ]]; then + log_warn "DRY RUN - Would update Package.swift with:" + echo " sdkVersion = \"$VERSION\"" + echo " RACommons checksum = $CHECKSUM_RACOMMONS" + echo " LlamaCPP checksum = $CHECKSUM_LLAMACPP" + echo " ONNX checksum = $CHECKSUM_ONNX" + return + fi + + # Update version + sed -i '' "s/let sdkVersion = \"[^\"]*\"/let sdkVersion = \"$VERSION\"/" "$package_file" + log_info "Updated sdkVersion to $VERSION" + + # Update checksums + sed -i '' "s/checksum: \"CHECKSUM_RACOMMONS\"/checksum: \"$CHECKSUM_RACOMMONS\"/" "$package_file" + sed -i '' "s/checksum: \"CHECKSUM_LLAMACPP\"/checksum: \"$CHECKSUM_LLAMACPP\"/" "$package_file" + sed -i '' "s/checksum: \"CHECKSUM_ONNX\"/checksum: \"$CHECKSUM_ONNX\"/" "$package_file" + + # Also update if they have old checksums (not placeholders) + # Match pattern: checksum: "64 hex characters" + local old_racommons_line + old_racommons_line=$(grep -n "RACommons-ios-v" "$package_file" | head -1 | cut -d: -f1) + if [[ -n "$old_racommons_line" ]]; then + local checksum_line=$((old_racommons_line + 1)) + sed -i '' "${checksum_line}s/checksum: \"[a-f0-9]\{64\}\"/checksum: \"$CHECKSUM_RACOMMONS\"/" "$package_file" + fi + + log_info "Updated checksums in Package.swift" + + # Also update VERSION file + echo "$VERSION" > "$SDK_ROOT/VERSION" + log_info "Updated VERSION file" +} + +# Commit and tag +commit_and_tag() { + log_header "Creating Git Commit and Tag" + + if [[ "$DRY_RUN" == true ]]; then + log_warn "DRY RUN - Would create:" + echo " Commit: chore(swift): release v$VERSION" + echo " Tag: v$VERSION" + return + fi + + cd "$REPO_ROOT" + + # Stage changes + git add Package.swift sdk/runanywhere-swift/VERSION 2>/dev/null || true + + # Commit if there are changes + if ! git diff --staged --quiet; then + git commit -m "chore(swift): release v$VERSION + +- Updated sdkVersion to $VERSION +- Updated binary checksums from $SOURCE_RELEASE" + log_info "Created commit" + else + log_info "No changes to commit" + fi + + # Create tag + git tag -a "v$VERSION" -m "RunAnywhere SDK v$VERSION" + log_info "Created tag: v$VERSION" +} + +# Push and create release +push_and_release() { + log_header "Pushing and Creating Release" + + if [[ "$DRY_RUN" == true ]]; then + log_warn "DRY RUN - Would:" + echo " - Push to origin" + echo " - Create release v$VERSION with iOS assets" + return + fi + + cd "$REPO_ROOT" + + # Push + log_step "Pushing commits and tag..." + git push origin HEAD + git push origin "v$VERSION" + log_info "Pushed to GitHub" + + # Create release + log_step "Creating GitHub release..." + gh release create "v$VERSION" \ + --repo "$GITHUB_REPO" \ + --title "RunAnywhere SDK v$VERSION" \ + --notes "## RunAnywhere SDK v$VERSION + +Privacy-first, on-device AI SDK for iOS, Android, Flutter, and React Native. + +### Swift Installation (SPM) + +\`\`\`swift +dependencies: [ + .package(url: \"https://github.com/$GITHUB_REPO\", from: \"$VERSION\") +] +\`\`\` + +### Features +- LLM: On-device text generation via llama.cpp +- STT: Speech-to-text via Sherpa-ONNX Whisper +- TTS: Text-to-speech via Sherpa-ONNX Piper +- VAD: Voice activity detection +- Privacy: All processing happens on-device + +### iOS Assets +- RACommons-ios-v$VERSION.zip +- RABackendLLAMACPP-ios-v$VERSION.zip +- RABackendONNX-ios-v$VERSION.zip + +--- +Based on: $SOURCE_RELEASE +" \ + "$ASSET_RACOMMONS" \ + "$ASSET_LLAMACPP" \ + "$ASSET_ONNX" + + log_info "Created release: v$VERSION" + log_info "URL: https://github.com/$GITHUB_REPO/releases/tag/v$VERSION" +} + +# Summary +print_summary() { + log_header "Summary" + + echo "" + echo "Version: $VERSION" + echo "Source Release: $SOURCE_RELEASE" + echo "Repository: https://github.com/$GITHUB_REPO" + echo "" + + if [[ "$DRY_RUN" == true ]]; then + log_warn "DRY RUN - No changes made" + echo "" + echo "To execute for real, remove --dry-run flag" + else + log_info "Release complete!" + echo "" + echo "Users can now install with:" + echo "" + echo " .package(url: \"https://github.com/$GITHUB_REPO\", from: \"$VERSION\")" + echo "" + echo "Or in Xcode:" + echo " File > Add Package Dependencies" + echo " URL: https://github.com/$GITHUB_REPO" + echo " Version: $VERSION" + fi +} + +# Main +main() { + log_header "RunAnywhere Swift SDK Release" + echo "" + echo "Version: $VERSION" + echo "Source Release: $SOURCE_RELEASE" + echo "Dry Run: $DRY_RUN" + + validate + calculate_checksums + update_package_swift + commit_and_tag + push_and_release + print_summary +} + +main "$@" diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 000000000..791ada1b7 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,34 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS) + repositories { + google() + mavenCentral() + maven { url = uri("https://jitpack.io") } // Add JitPack for android-vad + mavenLocal() + } +} + + +rootProject.name = "RunAnywhere-Android" + +// Include SDK modules +include(":runanywhere-kotlin") +include(":runanywhere-kotlin:jni") + +// Include example apps as composite builds to keep them self-contained +includeBuild("examples/android/RunAnywhereAI") +includeBuild("examples/intellij-plugin-demo/plugin")